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