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: " 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: "; 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)
---
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);
+
+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::()
+ .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, mut lines: ResMut) {
+ lines.0.clear();
+}
+
+/// Show every line whose record is here, in the order raised; the rest wait.
+fn feed_quest_lines(
+ script: Option>,
+ mut lines: ResMut,
+ quest_log: Res,
+ items: Res,
+ commands: Res,
+ 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,
+) -> 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::>()
+ .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) {
+ let (tx, rx) = crossbeam_channel::unbounded();
+ let mut app = App::new();
+ app.insert_resource(NetCommands(tx))
+ .init_resource::()
+ .init_resource::()
+ .init_resource::()
+ .init_resource::()
+ .init_resource::()
+ .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 {
+ app.world()
+ .resource::()
+ .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::()
+ .insert_template(template(783, "A Threat Within", 0));
+ world
+ .resource_mut::()
+ .insert_template(2_580, Some(crate::items::test_template("Linen Cloth")));
+ let mut lines = world.resource_mut::();
+ 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::().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::()
+ .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::()
+ .insert_template(template(7, "Kobold Camp Cleanup", 0));
+ world
+ .resource_mut::()
+ .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::().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::()
+ .insert_template(template(783, "A Threat Within", 7));
+ world.resource_mut::().insert_template(9_999, None);
+ let mut lines = world.resource_mut::();
+ lines.push(QuestLine::Completed(783));
+ lines.push(QuestLine::Item(9_999));
+ }
+ app.update();
+ assert!(chat(&app).is_empty());
+ assert!(app.world().resource::().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,
/// 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::().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, mut quest: ResMut) {
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, mut quest: ResMut) {
@@ -82,9 +84,13 @@ fn on_offer(In(ev): In, mut quest: ResMut) {
}
}
-fn on_complete(In(ev): In, mut quest: ResMut) {
+fn on_complete(
+ In(ev): In,
+ mut quest: ResMut,
+ mut lines: ResMut,
+) {
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)> {
/// 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>,
self_q: Query<(&ObjectStore, &Guid), With>,
mut quest_log: ResMut,
@@ -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>,
- mut prior_quest_ids: Local>>>,
+ // The accept line's queue.
+ mut quest_lines: ResMut,
+ mut prior_slots: Local>>,
) {
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 = 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 {
+ 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::()
.init_resource::()
.init_resource::()
- .init_resource::();
+ .init_resource::()
+ .init_resource::();
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::()
+ .init_resource::()
+ .init_resource::()
+ .init_resource::()
+ .init_resource::()
+ .init_resource::()
+ .init_resource::()
+ .init_resource::();
+ 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::().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::().queued(),
+ [QuestLine::Accepted(783)]
+ );
+ assert!(
+ app.world_mut()
+ .non_send_resource_mut::()
+ .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