From 3e4792f8867586a3d899cbd71bdbc174419a65c7 Mon Sep 17 00:00:00 2001 From: Sam Sung <51686767+samwhosung@users.noreply.github.com> Date: Sat, 26 Sep 2026 07:42:53 +0700 Subject: [PATCH] Quests print their chat lines: accepted, completed, experience, money and items (#390) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Quests printed no chat lines: no "Quest accepted: …", "… completed.", "Experience gained: …" or "Received …". Nothing raised the reference's quest messages. Fixes #69. - **Accept** (`0x5ddd80`): a quest-log slot watcher raises "Quest accepted: " when a slot gains a quest, or keeps its quest and loses the fail bit (`0x5dde17`-`0x5dde34`); a slot that swaps quests says nothing. An uncached quest prints when its record arrives. The QUESTADDED sound now comes from that message row. - **Turn-in** (`0x5dc400`), in order: "<title> completed." only when the quest has no follow-up in its chain; "Experience gained: N." when the XP is non-zero; "Received …" with the paid coins from the COPPER/SILVER/GOLD globals; then one "Received item: [link]." per fixed reward, and one for the chosen reward, which the client remembers from `GetQuestReward` (`0x50161a`) because the packet does not carry it (vmangos `Player.cpp:14500`). - Every line comes from the install's strings. A line whose quest or item record is still in flight waits for it; an unknown record is dropped. - The money line joins only the paid coins ("1 Gold, 5 Copper"). The reference's separator test prints "1 Gold, , 5 Copper" when the middle coin is zero, a quirk not copied. Tests: six new tests across the quest log, the turn-in handler and the line queue; `scripts/check.sh` is green. To see it: accept a quest from any giver and read "Quest accepted: <title>"; turn it in and read the completed, experience, money and item lines. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> --- crates/benilla-app/src/game_plugins.rs | 7 +- crates/benilla-app/src/ui_quest/lines.rs | 359 +++++++++++++++++++++++ crates/benilla-app/src/ui_quest/mod.rs | 19 +- crates/benilla-app/src/ui_quest/net.rs | 80 ++++- crates/benilla-app/src/ui_quest_log.rs | 125 +++++++- crates/benilla-ui/src/script/sound.rs | 2 +- 6 files changed, 568 insertions(+), 24 deletions(-) create mode 100644 crates/benilla-app/src/ui_quest/lines.rs diff --git a/crates/benilla-app/src/game_plugins.rs b/crates/benilla-app/src/game_plugins.rs index 5a196a92..1fb86f24 100644 --- a/crates/benilla-app/src/game_plugins.rs +++ b/crates/benilla-app/src/game_plugins.rs @@ -659,12 +659,15 @@ pub(crate) mod schedule_tests { /// - `ui_models::forget_dead_vm_tiles` against `portrait::glue_booth::sync_glue_scene` over /// `MatAnimTable`, a slot allocator whose owners touch disjoint slots; /// - the cursor classifier against `world_focus`'s focus publish and settle release over - /// `Player`: it reads `control_lost`, and they write only `settling` and `world_stale`. + /// `Player`: it reads `control_lost`, and they write only `settling` and `world_stale`; + /// - `ui_quest::lines::feed_quest_lines` against `ui_items::feed_item_stats` and + /// `ui_tooltip::feed_spell_tooltips` over `Items` and the VM: it only reads templates, which + /// those two do not write, and a chat line commutes with their pushes. /// /// Raising the ceiling is a claim that a new undeclared order is acceptable: make it with the /// reason read off the dump, or declare the order (`.after`, a set, a `chain`). A resource /// that commutes by construction belongs in [`Classes`]. - const UPDATE_ACTIONABLE_CEILING: usize = 5_464; + const UPDATE_ACTIONABLE_CEILING: usize = 5_463; const UPDATE_ACTIONABLE_SLACK: usize = 40; fn ratchet(what: &str, n: usize, ceiling: usize, slack: usize) { diff --git a/crates/benilla-app/src/ui_quest/lines.rs b/crates/benilla-app/src/ui_quest/lines.rs new file mode 100644 index 00000000..843d4979 --- /dev/null +++ b/crates/benilla-app/src/ui_quest/lines.rs @@ -0,0 +1,359 @@ +//! The quest chat lines the reference raises from its packet and field handlers: the accept line +//! from the quest-slot watcher, and the turn-in's completion, experience, money and item lines. A +//! line whose quest or item record is not cached waits for it, as the reference's cache callbacks +//! (`0x5ddeb0`, `0x5dc710`, `0x5dc7b0`) print on the record's arrival. + +use benilla_protocol::{SessionEvent, SessionEventKind}; +use bevy::prelude::*; + +use benilla_ui::script::UiScript; + +use crate::items::Items; +use crate::net::{NetCommands, NetHandlerApp}; +use crate::ui_action::{show_messages, ui_error_text, FillArg, MessageSink, Shown, UiError}; +use crate::ui_quest_log::QuestLog; + +/// One quest chat line, raised in order. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum QuestLine { + /// `ERR_QUEST_ACCEPTED_S` (`0x89`) with the quest's title (`0x5dde66`). + Accepted(u32), + /// `ERR_QUEST_COMPLETE_S` (`0x8a`) with the title, unless the quest names a next quest in its + /// chain (`record+0x24`, `0x5dc4d7`). + Completed(u32), + /// `ERR_QUEST_REWARD_EXP_I` (`0x95`, `0x5dc4f9`). + Experience(u32), + /// `ERR_QUEST_REWARD_MONEY_S` (`0x97`, `0x5dc609`) with the coin string. + Money(u32), + /// `ERR_QUEST_REWARD_ITEM_S` (`0x96`, `0x5dc822`) with the item's link. + Item(u32), +} + +/// The lines raised and not yet shown. +#[derive(Resource, Default)] +pub(crate) struct QuestLines(Vec<QuestLine>); + +impl QuestLines { + pub(crate) fn push(&mut self, line: QuestLine) { + self.0.push(line); + } + + #[cfg(test)] + pub(crate) fn queued(&self) -> &[QuestLine] { + &self.0 + } +} + +/// What a line resolves to this frame. +enum Resolved { + Show(UiError), + /// Its record is still in flight. + Wait, + /// The server does not know the quest or item, or the line has nothing to say. + Drop, +} + +pub(super) fn register(app: &mut App) { + app.init_resource::<QuestLines>() + .net_handler(SessionEventKind::Disconnected, on_session_end) + .add_systems( + Update, + feed_quest_lines + .in_set(crate::ui_unit::UnitFeed) + .after(crate::ui_quest_log::feed_quest_log), + ); +} + +/// The waiting lines die with the socket, as the record caches do. +fn on_session_end(In(_): In<SessionEvent>, mut lines: ResMut<QuestLines>) { + lines.0.clear(); +} + +/// Show every line whose record is here, in the order raised; the rest wait. +fn feed_quest_lines( + script: Option<NonSendMut<UiScript>>, + mut lines: ResMut<QuestLines>, + quest_log: Res<QuestLog>, + items: Res<Items>, + commands: Res<NetCommands>, + mut sink: MessageSink, +) { + let Some(mut script) = script else { + return; + }; + if lines.0.is_empty() { + return; + } + let get = |key: &str| benilla_ui::strings::global(script.lua(), key); + let mut shown = Vec::new(); + lines.0.retain( + |&line| match resolve(line, &quest_log, &items, &commands, &get) { + Resolved::Show(msg) => { + if let Some(text) = ui_error_text(&msg, &get) { + shown.push(Shown::keyed(msg.key, text)); + } + false + } + Resolved::Wait => true, + Resolved::Drop => false, + }, + ); + show_messages(&mut script, &mut sink, "ui_quest", shown); +} + +fn resolve( + line: QuestLine, + quest_log: &QuestLog, + items: &Items, + commands: &NetCommands, + get: &dyn Fn(&str) -> Option<String>, +) -> Resolved { + match line { + QuestLine::Accepted(id) => match quest(id, quest_log, commands) { + Ok(t) => Resolved::Show(UiError::s("ERR_QUEST_ACCEPTED_S", t.title.clone())), + Err(r) => r, + }, + QuestLine::Completed(id) => match quest(id, quest_log, commands) { + Ok(t) if t.next_quest_in_chain == 0 => { + Resolved::Show(UiError::s("ERR_QUEST_COMPLETE_S", t.title.clone())) + } + Ok(_) => Resolved::Drop, + Err(r) => r, + }, + QuestLine::Experience(xp) => Resolved::Show(UiError::args( + "ERR_QUEST_REWARD_EXP_I", + vec![FillArg::D(i64::from(xp))], + )), + QuestLine::Money(copper) => Resolved::Show(UiError::s( + "ERR_QUEST_REWARD_MONEY_S", + coin_text(copper, &|k| get(k).unwrap_or_default()), + )), + // The name is `0x5d8b00`'s with no random property, the link `0x52adb0`'s. + QuestLine::Item(entry) => match items.template(entry, 0, commands) { + Some(t) => Resolved::Show(UiError::s( + "ERR_QUEST_REWARD_ITEM_S", + crate::ui_items::item_link(entry, &t.name, t.quality), + )), + None if items.template_answered_unknown(entry) => Resolved::Drop, + None => Resolved::Wait, + }, + } +} + +/// The quest's record, or what its line does without one. +fn quest<'a>( + id: u32, + quest_log: &'a QuestLog, + commands: &NetCommands, +) -> Result<&'a benilla_protocol::messages::QuestTemplate, Resolved> { + match quest_log.template(id, commands) { + Some(t) => Ok(t), + None if quest_log.template_answered_unknown(id) => Err(Resolved::Drop), + None => Err(Resolved::Wait), + } +} + +/// The turn-in's coin string (`0x5dc511`-`0x5dc5fd`): each paid denomination as `"%d %s"` with +/// its `GOLD`, `SILVER` or `COPPER` global, gold first. The reference's separator test yields +/// `", , "` with a zero middle coin; this joins only the paid ones, with `", "`. +fn coin_text(copper: u32, get: &dyn Fn(&str) -> String) -> String { + // `0x6c6260`'s split by 10000, 100 and 1. + let (g, s, c) = (copper / 10000, copper / 100 % 100, copper % 100); + [(g, "GOLD"), (s, "SILVER"), (c, "COPPER")] + .into_iter() + .filter(|&(n, _)| n != 0) + .map(|(n, key)| format!("{n} {}", get(key))) + .collect::<Vec<_>>() + .join(", ") +} + +#[cfg(test)] +mod tests { + use super::*; + use benilla_protocol::messages::{QuestObjective, QuestTemplate}; + + use crate::net::ClientCommand; + + fn template(quest_id: u32, title: &str, next_quest_in_chain: u32) -> QuestTemplate { + let objective = || QuestObjective { + creature_or_go: 0, + required_count: 0, + item_id: 0, + item_count: 0, + text: String::new(), + }; + QuestTemplate { + quest_id, + method: 2, + level: 1, + zone_or_sort: 12, + quest_type: 0, + rep_objective_faction: 0, + rep_objective_value: 0, + next_quest_in_chain, + money: 0, + money_max_level: 0, + reward_spell: 0, + src_item_id: 0, + flags: 0, + rewards: [(0, 0); 4], + choices: [(0, 0); 6], + point_map_id: 0, + point_x: 0.0, + point_y: 0.0, + point_opt: 0, + title: title.into(), + objectives_text: String::new(), + details: String::new(), + end_text: String::new(), + objectives: std::array::from_fn(|_| objective()), + } + } + + /// The feed alone, over a VM holding the stock enUS templates. + fn app() -> (App, crossbeam_channel::Receiver<ClientCommand>) { + let (tx, rx) = crossbeam_channel::unbounded(); + let mut app = App::new(); + app.insert_resource(NetCommands(tx)) + .init_resource::<QuestLines>() + .init_resource::<QuestLog>() + .init_resource::<Items>() + .init_resource::<crate::ui_chat::ChatLog>() + .init_resource::<crate::sound::MessageSounds>() + .add_systems(Update, feed_quest_lines); + let script = UiScript::new().unwrap(); + script + .run( + r#" + ERR_QUEST_ACCEPTED_S = "Quest accepted: %s" + ERR_QUEST_COMPLETE_S = "%s completed." + ERR_QUEST_REWARD_EXP_I = "Experience gained: %d." + ERR_QUEST_REWARD_MONEY_S = "Received %s." + ERR_QUEST_REWARD_ITEM_S = "Received item: %s." + GOLD = "Gold"; SILVER = "Silver"; COPPER = "Copper" + "#, + ) + .unwrap(); + app.insert_non_send_resource(script); + (app, rx) + } + + fn chat(app: &App) -> Vec<String> { + app.world() + .resource::<crate::ui_chat::ChatLog>() + .pending_lines() + } + + #[test] + fn each_line_shows_as_its_record_lands_and_the_rest_keep_their_order() { + let (mut app, rx) = app(); + { + let world = app.world_mut(); + world + .resource_mut::<QuestLog>() + .insert_template(template(783, "A Threat Within", 0)); + world + .resource_mut::<Items>() + .insert_template(2_580, Some(crate::items::test_template("Linen Cloth"))); + let mut lines = world.resource_mut::<QuestLines>(); + for line in [ + QuestLine::Accepted(783), + QuestLine::Completed(7), + QuestLine::Experience(450), + QuestLine::Money(1_025), + QuestLine::Item(2_580), + QuestLine::Item(6_529), + ] { + lines.push(line); + } + } + app.update(); + assert_eq!( + chat(&app), + [ + "Quest accepted: A Threat Within", + "Experience gained: 450.", + "Received 10 Silver, 25 Copper.", + "Received item: |cffffffff|Hitem:2580:0:0:0|h[Linen Cloth]|h|r.", + ] + ); + assert_eq!( + app.world().resource::<QuestLines>().queued(), + [QuestLine::Completed(7), QuestLine::Item(6_529)], + "the uncached quest and item wait" + ); + let sent: Vec<_> = rx.try_iter().collect(); + assert!( + sent.iter() + .any(|c| matches!(c, ClientCommand::QuestQuery { quest: 7 })), + "the waiting quest's record is asked: {sent:?}" + ); + assert!( + app.world() + .resource::<crate::sound::MessageSounds>() + .queued() + .iter() + .any(|r| r.key == "ERR_QUEST_ACCEPTED_S"), + "the accept line's row carries its sound" + ); + + { + let world = app.world_mut(); + world + .resource_mut::<QuestLog>() + .insert_template(template(7, "Kobold Camp Cleanup", 0)); + world + .resource_mut::<Items>() + .insert_template(6_529, Some(crate::items::test_template("Shiny Bauble"))); + } + app.update(); + assert_eq!( + chat(&app)[4..], + [ + "Kobold Camp Cleanup completed.", + "Received item: |cffffffff|Hitem:6529:0:0:0|h[Shiny Bauble]|h|r.", + ] + ); + assert!(app.world().resource::<QuestLines>().queued().is_empty()); + } + + /// `0x5dc4d7`: a quest that names a next quest in its chain completes silently. + #[test] + fn a_chained_quest_and_an_unknown_record_show_nothing() { + let (mut app, _rx) = app(); + { + let world = app.world_mut(); + world + .resource_mut::<QuestLog>() + .insert_template(template(783, "A Threat Within", 7)); + world.resource_mut::<Items>().insert_template(9_999, None); + let mut lines = world.resource_mut::<QuestLines>(); + lines.push(QuestLine::Completed(783)); + lines.push(QuestLine::Item(9_999)); + } + app.update(); + assert!(chat(&app).is_empty()); + assert!(app.world().resource::<QuestLines>().queued().is_empty()); + } + + fn words(key: &str) -> String { + match key { + "GOLD" => "Gold", + "SILVER" => "Silver", + "COPPER" => "Copper", + _ => "", + } + .into() + } + + #[test] + fn the_coin_string_joins_only_the_paid_denominations() { + assert_eq!(coin_text(5, &words), "5 Copper"); + assert_eq!(coin_text(250, &words), "2 Silver, 50 Copper"); + assert_eq!(coin_text(10_200, &words), "1 Gold, 2 Silver"); + assert_eq!(coin_text(12_345, &words), "1 Gold, 23 Silver, 45 Copper"); + assert_eq!(coin_text(30_000, &words), "3 Gold"); + // A zero middle coin leaves no empty place between its neighbours. + assert_eq!(coin_text(10_005, &words), "1 Gold, 5 Copper"); + } +} diff --git a/crates/benilla-app/src/ui_quest/mod.rs b/crates/benilla-app/src/ui_quest/mod.rs index daaa9ff9..26d0948f 100644 --- a/crates/benilla-app/src/ui_quest/mod.rs +++ b/crates/benilla-app/src/ui_quest/mod.rs @@ -46,6 +46,10 @@ pub(crate) struct QuestGiver { /// `DeclineQuest`, which keeps the panel up; both refuse while it is set (`0x5013a8`, /// `0x5013fe`) until a panel publish clears it ([`Self::open`]). pub(crate) acted: bool, + /// The reference's `0xbe0828`: the chosen reward's item id, latched by `GetQuestReward` + /// (`0x50161a`) for the turn-in's item line (`0x5dc676`-`0x5dc6c6`), and zeroed by a panel + /// publish (`0x500d8c`), world enter (`0x500b15`) and the turn-in. + pub(crate) chosen_reward: u32, /// Per-guid dialog status from `SMSG_QUESTGIVER_STATUS`, the `!`/`?` marker's value. statuses: HashMap<u64, u32>, /// Messages queued for [`feed_quest`], each a GlobalStrings key and its fills. @@ -62,8 +66,10 @@ impl QuestGiver { pub(crate) fn open(&mut self, npc: u64, view: QuestView) { self.npc = Some(npc); self.view = Some(view); - // `0x500bd0` clears the already-acted latch on every panel publish (`0x500d91`). + // `0x500bd0` clears the already-acted latch (`0x500d91`) and the chosen reward + // (`0x500d8c`) on every panel publish. self.acted = false; + self.chosen_reward = 0; } #[allow(dead_code)] @@ -158,6 +164,7 @@ impl QuestGiver { self.messages.clear(); self.close_on_cancel = 0; self.acted = false; + self.chosen_reward = 0; } } @@ -186,13 +193,17 @@ pub(crate) fn questgiver_failed_key(reason: u32) -> &'static str { } } +mod lines; mod net; +pub(crate) use lines::{QuestLine, QuestLines}; + pub(crate) struct UiQuestPlugin; impl Plugin for UiQuestPlugin { fn build(&self, app: &mut App) { net::register(app); + lines::register(app); app.init_resource::<QuestGiver>().add_systems( Update, ( @@ -649,6 +660,12 @@ fn drain_quest( } QuestAction::Reward(choice) => { if let Some(quest) = view_quest { + // `0x5015b0` latches the chosen row's item when the panel offers a choice. + if let Some(QuestView::Reward(o)) = giver.view.as_ref() { + if let Some(it) = o.choices.get(choice as usize) { + giver.chosen_reward = it.item_id; + } + } let _ = commands.0.send(ClientCommand::QuestgiverChooseReward { npc, quest, diff --git a/crates/benilla-app/src/ui_quest/net.rs b/crates/benilla-app/src/ui_quest/net.rs index ce8a6902..eba8ce8a 100644 --- a/crates/benilla-app/src/ui_quest/net.rs +++ b/crates/benilla-app/src/ui_quest/net.rs @@ -7,7 +7,7 @@ use benilla_protocol::messages::{ use benilla_protocol::{SessionEvent, SessionEventKind}; use bevy::prelude::*; -use super::QuestGiver; +use super::{QuestGiver, QuestLine, QuestLines}; use crate::net::{ClientCommand, NetCommands, NetHandlerApp}; use crate::ui_action::UiError; use crate::ui_quest_log::QuestLog; @@ -50,6 +50,8 @@ fn on_worldport(In(ev): In<SessionEvent>, mut quest: ResMut<QuestGiver>) { fn world_enter_reset(quest: &mut QuestGiver) { quest.close_on_cancel = 0; + // The same reset zeroes the chosen reward (`0x500b15`). + quest.chosen_reward = 0; } fn on_giver_status(In(ev): In<SessionEvent>, mut quest: ResMut<QuestGiver>) { @@ -82,9 +84,13 @@ fn on_offer(In(ev): In<SessionEvent>, mut quest: ResMut<QuestGiver>) { } } -fn on_complete(In(ev): In<SessionEvent>, mut quest: ResMut<QuestGiver>) { +fn on_complete( + In(ev): In<SessionEvent>, + mut quest: ResMut<QuestGiver>, + mut lines: ResMut<QuestLines>, +) { if let SessionEvent::QuestComplete(c) = ev { - quest_complete(c, &mut quest); + quest_complete(c, &mut quest, &mut lines); } } @@ -221,11 +227,12 @@ fn quest_offer(o: QuestOfferReward, quest: &mut QuestGiver) { quest.open(o.npc, crate::ui_quest::QuestView::Reward(o)); } -/// `SMSG_QUESTGIVER_QUEST_COMPLETE`: the turn-in result, which closes the window. -fn quest_complete(c: QuestComplete, quest: &mut QuestGiver) { +/// `SMSG_QUESTGIVER_QUEST_COMPLETE` (`0x5dc400`): the turn-in's chat lines, in the reference's +/// order, then the window closes. The XP, money and items themselves arrive by `UPDATE_OBJECT` +/// and `ITEM_PUSH_RESULT`. +fn quest_complete(c: QuestComplete, quest: &mut QuestGiver, lines: &mut QuestLines) { // The reference plays the `QUESTCOMPLETED` fanfare on this packet. quest.completed_fanfare = true; - // The XP, money and items arrive separately, by `UPDATE_OBJECT` and `ITEM_PUSH_RESULT`. debug!( "net: quest {} complete — +{} XP, +{} copper, {} item(s)", c.quest_id, @@ -233,6 +240,20 @@ fn quest_complete(c: QuestComplete, quest: &mut QuestGiver) { c.money, c.items.len() ); + lines.push(QuestLine::Completed(c.quest_id)); + if c.xp != 0 { + lines.push(QuestLine::Experience(c.xp)); + } + // A signed test (`0x5dc50b`). + if (c.money as i32) > 0 { + lines.push(QuestLine::Money(c.money)); + } + for &(item, _count) in &c.items { + lines.push(QuestLine::Item(item)); + } + if quest.chosen_reward != 0 { + lines.push(QuestLine::Item(std::mem::take(&mut quest.chosen_reward))); + } quest.clear(); // A turn-in can move every giver's marker, so the reference re-asks from here. quest.bump_reask(); @@ -371,6 +392,53 @@ mod tests { ); } + /// `0x5dc400`'s order: the completion, the XP, the money, each packet item, then the chosen + /// reward, which the turn-in spends. + #[test] + fn a_turn_in_raises_its_lines_in_the_reference_order() { + let mut giver = QuestGiver { + chosen_reward: 2_047, + ..Default::default() + }; + let mut lines = QuestLines::default(); + quest_complete( + QuestComplete { + quest_id: 7, + xp: 450, + money: 1_025, + items: vec![(6_529, 1), (159, 5)], + }, + &mut giver, + &mut lines, + ); + assert_eq!( + lines.queued(), + [ + QuestLine::Completed(7), + QuestLine::Experience(450), + QuestLine::Money(1_025), + QuestLine::Item(6_529), + QuestLine::Item(159), + QuestLine::Item(2_047), + ] + ); + assert_eq!(giver.chosen_reward, 0, "the turn-in zeroes the latch"); + + // No XP (a capped level) and no money: only the completion line. + let mut lines = QuestLines::default(); + quest_complete( + QuestComplete { + quest_id: 7, + xp: 0, + money: 0, + items: vec![], + }, + &mut giver, + &mut lines, + ); + assert_eq!(lines.queued(), [QuestLine::Completed(7)]); + } + fn open_detail(quest_id: u32) -> QuestGiver { let mut giver = QuestGiver::default(); giver.open( diff --git a/crates/benilla-app/src/ui_quest_log.rs b/crates/benilla-app/src/ui_quest_log.rs index 9ff68386..c63666b6 100644 --- a/crates/benilla-app/src/ui_quest_log.rs +++ b/crates/benilla-app/src/ui_quest_log.rs @@ -68,6 +68,11 @@ impl QuestLog { }) } + /// Whether the server answered `quest_id` as unknown, not still pending. + pub(crate) fn template_answered_unknown(&self, quest_id: u32) -> bool { + self.templates.answered_unknown(quest_id) + } + /// Record a template answer (`SMSG_QUEST_QUERY_RESPONSE`). pub(crate) fn insert_template(&mut self, template: QuestTemplate) { self.templates.insert(template.quest_id, Some(template)); @@ -445,7 +450,7 @@ fn order_groups(rows: &[GroupRow]) -> Vec<(i32, String, Vec<usize>)> { /// Reads the player's `PLAYER_QUEST_LOG` slots each frame and pushes a [`QuestLogState`] on a /// change; also refreshes `row_slots` for the abandon drain. -fn feed_quest_log( +pub(crate) fn feed_quest_log( script: Option<NonSendMut<UiScript>>, self_q: Query<(&ObjectStore, &Guid), With<SelfPlayer>>, mut quest_log: ResMut<QuestLog>, @@ -462,22 +467,27 @@ fn feed_quest_log( // The completion toast is a message-catalog row, whose surface and sound the sink reads. mut sink: crate::ui_action::MessageSink, mut last: Local<crate::ui_script::VmMemo<QuestLogState>>, - mut prior_quest_ids: Local<crate::ui_script::VmMemo<Option<HashSet<u32>>>>, + // The accept line's queue. + mut quest_lines: ResMut<crate::ui_quest::QuestLines>, + mut prior_slots: Local<crate::ui_script::VmMemo<Option<SlotStates>>>, ) { let Some(mut script) = script else { return; }; let last = last.get(&script); - let prior_quest_ids = prior_quest_ids.get(&script); + let prior_slots = prior_slots.get(&script); let Some((store, _)) = self_q.iter().next() else { return; }; let mut rows = Vec::new(); + let mut slots = Vec::with_capacity(usize::from(PLAYER_QUEST_LOG_SLOTS)); for slot in 0..PLAYER_QUEST_LOG_SLOTS { let Some(log_slot) = store.0.player_quest_log(slot) else { + slots.push((0, 0)); continue; }; + slots.push((log_slot.quest_id, log_slot.state)); if log_slot.quest_id == 0 { continue; // a slot an abandon or turn-in cleared } @@ -488,18 +498,14 @@ fn feed_quest_log( }); } - // A quest entering the log plays `QUESTADDED`, the sound of the chat line the reference's - // quest-slot watcher raises, `ERR_QUEST_ACCEPTED_S` (`0x5dde61`); the line is not printed - // here. The first snapshot after login is silent: the initial create fills the whole log. - { - let current: HashSet<u32> = rows.iter().map(|r| r.quest_id).collect(); - if let Some(prior) = prior_quest_ids.as_ref() { - if current.iter().any(|id| !prior.contains(id)) { - script.queue_sound_kit("QUESTADDED"); - } + // The quest-slot watcher's accept line, whose catalog row carries `QUESTADDED`. The first + // snapshot after login is silent: the initial create fills the whole log. + if let Some(prior) = prior_slots.as_ref() { + for quest_id in accepted_quests(prior, &slots) { + quest_lines.push(crate::ui_quest::QuestLine::Accepted(quest_id)); } - *prior_quest_ids = Some(current); } + *prior_slots = Some(slots); // The player's own `GlobalStrings.lua`, for the leaderboard's three format keys. let get = |key: &str| { @@ -710,6 +716,26 @@ fn feed_quest_log( *last = fresh; } +/// Every quest-log slot as `(quest id, state)`, the slot watcher's input. +type SlotStates = Vec<(u32, u8)>; + +/// The quests the slot watcher `0x5ddd80` announces, per slot as `(quest id, state)`: a quest in a +/// slot that held none, or the same quest with its fail bit cleared (`0x5dde17`-`0x5dde34`). A slot +/// that swaps one quest for another announces nothing. +fn accepted_quests(prior: &[(u32, u8)], now: &[(u32, u8)]) -> Vec<u32> { + now.iter() + .zip(prior) + .filter(|&(&(id, state), &(was_id, was_state))| { + id != 0 + && (was_id == 0 + || (was_id == id + && state & quest_slot_state::FAIL == 0 + && was_state & quest_slot_state::FAIL != 0)) + }) + .map(|(&(id, _), _)| id) + .collect() +} + /// Whether an objective advanced. The reference toasts only on the server's additive /// `SMSG_QUESTUPDATE_ADD_KILL` and `_ADD_ITEM` (`0x5e5ad0`, `0x5dd060`), with no removal opcode, /// so a count going down or a name landing late announces nothing. A turn-in destroys the items @@ -1414,7 +1440,8 @@ mod tests { .init_resource::<Items>() .init_resource::<crate::world_state::WorldStates>() .init_resource::<crate::ui_chat::ChatLog>() - .init_resource::<crate::sound::MessageSounds>(); + .init_resource::<crate::sound::MessageSounds>() + .init_resource::<crate::ui_quest::QuestLines>(); let mut log = QuestLog::default(); let mut pairs = Vec::new(); for (slot, id, zos, title) in quests { @@ -1473,6 +1500,76 @@ mod tests { ); } + // ── The accept line ───────────────────────────────────────────────────────────────────────── + + #[test] + fn the_slot_watcher_announces_a_filled_slot_and_a_cleared_failure_only() { + use quest_slot_state::FAIL; + let prior = [(0, 0), (7, 0), (8, FAIL), (9, FAIL), (10, 0), (11, 0)]; + let now = [(783, 0), (7, 0), (8, 0), (9, FAIL), (12, 0), (0, 0)]; + assert_eq!( + accepted_quests(&prior, &now), + [783, 8], + "an empty slot filled, and the same quest with its fail bit cleared; a kept quest, a \ + kept failure, a slot swapping quests and a cleared slot say nothing" + ); + } + + /// The watcher runs on the log's slots, not its rows: an accepted quest is announced before its + /// template lands, and the login snapshot is silent. + #[test] + fn a_quest_entering_the_log_queues_its_accept_line() { + use crate::ui_quest::{QuestLine, QuestLines}; + use benilla_protocol::messages::field::FIELD_PLAYER_QUEST_LOG_1_1; + + let (tx, _rx) = crossbeam_channel::unbounded(); + let mut app = App::new(); + app.insert_resource(NetCommands(tx)); + app.init_resource::<crate::net::GuidIndex>() + .init_resource::<NameCache>() + .init_resource::<Items>() + .init_resource::<crate::world_state::WorldStates>() + .init_resource::<crate::ui_chat::ChatLog>() + .init_resource::<crate::sound::MessageSounds>() + .init_resource::<QuestLines>() + .init_resource::<QuestLog>(); + let player = app + .world_mut() + .spawn(( + ObjectStore(ObjectFields::from_pairs(&[(FIELD_PLAYER_QUEST_LOG_1_1, 7)])), + Guid(0x2A), + SelfPlayer, + )) + .id(); + app.insert_non_send_resource(UiScript::new().unwrap()); + app.add_systems(Update, feed_quest_log); + + app.update(); + assert!( + app.world().resource::<QuestLines>().queued().is_empty(), + "the login snapshot fills the log silently" + ); + + app.world_mut() + .entity_mut(player) + .insert(ObjectStore(ObjectFields::from_pairs(&[ + (FIELD_PLAYER_QUEST_LOG_1_1, 7), + (FIELD_PLAYER_QUEST_LOG_1_1 + 3, 783), + ]))); + app.update(); + assert_eq!( + app.world().resource::<QuestLines>().queued(), + [QuestLine::Accepted(783)] + ); + assert!( + app.world_mut() + .non_send_resource_mut::<UiScript>() + .take_sounds() + .is_empty(), + "the sound is the accept row's, played with its line" + ); + } + // ── quests_with_progressed_objectives ─────────────────────────────────────────────────────── /// A one-objective entry whose `cur`/`req` are parsed from its text's trailing `cur/req`, so diff --git a/crates/benilla-ui/src/script/sound.rs b/crates/benilla-ui/src/script/sound.rs index 1883d433..f33ac992 100644 --- a/crates/benilla-ui/src/script/sound.rs +++ b/crates/benilla-ui/src/script/sound.rs @@ -38,7 +38,7 @@ pub enum MusicRequest { impl super::UiScript { /// Queue a kit play from the app side, for the UI sounds the client fires from C++ - /// (`QUESTADDED` on a log add, `QUESTCOMPLETED` on the turn-in) that no Lua handler owns. + /// (`QUESTCOMPLETED` on the turn-in) that no Lua handler or message row owns. pub fn queue_sound_kit(&mut self, name: &str) { self.model_mut() .sound_queue