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
17 def is_blank_game(players):
\r
18 """Determine if this is a blank game or not. A blank game is either:
\r
20 1) a match that ended in the warmup stage, where accuracy events are not
\r
23 2) a match in which no player made a positive or negative score AND was
\r
26 flg_nonzero_score = False
\r
27 flg_acc_events = False
\r
29 for events in players:
\r
30 if is_real_player(events):
\r
31 for (key,value) in events.items():
\r
32 if key == 'scoreboard-score' and value != '0':
\r
33 flg_nonzero_score = True
\r
34 if key.startswith('acc-'):
\r
35 flg_acc_events = True
\r
37 return flg_nonzero_score and flg_acc_events
\r
39 def get_remote_addr(request):
\r
40 """Get the Xonotic server's IP address"""
\r
41 if 'X-Forwarded-For' in request.headers:
\r
42 return request.headers['X-Forwarded-For']
\r
44 return request.remote_addr
\r
47 def is_supported_gametype(gametype):
\r
48 """Whether a gametype is supported or not"""
\r
49 flg_supported = True
\r
51 if gametype == 'cts' or gametype == 'ca' or gametype == 'lms':
\r
52 flg_supported = False
\r
54 return flg_supported
\r
57 def verify_request(request):
\r
59 (idfp, status) = d0_blind_id_verify(
\r
60 sig=request.headers['X-D0-Blind-Id-Detached-Signature'],
\r
62 postdata=request.body)
\r
64 log.debug('\nidfp: {0}\nstatus: {1}'.format(idfp, status))
\r
69 return (idfp, status)
\r
72 def num_real_players(player_events):
\r
74 Returns the number of real players (those who played
\r
75 and are on the scoreboard).
\r
79 for events in player_events:
\r
80 if is_real_player(events):
\r
86 def has_minimum_real_players(settings, player_events):
\r
88 Determines if the collection of player events has enough "real" players
\r
89 to store in the database. The minimum setting comes from the config file
\r
90 under the setting xonstat.minimum_real_players.
\r
92 flg_has_min_real_players = True
\r
95 minimum_required_players = int(
\r
96 settings['xonstat.minimum_required_players'])
\r
98 minimum_required_players = 2
\r
100 real_players = num_real_players(player_events)
\r
102 #TODO: put this into a config setting in the ini file?
\r
103 if real_players < minimum_required_players:
\r
104 flg_has_min_real_players = False
\r
106 return flg_has_min_real_players
\r
109 def has_required_metadata(metadata):
\r
111 Determines if a give set of metadata has enough data to create a game,
\r
112 server, and map with.
\r
114 flg_has_req_metadata = True
\r
116 if 'T' not in metadata or\
\r
117 'G' not in metadata or\
\r
118 'M' not in metadata or\
\r
119 'I' not in metadata or\
\r
120 'S' not in metadata:
\r
121 flg_has_req_metadata = False
\r
123 return flg_has_req_metadata
\r
126 def is_real_player(events):
\r
128 Determines if a given set of player events correspond with a player who
\r
130 1) is not a bot (P event does not look like a bot)
\r
131 2) played in the game (matches 1)
\r
132 3) was present at the end of the game (scoreboardvalid 1)
\r
134 Returns True if the player meets the above conditions, and false otherwise.
\r
136 flg_is_real = False
\r
138 if not events['P'].startswith('bot'):
\r
139 # removing 'joins' here due to bug, but it should be here
\r
140 if 'matches' in events and 'scoreboardvalid' in events:
\r
146 def register_new_nick(session, player, new_nick):
\r
148 Change the player record's nick to the newly found nick. Store the old
\r
149 nick in the player_nicks table for that player.
\r
151 session - SQLAlchemy database session factory
\r
152 player - player record whose nick is changing
\r
153 new_nick - the new nickname
\r
155 # see if that nick already exists
\r
156 stripped_nick = strip_colors(player.nick)
\r
158 player_nick = session.query(PlayerNick).filter_by(
\r
159 player_id=player.player_id, stripped_nick=stripped_nick).one()
\r
160 except NoResultFound, e:
\r
161 # player_id/stripped_nick not found, create one
\r
162 # but we don't store "Anonymous Player #N"
\r
163 if not re.search('^Anonymous Player #\d+$', player.nick):
\r
164 player_nick = PlayerNick()
\r
165 player_nick.player_id = player.player_id
\r
166 player_nick.stripped_nick = player.stripped_nick
\r
167 player_nick.nick = player.nick
\r
168 session.add(player_nick)
\r
170 # We change to the new nick regardless
\r
171 player.nick = new_nick
\r
172 player.stripped_nick = strip_colors(new_nick)
\r
173 session.add(player)
\r
176 def get_or_create_server(session=None, name=None, hashkey=None, ip_addr=None,
\r
179 Find a server by name or create one if not found. Parameters:
\r
181 session - SQLAlchemy database session factory
\r
182 name - server name of the server to be found or created
\r
183 hashkey - server hashkey
\r
186 # find one by that name, if it exists
\r
187 server = session.query(Server).filter_by(name=name).one()
\r
189 # store new hashkey
\r
190 if server.hashkey != hashkey:
\r
191 server.hashkey = hashkey
\r
192 session.add(server)
\r
194 # store new IP address
\r
195 if server.ip_addr != ip_addr:
\r
196 server.ip_addr = ip_addr
\r
197 session.add(server)
\r
199 # store new revision
\r
200 if server.revision != revision:
\r
201 server.revision = revision
\r
202 session.add(server)
\r
204 log.debug("Found existing server {0}".format(server.server_id))
\r
206 except MultipleResultsFound, e:
\r
207 # multiple found, so also filter by hashkey
\r
208 server = session.query(Server).filter_by(name=name).\
\r
209 filter_by(hashkey=hashkey).one()
\r
210 log.debug("Found existing server {0}".format(server.server_id))
\r
212 except NoResultFound, e:
\r
213 # not found, create one
\r
214 server = Server(name=name, hashkey=hashkey)
\r
215 session.add(server)
\r
217 log.debug("Created server {0} with hashkey {1}".format(
\r
218 server.server_id, server.hashkey))
\r
223 def get_or_create_map(session=None, name=None):
\r
225 Find a map by name or create one if not found. Parameters:
\r
227 session - SQLAlchemy database session factory
\r
228 name - map name of the map to be found or created
\r
231 # find one by the name, if it exists
\r
232 gmap = session.query(Map).filter_by(name=name).one()
\r
233 log.debug("Found map id {0}: {1}".format(gmap.map_id,
\r
235 except NoResultFound, e:
\r
236 gmap = Map(name=name)
\r
239 log.debug("Created map id {0}: {1}".format(gmap.map_id,
\r
241 except MultipleResultsFound, e:
\r
242 # multiple found, so use the first one but warn
\r
244 gmaps = session.query(Map).filter_by(name=name).order_by(
\r
247 log.debug("Found map id {0}: {1} but found \
\r
248 multiple".format(gmap.map_id, gmap.name))
\r
253 def create_game(session=None, start_dt=None, game_type_cd=None,
\r
254 server_id=None, map_id=None, winner=None, match_id=None):
\r
256 Creates a game. Parameters:
\r
258 session - SQLAlchemy database session factory
\r
259 start_dt - when the game started (datetime object)
\r
260 game_type_cd - the game type of the game being played
\r
261 server_id - server identifier of the server hosting the game
\r
262 map_id - map on which the game was played
\r
263 winner - the team id of the team that won
\r
265 seq = Sequence('games_game_id_seq')
\r
266 game_id = session.execute(seq)
\r
267 game = Game(game_id=game_id, start_dt=start_dt, game_type_cd=game_type_cd,
\r
268 server_id=server_id, map_id=map_id, winner=winner)
\r
269 game.match_id = match_id
\r
272 session.query(Game).filter(Game.server_id==server_id).\
\r
273 filter(Game.match_id==match_id).one()
\r
274 # if a game under the same server and match_id found,
\r
275 # this is a duplicate game and can be ignored
\r
276 raise pyramid.httpexceptions.HTTPOk
\r
277 except NoResultFound, e:
\r
278 # server_id/match_id combination not found. game is ok to insert
\r
280 log.debug("Created game id {0} on server {1}, map {2} at \
\r
281 {3}".format(game.game_id,
\r
282 server_id, map_id, start_dt))
\r
287 def get_or_create_player(session=None, hashkey=None, nick=None):
\r
289 Finds a player by hashkey or creates a new one (along with a
\r
290 corresponding hashkey entry. Parameters:
\r
292 session - SQLAlchemy database session factory
\r
293 hashkey - hashkey of the player to be found or created
\r
294 nick - nick of the player (in case of a first time create)
\r
297 if re.search('^bot#\d+$', hashkey):
\r
298 player = session.query(Player).filter_by(player_id=1).one()
\r
299 # if we have an untracked player
\r
300 elif re.search('^player#\d+$', hashkey):
\r
301 player = session.query(Player).filter_by(player_id=2).one()
\r
302 # else it is a tracked player
\r
304 # see if the player is already in the database
\r
305 # if not, create one and the hashkey along with it
\r
307 hk = session.query(Hashkey).filter_by(
\r
308 hashkey=hashkey).one()
\r
309 player = session.query(Player).filter_by(
\r
310 player_id=hk.player_id).one()
\r
311 log.debug("Found existing player {0} with hashkey {1}".format(
\r
312 player.player_id, hashkey))
\r
315 session.add(player)
\r
318 # if nick is given to us, use it. If not, use "Anonymous Player"
\r
319 # with a suffix added for uniqueness.
\r
321 player.nick = nick[:128]
\r
322 player.stripped_nick = strip_colors(nick[:128])
\r
324 player.nick = "Anonymous Player #{0}".format(player.player_id)
\r
325 player.stripped_nick = player.nick
\r
327 hk = Hashkey(player_id=player.player_id, hashkey=hashkey)
\r
329 log.debug("Created player {0} ({2}) with hashkey {1}".format(
\r
330 player.player_id, hashkey, player.nick.encode('utf-8')))
\r
334 def create_player_game_stat(session=None, player=None,
\r
335 game=None, player_events=None):
\r
337 Creates game statistics for a given player in a given game. Parameters:
\r
339 session - SQLAlchemy session factory
\r
340 player - Player record of the player who owns the stats
\r
341 game - Game record for the game to which the stats pertain
\r
342 player_events - dictionary for the actual stats that need to be transformed
\r
345 # in here setup default values (e.g. if game type is CTF then
\r
346 # set kills=0, score=0, captures=0, pickups=0, fckills=0, etc
\r
347 # TODO: use game's create date here instead of now()
\r
348 seq = Sequence('player_game_stats_player_game_stat_id_seq')
\r
349 pgstat_id = session.execute(seq)
\r
350 pgstat = PlayerGameStat(player_game_stat_id=pgstat_id,
\r
351 create_dt=datetime.datetime.utcnow())
\r
353 # set player id from player record
\r
354 pgstat.player_id = player.player_id
\r
356 #set game id from game record
\r
357 pgstat.game_id = game.game_id
\r
359 # all games have a score
\r
362 if game.game_type_cd == 'dm' or game.game_type_cd == 'tdm' or game.game_type_cd == 'duel':
\r
365 pgstat.suicides = 0
\r
366 elif game.game_type_cd == 'ctf':
\r
368 pgstat.captures = 0
\r
372 pgstat.carrier_frags = 0
\r
374 for (key,value) in player_events.items():
\r
375 if key == 'n': pgstat.nick = value[:128]
\r
376 if key == 't': pgstat.team = value
\r
377 if key == 'rank': pgstat.rank = value
\r
378 if key == 'alivetime':
\r
379 pgstat.alivetime = datetime.timedelta(seconds=int(round(float(value))))
\r
380 if key == 'scoreboard-drops': pgstat.drops = value
\r
381 if key == 'scoreboard-returns': pgstat.returns = value
\r
382 if key == 'scoreboard-fckills': pgstat.carrier_frags = value
\r
383 if key == 'scoreboard-pickups': pgstat.pickups = value
\r
384 if key == 'scoreboard-caps': pgstat.captures = value
\r
385 if key == 'scoreboard-score': pgstat.score = value
\r
386 if key == 'scoreboard-deaths': pgstat.deaths = value
\r
387 if key == 'scoreboard-kills': pgstat.kills = value
\r
388 if key == 'scoreboard-suicides': pgstat.suicides = value
\r
390 # check to see if we had a name, and if
\r
391 # not use the name from the player id
\r
392 if pgstat.nick == None:
\r
393 pgstat.nick = player.nick
\r
395 # whichever nick we ended up with, strip it and store as the stripped_nick
\r
396 pgstat.stripped_nick = qfont_decode(strip_colors(pgstat.nick))
\r
398 # if the nick we end up with is different from the one in the
\r
399 # player record, change the nick to reflect the new value
\r
400 if pgstat.nick != player.nick and player.player_id > 2:
\r
401 register_new_nick(session, player, pgstat.nick)
\r
403 # if the player is ranked #1 and it is a team game, set the game's winner
\r
404 # to be the team of that player
\r
405 # FIXME: this is a hack, should be using the 'W' field (not present)
\r
406 if pgstat.rank == '1' and pgstat.team:
\r
407 game.winner = pgstat.team
\r
410 session.add(pgstat)
\r
415 def create_player_weapon_stats(session=None, player=None,
\r
416 game=None, pgstat=None, player_events=None):
\r
418 Creates accuracy records for each weapon used by a given player in a
\r
419 given game. Parameters:
\r
421 session - SQLAlchemy session factory object
\r
422 player - Player record who owns the weapon stats
\r
423 game - Game record in which the stats were created
\r
424 pgstat - Corresponding PlayerGameStat record for these weapon stats
\r
425 player_events - dictionary containing the raw weapon values that need to be
\r
430 for (key,value) in player_events.items():
\r
431 matched = re.search("acc-(.*?)-cnt-fired", key)
\r
433 weapon_cd = matched.group(1)
\r
434 seq = Sequence('player_weapon_stats_player_weapon_stats_id_seq')
\r
435 pwstat_id = session.execute(seq)
\r
436 pwstat = PlayerWeaponStat()
\r
437 pwstat.player_weapon_stats_id = pwstat_id
\r
438 pwstat.player_id = player.player_id
\r
439 pwstat.game_id = game.game_id
\r
440 pwstat.player_game_stat_id = pgstat.player_game_stat_id
\r
441 pwstat.weapon_cd = weapon_cd
\r
443 if 'n' in player_events:
\r
444 pwstat.nick = player_events['n']
\r
446 pwstat.nick = player_events['P']
\r
448 if 'acc-' + weapon_cd + '-cnt-fired' in player_events:
\r
449 pwstat.fired = int(round(float(
\r
450 player_events['acc-' + weapon_cd + '-cnt-fired'])))
\r
451 if 'acc-' + weapon_cd + '-fired' in player_events:
\r
452 pwstat.max = int(round(float(
\r
453 player_events['acc-' + weapon_cd + '-fired'])))
\r
454 if 'acc-' + weapon_cd + '-cnt-hit' in player_events:
\r
455 pwstat.hit = int(round(float(
\r
456 player_events['acc-' + weapon_cd + '-cnt-hit'])))
\r
457 if 'acc-' + weapon_cd + '-hit' in player_events:
\r
458 pwstat.actual = int(round(float(
\r
459 player_events['acc-' + weapon_cd + '-hit'])))
\r
460 if 'acc-' + weapon_cd + '-frags' in player_events:
\r
461 pwstat.frags = int(round(float(
\r
462 player_events['acc-' + weapon_cd + '-frags'])))
\r
464 session.add(pwstat)
\r
465 pwstats.append(pwstat)
\r
470 def parse_body(request):
\r
472 Parses the POST request body for a stats submission
\r
474 # storage vars for the request body
\r
477 current_team = None
\r
480 for line in request.body.split('\n'):
\r
482 (key, value) = line.strip().split(' ', 1)
\r
484 # Server (S) and Nick (n) fields can have international characters.
\r
485 # We convert to UTF-8.
\r
487 value = unicode(value, 'utf-8')
\r
489 if key in 'V' 'T' 'G' 'M' 'S' 'C' 'R' 'W' 'I':
\r
490 game_meta[key] = value
\r
493 # if we were working on a player record already, append
\r
494 # it and work on a new one (only set team info)
\r
495 if len(player_events) != 0:
\r
496 players.append(player_events)
\r
499 player_events[key] = value
\r
502 (subkey, subvalue) = value.split(' ', 1)
\r
503 player_events[subkey] = subvalue
\r
505 player_events[key] = value
\r
507 player_events[key] = value
\r
509 # no key/value pair - move on to the next line
\r
512 # add the last player we were working on
\r
513 if len(player_events) > 0:
\r
514 players.append(player_events)
\r
516 return (game_meta, players)
\r
519 def create_player_stats(session=None, player=None, game=None,
\r
520 player_events=None):
\r
522 Creates player game and weapon stats according to what type of player
\r
524 pgstat = create_player_game_stat(session=session,
\r
525 player=player, game=game, player_events=player_events)
\r
527 #TODO: put this into a config setting in the ini file?
\r
528 if not re.search('^bot#\d+$', player_events['P']):
\r
529 create_player_weapon_stats(session=session,
\r
530 player=player, game=game, pgstat=pgstat,
\r
531 player_events=player_events)
\r
534 def stats_submit(request):
\r
536 Entry handler for POST stats submissions.
\r
539 session = DBSession()
\r
541 log.debug("\n----- BEGIN REQUEST BODY -----\n" + request.body +
\r
542 "----- END REQUEST BODY -----\n\n")
\r
544 (idfp, status) = verify_request(request)
\r
546 log.debug("ERROR: Unverified request")
\r
547 raise pyramid.httpexceptions.HTTPUnauthorized("Unverified request")
\r
549 (game_meta, players) = parse_body(request)
\r
551 if not has_required_metadata(game_meta):
\r
552 log.debug("ERROR: Required game meta missing")
\r
553 raise pyramid.exceptions.HTTPUnprocessableEntity("Missing game meta")
\r
555 if not is_supported_gametype(game_meta['G']):
\r
556 log.debug("ERROR: Unsupported gametype")
\r
557 raise pyramid.httpexceptions.HTTPOk("OK")
\r
559 if not has_minimum_real_players(request.registry.settings, players):
\r
560 log.debug("ERROR: Not enough real players")
\r
561 raise pyramid.httpexceptions.HTTPOk("OK")
\r
563 if is_blank_game(players):
\r
564 log.debug("ERROR: Blank game")
\r
565 raise pyramid.httpexceptions.HTTPOk("OK")
\r
567 # FIXME: if we have two players and game type is 'dm',
\r
568 # change this into a 'duel' gametype. This should be
\r
569 # removed when the stats actually send 'duel' instead of 'dm'
\r
570 if num_real_players(players) == 2 and game_meta['G'] == 'dm':
\r
571 game_meta['G'] = 'duel'
\r
573 server = get_or_create_server(session=session, hashkey=idfp,
\r
574 name=game_meta['S'], revision=game_meta['R'],
\r
575 ip_addr=get_remote_addr(request))
\r
577 gmap = get_or_create_map(session=session, name=game_meta['M'])
\r
579 # FIXME: use the gmtime instead of utcnow() when the timezone bug is
\r
581 game = create_game(session=session,
\r
582 start_dt=datetime.datetime.utcnow(),
\r
583 #start_dt=datetime.datetime(
\r
584 #*time.gmtime(float(game_meta['T']))[:6]),
\r
585 server_id=server.server_id, game_type_cd=game_meta['G'],
\r
586 map_id=gmap.map_id, match_id=game_meta['I'])
\r
588 # find or create a record for each player
\r
589 # and add stats for each if they were present at the end
\r
591 for player_events in players:
\r
592 if 'n' in player_events:
\r
593 nick = player_events['n']
\r
597 if 'matches' in player_events and 'scoreboardvalid' \
\r
599 player = get_or_create_player(session=session,
\r
600 hashkey=player_events['P'], nick=nick)
\r
601 log.debug('Creating stats for %s' % player_events['P'])
\r
602 create_player_stats(session=session, player=player, game=game,
\r
603 player_events=player_events)
\r
606 log.debug('Success! Stats recorded.')
\r
607 return Response('200 OK')
\r
608 except Exception as e:
\r