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