3 import pyramid.httpexceptions
\r
6 from pyramid.response import Response
\r
7 from sqlalchemy import Sequence
\r
8 from sqlalchemy.orm.exc import MultipleResultsFound, NoResultFound
\r
9 from xonstat.d0_blind_id import d0_blind_id_verify
\r
10 from xonstat.models import *
\r
11 from xonstat.util import strip_colors, qfont_decode
\r
13 log = logging.getLogger(__name__)
\r
15 def get_remote_addr(request):
\r
16 """Get the Xonotic server's IP address"""
\r
17 if 'X-Server-IP' in request.headers:
\r
18 return request.headers['X-Server-IP']
\r
20 return request.remote_addr
\r
23 def is_supported_gametype(gametype):
\r
24 """Whether a gametype is supported or not"""
\r
25 flg_supported = True
\r
27 if gametype == 'cts' or gametype == 'ca' or gametype == 'lms':
\r
28 flg_supported = False
\r
30 return flg_supported
\r
33 def verify_request(request):
\r
35 (idfp, status) = d0_blind_id_verify(
\r
36 sig=request.headers['X-D0-Blind-Id-Detached-Signature'],
\r
38 postdata=request.body)
\r
40 log.debug('\nidfp: {0}\nstatus: {1}'.format(idfp, status))
\r
45 return (idfp, status)
\r
48 def num_real_players(player_events):
\r
50 Returns the number of real players (those who played
\r
51 and are on the scoreboard).
\r
55 for events in player_events:
\r
56 if is_real_player(events):
\r
62 def has_minimum_real_players(settings, player_events):
\r
64 Determines if the collection of player events has enough "real" players
\r
65 to store in the database. The minimum setting comes from the config file
\r
66 under the setting xonstat.minimum_real_players.
\r
68 flg_has_min_real_players = True
\r
71 minimum_required_players = int(
\r
72 settings['xonstat.minimum_required_players'])
\r
74 minimum_required_players = 2
\r
76 real_players = num_real_players(player_events)
\r
78 #TODO: put this into a config setting in the ini file?
\r
79 if real_players < minimum_required_players:
\r
80 flg_has_min_real_players = False
\r
82 return flg_has_min_real_players
\r
85 def has_required_metadata(metadata):
\r
87 Determines if a give set of metadata has enough data to create a game,
\r
88 server, and map with.
\r
90 flg_has_req_metadata = True
\r
92 if 'T' not in metadata or\
\r
93 'G' not in metadata or\
\r
94 'M' not in metadata or\
\r
95 'I' not in metadata or\
\r
96 'S' not in metadata:
\r
97 flg_has_req_metadata = False
\r
99 return flg_has_req_metadata
\r
102 def is_real_player(events):
\r
104 Determines if a given set of player events correspond with a player who
\r
106 1) is not a bot (P event does not look like a bot)
\r
107 2) played in the game (matches 1)
\r
108 3) was present at the end of the game (scoreboardvalid 1)
\r
110 Returns True if the player meets the above conditions, and false otherwise.
\r
112 flg_is_real = False
\r
114 if not events['P'].startswith('bot'):
\r
115 # removing 'joins' here due to bug, but it should be here
\r
116 if 'matches' in events and 'scoreboardvalid' in events:
\r
122 def register_new_nick(session, player, new_nick):
\r
124 Change the player record's nick to the newly found nick. Store the old
\r
125 nick in the player_nicks table for that player.
\r
127 session - SQLAlchemy database session factory
\r
128 player - player record whose nick is changing
\r
129 new_nick - the new nickname
\r
131 # see if that nick already exists
\r
132 stripped_nick = strip_colors(player.nick)
\r
134 player_nick = session.query(PlayerNick).filter_by(
\r
135 player_id=player.player_id, stripped_nick=stripped_nick).one()
\r
136 except NoResultFound, e:
\r
137 # player_id/stripped_nick not found, create one
\r
138 # but we don't store "Anonymous Player #N"
\r
139 if not re.search('^Anonymous Player #\d+$', player.nick):
\r
140 player_nick = PlayerNick()
\r
141 player_nick.player_id = player.player_id
\r
142 player_nick.stripped_nick = player.stripped_nick
\r
143 player_nick.nick = player.nick
\r
144 session.add(player_nick)
\r
146 # We change to the new nick regardless
\r
147 player.nick = new_nick
\r
148 player.stripped_nick = strip_colors(new_nick)
\r
149 session.add(player)
\r
152 def get_or_create_server(session=None, name=None, hashkey=None, ip_addr=None,
\r
155 Find a server by name or create one if not found. Parameters:
\r
157 session - SQLAlchemy database session factory
\r
158 name - server name of the server to be found or created
\r
159 hashkey - server hashkey
\r
162 # find one by that name, if it exists
\r
163 server = session.query(Server).filter_by(name=name).one()
\r
165 # store new hashkey
\r
166 if server.hashkey != hashkey:
\r
167 server.hashkey = hashkey
\r
168 session.add(server)
\r
170 # store new IP address
\r
171 if server.ip_addr != ip_addr:
\r
172 server.ip_addr = ip_addr
\r
173 session.add(server)
\r
175 # store new revision
\r
176 if server.revision != revision:
\r
177 server.revision = revision
\r
178 session.add(server)
\r
180 log.debug("Found existing server {0}".format(server.server_id))
\r
182 except MultipleResultsFound, e:
\r
183 # multiple found, so also filter by hashkey
\r
184 server = session.query(Server).filter_by(name=name).\
\r
185 filter_by(hashkey=hashkey).one()
\r
186 log.debug("Found existing server {0}".format(server.server_id))
\r
188 except NoResultFound, e:
\r
189 # not found, create one
\r
190 server = Server(name=name, hashkey=hashkey)
\r
191 session.add(server)
\r
193 log.debug("Created server {0} with hashkey {1}".format(
\r
194 server.server_id, server.hashkey))
\r
199 def get_or_create_map(session=None, name=None):
\r
201 Find a map by name or create one if not found. Parameters:
\r
203 session - SQLAlchemy database session factory
\r
204 name - map name of the map to be found or created
\r
207 # find one by the name, if it exists
\r
208 gmap = session.query(Map).filter_by(name=name).one()
\r
209 log.debug("Found map id {0}: {1}".format(gmap.map_id,
\r
211 except NoResultFound, e:
\r
212 gmap = Map(name=name)
\r
215 log.debug("Created map id {0}: {1}".format(gmap.map_id,
\r
217 except MultipleResultsFound, e:
\r
218 # multiple found, so use the first one but warn
\r
220 gmaps = session.query(Map).filter_by(name=name).order_by(
\r
223 log.debug("Found map id {0}: {1} but found \
\r
224 multiple".format(gmap.map_id, gmap.name))
\r
229 def create_game(session=None, start_dt=None, game_type_cd=None,
\r
230 server_id=None, map_id=None, winner=None, match_id=None):
\r
232 Creates a game. Parameters:
\r
234 session - SQLAlchemy database session factory
\r
235 start_dt - when the game started (datetime object)
\r
236 game_type_cd - the game type of the game being played
\r
237 server_id - server identifier of the server hosting the game
\r
238 map_id - map on which the game was played
\r
239 winner - the team id of the team that won
\r
241 seq = Sequence('games_game_id_seq')
\r
242 game_id = session.execute(seq)
\r
243 game = Game(game_id=game_id, start_dt=start_dt, game_type_cd=game_type_cd,
\r
244 server_id=server_id, map_id=map_id, winner=winner)
\r
245 game.match_id = match_id
\r
248 session.query(Game).filter(Game.server_id==server_id).\
\r
249 filter(Game.match_id==match_id).one()
\r
250 # if a game under the same server and match_id found,
\r
251 # this is a duplicate game and can be ignored
\r
252 raise pyramid.httpexceptions.HTTPOk
\r
253 except NoResultFound, e:
\r
254 # server_id/match_id combination not found. game is ok to insert
\r
256 log.debug("Created game id {0} on server {1}, map {2} at \
\r
257 {3}".format(game.game_id,
\r
258 server_id, map_id, start_dt))
\r
263 def get_or_create_player(session=None, hashkey=None, nick=None):
\r
265 Finds a player by hashkey or creates a new one (along with a
\r
266 corresponding hashkey entry. Parameters:
\r
268 session - SQLAlchemy database session factory
\r
269 hashkey - hashkey of the player to be found or created
\r
270 nick - nick of the player (in case of a first time create)
\r
273 if re.search('^bot#\d+$', hashkey):
\r
274 player = session.query(Player).filter_by(player_id=1).one()
\r
275 # if we have an untracked player
\r
276 elif re.search('^player#\d+$', hashkey):
\r
277 player = session.query(Player).filter_by(player_id=2).one()
\r
278 # else it is a tracked player
\r
280 # see if the player is already in the database
\r
281 # if not, create one and the hashkey along with it
\r
283 hk = session.query(Hashkey).filter_by(
\r
284 hashkey=hashkey).one()
\r
285 player = session.query(Player).filter_by(
\r
286 player_id=hk.player_id).one()
\r
287 log.debug("Found existing player {0} with hashkey {1}".format(
\r
288 player.player_id, hashkey))
\r
291 session.add(player)
\r
294 # if nick is given to us, use it. If not, use "Anonymous Player"
\r
295 # with a suffix added for uniqueness.
\r
297 player.nick = nick[:128]
\r
298 player.stripped_nick = strip_colors(nick[:128])
\r
300 player.nick = "Anonymous Player #{0}".format(player.player_id)
\r
301 player.stripped_nick = player.nick
\r
303 hk = Hashkey(player_id=player.player_id, hashkey=hashkey)
\r
305 log.debug("Created player {0} ({2}) with hashkey {1}".format(
\r
306 player.player_id, hashkey, player.nick.encode('utf-8')))
\r
310 def create_player_game_stat(session=None, player=None,
\r
311 game=None, player_events=None):
\r
313 Creates game statistics for a given player in a given game. Parameters:
\r
315 session - SQLAlchemy session factory
\r
316 player - Player record of the player who owns the stats
\r
317 game - Game record for the game to which the stats pertain
\r
318 player_events - dictionary for the actual stats that need to be transformed
\r
321 # in here setup default values (e.g. if game type is CTF then
\r
322 # set kills=0, score=0, captures=0, pickups=0, fckills=0, etc
\r
323 # TODO: use game's create date here instead of now()
\r
324 seq = Sequence('player_game_stats_player_game_stat_id_seq')
\r
325 pgstat_id = session.execute(seq)
\r
326 pgstat = PlayerGameStat(player_game_stat_id=pgstat_id,
\r
327 create_dt=datetime.datetime.utcnow())
\r
329 # set player id from player record
\r
330 pgstat.player_id = player.player_id
\r
332 #set game id from game record
\r
333 pgstat.game_id = game.game_id
\r
335 # all games have a score
\r
338 if game.game_type_cd == 'dm' or game.game_type_cd == 'tdm' or game.game_type_cd == 'duel':
\r
341 pgstat.suicides = 0
\r
342 elif game.game_type_cd == 'ctf':
\r
344 pgstat.captures = 0
\r
348 pgstat.carrier_frags = 0
\r
350 for (key,value) in player_events.items():
\r
351 if key == 'n': pgstat.nick = value[:128]
\r
352 if key == 't': pgstat.team = value
\r
353 if key == 'rank': pgstat.rank = value
\r
354 if key == 'alivetime':
\r
355 pgstat.alivetime = datetime.timedelta(seconds=int(round(float(value))))
\r
356 if key == 'scoreboard-drops': pgstat.drops = value
\r
357 if key == 'scoreboard-returns': pgstat.returns = value
\r
358 if key == 'scoreboard-fckills': pgstat.carrier_frags = value
\r
359 if key == 'scoreboard-pickups': pgstat.pickups = value
\r
360 if key == 'scoreboard-caps': pgstat.captures = value
\r
361 if key == 'scoreboard-score': pgstat.score = value
\r
362 if key == 'scoreboard-deaths': pgstat.deaths = value
\r
363 if key == 'scoreboard-kills': pgstat.kills = value
\r
364 if key == 'scoreboard-suicides': pgstat.suicides = value
\r
366 # check to see if we had a name, and if
\r
367 # not use the name from the player id
\r
368 if pgstat.nick == None:
\r
369 pgstat.nick = player.nick
\r
371 # whichever nick we ended up with, strip it and store as the stripped_nick
\r
372 pgstat.stripped_nick = qfont_decode(pgstat.nick)
\r
374 # if the nick we end up with is different from the one in the
\r
375 # player record, change the nick to reflect the new value
\r
376 if pgstat.nick != player.nick and player.player_id > 2:
\r
377 register_new_nick(session, player, pgstat.nick)
\r
379 # if the player is ranked #1 and it is a team game, set the game's winner
\r
380 # to be the team of that player
\r
381 # FIXME: this is a hack, should be using the 'W' field (not present)
\r
382 if pgstat.rank == '1' and pgstat.team:
\r
383 game.winner = pgstat.team
\r
386 session.add(pgstat)
\r
391 def create_player_weapon_stats(session=None, player=None,
\r
392 game=None, pgstat=None, player_events=None):
\r
394 Creates accuracy records for each weapon used by a given player in a
\r
395 given game. Parameters:
\r
397 session - SQLAlchemy session factory object
\r
398 player - Player record who owns the weapon stats
\r
399 game - Game record in which the stats were created
\r
400 pgstat - Corresponding PlayerGameStat record for these weapon stats
\r
401 player_events - dictionary containing the raw weapon values that need to be
\r
406 for (key,value) in player_events.items():
\r
407 matched = re.search("acc-(.*?)-cnt-fired", key)
\r
409 weapon_cd = matched.group(1)
\r
410 seq = Sequence('player_weapon_stats_player_weapon_stats_id_seq')
\r
411 pwstat_id = session.execute(seq)
\r
412 pwstat = PlayerWeaponStat()
\r
413 pwstat.player_weapon_stats_id = pwstat_id
\r
414 pwstat.player_id = player.player_id
\r
415 pwstat.game_id = game.game_id
\r
416 pwstat.player_game_stat_id = pgstat.player_game_stat_id
\r
417 pwstat.weapon_cd = weapon_cd
\r
419 if 'n' in player_events:
\r
420 pwstat.nick = player_events['n']
\r
422 pwstat.nick = player_events['P']
\r
424 if 'acc-' + weapon_cd + '-cnt-fired' in player_events:
\r
425 pwstat.fired = int(round(float(
\r
426 player_events['acc-' + weapon_cd + '-cnt-fired'])))
\r
427 if 'acc-' + weapon_cd + '-fired' in player_events:
\r
428 pwstat.max = int(round(float(
\r
429 player_events['acc-' + weapon_cd + '-fired'])))
\r
430 if 'acc-' + weapon_cd + '-cnt-hit' in player_events:
\r
431 pwstat.hit = int(round(float(
\r
432 player_events['acc-' + weapon_cd + '-cnt-hit'])))
\r
433 if 'acc-' + weapon_cd + '-hit' in player_events:
\r
434 pwstat.actual = int(round(float(
\r
435 player_events['acc-' + weapon_cd + '-hit'])))
\r
436 if 'acc-' + weapon_cd + '-frags' in player_events:
\r
437 pwstat.frags = int(round(float(
\r
438 player_events['acc-' + weapon_cd + '-frags'])))
\r
440 session.add(pwstat)
\r
441 pwstats.append(pwstat)
\r
446 def parse_body(request):
\r
448 Parses the POST request body for a stats submission
\r
450 # storage vars for the request body
\r
453 current_team = None
\r
456 for line in request.body.split('\n'):
\r
458 (key, value) = line.strip().split(' ', 1)
\r
460 # Server (S) and Nick (n) fields can have international characters.
\r
461 # We convert to UTF-8.
\r
463 value = unicode(value, 'utf-8')
\r
465 if key in 'V' 'T' 'G' 'M' 'S' 'C' 'R' 'W' 'I':
\r
466 game_meta[key] = value
\r
469 # if we were working on a player record already, append
\r
470 # it and work on a new one (only set team info)
\r
471 if len(player_events) != 0:
\r
472 players.append(player_events)
\r
475 player_events[key] = value
\r
478 (subkey, subvalue) = value.split(' ', 1)
\r
479 player_events[subkey] = subvalue
\r
481 player_events[key] = value
\r
483 player_events[key] = value
\r
485 # no key/value pair - move on to the next line
\r
488 # add the last player we were working on
\r
489 if len(player_events) > 0:
\r
490 players.append(player_events)
\r
492 return (game_meta, players)
\r
495 def create_player_stats(session=None, player=None, game=None,
\r
496 player_events=None):
\r
498 Creates player game and weapon stats according to what type of player
\r
500 pgstat = create_player_game_stat(session=session,
\r
501 player=player, game=game, player_events=player_events)
\r
503 #TODO: put this into a config setting in the ini file?
\r
504 if not re.search('^bot#\d+$', player_events['P']):
\r
505 create_player_weapon_stats(session=session,
\r
506 player=player, game=game, pgstat=pgstat,
\r
507 player_events=player_events)
\r
510 def stats_submit(request):
\r
512 Entry handler for POST stats submissions.
\r
515 session = DBSession()
\r
517 log.debug("\n----- BEGIN REQUEST BODY -----\n" + request.body +
\r
518 "----- END REQUEST BODY -----\n\n")
\r
520 (idfp, status) = verify_request(request)
\r
522 log.debug("ERROR: Unverified request")
\r
523 raise pyramid.httpexceptions.HTTPUnauthorized("Unverified request")
\r
525 (game_meta, players) = parse_body(request)
\r
527 if not has_required_metadata(game_meta):
\r
528 log.debug("ERROR: Required game meta missing")
\r
529 raise pyramid.exceptions.HTTPUnprocessableEntity("Missing game meta")
\r
531 if not is_supported_gametype(game_meta['G']):
\r
532 log.debug("ERROR: Unsupported gametype")
\r
533 raise pyramid.httpexceptions.HTTPOk("OK")
\r
535 if not has_minimum_real_players(request.registry.settings, players):
\r
536 log.debug("ERROR: Not enough real players")
\r
537 raise pyramid.httpexceptions.HTTPOk("OK")
\r
539 # FIXME: if we have two players and game type is 'dm',
\r
540 # change this into a 'duel' gametype. This should be
\r
541 # removed when the stats actually send 'duel' instead of 'dm'
\r
542 if num_real_players(players) == 2 and game_meta['G'] == 'dm':
\r
543 game_meta['G'] = 'duel'
\r
545 server = get_or_create_server(session=session, hashkey=idfp,
\r
546 name=game_meta['S'], revision=game_meta['R'],
\r
547 ip_addr=get_remote_addr(request))
\r
549 gmap = get_or_create_map(session=session, name=game_meta['M'])
\r
551 game = create_game(session=session,
\r
552 start_dt=datetime.datetime(
\r
553 *time.gmtime(float(game_meta['T']))[:6]),
\r
554 server_id=server.server_id, game_type_cd=game_meta['G'],
\r
555 map_id=gmap.map_id, match_id=game_meta['I'])
\r
557 # find or create a record for each player
\r
558 # and add stats for each if they were present at the end
\r
560 for player_events in players:
\r
561 if 'n' in player_events:
\r
562 nick = player_events['n']
\r
566 if 'matches' in player_events and 'scoreboardvalid' \
\r
568 player = get_or_create_player(session=session,
\r
569 hashkey=player_events['P'], nick=nick)
\r
570 log.debug('Creating stats for %s' % player_events['P'])
\r
571 create_player_stats(session=session, player=player, game=game,
\r
572 player_events=player_events)
\r
575 log.debug('Success! Stats recorded.')
\r
576 return Response('200 OK')
\r
577 except Exception as e:
\r