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