Clean up the function signature a bit.
[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 should_do_weapon_stats(game_type_cd):
396     """True of the game type should record weapon stats. False otherwise."""
397     return game_type_cd not in {'cts'}
398
399
400 def gametype_elo_eligible(game_type_cd):
401     """True of the game type should process Elos. False otherwise."""
402     return game_type_cd in {'duel', 'dm', 'ca', 'ctf', 'tdm', 'ka', 'ft'}
403
404
405 def register_new_nick(session, player, new_nick):
406     """
407     Change the player record's nick to the newly found nick. Store the old
408     nick in the player_nicks table for that player.
409
410     session - SQLAlchemy database session factory
411     player - player record whose nick is changing
412     new_nick - the new nickname
413     """
414     # see if that nick already exists
415     stripped_nick = strip_colors(qfont_decode(player.nick))
416     try:
417         player_nick = session.query(PlayerNick).filter_by(
418             player_id=player.player_id, stripped_nick=stripped_nick).one()
419     except NoResultFound, e:
420         # player_id/stripped_nick not found, create one
421         # but we don't store "Anonymous Player #N"
422         if not re.search('^Anonymous Player #\d+$', player.nick):
423             player_nick = PlayerNick()
424             player_nick.player_id = player.player_id
425             player_nick.stripped_nick = stripped_nick
426             player_nick.nick = player.nick
427             session.add(player_nick)
428
429     # We change to the new nick regardless
430     player.nick = new_nick
431     player.stripped_nick = strip_colors(qfont_decode(new_nick))
432     session.add(player)
433
434
435 def update_fastest_cap(session, player_id, game_id, map_id, captime, mod):
436     """
437     Check the fastest cap time for the player and map. If there isn't
438     one, insert one. If there is, check if the passed time is faster.
439     If so, update!
440     """
441     # we don't record fastest cap times for bots or anonymous players
442     if player_id <= 2:
443         return
444
445     # see if a cap entry exists already
446     # then check to see if the new captime is faster
447     try:
448         cur_fastest_cap = session.query(PlayerCaptime).filter_by(
449             player_id=player_id, map_id=map_id, mod=mod).one()
450
451         # current captime is faster, so update
452         if captime < cur_fastest_cap.fastest_cap:
453             cur_fastest_cap.fastest_cap = captime
454             cur_fastest_cap.game_id = game_id
455             cur_fastest_cap.create_dt = datetime.datetime.utcnow()
456             session.add(cur_fastest_cap)
457
458     except NoResultFound, e:
459         # none exists, so insert
460         cur_fastest_cap = PlayerCaptime(player_id, game_id, map_id, captime,
461                 mod)
462         session.add(cur_fastest_cap)
463         session.flush()
464
465
466 def update_server(server, name, hashkey, ip_addr, port, revision, impure_cvars):
467     """
468     Updates the server in the given DB session, if needed.
469
470     :param server: The found server instance.
471     :param name: The incoming server name.
472     :param hashkey: The incoming server hashkey.
473     :param ip_addr: The incoming server IP address.
474     :param port: The incoming server port.
475     :param revision: The incoming server revision.
476     :param impure_cvars: The incoming number of impure server cvars.
477     :return: bool
478     """
479     # ensure the two int attributes are actually ints
480     try:
481         port = int(port)
482     except:
483         port = None
484
485     try:
486         impure_cvars = int(impure_cvars)
487     except:
488         impure_cvars = 0
489
490     updated = False
491     if name and server.name != name:
492         server.name = name
493         updated = True
494     if hashkey and server.hashkey != hashkey:
495         server.hashkey = hashkey
496         updated = True
497     if ip_addr and server.ip_addr != ip_addr:
498         server.ip_addr = ip_addr
499         updated = True
500     if port and server.port != port:
501         server.port = port
502         updated = True
503     if revision and server.revision != revision:
504         server.revision = revision
505         updated = True
506     if impure_cvars and server.impure_cvars != impure_cvars:
507         server.impure_cvars = impure_cvars
508         server.pure_ind = True if impure_cvars == 0 else False
509         updated = True
510
511     return updated
512
513
514 def get_or_create_server(session, name, hashkey, ip_addr, revision, port, impure_cvars):
515     """
516     Find a server by name or create one if not found. Parameters:
517
518     session - SQLAlchemy database session factory
519     name - server name of the server to be found or created
520     hashkey - server hashkey
521     ip_addr - the IP address of the server
522     revision - the xonotic revision number
523     port - the port number of the server
524     impure_cvars - the number of impure cvar changes
525     """
526     servers_q = DBSession.query(Server).filter(Server.active_ind)
527
528     if hashkey:
529         # if the hashkey is provided, we'll use that
530         servers_q = servers_q.filter((Server.name == name) or (Server.hashkey == hashkey))
531     else:
532         # otherwise, it is just by name
533         servers_q = servers_q.filter(Server.name == name)
534
535     # order by the hashkey, which means any hashkey match will appear first if there are multiple
536     servers = servers_q.order_by(Server.hashkey, Server.create_dt).all()
537
538     if len(servers) == 0:
539         server = Server(name=name, hashkey=hashkey)
540         session.add(server)
541         session.flush()
542         log.debug("Created server {} with hashkey {}.".format(server.server_id, server.hashkey))
543     else:
544         server = servers[0]
545         if len(servers) == 1:
546             log.info("Found existing server {}.".format(server.server_id))
547
548         elif len(servers) > 1:
549             server_id_list = ", ".join(["{}".format(s.server_id) for s in servers])
550             log.warn("Multiple servers found ({})! Using the first one ({})."
551                      .format(server_id_list, server.server_id))
552
553     if update_server(server, name, hashkey, ip_addr, port, revision, impure_cvars):
554         session.add(server)
555
556     return server
557
558
559 def get_or_create_map(session, name):
560     """
561     Find a map by name or create one if not found. Parameters:
562
563     session - SQLAlchemy database session factory
564     name - map name of the map to be found or created
565     """
566     maps = session.query(Map).filter_by(name=name).order_by(Map.map_id).all()
567
568     if maps is None or len(maps) == 0:
569         gmap = Map(name=name)
570         session.add(gmap)
571         session.flush()
572         log.debug("Created map id {}: {}".format(gmap.map_id, gmap.name))
573     elif len(maps) == 1:
574         gmap = maps[0]
575         log.debug("Found map id {}: {}".format(gmap.map_id, gmap.name))
576     else:
577         gmap = maps[0]
578         map_id_list = ", ".join(["{}".format(m.map_id) for m in maps])
579         log.warn("Multiple maps found for {} ({})! Using the first one.".format(name, map_id_list))
580
581     return gmap
582
583
584 def create_game(session, game_type_cd, server_id, map_id, match_id, start_dt, duration, mod,
585                 winner=None):
586     """
587     Creates a game. Parameters:
588
589     session - SQLAlchemy database session factory
590     game_type_cd - the game type of the game being played
591     mod - mods in use during the game
592     server_id - server identifier of the server hosting the game
593     map_id - map on which the game was played
594     match_id - a unique match ID given by the server
595     start_dt - when the game started (datetime object)
596     duration - how long the game lasted
597     winner - the team id of the team that won
598     """
599     seq = Sequence('games_game_id_seq')
600     game_id = session.execute(seq)
601     game = Game(game_id=game_id, start_dt=start_dt, game_type_cd=game_type_cd, server_id=server_id,
602                 map_id=map_id, winner=winner)
603     game.match_id = match_id
604     game.mod = mod[:64]
605
606     # There is some drift between start_dt (provided by app) and create_dt
607     # (default in the database), so we'll make them the same until this is 
608     # resolved.
609     game.create_dt = start_dt
610
611     game.duration = duration
612
613     try:
614         session.query(Game).filter(Game.server_id == server_id)\
615             .filter(Game.match_id == match_id).one()
616
617         log.debug("Error: game with same server and match_id found! Ignoring.")
618
619         # if a game under the same server_id and match_id exists, this is a duplicate
620         msg = "Duplicate game (pre-existing match_id)"
621         log.debug(msg)
622         raise pyramid.httpexceptions.HTTPOk(body=msg, content_type="text/plain")
623
624     except NoResultFound:
625         # server_id/match_id combination not found. game is ok to insert
626         session.add(game)
627         session.flush()
628         log.debug("Created game id {} on server {}, map {} at {}"
629                   .format(game.game_id, server_id, map_id, start_dt))
630
631     return game
632
633
634 def get_or_create_player(session=None, hashkey=None, nick=None):
635     """
636     Finds a player by hashkey or creates a new one (along with a
637     corresponding hashkey entry. Parameters:
638
639     session - SQLAlchemy database session factory
640     hashkey - hashkey of the player to be found or created
641     nick - nick of the player (in case of a first time create)
642     """
643     # if we have a bot
644     if re.search('^bot#\d+', hashkey):
645         player = session.query(Player).filter_by(player_id=1).one()
646     # if we have an untracked player
647     elif re.search('^player#\d+$', hashkey):
648         player = session.query(Player).filter_by(player_id=2).one()
649     # else it is a tracked player
650     else:
651         # see if the player is already in the database
652         # if not, create one and the hashkey along with it
653         try:
654             hk = session.query(Hashkey).filter_by(
655                     hashkey=hashkey).one()
656             player = session.query(Player).filter_by(
657                     player_id=hk.player_id).one()
658             log.debug("Found existing player {0} with hashkey {1}".format(
659                 player.player_id, hashkey))
660         except:
661             player = Player()
662             session.add(player)
663             session.flush()
664
665             # if nick is given to us, use it. If not, use "Anonymous Player"
666             # with a suffix added for uniqueness.
667             if nick:
668                 player.nick = nick[:128]
669                 player.stripped_nick = strip_colors(qfont_decode(nick[:128]))
670             else:
671                 player.nick = "Anonymous Player #{0}".format(player.player_id)
672                 player.stripped_nick = player.nick
673
674             hk = Hashkey(player_id=player.player_id, hashkey=hashkey)
675             session.add(hk)
676             log.debug("Created player {0} ({2}) with hashkey {1}".format(
677                 player.player_id, hashkey, player.nick.encode('utf-8')))
678
679     return player
680
681
682 def create_default_game_stat(session, game_type_cd):
683     """Creates a blanked-out pgstat record for the given game type"""
684
685     # this is what we have to do to get partitioned records in - grab the
686     # sequence value first, then insert using the explicit ID (vs autogenerate)
687     seq = Sequence('player_game_stats_player_game_stat_id_seq')
688     pgstat_id = session.execute(seq)
689     pgstat = PlayerGameStat(player_game_stat_id=pgstat_id,
690             create_dt=datetime.datetime.utcnow())
691
692     if game_type_cd == 'as':
693         pgstat.kills = pgstat.deaths = pgstat.suicides = pgstat.collects = 0
694
695     if game_type_cd in 'ca' 'dm' 'duel' 'rune' 'tdm':
696         pgstat.kills = pgstat.deaths = pgstat.suicides = 0
697
698     if game_type_cd == 'cq':
699         pgstat.kills = pgstat.deaths = pgstat.suicides = pgstat.captures = 0
700         pgstat.drops = 0
701
702     if game_type_cd == 'ctf':
703         pgstat.kills = pgstat.captures = pgstat.pickups = pgstat.drops = 0
704         pgstat.returns = pgstat.carrier_frags = 0
705
706     if game_type_cd == 'cts':
707         pgstat.deaths = 0
708
709     if game_type_cd == 'dom':
710         pgstat.kills = pgstat.deaths = pgstat.suicides = pgstat.pickups = 0
711         pgstat.drops = 0
712
713     if game_type_cd == 'ft':
714         pgstat.kills = pgstat.deaths = pgstat.suicides = pgstat.revivals = 0
715
716     if game_type_cd == 'ka':
717         pgstat.kills = pgstat.deaths = pgstat.suicides = pgstat.pickups = 0
718         pgstat.carrier_frags = 0
719         pgstat.time = datetime.timedelta(seconds=0)
720
721     if game_type_cd == 'kh':
722         pgstat.kills = pgstat.deaths = pgstat.suicides = pgstat.pickups = 0
723         pgstat.captures = pgstat.drops = pgstat.pushes = pgstat.destroys = 0
724         pgstat.carrier_frags = 0
725
726     if game_type_cd == 'lms':
727         pgstat.kills = pgstat.deaths = pgstat.suicides = pgstat.lives = 0
728
729     if game_type_cd == 'nb':
730         pgstat.kills = pgstat.deaths = pgstat.suicides = pgstat.captures = 0
731         pgstat.drops = 0
732
733     if game_type_cd == 'rc':
734         pgstat.kills = pgstat.deaths = pgstat.suicides = pgstat.laps = 0
735
736     return pgstat
737
738
739 def create_game_stat(session, game, gmap, player, events):
740     """Game stats handler for all game types"""
741
742     game_type_cd = game.game_type_cd
743
744     pgstat = create_default_game_stat(session, game_type_cd)
745
746     # these fields should be on every pgstat record
747     pgstat.game_id       = game.game_id
748     pgstat.player_id     = player.player_id
749     pgstat.nick          = events.get('n', 'Anonymous Player')[:128]
750     pgstat.stripped_nick = strip_colors(qfont_decode(pgstat.nick))
751     pgstat.score         = int(round(float(events.get('scoreboard-score', 0))))
752     pgstat.alivetime     = datetime.timedelta(seconds=int(round(float(events.get('alivetime', 0.0)))))
753     pgstat.rank          = int(events.get('rank', None))
754     pgstat.scoreboardpos = int(events.get('scoreboardpos', pgstat.rank))
755
756     if pgstat.nick != player.nick \
757             and player.player_id > 2 \
758             and pgstat.nick != 'Anonymous Player':
759         register_new_nick(session, player, pgstat.nick)
760
761     wins = False
762
763     # gametype-specific stuff is handled here. if passed to us, we store it
764     for (key,value) in events.items():
765         if key == 'wins': wins = True
766         if key == 't': pgstat.team = int(value)
767
768         if key == 'scoreboard-drops': pgstat.drops = int(value)
769         if key == 'scoreboard-returns': pgstat.returns = int(value)
770         if key == 'scoreboard-fckills': pgstat.carrier_frags = int(value)
771         if key == 'scoreboard-pickups': pgstat.pickups = int(value)
772         if key == 'scoreboard-caps': pgstat.captures = int(value)
773         if key == 'scoreboard-score': pgstat.score = int(round(float(value)))
774         if key == 'scoreboard-deaths': pgstat.deaths = int(value)
775         if key == 'scoreboard-kills': pgstat.kills = int(value)
776         if key == 'scoreboard-suicides': pgstat.suicides = int(value)
777         if key == 'scoreboard-objectives': pgstat.collects = int(value)
778         if key == 'scoreboard-captured': pgstat.captures = int(value)
779         if key == 'scoreboard-released': pgstat.drops = int(value)
780         if key == 'scoreboard-fastest':
781             pgstat.fastest = datetime.timedelta(seconds=float(value)/100)
782         if key == 'scoreboard-takes': pgstat.pickups = int(value)
783         if key == 'scoreboard-ticks': pgstat.drops = int(value)
784         if key == 'scoreboard-revivals': pgstat.revivals = int(value)
785         if key == 'scoreboard-bctime':
786             pgstat.time = datetime.timedelta(seconds=int(value))
787         if key == 'scoreboard-bckills': pgstat.carrier_frags = int(value)
788         if key == 'scoreboard-losses': pgstat.drops = int(value)
789         if key == 'scoreboard-pushes': pgstat.pushes = int(value)
790         if key == 'scoreboard-destroyed': pgstat.destroys = int(value)
791         if key == 'scoreboard-kckills': pgstat.carrier_frags = int(value)
792         if key == 'scoreboard-lives': pgstat.lives = int(value)
793         if key == 'scoreboard-goals': pgstat.captures = int(value)
794         if key == 'scoreboard-faults': pgstat.drops = int(value)
795         if key == 'scoreboard-laps': pgstat.laps = int(value)
796
797         if key == 'avglatency': pgstat.avg_latency = float(value)
798         if key == 'scoreboard-captime':
799             pgstat.fastest = datetime.timedelta(seconds=float(value)/100)
800             if game.game_type_cd == 'ctf':
801                 update_fastest_cap(session, player.player_id, game.game_id,
802                         gmap.map_id, pgstat.fastest, game.mod)
803
804     # there is no "winning team" field, so we have to derive it
805     if wins and pgstat.team is not None and game.winner is None:
806         game.winner = pgstat.team
807         session.add(game)
808
809     session.add(pgstat)
810
811     return pgstat
812
813
814 def create_anticheats(session, pgstat, game, player, events):
815     """Anticheats handler for all game types"""
816
817     anticheats = []
818
819     # all anticheat events are prefixed by "anticheat"
820     for (key,value) in events.items():
821         if key.startswith("anticheat"):
822             try:
823                 ac = PlayerGameAnticheat(
824                     player.player_id,
825                     game.game_id,
826                     key,
827                     float(value)
828                 )
829                 anticheats.append(ac)
830                 session.add(ac)
831             except Exception as e:
832                 log.debug("Could not parse value for key %s. Ignoring." % key)
833
834     return anticheats
835
836
837 def create_default_team_stat(session, game_type_cd):
838     """Creates a blanked-out teamstat record for the given game type"""
839
840     # this is what we have to do to get partitioned records in - grab the
841     # sequence value first, then insert using the explicit ID (vs autogenerate)
842     seq = Sequence('team_game_stats_team_game_stat_id_seq')
843     teamstat_id = session.execute(seq)
844     teamstat = TeamGameStat(team_game_stat_id=teamstat_id,
845             create_dt=datetime.datetime.utcnow())
846
847     # all team game modes have a score, so we'll zero that out always
848     teamstat.score = 0
849
850     if game_type_cd in 'ca' 'ft' 'lms' 'ka':
851         teamstat.rounds = 0
852
853     if game_type_cd == 'ctf':
854         teamstat.caps = 0
855
856     return teamstat
857
858
859 def create_team_stat(session, game, events):
860     """Team stats handler for all game types"""
861
862     try:
863         teamstat = create_default_team_stat(session, game.game_type_cd)
864         teamstat.game_id = game.game_id
865
866         # we should have a team ID if we have a 'Q' event
867         if re.match(r'^team#\d+$', events.get('Q', '')):
868             team = int(events.get('Q').replace('team#', ''))
869             teamstat.team = team
870
871         # gametype-specific stuff is handled here. if passed to us, we store it
872         for (key,value) in events.items():
873             if key == 'scoreboard-score': teamstat.score = int(round(float(value)))
874             if key == 'scoreboard-caps': teamstat.caps = int(value)
875             if key == 'scoreboard-goals': teamstat.caps = int(value)
876             if key == 'scoreboard-rounds': teamstat.rounds = int(value)
877
878         session.add(teamstat)
879     except Exception as e:
880         raise e
881
882     return teamstat
883
884
885 def create_weapon_stats(session, version, game, player, pgstat, events):
886     """Weapon stats handler for all game types"""
887     pwstats = []
888
889     # Version 1 of stats submissions doubled the data sent.
890     # To counteract this we divide the data by 2 only for
891     # POSTs coming from version 1.
892     try:
893         if version == 1:
894             is_doubled = True
895             log.debug('NOTICE: found a version 1 request, halving the weapon stats...')
896         else:
897             is_doubled = False
898     except:
899         is_doubled = False
900
901     for (key,value) in events.items():
902         matched = re.search("acc-(.*?)-cnt-fired", key)
903         if matched:
904             weapon_cd = matched.group(1)
905
906             # Weapon names changed for 0.8. We'll convert the old
907             # ones to use the new scheme as well.
908             mapped_weapon_cd = weapon_map.get(weapon_cd, weapon_cd)
909
910             seq = Sequence('player_weapon_stats_player_weapon_stats_id_seq')
911             pwstat_id = session.execute(seq)
912             pwstat = PlayerWeaponStat()
913             pwstat.player_weapon_stats_id = pwstat_id
914             pwstat.player_id = player.player_id
915             pwstat.game_id = game.game_id
916             pwstat.player_game_stat_id = pgstat.player_game_stat_id
917             pwstat.weapon_cd = mapped_weapon_cd
918
919             if 'n' in events:
920                 pwstat.nick = events['n']
921             else:
922                 pwstat.nick = events['P']
923
924             if 'acc-' + weapon_cd + '-cnt-fired' in events:
925                 pwstat.fired = int(round(float(
926                         events['acc-' + weapon_cd + '-cnt-fired'])))
927             if 'acc-' + weapon_cd + '-fired' in events:
928                 pwstat.max = int(round(float(
929                         events['acc-' + weapon_cd + '-fired'])))
930             if 'acc-' + weapon_cd + '-cnt-hit' in events:
931                 pwstat.hit = int(round(float(
932                         events['acc-' + weapon_cd + '-cnt-hit'])))
933             if 'acc-' + weapon_cd + '-hit' in events:
934                 pwstat.actual = int(round(float(
935                         events['acc-' + weapon_cd + '-hit'])))
936             if 'acc-' + weapon_cd + '-frags' in events:
937                 pwstat.frags = int(round(float(
938                         events['acc-' + weapon_cd + '-frags'])))
939
940             if is_doubled:
941                 pwstat.fired = pwstat.fired/2
942                 pwstat.max = pwstat.max/2
943                 pwstat.hit = pwstat.hit/2
944                 pwstat.actual = pwstat.actual/2
945                 pwstat.frags = pwstat.frags/2
946
947             session.add(pwstat)
948             pwstats.append(pwstat)
949
950     return pwstats
951
952
953 def get_ranks(session, player_ids, game_type_cd):
954     """
955     Gets the rank entries for all players in the given list, returning a dict
956     of player_id -> PlayerRank instance. The rank entry corresponds to the
957     game type of the parameter passed in as well.
958     """
959     ranks = {}
960     for pr in session.query(PlayerRank).\
961             filter(PlayerRank.player_id.in_(player_ids)).\
962             filter(PlayerRank.game_type_cd == game_type_cd).\
963             all():
964                 ranks[pr.player_id] = pr
965
966     return ranks
967
968
969 def update_player(session, player, events):
970     pass
971
972
973 def create_player(session, events):
974     pass
975
976
977 def get_or_create_players(session, events_by_hashkey):
978     hashkeys = set(events_by_hashkey.keys())
979     players_by_hashkey = {}
980
981     bot = session.query(Player).filter(Player.player_id == 1).one()
982     anon = session.query(Player).filter(Player.player_id == 2).one()
983
984     # fill in the bots and anonymous players
985     for hashkey in events_by_hashkey.keys():
986         if hashkey.startswith("bot#"):
987             players_by_hashkey[hashkey] = bot
988             hashkeys.remove(hashkey)
989         elif hashkey.startswith("player#"):
990             players_by_hashkey[hashkey] = anon
991             hashkeys.remove(hashkey)
992
993     # We are left with the "real" players and can now fetch them by their collective hashkeys.
994     # Those that are returned here are pre-existing players who need to be updated.
995     for p, hk in session.query(Player, Hashkey)\
996             .filter(Player.player_id == Hashkey.player_id)\
997             .filter(Hashkey.hashkey.in_(hashkeys))\
998             .all():
999                 player = update_player(session, p, events_by_hashkey[hk.hashkey])
1000                 players_by_hashkey[hk.hashkey] = player
1001                 hashkeys.remove(hk.hashkey)
1002
1003     # The remainder are the players we haven't seen before, so we need to create them.
1004     for hashkey in hashkeys:
1005         player = create_player(session, events_by_hashkey[hashkey])
1006         players_by_hashkey[hashkey] = player
1007
1008     return players_by_hashkey
1009
1010
1011 def submit_stats(request):
1012     """
1013     Entry handler for POST stats submissions.
1014     """
1015     try:
1016         # placeholder for the actual session
1017         session = None
1018
1019         log.debug("\n----- BEGIN REQUEST BODY -----\n" + request.body +
1020                 "----- END REQUEST BODY -----\n\n")
1021
1022         (idfp, status) = verify_request(request)
1023         submission = Submission(request.body, request.headers)
1024
1025         do_precondition_checks(request.registry.settings, submission)
1026
1027         #----------------------------------------------------------------------
1028         # Actual setup (inserts/updates) below here
1029         #----------------------------------------------------------------------
1030         session = DBSession()
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=submission.server_name,
1038             revision=submission.revision,
1039             ip_addr=get_remote_addr(request),
1040             port=submission.port_number,
1041             impure_cvars=submission.impure_cvar_changes
1042         )
1043
1044         gmap = get_or_create_map(session, submission.map_name)
1045
1046         game = create_game(
1047             session=session,
1048             game_type_cd=submission.game_type_cd,
1049             mod=submission.mod,
1050             server_id=server.server_id,
1051             map_id=gmap.map_id,
1052             match_id=submission.match_id,
1053             start_dt=datetime.datetime.utcnow(),
1054             duration=submission.duration
1055         )
1056
1057         events_by_hashkey = {elem["P"]: elem for elem in submission.humans + submission.bots}
1058         get_or_create_players(session, game, gmap, events_by_hashkey)
1059
1060         # keep track of the players we've seen
1061         player_ids = []
1062         pgstats = []
1063         hashkeys = {}
1064         for events in submission.humans + submission.bots:
1065             player = get_or_create_player(session, events['P'], events.get('n', None))
1066             pgstat = create_game_stat(session, game, gmap, player, events)
1067             pgstats.append(pgstat)
1068
1069             if player.player_id > 1:
1070                 create_anticheats(session, pgstat, game, player, events)
1071
1072             if player.player_id > 2:
1073                 player_ids.append(player.player_id)
1074                 hashkeys[player.player_id] = events['P']
1075
1076             if should_do_weapon_stats(submission.game_type_cd) and player.player_id > 1:
1077                 create_weapon_stats(session, submission.version, game, player, pgstat, events)
1078
1079         # store them on games for easy access
1080         game.players = player_ids
1081
1082         for events in submission.teams:
1083             create_team_stat(session, game, events)
1084
1085         if server.elo_ind and gametype_elo_eligible(submission.game_type_cd):
1086             ep = EloProcessor(session, game, pgstats)
1087             ep.save(session)
1088
1089         session.commit()
1090         log.debug('Success! Stats recorded.')
1091
1092         # ranks are fetched after we've done the "real" processing
1093         ranks = get_ranks(session, player_ids, submission.game_type_cd)
1094
1095         # plain text response
1096         request.response.content_type = 'text/plain'
1097
1098         return {
1099                 "now": calendar.timegm(datetime.datetime.utcnow().timetuple()),
1100                 "server": server,
1101                 "game": game,
1102                 "gmap": gmap,
1103                 "player_ids": player_ids,
1104                 "hashkeys": hashkeys,
1105                 "elos": ep.wip,
1106                 "ranks": ranks,
1107         }
1108
1109     except Exception as e:
1110         if session:
1111             session.rollback()
1112         raise e