26 Ноя, 2016

Пилим ForThisTime #1 — World Of Tanks

Захотелось написать сервис, показывающий сколько времени я потратил на World Of Tanks. Пока разбирался с API и математикой рассчетов понял, что этого мало. Сервис ForThisTime будет показывать, что можно было сделать за потраченное на что либо время, а танковая статистика будет перенесена на поддомен wot.forthistime.ru.

В статье разберем WOT API, запросы, базу данных и математические выкладки. Весь backend и вычисления выполнены на голом Python, пока не понадобились даже Numpy и Pandas.

Задачи и решения
Внутриигровые детали
Как проверить (dossier)
Что будем хранить в базе
Модели
Как устроен WOT API
URls
Пишем запросы и функции
Добавление информации в базу
Django Commands
Как запускать свои скрипты на сервере Django
Анализ
В следующей серии

Задачи и решения
— Написать веб-сервис, высчитывающий на основе статистики игрока примерное время гемплея.
— Показать игроку инфографику о том, сколько разных дел можно было сделать за это время.
— Предложить ссылки на полезности (курсы, лекции, туториалы).
— Попутно собрать статистику по игрокам и проанализировать ее.
— Вразумить Димана.

Внутриигровые детали и рассчеты
World Of Tanks это краткосессионный (15 минут) PvP MMO-action. Есть несколько режимов игры, но нас интересует только Random Battles. Это основной режим, в нем участвуют две команды из 15 псевдослучайных игроков.

Если игрока убили он может либо досматривать бой, либо выйти и выйти в новый бой  на другом танке. В зависимости от скилла и класса техники игрока среднее время боя для него варьируется от 5 до 10 минут.

У всех танков разные роли на разных картах и в разных ситуациях. Главные показатели игрока — винрейт (50% и ниже — неопытный, 52%-54% — хороший игрок, больше — скилловые типы), средний урон (самая важная боевая характеристика, более всего влияет на бой) и рейтинги эффективности (более сложные формулы, на них все ровняются).

Мне нужны только примерные величины, поэтому я просто решил вычислить количество боев игрока на разных классах техники и умножить их на среднее время боя этого класса, согласно неофициальной статистики. ЛТ — 5 минут, СТ, ТТ и ПТ — 7 минут, арта — 10.

Это очень грубые расчеты, погрешность в таком случае 20-40%, что вполне допустимо для нашей задачи, хоть и некрасиво. Будем подгонять позже.

Как проверить расчеты
Как понятно из всей статьи, battle time это недоступная игроку статистика. Полагаю, это связано с основной аудиторией игры — мужчины старше 40 лет. Это действительно взрослые люди, которые могут покинуть игру, если увидят свои 500+ часов геймплея.

Но такая статистика ведется игрой для аналитики. Она доступно в файле-досье в папке с игрой, где описана вся подробная статистика игрока. То есть файл может открыть только хозяин компьютера, на котором установлен клиент игры, в который выполнен вход на аккаунт игрока. Я использовал этот файл, чтоб узнать свой battle time и подогнать формулу расчета.

Открыть этот файл можно на специальном сайте. А лучше пойти на сайт vBaddict и загрузить файл туда, для сбора неофициальной статистики. На этом же сайте можно ознакомиться с другими интересностями — тепловые карты позиций игроков, данные о длительности боях на разных картах и танках, средние показатели разной техники.

Что будем хранить в базе
Мне было очень интересно создать свою базу данных для быстрого анализа, поэтому я решил хранить всю полученную при расчетах информацию. Для корректного анализа нужна правильная и равномерная выборка игроков, поэтому я решил вносить в базу каждого десятого игрока (по id), от первого до последнего (их больше 30 000 000 на момент написания статьи).

Модели Django
Прежде чем обсуждать WOT API создадим модели Player, Tank и Having. Последняя необходима для создания связей между игроками и танками (чтобы можно было вычислить, сколько игроков имеют данный танк и наоборот):

class Tank(models.Model):
name = models.CharField(max_length=100, blank=True)
level = models.IntegerField(default=0, blank=True)
nation = models.CharField(default=0, max_length=15, blank=True)
type = models.CharField(default='', max_length=15, blank=True)
id = models.IntegerField(primary_key=True)
image = models.URLField(default='', blank=True)
is_premium = models.BooleanField(default=0, blank=True)

circular_vision_radius = models.IntegerField(default=0, blank=True)
max_health = models.IntegerField(default=0, blank=True)
price_credit = models.IntegerField(default=0, blank=True)
speed_limit = models.IntegerField(default=0, blank=True)
weight = models.FloatField(default=0, blank=True)

class Player(models.Model):
nickname = models.CharField(max_length=63)
id = models.IntegerField(primary_key=True)
global_rating = models.IntegerField(default=0, blank=True)

battle_time = models.IntegerField(default=0, blank=True)

battles_total = models.IntegerField(default=0, blank=True)
battles_light = models.IntegerField(default=0, blank=True)
battles_medium = models.IntegerField(default=0, blank=True)
battles_heavy = models.IntegerField(default=0, blank=True)
battles_at = models.IntegerField(default=0, blank=True)
battles_spg = models.IntegerField(default=0, blank=True)

tanks = models.ManyToManyField(Tank, through='Having')

class Having(models.Model):
player = models.ForeignKey(Player, on_delete=models.CASCADE)
tank = models.ForeignKey(Tank, on_delete=models.CASCADE)
battles = models.IntegerField(default=0)


Как устроен WOT API
Нормально!

Это мой первый игровой API и он мне понравился. Хорошая документация, приемлемая архитектура и даже онлайн-обозреватель с описанием всех полей запроса. Ниже указан application_id, который вам нужно получить самим на сайте. Это делается в один клик. Свой application_id вставляйте в соответсвующее поле.

Нам необходимы следующие URL:

  • https://api.worldoftanks.ru/wot/account/list/ — запрос по этому URL возвращает список игроков. Мы будем искать nickname, а сервер вернет нам (если найдет) account_id игрока.
  • https://api.worldoftanks.ru/wot/account/info/ — здесь можно достать информацию по игроку, как общую, так и приватную. Последняя нам не понадобится — для нее требуется авторизация.
  • https://api.worldoftanks.ru/wot/account/tanks/ — техника игрока и статистика по ней.
  • https://api.worldoftanks.ru/wot/encyclopedia/tanks/ — все танки игры с основной информацией.
  • https://api.worldoftanks.ru/wot/encyclopedia/tankinfo/ — более детальная информация по конкретной машине.


Пишем запросы и функции
Весь алгоритм получения нужной информации следующий: сначала вносим в базу всю информацию о всех танках (порядка 400-500 машин), по никнейму игрока получаем его account_id, получаем всю его технику и статистику по ней, смотрим количество боев на каждом танке, суммируем все бои для каждого класса техники, считаем итоговое время по формуле.

Запрос всех танков (models.py):

from django.db import models
from django.shortcuts import get_object_or_404

import sys
import time
import urllib
import urllib.request
import urllib.parse
import urllib.request as urllib2
import json

def update_all_tanks():
start = time.time()
all_tanks_url = 'https://api.worldoftanks.ru/wot/encyclopedia/tanks/'
info_tank_url = 'https://api.worldoftanks.ru/wot/encyclopedia/tankinfo/'

all_tanks_fields = {'application_id': 'ВАШ APPLICATION_ID',
'fields': 'type, short_name_i18n, level, nation, is_premium, image, tank_id ',
'language': 'ru'}

data = urllib.parse.urlencode(all_tanks_fields)
data = data.encode('utf-8')
req = urllib2.Request(all_tanks_url, data)

response = urllib2.urlopen(req)  # ответ
the_page = response.read()  # чтение ответа, байты
the_page = the_page.decode('utf-8')  # ответ to str
parsed = json.loads(the_page)  # str to json
#   print(json.dumps(parsed, indent=3, sort_keys=True))
parsed = parsed['data'] #в ответе сервера есть два ключа: 'data' и 'status'. нам нужен только последний

#   Получаем дополнительные характеристики танков
for tank in parsed:
info_tank_fields = {'application_id': 'ВАШ APPLICATION_ID',
'tank_id': str(tank),
'fields': 'limit_weight, speed_limit, price_credit, max_health, circular_vision_radius',
'language': 'ru'}

data = urllib.parse.urlencode(info_tank_fields)
data = data.encode('utf-8')
req = urllib2.Request(info_tank_url, data)

response = urllib2.urlopen(req)  # ответ
the_page = response.read()  # чтение ответа, байты
the_page = the_page.decode('utf-8')  # ответ to str
parsed_info = json.loads(the_page)  # str to json
#       print(json.dumps(parsed_info, indent=3, sort_keys=True))
parsed_info = parsed_info['data']

#       Создаем объект
Tank.objects.update_or_create(id=tank,
circular_vision_radius=parsed_info[tank]['circular_vision_radius'],
max_health=parsed_info[tank]['max_health'],
price_credit=parsed_info[tank]['price_credit'],
speed_limit=parsed_info[tank]['speed_limit'],
weight=parsed_info[tank]['limit_weight'],
name=parsed[tank]['short_name_i18n'],
level=parsed[tank]['level'], type=parsed[tank]['type'],
image=parsed[tank]['image'], is_premium=parsed[tank]['is_premium'],
nation=parsed[tank]['nation']
)

end = time.time()
print(end - start)

Все импорты файла models.py указаны выше. Запрос account_id по nickname:

def get_player_id(nickname):
start = time.time()
search_url = 'https://api.worldoftanks.ru/wot/account/list/'
search_fields = {'application_id': 'ВАШ APPLICATION_ID',
'search': nickname}

data = urllib.parse.urlencode(search_fields)
data = data.encode('utf-8')
req = urllib2.Request(search_url, data)
response = urllib2.urlopen(req)

the_page = response.read()
the_page = the_page.decode('unicode-escape')
# print(the_page)
parsed = json.loads(the_page)

if parsed['status'] == 'ok':
account_id = parsed['data'][0]['account_id']
# print(account_id)
# print(json.dumps(parsed, indent=3, sort_keys=True))

Player.objects.update_or_create(id=account_id, nickname=nickname)

end = time.time()

#print('Username {} (ID {}) was updated. Time: {}'.format(str(nickname), str(account_id), str((end - start))))
return account_id
else:
print('Can`t get account_id, status is: %S', parsed['status'])

Получаем краткую информацию об игроке для статистики:

def get_player_info(account_id):
start = time.time()
search_url = 'https://api.worldoftanks.ru/wot/account/info/'
search_fields = {'application_id': 'ВАШ APPLICATION_ID',
'account_id': account_id, 'fields': 'nickname,'
'global_rating, statistics.all.battles'}

data = urllib.parse.urlencode(search_fields)
data = data.encode('utf-8')
req = urllib2.Request(search_url, data)
response = urllib2.urlopen(req)

the_page = response.read()
the_page = the_page.decode('unicode-escape')
#print(the_page)
parsed = json.loads(the_page)

if parsed['status'] == 'ok' and parsed['data'][str(account_id)]\
and parsed['data'][str(account_id)]['statistics']['all']['battles']:

nickname = parsed['data'][str(account_id)]['nickname']
global_rating = parsed['data'][str(account_id)]['global_rating']
battles = parsed['data'][str(account_id)]['statistics']['all']['battles']

# print(account_id)
# print(json.dumps(parsed, indent=3, sort_keys=True))

try:
player = Player.objects.get(id=account_id)
player.nickname = nickname
player.global_rating = global_rating
player.battles_total = battles
player.save()
end = time.time()
print('Username {} (ID {}) was updated. Time: {}'.format(str(nickname), str(account_id), str((end - start))))

except Player.DoesNotExist:
Player.objects.create(id=account_id, nickname=nickname, global_rating=global_rating,
battles_total=battles)
end = time.time()
print('Username {} (ID {}) was created. Time: {}'.format(str(nickname), str(account_id), str((end - start))))

return account_id
else:
if parsed['status'] == 'ok':
print('ID {} does not exist'.format(account_id))
else:
print('Can`t get ID {}, status is: {}'.format(account_id, parsed['status']))

Получаем все танки игрока, связываем Player и Tank с помощью Having:

def get_players_tanks(account_id):
try:
Player.objects.get(id=account_id)
player = Player.objects.get(id=account_id)
start = time.time()
tanks_url = 'https://api.worldoftanks.ru/wot/account/tanks/'
tanks_fields = {'application_id': 'ВАШ APPLICATION_ID',
'account_id': account_id, 'fields': 'tank_id, statistics.battles'}

data = urllib.parse.urlencode(tanks_fields)
data = data.encode('utf-8')
req = urllib2.Request(tanks_url, data)
response = urllib2.urlopen(req)  # ответ
the_page = response.read()  # чтение ответа, байты

the_page = the_page.decode('unicode-escape')  # ответ to str
parsed = json.loads(the_page)  # str to json

if parsed['status'] == 'ok':
parsed = parsed['data'][str(account_id)]
#print(json.dumps(parsed, indent=3, sort_keys=True))
players_tanks = {}

for tank in parsed:
try:
tank_db = Tank.objects.get_or_create(id=tank['tank_id'])[0]
Having.objects.update_or_create(tank=tank_db, player=player,
battles=int(tank['statistics']['battles']))
except:
print(' Can`t get tank (ID {})'.format(tank['tank_id']), sys.exc_info()[0])
raise

# print(json.dumps(players_tanks, indent=3, sort_keys=True))  # pretty json

end = time.time()

print(' Get tanks of player (ID {}) Time: {}'.format(str(account_id), str((end - start))))
else:
print(' Can`t get tanks, status is: %S', parsed['status'])
except Player.DoesNotExist:
print(' User ID {} not exist in database'.format(account_id))


Django commands
Теперь, когда в базе есть все нужные данные для анализа, можно написать первые скрипты. В Django есть удобный встроенный элемент — кастомные команды. В приложении (web, в моем случае) создаем директорию managment, в ней папку commands. Здесь описываем в файлах .py свои команды, которые потом можно будет запускать в терминале как python3 manage.py command.

Я использую этот способ всегда, если алгоритм работает в пределах юрисдикции Django. Обычные bash-скрипты будут тяжеловато работать с данными и моделями, в то время как Django commands не требует вообще никаких телодвижений вроде авторизаций и запуска python shell.

Как запускать свои скрипты на сервере Django
Напишем команду python3 manage.py add_players 1 1000, которая добавит в базу игроков с account_id от единицы до тысячи. Если они существуют. Пример файла add_players.py:

from django.core.management.base import BaseCommand
from web.models import get_player_info
import time

class Command(BaseCommand):
help = 'Add players by id'

def add_arguments(self, parser):
parser.add_argument('player_from', nargs='+', type=int) #первый аргумент команды
parser.add_argument('player_to', nargs='+', type=int) #второй

#  Сама команда
def handle(self, *args, **options):
start_all = time.time()
for player_from in options['player_from']:
for player_to in options['player_to']:
for player_id in range(player_from, player_to, 10):
get_player_info(player_id)

end_all = time.time()
print('{} users was added. Time: {}'.format(player_to, (end_all-start_all)))

По аналогии, напишем первую команду поверхностного анализа. analysis будет показывать, сколько игроков наиграло сколько боев. Эти данные я собираю из-за распространенного мнения о том, что активная аудитория World Of Tanks это всего 20% от 30.000.000 пользователей. Проверим:

from django.core.management.base import BaseCommand
from web.models import Player
import time
from tabulate import tabulate

class Command(BaseCommand):
help = 'Add players by id'

def handle(self, *args, **options):
start_all = time.time()

players_all = Player.objects.count()
playersnot1000 = 0
players1000 = 0
players10000 = 0
players100000 = 0

for player in Player.objects.all():
if player.battles_total > 100000:
players100000 += 1

if player.battles_total > 10000:
players10000 += 1

if player.battles_total > 1000:
players1000 += 1
else:
playersnot1000 += 1

print('Statistics for players:\n')
print(tabulate([['All players', players_all, 100],
['Players under 1000 battles:', playersnot1000, (playersnot1000*100)/players_all],
['Players over 1000 battles:', players1000, (players1000*100)/players_all],
['Players over 10000 battles:', players10000, (players10000*100)/players_all],
['Players over 100000 battles:', players100000, (players100000*100)/players_all]]))

end_all = time.time()
print('Time: {}'.format((end_all-start_all)))

Библиотечка tabulate нам нужна для того, чтобы красиво вывести данные. Имея в базе каждого десятого пользователя с id от 1 до 8.400.000 имеем слудеющие данные:

>> python3 manage.py analysis

Statistics for players:
----------------------------  ------  ------------
All players                   673453  100%
Players under 1000 battles:   341598   50.7234%
Players over 1000 battles:    331855   49.2766%
Players over 10000 battles:   164354   24.4047%
Players over 100000 battles:      36    0.00534558%
----------------------------  ------  ------------
Time: 19.7128164768219

В следующей серии:
— Дополним данные до полноценной правильной выборки (от 1 до 30.000.000).
— Допишем analysis.py (будем считать больше данных).
— Напишем функцию, считающую battle_time. Иначе чо мы тут собрались вообще.
— Начнем работать над frontend.