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