4 import pyramid.httpexceptions
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
15 log = logging.getLogger(__name__)
18 def is_blank_game(players):
19 """Determine if this is a blank game or not. A blank game is either:
21 1) a match that ended in the warmup stage, where accuracy events are not
24 2) a match in which no player made a positive or negative score AND was
27 r = re.compile(r'acc-.*-cnt-fired')
28 flg_nonzero_score = False
29 flg_acc_events = False
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
39 return not (flg_nonzero_score and flg_acc_events)
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']
46 return request.remote_addr
49 def is_supported_gametype(gametype):
50 """Whether a gametype is supported or not"""
53 if gametype == 'cts' or gametype == 'lms':
59 def verify_request(request):
61 (idfp, status) = d0_blind_id_verify(
62 sig=request.headers['X-D0-Blind-Id-Detached-Signature'],
64 postdata=request.body)
66 log.debug('\nidfp: {0}\nstatus: {1}'.format(idfp, status))
74 def num_real_players(player_events, count_bots=False):
76 Returns the number of real players (those who played
77 and are on the scoreboard).
81 for events in player_events:
82 if is_real_player(events, count_bots):
88 def has_minimum_real_players(settings, player_events):
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.
94 flg_has_min_real_players = True
97 minimum_required_players = int(
98 settings['xonstat.minimum_required_players'])
100 minimum_required_players = 2
102 real_players = num_real_players(player_events)
104 if real_players < minimum_required_players:
105 flg_has_min_real_players = False
107 return flg_has_min_real_players
110 def verify_requests(settings):
112 Determines whether or not to verify requests using the blind_id algorithm
115 val_verify_requests = settings['xonstat.verify_requests']
116 if val_verify_requests == "true":
117 flg_verify_requests = True
119 flg_verify_requests = False
121 flg_verify_requests = True
123 return flg_verify_requests
126 def has_required_metadata(metadata):
128 Determines if a give set of metadata has enough data to create a game,
129 server, and map with.
131 flg_has_req_metadata = True
133 if 'T' not in metadata or\
134 'G' not in metadata or\
135 'M' not in metadata or\
136 'I' not in metadata or\
138 flg_has_req_metadata = False
140 return flg_has_req_metadata
143 def is_real_player(events, count_bots=False):
145 Determines if a given set of player events correspond with a player who
147 1) is not a bot (P event does not look like a bot)
148 2) played in the game (matches 1)
149 3) was present at the end of the game (scoreboardvalid 1)
151 Returns True if the player meets the above conditions, and false otherwise.
155 # removing 'joins' here due to bug, but it should be here
156 if 'matches' in events and 'scoreboardvalid' in events:
157 if (events['P'].startswith('bot') and count_bots) or \
158 not events['P'].startswith('bot'):
164 def register_new_nick(session, player, new_nick):
166 Change the player record's nick to the newly found nick. Store the old
167 nick in the player_nicks table for that player.
169 session - SQLAlchemy database session factory
170 player - player record whose nick is changing
171 new_nick - the new nickname
173 # see if that nick already exists
174 stripped_nick = strip_colors(qfont_decode(player.nick))
176 player_nick = session.query(PlayerNick).filter_by(
177 player_id=player.player_id, stripped_nick=stripped_nick).one()
178 except NoResultFound, e:
179 # player_id/stripped_nick not found, create one
180 # but we don't store "Anonymous Player #N"
181 if not re.search('^Anonymous Player #\d+$', player.nick):
182 player_nick = PlayerNick()
183 player_nick.player_id = player.player_id
184 player_nick.stripped_nick = stripped_nick
185 player_nick.nick = player.nick
186 session.add(player_nick)
188 # We change to the new nick regardless
189 player.nick = new_nick
190 player.stripped_nick = strip_colors(qfont_decode(new_nick))
194 def update_fastest_cap(session, player_id, game_id, map_id, captime):
196 Check the fastest cap time for the player and map. If there isn't
197 one, insert one. If there is, check if the passed time is faster.
200 # we don't record fastest cap times for bots or anonymous players
204 # see if a cap entry exists already
205 # then check to see if the new captime is faster
207 cur_fastest_cap = session.query(PlayerCaptime).filter_by(
208 player_id=player_id, map_id=map_id).one()
210 # current captime is faster, so update
211 if captime < cur_fastest_cap.fastest_cap:
212 cur_fastest_cap.fastest_cap = captime
213 cur_fastest_cap.game_id = game_id
214 cur_fastest_cap.create_dt = datetime.datetime.utcnow()
215 session.add(cur_fastest_cap)
217 except NoResultFound, e:
218 # none exists, so insert
219 cur_fastest_cap = PlayerCaptime(player_id, game_id, map_id, captime)
220 session.add(cur_fastest_cap)
224 def get_or_create_server(session=None, name=None, hashkey=None, ip_addr=None,
227 Find a server by name or create one if not found. Parameters:
229 session - SQLAlchemy database session factory
230 name - server name of the server to be found or created
231 hashkey - server hashkey
234 # find one by that name, if it exists
235 server = session.query(Server).filter_by(name=name).one()
238 if server.hashkey != hashkey:
239 server.hashkey = hashkey
242 # store new IP address
243 if server.ip_addr != ip_addr:
244 server.ip_addr = ip_addr
248 if server.revision != revision:
249 server.revision = revision
252 log.debug("Found existing server {0}".format(server.server_id))
254 except MultipleResultsFound, e:
255 # multiple found, so also filter by hashkey
256 server = session.query(Server).filter_by(name=name).\
257 filter_by(hashkey=hashkey).one()
258 log.debug("Found existing server {0}".format(server.server_id))
260 except NoResultFound, e:
261 # not found, create one
262 server = Server(name=name, hashkey=hashkey)
265 log.debug("Created server {0} with hashkey {1}".format(
266 server.server_id, server.hashkey))
271 def get_or_create_map(session=None, name=None):
273 Find a map by name or create one if not found. Parameters:
275 session - SQLAlchemy database session factory
276 name - map name of the map to be found or created
279 # find one by the name, if it exists
280 gmap = session.query(Map).filter_by(name=name).one()
281 log.debug("Found map id {0}: {1}".format(gmap.map_id,
283 except NoResultFound, e:
284 gmap = Map(name=name)
287 log.debug("Created map id {0}: {1}".format(gmap.map_id,
289 except MultipleResultsFound, e:
290 # multiple found, so use the first one but warn
292 gmaps = session.query(Map).filter_by(name=name).order_by(
295 log.debug("Found map id {0}: {1} but found \
296 multiple".format(gmap.map_id, gmap.name))
301 def create_game(session=None, start_dt=None, game_type_cd=None,
302 server_id=None, map_id=None, winner=None, match_id=None):
304 Creates a game. Parameters:
306 session - SQLAlchemy database session factory
307 start_dt - when the game started (datetime object)
308 game_type_cd - the game type of the game being played
309 server_id - server identifier of the server hosting the game
310 map_id - map on which the game was played
311 winner - the team id of the team that won
313 seq = Sequence('games_game_id_seq')
314 game_id = session.execute(seq)
315 game = Game(game_id=game_id, start_dt=start_dt, game_type_cd=game_type_cd,
316 server_id=server_id, map_id=map_id, winner=winner)
317 game.match_id = match_id
320 session.query(Game).filter(Game.server_id==server_id).\
321 filter(Game.match_id==match_id).one()
323 log.debug("Error: game with same server and match_id found! Ignoring.")
325 # if a game under the same server and match_id found,
326 # this is a duplicate game and can be ignored
327 raise pyramid.httpexceptions.HTTPOk('OK')
328 except NoResultFound, e:
329 # server_id/match_id combination not found. game is ok to insert
332 log.debug("Created game id {0} on server {1}, map {2} at \
333 {3}".format(game.game_id,
334 server_id, map_id, start_dt))
339 def get_or_create_player(session=None, hashkey=None, nick=None):
341 Finds a player by hashkey or creates a new one (along with a
342 corresponding hashkey entry. Parameters:
344 session - SQLAlchemy database session factory
345 hashkey - hashkey of the player to be found or created
346 nick - nick of the player (in case of a first time create)
349 if re.search('^bot#\d+$', hashkey) or re.search('^bot#\d+#', hashkey):
350 player = session.query(Player).filter_by(player_id=1).one()
351 # if we have an untracked player
352 elif re.search('^player#\d+$', hashkey):
353 player = session.query(Player).filter_by(player_id=2).one()
354 # else it is a tracked player
356 # see if the player is already in the database
357 # if not, create one and the hashkey along with it
359 hk = session.query(Hashkey).filter_by(
360 hashkey=hashkey).one()
361 player = session.query(Player).filter_by(
362 player_id=hk.player_id).one()
363 log.debug("Found existing player {0} with hashkey {1}".format(
364 player.player_id, hashkey))
370 # if nick is given to us, use it. If not, use "Anonymous Player"
371 # with a suffix added for uniqueness.
373 player.nick = nick[:128]
374 player.stripped_nick = strip_colors(qfont_decode(nick[:128]))
376 player.nick = "Anonymous Player #{0}".format(player.player_id)
377 player.stripped_nick = player.nick
379 hk = Hashkey(player_id=player.player_id, hashkey=hashkey)
381 log.debug("Created player {0} ({2}) with hashkey {1}".format(
382 player.player_id, hashkey, player.nick.encode('utf-8')))
386 def create_player_game_stat(session=None, player=None,
387 game=None, player_events=None):
389 Creates game statistics for a given player in a given game. Parameters:
391 session - SQLAlchemy session factory
392 player - Player record of the player who owns the stats
393 game - Game record for the game to which the stats pertain
394 player_events - dictionary for the actual stats that need to be transformed
397 # in here setup default values (e.g. if game type is CTF then
398 # set kills=0, score=0, captures=0, pickups=0, fckills=0, etc
399 # TODO: use game's create date here instead of now()
400 seq = Sequence('player_game_stats_player_game_stat_id_seq')
401 pgstat_id = session.execute(seq)
402 pgstat = PlayerGameStat(player_game_stat_id=pgstat_id,
403 create_dt=datetime.datetime.utcnow())
405 # set player id from player record
406 pgstat.player_id = player.player_id
408 #set game id from game record
409 pgstat.game_id = game.game_id
411 # all games have a score and every player has an alivetime
413 pgstat.alivetime = datetime.timedelta(seconds=0)
415 if game.game_type_cd == 'dm' or game.game_type_cd == 'tdm' or game.game_type_cd == 'duel':
419 elif game.game_type_cd == 'ctf':
425 pgstat.carrier_frags = 0
427 for (key,value) in player_events.items():
429 pgstat.nick = value[:128]
430 pgstat.stripped_nick = strip_colors(qfont_decode(pgstat.nick))
431 if key == 't': pgstat.team = int(value)
432 if key == 'rank': pgstat.rank = int(value)
433 if key == 'alivetime':
434 pgstat.alivetime = datetime.timedelta(seconds=int(round(float(value))))
435 if key == 'scoreboard-drops': pgstat.drops = int(value)
436 if key == 'scoreboard-returns': pgstat.returns = int(value)
437 if key == 'scoreboard-fckills': pgstat.carrier_frags = int(value)
438 if key == 'scoreboard-pickups': pgstat.pickups = int(value)
439 if key == 'scoreboard-caps': pgstat.captures = int(value)
440 if key == 'scoreboard-score': pgstat.score = int(value)
441 if key == 'scoreboard-deaths': pgstat.deaths = int(value)
442 if key == 'scoreboard-kills': pgstat.kills = int(value)
443 if key == 'scoreboard-suicides': pgstat.suicides = int(value)
444 if key == 'scoreboard-captime':
445 pgstat.fastest_cap = datetime.timedelta(seconds=float(value)/100)
446 if key == 'avglatency': pgstat.avg_latency = float(value)
448 # check to see if we had a name, and if
449 # not use an anonymous handle
450 if pgstat.nick == None:
451 pgstat.nick = "Anonymous Player"
452 pgstat.stripped_nick = "Anonymous Player"
454 # otherwise process a nick change
455 elif pgstat.nick != player.nick and player.player_id > 2:
456 register_new_nick(session, player, pgstat.nick)
458 # if the player is ranked #1 and it is a team game, set the game's winner
459 # to be the team of that player
460 # FIXME: this is a hack, should be using the 'W' field (not present)
461 if pgstat.rank == 1 and pgstat.team:
462 game.winner = pgstat.team
470 def create_player_weapon_stats(session=None, player=None,
471 game=None, pgstat=None, player_events=None, game_meta=None):
473 Creates accuracy records for each weapon used by a given player in a
474 given game. Parameters:
476 session - SQLAlchemy session factory object
477 player - Player record who owns the weapon stats
478 game - Game record in which the stats were created
479 pgstat - Corresponding PlayerGameStat record for these weapon stats
480 player_events - dictionary containing the raw weapon values that need to be
482 game_meta - dictionary of game metadata (only used for stats version info)
486 # Version 1 of stats submissions doubled the data sent.
487 # To counteract this we divide the data by 2 only for
488 # POSTs coming from version 1.
490 version = int(game_meta['V'])
493 log.debug('NOTICE: found a version 1 request, halving the weapon stats...')
499 for (key,value) in player_events.items():
500 matched = re.search("acc-(.*?)-cnt-fired", key)
502 weapon_cd = matched.group(1)
503 seq = Sequence('player_weapon_stats_player_weapon_stats_id_seq')
504 pwstat_id = session.execute(seq)
505 pwstat = PlayerWeaponStat()
506 pwstat.player_weapon_stats_id = pwstat_id
507 pwstat.player_id = player.player_id
508 pwstat.game_id = game.game_id
509 pwstat.player_game_stat_id = pgstat.player_game_stat_id
510 pwstat.weapon_cd = weapon_cd
512 if 'n' in player_events:
513 pwstat.nick = player_events['n']
515 pwstat.nick = player_events['P']
517 if 'acc-' + weapon_cd + '-cnt-fired' in player_events:
518 pwstat.fired = int(round(float(
519 player_events['acc-' + weapon_cd + '-cnt-fired'])))
520 if 'acc-' + weapon_cd + '-fired' in player_events:
521 pwstat.max = int(round(float(
522 player_events['acc-' + weapon_cd + '-fired'])))
523 if 'acc-' + weapon_cd + '-cnt-hit' in player_events:
524 pwstat.hit = int(round(float(
525 player_events['acc-' + weapon_cd + '-cnt-hit'])))
526 if 'acc-' + weapon_cd + '-hit' in player_events:
527 pwstat.actual = int(round(float(
528 player_events['acc-' + weapon_cd + '-hit'])))
529 if 'acc-' + weapon_cd + '-frags' in player_events:
530 pwstat.frags = int(round(float(
531 player_events['acc-' + weapon_cd + '-frags'])))
534 pwstat.fired = pwstat.fired/2
535 pwstat.max = pwstat.max/2
536 pwstat.hit = pwstat.hit/2
537 pwstat.actual = pwstat.actual/2
538 pwstat.frags = pwstat.frags/2
541 pwstats.append(pwstat)
546 def parse_body(request):
548 Parses the POST request body for a stats submission
550 # storage vars for the request body
556 for line in request.body.split('\n'):
558 (key, value) = line.strip().split(' ', 1)
560 # Server (S) and Nick (n) fields can have international characters.
561 # We convert to UTF-8.
563 value = unicode(value, 'utf-8')
565 if key in 'V' 'T' 'G' 'M' 'S' 'C' 'R' 'W' 'I':
566 game_meta[key] = value
569 # if we were working on a player record already, append
570 # it and work on a new one (only set team info)
571 if len(player_events) != 0:
572 players.append(player_events)
575 player_events[key] = value
578 (subkey, subvalue) = value.split(' ', 1)
579 player_events[subkey] = subvalue
581 player_events[key] = value
583 player_events[key] = value
585 # no key/value pair - move on to the next line
588 # add the last player we were working on
589 if len(player_events) > 0:
590 players.append(player_events)
592 return (game_meta, players)
595 def create_player_stats(session=None, player=None, game=None,
596 player_events=None, game_meta=None):
598 Creates player game and weapon stats according to what type of player
600 pgstat = create_player_game_stat(session=session,
601 player=player, game=game, player_events=player_events)
603 # fastest cap "upsert"
604 if game.game_type_cd == 'ctf' and pgstat.fastest_cap is not None:
605 update_fastest_cap(session, pgstat.player_id, game.game_id,
606 game.map_id, pgstat.fastest_cap)
608 # bots don't get weapon stats. sorry, bots!
609 if not re.search('^bot#\d+$', player_events['P']):
610 create_player_weapon_stats(session=session,
611 player=player, game=game, pgstat=pgstat,
612 player_events=player_events, game_meta=game_meta)
615 def stats_submit(request):
617 Entry handler for POST stats submissions.
620 # placeholder for the actual session
623 log.debug("\n----- BEGIN REQUEST BODY -----\n" + request.body +
624 "----- END REQUEST BODY -----\n\n")
626 (idfp, status) = verify_request(request)
627 if verify_requests(request.registry.settings):
629 log.debug("ERROR: Unverified request")
630 raise pyramid.httpexceptions.HTTPUnauthorized("Unverified request")
632 (game_meta, players) = parse_body(request)
634 if not has_required_metadata(game_meta):
635 log.debug("ERROR: Required game meta missing")
636 raise pyramid.httpexceptions.HTTPUnprocessableEntity("Missing game meta")
638 if not is_supported_gametype(game_meta['G']):
639 log.debug("ERROR: Unsupported gametype")
640 raise pyramid.httpexceptions.HTTPOk("OK")
642 if not has_minimum_real_players(request.registry.settings, players):
643 log.debug("ERROR: Not enough real players")
644 raise pyramid.httpexceptions.HTTPOk("OK")
646 if is_blank_game(players):
647 log.debug("ERROR: Blank game")
648 raise pyramid.httpexceptions.HTTPOk("OK")
650 # the "duel" gametype is fake
651 if num_real_players(players, count_bots=True) == 2 and \
652 game_meta['G'] == 'dm':
653 game_meta['G'] = 'duel'
656 # fix for DTG, who didn't #ifdef WATERMARK to set the revision info
658 revision = game_meta['R']
662 #----------------------------------------------------------------------
663 # This ends the "precondition" section of sanity checks. All
664 # functions not requiring a database connection go ABOVE HERE.
665 #----------------------------------------------------------------------
666 session = DBSession()
668 server = get_or_create_server(session=session, hashkey=idfp,
669 name=game_meta['S'], revision=revision,
670 ip_addr=get_remote_addr(request))
672 gmap = get_or_create_map(session=session, name=game_meta['M'])
674 # FIXME: use the gmtime instead of utcnow() when the timezone bug is
676 game = create_game(session=session,
677 start_dt=datetime.datetime.utcnow(),
678 #start_dt=datetime.datetime(
679 #*time.gmtime(float(game_meta['T']))[:6]),
680 server_id=server.server_id, game_type_cd=game_meta['G'],
681 map_id=gmap.map_id, match_id=game_meta['I'])
683 # find or create a record for each player
684 # and add stats for each if they were present at the end
686 for player_events in players:
687 if 'n' in player_events:
688 nick = player_events['n']
692 if 'matches' in player_events and 'scoreboardvalid' \
694 player = get_or_create_player(session=session,
695 hashkey=player_events['P'], nick=nick)
696 log.debug('Creating stats for %s' % player_events['P'])
697 create_player_stats(session=session, player=player, game=game,
698 player_events=player_events, game_meta=game_meta)
702 #process_elos(game, session)
703 #except Exception as e:
704 #log.debug('Error (non-fatal): elo processing failed.')
707 log.debug('Success! Stats recorded.')
708 return Response('200 OK')
709 except Exception as e: