- the mail window's send tab takes typing again: the subject, body and money boxes were taking no input while the recipient box swallowed every key, and a sent letter clears its subject and item icon instead of putting them straight back (2145) - seven events fire the way the reference does: UNIT_DYNAMIC_FLAGS fires at all, swapping two identical stacks between bank slots fires PLAYERBANKSLOTS_CHANGED twice, the pet sheet's training points repaint, and ITEM_LOCK_CHANGED loses its invented arguments (2140) - nine widget methods 1.12 addons probe for are in: ShaguTweaks stops recolouring textures the reference leaves alone, pfUI and _Nameplates stop raising on a tooltip's GetAnchorType, and 63 AceEvent addons get RegisterAllEvents (2142) - six post-1.12 methods leave the widget surface (SetSize, SetShown, HasFocus, SetFormattedText and two tooltip verbs), so an addon reaching for a later client's API finds what a real 1.12 client gives it (2142, 2146) Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
1106 lines
49 KiB
Rust
1106 lines
49 KiB
Rust
//! The app-side **mail feed** (decision 0544 P1/P2) — the inward half of the mail seam around
|
|
//! [`benilla_ui::script`]'s `mail` module, the mailbox twin of [`crate::ui_merchant`]'s vendor seam.
|
|
//!
|
|
//! The mailbox window is entirely client-side (there is no `SMSG_SHOW_MAILBOX` on 5875): a
|
|
//! right-click on a MAILBOX GameObject ([`crate::target`]) opens the session here — it stores the
|
|
//! mailbox guid and requests the window's show; it sends **no** packet (the wow-re §5 confirms the
|
|
//! MAILBOX use handler overrides the shared use-sender to a local open — never `CMSG_GAMEOBJ_USE`).
|
|
//! The window's own `MAIL_SHOW` handler then calls `CheckInbox()`, whose intent this module drains
|
|
//! into the first `CMSG_GET_MAIL_LIST` — one request, Lua-driven, exactly the reference flow
|
|
//! (MailFrame.lua l.42).
|
|
//!
|
|
//! The net bridge ([`crate::net::apply::mail`]) fills [`MailOpen`] from the wire
|
|
//! (`SMSG_MAIL_LIST_RESULT` → the inbox rows; `SMSG_ITEM_TEXT_QUERY_RESPONSE` → the body cache;
|
|
//! `SMSG_SEND_MAIL_RESULT` → the send/take result queues). Each frame [`feed_mail`] resolves each
|
|
//! wire [`MailListEntry`] to a Lua-facing [`MailInboxRow`] (sender via the ask-once name cache, item
|
|
//! name/quality/icon via the ask-once item-template cache + `ItemDisplayInfo.dbc`, body from the
|
|
//! cache, stationery basename from `Stationery.dbc`), pushes the snapshot
|
|
//! ([`benilla_ui::script::UiScript::set_mail`]), and fires the events the reference Lua drives —
|
|
//! `MAIL_SHOW` on open, `MAIL_INBOX_UPDATE` on a list/body change, `MAIL_CLOSED` on clear, and the
|
|
//! send-result trio (see [`feed_mail`]). [`drain_mail`] pulls the Lua intents back out into the mail
|
|
//! `CMSG`s. The standardized NPC-session range guard ([`crate::ui_session`]) client-side-closes the
|
|
//! window when the player leaves the mailbox's interaction range (wow-re §5: the client's generic
|
|
//! interaction-target range gate — past it every mail `CMSG` silently fails its `CheckMailBox`).
|
|
//!
|
|
//! **The arrival layer ([`MailPending`], decisions 0544 P3 / 0904 / 0913, wow-re
|
|
//! `ui/scratch/mail-pending-countdown.md`).** `HasNewMail()` mirrors the real client's own model: a
|
|
//! countdown float (`0x845eac`) read as **`|countdown| < ε`** — *near zero*, not `<= 0` (the sign
|
|
//! matters, decision 0904: vmangos's "no mail" reply is `-86400.0`, which a `<= 0` predicate reads
|
|
//! as "you have mail" at every login). vmangos only ever sends `0.0` ("waiting now") or `-86400.0`
|
|
//! ("none") — never a positive value — but the countdown is implemented in full since that's the
|
|
//! client's real model, not a two-value special case.
|
|
//!
|
|
//! **The icon's whole life is that float**, because `MiniMapMailFrame` is the *only* listener of
|
|
//! `UPDATE_PENDING_MAIL` in all of 1.12 FrameXML and its handler is a bare idempotent
|
|
//! `if HasNewMail() then Show() else Hide()` (`Minimap.xml` l.278-289) — it never listens for
|
|
//! `MAIL_INBOX_UPDATE`/`MAIL_CLOSED` despite the icon logically depending on the inbox state. So
|
|
//! **checking your mail clears the icon by a route that never touches the inbox** (decision 0913):
|
|
//! opening a letter arms the deferred-refresh flag ([`MailPending::arm_refresh`], the reference's
|
|
//! `[0xb6efcc]`), and the mailbox *close* re-asks the server — the sender stamping the countdown to
|
|
//! [`MAIL_TIME_NO_MAIL`] and the reply settling it. The same stamp-then-ask runs at every
|
|
//! world-enter ([`send_query_next_mail_time_on_enter`]), which is why the reference icon also blinks
|
|
//! off across a loading screen.
|
|
//!
|
|
//! [`feed_mail`] steps the float every frame ([`MailPending::step`]), keeps
|
|
//! [`benilla_ui::script::UiScript::set_has_new_mail`] current, and fires `UPDATE_PENDING_MAIL` at
|
|
//! the three sites the reference fires it (see [`MailPending::notify`]).
|
|
|
|
use std::collections::{HashMap, HashSet};
|
|
|
|
use benilla_protocol::messages::{mail_error, mail_message_type, MailListEntry};
|
|
use bevy::prelude::*;
|
|
|
|
use benilla_ui::script::{MailInboxRow, MailState, ScriptValue, StationeryView, UiScript};
|
|
|
|
use crate::entities::ItemDisplays;
|
|
use crate::items::Items;
|
|
use crate::names::NameCache;
|
|
use crate::net::{ClientCommand, EnteredWorldMessage, NetCommands, ObjectStore, SelfPlayer};
|
|
use crate::ui_script::UiInput;
|
|
use crate::ui_session::{close_npc_session_out_of_range, NpcSession};
|
|
|
|
mod pending;
|
|
|
|
pub(crate) use pending::MailPending;
|
|
|
|
/// `checked` mask bits (VERIFIED vmangos `Mail.h`, decision 0544): READ, RETURNED, COPIED.
|
|
const CHECKED_READ: u32 = 0x1;
|
|
const CHECKED_RETURNED: u32 = 0x2;
|
|
const CHECKED_COPIED: u32 = 0x4;
|
|
|
|
/// `MAIL_STATIONERY_GM` (`Stationery.dbc` row 61) — `isGM` is `stationery == 61` (MailFrame.lua
|
|
/// l.108: a GM row shows its stationery icon even when it carries a package).
|
|
const STATIONERY_GM: u32 = 61;
|
|
|
|
/// The `CheckInbox` → `CMSG_GET_MAIL_LIST` client-side rate limit, in seconds (wow-re §5
|
|
/// `ui/scratch/mail-interaction.md`: the real client drops repeat inbox queries inside this window).
|
|
const CHECK_INBOX_THROTTLE_SECS: f64 = 60.0;
|
|
|
|
/// The letter-row icon by kind (the wire carries only a stationery id; the reference derives the
|
|
/// row's `stationeryIcon` from it). VERIFIED via the data chain `Stationery.dbc` → stationery
|
|
/// item (41 → 9311 "Default Stationery", 61 → 18154 "Blizzard Stationery") → the item's
|
|
/// `ItemDisplayInfo` icon (displays 7798 / 30658) — and the default's pixels match the reference
|
|
/// screenshot's red-stamped letter (`INV_Letter_15`, our old guess, is the tan open envelope).
|
|
const STATIONERY_ICON_NORMAL: &str = "Interface\\Icons\\INV_Misc_Note_01";
|
|
const STATIONERY_ICON_GM: &str = "Interface\\Icons\\Mail_GMIcon";
|
|
|
|
/// The `Stationery.dbc` id → texture-basename catalog, loaded with the entity catalogs
|
|
/// ([`crate::entities`]). Optional resource — absent, every mail falls to the default backdrop
|
|
/// ([`benilla_formats::StationeryCatalog::DEFAULT_TEXTURE`]) and the window still works.
|
|
#[derive(Resource)]
|
|
pub(crate) struct Stationery(pub(crate) benilla_formats::StationeryCatalog);
|
|
|
|
/// One `SMSG_SEND_MAIL_RESULT` for `action == SEND`, queued by the net bridge for [`feed_mail`] to
|
|
/// fire (the feed owns the `UiScript`, the apply site doesn't). `MAIL_FAILED` fires on **every**
|
|
/// SEND result — success included — because the real client overloads it as "send resolved, unblock
|
|
/// the UI" (wow-re §5, byte-verified; MailFrame.lua's `MAIL_FAILED` handler only re-enables the
|
|
/// button). `MAIL_SEND_SUCCESS` fires additionally when `error == OK`.
|
|
pub(crate) struct MailSendAck {
|
|
pub(crate) ok: bool,
|
|
/// The message key for a non-OK, non-equip send failure (else `None`).
|
|
pub(crate) refusal: Option<&'static str>,
|
|
}
|
|
|
|
/// The open mailbox, filled by the net bridge ([`crate::net::apply::mail`]) and read by
|
|
/// [`feed_mail`]. Holds the mailbox guid and the inbox rows exactly as the wire delivered them
|
|
/// (`SMSG_MAIL_LIST_RESULT`), the ask-once letter-body cache, and the send-result queues; the feed
|
|
/// resolves each row to a display row and the drain maps a clicked 1-based row to its wire mail id.
|
|
/// Cleared on a client-side close and on disconnect.
|
|
#[derive(Resource, Default)]
|
|
pub(crate) struct MailOpen {
|
|
/// The mailbox whose window is open; `None` = no mailbox open.
|
|
pub(crate) mailbox: Option<u64>,
|
|
/// The inbox rows (wire order = 1-based display order).
|
|
pub(crate) mails: Vec<MailListEntry>,
|
|
/// Ask-once letter-body cache: `item_text_id` → body text.
|
|
pub(crate) bodies: HashMap<u32, String>,
|
|
/// `item_text_id`s with a `CMSG_ITEM_TEXT_QUERY` in flight (ask-once dedup).
|
|
pub(crate) pending_bodies: HashSet<u32>,
|
|
/// Fire `MAIL_SHOW` next feed — set on every mailbox click (the reference opens the window per
|
|
/// use; wow-re §5). Consumed by [`feed_mail`].
|
|
show_requested: bool,
|
|
/// Session-scoped throttle: the `Time::elapsed_secs_f64` of the last `CMSG_GET_MAIL_LIST`, or
|
|
/// `None` when never queried this session (a fresh open queries immediately).
|
|
last_list_query: Option<f64>,
|
|
/// SEND-action results the net bridge queued for the feed to fire ([`MailSendAck`]).
|
|
pub(crate) send_acks: Vec<MailSendAck>,
|
|
/// 1-based inbox rows a take just emptied and purged (`net::apply::mail`) — the feed fires
|
|
/// `CLOSE_INBOX_ITEM(index)` for each, ahead of the `MAIL_INBOX_UPDATE` the purge causes.
|
|
pub(crate) close_inbox: Vec<u32>,
|
|
/// Refusals (a take/return/delete failure) the feed shows, as message keys — resolved at the
|
|
/// feed, which is where the VM's `GlobalStrings` can be read
|
|
/// ([`crate::ui_action::keyed_line`]).
|
|
pub(crate) errors: Vec<&'static str>,
|
|
}
|
|
|
|
impl MailOpen {
|
|
/// A mailbox right-click (decision 0544): open (or re-show) the window's session and request its
|
|
/// `MAIL_SHOW`. A *different* mailbox resets the session (rows + throttle); the same one keeps
|
|
/// them and just re-shows (the reference re-fires `MAIL_SHOW` on every use — `CheckInbox`'s own
|
|
/// throttle stops the refresh from spamming the wire).
|
|
pub(crate) fn click(&mut self, mailbox: u64) {
|
|
if self.mailbox != Some(mailbox) {
|
|
self.mailbox = Some(mailbox);
|
|
self.mails.clear();
|
|
self.last_list_query = None;
|
|
}
|
|
self.show_requested = true;
|
|
}
|
|
|
|
/// The wire mail id at a 1-based display row — what the mail `CMSG`s address.
|
|
fn mail_id_at(&self, index_1based: u32) -> Option<u32> {
|
|
index_1based
|
|
.checked_sub(1)
|
|
.and_then(|i| self.mails.get(i as usize))
|
|
.map(|e| e.message_id)
|
|
}
|
|
|
|
/// Flip a row's local `checked` READ bit — the no-reply `CMSG_MAIL_MARK_AS_READ` has no server
|
|
/// answer (decision 0544 P1), so the read state is client-authoritative until the next list.
|
|
fn mark_read(&mut self, index_1based: u32) {
|
|
if let Some(e) = index_1based
|
|
.checked_sub(1)
|
|
.and_then(|i| self.mails.get_mut(i as usize))
|
|
{
|
|
e.checked |= CHECKED_READ;
|
|
}
|
|
}
|
|
|
|
/// Close the open window (a client-side close, no packet — vanilla). Keeps nothing; a re-open
|
|
/// re-lists.
|
|
pub(crate) fn clear(&mut self) {
|
|
self.mailbox = None;
|
|
self.mails.clear();
|
|
self.bodies.clear();
|
|
self.pending_bodies.clear();
|
|
self.show_requested = false;
|
|
self.last_list_query = None;
|
|
self.send_acks.clear();
|
|
self.errors.clear();
|
|
}
|
|
|
|
/// Disconnect: drop the open window (mirrors the merchant/gossip session clears).
|
|
pub(crate) fn clear_session(&mut self) {
|
|
self.clear();
|
|
}
|
|
}
|
|
|
|
/// The mailbox window is an NPC-less interaction session: the standardized range guard
|
|
/// ([`crate::ui_session`]) client-side-closes it — the exact `CloseMail` clear — when the player
|
|
/// leaves the mailbox's interaction range (wow-re §5: the client's generic interaction-target range
|
|
/// gate, the same one the mail `CMSG`s' server-side `CheckMailBox` enforces at 5 yd).
|
|
impl NpcSession for MailOpen {
|
|
fn npc(&self) -> Option<u64> {
|
|
self.mailbox
|
|
}
|
|
|
|
fn close(&mut self) {
|
|
self.clear();
|
|
}
|
|
}
|
|
|
|
pub(crate) struct UiMailPlugin;
|
|
|
|
impl Plugin for UiMailPlugin {
|
|
fn build(&self, app: &mut App) {
|
|
app.init_resource::<MailOpen>()
|
|
.init_resource::<MailPending>()
|
|
.add_systems(
|
|
Update,
|
|
(
|
|
// Range-close before the feed so the clear turns into MAIL_CLOSED the same
|
|
// frame; feed before the input pass so an open/close is on screen the same
|
|
// frame; drain after it so a click's intent (CheckInbox, a take, a send) goes
|
|
// out the same frame (the ui_merchant ordering exactly). After the UnitFeed set
|
|
// so the SetInboxItem tooltip reads a landed item-template store.
|
|
close_npc_session_out_of_range::<MailOpen>.before(feed_mail),
|
|
feed_mail.after(crate::ui_unit::UnitFeed).before(UiInput),
|
|
drain_mail.after(UiInput),
|
|
// The world-enter one-shot (decision 0548 §7: "once at UI/login load") — its
|
|
// own system, ordering-independent of the feed/drain pair above.
|
|
send_query_next_mail_time_on_enter,
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
/// `MSG_QUERY_NEXT_MAIL_TIME`, sent once per **world-enter** — VERIFIED faithful (decision 0913):
|
|
/// the query's two callers are the mail module's init tail and the close-with-pending path, and
|
|
/// that init hangs off the world-enter cascade `0x4908c0` (`PLAYER_ENTERING_WORLD` + 37 subsystem
|
|
/// inits), whose guard is re-armed by the paired teardown — so it runs per world-enter (login,
|
|
/// worldport, instance transfer, `ReloadUI`), not once per process as an earlier note had it.
|
|
///
|
|
/// The init stamps the countdown to [`MAIL_TIME_NO_MAIL`] before asking, which is why the reference
|
|
/// icon blinks off across a loading screen and comes back with the reply.
|
|
fn send_query_next_mail_time_on_enter(
|
|
mut entered: MessageReader<EnteredWorldMessage>,
|
|
commands: Res<NetCommands>,
|
|
mut pending: ResMut<MailPending>,
|
|
) {
|
|
if entered.read().next().is_some() {
|
|
pending.on_query_sent();
|
|
let _ = commands.0.send(ClientCommand::QueryNextMailTime);
|
|
}
|
|
}
|
|
|
|
/// The message a `SMSG_SEND_MAIL_RESULT` code shows — **always** a catalog key, which is the whole
|
|
/// correction decision 1821 brought here (superseding 0544's hand-written pair).
|
|
///
|
|
/// The reference's handler `0x4ad050` indexes `[0x4ad1a0 + error]` into a nine-entry jump table at
|
|
/// `0x4ad17c`, every arm a `push <id>; call CGGameUI::DisplayError`. This module used to claim
|
|
/// *"No 1.12 GlobalString for either"* about codes 5 and 14 and inline near-miss English for them;
|
|
/// both are real rows, and their real strings differ from what we were showing. And the arm this
|
|
/// module used to spell `"Mail failed (n)."` is the reference's own `ja 0x4ad147` — every code
|
|
/// from 6 to 13 and everything past 15 lands on `ERR_MAIL_DATABASE_ERROR` with them.
|
|
///
|
|
/// `OK` and `EQUIP_ERROR` never reach here: the first is not a refusal (it has
|
|
/// [`MAIL_SENT_KEY`] instead) and the second resolves through the equip-error table
|
|
/// (`0x4ad12e` → `0x622630`), which is [`crate::ui_items`]' job.
|
|
pub(crate) fn mail_refusal(error: u32) -> &'static str {
|
|
match error {
|
|
mail_error::CANNOT_SEND_TO_SELF => "ERR_MAIL_TO_SELF", // 0x164
|
|
mail_error::NOT_ENOUGH_MONEY => "ERR_NOT_ENOUGH_MONEY", // 0x25 — speaks, line 0x28
|
|
mail_error::RECIPIENT_NOT_FOUND => "ERR_MAIL_TARGET_NOT_FOUND", // 0x165
|
|
mail_error::NOT_YOUR_TEAM => "ERR_PLAYER_WRONG_FACTION", // 0xff
|
|
mail_error::TRIAL_ACCOUNT => "ERR_RESTRICTED_ACCOUNT", // 0x1be
|
|
mail_error::TOO_MANY_ATTACHMENTS => "ERR_MAIL_REACHED_CAP", // 0x1c4
|
|
// INTERNAL_ERROR (6) and every other code, by the same `0x4ad147`.
|
|
_ => "ERR_MAIL_DATABASE_ERROR", // 0x166
|
|
}
|
|
}
|
|
|
|
/// The line a **successful** `SEND` shows: `ERR_MAIL_SENT` — id `0x167`, and a `kind 1` row, so it
|
|
/// is the yellow info line and not the red one. `0x4ad0b1` reaches it before the form reset
|
|
/// (`0x4acdc0`), gated on the action being `SEND`; a successful take/return/delete says nothing.
|
|
pub(crate) const MAIL_SENT_KEY: &str = "ERR_MAIL_SENT";
|
|
|
|
mod invoice;
|
|
|
|
use invoice::auction_mail;
|
|
|
|
/// Resolve one wire [`MailListEntry`] into the Lua-facing [`MailInboxRow`]: sender through the ask-
|
|
/// once name cache (player guids only), the enclosed item's name/quality/icon through the ask-once
|
|
/// item-template cache + `ItemDisplayInfo.dbc`, the body from the cache, the stationery basename
|
|
/// from `Stationery.dbc`. `None`s stay `None` while a query is in flight — the row shows a
|
|
/// placeholder and fills in when the answer lands (the merchant/loot pattern).
|
|
#[allow(clippy::too_many_arguments)] // one resolve per ask-once cache the row reads
|
|
fn resolve_row(
|
|
entry: &MailListEntry,
|
|
bodies: &HashMap<u32, String>,
|
|
items: &mut Items,
|
|
icons: Option<&ItemDisplays>,
|
|
stationery: Option<&Stationery>,
|
|
names: &mut NameCache,
|
|
commands: &NetCommands,
|
|
rolls: crate::items::RollCatalogs,
|
|
macros: &crate::npc_text::MacroContext,
|
|
// The VM's own GlobalStrings, for the auction notice's subject template. A resolver rather than
|
|
// a table because the strings are the player's install's, and Rust must never carry a copy of
|
|
// them (decision 0669's shape).
|
|
get_text: &dyn Fn(&str) -> Option<String>,
|
|
) -> MailInboxRow {
|
|
let is_gm = entry.stationery == STATIONERY_GM;
|
|
let returned = entry.checked & CHECKED_RETURNED != 0;
|
|
// Reply is allowed only to a not-yet-returned mail from a player (MailFrame.lua l.281).
|
|
let can_reply = entry.sender_guid.is_some() && !returned;
|
|
// Delete-vs-return (INTERIM, decision 0548): a mail RETURNS on delete/expiry only when it still
|
|
// carries attachments (item or money) from a not-yet-returned player sender — that is the
|
|
// server's own expiry behavior (vmangos `ReturnOrDeleteOldMails`). Everything else — plain
|
|
// letters, system mail, already-returned mail — deletes. The exact client predicate behind
|
|
// `InboxItemCanDelete` is un-RE'd; this mirrors the observable law.
|
|
let can_delete =
|
|
returned || entry.sender_guid.is_none() || (entry.item.is_none() && entry.money == 0);
|
|
|
|
// The enclosed item, resolved ask-once from its template.
|
|
let (item_id, item_roll, item_count, item_name, item_texture, item_quality) = match &entry.item
|
|
{
|
|
Some(att) => {
|
|
let template = items.template(att.entry, 0, commands);
|
|
// The rolled name (1547) — the same join the loot window and the auction rows make.
|
|
let name = template.map(|t| rolls.name(&t.name, att.random_prop_id));
|
|
let quality = template.map(|t| t.quality);
|
|
let display_id = template.map(|t| t.display_info_id).unwrap_or(0);
|
|
let texture = icons
|
|
.and_then(|i| i.catalog.get(display_id))
|
|
.and_then(|d| d.icon.clone());
|
|
(
|
|
att.entry,
|
|
att.random_prop_id,
|
|
u32::from(att.count),
|
|
name,
|
|
texture,
|
|
quality,
|
|
)
|
|
}
|
|
None => (0, 0, 0, None, None, None),
|
|
};
|
|
|
|
let sender = entry
|
|
.sender_guid
|
|
.and_then(|g| names.resolve(g, commands).map(str::to_string));
|
|
|
|
let stationery_texture = stationery
|
|
.map(|s| s.0.texture(entry.stationery).to_string())
|
|
.unwrap_or_else(|| benilla_formats::StationeryCatalog::DEFAULT_TEXTURE.to_string());
|
|
|
|
// The letter body, raw. Hoisted because the auction invoice parses THIS and not the
|
|
// `$`-substituted text below: the invoice is machine-written colon fields, and running a macro
|
|
// expander over them would be expanding the auction house's own bookkeeping.
|
|
let raw_body = (entry.item_text_id != 0)
|
|
.then(|| bodies.get(&entry.item_text_id))
|
|
.flatten();
|
|
|
|
// ── The auction house's mail (`ui_mail::invoice`, wow-re §11.1a) ─────────────────────────
|
|
// Two independent resolves off one subject: the DISPLAYED subject, which every auction notice
|
|
// gets, and the INVOICE, which only a won/sold notice can answer and only once its body has
|
|
// been fetched.
|
|
let auction = (entry.message_type == mail_message_type::AUCTION)
|
|
.then(|| invoice::parse_subject(&entry.subject))
|
|
.flatten();
|
|
// The item the notice is ABOUT — resolved from the subject's own entry, not from the
|
|
// attachment: a "sold" notice encloses the money, and names an item it does not carry.
|
|
let notice_item_name = auction
|
|
.and_then(|a| items.template(a.entry, 0, commands))
|
|
.map(|t| t.name.clone());
|
|
// `<key>: %s` + the name. Until the item template lands the raw triplet stands — a subject that
|
|
// reads `4428:0:1` for one frame is better than one that reads `Auction won: ` forever if the
|
|
// query never answers, and the row re-resolves (and fires MAIL_INBOX_UPDATE) when it does.
|
|
let subject = match (auction, ¬ice_item_name) {
|
|
(Some(a), Some(name)) => invoice::subject_key(a.result)
|
|
.and_then(get_text)
|
|
.map(|t| t.replace("%s", name))
|
|
.unwrap_or_else(|| entry.subject.clone()),
|
|
_ => entry.subject.clone(),
|
|
};
|
|
let auction_invoice = auction
|
|
.filter(|a| matches!(a.result, auction_mail::WON | auction_mail::SOLD))
|
|
.and_then(|a| {
|
|
let seller = a.result == auction_mail::SOLD;
|
|
let n = invoice::parse_body(raw_body?, seller)?;
|
|
// An unresolved counterparty takes the miss tail this pass and fills in when the name
|
|
// query lands — the reference does exactly this, and fires MAIL_INBOX_UPDATE on the
|
|
// late arrival, which our snapshot diff does for free.
|
|
let player_name = names.resolve(n.player_guid, commands)?.to_string();
|
|
Some(benilla_ui::script::MailInvoice {
|
|
seller,
|
|
item_name: notice_item_name.clone()?,
|
|
player_name,
|
|
bid: n.bid,
|
|
buyout: n.buyout,
|
|
deposit: n.deposit,
|
|
consignment: n.consignment,
|
|
})
|
|
});
|
|
|
|
MailInboxRow {
|
|
package_icon: item_texture.clone(),
|
|
stationery_icon: Some(
|
|
if is_gm {
|
|
STATIONERY_ICON_GM
|
|
} else {
|
|
STATIONERY_ICON_NORMAL
|
|
}
|
|
.to_string(),
|
|
),
|
|
sender,
|
|
subject,
|
|
money: entry.money,
|
|
cod: entry.cod,
|
|
days_left: entry.expire_days,
|
|
item_count,
|
|
was_read: entry.checked & CHECKED_READ != 0,
|
|
was_returned: returned,
|
|
text_created: entry.checked & CHECKED_COPIED != 0,
|
|
can_reply,
|
|
is_gm,
|
|
// The letter body is server-authored text, so it runs the `$`-macro expander — the
|
|
// reference does it from two sites in `GetInboxText` (decision 0754). Subject is the
|
|
// local player, as it is for every panel seam.
|
|
body: raw_body.map(|b| crate::npc_text::substitute(b, macros)),
|
|
stationery_texture,
|
|
// NOT "is an auction mail": the reference's `isInvoice` reads the fields its subject
|
|
// parser persisted, and it persists them only for won(1)/sold(2) — so an outbid or expiry
|
|
// notice answers false here (decision 1527). This is also what nils the body: the two
|
|
// answers come off the same record state, which is exactly why MailFrame can lay the
|
|
// invoice pane over the letter page.
|
|
is_invoice: auction
|
|
.is_some_and(|a| matches!(a.result, auction_mail::WON | auction_mail::SOLD)),
|
|
invoice: auction_invoice,
|
|
has_body: entry.item_text_id != 0,
|
|
item_id,
|
|
item_random_property_id: item_roll,
|
|
item_name,
|
|
item_texture,
|
|
item_quality,
|
|
can_delete,
|
|
}
|
|
}
|
|
|
|
/// Build the Lua-facing snapshot from [`MailOpen`] — `None` when no mailbox is open.
|
|
/// The usable stationery list (wow-re `stationery-bindings.md` §`0x4ad970`, 1970): every
|
|
/// `Stationery.dbc` row that is always available (`Flags & 1`) or whose item the player carries
|
|
/// (bags, not the bank — the client's `0x622270`), and whose item template is cached — the name,
|
|
/// icon and price come off the template — sorted by BuyPrice ascending, the client's comparator
|
|
/// (`0x4ada90`). A carried paper costs nothing to use (`cost` nil). Templates are asked once here
|
|
/// so the list fills from the second frame after world enter, mailbox or no mailbox.
|
|
fn stationeries(
|
|
catalog: &Stationery,
|
|
self_q: &Query<(&ObjectStore, &crate::net::Guid), With<SelfPlayer>>,
|
|
items: &mut Items,
|
|
icons: Option<&ItemDisplays>,
|
|
commands: &NetCommands,
|
|
) -> Vec<StationeryView> {
|
|
let store = self_q.iter().next().map(|(s, _)| s);
|
|
let mut out: Vec<(u32, StationeryView)> = Vec::new();
|
|
for row in catalog.0.rows() {
|
|
let Some(t) = items.template(row.item, 0, commands).cloned() else {
|
|
continue;
|
|
};
|
|
let carried = store.is_some_and(|s| {
|
|
crate::ui_items::count_of(
|
|
&s.0,
|
|
items,
|
|
row.item,
|
|
crate::ui_items::InventoryScope::CARRIED,
|
|
) > 0
|
|
});
|
|
if row.flags & 1 == 0 && !carried {
|
|
continue;
|
|
}
|
|
out.push((
|
|
t.buy_price,
|
|
StationeryView {
|
|
id: row.id,
|
|
name: t.name.clone(),
|
|
icon: crate::ui_items::item_icon(icons, t.display_info_id).unwrap_or_default(),
|
|
cost: (!carried).then_some(t.buy_price),
|
|
texture: row.texture.clone(),
|
|
},
|
|
));
|
|
}
|
|
out.sort_by_key(|(price, v)| (*price, v.id));
|
|
out.into_iter().map(|(_, v)| v).collect()
|
|
}
|
|
|
|
#[allow(clippy::too_many_arguments)] // mirrors `resolve_row`'s cache set
|
|
fn snapshot(
|
|
mail: &MailOpen,
|
|
items: &mut Items,
|
|
icons: Option<&ItemDisplays>,
|
|
stationery: Option<&Stationery>,
|
|
names: &mut NameCache,
|
|
commands: &NetCommands,
|
|
rolls: crate::items::RollCatalogs,
|
|
macros: &crate::npc_text::MacroContext,
|
|
get_text: &dyn Fn(&str) -> Option<String>,
|
|
) -> Option<MailState> {
|
|
mail.mailbox?;
|
|
Some(MailState {
|
|
inbox: mail
|
|
.mails
|
|
.iter()
|
|
.map(|e| {
|
|
resolve_row(
|
|
e,
|
|
&mail.bodies,
|
|
items,
|
|
icons,
|
|
stationery,
|
|
names,
|
|
commands,
|
|
rolls,
|
|
macros,
|
|
get_text,
|
|
)
|
|
})
|
|
.collect(),
|
|
})
|
|
}
|
|
|
|
/// Push the current mail into the VM and fire the show/update/close + send-result events on a
|
|
/// transition or content change (an async sender/item/body landing, a mark-read flip, a fresh
|
|
/// list). Diffed against a `Local` memory, exactly like the merchant/gossip feeds.
|
|
/// The feed's tail, bundled — the signature sits at the 16-SystemParam ceiling: the random-suffix
|
|
/// roll's catalogs (1547), where a refusal lands (1815), and the usable stationery list's memo
|
|
/// (1970), pushed on change like the inbox.
|
|
#[derive(bevy::ecs::system::SystemParam)]
|
|
struct MailFeedExtras<'w, 's> {
|
|
props: Option<Res<'w, crate::items::RandomProperties>>,
|
|
enchants: Option<Res<'w, crate::items::Enchants>>,
|
|
sink: crate::ui_action::MessageSink<'w>,
|
|
stationeries: Local<'s, crate::ui_script::VmMemo<Vec<StationeryView>>>,
|
|
}
|
|
|
|
#[allow(clippy::too_many_arguments)]
|
|
fn feed_mail(
|
|
script: Option<NonSendMut<UiScript>>,
|
|
mut mail: ResMut<MailOpen>,
|
|
mut items: ResMut<Items>,
|
|
icons: Option<Res<ItemDisplays>>,
|
|
stationery: Option<Res<Stationery>>,
|
|
mut names: ResMut<NameCache>,
|
|
commands: Res<NetCommands>,
|
|
mut pending: ResMut<MailPending>,
|
|
time: Res<Time>,
|
|
mut last: Local<crate::ui_script::VmMemo<Option<MailState>>>,
|
|
mut last_mailbox: Local<crate::ui_script::VmMemo<Option<u64>>>,
|
|
mut last_has_new_mail: Local<crate::ui_script::VmMemo<bool>>,
|
|
// The `$`-macro subject for the letter body: the local player, as at every panel seam.
|
|
self_q: Query<(&crate::net::ObjectStore, &crate::net::Guid), With<crate::net::SelfPlayer>>,
|
|
states: Res<crate::world_state::WorldStates>,
|
|
// The random-suffix roll's catalogs (1547): an enclosed item's rolled name.
|
|
extras: MailFeedExtras,
|
|
) {
|
|
let Some(mut script) = script else {
|
|
return;
|
|
};
|
|
let MailFeedExtras {
|
|
props,
|
|
enchants,
|
|
mut sink,
|
|
stationeries: mut last_stationeries,
|
|
} = extras;
|
|
let rolls = crate::items::RollCatalogs {
|
|
props: props.as_deref(),
|
|
enchants: enchants.as_deref(),
|
|
};
|
|
let last = last.get(&script);
|
|
let last_mailbox = last_mailbox.get(&script);
|
|
let last_has_new_mail = last_has_new_mail.get(&script);
|
|
|
|
// Take/return/delete failures go to the surface — and the voice — their message record names.
|
|
let refusals: Vec<_> = std::mem::take(&mut mail.errors)
|
|
.into_iter()
|
|
.filter_map(|key| crate::ui_action::keyed_line(&script, key))
|
|
.collect();
|
|
crate::ui_action::show_messages(&mut script, &mut sink, "ui_mail", refusals);
|
|
// SEND acks: MAIL_FAILED on EVERY send result (unblocks the button — wow-re §5), plus
|
|
// MAIL_SEND_SUCCESS on OK (resets the form) or the refusal's own line on a failure. The
|
|
// success also SAYS so — `ERR_MAIL_SENT`, the yellow info line `0x4ad0b1` shows before it
|
|
// resets the form (decision 1821).
|
|
// A take emptied and purged a row: `CLOSE_INBOX_ITEM(index)` — the stock handler hides the
|
|
// open letter when it is that row — ahead of the `MAIL_INBOX_UPDATE` the changed list fires
|
|
// below, the client's own order (wow-re `stationery-bindings.md` §8, 1970).
|
|
for index in std::mem::take(&mut mail.close_inbox) {
|
|
script.fire_event("CLOSE_INBOX_ITEM", vec![ScriptValue::Int(i64::from(index))]);
|
|
}
|
|
// `SMSG_SEND_MAIL_RESULT 0x4ad050`, in its own order (wow-re `mail-interaction.md` §4): the
|
|
// GlobalString toast, then — on `action 0 / reason 0` only — the compose-tab reset `0x4acdc0(1)`
|
|
// with its three events, and **last, on every path, `MAIL_FAILED`** (`0x4ad15f`, unconditional:
|
|
// a successful send fires it too, as the generic "the send resolved, unblock the form" signal).
|
|
for ack in std::mem::take(&mut mail.send_acks) {
|
|
let key = if ack.ok {
|
|
Some(MAIL_SENT_KEY)
|
|
} else {
|
|
ack.refusal
|
|
};
|
|
let line = key.and_then(|k| crate::ui_action::keyed_line(&script, k));
|
|
crate::ui_action::show_messages(&mut script, &mut sink, "ui_mail", line);
|
|
if ack.ok {
|
|
script.reset_compose_tab();
|
|
}
|
|
script.fire_event("MAIL_FAILED", vec![]);
|
|
}
|
|
|
|
// The macro subject, resolved before the row walk borrows the name cache again. `None` until
|
|
// the player is streamed and named — the feed diffs on the substituted text, so a letter opened
|
|
// that early re-substitutes as soon as it lands.
|
|
let subject = crate::npc_text::player_identity(&self_q, &mut names, &commands);
|
|
let macros = crate::npc_text::MacroContext {
|
|
subject: subject.as_ref(),
|
|
states: &states,
|
|
};
|
|
let fresh = {
|
|
// Scoped: the resolver borrows the VM, and `set_mail` below needs it back.
|
|
let get_text = |key: &str| script.lua().globals().get::<String>(key).ok();
|
|
snapshot(
|
|
&mail,
|
|
&mut items,
|
|
icons.as_deref(),
|
|
stationery.as_deref(),
|
|
&mut names,
|
|
&commands,
|
|
rolls,
|
|
¯os,
|
|
&get_text,
|
|
)
|
|
};
|
|
let opened = last_mailbox.is_none() && mail.mailbox.is_some();
|
|
let closed = last_mailbox.is_some() && mail.mailbox.is_none();
|
|
let show_requested = std::mem::take(&mut mail.show_requested);
|
|
let changed = fresh != *last;
|
|
if changed {
|
|
script.set_mail(fresh.clone());
|
|
}
|
|
// The send tab's usable stationery (1970): `Stationery.dbc` rows the player may use — the
|
|
// always-available one, or one whose item they carry — whose item template is cached, priced
|
|
// by BuyPrice ascending. The templates are asked ahead of any mailbox (the catalog is five
|
|
// rows) so the list is whole the frame the window opens: the stock `SendMailFrame_Reset`
|
|
// selects row 1 on show, and an empty list then would leave every send silently unsent.
|
|
//
|
|
// **Only once there is a player.** `feed_mail` carries no run condition, so it runs from the
|
|
// first frame of the process — the login screen, before the socket exists. The five asks went
|
|
// out there, the io thread dropped them ("not connected"), and `Items::template` had already
|
|
// latched all five in its ask-once `pending` set, which is cleared on DISCONNECT and never on
|
|
// connect. The templates were therefore never re-asked, the list stayed empty for the whole
|
|
// session, and the failure this very comment describes — every send silently unsent — is what
|
|
// it caused. Gating on the self player is also what the list means: "what may I use", which is
|
|
// not a question until there is a me.
|
|
let usable = stationery
|
|
.as_deref()
|
|
.filter(|_| !self_q.is_empty())
|
|
.map(|catalog| stationeries(catalog, &self_q, &mut items, icons.as_deref(), &commands))
|
|
.unwrap_or_default();
|
|
let memo = last_stationeries.get(&script);
|
|
if *memo != usable {
|
|
*memo = usable.clone();
|
|
script.set_mail_stationeries(usable);
|
|
}
|
|
if opened {
|
|
// The selection is cleared on open AND close (`0x4ace07`, 1970); the stock tab's reset
|
|
// on MAIL_SHOW re-selects row 1.
|
|
script.clear_stationery();
|
|
// The open core's own order (wow-re `mail-interaction.md` §1): store the mailbox, register
|
|
// the interaction target, **reset the compose tab** — a fresh window carries no stale
|
|
// attachment or money — and only THEN `MAIL_SHOW`. The reset's `MAIL_SEND_SUCCESS` is the
|
|
// byte-verified open side-effect, so the page-turn sound on open is faithful, not a bug.
|
|
script.reset_compose_tab();
|
|
script.fire_event("MAIL_SHOW", vec![]);
|
|
} else if closed {
|
|
script.clear_stationery();
|
|
script.fire_event("MAIL_CLOSED", vec![]);
|
|
// The close core's tail (wow-re `0x4acdad`/`0x4acdb1`, decision 0913): a session where a
|
|
// mail was read — or one arrived while the window was open — re-asks the server, and the
|
|
// sender stamps the countdown to "no mail" on the way out. This is the whole mechanism by
|
|
// which checking your mail clears the minimap icon; nothing on the inbox path touches it.
|
|
// Both close paths converge here (explicit `CloseMail` and the walk-away range close), as
|
|
// they do in the reference through the same generic dispatcher.
|
|
if pending.take_refresh() {
|
|
pending.on_query_sent();
|
|
let _ = commands.0.send(ClientCommand::QueryNextMailTime);
|
|
}
|
|
} else if mail.mailbox.is_some() {
|
|
// A re-click re-shows the (already open) window; MAIL_SHOW's CheckInbox refreshes the list.
|
|
if show_requested {
|
|
script.fire_event("MAIL_SHOW", vec![]);
|
|
}
|
|
// A content change (async name/body landed, list replaced, mark-read flip) → repaint.
|
|
if changed {
|
|
script.fire_event("MAIL_INBOX_UPDATE", vec![]);
|
|
}
|
|
}
|
|
*last = fresh;
|
|
*last_mailbox = mail.mailbox;
|
|
|
|
// The arrival layer (decisions 0544 P3 / 0904 / 0913): step the client-local countdown, keep
|
|
// the VM's `HasNewMail()` answer current, and fire `UPDATE_PENDING_MAIL` only where the
|
|
// reference fires it. The two are deliberately separate: the *push* keeps the Lua getter
|
|
// truthful whenever it is asked, while the *event* is edge-triggered at three sites only (the
|
|
// query reply, a near-zero arrival, the step crossing ε) — so, exactly as in the reference, the
|
|
// query sender's "no mail" stamp does not move the icon until the reply lands.
|
|
// `MiniMapMailFrame` is the sole listener in all of 1.12 FrameXML and its handler is an
|
|
// idempotent Show/Hide (`Minimap.xml` l.278-289, quoted in the module doc).
|
|
pending.step(time.delta_secs());
|
|
let has_new_mail = pending.has_new_mail();
|
|
if has_new_mail != *last_has_new_mail {
|
|
script.set_has_new_mail(has_new_mail);
|
|
*last_has_new_mail = has_new_mail;
|
|
}
|
|
if pending.take_notify() {
|
|
script.fire_event("UPDATE_PENDING_MAIL", vec![]);
|
|
}
|
|
}
|
|
|
|
/// Drain the Lua intents into the mail `CMSG`s (decision 0544): `CheckInbox` → a throttled
|
|
/// `CMSG_GET_MAIL_LIST`; an opened mail → `CMSG_MAIL_MARK_AS_READ` (once, unread) + the ask-once
|
|
/// `CMSG_ITEM_TEXT_QUERY` for its body; take/return/delete → their per-mail `CMSG`s; `SendMail` →
|
|
/// `CMSG_SEND_MAIL` (the attachment's `(bag, slot)` resolved to its item guid here); `CloseMail` →
|
|
/// a local clear (no packet, vanilla).
|
|
fn drain_mail(
|
|
script: Option<NonSendMut<UiScript>>,
|
|
mut mail: ResMut<MailOpen>,
|
|
commands: Res<NetCommands>,
|
|
self_q: Query<&ObjectStore, With<SelfPlayer>>,
|
|
items: Res<Items>,
|
|
time: Res<Time>,
|
|
mut pending: ResMut<MailPending>,
|
|
) {
|
|
let Some(mut script) = script else {
|
|
return;
|
|
};
|
|
let Some(mailbox) = mail.mailbox else {
|
|
// No session: still honor a stray close (idempotent) and drop everything else.
|
|
if script.take_mail_close() {
|
|
mail.clear();
|
|
}
|
|
let _ = script.take_mail_check_inbox();
|
|
let _ = script.take_mail_opens();
|
|
let _ = script.take_mail_send();
|
|
return;
|
|
};
|
|
|
|
// CheckInbox → a session-scoped, 60s-throttled inbox query (wow-re §5).
|
|
if script.take_mail_check_inbox() {
|
|
let now = time.elapsed_secs_f64();
|
|
if mail
|
|
.last_list_query
|
|
.is_none_or(|t| now - t >= CHECK_INBOX_THROTTLE_SECS)
|
|
{
|
|
mail.last_list_query = Some(now);
|
|
let _ = commands.0.send(ClientCommand::GetMailList { mailbox });
|
|
}
|
|
}
|
|
|
|
// Opened mails: mark read (once) then ask-once the body (wow-re §5: read FIRST, body second).
|
|
for index in script.take_mail_opens() {
|
|
let Some((mail_id, text_id, read)) = index
|
|
.checked_sub(1)
|
|
.and_then(|i| mail.mails.get(i as usize))
|
|
.map(|e| (e.message_id, e.item_text_id, e.checked & CHECKED_READ != 0))
|
|
else {
|
|
continue;
|
|
};
|
|
// Arm the deferred refresh on **every** open, read or not: the reference's `GetInboxText`
|
|
// calls the mark-as-read sender unconditionally, and the `[0xb6efcc] = 1` inside it is
|
|
// unconditional too (`0x4adda6`) — so opening any letter is what makes the close re-ask the
|
|
// server, and that is what clears the minimap icon (decision 0913). Arming it on an
|
|
// already-read mail costs at most one redundant query on close; failing to arm it would
|
|
// strand the icon lit, so the eager side is the safe side.
|
|
pending.arm_refresh();
|
|
if !read {
|
|
mail.mark_read(index);
|
|
let _ = commands
|
|
.0
|
|
.send(ClientCommand::MailMarkAsRead { mailbox, mail_id });
|
|
}
|
|
if text_id != 0
|
|
&& !mail.bodies.contains_key(&text_id)
|
|
&& mail.pending_bodies.insert(text_id)
|
|
{
|
|
let _ = commands
|
|
.0
|
|
.send(ClientCommand::ItemTextQuery { text_id, mail_id });
|
|
}
|
|
}
|
|
|
|
// The per-mail row picks addressing the wire mail id.
|
|
for index in script.take_mail_take_items() {
|
|
if let Some(mail_id) = mail.mail_id_at(index) {
|
|
let _ = commands
|
|
.0
|
|
.send(ClientCommand::MailTakeItem { mailbox, mail_id });
|
|
}
|
|
}
|
|
for index in script.take_mail_take_money() {
|
|
if let Some(mail_id) = mail.mail_id_at(index) {
|
|
let _ = commands
|
|
.0
|
|
.send(ClientCommand::MailTakeMoney { mailbox, mail_id });
|
|
}
|
|
}
|
|
for index in script.take_mail_deletes() {
|
|
if let Some(mail_id) = mail.mail_id_at(index) {
|
|
let _ = commands
|
|
.0
|
|
.send(ClientCommand::MailDelete { mailbox, mail_id });
|
|
}
|
|
}
|
|
for index in script.take_mail_returns() {
|
|
if let Some(mail_id) = mail.mail_id_at(index) {
|
|
let _ = commands
|
|
.0
|
|
.send(ClientCommand::MailReturnToSender { mailbox, mail_id });
|
|
}
|
|
}
|
|
for index in script.take_mail_take_texts() {
|
|
if let Some(mail_id) = mail.mail_id_at(index) {
|
|
let _ = commands
|
|
.0
|
|
.send(ClientCommand::MailCreateTextItem { mailbox, mail_id });
|
|
}
|
|
}
|
|
|
|
// SendMail: resolve the attachment's (bag, slot) to the wire item guid at send time (the
|
|
// reference re-reads the slot when the send fires — a lazy resolve, decision 0216/0544).
|
|
if let Some(req) = script.take_mail_send() {
|
|
let item_guid = req.item.and_then(|(bag, slot)| {
|
|
self_q.iter().next().and_then(|s| {
|
|
crate::ui_items::slot_guid(&s.0, bag, (slot.max(1) - 1) as u8, &items)
|
|
})
|
|
});
|
|
if req.item.is_some() && item_guid.is_none() {
|
|
// The attached item is gone from its slot: `ERR_ITEM_NOT_FOUND`, no packet, the
|
|
// attachment dropped and `MAIL_SEND_INFO_UPDATE` fired (`0x4ae98d`, 1970).
|
|
mail.errors.push("ERR_ITEM_NOT_FOUND");
|
|
script.drop_send_mail_item();
|
|
} else {
|
|
let _ = commands.0.send(ClientCommand::SendMail {
|
|
mailbox,
|
|
receiver: req.target,
|
|
subject: req.subject,
|
|
body: req.body,
|
|
stationery: req.stationery,
|
|
item_guid: item_guid.unwrap_or(0),
|
|
money: req.money,
|
|
cod: req.cod,
|
|
});
|
|
}
|
|
}
|
|
|
|
if script.take_mail_close() {
|
|
mail.clear();
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use benilla_protocol::messages::MailAttachment;
|
|
|
|
/// **The mail-result table, welded to the message ids it was read from** (decision 1821) —
|
|
/// the reference's jump table at `0x4ad17c`, reached through the byte index at `0x4ad1a0`.
|
|
/// The id, not the key, is what was disassembled, so the id is what this asserts.
|
|
#[test]
|
|
fn the_mail_result_table_is_the_references_own() {
|
|
let id = |key: &str| {
|
|
benilla_ui::messages::by_key(key)
|
|
.unwrap_or_else(|| panic!("{key} is not a catalog row"))
|
|
.id
|
|
};
|
|
for (code, want) in [
|
|
(mail_error::CANNOT_SEND_TO_SELF, 0x164u16),
|
|
(mail_error::NOT_ENOUGH_MONEY, 0x25),
|
|
(mail_error::RECIPIENT_NOT_FOUND, 0x165),
|
|
(mail_error::NOT_YOUR_TEAM, 0xff),
|
|
(mail_error::INTERNAL_ERROR, 0x166),
|
|
(mail_error::TRIAL_ACCOUNT, 0x1be),
|
|
(mail_error::TOO_MANY_ATTACHMENTS, 0x1c4),
|
|
] {
|
|
assert_eq!(id(mail_refusal(code)), want, "mail error {code}");
|
|
}
|
|
// `ja 0x4ad147` and the index table's `08` run: 7..13 and everything past 15 share the
|
|
// database-error line, and there is no "(n)" arm for them to take.
|
|
for code in [7, 8, 13, 16, 99, u32::MAX] {
|
|
assert_eq!(id(mail_refusal(code)), 0x166, "mail error {code}");
|
|
}
|
|
// A success SAYS so — and on the YELLOW line, which is the half a red-only path would
|
|
// have got wrong.
|
|
assert_eq!(id(MAIL_SENT_KEY), 0x167);
|
|
assert_eq!(
|
|
benilla_ui::messages::by_key(MAIL_SENT_KEY)
|
|
.expect("row")
|
|
.kind,
|
|
benilla_ui::messages::MsgKind::Info
|
|
);
|
|
}
|
|
|
|
/// The no-subject macro context these row tests run under: none of them exercises a `$` token,
|
|
/// and a subject-less context leaves plain text untouched (the expander only rewrites at a `$`).
|
|
/// The macro path's own behaviour is covered in [`crate::npc_text`]'s tests.
|
|
fn no_macros(states: &crate::world_state::WorldStates) -> crate::npc_text::MacroContext<'_> {
|
|
crate::npc_text::MacroContext {
|
|
subject: None,
|
|
states,
|
|
}
|
|
}
|
|
|
|
fn entry(
|
|
message_id: u32,
|
|
sender_guid: Option<u64>,
|
|
checked: u32,
|
|
expire_days: f32,
|
|
) -> MailListEntry {
|
|
MailListEntry {
|
|
message_id,
|
|
message_type: mail_message_type::NORMAL,
|
|
sender_guid,
|
|
sender_id: None,
|
|
subject: "s".into(),
|
|
item_text_id: 0,
|
|
stationery: 41,
|
|
item: None,
|
|
money: 0,
|
|
cod: 0,
|
|
checked,
|
|
expire_days,
|
|
mail_template_id: 0,
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn click_opens_and_reclick_keeps_rows() {
|
|
let mut m = MailOpen::default();
|
|
m.click(0x100);
|
|
assert_eq!(m.mailbox, Some(0x100));
|
|
assert!(m.show_requested);
|
|
m.mails.push(entry(1, Some(0xA), 0, 30.0));
|
|
m.last_list_query = Some(5.0);
|
|
m.show_requested = false;
|
|
// Re-click the SAME mailbox keeps the rows + throttle, just re-requests the show.
|
|
m.click(0x100);
|
|
assert_eq!(m.mails.len(), 1);
|
|
assert_eq!(m.last_list_query, Some(5.0));
|
|
assert!(m.show_requested);
|
|
// A DIFFERENT mailbox resets the session.
|
|
m.click(0x200);
|
|
assert!(m.mails.is_empty());
|
|
assert_eq!(m.last_list_query, None);
|
|
}
|
|
|
|
#[test]
|
|
fn mail_id_and_mark_read() {
|
|
let mut m = MailOpen::default();
|
|
m.click(0x1);
|
|
m.mails.push(entry(42, Some(0xA), 0, 30.0));
|
|
m.mails.push(entry(43, None, CHECKED_READ, 10.0));
|
|
assert_eq!(m.mail_id_at(1), Some(42));
|
|
assert_eq!(m.mail_id_at(2), Some(43));
|
|
assert_eq!(m.mail_id_at(3), None);
|
|
assert_eq!(m.mail_id_at(0), None);
|
|
// Mark row 1 read: its READ bit flips locally.
|
|
assert_eq!(m.mails[0].checked & CHECKED_READ, 0);
|
|
m.mark_read(1);
|
|
assert_eq!(m.mails[0].checked & CHECKED_READ, CHECKED_READ);
|
|
}
|
|
|
|
#[test]
|
|
fn clear_closes_the_window() {
|
|
let mut m = MailOpen::default();
|
|
m.click(0x1);
|
|
m.mails.push(entry(1, Some(0xA), 0, 30.0));
|
|
m.bodies.insert(7, "hi".into());
|
|
m.clear();
|
|
assert!(m.mailbox.is_none());
|
|
assert!(m.mails.is_empty());
|
|
assert!(m.bodies.is_empty());
|
|
}
|
|
|
|
/// No GlobalStrings in a unit test — the auction-subject rewrite is exercised where the
|
|
/// strings are (`tests/mail_frame.rs`), and here every key simply misses.
|
|
fn no_strings(_key: &str) -> Option<String> {
|
|
None
|
|
}
|
|
|
|
#[test]
|
|
fn resolve_row_derives_the_reply_and_delete_law() {
|
|
let states = crate::world_state::WorldStates::default();
|
|
let mut items = Items::default();
|
|
let (tx, _rx) = crossbeam_channel::unbounded();
|
|
let commands = NetCommands(tx);
|
|
let mut names = NameCache::default();
|
|
let bodies = HashMap::new();
|
|
|
|
// A plain letter from a player (no attachments) → replyable AND deletable.
|
|
let from_player = entry(1, Some(0xA), 0, 30.0);
|
|
let row = resolve_row(
|
|
&from_player,
|
|
&bodies,
|
|
&mut items,
|
|
None,
|
|
None,
|
|
&mut names,
|
|
&commands,
|
|
crate::items::RollCatalogs::NONE,
|
|
&no_macros(&states),
|
|
&no_strings,
|
|
);
|
|
assert!(row.can_reply);
|
|
assert!(row.can_delete);
|
|
assert_eq!(row.stationery_texture, "STATIONERYTEST");
|
|
|
|
// A player mail still carrying money → returnable, NOT deletable (the 0548 INTERIM law:
|
|
// attachments return; the same holds for an enclosed item).
|
|
let mut with_money = entry(4, Some(0xA), 0, 30.0);
|
|
with_money.money = 500;
|
|
let row = resolve_row(
|
|
&with_money,
|
|
&bodies,
|
|
&mut items,
|
|
None,
|
|
None,
|
|
&mut names,
|
|
&commands,
|
|
crate::items::RollCatalogs::NONE,
|
|
&no_macros(&states),
|
|
&no_strings,
|
|
);
|
|
assert!(row.can_reply);
|
|
assert!(!row.can_delete);
|
|
|
|
// A returned mail → not replyable, deletable.
|
|
let returned = entry(2, Some(0xA), CHECKED_RETURNED, 30.0);
|
|
let row = resolve_row(
|
|
&returned,
|
|
&bodies,
|
|
&mut items,
|
|
None,
|
|
None,
|
|
&mut names,
|
|
&commands,
|
|
crate::items::RollCatalogs::NONE,
|
|
&no_macros(&states),
|
|
&no_strings,
|
|
);
|
|
assert!(!row.can_reply);
|
|
assert!(row.can_delete);
|
|
|
|
// A system mail (no sender guid) → deletable.
|
|
let system = entry(3, None, 0, 30.0);
|
|
let row = resolve_row(
|
|
&system,
|
|
&bodies,
|
|
&mut items,
|
|
None,
|
|
None,
|
|
&mut names,
|
|
&commands,
|
|
crate::items::RollCatalogs::NONE,
|
|
&no_macros(&states),
|
|
&no_strings,
|
|
);
|
|
assert!(row.can_delete);
|
|
}
|
|
|
|
#[test]
|
|
fn resolve_row_reads_the_item_and_body_caches() {
|
|
let states = crate::world_state::WorldStates::default();
|
|
let mut items = Items::default();
|
|
let (tx, _rx) = crossbeam_channel::unbounded();
|
|
let commands = NetCommands(tx);
|
|
let mut names = NameCache::default();
|
|
let mut bodies = HashMap::new();
|
|
bodies.insert(99, "the letter body".into());
|
|
|
|
let mut e = entry(1, Some(0xA), 0, 30.0);
|
|
e.item_text_id = 99;
|
|
e.item = Some(MailAttachment {
|
|
entry: 2589,
|
|
perm_enchant: 0,
|
|
random_prop_id: 0,
|
|
suffix_factor: 0,
|
|
count: 5,
|
|
charges: 0,
|
|
durability_max: 0,
|
|
durability: 0,
|
|
});
|
|
let row = resolve_row(
|
|
&e,
|
|
&bodies,
|
|
&mut items,
|
|
None,
|
|
None,
|
|
&mut names,
|
|
&commands,
|
|
crate::items::RollCatalogs::NONE,
|
|
&no_macros(&states),
|
|
&no_strings,
|
|
);
|
|
assert_eq!(row.item_id, 2589);
|
|
assert_eq!(row.item_count, 5);
|
|
assert_eq!(row.body.as_deref(), Some("the letter body"));
|
|
assert!(row.has_body);
|
|
// The item name/texture are in flight (no template answer yet).
|
|
assert!(row.item_name.is_none());
|
|
}
|
|
}
|