From fd14622a36cd0b925ff455e9e45754f2bee90e49 Mon Sep 17 00:00:00 2001 From: "Casper V. Kristensen" Date: Fri, 10 Jul 2020 03:56:08 +0200 Subject: [PATCH] Add BigBrother. --- BigBrother/BigBrother.lua | 41 ++++ BigBrother/BigBrother.toc | 12 + BigBrother/README.md | 0 BigBrother/bb/__init__.py | 4 + BigBrother/bb/__main__.py | 4 + BigBrother/bb/cli.py | 69 ++++++ BigBrother/bb/config/__init__.py | 36 +++ BigBrother/bb/config/buffs.yaml | 131 ++++++++++ BigBrother/bb/config/class_overrides.yaml | 10 + BigBrother/bb/graph.py | 111 +++++++++ BigBrother/bb/requirements.txt | 3 + BigBrother/bb/slpp.py | 281 ++++++++++++++++++++++ 12 files changed, 702 insertions(+) create mode 100644 BigBrother/BigBrother.lua create mode 100644 BigBrother/BigBrother.toc create mode 100644 BigBrother/README.md create mode 100644 BigBrother/bb/__init__.py create mode 100644 BigBrother/bb/__main__.py create mode 100644 BigBrother/bb/cli.py create mode 100644 BigBrother/bb/config/__init__.py create mode 100644 BigBrother/bb/config/buffs.yaml create mode 100644 BigBrother/bb/config/class_overrides.yaml create mode 100644 BigBrother/bb/graph.py create mode 100644 BigBrother/bb/requirements.txt create mode 100644 BigBrother/bb/slpp.py diff --git a/BigBrother/BigBrother.lua b/BigBrother/BigBrother.lua new file mode 100644 index 0000000..18a13db --- /dev/null +++ b/BigBrother/BigBrother.lua @@ -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) diff --git a/BigBrother/BigBrother.toc b/BigBrother/BigBrother.toc new file mode 100644 index 0000000..8d5e4f3 --- /dev/null +++ b/BigBrother/BigBrother.toc @@ -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 diff --git a/BigBrother/README.md b/BigBrother/README.md new file mode 100644 index 0000000..e69de29 diff --git a/BigBrother/bb/__init__.py b/BigBrother/bb/__init__.py new file mode 100644 index 0000000..1874a8b --- /dev/null +++ b/BigBrother/bb/__init__.py @@ -0,0 +1,4 @@ +__version__ = "0.0.1" +__author__ = "Casper V. Kristensen" +__licence__ = "GPLv3" +__url__ = "https://git.caspervk.net/caspervk/wow-addons" diff --git a/BigBrother/bb/__main__.py b/BigBrother/bb/__main__.py new file mode 100644 index 0000000..710a2d1 --- /dev/null +++ b/BigBrother/bb/__main__.py @@ -0,0 +1,4 @@ +if __name__ == "__main__": + from .cli import main + + main() diff --git a/BigBrother/bb/cli.py b/BigBrother/bb/cli.py new file mode 100644 index 0000000..1e2ebed --- /dev/null +++ b/BigBrother/bb/cli.py @@ -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//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() diff --git a/BigBrother/bb/config/__init__.py b/BigBrother/bb/config/__init__.py new file mode 100644 index 0000000..1add40a --- /dev/null +++ b/BigBrother/bb/config/__init__.py @@ -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"] + } + }, + } diff --git a/BigBrother/bb/config/buffs.yaml b/BigBrother/bb/config/buffs.yaml new file mode 100644 index 0000000..aca9992 --- /dev/null +++ b/BigBrother/bb/config/buffs.yaml @@ -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 diff --git a/BigBrother/bb/config/class_overrides.yaml b/BigBrother/bb/config/class_overrides.yaml new file mode 100644 index 0000000..c65c412 --- /dev/null +++ b/BigBrother/bb/config/class_overrides.yaml @@ -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 diff --git a/BigBrother/bb/graph.py b/BigBrother/bb/graph.py new file mode 100644 index 0000000..7be4ee2 --- /dev/null +++ b/BigBrother/bb/graph.py @@ -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("") + f.writelines( + f"" + for fig in figs + ) + f.write("") + + +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" diff --git a/BigBrother/bb/requirements.txt b/BigBrother/bb/requirements.txt new file mode 100644 index 0000000..2cd176a --- /dev/null +++ b/BigBrother/bb/requirements.txt @@ -0,0 +1,3 @@ +pyyaml +matplotlib +numpy diff --git a/BigBrother/bb/slpp.py b/BigBrother/bb/slpp.py new file mode 100644 index 0000000..1cf308e --- /dev/null +++ b/BigBrother/bb/slpp.py @@ -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 + +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']