]> de.git.xonotic.org Git - xonotic/xonstat.git/blob - xonstat/views/submission.py
26ba45ce7b50eabf649c7499e3b2f8d76f428dfa
[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     flg_supported = True
52
53     if gametype == 'cts' or gametype == 'lms':
54         flg_supported = False
55
56     return flg_supported
57
58
59 def verify_request(request):
60     try:
61         (idfp, status) = d0_blind_id_verify(
62                 sig=request.headers['X-D0-Blind-Id-Detached-Signature'],
63                 querystring='',
64                 postdata=request.body)
65
66         log.debug('\nidfp: {0}\nstatus: {1}'.format(idfp, status))
67     except: 
68         idfp = None
69         status = None
70
71     return (idfp, status)
72
73
74 def num_real_players(player_events, count_bots=False):
75     """
76     Returns the number of real players (those who played 
77     and are on the scoreboard).
78     """
79     real_players = 0
80
81     for events in player_events:
82         if is_real_player(events, count_bots):
83             real_players += 1
84
85     return real_players
86
87
88 def has_minimum_real_players(settings, player_events):
89     """
90     Determines if the collection of player events has enough "real" players
91     to store in the database. The minimum setting comes from the config file
92     under the setting xonstat.minimum_real_players.
93     """
94     flg_has_min_real_players = True
95
96     try:
97         minimum_required_players = int(
98                 settings['xonstat.minimum_required_players'])
99     except:
100         minimum_required_players = 2
101
102     real_players = num_real_players(player_events)
103
104     if real_players < minimum_required_players:
105         flg_has_min_real_players = False
106
107     return flg_has_min_real_players
108
109
110 def verify_requests(settings):
111     """
112     Determines whether or not to verify requests using the blind_id algorithm
113     """
114     try:
115         val_verify_requests = settings['xonstat.verify_requests']
116         if val_verify_requests == "true":
117             flg_verify_requests = True
118         else:
119             flg_verify_requests = False
120     except:
121         flg_verify_requests = True
122
123     return flg_verify_requests
124
125
126 def has_required_metadata(metadata):
127     """
128     Determines if a give set of metadata has enough data to create a game,
129     server, and map with.
130     """
131     flg_has_req_metadata = True
132
133     if 'T' not in metadata or\
134         'G' not in metadata or\
135         'M' not in metadata or\
136         'I' not in metadata or\
137         'S' not in metadata:
138             flg_has_req_metadata = False
139
140     return flg_has_req_metadata
141
142
143 def is_real_player(events, count_bots=False):
144     """
145     Determines if a given set of player events correspond with a player who
146
147     1) is not a bot (P event does not look like a bot)
148     2) played in the game (matches 1)
149     3) was present at the end of the game (scoreboardvalid 1)
150
151     Returns True if the player meets the above conditions, and false otherwise.
152     """
153     flg_is_real = False
154
155     # removing 'joins' here due to bug, but it should be here
156     if 'matches' in events and 'scoreboardvalid' in events:
157         if (events['P'].startswith('bot') and count_bots) or \
158             not events['P'].startswith('bot'):
159             flg_is_real = True
160
161     return flg_is_real
162
163
164 def register_new_nick(session, player, new_nick):
165     """
166     Change the player record's nick to the newly found nick. Store the old
167     nick in the player_nicks table for that player.
168
169     session - SQLAlchemy database session factory
170     player - player record whose nick is changing
171     new_nick - the new nickname
172     """
173     # see if that nick already exists
174     stripped_nick = strip_colors(qfont_decode(player.nick))
175     try:
176         player_nick = session.query(PlayerNick).filter_by(
177             player_id=player.player_id, stripped_nick=stripped_nick).one()
178     except NoResultFound, e:
179         # player_id/stripped_nick not found, create one
180         # but we don't store "Anonymous Player #N"
181         if not re.search('^Anonymous Player #\d+$', player.nick):
182             player_nick = PlayerNick()
183             player_nick.player_id = player.player_id
184             player_nick.stripped_nick = stripped_nick
185             player_nick.nick = player.nick
186             session.add(player_nick)
187
188     # We change to the new nick regardless
189     player.nick = new_nick
190     player.stripped_nick = strip_colors(qfont_decode(new_nick))
191     session.add(player)
192
193
194 def update_fastest_cap(session, player_id, game_id,  map_id, captime):
195     """
196     Check the fastest cap time for the player and map. If there isn't
197     one, insert one. If there is, check if the passed time is faster.
198     If so, update!
199     """
200     # we don't record fastest cap times for bots or anonymous players
201     if player_id <= 2:
202         return
203
204     # see if a cap entry exists already
205     # then check to see if the new captime is faster
206     try:
207         cur_fastest_cap = session.query(PlayerCaptime).filter_by(
208             player_id=player_id, map_id=map_id).one()
209
210         # current captime is faster, so update
211         if captime < cur_fastest_cap.fastest_cap:
212             cur_fastest_cap.fastest_cap = captime
213             cur_fastest_cap.game_id = game_id
214             cur_fastest_cap.create_dt = datetime.datetime.utcnow()
215             session.add(cur_fastest_cap)
216
217     except NoResultFound, e:
218         # none exists, so insert
219         cur_fastest_cap = PlayerCaptime(player_id, game_id, map_id, captime)
220         session.add(cur_fastest_cap)
221         session.flush()
222
223
224 def get_or_create_server(session=None, name=None, hashkey=None, ip_addr=None,
225         revision=None):
226     """
227     Find a server by name or create one if not found. Parameters:
228
229     session - SQLAlchemy database session factory
230     name - server name of the server to be found or created
231     hashkey - server hashkey
232     """
233     try:
234         # find one by that name, if it exists
235         server = session.query(Server).filter_by(name=name).one()
236
237         # store new hashkey
238         if server.hashkey != hashkey:
239             server.hashkey = hashkey
240             session.add(server)
241
242         # store new IP address
243         if server.ip_addr != ip_addr:
244             server.ip_addr = ip_addr
245             session.add(server)
246
247         # store new revision
248         if server.revision != revision:
249             server.revision = revision
250             session.add(server)
251
252         log.debug("Found existing server {0}".format(server.server_id))
253
254     except MultipleResultsFound, e:
255         # multiple found, so also filter by hashkey
256         server = session.query(Server).filter_by(name=name).\
257                 filter_by(hashkey=hashkey).one()
258         log.debug("Found existing server {0}".format(server.server_id))
259
260     except NoResultFound, e:
261         # not found, create one
262         server = Server(name=name, hashkey=hashkey)
263         session.add(server)
264         session.flush()
265         log.debug("Created server {0} with hashkey {1}".format(
266             server.server_id, server.hashkey))
267
268     return server
269
270
271 def get_or_create_map(session=None, name=None):
272     """
273     Find a map by name or create one if not found. Parameters:
274
275     session - SQLAlchemy database session factory
276     name - map name of the map to be found or created
277     """
278     try:
279         # find one by the name, if it exists
280         gmap = session.query(Map).filter_by(name=name).one()
281         log.debug("Found map id {0}: {1}".format(gmap.map_id, 
282             gmap.name))
283     except NoResultFound, e:
284         gmap = Map(name=name)
285         session.add(gmap)
286         session.flush()
287         log.debug("Created map id {0}: {1}".format(gmap.map_id,
288             gmap.name))
289     except MultipleResultsFound, e:
290         # multiple found, so use the first one but warn
291         log.debug(e)
292         gmaps = session.query(Map).filter_by(name=name).order_by(
293                 Map.map_id).all()
294         gmap = gmaps[0]
295         log.debug("Found map id {0}: {1} but found \
296                 multiple".format(gmap.map_id, gmap.name))
297
298     return gmap
299
300
301 def create_game(session=None, start_dt=None, game_type_cd=None, 
302         server_id=None, map_id=None, winner=None, match_id=None):
303     """
304     Creates a game. Parameters:
305
306     session - SQLAlchemy database session factory
307     start_dt - when the game started (datetime object)
308     game_type_cd - the game type of the game being played
309     server_id - server identifier of the server hosting the game
310     map_id - map on which the game was played
311     winner - the team id of the team that won
312     """
313     seq = Sequence('games_game_id_seq')
314     game_id = session.execute(seq)
315     game = Game(game_id=game_id, start_dt=start_dt, game_type_cd=game_type_cd,
316                 server_id=server_id, map_id=map_id, winner=winner)
317     game.match_id = match_id
318
319     try:
320         session.query(Game).filter(Game.server_id==server_id).\
321                 filter(Game.match_id==match_id).one()
322
323         log.debug("Error: game with same server and match_id found! Ignoring.")
324
325         # if a game under the same server and match_id found, 
326         # this is a duplicate game and can be ignored
327         raise pyramid.httpexceptions.HTTPOk('OK')
328     except NoResultFound, e:
329         # server_id/match_id combination not found. game is ok to insert
330         session.add(game)
331         session.flush()
332         log.debug("Created game id {0} on server {1}, map {2} at \
333                 {3}".format(game.game_id, 
334                     server_id, map_id, start_dt))
335
336     return game
337
338
339 def get_or_create_player(session=None, hashkey=None, nick=None):
340     """
341     Finds a player by hashkey or creates a new one (along with a
342     corresponding hashkey entry. Parameters:
343
344     session - SQLAlchemy database session factory
345     hashkey - hashkey of the player to be found or created
346     nick - nick of the player (in case of a first time create)
347     """
348     # if we have a bot
349     if re.search('^bot#\d+$', hashkey) or re.search('^bot#\d+#', hashkey):
350         player = session.query(Player).filter_by(player_id=1).one()
351     # if we have an untracked player
352     elif re.search('^player#\d+$', hashkey):
353         player = session.query(Player).filter_by(player_id=2).one()
354     # else it is a tracked player
355     else:
356         # see if the player is already in the database
357         # if not, create one and the hashkey along with it
358         try:
359             hk = session.query(Hashkey).filter_by(
360                     hashkey=hashkey).one()
361             player = session.query(Player).filter_by(
362                     player_id=hk.player_id).one()
363             log.debug("Found existing player {0} with hashkey {1}".format(
364                 player.player_id, hashkey))
365         except:
366             player = Player()
367             session.add(player)
368             session.flush()
369
370             # if nick is given to us, use it. If not, use "Anonymous Player"
371             # with a suffix added for uniqueness.
372             if nick:
373                 player.nick = nick[:128]
374                 player.stripped_nick = strip_colors(qfont_decode(nick[:128]))
375             else:
376                 player.nick = "Anonymous Player #{0}".format(player.player_id)
377                 player.stripped_nick = player.nick
378
379             hk = Hashkey(player_id=player.player_id, hashkey=hashkey)
380             session.add(hk)
381             log.debug("Created player {0} ({2}) with hashkey {1}".format(
382                 player.player_id, hashkey, player.nick.encode('utf-8')))
383
384     return player
385
386 def create_player_game_stat(session=None, player=None, 
387         game=None, player_events=None):
388     """
389     Creates game statistics for a given player in a given game. Parameters:
390
391     session - SQLAlchemy session factory
392     player - Player record of the player who owns the stats
393     game - Game record for the game to which the stats pertain
394     player_events - dictionary for the actual stats that need to be transformed
395     """
396
397     # in here setup default values (e.g. if game type is CTF then
398     # set kills=0, score=0, captures=0, pickups=0, fckills=0, etc
399     # TODO: use game's create date here instead of now()
400     seq = Sequence('player_game_stats_player_game_stat_id_seq')
401     pgstat_id = session.execute(seq)
402     pgstat = PlayerGameStat(player_game_stat_id=pgstat_id, 
403             create_dt=datetime.datetime.utcnow())
404
405     # set player id from player record
406     pgstat.player_id = player.player_id
407
408     #set game id from game record
409     pgstat.game_id = game.game_id
410
411     # all games have a score and every player has an alivetime
412     pgstat.score = 0
413     pgstat.alivetime = datetime.timedelta(seconds=0)
414
415     if game.game_type_cd == 'dm' or game.game_type_cd == 'tdm' or game.game_type_cd == 'duel':
416         pgstat.kills = 0
417         pgstat.deaths = 0
418         pgstat.suicides = 0
419     elif game.game_type_cd == 'ctf':
420         pgstat.kills = 0
421         pgstat.captures = 0
422         pgstat.pickups = 0
423         pgstat.drops = 0
424         pgstat.returns = 0
425         pgstat.carrier_frags = 0
426
427     for (key,value) in player_events.items():
428         if key == 'n': 
429             pgstat.nick = value[:128]
430             pgstat.stripped_nick = strip_colors(qfont_decode(pgstat.nick))
431         if key == 't': pgstat.team = int(value)
432         if key == 'rank': pgstat.rank = int(value)
433         if key == 'alivetime': 
434             pgstat.alivetime = datetime.timedelta(seconds=int(round(float(value))))
435         if key == 'scoreboard-drops': pgstat.drops = int(value)
436         if key == 'scoreboard-returns': pgstat.returns = int(value)
437         if key == 'scoreboard-fckills': pgstat.carrier_frags = int(value)
438         if key == 'scoreboard-pickups': pgstat.pickups = int(value)
439         if key == 'scoreboard-caps': pgstat.captures = int(value)
440         if key == 'scoreboard-score': pgstat.score = int(value)
441         if key == 'scoreboard-deaths': pgstat.deaths = int(value)
442         if key == 'scoreboard-kills': pgstat.kills = int(value)
443         if key == 'scoreboard-suicides': pgstat.suicides = int(value)
444         if key == 'scoreboard-captime':
445             pgstat.fastest_cap = datetime.timedelta(seconds=float(value)/100)
446         if key == 'avglatency': pgstat.avg_latency = float(value)
447
448     # check to see if we had a name, and if
449     # not use an anonymous handle
450     if pgstat.nick == None:
451         pgstat.nick = "Anonymous Player"
452         pgstat.stripped_nick = "Anonymous Player"
453
454     # otherwise process a nick change
455     elif pgstat.nick != player.nick and player.player_id > 2:
456         register_new_nick(session, player, pgstat.nick)
457
458     # if the player is ranked #1 and it is a team game, set the game's winner
459     # to be the team of that player
460     # FIXME: this is a hack, should be using the 'W' field (not present)
461     if pgstat.rank == 1 and pgstat.team:
462         game.winner = pgstat.team
463         session.add(game)
464
465     session.add(pgstat)
466
467     return pgstat
468
469
470 def create_player_weapon_stats(session=None, player=None, 
471         game=None, pgstat=None, player_events=None, game_meta=None):
472     """
473     Creates accuracy records for each weapon used by a given player in a
474     given game. Parameters:
475
476     session - SQLAlchemy session factory object
477     player - Player record who owns the weapon stats
478     game - Game record in which the stats were created
479     pgstat - Corresponding PlayerGameStat record for these weapon stats
480     player_events - dictionary containing the raw weapon values that need to be
481         transformed
482     game_meta - dictionary of game metadata (only used for stats version info)
483     """
484     pwstats = []
485
486     # Version 1 of stats submissions doubled the data sent.
487     # To counteract this we divide the data by 2 only for
488     # POSTs coming from version 1.
489     try:
490         version = int(game_meta['V'])
491         if version == 1:
492             is_doubled = True
493             log.debug('NOTICE: found a version 1 request, halving the weapon stats...')
494         else:
495             is_doubled = False
496     except:
497         is_doubled = False
498
499     for (key,value) in player_events.items():
500         matched = re.search("acc-(.*?)-cnt-fired", key)
501         if matched:
502             weapon_cd = matched.group(1)
503             seq = Sequence('player_weapon_stats_player_weapon_stats_id_seq')
504             pwstat_id = session.execute(seq)
505             pwstat = PlayerWeaponStat()
506             pwstat.player_weapon_stats_id = pwstat_id
507             pwstat.player_id = player.player_id
508             pwstat.game_id = game.game_id
509             pwstat.player_game_stat_id = pgstat.player_game_stat_id
510             pwstat.weapon_cd = weapon_cd
511
512             if 'n' in player_events:
513                 pwstat.nick = player_events['n']
514             else:
515                 pwstat.nick = player_events['P']
516
517             if 'acc-' + weapon_cd + '-cnt-fired' in player_events:
518                 pwstat.fired = int(round(float(
519                         player_events['acc-' + weapon_cd + '-cnt-fired'])))
520             if 'acc-' + weapon_cd + '-fired' in player_events:
521                 pwstat.max = int(round(float(
522                         player_events['acc-' + weapon_cd + '-fired'])))
523             if 'acc-' + weapon_cd + '-cnt-hit' in player_events:
524                 pwstat.hit = int(round(float(
525                         player_events['acc-' + weapon_cd + '-cnt-hit'])))
526             if 'acc-' + weapon_cd + '-hit' in player_events:
527                 pwstat.actual = int(round(float(
528                         player_events['acc-' + weapon_cd + '-hit'])))
529             if 'acc-' + weapon_cd + '-frags' in player_events:
530                 pwstat.frags = int(round(float(
531                         player_events['acc-' + weapon_cd + '-frags'])))
532
533             if is_doubled:
534                 pwstat.fired = pwstat.fired/2
535                 pwstat.max = pwstat.max/2
536                 pwstat.hit = pwstat.hit/2
537                 pwstat.actual = pwstat.actual/2
538                 pwstat.frags = pwstat.frags/2
539
540             session.add(pwstat)
541             pwstats.append(pwstat)
542
543     return pwstats
544
545
546 def parse_body(request):
547     """
548     Parses the POST request body for a stats submission
549     """
550     # storage vars for the request body
551     game_meta = {}
552     player_events = {}
553     current_team = None
554     players = []
555
556     for line in request.body.split('\n'):
557         try:
558             (key, value) = line.strip().split(' ', 1)
559
560             # Server (S) and Nick (n) fields can have international characters.
561             # We convert to UTF-8.
562             if key in 'S' 'n':
563                 value = unicode(value, 'utf-8')
564
565             if key in 'V' 'T' 'G' 'M' 'S' 'C' 'R' 'W' 'I':
566                 game_meta[key] = value
567
568             if key == 'P':
569                 # if we were working on a player record already, append
570                 # it and work on a new one (only set team info)
571                 if len(player_events) != 0:
572                     players.append(player_events)
573                     player_events = {}
574
575                 player_events[key] = value
576
577             if key == 'e':
578                 (subkey, subvalue) = value.split(' ', 1)
579                 player_events[subkey] = subvalue
580             if key == 'n':
581                 player_events[key] = value
582             if key == 't':
583                 player_events[key] = value
584         except:
585             # no key/value pair - move on to the next line
586             pass
587
588     # add the last player we were working on
589     if len(player_events) > 0:
590         players.append(player_events)
591
592     return (game_meta, players)
593
594
595 def create_player_stats(session=None, player=None, game=None, 
596         player_events=None, game_meta=None):
597     """
598     Creates player game and weapon stats according to what type of player
599     """
600     pgstat = create_player_game_stat(session=session, 
601         player=player, game=game, player_events=player_events)
602
603     # fastest cap "upsert"
604     if game.game_type_cd == 'ctf' and pgstat.fastest_cap is not None:
605         update_fastest_cap(session, pgstat.player_id, game.game_id, 
606                 game.map_id, pgstat.fastest_cap)
607
608     # bots don't get weapon stats. sorry, bots!
609     if not re.search('^bot#\d+$', player_events['P']):
610         create_player_weapon_stats(session=session, 
611             player=player, game=game, pgstat=pgstat,
612             player_events=player_events, game_meta=game_meta)
613
614
615 def stats_submit(request):
616     """
617     Entry handler for POST stats submissions.
618     """
619     try:
620         # placeholder for the actual session
621         session = None
622
623         log.debug("\n----- BEGIN REQUEST BODY -----\n" + request.body +
624                 "----- END REQUEST BODY -----\n\n")
625
626         (idfp, status) = verify_request(request)
627         if verify_requests(request.registry.settings):
628             if not idfp:
629                 log.debug("ERROR: Unverified request")
630                 raise pyramid.httpexceptions.HTTPUnauthorized("Unverified request")
631
632         (game_meta, players) = parse_body(request)
633
634         if not has_required_metadata(game_meta):
635             log.debug("ERROR: Required game meta missing")
636             raise pyramid.httpexceptions.HTTPUnprocessableEntity("Missing game meta")
637
638         if not is_supported_gametype(game_meta['G']):
639             log.debug("ERROR: Unsupported gametype")
640             raise pyramid.httpexceptions.HTTPOk("OK")
641
642         if not has_minimum_real_players(request.registry.settings, players):
643             log.debug("ERROR: Not enough real players")
644             raise pyramid.httpexceptions.HTTPOk("OK")
645
646         if is_blank_game(players):
647             log.debug("ERROR: Blank game")
648             raise pyramid.httpexceptions.HTTPOk("OK")
649
650         # the "duel" gametype is fake
651         if num_real_players(players, count_bots=True) == 2 and \
652                 game_meta['G'] == 'dm':
653             game_meta['G'] = 'duel'
654
655
656         # fix for DTG, who didn't #ifdef WATERMARK to set the revision info
657         try:
658             revision = game_meta['R']
659         except:
660             revision = "unknown"
661
662         #----------------------------------------------------------------------
663         # This ends the "precondition" section of sanity checks. All
664         # functions not requiring a database connection go ABOVE HERE.
665         #----------------------------------------------------------------------
666         session = DBSession()
667
668         server = get_or_create_server(session=session, hashkey=idfp, 
669                 name=game_meta['S'], revision=revision,
670                 ip_addr=get_remote_addr(request))
671
672         gmap = get_or_create_map(session=session, name=game_meta['M'])
673
674         # FIXME: use the gmtime instead of utcnow() when the timezone bug is
675         # fixed
676         game = create_game(session=session, 
677                 start_dt=datetime.datetime.utcnow(),
678                 #start_dt=datetime.datetime(
679                     #*time.gmtime(float(game_meta['T']))[:6]), 
680                 server_id=server.server_id, game_type_cd=game_meta['G'], 
681                    map_id=gmap.map_id, match_id=game_meta['I'])
682
683         # find or create a record for each player
684         # and add stats for each if they were present at the end
685         # of the game
686         for player_events in players:
687             if 'n' in player_events:
688                 nick = player_events['n']
689             else:
690                 nick = None
691
692             if 'matches' in player_events and 'scoreboardvalid' \
693                 in player_events:
694                 player = get_or_create_player(session=session, 
695                     hashkey=player_events['P'], nick=nick)
696                 log.debug('Creating stats for %s' % player_events['P'])
697                 create_player_stats(session=session, player=player, game=game, 
698                         player_events=player_events, game_meta=game_meta)
699
700         # update elos
701         #try:
702             #process_elos(game, session)
703         #except Exception as e:
704             #log.debug('Error (non-fatal): elo processing failed.')
705
706         session.commit()
707         log.debug('Success! Stats recorded.')
708         return Response('200 OK')
709     except Exception as e:
710         if session:
711             session.rollback()
712         return e