Author SHA1 Message Date
Jylpah 55a1eb65c5 Update 4.10: replays were added 2023-05-01 21:16:44 +03:00
Jylpah 4ca2ad5b69 Update 9.8 2023-05-01 21:14:29 +03:00
Jylpah becc608f0c release 9.7 2023-02-15 14:18:25 +02:00
Jylpah 97e9cf5a54 Add releases 9.4 - 9.6 2023-01-11 00:41:44 +02:00
Jylpah f5e67798b4 get_tank_type(), get_tank_id(): remove async 2022-10-30 15:46:33 +02:00
Jylpah 2eced2eda0 Update 9.3 2022-10-30 15:13:28 +02:00
Jylpah 025a786bdd Merge pull request #35 from Jylpah/dev-fix-wi-rate-limit
Dev fix replays.wotinspector.com rate limit
2022-10-29 22:22:30 +03:00
Jylpah bca474218e WoTinspector.read_replay_id(): added back 2022-10-29 22:21:14 +03:00
Jylpah e48759ce79 ThrottledClientSession(), WoTinspector(): updated 2022-10-29 22:14:33 +03:00
Jylpah 5c694fd721 Typing + remove unsed imports 2022-10-29 22:13:52 +03:00
Jylpah 1f41901042 update 9.2 2022-10-09 00:18:30 +03:00
Jylpah d275914b32 Update .gitignore 2022-08-18 09:44:39 +03:00
Jylpah 9f8c82f434 Stream client support 2022-08-18 09:42:55 +03:00
Jylpah 3a8f065f35 Update 9.2 2022-08-18 09:42:32 +03:00
Jylpah 0cac200f06 Update 9.1 2022-07-14 11:25:38 +03:00
Jylpah 342b90bcf9 SSL -> TLS 2022-06-09 20:48:50 +03:00
Jylpah a9845b6fbb Update 9.0 2022-06-08 17:25:06 +03:00
Jylpah ee36e9192f Update 8.10 2022-04-30 21:43:24 +03:00
Jylpah 5d5821564e update 8.9 2022-03-24 15:44:48 +02:00
Jylpah 3c6f9dcae3 Merge branch 'master' of https://github.com/Jylpah/blitz-tools 2022-03-24 15:41:26 +02:00
Jylpah 433e2295c2 Update 8.9 2022-03-24 15:41:22 +02:00
Jylpah 3dc9533c6a Update maps.json 2022-03-24 15:41:15 +02:00
Jylpah 83456ae150 Add updates 8.5 - 8.8 2022-02-16 17:48:01 +02:00
Jylpah 0c25c781a9 Update 8.8 2022-02-16 17:41:50 +02:00
Jylpah 244ae314d8 Update 8.8 2022-02-16 17:40:17 +02:00
Jylpah 33b0efb48d Update 8.6 2022-01-22 22:27:34 +02:00
Jylpah 574fb95f00 Update 8.6 2021-12-20 10:15:47 +02:00
Jylpah fdc5db966e Update installation and usage instructions 2021-11-24 10:28:32 +02:00
Jylpah 65e4fd83d8 Update installation instructions 2021-11-24 09:48:39 +02:00
Jylpah e9451c7f5d Merge pull request #34 from Jylpah/stable-filters-update2
Fix --filters and improve performance when filtering
2021-11-23 21:42:45 +02:00
Jylpah fa65872ad2 --filters: Bug fix and performance improvement 2021-11-23 21:42:09 +02:00
Jylpah b2f37ebcf5 Merge branch 'stable-filter-update' into stable-filters-update2 2021-11-23 18:44:09 +02:00
Jylpah d323f11662 Update analyze_wotb_replays.py 2021-11-23 18:38:49 +02:00
Jylpah d847a394a9 Add allies/enemies categorizations: xxx_damage_dealt, xxx_wins, process_battle_results(): Fix bug filter(s) 2021-11-23 18:20:11 +02:00
Jylpah 8514e9db17 Update analyze_wotb_replays.py 2021-11-23 18:08:24 +02:00
Jylpah 521986adf1 Update 8.5 2021-11-23 18:01:17 +02:00
Jylpah 7e39fdc442 Fix --filters bug with number categorization 2021-11-23 09:46:04 +02:00
Jylpah 14ee570ee5 Merge branch 'master' of https://github.com/Jylpah/blitz-tools 2021-10-29 21:18:27 +03:00
Jylpah e0228a4bf1 Fix shebang 2021-10-29 21:18:24 +03:00
Jylpah 2a93cfbce3 update 8.4 2021-10-24 21:19:06 +03:00
Jylpah 78a959224c Update 8.4 2021-10-23 22:07:23 +03:00
Jylpah ae24ab35f7 Updates 8.0 - 8.3 added 2021-09-17 10:44:38 +03:00
Jylpah ba6b4781e5 Update 8.3 2021-09-16 17:31:10 +03:00
Jylpah 050bd99488 get_filter_categories(): fix, add room_type=5: Quick Tournament(?) 2021-09-10 21:03:41 +03:00
Jylpah eb39c9ce84 Update 8.2 2021-08-22 21:21:42 +03:00
Jylpah 24547763e9 replayWorker(): Better warning text to incomplete replays 2021-08-13 13:43:17 +03:00
Jylpah 28347e1653 Fix printing of player/allies/enemies average damage columns 2021-08-07 16:42:19 +03:00
Jylpah ffe5afbf1e getTitle(): More robust tank names based on tank_ids, not UsrStrs that keep changing. 2021-08-07 16:40:42 +03:00
Jylpah 17ac09afde Update analyze_wotb_replays.py 2021-08-07 16:26:25 +03:00
Jylpah 9117963085 filter_results() bug fix 2021-08-03 19:14:44 +03:00
Jylpah dc8f9a233d Merge pull request #32 from Jylpah/master-platoon
help_extended(): change order of filter helps
2021-08-03 12:43:59 +03:00
Jylpah ce3f9993c5 help_extended(): change order of filter helps 2021-08-03 12:43:43 +03:00
Jylpah fd7eb8261d Merge pull request #31 from Jylpah/master-platoon
Added platoon rate and categorization by platoon/solo games
2021-08-03 12:43:19 +03:00
Jylpah 748ed7806b Added platoon rate and categorization by platoon/solo games 2021-08-03 12:40:36 +03:00
Jylpah 1ada279728 Update Lupus tanks.json 2021-07-27 18:47:08 +03:00
Jylpah 57cbc5b233 Tanks sorted by tank_id 2021-07-15 20:22:46 +03:00
Jylpah 43842dc140 Update 8.1 2021-07-15 20:21:53 +03:00
Jylpah 170a801652 sort_dict(): added 2021-07-15 20:21:32 +03:00
Jylpah 210e943157 Update 8.0 2021-06-08 16:57:42 +03:00
Jylpah b3b7b81eb4 BattleCategory(): add 'hits_bounced' result field 2021-06-03 15:02:27 +03:00
Jylpah 446e289041 Add --mode dmg_received, improved --mode help, new fields 2021-06-03 14:48:17 +03:00
Jylpah f8a0fdfbe1 Merge pull request #30 from Jylpah/stable-export-replays
Stable export replays
2021-05-12 00:16:42 +03:00
Jylpah 4c0de49077 name2tank_str(): error() -> debug() 2021-05-12 00:05:52 +03:00
Jylpah 07aa7af32f bug fix 2021-05-12 00:04:36 +03:00
Jylpah ea3306ee6c WoTinspector.post_replay(): add _id 2021-05-12 00:04:21 +03:00
Jylpah e4882d70af export replays(): --replays option to export replays 2021-05-11 20:55:13 +03:00
Jylpah 433c26d2f8 get_date_str(), get_replay_filename(): added 2021-05-11 20:54:39 +03:00
Jylpah f6b1b1353f Update analyze_wotb_replays.py 2021-05-11 14:14:10 +03:00
10 changed files with 1866 additions and 533 deletions

No files matched your search

Executable
+15
View File
@@ -0,0 +1,15 @@
__pycache__/
BlitzApp
tmp/
ssl/
*.ini
*.swp
*.code-workspace
*.sqlite3
.vscode/
*.sqlite3-journal
.gitattributes
export*
*.csv
!/stats/*.csv
!*.py
+25 -23
View File
@@ -1,5 +1,5 @@
# blitz-tools
Python scripts to analyze WoT Blitz replays and stats. All the scripts require [Python 3.7](https://www.python.org/downloads/) or higher. I am using 3.8 myself so you may have to change the Python interpreter version in the scripts' first line.
Python scripts to analyze WoT Blitz replays and stats. All the scripts require [Python 3.7](https://www.python.org/downloads/) or later.
### Replays
* [upload_wotb_replays.py](upload_wotb_replays.py): An asynchronous replay uploader.
@@ -13,46 +13,48 @@ Python scripts to analyze WoT Blitz replays and stats. All the scripts require [
* [blitzutils.py](blitzutils.py): A utils class for various Blitz related to functions. *Other scripts use this*, so you need to place it to the same folder with the other scripts
### Data
* [tanks.json](tanks.json): Tankopedia in WG JSON format extracted from the Blitz app files with [extract_tankopedia.py]](extractTankopedia.py]). Contains only limited information required by the scripts.
* [maps.json](maps.json): Blitz map names extracted from the Blitz app files with [extract_tankopedia.py]](extract_tankopedia.py]).
* [tanks.json](tanks.json): Tankopedia in WG JSON format extracted from the Blitz app files with [extract_tankopedia.py](extractTankopedia.py). Contains only limited information required by the scripts.
* [maps.json](maps.json): Blitz map names extracted from the Blitz app files with [extract_tankopedia.py](extract_tankopedia.py).
### Quick instructions
1. Install [Python 3.8](https://www.python.org/downloads/) on to your computer/laptop
2. Install the required Python modules: motor, aiohttp, aioconsole, aiofiles, aiosqlite, beautifulsoup4, lxml, xmltodict, progress
1. Install [Python 3.8](https://www.python.org/downloads/) or later to your computer/laptop
2. Install the required Python modules (see [requirements.txt](requirements.txt): motor, aiohttp, aioconsole, aiofiles, aiosqlite, beautifulsoup4, lxml, xmltodict, progress
```
# Remember to run the command with a Python interpreter >= 3.7
python -m pip install motor aiohttp aioconsole aiofiles aiosqlite beautifulsoup4 lxml xmltodict progress
python -m pip install -r requirements.txt
```
3. Download all the files including the [maps.json](maps.json) and [tanks.json](tanks.json) files. **The recommened way** is to install the scipts via `git clone`: [GitHub Desktop](https://desktop.github.com/) is an excellent GUI for git. This makes it easy to receive the updates via `git pull`.
4. Upload your replays.
3. Clone the repository. [GitHub Desktop](https://desktop.github.com/) is an excellent GUI for `git`. This makes it easy to receive the updates via `git pull`.
```
# Windows, run directly from Command Prompt or Powershell
# Check the options
git clone https://github.com/Jylpah/blitz-tools.git
```
4. Upload your replays with `upload_wotb_replays.py`
```
## Check the options
# Windows Command Prompt or Powershell
python.exe .\upload_wotb_replays.py --help
# Syntax
python.exe .\upload_wotb_replays.py REPLAY_FILE.wotbreplay
# Linux or Windows Subsystem for Linux (WSL)
./upload_wotb_replays.py --help
# Upload replays (Windows)
python.exe .\upload_wotb_replays.py REPLAY_FILE.wotbreplay DIR_WITH_REPLAYS
# example
python.exe .\upload_wotb_replays.py 20200223_0122__jylpah_T-54_grossberg.wotbreplay
# Linux
./upload_wotb_replays.py --help
```
5. Analyze replays.
5. Analyze replays with `analyze_wotb_replays.py`
```
# Windows, run directly from Command Prompt or Powershell
## Check options
# Windows Command Prompt or Powershell
python.exe .\analyze_wotb_replays.py --help
# Syntax
python.exe .\analyze_wotb_replays.py REPLAY_FILE.wotbreplay.json
# Syntax (Windows)
python.exe .\analyze_wotb_replays.py REPLAY_FILE.wotbreplay.json [DIR_WITH_REPLAYS ...]
# Example
# Example (Windows)
python.exe .\analyze_wotb_replays.py 20200223_0122__jylpah_T-54_grossberg.wotbreplay.json
# Linux
# Linux or Windows Subsystem for Linux (WSL)
./analyze_wotb_replays.py --help
```
+344 -155
View File
@@ -28,6 +28,10 @@ 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:
@@ -165,6 +169,7 @@ class BattleCategorizationList():
'Regular' : 1,
'Training Room' : 2,
'Tournament' : 4,
'Quick Tournament' : 5, # maybe?
'Rating' : 7,
'Mad Games' : 8,
'Realistic Battles' : 22,
@@ -182,6 +187,7 @@ class BattleCategorizationList():
'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],
@@ -201,7 +207,11 @@ class BattleCategorizationList():
'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, .35, .45, .50, .55, .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, .35, .45, .50, .55, .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],
@@ -218,10 +228,41 @@ class BattleCategorizationList():
'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):
return cls._categorizations.keys()
def get_categorizations_all(cls, cats: list = None) -> list:
try:
if cats == 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 == None:
return cls._categorizations_stats
else:
if stats_cats == 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
@@ -323,7 +364,6 @@ class BattleCategorizationList():
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 ]
@@ -352,6 +392,15 @@ class BattleCategorizationList():
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:
@@ -360,6 +409,7 @@ class BattleCategorizationList():
bu.error(exception=err)
return None
def record_result(self, result: dict):
try:
for cat in self.categorizations_list:
@@ -409,7 +459,7 @@ class BattleCategorizationList():
def get_filter_categories(self, cat: str, filters: list) -> dict:
try:
return self.categorizations[cat].get_filter_categories(filters)
return self.categorizations[str(cat)].get_filter_categories(filters)
except Exception as err:
bu.error(exception=err)
return None
@@ -455,6 +505,20 @@ class BattleCategorizationList():
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
@@ -489,10 +553,13 @@ class BattleCategorization():
def get_filter_categories(self, filters: list) -> list:
if type(filters) != list:
filters = [ filters ]
return [ x for x in filters if x in self.get_categories() ]
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"""
@@ -711,7 +778,10 @@ class BattleStringCategorization(BattleCategorization):
def get_filter_categories(self, filters: list) -> list:
try:
return filters
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
@@ -747,11 +817,14 @@ class BattleNumberCategorization(BattleCategorization):
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')
@@ -892,15 +965,23 @@ class BattleCategory():
'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%}' ],
'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%}' ],
@@ -910,9 +991,9 @@ class BattleCategory():
'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', 11, '{:11.0f}' ],
'allies_damage_dealt' : [ 'Allies Avg Dmg', 'Average damage of the allies', 11, '{:11.0f}' ],
'enemies_damage_dealt' : [ 'Enemies Avg Dmg', 'Average damage 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%}']
}
@@ -921,13 +1002,15 @@ class BattleCategory():
_result_fields_modes = {
'default' : [ 'battles', 'win','damage_made', 'enemies_destroyed', 'enemies_spotted',
'top_tier', 'DR', 'survived', 'allies_wins', 'enemies_wins'],
'team' : [ 'battles', 'win','player_wins','allies_wins','enemies_wins'],
'team_extended' : [ 'battles', 'battles%', 'win','player_wins','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','damage_made', 'enemies_destroyed', 'enemies_spotted',
'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() ]
}
@@ -940,11 +1023,14 @@ class BattleCategory():
]
_result_ratios = {
'KDR' : [ 'enemies_destroyed', 'destroyed' ],
'DR' : [ 'damage_made', 'damage_received' ],
'hit_rate' : [ 'shots_hit', 'shots_made' ],
'pen_rate' : [ 'shots_pen', 'shots_hit' ],
'dmg_block_rate' : [ 'damage_blocked', 'damage_received' ]
'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
@@ -1014,12 +1100,21 @@ class BattleCategory():
@classmethod
def get_field_name(cls, field: str):
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:
@@ -1081,6 +1176,17 @@ class BattleCategory():
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:
@@ -1314,7 +1420,7 @@ class ErrorCatchingArgumentParser(argparse.ArgumentParser):
## main() -------------------------------------------------------------
async def main(argv):
global wg, wi, WG_APP_ID, OPT_MODE_DEFAULT
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]))
@@ -1327,8 +1433,8 @@ async def main(argv):
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'
# 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
@@ -1340,8 +1446,8 @@ async def main(argv):
## VERY unlikely you have a DB set up
DB_SERVER = 'localhost'
DB_PORT = 27017
DB_SSL = False
DB_CERT_REQ = ssl.CERT_NONE
DB_TLS = False
DB_CERT_REQ = False
DB_AUTH = 'admin'
DB_NAME = 'BlitzStats'
DB_USER = 'mongouser'
@@ -1393,14 +1499,14 @@ async def main(argv):
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_SSL = configDB.getboolean('db_ssl', DB_SSL)
DB_CERT_REQ = configDB.getint('db_ssl_req', DB_CERT_REQ)
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_ssl_cert_file', DB_CERT)
DB_CA = configDB.get('db_ssl_ca_file', DB_CA)
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)
@@ -1419,9 +1525,10 @@ async def main(argv):
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: : [ {"tier" : [8,9,10] }, { "player_wins" : 5 }]')
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')
@@ -1445,7 +1552,7 @@ async def main(argv):
#### 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_SSL: ' + "True" if DB_SSL else "False")
bu.debug('DB_TLS: ' + "True" if DB_TLS else "False")
bu.debug('DB_AUTH: ' + DB_AUTH)
bu.debug('DB_NAME: ' + DB_NAME)
@@ -1453,7 +1560,7 @@ async def main(argv):
db = None
if args.db:
try:
client = motor.motor_asyncio.AsyncIOMotorClient(DB_SERVER,DB_PORT, authSource=DB_AUTH, username=DB_USER, password=DB_PASSWD, ssl=DB_SSL, ssl_cert_reqs=DB_CERT_REQ, ssl_certfile=DB_CERT, tlsCAFile=DB_CA)
client = motor.motor_asyncio.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:
@@ -1483,89 +1590,78 @@ async def main(argv):
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 = asyncio.Queue(maxsize=1000)
reader_tasks = []
# Make replay Queue
# try:
BattleCategory.set_fields(args.mode)
replayQ = asyncio.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')
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.debug('Scanner finished. Waiting for replay readers to finish the queue')
await replayQ.join()
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)
bu.debug('Replays read. Cancelling Readers and analyzing results')
for task in reader_tasks:
task.cancel()
await asyncio.sleep(0.1)
results = []
players = set()
for res in await asyncio.gather(*reader_tasks):
results.extend(res[0])
players.update(res[1])
if len(players) == 0:
raise Exception("No players found to fetch stats for. No replays found?")
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 args.min != None:
results = filter_min_replays_by_player(results, args.min)
players = get_players(results)
if len(players) == 0:
raise Exception("No players found to fetch stats for. No replays found?")
(player_stats, stat_id_map) = await process_player_stats(players, OPT_WORKERS_N, args, db)
bu.debug('Number of player stats: ' + str(len(player_stats)))
teamresults = calc_team_stats(results, player_stats, stat_id_map, args)
if args.min != None:
results = filter_min_replays_by_player(results, args.min)
blt_cat_list = process_battle_results(teamresults, args)
bu.verbose_std('WR = ' + StatFunc.get_title())
blt_cat_list.print_results()
# Filter based on non-stats filters
results = filter_results(results, args.filters, stats_filters=False)
players = get_players(results)
histograms = None
if args.hist:
histograms = process_player_dist(results, player_stats, stat_id_map)
print_player_dist(histograms)
(player_stats, stat_id_map) = await process_player_stats(players, OPT_WORKERS_N, args, db)
if args.json:
res = blt_cat_list.get_results_json()
if histograms != 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)
# 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)
if args.csv:
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)
bu.debug('Number of player stats: ' + str(len(player_stats)))
teamresults = calc_team_stats(results, player_stats, stat_id_map, args)
bu.debug('Finished. Cleaning up..................')
except Exception as err:
bu.error(exception=err)
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:
@@ -1581,17 +1677,81 @@ async def main(argv):
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 != 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 : motor.motor_asyncio.AsyncIOMotorDatabase = None, parser: argparse.ArgumentParser = None):
"""Help for --filters"""
if parser != 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('-------------------------------------------------------------------')
print('')
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 }, ... }'")
@@ -1610,23 +1770,24 @@ async def help_extended(db : motor.motor_asyncio.AsyncIOMotorDatabase = None, pa
except Exception as err:
bu.error(exception=err)
print('\n-------------------------------------------------------------------')
print('| Filters |')
print('-------------------------------------------------------------------')
print('')
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('-------------------------------------------------------------------')
print('')
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.')
async def export_replays(replays: dict):
"""Export replays"""
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?
@@ -1668,7 +1829,7 @@ def filter_min_replays_by_player(results: list, min_replays: int) -> 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:
@@ -1711,15 +1872,15 @@ def print_player_dist(histograms: dict):
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
if args.filters != None:
results = filter_replays(results, args.filters)
# remove if?
if len(results) > 0:
blt_cat_list = BattleCategorizationList(cats)
for result in results:
@@ -1730,7 +1891,7 @@ def process_battle_results(results: dict, args : argparse.Namespace):
blt_cat_list.calc_results()
else:
warning_txt = 'No replays to process '
if args.filter != None:
if args.filters != None:
warning_txt = warning_txt + 'after filtering'
bu.warning(warning_txt)
@@ -1739,24 +1900,31 @@ def process_battle_results(results: dict, args : argparse.Namespace):
return blt_cat_list
def filter_replays(results: list, filter_json : str) -> bool:
def filter_results(results: list, filters_json : str, stats_filters=None) -> bool:
"""Filter replays based on battle category filters"""
try:
filters = json.loads(filter_json)
if filters_json == None:
return results
filters = json.loads(filters_json)
bu.debug(str(filters))
for filter in filters:
if type(filters[filter]) != list:
filters[filter] = [ filters[filter]]
if type(filters) != dict:
bu.error('invalid --filter arguments: ' + str(filters))
bu.error('invalid --filter arguments: ' + str(filter))
sys.exit(1)
filter_cats = BattleCategorizationList(filters.keys())
for cat in filters:
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('Filter: ' + cat + ': ' + ','.join(filters[cat]))
bu.verbose(' ' + cat + ' = ' + ','.join(filters[cat]))
bu.verbose('')
res = list()
for result in results:
@@ -1773,13 +1941,31 @@ def filter_replays(results: list, filter_json : str) -> bool:
res.append(result)
except Exception as err:
bu.error(exception=err)
bu.verbose('Replays after filtering: ' + str(len(res)))
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 : motor.motor_asyncio.AsyncIOMotorDatabase) -> dict:
"""Start stat_workers to retrieve and store players' stats"""
## Create queue of player-tank pairs to find stats for
@@ -1835,9 +2021,9 @@ async def process_player_stats(players, N_workers: int, args : argparse.Namespac
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]]
# 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
@@ -2303,7 +2489,6 @@ async def mk_replayQ(queue : asyncio.Queue, args : argparse.Namespace, db : moto
Nreplays += 1
except Exception as err:
bu.error(exception=err)
bu.verbose('Finished scanning replays: ' + str(Nreplays) + ' replays to process')
return Nreplays
@@ -2314,7 +2499,8 @@ async def mk_readerQ_item(replay_json, filename : str = None, _id: str = None) -
try:
if wi.chk_JSON_replay(replay_json):
if not '_id' in replay_json:
_id = wi.read_replay_id(replay_json)
if _id == None:
_id = wi.read_replay_id(replay_json)
replay_json['_id'] = _id
else:
replay_json = None # mark error
@@ -2334,6 +2520,7 @@ async def replay_reader(queue: asyncio.Queue, readerID: int, args : argparse.Nam
#global SKIPPED_N
results = []
playerstanks = set()
replays = dict()
try:
while True:
item = await queue.get()
@@ -2360,8 +2547,10 @@ async def replay_reader(queue: asyncio.Queue, readerID: int, args : argparse.Nam
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:
@@ -2369,7 +2558,7 @@ async def replay_reader(queue: asyncio.Queue, readerID: int, args : argparse.Nam
queue.task_done()
except (asyncio.CancelledError, concurrent.futures.CancelledError):
bu.debug( str(len(results)) + ' replays, ' + str(len(playerstanks)) + ' player/tanks', readerID)
return results, playerstanks
return results, playerstanks, replays
return None
@@ -2436,20 +2625,20 @@ async def read_replay_JSON(replay_json: dict, args : argparse.Namespace) -> dict
player_tank_tier = wg.get_tank_data(player['vehicle_descr'], 'tier')
btl_tier = max(btl_tier, player_tank_tier)
if (protagonist != None) and (player['dbid'] == protagonist):
protagonist_tank = player['vehicle_descr']
if player['dbid'] == account_id:
# player itself is not put in results['allies']
tmp = dict()
tmp['account_id'] = account_id
tmp['tank_id'] = player['vehicle_descr']
tank_id = player['vehicle_descr']
tmp['tank_id'] = tank_id
tmp['tank_tier'] = player_tank_tier
tmp['tank_name'] = wg.get_tank_name(tmp['tank_id'])
tmp['tank_type'] = wg.get_tank_type_id(tmp['tank_id'])
tmp['tank_nation'] = wg.get_tank_nation_id(tmp['tank_id'])
tmp['is_premium'] = int(wg.is_premium(tmp['tank_id']))
tmp['squad_index'] = player['squad_index']
platoon_ndx = player['squad_index']
tmp['squad_index'] = platoon_ndx
tmp['in_platoon'] = 0 if platoon_ndx == None else 1
for key in replay_details_flds:
tmp[key] = player[key]
if player['hitpoints_left'] == 0:
@@ -2482,7 +2671,7 @@ async def read_replay_JSON(replay_json: dict, args : argparse.Namespace) -> dict
result['battle_duration'] = btl_duration
## Rather use 'player' than incomprehensible 'protagonist'...
result['player'] = get_stat_id(protagonist, protagonist_tank, result['battle_start_timestamp'])
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'] != None:
+214 -120
View File
@@ -36,132 +36,163 @@ _progress_obj = None
UMASK = os.umask(0)
os.umask(UMASK)
# -----------------------------------------------------------
# Class ThrottledClientSession(aiohttp.ClientSession)
# -----------------------------------------------------------
## -----------------------------------------------------------
#### Class ThrottledClientSession(aiohttp.ClientSession)
#
# Rate-limited async http client session
#
# Inherits aiohttp.ClientSession
## -----------------------------------------------------------
from typing import Optional, Union
import aiohttp
import asyncio
import time
import logging
import re
class ThrottledClientSession(aiohttp.ClientSession):
"""Rate-throttled client session class inherited from aiohttp.ClientSession)"""
# MIN_SLEEP = 0.1
def __init__(self, rate_limit: float = None, *args, **kwargs) -> None:
super().__init__(*args, **kwargs)
self.rate_limit = None
self._fillerTask = None
self._queue = None
self._start_time = None
self._count = 0
self._start_time = time.time()
def __init__(self, rate_limit: float = 0, filters: list[str] = list() ,
limit_filtered: bool = False, re_filter: bool = False, *args,**kwargs) -> None:
assert isinstance(rate_limit, (int, float)), "rate_limit has to be float"
assert isinstance(filters, list), "filters has to be list"
assert isinstance(limit_filtered, bool),"limit_filtered has to be bool"
assert isinstance(re_filter, bool), "re_filter has to be bool"
if rate_limit != None:
if rate_limit <= 0:
raise ValueError('rate_limit must be positive')
self.rate_limit = rate_limit
self._queue = asyncio.Queue(min(2, int(rate_limit)+1))
self._fillerTask = asyncio.create_task(self._filler())
super().__init__(*args,**kwargs)
def _get_sleep(self) -> list:
if self.rate_limit == None:
return None
self.rate_limit : float
self._fillerTask : Optional[asyncio.Task] = None
self._queue : Optional[asyncio.Queue] = None
self._start_time : float = time.time()
self._count : int = 0
self._limit_filtered: bool = limit_filtered
self._re_filter : bool = re_filter
self._filters : list[Union[str, re.Pattern]] = list()
if re_filter:
for filter in filters:
self._filters.append(re.compile(filter))
else:
for filter in filters:
self._filters.append(filter)
self.set_rate_limit(rate_limit)
def _get_sleep(self) -> float:
if self.rate_limit > 0:
return 1/self.rate_limit
return 0
def get_rate(self) -> float:
"""Return rate of requests"""
return self._count / (time.time() - self._start_time)
def get_stats(self):
def get_stats(self) -> dict[str, float]:
"""Get session statistics"""
res = {'rate': self.get_rate(), 'rate_limit': self.rate_limit,
'count': self._count}
res = {'rate' : self.get_rate(), 'rate_limit': self.rate_limit, 'count' : self._count }
return res
def get_stats_str(self):
"""Print session statistics"""
return 'rate limit: ' + str(self.rate_limit if self.rate_limit != None else '-') + \
' rate: ' + \
"{0:.1f}".format(self.get_rate()) + \
' requests: ' + str(self._count)
def reset_counters(self):
def get_stats_str(self) -> str:
"""Print session statistics"""
return f"rate limit: {str(self.rate_limit if self.rate_limit != None else '-')} \
rate: {0:.1f}.format(self.get_rate()) requests: {str(self._count)}"
def reset_counters(self) -> dict[str, float]:
"""Reset rate counters and return current results"""
res = self.get_stats()
self._start_time = time.time()
self._count = 0
return res
def set_rate_limit(self, rate_limit: float = 1):
if rate_limit >= 0:
self.rate_limit = rate_limit
return self.rate_limit
return None
def set_rate_limit(self, rate_limit: float = 0) -> float:
assert rate_limit is not None, "rate_limit must not be None"
assert isinstance(rate_limit, (int,float)) and rate_limit >= 0, "rate_limit has to be type of 'float' >= 0"
self.rate_limit = rate_limit
if rate_limit > 0:
self._queue = asyncio.Queue(int(rate_limit)+1)
if self._fillerTask is not None:
self._fillerTask.cancel()
self._fillerTask = asyncio.create_task(self._filler())
return self.rate_limit
async def close(self) -> None:
"""Close rate-limiter's "bucket filler" task"""
# DEBUG
debug(self.get_stats_str())
logging.debug(self.get_stats_str())
try:
if self._fillerTask != None:
if self._fillerTask is not None:
self._fillerTask.cancel()
await asyncio.wait_for(self._fillerTask, timeout=0.5)
except (asyncio.CancelledError, asyncio.TimeoutError):
pass
await asyncio.wait_for(self._fillerTask, timeout=0.5)
except asyncio.TimeoutError as err:
logging.error(str(err))
await super().close()
async def _filler_simple(self):
"""Filler task to fill the leaky bucket algo"""
try:
if self._queue == None:
return
sleep = self._get_sleep()
while True:
self._queue.put(None)
await asyncio.sleep(sleep)
except asyncio.CancelledError:
debug('Cancelled')
except Exception as err:
error(exception=err)
async def _filler(self):
async def _filler(self) -> None:
"""Filler task to fill the leaky bucket algo"""
try:
if self._queue == None:
return
sleep = self._get_sleep()
# debug('SLEEP: ' + str(sleep))
if self._queue is None:
return None
logging.debug('SLEEP: ' + str(self._get_sleep()))
updated_at = time.monotonic()
fraction = 0
extra_increment = 0
extra_increment : float = 0
for i in range(0, self._queue.maxsize):
self._queue.put_nowait(i)
await self._queue.put(i)
while True:
if not self._queue.full():
now = time.monotonic()
increment = self.rate_limit * (now - updated_at)
fraction += increment % 1
extra_increment = fraction // 1
items_2_add = int(
min(self._queue.maxsize - self._queue.qsize(), int(increment) + extra_increment))
fraction = fraction % 1
for i in range(0, items_2_add):
items_2_add = int(min(self._queue.maxsize - self._queue.qsize(), int(increment + extra_increment)))
extra_increment = (increment + extra_increment) % 1
for i in range(0,items_2_add):
self._queue.put_nowait(i)
updated_at = now
await asyncio.sleep(sleep)
await asyncio.sleep(self._get_sleep())
except asyncio.CancelledError:
debug('Cancelled')
logging.debug('Cancelled')
except Exception as err:
error(exception=err)
logging.error(str(err))
return None
async def _request(self, *args, **kwargs):
async def _request(self, *args,**kwargs) -> aiohttp.ClientResponse:
"""Throttled _request()"""
if self._queue != None:
if self._queue is not None and self.is_limited(*args):
await self._queue.get()
self._queue.task_done()
self._count += 1
return await super()._request(*args, **kwargs)
return await super()._request(*args,**kwargs)
def is_limited(self, *args: str) -> bool:
"""Check wether the rate limit should be applied"""
try:
url: str = args[1]
for filter in self._filters:
if isinstance(filter, re.Pattern) and filter.match(url) is not None:
return self._limit_filtered
elif isinstance(filter, str) and url.startswith(filter):
return self._limit_filtered
return not self._limit_filtered
except Exception as err:
logging.error(str(err))
return True
# -----------------------------------------------------------
# Class AsyncLogger()
# -----------------------------------------------------------
class AsyncLogger():
"""Async file logger"""
@@ -486,18 +517,27 @@ def NOW() -> int:
return int(time.time())
def rebase_file_args(current_dir, files):
"""REbase file command line params after moving working dir to the script's dir"""
if isinstance(files, list):
if (files[0] == '-') or (files[0] == 'db:'):
return files
else:
return [ os.path.join(current_dir, fn) for fn in files ]
elif isinstance(files, str):
return os.path.join(current_dir, files)
def get_date_str(timestamp:int = NOW(), date_format = '%Y%m%d_%H%M%S'):
"""Return YYYYMMDD_HHmm date string"""
try:
ts = datetime.fromtimestamp(timestamp)
return ts.strftime(date_format)
except Exception as err:
error(exception=err)
return None
async def read_int_list(filename: str) -> list():
def rebase_file_args(current_dir : str, files: list[str]) -> list[str]:
"""Rebase file command line params after moving working dir to the script's dir"""
assert isinstance(files, list), "files has to be a list"
if (files[0] == '-') or (files[0] == 'db:'):
return [ files[0] ]
else:
return [ os.path.join(current_dir, fn) for fn in files ]
async def read_int_list(filename: str) -> list[int]:
"""Read file to a list and return list of integers in the input file"""
input_list = []
@@ -633,6 +673,14 @@ def get_JSON_value(json, key : str = None, keys : list = None, keypath = None):
raise KeyError('Key not found: ' + get_JSON_keypath(keypath, keys[0]))
def sort_dict(d: dict, number: bool = False) -> dict:
"""Sort a dict by keys"""
if number:
return dict(sorted(d.items(), key=lambda item: int(item[0])))
else:
return dict(sorted(d.items(), key=lambda item: item[0]))
# -----------------------------------------------------------
# Class SlowBar
# -----------------------------------------------------------
@@ -916,33 +964,18 @@ class WG:
@classmethod
def get_map(cls, map_str: str) -> str:
def get_map(self, map_str: str) -> str:
"""Return map name from short map string in replays"""
try:
return cls.maps[map_str]
return self.maps[map_str]
except:
error('Map ' + map_str + ' not found')
return None
@classmethod
def get_map_user_strs(cls) -> str:
return cls.maps.keys()
@classmethod
def tank_str2name(cls, tank_str: str) -> str:
"""Return tank name from short tank string in replays"""
try:
return cls.tanks["userStr"][tank_str]
except:
error('Tank ' + tank_str + ' not found')
return tank_str
@classmethod
def get_tank_user_strs(cls) -> str:
return cls.tanks["userStr"].keys()
def get_tank_user_strs(self) -> str:
return self.tanks["userStr"].keys()
@classmethod
@@ -1079,10 +1112,14 @@ class WG:
"""Load tanks from tankopedia JSON"""
if tankopedia_fn == None:
return False
try:
with open(tankopedia_fn, 'rt', encoding='utf8') as f:
self.tanks = json.loads(f.read())
self.tanks['tankStr'] = dict()
p = re.compile('.+_short$')
for usr_str in self.tanks['userStr']:
if p.match(usr_str) == None:
self.tanks['tankStr'][self.tanks['userStr'][usr_str]] = usr_str
self.tanks_by_tier = dict()
for tier in range(1,11):
self.tanks_by_tier[str(tier)] = list()
@@ -1094,6 +1131,43 @@ class WG:
return False
def get_replay_filename(self, replay: dict):
try:
summary = replay['data']['summary']
timestamp = int(summary['battle_start_timestamp'])
timestamp = get_date_str(timestamp, date_format='%Y%m%d_%H%M')
player = summary['player_name']
arena_id = int(summary['arena_unique_id'])
vehicle = self.name2tank_str(summary['vehicle'])
vehicle.replace(' ','_')
return '{:s}_{:s}_{:s}_{:d}.wotbreplay.json'.format(timestamp, player, vehicle, arena_id)
except Exception as err:
error('Unexpected Exception', err)
return None
def get_map_user_strs(self) -> str:
return self.maps.keys()
def tank_str2name(self, tank_str: str) -> str:
"""Return tank name from short tank string in replays"""
try:
return self.tanks["userStr"][tank_str]
except:
error('Tank ' + tank_str + ' not found')
return tank_str
def name2tank_str(self, name: str) -> str:
"""Return shorttank string from tank string in replays"""
try:
return self.tanks["tankStr"][name]
except:
debug('Tank ' + name + ' not found')
return name
def get_tanks_by_tier(self, tier: int) -> list():
"""Returns tank_ids by tier"""
try:
@@ -1552,6 +1626,8 @@ class WG:
async def store_tank_stats(self, key: list , stats_data: list, update_time: int):
"""Save tank stats into cache"""
assert self.cache is not None, "cache must be initiated"
try:
account_id = key[0]
tank_ids = set(key[1])
@@ -1573,6 +1649,8 @@ class WG:
async def store_player_stats(self, key: list , stats_data: list, update_time: int):
"""Save player stats into cache"""
assert self.cache is not None, "cache must be initiated"
try:
account_id = key[0]
if stats_data != None:
@@ -1589,6 +1667,8 @@ class WG:
async def store_player_achievements(self, key: list , stats_data: list, update_time: int):
"""Save player stats into cache"""
assert self.cache is not None, "cache must be initiated"
try:
account_id = key[0]
if stats_data != None:
@@ -1604,6 +1684,8 @@ class WG:
async def get_cached_tank_stats(self, account_id: int, tank_ids: list, fields: list ):
assert self.cache is not None, "cache must be initiated"
try:
# test for cacheDB existence
debug('Trying cached stats first')
@@ -1717,17 +1799,27 @@ class WoTinspector:
URL_REPLAY_VIEW = URL_WI +'/en/view/'
URL_REPLAY_UL = 'https://api.wotinspector.com/replay/upload?'
URL_REPLAY_INFO = 'https://api.wotinspector.com/replay/upload?details=full&key='
URL_TANK_DB = "https://wotinspector.com/static/armorinspector/tank_db_blitz.js"
REPLAY_N = 1
DEFAULT_RATE_LIMIT : float = 10/60 # 10 requests / min
DEFAULT_TOKEN : str = 'a5428af49bf7495986577d59b0e5bcff'
def __init__(self, rate_limit: int = 30):
self.session = ThrottledClientSession(rate_limit=rate_limit)
def __init__(self, rate_limit: float = DEFAULT_RATE_LIMIT, auth_token: Optional[str] = None):
headers : Optional[dict[str, str]] = None
if auth_token is None:
auth_token = self.DEFAULT_TOKEN
headers = dict()
headers['Authorization'] = 'Token ' + auth_token
self.session = ThrottledClientSession(rate_limit=rate_limit,
filters=[self.URL_REPLAY_LIST, self.URL_REPLAY_INFO],
re_filter=False, limit_filtered=True, headers = headers)
async def close(self):
if self.session != None:
async def close(self) -> None:
if self.session is not None:
debug('Closing aiohttp session')
await self.session.close()
@@ -1791,7 +1883,7 @@ class WoTinspector:
hash.update(data)
replay_id = hash.hexdigest()
# Testing if the replay has already been posted
## Testing if the replay has already been posted
json_resp = await self.get_replay_JSON(replay_id)
if json_resp != None:
debug('Already uploaded: ' + title, id=N)
@@ -1806,7 +1898,7 @@ class WoTinspector:
}
url = self.URL_REPLAY_UL + urllib.parse.urlencode(params, quote_via=urllib.parse.quote)
# debug('URL: ' + url)
#debug('URL: ' + url)
headers ={'Content-type': 'application/x-www-form-urlencoded'}
payload = { 'file' : (filename, base64.b64encode(data)) }
except Exception as err:
@@ -1868,11 +1960,6 @@ class WoTinspector:
return replay_links
@classmethod
def get_replay_id(cls, url):
return url.rsplit('/', 1)[-1]
@classmethod
def read_replay_id(cls, json_replay):
"""Read replay_id from replay JSON file"""
@@ -1883,14 +1970,18 @@ class WoTinspector:
error(exception=err)
return None
@classmethod
def chk_JSON_replay(cls, json_resp):
def get_replay_id(cls, url):
return url.rsplit('/', 1)[-1]
@classmethod
def chk_JSON_replay(cls, json_resp) -> bool:
""""Check String for being a valid JSON file"""
try:
# FIX The replay can have allies/enemies i.e. be useful for
# team composition analysis, but does not have player contribution
if ('status' in json_resp) and json_resp['status'] == 'ok' and \
(get_JSON_value(json_resp, key='data.summary.winner_team') != None) :
(get_JSON_value(json_resp, key='data.summary.exp_base') != None) :
debug("JSON check OK")
return True
except KeyError as err:
@@ -1899,6 +1990,9 @@ class WoTinspector:
debug("Replay JSON check failed: " + str(json_resp))
return False
# -----------------------------------------------------------
# Class BlitzStars
# -----------------------------------------------------------
class BlitzStars:
+20 -12
View File
@@ -3,6 +3,7 @@
# Extract tankopedia data in WG API JSON format from Blitz app files (Android APK unzipped)
from os import path
import sys, argparse, json, os, inspect, aiohttp, asyncio, aiofiles, re, logging, time, xmltodict, collections, configparser
import blitzutils as bu
from blitzutils import WG
@@ -11,9 +12,11 @@ logging.getLogger("asyncio").setLevel(logging.DEBUG)
FILE_CONFIG = 'blitzstats.ini'
BLITZAPP_STRINGS='/assets/Data/Strings/en.yaml'
BLITZAPP_VEHICLES_DIR='/assets/Data/XML/item_defs/vehicles/'
BLITZAPP_VEHICLE_FILE='/list.xml'
# BLITZAPP_STRINGS='/assets/Data/Strings/en.yaml'
# BLITZAPP_VEHICLES_DIR='/assets/Data/XML/item_defs/vehicles/'
BLITZAPP_STRINGS='Data/Strings/en.yaml'
BLITZAPP_VEHICLES_DIR='Data/XML/item_defs/vehicles/'
BLITZAPP_VEHICLE_FILE='list.xml'
wg = None
@@ -79,8 +82,8 @@ async def main(argv):
tankopedia = collections.OrderedDict()
tankopedia['status'] = 'ok'
tankopedia['meta'] = { "count": len(tanks) }
tankopedia['data'] = tanks
tankopedia['userStr'] = userStrs
tankopedia['data'] = bu.sort_dict(tanks, number=True)
tankopedia['userStr'] = bu.sort_dict(userStrs)
bu.verbose_std('New tankopedia \'' + args.tanks + '\' contains ' + str(len(tanks)) + ' tanks')
bu.verbose_std('New tankopedia \'' + args.tanks + '\' contains ' + str(len(userStrs)) + ' tank strings')
await outfile.write(json.dumps(tankopedia, ensure_ascii=False, indent=4, sort_keys=False))
@@ -104,8 +107,11 @@ async def main(argv):
async def extract_tanks(blitz_app_base : str, nation: str):
tanks = []
list_xml_file = blitz_app_base + BLITZAPP_VEHICLES_DIR + nation + BLITZAPP_VEHICLE_FILE
if not os.path.isfile(list_xml_file):
# WG has changed the location of Data directory - at least in steam client
if path.isdir(path.join(blitz_app_base, 'assets')):
blitz_app_base = path.join(blitz_app_base, 'assets')
list_xml_file = path.join(blitz_app_base, BLITZAPP_VEHICLES_DIR ,nation ,BLITZAPP_VEHICLE_FILE)
if not path.isfile(list_xml_file):
print('ERROR: cannot open ' + list_xml_file)
return None
bu.debug('Opening file: ' + list_xml_file + ' (Nation: ' + nation + ')')
@@ -117,9 +123,9 @@ async def extract_tanks(blitz_app_base : str, nation: str):
tank = dict()
tank['is_premium'] = issubclass(type(tank_xml['price']), dict)
tank['nation'] = nation
tank['tank_id'] = await get_tank_id(nation, int(tank_xml['id']))
tank['tank_id'] = get_tank_id(nation, int(tank_xml['id']))
tank['tier'] = int(tank_xml['level'])
tank['type'] = await get_tank_type(tank_xml['tags'])
tank['type'] = get_tank_type(tank_xml['tags'])
tank['userStr'] = tank_xml['userString']
#bu.debug('Reading tank string: ' + tank['userStr'], force=True)
tanks.append(tank)
@@ -132,7 +138,7 @@ async def read_user_strs(blitz_app_base : str) -> dict:
"""Read user strings to convert map and tank names"""
tank_strs = {}
map_strs = {}
filename = blitz_app_base + BLITZAPP_STRINGS
filename = path.join(blitz_app_base, BLITZAPP_STRINGS)
bu.debug('Opening file: ' + filename + ' for reading UserStrings')
try:
async with aiofiles.open(filename, 'r', encoding="utf8") as f:
@@ -200,10 +206,12 @@ async def convert_tank_names(tanklist : list, tank_strs: dict) -> dict:
return tankopedia_sorted, userStrs_sorted
async def get_tank_id(nation: str, tank_id : int) -> int:
def get_tank_id(nation: str, tank_id : int) -> int:
return (tank_id << 8) + (wg.NATION_ID[nation] << 4) + 1
async def get_tank_type(tagstr : str):
def get_tank_type(tagstr : str):
tags = tagstr.split(' ')
for t_type in wg.TANK_TYPE:
if tags[0] == t_type:
+1 -1
View File
@@ -1,4 +1,4 @@
#!/usr/local/bin/python3.7
#!/usr/bin/env python3
# Get Tankpedia from WoTinspector.com
+2
View File
@@ -18,6 +18,7 @@
"fort": "Fort Despair",
"grossberg": "Dynasty's Pearl",
"himmelsdorf": "Himmelsdorf",
"holland": "Molendijk",
"holmeisk": "Wasteland",
"iceworld": "Everfrost",
"idle": "Yukon",
@@ -43,6 +44,7 @@
"neptune_02": "Normandy - Beach",
"north": "North",
"ordeal": "Trial by Fire",
"plant": "Ghost Factory",
"pliego": "Castilla",
"port": "Port Bay",
"rift": "Hellas",
+101 -81
View File
@@ -1,83 +1,103 @@
# WoT Blitz releases
| Release | Date | Notes | Inaccurate? |
|:-------:|:--------:|-------------|-----|
| 7.9 | 2021-04-27 | Mines rework, new tutorial, colorblind mode, JgPz E100 legendary camo | |
| 7.8 | 2021-03-24 | Mid-tier balance changes, Grille line new consumables | |
| 7.7 | 2021-02-24 | Czechoslovakian medium tanks | |
| 7.6 | 2021-01-27 | AMC 13 90, TOG II buffs, free replay camera | |
| 7.5 | 2020-12-09 | ATGM removal, Batchat, Kpz70, T-2020 buff | |
| 7.4 | 2020-11-12 | TD buffs, Clan missions | |
| 7.3 | 2020-10-08 | Swedish HT line | |
| 7.2 | 2020-08-19 | Balance changes, added tier X ammo capa, new aiming reticles | |
| 7.1 | 2020-07-15 | New Yukon map, IS-6, KV-4, Patton 48 armor buff, Grille DPM buff, SP IC, AMZ 13 75, M41 Bulldog mobility buff | |
| 7.0 | 2020-06-17 | British LTs, graphics improvements, ATGM nerf, Sheridan nerf, map fixes | |
| 6.10 | 2020-04-29 | T92E1 nerf, Tier V-IX heavy HP buff, Battle Pass, map fixes | |
| 6.9 | 2020-03-25 | Japanese TDs, Tier X heavy HP buff | |
| 6.8 | 2020-02-19 | ATGM nerf, Hellas map | |
| 6.7 | 2020-01-15 | ATGM US Lights | |
| 6.6 | 2019-12-18 | T-62A + Leo buffs | |
| 6.5 | 2019-11-21 | New UI | |
| 6.4 | 2019-10-16 | Halloween map, mid-tier HT buff... | |
| 6.3 | 2019-09-18 | European line | |
| 6.2 | 2019-08-14 | New MM | |
| 6.1 | 2019-07-10 | Autoloader buff | |
| 6.0 | 2019-06-05 | UK TD line #2 | |
| 5.10 | 2019-04-24 | new missions | |
| 5.9 | 2019-03-27 | New shell tracers, minor fixes | |
| 5.8 | 2019-02-21 | Chinese TD line | |
| 5.7 | 2019-01-16 | Avatars | |
| 5.6 | 2018-12-05 | Normandy map | |
| 5.5 | 2018-11-13 | Low-tier clean-up | |
| 5.4 | 2018-10-10 | MM "rigging" adjusted | |
| 5.3 | 2018-09-12 | MM "rigging" started, Newbie queue added | |
| 5.2 | 2018-08-22 | Faust map | |
| 5.1 | 2018-07-31 | New MM: vehicle class matching | |
| 5.0 | 2018-06-30 | Vineyeards map update, Spare Parts killed | |
| 4.10 | 2018-05-24 | Alpenstadt map | |
| 4.9 | 2018-04-25 | Chinese heavy tanks | |
| 4.8 | 2018-03-28 | Choice of battle modes, crew skills accelerator | |
| 4.7 | 2018-02-28 | New minimap | |
| 4.6 | 2018-01-31 | Chinese nation | |
| 4.5 | 2017-12-19 | Dynasty's Pearl map | |
| 4.4 | 2017-11-09 | New British tanks | |
| 4.3 | 2017-10-10 | Level 7 screw skills | |
| 4.2 | 2017-09-12 | French LTs, Naval Frontier map | |
| 4.1 | 2017-08-16 | New markers | |
| 4.0 | 2017-07-13 | Rating battles | Y |
| 3.10 | 2017-06-15 | Fixes | Y |
| 3.9 | 2017-05-19 | Military Honor, Mayan Ruins | Y |
| 3.8 | 2017-04-24 | Spare Parts, New 9-slot Equipment system | Y |
| 3.7 | 2017-03-22 | Balance changes, Tournaments | Y |
| 3.6 | 2017-02-16 | Vehicle Enrichment | Y |
| 3.5 | 2017-01-13 | Boosters, Dead rail update | Y |
| 3.4 | 2016-11-28 | Canyon map, USA T57 line | |
| 3.3 | 2016-11-01 | Updated Mines map | |
| 3.2 | 2016-10-04 | Copperfield update | |
| 3.1 | 2016-09-03 | Clan tournaments, Yamato Harbor map | Y |
| 3.0 | 2016-07-20 | Supremacy mode, Falls Creek update | Y |
| 2.11 | 2016-06-28 | Vineyeards map, Oasis Palms update, German paper TDs | |
| 2.10 | 2016-06-07 | Rockfield map update, Tank birthdays, high-pen gun for IS-6 | |
| 2.9 | 2016-05-03 | Training rooms, +/-1 Matchmaker | Y |
| 2.8 | 2016-04-04 | New Russian TD line, new consumables system | Y |
| 2.7 | 2016-03-07 | Mirage map | Y |
| 2.6 | 2016-02-10 | German LTs, shooting through obstacles | Y |
| 2.5 | 2016-01-11 | Camouflages, Clan supply | Y |
| 2.4 | 2015-12-09 | Additional Japanese tanks, windows version | Y |
| 2.3 | 2015-11-12 | Canal map | Y |
| 2.2 | 2015-10-05 | Desert Sands map update | Y |
| 2.1 | 2015-09-07 | UK TDs, Clan Chat | Y |
| 2.0 | 2015-08-05 | Clans, Oasis Palms updated | |
| 1.11 | 2015-07-13 | KV-2, KV-13, Sherman Jumbo | Y |
| 1.10 | 2015-06-11 | Port Bay map | Y |
| 1.9 | 2015-05-13 | New German medium tanks (Leo) | Y |
| 1.8 | 2015-04-14 | Game tutorial, Bot map for new players | Y |
| 1.7 | 2015-02-16 | US Turreted TDs | Y |
| 1.6 | 2015-01-19 | Castilla map, UK HT line | Y |
| 1.5 | 2014-12-01 | UK Nation | Y |
| 1.4 | 2014-10-29 | German HT line (Maus) | Y |
| 1.3 | 2014-10-02 | USSR IS-4 line, Lost Temple, Black Goldville maps | Y |
| 1.2 | 2014-08-20 | Oasis Palms map | Y |
| 1.1.1 | 2014-07-23 | Fixes :-) | |
| 1.0 | 2014-06-26 | Worldwide launch | |
| 0.9 | 2014-05-05 | Nordic soft launch | |
| Release | Date | Content updates |
|:-------:|:--------:|-------------|
| 9.8 | 2023-03-22 | F\*cked up The Quick Commands Wheel, camos |
| 9.7 | 2023-02-15 | Chinese light tanks |
| 9.6 | 2023-01-11 | Finally fixing game sounds for players with Genelec studio monitors, more gold camos |
| 9.5 | 2022-12-14 | Camos and ?|
| 9.4 | 2022-11-09 | Italian TD line |
| 9.3 | 2022-10-04 | Nothing, except legandary camos and utterly incomprehensible shell icons |
| 9.2 | 2022-08-17 | New shot sounds and shell hit marks, vehicle restoration from the game client |
| 9.1 | 2022-07-14 | Releases updated via DLC, graphics updates for Object 140, Patton 46 |
| 9.0 | 2022-06-08 | Japanies heavies, Castilla graphics update, new garage |
| 8.10 | 2022-04-28 | New battle results screen, new "Operation" mechanics |
| 8.9 | 2022-03-23 | "Free" camo resupply, VK 72.01 supplies |
| 8.8 | 2022-02-16 | Molendijk map |
| 8.7 | 2022-01-19 | Soviet Light Tanks |
| 8.6 | 2021-12-15 | Joining Light and Medium Tank classes in MM |
| 8.5 | 2021-11-17 | New national crew voices, economy update |
| 8.4 | 2021-10-20 | Polish heavy tanks |
| 8.3 | 2021-09-16 | FV4005, FV4022, VK 91.01 (P) and WZ-121 legendary camos, -5 deg gun depression to WZ-120 |
| 8.2 | 2021-08-18 | Ghost Factory map, XP/credits nerf for inactive players, new store UI |
| 8.1 | 2021-07-15 | Armor highlighting updated |
| 8.0 | 2021-06-08 | American experimental heavies (Yoh-line), PBR camos |
| 7.9 | 2021-04-27 | Mines rework, new tutorial, colorblind mode, JgPz E100 legendary camo |
| 7.8 | 2021-03-24 | Mid-tier balance changes, Grille line new consumables |
| 7.7 | 2021-02-24 | Czechoslovakian medium tanks |
| 7.6 | 2021-01-27 | AMC 13 90, TOG II buffs, free replay camera |
| 7.5 | 2020-12-09 | ATGM removal, Batchat, Kpz70, T-2020 buff |
| 7.4 | 2020-11-12 | TD buffs, Clan missions |
| 7.3 | 2020-10-08 | Swedish HT line |
| 7.2 | 2020-08-19 | Balance changes, added tier X ammo capa, new aiming reticles |
| 7.1 | 2020-07-15 | New Yukon map, IS-6, KV-4, Patton 48 armor buff, Grille DPM buff, SP IC, AMZ 13 75, M41 Bulldog mobility buff |
| 7.0 | 2020-06-17 | British LTs, graphics improvements, ATGM nerf, Sheridan nerf, map fixes |
| 6.10 | 2020-04-29 | T92E1 nerf, Tier V-IX heavy HP buff, Battle Pass, map fixes |
| 6.9 | 2020-03-25 | Japanese TDs, Tier X heavy HP buff |
| 6.8 | 2020-02-19 | ATGM nerf, Hellas map |
| 6.7 | 2020-01-15 | ATGM US Lights |
| 6.6 | 2019-12-18 | T-62A + Leo buffs |
| 6.5 | 2019-11-21 | New UI |
| 6.4 | 2019-10-16 | Halloween map, mid-tier HT buff... |
| 6.3 | 2019-09-18 | European line |
| 6.2 | 2019-08-14 | New MM |
| 6.1 | 2019-07-10 | Autoloader buff |
| 6.0 | 2019-06-05 | UK TD line #2 |
| 5.10 | 2019-04-24 | new missions |
| 5.9 | 2019-03-27 | New shell tracers, minor fixes |
| 5.8 | 2019-02-21 | Chinese TD line |
| 5.7 | 2019-01-16 | Avatars |
| 5.6 | 2018-12-05 | Normandy map |
| 5.5 | 2018-11-13 | Low-tier clean-up :-( |
| 5.4 | 2018-10-10 | MM "rigging" adjusted |
| 5.3 | 2018-09-12 | MM "rigging" started, Newbie queue added |
| 5.2 | 2018-08-22 | Faust map |
| 5.1 | 2018-07-31 | New MM: vehicle class matching |
| 5.0 | 2018-06-30 | Vineyeards map update, Spare Parts removed ! |
| 4.10 | 2018-05-24 | Replays, Alpenstadt map |
| 4.9 | 2018-04-25 | Chinese heavy tanks |
| 4.8 | 2018-03-28 | Choice of battle modes, crew skills accelerator |
| 4.7 | 2018-02-28 | New minimap |
| 4.6 | 2018-01-31 | Chinese nation |
| 4.5 | 2017-12-19 | Dynasty's Pearl map |
| 4.4 | 2017-11-09 | New British tanks |
| 4.3 | 2017-10-10 | Level 7 screw skills |
| 4.2 | 2017-09-12 | French LTs, Naval Frontier map |
| 4.1 | 2017-08-16 | New markers |
| 4.0 | 2017-07-13 | Rating battles (date uncertain) |
| 3.10 | 2017-06-15 | Fixes (date uncertain) |
| 3.9 | 2017-05-19 | Military Honor, Mayan Ruins (date uncertain) |
| 3.8 | 2017-04-24 | Spare Parts, New 9-slot Equipment system (date uncertain) |
| 3.7 | 2017-03-22 | Balance changes, Tournaments (date uncertain) |
| 3.6 | 2017-02-16 | Vehicle Enrichment (date uncertain) |
| 3.5 | 2017-01-13 | Boosters, Dead rail update (date uncertain) |
| 3.4 | 2016-11-28 | Canyon map, USA T57 line |
| 3.3 | 2016-11-01 | Updated Mines map |
| 3.2 | 2016-10-04 | Copperfield update |
| 3.1 | 2016-09-03 | Clan tournaments, Yamato Harbor map (date uncertain) |
| 3.0 | 2016-07-20 | Supremacy mode, Falls Creek update (date uncertain) |
| 2.11 | 2016-06-28 | Vineyeards map, Oasis Palms update, German paper TDs |
| 2.10 | 2016-06-07 | Rockfield map update, Tank birthdays, high-pen gun for IS-6 |
| 2.9 | 2016-05-03 | Training rooms, +/-1 Matchmaker (date uncertain) |
| 2.8 | 2016-04-04 | New Russian TD line, new consumables system (date uncertain) |
| 2.7 | 2016-03-07 | Mirage map (date uncertain) |
| 2.6 | 2016-02-10 | German LTs, shooting through obstacles (date uncertain) |
| 2.5 | 2016-01-11 | Camouflages, Clan supply (date uncertain) |
| 2.4 | 2015-12-09 | Additional Japanese tanks, windows version (date uncertain) |
| 2.3 | 2015-11-12 | Canal map (date uncertain) |
| 2.2 | 2015-10-05 | Desert Sands map update (date uncertain) |
| 2.1 | 2015-09-07 | UK TDs, Clan Chat (date uncertain) |
| 2.0 | 2015-08-05 | Clans, Oasis Palms updated |
| 1.11 | 2015-07-13 | KV-2, KV-13, Sherman Jumbo (date uncertain) |
| 1.10 | 2015-06-11 | Port Bay map (date uncertain) |
| 1.9 | 2015-05-13 | New German medium tanks (Leo) (date uncertain) |
| 1.8 | 2015-04-14 | Game tutorial, Bot map for new players (date uncertain) |
| 1.7 | 2015-02-16 | US Turreted TDs (date uncertain) |
| 1.6 | 2015-01-19 | Castilla map, UK HT line (date uncertain) |
| 1.5 | 2014-12-01 | UK Nation (date uncertain) |
| 1.4 | 2014-10-29 | German HT line (Maus) (date uncertain) |
| 1.3 | 2014-10-02 | USSR IS-4 line, Lost Temple, Black Goldville maps (date uncertain) |
| 1.2 | 2014-08-20 | Oasis Palms map (date uncertain) |
| 1.1.1 | 2014-07-23 | Fixes :-) |
| 1.0 | 2014-06-26 | Worldwide launch |
| 0.9 | 2014-05-05 | Nordic soft launch |
+1112 -109
View File
File diff suppressed because it is too large. Load diff
+32 -32
View File
@@ -1,8 +1,8 @@
#!/usr/bin/env python3
## PYTHON VERSION MUST BE 3.7 OR HIGHER
import json, argparse, inspect, sys, os, io, base64, aiohttp, urllib, asyncio, aiofiles, aioconsole
import logging, re, concurrent.futures, configparser, motor.motor_asyncio, ssl, zipfile
import json, argparse, sys, os, io, asyncio, aiofiles, aioconsole
import logging, re, configparser, zipfile
import blitzutils as bu
from blitzutils import WG
from blitzutils import WoTinspector
@@ -21,17 +21,17 @@ SKIPPED_N = 0
ERROR_N = 0
WIurl='https://wotinspector.com/api/replay/upload?'
WG_appID = '81381d3f45fa4aa75b78a7198eb216ad'
wg = None
wi = None
wg : WG | None = None
wi : WoTinspector | None = None
async def main(argv):
async def main(argv) -> int:
global wg, wi
current_dir = os.getcwd()
# set the directory for the script
os.chdir(os.path.dirname(sys.argv[0]))
# options defaults
OPT_WORKERS_N = 5
OPT_WORKERS_N = 2
WG_ID = None
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
@@ -43,12 +43,6 @@ async def main(argv):
bu.debug('Reading config file: ' + FILE_CONFIG)
config = configparser.ConfigParser()
config.read(FILE_CONFIG)
try:
if 'UPLOADER' in config.sections():
configUploader = config['UPLOADER']
OPT_WORKERS_N = configUploader.getint('opt_workers', OPT_WORKERS_N)
except (KeyError, configparser.NoSectionError) as err:
bu.error(exception=err)
try:
if 'WG' in config.sections():
@@ -82,18 +76,19 @@ async def main(argv):
bu.set_log_level(args.silent, args.verbose, args.debug)
wg = WG(WG_appID, args.tankopedia, args.mapfile)
wi = WoTinspector(rate_limit=WG_RATE_LIMIT)
wi = WoTinspector()
if args.account != None:
if args.account is not None:
args.accountID = await wg.get_account_id(args.account)
bu.debug('WG account_id: ' + str(args.accountID))
if args.accountID == None:
if args.accountID is None:
args.accountID = 0
try:
queue = asyncio.Queue()
queue : asyncio.Queue = asyncio.Queue()
# rebase file arguments due to moving the working directory to the script location
bu.verbose_std("replays.wotinspector.com rate limits replays to 6 / minute")
args.files = bu.rebase_file_args(current_dir, args.files)
tasks = []
@@ -123,7 +118,7 @@ async def main(argv):
await wg.session.close()
await wi.close()
return None
return 0
async def mkReplayQ(queue : asyncio.Queue, files : list, title : str):
@@ -137,13 +132,13 @@ async def mkReplayQ(queue : asyncio.Queue, files : list, title : str):
if not line:
break
else:
if (p_replayfile.match(line) != None):
if (p_replayfile.match(line) is not None):
await queue.put(await mkQueueItem(line, title))
else:
for fn in files:
if fn.endswith('"'):
fn = fn[:-1]
if os.path.isfile(fn) and (p_replayfile.match(fn) != None):
if os.path.isfile(fn) and (p_replayfile.match(fn) is not None):
await queue.put(await mkQueueItem(fn, title))
bu.debug('File added to queue: ' + fn)
elif os.path.isdir(fn):
@@ -151,7 +146,7 @@ async def mkReplayQ(queue : asyncio.Queue, files : list, title : str):
for entry in dirEntry:
try:
bu.debug('Found: ' + entry.name)
if entry.is_file() and (p_replayfile.match(entry.name) != None):
if entry.is_file() and (p_replayfile.match(entry.name) is not None):
bu.debug(entry.name)
await queue.put(await mkQueueItem(entry.path, title))
bu.debug('File added to queue: ' + entry.path)
@@ -175,6 +170,9 @@ async def replayWorker(queue: asyncio.Queue, workerID: int, account_id: int, pri
"""Async Worker to process the replay queue"""
global SKIPPED_N
global ERROR_N
global wi
assert wi is not None, "wi cannot be None"
while True:
item = await queue.get()
@@ -195,7 +193,7 @@ async def replayWorker(queue: asyncio.Queue, workerID: int, account_id: int, pri
bu.verbose_std(msg_str + title + ' has already been posted. Skipping.' )
else:
os.remove(replay_json_fn)
bu.debug(msg_str + "Replay JSON not valid: Deleting " + replay_json_fn, id=workerID)
bu.debug(msg_str + "Replay JSON not valid/complete: Deleting " + replay_json_fn, id=workerID)
SKIPPED_N += 1
queue.task_done()
continue
@@ -210,19 +208,19 @@ async def replayWorker(queue: asyncio.Queue, workerID: int, account_id: int, pri
filename = os.path.basename(filename)
bu.debug(msg_str + 'File: ' + filename)
json_resp = await wi.post_replay(await fp.read(), filename, account_id, title, priv, N)
if json_resp != None:
if json_resp is not None:
if (await bu.save_JSON(replay_json_fn,json_resp)):
if wi.chk_JSON_replay(json_resp):
if not bu.debug(msg_str + 'Replay saved OK: ' + filename):
bu.verbose_std(msg_str + title + ' posted')
else:
bu.warning(msg_str +'Replay file is not valid: ' + filename)
bu.warning(msg_str +'Replay file is not valid/complete: ' + filename)
ERROR_N += 1
else:
bu.error(msg_str + 'Error saving replay: ' + filename)
ERROR_N += 1
else:
bu.error(msg_str + 'Replay file is not valid: ' + filename)
bu.error(msg_str + 'Replay file is not valid/complete: ' + filename)
ERROR_N += 1
except Exception as err:
bu.error(msg_str + 'Unexpected Exception: ' + str(type(err)) + ' : ' + str(err) )
@@ -235,7 +233,7 @@ async def replayWorker(queue: asyncio.Queue, workerID: int, account_id: int, pri
def getTitle_old(replayfile: str, title: str, i : int) -> str:
global wg
if title == None:
if title is None:
try:
filename = os.path.basename(replayfile)
bu.debug(filename)
@@ -247,8 +245,8 @@ def getTitle_old(replayfile: str, title: str, i : int) -> str:
p = re.compile('\\d{8}_\\d{4}_.*?(' + '|'.join(tank_userStrs) + ')_(' + '|'.join(map_usrStrs) + ')(?:-\\d)?\\.wotbreplay$')
m = p.match(filename)
if (m != None):
if wg.tanks != None:
if (m is not None):
if wg.tanks is not None:
tank = m.group(1)
if tank in wg.tanks['userStr']:
tank = wg.tanks['userStr'][tank]
@@ -273,7 +271,9 @@ def getTitle_old(replayfile: str, title: str, i : int) -> str:
def getTitle(replayfile: str, title: str = None, i : int = 0) -> str:
global wg
if title == None:
assert wg is not None, "wg must not be None"
if title is None:
try:
filename = os.path.basename(replayfile)
bu.debug(filename)
@@ -287,13 +287,13 @@ def getTitle(replayfile: str, title: str = None, i : int = 0) -> str:
try:
metadata_json = json.load(meta)
#player = metadata_json['playerName']
tank = wg.tank_str2name(metadata_json['playerVehicleName'])
tank_name = wg.get_tank_name(metadata_json['vehicleCompDescriptor'])
map_name = wg.get_map(metadata_json['mapName'])
bu.debug('Tank: ' + tank + ' Map: ' + map_name)
bu.debug('Tank: ' + tank_name if tank_name is not None else "NOT FOUND IN tanks.json" + ' Map: ' + map_name)
except Exception as err:
bu.error(exception = err)
if (tank != None) and (map_name != None):
title = tank + ' @ ' + map_name
if (tank_name is not None) and (map_name is not None):
title = tank_name + ' @ ' + map_name
else:
title = re.sub('\\.wotbreplay$', '', filename)
except Exception as err: