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