DopingControl 0.7.0
@@ -0,0 +1,406 @@
|
|||||||
|
# Changelog
|
||||||
|
|
||||||
|
All notable changes to DopingControl. Versions are the ones actually
|
||||||
|
released here — a new feature raises the middle number and resets the last
|
||||||
|
one, the last number is for fixes without new features.
|
||||||
|
|
||||||
|
## v0.7.0
|
||||||
|
|
||||||
|
### New Addons tab: who in the raid runs BigWigs, Threat Meter, EPGP, SuperWoW, Rinse
|
||||||
|
|
||||||
|
A seventh tab checks the raid's addons without the other raiders needing
|
||||||
|
DopingControl. Each column is detected only through what that addon sends or
|
||||||
|
answers: BigWigs' version query, the TW Threatmeter "who" query, shootyepgp's
|
||||||
|
version announcement and its SuperWoW reply, and UpsiRinse's version query.
|
||||||
|
|
||||||
|
* **Version in every cell.** Green with a check mark = the newest version in
|
||||||
|
the raid (your own row counts); green with an exclamation mark = present but
|
||||||
|
older (the tooltip names the newest); red = no reply after the query
|
||||||
|
window. Versions compare numerically per segment, and only within the same
|
||||||
|
BigWigs fork.
|
||||||
|
* **Honest silence.** Red only after a query window passed; before any query a
|
||||||
|
cell is grey "?". Not-in-guild (EPGP/SuperWoW) and non-dispeller classes
|
||||||
|
(Rinse) are "n/a", not red. The red tooltip says what it really means: not
|
||||||
|
installed, an old version, or a fork that does not answer.
|
||||||
|
* **Check addons** button; opening the tab runs one automatic check, at most
|
||||||
|
every 30 seconds, never in combat and never on joining a raid. A passive
|
||||||
|
listener records versions that addons announce on their own, and forgets
|
||||||
|
raiders who left.
|
||||||
|
* Your own row is read from your own addon list instead of waiting for replies.
|
||||||
|
* `/dc test` shows a deterministic mix of states on this tab.
|
||||||
|
* This adds a new file (`scan/addons.lua`), so the game must be restarted
|
||||||
|
once, not just reloaded.
|
||||||
|
|
||||||
|
### Equipment tab names the enchant, not just "enchanted"
|
||||||
|
|
||||||
|
Hovering an Equipment cell used to say only "Enchanted" or "Not
|
||||||
|
enchanted". It now shows which permanent enchant the slot carries
|
||||||
|
("Enchant: Crusader", "Enchant: Agility +15", ...). The names come from a
|
||||||
|
table generated from the game client's own enchant data, so they match
|
||||||
|
what the client shows, including server-custom enchants. An enchant that
|
||||||
|
is not in the table shows as "enchant #<id>": it is still known to be
|
||||||
|
enchanted, only the name is unknown, and it is never reported as missing.
|
||||||
|
|
||||||
|
### The addon does the pre-raid test-mode check itself
|
||||||
|
|
||||||
|
`/dc test` is persisted, so it survives a reload and a relog. A session
|
||||||
|
that ended with the simulated raid on screen started the next one showing
|
||||||
|
it again — and from then on the matrix graded a roster that does not
|
||||||
|
exist. On screen the only marker was the source banner, so the failure
|
||||||
|
mode was never an error, just a plausible wrong answer. That is the worst
|
||||||
|
kind for a buff checker, and "remember to check before the raid" is not a
|
||||||
|
routine anyone keeps.
|
||||||
|
|
||||||
|
Now the addon says it, at the two moments it matters:
|
||||||
|
|
||||||
|
* **Once per login or reload** — printed on world entry, not earlier;
|
||||||
|
chat output before that point is silently dropped.
|
||||||
|
* **When a group forms** — on the 0 → grouped edge only, so a raid
|
||||||
|
filling up prints it once, not once per member.
|
||||||
|
|
||||||
|
Both lines name the mode that is actually on and the switch that leaves
|
||||||
|
it (`/dc test` or `/dc demo`) — naming the wrong one would send you to a
|
||||||
|
toggle that changes nothing.
|
||||||
|
|
||||||
|
### "readable" means one thing now — the footer and the tooltips caught up
|
||||||
|
|
||||||
|
v0.6.1 gave the coverage pill its channel nouns (`15/26 hit read`), but the
|
||||||
|
footer strip on the Hit and Resistances tabs still said **AVERAGE
|
||||||
|
(READABLE)**, the chat report still said "average, readable players", and
|
||||||
|
the empty-slot tooltips said "unit readable" — all naming the gear/hit/
|
||||||
|
resist read while the word elsewhere (the `Not readable` list, the
|
||||||
|
`N unreadable` chips, the `N/N readable` group chip) means "this player
|
||||||
|
could be read at all". Same word, two populations, on one screen.
|
||||||
|
|
||||||
|
Decided and pinned in a code comment (`core/model.lua`, `CoverageLabel`):
|
||||||
|
**"readable" = the player could be read this scan (online, auras
|
||||||
|
answered)** — and nothing else. Every channel-specific population is
|
||||||
|
named by its channel: `gear read`, `hit read`, `resist read`.
|
||||||
|
|
||||||
|
* Footer on Hit / Resistances: **AVERAGE (HIT READ)** / **AVERAGE (RESIST
|
||||||
|
READ)** — derived from the same function as the pill, so the two can
|
||||||
|
no longer drift apart.
|
||||||
|
* Chat report section headers: `Hit (average, hit-read players):` /
|
||||||
|
`Resistances (average, resist-read players):`.
|
||||||
|
* Empty-slot tooltips: `gear read, no trinket equipped …`, `gear read, no
|
||||||
|
item equipped …`; the generic "slot empty" line takes the tab's noun
|
||||||
|
(`readable` on the aura tabs, `gear read` on Equipment).
|
||||||
|
* Coverage-pill tooltip on gear/hit/resist tabs now leads with the pill's
|
||||||
|
own number and noun (`15 of 26 hit read`), then the row-readability
|
||||||
|
line (`26 of 26 raid members readable (online, auras answered)`) —
|
||||||
|
before, the pill said `15/26` and its tooltip `26 of 26`.
|
||||||
|
* Two more spots missed the first pass: the gear-unreadable row's status
|
||||||
|
chip and its row-spanning explanation (`ui/matrix.lua` `UnreadRowText`,
|
||||||
|
`core/model.lua` `row.unreadNote`) said `items not readable` — right
|
||||||
|
next to a group header that counts that same row as readable (only its
|
||||||
|
EQUIPMENT channel came back empty). Both now say `gear not read`.
|
||||||
|
|
||||||
|
## v0.6.4
|
||||||
|
|
||||||
|
### Equipment expectation top-up now self-heals every refresh, not just once at ADDON_LOADED
|
||||||
|
|
||||||
|
Field report after v0.6.3 shipped (WAIST seeded `true` for every role):
|
||||||
|
after deploying and reloading, the Equipment matrix still showed 16
|
||||||
|
columns (no WAI) and the owner's own row read "(read failed)" / sum "--".
|
||||||
|
|
||||||
|
Offline reproduction against the real captured dump
|
||||||
|
(`dc_raid_1788070493.txt`, the scan taken right after the reload) and the
|
||||||
|
real live `db.expectations` shape
|
||||||
|
(`WTF\...\SavedVariables\DopingControl.lua`, all 20 role/class lines —
|
||||||
|
`NECK`/`R1`/`R2 = true`, `WAIST` absent everywhere) through the actual
|
||||||
|
`DC_Gear.Assemble` -> `DC_Model.build` -> `DC_Matrix` pipeline found no
|
||||||
|
Lua error anywhere — no nil dereference, nothing caught by a `pcall`.
|
||||||
|
Both symptoms are real, and neither is what it first looked like:
|
||||||
|
|
||||||
|
* The "(read failed)"/"--" row is a correct, unrelated read: the dump's
|
||||||
|
own `P`/`H`/`X` lines already carry `aurasRead=0`/
|
||||||
|
`unreadableReason=read failed` before any render happens —
|
||||||
|
`scan/aura.lua`'s "zero USABLE auras this scan => aurasRead=false" rule
|
||||||
|
firing on a genuine zero-buff moment. Nothing to do with WAIST or
|
||||||
|
equipment.
|
||||||
|
* The missing WAIST column traces to `core/model.lua`'s `anyClassExpects`:
|
||||||
|
it only falls back to the `DC.DEFAULT_EXPECT` seed for a (role, class)
|
||||||
|
combo with NO saved line at all — an *existing* class line (any line
|
||||||
|
materialized before the v0.6.3 seed change) is trusted as complete, key
|
||||||
|
by key. `DC.EnsureExpectations`/`topUpLine` (`core/config.lua`) are
|
||||||
|
themselves correct — proven by running them against the exact real
|
||||||
|
saved shape above — but they only ran once, at `ADDON_LOADED`. A
|
||||||
|
`db.expectations` line materialized before v0.6.3 shipped therefore
|
||||||
|
stayed short the new key until a SECOND full reload happened to run
|
||||||
|
`EnsureExpectations` again against the current seed.
|
||||||
|
|
||||||
|
Fix: `ui/matrix.lua`'s `MX.ExpectTable` — the one resolver both the header
|
||||||
|
and `MX.Refresh` read the expectation table from — now defensively re-runs
|
||||||
|
`DC.EnsureExpectations(d)` every time it resolves, i.e. on every
|
||||||
|
scan/window redraw, not only once at boot. `topUpLine` only ever fills
|
||||||
|
`nil` keys, so this is idempotent and side-effect-free on an
|
||||||
|
already-current table; it self-heals a stale class line the moment the
|
||||||
|
window redraws, without requiring a second reload.
|
||||||
|
|
||||||
|
New regression test `tools/luatests/test_expectations_topup.lua`: embeds
|
||||||
|
the real captured `db.expectations` shape (all 20 role/class lines,
|
||||||
|
verbatim) and a real gear/hit dump line, reproduces the exact
|
||||||
|
16-column/no-WAI/`(read failed)` symptom through the unmodified
|
||||||
|
`DC_Model.build`, then proves `MX.ExpectTable` self-heals it to 17 columns
|
||||||
|
with WAIST at column 6.
|
||||||
|
|
||||||
|
## v0.6.3
|
||||||
|
|
||||||
|
### Waist is expected by default for every role
|
||||||
|
|
||||||
|
Owner decision, reversing the "confirmed, not a bug" call below: every role
|
||||||
|
wears an enchantable belt, so WAIST now belongs with the other worn
|
||||||
|
equipment slots (HEAD, CHST, WRST, ...) instead of the opt-in neck/ring
|
||||||
|
treatment. `data/expectations.lua`'s seed now carries `WAIST = true` for
|
||||||
|
all five roles. Existing SavedVariables pick up the flag automatically —
|
||||||
|
`core/config.lua`'s `topUpLine` already adds any seed slot the saved line
|
||||||
|
has never seen (`nil`) as `true` on load, the same mechanism that
|
||||||
|
delivered the neck/ring columns to existing saves; no separate migration
|
||||||
|
was needed. An explicit `WAIST = false` set for a role/class still hides
|
||||||
|
the column, same opt-out behavior as any other slot.
|
||||||
|
|
||||||
|
### Confirmed: the waist column's off-by-default is intended, not a bug
|
||||||
|
|
||||||
|
A field report said the belt was worn but the Equipment tab still showed
|
||||||
|
16 columns, no WAI, after the v0.6.2 waist-slot fix was deployed and the
|
||||||
|
UI reloaded. Traced end to end (`core/model.lua` `tabSlots`/
|
||||||
|
`anyClassExpects`, `ui/matrix.lua`'s `vm.slots` column build, the live
|
||||||
|
SavedVariables `expectations` table): the gating is working exactly as
|
||||||
|
v0.6.2 shipped and tested it — the waist enchant column is opt-in per
|
||||||
|
role/class, the same treatment as neck/rings, and simply had not been
|
||||||
|
enabled for any role/class yet (unlike neck/rings, which already carry
|
||||||
|
`= true` in every class line of the reporting player's save from before
|
||||||
|
this default existed). No code was hiding anything; the column appears
|
||||||
|
the moment the checkbox is enabled in Options > Equipment.
|
||||||
|
|
||||||
|
Added a regression test (`tools/luatests/test_model.lua`, section 8d)
|
||||||
|
that exercises the real production path — `core/model.lua`'s `M.build`
|
||||||
|
through `tabSlots`/`anyClassExpects`, the same function `ui/matrix.lua`
|
||||||
|
renders from — instead of the static `DC.SLOTS_EQUIPMENT` array the
|
||||||
|
layout test uses. It pins both halves: WAIST absent from `vm.slots`
|
||||||
|
under the default seed, and present + classifying correctly (HAS/
|
||||||
|
MISSING/UNKNOWN) once a role/class expectation is switched on. This
|
||||||
|
closes the gap that let the "is it really gated correctly" question go
|
||||||
|
unanswered by the existing suite.
|
||||||
|
|
||||||
|
## v0.6.2
|
||||||
|
|
||||||
|
A fix release.
|
||||||
|
|
||||||
|
### The waist slot was never scanned
|
||||||
|
|
||||||
|
No equipment read — the raid dump, the Equipment tab, the Hit tab's
|
||||||
|
belt contribution — ever covered the waist (inventory slot 6). The slot
|
||||||
|
list that drives every `GetInventoryItemLink` read was borrowed from the
|
||||||
|
Equipment tab's enchant-tracking column list, which had no waist column;
|
||||||
|
that list never included the waist at all, so it was silently skipped in
|
||||||
|
every read that reused it, not only display.
|
||||||
|
|
||||||
|
The waist turns out to be enchantable on this server (a custom "Belt
|
||||||
|
Buckle" item line, the same kind of addition that already made the neck
|
||||||
|
and both rings enchantable beyond stock behaviour), so it now gets its
|
||||||
|
own Equipment-tab column — off by default, like the neck and ring
|
||||||
|
columns, until enabled per role/class in the options grid.
|
||||||
|
|
||||||
|
## v0.6.1
|
||||||
|
|
||||||
|
A fix release. Everything here is something that was supposed to work
|
||||||
|
already — most of it found by running the addon in a real 26-man raid and
|
||||||
|
then checking what it had actually read.
|
||||||
|
|
||||||
|
### Resistances of other players are readable at last
|
||||||
|
|
||||||
|
The Resistances tab shipped in v0.6.0 and showed `?` for everyone but you.
|
||||||
|
That was not a display bug: this client is never told other players'
|
||||||
|
resistances. Both read paths return `0` for a raid member standing next to
|
||||||
|
you, fully loaded — including their armour value, which is the giveaway
|
||||||
|
that the field is simply not transmitted rather than genuinely zero.
|
||||||
|
|
||||||
|
The numbers are now **reconstructed from the equipped items' tooltips** —
|
||||||
|
the same route the Hit tab already takes — and `Equip: +N All Resistances`
|
||||||
|
counts on all five schools. On top of that comes the **racial bonus**,
|
||||||
|
which is written in no item: Dwarf +10 Frost, Gnome +10 Arcane, Night Elf
|
||||||
|
and Tauren +10 Nature, Undead +10 Shadow. Goblin and High Elf get nothing
|
||||||
|
added, because no resistance racial for them could be confirmed — an
|
||||||
|
invented bonus would be worse than a missing one.
|
||||||
|
|
||||||
|
This stays a near-range reading. A raid member the client has not loaded
|
||||||
|
has no readable equipment at all, and their column stays `?`.
|
||||||
|
|
||||||
|
### The weapon-buff column could never report a missing buff
|
||||||
|
|
||||||
|
For another player, the client returns *nothing* for the weapon
|
||||||
|
enchantment when there is no imbue — not an empty string, as the code
|
||||||
|
expected. So everyone without a weapon buff was shown as `?` instead of as
|
||||||
|
a gap, and was left out of the gap count, the footer and the whisper.
|
||||||
|
Measured in a 26-man raid: **14 of 25** players were mislabelled that way —
|
||||||
|
precisely the case a buff checker exists to report.
|
||||||
|
|
||||||
|
The fix distinguishes by readability: if the main hand can be read and
|
||||||
|
there is no imbue, that is a gap. If the player cannot be read, it stays
|
||||||
|
unknown. Unknown is still never silently turned into missing.
|
||||||
|
|
||||||
|
### Consumables that were being discarded
|
||||||
|
|
||||||
|
Six buffs were tracked in the tables but never recognised, because the
|
||||||
|
aura carries a different name than the item: **Elixir of the Giants**,
|
||||||
|
**Elixir of Greater Agility**, **Elixir of Fortitude**, **Strike of the
|
||||||
|
Scorpok**, **Rage of Ages** and **Elixir of Greater Frost Power**. In one
|
||||||
|
raid, `Elixir of the Giants` alone was thrown away 38 times.
|
||||||
|
|
||||||
|
Aura names also arrive from the client with trailing spaces on occasion,
|
||||||
|
which defeated the name match; they are trimmed now.
|
||||||
|
|
||||||
|
### "Gaps only" no longer empties Hit and Resistances
|
||||||
|
|
||||||
|
Those two tabs are numeric — no cell there can be *missing*, so the filter
|
||||||
|
hid every row. It now also counts the unknown cells, which is what there is
|
||||||
|
to see on those tabs.
|
||||||
|
|
||||||
|
### The coverage number belongs to the tab it is shown on
|
||||||
|
|
||||||
|
The pill counted the players whose **auras** could be read — and showed
|
||||||
|
that number on every tab. Auras are readable raid-wide, equipment and hit
|
||||||
|
only nearby, resistances only for yourself. In the same raid the pill
|
||||||
|
claimed `26/26` while the Hit tab really knew 15 of 26 and the Resistances
|
||||||
|
tab 1 of 26.
|
||||||
|
|
||||||
|
Each tab now reports its own reading, and says which one it is:
|
||||||
|
`26/26 readable` on the aura tabs, `15/26 gear read`, `15/26 hit read`,
|
||||||
|
`1/26 resist read`. The report line in chat carries the same number. As a
|
||||||
|
side effect the word "readable" now means one thing instead of two.
|
||||||
|
|
||||||
|
## v0.6.0
|
||||||
|
|
||||||
|
### Hit
|
||||||
|
|
||||||
|
A new **Hit** tab, and with it the addon's second numeric tab after
|
||||||
|
Resistances. Eight columns: melee, ranged, and one per spell school —
|
||||||
|
because talents and items raise schools individually, so one "spell hit"
|
||||||
|
number would be wrong for most casters.
|
||||||
|
|
||||||
|
There is **no hit API in 1.12**. The number is assembled from what can
|
||||||
|
actually be read:
|
||||||
|
|
||||||
|
- **items** — parsed out of the equipped items' tooltips
|
||||||
|
- **set bonuses** — counted **once per set**, only when the piece
|
||||||
|
threshold is met (the bonus line is printed on every piece of the set,
|
||||||
|
so adding it per item would multiply it)
|
||||||
|
- **hit auras** — buffs that grant hit
|
||||||
|
- **talents** — for your own character, and for other players over the
|
||||||
|
inspect protocol this server ships (see below)
|
||||||
|
- **weapon skill** — every point above 300 is worth 0.2% hit against a
|
||||||
|
+3-level boss
|
||||||
|
|
||||||
|
The cell tooltip shows that as a plain **addition**: one row per source,
|
||||||
|
values in a right-aligned column, so the total can be checked by adding it
|
||||||
|
up. Sources that contribute nothing are still listed at `+0.0%` — a missing
|
||||||
|
row would look like a forgotten source. A source that could not be **read**
|
||||||
|
shows `?`, never `+0.0%`: unknown is not zero.
|
||||||
|
|
||||||
|
The boss's requirement is stated as one flat number (`Cap vs a +3-level
|
||||||
|
boss`). It does not move with your weapon skill — skill counts on the
|
||||||
|
player's side of the comparison, where it belongs.
|
||||||
|
|
||||||
|
Caps: melee 8% at 300 weapon skill, dual-wield white swings 27%, spell 16%.
|
||||||
|
A cell at or above its cap is marked green.
|
||||||
|
|
||||||
|
### Reading other players' talents
|
||||||
|
|
||||||
|
Talents raise hit by up to several percent, so a hit number without them is
|
||||||
|
systematically low. WoW 1.12 has no foreign-talent channel and this server
|
||||||
|
transmits none either — but it ships **its own inspect protocol**, a plain
|
||||||
|
addon-message conversation: we ask by name, the target's client answers with
|
||||||
|
its talent tree.
|
||||||
|
|
||||||
|
DopingControl uses exactly that, for the players the scan already shows.
|
||||||
|
One request per player, at most one every five seconds, and each name at
|
||||||
|
most once per half hour. It can be switched off entirely with
|
||||||
|
`/dc talents off`.
|
||||||
|
|
||||||
|
### Weapon skill books
|
||||||
|
|
||||||
|
A quest weapon-skill book grants +3 skill — about 0.6% hit — and **cannot be
|
||||||
|
detected on another player**: no channel reports foreign weapon skills. So
|
||||||
|
it is entered by hand, in a new **Skill books** options tab: a grid with one
|
||||||
|
row per scanned player and one column per weapon skill, with a checkbox
|
||||||
|
wherever that class could own a book for it.
|
||||||
|
|
||||||
|
### Whisper on the Hit tab
|
||||||
|
|
||||||
|
The hit tab now offers the per-player whisper button, unlike the other
|
||||||
|
display tab: being short of the hit cap is something that player can fix,
|
||||||
|
a resistance total is not. The whisper carries both numbers —
|
||||||
|
`Melee hit 5.0/8.0` — so the reader knows the distance, not just the fact.
|
||||||
|
|
||||||
|
It only ever mentions the columns that **role** is asked about: a melee
|
||||||
|
player is never told about his shadow hit, and healers are never whispered
|
||||||
|
at all (heals cannot miss). Every column stays visible with its own cap
|
||||||
|
marking on the tab — the display hides nothing, only the whisper is
|
||||||
|
selective.
|
||||||
|
|
||||||
|
### Class buffs
|
||||||
|
|
||||||
|
A sixth slot: **Emerald Blessing**. It ships with no expectation default —
|
||||||
|
it is a raid quest reward almost nobody carries — so its column stays
|
||||||
|
hidden until you enable it per role and class. Its +1% spell hit counts on
|
||||||
|
the Hit tab either way.
|
||||||
|
|
||||||
|
### Tables
|
||||||
|
|
||||||
|
- **Alternating row shading** in every table (main window, both options
|
||||||
|
grids), so a row can be followed across a wide grid.
|
||||||
|
- The **options window width follows the selected tab** instead of being
|
||||||
|
fixed to the widest one.
|
||||||
|
- The options column headers now use the **same header tiles as the main
|
||||||
|
window** — three-letter label over a sub-line, in a bordered strip.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **Could not scroll to the bottom** of the main window once every role
|
||||||
|
group was expanded. The content height was right; the scrollbar was
|
||||||
|
never told about it, so it kept the maximum it had when the group was
|
||||||
|
still collapsed.
|
||||||
|
- An unreadable equipment row was labelled **"out of range"** even when
|
||||||
|
distance was not the cause. It now states what was actually observed —
|
||||||
|
that no equipment slot returned an item link — and prints the measured
|
||||||
|
distance separately.
|
||||||
|
- **"Not expected for role X"** was shown for cells decided by class or by
|
||||||
|
the item (a shaman has no ranged enchant slot; an empty exemptable slot),
|
||||||
|
and pointed at an options switch that cannot change them. Those cells now
|
||||||
|
give the real reason.
|
||||||
|
- The hit-column header claimed the number was summed from items alone,
|
||||||
|
while it also contained auras and talents.
|
||||||
|
|
||||||
|
### Test mode
|
||||||
|
|
||||||
|
`/dc test` now simulates a **40-player raid** in eight subgroups instead of
|
||||||
|
25 players, including deliberately unreadable players and a completely
|
||||||
|
unreadable group.
|
||||||
|
|
||||||
|
## v0.5.2
|
||||||
|
|
||||||
|
New **Resistances tab**: display-only totals for all five schools per
|
||||||
|
player with a readable-players raid average in the footer. Equipment
|
||||||
|
semantics finalized: an empty slot counts as a gap unless it is one that may
|
||||||
|
legitimately be empty (off hand, ranged), the expectation grid governs
|
||||||
|
enchants only, and neck/rings joined the enchant logic (enchantable
|
||||||
|
on this server; expectation defaults to off). Expectation storage now
|
||||||
|
distinguishes a deliberate uncheck from a never-seen slot, so slots added in
|
||||||
|
future versions appear automatically.
|
||||||
|
|
||||||
|
## v0.5.1
|
||||||
|
|
||||||
|
The Equipment tab now covers all sixteen slots: neck and both rings joined
|
||||||
|
as item tiles (quality border, tooltip, empty slot counts as a gap — no
|
||||||
|
enchant exists for them in vanilla, so they carry no corner marker).
|
||||||
|
|
||||||
|
## v0.5.0
|
||||||
|
|
||||||
|
First public release: the four-tab readiness matrix (Consumables / Class
|
||||||
|
buffs / Debuffs / Equipment), the HAS–MISSING–UNKNOWN honesty model with
|
||||||
|
coverage pill and last-known display, the five-role system with
|
||||||
|
auto-suggestion and per-character confirmation, the role × class
|
||||||
|
expectations grid, per-player whispers with cooldown, the raid report with
|
||||||
|
mandatory preview, ready-check and window-open scan automation, and the
|
||||||
|
deterministic test mode.
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
## Interface: 11200
|
||||||
|
## Title: DopingControl
|
||||||
|
## Notes: Raid consumables, buffs, debuffs, resistances, hit and enchant checker
|
||||||
|
## Author: ShempError
|
||||||
|
## Version: 0.7.0
|
||||||
|
## SavedVariables: DopingControlDB
|
||||||
|
|
||||||
|
core\const.lua
|
||||||
|
core\config.lua
|
||||||
|
data\consumables.lua
|
||||||
|
data\classbuffs.lua
|
||||||
|
data\enchants.lua
|
||||||
|
data\expectations.lua
|
||||||
|
data\hit.lua
|
||||||
|
data\talenthit.lua
|
||||||
|
data\resist.lua
|
||||||
|
core\model.lua
|
||||||
|
core\roles.lua
|
||||||
|
core\sim.lua
|
||||||
|
core\demo.lua
|
||||||
|
scan\talents.lua
|
||||||
|
scan\reach.lua
|
||||||
|
scan\aura.lua
|
||||||
|
scan\gear.lua
|
||||||
|
scan\resist.lua
|
||||||
|
scan\hit.lua
|
||||||
|
scan\engine.lua
|
||||||
|
scan\pull.lua
|
||||||
|
scan\addons.lua
|
||||||
|
core\probe.lua
|
||||||
|
ui\cellsheet.lua
|
||||||
|
ui\widgets.lua
|
||||||
|
ui\matrix.lua
|
||||||
|
ui\options.lua
|
||||||
|
ui\report.lua
|
||||||
|
features\minimap.lua
|
||||||
|
core\init.lua
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2026 ShempError
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
@@ -0,0 +1,349 @@
|
|||||||
|
# DopingControl
|
||||||
|
|
||||||
|
A raid consumables, class buff, debuff, resistance, hit and enchant checker for
|
||||||
|
**TurtleWoW / WoW 1.12.1 (Vanilla)**.
|
||||||
|
|
||||||
|
One window that answers, before the pull: who has their flask and elixirs up, who is missing
|
||||||
|
which class buff, who is carrying which debuff, who is short of the hit cap, and whose gear
|
||||||
|
went into the raid unenchanted.
|
||||||
|
Its defining trait is **honesty about what it cannot see** — a player the addon cannot read is
|
||||||
|
shown as *unknown* and is **never counted as missing**, anywhere. Requires
|
||||||
|
[SuperWoW](https://github.com/balakethelock/SuperWoW) for full raid reads; degrades gracefully
|
||||||
|
without it.
|
||||||
|
|
||||||
|
<img src="screenshots/testmode.png" alt="The full readiness matrix on a simulated raid" width="100%">
|
||||||
|
|
||||||
|
*The full readiness matrix (shown here in test mode on a simulated raid): buff icons
|
||||||
|
in the cells, ready pills per role group, gaps first, unreadable players marked instead of
|
||||||
|
blamed.*
|
||||||
|
|
||||||
|
## What's new — v0.7.0 · 2026-10-15
|
||||||
|
|
||||||
|
- **A new Addons tab** shows which raid addons the other raiders run — BigWigs, Threat Meter,
|
||||||
|
EPGP, SuperWoW and Rinse — with the version in every cell and the newest one marked. The
|
||||||
|
other raiders do not need DopingControl; see [The Addons tab](#the-addons-tab).
|
||||||
|
- **The Equipment tooltip names the enchant.** Hovering a slot now says "Enchant: Crusader"
|
||||||
|
or "Enchant: Agility +15" instead of only "Enchanted", including server-custom enchants.
|
||||||
|
- **Seven tabs now**, and the footer, the filter and the test mode (`/dc test`) work on the
|
||||||
|
new tab as well.
|
||||||
|
|
||||||
|
## What's new — v0.6.4 · 2026-08-30
|
||||||
|
|
||||||
|
- **The belt is checked.** The waist slot was never scanned (v0.6.2), is expected equipment
|
||||||
|
for every role by default (v0.6.3), and a profile saved before the column existed picks it up
|
||||||
|
on the next refresh instead of only at login (v0.6.4).
|
||||||
|
|
||||||
|
**Version history** — details in [CHANGELOG.md](CHANGELOG.md):
|
||||||
|
|
||||||
|
- **v0.7.0** — the **Addons tab** (BigWigs, Threat Meter, EPGP, SuperWoW, Rinse across the raid);
|
||||||
|
the Equipment tooltip names the enchant.
|
||||||
|
- **v0.6.4** — the equipment expectation top-up self-heals on every refresh, so a profile
|
||||||
|
saved before the waist column existed picks it up without a relogin.
|
||||||
|
- **v0.6.3** — the waist slot is expected equipment by default for every role.
|
||||||
|
- **v0.6.2** — fix: the waist slot was never scanned, so a missing belt could not be reported.
|
||||||
|
- **v0.6.1** — fixes: other players' resistances are reconstructed from their gear (the tab
|
||||||
|
showed `?` for everyone but you), the weapon column can report a missing imbue again, six
|
||||||
|
consumables whose aura name differs from the item name are matched, "gaps only" no longer
|
||||||
|
empties Hit and Resistances, and the coverage number belongs to the tab it is shown on.
|
||||||
|
- **v0.6.0** — the **Hit tab** (melee, ranged, per-school), hit assembled from items, set
|
||||||
|
bonuses, auras, talents and weapon skill with the tooltip showing the addition line by line;
|
||||||
|
other players' talents read over this server's inspect protocol; the **Skill books** options
|
||||||
|
tab; alternating row shading; scrolling fixed once every group is expanded.
|
||||||
|
- **v0.5.2** — the **Resistances tab**; equipment semantics finalized (empty slots always count,
|
||||||
|
the grid governs enchants only, neck/rings enchantable on this server).
|
||||||
|
- **v0.5.1** — the Equipment tab covers all sixteen slots.
|
||||||
|
- **v0.5.0** — first public release.
|
||||||
|
|
||||||
|
## Screenshots
|
||||||
|
|
||||||
|
<table>
|
||||||
|
<tr>
|
||||||
|
<td align="center" width="33%">
|
||||||
|
<a href="screenshots/classbuffs.png"><img src="screenshots/classbuffs.png" width="260" alt="Class buffs tab"></a><br>
|
||||||
|
<sub><b>Class buffs</b> — six slots, the buffer's duty</sub>
|
||||||
|
</td>
|
||||||
|
<td align="center" width="33%">
|
||||||
|
<a href="screenshots/debuffs.png"><img src="screenshots/debuffs.png" width="260" alt="Debuffs tab"></a><br>
|
||||||
|
<sub><b>Debuffs</b> — columns only for what is present</sub>
|
||||||
|
</td>
|
||||||
|
<td align="center" width="33%">
|
||||||
|
<a href="screenshots/resistances.png"><img src="screenshots/resistances.png" width="260" alt="Resistances tab"></a><br>
|
||||||
|
<sub><b>Resistances</b> — totals + raid average</sub>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td align="center" width="33%">
|
||||||
|
<a href="screenshots/hit.png"><img src="screenshots/hit.png" width="260" alt="Hit tab"></a><br>
|
||||||
|
<sub><b>Hit</b> — melee, ranged and one column per school</sub>
|
||||||
|
</td>
|
||||||
|
<td align="center" width="33%">
|
||||||
|
<a href="screenshots/hit-tooltip.png"><img src="screenshots/hit-tooltip.png" width="260" alt="Hit breakdown tooltip"></a><br>
|
||||||
|
<sub><b>Hit breakdown</b> — every source on its own line</sub>
|
||||||
|
</td>
|
||||||
|
<td align="center" width="33%">
|
||||||
|
<a href="screenshots/equipment.png"><img src="screenshots/equipment.png" width="260" alt="Equipment tab"></a><br>
|
||||||
|
<sub><b>Equipment</b> — item tiles, enchant corner markers</sub>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td align="center" width="33%">
|
||||||
|
<a href="screenshots/options.png"><img src="screenshots/options.png" width="260" alt="Options panel"></a><br>
|
||||||
|
<sub><b>Options</b> — expectations per role and class</sub>
|
||||||
|
</td>
|
||||||
|
<td align="center" width="33%">
|
||||||
|
<a href="screenshots/skillbooks.png"><img src="screenshots/skillbooks.png" width="260" alt="Skill books options tab"></a><br>
|
||||||
|
<sub><b>Skill books</b> — one column per weapon skill</sub>
|
||||||
|
</td>
|
||||||
|
<td align="center" width="33%">
|
||||||
|
<a href="screenshots/hit-tooltip-school.png"><img src="screenshots/hit-tooltip-school.png" width="260" alt="School hit tooltip"></a><br>
|
||||||
|
<sub><b>School columns</b> — what only that school gets</sub>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
*(Click any thumbnail for full size.)*
|
||||||
|
|
||||||
|
## The seven tabs
|
||||||
|
|
||||||
|
**Consumables** — eleven slots per player: Flask, Food buff, Attack power, Strength, Agility,
|
||||||
|
Spell power, Mana regen, Armor, Stamina, **Fire protection**, and the temporary weapon buff
|
||||||
|
(oils / stones). Cells show the **actual buff icon** the player has, not just a checkmark — a
|
||||||
|
Flask of the Titans and a lesser protection potion look different because they are different.
|
||||||
|
|
||||||
|
**Class buffs** — the six raid buffs: Arcane Intellect, Mark of the Wild, Power Word:
|
||||||
|
Fortitude, Shadow Protection, Emerald Blessing, Soulstone. Emerald Blessing ships with **no**
|
||||||
|
expectation default — it is a raid quest reward almost nobody carries — so its column stays
|
||||||
|
hidden until you enable it per role and class, while its +1% spell hit still counts on the Hit
|
||||||
|
tab. This tab deliberately has **no whisper button**: a
|
||||||
|
missing class buff is the *buffer's* duty to fix, and whispering the unbuffed player would nag
|
||||||
|
the wrong person.
|
||||||
|
|
||||||
|
**Debuffs** — a warning signal rather than a checklist. Columns are **built dynamically** from
|
||||||
|
what is actually on the raid: a debuff that nobody has gets no column, and a raid with no
|
||||||
|
debuffs shows none. The sum column carries the count per player, and clean players stay quiet —
|
||||||
|
no green wall of "nothing wrong here".
|
||||||
|
|
||||||
|
**Resistances** — a display-only tab: five fire/nature/frost/shadow/arcane columns showing each
|
||||||
|
player's current total, no gaps, no expectation checkboxes, no whisper button. The footer shows
|
||||||
|
the raid average across readable players only. The client is never told another player's
|
||||||
|
resistances, so for everyone but yourself the total is **reconstructed from their equipped
|
||||||
|
items** plus the racial bonus (Dwarf frost, Gnome arcane, Night Elf and Tauren nature, Undead
|
||||||
|
shadow). That makes it a near-range reading: a raid member the client has not loaded shows `?`,
|
||||||
|
never a made-up number.
|
||||||
|
|
||||||
|
**Equipment** — sixteen slots. Eleven armor slots (head, shoulders, back, chest, wrist, hands,
|
||||||
|
legs, feet, main hand, off hand, ranged) are enchant slots, checked present/missing with a
|
||||||
|
corner marker for the enchant state. Neck and both rings can be enchanted on this server (a
|
||||||
|
custom feature beyond vanilla WoW) and join the same enchant logic — the expectation defaults to
|
||||||
|
off, so enable it per role/class in the options grid where it matters. Both trinkets are item
|
||||||
|
tiles instead: the equipped item is shown regardless of any expectation. Empty slots count as a gap everywhere on this tab **except
|
||||||
|
the two that may legitimately be empty**: an empty off hand (you carry a two-hander) and an
|
||||||
|
empty ranged slot have nothing to enchant and are marked *not expected* instead. The
|
||||||
|
expectation grid only governs whether the *enchant* question is tracked. Slots where no enchant exists for that item or class are marked
|
||||||
|
*not expected* rather than red. Hovering a tile shows the **native item tooltip**, so you see
|
||||||
|
the exact enchant — not merely that one is present — and the tooltip names it
|
||||||
|
("Enchant: Crusader", "Enchant: Agility +15"), from a table generated from the game client's own
|
||||||
|
enchant data, so server-custom enchants are named too. An enchant that is not in the table shows
|
||||||
|
as "enchant #<id>": still known to be enchanted, never reported as missing.
|
||||||
|
|
||||||
|
**Hit** — melee, ranged and one column **per spell school**, because talents and items raise
|
||||||
|
schools individually: one "spell hit" number would be wrong for most casters. There is no hit
|
||||||
|
API in 1.12, so the number is assembled from the equipped items' tooltips, set bonuses, hit
|
||||||
|
auras, talents and weapon skill. The cell tooltip shows that as a plain **addition** — one row
|
||||||
|
per source, values in a right-aligned column, so the total can be checked by adding it up. A
|
||||||
|
source that contributes nothing is still listed at `+0.0%`; a source that could not be *read*
|
||||||
|
shows `?`, never a zero. The boss's requirement is one flat number (8% melee at 300 weapon
|
||||||
|
skill, 27% for dual-wield white swings, 16% spell) and does not move with your weapon skill —
|
||||||
|
skill counts on the player's side, where it belongs. Cells at or above the cap turn green.
|
||||||
|
|
||||||
|
Two things about that number are worth stating plainly:
|
||||||
|
|
||||||
|
- **Set bonuses count once per set**, not once per piece, and only when their piece threshold
|
||||||
|
is met. Their tooltip line is printed on every piece of the set, which is why a naive per-item
|
||||||
|
sum would multiply them.
|
||||||
|
- **Talents of other players are read**, over the inspect protocol this server ships — one
|
||||||
|
addon message per player, paced and repeated at most once per half hour, and switchable off
|
||||||
|
with `/dc talents off`. Until an answer arrives, the talent row shows `?` and the total says
|
||||||
|
it will rise.
|
||||||
|
|
||||||
|
A quest weapon-skill book (+3 skill, about 0.6% hit) is the one input that **cannot** be read
|
||||||
|
off another player — no channel reports foreign weapon skills. It is ticked by hand in the
|
||||||
|
**Skill books** options tab: one row per scanned player, one column per weapon skill.
|
||||||
|
|
||||||
|
### The Addons tab
|
||||||
|
|
||||||
|
<img src="screenshots/addons.png" alt="The Addons tab on a simulated raid: versions with a check, outdated versions with an exclamation mark, missing addons in red, unreadable players as a grey question mark, not-expected cells as a dot" width="100%">
|
||||||
|
|
||||||
|
*The Addons tab with the check finished (test mode, simulated raid): one row per raider, one
|
||||||
|
column per addon, the version in each cell. Every cell state is visible: a version with a check,
|
||||||
|
a version with `!`, a red missing cell, a grey `?` for an unreadable player and a dot where the
|
||||||
|
addon is not expected.*
|
||||||
|
|
||||||
|
The question this tab answers is **"who in my raid runs the addons I rely on?"** — BigWigs,
|
||||||
|
Threat Meter (TW Threatmeter), EPGP (shootyepgp), SuperWoW and Rinse. There are no
|
||||||
|
expectations to set and nothing to whisper; it is a display tab like Resistances and Hit.
|
||||||
|
|
||||||
|
**How it checks.** The other raiders do **not** need DopingControl. Each column is detected only
|
||||||
|
through what that addon itself sends or answers: BigWigs' own version query, the Threat Meter
|
||||||
|
"who" query, shootyepgp's version announcement (and its SuperWoW reply), and the UpsiRinse
|
||||||
|
version query. DopingControl sends the queries those addons already understand and reads the
|
||||||
|
answers.
|
||||||
|
|
||||||
|
- The **Check addons** button sends one burst of queries and collects replies for six seconds.
|
||||||
|
The status line next to it counts the window down.
|
||||||
|
- Opening the tab runs one check automatically, at most every 30 seconds, and only out of
|
||||||
|
combat. Joining a raid does not trigger one.
|
||||||
|
- Besides that, a passive listener records the versions addons announce on their own, and
|
||||||
|
forgets raiders who left the group.
|
||||||
|
- Your own row is read from your own addon list, not from replies.
|
||||||
|
|
||||||
|
**The cells.**
|
||||||
|
|
||||||
|
| Cell | Meaning |
|
||||||
|
|---|---|
|
||||||
|
| Green, version, check mark | The addon is there and this is the **newest version in the raid** (your own row counts). |
|
||||||
|
| Green, version, "!" | The addon is there but **older than the newest** version seen, or the version cannot be compared. The tooltip names the newest. |
|
||||||
|
| Red | **No reply** after the query window passed. |
|
||||||
|
| Grey "?" | **Not asked yet** (or the player is offline). Silence before any query is never shown as red. |
|
||||||
|
| Dot | **Not applicable** — see the limits below. |
|
||||||
|
|
||||||
|
Hovering a cell shows the version, the raw reply, which fork answered and how long ago it was
|
||||||
|
seen. SuperWoW has no version: its cell says `yes` (green) or `no` (red).
|
||||||
|
|
||||||
|
**What each column can and cannot tell you.** Red means *no reply*, and that is not always
|
||||||
|
"not installed" — it can also be an old version or a fork that does not answer. The tooltip on
|
||||||
|
a red cell says exactly that, and each column header carries its own limits:
|
||||||
|
|
||||||
|
- **BW (BigWigs)** — raid groups only, not party. Forks answer in their own format, and
|
||||||
|
revision numbers are compared only **within the same fork**.
|
||||||
|
- **THR (Threat Meter)** — only TW Threatmeter answers; other threat meters are not detected.
|
||||||
|
- **EPG (EPGP)** — members of **your own guild** only, and only if they run shootyepgp. The
|
||||||
|
version is known only if that player's announcement reached you; a
|
||||||
|
reply to the SuperWoW query alone proves the addon is installed but carries no version.
|
||||||
|
- **SW (SuperWoW)** — also guild members running shootyepgp only: SuperWoW itself cannot be seen
|
||||||
|
from outside, so shootyepgp answers for it. A raider outside your guild shows a dot, not red.
|
||||||
|
- **RIN (Rinse)** — only dispellers (Druid, Priest, Paladin, Shaman, Mage) are checked; everyone
|
||||||
|
else shows a dot. Only the **UpsiRinse** fork answers the query, so a raider with the original
|
||||||
|
Rinse shows red even though the addon is installed.
|
||||||
|
|
||||||
|
**Reading the totals.** Each class group's header counts the players, the gaps (red cells) and
|
||||||
|
how many answered. The footer **MISSING PER ADDON** counts the red cells per column, and the
|
||||||
|
**Gaps only** filter hides every raider with no red and no grey cell, so rows that still await an
|
||||||
|
answer stay visible. `/dc test` fills the tab with a deterministic mix of all states.
|
||||||
|
|
||||||
|
## Honest by design
|
||||||
|
|
||||||
|
The addon distinguishes three data states — **HAS**, **MISSING** and **UNKNOWN** — plus a
|
||||||
|
fourth, display-only one: *not expected*, for a slot the player is not asked to fill.
|
||||||
|
|
||||||
|
- **UNKNOWN is never counted as MISSING** — not in cells, not in sums, not in ready pills, not
|
||||||
|
in reports. A player the addon cannot read produces no accusation.
|
||||||
|
- A **coverage pill** in the titlebar shows how much of the raid is actually readable right now,
|
||||||
|
so you know how much the numbers are worth before you act on them.
|
||||||
|
- **Last-known display:** when a previously scanned player becomes unreadable, their stale data
|
||||||
|
is still shown — half-transparent, with its age. This is display-only; a stale cell still
|
||||||
|
*counts* as UNKNOWN everywhere.
|
||||||
|
|
||||||
|
A wrong number on a raid-readiness check is worse than no number. The addon would rather say
|
||||||
|
"I don't know" than guess.
|
||||||
|
|
||||||
|
## Roles and expectations
|
||||||
|
|
||||||
|
Players are grouped into five roles — **Tanks / Melee / Ranged / Casters / Healers** — because a
|
||||||
|
healer without a Mongoose elixir is not a problem and a tank without armor elixirs is.
|
||||||
|
|
||||||
|
- Roles are **auto-suggested** with a stated reason (visible on the badge's tooltip).
|
||||||
|
- **Left-click** a role badge to confirm the suggestion; **right-click** cycles through the
|
||||||
|
roles plausible for that class. Confirmed roles are saved per character name.
|
||||||
|
- An **unassigned counter** in the titlebar shows how many players still run on suggestions.
|
||||||
|
|
||||||
|
What each role is expected to bring is fully configurable — as a **role × class grid** in the
|
||||||
|
options panel, since a caster's "spell power" means something different for a mage than for a
|
||||||
|
shadow priest. Every role is configurable per performing class, and each role row has a bulk
|
||||||
|
toggle to switch a whole expectation on or off at once.
|
||||||
|
|
||||||
|
## Actions
|
||||||
|
|
||||||
|
- **Per-player whisper button** — tells a player exactly what they are missing on the current
|
||||||
|
tab. 30-second cooldown per player, and offered only for *readable* players with actual gaps;
|
||||||
|
the addon never whispers anyone based on data it does not have. On the **Hit** tab it carries
|
||||||
|
both numbers (`Melee hit 5.0/8.0`) and only mentions the columns that role is asked about — a
|
||||||
|
melee player is never told about his shadow hit, and healers are never whispered at all
|
||||||
|
(heals cannot miss).
|
||||||
|
- **Raid report with preview** — composes a summary for raid chat and shows it to you first.
|
||||||
|
**Nothing is ever sent without an explicit click.**
|
||||||
|
- **Scanning** — on demand (`/dc scan` or the toolbar button), automatically when the window
|
||||||
|
opens if there is no scan yet or the last one is over a minute old (never in test mode), and
|
||||||
|
automatically on a ready check. The two ready-check reactions — scan, and open the window —
|
||||||
|
can be switched off separately; the window-open scan is not optional.
|
||||||
|
|
||||||
|
## Test mode
|
||||||
|
|
||||||
|
`/dc test` swaps the live group for a **deterministic simulated 40-player raid** — same roster,
|
||||||
|
same gaps, every time. The full UI (all seven tabs, roles, pills, report preview) can be
|
||||||
|
inspected and configured without being in a group, and all chat output is disabled while test
|
||||||
|
mode is on, so nothing simulated can ever reach another player.
|
||||||
|
|
||||||
|
## Demo mode
|
||||||
|
|
||||||
|
`/dc demo` (or the button in the options panel) shows the same simulated raid, but with **all
|
||||||
|
six school protection columns at once** — fire, frost, nature, shadow, holy and arcane — even
|
||||||
|
though five of them are switched off by default. It is a viewing mode, nothing else: your saved
|
||||||
|
expectations are never modified, and the mode itself is not saved either, so a reload ends it.
|
||||||
|
|
||||||
|
## Install
|
||||||
|
|
||||||
|
**Option A — download (simplest):**
|
||||||
|
|
||||||
|
1. Grab **`DopingControl-vX.Y.Z.zip`** from
|
||||||
|
[**Releases**](https://octowow.st/git/ShempError/DopingControl/releases).
|
||||||
|
*(Use the Release zip, not "Code → Download ZIP" — that names the folder
|
||||||
|
`DopingControl-main`, which WoW won't load.)*
|
||||||
|
2. Extract the **`DopingControl`** folder into `Interface\AddOns\`.
|
||||||
|
3. Restart the client.
|
||||||
|
|
||||||
|
**Option B — git (auto-updatable):**
|
||||||
|
|
||||||
|
```
|
||||||
|
cd Interface/AddOns
|
||||||
|
git clone https://octowow.st/git/ShempError/DopingControl.git DopingControl
|
||||||
|
```
|
||||||
|
|
||||||
|
`main` is the stable release channel — git-based addon managers stay current with a
|
||||||
|
`git pull`.
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
| Command | Does |
|
||||||
|
| --- | --- |
|
||||||
|
| `/dc` or `/doping` | toggles the main window |
|
||||||
|
| `/dc scan` | scans the group now |
|
||||||
|
| `/dc options` | opens the options panel (also: right-click the minimap button) |
|
||||||
|
| `/dc report` | opens the raid report preview |
|
||||||
|
| `/dc test` | toggles test mode (simulated 40-player raid, chat output disabled) |
|
||||||
|
| `/dc demo` | toggles demo mode (the simulated raid with all six protection columns shown; your expectations stay untouched, the mode is not saved) |
|
||||||
|
| `/dc probe [range <name>]` | dumps raw scan-API returns to a file for debugging data-source gaps (SuperWoW required) |
|
||||||
|
| `/dc talents [on\|off\|clear]` | shows how many players' talents are in, or stops asking for them |
|
||||||
|
| `/dc unknown` | lists buff names the scanner saw but could not classify — useful for reporting gaps in the data tables |
|
||||||
|
|
||||||
|
The minimap button toggles the window on left-click, opens options on right-click, and can be
|
||||||
|
dragged to reposition.
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
- A **TurtleWoW** client (WoW 1.12.1) with
|
||||||
|
**[SuperWoW](https://github.com/balakethelock/SuperWoW)** — used for buff *identity* (knowing
|
||||||
|
which buff a cell shows, not just its icon) and for reading other players' equipment.
|
||||||
|
- Optional: **UnitXP_SP3** — distance diagnostics; flags players out of scan range. Distance
|
||||||
|
never changes any cell state or sum.
|
||||||
|
- Optional: **nampower** — when present, its aura descriptor supplies the spell **IDs** of other
|
||||||
|
players' buffs and debuffs, which the tooltip path does not deliver on this client; both
|
||||||
|
sources are merged. Without it, buff identity rests on names alone.
|
||||||
|
|
||||||
|
**Degrades gracefully:** without SuperWoW, cells for other players show *not readable* instead
|
||||||
|
of a guess, and the coverage pill tells you so. Your own character remains fully readable
|
||||||
|
either way.
|
||||||
|
|
||||||
|
## Licence
|
||||||
|
|
||||||
|
MIT.
|
||||||
@@ -0,0 +1,252 @@
|
|||||||
|
-- DopingControl core/config.lua
|
||||||
|
-- SavedVariables defaults + fill-in: single DEFAULTS table, per-field fill-in
|
||||||
|
-- with nil-checks (NEVER 'or' for booleans -- would flip saved false),
|
||||||
|
-- copy-on-assign for table values so editing a live value can never mutate
|
||||||
|
-- the defaults.
|
||||||
|
-- Pure Lua 5.0, no WoW API -- dofile-loadable offline.
|
||||||
|
|
||||||
|
DopingControl = DopingControl or {}
|
||||||
|
local DC = DopingControl
|
||||||
|
|
||||||
|
-- SavedVariables root (the client replaces this global at ADDON_LOADED;
|
||||||
|
-- core/init.lua then calls DC.EnsureDefaults(DopingControlDB)).
|
||||||
|
DopingControlDB = DopingControlDB or {}
|
||||||
|
|
||||||
|
-- ------------------------------------------------------------------
|
||||||
|
-- Defaults
|
||||||
|
--
|
||||||
|
-- Two keys intentionally have NO entry here (a nil value cannot live in a
|
||||||
|
-- Lua table):
|
||||||
|
-- framePos -- default nil = "no saved position, use the anchor
|
||||||
|
-- default"; written by the UI on drag as
|
||||||
|
-- { point=..., relPoint=..., x=..., y=... }.
|
||||||
|
-- expectations -- default nil = "not initialized yet"; EnsureDefaults
|
||||||
|
-- special-cases it below: the saved shape
|
||||||
|
-- is PER CLASS (db.expectations[role][class][slotId]),
|
||||||
|
-- materialized/migrated from the DC.DEFAULT_EXPECT seed
|
||||||
|
-- by DC.EnsureExpectations.
|
||||||
|
-- ------------------------------------------------------------------
|
||||||
|
DC.DEFAULTS = {
|
||||||
|
testMode = false, -- /dc test: simulated raid
|
||||||
|
showMinimapButton = true,
|
||||||
|
minimapAngle = 215, -- degrees around the minimap rim
|
||||||
|
readyCheckScan = true, -- auto-scan on READY_CHECK
|
||||||
|
readyCheckOpen = true, -- auto-open window on READY_CHECK
|
||||||
|
-- auto-scan on a BigWigs pull timer (scan/pull.lua), ANY raid/party
|
||||||
|
-- member's, never opens the window -- see that file's header for the
|
||||||
|
-- wire format and the self-pull backstop.
|
||||||
|
pullTimerScan = true,
|
||||||
|
collapse = {}, -- per-role collapsed flags, e.g. collapse.RANGED = true
|
||||||
|
filterGapsOnly = false, -- toolbar filter All | Gaps only
|
||||||
|
roles = {}, -- confirmed roles keyed by player NAME
|
||||||
|
-- Weapon skill books: db.skillBooks[playerName][weaponSkillName] =
|
||||||
|
-- true|false. A quest weapon-skill book (+3 skill) is INVISIBLE on
|
||||||
|
-- other players -- foreign weapon skill cannot be read at all (see
|
||||||
|
-- data/hit.lua) -- so it is an opt-in the user ticks per player and
|
||||||
|
-- per weapon skill; it only shifts that player's melee/ranged hit CAP.
|
||||||
|
-- Unchecking stores an explicit false (never nil), exactly like the
|
||||||
|
-- expectation grid, so "deliberately off" survives any later fill-in.
|
||||||
|
-- The own character ignores these flags: its weapon skill is measured
|
||||||
|
-- and a measured value already contains the book.
|
||||||
|
skillBooks = {},
|
||||||
|
}
|
||||||
|
|
||||||
|
-- ------------------------------------------------------------------
|
||||||
|
-- DC.SimOwnsStore(db) -> bool
|
||||||
|
--
|
||||||
|
-- The ONE owner predicate for DC.store, and the question every scan gate
|
||||||
|
-- asks -- never db.testMode alone. Both simulated modes replace the store
|
||||||
|
-- with the simulated raid, and while either is on "real data never beats
|
||||||
|
-- the simulation":
|
||||||
|
-- * db.testMode -- /dc test, persisted (DEFAULTS above)
|
||||||
|
-- * DC.demoMode -- /dc demo, transient (core/demo.lua)
|
||||||
|
--
|
||||||
|
-- Why it exists: the gates named db.testMode only, so a finished scan
|
||||||
|
-- overwrote the sim store while demo mode was still armed. The demo
|
||||||
|
-- expectation layer then graded the REAL raid against all six school
|
||||||
|
-- protection columns (five of which ship with no seed entry at all): every
|
||||||
|
-- raider grew fabricated MISSING cells, the banner vanished with the "sim"
|
||||||
|
-- source that had been the only marker that this is a mode and not a
|
||||||
|
-- measurement, and each row's whisper button went live against a real
|
||||||
|
-- player.
|
||||||
|
--
|
||||||
|
-- Why HERE and not in core/demo.lua, where the mode lives: config.lua loads
|
||||||
|
-- second, ahead of every gate that calls this, so the gates need no
|
||||||
|
-- load-order guard -- and a demo module that fails to load can never take
|
||||||
|
-- test mode's protection down with it (DC.demoMode is then simply nil).
|
||||||
|
--
|
||||||
|
-- db is optional; the fallback is for callers that do not hold one.
|
||||||
|
-- Always returns a real boolean -- the gates invert it.
|
||||||
|
-- ------------------------------------------------------------------
|
||||||
|
function DC.SimOwnsStore(db)
|
||||||
|
db = db or DC.db or DopingControlDB
|
||||||
|
if db and db.testMode then
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
if DC.demoMode then
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
return false
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Generic deep copy (tables only; keys are copied by reference, values
|
||||||
|
-- recursively). No cycle handling -- config/expectation shapes are trees.
|
||||||
|
function DC.DeepCopy(src)
|
||||||
|
if type(src) ~= "table" then
|
||||||
|
return src
|
||||||
|
end
|
||||||
|
local out = {}
|
||||||
|
for k, v in pairs(src) do
|
||||||
|
if type(v) == "table" then
|
||||||
|
out[k] = DC.DeepCopy(v)
|
||||||
|
else
|
||||||
|
out[k] = v
|
||||||
|
end
|
||||||
|
end
|
||||||
|
return out
|
||||||
|
end
|
||||||
|
|
||||||
|
-- ------------------------------------------------------------------
|
||||||
|
-- Expectations: the saved shape is
|
||||||
|
-- PER CLASS --
|
||||||
|
-- db.expectations[role][class][slotId] = true|nil
|
||||||
|
-- with one class line for every class that can perform the role
|
||||||
|
-- (DC_Roles.ROLE_CLASSES, the ordered inversion of CLASS_ROLES).
|
||||||
|
-- DC.DEFAULT_EXPECT (data/expectations.lua) STAYS the role x slot SEED.
|
||||||
|
--
|
||||||
|
-- EnsureExpectations guarantees the full structure:
|
||||||
|
-- * first load (expectations == nil): every (role, performing class)
|
||||||
|
-- gets a deep copy of the seed's role line.
|
||||||
|
-- * legacy saved tables (one line per role) -- detected by BOOLEANS directly under
|
||||||
|
-- [role] (new-shape values are class tables) -- are MIGRATED: the
|
||||||
|
-- old role line is copied to every performing class, then the
|
||||||
|
-- structure is replaced. User edits survive on every class where
|
||||||
|
-- they are representable.
|
||||||
|
-- * new-shape tables: existing class lines are NEVER touched; missing
|
||||||
|
-- (role,class) combos are filled from the seed. The fill is
|
||||||
|
-- behavior-neutral -- the model resolves a missing combo to the
|
||||||
|
-- DC.DEFAULT_EXPECT role line anyway (runtime fallback) -- it only
|
||||||
|
-- hands the options grid a complete structure.
|
||||||
|
-- Known ambiguity (accepted): an old-shape role line with EVERY slot
|
||||||
|
-- unchecked is {} and indistinguishable from an untouched new-shape
|
||||||
|
-- role table (the boolean marker is gone); it is re-seeded per class.
|
||||||
|
--
|
||||||
|
-- Resolved at CALL time, not file-load time: data/expectations.lua and
|
||||||
|
-- core/roles.lua load after this file (TOC order const -> config ->
|
||||||
|
-- data/* -> model -> roles), but EnsureDefaults only runs at
|
||||||
|
-- ADDON_LOADED when everything is in. Offline callers without DC_Roles
|
||||||
|
-- loaded simply skip the expectations block.
|
||||||
|
-- ------------------------------------------------------------------
|
||||||
|
local function isOldShapeLine(line)
|
||||||
|
for _, v in pairs(line) do
|
||||||
|
if type(v) == "boolean" then
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
end
|
||||||
|
return false
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Top up ONE class line in place: slots the seed marks true and the line
|
||||||
|
-- has NEVER SEEN (nil) become true. Deliberate unchecks survive (the
|
||||||
|
-- options grid stores those as explicit false, never nil). Shared by both
|
||||||
|
-- EnsureExpectations branches below so a freshly migrated line is already
|
||||||
|
-- complete -- not just on the SECOND call (idempotency: a migration must
|
||||||
|
-- converge in one pass, not two).
|
||||||
|
local function topUpLine(cl, seedLine)
|
||||||
|
for slotId, v in pairs(seedLine) do
|
||||||
|
if v == true and cl[slotId] == nil then
|
||||||
|
cl[slotId] = true
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
function DC.EnsureExpectations(db)
|
||||||
|
if DC.DEFAULT_EXPECT == nil or DC_Roles == nil
|
||||||
|
or DC_Roles.ROLE_CLASSES == nil then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
if db.expectations == nil then
|
||||||
|
db.expectations = {}
|
||||||
|
end
|
||||||
|
local exp = db.expectations
|
||||||
|
local nRoles = table.getn(DC.ROLES)
|
||||||
|
for r = 1, nRoles do
|
||||||
|
local role = DC.ROLES[r]
|
||||||
|
local classes = DC_Roles.ROLE_CLASSES[role] or {}
|
||||||
|
local nClasses = table.getn(classes)
|
||||||
|
local line = exp[role]
|
||||||
|
local seedLine = DC.DEFAULT_EXPECT[role] or {}
|
||||||
|
if line ~= nil and isOldShapeLine(line) then
|
||||||
|
-- migration: copy the old role line to every performing
|
||||||
|
-- class, then top up each new class line from the CURRENT
|
||||||
|
-- seed (a slot added since the old line was saved must reach
|
||||||
|
-- it immediately, not only on a later call -- see topUpLine),
|
||||||
|
-- then replace the structure (never lose user edits where
|
||||||
|
-- representable)
|
||||||
|
local byClass = {}
|
||||||
|
for c = 1, nClasses do
|
||||||
|
local cl = DC.DeepCopy(line)
|
||||||
|
topUpLine(cl, seedLine)
|
||||||
|
byClass[classes[c]] = cl
|
||||||
|
end
|
||||||
|
exp[role] = byClass
|
||||||
|
else
|
||||||
|
if line == nil then
|
||||||
|
line = {}
|
||||||
|
exp[role] = line
|
||||||
|
end
|
||||||
|
-- fill missing (role,class) combos from the seed; existing
|
||||||
|
-- class lines keep their values, but slots the seed knows
|
||||||
|
-- and the line has NEVER SEEN (nil) are topped up -- without
|
||||||
|
-- this, a slot added in a later version never reaches saved
|
||||||
|
-- lines (field report: neck/ring columns arrived as "not
|
||||||
|
-- expected" for everyone). Deliberate unchecks survive: the
|
||||||
|
-- options grid stores them as explicit false, never nil.
|
||||||
|
for c = 1, nClasses do
|
||||||
|
local cls = classes[c]
|
||||||
|
if line[cls] == nil then
|
||||||
|
line[cls] = DC.DeepCopy(seedLine)
|
||||||
|
else
|
||||||
|
topUpLine(line[cls], seedLine)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Fill missing fields in db from DC.DEFAULTS. Existing values -- including
|
||||||
|
-- saved booleans that are false -- are never touched. Table defaults are
|
||||||
|
-- deep-copied on assign.
|
||||||
|
function DC.EnsureDefaults(db)
|
||||||
|
for k, v in pairs(DC.DEFAULTS) do
|
||||||
|
if db[k] == nil then
|
||||||
|
if type(v) == "table" then
|
||||||
|
db[k] = DC.DeepCopy(v)
|
||||||
|
else
|
||||||
|
db[k] = v
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
-- expectations: per-(role,class) shape, materialized/migrated from
|
||||||
|
-- the DC.DEFAULT_EXPECT seed (see EnsureExpectations above)
|
||||||
|
DC.EnsureExpectations(db)
|
||||||
|
return db
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Reset the whole config IN PLACE (keeps the table reference -- the client
|
||||||
|
-- saves the global DopingControlDB, so the reference must survive).
|
||||||
|
function DC.ResetConfig(db)
|
||||||
|
for k in pairs(db) do
|
||||||
|
db[k] = nil
|
||||||
|
end
|
||||||
|
return DC.EnsureDefaults(db)
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Reset only the expectation matrix to the DC.DEFAULT_EXPECT seed,
|
||||||
|
-- materialized per (role, performing class) -- options button
|
||||||
|
-- "Reset expectations" (reseeds ALL classes).
|
||||||
|
function DC.ResetExpectations(db)
|
||||||
|
db.expectations = nil
|
||||||
|
DC.EnsureDefaults(db)
|
||||||
|
return db.expectations
|
||||||
|
end
|
||||||
@@ -0,0 +1,277 @@
|
|||||||
|
-- DopingControl core/const.lua
|
||||||
|
-- Shared constants: roles, states, tabs, color palette, quality colors.
|
||||||
|
-- Pure Lua 5.0, no WoW API -- dofile-loadable offline.
|
||||||
|
|
||||||
|
DopingControl = DopingControl or {}
|
||||||
|
local DC = DopingControl
|
||||||
|
|
||||||
|
-- ------------------------------------------------------------------
|
||||||
|
-- Roles (array order = display order) and labels
|
||||||
|
-- ------------------------------------------------------------------
|
||||||
|
DC.ROLES = { "TANK", "MELEE", "RANGED", "CASTER", "HEALER" }
|
||||||
|
|
||||||
|
DC.ROLE_LABEL = {
|
||||||
|
TANK = "Tanks",
|
||||||
|
MELEE = "Melee",
|
||||||
|
RANGED = "Ranged",
|
||||||
|
CASTER = "Casters",
|
||||||
|
HEALER = "Healers",
|
||||||
|
}
|
||||||
|
|
||||||
|
-- ------------------------------------------------------------------
|
||||||
|
-- Cell/data states. Hard rule: UNKNOWN is NEVER condensed to MISSING --
|
||||||
|
-- an unreadable player is "no data", not "has nothing".
|
||||||
|
-- ------------------------------------------------------------------
|
||||||
|
DC.STATE = {
|
||||||
|
HAS = "HAS",
|
||||||
|
MISSING = "MISSING",
|
||||||
|
UNKNOWN = "UNKNOWN",
|
||||||
|
NOTEXP = "NOTEXP",
|
||||||
|
}
|
||||||
|
|
||||||
|
-- ------------------------------------------------------------------
|
||||||
|
-- Tabs (order = display order). DEBUFFS' columns are built DYNAMICALLY
|
||||||
|
-- from the store (no data/slot table); every other tab has a static slot
|
||||||
|
-- table. DEBUFFS, RESIST and HIT are pure DISPLAY tabs -- no expectations,
|
||||||
|
-- no gaps, no sums, no whisper (see the slot tables below and
|
||||||
|
-- core/model.lua's per-tab branches). HIT sits between RESIST and
|
||||||
|
-- EQUIPMENT because it answers the same "how is this player set up"
|
||||||
|
-- question as the resistances, only for the attack side. ADDONS is the last
|
||||||
|
-- tab: it checks the raid's addons (scan/addons.lua), no expectations, no
|
||||||
|
-- whisper; a column with no reply after a query window is a red gap.
|
||||||
|
-- ------------------------------------------------------------------
|
||||||
|
DC.TABS = { "CONSUMABLES", "CLASSBUFFS", "DEBUFFS", "RESIST", "HIT", "EQUIPMENT",
|
||||||
|
"ADDONS" }
|
||||||
|
|
||||||
|
DC.TAB_LABEL = {
|
||||||
|
CONSUMABLES = "Consumables",
|
||||||
|
CLASSBUFFS = "Class buffs",
|
||||||
|
DEBUFFS = "Debuffs",
|
||||||
|
RESIST = "Resistances",
|
||||||
|
HIT = "Hit",
|
||||||
|
EQUIPMENT = "Equipment",
|
||||||
|
ADDONS = "Addons",
|
||||||
|
}
|
||||||
|
|
||||||
|
-- ------------------------------------------------------------------
|
||||||
|
-- RESIST tab slot table (static, unlike DEBUFFS' dynamic columns --
|
||||||
|
-- resistances are always the same five schools). All columns are ALWAYS
|
||||||
|
-- active; expectations are never consulted on this tab (display only, see
|
||||||
|
-- core/model.lua).
|
||||||
|
--
|
||||||
|
-- `school` is the UnitResistance() school index (0 = physical, 1 = holy --
|
||||||
|
-- both never queried here; 2..6 below).
|
||||||
|
-- ------------------------------------------------------------------
|
||||||
|
DC.SLOTS_RESIST = {
|
||||||
|
{ id = "RFIRE", label = "FIR", sub = "fire", full = "Fire resistance", kind = "resist", school = 2 },
|
||||||
|
{ id = "RNAT", label = "NAT", sub = "nature", full = "Nature resistance", kind = "resist", school = 3 },
|
||||||
|
{ id = "RFROST", label = "FRO", sub = "frost", full = "Frost resistance", kind = "resist", school = 4 },
|
||||||
|
{ id = "RSHAD", label = "SHA", sub = "shadow", full = "Shadow resistance", kind = "resist", school = 5 },
|
||||||
|
{ id = "RARC", label = "ARC", sub = "arcane", full = "Arcane resistance", kind = "resist", school = 6 },
|
||||||
|
}
|
||||||
|
|
||||||
|
-- ------------------------------------------------------------------
|
||||||
|
-- ADDONS tab slot table (static). One column per addon the raid leader
|
||||||
|
-- cares about. Other raiders do not run DopingControl, so every column is
|
||||||
|
-- detected only through what that addon itself sends or answers; `how`
|
||||||
|
-- and `limits` feed the column header tooltip. `sw` marks the SuperWoW
|
||||||
|
-- column, which has no version (yes/no).
|
||||||
|
-- ------------------------------------------------------------------
|
||||||
|
DC.SLOTS_ADDONS = {
|
||||||
|
{ id = "BIGWIGS", label = "BW", sub = "boss mod", full = "BigWigs", kind = "addon",
|
||||||
|
how = "Asks the raid with BigWigs' own version query; every BigWigs"
|
||||||
|
.. " client answers with its revision.",
|
||||||
|
limits = "Raid groups only (not party). Forks answer in their own"
|
||||||
|
.. " format; revisions are only compared within the same fork." },
|
||||||
|
{ id = "TWT", label = "THR", sub = "threat", full = "Threat Meter", kind = "addon",
|
||||||
|
how = "Asks the group with the TW Threatmeter 'who' query; each"
|
||||||
|
.. " client answers with its version.",
|
||||||
|
limits = "Other threat meters are not detected." },
|
||||||
|
{ id = "EPGP", label = "EPG", sub = "epgp", full = "EPGP", kind = "addon",
|
||||||
|
how = "shootyepgp announces its version when it loads, and answers the"
|
||||||
|
.. " SuperWoW query below, which proves it is installed.",
|
||||||
|
limits = "Guild members only. The version is only known if that"
|
||||||
|
.. " player logged in while you were listening." },
|
||||||
|
{ id = "SUPERWOW", label = "SW", sub = "superwow", full = "SuperWoW", kind = "addon",
|
||||||
|
how = "Asked via shootyepgp: its answer says whether SuperWoW is"
|
||||||
|
.. " present (yes/no).",
|
||||||
|
limits = "Guild members running shootyepgp only. The SuperWoW DLL"
|
||||||
|
.. " itself cannot be detected." },
|
||||||
|
{ id = "RINSE", label = "RIN", sub = "dispel", full = "Rinse", kind = "addon",
|
||||||
|
how = "Asks the group with the UpsiRinse version query; it answers"
|
||||||
|
.. " with its version.",
|
||||||
|
limits = "Only dispellers (Druid, Priest, Paladin, Shaman, Mage) are"
|
||||||
|
.. " checked, and only the UpsiRinse fork answers - the original"
|
||||||
|
.. " Rinse does not." },
|
||||||
|
}
|
||||||
|
|
||||||
|
-- ------------------------------------------------------------------
|
||||||
|
-- HIT tab slot table (static). Same display-only rules as RESIST: every
|
||||||
|
-- column is always active, expectations are never consulted, nothing is
|
||||||
|
-- ever MISSING and nothing is whispered -- a hit number below its cap is
|
||||||
|
-- information, not a gap to nag about.
|
||||||
|
--
|
||||||
|
-- The numbers behind these columns are read from item TOOLTIPS (per-item
|
||||||
|
-- lines plus set bonuses, counted once per set), from hit-granting AURAS,
|
||||||
|
-- and from TALENTS -- the own character's via GetTalentInfo, everyone
|
||||||
|
-- else's via this server's inspect protocol (scan/talents.lua). There is
|
||||||
|
-- no hit API on this client at all -- see data/hit.lua and
|
||||||
|
-- data/talenthit.lua. `hitKind` selects which number a column shows:
|
||||||
|
-- "melee" -- generic physical hit vs the yellow (special attack) cap
|
||||||
|
-- "ranged" -- the same generic gear hit against the RANGED cap, and only
|
||||||
|
-- for a player who actually carries a ranged attack weapon
|
||||||
|
-- "spell" -- spell hit vs the spell cap
|
||||||
|
--
|
||||||
|
-- WHY SIX SPELL COLUMNS INSTEAD OF ONE: spell hit is not one number. A
|
||||||
|
-- column's value is the GENERIC spell hit plus everything that only helps
|
||||||
|
-- THAT school -- school-specific item affixes and school-specific talents
|
||||||
|
-- (a mage's Arcane Focus, a priest's Shadow Focus, ...). Two casters of
|
||||||
|
-- the same class routinely differ per school, which is exactly what the
|
||||||
|
-- tab exists to show; folding them into one "spell hit" column would hide
|
||||||
|
-- the difference that decides whether a caster is capped.
|
||||||
|
--
|
||||||
|
-- `school` on a spell column is the LOWERCASE school id, shared with the
|
||||||
|
-- talent tables (data/talenthit.lua). Gear-parsed school extras are keyed
|
||||||
|
-- by the CAPITALIZED tooltip spelling instead ("Fire"), because they come
|
||||||
|
-- straight out of the tooltip text -- DC.HIT_SCHOOL_KEY (data/hit.lua)
|
||||||
|
-- bridges the two and DC_Hit.SchoolHit does the lookup for callers.
|
||||||
|
-- ------------------------------------------------------------------
|
||||||
|
DC.SLOTS_HIT = {
|
||||||
|
{ id = "MHIT", label = "MHT", sub = "melee", full = "Melee hit", kind = "hit", hitKind = "melee" },
|
||||||
|
{ id = "RHIT", label = "RNG", sub = "ranged", full = "Ranged hit", kind = "hit", hitKind = "ranged" },
|
||||||
|
{ id = "HARC", label = "ARC", sub = "arcane", full = "Arcane hit", kind = "hit", hitKind = "spell", school = "arcane" },
|
||||||
|
{ id = "HFIR", label = "FIR", sub = "fire", full = "Fire hit", kind = "hit", hitKind = "spell", school = "fire" },
|
||||||
|
{ id = "HFRO", label = "FRO", sub = "frost", full = "Frost hit", kind = "hit", hitKind = "spell", school = "frost" },
|
||||||
|
{ id = "HHOL", label = "HOL", sub = "holy", full = "Holy hit", kind = "hit", hitKind = "spell", school = "holy" },
|
||||||
|
{ id = "HNAT", label = "NAT", sub = "nature", full = "Nature hit", kind = "hit", hitKind = "spell", school = "nature" },
|
||||||
|
{ id = "HSHA", label = "SHA", sub = "shadow", full = "Shadow hit", kind = "hit", hitKind = "spell", school = "shadow" },
|
||||||
|
}
|
||||||
|
|
||||||
|
-- ------------------------------------------------------------------
|
||||||
|
-- Out-of-range threshold in yards:
|
||||||
|
-- a READABLE player with distance >= FAR_LIMIT gets rowVM.far = true and
|
||||||
|
-- counts into vm.coverage.far. Purely diagnostic -- far never changes any
|
||||||
|
-- cell state, sum, gap or pill (distance is never a filter).
|
||||||
|
-- ------------------------------------------------------------------
|
||||||
|
DC.FAR_LIMIT = 40
|
||||||
|
|
||||||
|
-- ------------------------------------------------------------------
|
||||||
|
-- Alternating-row wash ("zebra"): the alpha of DC.COLORS.zebra on every
|
||||||
|
-- SECOND data row of a table, so the eye can hold a line across a wide
|
||||||
|
-- grid. It has to stay well under the washes that carry MEANING -- the
|
||||||
|
-- unreadable-row wash sits at 0.25 -- or a striped row would read as a
|
||||||
|
-- state. One number, used by every table (matrix rows, both options
|
||||||
|
-- grids), so the whole tool stripes at the same strength.
|
||||||
|
-- ------------------------------------------------------------------
|
||||||
|
DC.ZEBRA_ALPHA = 0.035
|
||||||
|
|
||||||
|
-- ------------------------------------------------------------------
|
||||||
|
-- Color palette
|
||||||
|
-- Every entry is { r=<0..1>, g=<0..1>, b=<0..1> } (WoW SetTextColor /
|
||||||
|
-- SetVertexColor convention). Hex source noted per line.
|
||||||
|
-- ------------------------------------------------------------------
|
||||||
|
local function hexrgb(hex)
|
||||||
|
return {
|
||||||
|
r = tonumber(string.sub(hex, 1, 2), 16) / 255,
|
||||||
|
g = tonumber(string.sub(hex, 3, 4), 16) / 255,
|
||||||
|
b = tonumber(string.sub(hex, 5, 6), 16) / 255,
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
DC.COLORS = {
|
||||||
|
-- state colors (cell glyph / cell background / cell border)
|
||||||
|
hat = hexrgb("3fae5c"), -- HAS green
|
||||||
|
hatBg = hexrgb("152b1c"),
|
||||||
|
hatBorder = hexrgb("2b5e3a"),
|
||||||
|
fehlt = hexrgb("d05252"), -- MISSING red
|
||||||
|
fehltBg = hexrgb("341a1a"),
|
||||||
|
fehltBorder = hexrgb("6e3030"),
|
||||||
|
unbek = hexrgb("8b93a0"), -- UNKNOWN gray
|
||||||
|
unbekBg = hexrgb("22262e"),
|
||||||
|
unbekBorder = hexrgb("3b424e"),
|
||||||
|
skip = hexrgb("14181f"), -- not-expected cell bg
|
||||||
|
skipBorder = hexrgb("1d222b"),
|
||||||
|
skipGlyph = hexrgb("3a404c"),
|
||||||
|
|
||||||
|
-- gold family
|
||||||
|
gold = hexrgb("8f7440"), -- hover border gold
|
||||||
|
goldHi = hexrgb("e6c87e"), -- title / highlight gold
|
||||||
|
goldSoft = hexrgb("54462a"), -- frame accent borders
|
||||||
|
goldDim = hexrgb("a98f57"), -- secondary gold, links
|
||||||
|
|
||||||
|
-- panels / lines
|
||||||
|
panel = hexrgb("11141a"), -- frame bg
|
||||||
|
panel2 = hexrgb("171b23"), -- popover bg
|
||||||
|
panel3 = hexrgb("1d222c"), -- reserved
|
||||||
|
line = hexrgb("262c37"), -- standard borders
|
||||||
|
theadBg = hexrgb("141821"), -- sticky head/foot bg
|
||||||
|
void = hexrgb("0a0c10"), -- page background
|
||||||
|
-- alternating-row wash ("zebra"): the COLOR is plain white, the
|
||||||
|
-- subtlety lives entirely in DC.ZEBRA_ALPHA below -- a tinted stripe
|
||||||
|
-- would shift the hue of every cell it sits under.
|
||||||
|
zebra = hexrgb("ffffff"),
|
||||||
|
|
||||||
|
-- text
|
||||||
|
ink = hexrgb("c9cdd4"), -- body text
|
||||||
|
dim = hexrgb("868d99"), -- secondary text
|
||||||
|
faint = hexrgb("5b6270"), -- tertiary / hints
|
||||||
|
parch = hexrgb("d6cdb8"), -- parchment text
|
||||||
|
parchDim = hexrgb("9d9482"), -- parchment dim (tooltip detail)
|
||||||
|
|
||||||
|
-- role group colors
|
||||||
|
roleTANK = hexrgb("8fb3dd"),
|
||||||
|
roleMELEE = hexrgb("e2a75e"),
|
||||||
|
roleRANGED = hexrgb("7fcabe"),
|
||||||
|
roleCASTER = hexrgb("bd9ce8"),
|
||||||
|
roleHEALER = hexrgb("8fd6a0"),
|
||||||
|
|
||||||
|
-- role badge border colors
|
||||||
|
roleBorderTANK = hexrgb("3c5877"),
|
||||||
|
roleBorderMELEE = hexrgb("7a5c33"),
|
||||||
|
roleBorderRANGED = hexrgb("3a6b63"),
|
||||||
|
roleBorderCASTER = hexrgb("5e4a80"),
|
||||||
|
roleBorderHEALER = hexrgb("3c6c4a"),
|
||||||
|
|
||||||
|
-- class colors: OWN table -- RAID_CLASS_COLORS does NOT exist on 1.12.
|
||||||
|
-- Standard vanilla class colors, all 9 classes (the sim roster covers
|
||||||
|
-- all of them).
|
||||||
|
classWARRIOR = hexrgb("c79c6e"),
|
||||||
|
classPALADIN = hexrgb("f58cba"),
|
||||||
|
classHUNTER = hexrgb("abd473"),
|
||||||
|
classROGUE = hexrgb("fff569"),
|
||||||
|
classPRIEST = hexrgb("ffffff"),
|
||||||
|
classSHAMAN = hexrgb("0070de"),
|
||||||
|
classMAGE = hexrgb("69ccf0"),
|
||||||
|
classWARLOCK = hexrgb("9482c9"),
|
||||||
|
classDRUID = hexrgb("ff7d0a"),
|
||||||
|
|
||||||
|
-- trinket tile quality accents (WoW rare/3 and epic/4)
|
||||||
|
rareText = hexrgb("4aa3ff"),
|
||||||
|
rareBorder = hexrgb("1d5a9e"),
|
||||||
|
epicText = hexrgb("b06cf0"),
|
||||||
|
epicBorder = hexrgb("6a3f8f"),
|
||||||
|
|
||||||
|
-- green button / pill family + banners
|
||||||
|
btnGreenText = hexrgb("cfe0a8"),
|
||||||
|
btnGreenBg = hexrgb("26311e"),
|
||||||
|
btnGreenBorder = hexrgb("4c6236"),
|
||||||
|
bannerYellowBg = hexrgb("2b2413"),
|
||||||
|
bannerYellowBorder = hexrgb("6e5a26"),
|
||||||
|
bannerYellowText = hexrgb("e0c98a"),
|
||||||
|
bannerGreenBg = hexrgb("16240f"),
|
||||||
|
}
|
||||||
|
|
||||||
|
-- ------------------------------------------------------------------
|
||||||
|
-- Item quality colors, indices 0..6 as returned by GetItemQualityColor.
|
||||||
|
-- Own table so pure-Lua tests can run offline (GetItemQualityColor exists
|
||||||
|
-- in-game but not under lua50). Values = 1.12 FrameXML ITEM_QUALITY_COLORS.
|
||||||
|
-- ------------------------------------------------------------------
|
||||||
|
DC.QUALITY = {
|
||||||
|
[0] = { r = 0.65, g = 0.65, b = 0.65 }, -- poor (gray)
|
||||||
|
[1] = { r = 1.00, g = 1.00, b = 1.00 }, -- common (white)
|
||||||
|
[2] = { r = 0.10, g = 1.00, b = 0.00 }, -- uncommon (green)
|
||||||
|
[3] = { r = 0.00, g = 0.44, b = 0.87 }, -- rare (blue)
|
||||||
|
[4] = { r = 0.64, g = 0.21, b = 0.93 }, -- epic (purple)
|
||||||
|
[5] = { r = 1.00, g = 0.50, b = 0.00 }, -- legendary (orange)
|
||||||
|
[6] = { r = 0.90, g = 0.80, b = 0.50 }, -- artifact
|
||||||
|
}
|
||||||
@@ -0,0 +1,154 @@
|
|||||||
|
-- DopingControl core/demo.lua
|
||||||
|
-- Demo mode: the showcase view. It reuses test mode's simulated raid
|
||||||
|
-- (DC_Sim.buildStore -- no second roster exists) and lays its OWN
|
||||||
|
-- expectation table on top so ALL SIX school protection columns
|
||||||
|
-- (FR/FrR/NR/SR/HR/AR) are visible at once.
|
||||||
|
--
|
||||||
|
-- Two hard rules, both of them the reason this file exists at all:
|
||||||
|
--
|
||||||
|
-- 1. The demo layer NEVER writes into db.expectations. Live, five of the
|
||||||
|
-- six school columns ship with no seed entry (data/expectations.lua)
|
||||||
|
-- and are therefore hidden -- that is a deliberate default, and a
|
||||||
|
-- screenshot mode must not rewrite the user's saved matrix to get its
|
||||||
|
-- picture. BuildDemoExpectations deep-copies every line it starts
|
||||||
|
-- from, so no table is ever shared with the DB either.
|
||||||
|
-- 2. Demo state is TRANSIENT -- DC.demoMode / DC.demoExpectations live in
|
||||||
|
-- memory only and are gone after a reload. Deliberately unlike
|
||||||
|
-- db.testMode (which is persisted): the same treatment DC.simRoles
|
||||||
|
-- gets in core/roles.lua, and the BulwarkFrame /bf demo pattern.
|
||||||
|
--
|
||||||
|
-- Why the columns must be forced per (role, class) and not per role: the
|
||||||
|
-- model resolves a row through expect[role][class] and falls back to the
|
||||||
|
-- DC.DEFAULT_EXPECT ROLE line whenever that class line is missing
|
||||||
|
-- (core/model.lua, expectLine) -- a partially filled demo table would fall
|
||||||
|
-- back into the seed and silently lose the school columns again. The same
|
||||||
|
-- fallback drives the dynamic-column check (anyClassExpects), so an
|
||||||
|
-- incomplete layer would also drop the columns from the header.
|
||||||
|
--
|
||||||
|
-- FR is forced too, although the seed already ships it true for all five
|
||||||
|
-- roles: the user may have unchecked it, and "all six schools" is the
|
||||||
|
-- point of the mode.
|
||||||
|
--
|
||||||
|
-- Pure Lua 5.0, no WoW API -- dofile-loadable offline.
|
||||||
|
|
||||||
|
DopingControl = DopingControl or {}
|
||||||
|
local DC = DopingControl
|
||||||
|
|
||||||
|
-- ------------------------------------------------------------------
|
||||||
|
-- Transient state (NEVER SavedVariables -- see rule 2 above).
|
||||||
|
-- ------------------------------------------------------------------
|
||||||
|
DC.demoMode = false
|
||||||
|
DC.demoExpectations = nil
|
||||||
|
|
||||||
|
-- DC.demoMode is the demo half of DC.SimOwnsStore (core/config.lua) -- the
|
||||||
|
-- predicate every scan gate asks. It deliberately lives in config.lua, not
|
||||||
|
-- here: config.lua loads second, ahead of every gate, so a broken demo
|
||||||
|
-- module can never take TEST mode's protection down with it.
|
||||||
|
|
||||||
|
-- The six school protection columns of the consumables slot table
|
||||||
|
-- (data/consumables.lua). FR is the only one with a seed entry.
|
||||||
|
DC.DEMO_SCHOOL_SLOTS = { "FR", "FrR", "NR", "SR", "HR", "AR" }
|
||||||
|
|
||||||
|
local function forceSchools(line)
|
||||||
|
local n = table.getn(DC.DEMO_SCHOOL_SLOTS)
|
||||||
|
for i = 1, n do
|
||||||
|
line[DC.DEMO_SCHOOL_SLOTS[i]] = true
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
-- ------------------------------------------------------------------
|
||||||
|
-- DC.BuildDemoExpectations(db) -> demo table (also cached in
|
||||||
|
-- DC.demoExpectations)
|
||||||
|
--
|
||||||
|
-- Shape is exactly the live one -- demo[role][class][slotId] -- built per
|
||||||
|
-- (role, performing class) over DC_Roles.ROLE_CLASSES, so it can be handed
|
||||||
|
-- to DC_Model.build in place of db.expectations without any special case
|
||||||
|
-- in the model.
|
||||||
|
--
|
||||||
|
-- Source per class line, in order: the user's saved line, else the
|
||||||
|
-- DC.DEFAULT_EXPECT seed line -- deep-copied either way, then the six
|
||||||
|
-- school slots forced on. A legacy old-shape role line (booleans directly
|
||||||
|
-- under [role], migrated away by DC.EnsureExpectations at ADDON_LOADED)
|
||||||
|
-- is not a table per class and therefore falls back to the seed; demo mode
|
||||||
|
-- showing seed defaults for one session is acceptable, writing into the DB
|
||||||
|
-- to avoid it is not.
|
||||||
|
-- ------------------------------------------------------------------
|
||||||
|
function DC.BuildDemoExpectations(db)
|
||||||
|
local src = db and db.expectations
|
||||||
|
local out = {}
|
||||||
|
local nRoles = table.getn(DC.ROLES)
|
||||||
|
for r = 1, nRoles do
|
||||||
|
local role = DC.ROLES[r]
|
||||||
|
local seedLine = (DC.DEFAULT_EXPECT and DC.DEFAULT_EXPECT[role]) or {}
|
||||||
|
local savedByClass = src and src[role]
|
||||||
|
local classes = (DC_Roles and DC_Roles.ROLE_CLASSES
|
||||||
|
and DC_Roles.ROLE_CLASSES[role]) or {}
|
||||||
|
local nClasses = table.getn(classes)
|
||||||
|
if nClasses > 0 then
|
||||||
|
local byClass = {}
|
||||||
|
for c = 1, nClasses do
|
||||||
|
local cls = classes[c]
|
||||||
|
local saved = savedByClass and savedByClass[cls]
|
||||||
|
local line
|
||||||
|
if type(saved) == "table" then
|
||||||
|
line = DC.DeepCopy(saved)
|
||||||
|
else
|
||||||
|
line = DC.DeepCopy(seedLine)
|
||||||
|
end
|
||||||
|
forceSchools(line)
|
||||||
|
byClass[cls] = line
|
||||||
|
end
|
||||||
|
out[role] = byClass
|
||||||
|
else
|
||||||
|
-- defensive: without DC_Roles there is no class domain to fill,
|
||||||
|
-- so emit the legacy role x slot line (the model falls back to
|
||||||
|
-- the seed for the cells, but the header tooltip and any
|
||||||
|
-- legacy reader still see the schools)
|
||||||
|
local line = DC.DeepCopy(seedLine)
|
||||||
|
forceSchools(line)
|
||||||
|
out[role] = line
|
||||||
|
end
|
||||||
|
end
|
||||||
|
DC.demoExpectations = out
|
||||||
|
return out
|
||||||
|
end
|
||||||
|
|
||||||
|
-- ------------------------------------------------------------------
|
||||||
|
-- DC.SetDemoMode(v) -- the switch behind /dc demo and the options button.
|
||||||
|
-- Mirrors DC.SetTestMode (core/init.lua) but touches no SavedVariables.
|
||||||
|
--
|
||||||
|
-- Store handling: entering demo mode swaps in the simulated raid. Leaving
|
||||||
|
-- it hands the store back to the REAL scan -- unless db.testMode is on,
|
||||||
|
-- which owns the simulated store on its own account and must not be kicked
|
||||||
|
-- out of it. DC.SetTestMode mirrors that in the other direction, so neither
|
||||||
|
-- mode can empty the other's showcase; both ask DC.SimOwnsStore
|
||||||
|
-- (core/config.lua), which is also what every scan gate now asks instead
|
||||||
|
-- of db.testMode.
|
||||||
|
-- ------------------------------------------------------------------
|
||||||
|
function DC.SetDemoMode(v)
|
||||||
|
local db = DC.db or DopingControlDB
|
||||||
|
if v then
|
||||||
|
DC.demoMode = true
|
||||||
|
DC.BuildDemoExpectations(db)
|
||||||
|
if DC_Sim and DC_Sim.buildStore then
|
||||||
|
-- buildStore() also refills the transient DC.simRoles -- do NOT
|
||||||
|
-- wipe them afterwards (see DC.SetTestMode)
|
||||||
|
DC.store = DC_Sim.buildStore()
|
||||||
|
end
|
||||||
|
else
|
||||||
|
DC.demoMode = false
|
||||||
|
DC.demoExpectations = nil
|
||||||
|
if not (db and db.testMode) then
|
||||||
|
DC.store = { players = {}, source = "scan" }
|
||||||
|
if DC_Scan and DC_Scan.Start then
|
||||||
|
DC_Scan.Start(true) -- leaving demo mode: immediate rescan
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
if DC_Matrix and DC_Matrix.Refresh then
|
||||||
|
DC_Matrix.Refresh()
|
||||||
|
end
|
||||||
|
if DC_Options and DC_Options.Refresh then
|
||||||
|
DC_Options.Refresh() -- relabels the options button when it is open
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -0,0 +1,365 @@
|
|||||||
|
-- DopingControl core/init.lua
|
||||||
|
-- Bootstrap: ADDON_LOADED wiring (SavedVariables defaults), event
|
||||||
|
-- registration (roster changes, READY_CHECK), test-mode switch, slash
|
||||||
|
-- commands /dc + /doping. Loads LAST in the TOC -- every module global
|
||||||
|
-- (DC_Scan, DC_Matrix, DC_Options, DC_Report, DC_Sim, DC_Probe) exists by
|
||||||
|
-- the time any function here runs.
|
||||||
|
--
|
||||||
|
-- Pure-Lua 5.0 dofile-loadable: all WoW wiring behind "if CreateFrame then".
|
||||||
|
-- The pure parts (DC.HandleSlash, DC.SetTestMode, DC.DumpUnknown) are
|
||||||
|
-- offline-tested with stubbed modules.
|
||||||
|
|
||||||
|
DopingControl = DopingControl or {}
|
||||||
|
local DC = DopingControl
|
||||||
|
|
||||||
|
-- Store (the shared data shape every consumer reads). Empty until the
|
||||||
|
-- first scan finishes or test mode injects the simulated store.
|
||||||
|
DC.store = DC.store or { players = {}, source = "scan" }
|
||||||
|
|
||||||
|
-- Unknown-aura tally, filled by scan/aura.lua (DC.unknownSeen[name] = count);
|
||||||
|
-- read here by /dc unknown (unknown buff names are never silently discarded).
|
||||||
|
DC.unknownSeen = DC.unknownSeen or {}
|
||||||
|
|
||||||
|
local CHAT_PREFIX = "|cff3fae5cDopingControl|r: "
|
||||||
|
|
||||||
|
function DC.Print(msg)
|
||||||
|
if DEFAULT_CHAT_FRAME then
|
||||||
|
DEFAULT_CHAT_FRAME:AddMessage(CHAT_PREFIX .. tostring(msg))
|
||||||
|
else
|
||||||
|
print(CHAT_PREFIX .. tostring(msg)) -- offline Lua 5.0
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Called by scan/engine.lua whenever the store changed (scan finished).
|
||||||
|
function DC.OnStoreUpdated()
|
||||||
|
DC_Matrix.Refresh()
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Matrix visibility, used to decide whether a roster change should trigger
|
||||||
|
-- an immediate rescan. DC_Matrix.IsShown is not pinned in the plan -- if
|
||||||
|
-- ui/matrix.lua does not provide it, we conservatively treat the window as
|
||||||
|
-- hidden (next manual scan / ready check rescans anyway).
|
||||||
|
local function MatrixIsShown()
|
||||||
|
if DC_Matrix and DC_Matrix.IsShown then
|
||||||
|
return DC_Matrix.IsShown()
|
||||||
|
end
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
|
||||||
|
-- ------------------------------------------------------------------
|
||||||
|
-- Test mode: the simulator fully replaces the data source.
|
||||||
|
-- ------------------------------------------------------------------
|
||||||
|
function DC.SetTestMode(v)
|
||||||
|
local db = DC.db or DopingControlDB
|
||||||
|
db.testMode = v
|
||||||
|
if v then
|
||||||
|
-- buildStore() itself resets and refills DC.simRoles with the
|
||||||
|
-- built-in confirmations (DC_Sim.ROLES, core/sim.lua) -- do NOT wipe
|
||||||
|
-- DC.simRoles afterwards, that would erase them and turn every row
|
||||||
|
-- into a suggestion badge (the sim roster deliberately contains
|
||||||
|
-- exactly two suggestion-badge cases).
|
||||||
|
DC.store = DC_Sim.buildStore()
|
||||||
|
elseif DC.demoMode then
|
||||||
|
-- Demo mode owns the simulated store on its own account
|
||||||
|
-- (DC.SimOwnsStore) -- the mirror image of core/demo.lua's
|
||||||
|
-- "leaving demo mode must not kick test mode out of the sim
|
||||||
|
-- store". Without this the showcase would be emptied here and the
|
||||||
|
-- rescan below would be dropped by the scan gate anyway, leaving a
|
||||||
|
-- blank window. /dc demo is what ends demo mode.
|
||||||
|
DC.store = DC_Sim.buildStore()
|
||||||
|
else
|
||||||
|
DC.store = { players = {}, source = "scan" }
|
||||||
|
DC_Scan.Start(true) -- leaving test mode: immediate real rescan
|
||||||
|
end
|
||||||
|
DC_Matrix.Refresh()
|
||||||
|
end
|
||||||
|
|
||||||
|
-- ------------------------------------------------------------------
|
||||||
|
-- The pre-raid check, automated.
|
||||||
|
--
|
||||||
|
-- db.testMode is persisted (core/config.lua DEFAULTS), so it survives a
|
||||||
|
-- reload and a relog: a session that ends with the simulated raid on
|
||||||
|
-- screen starts the next one showing it again, and from then on the
|
||||||
|
-- matrix grades a roster that does not exist. On screen the only marker
|
||||||
|
-- is the source banner, and a "MISSING" wall reads the same either way --
|
||||||
|
-- so the failure mode is not a visible error, it is a plausible wrong
|
||||||
|
-- answer, which is the worst kind for a raid buff checker.
|
||||||
|
--
|
||||||
|
-- Remembering to check before every raid is not a routine anyone keeps.
|
||||||
|
-- The addon does it instead, at the two moments it matters: once per
|
||||||
|
-- login/reload, and again when a group forms.
|
||||||
|
--
|
||||||
|
-- DC.SimModeReminder(db, demoMode) -> string|nil
|
||||||
|
-- The line to print, or nil when neither simulated mode owns the
|
||||||
|
-- store. Pure -- the caller prints it, so it stays offline-testable.
|
||||||
|
-- Test mode is named first because it is the persisted one: the mode
|
||||||
|
-- that comes back on its own is the one this exists for. Demo mode is
|
||||||
|
-- transient (core/demo.lua) and cannot survive a reload, but it can
|
||||||
|
-- still be on when the raid forms, so it gets the same reminder with
|
||||||
|
-- its own switch named -- naming the wrong switch would send the user
|
||||||
|
-- to a toggle that changes nothing.
|
||||||
|
-- db/demoMode are optional; the fallback matches DC.SimOwnsStore.
|
||||||
|
-- ------------------------------------------------------------------
|
||||||
|
function DC.SimModeReminder(db, demoMode)
|
||||||
|
db = db or DC.db or DopingControlDB
|
||||||
|
if db and db.testMode then
|
||||||
|
return "test mode is ON - the matrix shows the simulated raid, not your group (/dc test to leave)"
|
||||||
|
end
|
||||||
|
if demoMode == nil then
|
||||||
|
demoMode = DC.demoMode
|
||||||
|
end
|
||||||
|
if demoMode then
|
||||||
|
return "demo mode is ON - the matrix shows the simulated raid, not your group (/dc demo to leave)"
|
||||||
|
end
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
|
||||||
|
-- ------------------------------------------------------------------
|
||||||
|
-- DC.NoteGroupSize(n) -> bool
|
||||||
|
--
|
||||||
|
-- Edge detector for "a group just formed": true only on the 0 -> >0
|
||||||
|
-- transition. The roster events fire once per member, so a 40-man raid
|
||||||
|
-- filling up would otherwise print the reminder 39 times. Pure apart
|
||||||
|
-- from its own last-seen counter, which starts at 0 (solo).
|
||||||
|
-- ------------------------------------------------------------------
|
||||||
|
local lastGroupSize = 0
|
||||||
|
|
||||||
|
function DC.NoteGroupSize(n)
|
||||||
|
n = n or 0
|
||||||
|
local joined = (lastGroupSize == 0 and n > 0)
|
||||||
|
lastGroupSize = n
|
||||||
|
return joined
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Raid count first, party count only when it is 0: the two calls do not
|
||||||
|
-- agree on what "in a party" means once a raid exists, and the raid
|
||||||
|
-- answer is the unambiguous one. Returns 0 offline (no WoW API).
|
||||||
|
local function GroupSize()
|
||||||
|
local n = GetNumRaidMembers and GetNumRaidMembers() or 0
|
||||||
|
if n and n > 0 then
|
||||||
|
return n
|
||||||
|
end
|
||||||
|
n = GetNumPartyMembers and GetNumPartyMembers() or 0
|
||||||
|
return n or 0
|
||||||
|
end
|
||||||
|
|
||||||
|
-- ------------------------------------------------------------------
|
||||||
|
-- /dc unknown -- dump DC.unknownSeen via SuperWoW ExportFile.
|
||||||
|
-- ------------------------------------------------------------------
|
||||||
|
function DC.DumpUnknown()
|
||||||
|
local names = {}
|
||||||
|
local distinct = 0
|
||||||
|
for name in pairs(DC.unknownSeen) do
|
||||||
|
table.insert(names, name)
|
||||||
|
distinct = distinct + 1
|
||||||
|
end
|
||||||
|
if distinct == 0 then
|
||||||
|
DC.Print("no unknown buffs recorded")
|
||||||
|
return
|
||||||
|
end
|
||||||
|
table.sort(names, function(a, b)
|
||||||
|
local ca, cb = DC.unknownSeen[a], DC.unknownSeen[b]
|
||||||
|
if ca ~= cb then return ca > cb end
|
||||||
|
return a < b
|
||||||
|
end)
|
||||||
|
local lines = { "DopingControl unknown buffs (" .. distinct .. " names)" }
|
||||||
|
for i = 1, table.getn(names) do
|
||||||
|
table.insert(lines, string.format("%dx %s", DC.unknownSeen[names[i]], names[i]))
|
||||||
|
end
|
||||||
|
local text = table.concat(lines, "\n")
|
||||||
|
if ExportFile then
|
||||||
|
-- SuperWoW appends ".txt" itself -- pass the name WITHOUT extension.
|
||||||
|
-- pcall: optional-DLL call -- an I/O error must not
|
||||||
|
-- escape into the slash handler.
|
||||||
|
local okE = pcall(ExportFile, "dc_unknown", text)
|
||||||
|
if okE then
|
||||||
|
DC.Print(distinct .. " unknown buff names dumped to dc_unknown.txt (WoW Imports folder)")
|
||||||
|
else
|
||||||
|
DC.Print("ExportFile failed - no dump written")
|
||||||
|
end
|
||||||
|
else
|
||||||
|
DC.Print("ExportFile unavailable (SuperWoW required) - no dump written")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
-- ------------------------------------------------------------------
|
||||||
|
-- Slash commands: /dc, /doping
|
||||||
|
-- ------------------------------------------------------------------
|
||||||
|
local HELP_TEXT = "commands: /dc (window) | scan | options | report | test | demo | probe [range <name>] | unknown | talents [on|off|clear]"
|
||||||
|
|
||||||
|
function DC.HandleSlash(msg)
|
||||||
|
msg = msg or ""
|
||||||
|
local _, _, word, rest = string.find(msg, "^%s*(%S*)%s*(.-)%s*$")
|
||||||
|
local cmd = string.lower(word or "")
|
||||||
|
local db = DC.db or DopingControlDB
|
||||||
|
|
||||||
|
if cmd == "" then
|
||||||
|
DC_Matrix.Toggle()
|
||||||
|
elseif cmd == "scan" then
|
||||||
|
-- Real data never beats the simulation while EITHER simulated mode
|
||||||
|
-- owns the store (DC.SimOwnsStore, core/config.lua). Test mode is
|
||||||
|
-- named first because it is the persisted one -- but the refusal
|
||||||
|
-- must name the mode that is actually on, or the user toggles the
|
||||||
|
-- wrong switch and the scan keeps refusing.
|
||||||
|
if db and db.testMode then
|
||||||
|
DC.Print("test mode is on - /dc test to switch back before scanning")
|
||||||
|
elseif DC.SimOwnsStore(db) then
|
||||||
|
DC.Print("demo mode is on - /dc demo to switch back before scanning")
|
||||||
|
else
|
||||||
|
DC_Scan.Start()
|
||||||
|
end
|
||||||
|
elseif cmd == "options" then
|
||||||
|
DC_Options.Toggle()
|
||||||
|
elseif cmd == "report" then
|
||||||
|
DC_Report.Open()
|
||||||
|
elseif cmd == "test" then
|
||||||
|
DC.SetTestMode(not (db and db.testMode))
|
||||||
|
if db and db.testMode then
|
||||||
|
DC.Print("test mode ON - simulated raid, chat output disabled")
|
||||||
|
elseif DC.demoMode then
|
||||||
|
-- SetTestMode kept the sim store for demo mode and started no
|
||||||
|
-- rescan; a "rescanning" message here would be false.
|
||||||
|
DC.Print("test mode OFF - demo mode still on, still showing the simulated raid (/dc demo to leave)")
|
||||||
|
else
|
||||||
|
DC.Print("test mode OFF - rescanning the real group")
|
||||||
|
end
|
||||||
|
elseif cmd == "demo" then
|
||||||
|
-- Showcase view: the simulated raid plus a TRANSIENT expectation
|
||||||
|
-- layer that shows all six school protection columns at once
|
||||||
|
-- (core/demo.lua). Nothing about it is saved -- unlike /dc test,
|
||||||
|
-- which persists db.testMode.
|
||||||
|
if not DC.SetDemoMode then
|
||||||
|
DC.Print("demo module not loaded")
|
||||||
|
else
|
||||||
|
DC.SetDemoMode(not DC.demoMode)
|
||||||
|
if DC.demoMode then
|
||||||
|
DC.Print("demo mode ON - simulated raid, all six protection columns shown")
|
||||||
|
else
|
||||||
|
DC.Print("demo mode OFF - back to the normal view")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
elseif cmd == "probe" then
|
||||||
|
-- core/probe.lua owns the probe subcommand grammar:
|
||||||
|
-- DC_Probe.HandleSlash(rest) handles "" (battery),
|
||||||
|
-- "range <name>" (1 Hz run) and the range-run stop toggle.
|
||||||
|
DC_Probe.HandleSlash(rest)
|
||||||
|
elseif cmd == "unknown" then
|
||||||
|
DC.DumpUnknown()
|
||||||
|
elseif cmd == "talents" then
|
||||||
|
-- foreign talents come in over this server's inspect protocol
|
||||||
|
-- (scan/talents.lua). "on"/"off" is a real switch, not cosmetic:
|
||||||
|
-- asking makes OTHER players' clients send their tree back, so it
|
||||||
|
-- must be possible to stop doing that.
|
||||||
|
if not DC_Talents then
|
||||||
|
DC.Print("talent module not loaded")
|
||||||
|
elseif rest == "on" or rest == "off" then
|
||||||
|
DopingControlDB.readTalents = (rest == "on")
|
||||||
|
DC.Print("talent reading " .. rest)
|
||||||
|
if rest == "off" and DC_Talents.Clear then
|
||||||
|
DC_Talents.Clear()
|
||||||
|
end
|
||||||
|
elseif rest == "clear" then
|
||||||
|
DC_Talents.Clear()
|
||||||
|
DC.Print("talent cache cleared - they will be asked again")
|
||||||
|
else
|
||||||
|
local s = DC_Talents.Stats()
|
||||||
|
DC.Print("talents: " .. s.known .. " known, " .. s.pending
|
||||||
|
.. " mid-reply, " .. s.queued .. " queued, "
|
||||||
|
.. (s.enabled and "ON" or "OFF")
|
||||||
|
.. " (/dc talents on|off|clear)")
|
||||||
|
end
|
||||||
|
else
|
||||||
|
DC.Print(HELP_TEXT)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
-- ------------------------------------------------------------------
|
||||||
|
-- WoW wiring
|
||||||
|
-- ------------------------------------------------------------------
|
||||||
|
if CreateFrame then
|
||||||
|
SLASH_DOPINGCONTROL1 = "/dc"
|
||||||
|
SLASH_DOPINGCONTROL2 = "/doping"
|
||||||
|
SlashCmdList["DOPINGCONTROL"] = DC.HandleSlash
|
||||||
|
|
||||||
|
-- Matrix toolbar hooks (ui/matrix.lua fires MX.hooks.*): the Options
|
||||||
|
-- and Report buttons fall back to DC_Options/DC_Report directly, but
|
||||||
|
-- the Scan button ONLY fires MX.hooks.scan -- wire it here through the
|
||||||
|
-- slash path so it gets the same test-mode gate as "/dc scan".
|
||||||
|
if DC_Matrix and DC_Matrix.hooks then
|
||||||
|
DC_Matrix.hooks.scan = function()
|
||||||
|
DC.HandleSlash("scan")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
local ev = CreateFrame("Frame", "DopingControlInitEventFrame", UIParent)
|
||||||
|
ev:RegisterEvent("ADDON_LOADED")
|
||||||
|
ev:SetScript("OnEvent", function()
|
||||||
|
if event == "ADDON_LOADED" then
|
||||||
|
if arg1 ~= "DopingControl" then return end
|
||||||
|
ev:UnregisterEvent("ADDON_LOADED")
|
||||||
|
|
||||||
|
-- SavedVariables are in place now.
|
||||||
|
DopingControlDB = DopingControlDB or {}
|
||||||
|
DC.EnsureDefaults(DopingControlDB) -- also deep-copies
|
||||||
|
-- DC.DEFAULT_EXPECT into db.expectations on first load
|
||||||
|
-- (core/config.lua handles that inside EnsureDefaults).
|
||||||
|
DC.db = DopingControlDB
|
||||||
|
|
||||||
|
-- Roster changes invalidate the store; rescan only when the
|
||||||
|
-- window is up (otherwise the next scan re-reads the roster
|
||||||
|
-- anyway -- scan/engine.lua re-walks it on every Start()).
|
||||||
|
ev:RegisterEvent("RAID_ROSTER_UPDATE")
|
||||||
|
ev:RegisterEvent("PARTY_MEMBERS_CHANGED")
|
||||||
|
|
||||||
|
-- READY_CHECK: registered defensively -- it is unverified
|
||||||
|
-- whether this event fires natively on 1.12/TurtleWoW, so a
|
||||||
|
-- rejecting client must not break the bootstrap.
|
||||||
|
pcall(ev.RegisterEvent, ev, "READY_CHECK")
|
||||||
|
|
||||||
|
-- Persisted test mode survives reloads: restore the simulated
|
||||||
|
-- store so window content and db.testMode stay consistent.
|
||||||
|
if DC.db.testMode then
|
||||||
|
DC.store = DC_Sim.buildStore()
|
||||||
|
end
|
||||||
|
|
||||||
|
-- ...and say so. Not here though: chat output before
|
||||||
|
-- PLAYER_ENTERING_WORLD is silently dropped (verified), so
|
||||||
|
-- the reminder waits for that event.
|
||||||
|
ev:RegisterEvent("PLAYER_ENTERING_WORLD")
|
||||||
|
|
||||||
|
elseif event == "PLAYER_ENTERING_WORLD" then
|
||||||
|
-- Fires on every loading screen, not just login -- once is
|
||||||
|
-- the point, so this unregisters itself. The group-join
|
||||||
|
-- reminder below covers the rest of the session.
|
||||||
|
ev:UnregisterEvent("PLAYER_ENTERING_WORLD")
|
||||||
|
local warn = DC.SimModeReminder(DC.db)
|
||||||
|
if warn then
|
||||||
|
DC.Print(warn)
|
||||||
|
end
|
||||||
|
|
||||||
|
elseif event == "RAID_ROSTER_UPDATE" or event == "PARTY_MEMBERS_CHANGED" then
|
||||||
|
local db = DC.db
|
||||||
|
-- A group just formed: the last moment before a raid where
|
||||||
|
-- a forgotten simulated mode is still cheap to notice.
|
||||||
|
if DC.NoteGroupSize(GroupSize()) then
|
||||||
|
local warn = DC.SimModeReminder(db)
|
||||||
|
if warn then
|
||||||
|
DC.Print(warn)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
if db and not DC.SimOwnsStore(db) and MatrixIsShown() then
|
||||||
|
DC_Scan.Start()
|
||||||
|
end
|
||||||
|
|
||||||
|
elseif event == "READY_CHECK" then
|
||||||
|
-- The auto-SCAN on ready check is owned by scan/engine.lua
|
||||||
|
-- (single owner) -- registering the Start
|
||||||
|
-- here too fired a duplicate full scan per ready check. This
|
||||||
|
-- handler only does the auto-OPEN half.
|
||||||
|
local db = DC.db
|
||||||
|
if db and db.readyCheckOpen then
|
||||||
|
DC_Matrix.Show()
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end)
|
||||||
|
end
|
||||||
@@ -0,0 +1,534 @@
|
|||||||
|
-- DopingControl core/probe.lua
|
||||||
|
-- Feasibility probe: dumps RAW API returns to dc_probe.txt (in the WoW
|
||||||
|
-- client's Imports folder) via SuperWoW ExportFile so raw facts can be read
|
||||||
|
-- from disk instead of retyped from chat lines.
|
||||||
|
--
|
||||||
|
-- Entry points (core/init.lua wires the slash command):
|
||||||
|
-- DC_Probe.HandleSlash(rest) -- rest = text after "probe":
|
||||||
|
-- "" -> Battery() (part 2, near-range questions)
|
||||||
|
-- "range <name>" -> RangeStart(name) / RangeStop() toggle (part 1)
|
||||||
|
-- DC_Probe.Battery()
|
||||||
|
-- DC_Probe.RangeStart(playerName) / DC_Probe.RangeStop()
|
||||||
|
-- DC_Probe.IsRangeRunning()
|
||||||
|
--
|
||||||
|
-- Part 1 (range run): a raid member with known buffs walks away; a 1 Hz
|
||||||
|
-- poller writes one line per tick:
|
||||||
|
-- dist | UnitExists | UnitIsVisible | nA=count(UnitBuff) | 4th-return |
|
||||||
|
-- nB=count(GetUnitField) | UnitPosition
|
||||||
|
-- The unit token is RE-RESOLVED from the name every tick (raid indices are
|
||||||
|
-- volatile after roster changes).
|
||||||
|
--
|
||||||
|
-- Part 2 (battery) answers five open client-behavior questions with raw data:
|
||||||
|
-- Q1 does UnitBuff(unit, i) return a 4th value ~= nil (spell id)?
|
||||||
|
-- Q2 does GetUnitField(guid,"aura") match the tooltip names; is the
|
||||||
|
-- slot split really 1-32 buffs / 33-48 debuffs?
|
||||||
|
-- Q3 does GetWeaponEnchantInfo(unit) name a foreign temp enchant?
|
||||||
|
-- Q4 does GetInventoryItemLink(unit, slot) serve out-of-sight units?
|
||||||
|
-- Q5 is the ENCHANT field in foreign item links filled (~= 0)?
|
||||||
|
--
|
||||||
|
-- File mechanics: ExportFile appends ".txt" ITSELF -- the name is passed
|
||||||
|
-- WITHOUT extension ("dc_probe", never "dc_probe.txt"). ExportFile is a
|
||||||
|
-- whole-file overwrite, so the probe keeps an in-memory ring of lines and
|
||||||
|
-- rewrites the complete file on every flush.
|
||||||
|
--
|
||||||
|
-- Pure Lua 5.0, dofile-loadable offline (all WoW usage inside functions /
|
||||||
|
-- existence-checked; the poller frame is created lazily in-game).
|
||||||
|
|
||||||
|
DopingControl = DopingControl or {}
|
||||||
|
local DC = DopingControl
|
||||||
|
|
||||||
|
DC_Probe = DC_Probe or {}
|
||||||
|
local P = DC_Probe
|
||||||
|
|
||||||
|
P.RING_CAP = 2000 -- max lines kept; oldest dropped first
|
||||||
|
P.EXPORT_NAME = "dc_probe" -- NO extension (ExportFile appends .txt)
|
||||||
|
P.BATTERY_UNIT_CAP = 8 -- full detail for at most N foreign units
|
||||||
|
|
||||||
|
local ring = {}
|
||||||
|
|
||||||
|
-- ------------------------------------------------------------------
|
||||||
|
-- ring + flush
|
||||||
|
-- ------------------------------------------------------------------
|
||||||
|
function P.Clear()
|
||||||
|
ring = {}
|
||||||
|
end
|
||||||
|
|
||||||
|
function P.Push(line)
|
||||||
|
table.insert(ring, line)
|
||||||
|
while table.getn(ring) > P.RING_CAP do
|
||||||
|
table.remove(ring, 1)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
function P.Text()
|
||||||
|
return table.concat(ring, "\n")
|
||||||
|
end
|
||||||
|
|
||||||
|
local function say(msg)
|
||||||
|
if DEFAULT_CHAT_FRAME then
|
||||||
|
DEFAULT_CHAT_FRAME:AddMessage("|cffe6c87eDopingControl|r probe: " .. msg)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
function P.Flush()
|
||||||
|
if ExportFile then
|
||||||
|
-- name WITHOUT extension -- ExportFile appends .txt itself.
|
||||||
|
-- pcall: optional-DLL call -- a DLL-side I/O error
|
||||||
|
-- must not escape into the slash handler or the 1 Hz range ticker.
|
||||||
|
local ok = pcall(ExportFile, P.EXPORT_NAME, P.Text())
|
||||||
|
if not ok then
|
||||||
|
say("ExportFile failed - dump not written")
|
||||||
|
return false
|
||||||
|
end
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
say("ExportFile missing (SuperWoW required) - dump not written")
|
||||||
|
return false
|
||||||
|
end
|
||||||
|
|
||||||
|
-- ------------------------------------------------------------------
|
||||||
|
-- formatting helpers
|
||||||
|
-- ------------------------------------------------------------------
|
||||||
|
local function fmt(v)
|
||||||
|
if v == nil then
|
||||||
|
return "nil"
|
||||||
|
end
|
||||||
|
if v == true then
|
||||||
|
return "true"
|
||||||
|
end
|
||||||
|
if v == false then
|
||||||
|
return "false"
|
||||||
|
end
|
||||||
|
if type(v) == "number" then
|
||||||
|
return tostring(v)
|
||||||
|
end
|
||||||
|
if type(v) == "string" then
|
||||||
|
return v
|
||||||
|
end
|
||||||
|
return type(v)
|
||||||
|
end
|
||||||
|
|
||||||
|
local function gtype(name)
|
||||||
|
local v = getglobal and getglobal(name)
|
||||||
|
if v == nil then
|
||||||
|
return "nil"
|
||||||
|
end
|
||||||
|
return type(v)
|
||||||
|
end
|
||||||
|
|
||||||
|
local function nowStamp()
|
||||||
|
if date then
|
||||||
|
return date("%Y-%m-%d %H:%M:%S")
|
||||||
|
end
|
||||||
|
return "?"
|
||||||
|
end
|
||||||
|
|
||||||
|
local function header(kind)
|
||||||
|
P.Push("=== DopingControl probe " .. kind .. " " .. nowStamp() .. " ===")
|
||||||
|
P.Push("client: TURTLE_WOW_VERSION=" .. fmt(TURTLE_WOW_VERSION)
|
||||||
|
.. " SUPERWOW_VERSION=" .. fmt(SUPERWOW_VERSION))
|
||||||
|
P.Push("dll-surface: UnitXP=" .. gtype("UnitXP")
|
||||||
|
.. " GetUnitField=" .. gtype("GetUnitField")
|
||||||
|
.. " ExportFile=" .. gtype("ExportFile")
|
||||||
|
.. " UnitPosition=" .. gtype("UnitPosition"))
|
||||||
|
end
|
||||||
|
|
||||||
|
-- resolve a roster unit token by player name; nil if not in group.
|
||||||
|
-- Re-run every use: raid indices are volatile.
|
||||||
|
local function resolveUnit(name)
|
||||||
|
if not UnitName then
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
local lower = string.lower(name)
|
||||||
|
local nraid = (GetNumRaidMembers and GetNumRaidMembers()) or 0
|
||||||
|
if nraid > 0 then
|
||||||
|
for i = 1, nraid do
|
||||||
|
local ok, nm = pcall(UnitName, "raid" .. i)
|
||||||
|
if ok and nm and string.lower(nm) == lower then
|
||||||
|
return "raid" .. i
|
||||||
|
end
|
||||||
|
end
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
for i = 1, 4 do
|
||||||
|
local ok, nm = pcall(UnitName, "party" .. i)
|
||||||
|
if ok and nm and string.lower(nm) == lower then
|
||||||
|
return "party" .. i
|
||||||
|
end
|
||||||
|
end
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
|
||||||
|
local function unitGuid(unit)
|
||||||
|
if not UnitExists then
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
local ok, ex, g = pcall(UnitExists, unit)
|
||||||
|
if ok and ex and g then
|
||||||
|
return g
|
||||||
|
end
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
|
||||||
|
-- count UnitBuff entries + first non-nil 4th return
|
||||||
|
local function pathACounts(unit)
|
||||||
|
local n = 0
|
||||||
|
local firstId = nil
|
||||||
|
local idCount = 0
|
||||||
|
if UnitBuff then
|
||||||
|
for i = 1, 32 do
|
||||||
|
local ok, tex, stacks, dtype, id4 = pcall(UnitBuff, unit, i)
|
||||||
|
if not ok or not tex then
|
||||||
|
break
|
||||||
|
end
|
||||||
|
n = n + 1
|
||||||
|
if type(id4) == "number" and id4 > 0 then
|
||||||
|
idCount = idCount + 1
|
||||||
|
if not firstId then
|
||||||
|
firstId = id4
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
return n, firstId, idCount
|
||||||
|
end
|
||||||
|
|
||||||
|
-- GetUnitField(guid,"aura") -> comma list for a slot range (sparse table)
|
||||||
|
local function pathBList(guid, from, to)
|
||||||
|
if not GetUnitField or not guid then
|
||||||
|
return nil, 0
|
||||||
|
end
|
||||||
|
local ok, t = pcall(GetUnitField, guid, "aura")
|
||||||
|
if not ok then
|
||||||
|
return "ERR", 0
|
||||||
|
end
|
||||||
|
if type(t) ~= "table" then
|
||||||
|
return "type=" .. type(t), 0
|
||||||
|
end
|
||||||
|
local parts = {}
|
||||||
|
local n = 0
|
||||||
|
for i = from, to do
|
||||||
|
local v = t[i]
|
||||||
|
if type(v) == "number" and v > 0 then
|
||||||
|
n = n + 1
|
||||||
|
table.insert(parts, i .. ":" .. v)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
if n == 0 then
|
||||||
|
return "-", 0
|
||||||
|
end
|
||||||
|
return table.concat(parts, ","), n
|
||||||
|
end
|
||||||
|
|
||||||
|
-- ------------------------------------------------------------------
|
||||||
|
-- Part 2: the battery (/dc probe)
|
||||||
|
-- ------------------------------------------------------------------
|
||||||
|
function P.Battery()
|
||||||
|
if not UnitName then
|
||||||
|
say("no client APIs (offline?) - battery skipped")
|
||||||
|
return false
|
||||||
|
end
|
||||||
|
P.Clear()
|
||||||
|
header("battery")
|
||||||
|
|
||||||
|
local roster = (DC_Scan and DC_Scan.BuildRoster and DC_Scan.BuildRoster()) or {}
|
||||||
|
local total = table.getn(roster)
|
||||||
|
P.Push("roster: " .. total .. " units; full detail for the first "
|
||||||
|
.. P.BATTERY_UNIT_CAP .. " foreign units")
|
||||||
|
P.Push("")
|
||||||
|
|
||||||
|
-- self weapon enchant baseline (vanilla 6-return form)
|
||||||
|
if GetWeaponEnchantInfo then
|
||||||
|
local ok, r1, r2, r3, r4, r5, r6 = pcall(GetWeaponEnchantInfo)
|
||||||
|
P.Push("self GetWeaponEnchantInfo(): ok=" .. fmt(ok)
|
||||||
|
.. " hasMain=" .. fmt(r1) .. " mainExp=" .. fmt(r2)
|
||||||
|
.. " mainCharges=" .. fmt(r3) .. " hasOff=" .. fmt(r4)
|
||||||
|
.. " offExp=" .. fmt(r5) .. " offCharges=" .. fmt(r6))
|
||||||
|
else
|
||||||
|
P.Push("self GetWeaponEnchantInfo(): API missing")
|
||||||
|
end
|
||||||
|
P.Push("")
|
||||||
|
|
||||||
|
local detailed = 0
|
||||||
|
for r = 1, total do
|
||||||
|
local e = roster[r]
|
||||||
|
local isSelf = false
|
||||||
|
if UnitIsUnit then
|
||||||
|
local ok, v = pcall(UnitIsUnit, e.unit, "player")
|
||||||
|
isSelf = (ok and v) and true or false
|
||||||
|
end
|
||||||
|
if not isSelf and detailed < P.BATTERY_UNIT_CAP then
|
||||||
|
detailed = detailed + 1
|
||||||
|
local guid = unitGuid(e.unit)
|
||||||
|
local online, dist = DC_Reach.ReadUnit(e.unit)
|
||||||
|
P.Push("--- " .. e.unit .. " name=" .. fmt(e.name)
|
||||||
|
.. " class=" .. fmt(e.class) .. " online=" .. fmt(online)
|
||||||
|
.. " dist=" .. fmt(dist) .. " guid=" .. fmt(guid))
|
||||||
|
|
||||||
|
-- Q1: UnitBuff rows with 4th return + tooltip name
|
||||||
|
if UnitBuff then
|
||||||
|
local rows = 0
|
||||||
|
for i = 1, 32 do
|
||||||
|
local ok, tex, stacks, dtype, id4 = pcall(UnitBuff, e.unit, i)
|
||||||
|
if not ok or not tex then
|
||||||
|
break
|
||||||
|
end
|
||||||
|
rows = rows + 1
|
||||||
|
local tipName = nil
|
||||||
|
-- same hidden tooltip as scan/aura.lua (shared accessor,
|
||||||
|
-- never a second frame under the same global name)
|
||||||
|
local tip = DC_Aura and DC_Aura.GetScanTip
|
||||||
|
and DC_Aura.GetScanTip()
|
||||||
|
if tip then
|
||||||
|
local ok2 = pcall(function()
|
||||||
|
tip:ClearLines()
|
||||||
|
tip:SetUnitBuff(e.unit, i)
|
||||||
|
end)
|
||||||
|
if ok2 then
|
||||||
|
local obj = getglobal("DopingControlScanTipTextLeft1")
|
||||||
|
if obj then
|
||||||
|
tipName = obj:GetText()
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
P.Push("Q1 UnitBuff i=" .. i .. " tex=" .. fmt(tex)
|
||||||
|
.. " stacks=" .. fmt(stacks) .. " dtype=" .. fmt(dtype)
|
||||||
|
.. " id4=" .. fmt(id4) .. " tipName=" .. fmt(tipName))
|
||||||
|
end
|
||||||
|
local nA, firstId, idCount = pathACounts(e.unit)
|
||||||
|
P.Push("Q1 summary: buffs=" .. nA .. " id4nonNil=" .. idCount
|
||||||
|
.. " (id4 ~= nil answers Q1)")
|
||||||
|
else
|
||||||
|
P.Push("Q1 UnitBuff: API missing")
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Q2: descriptor list, buff and debuff halves separately
|
||||||
|
local buffList = pathBList(guid, 1, 32)
|
||||||
|
local debuffList = pathBList(guid, 33, 48)
|
||||||
|
P.Push("Q2 GetUnitField(guid,\"aura\") slots1-32=" .. fmt(buffList))
|
||||||
|
P.Push("Q2 GetUnitField(guid,\"aura\") slots33-48=" .. fmt(debuffList)
|
||||||
|
.. " (compare vs UnitBuff names above -> slot-split check)")
|
||||||
|
|
||||||
|
-- Q3: foreign weapon enchant name
|
||||||
|
if GetWeaponEnchantInfo then
|
||||||
|
local ok, w1, w2 = pcall(GetWeaponEnchantInfo, e.unit)
|
||||||
|
P.Push("Q3 GetWeaponEnchantInfo(unit): ok=" .. fmt(ok)
|
||||||
|
.. " r1=" .. fmt(w1) .. " r2=" .. fmt(w2))
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Q4/Q5: inventory links, raw + parsed enchant field.
|
||||||
|
-- subType (GetItemInfo return 6, de-shifted by DC_Gear.ItemInfo)
|
||||||
|
-- is dumped per slot to verify the DC.ENCH_EXEMPT [ASSUMPTION]
|
||||||
|
-- strings against reality (data/enchants.lua).
|
||||||
|
local slots = DC_Gear.InvSlots()
|
||||||
|
local linkCount = 0
|
||||||
|
for s = 1, table.getn(slots) do
|
||||||
|
local inv = slots[s]
|
||||||
|
local ok, link = pcall(GetInventoryItemLink, e.unit, inv)
|
||||||
|
if ok and link then
|
||||||
|
linkCount = linkCount + 1
|
||||||
|
local id, ench = DC_Gear.ParseLink(link)
|
||||||
|
local subType = nil
|
||||||
|
if id then
|
||||||
|
local _, _, st = DC_Gear.ItemInfo(id)
|
||||||
|
subType = st
|
||||||
|
end
|
||||||
|
P.Push("Q4/Q5 slot" .. inv .. " link=" .. fmt(link)
|
||||||
|
.. " -> id=" .. fmt(id) .. " ench=" .. fmt(ench)
|
||||||
|
.. " subType=" .. fmt(subType))
|
||||||
|
end
|
||||||
|
end
|
||||||
|
P.Push("Q4 summary: " .. linkCount .. "/" .. table.getn(slots)
|
||||||
|
.. " slots returned a link (0 for an out-of-sight unit"
|
||||||
|
.. " answers Q4 negatively)")
|
||||||
|
P.Push("Q5b: subType above checks the ENCH_EXEMPT assumption"
|
||||||
|
.. " strings - expected \"Wands\"/\"Thrown\" (slot 18) and"
|
||||||
|
.. " \"Miscellaneous\" (slot 17), data/enchants.lua")
|
||||||
|
|
||||||
|
-- Q6: resistance read paths (RESIST tab, scan/engine.lua
|
||||||
|
-- readResists) -- school 2 (Fire) as a representative probe,
|
||||||
|
-- all three paths dumped raw: does UnitResistance answer for a
|
||||||
|
-- foreign unit token, does it also accept the GUID directly,
|
||||||
|
-- and does GetUnitField(guid,"resistances") carry a usable
|
||||||
|
-- table at all -- or does a foreign read silently return 0?
|
||||||
|
if UnitResistance then
|
||||||
|
local ok1, base1, total1 = pcall(UnitResistance, e.unit, 2)
|
||||||
|
P.Push("Q6 UnitResistance(unit,2): ok=" .. fmt(ok1)
|
||||||
|
.. " base=" .. fmt(base1) .. " total=" .. fmt(total1))
|
||||||
|
if guid then
|
||||||
|
local ok2, base2, total2 = pcall(UnitResistance, guid, 2)
|
||||||
|
P.Push("Q6 UnitResistance(guid,2): ok=" .. fmt(ok2)
|
||||||
|
.. " base=" .. fmt(base2) .. " total=" .. fmt(total2))
|
||||||
|
end
|
||||||
|
else
|
||||||
|
P.Push("Q6 UnitResistance: API missing")
|
||||||
|
end
|
||||||
|
if guid and GetUnitField then
|
||||||
|
local ok3, t = pcall(GetUnitField, guid, "resistances")
|
||||||
|
if ok3 and type(t) == "table" then
|
||||||
|
P.Push("Q6 GetUnitField(guid,\"resistances\"): [1]="
|
||||||
|
.. fmt(t[1]) .. " [2]=" .. fmt(t[2]) .. " [3]="
|
||||||
|
.. fmt(t[3]) .. " [4]=" .. fmt(t[4]) .. " [5]="
|
||||||
|
.. fmt(t[5]) .. " [6]=" .. fmt(t[6]) .. " [7]="
|
||||||
|
.. fmt(t[7]))
|
||||||
|
else
|
||||||
|
P.Push("Q6 GetUnitField(guid,\"resistances\"): ok="
|
||||||
|
.. fmt(ok3) .. " type=" .. type(t))
|
||||||
|
end
|
||||||
|
end
|
||||||
|
P.Push("")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
if detailed == 0 then
|
||||||
|
P.Push("no foreign units in group - battery needs a party/raid")
|
||||||
|
end
|
||||||
|
|
||||||
|
local written = P.Flush()
|
||||||
|
if written then
|
||||||
|
say("battery done (" .. detailed .. " foreign units) -> "
|
||||||
|
.. P.EXPORT_NAME .. ".txt")
|
||||||
|
end
|
||||||
|
return written
|
||||||
|
end
|
||||||
|
|
||||||
|
-- ------------------------------------------------------------------
|
||||||
|
-- Part 1: the 1 Hz range run (/dc probe range <name>)
|
||||||
|
-- ------------------------------------------------------------------
|
||||||
|
local rangeFrame = nil
|
||||||
|
local rangeName = nil
|
||||||
|
local rangeRunning = false
|
||||||
|
local rangeAcc = 0
|
||||||
|
local rangeT0 = 0
|
||||||
|
|
||||||
|
local function rangeTick()
|
||||||
|
local t = (GetTime and GetTime()) or 0
|
||||||
|
local rel = math.floor(t - rangeT0 + 0.5)
|
||||||
|
local unit = resolveUnit(rangeName)
|
||||||
|
if not unit then
|
||||||
|
P.Push("t=" .. rel .. "s target '" .. rangeName .. "' not in roster")
|
||||||
|
P.Flush()
|
||||||
|
return
|
||||||
|
end
|
||||||
|
local guid = unitGuid(unit)
|
||||||
|
|
||||||
|
local dist = nil
|
||||||
|
if UnitXP then
|
||||||
|
local ok, d = pcall(UnitXP, "distanceBetween", "player", unit)
|
||||||
|
if ok and type(d) == "number" then
|
||||||
|
dist = d
|
||||||
|
end
|
||||||
|
end
|
||||||
|
local exists = nil
|
||||||
|
if UnitExists then
|
||||||
|
local ok, ex = pcall(UnitExists, unit)
|
||||||
|
if ok then
|
||||||
|
exists = ex
|
||||||
|
end
|
||||||
|
end
|
||||||
|
local visible = nil
|
||||||
|
if UnitIsVisible then
|
||||||
|
local ok, v = pcall(UnitIsVisible, unit)
|
||||||
|
if ok then
|
||||||
|
visible = v
|
||||||
|
end
|
||||||
|
end
|
||||||
|
local nA, firstId = pathACounts(unit)
|
||||||
|
local _, nB = pathBList(guid, 1, 32)
|
||||||
|
local pos = "nil"
|
||||||
|
if UnitPosition and guid then
|
||||||
|
local ok, x, y = pcall(UnitPosition, guid)
|
||||||
|
if ok and type(x) == "number" then
|
||||||
|
pos = "ok(" .. string.format("%.1f", x) .. ","
|
||||||
|
.. string.format("%.1f", y or 0) .. ")"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
-- part-1 column order (matches the header line written by RangeStart)
|
||||||
|
P.Push("t=" .. rel .. "s unit=" .. unit
|
||||||
|
.. " dist=" .. fmt(dist)
|
||||||
|
.. " exists=" .. fmt(exists)
|
||||||
|
.. " visible=" .. fmt(visible)
|
||||||
|
.. " nA=" .. nA
|
||||||
|
.. " id4first=" .. fmt(firstId)
|
||||||
|
.. " nB=" .. nB
|
||||||
|
.. " pos=" .. pos)
|
||||||
|
P.Flush()
|
||||||
|
end
|
||||||
|
|
||||||
|
function P.IsRangeRunning()
|
||||||
|
return rangeRunning
|
||||||
|
end
|
||||||
|
|
||||||
|
function P.RangeStart(name)
|
||||||
|
if not CreateFrame then
|
||||||
|
say("no client (offline) - range run unavailable")
|
||||||
|
return false
|
||||||
|
end
|
||||||
|
if rangeRunning then
|
||||||
|
say("range run already active for " .. fmt(rangeName)
|
||||||
|
.. " - stop it first (/dc probe range)")
|
||||||
|
return false
|
||||||
|
end
|
||||||
|
rangeName = name
|
||||||
|
rangeRunning = true
|
||||||
|
rangeAcc = 0
|
||||||
|
rangeT0 = (GetTime and GetTime()) or 0
|
||||||
|
P.Clear()
|
||||||
|
header("range-run target=" .. name)
|
||||||
|
P.Push("columns: t | unit | dist | exists | visible | nA=count(UnitBuff) |"
|
||||||
|
.. " id4first | nB=count(GetUnitField slots 1-32) | pos=UnitPosition")
|
||||||
|
if not rangeFrame then
|
||||||
|
rangeFrame = CreateFrame("Frame", "DopingControlProbeFrame")
|
||||||
|
end
|
||||||
|
rangeFrame:SetScript("OnUpdate", function()
|
||||||
|
rangeAcc = rangeAcc + (arg1 or 0)
|
||||||
|
if rangeAcc >= 1.0 then
|
||||||
|
rangeAcc = 0
|
||||||
|
rangeTick()
|
||||||
|
end
|
||||||
|
end)
|
||||||
|
say("range run started, target " .. name .. ", 1 Hz -> "
|
||||||
|
.. P.EXPORT_NAME .. ".txt (stop: /dc probe range " .. name .. ")")
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
|
||||||
|
function P.RangeStop()
|
||||||
|
if rangeFrame then
|
||||||
|
rangeFrame:SetScript("OnUpdate", nil)
|
||||||
|
end
|
||||||
|
if rangeRunning then
|
||||||
|
rangeRunning = false
|
||||||
|
P.Push("=== range run stopped " .. nowStamp() .. " ===")
|
||||||
|
P.Flush()
|
||||||
|
say("range run stopped, " .. table.getn(ring) .. " lines in "
|
||||||
|
.. P.EXPORT_NAME .. ".txt")
|
||||||
|
end
|
||||||
|
rangeName = nil
|
||||||
|
end
|
||||||
|
|
||||||
|
-- ------------------------------------------------------------------
|
||||||
|
-- slash glue: rest of the line after "probe"
|
||||||
|
-- ------------------------------------------------------------------
|
||||||
|
function P.HandleSlash(rest)
|
||||||
|
rest = rest or ""
|
||||||
|
rest = string.gsub(rest, "^%s+", "")
|
||||||
|
rest = string.gsub(rest, "%s+$", "")
|
||||||
|
if rest == "" then
|
||||||
|
return P.Battery()
|
||||||
|
end
|
||||||
|
local _, _, word = string.find(rest, "^(%S+)")
|
||||||
|
if word and string.lower(word) == "range" then
|
||||||
|
if rangeRunning then
|
||||||
|
P.RangeStop()
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
local _, _, name = string.find(rest, "^%S+%s+(.+)$")
|
||||||
|
if name then
|
||||||
|
return P.RangeStart(name)
|
||||||
|
end
|
||||||
|
say("usage: /dc probe range <playername>")
|
||||||
|
return false
|
||||||
|
end
|
||||||
|
say("usage: /dc probe | /dc probe range <playername>")
|
||||||
|
return false
|
||||||
|
end
|
||||||
@@ -0,0 +1,247 @@
|
|||||||
|
-- DopingControl core/roles.lua
|
||||||
|
-- Role suggestion heuristic + confirmed-role resolution: five roles, the
|
||||||
|
-- role hangs on the player NAME, and no heuristic ever decides silently --
|
||||||
|
-- a suggestion is always flagged as such and carries a reason string.
|
||||||
|
-- Badge UX: CLASS_ROLES = ordered plausible roles per class, cycle() =
|
||||||
|
-- badge right-click (step + immediate confirm).
|
||||||
|
-- Per-class expectations:
|
||||||
|
-- ROLE_CLASSES = generated inversion of CLASS_ROLES (ordered classes per
|
||||||
|
-- role) -- the domain of db.expectations[role][class].
|
||||||
|
-- Pure Lua 5.0, no WoW API -- dofile-loadable offline.
|
||||||
|
|
||||||
|
DopingControl = DopingControl or {}
|
||||||
|
local DC = DopingControl
|
||||||
|
|
||||||
|
DC_Roles = DC_Roles or {}
|
||||||
|
|
||||||
|
-- Transient confirmations for the simulated raid (test mode). NEVER written
|
||||||
|
-- to SavedVariables: synthetic names must not pollute db.roles.
|
||||||
|
-- Reset by DC_Sim.buildStore() on every (re-)entry into test mode.
|
||||||
|
DC.simRoles = DC.simRoles or {}
|
||||||
|
|
||||||
|
-- ------------------------------------------------------------------
|
||||||
|
-- Plausible roles per class token, ORDERED (the order is
|
||||||
|
-- the badge right-click cycle order). suggest() below must only ever
|
||||||
|
-- return a role CONTAINED in the class's list -- property-tested over
|
||||||
|
-- every class x fact combination in the offline test suite.
|
||||||
|
-- ------------------------------------------------------------------
|
||||||
|
DC_Roles.CLASS_ROLES = {
|
||||||
|
WARRIOR = { "MELEE", "TANK" },
|
||||||
|
ROGUE = { "MELEE" },
|
||||||
|
HUNTER = { "RANGED", "MELEE" }, -- melee hunter is a real spec on TWoW
|
||||||
|
MAGE = { "CASTER" },
|
||||||
|
WARLOCK = { "CASTER" },
|
||||||
|
PRIEST = { "HEALER", "CASTER" },
|
||||||
|
DRUID = { "HEALER", "TANK", "MELEE", "CASTER" },
|
||||||
|
SHAMAN = { "HEALER", "TANK", "MELEE", "CASTER" },
|
||||||
|
PALADIN = { "HEALER", "TANK", "MELEE" },
|
||||||
|
}
|
||||||
|
|
||||||
|
-- ------------------------------------------------------------------
|
||||||
|
-- Per-class expectations:
|
||||||
|
-- ROLE_CLASSES = the INVERSION of CLASS_ROLES -- for each role the
|
||||||
|
-- ordered list of class tokens that can perform it, in the fixed
|
||||||
|
-- class order below, filtered per role. GENERATED from CLASS_ROLES so
|
||||||
|
-- the two tables can never drift apart.
|
||||||
|
-- Consumers: core/config.lua materializes db.expectations[role][class]
|
||||||
|
-- over exactly these lists, core/model.lua iterates them for the
|
||||||
|
-- dynamic-column check, and the options expectation grid renders one
|
||||||
|
-- indented class row per entry.
|
||||||
|
-- ------------------------------------------------------------------
|
||||||
|
DC_Roles.CLASS_ORDER = {
|
||||||
|
"WARRIOR", "PALADIN", "HUNTER", "ROGUE", "PRIEST",
|
||||||
|
"SHAMAN", "MAGE", "WARLOCK", "DRUID",
|
||||||
|
}
|
||||||
|
|
||||||
|
DC_Roles.ROLE_CLASSES = {}
|
||||||
|
do
|
||||||
|
-- fresh tables on every (re-)load -- regeneration, not accretion
|
||||||
|
local inv = DC_Roles.ROLE_CLASSES
|
||||||
|
for i = 1, table.getn(DC_Roles.CLASS_ORDER) do
|
||||||
|
local cls = DC_Roles.CLASS_ORDER[i]
|
||||||
|
local list = DC_Roles.CLASS_ROLES[cls] or {}
|
||||||
|
for j = 1, table.getn(list) do
|
||||||
|
local role = list[j]
|
||||||
|
if inv[role] == nil then
|
||||||
|
inv[role] = {}
|
||||||
|
end
|
||||||
|
table.insert(inv[role], cls)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
-- ------------------------------------------------------------------
|
||||||
|
-- Facts extraction: derive the suggestion facts from a store player entry.
|
||||||
|
-- Works on both aura paths: form NAMES from
|
||||||
|
-- the tooltip path, form SPELL IDS from the GUID path (which delivers ids
|
||||||
|
-- only).
|
||||||
|
-- [ASSUMPTION] Form aura spell IDs (vanilla 1.12): Cat Form 768, Bear Form
|
||||||
|
-- 5487, Dire Bear Form 9634, Moonkin Form 24858, Shadowform 15473.
|
||||||
|
-- ------------------------------------------------------------------
|
||||||
|
DC_Roles.FORM_NAMES = {
|
||||||
|
["Cat Form"] = "catForm",
|
||||||
|
["Bear Form"] = "bearForm",
|
||||||
|
["Dire Bear Form"] = "bearForm",
|
||||||
|
["Moonkin Form"] = "moonkin",
|
||||||
|
["Shadowform"] = "shadowform",
|
||||||
|
}
|
||||||
|
|
||||||
|
DC_Roles.FORM_IDS = {
|
||||||
|
[768] = "catForm",
|
||||||
|
[5487] = "bearForm",
|
||||||
|
[9634] = "bearForm",
|
||||||
|
[24858] = "moonkin",
|
||||||
|
[15473] = "shadowform",
|
||||||
|
}
|
||||||
|
|
||||||
|
function DC_Roles.factsFor(player)
|
||||||
|
local facts = {
|
||||||
|
hasShield = false, catForm = false, bearForm = false,
|
||||||
|
shadowform = false, moonkin = false,
|
||||||
|
}
|
||||||
|
if not player then
|
||||||
|
return facts
|
||||||
|
end
|
||||||
|
if player.aurasRead and player.auras then
|
||||||
|
if player.auras.names then
|
||||||
|
for name, key in pairs(DC_Roles.FORM_NAMES) do
|
||||||
|
if player.auras.names[name] then
|
||||||
|
facts[key] = true
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
if player.auras.ids then
|
||||||
|
for id, key in pairs(DC_Roles.FORM_IDS) do
|
||||||
|
if player.auras.ids[id] then
|
||||||
|
facts[key] = true
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
if player.gearRead and player.gear then
|
||||||
|
local off = player.gear[17] -- off hand inventory slot
|
||||||
|
if type(off) == "table" and off.subType == "Shields" then
|
||||||
|
facts.hasShield = true
|
||||||
|
end
|
||||||
|
end
|
||||||
|
return facts
|
||||||
|
end
|
||||||
|
|
||||||
|
-- ------------------------------------------------------------------
|
||||||
|
-- Suggestion heuristic. ALWAYS returns a
|
||||||
|
-- (role, reason) pair -- no suggestion without a stated reason (the badge
|
||||||
|
-- tooltip shows it).
|
||||||
|
-- ------------------------------------------------------------------
|
||||||
|
function DC_Roles.suggest(class, facts)
|
||||||
|
facts = facts or {}
|
||||||
|
if class == "WARRIOR" then
|
||||||
|
if facts.hasShield then
|
||||||
|
return "TANK", "shield equipped"
|
||||||
|
end
|
||||||
|
return "MELEE", "class default"
|
||||||
|
elseif class == "ROGUE" then
|
||||||
|
return "MELEE", "class default"
|
||||||
|
elseif class == "HUNTER" then
|
||||||
|
return "RANGED", "class default"
|
||||||
|
elseif class == "MAGE" then
|
||||||
|
return "CASTER", "class default"
|
||||||
|
elseif class == "WARLOCK" then
|
||||||
|
return "CASTER", "class default"
|
||||||
|
elseif class == "PRIEST" then
|
||||||
|
if facts.shadowform then
|
||||||
|
return "CASTER", "Shadowform at scan time"
|
||||||
|
end
|
||||||
|
return "HEALER", "class default"
|
||||||
|
elseif class == "DRUID" then
|
||||||
|
-- form precedence: bear > cat > moonkin > none (a druid can only be
|
||||||
|
-- in one form at a time; the order only matters for garbage input)
|
||||||
|
if facts.bearForm then
|
||||||
|
return "TANK", "Bear Form at scan time"
|
||||||
|
end
|
||||||
|
if facts.catForm then
|
||||||
|
return "MELEE", "Cat Form at scan time"
|
||||||
|
end
|
||||||
|
if facts.moonkin then
|
||||||
|
return "CASTER", "Moonkin Form at scan time"
|
||||||
|
end
|
||||||
|
return "HEALER", "class default"
|
||||||
|
elseif class == "SHAMAN" then
|
||||||
|
-- deliberately NOT shield->MELEE: resto shamans
|
||||||
|
-- carry shields too, so a shield is no enhancement signal here.
|
||||||
|
-- Plain class default; enhancers get confirmed by hand once.
|
||||||
|
return "HEALER", "class default"
|
||||||
|
elseif class == "PALADIN" then
|
||||||
|
if facts.hasShield then
|
||||||
|
return "TANK", "shield equipped"
|
||||||
|
end
|
||||||
|
return "HEALER", "class default"
|
||||||
|
end
|
||||||
|
-- unknown class token: still return a pair, never nil
|
||||||
|
return "MELEE", "unknown class"
|
||||||
|
end
|
||||||
|
|
||||||
|
-- ------------------------------------------------------------------
|
||||||
|
-- Resolution: confirmed role wins over suggestion.
|
||||||
|
-- source "sim" -> confirmations live ONLY in DC.simRoles (transient);
|
||||||
|
-- db.roles is never consulted and never written for
|
||||||
|
-- simulated players (synthetic names must
|
||||||
|
-- not end up in SavedVariables).
|
||||||
|
-- source "scan" -> confirmations live in db.roles (persistent, keyed by
|
||||||
|
-- player name).
|
||||||
|
-- Returns role, suggested(bool), reason(string|nil):
|
||||||
|
-- confirmed -> (role, false, nil)
|
||||||
|
-- suggested -> (role, true, reason)
|
||||||
|
-- ------------------------------------------------------------------
|
||||||
|
function DC_Roles.resolve(db, storeSource, player)
|
||||||
|
local name = player and player.name
|
||||||
|
local confirmed
|
||||||
|
if storeSource == "sim" then
|
||||||
|
confirmed = name and DC.simRoles[name]
|
||||||
|
else
|
||||||
|
confirmed = name and db and db.roles and db.roles[name]
|
||||||
|
end
|
||||||
|
if confirmed then
|
||||||
|
return confirmed, false, nil
|
||||||
|
end
|
||||||
|
local role, reason = DC_Roles.suggest(player and player.class,
|
||||||
|
DC_Roles.factsFor(player))
|
||||||
|
return role, true, reason
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Confirm a role (badge LEFT-click). Same routing rule as resolve().
|
||||||
|
function DC_Roles.confirm(db, storeSource, name, role)
|
||||||
|
if storeSource == "sim" then
|
||||||
|
DC.simRoles[name] = role
|
||||||
|
else
|
||||||
|
db.roles[name] = role
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
-- ------------------------------------------------------------------
|
||||||
|
-- cycle(db, storeSource, name, class, currentRole) -> nextRole
|
||||||
|
-- Badge RIGHT-click: step to the entry AFTER currentRole
|
||||||
|
-- in CLASS_ROLES[class] (wrapping; currentRole not in the list -> the
|
||||||
|
-- FIRST entry) and CONFIRM it immediately -- same persistence routing as
|
||||||
|
-- confirm() (sim source -> DC.simRoles, else db.roles).
|
||||||
|
-- Single-entry classes wrap onto themselves (the UI treats right-click
|
||||||
|
-- as a no-op there and never calls this; calling it anyway is harmless:
|
||||||
|
-- the same role comes back and is re-confirmed).
|
||||||
|
-- Unknown class token: no list -> currentRole returned unchanged and
|
||||||
|
-- NOTHING confirmed (defensive; scan classes are always known tokens).
|
||||||
|
-- ------------------------------------------------------------------
|
||||||
|
function DC_Roles.cycle(db, storeSource, name, class, currentRole)
|
||||||
|
local list = DC_Roles.CLASS_ROLES[class]
|
||||||
|
if not list or table.getn(list) == 0 then
|
||||||
|
return currentRole
|
||||||
|
end
|
||||||
|
local n = table.getn(list)
|
||||||
|
local nextRole = list[1]
|
||||||
|
for i = 1, n do
|
||||||
|
if list[i] == currentRole then
|
||||||
|
nextRole = list[math.mod(i, n) + 1]
|
||||||
|
break
|
||||||
|
end
|
||||||
|
end
|
||||||
|
DC_Roles.confirm(db, storeSource, name, nextRole)
|
||||||
|
return nextRole
|
||||||
|
end
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
-- DopingControl data/classbuffs.lua
|
||||||
|
-- Class-buffs tab:
|
||||||
|
-- slot definitions + buff name -> slot table with rank spell IDs.
|
||||||
|
--
|
||||||
|
-- [ASSUMPTION] Rank IDs below are from vanilla 1.12 knowledge and are
|
||||||
|
-- not individually verified in-game. Verify via
|
||||||
|
-- the probe dump before trusting ID matching; TWoW may add extra ranks.
|
||||||
|
-- Name-based detection alone is sufficient for tab purposes EXCEPT the
|
||||||
|
-- "Shadow Protection" name collision: the priest buff and the old
|
||||||
|
-- protection-potion aura (7242) share the name -> ID-before-name matching
|
||||||
|
-- is mandatory once both tabs are live.
|
||||||
|
-- Pure Lua 5.0, no WoW API -- dofile-loadable offline.
|
||||||
|
|
||||||
|
DopingControl = DopingControl or {}
|
||||||
|
local DC = DopingControl
|
||||||
|
|
||||||
|
-- ------------------------------------------------------------------
|
||||||
|
-- Slot definitions (array, order = column order). Same field shape as
|
||||||
|
-- DC.SLOTS_CONSUMABLES. Slot ids: AI/MOTW/PWF/SPROT/EMB/SOUL;
|
||||||
|
-- tile labels/sub-labels: INT/MTW/FRT/SP/EMB/SST. EMB (Emerald Blessing)
|
||||||
|
-- carries NO seed entry in data/expectations.lua -- see that file's
|
||||||
|
-- header for why -- so it starts as an inactive dynamic column, exactly
|
||||||
|
-- like the equipment tab's T1/T2.
|
||||||
|
-- ------------------------------------------------------------------
|
||||||
|
DC.SLOTS_CLASSBUFFS = {
|
||||||
|
{ id = "AI", label = "INT", sub = "Arcane", full = "Arcane Intellect", kind = "aura",
|
||||||
|
items = "Arcane Intellect / Brilliance (mage)" },
|
||||||
|
{ id = "MOTW", label = "MTW", sub = "Wild", full = "Mark of the Wild", kind = "aura",
|
||||||
|
items = "Mark / Gift of the Wild (druid)" },
|
||||||
|
{ id = "PWF", label = "FRT", sub = "Priest", full = "Power Word: Fortitude", kind = "aura",
|
||||||
|
items = "Power Word / Prayer of Fortitude (priest)" },
|
||||||
|
{ id = "SPROT", label = "SP", sub = "situat.", full = "Shadow Protection", kind = "aura",
|
||||||
|
items = "Shadow Protection (priest) - situational per boss" },
|
||||||
|
{ id = "EMB", label = "EMB", sub = "hit", full = "Emerald Blessing", kind = "aura",
|
||||||
|
items = "Emerald Blessing (druid, Emerald Sanctum questline) - also grants +1% spell hit, see Resistances" },
|
||||||
|
{ id = "SOUL", label = "SST", sub = "healers", full = "Soulstone", kind = "aura",
|
||||||
|
items = "Soulstone (warlock) - expected only for healers" },
|
||||||
|
}
|
||||||
|
|
||||||
|
-- ------------------------------------------------------------------
|
||||||
|
-- Buff name -> slot + rank IDs ([ASSUMPTION], see header).
|
||||||
|
-- ------------------------------------------------------------------
|
||||||
|
DC.CLASSBUFFS = {
|
||||||
|
["Arcane Intellect"] = { slot = "AI", ids = { 1459, 1460, 1461, 10156, 10157 } },
|
||||||
|
["Arcane Brilliance"] = { slot = "AI", ids = { 23028 } },
|
||||||
|
["Mark of the Wild"] = { slot = "MOTW", ids = { 1126, 5232, 6756, 5234, 8907, 9884, 9885 } },
|
||||||
|
["Gift of the Wild"] = { slot = "MOTW", ids = { 21849, 21850 } },
|
||||||
|
["Power Word: Fortitude"] = { slot = "PWF", ids = { 1243, 1244, 1245, 2791, 10937, 10938 } },
|
||||||
|
["Prayer of Fortitude"] = { slot = "PWF", ids = { 21562, 21564 } },
|
||||||
|
["Shadow Protection"] = { slot = "SPROT", ids = { 976, 10957, 10958 } }, -- name collides with potion aura 7242
|
||||||
|
["Prayer of Shadow Protection"] = { slot = "SPROT", ids = { 27683 } },
|
||||||
|
-- raid-wide druid buff on this server; both spell ids seen for it
|
||||||
|
["Emerald Blessing"] = { slot = "EMB", ids = { 57108, 57109 } },
|
||||||
|
["Soulstone Resurrection"] = { slot = "SOUL", ids = { 20707, 20762, 20763, 20764, 20765, 20766 } },
|
||||||
|
}
|
||||||
|
|
||||||
|
-- ------------------------------------------------------------------
|
||||||
|
-- Merge rank IDs into the global spellID -> slotId map started by
|
||||||
|
-- data/consumables.lua (slot ids are unique across tabs -> no ambiguity;
|
||||||
|
-- the ID sets are disjoint, test_data.lua asserts this).
|
||||||
|
-- ------------------------------------------------------------------
|
||||||
|
DC.SPELL_TO_SLOT = DC.SPELL_TO_SLOT or {}
|
||||||
|
for name, def in pairs(DC.CLASSBUFFS) do
|
||||||
|
local n = table.getn(def.ids)
|
||||||
|
for i = 1, n do
|
||||||
|
DC.SPELL_TO_SLOT[def.ids[i]] = def.slot
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
-- ------------------------------------------------------------------
|
||||||
|
-- Name-only aura collisions with CONSUMABLE slots (decision 2026-08-15):
|
||||||
|
-- a name-only aura match (no spell ID at all for that scan) must NEVER
|
||||||
|
-- satisfy a CONSUMABLE slot when the matched display name is also a class
|
||||||
|
-- buff name -- the scan cannot tell the two apart, and defaulting to HAS
|
||||||
|
-- would hide a missing consumable behind someone else's class buff. Such
|
||||||
|
-- a slot classifies UNKNOWN instead (core/model.lua's aura name-fallback
|
||||||
|
-- branch consults this table; core/const.lua's Iron Rule: UNKNOWN is
|
||||||
|
-- never collapsed into HAS or MISSING).
|
||||||
|
--
|
||||||
|
-- Scope is deliberately ONE-SIDED: only the CONSUMABLE slot side of the
|
||||||
|
-- collision is affected. The class-buff slot (e.g. SPROT) keeps the
|
||||||
|
-- existing name-fallback behavior unchanged -- the ambiguity here means
|
||||||
|
-- "we can't prove the CONSUMABLE was used", not "we can't prove the class
|
||||||
|
-- buff is missing". Do not widen this into "name-only never counts"; that
|
||||||
|
-- would make every ID-less scan MISSING/UNKNOWN across the whole matrix.
|
||||||
|
--
|
||||||
|
-- Keyed by buff display name (a key shared by DC.CONSUMABLES and
|
||||||
|
-- DC.CLASSBUFFS). test_data.lua asserts every entry here is a key in BOTH
|
||||||
|
-- tables, so a stale or speculative entry cannot ship silently.
|
||||||
|
-- "Shadow Protection" -- DC.CONSUMABLES["Shadow Protection"] (SR slot,
|
||||||
|
-- Shadow Protection Potion, spell 7242, data/consumables.lua) vs.
|
||||||
|
-- DC.CLASSBUFFS["Shadow Protection"] above (SPROT slot, priest buff,
|
||||||
|
-- ids 976/10957/10958) -- identical display name, already flagged as a
|
||||||
|
-- collision at both definitions and wowhead-confirmed 2026-08-11 (see
|
||||||
|
-- the SR entry's comment in data/consumables.lua). This is the ONLY
|
||||||
|
-- name shared between DC.CONSUMABLES and DC.CLASSBUFFS (checked against
|
||||||
|
-- every key in both tables) -- no other entry is added without the same
|
||||||
|
-- level of evidence.
|
||||||
|
-- ------------------------------------------------------------------
|
||||||
|
DC.CONSUMABLE_CLASSBUFF_NAME_COLLISION = {
|
||||||
|
["Shadow Protection"] = true,
|
||||||
|
}
|
||||||
@@ -0,0 +1,369 @@
|
|||||||
|
-- DopingControl data/consumables.lua
|
||||||
|
-- Consumables tab: slot definitions + buff->entry table + spellID->slot map
|
||||||
|
-- + the pure item-identification ladder (DC.ResolveItem).
|
||||||
|
-- Pure Lua 5.0, no WoW API -- dofile-loadable offline.
|
||||||
|
--
|
||||||
|
-- SLOT MODEL (v6, 22 slots -- the 17-slot base design plus the five school
|
||||||
|
-- protection-potion columns FrR/NR/SR/HR/AR added 2026-08-11):
|
||||||
|
-- One column = one thing that can be active independently of every other
|
||||||
|
-- column; items competing INSIDE a column are the "pick one" alternatives.
|
||||||
|
-- Source: community-sourced TurtleWoW 1.17.2 consumable-stacking rules,
|
||||||
|
-- trust external -- vanilla 1.12 slot rules, no TBC
|
||||||
|
-- Battle/Guardian categories. The old 11-slot model conflated four
|
||||||
|
-- independent slots into SP (GAE / school elixir / Dreamtonic / Dreamshard
|
||||||
|
-- are simultaneously active in the caster set) and two into HP (Fortitude
|
||||||
|
-- + stamina alcohol stack), and lacked BL/ZANZA entirely.
|
||||||
|
--
|
||||||
|
-- Deliberate compromises (documented, not hidden):
|
||||||
|
-- * ALC is ONE column although different alcohol types stack (Merlot +
|
||||||
|
-- Rumsey Black Label at once; same types do not): modelled as one
|
||||||
|
-- column showing the strongest active, detail in tooltip. The
|
||||||
|
-- slot-collision tests exempt exactly this column.
|
||||||
|
-- * Gift of Arthas and Juju Flurry get NO column (defense elixirs all
|
||||||
|
-- stack; Flurry lasts 20 s).
|
||||||
|
-- * UNVERIFIED gaps (community source does not cover them; marked
|
||||||
|
-- `unverified = true` on the slot/entries, upgrade via dev/raiddump
|
||||||
|
-- evidence next raid): (1) WPN main-hand/off-hand coexistence (oil on
|
||||||
|
-- MH + stone on OH), (2) protection-potion slot rules (all six school
|
||||||
|
-- columns FR/FrR/NR/SR/HR/AR -- cross-school stacking is NOT
|
||||||
|
-- evidenced, so each school gets its own column but every one stays
|
||||||
|
-- flagged unverified), (3) world-buff interactions (no world-buff
|
||||||
|
-- columns at all).
|
||||||
|
--
|
||||||
|
-- Entry fields (DC.CONSUMABLES[buffName]):
|
||||||
|
-- slot : slot id (column) the buff fills
|
||||||
|
-- spellID : aura spell id; 0 = "match by name only" (no known ID)
|
||||||
|
-- item : the consumable's display name (whisper/tooltip/cell)
|
||||||
|
-- icon : BARE icon texture name (no "Interface\Icons\" prefix, no
|
||||||
|
-- extension -- prefix is added at render time); nil when the
|
||||||
|
-- icon could not be sourced (the matrix fallback ladder
|
||||||
|
-- handles nil: slot-generic icon, then the green check)
|
||||||
|
-- discrim : optional Lua 5.0 string.find pattern matched against the
|
||||||
|
-- buff tooltip's EFFECT line (scan/aura.lua auras.effects) --
|
||||||
|
-- only where the buff NAME is generic
|
||||||
|
-- variants : optional ARRAY of { item, icon, discrim } for generic buff
|
||||||
|
-- names ("Well Fed") that many different items produce; the
|
||||||
|
-- identification ladder walks them in order, FIRST match wins
|
||||||
|
-- unverified : optional true -- see the honesty gaps above
|
||||||
|
--
|
||||||
|
-- Icon provenance (2026-08-11, never guessed): wowhead classic tooltip
|
||||||
|
-- API for the vanilla items, octowow.st/db + wowauctions for TurtleWoW
|
||||||
|
-- customs; icon = nil where no source confirmed one. Two lookalike
|
||||||
|
-- collisions are GENUINE, not typos: Dragonbreath Chili and Danonzo's
|
||||||
|
-- Delight share inv_drink_17 with Nightfin Soup; Gordok Green Grog
|
||||||
|
-- shares inv_drink_03 with Rumsey Rum; Cerebral Cortex Compound shares
|
||||||
|
-- inv_potion_32 with the Mongoose elixir.
|
||||||
|
--
|
||||||
|
-- Separator decision (once for all data files): items strings are
|
||||||
|
-- tooltip/FontString-only, so they may use the middle-dot list separator
|
||||||
|
-- "\194\183" (UTF-8 middle dot renders fine in own FontStrings; the group
|
||||||
|
-- headers already use the same glyph). Em dashes stay ASCII "-" (that
|
||||||
|
-- glyph is unproven in the 1.12 font). SENT chat lines remain pure ASCII
|
||||||
|
-- -- they never use these strings.
|
||||||
|
|
||||||
|
DopingControl = DopingControl or {}
|
||||||
|
local DC = DopingControl
|
||||||
|
|
||||||
|
-- ------------------------------------------------------------------
|
||||||
|
-- Slot definitions (array, order = column order).
|
||||||
|
-- Fields: id (stable key, used by DEFAULT_EXPECT / model / UI),
|
||||||
|
-- label (column tile abbrev), sub (tile sub-label), full (long
|
||||||
|
-- name, used in whisper/report), kind ("aura" | "weapon"),
|
||||||
|
-- items (tooltip example text, optional), unverified (optional,
|
||||||
|
-- see header),
|
||||||
|
-- icon (BARE slot-generic icon texture name -- same bare-name
|
||||||
|
-- rule as the entry icons above; the matrix HAS-cell fallback
|
||||||
|
-- ladder renders it when the ITEM behind a buff cannot be
|
||||||
|
-- resolved: resolved item icon -> this slot icon -> green check.
|
||||||
|
-- A representative member item's icon by design -- the cell must
|
||||||
|
-- read as "this column", not as a question mark).
|
||||||
|
-- ------------------------------------------------------------------
|
||||||
|
DC.SLOTS_CONSUMABLES = {
|
||||||
|
{ id = "FLASK", label = "FLK", sub = "Flask", full = "Flask", kind = "aura",
|
||||||
|
icon = "inv_potion_62",
|
||||||
|
items = "Flask of the Titans \194\183 Distilled Wisdom \194\183 Supreme Power" },
|
||||||
|
{ id = "FOOD", label = "FOD", sub = "Food", full = "Food buff", kind = "aura",
|
||||||
|
icon = "inv_misc_food_15",
|
||||||
|
items = "one food buff at a time - e.g. Smoked Desert Dumplings \194\183 Danonzo's Tel'Abim foods" },
|
||||||
|
{ id = "AP", label = "AP", sub = "Attack", full = "Attack power", kind = "aura",
|
||||||
|
icon = "inv_potion_92",
|
||||||
|
items = "Winterfall Firewater \194\183 Juju Might" },
|
||||||
|
{ id = "STR", label = "STR", sub = "Str.", full = "Strength", kind = "aura",
|
||||||
|
icon = "inv_potion_61",
|
||||||
|
items = "Juju Power \194\183 Elixir of Giants" },
|
||||||
|
{ id = "AGI", label = "AGI", sub = "Agi.", full = "Agility", kind = "aura",
|
||||||
|
icon = "inv_potion_32",
|
||||||
|
items = "Elixir of the Mongoose - free-stacker, own column" },
|
||||||
|
{ id = "BL", label = "BL", sub = "Blasted", full = "Blasted Lands buff", kind = "aura",
|
||||||
|
icon = "inv_stone_15",
|
||||||
|
items = "R.O.I.D.S. \194\183 Ground Scorpok Assay \194\183 Lung Juice Cocktail \194\183 Cortex Compound \194\183 Gizzard Gum" },
|
||||||
|
{ id = "ZANZA", label = "ZAN", sub = "Zanza", full = "Spirit of Zanza", kind = "aura",
|
||||||
|
icon = "inv_potion_30",
|
||||||
|
items = "Spirit of Zanza - free-stacker, one Zanza effect at a time" },
|
||||||
|
{ id = "GAE", label = "GAE", sub = "Arcane", full = "Greater Arcane Elixir", kind = "aura",
|
||||||
|
icon = "inv_potion_25",
|
||||||
|
items = "Greater Arcane Elixir \194\183 Arcane Elixir" },
|
||||||
|
{ id = "SCHOOL", label = "SCH", sub = "School", full = "School elixir", kind = "aura",
|
||||||
|
icon = "inv_potion_46",
|
||||||
|
items = "Shadow Power \194\183 Frost Power \194\183 Greater Firepower - one per school slot" },
|
||||||
|
{ id = "DREAMT", label = "DRT", sub = "Dreamt.", full = "Dreamtonic", kind = "aura",
|
||||||
|
icon = "inv_potion_10",
|
||||||
|
items = "Dreamtonic (TurtleWoW custom)" },
|
||||||
|
{ id = "SHARD", label = "SHD", sub = "Shard", full = "Dreamshard Elixir", kind = "aura",
|
||||||
|
icon = "inv_potion_12",
|
||||||
|
items = "Dreamshard Elixir (TurtleWoW custom)" },
|
||||||
|
{ id = "MP5", label = "MP5", sub = "Mana", full = "Mana regen", kind = "aura",
|
||||||
|
icon = "inv_potion_45",
|
||||||
|
items = "Mageblood Potion - Nightfin Soup is a FOOD buff, not this column" },
|
||||||
|
{ id = "ARM", label = "ARM", sub = "Armor", full = "Armor", kind = "aura",
|
||||||
|
icon = "inv_potion_66",
|
||||||
|
items = "Elixir of Superior Defense" },
|
||||||
|
{ id = "HPELX", label = "FRT", sub = "Fort.", full = "Elixir of Fortitude", kind = "aura",
|
||||||
|
icon = "inv_potion_43",
|
||||||
|
items = "Elixir of Fortitude - stacks with the alcohol column" },
|
||||||
|
{ id = "ALC", label = "ALC", sub = "Alcohol", full = "Alcohol buff", kind = "aura",
|
||||||
|
icon = "inv_drink_04",
|
||||||
|
items = "Medivh's Merlot \194\183 Rumsey Rum Black Label \194\183 Kreeg's Stout Beatdown - different types stack, same type does not; one column, strongest shown" },
|
||||||
|
{ id = "FR", label = "FR", sub = "fire", full = "Fire protection", kind = "aura", unverified = true,
|
||||||
|
icon = "inv_potion_24",
|
||||||
|
items = "Greater Fire Protection Potion - situational per boss; enable/disable via the expectation matrix" },
|
||||||
|
-- The remaining school protection columns follow the FR pattern (one
|
||||||
|
-- column per school, cross-school stacking unverified -- see header).
|
||||||
|
-- Unlike FR they carry NO seed entry in data/expectations.lua: the
|
||||||
|
-- dynamic-column rule keeps them hidden until enabled per role/class.
|
||||||
|
{ id = "FrR", label = "FrR", sub = "frost", full = "Frost protection", kind = "aura", unverified = true,
|
||||||
|
icon = "inv_potion_20",
|
||||||
|
items = "Greater Frost Protection Potion - situational per boss; enable/disable via the expectation matrix" },
|
||||||
|
{ id = "NR", label = "NR", sub = "nature", full = "Nature protection", kind = "aura", unverified = true,
|
||||||
|
icon = "inv_potion_22",
|
||||||
|
items = "Greater Nature Protection Potion - situational per boss; enable/disable via the expectation matrix" },
|
||||||
|
{ id = "SR", label = "SR", sub = "shadow", full = "Shadow protection", kind = "aura", unverified = true,
|
||||||
|
icon = "inv_potion_23",
|
||||||
|
items = "Greater Shadow Protection Potion - situational per boss; enable/disable via the expectation matrix" },
|
||||||
|
{ id = "HR", label = "HR", sub = "holy", full = "Holy protection", kind = "aura", unverified = true,
|
||||||
|
icon = "inv_potion_09",
|
||||||
|
items = "Holy Protection Potion - situational per boss; enable/disable via the expectation matrix" },
|
||||||
|
{ id = "AR", label = "AR", sub = "arcane", full = "Arcane protection", kind = "aura", unverified = true,
|
||||||
|
icon = "inv_potion_83",
|
||||||
|
items = "Greater Arcane Protection Potion - situational per boss; enable/disable via the expectation matrix" },
|
||||||
|
{ id = "WPN", label = "WPN", sub = "temp.", full = "Weapon (temporary)", kind = "weapon", unverified = true, -- NOT an aura slot; MH/OH split unverified
|
||||||
|
icon = "inv_stone_sharpeningstone_01",
|
||||||
|
items = "Elemental/Dense Sharpening Stone \194\183 Brilliant Wizard/Mana Oil - permanent enchant: Equipment tab" },
|
||||||
|
}
|
||||||
|
|
||||||
|
-- ------------------------------------------------------------------
|
||||||
|
-- Buff name -> entry table.
|
||||||
|
-- spellID = 0 means "match by name only" (no known aura spell ID).
|
||||||
|
-- ------------------------------------------------------------------
|
||||||
|
DC.CONSUMABLES = {
|
||||||
|
-- FLASK
|
||||||
|
["Flask of the Titans"] = { slot = "FLASK", spellID = 17626, item = "Flask of the Titans", icon = "inv_potion_62" },
|
||||||
|
["Flask of Supreme Power"] = { slot = "FLASK", spellID = 17628, item = "Flask of Supreme Power", icon = "inv_potion_41" },
|
||||||
|
["Flask of Distilled Wisdom"] = { slot = "FLASK", spellID = 17627, item = "Flask of Distilled Wisdom", icon = "inv_potion_97" },
|
||||||
|
["Flask of Chromatic Resistance"] = { slot = "FLASK", spellID = 17629, item = "Flask of Chromatic Resistance", icon = "inv_potion_48" },
|
||||||
|
["Flask of Petrification"] = { slot = "FLASK", spellID = 17624, item = "Flask of Petrification", icon = "inv_potion_26" }, -- NOT inv_potion_98 (a often-repeated wrong guess) -- wowhead-confirmed
|
||||||
|
|
||||||
|
-- FOOD (buff names, not item names).
|
||||||
|
-- "Well Fed" is the generic food aura: the ITEM is identified through
|
||||||
|
-- the tooltip EFFECT line (scan/aura.lua auras.effects) against the
|
||||||
|
-- per-variant discrim patterns below -- first match wins, no match =
|
||||||
|
-- nil = the matrix falls back to the slot-generic icon ("food buff,
|
||||||
|
-- unknown which"). The variant list is the food set of the
|
||||||
|
-- reference stacking rules plus the classic raid staples; the discrim
|
||||||
|
-- patterns are best-effort data (recorded-tooltip verification via
|
||||||
|
-- dev/raiddump pending) -- a wrong one is a one-line fix.
|
||||||
|
-- ["Food"] (the eating tick) deliberately REMOVED: it is the
|
||||||
|
-- sit-and-eat channel aura, not a food buff -- it made the FOOD cell
|
||||||
|
-- green for anyone currently chewing (field-tested). Only lasting
|
||||||
|
-- food buffs count.
|
||||||
|
["Well Fed"] = { slot = "FOOD", spellID = 0, item = "food buff", icon = nil,
|
||||||
|
variants = {
|
||||||
|
-- ORDER MATTERS: first match wins, so specific effect lines
|
||||||
|
-- (crit, ranged AP, spell damage) sit ABOVE the generic stat
|
||||||
|
-- words they could otherwise be swallowed by.
|
||||||
|
-- Several Turtle customs SHARE their aura spell with a classic
|
||||||
|
-- staple (icon research 2026-08-11: Power Mushroom = 24800 =
|
||||||
|
-- Dumplings, Sour Mountain Berry = 18230 = Grilled Squid,
|
||||||
|
-- Juicy Striped Melon = 22731 = Runn Tum Tuber) -- those pairs
|
||||||
|
-- are indistinguishable by effect line; the listed-first item
|
||||||
|
-- is shown as the representative. Same slot, so harmless.
|
||||||
|
{ item = "Danonzo's Tel'Abim Delight", icon = "inv_drink_17", discrim = "[Ss]pell [Dd]amage" },
|
||||||
|
{ item = "Danonzo's Tel'Abim Medley", icon = "inv_misc_food_08", discrim = "[Hh]aste" },
|
||||||
|
{ item = "Danonzo's Tel'Abim Surprise", icon = "inv_misc_food_09", discrim = "[Rr]anged [Aa]ttack [Pp]ower" },
|
||||||
|
{ item = "Gurubashi Gumbo", icon = "inv_misc_food_64", discrim = "[Cc]rit" },
|
||||||
|
{ item = "Le Fishe Au Chocolat", icon = nil, discrim = "[Dd]odge" }, -- icon: questionmark placeholder even upstream (octowow + wowauctions), stays nil
|
||||||
|
{ item = "Empowering Herbal Salad", icon = "inv_misc_food_salad", discrim = "[Hh]ealing" },
|
||||||
|
{ item = "Nightfin Soup", icon = "inv_drink_17", discrim = "8 [Mm]ana" },
|
||||||
|
{ item = "Sagefish Delight", icon = "inv_misc_fish_21", discrim = "6 [Mm]ana" },
|
||||||
|
-- +20 Strength: Dumplings and Power Mushroom, same spell 24800
|
||||||
|
{ item = "Smoked Desert Dumplings", icon = "inv_misc_food_64", discrim = "[Ss]trength" },
|
||||||
|
{ item = "Power Mushroom", icon = "inv_mushroom_11", discrim = "[Ss]trength" }, -- NOT "+10 all stats" (dead pattern until 2026-08-11); tooltip-verified +20 STR
|
||||||
|
-- +10 Agility: Grilled Squid and Sour Mountain Berry, same spell 18230
|
||||||
|
{ item = "Grilled Squid", icon = "inv_misc_fish_13", discrim = "[Aa]gility" },
|
||||||
|
{ item = "Sour Mountain Berry", icon = "inv_misc_food_40", discrim = "[Aa]gility" },
|
||||||
|
-- +10 Intellect: classic Tuber and Turtle Melon, same spell 22731
|
||||||
|
{ item = "Runn Tum Tuber Surprise", icon = "inv_misc_food_63", discrim = "[Ii]ntellect" },
|
||||||
|
{ item = "Juicy Striped Melon", icon = "inv_misc_food_22", discrim = "[Ii]ntellect" },
|
||||||
|
-- generic stamina LAST: Gumbo's line also contains Stamina and
|
||||||
|
-- must be caught by its crit pattern above, never by this one
|
||||||
|
{ item = "Hardened Mushroom", icon = "inv_mushroom_11", discrim = "[Ss]tamina" }, -- NOT "+armor" (dead pattern until 2026-08-11); tooltip-verified +25 STA, spell 25660
|
||||||
|
} },
|
||||||
|
["Increased Stamina"] = { slot = "FOOD", spellID = 0, item = "stamina food", icon = nil },
|
||||||
|
["Dragonbreath Chili"] = { slot = "FOOD", spellID = 15852, item = "Dragonbreath Chili", icon = "inv_drink_17" }, -- yes, the chili really uses a drink icon (wowhead-confirmed via og:image)
|
||||||
|
|
||||||
|
-- AP
|
||||||
|
["Winterfall Firewater"] = { slot = "AP", spellID = 17038, item = "Winterfall Firewater", icon = "inv_potion_92" },
|
||||||
|
["Juju Might"] = { slot = "AP", spellID = 16329, item = "Juju Might", icon = "inv_misc_monsterscales_07" },
|
||||||
|
|
||||||
|
-- STR
|
||||||
|
["Juju Power"] = { slot = "STR", spellID = 16323, item = "Juju Power", icon = "inv_misc_monsterscales_11" },
|
||||||
|
["Elixir of Giants"] = { slot = "STR", spellID = 11405, item = "Elixir of Giants", icon = "inv_potion_61" },
|
||||||
|
["Elixir of the Giants"] = { slot = "STR", spellID = 11405, item = "Elixir of Giants", icon = "inv_potion_61" }, -- AURA name on this client (measured live: SpellInfo(11405) = "Elixir of the Giants"); the item is named without "the", and matching runs on the aura name first
|
||||||
|
["Elixir of Brute Force"] = { slot = "STR", spellID = 17537, item = "Elixir of Brute Force", icon = "inv_potion_40" },
|
||||||
|
|
||||||
|
-- AGI
|
||||||
|
["Elixir of the Mongoose"] = { slot = "AGI", spellID = 17538, item = "Elixir of the Mongoose", icon = "inv_potion_32" },
|
||||||
|
["Elixir of Greater Agility"] = { slot = "AGI", spellID = 11334, item = "Elixir of Greater Agility", icon = "inv_potion_94" },
|
||||||
|
["Greater Agility"] = { slot = "AGI", spellID = 11334, item = "Elixir of Greater Agility", icon = "inv_potion_94" }, -- AURA name (the item name never appears as a buff); was tallied as unknown 14x
|
||||||
|
["Elixir of Agility"] = { slot = "AGI", spellID = 11328, item = "Elixir of Agility", icon = "inv_potion_93" },
|
||||||
|
|
||||||
|
-- BL (Blasted Lands buff slot -- one at a time, pick one; the five
|
||||||
|
-- items were split across STR/nowhere in the old model).
|
||||||
|
-- Aura names + spell ids wowhead-confirmed 2026-08-11 ("Strike of the
|
||||||
|
-- Scorpok" additionally measured live in a 26-man raid). The item
|
||||||
|
-- names ride along as alias keys (spellID 0) because the Turtle
|
||||||
|
-- client may surface the permanent aura under the item name (reference
|
||||||
|
-- note); a key that never matches is harmless.
|
||||||
|
["Rage of Ages"] = { slot = "BL", spellID = 10667, item = "R.O.I.D.S.", icon = "inv_stone_15" },
|
||||||
|
["R.O.I.D.S."] = { slot = "BL", spellID = 0, item = "R.O.I.D.S.", icon = "inv_stone_15" },
|
||||||
|
["Strike of the Scorpok"] = { slot = "BL", spellID = 10669, item = "Ground Scorpok Assay", icon = "inv_misc_dust_02" }, -- old model filed it under STR with the aura name as item -- both wrong (it is +25 AGI, and the item is the Assay)
|
||||||
|
["Ground Scorpok Assay"] = { slot = "BL", spellID = 0, item = "Ground Scorpok Assay", icon = "inv_misc_dust_02" },
|
||||||
|
["Spirit of Boar"] = { slot = "BL", spellID = 10668, item = "Lung Juice Cocktail", icon = "inv_drink_12" },
|
||||||
|
["Lung Juice Cocktail"] = { slot = "BL", spellID = 0, item = "Lung Juice Cocktail", icon = "inv_drink_12" },
|
||||||
|
["Infallible Mind"] = { slot = "BL", spellID = 10692, item = "Cerebral Cortex Compound", icon = "inv_potion_32" }, -- same icon as the Mongoose elixir -- genuine, wowhead-confirmed
|
||||||
|
["Cerebral Cortex Compound"] = { slot = "BL", spellID = 0, item = "Cerebral Cortex Compound", icon = "inv_potion_32" },
|
||||||
|
["Spiritual Domination"] = { slot = "BL", spellID = 10693, item = "Gizzard Gum", icon = "inv_misc_food_30" },
|
||||||
|
["Gizzard Gum"] = { slot = "BL", spellID = 0, item = "Gizzard Gum", icon = "inv_misc_food_30" },
|
||||||
|
|
||||||
|
-- ZANZA (free-stacker; only one Zanza-potion effect at a time; buff
|
||||||
|
-- name = item name, wowhead-confirmed)
|
||||||
|
["Spirit of Zanza"] = { slot = "ZANZA", spellID = 24382, item = "Spirit of Zanza", icon = "inv_potion_30" },
|
||||||
|
|
||||||
|
-- GAE (split out of the old SP column)
|
||||||
|
["Greater Arcane Elixir"] = { slot = "GAE", spellID = 17539, item = "Greater Arcane Elixir", icon = "inv_potion_25" },
|
||||||
|
["Arcane Elixir"] = { slot = "GAE", spellID = 11390, item = "Arcane Elixir", icon = "inv_potion_30" },
|
||||||
|
|
||||||
|
-- SCHOOL (split out of the old SP column; one school elixir at a time)
|
||||||
|
["Elixir of Shadow Power"] = { slot = "SCHOOL", spellID = 11474, item = "Elixir of Shadow Power", icon = "inv_potion_46" },
|
||||||
|
["Elixir of Frost Power"] = { slot = "SCHOOL", spellID = 21920, item = "Elixir of Frost Power", icon = "inv_potion_03" },
|
||||||
|
["Greater Frost Power"] = { slot = "SCHOOL", spellID = 56544, item = "Elixir of Greater Frost Power", icon = "inv_potion_13" }, -- AURA name (TurtleWoW-added). NOT measured in-game: taken from a TurtleWoW spell database (56544 = "Greater Frost Power") plus the item name in pfQuest-turtle's item DB; the aura drops "Elixir of" the same way Giants/Agility above do
|
||||||
|
["Elixir of Greater Firepower"] = { slot = "SCHOOL", spellID = 26276, item = "Elixir of Greater Firepower", icon = "inv_potion_60" },
|
||||||
|
["Elixir of Firepower"] = { slot = "SCHOOL", spellID = 7844, item = "Elixir of Firepower", icon = "inv_potion_33" },
|
||||||
|
|
||||||
|
-- DREAMT (TurtleWoW custom; own slot -- simultaneously active with
|
||||||
|
-- GAE, SCHOOL and SHARD in the caster set)
|
||||||
|
["Dreamtonic"] = { slot = "DREAMT", spellID = 45489, item = "Dreamtonic", icon = "inv_potion_10" }, -- use-spell ID; the aura's own ID is unverified
|
||||||
|
|
||||||
|
-- SHARD (TurtleWoW custom; own slot)
|
||||||
|
["Dreamshard Elixir"] = { slot = "SHARD", spellID = 45427, item = "Dreamshard Elixir", icon = "inv_potion_12" }, -- use-spell ID; the aura's own ID is unverified
|
||||||
|
|
||||||
|
-- MP5 (narrowed: Nightfin Soup is a FOOD buff and lives there now)
|
||||||
|
["Mageblood Potion"] = { slot = "MP5", spellID = 24363, item = "Mageblood Potion", icon = "inv_potion_45" },
|
||||||
|
["Mageblood"] = { slot = "MP5", spellID = 24363, item = "Mageblood Potion", icon = "inv_potion_45" }, -- TWoW buff-name variant (old Data.lua kept both)
|
||||||
|
|
||||||
|
-- ARM
|
||||||
|
["Elixir of Superior Defense"] = { slot = "ARM", spellID = 11348, item = "Elixir of Superior Defense", icon = "inv_potion_66" },
|
||||||
|
["Elixir of Greater Defense"] = { slot = "ARM", spellID = 11349, item = "Elixir of Greater Defense", icon = "inv_potion_65" },
|
||||||
|
|
||||||
|
-- HPELX (alcohol split out into ALC; Fortitude stacks with it)
|
||||||
|
["Elixir of Fortitude"] = { slot = "HPELX", spellID = 3593, item = "Elixir of Fortitude", icon = "inv_potion_43" },
|
||||||
|
["Health II"] = { slot = "HPELX", spellID = 3593, item = "Elixir of Fortitude", icon = "inv_potion_43" }, -- AURA name on this client (measured live: SpellInfo(3593) = "Health II")
|
||||||
|
|
||||||
|
-- ALC (alcohol buffs -- ONE column by design although different
|
||||||
|
-- types stack; see the header compromise note). Buff names and spell
|
||||||
|
-- ids wowhead/octowow-confirmed 2026-08-11; the "Blue Label" aura
|
||||||
|
-- name differs from the Blue item name, both keys are kept.
|
||||||
|
["Medivh's Merlot"] = { slot = "ALC", spellID = 57106, item = "Medivh's Merlot", icon = "inv_drink_waterskin_05" }, -- use-spell ID; the aura's own ID is unverified
|
||||||
|
["Medivh's Merlot Blue"] = { slot = "ALC", spellID = 0, item = "Medivh's Merlot Blue", icon = "inv_drink_waterskin_01" },
|
||||||
|
["Medivh's Merlot Blue Label"] = { slot = "ALC", spellID = 57107, item = "Medivh's Merlot Blue", icon = "inv_drink_waterskin_01" }, -- the AURA name (octowow: spell 57107 "Medivh's Merlot Blue Label")
|
||||||
|
["Rumsey Rum Black Label"] = { slot = "ALC", spellID = 25804, item = "Rumsey Rum Black Label", icon = "inv_drink_04" },
|
||||||
|
["Rumsey Rum"] = { slot = "ALC", spellID = 20875, item = "Rumsey Rum", icon = "inv_drink_03" },
|
||||||
|
["Rumsey Rum Light"] = { slot = "ALC", spellID = 25037, item = "Rumsey Rum Light", icon = "inv_drink_08" },
|
||||||
|
["Gordok Green Grog"] = { slot = "ALC", spellID = 22789, item = "Gordok Green Grog", icon = "inv_drink_03" }, -- same icon as Rumsey Rum -- genuine, wowhead-confirmed
|
||||||
|
["Kreeg's Stout Beatdown"] = { slot = "ALC", spellID = 22790, item = "Kreeg's Stout Beatdown", icon = "inv_drink_05" }, -- +25 Spirit/-5 Int -- the healer set's alcohol, not a stamina drink
|
||||||
|
|
||||||
|
-- FR (protection-potion slot rules are an UNVERIFIED gap, see header)
|
||||||
|
["Greater Fire Protection"] = { slot = "FR", spellID = 17543, item = "Greater Fire Protection Potion", icon = "inv_potion_24", unverified = true },
|
||||||
|
["Fire Protection"] = { slot = "FR", spellID = 7233, item = "Fire Protection Potion", icon = "inv_potion_16", unverified = true },
|
||||||
|
|
||||||
|
-- FrR/NR/SR/HR/AR (same UNVERIFIED protection-potion gap as FR).
|
||||||
|
-- Use-effect spell IDs + item icons wowhead-confirmed 2026-08-11.
|
||||||
|
-- Tier coverage is asymmetric by design: no Lesser Arcane potion and
|
||||||
|
-- no Greater Holy potion exist in vanilla 1.12. The aura on the
|
||||||
|
-- client likely drops the "Greater" (wowhead names both tiers
|
||||||
|
-- "<School> Protection"); the greater keys mirror the FR convention
|
||||||
|
-- and the ID match carries them regardless of the live aura name.
|
||||||
|
["Greater Frost Protection"] = { slot = "FrR", spellID = 17544, item = "Greater Frost Protection Potion", icon = "inv_potion_20", unverified = true },
|
||||||
|
["Frost Protection"] = { slot = "FrR", spellID = 7239, item = "Frost Protection Potion", icon = "inv_potion_13", unverified = true },
|
||||||
|
["Greater Nature Protection"] = { slot = "NR", spellID = 17546, item = "Greater Nature Protection Potion", icon = "inv_potion_22", unverified = true },
|
||||||
|
["Nature Protection"] = { slot = "NR", spellID = 7254, item = "Nature Protection Potion", icon = "inv_potion_06", unverified = true }, -- live aura name carries a trailing space (SpellInfo(7254)); scan/aura.lua's trimmed retry covers it
|
||||||
|
["Greater Shadow Protection"] = { slot = "SR", spellID = 17548, item = "Greater Shadow Protection Potion", icon = "inv_potion_23", unverified = true },
|
||||||
|
["Shadow Protection"] = { slot = "SR", spellID = 7242, item = "Shadow Protection Potion", icon = "inv_potion_44", unverified = true }, -- NAME collides with the priest buff (data/classbuffs.lua SPROT, ids 976/10957/10958): ID-before-name keeps them apart; a name-only scan cannot tell the two, which SR's default-off expectation mitigates
|
||||||
|
["Holy Protection"] = { slot = "HR", spellID = 7245, item = "Holy Protection Potion", icon = "inv_potion_09", unverified = true },
|
||||||
|
["Greater Arcane Protection"] = { slot = "AR", spellID = 17549, item = "Greater Arcane Protection Potion", icon = "inv_potion_83", unverified = true },
|
||||||
|
|
||||||
|
-- WPN: intentionally NO aura entries - temp enchant is not a buff
|
||||||
|
}
|
||||||
|
|
||||||
|
-- ------------------------------------------------------------------
|
||||||
|
-- DC.ResolveItem(buffName, effectText) -> item, icon
|
||||||
|
-- The PURE identification ladder (spec: "Identification ladder"):
|
||||||
|
-- 1. unique buff name -> item/icon straight from the entry
|
||||||
|
-- 2. generic buff name -> match effectText against the entry's
|
||||||
|
-- variant discrim patterns (plain Lua 5.0 string.find, first match
|
||||||
|
-- wins) -> that variant's item/icon
|
||||||
|
-- 3. no match / no effect text -> nil, nil (the caller falls back to
|
||||||
|
-- the slot-generic icon: "slot filled, item unknown")
|
||||||
|
-- An entry-level discrim (no variants) gates rule 1 the same way: the
|
||||||
|
-- item only resolves when the effect line matches. Offline-testable --
|
||||||
|
-- no WoW API, no upvalues beyond the data table.
|
||||||
|
-- ------------------------------------------------------------------
|
||||||
|
function DC.ResolveItem(buffName, effectText)
|
||||||
|
local def = DC.CONSUMABLES[buffName]
|
||||||
|
if not def then
|
||||||
|
return nil, nil
|
||||||
|
end
|
||||||
|
if def.variants then
|
||||||
|
if type(effectText) == "string" then
|
||||||
|
local n = table.getn(def.variants)
|
||||||
|
for i = 1, n do
|
||||||
|
local v = def.variants[i]
|
||||||
|
if v.discrim and string.find(effectText, v.discrim) then
|
||||||
|
return v.item, v.icon
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
return nil, nil
|
||||||
|
end
|
||||||
|
if def.discrim then
|
||||||
|
if type(effectText) == "string"
|
||||||
|
and string.find(effectText, def.discrim) then
|
||||||
|
return def.item, def.icon
|
||||||
|
end
|
||||||
|
return nil, nil
|
||||||
|
end
|
||||||
|
return def.item, def.icon
|
||||||
|
end
|
||||||
|
|
||||||
|
-- ------------------------------------------------------------------
|
||||||
|
-- spellID -> slotId map, generated from DC.CONSUMABLES (spellID > 0).
|
||||||
|
-- Collision rule: at classify time an ID match beats a
|
||||||
|
-- name match. data/classbuffs.lua later MERGES its rank IDs into this same
|
||||||
|
-- map (slot ids are unique across tabs, so one flat map is safe).
|
||||||
|
-- ------------------------------------------------------------------
|
||||||
|
DC.SPELL_TO_SLOT = {}
|
||||||
|
for name, def in pairs(DC.CONSUMABLES) do
|
||||||
|
if def.spellID and def.spellID > 0 then
|
||||||
|
DC.SPELL_TO_SLOT[def.spellID] = def.slot
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -0,0 +1,388 @@
|
|||||||
|
-- DopingControl data/enchants.lua
|
||||||
|
-- Equipment tab: 15 enchant slots + 2 item-tile slots (both trinkets), in
|
||||||
|
-- paperdoll order, with 1.12 inventory slot IDs.
|
||||||
|
-- Enchant columns are checked present/missing only (enchant ID in the item
|
||||||
|
-- link ~= 0); an EMPTY enchant slot is always a gap (core/model.lua), so it
|
||||||
|
-- is never gated by the expectation checkbox -- only the "has an enchant"
|
||||||
|
-- question is. Item-tile slots (trinkets) show the equipped item and
|
||||||
|
-- ignore expectations entirely; only an EMPTY slot counts as a gap there.
|
||||||
|
--
|
||||||
|
-- Server note: neck, both rings AND the waist ARE enchantable on this
|
||||||
|
-- server (a server-custom feature beyond vanilla WoW, where none of them
|
||||||
|
-- take an enchant) -- NECK/R1/R2/WAIST are therefore kind="ench" like any
|
||||||
|
-- armor slot, not item tiles. The waist enchant is a "Belt Buckle" item
|
||||||
|
-- (Copper/Bronze/Thorium/Dreamsteel/Bloody Belt Buckle),
|
||||||
|
-- the same custom itemization line as the neck/ring gems. Their
|
||||||
|
-- expectation defaults to OFF (data/expectations.lua): unlike the ARMOR
|
||||||
|
-- enchant slots, a neck/ring/waist enchant is not assumed baseline raid
|
||||||
|
-- gear, so the columns only track it once a role/class opts in via the
|
||||||
|
-- options grid.
|
||||||
|
--
|
||||||
|
-- Bug history (2026-08-30): the waist (invSlot 6) was missing from this
|
||||||
|
-- table entirely -- not a deliberate exclusion, an oversight that predates
|
||||||
|
-- the Belt Buckle lookup above. Because scan/gear.lua's G.InvSlots()
|
||||||
|
-- derives the full GetInventoryItemLink read list from THIS table, the
|
||||||
|
-- omission silently dropped the waist from every equipment read: no raid
|
||||||
|
-- dump ever carried a ";6=" entry, and the HIT tab under-counted any
|
||||||
|
-- +hit/+resist on a belt (scan/hit.lua's H.SLOTS already listed 6 and
|
||||||
|
-- expected scan/gear.lua's rawLinks to cover it -- see that file's
|
||||||
|
-- ReadUnit comment). Adding WAIST here fixes both at the source.
|
||||||
|
-- Pure Lua 5.0, no WoW API -- dofile-loadable offline.
|
||||||
|
|
||||||
|
DopingControl = DopingControl or {}
|
||||||
|
local DC = DopingControl
|
||||||
|
|
||||||
|
-- ------------------------------------------------------------------
|
||||||
|
-- Slot definitions (array, order = column order). Same field shape as the
|
||||||
|
-- other tabs plus invSlot (GetInventoryItemLink slot number). Ids
|
||||||
|
-- HEAD/SHLD/... with tile labels HEA/SHO/...
|
||||||
|
-- ------------------------------------------------------------------
|
||||||
|
DC.SLOTS_EQUIPMENT = {
|
||||||
|
{ id = "HEAD", label = "HEA", sub = "slot 1", full = "Head", kind = "ench", invSlot = 1 },
|
||||||
|
{ id = "NECK", label = "NCK", sub = "slot 2", full = "Neck", kind = "ench", invSlot = 2 }, -- enchantable on this server (custom, unlike vanilla)
|
||||||
|
{ id = "SHLD", label = "SHO", sub = "slot 3", full = "Shoulders", kind = "ench", invSlot = 3 },
|
||||||
|
{ id = "BACK", label = "BCK", sub = "slot 15", full = "Back", kind = "ench", invSlot = 15 },
|
||||||
|
{ id = "CHST", label = "CHE", sub = "slot 5", full = "Chest", kind = "ench", invSlot = 5 },
|
||||||
|
{ id = "WAIST", label = "WAI", sub = "slot 6", full = "Waist", kind = "ench", invSlot = 6 }, -- enchantable on this server (custom Belt Buckle line, unlike vanilla)
|
||||||
|
{ id = "WRST", label = "WRI", sub = "slot 9", full = "Wrist", kind = "ench", invSlot = 9 },
|
||||||
|
{ id = "HAND", label = "HND", sub = "slot 10", full = "Hands", kind = "ench", invSlot = 10 },
|
||||||
|
{ id = "LEGS", label = "LEG", sub = "slot 7", full = "Legs", kind = "ench", invSlot = 7 },
|
||||||
|
{ id = "FEET", label = "FEE", sub = "slot 8", full = "Feet", kind = "ench", invSlot = 8 },
|
||||||
|
{ id = "MAIN", label = "MH", sub = "slot 16", full = "Main hand", kind = "ench", invSlot = 16 },
|
||||||
|
{ id = "OFFH", label = "OH", sub = "slot 17", full = "Off hand", kind = "ench", invSlot = 17 },
|
||||||
|
{ id = "RNGD", label = "RNG", sub = "slot 18", full = "Ranged (scope)", kind = "ench", invSlot = 18 }, -- scope counts as the enchant
|
||||||
|
{ id = "R1", label = "R1", sub = "slot 11", full = "Ring 1", kind = "ench", invSlot = 11 }, -- enchantable on this server (custom, unlike vanilla)
|
||||||
|
{ id = "R2", label = "R2", sub = "slot 12", full = "Ring 2", kind = "ench", invSlot = 12 }, -- enchantable on this server (custom, unlike vanilla)
|
||||||
|
{ id = "T1", label = "T1", sub = "slot 13", full = "Trinket 1", kind = "trinket", invSlot = 13 },
|
||||||
|
{ id = "T2", label = "T2", sub = "slot 14", full = "Trinket 2", kind = "trinket", invSlot = 14 },
|
||||||
|
}
|
||||||
|
|
||||||
|
-- ------------------------------------------------------------------
|
||||||
|
-- Item-subtype exemptions: whether an enchant applies at all is a
|
||||||
|
-- property of the equipped ITEM, not of the role. If the equipped item's
|
||||||
|
-- itemSubType (GetItemInfo return 6 on TWoW 1.18.1)
|
||||||
|
-- is listed for the slot, the cell classifies as NOTEXP instead
|
||||||
|
-- of MISSING.
|
||||||
|
--
|
||||||
|
-- Shape: DC.ENCH_EXEMPT[slotId][itemSubType] = true
|
||||||
|
--
|
||||||
|
-- [ASSUMPTION] English-client subtype strings: wands = "Wands", off-hand
|
||||||
|
-- frills/holdables = "Miscellaneous", thrown = "Thrown" (thrown weapons
|
||||||
|
-- cannot take a scope -- same item-property principle as wands; "Thrown"
|
||||||
|
-- is a deliberate extension --
|
||||||
|
-- kept because dropping it would show thrown users a
|
||||||
|
-- false MISSING scope). All three strings are on the probe checklist:
|
||||||
|
-- the /dc probe battery dumps GetItemInfo subType (return 6) for every
|
||||||
|
-- equipped slot ("Q5b" lines, core/probe.lua) -- a mismatch means the
|
||||||
|
-- exemption silently never fires and must be corrected here.
|
||||||
|
-- ------------------------------------------------------------------
|
||||||
|
DC.ENCH_EXEMPT = {
|
||||||
|
RNGD = { ["Wands"] = true, ["Thrown"] = true }, -- Wands: no scope; Thrown: no scope [ASSUMPTION]
|
||||||
|
OFFH = { ["Miscellaneous"] = true }, -- held-in-off-hand frills: not enchantable
|
||||||
|
}
|
||||||
|
|
||||||
|
-- ------------------------------------------------------------------
|
||||||
|
-- Class-based enchant exemptions:
|
||||||
|
-- whole CLASSES for which an ench slot never applies,
|
||||||
|
-- regardless of what (if anything) is equipped. DC_Model.classify checks
|
||||||
|
-- this FIRST, before gearRead/EMPTY/expectation logic, and returns
|
||||||
|
-- NOTEXP (detail "no ranged enchant for this class").
|
||||||
|
--
|
||||||
|
-- Shape: DC.ENCH_CLASS_EXEMPT[slotId][classToken] = true
|
||||||
|
-- (classToken = english GetRaidRosterInfo return-6 token)
|
||||||
|
--
|
||||||
|
-- RNGD: shamans/druids/priests/mages/warlocks carry wands/totems/idols/
|
||||||
|
-- relics or nothing there -- no scope ever applies.
|
||||||
|
-- PALADIN added: vanilla paladins have a permanently EMPTY ranged
|
||||||
|
-- slot (no ranged/relic item exists for them on 1.12), so a scope can
|
||||||
|
-- never apply either.
|
||||||
|
-- ------------------------------------------------------------------
|
||||||
|
DC.ENCH_CLASS_EXEMPT = {
|
||||||
|
RNGD = {
|
||||||
|
SHAMAN = true,
|
||||||
|
DRUID = true,
|
||||||
|
PRIEST = true,
|
||||||
|
MAGE = true,
|
||||||
|
WARLOCK = true,
|
||||||
|
PALADIN = true, -- permanently empty ranged slot in vanilla
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
-- ------------------------------------------------------------------
|
||||||
|
-- Enchant ID -> display text (1.12 has no API for it). DC.ENCH_NAMES below
|
||||||
|
-- is generated from the client's own SpellItemEnchantment.dbc, restricted
|
||||||
|
-- to permanent item enchants (ENCHANT_ITEM spell effects).
|
||||||
|
-- DC.EnchantName(id) -> name | nil (unknown id, or no enchant: 0/nil).
|
||||||
|
-- DC.EnchantLabel(id) -> name | "enchant #<id>" | nil (only for no enchant):
|
||||||
|
-- an id missing from the table is still KNOWN to be enchanted, so it is
|
||||||
|
-- never reported as missing -- only its name is unknown.
|
||||||
|
-- ------------------------------------------------------------------
|
||||||
|
function DC.EnchantName(id)
|
||||||
|
if type(id) ~= "number" or id <= 0 or not DC.ENCH_NAMES then
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
return DC.ENCH_NAMES[id]
|
||||||
|
end
|
||||||
|
|
||||||
|
function DC.EnchantLabel(id)
|
||||||
|
if type(id) ~= "number" or id <= 0 then
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
return DC.EnchantName(id) or ("enchant #" .. id)
|
||||||
|
end
|
||||||
|
|
||||||
|
-- BEGIN GENERATED ENCH_NAMES (tools/gen-enchant-names.py; do not edit by hand)
|
||||||
|
DC.ENCH_NAMES = {
|
||||||
|
[1] = "Rockbiter 3",
|
||||||
|
[3] = "Flametongue 3",
|
||||||
|
[15] = "Reinforced Armor +8/+1 Stamina",
|
||||||
|
[16] = "Reinforced Armor +16/+2 Stamina",
|
||||||
|
[17] = "Reinforced Armor +24/+3 Stamina",
|
||||||
|
[18] = "Reinforced Armor +32/+4 Stamina",
|
||||||
|
[24] = "Mana +5",
|
||||||
|
[28] = "+4 All Resistances",
|
||||||
|
[30] = "Scope (+1 Damage)",
|
||||||
|
[32] = "Scope (+2 Damage)",
|
||||||
|
[33] = "Scope (+3 Damage)",
|
||||||
|
[34] = "Counterweight +3% Attack Speed",
|
||||||
|
[36] = "Enchant: Fiery Blaze",
|
||||||
|
[37] = "Weapon Chain - Immune Disarm",
|
||||||
|
[41] = "Health +5",
|
||||||
|
[43] = "Iron Spike (8-12)",
|
||||||
|
[44] = "Absorption (10)",
|
||||||
|
[63] = "Absorption (25)",
|
||||||
|
[65] = "+1 All Resistances",
|
||||||
|
[66] = "Stamina +1",
|
||||||
|
[71] = "+1 Stamina",
|
||||||
|
[72] = "+2 Stamina",
|
||||||
|
[73] = "+3 Stamina",
|
||||||
|
[76] = "+3 Agility",
|
||||||
|
[80] = "+2 Intellect",
|
||||||
|
[90] = "+4 Agility",
|
||||||
|
[92] = "+6 Agility",
|
||||||
|
[93] = "+7 Agility",
|
||||||
|
[94] = "+4 Intellect",
|
||||||
|
[96] = "+6 Intellect",
|
||||||
|
[98] = "+4 Spirit",
|
||||||
|
[104] = "+6 Stamina",
|
||||||
|
[107] = "+5 Strength",
|
||||||
|
[108] = "+6 Strength",
|
||||||
|
[208] = "Healing +7",
|
||||||
|
[225] = "Nature Damage +9",
|
||||||
|
[241] = "Weapon Damage +2",
|
||||||
|
[242] = "Health +15",
|
||||||
|
[243] = "Spirit +1",
|
||||||
|
[246] = "Mana +20",
|
||||||
|
[247] = "Agility +1",
|
||||||
|
[248] = "Strength +1",
|
||||||
|
[249] = "Beastslaying +2",
|
||||||
|
[250] = "Weapon Damage +1",
|
||||||
|
[254] = "Health +25",
|
||||||
|
[255] = "Spirit +3",
|
||||||
|
[256] = "+5 Fire Resistance",
|
||||||
|
[345] = "+40 Armor",
|
||||||
|
[354] = "+9 Intellect",
|
||||||
|
[357] = "+9 Strength",
|
||||||
|
[362] = "+10 Strength",
|
||||||
|
[370] = "+12 Spirit",
|
||||||
|
[371] = "+12 Stamina",
|
||||||
|
[426] = "Spell Damage +5",
|
||||||
|
[432] = "Spell Damage +12",
|
||||||
|
[440] = "Healing +12",
|
||||||
|
[449] = "Rift Tear",
|
||||||
|
[450] = "Scope (+2% Ranged Crit)",
|
||||||
|
[463] = "Mithril Spike (16-20)",
|
||||||
|
[464] = "Mithril Spurs",
|
||||||
|
[663] = "Scope (+5 Damage)",
|
||||||
|
[664] = "Scope (+7 Damage)",
|
||||||
|
[684] = "+15 Strength",
|
||||||
|
[723] = "Intellect +3",
|
||||||
|
[724] = "Stamina +3",
|
||||||
|
[744] = "Armor +20",
|
||||||
|
[763] = "Block +1%",
|
||||||
|
[783] = "Armor +10",
|
||||||
|
[803] = "Fiery Weapon",
|
||||||
|
[804] = "+10 Shadow Resistance",
|
||||||
|
[805] = "Weapon Damage +4",
|
||||||
|
[823] = "Strength +3",
|
||||||
|
[843] = "Mana +30",
|
||||||
|
[844] = "Mining +2",
|
||||||
|
[845] = "Herbalism +2",
|
||||||
|
[846] = "Fishing +2",
|
||||||
|
[847] = "All Stats +1",
|
||||||
|
[848] = "Armor +30",
|
||||||
|
[849] = "Agility +3",
|
||||||
|
[850] = "Health +35",
|
||||||
|
[851] = "Spirit +5",
|
||||||
|
[852] = "Stamina +5",
|
||||||
|
[853] = "Beastslaying +6",
|
||||||
|
[854] = "Elemental Slayer +6",
|
||||||
|
[856] = "Strength +5",
|
||||||
|
[857] = "Mana +50",
|
||||||
|
[863] = "Blocking +2%",
|
||||||
|
[865] = "Skinning +5",
|
||||||
|
[866] = "All Stats +2",
|
||||||
|
[883] = "+15 Agility",
|
||||||
|
[884] = "Armor +50",
|
||||||
|
[903] = "+3 All Resistances",
|
||||||
|
[904] = "Agility +5",
|
||||||
|
[905] = "Intellect +5",
|
||||||
|
[906] = "Mining +5",
|
||||||
|
[907] = "Spirit +7",
|
||||||
|
[908] = "Health +50",
|
||||||
|
[909] = "Herbalism +5",
|
||||||
|
[910] = "Increased Stealth",
|
||||||
|
[911] = "Minor Speed Increase",
|
||||||
|
[912] = "Demonslaying",
|
||||||
|
[913] = "Mana +65",
|
||||||
|
[923] = "Defense +3",
|
||||||
|
[924] = "Defense +1",
|
||||||
|
[925] = "Defense +2",
|
||||||
|
[926] = "+8 Frost Resistance",
|
||||||
|
[927] = "Strength +7",
|
||||||
|
[928] = "All Stats +3",
|
||||||
|
[929] = "Stamina +7",
|
||||||
|
[930] = "Minor Mount Speed Increase",
|
||||||
|
[931] = "Attack Speed +1%",
|
||||||
|
[943] = "Weapon Damage +3",
|
||||||
|
[963] = "Weapon Damage +7",
|
||||||
|
[1023] = "Feedback 1",
|
||||||
|
[1068] = "+15 Stamina",
|
||||||
|
[1125] = "+22 Intellect",
|
||||||
|
[1149] = "+20 Spirit",
|
||||||
|
[1250] = "+8 Arcane Resistance",
|
||||||
|
[1252] = "+10 Arcane Resistance",
|
||||||
|
[1257] = "+15 Arcane Resistance",
|
||||||
|
[1267] = "+25 Arcane Resistance",
|
||||||
|
[1341] = "+7 Fire Resistance",
|
||||||
|
[1342] = "+8 Fire Resistance",
|
||||||
|
[1344] = "+10 Fire Resistance",
|
||||||
|
[1388] = "+8 Nature Resistance",
|
||||||
|
[1434] = "+8 Shadow Resistance",
|
||||||
|
[1436] = "+10 Shadow Resistance",
|
||||||
|
[1441] = "+15 Shadow Resistance",
|
||||||
|
[1446] = "+20 Shadow Resistance",
|
||||||
|
[1483] = "Mana +150",
|
||||||
|
[1503] = "HP +100",
|
||||||
|
[1504] = "Armor +125",
|
||||||
|
[1505] = "+20 Fire Resistance",
|
||||||
|
[1506] = "Strength +8",
|
||||||
|
[1507] = "Stamina +8",
|
||||||
|
[1508] = "Agility +8",
|
||||||
|
[1509] = "Intellect +8",
|
||||||
|
[1510] = "Spirit +8",
|
||||||
|
[1523] = "MANA/FR +85/14",
|
||||||
|
[1524] = "HP/FR +75/14",
|
||||||
|
[1525] = "AC/FR +110/14",
|
||||||
|
[1526] = "STR/FR +10/14",
|
||||||
|
[1527] = "STA/FR +10/14",
|
||||||
|
[1528] = "AGI/FR +10/14",
|
||||||
|
[1529] = "INT/FR +10/14",
|
||||||
|
[1530] = "SPI/FR +10/14",
|
||||||
|
[1532] = "STR/STA/AC/FR +10/10/110/15",
|
||||||
|
[1543] = "INT/SPI/MANA/FR +10/10/100/15",
|
||||||
|
[1704] = "Thorium Spike (20-30)",
|
||||||
|
[1843] = "Reinforced Armor +40/+5 Stamina",
|
||||||
|
[1883] = "Intellect +7",
|
||||||
|
[1884] = "Spirit +9",
|
||||||
|
[1885] = "Strength +9",
|
||||||
|
[1886] = "Stamina +9",
|
||||||
|
[1887] = "Agility +7",
|
||||||
|
[1888] = "+5 All Resistances",
|
||||||
|
[1889] = "Armor +70",
|
||||||
|
[1890] = "Spirit +9",
|
||||||
|
[1891] = "All Stats +4",
|
||||||
|
[1892] = "Health +100",
|
||||||
|
[1893] = "Mana +100",
|
||||||
|
[1894] = "Icy Weapon",
|
||||||
|
[1896] = "Weapon Damage +9",
|
||||||
|
[1897] = "Weapon Damage +5",
|
||||||
|
[1898] = "Lifestealing",
|
||||||
|
[1899] = "Unholy Weapon",
|
||||||
|
[1900] = "Crusader",
|
||||||
|
[1903] = "Spirit +9",
|
||||||
|
[1904] = "Intellect +9",
|
||||||
|
[1944] = "+5 Defense",
|
||||||
|
[2045] = "+14 Ranged Attack Power",
|
||||||
|
[2082] = "+6 Arcane Spell Damage",
|
||||||
|
[2084] = "+9 Arcane Spell Damage",
|
||||||
|
[2144] = "+40 Shadow Spell Damage",
|
||||||
|
[2158] = "+6 Fire Spell Damage",
|
||||||
|
[2160] = "+9 Fire Spell Damage",
|
||||||
|
[2236] = "+9 Frost Spell Damage",
|
||||||
|
[2296] = "+40 Nature Spell Damage",
|
||||||
|
[2366] = "+2 mana every 5 sec.",
|
||||||
|
[2443] = "Frost Spell Damage +7",
|
||||||
|
[2463] = "+7 Fire Resistance",
|
||||||
|
[2483] = "+5 Fire Resistance",
|
||||||
|
[2484] = "+5 Frost Resistance",
|
||||||
|
[2485] = "+5 Arcane Resistance",
|
||||||
|
[2486] = "+5 Nature Resistance",
|
||||||
|
[2487] = "+5 Shadow Resistance",
|
||||||
|
[2488] = "+5 All Resistances",
|
||||||
|
[2503] = "Defense +5",
|
||||||
|
[2504] = "Spell Damage +30",
|
||||||
|
[2505] = "Healing Spells +55",
|
||||||
|
[2523] = "Scope (+3% Ranged Hit)",
|
||||||
|
[2544] = "Healing and Spell Damage +12",
|
||||||
|
[2545] = "Dodge +1%",
|
||||||
|
[2563] = "Strength +15",
|
||||||
|
[2564] = "Agility +15",
|
||||||
|
[2565] = "Mana Regen 4 per 5 sec.",
|
||||||
|
[2566] = "Healing Spells +24",
|
||||||
|
[2567] = "Spirit +20",
|
||||||
|
[2568] = "Intellect +22",
|
||||||
|
[2583] = "Defense +7/Stamina +10/Block Value +15",
|
||||||
|
[2584] = "Defense +7/Stamina +10/Healing Spells +24",
|
||||||
|
[2585] = "Attack Power +28/Dodge +1%",
|
||||||
|
[2586] = "Ranged Attack Power +24/Stamina +10/Hit +1%",
|
||||||
|
[2587] = "Healing and Spell Damage +13/Intellect +15",
|
||||||
|
[2588] = "Healing and Spell Damage +18/Spell Hit +1%",
|
||||||
|
[2589] = "Healing and Spell Damage +18/Stamina +10",
|
||||||
|
[2590] = "Mana Regen +4/Stamina +10/Healing Spells +24",
|
||||||
|
[2591] = "Intellect +10/Stamina +10/Healing Spells +24",
|
||||||
|
[2603] = "Eternium Line",
|
||||||
|
[2604] = "+33 Healing Spells",
|
||||||
|
[2605] = "+18 Spell Damage and Healing",
|
||||||
|
[2606] = "+30 Attack Power",
|
||||||
|
[2613] = "Threat +2%",
|
||||||
|
[2614] = "Shadow Damage +20",
|
||||||
|
[2615] = "Frost Damage +20",
|
||||||
|
[2616] = "Fire Damage +20",
|
||||||
|
[2617] = "Healing Spells +30",
|
||||||
|
[2619] = "+15 Fire Resistance",
|
||||||
|
[2620] = "+15 Nature Resistance",
|
||||||
|
[2621] = "Subtlety",
|
||||||
|
[2622] = "Dodge +1%",
|
||||||
|
[2646] = "Agility +25",
|
||||||
|
[2681] = "+10 Nature Resistance",
|
||||||
|
[2682] = "+10 Frost Resistance",
|
||||||
|
[2683] = "+10 Shadow Resistance",
|
||||||
|
[2715] = "Healing +31 and 5 mana per 5 sec.",
|
||||||
|
[2716] = "Stamina +16 and Armor +100",
|
||||||
|
[2717] = "Attack Power +26 and +1% Critical Strike",
|
||||||
|
[2721] = "Spell Damage +15 and +1% Spell Critical Strike",
|
||||||
|
[2722] = "Block Value +10",
|
||||||
|
[3001] = "Arcane Damage +20",
|
||||||
|
[3002] = "Nature Damage +20",
|
||||||
|
[3003] = "Holy Damage +20",
|
||||||
|
[3004] = "Wolfsheart",
|
||||||
|
[3005] = "+14 Stamina",
|
||||||
|
[3008] = "+6 Spell Penetration",
|
||||||
|
[3011] = "+1% Vampirism",
|
||||||
|
[3012] = "+2% Vampirism",
|
||||||
|
[3016] = "+1% Haste",
|
||||||
|
[3017] = "+2% Haste",
|
||||||
|
[3021] = "+3 Spell Power",
|
||||||
|
[3024] = "+5 Defense",
|
||||||
|
[3025] = "+9 Damage and Healing Spells",
|
||||||
|
[3026] = "Thorium Spurs",
|
||||||
|
[3038] = "Armor Penetration +40",
|
||||||
|
[3047] = "+25 Spell Penetration",
|
||||||
|
[3049] = "+2% Dodge",
|
||||||
|
[3051] = "+25 Armor Penetration",
|
||||||
|
[3052] = "+10 Intellect",
|
||||||
|
[3054] = "1% Resilience",
|
||||||
|
[3057] = "Healing +8",
|
||||||
|
}
|
||||||
|
-- END GENERATED ENCH_NAMES
|
||||||
@@ -0,0 +1,151 @@
|
|||||||
|
-- DopingControl data/expectations.lua
|
||||||
|
-- Default expectation matrix DC.DEFAULT_EXPECT[role][slotId] = true.
|
||||||
|
-- One flat slot-id namespace across all three tabs (ids are unique).
|
||||||
|
--
|
||||||
|
-- This table STAYS the role x slot
|
||||||
|
-- SEED. The saved/live shape is per class --
|
||||||
|
-- db.expectations[role][class][slotId] -- materialized from these lines
|
||||||
|
-- by core/config.lua (DC.EnsureExpectations) for every class that can
|
||||||
|
-- perform the role (DC_Roles.ROLE_CLASSES). Class-differentiated
|
||||||
|
-- DEFAULTS are deliberately NOT shipped; the one
|
||||||
|
-- motivating example lives here as a comment only:
|
||||||
|
-- a TANK-confirmed SHAMAN wants AI = true (Arcane Intellect for the
|
||||||
|
-- mana-fed tank kit) while a TANK WARRIOR does not -- the user flips
|
||||||
|
-- expectations.TANK.SHAMAN.AI in the options grid. AI stays
|
||||||
|
-- a CASTER/HEALER role default for every performing class.
|
||||||
|
--
|
||||||
|
-- Role-line choices:
|
||||||
|
-- Consumables: the four role sets of the reference stacking rules
|
||||||
|
-- (TurtleWoW 1.17.2 -- melee / caster / tank / healer, each "all
|
||||||
|
-- active simultaneously") ARE the defaults now. RANGED
|
||||||
|
-- has no reference set of its own and is derived from the melee
|
||||||
|
-- line (no STR -- ranged AP scales off AGI -- and no
|
||||||
|
-- FLASK). FR stays situational for all five roles
|
||||||
|
-- (enable/disable per boss via the expectation matrix),
|
||||||
|
-- WPN as before. The other school protection slots
|
||||||
|
-- (FrR/NR/SR/HR/AR, added 2026-08-11) carry NO seed entry
|
||||||
|
-- at all: default off for every role, so the dynamic-
|
||||||
|
-- column rule hides them until someone enables them per
|
||||||
|
-- role/class for a matching boss (same treatment as EMB
|
||||||
|
-- and T1/T2 below) -- unlike FR, which ships seeded true.
|
||||||
|
-- Class buffs: SOUL only HEALER, AI only CASTER/HEALER, SPROT situational
|
||||||
|
-- for all five -- like FR. EMB (Emerald Blessing) has NO
|
||||||
|
-- seed entry: the buff comes from a raid questline and most
|
||||||
|
-- druids do not have it. The column therefore stays hidden
|
||||||
|
-- until someone enables it per role/class in the options
|
||||||
|
-- grid; the dynamic-column rule makes an unexpected slot
|
||||||
|
-- disappear entirely (same treatment as T1/T2 below).
|
||||||
|
-- Equipment: ALL armor enchant slots for ALL roles, WAIST included
|
||||||
|
-- (owner decision, 2026-08-30: every role wears a belt,
|
||||||
|
-- and it is enchantable on this server via the Belt
|
||||||
|
-- Buckle item line -- so it is worn equipment like any
|
||||||
|
-- other slot, not an optional extra like neck/rings);
|
||||||
|
-- per-item exemptions (wand/holdable) are handled at
|
||||||
|
-- classify time via DC.ENCH_EXEMPT, not here.
|
||||||
|
-- NECK/R1/R2 (also enchantable on this server, a custom
|
||||||
|
-- feature beyond vanilla) stay OFF by default for every
|
||||||
|
-- role: unlike WAIST, a neck/ring enchant is not assumed
|
||||||
|
-- baseline raid gear -- enable it per role/class in the
|
||||||
|
-- options grid when it matters. T1/T2 (trinket item tiles)
|
||||||
|
-- carry NO seed entry at all -- the model ignores
|
||||||
|
-- expectations for tile-kind slots entirely (item
|
||||||
|
-- present/absent decides, always shown, never gated).
|
||||||
|
--
|
||||||
|
-- Known debatable rows -- documented deliberately, the shipped defaults
|
||||||
|
-- win:
|
||||||
|
-- * TANK+AP / MELEE+FLASK: the OLD defaults deliberately excluded them;
|
||||||
|
-- the reference role sets include them (tank runs Winterfall
|
||||||
|
-- Firewater, melee runs Flask of the Titans), so both
|
||||||
|
-- flipped to true with the v6 slot model.
|
||||||
|
-- * RANGED line: derived, not covered by the reference sets (see above) -- BL is included
|
||||||
|
-- (Ground Scorpok Assay is +25 AGI), STR/FLASK are not.
|
||||||
|
-- * HEALER+HPELX: the reference healer set carries no Elixir of Fortitude, so
|
||||||
|
-- HPELX defaults off for HEALER while ALC (Kreeg's
|
||||||
|
-- Stout Beatdown + Medivh's Merlot Blue) is on.
|
||||||
|
-- * ZANZA: expected for every role (all four reference sets carry it).
|
||||||
|
-- * Equipment: an alternative default would differentiate per role
|
||||||
|
-- (OFFH only TANK/MELEE, RNGD only RANGED,
|
||||||
|
-- casters/healers neither); deliberately flattened to
|
||||||
|
-- all-roles + ENCH_EXEMPT item checks.
|
||||||
|
--
|
||||||
|
-- This table is the pristine seed: core/config.lua materializes it into
|
||||||
|
-- db.expectations (per class, deep copies) on first load; the options
|
||||||
|
-- editor mutates only the materialized copies, never this table.
|
||||||
|
-- Pure Lua 5.0, no WoW API -- dofile-loadable offline.
|
||||||
|
|
||||||
|
DopingControl = DopingControl or {}
|
||||||
|
local DC = DopingControl
|
||||||
|
|
||||||
|
DC.DEFAULT_EXPECT = {
|
||||||
|
TANK = {
|
||||||
|
-- consumables (reference tank set: Mongoose, Firewater, Juju Power,
|
||||||
|
-- R.O.I.D.S., Zanza, Superior Defense, Fortitude, Titans, food,
|
||||||
|
-- Merlot + Rumsey, sharpening stone; Gift of Arthas has no column)
|
||||||
|
FLASK = true, FOOD = true, AP = true, STR = true, AGI = true,
|
||||||
|
BL = true, ZANZA = true, ARM = true, HPELX = true, ALC = true,
|
||||||
|
FR = true, WPN = true,
|
||||||
|
-- class buffs
|
||||||
|
MOTW = true, PWF = true, SPROT = true,
|
||||||
|
-- equipment (RNGD default only for RANGED:
|
||||||
|
-- scopes only matter for hunters; flip in the options
|
||||||
|
-- expectation matrix anytime)
|
||||||
|
HEAD = true, SHLD = true, BACK = true, CHST = true, WRST = true,
|
||||||
|
HAND = true, LEGS = true, FEET = true, MAIN = true, OFFH = true,
|
||||||
|
WAIST = true,
|
||||||
|
},
|
||||||
|
MELEE = {
|
||||||
|
-- consumables (reference melee set: Mongoose, Firewater/Juju Might,
|
||||||
|
-- Juju Power/Giants, R.O.I.D.S./Scorpok, Zanza, Titans, food,
|
||||||
|
-- sharpening stone; Juju Flurry has no column)
|
||||||
|
FLASK = true, FOOD = true, AP = true, STR = true, AGI = true,
|
||||||
|
BL = true, ZANZA = true,
|
||||||
|
FR = true, WPN = true,
|
||||||
|
-- class buffs
|
||||||
|
MOTW = true, PWF = true, SPROT = true,
|
||||||
|
-- equipment
|
||||||
|
HEAD = true, SHLD = true, BACK = true, CHST = true, WRST = true,
|
||||||
|
HAND = true, LEGS = true, FEET = true, MAIN = true, OFFH = true,
|
||||||
|
WAIST = true,
|
||||||
|
},
|
||||||
|
RANGED = {
|
||||||
|
-- consumables (derived from the melee line -- no reference set; no STR,
|
||||||
|
-- no FLASK, see "Known debatable rows")
|
||||||
|
FOOD = true, AP = true, AGI = true, BL = true, ZANZA = true,
|
||||||
|
FR = true, WPN = true,
|
||||||
|
-- class buffs
|
||||||
|
MOTW = true, PWF = true, SPROT = true,
|
||||||
|
-- equipment
|
||||||
|
HEAD = true, SHLD = true, BACK = true, CHST = true, WRST = true,
|
||||||
|
HAND = true, LEGS = true, FEET = true, MAIN = true, OFFH = true,
|
||||||
|
WAIST = true,
|
||||||
|
RNGD = true,
|
||||||
|
},
|
||||||
|
-- CASTER/HEALER: RNGD also dropped by role default (their classes are
|
||||||
|
-- additionally exempt via ENCH_CLASS_EXEMPT - belt and suspenders)
|
||||||
|
CASTER = {
|
||||||
|
-- consumables (reference caster set: Dreamshard, GAE, school elixir,
|
||||||
|
-- Dreamtonic, Mageblood, wizard oil, Zanza, Supreme Power, food)
|
||||||
|
FLASK = true, FOOD = true, GAE = true, SCHOOL = true,
|
||||||
|
DREAMT = true, SHARD = true, MP5 = true, ZANZA = true,
|
||||||
|
FR = true, WPN = true,
|
||||||
|
-- class buffs
|
||||||
|
AI = true, MOTW = true, PWF = true, SPROT = true,
|
||||||
|
-- equipment
|
||||||
|
HEAD = true, SHLD = true, BACK = true, CHST = true, WRST = true,
|
||||||
|
HAND = true, LEGS = true, FEET = true, MAIN = true, OFFH = true,
|
||||||
|
WAIST = true,
|
||||||
|
},
|
||||||
|
HEALER = {
|
||||||
|
-- consumables (reference healer set: Dreamshard, Mageblood, mana oil,
|
||||||
|
-- Zanza, Distilled Wisdom, food, Kreeg's + Merlot Blue)
|
||||||
|
FLASK = true, FOOD = true, MP5 = true, SHARD = true, ZANZA = true,
|
||||||
|
ALC = true,
|
||||||
|
FR = true, WPN = true,
|
||||||
|
-- class buffs (SOUL: expected ONLY here)
|
||||||
|
AI = true, MOTW = true, PWF = true, SPROT = true, SOUL = true,
|
||||||
|
-- equipment
|
||||||
|
HEAD = true, SHLD = true, BACK = true, CHST = true, WRST = true,
|
||||||
|
HAND = true, LEGS = true, FEET = true, MAIN = true, OFFH = true,
|
||||||
|
WAIST = true,
|
||||||
|
},
|
||||||
|
}
|
||||||
@@ -0,0 +1,319 @@
|
|||||||
|
-- DopingControl data/hit.lua
|
||||||
|
-- Hit-chance tables for the HIT tab: what GEAR and AURAS contribute, plus
|
||||||
|
-- the caps every column is judged against. The third contributor,
|
||||||
|
-- TALENTS, lives in its own file (data/talenthit.lua) because it is a
|
||||||
|
-- different kind of source -- looked up by talent name and rank rather
|
||||||
|
-- than parsed out of a tooltip.
|
||||||
|
--
|
||||||
|
-- WHY TOOLTIP PARSING (measured, not assumed): this client family exposes
|
||||||
|
-- NO hit API at all -- GetHitModifier, GetSpellHitModifier and
|
||||||
|
-- GetCombatRating are all absent (verified on the live client). The only
|
||||||
|
-- readable source of a player's +hit is the text of the equipped items'
|
||||||
|
-- tooltips, which IS readable for foreign units in range via
|
||||||
|
-- GameTooltip:SetInventoryItem(unit, slot). Everything in this file exists
|
||||||
|
-- to turn those tooltip lines into a number and to bound that number
|
||||||
|
-- against the caps of the current patch.
|
||||||
|
--
|
||||||
|
-- WHAT CANNOT BE READ (in principle, not a missing feature): a foreign
|
||||||
|
-- player's WEAPON SKILL. The server never sends PLAYER_SKILL_INFO for
|
||||||
|
-- anyone but the own character and no inspect-skill channel exists, so
|
||||||
|
-- weapon skill is MEASURED for the own character (GetSkillLineInfo, see
|
||||||
|
-- scan/hit.lua) and ESTIMATED for everyone else -- base skill + racial
|
||||||
|
-- specialization + an optional per-player "weapon skill book" flag the
|
||||||
|
-- user sets in the options. Every consumer must present the foreign value
|
||||||
|
-- as an assumption, never as a measurement.
|
||||||
|
--
|
||||||
|
-- PATCH BASIS (current server patch): weapon-skill scaling was rewritten
|
||||||
|
-- to LINEAR, the old 305-skill breakpoint was removed and the hidden 1%
|
||||||
|
-- suppression was dropped. Consequences encoded below: the yellow melee
|
||||||
|
-- hit cap against a boss three levels above the player is 8% (it was 9%
|
||||||
|
-- before the rewrite) and the dual-wield WHITE cap is 27%. The spell hit
|
||||||
|
-- cap is 16%, derived from the server's own clamp (see DC.HIT_CAPS below)
|
||||||
|
-- rather than measured -- it is a single constant here so a later
|
||||||
|
-- correction changes one line.
|
||||||
|
--
|
||||||
|
-- English client assumed: both the tooltip patterns and the weapon-skill
|
||||||
|
-- names below are English strings. On a localized client nothing matches
|
||||||
|
-- and the hit columns simply read 0 rather than showing wrong numbers.
|
||||||
|
--
|
||||||
|
-- Pure Lua 5.0, no WoW API -- dofile-loadable offline.
|
||||||
|
|
||||||
|
DopingControl = DopingControl or {}
|
||||||
|
local DC = DopingControl
|
||||||
|
|
||||||
|
-- ------------------------------------------------------------------
|
||||||
|
-- Magic schools that can carry a school-specific spell-hit line.
|
||||||
|
-- Order is display order for tooltip detail lines.
|
||||||
|
--
|
||||||
|
-- The CAPITALIZED spelling is load-bearing twice over: it is the word that
|
||||||
|
-- appears in the item tooltip ("Improves your chance to hit with Fire
|
||||||
|
-- spells by 1%"), so it is both the generated pattern text below and the
|
||||||
|
-- key of the parsed `schools` table.
|
||||||
|
-- ------------------------------------------------------------------
|
||||||
|
DC.HIT_SCHOOLS = { "Arcane", "Fire", "Frost", "Holy", "Nature", "Shadow" }
|
||||||
|
|
||||||
|
-- ------------------------------------------------------------------
|
||||||
|
-- DC.HIT_SCHOOL_KEY -- lowercase school id -> the CAPITALIZED key used by
|
||||||
|
-- everything that came out of a tooltip.
|
||||||
|
--
|
||||||
|
-- Two spellings exist because two sources exist and neither may be bent to
|
||||||
|
-- the other: tooltip text dictates "Fire", while the column definitions
|
||||||
|
-- (DC.SLOTS_HIT, core/const.lua) and the talent tables
|
||||||
|
-- (DC.TALENT_HIT, data/talenthit.lua) are hand-written data that follows
|
||||||
|
-- the file-wide lowercase-id convention. This table is GENERATED from
|
||||||
|
-- DC.HIT_SCHOOLS so the two can never drift apart, and DC_Hit.SchoolHit
|
||||||
|
-- (scan/hit.lua) is the one function callers should use instead of
|
||||||
|
-- indexing either table by hand.
|
||||||
|
-- ------------------------------------------------------------------
|
||||||
|
DC.HIT_SCHOOL_KEY = {}
|
||||||
|
for i = 1, table.getn(DC.HIT_SCHOOLS) do
|
||||||
|
local school = DC.HIT_SCHOOLS[i]
|
||||||
|
DC.HIT_SCHOOL_KEY[string.lower(school)] = school
|
||||||
|
end
|
||||||
|
|
||||||
|
-- ------------------------------------------------------------------
|
||||||
|
-- DC.HIT_PATTERNS -- ARRAY (order matters, FIRST MATCH WINS), each entry
|
||||||
|
-- { pattern = <Lua 5.0 string.find pattern with ONE numeric capture>,
|
||||||
|
-- kind = "melee" | "spell" | "both" | <school name> }
|
||||||
|
--
|
||||||
|
-- kind semantics (folded by DC_Hit.SumGear):
|
||||||
|
-- "melee" generic physical hit. 1.12 has no separate ranged-hit affix:
|
||||||
|
-- the same "chance to hit" line applies to melee AND ranged
|
||||||
|
-- attacks, so the MELEE bucket feeds both hit columns.
|
||||||
|
-- "spell" spell hit against every school.
|
||||||
|
-- "both" "spells and attacks": adds to melee AND spell.
|
||||||
|
-- <school> school-specific spell hit (e.g. "Fire"): it does NOT raise
|
||||||
|
-- the general spell-hit number, it is carried separately and
|
||||||
|
-- shown as an extra, because it only helps that one school.
|
||||||
|
--
|
||||||
|
-- Ordering rule: the more specific phrasings come first. The four
|
||||||
|
-- phrasings do not actually overlap as literals ("hit with spells and
|
||||||
|
-- attacks by", "hit with Fire spells by", "hit with spells by", "hit by"),
|
||||||
|
-- but the order keeps that from becoming load-bearing if a pattern is ever
|
||||||
|
-- widened.
|
||||||
|
--
|
||||||
|
-- Extension point: a phrasing that is not listed here is SILENTLY IGNORED
|
||||||
|
-- (never guessed at). Server-custom item text that grants hit therefore
|
||||||
|
-- needs its pattern added here -- this table is the single place.
|
||||||
|
-- ------------------------------------------------------------------
|
||||||
|
DC.HIT_PATTERNS = {
|
||||||
|
-- combined affix ("spells and attacks")
|
||||||
|
{ pattern = "Improves your chance to hit with spells and attacks by (%d+)%%",
|
||||||
|
kind = "both" },
|
||||||
|
}
|
||||||
|
|
||||||
|
-- school-specific spell hit, generated so the school list stays the single
|
||||||
|
-- source of truth ("Improves your chance to hit with Fire spells by 1%")
|
||||||
|
for i = 1, table.getn(DC.HIT_SCHOOLS) do
|
||||||
|
local school = DC.HIT_SCHOOLS[i]
|
||||||
|
table.insert(DC.HIT_PATTERNS, {
|
||||||
|
pattern = "Improves your chance to hit with " .. school
|
||||||
|
.. " spells by (%d+)%%",
|
||||||
|
kind = school,
|
||||||
|
})
|
||||||
|
end
|
||||||
|
|
||||||
|
-- general spell hit, then the generic (melee + ranged) hit line
|
||||||
|
table.insert(DC.HIT_PATTERNS,
|
||||||
|
{ pattern = "Improves your chance to hit with spells by (%d+)%%",
|
||||||
|
kind = "spell" })
|
||||||
|
table.insert(DC.HIT_PATTERNS,
|
||||||
|
{ pattern = "Improves your chance to hit by (%d+)%%",
|
||||||
|
kind = "melee" })
|
||||||
|
|
||||||
|
-- ------------------------------------------------------------------
|
||||||
|
-- DC.WEAPON_SKILL_OF_SUBTYPE -- GetItemInfo itemSubType -> weapon SKILL
|
||||||
|
-- name (the string GetSkillLineInfo reports for that weapon).
|
||||||
|
--
|
||||||
|
-- Absent key = "not a weapon" and that is load-bearing: off-hand items
|
||||||
|
-- whose subtype is missing here (shields, held-in-off-hand items, totems,
|
||||||
|
-- idols, librams, fishing poles) are what tells the caller that the player
|
||||||
|
-- is NOT dual-wielding.
|
||||||
|
--
|
||||||
|
-- Verified live on this client: "One-Handed Maces" (plus the non-weapon
|
||||||
|
-- subtypes "Shields" and "Totems", deliberately absent). The remaining
|
||||||
|
-- strings follow the standard 1.12 weapon subclass naming and are
|
||||||
|
-- UNVERIFIED -- each one is only a lookup key, so a wrong string degrades
|
||||||
|
-- to "unknown weapon" (no skill assumption, no dual-wield claim), never to
|
||||||
|
-- a wrong number.
|
||||||
|
--
|
||||||
|
-- Two mappings are worth calling out:
|
||||||
|
-- "Fist Weapons" -> "Unarmed": fist weapons run on the Unarmed skill
|
||||||
|
-- line, they have no skill of their own.
|
||||||
|
-- "Wands" -> "Wands": the skill line exists, but whether wand attacks
|
||||||
|
-- obey weapon skill or spell hit is DISPUTED on this patch. The
|
||||||
|
-- mapping only names the skill line; the ranged hit column
|
||||||
|
-- deliberately ignores wands (see DC.RANGED_ATTACK_SUBTYPE).
|
||||||
|
-- ------------------------------------------------------------------
|
||||||
|
DC.WEAPON_SKILL_OF_SUBTYPE = {
|
||||||
|
["One-Handed Axes"] = "Axes",
|
||||||
|
["Two-Handed Axes"] = "Two-Handed Axes",
|
||||||
|
["One-Handed Maces"] = "Maces", -- verified live
|
||||||
|
["Two-Handed Maces"] = "Two-Handed Maces",
|
||||||
|
["One-Handed Swords"] = "Swords",
|
||||||
|
["Two-Handed Swords"] = "Two-Handed Swords",
|
||||||
|
["Daggers"] = "Daggers",
|
||||||
|
["Fist Weapons"] = "Unarmed", -- see header
|
||||||
|
["Polearms"] = "Polearms",
|
||||||
|
["Staves"] = "Staves",
|
||||||
|
["Bows"] = "Bows",
|
||||||
|
["Crossbows"] = "Crossbows",
|
||||||
|
["Guns"] = "Guns",
|
||||||
|
["Thrown"] = "Thrown",
|
||||||
|
["Wands"] = "Wands", -- see header
|
||||||
|
}
|
||||||
|
|
||||||
|
-- Subtypes that make the RANGED hit question meaningful, i.e. things that
|
||||||
|
-- actually swing as a ranged ATTACK. Wands are excluded on purpose (their
|
||||||
|
-- hit mechanic is disputed, see above) and so is every non-weapon relic;
|
||||||
|
-- a player without one of these gets no ranged hit column value at all.
|
||||||
|
DC.RANGED_ATTACK_SUBTYPE = {
|
||||||
|
["Bows"] = true,
|
||||||
|
["Crossbows"] = true,
|
||||||
|
["Guns"] = true,
|
||||||
|
["Thrown"] = true,
|
||||||
|
}
|
||||||
|
|
||||||
|
-- ------------------------------------------------------------------
|
||||||
|
-- DC.CLASS_WEAPON_SKILLS -- class token -> ordered array of the weapon
|
||||||
|
-- skills that class can train (Vanilla 1.12 base list). Every name here is
|
||||||
|
-- a VALUE of DC.WEAPON_SKILL_OF_SUBTYPE above, same spelling -- the
|
||||||
|
-- options editor's Skill books tab (ui/options.lua, O.SkillBookRows)
|
||||||
|
-- writes db.skillBooks[player][skill] keyed by these exact strings, so a
|
||||||
|
-- typo here would silently create a book entry the hit calc never reads.
|
||||||
|
--
|
||||||
|
-- This server can diverge from the Vanilla baseline (extra or missing
|
||||||
|
-- trainable weapons for a class). If a player is seen wielding a skill
|
||||||
|
-- that is not in their class's list here, the Skill books tab still
|
||||||
|
-- offers it -- a WORN skill always rides along regardless of the class
|
||||||
|
-- list (see O.SkillBookRows) -- but if that turns out to be the NORMAL
|
||||||
|
-- case for a class rather than a one-off exception, extend the list below
|
||||||
|
-- rather than leaning on the worn-skill fallback forever.
|
||||||
|
-- ------------------------------------------------------------------
|
||||||
|
DC.CLASS_WEAPON_SKILLS = {
|
||||||
|
WARRIOR = { "Axes", "Two-Handed Axes", "Maces", "Two-Handed Maces",
|
||||||
|
"Swords", "Two-Handed Swords", "Daggers", "Unarmed",
|
||||||
|
"Polearms", "Staves", "Bows", "Crossbows", "Guns",
|
||||||
|
"Thrown" },
|
||||||
|
PALADIN = { "Axes", "Two-Handed Axes", "Maces", "Two-Handed Maces",
|
||||||
|
"Swords", "Two-Handed Swords", "Polearms" },
|
||||||
|
HUNTER = { "Axes", "Two-Handed Axes", "Swords", "Two-Handed Swords",
|
||||||
|
"Daggers", "Unarmed", "Polearms", "Staves", "Bows",
|
||||||
|
"Crossbows", "Guns", "Thrown" },
|
||||||
|
ROGUE = { "Daggers", "Unarmed", "Maces", "Swords", "Bows",
|
||||||
|
"Crossbows", "Guns", "Thrown" },
|
||||||
|
PRIEST = { "Daggers", "Maces", "Staves", "Wands" },
|
||||||
|
SHAMAN = { "Axes", "Two-Handed Axes", "Maces", "Two-Handed Maces",
|
||||||
|
"Daggers", "Unarmed", "Staves" },
|
||||||
|
MAGE = { "Daggers", "Swords", "Staves", "Wands" },
|
||||||
|
WARLOCK = { "Daggers", "Swords", "Staves", "Wands" },
|
||||||
|
DRUID = { "Maces", "Two-Handed Maces", "Daggers", "Unarmed",
|
||||||
|
"Staves", "Polearms" },
|
||||||
|
}
|
||||||
|
|
||||||
|
-- ------------------------------------------------------------------
|
||||||
|
-- Weapon skill baseline and modifiers.
|
||||||
|
--
|
||||||
|
-- DC.BASE_WEAPON_SKILL: a level-60 character caps every weapon skill at
|
||||||
|
-- 300 (5 x level). This tool is a raid tool, so 60 is assumed for the
|
||||||
|
-- estimate of FOREIGN players; the own character is measured and never
|
||||||
|
-- uses this number.
|
||||||
|
--
|
||||||
|
-- DC.RACIAL_SKILL_BONUS: the racial weapon specialization bonus. The value
|
||||||
|
-- is DISPUTED -- the classic value is +5, and a server-side change to +3
|
||||||
|
-- has been reported but is UNVERIFIED. It is ONE constant on purpose:
|
||||||
|
-- changing this single line changes every racial bonus and every derived
|
||||||
|
-- cap.
|
||||||
|
--
|
||||||
|
-- DC.SKILL_BOOK_BONUS: a quest weapon-skill book grants +3 skill. It is
|
||||||
|
-- invisible on other players (see the header), which is why it is an
|
||||||
|
-- opt-in per-player checkbox in the options rather than a scan result.
|
||||||
|
-- ------------------------------------------------------------------
|
||||||
|
DC.BASE_WEAPON_SKILL = 300
|
||||||
|
DC.RACIAL_SKILL_BONUS = 5
|
||||||
|
DC.SKILL_BOOK_BONUS = 3
|
||||||
|
|
||||||
|
-- ------------------------------------------------------------------
|
||||||
|
-- DC.RACE_WEAPON_SKILL -- race -> { [weapon skill name] = bonus }.
|
||||||
|
--
|
||||||
|
-- Keys: BOTH spellings are listed for every race that has a bonus -- the
|
||||||
|
-- display name ("Night Elf") and the file token ("NightElf", "Scourge"),
|
||||||
|
-- because the race string reaches us from UnitRace, whose two returns are
|
||||||
|
-- exactly those two spellings. Aliases point at the SAME table, so a race
|
||||||
|
-- is never counted twice.
|
||||||
|
--
|
||||||
|
-- Races without a weapon specialization (night elves, tauren, undead) are
|
||||||
|
-- deliberately absent: an absent race resolves to "no bonus", which is
|
||||||
|
-- also what an unknown/newer race token resolves to (nil-safe by design).
|
||||||
|
-- ------------------------------------------------------------------
|
||||||
|
local B = DC.RACIAL_SKILL_BONUS
|
||||||
|
|
||||||
|
local HUMAN_SKILLS = {
|
||||||
|
["Swords"] = B, ["Two-Handed Swords"] = B,
|
||||||
|
["Maces"] = B, ["Two-Handed Maces"] = B,
|
||||||
|
}
|
||||||
|
local ORC_SKILLS = {
|
||||||
|
["Axes"] = B, ["Two-Handed Axes"] = B,
|
||||||
|
}
|
||||||
|
local DWARF_SKILLS = { ["Guns"] = B }
|
||||||
|
local TROLL_SKILLS = { ["Bows"] = B, ["Thrown"] = B }
|
||||||
|
local GNOME_SKILLS = { ["Daggers"] = B, ["Swords"] = B }
|
||||||
|
-- races without a weapon specialization share ONE empty table
|
||||||
|
local NO_SKILLS = {}
|
||||||
|
|
||||||
|
DC.RACE_WEAPON_SKILL = {
|
||||||
|
["Human"] = HUMAN_SKILLS,
|
||||||
|
["Orc"] = ORC_SKILLS,
|
||||||
|
["Dwarf"] = DWARF_SKILLS,
|
||||||
|
["Troll"] = TROLL_SKILLS,
|
||||||
|
["Gnome"] = GNOME_SKILLS,
|
||||||
|
-- no weapon specialization; listed for readability, empty on purpose
|
||||||
|
["Night Elf"] = NO_SKILLS,
|
||||||
|
["NightElf"] = NO_SKILLS,
|
||||||
|
["Tauren"] = NO_SKILLS,
|
||||||
|
["Undead"] = NO_SKILLS,
|
||||||
|
["Scourge"] = NO_SKILLS,
|
||||||
|
}
|
||||||
|
|
||||||
|
-- ------------------------------------------------------------------
|
||||||
|
-- DC.HIT_CAPS -- hit percentages needed against a boss THREE LEVELS above
|
||||||
|
-- the player on the current patch, at base weapon skill (300):
|
||||||
|
-- meleeYellow 8 -- special attacks / abilities ("yellow" swings)
|
||||||
|
-- meleeWhiteDW 27 -- auto attacks while DUAL WIELDING (the 19% dual-
|
||||||
|
-- wield penalty rides on top); a single weapon's white
|
||||||
|
-- swings share the yellow cap
|
||||||
|
-- ranged 8 -- ranged attacks behave like yellow melee here
|
||||||
|
-- spell 16 -- the server clamps spell hit chance to 99%, so 1%
|
||||||
|
-- miss can never be removed: base miss vs a +3-level
|
||||||
|
-- boss is 17%, which makes 16% the effective cap
|
||||||
|
--
|
||||||
|
-- DC.CAP_PER_SKILL: every weapon skill point ABOVE 300 lowers the melee/
|
||||||
|
-- ranged cap by 0.2 percentage points (linear scaling, see header). A
|
||||||
|
-- racial specialization (+5 skill) therefore lowers the yellow cap from
|
||||||
|
-- 8.0 to 7.0, and a weapon skill book (+3) is worth about 0.6% hit.
|
||||||
|
-- ------------------------------------------------------------------
|
||||||
|
DC.HIT_CAPS = {
|
||||||
|
meleeYellow = 8,
|
||||||
|
meleeWhiteDW = 27,
|
||||||
|
ranged = 8,
|
||||||
|
spell = 16,
|
||||||
|
}
|
||||||
|
|
||||||
|
DC.CAP_PER_SKILL = 0.2
|
||||||
|
|
||||||
|
-- ------------------------------------------------------------------
|
||||||
|
-- DC.HIT_AURAS -- hit granted by AURAS rather than gear, folded on top of
|
||||||
|
-- the gear sum by scan/engine.lua's BuildPlayer (no extra scan: the buff
|
||||||
|
-- names are already in the store from the same aura read the class-buff
|
||||||
|
-- tab uses). Keys are buff NAMES (matched against player.auras.names);
|
||||||
|
-- values are { melee=, ranged=, spell= } percentages -- every field is
|
||||||
|
-- OPTIONAL, since a hit-granting aura need not touch all three columns.
|
||||||
|
-- ------------------------------------------------------------------
|
||||||
|
DC.HIT_AURAS = {
|
||||||
|
-- raid-wide druid buff: +1% SPELL hit only (also grants movement
|
||||||
|
-- speed and casting mana regen, which do not concern this addon) --
|
||||||
|
-- verified against the in-game tooltip text, so this is deliberately
|
||||||
|
-- NOT a melee/ranged entry; add further hit-granting auras here (a
|
||||||
|
-- food buff with hit would be the likeliest addition)
|
||||||
|
["Emerald Blessing"] = { spell = 1 },
|
||||||
|
}
|
||||||
@@ -0,0 +1,139 @@
|
|||||||
|
-- DopingControl data/resist.lua
|
||||||
|
-- Resistance-reconstruction tables for the RESIST tab: what GEAR tooltips
|
||||||
|
-- and RACE grant, mirroring data/hit.lua's split for the HIT tab (patterns
|
||||||
|
-- + constants here, the pure parser in scan/resist.lua).
|
||||||
|
--
|
||||||
|
-- WHY RECONSTRUCTION (measured, not assumed -- see scan/hit.lua's header
|
||||||
|
-- for the full story): the server does not transmit
|
||||||
|
-- UNIT_FIELD_RESISTANCES for foreign players at all (Player::
|
||||||
|
-- InitVisibleBits() never sets the bit for anyone but the owning client),
|
||||||
|
-- so UnitResistance/GetUnitField can NEVER answer for a foreign unit --
|
||||||
|
-- this is not a corner case scan/engine.lua's three read paths occasionally
|
||||||
|
-- miss, it is the only path that can ever ANSWER for one. The equipped
|
||||||
|
-- items' tooltips are readable for foreign units in range
|
||||||
|
-- (GameTooltip:SetInventoryItem), exactly like the HIT tab's +hit lines,
|
||||||
|
-- so the total is rebuilt from the same source: item stat text, plus the
|
||||||
|
-- racial bonus below (which no tooltip ever states).
|
||||||
|
--
|
||||||
|
-- DC.RESIST_SCHOOLS -- the five schools that carry a resistance stat, in
|
||||||
|
-- TOOLTIP capitalization ("Fire Resistance"), paired with the
|
||||||
|
-- UnitResistance() school index used throughout the addon (core/const.lua
|
||||||
|
-- DC.SLOTS_RESIST: 2 fire / 3 nature / 4 frost / 5 shadow / 6 arcane; 0
|
||||||
|
-- physical and 1 holy are never queried -- there is no "Holy Resistance"
|
||||||
|
-- item stat in 1.12, which is also why this list has no sixth entry).
|
||||||
|
-- Kept in sync with DC.SLOTS_RESIST BY HAND (self-contained, like
|
||||||
|
-- DC.HIT_SCHOOLS/DC.SLOTS_HIT in data/hit.lua and core/const.lua) --
|
||||||
|
-- test_resist.lua pins the two against each other so they cannot drift
|
||||||
|
-- apart silently.
|
||||||
|
DopingControl = DopingControl or {}
|
||||||
|
local DC = DopingControl
|
||||||
|
|
||||||
|
DC.RESIST_SCHOOLS = {
|
||||||
|
{ name = "Fire", school = 2 },
|
||||||
|
{ name = "Nature", school = 3 },
|
||||||
|
{ name = "Frost", school = 4 },
|
||||||
|
{ name = "Shadow", school = 5 },
|
||||||
|
{ name = "Arcane", school = 6 },
|
||||||
|
}
|
||||||
|
|
||||||
|
-- DC.RESIST_SCHOOL_NUM -- tooltip school name -> UnitResistance() index,
|
||||||
|
-- GENERATED from DC.RESIST_SCHOOLS so the two tables cannot drift apart
|
||||||
|
-- (same idea as DC.HIT_SCHOOL_KEY in data/hit.lua).
|
||||||
|
DC.RESIST_SCHOOL_NUM = {}
|
||||||
|
for i = 1, table.getn(DC.RESIST_SCHOOLS) do
|
||||||
|
local e = DC.RESIST_SCHOOLS[i]
|
||||||
|
DC.RESIST_SCHOOL_NUM[e.name] = e.school
|
||||||
|
end
|
||||||
|
|
||||||
|
-- ------------------------------------------------------------------
|
||||||
|
-- DC.RESIST_PATTERNS -- ARRAY (order matters, FIRST MATCH WINS), each
|
||||||
|
-- entry { pattern = <Lua 5.0 string.find pattern with ONE numeric
|
||||||
|
-- capture>, kind = <school name> | "all" }.
|
||||||
|
--
|
||||||
|
-- Measured tooltip forms (2026-08-02, own-character tooltip, English
|
||||||
|
-- client):
|
||||||
|
-- "+10 Arcane Resistance" -- PLAIN stat line, no "Equip:"
|
||||||
|
-- "+10 Fire Resistance" -- (item base stats print this way,
|
||||||
|
-- "+10 Nature Resistance" -- unlike +hit which is always an
|
||||||
|
-- "+1 Fire Resistance" -- "Equip:" trigger line)
|
||||||
|
-- "Equip: +8 All Resistances." -- multi-school EQUIP trigger --
|
||||||
|
-- applies to ALL FIVE schools at
|
||||||
|
-- once; a parser keyed on the five
|
||||||
|
-- single-school patterns alone would
|
||||||
|
-- silently miss this line entirely
|
||||||
|
-- (the trap this table exists to
|
||||||
|
-- close, see scan/resist.lua
|
||||||
|
-- R.SumLines).
|
||||||
|
--
|
||||||
|
-- Both anchored at the LINE START (^): a stat line is the line's entire
|
||||||
|
-- content on this client, so anchoring guards against a flavor-text
|
||||||
|
-- sentence that happens to contain "Fire Resistance" mid-phrase. No
|
||||||
|
-- trailing anchor: the plain form has nothing after the number+word (WoW
|
||||||
|
-- classic tooltip text is fixed, so this cannot mismatch), and the Equip
|
||||||
|
-- form is allowed to keep trailing punctuation.
|
||||||
|
--
|
||||||
|
-- Extension point (mirrors DC.HIT_PATTERNS): a phrasing not listed here is
|
||||||
|
-- SILENTLY IGNORED, never guessed at. An "Equip: +N <School> Resistance."
|
||||||
|
-- single-school equip form is conceivable on this server but UNMEASURED --
|
||||||
|
-- it is deliberately absent until someone reads it off a real tooltip.
|
||||||
|
-- ------------------------------------------------------------------
|
||||||
|
DC.RESIST_PATTERNS = {}
|
||||||
|
for i = 1, table.getn(DC.RESIST_SCHOOLS) do
|
||||||
|
local e = DC.RESIST_SCHOOLS[i]
|
||||||
|
table.insert(DC.RESIST_PATTERNS, {
|
||||||
|
pattern = "^%+(%d+) " .. e.name .. " Resistance",
|
||||||
|
kind = e.name,
|
||||||
|
})
|
||||||
|
end
|
||||||
|
table.insert(DC.RESIST_PATTERNS, {
|
||||||
|
pattern = "^Equip: %+(%d+) All Resistances",
|
||||||
|
kind = "all",
|
||||||
|
})
|
||||||
|
|
||||||
|
-- ------------------------------------------------------------------
|
||||||
|
-- DC.RACE_RESIST -- race -> { [UnitResistance school index] = bonus }.
|
||||||
|
-- INNATE racial resistance, granted by the race itself rather than any
|
||||||
|
-- item -- invisible to a tooltip scan, so it has to be added on top of the
|
||||||
|
-- gear sum by hand (scan/engine.lua's E.AssembleResists gear-fallback
|
||||||
|
-- path) or the reconstructed total would silently understate every member
|
||||||
|
-- of these five races by exactly this amount.
|
||||||
|
--
|
||||||
|
-- Keys: BOTH spellings for every race that has a bonus (display name and
|
||||||
|
-- file token), same reasoning as DC.RACE_WEAPON_SKILL in data/hit.lua --
|
||||||
|
-- scan/hit.lua's H.ReadUnit stores `token or display` as the race passed
|
||||||
|
-- through the store, so either spelling must resolve. Aliases point at the
|
||||||
|
-- SAME table (never a separate copy), so a race can never end up counted
|
||||||
|
-- with two different bonus tables that could drift apart.
|
||||||
|
--
|
||||||
|
-- Verified racial resistances (task brief, 2026-08-02):
|
||||||
|
-- Dwarf +10 Frost
|
||||||
|
-- Gnome +10 Arcane
|
||||||
|
-- Night Elf +10 Nature
|
||||||
|
-- Tauren +10 Nature
|
||||||
|
-- Undead/Forsaken +10 Shadow
|
||||||
|
-- Human, Orc, Troll: none
|
||||||
|
--
|
||||||
|
-- WARNING -- NOT COVERED: TurtleWoW's two extra playable races, Goblin and
|
||||||
|
-- High Elf/Blood Elf. Their racial resistance (if they even have one) is
|
||||||
|
-- UNKNOWN -- it is not guessed here. Both are simply ABSENT from this
|
||||||
|
-- table, which is safe by construction: DC_Resist.RaceBonus (scan/
|
||||||
|
-- resist.lua) returns an empty bonus table for any race it does not
|
||||||
|
-- recognize, exactly the same degrade an unlisted/future race token gets.
|
||||||
|
-- Do not "fix" this by inventing a number -- add the real one once it is
|
||||||
|
-- measured, the same way the five below were.
|
||||||
|
-- ------------------------------------------------------------------
|
||||||
|
local DWARF_RESIST = { [4] = 10 } -- frost
|
||||||
|
local GNOME_RESIST = { [6] = 10 } -- arcane
|
||||||
|
local NIGHTELF_RESIST = { [3] = 10 } -- nature
|
||||||
|
local TAUREN_RESIST = { [3] = 10 } -- nature
|
||||||
|
local UNDEAD_RESIST = { [5] = 10 } -- shadow
|
||||||
|
|
||||||
|
DC.RACE_RESIST = {
|
||||||
|
["Dwarf"] = DWARF_RESIST,
|
||||||
|
["Gnome"] = GNOME_RESIST,
|
||||||
|
["Night Elf"] = NIGHTELF_RESIST,
|
||||||
|
["NightElf"] = NIGHTELF_RESIST,
|
||||||
|
["Tauren"] = TAUREN_RESIST,
|
||||||
|
["Undead"] = UNDEAD_RESIST,
|
||||||
|
["Scourge"] = UNDEAD_RESIST,
|
||||||
|
}
|
||||||
@@ -0,0 +1,171 @@
|
|||||||
|
-- DopingControl data/talenthit.lua
|
||||||
|
-- The THIRD source of hit, next to gear and auras: TALENTS.
|
||||||
|
--
|
||||||
|
-- WHY ITS OWN FILE: gear hit is parsed from item tooltips, talent hit is
|
||||||
|
-- looked up by talent NAME and rank. The two arrive over completely
|
||||||
|
-- different paths -- GetTalentInfo for the own character, the server's
|
||||||
|
-- inspect protocol (scan/talents.lua) for everyone else -- and only meet
|
||||||
|
-- again in DC_Hit.TalentHit. Keeping the talent tables apart from
|
||||||
|
-- data/hit.lua keeps that split visible in the file layout instead of
|
||||||
|
-- hiding it inside one big table.
|
||||||
|
--
|
||||||
|
-- WHAT COUNTS AS SPELL HIT HERE: several talents are worded as "reduces
|
||||||
|
-- the chance that the opponent can resist your <school> spells by X%".
|
||||||
|
-- That is not a resistance mechanic -- it runs through the server's
|
||||||
|
-- resist-miss spell modifier, i.e. it IS spell hit for that school, and it
|
||||||
|
-- stacks with +hit from items. They are therefore modelled as
|
||||||
|
-- school-specific spell hit and not as anything resistance-shaped.
|
||||||
|
--
|
||||||
|
-- BUCKETS -- an entry is
|
||||||
|
-- { name = <talent name, EXACTLY as GetTalentInfo returns it>,
|
||||||
|
-- tree = <talent tab name; documentation only, never matched on>,
|
||||||
|
-- ranks = <max rank; the per-rank arrays must have this length>,
|
||||||
|
-- melee = { <per rank> }, -- generic physical hit (melee column)
|
||||||
|
-- ranged = { <per rank> }, -- generic ranged hit (ranged column)
|
||||||
|
-- spell = { <per rank> }, -- GENERIC spell hit: every school
|
||||||
|
-- offhand = { <per rank> }, -- OFF-HAND swings only, see below
|
||||||
|
-- dwExtra = { <per rank> }, -- extra melee hit while DUAL WIELDING
|
||||||
|
-- schools = { [<lowercase school id>] = { <per rank> } } }
|
||||||
|
-- Every bucket is optional; an absent bucket contributes nothing. School
|
||||||
|
-- ids are lowercase to match DC.SLOTS_HIT (core/const.lua); the
|
||||||
|
-- CAPITALIZED tooltip spelling that gear parsing produces is bridged by
|
||||||
|
-- DC.HIT_SCHOOL_KEY (data/hit.lua).
|
||||||
|
--
|
||||||
|
-- `offhand` is deliberately NOT part of the melee number. Off-hand hit
|
||||||
|
-- applies to off-hand swings only, so adding it to the melee column would
|
||||||
|
-- claim a main-hand hit chance the player does not have. It is carried so
|
||||||
|
-- the cell tooltip can mention it.
|
||||||
|
--
|
||||||
|
-- MATCHING IS BY NAME: the reader (DC_Hit.ReadSelfTalents) hands in
|
||||||
|
-- { [talent name] = rank }, because GetTalentInfo returns names, and
|
||||||
|
-- tab/index positions shift whenever a tree is rebalanced while names
|
||||||
|
-- almost never do. A talent this file does not list is ignored; a talent
|
||||||
|
-- listed here that no longer exists simply never matches. Both directions
|
||||||
|
-- degrade to "no talent hit counted", never to a wrong number.
|
||||||
|
--
|
||||||
|
-- English client assumed, like the tooltip patterns in data/hit.lua: on a
|
||||||
|
-- localized client no name matches and the talent contribution reads 0.
|
||||||
|
--
|
||||||
|
-- WHAT IS DELIBERATELY EXCLUDED (each one grants "hit" in its tooltip text
|
||||||
|
-- but not hit that belongs in any column of this tab):
|
||||||
|
-- * PET hit -- hunter "Bestial Precision", warlock "Demonic Precision".
|
||||||
|
-- They raise the PET's hit chance (and its weapon skill / inherited
|
||||||
|
-- ratios); the player's own swings and casts are untouched.
|
||||||
|
-- * ENEMY DEBUFFS -- rogue "Blinding Haze" LOWERS the hit chance of
|
||||||
|
-- targets hit by Distract. It is the opposite sign and lands on
|
||||||
|
-- someone else.
|
||||||
|
-- * SINGLE-SPELL hit -- paladin "Improved Hand of Reckoning" buys hit
|
||||||
|
-- for one ability only. A column that claimed it would overstate holy
|
||||||
|
-- hit for every other spell the paladin casts.
|
||||||
|
-- * WEAPON-TYPE hit -- mage "Wand Specialization" (wands) and priest
|
||||||
|
-- "Wand Specialization" (wands and bows). Wand and bow attacks are
|
||||||
|
-- neither generic spell hit nor -- for the priest -- the generic
|
||||||
|
-- ranged-attack hit this tab's ranged column measures (that column
|
||||||
|
-- only exists for a real ranged ATTACK weapon, see
|
||||||
|
-- DC.RANGED_ATTACK_SUBTYPE), so counting them would misplace them.
|
||||||
|
-- * TEMPORARY procs -- e.g. the second half of the shaman's Elemental
|
||||||
|
-- Devastation, which grants spell hit for 10 sec after a melee crit.
|
||||||
|
-- Only the permanent, always-on half is counted; a scan is a snapshot
|
||||||
|
-- and a proc that happens to be up would make one row incomparable
|
||||||
|
-- with the next.
|
||||||
|
--
|
||||||
|
-- Pure Lua 5.0, no WoW API -- dofile-loadable offline.
|
||||||
|
|
||||||
|
DopingControl = DopingControl or {}
|
||||||
|
local DC = DopingControl
|
||||||
|
|
||||||
|
-- ------------------------------------------------------------------
|
||||||
|
-- DC.TALENT_HIT -- [class token (UnitClass file token, e.g. "MAGE")] =
|
||||||
|
-- array of talent entries. Classes with no hit talent at all are absent.
|
||||||
|
-- ------------------------------------------------------------------
|
||||||
|
DC.TALENT_HIT = {
|
||||||
|
|
||||||
|
-- Generic hit: melee AND every spell school, from ONE talent. The
|
||||||
|
-- three entries below are the only talents in the game that raise
|
||||||
|
-- both halves at once, which is why hybrids can be capped on both
|
||||||
|
-- sides without spending a single item budget on it.
|
||||||
|
DRUID = {
|
||||||
|
{ name = "Natural Weapons", tree = "Balance", ranks = 3,
|
||||||
|
melee = { 1, 2, 3 }, spell = { 1, 2, 3 } },
|
||||||
|
},
|
||||||
|
|
||||||
|
PALADIN = {
|
||||||
|
{ name = "Precision", tree = "Protection", ranks = 3,
|
||||||
|
melee = { 1, 2, 3 }, spell = { 1, 2, 3 } },
|
||||||
|
},
|
||||||
|
|
||||||
|
SHAMAN = {
|
||||||
|
-- the talent's second half (melee crits grant 3/6/9% spell hit
|
||||||
|
-- for 10 sec) is a TEMPORARY proc and is not counted -- see the
|
||||||
|
-- exclusion list in the header
|
||||||
|
{ name = "Elemental Devastation", tree = "Elemental", ranks = 3,
|
||||||
|
melee = { 1, 2, 3 }, spell = { 1, 2, 3 } },
|
||||||
|
},
|
||||||
|
|
||||||
|
-- Melee only.
|
||||||
|
ROGUE = {
|
||||||
|
-- "with melee weapons": no ranged half, even though rogues can
|
||||||
|
-- carry a thrown weapon
|
||||||
|
{ name = "Precision", tree = "Combat", ranks = 5,
|
||||||
|
melee = { 1, 2, 3, 4, 5 } },
|
||||||
|
},
|
||||||
|
|
||||||
|
HUNTER = {
|
||||||
|
-- the talent says "hit chance" without naming a weapon, so the
|
||||||
|
-- base half feeds the melee AND the ranged column.
|
||||||
|
--
|
||||||
|
-- dwExtra is the "and by an additional X% while dual wielding"
|
||||||
|
-- half. The wording does not say which swings the EXTRA applies
|
||||||
|
-- to; a dual-wield condition is a melee-build condition, so it is
|
||||||
|
-- counted for MELEE ONLY. That is the reading that cannot
|
||||||
|
-- overstate a hunter's ranged hit -- the number that decides
|
||||||
|
-- whether the player is capped where it matters. Should the extra
|
||||||
|
-- turn out to be generic, this under-reports ranged by up to 3%,
|
||||||
|
-- which is the safe direction (same trade-off as the uncounted
|
||||||
|
-- set bonuses in scan/hit.lua).
|
||||||
|
{ name = "Surefooted", tree = "Survival", ranks = 3,
|
||||||
|
melee = { 1, 2, 3 }, ranged = { 1, 2, 3 },
|
||||||
|
dwExtra = { 1, 2, 3 } },
|
||||||
|
},
|
||||||
|
|
||||||
|
-- Off-hand only.
|
||||||
|
WARRIOR = {
|
||||||
|
-- off-hand swings only: NOT added to the melee column, carried
|
||||||
|
-- for the tooltip (see the `offhand` note in the header)
|
||||||
|
{ name = "Dual Wield Specialization", tree = "Fury", ranks = 5,
|
||||||
|
offhand = { 2, 4, 6, 8, 10 } },
|
||||||
|
},
|
||||||
|
|
||||||
|
-- School-specific spell hit.
|
||||||
|
MAGE = {
|
||||||
|
{ name = "Arcane Focus", tree = "Arcane", ranks = 5,
|
||||||
|
schools = { arcane = { 2, 4, 6, 8, 10 } } },
|
||||||
|
-- one talent, two schools, full value in each: it is not split
|
||||||
|
{ name = "Elemental Precision", tree = "Frost", ranks = 3,
|
||||||
|
schools = { frost = { 2, 4, 6 }, fire = { 2, 4, 6 } } },
|
||||||
|
},
|
||||||
|
|
||||||
|
PRIEST = {
|
||||||
|
{ name = "Shadow Focus", tree = "Shadow", ranks = 5,
|
||||||
|
schools = { shadow = { 2, 4, 6, 8, 10 } } },
|
||||||
|
-- "your Holy and Discipline spells": Discipline is a SPELL LINE,
|
||||||
|
-- not a magic school -- its damaging spells are holy school
|
||||||
|
-- anyway, so the whole talent maps onto holy. There is no
|
||||||
|
-- "discipline" column to put the other half in, and inventing one
|
||||||
|
-- would be a school this client's damage model does not have.
|
||||||
|
{ name = "Piercing Light", tree = "Discipline", ranks = 3,
|
||||||
|
schools = { holy = { 2, 4, 6 } } },
|
||||||
|
},
|
||||||
|
|
||||||
|
WARLOCK = {
|
||||||
|
-- "your Affliction spells": another SPELL-LINE talent rather than
|
||||||
|
-- a school talent. Affliction spells (Corruption, Curse of Agony,
|
||||||
|
-- Drain Life, Siphon Life, Death Coil, ...) are shadow school, so
|
||||||
|
-- the talent is mapped onto shadow. A warlock's fire spells
|
||||||
|
-- (Immolate, Shadowburn's destruction siblings) get nothing from
|
||||||
|
-- it, which is exactly why the fire and shadow columns of this tab
|
||||||
|
-- can differ by 10% for the same character.
|
||||||
|
{ name = "Suppression", tree = "Affliction", ranks = 5,
|
||||||
|
schools = { shadow = { 2, 4, 6, 8, 10 } } },
|
||||||
|
},
|
||||||
|
}
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
-- DopingControl features/minimap.lua
|
||||||
|
-- Minimap button, standard hand-rolled 1.12 pattern:
|
||||||
|
-- LEFT click toggles the main window, RIGHT
|
||||||
|
-- click opens the options.
|
||||||
|
--
|
||||||
|
-- Design facts (in-game verified on 1.12):
|
||||||
|
-- - Hand-rolled, no LibDBIcon on 1.12.
|
||||||
|
-- - pfUI-safe: parent = Minimap, frame NAME contains "Minimap",
|
||||||
|
-- SetFrameStrata("HIGH") + SetFrameLevel(9) -- MEDIUM hides under pfUI.
|
||||||
|
-- /dc options stays as the fallback path.
|
||||||
|
-- - Border recipe (vanilla FrameXML MiniMapTrackingButton): 54px
|
||||||
|
-- MiniMap-TrackingBorder anchored TOPLEFT(0,0) on a 33px button. The
|
||||||
|
-- ring is deliberately off-centre within the texture -- do NOT centre it.
|
||||||
|
-- - Drag + click coexist: drag flag on OnDragStart/Stop, plain clicks
|
||||||
|
-- still fire OnClick.
|
||||||
|
-- - Radius = Minimap:GetWidth()/2 + 5 (follows resized minimaps).
|
||||||
|
--
|
||||||
|
-- Pure-Lua 5.0 dofile-loadable: all WoW wiring behind "if CreateFrame then";
|
||||||
|
-- BuildButton/PlaceButton only touch the WoW API when called at runtime.
|
||||||
|
|
||||||
|
DopingControl = DopingControl or {}
|
||||||
|
local DC = DopingControl
|
||||||
|
|
||||||
|
DC_Minimap = DC_Minimap or {}
|
||||||
|
|
||||||
|
local button = nil
|
||||||
|
local function db() return DopingControlDB end
|
||||||
|
|
||||||
|
local function PlaceButton(angleDeg)
|
||||||
|
if not button then return end
|
||||||
|
local radius = (Minimap:GetWidth() / 2) + 5
|
||||||
|
local rad = angleDeg * math.pi / 180
|
||||||
|
button:ClearAllPoints()
|
||||||
|
button:SetPoint("CENTER", Minimap, "CENTER", math.cos(rad) * radius, math.sin(rad) * radius)
|
||||||
|
end
|
||||||
|
|
||||||
|
local function BuildButton()
|
||||||
|
if button then return end
|
||||||
|
local btn = CreateFrame("Button", "DopingControlMinimapButton", Minimap)
|
||||||
|
btn:SetWidth(33); btn:SetHeight(33)
|
||||||
|
btn:SetFrameStrata("HIGH"); btn:SetFrameLevel(9)
|
||||||
|
|
||||||
|
local icon = btn:CreateTexture("DopingControlMinimapIcon", "ARTWORK")
|
||||||
|
icon:SetTexture("Interface\\Icons\\INV_Potion_97") -- the addon's fixed icon
|
||||||
|
icon:SetWidth(20); icon:SetHeight(20)
|
||||||
|
icon:SetPoint("CENTER", btn, "CENTER", 0, 0)
|
||||||
|
icon:SetTexCoord(0.08, 0.92, 0.08, 0.92) -- standard action-button crop
|
||||||
|
|
||||||
|
local border = btn:CreateTexture("DopingControlMinimapBorder", "OVERLAY")
|
||||||
|
border:SetTexture("Interface\\Minimap\\MiniMap-TrackingBorder")
|
||||||
|
border:SetWidth(54); border:SetHeight(54)
|
||||||
|
border:SetPoint("TOPLEFT", btn, "TOPLEFT", 0, 0)
|
||||||
|
|
||||||
|
btn:SetHighlightTexture("Interface\\Minimap\\UI-Minimap-ZoomButton-Highlight")
|
||||||
|
btn:RegisterForClicks("LeftButtonUp", "RightButtonUp")
|
||||||
|
btn:RegisterForDrag("LeftButton")
|
||||||
|
|
||||||
|
btn:SetScript("OnDragStart", function() this.isDragging = true end)
|
||||||
|
btn:SetScript("OnDragStop", function() this.isDragging = false end)
|
||||||
|
btn:SetScript("OnUpdate", function()
|
||||||
|
if not this.isDragging then return end
|
||||||
|
local mx, my = Minimap:GetCenter()
|
||||||
|
local cx, cy = GetCursorPosition()
|
||||||
|
local scale = Minimap:GetEffectiveScale()
|
||||||
|
cx, cy = cx / scale, cy / scale
|
||||||
|
local angle = math.deg(math.atan2(cy - my, cx - mx))
|
||||||
|
db().minimapAngle = angle -- persist live while dragging
|
||||||
|
PlaceButton(angle)
|
||||||
|
end)
|
||||||
|
|
||||||
|
-- LEFT = main window toggle (same as /dc), RIGHT = options.
|
||||||
|
btn:SetScript("OnClick", function()
|
||||||
|
if arg1 == "RightButton" then DC_Options.Toggle() else DC_Matrix.Toggle() end
|
||||||
|
end)
|
||||||
|
btn:SetScript("OnEnter", function()
|
||||||
|
GameTooltip:SetOwner(this, "ANCHOR_LEFT")
|
||||||
|
GameTooltip:SetText("DopingControl", 1, 1, 1)
|
||||||
|
GameTooltip:AddLine("Left click: show / hide the window", 0.8, 0.8, 0.8)
|
||||||
|
GameTooltip:AddLine("Right click: options", 0.8, 0.8, 0.8)
|
||||||
|
GameTooltip:AddLine("Drag: move around the minimap", 0.8, 0.8, 0.8)
|
||||||
|
GameTooltip:Show()
|
||||||
|
end)
|
||||||
|
btn:SetScript("OnLeave", function() GameTooltip:Hide() end)
|
||||||
|
button = btn
|
||||||
|
PlaceButton(db().minimapAngle or 215)
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Honours the "Show minimap button" checkbox (db.showMinimapButton);
|
||||||
|
-- ui/options.lua calls this after toggling the setting.
|
||||||
|
function DC_Minimap.Update()
|
||||||
|
if not db().showMinimapButton then
|
||||||
|
if button then button:Hide() end
|
||||||
|
return
|
||||||
|
end
|
||||||
|
BuildButton()
|
||||||
|
PlaceButton(db().minimapAngle or 215)
|
||||||
|
button:Show()
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Reload survival: build on ADDON_LOADED for
|
||||||
|
-- THIS addon (SavedVariables populated by then), then UnregisterEvent.
|
||||||
|
-- NEVER on PLAYER_ENTERING_WORLD (would re-run and leak textures).
|
||||||
|
if CreateFrame then
|
||||||
|
local ev = CreateFrame("Frame", "DopingControlMinimapEventFrame", UIParent)
|
||||||
|
ev:RegisterEvent("ADDON_LOADED")
|
||||||
|
ev:SetScript("OnEvent", function()
|
||||||
|
if event == "ADDON_LOADED" and arg1 == "DopingControl" then
|
||||||
|
-- This handler fires BEFORE core/init.lua's (registration order
|
||||||
|
-- follows TOC load order), so defaults may not be filled yet on a
|
||||||
|
-- first-ever load. EnsureDefaults is idempotent -- run it here so
|
||||||
|
-- showMinimapButton/minimapAngle are valid.
|
||||||
|
DopingControlDB = DopingControlDB or {}
|
||||||
|
DC.EnsureDefaults(DopingControlDB)
|
||||||
|
DC_Minimap.Update()
|
||||||
|
ev:UnregisterEvent("ADDON_LOADED")
|
||||||
|
end
|
||||||
|
end)
|
||||||
|
end
|
||||||
@@ -0,0 +1,908 @@
|
|||||||
|
-- DopingControl scan/addons.lua
|
||||||
|
-- Addons tab backend: finds out which raid addons the OTHER raiders run.
|
||||||
|
-- They do not run DopingControl, so detection rides only on what their own
|
||||||
|
-- addons send or answer:
|
||||||
|
-- BigWigs prefix "BigWigs" (RAID): query "BWVQ BigWigs"; replies
|
||||||
|
-- "PEPO_BWVR <rev> <nick>", "BWVR <rev> <nick> <Fork>", the
|
||||||
|
-- old "MC:REV" style or a bare number. Any other BigWigs sync
|
||||||
|
-- proves presence without a version.
|
||||||
|
-- Threat prefix "TWT": query "TWT_WHO"; replies "TWT_ME:<ver>";
|
||||||
|
-- passive "TWTVersion:<ver>" at login. (Server threat packets
|
||||||
|
-- use the prefix "TWT " with a trailing space - a different
|
||||||
|
-- prefix, ignored here.)
|
||||||
|
-- EPGP shootyepgp, prefix "SEPGP_PREFIX" (GUILD): "VERSION;<ver>;<n>"
|
||||||
|
-- at the sender's login only; no query answer.
|
||||||
|
-- SuperWoW prefix "SWOW_Q" (GUILD) is answered by shootyepgp on prefix
|
||||||
|
-- "SWOW_R" with "1" (SuperWoW present) or "0". A reply also
|
||||||
|
-- proves shootyepgp itself is installed.
|
||||||
|
-- Rinse prefix "Rinse": query "REPORT_ADDON_VERSION", reply
|
||||||
|
-- "V_<ver>" (only the UpsiRinse fork answers).
|
||||||
|
-- Honest-unknown rule: silence is only "no reply" after a query window has
|
||||||
|
-- passed for a member who was in the group when the query went out;
|
||||||
|
-- before that the cell is unknown, and members who cannot answer (not in
|
||||||
|
-- the guild, a class that cannot dispel) are n/a.
|
||||||
|
-- The parsers, the evaluation and the query state machine are pure Lua 5.0
|
||||||
|
-- (dofile-loadable offline); the WoW wiring sits behind "if CreateFrame".
|
||||||
|
|
||||||
|
DopingControl = DopingControl or {}
|
||||||
|
local DC = DopingControl
|
||||||
|
|
||||||
|
DC_Addons = DC_Addons or {}
|
||||||
|
local A = DC_Addons
|
||||||
|
|
||||||
|
A.WINDOW = 6 -- seconds a query waits for replies
|
||||||
|
A.AUTO_THROTTLE = 30 -- seconds between automatic queries
|
||||||
|
A.MAX_NAMES = 400 -- passive-store cap (guild chatter from non-raiders)
|
||||||
|
|
||||||
|
-- slot ids (match DC.SLOTS_ADDONS)
|
||||||
|
A.SLOT_IDS = { "BIGWIGS", "TWT", "EPGP", "SUPERWOW", "RINSE" }
|
||||||
|
|
||||||
|
A.DISPELLERS = {
|
||||||
|
DRUID = true, PRIEST = true, PALADIN = true, SHAMAN = true, MAGE = true,
|
||||||
|
}
|
||||||
|
|
||||||
|
-- prefix -> slot (hash lookup = the whole cost of an unrelated message)
|
||||||
|
local PREFIX_SLOT = {
|
||||||
|
["BigWigs"] = "BIGWIGS",
|
||||||
|
["TWT"] = "TWT",
|
||||||
|
["Rinse"] = "RINSE",
|
||||||
|
["SEPGP_PREFIX"] = "EPGP",
|
||||||
|
["SWOW_R"] = "SUPERWOW",
|
||||||
|
}
|
||||||
|
A.PREFIXES = PREFIX_SLOT
|
||||||
|
|
||||||
|
-- passive store: A.data[name][slot] = entry
|
||||||
|
A.data = {}
|
||||||
|
A.dataCount = 0
|
||||||
|
|
||||||
|
-- query state
|
||||||
|
A.query = { at = nil, sent = {}, roster = {} }
|
||||||
|
A.lastStatus = nil -- last StartQuery verdict text (shown next to the button)
|
||||||
|
|
||||||
|
-- ------------------------------------------------------------------
|
||||||
|
-- text + version helpers
|
||||||
|
-- ------------------------------------------------------------------
|
||||||
|
|
||||||
|
-- Reply text from other people goes into UI strings: drop colour escapes,
|
||||||
|
-- trailing "<...>" decorations, control characters, and cap the length.
|
||||||
|
function A.Clean(s)
|
||||||
|
if type(s) ~= "string" then
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
s = string.gsub(s, "|", "")
|
||||||
|
s = string.gsub(s, "[%c]", "")
|
||||||
|
s = string.gsub(s, "%s*<.*>%s*$", "")
|
||||||
|
s = string.gsub(s, "^%s+", "")
|
||||||
|
s = string.gsub(s, "%s+$", "")
|
||||||
|
if string.len(s) > 24 then
|
||||||
|
s = string.sub(s, 1, 24)
|
||||||
|
end
|
||||||
|
if s == "" then
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
return s
|
||||||
|
end
|
||||||
|
|
||||||
|
-- "r4521" / "v1.2.3" / "1.10.0" -> { 4521 } / { 1, 2, 3 } / { 1, 10, 0 };
|
||||||
|
-- anything else -> nil (shown raw, never compared).
|
||||||
|
function A.Segments(text)
|
||||||
|
if type(text) ~= "string" then
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
local s = string.gsub(text, "^[rRvV]", "")
|
||||||
|
if not string.find(s, "^%d+$") and not string.find(s, "^%d+%.[%d%.]*%d$") then
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
if string.find(s, "%.%.") then
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
local out = {}
|
||||||
|
for d in string.gfind(s, "(%d+)") do
|
||||||
|
table.insert(out, tonumber(d))
|
||||||
|
end
|
||||||
|
return out
|
||||||
|
end
|
||||||
|
|
||||||
|
-- numeric segment compare, missing segments count as 0: -1 / 0 / 1
|
||||||
|
function A.Compare(a, b)
|
||||||
|
local na, nb = table.getn(a), table.getn(b)
|
||||||
|
local n = na
|
||||||
|
if nb > n then
|
||||||
|
n = nb
|
||||||
|
end
|
||||||
|
for i = 1, n do
|
||||||
|
local x, y = a[i] or 0, b[i] or 0
|
||||||
|
if x < y then
|
||||||
|
return -1
|
||||||
|
elseif x > y then
|
||||||
|
return 1
|
||||||
|
end
|
||||||
|
end
|
||||||
|
return 0
|
||||||
|
end
|
||||||
|
|
||||||
|
local function entry(text, variant, raw, channel)
|
||||||
|
return {
|
||||||
|
text = text,
|
||||||
|
ver = A.Segments(text),
|
||||||
|
variant = variant,
|
||||||
|
raw = raw,
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
-- ------------------------------------------------------------------
|
||||||
|
-- reply parsers. Each returns an entry table or nil (not a reply);
|
||||||
|
-- a second return value "query" marks the query messages themselves.
|
||||||
|
-- ------------------------------------------------------------------
|
||||||
|
|
||||||
|
function A.ParseBigWigs(msg)
|
||||||
|
if type(msg) ~= "string" then
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
local _, _, sync, rest = string.find(msg, "^(%S+)%s*(.*)$")
|
||||||
|
if not sync then
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
if sync == "BWVQ" then
|
||||||
|
return nil, "query"
|
||||||
|
end
|
||||||
|
if not string.find(sync, "BWVR") then
|
||||||
|
-- some other BigWigs sync (boss engaged, ...): presence, no version
|
||||||
|
local e = entry("yes", nil, msg)
|
||||||
|
e.presenceOnly = true
|
||||||
|
return e
|
||||||
|
end
|
||||||
|
local rev, fork
|
||||||
|
if string.find(rest, ":") then
|
||||||
|
-- old style "MC:1234 BWL:1234": the highest number is the revision
|
||||||
|
for d in string.gfind(rest, "(%d+)") do
|
||||||
|
local n = tonumber(d)
|
||||||
|
if rev == nil or n > rev then
|
||||||
|
rev = n
|
||||||
|
end
|
||||||
|
end
|
||||||
|
fork = "Legacy"
|
||||||
|
elseif tonumber(rest) then
|
||||||
|
rev = tonumber(rest)
|
||||||
|
fork = "Legacy"
|
||||||
|
else
|
||||||
|
local f1, f2, f3
|
||||||
|
local _, _, a, b, c = string.find(rest, "^(%S*)%s*(%S*)%s*(%S*)")
|
||||||
|
f1, f2, f3 = a, b, c
|
||||||
|
rev = tonumber(f1)
|
||||||
|
if f3 and f3 ~= "" then
|
||||||
|
fork = A.Clean(f3)
|
||||||
|
elseif sync == "PEPO_BWVR" then
|
||||||
|
fork = "Pepo"
|
||||||
|
else
|
||||||
|
fork = "unknown"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
if rev == nil or rev < 0 then
|
||||||
|
-- reply without a usable revision (zone unknown): BigWigs is there
|
||||||
|
local e = entry("yes", fork, msg)
|
||||||
|
e.presenceOnly = true
|
||||||
|
return e
|
||||||
|
end
|
||||||
|
return entry("r" .. rev, fork, msg)
|
||||||
|
end
|
||||||
|
|
||||||
|
function A.ParseTWT(msg)
|
||||||
|
if type(msg) ~= "string" then
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
if string.find(msg, "^TWT_WHO") then
|
||||||
|
return nil, "query"
|
||||||
|
end
|
||||||
|
local _, _, v = string.find(msg, "^TWT_ME:(.+)$")
|
||||||
|
if not v then
|
||||||
|
_, _, v = string.find(msg, "^TWTVersion:(.+)$")
|
||||||
|
end
|
||||||
|
v = A.Clean(v)
|
||||||
|
if not v then
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
return entry(v, nil, msg)
|
||||||
|
end
|
||||||
|
|
||||||
|
function A.ParseRinse(msg)
|
||||||
|
if type(msg) ~= "string" then
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
if msg == "REPORT_ADDON_VERSION" then
|
||||||
|
return nil, "query"
|
||||||
|
end
|
||||||
|
local _, _, v = string.find(msg, "^V_(.+)$")
|
||||||
|
v = A.Clean(v)
|
||||||
|
if not v then
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
return entry(v, nil, msg)
|
||||||
|
end
|
||||||
|
|
||||||
|
function A.ParseEPGP(msg)
|
||||||
|
if type(msg) ~= "string" then
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
local _, _, v = string.find(msg, "^VERSION;([^;]+)")
|
||||||
|
v = A.Clean(v)
|
||||||
|
if v then
|
||||||
|
return entry(v, nil, msg)
|
||||||
|
end
|
||||||
|
if string.find(msg, ";") then
|
||||||
|
-- any other shootyepgp traffic (EP awards, ...) proves it runs
|
||||||
|
local e = entry("yes", nil, msg)
|
||||||
|
e.presenceOnly = true
|
||||||
|
return e
|
||||||
|
end
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
|
||||||
|
-- "1" -> present, "0" -> absent; anything else is not an answer
|
||||||
|
function A.ParseSwow(msg)
|
||||||
|
if msg == "1" then
|
||||||
|
local e = entry("yes", nil, msg)
|
||||||
|
e.flag = true
|
||||||
|
return e
|
||||||
|
elseif msg == "0" then
|
||||||
|
local e = entry("no", nil, msg)
|
||||||
|
e.flag = false
|
||||||
|
return e
|
||||||
|
end
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
|
||||||
|
local PARSER = {
|
||||||
|
BIGWIGS = A.ParseBigWigs,
|
||||||
|
TWT = A.ParseTWT,
|
||||||
|
RINSE = A.ParseRinse,
|
||||||
|
EPGP = A.ParseEPGP,
|
||||||
|
SUPERWOW = A.ParseSwow,
|
||||||
|
}
|
||||||
|
|
||||||
|
-- ------------------------------------------------------------------
|
||||||
|
-- passive store
|
||||||
|
-- ------------------------------------------------------------------
|
||||||
|
local function put(name, slot, e, now)
|
||||||
|
local byName = A.data[name]
|
||||||
|
if not byName then
|
||||||
|
if A.dataCount >= A.MAX_NAMES then
|
||||||
|
A.data = {}
|
||||||
|
A.dataCount = 0
|
||||||
|
end
|
||||||
|
byName = {}
|
||||||
|
A.data[name] = byName
|
||||||
|
A.dataCount = A.dataCount + 1
|
||||||
|
end
|
||||||
|
local old = byName[slot]
|
||||||
|
-- a presence-only hint never replaces a real version
|
||||||
|
if e.presenceOnly and old and not old.presenceOnly then
|
||||||
|
return false
|
||||||
|
end
|
||||||
|
e.at = now
|
||||||
|
byName[slot] = e
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
|
||||||
|
-- OnAddonMessage(prefix, msg, channel, sender, now) -> changed (bool)
|
||||||
|
-- Early return on an unrelated prefix: one table lookup, no allocation.
|
||||||
|
function A.OnAddonMessage(prefix, msg, channel, sender, now)
|
||||||
|
local slot = PREFIX_SLOT[prefix]
|
||||||
|
if not slot or type(sender) ~= "string" or sender == A.own then
|
||||||
|
return false
|
||||||
|
end
|
||||||
|
-- BigWigs itself only honours its syncs on RAID
|
||||||
|
if slot == "BIGWIGS" and channel ~= "RAID" then
|
||||||
|
return false
|
||||||
|
end
|
||||||
|
local e = PARSER[slot](msg)
|
||||||
|
if not e then
|
||||||
|
return false
|
||||||
|
end
|
||||||
|
e.channel = channel
|
||||||
|
local changed = put(sender, slot, e, now)
|
||||||
|
if slot == "SUPERWOW" then
|
||||||
|
-- the responder lives inside shootyepgp: an answer proves it
|
||||||
|
local p = entry("yes", nil, msg)
|
||||||
|
p.presenceOnly = true
|
||||||
|
p.channel = channel
|
||||||
|
if put(sender, "EPGP", p, now) then
|
||||||
|
changed = true
|
||||||
|
end
|
||||||
|
end
|
||||||
|
return changed
|
||||||
|
end
|
||||||
|
|
||||||
|
-- drop everybody not in the given name set (members who left)
|
||||||
|
function A.Prune(nameSet)
|
||||||
|
for name in pairs(A.data) do
|
||||||
|
if not nameSet[name] then
|
||||||
|
A.data[name] = nil
|
||||||
|
A.dataCount = A.dataCount - 1
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
function A.Reset()
|
||||||
|
A.data = {}
|
||||||
|
A.dataCount = 0
|
||||||
|
A.query = { at = nil, sent = {}, roster = {} }
|
||||||
|
A.lastStatus = nil
|
||||||
|
end
|
||||||
|
|
||||||
|
-- ------------------------------------------------------------------
|
||||||
|
-- query
|
||||||
|
-- env = { now, inCombat, raid, party, guild, send(prefix, msg, channel),
|
||||||
|
-- roster = array of names }
|
||||||
|
-- StartQuery(env, auto) -> started (bool), reason (string|nil)
|
||||||
|
-- ------------------------------------------------------------------
|
||||||
|
function A.StartQuery(env, auto)
|
||||||
|
local q = A.query
|
||||||
|
if env.inCombat then
|
||||||
|
A.lastStatus = "not in combat"
|
||||||
|
return false, "in combat"
|
||||||
|
end
|
||||||
|
if q.at and env.now - q.at < A.WINDOW then
|
||||||
|
return false, "query running"
|
||||||
|
end
|
||||||
|
if auto and q.at and env.now - q.at < A.AUTO_THROTTLE then
|
||||||
|
return false, "throttled"
|
||||||
|
end
|
||||||
|
if not env.raid and not env.party then
|
||||||
|
A.lastStatus = "not in a group"
|
||||||
|
return false, "no group"
|
||||||
|
end
|
||||||
|
local send = env.send
|
||||||
|
local chan = env.raid and "RAID" or "PARTY"
|
||||||
|
local sent = {}
|
||||||
|
if env.raid then
|
||||||
|
pcall(send, "BigWigs", "BWVQ BigWigs", "RAID")
|
||||||
|
sent.BIGWIGS = true
|
||||||
|
end
|
||||||
|
pcall(send, "TWT", "TWT_WHO", chan)
|
||||||
|
sent.TWT = true
|
||||||
|
pcall(send, "Rinse", "REPORT_ADDON_VERSION", chan)
|
||||||
|
sent.RINSE = true
|
||||||
|
if env.guild then
|
||||||
|
pcall(send, "SWOW_Q", "1", "GUILD")
|
||||||
|
sent.SUPERWOW = true
|
||||||
|
sent.EPGP = true
|
||||||
|
end
|
||||||
|
local roster = {}
|
||||||
|
local names = env.roster or {}
|
||||||
|
for i = 1, table.getn(names) do
|
||||||
|
roster[names[i]] = true
|
||||||
|
end
|
||||||
|
A.query = { at = env.now, sent = sent, roster = roster }
|
||||||
|
A.lastStatus = nil
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
|
||||||
|
-- AutoCheck: the throttled variant used when the tab is opened
|
||||||
|
function A.AutoCheck(env)
|
||||||
|
return A.StartQuery(env, true)
|
||||||
|
end
|
||||||
|
|
||||||
|
function A.IsSilent(name, slot, now)
|
||||||
|
local q = A.query
|
||||||
|
if not q.at or not q.sent[slot] or not q.roster[name] then
|
||||||
|
return false
|
||||||
|
end
|
||||||
|
return now >= q.at + A.WINDOW
|
||||||
|
end
|
||||||
|
|
||||||
|
-- ------------------------------------------------------------------
|
||||||
|
-- own row: local knowledge instead of replies (several addons never
|
||||||
|
-- answer their own sender).
|
||||||
|
-- g = { loaded = function(name) -> bool, meta = function(addon, field),
|
||||||
|
-- bigwigsRev = number|nil, twtVer = string|nil, superwow = any }
|
||||||
|
-- returns slot -> entry | false (known not installed)
|
||||||
|
-- ------------------------------------------------------------------
|
||||||
|
function A.LocalEntries(g)
|
||||||
|
local out = {}
|
||||||
|
local function mk(text, variant)
|
||||||
|
local e = entry(A.Clean(text) or "yes", variant, "local")
|
||||||
|
e.islocal = true
|
||||||
|
return e
|
||||||
|
end
|
||||||
|
-- BigWigs
|
||||||
|
if g.loaded("BigWigs") then
|
||||||
|
local rev = g.bigwigsRev or tonumber(g.meta("BigWigs", "X-Revision"))
|
||||||
|
local fork = A.Clean(g.meta("BigWigs", "X-Fork"))
|
||||||
|
if rev and rev > 0 then
|
||||||
|
out.BIGWIGS = mk("r" .. rev, fork or "unknown")
|
||||||
|
else
|
||||||
|
out.BIGWIGS = mk("yes", fork or "unknown")
|
||||||
|
out.BIGWIGS.presenceOnly = true
|
||||||
|
end
|
||||||
|
else
|
||||||
|
out.BIGWIGS = false
|
||||||
|
end
|
||||||
|
-- threat meter
|
||||||
|
if g.twtVer then
|
||||||
|
out.TWT = mk(g.twtVer)
|
||||||
|
elseif g.loaded("TWThreat") then
|
||||||
|
out.TWT = mk(g.meta("TWThreat", "Version") or "yes")
|
||||||
|
else
|
||||||
|
out.TWT = false
|
||||||
|
end
|
||||||
|
-- shootyepgp
|
||||||
|
if g.loaded("shootyepgp") then
|
||||||
|
out.EPGP = mk(g.meta("shootyepgp", "Version") or "yes")
|
||||||
|
else
|
||||||
|
out.EPGP = false
|
||||||
|
end
|
||||||
|
-- SuperWoW: a global the DLL sets, no version needed
|
||||||
|
if g.superwow then
|
||||||
|
local e = mk("yes")
|
||||||
|
e.flag = true
|
||||||
|
out.SUPERWOW = e
|
||||||
|
else
|
||||||
|
local e = mk("no")
|
||||||
|
e.flag = false
|
||||||
|
out.SUPERWOW = e
|
||||||
|
end
|
||||||
|
-- Rinse (the fork first: it is the one that answers queries)
|
||||||
|
if g.loaded("UpsiRinse") then
|
||||||
|
out.RINSE = mk(g.meta("UpsiRinse", "Version") or "yes")
|
||||||
|
elseif g.loaded("Rinse") then
|
||||||
|
out.RINSE = mk(g.meta("Rinse", "Version") or "yes")
|
||||||
|
else
|
||||||
|
out.RINSE = false
|
||||||
|
end
|
||||||
|
return out
|
||||||
|
end
|
||||||
|
|
||||||
|
-- ------------------------------------------------------------------
|
||||||
|
-- evaluation
|
||||||
|
-- ------------------------------------------------------------------
|
||||||
|
|
||||||
|
local function variantKey(e)
|
||||||
|
return e.variant or ""
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Group the present entries of one slot by fork/variant and find the
|
||||||
|
-- reference variant (most members, ties broken by name) plus the newest
|
||||||
|
-- parseable version inside each variant.
|
||||||
|
local function buildGroups(list)
|
||||||
|
local byVar, vars = {}, {}
|
||||||
|
for i = 1, table.getn(list) do
|
||||||
|
local e = list[i]
|
||||||
|
local k = variantKey(e)
|
||||||
|
local g = byVar[k]
|
||||||
|
if not g then
|
||||||
|
g = { key = k, count = 0, parseable = 0, uniform = true,
|
||||||
|
first = e.text, best = nil }
|
||||||
|
byVar[k] = g
|
||||||
|
table.insert(vars, g)
|
||||||
|
end
|
||||||
|
g.count = g.count + 1
|
||||||
|
if e.text ~= g.first then
|
||||||
|
g.uniform = false
|
||||||
|
end
|
||||||
|
if e.ver then
|
||||||
|
g.parseable = g.parseable + 1
|
||||||
|
if not g.best or A.Compare(e.ver, g.best.ver) > 0 then
|
||||||
|
g.best = e
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
local ref
|
||||||
|
for i = 1, table.getn(vars) do
|
||||||
|
local g = vars[i]
|
||||||
|
if not ref or g.count > ref.count
|
||||||
|
or (g.count == ref.count and g.key < ref.key) then
|
||||||
|
ref = g
|
||||||
|
end
|
||||||
|
end
|
||||||
|
return byVar, ref
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Decide check / bang for one present entry. Returns mark, note.
|
||||||
|
local function markFor(e, byVar, ref)
|
||||||
|
if e.flag ~= nil then
|
||||||
|
return "check", nil -- SuperWoW has no version
|
||||||
|
end
|
||||||
|
local g = byVar[variantKey(e)]
|
||||||
|
if g ~= ref then
|
||||||
|
local who = (e.variant and e.variant ~= "") and e.variant or "other"
|
||||||
|
local refName = (ref.key ~= "") and ref.key or "main"
|
||||||
|
return "bang", "fork " .. who .. " (the raid mostly runs "
|
||||||
|
.. refName .. ", newest " .. (ref.best and ref.best.text or ref.first) .. ")"
|
||||||
|
end
|
||||||
|
if e.ver then
|
||||||
|
if g.best and A.Compare(e.ver, g.best.ver) < 0 then
|
||||||
|
return "bang", "newest in raid: " .. g.best.text
|
||||||
|
end
|
||||||
|
return "check", nil
|
||||||
|
end
|
||||||
|
-- unparseable text: fine only when nothing different is around
|
||||||
|
if g.parseable == 0 and g.uniform then
|
||||||
|
return "check", nil
|
||||||
|
end
|
||||||
|
return "bang", "version cannot be compared"
|
||||||
|
.. (g.best and (" (newest in raid: " .. g.best.text .. ")") or "")
|
||||||
|
end
|
||||||
|
|
||||||
|
local NA_CLASS = "this class cannot dispel, so Rinse is not expected"
|
||||||
|
local NA_GUILD = "not in your guild - shootyepgp only talks on guild chat"
|
||||||
|
local NA_NOGLD = "you are not in a guild - this check needs guild chat"
|
||||||
|
|
||||||
|
-- Annotate(players, ctx): sets p.addons[slot] and p.addonsAnswered on every
|
||||||
|
-- player table. ctx:
|
||||||
|
-- now clock
|
||||||
|
-- own own character name (nil in test mode)
|
||||||
|
-- ownEntries LocalEntries result (or nil)
|
||||||
|
-- myGuild bool, I am in a guild
|
||||||
|
-- inGuild(p) bool, this player is in my guild
|
||||||
|
-- lookup(p, slot) -> entry | nil
|
||||||
|
-- silent(p, slot) -> bool (queried, window over, no reply)
|
||||||
|
-- queryAt query time (nil = nothing asked yet)
|
||||||
|
function A.Annotate(players, ctx)
|
||||||
|
local n = table.getn(players)
|
||||||
|
local resolved = {} -- resolved[i][slot] = entry | false | "na" ... see below
|
||||||
|
local lists = {} -- present entries per slot (for the groups)
|
||||||
|
for s = 1, table.getn(A.SLOT_IDS) do
|
||||||
|
lists[A.SLOT_IDS[s]] = {}
|
||||||
|
end
|
||||||
|
local info = {} -- info[i][slot] = { kind=..., e=..., why=... }
|
||||||
|
|
||||||
|
for i = 1, n do
|
||||||
|
local p = players[i]
|
||||||
|
local row = {}
|
||||||
|
info[i] = row
|
||||||
|
local isOwn = ctx.own ~= nil and p.name == ctx.own and ctx.ownEntries
|
||||||
|
for s = 1, table.getn(A.SLOT_IDS) do
|
||||||
|
local slot = A.SLOT_IDS[s]
|
||||||
|
local r
|
||||||
|
if p.online == false then
|
||||||
|
r = { kind = "unknown", why = "offline" }
|
||||||
|
elseif slot == "RINSE" and not A.DISPELLERS[p.class or ""] then
|
||||||
|
r = { kind = "na", why = NA_CLASS }
|
||||||
|
elseif isOwn then
|
||||||
|
local e = ctx.ownEntries[slot]
|
||||||
|
if e then
|
||||||
|
r = { kind = "present", e = e }
|
||||||
|
if not e.presenceOnly then
|
||||||
|
table.insert(lists[slot], e)
|
||||||
|
end
|
||||||
|
else
|
||||||
|
r = { kind = "missing",
|
||||||
|
why = "not installed on your own character" }
|
||||||
|
end
|
||||||
|
elseif (slot == "EPGP" or slot == "SUPERWOW")
|
||||||
|
and not ctx.inGuild(p) then
|
||||||
|
r = { kind = "na", why = ctx.myGuild and NA_GUILD or NA_NOGLD }
|
||||||
|
else
|
||||||
|
local e = ctx.lookup(p, slot)
|
||||||
|
if e then
|
||||||
|
r = { kind = "present", e = e }
|
||||||
|
if not e.presenceOnly then
|
||||||
|
table.insert(lists[slot], e)
|
||||||
|
end
|
||||||
|
elseif ctx.silent(p, slot) then
|
||||||
|
r = { kind = "missing" }
|
||||||
|
else
|
||||||
|
local why
|
||||||
|
if ctx.queryAt then
|
||||||
|
why = "waiting for a reply, or this addon was not"
|
||||||
|
.. " queried in this kind of group"
|
||||||
|
else
|
||||||
|
why = "not asked yet - use Check addons"
|
||||||
|
end
|
||||||
|
r = { kind = "unknown", why = why }
|
||||||
|
end
|
||||||
|
end
|
||||||
|
row[slot] = r
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
-- newest per slot and variant
|
||||||
|
local groups, refs = {}, {}
|
||||||
|
for s = 1, table.getn(A.SLOT_IDS) do
|
||||||
|
local slot = A.SLOT_IDS[s]
|
||||||
|
if table.getn(lists[slot]) > 0 then
|
||||||
|
groups[slot], refs[slot] = buildGroups(lists[slot])
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
for i = 1, n do
|
||||||
|
local p = players[i]
|
||||||
|
local out = {}
|
||||||
|
local answered = false
|
||||||
|
for s = 1, table.getn(A.SLOT_IDS) do
|
||||||
|
local slot = A.SLOT_IDS[s]
|
||||||
|
local r = info[i][slot]
|
||||||
|
local res
|
||||||
|
if r.kind == "present" then
|
||||||
|
local e = r.e
|
||||||
|
if e.flag == false then
|
||||||
|
res = { status = "missing", text = e.text, raw = e.raw,
|
||||||
|
at = e.at, islocal = e.islocal,
|
||||||
|
why = e.islocal and "SuperWoW is not loaded"
|
||||||
|
or "shootyepgp reports: no SuperWoW" }
|
||||||
|
answered = true
|
||||||
|
else
|
||||||
|
local mark, note, newest
|
||||||
|
if e.presenceOnly then
|
||||||
|
-- present, version unknown: fine unless somebody
|
||||||
|
-- else reported one to compare against
|
||||||
|
if refs[slot] and refs[slot].best then
|
||||||
|
mark = "bang"
|
||||||
|
newest = refs[slot].best.text
|
||||||
|
note = "version unknown (newest in raid: "
|
||||||
|
.. newest .. ")"
|
||||||
|
else
|
||||||
|
mark = "check"
|
||||||
|
end
|
||||||
|
else
|
||||||
|
mark, note = markFor(e, groups[slot], refs[slot])
|
||||||
|
local g = groups[slot][variantKey(e)]
|
||||||
|
newest = g.best and g.best.text or nil
|
||||||
|
end
|
||||||
|
res = { status = (mark == "check") and "ok" or "old",
|
||||||
|
mark = mark, text = e.text, raw = e.raw,
|
||||||
|
at = e.at, variant = e.variant,
|
||||||
|
islocal = e.islocal, note = note,
|
||||||
|
presenceOnly = e.presenceOnly,
|
||||||
|
newest = newest }
|
||||||
|
answered = true
|
||||||
|
end
|
||||||
|
elseif r.kind == "missing" then
|
||||||
|
res = { status = "missing", why = r.why
|
||||||
|
or "no reply - addon missing, or an old version / fork"
|
||||||
|
.. " that does not answer" }
|
||||||
|
elseif r.kind == "na" then
|
||||||
|
res = { status = "na", why = r.why }
|
||||||
|
else
|
||||||
|
res = { status = "unknown", why = r.why }
|
||||||
|
end
|
||||||
|
out[slot] = res
|
||||||
|
end
|
||||||
|
p.addons = out
|
||||||
|
p.addonsAnswered = answered
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
-- ------------------------------------------------------------------
|
||||||
|
-- WoW wiring
|
||||||
|
-- ------------------------------------------------------------------
|
||||||
|
|
||||||
|
-- the real-game ctx: passive store + query state + guild lookup
|
||||||
|
function A.RealCtx(env)
|
||||||
|
local now = env.now
|
||||||
|
return {
|
||||||
|
now = now,
|
||||||
|
own = env.own,
|
||||||
|
ownEntries = env.ownEntries,
|
||||||
|
myGuild = env.myGuild,
|
||||||
|
inGuild = env.inGuild,
|
||||||
|
queryAt = A.query.at,
|
||||||
|
lookup = function(p, slot)
|
||||||
|
local byName = A.data[p.name]
|
||||||
|
return byName and byName[slot] or nil
|
||||||
|
end,
|
||||||
|
silent = function(p, slot)
|
||||||
|
return A.IsSilent(p.name, slot, now)
|
||||||
|
end,
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
-- the simulated raid: players carry p.addonSim = { guild = bool,
|
||||||
|
-- <slot> = entry (replied) | false (queried, silent) | nil (unknown) }
|
||||||
|
function A.SimCtx(now)
|
||||||
|
return {
|
||||||
|
now = now,
|
||||||
|
myGuild = true,
|
||||||
|
queryAt = now,
|
||||||
|
inGuild = function(p)
|
||||||
|
return p.addonSim ~= nil and p.addonSim.guild == true
|
||||||
|
end,
|
||||||
|
lookup = function(p, slot)
|
||||||
|
local v = p.addonSim and p.addonSim[slot]
|
||||||
|
if type(v) ~= "table" then
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
local e = entry(v.text, v.variant, "sim: " .. v.text)
|
||||||
|
e.flag = v.flag
|
||||||
|
e.presenceOnly = v.presenceOnly
|
||||||
|
e.at = now
|
||||||
|
return e
|
||||||
|
end,
|
||||||
|
silent = function(p, slot)
|
||||||
|
return p.addonSim ~= nil and p.addonSim[slot] == false
|
||||||
|
end,
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Annotate a store's players for display (called before the model builds).
|
||||||
|
function A.AnnotateStore(store, env)
|
||||||
|
if not store or not store.players then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
if store.source == "sim" then
|
||||||
|
A.Annotate(store.players, A.SimCtx(env and env.now or 0))
|
||||||
|
return
|
||||||
|
end
|
||||||
|
A.Annotate(store.players, A.RealCtx(env))
|
||||||
|
-- evict against the live roster (not the store: before the first scan
|
||||||
|
-- finishes the store is empty and would wipe everything)
|
||||||
|
if env and env.roster and table.getn(env.roster) > 0 then
|
||||||
|
local set = {}
|
||||||
|
for i = 1, table.getn(env.roster) do
|
||||||
|
set[env.roster[i]] = true
|
||||||
|
end
|
||||||
|
A.Prune(set)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
-- tooltip/age helper (pure): seconds -> "5s ago" / "2m ago" / "1h ago"
|
||||||
|
function A.AgeText(sec)
|
||||||
|
if type(sec) ~= "number" or sec < 0 then
|
||||||
|
return "just now"
|
||||||
|
end
|
||||||
|
if sec < 60 then
|
||||||
|
return math.floor(sec) .. "s ago"
|
||||||
|
elseif sec < 3600 then
|
||||||
|
return math.floor(sec / 60) .. "m ago"
|
||||||
|
end
|
||||||
|
return math.floor(sec / 3600) .. "h ago"
|
||||||
|
end
|
||||||
|
|
||||||
|
if CreateFrame then
|
||||||
|
|
||||||
|
local function names()
|
||||||
|
local list = {}
|
||||||
|
local own = UnitName("player")
|
||||||
|
if own then
|
||||||
|
table.insert(list, own)
|
||||||
|
end
|
||||||
|
local nr = GetNumRaidMembers and GetNumRaidMembers() or 0
|
||||||
|
if nr > 0 then
|
||||||
|
list = {}
|
||||||
|
for i = 1, nr do
|
||||||
|
local nm = GetRaidRosterInfo(i)
|
||||||
|
if nm then
|
||||||
|
table.insert(list, nm)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
else
|
||||||
|
local np = GetNumPartyMembers and GetNumPartyMembers() or 0
|
||||||
|
for i = 1, np do
|
||||||
|
local nm = UnitName("party" .. i)
|
||||||
|
if nm then
|
||||||
|
table.insert(list, nm)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
return list
|
||||||
|
end
|
||||||
|
|
||||||
|
local function guildName(unit)
|
||||||
|
if GetGuildInfo then
|
||||||
|
return (GetGuildInfo(unit))
|
||||||
|
end
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
|
||||||
|
-- name -> true for raid/party members in MY guild, rebuilt per call (the
|
||||||
|
-- display refresh is event driven, not per frame)
|
||||||
|
local function guildSet()
|
||||||
|
local set = {}
|
||||||
|
local mine = guildName("player")
|
||||||
|
if not mine then
|
||||||
|
return set, false
|
||||||
|
end
|
||||||
|
local nr = GetNumRaidMembers and GetNumRaidMembers() or 0
|
||||||
|
for i = 1, nr do
|
||||||
|
local unit = "raid" .. i
|
||||||
|
if guildName(unit) == mine then
|
||||||
|
set[UnitName(unit)] = true
|
||||||
|
end
|
||||||
|
end
|
||||||
|
local np = GetNumPartyMembers and GetNumPartyMembers() or 0
|
||||||
|
for i = 1, np do
|
||||||
|
local unit = "party" .. i
|
||||||
|
if guildName(unit) == mine then
|
||||||
|
set[UnitName(unit)] = true
|
||||||
|
end
|
||||||
|
end
|
||||||
|
local own = UnitName("player")
|
||||||
|
if own then
|
||||||
|
set[own] = true
|
||||||
|
end
|
||||||
|
return set, true
|
||||||
|
end
|
||||||
|
|
||||||
|
function A.GameEnv()
|
||||||
|
local now = GetTime()
|
||||||
|
local nr = GetNumRaidMembers and GetNumRaidMembers() or 0
|
||||||
|
local np = GetNumPartyMembers and GetNumPartyMembers() or 0
|
||||||
|
local gs, inGuild = guildSet()
|
||||||
|
local g = {
|
||||||
|
loaded = function(name)
|
||||||
|
return IsAddOnLoaded and IsAddOnLoaded(name) and true or false
|
||||||
|
end,
|
||||||
|
meta = function(addon, field)
|
||||||
|
return GetAddOnMetadata and GetAddOnMetadata(addon, field) or nil
|
||||||
|
end,
|
||||||
|
bigwigsRev = (type(BigWigs) == "table") and tonumber(BigWigs.revision) or nil,
|
||||||
|
twtVer = (type(TWT) == "table") and TWT.addonVer or nil,
|
||||||
|
superwow = SUPERWOW_VERSION,
|
||||||
|
}
|
||||||
|
local own = UnitName("player")
|
||||||
|
A.own = own
|
||||||
|
return {
|
||||||
|
now = now,
|
||||||
|
raid = nr > 0,
|
||||||
|
party = nr == 0 and np > 0,
|
||||||
|
guild = inGuild,
|
||||||
|
myGuild = inGuild,
|
||||||
|
inCombat = UnitAffectingCombat and UnitAffectingCombat("player") and true or false,
|
||||||
|
send = SendAddonMessage,
|
||||||
|
roster = names(),
|
||||||
|
own = own,
|
||||||
|
ownEntries = A.LocalEntries(g),
|
||||||
|
inGuild = function(p)
|
||||||
|
return gs[p.name] == true
|
||||||
|
end,
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Check addons button / tab open. Returns started, reason.
|
||||||
|
function A.Check(auto)
|
||||||
|
local ok, env = pcall(A.GameEnv)
|
||||||
|
if not ok then
|
||||||
|
return false, "error"
|
||||||
|
end
|
||||||
|
local started, reason = A.StartQuery(env, auto)
|
||||||
|
if started then
|
||||||
|
A.finishAt = GetTime() + A.WINDOW + 0.3
|
||||||
|
A.Wake()
|
||||||
|
end
|
||||||
|
return started, reason
|
||||||
|
end
|
||||||
|
|
||||||
|
-- one frame: passive listener + a throttled updater that repaints the tab
|
||||||
|
-- when replies came in and again when the query window ends
|
||||||
|
local listener = CreateFrame("Frame", "DopingControlAddonsEventFrame", UIParent)
|
||||||
|
listener:RegisterEvent("CHAT_MSG_ADDON")
|
||||||
|
listener:SetScript("OnEvent", function()
|
||||||
|
if A.OnAddonMessage(arg1, arg2, arg3, arg4, GetTime()) then
|
||||||
|
A.dirty = true
|
||||||
|
A.Wake()
|
||||||
|
end
|
||||||
|
end)
|
||||||
|
|
||||||
|
local updater = CreateFrame("Frame", "DopingControlAddonsUpdateFrame", UIParent)
|
||||||
|
updater.dcT = 0
|
||||||
|
updater:Hide()
|
||||||
|
updater:SetScript("OnUpdate", function()
|
||||||
|
this.dcT = this.dcT + arg1
|
||||||
|
if this.dcT < 0.5 then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
this.dcT = 0
|
||||||
|
local now = GetTime()
|
||||||
|
local repaint = false
|
||||||
|
if A.dirty then
|
||||||
|
A.dirty = false
|
||||||
|
repaint = true
|
||||||
|
end
|
||||||
|
if A.finishAt and now >= A.finishAt then
|
||||||
|
A.finishAt = nil
|
||||||
|
repaint = true
|
||||||
|
end
|
||||||
|
if repaint and DC_Matrix and DC_Matrix.RefreshAddons then
|
||||||
|
DC_Matrix.RefreshAddons()
|
||||||
|
end
|
||||||
|
if not A.dirty and not A.finishAt then
|
||||||
|
this:Hide()
|
||||||
|
end
|
||||||
|
end)
|
||||||
|
|
||||||
|
function A.Wake()
|
||||||
|
updater:Show()
|
||||||
|
end
|
||||||
|
|
||||||
|
end -- if CreateFrame
|
||||||
@@ -0,0 +1,519 @@
|
|||||||
|
-- DopingControl scan/aura.lua
|
||||||
|
-- Buff reading for one unit, two independent paths, merged:
|
||||||
|
-- Path A (unit token): UnitBuff(unit, i), i = 1..32 -- buff NAME via a
|
||||||
|
-- hidden tooltip (DopingControlScanTip, the standard 1.12
|
||||||
|
-- hidden-tooltip scanner pattern), spell ID via the
|
||||||
|
-- 4th return value (a SuperWoW extension; unverified on other client
|
||||||
|
-- builds -- covered by the probe battery).
|
||||||
|
-- UnitBuff compacts its list: stop at the first nil texture.
|
||||||
|
-- Path B (GUID): GetUnitField(guid, "aura") -- nampower descriptor table,
|
||||||
|
-- 48 ONE-BASED slots of spell ids (1-32 buffs, 33-48 debuffs). The
|
||||||
|
-- table is SPARSE (in-game verified: e.g. slots
|
||||||
|
-- 1,2,5,6 filled with 3,4 empty), so iterate all 32 buff slots and
|
||||||
|
-- skip empties instead of breaking at the first gap.
|
||||||
|
-- Both paths are pcall-wrapped per call (hard rule).
|
||||||
|
--
|
||||||
|
-- Merge (pure, offline-tested): union of the ID sets, names from path A.
|
||||||
|
-- Buff icons: auras.textures[name] = icon path (UnitBuff return 1) for
|
||||||
|
-- every path-A buff that yielded a name; ids without a name are OPTIONALLY
|
||||||
|
-- resolved via SuperWoW SpellInfo(id) (return 3 = icon) when the function
|
||||||
|
-- exists. textures is OPTIONAL/partial by design -- every consumer
|
||||||
|
-- nil-guards, nothing downstream may require it.
|
||||||
|
-- Buff EFFECT lines: auras.effects[name] = the tooltip's line 2 text
|
||||||
|
-- (same hidden-tooltip read as the name, TextLeft2) for every path-A buff
|
||||||
|
-- that yielded both a name and a non-empty line 2. This feeds the item
|
||||||
|
-- identification ladder for generic buff names ("Well Fed" -> which food,
|
||||||
|
-- via DC.ResolveItem's discrim patterns). effects is OPTIONAL/partial
|
||||||
|
-- exactly like textures -- every consumer nil-guards.
|
||||||
|
-- Disagreements between the two ID sets are counted (symmetric difference,
|
||||||
|
-- only when BOTH paths yielded at least one ID -- an absent path is missing
|
||||||
|
-- data, not a disagreement) and accumulated by the engine into
|
||||||
|
-- DC.probeStats. Zero USABLE auras (name- or id-carrying) from both paths
|
||||||
|
-- => aurasRead = false (a failed read, never "has nothing";
|
||||||
|
-- texture-only path-A rows carry no evidence and do not count).
|
||||||
|
--
|
||||||
|
-- Unknown auras are collected, never silently dropped:
|
||||||
|
-- an aura is unknown when neither its name is in DC.CONSUMABLES /
|
||||||
|
-- DC.CLASSBUFFS nor its spell ID in DC.SPELL_TO_SLOT. Path-B-only IDs
|
||||||
|
-- normally carry no name and are reported as "spell:<id>", UNLESS the
|
||||||
|
-- caller supplies idNames (see A.Assemble below): with a resolved name,
|
||||||
|
-- a path-B-only aura is judged the same way a path-A one is (known
|
||||||
|
-- consumable/class-buff name => not unknown at all) and, if still
|
||||||
|
-- unknown, is reported as that name instead of "spell:<id>" -- and only
|
||||||
|
-- once, even when the same aura also showed up unresolved via path A
|
||||||
|
-- (this is what keeps the same aura from being counted twice: once as a
|
||||||
|
-- name from path A, once as "spell:<id>" from path B, which used to
|
||||||
|
-- happen whenever path A never delivered a spell ID at all, making the
|
||||||
|
-- id-based dedup a no-op).
|
||||||
|
--
|
||||||
|
-- DEBUFFS:
|
||||||
|
-- ReadUnit additionally reads UnitDebuff(unit, i) 1..16 (same hidden
|
||||||
|
-- tooltip via SetUnitDebuff, texture = return 1, spell id = 4th return if
|
||||||
|
-- present) and the debuff half of the SAME GetUnitField "aura" fetch
|
||||||
|
-- (one-based slots 33-48). AssembleDebuffs merges them into the store
|
||||||
|
-- shape players[i].debuffs = { names, ids, textures } (sibling of auras).
|
||||||
|
-- Debuff READABILITY rides on the existing aurasRead flag -- zero debuffs
|
||||||
|
-- is a LEGITIMATE clean state (empty sets), NEVER a read failure, and it
|
||||||
|
-- never influences aurasRead. Debuff names are NOT collected into
|
||||||
|
-- DC.unknownSeen (that channel is consumable tracking);
|
||||||
|
-- nothing is silently dropped either way, because the DEBUFFS tab builds
|
||||||
|
-- its columns dynamically from exactly this data.
|
||||||
|
--
|
||||||
|
-- Pure Lua 5.0, dofile-loadable offline (WoW wiring only inside functions
|
||||||
|
-- that run in-game; tooltip frame is created lazily).
|
||||||
|
|
||||||
|
DopingControl = DopingControl or {}
|
||||||
|
local DC = DopingControl
|
||||||
|
|
||||||
|
DC_Aura = DC_Aura or {}
|
||||||
|
local A = DC_Aura
|
||||||
|
|
||||||
|
-- ==================================================================
|
||||||
|
-- Pure part
|
||||||
|
-- ==================================================================
|
||||||
|
|
||||||
|
-- Assemble(rawA, rawB, idNames) -> auras, aurasRead, disagreements, unknown
|
||||||
|
-- rawA : array of { name = string|nil, id = number|nil,
|
||||||
|
-- texture = string|nil, effect = string|nil }
|
||||||
|
-- (path A; texture is the 1st UnitBuff return,
|
||||||
|
-- effect the tooltip's line 2 -- both optional)
|
||||||
|
-- rawB : array of spell ids (numbers, path B)
|
||||||
|
-- idNames : OPTIONAL { [spellId] = "Buff Name" } for path-B ids,
|
||||||
|
-- filled by the WoW-side caller (SuperWoW SpellInfo lookup --
|
||||||
|
-- see A.ReadUnit). Omitted in tests/offline callers, in which
|
||||||
|
-- case unknown path-B ids are reported as "spell:<id>" exactly
|
||||||
|
-- as before (no behavior change without it).
|
||||||
|
-- Returns:
|
||||||
|
-- auras : { names = { [name]=true }, ids = { [id]=true },
|
||||||
|
-- textures = { [name]=iconPath },
|
||||||
|
-- effects = { [name]=effectLine } }
|
||||||
|
-- (store shape; textures AND effects may be empty/
|
||||||
|
-- partial -- consumers nil-guard)
|
||||||
|
-- aurasRead : bool -- true iff at least one USABLE aura arrived: a
|
||||||
|
-- path-A entry carrying a name or an id, or a path-B id.
|
||||||
|
-- Entries with NEITHER (UnitBuff texture present but the
|
||||||
|
-- tooltip read failed and no 4th return) are information-
|
||||||
|
-- free: counting them would flip a partially failed read
|
||||||
|
-- from UNKNOWN to all-slots-MISSING -- exactly what the
|
||||||
|
-- "zero auras => read failed" rule
|
||||||
|
-- exists to prevent.
|
||||||
|
-- disagreements : number of ids present in exactly one of the two ID
|
||||||
|
-- sets; 0 unless both sets are non-empty
|
||||||
|
-- unknown : array of unknown-aura keys (name, or "spell:<id>")
|
||||||
|
-- trim(s) -> s with leading/trailing whitespace stripped. Lua-5.0-safe:
|
||||||
|
-- string.find with a captured "middle" group, no string-method syntax.
|
||||||
|
local function trim(s)
|
||||||
|
local _, _, middle = string.find(s, "^%s*(.-)%s*$")
|
||||||
|
return middle
|
||||||
|
end
|
||||||
|
|
||||||
|
-- isKnownAuraName(name, cons, cbuf) -> true if `name` is a recognized
|
||||||
|
-- consumable/class-buff key -- tried EXACT first, then (only if that
|
||||||
|
-- misses) its trimmed form. SpellInfo() aura names can carry stray
|
||||||
|
-- leading/trailing whitespace on this client (measured live:
|
||||||
|
-- SpellInfo(7254) = "Nature Protection " with a trailing space) that a
|
||||||
|
-- hand-authored table key never has; without this fallback such a name
|
||||||
|
-- never matches its own table entry. Exact-first matters because a
|
||||||
|
-- table key legitimately containing whitespace (should one ever exist)
|
||||||
|
-- must still win over a coincidental trimmed collision.
|
||||||
|
local function isKnownAuraName(name, cons, cbuf)
|
||||||
|
if not name then
|
||||||
|
return false
|
||||||
|
end
|
||||||
|
if cons[name] or cbuf[name] then
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
local trimmed = trim(name)
|
||||||
|
if trimmed ~= name and (cons[trimmed] or cbuf[trimmed]) then
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
return false
|
||||||
|
end
|
||||||
|
|
||||||
|
function A.Assemble(rawA, rawB, idNames)
|
||||||
|
rawA = rawA or {}
|
||||||
|
rawB = rawB or {}
|
||||||
|
idNames = idNames or {}
|
||||||
|
local names = {}
|
||||||
|
local ids = {}
|
||||||
|
local idsA = {}
|
||||||
|
local idsB = {}
|
||||||
|
local textures = {} -- [buffName] = icon path
|
||||||
|
local effects = {} -- [buffName] = tooltip effect line (line 2)
|
||||||
|
local namedIds = {} -- ids that arrived WITH a name (path A rows)
|
||||||
|
local usable = 0 -- entries that carry actual evidence (see header)
|
||||||
|
|
||||||
|
local nA = table.getn(rawA)
|
||||||
|
for i = 1, nA do
|
||||||
|
local e = rawA[i]
|
||||||
|
local hasInfo = false
|
||||||
|
if e.name then
|
||||||
|
names[e.name] = true
|
||||||
|
hasInfo = true
|
||||||
|
if e.texture then
|
||||||
|
textures[e.name] = e.texture
|
||||||
|
end
|
||||||
|
if type(e.effect) == "string" and e.effect ~= "" then
|
||||||
|
effects[e.name] = e.effect
|
||||||
|
end
|
||||||
|
end
|
||||||
|
if type(e.id) == "number" and e.id > 0 then
|
||||||
|
idsA[e.id] = true
|
||||||
|
ids[e.id] = true
|
||||||
|
hasInfo = true
|
||||||
|
if e.name then
|
||||||
|
namedIds[e.id] = true
|
||||||
|
end
|
||||||
|
end
|
||||||
|
if hasInfo then
|
||||||
|
usable = usable + 1
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
local nB = table.getn(rawB)
|
||||||
|
for i = 1, nB do
|
||||||
|
local id = rawB[i]
|
||||||
|
if type(id) == "number" and id > 0 then
|
||||||
|
idsB[id] = true
|
||||||
|
ids[id] = true
|
||||||
|
usable = usable + 1
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
local aurasRead = usable > 0
|
||||||
|
|
||||||
|
-- Optional icon resolution for ids that carried no name (path-B-only
|
||||||
|
-- ids, or nameless path-A rows): SuperWoW SpellInfo(id) -> name, rank,
|
||||||
|
-- icon (3rd return). Runtime-only enhancement -- SpellInfo is nil in
|
||||||
|
-- offline tests and may be absent in-game (non-SuperWoW client);
|
||||||
|
-- textures simply stays partial then. Path-A textures win (never
|
||||||
|
-- overwritten): UnitBuff saw the actual aura, SpellInfo is a lookup.
|
||||||
|
if SpellInfo then
|
||||||
|
for id in pairs(ids) do
|
||||||
|
if not namedIds[id] then
|
||||||
|
local okSI, nm, _, icon = pcall(SpellInfo, id)
|
||||||
|
if okSI and nm and icon and textures[nm] == nil then
|
||||||
|
textures[nm] = icon
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
-- disagreement count: symmetric difference of the ID sets, but only
|
||||||
|
-- when both paths actually delivered IDs (else it is path absence)
|
||||||
|
local disagreements = 0
|
||||||
|
if next(idsA) ~= nil and next(idsB) ~= nil then
|
||||||
|
for id in pairs(idsA) do
|
||||||
|
if not idsB[id] then
|
||||||
|
disagreements = disagreements + 1
|
||||||
|
end
|
||||||
|
end
|
||||||
|
for id in pairs(idsB) do
|
||||||
|
if not idsA[id] then
|
||||||
|
disagreements = disagreements + 1
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
-- unknown-aura collection (no silent discard)
|
||||||
|
local unknown = {}
|
||||||
|
local unknownNamesA = {} -- names already added to `unknown` from path A
|
||||||
|
local cons = DC.CONSUMABLES or {}
|
||||||
|
local cbuf = DC.CLASSBUFFS or {}
|
||||||
|
local s2s = DC.SPELL_TO_SLOT or {}
|
||||||
|
for i = 1, nA do
|
||||||
|
local e = rawA[i]
|
||||||
|
-- name-known OR id-known -> known. Deliberately NOT an
|
||||||
|
-- if/elseif chain gated on "no name": a row can carry a name
|
||||||
|
-- that fails to match a table key exactly (SpellInfo() aura-
|
||||||
|
-- name spelling vs. the hand-authored table key) while still
|
||||||
|
-- carrying a correct spell id -- the id must be checked
|
||||||
|
-- regardless of whether a name is present (belied a 26-man
|
||||||
|
-- raid: a correctly ID-tracked consumable was discarded 38
|
||||||
|
-- times because its live aura-name spelling did not match the
|
||||||
|
-- table key it was filed under).
|
||||||
|
local known = isKnownAuraName(e.name, cons, cbuf)
|
||||||
|
if not known and type(e.id) == "number" and s2s[e.id] then
|
||||||
|
known = true
|
||||||
|
end
|
||||||
|
if not known then
|
||||||
|
if e.name then
|
||||||
|
table.insert(unknown, e.name)
|
||||||
|
unknownNamesA[e.name] = true
|
||||||
|
elseif e.id then
|
||||||
|
table.insert(unknown, "spell:" .. e.id)
|
||||||
|
end
|
||||||
|
-- entry with neither name nor id carries no information: skip
|
||||||
|
end
|
||||||
|
end
|
||||||
|
-- path-B-only ids: with a resolved name (idNames, WoW-side SpellInfo
|
||||||
|
-- lookup) an id is judged by name exactly like a path-A entry --
|
||||||
|
-- known consumable/class-buff name is not unknown, and a name already
|
||||||
|
-- reported from path A is not reported again (this is the dedup: on
|
||||||
|
-- clients where UnitBuff never yields a spell ID, idsA stays empty
|
||||||
|
-- and the id-based check below is a no-op, so the same aura would
|
||||||
|
-- otherwise be counted once as a name and once as "spell:<id>").
|
||||||
|
-- Without a resolved name, behavior is unchanged: "spell:<id>".
|
||||||
|
for i = 1, nB do
|
||||||
|
local id = rawB[i]
|
||||||
|
if type(id) == "number" and id > 0 and not idsA[id] and not s2s[id] then
|
||||||
|
local nm = idNames[id]
|
||||||
|
if not nm then
|
||||||
|
table.insert(unknown, "spell:" .. id)
|
||||||
|
elseif isKnownAuraName(nm, cons, cbuf) then
|
||||||
|
-- known by resolved name (exact or trimmed -- same rule
|
||||||
|
-- as the path-A loop above): not unknown, nothing to add
|
||||||
|
elseif unknownNamesA[nm] then
|
||||||
|
-- already reported via path A under this name: skip
|
||||||
|
else
|
||||||
|
table.insert(unknown, nm)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
return { names = names, ids = ids, textures = textures,
|
||||||
|
effects = effects },
|
||||||
|
aurasRead, disagreements, unknown
|
||||||
|
end
|
||||||
|
|
||||||
|
-- SplitAuraField(t) -> buffIds, debuffIds (two arrays of spell ids)
|
||||||
|
-- Splits ONE GetUnitField(guid, "aura") descriptor table into its buff
|
||||||
|
-- half (one-based slots 1-32) and debuff half (slots 33-48). The table is
|
||||||
|
-- SPARSE (in-game verified, see header) -- every slot is visited, empties
|
||||||
|
-- and non-positive/non-number values are skipped, no break at gaps.
|
||||||
|
-- Pure -- offline-tested in test_scan_parse.lua.
|
||||||
|
function A.SplitAuraField(t)
|
||||||
|
local buffs = {}
|
||||||
|
local debuffs = {}
|
||||||
|
if type(t) == "table" then
|
||||||
|
for i = 1, 32 do
|
||||||
|
local v = t[i]
|
||||||
|
if type(v) == "number" and v > 0 then
|
||||||
|
table.insert(buffs, v)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
for i = 33, 48 do
|
||||||
|
local v = t[i]
|
||||||
|
if type(v) == "number" and v > 0 then
|
||||||
|
table.insert(debuffs, v)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
return buffs, debuffs
|
||||||
|
end
|
||||||
|
|
||||||
|
-- AssembleDebuffs(rawDA, rawDB) -> debuffs
|
||||||
|
-- rawDA : array of { name = string|nil, id = number|nil,
|
||||||
|
-- texture = string|nil } (UnitDebuff path, same row
|
||||||
|
-- shape as the buff path A)
|
||||||
|
-- rawDB : array of spell ids (GetUnitField debuff half, slots 33-48)
|
||||||
|
-- Returns the store shape:
|
||||||
|
-- { names = { [name]=true }, ids = { [id]=true },
|
||||||
|
-- textures = { [name]=iconPath } }
|
||||||
|
-- Merge mirrors A.Assemble: union of the ID sets, names + textures from
|
||||||
|
-- the tooltip path, optional SpellInfo icon resolution for nameless ids.
|
||||||
|
-- NO read flag is derived here (readability = the existing aurasRead
|
||||||
|
-- flag; empty sets are a legitimate clean state) and NO unknown list is
|
||||||
|
-- returned (debuffs never feed DC.unknownSeen -- see header).
|
||||||
|
function A.AssembleDebuffs(rawDA, rawDB)
|
||||||
|
rawDA = rawDA or {}
|
||||||
|
rawDB = rawDB or {}
|
||||||
|
local names = {}
|
||||||
|
local ids = {}
|
||||||
|
local textures = {}
|
||||||
|
local namedIds = {}
|
||||||
|
|
||||||
|
local nA = table.getn(rawDA)
|
||||||
|
for i = 1, nA do
|
||||||
|
local e = rawDA[i]
|
||||||
|
if e.name then
|
||||||
|
names[e.name] = true
|
||||||
|
if e.texture then
|
||||||
|
textures[e.name] = e.texture
|
||||||
|
end
|
||||||
|
end
|
||||||
|
if type(e.id) == "number" and e.id > 0 then
|
||||||
|
ids[e.id] = true
|
||||||
|
if e.name then
|
||||||
|
namedIds[e.id] = true
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
local nB = table.getn(rawDB)
|
||||||
|
for i = 1, nB do
|
||||||
|
local id = rawDB[i]
|
||||||
|
if type(id) == "number" and id > 0 then
|
||||||
|
ids[id] = true
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
-- optional icon resolution for nameless ids (mirrors A.Assemble:
|
||||||
|
-- SpellInfo is a SuperWoW runtime lookup, nil offline; tooltip-path
|
||||||
|
-- textures are never overwritten)
|
||||||
|
if SpellInfo then
|
||||||
|
for id in pairs(ids) do
|
||||||
|
if not namedIds[id] then
|
||||||
|
local okSI, nm, _, icon = pcall(SpellInfo, id)
|
||||||
|
if okSI and nm and icon and textures[nm] == nil then
|
||||||
|
textures[nm] = icon
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
return { names = names, ids = ids, textures = textures }
|
||||||
|
end
|
||||||
|
|
||||||
|
-- ==================================================================
|
||||||
|
-- WoW part (runtime only; every call pcall-wrapped)
|
||||||
|
-- ==================================================================
|
||||||
|
|
||||||
|
-- Hidden tooltip scanner (standard 1.12 pattern).
|
||||||
|
-- The frame is NAMED, and the name is REQUIRED rather than cosmetic: the
|
||||||
|
-- line FontStrings are read back via getglobal("<name>TextLeft1").
|
||||||
|
local scanTip = nil
|
||||||
|
|
||||||
|
local function ensureTip()
|
||||||
|
if not scanTip and CreateFrame then
|
||||||
|
scanTip = CreateFrame("GameTooltip", "DopingControlScanTip",
|
||||||
|
nil, "GameTooltipTemplate")
|
||||||
|
scanTip:SetOwner(WorldFrame, "ANCHOR_NONE")
|
||||||
|
end
|
||||||
|
return scanTip
|
||||||
|
end
|
||||||
|
|
||||||
|
-- shared accessor (core/probe.lua reuses the same hidden tooltip instead
|
||||||
|
-- of creating a second frame under the same global name)
|
||||||
|
function A.GetScanTip()
|
||||||
|
return ensureTip()
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Returns name (tooltip line 1) AND effect (tooltip line 2 -- the buff's
|
||||||
|
-- effect text, feeds the item identification ladder for generic names).
|
||||||
|
-- raises an error on bad units -> always called through pcall
|
||||||
|
local function tipBuffName(unit, buffIndex)
|
||||||
|
scanTip:ClearLines()
|
||||||
|
scanTip:SetUnitBuff(unit, buffIndex)
|
||||||
|
local name, effect = nil, nil
|
||||||
|
local textObj = getglobal("DopingControlScanTipTextLeft1")
|
||||||
|
if textObj then
|
||||||
|
name = textObj:GetText()
|
||||||
|
end
|
||||||
|
local effObj = getglobal("DopingControlScanTipTextLeft2")
|
||||||
|
if effObj then
|
||||||
|
effect = effObj:GetText()
|
||||||
|
end
|
||||||
|
return name, effect
|
||||||
|
end
|
||||||
|
|
||||||
|
-- debuff twin of tipBuffName (same hidden tooltip, SetUnitDebuff);
|
||||||
|
-- raises an error on bad units -> always called through pcall
|
||||||
|
local function tipDebuffName(unit, debuffIndex)
|
||||||
|
scanTip:ClearLines()
|
||||||
|
scanTip:SetUnitDebuff(unit, debuffIndex)
|
||||||
|
local textObj = getglobal("DopingControlScanTipTextLeft1")
|
||||||
|
if textObj then
|
||||||
|
return textObj:GetText()
|
||||||
|
end
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
|
||||||
|
-- ReadUnit(unit, guid) -> rawA, rawB, rawDA, rawDB, idNames
|
||||||
|
-- rawA/rawB : buff shapes as taken by A.Assemble
|
||||||
|
-- rawDA/rawDB: debuff shapes as taken by A.AssembleDebuffs (rawDA from
|
||||||
|
-- the UnitDebuff 1..16 loop, rawDB = debuff half slots
|
||||||
|
-- 33-48 of the SAME GetUnitField fetch as rawB)
|
||||||
|
-- idNames : { [spellId] = "Buff Name" } for the ids in rawB, resolved
|
||||||
|
-- via SuperWoW SpellInfo (same lookup A.Assemble already
|
||||||
|
-- uses for icons); taken by A.Assemble's optional 3rd
|
||||||
|
-- parameter. Extra return value -- existing callers that
|
||||||
|
-- only capture rawA..rawDB are unaffected.
|
||||||
|
function A.ReadUnit(unit, guid)
|
||||||
|
local rawA = {}
|
||||||
|
local rawDA = {}
|
||||||
|
if UnitBuff then
|
||||||
|
local tip = ensureTip()
|
||||||
|
for i = 1, 32 do
|
||||||
|
-- 4th return = SuperWoW spell id (unverified on other builds; probe Q1)
|
||||||
|
local ok, texture, stacks, dtype, auraID = pcall(UnitBuff, unit, i)
|
||||||
|
if not ok or not texture then
|
||||||
|
break
|
||||||
|
end
|
||||||
|
local name, effect = nil, nil
|
||||||
|
if tip then
|
||||||
|
local ok2, nm, eff = pcall(tipBuffName, unit, i)
|
||||||
|
if ok2 then
|
||||||
|
name = nm
|
||||||
|
effect = eff
|
||||||
|
end
|
||||||
|
end
|
||||||
|
local id = nil
|
||||||
|
if type(auraID) == "number" and auraID > 0 then
|
||||||
|
id = auraID
|
||||||
|
end
|
||||||
|
-- texture (UnitBuff return 1) and the tooltip effect line ride
|
||||||
|
-- along so Assemble can key them under the tooltip name
|
||||||
|
-- (auras.textures / auras.effects)
|
||||||
|
table.insert(rawA, { name = name, id = id, texture = texture,
|
||||||
|
effect = effect })
|
||||||
|
end
|
||||||
|
end
|
||||||
|
if UnitDebuff then
|
||||||
|
-- mirror of the buff loop: 16 debuff slots on 1.12, list is
|
||||||
|
-- compacted (stop at the first nil texture), name via the same
|
||||||
|
-- hidden tooltip, spell id via 4th return if present
|
||||||
|
local tip = ensureTip()
|
||||||
|
for i = 1, 16 do
|
||||||
|
local ok, texture, stacks, dtype, auraID = pcall(UnitDebuff, unit, i)
|
||||||
|
if not ok or not texture then
|
||||||
|
break
|
||||||
|
end
|
||||||
|
local name = nil
|
||||||
|
if tip then
|
||||||
|
local ok2, nm = pcall(tipDebuffName, unit, i)
|
||||||
|
if ok2 then
|
||||||
|
name = nm
|
||||||
|
end
|
||||||
|
end
|
||||||
|
local id = nil
|
||||||
|
if type(auraID) == "number" and auraID > 0 then
|
||||||
|
id = auraID
|
||||||
|
end
|
||||||
|
table.insert(rawDA, { name = name, id = id, texture = texture })
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
local rawB = {}
|
||||||
|
local rawDB = {}
|
||||||
|
if GetUnitField and guid then
|
||||||
|
local ok, t = pcall(GetUnitField, guid, "aura")
|
||||||
|
if ok and type(t) == "table" then
|
||||||
|
-- ONE fetch carries both halves (sparse 48-slot table):
|
||||||
|
-- buffs 1-32, debuffs 33-48 -- split without extra API calls
|
||||||
|
rawB, rawDB = A.SplitAuraField(t)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
-- name resolution for path-B ids (SuperWoW SpellInfo lookup, same
|
||||||
|
-- pattern/guarding as the icon resolution in A.Assemble): path-B ids
|
||||||
|
-- never carry a name of their own, so every id here is a candidate.
|
||||||
|
-- Runtime-only, pcall-wrapped, nil-safe; stays empty offline/without
|
||||||
|
-- SuperWoW, which leaves A.Assemble's unknown-aura reporting exactly
|
||||||
|
-- as before ("spell:<id>").
|
||||||
|
local idNames = {}
|
||||||
|
if SpellInfo then
|
||||||
|
local nB = table.getn(rawB)
|
||||||
|
for i = 1, nB do
|
||||||
|
local id = rawB[i]
|
||||||
|
if type(id) == "number" and id > 0 then
|
||||||
|
local okSI, nm = pcall(SpellInfo, id)
|
||||||
|
if okSI and nm then
|
||||||
|
idNames[id] = nm
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
return rawA, rawB, rawDA, rawDB, idNames
|
||||||
|
end
|
||||||
@@ -0,0 +1,222 @@
|
|||||||
|
-- DopingControl scan/gear.lua
|
||||||
|
-- Equipment + enchant reading for one unit: GetInventoryItemLink for the 16
|
||||||
|
-- equipment-tab slots (DC.SLOTS_EQUIPMENT), enchant id parsed from the item
|
||||||
|
-- link ("item:ID:ENCHANT:..."), item details via GetItemInfo with the
|
||||||
|
-- MEASURED Turtle 1.18.1 return order
|
||||||
|
-- : 1 name, 2 link, 3 QUALITY, 4 reqLevel, 5 itemType,
|
||||||
|
-- 6 ITEMSUBTYPE, 7 maxStack, 8 equipSlot, 9 ICONPATH, 10 vendorPrice.
|
||||||
|
-- Reading position 4 as itemLevel or 10 as texture breaks SILENTLY with
|
||||||
|
-- plausible-looking values -- do not "fix" this back to the vanilla order.
|
||||||
|
--
|
||||||
|
-- Readability rule (binding):
|
||||||
|
-- ANY slot returned a link => gearRead = true; slots with nil => "EMPTY"
|
||||||
|
-- ALL 13 slots returned nil => gearRead = false (failed read, NOT naked)
|
||||||
|
-- A link that came back but does not parse leaves its slot nil while
|
||||||
|
-- gearRead stays true -- core/model.lua shows that cell as UNKNOWN
|
||||||
|
-- ("no data"), never as MISSING (resolution: a parse anomaly must not be
|
||||||
|
-- claimed as "no item").
|
||||||
|
--
|
||||||
|
-- Pure Lua 5.0, dofile-loadable offline: ParseLink / ParseLinkName /
|
||||||
|
-- ParseWeaponEnchantLine / Assemble are pure (offline-tested);
|
||||||
|
-- ReadUnit, ItemInfo and ReadSelfWeaponEnchantName
|
||||||
|
-- touch the WoW API and run in-game only, pcall-wrapped.
|
||||||
|
|
||||||
|
DopingControl = DopingControl or {}
|
||||||
|
local DC = DopingControl
|
||||||
|
|
||||||
|
DC_Gear = DC_Gear or {}
|
||||||
|
local G = DC_Gear
|
||||||
|
|
||||||
|
-- Fallback inventory-slot list (= invSlot column of DC.SLOTS_EQUIPMENT,
|
||||||
|
-- data/enchants.lua) so this file works standalone in tests.
|
||||||
|
local FALLBACK_INV_SLOTS = { 1, 2, 3, 15, 5, 6, 9, 10, 7, 8, 16, 17, 18, 11, 12, 13, 14 }
|
||||||
|
|
||||||
|
-- InvSlots() -> array of the 17 inventory slot numbers, in column order.
|
||||||
|
-- Prefers DC.SLOTS_EQUIPMENT (single source of truth once data/ is loaded).
|
||||||
|
function G.InvSlots()
|
||||||
|
if DC.SLOTS_EQUIPMENT then
|
||||||
|
local out = {}
|
||||||
|
local n = table.getn(DC.SLOTS_EQUIPMENT)
|
||||||
|
for i = 1, n do
|
||||||
|
table.insert(out, DC.SLOTS_EQUIPMENT[i].invSlot)
|
||||||
|
end
|
||||||
|
return out
|
||||||
|
end
|
||||||
|
return FALLBACK_INV_SLOTS
|
||||||
|
end
|
||||||
|
|
||||||
|
-- ==================================================================
|
||||||
|
-- Pure part
|
||||||
|
-- ==================================================================
|
||||||
|
|
||||||
|
-- ParseLink(link) -> itemId, enchantId (numbers) | nil, nil on malformed.
|
||||||
|
-- Enchant id is the 2nd field of the link payload (standard 1.12 link
|
||||||
|
-- pattern, widened to also capture the item id).
|
||||||
|
function G.ParseLink(link)
|
||||||
|
if type(link) ~= "string" then
|
||||||
|
return nil, nil
|
||||||
|
end
|
||||||
|
local _, _, idStr, enchStr = string.find(link, "item:(%d+):(%d+)")
|
||||||
|
if not idStr then
|
||||||
|
return nil, nil
|
||||||
|
end
|
||||||
|
return tonumber(idStr), tonumber(enchStr)
|
||||||
|
end
|
||||||
|
|
||||||
|
-- ParseLinkName(link) -> "[bracket] name" | nil (fallback when GetItemInfo
|
||||||
|
-- has no cached data yet; standard 1.12 pattern).
|
||||||
|
function G.ParseLinkName(link)
|
||||||
|
if type(link) ~= "string" then
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
local _, _, name = string.find(link, "%[(.+)%]")
|
||||||
|
return name
|
||||||
|
end
|
||||||
|
|
||||||
|
-- ParseWeaponEnchantLine(text) -> name|nil (pure).
|
||||||
|
-- The main-hand tooltip's temporary-enchant line reads "<Name> (N min)" on
|
||||||
|
-- the English client (e.g. "Dense Sharpening Stone (28 min)") -- strip the
|
||||||
|
-- "(N min)" part, keep the name. Any other line (item name, "Speed 2.70",
|
||||||
|
-- "+9 Damage", a bare "(28 min)") and any non-string input -> nil.
|
||||||
|
function G.ParseWeaponEnchantLine(text)
|
||||||
|
if type(text) ~= "string" then
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
local _, _, name = string.find(text, "^(.-)%s*%((%d+) min%)%s*$")
|
||||||
|
if name and name ~= "" then
|
||||||
|
return name
|
||||||
|
end
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Assemble(rawLinks, slots, itemInfo) -> gear, gearRead
|
||||||
|
-- rawLinks : { [invSlot] = link string | nil }
|
||||||
|
-- slots : array of invSlot numbers (default G.InvSlots())
|
||||||
|
-- itemInfo : nil, or function(itemId) -> name, quality, subType, texture
|
||||||
|
-- (already de-shifted; in-game wrapper is G.ItemInfo, tests
|
||||||
|
-- pass a stub)
|
||||||
|
-- Returns the store shape:
|
||||||
|
-- gearRead = false => gear = nil
|
||||||
|
-- gearRead = true => gear[invSlot] = { id, enchant, quality, subType,
|
||||||
|
-- texture, name } | "EMPTY" (no link) | nil
|
||||||
|
-- (link present but unparseable -> UNKNOWN cell)
|
||||||
|
-- The subType field feeds DC.ENCH_EXEMPT in core/model.lua (wand/holdable
|
||||||
|
-- exemptions); the model explicitly expects it.
|
||||||
|
function G.Assemble(rawLinks, slots, itemInfo)
|
||||||
|
rawLinks = rawLinks or {}
|
||||||
|
slots = slots or G.InvSlots()
|
||||||
|
local n = table.getn(slots)
|
||||||
|
|
||||||
|
local gear = {}
|
||||||
|
local any = false
|
||||||
|
for i = 1, n do
|
||||||
|
local inv = slots[i]
|
||||||
|
local link = rawLinks[inv]
|
||||||
|
if link ~= nil then
|
||||||
|
any = true
|
||||||
|
local id, ench = G.ParseLink(link)
|
||||||
|
if id then
|
||||||
|
local entry = { id = id, enchant = ench }
|
||||||
|
if itemInfo then
|
||||||
|
local name, quality, subType, texture = itemInfo(id)
|
||||||
|
entry.name = name
|
||||||
|
entry.quality = quality
|
||||||
|
entry.subType = subType
|
||||||
|
entry.texture = texture
|
||||||
|
end
|
||||||
|
if not entry.name then
|
||||||
|
entry.name = G.ParseLinkName(link)
|
||||||
|
end
|
||||||
|
gear[inv] = entry
|
||||||
|
end
|
||||||
|
-- unparseable link: slot stays nil (UNKNOWN), see header
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
if not any then
|
||||||
|
return nil, false
|
||||||
|
end
|
||||||
|
for i = 1, n do
|
||||||
|
local inv = slots[i]
|
||||||
|
if rawLinks[inv] == nil then
|
||||||
|
gear[inv] = "EMPTY"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
return gear, true
|
||||||
|
end
|
||||||
|
|
||||||
|
-- ==================================================================
|
||||||
|
-- WoW part (runtime only; every foreign-unit call pcall-wrapped)
|
||||||
|
-- ==================================================================
|
||||||
|
|
||||||
|
-- ItemInfo(itemId) -> name, quality, subType, texture | nil (uncached/err).
|
||||||
|
-- De-shifts the Turtle 1.18.1 GetItemInfo order (see header).
|
||||||
|
function G.ItemInfo(itemId)
|
||||||
|
if not GetItemInfo then
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
local ok, name, link, quality, reqLevel, itemType, subType,
|
||||||
|
maxStack, equipSlot, iconPath = pcall(GetItemInfo, itemId)
|
||||||
|
if not ok or not name then
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
return name, quality, subType, iconPath
|
||||||
|
end
|
||||||
|
|
||||||
|
-- ReadSelfWeaponEnchantName() -> name|nil (WoW runtime only).
|
||||||
|
-- Self path for store.weaponMainName: GetWeaponEnchantInfo() carries
|
||||||
|
-- no NAME for the own unit, but the equipped main hand's tooltip lists the
|
||||||
|
-- temp enchant as its own "<Name> (N min)" line (English client). Scans
|
||||||
|
-- the shared hidden tooltip (scan/aura.lua accessor -- never a second
|
||||||
|
-- frame under the same global name) over SetInventoryItem("player", 16)
|
||||||
|
-- and returns the first line that parses; nil-safe when no such line.
|
||||||
|
function G.ReadSelfWeaponEnchantName()
|
||||||
|
local tip = DC_Aura and DC_Aura.GetScanTip and DC_Aura.GetScanTip()
|
||||||
|
if not tip then
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
local ok = pcall(function()
|
||||||
|
tip:ClearLines()
|
||||||
|
tip:SetInventoryItem("player", 16)
|
||||||
|
end)
|
||||||
|
if not ok then
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
local n = 30
|
||||||
|
if tip.NumLines then
|
||||||
|
local ok2, v = pcall(tip.NumLines, tip)
|
||||||
|
if ok2 and type(v) == "number" then
|
||||||
|
n = v
|
||||||
|
end
|
||||||
|
end
|
||||||
|
for i = 1, n do
|
||||||
|
local obj = getglobal("DopingControlScanTipTextLeft" .. i)
|
||||||
|
if obj then
|
||||||
|
local name = G.ParseWeaponEnchantLine(obj:GetText())
|
||||||
|
if name then
|
||||||
|
return name
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
|
||||||
|
-- ReadUnit(unit) -> rawLinks table for G.Assemble.
|
||||||
|
-- Vanilla serves GetInventoryItemLink only for the own/inspected unit;
|
||||||
|
-- SuperWoW serves it for all friendly units (unverified beyond sight,
|
||||||
|
-- probe questions 4/5) -- either way nil slots are handled by the
|
||||||
|
-- EMPTY-vs-gearRead rule above, so no capability branching here.
|
||||||
|
function G.ReadUnit(unit)
|
||||||
|
local raw = {}
|
||||||
|
if GetInventoryItemLink then
|
||||||
|
local slots = G.InvSlots()
|
||||||
|
local n = table.getn(slots)
|
||||||
|
for i = 1, n do
|
||||||
|
local ok, link = pcall(GetInventoryItemLink, unit, slots[i])
|
||||||
|
if ok and link then
|
||||||
|
raw[slots[i]] = link
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
return raw
|
||||||
|
end
|
||||||
@@ -0,0 +1,212 @@
|
|||||||
|
-- DopingControl scan/pull.lua
|
||||||
|
-- Silent snapshot on a pull timer: BigWigs' Pulltimer plugin broadcasts a
|
||||||
|
-- CHAT_MSG_ADDON every time ANYONE in the raid/party starts (or rebroadcasts)
|
||||||
|
-- a pull timer -- one more moment where "who has their flask up" matters,
|
||||||
|
-- and one the addon currently misses entirely (only READY_CHECK triggers an
|
||||||
|
-- automatic snapshot -- scan/engine.lua's header -- and some raids' ready
|
||||||
|
-- checks route through a DIFFERENT addon's own protocol instead: prefix
|
||||||
|
-- "TW_RF", payload "<Name> Ready", never raising the native READY_CHECK
|
||||||
|
-- event at all (seen in a live capture). A whole Molten
|
||||||
|
-- Core night went unsnapshotted this way on 24 Sep 2026.
|
||||||
|
--
|
||||||
|
-- Wire format (source-confirmed against the installed BigWigs addon):
|
||||||
|
-- BigWigsComm:BigWigs_SendSync sends
|
||||||
|
-- SendAddonMessage("BigWigs", "<syncName> <rest>", "RAID")
|
||||||
|
-- (Plugins/Comm.lua:57-67) and receives the identical shape back on
|
||||||
|
-- CHAT_MSG_ADDON with prefix == "BigWigs" (Plugins/Comm.lua:43-54:
|
||||||
|
-- local _, _, sync, rest = string.find(message, "(%S+)%s*(.*)$")
|
||||||
|
-- -- the exact split used below). The pull-timer sync names
|
||||||
|
-- (Plugins/Pulltimer.lua:27-32):
|
||||||
|
-- "PulltimerSync" -- a (possibly soft/relayed) timer start,
|
||||||
|
-- rest = duration in seconds
|
||||||
|
-- "PulltimerBroadcastSync" -- an explicit rebroadcast, same rest shape
|
||||||
|
-- "PulltimerStopSync" -- pull aborted -- no duration, never a start
|
||||||
|
-- "PulltimerReqSync" -- "is a timer running" -- no duration
|
||||||
|
-- DoPull only ever Syncs a POSITIVE duration (Plugins/Pulltimer.lua:203-
|
||||||
|
-- 223: an explicit 0 either stops an existing bar or resolves to 6 before
|
||||||
|
-- sending), and BigWigs_Pulltimer itself discards anything `tonumber`
|
||||||
|
-- rejects (Plugins/Pulltimer.lua:256-260) -- so "sync name matches AND
|
||||||
|
-- rest is a positive number" is both necessary and sufficient for "a pull
|
||||||
|
-- just started".
|
||||||
|
-- No pfUI pull timer exists on this client (checked: no "pull" or
|
||||||
|
-- "Pulltimer" hit anywhere under Interface/AddOns/pfUI) -- BigWigs is the
|
||||||
|
-- only pull timer installed, so it is the only wire this file speaks.
|
||||||
|
-- Live confirmation the "BigWigs"/RAID channel is real traffic on this
|
||||||
|
-- server, not merely installed-but-idle code: captured chat addon
|
||||||
|
-- messages carry the same "<sync> <rest>" shape (a ward-fired notice).
|
||||||
|
-- No PulltimerSync sample was captured, so the exact in-game duration
|
||||||
|
-- argument is confirmed from source, not from a captured packet.
|
||||||
|
--
|
||||||
|
-- Self-pull: does the SENDER's own client receive its own CHAT_MSG_ADDON on
|
||||||
|
-- this fork? Unverified in-game either way -- but BigWigs' own code does not
|
||||||
|
-- trust it: BigWigs_SendSync calls SendAddonMessage AND ALSO invokes its own
|
||||||
|
-- CHAT_MSG_ADDON handler directly, passing the sender's own name, right
|
||||||
|
-- after (Plugins/Comm.lua:64-65). That is strong circumstantial evidence the
|
||||||
|
-- authors did not want a raid leader's own /pull to depend on the echo. This
|
||||||
|
-- file does the analogous thing: it hooks the global SendAddonMessage
|
||||||
|
-- (guarded, pcall-wrapped, every return value passed through unchanged) and
|
||||||
|
-- feeds prefix=="BigWigs" traffic through the SAME parser the CHAT_MSG_ADDON
|
||||||
|
-- handler uses. If the echo DOES fire on this client, the debounce below
|
||||||
|
-- absorbs the harmless double trigger; if it does not, the hook is the only
|
||||||
|
-- path a self-started pull has.
|
||||||
|
--
|
||||||
|
-- The scan itself rides the SAME entry point as the ready-check path
|
||||||
|
-- (DC_Scan.Start, scan/engine.lua) and is silent BY CONSTRUCTION: E.Start
|
||||||
|
-- never shows any UI frame -- the ready-check path's own auto-OPEN is a
|
||||||
|
-- SEPARATE decision made by core/init.lua's READY_CHECK handler
|
||||||
|
-- (db.readyCheckOpen -> DC_Matrix.Show()), which this file never touches or
|
||||||
|
-- even requires loaded. The resulting store gets a `trigger = "pull"` tag
|
||||||
|
-- (E.NewStore, scan/engine.lua) purely so dev/raiddump.lua's S line and the
|
||||||
|
-- store itself say which scans came from a pull timer; store.source stays
|
||||||
|
-- "scan" either way -- every real gate keeps reading THAT field.
|
||||||
|
--
|
||||||
|
-- Pure Lua 5.0, dofile-loadable offline: P.ParsePull/P.ShouldTrigger are
|
||||||
|
-- pure; the frame, the event and the SendAddonMessage hook sit behind
|
||||||
|
-- `if CreateFrame then`, the same convention as every other scan/*.lua file.
|
||||||
|
|
||||||
|
DopingControl = DopingControl or {}
|
||||||
|
local DC = DopingControl
|
||||||
|
|
||||||
|
DC_Pull = DC_Pull or {}
|
||||||
|
local P = DC_Pull
|
||||||
|
|
||||||
|
-- at most one pull-triggered scan every this many seconds
|
||||||
|
P.DEBOUNCE = 15
|
||||||
|
|
||||||
|
-- ==================================================================
|
||||||
|
-- Pure part
|
||||||
|
-- ==================================================================
|
||||||
|
|
||||||
|
-- ParsePull(prefix, msg) -> duration (number > 0) | nil
|
||||||
|
-- Recognizes a pull-timer START message on the BigWigs wire (see header).
|
||||||
|
-- Everything else (wrong prefix, a stop/req sync, a non-numeric or
|
||||||
|
-- non-positive rest) returns nil -- including a message from some OTHER
|
||||||
|
-- addon that happens to share the "BigWigs" prefix by accident, which would
|
||||||
|
-- simply fail the sync-name check.
|
||||||
|
function P.ParsePull(prefix, msg)
|
||||||
|
if prefix ~= "BigWigs" or type(msg) ~= "string" then
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
local _, _, sync, rest = string.find(msg, "(%S+)%s*(.*)$")
|
||||||
|
if sync ~= "PulltimerSync" and sync ~= "PulltimerBroadcastSync" then
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
local dur = tonumber(rest)
|
||||||
|
if dur and dur > 0 then
|
||||||
|
return dur
|
||||||
|
end
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
|
||||||
|
-- ShouldTrigger(now, lastAt, opts) -> bool
|
||||||
|
-- opts = { enabled, inGroup, simOwnsStore, scanRunning }
|
||||||
|
-- Every gate named in the brief, in one pure, orthogonal predicate: the
|
||||||
|
-- setting, group membership, the sim-store lock and the debounce window.
|
||||||
|
-- `scanRunning` is a SEPARATE gate from the debounce window -- a long scan
|
||||||
|
-- (a large raid, batched 5 units/frame in scan/engine.lua) can still be
|
||||||
|
-- running well past 15s, and a second pull during it must not restart it:
|
||||||
|
-- unlike a manual/ready-check Start(true), a pull-triggered Start is never
|
||||||
|
-- `force`, so DC_Scan.Start would already no-op on its own -- this gate just
|
||||||
|
-- avoids bumping `lastAt` (and thus the debounce window) for a call that was
|
||||||
|
-- never going to do anything.
|
||||||
|
function P.ShouldTrigger(now, lastAt, opts)
|
||||||
|
opts = opts or {}
|
||||||
|
if not opts.enabled then
|
||||||
|
return false
|
||||||
|
end
|
||||||
|
if opts.simOwnsStore then
|
||||||
|
return false
|
||||||
|
end
|
||||||
|
if not opts.inGroup then
|
||||||
|
return false
|
||||||
|
end
|
||||||
|
if opts.scanRunning then
|
||||||
|
return false
|
||||||
|
end
|
||||||
|
if lastAt and (now - lastAt) < P.DEBOUNCE then
|
||||||
|
return false
|
||||||
|
end
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
|
||||||
|
-- ==================================================================
|
||||||
|
-- WoW part
|
||||||
|
-- ==================================================================
|
||||||
|
if CreateFrame then
|
||||||
|
|
||||||
|
local lastAt = nil
|
||||||
|
|
||||||
|
-- Raid count first, party count only when it is 0 -- same convention as
|
||||||
|
-- core/init.lua's (private) GroupSize(): both read the same two API
|
||||||
|
-- calls and would drift identically if either ever changed shape.
|
||||||
|
local function inGroup()
|
||||||
|
local n = GetNumRaidMembers and GetNumRaidMembers() or 0
|
||||||
|
if n and n > 0 then
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
n = GetNumPartyMembers and GetNumPartyMembers() or 0
|
||||||
|
return (n or 0) > 0
|
||||||
|
end
|
||||||
|
|
||||||
|
local function pullEnabled(db)
|
||||||
|
-- default ON, like every other auto-scan switch in this file family
|
||||||
|
-- (db.readyCheckScan, core/config.lua) -- NEVER `db.pullTimerScan`
|
||||||
|
-- alone (would misread a saved `false` as truthy-missing).
|
||||||
|
return not (db and db.pullTimerScan == false)
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Shared by the CHAT_MSG_ADDON handler and the SendAddonMessage hook
|
||||||
|
-- below -- one decision path, fed from two entry points (see header).
|
||||||
|
local function maybeTrigger(prefix, msg)
|
||||||
|
local dur = P.ParsePull(prefix, msg)
|
||||||
|
if not dur then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
local db = DopingControlDB
|
||||||
|
local now = GetTime and GetTime() or 0
|
||||||
|
local scanRunning = (DC_Scan and DC_Scan.IsRunning
|
||||||
|
and DC_Scan.IsRunning()) or false
|
||||||
|
local go = P.ShouldTrigger(now, lastAt, {
|
||||||
|
enabled = pullEnabled(db),
|
||||||
|
inGroup = inGroup(),
|
||||||
|
simOwnsStore = DC.SimOwnsStore(db),
|
||||||
|
scanRunning = scanRunning,
|
||||||
|
})
|
||||||
|
if not go then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
lastAt = now
|
||||||
|
if DC_Scan and DC_Scan.Start then
|
||||||
|
DC_Scan.Start(nil, "pull")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
local f = CreateFrame("Frame", "DopingControlPullFrame")
|
||||||
|
f:RegisterEvent("CHAT_MSG_ADDON")
|
||||||
|
f:SetScript("OnEvent", function()
|
||||||
|
-- 1.12: arg1 prefix, arg2 message, arg3 channel, arg4 sender -- the
|
||||||
|
-- channel/sender are not needed here, unlike scan/talents.lua: ANY
|
||||||
|
-- raid/party member's pull (including our own, see below) is a
|
||||||
|
-- trigger, so there is nothing left to filter on.
|
||||||
|
maybeTrigger(arg1, arg2)
|
||||||
|
end)
|
||||||
|
|
||||||
|
-- Self-pull backstop -- see the file header. Installed once; pcall-
|
||||||
|
-- wrapped so a malformed call from ANY addon can never take the real
|
||||||
|
-- SendAddonMessage down with it, and every return value is passed
|
||||||
|
-- through unchanged so no other addon (BigWigs included) can tell it
|
||||||
|
-- was wrapped.
|
||||||
|
if SendAddonMessage and not P.hooked then
|
||||||
|
P.hooked = true
|
||||||
|
local orig = SendAddonMessage
|
||||||
|
P.origSendAddonMessage = orig -- exposed for tests
|
||||||
|
SendAddonMessage = function(prefix, msg, chatType, target)
|
||||||
|
-- our own logic is guarded so it can never break the caller's
|
||||||
|
-- send; the original runs unguarded so its errors still reach
|
||||||
|
-- the calling addon exactly as before the hook
|
||||||
|
pcall(maybeTrigger, prefix, msg)
|
||||||
|
return orig(prefix, msg, chatType, target)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
end
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
-- DopingControl scan/reach.lua
|
||||||
|
-- Unit readability probe: online FALLBACK via UnitIsConnected, distance via
|
||||||
|
-- UnitXP_SP3 "distanceBetween". Distance is DIAGNOSTIC ONLY -- it is written
|
||||||
|
-- into the store but NEVER used as a filter (readability is
|
||||||
|
-- decided from the read RESULT, not from a range assumption).
|
||||||
|
--
|
||||||
|
-- ONLINE IS A FALLBACK ONLY (measured
|
||||||
|
-- in-game): the raid roster online flag (GetRaidRosterInfo return 8) is
|
||||||
|
-- SERVER TRUTH and wins in scan/engine.lua scanOne -- UnitIsConnected can
|
||||||
|
-- read nil mid-zoning while the player is actually online (false-offline
|
||||||
|
-- bug). The reach online result is only consulted for roster entries
|
||||||
|
-- WITHOUT a roster flag (the party path).
|
||||||
|
--
|
||||||
|
-- Pure Lua 5.0, dofile-loadable offline: the WoW calls live in ReadUnit,
|
||||||
|
-- which is only invoked in-game; Normalize is pure and offline-tested.
|
||||||
|
|
||||||
|
DopingControl = DopingControl or {}
|
||||||
|
local DC = DopingControl
|
||||||
|
|
||||||
|
DC_Reach = DC_Reach or {}
|
||||||
|
local R = DC_Reach
|
||||||
|
|
||||||
|
-- ------------------------------------------------------------------
|
||||||
|
-- Normalize(haveOnline, onlineRaw, distOk, distVal) -> online, distance
|
||||||
|
-- haveOnline : bool -- UnitIsConnected existed AND its pcall succeeded.
|
||||||
|
-- UnitIsConnected returns 1/nil, where nil is read
|
||||||
|
-- as offline -- BUT it can also read nil mid-zoning
|
||||||
|
-- while the player is online (measured in-game),
|
||||||
|
-- which is why the engine only uses this value as a
|
||||||
|
-- fallback when there is no roster online flag.
|
||||||
|
-- onlineRaw : raw UnitIsConnected return (1/nil/false)
|
||||||
|
-- distOk : pcall success of UnitXP("distanceBetween", ...)
|
||||||
|
-- distVal : its first return
|
||||||
|
-- Returns:
|
||||||
|
-- online : true|false|nil -- nil = could not determine; the caller
|
||||||
|
-- (engine scanOne) consults this at all
|
||||||
|
-- only for entries without a roster flag
|
||||||
|
-- distance : number|nil -- nil = not measurable (no UnitXP_SP3, or
|
||||||
|
-- the call failed / returned a non-number)
|
||||||
|
-- ------------------------------------------------------------------
|
||||||
|
function R.Normalize(haveOnline, onlineRaw, distOk, distVal)
|
||||||
|
local online = nil
|
||||||
|
if haveOnline then
|
||||||
|
if onlineRaw then
|
||||||
|
online = true
|
||||||
|
else
|
||||||
|
online = false
|
||||||
|
end
|
||||||
|
end
|
||||||
|
local distance = nil
|
||||||
|
if distOk and type(distVal) == "number" then
|
||||||
|
distance = distVal
|
||||||
|
end
|
||||||
|
return online, distance
|
||||||
|
end
|
||||||
|
|
||||||
|
-- ------------------------------------------------------------------
|
||||||
|
-- ReadUnit(unit) -> online, distance (WoW runtime only)
|
||||||
|
-- Every foreign-unit call is pcall-wrapped (hard rule).
|
||||||
|
-- Missing UnitXP_SP3 only drops the diagnostic distance
|
||||||
|
-- -- the online read runs regardless.
|
||||||
|
-- ------------------------------------------------------------------
|
||||||
|
function R.ReadUnit(unit)
|
||||||
|
local haveOnline = false
|
||||||
|
local onlineRaw = nil
|
||||||
|
if UnitIsConnected then
|
||||||
|
local ok, v = pcall(UnitIsConnected, unit)
|
||||||
|
if ok then
|
||||||
|
haveOnline = true
|
||||||
|
onlineRaw = v
|
||||||
|
end
|
||||||
|
end
|
||||||
|
local distOk = false
|
||||||
|
local distVal = nil
|
||||||
|
if UnitXP then
|
||||||
|
distOk, distVal = pcall(UnitXP, "distanceBetween", "player", unit)
|
||||||
|
end
|
||||||
|
return R.Normalize(haveOnline, onlineRaw, distOk, distVal)
|
||||||
|
end
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
-- DopingControl scan/resist.lua
|
||||||
|
-- Pure resistance-reconstruction math: turn item TOOLTIP lines into a
|
||||||
|
-- per-school sum, and look up a race's innate bonus. Mirrors scan/hit.lua's
|
||||||
|
-- pure half (ParseHitLine/SumGear) one level down -- the constants live in
|
||||||
|
-- data/resist.lua, this file only turns them into behavior.
|
||||||
|
--
|
||||||
|
-- NO WoW-glue function lives here on purpose: unlike scan/hit.lua/scan/
|
||||||
|
-- gear.lua/scan/aura.lua, this module never calls SetInventoryItem itself.
|
||||||
|
-- The RESIST tab's numbers are reconstructed from tooltip lines
|
||||||
|
-- scan/hit.lua's item scan ALREADY fetched (see that file's itemHit()) --
|
||||||
|
-- one more table walk over data already in hand, not a second tooltip
|
||||||
|
-- operation per item. That is the entire point of hanging resistance
|
||||||
|
-- reconstruction off the HIT tab's existing per-item cache instead of
|
||||||
|
-- building an independent RESIST scan pass: 26 players x 19 slots would be
|
||||||
|
-- ~500 extra SetInventoryItem calls per scan, and scan/hit.lua already pays
|
||||||
|
-- that cost once per unique item.
|
||||||
|
--
|
||||||
|
-- Pure Lua 5.0, no WoW API, dofile-loadable offline: everything here is
|
||||||
|
-- offline-tested (test_resist.lua).
|
||||||
|
|
||||||
|
DopingControl = DopingControl or {}
|
||||||
|
local DC = DopingControl
|
||||||
|
|
||||||
|
DC_Resist = DC_Resist or {}
|
||||||
|
local R = DC_Resist
|
||||||
|
|
||||||
|
-- ParseResistLine(text) -> kind, value | nil
|
||||||
|
-- kind : a DC.RESIST_SCHOOLS name ("Fire", "Nature", ...) or "all"
|
||||||
|
-- value : the flat resistance amount as a number
|
||||||
|
-- Returns nil (a single value) when no pattern matches -- an unrecognized
|
||||||
|
-- phrasing is ignored, never guessed at (same contract as
|
||||||
|
-- DC_Hit.ParseHitLine).
|
||||||
|
function R.ParseResistLine(text)
|
||||||
|
if type(text) ~= "string" then
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
local pats = DC.RESIST_PATTERNS
|
||||||
|
if type(pats) ~= "table" then
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
local n = table.getn(pats)
|
||||||
|
for i = 1, n do
|
||||||
|
local e = pats[i]
|
||||||
|
local _, _, cap = string.find(text, e.pattern)
|
||||||
|
if cap then
|
||||||
|
local v = tonumber(cap)
|
||||||
|
if v then
|
||||||
|
return e.kind, v
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
|
||||||
|
-- SumLines(lines) -> sum
|
||||||
|
-- lines : the FULL tooltip line array of ONE item, element 1 = item name
|
||||||
|
-- (same convention as scan/hit.lua's itemHit -- line 1 is never
|
||||||
|
-- a stat line, so the scan starts at 2; a caller that already
|
||||||
|
-- stripped the name may safely pass an array starting at the
|
||||||
|
-- real first stat line too, since no genuine item name matches
|
||||||
|
-- "^%+%d+ <School> Resistance" or "^Equip: %+%d+ All
|
||||||
|
-- Resistances" in the first place).
|
||||||
|
-- sum : { [UnitResistance school index] = total } -- ONLY schools
|
||||||
|
-- that actually appeared; a plain item with no resistance stat
|
||||||
|
-- returns {} (empty, not nil) -- an empty sum is a real "this
|
||||||
|
-- item carries no resistance", exactly like SumGear's zero.
|
||||||
|
-- The "All Resistances" trap this table exists to close: a naive parser
|
||||||
|
-- keyed only on the five single-school patterns would silently skip
|
||||||
|
-- "Equip: +8 All Resistances." and undercount every one of the five
|
||||||
|
-- columns by that amount. Handled here by fanning ONE matched line out
|
||||||
|
-- over every entry of DC.RESIST_SCHOOLS.
|
||||||
|
function R.SumLines(lines)
|
||||||
|
local sum = {}
|
||||||
|
lines = lines or {}
|
||||||
|
local n = table.getn(lines)
|
||||||
|
for i = 2, n do
|
||||||
|
local kind, v = R.ParseResistLine(lines[i])
|
||||||
|
if kind then
|
||||||
|
if kind == "all" then
|
||||||
|
local schools = DC.RESIST_SCHOOLS or {}
|
||||||
|
for s = 1, table.getn(schools) do
|
||||||
|
local school = schools[s].school
|
||||||
|
sum[school] = (sum[school] or 0) + v
|
||||||
|
end
|
||||||
|
else
|
||||||
|
local num = DC.RESIST_SCHOOL_NUM and DC.RESIST_SCHOOL_NUM[kind]
|
||||||
|
if num then
|
||||||
|
sum[num] = (sum[num] or 0) + v
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
return sum
|
||||||
|
end
|
||||||
|
|
||||||
|
-- RaceBonus(race) -> { [UnitResistance school index] = bonus }
|
||||||
|
-- ALWAYS returns a table (empty for "no bonus", an unrecognized race
|
||||||
|
-- token/display name, or nil input) so callers can fold it onto a gear sum
|
||||||
|
-- without a nil check. The returned table is a FRESH COPY, never the
|
||||||
|
-- shared DC.RACE_RESIST entry itself -- DC.RACE_RESIST aliases two
|
||||||
|
-- spellings of the same race to ONE table object, so handing that
|
||||||
|
-- reference out would let a caller's in-place addition (E.AssembleResists
|
||||||
|
-- folds this straight onto the gear sum) corrupt the shared constant for
|
||||||
|
-- every future lookup of that race.
|
||||||
|
function R.RaceBonus(race)
|
||||||
|
local out = {}
|
||||||
|
local tbl = race and DC.RACE_RESIST and DC.RACE_RESIST[race]
|
||||||
|
if tbl then
|
||||||
|
for school, v in pairs(tbl) do
|
||||||
|
out[school] = v
|
||||||
|
end
|
||||||
|
end
|
||||||
|
return out
|
||||||
|
end
|
||||||
@@ -0,0 +1,431 @@
|
|||||||
|
-- DopingControl scan/talents.lua
|
||||||
|
-- Reading OTHER players' talents.
|
||||||
|
--
|
||||||
|
-- Why this file exists: talents raise hit (Precision, Elemental Precision,
|
||||||
|
-- Shadow Focus, Suppression, ...), so a hit number that omits them is
|
||||||
|
-- systematically LOW. GetTalentInfo has no unit parameter and the server
|
||||||
|
-- never transmits foreign talents (no SMSG_INSPECT_TALENT in this fork,
|
||||||
|
-- no talent field in InitVisibleBits or SMSG_PARTY_MEMBER_STATS).
|
||||||
|
--
|
||||||
|
-- This server nonetheless ships its OWN inspect protocol, and it is a
|
||||||
|
-- plain addon-message conversation any addon may hold.
|
||||||
|
--
|
||||||
|
-- ask SendAddonMessage("TW_CHAT_MSG_WHISPER<Name>", "INSTalentShow",
|
||||||
|
-- "GUILD")
|
||||||
|
-- reply the TARGET's client runs GetTalentInfo on ITSELF and sends one
|
||||||
|
-- message per line:
|
||||||
|
-- INSTalentTabInfo;<tree>;<treeName>;<pointsSpent>;<numTalents>
|
||||||
|
-- INSTalentInfo;<tree>;<index>;<name>;<tier>;<column>;
|
||||||
|
-- <currRank>;<maxRank>;<meetsPrereq>;
|
||||||
|
-- <ptier>;<pcolumn>;<isLearnable>
|
||||||
|
-- INSTalentEND;
|
||||||
|
--
|
||||||
|
-- Cost, as observed: ONE outgoing message per player; the reply is a few
|
||||||
|
-- dozen messages, ending in INSTalentEND.
|
||||||
|
--
|
||||||
|
-- Three properties of the reply path decide the design here:
|
||||||
|
--
|
||||||
|
-- * The answering client checks NOTHING -- no CanInspect, no group,
|
||||||
|
-- guild, range or visibility test. Anyone whose NAME we know answers.
|
||||||
|
-- So the limit has to be ours: we only ask for players the scan
|
||||||
|
-- actually shows (ASK_GAP apart, each name at most REASK_AFTER), and
|
||||||
|
-- the whole sender can be switched off.
|
||||||
|
-- * We cannot ask for single talents. What gets sent is decided by the
|
||||||
|
-- target's client (Turtle's own code in patch-9.mpq), not by us -- it
|
||||||
|
-- dumps the whole tree. Our outgoing traffic is one message per
|
||||||
|
-- player; everything else is inbound and we drop the lines we do not
|
||||||
|
-- need.
|
||||||
|
-- * 1.12's wire format is PREFIX<TAB>MESSAGE, and the server rewrites
|
||||||
|
-- the "<Name>" tag out of the prefix on the way -- which leaves the
|
||||||
|
-- TAB at the HEAD OF THE PAYLOAD (measured: first bytes 9 73 78 83).
|
||||||
|
-- An anchored match on "INS..." therefore fails on every single line.
|
||||||
|
-- T.Normalize is not cosmetic; without it the whole reply is dropped.
|
||||||
|
--
|
||||||
|
-- The pure half (Normalize/Parse/Feed/Plausible/ShouldAsk) is offline
|
||||||
|
-- tested under real Lua 5.0; only the frame, the event and SendAddonMessage
|
||||||
|
-- are in-game.
|
||||||
|
|
||||||
|
DopingControl = DopingControl or {}
|
||||||
|
local DC = DopingControl
|
||||||
|
|
||||||
|
DC_Talents = DC_Talents or {}
|
||||||
|
local T = DC_Talents
|
||||||
|
|
||||||
|
-- addon-message prefix of the protocol (ours to send, theirs to answer on)
|
||||||
|
T.PREFIX = "TW_CHAT_MSG_WHISPER"
|
||||||
|
-- the channel argument is INERT once a "<Name>" tag is in the prefix (the
|
||||||
|
-- tag is the whisper substitute) -- kept exactly as Turtle's own UI sends
|
||||||
|
-- it, because a value the server does not expect is a needless risk
|
||||||
|
T.CHANNEL = "GUILD"
|
||||||
|
T.ASK_MSG = "INSTalentShow"
|
||||||
|
|
||||||
|
-- seconds between two outgoing requests (any target). Nothing documents a
|
||||||
|
-- rate limit for THIS protocol, so the number is deliberately timid: one
|
||||||
|
-- request per this many seconds is far below what Turtle's own UI produces
|
||||||
|
-- when a player clicks through an inspect window.
|
||||||
|
T.ASK_GAP = 5
|
||||||
|
-- do not ask the same player again for this long. Talents change on
|
||||||
|
-- respec, which is rare and never mid-raid.
|
||||||
|
T.REASK_AFTER = 1800
|
||||||
|
-- how long an unfinished reply stays open. A whole reply lands within a
|
||||||
|
-- fraction of a second, so a line arriving this much later cannot belong to
|
||||||
|
-- it -- it is the start of a NEW reply and must not be folded into the old
|
||||||
|
-- accumulator (see T.AccStale).
|
||||||
|
T.ACC_TTL = 10
|
||||||
|
|
||||||
|
-- ==================================================================
|
||||||
|
-- PURE SECTION (offline-testable)
|
||||||
|
-- ==================================================================
|
||||||
|
|
||||||
|
-- Strip the leading wire separator. See the header: the payload arrives as
|
||||||
|
-- <TAB>INSTalent... because the server rewrote the name tag out of the
|
||||||
|
-- prefix. Leading spaces/CR/LF are stripped too -- cheap, and it keeps the
|
||||||
|
-- parser from depending on which whitespace the transport happens to leave.
|
||||||
|
function T.Normalize(msg)
|
||||||
|
if type(msg) ~= "string" then
|
||||||
|
return ""
|
||||||
|
end
|
||||||
|
return (string.gsub(msg, "^[ \t\r\n]+", ""))
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Split on ";" -- Lua 5.0 has no string.split and no gmatch. Empty fields
|
||||||
|
-- are preserved (the protocol uses fixed positions, so a dropped empty
|
||||||
|
-- field would shift every later one).
|
||||||
|
local function split(s)
|
||||||
|
local out, from = {}, 1
|
||||||
|
while true do
|
||||||
|
local i = string.find(s, ";", from, true)
|
||||||
|
if not i then
|
||||||
|
table.insert(out, string.sub(s, from))
|
||||||
|
break
|
||||||
|
end
|
||||||
|
table.insert(out, string.sub(s, from, i - 1))
|
||||||
|
from = i + 1
|
||||||
|
end
|
||||||
|
return out
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Classify + decode one payload.
|
||||||
|
-- Returns "tab", { tree, treeName, spent, numTalents }
|
||||||
|
-- or "talent", { tree, index, name, tier, column, rank, maxRank }
|
||||||
|
-- or "end", nil
|
||||||
|
-- or nil (not ours -- every other addon's traffic lands here too)
|
||||||
|
--
|
||||||
|
-- A talent line with a MISSING or non-numeric rank is rejected rather than
|
||||||
|
-- read as 0: a silently zeroed rank looks exactly like an untrained talent
|
||||||
|
-- and would understate hit without any sign that something went wrong.
|
||||||
|
function T.Parse(msg)
|
||||||
|
msg = T.Normalize(msg)
|
||||||
|
if msg == "" then
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
if string.find(msg, "INSTalentEND", 1, true) == 1 then
|
||||||
|
return "end", nil
|
||||||
|
end
|
||||||
|
if string.find(msg, "INSTalentTabInfo;", 1, true) == 1 then
|
||||||
|
local f = split(msg)
|
||||||
|
local tree = tonumber(f[2])
|
||||||
|
local spent = tonumber(f[4])
|
||||||
|
if not tree or not spent then
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
return "tab", { tree = tree, treeName = f[3], spent = spent,
|
||||||
|
numTalents = tonumber(f[5]) }
|
||||||
|
end
|
||||||
|
if string.find(msg, "INSTalentInfo;", 1, true) == 1 then
|
||||||
|
local f = split(msg)
|
||||||
|
local tree = tonumber(f[2])
|
||||||
|
local name = f[4]
|
||||||
|
local rank = tonumber(f[7])
|
||||||
|
if not tree or type(name) ~= "string" or name == "" or not rank then
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
return "talent", { tree = tree, index = tonumber(f[3]), name = name,
|
||||||
|
tier = tonumber(f[5]), column = tonumber(f[6]),
|
||||||
|
rank = rank, maxRank = tonumber(f[8]) }
|
||||||
|
end
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
|
||||||
|
-- A per-sender accumulator. Shape:
|
||||||
|
-- { ranks = { [talentName] = rank }, -- rank > 0 only, like the own read
|
||||||
|
-- spent = <sum of pointsSpent over the trees that reported>,
|
||||||
|
-- sumRank = <sum of every rank we stored>,
|
||||||
|
-- trees = <how many tab lines arrived>,
|
||||||
|
-- complete = <INSTalentEND seen>,
|
||||||
|
-- at = <when the first line arrived, nil offline> }
|
||||||
|
function T.NewAcc(now)
|
||||||
|
return { ranks = {}, spent = 0, sumRank = 0, trees = 0, complete = false,
|
||||||
|
at = now }
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Has this accumulator been waiting so long that the next line must belong to
|
||||||
|
-- a different reply? Only the END marker drops an accumulator, so a reply
|
||||||
|
-- whose tail was lost would otherwise stay open forever and swallow the next
|
||||||
|
-- one: tab lines add up twice while ranks are deduplicated by name, so
|
||||||
|
-- T.Plausible rejects a perfectly good retry. Without a clock (offline) or an
|
||||||
|
-- opening stamp nothing expires -- the old behaviour, unchanged.
|
||||||
|
function T.AccStale(acc, now)
|
||||||
|
if not acc or not acc.at or not now then
|
||||||
|
return false
|
||||||
|
end
|
||||||
|
return (now - acc.at) > T.ACC_TTL
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Fold one parsed line into the accumulator. Returns the accumulator so
|
||||||
|
-- callers can chain; `complete` flips exactly once, on the END marker.
|
||||||
|
function T.Feed(acc, kind, data)
|
||||||
|
if not acc or not kind then
|
||||||
|
return acc
|
||||||
|
end
|
||||||
|
if kind == "end" then
|
||||||
|
acc.complete = true
|
||||||
|
return acc
|
||||||
|
end
|
||||||
|
if kind == "tab" and data then
|
||||||
|
acc.trees = acc.trees + 1
|
||||||
|
acc.spent = acc.spent + (data.spent or 0)
|
||||||
|
return acc
|
||||||
|
end
|
||||||
|
if kind == "talent" and data and data.rank and data.rank > 0 then
|
||||||
|
-- keyed BY NAME, because that is what DC.TALENT_HIT matches on
|
||||||
|
-- (data/talenthit.lua) and what ReadSelfTalents produces -- the two
|
||||||
|
-- paths must hand DC_Hit.TalentHit the same shape or they would
|
||||||
|
-- drift apart silently
|
||||||
|
if not acc.ranks[data.name] then
|
||||||
|
acc.sumRank = acc.sumRank + data.rank
|
||||||
|
acc.ranks[data.name] = data.rank
|
||||||
|
end
|
||||||
|
end
|
||||||
|
return acc
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Consistency gate before a reply is believed.
|
||||||
|
--
|
||||||
|
-- The client that answered told us two independent things: how many points
|
||||||
|
-- it spent per tree, and every individual rank. They must agree. They are
|
||||||
|
-- computed from the same source on the far side, so agreement does not
|
||||||
|
-- prove the data is FRESH -- but disagreement proves we mis-parsed or lost
|
||||||
|
-- messages, and that is exactly the failure this protects against (a
|
||||||
|
-- half-received tree would understate hit while looking perfectly normal).
|
||||||
|
--
|
||||||
|
-- All three trees must have reported; a talentless character legitimately
|
||||||
|
-- sends three empty tab lines and zero talent lines, which passes.
|
||||||
|
function T.Plausible(acc)
|
||||||
|
if not acc or not acc.complete then
|
||||||
|
return false
|
||||||
|
end
|
||||||
|
if acc.trees < 3 then
|
||||||
|
return false
|
||||||
|
end
|
||||||
|
return acc.sumRank == acc.spent
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Throttle decision, pure so the whole policy is testable without a client.
|
||||||
|
-- asked = { [name] = <time we last asked> }
|
||||||
|
-- lastAny = <time of the last request to ANYBODY>
|
||||||
|
function T.ShouldAsk(name, now, asked, lastAny)
|
||||||
|
if type(name) ~= "string" or name == "" then
|
||||||
|
return false
|
||||||
|
end
|
||||||
|
now = now or 0
|
||||||
|
if lastAny and (now - lastAny) < T.ASK_GAP then
|
||||||
|
return false
|
||||||
|
end
|
||||||
|
local last = asked and asked[name]
|
||||||
|
if last and (now - last) < T.REASK_AFTER then
|
||||||
|
return false
|
||||||
|
end
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
|
||||||
|
-- ==================================================================
|
||||||
|
-- WOW SECTION -- frame, event, sending
|
||||||
|
-- ==================================================================
|
||||||
|
if CreateFrame then
|
||||||
|
|
||||||
|
local acc = {} -- [sender] = accumulator, dropped once complete
|
||||||
|
local ranks = {} -- [sender] = { [talent] = rank }, the result
|
||||||
|
local readAt = {} -- [sender] = when that result landed
|
||||||
|
local asked = {} -- [name] = when we last asked
|
||||||
|
local lastAny = nil -- when we last asked anybody
|
||||||
|
local queue = {} -- names waiting to be asked
|
||||||
|
local queued = {} -- [name] = true, so the queue cannot hold dupes
|
||||||
|
|
||||||
|
local function enabled()
|
||||||
|
local d = DopingControlDB
|
||||||
|
-- default ON: an addon that computes hit and silently leaves out a
|
||||||
|
-- readable component is the bug this file fixes
|
||||||
|
return not (d and d.readTalents == false)
|
||||||
|
end
|
||||||
|
|
||||||
|
-- ranks for a player, or nil if we have not got a plausible reply yet.
|
||||||
|
-- nil (not {}) is load-bearing, exactly as for the own character:
|
||||||
|
-- "not read" and "read, no talents" are different states and only the
|
||||||
|
-- second one may count as a measured zero.
|
||||||
|
function T.RanksFor(name)
|
||||||
|
return ranks[name]
|
||||||
|
end
|
||||||
|
|
||||||
|
function T.ReadAt(name)
|
||||||
|
return readAt[name]
|
||||||
|
end
|
||||||
|
|
||||||
|
-- diagnostics for /dc talents
|
||||||
|
function T.Stats()
|
||||||
|
local nRanks, nPending = 0, 0
|
||||||
|
for _ in pairs(ranks) do
|
||||||
|
nRanks = nRanks + 1
|
||||||
|
end
|
||||||
|
for _ in pairs(acc) do
|
||||||
|
nPending = nPending + 1
|
||||||
|
end
|
||||||
|
return { known = nRanks, pending = nPending,
|
||||||
|
queued = table.getn(queue), enabled = enabled() }
|
||||||
|
end
|
||||||
|
|
||||||
|
function T.Ask(name)
|
||||||
|
if not enabled() or type(name) ~= "string" or name == "" then
|
||||||
|
return false
|
||||||
|
end
|
||||||
|
if not SendAddonMessage then
|
||||||
|
return false
|
||||||
|
end
|
||||||
|
-- pcall: a malformed name must never take the scan down with it
|
||||||
|
local ok = pcall(SendAddonMessage, T.PREFIX .. "<" .. name .. ">",
|
||||||
|
T.ASK_MSG, T.CHANNEL)
|
||||||
|
if ok then
|
||||||
|
asked[name] = GetTime()
|
||||||
|
lastAny = asked[name]
|
||||||
|
end
|
||||||
|
return ok
|
||||||
|
end
|
||||||
|
|
||||||
|
-- set below, once the frame exists: arms the paced sender
|
||||||
|
local arm
|
||||||
|
|
||||||
|
-- Called by the scan with the players it can see. Nothing is sent here
|
||||||
|
-- -- names are only lined up; the timer below paces them out.
|
||||||
|
function T.Request(names)
|
||||||
|
if not enabled() or type(names) ~= "table" then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
local now = GetTime()
|
||||||
|
for i = 1, table.getn(names) do
|
||||||
|
local n = names[i]
|
||||||
|
if type(n) == "string" and n ~= "" and not queued[n]
|
||||||
|
and T.ShouldAsk(n, now, asked, nil) then
|
||||||
|
queued[n] = true
|
||||||
|
table.insert(queue, n)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
if arm and table.getn(queue) > 0 then
|
||||||
|
arm()
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
local f = CreateFrame("Frame", "DopingControlTalentFrame")
|
||||||
|
f:RegisterEvent("CHAT_MSG_ADDON")
|
||||||
|
f:SetScript("OnEvent", function()
|
||||||
|
-- 1.12: arg1 prefix, arg2 message, arg3 channel, arg4 sender
|
||||||
|
if arg1 ~= T.PREFIX then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
local sender = arg4
|
||||||
|
if type(sender) ~= "string" or sender == "" then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
local kind, data = T.Parse(arg2)
|
||||||
|
if not kind then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
local now = GetTime()
|
||||||
|
local a = acc[sender]
|
||||||
|
if a and T.AccStale(a, now) then
|
||||||
|
-- the previous reply lost its END line; this one starts fresh
|
||||||
|
a = nil
|
||||||
|
end
|
||||||
|
if not a then
|
||||||
|
a = T.NewAcc(now)
|
||||||
|
acc[sender] = a
|
||||||
|
end
|
||||||
|
T.Feed(a, kind, data)
|
||||||
|
if a.complete then
|
||||||
|
acc[sender] = nil
|
||||||
|
if T.Plausible(a) then
|
||||||
|
ranks[sender] = a.ranks
|
||||||
|
readAt[sender] = GetTime()
|
||||||
|
-- a fresh talent set changes computed hit, so the view has
|
||||||
|
-- to be rebuilt -- without this the number only corrects
|
||||||
|
-- itself on the next scan
|
||||||
|
if DC_Matrix and DC_Matrix.Refresh then
|
||||||
|
DC_Matrix.Refresh()
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end)
|
||||||
|
|
||||||
|
-- Paced sender: one request per T.ASK_GAP, and only ever from the
|
||||||
|
-- queue the scan filled. Deliberately a plain timer rather than a burst
|
||||||
|
-- after each scan -- 40 requests in one frame is what a rate limit
|
||||||
|
-- would punish, and nothing documents where that limit sits.
|
||||||
|
--
|
||||||
|
-- It is installed only while there is something to send and takes itself
|
||||||
|
-- off again the moment the queue runs dry (or the feature is switched
|
||||||
|
-- off): an OnUpdate runs 60+ times a second for the whole session, and a
|
||||||
|
-- player who never scans must not pay for a queue that is always empty.
|
||||||
|
-- Re-arming needs no extra wiring -- every scan calls T.Request.
|
||||||
|
local elapsed = 0
|
||||||
|
|
||||||
|
local function disarm()
|
||||||
|
f:SetScript("OnUpdate", nil)
|
||||||
|
end
|
||||||
|
|
||||||
|
local function drain()
|
||||||
|
elapsed = elapsed + (arg1 or 0)
|
||||||
|
if elapsed < 1 then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
elapsed = 0
|
||||||
|
if not enabled() or table.getn(queue) == 0 then
|
||||||
|
disarm()
|
||||||
|
return
|
||||||
|
end
|
||||||
|
local now = GetTime()
|
||||||
|
if lastAny and (now - lastAny) < T.ASK_GAP then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
local name = table.remove(queue, 1)
|
||||||
|
if name then
|
||||||
|
queued[name] = nil
|
||||||
|
if T.ShouldAsk(name, now, asked, lastAny) then
|
||||||
|
T.Ask(name)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
if table.getn(queue) == 0 then
|
||||||
|
disarm()
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
arm = function()
|
||||||
|
if f:GetScript("OnUpdate") then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
-- a long idle gap must not fire an immediate burst
|
||||||
|
elapsed = 0
|
||||||
|
f:SetScript("OnUpdate", drain)
|
||||||
|
end
|
||||||
|
|
||||||
|
-- forget everything (respec, or a deliberate re-read)
|
||||||
|
function T.Clear()
|
||||||
|
disarm()
|
||||||
|
acc = {}
|
||||||
|
ranks = {}
|
||||||
|
readAt = {}
|
||||||
|
asked = {}
|
||||||
|
queue = {}
|
||||||
|
queued = {}
|
||||||
|
lastAny = nil
|
||||||
|
end
|
||||||
|
|
||||||
|
end
|
||||||
|
After Width: | Height: | Size: 417 KiB |
|
After Width: | Height: | Size: 493 KiB |
|
After Width: | Height: | Size: 445 KiB |
|
After Width: | Height: | Size: 1.0 MiB |
|
After Width: | Height: | Size: 26 KiB |
|
After Width: | Height: | Size: 49 KiB |
|
After Width: | Height: | Size: 358 KiB |
|
After Width: | Height: | Size: 424 KiB |
|
After Width: | Height: | Size: 410 KiB |
|
After Width: | Height: | Size: 502 KiB |
|
After Width: | Height: | Size: 771 KiB |
|
After Width: | Height: | Size: 256 KiB |
|
After Width: | Height: | Size: 64 KiB |
|
After Width: | Height: | Size: 8.0 KiB |
@@ -0,0 +1,118 @@
|
|||||||
|
-- DopingControl ui/cellsheet.lua
|
||||||
|
--
|
||||||
|
-- The matrix draws one small box per cell: a solid background with a 1px
|
||||||
|
-- border whose color carries the state. Done with SetBackdrop that costs NINE
|
||||||
|
-- texture regions per cell (measured in the client: bare frame 0, backdrop
|
||||||
|
-- with bgFile only 1, backdrop with edgeFile 9). At 805 cells that is ~7200
|
||||||
|
-- regions of pure decoration.
|
||||||
|
--
|
||||||
|
-- Instead every box is baked into one sheet and a cell owns a single texture
|
||||||
|
-- region that picks its box with SetTexCoord. The border is part of the image,
|
||||||
|
-- so the result is pixel-identical -- the sheet is generated from the same
|
||||||
|
-- DC.COLORS palette by tools/gen-cellsheet.js.
|
||||||
|
--
|
||||||
|
-- Pure Lua on purpose: no WoW API at file scope, so the offline tests can
|
||||||
|
-- dofile it under lua50.exe.
|
||||||
|
|
||||||
|
DC_Cells = {}
|
||||||
|
|
||||||
|
DC_Cells.SHEET = {
|
||||||
|
path = "Interface\\AddOns\\DopingControl\\textures\\cells",
|
||||||
|
size = 256, -- power of two, mandatory: a 12x12 TGA fails silently
|
||||||
|
tileW = 64, -- holds the widest sprite (34) with room to spare
|
||||||
|
tileH = 32, -- holds CELL_H (23)
|
||||||
|
perRow = 4, -- 256 / 64
|
||||||
|
}
|
||||||
|
|
||||||
|
-- Cell height never varies; only the width does (trinket columns are 34).
|
||||||
|
DC_Cells.CELL_H = 23
|
||||||
|
DC_Cells.WIDTHS = { 30, 34 }
|
||||||
|
|
||||||
|
-- Every (background, border) pair a CELL can take, in sheet order. Borders
|
||||||
|
-- given as a number are DC.QUALITY indices, everything else is a DC.COLORS
|
||||||
|
-- key. Order is the wire format of the sheet: appending is safe, reordering
|
||||||
|
-- means regenerating the TGA.
|
||||||
|
DC_Cells.VARIANTS = {
|
||||||
|
{ name = "HAS", bg = "hatBg", border = "hatBorder" },
|
||||||
|
{ name = "MISSING", bg = "fehltBg", border = "fehltBorder" },
|
||||||
|
{ name = "UNKNOWN", bg = "unbekBg", border = "unbekBorder" },
|
||||||
|
{ name = "SKIP", bg = "skip", border = "skipBorder" },
|
||||||
|
{ name = "NEUTRAL", bg = "theadBg", border = "line" },
|
||||||
|
{ name = "ITEM_HAS", bg = "theadBg", border = "hatBorder" },
|
||||||
|
{ name = "ITEM_MISSING", bg = "theadBg", border = "fehltBorder" },
|
||||||
|
{ name = "Q_RARE", bg = "theadBg", border = "rareBorder" },
|
||||||
|
{ name = "Q_EPIC", bg = "theadBg", border = "epicBorder" },
|
||||||
|
{ name = "Q_0", bg = "theadBg", border = 0 },
|
||||||
|
{ name = "Q_1", bg = "theadBg", border = 1 },
|
||||||
|
{ name = "Q_2", bg = "theadBg", border = 2 },
|
||||||
|
{ name = "Q_5", bg = "theadBg", border = 5 },
|
||||||
|
{ name = "Q_6", bg = "theadBg", border = 6 },
|
||||||
|
}
|
||||||
|
|
||||||
|
DC_Cells.INDEX = {}
|
||||||
|
for i = 1, table.getn(DC_Cells.VARIANTS) do
|
||||||
|
DC_Cells.INDEX[DC_Cells.VARIANTS[i].name] = i
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Tile slot of a variant/width pair: each variant owns two consecutive tiles,
|
||||||
|
-- one per width, in DC_Cells.WIDTHS order.
|
||||||
|
local function slotOf(name, w)
|
||||||
|
local vi = DC_Cells.INDEX[name]
|
||||||
|
if not vi then
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
for k = 1, table.getn(DC_Cells.WIDTHS) do
|
||||||
|
if DC_Cells.WIDTHS[k] == w then
|
||||||
|
return (vi - 1) * table.getn(DC_Cells.WIDTHS) + (k - 1)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
DC_Cells.SlotOf = slotOf
|
||||||
|
|
||||||
|
-- SetTexCoord arguments for one box. Returns four numbers or nil -- nil so a
|
||||||
|
-- typo shows up as a missing box instead of silently sampling tile 0.
|
||||||
|
function DC_Cells.Coords(name, w)
|
||||||
|
local slot = slotOf(name, w)
|
||||||
|
if not slot then
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
local S = DC_Cells.SHEET
|
||||||
|
local col = math.mod(slot, S.perRow)
|
||||||
|
local row = math.floor(slot / S.perRow)
|
||||||
|
local x0 = col * S.tileW
|
||||||
|
local y0 = row * S.tileH
|
||||||
|
return x0 / S.size,
|
||||||
|
(x0 + w) / S.size,
|
||||||
|
y0 / S.size,
|
||||||
|
(y0 + DC_Cells.CELL_H) / S.size
|
||||||
|
end
|
||||||
|
|
||||||
|
-- State + slot kind + item quality -> variant name. Mirrors exactly what the
|
||||||
|
-- old SetBackdropG call sites in ui/matrix.lua chose -- which, for a nil
|
||||||
|
-- quality, differed BY KIND (see ui/matrix.lua's M.PaintItemTile, old lines
|
||||||
|
-- 1965-1990, confirmed at tools/luatests/test_cellsheet.lua):
|
||||||
|
-- * trinket: M.qualityColors(item.quality) ran unconditionally, and for
|
||||||
|
-- quality == nil that resolves to DC.QUALITY[1] (common/white) via the
|
||||||
|
-- `q or 1` fallback -- state-INDEPENDENT.
|
||||||
|
-- * ench: quality == nil skipped M.qualityColors and fell back to
|
||||||
|
-- C.hatBorder/C.fehltBorder/C.line BY STATE -- exactly
|
||||||
|
-- ITEM_HAS/ITEM_MISSING/NEUTRAL.
|
||||||
|
-- So trinket's nil-quality case must NOT share ench's state-based fallback.
|
||||||
|
function DC_Cells.VariantFor(state, kind, quality)
|
||||||
|
if kind == "ench" or kind == "trinket" then
|
||||||
|
if quality == 3 then return "Q_RARE" end
|
||||||
|
if quality == 4 then return "Q_EPIC" end
|
||||||
|
if quality ~= nil then return "Q_" .. quality end
|
||||||
|
if kind == "trinket" then
|
||||||
|
return "Q_1"
|
||||||
|
end
|
||||||
|
if state == "HAS" then return "ITEM_HAS" end
|
||||||
|
if state == "MISSING" then return "ITEM_MISSING" end
|
||||||
|
return "NEUTRAL"
|
||||||
|
end
|
||||||
|
if state == "HAS" then return "HAS" end
|
||||||
|
if state == "MISSING" then return "MISSING" end
|
||||||
|
if state == "UNKNOWN" then return "UNKNOWN" end
|
||||||
|
return "SKIP"
|
||||||
|
end
|
||||||
@@ -0,0 +1,447 @@
|
|||||||
|
-- DopingControl ui/report.lua
|
||||||
|
-- Report preview overlay + raid-chat send queue.
|
||||||
|
--
|
||||||
|
-- Pure part (offline-testable):
|
||||||
|
-- DC_Report.SendDisabled(source) -> bool (true iff source == "sim")
|
||||||
|
-- DC_Report.QueueCreate(lines) -> queue (independent copy of lines)
|
||||||
|
-- DC_Report.QueueTick(q, now, src) -> line-to-send-now | nil
|
||||||
|
-- DC_Report.QueueLabel(q) -> "sending k/n" button label
|
||||||
|
-- DC_Report.BuildLines(store, db, tab) -> lines, vm (via DC_Model)
|
||||||
|
--
|
||||||
|
-- Queue semantics:
|
||||||
|
-- * First line goes out immediately on Send; every further line waits
|
||||||
|
-- >= SEND_INTERVAL (0.6 s) measured from the PREVIOUS ACTUAL SEND
|
||||||
|
-- (the client chat throttle is ~10 msgs/10 s --
|
||||||
|
-- 0.6 s spacing stays safely under it even with other addons chatting).
|
||||||
|
-- * ABORT: if store.source flips to "sim" mid-drain, the very next tick
|
||||||
|
-- aborts the queue without sending (synthetic names must
|
||||||
|
-- never reach real chat). An aborted queue never resumes.
|
||||||
|
-- * In sim mode the Send button never activates in the first place
|
||||||
|
-- (SendDisabled predicate), with tooltip "disabled in test mode".
|
||||||
|
--
|
||||||
|
-- WoW wiring (behind `if CreateFrame then`): overlay frame
|
||||||
|
-- DopingControlReportFrame, DIALOG strata, anchored over the matrix frame,
|
||||||
|
-- scrollable preview FontString showing DC_Model.reportLines output EXACTLY
|
||||||
|
-- as it will be sent, buttons "Send to raid chat" + "Close".
|
||||||
|
-- SendChatMessage(line, "RAID") only ever fires from the queue drainer here
|
||||||
|
-- (single-sender rule).
|
||||||
|
--
|
||||||
|
-- Deliberate resolutions:
|
||||||
|
-- * DC_Report.Open() is exposed with OPTIONAL (vm, tab) arguments: the
|
||||||
|
-- matrix "Report..." button may pass its already-built vm; with no
|
||||||
|
-- arguments the report builds its own vm from DC.store via
|
||||||
|
-- DC_Model/DC_Roles and the current tab (DC_Matrix.GetTab()/.tab if
|
||||||
|
-- the matrix exposes it, else DC.currentTab, else "CONSUMABLES").
|
||||||
|
-- * The matrix frame global name is not fixed; anchoring
|
||||||
|
-- tries DC_Matrix.frame, then DopingControlFrame /
|
||||||
|
-- DopingControlMatrixFrame, else UIParent.
|
||||||
|
-- * Closing the overlay (Close / ESC) while draining cancels the rest of
|
||||||
|
-- the queue -- hidden frames get no OnUpdate on 1.12, so a hidden
|
||||||
|
-- "paused" queue would silently resume minutes later; cancel is the
|
||||||
|
-- predictable behavior.
|
||||||
|
|
||||||
|
DopingControl = DopingControl or {}
|
||||||
|
local DC = DopingControl
|
||||||
|
|
||||||
|
DC_Report = DC_Report or {}
|
||||||
|
local R = DC_Report
|
||||||
|
|
||||||
|
-- ==================================================================
|
||||||
|
-- Pure queue core
|
||||||
|
-- ==================================================================
|
||||||
|
|
||||||
|
R.SEND_INTERVAL = 0.6 -- seconds between two sent lines
|
||||||
|
R.CHAT_TYPE = "RAID"
|
||||||
|
|
||||||
|
-- Send is dead exactly in test mode. nil source (no scan
|
||||||
|
-- yet) is NOT sim: sending a "coverage 0/0" report is pointless but safe.
|
||||||
|
function R.SendDisabled(source)
|
||||||
|
return source == "sim"
|
||||||
|
end
|
||||||
|
|
||||||
|
-- The chat API rejects the WHOLE message with "Invalid escape code in chat
|
||||||
|
-- message" when it meets a "|" that does not open a valid escape sequence
|
||||||
|
-- (|c |r |H |h |T |t |n ||). Our header line uses " | " as a plain ASCII
|
||||||
|
-- separator, so every report was refused. "||" is the escape for a literal
|
||||||
|
-- pipe and still renders as a single "|", which is why this runs at the
|
||||||
|
-- send point and NOT inside DC_Model.reportLines: the preview shows what
|
||||||
|
-- reportLines returns and must keep showing the text as it APPEARS in chat.
|
||||||
|
-- Report lines are pure ASCII by design (no item links, no colour codes),
|
||||||
|
-- so doubling every pipe cannot damage a real escape sequence here.
|
||||||
|
function R.ChatSafe(line)
|
||||||
|
if not line then
|
||||||
|
return line
|
||||||
|
end
|
||||||
|
return (string.gsub(line, "|", "||"))
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Create a drain queue from an array of lines. Takes an independent copy
|
||||||
|
-- (the preview may be rebuilt while an old queue is still draining).
|
||||||
|
function R.QueueCreate(lines)
|
||||||
|
local q = {
|
||||||
|
lines = {},
|
||||||
|
n = 0,
|
||||||
|
sent = 0,
|
||||||
|
nextAt = 0, -- absolute time the next send is allowed
|
||||||
|
done = false,
|
||||||
|
aborted = false,
|
||||||
|
}
|
||||||
|
local n = table.getn(lines)
|
||||||
|
for i = 1, n do
|
||||||
|
table.insert(q.lines, lines[i])
|
||||||
|
end
|
||||||
|
q.n = n
|
||||||
|
if n == 0 then
|
||||||
|
q.done = true
|
||||||
|
end
|
||||||
|
return q
|
||||||
|
end
|
||||||
|
|
||||||
|
-- One drain step. now = current time (GetTime() in-game), source = current
|
||||||
|
-- store source ("scan"/"sim"/nil). Returns the line to send NOW, or nil.
|
||||||
|
-- Sets q.done when the last line went out, q.aborted on a sim flip.
|
||||||
|
function R.QueueTick(q, now, source)
|
||||||
|
if not q or q.done or q.aborted then
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
if source == "sim" then
|
||||||
|
q.aborted = true -- never resumes
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
if now < q.nextAt then
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
q.sent = q.sent + 1
|
||||||
|
local line = q.lines[q.sent]
|
||||||
|
q.nextAt = now + R.SEND_INTERVAL
|
||||||
|
if q.sent >= q.n then
|
||||||
|
q.done = true
|
||||||
|
end
|
||||||
|
return line
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Button label while a queue exists.
|
||||||
|
function R.QueueLabel(q)
|
||||||
|
if not q then
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
if q.aborted then
|
||||||
|
return "aborted"
|
||||||
|
end
|
||||||
|
return string.format("sending %d/%d", q.sent, q.n)
|
||||||
|
end
|
||||||
|
|
||||||
|
-- ==================================================================
|
||||||
|
-- Line building (pure given a store; uses DC_Model + DC_Roles)
|
||||||
|
-- ==================================================================
|
||||||
|
|
||||||
|
-- "14:04"-style timestamp: WoW 1.12 exposes a global date(); plain
|
||||||
|
-- Lua 5.0 has os.date.
|
||||||
|
function R.TimeStr()
|
||||||
|
if type(date) == "function" then
|
||||||
|
return date("%H:%M")
|
||||||
|
end
|
||||||
|
if os and type(os.date) == "function" then
|
||||||
|
return os.date("%H:%M")
|
||||||
|
end
|
||||||
|
return "--:--"
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Build the report lines for a store + db + tab. Returns lines, vm.
|
||||||
|
function R.BuildLines(store, dbt, tab)
|
||||||
|
store = store or DC.store or { players = {} }
|
||||||
|
tab = tab or "CONSUMABLES"
|
||||||
|
-- Same expectation resolver the matrix uses, or the report preview
|
||||||
|
-- would show different columns and different gap totals than the grid
|
||||||
|
-- it was opened from (demo mode). Safe for the SEND path too: the demo
|
||||||
|
-- layer only applies over the "sim" store, and R.SendDisabled blocks
|
||||||
|
-- sending on exactly that source. Guarded because ui/report.lua is
|
||||||
|
-- loadable without ui/matrix.lua (tools/luatests/test_report_queue.lua).
|
||||||
|
local expect
|
||||||
|
if DC_Matrix and DC_Matrix.ExpectTable then
|
||||||
|
expect = DC_Matrix.ExpectTable(dbt)
|
||||||
|
else
|
||||||
|
expect = (dbt and dbt.expectations) or DC.DEFAULT_EXPECT or {}
|
||||||
|
end
|
||||||
|
local function roleOf(p)
|
||||||
|
return DC_Roles.resolve(dbt, store.source, p)
|
||||||
|
end
|
||||||
|
local vm = DC_Model.build(store, roleOf, expect, tab)
|
||||||
|
local label = (DC.TAB_LABEL and DC.TAB_LABEL[tab]) or tab
|
||||||
|
return DC_Model.reportLines(vm, label, R.TimeStr()), vm
|
||||||
|
end
|
||||||
|
|
||||||
|
-- ==================================================================
|
||||||
|
-- WoW wiring
|
||||||
|
-- ==================================================================
|
||||||
|
if CreateFrame then
|
||||||
|
|
||||||
|
local frame = nil -- DopingControlReportFrame (lazy)
|
||||||
|
local previewText = nil -- the preview FontString
|
||||||
|
local scrollChild = nil
|
||||||
|
local infoText = nil
|
||||||
|
local sendBtn = nil
|
||||||
|
local sendBlocker = nil -- invisible tooltip overlay while disabled
|
||||||
|
local lines = nil -- current preview lines (array)
|
||||||
|
local queue = nil -- active drain queue or nil
|
||||||
|
|
||||||
|
local function db()
|
||||||
|
return DopingControlDB
|
||||||
|
end
|
||||||
|
|
||||||
|
local function currentSource()
|
||||||
|
return DC.store and DC.store.source
|
||||||
|
end
|
||||||
|
|
||||||
|
local function currentTab()
|
||||||
|
if DC_Matrix then
|
||||||
|
if DC_Matrix.GetTab then
|
||||||
|
return DC_Matrix.GetTab()
|
||||||
|
end
|
||||||
|
if DC_Matrix.tab then
|
||||||
|
return DC_Matrix.tab
|
||||||
|
end
|
||||||
|
end
|
||||||
|
return DC.currentTab or "CONSUMABLES"
|
||||||
|
end
|
||||||
|
|
||||||
|
-- The matrix frame global name is not guaranteed; try the likely
|
||||||
|
-- handles, fall back safely.
|
||||||
|
local function anchorFrame()
|
||||||
|
if DC_Matrix and DC_Matrix.frame then
|
||||||
|
return DC_Matrix.frame
|
||||||
|
end
|
||||||
|
local m = getglobal("DopingControlFrame")
|
||||||
|
or getglobal("DopingControlMatrixFrame")
|
||||||
|
return m or UIParent
|
||||||
|
end
|
||||||
|
|
||||||
|
local function panelBackdrop()
|
||||||
|
if DC_Widgets and DC_Widgets.PANEL_BACKDROP then
|
||||||
|
return DC_Widgets.PANEL_BACKDROP
|
||||||
|
end
|
||||||
|
if DC.PANEL_BACKDROP then
|
||||||
|
return DC.PANEL_BACKDROP
|
||||||
|
end
|
||||||
|
return {
|
||||||
|
bgFile = "Interface\\AddOns\\DopingControl\\textures\\panel_bg",
|
||||||
|
edgeFile = "Interface\\AddOns\\DopingControl\\textures\\panel_border",
|
||||||
|
tile = true, tileSize = 128, edgeSize = 16,
|
||||||
|
insets = { left = 5, right = 5, top = 5, bottom = 5 },
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Send button + blocker state machine: draining -> disabled "sending
|
||||||
|
-- k/n"; sim -> disabled with tooltip blocker; else enabled.
|
||||||
|
local function UpdateSendState()
|
||||||
|
if not sendBtn then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
if queue and not queue.done and not queue.aborted then
|
||||||
|
sendBtn:Disable()
|
||||||
|
sendBtn:SetText(R.QueueLabel(queue))
|
||||||
|
sendBlocker:Hide()
|
||||||
|
elseif R.SendDisabled(currentSource()) then
|
||||||
|
sendBtn:Disable()
|
||||||
|
sendBtn:SetText("Send to raid chat")
|
||||||
|
sendBlocker:Show()
|
||||||
|
else
|
||||||
|
sendBtn:Enable()
|
||||||
|
sendBtn:SetText("Send to raid chat")
|
||||||
|
sendBlocker:Hide()
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
-- OnUpdate drainer (installed on the frame only while a queue exists;
|
||||||
|
-- no allocation on the idle path).
|
||||||
|
local function OnUpdateDrain()
|
||||||
|
if not queue then
|
||||||
|
this:SetScript("OnUpdate", nil)
|
||||||
|
return
|
||||||
|
end
|
||||||
|
local line = R.QueueTick(queue, GetTime(), currentSource())
|
||||||
|
if line then
|
||||||
|
SendChatMessage(R.ChatSafe(line), R.CHAT_TYPE)
|
||||||
|
UpdateSendState()
|
||||||
|
end
|
||||||
|
if queue.done or queue.aborted then
|
||||||
|
queue = nil
|
||||||
|
this:SetScript("OnUpdate", nil)
|
||||||
|
UpdateSendState()
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
local function OnSendClick()
|
||||||
|
if queue then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
if not lines or table.getn(lines) == 0 then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
if R.SendDisabled(currentSource()) then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
queue = R.QueueCreate(lines)
|
||||||
|
frame:SetScript("OnUpdate", OnUpdateDrain)
|
||||||
|
UpdateSendState()
|
||||||
|
end
|
||||||
|
|
||||||
|
local function BuildReportFrame()
|
||||||
|
if frame then
|
||||||
|
return frame
|
||||||
|
end
|
||||||
|
local f = CreateFrame("Frame", "DopingControlReportFrame", UIParent)
|
||||||
|
f:SetWidth(460)
|
||||||
|
f:SetHeight(340)
|
||||||
|
f:SetFrameStrata("DIALOG")
|
||||||
|
f:SetPoint("CENTER", anchorFrame(), "CENTER", 0, 0)
|
||||||
|
f:SetBackdrop(panelBackdrop())
|
||||||
|
f:SetBackdropColor(1, 1, 1, 0.97)
|
||||||
|
f:SetMovable(true)
|
||||||
|
f:EnableMouse(true)
|
||||||
|
f:RegisterForDrag("LeftButton")
|
||||||
|
f:SetScript("OnDragStart", function() this:StartMoving() end)
|
||||||
|
f:SetScript("OnDragStop", function() this:StopMovingOrSizing() end)
|
||||||
|
f:SetClampedToScreen(true)
|
||||||
|
|
||||||
|
-- title (gold)
|
||||||
|
local title = f:CreateFontString("DopingControlReportTitle",
|
||||||
|
"ARTWORK", "GameFontNormal")
|
||||||
|
title:SetPoint("TOPLEFT", f, "TOPLEFT", 16, -14)
|
||||||
|
local g = DC.COLORS and DC.COLORS.goldHi
|
||||||
|
if g then
|
||||||
|
title:SetTextColor(g.r, g.g, g.b)
|
||||||
|
else
|
||||||
|
title:SetTextColor(0.85, 0.66, 0.31)
|
||||||
|
end
|
||||||
|
title:SetText("Report preview")
|
||||||
|
|
||||||
|
-- info line (line count + pacing note)
|
||||||
|
infoText = f:CreateFontString("DopingControlReportInfo",
|
||||||
|
"ARTWORK", "GameFontNormalSmall")
|
||||||
|
infoText:SetPoint("TOPLEFT", f, "TOPLEFT", 16, -30)
|
||||||
|
infoText:SetTextColor(0.6, 0.6, 0.6)
|
||||||
|
infoText:SetText("")
|
||||||
|
|
||||||
|
-- close X
|
||||||
|
local closeX = CreateFrame("Button", "DopingControlReportCloseX",
|
||||||
|
f, "UIPanelCloseButton")
|
||||||
|
closeX:SetPoint("TOPRIGHT", f, "TOPRIGHT", -8, -8)
|
||||||
|
|
||||||
|
-- scrollable preview: shows reportLines output EXACTLY as sent
|
||||||
|
local scroll = CreateFrame("ScrollFrame", "DopingControlReportScroll",
|
||||||
|
f, "UIPanelScrollFrameTemplate")
|
||||||
|
scroll:SetPoint("TOPLEFT", f, "TOPLEFT", 16, -48)
|
||||||
|
scroll:SetPoint("BOTTOMRIGHT", f, "BOTTOMRIGHT", -36, 44)
|
||||||
|
|
||||||
|
scrollChild = CreateFrame("Frame", "DopingControlReportScrollChild",
|
||||||
|
scroll)
|
||||||
|
scrollChild:SetWidth(400)
|
||||||
|
scrollChild:SetHeight(1)
|
||||||
|
scroll:SetScrollChild(scrollChild)
|
||||||
|
|
||||||
|
previewText = scrollChild:CreateFontString("DopingControlReportText",
|
||||||
|
"ARTWORK", "GameFontHighlightSmall")
|
||||||
|
previewText:SetPoint("TOPLEFT", scrollChild, "TOPLEFT", 0, 0)
|
||||||
|
previewText:SetWidth(390)
|
||||||
|
previewText:SetJustifyH("LEFT")
|
||||||
|
previewText:SetJustifyV("TOP")
|
||||||
|
-- mono-ish: 1.12 ships no true monospace; the condensed number
|
||||||
|
-- font keeps the indented name lists readable
|
||||||
|
previewText:SetFont("Fonts\\ARIALN.TTF", 12)
|
||||||
|
|
||||||
|
-- buttons
|
||||||
|
sendBtn = CreateFrame("Button", "DopingControlReportSendButton",
|
||||||
|
f, "UIPanelButtonTemplate")
|
||||||
|
sendBtn:SetWidth(150)
|
||||||
|
sendBtn:SetHeight(22)
|
||||||
|
sendBtn:SetPoint("BOTTOMLEFT", f, "BOTTOMLEFT", 16, 14)
|
||||||
|
sendBtn:SetText("Send to raid chat")
|
||||||
|
sendBtn:SetScript("OnClick", OnSendClick)
|
||||||
|
|
||||||
|
local closeBtn = CreateFrame("Button", "DopingControlReportCloseButton",
|
||||||
|
f, "UIPanelButtonTemplate")
|
||||||
|
closeBtn:SetWidth(80)
|
||||||
|
closeBtn:SetHeight(22)
|
||||||
|
closeBtn:SetPoint("BOTTOMRIGHT", f, "BOTTOMRIGHT", -16, 14)
|
||||||
|
closeBtn:SetText("Close")
|
||||||
|
closeBtn:SetScript("OnClick", function() f:Hide() end)
|
||||||
|
|
||||||
|
if pfUI and pfUI.api then
|
||||||
|
if pfUI.api.SkinButton then
|
||||||
|
pfUI.api.SkinButton(sendBtn)
|
||||||
|
pfUI.api.SkinButton(closeBtn)
|
||||||
|
end
|
||||||
|
if pfUI.api.SkinCloseButton then
|
||||||
|
pfUI.api.SkinCloseButton(closeX)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
-- invisible overlay for the disabled-in-test-mode tooltip
|
||||||
|
-- (disabled buttons receive no mouse events on 1.12)
|
||||||
|
sendBlocker = CreateFrame("Frame", "DopingControlReportSendBlocker", f)
|
||||||
|
sendBlocker:SetAllPoints(sendBtn)
|
||||||
|
sendBlocker:SetFrameLevel(sendBtn:GetFrameLevel() + 2)
|
||||||
|
sendBlocker:EnableMouse(true)
|
||||||
|
sendBlocker:SetScript("OnEnter", function()
|
||||||
|
GameTooltip:SetOwner(this, "ANCHOR_RIGHT")
|
||||||
|
GameTooltip:SetText("disabled in test mode", 1, 1, 1)
|
||||||
|
GameTooltip:AddLine("Simulated names never reach real chat.", 0.8, 0.8, 0.8)
|
||||||
|
GameTooltip:AddLine("The preview stays fully usable.", 0.8, 0.8, 0.8)
|
||||||
|
GameTooltip:Show()
|
||||||
|
end)
|
||||||
|
sendBlocker:SetScript("OnLeave", function() GameTooltip:Hide() end)
|
||||||
|
sendBlocker:Hide()
|
||||||
|
|
||||||
|
-- closing cancels a running drain (see header)
|
||||||
|
f:SetScript("OnHide", function()
|
||||||
|
if queue then
|
||||||
|
queue = nil
|
||||||
|
this:SetScript("OnUpdate", nil)
|
||||||
|
end
|
||||||
|
end)
|
||||||
|
|
||||||
|
-- ESC closes
|
||||||
|
table.insert(UISpecialFrames, "DopingControlReportFrame")
|
||||||
|
|
||||||
|
f:Hide()
|
||||||
|
frame = f
|
||||||
|
return f
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Rebuild the preview content. vm/tab optional (see header).
|
||||||
|
function R.Refresh(vm, tab)
|
||||||
|
BuildReportFrame()
|
||||||
|
tab = tab or currentTab()
|
||||||
|
if vm then
|
||||||
|
local label = (DC.TAB_LABEL and DC.TAB_LABEL[tab]) or tab
|
||||||
|
lines = DC_Model.reportLines(vm, label, R.TimeStr())
|
||||||
|
else
|
||||||
|
lines = R.BuildLines(DC.store, db(), tab)
|
||||||
|
end
|
||||||
|
local n = table.getn(lines)
|
||||||
|
previewText:SetText(table.concat(lines, "\n"))
|
||||||
|
scrollChild:SetHeight(n * 14 + 24)
|
||||||
|
infoText:SetText(string.format(
|
||||||
|
"%d line%s - sent one per %.1f s", n, (n == 1) and "" or "s",
|
||||||
|
R.SEND_INTERVAL))
|
||||||
|
UpdateSendState()
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Public entry point (matrix "Report..." button, /dc report).
|
||||||
|
function R.Open(vm, tab)
|
||||||
|
local f = BuildReportFrame()
|
||||||
|
R.Refresh(vm, tab)
|
||||||
|
f:Show()
|
||||||
|
end
|
||||||
|
|
||||||
|
function R.Toggle(vm, tab)
|
||||||
|
local f = BuildReportFrame()
|
||||||
|
if f:IsShown() then
|
||||||
|
f:Hide()
|
||||||
|
else
|
||||||
|
R.Open(vm, tab)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
end
|
||||||
@@ -0,0 +1,463 @@
|
|||||||
|
-- DopingControl ui/widgets.lua
|
||||||
|
-- Shared UI building blocks: panel backdrop, pooled-widget factories
|
||||||
|
-- (cell / pill / badge / button), tooltip helpers, pfUI-aware font helper,
|
||||||
|
-- change-guarded setters (only touch a region when the value changed).
|
||||||
|
--
|
||||||
|
-- Factory + pooling patterns follow common 1.12 addon practice
|
||||||
|
-- (pfUI-style WHITE8X8 backdrops, pooled regions, save-and-call hooks).
|
||||||
|
--
|
||||||
|
-- Pure Lua 5.0 dofile-loadable: top level only defines tables/functions;
|
||||||
|
-- CreateFrame & friends are only referenced INSIDE factory functions, which
|
||||||
|
-- are never called offline.
|
||||||
|
|
||||||
|
DopingControl = DopingControl or {}
|
||||||
|
local DC = DopingControl
|
||||||
|
|
||||||
|
DC_Widgets = DC_Widgets or {}
|
||||||
|
local W = DC_Widgets
|
||||||
|
|
||||||
|
-- ------------------------------------------------------------------
|
||||||
|
-- Texture / font constants
|
||||||
|
-- ------------------------------------------------------------------
|
||||||
|
W.SOLID_TEX = "Interface\\Buttons\\WHITE8X8" -- 1px-border backdrop base
|
||||||
|
W.FONT = "Fonts\\FRIZQT__.TTF" -- default client font
|
||||||
|
W.CHECK_TEX = "Interface\\Buttons\\UI-CheckBox-Check" -- HAS glyph (tinted green)
|
||||||
|
W.CROSS_TEX = "Interface\\Buttons\\UI-GroupLoot-Pass-Up" -- MISSING glyph (tinted red)
|
||||||
|
|
||||||
|
-- Main window backdrop (textures shipped in DopingControl\textures\).
|
||||||
|
W.PANEL_BACKDROP = {
|
||||||
|
bgFile = "Interface\\AddOns\\DopingControl\\textures\\panel_bg",
|
||||||
|
edgeFile = "Interface\\AddOns\\DopingControl\\textures\\panel_border",
|
||||||
|
tile = true, tileSize = 128, edgeSize = 16,
|
||||||
|
insets = { left = 5, right = 5, top = 5, bottom = 5 },
|
||||||
|
}
|
||||||
|
|
||||||
|
-- Solid 1px-border backdrop for cells/pills/badges/buttons (pfUI-style
|
||||||
|
-- WHITE8X8 recipe; bg + border colored via SetBackdropColor /
|
||||||
|
-- SetBackdropBorderColor).
|
||||||
|
W.SOLID_BACKDROP = {
|
||||||
|
bgFile = W.SOLID_TEX,
|
||||||
|
edgeFile = W.SOLID_TEX,
|
||||||
|
tile = false, edgeSize = 1,
|
||||||
|
insets = { left = 1, right = 1, top = 1, bottom = 1 },
|
||||||
|
}
|
||||||
|
|
||||||
|
W.WHITE = { r = 1, g = 1, b = 1 }
|
||||||
|
|
||||||
|
-- ------------------------------------------------------------------
|
||||||
|
-- Cell-box fallback palette: variant name -> (bg, border) color reference,
|
||||||
|
-- used ONLY when ui/cellsheet.lua's baked sheet is unavailable (see
|
||||||
|
-- W.HasCellSheet below). `border` is a DC.COLORS key, or a number that
|
||||||
|
-- indexes DC.QUALITY -- same convention as DC_Cells.VARIANTS in
|
||||||
|
-- ui/cellsheet.lua, which this table intentionally mirrors: colors are
|
||||||
|
-- pulled LIVE from DC.COLORS/DC.QUALITY (core/const.lua, already loaded --
|
||||||
|
-- ui/cellsheet.lua only bakes that same palette into a texture, it does
|
||||||
|
-- not own it), so nothing here duplicates a color VALUE. The pairing
|
||||||
|
-- itself (which key goes with which variant) can't be derived
|
||||||
|
-- mechanically -- it is the one piece of information that has to exist
|
||||||
|
-- twice given the failure mode this guards (ui/cellsheet.lua not loaded
|
||||||
|
-- at all). tools/luatests/test_widgets.lua cross-checks every entry
|
||||||
|
-- against the real DC_Cells.VARIANTS so the two cannot silently drift.
|
||||||
|
W.CELL_FALLBACK_VARIANTS = {
|
||||||
|
HAS = { bg = "hatBg", border = "hatBorder" },
|
||||||
|
MISSING = { bg = "fehltBg", border = "fehltBorder" },
|
||||||
|
UNKNOWN = { bg = "unbekBg", border = "unbekBorder" },
|
||||||
|
SKIP = { bg = "skip", border = "skipBorder" },
|
||||||
|
NEUTRAL = { bg = "theadBg", border = "line" },
|
||||||
|
ITEM_HAS = { bg = "theadBg", border = "hatBorder" },
|
||||||
|
ITEM_MISSING = { bg = "theadBg", border = "fehltBorder" },
|
||||||
|
Q_RARE = { bg = "theadBg", border = "rareBorder" },
|
||||||
|
Q_EPIC = { bg = "theadBg", border = "epicBorder" },
|
||||||
|
Q_0 = { bg = "theadBg", border = 0 },
|
||||||
|
Q_1 = { bg = "theadBg", border = 1 },
|
||||||
|
Q_2 = { bg = "theadBg", border = 2 },
|
||||||
|
Q_5 = { bg = "theadBg", border = 5 },
|
||||||
|
Q_6 = { bg = "theadBg", border = 6 },
|
||||||
|
}
|
||||||
|
|
||||||
|
-- ------------------------------------------------------------------
|
||||||
|
-- DC_Cells fallback shim -- covers a mid-session /reload where THIS file
|
||||||
|
-- (ui/widgets.lua, an EXISTING file whose new content /reload happily
|
||||||
|
-- re-executes) deploys, but ui/cellsheet.lua (a NEW file added in the same
|
||||||
|
-- change) does not: 1.12's /reload re-runs existing files but never loads
|
||||||
|
-- a file that was not already part of the running session (verified
|
||||||
|
-- in-game). Left alone, DC_Cells stays nil, and it is not only
|
||||||
|
-- W.MakeCell/W.SetCellBox below that touch it -- ui/matrix.lua calls
|
||||||
|
-- DC_Cells.VariantFor(...) directly at two paint sites (M.PaintItemTile
|
||||||
|
-- and the legacy trinket path, ui/matrix.lua ~2182/2381 -- out of scope
|
||||||
|
-- for this change) and would error on the very first painted item/trinket
|
||||||
|
-- cell regardless of anything done here.
|
||||||
|
--
|
||||||
|
-- The shim supplies ONLY the pure classification function -- state/kind/
|
||||||
|
-- quality -> variant NAME string -- no sheet, no texture, no coordinate
|
||||||
|
-- math, because none of that can work without ui/cellsheet.lua's baked
|
||||||
|
-- sheet file. It is a verbatim copy of DC_Cells.VariantFor's logic (see
|
||||||
|
-- ui/cellsheet.lua); test_widgets.lua cross-checks the two stay in sync.
|
||||||
|
-- Installs only when DC_Cells does not already exist, so the normal
|
||||||
|
-- (full-restart) load path -- where ui/cellsheet.lua runs before this file
|
||||||
|
-- per DopingControl.toc and sets the real DC_Cells -- is untouched.
|
||||||
|
if not DC_Cells then
|
||||||
|
DC_Cells = {
|
||||||
|
VariantFor = function(state, kind, quality)
|
||||||
|
if kind == "ench" or kind == "trinket" then
|
||||||
|
if quality == 3 then return "Q_RARE" end
|
||||||
|
if quality == 4 then return "Q_EPIC" end
|
||||||
|
if quality ~= nil then return "Q_" .. quality end
|
||||||
|
if kind == "trinket" then
|
||||||
|
return "Q_1"
|
||||||
|
end
|
||||||
|
if state == "HAS" then return "ITEM_HAS" end
|
||||||
|
if state == "MISSING" then return "ITEM_MISSING" end
|
||||||
|
return "NEUTRAL"
|
||||||
|
end
|
||||||
|
if state == "HAS" then return "HAS" end
|
||||||
|
if state == "MISSING" then return "MISSING" end
|
||||||
|
if state == "UNKNOWN" then return "UNKNOWN" end
|
||||||
|
return "SKIP"
|
||||||
|
end,
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Whether ui/cellsheet.lua's baked sheet is actually available (as
|
||||||
|
-- opposed to the shim above, which never sets .SHEET). One seam shared by
|
||||||
|
-- W.MakeCell and W.SetCellBox, and directly testable offline without
|
||||||
|
-- CreateFrame (unlike W.MakeCell itself).
|
||||||
|
function W.HasCellSheet()
|
||||||
|
return DC_Cells ~= nil and DC_Cells.SHEET ~= nil
|
||||||
|
end
|
||||||
|
|
||||||
|
-- ------------------------------------------------------------------
|
||||||
|
-- Font helper (we keep OUR pixel
|
||||||
|
-- size -- the matrix layout depends on it -- and only take pfUI's font
|
||||||
|
-- FAMILY when pfUI is present; forcing pfUI's global font size would break
|
||||||
|
-- the 30x23 cell grid).
|
||||||
|
-- ------------------------------------------------------------------
|
||||||
|
function W.ApplyFont(fs, size)
|
||||||
|
size = size or 10
|
||||||
|
if pfUI and pfUI.font_default then
|
||||||
|
fs:SetFont(pfUI.font_default, size)
|
||||||
|
else
|
||||||
|
fs:SetFont(W.FONT, size)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
-- ------------------------------------------------------------------
|
||||||
|
-- Change-guarded setters (convention: cache last-applied value on the
|
||||||
|
-- region as dcLast*; only call the real setter when the value changed.
|
||||||
|
-- Color guards compare by TABLE REFERENCE -- callers always pass the
|
||||||
|
-- shared DC.COLORS / DC.QUALITY tables, so this is exact and zero-alloc).
|
||||||
|
-- ------------------------------------------------------------------
|
||||||
|
function W.SetTextG(fs, text)
|
||||||
|
if fs.dcLastText ~= text then
|
||||||
|
fs.dcLastText = text
|
||||||
|
fs:SetText(text)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
function W.SetTextColorG(fs, c)
|
||||||
|
if fs.dcLastColor ~= c then
|
||||||
|
fs.dcLastColor = c
|
||||||
|
fs:SetTextColor(c.r, c.g, c.b)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
function W.SetBackdropG(f, bg, border)
|
||||||
|
if f.dcLastBg ~= bg then
|
||||||
|
f.dcLastBg = bg
|
||||||
|
f:SetBackdropColor(bg.r, bg.g, bg.b, bg.a or 1)
|
||||||
|
end
|
||||||
|
if f.dcLastBorder ~= border then
|
||||||
|
f.dcLastBorder = border
|
||||||
|
f:SetBackdropBorderColor(border.r, border.g, border.b, 1)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
function W.SetTextureG(tex, path)
|
||||||
|
if tex.dcLastTex ~= path then
|
||||||
|
tex.dcLastTex = path
|
||||||
|
tex:SetTexture(path)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
function W.SetVertexG(tex, c)
|
||||||
|
if tex.dcLastVertex ~= c then
|
||||||
|
tex.dcLastVertex = c
|
||||||
|
tex:SetVertexColor(c.r, c.g, c.b)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
function W.SetAlphaG(region, a)
|
||||||
|
if region.dcLastAlpha ~= a then
|
||||||
|
region.dcLastAlpha = a
|
||||||
|
region:SetAlpha(a)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
-- ------------------------------------------------------------------
|
||||||
|
-- FontString factory
|
||||||
|
-- ------------------------------------------------------------------
|
||||||
|
function W.MakeText(parent, size, color, layer)
|
||||||
|
local fs = parent:CreateFontString(nil, layer or "OVERLAY")
|
||||||
|
W.ApplyFont(fs, size)
|
||||||
|
if color then
|
||||||
|
W.SetTextColorG(fs, color)
|
||||||
|
end
|
||||||
|
return fs
|
||||||
|
end
|
||||||
|
|
||||||
|
-- ------------------------------------------------------------------
|
||||||
|
-- Cell factory: 30x23 state cell (the 30x23 size is part of the fixed
|
||||||
|
-- visual layout). The box (background + 1px border) is one texture region
|
||||||
|
-- sampled from the baked sheet in ui/cellsheet.lua -- SetBackdrop with an
|
||||||
|
-- edgeFile costs NINE texture regions per cell, this costs one. The icon
|
||||||
|
-- (check/cross glyph OR trinket item icon) and FontString (?, skip dot,
|
||||||
|
-- trinket monogram) are created on demand via W.CellIcon / W.CellText;
|
||||||
|
-- the matrix paint code shows exactly one of them per state.
|
||||||
|
-- Pool children stay anonymous (documented factory exception).
|
||||||
|
-- ------------------------------------------------------------------
|
||||||
|
function W.MakeCell(parent, name, w, h)
|
||||||
|
local c = CreateFrame("Frame", name, parent)
|
||||||
|
c:SetWidth(w or 30)
|
||||||
|
c:SetHeight(h or 23)
|
||||||
|
c:EnableMouse(true)
|
||||||
|
c.dcW = w or 30
|
||||||
|
if W.HasCellSheet() then
|
||||||
|
-- The state box (background + 1px border) comes from one baked sheet:
|
||||||
|
-- SetBackdrop with an edgeFile costs NINE texture regions per cell,
|
||||||
|
-- this costs one. See ui/cellsheet.lua.
|
||||||
|
c.boxTex = c:CreateTexture(nil, "BACKGROUND")
|
||||||
|
c.boxTex:SetAllPoints(c)
|
||||||
|
c.boxTex:SetTexture(DC_Cells.SHEET.path)
|
||||||
|
-- Without an initial texcoord the region would show the WHOLE sheet
|
||||||
|
-- stretched across the cell until the first paint. Start on NEUTRAL.
|
||||||
|
local l, r, t, b = DC_Cells.Coords("NEUTRAL", c.dcW)
|
||||||
|
if l then
|
||||||
|
c.boxTex:SetTexCoord(l, r, t, b)
|
||||||
|
c.dcBoxVariant = "NEUTRAL"
|
||||||
|
c.dcBoxW = c.dcW
|
||||||
|
end
|
||||||
|
else
|
||||||
|
-- Fallback: ui/cellsheet.lua did not load this session (see
|
||||||
|
-- W.HasCellSheet above) -- pre-refactor SetBackdrop path (no
|
||||||
|
-- boxTex region at all) so the matrix still draws instead of
|
||||||
|
-- erroring on a nil DC_Cells.SHEET. W.SetBackdropG and
|
||||||
|
-- W.SOLID_BACKDROP are untouched by the sprite refactor, so this
|
||||||
|
-- is exactly what W.MakeCell did before it.
|
||||||
|
c:SetBackdrop(W.SOLID_BACKDROP)
|
||||||
|
W.SetCellBox(c, "NEUTRAL")
|
||||||
|
end
|
||||||
|
return c
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Icon and FontString on demand: a cell shows one or the other, and in 1.12
|
||||||
|
-- eagerly created regions cost even while hidden.
|
||||||
|
function W.CellIcon(cell)
|
||||||
|
if not cell.icon then
|
||||||
|
cell.icon = cell:CreateTexture(nil, "ARTWORK")
|
||||||
|
cell.icon:SetPoint("CENTER", cell, "CENTER", 0, 0)
|
||||||
|
cell.icon:SetWidth(16)
|
||||||
|
cell.icon:SetHeight(16)
|
||||||
|
cell.icon:Hide()
|
||||||
|
end
|
||||||
|
return cell.icon
|
||||||
|
end
|
||||||
|
|
||||||
|
function W.CellText(cell)
|
||||||
|
if not cell.text then
|
||||||
|
cell.text = cell:CreateFontString(nil, "OVERLAY")
|
||||||
|
W.ApplyFont(cell.text, 9)
|
||||||
|
cell.text:SetPoint("CENTER", cell, "CENTER", 0, 0)
|
||||||
|
end
|
||||||
|
return cell.text
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Guards on variant AND width (cell.dcBoxW, the width the box was last
|
||||||
|
-- painted at) -- NOT variant alone. ui/matrix.lua resizes a pooled cell in
|
||||||
|
-- place when its column width changes (trinket columns are 34, normal
|
||||||
|
-- cells 30: see ui/matrix.lua's column-layout path around SetWidth(col.w))
|
||||||
|
-- and only ever writes cell.dcW; it has no notion of dcBoxVariant and isn't
|
||||||
|
-- expected to invalidate it. If this guard only checked variant, a cell
|
||||||
|
-- that kept its variant across such a resize would keep a stale texcoord
|
||||||
|
-- sized for the OLD width -- the baked border would sit off the new cell
|
||||||
|
-- edge. Tracking dcBoxW here keeps the guard self-contained: correctness
|
||||||
|
-- does not depend on any caller convention.
|
||||||
|
function W.SetCellBox(cell, variant)
|
||||||
|
if not W.HasCellSheet() then
|
||||||
|
-- Fallback: same pre-refactor SetBackdrop recipe, colors pulled
|
||||||
|
-- LIVE from DC.COLORS/DC.QUALITY via W.CELL_FALLBACK_VARIANTS
|
||||||
|
-- above. Never touches cell.boxTex -- a fallback cell (see
|
||||||
|
-- W.MakeCell) does not have one. W.SetBackdropG already
|
||||||
|
-- change-guards by table reference, so no separate
|
||||||
|
-- dcBoxVariant/dcBoxW bookkeeping is needed here.
|
||||||
|
local v = W.CELL_FALLBACK_VARIANTS[variant]
|
||||||
|
if not v then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
local bg = DC.COLORS[v.bg]
|
||||||
|
local border = v.border
|
||||||
|
if type(border) == "number" then
|
||||||
|
border = DC.QUALITY[border]
|
||||||
|
else
|
||||||
|
border = DC.COLORS[border]
|
||||||
|
end
|
||||||
|
if not bg or not border then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
W.SetBackdropG(cell, bg, border)
|
||||||
|
return
|
||||||
|
end
|
||||||
|
if cell.dcBoxVariant == variant and cell.dcBoxW == cell.dcW then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
local l, r, t, b = DC_Cells.Coords(variant, cell.dcW)
|
||||||
|
if not l then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
cell.dcBoxVariant = variant
|
||||||
|
cell.dcBoxW = cell.dcW
|
||||||
|
cell.boxTex:SetTexCoord(l, r, t, b)
|
||||||
|
end
|
||||||
|
|
||||||
|
-- ------------------------------------------------------------------
|
||||||
|
-- Pill factory (ready pill, coverage pill): bordered mini frame with a
|
||||||
|
-- centered FontString; width is adjusted by the caller to fit the text.
|
||||||
|
-- ------------------------------------------------------------------
|
||||||
|
function W.MakePill(parent, name)
|
||||||
|
local p = CreateFrame("Frame", name, parent)
|
||||||
|
p:SetWidth(70)
|
||||||
|
p:SetHeight(16)
|
||||||
|
p:SetBackdrop(W.SOLID_BACKDROP)
|
||||||
|
p:EnableMouse(true)
|
||||||
|
p.text = p:CreateFontString(nil, "OVERLAY")
|
||||||
|
W.ApplyFont(p.text, 9)
|
||||||
|
p.text:SetPoint("CENTER", p, "CENTER", 0, 0)
|
||||||
|
return p
|
||||||
|
end
|
||||||
|
|
||||||
|
-- ------------------------------------------------------------------
|
||||||
|
-- Role badge factory: clickable bordered button with centered label.
|
||||||
|
-- Suggested roles render with a "?" suffix + dimmer border (matrix paints
|
||||||
|
-- that -- 1.12 has no dashed borders, see the adaptation note there).
|
||||||
|
-- ------------------------------------------------------------------
|
||||||
|
function W.MakeBadge(parent, name)
|
||||||
|
local b = CreateFrame("Button", name, parent)
|
||||||
|
b:SetWidth(56)
|
||||||
|
b:SetHeight(16)
|
||||||
|
b:SetBackdrop(W.SOLID_BACKDROP)
|
||||||
|
b.text = b:CreateFontString(nil, "OVERLAY")
|
||||||
|
W.ApplyFont(b.text, 8)
|
||||||
|
b.text:SetPoint("CENTER", b, "CENTER", 0, 0)
|
||||||
|
b:RegisterForClicks("LeftButtonUp")
|
||||||
|
return b
|
||||||
|
end
|
||||||
|
|
||||||
|
-- ------------------------------------------------------------------
|
||||||
|
-- Dark gold-trim button (titlebar Scan/Report/Options/X).
|
||||||
|
-- ------------------------------------------------------------------
|
||||||
|
function W.MakeButton(parent, name, label, width, height)
|
||||||
|
local C = DC.COLORS
|
||||||
|
local b = CreateFrame("Button", name, parent)
|
||||||
|
b:SetWidth(width or 64)
|
||||||
|
b:SetHeight(height or 19)
|
||||||
|
b:SetBackdrop(W.SOLID_BACKDROP)
|
||||||
|
W.SetBackdropG(b, C.panel2, C.goldSoft)
|
||||||
|
b.text = b:CreateFontString(nil, "OVERLAY")
|
||||||
|
W.ApplyFont(b.text, 10)
|
||||||
|
b.text:SetPoint("CENTER", b, "CENTER", 0, 0)
|
||||||
|
W.SetTextColorG(b.text, C.goldHi)
|
||||||
|
b.text:SetText(label)
|
||||||
|
b:SetScript("OnEnter", function()
|
||||||
|
W.SetBackdropG(this, C.panel3, C.gold)
|
||||||
|
end)
|
||||||
|
b:SetScript("OnLeave", function()
|
||||||
|
W.SetBackdropG(this, C.panel2, C.goldSoft)
|
||||||
|
end)
|
||||||
|
return b
|
||||||
|
end
|
||||||
|
|
||||||
|
-- ------------------------------------------------------------------
|
||||||
|
-- Tooltip helpers. Convention: the builder only ADDS
|
||||||
|
-- lines; AttachTooltip calls SetOwner before and Show after -- AddLine
|
||||||
|
-- never calls Show itself.
|
||||||
|
-- AttachTooltip CHAINS any existing OnEnter/OnLeave (no HookScript on
|
||||||
|
-- 1.12 -- standard save-and-call pattern).
|
||||||
|
-- ------------------------------------------------------------------
|
||||||
|
function W.TipLine(text, c, wrap)
|
||||||
|
if not GameTooltip then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
local r, g, b = 1, 1, 1
|
||||||
|
if c then
|
||||||
|
r, g, b = c.r, c.g, c.b
|
||||||
|
end
|
||||||
|
if wrap then
|
||||||
|
GameTooltip:AddLine(text, r, g, b, 1)
|
||||||
|
else
|
||||||
|
GameTooltip:AddLine(text, r, g, b)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Two-column tooltip line: label left, value flush RIGHT. This is what
|
||||||
|
-- makes a breakdown read like an addition on paper -- the values line up
|
||||||
|
-- in a column instead of trailing their labels at ragged positions. 1.12
|
||||||
|
-- has no monospace font and no tab stops; AddDoubleLine is the only way to
|
||||||
|
-- get a right-aligned column, and it exists in this client's FrameXML.
|
||||||
|
-- `cr` defaults to the label color when omitted.
|
||||||
|
function W.TipDouble(left, right, c, cr)
|
||||||
|
if not GameTooltip then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
local r, g, b = 1, 1, 1
|
||||||
|
if c then
|
||||||
|
r, g, b = c.r, c.g, c.b
|
||||||
|
end
|
||||||
|
local r2, g2, b2 = r, g, b
|
||||||
|
if cr then
|
||||||
|
r2, g2, b2 = cr.r, cr.g, cr.b
|
||||||
|
end
|
||||||
|
GameTooltip:AddDoubleLine(left, right, r, g, b, r2, g2, b2)
|
||||||
|
end
|
||||||
|
|
||||||
|
-- W.dcTipOwner: our own record of which of OUR frames currently owns
|
||||||
|
-- GameTooltip. WoW 1.12's GameTooltip has no "GetOwner" method (confirmed
|
||||||
|
-- against FrameXML-1.12.1: zero real hits -- the only "GetOwner" match
|
||||||
|
-- anywhere in that tree is the unrelated global function
|
||||||
|
-- GetOwnerAuctionItems). Every caller in this addon that ever needs "who
|
||||||
|
-- currently owns the tooltip" goes through W.AttachTooltip to open one in
|
||||||
|
-- the first place, so this is the single place that needs to record it.
|
||||||
|
-- Set right after SetOwner
|
||||||
|
-- (matching order: SetOwner, then the builder runs, then Show -- a builder
|
||||||
|
-- that itself re-SetOwner()s the SAME frame, like M.CellTooltip's
|
||||||
|
-- ANCHOR_LEFT override, does not change who owns it). Cleared in OnLeave,
|
||||||
|
-- guarded on identity so a late/out-of-order OnLeave can never clobber a
|
||||||
|
-- newer owner.
|
||||||
|
W.dcTipOwner = nil
|
||||||
|
|
||||||
|
function W.AttachTooltip(frame, builder, anchor)
|
||||||
|
frame.dcTipBuilder = builder
|
||||||
|
frame.dcTipAnchor = anchor or "ANCHOR_LEFT"
|
||||||
|
frame.dcOldEnter = frame:GetScript("OnEnter")
|
||||||
|
frame.dcOldLeave = frame:GetScript("OnLeave")
|
||||||
|
frame:SetScript("OnEnter", function()
|
||||||
|
if this.dcOldEnter then
|
||||||
|
this.dcOldEnter()
|
||||||
|
end
|
||||||
|
if GameTooltip and this.dcTipBuilder then
|
||||||
|
GameTooltip:SetOwner(this, this.dcTipAnchor)
|
||||||
|
W.dcTipOwner = this
|
||||||
|
this.dcTipBuilder(this)
|
||||||
|
GameTooltip:Show()
|
||||||
|
end
|
||||||
|
end)
|
||||||
|
frame:SetScript("OnLeave", function()
|
||||||
|
if this.dcOldLeave then
|
||||||
|
this.dcOldLeave()
|
||||||
|
end
|
||||||
|
if W.dcTipOwner == this then
|
||||||
|
W.dcTipOwner = nil
|
||||||
|
end
|
||||||
|
if GameTooltip then
|
||||||
|
GameTooltip:Hide()
|
||||||
|
end
|
||||||
|
end)
|
||||||
|
end
|
||||||