]> de.git.xonotic.org Git - xonotic/xonstat.git/blob - xonstat/views/submission.py
75c0f017e9b83538ceeb8c3b2a034b63e89fd4ec
[xonotic/xonstat.git] / xonstat / views / submission.py
1 import calendar
2 import datetime
3 import logging
4 import re
5
6 import pyramid.httpexceptions
7 import sqlalchemy.sql.expression as expr
8 from sqlalchemy import Sequence
9 from sqlalchemy.orm.exc import MultipleResultsFound, NoResultFound
10 from xonstat.elo import EloProcessor
11 from xonstat.models import DBSession, Server, Map, Game, PlayerGameStat, PlayerWeaponStat
12 from xonstat.models import PlayerRank, PlayerCaptime
13 from xonstat.models import TeamGameStat, PlayerGameAnticheat, Player, Hashkey, PlayerNick
14 from xonstat.util import strip_colors, qfont_decode, verify_request, weapon_map
15
16 log = logging.getLogger(__name__)
17
18
19 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 gametype_elo_eligible(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     # There is some drift between start_dt (provided by app) and create_dt
490     # (default in the database), so we'll make them the same until this is 
491     # resolved.
492     game.create_dt = start_dt
493
494     try:
495         game.duration = datetime.timedelta(seconds=int(round(float(duration))))
496     except:
497         pass
498
499     try:
500         session.query(Game).filter(Game.server_id==server_id).\
501                 filter(Game.match_id==match_id).one()
502
503         log.debug("Error: game with same server and match_id found! Ignoring.")
504
505         # if a game under the same server and match_id found,
506         # this is a duplicate game and can be ignored
507         raise pyramid.httpexceptions.HTTPOk('OK')
508     except NoResultFound, e:
509         # server_id/match_id combination not found. game is ok to insert
510         session.add(game)
511         session.flush()
512         log.debug("Created game id {0} on server {1}, map {2} at \
513                 {3}".format(game.game_id,
514                     server_id, map_id, start_dt))
515
516     return game
517
518
519 def get_or_create_player(session=None, hashkey=None, nick=None):
520     """
521     Finds a player by hashkey or creates a new one (along with a
522     corresponding hashkey entry. Parameters:
523
524     session - SQLAlchemy database session factory
525     hashkey - hashkey of the player to be found or created
526     nick - nick of the player (in case of a first time create)
527     """
528     # if we have a bot
529     if re.search('^bot#\d+', hashkey):
530         player = session.query(Player).filter_by(player_id=1).one()
531     # if we have an untracked player
532     elif re.search('^player#\d+$', hashkey):
533         player = session.query(Player).filter_by(player_id=2).one()
534     # else it is a tracked player
535     else:
536         # see if the player is already in the database
537         # if not, create one and the hashkey along with it
538         try:
539             hk = session.query(Hashkey).filter_by(
540                     hashkey=hashkey).one()
541             player = session.query(Player).filter_by(
542                     player_id=hk.player_id).one()
543             log.debug("Found existing player {0} with hashkey {1}".format(
544                 player.player_id, hashkey))
545         except:
546             player = Player()
547             session.add(player)
548             session.flush()
549
550             # if nick is given to us, use it. If not, use "Anonymous Player"
551             # with a suffix added for uniqueness.
552             if nick:
553                 player.nick = nick[:128]
554                 player.stripped_nick = strip_colors(qfont_decode(nick[:128]))
555             else:
556                 player.nick = "Anonymous Player #{0}".format(player.player_id)
557                 player.stripped_nick = player.nick
558
559             hk = Hashkey(player_id=player.player_id, hashkey=hashkey)
560             session.add(hk)
561             log.debug("Created player {0} ({2}) with hashkey {1}".format(
562                 player.player_id, hashkey, player.nick.encode('utf-8')))
563
564     return player
565
566
567 def create_default_game_stat(session, game_type_cd):
568     """Creates a blanked-out pgstat record for the given game type"""
569
570     # this is what we have to do to get partitioned records in - grab the
571     # sequence value first, then insert using the explicit ID (vs autogenerate)
572     seq = Sequence('player_game_stats_player_game_stat_id_seq')
573     pgstat_id = session.execute(seq)
574     pgstat = PlayerGameStat(player_game_stat_id=pgstat_id,
575             create_dt=datetime.datetime.utcnow())
576
577     if game_type_cd == 'as':
578         pgstat.kills = pgstat.deaths = pgstat.suicides = pgstat.collects = 0
579
580     if game_type_cd in 'ca' 'dm' 'duel' 'rune' 'tdm':
581         pgstat.kills = pgstat.deaths = pgstat.suicides = 0
582
583     if game_type_cd == 'cq':
584         pgstat.kills = pgstat.deaths = pgstat.suicides = pgstat.captures = 0
585         pgstat.drops = 0
586
587     if game_type_cd == 'ctf':
588         pgstat.kills = pgstat.captures = pgstat.pickups = pgstat.drops = 0
589         pgstat.returns = pgstat.carrier_frags = 0
590
591     if game_type_cd == 'cts':
592         pgstat.deaths = 0
593
594     if game_type_cd == 'dom':
595         pgstat.kills = pgstat.deaths = pgstat.suicides = pgstat.pickups = 0
596         pgstat.drops = 0
597
598     if game_type_cd == 'ft':
599         pgstat.kills = pgstat.deaths = pgstat.suicides = pgstat.revivals = 0
600
601     if game_type_cd == 'ka':
602         pgstat.kills = pgstat.deaths = pgstat.suicides = pgstat.pickups = 0
603         pgstat.carrier_frags = 0
604         pgstat.time = datetime.timedelta(seconds=0)
605
606     if game_type_cd == 'kh':
607         pgstat.kills = pgstat.deaths = pgstat.suicides = pgstat.pickups = 0
608         pgstat.captures = pgstat.drops = pgstat.pushes = pgstat.destroys = 0
609         pgstat.carrier_frags = 0
610
611     if game_type_cd == 'lms':
612         pgstat.kills = pgstat.deaths = pgstat.suicides = pgstat.lives = 0
613
614     if game_type_cd == 'nb':
615         pgstat.kills = pgstat.deaths = pgstat.suicides = pgstat.captures = 0
616         pgstat.drops = 0
617
618     if game_type_cd == 'rc':
619         pgstat.kills = pgstat.deaths = pgstat.suicides = pgstat.laps = 0
620
621     return pgstat
622
623
624 def create_game_stat(session, game_meta, game, server, gmap, player, events):
625     """Game stats handler for all game types"""
626
627     game_type_cd = game.game_type_cd
628
629     pgstat = create_default_game_stat(session, game_type_cd)
630
631     # these fields should be on every pgstat record
632     pgstat.game_id       = game.game_id
633     pgstat.player_id     = player.player_id
634     pgstat.nick          = events.get('n', 'Anonymous Player')[:128]
635     pgstat.stripped_nick = strip_colors(qfont_decode(pgstat.nick))
636     pgstat.score         = int(round(float(events.get('scoreboard-score', 0))))
637     pgstat.alivetime     = datetime.timedelta(seconds=int(round(float(events.get('alivetime', 0.0)))))
638     pgstat.rank          = int(events.get('rank', None))
639     pgstat.scoreboardpos = int(events.get('scoreboardpos', pgstat.rank))
640
641     if pgstat.nick != player.nick \
642             and player.player_id > 2 \
643             and pgstat.nick != 'Anonymous Player':
644         register_new_nick(session, player, pgstat.nick)
645
646     wins = False
647
648     # gametype-specific stuff is handled here. if passed to us, we store it
649     for (key,value) in events.items():
650         if key == 'wins': wins = True
651         if key == 't': pgstat.team = int(value)
652
653         if key == 'scoreboard-drops': pgstat.drops = int(value)
654         if key == 'scoreboard-returns': pgstat.returns = int(value)
655         if key == 'scoreboard-fckills': pgstat.carrier_frags = int(value)
656         if key == 'scoreboard-pickups': pgstat.pickups = int(value)
657         if key == 'scoreboard-caps': pgstat.captures = int(value)
658         if key == 'scoreboard-score': pgstat.score = int(round(float(value)))
659         if key == 'scoreboard-deaths': pgstat.deaths = int(value)
660         if key == 'scoreboard-kills': pgstat.kills = int(value)
661         if key == 'scoreboard-suicides': pgstat.suicides = int(value)
662         if key == 'scoreboard-objectives': pgstat.collects = int(value)
663         if key == 'scoreboard-captured': pgstat.captures = int(value)
664         if key == 'scoreboard-released': pgstat.drops = int(value)
665         if key == 'scoreboard-fastest':
666             pgstat.fastest = datetime.timedelta(seconds=float(value)/100)
667         if key == 'scoreboard-takes': pgstat.pickups = int(value)
668         if key == 'scoreboard-ticks': pgstat.drops = int(value)
669         if key == 'scoreboard-revivals': pgstat.revivals = int(value)
670         if key == 'scoreboard-bctime':
671             pgstat.time = datetime.timedelta(seconds=int(value))
672         if key == 'scoreboard-bckills': pgstat.carrier_frags = int(value)
673         if key == 'scoreboard-losses': pgstat.drops = int(value)
674         if key == 'scoreboard-pushes': pgstat.pushes = int(value)
675         if key == 'scoreboard-destroyed': pgstat.destroys = int(value)
676         if key == 'scoreboard-kckills': pgstat.carrier_frags = int(value)
677         if key == 'scoreboard-lives': pgstat.lives = int(value)
678         if key == 'scoreboard-goals': pgstat.captures = int(value)
679         if key == 'scoreboard-faults': pgstat.drops = int(value)
680         if key == 'scoreboard-laps': pgstat.laps = int(value)
681
682         if key == 'avglatency': pgstat.avg_latency = float(value)
683         if key == 'scoreboard-captime':
684             pgstat.fastest = datetime.timedelta(seconds=float(value)/100)
685             if game.game_type_cd == 'ctf':
686                 update_fastest_cap(session, player.player_id, game.game_id,
687                         gmap.map_id, pgstat.fastest, game.mod)
688
689     # there is no "winning team" field, so we have to derive it
690     if wins and pgstat.team is not None and game.winner is None:
691         game.winner = pgstat.team
692         session.add(game)
693
694     session.add(pgstat)
695
696     return pgstat
697
698
699 def create_anticheats(session, pgstat, game, player, events):
700     """Anticheats handler for all game types"""
701
702     anticheats = []
703
704     # all anticheat events are prefixed by "anticheat"
705     for (key,value) in events.items():
706         if key.startswith("anticheat"):
707             try:
708                 ac = PlayerGameAnticheat(
709                     player.player_id,
710                     game.game_id,
711                     key,
712                     float(value)
713                 )
714                 anticheats.append(ac)
715                 session.add(ac)
716             except Exception as e:
717                 log.debug("Could not parse value for key %s. Ignoring." % key)
718
719     return anticheats
720
721
722 def create_default_team_stat(session, game_type_cd):
723     """Creates a blanked-out teamstat record for the given game type"""
724
725     # this is what we have to do to get partitioned records in - grab the
726     # sequence value first, then insert using the explicit ID (vs autogenerate)
727     seq = Sequence('team_game_stats_team_game_stat_id_seq')
728     teamstat_id = session.execute(seq)
729     teamstat = TeamGameStat(team_game_stat_id=teamstat_id,
730             create_dt=datetime.datetime.utcnow())
731
732     # all team game modes have a score, so we'll zero that out always
733     teamstat.score = 0
734
735     if game_type_cd in 'ca' 'ft' 'lms' 'ka':
736         teamstat.rounds = 0
737
738     if game_type_cd == 'ctf':
739         teamstat.caps = 0
740
741     return teamstat
742
743
744 def create_team_stat(session, game, events):
745     """Team stats handler for all game types"""
746
747     try:
748         teamstat = create_default_team_stat(session, game.game_type_cd)
749         teamstat.game_id = game.game_id
750
751         # we should have a team ID if we have a 'Q' event
752         if re.match(r'^team#\d+$', events.get('Q', '')):
753             team = int(events.get('Q').replace('team#', ''))
754             teamstat.team = team
755
756         # gametype-specific stuff is handled here. if passed to us, we store it
757         for (key,value) in events.items():
758             if key == 'scoreboard-score': teamstat.score = int(round(float(value)))
759             if key == 'scoreboard-caps': teamstat.caps = int(value)
760             if key == 'scoreboard-goals': teamstat.caps = int(value)
761             if key == 'scoreboard-rounds': teamstat.rounds = int(value)
762
763         session.add(teamstat)
764     except Exception as e:
765         raise e
766
767     return teamstat
768
769
770 def create_weapon_stats(session, game_meta, game, player, pgstat, events):
771     """Weapon stats handler for all game types"""
772     pwstats = []
773
774     # Version 1 of stats submissions doubled the data sent.
775     # To counteract this we divide the data by 2 only for
776     # POSTs coming from version 1.
777     try:
778         version = int(game_meta['V'])
779         if version == 1:
780             is_doubled = True
781             log.debug('NOTICE: found a version 1 request, halving the weapon stats...')
782         else:
783             is_doubled = False
784     except:
785         is_doubled = False
786
787     for (key,value) in events.items():
788         matched = re.search("acc-(.*?)-cnt-fired", key)
789         if matched:
790             weapon_cd = matched.group(1)
791
792             # Weapon names changed for 0.8. We'll convert the old
793             # ones to use the new scheme as well.
794             mapped_weapon_cd = weapon_map.get(weapon_cd, weapon_cd)
795
796             seq = Sequence('player_weapon_stats_player_weapon_stats_id_seq')
797             pwstat_id = session.execute(seq)
798             pwstat = PlayerWeaponStat()
799             pwstat.player_weapon_stats_id = pwstat_id
800             pwstat.player_id = player.player_id
801             pwstat.game_id = game.game_id
802             pwstat.player_game_stat_id = pgstat.player_game_stat_id
803             pwstat.weapon_cd = mapped_weapon_cd
804
805             if 'n' in events:
806                 pwstat.nick = events['n']
807             else:
808                 pwstat.nick = events['P']
809
810             if 'acc-' + weapon_cd + '-cnt-fired' in events:
811                 pwstat.fired = int(round(float(
812                         events['acc-' + weapon_cd + '-cnt-fired'])))
813             if 'acc-' + weapon_cd + '-fired' in events:
814                 pwstat.max = int(round(float(
815                         events['acc-' + weapon_cd + '-fired'])))
816             if 'acc-' + weapon_cd + '-cnt-hit' in events:
817                 pwstat.hit = int(round(float(
818                         events['acc-' + weapon_cd + '-cnt-hit'])))
819             if 'acc-' + weapon_cd + '-hit' in events:
820                 pwstat.actual = int(round(float(
821                         events['acc-' + weapon_cd + '-hit'])))
822             if 'acc-' + weapon_cd + '-frags' in events:
823                 pwstat.frags = int(round(float(
824                         events['acc-' + weapon_cd + '-frags'])))
825
826             if is_doubled:
827                 pwstat.fired = pwstat.fired/2
828                 pwstat.max = pwstat.max/2
829                 pwstat.hit = pwstat.hit/2
830                 pwstat.actual = pwstat.actual/2
831                 pwstat.frags = pwstat.frags/2
832
833             session.add(pwstat)
834             pwstats.append(pwstat)
835
836     return pwstats
837
838
839 def get_ranks(session, player_ids, game_type_cd):
840     """
841     Gets the rank entries for all players in the given list, returning a dict
842     of player_id -> PlayerRank instance. The rank entry corresponds to the
843     game type of the parameter passed in as well.
844     """
845     ranks = {}
846     for pr in session.query(PlayerRank).\
847             filter(PlayerRank.player_id.in_(player_ids)).\
848             filter(PlayerRank.game_type_cd == game_type_cd).\
849             all():
850                 ranks[pr.player_id] = pr
851
852     return ranks
853
854
855 def submit_stats(request):
856     """
857     Entry handler for POST stats submissions.
858     """
859     try:
860         # placeholder for the actual session
861         session = None
862
863         log.debug("\n----- BEGIN REQUEST BODY -----\n" + request.body +
864                 "----- END REQUEST BODY -----\n\n")
865
866         (idfp, status) = verify_request(request)
867         (game_meta, raw_players, raw_teams) = parse_stats_submission(request.body)
868         revision = game_meta.get('R', 'unknown')
869         duration = game_meta.get('D', None)
870
871         # only players present at the end of the match are eligible for stats
872         raw_players = filter(played_in_game, raw_players)
873
874         do_precondition_checks(request, game_meta, raw_players)
875
876         # the "duel" gametype is fake
877         if len(raw_players) == 2 \
878             and num_real_players(raw_players) == 2 \
879             and game_meta['G'] == 'dm':
880             game_meta['G'] = 'duel'
881
882         #----------------------------------------------------------------------
883         # Actual setup (inserts/updates) below here
884         #----------------------------------------------------------------------
885         session = DBSession()
886
887         game_type_cd = game_meta['G']
888
889         # All game types create Game, Server, Map, and Player records
890         # the same way.
891         server = get_or_create_server(
892                 session      = session,
893                 hashkey      = idfp,
894                 name         = game_meta['S'],
895                 revision     = revision,
896                 ip_addr      = get_remote_addr(request),
897                 port         = game_meta.get('U', None),
898                 impure_cvars = game_meta.get('C', 0))
899
900         gmap = get_or_create_map(
901                 session = session,
902                 name    = game_meta['M'])
903
904         game = create_game(
905                 session      = session,
906                 start_dt     = datetime.datetime.utcnow(),
907                 server_id    = server.server_id,
908                 game_type_cd = game_type_cd,
909                 map_id       = gmap.map_id,
910                 match_id     = game_meta['I'],
911                 duration     = duration,
912                 mod          = game_meta.get('O', None))
913
914         # keep track of the players we've seen
915         player_ids = []
916         pgstats = []
917         hashkeys = {}
918         for events in raw_players:
919             player = get_or_create_player(
920                 session = session,
921                 hashkey = events['P'],
922                 nick    = events.get('n', None))
923
924             pgstat = create_game_stat(session, game_meta, game, server,
925                     gmap, player, events)
926             pgstats.append(pgstat)
927
928             if player.player_id > 1:
929                 anticheats = create_anticheats(session, pgstat, game, player, events)
930
931             if player.player_id > 2:
932                 player_ids.append(player.player_id)
933                 hashkeys[player.player_id] = events['P']
934
935             if should_do_weapon_stats(game_type_cd) and player.player_id > 1:
936                 pwstats = create_weapon_stats(session, game_meta, game, player,
937                         pgstat, events)
938
939         # store them on games for easy access
940         game.players = player_ids
941
942         for events in raw_teams:
943             try:
944                 teamstat = create_team_stat(session, game, events)
945             except Exception as e:
946                 raise e
947
948         if server.elo_ind and gametype_elo_eligible(game_type_cd):
949             ep = EloProcessor(session, game, pgstats)
950             ep.save(session)
951
952         session.commit()
953         log.debug('Success! Stats recorded.')
954
955         # ranks are fetched after we've done the "real" processing
956         ranks = get_ranks(session, player_ids, game_type_cd)
957
958         # plain text response
959         request.response.content_type = 'text/plain'
960
961         return {
962                 "now"        : calendar.timegm(datetime.datetime.utcnow().timetuple()),
963                 "server"     : server,
964                 "game"       : game,
965                 "gmap"       : gmap,
966                 "player_ids" : player_ids,
967                 "hashkeys"   : hashkeys,
968                 "elos"       : ep.wip,
969                 "ranks"      : ranks,
970         }
971
972     except Exception as e:
973         if session:
974             session.rollback()
975         raise e