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