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