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