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