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