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