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