diff --git a/modules/tournament/src/main/ArrangementRepo.scala b/modules/tournament/src/main/ArrangementRepo.scala index 7b65fcf95f8c3..6485a55d8a81b 100644 --- a/modules/tournament/src/main/ArrangementRepo.scala +++ b/modules/tournament/src/main/ArrangementRepo.scala @@ -45,9 +45,9 @@ final class ArrangementRepo(coll: Coll)(implicit def byId(id: Arrangement.ID): Fu[Option[Arrangement]] = coll.byId[Arrangement](id) def byLookup(lookup: Arrangement.Lookup): Fu[Option[Arrangement]] = - coll.one[Arrangement](selectTourUsers(lookup.tourId, lookup.users._1, lookup.users._2) ++ { - lookup.order.filter(_ > 0) ?? { case o => $doc("o" -> o) } - }) + coll.one[Arrangement]((lookup.id ?? { id => + $id(id) + }) ++ selectTourUsers(lookup.tourId, lookup.users._1, lookup.users._2)) def byGame(tourId: Tournament.ID, gameId: Game.ID): Fu[Option[Arrangement]] = coll.one[Arrangement](selectTourGame(tourId, gameId)) @@ -60,34 +60,30 @@ final class ArrangementRepo(coll: Coll)(implicit def removePlaying(tourId: Tournament.ID) = coll.delete.one(selectTour(tourId) ++ selectPlaying).void + def find(tourId: Tournament.ID, userId: User.ID): Fu[List[Arrangement]] = + coll.list[Arrangement](selectTourUser(tourId, userId)) + def findPlaying(tourId: Tournament.ID, userId: User.ID): Fu[List[Arrangement]] = coll.list[Arrangement](selectTourUser(tourId, userId) ++ selectPlaying) def isPlaying(tourId: Tournament.ID, userId: User.ID): Fu[Boolean] = coll.exists(selectTourUser(tourId, userId) ++ selectPlaying) + def countByTour(tourId: Tournament.ID): Fu[Int] = + coll.countSel(selectTour(tourId)) + def countWithGame(tourId: Tournament.ID): Fu[Int] = coll.countSel(selectTour(tourId) ++ selectWithGame) def update(arrangement: Arrangement): Funit = coll.update.one($id(arrangement.id), arrangement, upsert = true).void - def withGame(id: Arrangement.ID, gid: lila.game.Game.ID) = - coll.update - .one( - $id(id), - $set( - "g" -> gid - ) ++ $unset("r1", "r2", "d1", "d2", "ua") - ) - .void - def finish(g: lila.game.Game, arr: Arrangement) = if (g.aborted) coll.update .one( $id(arr.id), - $unset("g") + $unset("g", "st") ) .void else @@ -98,10 +94,12 @@ final class ArrangementRepo(coll: Coll)(implicit "s" -> g.status.id, "w" -> g.winnerUserId.map(_ == arr.user1.id), "p" -> g.plies - ) + ) ++ $unset("l") ) .void + def delete(id: Arrangement.ID) = coll.delete.one($id(id)).void + def removeByTour(tourId: Tournament.ID) = coll.delete.one(selectTour(tourId)).void private[tournament] def allUpcomingByUserIdChronological( diff --git a/modules/tournament/src/main/BSONHandlers.scala b/modules/tournament/src/main/BSONHandlers.scala index 88d0172f48d72..1b7c0f31a5db1 100644 --- a/modules/tournament/src/main/BSONHandlers.scala +++ b/modules/tournament/src/main/BSONHandlers.scala @@ -32,7 +32,7 @@ object BSONHandlers { Arrangement.Points(p(0), p(1), p(2)) } }, - points => BSONArray(points.lose, points.draw, points.win) + points => BSONArray(points.loss, points.draw, points.win) ) } @@ -176,6 +176,7 @@ object BSONHandlers { rating = r int "r", provisional = r boolD "pr", withdraw = r boolD "w", + kicked = r boolD "k", score = r intD "s", fire = r boolD "f", performance = r intD "e", @@ -190,6 +191,7 @@ object BSONHandlers { "r" -> o.rating, "pr" -> w.boolO(o.provisional), "w" -> w.boolO(o.withdraw), + "k" -> w.boolO(o.kicked), "s" -> w.intO(o.score), "m" -> o.magicScore, "f" -> w.boolO(o.fire), @@ -238,7 +240,6 @@ object BSONHandlers { val user2Id = users lift 1 err "tournament arrangement second user" Arrangement( id = r str "_id", - order = r intD "o", tourId = r str "t", user1 = Arrangement.User( id = user1Id, @@ -254,6 +255,7 @@ object BSONHandlers { color = r.getO[shogi.Color]("c"), points = r.getO[Arrangement.Points]("pt"), gameId = r strO "g", + startedAt = r dateO "st", status = r.intO("s") flatMap shogi.Status.apply, winner = r boolO "w" map { case true => user1Id @@ -261,13 +263,13 @@ object BSONHandlers { }, plies = r intO "p", scheduledAt = r dateO "d", + lockedScheduledAt = r boolD "l", history = Arrangement.History(r strsD "h") ) } def writes(w: BSON.Writer, o: Arrangement) = $doc( "_id" -> o.id, - "o" -> o.order.some.filter(_ > 0), "t" -> o.tourId, "u" -> BSONArray(o.user1.id, o.user2.id), "r1" -> o.user1.readyAt, @@ -278,11 +280,13 @@ object BSONHandlers { "c" -> o.color, "pt" -> o.points.filterNot(_ == Arrangement.Points.default), "g" -> o.gameId, + "st" -> o.startedAt, "s" -> o.status.map(_.id), "w" -> o.winner.map(o.user1 ==), "p" -> o.plies, "d" -> o.scheduledAt, "h" -> o.history.list, + "l" -> w.boolO(o.lockedScheduledAt), "ua" -> o.gameId.isEmpty ?? DateTime.now.some // updated at ) } diff --git a/modules/tournament/src/main/Cached.scala b/modules/tournament/src/main/Cached.scala index 11302ec23e8fc..02c8d91bfb15d 100644 --- a/modules/tournament/src/main/Cached.scala +++ b/modules/tournament/src/main/Cached.scala @@ -115,16 +115,18 @@ final private[tournament] class Cached( } } - private[tournament] object robin { + private[tournament] object arrangement { - private val playersCache = cacheApi[Tournament.ID, List[JsObject]](32, "tournament.robin.players") { - _.expireAfterWrite(5 minutes) - .buildAsyncFuture(computePlayers) - } - private val arrangementsCache = cacheApi[Tournament.ID, List[JsObject]](32, "tournament.robin.players") { - _.expireAfterWrite(3 minutes) - .buildAsyncFuture(computeArrangements) - } + private val playersCache = + cacheApi[Tournament.ID, List[JsObject]](32, "tournament.robin.players") { + _.expireAfterWrite(5 minutes) + .buildAsyncFuture(computePlayers) + } + private val arrangementsCache = + cacheApi[Tournament.ID, List[JsObject]](32, "tournament.arrangement.players") { + _.expireAfterWrite(3 minutes) + .buildAsyncFuture(computeArrangements) + } def apply(tourId: Tournament.ID) = for { @@ -145,7 +147,9 @@ final private[tournament] class Cached( playerRepo .allByTour(tourId) .flatMap( - _.sortBy(_.order.getOrElse(Int.MaxValue)).map(JsonView.tablePlayerJson(lightUserApi, _)).sequenceFu + _.sortBy(p => p.order.getOrElse(p.magicScore)) + .map(JsonView.arrangementPlayerJson(lightUserApi, _)) + .sequenceFu ) def computeArrangements(tourId: Tournament.ID): Fu[List[JsObject]] = diff --git a/modules/tournament/src/main/Condition.scala b/modules/tournament/src/main/Condition.scala index a3d9c236e1a5e..b19d467148f8c 100644 --- a/modules/tournament/src/main/Condition.scala +++ b/modules/tournament/src/main/Condition.scala @@ -244,11 +244,14 @@ object Condition { def apply(x: TeamMember): TeamMemberSetup = TeamMemberSetup(x.teamId.some) } val all = mapping( - "nbRatedGame" -> optional(nbRatedGame), - "maxRating" -> optional(maxRating), - "minRating" -> optional(minRating), - "titled" -> optional(boolean), - "teamMember" -> optional(teamMember) + "nbRatedGame" -> optional(nbRatedGame).transform[Option[lila.tournament.Condition.NbRatedGame]]( + _.filter(_.nb > 0), + identity + ), + "maxRating" -> optional(maxRating), + "minRating" -> optional(minRating), + "titled" -> optional(boolean), + "teamMember" -> optional(teamMember) )(AllSetup.apply)(AllSetup.unapply) .verifying("Invalid ratings", _.validRatings) diff --git a/modules/tournament/src/main/DataForm.scala b/modules/tournament/src/main/DataForm.scala index 9d7b0ffe36a6e..fdbf2ae04443e 100644 --- a/modules/tournament/src/main/DataForm.scala +++ b/modules/tournament/src/main/DataForm.scala @@ -23,7 +23,7 @@ final class DataForm { name = teamBattleId.isEmpty option user.titleUsername, format = (if (teamBattleId.isDefined) Format.Arena.key else Format.Robin.key).some, timeControlSetup = TimeControl.DataForm.Setup.default, - minutes = minuteDefault, + minutes = minutesDefault.some, startDate = none, finishDate = none, variant = shogi.variant.Standard.id.toString.some, @@ -45,7 +45,7 @@ final class DataForm { name = tour.name.some, format = tour.format.key.some, timeControlSetup = TimeControl.DataForm.Setup(tour.timeControl), - minutes = tour.minutes, + minutes = tour.minutes.some, startDate = tour.startsAt.some, finishDate = tour.finishesAt.some, variant = tour.variant.id.toString.some, @@ -104,9 +104,9 @@ final class DataForm { private def makeMapping(user: User) = mapping( "name" -> optional(nameType), - "format" -> optional(stringIn(DataForm.formats.toSet)), + "format" -> optional(stringIn(Format.all.map(_.key).toSet)), "timeControlSetup" -> TimeControl.DataForm.setup, - "minutes" -> { + "minutes" -> optional { if (lila.security.Granter(_.ManageTournament)(user)) number else numberIn(minutes) }, @@ -126,6 +126,7 @@ final class DataForm { "hasChat" -> optional(boolean) )(TournamentSetup.apply)(TournamentSetup.unapply) .verifying("Invalid starting position", _.validPosition) + .verifying("Provide valid duration", _.validMinutes) .verifying("End date needs to come at least 20 minutes after start date", _.validFinishDate) .verifying("Games with this time control cannot be rated", _.validRatedVariant) .verifying("Cannot have correspondence in arena format", _.validTimeControl) @@ -139,10 +140,8 @@ object DataForm { import shogi.variant._ - val formats = Format.all.map(_.key) - - val minutes = (20 to 60 by 5) ++ (70 to 120 by 10) ++ (150 to 360 by 30) ++ (420 to 600 by 60) :+ 720 - val minuteDefault = 45 + val minutes = (20 to 60 by 5) ++ (70 to 120 by 10) ++ (150 to 360 by 30) ++ (420 to 600 by 60) :+ 720 + val minutesDefault = 60 val validVariants = List(Standard, Minishogi, Chushogi, Annanshogi, Kyotoshogi, Checkshogi) @@ -157,7 +156,7 @@ private[tournament] case class TournamentSetup( name: Option[String], format: Option[String], timeControlSetup: TimeControl.DataForm.Setup, - minutes: Int, + minutes: Option[Int], startDate: Option[DateTime], finishDate: Option[DateTime], variant: Option[String], @@ -174,16 +173,6 @@ private[tournament] case class TournamentSetup( hasChat: Option[Boolean] ) { - def validPosition = position.fold(true) { sfen => - sfen.toSituation(realVariant).exists(_.playable(strict = true, withImpasse = true)) - } - - def validFinishDate = finishDate.fold(true) { d => - d.minusMinutes(20) isAfter (realStartDate) - } - - def validTimeControl = timeControlSetup.isRealTime || format != Format.Arena - def realMode = if (position.filterNot(_.initialOf(realVariant)).isDefined) Mode.Casual else Mode(rated.orElse(mode.map(Mode.Rated.id ===)) | true) @@ -194,12 +183,26 @@ private[tournament] case class TournamentSetup( def realStartDate = startDate.filter(_ isAfter DateTime.now).getOrElse(DateTime.now) - def realMinutes = finishDate.ifTrue(format != Format.Arena).map { fd => - ((fd.getMillis - realStartDate.getMillis) / 60000).toInt - } getOrElse minutes + def realMinutes = finishDate + .ifTrue(format != Format.Arena) + .map { fd => + ((fd.getMillis - realStartDate.getMillis) / 60000).toInt + } + .orElse(minutes) + .getOrElse(DataForm.minutesDefault) def speed = timeControlSetup.clock.fold[shogi.Speed](shogi.Speed.Correspondence)(shogi.Speed.apply) + def validPosition = position.fold(true) { sfen => + sfen.toSituation(realVariant).exists(_.playable(strict = true, withImpasse = true)) + } + + def validMinutes = minutes.isDefined || realFormat != Format.Arena + + def validFinishDate = finishDate.fold(realFormat == Format.Arena)(_.minusMinutes(20) isAfter realStartDate) + + def validTimeControl = timeControlSetup.isRealTime || format != Format.Arena + def validRatedVariant = realMode == Mode.Casual || lila.game.Game.allowRated(position, timeControlSetup.clock, realVariant) diff --git a/modules/tournament/src/main/Env.scala b/modules/tournament/src/main/Env.scala index d242d60106902..ec585d6ccbf45 100644 --- a/modules/tournament/src/main/Env.scala +++ b/modules/tournament/src/main/Env.scala @@ -33,6 +33,7 @@ final class Env( chatApi: lila.chat.ChatApi, tellRound: lila.round.TellRound, roundSocket: lila.round.RoundSocket, + // notifyApi: lila.notify.NotifyApi, lightUserApi: lila.user.LightUserApi, onStart: lila.round.OnStart, historyApi: lila.history.HistoryApi, diff --git a/modules/tournament/src/main/JsonView.scala b/modules/tournament/src/main/JsonView.scala index 57857efa79fed..3a0f4b40ce28c 100644 --- a/modules/tournament/src/main/JsonView.scala +++ b/modules/tournament/src/main/JsonView.scala @@ -21,6 +21,7 @@ final class JsonView( lightUserApi: LightUserApi, playerRepo: PlayerRepo, pairingRepo: PairingRepo, + arrangementRepo: ArrangementRepo, tournamentRepo: TournamentRepo, cached: Cached, statsApi: TournamentStatsApi, @@ -34,6 +35,7 @@ final class JsonView( )(implicit ec: ExecutionContext) { import JsonView._ + import lila.common.LightUser.lightUserWrites private case class CachableData( duels: JsArray, @@ -65,7 +67,7 @@ final class JsonView( case (Some(i), _) => standingApi(tour, i.page) case _ => standingApi(tour, 1) } - else cached.robin(tour.id)) + else cached.arrangement(tour.id)) playerInfoJson <- playerInfoExt ?? { pie => playerInfoExtended(tour, pie).map(_.some) } @@ -76,6 +78,15 @@ final class JsonView( case Some(user) => verify(tour.conditions, user, tour.perfType, getUserTeamIds) map some } } + isCreator = me.exists(_.id == tour.createdBy) + candidates <- + if (isCreator) + lightUserApi.asyncManyFallback(tour.candidates).map(Some.apply) + else fuccess(none) + denied <- + if (isCreator) + lightUserApi.asyncManyFallback(tour.denied).map(Some.apply) + else fuccess(none) stats <- statsApi(tour) shieldOwner <- full.?? { shieldApi currentOwner tour } teamsToJoinWith <- full.??(~(for { @@ -96,8 +107,10 @@ final class JsonView( .add("isRecentlyFinished" -> tour.isRecentlyFinished) .add("isClosed" -> tour.closed) .add("candidatesOnly" -> tour.candidatesOnly) - .add("candidates" -> me.exists(_.id == tour.createdBy) ?? tour.candidates.some) + .add("candidates" -> candidates) + .add("denied" -> denied) .add("isCandidate" -> me ?? (m => tour.candidates.contains(m.id))) + .add("isDenied" -> me ?? (m => tour.denied.contains(m.id))) .add("candidatesFull" -> tour.candidatesFull) .add("secondsToFinish" -> tour.isStarted.option(tour.secondsToFinish)) .add("secondsToStart" -> tour.isCreated.option(tour.secondsToStart)) @@ -152,8 +165,8 @@ final class JsonView( def clearCache(tour: Tournament): Unit = { standingApi clearCache tour cachableData invalidate tour.id - cached.robin invalidatePlayers tour.id - cached.robin invalidateArrangaments tour.id + cached.arrangement invalidatePlayers tour.id + cached.arrangement invalidateArrangaments tour.id } def fetchMyInfo(tour: Tournament, me: User): Fu[Option[MyInfo]] = @@ -178,6 +191,11 @@ final class JsonView( } def playerInfoExtended(tour: Tournament, info: PlayerInfoExt): Fu[JsObject] = + if (tour.isArena) + playerInfoExtendedArena(tour, info) + else playerInfoExtendedArrangements(tour, info) + + private def playerInfoExtendedArena(tour: Tournament, info: PlayerInfoExt): Fu[JsObject] = for { ranking <- cached ranking tour sheet <- cached.sheet(tour, info.userId) @@ -203,6 +221,7 @@ final class JsonView( .add("rank" -> ranking.get(user.id).map(1 +)) .add("provisional" -> player.provisional) .add("withdraw" -> player.withdraw) + .add("kicked" -> player.kicked) .add("team" -> player.team), "pairings" -> povScores.map { case (pov, score) => Json @@ -219,6 +238,57 @@ final class JsonView( ) } + private def playerInfoExtendedArrangements(tour: Tournament, info: PlayerInfoExt): Fu[JsObject] = + for { + ranking <- cached ranking tour + arrs <- arrangementRepo.find(tour.id, info.userId) + user <- lightUserApi.asyncFallback(info.userId) + } yield info match { + case PlayerInfoExt(_, player, povs) => + Json.obj( + "player" -> Json + .obj( + "id" -> user.id, + "name" -> user.name, + "rating" -> player.rating, + "score" -> player.score, + "fire" -> player.fire, + "nb" -> Json.obj( + "game" -> arrs.size, + "win" -> arrs.count(_.winner.exists(_ == user.id)) + ) + ) + .add("title" -> user.title) + .add("performance" -> player.performanceOption) + .add("rank" -> ranking.get(user.id).map(1 +)) + .add("provisional" -> player.provisional) + .add("withdraw" -> player.withdraw) + .add("kicked" -> player.kicked) + .add("team" -> player.team), + "arrangements" -> povs.map { + case pov => { + val points = (!pov.game.playable && tour.isOrganized) ?? { + arrs.find(a => a.gameId.exists(_ == pov.gameId)).flatMap(_.points) + } | Arrangement.Points.default + val score = pov.game.finished ?? { + if (~pov.win) points.win + else if (pov.game.winner.isEmpty) points.draw + else points.loss + } + Json + .obj( + "id" -> pov.gameId, + "color" -> pov.color.name, + "op" -> gameUserJson(pov.opponent.userId, pov.opponent.rating), + "win" -> pov.win, + "status" -> pov.game.status.id, + "score" -> score + ) + } + } + ) + } + private def fetchCurrentGameId(tour: Tournament, user: User): Fu[Option[Game.ID]] = if (tour.hasArrangements) fuccess(none) // we can get that from arrangs else if (Uptime.startedSinceSeconds(60)) fuccess(duelStore.find(tour, user)) @@ -262,7 +332,7 @@ final class JsonView( } } featured <- tour ?? fetchFeaturedGame - podium <- tour.exists(_.isFinished) ?? podiumJsonCache.get(id) + podium <- tour.exists(t => t.isFinished && t.isArena) ?? podiumJsonCache.get(id) } yield CachableData( duels = JsArray(jsonDuels), duelTeams = duelTeams, @@ -328,7 +398,7 @@ final class JsonView( top3.map { case rp @ RankedPlayer(_, player) => for { sheet <- cached.sheet(tour, player.userId) - json <- playerJson(lightUserApi, sheet.some, rp, tour.streakable) + json <- arenaPlayerJson(lightUserApi, sheet.some, rp, tour.streakable) } yield json ++ Json .obj( "nb" -> sheetNbs(sheet) @@ -477,14 +547,14 @@ object JsonView { .add("team" -> player.team) } - def playerJson( + def arenaPlayerJson( lightUserApi: LightUserApi, sheets: Map[String, arena.Sheet], streakable: Boolean )(rankedPlayer: RankedPlayer)(implicit ec: ExecutionContext): Fu[JsObject] = - playerJson(lightUserApi, sheets get rankedPlayer.player.userId, rankedPlayer, streakable) + arenaPlayerJson(lightUserApi, sheets get rankedPlayer.player.userId, rankedPlayer, streakable) - private[tournament] def playerJson( + private[tournament] def arenaPlayerJson( lightUserApi: LightUserApi, sheet: Option[arena.Sheet], rankedPlayer: RankedPlayer, @@ -503,6 +573,7 @@ object JsonView { .add("title" -> light.flatMap(_.title)) .add("provisional" -> p.provisional) .add("withdraw" -> p.withdraw) + .add("kicked" -> p.kicked) .add("team" -> p.team) } } @@ -519,21 +590,22 @@ object JsonView { if (score.flag == arena.Sheet.Normal) JsNumber(score.value) else Json.arr(score.value, score.flag.id) - private[tournament] def tablePlayerJson( + private[tournament] def arrangementPlayerJson( lightUserApi: LightUserApi, player: Player )(implicit ec: ExecutionContext): Fu[JsObject] = - lightUserApi async player.userId map { light => + lightUserApi asyncFallback player.userId map { light => Json .obj( "id" -> player.userId, - "name" -> light.fold(player.userId)(_.name), + "name" -> light.name, "order" -> ~player.order, "rating" -> player.rating, "score" -> player.score ) - .add("title" -> light.flatMap(_.title)) + .add("title" -> light.title) .add("provisional" -> player.provisional) + .add("kicked" -> player.kicked) .add("withdraw" -> player.withdraw) } @@ -543,11 +615,11 @@ object JsonView { "user1" -> arrangementUser(a.user1), "user2" -> arrangementUser(a.user2) ) - .add("order", a.order.some.filter(_ > 0)) .add("name", a.name) .add("color", a.color.map(_.name)) .add("points", a.points.map(arrangementPoints)) .add("gameId", a.gameId) + .add("startedAt", a.startedAt) .add("status", a.status.map(_.id)) .add("winner", a.winner) .add("plies", a.plies) @@ -569,7 +641,7 @@ object JsonView { .obj( "w" -> pts.win, "d" -> pts.draw, - "l" -> pts.lose + "l" -> pts.loss ) private[tournament] def scheduleJson(s: Schedule) = @@ -608,6 +680,8 @@ object JsonView { ) } + implicit val lightUserSeqWrites: Writes[Seq[lila.common.LightUser]] = Writes.seq[lila.common.LightUser] + implicit private[tournament] val spotlightWrites: OWrites[Spotlight] = OWrites { s => Json .obj( diff --git a/modules/tournament/src/main/Player.scala b/modules/tournament/src/main/Player.scala index 5009966427896..cf047e944a6fc 100644 --- a/modules/tournament/src/main/Player.scala +++ b/modules/tournament/src/main/Player.scala @@ -13,6 +13,7 @@ private[tournament] case class Player( rating: Int, provisional: Boolean, withdraw: Boolean = false, + kicked: Boolean = false, score: Int = 0, fire: Boolean = false, performance: Int = 0, @@ -27,10 +28,7 @@ private[tournament] case class Player( 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) - - def magicScore = score * 10000 + (order | (performanceOption | rating)) + def magicScore = (score * 10000 + (order | (performanceOption | rating))) * (if (kicked) 0 else 1) def performanceOption = performance > 0 option performance } diff --git a/modules/tournament/src/main/PlayerRepo.scala b/modules/tournament/src/main/PlayerRepo.scala index 410298aedd4b6..21e256e274c86 100644 --- a/modules/tournament/src/main/PlayerRepo.scala +++ b/modules/tournament/src/main/PlayerRepo.scala @@ -204,6 +204,17 @@ final class PlayerRepo(coll: Coll)(implicit ec: scala.concurrent.ExecutionContex def find(tourId: Tournament.ID, userId: User.ID): Fu[Option[Player]] = coll.ext.find(selectTourUser(tourId, userId)).one[Player] + def findActiveTuple2(tourId: Tournament.ID, userIds: (User.ID, User.ID)): Fu[Option[(Player, Player)]] = + coll.list[Player]( + selectTour(tourId) ++ $doc("uid" $in List(userIds._1, userIds._2)) ++ selectActive, + 2 + ) map { players => + players match { + case List(p1, p2) => (p1, p2).some + case _ => none + } + } + def update(tourId: Tournament.ID, userId: User.ID)(f: Player => Fu[Player]) = find(tourId, userId) orFail s"No such player: $tourId/$userId" flatMap f flatMap { player => coll.update.one($id(player._id), player).void @@ -220,11 +231,13 @@ final class PlayerRepo(coll: Coll)(implicit ec: scala.concurrent.ExecutionContex useOrder: Boolean ) = find(tourId, user.id) flatMap { - case Some(p) if p.withdraw => coll.update.one($id(p._id), $unset("w")) + case Some(p) if p.withdraw => coll.update.one($id(p._id), $unset("w", "k")) case Some(_) => funit case None if useOrder => findLastJoinedOrder(tourId) flatMap { orderOpt => - coll.insert.one(Player.make(tourId, user, perfType, team, orderOpt.map(_ + 1).orElse(0.some))) + coll.insert.one( + Player.make(tourId, user, perfType, team, orderOpt.map(_ + 1).orElse(0.some).pp("join - last")) + ) } case None => coll.insert.one(Player.make(tourId, user, perfType, team, none)) } void @@ -232,6 +245,9 @@ final class PlayerRepo(coll: Coll)(implicit ec: scala.concurrent.ExecutionContex def withdraw(tourId: Tournament.ID, userId: User.ID) = coll.update.one(selectTourUser(tourId, userId), $set("w" -> true)).void + def kick(tourId: Tournament.ID, userId: User.ID) = + coll.update.one(selectTourUser(tourId, userId), $set("k" -> true, "w" -> true)).void + private[tournament] def withPoints(tourId: Tournament.ID): Fu[List[Player]] = coll.list[Player]( selectTour(tourId) ++ $doc("m" $gt 0) diff --git a/modules/tournament/src/main/StartedOrganizer.scala b/modules/tournament/src/main/StartedOrganizer.scala index 5150038124f3b..bb0503386ed9f 100644 --- a/modules/tournament/src/main/StartedOrganizer.scala +++ b/modules/tournament/src/main/StartedOrganizer.scala @@ -3,7 +3,6 @@ package lila.tournament import akka.actor._ import akka.stream.scaladsl._ import scala.concurrent.duration._ -import lila.common.ThreadLocalRandom final private class StartedOrganizer( api: TournamentApi, @@ -56,8 +55,11 @@ final private class StartedOrganizer( private def processTour(tour: Tournament): Fu[Int] = if (tour.secondsToFinish <= 0) api finish tour inject 0 - else if (tour.nbPlayers < 2) fuccess(0) - else if (tour.nbPlayers < 30 && ThreadLocalRandom.nextInt(10) == 0) { + else if (api.killSchedule contains tour.id) { + api.killSchedule remove tour.id + api finish tour inject 0 + } else if (tour.nbPlayers < 2) fuccess(0) + else if (tour.nbPlayers < 30) { playerRepo nbActivePlayers tour.id flatMap { nb => (nb >= 2) ?? startPairing(tour, nb.some) } diff --git a/modules/tournament/src/main/Tournament.scala b/modules/tournament/src/main/Tournament.scala index 760db98ecf017..9a97c79e3a673 100644 --- a/modules/tournament/src/main/Tournament.scala +++ b/modules/tournament/src/main/Tournament.scala @@ -207,7 +207,7 @@ object Tournament { noBerserk = !berserkable, noStreak = !streakable, schedule = None, - startsAt = startDate plusSeconds ThreadLocalRandom.nextInt(60), + startsAt = startDate, description = description, hasChat = hasChat ) @@ -228,7 +228,7 @@ object Tournament { mode = Mode.Rated, conditions = sched.conditions, schedule = Some(sched), - startsAt = sched.at plusSeconds ThreadLocalRandom.nextInt(60) + startsAt = sched.at ) def tournamentUrl(tourId: String): String = s"https://lishogi.org/tournament/$tourId" diff --git a/modules/tournament/src/main/TournamentApi.scala b/modules/tournament/src/main/TournamentApi.scala index df2db519c0ecd..d2958dd1a9493 100644 --- a/modules/tournament/src/main/TournamentApi.scala +++ b/modules/tournament/src/main/TournamentApi.scala @@ -64,7 +64,7 @@ final class TournamentApi( me: User, myTeams: List[LightTeam], getUserTeamIds: User.ID => Fu[List[TeamID]], - andJoin: Boolean = true // ? + andJoin: Boolean = true ): Fu[Tournament] = { val tour = Tournament.make( by = Right(me), @@ -154,7 +154,7 @@ final class TournamentApi( users: WaitingUsers, smallTourNbActivePlayers: Option[Int] ): Funit = - (users.size > 1 && ( + (users.size > 1 && forTour.isArena && ( !hadPairings.get(forTour.id) || users.haveWaitedEnough || smallTourNbActivePlayers.exists(_ <= users.size * 1.5) @@ -228,6 +228,7 @@ final class TournamentApi( case _ => for { _ <- tournamentRepo.setStatus(tour.id, Status.Finished) + _ <- tour.candidates.nonEmpty ?? tournamentRepo.clearCandidates(tour.id) _ <- playerRepo unWithdraw tour.id _ <- if (tour.isArena) pairingRepo.removePlaying(tour.id) else arrangementRepo.removePlaying(tour.id) @@ -251,8 +252,10 @@ final class TournamentApi( } } + private[tournament] val killSchedule = scala.collection.mutable.Set.empty[Tournament.ID] + def kill(tour: Tournament): Funit = { - if (tour.isStarted) finish(tour) + if (tour.isStarted) fuccess(killSchedule add tour.id).void else if (tour.isCreated) destroy(tour) else funit } @@ -325,7 +328,7 @@ final class TournamentApi( denied = tour.denied.filterNot(_ == user.id), tour.nbPlayers + 1 ) >>- { - if (tour.hasArrangements) cached.robin.invalidatePlayers(tour.id) + if (tour.hasArrangements) cached.arrangement.invalidatePlayers(tour.id) socket.foreach(_.reload(tour.id)) } else @@ -353,8 +356,8 @@ final class TournamentApi( playerRepo.exists(tour.id, me.id) flatMap { playerExists => val fuJoined = if ( - (tour.password == password && !tour.closed && tour.notFull && !tour.denied.contains(me.id)) || - playerExists + ((tour.password == password && !tour.closed && tour.notFull) || playerExists) && + !tour.denied.contains(me.id) ) { verdicts(tour, me.some, getUserTeamIds) flatMap { _.accepted ?? { @@ -376,11 +379,11 @@ final class TournamentApi( useOrder = tour.isRobin ) >> updateNbPlayers(tour.id) >>- { - if (tour.hasArrangements) cached.robin.invalidatePlayers(tour.id) + if (tour.hasArrangements) cached.arrangement.invalidatePlayers(tour.id) socket.foreach(_.reload(tour.id)) publish() } inject true - if (tour.candidatesOnly && !playerExists) proceedAsCandidate + if (tour.candidatesOnly && !playerExists && me.id != tour.createdBy) proceedAsCandidate else withTeamId match { case None if tour.isTeamBattle => playerExists ?? proceedWithTeam(none) @@ -429,15 +432,24 @@ final class TournamentApi( userId: User.ID, join: Boolean ): Funit = - ArrangementUpdate(lookup, userId, (_, arr) => (!arr.hasGame && arr.hasUser(userId))) { - (tour, arrangement) => - if (join && arrangement.opponentIsReady(userId, maxSeconds = 20)) { - makeManualPairings(tour, arrangement) - } else { - val updated = arrangement.setReadyAt(userId, join ?? DateTime.now.some) - arrangementRepo.update(updated) >>- cached.robin.invalidateArrangaments(arrangement.tourId) >>- - socket.foreach(_.arrangementChange(updated)) - } + ArrangementUpdate( + lookup, + userId, + (tour, arr) => + ( + tour.isStarted && + !arr.hasGame && + arr.hasUser(userId) + && arr.canGameStart + ) + ) { (tour, arrangement) => + if (join && arrangement.opponentIsReady(userId, maxSeconds = 20)) { + makeManualPairings(tour, arrangement) + } else { + val updated = arrangement.setReadyAt(userId, join ?? DateTime.now.some) + arrangementRepo.update(updated) >>- cached.arrangement.invalidateArrangaments(arrangement.tourId) >>- + socket.foreach(_.arrangementChange(updated)) + } } private def makePlayerMap(tourId: Tournament.ID, users: List[User.ID]): Fu[Map[User.ID, Player]] = @@ -456,16 +468,23 @@ final class TournamentApi( arrangement: Arrangement ): Funit = makePlayerMap(tour.id, arrangement.userIds) - .mon(_.tournament.robin.createPlayerMap) + .mon(_.tournament.arrangement.createPlayerMap) .flatMap { playersMap => autoPairing(tour, arrangement, playersMap) flatMap { game => - (arrangementRepo.withGame(arrangement.id, game.id) >>- - socket.foreach(_.startGame(tour.id, game))) >> + arrangementRepo.update( + arrangement.startGame( + game.id, + if (game.sentePlayer.userId.exists(_ == arrangement.user1.id)) shogi.Color.Sente + else shogi.Color.Gote + ) + ) >>- + socket.foreach(_.startGame(tour.id, game)) >>- featureOneOf(tour, game.id, playersMap.values) - .mon(_.tournament.robin.createFeature) + .mon(_.tournament.arrangement.createFeature) + .unit } } - .monSuccess(_.tournament.robin.create) + .monSuccess(_.tournament.arrangement.create) .chronometer .logIfSlow(75, logger)(_ => s"Robin pairing for https://lishogi.org/tournament/${tour.id}") .result @@ -493,9 +512,15 @@ final class TournamentApi( userId: User.ID, dateTime: Option[DateTime] ): Funit = - ArrangementUpdate(lookup, userId, (_, arr) => (!arr.hasGame && arr.hasUser(userId))) { (_, arrangement) => + ArrangementUpdate( + lookup, + userId, + (_, arr) => (!arr.hasGame && arr.hasUser(userId) && !arr.lockedScheduledAt) + ) { (_, arrangement) => val updated = arrangement.setScheduledAt(userId, dateTime) - arrangementRepo.update(updated) >>- cached.robin.invalidateArrangaments(arrangement.tourId) >>- socket + arrangementRepo.update(updated) >>- cached.arrangement.invalidateArrangaments( + arrangement.tourId + ) >>- socket .foreach(_.arrangementChange(updated)) } @@ -507,10 +532,23 @@ final class TournamentApi( ArrangementUpdate(lookup, userId, (tour, _) => (tour.createdBy == userId && tour.isOrganized)) { (_, arrangement) => val updated = arrangement.setSettings(settings) - arrangementRepo.update(updated) >>- cached.robin.invalidateArrangaments(arrangement.tourId) >>- socket + arrangementRepo.update(updated) >>- cached.arrangement.invalidateArrangaments( + arrangement.tourId + ) >>- socket .foreach(_.arrangementChange(updated)) } + def arrangementDelete( + lookup: Arrangement.Lookup, + userId: User.ID + ): Funit = + ArrangementUpdate(lookup, userId, (tour, _) => (tour.createdBy == userId && tour.isOrganized)) { + (_, arrangement) => + arrangementRepo.delete(arrangement.id) >>- + cached.arrangement.invalidateArrangaments(arrangement.tourId) >>- + socket.foreach(_.reload(arrangement.tourId)) + } + def pageOf(tour: Tournament, userId: User.ID): Fu[Option[Int]] = cached ranking tour map { _ get userId map { rank => @@ -522,12 +560,18 @@ final class TournamentApi( playerRepo count tourId flatMap { tournamentRepo.setNbPlayers(tourId, _) } def selfPause(tourId: Tournament.ID, userId: User.ID): Funit = - withdraw(tourId, userId, isPause = true, isStalling = false) + withdraw(tourId, userId, isPause = true, isStalling = false, isForced = false) private def stallPause(tourId: Tournament.ID, userId: User.ID): Funit = - withdraw(tourId, userId, isPause = false, isStalling = true) + withdraw(tourId, userId, isPause = false, isStalling = true, isForced = false) - private def withdraw(tourId: Tournament.ID, userId: User.ID, isPause: Boolean, isStalling: Boolean): Funit = + private def withdraw( + tourId: Tournament.ID, + userId: User.ID, + isPause: Boolean, + isStalling: Boolean, + isForced: Boolean + ): Funit = Sequencing(tourId)(tournamentRepo.enterableById) { case tour if tour.candidates.contains(userId) => tournamentRepo.setCandidates(tour.id, tour.candidates.filterNot(_ == userId)) >>- socket.foreach( @@ -537,7 +581,7 @@ final class TournamentApi( playerRepo.remove(tour.id, userId) >> updateNbPlayers(tour.id) >>- socket.foreach( _.reload(tour.id) ) >>- publish() - case tour if tour.isStarted => + case tour if (tour.isStarted && (tour.isArena || isForced)) => for { _ <- playerRepo.withdraw(tour.id, userId) pausable <- @@ -555,7 +599,7 @@ final class TournamentApi( def withdrawAll(user: User): Funit = tournamentRepo.withdrawableIds(user.id) flatMap { _.map { - withdraw(_, user.id, isPause = false, isStalling = false) + withdraw(_, user.id, isPause = false, isStalling = false, isForced = true) }.sequenceFu.void } @@ -586,10 +630,10 @@ final class TournamentApi( _ ?? { arr => arrangementRepo.finish(game, arr) >> game.userIds.map(updateArrangementPlayer(tour, game.some, arr)).sequenceFu.void >>- { - cached.robin.invalidateArrangaments(tourId) + cached.arrangement.invalidateArrangaments(tourId) + cached.arrangement.invalidatePlayers(tourId) socket.foreach(_.reload(tour.id)) updateTournamentStanding(tour) - withdrawNonMover(game) } } } @@ -606,7 +650,7 @@ final class TournamentApi( } private[tournament] def sittingDetected(game: Game, player: User.ID): Funit = - game.tournamentId ?? { stallPause(_, player) } + game.tournamentId.ifTrue(game.arrangementId.isEmpty) ?? { stallPause(_, player) } private def updateArrangementPlayer( tour: Tournament, @@ -619,7 +663,7 @@ final class TournamentApi( val points = arr.points | Arrangement.Points.default if (g.winnerUserId.exists(_ == player.userId)) points.win else if (g.winner.isEmpty) points.draw - else points.lose + else points.loss } fuccess( player.copy( @@ -666,14 +710,14 @@ final class TournamentApi( } yield opponentRating + 500 * multiplier private def withdrawNonMover(game: Game): Unit = - if (game.status == shogi.Status.NoStart) for { + if (game.status == shogi.Status.NoStart && game.arrangementId.isEmpty) for { tourId <- game.tournamentId player <- game.playerWhoDidNotMove userId <- player.userId - } withdraw(tourId, userId, isPause = false, isStalling = false) + } withdraw(tourId, userId, isPause = false, isStalling = false, isForced = false) def pausePlaybanned(userId: User.ID) = - tournamentRepo.withdrawableIds(userId) flatMap { + tournamentRepo.withdrawableIds(userId, onlyArena = true) flatMap { _.map { playerRepo.withdraw(_, userId) }.sequenceFu.void @@ -697,9 +741,9 @@ final class TournamentApi( by: User.ID ): Funit = Sequencing(tourId)(tournamentRepo.enterableById) { tour => - (tour.createdBy == by) ?? { - playerRepo.remove(tour.id, userId) >> { - tour.isStarted ?? { + (tour.createdBy == by && by != userId) ?? { + kickOrRemove(tour, userId) >> { + if (tour.isStarted) { if (tour.isArena) pairingRepo.findPlaying(tour.id, userId).map { _ foreach { currentPairing => @@ -709,21 +753,23 @@ final class TournamentApi( else arrangementRepo.findPlaying(tour.id, userId) map { curArrangements => curArrangements foreach { curArrangement => - curArrangement.gameId.pp foreach { tellRound(_, AbortForce) } + curArrangement.gameId foreach { tellRound(_, AbortForce) } } } - } + } else updateNbPlayers(tour.id) } >> - tournamentRepo.setDenied(tour.id, userId :: tour.denied) >> - updateNbPlayers(tour.id) >>- - socket.foreach(_.reload(tour.id)) >>- publish() + tournamentRepo.setDenied(tour.id, userId :: tour.denied) >>- { + if (tour.hasArrangements) cached.arrangement.invalidatePlayers(tour.id) + socket.foreach(_.reload(tour.id)) + publish() + } } } def ejectLameFromEnterable(tourId: Tournament.ID, userId: User.ID): Funit = Sequencing(tourId)(tournamentRepo.enterableById) { tour => - playerRepo.remove(tour.id, userId) >> { - tour.isStarted ?? { + kickOrRemove(tour, userId) >> { + if (tour.isStarted) { if (tour.isArena) pairingRepo.findPlaying(tour.id, userId).map { _ foreach { currentPairing => @@ -739,14 +785,13 @@ final class TournamentApi( curArrangement.gameId foreach { tellRound(_, AbortForce) } } } - } - } >> updateNbPlayers(tour.id) >>- - socket.foreach(_.reload(tour.id)) >>- publish() + } else updateNbPlayers(tour.id) + } >>- socket.foreach(_.reload(tour.id)) >>- publish() } def ejectLameFromHistory(tourId: Tournament.ID, userId: User.ID): Funit = Sequencing(tourId)(tournamentRepo.finishedById) { tour => - playerRepo.remove(tourId, userId) >> { + kickOrRemove(tour, userId) >> { tour.winnerId.contains(userId) ?? { playerRepo winner tour.id flatMap { _ ?? { p => @@ -757,6 +802,10 @@ final class TournamentApi( } } + private def kickOrRemove(tour: Tournament, userId: User.ID) = + if (tour.isCreated) playerRepo.remove(tour.id, userId) + else playerRepo.kick(tour.id, userId) + private val tournamentTopNb = 20 private val tournamentTopCache = cacheApi[Tournament.ID, TournamentTop](16, "tournament.top") { _.refreshAfterWrite(3 second) @@ -921,13 +970,29 @@ final class TournamentApi( userId: User.ID, filter: (Tournament, Arrangement) => Boolean )(run: (Tournament, Arrangement) => Funit): Funit = - Sequencing(lookup.tourId)(tournamentRepo.startedById) { tour => + Sequencing(lookup.tourId)(tournamentRepo.enterableById) { tour => arrangementRepo.byLookup(lookup) flatMap { arrangementOpt => arrangementOpt - .orElse(Arrangement.make(lookup).some.ifTrue(tour.isRobin || tour.createdBy == userId)) - .filter(a => filter(tour, a)) ?? { a => - run(tour, a) - } + .fold( + if (tour.isOrganized && tour.createdBy == userId) + arrangementRepo.countByTour(tour.id) map { count => + (count < 500) ?? { + Arrangement.make(tour.id, lookup.users).some + } + } + else if (tour.isRobin) + playerRepo.findActiveTuple2(lookup.tourId, lookup.users) map { players => + players ?? { ps => + val orderedUsers = + if (ps._1.order.zip(ps._2.order).exists { case (o1, o2) => o1 > o2 }) + (ps._2.userId, ps._1.userId) + else (ps._1.userId, ps._2.userId) + Arrangement.make(tour.id, orderedUsers).some + } + } + else fuccess(none) + )(a => fuccess(a.some)) + .flatMap(aOpt => aOpt.filter(a => filter(tour, a)) ?? { a => run(tour, a) }) } } diff --git a/modules/tournament/src/main/TournamentRepo.scala b/modules/tournament/src/main/TournamentRepo.scala index 29a8d64f9dd32..f0a9c74d1267e 100644 --- a/modules/tournament/src/main/TournamentRepo.scala +++ b/modules/tournament/src/main/TournamentRepo.scala @@ -43,6 +43,9 @@ final class TournamentRepo(val coll: Coll, playerCollName: CollName)(implicit _.id ) + def arenaById(id: Tournament.ID): Fu[Option[Tournament]] = + coll.one[Tournament]($id(id) ++ arena) + def uniqueById(id: Tournament.ID): Fu[Option[Tournament]] = coll.one[Tournament]($id(id) ++ selectUnique) @@ -123,12 +126,13 @@ final class TournamentRepo(val coll: Coll, playerCollName: CollName)(implicit private[tournament] def withdrawableIds( userId: User.ID, - teamId: Option[TeamID] = None + teamId: Option[TeamID] = None, + onlyArena: Boolean = false ): Fu[List[Tournament.ID]] = coll .aggregateList(Int.MaxValue, readPreference = ReadPreference.secondaryPreferred) { implicit framework => import framework._ - Match(enterableSelect ++ nonEmptySelect ++ teamId.??(forTeamSelect)) -> List( + Match(enterableSelect ++ nonEmptySelect ++ teamId.??(forTeamSelect) ++ onlyArena.??(arena)) -> List( PipelineOperator( $doc( "$lookup" -> $doc( @@ -168,6 +172,16 @@ final class TournamentRepo(val coll: Coll, playerCollName: CollName)(implicit def setCandidates(tourId: Tournament.ID, candidates: List[User.ID]) = coll.updateField($id(tourId), "candidates", candidates).void + def clearCandidates(tourId: Tournament.ID) = + coll.update + .one( + $id(tourId), + $unset( + "candidates" + ) + ) + .void + def setDenied(tourId: Tournament.ID, denied: List[User.ID]) = coll.updateField($id(tourId), "denied", denied).void diff --git a/modules/tournament/src/main/TournamentSocket.scala b/modules/tournament/src/main/TournamentSocket.scala index 838271d021d5c..8147c79993169 100644 --- a/modules/tournament/src/main/TournamentSocket.scala +++ b/modules/tournament/src/main/TournamentSocket.scala @@ -125,12 +125,22 @@ final private class TournamentSocket( userId <- userIdOpt d <- o obj "d" lookup <- Protocol.In.readArrangementLookup(tourId.value, d) - name = d str "name" - color = d.boolean("color").map(shogi.Color.fromSente) - points = d str "points" flatMap Arrangement.Points.apply - scheduledAt = d.long("scheduled") map { new DateTime(_) } - settings = Arrangement.Settings(name, color, points, scheduledAt) + name = d.str("name").map(_.take(32)).filter(_.nonEmpty) + color = d.boolean("color").map(shogi.Color.fromSente) + points = d + .str("points") + .flatMap(Arrangement.Points.apply) + .filterNot(_ == Arrangement.Points.default) + scheduledAt = d.long("scheduled") map { new DateTime(_) } + allowGameBefore = d.int("allowGameBefore") + settings = Arrangement.Settings(name, color, points, scheduledAt, allowGameBefore) } api.arrangementOrganizerSet(lookup, userId, settings) + case "arrangement-delete" => + for { + userId <- userIdOpt + d <- o obj "d" + lookup <- Protocol.In.readArrangementLookup(tourId.value, d) + } api.arrangementDelete(lookup, userId) case "process-candidate" => for { by <- userIdOpt @@ -175,8 +185,8 @@ final private class TournamentSocket( case _ => none } } - order = d int "order" - } yield Arrangement.Lookup(tourId, users, order) + id = d str "id" + } yield Arrangement.Lookup(id, tourId, users) val reader: P.In.Reader = raw => tourReader(raw) orElse RP.In.reader(raw) diff --git a/modules/tournament/src/main/TournamentStandingApi.scala b/modules/tournament/src/main/TournamentStandingApi.scala index 412d159435e9e..cdd4aa931e2ac 100644 --- a/modules/tournament/src/main/TournamentStandingApi.scala +++ b/modules/tournament/src/main/TournamentStandingApi.scala @@ -83,7 +83,7 @@ final class TournamentStandingApi( } .sequenceFu .dmap(_.toMap) - players <- rankedPlayers.map(JsonView.playerJson(lightUserApi, sheets, tour.streakable)).sequenceFu + players <- rankedPlayers.map(JsonView.arenaPlayerJson(lightUserApi, sheets, tour.streakable)).sequenceFu } yield Json.obj( "page" -> page, "players" -> players diff --git a/modules/tournament/src/main/crud/CrudForm.scala b/modules/tournament/src/main/crud/CrudForm.scala index 04aa4c303306f..be7cc818b3399 100644 --- a/modules/tournament/src/main/crud/CrudForm.scala +++ b/modules/tournament/src/main/crud/CrudForm.scala @@ -38,7 +38,7 @@ object CrudForm { name = "", homepageHours = 0, timeControlSetup = TimeControl.DataForm.Setup.default, - minutes = DataForm.minuteDefault, + minutes = DataForm.minutesDefault, variant = shogi.variant.Standard.id, position = none, date = DateTime.now plusDays 7,