]> de.git.xonotic.org Git - xonotic/xonstat.git/blob - xonstat/views/submission.py
dos2unix file conversions for everything
[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         # if a game under the same server and match_id found, 
277         # this is a duplicate game and can be ignored
278         raise pyramid.httpexceptions.HTTPOk('OK')
279     except NoResultFound, e:
280         # server_id/match_id combination not found. game is ok to insert
281         session.add(game)
282         log.debug("Created game id {0} on server {1}, map {2} at \
283                 {3}".format(game.game_id, 
284                     server_id, map_id, start_dt))
285
286     return game
287
288
289 def get_or_create_player(session=None, hashkey=None, nick=None):
290     """
291     Finds a player by hashkey or creates a new one (along with a
292     corresponding hashkey entry. Parameters:
293
294     session - SQLAlchemy database session factory
295     hashkey - hashkey of the player to be found or created
296     nick - nick of the player (in case of a first time create)
297     """
298     # if we have a bot
299     if re.search('^bot#\d+$', hashkey) or re.search('^bot#\d+#', hashkey):
300         player = session.query(Player).filter_by(player_id=1).one()
301     # if we have an untracked player
302     elif re.search('^player#\d+$', hashkey):
303         player = session.query(Player).filter_by(player_id=2).one()
304     # else it is a tracked player
305     else:
306         # see if the player is already in the database
307         # if not, create one and the hashkey along with it
308         try:
309             hk = session.query(Hashkey).filter_by(
310                     hashkey=hashkey).one()
311             player = session.query(Player).filter_by(
312                     player_id=hk.player_id).one()
313             log.debug("Found existing player {0} with hashkey {1}".format(
314                 player.player_id, hashkey))
315         except:
316             player = Player()
317             session.add(player)
318             session.flush()
319
320             # if nick is given to us, use it. If not, use "Anonymous Player"
321             # with a suffix added for uniqueness.
322             if nick:
323                 player.nick = nick[:128]
324                 player.stripped_nick = strip_colors(nick[:128])
325             else:
326                 player.nick = "Anonymous Player #{0}".format(player.player_id)
327                 player.stripped_nick = player.nick
328
329             hk = Hashkey(player_id=player.player_id, hashkey=hashkey)
330             session.add(hk)
331             log.debug("Created player {0} ({2}) with hashkey {1}".format(
332                 player.player_id, hashkey, player.nick.encode('utf-8')))
333
334     return player
335
336 def create_player_game_stat(session=None, player=None, 
337         game=None, player_events=None):
338     """
339     Creates game statistics for a given player in a given game. Parameters:
340
341     session - SQLAlchemy session factory
342     player - Player record of the player who owns the stats
343     game - Game record for the game to which the stats pertain
344     player_events - dictionary for the actual stats that need to be transformed
345     """
346
347     # in here setup default values (e.g. if game type is CTF then
348     # set kills=0, score=0, captures=0, pickups=0, fckills=0, etc
349     # TODO: use game's create date here instead of now()
350     seq = Sequence('player_game_stats_player_game_stat_id_seq')
351     pgstat_id = session.execute(seq)
352     pgstat = PlayerGameStat(player_game_stat_id=pgstat_id, 
353             create_dt=datetime.datetime.utcnow())
354
355     # set player id from player record
356     pgstat.player_id = player.player_id
357
358     #set game id from game record
359     pgstat.game_id = game.game_id
360
361     # all games have a score
362     pgstat.score = 0
363
364     if game.game_type_cd == 'dm' or game.game_type_cd == 'tdm' or game.game_type_cd == 'duel':
365         pgstat.kills = 0
366         pgstat.deaths = 0
367         pgstat.suicides = 0
368     elif game.game_type_cd == 'ctf':
369         pgstat.kills = 0
370         pgstat.captures = 0
371         pgstat.pickups = 0
372         pgstat.drops = 0
373         pgstat.returns = 0
374         pgstat.carrier_frags = 0
375
376     for (key,value) in player_events.items():
377         if key == 'n': pgstat.nick = value[:128]
378         if key == 't': pgstat.team = value
379         if key == 'rank': pgstat.rank = value
380         if key == 'alivetime': 
381             pgstat.alivetime = datetime.timedelta(seconds=int(round(float(value))))
382         if key == 'scoreboard-drops': pgstat.drops = value
383         if key == 'scoreboard-returns': pgstat.returns = value
384         if key == 'scoreboard-fckills': pgstat.carrier_frags = value
385         if key == 'scoreboard-pickups': pgstat.pickups = value
386         if key == 'scoreboard-caps': pgstat.captures = value
387         if key == 'scoreboard-score': pgstat.score = value
388         if key == 'scoreboard-deaths': pgstat.deaths = value
389         if key == 'scoreboard-kills': pgstat.kills = value
390         if key == 'scoreboard-suicides': pgstat.suicides = value
391
392     # check to see if we had a name, and if
393     # not use an anonymous handle
394     if pgstat.nick == None:
395         pgstat.nick = "Anonymous Player"
396         pgstat.stripped_nick = "Anonymous Player"
397
398     # otherwise process a nick change
399     elif pgstat.nick != player.nick and player.player_id > 2:
400         register_new_nick(session, player, pgstat.nick)
401
402     # if the player is ranked #1 and it is a team game, set the game's winner
403     # to be the team of that player
404     # FIXME: this is a hack, should be using the 'W' field (not present)
405     if pgstat.rank == '1' and pgstat.team:
406         game.winner = pgstat.team
407         session.add(game)
408
409     session.add(pgstat)
410
411     return pgstat
412
413
414 def create_player_weapon_stats(session=None, player=None, 
415         game=None, pgstat=None, player_events=None):
416     """
417     Creates accuracy records for each weapon used by a given player in a
418     given game. Parameters:
419
420     session - SQLAlchemy session factory object
421     player - Player record who owns the weapon stats
422     game - Game record in which the stats were created
423     pgstat - Corresponding PlayerGameStat record for these weapon stats
424     player_events - dictionary containing the raw weapon values that need to be
425         transformed
426     """
427     pwstats = []
428
429     for (key,value) in player_events.items():
430         matched = re.search("acc-(.*?)-cnt-fired", key)
431         if matched:
432             weapon_cd = matched.group(1)
433             seq = Sequence('player_weapon_stats_player_weapon_stats_id_seq')
434             pwstat_id = session.execute(seq)
435             pwstat = PlayerWeaponStat()
436             pwstat.player_weapon_stats_id = pwstat_id
437             pwstat.player_id = player.player_id
438             pwstat.game_id = game.game_id
439             pwstat.player_game_stat_id = pgstat.player_game_stat_id
440             pwstat.weapon_cd = weapon_cd
441
442             if 'n' in player_events:
443                 pwstat.nick = player_events['n']
444             else:
445                 pwstat.nick = player_events['P']
446
447             if 'acc-' + weapon_cd + '-cnt-fired' in player_events:
448                 pwstat.fired = int(round(float(
449                         player_events['acc-' + weapon_cd + '-cnt-fired'])))
450             if 'acc-' + weapon_cd + '-fired' in player_events:
451                 pwstat.max = int(round(float(
452                         player_events['acc-' + weapon_cd + '-fired'])))
453             if 'acc-' + weapon_cd + '-cnt-hit' in player_events:
454                 pwstat.hit = int(round(float(
455                         player_events['acc-' + weapon_cd + '-cnt-hit'])))
456             if 'acc-' + weapon_cd + '-hit' in player_events:
457                 pwstat.actual = int(round(float(
458                         player_events['acc-' + weapon_cd + '-hit'])))
459             if 'acc-' + weapon_cd + '-frags' in player_events:
460                 pwstat.frags = int(round(float(
461                         player_events['acc-' + weapon_cd + '-frags'])))
462
463             session.add(pwstat)
464             pwstats.append(pwstat)
465
466     return pwstats
467
468
469 def parse_body(request):
470     """
471     Parses the POST request body for a stats submission
472     """
473     # storage vars for the request body
474     game_meta = {}
475     player_events = {}
476     current_team = None
477     players = []
478
479     for line in request.body.split('\n'):
480         try:
481             (key, value) = line.strip().split(' ', 1)
482
483             # Server (S) and Nick (n) fields can have international characters.
484             # We convert to UTF-8.
485             if key in 'S' 'n':
486                 value = unicode(value, 'utf-8')
487
488             if key in 'V' 'T' 'G' 'M' 'S' 'C' 'R' 'W' 'I':
489                 game_meta[key] = value
490
491             if key == 'P':
492                 # if we were working on a player record already, append
493                 # it and work on a new one (only set team info)
494                 if len(player_events) != 0:
495                     players.append(player_events)
496                     player_events = {}
497
498                 player_events[key] = value
499
500             if key == 'e':
501                 (subkey, subvalue) = value.split(' ', 1)
502                 player_events[subkey] = subvalue
503             if key == 'n':
504                 player_events[key] = value
505             if key == 't':
506                 player_events[key] = value
507         except:
508             # no key/value pair - move on to the next line
509             pass
510
511     # add the last player we were working on
512     if len(player_events) > 0:
513         players.append(player_events)
514
515     return (game_meta, players)
516
517
518 def create_player_stats(session=None, player=None, game=None, 
519         player_events=None):
520     """
521     Creates player game and weapon stats according to what type of player
522     """
523     pgstat = create_player_game_stat(session=session, 
524         player=player, game=game, player_events=player_events)
525
526     #TODO: put this into a config setting in the ini file?
527     if not re.search('^bot#\d+$', player_events['P']):
528         create_player_weapon_stats(session=session, 
529             player=player, game=game, pgstat=pgstat,
530             player_events=player_events)
531
532
533 def stats_submit(request):
534     """
535     Entry handler for POST stats submissions.
536     """
537     try:
538         session = DBSession()
539
540         log.debug("\n----- BEGIN REQUEST BODY -----\n" + request.body +
541                 "----- END REQUEST BODY -----\n\n")
542
543         (idfp, status) = verify_request(request)
544         if not idfp:
545             log.debug("ERROR: Unverified request")
546             raise pyramid.httpexceptions.HTTPUnauthorized("Unverified request")
547
548         (game_meta, players) = parse_body(request)  
549
550         if not has_required_metadata(game_meta):
551             log.debug("ERROR: Required game meta missing")
552             raise pyramid.httpexceptions.HTTPUnprocessableEntity("Missing game meta")
553
554         if not is_supported_gametype(game_meta['G']):
555             log.debug("ERROR: Unsupported gametype")
556             raise pyramid.httpexceptions.HTTPOk("OK")
557
558         if not has_minimum_real_players(request.registry.settings, players):
559             log.debug("ERROR: Not enough real players")
560             raise pyramid.httpexceptions.HTTPOk("OK")
561
562         if is_blank_game(players):
563             log.debug("ERROR: Blank game")
564             raise pyramid.httpexceptions.HTTPOk("OK")
565
566         # the "duel" gametype is fake
567         if num_real_players(players, count_bots=True) == 2 and \
568                 game_meta['G'] == 'dm':
569             game_meta['G'] = 'duel'
570
571
572         # fix for DTG, who didn't #ifdef WATERMARK to set the revision info
573         try:
574             revision = game_meta['R']
575         except:
576             revision = "unknown"
577
578         server = get_or_create_server(session=session, hashkey=idfp, 
579                 name=game_meta['S'], revision=revision,
580                 ip_addr=get_remote_addr(request))
581
582         gmap = get_or_create_map(session=session, name=game_meta['M'])
583
584         # FIXME: use the gmtime instead of utcnow() when the timezone bug is
585         # fixed
586         game = create_game(session=session, 
587                 start_dt=datetime.datetime.utcnow(),
588                 #start_dt=datetime.datetime(
589                     #*time.gmtime(float(game_meta['T']))[:6]), 
590                 server_id=server.server_id, game_type_cd=game_meta['G'], 
591                    map_id=gmap.map_id, match_id=game_meta['I'])
592
593         # find or create a record for each player
594         # and add stats for each if they were present at the end
595         # of the game
596         for player_events in players:
597             if 'n' in player_events:
598                 nick = player_events['n']
599             else:
600                 nick = None
601
602             if 'matches' in player_events and 'scoreboardvalid' \
603                 in player_events:
604                 player = get_or_create_player(session=session, 
605                     hashkey=player_events['P'], nick=nick)
606                 log.debug('Creating stats for %s' % player_events['P'])
607                 create_player_stats(session=session, player=player, game=game, 
608                         player_events=player_events)
609
610         # update elos
611         try:
612             game.process_elos(session)
613         except Exception as e:
614             log.debug('Error (non-fatal): elo processing failed.')
615
616         session.commit()
617         log.debug('Success! Stats recorded.')
618         return Response('200 OK')
619     except Exception as e:
620         session.rollback()
621         return e