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