390 lines
16 KiB
Scala
390 lines
16 KiB
Scala
package lila.round
|
|
|
|
import actorApi._
|
|
import actorApi.round._
|
|
import akka.actor.{ ActorSystem, Cancellable, CoordinatedShutdown, Scheduler }
|
|
import chess.format.Uci
|
|
import chess.{ Black, Centis, Color, MoveMetrics, Speed, White }
|
|
import play.api.libs.json._
|
|
import scala.concurrent.duration._
|
|
import scala.concurrent.ExecutionContext
|
|
|
|
import lila.chat.{ BusChan, Chat }
|
|
import lila.common.{ Bus, IpAddress, Lilakka }
|
|
import lila.game.Game.{ FullId, PlayerId }
|
|
import lila.game.{ Event, Game, Pov }
|
|
import lila.hub.actorApi.map.{ Exists, Tell, TellAll, TellIfExists, TellMany }
|
|
import lila.hub.actorApi.round.{ Abort, Berserk, RematchNo, RematchYes, Resign, TourStanding }
|
|
import lila.hub.actorApi.socket.remote.TellSriIn
|
|
import lila.hub.actorApi.tv.TvSelect
|
|
import lila.hub.DuctConcMap
|
|
import lila.room.RoomSocket.{ Protocol => RP, _ }
|
|
import lila.socket.RemoteSocket.{ Protocol => P, _ }
|
|
import lila.socket.Socket.{ makeMessage, SocketVersion }
|
|
import lila.user.User
|
|
|
|
final class RoundSocket(
|
|
remoteSocketApi: lila.socket.RemoteSocket,
|
|
roundDependencies: RoundDuct.Dependencies,
|
|
proxyDependencies: GameProxy.Dependencies,
|
|
scheduleExpiration: ScheduleExpiration,
|
|
tournamentActor: lila.hub.actors.TournamentApi,
|
|
messenger: Messenger,
|
|
goneWeightsFor: Game => Fu[(Float, Float)],
|
|
shutdown: CoordinatedShutdown
|
|
)(implicit
|
|
ec: ExecutionContext,
|
|
system: ActorSystem
|
|
) {
|
|
|
|
import RoundSocket._
|
|
|
|
private var stopping = false
|
|
|
|
Lilakka.shutdown(shutdown, _.PhaseServiceUnbind, "Stop round socket") { () =>
|
|
stopping = true
|
|
rounds.tellAllWithAck(RoundDuct.LilaStop.apply) map { nb =>
|
|
Lilakka.logger.info(s"$nb round ducts have stopped")
|
|
}
|
|
}
|
|
|
|
def getGame(gameId: Game.ID): Fu[Option[Game]] =
|
|
rounds.getOrMake(gameId).getGame addEffect { g =>
|
|
if (g.isEmpty) finishRound(Game.Id(gameId))
|
|
}
|
|
def getGames(gameIds: List[Game.ID]): Fu[List[(Game.ID, Option[Game])]] =
|
|
gameIds.map { id =>
|
|
rounds.getOrMake(id).getGame dmap { id -> _ }
|
|
}.sequenceFu
|
|
|
|
def gameIfPresent(gameId: Game.ID): Fu[Option[Game]] = rounds.getIfPresent(gameId).??(_.getGame)
|
|
|
|
// get the proxied version of the game
|
|
def upgradeIfPresent(game: Game): Fu[Game] =
|
|
rounds.getIfPresent(game.id).fold(fuccess(game))(_.getGame.dmap(_ | game))
|
|
|
|
// update the proxied game
|
|
def updateIfPresent(gameId: Game.ID)(f: Game => Game): Funit =
|
|
rounds.getIfPresent(gameId) ?? {
|
|
_ updateGame f
|
|
}
|
|
|
|
val rounds = new DuctConcMap[RoundDuct](
|
|
mkDuct = id => {
|
|
val proxy = new GameProxy(id, proxyDependencies)
|
|
val duct = new RoundDuct(
|
|
dependencies = roundDependencies,
|
|
gameId = id,
|
|
socketSend = sendForGameId(id)
|
|
)(ec, proxy)
|
|
terminationDelay schedule Game.Id(id)
|
|
duct.getGame dforeach {
|
|
_ foreach { game =>
|
|
scheduleExpiration(game)
|
|
goneWeightsFor(game) dforeach { w =>
|
|
duct ! RoundDuct.SetGameInfo(game, w)
|
|
}
|
|
}
|
|
}
|
|
duct
|
|
},
|
|
initialCapacity = 65536
|
|
)
|
|
|
|
private def tellRound(gameId: Game.Id, msg: Any): Unit = rounds.tell(gameId.value, msg)
|
|
|
|
private lazy val roundHandler: Handler = {
|
|
case Protocol.In.PlayerMove(fullId, uci, blur, lag) if !stopping =>
|
|
tellRound(fullId.gameId, HumanPlay(fullId.playerId, uci, blur, lag, none))
|
|
case Protocol.In.PlayerDo(id, tpe) if !stopping =>
|
|
tpe match {
|
|
case "moretime" => tellRound(id.gameId, Moretime(id.playerId))
|
|
case "rematch-yes" => tellRound(id.gameId, RematchYes(id.playerId.value))
|
|
case "rematch-no" => tellRound(id.gameId, RematchNo(id.playerId.value))
|
|
case "takeback-yes" => tellRound(id.gameId, TakebackYes(id.playerId))
|
|
case "takeback-no" => tellRound(id.gameId, TakebackNo(id.playerId))
|
|
case "draw-yes" => tellRound(id.gameId, DrawYes(id.playerId))
|
|
case "draw-no" => tellRound(id.gameId, DrawNo(id.playerId))
|
|
case "draw-claim" => tellRound(id.gameId, DrawClaim(id.playerId))
|
|
case "resign" => tellRound(id.gameId, Resign(id.playerId.value))
|
|
case "resign-force" => tellRound(id.gameId, ResignForce(id.playerId))
|
|
case "draw-force" => tellRound(id.gameId, DrawForce(id.playerId))
|
|
case "abort" => tellRound(id.gameId, Abort(id.playerId.value))
|
|
case "outoftime" => tellRound(id.gameId, QuietFlag) // mobile app BC
|
|
case t => logger.warn(s"Unhandled round socket message: $t")
|
|
}
|
|
case Protocol.In.Flag(gameId, color, fromPlayerId) => tellRound(gameId, ClientFlag(color, fromPlayerId))
|
|
case Protocol.In.PlayerChatSay(id, Right(color), msg) =>
|
|
gameIfPresent(id.value) foreach {
|
|
_ foreach {
|
|
messenger.owner(_, color, msg).unit
|
|
}
|
|
}
|
|
case Protocol.In.PlayerChatSay(id, Left(userId), msg) =>
|
|
messenger.owner(id, userId, msg).unit
|
|
case Protocol.In.WatcherChatSay(id, userId, msg) =>
|
|
messenger.watcher(id, userId, msg).unit
|
|
case RP.In.ChatTimeout(roomId, modId, suspect, reason, text) =>
|
|
messenger.timeout(Chat.Id(s"$roomId/w"), modId, suspect, reason, text).unit
|
|
case Protocol.In.Berserk(gameId, userId) => tournamentActor ! Berserk(gameId.value, userId)
|
|
case Protocol.In.PlayerOnlines(onlines) =>
|
|
onlines foreach {
|
|
case (gameId, Some(on)) =>
|
|
tellRound(gameId, on)
|
|
terminationDelay cancel gameId
|
|
case (gameId, _) =>
|
|
if (rounds exists gameId.value) terminationDelay schedule gameId
|
|
}
|
|
case Protocol.In.Bye(fullId) => tellRound(fullId.gameId, ByePlayer(fullId.playerId))
|
|
case RP.In.TellRoomSri(_, P.In.TellSri(_, _, tpe, _)) =>
|
|
logger.warn(s"Unhandled round socket message: $tpe")
|
|
case hold: Protocol.In.HoldAlert => tellRound(hold.fullId.gameId, hold)
|
|
case r: Protocol.In.SelfReport => Bus.publish(r, "selfReport")
|
|
case P.In.TellSri(sri, userId, tpe, msg) => // eval cache
|
|
Bus.publish(TellSriIn(sri.value, userId, msg), s"remoteSocketIn:$tpe")
|
|
case RP.In.SetVersions(versions) =>
|
|
versions foreach { case (roomId, version) =>
|
|
rounds.tell(roomId, SetVersion(version))
|
|
}
|
|
case P.In.Ping(id) => send(P.Out.pong(id))
|
|
case P.In.WsBoot =>
|
|
logger.warn("Remote socket boot")
|
|
// schedule termination for all game ducts
|
|
// until players actually reconnect
|
|
rounds foreachKey { id =>
|
|
terminationDelay schedule Game.Id(id)
|
|
}
|
|
rounds.tellAll(RoundDuct.WsBoot)
|
|
}
|
|
|
|
private def finishRound(gameId: Game.Id): Unit =
|
|
rounds.terminate(gameId.value, _ ! RoundDuct.Stop)
|
|
|
|
private lazy val send: Sender = remoteSocketApi.makeSender("r-out", parallelism = 8)
|
|
|
|
private lazy val sendForGameId: Game.ID => String => Unit = gameId => msg => send.sticky(gameId, msg)
|
|
|
|
remoteSocketApi.subscribeRoundRobin("r-in", Protocol.In.reader, parallelism = 8)(
|
|
roundHandler orElse remoteSocketApi.baseHandler
|
|
) >>- send(P.Out.boot)
|
|
|
|
Bus.subscribeFun("tvSelect", "roundSocket", "tourStanding", "startGame", "finishGame") {
|
|
case TvSelect(gameId, speed, json) => sendForGameId(gameId)(Protocol.Out.tvSelect(gameId, speed, json))
|
|
case Tell(gameId, e @ BotConnected(color, v)) =>
|
|
rounds.tell(gameId, e)
|
|
sendForGameId(gameId)(Protocol.Out.botConnected(gameId, color, v))
|
|
case Tell(gameId, msg) => rounds.tell(gameId, msg)
|
|
case TellIfExists(gameId, msg) => rounds.tellIfPresent(gameId, msg)
|
|
case TellMany(gameIds, msg) => rounds.tellIds(gameIds, msg)
|
|
case TellAll(msg) => rounds.tellAll(msg)
|
|
case Exists(gameId, promise) => promise success rounds.exists(gameId)
|
|
case TourStanding(tourId, json) => send(Protocol.Out.tourStanding(tourId, json))
|
|
case lila.game.actorApi.StartGame(game) if game.hasClock =>
|
|
game.userIds.some.filter(_.nonEmpty) foreach { usersPlaying =>
|
|
sendForGameId(game.id)(Protocol.Out.startGame(usersPlaying))
|
|
}
|
|
case lila.game.actorApi.FinishGame(game, _, _) if game.hasClock =>
|
|
game.userIds.some.filter(_.nonEmpty) foreach { usersPlaying =>
|
|
sendForGameId(game.id)(Protocol.Out.finishGame(game.id, game.winnerColor, usersPlaying))
|
|
}
|
|
}
|
|
|
|
{
|
|
import lila.chat.actorApi._
|
|
Bus.subscribeFun(BusChan.Round.chan, BusChan.Global.chan) {
|
|
case ChatLine(Chat.Id(id), l) =>
|
|
val line = RoundLine(l, id endsWith "/w")
|
|
rounds.tellIfPresent(if (line.watcher) id take Game.gameIdSize else id, line)
|
|
case OnTimeout(Chat.Id(id), userId) =>
|
|
send(RP.Out.tellRoom(RoomId(id take Game.gameIdSize), makeMessage("chat_timeout", userId)))
|
|
case OnReinstate(Chat.Id(id), userId) =>
|
|
send(RP.Out.tellRoom(RoomId(id take Game.gameIdSize), makeMessage("chat_reinstate", userId)))
|
|
}
|
|
}
|
|
|
|
system.scheduler.scheduleWithFixedDelay(25 seconds, tickInterval) { () =>
|
|
rounds.tellAll(RoundDuct.Tick)
|
|
}
|
|
system.scheduler.scheduleWithFixedDelay(60 seconds, 60 seconds) { () =>
|
|
lila.mon.round.ductCount.update(rounds.size).unit
|
|
}
|
|
|
|
private val terminationDelay = new TerminationDelay(system.scheduler, 1 minute, finishRound)
|
|
}
|
|
|
|
object RoundSocket {
|
|
|
|
val tickSeconds = 5
|
|
val tickInterval = tickSeconds.seconds
|
|
val ragequitTimeout = 10.seconds
|
|
val disconnectTimeout = 40.seconds
|
|
|
|
def povDisconnectTimeout(pov: Pov): FiniteDuration =
|
|
disconnectTimeout * {
|
|
pov.game.speed match {
|
|
case Speed.Classical => 3
|
|
case Speed.Rapid => 2
|
|
case _ => 1
|
|
}
|
|
} / {
|
|
import chess.variant._
|
|
(pov.game.chess.board.materialImbalance, pov.game.variant) match {
|
|
case (_, Antichess | Crazyhouse | Horde) => 1
|
|
case (i, _) if (pov.color.white && i <= -4) || (pov.color.black && i >= 4) => 3
|
|
case _ => 1
|
|
}
|
|
} / {
|
|
if (pov.player.hasUser) 1 else 2
|
|
}
|
|
|
|
object Protocol {
|
|
|
|
object In {
|
|
|
|
case class PlayerOnlines(onlines: Iterable[(Game.Id, Option[RoomCrowd])]) extends P.In
|
|
case class PlayerDo(fullId: FullId, tpe: String) extends P.In
|
|
case class PlayerMove(fullId: FullId, uci: Uci, blur: Boolean, lag: MoveMetrics) extends P.In
|
|
case class PlayerChatSay(gameId: Game.Id, userIdOrColor: Either[User.ID, Color], msg: String)
|
|
extends P.In
|
|
case class WatcherChatSay(gameId: Game.Id, userId: User.ID, msg: String) extends P.In
|
|
case class Bye(fullId: FullId) extends P.In
|
|
case class HoldAlert(fullId: FullId, ip: IpAddress, mean: Int, sd: Int) extends P.In
|
|
case class Flag(gameId: Game.Id, color: Color, fromPlayerId: Option[PlayerId]) extends P.In
|
|
case class Berserk(gameId: Game.Id, userId: User.ID) extends P.In
|
|
case class SelfReport(fullId: FullId, ip: IpAddress, userId: Option[User.ID], name: String) extends P.In
|
|
|
|
val reader: P.In.Reader = raw =>
|
|
raw.path match {
|
|
case "r/ons" =>
|
|
PlayerOnlines {
|
|
P.In.commas(raw.args) map {
|
|
_ splitAt Game.gameIdSize match {
|
|
case (gameId, cs) =>
|
|
(
|
|
Game.Id(gameId),
|
|
if (cs.isEmpty) None else Some(RoomCrowd(cs(0) == '+', cs(1) == '+'))
|
|
)
|
|
}
|
|
}
|
|
}.some
|
|
case "r/do" =>
|
|
raw.get(2) { case Array(fullId, payload) =>
|
|
for {
|
|
obj <- Json.parse(payload).asOpt[JsObject]
|
|
tpe <- obj str "t"
|
|
} yield PlayerDo(FullId(fullId), tpe)
|
|
}
|
|
case "r/move" =>
|
|
raw.get(5) { case Array(fullId, uciS, blurS, lagS, mtS) =>
|
|
Uci(uciS) map { uci =>
|
|
PlayerMove(FullId(fullId), uci, P.In.boolean(blurS), MoveMetrics(centis(lagS), centis(mtS)))
|
|
}
|
|
}
|
|
case "chat/say" =>
|
|
raw.get(3) { case Array(roomId, author, msg) =>
|
|
PlayerChatSay(Game.Id(roomId), readColor(author).toRight(author), msg).some
|
|
}
|
|
case "chat/say/w" =>
|
|
raw.get(3) { case Array(roomId, userId, msg) =>
|
|
WatcherChatSay(Game.Id(roomId), userId, msg).some
|
|
}
|
|
case "r/berserk" =>
|
|
raw.get(2) { case Array(gameId, userId) =>
|
|
Berserk(Game.Id(gameId), userId).some
|
|
}
|
|
case "r/bye" => Bye(Game.FullId(raw.args)).some
|
|
case "r/hold" =>
|
|
raw.get(4) { case Array(fullId, ip, meanS, sdS) =>
|
|
for {
|
|
mean <- meanS.toIntOption
|
|
sd <- sdS.toIntOption
|
|
} yield HoldAlert(FullId(fullId), IpAddress(ip), mean, sd)
|
|
}
|
|
case "r/report" =>
|
|
raw.get(4) { case Array(fullId, ip, user, name) =>
|
|
SelfReport(FullId(fullId), IpAddress(ip), P.In.optional(user), name).some
|
|
}
|
|
case "r/flag" =>
|
|
raw.get(3) { case Array(gameId, color, playerId) =>
|
|
readColor(color) map {
|
|
Flag(Game.Id(gameId), _, P.In.optional(playerId) map PlayerId.apply)
|
|
}
|
|
}
|
|
case _ => RP.In.reader(raw)
|
|
}
|
|
|
|
private def centis(s: String): Option[Centis] =
|
|
if (s == "-") none
|
|
else s.toIntOption map Centis.apply
|
|
|
|
private def readColor(s: String) =
|
|
if (s == "w") Some(White)
|
|
else if (s == "b") Some(Black)
|
|
else None
|
|
}
|
|
|
|
object Out {
|
|
|
|
def resyncPlayer(fullId: FullId) = s"r/resync/player $fullId"
|
|
def gone(fullId: FullId, gone: Boolean) = s"r/gone $fullId ${P.Out.boolean(gone)}"
|
|
def goneIn(fullId: FullId, millis: Long) = {
|
|
val seconds = Math.ceil(millis / 1000d / tickSeconds).toInt * tickSeconds
|
|
s"r/goneIn $fullId $seconds"
|
|
}
|
|
|
|
def tellVersion(roomId: RoomId, version: SocketVersion, e: Event) = {
|
|
val flags = new StringBuilder(2)
|
|
if (e.watcher) flags += 's'
|
|
else if (e.owner) flags += 'p'
|
|
else
|
|
e.only.map(_.fold('w', 'b')).orElse {
|
|
e.moveBy.map(_.fold('W', 'B'))
|
|
} foreach flags.+=
|
|
if (e.troll) flags += 't'
|
|
if (flags.isEmpty) flags += '-'
|
|
s"r/ver $roomId $version $flags ${e.typ} ${e.data}"
|
|
}
|
|
|
|
def tvSelect(gameId: Game.ID, speed: chess.Speed, data: JsObject) =
|
|
s"tv/select $gameId ${speed.id} ${Json stringify data}"
|
|
|
|
def botConnected(gameId: Game.ID, color: Color, v: Boolean) =
|
|
s"r/bot/online $gameId ${P.Out.color(color)} ${P.Out.boolean(v)}"
|
|
|
|
def tourStanding(tourId: String, data: JsValue) =
|
|
s"r/tour/standing $tourId ${Json stringify data}"
|
|
|
|
def startGame(users: List[User.ID]) = s"r/start ${P.Out.commas(users)}"
|
|
def finishGame(gameId: Game.ID, winner: Option[Color], users: List[User.ID]) =
|
|
s"r/finish $gameId ${P.Out.color(winner)} ${P.Out.commas(users)}"
|
|
}
|
|
}
|
|
|
|
final private class TerminationDelay(
|
|
scheduler: Scheduler,
|
|
duration: FiniteDuration,
|
|
terminate: Game.Id => Unit
|
|
)(implicit ec: scala.concurrent.ExecutionContext) {
|
|
import java.util.concurrent.ConcurrentHashMap
|
|
|
|
private[this] val terminations = new ConcurrentHashMap[String, Cancellable](65536)
|
|
|
|
def schedule(gameId: Game.Id): Unit =
|
|
terminations
|
|
.compute(
|
|
gameId.value,
|
|
(id, canc) => {
|
|
Option(canc).foreach(_.cancel())
|
|
scheduler.scheduleOnce(duration) {
|
|
terminations remove id
|
|
terminate(Game.Id(id))
|
|
}
|
|
}
|
|
)
|
|
.unit
|
|
|
|
def cancel(gameId: Game.Id): Unit =
|
|
Option(terminations remove gameId.value).foreach(_.cancel())
|
|
}
|
|
}
|