2015-04-25 12:48:13 -06:00
|
|
|
package lila.playban
|
|
|
|
|
2019-11-29 19:16:11 -07:00
|
|
|
import reactivemongo.api.bson._
|
2019-08-21 04:44:19 -06:00
|
|
|
import scala.concurrent.duration._
|
2015-04-25 12:48:13 -06:00
|
|
|
|
2019-07-11 14:18:24 -06:00
|
|
|
import chess.variant._
|
2019-08-21 04:44:19 -06:00
|
|
|
import chess.{ Status, Color }
|
2019-12-01 11:03:39 -07:00
|
|
|
import lila.common.PlayApp.startedSinceMinutes
|
2019-11-26 14:44:08 -07:00
|
|
|
import lila.common.{ Bus, Iso }
|
2016-04-02 04:13:25 -06:00
|
|
|
import lila.db.dsl._
|
2015-04-25 15:06:44 -06:00
|
|
|
import lila.game.{ Pov, Game, Player, Source }
|
2019-08-24 07:21:29 -06:00
|
|
|
import lila.message.{ MessageApi, ModPreset }
|
2017-12-10 10:12:30 -07:00
|
|
|
import lila.user.{ User, UserRepo }
|
2015-04-25 12:48:13 -06:00
|
|
|
|
2019-06-30 07:28:33 -06:00
|
|
|
import org.joda.time.DateTime
|
|
|
|
|
2015-04-27 06:07:35 -06:00
|
|
|
final class PlaybanApi(
|
|
|
|
coll: Coll,
|
2017-09-11 23:09:46 -06:00
|
|
|
sandbag: SandbagWatch,
|
2018-03-10 08:56:38 -07:00
|
|
|
feedback: PlaybanFeedback,
|
2019-12-01 11:03:39 -07:00
|
|
|
userRepo: UserRepo,
|
2019-08-24 04:39:42 -06:00
|
|
|
asyncCache: lila.memo.AsyncCache.Builder,
|
|
|
|
messenger: MessageApi
|
2017-02-14 08:34:07 -07:00
|
|
|
) {
|
2015-04-25 12:48:13 -06:00
|
|
|
|
|
|
|
import lila.db.BSON.BSONJodaDateTimeHandler
|
2019-11-29 19:16:11 -07:00
|
|
|
import reactivemongo.api.bson.Macros
|
2019-12-01 11:03:39 -07:00
|
|
|
private implicit val OutcomeBSONHandler = tryHandler[Outcome](
|
|
|
|
{ case BSONInteger(v) => Outcome(v) toTry s"No such playban outcome: $v" },
|
|
|
|
x => BSONInteger(x.id)
|
|
|
|
)
|
2019-09-26 13:45:53 -06:00
|
|
|
private implicit val RageSitBSONHandler = intIsoHandler(Iso.int[RageSit](RageSit.apply, _.counter))
|
|
|
|
private implicit val BanBSONHandler = Macros.handler[TempBan]
|
2015-04-25 12:48:13 -06:00
|
|
|
private implicit val UserRecordBSONHandler = Macros.handler[UserRecord]
|
|
|
|
|
2015-04-25 15:06:44 -06:00
|
|
|
private case class Blame(player: Player, outcome: Outcome)
|
|
|
|
|
2017-09-11 23:09:46 -06:00
|
|
|
private val blameableSources: Set[Source] = Set(Source.Lobby, Source.Pool, Source.Tournament)
|
2016-11-29 18:07:23 -07:00
|
|
|
|
2016-07-19 13:47:22 -06:00
|
|
|
private def blameable(game: Game): Fu[Boolean] =
|
2017-10-22 16:03:47 -06:00
|
|
|
(game.source.exists(s => blameableSources(s)) && game.hasClock) ?? {
|
2018-03-16 17:20:53 -06:00
|
|
|
if (game.rated) fuTrue
|
2019-12-01 11:03:39 -07:00
|
|
|
else userRepo.containsEngine(game.userIds) map (!_)
|
2016-07-19 13:47:22 -06:00
|
|
|
}
|
|
|
|
|
|
|
|
private def IfBlameable[A: ornicar.scalalib.Zero](game: Game)(f: => Fu[A]): Fu[A] =
|
2019-12-01 11:03:39 -07:00
|
|
|
startedSinceMinutes(10) ?? {
|
2018-03-12 06:16:58 -06:00
|
|
|
blameable(game) flatMap { _ ?? f }
|
|
|
|
}
|
2015-04-25 15:06:44 -06:00
|
|
|
|
2019-07-03 16:07:14 -06:00
|
|
|
private def roughWinEstimate(game: Game, color: Color) = {
|
2019-07-11 07:52:13 -06:00
|
|
|
(game.chess.board.materialImbalance, game.variant) match {
|
2019-07-11 14:18:24 -06:00
|
|
|
case (_, Antichess | Crazyhouse | Horde) => 0
|
2019-07-11 07:52:13 -06:00
|
|
|
case (a, _) if a >= 5 => 1
|
|
|
|
case (a, _) if a <= -5 => -1
|
2019-07-03 16:07:14 -06:00
|
|
|
case _ => 0
|
|
|
|
}
|
2019-09-23 06:36:43 -06:00
|
|
|
} * (if (color.white) 1 else -1)
|
2019-07-03 16:07:14 -06:00
|
|
|
|
2016-09-07 11:33:24 -06:00
|
|
|
def abort(pov: Pov, isOnGame: Set[Color]): Funit = IfBlameable(pov.game) {
|
2018-03-10 08:56:38 -07:00
|
|
|
pov.player.userId.ifTrue(isOnGame(pov.opponent.color)) ?? { userId =>
|
2019-07-03 16:07:14 -06:00
|
|
|
save(Outcome.Abort, userId, 0) >>- feedback.abort(pov)
|
2018-03-10 08:56:38 -07:00
|
|
|
}
|
2017-10-22 16:03:47 -06:00
|
|
|
}
|
|
|
|
|
|
|
|
def noStart(pov: Pov): Funit = IfBlameable(pov.game) {
|
2018-03-10 08:56:38 -07:00
|
|
|
pov.player.userId ?? { userId =>
|
2019-07-03 16:07:14 -06:00
|
|
|
save(Outcome.NoPlay, userId, 0) >>- feedback.noStart(pov)
|
2018-03-10 08:56:38 -07:00
|
|
|
}
|
2015-04-26 05:04:22 -06:00
|
|
|
}
|
2015-04-25 15:06:44 -06:00
|
|
|
|
2017-09-11 23:09:46 -06:00
|
|
|
def rageQuit(game: Game, quitterColor: Color): Funit =
|
|
|
|
sandbag(game, quitterColor) >> IfBlameable(game) {
|
2018-03-10 08:56:38 -07:00
|
|
|
game.player(quitterColor).userId ?? { userId =>
|
2019-09-26 13:45:53 -06:00
|
|
|
save(Outcome.RageQuit, userId, roughWinEstimate(game, quitterColor) * 10) >>-
|
|
|
|
feedback.rageQuit(Pov(game, quitterColor))
|
2018-03-10 08:56:38 -07:00
|
|
|
}
|
2017-09-11 23:09:46 -06:00
|
|
|
}
|
2015-04-25 15:06:44 -06:00
|
|
|
|
2017-10-18 13:02:59 -06:00
|
|
|
def flag(game: Game, flaggerColor: Color): Funit = {
|
|
|
|
|
|
|
|
def unreasonableTime = game.clock map { c =>
|
2017-10-24 19:00:08 -06:00
|
|
|
(c.estimateTotalSeconds / 12) atLeast 15 atMost (3 * 60)
|
2017-10-18 13:02:59 -06:00
|
|
|
}
|
|
|
|
|
|
|
|
// flagged after waiting a long time
|
|
|
|
def sitting = for {
|
|
|
|
userId <- game.player(flaggerColor).userId
|
|
|
|
seconds = nowSeconds - game.movedAt.getSeconds
|
|
|
|
limit <- unreasonableTime
|
|
|
|
if seconds >= limit
|
2019-09-26 13:45:53 -06:00
|
|
|
} yield save(Outcome.Sitting, userId, roughWinEstimate(game, flaggerColor) * 10) >>-
|
2019-08-24 15:34:40 -06:00
|
|
|
feedback.sitting(Pov(game, flaggerColor)) >>-
|
2019-08-24 16:10:08 -06:00
|
|
|
propagateSitting(game, userId)
|
2017-10-18 13:02:59 -06:00
|
|
|
|
|
|
|
// flagged after waiting a short time;
|
|
|
|
// but the previous move used a long time.
|
|
|
|
// assumes game was already checked for sitting
|
|
|
|
def sitMoving = for {
|
|
|
|
userId <- game.player(flaggerColor).userId
|
|
|
|
movetimes <- game moveTimes flaggerColor
|
|
|
|
lastMovetime <- movetimes.lastOption
|
|
|
|
limit <- unreasonableTime
|
|
|
|
if lastMovetime.toSeconds >= limit
|
2019-09-26 13:45:53 -06:00
|
|
|
} yield save(Outcome.SitMoving, userId, roughWinEstimate(game, flaggerColor) * 10) >>-
|
2019-08-24 15:34:40 -06:00
|
|
|
feedback.sitting(Pov(game, flaggerColor)) >>-
|
2019-08-24 16:10:08 -06:00
|
|
|
propagateSitting(game, userId)
|
2017-10-18 13:02:59 -06:00
|
|
|
|
2017-10-18 17:16:52 -06:00
|
|
|
sandbag(game, flaggerColor) flatMap { isSandbag =>
|
|
|
|
IfBlameable(game) {
|
2017-10-18 17:26:38 -06:00
|
|
|
sitting orElse
|
|
|
|
sitMoving getOrElse
|
|
|
|
goodOrSandbag(game, flaggerColor, isSandbag)
|
2017-10-18 13:02:59 -06:00
|
|
|
}
|
2017-09-11 23:09:46 -06:00
|
|
|
}
|
2017-10-18 13:02:59 -06:00
|
|
|
}
|
2016-07-18 02:22:13 -06:00
|
|
|
|
2019-09-26 13:45:53 -06:00
|
|
|
private def propagateSitting(game: Game, userId: User.ID): Funit =
|
|
|
|
rageSitCache get userId map { rageSit =>
|
2019-11-29 17:07:51 -07:00
|
|
|
if (rageSit.isBad) Bus.publish(SittingDetected(game, userId), "playban")
|
2019-08-24 16:10:08 -06:00
|
|
|
}
|
|
|
|
|
2017-09-11 23:09:46 -06:00
|
|
|
def other(game: Game, status: Status.type => Status, winner: Option[Color]): Funit =
|
2017-10-18 17:16:52 -06:00
|
|
|
winner.?? { w => sandbag(game, !w) } flatMap { isSandbag =>
|
|
|
|
IfBlameable(game) {
|
2017-10-18 17:26:38 -06:00
|
|
|
~(for {
|
2017-10-18 17:16:52 -06:00
|
|
|
w <- winner
|
|
|
|
loserId <- game.player(!w).userId
|
2017-10-18 17:26:38 -06:00
|
|
|
} yield {
|
2019-07-03 16:07:14 -06:00
|
|
|
if (Status.NoStart is status) save(Outcome.NoPlay, loserId, 0) >>- feedback.noStart(Pov(game, !w))
|
2017-10-18 17:26:38 -06:00
|
|
|
else goodOrSandbag(game, !w, isSandbag)
|
|
|
|
})
|
2017-10-18 17:16:52 -06:00
|
|
|
}
|
2017-09-11 23:09:46 -06:00
|
|
|
}
|
2015-04-25 15:06:44 -06:00
|
|
|
|
2019-09-27 01:16:53 -06:00
|
|
|
private def goodOrSandbag(game: Game, loserColor: Color, isSandbag: Boolean): Funit =
|
|
|
|
game.player(loserColor).userId ?? { userId =>
|
|
|
|
if (isSandbag) feedback.sandbag(Pov(game, loserColor))
|
2019-10-11 00:50:13 -06:00
|
|
|
val rageSitDelta = if (isSandbag) 0 else 1 // proper defeat decays ragesit
|
2019-09-27 01:16:53 -06:00
|
|
|
save(if (isSandbag) Outcome.Sandbag else Outcome.Good, userId, rageSitDelta)
|
2017-10-18 17:16:52 -06:00
|
|
|
}
|
2017-06-19 13:39:39 -06:00
|
|
|
|
2019-08-21 04:44:19 -06:00
|
|
|
// memorize users without any ban to save DB reads
|
|
|
|
private val cleanUserIds = new lila.memo.ExpireSetMemo(30 minutes)
|
|
|
|
|
|
|
|
def currentBan(userId: User.ID): Fu[Option[TempBan]] = !cleanUserIds.get(userId) ?? {
|
2019-12-01 11:03:39 -07:00
|
|
|
coll.ext.find(
|
2019-08-21 04:44:19 -06:00
|
|
|
$doc("_id" -> userId, "b.0" $exists true),
|
|
|
|
$doc("_id" -> false, "b" -> $doc("$slice" -> -1))
|
2019-12-01 11:03:39 -07:00
|
|
|
).uno[Bdoc].dmap {
|
|
|
|
_.flatMap(_.getAsOpt[List[TempBan]]("b")).??(_.find(_.inEffect))
|
2019-08-21 04:44:19 -06:00
|
|
|
} addEffect { ban =>
|
|
|
|
if (ban.isEmpty) cleanUserIds put userId
|
|
|
|
}
|
|
|
|
}
|
2015-04-25 15:06:44 -06:00
|
|
|
|
2017-12-10 10:12:30 -07:00
|
|
|
def hasCurrentBan(userId: User.ID): Fu[Boolean] = currentBan(userId).map(_.isDefined)
|
2017-08-04 08:12:21 -06:00
|
|
|
|
2017-12-10 10:12:30 -07:00
|
|
|
def completionRate(userId: User.ID): Fu[Option[Double]] =
|
2016-07-18 04:44:54 -06:00
|
|
|
coll.primitiveOne[List[Outcome]]($id(userId), "o").map(~_) map { outcomes =>
|
|
|
|
outcomes.collect {
|
2019-07-27 20:34:05 -06:00
|
|
|
case Outcome.RageQuit | Outcome.Sitting | Outcome.NoPlay | Outcome.Abort => false
|
2017-02-14 08:34:07 -07:00
|
|
|
case Outcome.Good => true
|
2016-07-18 04:44:54 -06:00
|
|
|
} match {
|
|
|
|
case c if c.size >= 5 => Some(c.count(identity).toDouble / c.size)
|
2017-02-14 08:34:07 -07:00
|
|
|
case _ => none
|
2016-07-18 04:44:54 -06:00
|
|
|
}
|
2015-04-25 15:06:44 -06:00
|
|
|
}
|
|
|
|
|
2019-12-01 11:03:39 -07:00
|
|
|
def bans(userIds: List[User.ID]): Fu[Map[User.ID, Int]] = coll.ext.find(
|
2016-04-02 04:13:25 -06:00
|
|
|
$inIds(userIds),
|
|
|
|
$doc("b" -> true)
|
2019-08-21 04:44:19 -06:00
|
|
|
).list[Bdoc]().map {
|
2015-04-25 23:44:35 -06:00
|
|
|
_.flatMap { obj =>
|
2019-12-01 11:03:39 -07:00
|
|
|
obj.getAsOpt[User.ID]("_id") flatMap { id =>
|
|
|
|
obj.getAsOpt[Barr]("b") map { id -> _.size }
|
2015-04-25 23:44:35 -06:00
|
|
|
}
|
2019-12-01 11:03:39 -07:00
|
|
|
}.toMap
|
2015-04-25 23:44:35 -06:00
|
|
|
}
|
|
|
|
|
2019-09-26 13:45:53 -06:00
|
|
|
def getRageSit(userId: User.ID) = rageSitCache get userId
|
|
|
|
|
|
|
|
private val rageSitCache = asyncCache.multi[User.ID, RageSit](
|
2019-10-09 10:54:09 -06:00
|
|
|
name = "playban.ragesit",
|
2019-09-26 13:45:53 -06:00
|
|
|
f = userId => coll.primitiveOne[RageSit]($doc("_id" -> userId, "c" $exists true), "c").map(_ | RageSit.empty),
|
2019-08-23 10:12:18 -06:00
|
|
|
expireAfter = _.ExpireAfterWrite(30 minutes)
|
|
|
|
)
|
2019-08-21 11:39:04 -06:00
|
|
|
|
2019-09-26 13:45:53 -06:00
|
|
|
private def save(outcome: Outcome, userId: User.ID, rageSitDelta: Int): Funit = {
|
2017-10-22 17:14:35 -06:00
|
|
|
lila.mon.playban.outcome(outcome.key)()
|
2019-11-30 18:00:44 -07:00
|
|
|
coll.ext.findAndUpdate(
|
2019-06-30 16:26:31 -06:00
|
|
|
selector = $id(userId),
|
2019-09-23 06:36:43 -06:00
|
|
|
update = $doc(
|
|
|
|
$push("o" -> $doc("$each" -> List(outcome), "$slice" -> -30)),
|
2019-10-11 00:53:37 -06:00
|
|
|
if (rageSitDelta == 0) $min("c" -> 0)
|
|
|
|
else $inc("c" -> rageSitDelta)
|
2019-09-23 06:36:43 -06:00
|
|
|
),
|
2019-06-30 16:26:31 -06:00
|
|
|
fetchNewObject = true,
|
|
|
|
upsert = true
|
2019-12-01 11:03:39 -07:00
|
|
|
).dmap(_.value flatMap UserRecordBSONHandler.readOpt) orFail
|
2019-09-23 06:54:30 -06:00
|
|
|
s"can't find newly created record for user $userId" flatMap { record =>
|
|
|
|
(outcome != Outcome.Good) ?? {
|
2019-12-01 11:03:39 -07:00
|
|
|
userRepo.createdAtById(userId).flatMap { _ ?? { legiferate(record, _) } }
|
2019-09-23 06:54:30 -06:00
|
|
|
} >> {
|
2019-09-26 13:45:53 -06:00
|
|
|
(rageSitDelta != 0) ?? registerRageSit(record, rageSitDelta)
|
2019-08-24 04:39:42 -06:00
|
|
|
}
|
2019-06-30 16:26:31 -06:00
|
|
|
}
|
2019-06-30 15:00:01 -06:00
|
|
|
}.void logFailure lila.log("playban")
|
2015-04-25 15:06:44 -06:00
|
|
|
|
2019-09-26 13:45:53 -06:00
|
|
|
private def registerRageSit(record: UserRecord, delta: Int): Funit = {
|
|
|
|
rageSitCache.put(record.userId, record.rageSit)
|
|
|
|
(delta < 0) ?? {
|
|
|
|
if (record.rageSit.isTerrible) {
|
|
|
|
lila.log("ragesit").warn(s"Close https://lichess.org/@/${record.userId} ragesit=${record.rageSit}")
|
|
|
|
funit
|
|
|
|
} else if (record.rageSit.isVeryBad) for {
|
2019-12-01 11:03:39 -07:00
|
|
|
mod <- userRepo.lichess
|
|
|
|
user <- userRepo byId record.userId
|
2019-09-26 13:45:53 -06:00
|
|
|
} yield (mod zip user).headOption foreach {
|
|
|
|
case (m, u) =>
|
2019-10-23 06:58:53 -06:00
|
|
|
lila.log("ragesit").info(s"https://lichess.org/@/${u.username} ${record.rageSit.counterView}")
|
2019-11-29 17:07:51 -07:00
|
|
|
Bus.publish(lila.hub.actorApi.mod.AutoWarning(u.id, ModPreset.sittingAuto.subject), "autoWarning")
|
2019-09-26 13:45:53 -06:00
|
|
|
messenger.sendPreset(m, u, ModPreset.sittingAuto).void
|
|
|
|
}
|
|
|
|
else funit
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2019-06-30 16:10:33 -06:00
|
|
|
private def legiferate(record: UserRecord, accCreatedAt: DateTime): Funit = record.bannable(accCreatedAt) ?? { ban =>
|
2018-11-29 20:34:05 -07:00
|
|
|
(!record.banInEffect) ?? {
|
|
|
|
lila.mon.playban.ban.count()
|
|
|
|
lila.mon.playban.ban.mins(ban.mins)
|
2019-11-29 17:07:51 -07:00
|
|
|
Bus.publish(lila.hub.actorApi.playban.Playban(record.userId, ban.mins), "playban")
|
2019-11-30 18:00:44 -07:00
|
|
|
coll.update.one(
|
2018-11-29 20:34:05 -07:00
|
|
|
$id(record.userId),
|
|
|
|
$unset("o") ++
|
|
|
|
$push(
|
|
|
|
"b" -> $doc(
|
|
|
|
"$each" -> List(ban),
|
|
|
|
"$slice" -> -30
|
2017-10-19 22:02:55 -06:00
|
|
|
)
|
2018-11-29 20:34:05 -07:00
|
|
|
)
|
2019-08-21 04:44:19 -06:00
|
|
|
).void >>- cleanUserIds.remove(record.userId)
|
2017-10-19 22:02:55 -06:00
|
|
|
}
|
2015-04-25 15:06:44 -06:00
|
|
|
}
|
2015-04-25 12:48:13 -06:00
|
|
|
}
|