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