]> de.git.xonotic.org Git - xonotic/xonstat.git/blob - xonstat/views/submission.py
Simplify more functions.
[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     return game_type_cd in {'duel', 'dm', 'ca', 'ctf', 'tdm', 'ka', 'ft'}
417
418
419 def register_new_nick(session, player, new_nick):
420     """
421     Change the player record's nick to the newly found nick. Store the old
422     nick in the player_nicks table for that player.
423
424     session - SQLAlchemy database session factory
425     player - player record whose nick is changing
426     new_nick - the new nickname
427     """
428     # see if that nick already exists
429     stripped_nick = strip_colors(qfont_decode(player.nick))
430     try:
431         player_nick = session.query(PlayerNick).filter_by(
432             player_id=player.player_id, stripped_nick=stripped_nick).one()
433     except NoResultFound, e:
434         # player_id/stripped_nick not found, create one
435         # but we don't store "Anonymous Player #N"
436         if not re.search('^Anonymous Player #\d+$', player.nick):
437             player_nick = PlayerNick()
438             player_nick.player_id = player.player_id
439             player_nick.stripped_nick = stripped_nick
440             player_nick.nick = player.nick
441             session.add(player_nick)
442
443     # We change to the new nick regardless
444     player.nick = new_nick
445     player.stripped_nick = strip_colors(qfont_decode(new_nick))
446     session.add(player)
447
448
449 def update_fastest_cap(session, player_id, game_id, map_id, captime, mod):
450     """
451     Check the fastest cap time for the player and map. If there isn't
452     one, insert one. If there is, check if the passed time is faster.
453     If so, update!
454     """
455     # we don't record fastest cap times for bots or anonymous players
456     if player_id <= 2:
457         return
458
459     # see if a cap entry exists already
460     # then check to see if the new captime is faster
461     try:
462         cur_fastest_cap = session.query(PlayerCaptime).filter_by(
463             player_id=player_id, map_id=map_id, mod=mod).one()
464
465         # current captime is faster, so update
466         if captime < cur_fastest_cap.fastest_cap:
467             cur_fastest_cap.fastest_cap = captime
468             cur_fastest_cap.game_id = game_id
469             cur_fastest_cap.create_dt = datetime.datetime.utcnow()
470             session.add(cur_fastest_cap)
471
472     except NoResultFound, e:
473         # none exists, so insert
474         cur_fastest_cap = PlayerCaptime(player_id, game_id, map_id, captime,
475                 mod)
476         session.add(cur_fastest_cap)
477         session.flush()
478
479
480 def update_server(server, name, hashkey, ip_addr, port, revision, impure_cvars):
481     """
482     Updates the server in the given DB session, if needed.
483
484     :param server: The found server instance.
485     :param name: The incoming server name.
486     :param hashkey: The incoming server hashkey.
487     :param ip_addr: The incoming server IP address.
488     :param port: The incoming server port.
489     :param revision: The incoming server revision.
490     :param impure_cvars: The incoming number of impure server cvars.
491     :return: bool
492     """
493     # ensure the two int attributes are actually ints
494     try:
495         port = int(port)
496     except:
497         port = None
498
499     try:
500         impure_cvars = int(impure_cvars)
501     except:
502         impure_cvars = 0
503
504     updated = False
505     if name and server.name != name:
506         server.name = name
507         updated = True
508     if hashkey and server.hashkey != hashkey:
509         server.hashkey = hashkey
510         updated = True
511     if ip_addr and server.ip_addr != ip_addr:
512         server.ip_addr = ip_addr
513         updated = True
514     if port and server.port != port:
515         server.port = port
516         updated = True
517     if revision and server.revision != revision:
518         server.revision = revision
519         updated = True
520     if impure_cvars and server.impure_cvars != impure_cvars:
521         server.impure_cvars = impure_cvars
522         server.pure_ind = True if impure_cvars == 0 else False
523         updated = True
524
525     return updated
526
527
528 def get_or_create_server(session, name, hashkey, ip_addr, revision, port, impure_cvars):
529     """
530     Find a server by name or create one if not found. Parameters:
531
532     session - SQLAlchemy database session factory
533     name - server name of the server to be found or created
534     hashkey - server hashkey
535     ip_addr - the IP address of the server
536     revision - the xonotic revision number
537     port - the port number of the server
538     impure_cvars - the number of impure cvar changes
539     """
540     servers_q = DBSession.query(Server).filter(Server.active_ind)
541
542     if hashkey:
543         # if the hashkey is provided, we'll use that
544         servers_q = servers_q.filter((Server.name == name) or (Server.hashkey == hashkey))
545     else:
546         # otherwise, it is just by name
547         servers_q = servers_q.filter(Server.name == name)
548
549     # order by the hashkey, which means any hashkey match will appear first if there are multiple
550     servers = servers_q.order_by(Server.hashkey, Server.create_dt).all()
551
552     if len(servers) == 0:
553         server = Server(name=name, hashkey=hashkey)
554         session.add(server)
555         session.flush()
556         log.debug("Created server {} with hashkey {}.".format(server.server_id, server.hashkey))
557     else:
558         server = servers[0]
559         if len(servers) == 1:
560             log.info("Found existing server {}.".format(server.server_id))
561
562         elif len(servers) > 1:
563             server_id_list = ", ".join(["{}".format(s.server_id) for s in servers])
564             log.warn("Multiple servers found ({})! Using the first one ({})."
565                      .format(server_id_list, server.server_id))
566
567     if update_server(server, name, hashkey, ip_addr, port, revision, impure_cvars):
568         session.add(server)
569
570     return server
571
572
573 def get_or_create_map(session=None, name=None):
574     """
575     Find a map by name or create one if not found. Parameters:
576
577     session - SQLAlchemy database session factory
578     name - map name of the map to be found or created
579     """
580     try:
581         # find one by the name, if it exists
582         gmap = session.query(Map).filter_by(name=name).one()
583         log.debug("Found map id {0}: {1}".format(gmap.map_id,
584             gmap.name))
585     except NoResultFound, e:
586         gmap = Map(name=name)
587         session.add(gmap)
588         session.flush()
589         log.debug("Created map id {0}: {1}".format(gmap.map_id,
590             gmap.name))
591     except MultipleResultsFound, e:
592         # multiple found, so use the first one but warn
593         log.debug(e)
594         gmaps = session.query(Map).filter_by(name=name).order_by(
595                 Map.map_id).all()
596         gmap = gmaps[0]
597         log.debug("Found map id {0}: {1} but found \
598                 multiple".format(gmap.map_id, gmap.name))
599
600     return gmap
601
602
603 def create_game(session, start_dt, game_type_cd, server_id, map_id,
604         match_id, duration, mod, winner=None):
605     """
606     Creates a game. Parameters:
607
608     session - SQLAlchemy database session factory
609     start_dt - when the game started (datetime object)
610     game_type_cd - the game type of the game being played
611     server_id - server identifier of the server hosting the game
612     map_id - map on which the game was played
613     winner - the team id of the team that won
614     duration - how long the game lasted
615     mod - mods in use during the game
616     """
617     seq = Sequence('games_game_id_seq')
618     game_id = session.execute(seq)
619     game = Game(game_id=game_id, start_dt=start_dt, game_type_cd=game_type_cd,
620                 server_id=server_id, map_id=map_id, winner=winner)
621     game.match_id = match_id
622     game.mod = mod[:64]
623
624     # There is some drift between start_dt (provided by app) and create_dt
625     # (default in the database), so we'll make them the same until this is 
626     # resolved.
627     game.create_dt = start_dt
628
629     try:
630         game.duration = datetime.timedelta(seconds=int(round(float(duration))))
631     except:
632         pass
633
634     try:
635         session.query(Game).filter(Game.server_id==server_id).\
636                 filter(Game.match_id==match_id).one()
637
638         log.debug("Error: game with same server and match_id found! Ignoring.")
639
640         # if a game under the same server and match_id found,
641         # this is a duplicate game and can be ignored
642         raise pyramid.httpexceptions.HTTPOk('OK')
643     except NoResultFound, e:
644         # server_id/match_id combination not found. game is ok to insert
645         session.add(game)
646         session.flush()
647         log.debug("Created game id {0} on server {1}, map {2} at \
648                 {3}".format(game.game_id,
649                     server_id, map_id, start_dt))
650
651     return game
652
653
654 def get_or_create_player(session=None, hashkey=None, nick=None):
655     """
656     Finds a player by hashkey or creates a new one (along with a
657     corresponding hashkey entry. Parameters:
658
659     session - SQLAlchemy database session factory
660     hashkey - hashkey of the player to be found or created
661     nick - nick of the player (in case of a first time create)
662     """
663     # if we have a bot
664     if re.search('^bot#\d+', hashkey):
665         player = session.query(Player).filter_by(player_id=1).one()
666     # if we have an untracked player
667     elif re.search('^player#\d+$', hashkey):
668         player = session.query(Player).filter_by(player_id=2).one()
669     # else it is a tracked player
670     else:
671         # see if the player is already in the database
672         # if not, create one and the hashkey along with it
673         try:
674             hk = session.query(Hashkey).filter_by(
675                     hashkey=hashkey).one()
676             player = session.query(Player).filter_by(
677                     player_id=hk.player_id).one()
678             log.debug("Found existing player {0} with hashkey {1}".format(
679                 player.player_id, hashkey))
680         except:
681             player = Player()
682             session.add(player)
683             session.flush()
684
685             # if nick is given to us, use it. If not, use "Anonymous Player"
686             # with a suffix added for uniqueness.
687             if nick:
688                 player.nick = nick[:128]
689                 player.stripped_nick = strip_colors(qfont_decode(nick[:128]))
690             else:
691                 player.nick = "Anonymous Player #{0}".format(player.player_id)
692                 player.stripped_nick = player.nick
693
694             hk = Hashkey(player_id=player.player_id, hashkey=hashkey)
695             session.add(hk)
696             log.debug("Created player {0} ({2}) with hashkey {1}".format(
697                 player.player_id, hashkey, player.nick.encode('utf-8')))
698
699     return player
700
701
702 def create_default_game_stat(session, game_type_cd):
703     """Creates a blanked-out pgstat record for the given game type"""
704
705     # this is what we have to do to get partitioned records in - grab the
706     # sequence value first, then insert using the explicit ID (vs autogenerate)
707     seq = Sequence('player_game_stats_player_game_stat_id_seq')
708     pgstat_id = session.execute(seq)
709     pgstat = PlayerGameStat(player_game_stat_id=pgstat_id,
710             create_dt=datetime.datetime.utcnow())
711
712     if game_type_cd == 'as':
713         pgstat.kills = pgstat.deaths = pgstat.suicides = pgstat.collects = 0
714
715     if game_type_cd in 'ca' 'dm' 'duel' 'rune' 'tdm':
716         pgstat.kills = pgstat.deaths = pgstat.suicides = 0
717
718     if game_type_cd == 'cq':
719         pgstat.kills = pgstat.deaths = pgstat.suicides = pgstat.captures = 0
720         pgstat.drops = 0
721
722     if game_type_cd == 'ctf':
723         pgstat.kills = pgstat.captures = pgstat.pickups = pgstat.drops = 0
724         pgstat.returns = pgstat.carrier_frags = 0
725
726     if game_type_cd == 'cts':
727         pgstat.deaths = 0
728
729     if game_type_cd == 'dom':
730         pgstat.kills = pgstat.deaths = pgstat.suicides = pgstat.pickups = 0
731         pgstat.drops = 0
732
733     if game_type_cd == 'ft':
734         pgstat.kills = pgstat.deaths = pgstat.suicides = pgstat.revivals = 0
735
736     if game_type_cd == 'ka':
737         pgstat.kills = pgstat.deaths = pgstat.suicides = pgstat.pickups = 0
738         pgstat.carrier_frags = 0
739         pgstat.time = datetime.timedelta(seconds=0)
740
741     if game_type_cd == 'kh':
742         pgstat.kills = pgstat.deaths = pgstat.suicides = pgstat.pickups = 0
743         pgstat.captures = pgstat.drops = pgstat.pushes = pgstat.destroys = 0
744         pgstat.carrier_frags = 0
745
746     if game_type_cd == 'lms':
747         pgstat.kills = pgstat.deaths = pgstat.suicides = pgstat.lives = 0
748
749     if game_type_cd == 'nb':
750         pgstat.kills = pgstat.deaths = pgstat.suicides = pgstat.captures = 0
751         pgstat.drops = 0
752
753     if game_type_cd == 'rc':
754         pgstat.kills = pgstat.deaths = pgstat.suicides = pgstat.laps = 0
755
756     return pgstat
757
758
759 def create_game_stat(session, game_meta, game, server, gmap, player, events):
760     """Game stats handler for all game types"""
761
762     game_type_cd = game.game_type_cd
763
764     pgstat = create_default_game_stat(session, game_type_cd)
765
766     # these fields should be on every pgstat record
767     pgstat.game_id       = game.game_id
768     pgstat.player_id     = player.player_id
769     pgstat.nick          = events.get('n', 'Anonymous Player')[:128]
770     pgstat.stripped_nick = strip_colors(qfont_decode(pgstat.nick))
771     pgstat.score         = int(round(float(events.get('scoreboard-score', 0))))
772     pgstat.alivetime     = datetime.timedelta(seconds=int(round(float(events.get('alivetime', 0.0)))))
773     pgstat.rank          = int(events.get('rank', None))
774     pgstat.scoreboardpos = int(events.get('scoreboardpos', pgstat.rank))
775
776     if pgstat.nick != player.nick \
777             and player.player_id > 2 \
778             and pgstat.nick != 'Anonymous Player':
779         register_new_nick(session, player, pgstat.nick)
780
781     wins = False
782
783     # gametype-specific stuff is handled here. if passed to us, we store it
784     for (key,value) in events.items():
785         if key == 'wins': wins = True
786         if key == 't': pgstat.team = int(value)
787
788         if key == 'scoreboard-drops': pgstat.drops = int(value)
789         if key == 'scoreboard-returns': pgstat.returns = int(value)
790         if key == 'scoreboard-fckills': pgstat.carrier_frags = int(value)
791         if key == 'scoreboard-pickups': pgstat.pickups = int(value)
792         if key == 'scoreboard-caps': pgstat.captures = int(value)
793         if key == 'scoreboard-score': pgstat.score = int(round(float(value)))
794         if key == 'scoreboard-deaths': pgstat.deaths = int(value)
795         if key == 'scoreboard-kills': pgstat.kills = int(value)
796         if key == 'scoreboard-suicides': pgstat.suicides = int(value)
797         if key == 'scoreboard-objectives': pgstat.collects = int(value)
798         if key == 'scoreboard-captured': pgstat.captures = int(value)
799         if key == 'scoreboard-released': pgstat.drops = int(value)
800         if key == 'scoreboard-fastest':
801             pgstat.fastest = datetime.timedelta(seconds=float(value)/100)
802         if key == 'scoreboard-takes': pgstat.pickups = int(value)
803         if key == 'scoreboard-ticks': pgstat.drops = int(value)
804         if key == 'scoreboard-revivals': pgstat.revivals = int(value)
805         if key == 'scoreboard-bctime':
806             pgstat.time = datetime.timedelta(seconds=int(value))
807         if key == 'scoreboard-bckills': pgstat.carrier_frags = int(value)
808         if key == 'scoreboard-losses': pgstat.drops = int(value)
809         if key == 'scoreboard-pushes': pgstat.pushes = int(value)
810         if key == 'scoreboard-destroyed': pgstat.destroys = int(value)
811         if key == 'scoreboard-kckills': pgstat.carrier_frags = int(value)
812         if key == 'scoreboard-lives': pgstat.lives = int(value)
813         if key == 'scoreboard-goals': pgstat.captures = int(value)
814         if key == 'scoreboard-faults': pgstat.drops = int(value)
815         if key == 'scoreboard-laps': pgstat.laps = int(value)
816
817         if key == 'avglatency': pgstat.avg_latency = float(value)
818         if key == 'scoreboard-captime':
819             pgstat.fastest = datetime.timedelta(seconds=float(value)/100)
820             if game.game_type_cd == 'ctf':
821                 update_fastest_cap(session, player.player_id, game.game_id,
822                         gmap.map_id, pgstat.fastest, game.mod)
823
824     # there is no "winning team" field, so we have to derive it
825     if wins and pgstat.team is not None and game.winner is None:
826         game.winner = pgstat.team
827         session.add(game)
828
829     session.add(pgstat)
830
831     return pgstat
832
833
834 def create_anticheats(session, pgstat, game, player, events):
835     """Anticheats handler for all game types"""
836
837     anticheats = []
838
839     # all anticheat events are prefixed by "anticheat"
840     for (key,value) in events.items():
841         if key.startswith("anticheat"):
842             try:
843                 ac = PlayerGameAnticheat(
844                     player.player_id,
845                     game.game_id,
846                     key,
847                     float(value)
848                 )
849                 anticheats.append(ac)
850                 session.add(ac)
851             except Exception as e:
852                 log.debug("Could not parse value for key %s. Ignoring." % key)
853
854     return anticheats
855
856
857 def create_default_team_stat(session, game_type_cd):
858     """Creates a blanked-out teamstat record for the given game type"""
859
860     # this is what we have to do to get partitioned records in - grab the
861     # sequence value first, then insert using the explicit ID (vs autogenerate)
862     seq = Sequence('team_game_stats_team_game_stat_id_seq')
863     teamstat_id = session.execute(seq)
864     teamstat = TeamGameStat(team_game_stat_id=teamstat_id,
865             create_dt=datetime.datetime.utcnow())
866
867     # all team game modes have a score, so we'll zero that out always
868     teamstat.score = 0
869
870     if game_type_cd in 'ca' 'ft' 'lms' 'ka':
871         teamstat.rounds = 0
872
873     if game_type_cd == 'ctf':
874         teamstat.caps = 0
875
876     return teamstat
877
878
879 def create_team_stat(session, game, events):
880     """Team stats handler for all game types"""
881
882     try:
883         teamstat = create_default_team_stat(session, game.game_type_cd)
884         teamstat.game_id = game.game_id
885
886         # we should have a team ID if we have a 'Q' event
887         if re.match(r'^team#\d+$', events.get('Q', '')):
888             team = int(events.get('Q').replace('team#', ''))
889             teamstat.team = team
890
891         # gametype-specific stuff is handled here. if passed to us, we store it
892         for (key,value) in events.items():
893             if key == 'scoreboard-score': teamstat.score = int(round(float(value)))
894             if key == 'scoreboard-caps': teamstat.caps = int(value)
895             if key == 'scoreboard-goals': teamstat.caps = int(value)
896             if key == 'scoreboard-rounds': teamstat.rounds = int(value)
897
898         session.add(teamstat)
899     except Exception as e:
900         raise e
901
902     return teamstat
903
904
905 def create_weapon_stats(session, game_meta, game, player, pgstat, events):
906     """Weapon stats handler for all game types"""
907     pwstats = []
908
909     # Version 1 of stats submissions doubled the data sent.
910     # To counteract this we divide the data by 2 only for
911     # POSTs coming from version 1.
912     try:
913         version = int(game_meta['V'])
914         if version == 1:
915             is_doubled = True
916             log.debug('NOTICE: found a version 1 request, halving the weapon stats...')
917         else:
918             is_doubled = False
919     except:
920         is_doubled = False
921
922     for (key,value) in events.items():
923         matched = re.search("acc-(.*?)-cnt-fired", key)
924         if matched:
925             weapon_cd = matched.group(1)
926
927             # Weapon names changed for 0.8. We'll convert the old
928             # ones to use the new scheme as well.
929             mapped_weapon_cd = weapon_map.get(weapon_cd, weapon_cd)
930
931             seq = Sequence('player_weapon_stats_player_weapon_stats_id_seq')
932             pwstat_id = session.execute(seq)
933             pwstat = PlayerWeaponStat()
934             pwstat.player_weapon_stats_id = pwstat_id
935             pwstat.player_id = player.player_id
936             pwstat.game_id = game.game_id
937             pwstat.player_game_stat_id = pgstat.player_game_stat_id
938             pwstat.weapon_cd = mapped_weapon_cd
939
940             if 'n' in events:
941                 pwstat.nick = events['n']
942             else:
943                 pwstat.nick = events['P']
944
945             if 'acc-' + weapon_cd + '-cnt-fired' in events:
946                 pwstat.fired = int(round(float(
947                         events['acc-' + weapon_cd + '-cnt-fired'])))
948             if 'acc-' + weapon_cd + '-fired' in events:
949                 pwstat.max = int(round(float(
950                         events['acc-' + weapon_cd + '-fired'])))
951             if 'acc-' + weapon_cd + '-cnt-hit' in events:
952                 pwstat.hit = int(round(float(
953                         events['acc-' + weapon_cd + '-cnt-hit'])))
954             if 'acc-' + weapon_cd + '-hit' in events:
955                 pwstat.actual = int(round(float(
956                         events['acc-' + weapon_cd + '-hit'])))
957             if 'acc-' + weapon_cd + '-frags' in events:
958                 pwstat.frags = int(round(float(
959                         events['acc-' + weapon_cd + '-frags'])))
960
961             if is_doubled:
962                 pwstat.fired = pwstat.fired/2
963                 pwstat.max = pwstat.max/2
964                 pwstat.hit = pwstat.hit/2
965                 pwstat.actual = pwstat.actual/2
966                 pwstat.frags = pwstat.frags/2
967
968             session.add(pwstat)
969             pwstats.append(pwstat)
970
971     return pwstats
972
973
974 def get_ranks(session, player_ids, game_type_cd):
975     """
976     Gets the rank entries for all players in the given list, returning a dict
977     of player_id -> PlayerRank instance. The rank entry corresponds to the
978     game type of the parameter passed in as well.
979     """
980     ranks = {}
981     for pr in session.query(PlayerRank).\
982             filter(PlayerRank.player_id.in_(player_ids)).\
983             filter(PlayerRank.game_type_cd == game_type_cd).\
984             all():
985                 ranks[pr.player_id] = pr
986
987     return ranks
988
989
990 def submit_stats(request):
991     """
992     Entry handler for POST stats submissions.
993     """
994     try:
995         # placeholder for the actual session
996         session = None
997
998         log.debug("\n----- BEGIN REQUEST BODY -----\n" + request.body +
999                 "----- END REQUEST BODY -----\n\n")
1000
1001         (idfp, status) = verify_request(request)
1002         (game_meta, raw_players, raw_teams) = parse_stats_submission(request.body)
1003         revision = game_meta.get('R', 'unknown')
1004         duration = game_meta.get('D', None)
1005
1006         # only players present at the end of the match are eligible for stats
1007         raw_players = filter(played_in_game, raw_players)
1008
1009         do_precondition_checks(request, game_meta, raw_players)
1010
1011         # the "duel" gametype is fake
1012         if len(raw_players) == 2 \
1013             and num_real_players(raw_players) == 2 \
1014             and game_meta['G'] == 'dm':
1015             game_meta['G'] = 'duel'
1016
1017         #----------------------------------------------------------------------
1018         # Actual setup (inserts/updates) below here
1019         #----------------------------------------------------------------------
1020         session = DBSession()
1021
1022         game_type_cd = game_meta['G']
1023
1024         # All game types create Game, Server, Map, and Player records
1025         # the same way.
1026         server = get_or_create_server(
1027                 session      = session,
1028                 hashkey      = idfp,
1029                 name         = game_meta['S'],
1030                 revision     = revision,
1031                 ip_addr      = get_remote_addr(request),
1032                 port         = game_meta.get('U', None),
1033                 impure_cvars = game_meta.get('C', 0))
1034
1035         gmap = get_or_create_map(
1036                 session = session,
1037                 name    = game_meta['M'])
1038
1039         game = create_game(
1040                 session      = session,
1041                 start_dt     = datetime.datetime.utcnow(),
1042                 server_id    = server.server_id,
1043                 game_type_cd = game_type_cd,
1044                 map_id       = gmap.map_id,
1045                 match_id     = game_meta['I'],
1046                 duration     = duration,
1047                 mod          = game_meta.get('O', None))
1048
1049         # keep track of the players we've seen
1050         player_ids = []
1051         pgstats = []
1052         hashkeys = {}
1053         for events in raw_players:
1054             player = get_or_create_player(
1055                 session = session,
1056                 hashkey = events['P'],
1057                 nick    = events.get('n', None))
1058
1059             pgstat = create_game_stat(session, game_meta, game, server,
1060                     gmap, player, events)
1061             pgstats.append(pgstat)
1062
1063             if player.player_id > 1:
1064                 anticheats = create_anticheats(session, pgstat, game, player, events)
1065
1066             if player.player_id > 2:
1067                 player_ids.append(player.player_id)
1068                 hashkeys[player.player_id] = events['P']
1069
1070             if should_do_weapon_stats(game_type_cd) and player.player_id > 1:
1071                 pwstats = create_weapon_stats(session, game_meta, game, player,
1072                         pgstat, events)
1073
1074         # store them on games for easy access
1075         game.players = player_ids
1076
1077         for events in raw_teams:
1078             try:
1079                 teamstat = create_team_stat(session, game, events)
1080             except Exception as e:
1081                 raise e
1082
1083         if server.elo_ind and gametype_elo_eligible(game_type_cd):
1084             ep = EloProcessor(session, game, pgstats)
1085             ep.save(session)
1086
1087         session.commit()
1088         log.debug('Success! Stats recorded.')
1089
1090         # ranks are fetched after we've done the "real" processing
1091         ranks = get_ranks(session, player_ids, game_type_cd)
1092
1093         # plain text response
1094         request.response.content_type = 'text/plain'
1095
1096         return {
1097                 "now"        : calendar.timegm(datetime.datetime.utcnow().timetuple()),
1098                 "server"     : server,
1099                 "game"       : game,
1100                 "gmap"       : gmap,
1101                 "player_ids" : player_ids,
1102                 "hashkeys"   : hashkeys,
1103                 "elos"       : ep.wip,
1104                 "ranks"      : ranks,
1105         }
1106
1107     except Exception as e:
1108         if session:
1109             session.rollback()
1110         raise e