Quests print their chat lines: accepted, completed, experience, money and items (#390)

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: <title>" 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>
This commit is contained in:
Sam Sung
2026-09-26 07:42:53 +07:00
committed by GitHub
co-authored by Claude Opus 5.5
parent b8a32a06e7
commit 3e4792f886
6 changed files with 568 additions and 24 deletions
+5 -2
View File
@@ -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) {
+359
View File
@@ -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");
}
}
+18 -1
View File
@@ -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,
+74 -6
View File
@@ -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(
+111 -14
View File
@@ -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
+1 -1
View File
@@ -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