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