diff --git a/app/Env.scala b/app/Env.scala index 255e6682ca56..4d946d9ed704 100644 --- a/app/Env.scala +++ b/app/Env.scala @@ -25,6 +25,7 @@ final class Env( given translator: lila.core.i18n.Translator = lila.i18n.Translator given scheduler: Scheduler = system.scheduler given lila.core.config.RateLimit = net.rateLimit + 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 @@ -57,6 +58,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] @@ -94,6 +96,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 2282f411c806..a4d1272b7050 100644 --- a/app/LilaComponents.scala +++ b/app/LilaComponents.scala @@ -102,6 +102,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] @@ -126,6 +127,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 000000000000..b79f82781391 --- /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 89891233edab..6627870d1d4f 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 4412b32d2660..42433f2a6165 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 000000000000..545e78172415 --- /dev/null +++ b/app/controllers/Local.scala @@ -0,0 +1,138 @@ +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( + white: Option[String], + black: Option[String], + fen: Option[String], + time: Option[String], + go: Option[String] + ) = Open: + val initial = time.map(_.toFloat) + val increment = time.flatMap(_.split('+').drop(1).headOption.map(_.toFloat)) + val setup = + if white.isDefined || black.isDefined || fen.isDefined || time.isDefined then + GameSetup(white, black, fen, initial, increment, optTrue(go)).some + else none + for + bots <- env.local.repo.getLatestBots() + page <- renderPage(indexPage(setup, 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 <- getDevAssets + page <- renderPage(indexPage(none, bots, assets.some)) + yield Ok(page).enforceCrossSiteIsolation.withHeaders("Service-Worker-Allowed" -> "/") + + def devAssets = Auth: ctx ?=> + getDevAssets.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(_ => getDevAssets.map(JsonOk)) + + def devDeleteAsset(key: String) = Secure(_.BotEditor): _ ?=> + env.local.repo + .deleteAsset(key) + .flatMap(_ => getDevAssets.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(setup: Option[GameSetup], 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("setup", setup) + .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 getDevAssets = + 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 357aa366c93d..7794f13e7321 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/RelayRound.scala b/app/controllers/RelayRound.scala index 8d90f4443551..df55f30ae7c8 100644 --- a/app/controllers/RelayRound.scala +++ b/app/controllers/RelayRound.scala @@ -268,20 +268,22 @@ final class RelayRound( isSubscribed <- ctx.me.soFu: me => env.relay.api.isSubscribed(rt.tour.id, me.userId) videoUrls <- embed match - case VideoEmbed.Auto => - fuccess: - rt.tour.pinnedStream - .ifFalse(rt.round.isFinished) - .flatMap(_.upstream) - .map(_.urls(netDomain).toPair) - case VideoEmbed.No => fuccess(none) case VideoEmbed.Stream(userId) => env.streamer.api .find(userId) .flatMapz(s => env.streamer.liveStreamApi.of(s).dmap(some)) .map: _.flatMap(_.stream).map(_.urls(netDomain).toPair) - crossSiteIsolation = videoUrls.isEmpty + case VideoEmbed.PinnedStream => + fuccess: + rt.tour.pinnedStream + // the below line commented out for testy purposes + // .ifFalse(rt.round.isFinished) + .flatMap(_.upstream) + .map(_.urls(netDomain).toPair) + case _ => fuccess(none) + crossSiteIsolation = videoUrls.isEmpty || (rt.tour.pinnedStream.isDefined && crossOriginPolicy + .supportsCredentiallessIFrames(ctx.req)) data = env.relay.jsonView.makeData( rt.tour.withRounds(rounds.map(_.round)), rt.round.id, diff --git a/app/controllers/Round.scala b/app/controllers/Round.scala index b98c8f803b9e..79d61df20038 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 8b19ef9ada69..7291b2049a2c 100644 --- a/app/controllers/Ublog.scala +++ b/app/controllers/Ublog.scala @@ -52,10 +52,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: @@ -125,7 +135,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 3639252bb101..04e99267fc4d 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/lobby/home.scala b/app/views/lobby/home.scala index 08c0f900553e..33bc6a45da62 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 c78560a2f977..c336e4d26cfe 100644 --- a/app/views/ublog.scala +++ b/app/views/ublog.scala @@ -11,7 +11,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 12204f8de321..77da3d89e695 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 c4def9b03778..8dbc913566ad 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/build.sbt b/build.sbt index d2a513e24334..2f5dc5f577f8 100644 --- a/build.sbt +++ b/build.sbt @@ -81,8 +81,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 @@ -155,6 +155,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 @@ -176,12 +181,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) ) @@ -431,8 +436,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 cf0058a107c6..442130f2a602 100644 --- a/conf/base.conf +++ b/conf/base.conf @@ -378,6 +378,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 454a56b30c2b..15d31b9d911f 100644 --- a/conf/routes +++ b/conf/routes @@ -347,6 +347,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(white: Option[String], black: Option[String], fen: Option[String], time: Option[String], go: Option[String]) +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/ask/src/main/AskApi.scala b/modules/ask/src/main/AskApi.scala new file mode 100644 index 000000000000..c9facfc1a584 --- /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 000000000000..900b7318b48c --- /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(50) + .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 000000000000..5d26228fa7c9 --- /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 000000000000..e91056809f5a --- /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 000000000000..938ee96c4bd2 --- /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 000000000000..1aea5b98e216 --- /dev/null +++ b/modules/ask/src/main/ui/AskUi.scala @@ -0,0 +1,297 @@ +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/cms/src/main/CmsUi.scala b/modules/cms/src/main/CmsUi.scala index 5b7acd962f9b..becbe2920090 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 000000000000..1f60b5cecd00 --- /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 8aa08eac7daa..16e20d0eb192 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 b76982a77a69..9fa472d3d56c 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", @@ -187,6 +188,7 @@ enum Permission(val key: String, val alsoGrants: List[Permission], val name: Str extends Permission( "ADMIN", List( + BotEditor, LichessTeam, UserSearch, PrizeBan, @@ -242,7 +244,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 d1e13425a310..b873b31b66e8 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 e4179c5b9321..ded1c131133b 100644 --- a/modules/coreI18n/src/main/key.scala +++ b/modules/coreI18n/src/main/key.scala @@ -1379,6 +1379,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 e966276230bf..e9bd7f85a68b 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 d9ef1c1e15a9..8e3a09826cc8 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 6a1a42cd5c1e..589cadf0f698 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"/feed#${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( @@ -147,7 +150,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 4def4b2e88c6..bd08ffd3b50b 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 44709f612197..bfc67555aaef 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/ForumPostApi.scala b/modules/forum/src/main/ForumPostApi.scala index b8bed7ef8414..0f3c91894ea1 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 c70c6ea37864..a3dea6ecc59c 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, @@ -105,7 +107,7 @@ final private class ForumTopicApi( for _ <- 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 e97097d7cf0b..8a766c5e682f 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 1aef5e1f2a32..7771c34a2a3d 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 f97913ddb0a5..cb3c7b61c644 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 000000000000..7e9383dbfd3f --- /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 000000000000..05baf0a82a35 --- /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: "image" | "book" | "sound", + 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): 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) + + def updateAssets: JsObject = + val newAssets = Json.obj( + "image" -> listFiles("image"), + "net" -> listFiles("net"), + "sound" -> listFiles("sound"), + "book" -> listFiles("book") + .filter(_.endsWith(".bin")) + .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 000000000000..314622ea2ee9 --- /dev/null +++ b/modules/local/src/main/LocalRepo.scala @@ -0,0 +1,76 @@ +package lila.local + +import reactivemongo.api.Cursor +import reactivemongo.api.bson.* +import lila.common.Json.opaqueFormat +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 botMetaHandler: BSONDocumentHandler[BotMeta] = Macros.handler + given Format[BotMeta] = Json.format + + def getVersions(botId: Option[UserId] = none): Fu[JsArray] = + bots + .find(botId.fold[Bdoc]($doc())(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 = author.fold($doc("name" -> name))(a => $doc("name" -> name, "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 000000000000..3c78ee1e01a6 --- /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 + ) + ) + .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 000000000000..922526d21ba6 --- /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" diff --git a/modules/local/src/main/package.scala b/modules/local/src/main/package.scala new file mode 100644 index 000000000000..ecdf64ebda21 --- /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/relay/src/main/JsonView.scala b/modules/relay/src/main/JsonView.scala index a7bf9e8a646b..534f33bc8c36 100644 --- a/modules/relay/src/main/JsonView.scala +++ b/modules/relay/src/main/JsonView.scala @@ -128,8 +128,12 @@ final class JsonView(baseUrl: BaseUrl, markup: RelayMarkup, picfitUrl: PicfitUrl .add("lcc", trs.rounds.find(_.id == currentRoundId).map(_.sync.upstream.exists(_.hasLcc))) .add("isSubscribed" -> isSubscribed) .add("videoUrls" -> videoUrls) - .add("pinnedStream" -> pinned) - .add("note" -> trs.tour.note.ifTrue(canContribute)), + .add("note" -> trs.tour.note.ifTrue(canContribute)) + .add("pinned" -> pinned.map: p => + Json + .obj("name" -> p.name) + .add("redirect" -> p.upstream.map(_.urls(lila.core.config.NetDomain("")).redirect)) + .add("text" -> p.text)), study = studyData.study, analysis = studyData.analysis, group = group.map(_.group.name) diff --git a/modules/relay/src/main/RelayPinnedStream.scala b/modules/relay/src/main/RelayPinnedStream.scala index 5c27cddb858a..73427117a03f 100644 --- a/modules/relay/src/main/RelayPinnedStream.scala +++ b/modules/relay/src/main/RelayPinnedStream.scala @@ -4,22 +4,19 @@ import io.mola.galimatias.URL import scala.jdk.CollectionConverters.* import lila.core.config.NetDomain -case class RelayPinnedStream(name: String, url: URL): +case class RelayPinnedStream(name: String, url: URL, text: Option[String]): import RelayPinnedStream.* def upstream: Option[RelayPinnedStream.Upstream] = parseYoutube.orElse(parseTwitch) - // https://www.youtube.com/live/Lg0askmGqvo - // https://www.youtube.com/live/Lg0askmGqvo?si=KKOexnmA2xPcyStZ def parseYoutube: Option[YouTube] = - url.host.toString - .endsWith("youtube.com") - .so: - url.pathSegments.asScala.toList match - case List("live", id) => YouTube(id).some - case _ => none + if List("www.youtube.com", "youtube.com", "youtu.be").contains(url.host.toString) then + url.pathSegments.asScala.toList match + case List("live", id) => Some(YouTube(id)) + case _ => Option(url.queryParameter("v")).map(YouTube.apply) + else None // https://www.twitch.tv/tcec_chess_tv def parseTwitch: Option[Twitch] = @@ -37,11 +34,11 @@ object RelayPinnedStream: def urls(parent: NetDomain): Urls case class YouTube(id: String) extends Upstream: def urls(parent: NetDomain) = Urls( - s"https://www.youtube.com/embed/${id}?disablekb=1&modestbranding=1", + s"https://www.youtube.com/embed/${id}?disablekb=1&modestbranding=1&autoplay=1", s"https://www.youtube.com/watch?v=${id}" ) case class Twitch(id: String) extends Upstream: def urls(parent: NetDomain) = Urls( - s"https://player.twitch.tv/?channel=${id}&parent=${parent}", + s"https://player.twitch.tv/?channel=${id}&parent=${parent}&autoplay=true", s"https://www.twitch.tv/${id}" ) diff --git a/modules/relay/src/main/RelayTourForm.scala b/modules/relay/src/main/RelayTourForm.scala index b32792c96f1d..95eb2eea4b07 100644 --- a/modules/relay/src/main/RelayTourForm.scala +++ b/modules/relay/src/main/RelayTourForm.scala @@ -35,7 +35,9 @@ final class RelayTourForm(langList: lila.core.i18n.LangList): private val pinnedStreamMapping = mapping( "name" -> cleanNonEmptyText(maxLength = 100), - "url" -> url.field.verifying("Invalid stream URL", url => RelayPinnedStream("", url).upstream.isDefined) + "url" -> url.field + .verifying("Invalid stream URL", url => RelayPinnedStream("", url, None).upstream.isDefined), + "text" -> optional(cleanText(maxLength = 100)) )(RelayPinnedStream.apply)(unapply) private given Formatter[RelayTour.Tier] = diff --git a/modules/relay/src/main/RelayVideoEmbed.scala b/modules/relay/src/main/RelayVideoEmbed.scala index 4d7886b1bd79..f8e1392f56c9 100644 --- a/modules/relay/src/main/RelayVideoEmbed.scala +++ b/modules/relay/src/main/RelayVideoEmbed.scala @@ -7,11 +7,11 @@ import play.api.mvc.Result enum RelayVideoEmbed: case No case Auto + case PinnedStream case Stream(userId: UserId) override def toString = this match - case No => "no" - case Auto => "" - case Stream(u) => u.toString + case No => "no" + case _ => "" final class RelayVideoEmbedStore(baker: LilaCookie): @@ -21,11 +21,11 @@ final class RelayVideoEmbedStore(baker: LilaCookie): def read(using req: RequestHeader): RelayVideoEmbed = def fromCookie = req.cookies.get(cookieName).map(_.value).filter(_.nonEmpty) match case Some("no") => No + case Some("ps") => PinnedStream case _ => Auto req.queryString.get("embed") match - case Some(Nil) => fromCookie - case Some(Seq("")) => Auto case Some(Seq("no")) => No + case Some(Seq("ps")) => PinnedStream case Some(Seq(name)) => UserStr.read(name).fold(Auto)(u => Stream(u.id)) case _ => fromCookie diff --git a/modules/relay/src/main/ui/RelayFormUi.scala b/modules/relay/src/main/ui/RelayFormUi.scala index e3d58d352a7f..735244ef43d9 100644 --- a/modules/relay/src/main/ui/RelayFormUi.scala +++ b/modules/relay/src/main/ui/RelayFormUi.scala @@ -631,6 +631,17 @@ Team Dogs ; Scooby Doo"""), "Stream name", half = true )(form3.input(_)) + ), + form3.split( + form3.group( + form("pinnedStream.text"), + "Stream link label", + help = frag( + "Optional. Show a label on the image link to your live stream.", + br, + "Example: 'Watch us live on YouTube!'" + ).some + )(form3.input(_)) ) ) ) diff --git a/modules/security/src/main/Permission.scala b/modules/security/src/main/Permission.scala index 7fdb64cedb49..e30a16da9340 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/timeline/src/main/Entry.scala b/modules/timeline/src/main/Entry.scala index fea904b23df5..cb4040d9f049 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 d24c16bd72ef..00efa83d9213 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 2940e62c0e36..9fa30520e12e 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 8f11b514a83c..cd7403ecef20 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 4c56f015e577..800aac10a05a 100644 --- a/modules/ublog/src/main/UblogForm.scala +++ b/modules/ublog/src/main/UblogForm.scala @@ -60,13 +60,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(Language.apply)) | defaultLanguage, topics = topics.so(UblogTopic.fromStrList), image = none, @@ -81,11 +81,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 de57fe0aade3..504b4f802861 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", @@ -101,7 +106,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 bc9dda3cf60c..bb2a7b2a5624 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 c103fba0a836..587f79dfca73 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 549f03d9837e..c6c960f022f5 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 eef20884fded..c2cd40d0f301 100644 --- a/modules/web/src/main/ui/AuthUi.scala +++ b/modules/web/src/main/ui/AuthUi.scala @@ -119,7 +119,9 @@ final class AuthUi(helpers: Helpers): main( cls := s"page-small box box-pad email-confirm ${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( @@ -160,7 +162,7 @@ final class AuthUi(helpers: Helpers): a(href := routes.Account.emailConfirmHelp)("proceed to this page to solve the issue"), "." ) - ) + )*/ ) def passwordReset(form: HcaptchaForm[?], fail: Boolean)(using Context) = diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8e7616a82759..eb6fa1f226c1 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -403,6 +403,45 @@ importers: specifier: workspace:* version: link:../game + 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 + snabbdom: + specifier: 3.5.1 + version: 3.5.1 + zerofish: + specifier: 0.0.29 + version: 0.0.29 + ui/mod: dependencies: '@types/debounce-promise': @@ -1045,6 +1084,9 @@ packages: '@types/dragscroll@0.0.3': resolution: {integrity: sha512-Nti+uvpcVv2VAkqF1+XJivEdE1rxdvWmE4mtMydm9kYBaT0/QsvQSjzqA2G0SE62RoTjVmhC48ap8yfApl8YJw==} + '@types/emscripten@1.39.13': + resolution: {integrity: sha512-cFq+fO/isvhvmuP/+Sl4K4jtU6E23DoivtbO4r50e3odaxAiVdbfSYRDdJ4gCdxx+3aRjhphS5ZMwIH4hFy/Cw==} + '@types/estree@1.0.6': resolution: {integrity: sha512-AYnb1nQyY49te+VRAVgmzfcgjYS91mY5P0TKUDCLEM+gNnA+3T6rWITXRLYCpahpqSQbN5cE+gHpnPyXjHWxcw==} @@ -1060,6 +1102,9 @@ packages: '@types/node@22.10.1': resolution: {integrity: sha512-qKgsUwfHZV2WCWLAnVP1JqnpE6Im6h3Y0+fYgMTasNQ7V++CBX5OT1as0g0f+OyubbFqhf6XVNIsmN4IIhEgGQ==} + '@types/node@22.9.0': + resolution: {integrity: sha512-vuyHg81vvWA1Z1ELfvLko2c8f34gyA0zaic0+Rllc5lbCnbSyuvb2Oxpm6TAUAC/2xZN3QGqxBNggD1nNR2AfQ==} + '@types/node@22.9.1': resolution: {integrity: sha512-p8Yy/8sw1caA8CdRIQBG5tiLHmxtQKObCijiAa9Ez+d4+PRffM4054xbju0msf+cvhJpnFEeNjxmVT/0ipktrg==} @@ -1078,6 +1123,9 @@ packages: '@types/sortablejs@1.15.8': resolution: {integrity: sha512-b79830lW+RZfwaztgs1aVPgbasJ8e7AXtZYHTELNXZPsERt4ymJdjV4OccDbHQAvHrCcFpbF78jkm0R6h/pZVg==} + '@types/web@0.0.154': + resolution: {integrity: sha512-Tc10Nkpbb8AgM3iGnrvpKVb6x8pzrZpMCPqMJe8htXoEdNDKojEevNAkCjxkjCLZF2p1ZB+gknAwdbkypxwxKg==} + '@types/web@0.0.180': resolution: {integrity: sha512-G4sAw9smNoDTMBQtNupDd+2v9SWlqJfdOSRXbMg/KFLmYYhq+RI5oK98kXubfLB/49LKMN7eEzBaGtLMSEAy9A==} @@ -1295,6 +1343,10 @@ packages: resolution: {integrity: sha512-dLitG79d+GV1Nb/VYcCDFivJeK1hiukt9QjRNVOsUtTy1rR1YJsmpGGTZ3qJos+uw7WmWF4wUwBd9jxjocFC2w==} 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'} @@ -1524,6 +1576,9 @@ packages: fast-deep-equal@3.1.3: resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} + fast-diff@1.3.0: + resolution: {integrity: sha512-VxPP4NqbUjj6MaAOafWeUn2cXWLcCtljklUtZf0Ind4XQ+QPtmA0b18zZy0jIQx+ExRVCR/ZQpBmik5lXshNsw==} + fast-glob@3.3.2: resolution: {integrity: sha512-oX2ruAFQwf/Orj8m737Y5adxDQO0LAB7/S5MnxCdTNDd4p6BsyIVsv9JQsATbTSq8KHRpLwIHbVlUNatxd+1Ow==} engines: {node: '>=8.6.0'} @@ -1709,6 +1764,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==} @@ -1920,6 +1978,11 @@ packages: resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} engines: {node: '>= 0.8.0'} + prettier@3.0.2: + resolution: {integrity: sha512-o2YR9qtniXvwEZlOKbveKfDQVyqxbEIWn48Z8m3ZJjBjcCmUy3xZGIv+7AkaeuaTr6yPXJjwv07ZWlsWbEy1rQ==} + engines: {node: '>=14'} + hasBin: true + prettier@3.4.1: resolution: {integrity: sha512-G+YdqtITVZmOJje6QkXQWzl3fSfMxFwm1tjTyo9exhkmWSqC4Yhd1+lug++IlR2mvRVAxEDDWYkQdeSztajqgg==} engines: {node: '>=14'} @@ -2396,6 +2459,9 @@ packages: resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} engines: {node: '>=10'} + zerofish@0.0.29: + resolution: {integrity: sha512-fgVr6NoIymCmJx6l0gdPVsYu7heDMFd3l+IgxQZ7ngCZTmOC9hqXSlbuAURI/uBEJ5j1E9lyaDAxs41wR4WSEA==} + zxcvbn@4.4.2: resolution: {integrity: sha512-Bq0B+ixT/DMyG8kgX2xWcI5jUvCwqrMxSFam7m0lAf78nf04hv6lNCsyLYdyYTrCVMqNDY/206K7eExYCeSyUQ==} @@ -2649,6 +2715,8 @@ snapshots: '@types/dragscroll@0.0.3': {} + '@types/emscripten@1.39.13': {} + '@types/estree@1.0.6': {} '@types/fnando__sparkline@0.3.7': {} @@ -2661,6 +2729,10 @@ snapshots: dependencies: undici-types: 6.20.0 + '@types/node@22.9.0': + dependencies: + undici-types: 6.19.8 + '@types/node@22.9.1': dependencies: undici-types: 6.19.8 @@ -2680,6 +2752,8 @@ snapshots: '@types/sortablejs@1.15.8': {} + '@types/web@0.0.154': {} + '@types/web@0.0.180': {} '@types/webrtc@0.0.44': {} @@ -2916,6 +2990,10 @@ snapshots: chalk@5.3.0: {} + chart.js@4.4.3: + dependencies: + '@kurkle/color': 0.3.4 + chart.js@4.4.6: dependencies: '@kurkle/color': 0.3.4 @@ -3167,6 +3245,8 @@ snapshots: fast-deep-equal@3.1.3: {} + fast-diff@1.3.0: {} + fast-glob@3.3.2: dependencies: '@nodelib/fs.stat': 2.0.5 @@ -3346,6 +3426,8 @@ snapshots: 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 @@ -3553,6 +3635,8 @@ snapshots: prelude-ls@1.2.1: {} + prettier@3.0.2: {} + prettier@3.4.1: {} prop-types@15.8.1: @@ -3997,4 +4081,12 @@ snapshots: yocto-queue@0.1.0: {} + zerofish@0.0.29: + dependencies: + '@types/emscripten': 1.39.13 + '@types/node': 22.9.0 + '@types/web': 0.0.154 + prettier: 3.0.2 + typescript: 5.7.2 + zxcvbn@4.4.2: {} diff --git a/public/font/lichess.sfd b/public/font/lichess.sfd index df0a8a6ec6bd..67d005157c28 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 0106e97733d0..e147f780f8f2 100644 Binary files a/public/font/lichess.ttf and b/public/font/lichess.ttf differ diff --git a/public/font/lichess.woff b/public/font/lichess.woff index eb0a6e7f8ff7..9d869cabf8d8 100644 Binary files a/public/font/lichess.woff and b/public/font/lichess.woff differ diff --git a/public/font/lichess.woff2 b/public/font/lichess.woff2 index 03f0892cdfbb..daf261a24abf 100644 Binary files a/public/font/lichess.woff2 and b/public/font/lichess.woff2 differ diff --git a/public/images/icons/play-btn-youtube.svg b/public/images/icons/play-btn-youtube.svg new file mode 100644 index 000000000000..a757e4dbd0c7 --- /dev/null +++ b/public/images/icons/play-btn-youtube.svg @@ -0,0 +1,21 @@ + + + + + + + + + + + + + + + + \ No newline at end of file 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 205b03e47fbc..17ca2208ab6a 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 37374a6c6413..99c5aeafef0f 100644 --- a/translation/source/site.xml +++ b/translation/source/site.xml @@ -175,6 +175,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/src/clean.ts b/ui/.build/src/clean.ts index a582b265ca67..649c06d3a4a2 100644 --- a/ui/.build/src/clean.ts +++ b/ui/.build/src/clean.ts @@ -16,9 +16,9 @@ const allGlobs = [ 'ui/*/dist', 'ui/*/tsconfig.tsbuildinfo', 'public/compiled', - 'public/npm', 'public/css', 'public/hashed', + 'public/npm', ]; export async function clean(globs?: string[]): Promise { diff --git a/ui/.build/src/sync.ts b/ui/.build/src/sync.ts index 98671127ec70..a219e789d09d 100644 --- a/ui/.build/src/sync.ts +++ b/ui/.build/src/sync.ts @@ -57,6 +57,7 @@ export function isUnmanagedAsset(absfile: string): boolean { if (!absfile.startsWith(env.outDir)) return false; const name = absfile.slice(env.outDir.length + 1); if (['compiled/', 'hashed/', 'css/'].some(dir => name.startsWith(dir))) return false; + if (/json\/manifest.*.json/.test(name)) return false; return true; } diff --git a/ui/@types/lichess/i18n.d.ts b/ui/@types/lichess/i18n.d.ts index 8df1de4f1f73..48543a29587d 100644 --- a/ui/@types/lichess/i18n.d.ts +++ b/ui/@types/lichess/i18n.d.ts @@ -2755,6 +2755,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 */ diff --git a/ui/@types/lichess/index.d.ts b/ui/@types/lichess/index.d.ts index 8365674fd3d6..632fbc93d7a0 100644 --- a/ui/@types/lichess/index.d.ts +++ b/ui/@types/lichess/index.d.ts @@ -132,6 +132,12 @@ interface AssetUrlOpts { version?: false | 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; @@ -307,12 +313,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 d23bea278cd5..9dc2b3f68aa5 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; } @@ -22,7 +23,6 @@ main.is-relay { } @include mq-at-least-col3 { - #video-player-placeholder, button.streamer-show { display: block; } diff --git a/ui/analyse/css/study/relay/_tour.scss b/ui/analyse/css/study/relay/_tour.scss index 331c54efdd44..f134b983b6e9 100644 --- a/ui/analyse/css/study/relay/_tour.scss +++ b/ui/analyse/css/study/relay/_tour.scss @@ -117,10 +117,13 @@ $hover-bg: $m-primary_bg--mix-30; flex: 0 0 50%; } line-height: 0; - img { + > img { width: 100%; @include broken-img(2 / 1); } + .video-player-close { + display: none; + } text-align: center; } &__image-upload { diff --git a/ui/analyse/css/study/relay/_video-player.scss b/ui/analyse/css/study/relay/_video-player.scss index 9d3019de23f5..56919f8cd781 100644 --- a/ui/analyse/css/study/relay/_video-player.scss +++ b/ui/analyse/css/study/relay/_video-player.scss @@ -7,16 +7,88 @@ #video-player-placeholder { aspect-ratio: 16/9; + position: relative; width: 100%; } -img.video-player-close { +.video-player-close { z-index: $z-video-player-controls-101; position: absolute; - height: 20px; - width: 20px; + pointer-events: auto; + top: 6px; + right: 6px; + height: 24px; + width: 24px; + padding: 2px; + border-radius: 50%; + background-color: black; cursor: pointer; &:hover { - filter: brightness(10); + filter: brightness(3); + } +} + +#video-player-placeholder.link { + cursor: pointer; + overflow: hidden; + outline-offset: -3px; + outline: 3px solid $m-bad--alpha-50; + + .image { + position: absolute; + background: center / cover; + overflow: hidden; + inset: 0; + filter: blur(4px) brightness(0.8); + } + + .play-button { + position: absolute; + pointer-events: none; + transform: translate(-50%, -50%); + top: 50%; + left: 50%; + width: 18%; + } + + &:has(.text-box) .play-button { + top: 56%; + } + + .text-box { + @extend %flex-column; + position: absolute; + pointer-events: none; + justify-content: center; + align-items: center; + top: 10%; + left: 10%; + right: 10%; + } + + .text-box div { + margin: auto; + pointer-events: none; + border-radius: 5px; + border: 1px solid #888; + padding: 5px 8px; + text-align: center; + line-height: normal; + color: #ddde; + background-color: #333d; + font-family: 'Noto Sans'; + font-size: 1.2em; + } + + &:hover:not(:has(.video-player-close:hover)) { + box-shadow: + 0 0 5px $c-bad, + 0 0 20px $c-bad; + .play-button { + filter: brightness(1.2); + } + .image { + filter: blur(4px) brightness(0.7); + } } } diff --git a/ui/analyse/src/analyse.ts b/ui/analyse/src/analyse.ts index 2131f0ea27fb..04f68cf94250 100644 --- a/ui/analyse/src/analyse.ts +++ b/ui/analyse/src/analyse.ts @@ -5,9 +5,8 @@ import { wsConnect } from 'common/socket'; export { patch }; -export const start = makeStart(patch); - -export const boot = makeBoot(start); +const start = makeStart(patch); +const boot = makeBoot(start); export function initModule({ mode, cfg }: { mode: 'userAnalysis' | 'replay'; cfg: any }) { if (mode === 'replay') boot(cfg); diff --git a/ui/analyse/src/interfaces.ts b/ui/analyse/src/interfaces.ts index 94eb304a0abc..74ef61457b81 100644 --- a/ui/analyse/src/interfaces.ts +++ b/ui/analyse/src/interfaces.ts @@ -156,6 +156,8 @@ export interface AnalyseOpts { inlinePgn?: string; externalEngineEndpoint: string; embed?: boolean; + socketUrl?: string; + socketVersion?: number; } export interface JustCaptured extends Piece { diff --git a/ui/analyse/src/plugins/analyse.study.ts b/ui/analyse/src/plugins/analyse.study.ts index e79357678959..efae622bbac1 100644 --- a/ui/analyse/src/plugins/analyse.study.ts +++ b/ui/analyse/src/plugins/analyse.study.ts @@ -1,18 +1,18 @@ import { patch } from '../view/util'; -import makeBoot from '../boot'; import makeStart from '../start'; +import type { AnalyseOpts } from '../interfaces'; +import type { AnalyseSocketSend } from '../socket'; import * as studyDeps from '../study/studyDeps'; import { wsConnect } from 'common/socket'; export { patch }; -export const start = makeStart(patch, studyDeps); -export const boot = makeBoot(start); +const start = makeStart(patch, studyDeps); -export function initModule(cfg: any) { - cfg.socketSend = wsConnect(cfg.socketUrl || '/analysis/socket/v5', cfg.socketVersion, { +export function initModule(cfg: AnalyseOpts) { + cfg.socketSend = wsConnect(cfg.socketUrl || '/analysis/socket/v5', cfg.socketVersion ?? false, { receive: (t: string, d: any) => analyse.socketReceive(t, d), ...(cfg.embed ? { params: { flag: 'embed' } } : {}), - }).send; + }).send as AnalyseSocketSend; const analyse = start(cfg); } diff --git a/ui/analyse/src/study/relay/interfaces.ts b/ui/analyse/src/study/relay/interfaces.ts index ddf0ab794f2f..aebc0d3bdac5 100644 --- a/ui/analyse/src/study/relay/interfaces.ts +++ b/ui/analyse/src/study/relay/interfaces.ts @@ -5,7 +5,7 @@ export interface RelayData { group?: RelayGroup; isSubscribed?: boolean; // undefined if anon videoUrls?: [string, string]; - pinnedStream?: { name: string; youtube?: string; twitch?: string }; + pinned?: { name: string; redirect: string; text?: string }; note?: string; lcc?: boolean; } diff --git a/ui/analyse/src/study/relay/relayCtrl.ts b/ui/analyse/src/study/relay/relayCtrl.ts index 77f8aee39037..195408c38402 100644 --- a/ui/analyse/src/study/relay/relayCtrl.ts +++ b/ui/analyse/src/study/relay/relayCtrl.ts @@ -2,7 +2,7 @@ import type { RelayData, LogEvent, RelaySync, RelayRound, RoundId } from './inte import type { BothClocks, ChapterId, ChapterSelect, Federations, ServerClockMsg } from '../interfaces'; import type { StudyMemberCtrl } from '../studyMembers'; import type { AnalyseSocketSend } from '../../socket'; -import { type Prop, type Toggle, defined, myUserId, notNull, prop, toggle } from 'common'; +import { type Prop, type Toggle, myUserId, notNull, prop, toggle } from 'common'; import RelayTeams from './relayTeams'; import RelayPlayers from './relayPlayers'; import type { StudyChapters } from '../studyChapters'; @@ -59,19 +59,27 @@ export default class RelayCtrl { redraw, ); this.stats = new RelayStats(this.currentRound(), redraw); - this.videoPlayer = this.data.videoUrls?.[0] ? new VideoPlayer(this.data.videoUrls[0], redraw) : undefined; - setInterval(() => this.redraw(true), 1000); - - const pinned = data.pinnedStream; - if (pinned && this.pinStreamer()) this.streams.push(['', pinned.name]); + if (data.videoUrls?.[0] || this.isPinnedStreamOngoing()) + this.videoPlayer = new VideoPlayer( + { + embed: this.data.videoUrls?.[0] || false, + redirect: this.data.videoUrls?.[1] || this.data.pinned?.redirect, + image: this.data.tour.image, + text: this.data.pinned?.text, + }, + redraw, + ); + const pinnedName = this.isPinnedStreamOngoing() && data.pinned?.name; + if (pinnedName) this.streams.push(['ps', pinnedName]); pubsub.on('socket.in.crowd', d => { const s = (d.streams as [string, string][]) ?? []; - if (pinned && this.pinStreamer()) s.unshift(['', pinned.name]); + if (pinnedName) s.unshift(['ps', pinnedName]); if (this.streams.length === s.length && this.streams.every(([id], i) => id === s[i][0])) return; this.streams = s; this.redraw(); }); + setInterval(() => this.redraw(true), 1000); } openTab = (t: RelayTab) => { @@ -131,10 +139,17 @@ export default class RelayCtrl { isStreamer = () => this.streams.some(([id]) => id === myUserId()); - pinStreamer = () => - defined(this.data.pinnedStream) && - !this.currentRound().finished && - Date.now() > this.currentRound().startsAt! - 1000 * 3600; + isPinnedStreamOngoing = () => { + if (!this.data.pinned) return false; + // the below line commented out for testy purposes + //if (this.currentRound().finished) return false; + if (Date.now() < this.currentRound().startsAt! - 1000 * 3600) return false; + return true; + }; + + noEmbed() { + return document.cookie.includes('relayVideo=no'); + } private socketHandlers = { relayData: (d: RelayData) => { diff --git a/ui/analyse/src/study/relay/relayTourView.ts b/ui/analyse/src/study/relay/relayTourView.ts index d4a4a900f45d..1206492645ac 100644 --- a/ui/analyse/src/study/relay/relayTourView.ts +++ b/ui/analyse/src/study/relay/relayTourView.ts @@ -15,7 +15,6 @@ import { statsView } from './relayStats'; import { makeChatEl, type RelayViewContext } from '../../view/components'; import { gamesList } from './relayGames'; import { renderStreamerMenu } from './relayView'; -import { renderVideoPlayer } from './videoPlayer'; import { playersView } from './relayPlayers'; import { gameLinksListener } from '../studyChapters'; import { copyMeInput } from 'common/copyMe'; @@ -350,10 +349,9 @@ const teams = (ctx: RelayViewContext) => [ const stats = (ctx: RelayViewContext) => [...header(ctx), statsView(ctx.relay.stats)]; const header = (ctx: RelayViewContext) => { - const { ctrl, relay, allowVideo } = ctx; + const { ctrl, relay } = ctx; const d = relay.data, group = d.group, - embedVideo = d.videoUrls && allowVideo, studyD = ctrl.study?.data.description; return [ @@ -365,20 +363,7 @@ const header = (ctx: RelayViewContext) => { roundSelect(relay, ctx.study), ]), ]), - h( - `div.relay-tour__header__image${embedVideo ? '.video' : ''}`, - embedVideo - ? renderVideoPlayer(relay) - : d.tour.image - ? h('img', { attrs: { src: d.tour.image } }) - : ctx.study.members.isOwner() - ? h( - 'a.button.relay-tour__header__image-upload', - { attrs: { href: `/broadcast/${d.tour.id}/edit` } }, - i18n.broadcast.uploadImage, - ) - : undefined, - ), + broadcastImageOrStream(ctx), ]), studyD && h('div.relay-tour__note.pinned', h('div', [h('div', { hook: richHTML(studyD, false) })])), d.note && @@ -461,3 +446,24 @@ const roundStateIcon = (round: RelayRound, titleAsText: boolean) => { attrs: { ...dataIcon(licon.Checkmark), title: !titleAsText && i18n.site.finished } }, titleAsText && i18n.site.finished, ); + +const broadcastImageOrStream = (ctx: RelayViewContext) => { + const { relay, allowVideo } = ctx; + const d = relay.data, + embedVideo = (d.videoUrls || relay.isPinnedStreamOngoing()) && allowVideo; + + return h( + `div.relay-tour__header__image${embedVideo ? '.video' : ''}`, + embedVideo + ? relay.videoPlayer?.render() + : d.tour.image + ? h('img', { attrs: { src: d.tour.image } }) + : ctx.study.members.isOwner() + ? h( + 'a.button.relay-tour__header__image-upload', + { attrs: { href: `/broadcast/${d.tour.id}/edit` } }, + i18n.broadcast.uploadImage, + ) + : undefined, + ); +}; diff --git a/ui/analyse/src/study/relay/relayView.ts b/ui/analyse/src/study/relay/relayView.ts index e12d67c84fb4..546968987e50 100644 --- a/ui/analyse/src/study/relay/relayView.ts +++ b/ui/analyse/src/study/relay/relayView.ts @@ -6,7 +6,6 @@ import type AnalyseCtrl from '../../ctrl'; import { view as keyboardView } from '../../keyboard'; import type * as studyDeps from '../studyDeps'; import { tourSide, renderRelayTour } from './relayTourView'; -import { renderVideoPlayer } from './videoPlayer'; import { type RelayViewContext, viewContext, @@ -69,7 +68,7 @@ function renderBoardView(ctx: RelayViewContext) { return [ renderBoard(ctx), gaugeOn && cevalView.renderGauge(ctrl), - renderTools(ctx, renderVideoPlayer(ctx.relay)), + renderTools(ctx, relay.noEmbed() ? undefined : relay.videoPlayer?.render()), renderControls(ctrl), !ctrl.isEmbed && renderUnderboard(ctx), tourSide(ctx), diff --git a/ui/analyse/src/study/relay/videoPlayer.ts b/ui/analyse/src/study/relay/videoPlayer.ts index e2ce1ea4106d..351a4f731a43 100644 --- a/ui/analyse/src/study/relay/videoPlayer.ts +++ b/ui/analyse/src/study/relay/videoPlayer.ts @@ -1,48 +1,32 @@ -import { looseH as h, type Redraw, type VNode } from 'common/snabbdom'; -import type RelayCtrl from './relayCtrl'; +import { looseH as h, type Redraw, type VNode, onInsert } from 'common/snabbdom'; import { allowVideo } from './relayView'; export class VideoPlayer { private iframe: HTMLIFrameElement; private close: HTMLImageElement; - private autoplay: boolean; private animationFrameId?: number; constructor( - private url: string, + private o: { embed: string | false; redirect?: string; image?: string; text?: string }, private redraw: Redraw, ) { - this.autoplay = location.search.includes('embed='); + if (!o.embed) return; this.iframe = document.createElement('iframe'); + this.iframe.setAttribute('credentialless', ''); + this.iframe.style.display = 'none'; this.iframe.id = 'video-player'; - this.iframe.setAttribute('credentialless', ''); // a feeble mewling ignored by all - if (this.autoplay) { - this.url += '&autoplay=1'; - this.iframe.allow = 'autoplay'; - } else { - this.url += '&autoplay=false'; // needs to be "false" for twitch - } - this.iframe.src = this.url; - this.iframe.setAttribute('credentialless', 'credentialless'); + this.iframe.src = o.embed; + this.iframe.allow = 'autoplay'; + this.close = document.createElement('img'); this.close.src = site.asset.flairSrc('symbols.cancel'); this.close.className = 'video-player-close'; + this.close.addEventListener('click', () => this.onEmbed('no'), true); - this.close.addEventListener('click', this.onClose, true); - - this.onWindowResize(); + this.addWindowResizer(); } - private onClose = () => { - // we need to reload the page unfortunately, - // so that a better local engine can be loaded - // once the iframe and its CSP are gone - const url = new URL(location.href); - url.searchParams.set('embed', 'no'); - window.location.replace(url); - }; - cover = (el?: HTMLElement) => { if (this.animationFrameId) { cancelAnimationFrame(this.animationFrameId); @@ -66,7 +50,7 @@ export class VideoPlayer { }); }; - onWindowResize = () => { + addWindowResizer = () => { let showingVideo = false; window.addEventListener( 'resize', @@ -81,16 +65,38 @@ export class VideoPlayer { { passive: true }, ); }; -} -export function renderVideoPlayer(relay: RelayCtrl): VNode | undefined { - const player = relay.videoPlayer; - return player - ? h('div#video-player-placeholder', { - hook: { - insert: (vnode: VNode) => player.cover(vnode.elm as HTMLElement), - update: (_, vnode: VNode) => player.cover(vnode.elm as HTMLElement), - }, - }) - : undefined; + render = () => { + return this.o.embed + ? h('div#video-player-placeholder', { + hook: { + insert: (vnode: VNode) => this.cover(vnode.elm as HTMLElement), + update: (_, vnode: VNode) => this.cover(vnode.elm as HTMLElement), + }, + }) + : h('div#video-player-placeholder.link', [ + h('div.image', { + attrs: { style: `background-image: url(${this.o.image})` }, + hook: onInsert((el: HTMLElement) => { + el.addEventListener('click', e => { + if (e.ctrlKey || e.shiftKey) window.open(this.o.redirect, '_blank'); + else this.onEmbed('ps'); + }); + //el.addEventListener('contextmenu', () => window.open(this.o.redirect, '_blank')); + }), + }), + h('img.video-player-close', { + attrs: { src: site.asset.flairSrc('symbols.cancel') }, + hook: onInsert((el: HTMLElement) => el.addEventListener('click', () => this.onEmbed('no'))), + }), + this.o.text && h('div.text-box', h('div', this.o.text)), + h('img.play-button', { attrs: { src: site.asset.url(`images/icons/play-btn-youtube.svg`) } }), + ]); + }; + + onEmbed = (stream: 'ps' | 'no') => { + const urlWithEmbed = new URL(location.href); + urlWithEmbed.searchParams.set('embed', stream); + window.location.href = urlWithEmbed.toString(); + }; } diff --git a/ui/analyse/src/study/studyShare.ts b/ui/analyse/src/study/studyShare.ts index f68f107fd542..107e98b690d5 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/bits/css/_ask.admin.scss b/ui/bits/css/_ask.admin.scss new file mode 100644 index 000000000000..78c2a04a8ead --- /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 000000000000..53da23d50916 --- /dev/null +++ b/ui/bits/css/_ask.scss @@ -0,0 +1,412 @@ +$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); + + > 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; + } + + 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 000000000000..1cf5c308b4a2 --- /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/src/bits.ask.ts b/ui/bits/src/bits.ask.ts new file mode 100644 index 000000000000..33cc04276ecf --- /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 7768fe4977c5..47ba9e9b7af9 100644 --- a/ui/bits/src/bits.cropDialog.ts +++ b/ui/bits/src/bits.cropDialog.ts @@ -97,6 +97,7 @@ export async function initModule(o?: CropOpts): Promise { const tryQuality = (quality = 0.9) => { canvas.toBlob( blob => { + console.log(blob?.size, quality, 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/common/css/abstract/_licon.scss b/ui/common/css/abstract/_licon.scss index e87b5a864ed6..708f38feef41 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/_z-index.scss b/ui/common/css/abstract/_z-index.scss index 35cadfc007c4..9d74b8130087 100644 --- a/ui/common/css/abstract/_z-index.scss +++ b/ui/common/css/abstract/_z-index.scss @@ -22,6 +22,12 @@ $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; diff --git a/ui/common/src/dialog.ts b/ui/common/src/dialog.ts index 0889e41b77d4..7abbb9d0f69b 100644 --- a/ui/common/src/dialog.ts +++ b/ui/common/src/dialog.ts @@ -1,7 +1,7 @@ import { onInsert, looseH as h, type VNode, type Attrs, type LooseVNodes } from './snabbdom'; import { isTouchDevice } from './device'; import { escapeHtml, frag, $as } from './common'; -import { eventJanitor } from './event'; +import { Janitor } from './event'; import * as xhr from './xhr'; import * as licon from './licon'; import { pubsub } from './pubsub'; @@ -222,8 +222,8 @@ export function snabDialog(o: SnabDialogOpts): VNode { class DialogWrapper implements Dialog { private resolve?: (dialog: Dialog) => void; - private actionEvents = eventJanitor(); - private dialogEvents = eventJanitor(); + private actionEvents = new Janitor(); + private dialogEvents = new Janitor(); private observer: MutationObserver = new MutationObserver(list => { for (const m of list) if (m.type === 'childList') @@ -309,7 +309,7 @@ class DialogWrapper implements Dialog { // attach/reattach existing listeners or provide a set of new ones updateActions = (actions = this.o.actions) => { - this.actionEvents.removeAll(); + this.actionEvents.cleanup(); 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']) { @@ -343,8 +343,8 @@ class DialogWrapper implements Dialog { if ('hashed' in css) site.asset.removeCssPath(css.hashed); else if ('url' in css) site.asset.removeCss(css.url); } - this.actionEvents.removeAll(); - this.dialogEvents.removeAll(); + this.actionEvents.cleanup(); + this.dialogEvents.cleanup(); }; } @@ -367,6 +367,7 @@ function onKeydown(e: KeyboardEvent) { first = $as($focii.first()), last = $as($focii.last()), focus = document.activeElement as HTMLElement; + $focii.each((_, el) => console.log(el.id)); if (focus === last && !e.shiftKey) first.focus(); else if (focus === first && e.shiftKey) last.focus(); else return; @@ -382,5 +383,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/event.ts b/ui/common/src/event.ts index cdf9122eea75..519d03146c0d 100644 --- a/ui/common/src/event.ts +++ b/ui/common/src/event.ts @@ -1,31 +1,28 @@ -// stale listeners can cause memory loss, dry skin, and tooth decay +// usage: +// const janitor = new Janitor(); +// janitor.addListener(window, 'resize', () => console.log('resized')); +// janitor.addListener(document, 'click', () => console.log('clicked')); +// janitor.addCleanupTask(() => console.log('cleanup')); +// ... +// janitor.cleanup(); -export interface EventJanitor { - addListener: ( +export class Janitor { + private cleanupTasks: (() => void)[] = []; + + addListener( target: T, type: string, listener: (this: T, ev: E) => any, options?: boolean | AddEventListenerOptions, - ) => void; - removeAll: () => void; -} - -export function eventJanitor(): EventJanitor { - const removers: (() => void)[] = []; - - return { - addListener: ( - target: T, - type: string, - listener: (this: T, ev: E) => any, - options?: boolean | AddEventListenerOptions, - ): void => { - target.addEventListener(type, listener, options); - removers.push(() => target.removeEventListener(type, listener, options)); - }, - removeAll: () => { - removers.forEach(r => r()); - removers.length = 0; - }, - }; + ): void { + target.addEventListener(type, listener, options); + this.cleanupTasks.push(() => target.removeEventListener(type, listener, options)); + } + addCleanupTask(task: () => void): void { + this.cleanupTasks.push(task); + } + cleanup(): void { + for (const task of this.cleanupTasks) task(); + this.cleanupTasks.length = 0; + } } diff --git a/ui/common/src/licon.ts b/ui/common/src/licon.ts index 521c0ef5f7ee..a5dd450bb2a0 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/pubsub.ts b/ui/common/src/pubsub.ts index 52bf39da00e8..4d7c19bc111f 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,11 @@ 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.bots.ready'; export type PubsubCallback = (...data: any[]) => void; diff --git a/ui/common/src/snabbdom.ts b/ui/common/src/snabbdom.ts index e0948f0c3372..ad78b1974342 100644 --- a/ui/common/src/snabbdom.ts +++ b/ui/common/src/snabbdom.ts @@ -70,3 +70,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/learn/src/sound.ts b/ui/learn/src/sound.ts index 96149d8d2c6f..fbe68a779886 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/src/view/setup/modal.ts b/ui/lobby/src/view/setup/modal.ts index b4344a0d7c18..510a94063895 100644 --- a/ui/lobby/src/view/setup/modal.ts +++ b/ui/lobby/src/view/setup/modal.ts @@ -56,4 +56,5 @@ const views = { createButtons(ctrl), ]), ], + local: (): MaybeVNodes => [], }; diff --git a/ui/lobby/src/view/table.ts b/ui/lobby/src/view/table.ts index 8c463eaf0c11..e534f56b47e3 100644 --- a/ui/lobby/src/view/table.ts +++ b/ui/lobby/src/view/table.ts @@ -19,13 +19,22 @@ 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]) => + ].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/local/bin/export-bots.js b/ui/local/bin/export-bots.js new file mode 100644 index 000000000000..2a1b15968b40 --- /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 000000000000..0eb7ac671381 --- /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 000000000000..782558329e9e --- /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('/assets/lifat/bots/image/white-torso.webp'); + ---black-image-url: url('/assets/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 000000000000..8705a8df2724 --- /dev/null +++ b/ui/local/css/_edit-dialog.scss @@ -0,0 +1,321 @@ +#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 sources filters' + 'deck sources 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; + } + } + .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; + } + } + .sources, + .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; + } + } + .sources { + @extend %flex-column; + grid-area: sources; + 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; + } + .chart-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; + } + &.disabled .chart-wrapper { + display: none; + } + .btn-rack { + width: 100%; + border: none; + } + .by { + @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; + border: 1px solid $c-border-light; + border-bottom: none; + border-radius: $box-radius-size $box-radius-size 0 0; + margin-inline-end: -1px; + + &: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) { + background: $m-paper_dimmer--mix-75; + color: $c-font-clearer; + cursor: pointer; + } + } + } + .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 000000000000..5107ad61d79d --- /dev/null +++ b/ui/local/css/_hand-of-cards.scss @@ -0,0 +1,82 @@ +.with-cards { + position: relative; + + .card { + user-select: none; + cursor: pointer; + position: absolute; + top: 0; + left: 0; + 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 000000000000..b96a76610097 --- /dev/null +++ b/ui/local/css/_history-dialog.scss @@ -0,0 +1,81 @@ +div.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/_local.dev.scss b/ui/local/css/_local.dev.scss new file mode 100644 index 000000000000..856716447c98 --- /dev/null +++ b/ui/local/css/_local.dev.scss @@ -0,0 +1,308 @@ +.dev-view { + @extend %flex-column; + gap: 1em; + + span { + @extend %flex-center-nowrap; + white-space: nowrap; + gap: 1em; + } + textarea, + select, + input { + padding: 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; + } +} + +.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('/assets/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 000000000000..16892e76d022 --- /dev/null +++ b/ui/local/css/_local.scss @@ -0,0 +1,56 @@ +#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; + } +} + +#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 000000000000..dfa7fbd24424 --- /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('/assets/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 000000000000..3629250ad428 --- /dev/null +++ b/ui/local/css/build/local.dev.scss @@ -0,0 +1,11 @@ +@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 '../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 000000000000..1c7656b62f6d --- /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 000000000000..9952c1676a38 --- /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 000000000000..590ea5d8bbd1 --- /dev/null +++ b/ui/local/package.json @@ -0,0 +1,36 @@ +{ + "name": "local", + "private": true, + "author": "T-Bone Duplexus", + "license": "AGPL-3.0-or-later", + "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:*", + "snabbdom": "3.5.1", + "zerofish": "0.0.29" + }, + "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" + }, + "build": { + "bundle": [ + "src/local.setup.ts", + "src/local.ts", + "src/dev/local.dev.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 000000000000..fe0630d8131a --- /dev/null +++ b/ui/local/src/analyse.ts @@ -0,0 +1,36 @@ +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(); + const chess = co.Chess.fromSetup(co.fen.parseFen(local.initialFen).unwrap()).unwrap(); + let node = root; + for (const move of local.moves) { + const comments = move.clock ? [co.pgn.makeComment({ clock: move.clock[chess.turn] })] : []; + const san = co.san.makeSanAndPlay(chess, co.parseUci(move.uci)!); + const newNode = new co.pgn.ChildNode({ 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 000000000000..314c53a521c0 --- /dev/null +++ b/ui/local/src/assets.ts @@ -0,0 +1,85 @@ +import { type OpeningBook, makeBookFromPolyglot } from 'bits/polyglot'; +import { defined } from 'common'; +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(): Promise { + for (const bot of [env.bot.white, env.bot.black].filter(defined)) { + for (const sounds of Object.values(bot.sounds ?? {})) { + sounds.forEach(sound => fetch(botAssetUrl('sound', sound.key))); + } + } + const books = (['white', 'black'] as const).flatMap(c => env.bot[c]?.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, name: string): string { + return site.asset.url(`lifat/bots/${type}/${encodeURIComponent(name)}`, { version: false }); +} + +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 000000000000..2f3037a74568 --- /dev/null +++ b/ui/local/src/bot.ts @@ -0,0 +1,247 @@ +import * as co from 'chessops'; +import { zip, clamp } from 'common/algo'; +import { normalize, interpolate } from './filter'; +import type { FishSearch, SearchResult, Line } from 'zerofish'; +import type { OpeningBook } from 'bits/polyglot'; +import { env } from './localEnv'; +import type { + BotInfo, + ZeroSearch, + Filters, + 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 }; + 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; + + constructor(info: BotInfo) { + Object.assign(this, structuredClone(info)); + if (this.filters) Object.values(this.filters).forEach(normalize); + + // keep these from being stored or cloned with the bot + Object.defineProperties(this, { + stats: { value: { cplMoves: 0, cpl: 0 } }, + openings: { + get: () => Promise.all(this.books?.flatMap(b => env.assets.getBook(b.key)) ?? []), + }, + }); + } + + static viable(info: BotInfo): boolean { + return Boolean(info.uid && info.name && (info.zero || info.fish)); + } + + 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.by === 'score'); + } + + async move(args: MoveArgs): Promise { + const moveArgs = { ...args, thinktime: this.thinktime(args) }; + const { pos, chess } = moveArgs; + const { fish, zero } = this; + const opening = await this.bookMove(chess); + if (opening) return { uci: opening, thinktime: moveArgs.thinktime }; + const { uci, cpl } = this.chooseMove( + await Promise.all([ + fish && env.bot.zerofish.goFish(pos, fish), + zero && + env.bot.zerofish.goZero(pos, { + ...zero, + net: { + key: this.name + '-' + zero.net, + fetch: () => env.assets.getNet(zero.net), + }, + }), + ]), + moveArgs, + ); + if (cpl !== undefined && cpl < 1000) { + this.stats.cplMoves++; // debug stats + this.stats.cpl += cpl; + } + return { uci, thinktime: moveArgs.thinktime }; + } + + private filter(op: string, { chess, cp, thinktime }: MoveArgs): undefined | number { + const o = this.filters?.[op]; + if (!o) return undefined; + const val = interpolate( + o, + o.by === 'move' + ? chess.fullmoves + : o.by === 'score' + ? outcomeExpectancy(chess.turn, cp ?? 0) + : thinktime + ? Math.log2(thinktime) + : 240, + ); + return val; + } + + private thinktime({ initial, remaining, increment }: MoveArgs): number | undefined { + initial ??= Infinity; + increment ??= 0; + if (!remaining || !Number.isFinite(initial)) return undefined; + 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); + return quickest + Math.random() * variateMax; + } + + private async bookMove(chess: co.Chess) { + // first decide on a book from those with a move for the current position using book + // relative weights. then choose a move within that book using the move weights + if (!this.books?.length) return undefined; + const moveList: { moves: { uci: Uci; weight: number }[]; bookWeight: number }[] = []; + let bookChance = 0; + for (const [book, opening] of zip(this.books, await this.openings)) { + const moves = opening(chess); + if (moves.length === 0) continue; + moveList.push({ moves, bookWeight: book.weight }); + bookChance += book.weight; + } + bookChance = Math.random() * bookChance; + for (const { moves, bookWeight } of moveList) { + bookChance -= bookWeight; + if (bookChance <= 0) { + let chance = Math.random(); + for (const { uci, weight } of moves) { + chance -= weight; + if (chance <= 0) return uci; + } + } + } + return undefined; + } + + private chooseMove(results: (SearchResult | undefined)[], args: MoveArgs): { uci: Uci; cpl?: number } { + const moves = this.parseMoves(results, args); + + if (this.filters?.cplTarget) this.scoreByCpl(moves, args); + + moves.sort(weightSort); + + if (args.pos.moves?.length) { + const last = args.pos.moves[args.pos.moves.length - 1].slice(2, 4); + + // our one global rule - 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) return moves[0]; + } + return moveQualityDecay(moves, this.filter('moveDecay', args) ?? 0) ?? moves[0]; + } + + private parseMoves([fish, zero]: (SearchResult | undefined)[], args: MoveArgs): SearchMove[] { + if ((!fish || fish.bestmove === '0000') && (!zero || zero.bestmove === '0000')) + 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.filters?.cplTarget ? 0 : Math.random()) : 0; + + 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))); + } + } +} + +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; + +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); +} diff --git a/ui/local/src/botCtrl.ts b/ui/local/src/botCtrl.ts new file mode 100644 index 000000000000..527af19facfc --- /dev/null +++ b/ui/local/src/botCtrl.ts @@ -0,0 +1,248 @@ +import makeZerofish, { type Zerofish, type Position } from 'zerofish'; +import * as co from 'chessops'; +import { Bot, score } from './bot'; +import { RateBot } from './dev/rateBot'; +import { closeEnough } from './dev/devUtil'; +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, version: false }), + wasm: site.asset.url('npm/zerofishEngine.wasm', { version: false }), + dev: env.isDevPage, + }); + if (env.isDevPage) { + for (let i = 0; i <= RateBot.MAX_LEVEL; i++) { + this.rateBots.push(new RateBot(i)); + } + //site.pubsub.on('local.dev.import.book', this.onBookImported); + } + return this.initBots(serverBots!); + } + + 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), + ]); + 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; // just ignore requests from 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; + 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; + } + + 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 (closeEnough(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); + 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 && !closeEnough(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 onBookImported = (key: string, oldKey?: string): void => { + // if (!oldKey) return; + + // } + + private getSavedBots() { + return ( + this.store?.getMany() ?? + objectStorage({ store: 'local.bots' }).then(s => { + this.store = s; + return s.getMany(); + }) + ); + } + + 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 000000000000..4031b6b8810b --- /dev/null +++ b/ui/local/src/dev/assetDialog.ts @@ -0,0 +1,360 @@ +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.view.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.view.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.view.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.view.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: '[data-action="cancel"]', result: 'cancel' }, + { + selector: '[data-action="import"]', + listener: async (_, dlg) => { + const name = (dlg.view.querySelector('.name') as HTMLInputElement).value; + const ply = Number((dlg.view.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.view.querySelector('.options') as HTMLElement).classList.add('none'); + const progress = dlg.view.querySelector('.progress') as HTMLElement; + const bar = progress.querySelector('.bar') as HTMLElement; + const text = progress.querySelector('.text') as HTMLElement; + progress.classList.remove('none'); + 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.open; + }); + if (dlg.returnValue !== 'cancel') onSuccess(name); + } + 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 000000000000..61cb000f0385 --- /dev/null +++ b/ui/local/src/dev/booksPane.ts @@ -0,0 +1,125 @@ +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'; + +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); + } + + 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, + 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; + constructor(p: PaneArgs, key: string) { + super(p); + 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 000000000000..3db9b697c031 --- /dev/null +++ b/ui/local/src/dev/devAssets.ts @@ -0,0 +1,371 @@ +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.traverse(k => k === key, 'local') && !this.traverse(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.traverse(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. + if (!pgn.type.endsWith('chess-pgn')) throw new Error(`${pgn.type} not recognized as pgn`); + 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 traverse( + 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 000000000000..6065f3f4d68f --- /dev/null +++ b/ui/local/src/dev/devCtrl.ts @@ -0,0 +1,214 @@ +import { RateBot, rateBotMatchup } from './rateBot'; +import type { BotInfo, LocalSpeed } from '../types'; +import { statusOf } from 'game/status'; +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, MoveContext } from '../localGame'; +import { env } from '../localEnv'; +import stringify from 'json-stringify-pretty-compact'; +import { pubsub } from 'common/pubsub'; + +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[]; + ratings: { [uid: string]: DevRatings } = {}; + private localRatings: ObjectStorage; + + constructor() {} + + async init(): Promise { + this.resetScript(); + await 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.reset({ ...game, initialFen: env.game.initialFen }); + env.redraw(); + env.game.start(); + return true; + } + + resetScript(test?: Test): void { + this.log ??= []; + const players = [env.game.white, env.game.black].filter(x => defined(x)) as string[]; + this.script = { + type: 'matchup', + players, + games: [], + ...test, + }; + } + + onReset(): void {} + + preMove(moveResult: MoveContext): void { + env.round.chessground?.set({ animation: { enabled: !this.hurry } }); + if (this.hurry) moveResult.silent = true; + } + + onGameOver({ winner, turn, reason, status }: GameStatus): boolean { + const last = { winner, white: this.white?.uid, black: this.black?.uid }; + this.log.push(last); + if (status === statusOf('cheat')) { + console.error( + `${env.game.nameOf('white')} vs ${env.game.nameOf('black')} - ${turn} ${reason} - ${ + env.game.live.fen + } ${env.game.live.moves.join(' ')}`, + stringify(env.game.live.chess), + ); + return false; + } else + console.info( + `game ${this.log.length} ${env.game.nameOf('white')} vs ${env.game.nameOf('black')}:`, + winner ? `${env.game.nameOf(winner)} wins by` : 'draw', + status.name, + reason ?? '', + ); + if (!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]); + } + + get hasUser(): boolean { + return !(this.white && this.black); + } + + get gameInProgress(): boolean { + return env.game.live.ply > 0 && !env.game.live.status.end; + } + + 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] }); + } + } + shuffle(tourney); + games.push(...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 000000000000..b576096c34c5 --- /dev/null +++ b/ui/local/src/dev/devSideView.ts @@ -0,0 +1,366 @@ +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 { LocalSetup, LocalSpeed } from '../types'; +import { env } from '../localEnv'; + +export function renderDevSide(): VNode { + return h('div.dev-side.dev-view', [ + h('div', player(co.opposite(env.game.orientationForReal))), + dashboard(), + progress(), + h('div', player(env.game.orientationForReal)), + ]); +} + +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 } }), + 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', + ), + 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) { + const selected = env.bot[color]?.uid; + if (!selected) return; + 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.reset(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.board-action.button.button-metal`, { + attrs: { 'data-icon': licon.Switch }, + hook: bind('click', () => { + env.game.reset({ 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.reset(); + 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 disabled = env.game.isUserTurn; + const paused = env.game.isStopped || env.game.live.end; + 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(); + 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 { + return h('input.fen', { + attrs: { + type: 'text', + value: env.game.live.fen === co.fen.INITIAL_FEN ? '' : env.game.live.fen, + 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({ initialFen: 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.view.querySelectorAll('input:checked')).map( + (el: HTMLInputElement) => el.value, + ); + if (participants.length < 2) return; + const iterationField = dlg.view.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, + }); +} + +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({ + view: main, + getDrops: () => drops, + getCardData: () => cardData, + select: (el, domId) => { + const color = (el ?? clickedEl).dataset.color as Color; + env.game.stop(); + reset({ ...env.bot.uids, [color]: domIdToUid(domId) }); + }, + onRemove: () => { + main.classList.remove('with-cards'); + botSelector = undefined; + }, + orientation: 'left', + transient: true, + autoResize: true, + }); +} diff --git a/ui/local/src/dev/devTypes.ts b/ui/local/src/dev/devTypes.ts new file mode 100644 index 000000000000..aa5a549e986c --- /dev/null +++ b/ui/local/src/dev/devTypes.ts @@ -0,0 +1,118 @@ +import type { Filter, 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; 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 000000000000..ba1fcc82611f --- /dev/null +++ b/ui/local/src/dev/devUtil.ts @@ -0,0 +1,124 @@ +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 }); +} + +// ignores empty objects & arrays for BotInfo equivalence. +export function closeEnough(a: any, b: any): 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])); + } + if (typeof a !== 'object') return false; + + const [aKeys, bKeys] = [filteredKeys(a), filteredKeys(b)]; + if (aKeys.length !== bKeys.length) return false; + + for (const key of aKeys) { + if (!bKeys.includes(key) || !closeEnough(a[key], b[key])) return false; + } + return true; +} + +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; + const newBot = { + ...structuredClone(EditDialog.default), + uid: input.dataset.uid, + name: input.dataset.uid.slice(1), + } as WritableBot; + env.bot.save(newBot); + this.selectBot(newBot.uid); + dlg.close(); + }, + }, + ], + }).then(dlg => { + input.setSelectionRange(1, 1); + dlg.show(); + }); + } + + private deckEl = frag(`
+
+
+ legend + + + + +
+
`); + + private globalActionsEl = frag(`
+ + + + +
`); + + private botActionsEl = frag(`
+ + + + + +
`); + + private get botCardEl(): Node { + const botCard = frag(`
+
${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; +} diff --git a/ui/local/src/dev/filterPane.ts b/ui/local/src/dev/filterPane.ts new file mode 100644 index 000000000000..7e956b0286e8 --- /dev/null +++ b/ui/local/src/dev/filterPane.ts @@ -0,0 +1,173 @@ +import { Pane } from './pane'; +import { Chart, PointElement, LinearScale, LineController, LineElement } from 'chart.js'; +import { addPoint, asData, domain } from '../filter'; +import { frag } from 'common'; +import { clamp } from 'common/algo'; +import type { PaneArgs, FilterInfo } from './devTypes'; +import type { Filter } from '../types'; + +export class FilterPane extends Pane { + info: FilterInfo; + canvas: HTMLCanvasElement; + chart: Chart; + + constructor(p: PaneArgs) { + super(p); + if (this.info.title && this.label) this.label.title = this.info.title; + this.el.title = ''; + this.el.firstElementChild?.append(this.toggleGroup()); + this.canvas = document.createElement('canvas'); + const wrapper = frag(`
`); + wrapper.append(this.canvas); + this.el.append(wrapper); + this.host.chartJanitor.addCleanupTask(() => this.chart?.destroy()); + this.render(); + } + + setEnabled(enabled?: boolean): boolean { + if (this.requirementsAllow) enabled ??= !this.isOptional || (this.isDefined && !this.isDisabled); + else enabled = false; + if (enabled && !this.isDefined) { + this.setProperty(structuredClone(this.info.value)); + this.render(); + } + super.setEnabled(enabled); + this.el.querySelectorAll('.chart-wrapper, .btn-rack')?.forEach(x => x.classList.toggle('none', !enabled)); + if (enabled) this.host.bot.disabled.delete(this.id); + else this.host.bot.disabled.add(this.id); + return enabled; + } + + update(e?: Event): void { + if (!(e instanceof MouseEvent && e.target instanceof HTMLElement)) return; + if (e.target.dataset.action && this.enabled) { + if (e.target.classList.contains('active')) return; + this.el.querySelector('.by.active')?.classList.remove('active'); + e.target.classList.add('active'); + this.paneValue.by = e.target.dataset.action as 'move' | 'score' | 'time'; + this.paneValue.data = []; + this.render(); + } + if (e.target instanceof HTMLCanvasElement) { + const m = this.paneValue; + const remove = this.chart.getElementsAtEventForMode(e, 'nearest', { intersect: true }, false); + if (remove.length > 0 && remove[0].index > 0) { + m.data.splice(remove[0].index - 1, 1); + } else { + const rect = e.target.getBoundingClientRect(); + + const chartX = this.chart.scales.x.getValueForPixel(e.clientX - rect.left); + const chartY = this.chart.scales.y.getValueForPixel(e.clientY - rect.top); + if (!chartX || !chartY) return; + addPoint(m, [clamp(chartX, domain(m)), clamp(chartY, m.range)]); + } + this.chart.data.datasets[0].data = asData(m); + this.chart.update(); + } + } + + get paneValue(): Filter { + return this.getProperty() as Filter; + } + + private render() { + this.chart?.destroy(); + const m = this.paneValue; + if (!m?.data) return; + this.chart = new Chart(this.canvas.getContext('2d')!, { + type: 'line', + data: { + datasets: [ + { + data: asData(m), + 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: domain(m).min, + max: domain(m).max, + reverse: m.by === 'time', + ticks: getTicks(m), + title: { + display: true, + color: '#555', + text: + m.by === 'move' + ? 'full moves' + : m.by === 'time' + ? 'think time' + : `outcome expectancy for ${this.host.bot.name.toLowerCase()}`, + }, + }, + y: { + min: m.range.min, + max: m.range.max, + title: { + display: true, + color: '#555', + text: this.info.label, + }, + }, + }, + }, + }); + } + + private toggleGroup() { + const active = (this.paneValue ?? this.info.value).by; + const by = frag(`
+
by move
+
by score
+
by time
+
`); + return by; + } +} + +function getTicks(o: Filter) { + return o.by === '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 = { + byMove: 'vary the filter parameter by number of full moves since start of game', + byScore: `vary the filter parameter by current outcome expectancy for bot`, + byTime: '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 000000000000..3a329c1533ba --- /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 000000000000..37afd88d0794 --- /dev/null +++ b/ui/local/src/dev/local.dev.ts @@ -0,0 +1,58 @@ +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, initEnv } from '../localEnv'; +import { renderGameView } from '../gameView'; +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) { + initEnv({ 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; + + initEnv({ + redraw, + bot: new BotCtrl(), + push: new PushCtrl(), + assets: new DevAssets(opts.assets), + dev: new DevCtrl(), + game: new GameCtrl(opts), + user: opts.userId, + username: opts.username, + canPost: opts.canPost, + }); + + await Promise.all([env.bot.init(opts.bots), env.dev.init(), env.assets.init()]); + await env.game.init(); + + 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 000000000000..d774eb815158 --- /dev/null +++ b/ui/local/src/dev/pane.ts @@ -0,0 +1,364 @@ +import { removeObjectProperty, setObjectProperty, maxChars } from './devUtil'; +import { findMapped } 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; + readonly host: EditDialog; + readonly info: Info; + readonly el: HTMLElement; + readonly parent: Pane | undefined; + enabledToggle?: HTMLInputElement; + + 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); + } + } + this.initEnabledToggle(); + if (!this.enabledToggle) return; + this.enabledToggle.classList.add('toggle'); + this.enabledToggle.checked = this.isDefined; + this.label?.prepend(this.enabledToggle); + } + + setEnabled(enabled: boolean = this.canEnable): boolean { + this.el.classList.toggle('none', !this.requirementsAllow); + + if (this.input || this.enabledToggle) { + 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(); // ?? why update if not optional? + 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(); + } + + if (this.enabledToggle) this.enabledToggle.checked = 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 findMapped(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; + } + + private initEnabledToggle() { + if (this.radioGroup) { + this.enabledToggle = frag( + ``, + ); + } else if ( + this.isOptional && + this.info.label && + this.info.type !== 'books' && + this.info.type !== 'soundEvent' + ) { + this.enabledToggle = frag(``); + } + } +} + +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('