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-Forwarded-For' in request.headers:
\r
19 return request.headers['X-Forwarded-For']
\r
21 return request.remote_addr
\r
24 def is_supported_gametype(gametype):
\r
25 """Whether a gametype is supported or not"""
\r
26 flg_supported = True
\r
28 if gametype == 'cts' or gametype == 'ca' or gametype == 'lms':
\r
29 flg_supported = False
\r
31 return flg_supported
\r
34 def verify_request(request):
\r
36 (idfp, status) = d0_blind_id_verify(
\r
37 sig=request.headers['X-D0-Blind-Id-Detached-Signature'],
\r
39 postdata=request.body)
\r
41 log.debug('\nidfp: {0}\nstatus: {1}'.format(idfp, status))
\r
46 return (idfp, status)
\r
49 def num_real_players(player_events):
\r
51 Returns the number of real players (those who played
\r
52 and are on the scoreboard).
\r
56 for events in player_events:
\r
57 if is_real_player(events):
\r
63 def has_minimum_real_players(settings, player_events):
\r
65 Determines if the collection of player events has enough "real" players
\r
66 to store in the database. The minimum setting comes from the config file
\r
67 under the setting xonstat.minimum_real_players.
\r
69 flg_has_min_real_players = True
\r
72 minimum_required_players = int(
\r
73 settings['xonstat.minimum_required_players'])
\r
75 minimum_required_players = 2
\r
77 real_players = num_real_players(player_events)
\r
79 #TODO: put this into a config setting in the ini file?
\r
80 if real_players < minimum_required_players:
\r
81 flg_has_min_real_players = False
\r
83 return flg_has_min_real_players
\r
86 def has_required_metadata(metadata):
\r
88 Determines if a give set of metadata has enough data to create a game,
\r
89 server, and map with.
\r
91 flg_has_req_metadata = True
\r
93 if 'T' not in metadata or\
\r
94 'G' not in metadata or\
\r
95 'M' not in metadata or\
\r
96 'I' not in metadata or\
\r
97 'S' not in metadata:
\r
98 flg_has_req_metadata = False
\r
100 return flg_has_req_metadata
\r
103 def is_real_player(events):
\r
105 Determines if a given set of player events correspond with a player who
\r
107 1) is not a bot (P event does not look like a bot)
\r
108 2) played in the game (matches 1)
\r
109 3) was present at the end of the game (scoreboardvalid 1)
\r
111 Returns True if the player meets the above conditions, and false otherwise.
\r
113 flg_is_real = False
\r
115 if not events['P'].startswith('bot'):
\r
116 # removing 'joins' here due to bug, but it should be here
\r
117 if 'matches' in events and 'scoreboardvalid' in events:
\r
123 def register_new_nick(session, player, new_nick):
\r
125 Change the player record's nick to the newly found nick. Store the old
\r
126 nick in the player_nicks table for that player.
\r
128 session - SQLAlchemy database session factory
\r
129 player - player record whose nick is changing
\r
130 new_nick - the new nickname
\r
132 # see if that nick already exists
\r
133 stripped_nick = strip_colors(player.nick)
\r
135 player_nick = session.query(PlayerNick).filter_by(
\r
136 player_id=player.player_id, stripped_nick=stripped_nick).one()
\r
137 except NoResultFound, e:
\r
138 # player_id/stripped_nick not found, create one
\r
139 # but we don't store "Anonymous Player #N"
\r
140 if not re.search('^Anonymous Player #\d+$', player.nick):
\r
141 player_nick = PlayerNick()
\r
142 player_nick.player_id = player.player_id
\r
143 player_nick.stripped_nick = player.stripped_nick
\r
144 player_nick.nick = player.nick
\r
145 session.add(player_nick)
\r
147 # We change to the new nick regardless
\r
148 player.nick = new_nick
\r
149 player.stripped_nick = strip_colors(new_nick)
\r
150 session.add(player)
\r
153 def get_or_create_server(session=None, name=None, hashkey=None, ip_addr=None,
\r
156 Find a server by name or create one if not found. Parameters:
\r
158 session - SQLAlchemy database session factory
\r
159 name - server name of the server to be found or created
\r
160 hashkey - server hashkey
\r
163 # find one by that name, if it exists
\r
164 server = session.query(Server).filter_by(name=name).one()
\r
166 # store new hashkey
\r
167 if server.hashkey != hashkey:
\r
168 server.hashkey = hashkey
\r
169 session.add(server)
\r
171 # store new IP address
\r
172 if server.ip_addr != ip_addr:
\r
173 server.ip_addr = ip_addr
\r
174 session.add(server)
\r
176 # store new revision
\r
177 if server.revision != revision:
\r
178 server.revision = revision
\r
179 session.add(server)
\r
181 log.debug("Found existing server {0}".format(server.server_id))
\r
183 except MultipleResultsFound, e:
\r
184 # multiple found, so also filter by hashkey
\r
185 server = session.query(Server).filter_by(name=name).\
\r
186 filter_by(hashkey=hashkey).one()
\r
187 log.debug("Found existing server {0}".format(server.server_id))
\r
189 except NoResultFound, e:
\r
190 # not found, create one
\r
191 server = Server(name=name, hashkey=hashkey)
\r
192 session.add(server)
\r
194 log.debug("Created server {0} with hashkey {1}".format(
\r
195 server.server_id, server.hashkey))
\r
200 def get_or_create_map(session=None, name=None):
\r
202 Find a map by name or create one if not found. Parameters:
\r
204 session - SQLAlchemy database session factory
\r
205 name - map name of the map to be found or created
\r
208 # find one by the name, if it exists
\r
209 gmap = session.query(Map).filter_by(name=name).one()
\r
210 log.debug("Found map id {0}: {1}".format(gmap.map_id,
\r
212 except NoResultFound, e:
\r
213 gmap = Map(name=name)
\r
216 log.debug("Created map id {0}: {1}".format(gmap.map_id,
\r
218 except MultipleResultsFound, e:
\r
219 # multiple found, so use the first one but warn
\r
221 gmaps = session.query(Map).filter_by(name=name).order_by(
\r
224 log.debug("Found map id {0}: {1} but found \
\r
225 multiple".format(gmap.map_id, gmap.name))
\r
230 def create_game(session=None, start_dt=None, game_type_cd=None,
\r
231 server_id=None, map_id=None, winner=None, match_id=None):
\r
233 Creates a game. Parameters:
\r
235 session - SQLAlchemy database session factory
\r
236 start_dt - when the game started (datetime object)
\r
237 game_type_cd - the game type of the game being played
\r
238 server_id - server identifier of the server hosting the game
\r
239 map_id - map on which the game was played
\r
240 winner - the team id of the team that won
\r
242 seq = Sequence('games_game_id_seq')
\r
243 game_id = session.execute(seq)
\r
244 game = Game(game_id=game_id, start_dt=start_dt, game_type_cd=game_type_cd,
\r
245 server_id=server_id, map_id=map_id, winner=winner)
\r
246 game.match_id = match_id
\r
249 session.query(Game).filter(Game.server_id==server_id).\
\r
250 filter(Game.match_id==match_id).one()
\r
251 # if a game under the same server and match_id found,
\r
252 # this is a duplicate game and can be ignored
\r
253 raise pyramid.httpexceptions.HTTPOk
\r
254 except NoResultFound, e:
\r
255 # server_id/match_id combination not found. game is ok to insert
\r
257 log.debug("Created game id {0} on server {1}, map {2} at \
\r
258 {3}".format(game.game_id,
\r
259 server_id, map_id, start_dt))
\r
264 def get_or_create_player(session=None, hashkey=None, nick=None):
\r
266 Finds a player by hashkey or creates a new one (along with a
\r
267 corresponding hashkey entry. Parameters:
\r
269 session - SQLAlchemy database session factory
\r
270 hashkey - hashkey of the player to be found or created
\r
271 nick - nick of the player (in case of a first time create)
\r
274 if re.search('^bot#\d+$', hashkey):
\r
275 player = session.query(Player).filter_by(player_id=1).one()
\r
276 # if we have an untracked player
\r
277 elif re.search('^player#\d+$', hashkey):
\r
278 player = session.query(Player).filter_by(player_id=2).one()
\r
279 # else it is a tracked player
\r
281 # see if the player is already in the database
\r
282 # if not, create one and the hashkey along with it
\r
284 hk = session.query(Hashkey).filter_by(
\r
285 hashkey=hashkey).one()
\r
286 player = session.query(Player).filter_by(
\r
287 player_id=hk.player_id).one()
\r
288 log.debug("Found existing player {0} with hashkey {1}".format(
\r
289 player.player_id, hashkey))
\r
292 session.add(player)
\r
295 # if nick is given to us, use it. If not, use "Anonymous Player"
\r
296 # with a suffix added for uniqueness.
\r
298 player.nick = nick[:128]
\r
299 player.stripped_nick = strip_colors(nick[:128])
\r
301 player.nick = "Anonymous Player #{0}".format(player.player_id)
\r
302 player.stripped_nick = player.nick
\r
304 hk = Hashkey(player_id=player.player_id, hashkey=hashkey)
\r
306 log.debug("Created player {0} ({2}) with hashkey {1}".format(
\r
307 player.player_id, hashkey, player.nick.encode('utf-8')))
\r
311 def create_player_game_stat(session=None, player=None,
\r
312 game=None, player_events=None):
\r
314 Creates game statistics for a given player in a given game. Parameters:
\r
316 session - SQLAlchemy session factory
\r
317 player - Player record of the player who owns the stats
\r
318 game - Game record for the game to which the stats pertain
\r
319 player_events - dictionary for the actual stats that need to be transformed
\r
322 # in here setup default values (e.g. if game type is CTF then
\r
323 # set kills=0, score=0, captures=0, pickups=0, fckills=0, etc
\r
324 # TODO: use game's create date here instead of now()
\r
325 seq = Sequence('player_game_stats_player_game_stat_id_seq')
\r
326 pgstat_id = session.execute(seq)
\r
327 pgstat = PlayerGameStat(player_game_stat_id=pgstat_id,
\r
328 create_dt=datetime.datetime.utcnow())
\r
330 # set player id from player record
\r
331 pgstat.player_id = player.player_id
\r
333 #set game id from game record
\r
334 pgstat.game_id = game.game_id
\r
336 # all games have a score
\r
339 if game.game_type_cd == 'dm' or game.game_type_cd == 'tdm' or game.game_type_cd == 'duel':
\r
342 pgstat.suicides = 0
\r
343 elif game.game_type_cd == 'ctf':
\r
345 pgstat.captures = 0
\r
349 pgstat.carrier_frags = 0
\r
351 for (key,value) in player_events.items():
\r
352 if key == 'n': pgstat.nick = value[:128]
\r
353 if key == 't': pgstat.team = value
\r
354 if key == 'rank': pgstat.rank = value
\r
355 if key == 'alivetime':
\r
356 pgstat.alivetime = datetime.timedelta(seconds=int(round(float(value))))
\r
357 if key == 'scoreboard-drops': pgstat.drops = value
\r
358 if key == 'scoreboard-returns': pgstat.returns = value
\r
359 if key == 'scoreboard-fckills': pgstat.carrier_frags = value
\r
360 if key == 'scoreboard-pickups': pgstat.pickups = value
\r
361 if key == 'scoreboard-caps': pgstat.captures = value
\r
362 if key == 'scoreboard-score': pgstat.score = value
\r
363 if key == 'scoreboard-deaths': pgstat.deaths = value
\r
364 if key == 'scoreboard-kills': pgstat.kills = value
\r
365 if key == 'scoreboard-suicides': pgstat.suicides = value
\r
367 # check to see if we had a name, and if
\r
368 # not use the name from the player id
\r
369 if pgstat.nick == None:
\r
370 pgstat.nick = player.nick
\r
372 # whichever nick we ended up with, strip it and store as the stripped_nick
\r
373 pgstat.stripped_nick = qfont_decode(strip_colors(pgstat.nick))
\r
375 # if the nick we end up with is different from the one in the
\r
376 # player record, change the nick to reflect the new value
\r
377 if pgstat.nick != player.nick and player.player_id > 2:
\r
378 register_new_nick(session, player, pgstat.nick)
\r
380 # if the player is ranked #1 and it is a team game, set the game's winner
\r
381 # to be the team of that player
\r
382 # FIXME: this is a hack, should be using the 'W' field (not present)
\r
383 if pgstat.rank == '1' and pgstat.team:
\r
384 game.winner = pgstat.team
\r
387 session.add(pgstat)
\r
392 def create_player_weapon_stats(session=None, player=None,
\r
393 game=None, pgstat=None, player_events=None):
\r
395 Creates accuracy records for each weapon used by a given player in a
\r
396 given game. Parameters:
\r
398 session - SQLAlchemy session factory object
\r
399 player - Player record who owns the weapon stats
\r
400 game - Game record in which the stats were created
\r
401 pgstat - Corresponding PlayerGameStat record for these weapon stats
\r
402 player_events - dictionary containing the raw weapon values that need to be
\r
407 for (key,value) in player_events.items():
\r
408 matched = re.search("acc-(.*?)-cnt-fired", key)
\r
410 weapon_cd = matched.group(1)
\r
411 seq = Sequence('player_weapon_stats_player_weapon_stats_id_seq')
\r
412 pwstat_id = session.execute(seq)
\r
413 pwstat = PlayerWeaponStat()
\r
414 pwstat.player_weapon_stats_id = pwstat_id
\r
415 pwstat.player_id = player.player_id
\r
416 pwstat.game_id = game.game_id
\r
417 pwstat.player_game_stat_id = pgstat.player_game_stat_id
\r
418 pwstat.weapon_cd = weapon_cd
\r
420 if 'n' in player_events:
\r
421 pwstat.nick = player_events['n']
\r
423 pwstat.nick = player_events['P']
\r
425 if 'acc-' + weapon_cd + '-cnt-fired' in player_events:
\r
426 pwstat.fired = int(round(float(
\r
427 player_events['acc-' + weapon_cd + '-cnt-fired'])))
\r
428 if 'acc-' + weapon_cd + '-fired' in player_events:
\r
429 pwstat.max = int(round(float(
\r
430 player_events['acc-' + weapon_cd + '-fired'])))
\r
431 if 'acc-' + weapon_cd + '-cnt-hit' in player_events:
\r
432 pwstat.hit = int(round(float(
\r
433 player_events['acc-' + weapon_cd + '-cnt-hit'])))
\r
434 if 'acc-' + weapon_cd + '-hit' in player_events:
\r
435 pwstat.actual = int(round(float(
\r
436 player_events['acc-' + weapon_cd + '-hit'])))
\r
437 if 'acc-' + weapon_cd + '-frags' in player_events:
\r
438 pwstat.frags = int(round(float(
\r
439 player_events['acc-' + weapon_cd + '-frags'])))
\r
441 session.add(pwstat)
\r
442 pwstats.append(pwstat)
\r
447 def parse_body(request):
\r
449 Parses the POST request body for a stats submission
\r
451 # storage vars for the request body
\r
454 current_team = None
\r
457 for line in request.body.split('\n'):
\r
459 (key, value) = line.strip().split(' ', 1)
\r
461 # Server (S) and Nick (n) fields can have international characters.
\r
462 # We convert to UTF-8.
\r
464 value = unicode(value, 'utf-8')
\r
466 if key in 'V' 'T' 'G' 'M' 'S' 'C' 'R' 'W' 'I':
\r
467 game_meta[key] = value
\r
470 # if we were working on a player record already, append
\r
471 # it and work on a new one (only set team info)
\r
472 if len(player_events) != 0:
\r
473 players.append(player_events)
\r
476 player_events[key] = value
\r
479 (subkey, subvalue) = value.split(' ', 1)
\r
480 player_events[subkey] = subvalue
\r
482 player_events[key] = value
\r
484 player_events[key] = value
\r
486 # no key/value pair - move on to the next line
\r
489 # add the last player we were working on
\r
490 if len(player_events) > 0:
\r
491 players.append(player_events)
\r
493 return (game_meta, players)
\r
496 def create_player_stats(session=None, player=None, game=None,
\r
497 player_events=None):
\r
499 Creates player game and weapon stats according to what type of player
\r
501 pgstat = create_player_game_stat(session=session,
\r
502 player=player, game=game, player_events=player_events)
\r
504 #TODO: put this into a config setting in the ini file?
\r
505 if not re.search('^bot#\d+$', player_events['P']):
\r
506 create_player_weapon_stats(session=session,
\r
507 player=player, game=game, pgstat=pgstat,
\r
508 player_events=player_events)
\r
511 def stats_submit(request):
\r
513 Entry handler for POST stats submissions.
\r
516 session = DBSession()
\r
518 log.debug("\n----- BEGIN REQUEST BODY -----\n" + request.body +
\r
519 "----- END REQUEST BODY -----\n\n")
\r
521 (idfp, status) = verify_request(request)
\r
523 log.debug("ERROR: Unverified request")
\r
524 raise pyramid.httpexceptions.HTTPUnauthorized("Unverified request")
\r
526 (game_meta, players) = parse_body(request)
\r
528 if not has_required_metadata(game_meta):
\r
529 log.debug("ERROR: Required game meta missing")
\r
530 raise pyramid.exceptions.HTTPUnprocessableEntity("Missing game meta")
\r
532 if not is_supported_gametype(game_meta['G']):
\r
533 log.debug("ERROR: Unsupported gametype")
\r
534 raise pyramid.httpexceptions.HTTPOk("OK")
\r
536 if not has_minimum_real_players(request.registry.settings, players):
\r
537 log.debug("ERROR: Not enough real players")
\r
538 raise pyramid.httpexceptions.HTTPOk("OK")
\r
540 # FIXME: if we have two players and game type is 'dm',
\r
541 # change this into a 'duel' gametype. This should be
\r
542 # removed when the stats actually send 'duel' instead of 'dm'
\r
543 if num_real_players(players) == 2 and game_meta['G'] == 'dm':
\r
544 game_meta['G'] = 'duel'
\r
546 server = get_or_create_server(session=session, hashkey=idfp,
\r
547 name=game_meta['S'], revision=game_meta['R'],
\r
548 ip_addr=get_remote_addr(request))
\r
550 gmap = get_or_create_map(session=session, name=game_meta['M'])
\r
552 # FIXME: use the gmtime instead of utcnow() when the timezone bug is
\r
554 game = create_game(session=session,
\r
555 start_dt=datetime.datetime.utcnow(),
\r
556 #start_dt=datetime.datetime(
\r
557 #*time.gmtime(float(game_meta['T']))[:6]),
\r
558 server_id=server.server_id, game_type_cd=game_meta['G'],
\r
559 map_id=gmap.map_id, match_id=game_meta['I'])
\r
561 # find or create a record for each player
\r
562 # and add stats for each if they were present at the end
\r
564 for player_events in players:
\r
565 if 'n' in player_events:
\r
566 nick = player_events['n']
\r
570 if 'matches' in player_events and 'scoreboardvalid' \
\r
572 player = get_or_create_player(session=session,
\r
573 hashkey=player_events['P'], nick=nick)
\r
574 log.debug('Creating stats for %s' % player_events['P'])
\r
575 create_player_stats(session=session, player=player, game=game,
\r
576 player_events=player_events)
\r
579 log.debug('Success! Stats recorded.')
\r
580 return Response('200 OK')
\r
581 except Exception as e:
\r