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:
committed by
GitHub
co-authored by
Claude Opus 5.5
parent
b8a32a06e7
commit
3e4792f886
@@ -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) {
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user