DopingControl v0.6.1

This commit is contained in:
2026-08-03 09:47:19 +02:00
commit 6859d73285
41 changed files with 15056 additions and 0 deletions
+208
View File
@@ -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 HASMISSINGUNKNOWN 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.
+32
View File
@@ -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
+257
View File
@@ -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.
+210
View File
@@ -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
+238
View File
@@ -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
}
+240
View File
@@ -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
+1740
View File
File diff suppressed because it is too large Load Diff
+534
View File
@@ -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
+247
View File
@@ -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
+1135
View File
File diff suppressed because it is too large Load Diff
+68
View File
@@ -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
+132
View File
@@ -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
+95
View File
@@ -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
},
}
+122
View File
@@ -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,
},
}
+319
View File
@@ -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 },
}
+139
View File
@@ -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,
}
+171
View File
@@ -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 } } },
},
}
+118
View File
@@ -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
+496
View File
@@ -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
+1103
View File
File diff suppressed because it is too large Load Diff
+222
View File
@@ -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
+1052
View File
File diff suppressed because it is too large Load Diff
+79
View File
@@ -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
+114
View File
@@ -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
+375
View File
@@ -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
Binary file not shown.

After

Width:  |  Height:  |  Size: 486 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 460 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 969 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 426 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 424 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 402 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 502 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 576 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.0 KiB

+3072
View File
File diff suppressed because it is too large Load Diff
+1845
View File
File diff suppressed because it is too large Load Diff
+420
View File
@@ -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
+273
View File
@@ -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