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', 'ca', 'ctf', 'tdm', 'kh',
\r
101 'ka', 'ft', 'freezetag', 'nb', 'nexball', 'lms')
\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 should_do_weapon_stats(game_type_cd):
\r
232 """True of the game type should record weapon stats. False otherwise."""
\r
233 if game_type_cd in 'cts':
\r
239 def should_do_elos(game_type_cd):
\r
240 """True of the game type should process Elos. False otherwise."""
\r
241 elo_game_types = ('duel', 'dm', 'ca', 'ctf', 'tdm', 'kh',
\r
242 'ka', 'ft', 'freezetag')
\r
244 if game_type_cd in elo_game_types:
\r
250 def register_new_nick(session, player, new_nick):
\r
252 Change the player record's nick to the newly found nick. Store the old
\r
253 nick in the player_nicks table for that player.
\r
255 session - SQLAlchemy database session factory
\r
256 player - player record whose nick is changing
\r
257 new_nick - the new nickname
\r
259 # see if that nick already exists
\r
260 stripped_nick = strip_colors(qfont_decode(player.nick))
\r
262 player_nick = session.query(PlayerNick).filter_by(
\r
263 player_id=player.player_id, stripped_nick=stripped_nick).one()
\r
264 except NoResultFound, e:
\r
265 # player_id/stripped_nick not found, create one
\r
266 # but we don't store "Anonymous Player #N"
\r
267 if not re.search('^Anonymous Player #\d+$', player.nick):
\r
268 player_nick = PlayerNick()
\r
269 player_nick.player_id = player.player_id
\r
270 player_nick.stripped_nick = stripped_nick
\r
271 player_nick.nick = player.nick
\r
272 session.add(player_nick)
\r
274 # We change to the new nick regardless
\r
275 player.nick = new_nick
\r
276 player.stripped_nick = strip_colors(qfont_decode(new_nick))
\r
277 session.add(player)
\r
280 def update_fastest_cap(session, player_id, game_id, map_id, captime):
\r
282 Check the fastest cap time for the player and map. If there isn't
\r
283 one, insert one. If there is, check if the passed time is faster.
\r
286 # we don't record fastest cap times for bots or anonymous players
\r
290 # see if a cap entry exists already
\r
291 # then check to see if the new captime is faster
\r
293 cur_fastest_cap = session.query(PlayerCaptime).filter_by(
\r
294 player_id=player_id, map_id=map_id).one()
\r
296 # current captime is faster, so update
\r
297 if captime < cur_fastest_cap.fastest_cap:
\r
298 cur_fastest_cap.fastest_cap = captime
\r
299 cur_fastest_cap.game_id = game_id
\r
300 cur_fastest_cap.create_dt = datetime.datetime.utcnow()
\r
301 session.add(cur_fastest_cap)
\r
303 except NoResultFound, e:
\r
304 # none exists, so insert
\r
305 cur_fastest_cap = PlayerCaptime(player_id, game_id, map_id, captime)
\r
306 session.add(cur_fastest_cap)
\r
310 def get_or_create_server(session=None, name=None, hashkey=None, ip_addr=None,
\r
313 Find a server by name or create one if not found. Parameters:
\r
315 session - SQLAlchemy database session factory
\r
316 name - server name of the server to be found or created
\r
317 hashkey - server hashkey
\r
320 # find one by that name, if it exists
\r
321 server = session.query(Server).filter_by(name=name).one()
\r
323 # store new hashkey
\r
324 if server.hashkey != hashkey:
\r
325 server.hashkey = hashkey
\r
326 session.add(server)
\r
328 # store new IP address
\r
329 if server.ip_addr != ip_addr:
\r
330 server.ip_addr = ip_addr
\r
331 session.add(server)
\r
333 # store new revision
\r
334 if server.revision != revision:
\r
335 server.revision = revision
\r
336 session.add(server)
\r
338 log.debug("Found existing server {0}".format(server.server_id))
\r
340 except MultipleResultsFound, e:
\r
341 # multiple found, so also filter by hashkey
\r
342 server = session.query(Server).filter_by(name=name).\
\r
343 filter_by(hashkey=hashkey).one()
\r
344 log.debug("Found existing server {0}".format(server.server_id))
\r
346 except NoResultFound, e:
\r
347 # not found, create one
\r
348 server = Server(name=name, hashkey=hashkey)
\r
349 session.add(server)
\r
351 log.debug("Created server {0} with hashkey {1}".format(
\r
352 server.server_id, server.hashkey))
\r
357 def get_or_create_map(session=None, name=None):
\r
359 Find a map by name or create one if not found. Parameters:
\r
361 session - SQLAlchemy database session factory
\r
362 name - map name of the map to be found or created
\r
365 # find one by the name, if it exists
\r
366 gmap = session.query(Map).filter_by(name=name).one()
\r
367 log.debug("Found map id {0}: {1}".format(gmap.map_id,
\r
369 except NoResultFound, e:
\r
370 gmap = Map(name=name)
\r
373 log.debug("Created map id {0}: {1}".format(gmap.map_id,
\r
375 except MultipleResultsFound, e:
\r
376 # multiple found, so use the first one but warn
\r
378 gmaps = session.query(Map).filter_by(name=name).order_by(
\r
381 log.debug("Found map id {0}: {1} but found \
\r
382 multiple".format(gmap.map_id, gmap.name))
\r
387 def create_game(session=None, start_dt=None, game_type_cd=None,
\r
388 server_id=None, map_id=None, winner=None, match_id=None,
\r
391 Creates a game. Parameters:
\r
393 session - SQLAlchemy database session factory
\r
394 start_dt - when the game started (datetime object)
\r
395 game_type_cd - the game type of the game being played
\r
396 server_id - server identifier of the server hosting the game
\r
397 map_id - map on which the game was played
\r
398 winner - the team id of the team that won
\r
400 seq = Sequence('games_game_id_seq')
\r
401 game_id = session.execute(seq)
\r
402 game = Game(game_id=game_id, start_dt=start_dt, game_type_cd=game_type_cd,
\r
403 server_id=server_id, map_id=map_id, winner=winner)
\r
404 game.match_id = match_id
\r
407 game.duration = datetime.timedelta(seconds=int(round(float(duration))))
\r
412 session.query(Game).filter(Game.server_id==server_id).\
\r
413 filter(Game.match_id==match_id).one()
\r
415 log.debug("Error: game with same server and match_id found! Ignoring.")
\r
417 # if a game under the same server and match_id found,
\r
418 # this is a duplicate game and can be ignored
\r
419 raise pyramid.httpexceptions.HTTPOk('OK')
\r
420 except NoResultFound, e:
\r
421 # server_id/match_id combination not found. game is ok to insert
\r
424 log.debug("Created game id {0} on server {1}, map {2} at \
\r
425 {3}".format(game.game_id,
\r
426 server_id, map_id, start_dt))
\r
431 def get_or_create_player(session=None, hashkey=None, nick=None):
\r
433 Finds a player by hashkey or creates a new one (along with a
\r
434 corresponding hashkey entry. Parameters:
\r
436 session - SQLAlchemy database session factory
\r
437 hashkey - hashkey of the player to be found or created
\r
438 nick - nick of the player (in case of a first time create)
\r
441 if re.search('^bot#\d+$', hashkey) or re.search('^bot#\d+#', hashkey):
\r
442 player = session.query(Player).filter_by(player_id=1).one()
\r
443 # if we have an untracked player
\r
444 elif re.search('^player#\d+$', hashkey):
\r
445 player = session.query(Player).filter_by(player_id=2).one()
\r
446 # else it is a tracked player
\r
448 # see if the player is already in the database
\r
449 # if not, create one and the hashkey along with it
\r
451 hk = session.query(Hashkey).filter_by(
\r
452 hashkey=hashkey).one()
\r
453 player = session.query(Player).filter_by(
\r
454 player_id=hk.player_id).one()
\r
455 log.debug("Found existing player {0} with hashkey {1}".format(
\r
456 player.player_id, hashkey))
\r
459 session.add(player)
\r
462 # if nick is given to us, use it. If not, use "Anonymous Player"
\r
463 # with a suffix added for uniqueness.
\r
465 player.nick = nick[:128]
\r
466 player.stripped_nick = strip_colors(qfont_decode(nick[:128]))
\r
468 player.nick = "Anonymous Player #{0}".format(player.player_id)
\r
469 player.stripped_nick = player.nick
\r
471 hk = Hashkey(player_id=player.player_id, hashkey=hashkey)
\r
473 log.debug("Created player {0} ({2}) with hashkey {1}".format(
\r
474 player.player_id, hashkey, player.nick.encode('utf-8')))
\r
479 def create_game_stat(session, game_meta, game, server, gmap, player, events):
\r
480 """Game stats handler for all game types"""
\r
482 # this is what we have to do to get partitioned records in - grab the
\r
483 # sequence value first, then insert using the explicit ID (vs autogenerate)
\r
484 seq = Sequence('player_game_stats_player_game_stat_id_seq')
\r
485 pgstat_id = session.execute(seq)
\r
486 pgstat = PlayerGameStat(player_game_stat_id=pgstat_id,
\r
487 create_dt=datetime.datetime.utcnow())
\r
489 # these fields should be on every pgstat record
\r
490 pgstat.game_id = game.game_id
\r
491 pgstat.player_id = player.player_id
\r
492 pgstat.nick = events.get('n', 'Anonymous Player')[:128]
\r
493 pgstat.stripped_nick = strip_colors(qfont_decode(pgstat.nick))
\r
494 pgstat.score = int(round(float(events.get('scoreboard-score', 0))))
\r
495 pgstat.alivetime = datetime.timedelta(seconds=int(round(float(events.get('alivetime', 0.0)))))
\r
496 pgstat.rank = int(events.get('rank', None))
\r
497 pgstat.scoreboardpos = int(events.get('scoreboardpos', pgstat.rank))
\r
499 # defaults for common game types only
\r
500 if game.game_type_cd == 'dm' or game.game_type_cd == 'tdm' or game.game_type_cd == 'duel':
\r
503 pgstat.suicides = 0
\r
504 elif game.game_type_cd == 'ctf':
\r
506 pgstat.captures = 0
\r
510 pgstat.carrier_frags = 0
\r
512 if pgstat.nick != player.nick \
\r
513 and player.player_id > 2 \
\r
514 and pgstat.nick != 'Anonymous Player':
\r
515 register_new_nick(session, player, pgstat.nick)
\r
519 # gametype-specific stuff is handled here. if passed to us, we store it
\r
520 for (key,value) in events.items():
\r
521 if key == 'wins': wins = True
\r
522 if key == 't': pgstat.team = int(value)
\r
523 if key == 'scoreboard-drops': pgstat.drops = int(value)
\r
524 if key == 'scoreboard-returns': pgstat.returns = int(value)
\r
525 if key == 'scoreboard-fckills': pgstat.carrier_frags = int(value)
\r
526 if key == 'scoreboard-pickups': pgstat.pickups = int(value)
\r
527 if key == 'scoreboard-caps': pgstat.captures = int(value)
\r
528 if key == 'scoreboard-score': pgstat.score = int(round(float(value)))
\r
529 if key == 'scoreboard-deaths': pgstat.deaths = int(value)
\r
530 if key == 'scoreboard-kills': pgstat.kills = int(value)
\r
531 if key == 'scoreboard-suicides': pgstat.suicides = int(value)
\r
532 if key == 'avglatency': pgstat.avg_latency = float(value)
\r
534 if key == 'scoreboard-captime':
\r
535 pgstat.fastest_cap = datetime.timedelta(seconds=float(value)/100)
\r
536 if game.game_type_cd == 'ctf':
\r
537 update_fastest_cap(session, player.player_id, game.game_id,
\r
538 gmap.map_id, pgstat.fastest_cap)
\r
540 # there is no "winning team" field, so we have to derive it
\r
541 if wins and pgstat.team is not None and game.winner is None:
\r
542 game.winner = pgstat.team
\r
545 session.add(pgstat)
\r
550 def create_weapon_stats(session, game_meta, game, player, pgstat, events):
\r
551 """Weapon stats handler for all game types"""
\r
554 # Version 1 of stats submissions doubled the data sent.
\r
555 # To counteract this we divide the data by 2 only for
\r
556 # POSTs coming from version 1.
\r
558 version = int(game_meta['V'])
\r
561 log.debug('NOTICE: found a version 1 request, halving the weapon stats...')
\r
567 for (key,value) in events.items():
\r
568 matched = re.search("acc-(.*?)-cnt-fired", key)
\r
570 weapon_cd = matched.group(1)
\r
571 seq = Sequence('player_weapon_stats_player_weapon_stats_id_seq')
\r
572 pwstat_id = session.execute(seq)
\r
573 pwstat = PlayerWeaponStat()
\r
574 pwstat.player_weapon_stats_id = pwstat_id
\r
575 pwstat.player_id = player.player_id
\r
576 pwstat.game_id = game.game_id
\r
577 pwstat.player_game_stat_id = pgstat.player_game_stat_id
\r
578 pwstat.weapon_cd = weapon_cd
\r
581 pwstat.nick = events['n']
\r
583 pwstat.nick = events['P']
\r
585 if 'acc-' + weapon_cd + '-cnt-fired' in events:
\r
586 pwstat.fired = int(round(float(
\r
587 events['acc-' + weapon_cd + '-cnt-fired'])))
\r
588 if 'acc-' + weapon_cd + '-fired' in events:
\r
589 pwstat.max = int(round(float(
\r
590 events['acc-' + weapon_cd + '-fired'])))
\r
591 if 'acc-' + weapon_cd + '-cnt-hit' in events:
\r
592 pwstat.hit = int(round(float(
\r
593 events['acc-' + weapon_cd + '-cnt-hit'])))
\r
594 if 'acc-' + weapon_cd + '-hit' in events:
\r
595 pwstat.actual = int(round(float(
\r
596 events['acc-' + weapon_cd + '-hit'])))
\r
597 if 'acc-' + weapon_cd + '-frags' in events:
\r
598 pwstat.frags = int(round(float(
\r
599 events['acc-' + weapon_cd + '-frags'])))
\r
602 pwstat.fired = pwstat.fired/2
\r
603 pwstat.max = pwstat.max/2
\r
604 pwstat.hit = pwstat.hit/2
\r
605 pwstat.actual = pwstat.actual/2
\r
606 pwstat.frags = pwstat.frags/2
\r
608 session.add(pwstat)
\r
609 pwstats.append(pwstat)
\r
614 def create_elos(session, game):
\r
615 """Elo handler for all game types."""
\r
617 process_elos(game, session)
\r
618 except Exception as e:
\r
619 log.debug('Error (non-fatal): elo processing failed.')
\r
622 def submit_stats(request):
\r
624 Entry handler for POST stats submissions.
\r
627 # placeholder for the actual session
\r
630 log.debug("\n----- BEGIN REQUEST BODY -----\n" + request.body +
\r
631 "----- END REQUEST BODY -----\n\n")
\r
633 (idfp, status) = verify_request(request)
\r
634 (game_meta, raw_players) = parse_stats_submission(request.body)
\r
635 revision = game_meta.get('R', 'unknown')
\r
636 duration = game_meta.get('D', None)
\r
638 # only players present at the end of the match are eligible for stats
\r
639 raw_players = filter(played_in_game, raw_players)
\r
641 do_precondition_checks(request, game_meta, raw_players)
\r
643 # the "duel" gametype is fake
\r
644 if len(raw_players) == 2 \
\r
645 and num_real_players(raw_players) == 2 \
\r
646 and game_meta['G'] == 'dm':
\r
647 game_meta['G'] = 'duel'
\r
649 #----------------------------------------------------------------------
\r
650 # Actual setup (inserts/updates) below here
\r
651 #----------------------------------------------------------------------
\r
652 session = DBSession()
\r
654 game_type_cd = game_meta['G']
\r
656 # All game types create Game, Server, Map, and Player records
\r
658 server = get_or_create_server(
\r
661 name = game_meta['S'],
\r
662 revision = revision,
\r
663 ip_addr = get_remote_addr(request))
\r
665 gmap = get_or_create_map(
\r
667 name = game_meta['M'])
\r
669 game = create_game(
\r
671 start_dt = datetime.datetime.utcnow(),
\r
672 server_id = server.server_id,
\r
673 game_type_cd = game_type_cd,
\r
674 map_id = gmap.map_id,
\r
675 match_id = game_meta['I'],
\r
676 duration = duration)
\r
678 for events in raw_players:
\r
679 player = get_or_create_player(
\r
681 hashkey = events['P'],
\r
682 nick = events.get('n', None))
\r
684 pgstat = create_game_stat(session, game_meta, game, server,
\r
685 gmap, player, events)
\r
687 if should_do_weapon_stats(game_type_cd) and player.player_id > 1:
\r
688 pwstats = create_weapon_stats(session, game_meta, game, player,
\r
691 if should_do_elos(game_type_cd):
\r
692 create_elos(session, game)
\r
695 log.debug('Success! Stats recorded.')
\r
696 return Response('200 OK')
\r
697 except Exception as e:
\r