diff --git a/COPYING.md b/COPYING.md index dd02c834b83d7..3e857cb16d856 100644 --- a/COPYING.md +++ b/COPYING.md @@ -101,7 +101,7 @@ Lichess as deployed on https://lichess.org/ also uses these external services: - [check.torproject.org](https://check.torproject.org/exit-addresses) for a list or Tor exit nodes - [detectlanguage.com](https://detectlanguage.com/) - Fallback to [Google Fonts](https://fonts.google.com/) -- [Google Cloud Messaging](https://developers.google.com/cloud-messaging/) and [OneSignal](https://onesignal.com/) for mobile notifications +- [Google Cloud Messaging](https://developers.google.com/cloud-messaging/) for mobile notifications - [reCAPTCHA](https://www.google.com/recaptcha/) - [PeerJS](https://peerjs.com/) for voice chat - [crowdin](https://crowdin.com/project/lichess) for localization diff --git a/app/controllers/Dev.scala b/app/controllers/Dev.scala index 09a5c1b3f149c..55aeb5dace35e 100644 --- a/app/controllers/Dev.scala +++ b/app/controllers/Dev.scala @@ -16,7 +16,6 @@ final class Dev(env: Env) extends LilaController(env) { env.report.discordScoreThresholdSetting, env.streamer.homepageMaxSetting, env.streamer.alwaysFeaturedSetting, - env.streamer.twitchCredentialsSetting, env.rating.ratingFactorsSetting, env.plan.donationGoalSetting, env.apiTimelineSetting, diff --git a/app/controllers/GameMod.scala b/app/controllers/GameMod.scala index 8c233df659c39..128fab60dba94 100644 --- a/app/controllers/GameMod.scala +++ b/app/controllers/GameMod.scala @@ -88,21 +88,38 @@ final class GameMod(env: Env) extends LilaController(env) { object GameMod { - case class Filter(arena: Option[String], swiss: Option[String]) + case class Filter(arena: Option[String], swiss: Option[String], opponents: Option[String]) { + def opponentIds: List[lila.user.User.ID] = + (~opponents) + .take(800) + .replace(",", " ") + .split(' ') + .view + .flatMap(_.trim.some.filter(_.nonEmpty)) + .filter(lila.user.User.couldBeUsername) + .map(lila.user.User.normalize) + .toList + .distinct + } - val emptyFilter = Filter(none, none) + val emptyFilter = Filter(none, none, none) def toDbSelect(filter: Filter): Bdoc = filter.arena.?? { id => $doc(lila.game.Game.BSONFields.tournamentId -> id) } ++ filter.swiss.?? { id => $doc(lila.game.Game.BSONFields.swissId -> id) - } + } ++ (filter.opponentIds match { + case Nil => $empty + case List(id) => $and(lila.game.Game.BSONFields.playerUids $eq id) + case ids => $and(lila.game.Game.BSONFields.playerUids $in ids) + }) val filterForm = Form( mapping( - "arena" -> optional(nonEmptyText), - "swiss" -> optional(nonEmptyText) + "arena" -> optional(nonEmptyText), + "swiss" -> optional(nonEmptyText), + "opponents" -> optional(nonEmptyText) )(Filter.apply)(Filter.unapply _) ) diff --git a/app/controllers/Report.scala b/app/controllers/Report.scala index 0b9ff6a5a6914..fca024c00e497 100644 --- a/app/controllers/Report.scala +++ b/app/controllers/Report.scala @@ -21,23 +21,33 @@ final class Report( Secure(_.SeeReport) { implicit ctx => me => if (env.streamer.liveStreamApi.isStreaming(me.id) && !getBool("force")) fuccess(Forbidden(html.site.message.streamingMod)) - else renderList(env.report.modFilters.get(me).fold("all")(_.key)) + else renderList(me, env.report.modFilters.get(me).fold("all")(_.key)) } def listWithFilter(room: String) = Secure(_.SeeReport) { implicit ctx => me => env.report.modFilters.set(me, Room(room)) - renderList(room) + if (Room(room).fold(true)(Room.isGrantedFor(me))) renderList(me, room) + else notFound } protected[controllers] def getScores = api.maxScores zip env.streamer.api.approval.countRequests zip env.appeal.api.countUnread - private def renderList(room: String)(implicit ctx: Context) = + private def renderList(me: UserModel, room: String)(implicit ctx: Context) = api.openAndRecentWithFilter(12, Room(room)) zip getScores flatMap { case (reports, scores ~ streamers ~ appeals) => (env.user.lightUserApi preloadMany reports.flatMap(_.report.userIds)) inject - Ok(html.report.list(reports, room, scores, streamers, appeals)) + Ok( + html.report + .list( + reports.filter(r => lila.report.Reason.isGrantedFor(me)(r.report.reason)), + room, + scores, + streamers, + appeals + ) + ) } def inquiry(id: String) = diff --git a/app/controllers/Team.scala b/app/controllers/Team.scala index 65398b9b2c93a..5811d1e86691e 100644 --- a/app/controllers/Team.scala +++ b/app/controllers/Team.scala @@ -136,7 +136,7 @@ final class Team( WithOwnedTeam(id) { team => implicit val req = ctx.body forms.selectMember.bindFromRequest().value ?? { api.kick(team, _, me) } inject Redirect( - routes.Team.show(team.id) + routes.Team.kickForm(team.id) ).flashSuccess } } diff --git a/app/controllers/User.scala b/app/controllers/User.scala index f2890f589f742..cff2c8e8675db 100644 --- a/app/controllers/User.scala +++ b/app/controllers/User.scala @@ -365,7 +365,7 @@ final class User( appeal <- isGranted(_.Appeals) ?? env.appeal.api.get(user) } yield view.modLog(history, appeal) - val plan = env.plan.api.recentChargesOf(user).map(view.plan).dmap(~_) + val plan = isGranted(_.Admin) ?? env.plan.api.recentChargesOf(user).map(view.plan).dmap(~_) val student = env.clas.api.student.findManaged(user).map2(view.student).dmap(~_) diff --git a/app/views/forum/topic.scala b/app/views/forum/topic.scala index 400eb3db4cbd7..0b254a490fa8a 100644 --- a/app/views/forum/topic.scala +++ b/app/views/forum/topic.scala @@ -42,6 +42,10 @@ object topic { trans.toRequestSupport( strong(a(href := routes.Main.contact)(trans.tryTheContactPage())) ) + ), + p( + "Make sure to read ", + strong(a(href := routes.Page.loneBookmark("forum-etiquette"))("the forum etiquette")) ) ), postForm(cls := "form3", action := routes.ForumTopic.create(categ.slug))( @@ -171,7 +175,13 @@ object topic { action := s"${routes.ForumPost.create(categ.slug, topic.slug, posts.currentPage)}#reply", novalidate )( - form3.group(form("text"), trans.message()) { f => + form3.group( + form("text"), + trans.message(), + help = a(dataIcon := "", cls := "text", href := routes.Page.loneBookmark("forum-etiquette"))( + "Forum etiquette" + ).some + ) { f => form3.textarea(f, klass = "post-text-area")(rows := 10, bits.dataTopic := topic.id) }, views.html.base.captcha(form, captcha), diff --git a/app/views/mod/games.scala b/app/views/mod/games.scala index b83f3ec673d00..a2bd2b63bb5aa 100644 --- a/app/views/mod/games.scala +++ b/app/views/mod/games.scala @@ -40,6 +40,7 @@ object games { h1(userLink(user), " games (WIP)"), div(cls := "box__top__actions")( form(method := "get", action := routes.GameMod.index(user.id), cls := "mod-games__filter-form")( + form3.input(filterForm("opponents"))(placeholder := "Opponents"), form3.select( filterForm("arena"), arenas.map(t => @@ -74,7 +75,13 @@ object games { table(cls := "mod-games game-list slist")( thead( tr( - sortNoneTh(input(tpe := "checkbox", name := s"game[]", st.value := "all")), + sortNoneTh( + input( + tpe := "checkbox", + name := s"game[]", + st.value := "all" + ) + ), sortNumberTh("Opponent"), sortNumberTh("Speed"), th(iconTag('g')), @@ -89,8 +96,8 @@ object games { tbody( games.map { case (pov, assessment) => tr( - td( - input( + td(cls := pov.game.analysable.option("input"))( + pov.game.analysable option input( tpe := "checkbox", name := s"game[]", st.value := pov.gameId diff --git a/app/views/mod/inquiry.scala b/app/views/mod/inquiry.scala index 79642d94fc8ce..6b4244a6bc654 100644 --- a/app/views/mod/inquiry.scala +++ b/app/views/mod/inquiry.scala @@ -1,13 +1,16 @@ package views.html.mod +import cats.data.NonEmptyList +import controllers.routes import scala.util.matching.Regex import lila.api.Context import lila.app.templating.Environment._ import lila.app.ui.ScalatagsTemplate._ import lila.common.String.html.richText - -import controllers.routes +import lila.report.Reason +import lila.report.Report +import lila.user.User object inquiry { @@ -124,19 +127,13 @@ object inquiry { ) ), div(cls := "links")( - in.report.boostWith - .map { userId => - a(href := s"${routes.User.games(in.user.id, "search")}?players.b=$userId")("View", br, "Games") - } - .getOrElse { - in.report.bestAtomByHuman.map { atom => - a(href := s"${routes.User.games(in.user.id, "search")}?players.b=${atom.by.value}")( - "View", - br, - "Games" - ) - } - }, + boostOpponents(in.report) map { opponents => + a(href := s"${routes.GameMod.index(in.user.id)}?opponents=${opponents.toList mkString ", "}")( + "View", + br, + "Games" + ) + }, isGranted(_.Shadowban) option a(href := routes.Mod.communicationPublic(in.user.id))("View", br, "Comms") ), @@ -236,6 +233,23 @@ object inquiry { ) } + private def boostOpponents(report: Report): Option[NonEmptyList[User.ID]] = + (report.reason == Reason.Boost) ?? { + report.atoms.toList + .withFilter(_.byLichess) + .flatMap(_.text.linesIterator) + .collect { + case farmWithRegex(userId) => userId + case sandbagWithRegex(userId) => userId + } + .toNel + } + + private val farmWithRegex = + ("^Boosting: farms rating points from @(" + User.historicalUsernameRegex.pattern + ")").r.unanchored + private val sandbagWithRegex = + ("^Sandbagging: throws games to @(" + User.historicalUsernameRegex.pattern + ")").r.unanchored + private def thenForms(url: String, button: Tag) = div( postForm( diff --git a/app/views/report/list.scala b/app/views/report/list.scala index 954ce3eaf42f6..9d28f54d776a0 100644 --- a/app/views/report/list.scala +++ b/app/views/report/list.scala @@ -110,17 +110,19 @@ object list { "All", scoreTag(scores.highest) ), - lila.report.Room.all.map { room => - a( - href := routes.Report.listWithFilter(room.key), - cls := List( - "active" -> (filter == room.key), - s"room-${room.key}" -> true + ctx.me ?? { me => + lila.report.Room.all.filter(lila.report.Room.isGrantedFor(me)).map { room => + a( + href := routes.Report.listWithFilter(room.key), + cls := List( + "active" -> (filter == room.key), + s"room-${room.key}" -> true + ) + )( + room.name, + scoreTag(scores get room) ) - )( - room.name, - scoreTag(scores get room) - ) + } }, (appeals > 0 && isGranted(_.Appeals)) option a( href := routes.Appeal.queue, diff --git a/conf/base.conf b/conf/base.conf index 9523a888cd7af..219671bea1ba4 100644 --- a/conf/base.conf +++ b/conf/base.conf @@ -256,11 +256,6 @@ push { vapid_public_key = "BGr5CL0QlEYa7qW7HLqe7DFkCeTsYMLsi1Db+5Vwt1QBIs6+WxN8066AjtP8S9u+w+CbleE8xWY+qQaNEMs7sAs=" url = "http://push.lichess.ovh:9054" } - onesignal { - url = "https://onesignal.com/api/v1/notifications" - app_id = "" - key = "" - } firebase { url = "https://fcm.googleapis.com/v1/projects/lichess-1366/messages:send" json = "" @@ -322,6 +317,10 @@ streamer { collection.streamer = "streamer" paginator.max_per_page = 12 streaming { + twitch { + client_id = "" + secret = "" + } google.api_key = "" keyword = "lichess.org" } diff --git a/modules/chat/src/main/ChatApi.scala b/modules/chat/src/main/ChatApi.scala index 93eaadd598657..d8d8640de9622 100644 --- a/modules/chat/src/main/ChatApi.scala +++ b/modules/chat/src/main/ChatApi.scala @@ -173,7 +173,7 @@ final class ChatApi( val line = c.hasRecentLine(user) option UserLine( username = systemUserId, title = None, - text = s"${user.username} was timed out 10 minutes for ${reason.name}.", + text = s"${user.username} was timed out 15 minutes for ${reason.name}.", troll = false, deleted = false ) diff --git a/modules/forum/src/main/Categ.scala b/modules/forum/src/main/Categ.scala index 7faf33e9aaa2a..eb772725169cc 100644 --- a/modules/forum/src/main/Categ.scala +++ b/modules/forum/src/main/Categ.scala @@ -7,7 +7,6 @@ case class Categ( _id: String, // slug name: String, desc: String, - pos: Int, team: Option[TeamID] = None, nbTopics: Int, nbPosts: Int, diff --git a/modules/forum/src/main/CategApi.scala b/modules/forum/src/main/CategApi.scala index 10d95ad9e68b1..92000cb989e81 100644 --- a/modules/forum/src/main/CategApi.scala +++ b/modules/forum/src/main/CategApi.scala @@ -24,47 +24,45 @@ final class CategApi(env: Env)(implicit ec: scala.concurrent.ExecutionContext) { }).sequenceFu } yield views - def makeTeam(slug: String, name: String): Funit = - env.categRepo.nextPosition flatMap { position => - val categ = Categ( - _id = teamSlug(slug), - name = name, - desc = "Forum of the team " + name, - pos = position, - team = slug.some, - nbTopics = 0, - nbPosts = 0, - lastPostId = "", - nbTopicsTroll = 0, - nbPostsTroll = 0, - lastPostIdTroll = "" - ) - val topic = Topic.make( - categId = categ.slug, - slug = slug + "-forum", - name = name + " forum", - userId = User.lichessId, - troll = false, - hidden = false - ) - val post = Post.make( - topicId = topic.id, - author = none, - userId = User.lichessId, - text = - "Welcome to the %s forum!\nOnly members of the team can post here, but everybody can read." format name, - number = 1, - troll = false, - hidden = topic.hidden, - lang = "en".some, - categId = categ.id, - modIcon = None - ) - env.categRepo.coll.insert.one(categ).void >> - env.postRepo.coll.insert.one(post).void >> - env.topicRepo.coll.insert.one(topic withPost post).void >> - env.categRepo.coll.update.one($id(categ.id), categ.withPost(topic, post)).void - } + def makeTeam(slug: String, name: String): Funit = { + val categ = Categ( + _id = teamSlug(slug), + name = name, + desc = "Forum of the team " + name, + team = slug.some, + nbTopics = 0, + nbPosts = 0, + lastPostId = "", + nbTopicsTroll = 0, + nbPostsTroll = 0, + lastPostIdTroll = "" + ) + val topic = Topic.make( + categId = categ.slug, + slug = slug + "-forum", + name = name + " forum", + userId = User.lichessId, + troll = false, + hidden = false + ) + val post = Post.make( + topicId = topic.id, + author = none, + userId = User.lichessId, + text = + "Welcome to the %s forum!\nOnly members of the team can post here, but everybody can read." format name, + number = 1, + troll = false, + hidden = topic.hidden, + lang = "en".some, + categId = categ.id, + modIcon = None + ) + env.categRepo.coll.insert.one(categ).void >> + env.postRepo.coll.insert.one(post).void >> + env.topicRepo.coll.insert.one(topic withPost post).void >> + env.categRepo.coll.update.one($id(categ.id), categ.withPost(topic, post)).void + } def show(slug: String, page: Int, forUser: Option[User]): Fu[Option[(Categ, Paginator[TopicView])]] = env.categRepo bySlug slug flatMap { diff --git a/modules/forum/src/main/CategRepo.scala b/modules/forum/src/main/CategRepo.scala index c54c1759e573d..383ee341c5cc7 100644 --- a/modules/forum/src/main/CategRepo.scala +++ b/modules/forum/src/main/CategRepo.scala @@ -1,6 +1,7 @@ package lila.forum import lila.db.dsl._ +import reactivemongo.api.ReadPreference final class CategRepo(val coll: Coll)(implicit ec: scala.concurrent.ExecutionContext) { @@ -16,13 +17,9 @@ final class CategRepo(val coll: Coll)(implicit ec: scala.concurrent.ExecutionCon $doc("team" $in teams) ) ) - .sort($sort asc "pos") - .cursor[Categ]() + .cursor[Categ](ReadPreference.secondaryPreferred) .list() - def nextPosition: Fu[Int] = - coll.primitiveOne[Int]($empty, $sort desc "pos", "pos") dmap (~_ + 1) - def nbPosts(id: String): Fu[Int] = - coll.primitiveOne[Int]($id(id), "nbPosts") dmap (~_) + coll.primitiveOne[Int]($id(id), "nbPosts").dmap(~_) } diff --git a/modules/push/src/main/Device.scala b/modules/push/src/main/Device.scala index 0394f077c20f3..dded780b9763c 100644 --- a/modules/push/src/main/Device.scala +++ b/modules/push/src/main/Device.scala @@ -3,7 +3,7 @@ package lila.push import org.joda.time.DateTime final private case class Device( - _id: String, // Firebase token or OneSignal playerId + _id: String, // Firebase token platform: String, // cordova platform (android, ios, firebase) userId: String, seenAt: DateTime diff --git a/modules/push/src/main/Env.scala b/modules/push/src/main/Env.scala index b5fa8f6f30023..8b1143c37a09c 100644 --- a/modules/push/src/main/Env.scala +++ b/modules/push/src/main/Env.scala @@ -16,7 +16,6 @@ final private class PushConfig( @ConfigName("collection.device") val deviceColl: CollName, @ConfigName("collection.subscription") val subscriptionColl: CollName, val web: WebPush.Config, - val onesignal: OneSignalPush.Config, val firebase: FirebasePush.Config ) @@ -42,8 +41,6 @@ final class Env( def registerDevice = deviceApi.register _ def unregisterDevices = deviceApi.unregister _ - private lazy val oneSignalPush = wire[OneSignalPush] - private lazy val googleCredentials: Option[GoogleCredentials] = try { config.firebase.json.value.some.filter(_.nonEmpty).map { json => diff --git a/modules/push/src/main/OneSignalPush.scala b/modules/push/src/main/OneSignalPush.scala deleted file mode 100644 index 8f704c8b263db..0000000000000 --- a/modules/push/src/main/OneSignalPush.scala +++ /dev/null @@ -1,67 +0,0 @@ -package lila.push - -import io.methvin.play.autoconfig._ -import play.api.libs.json._ -import play.api.libs.ws._ -import play.api.libs.ws.JsonBodyReadables._ -import play.api.libs.ws.JsonBodyWritables._ - -final private class OneSignalPush( - deviceApi: DeviceApi, - ws: StandaloneWSClient, - config: OneSignalPush.Config -)(implicit ec: scala.concurrent.ExecutionContext) { - - import config._ - - def apply(userId: String, data: => PushApi.Data): Funit = - deviceApi.findLastManyByUserId("onesignal", 3)(userId) flatMap { - case Nil => funit - case devices => - ws.url(config.url) - .withHttpHeaders( - "Authorization" -> s"key=${key.value}", - "Accept" -> "application/json", - "Content-type" -> "application/json" - ) - .post( - Json.obj( - "app_id" -> appId, - "include_player_ids" -> devices.map(_.deviceId), - "headings" -> Map("en" -> data.title), - "contents" -> Map("en" -> data.body), - "data" -> data.payload, - "android_group" -> data.stacking.key, - "android_group_message" -> Map("en" -> data.stacking.message), - "collapse_id" -> data.stacking.key, - "ios_badgeType" -> "Increase", - "ios_badgeCount" -> 1 - ) - ) - .flatMap { - case res if res.status == 200 || res.status == 400 => - readErrors(res) - .filterNot(_ contains "must have English language") - .filterNot(_ contains "All included players are not subscribed") match { - case Nil => funit - case errors => - fufail(s"[push] ${devices.map(_.deviceId)} $data ${res.status} ${errors mkString ","}") - } - case res => - fufail(s"[push] ${devices.map(_.deviceId)} $data ${lila.log.http(res.status, res.body)}") - } - } - - private def readErrors(res: StandaloneWSResponse): List[String] = - ~(res.body[JsValue] \ "errors").asOpt[List[String]] -} - -private object OneSignalPush { - - final class Config( - val url: String, - @ConfigName("app_id") val appId: String, - val key: lila.common.config.Secret - ) - implicit val configLoader = AutoConfig.loader[Config] -} diff --git a/modules/push/src/main/PushApi.scala b/modules/push/src/main/PushApi.scala index cc819f74115c1..b4e884c7be5bb 100644 --- a/modules/push/src/main/PushApi.scala +++ b/modules/push/src/main/PushApi.scala @@ -13,7 +13,6 @@ import lila.user.User final private class PushApi( firebasePush: FirebasePush, - oneSignalPush: OneSignalPush, webPush: WebPush, userRepo: lila.user.UserRepo, implicit val lightUser: LightUser.Getter, @@ -257,9 +256,6 @@ final private class PushApi( webPush(userId, data).addEffects { res => monitor(lila.mon.push.send)("web", res.isSuccess) } zip - oneSignalPush(userId, data).addEffects { res => - monitor(lila.mon.push.send)("onesignal", res.isSuccess) - } zip firebasePush(userId, data).addEffects { res => monitor(lila.mon.push.send)("firebase", res.isSuccess) } void diff --git a/modules/rating/src/main/java/glicko2/Rating.java b/modules/rating/src/main/java/glicko2/Rating.java index 78587a1de6f14..2d0daec399fe9 100644 --- a/modules/rating/src/main/java/glicko2/Rating.java +++ b/modules/rating/src/main/java/glicko2/Rating.java @@ -19,6 +19,7 @@ */ public class Rating { + private final double advantage; private double rating; private double ratingDeviation; private double volatility; @@ -31,10 +32,15 @@ public class Rating { private double workingVolatility; public Rating(double initRating, double initRatingDeviation, double initVolatility, int nbResults) { - this(initRating, initRatingDeviation, initVolatility, nbResults, null); + this(0.0d, initRating, initRatingDeviation, initVolatility, nbResults, null); } public Rating(double initRating, double initRatingDeviation, double initVolatility, int nbResults, DateTime lastRatingPeriodEndDate) { + this(0.0d, initRating, initRatingDeviation, initVolatility, nbResults, lastRatingPeriodEndDate); + } + + public Rating(double advantage, double initRating, double initRatingDeviation, double initVolatility, int nbResults, DateTime lastRatingPeriodEndDate) { + this.advantage = advantage; this.rating = initRating; this.ratingDeviation = initRatingDeviation; this.volatility = initVolatility; @@ -42,6 +48,19 @@ public Rating(double initRating, double initRatingDeviation, double initVolatili this.lastRatingPeriodEndDate = lastRatingPeriodEndDate; } + public Rating withAdvantage(double advantage) { + return new Rating(advantage, rating, ratingDeviation, volatility, numberOfResults, lastRatingPeriodEndDate); + } + + /** + * Return the skill advantage (first-player handicap) value. + * + * @return double + */ + public double getAdvantage() { + return this.advantage; + } + /** * Return the average skill value of the player. * @@ -55,6 +74,16 @@ public void setRating(double rating) { this.rating = rating; } + /** + * Return the average skill value of the player scaled down + * to the scale used by the algorithm's internal workings. + * + * @return double + */ + public double getGlicko2RatingWithAdvantage() { + return RatingCalculator.convertRatingToGlicko2Scale(this.rating + advantage); + } + /** * Return the average skill value of the player scaled down * to the scale used by the algorithm's internal workings. @@ -70,7 +99,7 @@ public double getGlicko2Rating() { * * @param double */ - public void setGlicko2Rating(double rating) { + private void setGlicko2Rating(double rating) { this.rating = RatingCalculator.convertRatingToOriginalGlickoScale(rating); } diff --git a/modules/rating/src/main/java/glicko2/RatingCalculator.java b/modules/rating/src/main/java/glicko2/RatingCalculator.java index f3fe5e5986832..f740991b0e130 100644 --- a/modules/rating/src/main/java/glicko2/RatingCalculator.java +++ b/modules/rating/src/main/java/glicko2/RatingCalculator.java @@ -25,7 +25,7 @@ public class RatingCalculator { private final static double DEFAULT_TAU = 0.75; private final static double MULTIPLIER = 173.7178; private final static double CONVERGENCE_TOLERANCE = 0.000001; - private final static int ITERATION_MAX = 3000; + private final static int ITERATION_MAX = 1000; private final static double DAYS_PER_MILLI = 1.0 / (1000 * 60 * 60 * 24); private final double tau; // constrains volatility over time @@ -254,11 +254,11 @@ private double v(Rating player, List results) { for ( Result result: results ) { v = v + ( ( Math.pow( g(result.getOpponent(player).getGlicko2RatingDeviation()), 2) ) - * E(player.getGlicko2Rating(), - result.getOpponent(player).getGlicko2Rating(), + * E(player.getGlicko2RatingWithAdvantage(), + result.getOpponent(player).getGlicko2RatingWithAdvantage(), result.getOpponent(player).getGlicko2RatingDeviation()) - * ( 1.0 - E(player.getGlicko2Rating(), - result.getOpponent(player).getGlicko2Rating(), + * ( 1.0 - E(player.getGlicko2RatingWithAdvantage(), + result.getOpponent(player).getGlicko2RatingWithAdvantage(), result.getOpponent(player).getGlicko2RatingDeviation()) )); } @@ -293,8 +293,8 @@ private double outcomeBasedRating(Rating player, List results) { outcomeBasedRating = outcomeBasedRating + ( g(result.getOpponent(player).getGlicko2RatingDeviation()) * ( result.getScore(player) - E( - player.getGlicko2Rating(), - result.getOpponent(player).getGlicko2Rating(), + player.getGlicko2RatingWithAdvantage(), + result.getOpponent(player).getGlicko2RatingWithAdvantage(), result.getOpponent(player).getGlicko2RatingDeviation() )) ); } diff --git a/modules/report/src/main/Reason.scala b/modules/report/src/main/Reason.scala index 8e67d847d8219..3724a179d2b29 100644 --- a/modules/report/src/main/Reason.scala +++ b/modules/report/src/main/Reason.scala @@ -1,5 +1,7 @@ package lila.report +import lila.user.User + sealed trait Reason { def key = toString.toLowerCase @@ -41,4 +43,14 @@ object Reason { def isComm = reason == Comm def isPlaybans = reason == Playbans } + + def isGrantedFor(mod: User)(reason: Reason) = { + import lila.security.Granter + reason match { + case Cheat => Granter(_.MarkEngine)(mod) + case CheatPrint => Granter(_.ViewIpPrint)(mod) + case Comm => Granter(_.Shadowban)(mod) + case Boost | Playbans | Other => Granter(_.MarkBooster)(mod) + } + } } diff --git a/modules/report/src/main/Report.scala b/modules/report/src/main/Report.scala index 22164c43ba828..0d5545610cace 100644 --- a/modules/report/src/main/Report.scala +++ b/modules/report/src/main/Report.scala @@ -75,14 +75,6 @@ case class Report( def isRecentComm = room == Room.Comm && open def isRecentCommOf(sus: Suspect) = isRecentComm && user == sus.user.id - def boostWith: Option[User.ID] = - (reason == Reason.Boost) ?? { - atoms.toList.withFilter(_.byLichess).flatMap(_.text.linesIterator).collectFirst { - case Report.farmWithRegex(userId) => userId - case Report.sandbagWithRegex(userId) => userId - } - } - def isAppeal = room == Room.Other && atoms.head.text == Report.appealText } @@ -173,8 +165,4 @@ object Report { ) )(_ add c.atom) } - - private val farmWithRegex = s""". points from @(${User.historicalUsernameRegex.pattern}) """.r.unanchored - private val sandbagWithRegex = - s""". throws games to @(${User.historicalUsernameRegex.pattern}) """.r.unanchored } diff --git a/modules/report/src/main/Room.scala b/modules/report/src/main/Room.scala index 39e34ce8f9340..ed594daebe44a 100644 --- a/modules/report/src/main/Room.scala +++ b/modules/report/src/main/Room.scala @@ -1,5 +1,7 @@ package lila.report +import lila.user.User + sealed trait Room { def key = toString.toLowerCase @@ -49,4 +51,15 @@ object Room { def get = value.get _ def highest = ~value.values.maxOption } + + def isGrantedFor(mod: User)(room: Room) = { + import lila.security.Granter + room match { + case Cheat => Granter(_.MarkEngine)(mod) + case Print => Granter(_.ViewIpPrint)(mod) + case Comm => Granter(_.Shadowban)(mod) + case Other => Granter(_.MarkBooster)(mod) + case Xfiles => Granter(_.MarkEngine)(mod) + } + } } diff --git a/modules/round/src/main/PerfsUpdater.scala b/modules/round/src/main/PerfsUpdater.scala index 1db94ccc6293a..1c84260c8cd80 100644 --- a/modules/round/src/main/PerfsUpdater.scala +++ b/modules/round/src/main/PerfsUpdater.scala @@ -124,9 +124,9 @@ final class PerfsUpdater( private def updateRatings(white: Rating, black: Rating, result: Glicko.Result): Unit = { val results = new RatingPeriodResults() result match { - case Glicko.Result.Draw => results.addDraw(white, black) - case Glicko.Result.Win => results.addResult(white, black) - case Glicko.Result.Loss => results.addResult(black, white) + case Glicko.Result.Draw => results.addDraw(white.withAdvantage(5), black.withAdvantage(-5)) + case Glicko.Result.Win => results.addResult(white.withAdvantage(5), black.withAdvantage(-5)) + case Glicko.Result.Loss => results.addResult(black.withAdvantage(-5), white.withAdvantage(5)) } try { Glicko.system.updateRatings(results, true) diff --git a/modules/streamer/src/main/Env.scala b/modules/streamer/src/main/Env.scala index f21e7db890696..0e11779d6856e 100644 --- a/modules/streamer/src/main/Env.scala +++ b/modules/streamer/src/main/Env.scala @@ -13,8 +13,10 @@ private class StreamerConfig( @ConfigName("collection.streamer") val streamerColl: CollName, @ConfigName("paginator.max_per_page") val paginatorMaxPerPage: MaxPerPage, @ConfigName("streaming.keyword") val keyword: Stream.Keyword, - @ConfigName("streaming.google.api_key") val googleApiKey: Secret + @ConfigName("streaming.google.api_key") val googleApiKey: Secret, + @ConfigName("streaming.twitch") val twitchConfig: TwitchConfig ) +private case class TwitchConfig(clientId: String, secret: Secret) @Module final class Env( @@ -33,6 +35,7 @@ final class Env( system: ActorSystem ) { + implicit private val twitchLoader = AutoConfig.loader[TwitchConfig] implicit private val keywordLoader = strLoader(Stream.Keyword.apply) private val config = appConfig.get[StreamerConfig]("streamer")(AutoConfig.loader) @@ -51,13 +54,6 @@ final class Env( ) } - lazy val twitchCredentialsSetting = - settingStore[String]( - "twitchCredentials", - default = "", - text = "Twitch API client ID and secret, separated by a space".some - ) - lazy val homepageMaxSetting = settingStore[Int]( "streamerHomepageMax", @@ -69,6 +65,8 @@ final class Env( lazy val pager = wire[StreamerPager] + private lazy val twitchApi: TwitchApi = wire[TwitchApi] + private val streamingActor = system.actorOf( Props( new Streaming( @@ -79,11 +77,7 @@ final class Env( keyword = config.keyword, alwaysFeatured = alwaysFeaturedSetting.get _, googleApiKey = config.googleApiKey, - twitchCredentials = () => - twitchCredentialsSetting.get().split(' ') match { - case Array(client, secret) => (client, secret) - case _ => ("", "") - } + twitchApi = twitchApi ) ) ) diff --git a/modules/streamer/src/main/Stream.scala b/modules/streamer/src/main/Stream.scala index 05cb176e78251..45f64615e712e 100644 --- a/modules/streamer/src/main/Stream.scala +++ b/modules/streamer/src/main/Stream.scala @@ -35,15 +35,6 @@ object Stream { case class Pagination(cursor: Option[String]) case class Result(data: Option[List[TwitchStream]], pagination: Option[Pagination]) { def liveStreams = (~data).filter(_.isLive) - def streams(keyword: Keyword, streamers: List[Streamer], alwaysFeatured: List[User.ID]): List[Stream] = - liveStreams.collect { case TwitchStream(name, title, _) => - streamers.find { s => - s.twitch.exists(_.userId.toLowerCase == name.toLowerCase) && { - title.toLowerCase.contains(keyword.toLowerCase) || - alwaysFeatured.contains(s.userId) - } - } map { Stream(name, title, _) } - }.flatten } case class Stream(userId: String, status: String, streamer: Streamer) extends lila.streamer.Stream { def serviceName = "twitch" diff --git a/modules/streamer/src/main/Streaming.scala b/modules/streamer/src/main/Streaming.scala index 6a1e31c0d8fc4..9e50fc39912cb 100644 --- a/modules/streamer/src/main/Streaming.scala +++ b/modules/streamer/src/main/Streaming.scala @@ -20,11 +20,10 @@ final private class Streaming( keyword: Stream.Keyword, alwaysFeatured: () => lila.common.UserIds, googleApiKey: Secret, - twitchCredentials: () => (String, String) + twitchApi: TwitchApi ) extends Actor { import Stream._ - import Twitch.Reads._ import YouTube.Reads._ private case object Tick @@ -52,7 +51,16 @@ final private class Streaming( } streamers <- api byIds activeIds (twitchStreams, youTubeStreams) <- - fetchTwitchStreams(streamers, 0, None, Nil) zip fetchYouTubeStreams(streamers) + twitchApi.fetchStreams(streamers, 0, None) map { + _.collect { case Twitch.TwitchStream(name, title, _) => + streamers.find { s => + s.twitch.exists(_.userId.toLowerCase == name.toLowerCase) && { + title.toLowerCase.contains(keyword.toLowerCase) || + alwaysFeatured().value.contains(s.userId) + } + } map { Twitch.Stream(name, title, _) } + }.flatten + } zip fetchYouTubeStreams(streamers) streams = LiveStreams { lila.common.ThreadLocalRandom.shuffle { (twitchStreams ::: youTubeStreams) pipe dedupStreamers @@ -89,57 +97,6 @@ final private class Streaming( } } - def fetchTwitchStreams( - streamers: List[Streamer], - page: Int, - pagination: Option[Twitch.Pagination], - acc: List[Twitch.Stream] - ): Fu[List[Twitch.Stream]] = { - val (clientId, secret) = twitchCredentials() - if (clientId.nonEmpty && secret.nonEmpty && page < 10) { - val query = List( - "game_id" -> "743", // chess - "first" -> "100" // max results per page - ) ::: List( - pagination.flatMap(_.cursor).map { "after" -> _ } - ).flatten - ws.url("https://api.twitch.tv/helix/streams") - .withQueryStringParameters(query: _*) - .withHttpHeaders( - "Client-ID" -> clientId, - "Authorization" -> s"Bearer $secret" - ) - .get() - .flatMap { - case res if res.status == 200 => - res.body[JsValue].validate[Twitch.Result](twitchResultReads) match { - case JsSuccess(result, _) => fuccess(result) - case JsError(err) => fufail(s"twitch $err ${lila.log.http(res.status, res.body)}") - } - case res => fufail(s"twitch ${lila.log.http(res.status, res.body)}") - } - .recover { case e: Exception => - logger.warn(e.getMessage) - Twitch.Result(None, None) - } - .monSuccess(_.tv.streamer.twitch) - .flatMap { result => - if (result.data.exists(_.nonEmpty)) - fetchTwitchStreams( - streamers, - page + 1, - result.pagination, - acc ::: result.streams( - keyword, - streamers, - alwaysFeatured().value - ) - ) - else fuccess(acc) - } - } else fuccess(acc) - } - private var prevYouTubeStreams = YouTube.StreamsFetched(Nil, DateTime.now) def fetchYouTubeStreams(streamers: List[Streamer]): Fu[List[YouTube.Stream]] = { diff --git a/modules/streamer/src/main/TwitchApi.scala b/modules/streamer/src/main/TwitchApi.scala new file mode 100644 index 0000000000000..0ebd3612415dc --- /dev/null +++ b/modules/streamer/src/main/TwitchApi.scala @@ -0,0 +1,78 @@ +package lila.streamer + +import play.api.libs.json._ +import play.api.libs.ws.DefaultBodyWritables._ +import play.api.libs.ws.JsonBodyReadables._ +import play.api.libs.ws.StandaloneWSClient +import scala.concurrent.ExecutionContext + +import lila.common.config.Secret + +final private class TwitchApi(ws: StandaloneWSClient, config: TwitchConfig)(implicit ec: ExecutionContext) { + + import Stream.Twitch + import Twitch.Reads._ + + private var tmpToken = Secret("init") + + def fetchStreams( + streamers: List[Streamer], + page: Int, + pagination: Option[Twitch.Pagination] + ): Fu[List[Twitch.TwitchStream]] = + (config.clientId.nonEmpty && config.secret.value.nonEmpty && page < 10) ?? { + val query = List( + "game_id" -> "743", // chess + "first" -> "100" // max results per page + ) ::: List( + pagination.flatMap(_.cursor).map { "after" -> _ } + ).flatten + ws.url("https://api.twitch.tv/helix/streams") + .withQueryStringParameters(query: _*) + .withHttpHeaders( + "Client-ID" -> config.clientId, + "Authorization" -> s"Bearer ${tmpToken.value}" + ) + .get() + .flatMap { + case res if res.status == 200 => + res.body[JsValue].validate[Twitch.Result](twitchResultReads) match { + case JsSuccess(result, _) => fuccess(result) + case JsError(err) => fufail(s"twitch $err ${lila.log.http(res.status, res.body)}") + } + case res if res.status == 401 && res.body.contains("Invalid OAuth token") => + logger.warn("Renewing twitch API token") + renewToken >> fuccess(Twitch.Result(None, None)) + case res => fufail(s"twitch ${lila.log.http(res.status, res.body)}") + } + .recover { case e: Exception => + logger.warn(e.getMessage) + Twitch.Result(None, None) + } + .monSuccess(_.tv.streamer.twitch) + .flatMap { result => + if (result.data.exists(_.nonEmpty)) + fetchStreams(streamers, page + 1, result.pagination) map (result.liveStreams ::: _) + else fuccess(Nil) + } + } + + private def renewToken: Funit = + ws.url("https://id.twitch.tv/oauth2/token") + .withQueryStringParameters( + "client_id" -> config.clientId, + "client_secret" -> config.secret.value, + "grant_type" -> "client_credentials" + ) + .post(Map.empty[String, String]) + .flatMap { + case res if res.status == 200 => + res.body[JsValue].asOpt[JsObject].flatMap(_ str "access_token") match { + case Some(token) => + tmpToken = Secret(token) + funit + case _ => fufail(s"twitch.renewToken ${lila.log.http(res.status, res.body)}") + } + case res => fufail(s"twitch.renewToken ${lila.log.http(res.status, res.body)}") + } +} diff --git a/modules/swiss/src/main/BsonHandlers.scala b/modules/swiss/src/main/BsonHandlers.scala index 6234a9f43e6cd..5e2f85b943446 100644 --- a/modules/swiss/src/main/BsonHandlers.scala +++ b/modules/swiss/src/main/BsonHandlers.scala @@ -123,6 +123,7 @@ object BsonHandlers { implicit val swissHandler = Macros.handler[Swiss] + // "featurable" mostly means that the tournament isn't over yet def addFeaturable(s: Swiss) = swissHandler.writeTry(s).get ++ { s.isNotFinished ?? $doc( diff --git a/project/Dependencies.scala b/project/Dependencies.scala index a890267f414b9..81189e9f1fd9b 100644 --- a/project/Dependencies.scala +++ b/project/Dependencies.scala @@ -21,7 +21,7 @@ object Dependencies { val epoll = "io.netty" % "netty-transport-native-epoll" % "4.1.58.Final" classifier "linux-x86_64" val autoconfig = "io.methvin.play" %% "autoconfig-macros" % "0.3.2" % "provided" val scalatest = "org.scalatest" %% "scalatest" % "3.1.0" % Test - val uaparser = "org.uaparser" %% "uap-scala" % "0.11.0" + val uaparser = "org.uaparser" %% "uap-scala" % "0.12.0" val specs2 = "org.specs2" %% "specs2-core" % "4.10.6" % Test val apacheText = "org.apache.commons" % "commons-text" % "1.9" val bloomFilter = "com.github.alexandrnikitin" %% "bloom-filter" % "0.13.1" diff --git a/ui/analyse/src/autoShape.ts b/ui/analyse/src/autoShape.ts index 3bf2a2e3351b9..8ad5bfb1b760d 100644 --- a/ui/analyse/src/autoShape.ts +++ b/ui/analyse/src/autoShape.ts @@ -101,7 +101,7 @@ export function compute(ctrl: AnalyseCtrl): DrawShape[] { } }); } - if (ctrl.showMoveAnnotation()) { + if (ctrl.showMoveAnnotation() && ctrl.showComputer()) { const { uci, glyphs } = ctrl.node; if (glyphs && glyphs.length > 0) { const glyph = glyphs[0]; diff --git a/ui/ceval/src/ctrl.ts b/ui/ceval/src/ctrl.ts index d76cade351dfc..e9f134d6a2ba9 100644 --- a/ui/ceval/src/ctrl.ts +++ b/ui/ceval/src/ctrl.ts @@ -7,7 +7,11 @@ import throttle from 'common/throttle'; import { povChances } from './winningChances'; import { sanIrreversible } from './util'; -function sharedWasmMemory(initial: number, maximum: number): WebAssembly.Memory | undefined { +function sharedWasmMemory(initial: number, maximum: number): WebAssembly.Memory { + return new WebAssembly.Memory({ shared: true, initial, maximum } as WebAssembly.MemoryDescriptor); +} + +function sendableSharedWasmMemory(initial: number, maximum: number): WebAssembly.Memory | undefined { // Atomics if (typeof Atomics !== 'object') return; @@ -15,7 +19,7 @@ function sharedWasmMemory(initial: number, maximum: number): WebAssembly.Memory if (typeof SharedArrayBuffer !== 'function') return; // Shared memory - const mem = new WebAssembly.Memory({ shared: true, initial, maximum } as WebAssembly.MemoryDescriptor); + const mem = sharedWasmMemory(initial, maximum); if (!(mem.buffer instanceof SharedArrayBuffer)) return; // Structured cloning @@ -51,7 +55,7 @@ export default function (opts: CevalOpts): CevalCtrl { const source = Uint8Array.from([0, 97, 115, 109, 1, 0, 0, 0]); if (typeof WebAssembly === 'object' && typeof WebAssembly.validate === 'function' && WebAssembly.validate(source)) { technology = 'wasm'; // WebAssembly 1.0 - const sharedMem = sharedWasmMemory(8, 16); + const sharedMem = sendableSharedWasmMemory(8, 16); if (sharedMem) { technology = 'hce'; @@ -98,7 +102,7 @@ export default function (opts: CevalOpts): CevalCtrl { const hovering = prop(null); const isDeeper = prop(false); - const workerOpts = { + const protocolOpts = { minDepth, variant: opts.variant.key, threads: (technology == 'hce' || technology == 'nnue') && (() => Math.min(parseInt(threads()), maxThreads)), @@ -218,7 +222,7 @@ export default function (opts: CevalOpts): CevalCtrl { if (!worker) { if (technology == 'nnue') - worker = new ThreadedWasmWorker(workerOpts, { + worker = new ThreadedWasmWorker(protocolOpts, { baseUrl: 'vendor/stockfish-nnue.wasm/', module: 'Stockfish', downloadProgress: throttle(200, mb => { @@ -226,16 +230,16 @@ export default function (opts: CevalOpts): CevalCtrl { opts.redraw(); }), version: '2732f2', + wasmMemory: sharedWasmMemory(2048, growableSharedMem ? 32768 : 2048), }); else if (technology == 'hce') - worker = new ThreadedWasmWorker( - workerOpts, - officialStockfish(opts.variant.key) - ? { baseUrl: 'vendor/stockfish.wasm/', module: 'Stockfish' } - : { baseUrl: 'vendor/stockfish-mv.wasm/', module: 'StockfishMv' } - ); + worker = new ThreadedWasmWorker(protocolOpts, { + baseUrl: officialStockfish(opts.variant.key) ? 'vendor/stockfish.wasm/' : 'vendor/stockfish-mv.wasm/', + module: officialStockfish(opts.variant.key) ? 'Stockfish' : 'StockfishMv', + wasmMemory: sharedWasmMemory(1024, growableSharedMem ? 32768 : 1088), + }); else - worker = new WebWorker(workerOpts, { + worker = new WebWorker(protocolOpts, { url: technology == 'wasm' ? 'vendor/stockfish.js/stockfish.wasm.js' : 'vendor/stockfish.js/stockfish.js', }); } diff --git a/ui/ceval/src/worker.ts b/ui/ceval/src/worker.ts index befd187d4f0de..790bbff3762d8 100644 --- a/ui/ceval/src/worker.ts +++ b/ui/ceval/src/worker.ts @@ -65,6 +65,7 @@ export interface ThreadedWasmWorkerOpts { module: 'Stockfish' | 'StockfishMv'; version?: string; downloadProgress?: (mb: number) => void; + wasmMemory: WebAssembly.Memory; } export class ThreadedWasmWorker extends AbstractWorker { @@ -97,6 +98,7 @@ export class ThreadedWasmWorker extends AbstractWorker { wasmBinary, locateFile: (path: string) => lichess.assetUrl(this.opts.baseUrl + path, { version, sameDomain: path.endsWith('.worker.js') }), + wasmMemory: this.opts.wasmMemory, }) ) .then((sf: any) => { diff --git a/ui/learn/css/_board.scss b/ui/learn/css/_board.scss index 345c5bcb1c671..887bde2f24b8c 100644 --- a/ui/learn/css/_board.scss +++ b/ui/learn/css/_board.scss @@ -151,3 +151,20 @@ content: '1'; } } +// Workaround for chessground 4.4 used in `ui/learn` page. +// This selector has no effect for chessground 7.11.0 used in other pages. +cg-container > cg-board > svg { + overflow: hidden; + position: absolute; + top: 0px; + left: 0px; + width: 100%; + height: 100%; + pointer-events: none; + z-index: z('cg__svg.cg-shapes'); + opacity: 0.6; + + image { + opacity: 0.5; + } +} diff --git a/ui/learn/css/_screen.scss b/ui/learn/css/_screen.scss index 6801650d64ca2..68ca8a50b5b3e 100644 --- a/ui/learn/css/_screen.scss +++ b/ui/learn/css/_screen.scss @@ -3,17 +3,20 @@ background: rgba(79, 195, 247, 0.9); cursor: pointer; + display: grid; } .learn__screen { @extend %popup-shadow, %box-radius; - margin: 150px auto; + margin: auto; background-color: #fff; width: 350px; overflow: hidden; text-align: center; padding-top: 36px; + max-height: 100%; + overflow: auto; > :nth-child(1) { animation: slideIn 1s cubic-bezier(0.37, 0.82, 0.2, 1); diff --git a/ui/lobby/src/setup.ts b/ui/lobby/src/setup.ts index aa3374363b90e..8fb1d4985a4fb 100644 --- a/ui/lobby/src/setup.ts +++ b/ui/lobby/src/setup.ts @@ -382,8 +382,9 @@ export default class Setup { var validateFen = debounce(() => { $fenInput.removeClass('success failure'); var fen = $fenInput.val() as string; - if (fen) - xhr.text(xhr.url($fenInput.parent().data('validate-url'), { fen })).then( + if (fen) { + var [path, params] = $fenInput.parent().data('validate-url').split('?'); // Separate "strict=1" for AI match + xhr.text(xhr.url(path, { fen }) + (params ? `&${params}` : '')).then( data => { $fenInput.addClass('success'); $fenPosition.find('.preview').html(data); @@ -399,6 +400,7 @@ export default class Setup { $submits.addClass('nope'); } ); + } }, 200); $fenInput.on('keyup', validateFen); diff --git a/ui/round/src/view/button.ts b/ui/round/src/view/button.ts index c62e540722b5e..2be6afaed81bd 100644 --- a/ui/round/src/view/button.ts +++ b/ui/round/src/view/button.ts @@ -77,7 +77,7 @@ function rematchButtons(ctrl: RoundController): MaybeVNodes { } else if (d.opponent.onGame) { d.player.offeringRematch = true; ctrl.socket.send('rematch-yes'); - } else if (!(e.target as HTMLElement).classList.contains('disabled')) ctrl.challengeRematch(); + } else if (!(e.currentTarget as HTMLElement).classList.contains('disabled')) ctrl.challengeRematch(); }, ctrl.redraw ), diff --git a/ui/site/css/mod/_games.scss b/ui/site/css/mod/_games.scss index 3096495a39eaf..3a4a3d1106f66 100644 --- a/ui/site/css/mod/_games.scss +++ b/ui/site/css/mod/_games.scss @@ -14,7 +14,7 @@ background: mix($c-bad, $c-bg-box, 50%); } - td:first-child { + td.input { cursor: pointer; &:hover { background: $c-bg-zebra2; diff --git a/ui/site/package.json b/ui/site/package.json index cbc357a97525a..ba80caed21628 100644 --- a/ui/site/package.json +++ b/ui/site/package.json @@ -20,7 +20,7 @@ "highcharts": "=4.2.5", "hopscotch": "^0.3.1", "jquery-bar-rating": "^1.2.2", - "stockfish-mv.wasm": "^0.6.0", + "stockfish-mv.wasm": "^0.6.1", "stockfish-nnue.wasm": "0.0.1", "stockfish.js": "^10.0.2", "stockfish.wasm": "^0.10.0", diff --git a/ui/site/src/modGames.ts b/ui/site/src/modGames.ts index efce4c43048bd..fca31ee993bc1 100644 --- a/ui/site/src/modGames.ts +++ b/ui/site/src/modGames.ts @@ -76,9 +76,11 @@ const expandCheckboxZone = (table: HTMLTableElement, onSelect: OnSelect) => $(table).on('click', 'td:first-child', (e: MouseEvent) => { if ((e.target as HTMLElement).tagName == 'INPUT') onSelect(e.target as HTMLInputElement, e.shiftKey); else { - const input = (e.target as HTMLTableDataCellElement).querySelector('input') as HTMLInputElement; - input.checked = !input.checked; - onSelect(input, e.shiftKey); + const input = (e.target as HTMLTableDataCellElement).querySelector('input') as HTMLInputElement | undefined; + if (input) { + input.checked = !input.checked; + onSelect(input, e.shiftKey); + } } }); diff --git a/yarn.lock b/yarn.lock index 93208f9edc22e..e432added1e1d 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4132,10 +4132,10 @@ stdout-stream@^1.4.0: dependencies: readable-stream "^2.0.1" -stockfish-mv.wasm@^0.6.0: - version "0.6.0" - resolved "https://registry.yarnpkg.com/stockfish-mv.wasm/-/stockfish-mv.wasm-0.6.0.tgz#83c02f4ac08a3976b7c57c215d9f7a90a3b3a09d" - integrity sha512-eO9iP9iCf8N341wY7w6AfbdC1VLZvc/UawXn3lkJaZm1stL2Kvqyik3G6NGt0z1z1v967L5dg2MmfZivuNk8Sg== +stockfish-mv.wasm@^0.6.1: + version "0.6.1" + resolved "https://registry.yarnpkg.com/stockfish-mv.wasm/-/stockfish-mv.wasm-0.6.1.tgz#cd160b01b25ff64794cb44fe55b6791c3fb297f8" + integrity sha512-6QE1LeWbv9NNMABHCjDGyjgob+1CfMpIszxUKCUcCH0znreWPXz48G09rhKTUpRzC8YsOTHPQL/s74gDcqXJGw== stockfish-nnue.wasm@0.0.1: version "0.0.1"