]> de.git.xonotic.org Git - xonotic/xonstat.git/blob - xonstat/views/submission.py
Parse and store the game's duration.
[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         duration=None):
304     """
305     Creates a game. Parameters:
306
307     session - SQLAlchemy database session factory
308     start_dt - when the game started (datetime object)
309     game_type_cd - the game type of the game being played
310     server_id - server identifier of the server hosting the game
311     map_id - map on which the game was played
312     winner - the team id of the team that won
313     """
314     seq = Sequence('games_game_id_seq')
315     game_id = session.execute(seq)
316     game = Game(game_id=game_id, start_dt=start_dt, game_type_cd=game_type_cd,
317                 server_id=server_id, map_id=map_id, winner=winner)
318     game.match_id = match_id
319
320     try:
321         game.duration = datetime.timedelta(seconds=int(round(float(duration))))
322     except:
323         pass
324
325     try:
326         session.query(Game).filter(Game.server_id==server_id).\
327                 filter(Game.match_id==match_id).one()
328
329         log.debug("Error: game with same server and match_id found! Ignoring.")
330
331         # if a game under the same server and match_id found, 
332         # this is a duplicate game and can be ignored
333         raise pyramid.httpexceptions.HTTPOk('OK')
334     except NoResultFound, e:
335         # server_id/match_id combination not found. game is ok to insert
336         session.add(game)
337         session.flush()
338         log.debug("Created game id {0} on server {1}, map {2} at \
339                 {3}".format(game.game_id, 
340                     server_id, map_id, start_dt))
341
342     return game
343
344
345 def get_or_create_player(session=None, hashkey=None, nick=None):
346     """
347     Finds a player by hashkey or creates a new one (along with a
348     corresponding hashkey entry. Parameters:
349
350     session - SQLAlchemy database session factory
351     hashkey - hashkey of the player to be found or created
352     nick - nick of the player (in case of a first time create)
353     """
354     # if we have a bot
355     if re.search('^bot#\d+$', hashkey) or re.search('^bot#\d+#', hashkey):
356         player = session.query(Player).filter_by(player_id=1).one()
357     # if we have an untracked player
358     elif re.search('^player#\d+$', hashkey):
359         player = session.query(Player).filter_by(player_id=2).one()
360     # else it is a tracked player
361     else:
362         # see if the player is already in the database
363         # if not, create one and the hashkey along with it
364         try:
365             hk = session.query(Hashkey).filter_by(
366                     hashkey=hashkey).one()
367             player = session.query(Player).filter_by(
368                     player_id=hk.player_id).one()
369             log.debug("Found existing player {0} with hashkey {1}".format(
370                 player.player_id, hashkey))
371         except:
372             player = Player()
373             session.add(player)
374             session.flush()
375
376             # if nick is given to us, use it. If not, use "Anonymous Player"
377             # with a suffix added for uniqueness.
378             if nick:
379                 player.nick = nick[:128]
380                 player.stripped_nick = strip_colors(qfont_decode(nick[:128]))
381             else:
382                 player.nick = "Anonymous Player #{0}".format(player.player_id)
383                 player.stripped_nick = player.nick
384
385             hk = Hashkey(player_id=player.player_id, hashkey=hashkey)
386             session.add(hk)
387             log.debug("Created player {0} ({2}) with hashkey {1}".format(
388                 player.player_id, hashkey, player.nick.encode('utf-8')))
389
390     return player
391
392 def create_player_game_stat(session=None, player=None, 
393         game=None, player_events=None):
394     """
395     Creates game statistics for a given player in a given game. Parameters:
396
397     session - SQLAlchemy session factory
398     player - Player record of the player who owns the stats
399     game - Game record for the game to which the stats pertain
400     player_events - dictionary for the actual stats that need to be transformed
401     """
402
403     # in here setup default values (e.g. if game type is CTF then
404     # set kills=0, score=0, captures=0, pickups=0, fckills=0, etc
405     # TODO: use game's create date here instead of now()
406     seq = Sequence('player_game_stats_player_game_stat_id_seq')
407     pgstat_id = session.execute(seq)
408     pgstat = PlayerGameStat(player_game_stat_id=pgstat_id, 
409             create_dt=datetime.datetime.utcnow())
410
411     # set player id from player record
412     pgstat.player_id = player.player_id
413
414     #set game id from game record
415     pgstat.game_id = game.game_id
416
417     # all games have a score and every player has an alivetime
418     pgstat.score = 0
419     pgstat.alivetime = datetime.timedelta(seconds=0)
420
421     if game.game_type_cd == 'dm' or game.game_type_cd == 'tdm' or game.game_type_cd == 'duel':
422         pgstat.kills = 0
423         pgstat.deaths = 0
424         pgstat.suicides = 0
425     elif game.game_type_cd == 'ctf':
426         pgstat.kills = 0
427         pgstat.captures = 0
428         pgstat.pickups = 0
429         pgstat.drops = 0
430         pgstat.returns = 0
431         pgstat.carrier_frags = 0
432
433     for (key,value) in player_events.items():
434         if key == 'n': 
435             pgstat.nick = value[:128]
436             pgstat.stripped_nick = strip_colors(qfont_decode(pgstat.nick))
437         if key == 't': pgstat.team = int(value)
438         if key == 'rank': pgstat.rank = int(value)
439         if key == 'alivetime': 
440             pgstat.alivetime = datetime.timedelta(seconds=int(round(float(value))))
441         if key == 'scoreboard-drops': pgstat.drops = int(value)
442         if key == 'scoreboard-returns': pgstat.returns = int(value)
443         if key == 'scoreboard-fckills': pgstat.carrier_frags = int(value)
444         if key == 'scoreboard-pickups': pgstat.pickups = int(value)
445         if key == 'scoreboard-caps': pgstat.captures = int(value)
446         if key == 'scoreboard-score': pgstat.score = int(value)
447         if key == 'scoreboard-deaths': pgstat.deaths = int(value)
448         if key == 'scoreboard-kills': pgstat.kills = int(value)
449         if key == 'scoreboard-suicides': pgstat.suicides = int(value)
450         if key == 'scoreboard-captime':
451             pgstat.fastest_cap = datetime.timedelta(seconds=float(value)/100)
452         if key == 'avglatency': pgstat.avg_latency = float(value)
453
454     # check to see if we had a name, and if
455     # not use an anonymous handle
456     if pgstat.nick == None:
457         pgstat.nick = "Anonymous Player"
458         pgstat.stripped_nick = "Anonymous Player"
459
460     # otherwise process a nick change
461     elif pgstat.nick != player.nick and player.player_id > 2:
462         register_new_nick(session, player, pgstat.nick)
463
464     # if the player is ranked #1 and it is a team game, set the game's winner
465     # to be the team of that player
466     # FIXME: this is a hack, should be using the 'W' field (not present)
467     if pgstat.rank == 1 and pgstat.team:
468         game.winner = pgstat.team
469         session.add(game)
470
471     session.add(pgstat)
472
473     return pgstat
474
475
476 def create_player_weapon_stats(session=None, player=None, 
477         game=None, pgstat=None, player_events=None, game_meta=None):
478     """
479     Creates accuracy records for each weapon used by a given player in a
480     given game. Parameters:
481
482     session - SQLAlchemy session factory object
483     player - Player record who owns the weapon stats
484     game - Game record in which the stats were created
485     pgstat - Corresponding PlayerGameStat record for these weapon stats
486     player_events - dictionary containing the raw weapon values that need to be
487         transformed
488     game_meta - dictionary of game metadata (only used for stats version info)
489     """
490     pwstats = []
491
492     # Version 1 of stats submissions doubled the data sent.
493     # To counteract this we divide the data by 2 only for
494     # POSTs coming from version 1.
495     try:
496         version = int(game_meta['V'])
497         if version == 1:
498             is_doubled = True
499             log.debug('NOTICE: found a version 1 request, halving the weapon stats...')
500         else:
501             is_doubled = False
502     except:
503         is_doubled = False
504
505     for (key,value) in player_events.items():
506         matched = re.search("acc-(.*?)-cnt-fired", key)
507         if matched:
508             weapon_cd = matched.group(1)
509             seq = Sequence('player_weapon_stats_player_weapon_stats_id_seq')
510             pwstat_id = session.execute(seq)
511             pwstat = PlayerWeaponStat()
512             pwstat.player_weapon_stats_id = pwstat_id
513             pwstat.player_id = player.player_id
514             pwstat.game_id = game.game_id
515             pwstat.player_game_stat_id = pgstat.player_game_stat_id
516             pwstat.weapon_cd = weapon_cd
517
518             if 'n' in player_events:
519                 pwstat.nick = player_events['n']
520             else:
521                 pwstat.nick = player_events['P']
522
523             if 'acc-' + weapon_cd + '-cnt-fired' in player_events:
524                 pwstat.fired = int(round(float(
525                         player_events['acc-' + weapon_cd + '-cnt-fired'])))
526             if 'acc-' + weapon_cd + '-fired' in player_events:
527                 pwstat.max = int(round(float(
528                         player_events['acc-' + weapon_cd + '-fired'])))
529             if 'acc-' + weapon_cd + '-cnt-hit' in player_events:
530                 pwstat.hit = int(round(float(
531                         player_events['acc-' + weapon_cd + '-cnt-hit'])))
532             if 'acc-' + weapon_cd + '-hit' in player_events:
533                 pwstat.actual = int(round(float(
534                         player_events['acc-' + weapon_cd + '-hit'])))
535             if 'acc-' + weapon_cd + '-frags' in player_events:
536                 pwstat.frags = int(round(float(
537                         player_events['acc-' + weapon_cd + '-frags'])))
538
539             if is_doubled:
540                 pwstat.fired = pwstat.fired/2
541                 pwstat.max = pwstat.max/2
542                 pwstat.hit = pwstat.hit/2
543                 pwstat.actual = pwstat.actual/2
544                 pwstat.frags = pwstat.frags/2
545
546             session.add(pwstat)
547             pwstats.append(pwstat)
548
549     return pwstats
550
551
552 def parse_body(request):
553     """
554     Parses the POST request body for a stats submission
555     """
556     # storage vars for the request body
557     game_meta = {}
558     player_events = {}
559     current_team = None
560     players = []
561
562     for line in request.body.split('\n'):
563         try:
564             (key, value) = line.strip().split(' ', 1)
565
566             # Server (S) and Nick (n) fields can have international characters.
567             # We convert to UTF-8.
568             if key in 'S' 'n':
569                 value = unicode(value, 'utf-8')
570
571             if key in 'V' 'T' 'G' 'M' 'S' 'C' 'R' 'W' 'I' 'D':
572                 game_meta[key] = value
573
574             if key == 'P':
575                 # if we were working on a player record already, append
576                 # it and work on a new one (only set team info)
577                 if len(player_events) != 0:
578                     players.append(player_events)
579                     player_events = {}
580
581                 player_events[key] = value
582
583             if key == 'e':
584                 (subkey, subvalue) = value.split(' ', 1)
585                 player_events[subkey] = subvalue
586             if key == 'n':
587                 player_events[key] = value
588             if key == 't':
589                 player_events[key] = value
590         except:
591             # no key/value pair - move on to the next line
592             pass
593
594     # add the last player we were working on
595     if len(player_events) > 0:
596         players.append(player_events)
597
598     return (game_meta, players)
599
600
601 def create_player_stats(session=None, player=None, game=None, 
602         player_events=None, game_meta=None):
603     """
604     Creates player game and weapon stats according to what type of player
605     """
606     pgstat = create_player_game_stat(session=session, 
607         player=player, game=game, player_events=player_events)
608
609     # fastest cap "upsert"
610     if game.game_type_cd == 'ctf' and pgstat.fastest_cap is not None:
611         update_fastest_cap(session, pgstat.player_id, game.game_id, 
612                 game.map_id, pgstat.fastest_cap)
613
614     # bots don't get weapon stats. sorry, bots!
615     if not re.search('^bot#\d+$', player_events['P']):
616         create_player_weapon_stats(session=session, 
617             player=player, game=game, pgstat=pgstat,
618             player_events=player_events, game_meta=game_meta)
619
620
621 def stats_submit(request):
622     """
623     Entry handler for POST stats submissions.
624     """
625     try:
626         # placeholder for the actual session
627         session = None
628
629         log.debug("\n----- BEGIN REQUEST BODY -----\n" + request.body +
630                 "----- END REQUEST BODY -----\n\n")
631
632         (idfp, status) = verify_request(request)
633         if verify_requests(request.registry.settings):
634             if not idfp:
635                 log.debug("ERROR: Unverified request")
636                 raise pyramid.httpexceptions.HTTPUnauthorized("Unverified request")
637
638         (game_meta, players) = parse_body(request)
639
640         if not has_required_metadata(game_meta):
641             log.debug("ERROR: Required game meta missing")
642             raise pyramid.httpexceptions.HTTPUnprocessableEntity("Missing game meta")
643
644         if not is_supported_gametype(game_meta['G']):
645             log.debug("ERROR: Unsupported gametype")
646             raise pyramid.httpexceptions.HTTPOk("OK")
647
648         if not has_minimum_real_players(request.registry.settings, players):
649             log.debug("ERROR: Not enough real players")
650             raise pyramid.httpexceptions.HTTPOk("OK")
651
652         if is_blank_game(players):
653             log.debug("ERROR: Blank game")
654             raise pyramid.httpexceptions.HTTPOk("OK")
655
656         # the "duel" gametype is fake
657         if num_real_players(players, count_bots=True) == 2 and \
658                 game_meta['G'] == 'dm':
659             game_meta['G'] = 'duel'
660
661
662         # fix for DTG, who didn't #ifdef WATERMARK to set the revision info
663         try:
664             revision = game_meta['R']
665         except:
666             revision = "unknown"
667
668         #----------------------------------------------------------------------
669         # This ends the "precondition" section of sanity checks. All
670         # functions not requiring a database connection go ABOVE HERE.
671         #----------------------------------------------------------------------
672         session = DBSession()
673
674         server = get_or_create_server(session=session, hashkey=idfp, 
675                 name=game_meta['S'], revision=revision,
676                 ip_addr=get_remote_addr(request))
677
678         gmap = get_or_create_map(session=session, name=game_meta['M'])
679
680         # duration is optional
681         if 'D' in game_meta:
682             duration = game_meta['D']
683         else:
684             duration = None
685
686         game = create_game(session=session, 
687                 start_dt=datetime.datetime.utcnow(),
688                 #start_dt=datetime.datetime(
689                     #*time.gmtime(float(game_meta['T']))[:6]), 
690                 server_id=server.server_id, game_type_cd=game_meta['G'], 
691                    map_id=gmap.map_id, match_id=game_meta['I'],
692                    duration=duration)
693
694         # find or create a record for each player
695         # and add stats for each if they were present at the end
696         # of the game
697         for player_events in players:
698             if 'n' in player_events:
699                 nick = player_events['n']
700             else:
701                 nick = None
702
703             if 'matches' in player_events and 'scoreboardvalid' \
704                 in player_events:
705                 player = get_or_create_player(session=session, 
706                     hashkey=player_events['P'], nick=nick)
707                 log.debug('Creating stats for %s' % player_events['P'])
708                 create_player_stats(session=session, player=player, game=game, 
709                         player_events=player_events, game_meta=game_meta)
710
711         # update elos
712         try:
713             process_elos(game, session)
714         except Exception as e:
715             log.debug('Error (non-fatal): elo processing failed.')
716
717         session.commit()
718         log.debug('Success! Stats recorded.')
719         return Response('200 OK')
720     except Exception as e:
721         if session:
722             session.rollback()
723         return e