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