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