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 r = re.compile(r'acc-.*-cnt-fired')
\r
27 flg_nonzero_score = False
\r
28 flg_acc_events = False
\r
30 for events in players:
\r
31 if is_real_player(events):
\r
32 for (key,value) in events.items():
\r
33 if key == 'scoreboard-score' and value != '0':
\r
34 flg_nonzero_score = True
\r
36 flg_acc_events = True
\r
38 return not (flg_nonzero_score and flg_acc_events)
\r
40 def get_remote_addr(request):
\r
41 """Get the Xonotic server's IP address"""
\r
42 if 'X-Forwarded-For' in request.headers:
\r
43 return request.headers['X-Forwarded-For']
\r
45 return request.remote_addr
\r
48 def is_supported_gametype(gametype):
\r
49 """Whether a gametype is supported or not"""
\r
50 flg_supported = True
\r
52 if gametype == 'cts' or gametype == 'ca' or gametype == 'lms':
\r
53 flg_supported = False
\r
55 return flg_supported
\r
58 def verify_request(request):
\r
60 (idfp, status) = d0_blind_id_verify(
\r
61 sig=request.headers['X-D0-Blind-Id-Detached-Signature'],
\r
63 postdata=request.body)
\r
65 log.debug('\nidfp: {0}\nstatus: {1}'.format(idfp, status))
\r
70 return (idfp, status)
\r
73 def num_real_players(player_events):
\r
75 Returns the number of real players (those who played
\r
76 and are on the scoreboard).
\r
80 for events in player_events:
\r
81 if is_real_player(events):
\r
87 def has_minimum_real_players(settings, player_events):
\r
89 Determines if the collection of player events has enough "real" players
\r
90 to store in the database. The minimum setting comes from the config file
\r
91 under the setting xonstat.minimum_real_players.
\r
93 flg_has_min_real_players = True
\r
96 minimum_required_players = int(
\r
97 settings['xonstat.minimum_required_players'])
\r
99 minimum_required_players = 2
\r
101 real_players = num_real_players(player_events)
\r
103 #TODO: put this into a config setting in the ini file?
\r
104 if real_players < minimum_required_players:
\r
105 flg_has_min_real_players = False
\r
107 return flg_has_min_real_players
\r
110 def has_required_metadata(metadata):
\r
112 Determines if a give set of metadata has enough data to create a game,
\r
113 server, and map with.
\r
115 flg_has_req_metadata = True
\r
117 if 'T' not in metadata or\
\r
118 'G' not in metadata or\
\r
119 'M' not in metadata or\
\r
120 'I' not in metadata or\
\r
121 'S' not in metadata:
\r
122 flg_has_req_metadata = False
\r
124 return flg_has_req_metadata
\r
127 def is_real_player(events):
\r
129 Determines if a given set of player events correspond with a player who
\r
131 1) is not a bot (P event does not look like a bot)
\r
132 2) played in the game (matches 1)
\r
133 3) was present at the end of the game (scoreboardvalid 1)
\r
135 Returns True if the player meets the above conditions, and false otherwise.
\r
137 flg_is_real = False
\r
139 if not events['P'].startswith('bot'):
\r
140 # removing 'joins' here due to bug, but it should be here
\r
141 if 'matches' in events and 'scoreboardvalid' in events:
\r
147 def register_new_nick(session, player, new_nick):
\r
149 Change the player record's nick to the newly found nick. Store the old
\r
150 nick in the player_nicks table for that player.
\r
152 session - SQLAlchemy database session factory
\r
153 player - player record whose nick is changing
\r
154 new_nick - the new nickname
\r
156 # see if that nick already exists
\r
157 stripped_nick = strip_colors(player.nick)
\r
159 player_nick = session.query(PlayerNick).filter_by(
\r
160 player_id=player.player_id, stripped_nick=stripped_nick).one()
\r
161 except NoResultFound, e:
\r
162 # player_id/stripped_nick not found, create one
\r
163 # but we don't store "Anonymous Player #N"
\r
164 if not re.search('^Anonymous Player #\d+$', player.nick):
\r
165 player_nick = PlayerNick()
\r
166 player_nick.player_id = player.player_id
\r
167 player_nick.stripped_nick = player.stripped_nick
\r
168 player_nick.nick = player.nick
\r
169 session.add(player_nick)
\r
171 # We change to the new nick regardless
\r
172 player.nick = new_nick
\r
173 player.stripped_nick = strip_colors(new_nick)
\r
174 session.add(player)
\r
177 def get_or_create_server(session=None, name=None, hashkey=None, ip_addr=None,
\r
180 Find a server by name or create one if not found. Parameters:
\r
182 session - SQLAlchemy database session factory
\r
183 name - server name of the server to be found or created
\r
184 hashkey - server hashkey
\r
187 # find one by that name, if it exists
\r
188 server = session.query(Server).filter_by(name=name).one()
\r
190 # store new hashkey
\r
191 if server.hashkey != hashkey:
\r
192 server.hashkey = hashkey
\r
193 session.add(server)
\r
195 # store new IP address
\r
196 if server.ip_addr != ip_addr:
\r
197 server.ip_addr = ip_addr
\r
198 session.add(server)
\r
200 # store new revision
\r
201 if server.revision != revision:
\r
202 server.revision = revision
\r
203 session.add(server)
\r
205 log.debug("Found existing server {0}".format(server.server_id))
\r
207 except MultipleResultsFound, e:
\r
208 # multiple found, so also filter by hashkey
\r
209 server = session.query(Server).filter_by(name=name).\
\r
210 filter_by(hashkey=hashkey).one()
\r
211 log.debug("Found existing server {0}".format(server.server_id))
\r
213 except NoResultFound, e:
\r
214 # not found, create one
\r
215 server = Server(name=name, hashkey=hashkey)
\r
216 session.add(server)
\r
218 log.debug("Created server {0} with hashkey {1}".format(
\r
219 server.server_id, server.hashkey))
\r
224 def get_or_create_map(session=None, name=None):
\r
226 Find a map by name or create one if not found. Parameters:
\r
228 session - SQLAlchemy database session factory
\r
229 name - map name of the map to be found or created
\r
232 # find one by the name, if it exists
\r
233 gmap = session.query(Map).filter_by(name=name).one()
\r
234 log.debug("Found map id {0}: {1}".format(gmap.map_id,
\r
236 except NoResultFound, e:
\r
237 gmap = Map(name=name)
\r
240 log.debug("Created map id {0}: {1}".format(gmap.map_id,
\r
242 except MultipleResultsFound, e:
\r
243 # multiple found, so use the first one but warn
\r
245 gmaps = session.query(Map).filter_by(name=name).order_by(
\r
248 log.debug("Found map id {0}: {1} but found \
\r
249 multiple".format(gmap.map_id, gmap.name))
\r
254 def create_game(session=None, start_dt=None, game_type_cd=None,
\r
255 server_id=None, map_id=None, winner=None, match_id=None):
\r
257 Creates a game. Parameters:
\r
259 session - SQLAlchemy database session factory
\r
260 start_dt - when the game started (datetime object)
\r
261 game_type_cd - the game type of the game being played
\r
262 server_id - server identifier of the server hosting the game
\r
263 map_id - map on which the game was played
\r
264 winner - the team id of the team that won
\r
266 seq = Sequence('games_game_id_seq')
\r
267 game_id = session.execute(seq)
\r
268 game = Game(game_id=game_id, start_dt=start_dt, game_type_cd=game_type_cd,
\r
269 server_id=server_id, map_id=map_id, winner=winner)
\r
270 game.match_id = match_id
\r
273 session.query(Game).filter(Game.server_id==server_id).\
\r
274 filter(Game.match_id==match_id).one()
\r
275 # if a game under the same server and match_id found,
\r
276 # this is a duplicate game and can be ignored
\r
277 raise pyramid.httpexceptions.HTTPOk
\r
278 except NoResultFound, e:
\r
279 # server_id/match_id combination not found. game is ok to insert
\r
281 log.debug("Created game id {0} on server {1}, map {2} at \
\r
282 {3}".format(game.game_id,
\r
283 server_id, map_id, start_dt))
\r
288 def get_or_create_player(session=None, hashkey=None, nick=None):
\r
290 Finds a player by hashkey or creates a new one (along with a
\r
291 corresponding hashkey entry. Parameters:
\r
293 session - SQLAlchemy database session factory
\r
294 hashkey - hashkey of the player to be found or created
\r
295 nick - nick of the player (in case of a first time create)
\r
298 if re.search('^bot#\d+$', hashkey):
\r
299 player = session.query(Player).filter_by(player_id=1).one()
\r
300 # if we have an untracked player
\r
301 elif re.search('^player#\d+$', hashkey):
\r
302 player = session.query(Player).filter_by(player_id=2).one()
\r
303 # else it is a tracked player
\r
305 # see if the player is already in the database
\r
306 # if not, create one and the hashkey along with it
\r
308 hk = session.query(Hashkey).filter_by(
\r
309 hashkey=hashkey).one()
\r
310 player = session.query(Player).filter_by(
\r
311 player_id=hk.player_id).one()
\r
312 log.debug("Found existing player {0} with hashkey {1}".format(
\r
313 player.player_id, hashkey))
\r
316 session.add(player)
\r
319 # if nick is given to us, use it. If not, use "Anonymous Player"
\r
320 # with a suffix added for uniqueness.
\r
322 player.nick = nick[:128]
\r
323 player.stripped_nick = strip_colors(nick[:128])
\r
325 player.nick = "Anonymous Player #{0}".format(player.player_id)
\r
326 player.stripped_nick = player.nick
\r
328 hk = Hashkey(player_id=player.player_id, hashkey=hashkey)
\r
330 log.debug("Created player {0} ({2}) with hashkey {1}".format(
\r
331 player.player_id, hashkey, player.nick.encode('utf-8')))
\r
335 def create_player_game_stat(session=None, player=None,
\r
336 game=None, player_events=None):
\r
338 Creates game statistics for a given player in a given game. Parameters:
\r
340 session - SQLAlchemy session factory
\r
341 player - Player record of the player who owns the stats
\r
342 game - Game record for the game to which the stats pertain
\r
343 player_events - dictionary for the actual stats that need to be transformed
\r
346 # in here setup default values (e.g. if game type is CTF then
\r
347 # set kills=0, score=0, captures=0, pickups=0, fckills=0, etc
\r
348 # TODO: use game's create date here instead of now()
\r
349 seq = Sequence('player_game_stats_player_game_stat_id_seq')
\r
350 pgstat_id = session.execute(seq)
\r
351 pgstat = PlayerGameStat(player_game_stat_id=pgstat_id,
\r
352 create_dt=datetime.datetime.utcnow())
\r
354 # set player id from player record
\r
355 pgstat.player_id = player.player_id
\r
357 #set game id from game record
\r
358 pgstat.game_id = game.game_id
\r
360 # all games have a score
\r
363 if game.game_type_cd == 'dm' or game.game_type_cd == 'tdm' or game.game_type_cd == 'duel':
\r
366 pgstat.suicides = 0
\r
367 elif game.game_type_cd == 'ctf':
\r
369 pgstat.captures = 0
\r
373 pgstat.carrier_frags = 0
\r
375 for (key,value) in player_events.items():
\r
376 if key == 'n': pgstat.nick = value[:128]
\r
377 if key == 't': pgstat.team = value
\r
378 if key == 'rank': pgstat.rank = value
\r
379 if key == 'alivetime':
\r
380 pgstat.alivetime = datetime.timedelta(seconds=int(round(float(value))))
\r
381 if key == 'scoreboard-drops': pgstat.drops = value
\r
382 if key == 'scoreboard-returns': pgstat.returns = value
\r
383 if key == 'scoreboard-fckills': pgstat.carrier_frags = value
\r
384 if key == 'scoreboard-pickups': pgstat.pickups = value
\r
385 if key == 'scoreboard-caps': pgstat.captures = value
\r
386 if key == 'scoreboard-score': pgstat.score = value
\r
387 if key == 'scoreboard-deaths': pgstat.deaths = value
\r
388 if key == 'scoreboard-kills': pgstat.kills = value
\r
389 if key == 'scoreboard-suicides': pgstat.suicides = value
\r
391 # check to see if we had a name, and if
\r
392 # not use the name from the player id
\r
393 if pgstat.nick == None:
\r
394 pgstat.nick = player.nick
\r
396 # whichever nick we ended up with, strip it and store as the stripped_nick
\r
397 pgstat.stripped_nick = qfont_decode(strip_colors(pgstat.nick))
\r
399 # if the nick we end up with is different from the one in the
\r
400 # player record, change the nick to reflect the new value
\r
401 if pgstat.nick != player.nick and player.player_id > 2:
\r
402 register_new_nick(session, player, pgstat.nick)
\r
404 # if the player is ranked #1 and it is a team game, set the game's winner
\r
405 # to be the team of that player
\r
406 # FIXME: this is a hack, should be using the 'W' field (not present)
\r
407 if pgstat.rank == '1' and pgstat.team:
\r
408 game.winner = pgstat.team
\r
411 session.add(pgstat)
\r
416 def create_player_weapon_stats(session=None, player=None,
\r
417 game=None, pgstat=None, player_events=None):
\r
419 Creates accuracy records for each weapon used by a given player in a
\r
420 given game. Parameters:
\r
422 session - SQLAlchemy session factory object
\r
423 player - Player record who owns the weapon stats
\r
424 game - Game record in which the stats were created
\r
425 pgstat - Corresponding PlayerGameStat record for these weapon stats
\r
426 player_events - dictionary containing the raw weapon values that need to be
\r
431 for (key,value) in player_events.items():
\r
432 matched = re.search("acc-(.*?)-cnt-fired", key)
\r
434 weapon_cd = matched.group(1)
\r
435 seq = Sequence('player_weapon_stats_player_weapon_stats_id_seq')
\r
436 pwstat_id = session.execute(seq)
\r
437 pwstat = PlayerWeaponStat()
\r
438 pwstat.player_weapon_stats_id = pwstat_id
\r
439 pwstat.player_id = player.player_id
\r
440 pwstat.game_id = game.game_id
\r
441 pwstat.player_game_stat_id = pgstat.player_game_stat_id
\r
442 pwstat.weapon_cd = weapon_cd
\r
444 if 'n' in player_events:
\r
445 pwstat.nick = player_events['n']
\r
447 pwstat.nick = player_events['P']
\r
449 if 'acc-' + weapon_cd + '-cnt-fired' in player_events:
\r
450 pwstat.fired = int(round(float(
\r
451 player_events['acc-' + weapon_cd + '-cnt-fired'])))
\r
452 if 'acc-' + weapon_cd + '-fired' in player_events:
\r
453 pwstat.max = int(round(float(
\r
454 player_events['acc-' + weapon_cd + '-fired'])))
\r
455 if 'acc-' + weapon_cd + '-cnt-hit' in player_events:
\r
456 pwstat.hit = int(round(float(
\r
457 player_events['acc-' + weapon_cd + '-cnt-hit'])))
\r
458 if 'acc-' + weapon_cd + '-hit' in player_events:
\r
459 pwstat.actual = int(round(float(
\r
460 player_events['acc-' + weapon_cd + '-hit'])))
\r
461 if 'acc-' + weapon_cd + '-frags' in player_events:
\r
462 pwstat.frags = int(round(float(
\r
463 player_events['acc-' + weapon_cd + '-frags'])))
\r
465 session.add(pwstat)
\r
466 pwstats.append(pwstat)
\r
471 def parse_body(request):
\r
473 Parses the POST request body for a stats submission
\r
475 # storage vars for the request body
\r
478 current_team = None
\r
481 for line in request.body.split('\n'):
\r
483 (key, value) = line.strip().split(' ', 1)
\r
485 # Server (S) and Nick (n) fields can have international characters.
\r
486 # We convert to UTF-8.
\r
488 value = unicode(value, 'utf-8')
\r
490 if key in 'V' 'T' 'G' 'M' 'S' 'C' 'R' 'W' 'I':
\r
491 game_meta[key] = value
\r
494 # if we were working on a player record already, append
\r
495 # it and work on a new one (only set team info)
\r
496 if len(player_events) != 0:
\r
497 players.append(player_events)
\r
500 player_events[key] = value
\r
503 (subkey, subvalue) = value.split(' ', 1)
\r
504 player_events[subkey] = subvalue
\r
506 player_events[key] = value
\r
508 player_events[key] = value
\r
510 # no key/value pair - move on to the next line
\r
513 # add the last player we were working on
\r
514 if len(player_events) > 0:
\r
515 players.append(player_events)
\r
517 return (game_meta, players)
\r
520 def create_player_stats(session=None, player=None, game=None,
\r
521 player_events=None):
\r
523 Creates player game and weapon stats according to what type of player
\r
525 pgstat = create_player_game_stat(session=session,
\r
526 player=player, game=game, player_events=player_events)
\r
528 #TODO: put this into a config setting in the ini file?
\r
529 if not re.search('^bot#\d+$', player_events['P']):
\r
530 create_player_weapon_stats(session=session,
\r
531 player=player, game=game, pgstat=pgstat,
\r
532 player_events=player_events)
\r
535 def stats_submit(request):
\r
537 Entry handler for POST stats submissions.
\r
540 session = DBSession()
\r
542 log.debug("\n----- BEGIN REQUEST BODY -----\n" + request.body +
\r
543 "----- END REQUEST BODY -----\n\n")
\r
545 (idfp, status) = verify_request(request)
\r
547 log.debug("ERROR: Unverified request")
\r
548 raise pyramid.httpexceptions.HTTPUnauthorized("Unverified request")
\r
550 (game_meta, players) = parse_body(request)
\r
552 if not has_required_metadata(game_meta):
\r
553 log.debug("ERROR: Required game meta missing")
\r
554 raise pyramid.exceptions.HTTPUnprocessableEntity("Missing game meta")
\r
556 if not is_supported_gametype(game_meta['G']):
\r
557 log.debug("ERROR: Unsupported gametype")
\r
558 raise pyramid.httpexceptions.HTTPOk("OK")
\r
560 if not has_minimum_real_players(request.registry.settings, players):
\r
561 log.debug("ERROR: Not enough real players")
\r
562 raise pyramid.httpexceptions.HTTPOk("OK")
\r
564 if is_blank_game(players):
\r
565 log.debug("ERROR: Blank game")
\r
566 raise pyramid.httpexceptions.HTTPOk("OK")
\r
568 # FIXME: if we have two players and game type is 'dm',
\r
569 # change this into a 'duel' gametype. This should be
\r
570 # removed when the stats actually send 'duel' instead of 'dm'
\r
571 if num_real_players(players) == 2 and game_meta['G'] == 'dm':
\r
572 game_meta['G'] = 'duel'
\r
574 server = get_or_create_server(session=session, hashkey=idfp,
\r
575 name=game_meta['S'], revision=game_meta['R'],
\r
576 ip_addr=get_remote_addr(request))
\r
578 gmap = get_or_create_map(session=session, name=game_meta['M'])
\r
580 # FIXME: use the gmtime instead of utcnow() when the timezone bug is
\r
582 game = create_game(session=session,
\r
583 start_dt=datetime.datetime.utcnow(),
\r
584 #start_dt=datetime.datetime(
\r
585 #*time.gmtime(float(game_meta['T']))[:6]),
\r
586 server_id=server.server_id, game_type_cd=game_meta['G'],
\r
587 map_id=gmap.map_id, match_id=game_meta['I'])
\r
589 # find or create a record for each player
\r
590 # and add stats for each if they were present at the end
\r
592 for player_events in players:
\r
593 if 'n' in player_events:
\r
594 nick = player_events['n']
\r
598 if 'matches' in player_events and 'scoreboardvalid' \
\r
600 player = get_or_create_player(session=session,
\r
601 hashkey=player_events['P'], nick=nick)
\r
602 log.debug('Creating stats for %s' % player_events['P'])
\r
603 create_player_stats(session=session, player=player, game=game,
\r
604 player_events=player_events)
\r
607 log.debug('Success! Stats recorded.')
\r
608 return Response('200 OK')
\r
609 except Exception as e:
\r