4 import pyramid.httpexceptions
\r
7 from pyramid.response import Response
\r
8 from sqlalchemy import Sequence
\r
9 from sqlalchemy.orm.exc import MultipleResultsFound, NoResultFound
\r
10 from xonstat.d0_blind_id import d0_blind_id_verify
\r
11 from xonstat.elo import process_elos
\r
12 from xonstat.models import *
\r
13 from xonstat.util import strip_colors, qfont_decode
\r
16 log = logging.getLogger(__name__)
\r
19 def parse_stats_submission(body):
\r
21 Parses the POST request body for a stats submission
\r
23 # storage vars for the request body
\r
28 for line in body.split('\n'):
\r
30 (key, value) = line.strip().split(' ', 1)
\r
32 # Server (S) and Nick (n) fields can have international characters.
\r
34 value = unicode(value, 'utf-8')
\r
36 if key in 'V' 'T' 'G' 'M' 'S' 'C' 'R' 'W' 'I' 'D' 'O':
\r
37 game_meta[key] = value
\r
40 # if we were working on a player record already, append
\r
41 # it and work on a new one (only set team info)
\r
43 players.append(events)
\r
49 (subkey, subvalue) = value.split(' ', 1)
\r
50 events[subkey] = subvalue
\r
56 # no key/value pair - move on to the next line
\r
59 # add the last player we were working on
\r
61 players.append(events)
\r
63 return (game_meta, players)
\r
66 def is_blank_game(players):
\r
67 """Determine if this is a blank game or not. A blank game is either:
\r
69 1) a match that ended in the warmup stage, where accuracy events are not
\r
72 2) a match in which no player made a positive or negative score AND was
\r
75 r = re.compile(r'acc-.*-cnt-fired')
\r
76 flg_nonzero_score = False
\r
77 flg_acc_events = False
\r
79 for events in players:
\r
80 if is_real_player(events) and played_in_game(events):
\r
81 for (key,value) in events.items():
\r
82 if key == 'scoreboard-score' and value != 0:
\r
83 flg_nonzero_score = True
\r
85 flg_acc_events = True
\r
87 return not (flg_nonzero_score and flg_acc_events)
\r
90 def get_remote_addr(request):
\r
91 """Get the Xonotic server's IP address"""
\r
92 if 'X-Forwarded-For' in request.headers:
\r
93 return request.headers['X-Forwarded-For']
\r
95 return request.remote_addr
\r
98 def is_supported_gametype(gametype):
\r
99 """Whether a gametype is supported or not"""
\r
100 supported_game_types = ('duel', 'dm', 'ctf', 'tdm', 'kh',
\r
101 'ka', 'ft', 'freezetag', 'nb', 'nexball')
\r
103 if gametype in supported_game_types:
\r
109 def verify_request(request):
\r
110 """Verify requests using the d0_blind_id library"""
\r
112 # first determine if we should be verifying or not
\r
113 val_verify_requests = request.registry.settings.get('xonstat.verify_requests', 'true')
\r
114 if val_verify_requests == "true":
\r
115 flg_verify_requests = True
\r
117 flg_verify_requests = False
\r
120 (idfp, status) = d0_blind_id_verify(
\r
121 sig=request.headers['X-D0-Blind-Id-Detached-Signature'],
\r
123 postdata=request.body)
\r
125 log.debug('\nidfp: {0}\nstatus: {1}'.format(idfp, status))
\r
130 if flg_verify_requests and not idfp:
\r
131 log.debug("ERROR: Unverified request")
\r
132 raise pyramid.httpexceptions.HTTPUnauthorized("Unverified request")
\r
134 return (idfp, status)
\r
137 def do_precondition_checks(request, game_meta, raw_players):
\r
138 """Precondition checks for ALL gametypes.
\r
139 These do not require a database connection."""
\r
140 if not is_supported_gametype(game_meta['G']):
\r
141 log.debug("ERROR: Unsupported gametype")
\r
142 raise pyramid.httpexceptions.HTTPOk("OK")
\r
144 if not has_required_metadata(game_meta):
\r
145 log.debug("ERROR: Required game meta missing")
\r
146 raise pyramid.httpexceptions.HTTPUnprocessableEntity("Missing game meta")
\r
148 if not has_minimum_real_players(request.registry.settings, raw_players):
\r
149 log.debug("ERROR: Not enough real players")
\r
150 raise pyramid.httpexceptions.HTTPOk("OK")
\r
152 if is_blank_game(raw_players):
\r
153 log.debug("ERROR: Blank game")
\r
154 raise pyramid.httpexceptions.HTTPOk("OK")
\r
157 def is_real_player(events):
\r
159 Determines if a given set of events correspond with a non-bot
\r
161 if not events['P'].startswith('bot'):
\r
167 def played_in_game(events):
\r
169 Determines if a given set of player events correspond with a player who
\r
170 played in the game (matches 1 and scoreboardvalid 1)
\r
172 if 'matches' in events and 'scoreboardvalid' in events:
\r
178 def num_real_players(player_events):
\r
180 Returns the number of real players (those who played
\r
181 and are on the scoreboard).
\r
185 for events in player_events:
\r
186 if is_real_player(events) and played_in_game(events):
\r
189 return real_players
\r
192 def has_minimum_real_players(settings, player_events):
\r
194 Determines if the collection of player events has enough "real" players
\r
195 to store in the database. The minimum setting comes from the config file
\r
196 under the setting xonstat.minimum_real_players.
\r
198 flg_has_min_real_players = True
\r
201 minimum_required_players = int(
\r
202 settings['xonstat.minimum_required_players'])
\r
204 minimum_required_players = 2
\r
206 real_players = num_real_players(player_events)
\r
208 if real_players < minimum_required_players:
\r
209 flg_has_min_real_players = False
\r
211 return flg_has_min_real_players
\r
214 def has_required_metadata(metadata):
\r
216 Determines if a give set of metadata has enough data to create a game,
\r
217 server, and map with.
\r
219 flg_has_req_metadata = True
\r
221 if 'T' not in metadata or\
\r
222 'G' not in metadata or\
\r
223 'M' not in metadata or\
\r
224 'I' not in metadata or\
\r
225 'S' not in metadata:
\r
226 flg_has_req_metadata = False
\r
228 return flg_has_req_metadata
\r
231 def register_new_nick(session, player, new_nick):
\r
233 Change the player record's nick to the newly found nick. Store the old
\r
234 nick in the player_nicks table for that player.
\r
236 session - SQLAlchemy database session factory
\r
237 player - player record whose nick is changing
\r
238 new_nick - the new nickname
\r
240 # see if that nick already exists
\r
241 stripped_nick = strip_colors(qfont_decode(player.nick))
\r
243 player_nick = session.query(PlayerNick).filter_by(
\r
244 player_id=player.player_id, stripped_nick=stripped_nick).one()
\r
245 except NoResultFound, e:
\r
246 # player_id/stripped_nick not found, create one
\r
247 # but we don't store "Anonymous Player #N"
\r
248 if not re.search('^Anonymous Player #\d+$', player.nick):
\r
249 player_nick = PlayerNick()
\r
250 player_nick.player_id = player.player_id
\r
251 player_nick.stripped_nick = stripped_nick
\r
252 player_nick.nick = player.nick
\r
253 session.add(player_nick)
\r
255 # We change to the new nick regardless
\r
256 player.nick = new_nick
\r
257 player.stripped_nick = strip_colors(qfont_decode(new_nick))
\r
258 session.add(player)
\r
261 def update_fastest_cap(session, player_id, game_id, map_id, captime):
\r
263 Check the fastest cap time for the player and map. If there isn't
\r
264 one, insert one. If there is, check if the passed time is faster.
\r
267 # we don't record fastest cap times for bots or anonymous players
\r
271 # see if a cap entry exists already
\r
272 # then check to see if the new captime is faster
\r
274 cur_fastest_cap = session.query(PlayerCaptime).filter_by(
\r
275 player_id=player_id, map_id=map_id).one()
\r
277 # current captime is faster, so update
\r
278 if captime < cur_fastest_cap.fastest_cap:
\r
279 cur_fastest_cap.fastest_cap = captime
\r
280 cur_fastest_cap.game_id = game_id
\r
281 cur_fastest_cap.create_dt = datetime.datetime.utcnow()
\r
282 session.add(cur_fastest_cap)
\r
284 except NoResultFound, e:
\r
285 # none exists, so insert
\r
286 cur_fastest_cap = PlayerCaptime(player_id, game_id, map_id, captime)
\r
287 session.add(cur_fastest_cap)
\r
291 def get_or_create_server(session=None, name=None, hashkey=None, ip_addr=None,
\r
294 Find a server by name or create one if not found. Parameters:
\r
296 session - SQLAlchemy database session factory
\r
297 name - server name of the server to be found or created
\r
298 hashkey - server hashkey
\r
301 # find one by that name, if it exists
\r
302 server = session.query(Server).filter_by(name=name).one()
\r
304 # store new hashkey
\r
305 if server.hashkey != hashkey:
\r
306 server.hashkey = hashkey
\r
307 session.add(server)
\r
309 # store new IP address
\r
310 if server.ip_addr != ip_addr:
\r
311 server.ip_addr = ip_addr
\r
312 session.add(server)
\r
314 # store new revision
\r
315 if server.revision != revision:
\r
316 server.revision = revision
\r
317 session.add(server)
\r
319 log.debug("Found existing server {0}".format(server.server_id))
\r
321 except MultipleResultsFound, e:
\r
322 # multiple found, so also filter by hashkey
\r
323 server = session.query(Server).filter_by(name=name).\
\r
324 filter_by(hashkey=hashkey).one()
\r
325 log.debug("Found existing server {0}".format(server.server_id))
\r
327 except NoResultFound, e:
\r
328 # not found, create one
\r
329 server = Server(name=name, hashkey=hashkey)
\r
330 session.add(server)
\r
332 log.debug("Created server {0} with hashkey {1}".format(
\r
333 server.server_id, server.hashkey))
\r
338 def get_or_create_map(session=None, name=None):
\r
340 Find a map by name or create one if not found. Parameters:
\r
342 session - SQLAlchemy database session factory
\r
343 name - map name of the map to be found or created
\r
346 # find one by the name, if it exists
\r
347 gmap = session.query(Map).filter_by(name=name).one()
\r
348 log.debug("Found map id {0}: {1}".format(gmap.map_id,
\r
350 except NoResultFound, e:
\r
351 gmap = Map(name=name)
\r
354 log.debug("Created map id {0}: {1}".format(gmap.map_id,
\r
356 except MultipleResultsFound, e:
\r
357 # multiple found, so use the first one but warn
\r
359 gmaps = session.query(Map).filter_by(name=name).order_by(
\r
362 log.debug("Found map id {0}: {1} but found \
\r
363 multiple".format(gmap.map_id, gmap.name))
\r
368 def create_game(session=None, start_dt=None, game_type_cd=None,
\r
369 server_id=None, map_id=None, winner=None, match_id=None,
\r
372 Creates a game. Parameters:
\r
374 session - SQLAlchemy database session factory
\r
375 start_dt - when the game started (datetime object)
\r
376 game_type_cd - the game type of the game being played
\r
377 server_id - server identifier of the server hosting the game
\r
378 map_id - map on which the game was played
\r
379 winner - the team id of the team that won
\r
381 seq = Sequence('games_game_id_seq')
\r
382 game_id = session.execute(seq)
\r
383 game = Game(game_id=game_id, start_dt=start_dt, game_type_cd=game_type_cd,
\r
384 server_id=server_id, map_id=map_id, winner=winner)
\r
385 game.match_id = match_id
\r
388 game.duration = datetime.timedelta(seconds=int(round(float(duration))))
\r
393 session.query(Game).filter(Game.server_id==server_id).\
\r
394 filter(Game.match_id==match_id).one()
\r
396 log.debug("Error: game with same server and match_id found! Ignoring.")
\r
398 # if a game under the same server and match_id found,
\r
399 # this is a duplicate game and can be ignored
\r
400 raise pyramid.httpexceptions.HTTPOk('OK')
\r
401 except NoResultFound, e:
\r
402 # server_id/match_id combination not found. game is ok to insert
\r
405 log.debug("Created game id {0} on server {1}, map {2} at \
\r
406 {3}".format(game.game_id,
\r
407 server_id, map_id, start_dt))
\r
412 def get_or_create_player(session=None, hashkey=None, nick=None):
\r
414 Finds a player by hashkey or creates a new one (along with a
\r
415 corresponding hashkey entry. Parameters:
\r
417 session - SQLAlchemy database session factory
\r
418 hashkey - hashkey of the player to be found or created
\r
419 nick - nick of the player (in case of a first time create)
\r
422 if re.search('^bot#\d+$', hashkey) or re.search('^bot#\d+#', hashkey):
\r
423 player = session.query(Player).filter_by(player_id=1).one()
\r
424 # if we have an untracked player
\r
425 elif re.search('^player#\d+$', hashkey):
\r
426 player = session.query(Player).filter_by(player_id=2).one()
\r
427 # else it is a tracked player
\r
429 # see if the player is already in the database
\r
430 # if not, create one and the hashkey along with it
\r
432 hk = session.query(Hashkey).filter_by(
\r
433 hashkey=hashkey).one()
\r
434 player = session.query(Player).filter_by(
\r
435 player_id=hk.player_id).one()
\r
436 log.debug("Found existing player {0} with hashkey {1}".format(
\r
437 player.player_id, hashkey))
\r
440 session.add(player)
\r
443 # if nick is given to us, use it. If not, use "Anonymous Player"
\r
444 # with a suffix added for uniqueness.
\r
446 player.nick = nick[:128]
\r
447 player.stripped_nick = strip_colors(qfont_decode(nick[:128]))
\r
449 player.nick = "Anonymous Player #{0}".format(player.player_id)
\r
450 player.stripped_nick = player.nick
\r
452 hk = Hashkey(player_id=player.player_id, hashkey=hashkey)
\r
454 log.debug("Created player {0} ({2}) with hashkey {1}".format(
\r
455 player.player_id, hashkey, player.nick.encode('utf-8')))
\r
460 def create_game_stat(session, game_meta, game, server, gmap, player, events):
\r
461 """Game stats handler for all game types"""
\r
463 # this is what we have to do to get partitioned records in - grab the
\r
464 # sequence value first, then insert using the explicit ID (vs autogenerate)
\r
465 seq = Sequence('player_game_stats_player_game_stat_id_seq')
\r
466 pgstat_id = session.execute(seq)
\r
467 pgstat = PlayerGameStat(player_game_stat_id=pgstat_id,
\r
468 create_dt=datetime.datetime.utcnow())
\r
470 # these fields should be on every pgstat record
\r
471 pgstat.game_id = game.game_id
\r
472 pgstat.player_id = player.player_id
\r
473 pgstat.nick = events.get('n', 'Anonymous Player')[:128]
\r
474 pgstat.stripped_nick = strip_colors(qfont_decode(pgstat.nick))
\r
475 pgstat.score = int(events.get('scoreboard-score', 0))
\r
476 pgstat.alivetime = datetime.timedelta(seconds=int(round(float(events.get('alivetime', 0.0)))))
\r
477 pgstat.rank = int(events.get('rank', None))
\r
478 pgstat.scoreboardpos = int(events.get('scoreboardpos', pgstat.rank))
\r
480 # defaults for common game types only
\r
481 if game.game_type_cd == 'dm' or game.game_type_cd == 'tdm' or game.game_type_cd == 'duel':
\r
484 pgstat.suicides = 0
\r
485 elif game.game_type_cd == 'ctf':
\r
487 pgstat.captures = 0
\r
491 pgstat.carrier_frags = 0
\r
493 if pgstat.nick != player.nick \
\r
494 and player.player_id > 2 \
\r
495 and pgstat.nick != 'Anonymous Player':
\r
496 register_new_nick(session, player, pgstat.nick)
\r
500 # gametype-specific stuff is handled here. if passed to us, we store it
\r
501 for (key,value) in events.items():
\r
502 if key == 'wins': wins = True
\r
503 if key == 't': pgstat.team = int(value)
\r
504 if key == 'scoreboard-drops': pgstat.drops = int(value)
\r
505 if key == 'scoreboard-returns': pgstat.returns = int(value)
\r
506 if key == 'scoreboard-fckills': pgstat.carrier_frags = int(value)
\r
507 if key == 'scoreboard-pickups': pgstat.pickups = int(value)
\r
508 if key == 'scoreboard-caps': pgstat.captures = int(value)
\r
509 if key == 'scoreboard-score': pgstat.score = int(value)
\r
510 if key == 'scoreboard-deaths': pgstat.deaths = int(value)
\r
511 if key == 'scoreboard-kills': pgstat.kills = int(value)
\r
512 if key == 'scoreboard-suicides': pgstat.suicides = int(value)
\r
513 if key == 'avglatency': pgstat.avg_latency = float(value)
\r
515 if key == 'scoreboard-captime':
\r
516 pgstat.fastest_cap = datetime.timedelta(seconds=float(value)/100)
\r
517 if game.game_type_cd == 'ctf':
\r
518 update_fastest_cap(session, player.player_id, game.game_id,
\r
519 gmap.map_id, pgstat.fastest_cap)
\r
521 # there is no "winning team" field, so we have to derive it
\r
522 if wins and pgstat.team is not None and game.winner is None:
\r
523 game.winner = pgstat.team
\r
526 session.add(pgstat)
\r
531 def create_weapon_stats(session, game_meta, game, player, pgstat, events):
\r
532 """Weapon stats handler for all game types"""
\r
533 if game.game_type_cd in 'cts' or player.player_id == 1:
\r
538 # Version 1 of stats submissions doubled the data sent.
\r
539 # To counteract this we divide the data by 2 only for
\r
540 # POSTs coming from version 1.
\r
542 version = int(game_meta['V'])
\r
545 log.debug('NOTICE: found a version 1 request, halving the weapon stats...')
\r
551 for (key,value) in events.items():
\r
552 matched = re.search("acc-(.*?)-cnt-fired", key)
\r
554 weapon_cd = matched.group(1)
\r
555 seq = Sequence('player_weapon_stats_player_weapon_stats_id_seq')
\r
556 pwstat_id = session.execute(seq)
\r
557 pwstat = PlayerWeaponStat()
\r
558 pwstat.player_weapon_stats_id = pwstat_id
\r
559 pwstat.player_id = player.player_id
\r
560 pwstat.game_id = game.game_id
\r
561 pwstat.player_game_stat_id = pgstat.player_game_stat_id
\r
562 pwstat.weapon_cd = weapon_cd
\r
565 pwstat.nick = events['n']
\r
567 pwstat.nick = events['P']
\r
569 if 'acc-' + weapon_cd + '-cnt-fired' in events:
\r
570 pwstat.fired = int(round(float(
\r
571 events['acc-' + weapon_cd + '-cnt-fired'])))
\r
572 if 'acc-' + weapon_cd + '-fired' in events:
\r
573 pwstat.max = int(round(float(
\r
574 events['acc-' + weapon_cd + '-fired'])))
\r
575 if 'acc-' + weapon_cd + '-cnt-hit' in events:
\r
576 pwstat.hit = int(round(float(
\r
577 events['acc-' + weapon_cd + '-cnt-hit'])))
\r
578 if 'acc-' + weapon_cd + '-hit' in events:
\r
579 pwstat.actual = int(round(float(
\r
580 events['acc-' + weapon_cd + '-hit'])))
\r
581 if 'acc-' + weapon_cd + '-frags' in events:
\r
582 pwstat.frags = int(round(float(
\r
583 events['acc-' + weapon_cd + '-frags'])))
\r
586 pwstat.fired = pwstat.fired/2
\r
587 pwstat.max = pwstat.max/2
\r
588 pwstat.hit = pwstat.hit/2
\r
589 pwstat.actual = pwstat.actual/2
\r
590 pwstat.frags = pwstat.frags/2
\r
592 session.add(pwstat)
\r
593 pwstats.append(pwstat)
\r
598 def create_elos(session, game):
\r
599 """Elo handler for all game types."""
\r
601 # the following game types do not record elo
\r
602 if game.game_type_cd in 'cts':
\r
606 process_elos(game, session)
\r
607 except Exception as e:
\r
608 log.debug('Error (non-fatal): elo processing failed.')
\r
611 def submit_stats(request):
\r
613 Entry handler for POST stats submissions.
\r
616 # placeholder for the actual session
\r
619 log.debug("\n----- BEGIN REQUEST BODY -----\n" + request.body +
\r
620 "----- END REQUEST BODY -----\n\n")
\r
622 (idfp, status) = verify_request(request)
\r
623 (game_meta, raw_players) = parse_stats_submission(request.body)
\r
624 revision = game_meta.get('R', 'unknown')
\r
625 duration = game_meta.get('D', None)
\r
627 # only players present at the end of the match are eligible for stats
\r
628 raw_players = filter(played_in_game, raw_players)
\r
630 do_precondition_checks(request, game_meta, raw_players)
\r
632 # the "duel" gametype is fake
\r
633 if num_real_players(raw_players) == 2 and game_meta['G'] == 'dm':
\r
634 game_meta['G'] = 'duel'
\r
636 #----------------------------------------------------------------------
\r
637 # Actual setup (inserts/updates) below here
\r
638 #----------------------------------------------------------------------
\r
639 session = DBSession()
\r
641 game_type_cd = game_meta['G']
\r
643 # All game types create Game, Server, Map, and Player records
\r
645 server = get_or_create_server(
\r
648 name = game_meta['S'],
\r
649 revision = revision,
\r
650 ip_addr = get_remote_addr(request))
\r
652 gmap = get_or_create_map(
\r
654 name = game_meta['M'])
\r
656 game = create_game(
\r
658 start_dt = datetime.datetime.utcnow(),
\r
659 server_id = server.server_id,
\r
660 game_type_cd = game_type_cd,
\r
661 map_id = gmap.map_id,
\r
662 match_id = game_meta['I'],
\r
663 duration = duration)
\r
665 for events in raw_players:
\r
666 player = get_or_create_player(
\r
668 hashkey = events['P'],
\r
669 nick = events.get('n', None))
\r
671 pgstat = create_game_stat(session, game_meta, game, server,
\r
672 gmap, player, events)
\r
674 pwstats = create_weapon_stats(session, game_meta, game, player,
\r
677 create_elos(session, game)
\r
680 log.debug('Success! Stats recorded.')
\r
681 return Response('200 OK')
\r
682 except Exception as e:
\r