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