]> de.git.xonotic.org Git - xonotic/xonstat.git/blob - xonstat/views/submission.py
Allow 1-player CTS matches to be recorded.
[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     # Make an exception for CTS since it can be done by individuals and there is no Elo anyway
350     if submission.game_type_cd == "cts":
351         minimum_required_players = 1
352
353     return len(submission.humans) >= minimum_required_players
354
355
356 def do_precondition_checks(settings, submission):
357     """Precondition checks for ALL gametypes. These do not require a database connection."""
358     if not has_required_metadata(submission):
359         msg = "Missing required game metadata"
360         log.debug(msg)
361         raise pyramid.httpexceptions.HTTPUnprocessableEntity(
362             body=msg,
363             content_type="text/plain"
364         )
365
366     if submission.version is None:
367         msg = "Invalid or incorrect game metadata provided"
368         log.debug(msg)
369         raise pyramid.httpexceptions.HTTPUnprocessableEntity(
370             body=msg,
371             content_type="text/plain"
372         )
373
374     if not is_supported_gametype(submission):
375         msg = "Unsupported game type ({})".format(submission.game_type_cd)
376         log.debug(msg)
377         raise pyramid.httpexceptions.HTTPOk(
378             body=msg,
379             content_type="text/plain"
380         )
381
382     if not has_minimum_real_players(settings, submission):
383         msg = "Not enough real players"
384         log.debug(msg)
385         raise pyramid.httpexceptions.HTTPOk(
386             body=msg,
387             content_type="text/plain"
388         )
389
390     if is_blank_game(submission):
391         msg = "Blank game"
392         log.debug(msg)
393         raise pyramid.httpexceptions.HTTPOk(
394             body=msg,
395             content_type="text/plain"
396         )
397
398
399 def get_remote_addr(request):
400     """Get the Xonotic server's IP address"""
401     if 'X-Forwarded-For' in request.headers:
402         return request.headers['X-Forwarded-For']
403     else:
404         return request.remote_addr
405
406
407 def should_do_weapon_stats(game_type_cd):
408     """True of the game type should record weapon stats. False otherwise."""
409     return game_type_cd not in {'cts'}
410
411
412 def should_do_frag_matrix(game_type_cd):
413     """True if the game type should record frag matrix values. False otherwise."""
414     return game_type_cd in {
415         'as', 'ca', 'ctf', 'dm', 'dom', 'ft', 'freezetag', 'ka', 'kh', 'rune', 'tdm',
416     }
417
418
419 def gametype_elo_eligible(game_type_cd):
420     """True of the game type should process Elos. False otherwise."""
421     return game_type_cd in {'duel', 'dm', 'ca', 'ctf', 'tdm', 'ka', 'ft'}
422
423
424 def register_new_nick(session, player, new_nick):
425     """
426     Change the player record's nick to the newly found nick. Store the old
427     nick in the player_nicks table for that player.
428
429     session - SQLAlchemy database session factory
430     player - player record whose nick is changing
431     new_nick - the new nickname
432     """
433     # see if that nick already exists
434     stripped_nick = strip_colors(qfont_decode(player.nick))
435     try:
436         player_nick = session.query(PlayerNick).filter_by(
437             player_id=player.player_id, stripped_nick=stripped_nick).one()
438     except NoResultFound, e:
439         # player_id/stripped_nick not found, create one
440         # but we don't store "Anonymous Player #N"
441         if not re.search('^Anonymous Player #\d+$', player.nick):
442             player_nick = PlayerNick()
443             player_nick.player_id = player.player_id
444             player_nick.stripped_nick = stripped_nick
445             player_nick.nick = player.nick
446             session.add(player_nick)
447
448     # We change to the new nick regardless
449     player.nick = new_nick
450     player.stripped_nick = strip_colors(qfont_decode(new_nick))
451     session.add(player)
452
453
454 def update_fastest_cap(session, player_id, game_id, map_id, captime, mod):
455     """
456     Check the fastest cap time for the player and map. If there isn't
457     one, insert one. If there is, check if the passed time is faster.
458     If so, update!
459     """
460     # we don't record fastest cap times for bots or anonymous players
461     if player_id <= 2:
462         return
463
464     # see if a cap entry exists already
465     # then check to see if the new captime is faster
466     try:
467         cur_fastest_cap = session.query(PlayerCaptime).filter_by(
468             player_id=player_id, map_id=map_id, mod=mod).one()
469
470         # current captime is faster, so update
471         if captime < cur_fastest_cap.fastest_cap:
472             cur_fastest_cap.fastest_cap = captime
473             cur_fastest_cap.game_id = game_id
474             cur_fastest_cap.create_dt = datetime.datetime.utcnow()
475             session.add(cur_fastest_cap)
476
477     except NoResultFound, e:
478         # none exists, so insert
479         cur_fastest_cap = PlayerCaptime(player_id, game_id, map_id, captime,
480                 mod)
481         session.add(cur_fastest_cap)
482         session.flush()
483
484
485 def update_server(server, name, hashkey, ip_addr, port, revision, impure_cvars):
486     """
487     Updates the server in the given DB session, if needed.
488
489     :param server: The found server instance.
490     :param name: The incoming server name.
491     :param hashkey: The incoming server hashkey.
492     :param ip_addr: The incoming server IP address.
493     :param port: The incoming server port.
494     :param revision: The incoming server revision.
495     :param impure_cvars: The incoming number of impure server cvars.
496     :return: bool
497     """
498     # ensure the two int attributes are actually ints
499     try:
500         port = int(port)
501     except:
502         port = None
503
504     try:
505         impure_cvars = int(impure_cvars)
506     except:
507         impure_cvars = 0
508
509     updated = False
510     if name and server.name != name:
511         server.name = name
512         updated = True
513     if hashkey and server.hashkey != hashkey:
514         server.hashkey = hashkey
515         updated = True
516     if ip_addr and server.ip_addr != ip_addr:
517         server.ip_addr = ip_addr
518         updated = True
519     if port and server.port != port:
520         server.port = port
521         updated = True
522     if revision and server.revision != revision:
523         server.revision = revision
524         updated = True
525     if impure_cvars and server.impure_cvars != impure_cvars:
526         server.impure_cvars = impure_cvars
527         server.pure_ind = True if impure_cvars == 0 else False
528         updated = True
529
530     return updated
531
532
533 def get_or_create_server(session, name, hashkey, ip_addr, revision, port, impure_cvars):
534     """
535     Find a server by name or create one if not found. Parameters:
536
537     session - SQLAlchemy database session factory
538     name - server name of the server to be found or created
539     hashkey - server hashkey
540     ip_addr - the IP address of the server
541     revision - the xonotic revision number
542     port - the port number of the server
543     impure_cvars - the number of impure cvar changes
544     """
545     servers_q = DBSession.query(Server).filter(Server.active_ind)
546
547     if hashkey:
548         # if the hashkey is provided, we'll use that
549         servers_q = servers_q.filter((Server.name == name) or (Server.hashkey == hashkey))
550     else:
551         # otherwise, it is just by name
552         servers_q = servers_q.filter(Server.name == name)
553
554     # order by the hashkey, which means any hashkey match will appear first if there are multiple
555     servers = servers_q.order_by(Server.hashkey, Server.create_dt).all()
556
557     if len(servers) == 0:
558         server = Server(name=name, hashkey=hashkey)
559         session.add(server)
560         session.flush()
561         log.debug("Created server {} with hashkey {}.".format(server.server_id, server.hashkey))
562     else:
563         server = servers[0]
564         if len(servers) == 1:
565             log.info("Found existing server {}.".format(server.server_id))
566
567         elif len(servers) > 1:
568             server_id_list = ", ".join(["{}".format(s.server_id) for s in servers])
569             log.warn("Multiple servers found ({})! Using the first one ({})."
570                      .format(server_id_list, server.server_id))
571
572     if update_server(server, name, hashkey, ip_addr, port, revision, impure_cvars):
573         session.add(server)
574
575     return server
576
577
578 def get_or_create_map(session, name):
579     """
580     Find a map by name or create one if not found. Parameters:
581
582     session - SQLAlchemy database session factory
583     name - map name of the map to be found or created
584     """
585     maps = session.query(Map).filter_by(name=name).order_by(Map.map_id).all()
586
587     if maps is None or len(maps) == 0:
588         gmap = Map(name=name)
589         session.add(gmap)
590         session.flush()
591         log.debug("Created map id {}: {}".format(gmap.map_id, gmap.name))
592     elif len(maps) == 1:
593         gmap = maps[0]
594         log.debug("Found map id {}: {}".format(gmap.map_id, gmap.name))
595     else:
596         gmap = maps[0]
597         map_id_list = ", ".join(["{}".format(m.map_id) for m in maps])
598         log.warn("Multiple maps found for {} ({})! Using the first one.".format(name, map_id_list))
599
600     return gmap
601
602
603 def create_game(session, game_type_cd, server_id, map_id, match_id, start_dt, duration, mod,
604                 winner=None):
605     """
606     Creates a game. Parameters:
607
608     session - SQLAlchemy database session factory
609     game_type_cd - the game type of the game being played
610     mod - mods in use during the game
611     server_id - server identifier of the server hosting the game
612     map_id - map on which the game was played
613     match_id - a unique match ID given by the server
614     start_dt - when the game started (datetime object)
615     duration - how long the game lasted
616     winner - the team id of the team that won
617     """
618     seq = Sequence('games_game_id_seq')
619     game_id = session.execute(seq)
620     game = Game(game_id=game_id, start_dt=start_dt, game_type_cd=game_type_cd, server_id=server_id,
621                 map_id=map_id, winner=winner)
622     game.match_id = match_id
623     game.mod = mod[:64]
624
625     # There is some drift between start_dt (provided by app) and create_dt
626     # (default in the database), so we'll make them the same until this is
627     # resolved.
628     game.create_dt = start_dt
629
630     game.duration = duration
631
632     try:
633         session.query(Game).filter(Game.server_id == server_id)\
634             .filter(Game.match_id == match_id).one()
635
636         log.debug("Error: game with same server and match_id found! Ignoring.")
637
638         # if a game under the same server_id and match_id exists, this is a duplicate
639         msg = "Duplicate game (pre-existing match_id)"
640         log.debug(msg)
641         raise pyramid.httpexceptions.HTTPOk(body=msg, content_type="text/plain")
642
643     except NoResultFound:
644         # server_id/match_id combination not found. game is ok to insert
645         session.add(game)
646         session.flush()
647         log.debug("Created game id {} on server {}, map {} at {}"
648                   .format(game.game_id, server_id, map_id, start_dt))
649
650     return game
651
652
653 def get_or_create_player(session=None, hashkey=None, nick=None):
654     """
655     Finds a player by hashkey or creates a new one (along with a
656     corresponding hashkey entry. Parameters:
657
658     session - SQLAlchemy database session factory
659     hashkey - hashkey of the player to be found or created
660     nick - nick of the player (in case of a first time create)
661     """
662     # if we have a bot
663     if re.search('^bot#\d+', hashkey):
664         player = session.query(Player).filter_by(player_id=1).one()
665     # if we have an untracked player
666     elif re.search('^player#\d+$', hashkey):
667         player = session.query(Player).filter_by(player_id=2).one()
668     # else it is a tracked player
669     else:
670         # see if the player is already in the database
671         # if not, create one and the hashkey along with it
672         try:
673             hk = session.query(Hashkey).filter_by(
674                     hashkey=hashkey).one()
675             player = session.query(Player).filter_by(
676                     player_id=hk.player_id).one()
677             log.debug("Found existing player {0} with hashkey {1}".format(
678                 player.player_id, hashkey))
679         except:
680             player = Player()
681             session.add(player)
682             session.flush()
683
684             # if nick is given to us, use it. If not, use "Anonymous Player"
685             # with a suffix added for uniqueness.
686             if nick:
687                 player.nick = nick[:128]
688                 player.stripped_nick = strip_colors(qfont_decode(nick[:128]))
689             else:
690                 player.nick = "Anonymous Player #{0}".format(player.player_id)
691                 player.stripped_nick = player.nick
692
693             hk = Hashkey(player_id=player.player_id, hashkey=hashkey)
694             session.add(hk)
695             log.debug("Created player {0} ({2}) with hashkey {1}".format(
696                 player.player_id, hashkey, player.nick.encode('utf-8')))
697
698     return player
699
700
701 def create_default_game_stat(session, game_type_cd):
702     """Creates a blanked-out pgstat record for the given game type"""
703
704     # this is what we have to do to get partitioned records in - grab the
705     # sequence value first, then insert using the explicit ID (vs autogenerate)
706     seq = Sequence('player_game_stats_player_game_stat_id_seq')
707     pgstat_id = session.execute(seq)
708     pgstat = PlayerGameStat(player_game_stat_id=pgstat_id,
709             create_dt=datetime.datetime.utcnow())
710
711     if game_type_cd == 'as':
712         pgstat.kills = pgstat.deaths = pgstat.suicides = pgstat.collects = 0
713
714     if game_type_cd in 'ca' 'dm' 'duel' 'rune' 'tdm':
715         pgstat.kills = pgstat.deaths = pgstat.suicides = 0
716
717     if game_type_cd == 'cq':
718         pgstat.kills = pgstat.deaths = pgstat.suicides = pgstat.captures = 0
719         pgstat.drops = 0
720
721     if game_type_cd == 'ctf':
722         pgstat.kills = pgstat.captures = pgstat.pickups = pgstat.drops = 0
723         pgstat.returns = pgstat.carrier_frags = 0
724
725     if game_type_cd == 'cts':
726         pgstat.deaths = 0
727
728     if game_type_cd == 'dom':
729         pgstat.kills = pgstat.deaths = pgstat.suicides = pgstat.pickups = 0
730         pgstat.drops = 0
731
732     if game_type_cd == 'ft':
733         pgstat.kills = pgstat.deaths = pgstat.suicides = pgstat.revivals = 0
734
735     if game_type_cd == 'ka':
736         pgstat.kills = pgstat.deaths = pgstat.suicides = pgstat.pickups = 0
737         pgstat.carrier_frags = 0
738         pgstat.time = datetime.timedelta(seconds=0)
739
740     if game_type_cd == 'kh':
741         pgstat.kills = pgstat.deaths = pgstat.suicides = pgstat.pickups = 0
742         pgstat.captures = pgstat.drops = pgstat.pushes = pgstat.destroys = 0
743         pgstat.carrier_frags = 0
744
745     if game_type_cd == 'lms':
746         pgstat.kills = pgstat.deaths = pgstat.suicides = pgstat.lives = 0
747
748     if game_type_cd == 'nb':
749         pgstat.kills = pgstat.deaths = pgstat.suicides = pgstat.captures = 0
750         pgstat.drops = 0
751
752     if game_type_cd == 'rc':
753         pgstat.kills = pgstat.deaths = pgstat.suicides = pgstat.laps = 0
754
755     return pgstat
756
757
758 def create_game_stat(session, game, gmap, player, events):
759     """Game stats handler for all game types"""
760
761     game_type_cd = game.game_type_cd
762
763     pgstat = create_default_game_stat(session, game_type_cd)
764
765     # these fields should be on every pgstat record
766     pgstat.game_id       = game.game_id
767     pgstat.player_id     = player.player_id
768     pgstat.nick          = events.get('n', 'Anonymous Player')[:128]
769     pgstat.stripped_nick = strip_colors(qfont_decode(pgstat.nick))
770     pgstat.score         = int(round(float(events.get('scoreboard-score', 0))))
771     pgstat.alivetime     = datetime.timedelta(seconds=int(round(float(events.get('alivetime', 0.0)))))
772     pgstat.rank          = int(events.get('rank', None))
773     pgstat.scoreboardpos = int(events.get('scoreboardpos', pgstat.rank))
774
775     wins = False
776
777     # gametype-specific stuff is handled here. if passed to us, we store it
778     for (key,value) in events.items():
779         if key == 'wins': wins = True
780         if key == 't': pgstat.team = int(value)
781
782         if key == 'scoreboard-drops': pgstat.drops = int(value)
783         if key == 'scoreboard-returns': pgstat.returns = int(value)
784         if key == 'scoreboard-fckills': pgstat.carrier_frags = int(value)
785         if key == 'scoreboard-pickups': pgstat.pickups = int(value)
786         if key == 'scoreboard-caps': pgstat.captures = int(value)
787         if key == 'scoreboard-score': pgstat.score = int(round(float(value)))
788         if key == 'scoreboard-deaths': pgstat.deaths = int(value)
789         if key == 'scoreboard-kills': pgstat.kills = int(value)
790         if key == 'scoreboard-suicides': pgstat.suicides = int(value)
791         if key == 'scoreboard-objectives': pgstat.collects = int(value)
792         if key == 'scoreboard-captured': pgstat.captures = int(value)
793         if key == 'scoreboard-released': pgstat.drops = int(value)
794         if key == 'scoreboard-fastest':
795             pgstat.fastest = datetime.timedelta(seconds=float(value)/100)
796         if key == 'scoreboard-takes': pgstat.pickups = int(value)
797         if key == 'scoreboard-ticks': pgstat.drops = int(value)
798         if key == 'scoreboard-revivals': pgstat.revivals = int(value)
799         if key == 'scoreboard-bctime':
800             pgstat.time = datetime.timedelta(seconds=int(value))
801         if key == 'scoreboard-bckills': pgstat.carrier_frags = int(value)
802         if key == 'scoreboard-losses': pgstat.drops = int(value)
803         if key == 'scoreboard-pushes': pgstat.pushes = int(value)
804         if key == 'scoreboard-destroyed': pgstat.destroys = int(value)
805         if key == 'scoreboard-kckills': pgstat.carrier_frags = int(value)
806         if key == 'scoreboard-lives': pgstat.lives = int(value)
807         if key == 'scoreboard-goals': pgstat.captures = int(value)
808         if key == 'scoreboard-faults': pgstat.drops = int(value)
809         if key == 'scoreboard-laps': pgstat.laps = int(value)
810
811         if key == 'avglatency': pgstat.avg_latency = float(value)
812         if key == 'scoreboard-captime':
813             pgstat.fastest = datetime.timedelta(seconds=float(value)/100)
814             if game.game_type_cd == 'ctf':
815                 update_fastest_cap(session, player.player_id, game.game_id,
816                         gmap.map_id, pgstat.fastest, game.mod)
817
818     # there is no "winning team" field, so we have to derive it
819     if wins and pgstat.team is not None and game.winner is None:
820         game.winner = pgstat.team
821         session.add(game)
822
823     session.add(pgstat)
824
825     return pgstat
826
827
828 def create_anticheats(session, pgstat, game, player, events):
829     """Anticheats handler for all game types"""
830
831     anticheats = []
832
833     # all anticheat events are prefixed by "anticheat"
834     for (key,value) in events.items():
835         if key.startswith("anticheat"):
836             try:
837                 ac = PlayerGameAnticheat(
838                     player.player_id,
839                     game.game_id,
840                     key,
841                     float(value)
842                 )
843                 anticheats.append(ac)
844                 session.add(ac)
845             except Exception as e:
846                 log.debug("Could not parse value for key %s. Ignoring." % key)
847
848     return anticheats
849
850
851 def create_default_team_stat(session, game_type_cd):
852     """Creates a blanked-out teamstat record for the given game type"""
853
854     # this is what we have to do to get partitioned records in - grab the
855     # sequence value first, then insert using the explicit ID (vs autogenerate)
856     seq = Sequence('team_game_stats_team_game_stat_id_seq')
857     teamstat_id = session.execute(seq)
858     teamstat = TeamGameStat(team_game_stat_id=teamstat_id,
859             create_dt=datetime.datetime.utcnow())
860
861     # all team game modes have a score, so we'll zero that out always
862     teamstat.score = 0
863
864     if game_type_cd in 'ca' 'ft' 'lms' 'ka':
865         teamstat.rounds = 0
866
867     if game_type_cd == 'ctf':
868         teamstat.caps = 0
869
870     return teamstat
871
872
873 def create_team_stat(session, game, events):
874     """Team stats handler for all game types"""
875
876     try:
877         teamstat = create_default_team_stat(session, game.game_type_cd)
878         teamstat.game_id = game.game_id
879
880         # we should have a team ID if we have a 'Q' event
881         if re.match(r'^team#\d+$', events.get('Q', '')):
882             team = int(events.get('Q').replace('team#', ''))
883             teamstat.team = team
884
885         # gametype-specific stuff is handled here. if passed to us, we store it
886         for (key,value) in events.items():
887             if key == 'scoreboard-score': teamstat.score = int(round(float(value)))
888             if key == 'scoreboard-caps': teamstat.caps = int(value)
889             if key == 'scoreboard-goals': teamstat.caps = int(value)
890             if key == 'scoreboard-rounds': teamstat.rounds = int(value)
891
892         session.add(teamstat)
893     except Exception as e:
894         raise e
895
896     return teamstat
897
898
899 def create_weapon_stats(session, version, game, player, pgstat, events):
900     """Weapon stats handler for all game types"""
901     pwstats = []
902
903     # Version 1 of stats submissions doubled the data sent.
904     # To counteract this we divide the data by 2 only for
905     # POSTs coming from version 1.
906     try:
907         if version == 1:
908             is_doubled = True
909             log.debug('NOTICE: found a version 1 request, halving the weapon stats...')
910         else:
911             is_doubled = False
912     except:
913         is_doubled = False
914
915     for (key,value) in events.items():
916         matched = re.search("acc-(.*?)-cnt-fired", key)
917         if matched:
918             weapon_cd = matched.group(1)
919
920             # Weapon names changed for 0.8. We'll convert the old
921             # ones to use the new scheme as well.
922             mapped_weapon_cd = weapon_map.get(weapon_cd, weapon_cd)
923
924             seq = Sequence('player_weapon_stats_player_weapon_stats_id_seq')
925             pwstat_id = session.execute(seq)
926             pwstat = PlayerWeaponStat()
927             pwstat.player_weapon_stats_id = pwstat_id
928             pwstat.player_id = player.player_id
929             pwstat.game_id = game.game_id
930             pwstat.player_game_stat_id = pgstat.player_game_stat_id
931             pwstat.weapon_cd = mapped_weapon_cd
932
933             if 'n' in events:
934                 pwstat.nick = events['n']
935             else:
936                 pwstat.nick = events['P']
937
938             if 'acc-' + weapon_cd + '-cnt-fired' in events:
939                 pwstat.fired = int(round(float(
940                         events['acc-' + weapon_cd + '-cnt-fired'])))
941             if 'acc-' + weapon_cd + '-fired' in events:
942                 pwstat.max = int(round(float(
943                         events['acc-' + weapon_cd + '-fired'])))
944             if 'acc-' + weapon_cd + '-cnt-hit' in events:
945                 pwstat.hit = int(round(float(
946                         events['acc-' + weapon_cd + '-cnt-hit'])))
947             if 'acc-' + weapon_cd + '-hit' in events:
948                 pwstat.actual = int(round(float(
949                         events['acc-' + weapon_cd + '-hit'])))
950             if 'acc-' + weapon_cd + '-frags' in events:
951                 pwstat.frags = int(round(float(
952                         events['acc-' + weapon_cd + '-frags'])))
953
954             if is_doubled:
955                 pwstat.fired = pwstat.fired/2
956                 pwstat.max = pwstat.max/2
957                 pwstat.hit = pwstat.hit/2
958                 pwstat.actual = pwstat.actual/2
959                 pwstat.frags = pwstat.frags/2
960
961             session.add(pwstat)
962             pwstats.append(pwstat)
963
964     return pwstats
965
966
967 def get_ranks(session, player_ids, game_type_cd):
968     """
969     Gets the rank entries for all players in the given list, returning a dict
970     of player_id -> PlayerRank instance. The rank entry corresponds to the
971     game type of the parameter passed in as well.
972     """
973     ranks = {}
974     for pr in session.query(PlayerRank).\
975             filter(PlayerRank.player_id.in_(player_ids)).\
976             filter(PlayerRank.game_type_cd == game_type_cd).\
977             all():
978                 ranks[pr.player_id] = pr
979
980     return ranks
981
982
983 def update_player(session, player, events):
984     """
985     Updates a player record using the latest information.
986     :param session: SQLAlchemy session
987     :param player: Player model representing what is in the database right now (before updates)
988     :param events: Dict of player events from the submission
989     :return: player
990     """
991     nick = events.get('n', 'Anonymous Player')[:128]
992     if nick != player.nick and not nick.startswith("Anonymous Player"):
993         register_new_nick(session, player, nick)
994
995     return player
996
997
998 def create_player(session, events):
999     """
1000     Creates a new player from the list of events.
1001     :param session: SQLAlchemy session
1002     :param events: Dict of player events from the submission
1003     :return: Player
1004     """
1005     player = Player()
1006     session.add(player)
1007     session.flush()
1008
1009     nick = events.get('n', None)
1010     if nick:
1011         player.nick = nick[:128]
1012         player.stripped_nick = strip_colors(qfont_decode(player.nick))
1013     else:
1014         player.nick = "Anonymous Player #{0}".format(player.player_id)
1015         player.stripped_nick = player.nick
1016
1017     hk = Hashkey(player_id=player.player_id, hashkey=events.get('P', None))
1018     session.add(hk)
1019
1020     return player
1021
1022
1023 def get_or_create_players(session, events_by_hashkey):
1024     hashkeys = set(events_by_hashkey.keys())
1025     players_by_hashkey = {}
1026
1027     bot = session.query(Player).filter(Player.player_id == 1).one()
1028     anon = session.query(Player).filter(Player.player_id == 2).one()
1029
1030     # fill in the bots and anonymous players
1031     for hashkey in events_by_hashkey.keys():
1032         if hashkey.startswith("bot#"):
1033             players_by_hashkey[hashkey] = bot
1034             hashkeys.remove(hashkey)
1035         elif hashkey.startswith("player#"):
1036             players_by_hashkey[hashkey] = anon
1037             hashkeys.remove(hashkey)
1038
1039     # We are left with the "real" players and can now fetch them by their collective hashkeys.
1040     # Those that are returned here are pre-existing players who need to be updated.
1041     for p, hk in session.query(Player, Hashkey)\
1042             .filter(Player.player_id == Hashkey.player_id)\
1043             .filter(Hashkey.hashkey.in_(hashkeys))\
1044             .all():
1045                 log.debug("Found existing player {} with hashkey {}"
1046                           .format(p.player_id, hk.hashkey))
1047
1048                 player = update_player(session, p, events_by_hashkey[hk.hashkey])
1049                 players_by_hashkey[hk.hashkey] = player
1050                 hashkeys.remove(hk.hashkey)
1051
1052     # The remainder are the players we haven't seen before, so we need to create them.
1053     for hashkey in hashkeys:
1054         player = create_player(session, events_by_hashkey[hashkey])
1055
1056         log.debug("Created player {0} ({2}) with hashkey {1}"
1057                   .format(player.player_id, hashkey, player.nick.encode('utf-8')))
1058
1059         players_by_hashkey[hashkey] = player
1060
1061     return players_by_hashkey
1062
1063
1064 def create_frag_matrix(session, player_indexes, pgstat, events):
1065     """
1066     Construct a PlayerFragMatrix object from the events of a given player.
1067
1068     :param session: The DBSession we're adding objects to.
1069     :param player_indexes: The set of player indexes of those that actually played in the game.
1070     :param pgstat: The PlayerGameStat object of the player whose frag matrix we want to create.
1071     :param events: The raw player events of the above player.
1072     :return: PlayerFragMatrix
1073     """
1074     player_index = int(events.get("i", None))
1075
1076     # "kills-4" -> 4
1077     victim_index = lambda x: int(x.split("-")[1])
1078
1079     matrix = {victim_index(k): int(v) for (k, v) in events.items()
1080               if k.startswith("kills-") and victim_index(k) in player_indexes}
1081
1082     if len(matrix) > 0:
1083         pfm = PlayerGameFragMatrix(pgstat.game_id, pgstat.player_game_stat_id, pgstat.player_id,
1084                                    player_index, matrix)
1085
1086         session.add(pfm)
1087         return pfm
1088     else:
1089         return None
1090
1091
1092 def submit_stats(request):
1093     """
1094     Entry handler for POST stats submissions.
1095     """
1096     # placeholder for the actual session
1097     session = None
1098
1099     try:
1100         log.debug("\n----- BEGIN REQUEST BODY -----\n" + request.body +
1101                   "----- END REQUEST BODY -----\n\n")
1102
1103         (idfp, status) = verify_request(request)
1104         try:
1105             submission = Submission(request.body, request.headers)
1106         except:
1107             msg = "Invalid submission"
1108             log.debug(msg)
1109             raise pyramid.httpexceptions.HTTPUnprocessableEntity(
1110                 body=msg,
1111                 content_type="text/plain"
1112             )
1113
1114         do_precondition_checks(request.registry.settings, submission)
1115
1116         #######################################################################
1117         # Actual setup (inserts/updates) below here
1118         #######################################################################
1119         session = DBSession()
1120
1121         # All game types create Game, Server, Map, and Player records
1122         # the same way.
1123         server = get_or_create_server(
1124             session=session,
1125             hashkey=idfp,
1126             name=submission.server_name,
1127             revision=submission.revision,
1128             ip_addr=get_remote_addr(request),
1129             port=submission.port_number,
1130             impure_cvars=submission.impure_cvar_changes
1131         )
1132
1133         gmap = get_or_create_map(session, submission.map_name)
1134
1135         game = create_game(
1136             session=session,
1137             game_type_cd=submission.game_type_cd,
1138             mod=submission.mod,
1139             server_id=server.server_id,
1140             map_id=gmap.map_id,
1141             match_id=submission.match_id,
1142             start_dt=datetime.datetime.utcnow(),
1143             duration=submission.duration
1144         )
1145
1146         events_by_hashkey = {elem["P"]: elem for elem in submission.humans + submission.bots}
1147         players_by_hashkey = get_or_create_players(session, events_by_hashkey)
1148
1149         pgstats = []
1150         elo_pgstats = []
1151         player_ids = []
1152         hashkeys_by_player_id = {}
1153         for hashkey, player in players_by_hashkey.items():
1154             events = events_by_hashkey[hashkey]
1155
1156             pgstat = create_game_stat(session, game, gmap, player, events)
1157             pgstats.append(pgstat)
1158
1159             if should_do_frag_matrix(submission.game_type_cd):
1160                 create_frag_matrix(session, submission.player_indexes, pgstat, events)
1161
1162             # player ranking opt-out
1163             if 'r' in events and events['r'] == '0':
1164                 log.debug("Excluding player {} from ranking calculations (opt-out)"
1165                           .format(pgstat.player_id))
1166             else:
1167                 elo_pgstats.append(pgstat)
1168
1169             if player.player_id > 1:
1170                 create_anticheats(session, pgstat, game, player, events)
1171
1172             if player.player_id > 2:
1173                 player_ids.append(player.player_id)
1174                 hashkeys_by_player_id[player.player_id] = hashkey
1175
1176             if should_do_weapon_stats(submission.game_type_cd) and player.player_id > 1:
1177                 create_weapon_stats(session, submission.version, game, player, pgstat, events)
1178
1179         # player_ids for human players get stored directly on games for fast indexing
1180         game.players = player_ids
1181
1182         for events in submission.teams:
1183             create_team_stat(session, game, events)
1184
1185         if server.elo_ind and gametype_elo_eligible(submission.game_type_cd):
1186             ep = EloProcessor(session, game, elo_pgstats)
1187             ep.save(session)
1188             elos = ep.wip
1189         else:
1190             elos = {}
1191
1192         session.commit()
1193         log.debug('Success! Stats recorded.')
1194
1195         # ranks are fetched after we've done the "real" processing
1196         ranks = get_ranks(session, player_ids, submission.game_type_cd)
1197
1198         # plain text response
1199         request.response.content_type = 'text/plain'
1200
1201         return {
1202                 "now": calendar.timegm(datetime.datetime.utcnow().timetuple()),
1203                 "server": server,
1204                 "game": game,
1205                 "gmap": gmap,
1206                 "player_ids": player_ids,
1207                 "hashkeys": hashkeys_by_player_id,
1208                 "elos": elos,
1209                 "ranks": ranks,
1210         }
1211
1212     except Exception as e:
1213         if session:
1214             session.rollback()
1215         raise e