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