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