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