DopingControl v0.6.1
@@ -0,0 +1,208 @@
|
||||
# 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.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,32 @@
|
||||
## Interface: 11200
|
||||
## Title: DopingControl
|
||||
## Notes: Raid consumables, buffs, debuffs, resistances, hit and enchant checker
|
||||
## Version: 0.6.1
|
||||
## 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
|
||||
scan\talents.lua
|
||||
scan\reach.lua
|
||||
scan\aura.lua
|
||||
scan\gear.lua
|
||||
scan\resist.lua
|
||||
scan\hit.lua
|
||||
scan\engine.lua
|
||||
core\probe.lua
|
||||
ui\widgets.lua
|
||||
ui\matrix.lua
|
||||
ui\options.lua
|
||||
ui\report.lua
|
||||
features\minimap.lua
|
||||
core\init.lua
|
||||
@@ -0,0 +1,257 @@
|
||||
# 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.*
|
||||
|
||||
## 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 six 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.
|
||||
|
||||
**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.
|
||||
|
||||
## 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 six 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.
|
||||
|
||||
## Install
|
||||
|
||||
**Option A — download (simplest):**
|
||||
|
||||
1. Grab **`DopingControl-vX.Y.Z.zip`** from
|
||||
[**Releases**](https://github.com/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://github.com/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 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.
|
||||
|
||||
## Version history
|
||||
|
||||
See [CHANGELOG.md](CHANGELOG.md). The short form:
|
||||
|
||||
- **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.
|
||||
|
||||
## Licence
|
||||
|
||||
MIT.
|
||||
@@ -0,0 +1,210 @@
|
||||
-- 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
|
||||
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 = {},
|
||||
}
|
||||
|
||||
-- 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,238 @@
|
||||
-- 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.
|
||||
-- ------------------------------------------------------------------
|
||||
DC.TABS = { "CONSUMABLES", "CLASSBUFFS", "DEBUFFS", "RESIST", "HIT", "EQUIPMENT" }
|
||||
|
||||
DC.TAB_LABEL = {
|
||||
CONSUMABLES = "Consumables",
|
||||
CLASSBUFFS = "Class buffs",
|
||||
DEBUFFS = "Debuffs",
|
||||
RESIST = "Resistances",
|
||||
HIT = "Hit",
|
||||
EQUIPMENT = "Equipment",
|
||||
}
|
||||
|
||||
-- ------------------------------------------------------------------
|
||||
-- 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 },
|
||||
}
|
||||
|
||||
-- ------------------------------------------------------------------
|
||||
-- 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,240 @@
|
||||
-- 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()
|
||||
else
|
||||
DC.store = { players = {}, source = "scan" }
|
||||
DC_Scan.Start(true) -- leaving test mode: immediate real rescan
|
||||
end
|
||||
DC_Matrix.Refresh()
|
||||
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 | 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
|
||||
if db and db.testMode then
|
||||
-- real data never beats the simulation while test mode is on
|
||||
DC.Print("test mode is on - /dc test 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")
|
||||
else
|
||||
DC.Print("test mode OFF - rescanning the real group")
|
||||
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
|
||||
|
||||
elseif event == "RAID_ROSTER_UPDATE" or event == "PARTY_MEMBERS_CHANGED" then
|
||||
local db = DC.db
|
||||
if db and not db.testMode 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,68 @@
|
||||
-- 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
|
||||
@@ -0,0 +1,132 @@
|
||||
-- DopingControl data/consumables.lua
|
||||
-- Consumables tab: slot definitions + buff->slot table + spellID->slot map.
|
||||
-- Pure Lua 5.0, no WoW API -- dofile-loadable offline.
|
||||
|
||||
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).
|
||||
-- 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.
|
||||
-- ------------------------------------------------------------------
|
||||
DC.SLOTS_CONSUMABLES = {
|
||||
{ id = "FLASK", label = "FLK", sub = "Flask", full = "Flask", kind = "aura",
|
||||
items = "Flask of the Titans \194\183 Distilled Wisdom \194\183 Supreme Power" },
|
||||
{ id = "FOOD", label = "FOD", sub = "Food", full = "Food buff", kind = "aura",
|
||||
items = "Well Fed - e.g. Grilled Squid \194\183 Blessed Sunfruit" },
|
||||
{ id = "AP", label = "AP", sub = "Attack", full = "Attack power", kind = "aura",
|
||||
items = "Winterfall Firewater \194\183 Juju Might" },
|
||||
{ id = "STR", label = "STR", sub = "Str.", full = "Strength", kind = "aura",
|
||||
items = "Juju Power \194\183 Elixir of Giants" },
|
||||
{ id = "AGI", label = "AGI", sub = "Agi.", full = "Agility", kind = "aura",
|
||||
items = "Elixir of the Mongoose" },
|
||||
{ id = "SP", label = "SP", sub = "Spell", full = "Spell power", kind = "aura",
|
||||
items = "Greater Arcane Elixir \194\183 Shadow Power \194\183 Firepower" },
|
||||
{ id = "MP5", label = "MP5", sub = "Mana", full = "Mana regen", kind = "aura",
|
||||
items = "Mageblood Potion \194\183 Nightfin Soup" },
|
||||
{ id = "ARM", label = "ARM", sub = "Armor", full = "Armor", kind = "aura",
|
||||
items = "Elixir of Superior Defense" },
|
||||
{ id = "HP", label = "HP", sub = "Stam.", full = "Stamina/HP", kind = "aura",
|
||||
items = "Elixir of Fortitude \194\183 Rumsey Rum Black Label" },
|
||||
{ id = "FR", label = "FR", sub = "fire", full = "Fire protection", kind = "aura",
|
||||
items = "Greater Fire Protection Potion - situational per boss; enable/disable via the expectation matrix" },
|
||||
{ id = "WPN", label = "WPN", sub = "temp.", full = "Weapon (temporary)", kind = "weapon", -- NOT an aura slot
|
||||
items = "Dense Sharpening Stone \194\183 Brilliant Wizard/Mana Oil - permanent enchant: Equipment tab" },
|
||||
}
|
||||
|
||||
-- ------------------------------------------------------------------
|
||||
-- Buff name -> slot 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" },
|
||||
["Flask of Supreme Power"] = { slot = "FLASK", spellID = 17628, item = "Flask of Supreme Power" },
|
||||
["Flask of Distilled Wisdom"] = { slot = "FLASK", spellID = 17627, item = "Flask of Distilled Wisdom" },
|
||||
["Flask of Chromatic Resistance"] = { slot = "FLASK", spellID = 17629, item = "Flask of Chromatic Resistance" },
|
||||
["Flask of Petrification"] = { slot = "FLASK", spellID = 17624, item = "Flask of Petrification" },
|
||||
|
||||
-- FOOD (buff names, not item names; TWoW custom foods likely all show "Well Fed" - unverified)
|
||||
["Well Fed"] = { slot = "FOOD", spellID = 0, item = "Grilled Squid etc." },
|
||||
-- ["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.
|
||||
["Increased Stamina"] = { slot = "FOOD", spellID = 0, item = "stamina food" },
|
||||
["Dragonbreath Chili"] = { slot = "FOOD", spellID = 15852, item = "Dragonbreath Chili" },
|
||||
|
||||
-- AP
|
||||
["Winterfall Firewater"] = { slot = "AP", spellID = 17038, item = "Winterfall Firewater" },
|
||||
["Juju Might"] = { slot = "AP", spellID = 16329, item = "Juju Might" },
|
||||
|
||||
-- STR
|
||||
["Juju Power"] = { slot = "STR", spellID = 16323, item = "Juju Power" },
|
||||
["Elixir of Giants"] = { slot = "STR", spellID = 11405, item = "Elixir of Giants" },
|
||||
["Elixir of the Giants"] = { slot = "STR", spellID = 11405, item = "Elixir of Giants" }, -- 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" },
|
||||
["Strike of the Scorpok"] = { slot = "STR", spellID = 10669, item = "Strike of the Scorpok" }, -- measured live in a raid (26 players); was tallied as unknown
|
||||
["Rage of Ages"] = { slot = "STR", spellID = 0, item = "R.O.I.D.S." }, -- aura name differs from the item; own spell ID not measured yet
|
||||
|
||||
-- AGI
|
||||
["Elixir of the Mongoose"] = { slot = "AGI", spellID = 17538, item = "Elixir of the Mongoose" },
|
||||
["Elixir of Greater Agility"] = { slot = "AGI", spellID = 11334, item = "Elixir of Greater Agility" },
|
||||
["Greater Agility"] = { slot = "AGI", spellID = 11334, item = "Elixir of Greater Agility" }, -- 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" },
|
||||
|
||||
-- SP (merged: arcane_elixir + school_elixir + dreamtonic + dreamshard)
|
||||
["Greater Arcane Elixir"] = { slot = "SP", spellID = 17539, item = "Greater Arcane Elixir" },
|
||||
["Arcane Elixir"] = { slot = "SP", spellID = 11390, item = "Arcane Elixir" },
|
||||
["Elixir of Shadow Power"] = { slot = "SP", spellID = 11474, item = "Elixir of Shadow Power" },
|
||||
["Elixir of Frost Power"] = { slot = "SP", spellID = 21920, item = "Elixir of Frost Power" },
|
||||
["Greater Frost Power"] = { slot = "SP", spellID = 56544, item = "Elixir of Greater Frost Power" }, -- AURA name (TurtleWoW-added). NOT measured in-game: taken from a TurtleWoW spell database (56544 = "Greater Frost Power", "Increases frost spell damage by up to 40 for 3600 sec.") 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 = "SP", spellID = 26276, item = "Elixir of Greater Firepower" },
|
||||
["Elixir of Firepower"] = { slot = "SP", spellID = 7844, item = "Elixir of Firepower" },
|
||||
["Dreamtonic"] = { slot = "SP", spellID = 45489, item = "Dreamtonic" }, -- use-spell ID; the aura's own ID is unverified
|
||||
["Dreamshard Elixir"] = { slot = "SP", spellID = 45427, item = "Dreamshard Elixir" }, -- use-spell ID; the aura's own ID is unverified
|
||||
|
||||
-- MP5
|
||||
["Mageblood Potion"] = { slot = "MP5", spellID = 24363, item = "Mageblood Potion" },
|
||||
["Mageblood"] = { slot = "MP5", spellID = 24363, item = "Mageblood Potion" }, -- TWoW buff-name variant (old Data.lua kept both)
|
||||
|
||||
-- ARM
|
||||
["Elixir of Superior Defense"] = { slot = "ARM", spellID = 11348, item = "Elixir of Superior Defense" },
|
||||
["Elixir of Greater Defense"] = { slot = "ARM", spellID = 11349, item = "Elixir of Greater Defense" },
|
||||
|
||||
-- HP (merged: def_fortitude + alcohol STA variants)
|
||||
["Elixir of Fortitude"] = { slot = "HP", spellID = 3593, item = "Elixir of Fortitude" },
|
||||
["Health II"] = { slot = "HP", spellID = 3593, item = "Elixir of Fortitude" }, -- AURA name on this client (measured live: SpellInfo(3593) = "Health II")
|
||||
["Rumsey Rum Black Label"] = { slot = "HP", spellID = 25804, item = "Rumsey Rum Black Label" },
|
||||
["Rumsey Rum"] = { slot = "HP", spellID = 0, item = "Rumsey Rum" },
|
||||
["Rumsey Rum Light"] = { slot = "HP", spellID = 0, item = "Rumsey Rum Light" },
|
||||
["Medivh's Merlot"] = { slot = "HP", spellID = 57106, item = "Medivh's Merlot" }, -- use-spell ID; the aura's own ID is unverified
|
||||
["Gordok Green Grog"] = { slot = "HP", spellID = 0, item = "Gordok Green Grog" },
|
||||
|
||||
-- FR
|
||||
["Greater Fire Protection"] = { slot = "FR", spellID = 17543, item = "Greater Fire Protection Potion" },
|
||||
["Fire Protection"] = { slot = "FR", spellID = 7233, item = "Fire Protection Potion" },
|
||||
|
||||
-- WPN: intentionally NO aura entries - temp enchant is not a buff
|
||||
}
|
||||
|
||||
-- ------------------------------------------------------------------
|
||||
-- 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,95 @@
|
||||
-- DopingControl data/enchants.lua
|
||||
-- Equipment tab: 14 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 and both rings ARE enchantable on this server (a
|
||||
-- server-custom feature beyond vanilla WoW, where they take no enchant) --
|
||||
-- NECK/R1/R2 are therefore kind="ench" like any armor slot, not item
|
||||
-- tiles. Their expectation defaults to OFF (data/expectations.lua): unlike
|
||||
-- the ARMOR enchant slots, a neck/ring enchant is not assumed baseline
|
||||
-- raid gear, so the columns only track it once a role/class opts in via
|
||||
-- the options grid.
|
||||
-- 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 = "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
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
-- 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: standard raid role lines.
|
||||
-- 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;
|
||||
-- per-item exemptions (wand/holdable) are handled at
|
||||
-- classify time via DC.ENCH_EXEMPT, not here.
|
||||
-- NECK/R1/R2 (enchantable on this server, a custom feature
|
||||
-- beyond vanilla) default to OFF for every role: unlike
|
||||
-- the armor slots, 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: some tank consumable lists include Winterfall
|
||||
-- Firewater; the default has no AP for TANK.
|
||||
-- * MELEE+FLASK: some melee lists include Flask of the Titans; the
|
||||
-- default has no FLASK for MELEE.
|
||||
-- * MELEE/RANGED+HP: some melee lists have neither Fortitude nor
|
||||
-- alcohol; HP=true is the weakest row of the matrix.
|
||||
-- * RANGED+AP/WPN: Firewater/Juju Might are MELEE attack power; ranged
|
||||
-- AP comes from food. Arguably AP=false, WPN=false
|
||||
-- for RANGED; the default says true.
|
||||
-- * HEALER+SP: healer sets may carry Dreamshard (+healing), which the
|
||||
-- 11-slot merge puts into SP; the default has no SP for
|
||||
-- HEALER -> Dreamshard is invisible for healers by
|
||||
-- default.
|
||||
-- * 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
|
||||
FLASK = true, FOOD = true, STR = true, AGI = true, ARM = true,
|
||||
HP = 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,
|
||||
},
|
||||
MELEE = {
|
||||
-- consumables
|
||||
FOOD = true, AP = true, STR = true, AGI = true,
|
||||
HP = 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,
|
||||
},
|
||||
RANGED = {
|
||||
-- consumables
|
||||
FOOD = true, AP = true, AGI = true,
|
||||
HP = 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,
|
||||
RNGD = true,
|
||||
},
|
||||
-- CASTER/HEALER: RNGD also dropped by role default (their classes are
|
||||
-- additionally exempt via ENCH_CLASS_EXEMPT - belt and suspenders)
|
||||
CASTER = {
|
||||
-- consumables
|
||||
FLASK = true, FOOD = true, SP = true, MP5 = 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,
|
||||
},
|
||||
HEALER = {
|
||||
-- consumables
|
||||
FLASK = true, FOOD = true, MP5 = true,
|
||||
HP = 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,
|
||||
},
|
||||
}
|
||||
@@ -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,496 @@
|
||||
-- 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.
|
||||
-- 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 } (path A; texture is the 1st
|
||||
-- UnitBuff return, 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 } }
|
||||
-- (store shape; textures 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 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
|
||||
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 },
|
||||
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
|
||||
|
||||
-- raises an error on bad units -> always called through pcall
|
||||
local function tipBuffName(unit, buffIndex)
|
||||
scanTip:ClearLines()
|
||||
scanTip:SetUnitBuff(unit, buffIndex)
|
||||
local textObj = getglobal("DopingControlScanTipTextLeft1")
|
||||
if textObj then
|
||||
return textObj:GetText()
|
||||
end
|
||||
return nil
|
||||
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 = nil
|
||||
if tip then
|
||||
local ok2, nm = pcall(tipBuffName, unit, i)
|
||||
if ok2 then
|
||||
name = nm
|
||||
end
|
||||
end
|
||||
local id = nil
|
||||
if type(auraID) == "number" and auraID > 0 then
|
||||
id = auraID
|
||||
end
|
||||
-- texture (UnitBuff return 1) rides along so Assemble can key
|
||||
-- it under the tooltip name (auras.textures)
|
||||
table.insert(rawA, { name = name, id = id, texture = texture })
|
||||
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, 9, 10, 7, 8, 16, 17, 18, 11, 12, 13, 14 }
|
||||
|
||||
-- InvSlots() -> array of the 16 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,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,375 @@
|
||||
-- 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
|
||||
|
||||
-- ==================================================================
|
||||
-- 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> }
|
||||
function T.NewAcc()
|
||||
return { ranks = {}, spent = 0, sumRank = 0, trees = 0, complete = false }
|
||||
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
|
||||
|
||||
-- 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
|
||||
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 a = acc[sender]
|
||||
if not a then
|
||||
a = T.NewAcc()
|
||||
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.
|
||||
local elapsed = 0
|
||||
f:SetScript("OnUpdate", function()
|
||||
elapsed = elapsed + (arg1 or 0)
|
||||
if elapsed < 1 then
|
||||
return
|
||||
end
|
||||
elapsed = 0
|
||||
if not enabled() or table.getn(queue) == 0 then
|
||||
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
|
||||
end)
|
||||
|
||||
-- forget everything (respec, or a deliberate re-read)
|
||||
function T.Clear()
|
||||
acc = {}
|
||||
ranks = {}
|
||||
readAt = {}
|
||||
asked = {}
|
||||
queue = {}
|
||||
queued = {}
|
||||
lastAny = nil
|
||||
end
|
||||
|
||||
end
|
||||
|
After Width: | Height: | Size: 486 KiB |
|
After Width: | Height: | Size: 460 KiB |
|
After Width: | Height: | Size: 969 KiB |
|
After Width: | Height: | Size: 26 KiB |
|
After Width: | Height: | Size: 49 KiB |
|
After Width: | Height: | Size: 426 KiB |
|
After Width: | Height: | Size: 424 KiB |
|
After Width: | Height: | Size: 402 KiB |
|
After Width: | Height: | Size: 502 KiB |
|
After Width: | Height: | Size: 576 KiB |
|
After Width: | Height: | Size: 64 KiB |
|
After Width: | Height: | Size: 8.0 KiB |
@@ -0,0 +1,420 @@
|
||||
-- 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
|
||||
|
||||
-- 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"
|
||||
local expect = (dbt and dbt.expectations) or DC.DEFAULT_EXPECT or {}
|
||||
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(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,273 @@
|
||||
-- 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 }
|
||||
|
||||
-- ------------------------------------------------------------------
|
||||
-- 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). Carries one texture region (check/cross glyph OR trinket
|
||||
-- item icon) and one FontString (?, skip dot, trinket monogram); 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:SetBackdrop(W.SOLID_BACKDROP)
|
||||
c:EnableMouse(true)
|
||||
c.icon = c:CreateTexture(nil, "ARTWORK")
|
||||
c.icon:SetPoint("CENTER", c, "CENTER", 0, 0)
|
||||
c.icon:SetWidth(16)
|
||||
c.icon:SetHeight(16)
|
||||
c.icon:Hide()
|
||||
c.text = c:CreateFontString(nil, "OVERLAY")
|
||||
W.ApplyFont(c.text, 9)
|
||||
c.text:SetPoint("CENTER", c, "CENTER", 0, 0)
|
||||
return c
|
||||
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
|
||||
|
||||
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)
|
||||
this.dcTipBuilder(this)
|
||||
GameTooltip:Show()
|
||||
end
|
||||
end)
|
||||
frame:SetScript("OnLeave", function()
|
||||
if this.dcOldLeave then
|
||||
this.dcOldLeave()
|
||||
end
|
||||
if GameTooltip then
|
||||
GameTooltip:Hide()
|
||||
end
|
||||
end)
|
||||
end
|
||||