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