diff --git a/app/views/user/mod.scala.html b/app/views/user/mod.scala.html index 1095358c74..57b8077482 100644 --- a/app/views/user/mod.scala.html +++ b/app/views/user/mod.scala.html @@ -141,6 +141,42 @@ + + + + + + + + + + + + + + + + + +
Average Centipawn Loss Given:
Blurs + @aggregateAssessment.sfAvgBlurs +
high +
+ @aggregateAssessment.sfAvgNoBlurs +
low +
Move Times + @aggregateAssessment.sfAvgLowVar +
low variance +
+ @aggregateAssessment.sfAvgHighVar +
high variance +
Hold Alert + @aggregateAssessment.sfAvgHold +
alert +
+ @aggregateAssessment.sfAvgNoHold +
no alert +
diff --git a/modules/evaluation/src/main/AccountAction.scala b/modules/evaluation/src/main/AccountAction.scala new file mode 100644 index 0000000000..eaebd73abd --- /dev/null +++ b/modules/evaluation/src/main/AccountAction.scala @@ -0,0 +1,26 @@ +package lila.evaluation + +sealed trait AccountAction { + val description: String + val colorClass: String + override def toString = description +} + +object AccountAction { + case object EngineAndBan extends AccountAction { + val description: String = "Mark and IP ban" + val colorClass = "4" + } + case object Engine extends AccountAction { + val description: String = "Mark as engine" + val colorClass = "3" + } + case object Report extends AccountAction { + val description: String = "Report to mods" + val colorClass = "2" + } + case object Nothing extends AccountAction { + val description: String = "Not suspicious" + val colorClass = "1" + } +} diff --git a/modules/evaluation/src/main/Assessible.scala b/modules/evaluation/src/main/Assessible.scala new file mode 100644 index 0000000000..dd97a19e5b --- /dev/null +++ b/modules/evaluation/src/main/Assessible.scala @@ -0,0 +1,103 @@ +package lila.evaluation + +import chess.Color +import org.joda.time.DateTime +import lila.game.{ Game, Pov } +import lila.analyse.{ Accuracy, Analysis } +import Math.signum + +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 = + !analysed.analysis.infos.exists{ info => + info.score.fold(info.mate.fold(false){ a => (signum(a).toInt == color.fold(-1, 1)) }){ cp => + color.fold(cp.centipawns < -100, cp.centipawns > 100)} + } + + 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.5) + + def noFastMoves(color: Color): Boolean = moveTimes(color).count(_ < 10) <= 2 + + def suspiciousHoldAlert(color: Color): Boolean = + this.analysed.game.player(color).hasSuspiciousHoldAlert + + def flags(color: Color): PlayerFlags = PlayerFlags( + suspiciousErrorRate(color), + alwaysHasAdvantage(color), + highBlurRate(color), + moderateBlurRate(color), + consistentMoveTimes(color), + noFastMoves(color), + suspiciousHoldAlert(color) + ) + + def rankCheating(color: Color): Int = + (flags(color) match { + // SF1 SF2 BLR1 BLR2 MTs1 MTs2 Holds + case PlayerFlags(true, true, true, true, true, true, true) => 5 // all true, obvious cheat + case PlayerFlags(true, _, _, _, _, true, true) => 5 // high accuracy, no fast moves, hold alerts + case PlayerFlags(_, true, _, _, _, true, true) => 5 // always has advantage, no fast moves, hold alerts + case PlayerFlags(true, _, true, _, _, true, _) => 5 // high accuracy, high blurs, no fast moves + + case PlayerFlags(true, _, _, _, true, true, _) => 4 // high accuracy, consistent move times, no fast moves + case PlayerFlags(true, _, _, true, _, true, _) => 4 // high accuracy, moderate blurs, no fast moves + case PlayerFlags(_, true, _, true, true, _, _) => 4 // always has advantage, moderate blurs, highly consistent move times + case PlayerFlags(_, true, _, _, _, _, true) => 4 // always has advantage, hold alerts + case PlayerFlags(_, true, true, _, _, _, _) => 4 // always has advantage, high blurs + + case PlayerFlags(true, _, _, false, false, true, _) => 3 // high accuracy, no fast moves, but doesn't blur or flat line + + case PlayerFlags(true, _, _, _, _, false, _) => 2 // high accuracy, but has fast moves + + case PlayerFlags(false, false, _, _, _, _, _) => 1 // low accuracy, doesn't hold advantage + 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 + flags = flags(color), + 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))) +} diff --git a/modules/evaluation/src/main/Display.scala b/modules/evaluation/src/main/Display.scala new file mode 100644 index 0000000000..43850820b9 --- /dev/null +++ b/modules/evaluation/src/main/Display.scala @@ -0,0 +1,47 @@ +package lila.evaluation + +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 _ => "Undef" + } + + def stockfishSig(pa: PlayerAssessment): Int = + (pa.flags.suspiciousErrorRate, pa.flags.alwaysHasAdvantage) match { + case (true, true) => 5 + case (true, false) => 4 + case (false, true) => 3 + case _ => 1 + } + + def moveTimeSig(pa: PlayerAssessment): Int = + (pa.flags.consistentMoveTimes, pa.flags.noFastMoves) match { + case (true, true) => 5 + case (true, false) => 4 + case (false, true) => 3 + case _ => 1 + } + + def blurSig(pa: PlayerAssessment): Int = + (pa.flags.highBlurRate, pa.flags.moderateBlurRate) match { + case (true, _) => 5 + case (_, true) => 4 + case _ => 1 + } + + def holdSig(pa: PlayerAssessment): Int = if (pa.flags.suspiciousHoldAlert) 5 else 1 + + def emoticon(assessment: Int): String = assessment match { + case 5 => ">:(" + case 4 => ":(" + case 3 => ":|" + case 2 => ":)" + case 1 => ":D" + case _ => ":S" + } +} \ No newline at end of file diff --git a/modules/evaluation/src/main/GameAssessment.scala b/modules/evaluation/src/main/GameAssessment.scala deleted file mode 100644 index f0ca79be6d..0000000000 --- a/modules/evaluation/src/main/GameAssessment.scala +++ /dev/null @@ -1,342 +0,0 @@ -package lila.evaluation - -import chess.{ Color } -import lila.game.{ Pov, Game } -import lila.analyse.{ Accuracy, Analysis, Info } -import lila.user.User -import Math.{ pow, abs, sqrt, E, exp, signum } -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 - flags: PlayerFlags, - sfAvg: Int, - sfSd: Int, - mtAvg: Int, - mtSd: Int, - blurs: Int, - hold: Boolean - ) { - val color = Color.apply(white) -} - -sealed trait AccountAction { - val description: String - val colorClass: String - override def toString = description -} - -object AccountAction { - case object EngineAndBan extends AccountAction { - val description: String = "Mark and IP ban" - val colorClass = "4" - } - case object Engine extends AccountAction { - val description: String = "Mark as engine" - val colorClass = "3" - } - case object Report extends AccountAction { - val description: String = "Report to mods" - val colorClass = "2" - } - case object Nothing extends AccountAction { - val description: String = "Not suspicious" - val colorClass = "1" - } -} - -case class PlayerAggregateAssessment(playerAssessments: List[PlayerAssessment], - relatedUsers: List[String], - relatedCheaters: List[String]) { - import Statistics._ - import AccountAction._ - - def action = { - val markable: Boolean = ( - (cheatingSum >= 2 || cheatingSum + likelyCheatingSum >= 4) - // more than 5 percent of games are cheating - && (cheatingSum.toDouble / assessmentsCount >= 0.05 - relationModifier - // or more than 10 percent of games are likely cheating - || (cheatingSum + likelyCheatingSum).toDouble / assessmentsCount >= 0.10 - relationModifier) - ) - - val reportable: Boolean = ( - (cheatingSum >= 1 || cheatingSum + likelyCheatingSum >= 2) - // more than 2 percent of games are cheating - && (cheatingSum.toDouble / assessmentsCount >= 0.02 - relationModifier - // or more than 5 percent of games are likely cheating - || (cheatingSum + likelyCheatingSum).toDouble / assessmentsCount >= 0.05 - relationModifier) - ) - - val bannable: Boolean = (relatedCheatersCount == relatedUsersCount) && relatedUsersCount >= 1 - - if (markable && bannable) EngineAndBan - else if (markable) Engine - else if (reportable) Report - else Nothing - } - - def countAssessmentValue(assessment: Int) = listSum(playerAssessments map { - case a if (a.assessment == assessment) => 1 - case _ => 0 - }) - - val relatedCheatersCount = relatedCheaters.distinct.size - val relatedUsersCount = relatedUsers.distinct.size - val assessmentsCount = playerAssessments.size match { - case 0 => 1 - case a => a - } - val relationModifier = if (relatedUsersCount >= 1) 0.02 else 0 - val cheatingSum = countAssessmentValue(5) - val likelyCheatingSum = countAssessmentValue(4) -} - -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 PlayerFlags( - suspiciousErrorRate: Boolean, - alwaysHasAdvantage: Boolean, - highBlurRate: Boolean, - moderateBlurRate: Boolean, - consistentMoveTimes: Boolean, - noFastMoves: Boolean, - suspiciousHoldAlert: Boolean) - -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 = - !analysed.analysis.infos.exists{ info => - info.score.fold(info.mate.fold(false){ a => (signum(a).toInt == color.fold(-1, 1)) }){ cp => - color.fold(cp.centipawns < -100, cp.centipawns > 100)} - } - - 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.5) - - def noFastMoves(color: Color): Boolean = moveTimes(color).count(_ < 10) <= 2 - - def suspiciousHoldAlert(color: Color): Boolean = - this.analysed.game.player(color).hasSuspiciousHoldAlert - - def flags(color: Color): PlayerFlags = PlayerFlags( - suspiciousErrorRate(color), - alwaysHasAdvantage(color), - highBlurRate(color), - moderateBlurRate(color), - consistentMoveTimes(color), - noFastMoves(color), - suspiciousHoldAlert(color) - ) - - def rankCheating(color: Color): Int = - (flags(color) match { - // SF1 SF2 BLR1 BLR2 MTs1 MTs2 Holds - case PlayerFlags(true, true, true, true, true, true, true) => 5 // all true, obvious cheat - case PlayerFlags(true, _, _, _, _, true, true) => 5 // high accuracy, no fast moves, hold alerts - case PlayerFlags(_, true, _, _, _, true, true) => 5 // always has advantage, no fast moves, hold alerts - case PlayerFlags(true, _, true, _, _, true, _) => 5 // high accuracy, high blurs, no fast moves - - case PlayerFlags(true, _, _, _, true, true, _) => 4 // high accuracy, consistent move times, no fast moves - case PlayerFlags(true, _, _, true, _, true, _) => 4 // high accuracy, moderate blurs, no fast moves - case PlayerFlags(_, true, _, true, true, _, _) => 4 // always has advantage, moderate blurs, highly consistent move times - case PlayerFlags(_, true, _, _, _, _, true) => 4 // always has advantage, hold alerts - case PlayerFlags(_, true, true, _, _, _, _) => 4 // always has advantage, high blurs - - case PlayerFlags(true, _, _, false, false, true, _) => 3 // high accuracy, no fast moves, but doesn't blur or flat line - - case PlayerFlags(true, _, _, _, _, false, _) => 2 // high accuracy, but has fast moves - - case PlayerFlags(false, false, _, _, _, _, _) => 1 // low accuracy, doesn't hold advantage - 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 - flags = flags(color), - 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 _ => "Undef" - } - - def stockfishSig(pa: PlayerAssessment): Int = - (pa.flags.suspiciousErrorRate, pa.flags.alwaysHasAdvantage) match { - case (true, true) => 5 - case (true, false) => 4 - case (false, true) => 3 - case _ => 1 - } - - def moveTimeSig(pa: PlayerAssessment): Int = - (pa.flags.consistentMoveTimes, pa.flags.noFastMoves) match { - case (true, true) => 5 - case (true, false) => 4 - case (false, true) => 3 - case _ => 1 - } - - def blurSig(pa: PlayerAssessment): Int = - (pa.flags.highBlurRate, pa.flags.moderateBlurRate) match { - case (true, _) => 5 - case (_, true) => 4 - case _ => 1 - } - - def holdSig(pa: PlayerAssessment): Int = if (pa.flags.suspiciousHoldAlert) 5 else 1 - - def emoticon(assessment: Int): String = assessment match { - case 5 => ">:(" - case 4 => ":(" - case 3 => ":|" - case 2 => ":)" - case 1 => ":D" - case _ => "\\o/" - } -} - -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 - } -} diff --git a/modules/evaluation/src/main/GameAssessments.scala b/modules/evaluation/src/main/GameAssessments.scala new file mode 100644 index 0000000000..87f0f69076 --- /dev/null +++ b/modules/evaluation/src/main/GameAssessments.scala @@ -0,0 +1,12 @@ +package lila.evaluation + +import chess.Color + +case class GameAssessments( + white: Option[PlayerAssessment], + black: Option[PlayerAssessment]) { + def color(c: Color) = c match { + case Color.White => white + case _ => black + } +} diff --git a/modules/evaluation/src/main/PlayerAggregateAssessment.scala b/modules/evaluation/src/main/PlayerAggregateAssessment.scala new file mode 100644 index 0000000000..99cafa45e2 --- /dev/null +++ b/modules/evaluation/src/main/PlayerAggregateAssessment.scala @@ -0,0 +1,131 @@ +package lila.evaluation + +import chess.Color +import org.joda.time.DateTime + +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 + flags: PlayerFlags, + sfAvg: Int, + sfSd: Int, + mtAvg: Int, + mtSd: Int, + blurs: Int, + hold: Boolean + ) { + val color = Color.apply(white) +} + +case class PlayerAggregateAssessment(playerAssessments: List[PlayerAssessment], + relatedUsers: List[String], + relatedCheaters: List[String]) { + import Statistics._ + import AccountAction._ + + def action = { + val markable: Boolean = ( + (cheatingSum >= 2 || cheatingSum + likelyCheatingSum >= 4) + // more than 5 percent of games are cheating + && (cheatingSum.toDouble / assessmentsCount >= 0.05 - relationModifier + // or more than 10 percent of games are likely cheating + || (cheatingSum + likelyCheatingSum).toDouble / assessmentsCount >= 0.10 - relationModifier) + ) + + val reportable: Boolean = ( + (cheatingSum >= 1 || cheatingSum + likelyCheatingSum >= 2) + // more than 2 percent of games are cheating + && (cheatingSum.toDouble / assessmentsCount >= 0.02 - relationModifier + // or more than 5 percent of games are likely cheating + || (cheatingSum + likelyCheatingSum).toDouble / assessmentsCount >= 0.05 - relationModifier) + ) + + val bannable: Boolean = (relatedCheatersCount == relatedUsersCount) && relatedUsersCount >= 1 + + val actionable: Boolean = { + def sigDif(a: Option[Int], b: Option[Int]): Option[Boolean] = (a, b) match { + case (Some(a), Some(b)) => Some(b - a > 10) + case _ => none + } + + val difs = List( + sigDif(sfAvgBlurs, sfAvgNoBlurs), + sigDif(sfAvgLowVar, sfAvgHighVar), + sigDif(sfAvgHold, sfAvgNoHold) + ) + + difs.forall(_.isEmpty) || difs.exists(~_) || assessmentsCount < 50 + } + + if (actionable) { + if (markable && bannable) EngineAndBan + else if (markable) Engine + else if (reportable) Report + else Nothing + } else { + if (markable) Report + else if (reportable) Report + else Nothing + } + } + + def countAssessmentValue(assessment: Int) = listSum(playerAssessments map { + case a if (a.assessment == assessment) => 1 + case _ => 0 + }) + + val relatedCheatersCount = relatedCheaters.distinct.size + val relatedUsersCount = relatedUsers.distinct.size + val assessmentsCount = playerAssessments.size match { + case 0 => 1 + case a => a + } + val relationModifier = if (relatedUsersCount >= 1) 0.02 else 0 + val cheatingSum = countAssessmentValue(5) + val likelyCheatingSum = countAssessmentValue(4) + + + // Some statistics + def sfAvgGiven(predicate: PlayerAssessment => Boolean): Option[Int] = { + val avg = listAverage(playerAssessments.filter(predicate).map(_.sfAvg)).toInt + if (playerAssessments.exists(predicate)) Some(avg) else none + } + + // Average SF Avg given blur rate + val sfAvgBlurs = sfAvgGiven(_.blurs > 70) + val sfAvgNoBlurs = sfAvgGiven(_.blurs <= 70) + + // Average SF Avg given move time coef of variance + val sfAvgLowVar = sfAvgGiven((a: PlayerAssessment) => a.mtSd.toDouble / a.mtAvg < 0.5) + val sfAvgHighVar = sfAvgGiven((a: PlayerAssessment) => a.mtSd.toDouble / a.mtAvg >= 0.5) + + // Average SF Avg given bot + val sfAvgHold = sfAvgGiven(_.hold) + val sfAvgNoHold = sfAvgGiven(!_.hold) + + def reportText(maxGames: Int = 10): String = { + val gameLinks: String = (playerAssessments.sortBy(-_.assessment).take(maxGames).map{ a => + Display.emoticon(a.assessment) + " http://lichess.org/" + a.gameId + "/" + a.color.name + }).mkString("\n") + + s"""[AUTOREPORT] + Cheating Games: $cheatingSum + Likely Cheating Games: $likelyCheatingSum + + $gameLinks""" + } +} + +case class PlayerFlags( + suspiciousErrorRate: Boolean, + alwaysHasAdvantage: Boolean, + highBlurRate: Boolean, + moderateBlurRate: Boolean, + consistentMoveTimes: Boolean, + noFastMoves: Boolean, + suspiciousHoldAlert: Boolean) diff --git a/modules/evaluation/src/main/Statistics.scala b/modules/evaluation/src/main/Statistics.scala new file mode 100644 index 0000000000..66c50cbd25 --- /dev/null +++ b/modules/evaluation/src/main/Statistics.scala @@ -0,0 +1,86 @@ +package lila.evaluation + +import Math.{ pow, abs, sqrt, E, exp } +import scalaz.NonEmptyList + +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 + } +} diff --git a/modules/mod/src/main/AssessApi.scala b/modules/mod/src/main/AssessApi.scala index 2acf7e98dd..1eb5727b00 100644 --- a/modules/mod/src/main/AssessApi.scala +++ b/modules/mod/src/main/AssessApi.scala @@ -1,5 +1,6 @@ package lila.mod +import akka.actor.ActorSelection import lila.analyse.{ Analysis, AnalysisRepo } import lila.db.Types.Coll import lila.db.BSON.BSONJodaDateTimeHandler @@ -19,6 +20,7 @@ final class AssessApi( collAssessments: Coll, logApi: ModlogApi, modApi: ModApi, + reporter: ActorSelection, userIdsSharingIp: String => Fu[List[String]]) { private implicit val playerFlagsBSONhandler = Macros.handler[PlayerFlags] @@ -66,15 +68,15 @@ final class AssessApi( } def refreshAssessByUsername(username: String): Funit = withUser(username) { user => - GameRepo.gamesForAssessment(user.id, 100) flatMap { - gs => (gs map { + (GameRepo.gamesForAssessment(user.id, 100) flatMap { gs => + (gs map { g => AnalysisRepo.doneById(g.id) flatMap { case Some(a) => onAnalysisReady(g, a, false) case _ => funit } }).sequenceFu.void - } - } >> assessPlayer(username) + }) >> assessPlayer(user.id) + } def onAnalysisReady(game: Game, analysis: Analysis, assess: Boolean = true): Funit = { if (!game.isCorrespondence && game.turns >= 40 && game.mode.rated) { @@ -88,12 +90,17 @@ final class AssessApi( def assessPlayer(userId: String): Funit = getPlayerAggregateAssessment(userId) flatMap { case Some(playerAggregateAssessment) => playerAggregateAssessment.action match { - case AccountAction.EngineAndBan => modApi.autoAdjust(userId) >> logApi.engine("lichess", userId, true) - case AccountAction.Engine => modApi.autoAdjust(userId) >> logApi.engine("lichess", userId, true) - case AccountAction.Report => funit - case _ => funit + case AccountAction.EngineAndBan => + modApi.autoAdjust(userId) >> logApi.engine("lichess", userId, true) + case AccountAction.Engine => + modApi.autoAdjust(userId) >> logApi.engine("lichess", userId, true) + case AccountAction.Report => { + reporter ! lila.hub.actorApi.report.Cheater(userId, playerAggregateAssessment.reportText(3)) + funit + } + case AccountAction.Nothing => funit } - case _ => funit + case none => funit } private def withUser[A](username: String)(op: User => Fu[A]): Fu[A] = diff --git a/modules/mod/src/main/Env.scala b/modules/mod/src/main/Env.scala index 37d68bf38e..e0f74ffcc1 100644 --- a/modules/mod/src/main/Env.scala +++ b/modules/mod/src/main/Env.scala @@ -9,6 +9,7 @@ import lila.security.{ Firewall, UserSpy } final class Env( config: Config, db: lila.db.Env, + hub: lila.hub.Env, system: ActorSystem, firewall: Firewall, userSpy: String => Fu[UserSpy], @@ -41,7 +42,8 @@ final class Env( collAssessments = db(CollectionPlayerAssessment), logApi = logApi, modApi = api, - userIdsSharingIp) + reporter = hub.actor.report, + userIdsSharingIp = userIdsSharingIp) // api actor private val actorApi = system.actorOf(Props(new Actor { @@ -63,6 +65,7 @@ object Env { lazy val current = "[boot] mod" describes new Env( config = lila.common.PlayApp loadConfig "mod", db = lila.db.Env.current, + hub = lila.hub.Env.current, system = lila.common.PlayApp.system, firewall = lila.security.Env.current.firewall, userSpy = lila.security.Env.current.userSpy, diff --git a/public/stylesheets/user-show.css b/public/stylesheets/user-show.css index 8cbb9f5071..c77a4496cb 100644 --- a/public/stylesheets/user-show.css +++ b/public/stylesheets/user-show.css @@ -301,6 +301,25 @@ div.user_show .reportCard td { text-align: center; padding: 5px; } +div.user_show .extra_stats { + margin: 0 auto 10px auto; +} +div.user_show .extra_stats caption { + text-align: center; + font-weight: bold; + font-size: 1.2em; + padding-bottom: 5px; +} +div.user_show .extra_stats th { + text-align: right; + font-weight: bold; + font-size: 1.2em; + padding: 0 5px 0 5px; +} +div.user_show .extra_stats td { + text-align: center; + padding: 5px; +} div.user_show .results .aggregate { text-shadow: 1px -1px 1px #000; font-weight: bold;