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, count_bots=False):
\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, count_bots):
\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, count_bots=False):
\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 # removing 'joins' here due to bug, but it should be here
\r
140 if 'matches' in events and 'scoreboardvalid' in events:
\r
141 if (events['P'].startswith('bot') and count_bots) or \
\r
142 not events['P'].startswith('bot'):
\r
148 def register_new_nick(session, player, new_nick):
\r
150 Change the player record's nick to the newly found nick. Store the old
\r
151 nick in the player_nicks table for that player.
\r
153 session - SQLAlchemy database session factory
\r
154 player - player record whose nick is changing
\r
155 new_nick - the new nickname
\r
157 # see if that nick already exists
\r
158 stripped_nick = strip_colors(player.nick)
\r
160 player_nick = session.query(PlayerNick).filter_by(
\r
161 player_id=player.player_id, stripped_nick=stripped_nick).one()
\r
162 except NoResultFound, e:
\r
163 # player_id/stripped_nick not found, create one
\r
164 # but we don't store "Anonymous Player #N"
\r
165 if not re.search('^Anonymous Player #\d+$', player.nick):
\r
166 player_nick = PlayerNick()
\r
167 player_nick.player_id = player.player_id
\r
168 player_nick.stripped_nick = player.stripped_nick
\r
169 player_nick.nick = player.nick
\r
170 session.add(player_nick)
\r
172 # We change to the new nick regardless
\r
173 player.nick = new_nick
\r
174 player.stripped_nick = strip_colors(new_nick)
\r
175 session.add(player)
\r
178 def get_or_create_server(session=None, name=None, hashkey=None, ip_addr=None,
\r
181 Find a server by name or create one if not found. Parameters:
\r
183 session - SQLAlchemy database session factory
\r
184 name - server name of the server to be found or created
\r
185 hashkey - server hashkey
\r
188 # find one by that name, if it exists
\r
189 server = session.query(Server).filter_by(name=name).one()
\r
191 # store new hashkey
\r
192 if server.hashkey != hashkey:
\r
193 server.hashkey = hashkey
\r
194 session.add(server)
\r
196 # store new IP address
\r
197 if server.ip_addr != ip_addr:
\r
198 server.ip_addr = ip_addr
\r
199 session.add(server)
\r
201 # store new revision
\r
202 if server.revision != revision:
\r
203 server.revision = revision
\r
204 session.add(server)
\r
206 log.debug("Found existing server {0}".format(server.server_id))
\r
208 except MultipleResultsFound, e:
\r
209 # multiple found, so also filter by hashkey
\r
210 server = session.query(Server).filter_by(name=name).\
\r
211 filter_by(hashkey=hashkey).one()
\r
212 log.debug("Found existing server {0}".format(server.server_id))
\r
214 except NoResultFound, e:
\r
215 # not found, create one
\r
216 server = Server(name=name, hashkey=hashkey)
\r
217 session.add(server)
\r
219 log.debug("Created server {0} with hashkey {1}".format(
\r
220 server.server_id, server.hashkey))
\r
225 def get_or_create_map(session=None, name=None):
\r
227 Find a map by name or create one if not found. Parameters:
\r
229 session - SQLAlchemy database session factory
\r
230 name - map name of the map to be found or created
\r
233 # find one by the name, if it exists
\r
234 gmap = session.query(Map).filter_by(name=name).one()
\r
235 log.debug("Found map id {0}: {1}".format(gmap.map_id,
\r
237 except NoResultFound, e:
\r
238 gmap = Map(name=name)
\r
241 log.debug("Created map id {0}: {1}".format(gmap.map_id,
\r
243 except MultipleResultsFound, e:
\r
244 # multiple found, so use the first one but warn
\r
246 gmaps = session.query(Map).filter_by(name=name).order_by(
\r
249 log.debug("Found map id {0}: {1} but found \
\r
250 multiple".format(gmap.map_id, gmap.name))
\r
255 def create_game(session=None, start_dt=None, game_type_cd=None,
\r
256 server_id=None, map_id=None, winner=None, match_id=None):
\r
258 Creates a game. Parameters:
\r
260 session - SQLAlchemy database session factory
\r
261 start_dt - when the game started (datetime object)
\r
262 game_type_cd - the game type of the game being played
\r
263 server_id - server identifier of the server hosting the game
\r
264 map_id - map on which the game was played
\r
265 winner - the team id of the team that won
\r
267 seq = Sequence('games_game_id_seq')
\r
268 game_id = session.execute(seq)
\r
269 game = Game(game_id=game_id, start_dt=start_dt, game_type_cd=game_type_cd,
\r
270 server_id=server_id, map_id=map_id, winner=winner)
\r
271 game.match_id = match_id
\r
274 session.query(Game).filter(Game.server_id==server_id).\
\r
275 filter(Game.match_id==match_id).one()
\r
276 # if a game under the same server and match_id found,
\r
277 # this is a duplicate game and can be ignored
\r
278 raise pyramid.httpexceptions.HTTPOk
\r
279 except NoResultFound, e:
\r
280 # server_id/match_id combination not found. game is ok to insert
\r
282 log.debug("Created game id {0} on server {1}, map {2} at \
\r
283 {3}".format(game.game_id,
\r
284 server_id, map_id, start_dt))
\r
289 def get_or_create_player(session=None, hashkey=None, nick=None):
\r
291 Finds a player by hashkey or creates a new one (along with a
\r
292 corresponding hashkey entry. Parameters:
\r
294 session - SQLAlchemy database session factory
\r
295 hashkey - hashkey of the player to be found or created
\r
296 nick - nick of the player (in case of a first time create)
\r
299 if re.search('^bot#\d+$', hashkey) or re.search('^bot#\d+#', hashkey):
\r
300 player = session.query(Player).filter_by(player_id=1).one()
\r
301 # if we have an untracked player
\r
302 elif re.search('^player#\d+$', hashkey):
\r
303 player = session.query(Player).filter_by(player_id=2).one()
\r
304 # else it is a tracked player
\r
306 # see if the player is already in the database
\r
307 # if not, create one and the hashkey along with it
\r
309 hk = session.query(Hashkey).filter_by(
\r
310 hashkey=hashkey).one()
\r
311 player = session.query(Player).filter_by(
\r
312 player_id=hk.player_id).one()
\r
313 log.debug("Found existing player {0} with hashkey {1}".format(
\r
314 player.player_id, hashkey))
\r
317 session.add(player)
\r
320 # if nick is given to us, use it. If not, use "Anonymous Player"
\r
321 # with a suffix added for uniqueness.
\r
323 player.nick = nick[:128]
\r
324 player.stripped_nick = strip_colors(nick[:128])
\r
326 player.nick = "Anonymous Player #{0}".format(player.player_id)
\r
327 player.stripped_nick = player.nick
\r
329 hk = Hashkey(player_id=player.player_id, hashkey=hashkey)
\r
331 log.debug("Created player {0} ({2}) with hashkey {1}".format(
\r
332 player.player_id, hashkey, player.nick.encode('utf-8')))
\r
336 def create_player_game_stat(session=None, player=None,
\r
337 game=None, player_events=None):
\r
339 Creates game statistics for a given player in a given game. Parameters:
\r
341 session - SQLAlchemy session factory
\r
342 player - Player record of the player who owns the stats
\r
343 game - Game record for the game to which the stats pertain
\r
344 player_events - dictionary for the actual stats that need to be transformed
\r
347 # in here setup default values (e.g. if game type is CTF then
\r
348 # set kills=0, score=0, captures=0, pickups=0, fckills=0, etc
\r
349 # TODO: use game's create date here instead of now()
\r
350 seq = Sequence('player_game_stats_player_game_stat_id_seq')
\r
351 pgstat_id = session.execute(seq)
\r
352 pgstat = PlayerGameStat(player_game_stat_id=pgstat_id,
\r
353 create_dt=datetime.datetime.utcnow())
\r
355 # set player id from player record
\r
356 pgstat.player_id = player.player_id
\r
358 #set game id from game record
\r
359 pgstat.game_id = game.game_id
\r
361 # all games have a score
\r
364 if game.game_type_cd == 'dm' or game.game_type_cd == 'tdm' or game.game_type_cd == 'duel':
\r
367 pgstat.suicides = 0
\r
368 elif game.game_type_cd == 'ctf':
\r
370 pgstat.captures = 0
\r
374 pgstat.carrier_frags = 0
\r
376 for (key,value) in player_events.items():
\r
377 if key == 'n': pgstat.nick = value[:128]
\r
378 if key == 't': pgstat.team = value
\r
379 if key == 'rank': pgstat.rank = value
\r
380 if key == 'alivetime':
\r
381 pgstat.alivetime = datetime.timedelta(seconds=int(round(float(value))))
\r
382 if key == 'scoreboard-drops': pgstat.drops = value
\r
383 if key == 'scoreboard-returns': pgstat.returns = value
\r
384 if key == 'scoreboard-fckills': pgstat.carrier_frags = value
\r
385 if key == 'scoreboard-pickups': pgstat.pickups = value
\r
386 if key == 'scoreboard-caps': pgstat.captures = value
\r
387 if key == 'scoreboard-score': pgstat.score = value
\r
388 if key == 'scoreboard-deaths': pgstat.deaths = value
\r
389 if key == 'scoreboard-kills': pgstat.kills = value
\r
390 if key == 'scoreboard-suicides': pgstat.suicides = value
\r
392 # check to see if we had a name, and if
\r
393 # not use the name from the player id
\r
394 if pgstat.nick == None:
\r
395 pgstat.nick = player.nick
\r
397 # whichever nick we ended up with, strip it and store as the stripped_nick
\r
398 pgstat.stripped_nick = qfont_decode(strip_colors(pgstat.nick))
\r
400 # if the nick we end up with is different from the one in the
\r
401 # player record, change the nick to reflect the new value
\r
402 if pgstat.nick != player.nick and player.player_id > 2:
\r
403 register_new_nick(session, player, pgstat.nick)
\r
405 # if the player is ranked #1 and it is a team game, set the game's winner
\r
406 # to be the team of that player
\r
407 # FIXME: this is a hack, should be using the 'W' field (not present)
\r
408 if pgstat.rank == '1' and pgstat.team:
\r
409 game.winner = pgstat.team
\r
412 session.add(pgstat)
\r
417 def create_player_weapon_stats(session=None, player=None,
\r
418 game=None, pgstat=None, player_events=None):
\r
420 Creates accuracy records for each weapon used by a given player in a
\r
421 given game. Parameters:
\r
423 session - SQLAlchemy session factory object
\r
424 player - Player record who owns the weapon stats
\r
425 game - Game record in which the stats were created
\r
426 pgstat - Corresponding PlayerGameStat record for these weapon stats
\r
427 player_events - dictionary containing the raw weapon values that need to be
\r
432 for (key,value) in player_events.items():
\r
433 matched = re.search("acc-(.*?)-cnt-fired", key)
\r
435 weapon_cd = matched.group(1)
\r
436 seq = Sequence('player_weapon_stats_player_weapon_stats_id_seq')
\r
437 pwstat_id = session.execute(seq)
\r
438 pwstat = PlayerWeaponStat()
\r
439 pwstat.player_weapon_stats_id = pwstat_id
\r
440 pwstat.player_id = player.player_id
\r
441 pwstat.game_id = game.game_id
\r
442 pwstat.player_game_stat_id = pgstat.player_game_stat_id
\r
443 pwstat.weapon_cd = weapon_cd
\r
445 if 'n' in player_events:
\r
446 pwstat.nick = player_events['n']
\r
448 pwstat.nick = player_events['P']
\r
450 if 'acc-' + weapon_cd + '-cnt-fired' in player_events:
\r
451 pwstat.fired = int(round(float(
\r
452 player_events['acc-' + weapon_cd + '-cnt-fired'])))
\r
453 if 'acc-' + weapon_cd + '-fired' in player_events:
\r
454 pwstat.max = int(round(float(
\r
455 player_events['acc-' + weapon_cd + '-fired'])))
\r
456 if 'acc-' + weapon_cd + '-cnt-hit' in player_events:
\r
457 pwstat.hit = int(round(float(
\r
458 player_events['acc-' + weapon_cd + '-cnt-hit'])))
\r
459 if 'acc-' + weapon_cd + '-hit' in player_events:
\r
460 pwstat.actual = int(round(float(
\r
461 player_events['acc-' + weapon_cd + '-hit'])))
\r
462 if 'acc-' + weapon_cd + '-frags' in player_events:
\r
463 pwstat.frags = int(round(float(
\r
464 player_events['acc-' + weapon_cd + '-frags'])))
\r
466 session.add(pwstat)
\r
467 pwstats.append(pwstat)
\r
472 def parse_body(request):
\r
474 Parses the POST request body for a stats submission
\r
476 # storage vars for the request body
\r
479 current_team = None
\r
482 for line in request.body.split('\n'):
\r
484 (key, value) = line.strip().split(' ', 1)
\r
486 # Server (S) and Nick (n) fields can have international characters.
\r
487 # We convert to UTF-8.
\r
489 value = unicode(value, 'utf-8')
\r
491 if key in 'V' 'T' 'G' 'M' 'S' 'C' 'R' 'W' 'I':
\r
492 game_meta[key] = value
\r
495 # if we were working on a player record already, append
\r
496 # it and work on a new one (only set team info)
\r
497 if len(player_events) != 0:
\r
498 players.append(player_events)
\r
501 player_events[key] = value
\r
504 (subkey, subvalue) = value.split(' ', 1)
\r
505 player_events[subkey] = subvalue
\r
507 player_events[key] = value
\r
509 player_events[key] = value
\r
511 # no key/value pair - move on to the next line
\r
514 # add the last player we were working on
\r
515 if len(player_events) > 0:
\r
516 players.append(player_events)
\r
518 return (game_meta, players)
\r
521 def create_player_stats(session=None, player=None, game=None,
\r
522 player_events=None):
\r
524 Creates player game and weapon stats according to what type of player
\r
526 pgstat = create_player_game_stat(session=session,
\r
527 player=player, game=game, player_events=player_events)
\r
529 #TODO: put this into a config setting in the ini file?
\r
530 if not re.search('^bot#\d+$', player_events['P']):
\r
531 create_player_weapon_stats(session=session,
\r
532 player=player, game=game, pgstat=pgstat,
\r
533 player_events=player_events)
\r
536 def stats_submit(request):
\r
538 Entry handler for POST stats submissions.
\r
541 session = DBSession()
\r
543 log.debug("\n----- BEGIN REQUEST BODY -----\n" + request.body +
\r
544 "----- END REQUEST BODY -----\n\n")
\r
546 (idfp, status) = verify_request(request)
\r
548 log.debug("ERROR: Unverified request")
\r
549 raise pyramid.httpexceptions.HTTPUnauthorized("Unverified request")
\r
551 (game_meta, players) = parse_body(request)
\r
553 if not has_required_metadata(game_meta):
\r
554 log.debug("ERROR: Required game meta missing")
\r
555 raise pyramid.exceptions.HTTPUnprocessableEntity("Missing game meta")
\r
557 if not is_supported_gametype(game_meta['G']):
\r
558 log.debug("ERROR: Unsupported gametype")
\r
559 raise pyramid.httpexceptions.HTTPOk("OK")
\r
561 if not has_minimum_real_players(request.registry.settings, players):
\r
562 log.debug("ERROR: Not enough real players")
\r
563 raise pyramid.httpexceptions.HTTPOk("OK")
\r
565 if is_blank_game(players):
\r
566 log.debug("ERROR: Blank game")
\r
567 raise pyramid.httpexceptions.HTTPOk("OK")
\r
569 # FIXME: if we have two players and game type is 'dm',
\r
570 # change this into a 'duel' gametype. This should be
\r
571 # removed when the stats actually send 'duel' instead of 'dm'
\r
572 if num_real_players(players, count_bots=True) == 2 and \
\r
573 game_meta['G'] == 'dm':
\r
574 game_meta['G'] = 'duel'
\r
576 server = get_or_create_server(session=session, hashkey=idfp,
\r
577 name=game_meta['S'], revision=game_meta['R'],
\r
578 ip_addr=get_remote_addr(request))
\r
580 gmap = get_or_create_map(session=session, name=game_meta['M'])
\r
582 # FIXME: use the gmtime instead of utcnow() when the timezone bug is
\r
584 game = create_game(session=session,
\r
585 start_dt=datetime.datetime.utcnow(),
\r
586 #start_dt=datetime.datetime(
\r
587 #*time.gmtime(float(game_meta['T']))[:6]),
\r
588 server_id=server.server_id, game_type_cd=game_meta['G'],
\r
589 map_id=gmap.map_id, match_id=game_meta['I'])
\r
591 # find or create a record for each player
\r
592 # and add stats for each if they were present at the end
\r
594 for player_events in players:
\r
595 if 'n' in player_events:
\r
596 nick = player_events['n']
\r
600 if 'matches' in player_events and 'scoreboardvalid' \
\r
602 player = get_or_create_player(session=session,
\r
603 hashkey=player_events['P'], nick=nick)
\r
604 log.debug('Creating stats for %s' % player_events['P'])
\r
605 create_player_stats(session=session, player=player, game=game,
\r
606 player_events=player_events)
\r
609 log.debug('Success! Stats recorded.')
\r
610 return Response('200 OK')
\r
611 except Exception as e:
\r