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