d584915193706fa0bd47e9f1789fd750a1f72e11
[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.models import *
12 from xonstat.util import strip_colors, qfont_decode
13
14 log = logging.getLogger(__name__)
15
16
17 def is_blank_game(players):
18     """Determine if this is a blank game or not. A blank game is either:
19
20     1) a match that ended in the warmup stage, where accuracy events are not
21     present
22
23     2) a match in which no player made a positive or negative score AND was
24     on the scoreboard
25     """
26     r = re.compile(r'acc-.*-cnt-fired')
27     flg_nonzero_score = False
28     flg_acc_events = False
29
30     for events in players:
31         if is_real_player(events):
32             for (key,value) in events.items():
33                 if key == 'scoreboard-score' and value != '0':
34                     flg_nonzero_score = True
35                 if r.search(key):
36                     flg_acc_events = True
37
38     return not (flg_nonzero_score and flg_acc_events)
39
40 def get_remote_addr(request):
41     """Get the Xonotic server's IP address"""
42     if 'X-Forwarded-For' in request.headers:
43         return request.headers['X-Forwarded-For']
44     else:
45         return request.remote_addr
46
47
48 def is_supported_gametype(gametype):
49     """Whether a gametype is supported or not"""
50     flg_supported = True
51
52     if gametype == 'cts' or gametype == 'lms':
53         flg_supported = False
54
55     return flg_supported
56
57
58 def verify_request(request):
59     try:
60         (idfp, status) = d0_blind_id_verify(
61                 sig=request.headers['X-D0-Blind-Id-Detached-Signature'],
62                 querystring='',
63                 postdata=request.body)
64
65         log.debug('\nidfp: {0}\nstatus: {1}'.format(idfp, status))
66     except: 
67         idfp = None
68         status = None
69
70     return (idfp, status)
71
72
73 def num_real_players(player_events, count_bots=False):
74     """
75     Returns the number of real players (those who played 
76     and are on the scoreboard).
77     """
78     real_players = 0
79
80     for events in player_events:
81         if is_real_player(events, count_bots):
82             real_players += 1
83
84     return real_players
85
86
87 def has_minimum_real_players(settings, player_events):
88     """
89     Determines if the collection of player events has enough "real" players
90     to store in the database. The minimum setting comes from the config file
91     under the setting xonstat.minimum_real_players.
92     """
93     flg_has_min_real_players = True
94
95     try:
96         minimum_required_players = int(
97                 settings['xonstat.minimum_required_players'])
98     except:
99         minimum_required_players = 2
100
101     real_players = num_real_players(player_events)
102
103     #TODO: put this into a config setting in the ini file?
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):
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     """
430     pwstats = []
431
432     for (key,value) in player_events.items():
433         matched = re.search("acc-(.*?)-cnt-fired", key)
434         if matched:
435             weapon_cd = matched.group(1)
436             seq = Sequence('player_weapon_stats_player_weapon_stats_id_seq')
437             pwstat_id = session.execute(seq)
438             pwstat = PlayerWeaponStat()
439             pwstat.player_weapon_stats_id = pwstat_id
440             pwstat.player_id = player.player_id
441             pwstat.game_id = game.game_id
442             pwstat.player_game_stat_id = pgstat.player_game_stat_id
443             pwstat.weapon_cd = weapon_cd
444
445             if 'n' in player_events:
446                 pwstat.nick = player_events['n']
447             else:
448                 pwstat.nick = player_events['P']
449
450             if 'acc-' + weapon_cd + '-cnt-fired' in player_events:
451                 pwstat.fired = int(round(float(
452                         player_events['acc-' + weapon_cd + '-cnt-fired'])))
453             if 'acc-' + weapon_cd + '-fired' in player_events:
454                 pwstat.max = int(round(float(
455                         player_events['acc-' + weapon_cd + '-fired'])))
456             if 'acc-' + weapon_cd + '-cnt-hit' in player_events:
457                 pwstat.hit = int(round(float(
458                         player_events['acc-' + weapon_cd + '-cnt-hit'])))
459             if 'acc-' + weapon_cd + '-hit' in player_events:
460                 pwstat.actual = int(round(float(
461                         player_events['acc-' + weapon_cd + '-hit'])))
462             if 'acc-' + weapon_cd + '-frags' in player_events:
463                 pwstat.frags = int(round(float(
464                         player_events['acc-' + weapon_cd + '-frags'])))
465
466             session.add(pwstat)
467             pwstats.append(pwstat)
468
469     return pwstats
470
471
472 def parse_body(request):
473     """
474     Parses the POST request body for a stats submission
475     """
476     # storage vars for the request body
477     game_meta = {}
478     player_events = {}
479     current_team = None
480     players = []
481
482     for line in request.body.split('\n'):
483         try:
484             (key, value) = line.strip().split(' ', 1)
485
486             # Server (S) and Nick (n) fields can have international characters.
487             # We convert to UTF-8.
488             if key in 'S' 'n':
489                 value = unicode(value, 'utf-8')
490
491             if key in 'V' 'T' 'G' 'M' 'S' 'C' 'R' 'W' 'I':
492                 game_meta[key] = value
493
494             if key == 'P':
495                 # if we were working on a player record already, append
496                 # it and work on a new one (only set team info)
497                 if len(player_events) != 0:
498                     players.append(player_events)
499                     player_events = {}
500
501                 player_events[key] = value
502
503             if key == 'e':
504                 (subkey, subvalue) = value.split(' ', 1)
505                 player_events[subkey] = subvalue
506             if key == 'n':
507                 player_events[key] = value
508             if key == 't':
509                 player_events[key] = value
510         except:
511             # no key/value pair - move on to the next line
512             pass
513
514     # add the last player we were working on
515     if len(player_events) > 0:
516         players.append(player_events)
517
518     return (game_meta, players)
519
520
521 def create_player_stats(session=None, player=None, game=None, 
522         player_events=None):
523     """
524     Creates player game and weapon stats according to what type of player
525     """
526     pgstat = create_player_game_stat(session=session, 
527         player=player, game=game, player_events=player_events)
528
529     #TODO: put this into a config setting in the ini file?
530     if not re.search('^bot#\d+$', player_events['P']):
531         create_player_weapon_stats(session=session, 
532             player=player, game=game, pgstat=pgstat,
533             player_events=player_events)
534
535
536 def stats_submit(request):
537     """
538     Entry handler for POST stats submissions.
539     """
540     try:
541         session = DBSession()
542
543         log.debug("\n----- BEGIN REQUEST BODY -----\n" + request.body +
544                 "----- END REQUEST BODY -----\n\n")
545
546         (idfp, status) = verify_request(request)
547         if not idfp:
548             log.debug("ERROR: Unverified request")
549             raise pyramid.httpexceptions.HTTPUnauthorized("Unverified request")
550
551         (game_meta, players) = parse_body(request)  
552
553         if not has_required_metadata(game_meta):
554             log.debug("ERROR: Required game meta missing")
555             raise pyramid.httpexceptions.HTTPUnprocessableEntity("Missing game meta")
556
557         if not is_supported_gametype(game_meta['G']):
558             log.debug("ERROR: Unsupported gametype")
559             raise pyramid.httpexceptions.HTTPOk("OK")
560
561         if not has_minimum_real_players(request.registry.settings, players):
562             log.debug("ERROR: Not enough real players")
563             raise pyramid.httpexceptions.HTTPOk("OK")
564
565         if is_blank_game(players):
566             log.debug("ERROR: Blank game")
567             raise pyramid.httpexceptions.HTTPOk("OK")
568
569         # the "duel" gametype is fake
570         if num_real_players(players, count_bots=True) == 2 and \
571                 game_meta['G'] == 'dm':
572             game_meta['G'] = 'duel'
573
574
575         # fix for DTG, who didn't #ifdef WATERMARK to set the revision info
576         try:
577             revision = game_meta['R']
578         except:
579             revision = "unknown"
580
581         server = get_or_create_server(session=session, hashkey=idfp, 
582                 name=game_meta['S'], revision=revision,
583                 ip_addr=get_remote_addr(request))
584
585         gmap = get_or_create_map(session=session, name=game_meta['M'])
586
587         # FIXME: use the gmtime instead of utcnow() when the timezone bug is
588         # fixed
589         game = create_game(session=session, 
590                 start_dt=datetime.datetime.utcnow(),
591                 #start_dt=datetime.datetime(
592                     #*time.gmtime(float(game_meta['T']))[:6]), 
593                 server_id=server.server_id, game_type_cd=game_meta['G'], 
594                    map_id=gmap.map_id, match_id=game_meta['I'])
595
596         # find or create a record for each player
597         # and add stats for each if they were present at the end
598         # of the game
599         for player_events in players:
600             if 'n' in player_events:
601                 nick = player_events['n']
602             else:
603                 nick = None
604
605             if 'matches' in player_events and 'scoreboardvalid' \
606                 in player_events:
607                 player = get_or_create_player(session=session, 
608                     hashkey=player_events['P'], nick=nick)
609                 log.debug('Creating stats for %s' % player_events['P'])
610                 create_player_stats(session=session, player=player, game=game, 
611                         player_events=player_events)
612
613         # update elos
614         try:
615             game.process_elos(session)
616         except Exception as e:
617             log.debug('Error (non-fatal): elo processing failed.')
618
619         session.commit()
620         log.debug('Success! Stats recorded.')
621         return Response('200 OK')
622     except Exception as e:
623         session.rollback()
624         return e