coach stats wip

pull/742/head
Thibault Duplessis 2015-07-19 00:20:06 +02:00
parent 7a4b3b57a1
commit 2f72b1cb1d
12 changed files with 385 additions and 44 deletions

View File

@ -3,11 +3,120 @@
@user.layout(title = s"${u.username} stats") {
<div class="content_box no_padding">
<h1>@userLink(u, withOnline = false) stats</h1>
@stat
@if(stat.fold(true)(!_.isFresh)) {
<form method="post" action="@routes.Coach.refresh(u.username)">
<button type="submit">Refresh @u.username stats</button>
</form>
}
@stat.map { s =>
<table class="slist">
<thead>
<tr>
<th colspan=2>Favourite openings</th>
</tr>
</thead>
<tbody>
<tr>
@chess.Color.all.map { color =>
<td>
<table>
<thead>
<tr>
<th colspan=2>As @color.name</th>
</tr>
</thead>
<tbody>
@s.openings(color).toList.sortBy(-_._2).take(10).map {
case (code, nb) => {
<tr>
<td>@code</td>
<td>@nb.localize</td>
</tr>
}
}
</tbody>
</table>
</td>
}
</tr>
</tbody>
</table>
@defining(s.results) { r =>
<table class="slist">
<thead>
<tr>
<th colspan=2>Results over all rated games</th>
</tr>
</thead>
<tbody>
<tr>
<th>Games</th>
<td>@r.nbGames.localize</td>
</tr>
<tr>
<th>Analysed games</th>
<td>@r.nbAnalysis.localize</td>
</tr>
<tr>
<th>Wins</th>
<td>@r.nbWin.localize</td>
</tr>
<tr>
<th>Losses</th>
<td>@r.nbLoss.localize</td>
</tr>
<tr>
<th>Draws</th>
<td>@r.nbDraw.localize</td>
</tr>
<tr>
<th>Rating diff</th>
<td>@showProgress(r.ratingDiff)</td>
</tr>
<tr>
<th>Best Rating</th>
<td>@r.bestRating.map { br =>
<strong>@br.rating</strong> after <a href="@routes.Round.watcher(br.id, "white")">playing</a> @userIdLink(br.userId.some)
}</td>
</tr>
<tr>
<th>Best Win</th>
<td>@r.bestWin.map { bw =>
<a href="@routes.Round.watcher(bw.id, "white")">@userIdSpanMini(bw.userId) <strong>@bw.rating</strong></a>
}</td>
</tr>
<tr>
<th>Opponent average rating</th>
<td>@r.opponentRatingAvg</td>
</tr>
<tr>
<th>Win streak</th>
<td>@r.winStreak.best (current: @r.winStreak.cur)</td>
</tr>
@List("Win" -> r.outcomeStatuses.win, "Loss" -> r.outcomeStatuses.loss).map {
case (name, statuses) => {
<tr>
<th>@name statuses</th>
<td>
<table>
<tbody>
@statuses.m.toList.sortBy(-_._2).map {
case (status, nb) => {
<tr>
<th>@status.name</th>
<td>@nb.localize</td>
</tr>
}
}
</tbody>
</table>
</td>
</tr>
}
}
</tbody>
</table>
}
}
</div>
}

View File

@ -0,0 +1,40 @@
package lila.coach
import reactivemongo.bson._
import reactivemongo.bson.Macros
import lila.db.BSON._
import lila.db.Implicits._
import lila.rating.PerfType
private[coach] object BSONHandlers {
import UserStat.PerfResults
import Results.{ OutcomeStatuses, StatusScores, BestWin, BestRating, Streak }
private implicit val intMapHandler = MapValue.MapHandler[Int]
private implicit val StatusScoresBSONHandler = new BSONHandler[BSONDocument, StatusScores] {
def read(doc: BSONDocument): StatusScores = StatusScores {
intMapHandler read doc mapKeys { k =>
parseIntOption(k) flatMap chess.Status.apply
} collect { case (Some(k), v) => k -> v }
}
def write(x: StatusScores) = intMapHandler write x.m.mapKeys(_.id.toString)
}
implicit val ResultsStreakBSONHandler = Macros.handler[Streak]
implicit val ResultsOutcomeStatusesBSONHandler = Macros.handler[OutcomeStatuses]
implicit val OpeningsBSONHandler = Macros.handler[Openings]
implicit val ResultsBestWinBSONHandler = Macros.handler[BestWin]
implicit val ResultsBestRatingBSONHandler = Macros.handler[BestRating]
implicit val ResultsBSONHandler = Macros.handler[Results]
private implicit val resultsMapHandler = Map.MapHandler[Results]
private implicit val PerfResultsBSONHandler = new BSONHandler[BSONDocument, PerfResults] {
def read(doc: BSONDocument): PerfResults = PerfResults {
resultsMapHandler read doc mapKeys PerfType.apply collect { case (Some(k), v) => k -> v }
}
def write(x: PerfResults) = resultsMapHandler write x.m.mapKeys(_.key)
}
implicit val UserStatBSONHandler = Macros.handler[UserStat]
}

View File

@ -0,0 +1,18 @@
package lila.coach
case class Openings(
white: Map[String, Int],
black: Map[String, Int]) {
def apply(c: chess.Color) = c.fold(white, black)
def aggregate(p: lila.game.Pov) = p.game.opening.map(_.code).fold(this) { code =>
copy(
white = if (p.color.white) openingWithCode(white, code) else white,
black = if (p.color.black) openingWithCode(black, code) else black)
}
private def openingWithCode(opening: Map[String, Int], code: String) =
opening + (code -> opening.get(code).fold(1)(1+))
}

View File

@ -0,0 +1,114 @@
package lila.coach
import chess.Status
import lila.analyse.Analysis
import lila.game.Pov
import org.joda.time.DateTime
case class Results(
nbGames: Int,
nbAnalysis: Int,
nbWin: Int,
nbLoss: Int,
nbDraw: Int,
ratingDiff: Int,
plySum: Int,
acplSum: Int,
bestWin: Option[Results.BestWin],
bestRating: Option[Results.BestRating],
opponentRatingSum: Int,
winStreak: Results.Streak, // nb games won in a row
awakeMinutesStreak: Results.Streak, // minutes played without sleeping
dayStreak: Results.Streak, // days played in a row
outcomeStatuses: Results.OutcomeStatuses) {
def plyAvg = plySum / nbGames
def acplAvg = acplSum / nbAnalysis
def opponentRatingAvg = opponentRatingSum / nbGames
def aggregate(pov: Pov, analysis: Option[Analysis]) = copy(
nbGames = nbGames + 1,
nbAnalysis = nbAnalysis + analysis.isDefined.fold(1, 0),
nbWin = nbWin + (~pov.win).fold(1, 0),
nbLoss = nbLoss + (~pov.loss).fold(1, 0),
nbDraw = nbDraw + pov.game.draw.fold(1, 0),
ratingDiff = ratingDiff + ~pov.player.ratingDiff,
plySum = plySum + pov.game.turns,
acplSum = acplSum + ~analysis.flatMap { lila.analyse.Accuracy(pov, _) },
bestWin = if (~pov.win) {
Results.makeBestWin(pov).fold(bestWin) { newBest =>
bestWin.fold(newBest) { prev =>
if (newBest.rating > prev.rating) newBest else prev
}.some
}
}
else bestWin,
bestRating = if (~pov.win) {
Results.makeBestRating(pov).fold(bestRating) { newBest =>
bestRating.fold(newBest) { prev =>
if (newBest.rating > prev.rating) newBest else prev
}.some
}
}
else bestRating,
opponentRatingSum = opponentRatingSum + ~pov.opponent.rating,
outcomeStatuses = outcomeStatuses.aggregate(pov))
}
object Results {
val emptyStreak = Streak(0, 0)
val emptyOutcomeStatuses = OutcomeStatuses(StatusScores(Map.empty), StatusScores(Map.empty))
val empty = Results(0, 0, 0, 0, 0, 0, 0, 0, none, none, 0, emptyStreak, emptyStreak, emptyStreak, emptyOutcomeStatuses)
case class BestWin(id: String, userId: String, rating: Int)
def makeBestWin(pov: Pov): Option[BestWin] = pov.opponent.userId |@| pov.opponent.rating apply {
case (opId, opRating) => BestWin(pov.gameId, opId, opRating)
}
case class BestRating(id: String, userId: String, rating: Int)
def makeBestRating(pov: Pov): Option[BestRating] = pov.opponent.userId |@| pov.player.ratingAfter apply {
case (opId, myRating) => BestRating(pov.gameId, opId, myRating)
}
case class OutcomeStatuses(win: StatusScores, loss: StatusScores) {
def aggregate(pov: Pov) = copy(
win = if (~pov.win) win add pov.game.status else win,
loss = if (~pov.loss) loss add pov.game.status else loss)
}
case class StatusScores(m: Map[Status, Int]) {
def add(s: Status) = copy(m = m + (s -> m.get(s).fold(1)(1+)))
}
case class Streak(cur: Int, best: Int) {
def add(v: Int) = copy(cur = cur + v, best = best max (cur + v))
def reset = copy(cur = 0)
def set(v: Int) = copy(cur = v)
}
case class Computation(
results: Results,
previousWin: Boolean = false,
previousEndDate: Option[DateTime] = None) {
def aggregate(pov: Pov, analysis: Option[Analysis]) = copy(
results = results.aggregate(pov, analysis).copy(
winStreak = if (~pov.win) {
if (previousWin) results.winStreak.add(1)
else results.winStreak.set(1)
}
else results.winStreak.reset,
awakeMinutesStreak = results.awakeMinutesStreak,
dayStreak = (previousEndDate |@| pov.game.updatedAt) apply {
case (prev, next) if prev.getDayOfYear == next.getDayOfYear => results.dayStreak
case (prev, next) if next.minusDays(1).isBefore(prev) => results.dayStreak.add(1)
} getOrElse results.dayStreak.reset
),
previousWin = ~pov.win,
previousEndDate = pov.game.updatedAt)
def run = results
}
val emptyComputation = Computation(empty)
}

View File

@ -14,33 +14,32 @@ import lila.user.UserRepo
final class StatApi(coll: Coll) {
private implicit val openingsHandler = MapValue.MapHandler[Int]
import UserStat.Openings
private implicit val UserStatOpeningsBSONHandler = Macros.handler[Openings]
private implicit val UserStatBSONHandler = Macros.handler[UserStat]
import BSONHandlers._
private def selectId(id: String) = BSONDocument("_id" -> id)
def fetch(id: String): Fu[Option[UserStat]] = coll.find(selectId(id)).one[UserStat]
def computeIfOld(id: String): Fu[Option[UserStat]] = fetch(id) flatMap {
case Some(stat) if stat.isFresh => fuccess(stat.some)
def computeIfOld(id: String): Fu[UserStat] = fetch(id) flatMap {
case Some(stat) if stat.isFresh => fuccess(stat)
case _ => compute(id)
}
private def compute(id: String): Fu[Option[UserStat]] = {
private def compute(id: String): Fu[UserStat] = {
import lila.game.tube.gameTube
import lila.game.BSONHandlers.gameBSONHandler
import lila.game.Query
pimpQB($query(Query.user(id) ++ Query.rated))
pimpQB($query(Query.user(id) ++ Query.rated ++ Query.finished))
.sort(Query.sortCreated)
.cursor[lila.game.Game]().enumerate(10 * 1000, stopOnError = false) &>
StatApi.withAnalysis |>>>
Iteratee.fold(UserStat(id)) {
case (stat, a) => lila.game.Pov.ofUserId(a.game, id).fold(stat) { stat.withGame(_, a.analysis) }
Iteratee.fold(UserStat.makeComputation(id)) {
case (comp, a) => lila.game.Pov.ofUserId(a.game, id).fold(comp) {
comp.aggregate(_, a.analysis)
}
}
} flatMap { stat =>
(stat.nbGames > 0) ?? (coll.update(selectId(id), stat, upsert = true) inject stat.some)
} map (_.run) flatMap { stat =>
coll.update(selectId(id), stat, upsert = true) inject stat
}
}

View File

@ -2,48 +2,58 @@ package lila.coach
import org.joda.time.DateTime
import lila.analyse.Analysis
import lila.game.Pov
import lila.rating.PerfType
case class UserStat(
_id: String, // user ID
openings: UserStat.Openings,
nbGames: Int,
nbAnalysis: Int,
openings: Openings,
results: Results,
perfResults: UserStat.PerfResults,
date: DateTime) {
def id = _id
def withGame(pov: lila.game.Pov, analysis: Option[lila.analyse.Analysis]) = copy(
nbGames = nbGames + 1,
nbAnalysis = nbAnalysis + analysis.isDefined.fold(1, 0),
openings = openings withGame pov
)
def aggregate(pov: Pov, analysis: Option[lila.analyse.Analysis]) = copy(
openings = openings aggregate pov)
def isFresh = nbGames < 100 || {
def isFresh = results.nbGames < 100 || {
DateTime.now minusDays 1 isBefore date
}
}
object UserStat {
type OpeningMap = Map[String, Int]
case class PerfResults(m: Map[PerfType, Results])
val emptyPerfResults = PerfResults(Map.empty)
case class Openings(
white: OpeningMap,
black: OpeningMap) {
case class Computation(
stat: UserStat,
resultsComp: Results.Computation = Results.emptyComputation,
perfResultsComp: Map[PerfType, Results.Computation] = Map.empty) {
def withGame(p: lila.game.Pov) = p.game.opening.map(_.code).fold(this) { code =>
copy(
white = if (p.color.white) openingWithCode(white, code) else white,
black = if (p.color.black) openingWithCode(black, code) else black)
}
def aggregate(pov: Pov, analysis: Option[Analysis]) = copy(
stat = stat.aggregate(pov, analysis),
resultsComp = resultsComp.aggregate(pov, analysis),
perfResultsComp = pov.game.perfType.fold(perfResultsComp) { perfType =>
perfResultsComp + (
perfType ->
perfResultsComp.get(perfType).|(Results.emptyComputation).aggregate(pov, analysis)
)
}
)
private def openingWithCode(opening: OpeningMap, code: String) =
opening + (code -> opening.get(code).fold(1)(1+))
def run = stat.copy(
results = resultsComp.run,
perfResults = PerfResults(perfResultsComp.mapValues(_.run)))
}
def makeComputation(id: String) = Computation(apply(id))
def apply(id: String): UserStat = UserStat(
_id = id,
openings = Openings(Map.empty, Map.empty),
nbGames = 0,
nbAnalysis = 0,
results = Results.empty,
perfResults = emptyPerfResults,
date = DateTime.now)
}

View File

@ -43,6 +43,13 @@ object BSON {
}
}
}
implicit def MapHandler[V](implicit vr: BSONDocumentReader[V], vw: BSONDocumentWriter[V]): BSONHandler[BSONDocument, Map[String, V]] = new BSONHandler[BSONDocument, Map[String, V]] {
private val reader = MapReader[V]
private val writer = MapWriter[V]
def read(bson: BSONDocument): Map[String, V] = reader read bson
def write(map: Map[String, V]): BSONDocument = writer write map
}
}
object MapValue {
@ -70,6 +77,37 @@ object BSON {
}
}
// object MapKeyValue {
// type K = String
// implicit def MapReader[V](
// implicit kr: BSONReader[_ <: BSONValue, K],
// vr: BSONReader[_ <: BSONValue, V]): BSONDocumentReader[Map[K, V]] = new BSONDocumentReader[Map[K, V]] {
// def read(bson: BSONDocument): Map[K, V] =
// bson.elements.map { tuple =>
// kr.asInstanceOf[BSONReader[BSONValue, K]].read(tuple._1) -> vr.asInstanceOf[BSONReader[BSONValue, V]].read(tuple._2)
// }.toMap
// }
// implicit def MapWriter[V](
// implicit kw: BSONWriter[K, _ <: BSONValue],
// vw: BSONWriter[V, _ <: BSONValue]): BSONDocumentWriter[Map[K, V]] = new BSONDocumentWriter[Map[K, V]] {
// def write(map: Map[K, V]): BSONDocument = BSONDocument {
// map.toStream.map { tuple =>
// kw.write(tuple._1) -> vw.write(tuple._2)
// }
// }
// }
// implicit def MapHandler[V](implicit kr: BSONReader[_ <: BSONValue, K], kw: BSONWriter[K, _ <: BSONValue], vr: BSONReader[_ <: BSONValue, V], vw: BSONWriter[V, _ <: BSONValue]): BSONHandler[BSONDocument, Map[K, V]] = new BSONHandler[BSONDocument, Map[K, V]] {
// private val reader = MapReader[K, V]
// private val writer = MapWriter[K, V]
// def read(bson: BSONDocument): Map[K, V] = reader read bson
// def write(map: Map[K, V]): BSONDocument = writer write map
// }
// }
// List Handler
final class ListHandler[T](implicit reader: BSONReader[_ <: BSONValue, T], writer: BSONWriter[T, _ <: BSONValue]) extends BSONHandler[BSONArray, List[T]] {
def read(array: BSONArray) = array.stream.filter(_.isSuccess).map { v =>

View File

@ -13,6 +13,11 @@ object BSONHandlers {
def write(cc: CheckCount) = BSONArray(cc.white, cc.black)
}
implicit val StatusBSONHandler = new BSONHandler[BSONInteger, Status] {
def read(bsonInt: BSONInteger): Status = Status(bsonInt.value) err s"No such status: ${bsonInt.value}"
def write(x: Status) = BSONInteger(x.id)
}
implicit val gameBSONHandler = new BSON[Game] {
import Game.BSONFields._
@ -40,7 +45,7 @@ object BSONHandlers {
blackPlayer = player(blackPlayer, Black, blackId, blackUid),
binaryPieces = r bytes binaryPieces,
binaryPgn = r bytesD binaryPgn,
status = Status(r int status) err "game invalid status",
status = r.get[Status](status),
turns = nbTurns,
startedAtTurn = r intD startedAtTurn,
clock = r.getO[Color => Clock](clock)(clockBSONHandler(createdAtValue)) map (_(Color(0 == nbTurns % 2))),
@ -77,7 +82,7 @@ object BSONHandlers {
blackPlayer -> w.docO(playerBSONHandler write ((_: Color) => (_: Player.Id) => (_: Player.UserId) => (_: Player.Win) => o.blackPlayer)),
binaryPieces -> o.binaryPieces,
binaryPgn -> w.byteArrayO(o.binaryPgn),
status -> o.status.id,
status -> o.status,
turns -> o.turns,
startedAtTurn -> w.intO(o.startedAtTurn),
clock -> (o.clock map { c => clockBSONHandler(o.createdAt).write(_ => c) }),

View File

@ -346,6 +346,10 @@ case class Game(
def wonBy(c: Color): Option[Boolean] = winnerColor map (_ == c)
def lostBy(c: Color): Option[Boolean] = winnerColor map (_ != c)
def draw = status == Status.Draw
def outoftimePlayer: Option[Player] =
outoftimePlayerClock orElse outoftimePlayerCorrespondence
@ -372,9 +376,10 @@ case class Game(
def withClock(c: Clock) = Progress(this, copy(clock = Some(c)))
def estimateTotalTime =
clock.map(_.estimateTotalTime) orElse
correspondenceClock.map(_.estimateTotalTime) getOrElse 1200
def estimateClockTotalTime = clock.map(_.estimateTotalTime)
def estimateTotalTime = estimateClockTotalTime orElse
correspondenceClock.map(_.estimateTotalTime) getOrElse 1200
def playerWhoDidNotMove: Option[Player] = playedTurns match {
case 0 => player(White).some

View File

@ -83,6 +83,8 @@ case class Player(
case ((None, _), (Some(_), _)) => false
case ((_, a), (_, b)) => a < b
}
def ratingAfter = rating map (_ + ~ratingDiff)
}
object Player {

View File

@ -32,6 +32,10 @@ case class Pov(game: Game, color: Color) {
def hasMoved = game playerHasMoved color
def win = game wonBy color
def loss = game lostBy color
override def toString = ref.toString
}

View File

@ -18,10 +18,7 @@ private[simul] final class SimulRepo(simulColl: Coll) {
def read(bsonInt: BSONInteger): SimulStatus = SimulStatus(bsonInt.value) err s"No such simul status: ${bsonInt.value}"
def write(x: SimulStatus) = BSONInteger(x.id)
}
private implicit val ChessStatusBSONHandler = new BSONHandler[BSONInteger, Status] {
def read(bsonInt: BSONInteger): Status = Status(bsonInt.value) err s"No such chess status: ${bsonInt.value}"
def write(x: Status) = BSONInteger(x.id)
}
private implicit val ChessStatusBSONHandler = lila.game.BSONHandlers.StatusBSONHandler
private implicit val VariantBSONHandler = new BSONHandler[BSONInteger, Variant] {
def read(bsonInt: BSONInteger): Variant = Variant(bsonInt.value) err s"No such variant: ${bsonInt.value}"
def write(x: Variant) = BSONInteger(x.id)