diff --git a/app/controllers/Tournament.scala b/app/controllers/Tournament.scala index 9be1afb1ba87..2550d8c01b82 100644 --- a/app/controllers/Tournament.scala +++ b/app/controllers/Tournament.scala @@ -175,7 +175,7 @@ final class Tournament(env: Env, apiC: => Api)(using akka.stream.Materializer) e private def doJoin(tourId: TourId, data: TournamentForm.TournamentJoin)(using me: Me) = data.team - .so { env.team.api.isGranted(_, me, _.Tour) } + .so(env.team.api.isGranted(_, me, _.Tour)) .flatMap: isLeader => api.joinWithResult(tourId, data = data, isLeader) diff --git a/modules/db/src/main/BSON.scala b/modules/db/src/main/BSON.scala index df08a9cbe1fe..f8cca26ad67b 100644 --- a/modules/db/src/main/BSON.scala +++ b/modules/db/src/main/BSON.scala @@ -27,10 +27,8 @@ abstract class BSONReadOnly[T] extends BSONDocumentReader[T]: def reads(reader: Reader): T - def readDocument(doc: Bdoc) = - Try { - reads(new Reader(doc)) - } + def readDocument(doc: Bdoc) = Try: + reads(new Reader(doc)) def read(doc: Bdoc) = readDocument(doc).get diff --git a/modules/gathering/src/main/ui/GatheringUi.scala b/modules/gathering/src/main/ui/GatheringUi.scala index d03deb693627..75cb46c0c58c 100644 --- a/modules/gathering/src/main/ui/GatheringUi.scala +++ b/modules/gathering/src/main/ui/GatheringUi.scala @@ -109,7 +109,7 @@ final class GatheringFormUi(helpers: Helpers): half = true ) - def bots(field: Field)(using Translate) = + def bots(field: Field, disabledAfterStart: Boolean)(using Translate) = form3.checkbox( field, "Allow bot accounts", @@ -118,5 +118,6 @@ final class GatheringFormUi(helpers: Helpers): a(href := "/@/lichess/blog/welcome-lichess-bots/WvDNticA")("bots"), " join the tournament and play with their engines." ).some, - half = true + half = true, + disabled = disabledAfterStart ) diff --git a/modules/tournament/src/main/BSONHandlers.scala b/modules/tournament/src/main/BSONHandlers.scala index a18c3c03f57c..f74e23681f73 100644 --- a/modules/tournament/src/main/BSONHandlers.scala +++ b/modules/tournament/src/main/BSONHandlers.scala @@ -117,7 +117,8 @@ object BSONHandlers: score = r.intD("s"), fire = r.boolD("f"), performance = r.getO[IntRating]("e"), - team = r.getO[TeamId]("t") + team = r.getO[TeamId]("t"), + bot = r.boolD("bot") ) def writes(w: BSON.Writer, o: Player) = $doc( @@ -131,7 +132,8 @@ object BSONHandlers: "m" -> o.magicScore, "f" -> w.boolO(o.fire), "e" -> o.performance, - "t" -> o.team + "t" -> o.team, + "bot" -> w.boolO(o.bot) ) given pairingHandler: BSON[Pairing] with diff --git a/modules/tournament/src/main/PairingRepo.scala b/modules/tournament/src/main/PairingRepo.scala index 832fafdbfb6a..289ae3d500c2 100644 --- a/modules/tournament/src/main/PairingRepo.scala +++ b/modules/tournament/src/main/PairingRepo.scala @@ -150,6 +150,9 @@ final class PairingRepo(coll: Coll)(using Executor, Materializer): def isPlaying(tourId: TourId, userId: UserId): Fu[Boolean] = coll.exists(selectTourUser(tourId, userId) ++ selectPlaying) + def playingUserIds(tourId: TourId): Fu[Set[UserId]] = + coll.distinctEasy[UserId, Set]("u", selectTour(tourId) ++ selectPlaying) + private[tournament] def finishedByPlayerChronological( tourId: TourId, userId: UserId diff --git a/modules/tournament/src/main/Player.scala b/modules/tournament/src/main/Player.scala index db373398ba5c..6fb62641ff62 100644 --- a/modules/tournament/src/main/Player.scala +++ b/modules/tournament/src/main/Player.scala @@ -17,17 +17,14 @@ case class Player( score: Int = 0, fire: Boolean = false, performance: Option[IntRating] = None, - team: Option[TeamId] = None + team: Option[TeamId] = None, + bot: Boolean = false ): inline def id = _id def active = !withdraw - def is(uid: UserId): Boolean = uid == userId - def is(user: User): Boolean = is(user.id) - def is(other: Player): Boolean = is(other.userId) - def doWithdraw = copy(withdraw = true) def unWithdraw = copy(withdraw = false) @@ -37,6 +34,8 @@ case class Player( object Player: + given UserIdOf[Player] = _.userId + case class WithUser(player: Player, user: User) case class Result(player: Player, lightUser: LightUser, rank: Int, sheet: Option[arena.Sheet]) @@ -44,12 +43,14 @@ object Player: private[tournament] def make( tourId: TourId, user: WithPerf, - team: Option[TeamId] + team: Option[TeamId], + bot: Boolean ): Player = Player( _id = TourPlayerId(ThreadLocalRandom.nextString(8)), tourId = tourId, userId = user.id, rating = user.perf.intRating, provisional = user.perf.provisional, - team = team + team = team, + bot = bot ) diff --git a/modules/tournament/src/main/PlayerRepo.scala b/modules/tournament/src/main/PlayerRepo.scala index 5db4caa6992d..318d5613a33a 100644 --- a/modules/tournament/src/main/PlayerRepo.scala +++ b/modules/tournament/src/main/PlayerRepo.scala @@ -19,6 +19,7 @@ final class PlayerRepo(private[tournament] val coll: Coll)(using Executor): "uid" -> userId ) private val selectActive = $doc("w".$ne(true)) + private val selectBot = $doc("bot" -> true) private val selectWithdraw = $doc("w" -> true) private val bestSort = $doc("m" -> -1) @@ -86,7 +87,7 @@ final class PlayerRepo(private[tournament] val coll: Coll)(using Executor): ) ) ) - .map { + .map: _.flatMap: doc => for teamId <- doc.getAsOpt[TeamId]("_id") @@ -99,8 +100,7 @@ final class PlayerRepo(private[tournament] val coll: Coll)(using Executor): yield new RankedTeam(0, teamId, leaders) .sorted.mapWithIndex: (rt, pos) => rt.updateRank(pos + 1) - } - .map { ranked => + .map: ranked => if ranked.sizeIs == battle.teams.size then ranked else ranked ::: battle.teams @@ -110,7 +110,6 @@ final class PlayerRepo(private[tournament] val coll: Coll)(using Executor): case (acc, _) => acc } .reverse - } // very expensive private[tournament] def teamInfo( @@ -215,18 +214,20 @@ final class PlayerRepo(private[tournament] val coll: Coll)(using Executor): ) = prev match case Some(p) if p.withdraw => coll.update.one($id(p._id), $unset("w")) case Some(_) => funit - case None => coll.insert.one(Player.make(tourId, user, team)) + case None => coll.insert.one(Player.make(tourId, user, team, user.user.isBot)) def withdraw(tourId: TourId, userId: UserId) = coll.update.one(selectTourUser(tourId, userId), $set("w" -> true)).void private[tournament] def withPoints(tourId: TourId): Fu[List[Player]] = - coll.list[Player]: - selectTour(tourId) ++ $doc("m".$gt(0)) + coll.list[Player](selectTour(tourId) ++ $doc("m".$gt(0))) private[tournament] def nbActivePlayers(tourId: TourId): Fu[Int] = coll.countSel(selectTour(tourId) ++ selectActive) + private[tournament] def activeBotIds(tourId: TourId): Fu[Set[UserId]] = + coll.distinctEasy[UserId, Set]("uid", selectTour(tourId) ++ selectActive ++ selectBot) + def winner(tourId: TourId): Fu[Option[Player]] = coll.find(selectTour(tourId)).sort(bestSort).one[Player] @@ -243,11 +244,7 @@ final class PlayerRepo(private[tournament] val coll: Coll)(using Executor): List( Match(selectTour(tourId)), Sort(Descending("m")), - Group(BSONNull)( - "all" -> Push( - $doc("$concat" -> $arr("$_id", "$uid")) - ) - ) + Group(BSONNull)("all" -> Push($doc("$concat" -> $arr("$_id", "$uid")))) ) .headOption .map: diff --git a/modules/tournament/src/main/StartedOrganizer.scala b/modules/tournament/src/main/StartedOrganizer.scala index 1b64c490bfaa..d3f9a5ab5fdc 100644 --- a/modules/tournament/src/main/StartedOrganizer.scala +++ b/modules/tournament/src/main/StartedOrganizer.scala @@ -8,6 +8,8 @@ final private class StartedOrganizer( api: TournamentApi, tournamentRepo: TournamentRepo, playerRepo: PlayerRepo, + pairingRepo: PairingRepo, + waitingUsersApi: WaitingUsersApi, socket: TournamentSocket )(using Executor, Scheduler, akka.stream.Materializer): @@ -50,9 +52,18 @@ final private class StartedOrganizer( private def startPairing(tour: Tournament, smallTourNbActivePlayers: Option[Int] = None): Funit = tour.pairingsClosed.not.so: - socket - .getWaitingUsers(tour) - .monSuccess(_.tournament.startedOrganizer.waitingUsers) - .flatMap: waiting => - lila.mon.tournament.waitingPlayers.record(waiting.size) - api.makePairings(tour, waiting, smallTourNbActivePlayers) + for + waitingBots <- fetchWaitingBots(tour) + _ = if waitingBots.nonEmpty then waitingUsersApi.addApiUsers(tour, waitingBots) + waiting <- socket.getWaitingUsers(tour).monSuccess(_.tournament.startedOrganizer.waitingUsers) + _ = waiting.hash + _ = lila.mon.tournament.waitingPlayers.record(waiting.size) + _ <- api.makePairings(tour, waiting, smallTourNbActivePlayers) + yield () + + private def fetchWaitingBots(tour: Tournament): Fu[Set[UserId]] = + tour.conditions.allowsBots.so: + for + activeBots <- playerRepo.activeBotIds(tour.id) + playingUsers <- activeBots.nonEmpty.so(pairingRepo.playingUserIds(tour.id)) + yield activeBots.diff(playingUsers) diff --git a/modules/tournament/src/main/TournamentApi.scala b/modules/tournament/src/main/TournamentApi.scala index 8f851ddcf7f2..c3caddcc2206 100644 --- a/modules/tournament/src/main/TournamentApi.scala +++ b/modules/tournament/src/main/TournamentApi.scala @@ -55,21 +55,16 @@ final class TournamentApi( andJoin: Boolean = true )(using me: Me): Fu[Tournament] = val tour = Tournament.fromSetup(setup) - (tournamentRepo.insert(tour) >> { - setup.teamBattleByTeam + for + _ <- tournamentRepo.insert(tour) + _ <- setup.teamBattleByTeam .orElse(tour.conditions.teamMember.map(_.teamId)) .so: teamId => tournamentRepo.setForTeam(tour.id, teamId).void - } >> { - (andJoin && !me.isBot && !me.lame).so( - join( - tour.id, - TournamentForm.TournamentJoin(setup.teamBattleByTeam, tour.password), - asLeader = false, - none - )(using _ => fuccess(leaderTeams.map(_.id))) - ) - }).inject(tour) + _ <- (andJoin && !me.isBot && !me.lame).so: + val req = TournamentForm.TournamentJoin(setup.teamBattleByTeam, tour.password) + join(tour.id, req, asLeader = false, none)(using _ => fuccess(leaderTeams.map(_.id))) + yield tour def update(old: Tournament, data: TournamentSetup): Fu[Tournament] = updateTour(old, data, data.updateAll(old)) @@ -90,6 +85,7 @@ final class TournamentApi( for _ <- tournamentRepo.update(finalized) _ <- ejectPlayersNonLongerOnAllowList(old, finalized) + _ <- ejectBotPlayersNonLongerAllowed(old, finalized) _ = cached.tourCache.clear(tour.id) yield finalized @@ -99,6 +95,13 @@ final class TournamentApi( withdraw(tour.id, _, false, false) } + private def ejectBotPlayersNonLongerAllowed(old: Tournament, tour: Tournament): Funit = + (tour.isCreated && old.conditions.allowsBots && !tour.conditions.allowsBots).so: + for + botIds <- playerRepo.activeBotIds(tour.id) + _ <- botIds.toList.sequentiallyVoid(withdraw(tour.id, _, false, false)) + yield () + def teamBattleUpdate( tour: Tournament, data: TeamBattle.DataForm.Setup, @@ -370,15 +373,14 @@ final class TournamentApi( def withdrawAll(user: User): Funit = tournamentRepo.withdrawableIds(user.id, reason = "withdrawAll").flatMap { - _.sequentiallyVoid { + _.sequentiallyVoid: withdraw(_, user.id, isPause = false, isStalling = false) - } } private[tournament] def berserk(gameId: GameId, userId: UserId): Funit = gameProxy.game(gameId).flatMap { _.filter(_.berserkable).so { game => - game.tournamentId.so { tourId => + game.tournamentId.so: tourId => pairingRepo.findPlaying(tourId, userId).flatMap { case Some(pairing) if !pairing.berserkOf(userId) => pairing.colorOf(userId).so { color => @@ -388,7 +390,6 @@ final class TournamentApi( } case _ => funit } - } } } @@ -407,30 +408,32 @@ final class TournamentApi( withdrawNonMover(game) private def updatePlayerAfterGame(tour: Tournament, game: Game, pairing: Pairing)(userId: UserId): Funit = - tour.mode.rated.so { userApi.perfOptionOf(userId, tour.perfType) }.flatMap { perf => - playerRepo.update(tour.id, userId): player => - for - sheet <- cached.sheet.addResult(tour, userId, pairing) - newPlayer = player.copy( - score = sheet.total, - fire = tour.streakable && sheet.isOnFire, - rating = perf.fold(player.rating)(_.intRating), - provisional = perf.fold(player.provisional)(_.provisional), - performance = { - for - performance <- performanceOf(game, userId).map(_.value.toDouble) - nbGames = sheet.scores.size - if nbGames > 0 - yield IntRating: - Math.round { - (player.performance.so(_.value) * (nbGames - 1) + performance) / nbGames - }.toInt - }.orElse(player.performance) - ) - _ = game.whitePlayer.userId.foreach: whiteUserId => - colorHistoryApi.inc(player.id, Color.fromWhite(player.is(whiteUserId))) - yield newPlayer - } + tour.mode.rated + .so: + userApi.perfOptionOf(userId, tour.perfType) + .flatMap: perf => + playerRepo.update(tour.id, userId): player => + for + sheet <- cached.sheet.addResult(tour, userId, pairing) + newPlayer = player.copy( + score = sheet.total, + fire = tour.streakable && sheet.isOnFire, + rating = perf.fold(player.rating)(_.intRating), + provisional = perf.fold(player.provisional)(_.provisional), + performance = { + for + performance <- performanceOf(game, userId).map(_.value.toDouble) + nbGames = sheet.scores.size + if nbGames > 0 + yield IntRating: + Math.round { + (player.performance.so(_.value) * (nbGames - 1) + performance) / nbGames + }.toInt + }.orElse(player.performance) + ) + _ = game.whitePlayer.userId.foreach: whiteUserId => + colorHistoryApi.inc(player.id, Color.fromWhite(player.is(whiteUserId))) + yield newPlayer private def performanceOf(g: Game, userId: UserId): Option[IntRating] = for opponent <- g.opponentOf(userId) @@ -453,7 +456,7 @@ final class TournamentApi( def isForBots(tourId: TourId): Fu[Boolean] = for tour <- cached.tourCache.byId(tourId) - yield tour.exists(_.conditions.bots.so(_.allowed)) + yield tour.exists(_.conditions.allowsBots) private[tournament] def kickFromTeam(teamId: TeamId, userId: UserId): Funit = tournamentRepo.withdrawableIds(userId, teamId = teamId.some, reason = "kickFromTeam").flatMap { @@ -490,7 +493,7 @@ final class TournamentApi( private def recomputePlayerAndSheet(tour: Tournament)(userId: UserId): Funit = tour.mode.rated.so { userApi.perfOptionOf(userId, tour.perfType) }.flatMap { perf => - playerRepo.update(tour.id, userId) { player => + playerRepo.update(tour.id, userId): player => cached.sheet.recompute(tour, userId).map { sheet => player.copy( score = sheet.total, @@ -499,7 +502,6 @@ final class TournamentApi( provisional = perf.fold(player.provisional)(_.provisional) ) } - } } private[tournament] def recomputeEntireTournament(id: TourId): Funit = @@ -521,25 +523,23 @@ final class TournamentApi( // erases player from tournament and reassigns winner private[tournament] def removePlayerAndRewriteHistory(tourId: TourId, userId: UserId): Funit = - Parallel(tourId, "removePlayerAndRewriteHistory")(tournamentRepo.finishedById) { tour => - playerRepo.remove(tourId, userId) >> { - tour.winnerId.contains(userId).so { + Parallel(tourId, "removePlayerAndRewriteHistory")(tournamentRepo.finishedById): tour => + for + _ <- playerRepo.remove(tourId, userId) + _ <- tour.winnerId.contains(userId).so { playerRepo.winner(tour.id).flatMapz { p => tournamentRepo.setWinnerId(tour.id, p.userId) } } - } - } + yield () private val tournamentTopNb = 20 - private val tournamentTopCache = cacheApi[TourId, TournamentTop](16, "tournament.top") { + private val tournamentTopCache = cacheApi[TourId, TournamentTop](16, "tournament.top"): _.refreshAfterWrite(3.second) .expireAfterAccess(5.minutes) .maximumSize(64) - .buildAsyncFuture { id => + .buildAsyncFuture: id => playerRepo.bestByTour(id, tournamentTopNb).dmap(TournamentTop.apply) - } - } def tournamentTop(tourId: TourId): Fu[TournamentTop] = tournamentTopCache.get(tourId) diff --git a/modules/tournament/src/main/TournamentCondition.scala b/modules/tournament/src/main/TournamentCondition.scala index d8b66143275e..be713e2b9632 100644 --- a/modules/tournament/src/main/TournamentCondition.scala +++ b/modules/tournament/src/main/TournamentCondition.scala @@ -24,6 +24,8 @@ object TournamentCondition: List(nbRatedGame, maxRating, minRating, titled, teamMember, accountAge, allowList, bots) ): + private def listWithBots = if bots.isDefined then list else Bots(false) :: list + def withVerdicts(perfType: PerfType)(using Me, Perf, @@ -32,7 +34,7 @@ object TournamentCondition: GetMyTeamIds, GetAge ): Fu[WithVerdicts] = - list + listWithBots .parallel: case c: MaxRating => c(perfType).map(c.withVerdict) case c: FlatCond => fuccess(c.withVerdict(c(perfType))) @@ -45,7 +47,7 @@ object TournamentCondition: ex: Executor, getMyTeamIds: GetMyTeamIds ): Fu[WithVerdicts] = - list + listWithBots .parallel: case c: TeamMember => c.apply.map { c.withVerdict(_) } case c => fuccess(WithVerdict(c, Accepted)) @@ -62,6 +64,8 @@ object TournamentCondition: .so: current => prev.allowList.so(_.userIds.diff(current)) + def allowsBots = bots.exists(_.allowed) + object All: val empty = All(none, none, none, none, none, none, none, none) given zero: Zero[All] = Zero(empty) diff --git a/modules/tournament/src/main/TournamentForm.scala b/modules/tournament/src/main/TournamentForm.scala index 98aebd4ccf00..6d262bb27515 100644 --- a/modules/tournament/src/main/TournamentForm.scala +++ b/modules/tournament/src/main/TournamentForm.scala @@ -75,6 +75,10 @@ final class TournamentForm: "Can't change time control after players have joined", _.speed == tour.speed || tour.nbPlayers == 0 ) + .verifying( + "Can't change bot entry condition after the tournament started", + _.conditions.allowsBots == tour.conditions.allowsBots || tour.isCreated + ) private def makeMapping(leaderTeams: List[LightTeam], prev: Option[Tournament])(using me: Me) = val manager = Granter(_.ManageTournament) @@ -162,8 +166,7 @@ private[tournament] case class TournamentSetup( def validClock = (clockTime + clockIncrement.value) > 0 - def validClockForBots = !conditions.bots.exists(_.allowed) || - lila.core.game.isBotCompatible(clockConfig) + def validClockForBots = !conditions.allowsBots || lila.core.game.isBotCompatible(clockConfig) def realMode = if realPosition.isDefined && !thematicPosition then Mode.Casual diff --git a/modules/tournament/src/main/WaitingUsers.scala b/modules/tournament/src/main/WaitingUsers.scala index a915ca132f2e..f654c4c32866 100644 --- a/modules/tournament/src/main/WaitingUsers.scala +++ b/modules/tournament/src/main/WaitingUsers.scala @@ -40,13 +40,13 @@ private case class WaitingUsers( } def update(fromWebSocket: Set[UserId]) = - val newDate = nowInstant - val withApiUsers = fromWebSocket ++ apiUsers.so(_.keySet) + val newDate = nowInstant + val all = fromWebSocket ++ apiUsers.so(_.keySet) copy( date = newDate, hash = { - hash.view.filterKeys(withApiUsers.contains) ++ - withApiUsers.filterNot(hash.contains).map { _ -> newDate } + hash.view.filterKeys(all.contains) ++ // remove gone users + all.filterNot(hash.contains).map { _ -> newDate } // add new users }.toMap ) @@ -57,6 +57,9 @@ private case class WaitingUsers( memo.put(userId) if apiUsers.isEmpty then copy(apiUsers = memo.some) else this + def addApiUsers(users: Set[UserId])(using Executor) = + users.foldLeft(this)(_.addApiUser(_)) + def removePairedUsers(us: Set[UserId]) = apiUsers.foreach(_.removeAll(us)) copy(hash = hash -- us) @@ -84,6 +87,9 @@ final private class WaitingUsersApi(using Executor): def addApiUser(tour: Tournament, user: User) = updateOrCreate(tour): w => w.copy(waiting = w.waiting.addApiUser(user.id)) + def addApiUsers(tour: Tournament, users: Set[UserId]) = updateOrCreate(tour): w => + w.copy(waiting = w.waiting.addApiUsers(users)) + def remove(id: TourId) = store.remove(id) private def updateOrCreate(tour: Tournament)(f: WaitingUsers.WithNext => WaitingUsers.WithNext) = diff --git a/modules/tournament/src/main/ui/TournamentForm.scala b/modules/tournament/src/main/ui/TournamentForm.scala index be699ab3a307..c99f154e30a2 100644 --- a/modules/tournament/src/main/ui/TournamentForm.scala +++ b/modules/tournament/src/main/ui/TournamentForm.scala @@ -129,6 +129,7 @@ final class TournamentForm(val helpers: Helpers, showUi: TournamentShow)( teams: List[LightTeam], tour: Option[Tournament] )(using ctx: Context)(using FormPrefix) = + val disabledAfterStart = tour.exists(!_.isCreated) form3.fieldset("Entry conditions", toggle = tour.exists(_.conditions.list.nonEmpty).some)( errMsg(form.prefix("conditions")), form3.split( @@ -157,7 +158,7 @@ final class TournamentForm(val helpers: Helpers, showUi: TournamentShow)( (ctx.me.exists(_.hasTitle) || Granter.opt(_.ManageTournament)).so { gatheringFormUi.titled(form.prefix("conditions.titled")) }, - gatheringFormUi.bots(form.prefix("conditions.bots")) + gatheringFormUi.bots(form.prefix("conditions.bots"), disabledAfterStart) ) )