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