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