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