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 67d9d63599c9..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): @@ -49,11 +51,19 @@ final private class StartedOrganizer( else startPairing(tour) private def startPairing(tour: Tournament, smallTourNbActivePlayers: Option[Int] = None): Funit = - (!tour.pairingsClosed).so( - socket - .getWaitingUsers(tour) - .monSuccess(_.tournament.startedOrganizer.waitingUsers) - .flatMap: waiting => - lila.mon.tournament.waitingPlayers.record(waiting.size) - api.makePairings(tour, waiting, smallTourNbActivePlayers) - ) + tour.pairingsClosed.not.so: + 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 fa2e147ad9e1..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, @@ -133,11 +136,11 @@ final class TournamentApi( cached .ranking(tour) .mon(_.tournament.pairing.createRanking) - .flatMap { ranking => + .flatMap: ranking => pairingSystem .createPairings(tour, users, ranking, smallTourNbActivePlayers) .mon(_.tournament.pairing.createPairings) - .flatMap { + .flatMap: case Nil => funit case pairings => pairingRepo.insert(pairings.map(_.pairing)) >> @@ -156,8 +159,6 @@ final class TournamentApi( socket.reload(tour.id) hadPairings.put(tour.id) featureOneOf(tour, pairings, ranking.ranking) // do outside of queue - } - } .monSuccess(_.tournament.pairing.create) .chronometer .logIfSlow(100, logger)(_ => s"Pairings for https://lichess.org/tournament/${tour.id}") @@ -372,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 => @@ -390,7 +390,6 @@ final class TournamentApi( } case _ => funit } - } } } @@ -409,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) @@ -455,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 { @@ -492,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, @@ -501,7 +502,6 @@ final class TournamentApi( provisional = perf.fold(player.provisional)(_.provisional) ) } - } } private[tournament] def recomputeEntireTournament(id: TourId): Funit = @@ -523,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 d3c04e2e3ea9..f654c4c32866 100644 --- a/modules/tournament/src/main/WaitingUsers.scala +++ b/modules/tournament/src/main/WaitingUsers.scala @@ -9,7 +9,7 @@ private case class WaitingUsers( apiUsers: Option[ExpireSetMemo[UserId]], clock: TournamentClock, date: Instant -)(using Executor): +): // ultrabullet -> 8 // hyperbullet -> 10 @@ -40,23 +40,26 @@ 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 ) def hasUser(userId: UserId) = hash contains userId - def addApiUser(userId: UserId) = - val memo = apiUsers | new ExpireSetMemo[UserId](70.seconds) + def addApiUser(userId: UserId)(using Executor) = + val memo = apiUsers | ExpireSetMemo[UserId](70.seconds) 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) @@ -81,9 +84,11 @@ final private class WaitingUsersApi(using Executor): store.computeIfPresent(tourId): cur => cur.copy(waiting = cur.waiting.removePairedUsers(users)).some - def addApiUser(tour: Tournament, user: User) = updateOrCreate(tour) { w => + 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) 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) ) ) diff --git a/ui/analyse/src/keyboard.ts b/ui/analyse/src/keyboard.ts index d795ecc91b66..084fd066ccdb 100644 --- a/ui/analyse/src/keyboard.ts +++ b/ui/analyse/src/keyboard.ts @@ -9,7 +9,12 @@ export const bind = (ctrl: AnalyseCtrl) => { let shiftAlone = 0; document.addEventListener('keydown', e => e.key === 'Shift' && (shiftAlone = e.location)); document.addEventListener('keyup', e => { - if (e.key === 'Shift' && e.location === shiftAlone) { + if ( + e.key === 'Shift' && + e.location === shiftAlone && + !document.activeElement?.classList.contains('mchat__say') + ) { + // hilities confound ddugovic when he fails to capitalize a letter in chat if (shiftAlone === 1 && ctrl.fork.prev()) ctrl.setAutoShapes(); else if (shiftAlone === 2 && ctrl.fork.next()) ctrl.setAutoShapes(); else if (shiftAlone === 0) return; diff --git a/ui/analyse/src/study/chapterNewForm.ts b/ui/analyse/src/study/chapterNewForm.ts index 39cc95c11ac0..f47a7da9fde8 100644 --- a/ui/analyse/src/study/chapterNewForm.ts +++ b/ui/analyse/src/study/chapterNewForm.ts @@ -1,6 +1,6 @@ import { parseFen } from 'chessops/fen'; import { defined, prop, type Prop, toggle } from 'common'; -import { snabDialog, alert } from 'common/dialog'; +import { type Dialog, snabDialog, alert } from 'common/dialog'; import * as licon from 'common/licon'; import { bind, bindSubmit, onInsert, looseH as h, dataIcon, type VNode } from 'common/snabbdom'; import { storedProp } from 'common/storage'; @@ -29,7 +29,10 @@ export const fieldValue = (e: Event, id: string) => export class StudyChapterNewForm { readonly multiPgnMax = 64; variants: Variant[] = []; - isOpen = toggle(false); + dialog: Dialog | undefined; + isOpen = toggle(false, val => { + if (!val) this.dialog?.close(); + }); initial = toggle(false); tab = storedProp( 'analyse.study.form.tab', @@ -142,11 +145,16 @@ export function view(ctrl: StudyChapterNewForm): VNode { return snabDialog({ class: 'chapter-new', onClose() { + ctrl.dialog = undefined; ctrl.isOpen(false); ctrl.redraw(); }, modal: true, noClickAway: true, + onInsert: dlg => { + ctrl.dialog = dlg; + dlg.show(); + }, vnodes: [ activeTab !== 'edit' && h('h2', [ diff --git a/ui/common/src/dialog.ts b/ui/common/src/dialog.ts index 62a3a8c8f77d..afa58088925d 100644 --- a/ui/common/src/dialog.ts +++ b/ui/common/src/dialog.ts @@ -254,7 +254,10 @@ class DialogWrapper implements Dialog { view.parentElement?.style.setProperty('---viewport-height', `${window.innerHeight}px`); this.dialogEvents.addListener(view, 'click', e => e.stopPropagation()); - this.dialogEvents.addListener(dialog, 'cancel', () => !this.returnValue && (this.returnValue = 'cancel')); + this.dialogEvents.addListener(dialog, 'cancel', e => { + if (o.noClickAway && o.noCloseButton && o.class !== 'alert') return e.preventDefault(); + if (!this.returnValue) this.returnValue = 'cancel'; + }); this.dialogEvents.addListener(dialog, 'close', this.onRemove); if (!o.noCloseButton) this.dialogEvents.addListener( @@ -276,7 +279,7 @@ class DialogWrapper implements Dialog { else where.appendChild(app.node); } this.updateActions(); - this.dialogEvents.addListener(this.dialog, 'keydown', onKeydown); + this.dialogEvents.addListener(this.dialog, 'keydown', this.onKeydown); } get open(): boolean { @@ -322,6 +325,23 @@ class DialogWrapper implements Dialog { } }; + private onKeydown = (e: KeyboardEvent) => { + if (e.key === 'Escape' && !(this.o.noCloseButton && this.o.noClickAway)) { + this.close('cancel'); + e.preventDefault(); + } else if (e.key === 'Tab') { + const $focii = $(focusQuery, e.currentTarget as Element), + first = $as($focii.first()), + last = $as($focii.last()), + focus = document.activeElement as HTMLElement; + if (focus === last && !e.shiftKey) first.focus(); + else if (focus === first && e.shiftKey) last.focus(); + else return; + e.preventDefault(); + } + e.stopPropagation(); + }; + private autoFocus() { const focus = (this.o.focus ? this.view.querySelector(this.o.focus) : this.view.querySelector('input[autofocus]')) ?? @@ -365,20 +385,6 @@ function escapeHtmlAddBreaks(s: string) { return escapeHtml(s).replace(/\n/g, '
'); } -function onKeydown(e: KeyboardEvent) { - if (e.key === 'Tab') { - const $focii = $(focusQuery, e.currentTarget as Element), - first = $as($focii.first()), - last = $as($focii.last()), - focus = document.activeElement as HTMLElement; - if (focus === last && !e.shiftKey) first.focus(); - else if (focus === first && e.shiftKey) last.focus(); - else return; - e.preventDefault(); - } - e.stopPropagation(); -} - function onResize() { // ios safari vh behavior workaround $('dialog > div.scrollable').css('---viewport-height', `${window.innerHeight}px`); diff --git a/ui/site/src/asset.ts b/ui/site/src/asset.ts index 06de84453116..fd75a00d52c8 100644 --- a/ui/site/src/asset.ts +++ b/ui/site/src/asset.ts @@ -18,7 +18,7 @@ export const url = (path: string, opts: AssetUrlOpts = {}) => { function asHashed(path: string, hash: string) { const name = path.slice(path.lastIndexOf('/') + 1); - const extPos = name.indexOf('.'); + const extPos = name.lastIndexOf('.'); return `hashed/${extPos < 0 ? `${name}.${hash}` : `${name.slice(0, extPos)}.${hash}${name.slice(extPos)}`}`; }