diff --git a/HeavenClient/CMakeLists.txt b/HeavenClient/CMakeLists.txt index f54a97ad..f972b579 100644 --- a/HeavenClient/CMakeLists.txt +++ b/HeavenClient/CMakeLists.txt @@ -163,6 +163,8 @@ if(HEAVENCLIENT_BUILD_TESTS) add_test(NAME equip_cash COMMAND equip_cash_tests) add_executable(item_guard_tests tests/item_guard.cpp) add_test(NAME item_guard COMMAND item_guard_tests) + add_executable(remote_gachapon_tests tests/remote_gachapon.cpp) + add_test(NAME remote_gachapon COMMAND remote_gachapon_tests) add_executable(incubator_tests tests/incubator.cpp) add_test(NAME incubator COMMAND incubator_tests) add_executable(karma_scissors_tests tests/karma_scissors.cpp) @@ -1033,6 +1035,8 @@ add_executable(HeavenClient IO/UITypes/UIIncubator.h IO/UITypes/UIKarmaScissors.cpp IO/UITypes/UIKarmaScissors.h + IO/UITypes/UIRemoteGachapon.cpp + IO/UITypes/UIRemoteGachapon.h IO/UITypes/UIVegaSpell.cpp IO/UITypes/UIVegaSpell.h IO/UITypes/UIEnchantSkill.cpp diff --git a/HeavenClient/Character/Inventory/RemoteGachapon.h b/HeavenClient/Character/Inventory/RemoteGachapon.h new file mode 100644 index 00000000..c72fac61 --- /dev/null +++ b/HeavenClient/Character/Inventory/RemoteGachapon.h @@ -0,0 +1,38 @@ +#pragma once + +#include +#include + +namespace ms +{ + // The Remote Gachapon Ticket (5451xxx, v83 cash type 0x3b, report b196). Double-clicked in the Cash tab it opens + // a window of ten towns; a town's button sends the ticket with the button's index and closes the window, and the + // server answers as that town's machine. + namespace remote_gachapon + { + constexpr int32_t TICKET = 5451000; + + // 0x48645b: class 545 is type 0x3b only for 5451xxx; Miu Miu (5450000) is type 0x25. + constexpr bool is_ticket(int32_t item_id) + { + return item_id / 1000 == 5451; + } + + struct Place + { + int16_t x; + int16_t y; + }; + + // UIWindow.img/RemoteGachapon/Bt0..Bt9 from the window's top left (0x8111ac), in the order of the index + // they send (0x811a64): Henesys, Ellinia, Perion, Kerning City, Sleepywood, Mushroom Shrine, the Showa Spa's + // men's and women's, New Leaf City and Nautilus Harbor. The screen's order differs. + constexpr std::array TOWNS = {{ + { 15, 113 }, { 128, 113 }, { 15, 134 }, { 128, 134 }, { 128, 155 }, + { 15, 176 }, { 15, 197 }, { 128, 197 }, { 128, 176 }, { 15, 155 } + }}; + + // Basic.img/BtClose. + constexpr Place CLOSE = { 227, 8 }; + } +} diff --git a/HeavenClient/IO/UI.cpp b/HeavenClient/IO/UI.cpp index 533a1405..0a37fd13 100644 --- a/HeavenClient/IO/UI.cpp +++ b/HeavenClient/IO/UI.cpp @@ -495,6 +495,7 @@ namespace ms types.emplace_back(UIElement::Type::VEGASPELL); types.emplace_back(UIElement::Type::ITEMPROTECTOR); types.emplace_back(UIElement::Type::KARMASCISSORS); + types.emplace_back(UIElement::Type::REMOTEGACHAPON); types.emplace_back(UIElement::Type::INCUBATOR); types.emplace_back(UIElement::Type::INVITATION); types.emplace_back(UIElement::Type::VEGARESULT); @@ -532,6 +533,7 @@ namespace ms types.emplace_back(UIElement::Type::MAPLETV); types.emplace_back(UIElement::Type::ITEMPROTECTOR); types.emplace_back(UIElement::Type::KARMASCISSORS); + types.emplace_back(UIElement::Type::REMOTEGACHAPON); types.emplace_back(UIElement::Type::INCUBATOR); types.emplace_back(UIElement::Type::INVITATION); types.emplace_back(UIElement::Type::MERCHANTVISITORS); diff --git a/HeavenClient/IO/UIElement.h b/HeavenClient/IO/UIElement.h index 807ead3b..ba6ee5e7 100644 --- a/HeavenClient/IO/UIElement.h +++ b/HeavenClient/IO/UIElement.h @@ -96,6 +96,7 @@ namespace ms VEGASPELL, ITEMPROTECTOR, KARMASCISSORS, + REMOTEGACHAPON, VEGARESULT, PETCHOOSER, APRESET, diff --git a/HeavenClient/IO/UITypes/UIItemInventory.cpp b/HeavenClient/IO/UITypes/UIItemInventory.cpp index e078cca2..0157f0c2 100644 --- a/HeavenClient/IO/UITypes/UIItemInventory.cpp +++ b/HeavenClient/IO/UITypes/UIItemInventory.cpp @@ -25,6 +25,7 @@ #include "UIItemProtector.h" #include "UIIncubator.h" #include "UIKarmaScissors.h" +#include "UIRemoteGachapon.h" #include "UIMemoSend.h" #include "UINameChange.h" #include "UIKite.h" @@ -59,6 +60,7 @@ #include "../../Character/Inventory/CashUpgrades.h" #include "../../Character/Inventory/EquipCash.h" #include "../../Character/Inventory/Incubator.h" +#include "../../Character/Inventory/RemoteGachapon.h" #include "../../Character/Inventory/ItemDrop.h" #include "../../Character/Inventory/ScrollRules.h" #include "../../Data/CurseData.h" @@ -1521,6 +1523,14 @@ namespace ms if (may_use_cash_item() && !in_dialog()) UI::get().emplace(slot, item_id, InventoryType::Id::NONE, 0); } + // A Remote Gachapon Ticket (type 0x3b) opens its + // window once the last request is answered + // (0x4efd25's silent wait, 0x485bf7). + else if (remote_gachapon::is_ticket(item_id)) + { + if (Stage::get().get_player().can_request(ExclRequest::WAIT)) + UI::get().emplace(item_id); + } // A Water of Life (518xxxx) asks the server, which // offers to revive a doll. else if (item_id / 10000 == 518) diff --git a/HeavenClient/IO/UITypes/UIRemoteGachapon.cpp b/HeavenClient/IO/UITypes/UIRemoteGachapon.cpp new file mode 100644 index 00000000..64906abc --- /dev/null +++ b/HeavenClient/IO/UITypes/UIRemoteGachapon.cpp @@ -0,0 +1,101 @@ +#include "UIRemoteGachapon.h" + +#include "../Components/MapleButton.h" + +#include "../../Character/Inventory/RemoteGachapon.h" +#include "../../Constants.h" +#include "../../Net/Packets/GameplayPackets.h" +#include "../../Util/NxFiles.h" + +#include + +namespace ms +{ + namespace + { + constexpr int16_t DRAG_HEIGHT = 25; + + Point point(remote_gachapon::Place place) + { + return Point(place.x, place.y); + } + } + + UIRemoteGachapon::UIRemoteGachapon(int32_t id) + : UIElement(Point(), Point()), item_id(id), dragged(false) + { + nl::node src = NxFiles::ui_classic["UIWindow.img"]["RemoteGachapon"]; + + Texture background = src["backgrnd"]; + sprites.emplace_back(src["backgrnd"]); + + for (uint16_t town = 0; town < remote_gachapon::TOWNS.size(); town++) + buttons[town] = std::make_unique(src["Bt" + std::to_string(town)], + point(remote_gachapon::TOWNS[town])); + + buttons[CLOSE] = std::make_unique(NxFiles::ui_classic["Basic.img"]["BtClose"], + point(remote_gachapon::CLOSE)); + + // Centred, as v83 centres it in 800x600 (0x4edaef). + dimension = background.get_dimensions(); + position = Point((Constants::Constants::get().get_viewwidth() - dimension.x()) / 2, + (Constants::Constants::get().get_viewheight() - dimension.y()) / 2); + } + + void UIRemoteGachapon::remove_cursor() + { + UIElement::remove_cursor(); + + dragged = false; + } + + Cursor::State UIRemoteGachapon::send_cursor(bool clicked, Point cursorpos) + { + if (dragged) + { + if (clicked) + { + position = cursorpos - drag_from; + + return Cursor::State::CLICKING; + } + + dragged = false; + } + + Point cursor = cursorpos - position; + Cursor::State ret = UIElement::send_cursor(clicked, cursorpos); + + // The top is the caption. + if (clicked && cursor.y() >= 0 && cursor.y() < DRAG_HEIGHT && !is_holding_button()) + { + drag_from = cursor; + dragged = true; + } + + return ret; + } + + void UIRemoteGachapon::send_key(int32_t keycode, bool pressed, bool escape) + { + // Escape and Enter both close it without sending (0x4edd21). + if (pressed && (escape || keycode == KeyAction::Id::RETURN)) + deactivate(); + } + + UIElement::Type UIRemoteGachapon::get_type() const + { + return TYPE; + } + + Button::State UIRemoteGachapon::button_pressed(uint16_t buttonid) + { + // 0x811a64: a town sends the ticket and its index, then the window closes either way. + if (buttonid < remote_gachapon::TOWNS.size()) + UseRemoteGachaponPacket(item_id, buttonid).dispatch(); + + deactivate(); + + return Button::State::NORMAL; + } +} diff --git a/HeavenClient/IO/UITypes/UIRemoteGachapon.h b/HeavenClient/IO/UITypes/UIRemoteGachapon.h new file mode 100644 index 00000000..bcb85f51 --- /dev/null +++ b/HeavenClient/IO/UITypes/UIRemoteGachapon.h @@ -0,0 +1,38 @@ +#pragma once + +#include "../UIElement.h" + +namespace ms +{ + // The Remote Gachapon Ticket's window (v83 0x81104e on UIWindow.img/RemoteGachapon, report b196): a button for + // each town's machine, which sends the ticket with the town and closes the window. Nothing else is asked. + class UIRemoteGachapon : public UIElement + { + public: + static constexpr Type TYPE = UIElement::Type::REMOTEGACHAPON; + static constexpr bool FOCUSED = false; + static constexpr bool TOGGLED = false; + + // For the ticket `item_id`. + UIRemoteGachapon(int32_t item_id); + + 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: + // After the towns' buttons, 0 to 9. + static constexpr uint16_t CLOSE = 10; + + int32_t item_id; + bool dragged; + Point drag_from; + }; +} diff --git a/HeavenClient/Net/OutPacket.h b/HeavenClient/Net/OutPacket.h index 950372f2..aa3bd84d 100644 --- a/HeavenClient/Net/OutPacket.h +++ b/HeavenClient/Net/OutPacket.h @@ -153,6 +153,7 @@ namespace ms SKILL_MACRO = 110, USE_ITEM_REWARD = 112, ITEM_MAKE = 113, + REMOTE_GACHAPON = 116, WATER_OF_LIFE = 117, ADMIN_BROADCAST = 118, PARTY_OPERATION = 124, diff --git a/HeavenClient/Net/Packets/GameplayPackets.h b/HeavenClient/Net/Packets/GameplayPackets.h index 3cd6bcf3..0fb2539e 100644 --- a/HeavenClient/Net/Packets/GameplayPackets.h +++ b/HeavenClient/Net/Packets/GameplayPackets.h @@ -487,6 +487,19 @@ namespace ms } }; + // A Remote Gachapon Ticket's window picked town `index` (0xa14074): the + // ticket's item id, then the index. + // Opcode: REMOTE_GACHAPON(116) + class UseRemoteGachaponPacket : public OutPacket + { + public: + UseRemoteGachaponPacket(int32_t item_id, int32_t index) : OutPacket(OutPacket::Opcode::REMOTE_GACHAPON) + { + write_int(item_id); + write_int(index); + } + }; + // Asks the server to revive a doll with the Water of Life just used // (FUN_00a1dce6); it carries nothing. // Opcode: WATER_OF_LIFE(117) diff --git a/HeavenClient/tests/remote_gachapon.cpp b/HeavenClient/tests/remote_gachapon.cpp new file mode 100644 index 00000000..4ee56143 --- /dev/null +++ b/HeavenClient/tests/remote_gachapon.cpp @@ -0,0 +1,42 @@ +#include "../Character/Inventory/RemoteGachapon.h" +#include +#include +#include +#include + +void require(bool condition, const char* message) +{ + if (!condition) throw std::runtime_error(message); +} + +int main() +{ + try + { + using namespace ms::remote_gachapon; + + require(is_ticket(TICKET) && is_ticket(5451999), "5451xxx are tickets"); + require(!is_ticket(5450000) && !is_ticket(5220000), "Miu Miu and the Gachapon Ticket aren't"); + + // The background is 246x235 and a town's button 103x18 (UIClassic.nx). + std::set> places; + for (Place town : TOWNS) + { + require(town.x >= 0 && town.x + 103 <= 246 && town.y >= 0 && town.y + 18 <= 235, "a town fits"); + places.insert({ town.x, town.y }); + } + require(places.size() == TOWNS.size(), "no two towns overlap"); + require(TOWNS[0].x == 15 && TOWNS[0].y == 113, "Henesys sends 0 from the top left"); + require(TOWNS[4].x == 128 && TOWNS[4].y == 155, "Sleepywood sends 4 from the third row's right"); + require(TOWNS[9].x == 15 && TOWNS[9].y == 155, "Nautilus Harbor sends 9 from the third row's left"); + require(CLOSE.x + 12 <= 246, "the close button fits"); + } + catch (const std::exception& e) + { + std::cerr << "FAILED: " << e.what() << std::endl; + return 1; + } + + std::cout << "remote_gachapon tests passed" << std::endl; + return 0; +} diff --git a/agent_docs/fields.md b/agent_docs/fields.md index 0b0418dd..73b1fa16 100644 --- a/agent_docs/fields.md +++ b/agent_docs/fields.md @@ -137,7 +137,8 @@ In the shop, `CASHSHOP_OPERATION` (0xE5) buys a commodity and sets the wish list (`net/src/packet/handle/world/cash_shop.rs`). A purchase is checked against `Etc.nx` `Commodity.img` (`net::cash_shop`): the item must be on sale and allowed for the character's gender. Cash items that don't work here yet count as sold -out, alone or in a package: Character Transfer, Maple Life and Remote Gachapon +out, alone or in a package: Character Transfer and Maple Life, and Remote +Gachapon's class but for Miu Miu and the Remote Gachapon Ticket (`INERT_CLASSES`). `db::cash_shop::buy` then takes the price from the chosen balance and adds the item to the Cash Inventory in one transaction. The client gets the item (0x57) and its new balance, or a failed purchase (0x58, diff --git a/agent_docs/inventory.md b/agent_docs/inventory.md index 155b6233..4672c183 100644 --- a/agent_docs/inventory.md +++ b/agent_docs/inventory.md @@ -980,8 +980,23 @@ and the item as in an inventory. The v83 client shows it as a chat line, on hover; a channel of -1 would name the Cash Shop Surprise. HeavenClient reads it in `MessagingHandlers.cpp` (case 11), writes the line with `Megaphones::gachapon` and shows it as `UIChatBar::GACHAPON`, without the -tooltip. The Remote Gachapon (5451000, USE_REMOTE_GACHAPON `0x74`) isn't -handled and isn't sold. +tooltip. + +The Remote Gachapon Ticket (5451000, report b196) opens a window of ten town +buttons in the v83 client; a button sends USE_REMOTE (`0x74`, 0xa14074): +`i32` the ticket's item id, `i32` the button, 0 to 9, which are the machines +in `MACHINES`' order (`gachapon::remote_machine`). The client closes the +window and expects no answer of its own. `RemoteGachaponHandler` ignores +another item or button (Cosmic disconnects), and the machine's script +(`NpcScript::remote_gachapon`) answers as its NPC wherever the player is. +As Cosmic's `gachaponRemote` has it, there is no yes or no: the first page +names the prize as `Effect::Gachapon` takes the remote ticket, with the +same full-bag check and world notice as a machine. Without the ticket +nothing is said. HeavenClient opens the window (`UIRemoteGachapon`, +UIClassic's `UIWindow.img/RemoteGachapon`, the buttons where the v83 client +puts them, `remote_gachapon::TOWNS`) for any 5451xxx double-clicked in the +Cash tab once the last request is answered, and sends `UseRemoteGachaponPacket`; +Escape and Enter close it without sending. The Cash Shop sells the ticket. ### Item Maker diff --git a/agent_docs/npc-dialogue.md b/agent_docs/npc-dialogue.md index 61c3c8ce..3eafbe1b 100644 --- a/agent_docs/npc-dialogue.md +++ b/agent_docs/npc-dialogue.md @@ -101,7 +101,7 @@ a conversation already open. | `hair_*`, `face_*`, `lens_*`, `skin_*` | Clicking one of 53 stylists: hair salons, plastic surgeons, lens shops and skin care (`net::stylists`) | A menu of the NPC's services, each for its coupon. A VIP coupon lets the player pick the look on a preview; a regular or experimental one rolls it after a yes. A cut keeps the hair's colour and a face its lens colour, while a dye or a lens changes only the colour; a style that doesn't come in the player's colour is offered in colour 0. A salon's hair membership card (5420000-06) makes its cuts free and is kept, and a one-time lens (5152100-07) pays for its own colour. Without the coupon the NPC says so and nothing changes. Claudia (9201039) is not done yet | | `multipet_success` | Clicking the hidden spot (9102100) on the Pet-Walking Road (100000202) | While Bartos's test (4646) is started, yes gives the note (4031921) the test asks for, once; otherwise one page | | `q4647e` | QUEST_ACTION 5 for quest 4647 (Bartos) | Two pages, then takes a Pet Snack (5460000), completes the quest and teaches Follow the Lead for the player's line (8, 10000018 or 20000024); without a snack, one page | -| Gachapon machines | Clicking a machine (9100100-9100107, 9100109, 9100117) | With a Gachapon Ticket (5220000), yes trades it for the prize rolled when the conversation opened, and the next page names it; without one, a menu explains the machine. See `agent_docs/inventory.md`, "Gachapon" | +| Gachapon machines | Clicking a machine (9100100-9100107, 9100109, 9100117) | With a Gachapon Ticket (5220000), yes trades it for the prize rolled when the conversation opened, and the next page names it; without one, a menu explains the machine. A Remote Gachapon Ticket (5451000) plays a machine from afar with no yes, and the machine names the prize at once. See `agent_docs/inventory.md`, "Gachapon" | The five Explorer instructors (`INSTRUCTORS` in `npc_scripts.rs`) make a beginner a Warrior, Magician, Bowman, Thief, or Pirate. The beginner needs diff --git a/integration-harness/tests/gachapon.rs b/integration-harness/tests/gachapon.rs index ca07583e..448e74a0 100644 --- a/integration-harness/tests/gachapon.rs +++ b/integration-harness/tests/gachapon.rs @@ -3,6 +3,7 @@ //! every roll to the same uncommon prize, a Scroll for Bow for ATT 10% //! (RUSTMS_GACHAPON_SEED in docker-compose.test.yml). A full bag keeps the //! ticket before any roll, and without one the machine only explains itself. +//! A Remote Gachapon Ticket's window plays any machine from afar, at once. use integration_harness::database::grant_item; use integration_harness::events::talk; @@ -10,11 +11,17 @@ use integration_harness::preconditions::load_harness_config_or_fail; use integration_harness::quests::answer; use integration_harness::trace::{read_for, send, until, warp, Trace}; use integration_harness::{login_to_world_session, relogin, HarnessConfig, WorldSession}; +use net::packet::op::RecvOpcode; +use packet::io::write::PktWrite; +use packet::Packet; use std::time::Duration; const HENESYS: i32 = 100_000_100; const MACHINE: i32 = 9_100_100; const TICKET: i32 = 5_220_000; +const REMOTE_TICKET: i32 = 5_451_000; +/// The Nautilus Harbor machine, the remote window's last button. +const NAUTILUS: (i32, i32) = (9, 9_100_117); /// What seed 10 wins. const PRIZE: i32 = 2_044_502; const USE: i16 = 2; @@ -26,7 +33,7 @@ const MENU: u8 = 4; /// A new character, holding a ticket if `ticket`, logged in again in /// Henesys after `prepare` has changed it in the database. async fn player( - ticket: bool, + ticket: Option, prepare: impl FnOnce(i32) -> F, ) -> (WorldSession, Trace, HarnessConfig) where @@ -38,8 +45,8 @@ where .expect("first login failed") .character_id; tokio::time::sleep(Duration::from_millis(500)).await; - if ticket { - grant_item(id, CASH, 1, TICKET).await.expect("ticket"); + if let Some(ticket) = ticket { + grant_item(id, CASH, 1, ticket).await.expect("ticket"); } prepare(id).await; let mut session = relogin(&config).await; @@ -53,15 +60,46 @@ where /// The type of the next NPC prompt, and its text. async fn prompt(session: &mut WorldSession, t: &mut Trace) -> (u8, String) { + prompt_from(MACHINE, session, t).await +} + +/// The type of the next prompt, from `npc`, and its text. +async fn prompt_from(npc: i32, session: &mut WorldSession, t: &mut Trace) -> (u8, String) { let seen = t.prompts.len(); until(&mut session.connection, t, |t| t.prompts.len() > seen).await; - assert_eq!(t.prompts[seen].0, MACHINE); + assert_eq!(t.prompts[seen].0, npc); (t.prompts[seen].1, t.prompt_texts[seen].clone()) } +/// The town and item of `winner`'s win, as `listener` heard it. The other +/// tests' wins may come first. +async fn heard_win( + listener: &mut WorldSession, + heard: &mut Trace, + winner: &WorldSession, +) -> (String, i32) { + let message = format!("{} : got a(n)", winner.character_name); + let mine = |t: &Trace| { + (t.gachapon_wins.iter()) + .find(|(said, ..)| *said == message) + .map(|(_, _, town, item)| (town.clone(), *item)) + }; + until(&mut listener.connection, heard, |t| mine(t).is_some()).await; + mine(heard).unwrap() +} + +/// USE_REMOTE: the Remote Gachapon window's button `index`, for `ticket`. +fn use_remote(ticket: i32, index: i32) -> Packet { + let mut p = Packet::new_empty(); + p.write_short(RecvOpcode::RemoteGachapon as i16).unwrap(); + p.write_int(ticket).unwrap(); + p.write_int(index).unwrap(); + p +} + #[tokio::test] async fn a_ticket_buys_the_prize_and_the_world_hears_of_it() { - let (mut session, mut t, config) = player(true, |_| async {}).await; + let (mut session, mut t, config) = player(Some(TICKET), |_| async {}).await; let mut listener = login_to_world_session(&load_harness_config_or_fail().await) .await .unwrap(); @@ -83,13 +121,8 @@ async fn a_ticket_buys_the_prize_and_the_world_hears_of_it() { t.added ); - until(&mut listener.connection, &mut heard, |t| { - !t.gachapon_wins.is_empty() - }) - .await; - let (message, _, town, item) = &heard.gachapon_wins[0]; - assert_eq!(*message, format!("{} : got a(n)", session.character_name)); - assert_eq!((town.as_str(), *item), ("Henesys", PRIZE)); + let (town, item) = heard_win(&mut listener, &mut heard, &session).await; + assert_eq!((town.as_str(), item), ("Henesys", PRIZE)); drop(listener); drop(session); @@ -101,7 +134,7 @@ async fn a_ticket_buys_the_prize_and_the_world_hears_of_it() { #[tokio::test] async fn a_full_bag_keeps_the_ticket_unrolled() { - let (mut session, mut t, config) = player(true, |id| async move { + let (mut session, mut t, config) = player(Some(TICKET), |id| async move { for slot in 1..=24 { // Red Potions, which the prize can't stack with. grant_item(id, USE, slot, 2_000_000).await.expect("potion"); @@ -123,9 +156,70 @@ async fn a_full_bag_keeps_the_ticket_unrolled() { #[tokio::test] async fn without_a_ticket_the_machine_explains_itself() { - let (mut session, mut t, _) = player(false, |_| async {}).await; + let (mut session, mut t, _) = player(None, |_| async {}).await; send(&mut session.connection, talk(HENESYS, MACHINE)).await; assert_eq!(prompt(&mut session, &mut t).await.0, MENU); send(&mut session.connection, answer(MENU, 1, Some(0))).await; assert_eq!(prompt(&mut session, &mut t).await.0, SAY); } + +#[tokio::test] +async fn a_remote_ticket_plays_a_far_machine_at_once() { + let (mut session, mut t, config) = player(Some(REMOTE_TICKET), |_| async {}).await; + let mut listener = login_to_world_session(&load_harness_config_or_fail().await) + .await + .unwrap(); + let mut heard = Trace::default(); + read_for(&mut listener.connection, &mut heard, 300).await; + + // Another item, or a button past the last, does nothing. + let (index, machine) = NAUTILUS; + send(&mut session.connection, use_remote(TICKET, index)).await; + send(&mut session.connection, use_remote(REMOTE_TICKET, 10)).await; + read_for(&mut session.connection, &mut t, 300).await; + assert!(t.prompts.is_empty() && t.removed.is_empty(), "{t:?}"); + + // The machine names the prize as the ticket goes, with no yes or no. + send(&mut session.connection, use_remote(REMOTE_TICKET, index)).await; + let (kind, text) = prompt_from(machine, &mut session, &mut t).await; + assert_eq!(kind, SAY); + assert!(text.contains(&format!("#t{PRIZE}#")), "{text}"); + until(&mut session.connection, &mut t, |t| !t.removed.is_empty()).await; + assert_eq!(t.removed, [(CASH as u8, 1)]); + assert!( + t.added + .iter() + .any(|&(kind, _, item)| (kind, item) == (USE as u8, PRIZE)), + "{:?}", + t.added + ); + let (town, item) = heard_win(&mut listener, &mut heard, &session).await; + assert_eq!((town.as_str(), item), ("Nautilus Harbor", PRIZE)); + drop(listener); + drop(session); + + tokio::time::sleep(Duration::from_millis(500)).await; + let back = relogin(&config).await; + assert_eq!(back.items.get(&PRIZE), Some(&1)); + assert_eq!(back.items.get(&REMOTE_TICKET), None); +} + +#[tokio::test] +async fn a_full_bag_keeps_the_remote_ticket() { + let (mut session, mut t, config) = player(Some(REMOTE_TICKET), |id| async move { + for slot in 1..=24 { + grant_item(id, USE, slot, 2_000_000).await.expect("potion"); + } + }) + .await; + let (index, machine) = NAUTILUS; + send(&mut session.connection, use_remote(REMOTE_TICKET, index)).await; + assert_eq!(prompt_from(machine, &mut session, &mut t).await.0, SAY); + read_for(&mut session.connection, &mut t, 300).await; + assert!(t.removed.is_empty(), "{:?}", t.removed); + drop(session); + + tokio::time::sleep(Duration::from_millis(500)).await; + let back = relogin(&config).await; + assert_eq!(back.items.get(&REMOTE_TICKET), Some(&1)); +} diff --git a/net/src/cash_shop.rs b/net/src/cash_shop.rs index 3d700587..947c0705 100644 --- a/net/src/cash_shop.rs +++ b/net/src/cash_shop.rs @@ -197,13 +197,19 @@ pub fn check_gift(commodity: Option<&Commodity>) -> Result<&Commodity, CashShopF /// The classes (`item_id / 10_000`) of cash items on sale that do nothing /// here yet: Character Transfer (540, all but Name Change), Maple Life /// (543, all but the Extra Character Slot Coupon), and Remote Gachapon -/// (545, all but Miu Miu). +/// (545, all but Miu Miu and the Remote Gachapon Ticket). /// The shop calls them sold out rather than take NX for nothing (report /// c046). Take a class out once its items work. const INERT_CLASSES: [i32; 3] = [540, 543, 545]; fn inert(item_id: i32) -> bool { - ![NAME_CHANGE, MIU_MIU, CHARACTER_SLOT].contains(&item_id) + ![ + NAME_CHANGE, + MIU_MIU, + CHARACTER_SLOT, + crate::gachapon::REMOTE_TICKET, + ] + .contains(&item_id) && INERT_CLASSES.contains(&(item_id / 10_000)) } @@ -600,8 +606,8 @@ mod tests { .iter() .any(|c| c.item_id == item_id && c.on_sale) }; - // Character Transfer, Maple Life, Remote Gachapon. - for item_id in [5401000, 5431000, 5451000] { + // Character Transfer, Maple Life. + for item_id in [5401000, 5431000] { assert!(on_sale(item_id), "{item_id}"); let sn = commodity_sn(item_id, 1); assert_eq!( @@ -611,10 +617,11 @@ mod tests { ); } // Gachapon Tickets, Pet Snacks, guild emoticons, Name Change, Miu - // Miu, the Rock of Evolution, a hired merchant and the Store Remote - // Controller still sell. + // Miu, the Remote Gachapon Ticket, the Rock of Evolution, a hired + // merchant and the Store Remote Controller still sell. for item_id in [ 5220000, + crate::gachapon::REMOTE_TICKET, 5460000, 5290000, NAME_CHANGE, diff --git a/net/src/dialogue.rs b/net/src/dialogue.rs index d6c42527..236cdb0b 100644 --- a/net/src/dialogue.rs +++ b/net/src/dialogue.rs @@ -21,6 +21,10 @@ pub enum NpcInteraction { }, /// A Water of Life was used: Mar the Fairy offers to revive a doll. WaterOfLife, + /// A Remote Gachapon Ticket was used on the gachapon `machine`. + RemoteGachapon { + machine: i32, + }, } /// One server-driven NPC prompt. diff --git a/net/src/gachapon.rs b/net/src/gachapon.rs index 2e1adb6b..14015d79 100644 --- a/net/src/gachapon.rs +++ b/net/src/gachapon.rs @@ -13,6 +13,10 @@ use rand::{Rng, SeedableRng}; /// The Gachapon Ticket, from the Cash Shop. pub const TICKET: i32 = 5220000; +/// The Remote Gachapon Ticket, from the Cash Shop: its window plays any +/// machine from afar (`remote_machine`). +pub const REMOTE_TICKET: i32 = 5451000; + /// The bags a prize may go to. pub const BAGS: [InventoryType; 4] = [ InventoryType::Equip, @@ -22,7 +26,8 @@ pub const BAGS: [InventoryType; 4] = [ ]; /// The machines `Map.nx` places, and the town each is named after in the -/// world notice of a win. +/// world notice of a win. Their order is the Remote Gachapon window's +/// buttons' (report b196). const MACHINES: [(i32, &str); 10] = [ (9100100, "Henesys"), (9100101, "Ellinia"), @@ -44,6 +49,13 @@ pub fn town(npc_id: i32) -> Option<&'static str> { .map(|(_, town)| *town) } +/// The machine the Remote Gachapon window's button `index` plays (0 to 9, +/// report b196). +pub fn remote_machine(index: i32) -> Option { + let index = usize::try_from(index).ok()?; + MACHINES.get(index).map(|&(machine, _)| machine) +} + /// What a ticket won. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub struct Prize { @@ -185,5 +197,17 @@ mod tests { assert_eq!(town(9100108), None); } + #[test] + fn the_remote_windows_buttons_play_the_ten_machines() { + let machines: Vec<_> = (0..10).map(|i| remote_machine(i).unwrap()).collect(); + assert_eq!( + machines[..8], + [9100100, 9100101, 9100102, 9100103, 9100104, 9100105, 9100106, 9100107] + ); + assert_eq!(machines[8..], [9100109, 9100117]); + assert_eq!(remote_machine(-1), None); + assert_eq!(remote_machine(10), None); + } + const HARNESS_SEED: u64 = 10; } diff --git a/net/src/handler.rs b/net/src/handler.rs index 4b0df504..1449f576 100644 --- a/net/src/handler.rs +++ b/net/src/handler.rs @@ -1262,6 +1262,7 @@ fn world_routes() -> &'static HashMap { (Op::CharInfoRequest, InGame(&world::CharInfoRequestHandler)), (Op::SpawnPet, InGame(&world::SpawnPetHandler)), (Op::DeadPet, InGame(&world::DeadPetHandler)), + (Op::RemoteGachapon, InGame(&world::RemoteGachaponHandler)), (Op::WaterOfLife, InGame(&world::WaterOfLifeHandler)), (Op::MovePet, InGame(&world::MovePetHandler)), (Op::PetChat, InGame(&world::PetChatHandler)), @@ -1320,7 +1321,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(), 135); + assert_eq!(world_routes().len(), 136); } #[test] diff --git a/net/src/npc_scripts.rs b/net/src/npc_scripts.rs index 18a10df9..24eaed12 100644 --- a/net/src/npc_scripts.rs +++ b/net/src/npc_scripts.rs @@ -2156,14 +2156,8 @@ impl NpcScript { if inventory.count(gachapon::TICKET) == 0 { return Some(gachapon_machine(npc_id, town, None)); } - // A prize may go to any bag. Rolling with one full would turn - // some prizes away with the ticket kept: a free roll, which - // also tells which bag the prize was for. - if gachapon::BAGS.iter().any(|&bag| inventory.free(bag) == 0) { - return Some(NpcScript::new( - npc_id, - vec![Page::Say(text(keys::GACHAPON_FULL))], - )); + if let Some(full) = gachapon_bag_full(npc_id, player) { + return Some(full); } let prize = gachapon::roll(npc_id, &mut gachapon::rng()); return Some(gachapon_machine(npc_id, town, prize)); @@ -2250,6 +2244,41 @@ impl NpcScript { } } + /// Gachapon `machine` played from afar with a Remote Gachapon Ticket + /// (report b196). As Cosmic's `gachaponRemote` has it, there is no + /// yes or no: the ticket goes for the prize at once, the machine + /// naming it, unless a bag is full, which keeps the ticket. `None` if + /// the player holds no ticket, or `machine` is none. + pub fn remote_gachapon(machine: i32, player: &Player) -> Option { + let town = gachapon::town(machine)?; + if player.inventory.count(gachapon::REMOTE_TICKET) == 0 { + return None; + } + if let Some(full) = gachapon_bag_full(machine, player) { + return Some(full); + } + let prize = gachapon::roll(machine, &mut gachapon::rng())?; + let item = prize.item_id.to_string(); + let count = prize.quantity.to_string(); + let mut script = NpcScript::new( + machine, + vec![Page::Act { + text: content::text() + .get(keys::GACHAPON_WON, &[("item", &item), ("count", &count)]), + effects: vec![Effect::Gachapon { + ticket: gachapon::REMOTE_TICKET, + item_id: prize.item_id, + quantity: prize.quantity, + town, + announced: prize.announced(), + }], + }], + ); + script.inventory_full = Some(text(keys::GACHAPON_FULL)); + script.refused = Some(text(keys::GACHAPON_NO_TICKET)); + Some(script) + } + /// Mar the Fairy's offer when a Water of Life is used: pick a doll, /// and the water wakes it for the days the item gives (`info/life`). /// `None` if the player holds no Water of Life. @@ -8836,6 +8865,18 @@ pub fn resets_when_empty(map_id: i32) -> bool { || crate::scripts::SOLO_MAPS.contains(&map_id) } +/// Gachapon machine `npc_id`'s word that a bag is full, if one is. A +/// prize may go to any bag; rolling with one full would turn some prizes +/// away with the ticket kept: a free roll, which also tells which bag the +/// prize was for. +fn gachapon_bag_full(npc_id: i32, player: &Player) -> Option { + let inventory = &player.inventory; + gachapon::BAGS + .iter() + .any(|&bag| inventory.free(bag) == 0) + .then(|| NpcScript::new(npc_id, vec![Page::Say(text(keys::GACHAPON_FULL))])) +} + /// A Gachapon machine in `town` (report c058 Q6). To a player with a /// ticket it offers the `prize` already rolled, named once it is theirs; /// to anyone else it explains itself. @@ -8861,6 +8902,7 @@ fn gachapon_machine(npc_id: i32, town: &'static str, prize: Option) -> Np Page::Ask { text: content::text().get(keys::GACHAPON_ASK, &[("town", town)]), on_yes: vec![Effect::Gachapon { + ticket: gachapon::TICKET, item_id: prize.item_id, quantity: prize.quantity, town, @@ -9529,6 +9571,7 @@ mod tests { let ask = script.begin(); assert_eq!(kind(&ask), Some(1)); let Some(Effect::Gachapon { + ticket, item_id, quantity, town, @@ -9543,6 +9586,7 @@ mod tests { panic!("the yes trades the ticket"); }; assert_eq!(town, "Henesys"); + assert_eq!(ticket, gachapon::TICKET); assert!(crate::items::item_name(item_id).is_some()); assert!(quantity > 0); let _ = announced; @@ -9558,6 +9602,7 @@ mod tests { assert_eq!( yes.effects, [Effect::Gachapon { + ticket: gachapon::TICKET, item_id: 2_049_100, quantity: 1, town: "Ellinia", @@ -9597,6 +9642,50 @@ mod tests { assert!(full.effects.is_empty()); } + #[test] + fn a_remote_ticket_plays_the_machine_at_once() { + let mut player = player(50); + assert!(NpcScript::remote_gachapon(9100109, &player).is_none()); + // A Gachapon Ticket is not a remote one. + crate::items::give(&mut player.inventory, gachapon::TICKET, 1).unwrap(); + assert!(NpcScript::remote_gachapon(9100109, &player).is_none()); + crate::items::give(&mut player.inventory, gachapon::REMOTE_TICKET, 1).unwrap(); + assert!(NpcScript::remote_gachapon(9100108, &player).is_none()); + + let mut script = NpcScript::remote_gachapon(9100109, &player).unwrap(); + let won = script.begin(); + assert_eq!(kind(&won), Some(0)); + let [Effect::Gachapon { + ticket, + item_id, + town, + .. + }] = won.effects[..] + else { + panic!("{:?}", won.effects); + }; + assert_eq!((ticket, town), (gachapon::REMOTE_TICKET, "New Leaf City")); + assert!(page_text(&won).contains(&item_id.to_string())); + assert_eq!(buttons(&won), (false, false)); + assert_eq!(script.respond(ScriptInput::Next), Some(Step::default())); + assert_eq!( + page_text(&script.inventory_full().unwrap()), + text(keys::GACHAPON_FULL) + ); + + // A full bag keeps the ticket. + for slot in 1..=player.inventory.slot_limit(InventoryType::Etc) as i16 { + player + .inventory + .insert(InventoryType::Etc, slot, game::Item::stack(4_000_000, 1)); + } + let full = NpcScript::remote_gachapon(9100109, &player) + .unwrap() + .begin(); + assert_eq!(page_text(&full), text(keys::GACHAPON_FULL)); + assert!(full.effects.is_empty()); + } + #[test] fn a_gachapon_machine_explains_itself_without_a_ticket() { let mut script = NpcScript::for_npc(9100117, &player(50)).unwrap(); diff --git a/net/src/packet/handle/world/mod.rs b/net/src/packet/handle/world/mod.rs index f5f8f697..0ea2b9e4 100644 --- a/net/src/packet/handle/world/mod.rs +++ b/net/src/packet/handle/world/mod.rs @@ -61,6 +61,7 @@ mod quest_action; mod quick_slots; mod reactor; mod rearrange; +mod remote_gachapon; mod ring; mod rps; mod script_portal; @@ -159,6 +160,7 @@ pub use self::quest_action::QuestActionHandler; pub use self::quick_slots::QuickSlotHandler; pub use self::reactor::DamageReactorHandler; pub use self::rearrange::{GatherItemsHandler, SortItemsHandler}; +pub use self::remote_gachapon::RemoteGachaponHandler; pub use self::ring::{ RingActionHandler, WeddingActionHandler, WeddingBlessHandler, WeddingTalkHandler, }; diff --git a/net/src/packet/handle/world/remote_gachapon.rs b/net/src/packet/handle/world/remote_gachapon.rs new file mode 100644 index 00000000..a93dc5fa --- /dev/null +++ b/net/src/packet/handle/world/remote_gachapon.rs @@ -0,0 +1,78 @@ +//! USE_REMOTE (0x74, 0xa14074, report b196): the Remote Gachapon Ticket's +//! window picked a town. The ticket's item id, then the town's button, 0 +//! to 9 (`gachapon::remote_machine`). The client closes the window and +//! waits for nothing; the machine's script answers as its NPC. + +use crate::dialogue::NpcInteraction; +use crate::gachapon; +use crate::handler::{HandlerResult, WorldAction, WorldContext, WorldHandler, WorldResult}; +use packet::{io::read::PktRead, Packet}; +use std::io::Cursor; + +pub struct RemoteGachaponHandler; + +impl WorldHandler for RemoteGachaponHandler { + fn handle(&self, packet: &mut Packet, _ctx: &mut WorldContext) -> WorldResult { + let mut r = Cursor::new(&packet.bytes); + r.read_short()?; + let ticket = r.read_int()?; + let index = r.read_int()?; + // Cosmic disconnects for another item or button; nothing happens + // here. + let machine = (ticket == gachapon::REMOTE_TICKET) + .then(|| gachapon::remote_machine(index)) + .flatten(); + Ok(match machine { + Some(machine) => HandlerResult::empty().with(WorldAction::NpcInteraction( + NpcInteraction::RemoteGachapon { machine }, + )), + None => HandlerResult::empty(), + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::packet::op::RecvOpcode; + use packet::io::write::PktWrite; + + fn handle(ticket: i32, index: i32) -> Option { + let mut packet = Packet::new_empty(); + packet + .write_short(RecvOpcode::RemoteGachapon as i16) + .unwrap(); + packet.write_int(ticket).unwrap(); + packet.write_int(index).unwrap(); + let mut player = crate::codec::stats::sample_player(); + let mut ctx = WorldContext { + client_id: 1, + channel_id: 0, + player: &mut player, + }; + let actions = (RemoteGachaponHandler.handle(&mut packet, &mut ctx)) + .unwrap() + .actions; + match actions.as_slice() { + [] => None, + [WorldAction::NpcInteraction(NpcInteraction::RemoteGachapon { machine })] => { + Some(*machine) + } + other => panic!("{other:?}"), + } + } + + #[test] + fn a_town_button_plays_its_machine() { + assert_eq!(handle(gachapon::REMOTE_TICKET, 0), Some(9100100)); + assert_eq!(handle(gachapon::REMOTE_TICKET, 8), Some(9100109)); + assert_eq!(handle(gachapon::REMOTE_TICKET, 9), Some(9100117)); + } + + #[test] + fn another_item_or_button_does_nothing() { + assert_eq!(handle(gachapon::TICKET, 0), None); + assert_eq!(handle(gachapon::REMOTE_TICKET, 10), None); + assert_eq!(handle(gachapon::REMOTE_TICKET, -1), None); + } +} diff --git a/net/src/packet/op/recv.rs b/net/src/packet/op/recv.rs index 424007c8..2698ad61 100644 --- a/net/src/packet/op/recv.rs +++ b/net/src/packet/op/recv.rs @@ -43,6 +43,8 @@ pub enum RecvOpcode { TakeDamage = 0x30, AllChat = 0x31, CloseChalkboard = 0x32, + /// A Remote Gachapon Ticket's window picked a town (0xa14074). + RemoteGachapon = 0x74, /// A Water of Life was double-clicked (FUN_00a1dce6). WaterOfLife = 0x75, /// A GM's `/notice*`, `/alert*` or `/slide*` (report b175). diff --git a/net/src/scripts.rs b/net/src/scripts.rs index ebea3f6f..248123f0 100644 --- a/net/src/scripts.rs +++ b/net/src/scripts.rs @@ -440,11 +440,12 @@ pub enum Effect { /// One piece of equipment with its stats varied as a drop's are /// (`items::give_dropped`), as Mos forges with a stimulator. GiveVariedItem(i32), - /// Trade a Gachapon Ticket for the prize rolled when the machine in - /// `town` was opened (`gachapon::roll`). Nothing is applied if the - /// ticket is gone or the prize doesn't fit; the world hears of an - /// `announced` prize. + /// Trade a `ticket` (a Gachapon Ticket or a Remote Gachapon Ticket) + /// for the prize rolled when the machine in `town` was opened + /// (`gachapon::roll`). Nothing is applied if the ticket is gone or the + /// prize doesn't fit; the world hears of an `announced` prize. Gachapon { + ticket: i32, item_id: i32, quantity: i16, town: &'static str, diff --git a/runtime/src/actor/client.rs b/runtime/src/actor/client.rs index 1fef69a3..c83300bc 100644 --- a/runtime/src/actor/client.rs +++ b/runtime/src/actor/client.rs @@ -515,6 +515,13 @@ impl ClientActor { self.start_script(script).await? } } + WorldAction::NpcInteraction(NpcInteraction::RemoteGachapon { machine }) => { + let player = &self.in_game()?.player; + if let Some(script) = net::npc_scripts::NpcScript::remote_gachapon(machine, player) + { + self.start_script(script).await? + } + } WorldAction::NpcInteraction(NpcInteraction::Respond { message_type, response, diff --git a/runtime/src/actor/client/npc_script.rs b/runtime/src/actor/client/npc_script.rs index 4b7c6952..0f93e95c 100644 --- a/runtime/src/actor/client/npc_script.rs +++ b/runtime/src/actor/client/npc_script.rs @@ -277,12 +277,13 @@ impl ClientActor { )?); } Effect::Gachapon { + ticket, item_id, quantity, town, announced, } => { - let Ok(mut changes) = next.inventory.remove(net::gachapon::TICKET, 1) else { + let Ok(mut changes) = next.inventory.remove(*ticket, 1) else { warn!("Gachapon Ticket no longer held; nothing applied"); return Ok(Applied::Refused); }; @@ -305,10 +306,7 @@ impl ClientActor { changes.extend(given); changed = true; packets.push(inventory::build_inventory_operation(false, &changes)?); - packets.push(inventory::build_item_gain_in_chat( - net::gachapon::TICKET, - -1, - )?); + packets.push(inventory::build_item_gain_in_chat(*ticket, -1)?); packets.push(inventory::build_item_gain_in_chat( *item_id, i32::from(*quantity),