diff --git a/app/Env.scala b/app/Env.scala index ee3b4f84cbf17..9d3f8c9bc6f6b 100644 --- a/app/Env.scala +++ b/app/Env.scala @@ -24,8 +24,9 @@ final class Env( given mode: Mode = environment.mode given translator: lila.core.i18n.Translator = lila.i18n.Translator given scheduler: Scheduler = system.scheduler - given RateLimit = net.rateLimit + given lila.core.config.RateLimit = net.rateLimit given NetDomain = net.domain + given getFile: (String => java.io.File) = environment.getFile // wire all the lila modules in the right order val i18n: lila.i18n.Env.type = lila.i18n.Env @@ -60,6 +61,7 @@ final class Env( val tournament: lila.tournament.Env = wire[lila.tournament.Env] val swiss: lila.swiss.Env = wire[lila.swiss.Env] val mod: lila.mod.Env = wire[lila.mod.Env] + val ask: lila.ask.Env = wire[lila.ask.Env] val team: lila.team.Env = wire[lila.team.Env] val teamSearch: lila.teamSearch.Env = wire[lila.teamSearch.Env] val forum: lila.forum.Env = wire[lila.forum.Env] @@ -98,6 +100,7 @@ final class Env( val bot: lila.bot.Env = wire[lila.bot.Env] val storm: lila.storm.Env = wire[lila.storm.Env] val racer: lila.racer.Env = wire[lila.racer.Env] + val local: lila.local.Env = wire[lila.local.Env] val opening: lila.opening.Env = wire[lila.opening.Env] val tutor: lila.tutor.Env = wire[lila.tutor.Env] val recap: lila.recap.Env = wire[lila.recap.Env] diff --git a/app/LilaComponents.scala b/app/LilaComponents.scala index 2fbfcc2ad72ed..005f16e0f6d84 100644 --- a/app/LilaComponents.scala +++ b/app/LilaComponents.scala @@ -103,6 +103,7 @@ final class LilaComponents( lazy val analyse: Analyse = wire[Analyse] lazy val api: Api = wire[Api] lazy val appealC: appeal.Appeal = wire[appeal.Appeal] + lazy val ask: Ask = wire[Ask] lazy val auth: Auth = wire[Auth] lazy val feed: Feed = wire[Feed] lazy val playApi: PlayApi = wire[PlayApi] @@ -127,6 +128,7 @@ final class LilaComponents( lazy val irwin: Irwin = wire[Irwin] lazy val learn: Learn = wire[Learn] lazy val lobby: Lobby = wire[Lobby] + lazy val localPlay: Local = wire[Local] lazy val main: Main = wire[Main] lazy val msg: Msg = wire[Msg] lazy val mod: Mod = wire[Mod] diff --git a/app/controllers/Ask.scala b/app/controllers/Ask.scala new file mode 100644 index 0000000000000..b79f82781391d --- /dev/null +++ b/app/controllers/Ask.scala @@ -0,0 +1,128 @@ +package controllers + +import play.api.data.Form +import play.api.data.Forms.single +import views.* + +import lila.app.{ given, * } +import lila.core.id.AskId +import lila.core.ask.Ask + +final class Ask(env: Env) extends LilaController(env): + + def view(aid: AskId, view: Option[String], tally: Boolean) = Open: _ ?=> + env.ask.repo.getAsync(aid).flatMap { + case Some(ask) => Ok.snip(views.askUi.renderOne(ask, intVec(view), tally)) + case _ => fuccess(NotFound(s"Ask $aid not found")) + } + + def picks(aid: AskId, picks: Option[String], view: Option[String], anon: Boolean) = OpenBody: _ ?=> + effectiveId(aid, anon).flatMap: + case Some(id) => + val setPicks = () => + env.ask.repo.setPicks(aid, id, intVec(picks)).map { + case Some(ask) => Ok.snip(views.askUi.renderOne(ask, intVec(view))) + case _ => NotFound(s"Ask $aid not found") + } + feedbackForm + .bindFromRequest() + .fold( + _ => setPicks(), + text => + setPicks() >> env.ask.repo.setForm(aid, id, text.some).flatMap { + case Some(ask) => Ok.snip(views.askUi.renderOne(ask, intVec(view))) + case _ => NotFound(s"Ask $aid not found") + } + ) + case _ => authenticationFailed + + def form(aid: AskId, view: Option[String], anon: Boolean) = OpenBody: _ ?=> + effectiveId(aid, anon).flatMap: + case Some(id) => + env.ask.repo.setForm(aid, id, feedbackForm.bindFromRequest().value).map { + case Some(ask) => Ok.snip(views.askUi.renderOne(ask, intVec(view))) + case _ => NotFound(s"Ask $aid not found") + } + case _ => authenticationFailed + + def unset(aid: AskId, view: Option[String], anon: Boolean) = Open: _ ?=> + effectiveId(aid, anon).flatMap: + case Some(id) => + env.ask.repo + .unset(aid, id) + .map: + case Some(ask) => Ok.snip(views.askUi.renderOne(ask, intVec(view))) + case _ => NotFound(s"Ask $aid not found") + + case _ => authenticationFailed + + def admin(aid: AskId) = Auth: _ ?=> + env.ask.repo + .getAsync(aid) + .map: + case Some(ask) => Ok.snip(views.askAdminUi.renderOne(ask)) + case _ => NotFound(s"Ask $aid not found") + + def byUser(username: UserStr) = Auth: _ ?=> + me ?=> + Ok.async: + for + user <- env.user.lightUser(username.id) + asks <- env.ask.repo.byUser(username.id) + if (me.is(user)) || isGranted(_.ModerateForum) + yield views.askAdminUi.show(asks, user.get) + + def json(aid: AskId) = Auth: _ ?=> + me ?=> + env.ask.repo + .getAsync(aid) + .map: + case Some(ask) => + if (me.is(ask.creator)) || isGranted(_.ModerateForum) then JsonOk(ask.toJson) + else JsonBadRequest(jsonError(s"Not authorized to view ask $aid")) + case _ => JsonBadRequest(jsonError(s"Ask $aid not found")) + + def delete(aid: AskId) = Auth: _ ?=> + me ?=> + env.ask.repo + .getAsync(aid) + .map: + case Some(ask) => + if (me.is(ask.creator)) || isGranted(_.ModerateForum) then + env.ask.repo.delete(aid) + Ok + else Unauthorized + case _ => NotFound(s"Ask id ${aid} not found") + + def conclude(aid: AskId) = authorized(aid, env.ask.repo.conclude) + + def reset(aid: AskId) = authorized(aid, env.ask.repo.reset) + + private def effectiveId(aid: AskId, anon: Boolean)(using ctx: Context) = + ctx.myId match + case Some(u) => fuccess((if anon then Ask.anonHash(u.toString, aid) else u.toString).some) + case _ => + env.ask.repo + .isOpen(aid) + .map: + case true => Ask.anonHash(ctx.ip.toString, aid).some + case false => none[String] + + private def authorized(aid: AskId, action: AskId => Fu[Option[lila.core.ask.Ask]]) = Auth: _ ?=> + me ?=> + env.ask.repo + .getAsync(aid) + .flatMap: + case Some(ask) => + if (me.is(ask.creator)) || isGranted(_.ModerateForum) then + action(ask._id).map: + case Some(newAsk) => Ok.snip(views.askUi.renderOne(newAsk)) + case _ => NotFound(s"Ask id ${aid} not found") + else fuccess(Unauthorized) + case _ => fuccess(NotFound(s"Ask id $aid not found")) + + private def intVec(param: Option[String]) = + param.map(_.split('-').filter(_.nonEmpty).map(_.toInt).toVector) + + private val feedbackForm = + Form[String](single("text" -> lila.common.Form.cleanNonEmptyText(maxLength = 80))) diff --git a/app/controllers/Feed.scala b/app/controllers/Feed.scala index 89891233edab9..6627870d1d4fd 100644 --- a/app/controllers/Feed.scala +++ b/app/controllers/Feed.scala @@ -12,7 +12,8 @@ final class Feed(env: Env) extends LilaController(env): Reasonable(page): for updates <- env.feed.paginator.recent(isGrantedOpt(_.Feed), page) - renderedPage <- renderPage(views.feed.index(updates)) + hasAsks <- env.ask.repo.preload(updates.currentPageResults.map(_.content.value)*) + renderedPage <- renderPage(views.feed.index(updates, hasAsks)) yield Ok(renderedPage) def createForm = Secure(_.Feed) { _ ?=> _ ?=> @@ -29,12 +30,12 @@ final class Feed(env: Env) extends LilaController(env): } def edit(id: String) = Secure(_.Feed) { _ ?=> _ ?=> - Found(api.get(id)): up => + Found(api.edit(id)): up => Ok.async(views.feed.edit(api.form(up.some), up)) } def update(id: String) = SecureBody(_.Feed) { _ ?=> _ ?=> - Found(api.get(id)): from => + Found(api.edit(id)): from => bindForm(api.form(from.some))( err => BadRequest.async(views.feed.edit(err, from)), data => api.set(data.toUpdate(from.id.some)).inject(Redirect(routes.Feed.edit(from.id)).flashSuccess) diff --git a/app/controllers/ForumTopic.scala b/app/controllers/ForumTopic.scala index 4412b32d2660e..42433f2a6165a 100644 --- a/app/controllers/ForumTopic.scala +++ b/app/controllers/ForumTopic.scala @@ -54,10 +54,14 @@ final class ForumTopic(env: Env) extends LilaController(env) with ForumControlle .soUse: _ ?=> forms.postWithCaptcha(inOwnTeam).some _ <- env.user.lightUserApi.preloadMany(posts.currentPageResults.flatMap(_.post.userId)) + (_, hasAsks) <- env.user.lightUserApi + .preloadMany(posts.currentPageResults.flatMap(_.post.userId)) + .zip(env.ask.repo.preload(posts.currentPageResults.map(_.post.text)*)) res <- if canRead then Ok.page( - views.forum.topic.show(categ, topic, posts, form, unsub, canModCateg, None, replyBlocked) + views.forum.topic + .show(categ, topic, posts, form, unsub, canModCateg, None, replyBlocked, hasAsks) ).map(_.withCanonical(routes.ForumTopic.show(categ.id, topic.slug, page))) else notFound yield res diff --git a/app/controllers/Local.scala b/app/controllers/Local.scala new file mode 100644 index 0000000000000..7393eb1f06405 --- /dev/null +++ b/app/controllers/Local.scala @@ -0,0 +1,125 @@ +package controllers + +import play.api.libs.json.* +import play.api.i18n.Lang +import play.api.mvc.* +import play.api.data.* +import play.api.data.Forms.* +import views.* + +import lila.app.{ given, * } +import lila.common.Json.given +import lila.user.User +import lila.rating.{ Perf, PerfType } +import lila.security.Permission +import lila.local.{ GameSetup, AssetType } + +final class Local(env: Env) extends LilaController(env): + def index = Open: + for + bots <- env.local.repo.getLatestBots() + page <- renderPage(indexPage(bots, none)) + yield Ok(page).enforceCrossSiteIsolation.withHeaders("Service-Worker-Allowed" -> "/") + + def bots = Open: + env.local.repo + .getLatestBots() + .map: bots => + JsonOk(Json.obj("bots" -> bots)) + + def assetKeys = Open: // for service worker + JsonOk(env.local.api.assetKeys) + + def devIndex = Auth: _ ?=> + for + bots <- env.local.repo.getLatestBots() + assets <- devGetAssets + page <- renderPage(indexPage(bots, assets.some)) + yield Ok(page).enforceCrossSiteIsolation.withHeaders("Service-Worker-Allowed" -> "/") + + def devAssets = Auth: ctx ?=> + devGetAssets.map(JsonOk) + + def devBotHistory(botId: Option[String]) = Auth: _ ?=> + env.local.repo + .getVersions(botId.map(UserId.apply)) + .map: history => + JsonOk(Json.obj("bots" -> history)) + + def devPostBot = SecureBody(parse.json)(_.BotEditor) { ctx ?=> me ?=> + ctx.body.body + .validate[JsObject] + .fold( + err => BadRequest(Json.obj("error" -> err.toString)), + bot => + env.local.repo + .putBot(bot, me.userId) + .map: updatedBot => + JsonOk(updatedBot) + ) + } + + def devNameAsset(key: String, name: String) = Secure(_.BotEditor): _ ?=> + env.local.repo + .nameAsset(none, key, name, none) + .flatMap(_ => devGetAssets.map(JsonOk)) + + def devDeleteAsset(key: String) = Secure(_.BotEditor): _ ?=> + env.local.repo + .deleteAsset(key) + .flatMap(_ => devGetAssets.map(JsonOk)) + + def devPostAsset(notAString: String, key: String) = SecureBody(parse.multipartFormData)(_.BotEditor) { + ctx ?=> + val tpe: AssetType = notAString.asInstanceOf[AssetType] + val author: Option[String] = ctx.body.body.dataParts.get("author").flatMap(_.headOption) + val name = ctx.body.body.dataParts.get("name").flatMap(_.headOption).getOrElse(key) + ctx.body.body + .file("file") + .map: file => + env.local.api + .storeAsset(tpe, key, file) + .flatMap: + case Left(error) => InternalServerError(Json.obj("error" -> error.toString)).as(JSON) + case Right(assets) => + env.local.repo + .nameAsset(tpe.some, key, name, author) + .flatMap(_ => (JsonOk(Json.obj("key" -> key, "name" -> name)))) + .getOrElse(fuccess(BadRequest(Json.obj("error" -> "missing file")).as(JSON))) + } + + private def indexPage(bots: JsArray, devAssets: Option[JsObject] = none)(using + ctx: Context + ) = + given setupFormat: Format[GameSetup] = Json.format[GameSetup] + views.local.index( + Json + .obj("pref" -> pref, "bots" -> bots) + .add("assets", devAssets) + .add("userId", ctx.me.map(_.userId)) + .add("username", ctx.me.map(_.username)) + .add("canPost", isGrantedOpt(_.BotEditor)), + if devAssets.isDefined then "local.dev" else "local" + ) + + private def devGetAssets = + env.local.repo.getAssets.map: m => + JsObject: + env.local.api.assetKeys + .as[JsObject] + .fields + .collect: + case (category, JsArray(keys)) => + category -> JsArray: + keys.collect: + case JsString(key) if m.contains(key) => + Json.obj("key" -> key, "name" -> m(key)) + + private def pref(using ctx: Context) = + lila.pref.JsonView + .write(ctx.pref, false) + .add("animationDuration", ctx.pref.animationMillis.some) + .add("enablePremove", ctx.pref.premove.some) + + private def optTrue(s: Option[String]) = + s.exists(v => v == "" || v == "1" || v == "true") diff --git a/app/controllers/Push.scala b/app/controllers/Push.scala index 357aa366c93d5..7794f13e73215 100644 --- a/app/controllers/Push.scala +++ b/app/controllers/Push.scala @@ -15,7 +15,7 @@ final class Push(env: Env) extends LilaController(env): def webSubscribe = AuthBody(parse.json) { ctx ?=> me ?=> val currentSessionId = ~env.security.api.reqSessionId(ctx.req) - ctx.body.body + ctx.body.body.pp .validate[WebSubscription] .fold( err => BadRequest(err.toString), diff --git a/app/controllers/Round.scala b/app/controllers/Round.scala index 98b22972a50ab..8c7e23bd7da98 100644 --- a/app/controllers/Round.scala +++ b/app/controllers/Round.scala @@ -71,7 +71,7 @@ final class Round( jsChat <- chat.flatMap(_.game).map(_.chat).soFu(lila.chat.JsonView.asyncLines) yield Ok(data.add("chat", jsChat)).noCache ) - yield res + yield res.enforceCrossSiteIsolation def player(fullId: GameFullId) = Open: env.round.proxyRepo diff --git a/app/controllers/Ublog.scala b/app/controllers/Ublog.scala index 10a050486a7b9..81475d60bf38d 100644 --- a/app/controllers/Ublog.scala +++ b/app/controllers/Ublog.scala @@ -56,10 +56,20 @@ final class Ublog(env: Env) extends LilaController(env): prefFollowable <- ctx.isAuth.so(env.pref.api.followable(user.id)) blocked <- ctx.userId.so(env.relation.api.fetchBlocks(user.id, _)) followable = prefFollowable && !blocked - markup <- env.ublog.markup(post) + (markup, hasAsks) <- env.ublog.markup(post).zip(env.ask.repo.preload(post.markdown.value)) viewedPost = env.ublog.viewCounter(post, ctx.ip) page <- renderPage: - views.ublog.post.page(user, blog, viewedPost, markup, others, liked, followable, followed) + views.ublog.post.page( + user, + blog, + viewedPost, + markup, + others, + liked, + followable, + followed, + hasAsks + ) yield Ok(page) def discuss(id: UblogPostId) = Open: @@ -129,7 +139,11 @@ final class Ublog(env: Env) extends LilaController(env): def edit(id: UblogPostId) = AuthBody { ctx ?=> me ?=> NotForKids: FoundPage(env.ublog.api.findEditableByMe(id)): post => - views.ublog.form.edit(post, env.ublog.form.edit(post)) + env.ask.api + .unfreezeAndLoad(post.markdown.value) + .flatMap: frozen => + views.ublog.form.edit(post, env.ublog.form.edit(post.copy(markdown = Markdown(frozen)))) + // views.ublog.form.edit(post, env.ublog.form.edit(post)) } def update(id: UblogPostId) = AuthBody { ctx ?=> me ?=> diff --git a/app/mashup/Preload.scala b/app/mashup/Preload.scala index 3639252bb1016..04e99267fc4dd 100644 --- a/app/mashup/Preload.scala +++ b/app/mashup/Preload.scala @@ -30,6 +30,7 @@ final class Preload( simulIsFeaturable: SimulIsFeaturable, getLastUpdates: lila.feed.Feed.GetLastUpdates, lastPostsCache: AsyncLoadingCache[Unit, List[UblogPost.PreviewPost]], + askRepo: lila.ask.AskRepo, msgApi: lila.msg.MsgApi, relayListing: lila.relay.RelayListing, notifyApi: lila.notify.NotifyApi @@ -52,16 +53,19 @@ final class Preload( ( ( ( - (((((((data, povs), tours), events), simuls), feat), entries), puzzle), - streams + ( + (((((((data, povs), tours), events), simuls), feat), entries), puzzle), + streams + ), + playban ), - playban + blindGames ), - blindGames + ublogPosts ), - ublogPosts + lichessMsg ), - lichessMsg + hasAsks ) <- lobbyApi.apply .mon(_.lobby.segment("lobbyApi")) .zip(tours.mon(_.lobby.segment("tours"))) @@ -86,6 +90,7 @@ final class Preload( .filterNot(liveStreamApi.isStreaming) .so(msgApi.hasUnreadLichessMessage) ) + .zip(askRepo.preload(getLastUpdates().map(_.content.value)*)) (currentGame, _) <- (ctx.me .soUse(currentGameMyTurn(povs, lightUserApi.sync))) .mon(_.lobby.segment("currentGame")) @@ -111,7 +116,8 @@ final class Preload( getLastUpdates(), ublogPosts, withPerfs, - hasUnreadLichessMessage = lichessMsg + hasUnreadLichessMessage = lichessMsg, + hasAsks ) def currentGameMyTurn(using me: Me): Fu[Option[CurrentGame]] = @@ -155,7 +161,8 @@ object Preload: lastUpdates: List[lila.feed.Feed.Update], ublogPosts: List[UblogPost.PreviewPost], me: Option[UserWithPerfs], - hasUnreadLichessMessage: Boolean + hasUnreadLichessMessage: Boolean, + hasAsks: Boolean ) case class CurrentGame(pov: Pov, opponent: String) diff --git a/app/views/game/widgets.scala b/app/views/game/widgets.scala index b93e9eabbeb41..fbeef6da9da76 100644 --- a/app/views/game/widgets.scala +++ b/app/views/game/widgets.scala @@ -10,6 +10,7 @@ object widgets: private val separator = " • " + // mirrored html generation in file://../../../ui/user/src/user.ts def apply( games: Seq[Game], notes: Map[GameId, String] = Map(), diff --git a/app/views/lobby/home.scala b/app/views/lobby/home.scala index 4acc82e718262..531835f0cbcf1 100644 --- a/app/views/lobby/home.scala +++ b/app/views/lobby/home.scala @@ -29,6 +29,8 @@ object home: ) ) .css("lobby") + .css(homepage.hasAsks.option("bits.ask")) + .js(homepage.hasAsks.option(esmInit("bits.ask"))) .graph( OpenGraph( image = staticAssetUrl("logo/lichess-tile-wide.png").some, diff --git a/app/views/ublog.scala b/app/views/ublog.scala index 79a5002dd7551..9b6fa2efd29b4 100644 --- a/app/views/ublog.scala +++ b/app/views/ublog.scala @@ -12,7 +12,8 @@ lazy val ui = lila.ublog.ui.UblogUi(helpers, views.atomUi)(picfitUrl) lazy val post = lila.ublog.ui.UblogPostUi(helpers, ui)( ublogRank = env.ublog.rank, - connectLinks = views.bits.connectLinks + connectLinks = views.bits.connectLinks, + askRender = views.askUi.render ) lazy val form = lila.ublog.ui.UblogFormUi(helpers, ui)( diff --git a/app/views/ui.scala b/app/views/ui.scala index 12204f8de321a..77da3d89e695b 100644 --- a/app/views/ui.scala +++ b/app/views/ui.scala @@ -32,12 +32,15 @@ object oAuth: val plan = lila.plan.ui.PlanUi(helpers)(netConfig.email) val planPages = lila.plan.ui.PlanPages(helpers)(lila.fishnet.FishnetLimiter.maxPerDay) +val askUi = lila.ask.ui.AskUi(helpers)(env.ask.api) +val askAdminUi = lila.ask.ui.AskAdminUi(helpers)(askUi.renderGraph) + val feed = - lila.feed.ui.FeedUi(helpers, atomUi)(title => _ ?=> site.ui.SitePage(title, "news", ""))(using + lila.feed.ui.FeedUi(helpers, atomUi)(title => _ ?=> site.ui.SitePage(title, "news", ""), askUi.render)(using env.executor ) -val cms = lila.cms.ui.CmsUi(helpers)(mod.ui.menu("cms")) +val cms = lila.cms.ui.CmsUi(helpers)(mod.ui.menu("cms"), askUi.render) val event = lila.event.ui.EventUi(helpers)(mod.ui.menu("event"))(using env.executor) @@ -59,12 +62,9 @@ val practice = lila.practice.ui.PracticeUi(helpers)( object forum: import lila.forum.ui.* val bits = ForumBits(helpers) - val post = PostUi(helpers, bits) + val post = PostUi(helpers, bits)(askUi.render, env.ask.api.unfreeze) val categ = CategUi(helpers, bits) - val topic = TopicUi(helpers, bits, post)( - captcha.apply, - lila.msg.MsgPreset.forumDeletion.presets - ) + val topic = TopicUi(helpers, bits, post)(captcha.apply, lila.msg.MsgPreset.forumDeletion.presets) val timeline = lila.timeline.ui.TimelineUi(helpers)(streamer.bits.redirectLink(_)) @@ -87,6 +87,8 @@ val challenge = lila.challenge.ui.ChallengeUi(helpers) val dev = lila.web.ui.DevUi(helpers)(mod.ui.menu) +val local = lila.local.ui.LocalUi(helpers) + def mobile(p: lila.cms.CmsPage.Render)(using Context) = lila.web.ui.mobile(helpers)(cms.render(p)) diff --git a/bin/deploy b/bin/deploy index c4def9b037782..8dbc913566adf 100755 --- a/bin/deploy +++ b/bin/deploy @@ -116,7 +116,6 @@ PROFILES = { "manta-assets": asset_profile("root@manta.lichess.ovh", deploy_dir="/home/lichess"), } - class DeployError(Exception): pass @@ -313,7 +312,7 @@ def deploy_script(profile, session, run, url): ] else: commands += [ - f'echo "{artifact_unzipped}/d/{symlink} -> {deploy_dir}/{symlink}";ln -f --no-target-directory -s {artifact_unzipped}/d/{symlink} {deploy_dir}/{symlink}' + f'echo "{artifact_unzipped}/d/{symlink} -> {deploy_dir}/{symlink}"; ln -f --no-target-directory -s {artifact_unzipped}/d/{symlink} {deploy_dir}/{symlink}' for symlink in profile["symlinks"] ] + [f"chmod -f +x {deploy_dir}/bin/lila || true"] diff --git a/bin/mongodb/fuckshoe.js b/bin/mongodb/fuckshoe.js new file mode 100644 index 0000000000000..c27b40c3b204e --- /dev/null +++ b/bin/mongodb/fuckshoe.js @@ -0,0 +1,20 @@ +const fs = require('node:fs'); +const ps = require('node:process'); +const fuckshoeText = fs.readFileSync(fuckshoe, 'utf-8'); +if (!fuckshoe || !fuckshoeText) { + console.log(fuckshoe, 'fuckshoe file not found'); + ps.exit(1); +} +db.boards.drop(); +db.pieces.drop(); +const lines = fuckshoeText.split('\n'); +for (let i = 0; i < lines.length; i++) { + const line = lines[i].trim(); + if (!line.startsWith('/poll')) continue; + const parts = line.split(' '); + const item = parts[1]; + const type = parts[2]; + const id = lines[i + 1].substring(4, 12); + if (type === 'board') db.boards.insertOne({ _id: item, type: type, id: id }); + else db.pieces.insertOne({ _id: item, type: type, id: id }); +} diff --git a/build.sbt b/build.sbt index 0b581444fcd14..c85324da1fb64 100644 --- a/build.sbt +++ b/build.sbt @@ -76,8 +76,8 @@ lazy val modules = Seq( // and then the smaller ones pool, lobby, relation, tv, coordinate, feed, history, recap, shutup, appeal, irc, explorer, learn, event, coach, - practice, evalCache, irwin, bot, racer, cms, i18n, - socket, bookmark, studySearch, gameSearch, forumSearch, teamSearch, + practice, evalCache, irwin, bot, racer, cms, i18n, local, + socket, bookmark, studySearch, gameSearch, forumSearch, teamSearch, ask ) lazy val moduleRefs = modules map projectToRef @@ -149,6 +149,11 @@ lazy val racer = module("racer", Seq() ) +lazy val local = module("local", + Seq(db, memo, ui, pref), + Seq() +) + lazy val video = module("video", Seq(memo, ui), macwire.bundle @@ -170,12 +175,12 @@ lazy val coordinate = module("coordinate", ) lazy val feed = module("feed", - Seq(memo, ui), + Seq(memo, ui, ask), Seq() ) lazy val ublog = module("ublog", - Seq(memo, ui), + Seq(memo, ui, ask), Seq(bloomFilter) ) @@ -425,8 +430,13 @@ lazy val msg = module("msg", Seq() ) +lazy val ask = module("ask", + Seq(memo, ui, security), + reactivemongo.bundle +) + lazy val forum = module("forum", - Seq(memo, ui), + Seq(memo, ui, ask), Seq() ) diff --git a/conf/base.conf b/conf/base.conf index a015667d5db6c..2c41591ba4162 100644 --- a/conf/base.conf +++ b/conf/base.conf @@ -379,6 +379,7 @@ insight { learn { collection.progress = learn_progress } +local.asset_path = "public/lifat/bots" kaladin.enabled = false zulip { domain = "" diff --git a/conf/routes b/conf/routes index 73d3e17aed9fc..90b78488af36b 100644 --- a/conf/routes +++ b/conf/routes @@ -348,6 +348,18 @@ GET /streamer/:username controllers.Streamer.show(username: UserS GET /streamer/:username/redirect controllers.Streamer.redirect(username: UserStr) POST /streamer/:username/check controllers.Streamer.checkOnline(username: UserStr) +# Private Play + +GET /local controllers.Local.index +GET /local/bots controllers.Local.bots +GET /local/assets controllers.Local.assetKeys +GET /local/dev controllers.Local.devIndex +GET /local/dev/history controllers.Local.devBotHistory(id: Option[String]) +POST /local/dev/bot controllers.Local.devPostBot +GET /local/dev/assets controllers.Local.devAssets +POST /local/dev/asset/$tpe/$key<\w{12}(\.\w{2,4})?> controllers.Local.devPostAsset(tpe: String, key: String) +POST /local/dev/asset/mv/$key<\w{12}(\.\w{2,4})?>/:name controllers.Local.devNameAsset(key: String, name: String) + # Round GET /$gameId<\w{8}> controllers.Round.watcher(gameId: GameId, color: Color = Color.white) GET /$gameId<\w{8}>/$color controllers.Round.watcher(gameId: GameId, color: Color) @@ -603,6 +615,18 @@ GET /api/stream/irwin controllers.Irwin.eventStream # Kaladin GET /kaladin controllers.Irwin.kaladin +# Ask +GET /ask/:id controllers.Ask.view(id: AskId, view: Option[String], tally: Boolean ?= false) +POST /ask/picks/:id controllers.Ask.picks(id: AskId, picks: Option[String], view: Option[String], anon: Boolean ?= false) +POST /ask/form/:id controllers.Ask.form(id: AskId, view: Option[String], anon: Boolean ?= false) +POST /ask/conclude/:id controllers.Ask.conclude(id: AskId) +POST /ask/unset/:id controllers.Ask.unset(id: AskId, view: Option[String], anon: Boolean ?= false) +POST /ask/reset/:id controllers.Ask.reset(id: AskId) +POST /ask/delete/:id controllers.Ask.delete(id: AskId) +GET /ask/admin/:id controllers.Ask.admin(id: AskId) +GET /ask/byUser/:username controllers.Ask.byUser(username: UserStr) +GET /ask/json/:id controllers.Ask.json(id: AskId) + # Forum GET /forum controllers.ForumCateg.index GET /forum/search controllers.ForumPost.search(text ?= "", page: Int ?= 1) diff --git a/modules/api/src/main/Env.scala b/modules/api/src/main/Env.scala index f19328a4c0307..e2dd164aac37f 100644 --- a/modules/api/src/main/Env.scala +++ b/modules/api/src/main/Env.scala @@ -7,6 +7,7 @@ import play.api.Mode import lila.chat.{ GetLinkCheck, IsChatFresh } import lila.common.Bus import lila.core.misc.lpv.* +import lila.core.config.CollName @Module final class Env( @@ -59,6 +60,7 @@ final class Env( realPlayerApi: lila.web.RealPlayerApi, bookmarkExists: lila.core.misc.BookmarkExists, manifest: lila.web.AssetManifest, + db: lila.db.Db, tokenApi: lila.oauth.AccessTokenApi )(using val mode: Mode, scheduler: Scheduler)(using Executor, @@ -80,6 +82,8 @@ final class Env( lazy val gameApiV2 = wire[GameApiV2] + lazy val pollColls = PollColls(db) + lazy val roundApi = wire[RoundApi] lazy val lobbyApi = wire[LobbyApi] @@ -120,3 +124,7 @@ final class Env( // ensure the Lichess user is online socketEnv.remoteSocket.onlineUserIds.getAndUpdate(_ + UserId.lichess) userEnv.repo.setSeenAt(UserId.lichess) + +private class PollColls(db: lila.db.Db): + val boards = db(CollName("boards")) + val pieces = db(CollName("pieces")) diff --git a/modules/api/src/main/RoundApi.scala b/modules/api/src/main/RoundApi.scala index c576d4614c6e9..52e1c3facad90 100644 --- a/modules/api/src/main/RoundApi.scala +++ b/modules/api/src/main/RoundApi.scala @@ -19,6 +19,8 @@ import lila.simul.Simul import lila.swiss.GameView as SwissView import lila.tournament.GameView as TourView import lila.tree.{ ExportOptions, Tree } +import lila.db.JSON +import lila.db.dsl.{ *, given } final private[api] class RoundApi( jsonView: JsonView, @@ -35,7 +37,8 @@ final private[api] class RoundApi( userApi: lila.user.UserApi, prefApi: lila.pref.PrefApi, getLightUser: lila.core.LightUser.GetterSync, - userLag: lila.socket.UserLagCache + userLag: lila.socket.UserLagCache, + colls: lila.api.PollColls )(using Executor, lila.core.i18n.Translator): def player( @@ -166,6 +169,20 @@ final private[api] class RoundApi( owner = owner ) .flatMap(externalEngineApi.withExternalEngines) + .flatMap: json => + colls.boards + .find($doc()) + .cursor[Bdoc]() + .list(Int.MaxValue) + .map: docs => + json + ("boards" -> JsArray(docs.map(JSON.jval))) + .flatMap: json => + colls.pieces + .find($doc()) + .cursor[Bdoc]() + .list(Int.MaxValue) + .map: docs => + json + ("pieces" -> JsArray(docs.map(JSON.jval))) private def withTree( pov: Pov, diff --git a/modules/ask/src/main/AskApi.scala b/modules/ask/src/main/AskApi.scala new file mode 100644 index 0000000000000..6df5589539ba1 --- /dev/null +++ b/modules/ask/src/main/AskApi.scala @@ -0,0 +1,191 @@ +package lila.ask + +import lila.db.dsl.{ *, given } +import lila.core.id.AskId +import lila.core.ask.* +import lila.core.ask.Ask.{ frozenIdMagic, frozenIdRe } + +/* the freeze process transforms form text prior to database storage and creates/updates collection + * objects with data from ask markup. freeze methods return replacement text with magic id tags in place + * of any Ask markup found. unfreeze methods allow editing by doing the inverse, replacing magic + * tags in a previously frozen text with their markup. ids in magic tags correspond to db.ask._id + */ + +final class AskApi(val repo: lila.ask.AskRepo)(using Executor) extends lila.core.ask.AskApi: + + import AskApi.* + import Ask.* + + def freeze(text: String, creator: UserId): Frozen = + val askIntervals = getMarkupIntervals(text) + val asks = askIntervals.map((start, end) => textToAsk(text.substring(start, end), creator)) + + val it = asks.iterator + val sb = java.lang.StringBuilder(text.length) + + intervalClosure(askIntervals, text.length).map: seg => + if it.hasNext && askIntervals.contains(seg) then sb.append(s"$frozenIdMagic{${it.next()._id}}") + else sb.append(text, seg._1, seg._2) + + Frozen(sb.toString, asks) + + // commit flushes the asks to repo and optionally sets a timeline entry link (for poll conclusion) + def commit( + frozen: Frozen, + url: Option[String] = none[String] + ): Fu[Iterable[Ask]] = // TODO need return value? + frozen.asks.map(ask => repo.upsert(ask.copy(url = url))).parallel + + def freezeAndCommit(text: String, creator: UserId, url: Option[String] = none[String]): Fu[String] = + val askIntervals = getMarkupIntervals(text) + askIntervals + .map((start, end) => repo.upsert(textToAsk(text.substring(start, end), creator, url))) + .parallel + .map: asks => + val it = asks.iterator + val sb = java.lang.StringBuilder(text.length) + + intervalClosure(askIntervals, text.length).map: seg => + if it.hasNext && askIntervals.contains(seg) then sb.append(s"$frozenIdMagic{${it.next()._id}}") + else sb.append(text, seg._1, seg._2) + sb.toString + + // unfreeze methods replace magic ids with their ask markup to allow user edits + def unfreezeAndLoad(text: String): Fu[String] = + extractIds(text) + .map(repo.getAsync) + .parallel + .map: asks => + val it = asks.iterator + frozenIdRe.replaceAllIn(text, _ => it.next().fold(askNotFoundFrag)(askToText)) + + // dont call this without preloading first + def unfreeze(text: String): String = + val it = extractIds(text).map(repo.get).iterator + frozenIdRe.replaceAllIn(text, _ => it.next().fold(askNotFoundFrag)(askToText)) + + def isOpen(aid: AskId): Fu[Boolean] = repo.isOpen(aid) + + def bake(text: String, askFrags: Iterable[String]): String = AskApi.bake(text, askFrags) + +object AskApi: + val askNotFoundFrag = "<deleted>
" + + def hasAskId(text: String): Boolean = text.contains(frozenIdMagic) + + // the bake method interleaves rendered ask fragments within the html fragment, which is usually an + // inner html or

. any embedded asks should be directly in that root element. we make a best effort + // to close and reopen tags around asks, but attributes cannot be safely repeated so stick to plain + //

, ,

, etc if it's not a text node + def bake(html: String, askFrags: Iterable[String]): String = + val tag = if html.slice(0, 1) == "<" then html.slice(1, html.indexWhere(Set(' ', '>').contains)) else "" + val sb = java.lang.StringBuilder(html.length + askFrags.foldLeft(0)((x, y) => x + y.length)) + val magicIntervals = frozenIdRe.findAllMatchIn(html).map(m => (m.start, m.end)).toList + val it = askFrags.iterator + + intervalClosure(magicIntervals, html.length).map: seg => + val text = html.substring(seg._1, seg._2) + if it.hasNext && magicIntervals.contains(seg) then + if tag.nonEmpty then sb.append(s"") + sb.append(it.next) + else if !(text.isBlank() || text.startsWith(s"")) then + sb.append(if seg._1 > 0 && tag.nonEmpty then s"<$tag>$text" else text) + sb.toString + + def tag(html: String) = html.slice(1, html.indexOf(">")) + + def extractIds(t: String): List[AskId] = + frozenOffsets(t).map(off => lila.core.id.AskId(t.substring(off._1 + 5, off._2 - 1))) + + // render ask as markup text + private def askToText(ask: Ask): String = + val sb = scala.collection.mutable.StringBuilder(1024) + sb ++= s"/poll ${ask.question}\n" + // tags.mkString(" ") not used, make explicit tag conflict results for traceable/tally/anon on re-edits + sb ++= s"/id{${ask._id}}" + if ask.isForm then sb ++= " form" + if ask.isOpen then sb ++= " open" + if ask.isTraceable then sb ++= " traceable" + else + if ask.isTally then sb ++= " tally" + if ask.isAnon then sb ++= " anon" + if ask.isVertical then sb ++= " vertical" + if ask.isStretch then sb ++= " stretch" + if ask.isRandom then sb ++= " random" + if ask.isRanked then sb ++= " ranked" + if ask.isMulti then sb ++= " multiple" + if ask.isSubmit && !ask.isRanked && !ask.isForm then sb ++= " submit" + if ask.isConcluded then sb ++= " concluded" + sb ++= "\n" + sb ++= ask.choices.map(c => s"$c\n").mkString + sb ++= ~ask.footer.map(f => s"? $f\n") + sb.toString + + private def textToAsk(segment: String, creator: UserId, url: Option[String] = none[String]): Ask = + val tagString = extractTagString(segment) + Ask.make( + _id = extractIdFromTagString(tagString), + question = extractQuestion(segment), + choices = extractChoices(segment), + tags = extractTagList(tagString.map(_.toLowerCase)), + creator = creator, + footer = extractFooter(segment), + url = url + ) + + type Interval = (Int, Int) // [start, end) cleaner than regex match objects for our purpose + type Intervals = List[Interval] + + // return list of (start, end) indices of any ask markups in text. + private def getMarkupIntervals(t: String): Intervals = + if !t.contains("/poll") then List.empty[Interval] + else askRe.findAllMatchIn(t).map(m => (m.start, m.end)).toList + + // return intervals and their complement in [0, upper) + private def intervalClosure(intervals: Intervals, upper: Int): Intervals = + val points = (0 :: intervals.flatten(i => List(i._1, i._2)) ::: upper :: Nil).distinct.sorted + points.zip(points.tail) + + // // https://www.unicode.org/faq/private_use.html + // private val frozenIdMagic = "\ufdd6\ufdd4\ufdd2\ufdd0" + // private val frozenIdRe = s"$frozenIdMagic\\{(\\S{8})}".r + + // assemble a list of magic ids within a frozen text that look like: ﷖﷔﷒﷐{8 char id} + // this is called quite often so it's optimized and ugly + private def frozenOffsets(t: String): Intervals = + var i = t.indexOf(frozenIdMagic) + if i == -1 then List.empty + else + val ids = scala.collection.mutable.ListBuffer[Interval]() + while i != -1 && i <= t.length - 14 do // 14 is total magic length + ids.addOne(i, i + 14) // (5, 13) delimit id within magic + i = t.indexOf(frozenIdMagic, i + 14) + ids.toList + + private def extractQuestion(t: String): String = + questionInAskRe.findFirstMatchIn(t).fold("")(_.group(1)).trim + + private def extractTagString(t: String): Option[String] = + tagsInAskRe.findFirstMatchIn(t).map(_.group(1)).filter(_.nonEmpty) + + private def extractIdFromTagString(o: Option[String]): Option[String] = + o.flatMap(idInTagsRe.findFirstMatchIn(_).map(_.group(1))) + + private def extractTagList(o: Option[String]): Ask.Tags = + o.fold(Set.empty[String])( + tagListRe.findAllMatchIn(_).collect(_.group(1)).toSet + ).filterNot(_.startsWith("id{")) + + private def extractChoices(t: String): Ask.Choices = + (choiceInAskRe.findAllMatchIn(t).map(_.group(1).trim).distinct).toVector + + private def extractFooter(t: String): Option[String] = + footerInAskRe.findFirstMatchIn(t).map(_.group(1).trim).filter(_.nonEmpty) + + private val askRe = raw"(?m)^/poll\h+\S.*\R^(?:/.*(?:\R|$$))?(?:(?!/).*\S.*(?:\R|$$))*(?:\?.*)?".r + private val questionInAskRe = raw"^/poll\h+(\S.*)".r + private val tagsInAskRe = raw"(?m)^/poll(?:.*)\R^/(.*)$$".r + private val idInTagsRe = raw"\bid\{(\S{8})}".r + private val tagListRe = raw"\h*(\S+)".r + private val choiceInAskRe = raw"(?m)^(?![\?/])(.*\S.*)".r + private val footerInAskRe = raw"(?m)^\?(.*)".r diff --git a/modules/ask/src/main/AskRepo.scala b/modules/ask/src/main/AskRepo.scala new file mode 100644 index 0000000000000..b830b07a17afd --- /dev/null +++ b/modules/ask/src/main/AskRepo.scala @@ -0,0 +1,174 @@ +package lila.ask + +import scala.concurrent.duration.* +import scala.collection.concurrent.TrieMap +import reactivemongo.api.bson.* + +import lila.db.dsl.{ *, given } +import lila.core.id.AskId +import lila.core.ask.* +import lila.core.timeline.{ AskConcluded, Propagate } + +final class AskRepo( + askDb: lila.db.AsyncColl, + // timeline: lila.timeline.Timeline, + cacheApi: lila.memo.CacheApi +)(using + Executor +) extends lila.core.ask.AskRepo: + import lila.core.ask.Ask.* + import AskApi.* + + given BSONDocumentHandler[Ask] = Macros.handler[Ask] + + private val cache = cacheApi.sync[AskId, Option[Ask]]( + name = "ask", + initialCapacity = 1000, + compute = getDb, + default = _ => none[Ask], + strategy = lila.memo.Syncache.Strategy.WaitAfterUptime(20.millis), + expireAfter = lila.memo.Syncache.ExpireAfter.Access(1.hour) + ) + + def get(aid: AskId): Option[Ask] = cache.sync(aid) + + def getAsync(aid: AskId): Fu[Option[Ask]] = cache.async(aid) + + def preload(text: String*): Fu[Boolean] = + val ids = text.flatMap(AskApi.extractIds) + ids.map(getAsync).parallel.inject(ids.nonEmpty) + + // vid (voter id) are sometimes anonymous hashes. + def setPicks(aid: AskId, vid: String, picks: Option[Vector[Int]]): Fu[Option[Ask]] = + update(aid, vid, picks, modifyPicksCached, writePicks) + + def setForm(aid: AskId, vid: String, form: Option[String]): Fu[Option[Ask]] = + update(aid, vid, form, modifyFormCached, writeForm) + + def unset(aid: AskId, vid: String): Fu[Option[Ask]] = + update(aid, vid, none[Unit], unsetCached, writeUnset) + + def delete(aid: AskId): Funit = askDb: coll => + cache.invalidate(aid) + coll.delete.one($id(aid)).void + + def conclude(aid: AskId): Fu[Option[Ask]] = askDb: coll => + coll + .findAndUpdateSimplified[Ask]($id(aid), $addToSet("tags" -> "concluded"), fetchNewObject = true) + .collect: + case Some(ask) => + cache.set(aid, ask.some) // TODO fix timeline + /*if ask.url.nonEmpty && !ask.isAnon then + timeline ! Propagate(AskConcluded(ask.creator, ask.question, ~ask.url)) + .toUsers(ask.participants.map(UserId(_)).toList) + .exceptUser(ask.creator)*/ + ask.some + + def reset(aid: AskId): Fu[Option[Ask]] = askDb: coll => + coll + .findAndUpdateSimplified[Ask]( + $id(aid), + $doc($unset("picks", "form"), $pull("tags" -> "concluded")), + fetchNewObject = true + ) + .collect: + case Some(ask) => + cache.set(aid, ask.some) + ask.some + + def byUser(uid: UserId): Fu[List[Ask]] = askDb: coll => + coll + .find($doc("creator" -> uid)) + .sort($sort.desc("createdAt")) + .cursor[Ask]() + .list(Int.MaxValue) + .map: asks => + asks.map(a => cache.set(a._id, a.some)) + asks + + def deleteAll(text: String): Funit = askDb: coll => + val ids = AskApi.extractIds(text) + ids.map(cache.invalidate) + if ids.nonEmpty then coll.delete.one($inIds(ids)).void + else funit + + // none values (deleted asks) in these lists are still important for sequencing in renders + def asksIn(text: String): Fu[List[Option[Ask]]] = askDb: coll => + val ids = AskApi.extractIds(text) + ids.map(getAsync).parallel.inject(ids.map(get)) + + def isOpen(aid: AskId): Fu[Boolean] = askDb: coll => + getAsync(aid).map(_.exists(_.isOpen)) + + // call this after freezeAsync on form submission for edits + def setUrl(text: String, url: Option[String]): Funit = askDb: coll => + if !hasAskId(text) then funit + else + val selector = $inIds(AskApi.extractIds(text)) + coll.update.one(selector, $set("url" -> url), multi = true) >> + coll.list(selector).map(_.foreach(ask => cache.set(ask._id, ask.copy(url = url).some))) + + private val emptyPicks = Map.empty[String, Vector[Int]] + private val emptyForm = Map.empty[String, String] + + private def getDb(aid: AskId) = askDb: coll => + coll.byId[Ask](aid) + + private def update[A]( + aid: AskId, + vid: String, + value: Option[A], + cached: (Ask, String, Option[A]) => Ask, + writeField: (AskId, String, Option[A], Boolean) => Fu[Option[Ask]] + ) = + cache.sync(aid) match + case Some(ask) => + val cachedAsk = cached(ask, vid, value) + cache.set(aid, cachedAsk.some) + writeField(aid, vid, value, false).inject(cachedAsk.some) + case _ => + writeField(aid, vid, value, true).collect: + case Some(ask) => + cache.set(aid, ask.some) + ask.some + + // hey i know, let's write 17 functions so we can reuse 2 lines of code + private def modifyPicksCached(ask: Ask, vid: String, newPicks: Option[Vector[Int]]) = + ask.copy(picks = newPicks.fold(ask.picks.fold(emptyPicks)(_ - vid).some): p => + ((ask.picks.getOrElse(emptyPicks) + (vid -> p)).some)) + + private def modifyFormCached(ask: Ask, vid: String, newForm: Option[String]) = + ask.copy(form = newForm.fold(ask.form.fold(emptyForm)(_ - vid).some): f => + ((ask.form.getOrElse(emptyForm) + (vid -> f)).some)) + + private def unsetCached(ask: Ask, vid: String, unused: Option[Unit]) = + ask.copy(picks = ask.picks.fold(emptyPicks)(_ - vid).some, form = ask.form.fold(emptyForm)(_ - vid).some) + + private def writePicks(aid: AskId, vid: String, picks: Option[Vector[Int]], fetchNew: Boolean) = + updateAsk(aid, picks.fold($unset(s"picks.$vid"))(r => $set(s"picks.$vid" -> r)), fetchNew) + + private def writeForm(aid: AskId, vid: String, form: Option[String], fetchNew: Boolean) = + updateAsk(aid, form.fold($unset(s"form.$vid"))(f => $set(s"form.$vid" -> f)), fetchNew) + + private def writeUnset(aid: AskId, vid: String, unused: Option[Unit], fetchNew: Boolean) = + updateAsk(aid, $unset(s"picks.$vid", s"form.$vid"), fetchNew) + + private def updateAsk(aid: AskId, update: BSONDocument, fetchNew: Boolean) = askDb: coll => + coll.update + .one($and($id(aid), $doc("tags" -> $ne("concluded"))), update) + .flatMap: + case _ => if fetchNew then getAsync(aid) else fuccess(none[Ask]) + + // only preserve votes if important fields haven't been altered + private[ask] def upsert(ask: Ask): Fu[Ask] = askDb: coll => + coll + .byId[Ask](ask._id) + .flatMap: + case Some(dbAsk) => + val mergedAsk = ask.merge(dbAsk) + cache.set(ask._id, mergedAsk.some) + if dbAsk eq mergedAsk then fuccess(mergedAsk) + else coll.update.one($id(ask._id), mergedAsk).inject(mergedAsk) + case _ => + cache.set(ask._id, ask.some) + coll.insert.one(ask).inject(ask) diff --git a/modules/ask/src/main/Env.scala b/modules/ask/src/main/Env.scala new file mode 100644 index 0000000000000..5d26228fa7c99 --- /dev/null +++ b/modules/ask/src/main/Env.scala @@ -0,0 +1,15 @@ +package lila.ask + +import com.softwaremill.macwire.* +import com.softwaremill.tagging.@@ +import lila.core.config.* + +@Module +final class Env( + db: lila.db.AsyncDb @@ lila.db.YoloDb, + // timeline: lila.hub.actors.Timeline, + cacheApi: lila.memo.CacheApi +)(using Executor, Scheduler): + private lazy val askColl = db(CollName("ask")) + lazy val repo = wire[AskRepo] + lazy val api = wire[AskApi] diff --git a/modules/ask/src/main/package.scala b/modules/ask/src/main/package.scala new file mode 100644 index 0000000000000..e91056809f5a5 --- /dev/null +++ b/modules/ask/src/main/package.scala @@ -0,0 +1,6 @@ +package lila.ask + +export lila.core.lilaism.Lilaism.{ *, given } +export lila.common.extensions.* + +private[ask] val logger = lila.log("ask") diff --git a/modules/ask/src/main/ui/AskAdminUi.scala b/modules/ask/src/main/ui/AskAdminUi.scala new file mode 100644 index 0000000000000..938ee96c4bd2b --- /dev/null +++ b/modules/ask/src/main/ui/AskAdminUi.scala @@ -0,0 +1,67 @@ +package lila.ask +package ui + +import lila.ui.{ *, given } +import ScalatagsTemplate.{ *, given } +import lila.core.ask.Ask + +final class AskAdminUi(helpers: Helpers)(askRender: (Ask) => Context ?=> Frag): + import helpers.{ *, given } + + def show(asks: List[Ask], user: lila.core.LightUser)(using Me, Context) = + val askmap = asks.sortBy(_.createdAt).groupBy(_.url) + Page(s"${user.titleName} polls") + .css("bits.ask") + .js(esmInit("bits.ask")): + main(cls := "page-small box box-pad")( + h1(s"${user.titleName} polls"), + askmap.keys.map(url => showAsks(url, askmap.get(url).get)).toSeq + ) + + def showAsks(urlopt: Option[String], asks: List[Ask])(using Me, Context) = + div( + hr, + h2( + urlopt match + case Some(url) => div(a(href := url)(url)) + case None => "no url" + ), + br, + asks.map(renderOne) + ) + + def renderOne(ask: Ask)(using Context)(using me: Me) = + div(cls := "ask-admin")( + a(name := ask._id), + div(cls := "header")( + ask.question, + div(cls := "url-actions")( + button(formaction := routes.Ask.delete(ask._id))("Delete"), + button(formaction := routes.Ask.reset(ask._id))("Reset"), + (!ask.isConcluded).option(button(formaction := routes.Ask.conclude(ask._id))("Conclude")), + a(href := routes.Ask.json(ask._id))("JSON") + ) + ), + div(cls := "inset")( + Granter.opt(_.ModerateForum).option(property("id:", ask._id.value)), + (!me.is(ask.creator)).option(property("creator:", ask.creator.value)), + property("created at:", showInstant(ask.createdAt)), + ask.tags.nonEmpty.option(property("tags:", ask.tags.mkString(", "))), + ask.picks.map(p => (p.size > 0).option(property("responses:", p.size.toString))), + p, + askRender(ask) + ), + frag: + ask.form.map: fbmap => + frag( + property("form respondents:", fbmap.size.toString), + div(cls := "inset-box")( + fbmap.toSeq.map: + case (uid, fb) if uid.startsWith("anon-") => p(s"anon: $fb") + case (uid, fb) => p(s"$uid: $fb") + ) + ) + ) + + def property(name: String, value: String) = + div(cls := "prop")(div(cls := "name")(name), div(cls := "value")(value)) diff --git a/modules/ask/src/main/ui/AskUi.scala b/modules/ask/src/main/ui/AskUi.scala new file mode 100644 index 0000000000000..2c9d271df5795 --- /dev/null +++ b/modules/ask/src/main/ui/AskUi.scala @@ -0,0 +1,294 @@ +package lila.ask +package ui + +import scala.collection.mutable.StringBuilder +import scala.util.Random.shuffle + +import scalatags.Text.TypedTag +import lila.ui.{ *, given } +import ScalatagsTemplate.{ *, given } +import lila.core.id.AskId +import lila.core.ask.* + +final class AskUi(helpers: Helpers)(askApi: AskApi): + import helpers.{ *, given } + + def render(fragment: Frag)(using Context): Frag = + val ids = extractIds(fragment, Nil) + if ids.isEmpty then fragment + else + RawFrag: + askApi.bake( + fragment.render, + ids.map: id => + askApi.repo.get(id) match + case Some(ask) => + div(cls := s"ask-container${ask.isStretch.so(" stretch")}", renderOne(ask)).render + case _ => + p("").render + ) + + def renderOne(ask: Ask, prevView: Option[Vector[Int]] = None, tallyView: Boolean = false)(using + Context + ): Frag = + RenderAsk(ask, prevView, tallyView).render + + def renderGraph(ask: Ask)(using Context): Frag = + if ask.isRanked then RenderAsk(ask, None, true).rankGraphBody + else RenderAsk(ask, None, true).pollGraphBody + + def unfreeze(text: String): String = askApi.unfreeze(text) + + // AskApi.bake only has to support embedding in single fragments for all use cases + // but keep this recursion around for later + private def extractIds(fragment: Modifier, ids: List[AskId]): List[AskId] = fragment match + case StringFrag(s) => ids ++ AskApi.extractIds(s) + case RawFrag(f) => ids ++ AskApi.extractIds(f) + case t: TypedTag[?] => t.modifiers.flatten.foldLeft(ids)((acc, mod) => extractIds(mod, acc)) + case _ => ids + +private case class RenderAsk( + ask: Ask, + prevView: Option[Vector[Int]], + tallyView: Boolean +)(using ctx: Context): + val voterId = ctx.me.fold(ask.toAnon(ctx.ip))(me => ask.toAnon(me.userId)) + + val view = prevView.getOrElse: + if ask.isRandom then shuffle(ask.choices.indices.toList) + else ask.choices.indices.toList + + def render = + fieldset( + cls := s"ask${ask.isAnon.so(" anon")}", + id := ask._id, + ask.hasPickFor(voterId).option(value := "") + )( + header, + ask.isConcluded.option(label(s"${ask.form.so(_ size).max(ask.picks.so(_ size))} responses")), + ask.choices.nonEmpty.option( + if ask.isRanked then + if ask.isConcluded || tallyView then rankGraphBody + else rankBody + else if ask.isConcluded || tallyView then pollGraphBody + else pollBody + ), + footer + ) + + def header = + val viewParam = view.mkString("-") + legend( + span(cls := "ask__header")( + label( + ask.question, + (!tallyView).option( + if ask.isConcluded then span("(Results)") + else if ask.isRanked then span("(Drag to sort)") + else if ask.isMulti then span("(Choose all that apply)") + else span("(Choose one)") + ) + ), + maybeDiv( + "url-actions", + ask.isTally.option( + button( + cls := (if tallyView then "view" else "tally"), + formmethod := "GET", + formaction := routes.Ask.view(ask._id, viewParam.some, !tallyView) + ) + ), + (ctx.me.exists(_.userId == ask.creator) || Granter.opt(_.ModerateForum)).option( + button( + cls := "admin", + formmethod := "GET", + formaction := routes.Ask.admin(ask._id), + title := "Administrate this poll" + ) + ), + ((ask.hasPickFor(voterId) || ask.hasFormFor(voterId)) && !ask.isConcluded).option( + button( + cls := "unset", + formaction := routes.Ask.unset(ask._id, viewParam.some, ask.isAnon), + title := "Unset your submission" + ) + ) + ), + maybeDiv( + "properties", + ask.isTraceable.option( + button( + cls := "property trace", + title := "Participants can see who voted for what" + ) + ), + ask.isAnon.option( + button( + cls := "property anon", + title := "Your identity is anonymized and secure" + ) + ), + ask.isOpen.option(button(cls := "property open", title := "Anyone can participate")) + ) + ) + ) + + def footer = + div(cls := "ask__footer")( + ask.footer.map(label(_)), + (ask.isSubmit && !ask.isConcluded && voterId.nonEmpty).option( + frag( + ask.isForm.option( + input( + cls := "form-text", + tpe := "text", + maxlength := 80, + placeholder := "80 characters max", + value := ~ask.formFor(voterId) + ) + ), + div(cls := "form-submit")(input(cls := "button", tpe := "button", value := "Submit")) + ) + ), + (ask.isConcluded && ask.form.exists(_.size > 0)).option(frag: + ask.form.map: fmap => + div(cls := "form-results")( + ask.footer.map(label(_)), + fmap.toSeq.flatMap: + case (user, text) => Seq(div(ask.isTraceable.so(s"$user:")), div(text)) + )) + ) + + def pollBody = choiceContainer: + val picks = ask.picksFor(voterId) + val sb = StringBuilder("choice ") + if ask.isCheckbox then sb ++= "cbx " else sb ++= "btn " + if ask.isMulti then sb ++= "multiple " else sb ++= "exclusive " + if ask.isStretch then sb ++= "stretch " + view + .map(ask.choices) + .zipWithIndex + .map: + case (choiceText, choice) => + val selected = picks.exists(_ contains choice) + if ask.isCheckbox then + label( + cls := sb.toString + (if selected then "selected" else "enabled"), + title := tooltip(choice), + value := choice + )(input(tpe := "checkbox", selected.option(checked)), choiceText) + else + button( + cls := sb.toString + (if selected then "selected" else "enabled"), + title := tooltip(choice), + value := choice + )(choiceText) + + def rankBody = choiceContainer: + validRanking.zipWithIndex.map: + case (choice, index) => + val sb = StringBuilder("choice btn rank") + if ask.isStretch then sb ++= " stretch" + if ask.hasPickFor(voterId) then sb ++= " submitted" + div(cls := sb.toString, value := choice, draggable := true)( + div(s"${index + 1}"), + label(ask.choices(choice)), + i + ) + + def pollGraphBody = + div(cls := "ask__graph")(frag: + val totals = ask.totals + val max = totals.max + totals.zipWithIndex.flatMap: + case (total, choice) => + val pct = if max == 0 then 0 else total * 100 / max + val hint = tooltip(choice) + Seq( + div(title := hint)(ask.choices(choice)), + div(cls := "votes-text", title := hint)(pluralize("vote", total)), + div(cls := "set-width", title := hint, css("width") := s"$pct%")(nbsp) + )) + + def rankGraphBody = + div(cls := "ask__rank-graph")(frag: + val tooltipVec = rankedTooltips + ask.averageRank.zipWithIndex + .sortWith((i, j) => i._1 < j._1) + .flatMap: + case (avgIndex, choice) => + val lastIndex = ask.choices.size - 1 + val pct = (lastIndex - avgIndex) / lastIndex * 100 + val hint = tooltipVec(choice) + Seq( + div(title := hint)(ask.choices(choice)), + div(cls := "set-width", title := hint, style := s"width: $pct%")(nbsp) + )) + + def maybeDiv(clz: String, tags: Option[Frag]*) = + if tags.toList.flatten.nonEmpty then div(cls := clz, tags) else emptyFrag + + def choiceContainer = + val sb = StringBuilder("ask__choices") + if ask.isVertical then sb ++= " vertical" + if ask.isStretch then sb ++= " stretch" + div(cls := sb.toString) + + def tooltip(choice: Int) = + val sb = StringBuilder(256) + val choiceText = ask.choices(choice) + val hasPick = ask.hasPickFor(voterId) + + val count = ask.count(choiceText) + val isAuthor = ctx.me.exists(_.userId == ask.creator) + val isMod = Granter.opt(_.ModerateForum) + + if !ask.isRanked then + if ask.isConcluded || tallyView then + sb ++= pluralize("vote", count) + if ask.isTraceable || isMod then sb ++= s"\n\n${whoPicked(choice)}" + else + if isAuthor || ask.isTally then sb ++= pluralize("vote", count) + if ask.isTraceable && ask.isTally || isMod then sb ++= s"\n\n${whoPicked(choice)}" + + if sb.isEmpty then choiceText else sb.toString + + def rankedTooltips = + val respondents = ask.picks.so(picks => picks.size) + val rankM = ask.rankMatrix + val notables = List( + 0 -> "ranked this first", + 1 -> "chose this in their top two", + 2 -> "chose this in their top three", + 3 -> "chose this in their top four", + 4 -> "chose this in their top five" + ) + ask.choices.zipWithIndex.map: + case (choiceText, choice) => + val sb = StringBuilder(s"$choiceText:\n\n") + notables + .filter(_._1 < rankM.length - 1) + .map: + case (i, text) => + sb ++= s" ${rankM(choice)(i)} $text\n" + sb.toString + + def pluralize(item: String, n: Int) = + s"${if n == 0 then "No" else n} ${item}${if n != 1 then "s" else ""}" + + def whoPicked(choice: Int, max: Int = 100) = + val who = ask.whoPicked(choice) + if ask.isAnon then s"${who.size} votes" + else who.take(max).mkString("", ", ", (who.length > max).so(", and others...")) + + def validRanking = + val initialOrder = + if ask.isRandom then shuffle((0 until ask.choices.size).toVector) + else (0 until ask.choices.size).toVector + ask + .picksFor(voterId) + .fold(initialOrder): r => + if r == Vector.empty || r.distinct.sorted != initialOrder.sorted then + // voterId.so(id => env.ask.repo.setPicks(ask._id, id, Vector.empty[Int].some)) + initialOrder + else r diff --git a/modules/clas/src/main/ui/ClasUi.scala b/modules/clas/src/main/ui/ClasUi.scala index f8eee3e291de0..06be784bdc43b 100644 --- a/modules/clas/src/main/ui/ClasUi.scala +++ b/modules/clas/src/main/ui/ClasUi.scala @@ -15,7 +15,7 @@ final class ClasUi(helpers: lila.ui.Helpers)( student: Option[Student] = None )(mods: AttrPair*)(using lila.ui.Context): Page = Page(title) - .css("bits.clas") + .css("bits.clas", "user.activity") .js(Esm("bits.clas")) .wrap: body => if Granter.opt(_.Teacher) then diff --git a/modules/cms/src/main/CmsUi.scala b/modules/cms/src/main/CmsUi.scala index 5b7acd962f9b6..becbe2920090f 100644 --- a/modules/cms/src/main/CmsUi.scala +++ b/modules/cms/src/main/CmsUi.scala @@ -9,7 +9,7 @@ import lila.ui.* import ScalatagsTemplate.{ *, given } -final class CmsUi(helpers: Helpers)(menu: Context ?=> Frag): +final class CmsUi(helpers: Helpers)(menu: Context ?=> Frag, askRender: (Frag) => Context ?=> Frag): import helpers.{ *, given } def render(page: CmsPage.Render)(using Context): Frag = @@ -23,7 +23,7 @@ final class CmsUi(helpers: Helpers)(menu: Context ?=> Frag): "This draft is not published" ) ), - rawHtml(page.html) + askRender(rawHtml(page.html)) ) def render(p: CmsPage.RenderOpt)(using Context): Frag = diff --git a/modules/core/src/main/ask.scala b/modules/core/src/main/ask.scala new file mode 100644 index 0000000000000..40e955fe402f0 --- /dev/null +++ b/modules/core/src/main/ask.scala @@ -0,0 +1,216 @@ +package lila.core +package ask + +import alleycats.Zero + +import scalalib.extensions.{ *, given } +import lila.core.id.AskId +import lila.core.userId.* + +trait AskApi: + def freeze(text: String, creator: UserId): Frozen + def commit(frozen: Frozen, url: Option[String] = none[String]): Fu[Iterable[Ask]] + def freezeAndCommit(text: String, creator: UserId, url: Option[String] = none[String]): Fu[String] + def unfreezeAndLoad(text: String): Fu[String] + def unfreeze(text: String): String + def isOpen(aid: AskId): Fu[Boolean] + def bake(text: String, askFrags: Iterable[String]): String + val repo: AskRepo + +trait AskRepo: + def get(aid: AskId): Option[Ask] + def getAsync(aid: AskId): Fu[Option[Ask]] + def preload(text: String*): Fu[Boolean] + def setPicks(aid: AskId, vid: String, picks: Option[Vector[Int]]): Fu[Option[Ask]] + def setForm(aid: AskId, vid: String, form: Option[String]): Fu[Option[Ask]] + def unset(aid: AskId, vid: String): Fu[Option[Ask]] + def delete(aid: AskId): Funit + def conclude(aid: AskId): Fu[Option[Ask]] + def reset(aid: AskId): Fu[Option[Ask]] + def deleteAll(text: String): Funit + def asksIn(text: String): Fu[List[Option[Ask]]] + def isOpen(aid: AskId): Fu[Boolean] + def setUrl(text: String, url: Option[String]): Funit + +case class Frozen(text: String, asks: Iterable[Ask]) + +case class Ask( + _id: AskId, + question: String, + choices: Ask.Choices, + tags: Ask.Tags, + creator: UserId, + createdAt: java.time.Instant, + footer: Option[String], // optional text prompt for forms + picks: Option[Ask.Picks], + form: Option[Ask.Form], + url: Option[String] +): + + // changes to any of the fields checked in compatible will invalidate votes and form + def compatible(a: Ask): Boolean = + question == a.question && + choices == a.choices && + footer == a.footer && + creator == a.creator && + isOpen == a.isOpen && + isTraceable == a.isTraceable && + isAnon == a.isAnon && + isRanked == a.isRanked && + isMulti == a.isMulti + + def merge(dbAsk: Ask): Ask = + if this.compatible(dbAsk) then // keep votes & form + if tags.equals(dbAsk.tags) then dbAsk + else dbAsk.copy(tags = tags) + else copy(url = dbAsk.url) // discard votes & form + + def participants: Seq[String] = picks match + case Some(p) => p.keys.filter(!_.startsWith("anon-")).toSeq + case None => Nil + + lazy val isOpen = tags contains "open" // allow votes from anyone (no acct reqired) + lazy val isTraceable = tags.exists(_.startsWith("trace")) // everyone can see who voted for what + lazy val isAnon = !isTraceable && tags.exists(_.startsWith("anon")) // hide voters from creator/mods + lazy val isTally = isTraceable || tags.contains("tally") // partial results viewable before conclusion + lazy val isConcluded = tags contains "concluded" // closed poll + lazy val isRandom = tags.exists(_.startsWith("random")) // randomize order of choices + lazy val isMulti = !isRanked && tags.exists(_.startsWith("multi")) // multiple choices allowed + lazy val isRanked = tags.exists(_.startsWith("rank")) // drag to sort + lazy val isForm = tags.exists(_.startsWith("form")) // has a form/submit form + lazy val isStretch = tags.exists(_.startsWith("stretch")) // stretch to fill width + lazy val isVertical = tags.exists(_.startsWith("vert")) // one choice per row + lazy val isCheckbox = !isRanked && isVertical // use checkboxes + lazy val isSubmit = isForm || isRanked || tags.contains("submit") // has a submit button + + def toAnon(user: UserId): Option[String] = + Some(if isAnon then Ask.anonHash(user.value, _id) else user.value) + + def toAnon(ip: lila.core.net.IpAddress): Option[String] = + isOpen.option(Ask.anonHash(ip.toString, _id)) + + // eid = effective id, either a user id or an anonymous hash + def hasPickFor(o: Option[String]): Boolean = + o.fold(false)(eid => picks.exists(_.contains(eid))) + + def picksFor(o: Option[String]): Option[Vector[Int]] = + o.flatMap(eid => picks.flatMap(_.get(eid))) + + def firstPickFor(o: Option[String]): Option[Int] = + picksFor(o).flatMap(_.headOption) + + def hasFormFor(o: Option[String]): Boolean = + o.fold(false)(eid => form.exists(_.contains(eid))) + + def formFor(o: Option[String]): Option[String] = + o.flatMap(eid => form.flatMap(_.get(eid))) + + def count(choice: Int): Int = picks.fold(0)(_.values.count(_ contains choice)) + def count(choice: String): Int = count(choices.indexOf(choice)) + + def whoPicked(choice: String): List[String] = whoPicked(choices.indexOf(choice)) + def whoPicked(choice: Int): List[String] = picks + .getOrElse(Nil) + .collect: + case (uid, ls) if ls contains choice => uid + .toList + + def whoPickedAt(choice: Int, rank: Int): List[String] = picks + .getOrElse(Nil) + .collect: + case (uid, ls) if ls.indexOf(choice) == rank => uid + .toList + + @inline private def constrain(index: Int) = index.atMost(choices.size - 1).atLeast(0) + + def totals: Vector[Int] = picks match + case Some(pmap) if choices.nonEmpty && pmap.nonEmpty => + val results = Array.ofDim[Int](choices.size) + pmap.values.foreach(_.foreach { it => results(constrain(it)) += 1 }) + results.toVector + case _ => + Vector.fill(choices.size)(0) + + // index of returned vector maps to choices list, values from [0f, choices.size-1f] where 0 is "best" rank + def averageRank: Vector[Float] = picks match + case Some(pmap) if choices.nonEmpty && pmap.nonEmpty => + val results = Array.ofDim[Int](choices.size) + pmap.values.foreach: ranking => + for it <- choices.indices do results(constrain(ranking(it))) += it + results.map(_ / pmap.size.toFloat).toVector + case _ => + Vector.fill(choices.size)(0f) + + // an [n]x[n-1] matrix M describing response rankings. each element M[i][j] is the number of + // respondents who preferred the choice i at rank j or below (effectively in the top j+1 picks) + // the rightmost column is omitted as it is always equal to the number of respondents + def rankMatrix: Array[Array[Int]] = picks match + case Some(pmap) if choices.nonEmpty && pmap.nonEmpty => + val n = choices.size - 1 + val mat = Array.ofDim[Int](choices.size, n) + pmap.values.foreach: ranking => + for i <- choices.indices do + val iRank = ranking.indexOf(i) + for j <- iRank until n do mat(i)(j) += (if iRank <= j then 1 else 0) + mat + case _ => + Array.ofDim[Int](0, 0) + + def toJson: play.api.libs.json.JsObject = + play.api.libs.json.Json.obj( + "id" -> _id.value, + "question" -> question, + "choices" -> choices, + "tags" -> tags, + "creator" -> creator.value, + "created" -> createdAt.toString, + "footer" -> footer, + "picks" -> picks, + "form" -> form, + "url" -> url + ) + +object Ask: + + // type ID = AskId + type Tags = Set[String] + type Choices = Vector[String] + type Picks = Map[String, Vector[Int]] // ranked list of indices into Choices vector + type Form = Map[String, String] + + // https://www.unicode.org/faq/private_use.html + val frozenIdMagic = "\ufdd6\ufdd4\ufdd2\ufdd0" + val frozenIdRe = s"$frozenIdMagic\\{(\\S{8})}".r + + def make( + _id: Option[String], + question: String, + choices: Choices, + tags: Tags, + creator: UserId, + footer: Option[String], + url: Option[String] + ) = Ask( + _id = AskId(_id.getOrElse(scalalib.ThreadLocalRandom.nextString(8))), + question = question, + choices = choices, + tags = tags, + createdAt = java.time.Instant.now(), + creator = creator, + footer = footer, + picks = None, + form = None, + url = None + ) + + def strip(text: String, n: Int = -1): String = + frozenIdRe.replaceAllIn(text, "").take(if n == -1 then text.length else n) + + def anonHash(text: String, aid: AskId): String = + "anon-" + base64 + .encodeToString( + java.security.MessageDigest.getInstance("SHA-1").digest(s"$text-$aid".getBytes("UTF-8")) + ) + .substring(0, 11) + + private lazy val base64 = java.util.Base64.getEncoder().withoutPadding(); diff --git a/modules/core/src/main/id.scala b/modules/core/src/main/id.scala index 8aa08eac7daa4..16e20d0eb192a 100644 --- a/modules/core/src/main/id.scala +++ b/modules/core/src/main/id.scala @@ -97,6 +97,9 @@ object id: opaque type ChallengeId = String object ChallengeId extends OpaqueString[ChallengeId] + opaque type AskId = String + object AskId extends OpaqueString[AskId] + opaque type ClasId = String object ClasId extends OpaqueString[ClasId] diff --git a/modules/core/src/main/perm.scala b/modules/core/src/main/perm.scala index 2ef4ff8a7c425..bef9f55e8e97f 100644 --- a/modules/core/src/main/perm.scala +++ b/modules/core/src/main/perm.scala @@ -103,6 +103,7 @@ enum Permission(val key: String, val alsoGrants: List[Permission], val name: Str case ApiHog extends Permission("API_HOG", "API hog") case ApiChallengeAdmin extends Permission("API_CHALLENGE_ADMIN", "API Challenge admin") case LichessTeam extends Permission("LICHESS_TEAM", List(Beta), "Lichess team") + case BotEditor extends Permission("BOT_EDITOR", "Bot editor") case TimeoutMod extends Permission( "TIMEOUT_MOD", @@ -188,6 +189,7 @@ enum Permission(val key: String, val alsoGrants: List[Permission], val name: Str extends Permission( "ADMIN", List( + BotEditor, LichessTeam, UserSearch, PrizeBan, @@ -243,7 +245,7 @@ object Permission: val all: Set[Permission] = values.toSet val nonModPermissions: Set[Permission] = - Set(Beta, Coach, Teacher, Developer, Verified, ContentTeam, BroadcastTeam, ApiHog) + Set(Beta, Coach, Teacher, Developer, Verified, ContentTeam, BroadcastTeam, ApiHog, BotEditor) val modPermissions: Set[Permission] = all.diff(nonModPermissions) diff --git a/modules/core/src/main/timeline.scala b/modules/core/src/main/timeline.scala index d1e13425a310e..b873b31b66e8d 100644 --- a/modules/core/src/main/timeline.scala +++ b/modules/core/src/main/timeline.scala @@ -42,6 +42,9 @@ case class UblogPostLike(userId: UserId, id: UblogPostId, title: String) extends def userIds = List(userId) case class StreamStart(id: UserId, name: String) extends Atom("streamStart", false): def userIds = List(id) +case class AskConcluded(userId: UserId, question: String, askUrl: String) + extends Atom(s"askConcluded:${question}", false): + def userIds = List(userId) enum Propagation: case Users(users: List[UserId]) diff --git a/modules/coreI18n/src/main/key.scala b/modules/coreI18n/src/main/key.scala index 7ce21504921c3..ea030114d7044 100644 --- a/modules/coreI18n/src/main/key.scala +++ b/modules/coreI18n/src/main/key.scala @@ -1387,6 +1387,7 @@ object I18nKey: val `thisAccountIsClosed`: I18nKey = "settings:thisAccountIsClosed" object site: + val `askConcluded`: I18nKey = "askConcluded" val `playWithAFriend`: I18nKey = "playWithAFriend" val `playWithTheMachine`: I18nKey = "playWithTheMachine" val `toInviteSomeoneToPlayGiveThisUrl`: I18nKey = "toInviteSomeoneToPlayGiveThisUrl" diff --git a/modules/feed/src/main/Env.scala b/modules/feed/src/main/Env.scala index e966276230bf0..e9bd7f85a68b3 100644 --- a/modules/feed/src/main/Env.scala +++ b/modules/feed/src/main/Env.scala @@ -6,9 +6,12 @@ import lila.core.config.CollName import lila.core.lilaism.Lilaism.* @Module -final class Env(cacheApi: lila.memo.CacheApi, db: lila.db.Db, flairApi: lila.core.user.FlairApi)(using - Executor -): +final class Env( + cacheApi: lila.memo.CacheApi, + db: lila.db.Db, + flairApi: lila.core.user.FlairApi, + askApi: lila.core.ask.AskApi +)(using Executor): private val feedColl = db(CollName("daily_feed")) val api = wire[FeedApi] diff --git a/modules/feed/src/main/Feed.scala b/modules/feed/src/main/Feed.scala index 7ef94f28c1082..16fc95d0d80af 100644 --- a/modules/feed/src/main/Feed.scala +++ b/modules/feed/src/main/Feed.scala @@ -5,6 +5,8 @@ import reactivemongo.api.bson.* import reactivemongo.api.bson.Macros.Annotations.Key import java.time.format.{ DateTimeFormatter, FormatStyle } +import lila.core.ask.{ Ask, AskApi } +import lila.db.dsl.{ *, given } import lila.core.lilaism.Lilaism.* export lila.common.extensions.unapply @@ -41,7 +43,9 @@ object Feed: import scalalib.ThreadLocalRandom def makeId = ThreadLocalRandom.nextString(6) -final class FeedApi(coll: Coll, cacheApi: CacheApi, flairApi: FlairApi)(using Executor): +final class FeedApi(coll: Coll, cacheApi: CacheApi, flairApi: FlairApi, askApi: AskApi)(using + Executor +): import Feed.* @@ -68,10 +72,25 @@ final class FeedApi(coll: Coll, cacheApi: CacheApi, flairApi: FlairApi)(using Ex def recentPublished = cache.store.get({}).map(_.filter(_.published)) - def get(id: ID): Fu[Option[Update]] = coll.byId[Update](id) - - def set(update: Update): Funit = - for _ <- coll.update.one($id(update.id), update, upsert = true) yield cache.clear() + def get(id: ID): Fu[Option[Update]] = coll + .byId[Update](id) + .flatMap: + case Some(up) => askApi.repo.preload(up.content.value).inject(up.some) + case _ => fuccess(none[Update]) + + def edit(id: ID): Fu[Option[Update]] = get(id).flatMap: + case Some(up) => + askApi + .unfreezeAndLoad(up.content.value) + .map: text => + up.copy(content = Markdown(text.pp)).some + case _ => fuccess(none[Update]) + + def set(update: Update)(using me: Me): Funit = + for + text <- askApi.freezeAndCommit(update.content.value, me, s"/feed#${update.id}".some) + _ <- coll.update.one($id(update.id), update.copy(content = Markdown(text)), upsert = true) + yield cache.clear() def delete(id: ID): Funit = for _ <- coll.delete.one($id(id)) yield cache.clear() diff --git a/modules/feed/src/main/FeedUi.scala b/modules/feed/src/main/FeedUi.scala index 58db4c45f009e..86ca9da0ba76b 100644 --- a/modules/feed/src/main/FeedUi.scala +++ b/modules/feed/src/main/FeedUi.scala @@ -9,25 +9,28 @@ import lila.ui.{ *, given } import ScalatagsTemplate.{ *, given } final class FeedUi(helpers: Helpers, atomUi: AtomUi)( - sitePage: String => Context ?=> Page + sitePage: String => Context ?=> Page, + askRender: (Frag) => Context ?=> Frag )(using Executor): import helpers.{ *, given } - private def renderCache[A](ttl: FiniteDuration)(toFrag: A => Frag): A => Frag = - val cache = lila.memo.CacheApi.scaffeineNoScheduler - .expireAfterWrite(ttl) - .build[A, String]() - from => raw(cache.get(from, from => toFrag(from).render)) + // private def renderCache[A](ttl: FiniteDuration)(toFrag: A => Frag): A => Frag = + // val cache = lila.memo.CacheApi.scaffeineNoScheduler + // .expireAfterWrite(1 minute) + // .build[A, String]() + // from => raw(cache.get(from, from => toFrag(from).render)) - private def page(title: String, edit: Boolean = false)(using Context): Page = + private def page(title: String, hasAsks: Boolean, edit: Boolean = false)(using Context): Page = sitePage(title) .css("bits.dailyFeed") + .css(hasAsks.option("bits.ask")) .js(infiniteScrollEsmInit) .js(edit.option(Esm("bits.flatpickr"))) .js(edit.option(esmInitBit("dailyFeed"))) + .js(hasAsks.option(esmInit("bits.ask"))) - def index(ups: Paginator[Feed.Update])(using Context) = - page("Updates"): + def index(ups: Paginator[Feed.Update], hasAsks: Boolean)(using Context) = + page("Updates", hasAsks): div(cls := "daily-feed box box-pad")( boxTop( h1("Lichess updates"), @@ -48,7 +51,7 @@ final class FeedUi(helpers: Helpers, atomUi: AtomUi)( updates(ups, editor = Granter.opt(_.Feed)) ) - val lobbyUpdates = renderCache[List[Feed.Update]](1.minute): ups => + def lobbyUpdates(ups: List[Feed.Update])(using Context) = div(cls := "daily-feed__updates")( ups.map: update => div(cls := "daily-feed__update")( @@ -57,7 +60,7 @@ final class FeedUi(helpers: Helpers, atomUi: AtomUi)( a(cls := "daily-feed__update__day", href := s"${routes.Feed.index(1)}#${update.id}"): momentFromNow(update.at) , - rawHtml(update.rendered) + askRender(rawHtml(update.rendered)) ) ), div(cls := "daily-feed__update")( @@ -69,7 +72,7 @@ final class FeedUi(helpers: Helpers, atomUi: AtomUi)( ) def create(form: Form[?])(using Context) = - page("Lichess updates: New", true): + page("Lichess updates: New", true, true): main(cls := "daily-feed page-small box box-pad")( boxTop( h1( @@ -83,7 +86,7 @@ final class FeedUi(helpers: Helpers, atomUi: AtomUi)( ) def edit(form: Form[?], update: Feed.Update)(using Context) = - page(s"Lichess update ${update.id}", true): + page(s"Lichess update ${update.id}", true, true): main(cls := "daily-feed page-small")( div(cls := "box box-pad")( boxTop( @@ -150,7 +153,9 @@ final class FeedUi(helpers: Helpers, atomUi: AtomUi)( ) ) ), - div(cls := "daily-feed__update__markup")(rawHtml(update.rendered)) + div(cls := "daily-feed__update__markup")( + askRender(rawHtml(update.rendered)) + ) ) ), pagerNext(ups, np => routes.Feed.index(np).url) diff --git a/modules/forum/src/main/Env.scala b/modules/forum/src/main/Env.scala index 5316bb0adae50..eab27960402ba 100644 --- a/modules/forum/src/main/Env.scala +++ b/modules/forum/src/main/Env.scala @@ -30,7 +30,8 @@ final class Env( userApi: lila.core.user.UserApi, teamApi: lila.core.team.TeamApi, cacheApi: lila.memo.CacheApi, - ws: StandaloneWSClient + ws: StandaloneWSClient, + askApi: lila.core.ask.AskApi )(using Executor, Scheduler, akka.stream.Materializer): private val config = appConfig.get[ForumConfig]("forum")(AutoConfig.loader) diff --git a/modules/forum/src/main/ForumExpand.scala b/modules/forum/src/main/ForumExpand.scala index 44709f6121977..bfc67555aaef6 100644 --- a/modules/forum/src/main/ForumExpand.scala +++ b/modules/forum/src/main/ForumExpand.scala @@ -5,22 +5,19 @@ import scalatags.Text.all.{ Frag, raw } import lila.common.RawHtml import lila.core.config.NetDomain -final class ForumTextExpand(using Executor, Scheduler): +final class ForumTextExpand(askApi: lila.core.ask.AskApi)(using Executor, Scheduler): - private def one(text: String)(using NetDomain): Fu[Frag] = + private def one(post: ForumPost)(using NetDomain): Fu[ForumPost.WithFrag] = lila.common.Bus - .ask("lpv")(lila.core.misc.lpv.LpvLinkRenderFromText(text, _)) + .ask("lpv")(lila.core.misc.lpv.LpvLinkRenderFromText(post.text, _)) .map: linkRender => raw: RawHtml.nl2br { - RawHtml.addLinks(text, expandImg = true, linkRender = linkRender.some).value + RawHtml.addLinks(post.text, expandImg = true, linkRender = linkRender.some).value }.value + .zip(askApi.repo.preload(post.text)) + .map: (body, _) => + ForumPost.WithFrag(post, body) def manyPosts(posts: Seq[ForumPost])(using NetDomain): Fu[Seq[ForumPost.WithFrag]] = - posts.view - .map(_.text) - .toList - .sequentially(one) - .map: - _.zip(posts).map: (body, post) => - ForumPost.WithFrag(post, body) + posts.traverse(one) diff --git a/modules/forum/src/main/ForumForm.scala b/modules/forum/src/main/ForumForm.scala index d287f0f0b7949..0981571167c25 100644 --- a/modules/forum/src/main/ForumForm.scala +++ b/modules/forum/src/main/ForumForm.scala @@ -46,13 +46,12 @@ final private[forum] class ForumForm( single("categ" -> nonEmptyText.into[ForumCategId]) private def userTextMapping(inOwnTeam: Boolean, previousText: Option[String] = None)(using me: Me) = - cleanText(minLength = 3, 20_000) + cleanText(minLength = 3, 10_000_000) // bot move dumps .verifying( "You have reached the daily maximum for links in forum posts.", t => inOwnTeam || promotion.test(me, t, previousText) ) - - val diagnostic = Form(single("text" -> nonEmptyText(maxLength = 100_000))) + val diagnostic = Form(single("text" -> nonEmptyText(maxLength = 10_000_000))) // bot move dumps object ForumForm: diff --git a/modules/forum/src/main/ForumPostApi.scala b/modules/forum/src/main/ForumPostApi.scala index 633ae88af3636..6a784a81d6521 100644 --- a/modules/forum/src/main/ForumPostApi.scala +++ b/modules/forum/src/main/ForumPostApi.scala @@ -17,7 +17,8 @@ final class ForumPostApi( spam: lila.core.security.SpamApi, promotion: lila.core.security.PromotionApi, shutupApi: lila.core.shutup.ShutupApi, - detectLanguage: DetectLanguage + detectLanguage: DetectLanguage, + askApi: lila.core.ask.AskApi )(using Executor)(using scheduler: Scheduler) extends lila.core.forum.ForumPostApi: @@ -32,10 +33,11 @@ final class ForumPostApi( val publicMod = MasterGranter(_.PublicMod) val modIcon = ~data.modIcon && (publicMod || MasterGranter(_.SeeReport)) val anonMod = modIcon && !publicMod + val frozen = askApi.freeze(spam.replace(data.text), me) val post = ForumPost.make( topicId = topic.id, userId = (!anonMod).option(me), - text = spam.replace(data.text), + text = frozen.text, number = topic.nbPosts + 1, lang = lang.map(_.language), troll = me.marks.troll, @@ -49,6 +51,7 @@ final class ForumPostApi( _ <- postRepo.coll.insert.one(post) _ <- topicRepo.coll.update.one($id(topic.id), topic.withPost(post)) _ <- categRepo.coll.update.one($id(categ.id), categ.withPost(topic, post)) + _ <- askApi.commit(frozen, s"/forum/redirect/post/${post.id}".some) yield promotion.save(me, post.text) if post.isTeam @@ -83,13 +86,16 @@ final class ForumPostApi( case (_, post) if !post.canStillBeEdited => fufail("Post can no longer be edited") case (_, post) => - val newPost = post.editPost(nowInstant, spam.replace(newText)) - val save = (newPost.text != post.text).so: - for - _ <- postRepo.coll.update.one($id(post.id), newPost) - _ <- newPost.isAnonModPost.so(logAnonPost(newPost, edit = true)) - yield promotion.save(me, newPost.text) - save.inject(newPost) + askApi + .freezeAndCommit(spam.replace(newText), me, s"/forum/redirect/post/${postId}".some) + .flatMap: frozen => + val newPost = post.editPost(nowInstant, frozen) + val save = (newPost.text != post.text).so: + for + _ <- postRepo.coll.update.one($id(post.id), newPost) + _ <- newPost.isAnonModPost.so(logAnonPost(newPost, edit = true)) + yield promotion.save(me, newPost.text) + save.inject(newPost) } def urlData(postId: ForumPostId, forUser: Option[User]): Fu[Option[PostUrlData]] = diff --git a/modules/forum/src/main/ForumTopicApi.scala b/modules/forum/src/main/ForumTopicApi.scala index fed628060f46c..770f12a260c6c 100644 --- a/modules/forum/src/main/ForumTopicApi.scala +++ b/modules/forum/src/main/ForumTopicApi.scala @@ -25,7 +25,8 @@ final private class ForumTopicApi( shutupApi: lila.core.shutup.ShutupApi, detectLanguage: DetectLanguage, cacheApi: CacheApi, - relationApi: lila.core.relation.RelationApi + relationApi: lila.core.relation.RelationApi, + askApi: lila.core.ask.AskApi )(using Executor): import BSONHandlers.given @@ -82,6 +83,7 @@ final private class ForumTopicApi( data: ForumForm.TopicData )(using me: Me): Fu[ForumTopic] = topicRepo.nextSlug(categ, data.name).zip(detectLanguage(data.post.text)).flatMap { (slug, lang) => + val frozen = askApi.freeze(spam.replace(data.post.text), me) val topic = ForumTopic.make( categId = categ.id, slug = slug, @@ -93,7 +95,7 @@ final private class ForumTopicApi( topicId = topic.id, userId = me.some, troll = me.marks.troll, - text = spam.replace(data.post.text), + text = frozen.text, lang = lang.map(_.language), number = 1, categId = categ.id, @@ -106,6 +108,7 @@ final private class ForumTopicApi( _ <- topicRepo.coll.insert.one(topic.withPost(post)) _ <- categRepo.coll.update.one($id(categ.id), categ.withPost(topic, post)) _ <- postRepo.coll.insert.one(post) + _ <- askApi.commit(frozen, s"/forum/redirect/post/${post.id}".some) yield promotion.save(me, post.text) val text = s"${topic.name} ${post.text}" diff --git a/modules/forum/src/main/model.scala b/modules/forum/src/main/model.scala index e97097d7cf0b8..8a766c5e682f2 100644 --- a/modules/forum/src/main/model.scala +++ b/modules/forum/src/main/model.scala @@ -32,7 +32,7 @@ case class TopicView( def createdAt = topic.createdAt case class PostView(post: ForumPost, topic: ForumTopic, categ: ForumCateg): - def show = post.showUserIdOrAuthor + " @ " + topic.name + " - " + post.text.take(80) + def show = post.showUserIdOrAuthor + " @ " + topic.name + " - " + lila.core.ask.Ask.strip(post.text, 80) def logFormatted = "%s / %s#%s / %s".format(categ.name, topic.name, post.number, post.text) object PostView: diff --git a/modules/forum/src/main/ui/PostUi.scala b/modules/forum/src/main/ui/PostUi.scala index 1aef5e1f2a324..7771c34a2a3db 100644 --- a/modules/forum/src/main/ui/PostUi.scala +++ b/modules/forum/src/main/ui/PostUi.scala @@ -7,7 +7,10 @@ import lila.ui.* import ScalatagsTemplate.{ *, given } -final class PostUi(helpers: Helpers, bits: ForumBits): +final class PostUi(helpers: Helpers, bits: ForumBits)( + askRender: (Frag) => Context ?=> Frag, + unfreeze: String => String +): import helpers.{ *, given } def show( @@ -102,7 +105,7 @@ final class PostUi(helpers: Helpers, bits: ForumBits): frag: val postFrag = div(cls := s"forum-post__message expand-text")( if post.erased then "" - else body + else askRender(body) ) if hide then div(cls := "forum-post__blocked")( @@ -124,15 +127,13 @@ final class PostUi(helpers: Helpers, bits: ForumBits): cls := "post-text-area edit-post-box", minlength := 3, required - )(post.text), + )(unfreeze(post.text)), div(cls := "edit-buttons")( a( cls := "edit-post-cancel", href := routes.ForumPost.redirect(post.id), style := "margin-left:20px" - ): - trans.site.cancel() - , + )(trans.site.cancel()), submitButton(cls := "button")(trans.site.apply()) ) ) diff --git a/modules/forum/src/main/ui/TopicUi.scala b/modules/forum/src/main/ui/TopicUi.scala index f97913ddb0a59..cb3c7b61c6444 100644 --- a/modules/forum/src/main/ui/TopicUi.scala +++ b/modules/forum/src/main/ui/TopicUi.scala @@ -82,7 +82,8 @@ final class TopicUi(helpers: Helpers, bits: ForumBits, postUi: PostUi)( unsub: Option[Boolean], canModCateg: Boolean, formText: Option[String] = None, - replyBlocked: Boolean = false + replyBlocked: Boolean = false, + hasAsks: Boolean = false )(using ctx: Context) = val isDiagnostic = categ.isDiagnostic && (canModCateg || ctx.me.exists(topic.isAuthor)) val headerText = if isDiagnostic then "Diagnostics" else topic.name @@ -96,8 +97,12 @@ final class TopicUi(helpers: Helpers, bits: ForumBits, postUi: PostUi)( val pager = paginationByQuery(routes.ForumTopic.show(categ.id, topic.slug, 1), posts, showPost = true) Page(s"${topic.name} • page ${posts.currentPage}/${posts.nbPages} • ${categ.name}") .css("bits.forum") + .css(hasAsks.option("bits.ask")) .csp(_.withInlineIconFont.withTwitter) - .js(Esm("bits.forum") ++ Esm("bits.expandText") ++ formWithCaptcha.isDefined.so(captchaEsm)) + .js(Esm("bits.forum")) + .js(Esm("bits.expandText")) + .js(hasAsks.option(esmInit("bits.ask"))) + .js(formWithCaptcha.isDefined.option(captchaEsm)) .graph( OpenGraph( title = topic.name, diff --git a/modules/local/src/main/Env.scala b/modules/local/src/main/Env.scala new file mode 100644 index 0000000000000..7e9383dbfd3f4 --- /dev/null +++ b/modules/local/src/main/Env.scala @@ -0,0 +1,28 @@ +package lila.local + +import com.softwaremill.macwire.* +import play.api.Configuration + +import lila.common.autoconfig.{ *, given } +import lila.core.config.* + +@Module +final private class LocalConfig( + @ConfigName("asset_path") val assetPath: String +) + +@Module +final class Env( + appConfig: Configuration, + db: lila.db.Db, + getFile: (String => java.io.File) +)(using + Executor, + akka.stream.Materializer +)(using mode: play.api.Mode, scheduler: Scheduler): + + private val config: LocalConfig = appConfig.get[LocalConfig]("local")(AutoConfig.loader) + + val repo = LocalRepo(db(CollName("local_bots")), db(CollName("local_assets"))) + + val api: LocalApi = wire[LocalApi] diff --git a/modules/local/src/main/LocalApi.scala b/modules/local/src/main/LocalApi.scala new file mode 100644 index 0000000000000..da0de648cd49d --- /dev/null +++ b/modules/local/src/main/LocalApi.scala @@ -0,0 +1,56 @@ +package lila.local + +import java.nio.file.{ Files as NioFiles, Paths } +import play.api.libs.json.* +import play.api.libs.Files +import play.api.mvc.* +import akka.stream.scaladsl.{ FileIO, Source } +import akka.util.ByteString + +// this stuff is for bot devs + +final private class LocalApi(config: LocalConfig, repo: LocalRepo, getFile: (String => java.io.File))(using + Executor, + akka.stream.Materializer +): + + @volatile private var cachedAssets: Option[JsObject] = None + + def storeAsset( + tpe: AssetType, + name: String, + file: MultipartFormData.FilePart[Files.TemporaryFile] + ): Fu[Either[String, JsObject]] = + FileIO + .fromPath(file.ref.path) + .runWith(FileIO.toPath(getFile(s"public/lifat/bots/${tpe}/$name").toPath)) + .map: result => + if result.wasSuccessful then Right(updateAssets) + else Left(s"Error uploading asset $tpe $name") + .recover: + case e: Exception => Left(s"Exception: ${e.getMessage}") + + def assetKeys: JsObject = cachedAssets.getOrElse(updateAssets) + + private def listFiles(tpe: String, ext: String): List[String] = + val path = getFile(s"public/lifat/bots/${tpe}") + if !path.exists() then + NioFiles.createDirectories(path.toPath) + Nil + else + path + .listFiles() + .toList + .map(_.getName) + .filter(_.endsWith(s".${ext}")) + + def updateAssets: JsObject = + val newAssets = Json.obj( + "image" -> listFiles("image", "webp"), + "net" -> listFiles("net", "pb"), + "sound" -> listFiles("sound", "mp3"), + "book" -> listFiles("book", "png") + .map(_.dropRight(4)) + ) + cachedAssets = newAssets.some + newAssets diff --git a/modules/local/src/main/LocalRepo.scala b/modules/local/src/main/LocalRepo.scala new file mode 100644 index 0000000000000..e160ca3bbd713 --- /dev/null +++ b/modules/local/src/main/LocalRepo.scala @@ -0,0 +1,75 @@ +package lila.local + +import reactivemongo.api.Cursor +import reactivemongo.api.bson.* +import lila.common.Json.given +import lila.db.JSON +import play.api.libs.json.* + +import lila.db.dsl.{ *, given } + +final private class LocalRepo(private[local] val bots: Coll, private[local] val assets: Coll)(using Executor): + given Format[BotMeta] = Json.format + + def getVersions(botId: Option[UserId] = none): Fu[JsArray] = + bots + .find(botId.fold[Bdoc]($empty)(v => $doc("uid" -> v)), $doc("_id" -> 0).some) + .sort($doc("version" -> -1)) + .cursor[Bdoc]() + .list(Int.MaxValue) + .map: docs => + JsArray(docs.map(JSON.jval)) + + def getLatestBots(): Fu[JsArray] = + bots + .aggregateWith[Bdoc](readPreference = ReadPref.sec): framework => + import framework.* + List( + Sort(Descending("version")), + GroupField("uid")("doc" -> FirstField("$ROOT")), + ReplaceRootField("doc"), + Project($doc("_id" -> 0)) + ) + .list(Int.MaxValue) + .map: docs => + JsArray(docs.flatMap(JSON.jval(_).asOpt[JsObject])) + + def putBot(bot: JsObject, author: UserId): Fu[JsObject] = + val botId = (bot \ "uid").as[UserId] + for + nextVersion <- bots + .find($doc("uid" -> botId)) + .sort($doc("version" -> -1)) + .one[Bdoc] + .map(_.flatMap(_.getAsOpt[Int]("version")).getOrElse(-1) + 1) // race condition + botMeta = BotMeta(botId, author, nextVersion) + newBot = bot ++ Json.toJson(botMeta).as[JsObject] + _ <- bots.insert.one(JSON.bdoc(newBot)) + yield newBot + + def getAssets: Fu[Map[String, String]] = + assets + .find($doc()) + .cursor[Bdoc]() + .list(Int.MaxValue) + .map { docs => + docs.flatMap { doc => + for + id <- doc.getAsOpt[String]("_id") + name <- doc.getAsOpt[String]("name") + yield id -> name + }.toMap + } + + def nameAsset(tpe: Option[AssetType], key: String, name: String, author: Option[String]): Funit = + // filter out bookCovers as they share the same key as the book + if !(tpe.has("book") && key.endsWith(".png")) then + val id = if tpe.has("book") then key.dropRight(4) else key + val setDoc = $doc("name" -> name) ++ author.fold($empty)(a => $doc("author" -> a)) + assets.update.one($doc("_id" -> id), $doc("$set" -> setDoc), upsert = true).void + else funit + + def deleteAsset(key: String): Funit = + assets.delete.one($doc("_id" -> key)).void + +end LocalRepo diff --git a/modules/local/src/main/LocalUi.scala b/modules/local/src/main/LocalUi.scala new file mode 100644 index 0000000000000..18a35ba743722 --- /dev/null +++ b/modules/local/src/main/LocalUi.scala @@ -0,0 +1,36 @@ +package lila.local +package ui + +import play.api.libs.json.JsObject + +import lila.ui.* +import ScalatagsTemplate.{ *, given } + +final class LocalUi(helpers: Helpers): + import helpers.{ *, given } + + def index(data: JsObject, moduleName: String = "local")(using ctx: Context): Page = + Page("Private Play") + .css(moduleName) + .css("round") + .css(ctx.pref.hasKeyboardMove.option("keyboardMove")) + .css(ctx.pref.hasVoice.option("voice")) + .js( + PageModule( + moduleName, + data + ) + ) + .js(Esm("round")) + .csp(_.withWebAssembly) + .graph( + OpenGraph( + title = "Private Play", + description = "Private Play", + url = netBaseUrl.value + ) + ) + .flag(_.zoom) + .hrefLangs(lila.ui.LangPath("/")) { + emptyFrag + } diff --git a/modules/local/src/main/model.scala b/modules/local/src/main/model.scala new file mode 100644 index 0000000000000..410390c7643bf --- /dev/null +++ b/modules/local/src/main/model.scala @@ -0,0 +1,18 @@ +package lila.local + +import reactivemongo.api.bson.* +// import reactivemongo.api.bson.collection.BSONCollection +import play.api.libs.json.* + +case class GameSetup( + white: Option[String], + black: Option[String], + fen: Option[String], + initial: Option[Float], + increment: Option[Float], + go: Boolean = false +) + +case class BotMeta(uid: UserId, author: UserId, version: Int) + +type AssetType = "sound" | "image" | "book" | "net" diff --git a/modules/local/src/main/package.scala b/modules/local/src/main/package.scala new file mode 100644 index 0000000000000..ecdf64ebda218 --- /dev/null +++ b/modules/local/src/main/package.scala @@ -0,0 +1,6 @@ +package lila.local + +export lila.core.lilaism.Lilaism.{ *, given } +export lila.common.extensions.* + +private val logger = lila.log("local") diff --git a/modules/security/src/main/Permission.scala b/modules/security/src/main/Permission.scala index 7fdb64cedb49c..e30a16da93406 100644 --- a/modules/security/src/main/Permission.scala +++ b/modules/security/src/main/Permission.scala @@ -65,7 +65,8 @@ object Permission: PuzzleCurator, OpeningWiki, Presets, - Feed + Feed, + BotEditor ), "Dev" -> List( Cli, diff --git a/modules/streamer/src/main/ui/StreamerUi.scala b/modules/streamer/src/main/ui/StreamerUi.scala index 2da22e9a80c39..a63900974f7bd 100644 --- a/modules/streamer/src/main/ui/StreamerUi.scala +++ b/modules/streamer/src/main/ui/StreamerUi.scala @@ -105,7 +105,7 @@ final class StreamerUi(helpers: Helpers, bits: StreamerBits)(using netDomain: Ne def show(s: Streamer.WithUserAndStream, perfRatings: Frag, activities: Frag)(using ctx: Context) = Page(s"${s.titleName} streams chess") .csp(csp) - .css("bits.streamer.show") + .css("bits.streamer.show", "user.activity") .js(esmInitBit("streamerSubscribe")) .graph( OpenGraph( diff --git a/modules/timeline/src/main/Entry.scala b/modules/timeline/src/main/Entry.scala index fea904b23df58..cb4040d9f049b 100644 --- a/modules/timeline/src/main/Entry.scala +++ b/modules/timeline/src/main/Entry.scala @@ -38,6 +38,7 @@ object Entry: case d: TeamJoin => "team-join" -> toBson(d) case d: TeamCreate => "team-create" -> toBson(d) case d: ForumPost => "forum-post" -> toBson(d) + case d: AskConcluded => "ask-concluded" -> toBson(d) case d: UblogPost => "ublog-post" -> toBson(d) case d: TourJoin => "tour-join" -> toBson(d) case d: GameEnd => "game-end" -> toBson(d) @@ -57,6 +58,7 @@ object Entry: given teamJoinHandler: BSONDocumentHandler[TeamJoin] = Macros.handler given teamCreateHandler: BSONDocumentHandler[TeamCreate] = Macros.handler given forumPostHandler: BSONDocumentHandler[ForumPost] = Macros.handler + given askConcludedHandler: BSONDocumentHandler[AskConcluded] = Macros.handler given ublogPostHandler: BSONDocumentHandler[UblogPost] = Macros.handler given tourJoinHandler: BSONDocumentHandler[TourJoin] = Macros.handler given gameEndHandler: BSONDocumentHandler[GameEnd] = Macros.handler @@ -73,6 +75,7 @@ object Entry: "team-join" -> teamJoinHandler, "team-create" -> teamCreateHandler, "forum-post" -> forumPostHandler, + "ask-concluded" -> askConcludedHandler, "ublog-post" -> ublogPostHandler, "tour-join" -> tourJoinHandler, "game-end" -> gameEndHandler, @@ -90,6 +93,7 @@ object Entry: val teamJoinWrite = Json.writes[TeamJoin] val teamCreateWrite = Json.writes[TeamCreate] val forumPostWrite = Json.writes[ForumPost] + val askConcludedWrite = Json.writes[AskConcluded] val ublogPostWrite = Json.writes[UblogPost] val tourJoinWrite = Json.writes[TourJoin] val gameEndWrite = Json.writes[GameEnd] @@ -105,6 +109,7 @@ object Entry: case d: TeamJoin => teamJoinWrite.writes(d) case d: TeamCreate => teamCreateWrite.writes(d) case d: ForumPost => forumPostWrite.writes(d) + case d: AskConcluded => askConcludedWrite.writes(d) case d: UblogPost => ublogPostWrite.writes(d) case d: TourJoin => tourJoinWrite.writes(d) case d: GameEnd => gameEndWrite.writes(d) diff --git a/modules/timeline/src/main/TimelineUi.scala b/modules/timeline/src/main/TimelineUi.scala index f426f3c20bde6..8f3f2149a2bf2 100644 --- a/modules/timeline/src/main/TimelineUi.scala +++ b/modules/timeline/src/main/TimelineUi.scala @@ -51,6 +51,14 @@ final class TimelineUi(helpers: Helpers)( title := topicName )(shorten(topicName, 30)) ) + case AskConcluded(userId, question, url) => + trans.site.askConcluded( + userLink(userId), + a( + href := url, + title := question + )(shorten(question, 30)) + ) case UblogPost(userId, id, slug, title) => trans.ublog.xPublishedY( userLink(userId), diff --git a/modules/ublog/src/main/Env.scala b/modules/ublog/src/main/Env.scala index ead623fa71e12..881c606eaedf0 100644 --- a/modules/ublog/src/main/Env.scala +++ b/modules/ublog/src/main/Env.scala @@ -18,7 +18,8 @@ final class Env( captcha: lila.core.captcha.CaptchaApi, cacheApi: lila.memo.CacheApi, langList: lila.core.i18n.LangList, - net: NetConfig + net: NetConfig, + askApi: lila.core.ask.AskApi )(using Executor, Scheduler, akka.stream.Materializer, play.api.Mode): export net.{ assetBaseUrl, baseUrl, domain, assetDomain } diff --git a/modules/ublog/src/main/UblogApi.scala b/modules/ublog/src/main/UblogApi.scala index 5c73b9b63f776..10cb075b4fd97 100644 --- a/modules/ublog/src/main/UblogApi.scala +++ b/modules/ublog/src/main/UblogApi.scala @@ -3,6 +3,7 @@ package lila.ublog import reactivemongo.akkastream.{ AkkaStreamCursor, cursorProducer } import reactivemongo.api.* +import lila.common.Markdown import lila.core.shutup.{ PublicSource, ShutupApi } import lila.core.timeline as tl import lila.db.dsl.{ *, given } @@ -14,29 +15,31 @@ final class UblogApi( userApi: lila.core.user.UserApi, picfitApi: PicfitApi, shutupApi: ShutupApi, - irc: lila.core.irc.IrcApi + irc: lila.core.irc.IrcApi, + askApi: lila.core.ask.AskApi )(using Executor) extends lila.core.ublog.UblogApi: import UblogBsonHandlers.{ *, given } def create(data: UblogForm.UblogPostData, author: User): Fu[UblogPost] = - val post = data.create(author) - colls.post.insert - .one( + val frozen = askApi.freeze(data.markdown.value, author.id) + val post = data.create(author, Markdown(frozen.text)) + (askApi.commit(frozen, s"/ublog/${post.id}/redirect".some) >> + colls.post.insert.one( bsonWriteObjTry[UblogPost](post).get ++ $doc("likers" -> List(author.id)) - ) - .inject(post) + )).inject(post) def getByPrismicId(id: String): Fu[Option[UblogPost]] = colls.post.one[UblogPost]($doc("prismicId" -> id)) def update(data: UblogForm.UblogPostData, prev: UblogPost)(using me: Me): Fu[UblogPost] = for + frozen <- askApi.freezeAndCommit(data.markdown.value, me) author <- userApi.byId(prev.created.by).map(_ | me.value) blog <- getUserBlog(author, insertMissing = true) - post = data.update(me.value, prev) + post = data.update(me.value, prev, Markdown(frozen)) _ <- colls.post.update.one($id(prev.id), $set(bsonWriteObjTry[UblogPost](post).get)) _ <- (post.live && prev.lived.isEmpty).so(onFirstPublish(author, blog, post)) - yield post + yield post.copy(markdown = Markdown(askApi.unfreeze(frozen))) private def onFirstPublish(author: User, blog: UblogBlog, post: UblogPost): Funit = for _ <- rank @@ -162,7 +165,9 @@ final class UblogApi( .list(30) def delete(post: UblogPost): Funit = - colls.post.delete.one($id(post.id)) >> image.deleteAll(post) + colls.post.delete.one($id(post.id)) >> + image.deleteAll(post) >> + askApi.repo.deleteAll(post.markdown.value) def setTier(blog: UblogBlog.Id, tier: UblogRank.Tier): Funit = colls.blog.update diff --git a/modules/ublog/src/main/UblogForm.scala b/modules/ublog/src/main/UblogForm.scala index 6dfa79a6f4234..4b21d6bac06cb 100644 --- a/modules/ublog/src/main/UblogForm.scala +++ b/modules/ublog/src/main/UblogForm.scala @@ -64,13 +64,13 @@ object UblogForm: move: String ) extends WithCaptcha: - def create(user: User) = + def create(user: User, updatedMarkdown: Markdown) = UblogPost( id = UblogPost.randomId, blog = UblogBlog.Id.User(user.id), title = title, intro = intro, - markdown = markdown, + markdown = updatedMarkdown, language = language.orElse(user.realLang.map(toLanguage)) | defaultLanguage, topics = topics.so(UblogTopic.fromStrList), image = none, @@ -86,11 +86,11 @@ object UblogForm: pinned = none ) - def update(user: User, prev: UblogPost) = + def update(user: User, prev: UblogPost, updatedMarkdown: Markdown) = prev.copy( title = title, intro = intro, - markdown = markdown, + markdown = updatedMarkdown, image = prev.image.map: i => i.copy(alt = imageAlt, credit = imageCredit), language = language | prev.language, diff --git a/modules/ublog/src/main/ui/UblogPostUi.scala b/modules/ublog/src/main/ui/UblogPostUi.scala index 90f71596217cb..506ca39ec9a80 100644 --- a/modules/ublog/src/main/ui/UblogPostUi.scala +++ b/modules/ublog/src/main/ui/UblogPostUi.scala @@ -7,7 +7,8 @@ import ScalatagsTemplate.{ *, given } final class UblogPostUi(helpers: Helpers, ui: UblogUi)( ublogRank: UblogRank, - connectLinks: Frag + connectLinks: Frag, + askRender: (Frag) => Context ?=> Frag ): import helpers.{ *, given } @@ -19,11 +20,15 @@ final class UblogPostUi(helpers: Helpers, ui: UblogUi)( others: List[UblogPost.PreviewPost], liked: Boolean, followable: Boolean, - followed: Boolean + followed: Boolean, + hasAsks: Boolean )(using ctx: Context) = Page(s"${trans.ublog.xBlog.txt(user.username)} • ${post.title}") .css("bits.ublog") - .js(Esm("bits.expandText") ++ ctx.isAuth.so(Esm("bits.ublog"))) + .css(hasAsks.option("bits.ask")) + .js(Esm("bits.expandText")) + .js(ctx.isAuth.option(Esm("bits.ublog"))) + .js(hasAsks.option(esmInit("bits.ask"))) .graph( OpenGraph( `type` = "article", @@ -102,7 +107,7 @@ final class UblogPostUi(helpers: Helpers, ui: UblogUi)( a(href := routes.Ublog.topic(topic.url, 1))(topic.value) ), strong(cls := "ublog-post__intro")(post.intro), - div(cls := "ublog-post__markup expand-text")(markup), + div(cls := "ublog-post__markup expand-text")(askRender(markup)), post.isLichess.option( div(cls := "ublog-post__lichess")( connectLinks, diff --git a/modules/ui/src/main/ContentSecurityPolicy.scala b/modules/ui/src/main/ContentSecurityPolicy.scala index 2b479a5e36344..8e6e12b5bb660 100644 --- a/modules/ui/src/main/ContentSecurityPolicy.scala +++ b/modules/ui/src/main/ContentSecurityPolicy.scala @@ -7,6 +7,7 @@ case class ContentSecurityPolicy( frameSrc: List[String], workerSrc: List[String], imgSrc: List[String], + mediaSrc: List[String], scriptSrc: List[String], fontSrc: List[String], baseUri: List[String] diff --git a/modules/ui/src/main/Icon.scala b/modules/ui/src/main/Icon.scala index c103fba0a836b..587f79dfca73a 100644 --- a/modules/ui/src/main/Icon.scala +++ b/modules/ui/src/main/Icon.scala @@ -143,3 +143,4 @@ object Icon: val AccountCircle: Icon = "" // e079 val Logo: Icon = "" // e07a val Switch: Icon = "" // e07b + val Blindfold: Icon = "" // e07c diff --git a/modules/web/src/main/ContentSecurityPolicy.scala b/modules/web/src/main/ContentSecurityPolicy.scala index 549f03d9837ea..c6c960f022f5e 100644 --- a/modules/web/src/main/ContentSecurityPolicy.scala +++ b/modules/web/src/main/ContentSecurityPolicy.scala @@ -1,6 +1,7 @@ package lila.web import lila.core.config.AssetDomain +import org.checkerframework.checker.units.qual.m object ContentSecurityPolicy: @@ -12,6 +13,7 @@ object ContentSecurityPolicy: frameSrc = List("'self'", assetDomain.value, "www.youtube.com", "player.twitch.tv", "player.vimeo.com"), workerSrc = List("'self'", assetDomain.value, "blob:"), imgSrc = List("'self'", "blob:", "data:", "*"), + mediaSrc = List("'self'", "blob:", assetDomain.value), scriptSrc = List("'self'", assetDomain.value), fontSrc = List("'self'", assetDomain.value), baseUri = List("'none'") @@ -25,6 +27,7 @@ object ContentSecurityPolicy: frameSrc = Nil, workerSrc = Nil, imgSrc = List("'self'", "blob:", "data:", "*"), + mediaSrc = Nil, scriptSrc = List("'self'", assetDomain.value), fontSrc = List("'self'", assetDomain.value), baseUri = List("'none'") @@ -39,6 +42,7 @@ object ContentSecurityPolicy: "frame-src " -> frameSrc, "worker-src " -> workerSrc, "img-src " -> imgSrc, + "media-src " -> mediaSrc, "script-src " -> scriptSrc, "font-src " -> fontSrc, "base-uri " -> baseUri diff --git a/modules/web/src/main/ui/AuthUi.scala b/modules/web/src/main/ui/AuthUi.scala index 52a4b29c95063..e57721f73ac57 100644 --- a/modules/web/src/main/ui/AuthUi.scala +++ b/modules/web/src/main/ui/AuthUi.scala @@ -121,7 +121,9 @@ final class AuthUi(helpers: Helpers): if form.exists(_.hasErrors) then "error" else "anim" }" )( - boxTop(h1(cls := "is-green text", dataIcon := Icon.Checkmark)(trans.site.checkYourEmail())), + boxTop( + h1(cls := "is-green text", dataIcon := Icon.Checkmark)("All set!") + ) /* trans.site.checkYourEmail())), p(trans.site.weHaveSentYouAnEmailClickTheLink()), h2("Not receiving it?"), ol( @@ -162,7 +164,7 @@ final class AuthUi(helpers: Helpers): a(href := routes.Account.emailConfirmHelp)("proceed to this page to solve the issue"), "." ) - ) + )*/ ) def signupConfirm(user: User, token: String, referrer: Option[String])(using Context) = diff --git a/package.json b/package.json index 5c1cf04c4e5b2..72078ce82f939 100644 --- a/package.json +++ b/package.json @@ -22,6 +22,11 @@ "node": ">=22.6", "pnpm": "^10" }, + "pnpm": { + "onlyBuiltDependencies": [ + "esbuild" + ] + }, "dependencies": { "@types/lichess": "workspace:*", "@types/web": "^0.0.201", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b6e57987cb31f..392b9980f2ee8 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -49,7 +49,7 @@ importers: version: 5.7.3 vitest: specifier: ^3.0.5 - version: 3.0.5(@types/node@22.13.4)(jsdom@26.0.0)(yaml@2.7.0) + version: 3.0.5(@types/node@22.13.4)(yaml@2.7.0) bin: dependencies: @@ -94,6 +94,9 @@ importers: common: specifier: workspace:* version: link:../common + dasher: + specifier: workspace:* + version: link:../dasher debounce-promise: specifier: ^3.1.2 version: 3.1.2 @@ -147,7 +150,7 @@ importers: version: 3.2.2 '@types/canvas-confetti': specifier: ^1.6.4 - version: 1.6.4 + version: 1.9.0 '@types/debounce-promise': specifier: ^3.1.9 version: 3.1.9 @@ -193,6 +196,9 @@ importers: flatpickr: specifier: ^4.6.13 version: 4.6.13 + game: + specifier: workspace:* + version: link:../game lichess-pgn-viewer: specifier: ^2.4.0 version: 2.4.0 @@ -351,6 +357,9 @@ importers: ui/game: dependencies: + chess: + specifier: workspace:* + version: link:../chess common: specifier: workspace:* version: link:../common @@ -405,6 +414,45 @@ importers: game: specifier: workspace:* version: link:../game + local: + specifier: workspace:* + version: link:../local + + ui/local: + dependencies: + '@types/lichess': + specifier: workspace:* + version: link:../@types/lichess + bits: + specifier: workspace:* + version: link:../bits + chart.js: + specifier: 4.4.3 + version: 4.4.3 + chess: + specifier: workspace:* + version: link:../chess + chessops: + specifier: ^0.14.0 + version: 0.14.2 + common: + specifier: workspace:* + version: link:../common + fast-diff: + specifier: ^1.3.0 + version: 1.3.0 + game: + specifier: workspace:* + version: link:../game + json-stringify-pretty-compact: + specifier: 4.0.0 + version: 4.0.0 + round: + specifier: workspace:* + version: link:../round + zerofish: + specifier: ^0.0.31 + version: 0.0.31 ui/mod: dependencies: @@ -545,7 +593,7 @@ importers: version: 2.4.0 swiper: specifier: ^11.1.5 - version: 11.1.15 + version: 11.2.2 ui/round: dependencies: @@ -687,6 +735,12 @@ importers: common: specifier: workspace:* version: link:../common + game: + specifier: workspace:* + version: link:../game + local: + specifier: workspace:* + version: link:../local ui/voice: dependencies: @@ -705,11 +759,8 @@ importers: packages: - '@asamuzakjp/css-color@2.8.3': - resolution: {integrity: sha512-GIc76d9UI1hCvOATjZPyHFmE5qhRccp3/zGfMPapK3jBi+yocEzp6BBB0UnfRYP9NP4FANqUZYb0hnfs3TM3hw==} - - '@babel/runtime@7.26.0': - resolution: {integrity: sha512-FDSOghenHTiToteC/QRlv2q3DhPZ/oOXTBoirfWNx1Cx3TMVcGWQtMMmQcSvb/JjpNeGzx8Pq/b4fKEJuWm1sw==} + '@babel/runtime@7.26.7': + resolution: {integrity: sha512-AOPI3D+a8dXnja+iwsUqGRjr1BbZIe771sXdapOtYI531gSqpi92vXivKcq2asu/DFpdl1ceFAKZyRzK2PCVcQ==} engines: {node: '>=6.9.0'} '@badrap/result@0.2.13': @@ -721,34 +772,6 @@ packages: '@blakeembrey/template@1.2.0': resolution: {integrity: sha512-w/63nURdkRPpg3AXbNr7lPv6HgOuVDyefTumiXsbXxtIwcuk5EXayWR5OpSwDjsQPgaYsfUSedMduaNOjAYY8A==} - '@csstools/color-helpers@5.0.1': - resolution: {integrity: sha512-MKtmkA0BX87PKaO1NFRTFH+UnkgnmySQOvNxJubsadusqPEC2aJ9MOQiMceZJJ6oitUl/i0L6u0M1IrmAOmgBA==} - engines: {node: '>=18'} - - '@csstools/css-calc@2.1.1': - resolution: {integrity: sha512-rL7kaUnTkL9K+Cvo2pnCieqNpTKgQzy5f+N+5Iuko9HAoasP+xgprVh7KN/MaJVvVL1l0EzQq2MoqBHKSrDrag==} - engines: {node: '>=18'} - peerDependencies: - '@csstools/css-parser-algorithms': ^3.0.4 - '@csstools/css-tokenizer': ^3.0.3 - - '@csstools/css-color-parser@3.0.7': - resolution: {integrity: sha512-nkMp2mTICw32uE5NN+EsJ4f5N+IGFeCFu4bGpiKgb2Pq/7J/MpyLBeQ5ry4KKtRFZaYs6sTmcMYrSRIyj5DFKA==} - engines: {node: '>=18'} - peerDependencies: - '@csstools/css-parser-algorithms': ^3.0.4 - '@csstools/css-tokenizer': ^3.0.3 - - '@csstools/css-parser-algorithms@3.0.4': - resolution: {integrity: sha512-Up7rBoV77rv29d3uKHUIVubz1BTcgyUK72IvCQAbfbMv584xHcGKCKbWh7i8hPrRJ7qU4Y8IO3IY9m+iTB7P3A==} - engines: {node: '>=18'} - peerDependencies: - '@csstools/css-tokenizer': ^3.0.3 - - '@csstools/css-tokenizer@3.0.3': - resolution: {integrity: sha512-UJnjoFsmxfKUdNYdWgOB0mWUypuLvAfQPH1+pyvRJs6euowbFkFC6P13w1l8mJyi3vxYMxc9kld5jZEGRQs6bw==} - engines: {node: '>=18'} - '@esbuild/aix-ppc64@0.24.2': resolution: {integrity: sha512-thpVCb/rhxE/BnMLQ7GReQLLN8q9qbHmI55F4489/ByVg2aQaQ6kbcLb6FHkocZzQhxc4gx0sCk0tJkKBFzDhA==} engines: {node: '>=18'} @@ -909,8 +932,8 @@ packages: resolution: {integrity: sha512-CCZCDJuduB9OUkFkY2IgppNZMi2lBQgD2qzwXkEia16cge2pijY/aXi96CJMquDMn3nJdlPV1A5KrJEXwfLNzQ==} engines: {node: ^12.0.0 || ^14.0.0 || >=16.0.0} - '@eslint/config-array@0.19.0': - resolution: {integrity: sha512-zdHg2FPIFNKPdcHWtiNT+jEFCHYVplAXRDlQDyqy0zGx/q2parwh7brGJSiTxRk/TSMkbM//zt/f5CHgyTyaSQ==} + '@eslint/config-array@0.19.2': + resolution: {integrity: sha512-GNKqxfHG2ySmJOBSHg7LxeUx4xpuCoFjacmlCoYWEbaPXLwvfIjixRI12xCQZeULksQb23uiA8F40w5TojpV7w==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} '@eslint/core@0.10.0': @@ -929,22 +952,22 @@ packages: resolution: {integrity: sha512-iZA07H9io9Wn836aVTytRaNqh00Sad+EamwOVJT12GTLw1VGMFV/4JaME+JjLtr9fiGaoWgYnS54wrfWsSs4oQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@eslint/object-schema@2.1.4': - resolution: {integrity: sha512-BsWiH1yFGjXXS2yvrf5LyuoSIIbPrGUWob917o+BTKuZ7qJdxX8aJLRxs1fS9n6r7vESrq1OUqb68dANcFXuQQ==} + '@eslint/object-schema@2.1.6': + resolution: {integrity: sha512-RBMg5FRL0I0gs51M/guSAj5/e14VQ4tpZnQNWwuDT66P14I43ItmPfIZRhO9fUVIPOAQXU47atlywZ/czoqFPA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} '@eslint/plugin-kit@0.2.5': resolution: {integrity: sha512-lB05FkqEdUg2AA0xEbUz0SnkXT1LcCTa438W4IWTUh4hdOnVbQyOJ81OrDXsJk/LSiJHubgGEFoR5EHq1NsH1A==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@floating-ui/core@1.6.8': - resolution: {integrity: sha512-7XJ9cPU+yI2QeLS+FCSlqNFZJq8arvswefkZrYI1yQBbftw6FyrZOxYSh+9S7z7TpeWlRt9zJ5IhM1WIL334jA==} + '@floating-ui/core@1.6.9': + resolution: {integrity: sha512-uMXCuQ3BItDUbAMhIXw7UPXRfAlOAvZzdK9BWpE60MCn+Svt3aLn9jsPTi/WNGlRUu2uI0v5S7JiIUsbsvh3fw==} - '@floating-ui/dom@1.6.12': - resolution: {integrity: sha512-NP83c0HjokcGVEMeoStg317VD9W7eDlGK7457dMBANbKA6GJZdc7rjujdgqzTaz93jkGgc5P/jeWbaCHnMNc+w==} + '@floating-ui/dom@1.6.13': + resolution: {integrity: sha512-umqzocjDgNRGTuO7Q8CU32dkHkECqI8ZdMZ5Swb6QAM0t5rnlrN3lGo1hdpscRd3WS8T6DKYK4ephgIH9iRh3w==} - '@floating-ui/utils@0.2.8': - resolution: {integrity: sha512-kym7SodPp8/wloecOpcmSnWJsK7M0E5Wg8UcFA+uO4B9s5d0ywXOEro/8HM9x0rW+TljRzul/14UYz3TleT3ig==} + '@floating-ui/utils@0.2.9': + resolution: {integrity: sha512-MDWhGtE+eHw5JW7lq4qhc5yRLS11ERl1c7Z6Xd0a58DozHES6EnNNwUWbMiG4J9Cgj053Bhk8zvlhFYKVhULwg==} '@fnando/sparkline@0.3.10': resolution: {integrity: sha512-Rwz2swatdSU5F4sCOvYG8EOWdjtLgq5d8nmnqlZ3PXdWJI9Zq9BRUvJ/9ygjajJG8qOyNpMFX3GEVFjZIuB1Jg==} @@ -994,98 +1017,98 @@ packages: resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==} engines: {node: '>= 8'} - '@rollup/rollup-android-arm-eabi@4.30.1': - resolution: {integrity: sha512-pSWY+EVt3rJ9fQ3IqlrEUtXh3cGqGtPDH1FQlNZehO2yYxCHEX1SPsz1M//NXwYfbTlcKr9WObLnJX9FsS9K1Q==} + '@rollup/rollup-android-arm-eabi@4.34.6': + resolution: {integrity: sha512-+GcCXtOQoWuC7hhX1P00LqjjIiS/iOouHXhMdiDSnq/1DGTox4SpUvO52Xm+div6+106r+TcvOeo/cxvyEyTgg==} cpu: [arm] os: [android] - '@rollup/rollup-android-arm64@4.30.1': - resolution: {integrity: sha512-/NA2qXxE3D/BRjOJM8wQblmArQq1YoBVJjrjoTSBS09jgUisq7bqxNHJ8kjCHeV21W/9WDGwJEWSN0KQ2mtD/w==} + '@rollup/rollup-android-arm64@4.34.6': + resolution: {integrity: sha512-E8+2qCIjciYUnCa1AiVF1BkRgqIGW9KzJeesQqVfyRITGQN+dFuoivO0hnro1DjT74wXLRZ7QF8MIbz+luGaJA==} cpu: [arm64] os: [android] - '@rollup/rollup-darwin-arm64@4.30.1': - resolution: {integrity: sha512-r7FQIXD7gB0WJ5mokTUgUWPl0eYIH0wnxqeSAhuIwvnnpjdVB8cRRClyKLQr7lgzjctkbp5KmswWszlwYln03Q==} + '@rollup/rollup-darwin-arm64@4.34.6': + resolution: {integrity: sha512-z9Ib+OzqN3DZEjX7PDQMHEhtF+t6Mi2z/ueChQPLS/qUMKY7Ybn5A2ggFoKRNRh1q1T03YTQfBTQCJZiepESAg==} cpu: [arm64] os: [darwin] - '@rollup/rollup-darwin-x64@4.30.1': - resolution: {integrity: sha512-x78BavIwSH6sqfP2xeI1hd1GpHL8J4W2BXcVM/5KYKoAD3nNsfitQhvWSw+TFtQTLZ9OmlF+FEInEHyubut2OA==} + '@rollup/rollup-darwin-x64@4.34.6': + resolution: {integrity: sha512-PShKVY4u0FDAR7jskyFIYVyHEPCPnIQY8s5OcXkdU8mz3Y7eXDJPdyM/ZWjkYdR2m0izD9HHWA8sGcXn+Qrsyg==} cpu: [x64] os: [darwin] - '@rollup/rollup-freebsd-arm64@4.30.1': - resolution: {integrity: sha512-HYTlUAjbO1z8ywxsDFWADfTRfTIIy/oUlfIDmlHYmjUP2QRDTzBuWXc9O4CXM+bo9qfiCclmHk1x4ogBjOUpUQ==} + '@rollup/rollup-freebsd-arm64@4.34.6': + resolution: {integrity: sha512-YSwyOqlDAdKqs0iKuqvRHLN4SrD2TiswfoLfvYXseKbL47ht1grQpq46MSiQAx6rQEN8o8URtpXARCpqabqxGQ==} cpu: [arm64] os: [freebsd] - '@rollup/rollup-freebsd-x64@4.30.1': - resolution: {integrity: sha512-1MEdGqogQLccphhX5myCJqeGNYTNcmTyaic9S7CG3JhwuIByJ7J05vGbZxsizQthP1xpVx7kd3o31eOogfEirw==} + '@rollup/rollup-freebsd-x64@4.34.6': + resolution: {integrity: sha512-HEP4CgPAY1RxXwwL5sPFv6BBM3tVeLnshF03HMhJYCNc6kvSqBgTMmsEjb72RkZBAWIqiPUyF1JpEBv5XT9wKQ==} cpu: [x64] os: [freebsd] - '@rollup/rollup-linux-arm-gnueabihf@4.30.1': - resolution: {integrity: sha512-PaMRNBSqCx7K3Wc9QZkFx5+CX27WFpAMxJNiYGAXfmMIKC7jstlr32UhTgK6T07OtqR+wYlWm9IxzennjnvdJg==} + '@rollup/rollup-linux-arm-gnueabihf@4.34.6': + resolution: {integrity: sha512-88fSzjC5xeH9S2Vg3rPgXJULkHcLYMkh8faix8DX4h4TIAL65ekwuQMA/g2CXq8W+NJC43V6fUpYZNjaX3+IIg==} cpu: [arm] os: [linux] - '@rollup/rollup-linux-arm-musleabihf@4.30.1': - resolution: {integrity: sha512-B8Rcyj9AV7ZlEFqvB5BubG5iO6ANDsRKlhIxySXcF1axXYUyqwBok+XZPgIYGBgs7LDXfWfifxhw0Ik57T0Yug==} + '@rollup/rollup-linux-arm-musleabihf@4.34.6': + resolution: {integrity: sha512-wM4ztnutBqYFyvNeR7Av+reWI/enK9tDOTKNF+6Kk2Q96k9bwhDDOlnCUNRPvromlVXo04riSliMBs/Z7RteEg==} cpu: [arm] os: [linux] - '@rollup/rollup-linux-arm64-gnu@4.30.1': - resolution: {integrity: sha512-hqVyueGxAj3cBKrAI4aFHLV+h0Lv5VgWZs9CUGqr1z0fZtlADVV1YPOij6AhcK5An33EXaxnDLmJdQikcn5NEw==} + '@rollup/rollup-linux-arm64-gnu@4.34.6': + resolution: {integrity: sha512-9RyprECbRa9zEjXLtvvshhw4CMrRa3K+0wcp3KME0zmBe1ILmvcVHnypZ/aIDXpRyfhSYSuN4EPdCCj5Du8FIA==} cpu: [arm64] os: [linux] - '@rollup/rollup-linux-arm64-musl@4.30.1': - resolution: {integrity: sha512-i4Ab2vnvS1AE1PyOIGp2kXni69gU2DAUVt6FSXeIqUCPIR3ZlheMW3oP2JkukDfu3PsexYRbOiJrY+yVNSk9oA==} + '@rollup/rollup-linux-arm64-musl@4.34.6': + resolution: {integrity: sha512-qTmklhCTyaJSB05S+iSovfo++EwnIEZxHkzv5dep4qoszUMX5Ca4WM4zAVUMbfdviLgCSQOu5oU8YoGk1s6M9Q==} cpu: [arm64] os: [linux] - '@rollup/rollup-linux-loongarch64-gnu@4.30.1': - resolution: {integrity: sha512-fARcF5g296snX0oLGkVxPmysetwUk2zmHcca+e9ObOovBR++9ZPOhqFUM61UUZ2EYpXVPN1redgqVoBB34nTpQ==} + '@rollup/rollup-linux-loongarch64-gnu@4.34.6': + resolution: {integrity: sha512-4Qmkaps9yqmpjY5pvpkfOerYgKNUGzQpFxV6rnS7c/JfYbDSU0y6WpbbredB5cCpLFGJEqYX40WUmxMkwhWCjw==} cpu: [loong64] os: [linux] - '@rollup/rollup-linux-powerpc64le-gnu@4.30.1': - resolution: {integrity: sha512-GLrZraoO3wVT4uFXh67ElpwQY0DIygxdv0BNW9Hkm3X34wu+BkqrDrkcsIapAY+N2ATEbvak0XQ9gxZtCIA5Rw==} + '@rollup/rollup-linux-powerpc64le-gnu@4.34.6': + resolution: {integrity: sha512-Zsrtux3PuaxuBTX/zHdLaFmcofWGzaWW1scwLU3ZbW/X+hSsFbz9wDIp6XvnT7pzYRl9MezWqEqKy7ssmDEnuQ==} cpu: [ppc64] os: [linux] - '@rollup/rollup-linux-riscv64-gnu@4.30.1': - resolution: {integrity: sha512-0WKLaAUUHKBtll0wvOmh6yh3S0wSU9+yas923JIChfxOaaBarmb/lBKPF0w/+jTVozFnOXJeRGZ8NvOxvk/jcw==} + '@rollup/rollup-linux-riscv64-gnu@4.34.6': + resolution: {integrity: sha512-aK+Zp+CRM55iPrlyKiU3/zyhgzWBxLVrw2mwiQSYJRobCURb781+XstzvA8Gkjg/hbdQFuDw44aUOxVQFycrAg==} cpu: [riscv64] os: [linux] - '@rollup/rollup-linux-s390x-gnu@4.30.1': - resolution: {integrity: sha512-GWFs97Ruxo5Bt+cvVTQkOJ6TIx0xJDD/bMAOXWJg8TCSTEK8RnFeOeiFTxKniTc4vMIaWvCplMAFBt9miGxgkA==} + '@rollup/rollup-linux-s390x-gnu@4.34.6': + resolution: {integrity: sha512-WoKLVrY9ogmaYPXwTH326+ErlCIgMmsoRSx6bO+l68YgJnlOXhygDYSZe/qbUJCSiCiZAQ+tKm88NcWuUXqOzw==} cpu: [s390x] os: [linux] - '@rollup/rollup-linux-x64-gnu@4.30.1': - resolution: {integrity: sha512-UtgGb7QGgXDIO+tqqJ5oZRGHsDLO8SlpE4MhqpY9Llpzi5rJMvrK6ZGhsRCST2abZdBqIBeXW6WPD5fGK5SDwg==} + '@rollup/rollup-linux-x64-gnu@4.34.6': + resolution: {integrity: sha512-Sht4aFvmA4ToHd2vFzwMFaQCiYm2lDFho5rPcvPBT5pCdC+GwHG6CMch4GQfmWTQ1SwRKS0dhDYb54khSrjDWw==} cpu: [x64] os: [linux] - '@rollup/rollup-linux-x64-musl@4.30.1': - resolution: {integrity: sha512-V9U8Ey2UqmQsBT+xTOeMzPzwDzyXmnAoO4edZhL7INkwQcaW1Ckv3WJX3qrrp/VHaDkEWIBWhRwP47r8cdrOow==} + '@rollup/rollup-linux-x64-musl@4.34.6': + resolution: {integrity: sha512-zmmpOQh8vXc2QITsnCiODCDGXFC8LMi64+/oPpPx5qz3pqv0s6x46ps4xoycfUiVZps5PFn1gksZzo4RGTKT+A==} cpu: [x64] os: [linux] - '@rollup/rollup-win32-arm64-msvc@4.30.1': - resolution: {integrity: sha512-WabtHWiPaFF47W3PkHnjbmWawnX/aE57K47ZDT1BXTS5GgrBUEpvOzq0FI0V/UYzQJgdb8XlhVNH8/fwV8xDjw==} + '@rollup/rollup-win32-arm64-msvc@4.34.6': + resolution: {integrity: sha512-3/q1qUsO/tLqGBaD4uXsB6coVGB3usxw3qyeVb59aArCgedSF66MPdgRStUd7vbZOsko/CgVaY5fo2vkvPLWiA==} cpu: [arm64] os: [win32] - '@rollup/rollup-win32-ia32-msvc@4.30.1': - resolution: {integrity: sha512-pxHAU+Zv39hLUTdQQHUVHf4P+0C47y/ZloorHpzs2SXMRqeAWmGghzAhfOlzFHHwjvgokdFAhC4V+6kC1lRRfw==} + '@rollup/rollup-win32-ia32-msvc@4.34.6': + resolution: {integrity: sha512-oLHxuyywc6efdKVTxvc0135zPrRdtYVjtVD5GUm55I3ODxhU/PwkQFD97z16Xzxa1Fz0AEe4W/2hzRtd+IfpOA==} cpu: [ia32] os: [win32] - '@rollup/rollup-win32-x64-msvc@4.30.1': - resolution: {integrity: sha512-D6qjsXGcvhTjv0kI4fU8tUuBDF/Ueee4SVX79VfNDXZa64TfCW1Slkb6Z7O1p7vflqZjcmOVdZlqf8gvJxc6og==} + '@rollup/rollup-win32-x64-msvc@4.34.6': + resolution: {integrity: sha512-0PVwmgzZ8+TZ9oGBmdZoQVXflbvuwzN/HRclujpl4N/q3i+y0lqLw8n1bXA8ru3sApDjlmONaNAuYr38y1Kr9w==} cpu: [x64] os: [win32] @@ -1103,8 +1126,8 @@ packages: '@toast-ui/editor@3.2.2': resolution: {integrity: sha512-ASX7LFjN2ZYQJrwmkUajPs7DRr9FsM1+RQ82CfTO0Y5ZXorBk1VZS4C2Dpxinx9kl55V4F8/A2h2QF4QMDtRbA==} - '@types/canvas-confetti@1.6.4': - resolution: {integrity: sha512-fNyZ/Fdw/Y92X0vv7B+BD6ysHL4xVU5dJcgzgxLdGbn8O3PezZNIJpml44lKM0nsGur+o/6+NZbZeNTt00U1uA==} + '@types/canvas-confetti@1.9.0': + resolution: {integrity: sha512-aBGj/dULrimR1XDZLtG9JwxX1b4HPRF6CX9Yfwh3NvstZEm1ZL7RBnel4keCPSqs1ANRu1u2Aoz9R+VmtjYuTg==} '@types/debounce-promise@3.1.9': resolution: {integrity: sha512-awNxydYSU+E2vL7EiOAMtiSOfL5gUM5X4YSE2A92qpxDJQ/rXz6oMPYBFDcDywlUmvIDI6zsqgq17cGm5CITQw==} @@ -1112,6 +1135,9 @@ packages: '@types/dragscroll@0.0.3': resolution: {integrity: sha512-Nti+uvpcVv2VAkqF1+XJivEdE1rxdvWmE4mtMydm9kYBaT0/QsvQSjzqA2G0SE62RoTjVmhC48ap8yfApl8YJw==} + '@types/emscripten@1.40.0': + resolution: {integrity: sha512-MD2JJ25S4tnjnhjWyalMS6K6p0h+zQV6+Ylm+aGbiS8tSn/aHLSGNzBgduj6FB4zH0ax2GRMGYi/8G1uOxhXWA==} + '@types/estree@1.0.6': resolution: {integrity: sha512-AYnb1nQyY49te+VRAVgmzfcgjYS91mY5P0TKUDCLEM+gNnA+3T6rWITXRLYCpahpqSQbN5cE+gHpnPyXjHWxcw==} @@ -1127,17 +1153,11 @@ packages: '@types/node@22.13.4': resolution: {integrity: sha512-ywP2X0DYtX3y08eFVx5fNIw7/uIv8hYUKgXoK8oayJlLnKcRfEYCxWMVE1XagUdVtCJlZT1AU4LXEABW+L1Peg==} - '@types/node@22.9.1': - resolution: {integrity: sha512-p8Yy/8sw1caA8CdRIQBG5tiLHmxtQKObCijiAa9Ez+d4+PRffM4054xbju0msf+cvhJpnFEeNjxmVT/0ipktrg==} - - '@types/prop-types@15.7.13': - resolution: {integrity: sha512-hCZTSvwbzWGvhqxp/RqVqwU999pBf2vp7hzIjiYOsl8wqOmUxkQ6ddw1cV3l8811+kdUFus/q4d1Y3E3SyEifA==} - '@types/qrcode@1.5.5': resolution: {integrity: sha512-CdfBi/e3Qk+3Z/fXYShipBT13OJ2fDO2Q2w5CIP5anLTLIndQG9z6P1cnm+8zCWSpm5dnxMFd/uREtb0EXuQzg==} - '@types/react@18.3.12': - resolution: {integrity: sha512-D2wOSq/d6Agt28q7rSI3jhU7G6aiuzljDGZ2hTZHIkrTLUI+AF3WMeKkEZ9nN2fkBAlcktT6vcZjDFiIhMYEQw==} + '@types/react@19.0.8': + resolution: {integrity: sha512-9P/o1IGdfmQxrujGbIMDyYaaCykhLKc0NGCtYcECNUr9UAaDe4gwvV9bR6tvd5Br1SG0j+PBpbKr2UYY8CwqSw==} '@types/serviceworker@0.0.101': resolution: {integrity: sha512-ds7Vil7PMGcMYKjV2B0/EL6EldSWqaPVUtgdmjJqiY8Qrv007oBQeoWlh8aNSKikQluoJKKmnUCBNPII6fLhXg==} @@ -1145,6 +1165,9 @@ packages: '@types/sortablejs@1.15.8': resolution: {integrity: sha512-b79830lW+RZfwaztgs1aVPgbasJ8e7AXtZYHTELNXZPsERt4ymJdjV4OccDbHQAvHrCcFpbF78jkm0R6h/pZVg==} + '@types/web@0.0.194': + resolution: {integrity: sha512-VKseTFF3Y8SNbpZqdVFNWQ677ujwNyrI9LcySEUwZX5iebbcdE235Lq/vqrfCzj1oFsXyVUUBqq4x8enXSakMA==} + '@types/web@0.0.201': resolution: {integrity: sha512-pGg1tHPiGUxPB+T3ROqqvYAf/X52c3doP8IDOOyYlZhwE9XsuK5slPu9BJvZufHWonp4tE4eIILdnyD8dFVpkQ==} @@ -1256,10 +1279,6 @@ packages: engines: {node: '>=0.4.0'} hasBin: true - agent-base@7.1.3: - resolution: {integrity: sha512-jRR5wdylq8CkOe6hei19GGZnxM6rBGwFl3Bg0YItGDimvjGtAvdZk4Pu6Cl4u4Igsws4a1fd1Vq3ezrhn4KmFw==} - engines: {node: '>= 14'} - ajv@6.12.6: resolution: {integrity: sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==} @@ -1300,9 +1319,6 @@ packages: resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} engines: {node: '>=12'} - asynckit@0.4.0: - resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==} - balanced-match@1.0.2: resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} @@ -1347,6 +1363,10 @@ packages: resolution: {integrity: sha512-zgVZuo2WcZgfUEmsn6eO3kINexW8RAE4maiQ8QNs8CtpPCSyMiYsULR3HQYkm3w8FIA3SberyMJMSldGsW+U3w==} engines: {node: ^12.17.0 || ^14.13 || >=16.0.0} + chart.js@4.4.3: + resolution: {integrity: sha512-qK1gkGSRYcJzqrrzdR6a+I0vQ4/R+SoODXyAjscQ/4mzuNzySaMCd+hyVxitSY1+L2fjPD1Gbn+ibNqRmwQeLw==} + engines: {pnpm: '>=8'} + chart.js@4.4.6: resolution: {integrity: sha512-8Y406zevUPbbIBA/HRk33khEmQPk5+cxeflWE/2rx1NJsjVWMPw/9mSP9rxHP5eqi6LNoPBVMfZHxbwLSgldYA==} engines: {pnpm: '>=8'} @@ -1403,10 +1423,6 @@ packages: colorette@2.0.20: resolution: {integrity: sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==} - combined-stream@1.0.8: - resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==} - engines: {node: '>= 0.8'} - commander@13.1.0: resolution: {integrity: sha512-/rFeCpNJQbhSZjGVwO9RFV3xPqbnERS8MmIQzCtD/zl6gpJuV/bMLuN92oG3F7d8oDEHHRrujSXNUr8fpjntKw==} engines: {node: '>=18'} @@ -1421,17 +1437,9 @@ packages: resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} engines: {node: '>= 8'} - cssstyle@4.2.1: - resolution: {integrity: sha512-9+vem03dMXG7gDmZ62uqmRiMRNtinIZ9ZyuF6BdxzfOD+FdN5hretzynkn0ReS2DO2GSw76RWHs0UmJPI2zUjw==} - engines: {node: '>=18'} - csstype@3.1.3: resolution: {integrity: sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==} - data-urls@5.0.0: - resolution: {integrity: sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==} - engines: {node: '>=18'} - date-fns@2.30.0: resolution: {integrity: sha512-fnULvOpxnC5/Vg3NCiWelDsLiUc9bRwAPs/+LfTLNvetFCtCTN+yQz15C/fs4AwX1R9K5GLtLfn8QW+dWisaAw==} engines: {node: '>=0.11'} @@ -1455,9 +1463,6 @@ packages: resolution: {integrity: sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==} engines: {node: '>=0.10.0'} - decimal.js@10.5.0: - resolution: {integrity: sha512-8vDa8Qxvr/+d94hSh5P3IJwI5t8/c0KsMp+g8bNw9cY2icONa5aPfvKeieW1WlG0WQYwwhJ7mjui2xtiePQSXw==} - deep-eql@5.0.2: resolution: {integrity: sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==} engines: {node: '>=6'} @@ -1469,18 +1474,14 @@ packages: resolution: {integrity: sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==} engines: {node: '>=0.10.0'} - delayed-stream@1.0.0: - resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==} - engines: {node: '>=0.4.0'} - dialog-polyfill@0.5.6: resolution: {integrity: sha512-ZbVDJI9uvxPAKze6z146rmfUZjBqNEwcnFTVamQzXH+svluiV7swmVIGr7miwADgfgt1G2JQIytypM9fbyhX4w==} dijkstrajs@1.0.3: resolution: {integrity: sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==} - dompurify@2.5.7: - resolution: {integrity: sha512-2q4bEI+coQM8f5ez7kt2xclg1XsecaV9ASJk/54vwlfRRNQfDqJz2pzQ8t0Ix/ToBpXlVjrRIx7pFC/o8itG2Q==} + dompurify@2.5.8: + resolution: {integrity: sha512-o1vSNgrmYMQObbSSvF/1brBYEQPHhV1+gsmrusO7/GXtp1T9rCS8cXFqVxK/9crT1jA6Ccv+5MTSjBNqr7Sovw==} dragscroll@0.0.8: resolution: {integrity: sha512-nMrx/KErHpEkiKZlrghcT/nLWCj8vEJgv6s6TF84gmgn6uROPx2wRvClkcnjSEyvppYY9okOI1DIv573Toql1w==} @@ -1494,10 +1495,6 @@ packages: emoji-regex@8.0.0: resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} - entities@4.5.0: - resolution: {integrity: sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==} - engines: {node: '>=0.12'} - environment@1.1.0: resolution: {integrity: sha512-xUtoPkMggbz0MPyPiIWr1Kp4aeWJjDZ6SMvURhimjdZgsRuDplF5/s9hcgGhyXMhs+6vpnuoiZ2kFiu3FMnS8Q==} engines: {node: '>=18'} @@ -1576,8 +1573,11 @@ packages: fast-deep-equal@3.1.3: resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} - fast-glob@3.3.2: - resolution: {integrity: sha512-oX2ruAFQwf/Orj8m737Y5adxDQO0LAB7/S5MnxCdTNDd4p6BsyIVsv9JQsATbTSq8KHRpLwIHbVlUNatxd+1Ow==} + fast-diff@1.3.0: + resolution: {integrity: sha512-VxPP4NqbUjj6MaAOafWeUn2cXWLcCtljklUtZf0Ind4XQ+QPtmA0b18zZy0jIQx+ExRVCR/ZQpBmik5lXshNsw==} + + fast-glob@3.3.3: + resolution: {integrity: sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==} engines: {node: '>=8.6.0'} fast-json-stable-stringify@2.1.0: @@ -1590,8 +1590,8 @@ packages: resolution: {integrity: sha512-y655CeyUQ+jj7KBbYMc4FG01V8ZQqjN+gDYGJ50RtfsUB8iG9AmwmwoAgeKLJdmueKKMrH1RJ7yXHTSoczdv5w==} hasBin: true - fastq@1.17.1: - resolution: {integrity: sha512-sRVD3lWVIXWg6By68ZN7vho9a1pQcN/WBFaAAsDDFzlJjvoGx0P8z7V1t72grFJfJhu3YPZBuu25f7Kaw2jN1w==} + fastq@1.19.0: + resolution: {integrity: sha512-7SFSRCNjBQIZH/xZR3iy5iQYR8aGBE0h3VG6/cwlbrpdciNYBMotQav8c1XI3HjHH+NikUpP53nPdlZSdWmFzA==} file-entry-cache@8.0.0: resolution: {integrity: sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==} @@ -1619,10 +1619,6 @@ packages: flatted@3.3.2: resolution: {integrity: sha512-AiwGJM8YcNOaobumgtng+6NHuOqC3A7MixFeDafM3X9cIUM+xUXoS5Vfgf+OihAYe20fxqNM9yPBXJzRtZ/4eA==} - form-data@4.0.1: - resolution: {integrity: sha512-tzN8e4TX8+kkxGPK8D5u0FNmjPUjw3lwC9lSLxxoB/+GtsJG91CO8bSWy73APlgAZzZbXEYZJuxjkHH2w+Ezhw==} - engines: {node: '>= 6'} - fsevents@2.3.3: resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} @@ -1663,26 +1659,10 @@ packages: resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} engines: {node: '>=8'} - html-encoding-sniffer@4.0.0: - resolution: {integrity: sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==} - engines: {node: '>=18'} - - http-proxy-agent@7.0.2: - resolution: {integrity: sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==} - engines: {node: '>= 14'} - - https-proxy-agent@7.0.6: - resolution: {integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==} - engines: {node: '>= 14'} - human-signals@5.0.0: resolution: {integrity: sha512-AXcZb6vzzrFAUE61HnN4mpLqd/cSIwNQjtNWR0euPm6y0iqx3G4gOXaIDdtdDwZmhwe82LA6+zinmW4UBWVePQ==} engines: {node: '>=16.17.0'} - iconv-lite@0.6.3: - resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==} - engines: {node: '>=0.10.0'} - idb-keyval@6.2.1: resolution: {integrity: sha512-8Sb3veuYCyrZL+VBt9LJfZjLUPWVvqn8tG28VqYNFCo43KHcKuq+b4EiXGeuaLAQWL2YmyDgMp2aSpH9JHsEQg==} @@ -1690,8 +1670,8 @@ packages: resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} engines: {node: '>= 4'} - import-fresh@3.3.0: - resolution: {integrity: sha512-veYYhQa+D1QBKznvhUHxb8faxlrwUnxseDAbAp457E0wLNio2bOSKnjYDhMj+YiAq61xrMGhQk9iXVk5FzgQMw==} + import-fresh@3.3.1: + resolution: {integrity: sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==} engines: {node: '>=6'} imurmurhash@0.1.4: @@ -1726,9 +1706,6 @@ packages: resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==} engines: {node: '>=0.12.0'} - is-potential-custom-element-name@1.0.1: - resolution: {integrity: sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==} - is-stream@3.0.0: resolution: {integrity: sha512-LnQR4bZ9IADDRSkvpqMGvt/tEJWclzklNgSw48V5EAaAeDd6qGvN8ei6k5p0tvxSR171VmGyHuTiAOfxAbr8kA==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} @@ -1743,15 +1720,6 @@ packages: resolution: {integrity: sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==} hasBin: true - jsdom@26.0.0: - resolution: {integrity: sha512-BZYDGVAIriBWTpIxYzrXjv3E/4u8+/pSG5bQdIYCbNCGOvsPkDQfTVLAIXAf9ETdCpduCVTkDe2NNZ8NIwUVzw==} - engines: {node: '>=18'} - peerDependencies: - canvas: ^3.0.0 - peerDependenciesMeta: - canvas: - optional: true - json-buffer@3.0.1: resolution: {integrity: sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==} @@ -1761,6 +1729,9 @@ packages: json-stable-stringify-without-jsonify@1.0.1: resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==} + json-stringify-pretty-compact@4.0.0: + resolution: {integrity: sha512-3CNZ2DnrpByG9Nqj6Xo8vqbjT4F6N+tb4Gb28ESAZjYZ5yqvmc56J+/kuIwkaAMOyblTQhUW7PxMkUb8Q36N3Q==} + keyv@4.5.4: resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} @@ -1806,15 +1777,9 @@ packages: resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==} hasBin: true - loupe@3.1.2: - resolution: {integrity: sha512-23I4pFZHmAemUnz8WZXbYRSKYj801VDaNv9ETuMh7IrMc7VuVVSo+Z9iLE3ni30+U48iDWfi30d3twAXBYmnCg==} - loupe@3.1.3: resolution: {integrity: sha512-kkIp7XSkP78ZxJEsSxW3712C6teJVoeHHwgo9zJ380de7IYyJ2ISlxojcH2pC5OFLewESmnRi/+XCDIEEVyoug==} - lru-cache@10.4.3: - resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==} - magic-string@0.30.17: resolution: {integrity: sha512-sNPKHvyjVf7gyjwS4xGTaW/mCnF8wnjtifKBEhxfZ7E/S8tQ0rssrwGNn6q8JH/ohItJfSQp9mBtQYuTlH5QnA==} @@ -1829,14 +1794,6 @@ packages: resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==} engines: {node: '>=8.6'} - mime-db@1.52.0: - resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==} - engines: {node: '>= 0.6'} - - mime-types@2.1.35: - resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==} - engines: {node: '>= 0.6'} - mimic-fn@4.0.0: resolution: {integrity: sha512-vqiC06CuhBTUdZH+RYl8sFrL096vA45Ok5ISO6sE/Mr1jRbGH4Csnhi8f3wKVl7x8mO4Au7Ir9D3Oyv1VYMFJw==} engines: {node: '>=12'} @@ -1874,9 +1831,6 @@ packages: resolution: {integrity: sha512-ppwTtiJZq0O/ai0z7yfudtBpWIoxM8yE6nHi1X47eFR2EWORqfbu6CnPlNsjeN683eT0qG6H/Pyf9fCcvjnnnQ==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} - nwsapi@2.2.16: - resolution: {integrity: sha512-F1I/bimDpj3ncaNDhfyMWuFqmQDBwDB0Fogc2qpL3BWvkQteFD/8BzWuIRl83rq0DXfm8SGt/HFhLXZyljTXcQ==} - object-assign@4.1.1: resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} engines: {node: '>=0.10.0'} @@ -1924,9 +1878,6 @@ packages: resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==} engines: {node: '>=6'} - parse5@7.2.1: - resolution: {integrity: sha512-BuBYQYlv1ckiPdQi/ohiivi9Sagc9JG+Ozs0r7b/0iK3sKmrb0b9FdWdBbOdx6hBCM/F9Ir82ofnBhtZOjCRPQ==} - path-exists@4.0.0: resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} engines: {node: '>=8'} @@ -1998,8 +1949,8 @@ packages: prosemirror-keymap@1.2.2: resolution: {integrity: sha512-EAlXoksqC6Vbocqc0GtzCruZEzYgrn+iiGnNjsJsH4mrnIGex4qbLdWWNza3AW5W36ZRrlBID0eM6bdKH4OStQ==} - prosemirror-model@1.23.0: - resolution: {integrity: sha512-Q/fgsgl/dlOAW9ILu4OOhYWQbc7TQd4BwKH/RwmUjyVf8682Be4zj3rOYdLnYEcGzyg8LL9Q5IWYKD8tdToreQ==} + prosemirror-model@1.24.1: + resolution: {integrity: sha512-YM053N+vTThzlWJ/AtPtF1j0ebO36nvbmDy4U7qA2XQB8JVaQp1FmB9Jhrps8s+z+uxhhVTny4m20ptUvhk0Mg==} prosemirror-state@1.4.3: resolution: {integrity: sha512-goFKORVbvPuAQaXhpbemJFRKJ2aixr+AZMGiquiqKxaucC6hlpHNZHWgz5R7dS4roHiwq9vDctE//CZ++o0W1Q==} @@ -2007,8 +1958,8 @@ packages: prosemirror-transform@1.10.2: resolution: {integrity: sha512-2iUq0wv2iRoJO/zj5mv8uDUriOHWzXRnOTVgCzSXnktS/2iQRa3UUQwVlkBlYZFtygw6Nh1+X4mGqoYBINn5KQ==} - prosemirror-view@1.36.0: - resolution: {integrity: sha512-U0GQd5yFvV5qUtT41X1zCQfbw14vkbbKwLlQXhdylEmgpYVHkefXYcC4HHwWOfZa3x6Y8wxDLUBv7dxN5XQ3nA==} + prosemirror-view@1.37.2: + resolution: {integrity: sha512-ApcyrfV/cRcaL65on7TQcfWElwLyOgIjnIynfAuV+fIdlpbSvSWRwfuPaH7T5mo4AbO/FID29qOtjiDIKGWyog==} punycode@2.3.1: resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} @@ -2054,32 +2005,22 @@ packages: rfdc@1.4.1: resolution: {integrity: sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==} - rollup@4.30.1: - resolution: {integrity: sha512-mlJ4glW020fPuLi7DkM/lN97mYEZGWeqBnrljzN0gs7GLctqX3lNWxKQ7Gl712UAX+6fog/L3jh4gb7R6aVi3w==} + rollup@4.34.6: + resolution: {integrity: sha512-wc2cBWqJgkU3Iz5oztRkQbfVkbxoz5EhnCGOrnJvnLnQ7O0WhQUYyv18qQI79O8L7DdHrrlJNeCHd4VGpnaXKQ==} engines: {node: '>=18.0.0', npm: '>=8.0.0'} hasBin: true rope-sequence@1.3.4: resolution: {integrity: sha512-UT5EDe2cu2E/6O4igUr5PSFs23nvvukicWHx6GnOPlHAiiYbzNuCRQCuiUdHJQcqKalLKlrYJnjY0ySGsXNQXQ==} - rrweb-cssom@0.8.0: - resolution: {integrity: sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw==} - run-parallel@1.2.0: resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==} - safer-buffer@2.1.2: - resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} - - saxes@6.0.0: - resolution: {integrity: sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==} - engines: {node: '>=v12.22.7'} - sdp@3.2.0: resolution: {integrity: sha512-d7wDPgDV3DDiqulJjKiV2865wKsJ34YI+NDREbm+FySq6WuKOikwyNQcm+doLAZ1O6ltdO0SeKle2xMpN3Brgw==} - semver@7.6.3: - resolution: {integrity: sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A==} + semver@7.7.1: + resolution: {integrity: sha512-hlq8tAfn0m/61p4BVRcPzIGr6LKiMwo4VM6dGi6pt4qcRkmNzTcWq6eCEjEh+qXjkMDvPlOFFSGwQjoEa6gyMA==} engines: {node: '>=10'} hasBin: true @@ -2212,13 +2153,10 @@ packages: resolution: {integrity: sha512-h5IS/hKkuVCbKSieR9uQCj9w+zLHoPh+ce19bBYyqF53g6mnPB8sAtIbe1s9dh2S2fCmYX2xel1Ln3PJBbK4kw==} engines: {node: '>= 0.8.0'} - swiper@11.1.15: - resolution: {integrity: sha512-IzWeU34WwC7gbhjKsjkImTuCRf+lRbO6cnxMGs88iVNKDwV+xQpBCJxZ4bNH6gSrIbbyVJ1kuGzo3JTtz//CBw==} + swiper@11.2.2: + resolution: {integrity: sha512-FmAN6zACpVUbd/1prO9xQ9gKo9cc6RE2UKU/z4oXtS8fNyX4sdOW/HHT/e444WucLJs0jeMId6WjdWM2Lrs8zA==} engines: {node: '>= 4.7.0'} - symbol-tree@3.2.4: - resolution: {integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==} - tablesort@5.3.0: resolution: {integrity: sha512-WkfcZBHsp47gVH9CBHG0ZXopriG01IA87arGrchvIe868d4RiXVvoYPS1zMq9IdW05kBs5iGsqxTABqLyWonbg==} @@ -2243,25 +2181,10 @@ packages: resolution: {integrity: sha512-n1cw8k1k0x4pgA2+9XrOkFydTerNcJ1zWCO5Nn9scWHTD+5tp8dghT2x1uduQePZTZgd3Tupf+x9BxJjeJi77Q==} engines: {node: '>=14.0.0'} - tldts-core@6.1.76: - resolution: {integrity: sha512-uzhJ02RaMzgQR3yPoeE65DrcHI6LoM4saUqXOt/b5hmb3+mc4YWpdSeAQqVqRUlQ14q8ZuLRWyBR1ictK1dzzg==} - - tldts@6.1.76: - resolution: {integrity: sha512-6U2ti64/nppsDxQs9hw8ephA3nO6nSQvVVfxwRw8wLQPFtLI1cFI1a1eP22g+LUP+1TA2pKKjUTwWB+K2coqmQ==} - hasBin: true - to-regex-range@5.0.1: resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} engines: {node: '>=8.0'} - tough-cookie@5.1.0: - resolution: {integrity: sha512-rvZUv+7MoBYTiDmFPBrhL7Ujx9Sk+q9wwm22x8c8T5IJaR+Wsyc7TNxbVxo84kZoRJZZMazowFLqpankBEQrGg==} - engines: {node: '>=16'} - - tr46@5.0.0: - resolution: {integrity: sha512-tk2G5R2KRwBd+ZN0zaEXpmzdKyOYksXwywulIX95MBODjSzMIuQnQ3m8JxgbhnL1LeVo7lqQKsYa1O3Htl7K5g==} - engines: {node: '>=18'} - tree-kill@1.2.2: resolution: {integrity: sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==} hasBin: true @@ -2284,9 +2207,6 @@ packages: undate@0.3.0: resolution: {integrity: sha512-ssH8QTNBY6B+2fRr3stSQ+9m2NT8qTaun3ExTx5ibzYQvP7yX4+BnX0McNxFCvh6S5ia/DYu6bsCKQx/U4nb/Q==} - undici-types@6.19.8: - resolution: {integrity: sha512-ve2KP6f/JnbPBFyobGHuerC9g1FYGn/F8n1LWTwNxCEzd6IfqTwUQcNXgEtmmQ6DlRrC1hrSrBnCZPokRrDHjw==} - undici-types@6.20.0: resolution: {integrity: sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg==} @@ -2376,30 +2296,10 @@ packages: w3c-keyname@2.2.8: resolution: {integrity: sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==} - w3c-xmlserializer@5.0.0: - resolution: {integrity: sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==} - engines: {node: '>=18'} - - webidl-conversions@7.0.0: - resolution: {integrity: sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==} - engines: {node: '>=12'} - webrtc-adapter@9.0.1: resolution: {integrity: sha512-1AQO+d4ElfVSXyzNVTOewgGT/tAomwwztX/6e3totvyyzXPvXIIuUUjAmyZGbKBKbZOXauuJooZm3g6IuFuiNQ==} engines: {node: '>=6.0.0', npm: '>=3.10.0'} - whatwg-encoding@3.1.1: - resolution: {integrity: sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==} - engines: {node: '>=18'} - - whatwg-mimetype@4.0.0: - resolution: {integrity: sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==} - engines: {node: '>=18'} - - whatwg-url@14.1.0: - resolution: {integrity: sha512-jlf/foYIKywAt3x/XWKZ/3rz8OSJPiWktjmk891alJUEjiVxKX9LEO92qH3hv4aJ0mN3MWPvGMCy8jQi95xK4w==} - engines: {node: '>=18'} - which-module@2.0.1: resolution: {integrity: sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==} @@ -2425,25 +2325,6 @@ packages: resolution: {integrity: sha512-G8ura3S+3Z2G+mkgNRq8dqaFZAuxfsxpBB8OCTGRTCtp+l/v9nbFNmCUP1BZMts3G1142MsZfn6eeUKrr4PD1Q==} engines: {node: '>=18'} - ws@8.18.0: - resolution: {integrity: sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw==} - engines: {node: '>=10.0.0'} - peerDependencies: - bufferutil: ^4.0.1 - utf-8-validate: '>=5.0.2' - peerDependenciesMeta: - bufferutil: - optional: true - utf-8-validate: - optional: true - - xml-name-validator@5.0.0: - resolution: {integrity: sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==} - engines: {node: '>=18'} - - xmlchars@2.2.0: - resolution: {integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==} - y18n@4.0.3: resolution: {integrity: sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==} @@ -2464,21 +2345,15 @@ packages: resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} engines: {node: '>=10'} + zerofish@0.0.31: + resolution: {integrity: sha512-SlGd9oSmILRPdIsOT5VKyBvGw6VTBJDQdVZGdhVlUZYgX7l8utJhv8+L9jQxVoyzlhh0P6jb4q3YUbFy1m3LdA==} + zxcvbn@4.4.2: resolution: {integrity: sha512-Bq0B+ixT/DMyG8kgX2xWcI5jUvCwqrMxSFam7m0lAf78nf04hv6lNCsyLYdyYTrCVMqNDY/206K7eExYCeSyUQ==} snapshots: - '@asamuzakjp/css-color@2.8.3': - dependencies: - '@csstools/css-calc': 2.1.1(@csstools/css-parser-algorithms@3.0.4(@csstools/css-tokenizer@3.0.3))(@csstools/css-tokenizer@3.0.3) - '@csstools/css-color-parser': 3.0.7(@csstools/css-parser-algorithms@3.0.4(@csstools/css-tokenizer@3.0.3))(@csstools/css-tokenizer@3.0.3) - '@csstools/css-parser-algorithms': 3.0.4(@csstools/css-tokenizer@3.0.3) - '@csstools/css-tokenizer': 3.0.3 - lru-cache: 10.4.3 - optional: true - - '@babel/runtime@7.26.0': + '@babel/runtime@7.26.7': dependencies: regenerator-runtime: 0.14.1 @@ -2488,31 +2363,6 @@ snapshots: '@blakeembrey/template@1.2.0': {} - '@csstools/color-helpers@5.0.1': - optional: true - - '@csstools/css-calc@2.1.1(@csstools/css-parser-algorithms@3.0.4(@csstools/css-tokenizer@3.0.3))(@csstools/css-tokenizer@3.0.3)': - dependencies: - '@csstools/css-parser-algorithms': 3.0.4(@csstools/css-tokenizer@3.0.3) - '@csstools/css-tokenizer': 3.0.3 - optional: true - - '@csstools/css-color-parser@3.0.7(@csstools/css-parser-algorithms@3.0.4(@csstools/css-tokenizer@3.0.3))(@csstools/css-tokenizer@3.0.3)': - dependencies: - '@csstools/color-helpers': 5.0.1 - '@csstools/css-calc': 2.1.1(@csstools/css-parser-algorithms@3.0.4(@csstools/css-tokenizer@3.0.3))(@csstools/css-tokenizer@3.0.3) - '@csstools/css-parser-algorithms': 3.0.4(@csstools/css-tokenizer@3.0.3) - '@csstools/css-tokenizer': 3.0.3 - optional: true - - '@csstools/css-parser-algorithms@3.0.4(@csstools/css-tokenizer@3.0.3)': - dependencies: - '@csstools/css-tokenizer': 3.0.3 - optional: true - - '@csstools/css-tokenizer@3.0.3': - optional: true - '@esbuild/aix-ppc64@0.24.2': optional: true @@ -2595,9 +2445,9 @@ snapshots: '@eslint-community/regexpp@4.12.1': {} - '@eslint/config-array@0.19.0': + '@eslint/config-array@0.19.2': dependencies: - '@eslint/object-schema': 2.1.4 + '@eslint/object-schema': 2.1.6 debug: 4.4.0 minimatch: 3.1.2 transitivePeerDependencies: @@ -2618,7 +2468,7 @@ snapshots: espree: 10.3.0 globals: 14.0.0 ignore: 5.3.2 - import-fresh: 3.3.0 + import-fresh: 3.3.1 js-yaml: 4.1.0 minimatch: 3.1.2 strip-json-comments: 3.1.1 @@ -2627,23 +2477,23 @@ snapshots: '@eslint/js@9.20.0': {} - '@eslint/object-schema@2.1.4': {} + '@eslint/object-schema@2.1.6': {} '@eslint/plugin-kit@0.2.5': dependencies: '@eslint/core': 0.10.0 levn: 0.4.1 - '@floating-ui/core@1.6.8': + '@floating-ui/core@1.6.9': dependencies: - '@floating-ui/utils': 0.2.8 + '@floating-ui/utils': 0.2.9 - '@floating-ui/dom@1.6.12': + '@floating-ui/dom@1.6.13': dependencies: - '@floating-ui/core': 1.6.8 - '@floating-ui/utils': 0.2.8 + '@floating-ui/core': 1.6.9 + '@floating-ui/utils': 0.2.9 - '@floating-ui/utils@0.2.8': {} + '@floating-ui/utils@0.2.9': {} '@fnando/sparkline@0.3.10': {} @@ -2678,63 +2528,63 @@ snapshots: '@nodelib/fs.walk@1.2.8': dependencies: '@nodelib/fs.scandir': 2.1.5 - fastq: 1.17.1 + fastq: 1.19.0 - '@rollup/rollup-android-arm-eabi@4.30.1': + '@rollup/rollup-android-arm-eabi@4.34.6': optional: true - '@rollup/rollup-android-arm64@4.30.1': + '@rollup/rollup-android-arm64@4.34.6': optional: true - '@rollup/rollup-darwin-arm64@4.30.1': + '@rollup/rollup-darwin-arm64@4.34.6': optional: true - '@rollup/rollup-darwin-x64@4.30.1': + '@rollup/rollup-darwin-x64@4.34.6': optional: true - '@rollup/rollup-freebsd-arm64@4.30.1': + '@rollup/rollup-freebsd-arm64@4.34.6': optional: true - '@rollup/rollup-freebsd-x64@4.30.1': + '@rollup/rollup-freebsd-x64@4.34.6': optional: true - '@rollup/rollup-linux-arm-gnueabihf@4.30.1': + '@rollup/rollup-linux-arm-gnueabihf@4.34.6': optional: true - '@rollup/rollup-linux-arm-musleabihf@4.30.1': + '@rollup/rollup-linux-arm-musleabihf@4.34.6': optional: true - '@rollup/rollup-linux-arm64-gnu@4.30.1': + '@rollup/rollup-linux-arm64-gnu@4.34.6': optional: true - '@rollup/rollup-linux-arm64-musl@4.30.1': + '@rollup/rollup-linux-arm64-musl@4.34.6': optional: true - '@rollup/rollup-linux-loongarch64-gnu@4.30.1': + '@rollup/rollup-linux-loongarch64-gnu@4.34.6': optional: true - '@rollup/rollup-linux-powerpc64le-gnu@4.30.1': + '@rollup/rollup-linux-powerpc64le-gnu@4.34.6': optional: true - '@rollup/rollup-linux-riscv64-gnu@4.30.1': + '@rollup/rollup-linux-riscv64-gnu@4.34.6': optional: true - '@rollup/rollup-linux-s390x-gnu@4.30.1': + '@rollup/rollup-linux-s390x-gnu@4.34.6': optional: true - '@rollup/rollup-linux-x64-gnu@4.30.1': + '@rollup/rollup-linux-x64-gnu@4.34.6': optional: true - '@rollup/rollup-linux-x64-musl@4.30.1': + '@rollup/rollup-linux-x64-musl@4.34.6': optional: true - '@rollup/rollup-win32-arm64-msvc@4.30.1': + '@rollup/rollup-win32-arm64-msvc@4.34.6': optional: true - '@rollup/rollup-win32-ia32-msvc@4.30.1': + '@rollup/rollup-win32-ia32-msvc@4.34.6': optional: true - '@rollup/rollup-win32-x64-msvc@4.30.1': + '@rollup/rollup-win32-x64-msvc@4.34.6': optional: true '@textcomplete/core@0.1.13': @@ -2752,21 +2602,23 @@ snapshots: '@toast-ui/editor@3.2.2': dependencies: - dompurify: 2.5.7 + dompurify: 2.5.8 prosemirror-commands: 1.6.2 prosemirror-history: 1.4.1 prosemirror-inputrules: 1.4.0 prosemirror-keymap: 1.2.2 - prosemirror-model: 1.23.0 + prosemirror-model: 1.24.1 prosemirror-state: 1.4.3 - prosemirror-view: 1.36.0 + prosemirror-view: 1.37.2 - '@types/canvas-confetti@1.6.4': {} + '@types/canvas-confetti@1.9.0': {} '@types/debounce-promise@3.1.9': {} '@types/dragscroll@0.0.3': {} + '@types/emscripten@1.40.0': {} + '@types/estree@1.0.6': {} '@types/fnando__sparkline@0.3.7': {} @@ -2779,32 +2631,27 @@ snapshots: dependencies: undici-types: 6.20.0 - '@types/node@22.9.1': - dependencies: - undici-types: 6.19.8 - - '@types/prop-types@15.7.13': {} - '@types/qrcode@1.5.5': dependencies: - '@types/node': 22.9.1 + '@types/node': 22.13.4 - '@types/react@18.3.12': + '@types/react@19.0.8': dependencies: - '@types/prop-types': 15.7.13 csstype: 3.1.3 '@types/serviceworker@0.0.101': {} '@types/sortablejs@1.15.8': {} + '@types/web@0.0.194': {} + '@types/web@0.0.201': {} '@types/webrtc@0.0.44': {} '@types/yaireo__tagify@4.27.0': dependencies: - '@types/react': 18.3.12 + '@types/react': 19.0.8 '@types/zxcvbn@4.4.5': {} @@ -2860,10 +2707,10 @@ snapshots: '@typescript-eslint/types': 8.23.0 '@typescript-eslint/visitor-keys': 8.23.0 debug: 4.4.0 - fast-glob: 3.3.2 + fast-glob: 3.3.3 is-glob: 4.0.3 minimatch: 9.0.5 - semver: 7.6.3 + semver: 7.7.1 ts-api-utils: 2.0.1(typescript@5.7.3) typescript: 5.7.3 transitivePeerDependencies: @@ -2939,9 +2786,6 @@ snapshots: acorn@8.14.0: {} - agent-base@7.1.3: - optional: true - ajv@6.12.6: dependencies: fast-deep-equal: 3.1.3 @@ -2984,9 +2828,6 @@ snapshots: assertion-error@2.0.1: {} - asynckit@0.4.0: - optional: true - balanced-match@1.0.2: {} binary-extensions@2.3.0: {} @@ -3017,7 +2858,7 @@ snapshots: assertion-error: 2.0.1 check-error: 2.1.1 deep-eql: 5.0.2 - loupe: 3.1.2 + loupe: 3.1.3 pathval: 2.0.0 chalk@4.1.2: @@ -3027,6 +2868,10 @@ snapshots: chalk@5.4.1: {} + chart.js@4.4.3: + dependencies: + '@kurkle/color': 0.3.4 + chart.js@4.4.6: dependencies: '@kurkle/color': 0.3.4 @@ -3089,11 +2934,6 @@ snapshots: colorette@2.0.20: {} - combined-stream@1.0.8: - dependencies: - delayed-stream: 1.0.0 - optional: true - commander@13.1.0: {} concat-map@0.0.1: {} @@ -3106,23 +2946,11 @@ snapshots: shebang-command: 2.0.0 which: 2.0.2 - cssstyle@4.2.1: - dependencies: - '@asamuzakjp/css-color': 2.8.3 - rrweb-cssom: 0.8.0 - optional: true - csstype@3.1.3: {} - data-urls@5.0.0: - dependencies: - whatwg-mimetype: 4.0.0 - whatwg-url: 14.1.0 - optional: true - date-fns@2.30.0: dependencies: - '@babel/runtime': 7.26.0 + '@babel/runtime': 7.26.7 dayjs@1.11.13: {} @@ -3134,23 +2962,17 @@ snapshots: decamelize@1.2.0: {} - decimal.js@10.5.0: - optional: true - deep-eql@5.0.2: {} deep-is@0.1.4: {} deepmerge@4.3.1: {} - delayed-stream@1.0.0: - optional: true - dialog-polyfill@0.5.6: {} dijkstrajs@1.0.3: {} - dompurify@2.5.7: {} + dompurify@2.5.8: {} dragscroll@0.0.8: {} @@ -3160,9 +2982,6 @@ snapshots: emoji-regex@8.0.0: {} - entities@4.5.0: - optional: true - environment@1.1.0: {} es-module-lexer@1.6.0: {} @@ -3210,7 +3029,7 @@ snapshots: dependencies: '@eslint-community/eslint-utils': 4.4.1(eslint@9.20.0) '@eslint-community/regexpp': 4.12.1 - '@eslint/config-array': 0.19.0 + '@eslint/config-array': 0.19.2 '@eslint/core': 0.11.0 '@eslint/eslintrc': 3.2.0 '@eslint/js': 9.20.0 @@ -3287,7 +3106,9 @@ snapshots: fast-deep-equal@3.1.3: {} - fast-glob@3.3.2: + fast-diff@1.3.0: {} + + fast-glob@3.3.3: dependencies: '@nodelib/fs.stat': 2.0.5 '@nodelib/fs.walk': 1.2.8 @@ -3303,7 +3124,7 @@ snapshots: dependencies: strnum: 1.0.5 - fastq@1.17.1: + fastq@1.19.0: dependencies: reusify: 1.0.4 @@ -3334,13 +3155,6 @@ snapshots: flatted@3.3.2: {} - form-data@4.0.1: - dependencies: - asynckit: 0.4.0 - combined-stream: 1.0.8 - mime-types: 2.1.35 - optional: true - fsevents@2.3.3: optional: true @@ -3366,39 +3180,13 @@ snapshots: has-flag@4.0.0: {} - html-encoding-sniffer@4.0.0: - dependencies: - whatwg-encoding: 3.1.1 - optional: true - - http-proxy-agent@7.0.2: - dependencies: - agent-base: 7.1.3 - debug: 4.4.0 - transitivePeerDependencies: - - supports-color - optional: true - - https-proxy-agent@7.0.6: - dependencies: - agent-base: 7.1.3 - debug: 4.4.0 - transitivePeerDependencies: - - supports-color - optional: true - human-signals@5.0.0: {} - iconv-lite@0.6.3: - dependencies: - safer-buffer: 2.1.2 - optional: true - idb-keyval@6.2.1: {} ignore@5.3.2: {} - import-fresh@3.3.0: + import-fresh@3.3.1: dependencies: parent-module: 1.0.1 resolve-from: 4.0.0 @@ -3425,9 +3213,6 @@ snapshots: is-number@7.0.0: {} - is-potential-custom-element-name@1.0.1: - optional: true - is-stream@3.0.0: {} isexe@2.0.0: {} @@ -3438,41 +3223,14 @@ snapshots: dependencies: argparse: 2.0.1 - jsdom@26.0.0: - dependencies: - cssstyle: 4.2.1 - data-urls: 5.0.0 - decimal.js: 10.5.0 - form-data: 4.0.1 - html-encoding-sniffer: 4.0.0 - http-proxy-agent: 7.0.2 - https-proxy-agent: 7.0.6 - is-potential-custom-element-name: 1.0.1 - nwsapi: 2.2.16 - parse5: 7.2.1 - rrweb-cssom: 0.8.0 - saxes: 6.0.0 - symbol-tree: 3.2.4 - tough-cookie: 5.1.0 - w3c-xmlserializer: 5.0.0 - webidl-conversions: 7.0.0 - whatwg-encoding: 3.1.1 - whatwg-mimetype: 4.0.0 - whatwg-url: 14.1.0 - ws: 8.18.0 - xml-name-validator: 5.0.0 - transitivePeerDependencies: - - bufferutil - - supports-color - - utf-8-validate - optional: true - json-buffer@3.0.1: {} json-schema-traverse@0.4.1: {} json-stable-stringify-without-jsonify@1.0.1: {} + json-stringify-pretty-compact@4.0.0: {} + keyv@4.5.4: dependencies: json-buffer: 3.0.1 @@ -3539,13 +3297,8 @@ snapshots: dependencies: js-tokens: 4.0.0 - loupe@3.1.2: {} - loupe@3.1.3: {} - lru-cache@10.4.3: - optional: true - magic-string@0.30.17: dependencies: '@jridgewell/sourcemap-codec': 1.5.0 @@ -3559,14 +3312,6 @@ snapshots: braces: 3.0.3 picomatch: 2.3.1 - mime-db@1.52.0: - optional: true - - mime-types@2.1.35: - dependencies: - mime-db: 1.52.0 - optional: true - mimic-fn@4.0.0: {} mimic-function@5.0.1: {} @@ -3593,9 +3338,6 @@ snapshots: dependencies: path-key: 4.0.0 - nwsapi@2.2.16: - optional: true - object-assign@4.1.1: {} onchange@7.1.0: @@ -3649,11 +3391,6 @@ snapshots: dependencies: callsites: 3.1.0 - parse5@7.2.1: - dependencies: - entities: 4.5.0 - optional: true - path-exists@4.0.0: {} path-key@3.1.1: {} @@ -3699,7 +3436,7 @@ snapshots: prosemirror-commands@1.6.2: dependencies: - prosemirror-model: 1.23.0 + prosemirror-model: 1.24.1 prosemirror-state: 1.4.3 prosemirror-transform: 1.10.2 @@ -3707,7 +3444,7 @@ snapshots: dependencies: prosemirror-state: 1.4.3 prosemirror-transform: 1.10.2 - prosemirror-view: 1.36.0 + prosemirror-view: 1.37.2 rope-sequence: 1.3.4 prosemirror-inputrules@1.4.0: @@ -3720,23 +3457,23 @@ snapshots: prosemirror-state: 1.4.3 w3c-keyname: 2.2.8 - prosemirror-model@1.23.0: + prosemirror-model@1.24.1: dependencies: orderedmap: 2.1.1 prosemirror-state@1.4.3: dependencies: - prosemirror-model: 1.23.0 + prosemirror-model: 1.24.1 prosemirror-transform: 1.10.2 - prosemirror-view: 1.36.0 + prosemirror-view: 1.37.2 prosemirror-transform@1.10.2: dependencies: - prosemirror-model: 1.23.0 + prosemirror-model: 1.24.1 - prosemirror-view@1.36.0: + prosemirror-view@1.37.2: dependencies: - prosemirror-model: 1.23.0 + prosemirror-model: 1.24.1 prosemirror-state: 1.4.3 prosemirror-transform: 1.10.2 @@ -3773,51 +3510,40 @@ snapshots: rfdc@1.4.1: {} - rollup@4.30.1: + rollup@4.34.6: dependencies: '@types/estree': 1.0.6 optionalDependencies: - '@rollup/rollup-android-arm-eabi': 4.30.1 - '@rollup/rollup-android-arm64': 4.30.1 - '@rollup/rollup-darwin-arm64': 4.30.1 - '@rollup/rollup-darwin-x64': 4.30.1 - '@rollup/rollup-freebsd-arm64': 4.30.1 - '@rollup/rollup-freebsd-x64': 4.30.1 - '@rollup/rollup-linux-arm-gnueabihf': 4.30.1 - '@rollup/rollup-linux-arm-musleabihf': 4.30.1 - '@rollup/rollup-linux-arm64-gnu': 4.30.1 - '@rollup/rollup-linux-arm64-musl': 4.30.1 - '@rollup/rollup-linux-loongarch64-gnu': 4.30.1 - '@rollup/rollup-linux-powerpc64le-gnu': 4.30.1 - '@rollup/rollup-linux-riscv64-gnu': 4.30.1 - '@rollup/rollup-linux-s390x-gnu': 4.30.1 - '@rollup/rollup-linux-x64-gnu': 4.30.1 - '@rollup/rollup-linux-x64-musl': 4.30.1 - '@rollup/rollup-win32-arm64-msvc': 4.30.1 - '@rollup/rollup-win32-ia32-msvc': 4.30.1 - '@rollup/rollup-win32-x64-msvc': 4.30.1 + '@rollup/rollup-android-arm-eabi': 4.34.6 + '@rollup/rollup-android-arm64': 4.34.6 + '@rollup/rollup-darwin-arm64': 4.34.6 + '@rollup/rollup-darwin-x64': 4.34.6 + '@rollup/rollup-freebsd-arm64': 4.34.6 + '@rollup/rollup-freebsd-x64': 4.34.6 + '@rollup/rollup-linux-arm-gnueabihf': 4.34.6 + '@rollup/rollup-linux-arm-musleabihf': 4.34.6 + '@rollup/rollup-linux-arm64-gnu': 4.34.6 + '@rollup/rollup-linux-arm64-musl': 4.34.6 + '@rollup/rollup-linux-loongarch64-gnu': 4.34.6 + '@rollup/rollup-linux-powerpc64le-gnu': 4.34.6 + '@rollup/rollup-linux-riscv64-gnu': 4.34.6 + '@rollup/rollup-linux-s390x-gnu': 4.34.6 + '@rollup/rollup-linux-x64-gnu': 4.34.6 + '@rollup/rollup-linux-x64-musl': 4.34.6 + '@rollup/rollup-win32-arm64-msvc': 4.34.6 + '@rollup/rollup-win32-ia32-msvc': 4.34.6 + '@rollup/rollup-win32-x64-msvc': 4.34.6 fsevents: 2.3.3 rope-sequence@1.3.4: {} - rrweb-cssom@0.8.0: - optional: true - run-parallel@1.2.0: dependencies: queue-microtask: 1.2.3 - safer-buffer@2.1.2: - optional: true - - saxes@6.0.0: - dependencies: - xmlchars: 2.2.0 - optional: true - sdp@3.2.0: {} - semver@7.6.3: {} + semver@7.7.1: {} set-blocking@2.0.0: {} @@ -3829,7 +3555,7 @@ snapshots: shepherd.js@11.2.0: dependencies: - '@floating-ui/dom': 1.6.12 + '@floating-ui/dom': 1.6.13 deepmerge: 4.3.1 siginfo@2.0.0: {} @@ -3929,10 +3655,7 @@ snapshots: dependencies: svg.js: 2.7.1 - swiper@11.1.15: {} - - symbol-tree@3.2.4: - optional: true + swiper@11.2.2: {} tablesort@5.3.0: {} @@ -3948,28 +3671,10 @@ snapshots: tinyspy@3.0.2: {} - tldts-core@6.1.76: - optional: true - - tldts@6.1.76: - dependencies: - tldts-core: 6.1.76 - optional: true - to-regex-range@5.0.1: dependencies: is-number: 7.0.0 - tough-cookie@5.1.0: - dependencies: - tldts: 6.1.76 - optional: true - - tr46@5.0.0: - dependencies: - punycode: 2.3.1 - optional: true - tree-kill@1.2.2: {} ts-api-utils@2.0.1(typescript@5.7.3): @@ -3984,8 +3689,6 @@ snapshots: undate@0.3.0: {} - undici-types@6.19.8: {} - undici-types@6.20.0: {} uri-js@4.4.1: @@ -4019,13 +3722,13 @@ snapshots: dependencies: esbuild: 0.24.2 postcss: 8.5.1 - rollup: 4.30.1 + rollup: 4.34.6 optionalDependencies: '@types/node': 22.13.4 fsevents: 2.3.3 yaml: 2.7.0 - vitest@3.0.5(@types/node@22.13.4)(jsdom@26.0.0)(yaml@2.7.0): + vitest@3.0.5(@types/node@22.13.4)(yaml@2.7.0): dependencies: '@vitest/expect': 3.0.5 '@vitest/mocker': 3.0.5(vite@6.0.7(@types/node@22.13.4)(yaml@2.7.0)) @@ -4049,7 +3752,6 @@ snapshots: why-is-node-running: 2.3.0 optionalDependencies: '@types/node': 22.13.4 - jsdom: 26.0.0 transitivePeerDependencies: - jiti - less @@ -4070,32 +3772,10 @@ snapshots: w3c-keyname@2.2.8: {} - w3c-xmlserializer@5.0.0: - dependencies: - xml-name-validator: 5.0.0 - optional: true - - webidl-conversions@7.0.0: - optional: true - webrtc-adapter@9.0.1: dependencies: sdp: 3.2.0 - whatwg-encoding@3.1.1: - dependencies: - iconv-lite: 0.6.3 - optional: true - - whatwg-mimetype@4.0.0: - optional: true - - whatwg-url@14.1.0: - dependencies: - tr46: 5.0.0 - webidl-conversions: 7.0.0 - optional: true - which-module@2.0.1: {} which@2.0.2: @@ -4121,15 +3801,6 @@ snapshots: string-width: 7.2.0 strip-ansi: 7.1.0 - ws@8.18.0: - optional: true - - xml-name-validator@5.0.0: - optional: true - - xmlchars@2.2.0: - optional: true - y18n@4.0.3: {} yaml@2.7.0: {} @@ -4155,4 +3826,12 @@ snapshots: yocto-queue@0.1.0: {} + zerofish@0.0.31: + dependencies: + '@types/emscripten': 1.40.0 + '@types/node': 22.13.4 + '@types/web': 0.0.194 + prettier: 3.4.2 + typescript: 5.7.3 + zxcvbn@4.4.2: {} diff --git a/public/font/lichess.sfd b/public/font/lichess.sfd index df0a8a6ec6bd2..67d005157c284 100644 --- a/public/font/lichess.sfd +++ b/public/font/lichess.sfd @@ -7794,5 +7794,72 @@ SplineSet 332.514648438 263.379882812 332.514648438 263.379882812 338.904296875 260.184570312 c 6,49,-1 EndSplineSet EndChar + +StartChar: blindfold +Encoding: 57468 57468 125 +Width: 512 +Flags: WO +LayerCount: 2 +Fore +SplineSet +220.779296875 492.721679688 m 0,0,1 + 112.879882812 491.724609375 112.879882812 491.724609375 81.1875 425.9375 c 0,2,3 + 63.3408203125 388.690429688 63.3408203125 388.690429688 59.7197265625 369.42578125 c 1,4,-1 + 200.896484375 364.194335938 l 1,5,-1 + 325.41796875 336.1015625 l 1,6,-1 + 369.177734375 321.748046875 l 1,7,-1 + 415.43359375 302.10546875 l 1,8,9 + 419.641601562 322.499023438 419.641601562 322.499023438 419.515625 342.85546875 c 0,10,11 + 418.88671875 377.861328125 418.88671875 377.861328125 378.439453125 430.243164062 c 0,12,13 + 337.668945312 482.540039062 337.668945312 482.540039062 274.637695312 489.733398438 c 0,14,15 + 245.680664062 492.953125 245.680664062 492.953125 220.779296875 492.721679688 c 0,0,1 +280.71484375 309.561523438 m 0,16,17 + 279.702148438 309.53125 279.702148438 309.53125 268.741210938 310.50390625 c 128,-1,18 + 257.78125 311.475585938 257.78125 311.475585938 257.483398438 310.228515625 c 0,19,20 + 253.392578125 308.540039062 253.392578125 308.540039062 247.837890625 302.469726562 c 128,-1,21 + 242.282226562 296.3984375 242.282226562 296.3984375 238.772460938 291.172851562 c 2,22,-1 + 235.264648438 285.948242188 l 1,23,-1 + 143.985351562 280.404296875 l 1,24,-1 + 50.08203125 269.903320312 l 1,25,26 + 46.1953125 263.258789062 46.1953125 263.258789062 40.7822265625 253.876953125 c 0,27,28 + 35.3662109375 245.208984375 35.3662109375 245.208984375 32.201171875 237.891601562 c 0,29,30 + 28.1484375 230.770507812 28.1484375 230.770507812 26.5517578125 224.989257812 c 0,31,32 + 24.5390625 219.942382812 24.5390625 219.942382812 24.994140625 216.754882812 c 0,33,34 + 25.4404296875 211.819335938 25.4404296875 211.819335938 34.8623046875 207.239257812 c 0,35,36 + 43.4033203125 202.840820312 43.4033203125 202.840820312 51.375 200.970703125 c 2,37,-1 + 59.3076171875 199.110351562 l 2,38,39 + 72.2568359375 196.479492188 72.2568359375 196.479492188 56.4150390625 175.698242188 c 0,40,41 + 50.7080078125 168.504882812 50.7080078125 168.504882812 68.392578125 147.732421875 c 0,42,43 + 71.462890625 144.139648438 71.462890625 144.139648438 67.1259765625 130.637695312 c 0,44,45 + 64.2431640625 121.58203125 64.2431640625 121.58203125 65.4912109375 119.200195312 c 0,46,47 + 72.365234375 104.435546875 72.365234375 104.435546875 81.8876953125 103.086914062 c 0,48,49 + 109.364257812 98.1181640625 109.364257812 98.1181640625 110.149414062 98.0234375 c 0,50,51 + 142.75390625 101.727539062 142.75390625 101.727539062 168.194335938 93.41015625 c 1,52,53 + 192.383789062 20.51171875 192.383789062 20.51171875 159.897460938 1.5615234375 c 1,54,-1 + 371.661132812 1.5615234375 l 1,55,56 + 334.962890625 36.75390625 334.962890625 36.75390625 338.572265625 104.434570312 c 0,57,58 + 341.279296875 158.579101562 341.279296875 158.579101562 361.16796875 187.805664062 c 0,59,60 + 380.983398438 215.4296875 380.983398438 215.4296875 400.307617188 256.606445312 c 0,61,62 + 400.7109375 257.473632812 400.7109375 257.473632812 401.399414062 259.08984375 c 128,-1,63 + 402.0859375 260.705078125 402.0859375 260.705078125 402.248046875 261.075195312 c 1,64,-1 + 350.756835938 271.900390625 l 1,65,-1 + 298.178710938 288.208984375 l 1,66,67 + 283.987304688 309.66015625 283.987304688 309.66015625 280.71484375 309.561523438 c 0,16,17 +485.915039062 298.461914062 m 2,68,69 + 493.663085938 296.8984375 493.663085938 296.8984375 494.116210938 289.209960938 c 0,70,71 + 494.580078125 280.40234375 494.580078125 280.40234375 487.515625 278.616210938 c 2,72,-1 + 444.045898438 265.0625 l 1,73,74 + 464.801757812 236.184570312 464.801757812 236.184570312 463.364257812 213.731445312 c 0,75,76 + 462.997070312 207.30859375 462.997070312 207.30859375 457.12890625 204.26171875 c 0,77,78 + 450.475585938 201.93359375 450.475585938 201.93359375 445.947265625 206.157226562 c 2,79,-1 + 395.188476562 253.5 l 2,80,81 + 390.443359375 258.200195312 390.443359375 258.200195312 392.385742188 264.243164062 c 2,82,-1 + 405.5703125 305.2734375 l 2,83,84 + 408.927734375 313.985351562 408.927734375 313.985351562 417.48828125 312.258789062 c 2,85,-1 + 485.915039062 298.461914062 l 2,68,69 +415.43359375 302.10546875 m 1,86,-1 + 402.248046875 261.075195312 l 1025,87,-1 +EndSplineSet +EndChar EndChars EndSplineFont diff --git a/public/font/lichess.ttf b/public/font/lichess.ttf index 0106e97733d04..9f574e75bd4b6 100644 Binary files a/public/font/lichess.ttf and b/public/font/lichess.ttf differ diff --git a/public/font/lichess.woff2 b/public/font/lichess.woff2 index 03f0892cdfbbe..69b59cb7f1e7c 100644 Binary files a/public/font/lichess.woff2 and b/public/font/lichess.woff2 differ diff --git a/public/images/puzzle-themes/mix.svg b/public/images/puzzle-themes/healthyMix.svg similarity index 100% rename from public/images/puzzle-themes/mix.svg rename to public/images/puzzle-themes/healthyMix.svg diff --git a/public/oops/font.html b/public/oops/font.html index 205b03e47fbc3..17ca2208ab6ad 100644 --- a/public/oops/font.html +++ b/public/oops/font.html @@ -165,5 +165,6 @@ + diff --git a/translation/source/site.xml b/translation/source/site.xml index b8d17e98bece2..95edf3a9d6e7b 100644 --- a/translation/source/site.xml +++ b/translation/source/site.xml @@ -2,6 +2,7 @@ Play with a friend Play with the computer + Play offline To invite someone to play, give this URL Game Over Waiting for opponent @@ -187,6 +188,7 @@ Games Forum %1$s posted in topic %2$s + %1$s posted results for %2$s Latest forum posts Players Friends diff --git a/ui/.build/package.json b/ui/.build/package.json index 4754dc47b03fc..352e7983d12e7 100644 --- a/ui/.build/package.json +++ b/ui/.build/package.json @@ -24,6 +24,11 @@ "tinycolor2": "1.6.0", "typescript": "5.7.3" }, + "pnpm": { + "onlyBuiltDependencies": [ + "esbuild" + ] + }, "scripts": { "dev": "node --experimental-strip-types --no-warnings src/main.ts $@" } diff --git a/ui/.build/src/build.ts b/ui/.build/src/build.ts index aef393c470a19..475775fff36bb 100644 --- a/ui/.build/src/build.ts +++ b/ui/.build/src/build.ts @@ -1,6 +1,7 @@ import fs from 'node:fs'; import { execSync } from 'node:child_process'; import { chdir } from 'node:process'; +import { join } from 'node:path'; import { parsePackages } from './parse.ts'; import { task, stopTask } from './task.ts'; import { tsc, stopTsc } from './tsc.ts'; @@ -11,35 +12,36 @@ import { hash } from './hash.ts'; import { stopManifest } from './manifest.ts'; import { env, errorMark, c } from './env.ts'; import { i18n } from './i18n.ts'; -import { unique } from './algo.ts'; +import { definedUnique } from './algo.ts'; import { clean } from './clean.ts'; export async function build(pkgs: string[]): Promise { + env.startTime = Date.now(); + chdir(env.rootDir); try { - env.startTime = Date.now(); + try { + if (env.install) execSync('pnpm install', { stdio: 'inherit' }); + if (!pkgs.length) env.log(`Parsing packages in '${c.cyan(env.uiDir)}'`); - chdir(env.rootDir); + await Promise.allSettled([parsePackages(), fs.promises.mkdir(env.buildTempDir)]); - if (env.install) execSync('pnpm install', { stdio: 'inherit' }); - if (!pkgs.length) env.log(`Parsing packages in '${c.cyan(env.uiDir)}'`); + pkgs + .filter(x => !env.packages.has(x)) + .forEach(x => env.exit(`${errorMark} - unknown package '${c.magenta(x)}'`)); - await Promise.allSettled([parsePackages(), fs.promises.mkdir(env.buildTempDir)]); - - pkgs - .filter(x => !env.packages.has(x)) - .forEach(x => env.exit(`${errorMark} - unknown package '${c.magenta(x)}'`)); - - env.building = pkgs.length === 0 ? [...env.packages.values()] : unique(pkgs.flatMap(p => env.deps(p))); - - if (pkgs.length) env.log(`Building ${c.grey(env.building.map(x => x.name).join(', '))}`); + env.building = + pkgs.length === 0 ? [...env.packages.values()] : definedUnique(pkgs.flatMap(p => env.deps(p))); + if (pkgs.length) env.log(`Building ${c.grey(env.building.map(x => x.name).join(', '))}`); + } finally { + monitor(pkgs); + } await Promise.all([i18n(), sync().then(hash).then(sass), tsc(), esbuild()]); } catch (e) { - const errorText = `${errorMark} ${e instanceof Error ? (e.stack ?? e.message) : String(e)}`; - if (env.watch) env.log(errorText); - else env.exit(errorText); + env.log(`${errorMark} ${e instanceof Error ? (e.stack ?? e.message) : String(e)}`); + if (env.watch) env.log(c.grey('Watching...')); + else env.exit(); } - await monitor(pkgs); } function stopBuild(): Promise { @@ -67,10 +69,12 @@ function monitor(pkgs: string[]) { if (!env.install) env.exit('Exiting due to package.json change'); await stopBuild(); if (env.clean) await clean(); + else await clean([join(env.buildTempDir, 'noCheck/*')]); build(pkgs); } else if (files.some(x => x.endsWith('.d.ts') || x.endsWith('tsconfig.json'))) { stopManifest(); await Promise.allSettled([stopTsc(), stopEsbuild()]); + await clean([join(env.buildTempDir, 'noCheck/*')]); tsc(); esbuild(); } diff --git a/ui/.build/src/env.ts b/ui/.build/src/env.ts index f8b4388e6b060..71b83bd412e5b 100644 --- a/ui/.build/src/env.ts +++ b/ui/.build/src/env.ts @@ -2,7 +2,7 @@ import type { Package } from './parse.ts'; import fs from 'node:fs'; import ps from 'node:process'; import { join, resolve, dirname } from 'node:path'; -import { unique, isEquivalent, trimLines } from './algo.ts'; +import { definedUnique, isEquivalent, trimLines } from './algo.ts'; import { updateManifest } from './manifest.ts'; import { taskOk } from './task.ts'; @@ -31,7 +31,6 @@ export const env = new (class { clean = false; prod = false; debug = false; - rgb = false; test = false; install = true; logTime = true; @@ -72,7 +71,7 @@ export const env = new (class { ...(this.workspaceDeps.get(dep) ?? []).flatMap(d => depList(d)), dep, ]; - return unique(depList(pkgName).map(name => this.packages.get(name))); + return definedUnique(depList(pkgName).map(name => this.packages.get(name))); } log(d: any, ctx = 'build'): void { diff --git a/ui/.build/src/esbuild.ts b/ui/.build/src/esbuild.ts index c5bfe0e850b15..985406d0146b4 100644 --- a/ui/.build/src/esbuild.ts +++ b/ui/.build/src/esbuild.ts @@ -4,7 +4,7 @@ import { join, basename } from 'node:path'; import { env, errorMark, warnMark, c } from './env.ts'; import { type Manifest, updateManifest } from './manifest.ts'; import { task, stopTask } from './task.ts'; -import { reduceWhitespace } from './algo.ts'; +import { definedMap, reduceWhitespace } from './algo.ts'; let esbuildCtx: es.BuildContext | undefined; @@ -26,7 +26,6 @@ export async function esbuild(): Promise { chunkNames: 'common.[hash]', plugins, }; - await fs.promises.mkdir(env.jsOutDir).catch(() => {}); return Promise.all([ inlineTask(), @@ -37,10 +36,10 @@ export async function esbuild(): Promise { noEnvStatus: true, globListOnly: true, glob: env.building.flatMap(pkg => - pkg.bundle - .map(bundle => bundle.module) - .filter((module): module is string => Boolean(module)) - .map(path => ({ cwd: pkg.root, path })), + definedMap( + pkg.bundle.map(bundle => bundle.module), + path => ({ cwd: pkg.root, path }), + ), ), execute: async entryPoints => { await esbuildCtx?.dispose(); @@ -76,10 +75,10 @@ function inlineTask() { debounce: 300, noEnvStatus: true, glob: env.building.flatMap(pkg => - pkg.bundle - .map(b => b.inline) - .filter((i): i is string => Boolean(i)) - .map(i => ({ cwd: pkg.root, path: i })), + definedMap( + pkg.bundle.map(b => b.inline), + path => ({ cwd: pkg.root, path }), + ), ), execute: (_, inlines) => Promise.all( diff --git a/ui/.build/src/hash.ts b/ui/.build/src/hash.ts index 57266660a8ef0..954dde0439cb0 100644 --- a/ui/.build/src/hash.ts +++ b/ui/.build/src/hash.ts @@ -45,11 +45,11 @@ export async function hash(): Promise { }), ); if (update && pkg?.root) { - const updates: Record = {}; + const replacements: Record = {}; for (const src of fullList.map(f => relative(env.outDir, f))) { - updates[src] = hashedBasename(src, hashed[src].hash!); + replacements[src] = `hashed/${hashedBasename(src, hashed[src].hash!)}`; } - const { name, hash } = await replaceHash(relative(pkg.root, update), pkg.root, updates); + const { name, hash } = await replaceAllWithHashUrls(update, replacements); hashed[name] = { hash }; if (shouldLog) hashLog(name, hashedBasename(name, hash), pkg.name); } @@ -96,14 +96,14 @@ async function isLinkStale(symlink: string | undefined) { return !isClose(linkMs, targetMs); } -async function replaceHash(name: string, root: string, files: Record) { +async function replaceAllWithHashUrls(name: string, files: Record) { const result = Object.entries(files).reduce( (data, [from, to]) => data.replaceAll(from, to), - await fs.promises.readFile(join(root, name), 'utf8'), + await fs.promises.readFile(name, 'utf8'), ); const hash = crypto.createHash('sha256').update(result).digest('hex').slice(0, 8); await fs.promises.writeFile(join(env.hashOutDir, hashedBasename(name, hash)), result); - return { name, hash }; + return { name: relative(env.outDir, name), hash }; } async function hashAndLink(name: string) { diff --git a/ui/.build/src/main.ts b/ui/.build/src/main.ts index 33f75b4c7597d..d7ed7b8921544 100644 --- a/ui/.build/src/main.ts +++ b/ui/.build/src/main.ts @@ -103,7 +103,6 @@ env.debug = argv.includes('--debug') || oneDashArgs.includes('d'); env.remoteLog = stringArg('--log'); env.clean = argv.some(x => x.startsWith('--clean')) || oneDashArgs.includes('c'); env.install = !argv.includes('--no-install') && !oneDashArgs.includes('n'); -env.rgb = argv.includes('--rgb'); env.test = argv.includes('--test') || oneDashArgs.includes('t'); if (argv.includes('--help') || oneDashArgs.includes('h')) { diff --git a/ui/.build/src/sass.ts b/ui/.build/src/sass.ts index 909727eb2456b..ac1b05b1153f6 100644 --- a/ui/.build/src/sass.ts +++ b/ui/.build/src/sass.ts @@ -112,6 +112,7 @@ async function compile(sources: string[], logAll = true): Promise { sassPs.stderr?.on('data', (buf: Buffer) => sassError(buf.toString('utf8'))); sassPs.stdout?.on('data', (buf: Buffer) => sassError(buf.toString('utf8'))); sassPs.on('close', async (code: number) => { + sassPs = undefined; if (code === 0) resolveWithErrors([]); else Promise.all(sources.filter(scss => !readable(absTempCss(scss)))) @@ -138,8 +139,8 @@ async function parseScss(src: string, processed: Set) { colorMixMap.set(mixName, mixColor); } - for (const [, scssUrl] of text.matchAll(/[^a-zA-Z0-9\-_]url\((?:['"])?(\.\.\/[^'")]+)/g)) { - const url = scssUrl.replaceAll(/#\{[^}]+\}/g, '*'); // scss interpolation -> glob + for (const [, urlProp] of text.matchAll(/[^a-zA-Z0-9\-_]url\((?:['"])?(\.\.\/[^'")]+)/g)) { + const url = urlProp.replaceAll(/#\{[^}]+\}/g, '*'); // scss interpolation -> glob if (url.includes('*')) { for (const file of await glob(url, { cwd: env.cssOutDir, absolute: false })) { @@ -227,7 +228,7 @@ async function buildColorMixes() { return c1.setAlpha(c1.getAlpha() * (1 - clamp(mix.val / 100, { min: 0, max: 1 }))); } })(); - if (mixed) colors.push(` --m-${colorMix}: ${env.rgb ? mixed.toRgbString() : mixed.toHslString()};`); + if (mixed) colors.push(` --m-${colorMix}: ${mixed.toHslString()};`); else env.log(`${errorMark} Invalid mix op: '${c.magenta(colorMix)}'`, 'sass'); } out.write(colors.sort().join('\n') + '\n}\n\n'); diff --git a/ui/.build/src/task.ts b/ui/.build/src/task.ts index 4ac282fa7bbe1..2e21925a2337c 100644 --- a/ui/.build/src/task.ts +++ b/ui/.build/src/task.ts @@ -3,7 +3,7 @@ import mm from 'micromatch'; import fs from 'node:fs'; import { join, relative, basename } from 'node:path'; import { type Package, glob, isFolder, subfolders, isClose } from './parse.ts'; -import { randomToken } from './algo.ts'; +import { randomId } from './algo.ts'; import { type Context, env, c, errorMark } from './env.ts'; const fsWatches = new Map(); @@ -31,7 +31,7 @@ type Task = Omit & { type TaskOpts = { glob: CwdPath | CwdPath[]; execute: (touched: AbsPath[], fullList: AbsPath[]) => Promise; - key?: TaskKey; // optional key for overwrite, stop, tickle + key?: TaskKey; // optional key for task overwrite and stopTask ctx?: Context; // optional build step context for logging pkg?: Package; // optional package reference debounce?: number; // optional number in ms @@ -39,6 +39,7 @@ type TaskOpts = { globListOnly?: boolean; // default false - ignore file mods, only execute when glob list changes monitorOnly?: boolean; // default false - do not execute on initial traverse, only on future changes noEnvStatus?: boolean; // default false - don't inform env.done of task status + noFail?: boolean; // default false - exceptions are not logged and won't affect status }; export async function task(o: TaskOpts): Promise { @@ -48,7 +49,7 @@ export async function task(o: TaskOpts): Promise { const newWatch: Task = { ...o, glob, - key: inKey ?? randomToken(), + key: inKey ?? randomId(), status: noInitial ? 'ok' : undefined, debounce: { time: debounce ?? 0, rename: !noInitial, files: new Set() }, fileTimes: noInitial ? await globTimes(glob) : new Map(), diff --git a/ui/@types/lichess/i18n.d.ts b/ui/@types/lichess/i18n.d.ts index 6c719dd8547cc..7448305ba5d6f 100644 --- a/ui/@types/lichess/i18n.d.ts +++ b/ui/@types/lichess/i18n.d.ts @@ -2771,6 +2771,8 @@ interface I18n { asBlack: string; /** As free as Lichess */ asFreeAsLichess: string; + /** %1$s posted results for %2$s */ + askConcluded: I18nFormat; /** Your account is managed. Ask your chess teacher about lifting kid mode. */ askYourChessTeacherAboutLiftingKidMode: string; /** as white */ @@ -3737,6 +3739,8 @@ interface I18n { playFirstOpeningEndgameExplorerMove: string; /** Playing right now */ playingRightNow: string; + /** Play offline */ + playOffline: string; /** play selected move */ playSelectedMove: string; /** Play a variation to create conditional premoves */ diff --git a/ui/@types/lichess/index.d.ts b/ui/@types/lichess/index.d.ts index e0df5c18ca7c5..e9a794982afc6 100644 --- a/ui/@types/lichess/index.d.ts +++ b/ui/@types/lichess/index.d.ts @@ -132,6 +132,12 @@ interface AssetUrlOpts { pathVersion?: true | string; } +interface Dictionary { + [key: string]: T | undefined; +} + +type SocketHandlers = Dictionary<(d: any) => void>; + type Timeout = ReturnType; type SocketSend = (type: string, data?: any, opts?: any, noRetry?: boolean) => void; @@ -285,12 +291,6 @@ declare namespace PowerTip { } } -interface Dictionary { - [key: string]: T | undefined; -} - -type SocketHandlers = Dictionary<(d: any) => void>; - declare const site: Site; declare const fipr: Fipr; declare const i18n: I18n; diff --git a/ui/analyse/css/study/relay/_layout.scss b/ui/analyse/css/study/relay/_layout.scss index 903e0ed75ecf4..fd99f36a16ec3 100644 --- a/ui/analyse/css/study/relay/_layout.scss +++ b/ui/analyse/css/study/relay/_layout.scss @@ -12,6 +12,7 @@ body { main.is-relay { .relay-tour { grid-area: relay-tour; + overflow: visible; &__side { grid-area: side; } diff --git a/ui/analyse/package.json b/ui/analyse/package.json index 9c631c38f9864..3f1293e0bbb7c 100644 --- a/ui/analyse/package.json +++ b/ui/analyse/package.json @@ -17,6 +17,7 @@ "chat": "workspace:*", "chess": "workspace:*", "common": "workspace:*", + "dasher": "workspace:*", "debounce-promise": "^3.1.2", "editor": "workspace:*", "game": "workspace:*", diff --git a/ui/analyse/src/interfaces.ts b/ui/analyse/src/interfaces.ts index 3ea5e825a825c..649b3228cb060 100644 --- a/ui/analyse/src/interfaces.ts +++ b/ui/analyse/src/interfaces.ts @@ -49,6 +49,8 @@ export interface AnalyseData { }; puzzle?: OpeningPuzzle; externalEngines?: ExternalEngineInfo[]; + boards: any; + pieces: any; } export interface AnalysePref { diff --git a/ui/analyse/src/study/gamebook/gamebookPlayView.ts b/ui/analyse/src/study/gamebook/gamebookPlayView.ts index 782052a2e3745..425d31441e3ee 100644 --- a/ui/analyse/src/study/gamebook/gamebookPlayView.ts +++ b/ui/analyse/src/study/gamebook/gamebookPlayView.ts @@ -21,7 +21,11 @@ export function render(ctrl: GamebookPlayCtrl): VNode { h('div.floor', [ renderFeedback(ctrl, state), h('img.mascot', { - attrs: { width: 120, height: 120, src: site.asset.url('images/mascot/octopus.svg') }, + attrs: { + width: 120, + height: 120, + src: site.asset.url('images/mascot/octopus.svg'), + }, }), ]), ]); diff --git a/ui/analyse/src/study/relay/relayPlayers.ts b/ui/analyse/src/study/relay/relayPlayers.ts index b1cfc87ce2d7d..74f6e771027e6 100644 --- a/ui/analyse/src/study/relay/relayPlayers.ts +++ b/ui/analyse/src/study/relay/relayPlayers.ts @@ -161,7 +161,9 @@ const playerView = (ctrl: RelayPlayers, show: PlayerToShow, tour: RelayTour): VN h('em', i18n.broadcast.federation), h('a.relay-tour__player__fed', { attrs: { href: `/fide/federation/${p.fed.name}` } }, [ h('img.mini-game__flag', { - attrs: { src: site.asset.url(`images/fide-fed-webp/${p.fed.id}.webp`) }, + attrs: { + src: site.asset.url(`images/fide-fed-webp/${p.fed.id}.webp`), + }, }), p.fed.name, ]), diff --git a/ui/analyse/src/study/studyShare.ts b/ui/analyse/src/study/studyShare.ts index c9b1ef8dc2a08..03851a25ae82a 100644 --- a/ui/analyse/src/study/studyShare.ts +++ b/ui/analyse/src/study/studyShare.ts @@ -176,6 +176,28 @@ export function view(ctrl: StudyShare): VNode { }, 'GIF', ), + h( + 'button.button.text', + { + hook: bind('click', () => + xhrText(`/study/${studyId}.pgn`).then(pgn => + site.asset.loadEsm('local.dev', { init: { pgn, name: ctrl.data.name } }), + ), + ), + }, + 'bot editor', + ), + /*h( + 'button.button.text', + { + hook: bind('click', () => + xhrText(`/study/${studyId}/${chapter.id}.pgn`).then(pgn => + site.asset.loadEsm('local.dev', { init: { pgn, name: chapter.name } }), + ), + ), + }, + 'chapter to bot editor', + ),*/ ]), h('form.form3', [ ...(ctrl.relay diff --git a/ui/analyse/src/study/topics.ts b/ui/analyse/src/study/topics.ts index d9949f56572b4..3ad1781f45885 100644 --- a/ui/analyse/src/study/topics.ts +++ b/ui/analyse/src/study/topics.ts @@ -67,7 +67,7 @@ export const formView = (ctrl: TopicsCtrl, userId?: string): VNode => ], onInsert: dlg => { dlg.show(); - (dlg.view.querySelector('.tagify__input') as HTMLElement)?.focus(); + (dlg.viewEl.querySelector('.tagify__input') as HTMLElement)?.focus(); }, }); diff --git a/ui/analyse/src/view/main.ts b/ui/analyse/src/view/main.ts index d911afbf346a2..f145bf65cf987 100644 --- a/ui/analyse/src/view/main.ts +++ b/ui/analyse/src/view/main.ts @@ -19,8 +19,9 @@ import { renderTools, renderUnderboard, } from './components'; -import { wikiToggleBox } from '../wiki'; +//import { wikiToggleBox } from '../wiki'; import { watchers } from 'common/watchers'; +import { dasherPolls } from 'dasher'; export default function (deps?: typeof studyDeps) { return function (ctrl: AnalyseCtrl): VNode { @@ -50,35 +51,35 @@ function analyseView(ctrl: AnalyseCtrl, deps?: typeof studyDeps): VNode { ? deps?.studyPracticeView.side(study!) : h( 'aside.analyse__side', - { - hook: onInsert(elm => { - if (ctrl.opts.$side && ctrl.opts.$side.length) { - $(elm).replaceWith(ctrl.opts.$side); - wikiToggleBox(); - } - }), - }, + // { + // hook: onInsert(elm => { + // if (ctrl.opts.$side && ctrl.opts.$side.length) { + // $(elm).replaceWith(ctrl.opts.$side); + // wikiToggleBox(); + // } + // }), + // }, ctrl.studyPractice ? [deps?.studyPracticeView.side(study!)] : study ? [deps?.studyView.side(study, true)] : [ ctrl.forecast && forecastView(ctrl, ctrl.forecast), - !ctrl.synthetic && - playable(ctrl.data) && - h( - 'div.back-to-game', - h( - 'a.button.button-empty.text', - { - attrs: { - href: router.game(ctrl.data, ctrl.data.player.color), - 'data-icon': licon.Back, + !ctrl.synthetic && playable(ctrl.data) + ? h( + 'div.back-to-game', + h( + 'a.button.button-empty.text', + { + attrs: { + href: router.game(ctrl.data, ctrl.data.player.color), + 'data-icon': licon.Back, + }, }, - }, - i18n.site.backToGame, - ), - ), + i18n.site.backToGame, + ), + ) + : dasherPolls(ctrl.opts.data.boards, ctrl.opts.data.pieces), //, ctrl.redraw), ], ), h('div.chat__members.none', { hook: onInsert(watchers) }), diff --git a/ui/bits/css/_ask.admin.scss b/ui/bits/css/_ask.admin.scss new file mode 100644 index 0000000000000..78c2a04a8ead3 --- /dev/null +++ b/ui/bits/css/_ask.admin.scss @@ -0,0 +1,61 @@ +:root { + --c-box-border: #484848; + + @include if-light { + --c-box-border: #ddd; + } +} + +// someone, please delete all of this +div.ask-admin { + width: 100%; + font-size: 1rem; + div.header { + font-size: 1.3em; + display: flex; + justify-content: space-between; + align-items: center; + } + div.url-actions { + display: flex; + flex-flow: row-reverse nowrap; + a, + button { + @extend %button-none; + font-size: 1em; + color: $c-primary; + padding: 0.6em 0.8em; + margin: 0; + &:hover { + color: $m-primary_font--mix-50; + background: $m-primary_bg--mix-15; + } + } + } + .prop { + font-size: 1em; + font-weight: bold; + display: flex; + flex-flow: row nowrap; + .name { + width: 140px; + } + .value { + font-style: bold; + flex: auto; + } + } + .inset { + margin: 1em; + } + .inset-box { + padding: 1em; + border: solid 1px var(--c-box-border); + background: $c-bg-box; + > p { + margin-inline-start: 2em; + font-size: 0.8em; + text-indent: -2em; + } + } +} diff --git a/ui/bits/css/_ask.scss b/ui/bits/css/_ask.scss new file mode 100644 index 0000000000000..4bbaceb67b3d3 --- /dev/null +++ b/ui/bits/css/_ask.scss @@ -0,0 +1,417 @@ +$gap: 6px; +:root { + --c-contrast-font: #ddd; + --c-neutral: #777; + --c-box-border: #484848; + --c-unset1: #f66; + --c-unset2: #c33; + --c-enabled-gradient-top: hsl(0, 0%, 27%); + --c-enabled-gradient-bottom: hsl(0, 0%, 19%); + --c-badge-background: #666; + --c-badge-border: #666; + --c-badge: #000; + --c-ibeam: var(--c-font); + ---hover-opacity: 0.1; + + @include if-light { + --c-contrast-font: #444; + --c-neutral: #ccc; + --c-box-border: #ddd; + --c-unset1: #c33; + --c-unset2: #f66; + --c-enabled-gradient-top: hsl(0, 0%, 92%); + --c-enabled-gradient-bottom: hsl(0, 0%, 86%); + --c-badge-background: #bbb; + --c-badge-border: #aaa; + --c-badge: #ddd; + --c-ibeam: #bbb; + ---hover-opacity: 0.3; + } + @include if-transp { + --c-enabled-gradient-top: hsla(0, 0%, 50%, 0.3); + --c-enabled-gradient-bottom: hsla(0, 0%, 50%, 0.3); + } +} + +$c-contrast-font: var(--c-contrast-font); +$c-neutral: var(--c-neutral); +$c-box-border: var(--c-box-border); +//$bg-hover: var(---box-border); +$hover-opacity: var(---hover-opacity); +$c-unset1: var(--c-unset1); +$c-unset2: var(--c-unset2); + +%lighten-hover { + color: $c-contrast-font; + &:hover { + box-shadow: inset 0 0 1px 100px hsla(0, 0%, 100%, var(---hover-opacity)); + } +} + +div.ask-container { + display: flex; + font-size: 1rem; + + &.stretch { + flex-direction: column; + align-items: stretch; + } +} + +fieldset.ask { + margin-bottom: 2 * $gap; + padding: 0 (3 * $gap) $gap (2 * $gap); + width: 100%; + line-height: normal; + border: solid 1px var(--c-box-border); + container-type: inline-size; + + > label { + margin: $gap; + flex-basis: 100%; + font-weight: bold; + } + + & > * { + display: flex; + align-items: center; + flex-direction: row; + } +} + +span.ask__header { + display: flex; + flex: 1 0 100%; + column-gap: $gap; + justify-content: space-between; + + label { + flex: auto; + margin-inline-start: $gap; + padding-bottom: $gap; + font-size: 1.3em; + } + + label span { + white-space: nowrap; + margin-inline-start: $gap; + font-size: 0.6em; + + @container (max-width: #{$x-small}) { + display: none; + } + } + + div { + display: flex; + align-content: center; + border: 1px solid var(--c-box-border); + border-radius: 4px; + align-self: center; + } + + div button { + @extend %button-none; + padding: 0 0.25em; + font-size: 1.2em; + font-family: lichess; + } + + div.url-actions { + border-color: $m-primary_dimmer--mix-40; + font-size: 1em; + padding: 0.3em; + } + + div.url-actions button { + padding: 0 $gap / 4; + color: $c-link; + cursor: pointer; + + &:hover { + color: $c-link-hover; + } + + &.admin::before { + content: $licon-Gear; + } + + &.view::before { + content: $licon-Pencil; + } + + &.tally::before { + content: $licon-BarChart; + } + + &.unset { + color: var(--c-unset1); + + &::before { + content: $licon-X; + } + + &:hover { + color: var(--c-unset2); + } + } + } + + div.properties { + font-size: 0.8em; + padding: 0.2em; + } + + div.properties button { + padding: 0 $gap / 2; + color: $c-font-dim; + cursor: default; + + &.open::before { + content: $licon-Group; + } + + &.anon::before { + content: $licon-Mask; + } + + &.trace::before { + content: $licon-Search; + } + } +} + +div.ask__footer { + margin: $gap 0; + display: grid; + grid-template-columns: auto min-content; + + // form prompt + label { + margin: 0 0 $gap $gap; + grid-column: 1/3; + } + + .form-text { + margin: 0 0 $gap $gap; + padding: 0.4em; + } + + .form-submit { + @extend %data-icon, %flex-around; + visibility: hidden; + + input { + margin: 0 0 $gap (2 * $gap); + padding: $gap 1.2em; + } + + &.success { + visibility: visible; + color: $c-secondary; + + & > input { + visibility: hidden; + } + + &::after { + position: absolute; + content: $licon-Checkmark; + } + } + + &.dirty { + visibility: visible; + } + } + + .form-results { + grid-column: 1/3; + display: grid; + grid-template-columns: max-content auto; + padding: 0 (2 * $gap) $gap $gap; + label { + grid-column: 1/3; + font-size: 1.3em; + margin: 0 0 $gap 0; + } + div { + margin: 0 $gap; + } + } +} + +div.ask__choices { + margin: $gap 0 $gap 0; + display: flex; + flex-flow: row wrap; + align-items: flex-start; + + .choice { + display: inline-block; + user-select: none; + flex: initial; + &:focus-within { + outline: 1px solid $c-primary; + } + } + + .choice.cbx { + display: flex; + flex-flow: row nowrap; + align-items: center; + margin: 1.5 * $gap $gap 0; + &:first-child { + margin-top: 0; + } + + &.selected, + &.enabled { + cursor: pointer; + > input { + cursor: pointer; + } + } + > input { + pointer-events: none; + min-width: 24px; + min-height: 24px; + cursor: pointer; + margin-inline-end: $gap; + } + } + + .choice.btn { + @extend %metal, %box-radius; + margin: 0 0 $gap $gap; + padding: $gap (2 * $gap); + text-align: center; + border: 1px; + border-color: var(--c-neutral); + + &.enabled { + @extend %lighten-hover; + cursor: pointer; + background: linear-gradient(var(--c-enabled-gradient-top), var(--c-enabled-gradient-bottom)); + } + + &.selected { + @extend %lighten-hover; + cursor: pointer; + color: white; + background: linear-gradient(hsl(209, 79%, 58%) 0%, hsl(209, 79%, 52%) 100%); + } + + &.stretch { + flex: auto; + } + } + + .choice.btn.rank { + @extend %lighten-hover; + display: flex; + justify-content: space-between; + align-items: center; + cursor: move; + touch-action: none; + + &.dragging { + opacity: 0.3; + } + ::after { + content: ''; + } + + // rank badge + > div { + margin-inline-start: -$gap; + margin-inline-end: $gap; + width: 1.7em; + height: 1.7em; + border-radius: 100%; + background: var(--c-badge-background); + border: 1px solid var(--c-badge-border); + color: var(--c-badge); + text-align: center; + font-size: 0.7em; + font-weight: bold; + } + + > label { + cursor: move; + } + + // green rank badge (submitted) + &.submitted > div { + background: $c-secondary; + } + + @include if-transp { + background: hsla(0deg, 0%, 50%, 0.3); + } + } + + // vertical ask drag cursor + hr { + margin: 0 0 $gap ($gap / 2); + width: 100%; + height: 2px; + display: block; + border-top: 1px solid var(--c-neutral); + border-bottom: 1px solid var(--c-box-border); + } + + // horizontal ask drag cursor (I-beam) + .cursor { + margin: 0 0 0 $gap; + padding: 0; + width: 2 * $gap; + text-align: center; + + // I-beam icon + &::after { + @extend %data-icon; + margin-inline-start: -$gap; + font-size: 2.1em; + color: var(--c-ibeam); + text-align: center; + content: $licon-Ibeam; + } + } + + &.vertical { + flex-flow: column; + } + + &.center { + align-items: center; + justify-content: center; + } +} + +div.ask__graph, +div.ask__rank-graph { + margin: 0 $gap (2 * $gap) $gap; + display: grid; + grid-template-columns: fit-content(40%) max-content auto; + grid-auto-rows: 1fr; + align-items: center; + + div { + margin: $gap $gap 0 $gap; + user-select: none; + } + .votes-text { + margin-right: 0; + text-align: end; + } + .set-width { + height: 75%; + min-width: 0.2em; + background: $c-primary; + } +} + +div.ask__rank-graph { + grid-template-columns: fit-content(40%) auto; +} diff --git a/ui/bits/css/build/bits.ask.scss b/ui/bits/css/build/bits.ask.scss new file mode 100644 index 0000000000000..1cf5c308b4a2b --- /dev/null +++ b/ui/bits/css/build/bits.ask.scss @@ -0,0 +1,3 @@ +@import '../../../common/css/plugin'; +@import '../ask'; +@import '../ask.admin'; diff --git a/ui/bits/package.json b/ui/bits/package.json index 0b0210d21e910..750d1f530d007 100644 --- a/ui/bits/package.json +++ b/ui/bits/package.json @@ -36,6 +36,7 @@ "debounce-promise": "^3.1.2", "emoji-mart": "^5.6.0", "flatpickr": "^4.6.13", + "game": "workspace:*", "lichess-pgn-viewer": "^2.4.0", "prop-types": "^15.8.1", "qrcode": "^1.5.4", diff --git a/ui/bits/src/bits.ask.ts b/ui/bits/src/bits.ask.ts new file mode 100644 index 0000000000000..33cc04276ecfa --- /dev/null +++ b/ui/bits/src/bits.ask.ts @@ -0,0 +1,289 @@ +import * as xhr from 'common/xhr'; +import { throttle } from 'common/timing'; +import { isTouchDevice } from 'common/device'; + +export default function initModule(): void { + $('.ask-container').each((_, e: EleLoose) => new Ask(e.firstElementChild!)); +} + +if (isTouchDevice()) site.asset.loadIife('javascripts/vendor/dragdroptouch.js'); + +class Ask { + el: Element; + anon: boolean; + submitEl?: Element; + formEl?: HTMLInputElement; + view: string; // the initial order of picks when 'random' tag is used + initialRanks: string; // initial rank order + initialForm: string; // initial form value + db: 'clean' | 'hasPicks'; // clean means no picks for this (ask, user) in the db + constructor(askEl: Element) { + this.el = askEl; + this.anon = askEl.classList.contains('anon'); + this.db = askEl.hasAttribute('value') ? 'hasPicks' : 'clean'; + this.view = Array.from($('.choice', this.el), e => e?.getAttribute('value')).join('-'); + this.initialRanks = this.ranking(); + this.initialForm = this.formEl?.value ?? ''; + wireSubmit(this); + wireForm(this); + wireRankedChoices(this); + wireExclusiveChoices(this); + wireMultipleChoices(this); + wireActions(this); + } + + ranking(): string { + return Array.from($('.choice.rank', this.el), e => e?.getAttribute('value')).join('-'); + } + relabel() { + const submitted = this.ranking() == this.initialRanks && this.db == 'hasPicks'; + $('.choice.rank', this.el).each((i, e) => { + $('div', e).text(`${i + 1}`); + e.classList.toggle('submitted', submitted); + }); + } + setSubmitState(state: 'clean' | 'dirty' | 'success') { + this.submitEl?.classList.remove('dirty', 'success'); + if (state != 'clean') this.submitEl?.classList.add(state); + } + picksUrl(picks: string): string { + return `/ask/picks/${this.el.id}${picks ? `?picks=${picks}&` : '?'}view=${this.view}${ + this.el.classList.contains('anon') ? '&anon=true' : '' + }`; + } +} + +function rewire(el: Element | null, frag: string): Ask | undefined { + while (el && !el.classList.contains('ask-container')) el = el.parentElement; + if (el && frag) { + el.innerHTML = frag; + return new Ask(el.firstElementChild!); + } +} + +function askXhr(req: { ask: Ask; url: string; method?: string; body?: FormData; after?: (_: Ask) => void }) { + return xhr.textRaw(req.url, { method: req.method ? req.method : 'POST', body: req.body }).then( + async (rsp: Response) => { + if (rsp.redirected) { + if (!rsp.url.startsWith(window.location.origin)) throw new Error(`Bad redirect: ${rsp.url}`); + window.location.href = rsp.url; + return; + } + const newAsk = rewire(req.ask.el, await xhr.ensureOk(rsp).text()); + if (req.after) req.after(newAsk!); + }, + (rsp: Response) => { + console.log(`Ask failed: ${rsp.status} ${rsp.statusText}`); + }, + ); +} + +function wireSubmit(ask: Ask) { + ask.submitEl = $('.form-submit', ask.el).get(0); + if (!ask.submitEl) return; + $('input', ask.submitEl).on('click', async () => { + const path = `/ask/form/${ask.el.id}?view=${ask.view}&anon=${ask.el.classList.contains('anon')}`; + const body = ask.formEl?.value ? xhr.form({ text: ask.formEl.value }) : undefined; + const newOrder = ask.ranking(); + if (newOrder && (ask.db === 'clean' || newOrder != ask.initialRanks)) + await askXhr({ + ask: ask, + url: ask.picksUrl(newOrder), + body: body, + after: ask => ask.setSubmitState('success'), + }); + else if (ask.formEl) + askXhr({ + ask: ask, + url: path, + body: body, + after: ask => ask.setSubmitState(ask.formEl?.value ? 'success' : 'clean'), + }); + }); +} + +function wireExclusiveChoices(ask: Ask) { + $('.choice.exclusive', ask.el).on('click', function (e: Event) { + const el = e.target as Element; + askXhr({ + ask: ask, + url: ask.picksUrl(el.classList.contains('selected') ? '' : el.getAttribute('value')!), + }); + e.preventDefault(); + }); +} + +function wireMultipleChoices(ask: Ask) { + $('.choice.multiple', ask.el).on('click', function (e: Event) { + $(e.target as Element).toggleClass('selected'); + const picks = $('.choice', ask.el) + .filter((_, x) => x.classList.contains('selected')) + .get() + .map(x => x.getAttribute('value')); + askXhr({ ask: ask, url: ask.picksUrl(picks.join('-')) }); + e.preventDefault(); + }); +} + +function wireForm(ask: Ask) { + ask.formEl = $('.form-text', ask.el) + .on('input', () => { + const dirty = + ask.formEl?.value != ask.initialForm || + (ask.initialRanks && (ask.ranking() != ask.initialRanks || ask.db === 'clean')); + ask.setSubmitState(dirty ? 'dirty' : 'clean'); + }) + .on('keypress', (e: KeyboardEvent) => { + if ( + e.key != 'Enter' || + e.shiftKey || + e.ctrlKey || + e.altKey || + e.metaKey || + !ask.submitEl?.classList.contains('dirty') + ) + return; + $('input', ask.submitEl).trigger('click'); + e.preventDefault(); + }) + .get(0) as HTMLInputElement; +} + +function wireActions(ask: Ask) { + $('.url-actions button', ask.el).on('click', (e: Event) => { + const btn = e.target as HTMLButtonElement; + askXhr({ ask: ask, method: btn.formMethod, url: btn.formAction }); + }); +} + +function wireRankedChoices(ask: Ask) { + let d: DragContext; + + const container = $('.ask__choices', ask.el); + const vertical = container.hasClass('vertical'); + const [cursorEl, breakEl] = createCursor(vertical); + const updateCursor = throttle(100, (d: DragContext, e: DragEvent) => { + // avoid processing a delayed drag event after the drop + const ePoint = { x: e.clientX, y: e.clientY }; + if (!d.isDone) vertical ? updateVCursor(d, ePoint) : updateHCursor(d, ePoint); + }); + + if (ask.db === 'clean') ask.setSubmitState('dirty'); + container.on('dragover dragleave', (e: DragEvent) => { + e.preventDefault(); + updateCursor(d, e); + }); + /*.on('dragleave', (e: DragEvent) => { + e.preventDefault(); + updateCursor(d, e); + });*/ + + $('.choice.rank', ask.el) // wire each draggable + .on('dragstart', (e: DragEvent) => { + e.dataTransfer!.effectAllowed = 'move'; + e.dataTransfer!.setData('text/plain', ''); //$('label', e.target as Element).text()); + const dragEl = e.target as Element; + dragEl.classList.add('dragging'); + d = { + dragEl: dragEl, + parentEl: dragEl.parentElement!, + box: dragEl.parentElement!.getBoundingClientRect(), + cursorEl: cursorEl!, + breakEl: breakEl, + choices: Array.from($('.choice.rank', ask.el), e => e!), + isDone: false, + }; + }) + .on('dragend', (e: DragEvent) => { + e.preventDefault(); + d.isDone = true; + d.dragEl.classList.remove('dragging'); + if (d.cursorEl.parentElement != d.parentEl) return; + d.parentEl.insertBefore(d.dragEl, d.cursorEl); + clearCursor(d); + ask.relabel(); + if (ask.ranking() != ask.initialRanks) ask.setSubmitState('dirty'); + /*const newOrder = ask.ranking(); + if (newOrder == ask.initialRanks) return; + askXhr({ + ask: ask, + url: ask.picksUrl(newOrder), + after: () => { + ask.initialOrder = newOrder; + }, + });*/ + }); +} + +type DragContext = { + dragEl: Element; // we are dragging this + parentEl: Element; // the div.ask__chioces containing the draggables + box: DOMRect; // the rectangle containing all draggables + cursorEl: Element; // the insertion cursor (I beam div or
depending on mode) + breakEl: Element | null; // null if vertical, a div {flex-basis: 100%} if horizontal + choices: Array; // the draggable elements + isDone: boolean; // emerge victorious after the onslaught of throttled dragover events + data?: any; // used to track dirty state in updateHCursor +}; + +function createCursor(vertical: boolean) { + if (vertical) return [document.createElement('hr'), null]; + + const cursorEl = document.createElement('div'); + cursorEl.classList.add('cursor'); + const breakEl = document.createElement('div'); + breakEl.style.flexBasis = '100%'; + return [cursorEl, breakEl]; +} + +function clearCursor(d: DragContext) { + if (d.cursorEl.parentNode) d.parentEl.removeChild(d.cursorEl); + if (d.breakEl?.parentNode) d.parentEl.removeChild(d.breakEl); +} + +function updateHCursor(d: DragContext, e: { x: number; y: number }) { + if (e.x <= d.box.left || e.x >= d.box.right || e.y <= d.box.top || e.y >= d.box.bottom) { + clearCursor(d); + d.data = null; + return; + } + const rtl = document.dir == 'rtl'; + let target: { el: Element | null; break: 'beforebegin' | 'afterend' | null } | null = null; + for (let i = 0, lastY = 0; i < d.choices.length && !target; i++) { + const r = d.choices[i].getBoundingClientRect(); + const x = r.right - r.width / 2; + const y = r.bottom + 4; // +4 because there's (currently) 8 device px between rows + const rowBreak = i > 0 && y != lastY; + if (rowBreak && e.y <= lastY) target = { el: d.choices[i], break: 'afterend' }; + else if (e.y <= y && (rtl ? e.x >= x : e.x <= x)) + target = { el: d.choices[i], break: rowBreak ? 'beforebegin' : null }; + lastY = y; + } + if (d.data && target && d.data.el == target.el && d.data.break == target.break) return; // nothing to do here + + d.data = target; // keep last target in context data so we only diddle the DOM when dirty + + if (!target) { + d.parentEl.insertBefore(d.cursorEl, null); + return; + } + d.parentEl.insertBefore(d.cursorEl, target.el); + if (target.break) { + // don't add break when inserting the cursor at the end of a line with no room + if (target.break != 'afterend' || d.cursorEl.getBoundingClientRect().top < e.y) + d.cursorEl.insertAdjacentElement(target.break, d.breakEl!); + } else if (d.breakEl!.parentNode) d.parentEl.removeChild(d.breakEl!); +} + +function updateVCursor(d: DragContext, e: { x: number; y: number }) { + if (e.x <= d.box.left || e.x >= d.box.right || e.y <= d.box.top || e.y >= d.box.bottom) { + clearCursor(d); + return; + } + let target: Element | null = null; + for (let i = 0; i < d.choices.length && !target; i++) { + const r = d.choices[i].getBoundingClientRect(); + if (e.y < r.top + r.height / 2) target = d.choices[i]; + } + d.parentEl.insertBefore(d.cursorEl, target); +} diff --git a/ui/bits/src/bits.cropDialog.ts b/ui/bits/src/bits.cropDialog.ts index a457c5ac65bb6..e7b3d5247886f 100644 --- a/ui/bits/src/bits.cropDialog.ts +++ b/ui/bits/src/bits.cropDialog.ts @@ -88,7 +88,7 @@ export async function initModule(o?: CropOpts): Promise { dlg.show(); async function crop() { - const view = dlg.view.querySelector('.crop-view') as HTMLElement; + const view = dlg.viewEl.querySelector('.crop-view') as HTMLElement; view.style.display = 'flex'; view.style.alignItems = 'center'; view.innerHTML = spinnerHtml; @@ -100,6 +100,7 @@ export async function initModule(o?: CropOpts): Promise { const tryQuality = (quality = 0.9) => { canvas.toBlob( blob => { + console.log(blob?.size, quality, opts.max?.pixels, opts.max?.megabytes); if (blob && blob.size < (opts.max?.megabytes ?? 100) * 1024 * 1024) submit(blob); else if (blob && quality > 0.05) tryQuality(quality * 0.9); else submit(false, 'Rendering failed'); diff --git a/ui/bits/src/bits.diagnosticDialog.ts b/ui/bits/src/bits.diagnosticDialog.ts index d460527d520f2..57fa86fc1490d 100644 --- a/ui/bits/src/bits.diagnosticDialog.ts +++ b/ui/bits/src/bits.diagnosticDialog.ts @@ -50,16 +50,16 @@ export async function initModule(opts?: DiagnosticOpts): Promise { const select = () => setTimeout(() => { const range = document.createRange(); - range.selectNodeContents(dlg.view.querySelector('.err')!); + range.selectNodeContents(dlg.viewEl.querySelector('.err')!); window.getSelection()?.removeAllRanges(); window.getSelection()?.addRange(range); }, 0); - $('.err', dlg.view).on('focus', select); - $('.clear', dlg.view).on('click', () => log.clear().then(() => dlg.close())); - $('.copy', dlg.view).on('click', () => + $('.err', dlg.viewEl).on('focus', select); + $('.clear', dlg.viewEl).on('click', () => log.clear().then(() => dlg.close())); + $('.copy', dlg.viewEl).on('click', () => navigator.clipboard.writeText(text).then(() => { const copied = $(`
COPIED
`); - $('.copy', dlg.view).before(copied); + $('.copy', dlg.viewEl).before(copied); setTimeout(() => copied.remove(), 2000); }), ); diff --git a/ui/bits/src/bits.forum.ts b/ui/bits/src/bits.forum.ts index e8e70f4336506..a27a8a8f26006 100644 --- a/ui/bits/src/bits.forum.ts +++ b/ui/bits/src/bits.forum.ts @@ -13,7 +13,7 @@ site.load.then(() => { attrs: { view: { action: link.href } }, modal: true, }).then(dlg => { - $(dlg.view) + $(dlg.viewEl) .find('form') .attr('action', link.href) .on('submit', function (this: HTMLFormElement, e: Event) { @@ -22,7 +22,7 @@ site.load.then(() => { $(link).closest('.forum-post').hide(); dlg.close(); }); - $(dlg.view).find('form button.cancel').on('click', dlg.close); + $(dlg.viewEl).find('form button.cancel').on('click', dlg.close); dlg.show(); }); return false; @@ -34,8 +34,8 @@ site.load.then(() => { attrs: { view: { action: link.href } }, modal: true, }).then(dlg => { - $(dlg.view).find('form').attr('action', link.href); - $(dlg.view).find('form button.cancel').on('click', dlg.close); + $(dlg.viewEl).find('form').attr('action', link.href); + $(dlg.viewEl).find('form button.cancel').on('click', dlg.close); dlg.show(); }); return false; diff --git a/ui/bits/src/bits.polyglot.ts b/ui/bits/src/bits.polyglot.ts index d37630b0fa99d..1879bd33ee496 100644 --- a/ui/bits/src/bits.polyglot.ts +++ b/ui/bits/src/bits.polyglot.ts @@ -1,6 +1,6 @@ import * as co from 'chessops'; +import { hashBoard, hashChess } from 'chess/hash'; import { deepFreeze } from 'common/algo'; -import { type NormalMove, type Chess, parseUci, makeUci } from 'chessops'; import { normalizeMove } from 'chessops/chess'; export type OpeningMove = { uci: string; weight: number }; @@ -19,7 +19,8 @@ export type PolyglotOpts = { cover?: boolean | { boardSize: number } } & ( export type PolyglotResult = { getMoves: OpeningBook; positions?: number; polyglot?: Blob; cover?: Blob }; -export async function initModule(o: PolyglotOpts): Promise { +export async function initModule(o: PolyglotOpts): Promise { + if (!o) return hashBoard; const book = 'bytes' in o ? makeBookPolyglot(o.bytes) @@ -46,7 +47,6 @@ async function makeBookPgn( if (filter && !filter(game)) continue; traverseTree(co.pgn.startingPosition(game.headers).unwrap(), game.moves, maxPly); } - // calc weights and collapse secondary maps into arrays for less memory overhead const posMap = new Map(); for (const [hash, map] of bigMap) { const moves = Array.from(map, ([uci, weight]) => ({ uci, weight })); @@ -74,7 +74,7 @@ async function makeBookPgn( function traverseTree(chess: co.Chess, node: co.pgn.Node, plyToGo: number) { if (plyToGo === 0) return; - const zobrist = hashBoard(chess); + const zobrist = hashChess(chess); const moves = bigMap!.get(zobrist) ?? new Map(); if (plyToGo > 1) for (const nextNode of node.children) { @@ -125,16 +125,18 @@ async function* pgnFromBlob(blob: Blob, chunkSize: number, progress?: PgnProgres } const chunk = blob.slice(offset, offset + chunkSize); const textChunk = await chunk.text(); - const gamesThisChunk = - offset + chunk.size === totalSize || textChunk.lastIndexOf('\n\n[') === -1 + const crlfLast = textChunk.lastIndexOf('\r\n\r\n['); + const lfLast = textChunk.lastIndexOf('\n\n['); + const wholePgnsChunk = + offset + chunk.size === totalSize || Math.max(crlfLast, lfLast) === -1 ? textChunk - : textChunk.slice(0, textChunk.lastIndexOf('\n\n[') + 2); - const games = co.pgn.parsePgn(residual + gamesThisChunk).filter(game => { + : textChunk.slice(0, Math.max(lfLast + 2, crlfLast + 4)); + const games = co.pgn.parsePgn(residual + wholePgnsChunk).filter(game => { const tag = game.headers.get('Variant'); return !tag || tag.toLowerCase() === 'standard'; }); for (const game of games) yield game; - residual = textChunk.slice(gamesThisChunk.length); + residual = textChunk.slice(wholePgnsChunk.length); offset += chunkSize; } } @@ -217,37 +219,13 @@ function makeImage(svg: string) { function getMoves(book: Map): OpeningBook { return (pos: co.Chess, normalized = true) => { - const moves = book.get(hashBoard(pos)) ?? []; + const moves = book.get(hashChess(pos)) ?? []; if (!normalized) return moves; const sum = moves.reduce((sum: number, m) => sum + m.weight, 0); return moves.map(m => ({ uci: m.uci, weight: m.weight / sum })); }; } -function hashBoard({ board, castles, epSquare, turn }: co.Chess) { - let hash = 0n; - for (const sq of board.occupied) { - const { color, role } = board.get(sq) as co.Piece; - hash ^= hashish[sq + 64 * rolodex[color][role]]; - } - const rights = castles.castlingRights; - if (rights.has(7)) hash ^= hashish[768]; - if (rights.has(0)) hash ^= hashish[769]; - if (rights.has(63)) hash ^= hashish[770]; - if (rights.has(56)) hash ^= hashish[771]; - if (turn === 'white') hash ^= hashish[780]; - if (epSquare) { - const mv = epSquare + (turn === 'white' ? -8 : 8); - if ( - ((mv & 0x7) > 0 && board.get(mv - 1)?.role === 'pawn' && board.get(mv - 1)?.color === turn) || - ((mv & 0x7) < 7 && board.get(mv + 1)?.role === 'pawn' && board.get(mv + 1)?.color === turn) - ) { - hash ^= hashish[772 + (epSquare & 0x7)]; - } - } - return hash; -} - function shortToUci(move: number) { return ( co.FILE_NAMES[(move >>> 6) & 0b111] + @@ -269,19 +247,14 @@ function uciToShort(uci: Uci): number { return (promotion << 12) | (from << 6) | to; } -function normalMove(chess: Chess, unsafeUci: Uci): { uci: Uci; move: NormalMove } | undefined { - const unsafe = parseUci(unsafeUci); +function normalMove(chess: co.Chess, unsafeUci: Uci): { uci: Uci; move: co.NormalMove } | undefined { + const unsafe = co.parseUci(unsafeUci); const move = unsafe && 'from' in unsafe ? { ...unsafe, ...normalizeMove(chess, unsafe) } : undefined; - return move && chess.isLegal(move) ? { uci: makeUci(move), move } : undefined; + return move && chess.isLegal(move) ? { uci: co.makeUci(move), move } : undefined; } const promotes = ['', 'n', 'b', 'r', 'q', '?', '?', '?']; -const rolodex = { - black: { pawn: 0, knight: 2, bishop: 4, rook: 6, queen: 8, king: 10 }, - white: { pawn: 1, knight: 3, bishop: 5, rook: 7, queen: 9, king: 11 }, -}; - const pieces: { [color in Color]: { [role in co.Role]: HTMLImageElement } } = { black: { bishop: makeImage( @@ -324,787 +297,3 @@ const pieces: { [color in Color]: { [role in co.Role]: HTMLImageElement } } = { ), }, }; - -const hashish = [ - 0x9d39247e33776d41n, - 0x2af7398005aaa5c7n, - 0x44db015024623547n, - 0x9c15f73e62a76ae2n, - 0x75834465489c0c89n, - 0x3290ac3a203001bfn, - 0x0fbbad1f61042279n, - 0xe83a908ff2fb60can, - 0x0d7e765d58755c10n, - 0x1a083822ceafe02dn, - 0x9605d5f0e25ec3b0n, - 0xd021ff5cd13a2ed5n, - 0x40bdf15d4a672e32n, - 0x011355146fd56395n, - 0x5db4832046f3d9e5n, - 0x239f8b2d7ff719ccn, - 0x05d1a1ae85b49aa1n, - 0x679f848f6e8fc971n, - 0x7449bbff801fed0bn, - 0x7d11cdb1c3b7adf0n, - 0x82c7709e781eb7ccn, - 0xf3218f1c9510786cn, - 0x331478f3af51bbe6n, - 0x4bb38de5e7219443n, - 0xaa649c6ebcfd50fcn, - 0x8dbd98a352afd40bn, - 0x87d2074b81d79217n, - 0x19f3c751d3e92ae1n, - 0xb4ab30f062b19abfn, - 0x7b0500ac42047ac4n, - 0xc9452ca81a09d85dn, - 0x24aa6c514da27500n, - 0x4c9f34427501b447n, - 0x14a68fd73c910841n, - 0xa71b9b83461cbd93n, - 0x03488b95b0f1850fn, - 0x637b2b34ff93c040n, - 0x09d1bc9a3dd90a94n, - 0x3575668334a1dd3bn, - 0x735e2b97a4c45a23n, - 0x18727070f1bd400bn, - 0x1fcbacd259bf02e7n, - 0xd310a7c2ce9b6555n, - 0xbf983fe0fe5d8244n, - 0x9f74d14f7454a824n, - 0x51ebdc4ab9ba3035n, - 0x5c82c505db9ab0fan, - 0xfcf7fe8a3430b241n, - 0x3253a729b9ba3dden, - 0x8c74c368081b3075n, - 0xb9bc6c87167c33e7n, - 0x7ef48f2b83024e20n, - 0x11d505d4c351bd7fn, - 0x6568fca92c76a243n, - 0x4de0b0f40f32a7b8n, - 0x96d693460cc37e5dn, - 0x42e240cb63689f2fn, - 0x6d2bdcdae2919661n, - 0x42880b0236e4d951n, - 0x5f0f4a5898171bb6n, - 0x39f890f579f92f88n, - 0x93c5b5f47356388bn, - 0x63dc359d8d231b78n, - 0xec16ca8aea98ad76n, - 0x5355f900c2a82dc7n, - 0x07fb9f855a997142n, - 0x5093417aa8a7ed5en, - 0x7bcbc38da25a7f3cn, - 0x19fc8a768cf4b6d4n, - 0x637a7780decfc0d9n, - 0x8249a47aee0e41f7n, - 0x79ad695501e7d1e8n, - 0x14acbaf4777d5776n, - 0xf145b6beccdea195n, - 0xdabf2ac8201752fcn, - 0x24c3c94df9c8d3f6n, - 0xbb6e2924f03912ean, - 0x0ce26c0b95c980d9n, - 0xa49cd132bfbf7cc4n, - 0xe99d662af4243939n, - 0x27e6ad7891165c3fn, - 0x8535f040b9744ff1n, - 0x54b3f4fa5f40d873n, - 0x72b12c32127fed2bn, - 0xee954d3c7b411f47n, - 0x9a85ac909a24eaa1n, - 0x70ac4cd9f04f21f5n, - 0xf9b89d3e99a075c2n, - 0x87b3e2b2b5c907b1n, - 0xa366e5b8c54f48b8n, - 0xae4a9346cc3f7cf2n, - 0x1920c04d47267bbdn, - 0x87bf02c6b49e2ae9n, - 0x092237ac237f3859n, - 0xff07f64ef8ed14d0n, - 0x8de8dca9f03cc54en, - 0x9c1633264db49c89n, - 0xb3f22c3d0b0b38edn, - 0x390e5fb44d01144bn, - 0x5bfea5b4712768e9n, - 0x1e1032911fa78984n, - 0x9a74acb964e78cb3n, - 0x4f80f7a035dafb04n, - 0x6304d09a0b3738c4n, - 0x2171e64683023a08n, - 0x5b9b63eb9ceff80cn, - 0x506aacf489889342n, - 0x1881afc9a3a701d6n, - 0x6503080440750644n, - 0xdfd395339cdbf4a7n, - 0xef927dbcf00c20f2n, - 0x7b32f7d1e03680ecn, - 0xb9fd7620e7316243n, - 0x05a7e8a57db91b77n, - 0xb5889c6e15630a75n, - 0x4a750a09ce9573f7n, - 0xcf464cec899a2f8an, - 0xf538639ce705b824n, - 0x3c79a0ff5580ef7fn, - 0xede6c87f8477609dn, - 0x799e81f05bc93f31n, - 0x86536b8cf3428a8cn, - 0x97d7374c60087b73n, - 0xa246637cff328532n, - 0x043fcae60cc0eba0n, - 0x920e449535dd359en, - 0x70eb093b15b290ccn, - 0x73a1921916591cbdn, - 0x56436c9fe1a1aa8dn, - 0xefac4b70633b8f81n, - 0xbb215798d45df7afn, - 0x45f20042f24f1768n, - 0x930f80f4e8eb7462n, - 0xff6712ffcfd75ea1n, - 0xae623fd67468aa70n, - 0xdd2c5bc84bc8d8fcn, - 0x7eed120d54cf2dd9n, - 0x22fe545401165f1cn, - 0xc91800e98fb99929n, - 0x808bd68e6ac10365n, - 0xdec468145b7605f6n, - 0x1bede3a3aef53302n, - 0x43539603d6c55602n, - 0xaa969b5c691ccb7an, - 0xa87832d392efee56n, - 0x65942c7b3c7e11aen, - 0xded2d633cad004f6n, - 0x21f08570f420e565n, - 0xb415938d7da94e3cn, - 0x91b859e59ecb6350n, - 0x10cff333e0ed804an, - 0x28aed140be0bb7ddn, - 0xc5cc1d89724fa456n, - 0x5648f680f11a2741n, - 0x2d255069f0b7dab3n, - 0x9bc5a38ef729abd4n, - 0xef2f054308f6a2bcn, - 0xaf2042f5cc5c2858n, - 0x480412bab7f5be2an, - 0xaef3af4a563dfe43n, - 0x19afe59ae451497fn, - 0x52593803dff1e840n, - 0xf4f076e65f2ce6f0n, - 0x11379625747d5af3n, - 0xbce5d2248682c115n, - 0x9da4243de836994fn, - 0x066f70b33fe09017n, - 0x4dc4de189b671a1cn, - 0x51039ab7712457c3n, - 0xc07a3f80c31fb4b4n, - 0xb46ee9c5e64a6e7cn, - 0xb3819a42abe61c87n, - 0x21a007933a522a20n, - 0x2df16f761598aa4fn, - 0x763c4a1371b368fdn, - 0xf793c46702e086a0n, - 0xd7288e012aeb8d31n, - 0xde336a2a4bc1c44bn, - 0x0bf692b38d079f23n, - 0x2c604a7a177326b3n, - 0x4850e73e03eb6064n, - 0xcfc447f1e53c8e1bn, - 0xb05ca3f564268d99n, - 0x9ae182c8bc9474e8n, - 0xa4fc4bd4fc5558can, - 0xe755178d58fc4e76n, - 0x69b97db1a4c03dfen, - 0xf9b5b7c4acc67c96n, - 0xfc6a82d64b8655fbn, - 0x9c684cb6c4d24417n, - 0x8ec97d2917456ed0n, - 0x6703df9d2924e97en, - 0xc547f57e42a7444en, - 0x78e37644e7cad29en, - 0xfe9a44e9362f05fan, - 0x08bd35cc38336615n, - 0x9315e5eb3a129acen, - 0x94061b871e04df75n, - 0xdf1d9f9d784ba010n, - 0x3bba57b68871b59dn, - 0xd2b7adeeded1f73fn, - 0xf7a255d83bc373f8n, - 0xd7f4f2448c0ceb81n, - 0xd95be88cd210ffa7n, - 0x336f52f8ff4728e7n, - 0xa74049dac312ac71n, - 0xa2f61bb6e437fdb5n, - 0x4f2a5cb07f6a35b3n, - 0x87d380bda5bf7859n, - 0x16b9f7e06c453a21n, - 0x7ba2484c8a0fd54en, - 0xf3a678cad9a2e38cn, - 0x39b0bf7dde437ba2n, - 0xfcaf55c1bf8a4424n, - 0x18fcf680573fa594n, - 0x4c0563b89f495ac3n, - 0x40e087931a00930dn, - 0x8cffa9412eb642c1n, - 0x68ca39053261169fn, - 0x7a1ee967d27579e2n, - 0x9d1d60e5076f5b6fn, - 0x3810e399b6f65ba2n, - 0x32095b6d4ab5f9b1n, - 0x35cab62109dd038an, - 0xa90b24499fcfafb1n, - 0x77a225a07cc2c6bdn, - 0x513e5e634c70e331n, - 0x4361c0ca3f692f12n, - 0xd941aca44b20a45bn, - 0x528f7c8602c5807bn, - 0x52ab92beb9613989n, - 0x9d1dfa2efc557f73n, - 0x722ff175f572c348n, - 0x1d1260a51107fe97n, - 0x7a249a57ec0c9ba2n, - 0x04208fe9e8f7f2d6n, - 0x5a110c6058b920a0n, - 0x0cd9a497658a5698n, - 0x56fd23c8f9715a4cn, - 0x284c847b9d887aaen, - 0x04feabfbbdb619cbn, - 0x742e1e651c60ba83n, - 0x9a9632e65904ad3cn, - 0x881b82a13b51b9e2n, - 0x506e6744cd974924n, - 0xb0183db56ffc6a79n, - 0x0ed9b915c66ed37en, - 0x5e11e86d5873d484n, - 0xf678647e3519ac6en, - 0x1b85d488d0f20cc5n, - 0xdab9fe6525d89021n, - 0x0d151d86adb73615n, - 0xa865a54edcc0f019n, - 0x93c42566aef98ffbn, - 0x99e7afeabe000731n, - 0x48cbff086ddf285an, - 0x7f9b6af1ebf78bafn, - 0x58627e1a149bba21n, - 0x2cd16e2abd791e33n, - 0xd363eff5f0977996n, - 0x0ce2a38c344a6eedn, - 0x1a804aadb9cfa741n, - 0x907f30421d78c5den, - 0x501f65edb3034d07n, - 0x37624ae5a48fa6e9n, - 0x957baf61700cff4en, - 0x3a6c27934e31188an, - 0xd49503536abca345n, - 0x088e049589c432e0n, - 0xf943aee7febf21b8n, - 0x6c3b8e3e336139d3n, - 0x364f6ffa464ee52en, - 0xd60f6dcedc314222n, - 0x56963b0dca418fc0n, - 0x16f50edf91e513afn, - 0xef1955914b609f93n, - 0x565601c0364e3228n, - 0xecb53939887e8175n, - 0xbac7a9a18531294bn, - 0xb344c470397bba52n, - 0x65d34954daf3cebdn, - 0xb4b81b3fa97511e2n, - 0xb422061193d6f6a7n, - 0x071582401c38434dn, - 0x7a13f18bbedc4ff5n, - 0xbc4097b116c524d2n, - 0x59b97885e2f2ea28n, - 0x99170a5dc3115544n, - 0x6f423357e7c6a9f9n, - 0x325928ee6e6f8794n, - 0xd0e4366228b03343n, - 0x565c31f7de89ea27n, - 0x30f5611484119414n, - 0xd873db391292ed4fn, - 0x7bd94e1d8e17debcn, - 0xc7d9f16864a76e94n, - 0x947ae053ee56e63cn, - 0xc8c93882f9475f5fn, - 0x3a9bf55ba91f81can, - 0xd9a11fbb3d9808e4n, - 0x0fd22063edc29fcan, - 0xb3f256d8aca0b0b9n, - 0xb03031a8b4516e84n, - 0x35dd37d5871448afn, - 0xe9f6082b05542e4en, - 0xebfafa33d7254b59n, - 0x9255abb50d532280n, - 0xb9ab4ce57f2d34f3n, - 0x693501d628297551n, - 0xc62c58f97dd949bfn, - 0xcd454f8f19c5126an, - 0xbbe83f4ecc2bdecbn, - 0xdc842b7e2819e230n, - 0xba89142e007503b8n, - 0xa3bc941d0a5061cbn, - 0xe9f6760e32cd8021n, - 0x09c7e552bc76492fn, - 0x852f54934da55cc9n, - 0x8107fccf064fcf56n, - 0x098954d51fff6580n, - 0x23b70edb1955c4bfn, - 0xc330de426430f69dn, - 0x4715ed43e8a45c0an, - 0xa8d7e4dab780a08dn, - 0x0572b974f03ce0bbn, - 0xb57d2e985e1419c7n, - 0xe8d9ecbe2cf3d73fn, - 0x2fe4b17170e59750n, - 0x11317ba87905e790n, - 0x7fbf21ec8a1f45ecn, - 0x1725cabfcb045b00n, - 0x964e915cd5e2b207n, - 0x3e2b8bcbf016d66dn, - 0xbe7444e39328a0acn, - 0xf85b2b4fbcde44b7n, - 0x49353fea39ba63b1n, - 0x1dd01aafcd53486an, - 0x1fca8a92fd719f85n, - 0xfc7c95d827357afan, - 0x18a6a990c8b35ebdn, - 0xcccb7005c6b9c28dn, - 0x3bdbb92c43b17f26n, - 0xaa70b5b4f89695a2n, - 0xe94c39a54a98307fn, - 0xb7a0b174cff6f36en, - 0xd4dba84729af48adn, - 0x2e18bc1ad9704a68n, - 0x2de0966daf2f8b1cn, - 0xb9c11d5b1e43a07en, - 0x64972d68dee33360n, - 0x94628d38d0c20584n, - 0xdbc0d2b6ab90a559n, - 0xd2733c4335c6a72fn, - 0x7e75d99d94a70f4dn, - 0x6ced1983376fa72bn, - 0x97fcaacbf030bc24n, - 0x7b77497b32503b12n, - 0x8547eddfb81ccb94n, - 0x79999cdff70902cbn, - 0xcffe1939438e9b24n, - 0x829626e3892d95d7n, - 0x92fae24291f2b3f1n, - 0x63e22c147b9c3403n, - 0xc678b6d860284a1cn, - 0x5873888850659ae7n, - 0x0981dcd296a8736dn, - 0x9f65789a6509a440n, - 0x9ff38fed72e9052fn, - 0xe479ee5b9930578cn, - 0xe7f28ecd2d49eecdn, - 0x56c074a581ea17fen, - 0x5544f7d774b14aefn, - 0x7b3f0195fc6f290fn, - 0x12153635b2c0cf57n, - 0x7f5126dbba5e0ca7n, - 0x7a76956c3eafb413n, - 0x3d5774a11d31ab39n, - 0x8a1b083821f40cb4n, - 0x7b4a38e32537df62n, - 0x950113646d1d6e03n, - 0x4da8979a0041e8a9n, - 0x3bc36e078f7515d7n, - 0x5d0a12f27ad310d1n, - 0x7f9d1a2e1ebe1327n, - 0xda3a361b1c5157b1n, - 0xdcdd7d20903d0c25n, - 0x36833336d068f707n, - 0xce68341f79893389n, - 0xab9090168dd05f34n, - 0x43954b3252dc25e5n, - 0xb438c2b67f98e5e9n, - 0x10dcd78e3851a492n, - 0xdbc27ab5447822bfn, - 0x9b3cdb65f82ca382n, - 0xb67b7896167b4c84n, - 0xbfced1b0048eac50n, - 0xa9119b60369ffebdn, - 0x1fff7ac80904bf45n, - 0xac12fb171817eee7n, - 0xaf08da9177dda93dn, - 0x1b0cab936e65c744n, - 0xb559eb1d04e5e932n, - 0xc37b45b3f8d6f2ban, - 0xc3a9dc228caac9e9n, - 0xf3b8b6675a6507ffn, - 0x9fc477de4ed681dan, - 0x67378d8eccef96cbn, - 0x6dd856d94d259236n, - 0xa319ce15b0b4db31n, - 0x073973751f12dd5en, - 0x8a8e849eb32781a5n, - 0xe1925c71285279f5n, - 0x74c04bf1790c0efen, - 0x4dda48153c94938an, - 0x9d266d6a1cc0542cn, - 0x7440fb816508c4fen, - 0x13328503df48229fn, - 0xd6bf7baee43cac40n, - 0x4838d65f6ef6748fn, - 0x1e152328f3318dean, - 0x8f8419a348f296bfn, - 0x72c8834a5957b511n, - 0xd7a023a73260b45cn, - 0x94ebc8abcfb56daen, - 0x9fc10d0f989993e0n, - 0xde68a2355b93cae6n, - 0xa44cfe79ae538bben, - 0x9d1d84fcce371425n, - 0x51d2b1ab2ddfb636n, - 0x2fd7e4b9e72cd38cn, - 0x65ca5b96b7552210n, - 0xdd69a0d8ab3b546dn, - 0x604d51b25fbf70e2n, - 0x73aa8a564fb7ac9en, - 0x1a8c1e992b941148n, - 0xaac40a2703d9bea0n, - 0x764dbeae7fa4f3a6n, - 0x1e99b96e70a9be8bn, - 0x2c5e9deb57ef4743n, - 0x3a938fee32d29981n, - 0x26e6db8ffdf5adfen, - 0x469356c504ec9f9dn, - 0xc8763c5b08d1908cn, - 0x3f6c6af859d80055n, - 0x7f7cc39420a3a545n, - 0x9bfb227ebdf4c5cen, - 0x89039d79d6fc5c5cn, - 0x8fe88b57305e2ab6n, - 0xa09e8c8c35ab96den, - 0xfa7e393983325753n, - 0xd6b6d0ecc617c699n, - 0xdfea21ea9e7557e3n, - 0xb67c1fa481680af8n, - 0xca1e3785a9e724e5n, - 0x1cfc8bed0d681639n, - 0xd18d8549d140caean, - 0x4ed0fe7e9dc91335n, - 0xe4dbf0634473f5d2n, - 0x1761f93a44d5aefen, - 0x53898e4c3910da55n, - 0x734de8181f6ec39an, - 0x2680b122baa28d97n, - 0x298af231c85bafabn, - 0x7983eed3740847d5n, - 0x66c1a2a1a60cd889n, - 0x9e17e49642a3e4c1n, - 0xedb454e7badc0805n, - 0x50b704cab602c329n, - 0x4cc317fb9cddd023n, - 0x66b4835d9eafea22n, - 0x219b97e26ffc81bdn, - 0x261e4e4c0a333a9dn, - 0x1fe2cca76517db90n, - 0xd7504dfa8816edbbn, - 0xb9571fa04dc089c8n, - 0x1ddc0325259b27den, - 0xcf3f4688801eb9aan, - 0xf4f5d05c10cab243n, - 0x38b6525c21a42b0en, - 0x36f60e2ba4fa6800n, - 0xeb3593803173e0cen, - 0x9c4cd6257c5a3603n, - 0xaf0c317d32adaa8an, - 0x258e5a80c7204c4bn, - 0x8b889d624d44885dn, - 0xf4d14597e660f855n, - 0xd4347f66ec8941c3n, - 0xe699ed85b0dfb40dn, - 0x2472f6207c2d0484n, - 0xc2a1e7b5b459aeb5n, - 0xab4f6451cc1d45ecn, - 0x63767572ae3d6174n, - 0xa59e0bd101731a28n, - 0x116d0016cb948f09n, - 0x2cf9c8ca052f6e9fn, - 0x0b090a7560a968e3n, - 0xabeeddb2dde06ff1n, - 0x58efc10b06a2068dn, - 0xc6e57a78fbd986e0n, - 0x2eab8ca63ce802d7n, - 0x14a195640116f336n, - 0x7c0828dd624ec390n, - 0xd74bbe77e6116ac7n, - 0x804456af10f5fb53n, - 0xebe9ea2adf4321c7n, - 0x03219a39ee587a30n, - 0x49787fef17af9924n, - 0xa1e9300cd8520548n, - 0x5b45e522e4b1b4efn, - 0xb49c3b3995091a36n, - 0xd4490ad526f14431n, - 0x12a8f216af9418c2n, - 0x001f837cc7350524n, - 0x1877b51e57a764d5n, - 0xa2853b80f17f58een, - 0x993e1de72d36d310n, - 0xb3598080ce64a656n, - 0x252f59cf0d9f04bbn, - 0xd23c8e176d113600n, - 0x1bda0492e7e4586en, - 0x21e0bd5026c619bfn, - 0x3b097adaf088f94en, - 0x8d14dedb30be846en, - 0xf95cffa23af5f6f4n, - 0x3871700761b3f743n, - 0xca672b91e9e4fa16n, - 0x64c8e531bff53b55n, - 0x241260ed4ad1e87dn, - 0x106c09b972d2e822n, - 0x7fba195410e5ca30n, - 0x7884d9bc6cb569d8n, - 0x0647dfedcd894a29n, - 0x63573ff03e224774n, - 0x4fc8e9560f91b123n, - 0x1db956e450275779n, - 0xb8d91274b9e9d4fbn, - 0xa2ebee47e2fbfce1n, - 0xd9f1f30ccd97fb09n, - 0xefed53d75fd64e6bn, - 0x2e6d02c36017f67fn, - 0xa9aa4d20db084e9bn, - 0xb64be8d8b25396c1n, - 0x70cb6af7c2d5bcf0n, - 0x98f076a4f7a2322en, - 0xbf84470805e69b5fn, - 0x94c3251f06f90cf3n, - 0x3e003e616a6591e9n, - 0xb925a6cd0421aff3n, - 0x61bdd1307c66e300n, - 0xbf8d5108e27e0d48n, - 0x240ab57a8b888b20n, - 0xfc87614baf287e07n, - 0xef02cdd06ffdb432n, - 0xa1082c0466df6c0an, - 0x8215e577001332c8n, - 0xd39bb9c3a48db6cfn, - 0x2738259634305c14n, - 0x61cf4f94c97df93dn, - 0x1b6baca2ae4e125bn, - 0x758f450c88572e0bn, - 0x959f587d507a8359n, - 0xb063e962e045f54dn, - 0x60e8ed72c0dff5d1n, - 0x7b64978555326f9fn, - 0xfd080d236da814ban, - 0x8c90fd9b083f4558n, - 0x106f72fe81e2c590n, - 0x7976033a39f7d952n, - 0xa4ec0132764ca04bn, - 0x733ea705fae4fa77n, - 0xb4d8f77bc3e56167n, - 0x9e21f4f903b33fd9n, - 0x9d765e419fb69f6dn, - 0xd30c088ba61ea5efn, - 0x5d94337fbfaf7f5bn, - 0x1a4e4822eb4d7a59n, - 0x6ffe73e81b637fb3n, - 0xddf957bc36d8b9can, - 0x64d0e29eea8838b3n, - 0x08dd9bdfd96b9f63n, - 0x087e79e5a57d1d13n, - 0xe328e230e3e2b3fbn, - 0x1c2559e30f0946ben, - 0x720bf5f26f4d2eaan, - 0xb0774d261cc609dbn, - 0x443f64ec5a371195n, - 0x4112cf68649a260en, - 0xd813f2fab7f5c5can, - 0x660d3257380841een, - 0x59ac2c7873f910a3n, - 0xe846963877671a17n, - 0x93b633abfa3469f8n, - 0xc0c0f5a60ef4cdcfn, - 0xcaf21ecd4377b28cn, - 0x57277707199b8175n, - 0x506c11b9d90e8b1dn, - 0xd83cc2687a19255fn, - 0x4a29c6465a314cd1n, - 0xed2df21216235097n, - 0xb5635c95ff7296e2n, - 0x22af003ab672e811n, - 0x52e762596bf68235n, - 0x9aeba33ac6ecc6b0n, - 0x944f6de09134dfb6n, - 0x6c47bec883a7de39n, - 0x6ad047c430a12104n, - 0xa5b1cfdba0ab4067n, - 0x7c45d833aff07862n, - 0x5092ef950a16da0bn, - 0x9338e69c052b8e7bn, - 0x455a4b4cfe30e3f5n, - 0x6b02e63195ad0cf8n, - 0x6b17b224bad6bf27n, - 0xd1e0ccd25bb9c169n, - 0xde0c89a556b9ae70n, - 0x50065e535a213cf6n, - 0x9c1169fa2777b874n, - 0x78edefd694af1eedn, - 0x6dc93d9526a50e68n, - 0xee97f453f06791edn, - 0x32ab0edb696703d3n, - 0x3a6853c7e70757a7n, - 0x31865ced6120f37dn, - 0x67fef95d92607890n, - 0x1f2b1d1f15f6dc9cn, - 0xb69e38a8965c6b65n, - 0xaa9119ff184cccf4n, - 0xf43c732873f24c13n, - 0xfb4a3d794a9a80d2n, - 0x3550c2321fd6109cn, - 0x371f77e76bb8417en, - 0x6bfa9aae5ec05779n, - 0xcd04f3ff001a4778n, - 0xe3273522064480can, - 0x9f91508bffcfc14an, - 0x049a7f41061a9e60n, - 0xfcb6be43a9f2fe9bn, - 0x08de8a1c7797da9bn, - 0x8f9887e6078735a1n, - 0xb5b4071dbfc73a66n, - 0x230e343dfba08d33n, - 0x43ed7f5a0fae657dn, - 0x3a88a0fbbcb05c63n, - 0x21874b8b4d2dbc4fn, - 0x1bdea12e35f6a8c9n, - 0x53c065c6c8e63528n, - 0xe34a1d250e7a8d6bn, - 0xd6b04d3b7651dd7en, - 0x5e90277e7cb39e2dn, - 0x2c046f22062dc67dn, - 0xb10bb459132d0a26n, - 0x3fa9ddfb67e2f199n, - 0x0e09b88e1914f7afn, - 0x10e8b35af3eeab37n, - 0x9eedeca8e272b933n, - 0xd4c718bc4ae8ae5fn, - 0x81536d601170fc20n, - 0x91b534f885818a06n, - 0xec8177f83f900978n, - 0x190e714fada5156en, - 0xb592bf39b0364963n, - 0x89c350c893ae7dc1n, - 0xac042e70f8b383f2n, - 0xb49b52e587a1ee60n, - 0xfb152fe3ff26da89n, - 0x3e666e6f69ae2c15n, - 0x3b544ebe544c19f9n, - 0xe805a1e290cf2456n, - 0x24b33c9d7ed25117n, - 0xe74733427b72f0c1n, - 0x0a804d18b7097475n, - 0x57e3306d881edb4fn, - 0x4ae7d6a36eb5dbcbn, - 0x2d8d5432157064c8n, - 0xd1e649de1e7f268bn, - 0x8a328a1cedfe552cn, - 0x07a3aec79624c7dan, - 0x84547ddc3e203c94n, - 0x990a98fd5071d263n, - 0x1a4ff12616eefc89n, - 0xf6f7fd1431714200n, - 0x30c05b1ba332f41cn, - 0x8d2636b81555a786n, - 0x46c9feb55d120902n, - 0xccec0a73b49c9921n, - 0x4e9d2827355fc492n, - 0x19ebb029435dcb0fn, - 0x4659d2b743848a2cn, - 0x963ef2c96b33be31n, - 0x74f85198b05a2e7dn, - 0x5a0f544dd2b1fb18n, - 0x03727073c2e134b1n, - 0xc7f6aa2de59aea61n, - 0x352787baa0d7c22fn, - 0x9853eab63b5e0b35n, - 0xabbdcdd7ed5c0860n, - 0xcf05daf5ac8d77b0n, - 0x49cad48cebf4a71en, - 0x7a4c10ec2158c4a6n, - 0xd9e92aa246bf719en, - 0x13ae978d09fe5557n, - 0x730499af921549ffn, - 0x4e4b705b92903ba4n, - 0xff577222c14f0a3an, - 0x55b6344cf97aafaen, - 0xb862225b055b6960n, - 0xcac09afbddd2cdb4n, - 0xdaf8e9829fe96b5fn, - 0xb5fdfc5d3132c498n, - 0x310cb380db6f7503n, - 0xe87fbb46217a360en, - 0x2102ae466ebb1148n, - 0xf8549e1a3aa5e00dn, - 0x07a69afdcc42261an, - 0xc4c118bfe78feaaen, - 0xf9f4892ed96bd438n, - 0x1af3dbe25d8f45dan, - 0xf5b4b0b0d2deeeb4n, - 0x962aceefa82e1c84n, - 0x046e3ecaaf453ce9n, - 0xf05d129681949a4cn, - 0x964781ce734b3c84n, - 0x9c2ed44081ce5fbdn, - 0x522e23f3925e319en, - 0x177e00f9fc32f791n, - 0x2bc60a63a6f3b3f2n, - 0x222bbfae61725606n, - 0x486289ddcc3d6780n, - 0x7dc7785b8efdfc80n, - 0x8af38731c02ba980n, - 0x1fab64ea29a2ddf7n, - 0xe4d9429322cd065an, - 0x9da058c67844f20cn, - 0x24c0e332b70019b0n, - 0x233003b5a6cfe6adn, - 0xd586bd01c5c217f6n, - 0x5e5637885f29bc2bn, - 0x7eba726d8c94094bn, - 0x0a56a5f0bfe39272n, - 0xd79476a84ee20d06n, - 0x9e4c1269baa4bf37n, - 0x17efee45b0dee640n, - 0x1d95b0a5fcf90bc6n, - 0x93cbe0b699c2585dn, - 0x65fa4f227a2b6d79n, - 0xd5f9e858292504d5n, - 0xc2b5a03f71471a6fn, - 0x59300222b4561e00n, - 0xce2f8642ca0712dcn, - 0x7ca9723fbb2e8988n, - 0x2785338347f2ba08n, - 0xc61bb3a141e50e8cn, - 0x150f361dab9dec26n, - 0x9f6a419d382595f4n, - 0x64a53dc924fe7ac9n, - 0x142de49fff7a7c3dn, - 0x0c335248857fa9e7n, - 0x0a9c32d5eae45305n, - 0xe6c42178c4bbb92en, - 0x71f1ce2490d20b07n, - 0xf1bcc3d275afe51an, - 0xe728e8c83c334074n, - 0x96fbf83a12884624n, - 0x81a1549fd6573da5n, - 0x5fa7867caf35e149n, - 0x56986e2ef3ed091bn, - 0x917f1dd5f8886c61n, - 0xd20d8c88c8ffe65fn, - 0x31d71dce64b2c310n, - 0xf165b587df898190n, - 0xa57e6339dd2cf3a0n, - 0x1ef6e6dbb1961ec9n, - 0x70cc73d90bc26e24n, - 0xe21a6b35df0c3ad7n, - 0x003a93d8b2806962n, - 0x1c99ded33cb890a1n, - 0xcf3145de0add4289n, - 0xd0e4427a5514fb72n, - 0x77c621cc9fb3a483n, - 0x67a34dac4356550bn, - 0xf8d626aaaf278509n, -]; diff --git a/ui/bits/src/bits.publicChats.ts b/ui/bits/src/bits.publicChats.ts index 913094f026ee9..fed46922ee878 100644 --- a/ui/bits/src/bits.publicChats.ts +++ b/ui/bits/src/bits.publicChats.ts @@ -41,9 +41,9 @@ site.load.then(() => { $('#communication').on('click', '.line:not(.lichess)', function (this: HTMLDivElement) { const $l = $(this); domDialog({ cash: $('.timeout-modal'), modal: true }).then(dlg => { - $('.username', dlg.view).text($l.find('.user-link').text()); - $('.text', dlg.view).text($l.text().split(' ').slice(1).join(' ')); - $('.button', dlg.view).on('click', function (this: HTMLButtonElement) { + $('.username', dlg.viewEl).text($l.find('.user-link').text()); + $('.text', dlg.viewEl).text($l.text().split(' ').slice(1).join(' ')); + $('.button', dlg.viewEl).on('click', function (this: HTMLButtonElement) { const roomId = $l.parents('.game').data('room'); const chan = $l.parents('.game').data('chan'); text('/mod/public-chat/timeout', { @@ -51,9 +51,9 @@ site.load.then(() => { body: form({ roomId, chan, - userId: $('.username', dlg.view).text().toLowerCase(), + userId: $('.username', dlg.viewEl).text().toLowerCase(), reason: this.value, - text: $('.text', dlg.view).text(), + text: $('.text', dlg.viewEl).text(), }), }).then(_ => setTimeout(reloadNow, 1000)); dlg.close(); diff --git a/ui/bits/src/polyglot.ts b/ui/bits/src/polyglot.ts index 6557768d7518e..98927cb580577 100644 --- a/ui/bits/src/polyglot.ts +++ b/ui/bits/src/polyglot.ts @@ -13,7 +13,7 @@ export async function makeBookFromPolyglot(init: { bytes: DataView; cover?: boolean | { boardSize: number }; }): Promise { - return await site.asset.loadEsm('bits.polyglot', { init }); + return site.asset.loadEsm('bits.polyglot', { init }); } export async function makeBookFromPgn(init: { @@ -23,7 +23,7 @@ export async function makeBookFromPgn(init: { progress?: PgnProgress; filter?: PgnFilter; }): Promise { - return await site.asset.loadEsm('bits.polyglot', { init }); + return site.asset.loadEsm('bits.polyglot', { init }); } export async function makeCover(init: { diff --git a/ui/ceval/src/util.ts b/ui/ceval/src/util.ts index fd9e0b2d034bb..8cf06b9be92d0 100644 --- a/ui/ceval/src/util.ts +++ b/ui/ceval/src/util.ts @@ -59,11 +59,11 @@ export function showEngineError(engine: string, error: string): void { const select = () => setTimeout(() => { const range = document.createRange(); - range.selectNodeContents(dlg.view.querySelector('.err')!); + range.selectNodeContents(dlg.viewEl.querySelector('.err')!); window.getSelection()?.removeAllRanges(); window.getSelection()?.addRange(range); }, 0); - dlg.view.querySelector('.err')?.addEventListener('focus', select); + dlg.viewEl.querySelector('.err')?.addEventListener('focus', select); dlg.show(); }); } diff --git a/ui/chess/src/chess.ts b/ui/chess/src/chess.ts index aaa67e6b38e17..a74b115d563c6 100644 --- a/ui/chess/src/chess.ts +++ b/ui/chess/src/chess.ts @@ -1,4 +1,5 @@ import { uciChar } from './uciChar'; +import { shuffle } from 'common/algo'; export * from './sanWriter'; @@ -30,3 +31,18 @@ export const fenToEpd = (fen: FEN): string => fen.split(' ').slice(0, 4).join(' export const plyToTurn = (ply: number): number => Math.floor((ply - 1) / 2) + 1; export const pieceCount = (fen: FEN): number => fen.split(/\s/)[0].split(/[nbrqkp]/i).length - 1; + +export function fen960(): string { + // a bit slower but more compact than array[960] + const bishops = [2 * Math.floor(Math.random() * 4), 1 + 2 * Math.floor(Math.random() * 4)]; + const files = shuffle([0, 1, 2, 3, 4, 5, 6, 7]).filter(f => !bishops.includes(f)); + const [leftRook, king, rightRook] = files.slice(0, 3).sort(); + const [queen, knight1, knight2] = files.slice(3); + const board = Array(8); + board[bishops[0]] = board[bishops[1]] = 'b'; + board[leftRook] = board[rightRook] = 'r'; + board[king] = 'k'; + board[queen] = 'q'; + board[knight1] = board[knight2] = 'n'; + return `${board.join('')}/pppppppp/8/8/8/8/PPPPPPPP/${board.join('').toUpperCase()}`; +} diff --git a/ui/chess/src/hash.ts b/ui/chess/src/hash.ts new file mode 100644 index 0000000000000..04d83430c2829 --- /dev/null +++ b/ui/chess/src/hash.ts @@ -0,0 +1,819 @@ +import type { Board, Chess, Piece } from 'chessops'; + +export function hashBoard(board: Board): bigint { + let hash = 0n; + for (const sq of board.occupied) { + const { color, role } = board.get(sq) as Piece; + hash ^= hashes[sq + 64 * rolodex[color][role]]; + } + return hash; +} + +export function hashChess({ board, castles, epSquare, turn }: Chess): bigint { + let hash = hashBoard(board); + const rights = castles.castlingRights; + if (rights.has(7)) hash ^= hashes[768]; + if (rights.has(0)) hash ^= hashes[769]; + if (rights.has(63)) hash ^= hashes[770]; + if (rights.has(56)) hash ^= hashes[771]; + if (turn === 'white') hash ^= hashes[780]; + if (epSquare) { + const mv = epSquare + (turn === 'white' ? -8 : 8); + if ( + ((mv & 0x7) > 0 && board.get(mv - 1)?.role === 'pawn' && board.get(mv - 1)?.color === turn) || + ((mv & 0x7) < 7 && board.get(mv + 1)?.role === 'pawn' && board.get(mv + 1)?.color === turn) + ) { + hash ^= hashes[772 + (epSquare & 0x7)]; + } + } + return hash; +} + +const rolodex = { + black: { pawn: 0, knight: 2, bishop: 4, rook: 6, queen: 8, king: 10 }, + white: { pawn: 1, knight: 3, bishop: 5, rook: 7, queen: 9, king: 11 }, +}; + +const hashes = [ + 0x9d39247e33776d41n, + 0x2af7398005aaa5c7n, + 0x44db015024623547n, + 0x9c15f73e62a76ae2n, + 0x75834465489c0c89n, + 0x3290ac3a203001bfn, + 0x0fbbad1f61042279n, + 0xe83a908ff2fb60can, + 0x0d7e765d58755c10n, + 0x1a083822ceafe02dn, + 0x9605d5f0e25ec3b0n, + 0xd021ff5cd13a2ed5n, + 0x40bdf15d4a672e32n, + 0x011355146fd56395n, + 0x5db4832046f3d9e5n, + 0x239f8b2d7ff719ccn, + 0x05d1a1ae85b49aa1n, + 0x679f848f6e8fc971n, + 0x7449bbff801fed0bn, + 0x7d11cdb1c3b7adf0n, + 0x82c7709e781eb7ccn, + 0xf3218f1c9510786cn, + 0x331478f3af51bbe6n, + 0x4bb38de5e7219443n, + 0xaa649c6ebcfd50fcn, + 0x8dbd98a352afd40bn, + 0x87d2074b81d79217n, + 0x19f3c751d3e92ae1n, + 0xb4ab30f062b19abfn, + 0x7b0500ac42047ac4n, + 0xc9452ca81a09d85dn, + 0x24aa6c514da27500n, + 0x4c9f34427501b447n, + 0x14a68fd73c910841n, + 0xa71b9b83461cbd93n, + 0x03488b95b0f1850fn, + 0x637b2b34ff93c040n, + 0x09d1bc9a3dd90a94n, + 0x3575668334a1dd3bn, + 0x735e2b97a4c45a23n, + 0x18727070f1bd400bn, + 0x1fcbacd259bf02e7n, + 0xd310a7c2ce9b6555n, + 0xbf983fe0fe5d8244n, + 0x9f74d14f7454a824n, + 0x51ebdc4ab9ba3035n, + 0x5c82c505db9ab0fan, + 0xfcf7fe8a3430b241n, + 0x3253a729b9ba3dden, + 0x8c74c368081b3075n, + 0xb9bc6c87167c33e7n, + 0x7ef48f2b83024e20n, + 0x11d505d4c351bd7fn, + 0x6568fca92c76a243n, + 0x4de0b0f40f32a7b8n, + 0x96d693460cc37e5dn, + 0x42e240cb63689f2fn, + 0x6d2bdcdae2919661n, + 0x42880b0236e4d951n, + 0x5f0f4a5898171bb6n, + 0x39f890f579f92f88n, + 0x93c5b5f47356388bn, + 0x63dc359d8d231b78n, + 0xec16ca8aea98ad76n, + 0x5355f900c2a82dc7n, + 0x07fb9f855a997142n, + 0x5093417aa8a7ed5en, + 0x7bcbc38da25a7f3cn, + 0x19fc8a768cf4b6d4n, + 0x637a7780decfc0d9n, + 0x8249a47aee0e41f7n, + 0x79ad695501e7d1e8n, + 0x14acbaf4777d5776n, + 0xf145b6beccdea195n, + 0xdabf2ac8201752fcn, + 0x24c3c94df9c8d3f6n, + 0xbb6e2924f03912ean, + 0x0ce26c0b95c980d9n, + 0xa49cd132bfbf7cc4n, + 0xe99d662af4243939n, + 0x27e6ad7891165c3fn, + 0x8535f040b9744ff1n, + 0x54b3f4fa5f40d873n, + 0x72b12c32127fed2bn, + 0xee954d3c7b411f47n, + 0x9a85ac909a24eaa1n, + 0x70ac4cd9f04f21f5n, + 0xf9b89d3e99a075c2n, + 0x87b3e2b2b5c907b1n, + 0xa366e5b8c54f48b8n, + 0xae4a9346cc3f7cf2n, + 0x1920c04d47267bbdn, + 0x87bf02c6b49e2ae9n, + 0x092237ac237f3859n, + 0xff07f64ef8ed14d0n, + 0x8de8dca9f03cc54en, + 0x9c1633264db49c89n, + 0xb3f22c3d0b0b38edn, + 0x390e5fb44d01144bn, + 0x5bfea5b4712768e9n, + 0x1e1032911fa78984n, + 0x9a74acb964e78cb3n, + 0x4f80f7a035dafb04n, + 0x6304d09a0b3738c4n, + 0x2171e64683023a08n, + 0x5b9b63eb9ceff80cn, + 0x506aacf489889342n, + 0x1881afc9a3a701d6n, + 0x6503080440750644n, + 0xdfd395339cdbf4a7n, + 0xef927dbcf00c20f2n, + 0x7b32f7d1e03680ecn, + 0xb9fd7620e7316243n, + 0x05a7e8a57db91b77n, + 0xb5889c6e15630a75n, + 0x4a750a09ce9573f7n, + 0xcf464cec899a2f8an, + 0xf538639ce705b824n, + 0x3c79a0ff5580ef7fn, + 0xede6c87f8477609dn, + 0x799e81f05bc93f31n, + 0x86536b8cf3428a8cn, + 0x97d7374c60087b73n, + 0xa246637cff328532n, + 0x043fcae60cc0eba0n, + 0x920e449535dd359en, + 0x70eb093b15b290ccn, + 0x73a1921916591cbdn, + 0x56436c9fe1a1aa8dn, + 0xefac4b70633b8f81n, + 0xbb215798d45df7afn, + 0x45f20042f24f1768n, + 0x930f80f4e8eb7462n, + 0xff6712ffcfd75ea1n, + 0xae623fd67468aa70n, + 0xdd2c5bc84bc8d8fcn, + 0x7eed120d54cf2dd9n, + 0x22fe545401165f1cn, + 0xc91800e98fb99929n, + 0x808bd68e6ac10365n, + 0xdec468145b7605f6n, + 0x1bede3a3aef53302n, + 0x43539603d6c55602n, + 0xaa969b5c691ccb7an, + 0xa87832d392efee56n, + 0x65942c7b3c7e11aen, + 0xded2d633cad004f6n, + 0x21f08570f420e565n, + 0xb415938d7da94e3cn, + 0x91b859e59ecb6350n, + 0x10cff333e0ed804an, + 0x28aed140be0bb7ddn, + 0xc5cc1d89724fa456n, + 0x5648f680f11a2741n, + 0x2d255069f0b7dab3n, + 0x9bc5a38ef729abd4n, + 0xef2f054308f6a2bcn, + 0xaf2042f5cc5c2858n, + 0x480412bab7f5be2an, + 0xaef3af4a563dfe43n, + 0x19afe59ae451497fn, + 0x52593803dff1e840n, + 0xf4f076e65f2ce6f0n, + 0x11379625747d5af3n, + 0xbce5d2248682c115n, + 0x9da4243de836994fn, + 0x066f70b33fe09017n, + 0x4dc4de189b671a1cn, + 0x51039ab7712457c3n, + 0xc07a3f80c31fb4b4n, + 0xb46ee9c5e64a6e7cn, + 0xb3819a42abe61c87n, + 0x21a007933a522a20n, + 0x2df16f761598aa4fn, + 0x763c4a1371b368fdn, + 0xf793c46702e086a0n, + 0xd7288e012aeb8d31n, + 0xde336a2a4bc1c44bn, + 0x0bf692b38d079f23n, + 0x2c604a7a177326b3n, + 0x4850e73e03eb6064n, + 0xcfc447f1e53c8e1bn, + 0xb05ca3f564268d99n, + 0x9ae182c8bc9474e8n, + 0xa4fc4bd4fc5558can, + 0xe755178d58fc4e76n, + 0x69b97db1a4c03dfen, + 0xf9b5b7c4acc67c96n, + 0xfc6a82d64b8655fbn, + 0x9c684cb6c4d24417n, + 0x8ec97d2917456ed0n, + 0x6703df9d2924e97en, + 0xc547f57e42a7444en, + 0x78e37644e7cad29en, + 0xfe9a44e9362f05fan, + 0x08bd35cc38336615n, + 0x9315e5eb3a129acen, + 0x94061b871e04df75n, + 0xdf1d9f9d784ba010n, + 0x3bba57b68871b59dn, + 0xd2b7adeeded1f73fn, + 0xf7a255d83bc373f8n, + 0xd7f4f2448c0ceb81n, + 0xd95be88cd210ffa7n, + 0x336f52f8ff4728e7n, + 0xa74049dac312ac71n, + 0xa2f61bb6e437fdb5n, + 0x4f2a5cb07f6a35b3n, + 0x87d380bda5bf7859n, + 0x16b9f7e06c453a21n, + 0x7ba2484c8a0fd54en, + 0xf3a678cad9a2e38cn, + 0x39b0bf7dde437ba2n, + 0xfcaf55c1bf8a4424n, + 0x18fcf680573fa594n, + 0x4c0563b89f495ac3n, + 0x40e087931a00930dn, + 0x8cffa9412eb642c1n, + 0x68ca39053261169fn, + 0x7a1ee967d27579e2n, + 0x9d1d60e5076f5b6fn, + 0x3810e399b6f65ba2n, + 0x32095b6d4ab5f9b1n, + 0x35cab62109dd038an, + 0xa90b24499fcfafb1n, + 0x77a225a07cc2c6bdn, + 0x513e5e634c70e331n, + 0x4361c0ca3f692f12n, + 0xd941aca44b20a45bn, + 0x528f7c8602c5807bn, + 0x52ab92beb9613989n, + 0x9d1dfa2efc557f73n, + 0x722ff175f572c348n, + 0x1d1260a51107fe97n, + 0x7a249a57ec0c9ba2n, + 0x04208fe9e8f7f2d6n, + 0x5a110c6058b920a0n, + 0x0cd9a497658a5698n, + 0x56fd23c8f9715a4cn, + 0x284c847b9d887aaen, + 0x04feabfbbdb619cbn, + 0x742e1e651c60ba83n, + 0x9a9632e65904ad3cn, + 0x881b82a13b51b9e2n, + 0x506e6744cd974924n, + 0xb0183db56ffc6a79n, + 0x0ed9b915c66ed37en, + 0x5e11e86d5873d484n, + 0xf678647e3519ac6en, + 0x1b85d488d0f20cc5n, + 0xdab9fe6525d89021n, + 0x0d151d86adb73615n, + 0xa865a54edcc0f019n, + 0x93c42566aef98ffbn, + 0x99e7afeabe000731n, + 0x48cbff086ddf285an, + 0x7f9b6af1ebf78bafn, + 0x58627e1a149bba21n, + 0x2cd16e2abd791e33n, + 0xd363eff5f0977996n, + 0x0ce2a38c344a6eedn, + 0x1a804aadb9cfa741n, + 0x907f30421d78c5den, + 0x501f65edb3034d07n, + 0x37624ae5a48fa6e9n, + 0x957baf61700cff4en, + 0x3a6c27934e31188an, + 0xd49503536abca345n, + 0x088e049589c432e0n, + 0xf943aee7febf21b8n, + 0x6c3b8e3e336139d3n, + 0x364f6ffa464ee52en, + 0xd60f6dcedc314222n, + 0x56963b0dca418fc0n, + 0x16f50edf91e513afn, + 0xef1955914b609f93n, + 0x565601c0364e3228n, + 0xecb53939887e8175n, + 0xbac7a9a18531294bn, + 0xb344c470397bba52n, + 0x65d34954daf3cebdn, + 0xb4b81b3fa97511e2n, + 0xb422061193d6f6a7n, + 0x071582401c38434dn, + 0x7a13f18bbedc4ff5n, + 0xbc4097b116c524d2n, + 0x59b97885e2f2ea28n, + 0x99170a5dc3115544n, + 0x6f423357e7c6a9f9n, + 0x325928ee6e6f8794n, + 0xd0e4366228b03343n, + 0x565c31f7de89ea27n, + 0x30f5611484119414n, + 0xd873db391292ed4fn, + 0x7bd94e1d8e17debcn, + 0xc7d9f16864a76e94n, + 0x947ae053ee56e63cn, + 0xc8c93882f9475f5fn, + 0x3a9bf55ba91f81can, + 0xd9a11fbb3d9808e4n, + 0x0fd22063edc29fcan, + 0xb3f256d8aca0b0b9n, + 0xb03031a8b4516e84n, + 0x35dd37d5871448afn, + 0xe9f6082b05542e4en, + 0xebfafa33d7254b59n, + 0x9255abb50d532280n, + 0xb9ab4ce57f2d34f3n, + 0x693501d628297551n, + 0xc62c58f97dd949bfn, + 0xcd454f8f19c5126an, + 0xbbe83f4ecc2bdecbn, + 0xdc842b7e2819e230n, + 0xba89142e007503b8n, + 0xa3bc941d0a5061cbn, + 0xe9f6760e32cd8021n, + 0x09c7e552bc76492fn, + 0x852f54934da55cc9n, + 0x8107fccf064fcf56n, + 0x098954d51fff6580n, + 0x23b70edb1955c4bfn, + 0xc330de426430f69dn, + 0x4715ed43e8a45c0an, + 0xa8d7e4dab780a08dn, + 0x0572b974f03ce0bbn, + 0xb57d2e985e1419c7n, + 0xe8d9ecbe2cf3d73fn, + 0x2fe4b17170e59750n, + 0x11317ba87905e790n, + 0x7fbf21ec8a1f45ecn, + 0x1725cabfcb045b00n, + 0x964e915cd5e2b207n, + 0x3e2b8bcbf016d66dn, + 0xbe7444e39328a0acn, + 0xf85b2b4fbcde44b7n, + 0x49353fea39ba63b1n, + 0x1dd01aafcd53486an, + 0x1fca8a92fd719f85n, + 0xfc7c95d827357afan, + 0x18a6a990c8b35ebdn, + 0xcccb7005c6b9c28dn, + 0x3bdbb92c43b17f26n, + 0xaa70b5b4f89695a2n, + 0xe94c39a54a98307fn, + 0xb7a0b174cff6f36en, + 0xd4dba84729af48adn, + 0x2e18bc1ad9704a68n, + 0x2de0966daf2f8b1cn, + 0xb9c11d5b1e43a07en, + 0x64972d68dee33360n, + 0x94628d38d0c20584n, + 0xdbc0d2b6ab90a559n, + 0xd2733c4335c6a72fn, + 0x7e75d99d94a70f4dn, + 0x6ced1983376fa72bn, + 0x97fcaacbf030bc24n, + 0x7b77497b32503b12n, + 0x8547eddfb81ccb94n, + 0x79999cdff70902cbn, + 0xcffe1939438e9b24n, + 0x829626e3892d95d7n, + 0x92fae24291f2b3f1n, + 0x63e22c147b9c3403n, + 0xc678b6d860284a1cn, + 0x5873888850659ae7n, + 0x0981dcd296a8736dn, + 0x9f65789a6509a440n, + 0x9ff38fed72e9052fn, + 0xe479ee5b9930578cn, + 0xe7f28ecd2d49eecdn, + 0x56c074a581ea17fen, + 0x5544f7d774b14aefn, + 0x7b3f0195fc6f290fn, + 0x12153635b2c0cf57n, + 0x7f5126dbba5e0ca7n, + 0x7a76956c3eafb413n, + 0x3d5774a11d31ab39n, + 0x8a1b083821f40cb4n, + 0x7b4a38e32537df62n, + 0x950113646d1d6e03n, + 0x4da8979a0041e8a9n, + 0x3bc36e078f7515d7n, + 0x5d0a12f27ad310d1n, + 0x7f9d1a2e1ebe1327n, + 0xda3a361b1c5157b1n, + 0xdcdd7d20903d0c25n, + 0x36833336d068f707n, + 0xce68341f79893389n, + 0xab9090168dd05f34n, + 0x43954b3252dc25e5n, + 0xb438c2b67f98e5e9n, + 0x10dcd78e3851a492n, + 0xdbc27ab5447822bfn, + 0x9b3cdb65f82ca382n, + 0xb67b7896167b4c84n, + 0xbfced1b0048eac50n, + 0xa9119b60369ffebdn, + 0x1fff7ac80904bf45n, + 0xac12fb171817eee7n, + 0xaf08da9177dda93dn, + 0x1b0cab936e65c744n, + 0xb559eb1d04e5e932n, + 0xc37b45b3f8d6f2ban, + 0xc3a9dc228caac9e9n, + 0xf3b8b6675a6507ffn, + 0x9fc477de4ed681dan, + 0x67378d8eccef96cbn, + 0x6dd856d94d259236n, + 0xa319ce15b0b4db31n, + 0x073973751f12dd5en, + 0x8a8e849eb32781a5n, + 0xe1925c71285279f5n, + 0x74c04bf1790c0efen, + 0x4dda48153c94938an, + 0x9d266d6a1cc0542cn, + 0x7440fb816508c4fen, + 0x13328503df48229fn, + 0xd6bf7baee43cac40n, + 0x4838d65f6ef6748fn, + 0x1e152328f3318dean, + 0x8f8419a348f296bfn, + 0x72c8834a5957b511n, + 0xd7a023a73260b45cn, + 0x94ebc8abcfb56daen, + 0x9fc10d0f989993e0n, + 0xde68a2355b93cae6n, + 0xa44cfe79ae538bben, + 0x9d1d84fcce371425n, + 0x51d2b1ab2ddfb636n, + 0x2fd7e4b9e72cd38cn, + 0x65ca5b96b7552210n, + 0xdd69a0d8ab3b546dn, + 0x604d51b25fbf70e2n, + 0x73aa8a564fb7ac9en, + 0x1a8c1e992b941148n, + 0xaac40a2703d9bea0n, + 0x764dbeae7fa4f3a6n, + 0x1e99b96e70a9be8bn, + 0x2c5e9deb57ef4743n, + 0x3a938fee32d29981n, + 0x26e6db8ffdf5adfen, + 0x469356c504ec9f9dn, + 0xc8763c5b08d1908cn, + 0x3f6c6af859d80055n, + 0x7f7cc39420a3a545n, + 0x9bfb227ebdf4c5cen, + 0x89039d79d6fc5c5cn, + 0x8fe88b57305e2ab6n, + 0xa09e8c8c35ab96den, + 0xfa7e393983325753n, + 0xd6b6d0ecc617c699n, + 0xdfea21ea9e7557e3n, + 0xb67c1fa481680af8n, + 0xca1e3785a9e724e5n, + 0x1cfc8bed0d681639n, + 0xd18d8549d140caean, + 0x4ed0fe7e9dc91335n, + 0xe4dbf0634473f5d2n, + 0x1761f93a44d5aefen, + 0x53898e4c3910da55n, + 0x734de8181f6ec39an, + 0x2680b122baa28d97n, + 0x298af231c85bafabn, + 0x7983eed3740847d5n, + 0x66c1a2a1a60cd889n, + 0x9e17e49642a3e4c1n, + 0xedb454e7badc0805n, + 0x50b704cab602c329n, + 0x4cc317fb9cddd023n, + 0x66b4835d9eafea22n, + 0x219b97e26ffc81bdn, + 0x261e4e4c0a333a9dn, + 0x1fe2cca76517db90n, + 0xd7504dfa8816edbbn, + 0xb9571fa04dc089c8n, + 0x1ddc0325259b27den, + 0xcf3f4688801eb9aan, + 0xf4f5d05c10cab243n, + 0x38b6525c21a42b0en, + 0x36f60e2ba4fa6800n, + 0xeb3593803173e0cen, + 0x9c4cd6257c5a3603n, + 0xaf0c317d32adaa8an, + 0x258e5a80c7204c4bn, + 0x8b889d624d44885dn, + 0xf4d14597e660f855n, + 0xd4347f66ec8941c3n, + 0xe699ed85b0dfb40dn, + 0x2472f6207c2d0484n, + 0xc2a1e7b5b459aeb5n, + 0xab4f6451cc1d45ecn, + 0x63767572ae3d6174n, + 0xa59e0bd101731a28n, + 0x116d0016cb948f09n, + 0x2cf9c8ca052f6e9fn, + 0x0b090a7560a968e3n, + 0xabeeddb2dde06ff1n, + 0x58efc10b06a2068dn, + 0xc6e57a78fbd986e0n, + 0x2eab8ca63ce802d7n, + 0x14a195640116f336n, + 0x7c0828dd624ec390n, + 0xd74bbe77e6116ac7n, + 0x804456af10f5fb53n, + 0xebe9ea2adf4321c7n, + 0x03219a39ee587a30n, + 0x49787fef17af9924n, + 0xa1e9300cd8520548n, + 0x5b45e522e4b1b4efn, + 0xb49c3b3995091a36n, + 0xd4490ad526f14431n, + 0x12a8f216af9418c2n, + 0x001f837cc7350524n, + 0x1877b51e57a764d5n, + 0xa2853b80f17f58een, + 0x993e1de72d36d310n, + 0xb3598080ce64a656n, + 0x252f59cf0d9f04bbn, + 0xd23c8e176d113600n, + 0x1bda0492e7e4586en, + 0x21e0bd5026c619bfn, + 0x3b097adaf088f94en, + 0x8d14dedb30be846en, + 0xf95cffa23af5f6f4n, + 0x3871700761b3f743n, + 0xca672b91e9e4fa16n, + 0x64c8e531bff53b55n, + 0x241260ed4ad1e87dn, + 0x106c09b972d2e822n, + 0x7fba195410e5ca30n, + 0x7884d9bc6cb569d8n, + 0x0647dfedcd894a29n, + 0x63573ff03e224774n, + 0x4fc8e9560f91b123n, + 0x1db956e450275779n, + 0xb8d91274b9e9d4fbn, + 0xa2ebee47e2fbfce1n, + 0xd9f1f30ccd97fb09n, + 0xefed53d75fd64e6bn, + 0x2e6d02c36017f67fn, + 0xa9aa4d20db084e9bn, + 0xb64be8d8b25396c1n, + 0x70cb6af7c2d5bcf0n, + 0x98f076a4f7a2322en, + 0xbf84470805e69b5fn, + 0x94c3251f06f90cf3n, + 0x3e003e616a6591e9n, + 0xb925a6cd0421aff3n, + 0x61bdd1307c66e300n, + 0xbf8d5108e27e0d48n, + 0x240ab57a8b888b20n, + 0xfc87614baf287e07n, + 0xef02cdd06ffdb432n, + 0xa1082c0466df6c0an, + 0x8215e577001332c8n, + 0xd39bb9c3a48db6cfn, + 0x2738259634305c14n, + 0x61cf4f94c97df93dn, + 0x1b6baca2ae4e125bn, + 0x758f450c88572e0bn, + 0x959f587d507a8359n, + 0xb063e962e045f54dn, + 0x60e8ed72c0dff5d1n, + 0x7b64978555326f9fn, + 0xfd080d236da814ban, + 0x8c90fd9b083f4558n, + 0x106f72fe81e2c590n, + 0x7976033a39f7d952n, + 0xa4ec0132764ca04bn, + 0x733ea705fae4fa77n, + 0xb4d8f77bc3e56167n, + 0x9e21f4f903b33fd9n, + 0x9d765e419fb69f6dn, + 0xd30c088ba61ea5efn, + 0x5d94337fbfaf7f5bn, + 0x1a4e4822eb4d7a59n, + 0x6ffe73e81b637fb3n, + 0xddf957bc36d8b9can, + 0x64d0e29eea8838b3n, + 0x08dd9bdfd96b9f63n, + 0x087e79e5a57d1d13n, + 0xe328e230e3e2b3fbn, + 0x1c2559e30f0946ben, + 0x720bf5f26f4d2eaan, + 0xb0774d261cc609dbn, + 0x443f64ec5a371195n, + 0x4112cf68649a260en, + 0xd813f2fab7f5c5can, + 0x660d3257380841een, + 0x59ac2c7873f910a3n, + 0xe846963877671a17n, + 0x93b633abfa3469f8n, + 0xc0c0f5a60ef4cdcfn, + 0xcaf21ecd4377b28cn, + 0x57277707199b8175n, + 0x506c11b9d90e8b1dn, + 0xd83cc2687a19255fn, + 0x4a29c6465a314cd1n, + 0xed2df21216235097n, + 0xb5635c95ff7296e2n, + 0x22af003ab672e811n, + 0x52e762596bf68235n, + 0x9aeba33ac6ecc6b0n, + 0x944f6de09134dfb6n, + 0x6c47bec883a7de39n, + 0x6ad047c430a12104n, + 0xa5b1cfdba0ab4067n, + 0x7c45d833aff07862n, + 0x5092ef950a16da0bn, + 0x9338e69c052b8e7bn, + 0x455a4b4cfe30e3f5n, + 0x6b02e63195ad0cf8n, + 0x6b17b224bad6bf27n, + 0xd1e0ccd25bb9c169n, + 0xde0c89a556b9ae70n, + 0x50065e535a213cf6n, + 0x9c1169fa2777b874n, + 0x78edefd694af1eedn, + 0x6dc93d9526a50e68n, + 0xee97f453f06791edn, + 0x32ab0edb696703d3n, + 0x3a6853c7e70757a7n, + 0x31865ced6120f37dn, + 0x67fef95d92607890n, + 0x1f2b1d1f15f6dc9cn, + 0xb69e38a8965c6b65n, + 0xaa9119ff184cccf4n, + 0xf43c732873f24c13n, + 0xfb4a3d794a9a80d2n, + 0x3550c2321fd6109cn, + 0x371f77e76bb8417en, + 0x6bfa9aae5ec05779n, + 0xcd04f3ff001a4778n, + 0xe3273522064480can, + 0x9f91508bffcfc14an, + 0x049a7f41061a9e60n, + 0xfcb6be43a9f2fe9bn, + 0x08de8a1c7797da9bn, + 0x8f9887e6078735a1n, + 0xb5b4071dbfc73a66n, + 0x230e343dfba08d33n, + 0x43ed7f5a0fae657dn, + 0x3a88a0fbbcb05c63n, + 0x21874b8b4d2dbc4fn, + 0x1bdea12e35f6a8c9n, + 0x53c065c6c8e63528n, + 0xe34a1d250e7a8d6bn, + 0xd6b04d3b7651dd7en, + 0x5e90277e7cb39e2dn, + 0x2c046f22062dc67dn, + 0xb10bb459132d0a26n, + 0x3fa9ddfb67e2f199n, + 0x0e09b88e1914f7afn, + 0x10e8b35af3eeab37n, + 0x9eedeca8e272b933n, + 0xd4c718bc4ae8ae5fn, + 0x81536d601170fc20n, + 0x91b534f885818a06n, + 0xec8177f83f900978n, + 0x190e714fada5156en, + 0xb592bf39b0364963n, + 0x89c350c893ae7dc1n, + 0xac042e70f8b383f2n, + 0xb49b52e587a1ee60n, + 0xfb152fe3ff26da89n, + 0x3e666e6f69ae2c15n, + 0x3b544ebe544c19f9n, + 0xe805a1e290cf2456n, + 0x24b33c9d7ed25117n, + 0xe74733427b72f0c1n, + 0x0a804d18b7097475n, + 0x57e3306d881edb4fn, + 0x4ae7d6a36eb5dbcbn, + 0x2d8d5432157064c8n, + 0xd1e649de1e7f268bn, + 0x8a328a1cedfe552cn, + 0x07a3aec79624c7dan, + 0x84547ddc3e203c94n, + 0x990a98fd5071d263n, + 0x1a4ff12616eefc89n, + 0xf6f7fd1431714200n, + 0x30c05b1ba332f41cn, + 0x8d2636b81555a786n, + 0x46c9feb55d120902n, + 0xccec0a73b49c9921n, + 0x4e9d2827355fc492n, + 0x19ebb029435dcb0fn, + 0x4659d2b743848a2cn, + 0x963ef2c96b33be31n, + 0x74f85198b05a2e7dn, + 0x5a0f544dd2b1fb18n, + 0x03727073c2e134b1n, + 0xc7f6aa2de59aea61n, + 0x352787baa0d7c22fn, + 0x9853eab63b5e0b35n, + 0xabbdcdd7ed5c0860n, + 0xcf05daf5ac8d77b0n, + 0x49cad48cebf4a71en, + 0x7a4c10ec2158c4a6n, + 0xd9e92aa246bf719en, + 0x13ae978d09fe5557n, + 0x730499af921549ffn, + 0x4e4b705b92903ba4n, + 0xff577222c14f0a3an, + 0x55b6344cf97aafaen, + 0xb862225b055b6960n, + 0xcac09afbddd2cdb4n, + 0xdaf8e9829fe96b5fn, + 0xb5fdfc5d3132c498n, + 0x310cb380db6f7503n, + 0xe87fbb46217a360en, + 0x2102ae466ebb1148n, + 0xf8549e1a3aa5e00dn, + 0x07a69afdcc42261an, + 0xc4c118bfe78feaaen, + 0xf9f4892ed96bd438n, + 0x1af3dbe25d8f45dan, + 0xf5b4b0b0d2deeeb4n, + 0x962aceefa82e1c84n, + 0x046e3ecaaf453ce9n, + 0xf05d129681949a4cn, + 0x964781ce734b3c84n, + 0x9c2ed44081ce5fbdn, + 0x522e23f3925e319en, + 0x177e00f9fc32f791n, + 0x2bc60a63a6f3b3f2n, + 0x222bbfae61725606n, + 0x486289ddcc3d6780n, + 0x7dc7785b8efdfc80n, + 0x8af38731c02ba980n, + 0x1fab64ea29a2ddf7n, + 0xe4d9429322cd065an, + 0x9da058c67844f20cn, + 0x24c0e332b70019b0n, + 0x233003b5a6cfe6adn, + 0xd586bd01c5c217f6n, + 0x5e5637885f29bc2bn, + 0x7eba726d8c94094bn, + 0x0a56a5f0bfe39272n, + 0xd79476a84ee20d06n, + 0x9e4c1269baa4bf37n, + 0x17efee45b0dee640n, + 0x1d95b0a5fcf90bc6n, + 0x93cbe0b699c2585dn, + 0x65fa4f227a2b6d79n, + 0xd5f9e858292504d5n, + 0xc2b5a03f71471a6fn, + 0x59300222b4561e00n, + 0xce2f8642ca0712dcn, + 0x7ca9723fbb2e8988n, + 0x2785338347f2ba08n, + 0xc61bb3a141e50e8cn, + 0x150f361dab9dec26n, + 0x9f6a419d382595f4n, + 0x64a53dc924fe7ac9n, + 0x142de49fff7a7c3dn, + 0x0c335248857fa9e7n, + 0x0a9c32d5eae45305n, + 0xe6c42178c4bbb92en, + 0x71f1ce2490d20b07n, + 0xf1bcc3d275afe51an, + 0xe728e8c83c334074n, + 0x96fbf83a12884624n, + 0x81a1549fd6573da5n, + 0x5fa7867caf35e149n, + 0x56986e2ef3ed091bn, + 0x917f1dd5f8886c61n, + 0xd20d8c88c8ffe65fn, + 0x31d71dce64b2c310n, + 0xf165b587df898190n, + 0xa57e6339dd2cf3a0n, + 0x1ef6e6dbb1961ec9n, + 0x70cc73d90bc26e24n, + 0xe21a6b35df0c3ad7n, + 0x003a93d8b2806962n, + 0x1c99ded33cb890a1n, + 0xcf3145de0add4289n, + 0xd0e4427a5514fb72n, + 0x77c621cc9fb3a483n, + 0x67a34dac4356550bn, + 0xf8d626aaaf278509n, +]; diff --git a/ui/chess/src/sanWriter.ts b/ui/chess/src/sanWriter.ts index 44b486900176a..fea428ce7ad8f 100644 --- a/ui/chess/src/sanWriter.ts +++ b/ui/chess/src/sanWriter.ts @@ -90,6 +90,7 @@ function slidingMovesTo(s: number, deltas: number[], board: Board): number[] { * but lacks the check/checkmate flag, * and probably has incomplete disambiguation. * But it's quick. */ + export function almostSanOf(board: Board, uci: string): AlmostSan { if (uci.includes('@')) return fixCrazySan(uci); diff --git a/ui/common/css/abstract/_licon.scss b/ui/common/css/abstract/_licon.scss index e87b5a864ed6a..708f38feef41c 100644 --- a/ui/common/css/abstract/_licon.scss +++ b/ui/common/css/abstract/_licon.scss @@ -134,3 +134,4 @@ $licon-Reload: ''; // e078 $licon-AccountCircle: ''; // e079 $licon-Logo: ''; // e07a $licon-Switch: ''; // e07b +$licon-Blindfold: ''; // e07c diff --git a/ui/common/css/abstract/_uniboard.scss b/ui/common/css/abstract/_uniboard.scss index 5644c3eca5d3b..098b1c9858484 100644 --- a/ui/common/css/abstract/_uniboard.scss +++ b/ui/common/css/abstract/_uniboard.scss @@ -2,7 +2,7 @@ $scrollbar-width: 20px; -$col3-uniboard-side-min: 250px; +$col3-uniboard-side-min: var(---col3-uniboard-side-min, 250px); $col3-uniboard-table-min: 240px; $col3-uniboard-side: minmax(#{$col3-uniboard-side-min}, 350px); $col3-uniboard-table: minmax(#{$col3-uniboard-table-min}, 400px); diff --git a/ui/common/css/abstract/_z-index.scss b/ui/common/css/abstract/_z-index.scss index 35cadfc007c4c..bb36992642b9a 100644 --- a/ui/common/css/abstract/_z-index.scss +++ b/ui/common/css/abstract/_z-index.scss @@ -2,7 +2,6 @@ $z-cg__promotion-205: 205; $z-cg__piece_dragging-204: 204; - $z-modal-alert-200: 200; $z-powertip-120: 120; $z-complete-113: 113; @@ -20,24 +19,22 @@ $z-network-status-105: 105; $z-tour-reminder-104: 104; $z-video-player-controls-101: 101; $z-video-player-100: 100; - $z-cg__board_overlay-100: 100; +$z-above-dialog-14: 14; +$z-above-dialog-13: 13; +$z-above-dialog-12: 12; +$z-dialog-11: 11; $z-cg__board_resize-10: 10; $z-cg__svg_cg-custom-svgs-4: 4; - $z-mz-menu-4: 4; $z-above-link-overlay-3: 3; - $z-cg__piece_anim-3: 3; $z-cg__svg_cg-shapes-2: 2; $z-cg__cg-auto-pieces-2: 2; $z-cg__piece-2: 2; - $z-friend-box-2: 2; $z-link-overlay-2: 2; $z-game-bookmark-2: 2; $z-subnav-side-2: 2; - $z-cg__piece_fading-1: 1; - $z-default-0: 0; diff --git a/ui/common/css/layout/_base.scss b/ui/common/css/layout/_base.scss index 7c21f5302ffba..cb18ad96fbb63 100644 --- a/ui/common/css/layout/_base.scss +++ b/ui/common/css/layout/_base.scss @@ -26,10 +26,10 @@ body { } #main-wrap { + ---main-max-width: #{$main-max-width}; display: grid; grid-template-areas: '. . main . .'; grid-template-columns: $main-margin 1fr minmax(auto, var(---main-max-width)) 1fr $main-margin; - ---main-max-width: #{$main-max-width}; margin-top: $site-header-margin; @include mq-sticky-header { diff --git a/ui/common/src/algo.ts b/ui/common/src/algo.ts index 36c163ca7f447..2f326c3894b53 100644 --- a/ui/common/src/algo.ts +++ b/ui/common/src/algo.ts @@ -7,6 +7,13 @@ export const randomToken = (): string => { } }; +export function randomId(len = 8): string { + const charSet = 'abcdefghkmnpqrstuvwxyz0123456789'; + const charSetSize = charSet.length; // 32 + const buffer = crypto.getRandomValues(new Uint8Array(len)); + return Array.from(buffer, byte => charSet[byte % charSetSize]).join(''); +} + export function clamp(value: number, bounds: { min?: number; max?: number }): number { return Math.max(bounds.min ?? -Infinity, Math.min(value, bounds.max ?? Infinity)); } @@ -15,12 +22,13 @@ export function quantize(n: number | undefined, factor: number): number { return Math.round((n ?? 0) / factor) * factor; } -export function shuffle(array: T[]): T[] { - for (let i = array.length - 1; i > 0; i--) { +export function shuffle(arr: T[]): T[] { + const shuffled = arr.slice(); + for (let i = shuffled.length - 1; i > 0; i--) { const j = Math.floor(Math.random() * (i + 1)); - [array[i], array[j]] = [array[j], array[i]]; + [shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]]; } - return array; + return shuffled; } export function deepFreeze(obj: T): T { @@ -31,6 +39,36 @@ export function deepFreeze(obj: T): T { return Object.freeze(obj); } +export function zip(arr1: T[], arr2: U[]): [T, U][] { + const length = Math.min(arr1.length, arr2.length); + const result: [T, U][] = []; + for (let i = 0; i < length; i++) { + result.push([arr1[i], arr2[i]]); + } + return result; +} + +export function findMap(arr: T[], fn: (el: T) => U | undefined): U | undefined { + for (const el of arr) { + const result = fn(el); + if (result) return result; + } + return undefined; +} + +export function definedMap(arr: (T | undefined)[], fn: (v: T) => U | undefined): U[] { + return arr.reduce((acc, v) => { + if (v === undefined) return acc; + const result = fn(v); + if (result !== undefined) acc.push(result); + return acc; + }, []); +} + +export function definedUnique(items: (T | undefined)[]): T[] { + return [...new Set(items.filter((item): item is T => item !== undefined))]; +} + // comparison of enumerable primitives. complex properties get reference equality only export function isEquivalent(a: any, b: any): boolean { if (a === b) return true; @@ -57,27 +95,6 @@ export function isContained(o: any, sub: any): boolean { return subKeys.every(key => aKeys.includes(key) && isContained(o[key], sub[key])); } -export function zip(arr1: T[], arr2: U[]): [T, U][] { - const length = Math.min(arr1.length, arr2.length); - const result: [T, U][] = []; - for (let i = 0; i < length; i++) { - result.push([arr1[i], arr2[i]]); - } - return result; -} - -export function findMapped(arr: T[], callback: (el: T) => U | undefined): U | undefined { - for (const el of arr) { - const result = callback(el); - if (result) return result; - } - return undefined; -} - -export function unique(items: (T | undefined)[]): T[] { - return [...new Set(items.filter((item): item is T => item !== undefined))]; -} - export function shallowSort(obj: { [key: string]: any }): { [key: string]: any } { const sorted: { [key: string]: any } = {}; for (const key of Object.keys(obj).sort()) sorted[key] = obj[key]; diff --git a/ui/common/src/common.ts b/ui/common/src/common.ts index 9182b746c09af..028bae06eade6 100644 --- a/ui/common/src/common.ts +++ b/ui/common/src/common.ts @@ -113,12 +113,7 @@ export function escapeHtml(str: string): string { } export function frag(html: string): T { - const div = document.createElement('div'); - div.innerHTML = html; - - const fragment: DocumentFragment = document.createDocumentFragment(); - while (div.firstChild) fragment.appendChild(div.firstChild); - + const fragment = document.createRange().createContextualFragment(html); return (fragment.childElementCount === 1 ? fragment.firstElementChild : fragment) as unknown as T; } diff --git a/ui/common/src/dialog.ts b/ui/common/src/dialog.ts index afa58088925de..a9f94113c6132 100644 --- a/ui/common/src/dialog.ts +++ b/ui/common/src/dialog.ts @@ -9,8 +9,8 @@ import { pubsub } from './pubsub'; let dialogPolyfill: { registerDialog: (dialog: HTMLDialogElement) => void }; export interface Dialog { - readonly open: boolean; // is visible? - readonly view: HTMLElement; // your content div + readonly viewEl: HTMLElement; // your content div + readonly dialogEl: HTMLDialogElement; // the dialog element readonly returnValue?: 'ok' | 'cancel' | string; // how did we close? show(): Promise; // promise resolves on close @@ -44,7 +44,7 @@ export interface DomDialogOpts extends DialogOpts { // for snabDialog, show is inferred from !onInsert export interface SnabDialogOpts extends DialogOpts { vnodes?: LooseVNodes; // content, overrides all other content properties - onInsert?: (dialog: Dialog) => void; // if provided you must also call show + onInsert?: (dialog: Dialog) => void; // if provided you must call show } export type ActionListener = (e: Event, dialog: Dialog, action: Action) => void; @@ -136,7 +136,7 @@ export async function prompt(msg: string, def: string = ''): Promise { if (Date.now() - justThen < 200) return; // removed isConnected() check. we catch leaks this way - const r = dialog.getBoundingClientRect(); + const r = dialogEl.getBoundingClientRect(); if (e.clientX < r.left || e.clientX > r.right || e.clientY < r.top || e.clientY > r.bottom) this.close('cancel'); }; this.observer.observe(document.body, { childList: true, subtree: true }); - view.parentElement?.style.setProperty('---viewport-height', `${window.innerHeight}px`); - this.dialogEvents.addListener(view, 'click', e => e.stopPropagation()); + viewEl.parentElement?.style.setProperty('---viewport-height', `${window.innerHeight}px`); + this.dialogEvents.addListener(viewEl, 'click', e => e.stopPropagation()); this.dialogEvents.addListener(dialog, 'cancel', e => { if (o.noClickAway && o.noCloseButton && o.class !== 'alert') return e.preventDefault(); @@ -261,7 +261,7 @@ class DialogWrapper implements Dialog { this.dialogEvents.addListener(dialog, 'close', this.onRemove); if (!o.noCloseButton) this.dialogEvents.addListener( - dialog.querySelector('.close-button-anchor > .close-button')!, + dialogEl.querySelector('.close-button-anchor > .close-button')!, 'click', () => this.close('cancel'), ); @@ -269,11 +269,11 @@ class DialogWrapper implements Dialog { if (!o.noClickAway) setTimeout(() => { this.dialogEvents.addListener(document.body, 'click', cancelOnInterval); - this.dialogEvents.addListener(dialog, 'click', cancelOnInterval); + this.dialogEvents.addListener(dialogEl, 'click', cancelOnInterval); }); for (const app of o.append ?? []) { - if (app.node === view) break; - const where = (app.where ? view.querySelector(app.where) : view)!; + if (app.node === viewEl) break; + const where = (app.where ? viewEl.querySelector(app.where) : viewEl)!; if (app.how === 'before') where.before(app.node); else if (app.how === 'after') where.after(app.node); else where.appendChild(app.node); @@ -283,31 +283,31 @@ class DialogWrapper implements Dialog { } get open(): boolean { - return this.dialog.open; + return this.dialogEl.open; } get returnValue(): string { - return this.dialog.returnValue; + return this.dialogEl.returnValue; } set returnValue(v: string) { - this.dialog.returnValue = v; + this.dialogEl.returnValue = v; } show = (): Promise => { - if (this.o.modal) this.view.scrollTop = 0; + if (this.o.modal) this.viewEl.scrollTop = 0; if (this.isSnab) { - if (this.dialog.parentElement === this.dialog.closest('.snab-modal-mask')) - this.dialog.parentElement?.classList.remove('none'); - this.dialog.show(); - } else if (this.o.modal) this.dialog.showModal(); - else this.dialog.show(); + if (this.dialogEl.parentElement === this.dialogEl.closest('.snab-modal-mask')) + this.dialogEl.parentElement?.classList.remove('none'); + this.dialogEl.show(); + } else if (this.o.modal) this.dialogEl.showModal(); + else this.dialogEl.show(); this.autoFocus(); return new Promise(resolve => (this.resolve = resolve)); }; close = (v?: string) => { - this.dialog.close(v || this.returnValue || 'ok'); + this.dialogEl.close(v || this.returnValue || 'ok'); }; // attach/reattach existing listeners or provide a set of new ones @@ -316,7 +316,7 @@ class DialogWrapper implements Dialog { if (!actions) return; for (const a of Array.isArray(actions) ? actions : [actions]) { for (const event of Array.isArray(a.event) ? a.event : a.event ? [a.event] : ['click']) { - for (const el of a.selector ? this.view.querySelectorAll(a.selector) : [this.view]) { + for (const el of a.selector ? this.viewEl.querySelectorAll(a.selector) : [this.viewEl]) { const listener = 'listener' in a ? (e: Event) => a.listener(e, this, a) : () => this.close(a.result); this.actionEvents.addListener(el, event, listener); @@ -344,8 +344,9 @@ class DialogWrapper implements Dialog { private autoFocus() { const focus = - (this.o.focus ? this.view.querySelector(this.o.focus) : this.view.querySelector('input[autofocus]')) ?? - this.view.querySelector(focusQuery); + (this.o.focus + ? this.viewEl.querySelector(this.o.focus) + : this.viewEl.querySelector('input[autofocus]')) ?? this.viewEl.querySelector(focusQuery); if (!(focus instanceof HTMLElement)) return; focus.focus(); @@ -354,11 +355,12 @@ class DialogWrapper implements Dialog { private onRemove = () => { this.observer.disconnect(); - if (!this.dialog.returnValue) this.dialog.returnValue = 'cancel'; + if (!this.dialogEl.returnValue) this.dialogEl.returnValue = 'cancel'; this.resolve?.(this); this.o.onClose?.(this); - if (this.dialog.parentElement?.classList.contains('snab-modal-mask')) this.dialog.parentElement.remove(); - else this.dialog.remove(); + if (this.dialogEl.parentElement?.classList.contains('snab-modal-mask')) + this.dialogEl.parentElement.remove(); + else this.dialogEl.remove(); for (const css of this.o.css ?? []) { if ('hashed' in css) site.asset.removeCssPath(css.hashed); else if ('url' in css) site.asset.removeCss(css.url); @@ -392,5 +394,5 @@ function onResize() { const focusQuery = ['button', 'input', 'select', 'textarea'] .map(sel => `${sel}:not(:disabled)`) - .concat(['[href]', '[tabindex="0"]', '[role="tab"]']) + .concat(['[href]', '[tabindex]', '[role="tab"]']) .join(','); diff --git a/ui/common/src/licon.ts b/ui/common/src/licon.ts index 521c0ef5f7ee5..a5dd450bb2a01 100644 --- a/ui/common/src/licon.ts +++ b/ui/common/src/licon.ts @@ -134,3 +134,4 @@ export const Reload = ''; // e078 export const AccountCircle = ''; // e079 export const Logo = ''; // e07a export const Switch = ''; // e07b +export const Blindfold = ''; // e07c diff --git a/ui/common/src/linkPopup.ts b/ui/common/src/linkPopup.ts index 6b208fa06bed0..03c6e82bdb869 100644 --- a/ui/common/src/linkPopup.ts +++ b/ui/common/src/linkPopup.ts @@ -30,8 +30,8 @@ export const onClick = (a: HTMLLinkElement): boolean => {
`, modal: true, }).then(dlg => { - $('.cancel', dlg.view).on('click', dlg.close); - $('a', dlg.view).on('click', () => setTimeout(dlg.close, 1000)); + $('.cancel', dlg.viewEl).on('click', dlg.close); + $('a', dlg.viewEl).on('click', () => setTimeout(dlg.close, 1000)); dlg.show(); }); return false; diff --git a/ui/common/src/pubsub.ts b/ui/common/src/pubsub.ts index 52bf39da00e81..9ac7f8e6069fd 100644 --- a/ui/common/src/pubsub.ts +++ b/ui/common/src/pubsub.ts @@ -15,6 +15,7 @@ export type PubsubEvent = | 'content-loaded' | 'flip' | 'jump' + | 'local.dev.import.book' | 'notify-app.set-read' | 'palantir.toggle' | 'ply' @@ -51,7 +52,12 @@ export type PubsubEvent = | 'top.toggle.user_tag' | 'zen'; -export type PubsubOneTimeEvent = 'dialog.polyfill' | 'socket.hasConnected'; +export type PubsubOneTimeEvent = + | 'dialog.polyfill' + | 'socket.hasConnected' + | 'local.images.ready' + | 'local.gameDb.ready' + | 'local.bots.ready'; export type PubsubCallback = (...data: any[]) => void; @@ -73,23 +79,23 @@ export class Pubsub { for (const fn of this.allSubs.get(name) || []) fn.apply(null, args); } - after(event: PubsubOneTimeEvent): Promise { + after(event: PubsubOneTimeEvent): Promise { const found = this.oneTimeEvents.get(event); if (found) return found.promise; - const handler = {} as OneTimeHandler; - handler.promise = new Promise(resolve => (handler!.resolve = resolve)); + const handler = {} as OneTimeHandler; + handler.promise = new Promise(resolve => (handler!.resolve = resolve)); this.oneTimeEvents.set(event, handler); return handler.promise; } - complete(event: PubsubOneTimeEvent): void { + complete(event: PubsubOneTimeEvent, value?: T): void { const found = this.oneTimeEvents.get(event); if (found) { - found.resolve?.(); + found.resolve?.(value); found.resolve = undefined; - } else this.oneTimeEvents.set(event, { promise: Promise.resolve() }); + } else this.oneTimeEvents.set(event, { promise: Promise.resolve(value) }); } past(event: PubsubOneTimeEvent): boolean { @@ -99,7 +105,7 @@ export class Pubsub { export const pubsub: Pubsub = new Pubsub(); -interface OneTimeHandler { - promise: Promise; - resolve?: () => void; +interface OneTimeHandler { + promise: Promise; + resolve?: (value: T) => void; } diff --git a/ui/common/src/snabbdom.ts b/ui/common/src/snabbdom.ts index 962698264a65e..08916fff07c07 100644 --- a/ui/common/src/snabbdom.ts +++ b/ui/common/src/snabbdom.ts @@ -78,3 +78,5 @@ export function looseH(sel: string, dataOrKids?: VNodeData | VNodeKids, kids?: V return snabH(sel, filterKids(dataOrKids as VNodeKids)); else return snabH(sel, dataOrKids as VNodeData); } + +//export function snabHook((vnode)) diff --git a/ui/dasher/src/dasher.ts b/ui/dasher/src/dasher.ts index 4b24c9b4ad1df..08e46352a56d4 100644 --- a/ui/dasher/src/dasher.ts +++ b/ui/dasher/src/dasher.ts @@ -1,11 +1,16 @@ -import type { Redraw } from 'common/snabbdom'; +import type { Redraw, MaybeVNode } from 'common/snabbdom'; import { DasherCtrl } from './ctrl'; -import { json as xhrJson } from 'common/xhr'; +import { json as xhrJson, text as xhrText } from 'common/xhr'; import { spinnerVdom, spinnerHtml } from 'common/spinner'; import { init as initSnabbdom, type VNode, classModule, attributesModule, h } from 'snabbdom'; +import { frag } from 'common'; const patch = initSnabbdom([classModule, attributesModule]); +let pollsElPromise: Promise; +let lastBoard: string; +let lastPieces: string; + export function load(): Promise { return site.asset.loadEsm('dasher'); } @@ -32,3 +37,51 @@ export default async function initModule(): Promise { return ctrl; } + +function board(): string { + return document.querySelector('#main-wrap')?.classList.contains('is3d') + ? document.body.dataset.board3d! + : document.body.dataset.board!; +} + +function pieceSet(): string { + return document.querySelector('#main-wrap')?.classList.contains('is3d') + ? document.body.dataset.pieceSet3d! + : document.body.dataset.pieceSet!; +} + +async function loadAsk(boards: any, pieceSets: any): Promise { + lastBoard = board(); + lastPieces = pieceSet(); + const bid = boards.find((b: any) => b._id === lastBoard).id; + const pid = pieceSets.find((p: any) => p._id === lastPieces).id; + if (!bid || !pid) return frag('
'); + const [bask, pask] = await Promise.all([bid, pid].map(id => xhrText('/ask/' + id))); + + return frag(`
+
${bask}
+
${pask}
+
`); +} + +export function dasherPolls(boards: any, pieces: any /*, redraw: () => void*/): MaybeVNode { + if (!boards || !pieces) return undefined; + const newBoard = board(); + const newPieces = pieceSet(); + if (lastBoard !== newBoard || lastPieces !== newPieces) { + pollsElPromise = loadAsk(boards, pieces); + } + console.log('heyo!'); + return h('div#dasher-polls', { + key: `${newBoard}-${newPieces}`, + hook: { + insert: async v => { + if (!(v.elm instanceof HTMLElement)) return; + v.elm.append(await pollsElPromise); + await Promise.all([site.asset.loadEsm('bits.ask', { init: {} }), site.asset.loadCssPath('bits.ask')]); + v; + console.log('inserted'); + }, + }, + }); +} diff --git a/ui/game/package.json b/ui/game/package.json index 0c402af17345d..d3fd51190689a 100644 --- a/ui/game/package.json +++ b/ui/game/package.json @@ -18,6 +18,7 @@ "./*": "./src/*.ts" }, "dependencies": { - "common": "workspace:*" + "common": "workspace:*", + "chess": "workspace:*" } } diff --git a/ui/game/src/game.ts b/ui/game/src/game.ts index c33f397fd5dec..8dcf919f12dbd 100644 --- a/ui/game/src/game.ts +++ b/ui/game/src/game.ts @@ -1,9 +1,9 @@ import type { GameData, Player } from './interfaces'; -import { finished, aborted, ids } from './status'; +import { finished, aborted, status } from './status'; export * from './interfaces'; -export const playable = (data: GameData): boolean => data.game.status.id < ids.aborted && !imported(data); +export const playable = (data: GameData): boolean => data.game.status.id < status.aborted && !imported(data); export const isPlayerPlaying = (data: GameData): boolean => playable(data) && !data.player.spectator; diff --git a/ui/game/src/interfaces.ts b/ui/game/src/interfaces.ts index 8f646a57ba048..d992134bc3fc2 100644 --- a/ui/game/src/interfaces.ts +++ b/ui/game/src/interfaces.ts @@ -41,6 +41,8 @@ export interface Status { name: StatusName; } +export * from './status'; + export type StatusName = | 'created' | 'started' @@ -198,3 +200,12 @@ export interface MaterialDiff { white: MaterialDiffSide; black: MaterialDiffSide; } + +export interface RoundStep { + ply: Ply; + fen: FEN; + san: San; + uci: Uci; + check?: boolean; + crazy?: Record; +} diff --git a/ui/game/src/status.ts b/ui/game/src/status.ts index 7ea74db4cefd7..7a2c1ca9112ab 100644 --- a/ui/game/src/status.ts +++ b/ui/game/src/status.ts @@ -2,7 +2,7 @@ import type { GameData, StatusName, Status } from './interfaces'; // https://github.com/lichess-org/scalachess/blob/master/core/src/main/scala/Status.scala -export const ids: { [name in StatusName]: number } = { +export const status: { [name in StatusName]: number } = { created: 10, started: 20, aborted: 25, @@ -18,12 +18,12 @@ export const ids: { [name in StatusName]: number } = { variantEnd: 60, }; -export const statusOf = (name: StatusName): Status => ({ id: ids[name], name }); +export const statusOf = (name: StatusName): Status => ({ id: status[name], name }); -export const started = (data: GameData): boolean => data.game.status.id >= ids.started; +export const started = (data: GameData): boolean => data.game.status.id >= status.started; -export const finished = (data: GameData): boolean => data.game.status.id >= ids.mate; +export const finished = (data: GameData): boolean => data.game.status.id >= status.mate; -export const aborted = (data: GameData): boolean => data.game.status.id === ids.aborted; +export const aborted = (data: GameData): boolean => data.game.status.id === status.aborted; export const playing = (data: GameData): boolean => started(data) && !finished(data) && !aborted(data); diff --git a/ui/learn/src/sound.ts b/ui/learn/src/sound.ts index 96149d8d2c6f6..fbe68a7798864 100644 --- a/ui/learn/src/sound.ts +++ b/ui/learn/src/sound.ts @@ -10,3 +10,4 @@ export const levelEnd = make('other/energy3'); export const stageStart = make('other/guitar'); export const stageEnd = make('other/gewonnen'); export const failure = make('other/no-go'); +// diff --git a/ui/lobby/package.json b/ui/lobby/package.json index 7c5d74acbbf18..6ae65c1f66bf4 100644 --- a/ui/lobby/package.json +++ b/ui/lobby/package.json @@ -10,7 +10,8 @@ "common": "workspace:*", "dasher": "workspace:*", "debounce-promise": "^3.1.2", - "game": "workspace:*" + "game": "workspace:*", + "local": "workspace:*" }, "build": { "bundle": { diff --git a/ui/lobby/src/ctrl.ts b/ui/lobby/src/ctrl.ts index fd5c882ffbc0c..11d9497698103 100644 --- a/ui/lobby/src/ctrl.ts +++ b/ui/lobby/src/ctrl.ts @@ -25,6 +25,7 @@ import SetupController from './setupCtrl'; import { storage, type LichessStorage } from 'common/storage'; import { pubsub } from 'common/pubsub'; import { wsPingInterval } from 'common/socket'; +import type { LocalEnv, LiteGame } from 'local'; export default class LobbyController { data: LobbyData; @@ -42,6 +43,8 @@ export default class LobbyController { pools: Pool[]; filter: Filter; setupCtrl: SetupController; + local: LocalEnv; + localGames: LiteGame[] = []; private poolInStorage: LichessStorage; private flushHooksTimeout?: number; @@ -58,7 +61,12 @@ export default class LobbyController { this.playban = opts.playban; this.filter = new Filter(storage.make('lobby.filter'), this); this.setupCtrl = new SetupController(this); - + site.asset.loadEsm('local.db').then(async local => { + this.local = local; + this.localGames = await local.db.ongoing(); + console.log(this.localGames); + this.redraw(); + }); hookRepo.initAll(this); seekRepo.initAll(this); this.socket = new LobbySocket(opts.socketSend, this); @@ -138,7 +146,6 @@ export default class LobbyController { this.socket.realTimeIn(); } else if (this.tab === 'pools' && this.poolMember) this.poolIn(); }); - window.addEventListener('beforeunload', () => this.leavePool()); } diff --git a/ui/lobby/src/view/playing.ts b/ui/lobby/src/view/playing.ts index eaf9919ef076a..d724aac0c7952 100644 --- a/ui/lobby/src/view/playing.ts +++ b/ui/lobby/src/view/playing.ts @@ -3,6 +3,7 @@ import type LobbyController from '../ctrl'; import type { NowPlaying } from '../interfaces'; import { initMiniBoard } from 'common/miniBoard'; import { timeago } from 'common/i18n'; +import { myUsername } from 'common'; function timer(pov: NowPlaying) { const date = Date.now() + pov.secondsLeft! * 1000; @@ -20,9 +21,8 @@ function timer(pov: NowPlaying) { } export default function (ctrl: LobbyController) { - return h( - 'div.now-playing', - ctrl.data.nowPlaying.map(pov => + return h('div.now-playing', [ + ...ctrl.data.nowPlaying.map(pov => h('a.' + pov.variant.key, { key: `${pov.gameId}${pov.lastMove}`, attrs: { href: '/' + pov.fullId } }, [ h('span.mini-board.cg-wrap.is2d', { attrs: { 'data-state': `${pov.fen},${pov.orientation || pov.color},${pov.lastMove}` }, @@ -43,5 +43,19 @@ export default function (ctrl: LobbyController) { ]), ]), ), - ); + ...ctrl.localGames.map( + g => + !(g.white && g.black) && + h('a', { key: g.id, attrs: { href: '/local#id=' + g.id } }, [ + h('span.mini-board.cg-wrap.is2d', { + attrs: { 'data-state': `${g.fen},${g.turn},${g.lastMove}` }, + hook: { insert: vnode => initMiniBoard(vnode.elm as HTMLElement) }, + }), + h('span.meta', [ + g.white ? ctrl.local.nameOf(g.white) : g.black ? ctrl.local.nameOf(g.black) : myUsername(), + h('span.indicator', !g[g.turn] ? [i18n.site.yourTurn] : h('span', '\xa0')), + ]), + ]), + ), + ]); } diff --git a/ui/lobby/src/view/setup/modal.ts b/ui/lobby/src/view/setup/modal.ts index d86ae5ff76661..a826ea7507c9a 100644 --- a/ui/lobby/src/view/setup/modal.ts +++ b/ui/lobby/src/view/setup/modal.ts @@ -64,4 +64,5 @@ const views = { createButtons(ctrl), ]), ], + local: (): MaybeVNodes => [], }; diff --git a/ui/lobby/src/view/table.ts b/ui/lobby/src/view/table.ts index 8c463eaf0c11d..59e4f05fa49d9 100644 --- a/ui/lobby/src/view/table.ts +++ b/ui/lobby/src/view/table.ts @@ -19,13 +19,23 @@ export default function table(ctrl: LobbyController) { ['hook', i18n.site.createAGame, hookDisabled], ['friend', i18n.site.playWithAFriend, hasOngoingRealTimeGame], ['ai', i18n.site.playWithTheMachine, hasOngoingRealTimeGame], - ].map(([gameType, text, disabled]: [Exclude, string, boolean]) => + ['local', i18n.site.playOffline, hasOngoingRealTimeGame], + ].map(([gameType, text, disabled]: [GameType, string, boolean]) => h( `button.button.button-metal.config_${gameType}`, { class: { active: ctrl.setupCtrl.gameType === gameType, disabled }, attrs: { type: 'button' }, - hook: disabled ? {} : bind('click', () => ctrl.setupCtrl.openModal(gameType), ctrl.redraw), + hook: disabled + ? {} + : bind( + 'click', + () => { + if (gameType === 'local') site.asset.loadEsm('local.setup'); + else ctrl.setupCtrl.openModal(gameType); + }, + ctrl.redraw, + ), }, text, ), diff --git a/ui/lobby/src/view/tabs.ts b/ui/lobby/src/view/tabs.ts index 5c531170bcca3..f56fb5a469dd4 100644 --- a/ui/lobby/src/view/tabs.ts +++ b/ui/lobby/src/view/tabs.ts @@ -16,8 +16,8 @@ function tab(ctrl: LobbyController, key: Tab, active: Tab, content: MaybeVNodes) } export default function (ctrl: LobbyController) { - const nbPlaying = ctrl.data.nbNowPlaying, - myTurnPovsNb = ctrl.data.nowPlaying.filter(p => p.isMyTurn).length, + const nbPlaying = ctrl.data.nbNowPlaying + ctrl.localGames.length, + myTurnPovsNb = ctrl.data.nowPlaying.filter(p => p.isMyTurn).length + ctrl.localGames.length, active = ctrl.tab, isBot = ctrl.me?.isBot; return [ diff --git a/ui/lobby/tsconfig.json b/ui/lobby/tsconfig.json index 082cd52f938de..ca0d92f5afc77 100644 --- a/ui/lobby/tsconfig.json +++ b/ui/lobby/tsconfig.json @@ -4,6 +4,7 @@ "references": [ { "path": "../common/tsconfig.json" }, { "path": "../dasher/tsconfig.json" }, - { "path": "../game/tsconfig.json" } + { "path": "../game/tsconfig.json" }, + { "path": "../local/tsconfig.json" } ] } diff --git a/ui/local/bin/export-bots.js b/ui/local/bin/export-bots.js new file mode 100644 index 0000000000000..2a1b15968b407 --- /dev/null +++ b/ui/local/bin/export-bots.js @@ -0,0 +1,11 @@ +const docs = []; + +db.local_bots + .aggregate([ + { $sort: { version: -1 } }, + { $group: { _id: '$uid', doc: { $first: '$$ROOT' } } }, + { $replaceRoot: { newRoot: '$doc' } }, + ]) + .forEach(doc => docs.push(doc)); + +print(JSON.stringify(docs, null, 2)); diff --git a/ui/local/css/_asset-dialog.scss b/ui/local/css/_asset-dialog.scss new file mode 100644 index 0000000000000..0eb7ac6713812 --- /dev/null +++ b/ui/local/css/_asset-dialog.scss @@ -0,0 +1,128 @@ +.asset-dialog { + @extend %flex-column; + align-items: stretch; + min-width: 704px; + width: 70vw; + height: 80vh; + + &:not(.chooser) { + width: 90vw; + height: 90vh; + } + .asset-grid { + display: grid; + width: 100%; + justify-content: center; + grid-template-columns: repeat(auto-fill, minmax(208px, 240px)); + grid-auto-rows: auto; + gap: 1em; + } + .tab { + justify-content: center; + font-size: 1.2em; + font-weight: bold; + } + img { + width: 176px; + aspect-ratio: 1/1; + border-radius: 8px; + } + .asset-item { + @extend %flex-column; + position: relative; + align-items: center; + gap: 0.5em; + padding: 1.2em; + border-radius: 8px; + box-shadow: 0 0 5px 0 $c-border; + background: $c-bg-zebra; + + .asset-preview { + width: min-content; + } + &.local-only { + background: $c-bg-low; + } + .upper-right { + z-index: $z-above-dialog-14; + top: -4px; + right: -4px; + } + .upper-left { + z-index: $z-above-dialog-14; + top: -4px; + left: -4px; + } + .asset-label { + font-size: 0.9em; + font-weight: bold; + } + input.asset-label { + text-align: center; + background-color: transparent; + border: none; + outline: none; + padding: 2px 4px; + &[disabled] { + pointer-events: none; + } + &:hover { + background-color: $m-primary_bg--mix-40; + } + &:focus:not([disabled]) { + background-color: $c-bg-page; //$m-primary_bg--mix-60; + outline: 1px solid; + } + } + .preview-sound { + @extend %flex-center-nowrap; + text-transform: none; + color: $c-font-dim; + &::before { + margin-inline-end: 0.5em; + font-size: 24px; + } + } + } + .chooser .asset-item, + .asset-item[data-action='add'] { + &:hover { + cursor: pointer; + } + &:hover:not(:has(button:hover)) { + background: $m-bg_high--lighten-11; + outline: $c-primary 2px dashed; + } + } +} + +div.import-dialog { + &, + > div { + @extend %flex-column; + gap: 2em; + } + .options { + .name { + width: 160px; + } + .ply { + width: 50px; + } + } + .progress { + .bar { + height: 16px; + width: 0%; + border: $border; + background-color: $c-primary; + transition: width 0.3s; + } + .text { + color: $c-font-dim; + } + } + button { + align-self: end; + } +} diff --git a/ui/local/css/_base-dialog.scss b/ui/local/css/_base-dialog.scss new file mode 100644 index 0000000000000..08093b018fd23 --- /dev/null +++ b/ui/local/css/_base-dialog.scss @@ -0,0 +1,92 @@ +$scale-factor: var(---scale-factor); + +html.transp dialog::before { + backdrop-filter: blur(18px); +} + +.base-view { + ---white-image-url: url(../lifat/bots/image/white-torso.webp); + ---black-image-url: url(../lifat/bots/image/black-torso.webp); + ---scale-factor: 1; // ---scale-factor adjusted in javascript + + @extend %flex-column; + + width: 100vw; + height: 80vh; + background-color: $c-body-gradient; + border-radius: 5px; + + // @include if-transp { + // background-color: unset; + // } +} + +dialog .with-cards { + padding: calc(2em * var(---scale-factor)); + padding-bottom: 0; + flex-grow: 1; + overflow: hidden; + + .card { + z-index: $z-above-dialog-13; + + &.selected { + z-index: $z-dialog-11; + } + } + + .player { + @extend %flex-column; + position: relative; + width: 100%; + border-radius: 10px; + + &::before { + position: absolute; + content: ''; + width: 100%; + height: 100%; + background-size: contain; + background-repeat: no-repeat; + background-position: center; + } + + &[data-color='white'] { + background-color: hsl(37, 12%, 92%); + &::before { + background-image: var(---white-image-url); + } + } + &[data-color='black'] { + background-color: hsl(37, 5%, 23%); + &::before { + background-image: var(---black-image-url); + } + } + } + + .placard { + position: absolute; + bottom: calc(40px * $scale-factor * $scale-factor); + z-index: $z-above-dialog-14; + left: 50%; + transform: translateX(-50%); + width: max-content; + max-width: 90%; + margin-left: auto; + margin-right: auto; + padding: calc(10px * $scale-factor) calc(20px * $scale-factor * $scale-factor); + text-align: center; + font-size: calc(0.4em + 0.8em * $scale-factor); + opacity: 0.87; + border-radius: 5px; + background-color: #242424; + border: 1px solid #444; + color: white; + &[data-color='white'] { + background-color: $c-body-gradient; + color: $c-font-clearer; + border: 1px solid $c-border; + } + } +} diff --git a/ui/local/css/_edit-dialog.scss b/ui/local/css/_edit-dialog.scss new file mode 100644 index 0000000000000..f5278f230a78a --- /dev/null +++ b/ui/local/css/_edit-dialog.scss @@ -0,0 +1,354 @@ +#image-powertip { + @extend %box-radius-force, %popup-shadow; + width: 192px; + height: 192px; + display: none; + position: absolute; + z-index: $z-powertip-120; +} + +.edit-view { + height: 90vh; + max-width: min(1600px, 96vw); + padding-bottom: 0; + background-color: $c-bg-zebra; + + button { + padding: 0.8em 0.8em; + } + textarea { + white-space: pre-wrap; + width: 100%; + resize: none; + } + hr { + margin: 0; + flex: auto; + } + label { + @extend %flex-center-nowrap; + flex: initial; + gap: 12px; + } + canvas { + min-height: 300px; + } + fieldset { + @extend %flex-column; + align-items: stretch; + border: 1px solid $c-border; + border-radius: $box-radius-size; + padding: 0.75em 0 1em; + row-gap: 0.5em; + > div { + @extend %flex-column; + align-items: stretch; + row-gap: 0.5em; + } + &.disabled { + padding: 0; + border-left: none; + border-right: none; + border-bottom: none; + } + } + legend { + @extend %flex-center; + z-index: $z-above-dialog-12; // get around safari z-index bug in legend / fieldset + align-self: start; + text-align: left; + margin: 0 0.5em; + > label { + font-weight: bold; + } + } + .deck { + @extend %flex-between-nowrap; + grid-area: deck; + width: 100%; + height: 100%; + overflow: hidden; + + .placeholder { + margin-inline-start: -2em; + height: 100%; + aspect-ratio: 1/1; + } + } + .deck-legend { + padding: 0.5em; + row-gap: 0; + label { + @extend %flex-around; + flex-wrap: nowrap; + font-size: 12px; + padding: 4px 12px; + color: black; + position: relative; + height: 32px; + } + } +} + +.edit-bot { + display: grid; + height: 100%; + column-gap: 1em; + grid-template-columns: min(36vh, 30vw) min(480px, 33vw) 1fr; + grid-template-rows: 74% auto fit-content(0); + grid-template-areas: + 'info behavior filters' + 'deck behavior filters' + 'deck actions actions'; + + [data-bot-action='unrate-one'] { + color: $c-brag; + padding: 0; + background: none; + border: none; + outline: none; + &:hover { + color: $c-bad; + } + } + [data-action='remove'] { + cursor: pointer; + color: $c-bad; + &:hover { + color: $m-bad--lighten-11; + } + } + [data-action='add'] { + cursor: pointer; + color: $c-secondary; + &:hover { + color: $m-secondary--lighten-11; + } + &.disabled { + color: $c-border; + pointer-events: none; + } + } + + .bot-card { + @extend %flex-column; + border-radius: 10px; + gap: 1em; + grid-area: info; + + div { + padding: 0; + } + .uid { + z-index: $z-above-dialog-14; + font-size: 1.5em; + font-weight: bold; + font-style: italic; + padding: 0 4px; + color: $c-font-dim; + } + textarea { + text-align: center; + } + .player { + ---scale-factor: 0.8; + aspect-ratio: 1/1; + cursor: pointer; + //background-color: $c-bg-zebra2; + } + .placard { + padding: 0; + width: 90%; + border: none; + background: none; + } + } + .bot-actions { + @extend %flex-wrap; + justify-content: end; + align-items: start; + gap: 1em; + padding: 1em; + } + + .bot-info { + justify-content: space-between; + text-align: center; + + #info_name { + max-width: 50%; + flex: 1 1 auto; + padding: 0; + } + } + .setting { + @extend %flex-center-nowrap; + padding: 0 1em; + width: 100%; + gap: 1em; + input[data-type] { + width: 60px; + flex-grow: 1; + } + &.disabled > input[data-type] { + opacity: 0.5; + } + } + .books, + .sound-events { + input[type='text'] { + max-width: 5ch; + } + } + .books { + .btn-rack { + gap: 4px; + border: none; + button { + padding: 0.6em; + border: 1px solid $c-border; + } + button.active { + border-color: $c-primary; + outline: 4px solid $c-primary; + } + button[data-color='white'] { + border-radius: 0 6px 6px 0; + background: $c-paper; + } + button[data-color='black'] { + border-radius: 6px 0 0 6px; + background: $c-dark; + } + } + } + .sound-events { + padding: 0.5em 1em; + .hide-disabled { + width: 100%; + } + } + .sound-event > span > label { + width: 100%; + } + .total-chance.invalid { + color: $c-bad; + } + fieldset.sound { + @extend %flex-between; + flex-wrap: nowrap; + padding: 0.25em 0.5em 0.5em; + label { + gap: 6px; + } + } + .behavior, + .filters { + overflow-y: auto; + overflow-x: visible; + scrollbar-width: thin; + scrollbar-color: $c-primary transparent; + + &::-webkit-scrollbar-track { + background: transparent; + } + + &::-webkit-scrollbar-thumb { + background-color: $c-primary; + background-clip: content-box; + } + + &::-webkit-scrollbar-button { + display: none; + } + } + .behavior { + @extend %flex-column; + grid-area: behavior; + padding: 0 1em; + min-width: 300px; + justify-content: start; + gap: 1em; + } + .filters { + @extend %flex-column; + min-width: 0; + gap: 1em; + grid-area: filters; + } + .filter { + @extend %flex-column; + border-radius: $box-radius-size; + label { + flex-flow: row nowrap; + height: 32px; + padding-inline-start: 0.5em; + } + .graph-wrapper { + border: 1px solid $c-border-light; + position: relative; + border-radius: $box-radius-size 0 $box-radius-size $box-radius-size; + background: $c-paper; + cursor: pointer; + padding: 1em 1em 0.5em 0.5em; + &.hidden { + background: $m-paper_dimmer--mix-50; + canvas { + display: none; + } + } + } + .btn-rack { + width: 100%; + border: none; + } + .facet { + @extend %flex-center-nowrap; + z-index: $z-above-dialog-12; + justify-content: center; + background: $m-paper_dimmer--mix-50; + flex: auto; + height: 32px; + user-select: none; + color: $c-font; + white-space: nowrap; + gap: 0.5em; + border: 1px solid $c-border-light; + border-bottom: none; + border-radius: $box-radius-size $box-radius-size 0 0; + margin-inline-end: -1px; + + input { + padding-inline-end: 0.5em; + } + + &:last-child { + margin-inline-end: 0; + } + &.active { + background: $c-paper; + margin-bottom: -1px; + padding-bottom: 1px; + height: 33px; + color: #555; + cursor: default; + } + + &:hover { + //:not(.active) { + cursor: pointer; + //&:has(input:checked) { + background: $m-paper_dimmer--mix-75; + color: $c-font-clearer; + //} + } + } + } + .global-actions { + display: flex; + justify-content: end; + padding: 1em 0; + grid-area: actions; + gap: 1em; + } +} diff --git a/ui/local/css/_hand-of-cards.scss b/ui/local/css/_hand-of-cards.scss new file mode 100644 index 0000000000000..733fc19835b41 --- /dev/null +++ b/ui/local/css/_hand-of-cards.scss @@ -0,0 +1,80 @@ +.with-cards { + position: relative; + + .card { + user-select: none; + cursor: pointer; + position: absolute; + border-radius: 6px; + background-color: #f0f0f0; + border: 1px solid gray; + transition: + transform 0.3s, + background-color 0.2s; + img { + width: calc(192px * var(---scale-factor, 1)); + } + label { + font-weight: bold; + font-size: 1.3em; + text-align: center; + position: absolute; + top: -32px; + left: 0; + right: 0; + display: none; + } + &.left label { + text-align: start; + top: 50%; + left: 110%; + } + // &.dragging { + // transition: 0.05s; + // } + &.selected { + pointer-events: none; + border: none; + } + &.pull:not(&.selected) { + label { + display: block; + } + } + &:focus { + background-color: $c-bad; + outline: $c-primary solid 2px; + } + } + + .card-container { + position: absolute; + overflow: visible; + top: 0; + left: 0; + width: 0; + height: 0; + &.no-transition .card { + transition: none; + } + } +} + +.z-remove { + display: none; + z-index: $z-above-dialog-14; + position: absolute; + height: 20px; + width: 20px; + top: 5px; + right: 5px; + + cursor: pointer; + filter: grayscale(1); + &.show { + display: block; + } + &:hover { + filter: saturate(1); + } +} diff --git a/ui/local/css/_history-dialog.scss b/ui/local/css/_history-dialog.scss new file mode 100644 index 0000000000000..0d963de6e4e5d --- /dev/null +++ b/ui/local/css/_history-dialog.scss @@ -0,0 +1,81 @@ +.history-dialog { + display: grid; + grid-template-columns: 1fr 3fr; + grid-template-rows: 1fr auto; + width: 1080px; + height: 800px; + padding-bottom: 0; + gap: 0; + + .versions { + @extend %flex-column; + overflow: auto; + background: $c-bg-zebra; + border: $c-border 1px solid; + border-right: none; + border-radius: 10px 0 0 10px; + } + .version { + @extend %flex-center-nowrap; + border-bottom: 1px solid $c-border; + padding: 0.5em; + gap: 1em; + cursor: pointer; + .author { + @extend %flex-between-nowrap; + width: 65%; + font-size: 1em; + color: $c-font; + } + .version-number { + @extend %flex-center-nowrap; + width: 20%; + font-size: 1.2em; + font-weight: bold; + } + span { + pointer-events: none; + } + &:hover { + background-color: $m-primary--alpha-30; + } + &.selected { + background-color: $m-clas--alpha-30; + } + } + .json { + display: block; + text-align: left; + border-radius: 0 10px 10px 0; + padding: 1em; + border: 1px solid $c-border; + background: $c-paper; + color: $c-dark; + width: 100%; + height: 100%; + overflow: auto; + font-family: monospace; + font-size: 11px; + white-space: pre; + + span { + all: unset; + display: inline; + } + .selected { + background-color: $m-clas--alpha-30; + } + .hovered { + background-color: $m-primary--alpha-30; + } + .selected + .hovered, + .hovered + .selected { + margin-left: 5px; + } + } + .actions { + @extend %flex-center; + height: 64px; + justify-content: end; + } +} diff --git a/ui/local/css/_json-dialog.scss b/ui/local/css/_json-dialog.scss new file mode 100644 index 0000000000000..54a4d98bb86a9 --- /dev/null +++ b/ui/local/css/_json-dialog.scss @@ -0,0 +1,28 @@ +.json-dialog { + @extend %flex-column; + width: 1080px; + height: 800px; + padding-bottom: 0; + //gap: 0; + + .json { + display: block; + text-align: left; + border-radius: 0 10px 10px 0; + padding: 1em; + border: 1px solid $c-border; + background: $c-paper; + color: $c-dark; + width: 100%; + height: 100%; + overflow: auto; + font-family: monospace; + font-size: 11px; + white-space: pre; + } + .actions { + @extend %flex-center; + height: 64px; + justify-content: end; + } +} diff --git a/ui/local/css/_local.dev.scss b/ui/local/css/_local.dev.scss new file mode 100644 index 0000000000000..eb7ab08a8e2a1 --- /dev/null +++ b/ui/local/css/_local.dev.scss @@ -0,0 +1,312 @@ +.dev-view { + @extend %flex-column; + gap: 1em; + + span { + @extend %flex-center-nowrap; + white-space: nowrap; + gap: 1em; + } + textarea, + select, + input { + padding: 3px 5px; + &.invalid { + background-color: $m-bg_bad--mix-80; + } + } + select, + input:not([type='text']) { + cursor: pointer; + } + *:focus-visible, + input[type='range']:focus-visible { + outline: 2px solid $c-font; + outline-offset: -2px; + } + .disabled label > span { + opacity: 0.5; + } + .disabled .hide-disabled { + display: none; + } + .preview-sound::before { + color: $c-secondary; + &:hover { + color: $m-secondary--lighten-11; + } + } + .icon-btn { + padding: 6px; + } + .upper-right { + position: absolute; + font-size: 1.25em; + top: 0; + right: 0; + color: $c-bad; + cursor: pointer; + &.show { + display: block; + } + &:hover { + color: $m-bad--lighten-11; + filter: saturate(1); + } + } + .upper-left { + position: absolute; + font-size: 1.25em; + top: 0; + left: 0; + color: $c-primary; + cursor: pointer; + &.show { + display: block; + } + &:hover { + color: $m-primary--lighten-11; + filter: saturate(1); + } + } + .clean { + background-color: white; + } + .dirty { + &::after { + content: ''; + position: absolute; + right: 4px; + top: 4px; + border-radius: 100%; + border: 5px solid $c-bad; + } + } + .local-only { + background-color: $m-brag_white--mix-25; + } + .local-changes { + background-color: $m-clas_white--mix-28; + } + .upstream-changes { + background-color: $m-primary_white--mix-30; + } +} + +main.round { + ---col3-uniboard-side-min: 300px; +} + +.dev-side { + display: grid; + grid-template-columns: 1fr; + grid-template-rows: min-content min-content 1fr min-content; + height: 100%; + width: 100%; + flex-flow: column; + justify-content: stretch; + + input:not([type='text']) { + cursor: pointer; + } + span, + h3 { + @extend %flex-between-nowrap; + gap: 1em; + } + + label { + @extend %flex-center-nowrap; + gap: 0.5em; + } + button { + padding: 0.5em; + } + .player { + @extend %flex-center-nowrap; + position: relative; + padding-inline-end: 8px; + border-radius: 5px; + border: 1px solid $c-border; + align-content: center; + width: 100%; + font-size: 1em; + gap: 0.5em; + img { + width: 10vh; + height: 10vh; + } + &:hover { + cursor: pointer; + } + &[data-color='white'] { + background-color: #eee; + color: #333; + &:hover:not(:has(button:hover)) { + background-color: hsl(209, 79%, 85%); + } + } + &[data-color='black'] { + background-color: #333; + color: white; + &:hover:not(:has(button:hover)) { + background-color: hsl(209, 29%, 20%); + } + } + .select-bot { + width: 60%; + } + .stats { + @extend %flex-column; + align-items: center; + flex: auto; + span { + @extend %flex-center-nowrap; + justify-content: center; + font-size: 1.2em; + gap: 0.5em; + } + } + .upper-right { + background: none; + border: none; + outline: none; + } + .bot-actions { + @extend %flex-column; + gap: 1em; + } + } + + .spacer { + flex: 1 1 auto; + } + .fen { + font-family: monospace; + font-size: 12px; + &::placeholder { + color: #888; + } + width: 100%; + } + .num-games { + width: 50px; + } + .results-action { + flex: auto; + } + .board-action::before { + color: $c-font; + &:hover { + color: $c-font-clearer; + } + } + .reset::before { + @extend %data-icon; + content: $licon-X; + color: $c-bad; + &:hover { + color: $m-bad--lighten-11; + } + } + .play-pause { + margin-inline-start: auto; + &.play::before { + @extend %data-icon; + color: $c-secondary; + content: $licon-PlayTriangle; + &:hover { + color: $m-secondary--lighten-4; + } + } + &.pause::before { + @extend %data-icon; + color: $c-bad; + content: $licon-Pause; + &:hover { + color: $m-bad--lighten-11; + } + } + } +} +.round-robin-dialog { + @extend %flex-column; + gap: 1em; + ul { + display: grid; + grid-auto-flow: column; + grid-auto-columns: minmax(160px, 1fr); + grid-template-rows: repeat(12, 1fr); + gap: 2px; + } + li { + justify-self: start; + } + input[type='checkbox'] { + margin-inline-end: 0.5em; + } + span { + justify-content: center; + } +} +.dev-progress { + position: relative; + padding: 0.5em; + width: 100%; + background: $c-bg-high; + + .results { + display: grid; + grid-template-columns: repeat(2, 1fr); + grid-auto-rows: 1fr; + gap: 2px; + font-size: 12px; + font-family: monospace; + > div { + justify-self: start; + } + } +} +.dev-dashboard { + @extend %flex-column, %box-radius-bottom; + z-index: z(mz-menu); + width: 100%; + background: $c-bg-high; + gap: 1em; + padding: 1.5em 1em; + > hr { + margin: 5px; + } +} + +.new-opponent { + display: none; // file://./../../round/src/view/button.ts +} + +@media (max-width: at-most($x-large)) { + div#main-wrap { + display: flex; + flex-flow: column; + align-items: center; + justify-content: center; + height: 80vh; + width: 100vw; + > * { + display: none; + } + &::before { + content: ''; + display: block; + background-image: url(../lifat/bots/image/not-worthy.webp); + background-size: contain; + background-repeat: no-repeat; + min-width: 320px; + min-height: 320px; + } + &::after { + content: '\A Your device is not worthy.'; + font-size: 32px; + text-align: center; + } + } +} diff --git a/ui/local/css/_local.scss b/ui/local/css/_local.scss new file mode 100644 index 0000000000000..0a6e630d72d35 --- /dev/null +++ b/ui/local/css/_local.scss @@ -0,0 +1,75 @@ +#bot-view { + display: flex; + flex-flow: column nowrap; + overflow: hidden; + max-height: var(---cg-height); + + #bot-content { + flex: 1 1 auto; + overflow: auto; + } +} + +.fancy-bot { + @extend %flex-center; + + img { + width: 80px; + } + span { + @extend %flex-center-nowrap; + margin-inline-end: auto; + } +} + +#main-wrap.paused cg-container::after { + content: 'PAUSED'; + cursor: pointer; + z-index: 12; + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + font-size: 6em; + font-weight: 800; + background-color: $m-dark--fade-40; + color: $c-paper; + display: flex; + align-items: center; + justify-content: center; + text-align: center; +} + +#bot-content .fancy-bot { + &:nth-child(odd) { + background: $c-bg-box; + } + &:nth-child(even) { + background: $c-bg-zebra; + } + + &:hover { + background: $m-primary_bg--mix-15; + } + + img { + width: 128px; + } + h2 { + font-size: 1.4em; + font-weight: bold; + color: $c-font-clearer; + } + p { + margin-left: 1em; + } + .overview { + padding: 1em; + display: flex; + justify-content: space-around; + align-self: stretch; + flex: auto; + flex-flow: column; + } +} diff --git a/ui/local/css/_local.setup.scss b/ui/local/css/_local.setup.scss new file mode 100644 index 0000000000000..19dfeb4b88b02 --- /dev/null +++ b/ui/local/css/_local.setup.scss @@ -0,0 +1,118 @@ +body:has(.base-view.setup-view) { + -webkit-user-select: none !important; + user-select: none !important; +} + +.setup-view { + max-width: 640px; + max-height: 800px; + user-select: none; + + div.player { + aspect-ratio: 1; + width: min(70%, 28vh); + } + .vs { + @extend %flex-column; + align-items: center; + gap: 0.5em; + } + .actions { + @extend %flex-around; + align-items: start; + gap: calc(20px * $scale-factor); + + button { + font-size: calc(40px * $scale-factor); + padding: calc(6px * $scale-factor); + color: $c-font; + &:hover { + color: $c-font-clear; + } + } + } + + .switch { + @extend %flex-around; + font-size: 1.5em; + cursor: pointer; + align-items: center; + padding: 0.5em; + border-radius: 10px; + width: min(70%, 28vh); + + &[data-color='white'] { + @extend %metal-light; + color: $c-dark; + &:hover { + @extend %metal-light-hover; + } + } + &[data-color='black'] { + @extend %metal-dark; + color: #ddd; + &:hover { + @extend %metal-dark-hover; + } + } + &::after { + font-family: lichess; + font-size: 32px; + content: $licon-Switch; + } + } + .switch.disabled, + .random.disabled { + pointer-events: none; + } + + .clock { + @extend %flex-column; + gap: 4px; + } + .chin { + @extend %flex-around; + align-items: center; + + padding: 1em; + border-radius: 5px; + border-top: 1px solid $c-border; + background-color: $c-bg-zebra2; + label { + @extend %flex-center; + justify-content: end; + gap: 1ch; + } + select { + padding: 4px; + } + } +} + +@media (max-width: at-most($x-large)) { + div#main-wrap { + display: flex; + flex-flow: column; + align-items: center; + justify-content: center; + height: 80vh; + width: 100vw; + > * { + display: none; + } + &::before { + content: ''; + display: block; + background-image: url(../lifat/bots/image/not-worthy.webp); + background-size: contain; + background-repeat: no-repeat; + min-width: 320px; + min-height: 320px; + } + &::after { + content: '\A Your device is not worthy.'; + font-size: 32px; + text-align: center; + } + } +} diff --git a/ui/local/css/build/local.dev.scss b/ui/local/css/build/local.dev.scss new file mode 100644 index 0000000000000..c7f5749534987 --- /dev/null +++ b/ui/local/css/build/local.dev.scss @@ -0,0 +1,12 @@ +@import '../../../common/css/plugin'; +@import '../../../common/css/form/range'; +@import '../../../common/css/component/tabs-horiz'; + +@import '../hand-of-cards'; +@import '../base-dialog'; +@import '../edit-dialog'; +@import '../asset-dialog'; +@import '../history-dialog'; +@import '../json-dialog'; +@import '../local'; +@import '../local.dev'; diff --git a/ui/local/css/build/local.scss b/ui/local/css/build/local.scss new file mode 100644 index 0000000000000..1c7656b62f6d6 --- /dev/null +++ b/ui/local/css/build/local.scss @@ -0,0 +1,4 @@ +@import '../../../common/css/plugin'; +@import '../../../common/css/form/range'; + +@import '../local'; diff --git a/ui/local/css/build/local.setup.scss b/ui/local/css/build/local.setup.scss new file mode 100644 index 0000000000000..9952c1676a388 --- /dev/null +++ b/ui/local/css/build/local.setup.scss @@ -0,0 +1,7 @@ +@import '../../../common/css/plugin'; +@import '../../../common/css/form/range'; +@import '../../../lobby/css/setup'; + +@import '../hand-of-cards'; +@import '../base-dialog'; +@import '../local.setup'; diff --git a/ui/local/package.json b/ui/local/package.json new file mode 100644 index 0000000000000..25feea7608025 --- /dev/null +++ b/ui/local/package.json @@ -0,0 +1,44 @@ +{ + "name": "local", + "private": true, + "author": "T-Bone Duplexus", + "license": "AGPL-3.0-or-later", + "typings": "types", + "typesVersions": { + "*": { + "*": [ + "dist/*" + ] + } + }, + "dependencies": { + "@types/lichess": "workspace:*", + "bits": "workspace:*", + "chart.js": "4.4.3", + "chess": "workspace:*", + "chessops": "^0.14.0", + "common": "workspace:*", + "fast-diff": "^1.3.0", + "game": "workspace:*", + "json-stringify-pretty-compact": "4.0.0", + "round": "workspace:*", + "zerofish": "^0.0.31" + }, + "scripts": { + "import-bots": "mongoimport --db=lichess --collection=local_bots --drop --jsonArray --file", + "export-bots": "mongosh lichess bin/export-bots.js > ", + "import-assets": "mongoimport --db=lichess --collection=local_assets --drop --jsonArray --file", + "export-assets": "mongoexport --db=lichess --collection=local_assets --jsonArray --out" + }, + "//": [ + "pnpm import-assets ../../../lila-db-seed/spamdb/data/local.assets.json", + "pnpm import-bots ../../../lila-db-seed/spamdb/data/local.bots.json", + "the asset json in lila-db-seed map the hashed asset filenames in lifat/bots" + ], + "build": { + "bundle": "src/**/local.*ts", + "sync": { + "node_modules/zerofish/dist/zerofishEngine.*": "/public/npm" + } + } +} diff --git a/ui/local/src/analyse.ts b/ui/local/src/analyse.ts new file mode 100644 index 0000000000000..e36522c40be6e --- /dev/null +++ b/ui/local/src/analyse.ts @@ -0,0 +1,34 @@ +import { type GameCtrl } from './gameCtrl'; +import * as co from 'chessops'; +import { escapeHtml, frag } from 'common'; + +export function analyse(gameCtrl: GameCtrl): void { + const local = gameCtrl.live; + const root = new co.pgn.Node(); + let node = root; + for (const move of gameCtrl.live.observe()) { + const comments = move.clock ? [co.pgn.makeComment({ clock: move.clock[move.turn] })] : []; + const newNode = new co.pgn.ChildNode({ san: move.san, comments }); + node.children.push(newNode); + node = newNode; + } + const game = { + headers: new Map([ + ['Event', 'Local game'], + ['Site', 'lichess.org'], + ['Date', new Date().toISOString().split('T')[0]], + ['Round', '1'], + ['White', 'Player'], + ['Black', 'Opponent'], + ['Result', local.status.winner ? (local.status.winner === 'white' ? '1-0' : '0-1') : '1/2-1/2'], + ['TimeControl', gameCtrl.clock ? `${gameCtrl.clock.initial}+${gameCtrl.clock.increment}` : 'Unlimited'], + ]), + moves: root, + }; + const pgn = co.pgn.makePgn(game); + console.log(pgn); + const formEl = frag(`
+
`); + document.body.appendChild(formEl); + formEl.submit(); +} diff --git a/ui/local/src/assets.ts b/ui/local/src/assets.ts new file mode 100644 index 0000000000000..a1626042f6032 --- /dev/null +++ b/ui/local/src/assets.ts @@ -0,0 +1,89 @@ +import { type OpeningBook, makeBookFromPolyglot } from 'bits/polyglot'; +import { definedMap } from 'common/algo'; +import { pubsub } from 'common/pubsub'; +import { env } from './localEnv'; + +export type AssetType = 'image' | 'book' | 'sound' | 'net'; + +export class Assets { + net: Map> = new Map(); + book: Map> = new Map(); + + async init(): Promise { + // prefetch stuff here or in service worker install \o/ + await pubsub.after('local.bots.ready'); + await Promise.all( + [...new Set(Object.values(env.bot.bots).map(b => this.getImageUrl(b.image)))].map( + url => + new Promise(resolve => { + const img = new Image(); + img.src = url; + img.onload = () => resolve(); + img.onerror = () => resolve(); + }), + ), + ); + pubsub.complete('local.images.ready'); + return this; + } + + async preload(uids: string[]): Promise { + for (const bot of definedMap(uids, uid => env.bot.bots[uid])) { + for (const sounds of Object.values(bot.sounds ?? {})) { + sounds.forEach(sound => fetch(botAssetUrl('sound', sound.key))); + } + const books = bot?.books?.flatMap(x => x.key) ?? []; + [...this.book.keys()].filter(k => !books.includes(k)).forEach(release => this.book.delete(release)); + books.forEach(book => this.getBook(book)); + } + } + + async getNet(key: string): Promise { + if (this.net.has(key)) return (await this.net.get(key)!).data; + const netPromise = new Promise((resolve, reject) => { + fetch(botAssetUrl('net', key)) + .then(res => res.arrayBuffer()) + .then(buf => resolve({ key, data: new Uint8Array(buf) })) + .catch(reject); + }); + this.net.set(key, netPromise); + const [lru] = this.net.keys(); + if (this.net.size > 2) this.net.delete(lru); + return (await netPromise).data; + } + + async getBook(key: string | undefined): Promise { + if (!key) return undefined; + if (this.book.has(key)) return this.book.get(key); + const bookPromise = new Promise((resolve, reject) => + fetch(botAssetUrl('book', `${key}.bin`)) + .then(res => res.arrayBuffer()) + .then(buf => makeBookFromPolyglot({ bytes: new DataView(buf) })) + .then(result => resolve(result.getMoves)) + .catch(reject), + ); + this.book.set(key, bookPromise); + return bookPromise; + } + + getImageUrl(key: string): string { + return botAssetUrl('image', key); + } + + getSoundUrl(key: string): string { + return botAssetUrl('sound', key); + } +} + +export function botAssetUrl(type: AssetType, path: string): string { + return path.startsWith('https:') + ? path + : path.includes('/') + ? `${site.asset.baseUrl()}/assets/${path}` + : site.asset.url(`lifat/bots/${type}/${encodeURIComponent(path)}`); +} + +type NetData = { + key: string; + data: Uint8Array; +}; diff --git a/ui/local/src/bot.ts b/ui/local/src/bot.ts new file mode 100644 index 0000000000000..1a9f62798ad73 --- /dev/null +++ b/ui/local/src/bot.ts @@ -0,0 +1,372 @@ +import * as co from 'chessops'; +import { zip, clamp } from 'common/algo'; +import { quantizeFilter, filterParameter, filterFacets, combine, type FilterValue } from './filter'; +import type { FishSearch, SearchResult, Line } from 'zerofish'; +import type { OpeningBook } from 'bits/polyglot'; +import { env } from './localEnv'; +import type { + BotInfo, + ZeroSearch, + Filters, + FilterType, + Book, + MoveSource, + MoveArgs, + MoveResult, + SoundEvents, + Ratings, +} from './types'; + +export function score(pv: Line, depth: number = pv.scores.length - 1): number { + const sc = pv.scores[clamp(depth, { min: 0, max: pv.scores.length - 1 })]; + return isNaN(sc) ? 0 : clamp(sc, { min: -10000, max: 10000 }); +} + +export class Bot implements BotInfo, MoveSource { + private openings: Promise; + private stats: { cplMoves: number; cpl: number }; + private traces: string[]; + readonly uid: string; + readonly version: number = 0; + name: string; + description: string; + image: string; + ratings: Ratings; + books?: Book[]; + sounds?: SoundEvents; + filters?: Filters; + zero?: ZeroSearch; + fish?: FishSearch; + + static viable(info: BotInfo): boolean { + return Boolean(info.uid && info.name && (info.zero || info.fish)); // TODO moar validate + } + + static isSame(a: BotInfo | undefined, b: BotInfo | undefined): boolean { + if (!closeEnough(a, b, ['filters', 'version'])) return false; + const [aFilters, bFilters] = [a, b].map(bot => + Object.entries(bot?.filters ?? {}).filter(([k, v]) => v.move || v.time || v.score), + ); + return closeEnough(aFilters, bFilters); + } + + static migrate(oldDbVersion: number, oldBot: any): BotInfo { + const bot = structuredClone(oldBot); + // example: migrate from version 1 + // + // if (oldDbVersion === 1) { + // bot.filters = {}; + // for (const [name, filter] of Object.entries(oldBot.filters)) { + // bot.filters[name] = { + // range: { ...filter.range }, + // by: 'max', + // }; + // bot.filters[name][filter.by] = structuredClone(filter.data); + // } + // } + return bot; + } + + constructor(info: BotInfo) { + Object.assign(this, structuredClone(info)); + if (this.filters) Object.values(this.filters).forEach(quantizeFilter); + + // keep these from being stored or cloned with the bot + Object.defineProperties(this, { + stats: { value: { cplMoves: 0, cpl: 0 } }, + traces: { value: [], writable: true }, + openings: { + get: () => Promise.all(this.books?.flatMap(b => env.assets.getBook(b.key)) ?? []), + }, + }); + } + + get traceMove(): string { + return this.traces.join('\n'); + } + + get statsText(): string { + return this.stats.cplMoves ? `acpl ${Math.round(this.stats.cpl / this.stats.cplMoves)}` : ''; + } + + get needsScore(): boolean { + return Object.values(this.filters ?? {}).some(o => o.score?.length); + } + + async move(args: MoveArgs): Promise { + const { pos, chess } = args; + const { fish, zero } = this; + + this.trace([` ${env.game.live.ply}. '${this.name}' at '${co.fen.makeFen(chess.toSetup())}'`]); + if (args.avoid?.length || args.cp) + this.trace( + `[move] - ${args.avoid?.length ? 'avoid = [' + args.avoid.join(', ') + '], ' : ''}` + + (args.cp ? `cp = ${args.cp?.toFixed(2)}, ` : ''), + ); + const opening = await this.bookMove(chess); + args.thinkTime = this.thinkTime(args); + + // i need a better way to handle thinkTime, we probably need to adjust it in chooseMove + if (opening) return { uci: opening, thinkTime: args.thinkTime }; + + const zeroSearch = zero + ? { + nodes: zero.nodes, + multipv: Math.max(zero.multipv, args.avoid.length + 1), // avoid threefold + net: { + key: this.name + '-' + zero.net, + fetch: () => env.assets.getNet(zero.net), + }, + } + : undefined; + if (fish) this.trace(`[move] - fish: ${stringify(fish)}`); + if (zeroSearch) this.trace(`[move] - zero: ${stringify(zeroSearch)}`); + const { uci, cpl, thinkTime } = this.chooseMove( + await Promise.all([ + fish && env.bot.zerofish.goFish(pos, fish), + zeroSearch && env.bot.zerofish.goZero(pos, zeroSearch), + ]), + args, + ); + if (cpl !== undefined && cpl < 1000) { + this.stats.cplMoves++; // debug stats + this.stats.cpl += cpl; + } + this.trace(`[move] - chose ${uci} in ${thinkTime.toFixed(1)}s`); + return { uci, thinkTime: thinkTime }; + } + + private hasFilter(op: FilterType): boolean { + const f = this.filters?.[op]; + return Boolean(f && (f.move?.length || f.score?.length || f.time?.length)); + } + + private filter(op: FilterType, { chess, cp, thinkTime }: MoveArgs): number | undefined { + if (!this.hasFilter(op)) return undefined; + const f = this.filters![op]; + const x: FilterValue = Object.fromEntries( + filterFacets + .filter(k => f[k]) + .map(k => { + if (k === 'move') return [k, chess.fullmoves]; + else if (k === 'score') return [k, outcomeExpectancy(chess.turn, cp ?? 0)]; + else if (k === 'time') return [k, Math.log2(thinkTime ?? 64)]; + else return [k, undefined]; + }), + ); + + const vals = filterParameter(f, x); + const y = combine(vals, f.by); + this.trace(`[filter] - ${op} ${stringify(x)} -> ${stringify(vals)} by ${f.by} yields ${y.toFixed(2)}`); + return y; + } + + private thinkTime({ initial, remaining, increment }: MoveArgs): number { + initial ??= Infinity; + increment ??= 0; + if (!remaining || !Number.isFinite(initial)) return 60; + const pace = 45 * (remaining < initial / Math.log2(initial) && !increment ? 2 : 1); + const quickest = Math.min(initial / 150, 1); + const variateMax = Math.min(remaining, increment + initial / pace); + const thinkTime = quickest + Math.random() * variateMax; + this.trace( + `[thinkTime] - remaining = ${remaining.toFixed(1)}s, thinktime = ${thinkTime.toFixed(1)}s, pace = ` + + `${pace.toFixed(1)}, quickest = ${quickest.toFixed(1)}s, variateMax = ${variateMax.toFixed(1)}`, + ); + return thinkTime; + } + + private async bookMove(chess: co.Chess) { + const books = this.books?.filter(b => !b.color || b.color === chess.turn); + // first use book relative weights to choose from the subset of books with moves for the current position + if (!books?.length) return undefined; + const moveList: { moves: { uci: Uci; weight: number }[]; book: Book }[] = []; + let bookChance = 0; + for (const [book, opening] of zip(books, await this.openings)) { + const moves = opening(chess); + if (moves.length === 0) continue; + moveList.push({ moves, book }); + bookChance += book.weight; + } + bookChance = Math.random() * bookChance; + for (const { moves, book } of moveList) { + bookChance -= book.weight; + const key = book.key; + if (bookChance <= 0) { + // then choose the move from that book + let chance = Math.random(); + for (const { uci, weight } of moves) { + chance -= weight; + if (chance > 0) continue; + this.trace(`[bookMove] - chose ${uci} from ${book.color ? book.color + ' ' : ''}book '${key}'`); + return uci; + } + } + } + return undefined; + } + + private chooseMove( + results: (SearchResult | undefined)[], + args: MoveArgs, + ): { uci: Uci; cpl?: number; thinkTime: number } { + const moves = this.parseMoves(results, args); + this.trace(`[chooseMove] - parsed = ${stringify(moves)}`); + let thinkTime = args.thinkTime ?? 0; + if (this.hasFilter('cplTarget')) { + this.scoreByCpl(moves, args); + this.trace(`[chooseMove] - cpl scored = ${stringify(moves)}`); + } + + moves.sort(weightSort); + if (args.pos.moves?.length) { + const last = args.pos.moves[args.pos.moves.length - 1].slice(2, 4); + // if the current favorite is a capture of the opponent's last moved piece, + // always take it, regardless of move quality decay + if (moves[0].uci.slice(2, 4) === last) { + this.trace(`[chooseMove] - short-circuit = ${stringify(moves[0])}`); + return { ...moves[0], thinkTime: thinkTime / 4 }; + } + } + const filtered = moves.filter(mv => !args.avoid.includes(mv.uci)); + this.trace(`[chooseMove] - sorted & filtered = ${stringify(filtered)}`); + const decayed = + moveQualityDecay(filtered, this.filter('moveDecay', args) ?? 0) ?? filtered[0] ?? moves[0]; + return { ...decayed, thinkTime }; + } + + private parseMoves([fish, zero]: (SearchResult | undefined)[], args: MoveArgs): SearchMove[] { + if (fish) this.trace(`[parseMoves] - ${stringify(fish)}`); + if (zero) this.trace(`[parseMoves] - ${stringify(zero)}`); + if ((!fish || fish.bestmove === '0000') && (!zero || zero.bestmove === '0000')) { + this.trace(' parseMoves: no moves found!'); + return [{ uci: '0000', weights: {} }]; + } + const parsed: SearchMove[] = []; + const cp = fish?.lines[0] ? score(fish.lines[0]) : (args.cp ?? 0); + const lc0bias = this.filter('lc0bias', args) ?? 0; + const stockfishVariate = lc0bias ? (this.hasFilter('cplTarget') ? 0 : Math.random()) : 0; + this.trace( + `[parseMoves] - cp = ${cp.toFixed(2)}, lc0bias = ${lc0bias.toFixed(2)}, stockfishVariate = ${stockfishVariate.toFixed(2)}`, + ); + fish?.lines + .filter(line => line.moves[0]) + .forEach(line => + parsed.push({ + uci: line.moves[0], + cpl: Math.abs(cp - score(line)), + weights: { lc0bias: stockfishVariate }, + }), + ); + + zero?.lines + .map(v => v.moves[0]) + .filter(Boolean) + .forEach(uci => { + const existing = parsed.find(move => move.uci === uci); + if (existing) existing.weights.lc0bias = lc0bias; + else parsed.push({ uci, weights: { lc0bias } }); + }); + return parsed; + } + + private scoreByCpl(sorted: SearchMove[], args: MoveArgs) { + if (!this.filters?.cplTarget) return; + const mean = this.filter('cplTarget', args) ?? 0; + const stdev = this.filter('cplStdev', args) ?? 80; + const cplTarget = Math.abs(mean + stdev * getNormal()); + // folding the normal at zero skews the observed distribution mean a bit further from the target + const gain = 0.06; + const threshold = 80; // we could go with something like clamp(stdev, { min: 50, max: 100 }) here + for (const mv of sorted) { + if (mv.cpl === undefined) continue; + const distance = Math.abs((mv.cpl ?? 0) - cplTarget); + // cram cpl into [0, 1] with sigmoid + mv.weights.acpl = distance === 0 ? 1 : 1 / (1 + Math.E ** (gain * (distance - threshold))); + } + } + + private trace(msg: string | string[]) { + if (Array.isArray(msg)) this.traces = msg; + else this.traces.push(' ' + msg); + } +} + +type Weights = 'lc0bias' | 'acpl'; + +interface SearchMove { + uci: Uci; + score?: number; + cpl?: number; + weights: { [key in Weights]?: number }; + P?: number; +} + +function moveQualityDecay(sorted: SearchMove[], decay: number) { + // in this weighted random selection, each move's weight is given by a quality decay parameter + // raised to the power of the move's index as sorted by previous filters. a random number between + // 0 and the sum of all weights will identify the final move + + let variate = sorted.reduce((sum, mv, i) => (sum += mv.P = decay ** i), 0) * Math.random(); + return sorted.find(mv => (variate -= mv.P!) <= 0); +} + +function weightSort(a: SearchMove, b: SearchMove) { + const wScore = (mv: SearchMove) => Object.values(mv.weights).reduce((acc, w) => acc + (w ?? 0), 0); + return wScore(b) - wScore(a); +} + +function outcomeExpectancy(turn: Color, cp: number): number { + return 1 / (1 + 10 ** ((turn === 'black' ? cp : -cp) / 400)); +} + +let nextNormal: number | undefined = undefined; + +function getNormal(): number { + if (nextNormal !== undefined) { + const normal = nextNormal; + nextNormal = undefined; + return normal; + } + const r = Math.sqrt(-2.0 * Math.log(Math.random())); + const theta = 2.0 * Math.PI * Math.random(); + nextNormal = r * Math.sin(theta); + return r * Math.cos(theta); +} + +function stringify(obj: any) { + if (!obj) return ''; + return JSON.stringify(obj, (k, v) => (typeof v === 'number' ? v.toFixed(2) : v)); +} + +function closeEnough(a: any, b: any, ignore: string[] = []): boolean { + if (a === b) return true; + if (typeof a !== typeof b) return false; + if (Array.isArray(a)) { + return Array.isArray(b) && a.length === b.length && a.every((x, i) => closeEnough(x, b[i], ignore)); + } + if (typeof a !== 'object') return false; + + const [aKeys, bKeys] = [filteredKeys(a, ignore), filteredKeys(b, ignore)]; + if (aKeys.length !== bKeys.length) return false; + + for (const key of aKeys) { + if (!bKeys.includes(key) || !closeEnough(a[key], b[key], ignore)) return false; + } + return true; +} + +function isEmpty(prop: any): boolean { + return Array.isArray(prop) + ? false //prop.length === 0 + : typeof prop === 'object' + ? Object.keys(prop).length === 0 + : false; +} + +function filteredKeys(obj: any, ignore: string[] = []): string[] { + if (typeof obj !== 'object') return obj; + return Object.entries(obj) + .filter(([k, v]) => !ignore.includes(k) && !isEmpty(v)) + .map(([k]) => k); +} diff --git a/ui/local/src/botCtrl.ts b/ui/local/src/botCtrl.ts new file mode 100644 index 0000000000000..9ba99eba5fb6e --- /dev/null +++ b/ui/local/src/botCtrl.ts @@ -0,0 +1,253 @@ +import makeZerofish, { type Zerofish, type Position } from 'zerofish'; +import * as co from 'chessops'; +import { Bot, score } from './bot'; +import { RateBot } from './dev/rateBot'; +import { type CardData } from './handOfCards'; +import { type ObjectStorage, objectStorage } from 'common/objectStorage'; +import { defined } from 'common'; +import { deepFreeze } from 'common/algo'; +import { pubsub } from 'common/pubsub'; +import type { BotInfo, SoundEvent, MoveSource, MoveArgs, MoveResult, LocalSpeed } from './types'; +import { env } from './localEnv'; + +export class BotCtrl { + zerofish: Zerofish; + serverBots: Record; + localBots: Record; + readonly bots: Record = {}; + readonly rateBots: RateBot[] = []; + readonly uids: Record = { white: undefined, black: undefined }; + private store: ObjectStorage; + private busy = false; + private bestMove = { uci: 'e2e4', cp: 30 }; + + constructor() {} + + get white(): BotInfo | undefined { + return this.get(this.uids.white); + } + + get black(): BotInfo | undefined { + return this.get(this.uids.black); + } + + get isBusy(): boolean { + return this.busy; + } + + get firstUid(): string | undefined { + return Object.keys(this.bots)[0]; + } + + get all(): BotInfo[] { + // except for rate bots + return Object.values(this.bots) as Bot[]; + } + + get playing(): BotInfo[] { + return [this.white, this.black].filter(defined); + } + + async init(serverBots: BotInfo[]): Promise { + this.zerofish = await makeZerofish({ + root: site.asset.url('npm', { documentOrigin: true }), + wasm: site.asset.url('npm/zerofishEngine.wasm'), + dev: env.isDevPage, + }); + if (env.isDevPage) { + for (let i = 0; i <= RateBot.MAX_LEVEL; i++) { + this.rateBots.push(new RateBot(i)); + } + } + return this.initBots(serverBots.filter(Bot.viable)); + } + + async initBots(defBots?: BotInfo[]): Promise { + const [localBots, serverBots] = await Promise.all([ + this.getSavedBots(), + defBots ?? + fetch('/local/bots') + .then(res => res.json()) + .then(res => res.bots.filter(Bot.viable)), + ]); + for (const b of [...serverBots, ...localBots]) { + if (Bot.viable(b)) this.bots[b.uid] = new Bot(b); + } + this.localBots = {}; + this.serverBots = {}; + localBots.forEach((b: BotInfo) => (this.localBots[b.uid] = deepFreeze(b))); + serverBots.forEach((b: BotInfo) => (this.serverBots[b.uid] = deepFreeze(b))); + pubsub.complete('local.bots.ready'); + return this; + } + + async move(args: MoveArgs): Promise { + const bot = this[args.chess.turn] as BotInfo & MoveSource; + if (!bot) return undefined; + if (this.busy) return undefined; // ignore different call stacks + this.busy = true; + const cp = bot instanceof Bot && bot.needsScore ? (await this.fetchBestMove(args.pos)).cp : undefined; + const move = await bot?.move({ ...args, cp }); + if (!this[co.opposite(args.chess.turn)]) this.bestMove = await this.fetchBestMove(args.pos); + this.busy = false; + return move?.uci !== '0000' ? move : undefined; + } + + get(uid: string | undefined): BotInfo | undefined { + if (uid === undefined) return undefined; + return this.bots[uid] ?? this.rateBots[Number(uid.slice(1))]; + } + + sorted(by: 'alpha' | LocalSpeed = 'alpha'): BotInfo[] { + if (by === 'alpha') return Object.values(this.bots).sort((a, b) => a.name.localeCompare(b.name)); + else + return Object.values(this.bots).sort((a, b) => { + return (a.ratings[by] ?? 1500) - (b.ratings[by] ?? 1500) || a.name.localeCompare(b.name); + }); + } + + setUids({ white, black }: { white?: string | undefined; black?: string | undefined }): void { + this.uids.white = white; + this.uids.black = black; + env.assets.preload([white, black].filter(defined)); + } + + stop(): void { + return this.zerofish.stop(); + } + + reset(): void { + this.bestMove = { uci: 'e2e4', cp: 30 }; + return this.zerofish.reset(); + } + + save(bot: BotInfo): Promise { + delete this.localBots[bot.uid]; + this.bots[bot.uid] = new Bot(bot); + if (Bot.isSame(this.serverBots[bot.uid], bot)) return this.store.remove(bot.uid); + this.localBots[bot.uid] = deepFreeze(structuredClone(bot)); + return this.store.put(bot.uid, bot); + } + + async setServer(bot: BotInfo): Promise { + this.bots[bot.uid] = new Bot(bot); + console.log('from server', bot); + this.serverBots[bot.uid] = deepFreeze(structuredClone(bot)); + delete this.localBots[bot.uid]; + await this.store.remove(bot.uid); + } + + async delete(uid: string): Promise { + if (this.uids.white === uid) this.uids.white = undefined; + if (this.uids.black === uid) this.uids.black = undefined; + await this.store.remove(uid); + delete this.bots[uid]; + await this.initBots(); + } + + imageUrl(bot: BotInfo | undefined): string | undefined { + return bot?.image && env.assets.getImageUrl(bot.image); + } + + card(bot: BotInfo | undefined): CardData | undefined { + return ( + bot && { + label: bot.name, + domId: uidToDomId(bot.uid)!, + imageUrl: this.imageUrl(bot), + classList: [], + } + ); + } + + classifiedCard(bot: BotInfo, isDirty?: (b: BotInfo) => boolean): CardData | undefined { + const cd = this.card(bot); + const local = this.localBots[bot.uid]; + const server = this.serverBots[bot.uid]; + + if (isDirty?.(local ?? server)) cd?.classList.push('dirty'); + if (!server) cd?.classList.push('local-only'); + else if (server.version > bot.version) cd?.classList.push('upstream-changes'); + else if (local && !Bot.isSame(local, server)) cd?.classList.push('local-changes'); + return cd; + } + + classifiedSort(speed: LocalSpeed = 'classical'): (a: CardData, b: CardData) => number { + return (a, b) => { + for (const c of ['dirty', 'local-only', 'local-changes', 'upstream-changes']) { + if (a.classList.includes(c) && !b.classList.includes(c)) return -1; + if (!a.classList.includes(c) && b.classList.includes(c)) return 1; + } + const [ab, bb] = [this.get(domIdToUid(a.domId)), this.get(domIdToUid(b.domId))]; + return (ab?.ratings[speed] ?? 1500) - (bb?.ratings[speed] ?? 1500) || a.label.localeCompare(b.label); + }; + } + + async clearStoredBots(uids?: string[]): Promise { + await (uids ? Promise.all(uids.map(uid => this.store.remove(uid))) : this.store.clear()); + await this.initBots(); + } + + playSound(c: Color, eventList: SoundEvent[]): number { + const prioritized = soundPriority.filter(e => eventList.includes(e)); + for (const soundList of prioritized.map(priority => this[c]?.sounds?.[priority] ?? [])) { + let r = Math.random(); + for (const { key, chance, delay, mix } of soundList) { + r -= chance / 100; + if (r > 0) continue; + // right now we play at most one sound per move, might want to revisit this. + // also definitely need cancelation of the timeout + site.sound + .load(key, env.assets.getSoundUrl(key)) + .then(() => setTimeout(() => site.sound.play(key, Math.min(1, mix * 2)), delay * 1000)); + return Math.min(1, (1 - mix) * 2); + } + } + return 1; + } + + private getSavedBots() { + return ( + this.store?.getMany() ?? + objectStorage({ store: 'local.bots', version: 2, upgrade: this.upgrade }).then(s => { + this.store = s; + return s.getMany(); + }) + ); + } + + private upgrade = (change: IDBVersionChangeEvent, store: IDBObjectStore): void => { + const req = store.openCursor(); + req.onsuccess = e => { + const cursor = (e.target as IDBRequest).result; + if (!cursor) return; + cursor.update(Bot.migrate(change.oldVersion, cursor.value)); + cursor.continue(); + }; + }; + + private async fetchBestMove(pos: Position): Promise<{ uci: string; cp: number }> { + const best = (await this.zerofish.goFish(pos, { multipv: 1, by: { depth: 12 } })).lines[0]; + return { uci: best.moves[0], cp: score(best) }; + } +} + +export function uidToDomId(uid: string | undefined): string | undefined { + return uid?.startsWith('#') ? `bot-id-${uid.slice(1)}` : undefined; +} + +export function domIdToUid(domId: string | undefined): string | undefined { + return domId && domId.startsWith('bot-id-') ? `#${domId.slice(7)}` : undefined; +} + +const soundPriority: SoundEvent[] = [ + 'playerWin', + 'botWin', + 'playerCheck', + 'botCheck', + 'playerCapture', + 'botCapture', + 'playerMove', + 'botMove', + 'greeting', +]; diff --git a/ui/local/src/dev/assetDialog.ts b/ui/local/src/dev/assetDialog.ts new file mode 100644 index 0000000000000..8e03622649d3e --- /dev/null +++ b/ui/local/src/dev/assetDialog.ts @@ -0,0 +1,376 @@ +import { domDialog, alert, confirm, type Dialog } from 'common/dialog'; +import { frag } from 'common'; +import * as licon from 'common/licon'; +import { renderRemoveButton } from './devUtil'; +import { wireCropDialog } from 'bits/crop'; +import { env } from '../localEnv'; + +export type AssetType = 'image' | 'book' | 'sound'; + +const mimeTypes: { [type in AssetType]?: string[] } = { + image: ['image/jpeg', 'image/png', 'image/webp'], + book: ['application/x-chess-pgn', 'application/vnd.chess-pgn', 'application/octet-stream', '.pgn'], + sound: ['audio/mpeg', 'audio/aac'], +}; + +export class AssetDialog { + private dlg: Dialog; + private resolve?: (key: string | undefined) => void; + private type: AssetType; + private isChooser: boolean; + constructor(type?: AssetType) { + if (!type || type === 'image') wireCropDialog(); + this.isChooser = type !== undefined; + this.type = type ?? 'image'; + } + + private get active() { + return this.categories[this.type]; + } + + private get local() { + return env.repo.localKeyNames(this.type); + } + + private get server() { + return env.repo.serverKeyNames(this.type); + } + + show(): Promise { + return new Promise(resolve => + (async () => { + if (this.isChooser) + this.resolve = (key: string) => { + resolve(key); + this.resolve = undefined; + this.dlg.close(); + }; + this.dlg = await domDialog({ + class: `dev-view asset-dialog${this.isChooser ? ' chooser' : ''}`, + htmlText: this.bodyHtml(), + onClose: () => this.resolve?.(undefined), + actions: [ + { event: ['dragover', 'drop'], listener: this.dragDrop }, + { selector: '[data-action="add"]', listener: this.addItem }, + { selector: '[data-action="remove"]', listener: this.delete }, + { selector: '[data-action="push"]', listener: this.push }, + { selector: '[data-type="string"]', event: 'keydown', listener: this.nameKeyDown }, + { selector: '[data-type="string"]', event: 'change', listener: this.nameChange }, + { selector: '.asset-item', listener: this.clickItem }, + { selector: '.tab', listener: this.clickTab }, + ], + }); + this.update(); + this.dlg.show(); + })(), + ); + } + + update(type?: AssetType): void { + if (type && type !== this.type) return; + const grid = this.dlg.viewEl.querySelector('.asset-grid') as HTMLElement; + grid.innerHTML = `
+
${this.active.placeholder}
+
Add new ${this.type}
+
`; + this.local.forEach((name, key) => grid.append(this.renderAsset([key, name]))); + this.server.forEach((name, key) => !name.startsWith('.') && grid.append(this.renderAsset([key, name]))); + this.dlg.updateActions(); + } + + private bodyHtml() { + if (this.isChooser) return `
`; + return `
+ images + sounds + books +
+
`; + } + + private renderAsset([key, name]: [string, string]) { + const wrap = frag(`
+
+ +
`); + if (!this.isChooser) { + const localOnly = env.repo.isLocalOnly(key); + if (localOnly || env.canPost) wrap.append(renderRemoveButton('upper-right')); + if (localOnly && env.canPost) { + wrap.append( + frag(``, + actions: { + selector: 'button', + listener: async (_, dlg) => { + const value = (dlg.viewEl.querySelector('input') as HTMLInputElement).value; + if (!this.validName(value)) return; + dlg.close(value); + }, + }, + show: true, + }) + ).returnValue; + if (!name || name === 'cancel') return key; + try { + await env.push.pushAsset(env.repo.assetBlob(this.type, key)); + } catch (x) { + console.error('push failed', x); + return undefined; + } + await env.repo.update(); + this.update(); + return name; + }; + + private clickTab = (e: Event): void => { + const tab = (e.currentTarget as HTMLElement).closest('.tab')!; + const type = tab?.textContent?.slice(0, -1) as AssetType; + if (!tab || type === this.type) return; + this.dlg.viewEl.querySelectorAll('.tab').forEach(t => t.classList.remove('active')); + tab.classList.add('active'); + this.type = tab.textContent?.slice(0, -1) as AssetType; + this.update(); + }; + + private clickItem = (e: Event): void => { + const item = (e.currentTarget as HTMLElement).closest('.asset-item') as HTMLElement; + const oldKey = item?.getAttribute('data-asset'); + if (oldKey && this.isChooser) return this.resolve?.(oldKey); + }; + + private addItem = () => { + const fileInputEl = document.createElement('input'); + fileInputEl.type = 'file'; + fileInputEl.accept = mimeTypes[this.type]!.join(','); + fileInputEl.style.display = 'none'; + const onchange = () => { + fileInputEl.removeEventListener('change', onchange); + if (!fileInputEl.files || fileInputEl.files.length < 1) return; + this.active.process(fileInputEl.files[0], (key: string) => { + if (this.resolve) this.resolve(key); + else this.update(); + }); + }; + fileInputEl.addEventListener('change', onchange); + this.dlg.viewEl.append(fileInputEl); + fileInputEl.click(); + fileInputEl.remove(); + }; + + private validName(name: string): boolean { + const error = + name.length < 3 + ? 'name must be three characters or more' + : name.includes('/') + ? 'name cannot contain /' + : name.startsWith('.') + ? 'name cannot start with period' + : [...this.server.values()].includes(name) + ? 'that name is already in use' + : undefined; + if (error) alert(error); + return error === undefined; + } + + private category(mimeType: string): AssetType | undefined { + for (const type in mimeTypes) + if (mimeTypes[type as AssetType]?.includes(mimeType)) return type as AssetType; + return undefined; + } + + private categories = { + image: { + placeholder: '', + preview: (key: string) => frag(``), + process: (file: File, onSuccess: (key: string) => void) => { + if (!file.type.startsWith('image/')) return; + // TODO this doesn't seem to always work. find out why. + site.asset.loadEsm('bits.cropDialog', { + init: { + aspectRatio: 1, + source: file, + max: { megabytes: 0.05, pixels: 512 }, + onCropped: (r: Blob | boolean) => { + if (!(r instanceof Blob)) return; + env.repo.import('image', file.name, r).then(onSuccess); + }, + }, + }); + }, + }, + book: { + placeholder: '', + preview: (key: string) => { + const divEl = document.createElement('div'); + const imgEl = document.createElement('img'); + imgEl.src = env.repo.getBookCoverUrl(key); + divEl.append(imgEl); + return divEl; + }, + process: (file: File, onSuccess: (key: string) => void) => { + if (file.type === 'application/octet-stream' || file.name.endsWith('.bin')) { + env.repo.importPolyglot(file.name, file).then(onSuccess); + } else if (file.type.endsWith('chess-pgn') || file.name.endsWith('.pgn')) { + const suggested = file.name.endsWith('.pgn') ? file.name.slice(0, -4) : file.name; + domDialog({ + class: 'dev-view import-dialog', + htmlText: `

import opening book

+
+ + + + + +
+
+
+
+ +
`, + show: true, + modal: true, + focus: '.name', + noClickAway: true, + actions: [ + { + selector: '.options', + event: 'keydown', + listener: (e: KeyboardEvent) => { + if (!(e.target instanceof HTMLElement) || e.key !== 'Enter') return; + e.preventDefault(); + e.stopPropagation(); + e.target.closest('.options')?.querySelector('[data-action="import"]')?.click(); + }, + }, + { selector: '[data-action="cancel"]', result: 'cancel' }, + { + selector: '[data-action="import"]', + listener: async (_, dlg) => { + const name = (dlg.viewEl.querySelector('.name') as HTMLInputElement).value; + const ply = Number((dlg.viewEl.querySelector('.ply') as HTMLInputElement).value); + if (name.length < 4 || name.includes('/') || name.startsWith('.')) + alert(`bad name: ${name}`); + else if (!Number.isInteger(ply) || ply < 1 || ply > 16) alert(`bad ply: ${ply}`); + else { + (dlg.viewEl.querySelector('.options') as HTMLElement).classList.add('none'); + const progress = dlg.viewEl.querySelector('.progress') as HTMLElement; + const bar = progress.querySelector('.bar') as HTMLElement; + const text = progress.querySelector('.text') as HTMLElement; + progress.classList.remove('none'); + const key = await env.repo.importPgn( + name, + file, + ply, + false, + (processed: number, total: number) => { + bar.style.width = `${(processed / total) * 100}%`; + processed = Math.round(processed / (1024 * 1024)); + total = Math.round(total / (1024 * 1024)); + text.textContent = `processed ${processed} out of ${total} MB`; + return dlg.dialogEl.open; + }, + ); + if (dlg.returnValue !== 'cancel' && key) onSuccess(key); + } + dlg.close(); + }, + }, + ], + }); + } + }, + }, + sound: { + placeholder: '', + preview: (key: string) => { + const soundEl = document.createElement('span'); + const audioEl = frag(``); + const buttonEl = frag( + ``, + ); + buttonEl.addEventListener('click', e => { + audioEl.play(); + e.stopPropagation(); + }); + soundEl.append(audioEl); + soundEl.append(buttonEl); + audioEl.onloadedmetadata = () => { + buttonEl.textContent = audioEl.duration.toFixed(2) + 's'; + }; + return soundEl; + }, + process: (file: File, onSuccess: (key: string) => void) => { + if (!file.type.startsWith('audio/')) return; + env.repo.import('sound', file.name, file).then(onSuccess); + }, + }, + }; +} diff --git a/ui/local/src/dev/booksPane.ts b/ui/local/src/dev/booksPane.ts new file mode 100644 index 0000000000000..99f363a109ae2 --- /dev/null +++ b/ui/local/src/dev/booksPane.ts @@ -0,0 +1,156 @@ +import { Pane, RangeSetting } from './pane'; +import * as licon from 'common/licon'; +import { frag } from 'common'; +import type { PaneArgs, BooksInfo, RangeInfo } from './devTypes'; +import type { Book } from '../types'; +import { renderRemoveButton } from './devUtil'; +import { env } from '../localEnv'; +import { opposite } from 'chessops'; + +export class BooksPane extends Pane { + info: BooksInfo; + template: RangeInfo /* = { + type: 'range', + class: ['setting', 'book'], + value: 1, + min: 1, + max: 10, + step: 1, + }*/; + constructor(p: PaneArgs) { + super(p); + this.label?.prepend( + frag(``), + ); + this.template = { + type: 'range', + class: ['setting', 'book'], + ...Object.fromEntries( + [...Object.entries((p.info as BooksInfo).template)].map(([k, v]) => [k, v.weight]), + ), + } as RangeInfo; + if (!this.value) this.setProperty([]); + this.value.forEach((_, index) => this.makeBook(index)); + } + + update(e?: Event): void { + if (!(e?.target instanceof HTMLElement)) return; + if (e.target.dataset.action === 'add') { + this.host.assetDialog('book').then(b => { + if (!b) return; + this.value.push({ key: b, weight: this.template?.value ?? 1 }); + this.makeBook(this.value.length - 1); + }); + } + } + + setWeight(pane: BookPane, value: number): void { + this.value[this.index(pane)].weight = value; + } + + getWeight(pane: BookPane): number | undefined { + const index = this.index(pane); + return index == -1 ? undefined : (this.value[this.index(pane)]?.weight ?? 1); + } + + setColor(pane: BookPane, color?: Color): void { + this.value[this.index(pane)].color = color; + this.host.update(); + } + + getColor(pane: BookPane): Color | undefined { + return this.value[this.index(pane)]?.color; + } + + setEnabled(): boolean { + this.el.classList.toggle('disabled', !this.value.length); + return true; + } + + removeBook(pane: BookPane): void { + this.value.splice(this.index(pane), 1); + this.setEnabled(); + } + + index(pane: BookPane): number { + return this.bookEls.indexOf(pane.el); + } + + private makeBook(index: number): void { + const book = this.value[index]; + const pargs = { + host: this.host, + info: { + ...this.template, + label: env.repo.nameOf(book.key) ?? `unknown '${book.key}'`, + value: book.weight, + color: book.color, + id: `${this.id}_${idCounter++}`, + }, + parent: this, + }; + this.el.appendChild(new BookPane(pargs, book.key).el); + this.setEnabled(); + this.host.update(); + } + + private get value(): Book[] { + return this.getProperty() as Book[]; + } + + private get bookEls() { + return [...this.el.querySelectorAll('.book')]; + } +} + +let idCounter = 0; + +class BookPane extends RangeSetting { + label: HTMLLabelElement; + parent: BooksPane; + colorInput: HTMLElement = frag( + `
+ + +
`, + ); + constructor(p: PaneArgs, key: string) { + super(p); + this.colorInput.querySelectorAll('button').forEach(b => { + b.addEventListener('click', e => { + if (!(e.target instanceof HTMLElement)) return; + const color = e.target.dataset.color as Color; + const other = this.colorInput.querySelector(`button[data-color="${opposite(color)}"]`); + if (e.target.classList.contains('active')) other?.classList.toggle('active'); + e.target.classList.add('active'); + this.parent.setColor(this, other?.classList.contains('active') ? undefined : color); + }); + }); + this.colorInput + .querySelectorAll(p.info.color ? `button[data-color="${p.info.color}"]` : 'button') + .forEach(b => b.classList.add('active')); + this.el.append(this.colorInput); + this.el.append(renderRemoveButton()); + const span = this.label.firstElementChild as HTMLElement; + span.dataset.src = env.repo.getBookCoverUrl(key); + span.classList.add('image-powertip'); + this.rangeInput.insertAdjacentHTML('afterend', 'wt'); + } + + getProperty(): number { + return this.parent.getWeight(this) ?? this.info.value ?? 1; + } + + setProperty(value: number): void { + this.parent.setWeight(this, value); + } + + update(e?: Event): void { + if (!(e?.target instanceof HTMLElement)) return; + if (e.target.dataset.action === 'remove') { + this.parent.removeBook(this); + this.el.remove(); + this.host.update(); + } else super.update(e); + } +} diff --git a/ui/local/src/dev/devAssets.ts b/ui/local/src/dev/devAssets.ts new file mode 100644 index 0000000000000..6cff311e32fb4 --- /dev/null +++ b/ui/local/src/dev/devAssets.ts @@ -0,0 +1,370 @@ +import { type ObjectStorage, objectStorage } from 'common/objectStorage'; +import { botAssetUrl, Assets } from '../assets'; +import { + type OpeningBook, + makeBookFromPolyglot, + makeBookFromPgn, + PgnProgress, + PgnFilter, +} from 'bits/polyglot'; +import { alert } from 'common/dialog'; +import { zip } from 'common/algo'; +import { env } from '../localEnv'; +import { pubsub } from 'common/pubsub'; + +// dev asset keys are a 12 digit hex hash of the asset contents (plus the file extension for image/sound) +// dev asset names are strictly cosmetic and can be renamed at any time +// dev asset blobs are stored in idb +// asset keys give the filename on server filesystem + +export type ShareType = 'image' | 'sound' | 'book'; +export type AssetType = ShareType | 'bookCover' | 'net'; +export type AssetBlob = { type: AssetType; key: string; name: string; author: string; blob: Promise }; +export type AssetList = Record[]>; + +const assetTypes = ['image', 'sound', 'book', 'bookCover', 'net'] as const; +const urlTypes = ['image', 'sound', 'bookCover'] as const; + +export class DevAssets extends Assets { + private server = assetTypes.reduce( + (obj, type) => ({ ...obj, [type]: new Map() }), + {} as Record>, + ); + + private idb = assetTypes.reduce( + (obj, type) => ({ ...obj, [type]: new Store(type) }), + {} as Record, + ); + + private urls = urlTypes.reduce( + (obj, type) => ({ ...obj, [type]: new Map() }), + {} as Record>, + ); + + constructor(public rlist?: AssetList | undefined) { + super(); + this.update(rlist); + window.addEventListener('storage', this.onStorageEvent); + } + + async init(): Promise { + localStorage.removeItem('local.dev.import.book'); + for (const type of urlTypes) { + for (const url of this.urls[type].values()) { + URL.revokeObjectURL(url); + } + this.urls[type].clear(); + } + const [localImages, localSounds, localBookCovers] = await Promise.all( + ([...urlTypes, 'book'] as const).map(t => this.idb[t].init()), + ); + const urlAssets = { image: localImages, sound: localSounds, bookCover: localBookCovers }; + urlTypes.forEach(type => { + for (const [key, data] of urlAssets[type]) { + this.urls[type].set(key, URL.createObjectURL(new Blob([data.blob], { type: mimeOf(key) }))); + } + }); + return super.init(); + } + + localKeyNames(type: AssetType): Map { + return this.idb[type].keyNames; + } + + serverKeyNames(type: AssetType): Map { + return this.server[type]; + } + + allKeyNames(type: AssetType): Map { + const allMap = new Map(this.idb[type].keyNames); + for (const [k, v] of this.server[type]) { + if (!v.startsWith('.')) allMap.set(k, v); + } + return allMap; + } + + deletedKeys(type: AssetType): string[] { + return [...this.server[type].entries()].filter(([, v]) => v.startsWith('.')).map(([k]) => k); + } + + isLocalOnly(key: string): boolean { + return Boolean(this.find(k => k === key, 'local') && !this.find(k => k === key, 'server')); + } + + isDeleted(key: string): boolean { + for (const map of Object.values(this.server)) { + if (map.get(key)?.startsWith('.')) return true; + } + return false; + } + + nameOf(key: string): string | undefined { + return this.find(k => k === key)?.[1]; + } + + assetBlob(type: AssetType, key: string): AssetBlob | undefined { + if (this.isLocalOnly(key)) + return { + key, + type, + author: env.user, + name: this.idb[type].keyNames.get(key) ?? key, + blob: this.idb[type].get(key).then(data => data.blob), + }; + else return undefined; + } + + async import(type: AssetType, blobname: string, blob: Blob): Promise { + if (type === 'net' || type === 'book') throw new Error('no'); + const extpos = blobname.lastIndexOf('.'); + if (extpos === -1) throw new Error('filename must have extension'); + const [name, ext] = [blobname.slice(0, extpos), blobname.slice(extpos + 1)]; + const key = `${await hashBlob(blob)}.${ext}`; + await this.idb[type].put(key, { blob, name, user: env.user }); + if (!this.urls[type].has(key)) this.urls[type].set(key, URL.createObjectURL(blob)); + return key; + } + + async clearLocal(type: AssetType, key: string): Promise { + await this.idb[type].rm(key); + if (type === 'image' || type === 'sound' || type === 'bookCover') { + const oldUrl = this.urls[type].get(key); + if (oldUrl) URL.revokeObjectURL(oldUrl); + this.urls[type].delete(key); + } + } + + async delete(type: AssetType, key: string): Promise { + const [assetList] = await Promise.allSettled([ + fetch(`/local/dev/asset/mv/${key}/.${encodeURIComponent(this.nameOf(key)!)}`, { method: 'post' }), + this.clearLocal(type, key), + ]); + if (type === 'book') this.clearLocal('bookCover', key); + if (assetList.status === 'fulfilled') return this.update(await assetList.value.json()); + } + + async rename(type: AssetType, key: string, newName: string): Promise { + if (this.nameOf(key) === newName) return; + const [assetList] = await Promise.allSettled([ + fetch(`/local/dev/asset/mv/${key}/${encodeURIComponent(newName)}`, { method: 'post' }), + this.idb[type].mv(key, newName), + ]); + if (assetList.status === 'fulfilled') return this.update(await assetList.value.json()); + } + + async getBook(key: string | undefined): Promise { + if (!key) return undefined; + if (this.book.has(key)) return this.book.get(key); + if (!this.idb.book.keyNames.has(key)) return super.getBook(key); + const bookPromise = new Promise((resolve, reject) => + this.idb.book + .get(key) + .then(res => res.blob.arrayBuffer()) + .then(buf => makeBookFromPolyglot({ bytes: new DataView(buf) })) + .then(result => resolve(result.getMoves)) + .catch(reject), + ); + this.book.set(key, bookPromise); + return bookPromise; + } + + getImageUrl(key: string): string { + return this.urls.image.get(key) ?? botAssetUrl('image', key); + } + + getSoundUrl(key: string): string { + return this.urls.sound.get(key) ?? botAssetUrl('sound', key); + } + + getBookCoverUrl(key: string): string { + return this.urls.bookCover.get(key) ?? botAssetUrl('book', `${key}.png`); + } + + async importPolyglot(blobname: string, blob: Blob): Promise { + if (blob.type !== 'application/octet-stream') throw new Error('no'); + const data = await blobArrayBuffer(blob); + const book = await makeBookFromPolyglot({ bytes: new DataView(data), cover: true }); + if (!book.cover) throw new Error(`error parsing ${blobname}`); + const key = await hashBlob(blob); + const name = blobname.endsWith('.bin') ? blobname.slice(0, -4) : blobname; + const asset = { blob: blob, name, user: env.user }; + const cover = { blob: book.cover, name, user: env.user }; + await Promise.all([this.idb.book.put(key, asset), this.idb.bookCover.put(key, cover)]); + this.urls.bookCover.set(key, URL.createObjectURL(new Blob([book.cover], { type: 'image/png' }))); + return key; + } + + async importPgn( + blobname: string, + pgn: Blob, + ply: number, + fromStudy: boolean, + progress?: PgnProgress, + filter?: PgnFilter, + ): Promise { + // a study can be repeatedly imported with the same name during the play balancing cycle. in + // that case, we need to patch all bots using the key associated with the previous version to + // the new key at the time we import the change because it's tough for a user to figure out later. + const name = blobname.endsWith('.pgn') ? blobname.slice(0, -4) : blobname; + const result = await makeBookFromPgn({ pgn, ply, cover: true, progress, filter }); + if (!result.positions || !result.polyglot || !result.cover) { + console.log(result, 'cancelled?'); + return undefined; + } + const oldKey = [...this.idb.book.keyNames.entries()].find(([, n]) => n === name)?.[0]; + const key = await hashBlob(result.polyglot); + const asset = { blob: result.polyglot, name, user: env.user }; + const cover = { blob: result.cover, name, user: env.user }; + await Promise.all([this.idb.book.put(key, asset), this.idb.bookCover.put(key, cover)]); + + const promises: Promise[] = []; + if (oldKey && oldKey !== key) { + for (const bot of env.bot.all) { + const existing = bot.books?.find(b => b.key === oldKey); + if (existing) { + existing.key = key; + promises.push(env.bot.save(bot)); + } + } + await Promise.allSettled([...promises, this.idb.book.rm(oldKey), this.idb.bookCover.rm(oldKey)]); + } + if (fromStudy) { + localStorage.setItem('local.dev.import.book', `${key}${oldKey ? ',' + oldKey : ''}`); + alert(`${name} exported to bot studio. ${promises.length ? ` ${promises.length} bots updated` : ''}`); + } else { + this.urls.bookCover.set(key, URL.createObjectURL(new Blob([cover.blob], { type: 'image/png' }))); + pubsub.emit('local.dev.import.book', key, oldKey); + if (promises.length) alert(`updated ${promises.length} bots with new ${name}`); + } + return key; + } + + async update(rlist?: AssetList): Promise { + if (!rlist) rlist = await fetch('/local/dev/assets').then(res => res.json()); + Object.values(this.server).forEach(m => m.clear()); + assetTypes.forEach(type => rlist?.[type]?.forEach(a => this.server[type].set(a.key, a.name))); + const books = Object.entries(this.server.book); + this.server.bookCover = new Map(books.map(([k, v]) => [`${k}.png`, v])); + assetTypes.forEach(type => (this.server[type] = valueSorted(this.server[type]))); + } + + private onStorageEvent = async (e: StorageEvent) => { + if (e.key !== 'local.dev.import.book' || !e.newValue) return; + + await this.init(); + const [key, oldKey] = e.newValue.split(','); + pubsub.emit('local.dev.import.book', key, oldKey); + }; + + private find( + fn: (key: string, name: string, type: AssetType) => boolean, + maps: 'local' | 'server' | 'both' = 'both', + ): [key: string, name: string, type: AssetType] | undefined { + for (const type of assetTypes) { + if (maps !== 'server') + for (const [key, name] of this.idb[type].keyNames) { + if (fn(key, name, type)) return [key, name, type]; + } + if (maps === 'local') continue; + for (const [key, name] of this.server[type]) { + if (fn(key, name, type)) return [key, name, type]; + } + } + return undefined; + } +} + +type IdbAsset = { blob: Blob; name: string; user: string }; + +class Store { + private store: ObjectStorage; + + keyNames = new Map(); + + constructor(readonly type: AssetType) {} + + async init() { + this.keyNames.clear(); + this.store = await objectStorage({ store: `local.${this.type}` }); + const [keys, assets] = await Promise.all([this.store.list(), this.store.getMany()]); + const all = zip(keys, assets); + all.forEach(([k, a]) => this.keyNames.set(k, a.name)); + this.keyNames = valueSorted(this.keyNames); + return all; + } + + async put(key: string, value: IdbAsset): Promise { + this.keyNames.set(key, value.name); + return await this.store.put(key, value); + } + + async rm(key: string): Promise { + await this.store.remove(key); + this.keyNames.delete(key); + } + + async mv(key: string, newName: string): Promise { + if (this.keyNames.get(key) === newName) return; + const asset = await this.store.get(key); + if (!asset) return; + this.keyNames.set(key, newName); + await this.store.put(key, { ...asset, name: newName }); + } + + async get(key: string): Promise { + return await this.store?.get(key); + } +} + +function valueSorted(map: Map | undefined) { + return new Map(map ? [...map.entries()].sort((a, b) => a[1].localeCompare(b[1])) : []); +} + +async function hashBlob(file: Blob): Promise { + const hashBuffer = await window.crypto.subtle.digest('SHA-256', await blobArrayBuffer(file)); + const hashArray = Array.from(new Uint8Array(hashBuffer)); + return hashArray + .map(b => b.toString(16).padStart(2, '0')) + .join('') + .slice(0, 12); +} + +function blobArrayBuffer(file: Blob): Promise { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = () => resolve(reader.result as ArrayBuffer); + reader.onerror = reject; + reader.readAsArrayBuffer(file); + }); +} + +function blobString(file: Blob): Promise { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = () => resolve(reader.result as string); + reader.onerror = reject; + reader.readAsText(file); + }); +} + +function mimeOf(filename: string) { + // go live with webp and mp3 only, but support more formats during dev work + switch (filename.slice(filename.lastIndexOf('.') + 1).toLowerCase()) { + case 'jpg': + case 'jpeg': + return 'image/jpeg'; + case 'png': + return 'image/png'; + case 'webp': + return 'image/webp'; + case 'aac': + return 'audio/aac'; + case 'mp3': + return 'audio/mpeg'; + case 'pgn': + return 'application/x-chess-pgn'; + case 'bin': + return 'application/octet-stream'; + } + return undefined; +} diff --git a/ui/local/src/dev/devCtrl.ts b/ui/local/src/dev/devCtrl.ts new file mode 100644 index 0000000000000..318ab66a0685c --- /dev/null +++ b/ui/local/src/dev/devCtrl.ts @@ -0,0 +1,241 @@ +import { RateBot, rateBotMatchup } from './rateBot'; +import type { BotInfo, LocalSpeed } from '../types'; +import { statusOf } from 'game'; +import { defined, Prop } from 'common'; +import { shuffle } from 'common/algo'; +import { type ObjectStorage, objectStorage } from 'common/objectStorage'; +import { storedBooleanProp } from 'common/storage'; +import type { GameStatus, GameContext } from '../localGame'; +import { env } from '../localEnv'; +import { pubsub } from 'common/pubsub'; +import { type PermaLog, makeLog } from 'common/permalog'; + +export interface Result { + winner: Color | undefined; + white?: string; + black?: string; +} + +interface Test { + type: 'matchup' | 'roundRobin' | 'rate'; + players: string[]; + initialFen?: string; +} + +export interface Matchup { + white: string; + black: string; +} + +interface Script extends Test { + games: Matchup[]; +} + +export type Glicko = { r: number; rd: number }; + +type DevRatings = { [speed in LocalSpeed]?: Glicko }; + +export class DevCtrl { + hurryProp: Prop = storedBooleanProp('local.dev.hurry', false); + // skip animations, sounds, and artificial think times (clock still adjusted) + script: Script; + log: Result[]; + private traceDb: PermaLog; + private trace: string[] = []; + ratings: { [uid: string]: DevRatings } = {}; + private localRatings: ObjectStorage; + + constructor() {} + + async init(): Promise { + this.resetScript(); + [this.traceDb] = await Promise.all([makeLog({ store: 'botmove' }, 1), this.getStoredRatings()]); + pubsub.on('theme', env.redraw); + } + + get hurry(): boolean { + return this.hurryProp() || (this.gameInProgress && env.bot.playing.some(x => 'level' in x)); + } + + run(test?: Test, iterations: number = 1): boolean { + if (test) { + this.resetScript(test); + this.script.games.push(...this.matchups(test, iterations)); + } + const game = this.script.games.shift(); + if (!game) return false; + env.game.load({ ...game, setupFen: env.game.live.setupFen }); + env.redraw(); + env.game.start(); + return true; + } + + resetScript(test?: Test): void { + this.log ??= []; + this.trace = []; + const players = [env.game.white, env.game.black].filter(x => defined(x)) as string[]; + this.script = { + type: 'matchup', + players, + games: [], + ...test, + }; + } + + onReset(): void {} + + beforeMove(uci: string): void { + const ply = env.game.live.ply; + const fen = env.game.live.fen; + const turn = env.game.live.turn; + if (ply === 0) { + const white = env.game.nameOf('white'); + const black = env.game.nameOf('black'); + const stringify = (obj: any) => + JSON.stringify(obj, (_, v) => (!obj ? '' : typeof v === 'number' ? v.toFixed(2) : v)); + this.trace.push( + `\n${white} vs ${black} ${env.game.speed} ${env.game.initial ?? ''}` + + `${env.game.increment ? `-${env.game.increment}` : ''} ${env.game.live.initialFen}`, + ); + this.trace.push(`\nWhite: '${white}' ${env.bot.white ? stringify(env.bot.white) : ''}`); + this.trace.push(`Black: '${black}' ${env.bot.black ? stringify(env.bot.black) : ''}`); + } + if (ply % 2 === 0) this.trace.push(`\n ${'-'.repeat(64)} Move ${ply / 2 + 1} ${'-'.repeat(64)}`); + if (!env.bot[turn]) this.trace.push(` ${ply}. '${env.game.nameOf(turn)}' at '${fen}': '${uci}'`); + } + + afterMove(moveResult: GameContext): void { + const ply = env.game.live.ply - 1; + const lastColor = env.game.live.awaiting; + env.round.chessground?.set({ animation: { enabled: !this.hurry } }); + if (this.hurry) moveResult.silent = true; + const trace = env.bot[lastColor]?.traceMove; + if (trace) this.trace.push(trace); + } + + onGameOver({ winner, reason, status }: GameStatus): boolean { + const last = { winner, white: this.white?.uid, black: this.black?.uid }; + this.log.push(last); + const matchup = `'${env.game.nameOf('white')}' vs '${env.game.nameOf('black')}'`; + const error = + status === statusOf('unknownFinish') && + `${matchup} - ${env.game.live.turn} ${reason} - ${env.game.live.fen} ${env.game.live.moves.join(' ')}`; + const result = `${matchup}:${winner ? ` ${env.game.nameOf(winner)} wins by` : ''} ${status.name} ${reason ?? ''}`; + this.trace.push(`\n ${error || result}\n`); + this.trace.push('='.repeat(144)); + this.traceDb(this.trace.join('\n')); + this.trace = []; + + console.log(`game ${this.log.length} - ` + (error || result)); + + if (error || !this.white?.uid || !this.black?.uid) return false; + this.updateRatings(this.white.uid, this.black.uid, winner); + + if (this.script.type === 'rate') { + const uid = this.script.players[0]!; + const rating = this.getRating(uid, env.game.speed); + this.script.games.push(...rateBotMatchup(uid, rating, last)); + } + if (this.testInProgress) return this.run(); + this.resetScript(); + env.redraw(); + return false; + } + + getRating(uid: string | undefined, speed: LocalSpeed): Glicko { + if (!uid) return { r: 1500, rd: 350 }; + const bot = env.bot.get(uid); + if (bot instanceof RateBot) return { r: bot.ratings[speed], rd: 0.01 }; + else return this.ratings[uid]?.[speed] ?? { r: 1500, rd: 350 }; + } + + setRating(uid: string | undefined, speed: LocalSpeed, rating: Glicko): Promise { + if (!uid || !env.bot.bots[uid]) return Promise.resolve(); + this.ratings[uid] ??= {}; + this.ratings[uid][speed] = rating; + return this.localRatings.put(uid, this.ratings[uid]); + } + + async getTrace(): Promise { + return (await this.traceDb.get()) + '\n' + this.trace.join('\n'); + } + + get hasUser(): boolean { + return !(this.white && this.black); + } + + get gameInProgress(): boolean { + return !!env.game.rewind || (env.game.live.ply > 0 && !env.game.live.finished); + } + + async clearRatings(): Promise { + await this.localRatings.clear(); + this.ratings = {}; + } + + private matchups(test: Test, iterations = 1): Matchup[] { + const players = test.players; + if (players.length < 2) return []; + if (test.type === 'rate') { + const rating = this.getRating(players[0], env.game.speed); + return rateBotMatchup(players[0], rating); + } + const games: Matchup[] = []; + for (let it = 0; it < iterations; it++) { + if (test.type === 'roundRobin') { + const tourney: Matchup[] = []; + for (let i = 0; i < players.length; i++) { + for (let j = i + 1; j < players.length; j++) { + tourney.push({ white: players[i], black: players[j] }); + tourney.push({ white: players[j], black: players[i] }); + } + } + games.push(...shuffle(tourney)); + } else games.push({ white: test.players[it % 2], black: test.players[(it + 1) % 2] }); + } + return games; + } + + private async getStoredRatings(): Promise { + if (!this.localRatings) + this.localRatings = await objectStorage({ store: 'local.bot.ratings' }); + const keys = await this.localRatings.list(); + this.ratings = Object.fromEntries( + await Promise.all(keys.map(k => this.localRatings.get(k).then(v => [k, v]))), + ); + } + + private updateRatings(whiteUid: string, blackUid: string, winner: Color | undefined): Promise { + const whiteScore = winner === 'white' ? 1 : winner === 'black' ? 0 : 0.5; + const rats = [whiteUid, blackUid].map(uid => this.getRating(uid, env.game.speed)); + + return Promise.all([ + this.setRating(whiteUid, env.game.speed, updateGlicko(rats, whiteScore)), + this.setRating(blackUid, env.game.speed, updateGlicko(rats.reverse(), 1 - whiteScore)), + ]); + + function updateGlicko(glk: Glicko[], score: number): Glicko { + const q = Math.log(10) / 400; + const expected = 1 / (1 + 10 ** ((glk[1].r - glk[0].r) / 400)); + const g = 1 / Math.sqrt(1 + (3 * q ** 2 * glk[1].rd ** 2) / Math.PI ** 2); + const dSquared = 1 / (q ** 2 * g ** 2 * expected * (1 - expected)); + const deltaR = glk[0].rd <= 0 ? 0 : (q * g * (score - expected)) / (1 / dSquared + 1 / glk[0].rd ** 2); + return { + r: Math.round(glk[0].r + deltaR), + rd: Math.max(30, Math.sqrt(1 / (1 / glk[0].rd ** 2 + 1 / dSquared))), + }; + } + } + + private get white(): BotInfo | undefined { + return env.bot.white; + } + + private get black(): BotInfo | undefined { + return env.bot.black; + } + + private get testInProgress(): boolean { + return this.script.games.length !== 0; + } +} diff --git a/ui/local/src/dev/devSideView.ts b/ui/local/src/dev/devSideView.ts new file mode 100644 index 0000000000000..5440d25f67990 --- /dev/null +++ b/ui/local/src/dev/devSideView.ts @@ -0,0 +1,381 @@ +import * as co from 'chessops'; +import { type VNode, looseH as h, onInsert, bind } from 'common/snabbdom'; +import * as licon from 'common/licon'; +import { storedBooleanProp, storedIntProp } from 'common/storage'; +import { domDialog } from 'common/dialog'; +import { EditDialog } from './editDialog'; +import { Bot } from '../bot'; +import { resultsString, playersWithResults } from './devUtil'; +import { type Drop, type HandOfCards, handOfCards } from '../handOfCards'; +import { domIdToUid, uidToDomId } from '../botCtrl'; +import { rangeTicks } from '../gameView'; +import { defined } from 'common'; +import type { LocalSpeed, LocalSetup } from '../types'; +import { env } from '../localEnv'; + +export function renderDevSide(): VNode { + return h('div.dev-side.dev-view', [ + h('div', player(co.opposite(env.game.screenOrientation))), + dashboard(), + progress(), + h('div', player(env.game.screenOrientation)), + ]); +} + +function player(color: Color): VNode { + const p = env.bot[color] as Bot | undefined; + const imgUrl = env.bot.imageUrl(p) ?? `/assets/lifat/bots/image/${color}-torso.webp`; + const isLight = document.documentElement.classList.contains('light'); + const buttonClass = { + white: isLight ? '.button-metal' : '.button-inverse', + black: isLight ? '.button-inverse' : '.button-metal', + }; + return h( + `div.player`, + { + attrs: { 'data-color': color }, + hook: onInsert(el => el.addEventListener('click', () => showBotSelector(el))), + }, + [ + env.bot[color] && + h(`button.upper-right`, { + attrs: { 'data-action': 'remove', 'data-icon': licon.Cancel }, + hook: bind('click', e => { + reset({ ...env.bot.uids, [color]: undefined }); + e.stopPropagation(); + }), + }), + h('img', { attrs: { src: imgUrl } }), + (!(env.bot.white || env.bot.black) || (p && !('level' in p))) && + h('div.bot-actions', [ + //p instanceof Bot && + h( + 'button.button' + buttonClass[color], + { + hook: onInsert(el => + el.addEventListener('click', e => { + editBot(color); + e.stopPropagation(); + }), + ), + }, + 'Edit', + ), + p && + !('level' in p) && + h( + 'button.button' + buttonClass[color], + { + hook: onInsert(el => + el.addEventListener('click', e => { + const bot = env.bot[color] as Bot; + if (!bot) return; + env.dev.setRating(bot.uid, env.game.speed, { r: 1500, rd: 350 }); + e.stopPropagation(); + env.dev.run({ + type: 'rate', + players: [bot.uid, ...env.bot.rateBots.map(b => b.uid)], + }); + }), + ), + }, + 'rate', + ), + ]), + h('div.stats', [ + h('span', env.game.nameOf(color)), + p && ratingSpan(p), + p instanceof Bot && h('span.stats', p.statsText), + h('span', resultsString(env.dev.log, env.bot[color]?.uid)), + ]), + ], + ); +} + +function ratingText(uid: string, speed: LocalSpeed): string { + const glicko = env.dev.getRating(uid, speed); + return `${glicko.r}${glicko.rd > 80 ? '?' : ''}`; +} + +function ratingSpan(p: Bot): VNode { + const glicko = env.dev.getRating(p.uid, env.game.speed); + return h('span.stats', [ + h('i', { attrs: { 'data-icon': speedIcon(env.game.speed) } }), + `${glicko.r}${glicko.rd > 80 ? '?' : ''}`, + ]); +} + +function speedIcon(speed: LocalSpeed = env.game.speed): string { + switch (speed) { + case 'classical': + return licon.Turtle; + case 'rapid': + return licon.Rabbit; + case 'blitz': + return licon.Fire; + case 'bullet': + case 'ultraBullet': + return licon.Bullet; + } +} +async function editBot(color: Color) { + await new EditDialog(color).show(); + env.redraw(); +} + +function clockOptions() { + return h('span', [ + ...(['initial', 'increment'] as const).map(type => { + return h('label', [ + type === 'initial' ? 'clk' : 'inc', + h( + `select.${type}`, + { + hook: onInsert(el => + el.addEventListener('change', () => { + const newVal = Number((el as HTMLSelectElement).value); + reset({ [type]: newVal }); + }), + ), + }, + [ + ...rangeTicks[type].map(([secs, label]) => + h('option', { attrs: { value: secs, selected: secs === env.game[type] } }, label), + ), + ], + ), + ]); + }), + ]); +} + +function reset(params: Partial): void { + env.game.load(params); + //localStorage.setItem('local.dev.setup', JSON.stringify(env.game.localSetup)); + env.redraw(); +} + +function dashboard() { + return h('div.dev-dashboard', [ + fen(), + clockOptions(), + h('span', [ + h('div', [ + h('label', { attrs: { title: 'instantly deduct bot move times. disable animations and sound' } }, [ + h('input', { + attrs: { type: 'checkbox', checked: env.dev.hurryProp() }, + hook: bind('change', e => env.dev.hurryProp((e.target as HTMLInputElement).checked)), + }), + 'hurry', + ]), + ]), + h('label', [ + 'games', + h('input.num-games', { + attrs: { type: 'text', value: storedIntProp('local.dev.numGames', 1)() }, + hook: bind('input', e => { + const el = e.target as HTMLInputElement; + const val = Number(el.value); + const valid = val >= 1 && val <= 1000 && !isNaN(val); + el.classList.toggle('invalid', !valid); + if (valid) localStorage.setItem('local.dev.numGames', `${val}`); + }), + }), + ]), + ]), + h('span', [ + h('button.button.button-metal', { hook: bind('click', () => roundRobin()) }, 'round robin'), + h('div.spacer'), + h('button.button.button-metal', { + attrs: { 'data-icon': licon.ShareIos }, + hook: bind('click', () => report()), + }), + h(`button.board-action.button.button-metal`, { + attrs: { 'data-icon': licon.Switch }, + hook: bind('click', () => { + env.game.load({ white: env.bot.uids.black, black: env.bot.uids.white }); + env.redraw(); + }), + }), + h(`button.board-action.button.button-metal`, { + attrs: { 'data-icon': licon.Reload }, + hook: onInsert(el => + el.addEventListener('click', () => { + env.game.load(undefined); + env.redraw(); + }), + ), + }), + renderPlayPause(), + ]), + ]); +} + +function progress() { + return h('div.dev-progress', [ + h('div.results', [ + env.dev.log.length > 0 && + h('button.button.button-empty.button-red.icon-btn.upper-right', { + attrs: { 'data-icon': licon.Cancel }, + hook: bind('click', () => { + env.dev.log = []; + env.redraw(); + }), + }), + ...playersWithResults(env.dev.log).map(p => { + const bot = env.bot.get(p)!; + return h( + 'div', + `${bot?.name ?? p} ${ratingText(p, env.game.speed)} ${resultsString(env.dev.log, p)}`, + ); + }), + ]), + ]); +} + +function renderPlayPause(): VNode { + const boardTurn = env.game.rewind?.turn ?? env.game.live.turn; + const disabled = !env.bot[boardTurn]; + const paused = env.game.isStopped || env.game.rewind || env.game.live.finished; + return h( + `button.play-pause.button.button-metal${disabled ? '.play.disabled' : paused ? '.play' : '.pause'}`, + { + hook: onInsert(el => + el.addEventListener('click', () => { + if (env.dev.hasUser && env.game.isStopped) env.game.start(); + else if (!paused) { + env.game.stop(); + env.redraw(); + } else { + if (env.dev.gameInProgress) env.game.start(); + else { + const numGamesField = document.querySelector('.num-games') as HTMLInputElement; + if (numGamesField.classList.contains('invalid')) { + numGamesField.focus(); + return; + } + const numGames = Number(numGamesField.value); + env.dev.run({ type: 'matchup', players: [env.bot.white!.uid, env.bot.black!.uid] }, numGames); + } + } + env.redraw(); + }), + ), + }, + ); +} + +function fen(): VNode { + const boardFen = env.game.rewind?.fen ?? env.game.live.fen; + return h('input.fen', { + key: boardFen, + attrs: { + type: 'text', + value: boardFen === co.fen.INITIAL_FEN ? '' : boardFen, + spellcheck: 'false', + placeholder: co.fen.INITIAL_FEN, + }, + hook: bind('input', e => { + let fen = co.fen.INITIAL_FEN; + const el = e.target as HTMLInputElement; + if (!el.value || co.fen.parseFen(el.value).isOk) fen = el.value || co.fen.INITIAL_FEN; + else { + el.classList.add('invalid'); + return; + } + el.classList.remove('invalid'); + if (fen) reset({ setupFen: fen }); + }), + }); +} + +function roundRobin() { + domDialog({ + class: 'round-robin-dialog', + htmlText: `

round robin participants

+
    ${[...env.bot.sorted(), ...env.bot.rateBots.filter(b => b.ratings[env.game.speed] % 100 === 0)] + .map(p => { + const checked = isNaN(parseInt(p.uid.slice(1))) + ? storedBooleanProp(`local.dev.tournament-${p.uid.slice(1)}`, true)() + : false; + return `
  • +
  • `; + }) + .join('')}
+ Repeat: `, + actions: [ + { + selector: '#start-tournament', + listener: (_, dlg) => { + const participants = Array.from(dlg.viewEl.querySelectorAll('input:checked')).map( + (el: HTMLInputElement) => el.value, + ); + if (participants.length < 2) return; + const iterationField = dlg.viewEl.querySelector('input[type="number"]') as HTMLInputElement; + const iterations = Number(iterationField.value); + env.dev.run( + { + type: 'roundRobin', + players: participants, + }, + isNaN(iterations) ? 1 : iterations, + ); + dlg.close(); + }, + }, + { + selector: 'input[type="checkbox"]', + event: 'change', + listener: e => { + const el = e.target as HTMLInputElement; + if (!isNaN(parseInt(el.value.slice(1)))) return; + storedBooleanProp(`local.dev.tournament-${el.value.slice(1)}`, true)(el.checked); + }, + }, + ], + show: true, + modal: true, + }); +} + +async function report() { + const text = await env.dev.getTrace(); + if (text.length) { + site.asset.loadEsm('bits.diagnosticDialog', { + init: { text, header: 'Game Info', submit: 'send to lichess' }, + }); + } +} + +let botSelector: HandOfCards | undefined; + +function showBotSelector(clickedEl: HTMLElement) { + if (botSelector) return; + const cardData = [...env.bot.sorted('classical').map(b => env.bot.card(b))].filter(defined); + cardData.forEach(c => c.classList.push('left')); + const main = document.querySelector('main') as HTMLElement; + const drops: Drop[] = []; + main.classList.add('with-cards'); + + document.querySelectorAll('main .player')?.forEach(el => { + const selected = uidToDomId(env.bot[el.dataset.color as Color]?.uid); + drops.push({ el: el as HTMLElement, selected }); + }); + botSelector = handOfCards({ + viewEl: main, + getDrops: () => drops, + getCardData: () => cardData, + select: (el, domId) => { + const color = (el ?? clickedEl).dataset.color as Color; + reset({ ...env.bot.uids, [color]: domIdToUid(domId) }); + }, + onRemove: () => { + main.classList.remove('with-cards'); + botSelector = undefined; + }, + orientation: 'left', + transient: true, + }); +} diff --git a/ui/local/src/dev/devTypes.ts b/ui/local/src/dev/devTypes.ts new file mode 100644 index 0000000000000..b81111eadd10e --- /dev/null +++ b/ui/local/src/dev/devTypes.ts @@ -0,0 +1,118 @@ +import type { Filter, FilterFacet, Book, SoundEvent, Sound as NamedSound } from '../types'; +import type { Pane } from './pane'; +import type { AssetType } from './devAssets'; +import type { EditDialog } from './editDialog'; + +export type Sound = Omit; + +export interface Template { + min: Record; + max: Record; + step: Record; + value: Record; +} + +export interface PaneInfo { + type?: InfoType; + id?: string; + class?: string[]; + label?: string; + title?: string; + toggle?: boolean; + requires?: Requirement; + value?: string | number | boolean | Filter; + assetType?: AssetType; +} + +export interface SelectInfo extends PaneInfo { + type: 'select'; + value?: string; + choices?: { name: string; value: string }[]; +} + +export interface TextareaInfo extends PaneInfo { + type: 'textarea'; + value?: string; + rows?: number; +} + +export interface NumberInfo extends PaneInfo { + type: 'number' | 'range'; + value?: number; + min: number; + max: number; +} + +export interface RangeInfo extends NumberInfo { + type: 'range'; + step: number; +} + +export interface TextInfo extends PaneInfo { + type: 'text'; + value?: string; +} + +export interface BooksInfo extends PaneInfo { + type: 'books'; + template: Template<{ weight: number }>; +} + +export interface SoundEventInfo extends PaneInfo { + type: 'soundEvent'; +} + +interface BaseSoundsInfo extends PaneInfo { + type: 'sounds'; + template: Template; +} + +export type SoundsInfo = BaseSoundsInfo & { + [key in SoundEvent]: SoundEventInfo; +}; + +export interface FilterInfo extends PaneInfo { + type: 'filter'; + value: Filter; +} + +export type InfoKey = + | keyof SelectInfo + | keyof TextInfo + | keyof TextareaInfo + | keyof RangeInfo + | keyof NumberInfo + | keyof BooksInfo + | keyof SoundEventInfo + | keyof FilterInfo; + +export type AnyInfo = + | SelectInfo + | TextInfo + | TextareaInfo + | RangeInfo + | NumberInfo + | BooksInfo + | SoundsInfo + | SoundEventInfo + | FilterInfo + | AnyInfo[]; + +type ExtractType = T extends { type: infer U } ? U : never; + +type InfoType = ExtractType | 'group' | 'radioGroup'; + +export type PropertyValue = Filter | Book[] | Sound[] | string | number | boolean | undefined; + +type SchemaValue = Schema | AnyInfo | PropertyValue | Requirement | string[]; + +export interface Schema extends PaneInfo { + [key: string]: SchemaValue; + type?: undefined | 'group' | 'radioGroup'; +} + +export type PaneArgs = { host: EditDialog; info: PaneInfo & Record; parent?: Pane }; + +export type PropertySource = 'scratch' | 'local' | 'server' | 'schema'; + +export type Requirement = string | { every: Requirement[] } | { some: Requirement[] }; diff --git a/ui/local/src/dev/devUtil.ts b/ui/local/src/dev/devUtil.ts new file mode 100644 index 0000000000000..6a60b09e03b9e --- /dev/null +++ b/ui/local/src/dev/devUtil.ts @@ -0,0 +1,91 @@ +import * as co from 'chessops'; +import * as licon from 'common/licon'; +import type { BotInfo } from '../types'; +import { frag } from 'common'; +import type { NumberInfo, RangeInfo } from './devTypes'; +import type { Result } from './devCtrl'; + +type ObjectPath = { obj: any; path: { keys: string[] } | { id: string } }; + +export function resolveObjectProperty(op: ObjectPath): string[] { + return pathToKeys(op).reduce((o, key) => o?.[key], op.obj); +} + +export function removeObjectProperty({ obj, path }: ObjectPath, stripEmptyObjects = false): void { + const keys = pathToKeys({ obj, path }); + if (!(obj && keys[0] && obj[keys[0]])) return; + if (keys.length > 1) + removeObjectProperty({ obj: obj[keys[0]], path: { keys: keys.slice(1) } }, stripEmptyObjects); + if (keys.length === 1 || (stripEmptyObjects && Object.keys(obj[keys[0]]).length === 0)) { + delete obj[keys[0]]; + } +} + +export function setObjectProperty({ obj, path, value }: ObjectPath & { value: any }): void { + const keys = pathToKeys({ obj, path }); + if (keys.length === 0) return; + if (keys.length === 1) obj[keys[0]] = value; + else if (!(keys[0] in obj)) obj[keys[0]] = {}; + setObjectProperty({ obj: obj[keys[0]], path: { keys: keys.slice(1) }, value }); +} + +export function deadStrip(info: BotInfo & { disabled: Set }): BotInfo { + if (!('disabled' in info)) return info; + + const temp = structuredClone(info); + + for (const id of info.disabled) { + removeObjectProperty({ obj: temp, path: { id } }, true); + } + return temp; +} + +export function maxChars(info: NumberInfo | RangeInfo): number { + const len = Math.max(info.max.toString().length, info.min.toString().length); + if (!('step' in info)) return len; + const fractionLen = info.step < 1 ? String(info.step).length - String(info.step).indexOf('.') - 1 : 0; + return len + fractionLen + 1; +} + +export function score(outcome: Color | undefined, color: Color = 'white'): number { + return outcome === color ? 1 : outcome === undefined ? 0.5 : 0; +} + +export function botScore(r: Result, uid: string): number { + return r.winner === undefined ? 0.5 : r[r.winner] === uid ? 1 : 0; +} + +export function resultsObject( + results: Result[], + uid: string | undefined, +): { w: number; d: number; l: number } { + return results.reduce( + (a, r) => ({ + w: a.w + (r.winner !== undefined && r[r.winner] === uid ? 1 : 0), + d: a.d + (r.winner === undefined && (r.white === uid || r.black === uid) ? 1 : 0), + l: a.l + (r.winner !== undefined && r[co.opposite(r.winner)] === uid ? 1 : 0), + }), + { w: 0, d: 0, l: 0 }, + ); +} + +export function resultsString(results: Result[], uid?: string): string { + const { w, d, l } = resultsObject(results, uid); + return `${w}/${d}/${l}`; +} + +export function playersWithResults(results: Result[]): string[] { + return [...new Set(results.flatMap(r => [r.white ?? '', r.black ?? ''].filter(x => x)))]; +} + +export function renderRemoveButton(cls: string = ''): Node { + return frag( + `'); + const input = frag(``); + domDialog({ + class: 'dev-view', + htmlText: `

Choose a user id

must be unique and begin with #

`, + append: [ + { node: input, where: 'span' }, + { node: ok, where: 'span' }, + ], + focus: 'input', + modal: true, + actions: [ + { + selector: 'input', + event: ['input'], + listener: () => { + const newUid = input.value.toLowerCase(); + const isValid = /^#[a-z][a-z0-9-]{2,19}$/.test(newUid) && this.bots[newUid] === undefined; + input.dataset.uid = isValid ? newUid : ''; + input.classList.toggle('invalid', !isValid); + ok.classList.toggle('disabled', !isValid); + }, + }, + { + selector: 'input', + event: ['keydown'], + listener: e => { + if ('key' in e && e.key === 'Enter') { + ok.click(); + e.stopPropagation(); + e.preventDefault(); + } + }, + }, + { + selector: '.ok', + listener: (_, dlg) => { + if (!input.dataset.uid) return; + env.bot.save({ ...EditDialog.default, uid: input.dataset.uid, name: input.dataset.uid.slice(1) }); + this.selectBot(input.dataset.uid); + dlg.close(); + }, + }, + ], + }).then(dlg => { + input.setSelectionRange(1, 1); + dlg.show(); + }); + } + + private async json(): Promise { + const version = this.bot.version; + const view = frag($html` +
+ +
+ + + +
+
`); + const dlg = await domDialog({ + append: [{ node: view }], + onClose: () => {}, + show: true, + actions: [ + { selector: '[data-action="cancel"]', result: 'cancel' }, + { selector: '[data-action="save"]', result: 'save' }, + { + selector: '[data-action="copy"]', + listener: async () => { + await navigator.clipboard.writeText(view.querySelector('.json')!.value); + const copied = frag( + `
COPIED
`, + ); + view.querySelector('[data-action="copy"]')?.before(copied); + setTimeout(() => copied.remove(), 2000); + }, + }, + ], + }); + if (dlg.returnValue !== 'save') return; + + const newBot = { + ...(JSON.parse(view.querySelector('.json')!.value) as BotInfo), + version, + }; + this.scratch[this.uid] = Object.defineProperties(new Bot(newBot), { + disabled: { value: new Set() }, + viewing: { value: new Map() }, + }) as WritableBot; + this.makeEditView(); + this.update(); + } + + private deckEl = frag($html` +
+
+
+ legend + + + + +
+
`); + + private globalActionsEl = frag($html` +
+ + + + +
`); + + private botActionsEl = frag($html` +
+ + + + + + +
`); + + private get botCardEl(): Node { + const botCard = frag($html` +
+
${this.uid}
+
`); + + buildFromSchema(this, ['info']); + botCard.firstElementChild?.appendChild(this.panes.byId['info_description'].el); + botCard.append(this.panes.byId['info_name'].el); + botCard.append(this.panes.byId['info_ratings_classical'].el); + botCard.append(this.botActionsEl); + return botCard; + } +} + +interface ReadableBot extends BotInfo { + readonly [key: string]: any; +} + +interface WritableBot extends Bot { + [key: string]: any; + disabled: Set; + viewing: Map; +} diff --git a/ui/local/src/dev/filterPane.ts b/ui/local/src/dev/filterPane.ts new file mode 100644 index 0000000000000..8c5d1f5e4e7f4 --- /dev/null +++ b/ui/local/src/dev/filterPane.ts @@ -0,0 +1,221 @@ +import { Pane } from './pane'; +import { Chart, PointElement, LinearScale, LineController, LineElement } from 'chart.js'; +import { type FilterBy, addPoint, asData, filterData, filterFacets, filterBys } from '../filter'; +import { frag } from 'common'; +import { clamp } from 'common/algo'; +import type { PaneArgs, FilterInfo } from './devTypes'; +import type { Filter, FilterFacet } from '../types'; + +type FacetToggle = { el: HTMLElement; input: HTMLInputElement }; + +export class FilterPane extends Pane { + info: FilterInfo; + graphEl: HTMLElement; + graph: Chart; + facets = {} as { [key in FilterFacet]: FacetToggle }; + + constructor(p: PaneArgs) { + super(p); + if (!this.isDefined) this.setProperty(structuredClone(this.info.value)); + if (this.info.title && this.label) this.label.title = this.info.title; + this.el.title = ''; + this.input = document.createElement('select'); + this.input.title = 'move / score / time function combinator'; + this.input.append( + ...filterBys.map(c => + frag(``), + ), + ); + this.input.addEventListener('change', e => { + this.paneValue.by = (e.target as HTMLSelectElement).value as FilterBy; + super.update(); + }); + this.label?.append(this.input); + const tabs = frag(`
`); + for (const facet of filterFacets) { + this.facets[facet] = this.makeFacet(facet); + tabs.append(this.facets[facet].el); + } + this.el.firstElementChild?.append(tabs); + this.graphEl = frag(`
`); + this.el.append(this.graphEl); + this.host.janitor.addCleanupTask(() => this.graph?.destroy()); + } + + setEnabled(enabled?: boolean): boolean { + if (this.requirementsAllow) enabled ??= !this.isOptional || (this.isDefined && !this.isDisabled); + else enabled = false; + let enabledFacets = 0; + for (const [facet, facetPane] of Object.entries(this.facets)) { + facetPane.el.classList.toggle('active', enabled && facet === this.viewing && facetPane.input.checked); + if (facetPane.input.checked) enabledFacets++; + } + this.input?.classList.toggle('none', enabledFacets < 2); + super.setEnabled(enabled); + this.renderGraph(); + return enabled; + } + + private toggleFacet = (facet: FilterFacet, checked?: boolean): boolean => { + if (checked) this.facets[facet].input.checked = checked; + else checked = this.facets[facet].input.checked; + if (checked) { + this.host.bot.disabled.delete(`${this.id}_${facet}`); + this.paneValue[facet] ??= []; + } else this.host.bot.disabled.add(`${this.id}_${facet}`); + return checked; + }; + + private makeFacet(facet: FilterFacet): { input: HTMLInputElement; el: HTMLElement } { + const input = frag(``); + const el = frag( + `
${facet}
`, + ); + input.addEventListener('change', () => { + if (this.toggleFacet(facet)) this.viewing = facet; + else if (this.viewing === facet) this.viewing = undefined; + super.update(); + }); + input.checked = Boolean(this.paneValue?.[facet]) && !this.host.bot.disabled.has(`${this.id}_${facet}`); + if (input.checked && !this.viewing) this.viewing = facet; + el.addEventListener('click', e => { + if (e.target instanceof HTMLInputElement || !(e.target instanceof HTMLElement)) return; + if (this.viewing === facet) this.viewing = undefined; + else { + this.viewing = facet; + this.toggleFacet(facet, true); + } + if (this.facets[facet].input.checked && this.viewing === facet) { + e.target.closest('.facet')?.classList.add('active'); + this.paneValue[facet] ??= []; + } + super.update(e); + }); + el.prepend(input); + return { input, el }; + } + + update(e?: Event): void { + if (!(e instanceof MouseEvent && this.viewing && e.target instanceof HTMLCanvasElement)) return; + + const f = this.paneValue; + const data = this.paneValue[this.viewing]; + const remove = this.graph.getElementsAtEventForMode(e, 'nearest', { intersect: true }, false); + if (remove.length > 0 && remove[0].index > 0) { + data?.splice(remove[0].index - 1, 1); + } else { + const rect = e.target.getBoundingClientRect(); + + const graphX = this.graph.scales.x.getValueForPixel(e.clientX - rect.left); + const graphY = this.graph.scales.y.getValueForPixel(e.clientY - rect.top); + if (!graphX || !graphY) return; + addPoint(f, this.viewing, [clamp(graphX, filterData[this.viewing].domain), clamp(graphY, f.range)]); + } + this.graph.data.datasets[0].data = asData(f, this.viewing); + this.graph.update(); + } + + get paneValue(): Filter { + return this.getProperty() as Filter; + } + + get viewing(): FilterFacet | undefined { + return this.host.bot.viewing?.get(this.id) as FilterFacet | undefined; + } + + set viewing(f: FilterFacet | undefined) { + if (f) this.host.bot.viewing.set(this.id, f); + else this.host.bot.viewing.delete(this.id); + } + + private renderGraph() { + this.graph?.destroy(); + this.graphEl.classList.remove('hidden', 'none'); + const f = this.paneValue; + if (!this.viewing || !f?.[this.viewing] || this.host.bot.disabled.has(`${this.id}_${this.viewing}`)) { + this.graphEl.classList.add(Object.values(this.facets).some(x => x.input.checked) ? 'hidden' : 'none'); + return; + } + this.graph = new Chart(this.graphEl.querySelector('canvas')!.getContext('2d')!, { + type: 'line', + data: { + datasets: [ + { + data: asData(f, this.viewing), + backgroundColor: 'rgba(75, 192, 192, 0.6)', + pointRadius: 4, + pointHoverBackgroundColor: 'rgba(220, 105, 105, 0.6)', + }, + ], + }, + options: { + parsing: false, + responsive: true, + maintainAspectRatio: false, + animation: false, + scales: { + x: { + type: 'linear', + min: filterData[this.viewing].domain.min, + max: filterData[this.viewing].domain.max, + //reverse: this.viewing === 'time', + ticks: getTicks(this.viewing), + title: { + display: true, + color: '#555', + text: + this.viewing === 'move' + ? 'full moves' + : this.viewing === 'time' + ? 'think time' + : `outcome expectancy for ${this.host.bot.name.toLowerCase()}`, + }, + }, + y: { + min: f.range.min, + max: f.range.max, + title: { + display: true, + color: '#555', + text: this.info.label, + }, + }, + }, + }, + }); + } +} + +function getTicks(facet: FilterFacet) { + return facet === 'time' + ? { + callback: (value: number) => ticks[value] ?? '', + maxTicksLimit: 11, + stepSize: 1, + } + : undefined; +} + +const ticks: Record = { + '-2': '¼s', + '-1': '½s', + 0: '1s', + 1: '2s', + 2: '4s', + 3: '8s', + 4: '15s', + 5: '30s', + 6: '1m', + 7: '2m', + 8: '4m', +}; + +const tooltips: { [key in FilterFacet]: string } = { + move: 'vary the filter parameter by number of full moves since start of game', + score: `vary the filter parameter by current outcome expectancy for bot`, + time: 'vary the filter parameter by think time in seconds per move', +}; + +Chart.register(PointElement, LinearScale, LineController, LineElement); diff --git a/ui/local/src/dev/historyDialog.ts b/ui/local/src/dev/historyDialog.ts new file mode 100644 index 0000000000000..3a329c1533ba6 --- /dev/null +++ b/ui/local/src/dev/historyDialog.ts @@ -0,0 +1,162 @@ +import { domDialog, type Dialog } from 'common/dialog'; +import { frag, escapeHtml } from 'common'; +import * as licon from 'common/licon'; +import type { EditDialog } from './editDialog'; +import { env } from '../localEnv'; +import type { BotInfo } from '../types'; +import stringify from 'json-stringify-pretty-compact'; +import diff from 'fast-diff'; + +interface BotVersionInfo extends Omit { + author: string; + version: string | number; +} + +export async function historyDialog(host: EditDialog, uid: string): Promise { + const dlg = new HistoryDialog(host, uid); + await dlg.show(); +} + +class HistoryDialog { + dlg: Dialog; + view: HTMLElement; + versions: BotVersionInfo[]; + + constructor( + readonly host: EditDialog, + readonly uid: string, + ) {} + + async show(): Promise { + this.view = frag(`
+
+
+
+ + +
+
+ +
+
`); + await this.updateHistory(); + this.dlg = await domDialog({ + append: [{ node: this.view }], + onClose: () => {}, + actions: [ + { selector: '[data-action="pull"]', listener: this.pull }, + { selector: '[data-action="push"]', listener: this.push }, + { selector: '.version', listener: this.clickItem }, + { selector: '.version', event: 'mouseenter', listener: this.mouseEnterItem }, + { selector: '.version', event: 'mouseleave', listener: () => this.json() }, + { selector: '[data-action="copy"]', listener: this.copy }, + ], + }); + this.select(this.versions[this.versions.length - 1]); + this.dlg.show(); + const versionsEl = this.view.querySelector('.versions') as HTMLElement; + versionsEl.scrollTop = versionsEl.scrollHeight; + return this; + } + + async updateHistory() { + const history = await (await fetch('/local/dev/history?id=' + encodeURIComponent(this.uid))).json(); + this.versions = history.bots.reverse(); + if (env.bot.localBots[this.uid]) + this.versions.push({ ...env.bot.localBots[this.uid], version: 'local', author: env.user }); + const versionsEl = this.view.querySelector('.versions') as HTMLElement; + versionsEl.innerHTML = ''; + for (const bot of this.versions) { + const isLive = + bot === env.bot.localBots[this.host.uid] || bot === this.versions[this.versions.length - 1]; + const version = bot.version; + const div = frag( + `
`, + ); + const versionStr = typeof version === 'number' ? `#${version}` : version; + const span = frag(`${bot.author}`); + if (isLive) span.appendChild(frag(``)); + div.append(frag(`${versionStr}`), span); + versionsEl.append(div); + } + versionsEl.scrollTop = versionsEl.scrollHeight; + this.dlg?.updateActions(); + } + + clickItem = async (e: Event) => { + this.select(this.version((e.target as HTMLElement).dataset.version)); + }; + + mouseEnterItem = async (e: Event) => { + this.json(this.version((e.target as HTMLElement)?.dataset.version)); + }; + + select(bot: BotVersionInfo | undefined = this.selected): void { + this.view.querySelector('[data-action="pull"]')?.classList.toggle('none', bot && bot === this.live); + this.view + .querySelector('[data-action="push"]') + ?.classList.toggle('none', !env.canPost || bot?.version !== 'local'); + if (!bot) return; + this.view.querySelectorAll('.version')?.forEach(v => v.classList.remove('selected')); + this.versionEl(bot.version)?.classList.add('selected'); + this.json(bot); + } + + version(version: string | number | undefined): BotVersionInfo | undefined { + if (!version) return; + return this.versions.find(b => String(b.version) === String(version)); + } + + versionEl(version: string | number): HTMLElement | undefined { + return this.view.querySelector(`.version[data-version="${version}"]`) as HTMLElement; + } + + copy = async () => { + await navigator.clipboard.writeText(stringify(this.selected!)); + const copied = frag(`
COPIED
`); + this.view.querySelector('[data-action="copy"]')?.before(copied); + setTimeout(() => copied.remove(), 2000); + }; + + pull = async () => { + await env.bot.save(this.selected as BotInfo); + await this.updateHistory(); + this.select(); + this.host.update(); + }; + + push = async () => { + const err = await env.push.pushBot(this.selected as BotInfo); + if (err) { + alert(`push failed: ${escapeHtml(err)}`); + return; + } + await this.updateHistory(); + this.select(); + this.host.update(); + }; + + get selected() { + const selected = this.view.querySelector('.version.selected') as HTMLElement; + return selected && this.versions.find(b => String(b.version) === selected.dataset.version); + } + + get live() { + return this.versions[this.versions.length - 1]; + } + + json(hover?: BotVersionInfo) { + const json = this.view.querySelector('.json') as HTMLElement; + json.innerHTML = ''; + const changes = diff( + stringify(this.selected, { indent: 2, maxLength: 80 }), + stringify(hover ?? this.selected, { indent: 2, maxLength: 80 }), + ); + for (const change of changes) { + const span = frag(`${change[1]}`); + if (change[0] === 1) span.classList.add('hovered'); + else if (change[0] === -1) span.classList.add('selected'); + json.append(span); + } + } +} diff --git a/ui/local/src/dev/local.dev.ts b/ui/local/src/dev/local.dev.ts new file mode 100644 index 0000000000000..badb9494e21bf --- /dev/null +++ b/ui/local/src/dev/local.dev.ts @@ -0,0 +1,60 @@ +import { attributesModule, classModule, init } from 'snabbdom'; +import { GameCtrl } from '../gameCtrl'; +import { DevCtrl } from './devCtrl'; +import { DevAssets, type AssetList } from './devAssets'; +import { renderDevSide } from './devSideView'; +import { BotCtrl } from '../botCtrl'; +import { PushCtrl } from './pushCtrl'; +import { env, makeEnv } from '../localEnv'; +import { renderGameView } from '../gameView'; +import { LocalDb } from '../localDb'; +import type { RoundController } from 'round'; +import type { LocalPlayOpts } from '../types'; + +const patch = init([classModule, attributesModule]); + +interface LocalPlayDevOpts extends LocalPlayOpts { + assets?: AssetList; + pgn?: string; + name?: string; + canPost: boolean; +} + +export async function initModule(opts: LocalPlayDevOpts): Promise { + if (opts.pgn && opts.name) { + makeEnv({ bot: new BotCtrl(), assets: new DevAssets() }); + await Promise.all([env.bot.initBots(), env.assets.init()]); + await env.repo.importPgn(opts.name, new Blob([opts.pgn], { type: 'application/x-chess-pgn' }), 16, true); + return; + } + if (window.screen.width < 1260) return; + + makeEnv({ + redraw, + bot: new BotCtrl(), + push: new PushCtrl(), + assets: new DevAssets(opts.assets), + dev: new DevCtrl(), + db: new LocalDb(), + game: new GameCtrl(opts), + user: opts.userId, + username: opts.username, + canPost: opts.canPost, + }); + + await Promise.all([env.db.init(), env.bot.init(opts.bots), env.dev.init(), env.assets.init()]); + env.game.load(await env.db.get()); + + const el = document.createElement('main'); + document.getElementById('main-wrap')?.appendChild(el); + + let vnode = patch(el, renderGameView(renderDevSide())); + + env.round = await site.asset.loadEsm('round', { init: env.game.proxy.roundOpts }); + redraw(); + + function redraw() { + vnode = patch(vnode, renderGameView(renderDevSide())); + env.round.redraw(); + } +} diff --git a/ui/local/src/dev/pane.ts b/ui/local/src/dev/pane.ts new file mode 100644 index 0000000000000..bb5a22b675764 --- /dev/null +++ b/ui/local/src/dev/pane.ts @@ -0,0 +1,357 @@ +import { removeObjectProperty, setObjectProperty, maxChars } from './devUtil'; +import { findMap } from 'common/algo'; +import { frag } from 'common'; +import { getSchemaDefault, requiresOpRe } from './schema'; +import type { EditDialog } from './editDialog'; +import { env } from '../localEnv'; +import type { + PaneArgs, + SelectInfo, + TextInfo, + TextareaInfo, + NumberInfo, + RangeInfo, + PaneInfo, + PropertySource, + PropertyValue, + Requirement, +} from './devTypes'; + +export class Pane { + input?: HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement; + label?: HTMLLabelElement; + toggle?: (v?: boolean) => boolean; // TODO use common whatever + readonly host: EditDialog; + readonly info: Info; + readonly el: HTMLElement; + readonly parent: Pane | undefined; + + constructor(args: PaneArgs) { + Object.assign(this, args); + this.el = document.createElement(this.isFieldset ? 'fieldset' : 'div'); + this.el.id = this.id; + this.info.class?.forEach(c => this.el.classList.add(c)); + this.host.panes.add(this); + if (this.info.title) this.el.title = this.info.title; + if (this.info.label) { + this.label = frag(``); + if (this.info.class?.includes('setting')) this.el.appendChild(this.label); + else { + const header = document.createElement(this.isFieldset ? 'legend' : 'span'); + header.appendChild(this.label); + this.el.appendChild(header); + } + } + const toggleInputEl = this.radioGroup + ? frag(``) + : this.isOptional && this.info.label && this.info.type !== 'books' && this.info.type !== 'soundEvent' + ? frag(``) + : undefined; + if (!toggleInputEl) return; + toggleInputEl.checked = this.isDefined; + this.label?.prepend(toggleInputEl); + this.toggle = (v?: boolean) => { + if (v !== undefined) toggleInputEl.checked = v; + return toggleInputEl.checked; + }; + } + + setEnabled(enabled: boolean = this.canEnable): boolean { + const allowed = this.requirementsAllow; + if (!allowed) enabled = false; + this.el.classList.toggle('none', !allowed); + + if (this.input || this.toggle) { + const { panes: editor, view } = this.host; + this.el.classList.toggle('disabled', !enabled); + + if (enabled) this.host.bot.disabled.delete(this.id); + else this.host.bot.disabled.add(this.id); + + if (this.input && !this.input.value) + this.input.value = this.getStringProperty(['scratch', 'local', 'server', 'schema']); + + for (const kid of this.children) { + kid.el.classList.toggle('none', !enabled || !kid.requirementsAllow); + if (!enabled) continue; + if (!kid.isOptional) kid.update(); + else if (kid.info.type !== 'radioGroup') continue; + const radios = Object.values(editor.byId).filter(x => x.radioGroup === kid.id); + const active = radios?.find(x => x.enabled) ?? radios?.find(x => x.getProperty(['local', 'server'])); + if (active) active.update(); + else if (radios.length) radios[0].update(); + } + + this.toggle?.(enabled); + if (this.radioGroup && enabled) + view.querySelectorAll(`[name="${this.radioGroup}"]`).forEach(el => { + const radio = editor.byEl(el); + if (radio === this) return; + radio?.setEnabled(false); + }); + } + for (const r of this.host.panes.dependsOn(this.id)) r.setEnabled(); + return enabled; + } + + update(_?: Event): void { + this.setProperty(this.paneValue); + this.setEnabled(this.isDefined); + this.host.update(); + } + + setProperty(value: PropertyValue): void { + if (value === undefined) { + if (this.paneValue) removeObjectProperty({ obj: this.host.bot, path: { id: this.id } }); + } else setObjectProperty({ obj: this.host.bot, path: { id: this.id }, value }); + } + + getProperty(from: PropertySource[] = ['scratch']): PropertyValue { + return findMap(from, src => + src === 'schema' + ? getSchemaDefault(this.id) + : this.path.reduce( + (o, key) => o?.[key], + src === 'scratch' ? this.host.bot : src === 'local' ? this.host.localBot : this.host.serverBot, + ), + ); + } + + getStringProperty(src: PropertySource[] = ['scratch']): string { + const prop = this.getProperty(src); + return typeof prop === 'object' ? JSON.stringify(prop) : prop !== undefined ? String(prop) : ''; + } + + get paneValue(): PropertyValue { + return this.input?.value; + } + + get id(): string { + return this.info.id!; + } + + get enabled(): boolean { + if (this.isDisabled) return false; + const kids = this.children; + if (!kids.length) return this.isDefined && this.requirementsAllow; + return kids.every(x => x.enabled || x.isOptional); + } + + get requires(): string[] { + return getRequirementIds(this.info.requires); + } + + protected init(): void { + this.setEnabled(); + if (this.input) this.el.appendChild(this.input); + } + + protected get path(): string[] { + return this.id.split('_').slice(1); + } + + protected get radioGroup(): string | undefined { + return this.parent?.info.type === 'radioGroup' ? this.parent.id : undefined; + } + + protected get isFieldset(): boolean { + return this.info.type === 'group' || this.info.type === 'books' || this.info.type === 'sounds'; + } + + protected get isDefined(): boolean { + return this.getProperty() !== undefined; + } + + protected get isDisabled(): boolean { + return this.host.bot.disabled.has(this.id) || (this.parent !== undefined && this.parent.isDisabled); + } + + protected get children(): Pane[] { + if (!this.id) return []; + return Object.keys(this.host.panes.byId) + .filter(id => id.startsWith(this.id) && id.split('_').length === this.id.split('_').length + 1) + .map(id => this.host.panes.byId[id]); + } + + protected get isOptional(): boolean { + return this.info.toggle === true; + } + + protected get requirementsAllow(): boolean { + return !this.parent?.isDisabled && this.evaluate(this.info.requires); + } + + protected get canEnable(): boolean { + const kids = this.children; + if (this.input && !kids.length) return this.isDefined; + return kids.every(x => x.enabled || x.isOptional) && this.requirementsAllow; + } + + private evaluate(requirement: Requirement | undefined): boolean { + if (typeof requirement === 'string') { + const req = requirement.trim(); + if (req.startsWith('!')) { + const paneId = req.slice(1).trim(); + const pane = this.host.panes.byId[paneId]; + return pane ? !pane.enabled : true; + } + + const op = req.match(requiresOpRe)?.[0] as string; + const [left, right] = req.split(op).map(x => x.trim()); + + if ([left, right].some(x => this.host.panes.byId[x]?.enabled === false)) return false; + + const maybeLeftPane = this.host.panes.byId[left]; + const maybeRightPane = this.host.panes.byId[right]; + const leftValue = maybeLeftPane ? maybeLeftPane.paneValue : left; + const rightValue = maybeRightPane ? maybeRightPane.paneValue : right; + + switch (op) { + case '==': + return String(leftValue) === String(rightValue); + case '!=': + return String(leftValue) !== String(rightValue); + case '<<=': + return String(leftValue).startsWith(String(rightValue)); + case '>=': + return Number(leftValue) >= Number(rightValue); + case '>': + return Number(leftValue) > Number(rightValue); + case '<=': + return Number(leftValue) <= Number(rightValue); + case '<': + return Number(leftValue) < Number(rightValue); + default: + return maybeLeftPane?.enabled; + } + } else if (Array.isArray(requirement)) { + return requirement.every(r => this.evaluate(r)); + } else if (typeof requirement === 'object') { + if ('every' in requirement) { + return requirement.every.every(r => this.evaluate(r)); + } else if ('some' in requirement) { + return requirement.some.some(r => this.evaluate(r)); + } + } + return true; + } +} + +export class SelectSetting extends Pane { + input: HTMLSelectElement = frag('', + ); + constructor(p: PaneArgs) { + super(p); + this.init(); + } + get paneValue(): string { + return this.input.value; + } +} + +export class TextareaSetting extends Pane { + input: HTMLTextAreaElement = frag('