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.models import *
\r
12 from xonstat.util import strip_colors, qfont_decode
\r
14 log = logging.getLogger(__name__)
\r
16 def get_remote_addr(request):
\r
17 """Get the Xonotic server's IP address"""
\r
18 if 'X-Server-IP' in request.headers:
\r
19 return request.headers['X-Server-IP']
\r
20 elif 'X-Forwarded-For' in request.headers:
\r
21 return request.headers['X-Forwarded-For']
\r
23 return request.remote_addr
\r
26 def is_supported_gametype(gametype):
\r
27 """Whether a gametype is supported or not"""
\r
28 flg_supported = True
\r
30 if gametype == 'cts' or gametype == 'ca' or gametype == 'lms':
\r
31 flg_supported = False
\r
33 return flg_supported
\r
36 def verify_request(request):
\r
38 (idfp, status) = d0_blind_id_verify(
\r
39 sig=request.headers['X-D0-Blind-Id-Detached-Signature'],
\r
41 postdata=request.body)
\r
43 log.debug('\nidfp: {0}\nstatus: {1}'.format(idfp, status))
\r
48 return (idfp, status)
\r
51 def num_real_players(player_events):
\r
53 Returns the number of real players (those who played
\r
54 and are on the scoreboard).
\r
58 for events in player_events:
\r
59 if is_real_player(events):
\r
65 def has_minimum_real_players(settings, player_events):
\r
67 Determines if the collection of player events has enough "real" players
\r
68 to store in the database. The minimum setting comes from the config file
\r
69 under the setting xonstat.minimum_real_players.
\r
71 flg_has_min_real_players = True
\r
74 minimum_required_players = int(
\r
75 settings['xonstat.minimum_required_players'])
\r
77 minimum_required_players = 2
\r
79 real_players = num_real_players(player_events)
\r
81 #TODO: put this into a config setting in the ini file?
\r
82 if real_players < minimum_required_players:
\r
83 flg_has_min_real_players = False
\r
85 return flg_has_min_real_players
\r
88 def has_required_metadata(metadata):
\r
90 Determines if a give set of metadata has enough data to create a game,
\r
91 server, and map with.
\r
93 flg_has_req_metadata = True
\r
95 if 'T' not in metadata or\
\r
96 'G' not in metadata or\
\r
97 'M' not in metadata or\
\r
98 'I' not in metadata or\
\r
99 'S' not in metadata:
\r
100 flg_has_req_metadata = False
\r
102 return flg_has_req_metadata
\r
105 def is_real_player(events):
\r
107 Determines if a given set of player events correspond with a player who
\r
109 1) is not a bot (P event does not look like a bot)
\r
110 2) played in the game (matches 1)
\r
111 3) was present at the end of the game (scoreboardvalid 1)
\r
113 Returns True if the player meets the above conditions, and false otherwise.
\r
115 flg_is_real = False
\r
117 if not events['P'].startswith('bot'):
\r
118 # removing 'joins' here due to bug, but it should be here
\r
119 if 'matches' in events and 'scoreboardvalid' in events:
\r
125 def register_new_nick(session, player, new_nick):
\r
127 Change the player record's nick to the newly found nick. Store the old
\r
128 nick in the player_nicks table for that player.
\r
130 session - SQLAlchemy database session factory
\r
131 player - player record whose nick is changing
\r
132 new_nick - the new nickname
\r
134 # see if that nick already exists
\r
135 stripped_nick = strip_colors(player.nick)
\r
137 player_nick = session.query(PlayerNick).filter_by(
\r
138 player_id=player.player_id, stripped_nick=stripped_nick).one()
\r
139 except NoResultFound, e:
\r
140 # player_id/stripped_nick not found, create one
\r
141 # but we don't store "Anonymous Player #N"
\r
142 if not re.search('^Anonymous Player #\d+$', player.nick):
\r
143 player_nick = PlayerNick()
\r
144 player_nick.player_id = player.player_id
\r
145 player_nick.stripped_nick = player.stripped_nick
\r
146 player_nick.nick = player.nick
\r
147 session.add(player_nick)
\r
149 # We change to the new nick regardless
\r
150 player.nick = new_nick
\r
151 player.stripped_nick = strip_colors(new_nick)
\r
152 session.add(player)
\r
155 def get_or_create_server(session=None, name=None, hashkey=None, ip_addr=None,
\r
158 Find a server by name or create one if not found. Parameters:
\r
160 session - SQLAlchemy database session factory
\r
161 name - server name of the server to be found or created
\r
162 hashkey - server hashkey
\r
165 # find one by that name, if it exists
\r
166 server = session.query(Server).filter_by(name=name).one()
\r
168 # store new hashkey
\r
169 if server.hashkey != hashkey:
\r
170 server.hashkey = hashkey
\r
171 session.add(server)
\r
173 # store new IP address
\r
174 if server.ip_addr != ip_addr:
\r
175 server.ip_addr = ip_addr
\r
176 session.add(server)
\r
178 # store new revision
\r
179 if server.revision != revision:
\r
180 server.revision = revision
\r
181 session.add(server)
\r
183 log.debug("Found existing server {0}".format(server.server_id))
\r
185 except MultipleResultsFound, e:
\r
186 # multiple found, so also filter by hashkey
\r
187 server = session.query(Server).filter_by(name=name).\
\r
188 filter_by(hashkey=hashkey).one()
\r
189 log.debug("Found existing server {0}".format(server.server_id))
\r
191 except NoResultFound, e:
\r
192 # not found, create one
\r
193 server = Server(name=name, hashkey=hashkey)
\r
194 session.add(server)
\r
196 log.debug("Created server {0} with hashkey {1}".format(
\r
197 server.server_id, server.hashkey))
\r
202 def get_or_create_map(session=None, name=None):
\r
204 Find a map by name or create one if not found. Parameters:
\r
206 session - SQLAlchemy database session factory
\r
207 name - map name of the map to be found or created
\r
210 # find one by the name, if it exists
\r
211 gmap = session.query(Map).filter_by(name=name).one()
\r
212 log.debug("Found map id {0}: {1}".format(gmap.map_id,
\r
214 except NoResultFound, e:
\r
215 gmap = Map(name=name)
\r
218 log.debug("Created map id {0}: {1}".format(gmap.map_id,
\r
220 except MultipleResultsFound, e:
\r
221 # multiple found, so use the first one but warn
\r
223 gmaps = session.query(Map).filter_by(name=name).order_by(
\r
226 log.debug("Found map id {0}: {1} but found \
\r
227 multiple".format(gmap.map_id, gmap.name))
\r
232 def create_game(session=None, start_dt=None, game_type_cd=None,
\r
233 server_id=None, map_id=None, winner=None, match_id=None):
\r
235 Creates a game. Parameters:
\r
237 session - SQLAlchemy database session factory
\r
238 start_dt - when the game started (datetime object)
\r
239 game_type_cd - the game type of the game being played
\r
240 server_id - server identifier of the server hosting the game
\r
241 map_id - map on which the game was played
\r
242 winner - the team id of the team that won
\r
244 seq = Sequence('games_game_id_seq')
\r
245 game_id = session.execute(seq)
\r
246 game = Game(game_id=game_id, start_dt=start_dt, game_type_cd=game_type_cd,
\r
247 server_id=server_id, map_id=map_id, winner=winner)
\r
248 game.match_id = match_id
\r
251 session.query(Game).filter(Game.server_id==server_id).\
\r
252 filter(Game.match_id==match_id).one()
\r
253 # if a game under the same server and match_id found,
\r
254 # this is a duplicate game and can be ignored
\r
255 raise pyramid.httpexceptions.HTTPOk
\r
256 except NoResultFound, e:
\r
257 # server_id/match_id combination not found. game is ok to insert
\r
259 log.debug("Created game id {0} on server {1}, map {2} at \
\r
260 {3}".format(game.game_id,
\r
261 server_id, map_id, start_dt))
\r
266 def get_or_create_player(session=None, hashkey=None, nick=None):
\r
268 Finds a player by hashkey or creates a new one (along with a
\r
269 corresponding hashkey entry. Parameters:
\r
271 session - SQLAlchemy database session factory
\r
272 hashkey - hashkey of the player to be found or created
\r
273 nick - nick of the player (in case of a first time create)
\r
276 if re.search('^bot#\d+$', hashkey):
\r
277 player = session.query(Player).filter_by(player_id=1).one()
\r
278 # if we have an untracked player
\r
279 elif re.search('^player#\d+$', hashkey):
\r
280 player = session.query(Player).filter_by(player_id=2).one()
\r
281 # else it is a tracked player
\r
283 # see if the player is already in the database
\r
284 # if not, create one and the hashkey along with it
\r
286 hk = session.query(Hashkey).filter_by(
\r
287 hashkey=hashkey).one()
\r
288 player = session.query(Player).filter_by(
\r
289 player_id=hk.player_id).one()
\r
290 log.debug("Found existing player {0} with hashkey {1}".format(
\r
291 player.player_id, hashkey))
\r
294 session.add(player)
\r
297 # if nick is given to us, use it. If not, use "Anonymous Player"
\r
298 # with a suffix added for uniqueness.
\r
300 player.nick = nick[:128]
\r
301 player.stripped_nick = strip_colors(nick[:128])
\r
303 player.nick = "Anonymous Player #{0}".format(player.player_id)
\r
304 player.stripped_nick = player.nick
\r
306 hk = Hashkey(player_id=player.player_id, hashkey=hashkey)
\r
308 log.debug("Created player {0} ({2}) with hashkey {1}".format(
\r
309 player.player_id, hashkey, player.nick.encode('utf-8')))
\r
313 def create_player_game_stat(session=None, player=None,
\r
314 game=None, player_events=None):
\r
316 Creates game statistics for a given player in a given game. Parameters:
\r
318 session - SQLAlchemy session factory
\r
319 player - Player record of the player who owns the stats
\r
320 game - Game record for the game to which the stats pertain
\r
321 player_events - dictionary for the actual stats that need to be transformed
\r
324 # in here setup default values (e.g. if game type is CTF then
\r
325 # set kills=0, score=0, captures=0, pickups=0, fckills=0, etc
\r
326 # TODO: use game's create date here instead of now()
\r
327 seq = Sequence('player_game_stats_player_game_stat_id_seq')
\r
328 pgstat_id = session.execute(seq)
\r
329 pgstat = PlayerGameStat(player_game_stat_id=pgstat_id,
\r
330 create_dt=datetime.datetime.utcnow())
\r
332 # set player id from player record
\r
333 pgstat.player_id = player.player_id
\r
335 #set game id from game record
\r
336 pgstat.game_id = game.game_id
\r
338 # all games have a score
\r
341 if game.game_type_cd == 'dm' or game.game_type_cd == 'tdm' or game.game_type_cd == 'duel':
\r
344 pgstat.suicides = 0
\r
345 elif game.game_type_cd == 'ctf':
\r
347 pgstat.captures = 0
\r
351 pgstat.carrier_frags = 0
\r
353 for (key,value) in player_events.items():
\r
354 if key == 'n': pgstat.nick = value[:128]
\r
355 if key == 't': pgstat.team = value
\r
356 if key == 'rank': pgstat.rank = value
\r
357 if key == 'alivetime':
\r
358 pgstat.alivetime = datetime.timedelta(seconds=int(round(float(value))))
\r
359 if key == 'scoreboard-drops': pgstat.drops = value
\r
360 if key == 'scoreboard-returns': pgstat.returns = value
\r
361 if key == 'scoreboard-fckills': pgstat.carrier_frags = value
\r
362 if key == 'scoreboard-pickups': pgstat.pickups = value
\r
363 if key == 'scoreboard-caps': pgstat.captures = value
\r
364 if key == 'scoreboard-score': pgstat.score = value
\r
365 if key == 'scoreboard-deaths': pgstat.deaths = value
\r
366 if key == 'scoreboard-kills': pgstat.kills = value
\r
367 if key == 'scoreboard-suicides': pgstat.suicides = value
\r
369 # check to see if we had a name, and if
\r
370 # not use the name from the player id
\r
371 if pgstat.nick == None:
\r
372 pgstat.nick = player.nick
\r
374 # whichever nick we ended up with, strip it and store as the stripped_nick
\r
375 pgstat.stripped_nick = qfont_decode(strip_colors(pgstat.nick))
\r
377 # if the nick we end up with is different from the one in the
\r
378 # player record, change the nick to reflect the new value
\r
379 if pgstat.nick != player.nick and player.player_id > 2:
\r
380 register_new_nick(session, player, pgstat.nick)
\r
382 # if the player is ranked #1 and it is a team game, set the game's winner
\r
383 # to be the team of that player
\r
384 # FIXME: this is a hack, should be using the 'W' field (not present)
\r
385 if pgstat.rank == '1' and pgstat.team:
\r
386 game.winner = pgstat.team
\r
389 session.add(pgstat)
\r
394 def create_player_weapon_stats(session=None, player=None,
\r
395 game=None, pgstat=None, player_events=None):
\r
397 Creates accuracy records for each weapon used by a given player in a
\r
398 given game. Parameters:
\r
400 session - SQLAlchemy session factory object
\r
401 player - Player record who owns the weapon stats
\r
402 game - Game record in which the stats were created
\r
403 pgstat - Corresponding PlayerGameStat record for these weapon stats
\r
404 player_events - dictionary containing the raw weapon values that need to be
\r
409 for (key,value) in player_events.items():
\r
410 matched = re.search("acc-(.*?)-cnt-fired", key)
\r
412 weapon_cd = matched.group(1)
\r
413 seq = Sequence('player_weapon_stats_player_weapon_stats_id_seq')
\r
414 pwstat_id = session.execute(seq)
\r
415 pwstat = PlayerWeaponStat()
\r
416 pwstat.player_weapon_stats_id = pwstat_id
\r
417 pwstat.player_id = player.player_id
\r
418 pwstat.game_id = game.game_id
\r
419 pwstat.player_game_stat_id = pgstat.player_game_stat_id
\r
420 pwstat.weapon_cd = weapon_cd
\r
422 if 'n' in player_events:
\r
423 pwstat.nick = player_events['n']
\r
425 pwstat.nick = player_events['P']
\r
427 if 'acc-' + weapon_cd + '-cnt-fired' in player_events:
\r
428 pwstat.fired = int(round(float(
\r
429 player_events['acc-' + weapon_cd + '-cnt-fired'])))
\r
430 if 'acc-' + weapon_cd + '-fired' in player_events:
\r
431 pwstat.max = int(round(float(
\r
432 player_events['acc-' + weapon_cd + '-fired'])))
\r
433 if 'acc-' + weapon_cd + '-cnt-hit' in player_events:
\r
434 pwstat.hit = int(round(float(
\r
435 player_events['acc-' + weapon_cd + '-cnt-hit'])))
\r
436 if 'acc-' + weapon_cd + '-hit' in player_events:
\r
437 pwstat.actual = int(round(float(
\r
438 player_events['acc-' + weapon_cd + '-hit'])))
\r
439 if 'acc-' + weapon_cd + '-frags' in player_events:
\r
440 pwstat.frags = int(round(float(
\r
441 player_events['acc-' + weapon_cd + '-frags'])))
\r
443 session.add(pwstat)
\r
444 pwstats.append(pwstat)
\r
449 def parse_body(request):
\r
451 Parses the POST request body for a stats submission
\r
453 # storage vars for the request body
\r
456 current_team = None
\r
459 for line in request.body.split('\n'):
\r
461 (key, value) = line.strip().split(' ', 1)
\r
463 # Server (S) and Nick (n) fields can have international characters.
\r
464 # We convert to UTF-8.
\r
466 value = unicode(value, 'utf-8')
\r
468 if key in 'V' 'T' 'G' 'M' 'S' 'C' 'R' 'W' 'I':
\r
469 game_meta[key] = value
\r
472 # if we were working on a player record already, append
\r
473 # it and work on a new one (only set team info)
\r
474 if len(player_events) != 0:
\r
475 players.append(player_events)
\r
478 player_events[key] = value
\r
481 (subkey, subvalue) = value.split(' ', 1)
\r
482 player_events[subkey] = subvalue
\r
484 player_events[key] = value
\r
486 player_events[key] = value
\r
488 # no key/value pair - move on to the next line
\r
491 # add the last player we were working on
\r
492 if len(player_events) > 0:
\r
493 players.append(player_events)
\r
495 return (game_meta, players)
\r
498 def create_player_stats(session=None, player=None, game=None,
\r
499 player_events=None):
\r
501 Creates player game and weapon stats according to what type of player
\r
503 pgstat = create_player_game_stat(session=session,
\r
504 player=player, game=game, player_events=player_events)
\r
506 #TODO: put this into a config setting in the ini file?
\r
507 if not re.search('^bot#\d+$', player_events['P']):
\r
508 create_player_weapon_stats(session=session,
\r
509 player=player, game=game, pgstat=pgstat,
\r
510 player_events=player_events)
\r
513 def stats_submit(request):
\r
515 Entry handler for POST stats submissions.
\r
518 session = DBSession()
\r
520 log.debug("\n----- BEGIN REQUEST BODY -----\n" + request.body +
\r
521 "----- END REQUEST BODY -----\n\n")
\r
523 (idfp, status) = verify_request(request)
\r
525 log.debug("ERROR: Unverified request")
\r
526 raise pyramid.httpexceptions.HTTPUnauthorized("Unverified request")
\r
528 (game_meta, players) = parse_body(request)
\r
530 if not has_required_metadata(game_meta):
\r
531 log.debug("ERROR: Required game meta missing")
\r
532 raise pyramid.exceptions.HTTPUnprocessableEntity("Missing game meta")
\r
534 if not is_supported_gametype(game_meta['G']):
\r
535 log.debug("ERROR: Unsupported gametype")
\r
536 raise pyramid.httpexceptions.HTTPOk("OK")
\r
538 if not has_minimum_real_players(request.registry.settings, players):
\r
539 log.debug("ERROR: Not enough real players")
\r
540 raise pyramid.httpexceptions.HTTPOk("OK")
\r
542 # FIXME: if we have two players and game type is 'dm',
\r
543 # change this into a 'duel' gametype. This should be
\r
544 # removed when the stats actually send 'duel' instead of 'dm'
\r
545 if num_real_players(players) == 2 and game_meta['G'] == 'dm':
\r
546 game_meta['G'] = 'duel'
\r
548 server = get_or_create_server(session=session, hashkey=idfp,
\r
549 name=game_meta['S'], revision=game_meta['R'],
\r
550 ip_addr=get_remote_addr(request))
\r
552 gmap = get_or_create_map(session=session, name=game_meta['M'])
\r
554 # FIXME: use the gmtime instead of utcnow() when the timezone bug is
\r
556 game = create_game(session=session,
\r
557 start_dt=datetime.datetime.utcnow(),
\r
558 #start_dt=datetime.datetime(
\r
559 #*time.gmtime(float(game_meta['T']))[:6]),
\r
560 server_id=server.server_id, game_type_cd=game_meta['G'],
\r
561 map_id=gmap.map_id, match_id=game_meta['I'])
\r
563 # find or create a record for each player
\r
564 # and add stats for each if they were present at the end
\r
566 for player_events in players:
\r
567 if 'n' in player_events:
\r
568 nick = player_events['n']
\r
572 if 'matches' in player_events and 'scoreboardvalid' \
\r
574 player = get_or_create_player(session=session,
\r
575 hashkey=player_events['P'], nick=nick)
\r
576 log.debug('Creating stats for %s' % player_events['P'])
\r
577 create_player_stats(session=session, player=player, game=game,
\r
578 player_events=player_events)
\r
581 log.debug('Success! Stats recorded.')
\r
582 return Response('200 OK')
\r
583 except Exception as e:
\r