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