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