From 6fbd7febb098e7f593e84c18b8782685000cd61b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Olivier=20Teuli=C3=A8re?= Date: Tue, 11 Dec 2012 21:33:47 +0100 Subject: [PATCH] Reports: initial version, with 2 reports. The reports are already usable, but the statistics report could be improved. --- extras/reports/README.txt | 20 +++ extras/reports/eliot.py | 249 ++++++++++++++++++++++++++++++++++++++ extras/reports/sigles.py | 36 ++++++ extras/reports/stats.css | 112 +++++++++++++++++ extras/reports/stats.py | 32 +++++ extras/reports/stats.tmpl | 107 ++++++++++++++++ 6 files changed, 556 insertions(+) create mode 100644 extras/reports/README.txt create mode 100644 extras/reports/eliot.py create mode 100755 extras/reports/sigles.py create mode 100644 extras/reports/stats.css create mode 100755 extras/reports/stats.py create mode 100644 extras/reports/stats.tmpl diff --git a/extras/reports/README.txt b/extras/reports/README.txt new file mode 100644 index 0000000..db3edd6 --- /dev/null +++ b/extras/reports/README.txt @@ -0,0 +1,20 @@ +This directory contains several Python scripts to generate reports from Eliot +save games (in XML format). To run them, you need python installed, and in the +case of stats.py, you also need the Cheetah template engine +(http://www.cheetahtemplate.org/, package python-cheetah on Debian systems). + +At the moment, the following scripts are supported: + - stats.py generates an HTML page with information roughly equivalent to + the Statistics window in Eliot + - sigles.py generates a game summary, usable with the SIGLES program (used + in French Scrabble tournaments) + +Each script gives usage information when called with a -h or --help argument. + +Note that eliot.py is not directly usable: it contains utility functions +(mainly to extract data from the save game) that are called from the other +scripts. + +If you write a new report that you find useful, please consider contributing +it! + diff --git a/extras/reports/eliot.py b/extras/reports/eliot.py new file mode 100644 index 0000000..51a5889 --- /dev/null +++ b/extras/reports/eliot.py @@ -0,0 +1,249 @@ +import xml.etree.ElementTree as ET +import codecs + + +# Define some classes to store parsed data in a structured way + +class Move(object): + """Data for a move. The 'type' and 'points' members are always valid. + Other members depend on the type of move: + - for valid and invalid moves, word and coords can be used + - for a move changing letters, word can be used (it contains the changed letters) + - otherwise, no other field is valid""" + + def __init__(self, type, points, word, coords): + self.type = type + self.points = int(points) + self.word = word + self.coords = coords + + def __str__(self): + return "Move (type=%s): %s" % (self.type, self.points) + + def isValid(self): + return self.type == "valid" + + def isInvalid(self): + return self.type == "invalid" + + def isChange(self): + return self.type == "change" + + def isPass(self): + return self.type == "pass" + + def isNone(self): + return self.type == "none" + +class Turn(object): + """Data for a player turn (or game turn)""" + + def __init__(self, num, rack, move, events): + self.num = num + self.rack = rack + self.move = move + self.events = events + +class Player(object): + """Statistics of a player""" + + def __init__(self, id, name): + self.id = id + self.name = name + self.tableNb = None + self.turns = {} + +class GameData(object): + """Entry point for all the game data""" + + def __init__(self, name): + self.players = [] + self.turns = {} + self.turnNb = 0 + self.name = name + self.mode = None + + def isTop(self, turnNb, move): + if move is None: + return False + movePoints = move.points + masterPoints = self.turns[turnNb].move.points + return masterPoints <= movePoints + + def isSubTop(self, turnNb, move): + if move is None: + return False + playerPoints = [p.turns[turnNb].move.points for p in self.players] + return move.points >= max(playerPoints) + + def isSolo(self, turnNb, move): + if move is None: + return False + playerPoints = [p.turns[turnNb].move.points for p in self.players] + return self.isSubTop(turnNb, move) and playerPoints.count(move.points) == 1 + +def readSaveGame(xmlFileName): + + # Entry point for all the data + gameData = GameData(xmlFileName) + + # XML tree + tree = ET.parse(xmlFileName) + + gameData.mode = tree.findtext("Game/Mode") + + # Parse players + for playerElem in tree.findall("Game/Player"): + player = Player( + int(playerElem.get("id")), + playerElem.findtext("Name")) + # Priori to Eliot 2.1, there was no table number in the file + tableNb = playerElem.findtext("TableNb"); + if tableNb is None: + player.tableNb = player.id + else: + player.tableNb = int(tableNb) + + gameData.players.append(player) + + # Parse turns + turnNum = 0 + for turnElem in tree.findall("History/Turn"): + turnNum += 1 + + # Get players data for this turn + for p in gameData.players: + # Get the player rack + allRacks = turnElem.findall("PlayerRack[@playerId='%s']" % p.id) + # Before Eliot 2.1 (version 2.0 only), the attribute was called "playerid" + allRacks.extend(turnElem.findall("PlayerRack[@playerid='%s']" % p.id)) + if len(allRacks) == 0 and turnNum > 1: + rack = p.turns[turnNum - 1].rack + else: + # FIXME: does not work well in freegame mode, + # because we take the next rack instead of the current one + rack = allRacks[-1].text + + # Get the player move + allMoves = turnElem.findall("PlayerMove[@playerId='%s']" % p.id) + # Before Eliot 2.1 (version 2.0 only), the attribute was called "playerid" + allMoves.extend(turnElem.findall("PlayerMove[@playerid='%s']" % p.id)) + if len(allMoves) == 0: + move = Move("none", "0", "", "") + else: + lastMove = allMoves[-1] + move = Move(lastMove.get("type"), + lastMove.get("points"), + lastMove.get("word"), + lastMove.get("coord")) + if move.isChange(): + move.word = lastMove.get("letters") + # Create turn + # TODO: handle events + turn = Turn(turnNum, rack, move, None) + p.turns[turnNum] = turn + + # Get game data for this turn + rack = turnElem.findtext("GameRack") + gameMove = turnElem.find("GameMove") + if gameMove is None: + move = Move("none", "0", "", "") + else: + move = Move(gameMove.get("type"), + gameMove.get("points"), + gameMove.get("word"), + gameMove.get("coord")) + if move.isChange(): + move.word = lastMove.get("letters") + gameData.turns[turnNum] = Turn(turnNum, rack, move, None); + + gameData.turnNb = turnNum + + # Parse statistics (introduced in version 2.1: version 2.0 didn't have that) + if tree.find("Statistics/GameStats") is None: + # For Eliot 2.0 only (version 2.1 introduced the tag) + gameData.totalScore = sum([t.move.points for t in gameData.turns.values()]) + + for p in gameData.players: + p.rawScore = sum([t.move.points for t in p.turns.values()]) + # FIXME: should be computed from events + p.warningsNb = 0 + p.penaltiesPoints = 0 + p.solosPoints = 0 + p.totalScore = p.rawScore + p.warningsNb + p.penaltiesPoints + p.solosPoints + p.diffWithTop = gameData.totalScore - p.totalScore + # TODO: percent + p.percentTop = "" + # TODO: rank + p.rank = 0 + else: + playersById = dict([(p.id, p) for p in gameData.players]) + for playerStat in tree.findall("Statistics/PlayerStats"): + playerId = int(playerStat.get("playerId")) + player = playersById[playerId] + assert player != None, \ + "No player found with id %s" % playerId + + player.rawScore = int(playerStat.get("rawScore")) + player.warningsNb = int(playerStat.get("warningsNb")) + player.penaltiesPoints = int(playerStat.get("penaltiesPoints")) + player.solosPoints = int(playerStat.get("solosPoints")) + player.totalScore = int(playerStat.get("totalScore")) + player.diffWithTop = int(playerStat.get("diffWithTop")) + player.percentTop = playerStat.get("percentTop") + player.rank = int(playerStat.get("rank")) + gameData.totalScore = tree.find("Statistics/GameStats").get("totalScore") + + return gameData + + +# Utility class, to replace argparse.FileType until Issue 11175 is solved (http://bugs.python.org/issue11175) +class FileType(object): + """Factory for creating file object types + + Instances of FileType are typically passed as type= arguments to the + ArgumentParser add_argument() method. + + Keyword Arguments: + - mode -- A string indicating how the file is to be opened. Accepts the + same values as the builtin open() function. + - bufsize -- The file's desired buffer size. Accepts the same values as + the builtin open() function. + - encoding -- The file's encoding. Accepts the same values as the + the builtin open() function. + - errors -- A string indicating how encoding and decoding errors are to + be handled. Accepts the same value as the builtin open() function. + """ + + def __init__(self, mode='r', bufsize=-1, encoding=None, errors=None): + self._mode = mode + self._bufsize = bufsize + self._encoding = encoding + self._errors = errors + + def __call__(self, string): + # the special argument "-" means sys.std{in,out} + if string == '-': + if 'r' in self._mode: + return _sys.stdin + elif 'w' in self._mode: + return _sys.stdout + else: + msg = _('argument "-" with mode %r') % self._mode + raise ValueError(msg) + + # all other arguments are used as file names + try: + return codecs.open(string, self._mode, self._encoding, self._errors, self._bufsize) + except IOError as e: + message = _("can't open '%s': %s") + raise ArgumentTypeError(message % (string, e)) + + def __repr__(self): + args = self._mode, self._bufsize + kwargs = [('encoding', self._encoding), ('errors', self._errors)] + args_str = ', '.join([repr(arg) for arg in args if arg != -1] + + ['%s=%r' % (kw, arg) for kw, arg in kwargs + if arg is not None]) + return '%s(%s)' % (type(self).__name__, args_str) + diff --git a/extras/reports/sigles.py b/extras/reports/sigles.py new file mode 100755 index 0000000..8ba953b --- /dev/null +++ b/extras/reports/sigles.py @@ -0,0 +1,36 @@ +#!/usr/bin/env python + +import os +import sys +import argparse + +# Eliot modules +import eliot + + +# Command-line parsing +parser = argparse.ArgumentParser(description="""Generate a game summary suitable for the SIGLES program +(used by the French Scrabble Federation for official tournaments)""") +parser.add_argument("-s", "--savegame", help="game saved with Eliot", type=file, required=True) +parser.add_argument("-o", "--output", help="output file in latin1 encoding (stdout by default)", + type=eliot.FileType(mode='w', encoding="latin1"), default=sys.stdout) +parser.add_argument("-n", "--no-empty", help="do not output lines for empty tables", action="store_true") +args = parser.parse_args() + +# Do most of the work: open the save game file, parse it, +# and build easy to use data structures +gameData = eliot.readSaveGame(args.savegame) + +# Output file +out = args.output + +# Index the players by their table number +playerByTable = dict([(p.tableNb, p) for p in gameData.players]) +# Generate the report +for num in range(1, 1 + max(playerByTable.keys())): + if num in playerByTable: + p = playerByTable[num] + out.write('"%s","%s","%s"\n' % (num, p.name, p.totalScore)) + elif not args.no_empty: + out.write('"%s","%s","%s"\n' % (num, "--table vide--", -1)) + diff --git a/extras/reports/stats.css b/extras/reports/stats.css new file mode 100644 index 0000000..79f8238 --- /dev/null +++ b/extras/reports/stats.css @@ -0,0 +1,112 @@ +table { + border-collapse: collapse; +} + +td { + border: 1px solid white; + padding: 3px; +} + +.statsTable { + text-align: right; +} + +.gameTitle, .colHeader { + background-color: #666699; + color: white; + font-weight: bold; + text-align: center; +} + +.playerName { + background-color: #9999cc; + text-align: center; +} + +.master { + background-color: #b3b3b3; +} + +.topSolo { + color: white; + font-weight: bold; + + /* Generated by Ultimate CSS Gradient Generator (http://www.colorzilla.com/gradient-editor/) */ + background: #f50000; /* Old browsers */ + /* IE9 SVG, needs conditional override of 'filter' to 'none' */ + background: url(data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiA/Pgo8c3ZnIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIgd2lkdGg9IjEwMCUiIGhlaWdodD0iMTAwJSIgdmlld0JveD0iMCAwIDEgMSIgcHJlc2VydmVBc3BlY3RSYXRpbz0ibm9uZSI+CiAgPGxpbmVhckdyYWRpZW50IGlkPSJncmFkLXVjZ2ctZ2VuZXJhdGVkIiBncmFkaWVudFVuaXRzPSJ1c2VyU3BhY2VPblVzZSIgeDE9IjAlIiB5MT0iMCUiIHgyPSIwJSIgeTI9IjEwMCUiPgogICAgPHN0b3Agb2Zmc2V0PSIwJSIgc3RvcC1jb2xvcj0iI2Y1MDAwMCIgc3RvcC1vcGFjaXR5PSIxIi8+CiAgICA8c3RvcCBvZmZzZXQ9IjEwMCUiIHN0b3AtY29sb3I9IiNmNGJkYzAiIHN0b3Atb3BhY2l0eT0iMSIvPgogIDwvbGluZWFyR3JhZGllbnQ+CiAgPHJlY3QgeD0iMCIgeT0iMCIgd2lkdGg9IjEiIGhlaWdodD0iMSIgZmlsbD0idXJsKCNncmFkLXVjZ2ctZ2VuZXJhdGVkKSIgLz4KPC9zdmc+); + background: -moz-linear-gradient(top, #f50000 0%, #f4bdc0 100%); /* FF3.6+ */ + background: -webkit-gradient(linear, left top, left bottom, color-stop(0%,#f50000), color-stop(100%,#f4bdc0)); /* Chrome,Safari4+ */ + background: -webkit-linear-gradient(top, #f50000 0%,#f4bdc0 100%); /* Chrome10+,Safari5.1+ */ + background: -o-linear-gradient(top, #f50000 0%,#f4bdc0 100%); /* Opera 11.10+ */ + background: -ms-linear-gradient(top, #f50000 0%,#f4bdc0 100%); /* IE10+ */ + background: linear-gradient(to bottom, #f50000 0%,#f4bdc0 100%); /* W3C */ + filter: progid:DXImageTransform.Microsoft.gradient( startColorstr='#f50000', endColorstr='#f4bdc0',GradientType=0 ); /* IE6-8 */ +} + +.top { + /* Generated by Ultimate CSS Gradient Generator (http://www.colorzilla.com/gradient-editor/) */ + background: #f2a675; /* Old browsers */ + /* IE9 SVG, needs conditional override of 'filter' to 'none' */ + background: url(data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiA/Pgo8c3ZnIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIgd2lkdGg9IjEwMCUiIGhlaWdodD0iMTAwJSIgdmlld0JveD0iMCAwIDEgMSIgcHJlc2VydmVBc3BlY3RSYXRpbz0ibm9uZSI+CiAgPGxpbmVhckdyYWRpZW50IGlkPSJncmFkLXVjZ2ctZ2VuZXJhdGVkIiBncmFkaWVudFVuaXRzPSJ1c2VyU3BhY2VPblVzZSIgeDE9IjAlIiB5MT0iMCUiIHgyPSIwJSIgeTI9IjEwMCUiPgogICAgPHN0b3Agb2Zmc2V0PSIxJSIgc3RvcC1jb2xvcj0iI2YyYTY3NSIgc3RvcC1vcGFjaXR5PSIxIi8+CiAgICA8c3RvcCBvZmZzZXQ9IjEwMCUiIHN0b3AtY29sb3I9IiNmMGUzZGEiIHN0b3Atb3BhY2l0eT0iMSIvPgogIDwvbGluZWFyR3JhZGllbnQ+CiAgPHJlY3QgeD0iMCIgeT0iMCIgd2lkdGg9IjEiIGhlaWdodD0iMSIgZmlsbD0idXJsKCNncmFkLXVjZ2ctZ2VuZXJhdGVkKSIgLz4KPC9zdmc+); + background: -moz-linear-gradient(top, #f2a675 1%, #f0e3da 100%); /* FF3.6+ */ + background: -webkit-gradient(linear, left top, left bottom, color-stop(1%,#f2a675), color-stop(100%,#f0e3da)); /* Chrome,Safari4+ */ + background: -webkit-linear-gradient(top, #f2a675 1%,#f0e3da 100%); /* Chrome10+,Safari5.1+ */ + background: -o-linear-gradient(top, #f2a675 1%,#f0e3da 100%); /* Opera 11.10+ */ + background: -ms-linear-gradient(top, #f2a675 1%,#f0e3da 100%); /* IE10+ */ + background: linear-gradient(to bottom, #f2a675 1%,#f0e3da 100%); /* W3C */ + filter: progid:DXImageTransform.Microsoft.gradient( startColorstr='#f2a675', endColorstr='#f0e3da',GradientType=0 ); /* IE6-8 */ +} + +.subTopSolo { + color: white; + font-weight: bold; + + /* Generated by Ultimate CSS Gradient Generator (http://www.colorzilla.com/gradient-editor/) */ + background: #0668c1; /* Old browsers */ + /* IE9 SVG, needs conditional override of 'filter' to 'none' */ + background: url(data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiA/Pgo8c3ZnIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIgd2lkdGg9IjEwMCUiIGhlaWdodD0iMTAwJSIgdmlld0JveD0iMCAwIDEgMSIgcHJlc2VydmVBc3BlY3RSYXRpbz0ibm9uZSI+CiAgPGxpbmVhckdyYWRpZW50IGlkPSJncmFkLXVjZ2ctZ2VuZXJhdGVkIiBncmFkaWVudFVuaXRzPSJ1c2VyU3BhY2VPblVzZSIgeDE9IjAlIiB5MT0iMCUiIHgyPSIwJSIgeTI9IjEwMCUiPgogICAgPHN0b3Agb2Zmc2V0PSIxJSIgc3RvcC1jb2xvcj0iIzA2NjhjMSIgc3RvcC1vcGFjaXR5PSIxIi8+CiAgICA8c3RvcCBvZmZzZXQ9IjEwMCUiIHN0b3AtY29sb3I9IiNjNGQ2ZWEiIHN0b3Atb3BhY2l0eT0iMSIvPgogIDwvbGluZWFyR3JhZGllbnQ+CiAgPHJlY3QgeD0iMCIgeT0iMCIgd2lkdGg9IjEiIGhlaWdodD0iMSIgZmlsbD0idXJsKCNncmFkLXVjZ2ctZ2VuZXJhdGVkKSIgLz4KPC9zdmc+); + background: -moz-linear-gradient(top, #0668c1 1%, #c4d6ea 100%); /* FF3.6+ */ + background: -webkit-gradient(linear, left top, left bottom, color-stop(1%,#0668c1), color-stop(100%,#c4d6ea)); /* Chrome,Safari4+ */ + background: -webkit-linear-gradient(top, #0668c1 1%,#c4d6ea 100%); /* Chrome10+,Safari5.1+ */ + background: -o-linear-gradient(top, #0668c1 1%,#c4d6ea 100%); /* Opera 11.10+ */ + background: -ms-linear-gradient(top, #0668c1 1%,#c4d6ea 100%); /* IE10+ */ + background: linear-gradient(to bottom, #0668c1 1%,#c4d6ea 100%); /* W3C */ + filter: progid:DXImageTransform.Microsoft.gradient( startColorstr='#0668c1', endColorstr='#c4d6ea',GradientType=0 ); /* IE6-8 */ +} + +.subTop { + /* Generated by Ultimate CSS Gradient Generator (http://www.colorzilla.com/gradient-editor/) */ + background: #b8cad8; /* Old browsers */ + /* IE9 SVG, needs conditional override of 'filter' to 'none' */ + background: url(data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiA/Pgo8c3ZnIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIgd2lkdGg9IjEwMCUiIGhlaWdodD0iMTAwJSIgdmlld0JveD0iMCAwIDEgMSIgcHJlc2VydmVBc3BlY3RSYXRpbz0ibm9uZSI+CiAgPGxpbmVhckdyYWRpZW50IGlkPSJncmFkLXVjZ2ctZ2VuZXJhdGVkIiBncmFkaWVudFVuaXRzPSJ1c2VyU3BhY2VPblVzZSIgeDE9IjAlIiB5MT0iMCUiIHgyPSIwJSIgeTI9IjEwMCUiPgogICAgPHN0b3Agb2Zmc2V0PSIxJSIgc3RvcC1jb2xvcj0iI2I4Y2FkOCIgc3RvcC1vcGFjaXR5PSIxIi8+CiAgICA8c3RvcCBvZmZzZXQ9IjEwMCUiIHN0b3AtY29sb3I9IiNlN2ViZWUiIHN0b3Atb3BhY2l0eT0iMSIvPgogIDwvbGluZWFyR3JhZGllbnQ+CiAgPHJlY3QgeD0iMCIgeT0iMCIgd2lkdGg9IjEiIGhlaWdodD0iMSIgZmlsbD0idXJsKCNncmFkLXVjZ2ctZ2VuZXJhdGVkKSIgLz4KPC9zdmc+); + background: -moz-linear-gradient(top, #b8cad8 1%, #e7ebee 100%); /* FF3.6+ */ + background: -webkit-gradient(linear, left top, left bottom, color-stop(1%,#b8cad8), color-stop(100%,#e7ebee)); /* Chrome,Safari4+ */ + background: -webkit-linear-gradient(top, #b8cad8 1%,#e7ebee 100%); /* Chrome10+,Safari5.1+ */ + background: -o-linear-gradient(top, #b8cad8 1%,#e7ebee 100%); /* Opera 11.10+ */ + background: -ms-linear-gradient(top, #b8cad8 1%,#e7ebee 100%); /* IE10+ */ + background: linear-gradient(to bottom, #b8cad8 1%,#e7ebee 100%); /* W3C */ + filter: progid:DXImageTransform.Microsoft.gradient( startColorstr='#b8cad8', endColorstr='#e7ebee',GradientType=0 ); /* IE6-8 */ +} + +.normal1 { + background-color: #e6e6e6; +} + +.normal2 { + background-color: #f2f2f2; +} + +.legendTable { + margin-top: 40px; +} + +.legendIcon { + width: 20px; + height: 20px; +} + +.legendText { + +} + diff --git a/extras/reports/stats.py b/extras/reports/stats.py new file mode 100755 index 0000000..6205e2c --- /dev/null +++ b/extras/reports/stats.py @@ -0,0 +1,32 @@ +#!/usr/bin/env python + +import os +import sys +import argparse +from Cheetah.Template import Template + +# Eliot modules +import eliot + + +# Command-line parsing +parser = argparse.ArgumentParser(description="""Generate an HTML page roughly equivalent to the Statistics window in Eliot""") +parser.add_argument("-s", "--savegame", help="game saved with Eliot", type=file, required=True) +parser.add_argument("-o", "--output", help="output file (stdout by default)", + type=argparse.FileType('w'), default=sys.stdout) +args = parser.parse_args() + +# Do most of the work: open the save game file, parse it, +# and build easy to use data structures +gameData = eliot.readSaveGame(args.savegame.name) + +# Load the template +with file("stats.tmpl") as f: + templDef = f.read() + +# Fill it with values +templ = Template(templDef, {'gameData': gameData}) + +# Print the generated document +args.output.write(str(templ)); + diff --git a/extras/reports/stats.tmpl b/extras/reports/stats.tmpl new file mode 100644 index 0000000..864aec8 --- /dev/null +++ b/extras/reports/stats.tmpl @@ -0,0 +1,107 @@ + + + + + + + Page Title XXX + + + + + + + + + #for $turn in $gameData.turns.keys + + #end for + + + + + + + + + + + + #for $turn in $gameData.turns.values + + #end for + + + + + + + + + + #for $player in $gameData.players + + + #for $turn in $player.turns.values + #if $gameData.isSolo($turn.num, $turn.move) + #if $gameData.isTop($turn.num, $turn.move) + + #else + + #end if + #else + #if $gameData.isTop($turn.num, $turn.move) + + #elif $gameData.isSubTop($turn.num, $turn.move) + + #elif $player.id % 2 == 1 + + #else + + #end if + #end if + #end for + #if $player.id % 2 == 1 + + + + + + + + + #else + + + + + + + + + #end if + + #end for +
$gameData.name
Player$turnSub-totalSWPTotalDiffGame %Ranking
Master$turn.move.points$gameData.totalScore$gameData.totalScore100%
$player.name$turn.move.points$turn.move.points$turn.move.points$turn.move.points$turn.move.points$turn.move.points$player.rawScore$player.solosPoints$player.warningsNb$player.penaltiesPoints$player.totalScore$player.diffWithTop$player.percentTop$player.rank$player.rawScore$player.solosPoints$player.warningsNb$player.penaltiesPoints$player.totalScore$player.diffWithTop$player.percentTop$player.rank
+ + + + + + + + + + + + + + + + + + +
Solo with maximal score
Solo with non-maximal score
Move with maximal score (not a solo)
Move with non-maximal score (not a solo)
+ + +