]> de.git.xonotic.org Git - xonotic/xonstat.git/blob - xonstat/views/submission.py
Check for new-style bot hashkeys too.
[xonotic/xonstat.git] / xonstat / views / submission.py
1 import datetime\r
2 import logging\r
3 import os\r
4 import pyramid.httpexceptions\r
5 import re\r
6 import time\r
7 from pyramid.response import Response\r
8 from sqlalchemy import Sequence\r
9 from sqlalchemy.orm.exc import MultipleResultsFound, NoResultFound\r
10 from xonstat.d0_blind_id import d0_blind_id_verify\r
11 from xonstat.models import *\r
12 from xonstat.util import strip_colors, qfont_decode\r
13 \r
14 log = logging.getLogger(__name__)\r
15 \r
16 \r
17 def is_blank_game(players):\r
18     """Determine if this is a blank game or not. A blank game is either:\r
19 \r
20     1) a match that ended in the warmup stage, where accuracy events are not\r
21     present\r
22 \r
23     2) a match in which no player made a positive or negative score AND was\r
24     on the scoreboard\r
25     """\r
26     r = re.compile(r'acc-.*-cnt-fired')\r
27     flg_nonzero_score = False\r
28     flg_acc_events = False\r
29 \r
30     for events in players:\r
31         if is_real_player(events):\r
32             for (key,value) in events.items():\r
33                 if key == 'scoreboard-score' and value != '0':\r
34                     flg_nonzero_score = True\r
35                 if r.search(key):\r
36                     flg_acc_events = True\r
37 \r
38     return not (flg_nonzero_score and flg_acc_events)\r
39 \r
40 def get_remote_addr(request):\r
41     """Get the Xonotic server's IP address"""\r
42     if 'X-Forwarded-For' in request.headers:\r
43         return request.headers['X-Forwarded-For']\r
44     else:\r
45         return request.remote_addr\r
46 \r
47 \r
48 def is_supported_gametype(gametype):\r
49     """Whether a gametype is supported or not"""\r
50     flg_supported = True\r
51 \r
52     if gametype == 'cts' or gametype == 'ca' or gametype == 'lms':\r
53         flg_supported = False\r
54 \r
55     return flg_supported\r
56 \r
57 \r
58 def verify_request(request):\r
59     try:\r
60         (idfp, status) = d0_blind_id_verify(\r
61                 sig=request.headers['X-D0-Blind-Id-Detached-Signature'],\r
62                 querystring='',\r
63                 postdata=request.body)\r
64 \r
65         log.debug('\nidfp: {0}\nstatus: {1}'.format(idfp, status))\r
66     except: \r
67         idfp = None\r
68         status = None\r
69 \r
70     return (idfp, status)\r
71 \r
72 \r
73 def num_real_players(player_events, count_bots=False):\r
74     """\r
75     Returns the number of real players (those who played \r
76     and are on the scoreboard).\r
77     """\r
78     real_players = 0\r
79 \r
80     for events in player_events:\r
81         if is_real_player(events, count_bots):\r
82             real_players += 1\r
83 \r
84     return real_players\r
85 \r
86 \r
87 def has_minimum_real_players(settings, player_events):\r
88     """\r
89     Determines if the collection of player events has enough "real" players\r
90     to store in the database. The minimum setting comes from the config file\r
91     under the setting xonstat.minimum_real_players.\r
92     """\r
93     flg_has_min_real_players = True\r
94 \r
95     try:\r
96         minimum_required_players = int(\r
97                 settings['xonstat.minimum_required_players'])\r
98     except:\r
99         minimum_required_players = 2\r
100 \r
101     real_players = num_real_players(player_events)\r
102 \r
103     #TODO: put this into a config setting in the ini file?\r
104     if real_players < minimum_required_players:\r
105         flg_has_min_real_players = False\r
106 \r
107     return flg_has_min_real_players\r
108 \r
109 \r
110 def has_required_metadata(metadata):\r
111     """\r
112     Determines if a give set of metadata has enough data to create a game,\r
113     server, and map with.\r
114     """\r
115     flg_has_req_metadata = True\r
116 \r
117     if 'T' not in metadata or\\r
118         'G' not in metadata or\\r
119         'M' not in metadata or\\r
120         'I' not in metadata or\\r
121         'S' not in metadata:\r
122             flg_has_req_metadata = False\r
123 \r
124     return flg_has_req_metadata\r
125 \r
126 \r
127 def is_real_player(events, count_bots=False):\r
128     """\r
129     Determines if a given set of player events correspond with a player who\r
130 \r
131     1) is not a bot (P event does not look like a bot)\r
132     2) played in the game (matches 1)\r
133     3) was present at the end of the game (scoreboardvalid 1)\r
134 \r
135     Returns True if the player meets the above conditions, and false otherwise.\r
136     """\r
137     flg_is_real = False\r
138 \r
139     # removing 'joins' here due to bug, but it should be here\r
140     if 'matches' in events and 'scoreboardvalid' in events:\r
141         if (events['P'].startswith('bot') and count_bots) or \\r
142             not events['P'].startswith('bot'):\r
143             flg_is_real = True\r
144 \r
145     return flg_is_real\r
146 \r
147 \r
148 def register_new_nick(session, player, new_nick):\r
149     """\r
150     Change the player record's nick to the newly found nick. Store the old\r
151     nick in the player_nicks table for that player.\r
152 \r
153     session - SQLAlchemy database session factory\r
154     player - player record whose nick is changing\r
155     new_nick - the new nickname\r
156     """\r
157     # see if that nick already exists\r
158     stripped_nick = strip_colors(player.nick)\r
159     try:\r
160         player_nick = session.query(PlayerNick).filter_by(\r
161             player_id=player.player_id, stripped_nick=stripped_nick).one()\r
162     except NoResultFound, e:\r
163         # player_id/stripped_nick not found, create one\r
164         # but we don't store "Anonymous Player #N"\r
165         if not re.search('^Anonymous Player #\d+$', player.nick):\r
166             player_nick = PlayerNick()\r
167             player_nick.player_id = player.player_id\r
168             player_nick.stripped_nick = player.stripped_nick\r
169             player_nick.nick = player.nick\r
170             session.add(player_nick)\r
171 \r
172     # We change to the new nick regardless\r
173     player.nick = new_nick\r
174     player.stripped_nick = strip_colors(new_nick)\r
175     session.add(player)\r
176 \r
177 \r
178 def get_or_create_server(session=None, name=None, hashkey=None, ip_addr=None,\r
179         revision=None):\r
180     """\r
181     Find a server by name or create one if not found. Parameters:\r
182 \r
183     session - SQLAlchemy database session factory\r
184     name - server name of the server to be found or created\r
185     hashkey - server hashkey\r
186     """\r
187     try:\r
188         # find one by that name, if it exists\r
189         server = session.query(Server).filter_by(name=name).one()\r
190 \r
191         # store new hashkey\r
192         if server.hashkey != hashkey:\r
193             server.hashkey = hashkey\r
194             session.add(server)\r
195 \r
196         # store new IP address\r
197         if server.ip_addr != ip_addr:\r
198             server.ip_addr = ip_addr\r
199             session.add(server)\r
200 \r
201         # store new revision\r
202         if server.revision != revision:\r
203             server.revision = revision\r
204             session.add(server)\r
205 \r
206         log.debug("Found existing server {0}".format(server.server_id))\r
207 \r
208     except MultipleResultsFound, e:\r
209         # multiple found, so also filter by hashkey\r
210         server = session.query(Server).filter_by(name=name).\\r
211                 filter_by(hashkey=hashkey).one()\r
212         log.debug("Found existing server {0}".format(server.server_id))\r
213 \r
214     except NoResultFound, e:\r
215         # not found, create one\r
216         server = Server(name=name, hashkey=hashkey)\r
217         session.add(server)\r
218         session.flush()\r
219         log.debug("Created server {0} with hashkey {1}".format(\r
220             server.server_id, server.hashkey))\r
221 \r
222     return server\r
223 \r
224 \r
225 def get_or_create_map(session=None, name=None):\r
226     """\r
227     Find a map by name or create one if not found. Parameters:\r
228 \r
229     session - SQLAlchemy database session factory\r
230     name - map name of the map to be found or created\r
231     """\r
232     try:\r
233         # find one by the name, if it exists\r
234         gmap = session.query(Map).filter_by(name=name).one()\r
235         log.debug("Found map id {0}: {1}".format(gmap.map_id, \r
236             gmap.name))\r
237     except NoResultFound, e:\r
238         gmap = Map(name=name)\r
239         session.add(gmap)\r
240         session.flush()\r
241         log.debug("Created map id {0}: {1}".format(gmap.map_id,\r
242             gmap.name))\r
243     except MultipleResultsFound, e:\r
244         # multiple found, so use the first one but warn\r
245         log.debug(e)\r
246         gmaps = session.query(Map).filter_by(name=name).order_by(\r
247                 Map.map_id).all()\r
248         gmap = gmaps[0]\r
249         log.debug("Found map id {0}: {1} but found \\r
250                 multiple".format(gmap.map_id, gmap.name))\r
251 \r
252     return gmap\r
253 \r
254 \r
255 def create_game(session=None, start_dt=None, game_type_cd=None, \r
256         server_id=None, map_id=None, winner=None, match_id=None):\r
257     """\r
258     Creates a game. Parameters:\r
259 \r
260     session - SQLAlchemy database session factory\r
261     start_dt - when the game started (datetime object)\r
262     game_type_cd - the game type of the game being played\r
263     server_id - server identifier of the server hosting the game\r
264     map_id - map on which the game was played\r
265     winner - the team id of the team that won\r
266     """\r
267     seq = Sequence('games_game_id_seq')\r
268     game_id = session.execute(seq)\r
269     game = Game(game_id=game_id, start_dt=start_dt, game_type_cd=game_type_cd,\r
270                 server_id=server_id, map_id=map_id, winner=winner)\r
271     game.match_id = match_id\r
272 \r
273     try:\r
274         session.query(Game).filter(Game.server_id==server_id).\\r
275                 filter(Game.match_id==match_id).one()\r
276         # if a game under the same server and match_id found, \r
277         # this is a duplicate game and can be ignored\r
278         raise pyramid.httpexceptions.HTTPOk\r
279     except NoResultFound, e:\r
280         # server_id/match_id combination not found. game is ok to insert\r
281         session.add(game)\r
282         log.debug("Created game id {0} on server {1}, map {2} at \\r
283                 {3}".format(game.game_id, \r
284                     server_id, map_id, start_dt))\r
285 \r
286     return game\r
287 \r
288 \r
289 def get_or_create_player(session=None, hashkey=None, nick=None):\r
290     """\r
291     Finds a player by hashkey or creates a new one (along with a\r
292     corresponding hashkey entry. Parameters:\r
293 \r
294     session - SQLAlchemy database session factory\r
295     hashkey - hashkey of the player to be found or created\r
296     nick - nick of the player (in case of a first time create)\r
297     """\r
298     # if we have a bot\r
299     if re.search('^bot#\d+$', hashkey) or re.search('^bot#\d+#', hashkey):\r
300         player = session.query(Player).filter_by(player_id=1).one()\r
301     # if we have an untracked player\r
302     elif re.search('^player#\d+$', hashkey):\r
303         player = session.query(Player).filter_by(player_id=2).one()\r
304     # else it is a tracked player\r
305     else:\r
306         # see if the player is already in the database\r
307         # if not, create one and the hashkey along with it\r
308         try:\r
309             hk = session.query(Hashkey).filter_by(\r
310                     hashkey=hashkey).one()\r
311             player = session.query(Player).filter_by(\r
312                     player_id=hk.player_id).one()\r
313             log.debug("Found existing player {0} with hashkey {1}".format(\r
314                 player.player_id, hashkey))\r
315         except:\r
316             player = Player()\r
317             session.add(player)\r
318             session.flush()\r
319 \r
320             # if nick is given to us, use it. If not, use "Anonymous Player"\r
321             # with a suffix added for uniqueness.\r
322             if nick:\r
323                 player.nick = nick[:128]\r
324                 player.stripped_nick = strip_colors(nick[:128])\r
325             else:\r
326                 player.nick = "Anonymous Player #{0}".format(player.player_id)\r
327                 player.stripped_nick = player.nick\r
328 \r
329             hk = Hashkey(player_id=player.player_id, hashkey=hashkey)\r
330             session.add(hk)\r
331             log.debug("Created player {0} ({2}) with hashkey {1}".format(\r
332                 player.player_id, hashkey, player.nick.encode('utf-8')))\r
333 \r
334     return player\r
335 \r
336 def create_player_game_stat(session=None, player=None, \r
337         game=None, player_events=None):\r
338     """\r
339     Creates game statistics for a given player in a given game. Parameters:\r
340 \r
341     session - SQLAlchemy session factory\r
342     player - Player record of the player who owns the stats\r
343     game - Game record for the game to which the stats pertain\r
344     player_events - dictionary for the actual stats that need to be transformed\r
345     """\r
346 \r
347     # in here setup default values (e.g. if game type is CTF then\r
348     # set kills=0, score=0, captures=0, pickups=0, fckills=0, etc\r
349     # TODO: use game's create date here instead of now()\r
350     seq = Sequence('player_game_stats_player_game_stat_id_seq')\r
351     pgstat_id = session.execute(seq)\r
352     pgstat = PlayerGameStat(player_game_stat_id=pgstat_id, \r
353             create_dt=datetime.datetime.utcnow())\r
354 \r
355     # set player id from player record\r
356     pgstat.player_id = player.player_id\r
357 \r
358     #set game id from game record\r
359     pgstat.game_id = game.game_id\r
360 \r
361     # all games have a score\r
362     pgstat.score = 0\r
363 \r
364     if game.game_type_cd == 'dm' or game.game_type_cd == 'tdm' or game.game_type_cd == 'duel':\r
365         pgstat.kills = 0\r
366         pgstat.deaths = 0\r
367         pgstat.suicides = 0\r
368     elif game.game_type_cd == 'ctf':\r
369         pgstat.kills = 0\r
370         pgstat.captures = 0\r
371         pgstat.pickups = 0\r
372         pgstat.drops = 0\r
373         pgstat.returns = 0\r
374         pgstat.carrier_frags = 0\r
375 \r
376     for (key,value) in player_events.items():\r
377         if key == 'n': pgstat.nick = value[:128]\r
378         if key == 't': pgstat.team = value\r
379         if key == 'rank': pgstat.rank = value\r
380         if key == 'alivetime': \r
381             pgstat.alivetime = datetime.timedelta(seconds=int(round(float(value))))\r
382         if key == 'scoreboard-drops': pgstat.drops = value\r
383         if key == 'scoreboard-returns': pgstat.returns = value\r
384         if key == 'scoreboard-fckills': pgstat.carrier_frags = value\r
385         if key == 'scoreboard-pickups': pgstat.pickups = value\r
386         if key == 'scoreboard-caps': pgstat.captures = value\r
387         if key == 'scoreboard-score': pgstat.score = value\r
388         if key == 'scoreboard-deaths': pgstat.deaths = value\r
389         if key == 'scoreboard-kills': pgstat.kills = value\r
390         if key == 'scoreboard-suicides': pgstat.suicides = value\r
391 \r
392     # check to see if we had a name, and if \r
393     # not use the name from the player id\r
394     if pgstat.nick == None:\r
395         pgstat.nick = player.nick\r
396 \r
397     # whichever nick we ended up with, strip it and store as the stripped_nick\r
398     pgstat.stripped_nick = qfont_decode(strip_colors(pgstat.nick))\r
399 \r
400     # if the nick we end up with is different from the one in the\r
401     # player record, change the nick to reflect the new value\r
402     if pgstat.nick != player.nick and player.player_id > 2:\r
403         register_new_nick(session, player, pgstat.nick)\r
404 \r
405     # if the player is ranked #1 and it is a team game, set the game's winner\r
406     # to be the team of that player\r
407     # FIXME: this is a hack, should be using the 'W' field (not present)\r
408     if pgstat.rank == '1' and pgstat.team:\r
409         game.winner = pgstat.team\r
410         session.add(game)\r
411 \r
412     session.add(pgstat)\r
413 \r
414     return pgstat\r
415 \r
416 \r
417 def create_player_weapon_stats(session=None, player=None, \r
418         game=None, pgstat=None, player_events=None):\r
419     """\r
420     Creates accuracy records for each weapon used by a given player in a\r
421     given game. Parameters:\r
422 \r
423     session - SQLAlchemy session factory object\r
424     player - Player record who owns the weapon stats\r
425     game - Game record in which the stats were created\r
426     pgstat - Corresponding PlayerGameStat record for these weapon stats\r
427     player_events - dictionary containing the raw weapon values that need to be\r
428         transformed\r
429     """\r
430     pwstats = []\r
431 \r
432     for (key,value) in player_events.items():\r
433         matched = re.search("acc-(.*?)-cnt-fired", key)\r
434         if matched:\r
435             weapon_cd = matched.group(1)\r
436             seq = Sequence('player_weapon_stats_player_weapon_stats_id_seq')\r
437             pwstat_id = session.execute(seq)\r
438             pwstat = PlayerWeaponStat()\r
439             pwstat.player_weapon_stats_id = pwstat_id\r
440             pwstat.player_id = player.player_id\r
441             pwstat.game_id = game.game_id\r
442             pwstat.player_game_stat_id = pgstat.player_game_stat_id\r
443             pwstat.weapon_cd = weapon_cd\r
444 \r
445             if 'n' in player_events:\r
446                 pwstat.nick = player_events['n']\r
447             else:\r
448                 pwstat.nick = player_events['P']\r
449 \r
450             if 'acc-' + weapon_cd + '-cnt-fired' in player_events:\r
451                 pwstat.fired = int(round(float(\r
452                         player_events['acc-' + weapon_cd + '-cnt-fired'])))\r
453             if 'acc-' + weapon_cd + '-fired' in player_events:\r
454                 pwstat.max = int(round(float(\r
455                         player_events['acc-' + weapon_cd + '-fired'])))\r
456             if 'acc-' + weapon_cd + '-cnt-hit' in player_events:\r
457                 pwstat.hit = int(round(float(\r
458                         player_events['acc-' + weapon_cd + '-cnt-hit'])))\r
459             if 'acc-' + weapon_cd + '-hit' in player_events:\r
460                 pwstat.actual = int(round(float(\r
461                         player_events['acc-' + weapon_cd + '-hit'])))\r
462             if 'acc-' + weapon_cd + '-frags' in player_events:\r
463                 pwstat.frags = int(round(float(\r
464                         player_events['acc-' + weapon_cd + '-frags'])))\r
465 \r
466             session.add(pwstat)\r
467             pwstats.append(pwstat)\r
468 \r
469     return pwstats\r
470 \r
471 \r
472 def parse_body(request):\r
473     """\r
474     Parses the POST request body for a stats submission\r
475     """\r
476     # storage vars for the request body\r
477     game_meta = {}\r
478     player_events = {}\r
479     current_team = None\r
480     players = []\r
481 \r
482     for line in request.body.split('\n'):\r
483         try:\r
484             (key, value) = line.strip().split(' ', 1)\r
485 \r
486             # Server (S) and Nick (n) fields can have international characters.\r
487             # We convert to UTF-8.\r
488             if key in 'S' 'n':\r
489                 value = unicode(value, 'utf-8')\r
490 \r
491             if key in 'V' 'T' 'G' 'M' 'S' 'C' 'R' 'W' 'I':\r
492                 game_meta[key] = value\r
493 \r
494             if key == 'P':\r
495                 # if we were working on a player record already, append\r
496                 # it and work on a new one (only set team info)\r
497                 if len(player_events) != 0:\r
498                     players.append(player_events)\r
499                     player_events = {}\r
500 \r
501                 player_events[key] = value\r
502 \r
503             if key == 'e':\r
504                 (subkey, subvalue) = value.split(' ', 1)\r
505                 player_events[subkey] = subvalue\r
506             if key == 'n':\r
507                 player_events[key] = value\r
508             if key == 't':\r
509                 player_events[key] = value\r
510         except:\r
511             # no key/value pair - move on to the next line\r
512             pass\r
513 \r
514     # add the last player we were working on\r
515     if len(player_events) > 0:\r
516         players.append(player_events)\r
517 \r
518     return (game_meta, players)\r
519 \r
520 \r
521 def create_player_stats(session=None, player=None, game=None, \r
522         player_events=None):\r
523     """\r
524     Creates player game and weapon stats according to what type of player\r
525     """\r
526     pgstat = create_player_game_stat(session=session, \r
527         player=player, game=game, player_events=player_events)\r
528 \r
529     #TODO: put this into a config setting in the ini file?\r
530     if not re.search('^bot#\d+$', player_events['P']):\r
531         create_player_weapon_stats(session=session, \r
532             player=player, game=game, pgstat=pgstat,\r
533             player_events=player_events)\r
534 \r
535 \r
536 def stats_submit(request):\r
537     """\r
538     Entry handler for POST stats submissions.\r
539     """\r
540     try:\r
541         session = DBSession()\r
542 \r
543         log.debug("\n----- BEGIN REQUEST BODY -----\n" + request.body +\r
544                 "----- END REQUEST BODY -----\n\n")\r
545 \r
546         (idfp, status) = verify_request(request)\r
547         if not idfp:\r
548             log.debug("ERROR: Unverified request")\r
549             raise pyramid.httpexceptions.HTTPUnauthorized("Unverified request")\r
550 \r
551         (game_meta, players) = parse_body(request)  \r
552 \r
553         if not has_required_metadata(game_meta):\r
554             log.debug("ERROR: Required game meta missing")\r
555             raise pyramid.exceptions.HTTPUnprocessableEntity("Missing game meta")\r
556 \r
557         if not is_supported_gametype(game_meta['G']):\r
558             log.debug("ERROR: Unsupported gametype")\r
559             raise pyramid.httpexceptions.HTTPOk("OK")\r
560 \r
561         if not has_minimum_real_players(request.registry.settings, players):\r
562             log.debug("ERROR: Not enough real players")\r
563             raise pyramid.httpexceptions.HTTPOk("OK")\r
564 \r
565         if is_blank_game(players):\r
566             log.debug("ERROR: Blank game")\r
567             raise pyramid.httpexceptions.HTTPOk("OK")\r
568 \r
569         # FIXME: if we have two players and game type is 'dm',\r
570         # change this into a 'duel' gametype. This should be\r
571         # removed when the stats actually send 'duel' instead of 'dm'\r
572         if num_real_players(players, count_bots=True) == 2 and \\r
573                 game_meta['G'] == 'dm':\r
574             game_meta['G'] = 'duel'\r
575 \r
576         server = get_or_create_server(session=session, hashkey=idfp, \r
577                 name=game_meta['S'], revision=game_meta['R'],\r
578                 ip_addr=get_remote_addr(request))\r
579 \r
580         gmap = get_or_create_map(session=session, name=game_meta['M'])\r
581 \r
582         # FIXME: use the gmtime instead of utcnow() when the timezone bug is\r
583         # fixed\r
584         game = create_game(session=session, \r
585                 start_dt=datetime.datetime.utcnow(),\r
586                 #start_dt=datetime.datetime(\r
587                     #*time.gmtime(float(game_meta['T']))[:6]), \r
588                 server_id=server.server_id, game_type_cd=game_meta['G'], \r
589                    map_id=gmap.map_id, match_id=game_meta['I'])\r
590 \r
591         # find or create a record for each player\r
592         # and add stats for each if they were present at the end\r
593         # of the game\r
594         for player_events in players:\r
595             if 'n' in player_events:\r
596                 nick = player_events['n']\r
597             else:\r
598                 nick = None\r
599 \r
600             if 'matches' in player_events and 'scoreboardvalid' \\r
601                 in player_events:\r
602                 player = get_or_create_player(session=session, \r
603                     hashkey=player_events['P'], nick=nick)\r
604                 log.debug('Creating stats for %s' % player_events['P'])\r
605                 create_player_stats(session=session, player=player, game=game, \r
606                         player_events=player_events)\r
607 \r
608         session.commit()\r
609         log.debug('Success! Stats recorded.')\r
610         return Response('200 OK')\r
611     except Exception as e:\r
612         session.rollback()\r
613         return e\r