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