commit
4f1fbabca1
|
@ -11,7 +11,7 @@ import lila.analyse.{ Analysis, TimeChart, AdvantageChart, Accuracy }
|
||||||
import lila.api.Context
|
import lila.api.Context
|
||||||
import lila.app._
|
import lila.app._
|
||||||
import lila.common.HTTPRequest
|
import lila.common.HTTPRequest
|
||||||
import lila.evaluation.GameResults
|
import lila.evaluation.GameAssessments
|
||||||
import lila.game.{ Pov, Game => GameModel, GameRepo, PgnDump }
|
import lila.game.{ Pov, Game => GameModel, GameRepo, PgnDump }
|
||||||
import lila.hub.actorApi.map.Tell
|
import lila.hub.actorApi.map.Tell
|
||||||
import lila.round.actorApi.AnalysisAvailable
|
import lila.round.actorApi.AnalysisAvailable
|
||||||
|
@ -66,8 +66,8 @@ object Analyse extends LilaController {
|
||||||
if (HTTPRequest.isBot(ctx.req)) divider.empty
|
if (HTTPRequest.isBot(ctx.req)) divider.empty
|
||||||
else divider(pov.game, initialFen)
|
else divider(pov.game, initialFen)
|
||||||
val pgn = Env.game.pgnDump(pov.game, initialFen)
|
val pgn = Env.game.pgnDump(pov.game, initialFen)
|
||||||
val assessResults = if (isGranted(_.MarkEngine)) Env.mod.assessApi.getResultsByGameId(pov.game.id)
|
val assessResults = if (isGranted(_.MarkEngine)) Env.mod.assessApi.getGameResultsById(pov.game.id)
|
||||||
else fuccess(GameResults(None, None))
|
else fuccess(GameAssessments(None, None))
|
||||||
assessResults flatMap {
|
assessResults flatMap {
|
||||||
results =>
|
results =>
|
||||||
Env.api.roundApi.watcher(pov, lila.api.Mobile.Api.currentVersion,
|
Env.api.roundApi.watcher(pov, lila.api.Mobile.Api.currentVersion,
|
||||||
|
|
|
@ -86,28 +86,6 @@ object Mod extends LilaController {
|
||||||
|
|
||||||
def redirect(username: String, mod: Boolean = true) = Redirect(routes.User.show(username).url + mod.??("?mod"))
|
def redirect(username: String, mod: Boolean = true) = Redirect(routes.User.show(username).url + mod.??("?mod"))
|
||||||
|
|
||||||
def assessGame(id: String, side: String) = SecureBody(_.MarkEngine) { implicit ctx =>
|
|
||||||
me =>
|
|
||||||
import play.api.data.Forms._
|
|
||||||
import play.api.data._
|
|
||||||
implicit def req = ctx.body
|
|
||||||
|
|
||||||
Form(single("assessment" -> number(min = 1, max = 5))).bindFromRequest.fold(
|
|
||||||
err => fuccess(BadRequest),
|
|
||||||
assessment => {
|
|
||||||
val color: Color = Color(side == "white")
|
|
||||||
|
|
||||||
assessApi.createPlayerAssessment(PlayerAssessment(
|
|
||||||
_id = id + "/" + color.name,
|
|
||||||
gameId = id,
|
|
||||||
white = (color == Color.White),
|
|
||||||
assessment = assessment,
|
|
||||||
by = me.id,
|
|
||||||
date = DateTime.now)).void
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
def refreshAssess = SecureBody(_.MarkEngine) { implicit ctx =>
|
def refreshAssess = SecureBody(_.MarkEngine) { implicit ctx =>
|
||||||
me =>
|
me =>
|
||||||
import play.api.data.Forms._
|
import play.api.data.Forms._
|
||||||
|
|
|
@ -12,7 +12,7 @@ import lila.rating.PerfType
|
||||||
import lila.security.Permission
|
import lila.security.Permission
|
||||||
import lila.user.tube.userTube
|
import lila.user.tube.userTube
|
||||||
import lila.user.{ User => UserModel, UserRepo }
|
import lila.user.{ User => UserModel, UserRepo }
|
||||||
import lila.evaluation.PlayerAggregateAssessment
|
import lila.evaluation.{PlayerAggregateAssessment}
|
||||||
import views._
|
import views._
|
||||||
|
|
||||||
object User extends LilaController {
|
object User extends LilaController {
|
||||||
|
@ -147,7 +147,7 @@ object User extends LilaController {
|
||||||
|
|
||||||
def mod(username: String) = Secure(_.UserSpy) { implicit ctx =>
|
def mod(username: String) = Secure(_.UserSpy) { implicit ctx =>
|
||||||
me => OptionFuOk(UserRepo named username) { user =>
|
me => OptionFuOk(UserRepo named username) { user =>
|
||||||
(Env.evaluation.evaluator find user) zip (Env.security userSpy user.id) zip (Env.mod.assessApi.getResultsByUserId(user.id, 25)) map {
|
(Env.evaluation.evaluator find user) zip (Env.security userSpy user.id) zip (Env.mod.assessApi.getPlayerAssessmentsByUserId(user.id, 25)) map {
|
||||||
case ((eval, spy), gameResults) => html.user.mod(user, spy, eval, PlayerAggregateAssessment(gameResults))
|
case ((eval, spy), gameResults) => html.user.mod(user, spy, eval, PlayerAggregateAssessment(gameResults))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
@import lila.evaluation.GameResults
|
@import lila.evaluation.GameAssessments
|
||||||
|
|
||||||
@(pov: Pov, data: play.api.libs.json.JsObject, pgn: String, analysis: Option[lila.analyse.Analysis], advantageChart: Option[String], tour: Option[lila.tournament.Tournament], timeChart: lila.analyse.TimeChart, cross: Option[lila.game.Crosstable], userTv: Option[User], division: chess.Division, results: GameResults)(implicit ctx: Context)
|
@(pov: Pov, data: play.api.libs.json.JsObject, pgn: String, analysis: Option[lila.analyse.Analysis], advantageChart: Option[String], tour: Option[lila.tournament.Tournament], timeChart: lila.analyse.TimeChart, cross: Option[lila.game.Crosstable], userTv: Option[User], division: chess.Division, results: GameAssessments)(implicit ctx: Context)
|
||||||
|
|
||||||
@import pov._
|
@import pov._
|
||||||
|
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
@import lila.evaluation.GameResults
|
@import lila.evaluation.{ GameAssessments, Display }
|
||||||
|
|
||||||
@(game: Game, results: GameResults)(implicit ctx: Context)
|
@(game: Game, results: GameAssessments)(implicit ctx: Context)
|
||||||
<br />
|
<br />
|
||||||
@if(isGranted(_.MarkEngine) && game.analysable && !game.isCorrespondence && game.turns >= 40) {
|
@if(isGranted(_.MarkEngine) && game.analysable && !game.isCorrespondence && game.turns >= 40) {
|
||||||
@game.players.map { p =>
|
@game.players.map { p =>
|
||||||
|
@ -9,30 +9,17 @@
|
||||||
@playerLink(p, cssClass = s"is color-icon ${p.color.name}".some, withOnline = false, mod = true)
|
@playerLink(p, cssClass = s"is color-icon ${p.color.name}".some, withOnline = false, mod = true)
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td>
|
|
||||||
<select id="@(p.color.name)Assessment" data-val="@(results.color(p.color).fold(0){ _.bestMatch.assessment })">
|
|
||||||
<option value="0"></option>
|
|
||||||
<option value="1">Not cheating</option>
|
|
||||||
<option value="2">Unlikely cheating</option>
|
|
||||||
<option value="3">Unclear</option>
|
|
||||||
<option value="4">Likely cheating</option>
|
|
||||||
<option value="5">Cheating</option>
|
|
||||||
</select>
|
|
||||||
</td>
|
|
||||||
@results.color(p.color).fold{
|
@results.color(p.color).fold{
|
||||||
<td class="noMatch">None</td>
|
<td class="noMatch">None</td>
|
||||||
}{ result =>
|
}{ result =>
|
||||||
<td class=@(if(result.bestMatch.positiveMatch) "match" else "partial")>
|
<td class="match">
|
||||||
<a href="@routes.Round.watcher(result.bestMatch.gameId, result.bestMatch.color.name)">
|
@Display.assessmentString(result.assessment)
|
||||||
@(result.bestMatch.matchPercentage)%
|
|
||||||
</a>
|
|
||||||
</td>
|
</td>
|
||||||
}
|
}
|
||||||
</tr>
|
</tr>
|
||||||
</table>
|
</table>
|
||||||
}
|
}
|
||||||
<div class="assessmentButtons">
|
<div class="assessmentButtons">
|
||||||
<a class="button" id="confirmAssessment">Confirm</a>
|
|
||||||
<a class="button" id="refreshAssessment">Refresh</a>
|
<a class="button" id="refreshAssessment">Refresh</a>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|
|
@ -82,7 +82,7 @@
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
@if(aggregateAssessment.gameGroupResults.nonEmpty) {
|
@if(aggregateAssessment.playerAssessments.nonEmpty) {
|
||||||
<div class="evaluation results">
|
<div class="evaluation results">
|
||||||
<table class="reportCard">
|
<table class="reportCard">
|
||||||
<thead>
|
<thead>
|
||||||
|
@ -140,47 +140,21 @@
|
||||||
<th>Blurs</th>
|
<th>Blurs</th>
|
||||||
<th>Bot</th>
|
<th>Bot</th>
|
||||||
<th><span class="hint--top-left" data-hint="Aggregate match">Σ</span></th>
|
<th><span class="hint--top-left" data-hint="Aggregate match">Σ</span></th>
|
||||||
<th><span class="hint--top-left" data-hint="Primary match">1</span></th>
|
|
||||||
<th><span class="hint--top-left" data-hint="Secondary match">2</span></th>
|
|
||||||
<th><span class="hint--top-left" data-hint="Tertiary match">3</span></th>
|
|
||||||
<th><span class="hint--top-left" data-hint="Quaternary match">4</span></th>
|
|
||||||
<th><span class="hint--top-left" data-hint="Quinary match">5</span></th>
|
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
@aggregateAssessment.gameGroupResults.map { result =>
|
@aggregateAssessment.playerAssessments.map { result =>
|
||||||
<tr>
|
<tr>
|
||||||
<td><a href="@routes.Round.watcher(result.gameId, result.color.name)">@routes.Round.watcher(result.gameId, result.color.name)</a></td>
|
<td><a href="@routes.Round.watcher(result.gameId, result.color.name)">@routes.Round.watcher(result.gameId, result.color.name)</a></td>
|
||||||
<td>@result.sfAvg ± @result.sfSd</td>
|
<td>@result.sfAvg ± @result.sfSd</td>
|
||||||
<td>@(result.mtAvg/10) ± @(result.mtSd/10)</td>
|
<td>@(result.mtAvg/10) ± @(result.mtSd/10)</td>
|
||||||
<td>@(result.blur)%</td>
|
<td>@(result.blurs)%</td>
|
||||||
<td>@if(result.hold){Yes} else {No}</td>
|
<td>@if(result.hold){Yes} else {No}</td>
|
||||||
<td>
|
<td>
|
||||||
<span class="aggregate hint--top-left" data-hint="@result.aggregate.explanation">
|
<span class="aggregate hint--top-left">
|
||||||
<span class="sig_@(result.aggregate.assessment)" data-icon="@Display.dataIcon(result.aggregate)"></span>
|
<span class="sig_@(result.assessment)" data-icon="J"></span>
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td>
|
|
||||||
<a href="@routes.Round.watcher(result.bestMatch.gameId, result.bestMatch.color.name)" class="peerLink hint--top-left" data-hint="@result.bestMatch.explanation">
|
|
||||||
<span class="sig_@(result.bestMatch.assessment)" data-icon="@Display.dataIcon(result.bestMatch)"> </span>
|
|
||||||
<br /><span class="percentage">@(result.bestMatch.matchPercentage)%</span>
|
|
||||||
</a>
|
|
||||||
</td>
|
|
||||||
@Range(0, 4).map { i =>
|
|
||||||
@result.secondaryMatches.lift(i) match {
|
|
||||||
case Some(peer) => {
|
|
||||||
<td>
|
|
||||||
<a href="@routes.Round.watcher(peer.gameId, peer.color.name)" class="peerLink hint--top-left" data-hint="@peer.explanation">
|
|
||||||
<span class="sig_@(peer.assessment)" data-icon="@Display.dataIcon(peer)"></span>
|
|
||||||
<br /><span class="percentage">@(peer.matchPercentage)%</span>
|
|
||||||
</a>
|
|
||||||
</td>
|
|
||||||
}
|
|
||||||
case _ => {
|
|
||||||
<td></td>
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</tr>
|
</tr>
|
||||||
}
|
}
|
||||||
</tbody>
|
</tbody>
|
||||||
|
|
|
@ -210,7 +210,6 @@ POST /mod/:username/title controllers.Mod.setTitle(username: String
|
||||||
GET /mod/:username/communication controllers.Mod.communication(username: String)
|
GET /mod/:username/communication controllers.Mod.communication(username: String)
|
||||||
POST /mod/:ip/ipban controllers.Mod.ipban(ip: String)
|
POST /mod/:ip/ipban controllers.Mod.ipban(ip: String)
|
||||||
GET /mod/log controllers.Mod.log
|
GET /mod/log controllers.Mod.log
|
||||||
POST /mod/$gameId<\w{8}>/$color<white|black>/assess controllers.Mod.assessGame(gameId: String, color: String)
|
|
||||||
POST /mod/refreshAssess controllers.Mod.refreshAssess
|
POST /mod/refreshAssess controllers.Mod.refreshAssess
|
||||||
|
|
||||||
# Wiki
|
# Wiki
|
||||||
|
|
240
modules/evaluation/src/main/GameAssessment.scala
Normal file
240
modules/evaluation/src/main/GameAssessment.scala
Normal file
|
@ -0,0 +1,240 @@
|
||||||
|
package lila.evaluation
|
||||||
|
|
||||||
|
import chess.{ Color }
|
||||||
|
import lila.game.{ Pov, Game }
|
||||||
|
import lila.analyse.{ Accuracy, Analysis }
|
||||||
|
import Math.{ pow, abs, sqrt, E, exp }
|
||||||
|
import org.joda.time.DateTime
|
||||||
|
import scalaz.NonEmptyList
|
||||||
|
|
||||||
|
case class PlayerAssessment(
|
||||||
|
_id: String,
|
||||||
|
gameId: String,
|
||||||
|
userId: String,
|
||||||
|
white: Boolean,
|
||||||
|
assessment: Int, // 1 = Not Cheating, 2 = Unlikely Cheating, 3 = Unknown, 4 = Likely Cheating, 5 = Cheating
|
||||||
|
date: DateTime,
|
||||||
|
// meta
|
||||||
|
sfAvg: Int,
|
||||||
|
sfSd: Int,
|
||||||
|
mtAvg: Int,
|
||||||
|
mtSd: Int,
|
||||||
|
blurs: Int,
|
||||||
|
hold: Boolean
|
||||||
|
) {
|
||||||
|
val color = Color.apply(white)
|
||||||
|
}
|
||||||
|
|
||||||
|
case class PlayerAggregateAssessment(playerAssessments: List[PlayerAssessment]) {
|
||||||
|
import Statistics._
|
||||||
|
|
||||||
|
def countAssessmentValue(assessment: Int) = listSum(playerAssessments map {
|
||||||
|
case a if (a.assessment == assessment) => 1
|
||||||
|
case _ => 0
|
||||||
|
})
|
||||||
|
|
||||||
|
val cheatingSum = countAssessmentValue(5)
|
||||||
|
val likelyCheatingSum = countAssessmentValue(4)
|
||||||
|
|
||||||
|
val markPri = countAssessmentValue(5) >= 2
|
||||||
|
val markSec = countAssessmentValue(5) + countAssessmentValue(4) >= 4
|
||||||
|
|
||||||
|
val reportPri = countAssessmentValue(5) >= 1
|
||||||
|
val reportSec = countAssessmentValue(5) + countAssessmentValue(4) >= 2
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
case class GameAssessments(
|
||||||
|
white: Option[PlayerAssessment],
|
||||||
|
black: Option[PlayerAssessment]) {
|
||||||
|
def color(c: Color) = c match {
|
||||||
|
case Color.White => white
|
||||||
|
case _ => black
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
case class Analysed(game: Game, analysis: Analysis)
|
||||||
|
|
||||||
|
case class Assessible(analysed: Analysed) {
|
||||||
|
import Statistics._
|
||||||
|
|
||||||
|
def moveTimes(color: Color): List[Int] =
|
||||||
|
skip(this.analysed.game.moveTimes.toList, {if (color == Color.White) 0 else 1})
|
||||||
|
|
||||||
|
def suspiciousErrorRate(color: Color): Boolean =
|
||||||
|
listAverage(Accuracy.diffsList(Pov(this.analysed.game, color), this.analysed.analysis)) < 15
|
||||||
|
|
||||||
|
def alwaysHasAdvantage(color: Color): Boolean = {
|
||||||
|
def chartFromDifs(difs: List[(Int, Int)], sum: Int = 0): List[Int] =
|
||||||
|
difs match {
|
||||||
|
case Nil => Nil
|
||||||
|
case a :: Nil => List((sum + a._1), (sum + a._1 + a._2))
|
||||||
|
case a :: b => List((sum + a._1), (sum + a._1 + a._2)) ::: chartFromDifs(b, sum + a._1 + a._2)
|
||||||
|
}
|
||||||
|
|
||||||
|
!chartFromDifs(Accuracy.diffsList(Pov(this.analysed.game, color), this.analysed.analysis) zip
|
||||||
|
Accuracy.diffsList(Pov(this.analysed.game, !color), this.analysed.analysis)).exists(_ < -50)
|
||||||
|
}
|
||||||
|
|
||||||
|
def highBlurRate(color: Color): Boolean =
|
||||||
|
this.analysed.game.playerBlurPercent(color) > 90
|
||||||
|
|
||||||
|
def moderateBlurRate(color: Color): Boolean =
|
||||||
|
this.analysed.game.playerBlurPercent(color) > 70
|
||||||
|
|
||||||
|
def consistentMoveTimes(color: Color): Boolean =
|
||||||
|
moveTimes(color).toNel.map(coefVariation).fold(false)(_ < 0.8)
|
||||||
|
|
||||||
|
def noFastMoves(color: Color): Boolean = !moveTimes(color).exists(_ < 10)
|
||||||
|
|
||||||
|
def suspiciousHoldAlert(color: Color): Boolean =
|
||||||
|
this.analysed.game.player(color).hasSuspiciousHoldAlert
|
||||||
|
|
||||||
|
def rankCheating(color: Color): Int =
|
||||||
|
((
|
||||||
|
suspiciousErrorRate(color),
|
||||||
|
alwaysHasAdvantage(color),
|
||||||
|
highBlurRate(color),
|
||||||
|
moderateBlurRate(color),
|
||||||
|
consistentMoveTimes(color),
|
||||||
|
noFastMoves(color),
|
||||||
|
suspiciousHoldAlert(color)
|
||||||
|
) match {
|
||||||
|
// SF1 SF2 BLR1 BLR2 MTs1 MTs2 Holds
|
||||||
|
case (true, true, true, true, true, true, true) => 5 // all true, obvious cheat
|
||||||
|
case (true, _, _, _, _, true, true) => 5 // high accuracy, no fast moves, hold alerts
|
||||||
|
case (true, _, true, _, _, true, _) => 5 // high accuracy, high blurs, no fast moves
|
||||||
|
case (true, _, _, _, true, true, _) => 5 // high accuracy, consistent move times, no fast moves
|
||||||
|
case (_, true, _, _, _, _, true) => 4 // always has advantage, hold alerts
|
||||||
|
case (_, true, true, _, _, _, _) => 4 // always has advantage, high blurs
|
||||||
|
case (true, _, _, false, false, true, _) => 3 // high accuracy, no fast moves, but doesn't blur or flat line
|
||||||
|
case (true, _, _, _, _, false, _) => 2 // high accuracy, but has fast moves
|
||||||
|
case (false, false, _, _, _, _, _) => 1 // low accuracy, doesn't hold advantage
|
||||||
|
case (false, false, false, false, false, false, false) => 1 // all false, obviously not cheating
|
||||||
|
case _ => 1
|
||||||
|
}).min(this.analysed.game.wonBy(color) match {
|
||||||
|
case Some(c) if (c) => 5
|
||||||
|
case _ => 3
|
||||||
|
})
|
||||||
|
|
||||||
|
def sfAvg(color: Color): Int = listAverage(Accuracy.diffsList(Pov(this.analysed.game, color), this.analysed.analysis)).toInt
|
||||||
|
def sfSd(color: Color): Int = listDeviation(Accuracy.diffsList(Pov(this.analysed.game, color), this.analysed.analysis)).toInt
|
||||||
|
def mtAvg(color: Color): Int = listAverage(skip(this.analysed.game.moveTimes.toList, {if (color == Color.White) 0 else 1})).toInt
|
||||||
|
def mtSd(color: Color): Int = listDeviation(skip(this.analysed.game.moveTimes.toList, {if (color == Color.White) 0 else 1})).toInt
|
||||||
|
def blurs(color: Color): Int = this.analysed.game.playerBlurPercent(color)
|
||||||
|
def hold(color: Color): Boolean = this.analysed.game.player(color).hasSuspiciousHoldAlert
|
||||||
|
|
||||||
|
def playerAssessment(color: Color): PlayerAssessment = PlayerAssessment(
|
||||||
|
_id = this.analysed.game.id + "/" + color.name,
|
||||||
|
gameId = this.analysed.game.id,
|
||||||
|
userId = this.analysed.game.player(color).userId.getOrElse(""),
|
||||||
|
white = (color == Color.White),
|
||||||
|
assessment = rankCheating(color),
|
||||||
|
date = DateTime.now,
|
||||||
|
// meta
|
||||||
|
sfAvg = sfAvg(color),
|
||||||
|
sfSd = sfSd(color),
|
||||||
|
mtAvg = mtAvg(color),
|
||||||
|
mtSd = mtSd(color),
|
||||||
|
blurs = blurs(color),
|
||||||
|
hold = hold(color)
|
||||||
|
)
|
||||||
|
|
||||||
|
val assessments: GameAssessments = GameAssessments(
|
||||||
|
white = Some(playerAssessment(Color.White)),
|
||||||
|
black = Some(playerAssessment(Color.Black)))
|
||||||
|
}
|
||||||
|
|
||||||
|
object Display {
|
||||||
|
def assessmentString(x: Int): String =
|
||||||
|
x match {
|
||||||
|
case 1 => "Not cheating"
|
||||||
|
case 2 => "Unlikely cheating"
|
||||||
|
case 3 => "Unclear"
|
||||||
|
case 4 => "Likely cheating"
|
||||||
|
case 5 => "Cheating"
|
||||||
|
case 6 => "Undef"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
object Statistics {
|
||||||
|
import Erf._
|
||||||
|
import scala.annotation._
|
||||||
|
|
||||||
|
def variance[T](a: NonEmptyList[T], optionalAvg: Option[T] = None)(implicit n: Numeric[T]): Double = {
|
||||||
|
val avg: Double = optionalAvg.fold(average(a)){n.toDouble}
|
||||||
|
|
||||||
|
a.map( i => pow(n.toDouble(i) - avg, 2)).list.sum / a.length
|
||||||
|
}
|
||||||
|
|
||||||
|
def deviation[T](a: NonEmptyList[T], optionalAvg: Option[T] = None)(implicit n: Numeric[T]): Double = sqrt(variance(a, optionalAvg))
|
||||||
|
|
||||||
|
def average[T](a: NonEmptyList[T])(implicit n: Numeric[T]): Double = {
|
||||||
|
@tailrec def average(a: List[T], sum: T = n.zero, depth: Int = 0): Double = {
|
||||||
|
a match {
|
||||||
|
case List() => n.toDouble(sum) / depth
|
||||||
|
case x :: xs => average(xs, n.plus(sum, x), depth + 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
average(a.list)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Coefficient of Variance
|
||||||
|
def coefVariation(a: NonEmptyList[Int]): Double = sqrt(variance(a)) / average(a)
|
||||||
|
|
||||||
|
def intervalToVariance4(interval: Double): Double = pow(interval / 3, 8) // roughly speaking
|
||||||
|
|
||||||
|
// Accumulative probability function for normal distributions
|
||||||
|
def cdf[T](x: T, avg: T, sd: T)(implicit n: Numeric[T]): Double =
|
||||||
|
0.5 * (1 + erf(n.toDouble(n.minus(x, avg)) / (n.toDouble(sd)*sqrt(2))))
|
||||||
|
|
||||||
|
// The probability that you are outside of abs(x-n) from the mean on both sides
|
||||||
|
def confInterval[T](x: T, avg: T, sd: T)(implicit n: Numeric[T]): Double =
|
||||||
|
1 - cdf(n.abs(x), avg, sd) + cdf(n.times(n.fromInt(-1), n.abs(x)), avg, sd)
|
||||||
|
|
||||||
|
def skip[A](l: List[A], n: Int) =
|
||||||
|
l.zipWithIndex.collect {case (e,i) if ((i+n) % 2) == 0 => e} // (i+1) because zipWithIndex is 0-based
|
||||||
|
|
||||||
|
def listSum(xs: List[Int]): Int = xs match {
|
||||||
|
case Nil => 0
|
||||||
|
case x :: tail => x + listSum(tail)
|
||||||
|
}
|
||||||
|
|
||||||
|
def listSum(xs: List[Double]): Double = xs match {
|
||||||
|
case Nil => 0
|
||||||
|
case x :: tail => x + listSum(tail)
|
||||||
|
}
|
||||||
|
|
||||||
|
def listAverage[T](x: List[T])(implicit n: Numeric[T]): Double = x match {
|
||||||
|
case Nil => 0
|
||||||
|
case a :: Nil => n.toDouble(a)
|
||||||
|
case a :: b => average(NonEmptyList.nel(a, b))
|
||||||
|
}
|
||||||
|
|
||||||
|
def listDeviation[T](x: List[T])(implicit n: Numeric[T]): Double = x match {
|
||||||
|
case Nil => 0
|
||||||
|
case _ :: Nil => 0
|
||||||
|
case a :: b => deviation(NonEmptyList.nel(a, b))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
object Erf {
|
||||||
|
// constants
|
||||||
|
val a1: Double = 0.254829592
|
||||||
|
val a2: Double = -0.284496736
|
||||||
|
val a3: Double = 1.421413741
|
||||||
|
val a4: Double = -1.453152027
|
||||||
|
val a5: Double = 1.061405429
|
||||||
|
val p: Double = 0.3275911
|
||||||
|
|
||||||
|
def erf(x: Double): Double = {
|
||||||
|
// Save the sign of x
|
||||||
|
val sign = if (x < 0) -1 else 1
|
||||||
|
val absx = abs(x)
|
||||||
|
|
||||||
|
// A&S formula 7.1.26, rational approximation of error function
|
||||||
|
val t = 1.0/(1.0 + p*absx);
|
||||||
|
val y = 1.0 - (((((a5*t + a4)*t) + a3)*t + a2)*t + a1)*t*exp(-x*x);
|
||||||
|
sign*y
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,356 +0,0 @@
|
||||||
package lila.evaluation
|
|
||||||
|
|
||||||
import Math.{pow, E, PI, log, sqrt, abs, exp, round}
|
|
||||||
import org.joda.time.DateTime
|
|
||||||
import scalaz.NonEmptyList
|
|
||||||
import chess.{ Color }
|
|
||||||
import lila.game.{ Pov, Game }
|
|
||||||
import lila.analyse.{ Accuracy, Analysis }
|
|
||||||
|
|
||||||
case class PlayerAssessment(
|
|
||||||
_id: String,
|
|
||||||
gameId: String,
|
|
||||||
white: Boolean, // Side of the game being analysed
|
|
||||||
assessment: Int, // 1 = Not Cheating, 2 = Unlikely Cheating, 3 = Unknown, 4 = Likely Cheating, 5 = Cheating
|
|
||||||
by: String, // moderator ID
|
|
||||||
date: DateTime) {
|
|
||||||
val color = Color(white)
|
|
||||||
}
|
|
||||||
|
|
||||||
case class PeerGame(
|
|
||||||
gameId: String,
|
|
||||||
white: Boolean,
|
|
||||||
positiveMatch: Boolean,
|
|
||||||
matchPercentage: Int,
|
|
||||||
assessment: Int
|
|
||||||
) {
|
|
||||||
val color = Color(white)
|
|
||||||
|
|
||||||
val explanation: String =
|
|
||||||
(if (positiveMatch) "Matches " else "Partially matches ") + "with " +
|
|
||||||
(assessment match {
|
|
||||||
case 1 => "a non cheating"
|
|
||||||
case 2 => "an unlikely cheating"
|
|
||||||
case 3 => "an unclear"
|
|
||||||
case 4 => "a likely cheating"
|
|
||||||
case 5 => "a cheating"
|
|
||||||
case _ => "Undefined"
|
|
||||||
}) + " game at " + matchPercentage + "% confidence"
|
|
||||||
}
|
|
||||||
|
|
||||||
case class PlayerAggregateAssessment(
|
|
||||||
gameGroupResults: List[GameGroupResult]
|
|
||||||
) {
|
|
||||||
import Statistics.listSum
|
|
||||||
def sumAssessment(x: Int): Int =
|
|
||||||
listSum(gameGroupResults.map { result =>
|
|
||||||
if (result.aggregate.assessment == x && (result.aggregate.positiveMatch || result.aggregate.confidence >= 80)) 1
|
|
||||||
else 0
|
|
||||||
})
|
|
||||||
|
|
||||||
val cheatingSum: Int = sumAssessment(5)
|
|
||||||
val likelyCheatingSum: Int = sumAssessment(4)
|
|
||||||
val unclearSum: Int = sumAssessment(3)
|
|
||||||
|
|
||||||
val markPri: Boolean = cheatingSum >= 2
|
|
||||||
val markSec: Boolean = cheatingSum + likelyCheatingSum >= 4
|
|
||||||
val reportPri: Boolean = cheatingSum >= 1
|
|
||||||
val reportSec: Boolean = cheatingSum + likelyCheatingSum >= 2
|
|
||||||
}
|
|
||||||
|
|
||||||
case class AggregateAssessment(
|
|
||||||
assessment: Int,
|
|
||||||
confidence: Int,
|
|
||||||
positiveMatch: Boolean
|
|
||||||
) {
|
|
||||||
val explanation: String =
|
|
||||||
(if (positiveMatch) "Matches " else "Partially matches ") +
|
|
||||||
(assessment match {
|
|
||||||
case 1 => "Not Cheating"
|
|
||||||
case 2 => "Unlikely Cheating"
|
|
||||||
case 3 => "Unclear"
|
|
||||||
case 4 => "Likely Cheating"
|
|
||||||
case 5 => "Cheating"
|
|
||||||
case _ => "Undefined"
|
|
||||||
}) + " at " + confidence + "% confidence"
|
|
||||||
}
|
|
||||||
|
|
||||||
case class GameGroupResult(
|
|
||||||
_id: String, // sourceGameId + "/" + sourceGameColor
|
|
||||||
userId: String, // The userId of the player being evaluated
|
|
||||||
gameId: String, // The game being talked about
|
|
||||||
white: Boolean, // The side of the game being talked about
|
|
||||||
bestMatch: PeerGame,
|
|
||||||
secondaryMatches: List[PeerGame],
|
|
||||||
date: DateTime,
|
|
||||||
// Meta infos
|
|
||||||
sfAvg: Int,
|
|
||||||
sfSd: Int,
|
|
||||||
mtAvg: Int,
|
|
||||||
mtSd: Int,
|
|
||||||
blur: Int,
|
|
||||||
hold: Boolean
|
|
||||||
) {
|
|
||||||
import Statistics.{listSum, listAverage}
|
|
||||||
val color = Color(white)
|
|
||||||
val aggregate: AggregateAssessment = {
|
|
||||||
val peers = bestMatch :: secondaryMatches
|
|
||||||
AggregateAssessment(
|
|
||||||
round(listSum(peers.map {
|
|
||||||
case a if (a.positiveMatch) => 4 * a.matchPercentage * a.assessment
|
|
||||||
case a => a.matchPercentage * a.assessment
|
|
||||||
}).toDouble / listSum(peers.map {
|
|
||||||
case a if (a.positiveMatch) => 4 * a.matchPercentage
|
|
||||||
case a => a.matchPercentage
|
|
||||||
})).toInt,
|
|
||||||
bestMatch.matchPercentage,
|
|
||||||
peers.exists(_.positiveMatch)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
object Display {
|
|
||||||
def assessmentString(x: Int): String =
|
|
||||||
x match {
|
|
||||||
case 1 => "Not Cheating" // Not cheating
|
|
||||||
case 2 => "Unlikely Cheating" // Unlikely Cheating
|
|
||||||
case 3 => "Unclear" // Unclear
|
|
||||||
case 4 => "Likely Cheating" // Likely Cheating
|
|
||||||
case 5 => "Cheating" // Cheating
|
|
||||||
case _ => "Undef"
|
|
||||||
}
|
|
||||||
|
|
||||||
def dataIcon(positiveMatch: Boolean, confidence: Int): String = {
|
|
||||||
(positiveMatch, confidence) match {
|
|
||||||
case (true, _) => "J"
|
|
||||||
case (_, a) if (a >= 80) => "l"
|
|
||||||
case _ => "K"
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
def dataIcon(x: AggregateAssessment): String = dataIcon(x.positiveMatch, x.confidence)
|
|
||||||
def dataIcon(x: PeerGame): String = dataIcon(x.positiveMatch, x.matchPercentage)
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
case class GameResults(
|
|
||||||
white: Option[GameGroupResult],
|
|
||||||
black: Option[GameGroupResult]
|
|
||||||
) {
|
|
||||||
|
|
||||||
def color(c: Color): Option[GameGroupResult] = c.fold(white, black)
|
|
||||||
}
|
|
||||||
|
|
||||||
case class Rating(perf: Int, interval: Int)
|
|
||||||
|
|
||||||
case class Similarity(a: Double, threshold: Double = 0.9) {
|
|
||||||
def apply: Double = a.min(1).max(0)
|
|
||||||
|
|
||||||
val matches: Boolean = this.apply >= threshold
|
|
||||||
}
|
|
||||||
|
|
||||||
case class Analysed(game: Game, analysis: Analysis)
|
|
||||||
|
|
||||||
case class MatchAndSig(matches: Boolean, significance: Double)
|
|
||||||
|
|
||||||
case class GameGroup(analysed: Analysed, color: Color, assessment: Option[Int] = None) {
|
|
||||||
import Statistics._
|
|
||||||
|
|
||||||
def compareMoveTimes (that: GameGroup): Similarity = {
|
|
||||||
val thisMt: List[Int] = skip(this.analysed.game.moveTimes.toList, {if (this.color == Color.White) 0 else 1})
|
|
||||||
val thatMt: List[Int] = skip(that.analysed.game.moveTimes.toList, {if (that.color == Color.White) 0 else 1})
|
|
||||||
|
|
||||||
listToListSimilarity(thisMt, thatMt, 0.8)
|
|
||||||
}
|
|
||||||
|
|
||||||
def compareSfAccuracies (that: GameGroup): Similarity = {
|
|
||||||
def chartDifs(difs: List[(Int, Int)], sum: Int = 0): List[Int] = {
|
|
||||||
difs match {
|
|
||||||
case Nil => Nil
|
|
||||||
case a :: Nil => List((sum + a._1), (sum + a._1 + a._2))
|
|
||||||
case a :: b => List((sum + a._1), (sum + a._1 + a._2)) ::: chartDifs(b, sum + a._1 + a._2)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
def averageDif(chart: List[(Int, Int)]): Double = listAverage(chart.map{a => abs(a._1 - a._2)})
|
|
||||||
|
|
||||||
if (abs(this.analysed.game.playedTurns - that.analysed.game.playedTurns) <= 5) {
|
|
||||||
val avgDif = averageDif(
|
|
||||||
(
|
|
||||||
chartDifs(Accuracy.diffsList(Pov(this.analysed.game, this.color), this.analysed.analysis) zip
|
|
||||||
Accuracy.diffsList(Pov(this.analysed.game, !this.color), this.analysed.analysis))
|
|
||||||
) zip (
|
|
||||||
chartDifs(Accuracy.diffsList(Pov(that.analysed.game, that.color), that.analysed.analysis) zip
|
|
||||||
Accuracy.diffsList(Pov(that.analysed.game, !that.color), that.analysed.analysis))
|
|
||||||
))
|
|
||||||
Similarity(((300d - avgDif) / 300d).max(0d), 0.67)
|
|
||||||
} else Similarity(0)
|
|
||||||
}
|
|
||||||
|
|
||||||
def compareBlurRates (that: GameGroup): Similarity = pointToPointSimilarity(
|
|
||||||
(200 * this.analysed.game.player(this.color).blurs / this.analysed.game.turns).toInt,
|
|
||||||
(200 * that.analysed.game.player(that.color).blurs / that.analysed.game.turns).toInt,
|
|
||||||
5d
|
|
||||||
)
|
|
||||||
|
|
||||||
def compareHoldAlerts (that: GameGroup): Similarity = Similarity(
|
|
||||||
if (this.analysed.game.player(this.color).hasSuspiciousHoldAlert == that.analysed.game.player(that.color).hasSuspiciousHoldAlert) 1 else 0,
|
|
||||||
0.9
|
|
||||||
)
|
|
||||||
|
|
||||||
val sfAvg: Int = listAverage(Accuracy.diffsList(Pov(this.analysed.game, this.color), this.analysed.analysis)).toInt
|
|
||||||
val sfSd: Int = listDeviation(Accuracy.diffsList(Pov(this.analysed.game, this.color), this.analysed.analysis)).toInt
|
|
||||||
val mtAvg: Int = listAverage(skip(this.analysed.game.moveTimes.toList, {if (this.color == Color.White) 0 else 1})).toInt
|
|
||||||
val mtSd: Int = listDeviation(skip(this.analysed.game.moveTimes.toList, {if (this.color == Color.White) 0 else 1})).toInt
|
|
||||||
val blurs: Int = (200 * this.analysed.game.player(this.color).blurs / this.analysed.game.turns).toInt;
|
|
||||||
val hold: Boolean = this.analysed.game.player(this.color).hasSuspiciousHoldAlert
|
|
||||||
|
|
||||||
def similarityTo (that: GameGroup): MatchAndSig = {
|
|
||||||
// Calls compare functions to determine how similar `this` and `that` are to each other
|
|
||||||
val sfComparison = compareSfAccuracies(that)
|
|
||||||
|
|
||||||
val similarities = NonEmptyList(
|
|
||||||
compareMoveTimes(that),
|
|
||||||
compareSfAccuracies(that),
|
|
||||||
compareBlurRates(that),
|
|
||||||
compareHoldAlerts(that)
|
|
||||||
)
|
|
||||||
|
|
||||||
MatchAndSig(
|
|
||||||
allSimilar(similarities), // Are they all similar?
|
|
||||||
ssd(similarities) // How significant is the similarity?
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
object Statistics {
|
|
||||||
import Erf._
|
|
||||||
import scala.annotation._
|
|
||||||
|
|
||||||
def variance[T](a: NonEmptyList[T], optionalAvg: Option[T] = None)(implicit n: Numeric[T]): Double = {
|
|
||||||
val avg: Double = optionalAvg.fold(average(a)){n.toDouble}
|
|
||||||
|
|
||||||
a.map( i => pow(n.toDouble(i) - avg, 2)).list.sum / a.length
|
|
||||||
}
|
|
||||||
|
|
||||||
def deviation[T](a: NonEmptyList[T], optionalAvg: Option[T] = None)(implicit n: Numeric[T]): Double = sqrt(variance(a, optionalAvg))
|
|
||||||
|
|
||||||
def average[T](a: NonEmptyList[T])(implicit n: Numeric[T]): Double = {
|
|
||||||
@tailrec def average(a: List[T], sum: T = n.zero, depth: Int = 0): Double = {
|
|
||||||
a match {
|
|
||||||
case List() => n.toDouble(sum) / depth
|
|
||||||
case x :: xs => average(xs, n.plus(sum, x), depth + 1)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
average(a.list)
|
|
||||||
}
|
|
||||||
|
|
||||||
def setToSetSimilarity(avgA: Double, avgB: Double, varA: Double, varB: Double, threshold: Double): Similarity = {
|
|
||||||
val sim = Similarity(
|
|
||||||
pow(E, (-0.25) * ( log( 0.25 * ((varA / varB) + (varB / varA) + 2) ) + pow(avgA - avgB, 2) / ( varA + varB ) )),
|
|
||||||
threshold)
|
|
||||||
|
|
||||||
if (sim.a.isNaN || sim.a.isInfinity) Similarity(1, threshold)
|
|
||||||
else sim
|
|
||||||
}
|
|
||||||
|
|
||||||
// Bhattacharyya Coefficient
|
|
||||||
def setToSetSimilarity[T](a: NonEmptyList[T], b: NonEmptyList[T], threshold: Double = 0.9)(implicit n: Numeric[T]): Similarity = {
|
|
||||||
val aDouble: NonEmptyList[Double] = a.map(n.toDouble)
|
|
||||||
val bDouble: NonEmptyList[Double] = b.map(n.toDouble)
|
|
||||||
|
|
||||||
val avgA = average(a)
|
|
||||||
val avgB = average(b)
|
|
||||||
|
|
||||||
val varA = pow(variance(aDouble, Some(avgA)), 2)
|
|
||||||
val varB = pow(variance(bDouble, Some(avgB)), 2)
|
|
||||||
|
|
||||||
setToSetSimilarity(avgA, avgB, varA, varB, threshold)
|
|
||||||
}
|
|
||||||
|
|
||||||
def listToListSimilarity[T](x: List[T], y: List[T], threshold: Double = 0.9)(implicit n: Numeric[T]): Similarity =
|
|
||||||
(x, y) match {
|
|
||||||
case (Nil, Nil) => Similarity(1) // Both empty
|
|
||||||
case (Nil, _ :: _) => Similarity(0) // One empty, The other with some
|
|
||||||
case (_ :: _, Nil) => Similarity(0)
|
|
||||||
case (a :: Nil, b :: Nil) => pointToPointSimilarity(a, b, 5d) // Both have one
|
|
||||||
case (a :: Nil, b :: c) => pointToSetSimilarity(a, NonEmptyList.nel(b, c)) // One with one element, the other with many
|
|
||||||
case (a :: b, c :: Nil) => pointToSetSimilarity(c, NonEmptyList.nel(a, b))
|
|
||||||
case (a :: b, c :: d) => setToSetSimilarity(NonEmptyList.nel(a, b), NonEmptyList.nel(c, d), threshold) // Both have many
|
|
||||||
}
|
|
||||||
|
|
||||||
def pointToSetSimilarity[T](x: T, set: NonEmptyList[T])(implicit n: Numeric[T]): Similarity = Similarity(
|
|
||||||
confInterval(n.toDouble(x), average(set), sqrt(variance(set))),
|
|
||||||
0.9
|
|
||||||
)
|
|
||||||
|
|
||||||
def pointToPointSimilarity[T](a: T, b: T, variance: Double)(implicit n: Numeric[T]): Similarity = Similarity(
|
|
||||||
(a, b) match {
|
|
||||||
case (a, b) if (a == b || n.toDouble(n.abs(n.minus(a, b))) < variance) => 1
|
|
||||||
case _ => 0
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
// Coefficient of Variance
|
|
||||||
def coefVariation(a: NonEmptyList[Int]): Double = sqrt(variance(a)) / average(a)
|
|
||||||
|
|
||||||
def intervalToVariance4(interval: Double): Double = pow(interval / 3, 8) // roughly speaking
|
|
||||||
|
|
||||||
// Accumulative probability function for normal distributions
|
|
||||||
def cdf[T](x: T, avg: T, sd: T)(implicit n: Numeric[T]): Double =
|
|
||||||
0.5 * (1 + erf(n.toDouble(n.minus(x, avg)) / (n.toDouble(sd)*sqrt(2))))
|
|
||||||
|
|
||||||
// The probability that you are outside of abs(x-n) from the mean on both sides
|
|
||||||
def confInterval[T](x: T, avg: T, sd: T)(implicit n: Numeric[T]): Double =
|
|
||||||
1 - cdf(n.abs(x), avg, sd) + cdf(n.times(n.fromInt(-1), n.abs(x)), avg, sd)
|
|
||||||
|
|
||||||
// all Similarities in the non empty list are similar
|
|
||||||
def allSimilar(a: NonEmptyList[Similarity]): Boolean = a.list.forall( _.matches )
|
|
||||||
def allSimilar(a: Similarity, b: List[Similarity]): Boolean = allSimilar(NonEmptyList.nel(a, b))
|
|
||||||
|
|
||||||
// Square Sum Distance
|
|
||||||
def ssd(a: NonEmptyList[Similarity]): Double = sqrt(a.map(x => pow(x.apply, 2)).list.sum / a.size)
|
|
||||||
def ssd(a: Similarity, b: List[Similarity]): Double = ssd(NonEmptyList.nel(a, b))
|
|
||||||
|
|
||||||
def skip[A](l: List[A], n: Int) =
|
|
||||||
l.zipWithIndex.collect {case (e,i) if ((i+n) % 2) == 0 => e} // (i+1) because zipWithIndex is 0-based
|
|
||||||
|
|
||||||
|
|
||||||
def listSum(xs: List[Int]): Int = xs match {
|
|
||||||
case Nil => 0
|
|
||||||
case x :: tail => x + listSum(tail)
|
|
||||||
}
|
|
||||||
|
|
||||||
def listAverage[T](x: List[T])(implicit n: Numeric[T]): Double = x match {
|
|
||||||
case Nil => 0
|
|
||||||
case a :: Nil => n.toDouble(a)
|
|
||||||
case a :: b => average(NonEmptyList.nel(a, b))
|
|
||||||
}
|
|
||||||
|
|
||||||
def listDeviation[T](x: List[T])(implicit n: Numeric[T]): Double = x match {
|
|
||||||
case Nil => 0
|
|
||||||
case _ :: Nil => 0
|
|
||||||
case a :: b => deviation(NonEmptyList.nel(a, b))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
object Erf {
|
|
||||||
// constants
|
|
||||||
val a1: Double = 0.254829592
|
|
||||||
val a2: Double = -0.284496736
|
|
||||||
val a3: Double = 1.421413741
|
|
||||||
val a4: Double = -1.453152027
|
|
||||||
val a5: Double = 1.061405429
|
|
||||||
val p: Double = 0.3275911
|
|
||||||
|
|
||||||
def erf(x: Double): Double = {
|
|
||||||
// Save the sign of x
|
|
||||||
val sign = if (x < 0) -1 else 1
|
|
||||||
val absx = abs(x)
|
|
||||||
|
|
||||||
// A&S formula 7.1.26, rational approximation of error function
|
|
||||||
val t = 1.0/(1.0 + p*absx);
|
|
||||||
val y = 1.0 - (((((a5*t + a4)*t) + a3)*t + a2)*t + a1)*t*exp(-x*x);
|
|
||||||
sign*y
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -3,7 +3,7 @@ package lila.mod
|
||||||
import lila.analyse.{ Analysis, AnalysisRepo }
|
import lila.analyse.{ Analysis, AnalysisRepo }
|
||||||
import lila.db.Types.Coll
|
import lila.db.Types.Coll
|
||||||
import lila.db.BSON.BSONJodaDateTimeHandler
|
import lila.db.BSON.BSONJodaDateTimeHandler
|
||||||
import lila.evaluation.{ PlayerAssessment, GameGroupResult, GameResults, GameGroup, Analysed, PeerGame, MatchAndSig }
|
import lila.evaluation.{ Analysed, PlayerAssessment, GameAssessments, Assessible }
|
||||||
import lila.game.Game
|
import lila.game.Game
|
||||||
import lila.game.{ Game, GameRepo }
|
import lila.game.{ Game, GameRepo }
|
||||||
import org.joda.time.DateTime
|
import org.joda.time.DateTime
|
||||||
|
@ -13,39 +13,32 @@ import scala.concurrent._
|
||||||
import chess.Color
|
import chess.Color
|
||||||
|
|
||||||
|
|
||||||
final class AssessApi(collRef: Coll, collRes: Coll, logApi: ModlogApi) {
|
final class AssessApi(collAssessments: Coll, logApi: ModlogApi) {
|
||||||
|
|
||||||
private implicit val bestMatchBSONhandler = Macros.handler[PeerGame]
|
|
||||||
private implicit val playerAssessmentBSONhandler = Macros.handler[PlayerAssessment]
|
private implicit val playerAssessmentBSONhandler = Macros.handler[PlayerAssessment]
|
||||||
private implicit val gameGroupResultBSONhandler = Macros.handler[GameGroupResult]
|
|
||||||
|
|
||||||
def createPlayerAssessment(assessed: PlayerAssessment) =
|
def createPlayerAssessment(assessed: PlayerAssessment) =
|
||||||
collRef.update(BSONDocument("_id" -> assessed._id), assessed, upsert = true) >>
|
collAssessments.update(BSONDocument("_id" -> assessed._id), assessed, upsert = true).void
|
||||||
logApi.assessGame(assessed.by, assessed.gameId, assessed.color.name, assessed.assessment) >>
|
|
||||||
refreshAssess(assessed.gameId)
|
|
||||||
|
|
||||||
def createResult(result: GameGroupResult) =
|
def getPlayerAssessmentById(id: String) =
|
||||||
collRes.update(BSONDocument("_id" -> result._id), result, upsert = true).void
|
collAssessments.find(BSONDocument("_id" -> id))
|
||||||
|
|
||||||
def getPlayerAssessments(max: Int): Fu[List[PlayerAssessment]] = collRef.find(BSONDocument())
|
|
||||||
.cursor[PlayerAssessment]
|
|
||||||
.collect[List](max)
|
|
||||||
|
|
||||||
def getPlayerAssessmentById(id: String) = collRef.find(BSONDocument("_id" -> id))
|
|
||||||
.one[PlayerAssessment]
|
.one[PlayerAssessment]
|
||||||
|
|
||||||
def getResultsByUserId(userId: String, nb: Int = 100) = collRes.find(BSONDocument("userId" -> userId))
|
def getPlayerAssessmentsByUserId(userId: String, nb: Int = 100) =
|
||||||
.sort(BSONDocument("bestMatch.assessment" -> -1, "bestMatch.positiveMatch" -> -1, "bestMatch.matchPercentage" -> -1))
|
collAssessments.find(BSONDocument("userId" -> userId))
|
||||||
.cursor[GameGroupResult]
|
.sort(BSONDocument("assessment" -> -1))
|
||||||
|
.cursor[PlayerAssessment]
|
||||||
.collect[List](nb)
|
.collect[List](nb)
|
||||||
|
|
||||||
def getResultsByGameIdAndColor(gameId: String, color: Color) = collRes.find(BSONDocument("_id" -> (gameId + "/" + color.name)))
|
def getResultsByGameIdAndColor(gameId: String, color: Color) =
|
||||||
.one[GameGroupResult]
|
collAssessments.find(BSONDocument("_id" -> (gameId + "/" + color.name)))
|
||||||
|
.one[PlayerAssessment]
|
||||||
|
|
||||||
def getResultsByGameId(gameId: String): Fu[GameResults] =
|
def getGameResultsById(gameId: String) =
|
||||||
getResultsByGameIdAndColor(gameId, Color.White) zip
|
getResultsByGameIdAndColor(gameId, Color.White) zip
|
||||||
getResultsByGameIdAndColor(gameId, Color.Black) map
|
getResultsByGameIdAndColor(gameId, Color.Black) map {
|
||||||
GameResults.tupled
|
a => GameAssessments(a._1, a._2)
|
||||||
|
}
|
||||||
|
|
||||||
def refreshAssess(gameId: String): Funit =
|
def refreshAssess(gameId: String): Funit =
|
||||||
GameRepo.game(gameId) zip
|
GameRepo.game(gameId) zip
|
||||||
|
@ -55,62 +48,10 @@ final class AssessApi(collRef: Coll, collRes: Coll, logApi: ModlogApi) {
|
||||||
}
|
}
|
||||||
|
|
||||||
def onAnalysisReady(game: Game, analysis: Analysis): Funit = {
|
def onAnalysisReady(game: Game, analysis: Analysis): Funit = {
|
||||||
def playerAssessmentGameGroups: Fu[List[GameGroup]] =
|
|
||||||
getPlayerAssessments(400) flatMap { assessments =>
|
|
||||||
GameRepo.gameOptions(assessments.map(_.gameId)) flatMap { games =>
|
|
||||||
AnalysisRepo.doneByIds(assessments.map(_.gameId)) map { analyses =>
|
|
||||||
assessments zip games zip analyses flatMap {
|
|
||||||
case ((assessment, Some(game)), Some(analysis)) =>
|
|
||||||
Some(GameGroup(Analysed(game, analysis), assessment.color, Some(assessment.assessment)))
|
|
||||||
case _ => None
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
def buildGameGroupResult(source: GameGroup, assessments: List[GameGroup], nb: Int = 5): Option[GameGroupResult] =
|
|
||||||
(assessments.map(source.similarityTo) zip assessments).sortBy(-_._1.significance).take(nb).map {
|
|
||||||
case (matchAndSig: MatchAndSig, gameGroup: GameGroup) =>
|
|
||||||
PeerGame(
|
|
||||||
gameId = gameGroup.analysed.game.id,
|
|
||||||
white = gameGroup.color.white,
|
|
||||||
positiveMatch = matchAndSig.matches,
|
|
||||||
matchPercentage = (100 * matchAndSig.significance).toInt,
|
|
||||||
assessment = gameGroup.assessment.getOrElse(1).min(source.analysed.game.wonBy(source.color) match {
|
|
||||||
case Some(color) if (color) => 5
|
|
||||||
case _ => 3
|
|
||||||
})
|
|
||||||
)
|
|
||||||
} match {
|
|
||||||
case a :: b =>
|
|
||||||
Some(GameGroupResult(
|
|
||||||
_id = source.analysed.game.id + "/" + source.color.name,
|
|
||||||
userId = source.analysed.game.player(source.color).userId.getOrElse(""),
|
|
||||||
gameId = source.analysed.game.id,
|
|
||||||
white = source.color.white,
|
|
||||||
bestMatch = a,
|
|
||||||
secondaryMatches = b,
|
|
||||||
date = DateTime.now,
|
|
||||||
sfAvg = source.sfAvg,
|
|
||||||
sfSd = source.sfSd,
|
|
||||||
mtAvg = source.mtAvg,
|
|
||||||
mtSd = source.mtSd,
|
|
||||||
blur = source.blurs,
|
|
||||||
hold = source.hold
|
|
||||||
))
|
|
||||||
case _ => None
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!game.isCorrespondence && game.turns >= 40 && game.mode.rated) {
|
if (!game.isCorrespondence && game.turns >= 40 && game.mode.rated) {
|
||||||
val whiteGameGroup = GameGroup(Analysed(game, analysis), Color.White)
|
val gameAssessments: GameAssessments = Assessible(Analysed(game, analysis)).assessments
|
||||||
val blackGameGroup = GameGroup(Analysed(game, analysis), Color.Black)
|
gameAssessments.white.fold(funit){createPlayerAssessment} >>
|
||||||
|
gameAssessments.black.fold(funit){createPlayerAssessment}
|
||||||
playerAssessmentGameGroups flatMap {
|
|
||||||
a => {
|
|
||||||
buildGameGroupResult(whiteGameGroup, a).fold(funit){createResult} >>
|
|
||||||
buildGameGroupResult(blackGameGroup, a).fold(funit){createResult}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else funit
|
} else funit
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -13,8 +13,7 @@ final class Env(
|
||||||
firewall: Firewall,
|
firewall: Firewall,
|
||||||
userSpy: String => Fu[UserSpy]) {
|
userSpy: String => Fu[UserSpy]) {
|
||||||
|
|
||||||
private val CollectionPlayerAssessment = config getString "collection.crossref"
|
private val CollectionPlayerAssessment= config getString "collection.player_assessment"
|
||||||
private val CollectionResult = config getString "collection.result"
|
|
||||||
private val CollectionBoosting = config getString "collection.boosting"
|
private val CollectionBoosting = config getString "collection.boosting"
|
||||||
private val CollectionModlog = config getString "collection.modlog"
|
private val CollectionModlog = config getString "collection.modlog"
|
||||||
private val ActorName = config getString "actor.name"
|
private val ActorName = config getString "actor.name"
|
||||||
|
@ -24,7 +23,7 @@ final class Env(
|
||||||
|
|
||||||
lazy val logApi = new ModlogApi
|
lazy val logApi = new ModlogApi
|
||||||
|
|
||||||
lazy val assessApi = new AssessApi(db(CollectionPlayerAssessment), db(CollectionResult), logApi)
|
lazy val assessApi = new AssessApi(db(CollectionPlayerAssessment), logApi)
|
||||||
|
|
||||||
lazy val api = new ModApi(
|
lazy val api = new ModApi(
|
||||||
logApi = logApi,
|
logApi = logApi,
|
||||||
|
|
|
@ -2032,38 +2032,12 @@ lichess.storage = {
|
||||||
});
|
});
|
||||||
|
|
||||||
if ($('#refreshAssessment').length) {
|
if ($('#refreshAssessment').length) {
|
||||||
$('#whiteAssessment').val($('#whiteAssessment').data('val'));
|
|
||||||
$('#blackAssessment').val($('#blackAssessment').data('val'));
|
|
||||||
|
|
||||||
var sendAssessment = function(side, e) {
|
|
||||||
if ($(e.target).val() != "0") {
|
|
||||||
$.post('/mod/' + data.game.id + '/' + side + '/assess', {
|
|
||||||
assessment: $(e.target).val()
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
$('#whiteAssessment').change(sendAssessment.bind(null, 'white'));
|
|
||||||
$('#blackAssessment').change(sendAssessment.bind(null, 'black'));
|
|
||||||
|
|
||||||
$('#refreshAssessment').click(function() {
|
$('#refreshAssessment').click(function() {
|
||||||
$.post('/mod/refreshAssess', {
|
$.post('/mod/refreshAssess', {
|
||||||
assess: data.game.id,
|
assess: data.game.id,
|
||||||
success: function(){ setTimeout(function(){ location.reload(); }, 3000) }
|
success: function(){ setTimeout(function(){ location.reload(); }, 3000) }
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
$('#confirmAssessment').click(function() {
|
|
||||||
if ($('#whiteAssessment').val() != "0") {
|
|
||||||
$.post('/mod/' + data.game.id + '/white/assess', {
|
|
||||||
assessment: $('#whiteAssessment').val()
|
|
||||||
});
|
|
||||||
}
|
|
||||||
if ($('#blackAssessment').val() != "0") {
|
|
||||||
$.post('/mod/' + data.game.id + '/black/assess', {
|
|
||||||
assessment: $('#blackAssessment').val()
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -3,7 +3,6 @@ body {
|
||||||
font: 12px 'Lucida Grande', 'Lucida Sans Unicode', Geneva, Verdana, Sans-Serif;
|
font: 12px 'Lucida Grande', 'Lucida Sans Unicode', Geneva, Verdana, Sans-Serif;
|
||||||
color: #909090;
|
color: #909090;
|
||||||
background: #05121b; /* url("bluegrid.jpg") center repeat; */
|
background: #05121b; /* url("bluegrid.jpg") center repeat; */
|
||||||
margin-top: 50px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
h1 {
|
h1 {
|
||||||
|
|
Loading…
Reference in a new issue