]> de.git.xonotic.org Git - xonotic/xonstat.git/blob - xonstat/views/submission.py
Halve the weapon stats if the POST request is from version 1.
[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(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(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(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
365     pgstat.score = 0
366
367     if game.game_type_cd == 'dm' or game.game_type_cd == 'tdm' or game.game_type_cd == 'duel':
368         pgstat.kills = 0
369         pgstat.deaths = 0
370         pgstat.suicides = 0
371     elif game.game_type_cd == 'ctf':
372         pgstat.kills = 0
373         pgstat.captures = 0
374         pgstat.pickups = 0
375         pgstat.drops = 0
376         pgstat.returns = 0
377         pgstat.carrier_frags = 0
378
379     for (key,value) in player_events.items():
380         if key == 'n': pgstat.nick = value[:128]
381         if key == 't': pgstat.team = int(value)
382         if key == 'rank': pgstat.rank = int(value)
383         if key == 'alivetime': 
384             pgstat.alivetime = datetime.timedelta(seconds=int(round(float(value))))
385         if key == 'scoreboard-drops': pgstat.drops = int(value)
386         if key == 'scoreboard-returns': pgstat.returns = int(value)
387         if key == 'scoreboard-fckills': pgstat.carrier_frags = int(value)
388         if key == 'scoreboard-pickups': pgstat.pickups = int(value)
389         if key == 'scoreboard-caps': pgstat.captures = int(value)
390         if key == 'scoreboard-score': pgstat.score = int(value)
391         if key == 'scoreboard-deaths': pgstat.deaths = int(value)
392         if key == 'scoreboard-kills': pgstat.kills = int(value)
393         if key == 'scoreboard-suicides': pgstat.suicides = int(value)
394
395     # check to see if we had a name, and if
396     # not use an anonymous handle
397     if pgstat.nick == None:
398         pgstat.nick = "Anonymous Player"
399         pgstat.stripped_nick = "Anonymous Player"
400
401     # otherwise process a nick change
402     elif pgstat.nick != player.nick and player.player_id > 2:
403         register_new_nick(session, player, pgstat.nick)
404
405     # if the player is ranked #1 and it is a team game, set the game's winner
406     # to be the team of that player
407     # FIXME: this is a hack, should be using the 'W' field (not present)
408     if pgstat.rank == 1 and pgstat.team:
409         game.winner = pgstat.team
410         session.add(game)
411
412     session.add(pgstat)
413
414     return pgstat
415
416
417 def create_player_weapon_stats(session=None, player=None, 
418         game=None, pgstat=None, player_events=None, game_meta=None):
419     """
420     Creates accuracy records for each weapon used by a given player in a
421     given game. Parameters:
422
423     session - SQLAlchemy session factory object
424     player - Player record who owns the weapon stats
425     game - Game record in which the stats were created
426     pgstat - Corresponding PlayerGameStat record for these weapon stats
427     player_events - dictionary containing the raw weapon values that need to be
428         transformed
429     game_meta - dictionary of game metadata (only used for stats version info)
430     """
431     pwstats = []
432
433     # Version 1 of stats submissions doubled the data sent.
434     # To counteract this we divide the data by 2 only for
435     # POSTs coming from version 1.
436     try:
437         version = int(game_meta['V'])
438         if version == 1:
439             is_doubled = True
440             log.debug('NOTICE: found a version 1 request, halving the weapon stats...')
441         else:
442             is_doubled = False
443     except:
444         is_doubled = False
445
446     for (key,value) in player_events.items():
447         matched = re.search("acc-(.*?)-cnt-fired", key)
448         if matched:
449             weapon_cd = matched.group(1)
450             seq = Sequence('player_weapon_stats_player_weapon_stats_id_seq')
451             pwstat_id = session.execute(seq)
452             pwstat = PlayerWeaponStat()
453             pwstat.player_weapon_stats_id = pwstat_id
454             pwstat.player_id = player.player_id
455             pwstat.game_id = game.game_id
456             pwstat.player_game_stat_id = pgstat.player_game_stat_id
457             pwstat.weapon_cd = weapon_cd
458
459             if 'n' in player_events:
460                 pwstat.nick = player_events['n']
461             else:
462                 pwstat.nick = player_events['P']
463
464             if 'acc-' + weapon_cd + '-cnt-fired' in player_events:
465                 pwstat.fired = int(round(float(
466                         player_events['acc-' + weapon_cd + '-cnt-fired'])))
467                 if is_doubled:
468                     pwstat.fired = pwstat.fired/2
469             if 'acc-' + weapon_cd + '-fired' in player_events:
470                 pwstat.max = int(round(float(
471                         player_events['acc-' + weapon_cd + '-fired'])))
472                 if is_doubled:
473                     pwstat.max = pwstat.max/2
474             if 'acc-' + weapon_cd + '-cnt-hit' in player_events:
475                 pwstat.hit = int(round(float(
476                         player_events['acc-' + weapon_cd + '-cnt-hit'])))
477                 if is_doubled:
478                     pwstat.hit = pwstat.hit/2
479             if 'acc-' + weapon_cd + '-hit' in player_events:
480                 pwstat.actual = int(round(float(
481                         player_events['acc-' + weapon_cd + '-hit'])))
482                 if is_doubled:
483                     pwstat.actual = pwstat.actual/2
484             if 'acc-' + weapon_cd + '-frags' in player_events:
485                 pwstat.frags = int(round(float(
486                         player_events['acc-' + weapon_cd + '-frags'])))
487                 if is_doubled:
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         log.debug("\n----- BEGIN REQUEST BODY -----\n" + request.body +
566                 "----- END REQUEST BODY -----\n\n")
567
568         (idfp, status) = verify_request(request)
569         if not idfp:
570             log.debug("ERROR: Unverified request")
571             raise pyramid.httpexceptions.HTTPUnauthorized("Unverified request")
572
573         (game_meta, players) = parse_body(request)
574
575         if not has_required_metadata(game_meta):
576             log.debug("ERROR: Required game meta missing")
577             raise pyramid.httpexceptions.HTTPUnprocessableEntity("Missing game meta")
578
579         if not is_supported_gametype(game_meta['G']):
580             log.debug("ERROR: Unsupported gametype")
581             raise pyramid.httpexceptions.HTTPOk("OK")
582
583         if not has_minimum_real_players(request.registry.settings, players):
584             log.debug("ERROR: Not enough real players")
585             raise pyramid.httpexceptions.HTTPOk("OK")
586
587         if is_blank_game(players):
588             log.debug("ERROR: Blank game")
589             raise pyramid.httpexceptions.HTTPOk("OK")
590
591         # the "duel" gametype is fake
592         if num_real_players(players, count_bots=True) == 2 and \
593                 game_meta['G'] == 'dm':
594             game_meta['G'] = 'duel'
595
596
597         # fix for DTG, who didn't #ifdef WATERMARK to set the revision info
598         try:
599             revision = game_meta['R']
600         except:
601             revision = "unknown"
602
603         #----------------------------------------------------------------------
604         # This ends the "precondition" section of sanity checks. All
605         # functions not requiring a database connection go ABOVE HERE.
606         #----------------------------------------------------------------------
607         session = DBSession()
608
609         server = get_or_create_server(session=session, hashkey=idfp, 
610                 name=game_meta['S'], revision=revision,
611                 ip_addr=get_remote_addr(request))
612
613         gmap = get_or_create_map(session=session, name=game_meta['M'])
614
615         # FIXME: use the gmtime instead of utcnow() when the timezone bug is
616         # fixed
617         game = create_game(session=session, 
618                 start_dt=datetime.datetime.utcnow(),
619                 #start_dt=datetime.datetime(
620                     #*time.gmtime(float(game_meta['T']))[:6]), 
621                 server_id=server.server_id, game_type_cd=game_meta['G'], 
622                    map_id=gmap.map_id, match_id=game_meta['I'])
623
624         # find or create a record for each player
625         # and add stats for each if they were present at the end
626         # of the game
627         for player_events in players:
628             if 'n' in player_events:
629                 nick = player_events['n']
630             else:
631                 nick = None
632
633             if 'matches' in player_events and 'scoreboardvalid' \
634                 in player_events:
635                 player = get_or_create_player(session=session, 
636                     hashkey=player_events['P'], nick=nick)
637                 log.debug('Creating stats for %s' % player_events['P'])
638                 create_player_stats(session=session, player=player, game=game, 
639                         player_events=player_events, game_meta=game_meta)
640
641         # update elos
642         try:
643             process_elos(game, session)
644         except Exception as e:
645             log.debug('Error (non-fatal): elo processing failed.')
646
647         session.commit()
648         log.debug('Success! Stats recorded.')
649         return Response('200 OK')
650     except Exception as e:
651         session.rollback()
652         return e