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