]> de.git.xonotic.org Git - xonotic/xonstat.git/blob - xonstat/views/submission.py
0cd61be18f98f4d5699640d562f6e6e7bd6f4328
[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, name):
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     maps = session.query(Map).filter_by(name=name).order_by(Map.map_id).all()
567
568     if maps is None or len(maps) == 0:
569         gmap = Map(name=name)
570         session.add(gmap)
571         session.flush()
572         log.debug("Created map id {}: {}".format(gmap.map_id, gmap.name))
573     elif len(maps) == 1:
574         gmap = maps[0]
575         log.debug("Found map id {}: {}".format(gmap.map_id, gmap.name))
576     else:
577         gmap = maps[0]
578         map_id_list = ", ".join(["{}".format(m.map_id) for m in maps])
579         log.warn("Multiple maps found for {} ({})! Using the first one.".format(name, map_id_list))
580
581     return gmap
582
583
584 def create_game(session, start_dt, game_type_cd, server_id, map_id,
585         match_id, duration, mod, winner=None):
586     """
587     Creates a game. Parameters:
588
589     session - SQLAlchemy database session factory
590     start_dt - when the game started (datetime object)
591     game_type_cd - the game type of the game being played
592     server_id - server identifier of the server hosting the game
593     map_id - map on which the game was played
594     winner - the team id of the team that won
595     duration - how long the game lasted
596     mod - mods in use during the game
597     """
598     seq = Sequence('games_game_id_seq')
599     game_id = session.execute(seq)
600     game = Game(game_id=game_id, start_dt=start_dt, game_type_cd=game_type_cd,
601                 server_id=server_id, map_id=map_id, winner=winner)
602     game.match_id = match_id
603     game.mod = mod[:64]
604
605     # There is some drift between start_dt (provided by app) and create_dt
606     # (default in the database), so we'll make them the same until this is 
607     # resolved.
608     game.create_dt = start_dt
609
610     try:
611         game.duration = datetime.timedelta(seconds=int(round(float(duration))))
612     except:
613         pass
614
615     try:
616         session.query(Game).filter(Game.server_id==server_id).\
617                 filter(Game.match_id==match_id).one()
618
619         log.debug("Error: game with same server and match_id found! Ignoring.")
620
621         # if a game under the same server and match_id found,
622         # this is a duplicate game and can be ignored
623         raise pyramid.httpexceptions.HTTPOk('OK')
624     except NoResultFound, e:
625         # server_id/match_id combination not found. game is ok to insert
626         session.add(game)
627         session.flush()
628         log.debug("Created game id {0} on server {1}, map {2} at \
629                 {3}".format(game.game_id,
630                     server_id, map_id, start_dt))
631
632     return game
633
634
635 def get_or_create_player(session=None, hashkey=None, nick=None):
636     """
637     Finds a player by hashkey or creates a new one (along with a
638     corresponding hashkey entry. Parameters:
639
640     session - SQLAlchemy database session factory
641     hashkey - hashkey of the player to be found or created
642     nick - nick of the player (in case of a first time create)
643     """
644     # if we have a bot
645     if re.search('^bot#\d+', hashkey):
646         player = session.query(Player).filter_by(player_id=1).one()
647     # if we have an untracked player
648     elif re.search('^player#\d+$', hashkey):
649         player = session.query(Player).filter_by(player_id=2).one()
650     # else it is a tracked player
651     else:
652         # see if the player is already in the database
653         # if not, create one and the hashkey along with it
654         try:
655             hk = session.query(Hashkey).filter_by(
656                     hashkey=hashkey).one()
657             player = session.query(Player).filter_by(
658                     player_id=hk.player_id).one()
659             log.debug("Found existing player {0} with hashkey {1}".format(
660                 player.player_id, hashkey))
661         except:
662             player = Player()
663             session.add(player)
664             session.flush()
665
666             # if nick is given to us, use it. If not, use "Anonymous Player"
667             # with a suffix added for uniqueness.
668             if nick:
669                 player.nick = nick[:128]
670                 player.stripped_nick = strip_colors(qfont_decode(nick[:128]))
671             else:
672                 player.nick = "Anonymous Player #{0}".format(player.player_id)
673                 player.stripped_nick = player.nick
674
675             hk = Hashkey(player_id=player.player_id, hashkey=hashkey)
676             session.add(hk)
677             log.debug("Created player {0} ({2}) with hashkey {1}".format(
678                 player.player_id, hashkey, player.nick.encode('utf-8')))
679
680     return player
681
682
683 def create_default_game_stat(session, game_type_cd):
684     """Creates a blanked-out pgstat record for the given game type"""
685
686     # this is what we have to do to get partitioned records in - grab the
687     # sequence value first, then insert using the explicit ID (vs autogenerate)
688     seq = Sequence('player_game_stats_player_game_stat_id_seq')
689     pgstat_id = session.execute(seq)
690     pgstat = PlayerGameStat(player_game_stat_id=pgstat_id,
691             create_dt=datetime.datetime.utcnow())
692
693     if game_type_cd == 'as':
694         pgstat.kills = pgstat.deaths = pgstat.suicides = pgstat.collects = 0
695
696     if game_type_cd in 'ca' 'dm' 'duel' 'rune' 'tdm':
697         pgstat.kills = pgstat.deaths = pgstat.suicides = 0
698
699     if game_type_cd == 'cq':
700         pgstat.kills = pgstat.deaths = pgstat.suicides = pgstat.captures = 0
701         pgstat.drops = 0
702
703     if game_type_cd == 'ctf':
704         pgstat.kills = pgstat.captures = pgstat.pickups = pgstat.drops = 0
705         pgstat.returns = pgstat.carrier_frags = 0
706
707     if game_type_cd == 'cts':
708         pgstat.deaths = 0
709
710     if game_type_cd == 'dom':
711         pgstat.kills = pgstat.deaths = pgstat.suicides = pgstat.pickups = 0
712         pgstat.drops = 0
713
714     if game_type_cd == 'ft':
715         pgstat.kills = pgstat.deaths = pgstat.suicides = pgstat.revivals = 0
716
717     if game_type_cd == 'ka':
718         pgstat.kills = pgstat.deaths = pgstat.suicides = pgstat.pickups = 0
719         pgstat.carrier_frags = 0
720         pgstat.time = datetime.timedelta(seconds=0)
721
722     if game_type_cd == 'kh':
723         pgstat.kills = pgstat.deaths = pgstat.suicides = pgstat.pickups = 0
724         pgstat.captures = pgstat.drops = pgstat.pushes = pgstat.destroys = 0
725         pgstat.carrier_frags = 0
726
727     if game_type_cd == 'lms':
728         pgstat.kills = pgstat.deaths = pgstat.suicides = pgstat.lives = 0
729
730     if game_type_cd == 'nb':
731         pgstat.kills = pgstat.deaths = pgstat.suicides = pgstat.captures = 0
732         pgstat.drops = 0
733
734     if game_type_cd == 'rc':
735         pgstat.kills = pgstat.deaths = pgstat.suicides = pgstat.laps = 0
736
737     return pgstat
738
739
740 def create_game_stat(session, game, gmap, player, events):
741     """Game stats handler for all game types"""
742
743     game_type_cd = game.game_type_cd
744
745     pgstat = create_default_game_stat(session, game_type_cd)
746
747     # these fields should be on every pgstat record
748     pgstat.game_id       = game.game_id
749     pgstat.player_id     = player.player_id
750     pgstat.nick          = events.get('n', 'Anonymous Player')[:128]
751     pgstat.stripped_nick = strip_colors(qfont_decode(pgstat.nick))
752     pgstat.score         = int(round(float(events.get('scoreboard-score', 0))))
753     pgstat.alivetime     = datetime.timedelta(seconds=int(round(float(events.get('alivetime', 0.0)))))
754     pgstat.rank          = int(events.get('rank', None))
755     pgstat.scoreboardpos = int(events.get('scoreboardpos', pgstat.rank))
756
757     if pgstat.nick != player.nick \
758             and player.player_id > 2 \
759             and pgstat.nick != 'Anonymous Player':
760         register_new_nick(session, player, pgstat.nick)
761
762     wins = False
763
764     # gametype-specific stuff is handled here. if passed to us, we store it
765     for (key,value) in events.items():
766         if key == 'wins': wins = True
767         if key == 't': pgstat.team = int(value)
768
769         if key == 'scoreboard-drops': pgstat.drops = int(value)
770         if key == 'scoreboard-returns': pgstat.returns = int(value)
771         if key == 'scoreboard-fckills': pgstat.carrier_frags = int(value)
772         if key == 'scoreboard-pickups': pgstat.pickups = int(value)
773         if key == 'scoreboard-caps': pgstat.captures = int(value)
774         if key == 'scoreboard-score': pgstat.score = int(round(float(value)))
775         if key == 'scoreboard-deaths': pgstat.deaths = int(value)
776         if key == 'scoreboard-kills': pgstat.kills = int(value)
777         if key == 'scoreboard-suicides': pgstat.suicides = int(value)
778         if key == 'scoreboard-objectives': pgstat.collects = int(value)
779         if key == 'scoreboard-captured': pgstat.captures = int(value)
780         if key == 'scoreboard-released': pgstat.drops = int(value)
781         if key == 'scoreboard-fastest':
782             pgstat.fastest = datetime.timedelta(seconds=float(value)/100)
783         if key == 'scoreboard-takes': pgstat.pickups = int(value)
784         if key == 'scoreboard-ticks': pgstat.drops = int(value)
785         if key == 'scoreboard-revivals': pgstat.revivals = int(value)
786         if key == 'scoreboard-bctime':
787             pgstat.time = datetime.timedelta(seconds=int(value))
788         if key == 'scoreboard-bckills': pgstat.carrier_frags = int(value)
789         if key == 'scoreboard-losses': pgstat.drops = int(value)
790         if key == 'scoreboard-pushes': pgstat.pushes = int(value)
791         if key == 'scoreboard-destroyed': pgstat.destroys = int(value)
792         if key == 'scoreboard-kckills': pgstat.carrier_frags = int(value)
793         if key == 'scoreboard-lives': pgstat.lives = int(value)
794         if key == 'scoreboard-goals': pgstat.captures = int(value)
795         if key == 'scoreboard-faults': pgstat.drops = int(value)
796         if key == 'scoreboard-laps': pgstat.laps = int(value)
797
798         if key == 'avglatency': pgstat.avg_latency = float(value)
799         if key == 'scoreboard-captime':
800             pgstat.fastest = datetime.timedelta(seconds=float(value)/100)
801             if game.game_type_cd == 'ctf':
802                 update_fastest_cap(session, player.player_id, game.game_id,
803                         gmap.map_id, pgstat.fastest, game.mod)
804
805     # there is no "winning team" field, so we have to derive it
806     if wins and pgstat.team is not None and game.winner is None:
807         game.winner = pgstat.team
808         session.add(game)
809
810     session.add(pgstat)
811
812     return pgstat
813
814
815 def create_anticheats(session, pgstat, game, player, events):
816     """Anticheats handler for all game types"""
817
818     anticheats = []
819
820     # all anticheat events are prefixed by "anticheat"
821     for (key,value) in events.items():
822         if key.startswith("anticheat"):
823             try:
824                 ac = PlayerGameAnticheat(
825                     player.player_id,
826                     game.game_id,
827                     key,
828                     float(value)
829                 )
830                 anticheats.append(ac)
831                 session.add(ac)
832             except Exception as e:
833                 log.debug("Could not parse value for key %s. Ignoring." % key)
834
835     return anticheats
836
837
838 def create_default_team_stat(session, game_type_cd):
839     """Creates a blanked-out teamstat record for the given game type"""
840
841     # this is what we have to do to get partitioned records in - grab the
842     # sequence value first, then insert using the explicit ID (vs autogenerate)
843     seq = Sequence('team_game_stats_team_game_stat_id_seq')
844     teamstat_id = session.execute(seq)
845     teamstat = TeamGameStat(team_game_stat_id=teamstat_id,
846             create_dt=datetime.datetime.utcnow())
847
848     # all team game modes have a score, so we'll zero that out always
849     teamstat.score = 0
850
851     if game_type_cd in 'ca' 'ft' 'lms' 'ka':
852         teamstat.rounds = 0
853
854     if game_type_cd == 'ctf':
855         teamstat.caps = 0
856
857     return teamstat
858
859
860 def create_team_stat(session, game, events):
861     """Team stats handler for all game types"""
862
863     try:
864         teamstat = create_default_team_stat(session, game.game_type_cd)
865         teamstat.game_id = game.game_id
866
867         # we should have a team ID if we have a 'Q' event
868         if re.match(r'^team#\d+$', events.get('Q', '')):
869             team = int(events.get('Q').replace('team#', ''))
870             teamstat.team = team
871
872         # gametype-specific stuff is handled here. if passed to us, we store it
873         for (key,value) in events.items():
874             if key == 'scoreboard-score': teamstat.score = int(round(float(value)))
875             if key == 'scoreboard-caps': teamstat.caps = int(value)
876             if key == 'scoreboard-goals': teamstat.caps = int(value)
877             if key == 'scoreboard-rounds': teamstat.rounds = int(value)
878
879         session.add(teamstat)
880     except Exception as e:
881         raise e
882
883     return teamstat
884
885
886 def create_weapon_stats(session, version, game, player, pgstat, events):
887     """Weapon stats handler for all game types"""
888     pwstats = []
889
890     # Version 1 of stats submissions doubled the data sent.
891     # To counteract this we divide the data by 2 only for
892     # POSTs coming from version 1.
893     try:
894         if version == 1:
895             is_doubled = True
896             log.debug('NOTICE: found a version 1 request, halving the weapon stats...')
897         else:
898             is_doubled = False
899     except:
900         is_doubled = False
901
902     for (key,value) in events.items():
903         matched = re.search("acc-(.*?)-cnt-fired", key)
904         if matched:
905             weapon_cd = matched.group(1)
906
907             # Weapon names changed for 0.8. We'll convert the old
908             # ones to use the new scheme as well.
909             mapped_weapon_cd = weapon_map.get(weapon_cd, weapon_cd)
910
911             seq = Sequence('player_weapon_stats_player_weapon_stats_id_seq')
912             pwstat_id = session.execute(seq)
913             pwstat = PlayerWeaponStat()
914             pwstat.player_weapon_stats_id = pwstat_id
915             pwstat.player_id = player.player_id
916             pwstat.game_id = game.game_id
917             pwstat.player_game_stat_id = pgstat.player_game_stat_id
918             pwstat.weapon_cd = mapped_weapon_cd
919
920             if 'n' in events:
921                 pwstat.nick = events['n']
922             else:
923                 pwstat.nick = events['P']
924
925             if 'acc-' + weapon_cd + '-cnt-fired' in events:
926                 pwstat.fired = int(round(float(
927                         events['acc-' + weapon_cd + '-cnt-fired'])))
928             if 'acc-' + weapon_cd + '-fired' in events:
929                 pwstat.max = int(round(float(
930                         events['acc-' + weapon_cd + '-fired'])))
931             if 'acc-' + weapon_cd + '-cnt-hit' in events:
932                 pwstat.hit = int(round(float(
933                         events['acc-' + weapon_cd + '-cnt-hit'])))
934             if 'acc-' + weapon_cd + '-hit' in events:
935                 pwstat.actual = int(round(float(
936                         events['acc-' + weapon_cd + '-hit'])))
937             if 'acc-' + weapon_cd + '-frags' in events:
938                 pwstat.frags = int(round(float(
939                         events['acc-' + weapon_cd + '-frags'])))
940
941             if is_doubled:
942                 pwstat.fired = pwstat.fired/2
943                 pwstat.max = pwstat.max/2
944                 pwstat.hit = pwstat.hit/2
945                 pwstat.actual = pwstat.actual/2
946                 pwstat.frags = pwstat.frags/2
947
948             session.add(pwstat)
949             pwstats.append(pwstat)
950
951     return pwstats
952
953
954 def get_ranks(session, player_ids, game_type_cd):
955     """
956     Gets the rank entries for all players in the given list, returning a dict
957     of player_id -> PlayerRank instance. The rank entry corresponds to the
958     game type of the parameter passed in as well.
959     """
960     ranks = {}
961     for pr in session.query(PlayerRank).\
962             filter(PlayerRank.player_id.in_(player_ids)).\
963             filter(PlayerRank.game_type_cd == game_type_cd).\
964             all():
965                 ranks[pr.player_id] = pr
966
967     return ranks
968
969
970 def submit_stats(request):
971     """
972     Entry handler for POST stats submissions.
973     """
974     try:
975         # placeholder for the actual session
976         session = None
977
978         log.debug("\n----- BEGIN REQUEST BODY -----\n" + request.body +
979                 "----- END REQUEST BODY -----\n\n")
980
981         (idfp, status) = verify_request(request)
982         submission = Submission(request.body, request.headers)
983
984         do_precondition_checks(request.registry.settings, submission)
985
986         #----------------------------------------------------------------------
987         # Actual setup (inserts/updates) below here
988         #----------------------------------------------------------------------
989         session = DBSession()
990
991         # All game types create Game, Server, Map, and Player records
992         # the same way.
993         server = get_or_create_server(
994             session=session,
995             hashkey=idfp,
996             name=submission.server_name,
997             revision=submission.revision,
998             ip_addr=get_remote_addr(request),
999             port=submission.port_number,
1000             impure_cvars=submission.impure_cvar_changes
1001         )
1002
1003         gmap = get_or_create_map(session, submission.map_name)
1004
1005         game = create_game(
1006             session=session,
1007             start_dt=datetime.datetime.utcnow(),
1008             server_id=server.server_id,
1009             game_type_cd=submission.game_type_cd,
1010             map_id=gmap.map_id,
1011             match_id=submission.match_id,
1012             duration=submission.duration,
1013             mod=submission.mod
1014         )
1015
1016         # keep track of the players we've seen
1017         player_ids = []
1018         pgstats = []
1019         hashkeys = {}
1020         for events in submission.humans + submission.bots:
1021             player = get_or_create_player(session, events['P'], events.get('n', None))
1022             pgstat = create_game_stat(session, game, gmap, player, events)
1023             pgstats.append(pgstat)
1024
1025             if player.player_id > 1:
1026                 create_anticheats(session, pgstat, game, player, events)
1027
1028             if player.player_id > 2:
1029                 player_ids.append(player.player_id)
1030                 hashkeys[player.player_id] = events['P']
1031
1032             if should_do_weapon_stats(submission.game_type_cd) and player.player_id > 1:
1033                 create_weapon_stats(session, submission.version, game, player, pgstat, events)
1034
1035         # store them on games for easy access
1036         game.players = player_ids
1037
1038         for events in submission.teams:
1039             create_team_stat(session, game, events)
1040
1041         if server.elo_ind and gametype_elo_eligible(submission.game_type_cd):
1042             ep = EloProcessor(session, game, pgstats)
1043             ep.save(session)
1044
1045         session.commit()
1046         log.debug('Success! Stats recorded.')
1047
1048         # ranks are fetched after we've done the "real" processing
1049         ranks = get_ranks(session, player_ids, submission.game_type_cd)
1050
1051         # plain text response
1052         request.response.content_type = 'text/plain'
1053
1054         return {
1055                 "now": calendar.timegm(datetime.datetime.utcnow().timetuple()),
1056                 "server": server,
1057                 "game": game,
1058                 "gmap": gmap,
1059                 "player_ids": player_ids,
1060                 "hashkeys": hashkeys,
1061                 "elos": ep.wip,
1062                 "ranks": ranks,
1063         }
1064
1065     except Exception as e:
1066         if session:
1067             session.rollback()
1068         raise e