Reports: initial version, with 2 reports.

The reports are already usable, but the statistics report could be
improved.
This commit is contained in:
Olivier Teulière 2012-12-11 21:33:47 +01:00
parent 50a2ab7996
commit 6fbd7febb0
6 changed files with 556 additions and 0 deletions

20
extras/reports/README.txt Normal file
View file

@ -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!

249
extras/reports/eliot.py Normal file
View file

@ -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)

36
extras/reports/sigles.py Executable file
View file

@ -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))

112
extras/reports/stats.css Normal file
View file

@ -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 {
}

32
extras/reports/stats.py Executable file
View file

@ -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));

107
extras/reports/stats.tmpl Normal file
View file

@ -0,0 +1,107 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.1 plus MathML 2.0//EN" "http://www.w3.org/Math/DTD/mathml2/xhtml-math11-f.dtd">
<html xmlns="http://www.w3.org/1999/xhtml">
<head>
<meta http-equiv="Content-Type" content="application/xhtml+xml; charset=utf-8"/>
<link rel="stylesheet" type="text/css" href="stats.css" />
<title>Page Title XXX</title>
</head>
<body>
<table class="statsTable">
<tr class="statsTable">
<td class="gameTitle" colspan="${gameData.turnNb + 9}">$gameData.name</td>
</tr>
<tr class="statsTable">
<td class="colHeader">Player</td>
#for $turn in $gameData.turns.keys
<td class="colHeader">$turn</td>
#end for
<td class="colHeader">Sub-total</td>
<td class="colHeader">S</td>
<td class="colHeader">W</td>
<td class="colHeader">P</td>
<td class="colHeader">Total</td>
<td class="colHeader">Diff</td>
<td class="colHeader">Game %</td>
<td class="colHeader">Ranking</td>
</tr>
<tr class="statsTable">
<td class="master playerName">Master</td>
#for $turn in $gameData.turns.values
<td class="master">$turn.move.points</td>
#end for
<td class="master">$gameData.totalScore</td>
<td class="master"></td>
<td class="master"></td>
<td class="master"></td>
<td class="master">$gameData.totalScore</td>
<td class="master"></td>
<td class="master">100%</td>
<td class="master"></td>
</tr>
#for $player in $gameData.players
<tr class="statsTable">
<td class="playerName">$player.name</td>
#for $turn in $player.turns.values
#if $gameData.isSolo($turn.num, $turn.move)
#if $gameData.isTop($turn.num, $turn.move)
<td class="topSolo">$turn.move.points</td>
#else
<td class="subTopSolo">$turn.move.points</td>
#end if
#else
#if $gameData.isTop($turn.num, $turn.move)
<td class="top">$turn.move.points</td>
#elif $gameData.isSubTop($turn.num, $turn.move)
<td class="subTop">$turn.move.points</td>
#elif $player.id % 2 == 1
<td class="normal1">$turn.move.points</td>
#else
<td class="normal2">$turn.move.points</td>
#end if
#end if
#end for
#if $player.id % 2 == 1
<td class="normal1">$player.rawScore</td>
<td class="normal1">$player.solosPoints</td>
<td class="normal1">$player.warningsNb</td>
<td class="normal1">$player.penaltiesPoints</td>
<td class="normal1">$player.totalScore</td>
<td class="normal1">$player.diffWithTop</td>
<td class="normal1">$player.percentTop</td>
<td class="normal1">$player.rank</td>
#else
<td class="normal2">$player.rawScore</td>
<td class="normal2">$player.solosPoints</td>
<td class="normal2">$player.warningsNb</td>
<td class="normal2">$player.penaltiesPoints</td>
<td class="normal2">$player.totalScore</td>
<td class="normal2">$player.diffWithTop</td>
<td class="normal2">$player.percentTop</td>
<td class="normal2">$player.rank</td>
#end if
</tr>
#end for
</table>
<table class="legendTable">
<tr>
<td class="legendIcon topSolo"></td>
<td class="legendText">Solo with maximal score</td>
</tr>
<tr>
<td class="legendIcon subTopSolo"></td>
<td class="legendText">Solo with non-maximal score</td>
</tr>
<tr>
<td class="legendIcon top"></td>
<td class="legendText">Move with maximal score (not a solo)</td>
</tr>
<tr>
<td class="legendIcon subTop"></td>
<td class="legendText">Move with non-maximal score (not a solo)</td>
</tr>
</table>
</body>
</html>