lila/modules/round/src/main/RoundAsyncActor.scala

579 lines
17 KiB
Scala

package lila.round
import actorApi._, round._
import chess.{ Black, Centis, Color, White }
import org.joda.time.DateTime
import ornicar.scalalib.Zero
import play.api.libs.json._
import scala.concurrent.duration._
import scala.concurrent.Promise
import scala.util.chaining._
import lila.game.Game.{ FullId, PlayerId }
import lila.game.{ Game, GameRepo, Pov, Event, Progress, Player => GamePlayer }
import lila.hub.actorApi.round.{
Abort,
BotPlay,
FishnetPlay,
FishnetStart,
IsOnGame,
RematchNo,
RematchYes,
Resign
}
import lila.hub.AsyncActor
import lila.room.RoomSocket.{ Protocol => RP, _ }
import lila.socket.Socket.{ makeMessage, GetVersion, SocketVersion }
import lila.socket.UserLagCache
import lila.user.User
final private[round] class RoundAsyncActor(
dependencies: RoundAsyncActor.Dependencies,
gameId: Game.ID,
socketSend: String => Unit,
private var version: SocketVersion
)(implicit
ec: scala.concurrent.ExecutionContext,
proxy: GameProxy
) extends AsyncActor {
import RoundSocket.Protocol
import RoundAsyncActor._
import dependencies._
private var takebackSituation: Option[TakebackSituation] = None
private var mightBeSimul = true // until proven otherwise
final private class Player(color: Color) {
private var offlineSince: Option[Long] = nowMillis.some
// wether the player closed the window intentionally
private var bye: Boolean = false
private var botConnections: Int = 0
def botConnected = botConnections > 0
var userId = none[User.ID]
var goneWeight = 1f
def isOnline = offlineSince.isEmpty || botConnected
def setOnline(on: Boolean): Unit = {
isLongGone foreach { _ ?? notifyGone(color, gone = false) }
offlineSince = if (on) None else offlineSince orElse nowMillis.some
bye = bye && !on
}
def setBye(): Unit = {
bye = true
}
private def isHostingSimul: Fu[Boolean] = mightBeSimul ?? userId ?? isSimulHost
private def timeoutMillis: Long = {
val base = {
if (bye) RoundSocket.ragequitTimeout
else
proxy.withGameOptionSync { g =>
RoundSocket.povDisconnectTimeout(g pov color)
} | RoundSocket.disconnectTimeout
}.toMillis * goneWeight
base atLeast RoundSocket.ragequitTimeout.toMillis.toFloat
}.toLong
def isLongGone: Fu[Boolean] = {
!botConnected && offlineSince.exists(_ < (nowMillis - timeoutMillis))
} ?? !isHostingSimul
def showMillisToGone: Fu[Option[Long]] =
if (botConnected) fuccess(none)
else {
val now = nowMillis
offlineSince.filter { since =>
bye || (now - since) > 5000
} ?? { since =>
isHostingSimul map {
!_ option (timeoutMillis + since - now)
}
}
}
def setBotConnected(v: Boolean) =
botConnections = Math.max(0, botConnections + (if (v) 1 else -1))
}
private val whitePlayer = new Player(White)
private val blackPlayer = new Player(Black)
def getGame: Fu[Option[Game]] = proxy.game
def updateGame(f: Game => Game): Funit = proxy update f
val process: AsyncActor.ReceiveAsync = {
case SetGameInfo(game, (whiteGoneWeight, blackGoneWeight)) =>
fuccess {
whitePlayer.userId = game.player(White).userId
blackPlayer.userId = game.player(Black).userId
mightBeSimul = game.isSimul
whitePlayer.goneWeight = whiteGoneWeight
blackPlayer.goneWeight = blackGoneWeight
if (game.playableByAi) player.requestFishnet(game, this)
}
// socket stuff
case ByePlayer(playerId) =>
proxy.withPov(playerId) {
_ ?? { pov =>
fuccess(getPlayer(pov.color).setBye())
}
}
case GetVersion(promise) =>
fuccess {
promise success version
}
case SetVersion(v) =>
fuccess {
version = v
}
case RoomCrowd(white, black) =>
fuccess {
whitePlayer setOnline white
blackPlayer setOnline black
}
case IsOnGame(color, promise) =>
fuccess {
promise success getPlayer(color).isOnline
}
case GetSocketStatus(promise) =>
whitePlayer.isLongGone zip blackPlayer.isLongGone map { case (whiteIsGone, blackIsGone) =>
promise success SocketStatus(
version = version,
whiteOnGame = whitePlayer.isOnline,
whiteIsGone = whiteIsGone,
blackOnGame = blackPlayer.isOnline,
blackIsGone = blackIsGone
)
}
case HasUserId(userId, promise) =>
fuccess {
promise success {
(whitePlayer.userId.has(userId) && whitePlayer.isOnline) ||
(blackPlayer.userId.has(userId) && blackPlayer.isOnline)
}
}
case lila.chat.actorApi.RoundLine(line, watcher) =>
fuccess {
publish(List(line match {
case l: lila.chat.UserLine => Event.UserMessage(l, watcher)
case l: lila.chat.PlayerLine => Event.PlayerMessage(l)
}))
}
case Protocol.In.HoldAlert(fullId, ip, mean, sd) =>
handle(fullId.playerId) { pov =>
gameRepo hasHoldAlert pov flatMap {
case true => funit
case false =>
lila
.log("cheat")
.info(
s"hold alert $ip https://lichess.org/${pov.gameId}/${pov.color.name}#${pov.game.turns} ${pov.player.userId | "anon"} mean: $mean SD: $sd"
)
lila.mon.cheat.holdAlert.increment()
gameRepo.setHoldAlert(pov, GamePlayer.HoldAlert(ply = pov.game.turns, mean = mean, sd = sd)).void
} inject Nil
}
case a: lila.analyse.actorApi.AnalysisProgress =>
fuccess {
socketSend(
RP.Out.tellRoom(
roomId,
makeMessage(
"analysisProgress",
Json.obj(
"analysis" -> lila.analyse.JsonView.bothPlayers(a.game, a.analysis),
"tree" -> lila.tree.Node.minimalNodeJsonWriter.writes {
TreeBuilder(
a.game,
a.analysis.some,
a.initialFen,
JsonView.WithFlags()
)
}
)
)
)
)
}
// round stuff
case p: HumanPlay =>
handle(p.playerId) { pov =>
if (pov.player.isAi) fufail(s"player $pov can't play AI")
else if (pov.game.outoftime(withGrace = true)) finisher.outOfTime(pov.game)
else {
recordLag(pov)
player.human(p, this)(pov)
}
}.chronometer.lap.addEffects(
err => {
p.promise.foreach(_ failure err)
socketSend(Protocol.Out.resyncPlayer(Game.Id(gameId) full p.playerId))
},
lap => {
p.promise.foreach(_ success {})
lila.mon.round.move.time.record(lap.nanos)
MoveLatMonitor record lap.micros
}
)
case p: BotPlay =>
val res = proxy.withPov(PlayerId(p.playerId)) {
_ ?? { pov =>
if (pov.game.outoftime(withGrace = true)) finisher.outOfTime(pov.game)
else player.bot(p.uci, this)(pov)
}
} dmap publish
p.promise.foreach(_ completeWith res)
res
case FishnetPlay(uci, ply) =>
handle { game =>
player.fishnet(game, ply, uci)
}.mon(_.round.move.time)
case Abort(playerId) =>
handle(PlayerId(playerId)) { pov =>
pov.game.abortable ?? finisher.abort(pov)
}
case Resign(playerId) =>
handle(PlayerId(playerId)) { pov =>
pov.game.resignable ?? finisher.other(pov.game, _.Resign, Some(!pov.color))
}
case ResignAi =>
handleAi { pov =>
pov.game.resignable ?? finisher.other(pov.game, _.Resign, Some(!pov.color))
}
case GoBerserk(color, promise) =>
handle(color) { pov =>
val berserked = pov.game.goBerserk(color)
berserked.?? { progress =>
proxy.save(progress) >> gameRepo.goBerserk(pov) inject progress.events
} >>- promise.success(berserked.isDefined)
}
case ResignForce(playerId) =>
handle(playerId) { pov =>
pov.mightClaimWin ?? {
getPlayer(!pov.color).isLongGone flatMap {
case true =>
finisher.rageQuit(
pov.game,
Some(pov.color) ifFalse pov.game.situation.opponentHasInsufficientMaterial
)
case _ => fuccess(List(Event.Reload))
}
}
}
case DrawForce(playerId) =>
handle(playerId) { pov =>
(pov.game.drawable && !pov.game.hasAi && pov.game.hasClock && pov.game.bothPlayersHaveMoved) ?? {
getPlayer(!pov.color).isLongGone flatMap {
case true => finisher.rageQuit(pov.game, None)
case _ => fuccess(List(Event.Reload))
}
}
}
case AbortForce =>
handle { game =>
game.playable ?? finisher.abortForce(game)
}
// checks if any player can safely (grace) be flagged
case QuietFlag =>
handle { game =>
game.outoftime(withGrace = true) ?? finisher.outOfTime(game)
}
// flags a specific player, possibly without grace if self
case ClientFlag(color, from) =>
handle { game =>
(game.turnColor == color) ?? {
val toSelf = from has PlayerId(game.player(color).id)
game.outoftime(withGrace = !toSelf) ?? finisher.outOfTime(game)
}
}
// exceptionally we don't publish events
// if the game is abandoned, then nobody is around to see it
case Abandon =>
proxy withGame { game =>
game.abandoned ?? {
if (game.abortable) finisher.other(game, _.Aborted, None)
else finisher.other(game, _.Resign, Some(!game.player.color))
}
}
case DrawYes(playerId) => handle(playerId)(drawer.yes)
case DrawNo(playerId) => handle(playerId)(drawer.no)
case DrawClaim(playerId) => handle(playerId)(drawer.claim)
case Cheat(color) =>
handle { game =>
(game.playable && !game.imported) ?? {
finisher.other(game, _.Cheat, Some(!color))
}
}
case TooManyPlies => handle(drawer force _)
case Threefold =>
proxy withGame { game =>
drawer autoThreefold game map {
_ foreach { pov =>
this ! DrawClaim(PlayerId(pov.player.id))
}
}
}
case RematchYes(playerId) => handle(PlayerId(playerId))(rematcher.yes)
case RematchNo(playerId) => handle(PlayerId(playerId))(rematcher.no)
case TakebackYes(playerId) =>
handle(playerId) { pov =>
takebacker.yes(~takebackSituation)(pov) map { case (events, situation) =>
takebackSituation = situation.some
events
}
}
case TakebackNo(playerId) =>
handle(playerId) { pov =>
takebacker.no(~takebackSituation)(pov) map { case (events, situation) =>
takebackSituation = situation.some
events
}
}
case Moretime(playerId, duration) =>
handle(playerId) { pov =>
moretimer(pov, duration) flatMap {
_ ?? { progress =>
proxy save progress inject progress.events
}
}
}
case ForecastPlay(lastMove) =>
handle { game =>
forecastApi.nextMove(game, lastMove) map { mOpt =>
mOpt foreach { move =>
this ! HumanPlay(PlayerId(game.player.id), move, blur = false)
}
Nil
}
}
case LilaStop(promise) =>
proxy.withGame { g =>
g.playable ?? {
proxy saveAndFlush {
g.clock.fold(Progress(g)) { clock =>
g.withClock {
clock
.giveTime(g.turnColor, Centis(2000))
.giveTime(!g.turnColor, Centis(1000))
}
}
}
}
} tap promise.completeWith
case WsBoot =>
handle { game =>
game.playable ?? {
messenger.volatile(game, "Lichess has been updated! Sorry for the inconvenience.")
val progress = moretimer.give(game, Color.all, 20 seconds)
proxy save progress inject progress.events
}
}
case BotConnected(color, v) =>
fuccess {
getPlayer(color) setBotConnected v
}
case NoStart =>
handle { game =>
game.timeBeforeExpiration.exists(_.centis == 0) ?? {
if (game.isSwiss) game.startClock ?? { g =>
proxy save g inject List(Event.Reload)
}
else finisher.noStart(game)
}
}
case StartClock =>
handle { game =>
game.startClock ?? { g =>
proxy save g inject List(Event.Reload)
}
}
case FishnetStart =>
proxy.withGame { g =>
g.playableByAi ?? player.requestFishnet(g, this)
}
case Tick =>
proxy.withGameOptionSync { g =>
(g.forceResignable && g.bothPlayersHaveMoved) ?? fuccess {
Color.all.foreach { c =>
if (!getPlayer(c).isOnline && getPlayer(!c).isOnline) {
getPlayer(c).showMillisToGone foreach {
_ ?? { millis =>
if (millis <= 0) notifyGone(c, gone = true)
else g.clock.exists(_.remainingTime(c).millis > millis + 3000) ?? notifyGoneIn(c, millis)
}
}
}
}
}
} | funit
case Stop => proxy.terminate() >>- socketSend(RP.Out.stop(roomId))
}
private def getPlayer(color: Color): Player = color.fold(whitePlayer, blackPlayer)
private def recordLag(pov: Pov): Unit =
if ((pov.game.playedTurns & 30) == 10) {
// Triggers every 32 moves starting on ply 10.
// i.e. 10, 11, 42, 43, 74, 75, ...
for {
user <- pov.player.userId
clock <- pov.game.clock
lag <- clock.lag(pov.color).lagMean
} UserLagCache.put(user, lag)
}
private def notifyGone(color: Color, gone: Boolean): Funit =
proxy.withPov(color) { pov =>
fuccess {
socketSend(Protocol.Out.gone(FullId(pov.fullId), gone))
}
}
private def notifyGoneIn(color: Color, millis: Long): Funit =
proxy.withPov(color) { pov =>
fuccess {
socketSend(Protocol.Out.goneIn(FullId(pov.fullId), millis))
}
}
private def handle(op: Game => Fu[Events]): Funit =
proxy.withGame { g =>
handleAndPublish(op(g))
}
private def handle(playerId: PlayerId)(op: Pov => Fu[Events]): Funit =
proxy.withPov(playerId) {
_ ?? { pov =>
handleAndPublish(op(pov))
}
}
private def handle(color: Color)(op: Pov => Fu[Events]): Funit =
proxy.withPov(color) { pov =>
handleAndPublish(op(pov))
}
private def handleAndPublish(events: Fu[Events]): Funit =
events dmap publish recover errorHandler("handle")
private def handleAi(op: Pov => Fu[Events]): Funit =
proxy.withGame {
_.aiPov ?? { p =>
handleAndPublish(op(p))
}
}
private def publish[A](events: Events): Unit =
if (events.nonEmpty) {
events foreach { e =>
version = version.inc
socketSend {
Protocol.Out.tellVersion(roomId, version, e)
}
}
if (
events exists {
case e: Event.Move => e.threefold
case _ => false
}
) this ! Threefold
}
private def errorHandler(name: String): PartialFunction[Throwable, Unit] = {
case e: ClientError =>
logger.info(s"Round client error $name: ${e.getMessage}")
lila.mon.round.error.client.increment().unit
case e: FishnetError =>
logger.info(s"Round fishnet error $name: ${e.getMessage}")
lila.mon.round.error.fishnet.increment().unit
case e: Exception =>
logger.warn(s"$name: ${e.getMessage}")
lila.mon.round.error.other.increment().unit
}
def roomId = RoomId(gameId)
}
object RoundAsyncActor {
case class HasUserId(userId: User.ID, promise: Promise[Boolean])
case class SetGameInfo(game: lila.game.Game, goneWeights: (Float, Float))
case object Tick
case object Stop
case object WsBoot
case class LilaStop(promise: Promise[Unit])
private[round] case class TakebackSituation(nbDeclined: Int, lastDeclined: Option[DateTime]) {
def decline = TakebackSituation(nbDeclined + 1, DateTime.now.some)
def delaySeconds = (math.pow(nbDeclined min 10, 2) * 10).toInt
def offerable = lastDeclined.fold(true) { _ isBefore DateTime.now.minusSeconds(delaySeconds) }
def reset = takebackSituationZero.zero
}
implicit private[round] val takebackSituationZero: Zero[TakebackSituation] =
Zero.instance(TakebackSituation(0, none))
private[round] class Dependencies(
val gameRepo: GameRepo,
val messenger: Messenger,
val takebacker: Takebacker,
val moretimer: Moretimer,
val finisher: Finisher,
val rematcher: Rematcher,
val player: Player,
val drawer: Drawer,
val forecastApi: ForecastApi,
val isSimulHost: IsSimulHost
)
}