Add a function to determine the Elo category.
[xonotic/xonstat.git] / xonstat / views / submission.py
1 import calendar
2 import collections
3 import datetime
4 import logging
5 import re
6
7 import pyramid.httpexceptions
8 from sqlalchemy import Sequence
9 from sqlalchemy.orm.exc import MultipleResultsFound, NoResultFound
10 from xonstat.elo import EloProcessor
11 from xonstat.models import DBSession, Server, Map, Game, PlayerGameStat, PlayerWeaponStat
12 from xonstat.models import PlayerRank, PlayerCaptime
13 from xonstat.models import TeamGameStat, PlayerGameAnticheat, Player, Hashkey, PlayerNick
14 from xonstat.util import strip_colors, qfont_decode, verify_request, weapon_map
15
16 log = logging.getLogger(__name__)
17
18
19 def is_real_player(events):
20     """
21     Determines if a given set of events correspond with a non-bot
22     """
23     if not events['P'].startswith('bot'):
24         return True
25     else:
26         return False
27
28
29 def played_in_game(events):
30     """
31     Determines if a given set of player events correspond with a player who
32     played in the game (matches 1 and scoreboardvalid 1)
33     """
34     if 'matches' in events and 'scoreboardvalid' in events:
35         return True
36     else:
37         return False
38
39
40 class Submission(object):
41     """Parses an incoming POST request for stats submissions."""
42
43     def __init__(self, body, headers):
44         # a copy of the HTTP headers
45         self.headers = headers
46
47         # a copy of the HTTP POST body
48         self.body = body
49
50         # game metadata
51         self.meta = {}
52
53         # raw player events
54         self.players = []
55
56         # raw team events
57         self.teams = []
58
59         # distinct weapons that we have seen fired
60         self.weapons = set()
61
62         # number of real players in the match
63         self.real_players = 0
64
65         # the parsing deque (we use this to allow peeking)
66         self.q = collections.deque(self.body.split("\n"))
67
68     def next_item(self):
69         """Returns the next key:value pair off the queue."""
70         try:
71             items = self.q.popleft().strip().split(' ', 1)
72             if len(items) == 1:
73                 # Some keys won't have values, like 'L' records where the server isn't actually
74                 # participating in any ladders. These can be safely ignored.
75                 return None, None
76             else:
77                 return items
78         except:
79             return None, None
80
81     def check_for_new_weapon_fired(self, sub_key):
82         """Checks if a given player key (subkey, actually) is a new weapon fired in the match."""
83         if sub_key.endswith("cnt-fired"):
84             weapon = sub_key.split("-")[1]
85             if weapon not in self.weapons:
86                 self.weapons.add(weapon)
87
88     def parse_player(self, key, pid):
89         """Construct a player events listing from the submission."""
90
91         # all of the keys related to player records
92         player_keys = ['i', 'n', 't', 'e']
93
94         player = {key: pid}
95
96         # Consume all following 'i' 'n' 't'  'e' records
97         while len(self.q) > 0:
98             (key, value) = self.next_item()
99             if key is None and value is None:
100                 continue
101             elif key == 'e':
102                 (sub_key, sub_value) = value.split(' ', 1)
103                 player[sub_key] = sub_value
104
105                 # keep track of the distinct weapons fired during the match
106                 self.check_for_new_weapon_fired(sub_key)
107             elif key == 'n':
108                 player[key] = unicode(value, 'utf-8')
109             elif key in player_keys:
110                 player[key] = value
111             else:
112                 # something we didn't expect - put it back on the deque
113                 self.q.appendleft("{} {}".format(key, value))
114                 break
115
116         if is_real_player(player) and played_in_game(player):
117             self.real_players += 1
118
119         self.players.append(player)
120
121     def parse_team(self, key, tid):
122         """Construct a team events listing from the submission."""
123         team = {key: tid}
124
125         # Consume all following 'e' records
126         while len(self.q) > 0 and self.q[0].startswith('e'):
127             (_, value) = self.next_item()
128             (sub_key, sub_value) = value.split(' ', 1)
129             team[sub_key] = sub_value
130
131         self.teams.append(team)
132
133     def parse(self):
134         """Parses the request body into instance variables."""
135         while len(self.q) > 0:
136             (key, value) = self.next_item()
137             if key is None and value is None:
138                 continue
139             elif key == 'S':
140                 self.meta[key] = unicode(value, 'utf-8')
141             elif key == 'P':
142                 self.parse_player(key, value)
143             elif key == 'Q':
144                 self.parse_team(key, value)
145             else:
146                 self.meta[key] = value
147
148         return self
149
150
151 def elo_submission_category(submission):
152     """Determines the Elo category purely by what is in the submission data."""
153     mod = submission.meta.get("O", "None")
154
155     vanilla_allowed_weapons = {"shotgun", "devastatorblaster", "mortar", "vortex", "electro",
156                                "arc", "hagar", "crylink", "machinegun"}
157     insta_allowed_weapons = {"vaporizer", "blaster"}
158     overkill_allowed_weapons = {"hmg", "vortex", "shotgun blaster", "machinegun", "rpc"}
159
160     if mod == "Xonotic":
161         if len(submission.weapons - vanilla_allowed_weapons) == 0:
162             return "vanilla"
163     elif mod == "InstaGib":
164         if len(submission.weapons - insta_allowed_weapons) == 0:
165             return "insta"
166     elif mod == "Overkill":
167         if len(submission.weapons - overkill_allowed_weapons) == 0:
168             return "overkill"
169     else:
170         return "general"
171
172     return "general"
173
174
175 def parse_stats_submission(body):
176     """
177     Parses the POST request body for a stats submission
178     """
179     # storage vars for the request body
180     game_meta = {}
181     events = {}
182     players = []
183     teams = []
184
185     # we're not in either stanza to start
186     in_P = in_Q = False
187
188     for line in body.split('\n'):
189         try:
190             (key, value) = line.strip().split(' ', 1)
191
192             # Server (S) and Nick (n) fields can have international characters.
193             if key in 'S' 'n':
194                 value = unicode(value, 'utf-8')
195
196             if key not in 'P' 'Q' 'n' 'e' 't' 'i':
197                 game_meta[key] = value
198
199             if key == 'Q' or key == 'P':
200                 #log.debug('Found a {0}'.format(key))
201                 #log.debug('in_Q: {0}'.format(in_Q))
202                 #log.debug('in_P: {0}'.format(in_P))
203                 #log.debug('events: {0}'.format(events))
204
205                 # check where we were before and append events accordingly
206                 if in_Q and len(events) > 0:
207                     #log.debug('creating a team (Q) entry')
208                     teams.append(events)
209                     events = {}
210                 elif in_P and len(events) > 0:
211                     #log.debug('creating a player (P) entry')
212                     players.append(events)
213                     events = {}
214
215                 if key == 'P':
216                     #log.debug('key == P')
217                     in_P = True
218                     in_Q = False
219                 elif key == 'Q':
220                     #log.debug('key == Q')
221                     in_P = False
222                     in_Q = True
223
224                 events[key] = value
225
226             if key == 'e':
227                 (subkey, subvalue) = value.split(' ', 1)
228                 events[subkey] = subvalue
229             if key == 'n':
230                 events[key] = value
231             if key == 't':
232                 events[key] = value
233         except:
234             # no key/value pair - move on to the next line
235             pass
236
237     # add the last entity we were working on
238     if in_P and len(events) > 0:
239         players.append(events)
240     elif in_Q and len(events) > 0:
241         teams.append(events)
242
243     return (game_meta, players, teams)
244
245
246 def is_blank_game(gametype, players):
247     """Determine if this is a blank game or not. A blank game is either:
248
249     1) a match that ended in the warmup stage, where accuracy events are not
250     present (for non-CTS games)
251
252     2) a match in which no player made a positive or negative score AND was
253     on the scoreboard
254
255     ... or for CTS, which doesn't record accuracy events
256
257     1) a match in which no player made a fastest lap AND was
258     on the scoreboard
259
260     ... or for NB, in which not all maps have weapons
261
262     1) a match in which no player made a positive or negative score
263     """
264     r = re.compile(r'acc-.*-cnt-fired')
265     flg_nonzero_score = False
266     flg_acc_events = False
267     flg_fastest_lap = False
268
269     for events in players:
270         if is_real_player(events) and played_in_game(events):
271             for (key,value) in events.items():
272                 if key == 'scoreboard-score' and value != 0:
273                     flg_nonzero_score = True
274                 if r.search(key):
275                     flg_acc_events = True
276                 if key == 'scoreboard-fastest':
277                     flg_fastest_lap = True
278
279     if gametype == 'cts':
280         return not flg_fastest_lap
281     elif gametype == 'nb':
282         return not flg_nonzero_score
283     else:
284         return not (flg_nonzero_score and flg_acc_events)
285
286
287 def get_remote_addr(request):
288     """Get the Xonotic server's IP address"""
289     if 'X-Forwarded-For' in request.headers:
290         return request.headers['X-Forwarded-For']
291     else:
292         return request.remote_addr
293
294
295 def is_supported_gametype(gametype, version):
296     """Whether a gametype is supported or not"""
297     is_supported = False
298
299     # if the type can be supported, but with version constraints, uncomment
300     # here and add the restriction for a specific version below
301     supported_game_types = (
302             'as',
303             'ca',
304             # 'cq',
305             'ctf',
306             'cts',
307             'dm',
308             'dom',
309             'duel',
310             'ft', 'freezetag',
311             'ka', 'keepaway',
312             'kh',
313             # 'lms',
314             'nb', 'nexball',
315             # 'rc',
316             'rune',
317             'tdm',
318         )
319
320     if gametype in supported_game_types:
321         is_supported = True
322     else:
323         is_supported = False
324
325     # some game types were buggy before revisions, thus this additional filter
326     if gametype == 'ca' and version <= 5:
327         is_supported = False
328
329     return is_supported
330
331
332 def do_precondition_checks(request, game_meta, raw_players):
333     """Precondition checks for ALL gametypes.
334        These do not require a database connection."""
335     if not has_required_metadata(game_meta):
336         msg = "Missing required game metadata"
337         log.debug(msg)
338         raise pyramid.httpexceptions.HTTPUnprocessableEntity(
339             body=msg,
340             content_type="text/plain"
341         )
342
343     try:
344         version = int(game_meta['V'])
345     except:
346         msg = "Invalid or incorrect game metadata provided"
347         log.debug(msg)
348         raise pyramid.httpexceptions.HTTPUnprocessableEntity(
349             body=msg,
350             content_type="text/plain"
351         )
352
353     if not is_supported_gametype(game_meta['G'], version):
354         msg = "Unsupported game type ({})".format(game_meta['G'])
355         log.debug(msg)
356         raise pyramid.httpexceptions.HTTPOk(
357             body=msg,
358             content_type="text/plain"
359         )
360
361     if not has_minimum_real_players(request.registry.settings, raw_players):
362         msg = "Not enough real players"
363         log.debug(msg)
364         raise pyramid.httpexceptions.HTTPOk(
365             body=msg,
366             content_type="text/plain"
367         )
368
369     if is_blank_game(game_meta['G'], raw_players):
370         msg = "Blank game"
371         log.debug(msg)
372         raise pyramid.httpexceptions.HTTPOk(
373             body=msg,
374             content_type="text/plain"
375         )
376
377
378 def num_real_players(player_events):
379     """
380     Returns the number of real players (those who played
381     and are on the scoreboard).
382     """
383     real_players = 0
384
385     for events in player_events:
386         if is_real_player(events) and played_in_game(events):
387             real_players += 1
388
389     return real_players
390
391
392 def has_minimum_real_players(settings, player_events):
393     """
394     Determines if the collection of player events has enough "real" players
395     to store in the database. The minimum setting comes from the config file
396     under the setting xonstat.minimum_real_players.
397     """
398     flg_has_min_real_players = True
399
400     try:
401         minimum_required_players = int(
402                 settings['xonstat.minimum_required_players'])
403     except:
404         minimum_required_players = 2
405
406     real_players = num_real_players(player_events)
407
408     if real_players < minimum_required_players:
409         flg_has_min_real_players = False
410
411     return flg_has_min_real_players
412
413
414 def has_required_metadata(metadata):
415     """
416     Determines if a give set of metadata has enough data to create a game,
417     server, and map with.
418     """
419     flg_has_req_metadata = True
420
421     if 'G' not in metadata or\
422         'M' not in metadata or\
423         'I' not in metadata or\
424         'S' not in metadata:
425             flg_has_req_metadata = False
426
427     return flg_has_req_metadata
428
429
430 def should_do_weapon_stats(game_type_cd):
431     """True of the game type should record weapon stats. False otherwise."""
432     if game_type_cd in 'cts':
433         return False
434     else:
435         return True
436
437
438 def gametype_elo_eligible(game_type_cd):
439     """True of the game type should process Elos. False otherwise."""
440     elo_game_types = ('duel', 'dm', 'ca', 'ctf', 'tdm', 'ka', 'ft')
441
442     if game_type_cd in elo_game_types:
443         return True
444     else:
445         return False
446
447
448 def register_new_nick(session, player, new_nick):
449     """
450     Change the player record's nick to the newly found nick. Store the old
451     nick in the player_nicks table for that player.
452
453     session - SQLAlchemy database session factory
454     player - player record whose nick is changing
455     new_nick - the new nickname
456     """
457     # see if that nick already exists
458     stripped_nick = strip_colors(qfont_decode(player.nick))
459     try:
460         player_nick = session.query(PlayerNick).filter_by(
461             player_id=player.player_id, stripped_nick=stripped_nick).one()
462     except NoResultFound, e:
463         # player_id/stripped_nick not found, create one
464         # but we don't store "Anonymous Player #N"
465         if not re.search('^Anonymous Player #\d+$', player.nick):
466             player_nick = PlayerNick()
467             player_nick.player_id = player.player_id
468             player_nick.stripped_nick = stripped_nick
469             player_nick.nick = player.nick
470             session.add(player_nick)
471
472     # We change to the new nick regardless
473     player.nick = new_nick
474     player.stripped_nick = strip_colors(qfont_decode(new_nick))
475     session.add(player)
476
477
478 def update_fastest_cap(session, player_id, game_id, map_id, captime, mod):
479     """
480     Check the fastest cap time for the player and map. If there isn't
481     one, insert one. If there is, check if the passed time is faster.
482     If so, update!
483     """
484     # we don't record fastest cap times for bots or anonymous players
485     if player_id <= 2:
486         return
487
488     # see if a cap entry exists already
489     # then check to see if the new captime is faster
490     try:
491         cur_fastest_cap = session.query(PlayerCaptime).filter_by(
492             player_id=player_id, map_id=map_id, mod=mod).one()
493
494         # current captime is faster, so update
495         if captime < cur_fastest_cap.fastest_cap:
496             cur_fastest_cap.fastest_cap = captime
497             cur_fastest_cap.game_id = game_id
498             cur_fastest_cap.create_dt = datetime.datetime.utcnow()
499             session.add(cur_fastest_cap)
500
501     except NoResultFound, e:
502         # none exists, so insert
503         cur_fastest_cap = PlayerCaptime(player_id, game_id, map_id, captime,
504                 mod)
505         session.add(cur_fastest_cap)
506         session.flush()
507
508
509 def update_server(server, name, hashkey, ip_addr, port, revision, impure_cvars):
510     """
511     Updates the server in the given DB session, if needed.
512
513     :param server: The found server instance.
514     :param name: The incoming server name.
515     :param hashkey: The incoming server hashkey.
516     :param ip_addr: The incoming server IP address.
517     :param port: The incoming server port.
518     :param revision: The incoming server revision.
519     :param impure_cvars: The incoming number of impure server cvars.
520     :return: bool
521     """
522     # ensure the two int attributes are actually ints
523     try:
524         port = int(port)
525     except:
526         port = None
527
528     try:
529         impure_cvars = int(impure_cvars)
530     except:
531         impure_cvars = 0
532
533     updated = False
534     if name and server.name != name:
535         server.name = name
536         updated = True
537     if hashkey and server.hashkey != hashkey:
538         server.hashkey = hashkey
539         updated = True
540     if ip_addr and server.ip_addr != ip_addr:
541         server.ip_addr = ip_addr
542         updated = True
543     if port and server.port != port:
544         server.port = port
545         updated = True
546     if revision and server.revision != revision:
547         server.revision = revision
548         updated = True
549     if impure_cvars and server.impure_cvars != impure_cvars:
550         server.impure_cvars = impure_cvars
551         server.pure_ind = True if impure_cvars == 0 else False
552         updated = True
553
554     return updated
555
556
557 def get_or_create_server(session, name, hashkey, ip_addr, revision, port, impure_cvars):
558     """
559     Find a server by name or create one if not found. Parameters:
560
561     session - SQLAlchemy database session factory
562     name - server name of the server to be found or created
563     hashkey - server hashkey
564     ip_addr - the IP address of the server
565     revision - the xonotic revision number
566     port - the port number of the server
567     impure_cvars - the number of impure cvar changes
568     """
569     servers_q = DBSession.query(Server).filter(Server.active_ind)
570
571     if hashkey:
572         # if the hashkey is provided, we'll use that
573         servers_q = servers_q.filter((Server.name == name) or (Server.hashkey == hashkey))
574     else:
575         # otherwise, it is just by name
576         servers_q = servers_q.filter(Server.name == name)
577
578     # order by the hashkey, which means any hashkey match will appear first if there are multiple
579     servers = servers_q.order_by(Server.hashkey, Server.create_dt).all()
580
581     if len(servers) == 0:
582         server = Server(name=name, hashkey=hashkey)
583         session.add(server)
584         session.flush()
585         log.debug("Created server {} with hashkey {}.".format(server.server_id, server.hashkey))
586     else:
587         server = servers[0]
588         if len(servers) == 1:
589             log.info("Found existing server {}.".format(server.server_id))
590
591         elif len(servers) > 1:
592             server_id_list = ", ".join(["{}".format(s.server_id) for s in servers])
593             log.warn("Multiple servers found ({})! Using the first one ({})."
594                      .format(server_id_list, server.server_id))
595
596     if update_server(server, name, hashkey, ip_addr, port, revision, impure_cvars):
597         session.add(server)
598
599     return server
600
601
602 def get_or_create_map(session=None, name=None):
603     """
604     Find a map by name or create one if not found. Parameters:
605
606     session - SQLAlchemy database session factory
607     name - map name of the map to be found or created
608     """
609     try:
610         # find one by the name, if it exists
611         gmap = session.query(Map).filter_by(name=name).one()
612         log.debug("Found map id {0}: {1}".format(gmap.map_id,
613             gmap.name))
614     except NoResultFound, e:
615         gmap = Map(name=name)
616         session.add(gmap)
617         session.flush()
618         log.debug("Created map id {0}: {1}".format(gmap.map_id,
619             gmap.name))
620     except MultipleResultsFound, e:
621         # multiple found, so use the first one but warn
622         log.debug(e)
623         gmaps = session.query(Map).filter_by(name=name).order_by(
624                 Map.map_id).all()
625         gmap = gmaps[0]
626         log.debug("Found map id {0}: {1} but found \
627                 multiple".format(gmap.map_id, gmap.name))
628
629     return gmap
630
631
632 def create_game(session, start_dt, game_type_cd, server_id, map_id,
633         match_id, duration, mod, winner=None):
634     """
635     Creates a game. Parameters:
636
637     session - SQLAlchemy database session factory
638     start_dt - when the game started (datetime object)
639     game_type_cd - the game type of the game being played
640     server_id - server identifier of the server hosting the game
641     map_id - map on which the game was played
642     winner - the team id of the team that won
643     duration - how long the game lasted
644     mod - mods in use during the game
645     """
646     seq = Sequence('games_game_id_seq')
647     game_id = session.execute(seq)
648     game = Game(game_id=game_id, start_dt=start_dt, game_type_cd=game_type_cd,
649                 server_id=server_id, map_id=map_id, winner=winner)
650     game.match_id = match_id
651     game.mod = mod[:64]
652
653     # There is some drift between start_dt (provided by app) and create_dt
654     # (default in the database), so we'll make them the same until this is 
655     # resolved.
656     game.create_dt = start_dt
657
658     try:
659         game.duration = datetime.timedelta(seconds=int(round(float(duration))))
660     except:
661         pass
662
663     try:
664         session.query(Game).filter(Game.server_id==server_id).\
665                 filter(Game.match_id==match_id).one()
666
667         log.debug("Error: game with same server and match_id found! Ignoring.")
668
669         # if a game under the same server and match_id found,
670         # this is a duplicate game and can be ignored
671         raise pyramid.httpexceptions.HTTPOk('OK')
672     except NoResultFound, e:
673         # server_id/match_id combination not found. game is ok to insert
674         session.add(game)
675         session.flush()
676         log.debug("Created game id {0} on server {1}, map {2} at \
677                 {3}".format(game.game_id,
678                     server_id, map_id, start_dt))
679
680     return game
681
682
683 def get_or_create_player(session=None, hashkey=None, nick=None):
684     """
685     Finds a player by hashkey or creates a new one (along with a
686     corresponding hashkey entry. Parameters:
687
688     session - SQLAlchemy database session factory
689     hashkey - hashkey of the player to be found or created
690     nick - nick of the player (in case of a first time create)
691     """
692     # if we have a bot
693     if re.search('^bot#\d+', hashkey):
694         player = session.query(Player).filter_by(player_id=1).one()
695     # if we have an untracked player
696     elif re.search('^player#\d+$', hashkey):
697         player = session.query(Player).filter_by(player_id=2).one()
698     # else it is a tracked player
699     else:
700         # see if the player is already in the database
701         # if not, create one and the hashkey along with it
702         try:
703             hk = session.query(Hashkey).filter_by(
704                     hashkey=hashkey).one()
705             player = session.query(Player).filter_by(
706                     player_id=hk.player_id).one()
707             log.debug("Found existing player {0} with hashkey {1}".format(
708                 player.player_id, hashkey))
709         except:
710             player = Player()
711             session.add(player)
712             session.flush()
713
714             # if nick is given to us, use it. If not, use "Anonymous Player"
715             # with a suffix added for uniqueness.
716             if nick:
717                 player.nick = nick[:128]
718                 player.stripped_nick = strip_colors(qfont_decode(nick[:128]))
719             else:
720                 player.nick = "Anonymous Player #{0}".format(player.player_id)
721                 player.stripped_nick = player.nick
722
723             hk = Hashkey(player_id=player.player_id, hashkey=hashkey)
724             session.add(hk)
725             log.debug("Created player {0} ({2}) with hashkey {1}".format(
726                 player.player_id, hashkey, player.nick.encode('utf-8')))
727
728     return player
729
730
731 def create_default_game_stat(session, game_type_cd):
732     """Creates a blanked-out pgstat record for the given game type"""
733
734     # this is what we have to do to get partitioned records in - grab the
735     # sequence value first, then insert using the explicit ID (vs autogenerate)
736     seq = Sequence('player_game_stats_player_game_stat_id_seq')
737     pgstat_id = session.execute(seq)
738     pgstat = PlayerGameStat(player_game_stat_id=pgstat_id,
739             create_dt=datetime.datetime.utcnow())
740
741     if game_type_cd == 'as':
742         pgstat.kills = pgstat.deaths = pgstat.suicides = pgstat.collects = 0
743
744     if game_type_cd in 'ca' 'dm' 'duel' 'rune' 'tdm':
745         pgstat.kills = pgstat.deaths = pgstat.suicides = 0
746
747     if game_type_cd == 'cq':
748         pgstat.kills = pgstat.deaths = pgstat.suicides = pgstat.captures = 0
749         pgstat.drops = 0
750
751     if game_type_cd == 'ctf':
752         pgstat.kills = pgstat.captures = pgstat.pickups = pgstat.drops = 0
753         pgstat.returns = pgstat.carrier_frags = 0
754
755     if game_type_cd == 'cts':
756         pgstat.deaths = 0
757
758     if game_type_cd == 'dom':
759         pgstat.kills = pgstat.deaths = pgstat.suicides = pgstat.pickups = 0
760         pgstat.drops = 0
761
762     if game_type_cd == 'ft':
763         pgstat.kills = pgstat.deaths = pgstat.suicides = pgstat.revivals = 0
764
765     if game_type_cd == 'ka':
766         pgstat.kills = pgstat.deaths = pgstat.suicides = pgstat.pickups = 0
767         pgstat.carrier_frags = 0
768         pgstat.time = datetime.timedelta(seconds=0)
769
770     if game_type_cd == 'kh':
771         pgstat.kills = pgstat.deaths = pgstat.suicides = pgstat.pickups = 0
772         pgstat.captures = pgstat.drops = pgstat.pushes = pgstat.destroys = 0
773         pgstat.carrier_frags = 0
774
775     if game_type_cd == 'lms':
776         pgstat.kills = pgstat.deaths = pgstat.suicides = pgstat.lives = 0
777
778     if game_type_cd == 'nb':
779         pgstat.kills = pgstat.deaths = pgstat.suicides = pgstat.captures = 0
780         pgstat.drops = 0
781
782     if game_type_cd == 'rc':
783         pgstat.kills = pgstat.deaths = pgstat.suicides = pgstat.laps = 0
784
785     return pgstat
786
787
788 def create_game_stat(session, game_meta, game, server, gmap, player, events):
789     """Game stats handler for all game types"""
790
791     game_type_cd = game.game_type_cd
792
793     pgstat = create_default_game_stat(session, game_type_cd)
794
795     # these fields should be on every pgstat record
796     pgstat.game_id       = game.game_id
797     pgstat.player_id     = player.player_id
798     pgstat.nick          = events.get('n', 'Anonymous Player')[:128]
799     pgstat.stripped_nick = strip_colors(qfont_decode(pgstat.nick))
800     pgstat.score         = int(round(float(events.get('scoreboard-score', 0))))
801     pgstat.alivetime     = datetime.timedelta(seconds=int(round(float(events.get('alivetime', 0.0)))))
802     pgstat.rank          = int(events.get('rank', None))
803     pgstat.scoreboardpos = int(events.get('scoreboardpos', pgstat.rank))
804
805     if pgstat.nick != player.nick \
806             and player.player_id > 2 \
807             and pgstat.nick != 'Anonymous Player':
808         register_new_nick(session, player, pgstat.nick)
809
810     wins = False
811
812     # gametype-specific stuff is handled here. if passed to us, we store it
813     for (key,value) in events.items():
814         if key == 'wins': wins = True
815         if key == 't': pgstat.team = int(value)
816
817         if key == 'scoreboard-drops': pgstat.drops = int(value)
818         if key == 'scoreboard-returns': pgstat.returns = int(value)
819         if key == 'scoreboard-fckills': pgstat.carrier_frags = int(value)
820         if key == 'scoreboard-pickups': pgstat.pickups = int(value)
821         if key == 'scoreboard-caps': pgstat.captures = int(value)
822         if key == 'scoreboard-score': pgstat.score = int(round(float(value)))
823         if key == 'scoreboard-deaths': pgstat.deaths = int(value)
824         if key == 'scoreboard-kills': pgstat.kills = int(value)
825         if key == 'scoreboard-suicides': pgstat.suicides = int(value)
826         if key == 'scoreboard-objectives': pgstat.collects = int(value)
827         if key == 'scoreboard-captured': pgstat.captures = int(value)
828         if key == 'scoreboard-released': pgstat.drops = int(value)
829         if key == 'scoreboard-fastest':
830             pgstat.fastest = datetime.timedelta(seconds=float(value)/100)
831         if key == 'scoreboard-takes': pgstat.pickups = int(value)
832         if key == 'scoreboard-ticks': pgstat.drops = int(value)
833         if key == 'scoreboard-revivals': pgstat.revivals = int(value)
834         if key == 'scoreboard-bctime':
835             pgstat.time = datetime.timedelta(seconds=int(value))
836         if key == 'scoreboard-bckills': pgstat.carrier_frags = int(value)
837         if key == 'scoreboard-losses': pgstat.drops = int(value)
838         if key == 'scoreboard-pushes': pgstat.pushes = int(value)
839         if key == 'scoreboard-destroyed': pgstat.destroys = int(value)
840         if key == 'scoreboard-kckills': pgstat.carrier_frags = int(value)
841         if key == 'scoreboard-lives': pgstat.lives = int(value)
842         if key == 'scoreboard-goals': pgstat.captures = int(value)
843         if key == 'scoreboard-faults': pgstat.drops = int(value)
844         if key == 'scoreboard-laps': pgstat.laps = int(value)
845
846         if key == 'avglatency': pgstat.avg_latency = float(value)
847         if key == 'scoreboard-captime':
848             pgstat.fastest = datetime.timedelta(seconds=float(value)/100)
849             if game.game_type_cd == 'ctf':
850                 update_fastest_cap(session, player.player_id, game.game_id,
851                         gmap.map_id, pgstat.fastest, game.mod)
852
853     # there is no "winning team" field, so we have to derive it
854     if wins and pgstat.team is not None and game.winner is None:
855         game.winner = pgstat.team
856         session.add(game)
857
858     session.add(pgstat)
859
860     return pgstat
861
862
863 def create_anticheats(session, pgstat, game, player, events):
864     """Anticheats handler for all game types"""
865
866     anticheats = []
867
868     # all anticheat events are prefixed by "anticheat"
869     for (key,value) in events.items():
870         if key.startswith("anticheat"):
871             try:
872                 ac = PlayerGameAnticheat(
873                     player.player_id,
874                     game.game_id,
875                     key,
876                     float(value)
877                 )
878                 anticheats.append(ac)
879                 session.add(ac)
880             except Exception as e:
881                 log.debug("Could not parse value for key %s. Ignoring." % key)
882
883     return anticheats
884
885
886 def create_default_team_stat(session, game_type_cd):
887     """Creates a blanked-out teamstat record for the given game type"""
888
889     # this is what we have to do to get partitioned records in - grab the
890     # sequence value first, then insert using the explicit ID (vs autogenerate)
891     seq = Sequence('team_game_stats_team_game_stat_id_seq')
892     teamstat_id = session.execute(seq)
893     teamstat = TeamGameStat(team_game_stat_id=teamstat_id,
894             create_dt=datetime.datetime.utcnow())
895
896     # all team game modes have a score, so we'll zero that out always
897     teamstat.score = 0
898
899     if game_type_cd in 'ca' 'ft' 'lms' 'ka':
900         teamstat.rounds = 0
901
902     if game_type_cd == 'ctf':
903         teamstat.caps = 0
904
905     return teamstat
906
907
908 def create_team_stat(session, game, events):
909     """Team stats handler for all game types"""
910
911     try:
912         teamstat = create_default_team_stat(session, game.game_type_cd)
913         teamstat.game_id = game.game_id
914
915         # we should have a team ID if we have a 'Q' event
916         if re.match(r'^team#\d+$', events.get('Q', '')):
917             team = int(events.get('Q').replace('team#', ''))
918             teamstat.team = team
919
920         # gametype-specific stuff is handled here. if passed to us, we store it
921         for (key,value) in events.items():
922             if key == 'scoreboard-score': teamstat.score = int(round(float(value)))
923             if key == 'scoreboard-caps': teamstat.caps = int(value)
924             if key == 'scoreboard-goals': teamstat.caps = int(value)
925             if key == 'scoreboard-rounds': teamstat.rounds = int(value)
926
927         session.add(teamstat)
928     except Exception as e:
929         raise e
930
931     return teamstat
932
933
934 def create_weapon_stats(session, game_meta, game, player, pgstat, events):
935     """Weapon stats handler for all game types"""
936     pwstats = []
937
938     # Version 1 of stats submissions doubled the data sent.
939     # To counteract this we divide the data by 2 only for
940     # POSTs coming from version 1.
941     try:
942         version = int(game_meta['V'])
943         if version == 1:
944             is_doubled = True
945             log.debug('NOTICE: found a version 1 request, halving the weapon stats...')
946         else:
947             is_doubled = False
948     except:
949         is_doubled = False
950
951     for (key,value) in events.items():
952         matched = re.search("acc-(.*?)-cnt-fired", key)
953         if matched:
954             weapon_cd = matched.group(1)
955
956             # Weapon names changed for 0.8. We'll convert the old
957             # ones to use the new scheme as well.
958             mapped_weapon_cd = weapon_map.get(weapon_cd, weapon_cd)
959
960             seq = Sequence('player_weapon_stats_player_weapon_stats_id_seq')
961             pwstat_id = session.execute(seq)
962             pwstat = PlayerWeaponStat()
963             pwstat.player_weapon_stats_id = pwstat_id
964             pwstat.player_id = player.player_id
965             pwstat.game_id = game.game_id
966             pwstat.player_game_stat_id = pgstat.player_game_stat_id
967             pwstat.weapon_cd = mapped_weapon_cd
968
969             if 'n' in events:
970                 pwstat.nick = events['n']
971             else:
972                 pwstat.nick = events['P']
973
974             if 'acc-' + weapon_cd + '-cnt-fired' in events:
975                 pwstat.fired = int(round(float(
976                         events['acc-' + weapon_cd + '-cnt-fired'])))
977             if 'acc-' + weapon_cd + '-fired' in events:
978                 pwstat.max = int(round(float(
979                         events['acc-' + weapon_cd + '-fired'])))
980             if 'acc-' + weapon_cd + '-cnt-hit' in events:
981                 pwstat.hit = int(round(float(
982                         events['acc-' + weapon_cd + '-cnt-hit'])))
983             if 'acc-' + weapon_cd + '-hit' in events:
984                 pwstat.actual = int(round(float(
985                         events['acc-' + weapon_cd + '-hit'])))
986             if 'acc-' + weapon_cd + '-frags' in events:
987                 pwstat.frags = int(round(float(
988                         events['acc-' + weapon_cd + '-frags'])))
989
990             if is_doubled:
991                 pwstat.fired = pwstat.fired/2
992                 pwstat.max = pwstat.max/2
993                 pwstat.hit = pwstat.hit/2
994                 pwstat.actual = pwstat.actual/2
995                 pwstat.frags = pwstat.frags/2
996
997             session.add(pwstat)
998             pwstats.append(pwstat)
999
1000     return pwstats
1001
1002
1003 def get_ranks(session, player_ids, game_type_cd):
1004     """
1005     Gets the rank entries for all players in the given list, returning a dict
1006     of player_id -> PlayerRank instance. The rank entry corresponds to the
1007     game type of the parameter passed in as well.
1008     """
1009     ranks = {}
1010     for pr in session.query(PlayerRank).\
1011             filter(PlayerRank.player_id.in_(player_ids)).\
1012             filter(PlayerRank.game_type_cd == game_type_cd).\
1013             all():
1014                 ranks[pr.player_id] = pr
1015
1016     return ranks
1017
1018
1019 def submit_stats(request):
1020     """
1021     Entry handler for POST stats submissions.
1022     """
1023     try:
1024         # placeholder for the actual session
1025         session = None
1026
1027         log.debug("\n----- BEGIN REQUEST BODY -----\n" + request.body +
1028                 "----- END REQUEST BODY -----\n\n")
1029
1030         (idfp, status) = verify_request(request)
1031         (game_meta, raw_players, raw_teams) = parse_stats_submission(request.body)
1032         revision = game_meta.get('R', 'unknown')
1033         duration = game_meta.get('D', None)
1034
1035         # only players present at the end of the match are eligible for stats
1036         raw_players = filter(played_in_game, raw_players)
1037
1038         do_precondition_checks(request, game_meta, raw_players)
1039
1040         # the "duel" gametype is fake
1041         if len(raw_players) == 2 \
1042             and num_real_players(raw_players) == 2 \
1043             and game_meta['G'] == 'dm':
1044             game_meta['G'] = 'duel'
1045
1046         #----------------------------------------------------------------------
1047         # Actual setup (inserts/updates) below here
1048         #----------------------------------------------------------------------
1049         session = DBSession()
1050
1051         game_type_cd = game_meta['G']
1052
1053         # All game types create Game, Server, Map, and Player records
1054         # the same way.
1055         server = get_or_create_server(
1056                 session      = session,
1057                 hashkey      = idfp,
1058                 name         = game_meta['S'],
1059                 revision     = revision,
1060                 ip_addr      = get_remote_addr(request),
1061                 port         = game_meta.get('U', None),
1062                 impure_cvars = game_meta.get('C', 0))
1063
1064         gmap = get_or_create_map(
1065                 session = session,
1066                 name    = game_meta['M'])
1067
1068         game = create_game(
1069                 session      = session,
1070                 start_dt     = datetime.datetime.utcnow(),
1071                 server_id    = server.server_id,
1072                 game_type_cd = game_type_cd,
1073                 map_id       = gmap.map_id,
1074                 match_id     = game_meta['I'],
1075                 duration     = duration,
1076                 mod          = game_meta.get('O', None))
1077
1078         # keep track of the players we've seen
1079         player_ids = []
1080         pgstats = []
1081         hashkeys = {}
1082         for events in raw_players:
1083             player = get_or_create_player(
1084                 session = session,
1085                 hashkey = events['P'],
1086                 nick    = events.get('n', None))
1087
1088             pgstat = create_game_stat(session, game_meta, game, server,
1089                     gmap, player, events)
1090             pgstats.append(pgstat)
1091
1092             if player.player_id > 1:
1093                 anticheats = create_anticheats(session, pgstat, game, player, events)
1094
1095             if player.player_id > 2:
1096                 player_ids.append(player.player_id)
1097                 hashkeys[player.player_id] = events['P']
1098
1099             if should_do_weapon_stats(game_type_cd) and player.player_id > 1:
1100                 pwstats = create_weapon_stats(session, game_meta, game, player,
1101                         pgstat, events)
1102
1103         # store them on games for easy access
1104         game.players = player_ids
1105
1106         for events in raw_teams:
1107             try:
1108                 teamstat = create_team_stat(session, game, events)
1109             except Exception as e:
1110                 raise e
1111
1112         if server.elo_ind and gametype_elo_eligible(game_type_cd):
1113             ep = EloProcessor(session, game, pgstats)
1114             ep.save(session)
1115
1116         session.commit()
1117         log.debug('Success! Stats recorded.')
1118
1119         # ranks are fetched after we've done the "real" processing
1120         ranks = get_ranks(session, player_ids, game_type_cd)
1121
1122         # plain text response
1123         request.response.content_type = 'text/plain'
1124
1125         return {
1126                 "now"        : calendar.timegm(datetime.datetime.utcnow().timetuple()),
1127                 "server"     : server,
1128                 "game"       : game,
1129                 "gmap"       : gmap,
1130                 "player_ids" : player_ids,
1131                 "hashkeys"   : hashkeys,
1132                 "elos"       : ep.wip,
1133                 "ranks"      : ranks,
1134         }
1135
1136     except Exception as e:
1137         if session:
1138             session.rollback()
1139         raise e