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