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