# Changelog

## [Unreleased]

### Fixed

- **Invites and whispers failed for anyone on a connected realm.** Reported by Moowmoowmilk on
  ArcaniteReaper, where invites to Whitemane players never arrived — thank you.

  Chat events deliver `Name-Realm` for players from another realm in a merged cluster. Portal
  Pro stripped the realm to keep cards readable, then reused that stripped name for the invite
  — and `InviteUnit("Moowmoowmilk")` finds nobody when the player is `Moowmoowmilk-Whitemane`.
  On a merged realm that is most customers, and it failed silently.

  Display and action are now separate everywhere: cards still show the short name, while
  invites, uninvites and whispers get the realm-qualified one. `Whispers:Send` resolves the
  realm itself from names seen in chat, the party roster or a trade window, so every whisper
  path is covered — including the post-trade thank-you, which is composed long after the
  trade window is gone.

  Also fixed by the same change: **Clear the group after each portal** couldn't remove a
  cross-realm member, and the auto-reply to a matched request never reached one.

### Added

- **A greeting per ad.** Sometimes you advertise for tips, sometimes a fixed price — and the
  opening line you send should match the ad that brought them in.
  - Write alternatives under **Settings → Greeting library**, using the same `{name}` `{dest}`
    `{price}` variables as everything else.
  - Assign one to an ad with the **Greeting for this ad** dropdown on the Ads tab. The preview
    underneath shows exactly what will go out.
  - Replies are matched to the ad you last broadcast: in that channel for a channel request,
    or anywhere for a whisper, since a whisper carries no channel of its own. Nothing matched
    within 30 minutes falls back to the shared greeting, so this can never send nothing.
  - Greetings are referenced by a stable id. Renaming one doesn't repoint an ad, deleting one
    doesn't shift every ad below it onto the wrong text, and removing a greeting clears the
    ads that used it rather than leaving them pointing at nothing.
  - The other six templates are unchanged — they're roles, not alternatives. There's only ever
    one "thanks".

- **Update notice via peer gossip.** Addons have no internet access, so a newer build can
  only be learned from other players running Portal Pro. A new `Comms` module broadcasts our
  version over addon messages — invisible to everyone, including players without the addon —
  and listens for others'.
  - Reaches guild, group and SAY range. Classic Era has no `CHANNEL` distribution for addon
    messages, so Trade chat is unreachable; SAY is what finds unrelated mages in a city.
  - **One quiet chat line, once per session**, only when someone reports strictly newer.
  - Status label under **Settings → About**: current version plus up to date / update
    available / development build.
  - Version comparison is numeric per segment, so `1.10.0` correctly beats `1.9.0` where a
    string compare gets it backwards. Non-numeric versions (`dev`, `1.2.0-beta`) are treated
    as uncomparable rather than coerced to zero — a build that can't say what it is doesn't
    get to claim it's newer.
  - Development builds listen but never broadcast.
  - **Builds that can't report a version still get told.** An uncomparable local version
    (`dev`, or a release whose `@project-version@` token was never substituted) would
    otherwise fail the "is theirs newer" test silently and strand the user forever — and
    that's precisely the person most likely to be running a broken download. They now get a
    notice worded to be true for developers and for broken installs alike, and the Settings
    label reads "Unversioned build - other players are running X" rather than falsely
    claiming update checks are disabled.
  - Replies are whispered back so both sides learn, never in response to a whisper, so two
    clients can't ping-pong. Per-player and per-channel cooldowns throughout.
  - Sandbox gained **Fake newer version** and **Test version compare** (9 cases).
- TOC version is now a literal `1.2.0` rather than the packager's `@project-version@` token,
  since releases are currently uploaded by hand. Previously the addon reported `dev` in-game
  regardless of what CurseForge displayed.
- **A paid lead is removed from the queue.** Handled by the Leads module subscribing to
  `PAYMENT_RECEIVED` rather than by each payment watcher mutating the lead directly, so
  trades and mail now behave identically — mail payments previously left the card sitting
  there. Their per-player cooldown is cleared on the way out, so a repeat request inside the
  five-minute window still produces a fresh card instead of being suppressed.
- **Live market intelligence.** The lead scanner already read every rival advert in order to
  reject it as a customer; those adverts contain prices. Keeping them turns a filter into a
  price feed. New **Market** tab shows what the competition is asking, what customers are
  offering, and how your pricing compares per destination. A live line on the Ads tab shows
  the Trade median next to your price while you're editing the ad. `/pp market`.
  - **Asks and bids are tracked separately** — a mage advertising is competition, a customer
    naming a price is demand. They answer different questions.
  - The median counts **one observation per advertiser**, not per message. Without that,
    whoever spams hardest defines the "market rate".
  - Price parsing handles `10g`, `1g50s`, `75 silver`, `12 gold`, and multi-price lines
    (taking the low end, which is what customers compare against). Bounded to 10s–200g to
    reject boasts and unrelated item prices. Realm-tagged.
  - 18 parser cases verified, including rejecting `WTS Arcanite Bar 200g`, `portal room`,
    and `1st port free`.
- Sandbox gained **Inject market data**, which pushes sample adverts through the real
  classifier rather than writing observations directly.
- **Money is shown in full gold/silver/copper** everywhere a balance appears — header,
  compact bar, ledger, stats. Previously collapsed to a single rounded unit (`12.3g`), which
  is fine for a chart axis and misleading for a balance. Chips widened to fit.
- **Excluding non-business portals.** Portals cast for a dungeon group were being billed
  against your margin as a reagent cost. Three layers of control:
  - Casts inside a dungeon or raid are no longer logged (on by default, `IsInInstance`).
  - Per-destination exclusion under Settings → Tracking.
  - Free-cast mode (`/pp free`) suspends logging entirely. Session-only by design — a
    forgotten flag surviving a relog would quietly stop recording income. Shown as
    "NOT LOGGING" in the header whenever tracking is suspended for any reason.
  - `/pp oops` retroactively untracks the portal you just cast and removes its rune cost.
  - Per-row delete in the Ledger, arm-then-confirm so a payment can't vanish on a stray
    click.
- Untracked casts are counted separately (`personalCasts`) rather than discarded, and are
  excluded from the Portals figure so it matches what the ledger charged you for.
- Auto-uninvite now skips untracked casts — it would otherwise have kicked your dungeon
  party 20 seconds after opening their portal.

### Fixed

- **Broadcast could send an ad you hadn't selected.** Rotation was forced on with no way to
  disable it, so every send silently advanced the selection — pick an ad, broadcast it, and
  the next click posts a different one. Rotation is now opt-in via a new "Always send
  selected" mode, which existing profiles are migrated to (schema v2).
- **Ad edits could land on the wrong ad.** The name, message and channel handlers resolved
  `Broadcaster:Selected()` at commit time rather than writing to the ad the editor was
  showing. The name field only committed on focus loss, so changing selection between
  typing and committing wrote the text onto whichever ad was selected by then. The editor
  now binds to the ad object, and the name commits per keystroke like the message box.
- **"Delete ad" destroyed an ad on a single click**, with no confirmation, positioned
  directly beside the name field. Now arm-then-confirm, matching the ledger.
- `Broadcaster:List()` returned a throwaway `{}` when the profile was unavailable, so
  `NewAd()` could append to a table unreachable from the saved variables and the ad would
  vanish on reload. It now creates the list instead.
- **Market tab buttons did nothing.** `MARKET_UPDATED` was fired by every market mutation
  but no UI code subscribed to it, so removing an advert, ignoring an advertiser and
  clearing the data all worked on the data and never redrew the screen. `PRICES_CHANGED`
  and `FREEMODE_CHANGED` had the same gap. Audited every fired event against its
  subscribers; the remaining unsubscribed ones are informational or covered by a ticker.
- Removing an advert now matches on the observation itself rather than its id, so records
  saved before ids existed can still be removed. Missing ids are backfilled once on load.
- **Goods listings were being counted as portal prices.** An enchanter's multi-item advert
  that mentioned a portal in passing was booked as a 1g "competitor ask", dragging the
  median down and making the addon report that you were undercutting everyone. Four
  defences added, cheapest first:
  - Messages containing an item or enchant link (`|H`) are rejected outright — that's
    someone selling goods.
  - Three or more distinct prices with no city named is a shop listing. A mage who genuinely
    quotes per-city prices names the cities, so `WTS ports: SW 10g, IF 12g, Darn 15g`
    still counts.
  - A price must sit within 40 characters of the word "port" to be read as its price.
  - The price used is now the one **nearest** the portal mention rather than the cheapest,
    so `ports 5g, teles 3g` reports 5g — the portal price — instead of the teleport's.
  - 14 parser cases verified.
- **You can now remove adverts from the figures by hand**, because no classifier catches
  everything. Each row on the Market tab has **X** (drop this line) and **!** (ignore this
  advertiser entirely, and forget what they've already posted). Ignored advertisers are
  counted on the tab with a reset.
- **Full advert text is visible.** Rows are too narrow for a chat line, so hovering shows
  the complete message, side, price and channel. Stored text was also raised from 120 to
  255 characters so nothing is lost from the record in the first place.
- **Trade payments were being missed entirely.** The money claim only armed when
  `TRADE_ACCEPT_UPDATE` reported *both* parties accepted — but when the second party
  accepts, the server settles immediately and the client usually goes straight to
  `TRADE_CLOSED` without ever firing that update. No claim, no capture, sale lost. This was
  listed as a "known unknown" and is now confirmed wrong against a real trade.
  The claim now arms as soon as **money appears in the trade window**, which can't happen on
  an empty trade and is always well before the delta arrives. `ERR_TRADE_COMPLETE` is used
  as positive confirmation to extend the window when it fires, and `TRADE_CLOSED` leaves a
  grace period rather than deciding an outcome it can't see.
- **`port to UC from Org` resolved to Orgrimmar.** Longest-alias-wins picked `org` (3 chars)
  over `uc` (2), quoting the wrong city and booking the wrong destination. Resolution is now
  position-aware: a city after "to" wins outright, a city after "from" is treated as the
  origin and excluded. 13 cases verified.
- `/pp paid <name> <amount> [city]` records a payment by hand, for anything the automatic
  capture still misses. It fires the same event as a real trade, so the lead clears and the
  stats update identically.

- **Casting a Teleport announced "portal is up - hop in!" to the party.** Teleport moves
  only the caster, so there was nothing for anyone to hop into. Both PORTAL_CAST consumers
  in Group.lua accepted the cast `kind` and neither checked it — the auto-uninvite handler
  had the same defect and would have cleared the group after a self-teleport.
- Teleports are **no longer counted as a business cost by default**, for the same reason: a
  spell that relocates only you can never be a sale, so charging its Rune of Teleportation
  against your margin makes the ledger lie. Settings → Tracking has a switch for anyone who
  wants to account for travel to their selling spot.
- **Lead toast appeared and vanished instantly.** `AnimationGroup:Stop()` fires `OnFinished`
  with `requested = true`, so interrupting a fade-out ran that fade's completion callback —
  hiding the frame — and the replacement fade-in then played on an already-hidden frame.
  `OnFinished` now ignores interrupted animations, and the stale handler is detached before
  stopping.
- `UI:SlideIn` created a fresh AnimationGroup on every call (leaking one per toast) and its
  Alpha animation competed with `UI:Fade` on the same frame. It's now translation-only and
  reuses a single group.
- Lead card message text ran underneath the Whisper / Quote / Invite buttons. It's now
  anchored to the leftmost button, so it reclaims the space when the window is widened.
- Stat tile sub-lines had no right anchor and drew straight past the tile border. All three
  lines are now bounded, and sub-lines wrap to a second line instead of clipping.
- Destination breakdown values could grow leftwards over their own bar.

### Changed

- The lead toast is suppressed during combat and replayed on leaving it. If combat starts
  while a toast is up, it's pulled and the lead is held rather than lost. Newest lead shows
  first with a "+N more waiting" count; leads that expired during the fight are dropped.
  The alert sound is unaffected and still fires immediately.
- Conversion tile is labelled "(session)" rather than prefixing its sub-line, which is what
  was overflowing.

Initial build. Not yet run in a live game session — see "Known unknowns" below.

### Added

- **Leads** — chat scanner across Say, Yell, General, Trade, LFG, Guild, Party and
  Whisper. Intent is scored against a weighted rule set instead of a substring match, so
  competitors advertising ("wts ports") and incidental mentions ("the portal room") don't
  generate leads. Destination parsing from abbreviations. Per-player cooldown, blacklist,
  VIP list, returning-customer detection, expiry.
- **Ads** — multiple templates with sequential or random rotation, `{price}`-style
  variables, raid-icon picker inserting `{rt1}`–`{rt8}`, colour codes, live preview
  rendered as chat will show it, 255-character validation.
- **Broadcasting** — Blizzard requires a hardware event for Say/Yell/Channel, so sends
  come from the Broadcast button, a keybind, or opt-in arm mode which releases the staged
  ad on your next keypress. Per-channel throttle with a cooldown indicator.
- **Whispers** — seven templates (greeting, quote, location, invited, portal-up, thanks,
  busy) with variable expansion and quick-reply buttons on each lead card.
- **Ledger** — append-only. Payments captured from trades and mail via a single
  `PLAYER_MONEY` arbiter so no transaction is double-booked. Refunds are linked negative
  entries that never edit the original sale. Rune consumption recorded as cost basis.
  CSV export.
- **Stats** — net/gross/refunds/reagents, gold per hour on an idle-pausing session clock,
  leads→invited→paid funnel, 7-day chart, destination breakdown, top customers.
- **Interface** — custom widget kit on design tokens, no Blizzard chrome. Resizable and
  scalable main window, compact always-on bar, lead toasts, minimap button.
- **Ops** — rune counter with low-stock warning, party announce on cast, optional group
  cleanup, Hardcore rescue-zone highlighting on zone-local channels.
- **Sandbox** (`/pp sandbox`) — injects synthetic leads, payments, refunds and casts, and
  scores arbitrary chat lines, so the parser and ledger can be exercised without customers.
- Font picker supporting bundled Inter / Noto Sans (both OFL) with probe-based fallback.
- CurseForge / Wago / WoWInterface packaging via the BigWigs packager.

### Known unknowns

- Not yet loaded in a live client. Frame layout, anchor collisions, and whether
  `SetClipsChildren` exists on this build are unverified.
- Trade-completion attribution is built against documented event behaviour; the ordering
  of `TRADE_ACCEPT_UPDATE`, `TRADE_CLOSED` and `PLAYER_MONEY` should be confirmed against
  a real trade.
- Font files are not bundled in the working copy and must be added before release — see
  `Media/fonts/README.md`.
