]> de.git.xonotic.org Git - xonotic/xonstat.git/blob - xonstat/views/submission.py
Add support for the not-yet-created "duel" gametype.
[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':\r
328         pgstat.kills = 0\r
329         pgstat.deaths = 0\r
330         pgstat.suicides = 0\r
331     elif game.game_type_cd == 'ctf':\r
332         pgstat.kills = 0\r
333         pgstat.captures = 0\r
334         pgstat.pickups = 0\r
335         pgstat.drops = 0\r
336         pgstat.returns = 0\r
337         pgstat.carrier_frags = 0\r
338 \r
339     for (key,value) in player_events.items():\r
340         if key == 'n': pgstat.nick = value[:128]\r
341         if key == 't': pgstat.team = value\r
342         if key == 'rank': pgstat.rank = value\r
343         if key == 'alivetime': \r
344             pgstat.alivetime = datetime.timedelta(seconds=int(round(float(value))))\r
345         if key == 'scoreboard-drops': pgstat.drops = value\r
346         if key == 'scoreboard-returns': pgstat.returns = value\r
347         if key == 'scoreboard-fckills': pgstat.carrier_frags = value\r
348         if key == 'scoreboard-pickups': pgstat.pickups = value\r
349         if key == 'scoreboard-caps': pgstat.captures = value\r
350         if key == 'scoreboard-score': pgstat.score = value\r
351         if key == 'scoreboard-deaths': pgstat.deaths = value\r
352         if key == 'scoreboard-kills': pgstat.kills = value\r
353         if key == 'scoreboard-suicides': pgstat.suicides = value\r
354 \r
355     # check to see if we had a name, and if \r
356     # not use the name from the player id\r
357     if pgstat.nick == None:\r
358         pgstat.nick = player.nick\r
359 \r
360     # whichever nick we ended up with, strip it and store as the stripped_nick\r
361     pgstat.stripped_nick = qfont_decode(pgstat.nick)\r
362 \r
363     # if the nick we end up with is different from the one in the\r
364     # player record, change the nick to reflect the new value\r
365     if pgstat.nick != player.nick and player.player_id > 2:\r
366         register_new_nick(session, player, pgstat.nick)\r
367 \r
368     # if the player is ranked #1 and it is a team game, set the game's winner\r
369     # to be the team of that player\r
370     # FIXME: this is a hack, should be using the 'W' field (not present)\r
371     if pgstat.rank == '1' and pgstat.team:\r
372         game.winner = pgstat.team\r
373         session.add(game)\r
374 \r
375     session.add(pgstat)\r
376 \r
377     return pgstat\r
378 \r
379 \r
380 def create_player_weapon_stats(session=None, player=None, \r
381         game=None, pgstat=None, player_events=None):\r
382     """\r
383     Creates accuracy records for each weapon used by a given player in a\r
384     given game. Parameters:\r
385 \r
386     session - SQLAlchemy session factory object\r
387     player - Player record who owns the weapon stats\r
388     game - Game record in which the stats were created\r
389     pgstat - Corresponding PlayerGameStat record for these weapon stats\r
390     player_events - dictionary containing the raw weapon values that need to be\r
391         transformed\r
392     """\r
393     pwstats = []\r
394 \r
395     for (key,value) in player_events.items():\r
396         matched = re.search("acc-(.*?)-cnt-fired", key)\r
397         if matched:\r
398             weapon_cd = matched.group(1)\r
399             seq = Sequence('player_weapon_stats_player_weapon_stats_id_seq')\r
400             pwstat_id = session.execute(seq)\r
401             pwstat = PlayerWeaponStat()\r
402             pwstat.player_weapon_stats_id = pwstat_id\r
403             pwstat.player_id = player.player_id\r
404             pwstat.game_id = game.game_id\r
405             pwstat.player_game_stat_id = pgstat.player_game_stat_id\r
406             pwstat.weapon_cd = weapon_cd\r
407 \r
408             if 'n' in player_events:\r
409                 pwstat.nick = player_events['n']\r
410             else:\r
411                 pwstat.nick = player_events['P']\r
412 \r
413             if 'acc-' + weapon_cd + '-cnt-fired' in player_events:\r
414                 pwstat.fired = int(round(float(\r
415                         player_events['acc-' + weapon_cd + '-cnt-fired'])))\r
416             if 'acc-' + weapon_cd + '-fired' in player_events:\r
417                 pwstat.max = int(round(float(\r
418                         player_events['acc-' + weapon_cd + '-fired'])))\r
419             if 'acc-' + weapon_cd + '-cnt-hit' in player_events:\r
420                 pwstat.hit = int(round(float(\r
421                         player_events['acc-' + weapon_cd + '-cnt-hit'])))\r
422             if 'acc-' + weapon_cd + '-hit' in player_events:\r
423                 pwstat.actual = int(round(float(\r
424                         player_events['acc-' + weapon_cd + '-hit'])))\r
425             if 'acc-' + weapon_cd + '-frags' in player_events:\r
426                 pwstat.frags = int(round(float(\r
427                         player_events['acc-' + weapon_cd + '-frags'])))\r
428 \r
429             session.add(pwstat)\r
430             pwstats.append(pwstat)\r
431 \r
432     return pwstats\r
433 \r
434 \r
435 def parse_body(request):\r
436     """\r
437     Parses the POST request body for a stats submission\r
438     """\r
439     # storage vars for the request body\r
440     game_meta = {}\r
441     player_events = {}\r
442     current_team = None\r
443     players = []\r
444 \r
445     for line in request.body.split('\n'):\r
446         try:\r
447             (key, value) = line.strip().split(' ', 1)\r
448 \r
449             # Server (S) and Nick (n) fields can have international characters.\r
450             # We convert to UTF-8.\r
451             if key in 'S' 'n':\r
452                 value = unicode(value, 'utf-8')\r
453 \r
454             if key in 'V' 'T' 'G' 'M' 'S' 'C' 'R' 'W' 'I':\r
455                 game_meta[key] = value\r
456 \r
457             if key == 'P':\r
458                 # if we were working on a player record already, append\r
459                 # it and work on a new one (only set team info)\r
460                 if len(player_events) != 0:\r
461                     players.append(player_events)\r
462                     player_events = {}\r
463 \r
464                 player_events[key] = value\r
465 \r
466             if key == 'e':\r
467                 (subkey, subvalue) = value.split(' ', 1)\r
468                 player_events[subkey] = subvalue\r
469             if key == 'n':\r
470                 player_events[key] = value\r
471             if key == 't':\r
472                 player_events[key] = value\r
473         except:\r
474             # no key/value pair - move on to the next line\r
475             pass\r
476 \r
477     # add the last player we were working on\r
478     if len(player_events) > 0:\r
479         players.append(player_events)\r
480 \r
481     return (game_meta, players)\r
482 \r
483 \r
484 def create_player_stats(session=None, player=None, game=None, \r
485         player_events=None):\r
486     """\r
487     Creates player game and weapon stats according to what type of player\r
488     """\r
489     pgstat = create_player_game_stat(session=session, \r
490         player=player, game=game, player_events=player_events)\r
491 \r
492     #TODO: put this into a config setting in the ini file?\r
493     if not re.search('^bot#\d+$', player_events['P']):\r
494         create_player_weapon_stats(session=session, \r
495             player=player, game=game, pgstat=pgstat,\r
496             player_events=player_events)\r
497 \r
498 \r
499 def stats_submit(request):\r
500     """\r
501     Entry handler for POST stats submissions.\r
502     """\r
503     try:\r
504         session = DBSession()\r
505 \r
506         log.debug("\n----- BEGIN REQUEST BODY -----\n" + request.body +\r
507                 "----- END REQUEST BODY -----\n\n")\r
508 \r
509         (idfp, status) = verify_request(request)\r
510         if not idfp:\r
511             log.debug("ERROR: Unverified request")\r
512             raise pyramid.httpexceptions.HTTPUnauthorized("Unverified request")\r
513 \r
514         (game_meta, players) = parse_body(request)  \r
515 \r
516         if not has_required_metadata(game_meta):\r
517             log.debug("ERROR: Required game meta missing")\r
518             raise pyramid.exceptions.HTTPUnprocessableEntity("Missing game meta")\r
519 \r
520         if not is_supported_gametype(game_meta['G']):\r
521             log.debug("ERROR: Unsupported gametype")\r
522             raise pyramid.httpexceptions.HTTPOk("OK")\r
523 \r
524         if not has_minimum_real_players(request.registry.settings, players):\r
525             log.debug("ERROR: Not enough real players")\r
526             raise pyramid.httpexceptions.HTTPOk("OK")\r
527 \r
528         # FIXME: if we have two players and game type is 'dm',\r
529         # change this into a 'duel' gametype. This should be\r
530         # removed when the stats actually send 'duel' instead of 'dm'\r
531         if len(players) == 2 and game_meta['G'] == 'dm':\r
532             game_meta['G'] = 'duel'\r
533 \r
534         server = get_or_create_server(session=session, hashkey=idfp, \r
535                 name=game_meta['S'], revision=game_meta['R'],\r
536                 ip_addr=get_remote_addr(request))\r
537 \r
538         gmap = get_or_create_map(session=session, name=game_meta['M'])\r
539 \r
540         game = create_game(session=session, \r
541                 start_dt=datetime.datetime(\r
542                     *time.gmtime(float(game_meta['T']))[:6]), \r
543                 server_id=server.server_id, game_type_cd=game_meta['G'], \r
544                    map_id=gmap.map_id, match_id=game_meta['I'])\r
545 \r
546         # find or create a record for each player\r
547         # and add stats for each if they were present at the end\r
548         # of the game\r
549         for player_events in players:\r
550             if 'n' in player_events:\r
551                 nick = player_events['n']\r
552             else:\r
553                 nick = None\r
554 \r
555             if 'matches' in player_events and 'scoreboardvalid' \\r
556                 in player_events:\r
557                 player = get_or_create_player(session=session, \r
558                     hashkey=player_events['P'], nick=nick)\r
559                 log.debug('Creating stats for %s' % player_events['P'])\r
560                 create_player_stats(session=session, player=player, game=game, \r
561                         player_events=player_events)\r
562 \r
563         session.commit()\r
564         log.debug('Success! Stats recorded.')\r
565         return Response('200 OK')\r
566     except Exception as e:\r
567         session.rollback()\r
568         return e\r