Add BigBrother.

This commit is contained in:
Casper V. Kristensen 2020-07-10 03:56:08 +02:00
parent 63832a3d8f
commit fd14622a36
Signed by: caspervk
GPG key ID: 289CA03790535054
12 changed files with 702 additions and 0 deletions

41
BigBrother/BigBrother.lua Normal file
View file

@ -0,0 +1,41 @@
BigBrother = {}
BigBrother.title = GetAddOnMetadata("BigBrother", "Title")
BigBrother.version = GetAddOnMetadata("BigBrother", "Version")
BigBrother.author = GetAddOnMetadata("BigBrother", "Author")
BigBrother.Items = {}
print(("Loaded %s v%s by %s."):format(BigBrother.title, BigBrother.version, BigBrother.author))
BigBrotherDB = {} -- default value, will be overwritten by the game if persistent data exists
local frame = CreateFrame("FRAME")
frame:RegisterEvent("ENCOUNTER_START")
frame:SetScript("OnEvent", function(self, event, encounterID, encounterName, difficultyID, groupSize)
if not IsInRaid() then
return
end
local classBuffs = {}
for className, _ in pairs(RAID_CLASS_COLORS) do
classBuffs[className] = {}
end
for m = 1, GetNumGroupMembers() do
local unit = "raid" .. m
local unitBuffs = {}
for b = 1, 40 do
_, _, _, _, _, _, _, _, _, spellId = UnitBuff(unit, b)
if not spellId then
break
end
unitBuffs[b] = spellId
end
local _, className = UnitClass(unit)
classBuffs[className][UnitName(unit)] = unitBuffs
end
table.insert(BigBrotherDB, {
date = date("%F %T"),
zone = GetRealZoneText(),
encounterID = encounterID,
encounterName = encounterName,
buffs = classBuffs,
})
end)

12
BigBrother/BigBrother.toc Normal file
View file

@ -0,0 +1,12 @@
## Interface: 11305
## Title: BigBrother
## Notes: Log raid buffs and consumables.
## Author: Caspervk
## Version: 0.0.1
## X-License: GNU General Public License v3 or later (GPLv3+)
##SavedVariables: BigBrotherDB
BigBrother.lua

0
BigBrother/README.md Normal file
View file

View file

@ -0,0 +1,4 @@
__version__ = "0.0.1"
__author__ = "Casper V. Kristensen"
__licence__ = "GPLv3"
__url__ = "https://git.caspervk.net/caspervk/wow-addons"

View file

@ -0,0 +1,4 @@
if __name__ == "__main__":
from .cli import main
main()

69
BigBrother/bb/cli.py Normal file
View file

@ -0,0 +1,69 @@
import argparse
import itertools
import logging
import logging.config
from pathlib import Path
from typing import List
from . import __url__, __author__, __version__, config, graph
from .slpp import slpp as lua
logger = logging.getLogger(__name__)
class CLI:
def __init__(self) -> None:
super().__init__()
def parse_args(self):
parser = argparse.ArgumentParser(
prog="bb",
description=f"BigBrother v{__version__} by {__author__}.",
epilog=f"For more information, see <{__url__}>."
)
parser.add_argument(
dest="saved_variables",
type=lambda s: Path(s),
help="Path to WTF/Account/<ACCOUNT>/SavedVariables/BigBrother.lua",
)
parser.add_argument(
"-v", "--verbose",
action="count",
default=0,
help="Increase verbosity level. Can be used multiple times."
)
return parser.parse_args()
def select_raid(self, saved_variables: Path) -> List[dict]:
text = saved_variables.read_text(encoding="utf8")
db = lua.decode(f"{{{text}}}") # double-{} since slpp requires all variables to be part of a lua table
raids = []
for zone, encounters in itertools.groupby(db["BigBrotherDB"], key=lambda x: (x["zone"], x["date"][:10])):
raids.append(list(encounters))
raids.reverse() # ensure latest raid is first
for i, raid in enumerate(raids, start=1):
print(f"{i:3} {raid[0]['date']} {raid[0]['zone']}")
choice = int(input("Raid: "))
return raids[choice-1]
def prompt_delete_saved_variables(self, saved_variables: Path) -> None:
if input("Delete saved variables BigBrother.lua? [y/N] ").lower() == "y":
saved_variables.unlink()
logger.info("%s deleted.", saved_variables)
def run(self) -> None:
args = self.parse_args()
logging.config.dictConfig(config.get_logging_config(level=("WARNING", "INFO", "DEBUG")[min(args.verbose, 2)]))
logger.debug("Args: %s", args)
raid = self.select_raid(args.saved_variables)
graph.graph(raid)
self.prompt_delete_saved_variables(args.saved_variables)
def main():
cli = CLI()
cli.run()
if __name__ == '__main__':
main()

View file

@ -0,0 +1,36 @@
import logging
from pathlib import Path
import yaml
logger = logging.getLogger(__name__)
here = Path(__file__).parent.resolve()
buffs = yaml.safe_load(here.joinpath("buffs.yaml").read_text("utf8"))
class_overrides = yaml.safe_load(here.joinpath("class_overrides.yaml").read_text("utf8"))
def get_logging_config(level="WARNING") -> dict:
return {
"version": 1,
"disable_existing_loggers": False,
"formatters": {
"standard": {
"format": "%(asctime)s [%(levelname)-7s] %(name)s:%(funcName)s - %(message)s"
}
},
"handlers": {
"console": {
"class": "logging.StreamHandler",
"stream": "ext://sys.stdout",
"formatter": "standard",
"level": level,
}
},
"loggers": {
"bb": {
"level": "DEBUG",
"handlers": ["console"]
}
},
}

View file

@ -0,0 +1,131 @@
---
# 15366 Songflower
Dragonslayer:
color: "e20a00"
ids:
- 22888
classes:
- ALL
Zandalar:
color: "41ca46"
ids:
- 24425
classes:
- ALL
DM:T:
color: "016275"
ids:
- 22817 # Fengus' Ferocity
- 22820 # Slip'kik's Savvy
- 22818 # Mol'dar's Moxie
classes:
- ALL
Zanza:
color: "f07cf7"
ids:
- 24382 # Spirit of Zanza
- 20081 # Swiftness of Zanza
- 20080 # Sheen of Zanza
classes:
- ALL
Alchohol:
color: "b99863"
ids:
- 22790 # Kreeg's Stout Beatdown
- 22789 # Gordok Green Grog
- 25804 # Rumsey Rum Black Label
classes:
- Druid
- Priest
- Tank Warrior
Food:
color: "f6abaf"
ids:
- 18194 # Nightfin Soup
- 24799 # Smoked Desert Dumplings
- 18192 # Grilled Squid
- 18125 # Blessed Sunfruit
- 19710 # Tender Wolf Steak
- 22730 # Runn Tum Tuber Surprise
classes:
- ALL
Mageblood:
color: "550c65"
ids:
- 24363
classes:
- Balance Druid
- Druid
- Paladin
- Priest
Strength:
color: "d5bb33"
ids:
- 11405 # Elixir of the Giants
- 16323 # Juju Power
classes:
- Feral Druid
- Retribution Paladin
- Rogue
- Warrior
- Tank Warrior
Mongoose:
color: "5e66e5"
ids:
- 17538
classes:
- Feral Druid
- Hunter
- Retribution Paladin
- Rogue
- Warrior
- Tank Warrior
Arcane:
color: "9a39ad"
ids:
- 17539
classes:
- Balance Druid
- Mage
- Shadow Priest
- Warlock
Shadow:
color: "510164"
ids:
- 11474
classes:
- Shadow Priest
- Warlock
Frost:
color: "60d0fe"
ids:
- 21920
classes:
- Mage
Arthas:
color: "2bddc7"
ids:
- 11374
classes:
- Tank Warrior
Defense:
color: "0a0a0a"
ids:
- 11348
classes:
- Tank Warrior

View file

@ -0,0 +1,10 @@
---
# Needed since Classic API doesn't support talent specialisation inspection
Ralfe: Balance Druid
Ezeriel: Feral Druid
Zyalar: Feral Druid
Borahk: Retribution Paladin
Loodt: Shadow Priest
Corten: Tank Warrior
Boldus: Tank Warrior
Exas: Tank Warrior

111
BigBrother/bb/graph.py Normal file
View file

@ -0,0 +1,111 @@
import base64
import io
from collections import defaultdict
from datetime import datetime, timedelta
from typing import List
import matplotlib.pyplot as plt
import numpy as np
from . import config
def graph(raid: List[dict]) -> None:
figs = []
buff_ids = {
buff_id: buff_name
for buff_name, buff_data in config.buffs.items()
for buff_id in buff_data["ids"]
}
classes = defaultdict(lambda: defaultdict(lambda: defaultdict(list)))
xticks = []
first_encounter_date = None
last_encounter_date = None
previous_encounter_date = None
for encounter in raid:
encounter_date = datetime.strptime(encounter["date"], "%Y-%m-%d %H:%M:%S")
if first_encounter_date is None:
first_encounter_date = encounter_date
previous_encounter_date = encounter_date - timedelta(minutes=5)
last_encounter_date = encounter_date
xticks.append((encounter_date, encounter["encounterName"]))
for class_name, players in encounter["buffs"].items():
for player_name, buffs in sorted(players.items()):
real_class_name = config.class_overrides.get(player_name, str.title(class_name))
for buff_id in buffs:
try:
buff_name = buff_ids[buff_id]
except KeyError:
continue # buff not tracked
classes[real_class_name][player_name][buff_name].append(
(previous_encounter_date, (encounter_date - previous_encounter_date))
)
previous_encounter_date = encounter_date
for class_name, players in sorted(classes.items()):
fig, ax = plt.subplots()
required_class_buffs = {
buff_name: buff_data
for buff_name, buff_data in config.buffs.items()
if any(x in buff_data["classes"] for x in ("ALL", class_name))
}
#legend_handles = []
bar_width = 1 / (len(required_class_buffs) + 1)
for b, (buff_name, buff) in enumerate(required_class_buffs.items()):
bar_color = "#{color}".format(**buff)
#legend_handles.append(Patch(label=buff_name, color=bar_color))
for y0, (player_name, player_buffs) in zip(np.arange(len(players)), players.items()):
ax.broken_barh(
player_buffs[buff_name],
(bar_width/2 + y0 + b * bar_width, bar_width),
color=bar_color
)
annotation_color = "black"
try:
if player_buffs[buff_name][1][0] == first_encounter_date:
annotation_color = contrast_color(*tuple(int(buff["color"][i:i+2], 16) for i in (0, 2, 4)))
except IndexError:
pass
ax.annotate(
f" {buff_name}",
xy=(first_encounter_date, bar_width + y0 + b * bar_width),
ha="left",
va="center",
color=annotation_color
)
for y0 in np.arange(len(players)):
ax.axhline(y=y0, linewidth=1.0, color="black")
plt.xticks(*zip(*xticks), rotation=30, ha="right")
plt.yticks(
[x for x in np.arange(len(players))],
players
)
ax.invert_yaxis()
ax.grid(axis="x", linestyle="--")
ax.set_xlim(first_encounter_date - timedelta(minutes=10), last_encounter_date + timedelta(minutes=1))
plt.title(class_name, fontweight="bold", fontsize=16)
#plt.legend(handles=legend_handles, bbox_to_anchor=(1, 1))
fig.set_size_inches(15, 2*len(players))
plt.tight_layout()
#plt.show()
buffer = io.BytesIO()
fig.savefig(buffer, format="svg")
figs.append(base64.b64encode(buffer.getbuffer()).decode("ascii"))
with open("{date:10.10}-{zone}.html".format(**raid[0]), "w") as f:
f.write("<html>")
f.writelines(
f"<img src='data:image/svg+xml;base64,{fig}'/>"
for fig in figs
)
f.write("</html>")
def contrast_color(r, g, b):
luma = ((0.299 * r) + (0.587 * g) + (0.114 * b)) / 255
return "black" if luma > 0.5 else "white"

View file

@ -0,0 +1,3 @@
pyyaml
matplotlib
numpy

281
BigBrother/bb/slpp.py Normal file
View file

@ -0,0 +1,281 @@
"""
Simple lua-python data structures parser from https://github.com/SirAnthony/slpp (b947496 @ 28-04-2020).
Copyright (c) 2010, 2011, 2012 SirAnthony <anthony at adsorbtion.org>
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
"""
import re
import sys
from numbers import Number
ERRORS = {
'unexp_end_string': u'Unexpected end of string while parsing Lua string.',
'unexp_end_table': u'Unexpected end of table while parsing Lua string.',
'mfnumber_minus': u'Malformed number (no digits after initial minus).',
'mfnumber_dec_point': u'Malformed number (no digits after decimal point).',
'mfnumber_sci': u'Malformed number (bad scientific format).',
}
def sequential(lst):
length = len(lst)
if length == 0 or lst[0] != 0:
return False
for i in range(length):
if i + 1 < length:
if lst[i] + 1 != lst[i+1]:
return False
return True
class ParseError(Exception):
pass
class SLPP(object):
def __init__(self):
self.text = ''
self.ch = ''
self.at = 0
self.len = 0
self.depth = 0
self.space = re.compile('\s', re.M)
self.alnum = re.compile('\w', re.M)
self.newline = '\n'
self.tab = '\t'
def decode(self, text):
if not text or not isinstance(text, str):
return
# FIXME: only short comments removed
reg = re.compile('--.*$', re.M)
text = reg.sub('', text, 0)
self.text = text
self.at, self.ch, self.depth = 0, '', 0
self.len = len(text)
self.next_chr()
result = self.value()
return result
def encode(self, obj):
self.depth = 0
return self.__encode(obj)
def __encode(self, obj):
s = ''
tab = self.tab
newline = self.newline
if isinstance(obj, str):
s += '"%s"' % obj.replace(r'"', r'\"')
elif isinstance(obj, bytes):
s += '"{}"'.format(''.join(r'\x{:02x}'.format(c) for c in obj))
elif isinstance(obj, bool):
s += str(obj).lower()
elif obj is None:
s += 'nil'
elif isinstance(obj, Number):
s += str(obj)
elif isinstance(obj, (list, tuple, dict)):
self.depth += 1
if len(obj) == 0 or (not isinstance(obj, dict) and len([
x for x in obj
if isinstance(x, Number) or (isinstance(x, str) and len(x) < 10)
]) == len(obj)):
newline = tab = ''
dp = tab * self.depth
s += "%s{%s" % (tab * (self.depth - 2), newline)
if isinstance(obj, dict):
key = '[%s]' if all(isinstance(k, int) for k in obj.keys()) else '%s'
contents = [dp + (key + ' = %s') % (k, self.__encode(v)) for k, v in obj.items()]
s += (',%s' % newline).join(contents)
else:
s += (',%s' % newline).join(
[dp + self.__encode(el) for el in obj])
self.depth -= 1
s += "%s%s}" % (newline, tab * self.depth)
return s
def white(self):
while self.ch:
if self.space.match(self.ch):
self.next_chr()
else:
break
def next_chr(self):
if self.at >= self.len:
self.ch = None
return None
self.ch = self.text[self.at]
self.at += 1
return True
def value(self):
self.white()
if not self.ch:
return
if self.ch == '{':
return self.object()
if self.ch == "[":
self.next_chr()
if self.ch in ['"', "'", '[']:
return self.string(self.ch)
if self.ch.isdigit() or self.ch == '-':
return self.number()
return self.word()
def string(self, end=None):
s = ''
start = self.ch
if end == '[':
end = ']'
if start in ['"', "'", '[']:
while self.next_chr():
if self.ch == end:
self.next_chr()
if start != "[" or self.ch == ']':
return s
if self.ch == '\\' and start == end:
self.next_chr()
if self.ch != end:
s += '\\'
s += self.ch
raise ParseError(ERRORS['unexp_end_string'])
def object(self):
o = {}
k = None
idx = 0
numeric_keys = False
self.depth += 1
self.next_chr()
self.white()
if self.ch and self.ch == '}':
self.depth -= 1
self.next_chr()
return o # Exit here
else:
while self.ch:
self.white()
if self.ch == '{':
o[idx] = self.object()
idx += 1
continue
elif self.ch == '}':
self.depth -= 1
self.next_chr()
if k is not None:
o[idx] = k
if len([key for key in o if isinstance(key, (str, float, bool, tuple))]) == 0:
so = sorted([key for key in o])
if sequential(so):
ar = []
for key in o:
ar.insert(key, o[key])
o = ar
return o # or here
else:
if self.ch == ',':
self.next_chr()
continue
else:
k = self.value()
if self.ch == ']':
self.next_chr()
self.white()
ch = self.ch
if ch in ('=', ','):
self.next_chr()
self.white()
if ch == '=':
o[k] = self.value()
else:
o[idx] = k
idx += 1
k = None
raise ParseError(ERRORS['unexp_end_table']) # Bad exit here
words = {'true': True, 'false': False, 'nil': None}
def word(self):
s = ''
if self.ch != '\n':
s = self.ch
self.next_chr()
while self.ch is not None and self.alnum.match(self.ch) and s not in self.words:
s += self.ch
self.next_chr()
return self.words.get(s, s)
def number(self):
def next_digit(err):
n = self.ch
self.next_chr()
if not self.ch or not self.ch.isdigit():
raise ParseError(err)
return n
n = ''
try:
if self.ch == '-':
n += next_digit(ERRORS['mfnumber_minus'])
n += self.digit()
if n == '0' and self.ch in ['x', 'X']:
n += self.ch
self.next_chr()
n += self.hex()
else:
if self.ch and self.ch == '.':
n += next_digit(ERRORS['mfnumber_dec_point'])
n += self.digit()
if self.ch and self.ch in ['e', 'E']:
n += self.ch
self.next_chr()
if not self.ch or self.ch not in ('+', '-'):
raise ParseError(ERRORS['mfnumber_sci'])
n += next_digit(ERRORS['mfnumber_sci'])
n += self.digit()
except ParseError:
t, e = sys.exc_info()[:2]
print(e)
return 0
try:
return int(n, 0)
except:
pass
return float(n)
def digit(self):
n = ''
while self.ch and self.ch.isdigit():
n += self.ch
self.next_chr()
return n
def hex(self):
n = ''
while self.ch and (self.ch in 'ABCDEFabcdef' or self.ch.isdigit()):
n += self.ch
self.next_chr()
return n
slpp = SLPP()
__all__ = ['slpp']