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