gamestats.py 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590
  1. """
  2. # gamestats.py
  3. # Copyright (c) 2010-2011 Michael Buesch <m@bues.ch>
  4. # Licensed under the GNU/GPL version 2 or later.
  5. """
  6. import sys
  7. import re
  8. import datetime
  9. import errno
  10. import getopt
  11. import pygraphviz as gv
  12. debug = False
  13. re_iso_timestamp = re.compile(r'(\d\d\d\d)-(\d\d)-(\d\d)T(\d\d):(\d\d):(\d\d)\.(\d\d\d\d\d\d)')
  14. ISO_TIMESTAMP_LEN = 26
  15. class Warn(Exception): pass
  16. class Error(Exception): pass
  17. def debugMsg(msg):
  18. if debug:
  19. print(msg)
  20. def clearscreen():
  21. sys.stdout.write("\33[2J\33[0;0H")
  22. sys.stdout.flush()
  23. def filterPlayers(options, players):
  24. if options.filterLeft:
  25. players = [p for p in players if not p.disconnectedInGame]
  26. if options.filterJoinIG:
  27. players = [p for p in players if not p.connectedInGame]
  28. return players
  29. class Options(object):
  30. def __init__(self):
  31. self.myname = None
  32. self.liveUpdate = False
  33. self.alwaysSortByFrags = False
  34. self.filterLeft = False
  35. self.filterJoinIG = False
  36. self.splitLogs = False
  37. self.rawlogdir = None
  38. self.fragGraphDir = None
  39. class Event(object):
  40. def __init__(self, timestamp):
  41. self.timestamp = timestamp
  42. class Events(object):
  43. def __init__(self, options):
  44. self.options = options
  45. self.events = []
  46. def addEvent(self, event):
  47. self.events.append(event)
  48. def countAll(self):
  49. return len(self.events)
  50. class PlayerEvents(object):
  51. def __init__(self, options, players={}):
  52. self.options = options
  53. self.players = players.copy()
  54. def copy(self):
  55. return PlayerEvents(self.options, self.players)
  56. def extend(self, other):
  57. self.players.update(other.players)
  58. def addEvent(self, event, player):
  59. self.players.setdefault(player, []).append(event)
  60. def countAll(self):
  61. return sum(len(evs) for evs in self.players.values())
  62. def countFor(self, player):
  63. return len(self.players[player])
  64. def getPlayersUnfiltered(self):
  65. return list(self.players.keys())
  66. def getPlayers(self):
  67. return filterPlayers(self.options, self.getPlayersUnfiltered())
  68. class Player(object):
  69. def __init__(self, name, realName, game):
  70. self.name = name
  71. self.realName = realName
  72. self.game = game
  73. self.frags = PlayerEvents(game.options)
  74. self.teamkills = PlayerEvents(game.options)
  75. self.deaths = PlayerEvents(game.options)
  76. self.ctfScores = Events(game.options)
  77. self.ctfDrops = Events(game.options)
  78. self.ctfPicks = Events(game.options)
  79. self.ctfStolen = Events(game.options)
  80. self.ctfReturns = Events(game.options)
  81. self.suicides = Events(game.options)
  82. self.connectedInGame = False
  83. self.disconnectedInGame = False
  84. #FIXME Is there a way to detect connectedInGame for self?
  85. def connected(self):
  86. if not self.game.ended and self.game.hadFirstBlood:
  87. self.connectedInGame = True
  88. self.disconnectedInGame = False
  89. def disconnected(self):
  90. if not self.game.ended:
  91. self.disconnectedInGame = True
  92. def addFrag(self, timestamp, fraggedPlayer):
  93. if not self.game.ended:
  94. self.frags.addEvent(Event(timestamp), fraggedPlayer)
  95. fraggedPlayer.__addDeath(timestamp, self)
  96. def getFrags(self):
  97. # Returns PlayerEvents instance
  98. return self.frags
  99. def getNrFrags(self):
  100. return self.getFrags().countAll() - self.getNrTeamkills()
  101. def addTeamkill(self, timestamp, fraggedPlayer):
  102. if not self.game.ended:
  103. self.teamkills.addEvent(Event(timestamp), fraggedPlayer)
  104. fraggedPlayer.__addDeath(timestamp, self)
  105. def getTeamkills(self):
  106. # Returns PlayerEvents instance
  107. return self.teamkills
  108. def getNrTeamkills(self):
  109. return self.getTeamkills().countAll()
  110. def getKills(self):
  111. # Returns a PlayerEvents instance with all kills (= frags + teamkills)
  112. kills = self.getFrags().copy()
  113. kills.extend(self.getTeamkills())
  114. return kills
  115. def __addDeath(self, timestamp, killer):
  116. if not self.game.ended:
  117. self.deaths.addEvent(Event(timestamp), killer)
  118. def getDeaths(self):
  119. # Returns PlayerEvents instance
  120. return self.deaths
  121. def getNrDeaths(self):
  122. return self.getDeaths().countAll()
  123. def addSuicide(self, timestamp):
  124. if not self.game.ended:
  125. self.suicides.addEvent(Event(timestamp))
  126. def getNrSuicides(self):
  127. return self.suicides.countAll()
  128. def addCtfScore(self, timestamp):
  129. if not self.game.ended and self.game.isCtf():
  130. self.ctfScores.addEvent(Event(timestamp))
  131. def getNrCtfScores(self):
  132. return self.ctfScores.countAll()
  133. def addCtfDrop(self, timestamp):
  134. if not self.game.ended and self.game.isCtf():
  135. self.ctfDrops.addEvent(Event(timestamp))
  136. def getNrCtfDrops(self):
  137. return self.ctfDrops.countAll()
  138. def addCtfPick(self, timestamp):
  139. if not self.game.ended and self.game.isCtf():
  140. self.ctfPicks.addEvent(Event(timestamp))
  141. def getNrCtfPicks(self):
  142. return self.ctfPicks.countAll()
  143. def addCtfSteal(self, timestamp):
  144. if not self.game.ended and self.game.isCtf():
  145. self.ctfStolen.addEvent(Event(timestamp))
  146. def getNrCtfStolen(self):
  147. return self.ctfStolen.countAll()
  148. def addCtfReturn(self, timestamp):
  149. if not self.game.ended and self.game.isCtf():
  150. self.ctfReturns.addEvent(Event(timestamp))
  151. def getNrCtfReturns(self):
  152. return self.ctfReturns.countAll()
  153. def rename(self, newName):
  154. self.game.renamePlayer(self.realName, newName)
  155. def generateStats(self):
  156. extra = []
  157. if self.connectedInGame:
  158. extra.append("join-ig")
  159. if self.disconnectedInGame:
  160. extra.append("left")
  161. extra = " (" + ", ".join(extra) + ")" if extra else ""
  162. name = self.realName
  163. if self.name == "self":
  164. name = "==> " + name + " <=="
  165. frags = self.getNrFrags()
  166. tks = self.getNrTeamkills()
  167. deaths = self.getNrDeaths()
  168. suicides = self.getNrSuicides()
  169. ctf = ""
  170. if self.game.isCtf():
  171. ctfScores = self.getNrCtfScores()
  172. ctfDrops = self.getNrCtfDrops()
  173. ctfPicks = self.getNrCtfPicks()
  174. ctfStolen = self.getNrCtfStolen()
  175. ctfReturns = self.getNrCtfReturns()
  176. ctf = " %2.1d sco %2.1d ret %2.1d drp %2.1d stol %2.1d pck" %\
  177. (ctfScores, ctfReturns, ctfDrops, ctfStolen, ctfPicks)
  178. return "%20s: %3.1d frg %2.1d tk %3.1d dth %2.1d sk%s%s" %\
  179. (name, frags, tks, deaths, suicides, ctf, extra)
  180. class Game(object):
  181. def __init__(self, options, timestamp, mode, mapname, selfIDs=[]):
  182. self.options = options
  183. self.mode = mode.lower()
  184. self.mapname = mapname
  185. self.hadFirstBlood = False
  186. self.started = timestamp
  187. self.ended = False
  188. self.selfIDs = selfIDs
  189. myname = self.options.myname if self.options.myname else "self"
  190. self.players = {
  191. "self" : Player("self", myname, self)
  192. }
  193. debugMsg("New game: mode=%s" % mode)
  194. def __saneName(self, name):
  195. return re.sub(r'\s+', '-', name)
  196. def getSaneName(self, sep="-"):
  197. items = []
  198. items.append(self.__saneName(self.started.strftime("%Y%m%d-%H%M%S")))
  199. items.append(self.__saneName(self.mode))
  200. items.append(self.__saneName(self.mapname))
  201. return sep.join(items)
  202. def __mkPlayerKey(self, name):
  203. return "player_" + name
  204. def player(self, name):
  205. if self.options.myname and self.options.myname == name:
  206. return self.me()
  207. key = self.__mkPlayerKey(name)
  208. return self.players.setdefault(key, Player(key, name, self))
  209. def me(self):
  210. return self.players["self"]
  211. def playerOrMe(self, name):
  212. if not name or name in self.selfIDs:
  213. return self.me()
  214. return self.player(name)
  215. def getPlayersUnfiltered(self):
  216. return list(self.players.values())
  217. def getPlayers(self):
  218. return filterPlayers(self.options, self.getPlayersUnfiltered())
  219. def renamePlayer(self, oldName, newName):
  220. player = self.players.pop(self.__mkPlayerKey(oldName))
  221. player.name = self.__mkPlayerKey(newName)
  222. player.realName = newName
  223. self.players[player.name] = player
  224. def isCtf(self):
  225. return self.mode in ("ctf", "insta ctf", "efficiency ctf")
  226. def generateStats(self):
  227. ret = []
  228. if self.ended:
  229. prefix = "<GAME ENDED %s> " % self.ended.ctime()
  230. else:
  231. if self.options.liveUpdate:
  232. prefix = "<game running> "
  233. else:
  234. prefix = "<GAME INTERRUPTED> "
  235. ret.append("%s'%s' on map '%s' started %s:" %\
  236. (prefix, self.mode, self.mapname, self.started.ctime()))
  237. players = list(self.getPlayers())[:]
  238. if self.isCtf() and not self.options.alwaysSortByFrags:
  239. key = lambda p: p.getNrCtfScores()
  240. else:
  241. key = lambda p: p.getNrFrags()
  242. players.sort(key=key, reverse=True)
  243. for player in players:
  244. ret.append(player.generateStats())
  245. return "\n".join(ret)
  246. class Parser(object):
  247. def __init__(self, options):
  248. self.options = options
  249. self.currentGame = None
  250. self.games = []
  251. def parseLine(self, line):
  252. if not self.lineHasTimestamp(line):
  253. now = datetime.datetime.now().isoformat()
  254. line = "%s/%s" % (now, line)
  255. lineNoStamp, stamp = self.parseIsoTimestamp(line)
  256. self.doParseLine(stamp, lineNoStamp)
  257. if not line.endswith("\n"):
  258. line += "\n"
  259. writeRawLog(self.options.rawlogdir, line)
  260. def doParseLine(self, timestamp, line):
  261. # This is the actual game specific parser.
  262. # Reimplement this method in the subclass.
  263. raise NotImplementedError
  264. def parseFile(self, fd):
  265. while True:
  266. try:
  267. line = fd.readline()
  268. if not line:
  269. break
  270. self.parseLine(line)
  271. if self.options.liveUpdate:
  272. sys.stdout.write(self.generateStats())
  273. sys.stdout.flush()
  274. except Warn as e:
  275. print("Warning: " + str(e))
  276. stats = self.generateStats()
  277. if stats:
  278. sys.stdout.write(stats + "\n\n\n")
  279. sys.stdout.flush()
  280. def assertCurrentGame(self, msg):
  281. if not self.currentGame:
  282. print(str(msg) + ", but there's no game")
  283. return False
  284. return True
  285. def generateStats(self):
  286. if not self.games:
  287. return ""
  288. if self.options.liveUpdate:
  289. clearscreen()
  290. return self.games[-1].generateStats()
  291. else:
  292. ret = []
  293. for game in self.games:
  294. ret.append(game.generateStats())
  295. return "\n\n\n".join(ret)
  296. def gameEnded(self):
  297. if not self.currentGame or\
  298. not self.options.fragGraphDir:
  299. return
  300. fg = FragGraph(self.currentGame)
  301. for algo in ("dot", "circo"):
  302. filename = "%s/frags-%s-%s.svg" %\
  303. (self.options.fragGraphDir, algo,
  304. self.currentGame.getSaneName())
  305. fg.generateSVG(filename, algo)
  306. def parseIsoTimestamp(self, line):
  307. # Returns a tuple (rest-of-line, datetime-instance)
  308. idx = line.find("/")
  309. if idx < 0:
  310. raise Error("Parser: Did not find timestamp on line: " + line)
  311. try:
  312. stamp = line[0:idx]
  313. line = line[idx+1:]
  314. if len(stamp) != ISO_TIMESTAMP_LEN:
  315. raise ValueError
  316. m = re_iso_timestamp.match(stamp)
  317. if not m:
  318. raise ValueError
  319. stamp = datetime.datetime(
  320. year=int(m.group(1)), month=int(m.group(2)), day=int(m.group(3)),
  321. hour=int(m.group(4)), minute=int(m.group(5)), second=int(m.group(6)),
  322. microsecond=int(m.group(7)))
  323. except (IndexError, ValueError) as e:
  324. raise Error("Parser: Invalid timestamp")
  325. return line, stamp
  326. @staticmethod
  327. def lineHasTimestamp(line):
  328. try:
  329. m = re_iso_timestamp.match(line[0:ISO_TIMESTAMP_LEN])
  330. if m:
  331. return True
  332. except IndexError as e:
  333. pass
  334. return False
  335. class FragGraph(object):
  336. # Graph tuning parameters
  337. ARROW_SCALE = 2.0
  338. ARROW_BASE = 0.5
  339. WEIGHT_SCALE = 0.5
  340. PEN_SCALE = 8.0
  341. NAME_BASE = 12
  342. NAME_SCALE = 14.0
  343. def __init__(self, game):
  344. self.game = game
  345. def __p2n(self, player):
  346. return "%s (%d)" % (player.realName, player.getNrFrags())
  347. def __genEdge(self, player, fraggedPlayer, nrFrags, maxPerTargetKillCnt,
  348. color="#000000"):
  349. assert(nrFrags <= maxPerTargetKillCnt)
  350. if maxPerTargetKillCnt != 0:
  351. arrowSize = ((nrFrags / maxPerTargetKillCnt) * self.ARROW_SCALE) + self.ARROW_BASE
  352. penWidth = max(1, ((nrFrags / maxPerTargetKillCnt) * self.PEN_SCALE))
  353. else:
  354. arrowSize = self.ARROW_BASE
  355. penWidth = 1
  356. edgeWeight = float(nrFrags) * self.WEIGHT_SCALE
  357. self.g.add_edge(self.__p2n(player),
  358. self.__p2n(fraggedPlayer),
  359. key="%s->%s" % (player.realName, fraggedPlayer.realName),
  360. dir="forward",
  361. arrowhead="normal",
  362. weight=edgeWeight,
  363. arrowsize=arrowSize,
  364. penwidth=penWidth,
  365. color=color)
  366. def __generate(self, layoutAlgorithm):
  367. self.g = gv.AGraph(strict=False, directed=False,
  368. landscape=False,
  369. name=self.game.getSaneName(),
  370. label=self.game.getSaneName(" - "),
  371. labelfontsize=22,
  372. labelloc="t",
  373. dpi=70)
  374. # Find extrema
  375. maxPerTargetKillCnt = 0 # Max per-target kill count
  376. maxNrFrags = 0 # Max frags (no SKs, no TKs, any target)
  377. for player in self.game.getPlayers():
  378. for fraggedPlayer in player.getFrags().getPlayers():
  379. maxPerTargetKillCnt = max(maxPerTargetKillCnt,
  380. player.getFrags().countFor(fraggedPlayer))
  381. for fraggedPlayer in player.getTeamkills().getPlayers():
  382. maxPerTargetKillCnt = max(maxPerTargetKillCnt,
  383. player.getTeamkills().countFor(fraggedPlayer))
  384. maxPerTargetKillCnt = max(maxPerTargetKillCnt,
  385. player.getNrSuicides())
  386. maxNrFrags = max(maxNrFrags, player.getNrFrags())
  387. # Create all nodes
  388. for player in self.game.getPlayers():
  389. nrFrags = player.getNrFrags()
  390. if maxNrFrags != 0:
  391. fontsize = ((nrFrags / maxNrFrags) * self.NAME_SCALE) + self.NAME_BASE
  392. else:
  393. fontsize = self.NAME_BASE
  394. self.g.add_node(self.__p2n(player),
  395. margin="0.2, 0.1",
  396. fontsize=int(round(fontsize)))
  397. # Create all edges
  398. for player in self.game.getPlayers():
  399. for fraggedPlayer in player.getFrags().getPlayers():
  400. self.__genEdge(player, fraggedPlayer,
  401. player.getFrags().countFor(fraggedPlayer),
  402. maxPerTargetKillCnt)
  403. for fraggedPlayer in player.getTeamkills().getPlayers():
  404. self.__genEdge(player, fraggedPlayer,
  405. player.getTeamkills().countFor(fraggedPlayer),
  406. maxPerTargetKillCnt,
  407. color="#FF0000")
  408. if player.getNrSuicides():
  409. self.__genEdge(player, player, player.getNrSuicides(),
  410. maxPerTargetKillCnt,
  411. color="#0000FF")
  412. self.g.layout(prog=layoutAlgorithm)
  413. def generateSVG(self, filename, layoutAlgorithm):
  414. self.__generate(layoutAlgorithm)
  415. self.g.draw(filename, format="svg")
  416. rawlogfile = None
  417. def writeRawLog(directory, line):
  418. global rawlogfile
  419. if not directory:
  420. return
  421. try:
  422. if not rawlogfile:
  423. count = 0
  424. datestr = datetime.datetime.now().strftime("%Y%m%d-%H%M%S")
  425. while True:
  426. name = "%s/%s-%03d.log" % (directory, datestr, count)
  427. try:
  428. open(name, "r").close()
  429. except IOError as e:
  430. if e.errno == errno.ENOENT:
  431. break
  432. if count >= 999:
  433. raise Warn("Could not find possible filename")
  434. count += 1
  435. rawlogfile = open(name, "w")
  436. rawlogfile.write(line)
  437. rawlogfile.flush()
  438. except IOError as e:
  439. raise Warn("Failed to write logfile: %s" % str(e))
  440. def closeRawLog():
  441. global rawlogfile
  442. if rawlogfile:
  443. rawlogfile.flush()
  444. rawlogfile.close()
  445. rawlogfile = None
  446. def genericUsage(scriptname, additionalInfo):
  447. print("Usage: %s [OPTIONS] [LOGFILES]" % scriptname)
  448. print("")
  449. print("The optional LOGFILES are files logged with the -l|--logdir option")
  450. print("If no logfiles are given, %s will listen to raw logdata" % scriptname)
  451. print("input on stdin.")
  452. if additionalInfo:
  453. print("\n" + additionalInfo)
  454. print("")
  455. print(" -n|--myname NAME my nickname ('self', if not given)")
  456. print(" -l|--logdir DIR Write the raw logs to DIRectory")
  457. print(" -s|--splitlogs Split logs by map")
  458. print(" -F|--fraggraphdir DIR Write frag-graph SVGs to DIRectory")
  459. print(" -L|--filterleft Filter all players who left the game early")
  460. print(" -J|--filterjoinig Filter all players who joined the game late")
  461. print(" -f|--sortbyfrags Always sort by # of frags")
  462. print(" -d|--debug Enable debugging")
  463. def genericMain(scriptname, usageinfo, parserClass):
  464. global debug
  465. options = Options()
  466. try:
  467. (opts, args) = getopt.getopt(sys.argv[1:],
  468. "hdn:l:fF:LJs",
  469. [ "help", "debug", "myname=", "logdir=", "sortbyfrags",
  470. "fraggraphdir=", "filterleft", "filterjoinig",
  471. "splitlogs", ])
  472. for (o, v) in opts:
  473. if o in ("-h", "--help"):
  474. genericUsage(scriptname, usageinfo)
  475. return 0
  476. if o in ("-d", "--debug"):
  477. debug = True
  478. if o in ("-n", "--myname"):
  479. options.myname = v
  480. if o in ("-l", "--logdir"):
  481. options.rawlogdir = v
  482. if o in ("-f", "--sortbyfrags"):
  483. options.alwaysSortByFrags = True
  484. if o in ("-F", "--fraggraphdir"):
  485. options.fragGraphDir = v
  486. if o in ("-L", "--filterleft"):
  487. options.filterLeft = True
  488. if o in ("-J", "--filterjoinig"):
  489. options.filterJoinIG = True
  490. if o in ("-s", "--splitlogs"):
  491. options.splitLogs = True
  492. except (getopt.GetoptError):
  493. genericUsage(scriptname, usageinfo)
  494. return 1
  495. try:
  496. if args:
  497. for arg in args:
  498. fd = open(arg, "r")
  499. parserClass(options).parseFile(fd)
  500. else:
  501. options.liveUpdate = True
  502. parserClass(options).parseFile(sys.stdin)
  503. except (Warn, Error) as e:
  504. print("Exception: " + str(e))
  505. return 1
  506. return 0