lila/modules/tournament/src/main/TournamentApi.scala

483 lines
17 KiB
Scala

package lila.tournament
import akka.actor.{ Props, ActorRef, ActorSelection, ActorSystem }
import akka.pattern.{ ask, pipe }
import org.joda.time.DateTime
import play.api.libs.json._
import scala.concurrent.duration._
import actorApi._
import lila.common.Debouncer
import lila.game.{ Game, GameRepo, Pov }
import lila.hub.actorApi.lobby.ReloadTournaments
import lila.hub.actorApi.map.Tell
import lila.hub.actorApi.timeline.{ Propagate, TourJoin }
import lila.hub.Sequencer
import lila.round.actorApi.round.{ GoBerserk, AbortForce }
import lila.socket.actorApi.SendToFlag
import lila.user.{ User, UserRepo }
import makeTimeout.short
final class TournamentApi(
cached: Cached,
scheduleJsonView: ScheduleJsonView,
system: ActorSystem,
sequencers: ActorRef,
autoPairing: AutoPairing,
clearJsonViewCache: String => Unit,
clearWinnersCache: Tournament => Unit,
clearShieldCache: () => Unit,
renderer: ActorSelection,
timeline: ActorSelection,
socketHub: ActorRef,
site: ActorSelection,
lobby: ActorSelection,
roundMap: ActorRef,
trophyApi: lila.user.TrophyApi,
verify: Condition.Verify,
indexLeaderboard: Tournament => Funit,
asyncCache: lila.memo.AsyncCache.Builder,
lightUserApi: lila.user.LightUserApi
) {
private val bus = system.lilaBus
def createTournament(setup: TournamentSetup, me: User): Fu[Tournament] = {
val tour = Tournament.make(
by = Right(me),
name = DataForm.canPickName(me) ?? setup.name,
clock = setup.clockConfig,
minutes = setup.minutes,
waitMinutes = setup.waitMinutes,
mode = setup.realMode,
`private` = setup.isPrivate,
password = setup.password.ifTrue(setup.isPrivate),
system = System.Arena,
variant = setup.realVariant,
position = DataForm.startingPosition(setup.position, setup.realVariant)
)
if (tour.name != me.titleUsername && lila.common.LameName.anyNameButLichessIsOk(tour.name)) {
val msg = s"""@${me.username} created tournament "${tour.name} Arena" :kappa: https://lichess.org/tournament/${tour.id}"""
logger warn msg
bus.publish(lila.hub.actorApi.slack.Warning(msg), 'slack)
}
logger.info(s"Create $tour")
TournamentRepo.insert(tour) >>- join(tour.id, me, tour.password) inject tour
}
private[tournament] def createFromPlan(plan: Schedule.Plan): Funit = {
val minutes = Schedule durationFor plan.schedule
val tournament = plan build Tournament.schedule(plan.schedule, minutes)
logger.info(s"Create $tournament")
TournamentRepo.insert(tournament).void >>- publish()
}
private[tournament] def makePairings(oldTour: Tournament, users: WaitingUsers, startAt: Long): Unit = {
Sequencing(oldTour.id)(TournamentRepo.startedById) { tour =>
cached ranking tour flatMap { ranking =>
tour.system.pairingSystem.createPairings(tour, users, ranking).flatMap {
case Nil => funit
case pairings if nowMillis - startAt > 1200 =>
pairingLogger.warn(s"Give up making https://lichess.org/tournament/${tour.id} ${pairings.size} pairings in ${nowMillis - startAt}ms")
lila.mon.tournament.pairing.giveup()
funit
case pairings => UserRepo.idsMap(pairings.flatMap(_.users)) flatMap { users =>
pairings.map { pairing =>
PairingRepo.insert(pairing) >>
autoPairing(tour, pairing, users) addEffect { game =>
sendTo(tour.id, StartGame(game))
}
}.sequenceFu >> featureOneOf(tour, pairings, ranking) >>- {
lila.mon.tournament.pairing.create(pairings.size)
}
}
} >>- {
val time = nowMillis - startAt
lila.mon.tournament.pairing.createTime(time.toInt)
if (time > 100)
pairingLogger.debug(s"Done making https://lichess.org/tournament/${tour.id} in ${time}ms")
}
}
}
}
private def featureOneOf(tour: Tournament, pairings: Pairings, ranking: Ranking): Funit =
tour.featuredId.ifTrue(pairings.nonEmpty) ?? PairingRepo.byId map2
RankedPairing(ranking) map (_.flatten) flatMap { curOption =>
pairings.flatMap(RankedPairing(ranking)).sortBy(_.bestRank).headOption ?? { bestCandidate =>
def switch = TournamentRepo.setFeaturedGameId(tour.id, bestCandidate.pairing.gameId)
curOption.filter(_.pairing.playing) match {
case Some(current) if bestCandidate.bestRank < current.bestRank => switch
case Some(_) => funit
case _ => switch
}
}
}
def tourAndRanks(game: Game): Fu[Option[TourAndRanks]] = ~{
for {
tourId <- game.tournamentId
whiteId <- game.whitePlayer.userId
blackId <- game.blackPlayer.userId
} yield TournamentRepo byId tourId flatMap {
_ ?? { tour =>
cached ranking tour map { ranking =>
ranking.get(whiteId) |@| ranking.get(blackId) apply {
case (whiteR, blackR) => TourAndRanks(tour, whiteR + 1, blackR + 1)
}
}
}
}
}
def start(oldTour: Tournament): Unit =
Sequencing(oldTour.id)(TournamentRepo.createdById) { tour =>
TournamentRepo.setStatus(tour.id, Status.Started) >>-
sendTo(tour.id, Reload) >>-
publish()
}
def wipe(tour: Tournament): Funit =
TournamentRepo.remove(tour).void >>
PairingRepo.removeByTour(tour.id) >>
PlayerRepo.removeByTour(tour.id) >>- publish() >>- socketReload(tour.id)
def finish(oldTour: Tournament): Unit = {
Sequencing(oldTour.id)(TournamentRepo.startedById) { tour =>
PairingRepo count tour.id flatMap {
case 0 => wipe(tour)
case _ => for {
_ <- TournamentRepo.setStatus(tour.id, Status.Finished)
_ <- PlayerRepo unWithdraw tour.id
_ <- PairingRepo removePlaying tour.id
winner <- PlayerRepo winner tour.id
_ <- winner.??(p => TournamentRepo.setWinnerId(tour.id, p.userId))
} yield {
clearJsonViewCache(tour.id)
sendTo(tour.id, Reload)
publish()
PlayerRepo withPoints tour.id foreach {
_ foreach { p => UserRepo.incToints(p.userId, p.score) }
}
awardTrophies(tour)
indexLeaderboard(tour)
clearWinnersCache(tour)
clearShieldCache()
}
}
}
}
def kill(tour: Tournament): Unit = {
if (tour.isStarted) finish(tour)
else if (tour.isCreated) wipe(tour)
}
private def awardTrophies(tour: Tournament): Funit =
tour.schedule.??(_.freq == Schedule.Freq.Marathon) ?? {
PlayerRepo.bestByTourWithRank(tour.id, 100).flatMap {
_.map {
case rp if rp.rank == 1 => trophyApi.award(rp.player.userId, _.MarathonWinner)
case rp if rp.rank <= 10 => trophyApi.award(rp.player.userId, _.MarathonTopTen)
case rp if rp.rank <= 50 => trophyApi.award(rp.player.userId, _.MarathonTopFifty)
case rp => trophyApi.award(rp.player.userId, _.MarathonTopHundred)
}.sequenceFu.void
}
}
def verdicts(tour: Tournament, me: Option[User]): Fu[Condition.All.WithVerdicts] = me match {
case None => fuccess(tour.conditions.accepted)
case Some(user) => verify(tour.conditions, user)
}
def join(tourId: String, me: User, p: Option[String]): Unit = {
Sequencing(tourId)(TournamentRepo.enterableById) { tour =>
if (tour.password == p) {
verdicts(tour, me.some) flatMap {
_.accepted ?? {
PlayerRepo.join(tour.id, me, tour.perfLens) >> updateNbPlayers(tour.id) >>- {
withdrawOtherTournaments(tour.id, me.id)
socketReload(tour.id)
publish()
if (!tour.`private`) timeline ! {
Propagate(TourJoin(me.id, tour.id, tour.fullName)) toFollowersOf me.id
}
}
}
}
} else fuccess(socketReload(tour.id))
}
}
def joinWithResult(tourId: String, me: User, p: Option[String]): Fu[Boolean] = {
join(tourId, me, p)
// atrocious hack, because joining is fire and forget
akka.pattern.after(500 millis, system.scheduler) {
PlayerRepo.find(tourId, me.id) map { _ ?? (_.active) }
}
}
private def updateNbPlayers(tourId: String) =
PlayerRepo count tourId flatMap { TournamentRepo.setNbPlayers(tourId, _) }
private def withdrawOtherTournaments(tourId: String, userId: String): Unit = {
TournamentRepo toursToWithdrawWhenEntering tourId foreach {
_ foreach { other =>
PlayerRepo.exists(other.id, userId) foreach {
_ ?? withdraw(other.id, userId)
}
}
}
}
def withdraw(tourId: String, userId: String): Unit = {
Sequencing(tourId)(TournamentRepo.enterableById) {
case tour if tour.isCreated =>
PlayerRepo.remove(tour.id, userId) >> updateNbPlayers(tour.id) >>- socketReload(tour.id) >>- publish()
case tour if tour.isStarted =>
PlayerRepo.withdraw(tour.id, userId) >>- socketReload(tour.id) >>- publish()
case _ => funit
}
}
def withdrawAll(user: User): Unit = {
TournamentRepo.nonEmptyEnterable foreach {
_ foreach { tour =>
PlayerRepo.exists(tour.id, user.id) foreach {
_ ?? withdraw(tour.id, user.id)
}
}
}
}
def berserk(gameId: String, userId: String): Unit = {
GameRepo game gameId foreach {
_.flatMap(_.tournamentId) foreach { tourId =>
Sequencing(tourId)(TournamentRepo.startedById) { tour =>
PairingRepo.findPlaying(tour.id, userId) flatMap {
case Some(pairing) if !pairing.berserkOf(userId) =>
(pairing povRef userId) ?? { povRef =>
GameRepo pov povRef flatMap {
_.filter(_.game.berserkable) ?? { pov =>
PairingRepo.setBerserk(pairing, userId) >>- {
roundMap ! Tell(povRef.gameId, GoBerserk(povRef.color))
}
}
}
}
case _ => funit
}
}
}
}
}
def finishGame(game: Game): Unit = {
game.tournamentId foreach { tourId =>
Sequencing(tourId)(TournamentRepo.startedById) { tour =>
PairingRepo.finish(game) >>
game.userIds.map(updatePlayer(tour)).sequenceFu.void >>- {
socketReload(tour.id)
updateTournamentStanding(tour.id)
withdrawNonMover(game)
}
}
}
}
private def updatePlayer(tour: Tournament)(userId: String): Funit =
(tour.perfType.ifTrue(tour.mode.rated) ?? { UserRepo.perfOf(userId, _) }) flatMap { perf =>
PlayerRepo.update(tour.id, userId) { player =>
cached.sheet.update(tour, userId) map { sheet =>
player.copy(
score = sheet.total,
fire = sheet.onFire,
ratingDiff = perf.fold(player.ratingDiff)(_.intRating - player.rating),
provisional = perf.fold(player.provisional)(_.provisional)
).recomputeMagicScore
}
}
}
private def withdrawNonMover(game: Game): Unit = for {
tourId <- game.tournamentId
if game.status == chess.Status.NoStart
player <- game.playerWhoDidNotMove
userId <- player.userId
} withdraw(tourId, userId)
def pausePlaybanned(userId: String) =
TournamentRepo.started.flatMap {
_.map { tour =>
PlayerRepo.exists(tour.id, userId) flatMap {
_ ?? {
PlayerRepo.withdraw(tour.id, userId) >>- socketReload(tour.id) >>- publish()
}
}
}.sequenceFu.void
}
def ejectLame(userId: String): Unit = {
TournamentRepo.recentAndNext foreach {
_ foreach { tour =>
PlayerRepo.exists(tour.id, userId) foreach {
_ ?? ejectLame(tour.id, userId)
}
}
}
}
def ejectLame(tourId: String, userId: String): Unit = {
Sequencing(tourId)(TournamentRepo.byId) { tour =>
PlayerRepo.remove(tour.id, userId) >> {
if (tour.isStarted)
PairingRepo.findPlaying(tour.id, userId).map {
_ foreach { currentPairing =>
roundMap ! Tell(currentPairing.gameId, AbortForce)
}
} >> PairingRepo.opponentsOf(tour.id, userId).flatMap { uids =>
PairingRepo.removeByTourAndUserId(tour.id, userId) >>
lila.common.Future.applySequentially(uids.toList)(updatePlayer(tour))
}
else if (tour.isFinished && tour.winnerId.contains(userId))
PlayerRepo winner tour.id flatMap {
_ ?? { p =>
TournamentRepo.setWinnerId(tour.id, p.userId)
}
}
else funit
} >>
updateNbPlayers(tour.id) >>-
socketReload(tour.id) >>- publish()
}
}
private val tournamentTopCache = asyncCache.multi[Tournament.ID, TournamentTop](
name = "tournament.top",
id => PlayerRepo.bestByTour(id, 20) map TournamentTop.apply,
expireAfter = _.ExpireAfterWrite(3 second)
)
def tournamentTop(tourId: Tournament.ID): Fu[TournamentTop] =
tournamentTopCache get tourId
def miniView(tourId: Tournament.ID, withTop: Boolean): Fu[Option[TourMiniView]] =
TournamentRepo byId tourId flatMap {
_ ?? { tour =>
withTop ?? { tournamentTop(tour.id) map some } map { TourMiniView(tour, _).some }
}
}
def fetchVisibleTournaments: Fu[VisibleTournaments] =
TournamentRepo.publicCreatedSorted(6 * 60) zip
TournamentRepo.publicStarted zip
TournamentRepo.finishedNotable(30) map {
case ((created, started), finished) =>
VisibleTournaments(created, started, finished)
}
def playerInfo(tourId: String, userId: String): Fu[Option[PlayerInfoExt]] =
UserRepo named userId flatMap {
_ ?? { user =>
TournamentRepo byId tourId flatMap {
_ ?? { tour =>
PlayerRepo.find(tour.id, user.id) flatMap {
_ ?? { player =>
playerPovs(tour, user.id, 50) map { povs =>
PlayerInfoExt(tour, user, player, povs).some
}
}
}
}
}
}
}
def allCurrentLeadersInStandard: Fu[Map[Tournament, TournamentTop]] =
TournamentRepo.standardPublicStartedFromSecondary.flatMap { tours =>
tours.map { tour =>
tournamentTop(tour.id) map (tour -> _)
}.sequenceFu.map(_.toMap)
}
def calendar: Fu[List[Tournament]] = {
val from = DateTime.now.minusDays(1)
TournamentRepo.calendar(from = from, to = from plusYears 1)
}
private def fetchGames(tour: Tournament, ids: Seq[String]) =
if (tour.isFinished) GameRepo gamesFromSecondary ids
else GameRepo gamesFromPrimary ids
private def playerPovs(tour: Tournament, userId: String, nb: Int): Fu[List[Pov]] =
PairingRepo.recentIdsByTourAndUserId(tour.id, userId, nb) flatMap { ids =>
fetchGames(tour, ids) map {
_.flatMap { Pov.ofUserId(_, userId) }
}
}
private def sequence(tourId: String)(work: => Funit): Unit = {
sequencers ! Tell(tourId, Sequencer work work)
}
private def Sequencing(tourId: String)(fetch: String => Fu[Option[Tournament]])(run: Tournament => Funit): Unit = {
sequence(tourId) {
fetch(tourId) flatMap {
case Some(t) => run(t)
case None => fufail(s"Can't run sequenced operation on missing tournament $tourId")
}
}
}
private def socketReload(tourId: String): Unit = {
sendTo(tourId, Reload)
}
private object publish {
private val debouncer = system.actorOf(Props(new Debouncer(15 seconds, {
(_: Debouncer.Nothing) =>
fetchVisibleTournaments flatMap scheduleJsonView.apply foreach { json =>
site ! SendToFlag("tournament", Json.obj("t" -> "reload", "d" -> json))
}
TournamentRepo.promotable foreach { tours =>
renderer ? TournamentTable(tours) map {
case view: play.twirl.api.Html => ReloadTournaments(view.body)
} pipeToSelection lobby
}
})))
def apply(): Unit = { debouncer ! Debouncer.Nothing }
}
private object updateTournamentStanding {
import lila.hub.EarlyMultiThrottler
import com.github.blemale.scaffeine.{ Cache, Scaffeine }
// last published top hashCode
private val lastPublished: Cache[Tournament.ID, Int] = Scaffeine()
.expireAfterWrite(2 minute)
.build[Tournament.ID, Int]
private def publishNow(tourId: Tournament.ID) = tournamentTop(tourId) map { top =>
val lastHash: Int = ~lastPublished.getIfPresent(tourId)
if (lastHash != top.hashCode) bus.publish(
lila.hub.actorApi.round.TourStanding(JsonView.top(top, lightUserApi.sync)),
Symbol(s"tour-standing-$tourId")
)
lastPublished.put(tourId, top.hashCode)
}
private val throttler = system.actorOf(Props(new EarlyMultiThrottler(logger = logger)))
def apply(tourId: Tournament.ID): Unit =
throttler ! EarlyMultiThrottler.work(
id = tourId,
run = publishNow(tourId),
cooldown = 15.seconds
)
}
private def sendTo(tourId: String, msg: Any): Unit =
socketHub ! Tell(tourId, msg)
}