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 has_minimum_real_players(settings, player_events):
\r
50 Determines if the collection of player events has enough "real" players
\r
51 to store in the database. The minimum setting comes from the config file
\r
52 under the setting xonstat.minimum_real_players.
\r
54 flg_has_min_real_players = True
\r
57 minimum_required_players = int(
\r
58 settings['xonstat.minimum_required_players'])
\r
60 minimum_required_players = 2
\r
63 for events in player_events:
\r
64 if is_real_player(events):
\r
67 #TODO: put this into a config setting in the ini file?
\r
68 if real_players < minimum_required_players:
\r
69 flg_has_min_real_players = False
\r
71 return flg_has_min_real_players
\r
74 def has_required_metadata(metadata):
\r
76 Determines if a give set of metadata has enough data to create a game,
\r
77 server, and map with.
\r
79 flg_has_req_metadata = True
\r
81 if 'T' not in metadata or\
\r
82 'G' not in metadata or\
\r
83 'M' not in metadata or\
\r
84 'I' not in metadata or\
\r
85 'S' not in metadata:
\r
86 flg_has_req_metadata = False
\r
88 return flg_has_req_metadata
\r
91 def is_real_player(events):
\r
93 Determines if a given set of player events correspond with a player who
\r
95 1) is not a bot (P event does not look like a bot)
\r
96 2) played in the game (matches 1)
\r
97 3) was present at the end of the game (scoreboardvalid 1)
\r
99 Returns True if the player meets the above conditions, and false otherwise.
\r
101 flg_is_real = False
\r
103 if not events['P'].startswith('bot'):
\r
104 # removing 'joins' here due to bug, but it should be here
\r
105 if 'matches' in events and 'scoreboardvalid' in events:
\r
111 def register_new_nick(session, player, new_nick):
\r
113 Change the player record's nick to the newly found nick. Store the old
\r
114 nick in the player_nicks table for that player.
\r
116 session - SQLAlchemy database session factory
\r
117 player - player record whose nick is changing
\r
118 new_nick - the new nickname
\r
120 # see if that nick already exists
\r
121 stripped_nick = strip_colors(player.nick)
\r
123 player_nick = session.query(PlayerNick).filter_by(
\r
124 player_id=player.player_id, stripped_nick=stripped_nick).one()
\r
125 except NoResultFound, e:
\r
126 # player_id/stripped_nick not found, create one
\r
127 # but we don't store "Anonymous Player #N"
\r
128 if not re.search('^Anonymous Player #\d+$', player.nick):
\r
129 player_nick = PlayerNick()
\r
130 player_nick.player_id = player.player_id
\r
131 player_nick.stripped_nick = player.stripped_nick
\r
132 player_nick.nick = player.nick
\r
133 session.add(player_nick)
\r
135 # We change to the new nick regardless
\r
136 player.nick = new_nick
\r
137 player.stripped_nick = strip_colors(new_nick)
\r
138 session.add(player)
\r
141 def get_or_create_server(session=None, name=None, hashkey=None, ip_addr=None,
\r
144 Find a server by name or create one if not found. Parameters:
\r
146 session - SQLAlchemy database session factory
\r
147 name - server name of the server to be found or created
\r
148 hashkey - server hashkey
\r
151 # find one by that name, if it exists
\r
152 server = session.query(Server).filter_by(name=name).one()
\r
154 # store new hashkey
\r
155 if server.hashkey != hashkey:
\r
156 server.hashkey = hashkey
\r
157 session.add(server)
\r
159 # store new IP address
\r
160 if server.ip_addr != ip_addr:
\r
161 server.ip_addr = ip_addr
\r
162 session.add(server)
\r
164 # store new revision
\r
165 if server.revision != revision:
\r
166 server.revision = revision
\r
167 session.add(server)
\r
169 log.debug("Found existing server {0}".format(server.server_id))
\r
171 except MultipleResultsFound, e:
\r
172 # multiple found, so also filter by hashkey
\r
173 server = session.query(Server).filter_by(name=name).\
\r
174 filter_by(hashkey=hashkey).one()
\r
175 log.debug("Found existing server {0}".format(server.server_id))
\r
177 except NoResultFound, e:
\r
178 # not found, create one
\r
179 server = Server(name=name, hashkey=hashkey)
\r
180 session.add(server)
\r
182 log.debug("Created server {0} with hashkey {1}".format(
\r
183 server.server_id, server.hashkey))
\r
188 def get_or_create_map(session=None, name=None):
\r
190 Find a map by name or create one if not found. Parameters:
\r
192 session - SQLAlchemy database session factory
\r
193 name - map name of the map to be found or created
\r
196 # find one by the name, if it exists
\r
197 gmap = session.query(Map).filter_by(name=name).one()
\r
198 log.debug("Found map id {0}: {1}".format(gmap.map_id,
\r
200 except NoResultFound, e:
\r
201 gmap = Map(name=name)
\r
204 log.debug("Created map id {0}: {1}".format(gmap.map_id,
\r
206 except MultipleResultsFound, e:
\r
207 # multiple found, so use the first one but warn
\r
209 gmaps = session.query(Map).filter_by(name=name).order_by(
\r
212 log.debug("Found map id {0}: {1} but found \
\r
213 multiple".format(gmap.map_id, gmap.name))
\r
218 def create_game(session=None, start_dt=None, game_type_cd=None,
\r
219 server_id=None, map_id=None, winner=None, match_id=None):
\r
221 Creates a game. Parameters:
\r
223 session - SQLAlchemy database session factory
\r
224 start_dt - when the game started (datetime object)
\r
225 game_type_cd - the game type of the game being played
\r
226 server_id - server identifier of the server hosting the game
\r
227 map_id - map on which the game was played
\r
228 winner - the team id of the team that won
\r
230 seq = Sequence('games_game_id_seq')
\r
231 game_id = session.execute(seq)
\r
232 game = Game(game_id=game_id, start_dt=start_dt, game_type_cd=game_type_cd,
\r
233 server_id=server_id, map_id=map_id, winner=winner)
\r
234 game.match_id = match_id
\r
237 session.query(Game).filter(Game.server_id==server_id).\
\r
238 filter(Game.match_id==match_id).one()
\r
239 # if a game under the same server and match_id found,
\r
240 # this is a duplicate game and can be ignored
\r
241 raise pyramid.httpexceptions.HTTPOk
\r
242 except NoResultFound, e:
\r
243 # server_id/match_id combination not found. game is ok to insert
\r
245 log.debug("Created game id {0} on server {1}, map {2} at \
\r
246 {3}".format(game.game_id,
\r
247 server_id, map_id, start_dt))
\r
252 def get_or_create_player(session=None, hashkey=None, nick=None):
\r
254 Finds a player by hashkey or creates a new one (along with a
\r
255 corresponding hashkey entry. Parameters:
\r
257 session - SQLAlchemy database session factory
\r
258 hashkey - hashkey of the player to be found or created
\r
259 nick - nick of the player (in case of a first time create)
\r
262 if re.search('^bot#\d+$', hashkey):
\r
263 player = session.query(Player).filter_by(player_id=1).one()
\r
264 # if we have an untracked player
\r
265 elif re.search('^player#\d+$', hashkey):
\r
266 player = session.query(Player).filter_by(player_id=2).one()
\r
267 # else it is a tracked player
\r
269 # see if the player is already in the database
\r
270 # if not, create one and the hashkey along with it
\r
272 hk = session.query(Hashkey).filter_by(
\r
273 hashkey=hashkey).one()
\r
274 player = session.query(Player).filter_by(
\r
275 player_id=hk.player_id).one()
\r
276 log.debug("Found existing player {0} with hashkey {1}".format(
\r
277 player.player_id, hashkey))
\r
280 session.add(player)
\r
283 # if nick is given to us, use it. If not, use "Anonymous Player"
\r
284 # with a suffix added for uniqueness.
\r
286 player.nick = nick[:128]
\r
287 player.stripped_nick = strip_colors(nick[:128])
\r
289 player.nick = "Anonymous Player #{0}".format(player.player_id)
\r
290 player.stripped_nick = player.nick
\r
292 hk = Hashkey(player_id=player.player_id, hashkey=hashkey)
\r
294 log.debug("Created player {0} ({2}) with hashkey {1}".format(
\r
295 player.player_id, hashkey, player.nick.encode('utf-8')))
\r
299 def create_player_game_stat(session=None, player=None,
\r
300 game=None, player_events=None):
\r
302 Creates game statistics for a given player in a given game. Parameters:
\r
304 session - SQLAlchemy session factory
\r
305 player - Player record of the player who owns the stats
\r
306 game - Game record for the game to which the stats pertain
\r
307 player_events - dictionary for the actual stats that need to be transformed
\r
310 # in here setup default values (e.g. if game type is CTF then
\r
311 # set kills=0, score=0, captures=0, pickups=0, fckills=0, etc
\r
312 # TODO: use game's create date here instead of now()
\r
313 seq = Sequence('player_game_stats_player_game_stat_id_seq')
\r
314 pgstat_id = session.execute(seq)
\r
315 pgstat = PlayerGameStat(player_game_stat_id=pgstat_id,
\r
316 create_dt=datetime.datetime.utcnow())
\r
318 # set player id from player record
\r
319 pgstat.player_id = player.player_id
\r
321 #set game id from game record
\r
322 pgstat.game_id = game.game_id
\r
324 # all games have a score
\r
327 if game.game_type_cd == 'dm' or game.game_type_cd == 'tdm' or game.game_type_cd == 'duel':
\r
330 pgstat.suicides = 0
\r
331 elif game.game_type_cd == 'ctf':
\r
333 pgstat.captures = 0
\r
337 pgstat.carrier_frags = 0
\r
339 for (key,value) in player_events.items():
\r
340 if key == 'n': pgstat.nick = value[:128]
\r
341 if key == 't': pgstat.team = value
\r
342 if key == 'rank': pgstat.rank = value
\r
343 if key == 'alivetime':
\r
344 pgstat.alivetime = datetime.timedelta(seconds=int(round(float(value))))
\r
345 if key == 'scoreboard-drops': pgstat.drops = value
\r
346 if key == 'scoreboard-returns': pgstat.returns = value
\r
347 if key == 'scoreboard-fckills': pgstat.carrier_frags = value
\r
348 if key == 'scoreboard-pickups': pgstat.pickups = value
\r
349 if key == 'scoreboard-caps': pgstat.captures = value
\r
350 if key == 'scoreboard-score': pgstat.score = value
\r
351 if key == 'scoreboard-deaths': pgstat.deaths = value
\r
352 if key == 'scoreboard-kills': pgstat.kills = value
\r
353 if key == 'scoreboard-suicides': pgstat.suicides = value
\r
355 # check to see if we had a name, and if
\r
356 # not use the name from the player id
\r
357 if pgstat.nick == None:
\r
358 pgstat.nick = player.nick
\r
360 # whichever nick we ended up with, strip it and store as the stripped_nick
\r
361 pgstat.stripped_nick = qfont_decode(pgstat.nick)
\r
363 # if the nick we end up with is different from the one in the
\r
364 # player record, change the nick to reflect the new value
\r
365 if pgstat.nick != player.nick and player.player_id > 2:
\r
366 register_new_nick(session, player, pgstat.nick)
\r
368 # if the player is ranked #1 and it is a team game, set the game's winner
\r
369 # to be the team of that player
\r
370 # FIXME: this is a hack, should be using the 'W' field (not present)
\r
371 if pgstat.rank == '1' and pgstat.team:
\r
372 game.winner = pgstat.team
\r
375 session.add(pgstat)
\r
380 def create_player_weapon_stats(session=None, player=None,
\r
381 game=None, pgstat=None, player_events=None):
\r
383 Creates accuracy records for each weapon used by a given player in a
\r
384 given game. Parameters:
\r
386 session - SQLAlchemy session factory object
\r
387 player - Player record who owns the weapon stats
\r
388 game - Game record in which the stats were created
\r
389 pgstat - Corresponding PlayerGameStat record for these weapon stats
\r
390 player_events - dictionary containing the raw weapon values that need to be
\r
395 for (key,value) in player_events.items():
\r
396 matched = re.search("acc-(.*?)-cnt-fired", key)
\r
398 weapon_cd = matched.group(1)
\r
399 seq = Sequence('player_weapon_stats_player_weapon_stats_id_seq')
\r
400 pwstat_id = session.execute(seq)
\r
401 pwstat = PlayerWeaponStat()
\r
402 pwstat.player_weapon_stats_id = pwstat_id
\r
403 pwstat.player_id = player.player_id
\r
404 pwstat.game_id = game.game_id
\r
405 pwstat.player_game_stat_id = pgstat.player_game_stat_id
\r
406 pwstat.weapon_cd = weapon_cd
\r
408 if 'n' in player_events:
\r
409 pwstat.nick = player_events['n']
\r
411 pwstat.nick = player_events['P']
\r
413 if 'acc-' + weapon_cd + '-cnt-fired' in player_events:
\r
414 pwstat.fired = int(round(float(
\r
415 player_events['acc-' + weapon_cd + '-cnt-fired'])))
\r
416 if 'acc-' + weapon_cd + '-fired' in player_events:
\r
417 pwstat.max = int(round(float(
\r
418 player_events['acc-' + weapon_cd + '-fired'])))
\r
419 if 'acc-' + weapon_cd + '-cnt-hit' in player_events:
\r
420 pwstat.hit = int(round(float(
\r
421 player_events['acc-' + weapon_cd + '-cnt-hit'])))
\r
422 if 'acc-' + weapon_cd + '-hit' in player_events:
\r
423 pwstat.actual = int(round(float(
\r
424 player_events['acc-' + weapon_cd + '-hit'])))
\r
425 if 'acc-' + weapon_cd + '-frags' in player_events:
\r
426 pwstat.frags = int(round(float(
\r
427 player_events['acc-' + weapon_cd + '-frags'])))
\r
429 session.add(pwstat)
\r
430 pwstats.append(pwstat)
\r
435 def parse_body(request):
\r
437 Parses the POST request body for a stats submission
\r
439 # storage vars for the request body
\r
442 current_team = None
\r
445 for line in request.body.split('\n'):
\r
447 (key, value) = line.strip().split(' ', 1)
\r
449 # Server (S) and Nick (n) fields can have international characters.
\r
450 # We convert to UTF-8.
\r
452 value = unicode(value, 'utf-8')
\r
454 if key in 'V' 'T' 'G' 'M' 'S' 'C' 'R' 'W' 'I':
\r
455 game_meta[key] = value
\r
458 # if we were working on a player record already, append
\r
459 # it and work on a new one (only set team info)
\r
460 if len(player_events) != 0:
\r
461 players.append(player_events)
\r
464 player_events[key] = value
\r
467 (subkey, subvalue) = value.split(' ', 1)
\r
468 player_events[subkey] = subvalue
\r
470 player_events[key] = value
\r
472 player_events[key] = value
\r
474 # no key/value pair - move on to the next line
\r
477 # add the last player we were working on
\r
478 if len(player_events) > 0:
\r
479 players.append(player_events)
\r
481 return (game_meta, players)
\r
484 def create_player_stats(session=None, player=None, game=None,
\r
485 player_events=None):
\r
487 Creates player game and weapon stats according to what type of player
\r
489 pgstat = create_player_game_stat(session=session,
\r
490 player=player, game=game, player_events=player_events)
\r
492 #TODO: put this into a config setting in the ini file?
\r
493 if not re.search('^bot#\d+$', player_events['P']):
\r
494 create_player_weapon_stats(session=session,
\r
495 player=player, game=game, pgstat=pgstat,
\r
496 player_events=player_events)
\r
499 def stats_submit(request):
\r
501 Entry handler for POST stats submissions.
\r
504 session = DBSession()
\r
506 log.debug("\n----- BEGIN REQUEST BODY -----\n" + request.body +
\r
507 "----- END REQUEST BODY -----\n\n")
\r
509 (idfp, status) = verify_request(request)
\r
511 log.debug("ERROR: Unverified request")
\r
512 raise pyramid.httpexceptions.HTTPUnauthorized("Unverified request")
\r
514 (game_meta, players) = parse_body(request)
\r
516 if not has_required_metadata(game_meta):
\r
517 log.debug("ERROR: Required game meta missing")
\r
518 raise pyramid.exceptions.HTTPUnprocessableEntity("Missing game meta")
\r
520 if not is_supported_gametype(game_meta['G']):
\r
521 log.debug("ERROR: Unsupported gametype")
\r
522 raise pyramid.httpexceptions.HTTPOk("OK")
\r
524 if not has_minimum_real_players(request.registry.settings, players):
\r
525 log.debug("ERROR: Not enough real players")
\r
526 raise pyramid.httpexceptions.HTTPOk("OK")
\r
528 # FIXME: if we have two players and game type is 'dm',
\r
529 # change this into a 'duel' gametype. This should be
\r
530 # removed when the stats actually send 'duel' instead of 'dm'
\r
531 if len(players) == 2 and game_meta['G'] == 'dm':
\r
532 game_meta['G'] = 'duel'
\r
534 server = get_or_create_server(session=session, hashkey=idfp,
\r
535 name=game_meta['S'], revision=game_meta['R'],
\r
536 ip_addr=get_remote_addr(request))
\r
538 gmap = get_or_create_map(session=session, name=game_meta['M'])
\r
540 game = create_game(session=session,
\r
541 start_dt=datetime.datetime(
\r
542 *time.gmtime(float(game_meta['T']))[:6]),
\r
543 server_id=server.server_id, game_type_cd=game_meta['G'],
\r
544 map_id=gmap.map_id, match_id=game_meta['I'])
\r
546 # find or create a record for each player
\r
547 # and add stats for each if they were present at the end
\r
549 for player_events in players:
\r
550 if 'n' in player_events:
\r
551 nick = player_events['n']
\r
555 if 'matches' in player_events and 'scoreboardvalid' \
\r
557 player = get_or_create_player(session=session,
\r
558 hashkey=player_events['P'], nick=nick)
\r
559 log.debug('Creating stats for %s' % player_events['P'])
\r
560 create_player_stats(session=session, player=player, game=game,
\r
561 player_events=player_events)
\r
564 log.debug('Success! Stats recorded.')
\r
565 return Response('200 OK')
\r
566 except Exception as e:
\r