From 719f6996d8a4ce01a4229f75518e31601429744e Mon Sep 17 00:00:00 2001 From: David Goldstein Date: Sun, 18 Oct 2026 08:43:20 -0400 Subject: [PATCH 01/11] L189: double-clicking a skill book reads it The USE tab's double-click sent skill and mastery books (228xxxx, 229xxxx) as USE_ITEM, which the server does not answer with a book result. v83's inventory window sends them as USE_SKILL_BOOK (FUN_00a0a1b2, called from the same chain as pet food), which the hotkey path already did. --- HeavenClient/IO/UITypes/UIItemInventory.cpp | 2 ++ agent_docs/inventory.md | 3 ++- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/HeavenClient/IO/UITypes/UIItemInventory.cpp b/HeavenClient/IO/UITypes/UIItemInventory.cpp index e5d5b5cfa..dd8ef6ca8 100644 --- a/HeavenClient/IO/UITypes/UIItemInventory.cpp +++ b/HeavenClient/IO/UITypes/UIItemInventory.cpp @@ -437,6 +437,8 @@ namespace ms break; else if (pets::is_food(item_id)) Stage::get().get_player().feed_pets(slot, item_id); + else if (item_id / 10000 == 228 || item_id / 10000 == 229) + Stage::get().get_player().request(SkillBookPacket(slot, item_id), ExclRequest::USE_WAIT); else Stage::get().get_player().request(UseItemPacket(slot, item_id), ExclRequest::USE_WAIT); diff --git a/agent_docs/inventory.md b/agent_docs/inventory.md index 7c296d19a..a19f71e61 100644 --- a/agent_docs/inventory.md +++ b/agent_docs/inventory.md @@ -129,7 +129,8 @@ intervals are never shorter than 10 s, so climbing needs no separate check. USE_SKILL_BOOK (`0x52`: tick, slot, item id) reads a skill book (228xxxx) or a mastery book (229xxxx). v83 sends it from the item-use path with the -usual exclusive-request gate and checks nothing itself. The book's +usual exclusive-request gate and checks nothing itself; HeavenClient sends +it from both the USE tab double-click and a hotkey. The book's `info/skill` lists skills; the server takes the one whose job (skill / 10000) is the reader's own job, and refuses the book (kept, `used` 0) when there is none, when the reader's level in it is below From 789e91caa7d5974a8f7e1ab95221918930d9ab67 Mon Sep 17 00:00:00 2001 From: David Goldstein Date: Sun, 18 Oct 2026 08:59:24 -0400 Subject: [PATCH 02/11] L179: megaphones reach the channel or the world USE_CASH_ITEM now uses megaphones (507xxxx), reading the kind from the item id's thousands digit as the v83 client does. A plain megaphone needs a level above 10 and its line (BROADCAST_MSG type 2) goes to every player on the speaker's channel. The super, item and triple megaphones (types 3, 8 and 10, with the zero-based channel and the ear byte) go to the whole world, the speaker included. Every line is at most 60 bytes and is sent as " : ", which the client splits on " : "; the item megaphone carries the item the speaker picked. One exclusive inventory operation uses the megaphone up, and anything refused, Maple TV included, only unlocks the client. The client actor hands the line to the world directory through the new WorldAction::Broadcast, and BroadcastScope gains Channel so the directory can pick the speaker's channel. --- agent_docs/inventory.md | 27 +++ agent_docs/pets.md | 7 +- integration-harness/tests/megaphones.rs | 165 +++++++++++++ net/src/handler.rs | 10 +- net/src/packet/build/world/messaging.rs | 69 +++++- net/src/packet/handle/world/cash_item.rs | 4 +- net/src/packet/handle/world/megaphone.rs | 295 +++++++++++++++++++++++ net/src/packet/handle/world/mod.rs | 1 + runtime/src/actor/client.rs | 8 + runtime/src/actor/directory.rs | 10 +- 10 files changed, 587 insertions(+), 9 deletions(-) create mode 100644 integration-harness/tests/megaphones.rs create mode 100644 net/src/packet/handle/world/megaphone.rs diff --git a/agent_docs/inventory.md b/agent_docs/inventory.md index a19f71e61..c01aa62b4 100644 --- a/agent_docs/inventory.md +++ b/agent_docs/inventory.md @@ -166,6 +166,29 @@ it has no Legendary Spirit window. HeavenClient plays the Enchant sound and the scroll glow, and gives the scroller the v83 chat line. It has no sealed items, so the v83 sealed-item prompts are skipped. +### Megaphones + +A megaphone (507xxxx) is used through USE_CASH_ITEM (`0x4F`: Cash slot, item +id, the megaphone's fields, tick), in `world/megaphone.rs`. The kind is the +item id's thousands digit, as the v83 client reads it: 1 and 8 are plain +megaphones, 2 super, 6 (and 5 with a last digit of 6 or more) item +megaphones, 7 triple; 5 up to 5 is Maple TV, which isn't done. The fields +are the text and, but for the plain megaphone, the ear byte; the item +megaphone adds whether an item is shown and then its inventory kind and +slot, and the triple megaphone starts with its line count (1 to 3). Every +line is at most 60 bytes and goes out as ` : `, which the client +splits on " : ". The plain megaphone needs a level above 10. + +BROADCAST_MSG (`0x44`) carries the line: type 2 to every player on the +speaker's channel (`BroadcastScope::Channel`), and type 3 (super, with the +zero-based channel and ear), 8 (item, then whether an item follows and the +item as the inventory encodes it) and 10 (triple: the first line, the count, +the rest, channel, ear) to the whole world, the speaker included. The client +actor hands the line to the world directory (`WorldAction::Broadcast`), which +knows every client's channel. One exclusive INVENTORY_OPERATION takes the +megaphone first. Anything refused (too long, too low, an item that isn't +there, Maple TV) gets only an empty stat change to unlock the client. + ## NPC shops Clicking an NPC that keeps a shop (`content::shops()`, from the pack's @@ -417,6 +440,10 @@ refusals; `scrolls` (integration) scrolls the starting sword with and without a White Scroll, is refused a helmet scroll, watches the glow from a second player, and checks the upgrade is saved. The client's `scroll_rules` test covers the fit, the White Scroll prompt and the chat lines. +`net`'s megaphone handler covers each kind's line, scope and refusals; +`megaphones` (integration) hears a plain megaphone on the speaker's channel +and the super, item and triple ones on another, then finds the used slot +empty. `net::storage` covers each request and its refusals. `storage` (integration) opens Mr. Lee's storage, stores part of a stack and a whole one for the fee, is refused too many and too many mesos, moves mesos in, sorts, takes items diff --git a/agent_docs/pets.md b/agent_docs/pets.md index 26ebc7748..c3fbe2556 100644 --- a/agent_docs/pets.md +++ b/agent_docs/pets.md @@ -68,9 +68,10 @@ client's side of each. food's list of pets it suits isn't checked; the v83 client doesn't check it either. - **Cash food.** USE_CASH_ITEM (`0x4F` in, `world/cash_item.rs`) is the Cash - slot, the item id, the item's own fields and the tick. Only cash pet food - (`524xxxx`) is used so far; any other item, or one not in that slot, gets - an empty stat change to unlock the client. The first pet out, in list + slot, the item id, the item's own fields and the tick. Cash pet food + (`524xxxx`) and megaphones (`inventory.md`) are used so far; any other + item, or one not in that slot, gets an empty stat change to unlock the + client. The first pet out, in list order, whose id is in the food's `spec/0..n` eats: fullness 100 and `spec/inc` closeness (100), and it always enjoys it. The server answers with 76 `0, index`, then the 174, inventory operation and level-up effect that diff --git a/integration-harness/tests/megaphones.rs b/integration-harness/tests/megaphones.rs new file mode 100644 index 000000000..f737dff0d --- /dev/null +++ b/integration-harness/tests/megaphones.rs @@ -0,0 +1,165 @@ +//! Megaphones: a plain megaphone speaks to the speaker's channel, the super, +//! item and triple ones to every channel. Each is used up. + +use integration_harness::connection::MapleTestConnection; +use integration_harness::database::{grant_stack, set_job}; +use integration_harness::preconditions::load_harness_config_or_fail; +use integration_harness::trace::{read, send, Trace}; +use integration_harness::{login_to_world_session, relogin}; +use net::packet::op::{RecvOpcode, SendOpcode}; +use packet::io::read::PktRead; +use packet::io::write::PktWrite; +use packet::Packet; +use std::io::Cursor; + +const CASH: i16 = 5; +const USE: i16 = 2; +const MEGAPHONE: i32 = 5_071_000; +const SUPER_MEGAPHONE: i32 = 5_072_000; +const ITEM_MEGAPHONE: i32 = 5_076_000; +const TRIPLE_MEGAPHONE: i32 = 5_077_000; +const RED_POTION: i32 = 2_000_000; + +/// Read until a packet with `op`, and return its body. +async fn next(c: &mut MapleTestConnection, t: &mut Trace, op: SendOpcode) -> Vec { + let op = op as i16; + loop { + let p = read(c, t).await; + if p.opcode() == op { + return p.bytes[2..].to_vec(); + } + } +} + +/// USE_CASH_ITEM: the item in Cash `slot`, its fields, then the tick. +fn use_cash_item(slot: i16, item_id: i32, body: impl FnOnce(&mut Packet)) -> Packet { + let mut p = Packet::new_empty(); + p.write_short(RecvOpcode::UseCashItem as i16).unwrap(); + p.write_short(slot).unwrap(); + p.write_int(item_id).unwrap(); + body(&mut p); + p.write_int(0).unwrap(); + p +} + +#[tokio::test] +async fn megaphones_reach_their_channel_or_every_channel() { + let config = load_harness_config_or_fail().await.with_channel(0, 0); + let id = login_to_world_session(&config) + .await + .expect("speaker login failed") + .character_id; + tokio::time::sleep(std::time::Duration::from_millis(500)).await; + set_job(id, 0, 30, 50).await.expect("level 30"); + for (slot, item) in [ + (1, MEGAPHONE), + (2, SUPER_MEGAPHONE), + (3, ITEM_MEGAPHONE), + (4, TRIPLE_MEGAPHONE), + ] { + grant_stack(id, CASH, slot, item, 1) + .await + .expect("grant a megaphone"); + } + grant_stack(id, USE, 1, RED_POTION, 5) + .await + .expect("grant a potion"); + let mut speaker = relogin(&config).await; + let name = speaker.character_name.clone(); + let near_config = load_harness_config_or_fail().await.with_channel(0, 0); + let mut near = login_to_world_session(&near_config).await.unwrap(); + let far_config = load_harness_config_or_fail().await.with_channel(0, 1); + let mut far = login_to_world_session(&far_config).await.unwrap(); + let (s, mut st) = (&mut speaker.connection, Trace::default()); + let (n, mut nt) = (&mut near.connection, Trace::default()); + let (f, mut ft) = (&mut far.connection, Trace::default()); + + // A plain megaphone reaches the speaker's channel, the speaker included. + send( + s, + use_cash_item(1, MEGAPHONE, |p| { + p.write_str_with_length("hello channel").unwrap(); + }), + ) + .await; + let used = next(s, &mut st, SendOpcode::InventoryOperation).await; + assert_eq!(used[..2], [1, 1], "unlocked, one change"); + let line = format!("{name} : hello channel"); + let mut expected = vec![2]; + expected.extend((line.len() as u16).to_le_bytes()); + expected.extend(line.as_bytes()); + assert_eq!(next(s, &mut st, SendOpcode::BroadcastMsg).await, expected); + assert_eq!(next(n, &mut nt, SendOpcode::BroadcastMsg).await, expected); + + // The super megaphone reaches the other channel, which never saw the + // plain one, with the speaker's channel and ear. + send( + s, + use_cash_item(2, SUPER_MEGAPHONE, |p| { + p.write_str_with_length("hello world").unwrap(); + p.write_byte(1).unwrap(); + }), + ) + .await; + let heard = next(f, &mut ft, SendOpcode::BroadcastMsg).await; + let mut r = Cursor::new(&heard); + assert_eq!(r.read_byte().unwrap(), 3, "a super megaphone"); + assert_eq!( + r.read_str_with_length().unwrap(), + format!("{name} : hello world") + ); + assert_eq!((r.read_byte().unwrap(), r.read_byte().unwrap()), (0, 1)); + + // The item megaphone shows the potion. + send( + s, + use_cash_item(3, ITEM_MEGAPHONE, |p| { + p.write_str_with_length("see this").unwrap(); + p.write_byte(0).unwrap(); + p.write_byte(1).unwrap(); + p.write_int(USE.into()).unwrap(); + p.write_int(1).unwrap(); + }), + ) + .await; + let heard = next(f, &mut ft, SendOpcode::BroadcastMsg).await; + let mut r = Cursor::new(&heard); + assert_eq!(r.read_byte().unwrap(), 8, "an item megaphone"); + assert_eq!( + r.read_str_with_length().unwrap(), + format!("{name} : see this") + ); + assert_eq!(r.read_bytes(3).unwrap(), [0, 0, 1], "channel, ear, an item"); + assert_eq!(r.read_byte().unwrap(), 2, "a stackable item"); + assert_eq!(r.read_int().unwrap(), RED_POTION); + + // The triple megaphone names the speaker on every line. + send( + s, + use_cash_item(4, TRIPLE_MEGAPHONE, |p| { + p.write_byte(2).unwrap(); + p.write_str_with_length("one").unwrap(); + p.write_str_with_length("two").unwrap(); + p.write_byte(0).unwrap(); + }), + ) + .await; + let heard = next(f, &mut ft, SendOpcode::BroadcastMsg).await; + let mut r = Cursor::new(&heard); + assert_eq!(r.read_byte().unwrap(), 10, "a triple megaphone"); + assert_eq!(r.read_str_with_length().unwrap(), format!("{name} : one")); + assert_eq!(r.read_byte().unwrap(), 2, "two lines"); + assert_eq!(r.read_str_with_length().unwrap(), format!("{name} : two")); + assert_eq!((r.read_byte().unwrap(), r.read_byte().unwrap()), (0, 0)); + + // Each was used up: the megaphone's slot is empty now. + send( + s, + use_cash_item(1, MEGAPHONE, |p| { + p.write_str_with_length("again").unwrap(); + }), + ) + .await; + let unlocked = next(s, &mut st, SendOpcode::StatChange).await; + assert_eq!(unlocked[0], 1, "an empty stat change unlocks the client"); +} diff --git a/net/src/handler.rs b/net/src/handler.rs index 08e8a125d..0e5d1144d 100644 --- a/net/src/handler.rs +++ b/net/src/handler.rs @@ -374,6 +374,12 @@ pub enum WorldAction { Find { target_name: String, }, + /// Send `packet` to every player `scope` covers, such as a megaphone's + /// line. + Broadcast { + scope: BroadcastScope, + packet: Packet, + }, Messenger(MessengerRequest), ChangeChannel { channel_id: u8, @@ -677,12 +683,14 @@ pub fn world_route(opcode: i16) -> Option<&'static WorldRoute> { } /// Defines who should receive a world-wide broadcast. -#[derive(Debug, Clone)] +#[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum BroadcastScope { /// All players in the world. World, /// All players in the world except the sender. WorldExcludeSelf, + /// All players on this zero-based channel. + Channel(u8), } #[cfg(test)] diff --git a/net/src/packet/build/world/messaging.rs b/net/src/packet/build/world/messaging.rs index e98bb7006..6308ce945 100644 --- a/net/src/packet/build/world/messaging.rs +++ b/net/src/packet/build/world/messaging.rs @@ -1,4 +1,9 @@ -use crate::{error::NetworkError, packet::op::SendOpcode}; +use crate::{ + codec::item::{encode_item, SlotPrefix}, + error::NetworkError, + packet::op::SendOpcode, +}; +use game::Item; use packet::{io::write::PktWrite, Packet}; /// Client → server: send a whisper. @@ -30,10 +35,17 @@ pub enum Broadcast { Notice = 0, /// A dialog. Popup = 1, + /// A megaphone's line, to the sender's channel. + Megaphone = 2, /// Pink chat, as for a mob's Banish. Pink = 5, } +/// BROADCAST_MSG kinds that carry the sender's channel and ear. +const SUPER_MEGAPHONE: u8 = 3; +const ITEM_MEGAPHONE: u8 = 8; +const TRIPLE_MEGAPHONE: u8 = 10; + /// A message from the server, shown as `kind` says. pub fn build_broadcast(kind: Broadcast, message: &str) -> Result { let mut packet = Packet::new_empty(); @@ -43,6 +55,61 @@ pub fn build_broadcast(kind: Broadcast, message: &str) -> Result Result { + let mut packet = Packet::new_empty(); + packet.write_short(SendOpcode::BroadcastMsg as i16)?; + packet.write_byte(SUPER_MEGAPHONE)?; + packet.write_str_with_length(line)?; + packet.write_byte(channel)?; + packet.write_byte(ear as u8)?; + Ok(packet) +} + +/// An item megaphone's line, with the item it shows, if any. +pub fn build_item_megaphone( + line: &str, + channel: u8, + ear: bool, + item: Option<&Item>, +) -> Result { + let mut packet = Packet::new_empty(); + packet.write_short(SendOpcode::BroadcastMsg as i16)?; + packet.write_byte(ITEM_MEGAPHONE)?; + packet.write_str_with_length(line)?; + packet.write_byte(channel)?; + packet.write_byte(ear as u8)?; + packet.write_byte(item.is_some() as u8)?; + if let Some(item) = item { + encode_item(&mut packet, SlotPrefix::None, item)?; + } + Ok(packet) +} + +/// A triple megaphone's one to three lines. v83 reads the count after the +/// first line, then the others. +pub fn build_triple_megaphone( + lines: &[String], + channel: u8, + ear: bool, +) -> Result { + let mut packet = Packet::new_empty(); + packet.write_short(SendOpcode::BroadcastMsg as i16)?; + packet.write_byte(TRIPLE_MEGAPHONE)?; + let (first, rest) = lines.split_first().ok_or(NetworkError::PacketHandlerError( + "a triple megaphone needs a line", + ))?; + packet.write_str_with_length(first)?; + packet.write_byte(lines.len() as u8)?; + for line in rest { + packet.write_str_with_length(line)?; + } + packet.write_byte(channel)?; + packet.write_byte(ear as u8)?; + Ok(packet) +} + /// An incoming whisper. `channel` is the sender's zero-based channel. pub fn build_whisper_receive( sender_name: &str, diff --git a/net/src/packet/handle/world/cash_item.rs b/net/src/packet/handle/world/cash_item.rs index 2624505ad..fedeee77e 100644 --- a/net/src/packet/handle/world/cash_item.rs +++ b/net/src/packet/handle/world/cash_item.rs @@ -1,8 +1,9 @@ //! USE_CASH_ITEM (0x4F, FUN_00a0a63f, report 179): an item used from the //! Cash Inventory. Every request is the item's Cash slot and id, then the //! item's own fields and the tick. The client then waits for an answer that -//! unlocks it. Only cash pet food is used so far. +//! unlocks it. Cash pet food and megaphones are used so far. +use super::megaphone::megaphone; use super::pet::cash_pet_food; use crate::handler::{HandlerResult, WorldContext, WorldHandler, WorldResult}; use crate::packet::build::world::stat::build_stat_update; @@ -24,6 +25,7 @@ impl WorldHandler for UseCashItemHandler { .get(InventoryType::Cash, slot) .is_some_and(|item| item.item_id == item_id); match item_id / 10_000 { + 507 if held => megaphone(ctx, &mut r, slot, item_id), 524 if held => cash_pet_food(ctx, slot, item_id), _ => Ok(HandlerResult::reply(build_stat_update(true, &[])?)), } diff --git a/net/src/packet/handle/world/megaphone.rs b/net/src/packet/handle/world/megaphone.rs new file mode 100644 index 000000000..bebb92590 --- /dev/null +++ b/net/src/packet/handle/world/megaphone.rs @@ -0,0 +1,295 @@ +//! Megaphones (507xxxx), used through USE_CASH_ITEM (report 179, 1.1-1.4). +//! Each line goes out as ` : `, as the v83 client expects +//! (FUN_00a22785 looks for the " : "), and the megaphone is used up. + +use crate::handler::{BroadcastScope, HandlerResult, WorldAction, WorldContext, WorldResult}; +use crate::packet::build::world::inventory::build_inventory_operation; +use crate::packet::build::world::messaging::{ + build_broadcast, build_item_megaphone, build_super_megaphone, build_triple_megaphone, Broadcast, +}; +use crate::packet::build::world::stat::build_stat_update; +use game::InventoryType; +use packet::{io::read::PktRead, Packet}; +use std::io::Cursor; + +/// The longest line the v83 client lets a megaphone send (StringPool 0x111). +pub const MAX_LINE: usize = 60; +/// The plain megaphone is for characters over this level (StringPool 0xea1). +pub const MIN_LEVEL: u8 = 10; + +/// Use the megaphone `item_id` in Cash `slot`; `r` is past the item id. +pub fn megaphone( + ctx: &mut WorldContext, + r: &mut Cursor<&Vec>, + slot: i16, + item_id: i32, +) -> WorldResult { + let Some((scope, packet)) = read_line(ctx, r, item_id)? else { + return Ok(HandlerResult::reply(build_stat_update(true, &[])?)); + }; + let Ok(changes) = ctx + .player + .inventory_mut() + .take(InventoryType::Cash, slot, 1) + else { + return Ok(HandlerResult::reply(build_stat_update(true, &[])?)); + }; + Ok( + HandlerResult::reply(build_inventory_operation(true, &changes)?) + .with(WorldAction::Broadcast { scope, packet }), + ) +} + +/// What the megaphone says and to whom, or `None` when it can't be used +/// (FUN_0048645b's 507 kinds: 1 and 8 plain, 2 super, 5 above 5 and 6 +/// item, 7 triple; 5 up to 5 is Maple TV). +fn read_line( + ctx: &WorldContext, + r: &mut Cursor<&Vec>, + item_id: i32, +) -> Result, crate::error::NetworkError> { + let channel = ctx.channel_id; + let said = |text: &str| format!("{} : {}", ctx.player.name(), text); + let read = match (item_id % 10_000 / 1_000, item_id % 10) { + (1 | 8, _) => { + let text = r.read_str_with_length()?; + if ctx.player.stats.level <= MIN_LEVEL || !fits(&text) { + return Ok(None); + } + ( + BroadcastScope::Channel(channel), + build_broadcast(Broadcast::Megaphone, &said(&text))?, + ) + } + (2, _) => { + let text = r.read_str_with_length()?; + let ear = r.read_byte()? != 0; + if !fits(&text) { + return Ok(None); + } + ( + BroadcastScope::World, + build_super_megaphone(&said(&text), channel, ear)?, + ) + } + (5, 6..) | (6, _) => { + let text = r.read_str_with_length()?; + let ear = r.read_byte()? != 0; + let item = if r.read_byte()? != 0 { + let kind = InventoryType::from_i16(r.read_int()? as i16) + .filter(|&kind| kind != InventoryType::Equipped); + let slot = r.read_int()? as i16; + match kind.and_then(|kind| ctx.player.inventory.get(kind, slot)) { + Some(item) => Some(item), + None => return Ok(None), + } + } else { + None + }; + if !fits(&text) { + return Ok(None); + } + ( + BroadcastScope::World, + build_item_megaphone(&said(&text), channel, ear, item)?, + ) + } + (7, _) => { + let count = r.read_byte()?; + if !(1..=3).contains(&count) { + return Ok(None); + } + let mut lines = Vec::new(); + for _ in 0..count { + lines.push(r.read_str_with_length()?); + } + let ear = r.read_byte()? != 0; + if !lines.iter().all(|line| fits(line)) { + return Ok(None); + } + let lines: Vec = lines.iter().map(|line| said(line)).collect(); + ( + BroadcastScope::World, + build_triple_megaphone(&lines, channel, ear)?, + ) + } + _ => return Ok(None), + }; + Ok(Some(read)) +} + +/// A line the client would have sent: not empty and at most 60 long. +fn fits(text: &str) -> bool { + !text.is_empty() && text.len() <= MAX_LINE +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::codec::testing::Expect; + use crate::handler::WorldHandler; + use crate::packet::handle::world::UseCashItemHandler; + use crate::packet::op::RecvOpcode; + use game::{Item, Player}; + use packet::io::write::PktWrite; + + /// "Tester", level `level`, with `item_id` in Cash slot 2 and a red + /// potion in USE slot 4. + fn speaker(level: u8, item_id: i32) -> Player { + let mut player = crate::codec::stats::sample_player(); + player.stats.level = level; + player + .inventory + .insert(InventoryType::Cash, 2, Item::stack(item_id, 3)); + player + .inventory + .insert(InventoryType::Use, 4, Item::stack(2000000, 7)); + player + } + + fn use_item(player: &mut Player, item_id: i32, body: impl Fn(&mut Packet)) -> Vec { + let mut p = Packet::new_empty(); + p.write_short(RecvOpcode::UseCashItem as i16).unwrap(); + p.write_short(2).unwrap(); + p.write_int(item_id).unwrap(); + body(&mut p); + p.write_int(1234).unwrap(); + let mut ctx = WorldContext { + client_id: 1, + channel_id: 2, + player, + }; + UseCashItemHandler.handle(&mut p, &mut ctx).unwrap().actions + } + + /// The line sent to `scope`, after the reply that used the megaphone up. + fn broadcast(actions: &[WorldAction], expected: BroadcastScope) -> &Packet { + let [WorldAction::Reply(used), WorldAction::Broadcast { scope, packet }] = actions else { + panic!("expected a reply and a broadcast: {actions:?}"); + }; + assert_eq!(used.bytes[..2], [0x1D, 0], "an inventory operation"); + assert_eq!(*scope, expected); + packet + } + + fn refused(actions: &[WorldAction]) { + let [WorldAction::Reply(packet)] = actions else { + panic!("expected a lone reply: {actions:?}"); + }; + assert_eq!(packet.bytes[..2], [0x1F, 0], "a stat change to unlock"); + } + + fn left(player: &Player) -> i16 { + player + .inventory + .get(InventoryType::Cash, 2) + .map_or(0, |item| item.quantity) + } + + #[test] + fn a_megaphone_speaks_to_the_channel() { + let mut player = speaker(11, 5071000); + let actions = use_item(&mut player, 5071000, |p| { + p.write_str_with_length("hi all").unwrap(); + }); + Expect::new(&broadcast(&actions, BroadcastScope::Channel(2)).bytes) + .short(0x44) + .byte(2) + .string("Tester : hi all") + .end(); + assert_eq!(left(&player), 2); + } + + #[test] + fn a_megaphone_needs_level_11() { + let mut player = speaker(10, 5071000); + refused(&use_item(&mut player, 5071000, |p| { + p.write_str_with_length("hi").unwrap(); + })); + assert_eq!(left(&player), 3); + } + + #[test] + fn a_super_megaphone_speaks_to_the_world() { + let mut player = speaker(1, 5072000); + let actions = use_item(&mut player, 5072000, |p| { + p.write_str_with_length("buying").unwrap(); + p.write_byte(1).unwrap(); + }); + Expect::new(&broadcast(&actions, BroadcastScope::World).bytes) + .short(0x44) + .byte(3) + .string("Tester : buying") + .byte(2) + .byte(1) + .end(); + } + + #[test] + fn a_line_longer_than_60_is_refused() { + let mut player = speaker(30, 5072000); + refused(&use_item(&mut player, 5072000, |p| { + p.write_str_with_length(&"a".repeat(61)).unwrap(); + p.write_byte(0).unwrap(); + })); + assert_eq!(left(&player), 3); + } + + #[test] + fn an_item_megaphone_shows_the_item() { + let mut player = speaker(1, 5076000); + let actions = use_item(&mut player, 5076000, |p| { + p.write_str_with_length("look").unwrap(); + p.write_byte(0).unwrap(); + p.write_byte(1).unwrap(); + p.write_int(2).unwrap(); + p.write_int(4).unwrap(); + }); + let mut expect = Expect::new(&broadcast(&actions, BroadcastScope::World).bytes); + expect + .short(0x44) + .byte(8) + .string("Tester : look") + .byte(2) + .byte(0); + expect.byte(1).byte(2).int(2000000); + } + + #[test] + fn an_item_megaphone_without_the_item_is_refused() { + let mut player = speaker(1, 5076000); + refused(&use_item(&mut player, 5076000, |p| { + p.write_str_with_length("look").unwrap(); + p.write_byte(0).unwrap(); + p.write_byte(1).unwrap(); + p.write_int(2).unwrap(); + p.write_int(5).unwrap(); + })); + } + + #[test] + fn a_triple_megaphone_names_every_line() { + let mut player = speaker(1, 5077000); + let actions = use_item(&mut player, 5077000, |p| { + p.write_byte(2).unwrap(); + p.write_str_with_length("one").unwrap(); + p.write_str_with_length("two").unwrap(); + p.write_byte(1).unwrap(); + }); + Expect::new(&broadcast(&actions, BroadcastScope::World).bytes) + .short(0x44) + .byte(10) + .string("Tester : one") + .byte(2) + .string("Tester : two") + .byte(2) + .byte(1) + .end(); + } + + #[test] + fn maple_tv_is_not_a_megaphone() { + let mut player = speaker(30, 5075000); + refused(&use_item(&mut player, 5075000, |_| {})); + } +} diff --git a/net/src/packet/handle/world/mod.rs b/net/src/packet/handle/world/mod.rs index dca6d6875..226e38147 100644 --- a/net/src/packet/handle/world/mod.rs +++ b/net/src/packet/handle/world/mod.rs @@ -16,6 +16,7 @@ mod item_move; mod keybinds; mod logged_in; mod map_transfer; +mod megaphone; mod meso_drop; mod messenger; mod monster_book_cover; diff --git a/runtime/src/actor/client.rs b/runtime/src/actor/client.rs index 535d9cafc..8fad52acb 100644 --- a/runtime/src/actor/client.rs +++ b/runtime/src/actor/client.rs @@ -426,6 +426,14 @@ impl ClientActor { }) .await? } + WorldAction::Broadcast { scope, packet } => { + self.send_to_directory(DirectoryMessage::Broadcast { + from: self.client_id, + scope, + packet, + }) + .await? + } WorldAction::Messenger(request) => { self.send_to_directory(DirectoryMessage::Messenger { from: self.client_id, diff --git a/runtime/src/actor/directory.rs b/runtime/src/actor/directory.rs index b24e8f3c3..b52b14f8b 100644 --- a/runtime/src/actor/directory.rs +++ b/runtime/src/actor/directory.rs @@ -345,10 +345,14 @@ impl WorldDirectory { fn broadcast(&self, from: ClientId, scope: BroadcastScope, packet: Packet) { for (&client_id, entry) in &self.clients { - if matches!(scope, BroadcastScope::WorldExcludeSelf) && client_id == from { - continue; + let reached = match scope { + BroadcastScope::World => true, + BroadcastScope::WorldExcludeSelf => client_id != from, + BroadcastScope::Channel(channel_id) => entry.channel_id == channel_id, + }; + if reached { + entry.handle.send_packet(packet.clone()); } - entry.handle.send_packet(packet.clone()); } } From 1589092841f9e64143d32f91791b27a970373bd0 Mon Sep 17 00:00:00 2001 From: David Goldstein Date: Sun, 18 Oct 2026 09:05:57 -0400 Subject: [PATCH 03/11] L159: rename a pet with the pet name tag USE_CASH_ITEM now handles the pet name tag (5170000). It carries the new name after the item id, and renames the first pet out, as the v83 client targets it. The server takes the tag and re-sends the pet's item in one exclusive inventory operation, then sends 172 (cid, index, name, name tag style) to the owner and the map. The owner needs the 172 as well: the client hears pet commands by the pet's name, and 172 is what changes it. Without a pet out, or with a name the client would not have sent (outside 4 to 12 bytes, padded, or with control characters), the server only unlocks the client with an empty stat change. The rename goes through the pets' change list as PetChange::Renamed, so the client actor sends 172 alongside the other pet packets. The harness pets test renames the Baby Tiger and checks that the owner and a watcher see the name, and that it is kept after a relog. --- agent_docs/pets.md | 40 ++++++++--- game/src/pet.rs | 4 +- integration-harness/tests/pets.rs | 37 ++++++++-- net/src/packet/build/world/pet.rs | 25 +++++++ net/src/packet/handle/world/cash_item.rs | 6 +- net/src/packet/handle/world/pet.rs | 90 ++++++++++++++++++++++++ net/src/packet/op/send.rs | 1 + runtime/src/actor/client/pets.rs | 16 +++-- 8 files changed, 196 insertions(+), 23 deletions(-) diff --git a/agent_docs/pets.md b/agent_docs/pets.md index c3fbe2556..c2642e0fc 100644 --- a/agent_docs/pets.md +++ b/agent_docs/pets.md @@ -4,9 +4,9 @@ A pet is a cash item (`500xxxx`) bought in the Cash Shop. Taken out to the Cash tab, it can be summoned to follow its owner, and everyone on the map sees it. Up to three can be out with Follow the Lead, one without. It answers commands said in chat and grows closer when it obeys. It grows hungry, eats pet -food and cash food, and goes home when it starves. Name tags, pet equipment, -looting and auto potions are not done yet; `v83ref` report 159 has the v83 -client's side of each. +food and cash food, goes home when it starves, and can be renamed with a name +tag. Pet equipment, looting and auto potions are not done yet; `v83ref` report +159 has the v83 client's side of each. ## Server @@ -69,14 +69,22 @@ client's side of each. it either. - **Cash food.** USE_CASH_ITEM (`0x4F` in, `world/cash_item.rs`) is the Cash slot, the item id, the item's own fields and the tick. Cash pet food - (`524xxxx`) and megaphones (`inventory.md`) are used so far; any other - item, or one not in that slot, gets an empty stat change to unlock the - client. The first pet out, in list - order, whose id is in the food's `spec/0..n` eats: fullness 100 and `spec/inc` - closeness (100), and it always enjoys it. The server answers with 76 + (`524xxxx`), the pet name tag and megaphones (`inventory.md`) are used so + far; any other item, or one not in that slot, gets an empty stat change to + unlock the client. The first pet out, in list order, whose id is in the + food's `spec/0..n` eats: fullness 100 and `spec/inc` closeness (100), and + it always enjoys it. The server answers with 76 `0, index`, then the 174, inventory operation and level-up effect that PET_FOOD sends. When no pet out suits the food it sends 76 `1` and an empty stat change. These are Cosmic's rules. +- **Name tag.** The pet name tag (5170000) carries the new name and renames + the first pet out. The name must be 4 to 12 bytes with no control + characters or spaces at either end, as the client sends it. The server + answers with an exclusive inventory operation that takes the tag and + re-sends the pet's item, then 172 `cid, index, name, 0` to the owner and the + map; the owner needs it too, since the client hears commands by the pet's + name. Without a pet out, or with a name the client wouldn't send, it sends + only an empty stat change. The trailing 0 is the name tag style, as in 168. - **Hunger.** Every 3 minutes (`client/pets.rs`) each pet out loses its `info/hungry` fullness (1 if unset; the Baby Tiger loses 3) and its item is re-sent. One left at 5 or less is set to 15 and goes home with 168 reason @@ -143,6 +151,16 @@ client's side of each. pet." instead. 76 plays the eating pet's eat sound, or shows "Your pet cannot consume this food. Please check again." The inventory operation after it unlocks the client. +- **Name tag.** Double-clicking a name tag in the Cash tab renames the first + pet out, following v83; with no pet out it does nothing. A pet with a name + of its own first asks "The pet already has a name. Will you change it?". + The name prompt takes up to 12 bytes; `pets::check_name` trims it, asks + again under 4 ("Please enter at least 4 characters."), refuses control + characters ("Cursing is not allowed."), stars out curse words, and refuses + the kind's name in any case or the pet's own ("That name is not + available."). "Do you want to name your pet 'X'?" then sends USE_CASH_ITEM + under the WAIT lock and plays `UseShopItem`. 172 sets the pet's name and + label; the packet's name tag style is ignored, so the label stays plain. - **Closeness.** After every inventory operation, a pet out whose item now shows different closeness gets the yellow status line "The closeness increased (+N)" or "The closeness decreased (-N)". @@ -156,7 +174,7 @@ client's side of each. - `game::pet` unit tests cover the list, feeding, cash food, closeness lost and starving; `net` tests cover the codecs, which pet eats (cash food - included), and hunger. + included), hunger, and the name tag. - `integration-harness/tests/pets.rs` buys the Baby Tiger Package, takes the pet out, summons it, moves it, sends it back, and checks that it is still out after a relog and that a watcher sees it. It also commands the pet by @@ -164,8 +182,10 @@ client's side of each. for the owner and the watcher, and the relayed PET_CHAT. It then feeds the pet and checks the answer, the food taken and the pet's new fullness and closeness, then gives it a cash food that suits it and one that doesn't. + It renames the pet with a name tag, checks the 172 for the owner and the + watcher, and that the watcher sees the new name after the relog. - `HeavenClient/tests/pets.cpp` covers the closing-up list, the summon rules, Follow the Lead's skill ids, the stance mapping, the SPAWN_PLAYER list, splitting command words, hearing a command, the action mapping, the idle timer, which pet eats, the food answer for a level, the closeness - lines and the food item ranges. + lines, the food item ranges and the name checks. diff --git a/game/src/pet.rs b/game/src/pet.rs index abe26e9d2..f0e031ef9 100644 --- a/game/src/pet.rs +++ b/game/src/pet.rs @@ -155,13 +155,15 @@ pub struct FieldPet { pub foothold: i16, } -/// One change to a player's pet list, at its position in the list. +/// One change to a player's pets out, at its position in the list. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum PetChange { /// The pet now at `index`; those after it moved up one. Summoned { index: u8, serial: i64 }, /// The pet that was at `index`; those after it moved down one. Dismissed { index: u8 }, + /// The pet at `index` has a new name. + Renamed { index: u8 }, } /// Summon the pet `serial` into `pets`, the summoned serials in list order, diff --git a/integration-harness/tests/pets.rs b/integration-harness/tests/pets.rs index 1831d5c1d..84fb0101e 100644 --- a/integration-harness/tests/pets.rs +++ b/integration-harness/tests/pets.rs @@ -1,8 +1,8 @@ //! Pets: a Baby Tiger bought in the Cash Shop comes out of the Cash //! Inventory named after its kind. Summoned, it follows its owner and the //! map sees it come, move, obey, speak and go back; it eats food and cash -//! food; it is out again after a relog, and a player arriving later sees it -//! beside its owner. +//! food and is renamed; it is out again after a relog, and a player +//! arriving later sees it beside its owner. use integration_harness::connection::MapleTestConnection; use integration_harness::database::{grant_stack, set_nx_credit, set_pet_fullness}; @@ -29,6 +29,7 @@ const PET_FOOD: i32 = 2_120_000; /// Cash food for the Baby Tiger alone, and food for monkeys. const GOLDEN_DRUMSTICK: i32 = 5_240_027; const MONKEY_BANANA: i32 = 5_240_000; +const NAME_TAG: i32 = 5_170_000; /// Read until a packet with `op`, and return its body. async fn next(c: &mut MapleTestConnection, t: &mut Trace, op: SendOpcode) -> Vec { @@ -97,6 +98,17 @@ fn cash_food(slot: i16, item_id: i32) -> Packet { p } +/// USE_CASH_ITEM: the name tag in Cash `slot` names the first pet out. +fn name_tag(slot: i16, name: &str) -> Packet { + let mut p = Packet::new_empty(); + p.write_short(RecvOpcode::UseCashItem as i16).unwrap(); + p.write_short(slot).unwrap(); + p.write_int(NAME_TAG).unwrap(); + p.write_str_with_length(name).unwrap(); + p.write_int(0).unwrap(); // time + p +} + /// PET_CHAT: the owner's client showed pet `serial` saying `text`. fn pet_chat(serial: i64, action: u8, text: &str) -> Packet { let mut p = Packet::new_empty(); @@ -158,10 +170,10 @@ async fn a_pet_comes_out_follows_its_owner_and_stays_out() { grant_stack(owner_id, USE.into(), 1, PET_FOOD, 2) .await .expect("grant pet food"); - for (slot, food) in [(2, GOLDEN_DRUMSTICK), (3, MONKEY_BANANA)] { - grant_stack(owner_id, CASH.into(), slot, food, 2) + for (slot, item) in [(2, GOLDEN_DRUMSTICK), (3, MONKEY_BANANA), (4, NAME_TAG)] { + grant_stack(owner_id, CASH.into(), slot, item, 2) .await - .expect("grant cash pet food"); + .expect("grant cash pet food and a name tag"); } set_pet_fullness(cash_id, 60).await.expect("a hungry tiger"); let mut owner = relogin(&config).await; @@ -290,6 +302,19 @@ async fn a_pet_comes_out_follows_its_owner_and_stays_out() { assert_eq!(ate, [1], "no pet out eats it"); next(o, &mut ot, SendOpcode::StatChange).await; + // A name tag renames it: one update takes the tag and re-sends the pet, + // then the owner and the watcher learn its name. + send(o, name_tag(4, "Tigger")).await; + let update = next(o, &mut ot, SendOpcode::InventoryOperation).await; + assert_eq!(update[..2], [1, 3], "a reply; three changes"); + let mut renamed = owner_id.to_le_bytes().to_vec(); + renamed.extend([0, 6, 0]); // pet 0, then the name's length + renamed.extend(b"Tigger"); + renamed.push(0); // no name tag + assert_eq!(next(o, &mut ot, SendOpcode::PetNameChange).await, renamed); + assert_eq!(next(w, &mut wt, SendOpcode::PetNameChange).await, renamed); + let summoned = (owner_id, 0, BABY_TIGER, "Tigger".to_string(), cash_id); + // Summoned again, it goes back. send(o, spawn_pet(1)).await; until(o, &mut ot, |t| !t.pets_gone.is_empty()).await; @@ -318,5 +343,5 @@ async fn a_pet_comes_out_follows_its_owner_and_stays_out() { .iter() .find(|p| p.character_id == owner_id) .unwrap(); - assert_eq!(seen.pets, [(BABY_TIGER, "Baby Tiger".to_string(), cash_id)]); + assert_eq!(seen.pets, [(BABY_TIGER, "Tigger".to_string(), cash_id)]); } diff --git a/net/src/packet/build/world/pet.rs b/net/src/packet/build/world/pet.rs index e39209198..1a8559348 100644 --- a/net/src/packet/build/world/pet.rs +++ b/net/src/packet/build/world/pet.rs @@ -96,6 +96,19 @@ pub fn build_pet_chat( Ok(p) } +/// PET_NAMECHANGE (172, FUN_00704801): the pet at `index` is now called +/// `name`. Its owner's client needs it too, as pet commands may start with +/// the name. The name tag style byte matches the spawn's (none). +pub fn build_pet_name(owner_id: i32, index: u8, name: &str) -> Result { + let mut p = Packet::new_empty(); + p.write_short(SendOpcode::PetNameChange as i16)?; + p.write_int(owner_id)?; + p.write_byte(index)?; + p.write_str_with_length(name)?; + p.write_byte(0)?; // name tag + Ok(p) +} + /// PET_COMMAND (174, FUN_007048ab) mode 0: whether the pet at `index` /// obeyed `command`. Only its owner's client reacts: it picks the action /// and line and sends them back as PET_CHAT for the others. A pet `named` @@ -286,4 +299,16 @@ mod tests { expect.byte(4).byte(0).byte(2); expect.end(); } + + #[test] + fn a_pet_is_renamed() { + let packet = build_pet_name(9, 0, "Tigger").unwrap(); + let mut expect = Expect::new(&packet.bytes); + expect + .short(SendOpcode::PetNameChange as i16) + .int(9) + .byte(0); + expect.string("Tigger").byte(0); + expect.end(); + } } diff --git a/net/src/packet/handle/world/cash_item.rs b/net/src/packet/handle/world/cash_item.rs index fedeee77e..5b148f071 100644 --- a/net/src/packet/handle/world/cash_item.rs +++ b/net/src/packet/handle/world/cash_item.rs @@ -1,10 +1,11 @@ //! USE_CASH_ITEM (0x4F, FUN_00a0a63f, report 179): an item used from the //! Cash Inventory. Every request is the item's Cash slot and id, then the //! item's own fields and the tick. The client then waits for an answer that -//! unlocks it. Cash pet food and megaphones are used so far. +//! unlocks it. Cash pet food, the pet name tag and megaphones are used so +//! far. use super::megaphone::megaphone; -use super::pet::cash_pet_food; +use super::pet::{cash_pet_food, pet_name_tag}; use crate::handler::{HandlerResult, WorldContext, WorldHandler, WorldResult}; use crate::packet::build::world::stat::build_stat_update; use game::InventoryType; @@ -26,6 +27,7 @@ impl WorldHandler for UseCashItemHandler { .is_some_and(|item| item.item_id == item_id); match item_id / 10_000 { 507 if held => megaphone(ctx, &mut r, slot, item_id), + 517 if held && item_id % 10_000 == 0 => pet_name_tag(ctx, &mut r, slot), 524 if held => cash_pet_food(ctx, slot, item_id), _ => Ok(HandlerResult::reply(build_stat_update(true, &[])?)), } diff --git a/net/src/packet/handle/world/pet.rs b/net/src/packet/handle/world/pet.rs index 078d74e3a..a60cb3bcb 100644 --- a/net/src/packet/handle/world/pet.rs +++ b/net/src/packet/handle/world/pet.rs @@ -310,6 +310,41 @@ fn feed_cash_pet(player: &mut Player, slot: i16, spec: &ConsumeSpec) -> Option>, slot: i16) -> WorldResult { + let name = r.read_str_with_length()?; + let Some(changes) = rename_pet(ctx.player, slot, &name) else { + return Ok(HandlerResult::reply(build_stat_update(true, &[])?)); + }; + Ok( + HandlerResult::reply(build_inventory_operation(true, &changes)?).with( + WorldAction::PetsChanged(vec![PetChange::Renamed { index: 0 }]), + ), + ) +} + +/// Call the first pet out `name`, using up the tag in Cash `slot`. `None` +/// if no pet is out or the name isn't one the client sends: 4 to 12 bytes, +/// trimmed, without control characters. +fn rename_pet(player: &mut Player, slot: i16, name: &str) -> Option> { + let fits = (4..=12).contains(&name.len()) && name.trim() == name; + if !fits || name.chars().any(char::is_control) { + return None; + } + let (pet_slot, mut item) = pet_item(player, *player.pets.first()?)?; + item.pet.as_mut()?.name = name.to_owned(); + let mut changes = player + .inventory_mut() + .take(InventoryType::Cash, slot, 1) + .ok()?; + changes.extend(put_back(player, pet_slot, item)); + Some(changes) +} + /// What growing hungry did to a player's pets. #[derive(Debug, Default)] pub struct Hungry { @@ -377,7 +412,9 @@ impl WorldHandler for PetChatHandler { #[cfg(test)] mod tests { use super::*; + use crate::packet::handle::world::UseCashItemHandler; use game::{Item, PetData}; + use packet::io::write::PktWrite; const TIGER: i32 = 5000066; @@ -559,6 +596,59 @@ mod tests { assert_eq!(food.quantity, 2); } + const NAME_TAG: i32 = 5170000; + + fn name(player: &Player, slot: i16) -> &str { + let item = player.inventory.get(InventoryType::Cash, slot).unwrap(); + &item.pet.as_ref().unwrap().name + } + + #[test] + fn a_name_tag_renames_the_first_pet_out() { + let mut player = two_pets(50, 50); + player + .inventory + .insert(InventoryType::Cash, 5, Item::stack(NAME_TAG, 1)); + let mut p = Packet::new_empty(); + p.write_short(0x4F).unwrap(); + p.write_short(5).unwrap(); + p.write_int(NAME_TAG).unwrap(); + p.write_str_with_length("Tigger").unwrap(); + p.write_int(1234).unwrap(); + let mut ctx = WorldContext { + client_id: 1, + channel_id: 0, + player: &mut player, + }; + let done = UseCashItemHandler.handle(&mut p, &mut ctx).unwrap(); + let [WorldAction::Reply(update), WorldAction::PetsChanged(renamed)] = &done.actions[..] + else { + panic!("expected an update and a rename: {:?}", done.actions); + }; + assert_eq!(update.bytes[..4], [0x1D, 0, 1, 3], "unlocked, 3 changes"); + assert_eq!(renamed[..], [PetChange::Renamed { index: 0 }]); + assert_eq!( + (name(&player, 3), name(&player, 4)), + ("Tigger", "Brown Kitty") + ); + assert!(player.inventory.get(InventoryType::Cash, 5).is_none()); + } + + #[test] + fn a_name_tag_needs_a_pet_out_and_a_name_the_client_sends() { + let mut player = owner(); + player + .inventory + .insert(InventoryType::Cash, 5, Item::stack(NAME_TAG, 1)); + for refused in ["Tig", "ThirteenBytes", " Tigger", "Tig\tger"] { + assert!(rename_pet(&mut player, 5, refused).is_none(), "{refused}"); + } + player.pets.clear(); + assert!(rename_pet(&mut player, 5, "Tigger").is_none()); + assert_eq!(name(&player, 3), "Baby Tiger"); + assert!(player.inventory.get(InventoryType::Cash, 5).is_some()); + } + #[test] fn starving_pets_go_home_and_the_rest_close_up() { let mut player = two_pets(8, 30); diff --git a/net/src/packet/op/send.rs b/net/src/packet/op/send.rs index 07cb6944e..81ea3bf68 100644 --- a/net/src/packet/op/send.rs +++ b/net/src/packet/op/send.rs @@ -55,6 +55,7 @@ pub enum SendOpcode { SpawnPet = 0xA8, MovePet = 0xAA, PetChat = 0xAB, + PetNameChange = 0xAC, PetExceptionList = 0xAD, PetCommand = 0xAE, SpawnSummon = 0xAF, diff --git a/runtime/src/actor/client/pets.rs b/runtime/src/actor/client/pets.rs index 54f4c160b..721ddd43e 100644 --- a/runtime/src/actor/client/pets.rs +++ b/runtime/src/actor/client/pets.rs @@ -8,7 +8,7 @@ use game::pet::PetChange; use game::FieldAvatar; use net::packet::build::world::inventory::build_inventory_operation; use net::packet::build::world::pet::{ - build_pet_exception_list, build_remove_pet, build_spawn_pet, PetGone, + build_pet_exception_list, build_pet_name, build_remove_pet, build_spawn_pet, PetGone, }; use net::packet::build::world::stat::{build_stat_update, StatUpdate}; use net::packet::handle::world::pets_grow_hungry; @@ -20,9 +20,9 @@ use std::time::Duration; pub(super) const PET_HUNGER_INTERVAL: Duration = Duration::from_secs(180); impl ClientActor { - /// The player summoned or sent back pets: show everyone on the map, and - /// keep the field avatar for those who arrive later. The owner reads - /// why those sent back went. + /// The player summoned, sent back or renamed pets: show everyone on the + /// map, and keep the field avatar for those who arrive later. The owner + /// reads why those sent back went. pub(super) async fn pets_changed( &mut self, changes: Vec, @@ -49,6 +49,14 @@ impl ClientActor { own.push(packet.clone()); announce.push(packet); } + PetChange::Renamed { index } => { + let Some(pet) = avatar.pets.get(usize::from(index)) else { + continue; + }; + let packet = build_pet_name(avatar.id, index, &pet.name)?; + own.push(packet.clone()); + announce.push(packet); + } } } self.send_all(&mut own).await?; From 3d8d133bdc061c5fc3adb32fbf8727e124c0893a Mon Sep 17 00:00:00 2001 From: David Goldstein Date: Sun, 18 Oct 2026 09:05:57 -0400 Subject: [PATCH 04/11] L159: name a pet with the name tag in HeavenClient Double-clicking the pet name tag in the Cash tab now walks through the v83 dialogs. A pet that already has a name of its own asks whether to change it, then the name prompt takes up to 12 bytes. pets::check_name follows the v83 checks: the name is trimmed, under 4 bytes asks again, control characters are refused as cursing, curse words are starred out, and the kind's own name (in any case) or the pet's current name is not available. A last Yes/No sends USE_CASH_ITEM with the name under the WAIT lock and plays UseShopItem. With no pet out the tag does nothing, as in v83. 172 sets the pet's name and its label. The name tag style byte is read and ignored, so the label stays plain. --- HeavenClient/Character/Inventory/Pet.cpp | 10 +++ HeavenClient/Character/Inventory/Pet.h | 2 + HeavenClient/Character/Look/PetLook.cpp | 6 ++ HeavenClient/Character/Look/PetLook.h | 3 + HeavenClient/Character/Pets.h | 60 ++++++++++++++++ HeavenClient/IO/UITypes/UIItemInventory.cpp | 71 +++++++++++++++++++ .../Net/Handlers/MapObjectHandlers.cpp | 15 ++++ HeavenClient/Net/Handlers/MapObjectHandlers.h | 9 +++ HeavenClient/Net/PacketSwitch.cpp | 2 + HeavenClient/Net/Packets/InventoryPackets.h | 10 +++ HeavenClient/tests/pets.cpp | 37 ++++++++++ 11 files changed, 225 insertions(+) diff --git a/HeavenClient/Character/Inventory/Pet.cpp b/HeavenClient/Character/Inventory/Pet.cpp index 1ed1fff89..d04dfcf68 100644 --- a/HeavenClient/Character/Inventory/Pet.cpp +++ b/HeavenClient/Character/Inventory/Pet.cpp @@ -24,6 +24,16 @@ namespace ms closeness(closeness), fullness(fullness) {} + int32_t Pet::get_item_id() const + { + return item_id; + } + + const std::string &Pet::get_name() const + { + return petname; + } + uint8_t Pet::get_level() const { return petlevel; diff --git a/HeavenClient/Character/Inventory/Pet.h b/HeavenClient/Character/Inventory/Pet.h index ccc83475d..b0a3a410d 100644 --- a/HeavenClient/Character/Inventory/Pet.h +++ b/HeavenClient/Character/Inventory/Pet.h @@ -28,6 +28,8 @@ namespace ms Pet(int32_t item_id, int64_t expiration, const std::string &name, uint8_t level, uint16_t closeness, uint8_t fullness); + int32_t get_item_id() const; + const std::string &get_name() const; uint8_t get_level() const; uint16_t get_closeness() const; uint8_t get_fullness() const; diff --git a/HeavenClient/Character/Look/PetLook.cpp b/HeavenClient/Character/Look/PetLook.cpp index 1b9385923..5be66acb1 100644 --- a/HeavenClient/Character/Look/PetLook.cpp +++ b/HeavenClient/Character/Look/PetLook.cpp @@ -450,6 +450,12 @@ namespace ms return serial; } + void PetLook::set_name(const std::string &n) + { + name = n; + namelabel.change_text(name); + } + bool PetLook::is_local() const { return local; diff --git a/HeavenClient/Character/Look/PetLook.h b/HeavenClient/Character/Look/PetLook.h index e280c5ce5..901340924 100644 --- a/HeavenClient/Character/Look/PetLook.h +++ b/HeavenClient/Character/Look/PetLook.h @@ -114,6 +114,9 @@ namespace ms int64_t get_serial() const; + // PET_NAMECHANGE (172): the pet answers to its new name. + void set_name(const std::string &name); + bool is_local() const; Stance get_stance() const; diff --git a/HeavenClient/Character/Pets.h b/HeavenClient/Character/Pets.h index 98030e92f..c78404708 100644 --- a/HeavenClient/Character/Pets.h +++ b/HeavenClient/Character/Pets.h @@ -1,5 +1,6 @@ #pragma once +#include #include #include #include @@ -290,6 +291,65 @@ namespace ms return itemid / 10000 == 524; } + // The Pet Name Tag (5170000) names the first pet out. + inline bool is_name_tag(int32_t itemid) + { + return itemid / 10000 == 517 && itemid % 10000 == 0; + } + + // A pet's name is 4 to 12 bytes (FUN_009ac7cb). + constexpr size_t MIN_NAME = 4; + constexpr size_t MAX_NAME = 12; + + enum class Naming + { + OK, + // Under 4 bytes: the prompt asks again (FUN_009ab50a). + SHORT, + // A control character other than a line break. + CURSING, + // The kind's own name, in any case, or the pet's. + TAKEN + }; + + // Whether two names match, ignoring case (FUN_005392d4). + inline bool same_name(const std::string &a, const std::string &b) + { + if (a.size() != b.size()) + return false; + + for (size_t i = 0; i < a.size(); i++) + if (std::toupper(static_cast(a[i])) != std::toupper(static_cast(b[i]))) + return false; + + return true; + } + + // Check the name the player typed for a pet of kind `species` now + // called `current` (FUN_00a0a63f type 0x11). `typed` becomes the + // name to send: trimmed, with curse words starred out by `censor` + // and tabs as spaces. + template + Naming check_name(std::string &typed, const std::string &species, const std::string ¤t, Censor censor) + { + typed = trim(typed); + + if (typed.size() < MIN_NAME) + return Naming::SHORT; + + for (char c : typed) + if (static_cast(c) < 0x20 && c != '\r' && c != '\n') + return Naming::CURSING; + + typed = censor(typed); + std::replace(typed.begin(), typed.end(), '\t', ' '); + + if (same_name(typed, species) || typed == current) + return Naming::TAKEN; + + return Naming::OK; + } + // The pet out that eats: the one least full, the first of equals // (FUN_00a09905). -1 marks a slot without a pet; none gives -1. inline int hungriest(const std::array &fullness) diff --git a/HeavenClient/IO/UITypes/UIItemInventory.cpp b/HeavenClient/IO/UITypes/UIItemInventory.cpp index dd8ef6ca8..ddf15427d 100644 --- a/HeavenClient/IO/UITypes/UIItemInventory.cpp +++ b/HeavenClient/IO/UITypes/UIItemInventory.cpp @@ -31,6 +31,7 @@ #include "../../Character/Pets.h" #include "../../Character/Inventory/ItemDrop.h" #include "../../Character/Inventory/ScrollRules.h" +#include "../../Data/CurseData.h" #include "../../Data/EquipData.h" #include "../../Gameplay/Stage.h" #include "../../Util/NxFiles.h" @@ -75,6 +76,11 @@ namespace ms constexpr const char *TOO_MANY_PETS = "You have exceeded the maximum number\\n of pets that can accompany you.\\nPlease try again after \\nyou change the living pet back into a doll."; // StringPool 0xed8. constexpr const char *CASH_FOOD_NO_PET = "This item can only be used with a pet."; + // StringPool 0x319 to 0x31c, 0x110 and 0x335. + constexpr const char *PET_HAS_NAME = "The pet already has a name.\\nWill you change it?"; + constexpr const char *NAME_PET = "Please name your pet."; + constexpr const char *NAME_TAKEN = "That name is not available."; + constexpr const char *NO_CURSING = "Cursing is not allowed."; constexpr const char *BOSS_PET = "Would you like to change your Boss Pet?\\n (The \"Item Ignore\" skill, as well as \\nthe stats for speed and jump will \\nfollow that of the Lead Pet)"; // v83 FUN_00a240a2: double-clicking a pet asks the server to bring it @@ -136,6 +142,69 @@ namespace ms Sound(nl::nx::sound["Game.img"]["UseShopItem"]).play(); } + // v83 FUN_00a0a63f, type 0x11, after asking whether to rename a pet + // that has a name: the name, then whether to use it. A name too + // short asks again; anything else refused ends it. + void ask_pet_name(int16_t slot, int32_t item_id, const std::string &species, const std::string ¤t) + { + UI::get().emplace(NAME_PET, [=](const std::string &typed) + { + std::string name = typed; + + switch (pets::check_name(name, species, current, CurseData::filter)) + { + case pets::Naming::OK: + UI::get().emplace("Do you want to name your pet\\n'" + name + "'?", [=](bool yes) + { + if (yes && Stage::get().get_player().request(UseCashItemPacket(slot, item_id, name), ExclRequest::WAIT)) + Sound(nl::nx::sound["Game.img"]["UseShopItem"]).play(); + }); + break; + case pets::Naming::SHORT: + UI::get().emplace("Please enter at least " + std::to_string(pets::MIN_NAME) + " characters.", [=](bool) + { + ask_pet_name(slot, item_id, species, current); + }); + break; + case pets::Naming::CURSING: + UI::get().emplace(NO_CURSING, [](bool) + {}); + break; + case pets::Naming::TAKEN: + UI::get().emplace(NAME_TAKEN, [](bool) + {}); + break; + } + }, pets::MAX_NAME); + } + + // v83 FUN_00a0a63f, type 0x11: the Pet Name Tag names the first pet + // out. Without one it does nothing. A pet already renamed asks first. + void name_pet(int16_t slot, int32_t item_id) + { + Player &player = Stage::get().get_player(); + + if (!player.can_request(ExclRequest::WAIT)) + return; + + Optional pet = player.get_inventory().get_pet(player.get_pet_serials()[0]); + + if (!pet) + return; + + std::string species = nl::nx::string["Pet.img"][std::to_string(pet->get_item_id())]["name"]; + std::string current = pet->get_name(); + + if (pets::same_name(species, current)) + ask_pet_name(slot, item_id, species, current); + else + UI::get().emplace(PET_HAS_NAME, [=](bool yes) + { + if (yes) + ask_pet_name(slot, item_id, species, current); + }); + } + // v83 FUN_00a0900a: a map may keep items off the ground, which it // says only once the player has answered every question. void send_drop(InventoryType::Id tab, int16_t slot, int16_t qty) @@ -458,6 +527,8 @@ namespace ms summon_pet(slot, inventory.get_cash_id(tab, slot)); else if (pets::is_cash_food(item_id)) feed_cash_food(slot, item_id); + else if (pets::is_name_tag(item_id)) + name_pet(slot, item_id); break; } diff --git a/HeavenClient/Net/Handlers/MapObjectHandlers.cpp b/HeavenClient/Net/Handlers/MapObjectHandlers.cpp index f3f014fc7..cbe5ab3f2 100644 --- a/HeavenClient/Net/Handlers/MapObjectHandlers.cpp +++ b/HeavenClient/Net/Handlers/MapObjectHandlers.cpp @@ -199,6 +199,21 @@ namespace ms pet->act({ type, action, text }, styled); } + void PetNameChangeHandler::handle(InPacket& recv) const + { + int32_t cid = recv.read_int(); + uint8_t index = recv.read_byte(); + std::string name = recv.read_string(); + + // The name tag's style (`info/nameTag`) when set; HeavenClient + // draws one plain tag for every pet. + recv.read_bool(); + + if (Optional character = Stage::get().get_character(cid)) + if (Optional pet = character->get_pet(index)) + pet->set_name(name); + } + void CashPetFoodResultHandler::handle(InPacket& recv) const { switch (recv.read_byte()) diff --git a/HeavenClient/Net/Handlers/MapObjectHandlers.h b/HeavenClient/Net/Handlers/MapObjectHandlers.h index 58e61208c..358974ccc 100644 --- a/HeavenClient/Net/Handlers/MapObjectHandlers.h +++ b/HeavenClient/Net/Handlers/MapObjectHandlers.h @@ -64,6 +64,15 @@ namespace ms void handle(InPacket& recv) const override; }; + // A pet's new name, which a Pet Name Tag gave it (FUN_00704801). The + // owner's pet answers commands to it. It doesn't unlock the client; + // the inventory operation before it does. + // Opcode: PET_NAMECHANGE(172) + class PetNameChangeHandler : public PacketHandler + { + void handle(InPacket& recv) const override; + }; + // Whether a pet ate the cash food the player used: it plays its eat // sound, or the food suits none of the pets out (FUN_00a29049). It // doesn't unlock the client; the inventory operation after it does. diff --git a/HeavenClient/Net/PacketSwitch.cpp b/HeavenClient/Net/PacketSwitch.cpp index d954f29b3..854d906a4 100644 --- a/HeavenClient/Net/PacketSwitch.cpp +++ b/HeavenClient/Net/PacketSwitch.cpp @@ -117,6 +117,7 @@ namespace ms SPAWN_PET = 168, MOVE_PET = 170, PET_CHAT = 171, + PET_NAMECHANGE = 172, PET_COMMAND = 174, CASH_PET_FOOD_RESULT = 76, CHAR_MOVED = 185, @@ -240,6 +241,7 @@ namespace ms emplace(); emplace(); emplace(); + emplace(); emplace(); emplace(); emplace(); diff --git a/HeavenClient/Net/Packets/InventoryPackets.h b/HeavenClient/Net/Packets/InventoryPackets.h index f0d8a4f78..b59160ee6 100644 --- a/HeavenClient/Net/Packets/InventoryPackets.h +++ b/HeavenClient/Net/Packets/InventoryPackets.h @@ -133,6 +133,16 @@ namespace ms write_int(itemid); write_time(); } + + // The Pet Name Tag (5170000) sends the pet's new name. + UseCashItemPacket(int16_t slot, int32_t itemid, const std::string &text) + : OutPacket(OutPacket::Opcode::USE_CASH_ITEM) + { + write_short(slot); + write_int(itemid); + write_string(text); + write_time(); + } }; // Requests reading a skill or mastery book (v83 FUN_00a0a1b2) diff --git a/HeavenClient/tests/pets.cpp b/HeavenClient/tests/pets.cpp index 89c560146..b4246c2d4 100644 --- a/HeavenClient/tests/pets.cpp +++ b/HeavenClient/tests/pets.cpp @@ -221,6 +221,42 @@ void food() require(closeness_change(4, 4).empty(), "no change, no line"); } +void naming() +{ + using ms::pets::check_name; + using ms::pets::Naming; + + require(ms::pets::is_name_tag(5170000) && !ms::pets::is_name_tag(5170001) && !ms::pets::is_name_tag(5240000), + "the name tag is 5170000"); + require(ms::pets::same_name("Baby Tiger", "baby TIGER") && !ms::pets::same_name("Baby Tiger", "Baby Tige"), + "names match in any case"); + + auto censor = [](const std::string& text) + { + std::string starred = text; + size_t at = starred.find("darn"); + + if (at != std::string::npos) + starred.replace(at, 4, "****"); + + return starred; + }; + auto check = [&](std::string typed, const std::string& current) + { + Naming naming = check_name(typed, "Baby Tiger", current, censor); + return std::make_pair(naming, typed); + }; + + require(check(" Tigger ", "Baby Tiger") == std::make_pair(Naming::OK, std::string("Tigger")), "trimmed"); + require(check(" Tig ", "Baby Tiger").first == Naming::SHORT, "under 4 bytes once trimmed"); + require(check("Tig\x01ger", "Baby Tiger").first == Naming::CURSING, "a control character"); + require(check("darn cat", "Baby Tiger") == std::make_pair(Naming::OK, std::string("**** cat")), + "curse words starred out"); + require(check("BABY TIGER", "Tigger").first == Naming::TAKEN, "the kind's own name"); + require(check("Tigger", "Tigger").first == Naming::TAKEN, "the name it has"); + require(check("tigger", "Tigger").first == Naming::OK, "its name in another case"); +} + int main() { try @@ -233,6 +269,7 @@ int main() hearing(); actions(); food(); + naming(); std::cout << "Pets passed\n"; return 0; From d8971d5c4fecdc7e4296b86297cf0e2ee7ec9c01 Mon Sep 17 00:00:00 2001 From: David Goldstein Date: Sun, 18 Oct 2026 09:30:36 -0400 Subject: [PATCH 05/11] L179: hold up a chalkboard USE_CASH_ITEM now handles the chalkboard (537xxxx). Its note, 1 to 40 bytes as the v83 client allows, is kept on the player (not saved) and their field avatar, and CHALKBOARD (164) shows it to the holder and the map. The holder needs the 164 too: the v83 client only lets them click the board away once it has been told the board is up. An empty stat change then unlocks the client, since the chalkboard isn't used up. CLOSE_CHALKBOARD (0x32) puts the board away for the holder and the map. SPAWN_PLAYER now writes the board, so later arrivals see it. It comes along to the next map, where the holder's client is told again, but a map where shops open (the Free Market rooms) puts it away, as the client does there. The harness's SPAWN_PLAYER decoder reads the board, and the chalkboard test covers a watcher, a map change, closing, and the Free Market. --- agent_docs/inventory.md | 22 +++- agent_docs/pets.md | 6 +- game/src/character.rs | 6 + integration-harness/src/packets.rs | 22 ++++ integration-harness/tests/chalkboard.rs | 136 ++++++++++++++++++++++ net/src/handler.rs | 7 +- net/src/packet/build/world/field.rs | 68 ++++++++++- net/src/packet/handle/world/cash_item.rs | 6 +- net/src/packet/handle/world/chalkboard.rs | 132 +++++++++++++++++++++ net/src/packet/handle/world/mod.rs | 2 + net/src/packet/op/recv.rs | 1 + net/src/packet/op/send.rs | 1 + runtime/src/actor/client.rs | 20 +++- runtime/src/actor/field.rs | 1 + 14 files changed, 421 insertions(+), 9 deletions(-) create mode 100644 integration-harness/tests/chalkboard.rs create mode 100644 net/src/packet/handle/world/chalkboard.rs diff --git a/agent_docs/inventory.md b/agent_docs/inventory.md index c01aa62b4..13968d641 100644 --- a/agent_docs/inventory.md +++ b/agent_docs/inventory.md @@ -189,6 +189,24 @@ knows every client's channel. One exclusive INVENTORY_OPERATION takes the megaphone first. Anything refused (too long, too low, an item that isn't there, Maple TV) gets only an empty stat change to unlock the client. +### Chalkboard + +A chalkboard (537xxxx, `world/chalkboard.rs`) is used through USE_CASH_ITEM +with its note, 1 to 40 bytes, and isn't used up. The note goes in +`Player.chalkboard` (not saved) and the field avatar. CHALKBOARD (`0xA4`: +character id, 1, note) goes to the holder and the map, then an empty stat +change unlocks the holder's client; the holder's client needs the 164 too, +since it only lets the player click the board away once told. SPAWN_PLAYER +writes the board (1 and the note, or 0) after the mini room balloon, so +later arrivals see it. CLOSE_CHALKBOARD (`0x32`, empty) puts it away with +CHALKBOARD `id, 0` to the holder and the map. + +The board comes along to the next map, where the client actor tells the +holder's client again, as it forgets it on a map change. Entering a map +where shops open (`info/personalShop`, the Free Market rooms) puts it away, +as the v83 client does there; the server refuses a board in those maps too. +Losing the last chalkboard doesn't put the board away yet. + ## NPC shops Clicking an NPC that keeps a shop (`content::shops()`, from the pack's @@ -443,7 +461,9 @@ test covers the fit, the White Scroll prompt and the chat lines. `net`'s megaphone handler covers each kind's line, scope and refusals; `megaphones` (integration) hears a plain megaphone on the speaker's channel and the super, item and triple ones on another, then finds the used slot -empty. +empty. `net`'s chalkboard handler covers the board, the refused notes and +closing it; `chalkboard` (integration) holds one up for a watcher, takes it +to another map and back, closes it, and has a Free Market room put it away. `net::storage` covers each request and its refusals. `storage` (integration) opens Mr. Lee's storage, stores part of a stack and a whole one for the fee, is refused too many and too many mesos, moves mesos in, sorts, takes items diff --git a/agent_docs/pets.md b/agent_docs/pets.md index c2642e0fc..eae085289 100644 --- a/agent_docs/pets.md +++ b/agent_docs/pets.md @@ -69,9 +69,9 @@ tag. Pet equipment, looting and auto potions are not done yet; `v83ref` report it either. - **Cash food.** USE_CASH_ITEM (`0x4F` in, `world/cash_item.rs`) is the Cash slot, the item id, the item's own fields and the tick. Cash pet food - (`524xxxx`), the pet name tag and megaphones (`inventory.md`) are used so - far; any other item, or one not in that slot, gets an empty stat change to - unlock the client. The first pet out, in list order, whose id is in the + (`524xxxx`), the pet name tag, megaphones and the chalkboard + (`inventory.md`) are used so far; any other item, or one not in that slot, + gets an empty stat change to unlock the client. The first pet out, in list order, whose id is in the food's `spec/0..n` eats: fullness 100 and `spec/inc` closeness (100), and it always enjoys it. The server answers with 76 `0, index`, then the 174, inventory operation and level-up effect that diff --git a/game/src/character.rs b/game/src/character.rs index f96613869..c333a5241 100644 --- a/game/src/character.rs +++ b/game/src/character.rs @@ -135,6 +135,8 @@ pub struct FieldAvatar { pub mini_room: Option, /// The summoned pets, in list order. The field moves them. pub pets: Vec, + /// The note on the chalkboard the player holds up, if any. + pub chalkboard: Option, } /// The balloon over a player who keeps a mini room open, which others @@ -180,6 +182,8 @@ pub struct Player { /// The pets whose last command called them by name; their answer shows /// in their own chat balloon. Not saved. pub pets_named: Vec, + /// The note on the chalkboard the player holds up, if any; not saved. + pub chalkboard: Option, dirty: bool, } @@ -211,6 +215,7 @@ impl Player { guild: None, pets: Vec::new(), pets_named: Vec::new(), + chalkboard: None, dirty: false, } } @@ -294,6 +299,7 @@ impl Player { foothold: 0, }) .collect(), + chalkboard: self.chalkboard.clone(), } } diff --git a/integration-harness/src/packets.rs b/integration-harness/src/packets.rs index d5d88bf12..af2a2717d 100644 --- a/integration-harness/src/packets.rs +++ b/integration-harness/src/packets.rs @@ -114,6 +114,8 @@ pub struct SpawnPlayerPacket { pub stance: u8, /// The pets out: (item id, name, serial), in list order. pub pets: Vec<(i32, String, i64)>, + /// The note on the chalkboard they hold up, if any. + pub chalkboard: Option, } #[derive(Debug, Clone, PartialEq, Eq)] @@ -1052,6 +1054,8 @@ pub fn decode_spawn_player(packet: &Packet) -> Result .map_err(|e| format!("failed to read spawn-player stance: {e}"))?; let pets = decode_spawn_player_pets(&mut cursor) .map_err(|e| format!("failed to read spawn-player pets: {e}"))?; + let chalkboard = decode_spawn_player_chalkboard(&mut cursor) + .map_err(|e| format!("failed to read spawn-player chalkboard: {e}"))?; Ok(SpawnPlayerPacket { character_id, @@ -1063,6 +1067,7 @@ pub fn decode_spawn_player(packet: &Packet) -> Result y, stance, pets, + chalkboard, }) } @@ -1086,6 +1091,23 @@ fn decode_spawn_player_pets( Ok(pets) } +/// After the mount and any mini room balloon, whether a chalkboard is up +/// and its note. +fn decode_spawn_player_chalkboard( + cursor: &mut Cursor<&[u8]>, +) -> Result, std::io::Error> { + cursor.read_bytes(4 + 8)?; + if cursor.read_byte()? != 0 { + cursor.read_int()?; + cursor.read_str_with_length()?; + cursor.read_bytes(5)?; + } + if cursor.read_byte()? == 0 { + return Ok(None); + } + cursor.read_str_with_length().map(Some) +} + pub fn decode_spawn_npc(packet: &Packet) -> Result { expect_opcode(packet, SendOpcode::SpawnNpc as i16)?; diff --git a/integration-harness/tests/chalkboard.rs b/integration-harness/tests/chalkboard.rs new file mode 100644 index 000000000..994fb2a7d --- /dev/null +++ b/integration-harness/tests/chalkboard.rs @@ -0,0 +1,136 @@ +//! The chalkboard: held up, everyone on the map sees the note, those who +//! arrive later too, and it comes along to the next map but not into the +//! Free Market rooms. Clicking it puts it away. + +use integration_harness::connection::MapleTestConnection; +use integration_harness::database::grant_stack; +use integration_harness::preconditions::load_harness_config_or_fail; +use integration_harness::trace::{read, send, until, warp, Trace}; +use integration_harness::{login_to_world_session, relogin}; +use net::packet::op::{RecvOpcode, SendOpcode}; +use packet::io::write::PktWrite; +use packet::Packet; + +const CASH: i16 = 5; +const CHALKBOARD: i32 = 5_370_000; +const ELLINIA: i32 = 101_000_000; +const HENESYS: i32 = 100_000_000; +const FREE_MARKET_ROOM: i32 = 910_000_001; +const NOTE: &str = "selling ilbis"; + +/// Read until a packet with `op`, and return its body. +async fn next(c: &mut MapleTestConnection, t: &mut Trace, op: SendOpcode) -> Vec { + let op = op as i16; + loop { + let p = read(c, t).await; + if p.opcode() == op { + return p.bytes[2..].to_vec(); + } + } +} + +fn write_note(note: &str) -> Packet { + let mut p = Packet::new_empty(); + p.write_short(RecvOpcode::UseCashItem as i16).unwrap(); + p.write_short(1).unwrap(); + p.write_int(CHALKBOARD).unwrap(); + p.write_str_with_length(note).unwrap(); + p.write_int(0).unwrap(); + p +} + +fn close() -> Packet { + let mut p = Packet::new_empty(); + p.write_short(RecvOpcode::CloseChalkboard as i16).unwrap(); + p +} + +/// CHALKBOARD's body: the holder, then the note if the board is up. +fn board(id: i32, note: Option<&str>) -> Vec { + let mut body = id.to_le_bytes().to_vec(); + match note { + Some(note) => { + body.push(1); + body.extend((note.len() as u16).to_le_bytes()); + body.extend(note.as_bytes()); + } + None => body.push(0), + } + body +} + +/// Watch the holder `id` arrive on the watcher's map, and return the note +/// they arrive with. +async fn arrives(w: &mut MapleTestConnection, wt: &mut Trace, id: i32) -> Option { + wt.players.clear(); + until(w, wt, |t| t.players.iter().any(|p| p.character_id == id)).await; + let seen = wt.players.iter().find(|p| p.character_id == id).unwrap(); + seen.chalkboard.clone() +} + +#[tokio::test] +async fn a_chalkboard_shows_its_note_on_the_map() { + let config = load_harness_config_or_fail().await; + let id = login_to_world_session(&config) + .await + .expect("holder login failed") + .character_id; + tokio::time::sleep(std::time::Duration::from_millis(500)).await; + grant_stack(id, CASH, 1, CHALKBOARD, 1) + .await + .expect("grant a chalkboard"); + let mut holder = relogin(&config).await; + let watcher_config = load_harness_config_or_fail().await; + let mut watcher = login_to_world_session(&watcher_config).await.unwrap(); + let (h, mut ht) = (&mut holder.connection, Trace::default()); + let (w, mut wt) = (&mut watcher.connection, Trace::default()); + assert_eq!(warp(w, ELLINIA, "", &mut wt).await, ELLINIA); + assert_eq!(warp(h, ELLINIA, "", &mut ht).await, ELLINIA); + assert_eq!(arrives(w, &mut wt, id).await, None); + + // Held up, the holder and the watcher see the note, and the holder's + // client is unlocked. + send(h, write_note(NOTE)).await; + assert_eq!( + next(h, &mut ht, SendOpcode::Chalkboard).await, + board(id, Some(NOTE)) + ); + let unlocked = next(h, &mut ht, SendOpcode::StatChange).await; + assert_eq!(unlocked[0], 1, "an empty stat change unlocks the client"); + assert_eq!( + next(w, &mut wt, SendOpcode::Chalkboard).await, + board(id, Some(NOTE)) + ); + + // It comes along to the next map, where the holder's client is told + // again, and back, where the watcher sees it on arrival. + assert_eq!(warp(h, HENESYS, "", &mut ht).await, HENESYS); + assert_eq!( + next(h, &mut ht, SendOpcode::Chalkboard).await, + board(id, Some(NOTE)) + ); + assert_eq!(warp(h, ELLINIA, "", &mut ht).await, ELLINIA); + next(h, &mut ht, SendOpcode::Chalkboard).await; + assert_eq!(arrives(w, &mut wt, id).await.as_deref(), Some(NOTE)); + + // Clicked, it is put away for both. + send(h, close()).await; + assert_eq!( + next(h, &mut ht, SendOpcode::Chalkboard).await, + board(id, None) + ); + assert_eq!( + next(w, &mut wt, SendOpcode::Chalkboard).await, + board(id, None) + ); + + // The Free Market rooms put it away too. + send(h, write_note(NOTE)).await; + next(w, &mut wt, SendOpcode::Chalkboard).await; + assert_eq!( + warp(h, FREE_MARKET_ROOM, "", &mut ht).await, + FREE_MARKET_ROOM + ); + assert_eq!(warp(h, ELLINIA, "", &mut ht).await, ELLINIA); + assert_eq!(arrives(w, &mut wt, id).await, None); +} diff --git a/net/src/handler.rs b/net/src/handler.rs index 0e5d1144d..f33b1bf9d 100644 --- a/net/src/handler.rs +++ b/net/src/handler.rs @@ -337,6 +337,10 @@ pub enum WorldAction { /// The player summoned or sent back pets (`player.pets` already /// changed); show everyone on the map. PetsChanged(Vec), + /// The player held up or put away a chalkboard (`player.chalkboard` + /// already changed); refresh their field avatar and show the others + /// this CHALKBOARD. + ChalkboardChanged(Packet), /// Natural HP/MP recovery the client reports; the runtime checks timing. NaturalHeal { hp: i16, @@ -663,6 +667,7 @@ fn world_routes() -> &'static HashMap { (Op::PetCommand, InGame(&world::PetCommandHandler)), (Op::PetFood, InGame(&world::PetFoodHandler)), (Op::UseCashItem, InGame(&world::UseCashItemHandler)), + (Op::CloseChalkboard, InGame(&world::CloseChalkboardHandler)), (Op::GiveFame, InGame(&world::GiveFameHandler)), (Op::MoveSummon, InGame(&world::MoveSummonHandler)), (Op::SummonAttack, InGame(&world::SummonAttackHandler)), @@ -701,7 +706,7 @@ mod tests { fn every_registered_opcode_is_unique_per_server() { // The maps would silently drop duplicates; check the table sizes match. assert_eq!(login_routes().len(), 13); - assert_eq!(world_routes().len(), 68); + assert_eq!(world_routes().len(), 69); } #[test] diff --git a/net/src/packet/build/world/field.rs b/net/src/packet/build/world/field.rs index e3cd82a46..d5a158574 100644 --- a/net/src/packet/build/world/field.rs +++ b/net/src/packet/build/world/field.rs @@ -46,7 +46,7 @@ pub fn build_player_enter_field(avatar: &FieldAvatar) -> Result Result { Ok(packet) } +/// CHALKBOARD (recv 164): the player holds up a chalkboard with `note`, or +/// puts it away. The player's own client needs it too: it lets them close +/// the board by clicking it. +pub fn build_chalkboard(character_id: i32, note: Option<&str>) -> Result { + let mut packet = Packet::new_empty(); + packet.write_short(SendOpcode::Chalkboard as i16)?; + packet.write_int(character_id)?; + write_chalkboard(&mut packet, note)?; + Ok(packet) +} + +/// Whether a chalkboard is up, then its note if it is. +fn write_chalkboard(packet: &mut Packet, note: Option<&str>) -> Result<(), NetworkError> { + packet.write_byte(u8::from(note.is_some()))?; + if let Some(note) = note { + packet.write_str_with_length(note)?; + } + Ok(()) +} + pub fn parse_movement_state( movement_bytes: &[u8], current: (i16, i16, u8), @@ -262,6 +282,7 @@ mod tests { guild: None, mini_room: None, pets: Vec::new(), + chalkboard: None, } } @@ -359,6 +380,34 @@ mod tests { expect.int(1).long(0); } + #[test] + fn spawn_player_carries_the_chalkboard() { + let mut avatar = avatar(); + avatar.chalkboard = Some("selling".into()); + let packet = build_player_enter_field(&avatar).unwrap(); + + let mut expect = Expect::new(&packet.bytes); + expect.short(SendOpcode::SpawnPlayer as i16); + expect.int(0x0102_0304).byte(10).string("Tester"); + expect.string("").bytes(&[0; 6]); + foreign_buffs(&mut expect); + expect.short(100); + expect.look(&avatar.look, false); + expect.int(0).int(0).int(3010000); + expect.short(-120).short(275).byte(4).short(0).byte(0); + expect.byte(0).int(1).long(0).byte(0); // pets, mount, no box + expect.byte(1).string("selling"); + expect + .byte(0) + .byte(0) + .byte(0) + .byte(0) + .byte(0) + .byte(0) + .byte(0); + expect.end(); + } + #[test] fn spawn_player_carries_the_buffs_others_see() { let mut avatar = avatar(); @@ -428,6 +477,23 @@ mod tests { .end(); } + #[test] + fn a_chalkboard_goes_up_and_away() { + let up = build_chalkboard(0x0102_0304, Some("selling")).unwrap(); + Expect::new(&up.bytes) + .short(SendOpcode::Chalkboard as i16) + .int(0x0102_0304) + .byte(1) + .string("selling") + .end(); + let away = build_chalkboard(0x0102_0304, None).unwrap(); + Expect::new(&away.bytes) + .short(SendOpcode::Chalkboard as i16) + .int(0x0102_0304) + .byte(0) + .end(); + } + #[test] fn leave_field_is_just_the_character_id() { let packet = build_player_leave_field(0x0102_0304).unwrap(); diff --git a/net/src/packet/handle/world/cash_item.rs b/net/src/packet/handle/world/cash_item.rs index 5b148f071..cbe0974e0 100644 --- a/net/src/packet/handle/world/cash_item.rs +++ b/net/src/packet/handle/world/cash_item.rs @@ -1,9 +1,10 @@ //! USE_CASH_ITEM (0x4F, FUN_00a0a63f, report 179): an item used from the //! Cash Inventory. Every request is the item's Cash slot and id, then the //! item's own fields and the tick. The client then waits for an answer that -//! unlocks it. Cash pet food, the pet name tag and megaphones are used so -//! far. +//! unlocks it. Cash pet food, the pet name tag, megaphones and the +//! chalkboard are used so far. +use super::chalkboard::chalkboard; use super::megaphone::megaphone; use super::pet::{cash_pet_food, pet_name_tag}; use crate::handler::{HandlerResult, WorldContext, WorldHandler, WorldResult}; @@ -29,6 +30,7 @@ impl WorldHandler for UseCashItemHandler { 507 if held => megaphone(ctx, &mut r, slot, item_id), 517 if held && item_id % 10_000 == 0 => pet_name_tag(ctx, &mut r, slot), 524 if held => cash_pet_food(ctx, slot, item_id), + 537 if held => chalkboard(ctx, &mut r), _ => Ok(HandlerResult::reply(build_stat_update(true, &[])?)), } } diff --git a/net/src/packet/handle/world/chalkboard.rs b/net/src/packet/handle/world/chalkboard.rs new file mode 100644 index 000000000..e9d7f8cc3 --- /dev/null +++ b/net/src/packet/handle/world/chalkboard.rs @@ -0,0 +1,132 @@ +//! The chalkboard (537xxxx): held up through USE_CASH_ITEM, put away with +//! CLOSE_CHALKBOARD (report 179, 1.7 and 3). It isn't used up. + +use crate::handler::{HandlerResult, WorldAction, WorldContext, WorldHandler, WorldResult}; +use crate::packet::build::world::field::build_chalkboard; +use crate::packet::build::world::stat::build_stat_update; +use packet::{io::read::PktRead, Packet}; +use std::io::Cursor; + +/// The longest note the v83 client lets a chalkboard hold. +pub const MAX_NOTE: usize = 40; + +/// Hold up a chalkboard with the note `r` carries. Not where shops open +/// (the Free Market rooms), which the client checks too. +pub fn chalkboard(ctx: &mut WorldContext, r: &mut Cursor<&Vec>) -> WorldResult { + let note = r.read_str_with_length()?; + let unlock = build_stat_update(true, &[])?; + let fits = !note.is_empty() && note.len() <= MAX_NOTE; + if !fits || crate::personal_shop::allowed_on(ctx.player.stats.map_id) { + return Ok(HandlerResult::reply(unlock)); + } + let packet = build_chalkboard(ctx.player.stats.id, Some(¬e))?; + ctx.player.chalkboard = Some(note); + Ok(HandlerResult::reply(packet.clone()) + .with_reply(unlock) + .with(WorldAction::ChalkboardChanged(packet))) +} + +/// CLOSE_CHALKBOARD (0x32): the player clicked their chalkboard to put it +/// away. +pub struct CloseChalkboardHandler; + +impl WorldHandler for CloseChalkboardHandler { + fn handle(&self, _packet: &mut Packet, ctx: &mut WorldContext) -> WorldResult { + if ctx.player.chalkboard.take().is_none() { + return Ok(HandlerResult::empty()); + } + let packet = build_chalkboard(ctx.player.stats.id, None)?; + Ok(HandlerResult::reply(packet.clone()).with(WorldAction::ChalkboardChanged(packet))) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::packet::handle::world::UseCashItemHandler; + use crate::packet::op::{RecvOpcode, SendOpcode}; + use game::{InventoryType, Item, Player}; + use packet::io::write::PktWrite; + + const CHALKBOARD: i32 = 5370000; + + fn holder() -> Player { + let mut player = crate::codec::stats::sample_player(); + player + .inventory + .insert(InventoryType::Cash, 3, Item::stack(CHALKBOARD, 1)); + player + } + + fn handle(handler: &dyn WorldHandler, player: &mut Player, p: &mut Packet) -> Vec { + let mut ctx = WorldContext { + client_id: 1, + channel_id: 0, + player, + }; + handler.handle(p, &mut ctx).unwrap().actions + } + + fn write_note(player: &mut Player, note: &str) -> Vec { + let mut p = Packet::new_empty(); + p.write_short(RecvOpcode::UseCashItem as i16).unwrap(); + p.write_short(3).unwrap(); + p.write_int(CHALKBOARD).unwrap(); + p.write_str_with_length(note).unwrap(); + p.write_int(1234).unwrap(); + handle(&UseCashItemHandler, player, &mut p) + } + + fn close(player: &mut Player) -> Vec { + let mut p = Packet::new_empty(); + p.write_short(RecvOpcode::CloseChalkboard as i16).unwrap(); + handle(&CloseChalkboardHandler, player, &mut p) + } + + fn opcode(packet: &Packet) -> i16 { + i16::from_le_bytes([packet.bytes[0], packet.bytes[1]]) + } + + #[test] + fn a_chalkboard_shows_its_note_to_everyone() { + let mut player = holder(); + let actions = write_note(&mut player, "selling"); + let [WorldAction::Reply(own), WorldAction::Reply(unlock), WorldAction::ChalkboardChanged(shown)] = + &actions[..] + else { + panic!("expected the board, an unlock and the board for the map: {actions:?}"); + }; + assert_eq!(own.bytes, shown.bytes); + assert_eq!(opcode(own), SendOpcode::Chalkboard as i16); + assert_eq!(opcode(unlock), SendOpcode::StatChange as i16); + assert_eq!(player.chalkboard.as_deref(), Some("selling")); + assert_eq!(player.inventory.count(CHALKBOARD), 1, "not used up"); + } + + #[test] + fn a_note_the_client_would_not_send_is_refused() { + let mut player = holder(); + for note in ["", &"a".repeat(MAX_NOTE + 1)] { + let actions = write_note(&mut player, note); + let [WorldAction::Reply(unlock)] = &actions[..] else { + panic!("expected a lone unlock: {actions:?}"); + }; + assert_eq!(opcode(unlock), SendOpcode::StatChange as i16); + assert_eq!(player.chalkboard, None); + } + } + + #[test] + fn closing_the_chalkboard_puts_it_away() { + let mut player = holder(); + assert!(close(&mut player).is_empty(), "no board to put away"); + write_note(&mut player, "selling"); + let actions = close(&mut player); + let [WorldAction::Reply(own), WorldAction::ChalkboardChanged(shown)] = &actions[..] else { + panic!("expected the board put away for all: {actions:?}"); + }; + assert_eq!(own.bytes, shown.bytes); + assert_eq!(own.bytes[2..], [0x04, 0x03, 0x02, 0x01, 0][..]); + assert_eq!(player.chalkboard, None); + } +} diff --git a/net/src/packet/handle/world/mod.rs b/net/src/packet/handle/world/mod.rs index 226e38147..87a452ee7 100644 --- a/net/src/packet/handle/world/mod.rs +++ b/net/src/packet/handle/world/mod.rs @@ -2,6 +2,7 @@ mod buddy; mod cash_item; mod cash_shop; mod chair; +mod chalkboard; mod change_channel; mod change_map; mod char_info; @@ -44,6 +45,7 @@ pub use self::buddy::BuddyListModifyHandler; pub use self::cash_item::UseCashItemHandler; pub use self::cash_shop::{CashShopOperationHandler, CheckCashHandler, EnterCashShopHandler}; pub use self::chair::{CancelChairHandler, UseChairHandler}; +pub use self::chalkboard::CloseChalkboardHandler; pub use self::change_channel::ChangeChannelHandler; pub use self::change_map::{door_warp, warp, ChangeMapHandler, DoorExit}; pub use self::char_info::CharInfoRequestHandler; diff --git a/net/src/packet/op/recv.rs b/net/src/packet/op/recv.rs index 9e0771683..59ff23c6d 100644 --- a/net/src/packet/op/recv.rs +++ b/net/src/packet/op/recv.rs @@ -38,6 +38,7 @@ pub enum RecvOpcode { MagicAttack = 0x2E, TakeDamage = 0x30, AllChat = 0x31, + CloseChalkboard = 0x32, MultiChat = 0x77, PartyOperation = 0x7C, BuddyListModify = 0x82, diff --git a/net/src/packet/op/send.rs b/net/src/packet/op/send.rs index 81ea3bf68..f8355aac3 100644 --- a/net/src/packet/op/send.rs +++ b/net/src/packet/op/send.rs @@ -50,6 +50,7 @@ pub enum SendOpcode { SpawnPlayer = 0xA0, RemovePlayerFromMap = 0xA1, ChatText = 0xA2, + Chalkboard = 0xA4, UpdateCharBox = 0xA5, ShowScrollEffect = 0xA7, SpawnPet = 0xA8, diff --git a/runtime/src/actor/client.rs b/runtime/src/actor/client.rs index 8fad52acb..20d73caa9 100644 --- a/runtime/src/actor/client.rs +++ b/runtime/src/actor/client.rs @@ -31,7 +31,7 @@ use net::cash_shop::{CashShopRequest, Expansion}; use net::dialogue::{Conversation, NpcInteraction}; use net::login_world::resolve_login_channel; use net::packet::build; -use net::packet::build::world::field::parse_movement_state; +use net::packet::build::world::field::{build_chalkboard, parse_movement_state}; use net::packet::build::world::messaging::{build_find_result, FindLocation}; use net::packet::build::world::pet::PetGone; use net::packet::op::RecvOpcode; @@ -388,6 +388,15 @@ impl ClientActor { .await? } WorldAction::BuffsChanged(announce) => self.show_buffs(announce).await?, + WorldAction::ChalkboardChanged(announce) => { + let avatar = self.in_game()?.player.field_avatar(0, 0, 0); + self.send_to_field(FieldMessage::UpdateAvatar { + client_id: self.client_id, + avatar, + announce: vec![announce], + }) + .await? + } WorldAction::PetsChanged(changes) => { self.pets_changed(changes, PetGone::Dismissed).await? } @@ -563,6 +572,10 @@ impl ClientActor { let handle = self.handle(); let game = self.in_game_mut()?; game.position = (spawn.x, spawn.y); + // The client puts a chalkboard away in the Free Market rooms. + if net::personal_shop::allowed_on(game.player.stats.map_id) { + game.player.chalkboard = None; + } let avatar = game.player.field_avatar(spawn.x, spawn.y, spawn.stance); let party = self.party_id; self.send_to_field(FieldMessage::Join { @@ -574,6 +587,11 @@ impl ClientActor { self.set_wall_clock().await?; self.bring_summons(spawn).await?; self.bring_pets(&avatar).await?; + if let Some(note) = &avatar.chalkboard { + // The player's client shows its own board only when told. + self.send(&mut build_chalkboard(avatar.id, Some(note))?) + .await?; + } self.run_map_enter_script().await } diff --git a/runtime/src/actor/field.rs b/runtime/src/actor/field.rs index 7f8e9e8d5..70ab7a6da 100644 --- a/runtime/src/actor/field.rs +++ b/runtime/src/actor/field.rs @@ -1684,6 +1684,7 @@ mod tests { buffs: Vec::new(), mini_room: None, pets: Vec::new(), + chalkboard: None, } } From 1924037a90ac035ca09eeac0b23c96b6cc10e190 Mon Sep 17 00:00:00 2001 From: David Goldstein Date: Sun, 18 Oct 2026 09:42:25 -0400 Subject: [PATCH 06/11] L179: keep maps for teleport rocks and use them A character now keeps maps for the teleport rocks: five for the regular rock and ten for the VIP one, saved in a new teleport_rocks table and written into SET_FIELD's character data, with 999999999 for the empty slots. TROCK_ADD_MAP (0x66) keeps the player's map or drops one and answers with the new list in MAP_TRANSFER_RESULT (0x2A, result 3 or 2). Maps the v83 client wouldn't offer (Maple Island, event maps), maps whose fieldLimit forbids rocks, a full list and a map already kept are refused with result 10. The rock is used through USE_CASH_ITEM, to a kept map or to a character by name. A name goes through the world directory to the character's client actor on the same channel, which answers with where they stand; the user lands on the spawn point nearest them. A refusal is MAP_TRANSFER_RESULT with the v83 reason (6 not found, 9 the same map, 11 Maple Island under level 7, 8 the rest: a map not kept, a dead user, fieldLimit 0x40 or 0x2 on either end, another continent for the regular rock, a forcedReturn map off Maple Island) and an empty stat change, as 42 doesn't unlock the client. Otherwise an exclusive inventory operation takes one rock and the user is warped. warp_to_spawn_point is split out of warp for the trip to a character. Unit tests cover the lists, each refusal and both kinds of trip, and the teleport_rocks harness test keeps a map, rocks to it and to a watcher, is refused twice, and finds the list kept after a relog. --- agent_docs/inventory.md | 34 ++ agent_docs/pets.md | 4 +- .../2026-10-26-000000_teleport_rocks/down.sql | 1 + .../2026-10-26-000000_teleport_rocks/up.sql | 9 + db/src/player.rs | 51 ++- db/src/schema.rs | 10 + game-data/src/lib.rs | 6 + game/src/character.rs | 4 + game/src/lib.rs | 2 + game/src/teleport_rock.rs | 120 ++++++ integration-harness/tests/teleport_rocks.rs | 129 ++++++ net/src/codec/character.rs | 11 +- net/src/handler.rs | 12 +- net/src/packet/build/world/map.rs | 66 ++- net/src/packet/handle/world/cash_item.rs | 6 +- net/src/packet/handle/world/change_map.rs | 10 + net/src/packet/handle/world/mod.rs | 2 + net/src/packet/handle/world/teleport_rock.rs | 406 ++++++++++++++++++ net/src/packet/op/recv.rs | 1 + net/src/packet/op/send.rs | 1 + runtime/src/actor/client.rs | 39 ++ runtime/src/actor/directory.rs | 27 +- runtime/src/message.rs | 20 + 23 files changed, 955 insertions(+), 16 deletions(-) create mode 100644 db/migrations/2026-10-26-000000_teleport_rocks/down.sql create mode 100644 db/migrations/2026-10-26-000000_teleport_rocks/up.sql create mode 100644 game/src/teleport_rock.rs create mode 100644 integration-harness/tests/teleport_rocks.rs create mode 100644 net/src/packet/handle/world/teleport_rock.rs diff --git a/agent_docs/inventory.md b/agent_docs/inventory.md index 13968d641..cbdcc292c 100644 --- a/agent_docs/inventory.md +++ b/agent_docs/inventory.md @@ -207,6 +207,35 @@ where shops open (`info/personalShop`, the Free Market rooms) puts it away, as the v83 client does there; the server refuses a board in those maps too. Losing the last chalkboard doesn't put the board away yet. +### Teleport rocks + +A character keeps maps for the teleport rocks (504xxxx): five for the +regular rock, ten for the VIP one (any 504 id outside 5040xxx). The lists +are `Player.teleport_rocks` (`game::teleport_rock`), saved in the +`teleport_rocks` table by place, and SET_FIELD's character data writes +every slot, an empty one as 999999999. TROCK_ADD_MAP (`0x66`: 1 to keep +the player's map or 0 to drop one, the VIP flag, and the map id when +dropping) answers with MAP_TRANSFER_RESULT (`0x2A`: 3 kept or 2 dropped, +the VIP flag, the list's slots). The client offers to keep only maps off +Maple Island and outside the event maps (`(id / 1000000) % 100 == 9`); +the server refuses those, maps whose `fieldLimit` forbids rocks (`0x40`), +a full list and a map already kept with result 10. + +The rock is used through USE_CASH_ITEM with 0 and a kept map id, or 1 and +a character's name (`world/teleport_rock.rs`). A name goes to the world +directory (`WorldAction::RockToCharacter`), which asks the character's +client actor on the same channel where they stand; the answer comes back to +the user's actor, which finishes with `to_character` and lands on the spawn +point nearest them. Either way the rock is refused with MAP_TRANSFER_RESULT +`result, vip` and an empty stat change, since 42 doesn't unlock the client: +6 for a name not found (or the user's own), 9 for the map they are on, 11 +on Maple Island under level 7, and 8 for the rest: a map not kept, a dead +user, a map with `fieldLimit` `0x40` or `0x2` on either end, another +continent (`id / 100000000`) for the regular rock, or a map that sends +players elsewhere on login (`forcedReturn`) but on Maple Island, as Cosmic +does. Otherwise one exclusive INVENTORY_OPERATION takes one rock and the +user is warped. + ## NPC shops Clicking an NPC that keeps a shop (`content::shops()`, from the pack's @@ -464,6 +493,11 @@ and the super, item and triple ones on another, then finds the used slot empty. `net`'s chalkboard handler covers the board, the refused notes and closing it; `chalkboard` (integration) holds one up for a watcher, takes it to another map and back, closes it, and has a Free Market room put it away. +`game::teleport_rock` covers the lists; `net`'s teleport rock handler covers +keeping and dropping maps, each refusal, the trips by map and to a character, +and the rock used; `teleport_rocks` (integration) keeps Henesys, rocks back to +it, is refused the same map and a missing name, rocks to a watcher, and rocks +to Henesys again after a relog. `net::storage` covers each request and its refusals. `storage` (integration) opens Mr. Lee's storage, stores part of a stack and a whole one for the fee, is refused too many and too many mesos, moves mesos in, sorts, takes items diff --git a/agent_docs/pets.md b/agent_docs/pets.md index eae085289..42539ae01 100644 --- a/agent_docs/pets.md +++ b/agent_docs/pets.md @@ -69,8 +69,8 @@ tag. Pet equipment, looting and auto potions are not done yet; `v83ref` report it either. - **Cash food.** USE_CASH_ITEM (`0x4F` in, `world/cash_item.rs`) is the Cash slot, the item id, the item's own fields and the tick. Cash pet food - (`524xxxx`), the pet name tag, megaphones and the chalkboard - (`inventory.md`) are used so far; any other item, or one not in that slot, + (`524xxxx`), the pet name tag, megaphones, the chalkboard and teleport + rocks (`inventory.md`) are used so far; any other item, or one not in that slot, gets an empty stat change to unlock the client. The first pet out, in list order, whose id is in the food's `spec/0..n` eats: fullness 100 and `spec/inc` closeness (100), and it always enjoys it. The server answers with 76 diff --git a/db/migrations/2026-10-26-000000_teleport_rocks/down.sql b/db/migrations/2026-10-26-000000_teleport_rocks/down.sql new file mode 100644 index 000000000..7ab56ac8a --- /dev/null +++ b/db/migrations/2026-10-26-000000_teleport_rocks/down.sql @@ -0,0 +1 @@ +DROP TABLE teleport_rocks; diff --git a/db/migrations/2026-10-26-000000_teleport_rocks/up.sql b/db/migrations/2026-10-26-000000_teleport_rocks/up.sql new file mode 100644 index 000000000..5fb2a7ab8 --- /dev/null +++ b/db/migrations/2026-10-26-000000_teleport_rocks/up.sql @@ -0,0 +1,9 @@ +-- The maps a character keeps for their teleport rocks, by place in the +-- list: five for the regular rock, ten for the VIP one. +CREATE TABLE teleport_rocks ( + character_id INTEGER NOT NULL REFERENCES characters(id) ON DELETE CASCADE, + vip BOOLEAN NOT NULL, + place SMALLINT NOT NULL, + map_id INTEGER NOT NULL, + PRIMARY KEY (character_id, vip, place) +); diff --git a/db/src/player.rs b/db/src/player.rs index 04eb32bb1..55a05528b 100644 --- a/db/src/player.rs +++ b/db/src/player.rs @@ -9,7 +9,7 @@ use crate::keybinding::KeybindType; use crate::quest::QuestProgress; use crate::schema::{ accounts, character_quests, characters, equipment_stats, inventory_items, keybindings, - monster_book_cards, skills, + monster_book_cards, skills, teleport_rocks, }; use diesel::pg::PgConnection; use diesel::prelude::*; @@ -18,7 +18,7 @@ use game::inventory::DEFAULT_SLOT_LIMIT; use game::quest::{decode_kills, encode_kills}; use game::{ AvatarLook, CharacterStats, EquipStats, Inventory, InventoryType, Item, KeyBinding, Keymap, - MonsterBook, Player, QuestLog, QuestRecord, QuestStatus, SkillBook, SkillRecord, + MonsterBook, Player, QuestLog, QuestRecord, QuestStatus, SkillBook, SkillRecord, TeleportRocks, }; use std::collections::HashMap; use std::convert::TryFrom; @@ -212,6 +212,7 @@ pub fn load_player(character_id: i32) -> crate::Result { .into_iter() .map(|(card, level)| (card, u8::try_from(level).unwrap_or(0))), ); + player.teleport_rocks = load_teleport_rocks(&mut conn, character_id)?; Ok(player) } @@ -285,6 +286,7 @@ pub(crate) fn write_player(conn: &mut PgConnection, player: &Player) -> QueryRes } save_keymap(conn, id, &player.keymap)?; + save_teleport_rocks(conn, id, &player.teleport_rocks)?; save_inventory(conn, id, &player.inventory)?; crate::pet::save(conn, &player.inventory, &player.pets)?; // Kill progress of started quests. @@ -318,6 +320,51 @@ pub(crate) fn write_player(conn: &mut PgConnection, player: &Player) -> QueryRes } } +fn load_teleport_rocks(conn: &mut PgConnection, character_id: i32) -> QueryResult { + let rows: Vec<(bool, i32)> = teleport_rocks::table + .filter(teleport_rocks::character_id.eq(character_id)) + .order((teleport_rocks::vip, teleport_rocks::place)) + .select((teleport_rocks::vip, teleport_rocks::map_id)) + .load(conn)?; + let list = |vip| { + rows.iter() + .filter(|&&(of, _)| of == vip) + .map(|&(_, map)| map) + .collect() + }; + Ok(TeleportRocks::new(list(false), list(true))) +} + +fn save_teleport_rocks( + conn: &mut PgConnection, + character_id: i32, + rocks: &TeleportRocks, +) -> QueryResult<()> { + diesel::delete(teleport_rocks::table.filter(teleport_rocks::character_id.eq(character_id))) + .execute(conn)?; + let rows: Vec<_> = [false, true] + .into_iter() + .flat_map(|vip| { + rocks + .maps(vip) + .iter() + .enumerate() + .map(move |(place, &map)| { + ( + teleport_rocks::character_id.eq(character_id), + teleport_rocks::vip.eq(vip), + teleport_rocks::place.eq(place as i16), + teleport_rocks::map_id.eq(map), + ) + }) + }) + .collect(); + diesel::insert_into(teleport_rocks::table) + .values(&rows) + .execute(conn)?; + Ok(()) +} + /// Stats and looks for every character on an account, for the character list. pub fn load_character_entries(account_id: i32) -> crate::Result> { let mut conn = crate::connection()?; diff --git a/db/src/schema.rs b/db/src/schema.rs index 74208c891..d869021e4 100644 --- a/db/src/schema.rs +++ b/db/src/schema.rs @@ -232,6 +232,15 @@ diesel::table! { } } diesel::joinable!(monster_book_cards -> characters (character_id)); +diesel::table! { + teleport_rocks (character_id, vip, place) { + character_id -> Int4, + vip -> Bool, + place -> Int2, + map_id -> Int4, + } +} +diesel::joinable!(teleport_rocks -> characters (character_id)); diesel::table! { fame_log (id) { id -> Int4, @@ -344,6 +353,7 @@ diesel::allow_tables_to_appear_in_same_query!( cash_sales, wish_lists, monster_book_cards, + teleport_rocks, fame_log, storage_items, storage_equipment_stats, diff --git a/game-data/src/lib.rs b/game-data/src/lib.rs index 52fe696bb..59d9c2d2c 100644 --- a/game-data/src/lib.rs +++ b/game-data/src/lib.rs @@ -20,8 +20,14 @@ pub const NO_DESTINATION_MAP: i32 = 999_999_999; pub const PORTAL_TYPE_START_POINT: i32 = 0; /// `pt` of a town's door portals (`tp`), where Mystic Doors stand. pub const PORTAL_TYPE_TOWN_DOOR: i32 = 6; +/// `info/fieldLimit` bit forbidding movement skills; teleport rocks can't +/// be used there either (v83 FUN_00a0a4aa). +pub const FIELD_LIMIT_MOVEMENT_SKILLS: i32 = 0x2; /// `info/fieldLimit` bit forbidding Mystic Door (v83 FUN_00969bf9). pub const FIELD_LIMIT_MYSTIC_DOOR: i32 = 0x8; +/// `info/fieldLimit` bit keeping teleport rocks from the map, to or from it +/// (v83 FUN_00a0a4aa). +pub const FIELD_LIMIT_TELEPORT_ROCK: i32 = 0x40; /// `info/fieldLimit` bit leaving only the map's `info/allowedItem` usable /// (v83 FUN_00a092fb). pub const FIELD_LIMIT_CONSUMABLES: i32 = 0x400; diff --git a/game/src/character.rs b/game/src/character.rs index c333a5241..bab432599 100644 --- a/game/src/character.rs +++ b/game/src/character.rs @@ -6,6 +6,7 @@ use crate::monster_book::MonsterBook; use crate::pet::{FieldPet, FOLLOW_THE_LEAD}; use crate::quest::QuestLog; use crate::skill::{self, SkillBook, SkillRecord}; +use crate::teleport_rock::TeleportRocks; use std::collections::BTreeMap; /// The most STR, DEX, INT, or LUK AP can raise a stat to. @@ -184,6 +185,8 @@ pub struct Player { pub pets_named: Vec, /// The note on the chalkboard the player holds up, if any; not saved. pub chalkboard: Option, + /// The maps kept for the teleport rocks. + pub teleport_rocks: TeleportRocks, dirty: bool, } @@ -216,6 +219,7 @@ impl Player { pets: Vec::new(), pets_named: Vec::new(), chalkboard: None, + teleport_rocks: TeleportRocks::default(), dirty: false, } } diff --git a/game/src/lib.rs b/game/src/lib.rs index b6108f2cb..7d254bfb8 100644 --- a/game/src/lib.rs +++ b/game/src/lib.rs @@ -19,6 +19,7 @@ pub mod quest; pub mod scroll; pub mod skill; pub mod storage; +pub mod teleport_rock; pub use buff::{Buff, BuffStat, Buffs, Disease, ShownBuff}; pub use character::{ApStat, AvatarLook, CharacterStats, FieldAvatar, MiniRoomBox, Player}; @@ -31,3 +32,4 @@ pub use pet::{FieldPet, PetCommand, PetData}; pub use quest::{QuestLog, QuestRecord, QuestState, QuestStatus}; pub use skill::{SkillBook, SkillRecord}; pub use storage::Storage; +pub use teleport_rock::TeleportRocks; diff --git a/game/src/teleport_rock.rs b/game/src/teleport_rock.rs new file mode 100644 index 000000000..46ef06d51 --- /dev/null +++ b/game/src/teleport_rock.rs @@ -0,0 +1,120 @@ +//! The maps a character keeps for their teleport rocks: five for the +//! regular rock (504000x) and ten for the VIP one (5041000). The v83 client +//! holds each list as fixed slots, the empty ones `EMPTY` (report 179, 4). + +/// The map id of an empty slot. +pub const EMPTY: i32 = 999_999_999; + +/// Whether `item_id` is a VIP rock, which keeps ten maps and crosses +/// continents (v83 FUN_00a0a63f: `item_id / 1000 != 5040`). +pub fn is_vip(item_id: i32) -> bool { + item_id / 1000 != 5040 +} + +/// How many maps a list keeps. +pub fn capacity(vip: bool) -> usize { + if vip { + 10 + } else { + 5 + } +} + +/// Whether the client lets a player keep `map_id`: not Maple Island and +/// not an event map (v83 FUN_0083a084). +pub fn may_keep(map_id: i32) -> bool { + map_id >= 100_000_000 && (map_id / 1_000_000) % 100 != 9 +} + +/// The continent of `map_id`, which a regular rock can't leave. +pub fn continent(map_id: i32) -> i32 { + map_id / 100_000_000 +} + +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct TeleportRocks { + regular: Vec, + vip: Vec, +} + +impl TeleportRocks { + /// The lists as saved, cut to their capacity. + pub fn new(mut regular: Vec, mut vip: Vec) -> Self { + regular.truncate(capacity(false)); + vip.truncate(capacity(true)); + Self { regular, vip } + } + + /// The maps kept in a list, in order. + pub fn maps(&self, vip: bool) -> &[i32] { + if vip { + &self.vip + } else { + &self.regular + } + } + + /// Every slot of a list, as the client holds it. + pub fn slots(&self, vip: bool) -> impl Iterator + '_ { + let maps = self.maps(vip); + (0..capacity(vip)).map(move |i| maps.get(i).copied().unwrap_or(EMPTY)) + } + + /// Keep `map_id` at the end of a list; false if it is full or already + /// keeps it. + pub fn keep(&mut self, vip: bool, map_id: i32) -> bool { + let maps = if vip { + &mut self.vip + } else { + &mut self.regular + }; + if maps.len() >= capacity(vip) || maps.contains(&map_id) { + return false; + } + maps.push(map_id); + true + } + + /// Drop `map_id` from a list; the ones after it move up. + pub fn remove(&mut self, vip: bool, map_id: i32) { + let maps = if vip { + &mut self.vip + } else { + &mut self.regular + }; + maps.retain(|&kept| kept != map_id); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn a_list_fills_up_and_closes_up() { + let mut rocks = TeleportRocks::default(); + for map in 0..5 { + assert!(rocks.keep(false, 100_000_000 + map)); + } + assert!(!rocks.keep(false, 200_000_000), "full"); + assert!(rocks.keep(true, 200_000_000), "the VIP list is apart"); + assert!(!rocks.keep(true, 200_000_000), "kept already"); + rocks.remove(false, 100_000_001); + assert_eq!( + rocks.slots(false).collect::>(), + [100_000_000, 100_000_002, 100_000_003, 100_000_004, EMPTY] + ); + assert_eq!(rocks.slots(true).count(), 10); + } + + #[test] + fn which_rocks_and_maps() { + assert!(!is_vip(5_040_000)); + assert!(!is_vip(5_040_001)); + assert!(is_vip(5_041_000)); + assert!(may_keep(100_000_000)); + assert!(!may_keep(40_000), "Maple Island"); + assert!(!may_keep(109_010_000), "an event map"); + assert_eq!(continent(220_000_000), 2); + } +} diff --git a/integration-harness/tests/teleport_rocks.rs b/integration-harness/tests/teleport_rocks.rs new file mode 100644 index 000000000..7e0d79f29 --- /dev/null +++ b/integration-harness/tests/teleport_rocks.rs @@ -0,0 +1,129 @@ +//! Teleport rocks: the player keeps maps in a list, and a rock takes them to +//! one, or to a character on the channel, one rock per trip. The list +//! outlasts a relog. + +use integration_harness::connection::MapleTestConnection; +use integration_harness::database::grant_stack; +use integration_harness::preconditions::load_harness_config_or_fail; +use integration_harness::trace::{read, send, until, warp, Trace}; +use integration_harness::{login_to_world_session, relogin}; +use net::packet::op::{RecvOpcode, SendOpcode}; +use packet::io::write::PktWrite; +use packet::Packet; + +const CASH: i16 = 5; +const ROCK: i32 = 5_040_000; +const ROCKS: i16 = 5; +const HENESYS: i32 = 100_000_000; +const ELLINIA: i32 = 101_000_000; +const EMPTY: i32 = 999_999_999; + +/// Read until a packet with `op`, and return its body. +async fn next(c: &mut MapleTestConnection, t: &mut Trace, op: SendOpcode) -> Vec { + let op = op as i16; + loop { + let p = read(c, t).await; + if p.opcode() == op { + return p.bytes[2..].to_vec(); + } + } +} + +/// Read until the next map change, and return the map. +async fn arrival(c: &mut MapleTestConnection, t: &mut Trace) -> i32 { + let before = t.warps.len(); + until(c, t, |t| t.warps.len() > before).await; + t.warps[before] +} + +/// Keep the player's map in the regular list. +fn keep() -> Packet { + let mut p = Packet::new_empty(); + p.write_short(RecvOpcode::TeleportRockList as i16).unwrap(); + p.write_byte(1).unwrap(); + p.write_byte(0).unwrap(); + p +} + +/// Use the regular rock in Cash slot 1 to go to `to`: a map or a name. +fn use_rock(to: Result) -> Packet { + let mut p = Packet::new_empty(); + p.write_short(RecvOpcode::UseCashItem as i16).unwrap(); + p.write_short(1).unwrap(); + p.write_int(ROCK).unwrap(); + match to { + Ok(map_id) => { + p.write_byte(0).unwrap(); + p.write_int(map_id).unwrap(); + } + Err(name) => { + p.write_byte(1).unwrap(); + p.write_str_with_length(name).unwrap(); + } + } + p.write_int(0).unwrap(); + p +} + +/// Read a refusal, and the empty stat change that unlocks the client. +async fn refused(c: &mut MapleTestConnection, t: &mut Trace) -> u8 { + let refusal = next(c, t, SendOpcode::MapTransferResult).await; + assert_eq!(refusal.len(), 2, "a refusal and the list it is about"); + let unlocked = next(c, t, SendOpcode::StatChange).await; + assert_eq!(unlocked[0], 1, "an empty stat change unlocks the client"); + refusal[0] +} + +#[tokio::test] +async fn a_teleport_rock_goes_to_kept_maps_and_characters() { + let config = load_harness_config_or_fail().await; + let id = login_to_world_session(&config) + .await + .expect("traveller login failed") + .character_id; + tokio::time::sleep(std::time::Duration::from_millis(500)).await; + grant_stack(id, CASH, 1, ROCK, ROCKS) + .await + .expect("grant teleport rocks"); + let mut traveller = relogin(&config).await; + let watcher_config = load_harness_config_or_fail().await; + let mut watcher = login_to_world_session(&watcher_config).await.unwrap(); + let (h, mut ht) = (&mut traveller.connection, Trace::default()); + let (w, mut wt) = (&mut watcher.connection, Trace::default()); + + // Henesys is kept, and the list comes back. + assert_eq!(warp(h, HENESYS, "", &mut ht).await, HENESYS); + send(h, keep()).await; + let mut list = vec![3, 0]; + list.extend(HENESYS.to_le_bytes()); + for _ in 0..4 { + list.extend(EMPTY.to_le_bytes()); + } + assert_eq!(next(h, &mut ht, SendOpcode::MapTransferResult).await, list); + + // From Ellinia a rock takes the traveller back, and is used up. + assert_eq!(warp(h, ELLINIA, "", &mut ht).await, ELLINIA); + send(h, use_rock(Ok(HENESYS))).await; + assert_eq!(arrival(h, &mut ht).await, HENESYS); + assert_eq!(ht.quantities.last(), Some(&(CASH as u8, 1, ROCKS - 1))); + + // Not to the map they are on, nor to a character who isn't there. + send(h, use_rock(Ok(HENESYS))).await; + assert_eq!(refused(h, &mut ht).await, 9); + send(h, use_rock(Err("Nobody"))).await; + assert_eq!(refused(h, &mut ht).await, 6); + + // To a character on the channel. + assert_eq!(warp(w, ELLINIA, "", &mut wt).await, ELLINIA); + send(h, use_rock(Err(&watcher.character_name))).await; + assert_eq!(arrival(h, &mut ht).await, ELLINIA); + assert_eq!(ht.quantities.last(), Some(&(CASH as u8, 1, ROCKS - 2))); + + // The list outlasts a relog. + drop(traveller); + tokio::time::sleep(std::time::Duration::from_millis(500)).await; + let mut traveller = relogin(&config).await; + let (h, mut ht) = (&mut traveller.connection, Trace::default()); + send(h, use_rock(Ok(HENESYS))).await; + assert_eq!(arrival(h, &mut ht).await, HENESYS); +} diff --git a/net/src/codec/character.rs b/net/src/codec/character.rs index 2207c1c15..d025d6f72 100644 --- a/net/src/codec/character.rs +++ b/net/src/codec/character.rs @@ -6,11 +6,6 @@ use game::quest::encode_kills; use game::Player; use packet::{io::write::PktWrite, Packet}; -/// Teleport rock slots hold this map id when empty. -const EMPTY_TELEPORT_ROCK: i32 = 999_999_999; -const REGULAR_TELEPORT_ROCKS: usize = 5; -const VIP_TELEPORT_ROCKS: usize = 10; - /// Write the full character data block sent on world entry /// (`addCharacterInfo` in HeavenMS). pub fn encode_character_data(packet: &mut Packet, player: &Player) -> Result<(), NetworkError> { @@ -51,8 +46,10 @@ pub fn encode_character_data(packet: &mut Packet, player: &Player) -> Result<(), packet.write_short(0)?; // crush rings packet.write_short(0)?; // friendship rings packet.write_short(0)?; // marriage - for _ in 0..REGULAR_TELEPORT_ROCKS + VIP_TELEPORT_ROCKS { - packet.write_int(EMPTY_TELEPORT_ROCK)?; + for vip in [false, true] { + for map_id in player.teleport_rocks.slots(vip) { + packet.write_int(map_id)?; + } } packet.write_int(player.monster_book.cover())?; packet.write_byte(0)?; diff --git a/net/src/handler.rs b/net/src/handler.rs index f33b1bf9d..d8b8f2f41 100644 --- a/net/src/handler.rs +++ b/net/src/handler.rs @@ -341,6 +341,12 @@ pub enum WorldAction { /// already changed); refresh their field avatar and show the others /// this CHALKBOARD. ChalkboardChanged(Packet), + /// Look on the channel for the character named `name`, to take the + /// player to them with `rock`. + RockToCharacter { + name: String, + rock: crate::packet::handle::world::Rock, + }, /// Natural HP/MP recovery the client reports; the runtime checks timing. NaturalHeal { hp: i16, @@ -668,6 +674,10 @@ fn world_routes() -> &'static HashMap { (Op::PetFood, InGame(&world::PetFoodHandler)), (Op::UseCashItem, InGame(&world::UseCashItemHandler)), (Op::CloseChalkboard, InGame(&world::CloseChalkboardHandler)), + ( + Op::TeleportRockList, + InGame(&world::TeleportRockListHandler), + ), (Op::GiveFame, InGame(&world::GiveFameHandler)), (Op::MoveSummon, InGame(&world::MoveSummonHandler)), (Op::SummonAttack, InGame(&world::SummonAttackHandler)), @@ -706,7 +716,7 @@ mod tests { fn every_registered_opcode_is_unique_per_server() { // The maps would silently drop duplicates; check the table sizes match. assert_eq!(login_routes().len(), 13); - assert_eq!(world_routes().len(), 69); + assert_eq!(world_routes().len(), 70); } #[test] diff --git a/net/src/packet/build/world/map.rs b/net/src/packet/build/world/map.rs index 24f80aab8..e05011320 100644 --- a/net/src/packet/build/world/map.rs +++ b/net/src/packet/build/world/map.rs @@ -1,6 +1,6 @@ use crate::codec::time::filetime; use crate::{error::NetworkError, packet::op::SendOpcode}; -use game::CharacterStats; +use game::{CharacterStats, TeleportRocks}; use packet::{io::write::PktWrite, Packet}; use std::time::SystemTime; @@ -34,6 +34,48 @@ pub fn build_warp_to_map( Ok(packet) } +/// MAP_TRANSFER_RESULT (0x2A, v83 FUN_00a25268) after a teleport rock list +/// changed: the whole list, empty slots and all, which the client keeps and +/// shows in an open rock window. +pub fn build_rock_list( + deleted: bool, + vip: bool, + rocks: &TeleportRocks, +) -> Result { + let mut packet = Packet::new_empty(); + packet.write_short(SendOpcode::MapTransferResult as i16)?; + packet.write_byte(if deleted { 2 } else { 3 })?; + packet.write_byte(u8::from(vip))?; + for map_id in rocks.slots(vip) { + packet.write_int(map_id)?; + } + Ok(packet) +} + +/// Why a teleport rock request was refused: MAP_TRANSFER_RESULT's notices. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum RockRefusal { + /// "%s is currently difficult to locate, so the teleport will not take + /// place.", with the name the player typed. + NotFound = 6, + /// "You cannot go to that place." + CannotGo = 8, + /// "It's the map you're currently on." + SameMap = 9, + /// "This map is not available to enter for the list." + NotForList = 10, + /// "Users below level 7 are not allowed to go out from Maple Island." + MapleIsland = 11, +} + +pub fn build_rock_refusal(why: RockRefusal, vip: bool) -> Result { + let mut packet = Packet::new_empty(); + packet.write_short(SendOpcode::MapTransferResult as i16)?; + packet.write_byte(why as u8)?; + packet.write_byte(u8::from(vip))?; + Ok(packet) +} + /// Why a map change was refused (v83 FUN_0053185c's chat lines and notices). #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum BlockedMap { @@ -90,6 +132,28 @@ mod tests { use crate::codec::stats::sample_stats; use crate::codec::testing::Expect; + #[test] + fn a_rock_list_fills_every_slot() { + let mut rocks = TeleportRocks::default(); + rocks.keep(false, 100_000_000); + let packet = build_rock_list(false, false, &rocks).unwrap(); + let mut expected = vec![0x2A, 0, 3, 0]; + expected.extend(100_000_000i32.to_le_bytes()); + for _ in 0..4 { + expected.extend(999_999_999i32.to_le_bytes()); + } + assert_eq!(packet.bytes, expected); + let packet = build_rock_list(true, true, &rocks).unwrap(); + assert_eq!(packet.bytes[..4], [0x2A, 0, 2, 1]); + assert_eq!(packet.bytes.len(), 4 + 10 * 4); + assert_eq!( + build_rock_refusal(RockRefusal::SameMap, true) + .unwrap() + .bytes, + [0x2A, 0, 9, 1] + ); + } + #[test] fn a_refusal_names_its_reason() { assert_eq!( diff --git a/net/src/packet/handle/world/cash_item.rs b/net/src/packet/handle/world/cash_item.rs index cbe0974e0..cd7bf46db 100644 --- a/net/src/packet/handle/world/cash_item.rs +++ b/net/src/packet/handle/world/cash_item.rs @@ -1,12 +1,13 @@ //! USE_CASH_ITEM (0x4F, FUN_00a0a63f, report 179): an item used from the //! Cash Inventory. Every request is the item's Cash slot and id, then the //! item's own fields and the tick. The client then waits for an answer that -//! unlocks it. Cash pet food, the pet name tag, megaphones and the -//! chalkboard are used so far. +//! unlocks it. Cash pet food, the pet name tag, megaphones, the chalkboard +//! and teleport rocks are used so far. use super::chalkboard::chalkboard; use super::megaphone::megaphone; use super::pet::{cash_pet_food, pet_name_tag}; +use super::teleport_rock::teleport_rock; use crate::handler::{HandlerResult, WorldContext, WorldHandler, WorldResult}; use crate::packet::build::world::stat::build_stat_update; use game::InventoryType; @@ -27,6 +28,7 @@ impl WorldHandler for UseCashItemHandler { .get(InventoryType::Cash, slot) .is_some_and(|item| item.item_id == item_id); match item_id / 10_000 { + 504 if held => teleport_rock(ctx, &mut r, slot, item_id), 507 if held => megaphone(ctx, &mut r, slot, item_id), 517 if held && item_id % 10_000 == 0 => pet_name_tag(ctx, &mut r, slot), 524 if held => cash_pet_food(ctx, slot, item_id), diff --git a/net/src/packet/handle/world/change_map.rs b/net/src/packet/handle/world/change_map.rs index ac97b141e..a24d0ec93 100644 --- a/net/src/packet/handle/world/change_map.rs +++ b/net/src/packet/handle/world/change_map.rs @@ -97,6 +97,16 @@ pub fn warp( let spawn_point = field .portal_by_name(portal) .map_or(0, |portal| u8::try_from(portal.id).unwrap_or(0)); + warp_to_spawn_point(player, map_id, spawn_point, channel_id) +} + +/// Send the player to `map_id`'s portal `spawn_point`. +pub fn warp_to_spawn_point( + player: &mut Player, + map_id: i32, + spawn_point: u8, + channel_id: u8, +) -> Result, NetworkError> { player.move_to(map_id, spawn_point); Ok(vec![ WorldAction::Reply(build::world::map::build_warp_to_map( diff --git a/net/src/packet/handle/world/mod.rs b/net/src/packet/handle/world/mod.rs index 87a452ee7..fc12aead2 100644 --- a/net/src/packet/handle/world/mod.rs +++ b/net/src/packet/handle/world/mod.rs @@ -37,6 +37,7 @@ mod scroll; mod spend_points; mod storage; mod summon; +mod teleport_rock; mod use_item; mod use_skill; mod whisper; @@ -87,6 +88,7 @@ pub use self::storage::TrunkRequestHandler; pub use self::summon::{ BeholderHandler, DamageSummonHandler, MoveSummonHandler, SummonAttackHandler, }; +pub use self::teleport_rock::{to_character, Rock, TeleportRockListHandler, Whereabouts}; pub use self::use_item::{HealOverTimeHandler, UseItemHandler, UseSkillBookHandler}; pub use self::use_skill::{ party_skill_reached, CancelBuffHandler, SkillEffectHandler, ThrowGrenadeHandler, diff --git a/net/src/packet/handle/world/teleport_rock.rs b/net/src/packet/handle/world/teleport_rock.rs new file mode 100644 index 000000000..001cb38cc --- /dev/null +++ b/net/src/packet/handle/world/teleport_rock.rs @@ -0,0 +1,406 @@ +//! Teleport rocks (504xxxx, report 179, 1.8 and 4): the maps kept for them, +//! edited with TROCK_ADD_MAP (0x66), and the rock itself, used through +//! USE_CASH_ITEM to go to a kept map or to a character on the channel. A +//! regular rock keeps five maps and stays on its continent; the VIP rock +//! keeps ten and crosses continents. Each use takes one rock. + +use super::change_map::{warp, warp_to_spawn_point}; +use crate::handler::{HandlerResult, WorldAction, WorldContext, WorldHandler, WorldResult}; +use crate::packet::build::world::inventory::build_inventory_operation; +use crate::packet::build::world::map::{build_rock_list, build_rock_refusal, RockRefusal}; +use crate::packet::build::world::stat::build_stat_update; +use game::teleport_rock::{continent, is_vip, may_keep}; +use game::{InventoryType, Player}; +use game_data::{FIELD_LIMIT_MOVEMENT_SKILLS, FIELD_LIMIT_TELEPORT_ROCK}; +use packet::{io::read::PktRead, Packet}; +use std::io::Cursor; + +/// The level a character must reach to leave Maple Island by rock. +pub const MIN_LEVEL_OFF_ISLAND: u8 = 7; + +/// TROCK_ADD_MAP (0x66, v83 FUN_00a261bc): keep the player's map in a list, +/// or drop one from it. The answer is the new list. +pub struct TeleportRockListHandler; + +impl WorldHandler for TeleportRockListHandler { + fn handle(&self, packet: &mut Packet, ctx: &mut WorldContext) -> WorldResult { + let mut r = Cursor::new(&packet.bytes); + r.read_short()?; + let keep = r.read_byte()? != 0; + let vip = r.read_byte()? != 0; + let player = &mut *ctx.player; + if keep { + let map_id = player.stats.map_id; + if !may_keep(map_id) + || limits(map_id) & FIELD_LIMIT_TELEPORT_ROCK != 0 + || !player.teleport_rocks.keep(vip, map_id) + { + return Ok(HandlerResult::reply(build_rock_refusal( + RockRefusal::NotForList, + vip, + )?)); + } + } else { + let map_id = r.read_int()?; + player.teleport_rocks.remove(vip, map_id); + } + player.mark_dirty(); + Ok(HandlerResult::reply(build_rock_list( + !keep, + vip, + &player.teleport_rocks, + )?)) + } +} + +/// A teleport rock in use, while the server looks for the character it +/// was used to reach. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Rock { + pub slot: i16, + pub item_id: i32, +} + +/// Where the character a rock looks for stands. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct Whereabouts { + pub map_id: i32, + pub x: i16, + pub y: i16, +} + +/// Use the teleport rock `item_id` in Cash `slot`; `r` is past the item id. +/// A character target is looked for first (`WorldAction::RockToCharacter`). +pub fn teleport_rock( + ctx: &mut WorldContext, + r: &mut Cursor<&Vec>, + slot: i16, + item_id: i32, +) -> WorldResult { + let vip = is_vip(item_id); + let rock = Rock { slot, item_id }; + if r.read_byte()? != 0 { + let name = r.read_str_with_length()?; + if name.is_empty() || name.eq_ignore_ascii_case(ctx.player.name()) { + return refuse(RockRefusal::NotFound, vip); + } + return Ok(HandlerResult::empty().with(WorldAction::RockToCharacter { name, rock })); + } + let map_id = r.read_int()?; + if !ctx.player.teleport_rocks.maps(vip).contains(&map_id) { + return refuse(RockRefusal::CannotGo, vip); + } + go(ctx.player, rock, map_id, None, ctx.channel_id) +} + +/// Take the player to the character the rock looked for, found at `found` +/// or not at all. +pub fn to_character( + player: &mut Player, + rock: Rock, + found: Option, + channel_id: u8, +) -> WorldResult { + let vip = is_vip(rock.item_id); + let Some(found) = found else { + return refuse(RockRefusal::NotFound, vip); + }; + let near = crate::game_data::closest_spawn_point(found.map_id, found.x, found.y); + go( + player, + rock, + found.map_id, + Some(near.unwrap_or(0)), + channel_id, + ) +} + +/// Take the player to `map_id` with the rock, at `spawn_point` (the map's +/// first spawn point without one), unless something forbids it. +fn go( + player: &mut Player, + rock: Rock, + map_id: i32, + spawn_point: Option, + channel_id: u8, +) -> WorldResult { + let vip = is_vip(rock.item_id); + if let Some(why) = refusal(player, vip, map_id) { + return refuse(why, vip); + } + let held = player + .inventory + .get(InventoryType::Cash, rock.slot) + .is_some_and(|item| item.item_id == rock.item_id); + if !held { + return Ok(HandlerResult::reply(build_stat_update(true, &[])?)); + } + let Ok(changes) = player + .inventory_mut() + .take(InventoryType::Cash, rock.slot, 1) + else { + return Ok(HandlerResult::reply(build_stat_update(true, &[])?)); + }; + let mut result = HandlerResult::reply(build_inventory_operation(true, &changes)?); + let actions = match spawn_point { + Some(spawn_point) => warp_to_spawn_point(player, map_id, spawn_point, channel_id)?, + None => warp(player, map_id, "sp", channel_id)?, + }; + result.actions.extend(actions); + Ok(result) +} + +/// Why the player can't go to `map_id` by rock, if they can't. +fn refusal(player: &Player, vip: bool, map_id: i32) -> Option { + let here = player.stats.map_id; + let Some(there) = crate::game_data::get() + .ok() + .and_then(|data| data.field(map_id)) + else { + return Some(RockRefusal::CannotGo); + }; + // Maps that send the player elsewhere on login, but Maple Island's. + let held_back = there.forced_return.is_some() && continent(map_id) != 0; + if map_id == here { + Some(RockRefusal::SameMap) + } else if player.stats.hp <= 0 + || limits(here) & (FIELD_LIMIT_TELEPORT_ROCK | FIELD_LIMIT_MOVEMENT_SKILLS) != 0 + { + Some(RockRefusal::CannotGo) + } else if continent(here) == 0 && player.stats.level < MIN_LEVEL_OFF_ISLAND { + Some(RockRefusal::MapleIsland) + } else if (!vip && continent(map_id) != continent(here)) + || there.field_limit & FIELD_LIMIT_TELEPORT_ROCK != 0 + || held_back + { + Some(RockRefusal::CannotGo) + } else { + None + } +} + +/// The map's `info/fieldLimit`. +fn limits(map_id: i32) -> i32 { + crate::game_data::get() + .ok() + .and_then(|data| data.field(map_id)) + .map_or(0, |field| field.field_limit) +} + +/// MAP_TRANSFER_RESULT doesn't unlock the client, so a refusal comes with +/// an empty stat change. +fn refuse(why: RockRefusal, vip: bool) -> WorldResult { + Ok(HandlerResult::reply(build_rock_refusal(why, vip)?) + .with_reply(build_stat_update(true, &[])?)) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::packet::handle::world::UseCashItemHandler; + use crate::packet::op::{RecvOpcode, SendOpcode}; + use game::teleport_rock::EMPTY; + use game::Item; + use packet::io::write::PktWrite; + + const ROCK: i32 = 5040000; + const VIP_ROCK: i32 = 5041000; + const HENESYS: i32 = 100000000; + const ELLINIA: i32 = 101000000; + const ORBIS: i32 = 200000000; + const AMHERST: i32 = 1000000; + + /// A player in Henesys holding three regular rocks in Cash slot 1 and a + /// VIP rock in slot 2. + fn traveller() -> Player { + let mut player = crate::codec::stats::sample_player(); + let cash = &mut player.inventory; + cash.insert(InventoryType::Cash, 1, Item::stack(ROCK, 3)); + cash.insert(InventoryType::Cash, 2, Item::stack(VIP_ROCK, 1)); + player + } + + fn handle(handler: &dyn WorldHandler, player: &mut Player, p: &mut Packet) -> Vec { + let mut ctx = WorldContext { + client_id: 1, + channel_id: 0, + player, + }; + handler.handle(p, &mut ctx).unwrap().actions + } + + fn edit_list(player: &mut Player, vip: bool, dropped: Option) -> Vec { + let mut p = Packet::new_empty(); + p.write_short(RecvOpcode::TeleportRockList as i16).unwrap(); + p.write_byte(u8::from(dropped.is_none())).unwrap(); + p.write_byte(u8::from(vip)).unwrap(); + if let Some(map_id) = dropped { + p.write_int(map_id).unwrap(); + } + handle(&TeleportRockListHandler, player, &mut p) + } + + fn use_rock(player: &mut Player, item_id: i32, to: Result) -> Vec { + let mut p = Packet::new_empty(); + p.write_short(RecvOpcode::UseCashItem as i16).unwrap(); + p.write_short(if is_vip(item_id) { 2 } else { 1 }).unwrap(); + p.write_int(item_id).unwrap(); + match to { + Ok(map_id) => { + p.write_byte(0).unwrap(); + p.write_int(map_id).unwrap(); + } + Err(name) => { + p.write_byte(1).unwrap(); + p.write_str_with_length(name).unwrap(); + } + } + p.write_int(1234).unwrap(); + handle(&UseCashItemHandler, player, &mut p) + } + + fn opcode(packet: &Packet) -> i16 { + i16::from_le_bytes([packet.bytes[0], packet.bytes[1]]) + } + + /// The MAP_TRANSFER_RESULT refusal `why` and the unlock after it. + fn assert_refused(actions: &[WorldAction], why: RockRefusal, vip: bool) { + let [WorldAction::Reply(refusal), WorldAction::Reply(unlock)] = actions else { + panic!("expected a refusal and an unlock: {actions:?}"); + }; + assert_eq!(refusal.bytes, [0x2A, 0, why as u8, u8::from(vip)]); + assert_eq!(opcode(unlock), SendOpcode::StatChange as i16); + } + + #[test] + fn the_players_map_is_kept_and_dropped() { + let mut player = traveller(); + let actions = edit_list(&mut player, false, None); + let [WorldAction::Reply(list)] = &actions[..] else { + panic!("expected the new list: {actions:?}"); + }; + assert_eq!(list.bytes[..8], [0x2A, 0, 3, 0, 0x00, 0xE1, 0xF5, 0x05]); + assert_eq!(player.teleport_rocks.maps(false), [HENESYS]); + assert!(player.is_dirty()); + + let actions = edit_list(&mut player, false, None); + let [WorldAction::Reply(refusal)] = &actions[..] else { + panic!("expected a refusal: {actions:?}"); + }; + assert_eq!(refusal.bytes, [0x2A, 0, 10, 0], "kept already"); + + player.stats.map_id = AMHERST; + let actions = edit_list(&mut player, true, None); + let [WorldAction::Reply(refusal)] = &actions[..] else { + panic!("expected a refusal: {actions:?}"); + }; + assert_eq!(refusal.bytes, [0x2A, 0, 10, 1], "not Maple Island"); + + let actions = edit_list(&mut player, false, Some(HENESYS)); + let [WorldAction::Reply(list)] = &actions[..] else { + panic!("expected the new list: {actions:?}"); + }; + assert_eq!(list.bytes[..4], [0x2A, 0, 2, 0]); + assert_eq!(list.bytes[4..8], EMPTY.to_le_bytes()); + assert!(player.teleport_rocks.maps(false).is_empty()); + } + + #[test] + fn a_rock_goes_only_to_a_kept_map() { + let mut player = traveller(); + let actions = use_rock(&mut player, ROCK, Ok(ELLINIA)); + assert_refused(&actions, RockRefusal::CannotGo, false); + assert_eq!(player.inventory.count(ROCK), 3, "not used"); + } + + #[test] + fn a_rock_takes_the_player_to_a_kept_map() { + if crate::get_game_data().is_err() { + return; + } + let mut player = traveller(); + player.teleport_rocks.keep(false, ELLINIA); + let actions = use_rock(&mut player, ROCK, Ok(ELLINIA)); + let [WorldAction::Reply(taken), WorldAction::Reply(_), WorldAction::ChangeField { map_id, .. }] = + &actions[..] + else { + panic!("expected the rock taken and the warp: {actions:?}"); + }; + assert_eq!(opcode(taken), SendOpcode::InventoryOperation as i16); + assert_eq!(*map_id, ELLINIA); + assert_eq!(player.stats.map_id, ELLINIA); + assert_eq!(player.inventory.count(ROCK), 2); + } + + #[test] + fn a_rock_is_refused_where_it_cannot_go() { + if crate::get_game_data().is_err() { + return; + } + let mut player = traveller(); + player.teleport_rocks.keep(false, HENESYS); + player.teleport_rocks.keep(false, ORBIS); + player.teleport_rocks.keep(true, ORBIS); + let same_map = use_rock(&mut player, ROCK, Ok(HENESYS)); + assert_refused(&same_map, RockRefusal::SameMap, false); + let overseas = use_rock(&mut player, ROCK, Ok(ORBIS)); + assert_refused(&overseas, RockRefusal::CannotGo, false); + player.stats.hp = 0; + let dead = use_rock(&mut player, VIP_ROCK, Ok(ORBIS)); + assert_refused(&dead, RockRefusal::CannotGo, true); + player.stats.hp = 100; + player.stats.map_id = AMHERST; + player.stats.level = MIN_LEVEL_OFF_ISLAND - 1; + let young = use_rock(&mut player, VIP_ROCK, Ok(ORBIS)); + assert_refused(&young, RockRefusal::MapleIsland, true); + assert_eq!(player.inventory.count(ROCK), 3, "not used"); + assert_eq!(player.inventory.count(VIP_ROCK), 1, "not used"); + + player.stats.level = MIN_LEVEL_OFF_ISLAND; + use_rock(&mut player, VIP_ROCK, Ok(ORBIS)); + assert_eq!(player.stats.map_id, ORBIS, "the VIP rock crosses the sea"); + assert_eq!(player.inventory.count(VIP_ROCK), 0); + } + + #[test] + fn a_rock_looks_for_a_character_by_name() { + let mut player = traveller(); + let actions = use_rock(&mut player, VIP_ROCK, Err("Friend")); + let [WorldAction::RockToCharacter { name, rock }] = &actions[..] else { + panic!("expected a search for the character: {actions:?}"); + }; + assert_eq!(name, "Friend"); + assert_eq!( + *rock, + Rock { + slot: 2, + item_id: VIP_ROCK + } + ); + let own = use_rock(&mut player, VIP_ROCK, Err("tester")); + assert_refused(&own, RockRefusal::NotFound, true); + let lost = to_character(&mut player, rock.clone(), None, 0).unwrap(); + assert_refused(&lost.actions, RockRefusal::NotFound, true); + assert_eq!(player.inventory.count(VIP_ROCK), 1, "not used"); + } + + #[test] + fn a_rock_takes_the_player_to_a_character() { + if crate::get_game_data().is_err() { + return; + } + let mut player = traveller(); + let rock = Rock { + slot: 1, + item_id: ROCK, + }; + let found = Whereabouts { + map_id: ELLINIA, + x: 0, + y: 0, + }; + to_character(&mut player, rock, Some(found), 0).unwrap(); + assert_eq!(player.stats.map_id, ELLINIA); + assert_eq!(player.inventory.count(ROCK), 2); + } +} diff --git a/net/src/packet/op/recv.rs b/net/src/packet/op/recv.rs index 59ff23c6d..1a28a2cb9 100644 --- a/net/src/packet/op/recv.rs +++ b/net/src/packet/op/recv.rs @@ -75,6 +75,7 @@ pub enum RecvOpcode { CharInfoRequest = 0x61, SpawnPet = 0x62, ChangeMapSpecial = 0x64, + TeleportRockList = 0x66, QuestAction = 0x6B, ThrowGrenade = 0x6D, diff --git a/net/src/packet/op/send.rs b/net/src/packet/op/send.rs index f8355aac3..58deac6b3 100644 --- a/net/src/packet/op/send.rs +++ b/net/src/packet/op/send.rs @@ -22,6 +22,7 @@ pub enum SendOpcode { UpdateSkills = 0x24, FameResponse = 0x26, ShowStatusInfo = 0x27, + MapTransferResult = 0x2A, SkillBookResult = 0x33, GatherItemResult = 0x34, SortItemResult = 0x35, diff --git a/runtime/src/actor/client.rs b/runtime/src/actor/client.rs index 20d73caa9..2480853db 100644 --- a/runtime/src/actor/client.rs +++ b/runtime/src/actor/client.rs @@ -34,6 +34,7 @@ use net::packet::build; use net::packet::build::world::field::{build_chalkboard, parse_movement_state}; use net::packet::build::world::messaging::{build_find_result, FindLocation}; use net::packet::build::world::pet::PetGone; +use net::packet::handle::world::{to_character, Rock, Whereabouts}; use net::packet::op::RecvOpcode; use net::quests::QuestRequest; use net::{ClientId, SpawnPosition, WorldAction, WorldContext, WorldRoute}; @@ -428,6 +429,14 @@ impl ClientActor { WorldAction::GiveFame { target, up } => self.give_fame(target, up).await?, WorldAction::OpenDoor { town, at, lasts } => self.open_door(town, at, lasts).await?, WorldAction::UseDoor { owner, from_town } => self.use_door(owner, from_town).await?, + WorldAction::RockToCharacter { name, rock } => { + self.send_to_directory(DirectoryMessage::RockLocate { + from: self.client_id, + name, + rock, + }) + .await? + } WorldAction::Find { target_name } => { self.send_to_directory(DirectoryMessage::Find { from: self.client_id, @@ -1042,6 +1051,19 @@ impl ClientActor { } => self.drop_reactor_loot(reactor_id, object_id, x, y).await?, ServerMessage::PickedUp { drop } => self.picked_up(drop).await?, ServerMessage::Locate { reply_to } => self.locate(&reply_to), + ServerMessage::RockLocate { reply_to, rock } => { + let found = self + .game + .as_ref() + .filter(|_| !self.in_cash_shop) + .map(|game| Whereabouts { + map_id: game.player.stats.map_id, + x: game.position.0, + y: game.position.1, + }); + reply_to.send(ServerMessage::RockLocated { rock, found }); + } + ServerMessage::RockLocated { rock, found } => self.rock_located(rock, found).await?, ServerMessage::CharInfoRequested { requester } => { let packet = build::world::info::build_character_info(&self.in_game()?.player) .map_err(|e| RuntimeError::Handler(e.to_string()))?; @@ -1077,6 +1099,23 @@ impl ClientActor { } /// Tell someone who looked for this player which map they are on. + /// Take the player to the character their teleport rock looked for. + async fn rock_located( + &mut self, + rock: Rock, + found: Option, + ) -> Result<(), RuntimeError> { + let channel_id = self.channel_id; + let Some(game) = self.game.as_mut() else { + return Ok(()); + }; + let result = to_character(&mut game.player, rock, found, channel_id)?; + for action in result.actions { + self.apply(action).await?; + } + Ok(()) + } + fn locate(&self, reply_to: &ClientHandle) { let Some(game) = self.game.as_ref() else { return; diff --git a/runtime/src/actor/directory.rs b/runtime/src/actor/directory.rs index b52b14f8b..5510d9fff 100644 --- a/runtime/src/actor/directory.rs +++ b/runtime/src/actor/directory.rs @@ -17,7 +17,7 @@ use net::packet::build::world::party::{ build_party_member_hp, build_party_status, build_party_update, Departure, PartyMemberView, PartyStatus, PartyView, }; -use net::packet::handle::world::DoorExit; +use net::packet::handle::world::{DoorExit, Rock}; use net::{BroadcastScope, ClientId, MessengerRequest, PartyRequest, PartySkill, WhisperDelivery}; use packet::Packet; use std::collections::HashMap; @@ -117,6 +117,9 @@ impl WorldDirectory { } => self.update_presence(client_id, presence), DirectoryMessage::Whisper { from, delivery } => self.whisper(from, delivery), DirectoryMessage::Find { from, target_name } => self.find(from, &target_name), + DirectoryMessage::RockLocate { from, name, rock } => { + self.rock_locate(from, &name, rock) + } DirectoryMessage::Broadcast { from, scope, @@ -307,6 +310,28 @@ impl WorldDirectory { } } + /// Ask the character named `name` where they stand, if they are on the + /// asker's channel; otherwise tell the asker they weren't found. + fn rock_locate(&self, from: ClientId, name: &str, rock: Rock) { + let Some(asker) = self.clients.get(&from) else { + return; + }; + let target = self + .names + .get(name) + .and_then(|id| self.clients.get(id)) + .filter(|target| target.channel_id == asker.channel_id); + match target { + Some(target) => target.handle.send(ServerMessage::RockLocate { + reply_to: asker.handle.clone(), + rock, + }), + None => asker + .handle + .send(ServerMessage::RockLocated { rock, found: None }), + }; + } + fn messenger(&mut self, from: ClientId, request: MessengerRequest) { let Some(sender) = self.clients.get(&from) else { return; diff --git a/runtime/src/message.rs b/runtime/src/message.rs index 0fbd8bd93..33d23362e 100644 --- a/runtime/src/message.rs +++ b/runtime/src/message.rs @@ -15,6 +15,7 @@ use game::{AvatarLook, FieldAvatar, Guild, Player}; use net::packet::build::world::door::DoorView; use net::packet::build::world::guild::InviteRefusal; +use net::packet::handle::world::{Rock, Whereabouts}; use net::{ BroadcastScope, ClientId, FieldRequest, MessengerRequest, PartyRequest, PartySkill, WhisperDelivery, @@ -104,6 +105,18 @@ pub enum ServerMessage { Locate { reply_to: ClientHandle, }, + /// Someone on this channel used `rock` to reach this player; tell them + /// where this player stands. + RockLocate { + reply_to: ClientHandle, + rock: Rock, + }, + /// Where the character this player used `rock` to reach stands, if they + /// are on the channel. + RockLocated { + rock: Rock, + found: Option, + }, /// `requester` (maybe this player) double-clicked this player; send them /// CHAR_INFO. CharInfoRequested { @@ -392,6 +405,13 @@ pub enum DirectoryMessage { from: ClientId, target_name: String, }, + /// Find the character named `name` on the sender's channel, to take the + /// sender to them with `rock`. + RockLocate { + from: ClientId, + name: String, + rock: Rock, + }, Broadcast { from: ClientId, scope: BroadcastScope, From 1603de8c50e118f283f5c93782110d175256b993 Mon Sep 17 00:00:00 2001 From: David Goldstein Date: Sun, 18 Oct 2026 09:47:42 -0400 Subject: [PATCH 07/11] L179: show megaphone lines in the chat log HeavenClient now reads BROADCAST_MSG types 2, 3, 8, 9 and 10 as v83 does and puts each megaphone in the chat log with v83's colours: blue on a pale blue strip for the plain megaphone, purple on pink for super and triple megaphones, brown on yellow for item megaphones. Every row but the plain megaphone's starts with the channel tag from UIWindow.img/Megaphone (ch0 with the ear, ch1 without) and the two-digit channel, so the first row leaves room for it. An item megaphone puts [item name] after the speaker's name, and a triple megaphone's later lines take the first line's name. Megaphones are not heard on event maps. The client still can't use a megaphone; its dialogs come next. --- HeavenClient/CMakeLists.txt | 2 + HeavenClient/Graphics/Color.h | 8 +- HeavenClient/IO/UITypes/UIChatBar.cpp | 86 +++++++++++++++++-- HeavenClient/IO/UITypes/UIChatBar.h | 23 ++++- .../Net/Handlers/MessagingHandlers.cpp | 53 +++++++++++- HeavenClient/Util/Megaphones.h | 47 ++++++++++ HeavenClient/tests/megaphones.cpp | 34 ++++++++ agent_docs/client.md | 2 +- agent_docs/inventory.md | 11 +++ 9 files changed, 250 insertions(+), 16 deletions(-) create mode 100644 HeavenClient/Util/Megaphones.h create mode 100644 HeavenClient/tests/megaphones.cpp diff --git a/HeavenClient/CMakeLists.txt b/HeavenClient/CMakeLists.txt index d2a2ab73b..61fd7e7f2 100644 --- a/HeavenClient/CMakeLists.txt +++ b/HeavenClient/CMakeLists.txt @@ -44,6 +44,8 @@ if(HEAVENCLIENT_BUILD_TESTS) add_test(NAME text_layout COMMAND text_layout_tests) add_executable(chat_wrap_tests tests/chat_wrap.cpp) add_test(NAME chat_wrap COMMAND chat_wrap_tests) + add_executable(megaphones_tests tests/megaphones.cpp) + add_test(NAME megaphones COMMAND megaphones_tests) add_executable(curse_words_tests tests/curse_words.cpp) add_test(NAME curse_words COMMAND curse_words_tests) add_executable(character_name_tests tests/character_name.cpp) diff --git a/HeavenClient/Graphics/Color.h b/HeavenClient/Graphics/Color.h index 3cf797eba..b34d20b5c 100644 --- a/HeavenClient/Graphics/Color.h +++ b/HeavenClient/Graphics/Color.h @@ -123,6 +123,9 @@ namespace ms PRICEPLUM, SEATBLUE, SEATPINK, + MEGAPHONE, + SUPERMEGAPHONE, + ITEMMEGAPHONE, NUM_COLORS }; @@ -195,7 +198,10 @@ namespace ms {0.82f, 0.13f, 0.76f}, // Price pink (#D022C2, v83 font 0x36: prices under 1,000,000,000) {0.65f, 0.00f, 0.43f}, // Price plum (#A6006E, v83 font 0x37: higher prices) {0.00f, 0.40f, 0.60f}, // Seat blue (#006699, v83 font 11: a personal shop's first visitor's chat) - {0.80f, 0.00f, 0.40f} // Seat pink (#CC0066, v83 font 12: the second visitor's chat) + {0.80f, 0.00f, 0.40f}, // Seat pink (#CC0066, v83 font 12: the second visitor's chat) + {0.00f, 0.25f, 0.50f}, // Megaphone (#003F7F, v83 chat font 0xd: megaphones) + {0.47f, 0.00f, 0.26f}, // Super megaphone (#770042, v83 chat font 0xe: super and triple megaphones) + {0.27f, 0.15f, 0.02f} // Item megaphone (#462706, v83 chat font 0xf: item megaphones) }; // Create a color by an array of real numbers [0.0f, 1.0f] diff --git a/HeavenClient/IO/UITypes/UIChatBar.cpp b/HeavenClient/IO/UITypes/UIChatBar.cpp index 572c970d1..16cbff99e 100644 --- a/HeavenClient/IO/UITypes/UIChatBar.cpp +++ b/HeavenClient/IO/UITypes/UIChatBar.cpp @@ -144,8 +144,19 @@ namespace ms // The original fills the whole log with black at half opacity. constexpr float LOG_ALPHA = 0.5f; - // It puts GM chat on a white strip of opacity 0xB0 (FUN_008db070). - constexpr float GM_ROW_ALPHA = 0xB0 / 255.0f; + // It puts GM chat on a white strip of opacity 0xB0, and megaphones on + // strips of their own (FUN_008db070). + constexpr Color GM_STRIP = Color(0xFFFFFFB0); + constexpr Color MEGAPHONE_STRIP = Color(0xCAE7FFB0); + constexpr Color SUPER_MEGAPHONE_STRIP = Color(0xFFBFDDCC); + constexpr Color ITEM_MEGAPHONE_STRIP = Color(0xFFC600DD); + + // A megaphone's channel tag is drawn this far into its first row, + // whose text starts after it (FUN_008dbb93). + constexpr Point TAG_POS = Point(4, 2); + constexpr Point TENS_POS = Point(15, 4); + constexpr Point ONES_POS = Point(20, 4); + constexpr int16_t TAG_WIDTH = 38; // The input field, from the status bar's top left constexpr Rectangle CHATFIELD = Rectangle(87, 530, 5, 25); @@ -187,6 +198,14 @@ namespace ms set_log_height(saved_height); + nl::node megaphone = NxFiles::ui_classic["UIWindow.img"]["Megaphone"]; + + for (size_t i = 0; i < 2; i++) + channel_tags[i] = megaphone["ch" + std::to_string(i)]; + + for (size_t i = 0; i < 10; i++) + channel_digits[i] = megaphone["Num"][std::to_string(i)]; + nl::node base = NxFiles::ui_classic["StatusBar.img"]["base"]; nl::node basic = NxFiles::ui_classic["Basic.img"]; nl::node scrollbar = basic["VScr5"]; @@ -409,11 +428,28 @@ namespace ms int16_t top = logbottom - CHATROWHEIGHT * (i + 1); - if (rowtexts[row].highlighted) - graphics.drawrectangle(position.x(), position.y() + top, LOG_WIDTH - sbwidth, CHATROWHEIGHT, 1.0f, - 1.0f, 1.0f, GM_ROW_ALPHA); + const ChatRow& shown = rowtexts[row]; + const Color& strip = shown.strip; + + if (!strip.invisible()) + graphics.drawrectangle(position.x(), position.y() + top, LOG_WIDTH - sbwidth, CHATROWHEIGHT, strip.r(), + strip.g(), strip.b(), strip.a()); + + int16_t indent = 0; - rowtexts[row].text.draw(position + Point(3, top - TEXT_RAISE)); + if (shown.channel >= 0) + { + // The channel counts from one, always in two digits. + Point origin = position + Point(0, top); + int16_t number = shown.channel + 1; + + channel_tags[shown.ear ? 0 : 1].draw(origin + TAG_POS); + channel_digits[number / 10 % 10].draw(origin + TENS_POS); + channel_digits[number % 10].draw(origin + ONES_POS); + indent = TAG_WIDTH; + } + + shown.text.draw(position + Point(3 + indent, top - TEXT_RAISE)); } Point bar = position + get_scrollbar_pos(); @@ -624,8 +660,19 @@ namespace ms } void UIChatBar::send_chatline(const std::string &line, LineType type) + { + add_line(line, type, -1, false); + } + + void UIChatBar::send_megaphone(const std::string &line, LineType type, int8_t channel, bool ear) + { + add_line(line, type, channel, ear); + } + + void UIChatBar::add_line(const std::string &line, LineType type, int8_t channel, bool ear) { Color::Name color; + Color strip; switch (type) { @@ -658,10 +705,23 @@ namespace ms break; case LineType::GM: color = Color::Name::BLACK; + strip = GM_STRIP; break; case LineType::GUILD: color = Color::Name::MAUVE; break; + case LineType::MEGAPHONE: + color = Color::Name::MEGAPHONE; + strip = MEGAPHONE_STRIP; + break; + case LineType::SUPER_MEGAPHONE: + color = Color::Name::SUPERMEGAPHONE; + strip = SUPER_MEGAPHONE_STRIP; + break; + case LineType::ITEM_MEGAPHONE: + color = Color::Name::ITEMMEGAPHONE; + strip = ITEM_MEGAPHONE_STRIP; + break; default: color = Color::Name::WHITE; break; @@ -670,22 +730,30 @@ namespace ms // The original indents the rows after the first, except for its // notice and error colours. bool indent = type == LineType::UNK0 || type == LineType::WHITE || type == LineType::PARTY || - type == LineType::BUDDY || type == LineType::WHISPER || type == LineType::GM || type == LineType::GUILD; + type == LineType::BUDDY || type == LineType::WHISPER || type == LineType::GM || + type == LineType::GUILD || type >= LineType::MEGAPHONE; int16_t width = WRAP_WIDTH - arrows[Arrow::PREV][0].width(); + // The first row makes room for a channel tag. + int16_t room = channel >= 0 ? width - TAG_WIDTH : width; auto fit = [&](const std::string& text) { Text measured(Text::Font::A12M, Text::Alignment::LEFT, color, text, MEASURE_WIDTH); size_t count = 0; - while (count < text.size() && measured.advance(count + 1) <= width) + while (count < text.size() && measured.advance(count + 1) <= room) count++; + room = width; + return count; }; for (const auto& row : ChatWrap::wrap(line, indent, fit)) - rowtexts.push_back({ Text(Text::Font::A12M, Text::Alignment::LEFT, color, row), type == LineType::GM }); + { + rowtexts.push_back({ Text(Text::Font::A12M, Text::Alignment::LEFT, color, row), strip, channel, ear }); + channel = -1; + } while (rowtexts.size() > MAX_ROWS) rowtexts.pop_front(); diff --git a/HeavenClient/IO/UITypes/UIChatBar.h b/HeavenClient/IO/UITypes/UIChatBar.h index 2d53ea0e4..14117ced1 100644 --- a/HeavenClient/IO/UITypes/UIChatBar.h +++ b/HeavenClient/IO/UITypes/UIChatBar.h @@ -69,7 +69,11 @@ namespace ms // What a GM says nearby GM, // Guild chat - GUILD + GUILD, + // Megaphones (v83 chat types 0xd to 0xf) + MEGAPHONE, + SUPER_MEGAPHONE, + ITEM_MEGAPHONE }; // Who a typed line goes to @@ -103,6 +107,10 @@ namespace ms void send_chatline(const std::string &line, LineType type); + // A megaphone's line, tagged with the speaker's zero-based channel + // and whether they can be whispered back. + void send_megaphone(const std::string &line, LineType type, int8_t channel, bool ear); + // item: the name the line gives in place of %s void display_message(Messages::Type line, UIChatBar::LineType type, const std::string &item = ""); @@ -258,9 +266,18 @@ namespace ms struct ChatRow { Text text; - bool highlighted; + // The strip drawn behind the row, if any + Color strip; + // A megaphone's first row is tagged with the channel, or -1 + int8_t channel; + bool ear; }; - // Oldest first; a wrapped GM row keeps its background when old rows leave. + // Oldest first; a wrapped row keeps its strip when old rows leave. std::deque rowtexts; + // The channel tags, with and without the ear, and their digits + Texture channel_tags[2]; + Texture channel_digits[10]; + + void add_line(const std::string &line, LineType type, int8_t channel, bool ear); }; } diff --git a/HeavenClient/Net/Handlers/MessagingHandlers.cpp b/HeavenClient/Net/Handlers/MessagingHandlers.cpp index 6689f2be0..465021c52 100644 --- a/HeavenClient/Net/Handlers/MessagingHandlers.cpp +++ b/HeavenClient/Net/Handlers/MessagingHandlers.cpp @@ -37,6 +37,7 @@ #include "../../IO/UITypes/UINotice.h" #include "../../IO/UITypes/UIStatusMessenger.h" #include "../../Util/CurseWords.h" +#include "../../Util/Megaphones.h" #include "../../Util/Misc.h" #include @@ -336,6 +337,13 @@ namespace ms std::string message = recv.read_string(); auto chatbar = UI::get().get_element(); + // Megaphones go unheard on event maps. HeavenClient doesn't know + // whether its player is a GM, who would hear them. + bool megaphone = type == 2 || type == 3 || (type >= 8 && type <= 10); + + if (megaphone && !Megaphones::heard_on(Stage::get().get_mapid())) + return; + switch (type) { case 0: @@ -346,10 +354,19 @@ namespace ms case 1: UI::get().emplace(message, [](bool) {}); break; + case 2: + if (chatbar) + chatbar->send_megaphone(message, UIChatBar::LineType::MEGAPHONE, -1, false); + break; case 3: - recv.read_byte(); // channel - recv.read_bool(); // megaphone + { + int8_t channel = recv.read_byte(); + bool ear = recv.read_bool(); + + if (chatbar) + chatbar->send_megaphone(message, UIChatBar::LineType::SUPER_MEGAPHONE, channel, ear); break; + } case 4: UI::get().set_scrollnotice(message); break; @@ -367,6 +384,38 @@ namespace ms case 7: recv.read_int(); // npcid break; + case 8: + case 9: + { + int8_t channel = recv.read_byte(); + bool ear = type == 9 || recv.read_bool(); + + // The item comes last, after its kind; only its name is shown. + if (type == 8 && recv.read_bool()) + { + recv.read_byte(); + message = Megaphones::with_item(message, ItemData::get(recv.read_int()).get_name()); + } + + if (chatbar) + chatbar->send_megaphone(message, UIChatBar::LineType::ITEM_MEGAPHONE, channel, ear); + break; + } + case 10: + { + std::vector lines = { message }; + + for (auto count = recv.read_byte(); count > 1; count--) + lines.push_back(Megaphones::under_name(message, recv.read_string())); + + int8_t channel = recv.read_byte(); + bool ear = recv.read_bool(); + + if (chatbar) + for (const auto& line : lines) + chatbar->send_megaphone(line, UIChatBar::LineType::SUPER_MEGAPHONE, channel, ear); + break; + } } } diff --git a/HeavenClient/Util/Megaphones.h b/HeavenClient/Util/Megaphones.h new file mode 100644 index 000000000..ade7a1659 --- /dev/null +++ b/HeavenClient/Util/Megaphones.h @@ -0,0 +1,47 @@ +#pragma once + +#include +#include + +namespace ms +{ + // How v83 shows the lines megaphones send (BROADCAST_MSG types 2, 3 and + // 8 to 10, FUN_00a22d3a). + namespace Megaphones + { + // What separates the speaker's name from the text. + inline const std::string SEPARATOR = " : "; + + // Megaphones aren't heard on event maps, but by GMs. + inline bool heard_on(int32_t mapid) + { + return (mapid / 1'000'000) % 100 != 9; + } + + // The line with the shown item's name before the text, as + // "Name : [Item]text". + inline std::string with_item(const std::string& line, const std::string& item) + { + size_t split = line.find(SEPARATOR); + + if (split == std::string::npos) + return "[" + item + "]" + line; + + split += SEPARATOR.size(); + + return line.substr(0, split) + "[" + item + "]" + line.substr(split); + } + + // A triple megaphone's later line, under the first line's name. + inline std::string under_name(const std::string& first, const std::string& line) + { + size_t name = first.find(SEPARATOR); + size_t text = line.find(SEPARATOR); + + if (name == std::string::npos || text == std::string::npos) + return line; + + return first.substr(0, name) + line.substr(text); + } + } +} diff --git a/HeavenClient/tests/megaphones.cpp b/HeavenClient/tests/megaphones.cpp new file mode 100644 index 000000000..aebd25d32 --- /dev/null +++ b/HeavenClient/tests/megaphones.cpp @@ -0,0 +1,34 @@ +#include "../Util/Megaphones.h" +#include +#include + +void require(bool condition, const char* message) +{ + if (!condition) throw std::runtime_error(message); +} + +int main() +{ + try + { + using namespace ms::Megaphones; + + require(heard_on(100000000), "megaphones are heard in Henesys"); + require(!heard_on(109010000), "but not on an event map"); + + require(with_item("Hero : selling", "Ilbi") == "Hero : [Ilbi]selling", "the item goes before the text"); + require(with_item("selling", "Ilbi") == "[Ilbi]selling", "even without a name"); + + require(under_name("Hero : one", "Hero : two") == "Hero : two", "a later line keeps the name"); + require(under_name("Hero : one", "Other : two : three") == "Hero : two : three", + "a later line takes the first line's name"); + require(under_name("Hero : one", "two") == "two", "a line without a name stays as it is"); + } + catch (const std::exception& e) + { + std::cerr << "FAIL: " << e.what() << std::endl; + return 1; + } + + return 0; +} diff --git a/agent_docs/client.md b/agent_docs/client.md index af1af45be..3fb1c2139 100644 --- a/agent_docs/client.md +++ b/agent_docs/client.md @@ -466,7 +466,7 @@ side yet. | Skills, stat/AP/SP allocation, buffs, keymap | Implemented | | NPC dialogue and NPC shop | Implemented (`UINpcTalk`, `UIShop`) | | Quest log and markers | Implemented (`UIQuestLog`); progress arrives via SHOW_STATUS_INFO, quest start/complete is driven by NPC dialogue; UPDATE_QUEST_INFO refusals 9–11, 13 and 14 show v83's notices (10 names the full inventories from `Act.img`), 15 the expiry chat line, and the timer modes are ignored | -| Chat, whisper, `/find`, `/?`, `/party`, `/buddy`, `/all`, server messages | Implemented; buddy whisper/find modes ignored; `/w NAME` alone fills in a whisper to NAME, or says "Incorrect ID." for a name no character could have, as v83's `/whisper NAME` does; other commands are dropped, as v83 does for non-GMs | +| Chat, whisper, `/find`, `/?`, `/party`, `/buddy`, `/all`, server messages, megaphone lines | Implemented; megaphones can't be used yet (see `inventory.md`); buddy whisper/find modes ignored; `/w NAME` alone fills in a whisper to NAME, or says "Incorrect ID." for a name no character could have, as v83's `/whisper NAME` does; other commands are dropped, as v83 does for non-GMs | | Character info, minimap, world map | Implemented | | Party (`UIUserList` Party tab, `UIPartyHP`, party chat) | Implemented; see `parties.md` | | Buddy list (`UIUserList` Friend tab, buddy chat) | Implemented without groups; see `buddies.md` | diff --git a/agent_docs/inventory.md b/agent_docs/inventory.md index cbdcc292c..642ad54ca 100644 --- a/agent_docs/inventory.md +++ b/agent_docs/inventory.md @@ -189,6 +189,17 @@ knows every client's channel. One exclusive INVENTORY_OPERATION takes the megaphone first. Anything refused (too long, too low, an item that isn't there, Maple TV) gets only an empty stat change to unlock the client. +HeavenClient shows each line in the chat log (`ServerMessageHandler`, +`UIChatBar::send_megaphone`) as v83 draws it: plain megaphones in blue on a +pale blue strip, super and triple ones in purple on pink, item megaphones in +brown on yellow with `[item name]` after the speaker's name. Every row but +the plain megaphone's starts with the `UIWindow.img/Megaphone` channel tag +(`ch0` with the ear, `ch1` without) and the two-digit channel. A triple +megaphone's later lines take the first line's name. Lines are dropped on +event maps (`Util/Megaphones.h`); v83 shows them there to GMs, but +HeavenClient doesn't know who is a GM. It has no megaphone dialogs yet: the +items can't be used from the Cash tab. + ### Chalkboard A chalkboard (537xxxx, `world/chalkboard.rs`) is used through USE_CASH_ITEM From cdfb6869264b63c83906ae8d1197645ce78f5132 Mon Sep 17 00:00:00 2001 From: David Goldstein Date: Sun, 18 Oct 2026 10:11:32 -0400 Subject: [PATCH 08/11] L179: show chalkboards in HeavenClient The client now draws the board over anyone holding a chalkboard, as v83 does with the ChatBalloon.img adboard style. The note wraps by character at 14 tiles, a one-line note gets an odd tile count so the arrow is centred, and the board floats over the head with its arrow just right of the feet. Another player's note is trimmed, has its curse words masked and is cut to 40 bytes, both when they spawn and when packet 164 changes it. The player's own board has a close button that needs both the press and the release on it, left button only, and an open mini room blocks it. Closing sends packet 50 and says "The chalkboard has closed."; the board stays until the server takes it down. A press whose release lands on a window, or is lost to a focus change, is forgotten, so hovering the button later does not close the board. The board follows the player across map changes, except into a map that allows personal shops, where it is dropped with "You cannot use the chalkboard here." Double-clicking a chalkboard in the Cash tab asks for a note and sends it with the cash item packet. A blank or cancelled note sends nothing. An open mini room answers "Not available", and a personal shop map answers "You can't use it here in this map." --- HeavenClient/CMakeLists.txt | 5 + HeavenClient/Character/ChalkboardBalloon.cpp | 184 ++++++++++++++++++ HeavenClient/Character/ChalkboardBalloon.h | 74 +++++++ HeavenClient/Character/Char.cpp | 29 +++ HeavenClient/Character/Char.h | 13 ++ HeavenClient/Gameplay/Spawn.cpp | 6 +- HeavenClient/Gameplay/Spawn.h | 3 +- HeavenClient/Gameplay/Stage.cpp | 38 ++++ HeavenClient/Gameplay/Stage.h | 3 + HeavenClient/IO/UI.cpp | 10 + HeavenClient/IO/UI.h | 3 + HeavenClient/IO/UITypes/UIItemInventory.cpp | 32 +++ .../Net/Handlers/MapObjectHandlers.cpp | 27 ++- HeavenClient/Net/Handlers/MapObjectHandlers.h | 7 + HeavenClient/Net/OutPacket.h | 1 + HeavenClient/Net/PacketSwitch.cpp | 2 + HeavenClient/Net/Packets/GameplayPackets.h | 8 + HeavenClient/Util/Chalkboard.h | 80 ++++++++ HeavenClient/tests/chalkboard.cpp | 62 ++++++ agent_docs/client.md | 3 +- agent_docs/inventory.md | 26 ++- 21 files changed, 605 insertions(+), 11 deletions(-) create mode 100644 HeavenClient/Character/ChalkboardBalloon.cpp create mode 100644 HeavenClient/Character/ChalkboardBalloon.h create mode 100644 HeavenClient/Util/Chalkboard.h create mode 100644 HeavenClient/tests/chalkboard.cpp diff --git a/HeavenClient/CMakeLists.txt b/HeavenClient/CMakeLists.txt index 61fd7e7f2..70033b2e8 100644 --- a/HeavenClient/CMakeLists.txt +++ b/HeavenClient/CMakeLists.txt @@ -46,6 +46,8 @@ if(HEAVENCLIENT_BUILD_TESTS) add_test(NAME chat_wrap COMMAND chat_wrap_tests) add_executable(megaphones_tests tests/megaphones.cpp) add_test(NAME megaphones COMMAND megaphones_tests) + add_executable(chalkboard_tests tests/chalkboard.cpp) + add_test(NAME chalkboard COMMAND chalkboard_tests) add_executable(curse_words_tests tests/curse_words.cpp) add_test(NAME curse_words COMMAND curse_words_tests) add_executable(character_name_tests tests/character_name.cpp) @@ -295,6 +297,8 @@ add_executable(HeavenClient Character/BuddyList.h Character/Buff.cpp Character/Buff.h + Character/ChalkboardBalloon.cpp + Character/ChalkboardBalloon.h Character/Char.cpp Character/Char.h Character/CharEffect.cpp @@ -748,6 +752,7 @@ add_executable(HeavenClient Template/TimedQueue.h Template/TypeMap.h Util/CharacterName.h + Util/Chalkboard.h Util/ChatWrap.h Util/CurseWords.h Util/HardwareInfo.h diff --git a/HeavenClient/Character/ChalkboardBalloon.cpp b/HeavenClient/Character/ChalkboardBalloon.cpp new file mode 100644 index 000000000..8dc0a492c --- /dev/null +++ b/HeavenClient/Character/ChalkboardBalloon.cpp @@ -0,0 +1,184 @@ +#include "ChalkboardBalloon.h" + +#include "../Util/Chalkboard.h" +#include "../Util/NxFiles.h" + +#include + +#include + +namespace ms +{ + namespace + { + // The board floats this far over the head, its arrow 3 right of the + // feet (FUN_0048e298). + constexpr int16_t GAP = 5; + constexpr int16_t ARROW_SHIFT = 3; + // The close button sits 16 from the board's right and 4 from its top. + constexpr Point CLOSE_OFFSET = Point(-16, 4); + // The balloon font's descent; see ChatBalloon. + constexpr int16_t TEXT_DESCENT = 3; + } + + ChalkboardBalloon::ChalkboardBalloon() : shown(false), closable(false), armed(false), close_state(NORMAL), left(0), + right(0), top(0), bottom(0), tiles(0) + {} + + void ChalkboardBalloon::change(const std::optional ¬e, bool can_close) + { + shown = note.has_value(); + closable = can_close; + armed = false; + close_state = NORMAL; + text = note.value_or(""); + lines.clear(); + + if (!shown) + return; + + nl::node adboard = NxFiles::ui_classic["ChatBalloon.img"]["adboard"]["0"]; + + constexpr const char *names[NUM_PIECES] = { "c", "n", "s", "w", "e", "nw", "ne", "sw", "se", "arrow" }; + + for (size_t i = 0; i < NUM_PIECES; i++) + pieces[i] = adboard[names[i]]; + + nl::node button = NxFiles::ui_classic["Basic.img"]["BtClose"]; + close[NORMAL] = button["normal"]["0"]; + close[MOUSEOVER] = button["mouseOver"]["0"]; + close[PRESSED] = button["pressed"]["0"]; + + // The margins reach as far as the pieces stick out of the inner area. + auto ox = [this](Piece p) { return pieces[p].get_origin().x(); }; + auto oy = [this](Piece p) { return pieces[p].get_origin().y(); }; + auto w = [this](Piece p) { return pieces[p].get_dimensions().x(); }; + auto h = [this](Piece p) { return pieces[p].get_dimensions().y(); }; + + left = std::max({ ox(SOUTHWEST), ox(NORTHWEST), ox(WEST) }); + right = std::max({ w(SOUTHEAST) - ox(SOUTHEAST), w(NORTHEAST) - ox(NORTHEAST), w(EAST) - ox(EAST) }); + top = std::max({ oy(NORTHEAST), oy(NORTHWEST), oy(NORTH) }); + bottom = std::max(h(ARROW) - oy(ARROW), h(SOUTH) - oy(SOUTH)); + + Text measure(Text::Font::BALLOON, Text::Alignment::LEFT, Color::Name::WHITE, "", 0, false); + auto width = [&measure](const std::string &part) + { + measure.change_text(part); + + return measure.width(); + }; + + for (const std::string &line : Chalkboard::wrap(text, width)) + lines.emplace_back(Text::Font::BALLOON, Text::Alignment::LEFT, Color::Name::WHITE, line, 0, false); + + int16_t first_width = lines.empty() ? 0 : lines.front().width(); + tiles = Chalkboard::tiles(lines.size(), first_width); + + int16_t rows = static_cast(lines.size()); + size = Point(Chalkboard::TILE * tiles + left + right, Chalkboard::TILE * rows + top + bottom); + } + + bool ChalkboardBalloon::is_shown() const + { + return shown; + } + + const std::string &ChalkboardBalloon::get_text() const + { + return text; + } + + Point ChalkboardBalloon::top_left(Point feet, int16_t avatar_height) const + { + int16_t inner = Chalkboard::TILE * tiles; + + return feet + Point(ARROW_SHIFT - inner / 2 - right, -size.y() - avatar_height - GAP); + } + + Rectangle ChalkboardBalloon::close_bounds(Point feet, int16_t avatar_height) const + { + Point lt = top_left(feet, avatar_height) + Point(size.x(), 0) + CLOSE_OFFSET; + + return Rectangle(lt, lt + close[NORMAL].get_dimensions()); + } + + void ChalkboardBalloon::draw(Point feet, int16_t avatar_height) const + { + if (!shown) + return; + + // Pieces are placed through their origins, as in the original. + Point origin = top_left(feet, avatar_height); + int16_t inner_right = size.x() - right; + int16_t inner_bottom = size.y() - bottom; + auto at = [origin](int16_t x, int16_t y) { return DrawArgument(origin + Point(x, y)); }; + + for (int16_t i = 0; i < tiles; i++) + { + int16_t x = left + Chalkboard::TILE * i; + + pieces[NORTH].draw(at(x, top)); + pieces[i == tiles / 2 ? ARROW : SOUTH].draw(at(x, inner_bottom)); + } + + for (size_t j = 0; j < lines.size(); j++) + { + int16_t y = top + Chalkboard::TILE * static_cast(j); + + pieces[WEST].draw(at(left, y)); + + for (int16_t k = 0; k < tiles; k++) + pieces[CENTER].draw(at(left + Chalkboard::TILE * k, y)); + + pieces[EAST].draw(at(inner_right, y)); + } + + pieces[NORTHWEST].draw(at(left, top)); + pieces[NORTHEAST].draw(at(inner_right, top)); + pieces[SOUTHWEST].draw(at(left, inner_bottom)); + pieces[SOUTHEAST].draw(at(inner_right, inner_bottom)); + + int16_t inner = Chalkboard::TILE * tiles; + + for (size_t j = 0; j < lines.size(); j++) + { + int16_t x = left + (inner - lines[j].width()) / 2; + int16_t y = top + Chalkboard::TILE * static_cast(j) - TEXT_DESCENT; + + lines[j].draw(at(x, y)); + } + + if (closable) + close[close_state].draw(DrawArgument(close_bounds(feet, avatar_height).get_left_top())); + } + + bool ChalkboardBalloon::on_close(Point feet, int16_t avatar_height, Point cursor) const + { + return shown && closable && close_bounds(feet, avatar_height).contains(cursor); + } + + bool ChalkboardBalloon::send_cursor(Point feet, int16_t avatar_height, Point cursor, bool pressed, + bool held) + { + bool inside = on_close(feet, avatar_height, cursor); + bool closed = false; + + if (pressed) + { + armed = inside; + } else if (!held && armed) + { + armed = false; + closed = inside; + } + + close_state = !inside ? NORMAL : armed && held ? PRESSED : MOUSEOVER; + + return closed; + } + + void ChalkboardBalloon::release_cursor() + { + armed = false; + } +} diff --git a/HeavenClient/Character/ChalkboardBalloon.h b/HeavenClient/Character/ChalkboardBalloon.h new file mode 100644 index 000000000..367c81513 --- /dev/null +++ b/HeavenClient/Character/ChalkboardBalloon.h @@ -0,0 +1,74 @@ +#pragma once + +#include "../Graphics/Text.h" +#include "../Graphics/Texture.h" +#include "../Template/Rectangle.h" + +#include +#include +#include +#include + +namespace ms +{ + // The board over someone using a chalkboard (v83 FUN_004890c4 with the + // ChatBalloon.img "adboard" style): white text on a tiled board that stays + // up until the server takes it down. The player's own board has a close + // button. + class ChalkboardBalloon + { + public: + ChalkboardBalloon(); + + // Shows `text`, or nothing. + void change(const std::optional &text, bool closable); + + bool is_shown() const; + + const std::string &get_text() const; + + void draw(Point feet, int16_t avatar_height) const; + + // Tracks the close button under the cursor. True when a left click on + // it is released over it. + bool send_cursor(Point feet, int16_t avatar_height, Point cursor, bool pressed, bool held); + + // Forgets a press on the close button whose release went elsewhere. + void release_cursor(); + + // Whether the cursor is over the close button. + bool on_close(Point feet, int16_t avatar_height, Point cursor) const; + + private: + Point top_left(Point feet, int16_t avatar_height) const; + + Rectangle close_bounds(Point feet, int16_t avatar_height) const; + + // The board pieces, named in ChatBalloon.img as c, n, s, w, e, nw, ne, sw, se, and arrow. + enum Piece : uint8_t + { + CENTER, NORTH, SOUTH, WEST, EAST, NORTHWEST, NORTHEAST, SOUTHWEST, SOUTHEAST, ARROW, NUM_PIECES + }; + + enum CloseState : uint8_t + { + NORMAL, MOUSEOVER, PRESSED, NUM_STATES + }; + + bool shown; + bool closable; + bool armed; + CloseState close_state; + std::string text; + std::vector lines; + std::array pieces; + std::array close; + // The frame's margins around the inner area. + int16_t left; + int16_t right; + int16_t top; + int16_t bottom; + int16_t tiles; + Point size; + }; +} diff --git a/HeavenClient/Character/Char.cpp b/HeavenClient/Character/Char.cpp index 5a00413b3..276f39c96 100644 --- a/HeavenClient/Character/Char.cpp +++ b/HeavenClient/Character/Char.cpp @@ -120,6 +120,7 @@ namespace ms guildmark.draw(guildpos + Point(-guildlabel.width() / 2, 0) + EMBLEM_OFFSET); } + chalkboard.draw(absp, AVATAR_HEIGHT); chatballoon.draw(absp - Point(0, 85)); hintballoon.draw(absp - Point(0, 85)); // v83 puts a room's balloon in front of the chat balloon. @@ -693,6 +694,34 @@ namespace ms return miniroom.serial_at(feet, AVATAR_HEIGHT, cursor); } + void Char::set_chalkboard(const std::optional &text, bool closable) + { + chalkboard.change(text, closable); + } + + std::optional Char::get_chalkboard() const + { + if (!chalkboard.is_shown()) + return std::nullopt; + + return chalkboard.get_text(); + } + + bool Char::send_chalkboard_cursor(Point feet, Point cursor, bool pressed, bool held) + { + return chalkboard.send_cursor(feet, AVATAR_HEIGHT, cursor, pressed, held); + } + + void Char::release_chalkboard_cursor() + { + chalkboard.release_cursor(); + } + + bool Char::on_chalkboard_close(Point feet, Point cursor) const + { + return chalkboard.on_close(feet, AVATAR_HEIGHT, cursor); + } + CharLook &Char::get_look() { return look; diff --git a/HeavenClient/Character/Char.h b/HeavenClient/Character/Char.h index 3954b7552..f18112e02 100644 --- a/HeavenClient/Character/Char.h +++ b/HeavenClient/Character/Char.h @@ -26,6 +26,7 @@ #include +#include "ChalkboardBalloon.h" #include "CharEffect.h" #include "GuildMark.h" #include "MiniRoomBalloon.h" @@ -242,6 +243,17 @@ namespace ms // on the screen. int32_t mini_room_at(Point feet, Point cursor) const; + // Puts up the character's chalkboard with `text`, or takes it down. + // Only the player's own board can be closed. + void set_chalkboard(const std::optional &text, bool closable); + // The note on the character's chalkboard, if one is up. + std::optional get_chalkboard() const; + // Tracks the chalkboard's close button, with the feet at `feet` on the + // screen. True when it was clicked. + bool send_chalkboard_cursor(Point feet, Point cursor, bool pressed, bool held); + void release_chalkboard_cursor(); + bool on_chalkboard_close(Point feet, Point cursor) const; + // Return if the char is in the Char::State::SIT state bool is_sitting() const; @@ -310,6 +322,7 @@ namespace ms Text namelabel; Text guildlabel; GuildMark guildmark; + ChalkboardBalloon chalkboard; ChatBalloon chatballoon; ChatBalloon hintballoon; MiniRoomBalloon miniroom; diff --git a/HeavenClient/Gameplay/Spawn.cpp b/HeavenClient/Gameplay/Spawn.cpp index 1881ecbf8..c0029657f 100644 --- a/HeavenClient/Gameplay/Spawn.cpp +++ b/HeavenClient/Gameplay/Spawn.cpp @@ -115,8 +115,9 @@ namespace ms CharSpawn::CharSpawn(int32_t c, const LookEntry &lk, uint8_t l, int16_t j, const std::string &nm, int8_t st, Point p, int32_t ch, std::vector> bf, const std::string &gd, const GuildEmblem &em, std::optional rm, - std::vector pt) : cid(c), look(lk), level(l), job(j), name(nm), stance(st), position(p), - chair(ch), buffs(std::move(bf)), guild(gd), emblem(em), room(std::move(rm)), pets(std::move(pt)) + std::vector pt, std::optional cb) : cid(c), look(lk), level(l), job(j), + name(nm), stance(st), position(p), chair(ch), buffs(std::move(bf)), guild(gd), emblem(em), + room(std::move(rm)), pets(std::move(pt)), chalkboard(std::move(cb)) {} int32_t CharSpawn::get_cid() const @@ -132,6 +133,7 @@ namespace ms otherchar->set_guild_name(guild); otherchar->set_guild_emblem(emblem); otherchar->set_mini_room(room); + otherchar->set_chalkboard(chalkboard, false); for (size_t i = 0; i < pets.size(); i++) otherchar->add_pet(static_cast(i), false, pets[i], false); diff --git a/HeavenClient/Gameplay/Spawn.h b/HeavenClient/Gameplay/Spawn.h index c0fc4062b..f47c893af 100644 --- a/HeavenClient/Gameplay/Spawn.h +++ b/HeavenClient/Gameplay/Spawn.h @@ -137,7 +137,7 @@ namespace ms int8_t stance, Point position, int32_t chair, std::vector> buffs, const std::string &guild, const GuildEmblem &emblem, std::optional room = std::nullopt, - std::vector pets = {}); + std::vector pets = {}, std::optional chalkboard = std::nullopt); int32_t get_cid() const; @@ -157,5 +157,6 @@ namespace ms GuildEmblem emblem; std::optional room; std::vector pets; + std::optional chalkboard; }; } \ No newline at end of file diff --git a/HeavenClient/Gameplay/Stage.cpp b/HeavenClient/Gameplay/Stage.cpp index 23f74095b..ece3f1a04 100644 --- a/HeavenClient/Gameplay/Stage.cpp +++ b/HeavenClient/Gameplay/Stage.cpp @@ -31,7 +31,9 @@ #include "../IO/UITypes/UIChatBar.h" #include "../IO/UITypes/UINotice.h" #include "../IO/UITypes/UINpcTalk.h" +#include "../IO/UITypes/UIPersonalShop.h" #include "../IO/UITypes/UIStatusBar.h" +#include "../IO/UITypes/UITradingRoom.h" #include "../Net/Packets/AttackAndSkillPackets.h" #include "../Net/Packets/GameplayPackets.h" @@ -72,6 +74,16 @@ namespace ms pending_clock.reset(); + // The player's chalkboard follows them, but not to a map where + // shops open (FUN_00949513). + if (player.get_chalkboard() && mapinfo.allows_personal_shop()) + { + player.set_chalkboard(std::nullopt, true); + + if (auto chatbar = UI::get().get_element()) + chatbar->send_chatline("You cannot use the chalkboard here.", UIChatBar::LineType::SKILL); + } + // A ship field asks where its ship is (FUN_0054db7a). if (ship.get_kind() >= 0) ContiStateRequestPacket(mapid, static_cast(ship.get_kind())).dispatch(); @@ -793,6 +805,11 @@ namespace ms combat.finish_prepare(); } + void Stage::release_cursor() + { + player.release_chalkboard_cursor(); + } + Cursor::State Stage::send_cursor(bool pressed, Point position) { auto statusbar = UI::get().get_element(); @@ -806,6 +823,27 @@ namespace ms return statusbar->send_cursor(pressed, position); } + // The close button on the player's own chalkboard, which needs both + // the press and the release on it (FUN_0094fa8e). The board stays up + // until the server takes it down. An open mini room blocks it. + auto shop = UI::get().get_element(); + auto trade = UI::get().get_element(); + bool mini_room = (shop && shop->is_active()) || (trade && trade->is_active()); + Point feet = player.get_position() + camera.position(); + + if (!mini_room && player.send_chalkboard_cursor(feet, position, pressed, UI::get().is_cursor_pressed())) + { + CloseChalkboardPacket().dispatch(); + + if (auto chatbar = UI::get().get_element()) + chatbar->send_chatline("The chalkboard has closed.", UIChatBar::LineType::SKILL); + + return Cursor::State::CANCLICK; + } + + if (!mini_room && player.on_chalkboard_close(feet, position)) + return Cursor::State::CANCLICK; + Cursor::State npcstate = npcs.send_cursor(pressed, position, camera.position()); // A shop's balloon shows the house cursor (FUN_00531cc7). diff --git a/HeavenClient/Gameplay/Stage.h b/HeavenClient/Gameplay/Stage.h index 672e12a93..d8e4f3d79 100644 --- a/HeavenClient/Gameplay/Stage.h +++ b/HeavenClient/Gameplay/Stage.h @@ -97,6 +97,9 @@ namespace ms // Lets go of every held key. void release_keys(); + // Lets go of a mouse press, wherever its release went. + void release_cursor(); + // Send mouse input to the stage. Cursor::State send_cursor(bool pressed, Point position); diff --git a/HeavenClient/IO/UI.cpp b/HeavenClient/IO/UI.cpp index c990b912e..a1d88a8f3 100644 --- a/HeavenClient/IO/UI.cpp +++ b/HeavenClient/IO/UI.cpp @@ -140,6 +140,7 @@ namespace ms is_key_down.clear(); Stage::get().release_keys(); + Stage::get().release_cursor(); cursor.set_pressed(false); } } @@ -165,6 +166,10 @@ namespace ms Textfield *focused_before = focusedtextfield.get(); send_cursor(cursorpos, cursorstate); + // A release over a window never reaches the stage. + if (!pressed) + Stage::get().release_cursor(); + if (focusedtextfield && pressed && focusedtextfield.get() == focused_before) { Cursor::State tstate = focusedtextfield->send_cursor(cursorpos, pressed); @@ -183,6 +188,11 @@ namespace ms send_cursor(pos, cursor.get_state()); } + bool UI::is_cursor_pressed() const + { + return cursor.is_pressed(); + } + void UI::rightclick() { Point pos = cursor.get_position(); diff --git a/HeavenClient/IO/UI.h b/HeavenClient/IO/UI.h index bc58ee93b..6325a53fe 100644 --- a/HeavenClient/IO/UI.h +++ b/HeavenClient/IO/UI.h @@ -65,6 +65,9 @@ namespace ms void send_cursor(Point cursorpos, Cursor::State cursorstate); + // Whether the left button is held down. + bool is_cursor_pressed() const; + void send_focus(int focused); void send_scroll(double yoffset); diff --git a/HeavenClient/IO/UITypes/UIItemInventory.cpp b/HeavenClient/IO/UITypes/UIItemInventory.cpp index ddf15427d..ae2e2c59e 100644 --- a/HeavenClient/IO/UITypes/UIItemInventory.cpp +++ b/HeavenClient/IO/UITypes/UIItemInventory.cpp @@ -20,6 +20,7 @@ #include "UIChatBar.h" #include "UINotice.h" #include "UIPersonalShop.h" +#include "UITradingRoom.h" #include "../Messages.h" #include "../UI.h" @@ -34,6 +35,7 @@ #include "../../Data/CurseData.h" #include "../../Data/EquipData.h" #include "../../Gameplay/Stage.h" +#include "../../Util/Chalkboard.h" #include "../../Util/NxFiles.h" #include "../../Net/Handlers/MiniRoom.h" @@ -205,6 +207,34 @@ namespace ms }); } + // v83 FUN_00a0a63f, type 0x20: a chalkboard asks for a note, which + // goes up once the server sends it back. A blank note isn't sent. + void use_chalkboard(int16_t slot, int32_t item_id) + { + if (!Stage::get().get_player().can_request(ExclRequest::WAIT)) + return; + + auto shop = UI::get().get_element(); + auto trade = UI::get().get_element(); + + if ((shop && shop->is_active()) || (trade && trade->is_active())) + { + if (auto chatbar = UI::get().get_element()) + chatbar->send_chatline("Not available", UIChatBar::LineType::SKILL); + } else if (Stage::get().get_mapinfo().allows_personal_shop()) + { + UI::get().emplace("You can't use it here in this map.", [](bool) + {}); + } else + { + UI::get().emplace("Enter a note.", [=](const std::string &text) + { + if (!Chalkboard::trim(text).empty()) + Stage::get().get_player().request(UseCashItemPacket(slot, item_id, text), ExclRequest::WAIT); + }, Chalkboard::MAX_TEXT); + } + } + // v83 FUN_00a0900a: a map may keep items off the ground, which it // says only once the player has answered every question. void send_drop(InventoryType::Id tab, int16_t slot, int16_t qty) @@ -529,6 +559,8 @@ namespace ms feed_cash_food(slot, item_id); else if (pets::is_name_tag(item_id)) name_pet(slot, item_id); + else if (item_id / 10000 == 537) + use_chalkboard(slot, item_id); break; } diff --git a/HeavenClient/Net/Handlers/MapObjectHandlers.cpp b/HeavenClient/Net/Handlers/MapObjectHandlers.cpp index cbe5ab3f2..4a879e42e 100644 --- a/HeavenClient/Net/Handlers/MapObjectHandlers.cpp +++ b/HeavenClient/Net/Handlers/MapObjectHandlers.cpp @@ -25,11 +25,13 @@ #include "Helpers/PetParser.h" #include "MiniRoom.h" +#include "../../Data/CurseData.h" #include "../../Data/SkillData.h" #include "../../Gameplay/Stage.h" #include "../../IO/UI.h" #include "../../IO/UITypes/UIChatBar.h" #include "../../IO/UITypes/UINotice.h" +#include "../../Util/Chalkboard.h" #include "../Packets/GameplayPackets.h" namespace ms @@ -71,17 +73,36 @@ namespace ms std::optional room = mini_room::read_box(recv); - bool chalkboard = recv.read_bool(); - std::string chalktext = chalkboard ? recv.read_string() : ""; + std::optional chalkboard; + + if (recv.read_bool()) + chalkboard = Chalkboard::remote_text(recv.read_string(), CurseData::filter); recv.skip(3); recv.read_byte(); // team Stage::get().get_chars().spawn( - { cid, look, level, job, name, stance, position, chair, std::move(buffs), guild, emblem, room, std::move(pets) } + { cid, look, level, job, name, stance, position, chair, std::move(buffs), guild, emblem, room, std::move(pets), + chalkboard } ); } + void ChalkboardHandler::handle(InPacket& recv) const + { + int32_t cid = recv.read_int(); + std::optional text; + + if (recv.read_bool()) + text = recv.read_string(); + + // Only the player's own board can be closed, and only someone else's + // note is filtered and cut. + if (Stage::get().is_player(cid)) + Stage::get().get_player().set_chalkboard(text ? Chalkboard::trim(*text) : text, true); + else if (Optional other = Stage::get().get_chars().get_char(cid)) + other->set_chalkboard(text ? Chalkboard::remote_text(*text, CurseData::filter) : text, false); + } + void UpdateCharBoxHandler::handle(InPacket& recv) const { int32_t cid = recv.read_int(); diff --git a/HeavenClient/Net/Handlers/MapObjectHandlers.h b/HeavenClient/Net/Handlers/MapObjectHandlers.h index 358974ccc..a0d3a05a4 100644 --- a/HeavenClient/Net/Handlers/MapObjectHandlers.h +++ b/HeavenClient/Net/Handlers/MapObjectHandlers.h @@ -28,6 +28,13 @@ namespace ms void handle(InPacket& recv) const override; }; + // Put up or take down someone's chalkboard (FUN_00937607) + // Opcode: CHALKBOARD(164) + class ChalkboardHandler : public PacketHandler + { + void handle(InPacket& recv) const override; + }; + // Put up, change or take down the balloon over someone keeping a room // open (FUN_00938ba5) // Opcode: UPDATE_CHAR_BOX(165) diff --git a/HeavenClient/Net/OutPacket.h b/HeavenClient/Net/OutPacket.h index 262bf1991..0c5903afa 100644 --- a/HeavenClient/Net/OutPacket.h +++ b/HeavenClient/Net/OutPacket.h @@ -74,6 +74,7 @@ namespace ms /// Messaging GENERAL_CHAT = 49, + CLOSE_CHALKBOARD = 50, MULTICHAT = 119, FACE_EXPRESSION = 51, WHISPER = 120, diff --git a/HeavenClient/Net/PacketSwitch.cpp b/HeavenClient/Net/PacketSwitch.cpp index 854d906a4..f1266db87 100644 --- a/HeavenClient/Net/PacketSwitch.cpp +++ b/HeavenClient/Net/PacketSwitch.cpp @@ -110,6 +110,7 @@ namespace ms /// Messaging CHAT_RECEIVED = 162, + CHALKBOARD = 164, UPDATE_CHAR_BOX = 165, SCROLL_RESULT = 167, @@ -228,6 +229,7 @@ namespace ms // MapObject handlers emplace(); + emplace(); emplace(); emplace(); emplace(); diff --git a/HeavenClient/Net/Packets/GameplayPackets.h b/HeavenClient/Net/Packets/GameplayPackets.h index f24fa239c..ffbdb0cdb 100644 --- a/HeavenClient/Net/Packets/GameplayPackets.h +++ b/HeavenClient/Net/Packets/GameplayPackets.h @@ -331,6 +331,14 @@ namespace ms } }; + // Asks to take the player's chalkboard down + // Opcode: CLOSE_CHALKBOARD(50) + class CloseChalkboardPacket : public OutPacket + { + public: + CloseChalkboardPacket() : OutPacket(OutPacket::Opcode::CLOSE_CHALKBOARD) {} + }; + // Opcode: PARTY_OPERATION(124) class PartyOperationPacket : public OutPacket { diff --git a/HeavenClient/Util/Chalkboard.h b/HeavenClient/Util/Chalkboard.h new file mode 100644 index 000000000..5ec79434a --- /dev/null +++ b/HeavenClient/Util/Chalkboard.h @@ -0,0 +1,80 @@ +#pragma once + +#include +#include +#include + +namespace ms +{ + // How v83 lays out a chalkboard's note (FUN_004890c4 with the + // ChatBalloon.img "adboard" style). + namespace Chalkboard + { + // The longest note another player's board shows. + constexpr size_t MAX_TEXT = 40; + // A line is at most 14 tiles of 14 px. + constexpr int16_t TILE = 14; + constexpr int16_t MAX_TILES = 14; + constexpr int16_t MAX_WIDTH = TILE * MAX_TILES; + + inline const char *const BLANKS = " \t\r\n"; + + inline std::string trim(const std::string &text) + { + size_t first = text.find_first_not_of(BLANKS); + + if (first == std::string::npos) + return ""; + + return text.substr(first, text.find_last_not_of(BLANKS) - first + 1); + } + + // Another player's note is trimmed, has its curse words masked, and is + // cut to 40 bytes. The player's own note is only trimmed. + template + std::string remote_text(const std::string &text, Filter filter) + { + std::string shown = filter(trim(text)); + + if (shown.size() > MAX_TEXT) + shown.resize(MAX_TEXT); + + return shown; + } + + // Wraps by character: each line takes as many characters as fit, loses + // its trailing blanks, and the next line skips its leading ones. + template + std::vector wrap(const std::string &text, Width width, int16_t max_width = MAX_WIDTH) + { + std::vector lines; + size_t start = text.find_first_not_of(BLANKS); + + while (start != std::string::npos) + { + size_t count = 1; + + while (start + count < text.size() && width(text.substr(start, count + 1)) <= max_width) + count++; + + std::string line = text.substr(start, count); + line.erase(line.find_last_not_of(BLANKS) + 1); + lines.push_back(line); + + start = text.find_first_not_of(BLANKS, start + count); + } + + return lines; + } + + // A single line gets an odd number of tiles, so the arrow under the + // middle tile is centred. Longer notes use the full width. + inline int16_t tiles(size_t lines, int16_t first_width) + { + if (lines > 1) + return MAX_TILES; + + return static_cast((first_width + TILE - 1) / TILE) | 1; + } + } +} diff --git a/HeavenClient/tests/chalkboard.cpp b/HeavenClient/tests/chalkboard.cpp new file mode 100644 index 000000000..1bc9be93b --- /dev/null +++ b/HeavenClient/tests/chalkboard.cpp @@ -0,0 +1,62 @@ +#include "../Util/Chalkboard.h" +#include +#include + +void require(bool condition, const char* message) +{ + if (!condition) throw std::runtime_error(message); +} + +int main() +{ + try + { + using namespace ms::Chalkboard; + + require(trim(" \tselling\r\n") == "selling", "a note is trimmed"); + require(trim(" \n ").empty(), "a blank note is empty"); + + auto mask = [](const std::string& text) + { + std::string masked = text; + size_t pos = masked.find("bad"); + + if (pos != std::string::npos) + masked.replace(pos, 3, "***"); + + return masked; + }; + + require(remote_text(" bad deal ", mask) == "*** deal", "a remote note is trimmed and masked"); + require(remote_text(std::string(45, 'a'), mask) == std::string(40, 'a'), "a remote note is cut to 40"); + + // Ten pixels per character, so a 196 px line holds 19 characters. + auto width = [](const std::string& text) { return static_cast(text.size() * 10); }; + + require(wrap("selling", width) == std::vector{ "selling" }, "a short note is one line"); + require(wrap("abcdefghijklmnopqrstuvwxyz", width) + == std::vector{ "abcdefghijklmnopqrs", "tuvwxyz" }, + "a long note wraps by character"); + require(wrap("abcdefghijklmnopq rstuvwxyz", width) + == std::vector{ "abcdefghijklmnopq", "rstuvwxyz" }, + "a line loses its trailing blanks and the next its leading ones"); + require(wrap("", width).empty(), "an empty note has no lines"); + require(wrap("abc", [](const std::string&) { return int16_t(500); }) + == std::vector{ "a", "b", "c" }, + "a line holds at least one character"); + + require(tiles(1, 1) == 1, "a tiny note gets one tile"); + require(tiles(1, 14) == 1, "a full tile is one tile"); + require(tiles(1, 15) == 3, "a single line gets an odd tile count"); + require(tiles(1, 42) == 3, "three full tiles stay three"); + require(tiles(1, 196) == 15, "even a full line rounds up to an odd count"); + require(tiles(2, 30) == 14, "a longer note uses fourteen tiles"); + } + catch (const std::exception& e) + { + std::cerr << "FAIL: " << e.what() << std::endl; + return 1; + } + + return 0; +} diff --git a/agent_docs/client.md b/agent_docs/client.md index 3fb1c2139..856a7e7eb 100644 --- a/agent_docs/client.md +++ b/agent_docs/client.md @@ -476,6 +476,7 @@ side yet. | Cash shop (`UICashShop`) | Enter/exit, catalog, buying, gifts, Cash Inventory, wish list, and moving items to and from the bags | | Maple Messenger (`UIMessenger`) | Implemented | | Trade (`UITradingRoom`, `UIInviteCard`) | Implemented, without cash trades | +| Chalkboards (`ChalkboardBalloon`) | Implemented: the board, the note prompt and the close button; see `inventory.md` | | Storage (`UIStorage`) | Implemented; see `inventory.md` | | Continent ships, station wall clocks | Docking ships and the Balrog's ship, CONTI_MOVE/CONTI_STATE, CLOCK kind 1; see `ships.md` | | Player shops, mini-games, memo/mail | No UI, no handlers | @@ -484,7 +485,7 @@ side yet. | Mounts, summons | Partial or absent | | Party/guild/boss quests, events, MTS | Absent | -81 outbound opcodes are defined (`Net/OutPacket.h`) and 116 inbound handlers +83 outbound opcodes are defined (`Net/OutPacket.h`) and 118 inbound handlers are registered (`Net/PacketSwitch.cpp`). Anything not listed there is silently ignored by the client. ## Movement physics diff --git a/agent_docs/inventory.md b/agent_docs/inventory.md index 642ad54ca..104909f1e 100644 --- a/agent_docs/inventory.md +++ b/agent_docs/inventory.md @@ -213,10 +213,28 @@ later arrivals see it. CLOSE_CHALKBOARD (`0x32`, empty) puts it away with CHALKBOARD `id, 0` to the holder and the map. The board comes along to the next map, where the client actor tells the -holder's client again, as it forgets it on a map change. Entering a map -where shops open (`info/personalShop`, the Free Market rooms) puts it away, -as the v83 client does there; the server refuses a board in those maps too. -Losing the last chalkboard doesn't put the board away yet. +holder's client again (v83 keeps its own board across the change anyway). +Entering a map where shops open (`info/personalShop`, the Free Market rooms) +puts it away, as the v83 client does there; the server refuses a board in +those maps too. Losing the last chalkboard doesn't put the board away yet. +The server doesn't mask curse words in the note: v83 clients star them out of +other players' boards themselves. + +HeavenClient draws the board (`Character/ChalkboardBalloon`, layout in +`Util/Chalkboard.h`) from `ChatBalloon.img/adboard/0` as v83 does: white +text wrapped by character at 196 px, one line on an odd number of 14 px +tiles so the arrow is centred, a longer note on 14 tiles, 5 px over the +head. Another player's note, from CHALKBOARD or SPAWN_PLAYER, is trimmed, +has its curse words starred out and is cut to 40 bytes; the player's own is +only trimmed. The player's own board has the `Basic.img/BtClose` button, +which takes a press and a release on it and does nothing while a mini room +is open. It sends CLOSE_CHALKBOARD and logs "The chalkboard has closed."; +the board stays until CHALKBOARD `id, 0`. The board follows the player to +the next map, except to one where shops open, where it goes away with "You +cannot use the chalkboard here.". Double-clicking a chalkboard in the Cash +tab asks "Enter a note." (40 bytes; a blank note sends nothing), unless a +mini room is open ("Not available") or shops open on the map ("You can't use +it here in this map."). ### Teleport rocks From 14bbeef0c996bcbd695766dae1d570c459420911 Mon Sep 17 00:00:00 2001 From: David Goldstein Date: Sun, 18 Oct 2026 10:42:06 -0400 Subject: [PATCH 09/11] L179: use teleport rocks from HeavenClient Double-clicking a teleport rock in the Cash tab now opens the v83 map list from UIWindow.img/Teleport, centred and modal. It shows the kept maps by name, a linked map under its source's name, five rows at a time with a scroll bar on the VIP rock, and counts them as [used/size]. The client reads both lists from the character data and replaces one when packet 42 answers with result 2 or 3. ADD MAP asks before sending packet 102 for the current map, unless the list is full, already has it, or the map is on Maple Island or an event map. DEL. MAP asks before dropping the selected map. MOVE asks before using the rock on the selected map, or uses it at once on a typed name once the name is valid and isn't the player's own. Enter is MOVE and Escape closes the window, which closes before the rock is used and plays the shop sound once the request goes out. A refusal on packet 42 shows its notice, naming the character for result 6. The rock isn't offered on a map whose fieldLimit forbids teleporting, and like the chalkboard it waits for an earlier request with "Other requests are in process." Notices raised from a window now hand the focus back to it when they close: the game state keeps the windows a focused one opened over, and a dialog under its notice stays modal. The name field gives up the keys for a notice and takes them back afterwards if it had them. --- HeavenClient/CMakeLists.txt | 4 + HeavenClient/Character/TeleportRock.cpp | 90 +++- HeavenClient/Character/TeleportRock.h | 78 +++- HeavenClient/IO/UI.cpp | 2 + HeavenClient/IO/UIElement.h | 1 + HeavenClient/IO/UIStateGame.cpp | 45 +- HeavenClient/IO/UIStateGame.h | 8 + HeavenClient/IO/UITypes/UIItemInventory.cpp | 57 ++- HeavenClient/IO/UITypes/UITeleportRock.cpp | 426 ++++++++++++++++++ HeavenClient/IO/UITypes/UITeleportRock.h | 96 ++++ .../Net/Handlers/Helpers/CharacterParser.cpp | 12 +- .../Net/Handlers/InventoryHandlers.cpp | 28 ++ HeavenClient/Net/Handlers/InventoryHandlers.h | 8 + HeavenClient/Net/OutPacket.h | 1 + HeavenClient/Net/PacketSwitch.cpp | 2 + HeavenClient/Net/Packets/InventoryPackets.h | 48 ++ HeavenClient/tests/teleport_rock.cpp | 69 +++ agent_docs/client.md | 3 +- agent_docs/inventory.md | 19 +- 19 files changed, 969 insertions(+), 28 deletions(-) create mode 100644 HeavenClient/IO/UITypes/UITeleportRock.cpp create mode 100644 HeavenClient/IO/UITypes/UITeleportRock.h create mode 100644 HeavenClient/tests/teleport_rock.cpp diff --git a/HeavenClient/CMakeLists.txt b/HeavenClient/CMakeLists.txt index 70033b2e8..62ff75a29 100644 --- a/HeavenClient/CMakeLists.txt +++ b/HeavenClient/CMakeLists.txt @@ -48,6 +48,8 @@ if(HEAVENCLIENT_BUILD_TESTS) add_test(NAME megaphones COMMAND megaphones_tests) add_executable(chalkboard_tests tests/chalkboard.cpp) add_test(NAME chalkboard COMMAND chalkboard_tests) + add_executable(teleport_rock_tests tests/teleport_rock.cpp Character/TeleportRock.cpp) + add_test(NAME teleport_rock COMMAND teleport_rock_tests) add_executable(curse_words_tests tests/curse_words.cpp) add_test(NAME curse_words COMMAND curse_words_tests) add_executable(character_name_tests tests/character_name.cpp) @@ -600,6 +602,8 @@ add_executable(HeavenClient IO/UITypes/UIShop.h IO/UITypes/UIStorage.cpp IO/UITypes/UIStorage.h + IO/UITypes/UITeleportRock.cpp + IO/UITypes/UITeleportRock.h IO/UITypes/UIInviteCard.cpp IO/UITypes/UIInviteCard.h IO/UITypes/UITradingRoom.cpp diff --git a/HeavenClient/Character/TeleportRock.cpp b/HeavenClient/Character/TeleportRock.cpp index 73f69cb69..fff74fa72 100644 --- a/HeavenClient/Character/TeleportRock.cpp +++ b/HeavenClient/Character/TeleportRock.cpp @@ -17,15 +17,95 @@ ////////////////////////////////////////////////////////////////////////////////// #include "TeleportRock.h" +#include "../Util/CharacterName.h" + +#include + namespace ms { - void TeleportRock::addlocation(int32_t mapid) + std::string TeleportRock::trim(const std::string &name) + { + const char *blanks = " \t\r\n"; + size_t first = name.find_first_not_of(blanks); + + if (first == std::string::npos) + return ""; + + return name.substr(first, name.find_last_not_of(blanks) - first + 1); + } + + std::string TeleportRock::keep_problem(const std::vector &maps, int32_t map_id) + { + if (std::find(maps.begin(), maps.end(), EMPTY) == maps.end()) + return "Your teleport list is full.\\nPlease delete an entry before trying again."; + + if (std::find(maps.begin(), maps.end(), map_id) != maps.end()) + return "You have already entered this map."; + + if (!may_keep(map_id)) + return NOT_FOR_LIST; + + return ""; + } + + std::string TeleportRock::target_problem(const std::string &name, const std::string &own_name) + { + if (!character_name_valid(name)) + return "- The character name is invalid."; + + bool own = std::equal(name.begin(), name.end(), own_name.begin(), own_name.end(), [](char a, char b) + { + return std::tolower(static_cast(a)) == std::tolower(static_cast(b)); + }); + + if (own) + return "You cannot target yourself\\nfor the teleport."; + + return ""; + } + + std::string TeleportRock::refusal(int8_t result, const std::string &target) + { + switch (result) + { + case 5: + case 8: + return "You cannot go to that place."; + case 6: + case 7: + return target + " is currently difficult to locate, so\\nthe teleport will not take place."; + case 9: + return SAME_MAP; + case 10: + return NOT_FOR_LIST; + case 11: + return "Users below level 7 are not allowed \\nto go out from Maple Island."; + default: + return ""; + } + } + + TeleportRock::TeleportRock() : regular(slots(false), EMPTY), vip(slots(true), EMPTY) + {} + + void TeleportRock::set_maps(bool is_vip, std::vector maps) + { + maps.resize(slots(is_vip), EMPTY); + (is_vip ? vip : regular) = std::move(maps); + } + + const std::vector &TeleportRock::get_maps(bool is_vip) const + { + return is_vip ? vip : regular; + } + + void TeleportRock::set_target(const std::string &name) { - locations.push_back(mapid); + target = name; } - void TeleportRock::addviplocation(int32_t mapid) + const std::string &TeleportRock::get_target() const { - viplocations.push_back(mapid); + return target; } -} \ No newline at end of file +} diff --git a/HeavenClient/Character/TeleportRock.h b/HeavenClient/Character/TeleportRock.h index 0cc0aed50..4a8de0ff1 100644 --- a/HeavenClient/Character/TeleportRock.h +++ b/HeavenClient/Character/TeleportRock.h @@ -17,21 +17,87 @@ ////////////////////////////////////////////////////////////////////////////////// #pragma once +#include #include +#include #include namespace ms { - // Saved locations for the 'teleport rock' and 'VIP teleport rock' cash items + // The maps kept for the teleport rocks (v83 CharacterData +0x563 and + // +0x577): five for the regular rock and ten for the VIP one, an empty + // slot holding EMPTY. class TeleportRock { public: - void addlocation(int32_t); + static constexpr int32_t EMPTY = 999999999; - void addviplocation(int32_t); + // MAP_TRANSFER_RESULT's results that carry a list. + static constexpr int8_t DROPPED = 2; + static constexpr int8_t KEPT = 3; + + // StringPool 0xb8b and 0xb88. + static constexpr const char *SAME_MAP = "It's the map you're currently on."; + static constexpr const char *NOT_FOR_LIST = "This map is not available to enter for the list."; + + // 5040000 and 5040001 are regular rocks, 5041000 the VIP one + // (FUN_00a0a63f). + static bool is_vip(int32_t item_id) + { + return item_id / 1000 != 5040; + } + + static size_t slots(bool vip) + { + return vip ? 10 : 5; + } + + // Maple Island and the event maps can't be kept (FUN_0083a084). + static bool may_keep(int32_t map_id) + { + return map_id / 100000000 != 0 && map_id / 1000000 % 100 != 9; + } + + static int16_t used(const std::vector &maps) + { + return static_cast(std::count_if(maps.begin(), maps.end(), [](int32_t map_id) + { + return map_id != EMPTY; + })); + } + + // A typed name loses its blanks at both ends, as CString's TrimRight + // and TrimLeft take them. + static std::string trim(const std::string &name); + + // Why the current map can't be added to `maps`, or an empty string + // (FUN_0083a084). + static std::string keep_problem(const std::vector &maps, int32_t map_id); + + // Why a typed name, already trimmed, can't be the target, or an empty + // string (FUN_0083a3c2). + static std::string target_problem(const std::string &name, const std::string &own_name); + + // What a refusal in MAP_TRANSFER_RESULT says (FUN_00a25268), naming + // `target` when they can't be found. Empty for anything else. + static std::string refusal(int8_t result, const std::string &target); + + TeleportRock(); + + // Replaces a list. Missing slots are empty, extra ones dropped. + void set_maps(bool vip, std::vector maps); + + const std::vector &get_maps(bool vip) const; + + // The character last named in the window, for the notice when they + // can't be found (v83 keeps it at ctx+0x36f4). + void set_target(const std::string &name); + + const std::string &get_target() const; private: - std::vector locations; - std::vector viplocations; + std::vector regular; + std::vector vip; + std::string target; }; -} \ No newline at end of file +} diff --git a/HeavenClient/IO/UI.cpp b/HeavenClient/IO/UI.cpp index a1d88a8f3..ede8e70d1 100644 --- a/HeavenClient/IO/UI.cpp +++ b/HeavenClient/IO/UI.cpp @@ -349,6 +349,7 @@ namespace ms types.emplace_back(UIElement::Type::PERSONALSHOP); types.emplace_back(UIElement::Type::SHOPSELL); types.emplace_back(UIElement::Type::SHOPSALES); + types.emplace_back(UIElement::Type::TELEPORTROCK); } else if (enter) { @@ -358,6 +359,7 @@ namespace ms // Game types.emplace_back(UIElement::Type::NOTICE); types.emplace_back(UIElement::Type::ENTERNUMBER); + types.emplace_back(UIElement::Type::TELEPORTROCK); } else if (tab) { // Game diff --git a/HeavenClient/IO/UIElement.h b/HeavenClient/IO/UIElement.h index fbb902b85..ba76055ac 100644 --- a/HeavenClient/IO/UIElement.h +++ b/HeavenClient/IO/UIElement.h @@ -85,6 +85,7 @@ namespace ms SHOPSELL, SHOPSALES, CONTEXTMENU, + TELEPORTROCK, NUM_TYPES }; diff --git a/HeavenClient/IO/UIStateGame.cpp b/HeavenClient/IO/UIStateGame.cpp index e964ef0f7..a3b90bd94 100644 --- a/HeavenClient/IO/UIStateGame.cpp +++ b/HeavenClient/IO/UIStateGame.cpp @@ -50,6 +50,8 @@ #include "../Net/Packets/MiniRoomPackets.h" #include "../Net/Packets/PlayerInteractionPackets.h" +#include + namespace ms { namespace @@ -234,7 +236,7 @@ namespace ms void UIStateGame::rightclick(Point pos) { // A window holding the mouse, such as the menu, hears it alone. - if (UIElement *focusedelement = get(focused); focusedelement && focusedelement->is_active()) + if (UIElement *focusedelement = get_focused()) focusedelement->rightclick(pos); else if (UIElement *front = get_front(pos)) front->rightclick(pos); @@ -249,9 +251,8 @@ namespace ms void UIStateGame::send_key(KeyType::Id type, int32_t action, bool pressed, bool escape) { - dispatch_focused_key(get(focused), action, pressed, escape, [&] + dispatch_focused_key(get_focused(), action, pressed, escape, [&] { - focused = UIElement::NONE; switch (type) { case KeyType::Id::MENU: @@ -443,10 +444,7 @@ namespace ms // A focused window that has closed lets go of the cursor, which // then reaches whatever is under it. - if (auto focusedelement = get(focused); focusedelement && !focusedelement->is_active()) - focused = UIElement::Type::NONE; - - if (auto focusedelement = get(focused)) + if (auto focusedelement = get_focused()) { remove_cursor(focusedelement->get_type()); @@ -692,7 +690,12 @@ namespace ms elementorder.push_back(type); if (is_focused) + { + if (get_focused() && focused != type) + focus_below.push_back(focused); + focused = type; + } return elements.find(type); } @@ -722,9 +725,33 @@ namespace ms bool UIStateGame::modal_open() const { - const UIElement::UPtr& element = elements[focused]; + auto open = [this](UIElement::Type type) + { + const UIElement::UPtr &element = elements[type]; + + return type != UIElement::Type::NONE && element && element->is_active(); + }; + + return open(focused) || std::any_of(focus_below.begin(), focus_below.end(), open); + } - return focused != UIElement::Type::NONE && element && element->is_active(); + UIElement *UIStateGame::get_focused() + { + while (true) + { + if (UIElement *element = get(focused); element && element->is_active()) + return element; + + if (focus_below.empty()) + { + focused = UIElement::Type::NONE; + + return nullptr; + } + + focused = focus_below.back(); + focus_below.pop_back(); + } } UIElement *UIStateGame::get_front(std::list types) diff --git a/HeavenClient/IO/UIStateGame.h b/HeavenClient/IO/UIStateGame.h index 458a5cdc4..c0239fc87 100644 --- a/HeavenClient/IO/UIStateGame.h +++ b/HeavenClient/IO/UIStateGame.h @@ -20,6 +20,7 @@ #include #include +#include #include "UIState.h" @@ -99,12 +100,19 @@ namespace ms void remove_cursor(UIElement::Type type); + // The window holding the focus. Once it closes, the focus goes back + // to the one it opened over, such as a dialog under its notice. + UIElement *get_focused(); + template void emplace(Args &&...args); EnumMap elements; std::list elementorder; UIElement::Type focused; + // The windows that held the focus before one opened over them, the + // latest last. + std::vector focus_below; UIElement *dragged; EquipTooltip eqtooltip; diff --git a/HeavenClient/IO/UITypes/UIItemInventory.cpp b/HeavenClient/IO/UITypes/UIItemInventory.cpp index ae2e2c59e..1a4486fb9 100644 --- a/HeavenClient/IO/UITypes/UIItemInventory.cpp +++ b/HeavenClient/IO/UITypes/UIItemInventory.cpp @@ -20,6 +20,7 @@ #include "UIChatBar.h" #include "UINotice.h" #include "UIPersonalShop.h" +#include "UITeleportRock.h" #include "UITradingRoom.h" #include "../Messages.h" @@ -74,6 +75,8 @@ namespace ms // v83's notices 0x10a and 0x42 (FUN_004f5697). constexpr const char *CATCH_BREATH = "You can't do it right now.\\nCatch your breath and then try again."; constexpr const char *DEAD = "You can't do it once you're dead."; + // StringPool 0x129. + constexpr const char *REQUEST_BUSY = "Other requests are in process.\\nPlease try again in a little bit."; // StringPool 0x180 and 0x181. constexpr const char *TOO_MANY_PETS = "You have exceeded the maximum number\\n of pets that can accompany you.\\nPlease try again after \\nyou change the living pet back into a doll."; // StringPool 0xed8. @@ -207,11 +210,24 @@ namespace ms }); } + // v83 FUN_00a0a63f waits for the last request before a cash item + // opens its window, and again before it is used, saying so. + bool may_use_cash_item() + { + if (Stage::get().get_player().can_request(ExclRequest::WAIT)) + return true; + + UI::get().emplace(REQUEST_BUSY, [](bool) + {}); + + return false; + } + // v83 FUN_00a0a63f, type 0x20: a chalkboard asks for a note, which // goes up once the server sends it back. A blank note isn't sent. void use_chalkboard(int16_t slot, int32_t item_id) { - if (!Stage::get().get_player().can_request(ExclRequest::WAIT)) + if (!may_use_cash_item()) return; auto shop = UI::get().get_element(); @@ -229,12 +245,47 @@ namespace ms { UI::get().emplace("Enter a note.", [=](const std::string &text) { - if (!Chalkboard::trim(text).empty()) + if (!Chalkboard::trim(text).empty() && may_use_cash_item()) Stage::get().get_player().request(UseCashItemPacket(slot, item_id, text), ExclRequest::WAIT); }, Chalkboard::MAX_TEXT); } } + // v83 FUN_00a0a4aa: a teleport rock goes to the map or the + // character chosen in its list. Maps that don't allow teleports + // refuse it before the list opens. + void use_teleport_rock(int16_t slot, int32_t item_id) + { + if (!may_use_cash_item()) + return; + + if (Stage::get().get_mapinfo().get_fieldlimit() & (0x40 | 0x2)) + { + UI::get().emplace("You cannot teleport to this map.", [](bool) + {}); + return; + } + + UI::get().emplace(TeleportRock::is_vip(item_id), [slot, item_id](const std::string &name, int32_t map_id) + { + Player &player = Stage::get().get_player(); + player.get_teleport_rock().set_target(name); + + if (name.empty() && (map_id == TeleportRock::EMPTY || map_id == 0)) + return; + + if (!may_use_cash_item()) + return; + + bool sent = name.empty() + ? player.request(UseTeleportRockPacket(slot, item_id, map_id), ExclRequest::WAIT) + : player.request(UseTeleportRockPacket(slot, item_id, name), ExclRequest::WAIT); + + if (sent) + Sound(nl::nx::sound["Game.img"]["UseShopItem"]).play(); + }); + } + // v83 FUN_00a0900a: a map may keep items off the ground, which it // says only once the player has answered every question. void send_drop(InventoryType::Id tab, int16_t slot, int16_t qty) @@ -561,6 +612,8 @@ namespace ms name_pet(slot, item_id); else if (item_id / 10000 == 537) use_chalkboard(slot, item_id); + else if (item_id / 10000 == 504) + use_teleport_rock(slot, item_id); break; } diff --git a/HeavenClient/IO/UITypes/UITeleportRock.cpp b/HeavenClient/IO/UITypes/UITeleportRock.cpp new file mode 100644 index 000000000..693731e97 --- /dev/null +++ b/HeavenClient/IO/UITypes/UITeleportRock.cpp @@ -0,0 +1,426 @@ +#include "UITeleportRock.h" + +#include "UINotice.h" + +#include "../UI.h" + +#include "../Components/MapleButton.h" + +#include "../../Constants.h" +#include "../../Character/TeleportRock.h" +#include "../../Gameplay/Stage.h" +#include "../../Net/Packets/InventoryPackets.h" +#include "../../Util/Misc.h" +#include "../../Util/NxFiles.h" + +#include + +#include + +namespace ms +{ + namespace + { + // FUN_008392c1 and FUN_008398d5, from the window's top left. + constexpr int16_t ROWS = 5; + constexpr int16_t ROW_PITCH = 18; + constexpr Point ROW_BAR = Point(9, 84); + constexpr Point ROW_TEXT = Point(15, 87); + constexpr size_t ROW_WIDTH = 158; + // A row is hit 150 wide and 15 tall, inside its bar. + constexpr int16_t HIT_RIGHT = 159; + constexpr int16_t HIT_HEIGHT = 15; + // With a row selected, a click here deselects it. + constexpr int16_t DESELECT_TOP = 191; + constexpr int16_t DESELECT_BOTTOM = 279; + constexpr int16_t COUNTER_RIGHT = 165; + constexpr int16_t COUNTER_Y = 38; + // v83 draws a line's cap top about 3 below where it is drawn, we 9, + // so all text here goes up by the difference. + constexpr int16_t TEXT_RAISE = 6; + constexpr Rectangle FIELD = Rectangle(34, 154, 250, 263); + constexpr size_t NAME_LIMIT = 12; + // The VIP rock's ten maps scroll through the five rows. + constexpr int16_t SLIDER_X = 155; + constexpr Range SLIDER = Range(80, 165); + + // The map's name, under the map it borrows its data from (FUN_005cf792). + std::string map_name(int32_t map_id) + { + std::string link = NxHelper::Map::get_map_node_name(map_id)["info"]["link"]; + + if (!link.empty()) + map_id = std::atoi(link.c_str()); + + return NxHelper::Map::get_map_info_by_id(map_id).name; + } + } + + UITeleportRock::UITeleportRock(bool v, std::function om) + : UIElement(Point(), Point()), vip(v), onmove(std::move(om)), counter_width(0), first(0), + selected(-1), held(false) + { + nl::node src = NxFiles::ui_classic["UIWindow.img"]["Teleport"]; + + Texture background = src["backgrnd"]; + sprites.emplace_back(src["backgrnd"]); + select = src["select"]; + + buttons[Buttons::REGISTER] = std::make_unique(src["BtRegister"], Point(6, 285)); + buttons[Buttons::DELETE] = std::make_unique(src["BtDelete"], Point(56, 285)); + buttons[Buttons::MOVE] = std::make_unique(src["BtMove"], Point(120, 285)); + buttons[Buttons::CLOSE] = std::make_unique(NxFiles::ui_classic["Basic.img"]["BtClose"], + Point(156, 6)); + + // Until something is chosen. + buttons[Buttons::DELETE]->set_state(Button::State::DISABLED); + buttons[Buttons::MOVE]->set_state(Button::State::DISABLED); + + namefield = Textfield(Text::Font::A12M, Text::Alignment::LEFT, Color::Name::BLACK, FIELD, NAME_LIMIT); + + namefield.set_enter_callback( + [&](std::string) + { + if (buttons[Buttons::MOVE]->get_state() != Button::State::DISABLED) + move(); + } + ); + + namefield.set_key_callback( + KeyAction::Id::ESCAPE, + [&]() + { + close(); + } + ); + + if (vip) + slider = Slider( + Slider::Type::CLASSIC, SLIDER, SLIDER_X, ROWS, static_cast(TeleportRock::slots(true)), + [&](bool upwards) + { + first += upwards ? -1 : 1; + } + ); + + // Centred, with no title bar to drag it by. + dimension = background.get_dimensions(); + position = Point((Constants::Constants::get().get_viewwidth() - dimension.x()) / 2, + (Constants::Constants::get().get_viewheight() - dimension.y()) / 2); + + refresh(); + + namefield.set_state(Textfield::State::FOCUSED); + } + + void UITeleportRock::draw(float alpha) const + { + UIElement::draw(alpha); + + namefield.draw(position - Point(0, TEXT_RAISE)); + + if (vip) + slider.draw(position); + + counter.draw(position + Point(COUNTER_RIGHT - counter_width, COUNTER_Y - TEXT_RAISE)); + + for (int16_t r = 0; r < ROWS; r++) + { + int16_t i = first + r; + + if (i >= static_cast(maps.size()) || maps[i] == TeleportRock::EMPTY) + continue; + + Point offset = position + Point(0, ROW_PITCH * r); + + if (i == selected) + select.draw(offset + ROW_BAR); + + const Row &row = rows[i]; + (i == selected ? row.selected : row.normal).draw(offset + ROW_TEXT - Point(0, TEXT_RAISE)); + } + } + + void UITeleportRock::update() + { + UIElement::update(); + + namefield.update(position); + + // FUN_00839f10. + if (namefield.get_state() == Textfield::State::FOCUSED) + selected = -1; + + auto enable = [this](Buttons id, bool enabled) + { + Button &button = *buttons[id]; + + if (!enabled) + button.set_state(Button::State::DISABLED); + else if (button.get_state() == Button::State::DISABLED) + button.set_state(Button::State::NORMAL); + }; + + enable(Buttons::MOVE, selected >= 0 || !namefield.empty()); + enable(Buttons::DELETE, selected >= 0); + } + + void UITeleportRock::remove_cursor() + { + UIElement::remove_cursor(); + + held = false; + + if (vip) + slider.remove_cursor(); + } + + Cursor::State UITeleportRock::send_cursor(bool clicked, Point cursorpos) + { + // The rows answer only to the button going down. + bool press = clicked && !held; + held = clicked; + + Point cursor = cursorpos - position; + + if (vip) + { + Cursor::State state = slider.send_cursor(cursor, clicked); + + if (state != Cursor::State::IDLE) + return state; + } + + if (press && select_at(cursor)) + return Cursor::State::CLICKING; + + Cursor::State state = namefield.send_cursor(cursorpos, clicked); + + if (state != Cursor::State::IDLE) + return state; + + return UIElement::send_cursor(clicked, cursorpos); + } + + void UITeleportRock::send_key(int32_t keycode, bool pressed, bool escape) + { + if (!pressed) + return; + + // FUN_004edd21: Enter is Move, if it may be pressed, and Escape is Close. + if (escape) + close(); + else if (keycode == KeyAction::Id::RETURN && buttons[Buttons::MOVE]->get_state() != Button::State::DISABLED) + move(); + } + + UIElement::Type UITeleportRock::get_type() const + { + return TYPE; + } + + void UITeleportRock::refresh() + { + maps = Stage::get().get_player().get_teleport_rock().get_maps(vip); + rows.clear(); + + for (int32_t map_id : maps) + { + std::string name = map_id == TeleportRock::EMPTY ? "" : map_name(map_id); + + Text normal = Text(Text::Font::A12M, Text::Alignment::LEFT, Color::Name::BLACK, name); + string_format::format_with_ellipsis(normal, ROW_WIDTH); + + rows.push_back({ normal, Text(Text::Font::A12M, Text::Alignment::LEFT, Color::Name::WHITE, normal.get_text()) }); + } + + // Measured in the rows' font, but drawn smaller. + std::string count = "[" + std::to_string(TeleportRock::used(maps)) + "/" + std::to_string(maps.size()) + "]"; + counter = Text(Text::Font::A11M, Text::Alignment::LEFT, Color::Name::WHITE, count); + counter_width = Text(Text::Font::A12M, Text::Alignment::LEFT, Color::Name::WHITE, count).width(); + } + + Button::State UITeleportRock::button_pressed(uint16_t buttonid) + { + switch (buttonid) + { + case Buttons::REGISTER: + keep(); + break; + case Buttons::DELETE: + drop(); + break; + case Buttons::MOVE: + move(); + break; + case Buttons::CLOSE: + close(); + break; + } + + return Button::State::NORMAL; + } + + bool UITeleportRock::select_at(Point cursor) + { + if (selected >= 0 && cursor.y() >= DESELECT_TOP && cursor.y() <= DESELECT_BOTTOM) + { + selected = -1; + namefield.set_state(Textfield::State::FOCUSED); + + return true; + } + + // v83 also hits the VIP rows scrolled out of view; only the shown ones are hit here. + for (int16_t r = 0; r < ROWS; r++) + { + int16_t i = first + r; + int16_t top = ROW_BAR.y() + ROW_PITCH * r; + + if (!Rectangle(ROW_BAR.x(), HIT_RIGHT, top, top + HIT_HEIGHT).contains(cursor)) + continue; + + if (i >= static_cast(maps.size()) || maps[i] == TeleportRock::EMPTY || i == selected) + return false; + + // v83 gives the keys to Register. + selected = i; + release_field(); + + return true; + } + + return false; + } + + void UITeleportRock::keep() + { + int32_t map_id = Stage::get().get_mapid(); + std::string problem = TeleportRock::keep_problem(maps, map_id); + + if (!problem.empty()) + { + notice(problem); + + return; + } + + ask("Will you enter this map\\nin your teleport list?\\n[" + map_name(map_id) + "]", + [](UITeleportRock &window) + { + TeleportRockListPacket(window.vip).dispatch(); + } + ); + } + + void UITeleportRock::drop() + { + if (selected < 0) + return; + + int32_t map_id = maps[selected]; + + ask("Will you delete this map from the\\nteleport list?\\n[" + map_name(map_id) + "]", + [map_id](UITeleportRock &window) + { + TeleportRockListPacket(window.vip, map_id).dispatch(); + window.selected = -1; + } + ); + } + + void UITeleportRock::move() + { + // FUN_0083a3c2: a name is checked here and goes without asking. + if (selected < 0) + { + std::string name = TeleportRock::trim(namefield.get_text()); + std::string problem = TeleportRock::target_problem(name, Stage::get().get_player().get_name()); + + if (!problem.empty()) + { + notice(problem); + + return; + } + + close(); + onmove(name, TeleportRock::EMPTY); + + return; + } + + int32_t map_id = maps[selected]; + + if (map_id == Stage::get().get_mapid()) + { + notice(TeleportRock::SAME_MAP); + + return; + } + + ask("Will you teleport to this map?\\n[" + map_name(map_id) + "]", + [map_id](UITeleportRock &window) + { + window.close(); + window.onmove("", map_id); + } + ); + } + + void UITeleportRock::close() + { + release_field(); + deactivate(); + } + + void UITeleportRock::notice(const std::string &text) + { + bool refocus = release_field(); + + UI::get().emplace( + text, + [refocus](bool) + { + after_notice(refocus); + } + ); + } + + void UITeleportRock::ask(const std::string &question, std::function on_yes) + { + bool refocus = release_field(); + + UI::get().emplace( + question, + [refocus, on_yes](bool yes) + { + if (auto window = after_notice(refocus); window && yes) + on_yes(*window); + } + ); + } + + Optional UITeleportRock::after_notice(bool refocus) + { + auto window = UI::get().get_element(); + + if (!window || !window->is_active()) + return {}; + + if (refocus) + window->namefield.set_state(Textfield::State::FOCUSED); + + return window; + } + + bool UITeleportRock::release_field() + { + if (namefield.get_state() != Textfield::State::FOCUSED) + return false; + + UI::get().remove_textfield(); + + return true; + } +} diff --git a/HeavenClient/IO/UITypes/UITeleportRock.h b/HeavenClient/IO/UITypes/UITeleportRock.h new file mode 100644 index 000000000..75b62993c --- /dev/null +++ b/HeavenClient/IO/UITypes/UITeleportRock.h @@ -0,0 +1,96 @@ +#pragma once + +#include "../UIElement.h" + +#include "../Components/Slider.h" +#include "../Components/Textfield.h" + +#include "../../Template/Optional.h" + +#include +#include +#include + +namespace ms +{ + // The map list of a teleport rock (v83 FUN_008390d1, UIWindow.img/Teleport): choose a kept map or type a + // character's name, keep the current map, or drop a kept one. It is modal, and closes before the rock is used. + class UITeleportRock : public UIElement + { + public: + static constexpr Type TYPE = UIElement::Type::TELEPORTROCK; + static constexpr bool FOCUSED = true; + static constexpr bool TOGGLED = false; + + // `onmove` gets the typed name, or an empty name and the chosen map. + UITeleportRock(bool vip, std::function onmove); + + void draw(float alpha) const override; + + void update() override; + + void remove_cursor() override; + + Cursor::State send_cursor(bool clicked, Point cursorpos) override; + + void send_key(int32_t keycode, bool pressed, bool escape) override; + + UIElement::Type get_type() const override; + + // Shows the kept maps again, keeping the selection and the scroll. + void refresh(); + + protected: + Button::State button_pressed(uint16_t buttonid) override; + + private: + // Selects the row under `cursor`, or deselects from the area below the list (FUN_00839dc0). + bool select_at(Point cursor); + + void keep(); + + void drop(); + + void move(); + + void close(); + + // Raises a notice, giving the field's keys back after it. + void notice(const std::string &text); + + void ask(const std::string &question, std::function on_yes); + + // The open window after a notice, with its field focused again if it was before. + static Optional after_notice(bool refocus); + + // Lets go of the keys for a notice. Returns whether the field had them. + bool release_field(); + + enum Buttons : uint16_t + { + REGISTER, + DELETE, + MOVE, + CLOSE + }; + + struct Row + { + Text normal; + Text selected; + }; + + bool vip; + std::function onmove; + std::vector maps; + std::vector rows; + Texture select; + Text counter; + int16_t counter_width; + Textfield namefield; + Slider slider; + int16_t first; + int16_t selected; + bool held; + }; +} diff --git a/HeavenClient/Net/Handlers/Helpers/CharacterParser.cpp b/HeavenClient/Net/Handlers/Helpers/CharacterParser.cpp index c3ecfdbf4..d44ed8705 100644 --- a/HeavenClient/Net/Handlers/Helpers/CharacterParser.cpp +++ b/HeavenClient/Net/Handlers/Helpers/CharacterParser.cpp @@ -197,11 +197,15 @@ namespace ms void CharacterParser::parse_teleportrock(InPacket& recv, TeleportRock& teleportrock) { - for (size_t i = 0; i < 5; i++) - teleportrock.addlocation(recv.read_int()); + for (bool vip : { false, true }) + { + std::vector maps; + + for (size_t i = 0; i < TeleportRock::slots(vip); i++) + maps.push_back(recv.read_int()); - for (size_t i = 0; i < 10; i++) - teleportrock.addviplocation(recv.read_int()); + teleportrock.set_maps(vip, maps); + } } void CharacterParser::parse_nyinfo(InPacket& recv) diff --git a/HeavenClient/Net/Handlers/InventoryHandlers.cpp b/HeavenClient/Net/Handlers/InventoryHandlers.cpp index 61904265b..2c0a77734 100644 --- a/HeavenClient/Net/Handlers/InventoryHandlers.cpp +++ b/HeavenClient/Net/Handlers/InventoryHandlers.cpp @@ -30,6 +30,8 @@ #include "../IO/UITypes/UIShop.h" #include "../IO/UITypes/UIStatusMessenger.h" #include "../IO/UITypes/UIStorage.h" +#include "../IO/UITypes/UITeleportRock.h" +#include "../IO/UITypes/UINotice.h" #include "../Character/Pets.h" namespace ms @@ -60,6 +62,32 @@ namespace ms iteminventory->refresh_slots(); } + void MapTransferResultHandler::handle(InPacket& recv) const + { + int8_t result = recv.read_byte(); + bool vip = recv.read_bool(); + TeleportRock &rock = Stage::get().get_player().get_teleport_rock(); + + if (result == TeleportRock::DROPPED || result == TeleportRock::KEPT) + { + std::vector maps; + + for (size_t i = 0; i < TeleportRock::slots(vip); i++) + maps.push_back(recv.read_int()); + + rock.set_maps(vip, maps); + + if (auto window = UI::get().get_element(); window && window->is_active()) + window->refresh(); + } else if (std::string notice = TeleportRock::refusal(result, rock.get_target()); !notice.empty()) + { + UI::get().emplace(notice, [](bool) + {}); + } + + rock.set_target(""); + } + void ModifyInventoryHandler::handle(InPacket& recv) const { // Set when this answers the player's item request. diff --git a/HeavenClient/Net/Handlers/InventoryHandlers.h b/HeavenClient/Net/Handlers/InventoryHandlers.h index 8acb3f809..c96ad7f63 100644 --- a/HeavenClient/Net/Handlers/InventoryHandlers.h +++ b/HeavenClient/Net/Handlers/InventoryHandlers.h @@ -40,6 +40,14 @@ namespace ms void handle(InPacket& recv) const override; }; + // What came of keeping a map for a teleport rock or using one (v83 + // FUN_00a25268): the new list, shown in the open window, or a notice + // for a refusal. + class MapTransferResultHandler : public PacketHandler + { + void handle(InPacket& recv) const override; + }; + // Handler for a packet which modifies the player's inventory. class ModifyInventoryHandler : public PacketHandler { diff --git a/HeavenClient/Net/OutPacket.h b/HeavenClient/Net/OutPacket.h index 0c5903afa..a61803e6e 100644 --- a/HeavenClient/Net/OutPacket.h +++ b/HeavenClient/Net/OutPacket.h @@ -120,6 +120,7 @@ namespace ms /// Gameplay 2 CHANGE_MAP_SPECIAL = 100, + TELEPORT_ROCK_LIST = 102, QUEST_ACTION = 107, THROW_GRENADE = 109, PARTY_OPERATION = 124, diff --git a/HeavenClient/Net/PacketSwitch.cpp b/HeavenClient/Net/PacketSwitch.cpp index f1266db87..71e459bc6 100644 --- a/HeavenClient/Net/PacketSwitch.cpp +++ b/HeavenClient/Net/PacketSwitch.cpp @@ -70,6 +70,7 @@ namespace ms UPDATE_SKILL = 36, SKILL_USE_RESULT = 37, FAME_RESPONSE = 38, + MAP_TRANSFER_RESULT = 42, SKILL_LEARN_ITEM_RESULT = 51, /// Messaging 1 @@ -318,6 +319,7 @@ namespace ms emplace(); emplace(); emplace(); + emplace(); // NPC Interaction Handlers emplace(); diff --git a/HeavenClient/Net/Packets/InventoryPackets.h b/HeavenClient/Net/Packets/InventoryPackets.h index b59160ee6..64214b2c9 100644 --- a/HeavenClient/Net/Packets/InventoryPackets.h +++ b/HeavenClient/Net/Packets/InventoryPackets.h @@ -145,6 +145,54 @@ namespace ms } }; + // Uses the teleport rock (504xxxx) in Cash slot `slot` to go to a + // character, or to a kept map (FUN_00a0a4aa). + // Opcode: USE_CASH_ITEM(79) + class UseTeleportRockPacket : public OutPacket + { + public: + UseTeleportRockPacket(int16_t slot, int32_t itemid, const std::string &name) + : OutPacket(OutPacket::Opcode::USE_CASH_ITEM) + { + write_short(slot); + write_int(itemid); + write_byte(1); + write_string(name); + write_time(); + } + + UseTeleportRockPacket(int16_t slot, int32_t itemid, int32_t mapid) : OutPacket(OutPacket::Opcode::USE_CASH_ITEM) + { + write_short(slot); + write_int(itemid); + write_byte(0); + write_int(mapid); + write_time(); + } + }; + + // Keeps the player's map for a teleport rock, or drops a kept one + // (FUN_00a261bc). The server answers with the list. + // Opcode: TELEPORT_ROCK_LIST(102) + class TeleportRockListPacket : public OutPacket + { + public: + // Keeps the map the player is on. + TeleportRockListPacket(bool vip) : OutPacket(OutPacket::Opcode::TELEPORT_ROCK_LIST) + { + write_byte(1); + write_byte(vip); + } + + // Drops `mapid`. + TeleportRockListPacket(bool vip, int32_t mapid) : OutPacket(OutPacket::Opcode::TELEPORT_ROCK_LIST) + { + write_byte(0); + write_byte(vip); + write_int(mapid); + } + }; + // Requests reading a skill or mastery book (v83 FUN_00a0a1b2) // Opcode: USE_SKILL_BOOK(82) class SkillBookPacket : public OutPacket diff --git a/HeavenClient/tests/teleport_rock.cpp b/HeavenClient/tests/teleport_rock.cpp new file mode 100644 index 000000000..78f72aae6 --- /dev/null +++ b/HeavenClient/tests/teleport_rock.cpp @@ -0,0 +1,69 @@ +#include "../Character/TeleportRock.h" +#include +#include + +void require(bool condition, const char* message) +{ + if (!condition) throw std::runtime_error(message); +} + +int main() +{ + try + { + using ms::TeleportRock; + + require(!TeleportRock::is_vip(5040000) && !TeleportRock::is_vip(5040001), "5040xxx rocks are regular"); + require(TeleportRock::is_vip(5041000), "5041000 is the VIP rock"); + require(TeleportRock::slots(false) == 5 && TeleportRock::slots(true) == 10, "five and ten slots"); + + require(TeleportRock::may_keep(100000000), "Henesys can be kept"); + require(!TeleportRock::may_keep(10000), "Maple Island can't be kept"); + require(!TeleportRock::may_keep(109000000), "an event map can't be kept"); + + require(TeleportRock::trim(" \tNeeerp\r\n") == "Neeerp", "a name is trimmed"); + require(TeleportRock::trim(" ").empty(), "a blank name is empty"); + + TeleportRock rock; + const int32_t E = TeleportRock::EMPTY; + + require(rock.get_maps(false) == std::vector(5, E), "the regular list starts empty"); + require(rock.get_maps(true) == std::vector(10, E), "the VIP list starts empty"); + + rock.set_maps(false, { 100000000, E, 104000000 }); + require(rock.get_maps(false) == std::vector{ 100000000, E, 104000000, E, E }, "a short list is padded"); + require(TeleportRock::used(rock.get_maps(false)) == 2, "empty slots aren't used"); + + rock.set_maps(false, { 1, 2, 3, 4, 5, 6 }); + require(rock.get_maps(false) == std::vector{ 1, 2, 3, 4, 5 }, "a long list is cut"); + require(rock.get_maps(true) == std::vector(10, E), "the other list is left alone"); + + std::vector maps = { 100000000, E, E, E, E }; + require(TeleportRock::keep_problem(maps, 104000000).empty(), "a new map can be kept"); + require(TeleportRock::keep_problem(maps, 100000000) == "You have already entered this map.", + "a listed map isn't kept twice"); + require(TeleportRock::keep_problem(maps, 10000) == TeleportRock::NOT_FOR_LIST, "Maple Island isn't kept"); + require(TeleportRock::keep_problem({ 1, 2, 3, 4, 5 }, 2).find("is full") != std::string::npos, + "a full list is checked first"); + + require(TeleportRock::target_problem("Friend", "Neeerp").empty(), "another character is a target"); + require(TeleportRock::target_problem("neeERP", "Neeerp").find("yourself") != std::string::npos, + "the player isn't, in any case"); + require(TeleportRock::target_problem("abc", "Neeerp") == "- The character name is invalid.", + "a short name is invalid"); + require(TeleportRock::target_problem("abc", "abc") == "- The character name is invalid.", + "validity is checked first"); + + require(TeleportRock::refusal(6, "Friend").rfind("Friend is currently", 0) == 0, "a lost target is named"); + require(TeleportRock::refusal(9, "") == TeleportRock::SAME_MAP, "the same map"); + require(TeleportRock::refusal(5, "") == TeleportRock::refusal(8, ""), "5 and 8 say the same"); + require(TeleportRock::refusal(2, "").empty() && TeleportRock::refusal(3, "").empty(), "a list has no notice"); + } + catch (const std::exception& e) + { + std::cerr << "FAIL: " << e.what() << std::endl; + return 1; + } + + return 0; +} diff --git a/agent_docs/client.md b/agent_docs/client.md index 856a7e7eb..f9c4b0fee 100644 --- a/agent_docs/client.md +++ b/agent_docs/client.md @@ -478,6 +478,7 @@ side yet. | Trade (`UITradingRoom`, `UIInviteCard`) | Implemented, without cash trades | | Chalkboards (`ChalkboardBalloon`) | Implemented: the board, the note prompt and the close button; see `inventory.md` | | Storage (`UIStorage`) | Implemented; see `inventory.md` | +| Teleport rocks (`UITeleportRock`) | Implemented: the map list, keeping and dropping maps, and moving to a map or a character; see `inventory.md` | | Continent ships, station wall clocks | Docking ships and the Balrog's ship, CONTI_MOVE/CONTI_STATE, CLOCK kind 1; see `ships.md` | | Player shops, mini-games, memo/mail | No UI, no handlers | | Reactors | Spawning, swings, hits and breaking; no skill hits, touch or quest gates; see `combat.md` | @@ -485,7 +486,7 @@ side yet. | Mounts, summons | Partial or absent | | Party/guild/boss quests, events, MTS | Absent | -83 outbound opcodes are defined (`Net/OutPacket.h`) and 118 inbound handlers +84 outbound opcodes are defined (`Net/OutPacket.h`) and 119 inbound handlers are registered (`Net/PacketSwitch.cpp`). Anything not listed there is silently ignored by the client. ## Movement physics diff --git a/agent_docs/inventory.md b/agent_docs/inventory.md index 104909f1e..05ddebf81 100644 --- a/agent_docs/inventory.md +++ b/agent_docs/inventory.md @@ -265,6 +265,22 @@ players elsewhere on login (`forcedReturn`) but on Maple Island, as Cosmic does. Otherwise one exclusive INVENTORY_OPERATION takes one rock and the user is warped. +HeavenClient reads both lists from the character data +(`Character/TeleportRock`) and keeps them from MAP_TRANSFER_RESULT 2 and 3. +Double-clicking a rock in the Cash tab opens `UITeleportRock` +(`UIWindow.img/Teleport`), centred and modal, unless a request is still +waiting ("Other requests are in process.", which the chalkboard checks too) +or the map's `fieldLimit` has `0x40` or `0x2` ("You cannot teleport to this +map."). It lists the kept maps by name (a map with `info/link` under the +linked map's), five at a time with a scroll bar on the VIP rock, and counts +them as `[used/size]`. ADD MAP asks before sending TROCK_ADD_MAP for the +current map, unless the list is full, already has it, or the map can't be +kept; DEL. MAP asks before dropping the selected map. MOVE asks before using +the rock on the selected map, or uses it at once on the typed name once the +name is valid and isn't the player's own. Enter is MOVE and Escape closes +the window, which closes before the rock is used. A refused rock shows the +result as a notice, naming the character for result 6. + ## NPC shops Clicking an NPC that keeps a shop (`content::shops()`, from the pack's @@ -526,7 +542,8 @@ to another map and back, closes it, and has a Free Market room put it away. keeping and dropping maps, each refusal, the trips by map and to a character, and the rock used; `teleport_rocks` (integration) keeps Henesys, rocks back to it, is refused the same map and a missing name, rocks to a watcher, and rocks -to Henesys again after a relog. +to Henesys again after a relog. The client's `teleport_rock` test covers the +lists, the checks on the current map and a typed name, and the refusals. `net::storage` covers each request and its refusals. `storage` (integration) opens Mr. Lee's storage, stores part of a stack and a whole one for the fee, is refused too many and too many mesos, moves mesos in, sorts, takes items From 2b92e491600d34bac245eb974c1812d5dbcf1478 Mon Sep 17 00:00:00 2001 From: David Goldstein Date: Sun, 18 Oct 2026 11:00:23 -0400 Subject: [PATCH 10/11] L179: use megaphones from HeavenClient Double-clicking a megaphone in the Cash tab now opens its v83 dialog instead of doing nothing. The plain megaphone asks for a line through the text prompt. The super and triple megaphones open a modal window on the Megaphone and ArtMegaphone art, with the whisper-icon checkbox. The triple megaphone asks before sending with an empty line, and its Preview button writes the lines to the player's own chat log. The item megaphone opens a window that leaves the game playable; an item dropped on it from the inventory shows in its well with its name and tooltip. The checks run in v83's order, after the dialog closes: an empty line is dropped, then cursing, a level of 10 or below and an over-long line are refused with v83's notices. Util/Megaphones.h holds these checks and the kind decoding, with tests. The item megaphone checks its own request lock and asks before going without an item. The Special Character button does nothing yet, the windows can't be dragged, and Enter reaches the item megaphone only while its field has focus. --- HeavenClient/CMakeLists.txt | 4 + HeavenClient/IO/Components/EquipTooltip.cpp | 1 + HeavenClient/IO/Components/Icon.cpp | 5 + HeavenClient/IO/Components/Icon.h | 4 + HeavenClient/IO/Components/Tooltip.h | 3 +- HeavenClient/IO/UI.cpp | 3 + HeavenClient/IO/UIElement.h | 2 + HeavenClient/IO/UIStateGame.cpp | 2 + HeavenClient/IO/UITypes/UIItemInventory.cpp | 136 ++++++++- HeavenClient/IO/UITypes/UIItemInventory.h | 2 + HeavenClient/IO/UITypes/UIItemMegaphone.cpp | 302 +++++++++++++++++++ HeavenClient/IO/UITypes/UIItemMegaphone.h | 87 ++++++ HeavenClient/IO/UITypes/UIMegaphone.cpp | 305 ++++++++++++++++++++ HeavenClient/IO/UITypes/UIMegaphone.h | 84 ++++++ HeavenClient/Net/Packets/InventoryPackets.h | 62 ++++ HeavenClient/Util/Megaphones.h | 99 +++++++ HeavenClient/tests/megaphones.cpp | 28 ++ agent_docs/client.md | 2 +- agent_docs/inventory.md | 25 +- 19 files changed, 1138 insertions(+), 18 deletions(-) create mode 100644 HeavenClient/IO/UITypes/UIItemMegaphone.cpp create mode 100644 HeavenClient/IO/UITypes/UIItemMegaphone.h create mode 100644 HeavenClient/IO/UITypes/UIMegaphone.cpp create mode 100644 HeavenClient/IO/UITypes/UIMegaphone.h diff --git a/HeavenClient/CMakeLists.txt b/HeavenClient/CMakeLists.txt index 62ff75a29..0cc868728 100644 --- a/HeavenClient/CMakeLists.txt +++ b/HeavenClient/CMakeLists.txt @@ -604,6 +604,10 @@ add_executable(HeavenClient IO/UITypes/UIStorage.h IO/UITypes/UITeleportRock.cpp IO/UITypes/UITeleportRock.h + IO/UITypes/UIMegaphone.cpp + IO/UITypes/UIMegaphone.h + IO/UITypes/UIItemMegaphone.cpp + IO/UITypes/UIItemMegaphone.h IO/UITypes/UIInviteCard.cpp IO/UITypes/UIInviteCard.h IO/UITypes/UITradingRoom.cpp diff --git a/HeavenClient/IO/Components/EquipTooltip.cpp b/HeavenClient/IO/Components/EquipTooltip.cpp index 59a2a8341..372325521 100644 --- a/HeavenClient/IO/Components/EquipTooltip.cpp +++ b/HeavenClient/IO/Components/EquipTooltip.cpp @@ -165,6 +165,7 @@ namespace ms case Tooltip::Parent::ITEMINVENTORY: case Tooltip::Parent::SHOP: case Tooltip::Parent::STORAGE: + case Tooltip::Parent::ITEMMEGAPHONE: invtype = InventoryType::Id::EQUIP; break; case Tooltip::Parent::EQUIPINVENTORY: diff --git a/HeavenClient/IO/Components/Icon.cpp b/HeavenClient/IO/Components/Icon.cpp index f4abf8e09..1af5e51e3 100644 --- a/HeavenClient/IO/Components/Icon.cpp +++ b/HeavenClient/IO/Components/Icon.cpp @@ -92,6 +92,11 @@ namespace ms type->drop_on_shop(); } + void Icon::drop_on_megaphone() const + { + type->drop_on_megaphone(); + } + InventoryType::Id Icon::get_return_tab() const { return type->get_return_tab(); diff --git a/HeavenClient/IO/Components/Icon.h b/HeavenClient/IO/Components/Icon.h index 8073e4db1..55e945da3 100644 --- a/HeavenClient/IO/Components/Icon.h +++ b/HeavenClient/IO/Components/Icon.h @@ -61,6 +61,9 @@ namespace ms // Puts the icon's item up for sale in my personal shop. virtual void drop_on_shop() const {} + // Shows the icon's item in the item megaphone's window. + virtual void drop_on_megaphone() const {} + // The inventory tab an icon from another window goes back to, // or NONE if the inventory doesn't take it. virtual InventoryType::Id get_return_tab() const @@ -103,6 +106,7 @@ namespace ms void drop_on_bindings(Point cursorposition, bool remove) const; void drop_on_trade(int8_t slot) const; void drop_on_shop() const; + void drop_on_megaphone() const; InventoryType::Id get_return_tab() const; void drop_on_inventory() const; Keyboard::Mapping get_mapping() const; diff --git a/HeavenClient/IO/Components/Tooltip.h b/HeavenClient/IO/Components/Tooltip.h index 5ad2c20b4..c6c908ea0 100644 --- a/HeavenClient/IO/Components/Tooltip.h +++ b/HeavenClient/IO/Components/Tooltip.h @@ -45,7 +45,8 @@ namespace ms MONSTERBOOK, BUFFLIST, TRADINGROOM, - PERSONALSHOP + PERSONALSHOP, + ITEMMEGAPHONE }; virtual ~Tooltip() diff --git a/HeavenClient/IO/UI.cpp b/HeavenClient/IO/UI.cpp index ede8e70d1..eb590892b 100644 --- a/HeavenClient/IO/UI.cpp +++ b/HeavenClient/IO/UI.cpp @@ -350,6 +350,8 @@ namespace ms types.emplace_back(UIElement::Type::SHOPSELL); types.emplace_back(UIElement::Type::SHOPSALES); types.emplace_back(UIElement::Type::TELEPORTROCK); + types.emplace_back(UIElement::Type::MEGAPHONE); + types.emplace_back(UIElement::Type::ITEMMEGAPHONE); } else if (enter) { @@ -360,6 +362,7 @@ namespace ms types.emplace_back(UIElement::Type::NOTICE); types.emplace_back(UIElement::Type::ENTERNUMBER); types.emplace_back(UIElement::Type::TELEPORTROCK); + types.emplace_back(UIElement::Type::MEGAPHONE); } else if (tab) { // Game diff --git a/HeavenClient/IO/UIElement.h b/HeavenClient/IO/UIElement.h index ba76055ac..cf322c978 100644 --- a/HeavenClient/IO/UIElement.h +++ b/HeavenClient/IO/UIElement.h @@ -86,6 +86,8 @@ namespace ms SHOPSALES, CONTEXTMENU, TELEPORTROCK, + MEGAPHONE, + ITEMMEGAPHONE, NUM_TYPES }; diff --git a/HeavenClient/IO/UIStateGame.cpp b/HeavenClient/IO/UIStateGame.cpp index a3b90bd94..825f93f72 100644 --- a/HeavenClient/IO/UIStateGame.cpp +++ b/HeavenClient/IO/UIStateGame.cpp @@ -74,6 +74,8 @@ namespace ms return Tooltip::Parent::TRADINGROOM; case UIElement::Type::PERSONALSHOP: return Tooltip::Parent::PERSONALSHOP; + case UIElement::Type::ITEMMEGAPHONE: + return Tooltip::Parent::ITEMMEGAPHONE; case UIElement::Type::EVENT: return Tooltip::Parent::EVENT; case UIElement::Type::KEYCONFIG: diff --git a/HeavenClient/IO/UITypes/UIItemInventory.cpp b/HeavenClient/IO/UITypes/UIItemInventory.cpp index 1a4486fb9..9b465852a 100644 --- a/HeavenClient/IO/UITypes/UIItemInventory.cpp +++ b/HeavenClient/IO/UITypes/UIItemInventory.cpp @@ -18,6 +18,8 @@ #include "UIItemInventory.h" #include "UIChatBar.h" +#include "UIItemMegaphone.h" +#include "UIMegaphone.h" #include "UINotice.h" #include "UIPersonalShop.h" #include "UITeleportRock.h" @@ -37,6 +39,7 @@ #include "../../Data/EquipData.h" #include "../../Gameplay/Stage.h" #include "../../Util/Chalkboard.h" +#include "../../Util/Megaphones.h" #include "../../Util/NxFiles.h" #include "../../Net/Handlers/MiniRoom.h" @@ -86,6 +89,10 @@ namespace ms constexpr const char *NAME_PET = "Please name your pet."; constexpr const char *NAME_TAKEN = "That name is not available."; constexpr const char *NO_CURSING = "Cursing is not allowed."; + // StringPool 0x10f, 0xea1 and 0x111. + constexpr const char *MEGAPHONE_PROMPT = "Enter your comments through the megaphone."; + constexpr const char *MEGAPHONE_LEVEL = "This megaphone is only available for \\ncharacters that are over Level 10."; + constexpr const char *MEGAPHONE_LONG = "Use the megaphone to text up to 60 characters."; constexpr const char *BOSS_PET = "Would you like to change your Boss Pet?\\n (The \"Item Ignore\" skill, as well as \\nthe stats for speed and jump will \\nfollow that of the Lead Pet)"; // v83 FUN_00a240a2: double-clicking a pet asks the server to bring it @@ -223,31 +230,123 @@ namespace ms return false; } + // Some cash items say so in the chat log while a shop or trade is open. + bool in_mini_room() + { + auto shop = UI::get().get_element(); + auto trade = UI::get().get_element(); + + if ((!shop || !shop->is_active()) && (!trade || !trade->is_active())) + return false; + + if (auto chatbar = UI::get().get_element()) + chatbar->send_chatline("Not available", UIChatBar::LineType::SKILL); + + return true; + } + // v83 FUN_00a0a63f, type 0x20: a chalkboard asks for a note, which // goes up once the server sends it back. A blank note isn't sent. void use_chalkboard(int16_t slot, int32_t item_id) { - if (!may_use_cash_item()) + if (!may_use_cash_item() || in_mini_room()) return; - auto shop = UI::get().get_element(); - auto trade = UI::get().get_element(); + if (Stage::get().get_mapinfo().allows_personal_shop()) + { + UI::get().emplace("You can't use it here in this map.", [](bool) + {}); + return; + } - if ((shop && shop->is_active()) || (trade && trade->is_active())) + UI::get().emplace("Enter a note.", [=](const std::string &text) { - if (auto chatbar = UI::get().get_element()) - chatbar->send_chatline("Not available", UIChatBar::LineType::SKILL); - } else if (Stage::get().get_mapinfo().allows_personal_shop()) + if (!Chalkboard::trim(text).empty() && may_use_cash_item()) + Stage::get().get_player().request(UseCashItemPacket(slot, item_id, text), ExclRequest::WAIT); + }, Chalkboard::MAX_TEXT); + } + + // Whether a megaphone's line is refused, saying why if v83 does. + bool megaphone_refused(Megaphones::Refusal refusal) + { + const char *reason = nullptr; + + switch (refusal) { - UI::get().emplace("You can't use it here in this map.", [](bool) + case Megaphones::Refusal::NONE: + return false; + case Megaphones::Refusal::CURSING: + reason = NO_CURSING; + break; + case Megaphones::Refusal::LEVEL: + reason = MEGAPHONE_LEVEL; + break; + case Megaphones::Refusal::LONG: + reason = MEGAPHONE_LONG; + break; + case Megaphones::Refusal::QUIET: + break; + } + + if (reason) + UI::get().emplace(reason, [](bool) {}); - } else + + return true; + } + + // Checks a plain or super megaphone's line with the level I have now. + bool line_refused(const std::string &text) + { + uint16_t level = Stage::get().get_player().get_stats().get_stat(MapleStat::Id::LEVEL); + + return megaphone_refused(Megaphones::check_line(text, level, CurseData::filter)); + } + + void send_megaphone(OutPacket &&packet) + { + if (may_use_cash_item() && Stage::get().get_player().request(std::move(packet), ExclRequest::WAIT)) + Sound(nl::nx::sound["Game.img"]["UseShopItem"]).play(); + } + + // v83 FUN_00a0a63f, types 0xc to 0xf and 0x3c: a megaphone asks for + // its line, and checks it once the prompt has closed. The item + // megaphone's window stays open beside the game and sends by itself. + void use_megaphone(int16_t slot, int32_t item_id) + { + if (!may_use_cash_item()) + return; + + switch (Megaphones::kind(item_id)) { - UI::get().emplace("Enter a note.", [=](const std::string &text) - { - if (!Chalkboard::trim(text).empty() && may_use_cash_item()) - Stage::get().get_player().request(UseCashItemPacket(slot, item_id, text), ExclRequest::WAIT); - }, Chalkboard::MAX_TEXT); + case Megaphones::Kind::PLAIN: + UI::get().emplace(MEGAPHONE_PROMPT, [=](const std::string &text) + { + if (!line_refused(text)) + send_megaphone(UseCashItemPacket(slot, item_id, text)); + }, Megaphones::MAX_LINE); + break; + case Megaphones::Kind::SUPER: + UI::get().emplace(false, [=](const std::vector &lines, bool ear) + { + if (!line_refused(lines[0])) + send_megaphone(UseSuperMegaphonePacket(slot, item_id, lines[0], ear)); + }); + break; + case Megaphones::Kind::TRIPLE: + UI::get().emplace(true, [=](const std::vector &lines, bool ear) + { + if (!megaphone_refused(Megaphones::check_lines(lines))) + send_megaphone(UseTripleMegaphonePacket(slot, item_id, Megaphones::lines_sent(lines), ear)); + }); + break; + case Megaphones::Kind::ITEM: + if (!in_mini_room()) + UI::get().emplace(slot, item_id); + + break; + case Megaphones::Kind::NONE: + break; } } @@ -614,6 +713,8 @@ namespace ms use_chalkboard(slot, item_id); else if (item_id / 10000 == 504) use_teleport_rock(slot, item_id); + else if (item_id / 10000 == 507) + use_megaphone(slot, item_id); break; } @@ -1068,6 +1169,13 @@ namespace ms shop->sell(sourcetab, source, item_id, count); } + void UIItemInventory::ItemIcon::drop_on_megaphone() const + { + // v83 takes any item here; the server decides (FUN_004f49b3). + if (auto megaphone = UI::get().get_element()) + megaphone->set_item(sourcetab, source); + } + void UIItemInventory::ItemIcon::set_count(int16_t c) { count = c; diff --git a/HeavenClient/IO/UITypes/UIItemInventory.h b/HeavenClient/IO/UITypes/UIItemInventory.h index 1ceeaaec3..6dab7707c 100644 --- a/HeavenClient/IO/UITypes/UIItemInventory.h +++ b/HeavenClient/IO/UITypes/UIItemInventory.h @@ -114,6 +114,8 @@ namespace ms void drop_on_shop() const override; + void drop_on_megaphone() const override; + void set_count(int16_t count) override; Icon::IconType get_type() override; diff --git a/HeavenClient/IO/UITypes/UIItemMegaphone.cpp b/HeavenClient/IO/UITypes/UIItemMegaphone.cpp new file mode 100644 index 000000000..1b97485b3 --- /dev/null +++ b/HeavenClient/IO/UITypes/UIItemMegaphone.cpp @@ -0,0 +1,302 @@ +#include "UIItemMegaphone.h" + +#include "UINotice.h" + +#include "../UI.h" + +#include "../Components/MapleButton.h" + +#include "../../Constants.h" +#include "../../Data/ItemData.h" +#include "../../Gameplay/Stage.h" +#include "../../Net/Packets/InventoryPackets.h" +#include "../../Util/Megaphones.h" +#include "../../Util/NxFiles.h" + +#include + +namespace ms +{ + namespace + { + // From the window's top left (FUN_005eb5b8). v83 draws a line's cap + // top about 3 below where it is drawn, we 9, so text goes up by the + // difference. + constexpr int16_t TEXT_RAISE = 6; + constexpr Rectangle FIELD = Rectangle(19, 221, 129, 145); + // The item's well, and the bar under it with its name. + constexpr Rectangle WELL = Rectangle(105, 137, 65, 97); + constexpr Point NAME = Point(120, 101); + // The checkbox control; its box is drawn 2 right and 1 down in it. + constexpr Rectangle CHECK = Rectangle(14, 174, 161, 175); + constexpr Point CHECK_BOX = Point(2, 1); + + // StringPool 0x129 and 0x1152. + constexpr const char *REQUEST_BUSY = "Other requests are in process.\\nPlease try again in a little bit."; + constexpr const char *NO_ITEM = "You have not selected an item.\\nAre you sure you still want to use the item?"; + } + + UIItemMegaphone::UIItemMegaphone(int16_t s, int32_t id) + : UIElement(Point(), Point()), slot(s), item_id(id), item_tab(InventoryType::Id::NONE), + item_slot(0), shown_id(0), ear(true), held(false) + { + nl::node basic = NxFiles::ui_classic["Basic.img"]; + nl::node src = NxFiles::ui_classic["UIWindow.img"]["ItemMegaphone"]; + + Texture background = src["backgrnd"]; + sprites.emplace_back(src["backgrnd"]); + + checkbox[false] = basic["CheckBox"]["0"]; + checkbox[true] = basic["CheckBox"]["1"]; + + buttons[Buttons::OK] = std::make_unique(basic["BtOK2"], Point(145, 186)); + buttons[Buttons::CANCEL] = std::make_unique(basic["BtCancel2"], Point(186, 186)); + + item_name = Text(Text::Font::A12M, Text::Alignment::CENTER, Color::Name::WHITE); + + field = Textfield(Text::Font::A12M, Text::Alignment::LEFT, Color::Name::BLACK, FIELD, Megaphones::MAX_LINE); + field.set_enter_callback( + [&](std::string) + { + send(); + } + ); + field.set_key_callback( + KeyAction::Id::ESCAPE, + [&]() + { + close(); + } + ); + + // Centred, with no title bar to drag it by. + dimension = background.get_dimensions(); + position = Point((Constants::Constants::get().get_viewwidth() - dimension.x()) / 2, + (Constants::Constants::get().get_viewheight() - dimension.y()) / 2); + + field.set_state(Textfield::State::FOCUSED); + } + + void UIItemMegaphone::draw(float alpha) const + { + UIElement::draw(alpha); + + if (shown_id) + { + ItemData::get(shown_id).get_icon(false).draw(position + WELL.get_left_top() + Point(0, 32)); + item_name.draw(position + NAME - Point(0, TEXT_RAISE)); + } + + field.draw(position - Point(0, TEXT_RAISE)); + checkbox[ear].draw(position + CHECK.get_left_top() + CHECK_BOX); + } + + void UIItemMegaphone::update() + { + UIElement::update(); + + field.update(position); + } + + void UIItemMegaphone::remove_cursor() + { + UIElement::remove_cursor(); + + held = false; + clear_tooltip(); + } + + Cursor::State UIItemMegaphone::send_cursor(bool clicked, Point cursorpos) + { + // The well and the checkbox answer only to the button going down. + bool press = clicked && !held; + held = clicked; + + Point cursor = cursorpos - position; + + Cursor::State state = field.send_cursor(cursorpos, clicked); + + if (state != Cursor::State::IDLE) + { + clear_tooltip(); + + return state; + } + + // FUN_005ebbe7 and FUN_005ebc20: a click takes the item back, and + // the item shows its tooltip. + if (shown_id && WELL.contains(cursor)) + { + if (press) + { + set_item(InventoryType::Id::NONE, 0); + clear_tooltip(); + + return Cursor::State::CLICKING; + } + + if (item_tab == InventoryType::Id::EQUIP) + UI::get().show_equip(Tooltip::Parent::ITEMMEGAPHONE, item_slot); + else + UI::get().show_item(Tooltip::Parent::ITEMMEGAPHONE, shown_id); + + return Cursor::State::IDLE; + } + + clear_tooltip(); + + if (CHECK.contains(cursor)) + { + if (press) + ear = !ear; + + return clicked ? Cursor::State::CLICKING : Cursor::State::CANCLICK; + } + + return UIElement::send_cursor(clicked, cursorpos); + } + + void UIItemMegaphone::send_key(int32_t keycode, bool pressed, bool escape) + { + if (!pressed) + return; + + // FUN_005ebd0f: Enter is OK, and Escape is Cancel. + if (escape) + close(); + else if (keycode == KeyAction::Id::RETURN) + send(); + } + + bool UIItemMegaphone::send_icon(const Icon &icon, Point cursorpos) + { + icon.drop_on_megaphone(); + + return true; + } + + UIElement::Type UIItemMegaphone::get_type() const + { + return TYPE; + } + + void UIItemMegaphone::set_item(InventoryType::Id tab, int16_t s) + { + int32_t id = tab == InventoryType::Id::NONE ? 0 : Stage::get().get_player().get_inventory().get_item_id(tab, s); + + item_tab = id ? tab : InventoryType::Id::NONE; + item_slot = id ? s : 0; + shown_id = id; + item_name.change_text(id ? ItemData::get(id).get_name() : ""); + } + + Button::State UIItemMegaphone::button_pressed(uint16_t buttonid) + { + if (buttonid == Buttons::OK) + send(); + else + close(); + + return Button::State::NORMAL; + } + + void UIItemMegaphone::send() + { + // FUN_005ec4b7: nothing typed keeps the window open. + if (field.empty()) + return; + + if (!shown_id) + { + ask(NO_ITEM, [](UIItemMegaphone &window) + { + window.send_checked(); + }); + + return; + } + + send_checked(); + } + + void UIItemMegaphone::send_checked() + { + Player &player = Stage::get().get_player(); + + if (!player.can_request(ExclRequest::WAIT)) + { + notice(REQUEST_BUSY); + + return; + } + + UseItemMegaphonePacket packet(slot, item_id, field.get_text(), ear, item_tab, item_slot); + + if (player.request(std::move(packet), ExclRequest::WAIT)) + Sound(nl::nx::sound["Game.img"]["UseShopItem"]).play(); + + close(); + } + + void UIItemMegaphone::close() + { + release_field(); + clear_tooltip(); + deactivate(); + } + + void UIItemMegaphone::notice(const std::string &text) + { + bool refocus = release_field(); + + UI::get().emplace( + text, + [refocus](bool) + { + after_notice(refocus); + } + ); + } + + void UIItemMegaphone::ask(const std::string &question, std::function on_yes) + { + bool refocus = release_field(); + + UI::get().emplace( + question, + [refocus, on_yes](bool yes) + { + if (auto window = after_notice(refocus); window && yes) + on_yes(*window); + } + ); + } + + Optional UIItemMegaphone::after_notice(bool refocus) + { + auto window = UI::get().get_element(); + + if (!window || !window->is_active()) + return {}; + + if (refocus) + window->field.set_state(Textfield::State::FOCUSED); + + return window; + } + + bool UIItemMegaphone::release_field() + { + if (field.get_state() != Textfield::State::FOCUSED) + return false; + + UI::get().remove_textfield(); + + return true; + } + + void UIItemMegaphone::clear_tooltip() + { + UI::get().clear_tooltip(Tooltip::Parent::ITEMMEGAPHONE); + } +} diff --git a/HeavenClient/IO/UITypes/UIItemMegaphone.h b/HeavenClient/IO/UITypes/UIItemMegaphone.h new file mode 100644 index 000000000..e6c5b66c2 --- /dev/null +++ b/HeavenClient/IO/UITypes/UIItemMegaphone.h @@ -0,0 +1,87 @@ +#pragma once + +#include "../UIElement.h" + +#include "../Components/Textfield.h" + +#include "../../Character/Inventory/InventoryType.h" +#include "../../Template/Optional.h" + +#include +#include +#include + +namespace ms +{ + // The item megaphone's window (v83 FUN_005eb36d on UIWindow.img/ItemMegaphone): a line, an item dragged in from + // the inventory to show with it, and whether listeners get an ear to whisper back by. Unlike the other megaphones + // it leaves the game playable, and sends the line itself. + class UIItemMegaphone : public UIElement + { + public: + static constexpr Type TYPE = UIElement::Type::ITEMMEGAPHONE; + static constexpr bool FOCUSED = false; + static constexpr bool TOGGLED = false; + + // Uses the megaphone `item_id` in Cash slot `slot`. + UIItemMegaphone(int16_t slot, int32_t item_id); + + void draw(float alpha) const override; + + void update() override; + + void remove_cursor() override; + + Cursor::State send_cursor(bool clicked, Point cursorpos) override; + + void send_key(int32_t keycode, bool pressed, bool escape) override; + + bool send_icon(const Icon &icon, Point cursorpos) override; + + UIElement::Type get_type() const override; + + // Shows the item in `item_slot` of `tab`, or none for NONE (FUN_005ebd3f). + void set_item(InventoryType::Id tab, int16_t item_slot); + + protected: + Button::State button_pressed(uint16_t buttonid) override; + + private: + void send(); + + // Sends once the player has said whether to go without an item. + void send_checked(); + + void close(); + + // Raises a notice, giving the field's keys back after it. + void notice(const std::string &text); + + void ask(const std::string &question, std::function on_yes); + + // The open window after a notice, with its field focused again if it was before. + static Optional after_notice(bool refocus); + + // Lets go of the keys for a notice. Returns whether the field had them. + bool release_field(); + + void clear_tooltip(); + + enum Buttons : uint16_t + { + OK, + CANCEL + }; + + int16_t slot; + int32_t item_id; + InventoryType::Id item_tab; + int16_t item_slot; + int32_t shown_id; + Text item_name; + Textfield field; + std::array checkbox; + bool ear; + bool held; + }; +} diff --git a/HeavenClient/IO/UITypes/UIMegaphone.cpp b/HeavenClient/IO/UITypes/UIMegaphone.cpp new file mode 100644 index 000000000..2a8d2891a --- /dev/null +++ b/HeavenClient/IO/UITypes/UIMegaphone.cpp @@ -0,0 +1,305 @@ +#include "UIMegaphone.h" + +#include "UIChatBar.h" +#include "UINotice.h" + +#include "../UI.h" + +#include "../Components/MapleButton.h" + +#include "../../Constants.h" +#include "../../Gameplay/Stage.h" +#include "../../Util/Megaphones.h" +#include "../../Util/NxFiles.h" + +#include + +namespace ms +{ + namespace + { + // From the window's top left. v83 draws a line's cap top about 3 + // below where it is drawn, we 9, so the typed text goes up by the + // difference. + constexpr int16_t TEXT_RAISE = 6; + constexpr Rectangle SUPER_FIELD = Rectangle(19, 245, 58, 73); + constexpr Rectangle TRIPLE_FIELD = Rectangle(19, 414, 63, 79); + constexpr int16_t TRIPLE_LINES[] = { 0, 21, 43 }; + // The checkbox control; its box is drawn 2 right and 1 down in it. + constexpr Rectangle SUPER_CHECK = Rectangle(14, 174, 95, 109); + constexpr Rectangle TRIPLE_CHECK = Rectangle(18, 178, 153, 167); + constexpr Point CHECK_BOX = Point(2, 1); + + // StringPool 0x1172 and 0x1173. + constexpr const char *PREVIEW_EMPTY = "This is a preview option for the Triple Megaphone."; + constexpr const char *LINE_MISSING = "One of the lines has not been applied.\\nWould you still like to use it?"; + } + + UIMegaphone::UIMegaphone(bool t, std::function &, bool)> os) + : UIElement(Point(), Point()), triple(t), onsend(std::move(os)), ear(true), + held(false) + { + nl::node basic = NxFiles::ui_classic["Basic.img"]; + nl::node src = NxFiles::ui_classic["UIWindow.img"][triple ? "ArtMegaphone" : "Megaphone"]; + + Texture background = src["backgrnd"]; + sprites.emplace_back(src["backgrnd"]); + + checkbox[false] = basic["CheckBox"]["0"]; + checkbox[true] = basic["CheckBox"]["1"]; + + if (triple) + { + buttons[Buttons::OK] = std::make_unique(basic["BtOK2"], Point(340, 152)); + buttons[Buttons::CANCEL] = std::make_unique(basic["BtCancel2"], Point(381, 152)); + buttons[Buttons::EXAMPLE] = std::make_unique(src["BtEx"], Point(350, 33)); + buttons[Buttons::PREVIEW] = std::make_unique(src["BtPreView"], Point(277, 152)); + + field_count = MAX_FIELDS; + check_area = TRIPLE_CHECK; + } else + { + buttons[Buttons::OK] = std::make_unique(basic["BtOK2"], Point(146, 126)); + buttons[Buttons::CANCEL] = std::make_unique(basic["BtCancel2"], Point(198, 126)); + + field_count = 1; + check_area = SUPER_CHECK; + } + + for (size_t i = 0; i < field_count; i++) + { + Rectangle bounds = triple ? TRIPLE_FIELD : SUPER_FIELD; + bounds.shift(Point(0, triple ? TRIPLE_LINES[i] : 0)); + + Textfield &field = fields[i]; + field = Textfield(Text::Font::A12M, Text::Alignment::LEFT, Color::Name::BLACK, bounds, + Megaphones::MAX_LINE); + field.set_enter_callback( + [&](std::string) + { + send(); + } + ); + field.set_key_callback( + KeyAction::Id::ESCAPE, + [&]() + { + close(); + } + ); + } + + // Centred, with no title bar to drag it by. + dimension = background.get_dimensions(); + position = Point((Constants::Constants::get().get_viewwidth() - dimension.x()) / 2, + (Constants::Constants::get().get_viewheight() - dimension.y()) / 2); + + fields[0].set_state(Textfield::State::FOCUSED); + } + + void UIMegaphone::draw(float alpha) const + { + UIElement::draw(alpha); + + for (size_t i = 0; i < field_count; i++) + fields[i].draw(position - Point(0, TEXT_RAISE)); + + checkbox[ear].draw(position + check_area.get_left_top() + CHECK_BOX); + } + + void UIMegaphone::update() + { + UIElement::update(); + + for (size_t i = 0; i < field_count; i++) + fields[i].update(position); + } + + void UIMegaphone::remove_cursor() + { + UIElement::remove_cursor(); + + held = false; + } + + Cursor::State UIMegaphone::send_cursor(bool clicked, Point cursorpos) + { + // The checkbox answers only to the button going down. + bool press = clicked && !held; + held = clicked; + + for (size_t i = 0; i < field_count; i++) + { + Cursor::State state = fields[i].send_cursor(cursorpos, clicked); + + if (state != Cursor::State::IDLE) + return state; + } + + if (check_area.contains(cursorpos - position)) + { + if (press) + ear = !ear; + + return clicked ? Cursor::State::CLICKING : Cursor::State::CANCLICK; + } + + return UIElement::send_cursor(clicked, cursorpos); + } + + void UIMegaphone::send_key(int32_t keycode, bool pressed, bool escape) + { + if (!pressed) + return; + + // FUN_004edd21: Enter is OK, and Escape is Cancel. + if (escape) + close(); + else if (keycode == KeyAction::Id::RETURN) + send(); + } + + UIElement::Type UIMegaphone::get_type() const + { + return TYPE; + } + + Button::State UIMegaphone::button_pressed(uint16_t buttonid) + { + switch (buttonid) + { + case Buttons::OK: + send(); + break; + case Buttons::CANCEL: + close(); + break; + case Buttons::PREVIEW: + preview(); + break; + default: + break; + } + + return Button::State::NORMAL; + } + + void UIMegaphone::send() + { + std::vector typed = lines(); + + auto done = [typed](UIMegaphone &window) + { + window.close(); + window.onsend(typed, window.ear); + }; + + // FUN_007e1403: the triple megaphone asks before leaving a line out. + for (const std::string &line : typed) + { + if (line.empty() && triple) + { + ask(LINE_MISSING, done); + + return; + } + } + + done(*this); + } + + void UIMegaphone::preview() + { + std::vector typed = lines(); + + if (Megaphones::check_lines(typed) == Megaphones::Refusal::QUIET) + { + notice(PREVIEW_EMPTY); + + return; + } + + // FUN_007e0c37: each line goes only to my own chat log, as it would be heard. + auto chatbar = UI::get().get_element(); + + if (!chatbar) + return; + + const std::string &name = Stage::get().get_player().get_stats().get_name(); + + for (const std::string &line : typed) + if (!line.empty()) + chatbar->send_chatline(name + Megaphones::SEPARATOR + line, UIChatBar::LineType::GREY); + } + + void UIMegaphone::close() + { + release_field(); + deactivate(); + } + + void UIMegaphone::notice(const std::string &text) + { + int16_t refocus = release_field(); + + UI::get().emplace( + text, + [refocus](bool) + { + after_notice(refocus); + } + ); + } + + void UIMegaphone::ask(const std::string &question, std::function on_yes) + { + int16_t refocus = release_field(); + + UI::get().emplace( + question, + [refocus, on_yes](bool yes) + { + if (auto window = after_notice(refocus); window && yes) + on_yes(*window); + } + ); + } + + Optional UIMegaphone::after_notice(int16_t refocus) + { + auto window = UI::get().get_element(); + + if (!window || !window->is_active()) + return {}; + + if (refocus >= 0) + window->fields[refocus].set_state(Textfield::State::FOCUSED); + + return window; + } + + int16_t UIMegaphone::release_field() + { + for (size_t i = 0; i < field_count; i++) + { + if (fields[i].get_state() == Textfield::State::FOCUSED) + { + UI::get().remove_textfield(); + + return static_cast(i); + } + } + + return -1; + } + + std::vector UIMegaphone::lines() const + { + std::vector typed; + + for (size_t i = 0; i < field_count; i++) + typed.push_back(fields[i].get_text()); + + return typed; + } +} diff --git a/HeavenClient/IO/UITypes/UIMegaphone.h b/HeavenClient/IO/UITypes/UIMegaphone.h new file mode 100644 index 000000000..caab8b61b --- /dev/null +++ b/HeavenClient/IO/UITypes/UIMegaphone.h @@ -0,0 +1,84 @@ +#pragma once + +#include "../UIElement.h" + +#include "../Components/Textfield.h" + +#include "../../Template/Optional.h" + +#include +#include +#include +#include + +namespace ms +{ + // The super megaphone's window (v83 FUN_00a0ece4 on UIWindow.img/Megaphone) and the triple megaphone's + // (FUN_00a0ee22 on ArtMegaphone): one line or three, and whether listeners get an ear to whisper back by. It is + // modal, and closes before the lines are checked. + class UIMegaphone : public UIElement + { + public: + static constexpr Type TYPE = UIElement::Type::MEGAPHONE; + static constexpr bool FOCUSED = true; + static constexpr bool TOGGLED = false; + + // `onsend` gets the lines as typed and the ear. + UIMegaphone(bool triple, std::function &lines, bool ear)> onsend); + + void draw(float alpha) const override; + + void update() override; + + void remove_cursor() override; + + Cursor::State send_cursor(bool clicked, Point cursorpos) override; + + void send_key(int32_t keycode, bool pressed, bool escape) override; + + UIElement::Type get_type() const override; + + protected: + Button::State button_pressed(uint16_t buttonid) override; + + private: + void send(); + + void preview(); + + void close(); + + // Raises a notice, giving the keys back to the field that had them after it. + void notice(const std::string &text); + + void ask(const std::string &question, std::function on_yes); + + // The open window after a notice, with field `refocus` focused again (none for -1). + static Optional after_notice(int16_t refocus); + + // Lets go of the keys for a notice. Returns the field that had them, or -1. + int16_t release_field(); + + std::vector lines() const; + + enum Buttons : uint16_t + { + OK, + CANCEL, + EXAMPLE, + PREVIEW + }; + + static constexpr size_t MAX_FIELDS = 3; + + bool triple; + std::function &lines, bool ear)> onsend; + // Fields don't move once made: the UI keeps a pointer to the one with the keys. + std::array fields; + size_t field_count; + std::array checkbox; + Rectangle check_area; + bool ear; + bool held; + }; +} diff --git a/HeavenClient/Net/Packets/InventoryPackets.h b/HeavenClient/Net/Packets/InventoryPackets.h index 64214b2c9..87af6e025 100644 --- a/HeavenClient/Net/Packets/InventoryPackets.h +++ b/HeavenClient/Net/Packets/InventoryPackets.h @@ -145,6 +145,68 @@ namespace ms } }; + // Uses a super megaphone (FUN_00a0a63f type 0xd): the line, and + // whether it shows an ear to whisper back by. + // Opcode: USE_CASH_ITEM(79) + class UseSuperMegaphonePacket : public OutPacket + { + public: + UseSuperMegaphonePacket(int16_t slot, int32_t itemid, const std::string &text, bool ear) + : OutPacket(OutPacket::Opcode::USE_CASH_ITEM) + { + write_short(slot); + write_int(itemid); + write_string(text); + write_byte(ear); + write_time(); + } + }; + + // Uses an item megaphone (FUN_005ec4b7): a super megaphone's line that + // may show the item in `item_slot` of `tab`, or none for NONE. + // Opcode: USE_CASH_ITEM(79) + class UseItemMegaphonePacket : public OutPacket + { + public: + UseItemMegaphonePacket(int16_t slot, int32_t itemid, const std::string &text, bool ear, InventoryType::Id tab, + int16_t item_slot) : OutPacket(OutPacket::Opcode::USE_CASH_ITEM) + { + write_short(slot); + write_int(itemid); + write_string(text); + write_byte(ear); + write_byte(tab != InventoryType::Id::NONE); + + if (tab != InventoryType::Id::NONE) + { + write_int(tab); + write_int(item_slot); + } + + write_time(); + } + }; + + // Uses a triple megaphone (FUN_00a0a63f type 0x3c): one to three lines. + // Opcode: USE_CASH_ITEM(79) + class UseTripleMegaphonePacket : public OutPacket + { + public: + UseTripleMegaphonePacket(int16_t slot, int32_t itemid, const std::vector &lines, bool ear) + : OutPacket(OutPacket::Opcode::USE_CASH_ITEM) + { + write_short(slot); + write_int(itemid); + write_byte(static_cast(lines.size())); + + for (const std::string &line : lines) + write_string(line); + + write_byte(ear); + write_time(); + } + }; + // Uses the teleport rock (504xxxx) in Cash slot `slot` to go to a // character, or to a kept map (FUN_00a0a4aa). // Opcode: USE_CASH_ITEM(79) diff --git a/HeavenClient/Util/Megaphones.h b/HeavenClient/Util/Megaphones.h index ade7a1659..df5df3226 100644 --- a/HeavenClient/Util/Megaphones.h +++ b/HeavenClient/Util/Megaphones.h @@ -2,6 +2,7 @@ #include #include +#include namespace ms { @@ -43,5 +44,103 @@ namespace ms return first.substr(0, name) + line.substr(text); } + + // The longest line a megaphone sends (StringPool 0x111), and the + // longest it drops without a word. + constexpr size_t MAX_LINE = 60; + constexpr size_t MAX_TYPED = 80; + // The plain and super megaphones are for characters over this level + // (StringPool 0xea1). + constexpr uint16_t MIN_LEVEL = 10; + + // The megaphones the Cash tab uses, by what they ask for + // (FUN_0048645b's 507 kinds: 1 and 8 plain, 2 super, 5 from 6 and 6 + // item, 7 triple; 5 below 6 is Maple TV). + enum class Kind : uint8_t + { + NONE, PLAIN, SUPER, ITEM, TRIPLE + }; + + inline Kind kind(int32_t item_id) + { + if (item_id / 10'000 != 507) + return Kind::NONE; + + switch (item_id % 10'000 / 1'000) + { + case 1: + case 8: + return Kind::PLAIN; + case 2: + return Kind::SUPER; + case 5: + return item_id % 10 >= 6 ? Kind::ITEM : Kind::NONE; + case 6: + return Kind::ITEM; + case 7: + return Kind::TRIPLE; + default: + return Kind::NONE; + } + } + + // Why a line isn't sent. QUIET says nothing. + enum class Refusal : uint8_t + { + NONE, QUIET, CURSING, LEVEL, LONG + }; + + // Checks a plain or super megaphone's line in v83's order, once its + // prompt has closed (FUN_00a0a63f types 0xc, 0xd and 0xf). `censor` + // stars out curse words. + template + Refusal check_line(const std::string& text, uint16_t level, Censor censor) + { + if (text.empty()) + return Refusal::QUIET; + + if (censor(text) != text) + return Refusal::CURSING; + + if (level <= MIN_LEVEL) + return Refusal::LEVEL; + + if (text.size() > MAX_TYPED) + return Refusal::QUIET; + + if (text.size() > MAX_LINE) + return Refusal::LONG; + + return Refusal::NONE; + } + + // A triple megaphone checks only that something is written, and how + // long each line is (type 0x3c). + inline Refusal check_lines(const std::vector& lines) + { + bool written = false; + + for (const std::string& line : lines) + { + if (line.size() > MAX_LINE) + return Refusal::LONG; + + written = written || !line.empty(); + } + + return written ? Refusal::NONE : Refusal::QUIET; + } + + // The lines a triple megaphone sends: up to the last one written, + // with any blank ones before it. + inline std::vector lines_sent(const std::vector& lines) + { + size_t count = lines.size(); + + while (count > 1 && lines[count - 1].empty()) + count--; + + return std::vector(lines.begin(), lines.begin() + count); + } } } diff --git a/HeavenClient/tests/megaphones.cpp b/HeavenClient/tests/megaphones.cpp index aebd25d32..cae31c066 100644 --- a/HeavenClient/tests/megaphones.cpp +++ b/HeavenClient/tests/megaphones.cpp @@ -23,6 +23,34 @@ int main() require(under_name("Hero : one", "Other : two : three") == "Hero : two : three", "a later line takes the first line's name"); require(under_name("Hero : one", "two") == "two", "a line without a name stays as it is"); + + require(kind(5071000) == Kind::PLAIN && kind(5078000) == Kind::PLAIN, "kinds 1 and 8 are plain"); + require(kind(5072000) == Kind::SUPER, "kind 2 is the super megaphone"); + require(kind(5076000) == Kind::ITEM && kind(5075006) == Kind::ITEM, "kind 6, and 5 from 6, show an item"); + require(kind(5075005) == Kind::NONE, "kind 5 below 6 is Maple TV"); + require(kind(5077000) == Kind::TRIPLE, "kind 7 is the triple megaphone"); + require(kind(5073000) == Kind::NONE && kind(5370000) == Kind::NONE, "nothing else is asked for"); + + auto keep = [](const std::string& text) { return text; }; + auto star = [](const std::string& text) { return std::string(text.size(), '*'); }; + const std::string long_line(61, 'a'); + + require(check_line("", 5, star) == Refusal::QUIET, "an empty line goes before anything else"); + require(check_line("hi", 5, star) == Refusal::CURSING, "then cursing"); + require(check_line("hi", 10, keep) == Refusal::LEVEL, "then level 10 and below"); + require(check_line(long_line, 11, keep) == Refusal::LONG, "then over 60"); + require(check_line(std::string(81, 'a'), 11, keep) == Refusal::QUIET, "and over 80 says nothing"); + require(check_line(std::string(60, 'a'), 11, keep) == Refusal::NONE, "60 at level 11 goes"); + + require(check_lines({ "", "", "" }) == Refusal::QUIET, "a triple needs a line"); + require(check_lines({ "", long_line, "" }) == Refusal::LONG, "of 60 at most"); + require(check_lines({ "", "", "c" }) == Refusal::NONE, "in any field"); + + using Lines = std::vector; + require(lines_sent({ "a", "", "" }) == Lines{ "a" }, "blank lines after the last go unsent"); + require(lines_sent({ "", "b", "" }) == Lines{ "", "b" }, "blank lines before it are sent"); + require(lines_sent({ "a", "", "c" }) == Lines{ "a", "", "c" }, "between too"); + require(lines_sent({ "", "", "" }) == Lines{ "" }, "and one at least"); } catch (const std::exception& e) { diff --git a/agent_docs/client.md b/agent_docs/client.md index f9c4b0fee..cf8b065be 100644 --- a/agent_docs/client.md +++ b/agent_docs/client.md @@ -466,7 +466,7 @@ side yet. | Skills, stat/AP/SP allocation, buffs, keymap | Implemented | | NPC dialogue and NPC shop | Implemented (`UINpcTalk`, `UIShop`) | | Quest log and markers | Implemented (`UIQuestLog`); progress arrives via SHOW_STATUS_INFO, quest start/complete is driven by NPC dialogue; UPDATE_QUEST_INFO refusals 9–11, 13 and 14 show v83's notices (10 names the full inventories from `Act.img`), 15 the expiry chat line, and the timer modes are ignored | -| Chat, whisper, `/find`, `/?`, `/party`, `/buddy`, `/all`, server messages, megaphone lines | Implemented; megaphones can't be used yet (see `inventory.md`); buddy whisper/find modes ignored; `/w NAME` alone fills in a whisper to NAME, or says "Incorrect ID." for a name no character could have, as v83's `/whisper NAME` does; other commands are dropped, as v83 does for non-GMs | +| Chat, whisper, `/find`, `/?`, `/party`, `/buddy`, `/all`, server messages, megaphones | Implemented; the megaphone dialogs are in `inventory.md`; buddy whisper/find modes ignored; `/w NAME` alone fills in a whisper to NAME, or says "Incorrect ID." for a name no character could have, as v83's `/whisper NAME` does; other commands are dropped, as v83 does for non-GMs | | Character info, minimap, world map | Implemented | | Party (`UIUserList` Party tab, `UIPartyHP`, party chat) | Implemented; see `parties.md` | | Buddy list (`UIUserList` Friend tab, buddy chat) | Implemented without groups; see `buddies.md` | diff --git a/agent_docs/inventory.md b/agent_docs/inventory.md index 05ddebf81..61e9210a0 100644 --- a/agent_docs/inventory.md +++ b/agent_docs/inventory.md @@ -197,8 +197,29 @@ the plain megaphone's starts with the `UIWindow.img/Megaphone` channel tag (`ch0` with the ear, `ch1` without) and the two-digit channel. A triple megaphone's later lines take the first line's name. Lines are dropped on event maps (`Util/Megaphones.h`); v83 shows them there to GMs, but -HeavenClient doesn't know who is a GM. It has no megaphone dialogs yet: the -items can't be used from the Cash tab. +HeavenClient doesn't know who is a GM. + +Double-clicking a megaphone in the Cash tab opens its dialog +(`UIItemInventory::use_megaphone`), as v83 does: + +- Plain: a text prompt ("Enter your comments through the megaphone.", 60 + characters). After OK, an empty line is dropped, then cursing, a level of + 10 or below, and a line over 60 bytes are refused with v83's notices. +- Super and triple: `UIMegaphone`, a modal window on + `UIWindow.img/Megaphone` (one line) or `ArtMegaphone` (three), with the + "Display whisper icon" checkbox (on at first). It closes before the lines + are checked as above; the triple megaphone asks "One of the lines has not + been applied." before sending with an empty line, and sends the lines up + to the last one typed. Its Preview button writes the lines to the own chat + log only. The "Special character" button does nothing yet. +- Item: `UIItemMegaphone` on `ItemMegaphone`, which leaves the game playable. + An item dropped on it from the inventory shows in its well with its name + and tooltip; clicking the well takes it back. OK with nothing typed does + nothing, and with no item asks first. It sends the line itself, refusing + with "Other requests are in process." while a request is waiting. + +The item megaphone can't be opened in a shop or trade ("Not available"). +The gates are in `Util/Megaphones.h`. ### Chalkboard From 4bd1fdd86b2a0d4f9feaee919688a6089c1cf58f Mon Sep 17 00:00:00 2001 From: David Goldstein Date: Sun, 18 Oct 2026 11:13:58 -0400 Subject: [PATCH 11/11] L179: give the triple megaphone its samples and v83's preview The triple megaphone's Special Character button now opens the item's samples from Item.nx in a modal popup, placed and drawn as v83 does. A sample replaces all three lines, with anything past ASCII turned into '?' as the ANSI code page would. "CREATE NEW" empties the lines. Escape, Enter, or a click outside closes the popup without changing anything. Preview now prints v83's header line and then the lines up to the last one written, empty ones included, in the grey of chat type 0x15 with indented wrapped rows. With every line empty it prints nothing; before, it raised a notice. Both megaphone windows now toggle the whisper checkbox when the button comes up after a press on it, even outside the window, instead of on the press. Tab moves between the triple megaphone's lines. The item megaphone's item name is now left-aligned in its bar and cut to fit with "..". --- HeavenClient/IO/UITypes/UIChatBar.cpp | 3 + HeavenClient/IO/UITypes/UIChatBar.h | 4 +- HeavenClient/IO/UITypes/UIItemInventory.cpp | 4 +- HeavenClient/IO/UITypes/UIItemMegaphone.cpp | 30 ++- HeavenClient/IO/UITypes/UIItemMegaphone.h | 2 + HeavenClient/IO/UITypes/UIMegaphone.cpp | 204 ++++++++++++++++++-- HeavenClient/IO/UITypes/UIMegaphone.h | 37 +++- HeavenClient/Util/Megaphones.h | 26 +++ HeavenClient/tests/megaphones.cpp | 3 + agent_docs/inventory.md | 9 +- 10 files changed, 288 insertions(+), 34 deletions(-) diff --git a/HeavenClient/IO/UITypes/UIChatBar.cpp b/HeavenClient/IO/UITypes/UIChatBar.cpp index 16cbff99e..f079b89df 100644 --- a/HeavenClient/IO/UITypes/UIChatBar.cpp +++ b/HeavenClient/IO/UITypes/UIChatBar.cpp @@ -722,6 +722,9 @@ namespace ms color = Color::Name::ITEMMEGAPHONE; strip = ITEM_MEGAPHONE_STRIP; break; + case LineType::MEGAPHONE_PREVIEW: + color = Color::Name::SILVER; + break; default: color = Color::Name::WHITE; break; diff --git a/HeavenClient/IO/UITypes/UIChatBar.h b/HeavenClient/IO/UITypes/UIChatBar.h index 14117ced1..062288730 100644 --- a/HeavenClient/IO/UITypes/UIChatBar.h +++ b/HeavenClient/IO/UITypes/UIChatBar.h @@ -73,7 +73,9 @@ namespace ms // Megaphones (v83 chat types 0xd to 0xf) MEGAPHONE, SUPER_MEGAPHONE, - ITEM_MEGAPHONE + ITEM_MEGAPHONE, + // A triple megaphone's preview (v83 chat type 0x15) + MEGAPHONE_PREVIEW }; // Who a typed line goes to diff --git a/HeavenClient/IO/UITypes/UIItemInventory.cpp b/HeavenClient/IO/UITypes/UIItemInventory.cpp index 9b465852a..1f633eac3 100644 --- a/HeavenClient/IO/UITypes/UIItemInventory.cpp +++ b/HeavenClient/IO/UITypes/UIItemInventory.cpp @@ -327,14 +327,14 @@ namespace ms }, Megaphones::MAX_LINE); break; case Megaphones::Kind::SUPER: - UI::get().emplace(false, [=](const std::vector &lines, bool ear) + UI::get().emplace(item_id, [=](const std::vector &lines, bool ear) { if (!line_refused(lines[0])) send_megaphone(UseSuperMegaphonePacket(slot, item_id, lines[0], ear)); }); break; case Megaphones::Kind::TRIPLE: - UI::get().emplace(true, [=](const std::vector &lines, bool ear) + UI::get().emplace(item_id, [=](const std::vector &lines, bool ear) { if (!megaphone_refused(Megaphones::check_lines(lines))) send_megaphone(UseTripleMegaphonePacket(slot, item_id, Megaphones::lines_sent(lines), ear)); diff --git a/HeavenClient/IO/UITypes/UIItemMegaphone.cpp b/HeavenClient/IO/UITypes/UIItemMegaphone.cpp index 1b97485b3..b32649e88 100644 --- a/HeavenClient/IO/UITypes/UIItemMegaphone.cpp +++ b/HeavenClient/IO/UITypes/UIItemMegaphone.cpp @@ -11,6 +11,7 @@ #include "../../Gameplay/Stage.h" #include "../../Net/Packets/InventoryPackets.h" #include "../../Util/Megaphones.h" +#include "../../Util/Misc.h" #include "../../Util/NxFiles.h" #include @@ -24,9 +25,10 @@ namespace ms // difference. constexpr int16_t TEXT_RAISE = 6; constexpr Rectangle FIELD = Rectangle(19, 221, 129, 145); - // The item's well, and the bar under it with its name. + // The item's well, and where its name starts in the bar under it, cut to fit. constexpr Rectangle WELL = Rectangle(105, 137, 65, 97); - constexpr Point NAME = Point(120, 101); + constexpr Point NAME = Point(43, 103); + constexpr size_t NAME_WIDTH = 159; // The checkbox control; its box is drawn 2 right and 1 down in it. constexpr Rectangle CHECK = Rectangle(14, 174, 161, 175); constexpr Point CHECK_BOX = Point(2, 1); @@ -38,7 +40,7 @@ namespace ms UIItemMegaphone::UIItemMegaphone(int16_t s, int32_t id) : UIElement(Point(), Point()), slot(s), item_id(id), item_tab(InventoryType::Id::NONE), - item_slot(0), shown_id(0), ear(true), held(false) + item_slot(0), shown_id(0), ear(true), held(false), checking(false) { nl::node basic = NxFiles::ui_classic["Basic.img"]; nl::node src = NxFiles::ui_classic["UIWindow.img"]["ItemMegaphone"]; @@ -52,7 +54,7 @@ namespace ms buttons[Buttons::OK] = std::make_unique(basic["BtOK2"], Point(145, 186)); buttons[Buttons::CANCEL] = std::make_unique(basic["BtCancel2"], Point(186, 186)); - item_name = Text(Text::Font::A12M, Text::Alignment::CENTER, Color::Name::WHITE); + item_name = Text(Text::Font::A12M, Text::Alignment::LEFT, Color::Name::WHITE); field = Textfield(Text::Font::A12M, Text::Alignment::LEFT, Color::Name::BLACK, FIELD, Megaphones::MAX_LINE); field.set_enter_callback( @@ -102,16 +104,31 @@ namespace ms { UIElement::remove_cursor(); + // The button came up outside the window. + if (checking) + { + checking = false; + ear = !ear; + } + held = false; clear_tooltip(); } Cursor::State UIItemMegaphone::send_cursor(bool clicked, Point cursorpos) { - // The well and the checkbox answer only to the button going down. + // The well answers to the button going down. The checkbox toggles when it comes up, wherever it is, after + // going down on it. bool press = clicked && !held; + bool release = !clicked && held; held = clicked; + if (release && checking) + { + checking = false; + ear = !ear; + } + Point cursor = cursorpos - position; Cursor::State state = field.send_cursor(cursorpos, clicked); @@ -148,7 +165,7 @@ namespace ms if (CHECK.contains(cursor)) { if (press) - ear = !ear; + checking = true; return clicked ? Cursor::State::CLICKING : Cursor::State::CANCLICK; } @@ -188,6 +205,7 @@ namespace ms item_slot = id ? s : 0; shown_id = id; item_name.change_text(id ? ItemData::get(id).get_name() : ""); + string_format::format_with_ellipsis(item_name, NAME_WIDTH); } Button::State UIItemMegaphone::button_pressed(uint16_t buttonid) diff --git a/HeavenClient/IO/UITypes/UIItemMegaphone.h b/HeavenClient/IO/UITypes/UIItemMegaphone.h index e6c5b66c2..e26bb2e68 100644 --- a/HeavenClient/IO/UITypes/UIItemMegaphone.h +++ b/HeavenClient/IO/UITypes/UIItemMegaphone.h @@ -83,5 +83,7 @@ namespace ms std::array checkbox; bool ear; bool held; + // The checkbox was pressed, and toggles when the button comes up. + bool checking; }; } diff --git a/HeavenClient/IO/UITypes/UIMegaphone.cpp b/HeavenClient/IO/UITypes/UIMegaphone.cpp index 2a8d2891a..ab24bcd66 100644 --- a/HeavenClient/IO/UITypes/UIMegaphone.cpp +++ b/HeavenClient/IO/UITypes/UIMegaphone.cpp @@ -10,6 +10,7 @@ #include "../../Constants.h" #include "../../Gameplay/Stage.h" #include "../../Util/Megaphones.h" +#include "../../Util/Misc.h" #include "../../Util/NxFiles.h" #include @@ -29,15 +30,25 @@ namespace ms constexpr Rectangle SUPER_CHECK = Rectangle(14, 174, 95, 109); constexpr Rectangle TRIPLE_CHECK = Rectangle(18, 178, 153, 167); constexpr Point CHECK_BOX = Point(2, 1); + // The samples' popup (FUN_00999320 and FUN_009995ae): where it opens from the window's top left, its width, + // its rows, the first sample button in it, and the "CREATE NEW" bar that empties the lines. + constexpr Point SAMPLES = Point(407, 30); + constexpr int16_t SAMPLES_WIDTH = 100; + constexpr int16_t SAMPLE_ROW = 15; + constexpr Point SAMPLES_ROWS = Point(3, 23); + constexpr Point SAMPLE_BUTTON = Point(9, 24); + constexpr Rectangle SAMPLES_CLEAR = Rectangle(11, 76, 10, 21); // StringPool 0x1172 and 0x1173. - constexpr const char *PREVIEW_EMPTY = "This is a preview option for the Triple Megaphone."; + constexpr const char *PREVIEW_HEADER = "This is a preview option for the Triple Megaphone."; constexpr const char *LINE_MISSING = "One of the lines has not been applied.\\nWould you still like to use it?"; } - UIMegaphone::UIMegaphone(bool t, std::function &, bool)> os) - : UIElement(Point(), Point()), triple(t), onsend(std::move(os)), ear(true), - held(false) + UIMegaphone::UIMegaphone(int32_t item_id, std::function &, bool)> os) + : UIElement(Point(), Point()), + triple(Megaphones::kind(item_id) == Megaphones::Kind::TRIPLE), + item(nl::nx::item["Cash"]["0507.img"][string_format::extend_id(item_id, 8)]), onsend(std::move(os)), + ear(true), held(false), checking(false), sampling(false), sample_count(0) { nl::node basic = NxFiles::ui_classic["Basic.img"]; nl::node src = NxFiles::ui_classic["UIWindow.img"][triple ? "ArtMegaphone" : "Megaphone"]; @@ -87,6 +98,16 @@ namespace ms close(); } ); + + // FUN_004edd21 moves through every control, buttons too; here Tab moves through the lines. + if (triple) + field.set_key_callback( + KeyAction::Id::TAB, + [&, i]() + { + focus_field(static_cast((i + 1) % field_count)); + } + ); } // Centred, with no title bar to drag it by. @@ -94,17 +115,67 @@ namespace ms position = Point((Constants::Constants::get().get_viewwidth() - dimension.x()) / 2, (Constants::Constants::get().get_viewheight() - dimension.y()) / 2); + if (triple) + { + // The item's `ui` holds a button for each sample, and the popup's top, rows and bottom. + nl::node ui = item["ui"]; + + samples_top = ui["t"]; + samples_row = ui["c"]; + samples_bottom = ui["s"]; + sample_count = static_cast(ui.size() > 3 ? ui.size() - 3 : 0); + + // Kept on the screen. + int16_t height = (sample_count + 2) * SAMPLE_ROW; + Point screen = position + SAMPLES; + + if (screen.y() + height > Constants::Constants::get().get_viewheight()) + screen.set_y(Constants::Constants::get().get_viewheight() - height); + + if (screen.x() + SAMPLES_WIDTH > Constants::Constants::get().get_viewwidth()) + screen.set_x(Constants::Constants::get().get_viewwidth() - SAMPLES_WIDTH); + + samples_pos = screen - position; + + for (uint16_t i = 0; i < sample_count; i++) + { + auto button = std::make_unique( + ui[std::to_string(i)], samples_pos + SAMPLE_BUTTON + Point(0, SAMPLE_ROW * i)); + button->set_active(false); + buttons[Buttons::SAMPLE + i] = std::move(button); + } + } + fields[0].set_state(Textfield::State::FOCUSED); } void UIMegaphone::draw(float alpha) const { - UIElement::draw(alpha); + draw_sprites(alpha); + + for (auto &iter : buttons) + if (iter.first < Buttons::SAMPLE) + iter.second->draw(position); for (size_t i = 0; i < field_count; i++) fields[i].draw(position - Point(0, TEXT_RAISE)); checkbox[ear].draw(position + check_area.get_left_top() + CHECK_BOX); + + if (!sampling) + return; + + Point popup = position + samples_pos; + + samples_top.draw(popup); + + for (uint16_t i = 0; i < sample_count; i++) + samples_row.draw(popup + SAMPLES_ROWS + Point(0, SAMPLE_ROW * i)); + + samples_bottom.draw(popup + SAMPLES_ROWS + Point(0, SAMPLE_ROW * sample_count)); + + for (uint16_t i = 0; i < sample_count; i++) + buttons.at(Buttons::SAMPLE + i)->draw(position); } void UIMegaphone::update() @@ -120,14 +191,25 @@ namespace ms UIElement::remove_cursor(); held = false; + checking = false; } Cursor::State UIMegaphone::send_cursor(bool clicked, Point cursorpos) { - // The checkbox answers only to the button going down. bool press = clicked && !held; + bool release = !clicked && held; held = clicked; + if (sampling) + return send_samples_cursor(press, release, clicked, cursorpos); + + // The checkbox toggles when the button comes up, wherever it is, after going down on it. + if (release && checking) + { + checking = false; + ear = !ear; + } + for (size_t i = 0; i < field_count; i++) { Cursor::State state = fields[i].send_cursor(cursorpos, clicked); @@ -139,7 +221,7 @@ namespace ms if (check_area.contains(cursorpos - position)) { if (press) - ear = !ear; + checking = true; return clicked ? Cursor::State::CLICKING : Cursor::State::CANCLICK; } @@ -147,16 +229,52 @@ namespace ms return UIElement::send_cursor(clicked, cursorpos); } + Cursor::State UIMegaphone::send_samples_cursor(bool press, bool release, bool clicked, Point cursorpos) + { + // FUN_00999b5b: the popup has the mouse to itself. The button going down or up outside it closes it, and + // going down on the "CREATE NEW" bar empties the lines. + Point cursor = cursorpos - position - samples_pos; + Rectangle popup = Rectangle(0, SAMPLES_WIDTH - 1, 0, (sample_count + 2) * SAMPLE_ROW - 1); + + if ((press || release) && !popup.contains(cursor)) + { + UIElement::send_cursor(clicked, cursorpos); + use_sample(std::nullopt); + + return Cursor::State::IDLE; + } + + if (press && SAMPLES_CLEAR.contains(cursor)) + { + for (size_t i = 0; i < field_count; i++) + fields[i].change_text(""); + + use_sample(std::nullopt); + + return Cursor::State::IDLE; + } + + return UIElement::send_cursor(clicked, cursorpos); + } + void UIMegaphone::send_key(int32_t keycode, bool pressed, bool escape) { if (!pressed) return; - // FUN_004edd21: Enter is OK, and Escape is Cancel. - if (escape) + // FUN_004edd21: Enter is OK, and Escape is Cancel. Over the samples, Escape closes them, and Enter picks + // none. + if (sampling) + { + if (escape || keycode == KeyAction::Id::RETURN) + use_sample(std::nullopt); + } else if (escape) + { close(); - else if (keycode == KeyAction::Id::RETURN) + } else if (keycode == KeyAction::Id::RETURN) + { send(); + } } UIElement::Type UIMegaphone::get_type() const @@ -174,16 +292,27 @@ namespace ms case Buttons::CANCEL: close(); break; + case Buttons::EXAMPLE: + open_samples(); + break; case Buttons::PREVIEW: preview(); break; default: + if (buttonid >= Buttons::SAMPLE) + use_sample(static_cast(buttonid - Buttons::SAMPLE)); + break; } return Button::State::NORMAL; } + bool UIMegaphone::button_covered(uint16_t buttonid, Point) const + { + return sampling && buttonid < Buttons::SAMPLE; + } + void UIMegaphone::send() { std::vector typed = lines(); @@ -208,18 +337,54 @@ namespace ms done(*this); } - void UIMegaphone::preview() + void UIMegaphone::open_samples() { - std::vector typed = lines(); + // FUN_007e0fbf: the samples are modal over the window. Clicking the button took the keys from the lines, and + // they don't get them back. + sampling = true; + checking = false; + + for (uint16_t i = 0; i < sample_count; i++) + { + Button &button = *buttons[Buttons::SAMPLE + i]; + button.set_state(Button::State::NORMAL); + button.set_active(true); + } + } - if (Megaphones::check_lines(typed) == Megaphones::Refusal::QUIET) + void UIMegaphone::use_sample(std::optional index) + { + // FUN_007e1052: a sample replaces all three lines as they are, without the typed limit, and a line it + // lacks is left empty. + if (index) { - notice(PREVIEW_EMPTY); + nl::node sample = item["sample"][std::to_string(*index)]; - return; + for (size_t i = 0; i < field_count; i++) + fields[i].change_text(Megaphones::sample_line(sample[std::to_string(i)].get_string())); } - // FUN_007e0c37: each line goes only to my own chat log, as it would be heard. + sampling = false; + + for (uint16_t i = 0; i < sample_count; i++) + buttons[Buttons::SAMPLE + i]->set_active(false); + } + + void UIMegaphone::focus_field(int16_t index) + { + if (index >= 0) + fields[index].set_state(Textfield::State::FOCUSED); + } + + void UIMegaphone::preview() + { + std::vector shown = Megaphones::lines_sent(lines()); + + // FUN_007e0c37: nothing written shows nothing. Otherwise a header, then the lines up to the last one + // written, only in my own chat log, as they would be heard. + if (shown.size() == 1 && shown[0].empty()) + return; + auto chatbar = UI::get().get_element(); if (!chatbar) @@ -227,9 +392,10 @@ namespace ms const std::string &name = Stage::get().get_player().get_stats().get_name(); - for (const std::string &line : typed) - if (!line.empty()) - chatbar->send_chatline(name + Megaphones::SEPARATOR + line, UIChatBar::LineType::GREY); + chatbar->send_chatline(PREVIEW_HEADER, UIChatBar::LineType::GREY); + + for (const std::string &line : shown) + chatbar->send_chatline(name + Megaphones::SEPARATOR + line, UIChatBar::LineType::MEGAPHONE_PREVIEW); } void UIMegaphone::close() diff --git a/HeavenClient/IO/UITypes/UIMegaphone.h b/HeavenClient/IO/UITypes/UIMegaphone.h index caab8b61b..3a967081f 100644 --- a/HeavenClient/IO/UITypes/UIMegaphone.h +++ b/HeavenClient/IO/UITypes/UIMegaphone.h @@ -6,8 +6,11 @@ #include "../../Template/Optional.h" +#include + #include #include +#include #include #include @@ -15,7 +18,8 @@ namespace ms { // The super megaphone's window (v83 FUN_00a0ece4 on UIWindow.img/Megaphone) and the triple megaphone's // (FUN_00a0ee22 on ArtMegaphone): one line or three, and whether listeners get an ear to whisper back by. It is - // modal, and closes before the lines are checked. + // modal, and closes before the lines are checked. The triple megaphone's Special Character button opens the item's + // samples over it (FUN_00999320). class UIMegaphone : public UIElement { public: @@ -23,8 +27,8 @@ namespace ms static constexpr bool FOCUSED = true; static constexpr bool TOGGLED = false; - // `onsend` gets the lines as typed and the ear. - UIMegaphone(bool triple, std::function &lines, bool ear)> onsend); + // For megaphone `item_id`. `onsend` gets the lines as typed and the ear. + UIMegaphone(int32_t item_id, std::function &lines, bool ear)> onsend); void draw(float alpha) const override; @@ -41,9 +45,21 @@ namespace ms protected: Button::State button_pressed(uint16_t buttonid) override; + bool button_covered(uint16_t buttonid, Point cursorpos) const override; + private: void send(); + void open_samples(); + + // Closes the samples, putting sample `index` in the lines if one was picked. + void use_sample(std::optional index); + + Cursor::State send_samples_cursor(bool press, bool release, bool clicked, Point cursorpos); + + // Gives the keys to field `index`, if there is one. + void focus_field(int16_t index); + void preview(); void close(); @@ -66,12 +82,16 @@ namespace ms OK, CANCEL, EXAMPLE, - PREVIEW + PREVIEW, + // The samples follow, one button each. + SAMPLE }; static constexpr size_t MAX_FIELDS = 3; bool triple; + // Item.nx's Cash/0507.img entry for the megaphone. + nl::node item; std::function &lines, bool ear)> onsend; // Fields don't move once made: the UI keeps a pointer to the one with the keys. std::array fields; @@ -80,5 +100,14 @@ namespace ms Rectangle check_area; bool ear; bool held; + // The checkbox was pressed, and toggles when the button comes up. + bool checking; + // The samples' popup: whether it is open, where it is, and its art. + bool sampling; + Point samples_pos; + uint16_t sample_count; + Texture samples_top; + Texture samples_row; + Texture samples_bottom; }; } diff --git a/HeavenClient/Util/Megaphones.h b/HeavenClient/Util/Megaphones.h index df5df3226..aa9ad8d2e 100644 --- a/HeavenClient/Util/Megaphones.h +++ b/HeavenClient/Util/Megaphones.h @@ -142,5 +142,31 @@ namespace ms return std::vector(lines.begin(), lines.begin() + count); } + + // A triple megaphone's sample line as v83 puts it in a field: through + // the ANSI code page, where anything past ASCII here becomes '?' + // (FUN_007e1052). Latin-1 letters would survive on a Western page, but + // the samples have none. + inline std::string sample_line(const std::string& utf8) + { + std::string line; + + for (size_t i = 0; i < utf8.size();) + { + auto lead = static_cast(utf8[i]); + + if (lead < 0x80) + { + line += utf8[i]; + i++; + } else + { + line += '?'; + i += lead >= 0xf0 ? 4 : lead >= 0xe0 ? 3 : lead >= 0xc0 ? 2 : 1; + } + } + + return line; + } } } diff --git a/HeavenClient/tests/megaphones.cpp b/HeavenClient/tests/megaphones.cpp index cae31c066..fc1bd5d50 100644 --- a/HeavenClient/tests/megaphones.cpp +++ b/HeavenClient/tests/megaphones.cpp @@ -51,6 +51,9 @@ int main() require(lines_sent({ "", "b", "" }) == Lines{ "", "b" }, "blank lines before it are sent"); require(lines_sent({ "a", "", "c" }) == Lines{ "a", "", "c" }, "between too"); require(lines_sent({ "", "", "" }) == Lines{ "" }, "and one at least"); + + require(sample_line("[***] M A P") == "[***] M A P", "a sample line stays as it is"); + require(sample_line("\xe3\x80\x80[^^^]") == "?[^^^]", "but for what the code page lacks"); } catch (const std::exception& e) { diff --git a/agent_docs/inventory.md b/agent_docs/inventory.md index 61e9210a0..17175ed31 100644 --- a/agent_docs/inventory.md +++ b/agent_docs/inventory.md @@ -210,8 +210,13 @@ Double-clicking a megaphone in the Cash tab opens its dialog "Display whisper icon" checkbox (on at first). It closes before the lines are checked as above; the triple megaphone asks "One of the lines has not been applied." before sending with an empty line, and sends the lines up - to the last one typed. Its Preview button writes the lines to the own chat - log only. The "Special character" button does nothing yet. + to the last one typed. Tab moves to the next line. Preview writes "This is + a preview option for the Triple Megaphone." and the lines up to the last + one typed to the own chat log only, and nothing when all are empty. + "Special character" opens the item's samples (`Item.nx` + `Cash/0507.img//ui` and `sample`) in a modal popup: a sample replaces + all three lines, "CREATE NEW" empties them, and Escape or a click outside + closes it. - Item: `UIItemMegaphone` on `ItemMegaphone`, which leaves the game playable. An item dropped on it from the inventory shows in its well with its name and tooltip; clicking the well takes it back. OK with nothing typed does