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