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 #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
108 return flg_has_min_real_players
111 def has_required_metadata(metadata):
113 Determines if a give set of metadata has enough data to create a game,
114 server, and map with.
116 flg_has_req_metadata = True
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\
123 flg_has_req_metadata = False
125 return flg_has_req_metadata
128 def is_real_player(events, count_bots=False):
130 Determines if a given set of player events correspond with a player who
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)
136 Returns True if the player meets the above conditions, and false otherwise.
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'):
149 def register_new_nick(session, player, new_nick):
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.
154 session - SQLAlchemy database session factory
155 player - player record whose nick is changing
156 new_nick - the new nickname
158 # see if that nick already exists
159 stripped_nick = strip_colors(player.nick)
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)
173 # We change to the new nick regardless
174 player.nick = new_nick
175 player.stripped_nick = strip_colors(new_nick)
179 def get_or_create_server(session=None, name=None, hashkey=None, ip_addr=None,
182 Find a server by name or create one if not found. Parameters:
184 session - SQLAlchemy database session factory
185 name - server name of the server to be found or created
186 hashkey - server hashkey
189 # find one by that name, if it exists
190 server = session.query(Server).filter_by(name=name).one()
193 if server.hashkey != hashkey:
194 server.hashkey = hashkey
197 # store new IP address
198 if server.ip_addr != ip_addr:
199 server.ip_addr = ip_addr
203 if server.revision != revision:
204 server.revision = revision
207 log.debug("Found existing server {0}".format(server.server_id))
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))
215 except NoResultFound, e:
216 # not found, create one
217 server = Server(name=name, hashkey=hashkey)
220 log.debug("Created server {0} with hashkey {1}".format(
221 server.server_id, server.hashkey))
226 def get_or_create_map(session=None, name=None):
228 Find a map by name or create one if not found. Parameters:
230 session - SQLAlchemy database session factory
231 name - map name of the map to be found or created
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,
238 except NoResultFound, e:
239 gmap = Map(name=name)
242 log.debug("Created map id {0}: {1}".format(gmap.map_id,
244 except MultipleResultsFound, e:
245 # multiple found, so use the first one but warn
247 gmaps = session.query(Map).filter_by(name=name).order_by(
250 log.debug("Found map id {0}: {1} but found \
251 multiple".format(gmap.map_id, gmap.name))
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):
259 Creates a game. Parameters:
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
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
275 session.query(Game).filter(Game.server_id==server_id).\
276 filter(Game.match_id==match_id).one()
278 log.debug("Error: game with same server and match_id found! Ignoring.")
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
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))
293 def get_or_create_player(session=None, hashkey=None, nick=None):
295 Finds a player by hashkey or creates a new one (along with a
296 corresponding hashkey entry. Parameters:
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)
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
310 # see if the player is already in the database
311 # if not, create one and the hashkey along with it
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))
324 # if nick is given to us, use it. If not, use "Anonymous Player"
325 # with a suffix added for uniqueness.
327 player.nick = nick[:128]
328 player.stripped_nick = strip_colors(nick[:128])
330 player.nick = "Anonymous Player #{0}".format(player.player_id)
331 player.stripped_nick = player.nick
333 hk = Hashkey(player_id=player.player_id, hashkey=hashkey)
335 log.debug("Created player {0} ({2}) with hashkey {1}".format(
336 player.player_id, hashkey, player.nick.encode('utf-8')))
340 def create_player_game_stat(session=None, player=None,
341 game=None, player_events=None):
343 Creates game statistics for a given player in a given game. Parameters:
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
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())
359 # set player id from player record
360 pgstat.player_id = player.player_id
362 #set game id from game record
363 pgstat.game_id = game.game_id
365 # all games have a score
368 if game.game_type_cd == 'dm' or game.game_type_cd == 'tdm' or game.game_type_cd == 'duel':
372 elif game.game_type_cd == 'ctf':
378 pgstat.carrier_frags = 0
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)
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"
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)
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
418 def create_player_weapon_stats(session=None, player=None,
419 game=None, pgstat=None, player_events=None):
421 Creates accuracy records for each weapon used by a given player in a
422 given game. Parameters:
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
433 for (key,value) in player_events.items():
434 matched = re.search("acc-(.*?)-cnt-fired", key)
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
446 if 'n' in player_events:
447 pwstat.nick = player_events['n']
449 pwstat.nick = player_events['P']
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'])))
468 pwstats.append(pwstat)
473 def parse_body(request):
475 Parses the POST request body for a stats submission
477 # storage vars for the request body
483 for line in request.body.split('\n'):
485 (key, value) = line.strip().split(' ', 1)
487 # Server (S) and Nick (n) fields can have international characters.
488 # We convert to UTF-8.
490 value = unicode(value, 'utf-8')
492 if key in 'V' 'T' 'G' 'M' 'S' 'C' 'R' 'W' 'I':
493 game_meta[key] = value
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)
502 player_events[key] = value
505 (subkey, subvalue) = value.split(' ', 1)
506 player_events[subkey] = subvalue
508 player_events[key] = value
510 player_events[key] = value
512 # no key/value pair - move on to the next line
515 # add the last player we were working on
516 if len(player_events) > 0:
517 players.append(player_events)
519 return (game_meta, players)
522 def create_player_stats(session=None, player=None, game=None,
525 Creates player game and weapon stats according to what type of player
527 pgstat = create_player_game_stat(session=session,
528 player=player, game=game, player_events=player_events)
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)
537 def stats_submit(request):
539 Entry handler for POST stats submissions.
542 session = DBSession()
544 log.debug("\n----- BEGIN REQUEST BODY -----\n" + request.body +
545 "----- END REQUEST BODY -----\n\n")
547 (idfp, status) = verify_request(request)
549 log.debug("ERROR: Unverified request")
550 raise pyramid.httpexceptions.HTTPUnauthorized("Unverified request")
552 (game_meta, players) = parse_body(request)
554 if not has_required_metadata(game_meta):
555 log.debug("ERROR: Required game meta missing")
556 raise pyramid.httpexceptions.HTTPUnprocessableEntity("Missing game meta")
558 if not is_supported_gametype(game_meta['G']):
559 log.debug("ERROR: Unsupported gametype")
560 raise pyramid.httpexceptions.HTTPOk("OK")
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")
566 if is_blank_game(players):
567 log.debug("ERROR: Blank game")
568 raise pyramid.httpexceptions.HTTPOk("OK")
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'
576 # fix for DTG, who didn't #ifdef WATERMARK to set the revision info
578 revision = game_meta['R']
582 server = get_or_create_server(session=session, hashkey=idfp,
583 name=game_meta['S'], revision=revision,
584 ip_addr=get_remote_addr(request))
586 gmap = get_or_create_map(session=session, name=game_meta['M'])
588 # FIXME: use the gmtime instead of utcnow() when the timezone bug is
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'])
597 # find or create a record for each player
598 # and add stats for each if they were present at the end
600 for player_events in players:
601 if 'n' in player_events:
602 nick = player_events['n']
606 if 'matches' in player_events and 'scoreboardvalid' \
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)
616 process_elos(game, session)
617 except Exception as e:
618 log.debug('Error (non-fatal): elo processing failed.')
621 log.debug('Success! Stats recorded.')
622 return Response('200 OK')
623 except Exception as e: