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