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