]> git.xonotic.org Git - xonotic/xonstat.git/blob - xonstat/views/submission.py
Remove an unneeded import.
[xonotic/xonstat.git] / xonstat / views / submission.py
1 import calendar
2 import datetime
3 import logging
4 import re
5
6 import pyramid.httpexceptions
7 from sqlalchemy import Sequence
8 from sqlalchemy.orm.exc import MultipleResultsFound, NoResultFound
9 from xonstat.elo import EloProcessor
10 from xonstat.models import DBSession, Server, Map, Game, PlayerGameStat, PlayerWeaponStat
11 from xonstat.models import PlayerRank, PlayerCaptime
12 from xonstat.models import TeamGameStat, PlayerGameAnticheat, Player, Hashkey, PlayerNick
13 from xonstat.util import strip_colors, qfont_decode, verify_request, weapon_map
14
15 log = logging.getLogger(__name__)
16
17
18 def parse_stats_submission(body):
19     """
20     Parses the POST request body for a stats submission
21     """
22     # storage vars for the request body
23     game_meta = {}
24     events = {}
25     players = []
26     teams = []
27
28     # we're not in either stanza to start
29     in_P = in_Q = False
30
31     for line in body.split('\n'):
32         try:
33             (key, value) = line.strip().split(' ', 1)
34
35             # Server (S) and Nick (n) fields can have international characters.
36             if key in 'S' 'n':
37                 value = unicode(value, 'utf-8')
38
39             if key not in 'P' 'Q' 'n' 'e' 't' 'i':
40                 game_meta[key] = value
41
42             if key == 'Q' or key == 'P':
43                 #log.debug('Found a {0}'.format(key))
44                 #log.debug('in_Q: {0}'.format(in_Q))
45                 #log.debug('in_P: {0}'.format(in_P))
46                 #log.debug('events: {0}'.format(events))
47
48                 # check where we were before and append events accordingly
49                 if in_Q and len(events) > 0:
50                     #log.debug('creating a team (Q) entry')
51                     teams.append(events)
52                     events = {}
53                 elif in_P and len(events) > 0:
54                     #log.debug('creating a player (P) entry')
55                     players.append(events)
56                     events = {}
57
58                 if key == 'P':
59                     #log.debug('key == P')
60                     in_P = True
61                     in_Q = False
62                 elif key == 'Q':
63                     #log.debug('key == Q')
64                     in_P = False
65                     in_Q = True
66
67                 events[key] = value
68
69             if key == 'e':
70                 (subkey, subvalue) = value.split(' ', 1)
71                 events[subkey] = subvalue
72             if key == 'n':
73                 events[key] = value
74             if key == 't':
75                 events[key] = value
76         except:
77             # no key/value pair - move on to the next line
78             pass
79
80     # add the last entity we were working on
81     if in_P and len(events) > 0:
82         players.append(events)
83     elif in_Q and len(events) > 0:
84         teams.append(events)
85
86     return (game_meta, players, teams)
87
88
89 def is_blank_game(gametype, players):
90     """Determine if this is a blank game or not. A blank game is either:
91
92     1) a match that ended in the warmup stage, where accuracy events are not
93     present (for non-CTS games)
94
95     2) a match in which no player made a positive or negative score AND was
96     on the scoreboard
97
98     ... or for CTS, which doesn't record accuracy events
99
100     1) a match in which no player made a fastest lap AND was
101     on the scoreboard
102
103     ... or for NB, in which not all maps have weapons
104
105     1) a match in which no player made a positive or negative score
106     """
107     r = re.compile(r'acc-.*-cnt-fired')
108     flg_nonzero_score = False
109     flg_acc_events = False
110     flg_fastest_lap = False
111
112     for events in players:
113         if is_real_player(events) and played_in_game(events):
114             for (key,value) in events.items():
115                 if key == 'scoreboard-score' and value != 0:
116                     flg_nonzero_score = True
117                 if r.search(key):
118                     flg_acc_events = True
119                 if key == 'scoreboard-fastest':
120                     flg_fastest_lap = True
121
122     if gametype == 'cts':
123         return not flg_fastest_lap
124     elif gametype == 'nb':
125         return not flg_nonzero_score
126     else:
127         return not (flg_nonzero_score and flg_acc_events)
128
129
130 def get_remote_addr(request):
131     """Get the Xonotic server's IP address"""
132     if 'X-Forwarded-For' in request.headers:
133         return request.headers['X-Forwarded-For']
134     else:
135         return request.remote_addr
136
137
138 def is_supported_gametype(gametype, version):
139     """Whether a gametype is supported or not"""
140     is_supported = False
141
142     # if the type can be supported, but with version constraints, uncomment
143     # here and add the restriction for a specific version below
144     supported_game_types = (
145             'as',
146             'ca',
147             # 'cq',
148             'ctf',
149             'cts',
150             'dm',
151             'dom',
152             'ft', 'freezetag',
153             'ka', 'keepaway',
154             'kh',
155             # 'lms',
156             'nb', 'nexball',
157             # 'rc',
158             'rune',
159             'tdm',
160         )
161
162     if gametype in supported_game_types:
163         is_supported = True
164     else:
165         is_supported = False
166
167     # some game types were buggy before revisions, thus this additional filter
168     if gametype == 'ca' and version <= 5:
169         is_supported = False
170
171     return is_supported
172
173
174 def do_precondition_checks(request, game_meta, raw_players):
175     """Precondition checks for ALL gametypes.
176        These do not require a database connection."""
177     if not has_required_metadata(game_meta):
178         log.debug("ERROR: Required game meta missing")
179         raise pyramid.httpexceptions.HTTPUnprocessableEntity("Missing game meta")
180
181     try:
182         version = int(game_meta['V'])
183     except:
184         log.debug("ERROR: Required game meta invalid")
185         raise pyramid.httpexceptions.HTTPUnprocessableEntity("Invalid game meta")
186
187     if not is_supported_gametype(game_meta['G'], version):
188         log.debug("ERROR: Unsupported gametype")
189         raise pyramid.httpexceptions.HTTPOk("OK")
190
191     if not has_minimum_real_players(request.registry.settings, raw_players):
192         log.debug("ERROR: Not enough real players")
193         raise pyramid.httpexceptions.HTTPOk("OK")
194
195     if is_blank_game(game_meta['G'], raw_players):
196         log.debug("ERROR: Blank game")
197         raise pyramid.httpexceptions.HTTPOk("OK")
198
199
200 def is_real_player(events):
201     """
202     Determines if a given set of events correspond with a non-bot
203     """
204     if not events['P'].startswith('bot'):
205         return True
206     else:
207         return False
208
209
210 def played_in_game(events):
211     """
212     Determines if a given set of player events correspond with a player who
213     played in the game (matches 1 and scoreboardvalid 1)
214     """
215     if 'matches' in events and 'scoreboardvalid' in events:
216         return True
217     else:
218         return False
219
220
221 def num_real_players(player_events):
222     """
223     Returns the number of real players (those who played
224     and are on the scoreboard).
225     """
226     real_players = 0
227
228     for events in player_events:
229         if is_real_player(events) and played_in_game(events):
230             real_players += 1
231
232     return real_players
233
234
235 def has_minimum_real_players(settings, player_events):
236     """
237     Determines if the collection of player events has enough "real" players
238     to store in the database. The minimum setting comes from the config file
239     under the setting xonstat.minimum_real_players.
240     """
241     flg_has_min_real_players = True
242
243     try:
244         minimum_required_players = int(
245                 settings['xonstat.minimum_required_players'])
246     except:
247         minimum_required_players = 2
248
249     real_players = num_real_players(player_events)
250
251     if real_players < minimum_required_players:
252         flg_has_min_real_players = False
253
254     return flg_has_min_real_players
255
256
257 def has_required_metadata(metadata):
258     """
259     Determines if a give set of metadata has enough data to create a game,
260     server, and map with.
261     """
262     flg_has_req_metadata = True
263
264     if 'G' not in metadata or\
265         'M' not in metadata or\
266         'I' not in metadata or\
267         'S' not in metadata:
268             flg_has_req_metadata = False
269
270     return flg_has_req_metadata
271
272
273 def should_do_weapon_stats(game_type_cd):
274     """True of the game type should record weapon stats. False otherwise."""
275     if game_type_cd in 'cts':
276         return False
277     else:
278         return True
279
280
281 def gametype_elo_eligible(game_type_cd):
282     """True of the game type should process Elos. False otherwise."""
283     elo_game_types = ('duel', 'dm', 'ca', 'ctf', 'tdm', 'ka', 'ft')
284
285     if game_type_cd in elo_game_types:
286         return True
287     else:
288         return False
289
290
291 def register_new_nick(session, player, new_nick):
292     """
293     Change the player record's nick to the newly found nick. Store the old
294     nick in the player_nicks table for that player.
295
296     session - SQLAlchemy database session factory
297     player - player record whose nick is changing
298     new_nick - the new nickname
299     """
300     # see if that nick already exists
301     stripped_nick = strip_colors(qfont_decode(player.nick))
302     try:
303         player_nick = session.query(PlayerNick).filter_by(
304             player_id=player.player_id, stripped_nick=stripped_nick).one()
305     except NoResultFound, e:
306         # player_id/stripped_nick not found, create one
307         # but we don't store "Anonymous Player #N"
308         if not re.search('^Anonymous Player #\d+$', player.nick):
309             player_nick = PlayerNick()
310             player_nick.player_id = player.player_id
311             player_nick.stripped_nick = stripped_nick
312             player_nick.nick = player.nick
313             session.add(player_nick)
314
315     # We change to the new nick regardless
316     player.nick = new_nick
317     player.stripped_nick = strip_colors(qfont_decode(new_nick))
318     session.add(player)
319
320
321 def update_fastest_cap(session, player_id, game_id, map_id, captime, mod):
322     """
323     Check the fastest cap time for the player and map. If there isn't
324     one, insert one. If there is, check if the passed time is faster.
325     If so, update!
326     """
327     # we don't record fastest cap times for bots or anonymous players
328     if player_id <= 2:
329         return
330
331     # see if a cap entry exists already
332     # then check to see if the new captime is faster
333     try:
334         cur_fastest_cap = session.query(PlayerCaptime).filter_by(
335             player_id=player_id, map_id=map_id, mod=mod).one()
336
337         # current captime is faster, so update
338         if captime < cur_fastest_cap.fastest_cap:
339             cur_fastest_cap.fastest_cap = captime
340             cur_fastest_cap.game_id = game_id
341             cur_fastest_cap.create_dt = datetime.datetime.utcnow()
342             session.add(cur_fastest_cap)
343
344     except NoResultFound, e:
345         # none exists, so insert
346         cur_fastest_cap = PlayerCaptime(player_id, game_id, map_id, captime,
347                 mod)
348         session.add(cur_fastest_cap)
349         session.flush()
350
351
352 def update_server(server, name, hashkey, ip_addr, port, revision, impure_cvars):
353     """
354     Updates the server in the given DB session, if needed.
355
356     :param server: The found server instance.
357     :param name: The incoming server name.
358     :param hashkey: The incoming server hashkey.
359     :param ip_addr: The incoming server IP address.
360     :param port: The incoming server port.
361     :param revision: The incoming server revision.
362     :param impure_cvars: The incoming number of impure server cvars.
363     :return: bool
364     """
365     # ensure the two int attributes are actually ints
366     try:
367         port = int(port)
368     except:
369         port = None
370
371     try:
372         impure_cvars = int(impure_cvars)
373     except:
374         impure_cvars = 0
375
376     updated = False
377     if name and server.name != name:
378         server.name = name
379         updated = True
380     if hashkey and server.hashkey != hashkey:
381         server.hashkey = hashkey
382         updated = True
383     if ip_addr and server.ip_addr != ip_addr:
384         server.ip_addr = ip_addr
385         updated = True
386     if port and server.port != port:
387         server.port = port
388         updated = True
389     if revision and server.revision != revision:
390         server.revision = revision
391         updated = True
392     if impure_cvars and server.impure_cvars != impure_cvars:
393         server.impure_cvars = impure_cvars
394         server.pure_ind = True if impure_cvars == 0 else False
395         updated = True
396
397     return updated
398
399
400 def get_or_create_server(session, name, hashkey, ip_addr, revision, port, impure_cvars):
401     """
402     Find a server by name or create one if not found. Parameters:
403
404     session - SQLAlchemy database session factory
405     name - server name of the server to be found or created
406     hashkey - server hashkey
407     ip_addr - the IP address of the server
408     revision - the xonotic revision number
409     port - the port number of the server
410     impure_cvars - the number of impure cvar changes
411     """
412     servers_q = DBSession.query(Server).filter(Server.active_ind)
413
414     if hashkey:
415         # if the hashkey is provided, we'll use that
416         servers_q = servers_q.filter((Server.name == name) or (Server.hashkey == hashkey))
417     else:
418         # otherwise, it is just by name
419         servers_q = servers_q.filter(Server.name == name)
420
421     # order by the hashkey, which means any hashkey match will appear first if there are multiple
422     servers = servers_q.order_by(Server.hashkey, Server.create_dt).all()
423
424     if len(servers) == 0:
425         server = Server(name=name, hashkey=hashkey)
426         session.add(server)
427         session.flush()
428         log.debug("Created server {} with hashkey {}.".format(server.server_id, server.hashkey))
429     else:
430         server = servers[0]
431         if len(servers) == 1:
432             log.info("Found existing server {}.".format(server.server_id))
433
434         elif len(servers) > 1:
435             server_id_list = ", ".join(["{}".format(s.server_id) for s in servers])
436             log.warn("Multiple servers found ({})! Using the first one ({})."
437                      .format(server_id_list, server.server_id))
438
439     if update_server(server, name, hashkey, ip_addr, port, revision, impure_cvars):
440         session.add(server)
441
442     return server
443
444
445 def get_or_create_map(session=None, name=None):
446     """
447     Find a map by name or create one if not found. Parameters:
448
449     session - SQLAlchemy database session factory
450     name - map name of the map to be found or created
451     """
452     try:
453         # find one by the name, if it exists
454         gmap = session.query(Map).filter_by(name=name).one()
455         log.debug("Found map id {0}: {1}".format(gmap.map_id,
456             gmap.name))
457     except NoResultFound, e:
458         gmap = Map(name=name)
459         session.add(gmap)
460         session.flush()
461         log.debug("Created map id {0}: {1}".format(gmap.map_id,
462             gmap.name))
463     except MultipleResultsFound, e:
464         # multiple found, so use the first one but warn
465         log.debug(e)
466         gmaps = session.query(Map).filter_by(name=name).order_by(
467                 Map.map_id).all()
468         gmap = gmaps[0]
469         log.debug("Found map id {0}: {1} but found \
470                 multiple".format(gmap.map_id, gmap.name))
471
472     return gmap
473
474
475 def create_game(session, start_dt, game_type_cd, server_id, map_id,
476         match_id, duration, mod, winner=None):
477     """
478     Creates a game. Parameters:
479
480     session - SQLAlchemy database session factory
481     start_dt - when the game started (datetime object)
482     game_type_cd - the game type of the game being played
483     server_id - server identifier of the server hosting the game
484     map_id - map on which the game was played
485     winner - the team id of the team that won
486     duration - how long the game lasted
487     mod - mods in use during the game
488     """
489     seq = Sequence('games_game_id_seq')
490     game_id = session.execute(seq)
491     game = Game(game_id=game_id, start_dt=start_dt, game_type_cd=game_type_cd,
492                 server_id=server_id, map_id=map_id, winner=winner)
493     game.match_id = match_id
494     game.mod = mod[:64]
495
496     # There is some drift between start_dt (provided by app) and create_dt
497     # (default in the database), so we'll make them the same until this is 
498     # resolved.
499     game.create_dt = start_dt
500
501     try:
502         game.duration = datetime.timedelta(seconds=int(round(float(duration))))
503     except:
504         pass
505
506     try:
507         session.query(Game).filter(Game.server_id==server_id).\
508                 filter(Game.match_id==match_id).one()
509
510         log.debug("Error: game with same server and match_id found! Ignoring.")
511
512         # if a game under the same server and match_id found,
513         # this is a duplicate game and can be ignored
514         raise pyramid.httpexceptions.HTTPOk('OK')
515     except NoResultFound, e:
516         # server_id/match_id combination not found. game is ok to insert
517         session.add(game)
518         session.flush()
519         log.debug("Created game id {0} on server {1}, map {2} at \
520                 {3}".format(game.game_id,
521                     server_id, map_id, start_dt))
522
523     return game
524
525
526 def get_or_create_player(session=None, hashkey=None, nick=None):
527     """
528     Finds a player by hashkey or creates a new one (along with a
529     corresponding hashkey entry. Parameters:
530
531     session - SQLAlchemy database session factory
532     hashkey - hashkey of the player to be found or created
533     nick - nick of the player (in case of a first time create)
534     """
535     # if we have a bot
536     if re.search('^bot#\d+', hashkey):
537         player = session.query(Player).filter_by(player_id=1).one()
538     # if we have an untracked player
539     elif re.search('^player#\d+$', hashkey):
540         player = session.query(Player).filter_by(player_id=2).one()
541     # else it is a tracked player
542     else:
543         # see if the player is already in the database
544         # if not, create one and the hashkey along with it
545         try:
546             hk = session.query(Hashkey).filter_by(
547                     hashkey=hashkey).one()
548             player = session.query(Player).filter_by(
549                     player_id=hk.player_id).one()
550             log.debug("Found existing player {0} with hashkey {1}".format(
551                 player.player_id, hashkey))
552         except:
553             player = Player()
554             session.add(player)
555             session.flush()
556
557             # if nick is given to us, use it. If not, use "Anonymous Player"
558             # with a suffix added for uniqueness.
559             if nick:
560                 player.nick = nick[:128]
561                 player.stripped_nick = strip_colors(qfont_decode(nick[:128]))
562             else:
563                 player.nick = "Anonymous Player #{0}".format(player.player_id)
564                 player.stripped_nick = player.nick
565
566             hk = Hashkey(player_id=player.player_id, hashkey=hashkey)
567             session.add(hk)
568             log.debug("Created player {0} ({2}) with hashkey {1}".format(
569                 player.player_id, hashkey, player.nick.encode('utf-8')))
570
571     return player
572
573
574 def create_default_game_stat(session, game_type_cd):
575     """Creates a blanked-out pgstat record for the given game type"""
576
577     # this is what we have to do to get partitioned records in - grab the
578     # sequence value first, then insert using the explicit ID (vs autogenerate)
579     seq = Sequence('player_game_stats_player_game_stat_id_seq')
580     pgstat_id = session.execute(seq)
581     pgstat = PlayerGameStat(player_game_stat_id=pgstat_id,
582             create_dt=datetime.datetime.utcnow())
583
584     if game_type_cd == 'as':
585         pgstat.kills = pgstat.deaths = pgstat.suicides = pgstat.collects = 0
586
587     if game_type_cd in 'ca' 'dm' 'duel' 'rune' 'tdm':
588         pgstat.kills = pgstat.deaths = pgstat.suicides = 0
589
590     if game_type_cd == 'cq':
591         pgstat.kills = pgstat.deaths = pgstat.suicides = pgstat.captures = 0
592         pgstat.drops = 0
593
594     if game_type_cd == 'ctf':
595         pgstat.kills = pgstat.captures = pgstat.pickups = pgstat.drops = 0
596         pgstat.returns = pgstat.carrier_frags = 0
597
598     if game_type_cd == 'cts':
599         pgstat.deaths = 0
600
601     if game_type_cd == 'dom':
602         pgstat.kills = pgstat.deaths = pgstat.suicides = pgstat.pickups = 0
603         pgstat.drops = 0
604
605     if game_type_cd == 'ft':
606         pgstat.kills = pgstat.deaths = pgstat.suicides = pgstat.revivals = 0
607
608     if game_type_cd == 'ka':
609         pgstat.kills = pgstat.deaths = pgstat.suicides = pgstat.pickups = 0
610         pgstat.carrier_frags = 0
611         pgstat.time = datetime.timedelta(seconds=0)
612
613     if game_type_cd == 'kh':
614         pgstat.kills = pgstat.deaths = pgstat.suicides = pgstat.pickups = 0
615         pgstat.captures = pgstat.drops = pgstat.pushes = pgstat.destroys = 0
616         pgstat.carrier_frags = 0
617
618     if game_type_cd == 'lms':
619         pgstat.kills = pgstat.deaths = pgstat.suicides = pgstat.lives = 0
620
621     if game_type_cd == 'nb':
622         pgstat.kills = pgstat.deaths = pgstat.suicides = pgstat.captures = 0
623         pgstat.drops = 0
624
625     if game_type_cd == 'rc':
626         pgstat.kills = pgstat.deaths = pgstat.suicides = pgstat.laps = 0
627
628     return pgstat
629
630
631 def create_game_stat(session, game_meta, game, server, gmap, player, events):
632     """Game stats handler for all game types"""
633
634     game_type_cd = game.game_type_cd
635
636     pgstat = create_default_game_stat(session, game_type_cd)
637
638     # these fields should be on every pgstat record
639     pgstat.game_id       = game.game_id
640     pgstat.player_id     = player.player_id
641     pgstat.nick          = events.get('n', 'Anonymous Player')[:128]
642     pgstat.stripped_nick = strip_colors(qfont_decode(pgstat.nick))
643     pgstat.score         = int(round(float(events.get('scoreboard-score', 0))))
644     pgstat.alivetime     = datetime.timedelta(seconds=int(round(float(events.get('alivetime', 0.0)))))
645     pgstat.rank          = int(events.get('rank', None))
646     pgstat.scoreboardpos = int(events.get('scoreboardpos', pgstat.rank))
647
648     if pgstat.nick != player.nick \
649             and player.player_id > 2 \
650             and pgstat.nick != 'Anonymous Player':
651         register_new_nick(session, player, pgstat.nick)
652
653     wins = False
654
655     # gametype-specific stuff is handled here. if passed to us, we store it
656     for (key,value) in events.items():
657         if key == 'wins': wins = True
658         if key == 't': pgstat.team = int(value)
659
660         if key == 'scoreboard-drops': pgstat.drops = int(value)
661         if key == 'scoreboard-returns': pgstat.returns = int(value)
662         if key == 'scoreboard-fckills': pgstat.carrier_frags = int(value)
663         if key == 'scoreboard-pickups': pgstat.pickups = int(value)
664         if key == 'scoreboard-caps': pgstat.captures = int(value)
665         if key == 'scoreboard-score': pgstat.score = int(round(float(value)))
666         if key == 'scoreboard-deaths': pgstat.deaths = int(value)
667         if key == 'scoreboard-kills': pgstat.kills = int(value)
668         if key == 'scoreboard-suicides': pgstat.suicides = int(value)
669         if key == 'scoreboard-objectives': pgstat.collects = int(value)
670         if key == 'scoreboard-captured': pgstat.captures = int(value)
671         if key == 'scoreboard-released': pgstat.drops = int(value)
672         if key == 'scoreboard-fastest':
673             pgstat.fastest = datetime.timedelta(seconds=float(value)/100)
674         if key == 'scoreboard-takes': pgstat.pickups = int(value)
675         if key == 'scoreboard-ticks': pgstat.drops = int(value)
676         if key == 'scoreboard-revivals': pgstat.revivals = int(value)
677         if key == 'scoreboard-bctime':
678             pgstat.time = datetime.timedelta(seconds=int(value))
679         if key == 'scoreboard-bckills': pgstat.carrier_frags = int(value)
680         if key == 'scoreboard-losses': pgstat.drops = int(value)
681         if key == 'scoreboard-pushes': pgstat.pushes = int(value)
682         if key == 'scoreboard-destroyed': pgstat.destroys = int(value)
683         if key == 'scoreboard-kckills': pgstat.carrier_frags = int(value)
684         if key == 'scoreboard-lives': pgstat.lives = int(value)
685         if key == 'scoreboard-goals': pgstat.captures = int(value)
686         if key == 'scoreboard-faults': pgstat.drops = int(value)
687         if key == 'scoreboard-laps': pgstat.laps = int(value)
688
689         if key == 'avglatency': pgstat.avg_latency = float(value)
690         if key == 'scoreboard-captime':
691             pgstat.fastest = datetime.timedelta(seconds=float(value)/100)
692             if game.game_type_cd == 'ctf':
693                 update_fastest_cap(session, player.player_id, game.game_id,
694                         gmap.map_id, pgstat.fastest, game.mod)
695
696     # there is no "winning team" field, so we have to derive it
697     if wins and pgstat.team is not None and game.winner is None:
698         game.winner = pgstat.team
699         session.add(game)
700
701     session.add(pgstat)
702
703     return pgstat
704
705
706 def create_anticheats(session, pgstat, game, player, events):
707     """Anticheats handler for all game types"""
708
709     anticheats = []
710
711     # all anticheat events are prefixed by "anticheat"
712     for (key,value) in events.items():
713         if key.startswith("anticheat"):
714             try:
715                 ac = PlayerGameAnticheat(
716                     player.player_id,
717                     game.game_id,
718                     key,
719                     float(value)
720                 )
721                 anticheats.append(ac)
722                 session.add(ac)
723             except Exception as e:
724                 log.debug("Could not parse value for key %s. Ignoring." % key)
725
726     return anticheats
727
728
729 def create_default_team_stat(session, game_type_cd):
730     """Creates a blanked-out teamstat record for the given game type"""
731
732     # this is what we have to do to get partitioned records in - grab the
733     # sequence value first, then insert using the explicit ID (vs autogenerate)
734     seq = Sequence('team_game_stats_team_game_stat_id_seq')
735     teamstat_id = session.execute(seq)
736     teamstat = TeamGameStat(team_game_stat_id=teamstat_id,
737             create_dt=datetime.datetime.utcnow())
738
739     # all team game modes have a score, so we'll zero that out always
740     teamstat.score = 0
741
742     if game_type_cd in 'ca' 'ft' 'lms' 'ka':
743         teamstat.rounds = 0
744
745     if game_type_cd == 'ctf':
746         teamstat.caps = 0
747
748     return teamstat
749
750
751 def create_team_stat(session, game, events):
752     """Team stats handler for all game types"""
753
754     try:
755         teamstat = create_default_team_stat(session, game.game_type_cd)
756         teamstat.game_id = game.game_id
757
758         # we should have a team ID if we have a 'Q' event
759         if re.match(r'^team#\d+$', events.get('Q', '')):
760             team = int(events.get('Q').replace('team#', ''))
761             teamstat.team = team
762
763         # gametype-specific stuff is handled here. if passed to us, we store it
764         for (key,value) in events.items():
765             if key == 'scoreboard-score': teamstat.score = int(round(float(value)))
766             if key == 'scoreboard-caps': teamstat.caps = int(value)
767             if key == 'scoreboard-goals': teamstat.caps = int(value)
768             if key == 'scoreboard-rounds': teamstat.rounds = int(value)
769
770         session.add(teamstat)
771     except Exception as e:
772         raise e
773
774     return teamstat
775
776
777 def create_weapon_stats(session, game_meta, game, player, pgstat, events):
778     """Weapon stats handler for all game types"""
779     pwstats = []
780
781     # Version 1 of stats submissions doubled the data sent.
782     # To counteract this we divide the data by 2 only for
783     # POSTs coming from version 1.
784     try:
785         version = int(game_meta['V'])
786         if version == 1:
787             is_doubled = True
788             log.debug('NOTICE: found a version 1 request, halving the weapon stats...')
789         else:
790             is_doubled = False
791     except:
792         is_doubled = False
793
794     for (key,value) in events.items():
795         matched = re.search("acc-(.*?)-cnt-fired", key)
796         if matched:
797             weapon_cd = matched.group(1)
798
799             # Weapon names changed for 0.8. We'll convert the old
800             # ones to use the new scheme as well.
801             mapped_weapon_cd = weapon_map.get(weapon_cd, weapon_cd)
802
803             seq = Sequence('player_weapon_stats_player_weapon_stats_id_seq')
804             pwstat_id = session.execute(seq)
805             pwstat = PlayerWeaponStat()
806             pwstat.player_weapon_stats_id = pwstat_id
807             pwstat.player_id = player.player_id
808             pwstat.game_id = game.game_id
809             pwstat.player_game_stat_id = pgstat.player_game_stat_id
810             pwstat.weapon_cd = mapped_weapon_cd
811
812             if 'n' in events:
813                 pwstat.nick = events['n']
814             else:
815                 pwstat.nick = events['P']
816
817             if 'acc-' + weapon_cd + '-cnt-fired' in events:
818                 pwstat.fired = int(round(float(
819                         events['acc-' + weapon_cd + '-cnt-fired'])))
820             if 'acc-' + weapon_cd + '-fired' in events:
821                 pwstat.max = int(round(float(
822                         events['acc-' + weapon_cd + '-fired'])))
823             if 'acc-' + weapon_cd + '-cnt-hit' in events:
824                 pwstat.hit = int(round(float(
825                         events['acc-' + weapon_cd + '-cnt-hit'])))
826             if 'acc-' + weapon_cd + '-hit' in events:
827                 pwstat.actual = int(round(float(
828                         events['acc-' + weapon_cd + '-hit'])))
829             if 'acc-' + weapon_cd + '-frags' in events:
830                 pwstat.frags = int(round(float(
831                         events['acc-' + weapon_cd + '-frags'])))
832
833             if is_doubled:
834                 pwstat.fired = pwstat.fired/2
835                 pwstat.max = pwstat.max/2
836                 pwstat.hit = pwstat.hit/2
837                 pwstat.actual = pwstat.actual/2
838                 pwstat.frags = pwstat.frags/2
839
840             session.add(pwstat)
841             pwstats.append(pwstat)
842
843     return pwstats
844
845
846 def get_ranks(session, player_ids, game_type_cd):
847     """
848     Gets the rank entries for all players in the given list, returning a dict
849     of player_id -> PlayerRank instance. The rank entry corresponds to the
850     game type of the parameter passed in as well.
851     """
852     ranks = {}
853     for pr in session.query(PlayerRank).\
854             filter(PlayerRank.player_id.in_(player_ids)).\
855             filter(PlayerRank.game_type_cd == game_type_cd).\
856             all():
857                 ranks[pr.player_id] = pr
858
859     return ranks
860
861
862 def submit_stats(request):
863     """
864     Entry handler for POST stats submissions.
865     """
866     try:
867         # placeholder for the actual session
868         session = None
869
870         log.debug("\n----- BEGIN REQUEST BODY -----\n" + request.body +
871                 "----- END REQUEST BODY -----\n\n")
872
873         (idfp, status) = verify_request(request)
874         (game_meta, raw_players, raw_teams) = parse_stats_submission(request.body)
875         revision = game_meta.get('R', 'unknown')
876         duration = game_meta.get('D', None)
877
878         # only players present at the end of the match are eligible for stats
879         raw_players = filter(played_in_game, raw_players)
880
881         do_precondition_checks(request, game_meta, raw_players)
882
883         # the "duel" gametype is fake
884         if len(raw_players) == 2 \
885             and num_real_players(raw_players) == 2 \
886             and game_meta['G'] == 'dm':
887             game_meta['G'] = 'duel'
888
889         #----------------------------------------------------------------------
890         # Actual setup (inserts/updates) below here
891         #----------------------------------------------------------------------
892         session = DBSession()
893
894         game_type_cd = game_meta['G']
895
896         # All game types create Game, Server, Map, and Player records
897         # the same way.
898         server = get_or_create_server(
899                 session      = session,
900                 hashkey      = idfp,
901                 name         = game_meta['S'],
902                 revision     = revision,
903                 ip_addr      = get_remote_addr(request),
904                 port         = game_meta.get('U', None),
905                 impure_cvars = game_meta.get('C', 0))
906
907         gmap = get_or_create_map(
908                 session = session,
909                 name    = game_meta['M'])
910
911         game = create_game(
912                 session      = session,
913                 start_dt     = datetime.datetime.utcnow(),
914                 server_id    = server.server_id,
915                 game_type_cd = game_type_cd,
916                 map_id       = gmap.map_id,
917                 match_id     = game_meta['I'],
918                 duration     = duration,
919                 mod          = game_meta.get('O', None))
920
921         # keep track of the players we've seen
922         player_ids = []
923         pgstats = []
924         hashkeys = {}
925         for events in raw_players:
926             player = get_or_create_player(
927                 session = session,
928                 hashkey = events['P'],
929                 nick    = events.get('n', None))
930
931             pgstat = create_game_stat(session, game_meta, game, server,
932                     gmap, player, events)
933             pgstats.append(pgstat)
934
935             if player.player_id > 1:
936                 anticheats = create_anticheats(session, pgstat, game, player, events)
937
938             if player.player_id > 2:
939                 player_ids.append(player.player_id)
940                 hashkeys[player.player_id] = events['P']
941
942             if should_do_weapon_stats(game_type_cd) and player.player_id > 1:
943                 pwstats = create_weapon_stats(session, game_meta, game, player,
944                         pgstat, events)
945
946         # store them on games for easy access
947         game.players = player_ids
948
949         for events in raw_teams:
950             try:
951                 teamstat = create_team_stat(session, game, events)
952             except Exception as e:
953                 raise e
954
955         if server.elo_ind and gametype_elo_eligible(game_type_cd):
956             ep = EloProcessor(session, game, pgstats)
957             ep.save(session)
958
959         session.commit()
960         log.debug('Success! Stats recorded.')
961
962         # ranks are fetched after we've done the "real" processing
963         ranks = get_ranks(session, player_ids, game_type_cd)
964
965         # plain text response
966         request.response.content_type = 'text/plain'
967
968         return {
969                 "now"        : calendar.timegm(datetime.datetime.utcnow().timetuple()),
970                 "server"     : server,
971                 "game"       : game,
972                 "gmap"       : gmap,
973                 "player_ids" : player_ids,
974                 "hashkeys"   : hashkeys,
975                 "elos"       : ep.wip,
976                 "ranks"      : ranks,
977         }
978
979     except Exception as e:
980         if session:
981             session.rollback()
982         raise e