Files
blitz-tools/analyze_wotb_replays.py
T

3355 lines
112 KiB
Python
Executable File

#!/usr/bin/env python3
## PYTHON VERSION MUST BE 3.7 OR HIGHER
# Script Analyze WoT Blitz replays
import sys, argparse, json, os, concurrent, inspect, aiohttp, asyncio, aiofiles, aioconsole
import configparser, ssl, re, logging, time, xmltodict, collections, csv
from asyncio import Queue, Task, create_task
from datetime import datetime, timedelta, date
from motor.motor_asyncio import (
AsyncIOMotorDatabase,
AsyncIOMotorClient,
)
import blitzutils1 as bu
from blitzutils1 import WG
from blitzutils1 import WoTinspector
from pyutils import FileQueue
logging.getLogger("asyncio").setLevel(logging.DEBUG)
WG_APP_ID = "df25c506c7944e48fbec67e8b74fd34e"
FILE_CONFIG = "blitzstats.ini"
DB_C_TANK_STATS = "WG_TankStats"
DB_C_PLAYER_STATS = "WG_PlayerStats"
DB_C_REPLAYS = "Replays"
N_PLAYERS = "Nplayers"
MISSING_STATS = "missing_stats"
wg: WG
wi: WoTinspector
REPLAY_N = 0
REPLAY_I = 0
STAT_TANK_BATTLE_MIN = 100
BATTLE_TIME_BUCKET = 3600 * 24 * 14
OPT_EXPORT_REPLAY_DIR = "export_replays"
OPT_EXPORT_CSV_FILE = "export.csv"
OPT_EXPORT_JSON_FILE = "export.json"
RE_SRC_IS_DB = re.compile(r"^DB:")
class StatFunc:
## For different player stat functions (global stats, tank tier stats, etc)
# 1st function = for forming stat_id, 2nd for DB stats query, 3rd for WG API stats query
STAT_FUNC = {
"tank_tier": [
"get_stat_id_tank_tier",
"get_db_tank_tier_stats",
"get_wg_tank_tier_stats",
"WR at tier",
],
"tank": [
"get_stat_id_tank",
"get_db_tank_stats",
"get_wg_tank_stats",
"WR in Tank",
],
"player": [
"get_stat_id_player",
"get_db_player_stats",
"get_wg_player_stats",
"Career WR",
],
"tier_x": [
"get_stat_id_player",
"get_db_tier_x_stats",
"get_wg_tier_x_stats",
"WR at tier X",
],
}
_DEFAULT = "player"
_stat_func = _DEFAULT
@classmethod
def get_default(cls):
return cls._DEFAULT
@classmethod
def get_stat_funcs(cls):
return cls.STAT_FUNC.keys()
@classmethod
def set_stat_func(cls, stat_func: str = _DEFAULT):
if stat_func in cls.STAT_FUNC.keys():
cls._stat_func = stat_func
else:
## ERROR
pass
return cls._stat_func
@classmethod
def get_stat_func(cls):
return cls._stat_func
@classmethod
def get_stat_id_func(cls):
return cls.STAT_FUNC[cls._stat_func][0]
@classmethod
def get_db_func(cls):
return cls.STAT_FUNC[cls._stat_func][1]
@classmethod
def get_wg_func(cls):
return cls.STAT_FUNC[cls._stat_func][2]
@classmethod
def get_title(cls):
return cls.STAT_FUNC[cls._stat_func][3]
replay_summary_flds = [
"battle_result",
"battle_type",
"map_name",
"battle_duration",
"room_type",
"protagonist",
"player_name",
"title",
"mastery_badge",
]
replay_details_flds = [
"damage_made",
"damage_assisted_track",
"damage_assisted",
"damage_received",
"damage_blocked",
"enemies_damaged",
"enemies_destroyed",
"hitpoints_left",
"time_alive",
"distance_travelled",
"base_capture_points",
"base_defend_points",
"wp_points_earned",
"wp_points_stolen",
"hits_received",
"hits_bounced",
"hits_splash",
"hits_pen",
"shots_made",
"shots_hit",
"shots_pen",
"shots_splash",
"enemies_spotted",
]
## Syntax: key == stat field in https://api.wotblitz.eu/wotb/tanks/stats/ (all.KEY)
## Value array [ 'Stat Title', [ 0, data buckets ....], scaling_factor_for_bucket_values, 'print_format' ]
histogram_fields = {
"wins": [
"Win rate",
[0, 0.35, 0.40, 0.45, 0.48, 0.5, 0.52, 0.55, 0.60, 0.65, 0.70, 1],
1,
"{:2.0%}",
],
"damage_dealt": [
"Avg. Dmg.",
[
0,
200,
300,
400,
500,
600,
700,
800,
900,
1000,
1100,
1200,
1300,
1400,
1500,
1750,
2000,
2250,
2500,
2750,
3000,
100e3,
],
1,
"{:3.0f}",
],
"battles": [
"Battles",
[0, 1000, 2500, 5000, 7000, 10e3, 15e3, 25e3, 50e3, 5e7],
0.001,
"{:.0f}k",
], # battles is a mandatory stat to include
}
def def_value_zero():
return 0
def def_value_BattleCategory():
return BattleCategory()
class BattleCategorizationList:
@staticmethod
def mk_battle_modes(modes: dict):
"""Make a list of battle modes as required for the _categorizations"""
mode_ids = modes.values()
id_max = max(mode_ids)
ret_modes = ["-"] * (id_max + 16)
for i in range(0, id_max + 16):
ret_modes[i] = "room_type=" + str(i)
for mode in modes:
id = modes[mode]
ret_modes[id] = mode
return ret_modes
_battle_modes = {
"Any": 0,
"Regular": 1,
"Training Room": 2,
"Tournament": 4,
"Quick Tournament": 5, # maybe?
"Rating": 7,
"Mad Games": 8,
"Realistic Battles": 22,
"Uprising": 23,
"Gravity Mode": 24,
"Skirmish": 25,
"Burning Games": 26,
}
_BATTLE_MODES = mk_battle_modes.__func__(_battle_modes)
_categorizations = {
"total": ["TOTAL", "total"],
"battle_result": ["Result", "category", ["Loss", "Win", "Draw"]],
"battle_type": ["Battle Type", "category", ["Encounter", "Supremacy"]],
"room_type": ["Battle Mode", "category", _BATTLE_MODES],
"top_tier": ["Tier", "category", ["Bottom tier", "Top tier"]],
"in_platoon": ["Platoon", "category", ["Solo", "Platoon"]],
"tank_tier": ["Tank Tier", "number"],
"is_premium": ["Tank Type", "category", ["Tech tree", "Premium"]],
"tank_type": ["Tank Class", "category", WG.TANK_TYPE_STR],
"tank_nation": ["Nation", "category", WG.NATION_STR],
"mastery_badge": [
"Battle Medal",
"category",
["-", "3rd Class", "2nd Class", "1st Class", "Mastery"],
],
"team_result": ["Team Result", "string"],
"player_wins": ["Player WR", "bucket", [0, 0.35, 0.45, 0.50, 0.55, 0.65], "%"],
"player_battles": [
"Player Battles",
"bucket",
[0, 500, 1000, 2500, 5e3, 10e3, 15e3, 25e3],
"int",
],
"player_damage_dealt": [
"Player Avg Dmg",
"bucket",
[0, 500, 1000, 1250, 1500, 1750, 2000, 2500],
"int",
],
"damage_made": [
"Player Dmg made",
"bucket",
[0, 500, 1000, 1250, 1500, 1750, 2000, 2500],
"int",
],
"enemies_destroyed": ["Player Kills", "number"],
"enemies_spotted": ["Player Spots", "number"],
"hit_rate": [
"Player Hit Rate",
"bucket",
[0, 0.25, 0.5, 0.6, 0.7, 0.8, 0.9, 0.95],
"%",
],
"pen_rate": [
"Player Pen Rate",
"bucket",
[0, 0.25, 0.5, 0.6, 0.7, 0.8, 0.9, 0.95],
"%",
],
"alive": [
"Time alive (pcs)",
"bucket",
[x / 100 for x in range(0, 100, 10)],
"%",
],
"time_alive": [
"Time alive (s)",
"bucket",
[x * 60 for x in range(0, 8)],
"int",
],
"battle_duration": [
"Battle Duration",
"bucket",
[x * 60 for x in range(0, 8)],
"int",
],
"distance_travelled": [
"Distance Driven",
"bucket",
[x * 250 for x in range(0, 20)],
"int",
],
"allies_wins": ["Allies WR", "bucket", [0, 0.35, 0.45, 0.50, 0.55, 0.65], "%"],
"allies_battles": [
"Player Battles",
"bucket",
[0, 500, 1000, 2500, 5e3, 10e3, 15e3, 25e3],
"int",
],
"allies_damage_dealt": [
"Player Avg Dmg",
"bucket",
[0, 500, 1000, 1250, 1500, 1750, 2000, 2500],
"int",
],
"enemies_wins": [
"Enemies WR",
"bucket",
[0, 0.35, 0.45, 0.50, 0.55, 0.65],
"%",
],
"enemies_battles": [
"Player Battles",
"bucket",
[0, 500, 1000, 2500, 5e3, 10e3, 15e3, 25e3],
"int",
],
"enemies_damage_dealt": [
"Player Avg Dmg",
"bucket",
[0, 500, 1000, 1250, 1500, 1750, 2000, 2500],
"int",
],
"player_name": ["Player", "string", 25],
"protagonist": ["account_id", "number"],
"tank_name": ["Tank", "string", 25],
"map_name": ["Map", "string", 20],
"battle": ["Battle", "string", 40],
"battle_i": ["Battle #", "number"],
}
_categorizations_default = [
"total",
"battle_result",
"battle_type",
"tank_tier",
"top_tier",
]
## Player stats based categorizations
_categorizations_stats = [
group + "_" + measure
for group in ["player", "allies", "enemies"]
for measure in ["wins", "battles", "damage_dealt"]
]
@classmethod
def get_categorizations_all(cls, cats: list = None) -> list:
try:
if cats is None:
return list(cls._categorizations.keys())
else:
return list(set(cls._categorizations.keys()) & set(cats))
except Exception as err:
bu.error(exception=err)
return None
@classmethod
def get_categorizations_stats(cls, cats=None, stats_cats=None):
try:
if cats is None:
return cls._categorizations_stats
else:
if stats_cats is None:
return cats
player_stat_filters = set(cls._categorizations_stats)
cats = set(cls.get_categorizations_all(cats))
if stats_cats:
cats = cats & player_stat_filters
else:
cats = cats - player_stat_filters
bu.debug(", ".join(list(cats)))
return list(cats)
except Exception as err:
bu.error(exception=err)
return None
@classmethod
def get_categorizations_default(cls):
return cls._categorizations_default
@classmethod
def set_categorizations_default(cls, cat_defaults: list):
try:
defaults = list()
for cat in cat_defaults:
if cat in cls.get_categorizations_all():
defaults.append(cat)
else:
bu.error(cat + " is not a defined categorization")
cls._categorizations_default = defaults
except Exception as err:
bu.error(exception=err)
@classmethod
def get_categorizations_extra(cls) -> list:
# return cls._categorizations_extra
return list(
set(cls._categorizations.keys()) - set(cls.get_categorizations_default())
)
@classmethod
def get_categorization_title(cls, cat: str) -> str:
try:
return cls._categorizations[cat][0]
except Exception as err:
bu.error(exception=err)
return None
@classmethod
def get_categorization_type(cls, cat: str) -> str:
try:
return cls._categorizations[cat][1]
except Exception as err:
bu.error(exception=err)
return None
@classmethod
def get_categorization_params(cls, cat: str) -> list:
try:
if len(cls._categorizations[cat]) > 2:
return cls._categorizations[cat][2:]
except Exception as err:
bu.error(exception=err)
return None
@classmethod
def help(cls):
for cat in cls.get_categorizations_all():
try:
ctitle = cls.get_categorization_title(cat)
ctype = cls.get_categorization_type(cat)
cparams = cls.get_categorization_params(cat)
if ctype == "number":
cat_tmp = BattleNumberCategorization(cat, ctitle)
elif ctype == "string":
cat_tmp = BattleStringCategorization(cat, ctitle, cparams)
elif ctype == "category":
cat_tmp = BattleClassCategorization(cat, ctitle, cparams[0])
elif ctype == "bucket":
cat_tmp = BattleBucketCategorization(
cat, ctitle, cparams[0], cparams[1]
)
else:
cat_tmp = BattleTotals(ctitle)
cat_tmp.help()
except KeyError as err:
bu.error(
"BattleCategorizationList(): Key not found: Category: " + cat,
exception=err,
)
except Exception as err:
bu.error("BattleCategorizationList()", exception=err)
@classmethod
def get_categorizations(cls, args: argparse.Namespace):
extra_cats = args.extra
only_extra = args.only_extra
if only_extra:
cats = list()
else:
cats = cls.get_categorizations_default()
if extra_cats is not None:
cats = cats + extra_cats
return cats
def __init__(self, cats: list):
self.urls = collections.OrderedDict()
self.url_title_max_len = 0
cats = list(set(cats)) # remove duplicates
# ordering
self.categorizations_list = [
cat for cat in self._categorizations if cat in cats
]
self.categorizations = dict()
for cat in self.categorizations_list:
try:
ctitle = self.get_categorization_title(cat)
ctype = self.get_categorization_type(cat)
cparams = self.get_categorization_params(cat)
if ctype == "total":
self.categorizations[cat] = BattleTotals(ctitle)
elif ctype == "number":
self.categorizations[cat] = BattleNumberCategorization(cat, ctitle)
elif ctype == "string":
self.categorizations[cat] = BattleStringCategorization(
cat, ctitle, cparams
)
elif ctype == "category":
self.categorizations[cat] = BattleClassCategorization(
cat, ctitle, cparams[0]
)
elif ctype == "bucket":
self.categorizations[cat] = BattleBucketCategorization(
cat, ctitle, cparams[0], cparams[1]
)
except KeyError as err:
bu.error(
"BattleCategorizationList(): Key not found: Category: " + cat,
exception=err,
)
except Exception as err:
bu.error("BattleCategorizationList()", exception=err)
def __iter__(self):
"""Returns the Iterator object"""
return BattleCategorizationListIterator(self)
def len(self):
return len(self.categorizations)
def get_categorization(self, cat: str):
"""Return a BattleCategorization for a category"""
try:
return self.categorizations[cat]
except Exception as err:
bu.error(exception=err)
return None
def record_result(self, result: dict):
try:
for cat in self.categorizations_list:
self.categorizations[cat].record_result(result)
except Exception as err:
bu.error(exception=err)
return None
def calc_results(self):
try:
for cat in self.categorizations_list:
self.categorizations[cat].calc_results()
except Exception as err:
bu.error(exception=err)
return None
def record_url(self, result):
try:
self.url_title_max_len = max(self.url_title_max_len, len(result["title"]))
self.urls[result["title"]] = result["url"]
except Exception as err:
bu.error(exception=err)
return None
def print_results(self):
try:
for cat in self.categorizations_list:
self.categorizations[cat].print_results()
self.print_urls()
except Exception as err:
bu.error(exception=err)
def print_urls(self):
try:
if len(self.urls) > 0:
print("\nURLs to Battle replays:")
for title in self.urls:
print(
("{:" + str(3 + self.url_title_max_len) + "}").format(title)
+ " : "
+ self.urls[title]
)
except Exception as err:
bu.error(exception=err)
def get_filter_categories(self, cat: str, filters: list) -> dict:
try:
return self.categorizations[str(cat)].get_filter_categories(filters)
except Exception as err:
bu.error(exception=err)
return None
def get_categories(self, result: dict) -> dict:
"""Get categories for each categorization in the CategorizationList() for a battle result"""
try:
res = dict()
for cat in self.categorizations_list:
res[cat] = self.categorizations[cat].get_category(result)
return res
except Exception as err:
bu.error(exception=err)
return None
def get_results_json(self):
try:
res = dict()
for cat in self.categorizations_list:
res[cat] = self.categorizations[cat].get_results_json()
res["replay_urls"] = self.urls
return res
except Exception as err:
bu.error(exception=err)
return None
def get_results_list(self, urls: bool = False):
try:
res = list()
for cat in self.categorizations_list:
res.append("")
res.extend(self.categorizations[cat].get_results_list())
if urls:
res.append("")
for url in self.urls:
res.append(url)
return res
except Exception as err:
bu.error(exception=err)
return None
class BattleCategorizationListIterator:
def __init__(self, btl_cats: BattleCategorizationList) -> None:
self._btl_cats = btl_cats
self._ndx = 0
def __next__(self):
if self._ndx < len(self._btl_cats.categorizations_list):
cat = self._btl_cats.categorizations_list[self._ndx]
res = self._btl_cats.categorizations[cat]
self._ndx += 1
return res
raise StopIteration
class BattleCategorization:
RESULT_CAT_HEADER_LEN = 15
RESULT_CAT_HEADER_FRMT = "{:_<" + str(RESULT_CAT_HEADER_LEN) + "s}"
RESULT_CAT_FRMT = "{:>" + str(RESULT_CAT_HEADER_LEN) + "s}"
def __init__(self, cat_key: str, title: str):
self.total_battles = 0
self.category_key = cat_key
self.title = title
self.categories = collections.defaultdict(def_value_BattleCategory)
def set_cat_header_format(self, header_len: int, left_aligned: bool = False):
self.RESULT_CAT_HEADER_LEN = header_len
self.RESULT_CAT_HEADER_FRMT = "{:_<" + str(header_len) + "s}"
if left_aligned:
self.RESULT_CAT_FRMT = "{:<" + str(header_len) + "s}"
else:
self.RESULT_CAT_FRMT = "{:>" + str(header_len) + "s}"
def get_categories(self):
"""Get category keys sorted"""
return sorted(self.categories.keys(), key=str.casefold)
### PER Child Class
def get_category(self, result: dict) -> str:
"""Get category. Must be implemented in the child classes"""
return "NONE"
def get_filter_categories(self, filters: list) -> list:
try:
if type(filters) != list:
filters = [filters]
return [x for x in filters if x in self.get_categories()]
except Exception as err:
bu.error("Category: " + str(self.category_key), exception=err)
return None
def help(self):
"""Help"""
FORMAT = "\t{:<15s}\t{}. Options:"
print(FORMAT.format(self.category_key, self.title), end=" ")
def record_result(self, result: dict) -> bool:
try:
cat = self.get_category(result)
self.categories[cat].record_result(result)
self.total_battles += 1
return True
except KeyError as err:
bu.error("Category key not found: " + cat, exception=err)
except Exception as err:
bu.error("Category key: " + cat, exception=err)
return False
def calc_results(self):
try:
for cat in self.get_categories():
self.categories[cat].calc_results(self.total_battles)
except Exception as err:
bu.error(exception=err)
def print_headers(self):
try:
if len(self.title) > self.RESULT_CAT_HEADER_LEN:
cat_name = self.title[: self.RESULT_CAT_HEADER_LEN]
else:
cat_name = self.title
header = self.RESULT_CAT_HEADER_FRMT.format(cat_name)
BattleCategory.print_headers(header)
return None
except Exception as err:
bu.error(exception=err)
def get_headers(self) -> list:
"""Get BattleCategory headers as list"""
try:
return BattleCategory.get_headers(self.title)
except Exception as err:
bu.error(exception=err)
return None
def print_results(self):
try:
bu.print_new_line()
self.print_headers()
for cat in self.get_categories():
if len(cat) > self.RESULT_CAT_HEADER_LEN:
cat_name = cat[: self.RESULT_CAT_HEADER_LEN]
else:
cat_name = cat
print(self.RESULT_CAT_FRMT.format(cat_name), end=" ")
self.categories[cat].print_results()
except KeyError as err:
bu.error("Key not found", err)
except Exception as err:
bu.error(exception=err)
return None
def get_results_json(self) -> dict:
"""Get results in JSON format"""
try:
results = dict()
results["categorization"] = self.title
results["category_key"] = self.category_key
for cat in self.get_categories():
results[cat] = self.categories[cat].get_results()
return results
except KeyError as err:
bu.error("Key not found", err)
return None
def get_results_list(self) -> list:
"""Get results in list format (for CSV printing)"""
try:
results = list()
results.append(self.get_headers())
for cat in self.get_categories():
results.append([cat] + self.categories[cat].get_results_list())
return results
except KeyError as err:
bu.error("Key not found", err)
return None
class BattleTotals(BattleCategorization):
TOTAL = "Total"
def __init__(self, title: str):
super().__init__(self.TOTAL, title)
def get_category(self, result: dict) -> str:
return self.TOTAL
def get_categories(self) -> str:
return [self.TOTAL]
def help(self):
super().help()
print("-")
class BattleClassCategorization(BattleCategorization):
"""Class for categorization by fixed classes with ID (int)"""
def __init__(self, cat_key: str, title: str, classes: list):
super().__init__(cat_key, title)
self.category_labels = classes
def help(self):
super().help()
labels = list()
for ndx in range(0, len(self.category_labels)):
labels.append(str(ndx) + "=" + self.category_labels[ndx])
print(", ".join(labels))
def get_category(self, result: dict) -> str:
"""Get category"""
cat_id = -1
try:
cat_id = result[self.category_key]
return self.category_labels[cat_id]
except KeyError as err:
bu.error(
"Category: " + str(self.category_key) + " ID: " + str(cat_id),
exception=err,
)
except Exception as err:
bu.error(
"Category: " + str(self.category_key) + " ID: " + str(cat_id),
exception=err,
)
return None
def get_filter_categories(self, filters: list) -> list:
try:
# bu.debug(str(filters), force=True)
if type(filters) != list:
filters = [filters]
bu.debug(str(self.category_labels))
res = list()
for ndx in filters:
if ndx in range(0, len(self.category_labels)):
res.append(self.category_labels[ndx])
return res
# return [ self.category_labels[int(x)] for x in filters if x in range(0, len(self.get_categories())) ]
except Exception as err:
bu.error("Category: " + str(self.category_key), exception=err)
return None
def get_categories(self) -> list:
try:
cats = list()
for cat in self.category_labels:
if cat in self.categories.keys():
cats.append(cat)
return cats
except KeyError as err:
bu.error("Key not found", exception=err)
except Exception as err:
bu.error(exception=err)
return None
class BattleStringCategorization(BattleCategorization):
"""Class for categorization by string"""
def __init__(self, cat_key: str, title: str, params: list):
super().__init__(cat_key, title)
if params is not None and len(params) > 0:
width = params[0]
left_align = True # align long category names left by default
if len(params) > 1:
left_align = params[1]
self.set_cat_header_format(width, left_align)
def get_category(self, result: dict) -> str:
"""Get category"""
try:
return result[self.category_key]
except KeyError as err:
bu.error("Category key: " + self.category_key + " not found", exception=err)
except Exception as err:
bu.error("Category: " + self.category_key, exception=err)
return None
def get_categories(self):
"""Get category keys sorted"""
try:
p_number_sort = re.compile(r"^(\d+): .+")
cats = list(self.categories.keys())
if p_number_sort.match(cats[0]) is not None:
ndxs = sorted(
range(len(cats)), key=lambda k: int(cats[k][: cats[k].find(":")])
)
return [cats[i] for i in ndxs]
else:
return sorted(cats, key=str.casefold)
except Exception as err:
bu.error("Category: " + self.category_key, exception=err)
return None
def get_filter_categories(self, filters: list) -> list:
try:
if type(filters) != list:
filters = [filters]
return [x for x in filters if x in self.get_categories()]
# return filters
except Exception as err:
bu.error("Category: " + str(self.category_key), exception=err)
return None
def help(self):
super().help()
print(self.title + " name")
class BattleNumberCategorization(BattleCategorization):
"""Class for categorization by number"""
def get_category(self, result: dict) -> str:
"""Get category as"""
try:
return str(result[self.category_key])
except KeyError as err:
bu.error("Category: " + str(self.category_key), exception=err)
except Exception as err:
bu.error("Category: " + str(self.category_key), exception=err)
return None
def get_categories(self) -> list:
try:
return map(str, sorted(map(int, self.categories.keys())))
except KeyError as err:
bu.error("Key not found", exception=err)
return None
def get_filter_categories(self, filters: list) -> list:
try:
if type(filters) != list:
filters = [filters]
return list(map(str, filters))
except Exception as err:
bu.error("Category: " + str(self.category_key), exception=err)
return None
def help(self):
super().help()
print("INTEGER")
class BattleBucketCategorization(BattleCategorization):
"""Class for categorization by buckets based on value"""
def __init__(self, cat_key: str, title: str, breaks: list, bucket_format=""):
try:
super().__init__(cat_key, title)
self.breaks = breaks
self.bucket_format = bucket_format
self.category_labels = self.mk_category_labels(breaks, bucket_format)
except Exception as err:
bu.error("BattleBucketCategorization()", exception=err)
def help(self):
super().help()
labels = list()
for ndx in range(0, len(self.category_labels)):
labels.append(str(ndx) + "=" + self.category_labels[ndx])
print(", ".join(labels))
def get_category(self, result: dict) -> str:
"""Get category"""
cat_id = -1
value = None
try:
value = result[self.category_key]
cat_id = self.find_bucket(value, self.breaks)
return self.category_labels[cat_id]
except KeyError as err:
bu.error(
"Category: "
+ str(self.category_key)
+ " value: "
+ str(value)
+ " cat ID: "
+ str(cat_id),
exception=err,
)
except Exception as err:
bu.error(
"Category: "
+ str(self.category_key)
+ " value: "
+ str(value)
+ " cat ID: "
+ str(cat_id),
exception=err,
)
return None
def get_categories(self) -> list:
try:
cats = list()
for cat in self.category_labels:
if cat in self.categories.keys():
cats.append(cat)
return cats
except KeyError as err:
bu.error("Key not found", exception=err)
except Exception as err:
bu.error(exception=err)
return None
def get_filter_categories(self, filters: list) -> list:
try:
if type(filters) != list:
filters = [filters]
res = list()
for ndx in filters:
if ndx in range(0, len(self.category_labels)):
res.append(self.category_labels[ndx])
return res
except Exception as err:
bu.error("Category: " + str(self.category_key), exception=err)
return None
def find_bucket(self, value, bucket_breaks: list):
"""Find bucket for value"""
try:
max = len(bucket_breaks)
if max <= 1:
return 0
ndx = int(max // 2)
if value > bucket_breaks[ndx]:
return ndx + self.find_bucket(value, bucket_breaks[ndx:])
elif value < bucket_breaks[ndx]:
return self.find_bucket(value, bucket_breaks[:ndx])
else:
return ndx
except KeyError as err:
bu.error(
"Key not found ndx=" + str(ndx) + ", value=" + str(value), exception=err
)
except Exception as err:
bu.error("ndx=" + str(ndx) + ", value=" + str(value), exception=err)
return -2
def mk_category_labels(self, bucket_breaks: list, bucket_format: str) -> list:
try:
# add open-ended last category. list.append() modifies by reference self.breaks (not OK)!!
bucket_breaks = bucket_breaks + [None]
multiplier = 1
prefix = ""
if bucket_format == "%":
frmt = "{:2.0f}"
multiplier = 100
prefix = "%"
elif bucket_format == "float":
frmt = "{:.2f}"
elif bucket_format == "int":
frmt = "{:.0f}"
else:
frmt = "{:.0f}"
labels = list()
for ndx in range(0, len(bucket_breaks) - 1):
low = bucket_breaks[ndx]
high = bucket_breaks[ndx + 1]
# None makes open ended category label: '1500 - '
if low is None:
label = " - "
else:
label = frmt.format(low * multiplier) + " - "
if high is None:
label = label + " "
else:
label = label + frmt.format(high * multiplier)
label = label + prefix
labels.append(label)
return labels
except KeyError as err:
bu.error(
"KeyError: ndx=" + str(ndx) + " buckets=" + str(bucket_breaks),
exception=err,
)
except Exception as err:
bu.error("Unknown error", exception=err)
return None
class BattleCategory:
## Syntax: Check how the replay JSON files look. The algorithm is counting/recording fields
_result_fields = {
"battles": ["Battles", "Number of battles", 7, "{:7.0f}"],
"battles%": ["% Battles", "Share of Battles", 9, "{:9.0%}"],
"win": ["WR", "Win rate", 6, "{:6.1%}"],
"damage_made": ["DPB", "Average Damage", 5, "{:5.0f}"],
"damage_received": ["DPBr", "Average Damage Received", 5, "{:5.0f}"],
"enemies_destroyed": ["KPB", "Kills / Battle", 4, "{:4.2f}"],
"DR": ["DR", "Damage Ratio", 5, "{:5.1f}"],
"KDR": ["KDR", "Kills / Death", 4, "{:4.1f}"],
"enemies_spotted": ["Spot", "Enemies spotted per battle", 4, "{:4.1f}"],
"hit_rate": ["Hit rate", "Shots hit / all shots made", 8, "{:8.1%}"],
"shots_made": ["Shots", "Shots made", 5, "{:5.1f}"],
"hits_received": ["Hits R.", "Hits reveived", 7, "{:7.1f}"],
"hits_bounced": ["Bounced R.", "Hits & bounced reveived", 10, "{:10.1f}"],
"in_platoon": ["Plat%", "Platoon rate", 6, "{:6.1%}"],
"pen_rate": ["Pen rate", "Shots pen / shots hit", 8, "{:8.1%}"],
"pen_rate_received": [
"Penned%",
"Received hits pen / hits received",
7,
"{:7.1%}",
],
"splash_rate": ["Splash%", "HE splash shots / shots hit", 7, "{:7.1%}"],
"splash_rate_received": [
"Splashed%",
"HE splash shots received / hits hit",
9,
"{:9.1%}",
],
"survived": ["Surv%", "Survival rate", 6, "{:6.1%}"],
"time_alive": ["T alive", "Time being alive in a battle in secs", 7, "{:7.0f}"],
"alive": [
"Share live",
"Percentage of time being alive in a battle",
8,
"{:8.0%}",
],
"battle_duration": ["Duration", "Battle duration", 8, "{:8.0f}"],
"distance_travelled": ["Distance", "Distance travelled", 8, "{:8.0f}"],
"top_tier": ["Top tier", "Share of games as top tier", 8, "{:8.0%}"],
"player_wins": ["Player WR", "Average WR of the player", 9, "{:9.2%}"],
"allies_wins": [
"Allies WR",
"Average WR of allies at the tier of their tank",
9,
"{:9.2%}",
],
"enemies_wins": [
"Enemies WR",
"Average WR of enemies at the tier of their tank",
10,
"{:10.2%}",
],
"player_battles": [
"Player Btls",
"Average number battles of the player",
11,
"{:11.0f}",
],
"allies_battles": [
"Allies Btls",
"Average number battles of the allies",
11,
"{:11.0f}",
],
"enemies_battles": [
"Enemies Btls",
"Average number battles of the enemies",
12,
"{:12.0f}",
],
"player_damage_dealt": [
"Player Avg Dmg",
"Player Average damage",
14,
"{:14.0f}",
],
"allies_damage_dealt": [
"Allies Avg Dmg",
"Average damage of the allies",
14,
"{:14.0f}",
],
"enemies_damage_dealt": [
"Enemies Avg Dmg",
"Average damage of the enemies",
15,
"{:14.0f}",
],
MISSING_STATS: ["No stats", "Players without stats avail", 8, "{:8.1%}"],
}
# fields to display in results
_result_fields_modes = {
"default": [
"battles",
"win",
"damage_made",
"enemies_destroyed",
"enemies_spotted",
"top_tier",
"DR",
"survived",
"allies_wins",
"enemies_wins",
],
"team": [
"battles",
"win",
"in_platoon",
"player_wins",
"allies_wins",
"enemies_wins",
],
"team_extended": [
"battles",
"battles%",
"win",
"in_platoon",
"player_wins",
"allies_wins",
"enemies_wins",
"player_damage_dealt",
"allies_damage_dealt",
"enemies_damage_dealt",
"player_battles",
"allies_battles",
"enemies_battles",
],
"extended": [
"battles",
"battles%",
"win",
"in_platoon",
"damage_made",
"enemies_destroyed",
"enemies_spotted",
"top_tier",
"DR",
"KDR",
"hit_rate",
"pen_rate",
"survived",
"alive",
"player_wins",
"allies_wins",
"enemies_wins",
MISSING_STATS,
],
"dmg_received": [
"battles",
"win",
"damage_made",
"damage_received",
"enemies_destroyed",
"top_tier",
"DR",
"hits_received",
"hits_bounced",
"pen_rate_received",
"splash_rate_received",
],
"all": [cat for cat in _result_fields.keys()],
}
_team_fields = ["wins", "battles", "damage_dealt"]
count_fields = ["battles", MISSING_STATS, "mastery_badge"]
_result_ratios = {
"KDR": ["enemies_destroyed", "destroyed"],
"DR": ["damage_made", "damage_received"],
"hit_rate": ["shots_hit", "shots_made"],
"splash_rate": ["shots_splash", "shots_hit"],
"splash_rate_received": ["hits_splash", "hits_received"],
"pen_rate": ["shots_pen", "shots_hit"],
"pen_rate_received": ["hits_pen", "hits_received"],
"dmg_block_rate": ["damage_blocked", "damage_received"],
}
# _extended_stats = False
result_fields = list()
avg_fields = set()
ratio_fields = set()
@classmethod
def get_modes(cls) -> list:
return list(cls._result_fields_modes.keys())
@classmethod
def get_mode_fields(cls, mode: str):
try:
return cls._result_fields_modes[mode]
except Exception as err:
bu.error(exception=err)
return None
@classmethod
def set_fields(cls, mode: str = None):
try:
if mode not in cls.get_modes():
bu.error(
"Mode "
+ mode
+ " not in defined modes: "
+ ", ".join(cls.get_modes())
)
sys.exit(2)
cls.mode = mode
cls.result_fields = cls.get_mode_fields(cls.mode)
cls.result_fields_ratio = set(cls._result_ratios.keys()) & set(
cls.result_fields
)
cls.avg_fields = (
set(cls.result_fields)
- set(cls._result_ratios.keys())
- set(cls.count_fields)
)
for ratio in cls.result_fields_ratio:
cls.ratio_fields.add(cls._result_ratios[ratio][0])
cls.ratio_fields.add(cls._result_ratios[ratio][1])
except Exception as err:
bu.error("BattleCategory:", exception=err)
@classmethod
def get_result_fields(cls) -> list:
return cls.result_fields
@classmethod
def get_result_fields_all(cls) -> list:
return cls._result_fields.keys()
@classmethod
def get_result_fields_ratio(cls, all: bool = False) -> list:
if all:
return cls._result_ratios.keys()
else:
return cls.result_fields_ratio
@classmethod
def get_avg_fields(cls) -> set:
return cls.avg_fields
@classmethod
def get_ratio_fields(cls) -> set:
return cls.ratio_fields
@classmethod
def get_field_name(cls, field: str) -> str:
try:
return cls._result_fields[field][0]
except Exception as err:
bu.error(exception=err)
@classmethod
def get_field_description(cls, field: str) -> str:
try:
return cls._result_fields[field][1]
except Exception as err:
bu.error(exception=err)
@classmethod
def get_field_width(cls, field: str):
try:
return cls._result_fields[field][2]
except Exception as err:
bu.error(exception=err)
@classmethod
def get_fields_team(cls) -> list:
return cls._team_fields
@classmethod
def calc_ratio(cls, ratio: str, result: dict):
try:
value = result[cls._result_ratios[ratio][0]]
divider = result[cls._result_ratios[ratio][1]]
if divider != 0:
if value is None:
value = 0
return value / divider
else:
if value == 0:
return 0
else:
return float("Inf")
except Exception as err:
bu.error("Replay _id=" + result["_id"], exception=err)
return None
@classmethod
def print_headers(cls, header: str):
try:
headers = [header]
for field in cls.result_fields:
print_format = "{:^" + str(cls.get_field_width(field)) + "s}"
headers.append(print_format.format(cls.get_field_name(field)))
print(" ".join(headers))
except KeyError as err:
bu.error("Key not found", exception=err)
except Exception as err:
bu.error(exception=err)
return None
@classmethod
def get_headers(cls, header: str = None) -> list:
try:
headers = [header]
for field in cls.result_fields:
headers.append(cls.get_field_name(field))
return headers
except KeyError as err:
bu.error("Key not found", exception=err)
except Exception as err:
bu.error(exception=err)
return None
@classmethod
def help(cls):
try:
for field in cls.get_result_fields_all():
print("{:24s} : {}".format(field, cls.get_field_description(field)))
except Exception as err:
bu.error(exception=err)
return None
def __init__(self):
try:
self.battles = 0
self.missing_stats = 0
self.n_players = 0
self.ratios_ready = False
self.results_ready = False
self.results = collections.defaultdict(def_value_zero)
except KeyError as err:
bu.error("BattleCategory(): Key not found", err)
def record_result(self, result: dict) -> bool:
try:
for field in self.avg_fields | self.ratio_fields:
if (field in result) and (result[field] is not None):
self.results[field] += result[field]
self.battles += 1
self.n_players += result[N_PLAYERS]
self.missing_stats += result[MISSING_STATS]
return True
except KeyError as err:
bu.error("Key not found", err)
bu.error(str(result))
except Exception as err:
bu.error("BattleCategory:", exception=err)
return False
def calc_ratios(self) -> bool:
if self.ratios_ready:
return True
try:
for field in self.result_fields_ratio:
if self.results[self._result_ratios[field][1]] != 0:
self.results[field] = (
self.results[self._result_ratios[field][0]]
/ self.results[self._result_ratios[field][1]]
)
else:
self.results[field] = float("Inf")
self.ratios_ready = True
return True
except KeyError as err:
bu.error("Key not found", err)
return False
def calc_results(self, total_battles: int = None):
if self.results_ready == True:
return True
if not self.ratios_ready:
self.calc_ratios()
try:
for field in self.avg_fields:
self.results[field] = self.results[field] / max(self.battles, 1)
self.results["battles"] = self.battles
self.results["battles%"] = self.battles / total_battles
self.results[MISSING_STATS] = self.missing_stats / max(self.n_players, 1)
self.results_ready = True
return True
except KeyError as err:
bu.error("Key not found", err)
except Exception as err:
bu.error(exception=err)
return False
def get_results(self) -> dict:
"""Return results as a dict"""
if not self.results_ready:
bu.error(
"Stats have not been calculated yet. call calc_results() before get_results()"
)
try:
results = dict()
for field in self.result_fields:
results[field] = self.results[field]
return results
except KeyError as err:
bu.error("Key not found", err)
except Exception as err:
bu.error(exception=err)
return None
def get_results_list(self) -> list:
"""Return results as a list"""
if not self.results_ready:
bu.error(
"Stats have not been calculated yet. call calc_results() before get_results()"
)
try:
results = list()
for field in self.result_fields:
results.append(
self._result_fields[field][3].format(self.results[field])
)
return results
except KeyError as err:
bu.error("Key not found", err)
except Exception as err:
bu.error(exception=err)
return None
def print_results(self):
if not self.results_ready:
bu.error(
"Stats have not been calculated yet. call calc_results() before printing()"
)
try:
results = []
for field in self.result_fields:
results.append(
self._result_fields[field][3].format(self.results[field])
)
print(" : ".join(results))
return True
except KeyError as err:
bu.error("Key not found", err)
except Exception as err:
bu.error(exception=err)
return False
class PlayerHistogram:
def __init__(
self, field: str, name: str, fields: list, factor: float, cat_format: str
):
self.field = field
if self.field == "wins":
self.name = StatFunc.get_title()
else:
self.name = name
self.fields = fields
self.cat_factor = factor
self.cat_format = cat_format
self.ncat = len(self.fields) - 1
self.allies = [0] * self.ncat
self.enemies = [0] * self.ncat
self.total = [0] * self.ncat
self.results = None
def record_ally(self, stat: float):
for cat in range(0, self.ncat):
if self.fields[cat] <= stat <= self.fields[cat + 1]:
self.allies[cat] += 1
self.total[cat] += 1
return True
return False
def record_enemy(self, stat: float):
for cat in range(0, self.ncat):
if self.fields[cat] <= stat <= self.fields[cat + 1]:
self.enemies[cat] += 1
self.total[cat] += 1
return True
return False
def get_category_name(self, cat: int):
if cat < self.ncat - 1:
return self.format_category(cat) + " - " + self.format_category(cat + 1)
else:
return self.format_category(cat) + " -"
def format_category(self, cat: int):
val = self.fields[cat]
return self.cat_format.format(float(val * self.cat_factor))
def calc_results(self):
N_enemies = 0
N_allies = 0
res = dict()
# calculate buckets
for cat in range(0, self.ncat):
N_enemies += self.enemies[cat]
N_allies += self.allies[cat]
N_total = N_allies + N_enemies
for cat in range(0, self.ncat):
stat = dict()
stat["allies"] = self.allies[cat]
stat["allies%"] = stat["allies"] / N_allies
stat["enemies"] = self.enemies[cat]
stat["enemies%"] = stat["enemies"] / N_enemies
stat["total"] = self.allies[cat] + self.enemies[cat]
stat["total%"] = stat["total"] / N_total
res[self.get_category_name(cat)] = stat
self.results = res
return res
def get_results(self) -> dict:
return self.results
def get_results_list(self) -> list:
try:
if self.results is not None:
res = list()
res.append(
[
self.name,
"Allies",
"Allies %",
"Enemies",
"Enemies %",
"TOTAL",
"TOTAL %",
]
)
for cat in self.results:
stat = self.results[cat]
res.append(
[
cat,
stat["allies"],
stat["allies%"],
stat["enemies"],
stat["enemies%"],
stat["total"],
stat["total%"],
]
)
return res
else:
bu.error("Results have not been calculated yet.")
return None
except Exception as err:
bu.error(exception=err)
return None
def print(self):
try:
if self.results is not None:
print(
"\n{:12s} | {:13s} | {:13s} | {:13s}".format(
self.name, "Allies", "Enemies", "TOTAL"
)
)
for cat in self.results:
stat = self.results[cat]
print(
"{:12s} | {:5d} ({:5.1%}) | {:5d} ({:5.1%}) | {:5d} ({:5.1%})".format(
cat,
stat["allies"],
stat["allies%"],
stat["enemies"],
stat["enemies%"],
stat["total"],
stat["total%"],
)
)
else:
bu.error("Results have not been calculated yet.")
except Exception as err:
bu.error(exception=err)
return None
class ErrorCatchingArgumentParser(argparse.ArgumentParser):
def exit(self, status=0, message=None):
if status:
if message is not None:
raise UserWarning(message)
else:
raise UserWarning()
## main() -------------------------------------------------------------
async def main(argv):
global wg, wi, WG_APP_ID, OPT_MODE_DEFAULT, OPT_EXPORT_CSV_FILE, OPT_EXPORT_JSON_FILE
# set the directory for the script
current_dir = os.getcwd()
os.chdir(os.path.dirname(sys.argv[0]))
## Default options:
OPT_MODE_DEFAULT = "default"
OPT_MODE_HELP = "help"
OPT_MODES = BattleCategory.get_modes() + [OPT_MODE_HELP]
OPT_DB = False
OPT_HIST = False
OPT_STAT_FUNC = StatFunc.get_default()
OPT_WORKERS_N = 10
# OPT_EXPORT_CSV_FILE = 'export.csv'
# OPT_EXPORT_JSON_FILE = 'export.json'
# WG_ACCOUNT = None # format: nick@server, where server is either 'eu', 'ru', 'na', 'asia' or 'china'.
# China is not supported since WG API stats are not available there
# WG_ID = None # WG account_id in integer format.
# WG_ACCOUNT will be used to retrieve the account_id, but it can be set directly too
WG_RATE_LIMIT = 10 ## WG standard. Do not edit unless you have your
## own server app ID, it will REDUCE the performance
## VERY unlikely you have a DB set up
DB_SERVER = "localhost"
DB_PORT = 27017
DB_TLS = False
DB_CERT_REQ = False
DB_AUTH = "admin"
DB_NAME = "BlitzStats"
DB_USER = "mongouser"
DB_PASSWD = "PASSWORD"
DB_CERT = None
DB_CA = None
try:
## Read config
if os.path.isfile(FILE_CONFIG):
config = configparser.ConfigParser()
config.read(FILE_CONFIG)
try:
if "ANALYZER" in config.sections():
configAnalyzer = config["ANALYZER"]
OPT_MODE_DEFAULT = configAnalyzer.getboolean(
"mode", OPT_MODE_DEFAULT
)
OPT_HIST = configAnalyzer.getboolean("histograms", OPT_HIST)
OPT_STAT_FUNC = configAnalyzer.get(
"stat_func", fallback=OPT_STAT_FUNC
)
OPT_WORKERS_N = configAnalyzer.getint("workers", OPT_WORKERS_N)
OPT_EXPORT_CSV_FILE = configAnalyzer.get(
"csv_file", OPT_EXPORT_CSV_FILE
)
OPT_EXPORT_JSON_FILE = configAnalyzer.get(
"json_file", OPT_EXPORT_JSON_FILE
)
res_categorizations = configAnalyzer.get("categorizations", None)
if res_categorizations is not None:
res_categorizations.replace(" ", "")
BattleCategorizationList.set_categorizations_default(
res_categorizations.split(",")
)
histogram_fields_str = configAnalyzer.get("histogram_buckets", None)
if histogram_fields_str is not None:
set_histogram_buckets(json.loads(histogram_fields_str))
except (KeyError, configparser.NoSectionError) as err:
bu.error(exception=err)
try:
if "WG" in config.sections():
configWG = config["WG"]
# WG account id of the uploader:
# # Find it here: https://developers.wargaming.net/reference/all/wotb/account/list/
# WG_ID = configWG.getint('wg_id', WG_ID)
# WG_ACCOUNT = configWG.get('wg_account', WG_ACCOUNT)
WG_APP_ID = configWG.get("wg_app_id", WG_APP_ID)
WG_RATE_LIMIT = configWG.getint("wg_rate_limit", WG_RATE_LIMIT)
except (KeyError, configparser.NoSectionError) as err:
bu.error(exception=err)
try:
if "DATABASE" in config.sections():
configDB = config["DATABASE"]
OPT_DB = configDB.getboolean("opt_DB", OPT_DB)
DB_SERVER = configDB.get("db_server", DB_SERVER)
DB_PORT = configDB.getint("db_port", DB_PORT)
DB_TLS = configDB.getboolean("db_tls", DB_TLS)
DB_CERT_REQ = configDB.getboolean("db_tls_req", DB_CERT_REQ)
DB_AUTH = configDB.get("db_auth", DB_AUTH)
DB_NAME = configDB.get("db_name", DB_NAME)
DB_USER = configDB.get("db_user", DB_USER)
DB_PASSWD = configDB.get("db_password", DB_PASSWD)
DB_CERT = configDB.get("db_tls_cert_file", DB_CERT)
DB_CA = configDB.get("db_tls_ca_file", DB_CA)
except (KeyError, configparser.NoSectionError) as err:
bu.error(exception=err)
parser = ErrorCatchingArgumentParser(
description="Analyze Blitz replay JSON files from WoTinspector.com. Use 'upload_wotb_replays.py' to upload the replay files first."
)
parser.add_argument(
"--mode",
default=OPT_MODE_DEFAULT,
choices=OPT_MODES,
help="Select stats to print out (columns). Options: "
+ ", ".join(OPT_MODES),
)
parser.add_argument(
"--extra",
choices=BattleCategorizationList.get_categorizations_all(),
default=None,
nargs="*",
help="Print extra categories: "
+ ", ".join(
cat + "=" + BattleCategorizationList.get_categorization_title(cat)
for cat in BattleCategorizationList.get_categorizations_all()
),
)
parser.add_argument(
"--only_extra",
action="store_true",
default=False,
help="Print only the extra categories",
)
parser.add_argument(
"--hist",
action="store_true",
default=OPT_HIST,
help="Print player histograms: "
+ ", ".join(histogram_fields[k][0] for k in histogram_fields),
)
# parser.add_argument('--output', default='plain', choices=['plain', 'db'], help='Select output mode: plain text or database')
parser.add_argument(
"-id",
dest="account_id",
type=int,
default=None,
help="WG account_id to analyze. Replays without the account_id will be ignored.",
)
parser.add_argument(
"-a",
"--account",
type=str,
default=None,
help="WG account name to analyze. Format: ACCOUNT_NAME@SERVER",
)
parser.add_argument(
"--stat_func",
default=OPT_STAT_FUNC,
choices=StatFunc.get_stat_funcs(),
help="Select how to calculate for ally/enemy performance: tank-tier stats, global player stats",
)
parser.add_argument(
"-u", "--url", action="store_true", default=False, help="Print replay URLs"
)
parser.add_argument(
"--tankfile",
type=str,
default="tanks.json",
help='JSON file to read Tankopedia from. Default is "tanks.json"',
)
parser.add_argument(
"--mapfile",
type=str,
default="maps.json",
help='JSON file to read Blitz map names from. Default is "maps.json"',
)
parser.add_argument(
"--json",
action="store_true",
default=False,
help="Export data in JSON format",
)
parser.add_argument(
"--csv",
action="store_true",
default=False,
help="Export data in CSV format",
)
parser.add_argument(
"--replays",
action="store_true",
default=False,
help="Export replay info in JSON format",
)
parser.add_argument(
"-o",
"--outfile",
type=str,
default="-",
metavar="OUTPUT",
help="File to write results. Default STDOUT",
)
parser.add_argument(
"--db",
action="store_true",
default=OPT_DB,
help="Use DB - You are unlikely to have it",
)
parser.add_argument(
"--filters",
type=str,
default=None,
help='Filter replays based on categories. Filters given in JSON format.\nUse array "[]" for multiple filters/values. see --mode help.\nExample: : [ {"tank_tier" : [8,9,10] }, { "player_wins" : 5 }]',
)
parser.add_argument(
"--filters_db",
type=str,
default=None,
help="[only for DB setup] Filter replays in DB based on categories. Filters given in MongoDB JSON format. See --mode help",
)
parser.add_argument(
"--min",
type=int,
default=None,
help="Only select replays from players with minimum number of replays in the dataset",
)
parser.add_argument(
"-d", "--debug", action="store_true", default=False, help="Debug mode"
)
parser.add_argument(
"-v", "--verbose", action="store_true", default=False, help="Verbose mode"
)
parser.add_argument(
"-s", "--silent", action="store_true", default=False, help="Silent mode"
)
parser.add_argument(
"-l",
"--log",
action="store_true",
default=False,
help="Enable file logging",
)
parser.add_argument(
"files",
metavar="FILE1 [FILE2 ...]",
type=str,
nargs="*",
help="Files/dirs to read. Use '-' for STDIN, \"db:\" for database",
)
## parser.add_argument('--help_filters', action='store_true', default=False, help='Extended help for --filters')
try:
args = parser.parse_args()
except Exception as err:
bu.error("Invalid arguments", exception=err)
sys.exit(0)
# res_categories = BattleCategorization.get_categorizations(OPT_CATEGORIZATIONS, args)
bu.set_log_level(args.silent, args.verbose, args.debug)
# bu.set_progress_step(250) # Set the frequency of the progress dots.
#### Connect to MongoDB. You are unlikely to have this set up...
bu.debug("DB_SERVER: " + DB_SERVER)
bu.debug("DB_PORT: " + str(DB_PORT))
bu.debug("DB_TLS: " + "True" if DB_TLS else "False")
bu.debug("DB_AUTH: " + DB_AUTH)
bu.debug("DB_NAME: " + DB_NAME)
client = None
db = None
if args.db:
try:
client = AsyncIOMotorClient(
DB_SERVER,
DB_PORT,
authSource=DB_AUTH,
username=DB_USER,
password=DB_PASSWD,
tls=DB_TLS,
tlsAllowInvalidCertificates=DB_CERT_REQ,
tlsCertificateKeyFile=DB_CERT,
tlsCAFile=DB_CA,
)
db = client[DB_NAME]
bu.debug("Database connection initiated")
except Exception as err:
bu.error("Could no initiate DB connection: Disabling DB", err)
args.db = False
else:
bu.debug("No DB in use")
# TBD
if args.mode == OPT_MODE_HELP:
await help_extended(db, parser)
sys.exit(0)
elif len(args.files) == 0:
raise UserWarning("No FILES argument given: No replays to analyse")
if args.log:
await bu.set_file_logging("analyze_wotb_replays", add_timestamp=True)
StatFunc.set_stat_func(args.stat_func)
# rebase file arguments due to moving the working directory to the script location
args.files = bu.rebase_file_args(current_dir, args.files)
except Exception as err:
bu.error(err)
sys.exit(1)
try:
wg = WG(
WG_app_id=WG_APP_ID,
tankopedia_fn=args.tankfile,
maps_fn=args.mapfile,
stats_cache=True,
rate_limit=WG_RATE_LIMIT,
)
wi = WoTinspector(rate_limit=10)
if args.account is not None:
args.account_id = await wg.get_account_id(args.account)
bu.debug("WG account_id: " + str(args.account_id))
# try:
BattleCategory.set_fields(args.mode)
replayQ = Queue(maxsize=1000)
reader_tasks = []
# Make replay Queue
scanner_task = asyncio.create_task(mk_replayQ(replayQ, args, db))
bu.set_counter("Reading replays", 10)
# Start tasks to process the Queue
for i in range(OPT_WORKERS_N):
reader_tasks.append(asyncio.create_task(replay_reader(replayQ, i, args)))
bu.debug("ReplayReader " + str(i) + " started")
bu.debug("Waiting for the replay scanner to finish")
await asyncio.wait([scanner_task])
bu.finish_progress_bar()
# bu.debug('Scanner finished. Waiting for replay readers to finish the queue')
await replayQ.join()
await asyncio.sleep(0.1)
bu.debug("Replays read. Cancelling Readers and analyzing results")
for task in reader_tasks:
task.cancel()
await asyncio.sleep(0.1)
results = []
players = set()
replays = dict()
for res in await asyncio.gather(*reader_tasks):
results.extend(res[0])
players.update(res[1])
replays.update(res[2])
if len(players) == 0:
raise Exception("No players found to fetch stats for. No replays found?")
if args.min is not None:
results = filter_min_replays_by_player(results, args.min)
# Filter based on non-stats filters
results = filter_results(results, args.filters, stats_filters=False)
players = get_players(results)
(player_stats, stat_id_map) = await process_player_stats(
players, OPT_WORKERS_N, args, db
)
# Filter based on stats filters
results = filter_results(results, args.filters, stats_filters=True)
bu.debug(str(len(replays)) + " replays to process")
replays = filter_replays(replays, results)
bu.debug("Number of player stats: " + str(len(player_stats)))
teamresults = calc_team_stats(results, player_stats, stat_id_map, args)
blt_cat_list = process_battle_results(teamresults, args)
bu.verbose_std("WR = " + StatFunc.get_title())
blt_cat_list.print_results()
histograms = None
if args.hist:
histograms = process_player_dist(results, player_stats, stat_id_map)
print_player_dist(histograms)
if args.json:
await export_json(args, blt_cat_list, histograms)
if args.csv:
await export_csv(args, blt_cat_list, histograms)
# if args.replays:
# await export_replays(replays)
bu.debug("Finished. Cleaning up..................")
# except Exception as err:
# bu.error(exception=err)
except UserWarning as err:
bu.warning(str(err))
except asyncio.CancelledError:
pass
except Exception as err:
bu.error(exception=err)
finally:
## Need to close the aiohttp.session since Python destructors do not support async methods...
if wg is not None:
await wg.close()
if wi is not None:
await wi.close()
return None
async def export_csv(
args: argparse.Namespace,
blt_cat_list: BattleCategorizationList,
histograms: dict = None,
):
"""Export results into a CSV file"""
try:
rows = blt_cat_list.get_results_list()
if args.hist:
rows.append("")
rows.append(["Player Histograms"])
for histogram in histograms.values():
rows.append("")
rows.extend(histogram.get_results_list())
if args.outfile == "-":
export_file = OPT_EXPORT_CSV_FILE
else:
export_file = args.outfile
with open(export_file, "w") as csvfile:
# creating a csv writer object
csvwriter = csv.writer(csvfile)
csvwriter.writerows(rows)
bu.verbose_std("Results exported to: " + export_file)
except Exception as err:
bu.error(exception=err)
async def export_json(
args: argparse.Namespace,
blt_cat_list: BattleCategorizationList,
histograms: dict = None,
):
"""Export results into a JSON file"""
try:
res = blt_cat_list.get_results_json()
if histograms is not None:
res_hist = dict()
for histogram in histograms.keys():
res_hist[histogram] = histograms[histogram].get_results()
res["histograms"] = res_hist
if args.outfile == "-":
export_file = OPT_EXPORT_JSON_FILE
else:
export_file = args.outfile
await bu.save_JSON(
export_file, res, pretty=False
) ## Excel does not understand pretty JSON
bu.verbose_std("Results exported to: " + export_file)
except Exception as err:
bu.error(exception=err)
async def help_extended(
db: AsyncIOMotorDatabase | None = None,
parser: argparse.ArgumentParser | None = None,
):
"""Help for --filters"""
if parser is not None:
parser.print_help()
# Result category options
# Results fields
print("\n-------------------------------------------------------------------")
print("| Result fields |")
print("-------------------------------------------------------------------\n")
BattleCategory.help()
print("\n-------------------------------------------------------------------")
print("| Filters |")
print("-------------------------------------------------------------------\n")
print("\t--filters '{ \"categorization\" : category }'")
print(
"\tThese filters let you filter only battles matching the filter. The filter has been proper JSON dict."
)
print("\tExample: --filter '{ \"room_type\" :1 }'")
print("")
BattleCategorizationList.help()
print("\n-------------------------------------------------------------------")
print("| Rounding errors |")
print("-------------------------------------------------------------------\n")
print("Yes, the numbers are rounded UP because reasons.")
print("If interested why please read this: ")
print("\thttps://realpython.com/python-rounding/#pythons-built-in-round-function")
print("No, I am not planning to fix these for now.")
# Filter usage
print("\n-------------------------------------------------------------------")
print("| DB Filter usage |")
print("-------------------------------------------------------------------\n")
print(
'You are unlikely to have DB setup required for these filters. This is "Jylpah\'s special"'
)
print("")
print(
'Syntax: --filters_db \'{ "replay.param": VALUE, "replay.param2": { "$MONGO_OPERATOR" : VALUE }, ... }\''
)
print(
'\tExample: --filters_db \'{ "data.summary.protagonist": ACCOUNT_ID, "data.summary.battle_start_timestamp": { "$gt" : 1602853200 } }\''
)
if db is not None:
dbc = db[DB_C_REPLAYS]
try:
res = await dbc.find_one()
summary = res["data"]["summary"]
details = summary["details"][0]
print("\n--filters KEYS:")
for s in summary:
print("\tdata.summary." + s)
for d in details:
print("\tdata.summary.details.[]." + d)
except Exception as err:
bu.error(exception=err)
# async def export_replays(replays: dict):
# """Export replays"""
# global wg
# try:
# replay_dir = OPT_EXPORT_REPLAY_DIR + "_" + bu.get_date_str()
# bu.verbose_std("Exporting replays to " + replay_dir)
# if not os.path.isdir(replay_dir):
# os.mkdir(replay_dir)
# i = 0
# for replay in replays.values():
# filename = wg.get_replay_filename(replay)
# async with aiofiles.open(
# os.path.join(replay_dir, filename), "w", encoding="utf8"
# ) as f:
# await f.write(json.dumps(replay, indent=4))
# i += 1
# bu.verbose_std(str(i) + " replays exported")
# except Exception as err:
# bu.error(exception=err)
## move the class PlayerHistogram?
def set_histogram_buckets(json: dict):
global histogram_fields
try:
for field in json.keys():
histogram_fields[field][1] = json[field]
except (KeyError, Exception) as err:
bu.error(exception=err)
return histogram_fields
def get_players(results: list) -> set:
try:
players = set()
for res in results:
players.add(res["player"])
players.update(res["allies"])
players.update(res["enemies"])
return players
except Exception as err:
bu.error(exception=err)
return None
def filter_min_replays_by_player(results: list, min_replays: int) -> list:
try:
replay_counter = collections.defaultdict(def_value_zero)
for res in results:
replay_counter[res["protagonist"]] += 1
players_enough_replays = set()
for player in replay_counter.keys():
if replay_counter[player] >= min_replays:
players_enough_replays.add(player)
res_ret = list()
for res in results:
if res["protagonist"] in players_enough_replays:
res_ret.append(res)
bu.verbose_std("Replays after filtering: " + str(len(res_ret)))
return res_ret
except Exception as err:
bu.error(exception=err)
return None
def process_player_dist(results: list, player_stats: dict, stat_id_map: dict) -> dict:
"""Process player distribution"""
hist_stats = dict()
try:
for field in histogram_fields:
hist_stats[field] = PlayerHistogram(
field,
histogram_fields[field][0],
histogram_fields[field][1],
histogram_fields[field][2],
histogram_fields[field][3],
)
for result in results:
for player in result["allies"] | result["enemies"]: # union of Sets
player_remapped = stat_id_map[player]
if player_remapped in player_stats:
if player in result["allies"]:
for stat_field in hist_stats:
hist_stats[stat_field].record_ally(
player_stats[player_remapped][stat_field]
)
else:
for stat_field in hist_stats:
hist_stats[stat_field].record_enemy(
player_stats[player_remapped][stat_field]
)
for stat_field in hist_stats:
hist_stats[stat_field].calc_results()
return hist_stats
except Exception as err:
bu.error(exception=err)
return None
def print_player_dist(histograms: dict):
# Print results
print("\nPlayer Histograms______", end="", flush=True)
for hist in histograms.values():
print("")
hist.print()
def process_battle_results(results: dict, args: argparse.Namespace):
"""Process replay battle results"""
try:
url = args.url
cats = BattleCategorizationList.get_categorizations(args)
blt_cat_list = None
# remove if?
if len(results) > 0:
blt_cat_list = BattleCategorizationList(cats)
for result in results:
blt_cat_list.record_result(result)
if url:
blt_cat_list.record_url(result)
blt_cat_list.calc_results()
else:
warning_txt = "No replays to process "
if args.filters is not None:
warning_txt = warning_txt + "after filtering"
bu.warning(warning_txt)
except Exception as err:
bu.error(exception=err)
return blt_cat_list
def filter_results(results: list, filters_json: str, stats_filters=None) -> bool:
"""Filter replays based on battle category filters"""
try:
if filters_json is None:
return results
filters = json.loads(filters_json)
bu.debug(str(filters))
if type(filters) != dict:
bu.error("invalid --filter arguments: " + str(filter))
sys.exit(1)
filter_cats = BattleCategorizationList.get_categorizations_stats(
filters.keys(), stats_filters
)
filter_cats = BattleCategorizationList(filter_cats)
if filter_cats.len() == 0:
bu.debug("Nothing to filter")
return results
bu.verbose("Filters applied:")
for category in filter_cats:
cat = category.category_key
categorization = filter_cats.get_categorization(cat)
bu.debug("Category: " + cat + " filters: " + str(filters))
filters[cat] = categorization.get_filter_categories(filters[cat])
bu.verbose(" " + cat + " = " + ",".join(filters[cat]))
bu.verbose("")
res = list()
for result in results:
try:
cats = filter_cats.get_categories(result)
bu.debug(str(cats))
ret = False
for cat in cats:
if cats[cat] not in filters[cat]:
ret = False
break
ret = True
if ret:
res.append(result)
except Exception as err:
bu.error(exception=err)
if len(res) == 0:
raise UserWarning("No replays left after filtering")
bu.verbose_std("Replays after filtering: " + str(len(res)))
return res
except UserWarning:
raise
except Exception as err:
bu.error(exception=err)
return None
def filter_replays(replays_in: dict, results: list) -> list:
"""Filter source replays based on (filtered) results"""
replays = dict()
for res in results:
try:
# bu.debug('res[_id] : ' + res['_id'], force=True)
_id = res["_id"]
replays[_id] = replays_in[_id]
except Exception as err:
bu.error(exception=err)
return replays
async def process_player_stats(
players,
N_workers: int,
args: argparse.Namespace,
db: AsyncIOMotorDatabase,
) -> dict:
"""Start stat_workers to retrieve and store players' stats"""
## Create queue of player-tank pairs to find stats for
try:
statsQ = Queue()
bu.debug("Create player stats queue: " + str(len(players)) + " players")
stat_id_map = dict()
stat_ids = set()
stat_id_map_func = globals()[StatFunc.get_stat_id_func()]
for player in players:
stat_id_map[player] = stat_id_map_func(player)
stat_ids.add(stat_id_map[player])
bu.set_progress_bar("Fetching player stats", len(stat_ids), 25, True)
# create statsQ
for stat_id in stat_ids:
await statsQ.put(stat_id)
# Process player WR / battle stats
stats_tasks = []
bu.debug("Starting player stats workers")
for i in range(N_workers):
bu.debug("Starting worker " + str(i))
stats_tasks.append(asyncio.create_task(stat_worker(statsQ, i, args, db)))
bu.debug("Waiting stats workers to finish")
await statsQ.join()
bu.debug("Cancelling stats workers")
for task in stats_tasks:
task.cancel()
player_stats = dict()
stat_id_remap = dict()
bu.debug("Gathering stats worker outputs")
for stats, id_remap in await asyncio.gather(*stats_tasks):
player_stats = {**player_stats, **stats}
stat_id_remap = {**stat_id_remap, **id_remap}
bu.finish_progress_bar()
## DO REMAPPING
stat_id_map = remap_stat_id(stat_id_map, stat_id_remap)
bu.debug("Returning player_stats")
return (player_stats, stat_id_map)
except Exception as err:
bu.error(exception=err)
return None
def remap_stat_id(stat_id_map: dict, stat_id_remap: dict):
bu.debug(
"Remapping stat_ids: stat_id_map: "
+ str(len(stat_id_map))
+ " remap needed: "
+ str(len(stat_id_remap))
)
for stat_id in stat_id_map:
# bu.debug(stat_id)
if stat_id_map[stat_id] in stat_id_remap.keys():
stat_id_map[stat_id] = stat_id_remap[stat_id_map[stat_id]]
return stat_id_map
def calc_team_stats(
results: list, player_stats: dict, stat_id_map: dict, args: argparse.Namespace
) -> list:
"""Calculate team stats"""
return_list = []
## Bug here??
# stat_types = player_stats[list(player_stats.keys())[0]].keys()
# stat_types = list()
stat_types = BattleCategory.get_fields_team()
for result in results:
try:
missing_stats = 0
n_players = len(result["allies"]) + len(result["enemies"])
n_allies = collections.defaultdict(def_value_zero)
allies_stats = collections.defaultdict(def_value_zero)
n_enemies = collections.defaultdict(def_value_zero)
enemies_stats = collections.defaultdict(def_value_zero)
# bu.debug('Processing Allies')
for ally in result["allies"]:
# Player itself is not in 'allies': see read_replay_JSON()
ally_mapped = stat_id_map[ally]
if ally_mapped not in player_stats.keys():
missing_stats += 1
continue
for stat in stat_types:
if player_stats[ally_mapped][stat] is not None:
allies_stats[stat] += player_stats[ally_mapped][stat]
n_allies[stat] += 1
# bu.debug('Processing Enemies')
for enemy in result["enemies"]:
enemy_mapped = stat_id_map[enemy]
if enemy_mapped not in player_stats:
missing_stats += 1
continue
for stat in stat_types:
if player_stats[enemy_mapped][stat] is not None:
enemies_stats[stat] += player_stats[enemy_mapped][stat]
n_enemies[stat] += 1
# bu.debug('Processing player's own stats')
player_mapped = stat_id_map[result["player"]]
if player_mapped not in player_stats:
missing_stats += 1
else:
for stat in stat_types:
if player_stats[player_mapped][stat] is not None:
result["player_" + stat] = player_stats[player_mapped][stat]
for stat in stat_types:
if n_allies[stat] > 0:
result["allies_" + stat] = allies_stats[stat] / n_allies[stat]
else:
bu.debug("No allies stats for: " + str(result))
if n_enemies[stat] > 0:
result["enemies_" + stat] = enemies_stats[stat] / n_enemies[stat]
else:
bu.debug("No enemies stats for: " + str(result))
# Steamroller stats
result["team_result"] = (
str(result["allies_survived"]) + "-" + str(result["enemies_survived"])
)
result[N_PLAYERS] = n_players
result[MISSING_STATS] = missing_stats
return_list.append(result)
except KeyError as err:
bu.error("Key not found", err)
except Exception as err:
bu.error(exception=err)
return return_list
async def stat_worker(
queue: Queue,
workerID: int,
args: argparse.Namespace,
db: AsyncIOMotorDatabase,
) -> list:
"""Worker thread to find stats for player / tank pairs"""
global wg
stats = dict()
stat_id_remap = dict()
stat_db_func = globals()[StatFunc.get_db_func()]
stat_wg_func = globals()[StatFunc.get_wg_func()]
bu.debug("workedID: " + str(workerID) + " started", id=workerID)
try:
while True:
stat_id = await queue.get()
try:
bu.debug("Stat_id: " + stat_id, id=workerID)
bu.print_progress()
stats_tmp = None
# Try cache first
pruned_stat_id = prune_stat_id(stat_id)
if pruned_stat_id not in stats:
stats_tmp = await stat_wg_func(pruned_stat_id, cache_only=True)
else:
stat_id_remap[stat_id] = pruned_stat_id
continue
if stats_tmp is not None:
stats[pruned_stat_id] = stats_tmp
stat_id_remap[stat_id] = pruned_stat_id
continue
# try DB
stats[stat_id] = await stat_db_func(db, stat_id)
bu.debug(
"get_db_"
+ args.stat_func
+ "_stats returned: account_id="
+ str(get_account_id_f_stat_id(stat_id))
+ ": "
+ str(stats[stat_id]),
workerID,
)
# no DB stats found, trying WG AP
if stats[stat_id] is None:
stats[pruned_stat_id] = await stat_wg_func(pruned_stat_id)
stat_id_remap[stat_id] = pruned_stat_id
del stats[stat_id]
bu.debug(
"get_wg_"
+ args.stat_func
+ "_stats returned: account_id="
+ str(get_account_id_f_stat_id(pruned_stat_id))
+ ": "
+ str(stats[pruned_stat_id]),
workerID,
)
except KeyError as err:
bu.error("Key not found", err, id=workerID)
except Exception as err:
bu.error("Unexpected error", err, id=workerID)
finally:
queue.task_done()
except (asyncio.CancelledError, concurrent.futures.CancelledError):
bu.debug("Stats queue is empty", id=workerID)
except Exception as err:
bu.error(exception=err, id=workerID)
finally:
# remove empty stats
try:
keys_2_del = []
for key in stats:
if stats[key] is None:
keys_2_del.append(key)
for key in keys_2_del:
del stats[key]
except KeyError as err:
bu.error("Error in pruning empty stats", err, id=workerID)
# bu.debug('Returning stats & exiting')
return (stats, stat_id_remap)
## player stat functions: tank
async def get_wg_tank_stats(stat_id_str: str, cache_only=False) -> dict:
"""Get player stats from WG. Returns WR per tier of tank_id"""
global wg
try:
(account_id, tank_id) = str2ints(stat_id_str)
# 'battles' must always be there
hist_stats = ["all." + x for x in histogram_fields.keys()]
hist_stats.append("tank_id")
player_stats = await wg.get_player_tank_stats(
account_id, [tank_id], hist_stats, cache_only=cache_only
)
# bu.debug('account_id: ' + str(account_id) + ' ' + str(player_stats))
return tank_stats_helper(player_stats)
except KeyError as err:
bu.error(
"account_id: "
+ str(account_id)
+ " tank_id:"
+ str(tank_id)
+ " : Key not found",
err,
)
except Exception as err:
bu.error(exception=err)
return None
async def get_db_tank_stats(db: AsyncIOMotorDatabase, stat_id_str: str) -> dict:
"""Get player stats from MongoDB (you are very unlikely to have it, unless you have set it up)"""
if db is None:
return None
try:
dbc = db[DB_C_TANK_STATS]
(account_id, tank_id, battletime) = str2ints(stat_id_str)
time_buffer = 2 * 7 * 24 * 3600
hist_stats = ["all." + x for x in histogram_fields.keys()]
hist_stats.append("tank_id")
hist_stats.append("account_id")
hist_stats.append("last_battle_time")
project = dict()
for stat in hist_stats:
project[stat] = True
project["_id"] = False
bu.debug(
"find(): tank_id={} account_id={} last_battle_time>={}".format(
tank_id, account_id, battletime - time_buffer
)
)
cursor = (
dbc.find(
{
"$and": [
{"tank_id": tank_id},
{"account_id": account_id},
{"last_battle_time": {"$gte": battletime - time_buffer}},
]
},
projection=project,
)
.sort("last_battle_time", -1)
.limit(1)
)
# pipeline = [ { '$match': { '$and': [ { 'account_id': account_id }, { 'last_battle_time': { '$lte': battletime + time_buffer }}, { 'tank_id' : tank_id } ]}},
# { '$sort': { 'last_battle_time': -1 }},
# { '$group': { '_id': '$tank_id', 'doc': { '$first': '$$ROOT' }}},
# { '$replaceRoot': { 'newRoot': '$doc' }},
# { '$project': { '_id': 0 }} ]
# cursor = dbc.aggregate(pipeline, allowDiskUse=True)
# cursor = dbc.aggregate(pipeline)
stats = await cursor.to_list(1)
# store cache
# await wg.save_tank_stats(account_id, [tank_id], stats)
return tank_stats_helper(stats)
except Exception as err:
bu.error("account_id: " + str(account_id) + " Error", err)
return None
## player stat functions: tank_tier
async def get_wg_tank_tier_stats(stat_id_str: str, cache_only: bool = False) -> dict:
"""Get player stats from WG. Returns WR per tier of tank_id"""
global wg
try:
(account_id, tier) = str2ints(stat_id_str)
tier_tanks = wg.get_tanks_by_tier(tier)
# 'battles' must always be there
hist_stats = ["all." + x for x in histogram_fields.keys()]
hist_stats.append("tank_id")
player_stats = await wg.get_player_tank_stats(
account_id, tier_tanks, hist_stats, cache_only=cache_only
)
# bu.debug('account_id: ' + str(account_id) + ' ' + str(player_stats))
return tank_stats_helper(player_stats)
except KeyError as err:
bu.error("account_id: " + str(account_id) + " Key not found", err)
except Exception as err:
bu.error(exception=err)
return None
## player stat functions: tier_x
async def get_wg_tier_x_stats(stat_id_str: str, cache_only=False) -> dict:
"""Get player stats from WG. Returns WR per tier of tank_id"""
global wg
try:
account_id = int(stat_id_str)
tier_tanks = wg.get_tanks_by_tier(10)
# 'battles' must always be there
hist_stats = ["all." + x for x in histogram_fields.keys()]
hist_stats.append("tank_id")
player_stats = await wg.get_player_tank_stats(
account_id, tier_tanks, hist_stats, cache_only=cache_only
)
# bu.debug('account_id: ' + str(account_id) + ' ' + str(player_stats))
return tank_stats_helper(player_stats)
except KeyError as err:
bu.error("account_id: " + str(account_id) + " Key not found", err)
except Exception as err:
bu.error(exception=err)
return None
async def get_db_tank_tier_stats(db: AsyncIOMotorDatabase, stat_id_str: str) -> dict:
"""Get player stats from MongoDB (you are very unlikely to have it, unless you have set it up)"""
global wg
if db is None:
return None
try:
dbc = db[DB_C_TANK_STATS]
(account_id, tier, battletime) = str2ints(stat_id_str)
tier_tanks = wg.get_tanks_by_tier(tier)
time_buffer = 2 * 7 * 24 * 3600
pipeline = [
{
"$match": {
"$and": [
{"account_id": account_id},
{"last_battle_time": {"$lte": battletime + time_buffer}},
{"tank_id": {"$in": tier_tanks}},
]
}
},
{"$sort": {"last_battle_time": -1}},
{"$group": {"_id": "$tank_id", "doc": {"$first": "$$ROOT"}}},
{"$replaceRoot": {"newRoot": "$doc"}},
{"$project": {"_id": 0}},
]
# cursor = dbc.aggregate(pipeline, allowDiskUse=True)
cursor = dbc.aggregate(pipeline)
stats = await cursor.to_list(1000)
# await wg.save_tank_stats(account_id, tier_tanks, stats)
return tank_stats_helper(stats)
except Exception as err:
bu.error("account_id: " + str(account_id) + " Error", err)
return None
async def get_db_tier_x_stats(db: AsyncIOMotorDatabase, stat_id_str: str) -> dict:
"""Get player stats from MongoDB (you are very unlikely to have it, unless you have set it up)"""
if db is None:
return None
try:
(account_id, battletime) = str2ints(stat_id_str)
return await get_db_tank_tier_stats(db, get_stat_id(account_id, 10, battletime))
except Exception as err:
bu.error("account_id: " + str(account_id) + " Error", err)
return None
## player stat functions: player
async def get_db_player_stats(db: AsyncIOMotorDatabase, stat_id_str: str) -> dict:
"""Get player stats from MongoDB (you are very unlikely to have it, unless you have set it up)"""
if db is None:
return None
try:
dbc = db[DB_C_TANK_STATS] ## not collecting player stats yet 2019-12-23
(account_id, battletime) = str2ints(stat_id_str)
time_buffer = 2 * 7 * 24 * 3600
pipeline = [
{
"$match": {
"$and": [
{"account_id": account_id},
{"last_battle_time": {"$lte": battletime + time_buffer}},
]
}
},
{"$sort": {"last_battle_time": -1}},
{"$group": {"_id": {"tank_id": "$tank_id"}, "doc": {"$first": "$$ROOT"}}},
{"$replaceRoot": {"newRoot": "$doc"}},
{"$project": {"_id": 0}},
]
# cursor = dbc.aggregate(pipeline, allowDiskUse=True)
cursor = dbc.aggregate(pipeline)
stats = await cursor.to_list(1000)
# await wg.save_tank_stats(account_id, [], stats)
return tank_stats_helper(stats)
except Exception as err:
bu.error("account_id: " + str(account_id) + " Error", err)
return None
async def get_wg_player_stats(stat_id_str: str, cache_only=False) -> dict:
"""Get player stats from WG. Returns WR per tier of tank_id"""
try:
account_id = int(stat_id_str)
# 'battles' must always be there
hist_stats = ["statistics.all." + x for x in histogram_fields.keys()]
hist_stats.append("account_id")
player_stats = await wg.get_player_stats(
account_id, hist_stats, cache_only=cache_only
)
# bu.debug('account_id: ' + str(account_id) + ' ' + str(player_stats))
return await player_stats_helper(player_stats)
except KeyError as err:
bu.error("account_id: " + str(account_id) + " Key not found", err)
except Exception as err:
bu.error(exception=err)
return None
def tank_stats_helper(stat_list: list):
"""Helpher func got get_db_tank_tier_stats() and get_wg_tank_tier_stats()"""
try:
if stat_list is None:
return None
stats = collections.defaultdict(def_value_zero)
hist_fields = histogram_fields.keys()
if "battles" not in hist_fields:
bu.error("'battles' must be defined in 'histogram_fields'")
return None
for tank_stat in stat_list:
tank_stat = tank_stat["all"]
for field in hist_fields:
stats[field] += max(0, tank_stat[field])
if stats["battles"] == 0:
return None
for field in hist_fields:
if field == "battles":
continue
stats[field] = stats[field] / stats["battles"]
return stats
except Exception as err:
bu.error(exception=err)
return None
async def player_stats_helper(player_stats: dict):
"""Extract relevant stats for player histogram from raw player stats"""
try:
if player_stats is None:
return None
stats = dict()
hist_fields = histogram_fields.keys()
if "battles" not in hist_fields:
bu.error("'battles' must be defined in 'histogram_fields'")
return None
for field in hist_fields:
stats[field] = max(0, player_stats["statistics"]["all"][field])
if stats["battles"] == 0:
return None
for field in hist_fields:
if field == "battles":
continue
stats[field] = stats[field] / stats["battles"]
return stats
except Exception as err:
bu.error(exception=err)
return None
async def mk_replayQ(
replayQ: Queue,
args: argparse.Namespace,
db: AsyncIOMotorDatabase | None = None,
):
"""Create queue of replays to post"""
p_replayfile = re.compile(".*\\.wotbreplay\\.json$")
files: list[str] = args.files
Nreplays = 0
if files[0] == "db:":
if db is None:
bu.error("No database connection opened")
sys.exit(1)
try:
dbc = db[DB_C_REPLAYS]
cursor = None
if args.filters_db is not None:
bu.debug(str(args.filters_db))
filters = json.loads(args.filters_db)
bu.debug(json.dumps(filters, indent=2))
cursor = dbc.find(filters)
else:
# select all
cursor = dbc.find(dict())
bu.debug("Reading replays...")
async for replay_json in cursor:
_id = replay_json["_id"]
# del(replay_json['_id'])
await replayQ.put(await mk_readerQ_item(replay_json, _id=_id))
Nreplays += 1
bu.debug("All the matching replays have been read from the DB")
except Exception as err:
bu.error(exception=err)
elif files[0] == "-":
bu.debug("reading replay file list from STDIN")
stdin, _ = await aioconsole.get_standard_streams()
while (line := (await stdin.readline()).decode("utf-8").rstrip()) is not None:
try:
if p_replayfile.match(line) is not None:
replay_json = await bu.open_JSON(line, wi.chk_JSON_replay)
await replayQ.put(await mk_readerQ_item(replay_json, filename=line))
except Exception as err:
bu.error(exception=err)
else:
jsonQ = FileQueue(maxsize=500, filter="*.wotbreplay.json")
scanner: Task = create_task(jsonQ.mk_queue(files=files))
async for fn in jsonQ:
try:
replay_json = await bu.open_JSON(fn, wi.chk_JSON_replay)
await replayQ.put(await mk_readerQ_item(replay_json, filename=fn))
bu.debug("File added to queue: " + fn)
Nreplays += 1
except Exception as err:
bu.error(exception=err)
await asyncio.wait([scanner])
return Nreplays
async def mk_readerQ_item(
replay_json, filename: str | None = None, _id: str | None = None
) -> list | None:
"""Make an item to replay queue"""
global REPLAY_N, wi
REPLAY_N += 1
try:
if wi.chk_JSON_replay(replay_json):
if not "_id" in replay_json:
if _id is None:
_id = wi.read_replay_id(replay_json)
replay_json["_id"] = _id
else:
replay_json = None # mark error
except Exception as err:
bu.error(exception=err)
if filename is not None:
return [replay_json, REPLAY_N, os.path.basename(filename)]
elif _id is not None:
return [replay_json, REPLAY_N, "DB: " + _id]
else:
return None
async def replay_reader(queue: Queue, readerID: int, args: argparse.Namespace):
"""Async Worker to process the replay queue"""
# global SKIPPED_N
global wi
results = []
playerstanks = set()
replays = dict()
try:
while True:
item = await queue.get()
replay_json = item[0]
replayID = item[1]
replay_file = item[2]
try:
# msg_str = 'Replay[' + str(replayID) + ']: '
if replay_json is None:
bu.warning(
"Invalid replay. Skipping: "
+ (replay_file if replay_file is not None else "")
)
queue.task_done()
continue
## Read the replay JSON
bu.debug("reading replay", readerID)
result = await read_replay_JSON(replay_json, args)
bu.print_progress()
if result is None:
bu.warning(
"Invalid replay "
+ (replay_file if replay_file is not None else "")
)
queue.task_done()
continue
playerstanks.update(set(result["allies"]))
playerstanks.update(set(result["enemies"]))
playerstanks.update(set([result["player"]]))
_id = wi.read_replay_id(replay_json)
replays[_id] = replay_json
results.append(result)
bu.debug("Marking replay [" + str(replayID) + "] done", id=readerID)
except Exception as err:
bu.error(exception=err)
queue.task_done()
except (asyncio.CancelledError, concurrent.futures.CancelledError):
bu.debug(
str(len(results)) + " replays, " + str(len(playerstanks)) + " player/tanks",
readerID,
)
return results, playerstanks, replays
return None
async def read_replay_JSON(replay_json: dict, args: argparse.Namespace) -> dict:
"""Parse replay JSON dict"""
global REPLAY_I, wg
account_id = args.account_id
url = args.url
# db = args.db
result = dict()
try:
# bu.debug(str(replay_json))
if not wi.chk_JSON_replay(replay_json):
bu.debug("Invalid replay")
return None
result["_id"] = replay_json["_id"]
replay_summary = replay_json["data"]["summary"]
result["battle_start_timestamp"] = int(replay_summary["battle_start_timestamp"])
# TBD...
protagonist = int(replay_summary["protagonist"])
# For filtering replays per submitter
result["protagonist"] = protagonist
if account_id is None:
account_id = protagonist
elif protagonist != account_id:
if account_id in replay_summary["enemies"]:
# switch the teams...
if replay_summary["battle_result"] != 2:
if replay_summary["battle_result"] == 0:
replay_summary["battle_result"] = 1
else:
replay_summary["battle_result"] = 0
tmp = replay_summary["enemies"]
replay_summary["enemies"] = replay_summary["allies"]
replay_summary["allies"] = tmp
elif account_id in replay_summary["allies"]:
pass
else:
# looking for an account_id, but account_id not found in the teams => skipping the replay
return None
if url:
result["url"] = replay_json["data"]["view_url"]
for key in replay_summary_flds:
result[key] = replay_summary[key]
except Exception as err:
bu.error(exception=err)
return None
try:
# bu.debug('Part 2')
result["allies"] = set()
result["enemies"] = set()
result["allies_survived"] = 0 # for steamroller stats
result["enemies_survived"] = 0 # for steamroller stats
btl_duration = 0
btl_tier = 0
protagonist_tank = None
for player in replay_summary["details"]:
tank_id: int = player["vehicle_descr"]
btl_duration = max(btl_duration, player["time_alive"])
player_tank_tier = wg.get_tank_tier(tank_id)
btl_tier = max(btl_tier, player_tank_tier)
if player["dbid"] == account_id:
# player itself is not put in results['allies']
tmp = dict()
tmp["account_id"] = account_id
tmp["tank_id"] = tank_id
tmp["tank_tier"] = player_tank_tier
tmp["tank_name"] = wg.get_tank_name(tank_id)
tmp["tank_type"] = wg.get_tank_type(tank_id)
tmp["tank_nation"] = wg.get_tank_nation(tank_id)
tmp["is_premium"] = int(wg.is_premium(tank_id))
platoon_ndx = player["squad_index"]
tmp["squad_index"] = platoon_ndx
tmp["in_platoon"] = 0 if platoon_ndx is None else 1
for key in replay_details_flds:
tmp[key] = player[key]
if player["hitpoints_left"] == 0:
tmp["survived"] = 0
tmp["destroyed"] = 1
else:
tmp["survived"] = 1
tmp["destroyed"] = 0
result["allies_survived"] += 1
for key in tmp.keys():
result[key] = tmp[key]
else:
tmp_account_id = player["dbid"]
tmp_tank_id = player["vehicle_descr"]
tmp_battletime = result["battle_start_timestamp"]
if player["death_reason"] == -1:
survived = True
else:
survived = False
if player["dbid"] in replay_summary["allies"]:
result["allies"].add(
get_stat_id(tmp_account_id, tmp_tank_id, tmp_battletime)
)
if survived:
result["allies_survived"] += 1
else:
result["enemies"].add(
get_stat_id(tmp_account_id, tmp_tank_id, tmp_battletime)
)
if survived:
result["enemies_survived"] += 1
result["battle_duration"] = btl_duration
## Rather use 'player' than incomprehensible 'protagonist'...
result["player"] = get_stat_id(
account_id, tank_id, result["battle_start_timestamp"]
)
bu.debug("Player stats_id: " + result["player"])
# remove platoon buddy from stats
if result["squad_index"] is not None:
for player in replay_summary["details"]:
bu.debug(str(player))
if (
(player["squad_index"] == result["squad_index"])
and (player["dbid"] in replay_summary["allies"])
and (player["dbid"] != account_id)
):
# platoon buddy found
tmp_account_id = player["dbid"]
tmp_tank_id = player["vehicle_descr"]
# tmp_tank_tier = str(wg.get_tank_tier(tmp_tank_id))
tmp_battletime = result["battle_start_timestamp"]
# platoon buddy removed from stats
result["allies"].remove(
get_stat_id(tmp_account_id, tmp_tank_id, tmp_battletime)
)
break
for ratio in BattleCategory.get_result_fields_ratio(all=True):
result[ratio] = BattleCategory.calc_ratio(ratio, result)
result["alive"] = min(result["time_alive"] / result["battle_duration"], 1)
result["battle_tier"] = btl_tier
result["top_tier"] = 1 if (result["tank_tier"] == btl_tier) else 0
result["win"] = 1 if result["battle_result"] == 1 else 0
REPLAY_I += 1
result["battle_i"] = REPLAY_I
result["battle"] = (
str(REPLAY_I)
+ ": "
+ replay_summary["vehicle"]
+ " @"
+ replay_summary["map_name"]
)
bu.debug(str(result))
return result
except KeyError as err:
bu.warning("Invalid replay. Key not found", err)
except Exception as err:
bu.error(exception=err)
return None
def str2ints(stat_id_str: str) -> list:
"""Convert stat_id string e.g. 'account_id:tank_id' to list of ints"""
return [int(x) for x in stat_id_str.split(":")]
def prune_stat_id(stat_id_str: str) -> str:
stat_id = stat_id_str.split(":")
del stat_id[-1]
return ":".join(stat_id)
def get_stat_id(account_id: int, tank_id: int, battle_time: int) -> str:
return ":".join(map(str, [account_id, tank_id, battle_time]))
def get_stat_id_tank_tier(stat_id_str: str) -> str:
"""Return stat_id = account_id:tank_tier:battletime"""
global wg
try:
stat_id = str2ints(stat_id_str)
account_id = stat_id[0]
tank_tier = wg.get_tank_tier(stat_id[1])
battle_time = (stat_id[2] // BATTLE_TIME_BUCKET) * BATTLE_TIME_BUCKET
return get_stat_id(account_id, tank_tier, battle_time)
except Exception as err:
bu.error("Stats_id: " + stat_id_str, exception=err)
return None
def get_stat_id_tank(stat_id_str: str) -> str:
"""Return stat_id = account_id:tank_id:battletime"""
try:
stat_id = str2ints(stat_id_str)
account_id = stat_id[0]
tank_id = stat_id[1]
battle_time = (stat_id[2] // BATTLE_TIME_BUCKET) * BATTLE_TIME_BUCKET
return get_stat_id(account_id, tank_id, battle_time)
except Exception as err:
bu.error("Stats_id: " + stat_id_str, exception=err)
return None
def get_stat_id_player(stat_id_str: str) -> str:
"""Return stat_id = account_id:battletime"""
try:
stat_id = str2ints(stat_id_str)
battle_time = (stat_id[2] // BATTLE_TIME_BUCKET) * BATTLE_TIME_BUCKET
return ":".join(map(str, [stat_id[0], battle_time]))
except Exception as err:
bu.error("Stats_id: " + stat_id_str, exception=err)
return None
def get_account_id_f_stat_id(stat_id_str: str) -> int:
"""get account_id from a stat_id"""
try:
stat_id = str2ints(stat_id_str)
return stat_id[0]
except Exception as err:
bu.error("Stats_id: " + stat_id_str, exception=err)
return None
### main()
if __name__ == "__main__":
# To avoid 'Event loop is closed' RuntimeError due to compatibility issue with aiohttp
if sys.platform.startswith("win") and sys.version_info >= (3, 8):
try:
from asyncio import WindowsSelectorEventLoopPolicy
except ImportError:
pass
else:
if not isinstance(
asyncio.get_event_loop_policy(), WindowsSelectorEventLoopPolicy
):
asyncio.set_event_loop_policy(WindowsSelectorEventLoopPolicy())
asyncio.run(main(sys.argv[1:]), debug=False)