puzzle WIP
parent
cd5f13ab02
commit
f2b45b377d
|
@ -15,19 +15,17 @@ final class Puzzle(
|
|||
apiC: => Api
|
||||
) extends LilaController(env) {
|
||||
|
||||
private type ThemeOrAny = Option[PuzzleTheme.Key]
|
||||
|
||||
private def renderJson(puzzle: Puz, theme: ThemeOrAny, round: Option[PuzzleRound] = None)(implicit
|
||||
private def renderJson(puzzle: Puz, theme: PuzzleTheme, round: Option[PuzzleRound] = None)(implicit
|
||||
ctx: Context
|
||||
): Fu[JsObject] =
|
||||
env.puzzle.jsonView(puzzle = puzzle, theme = theme, user = ctx.me, round = round)
|
||||
env.puzzle.jsonView(puzzle = puzzle, theme = theme.key, user = ctx.me, round = round)
|
||||
|
||||
private def renderShow(puzzle: Puz, theme: ThemeOrAny)(implicit ctx: Context) =
|
||||
private def renderShow(puzzle: Puz, theme: PuzzleTheme)(implicit ctx: Context) =
|
||||
ctx.me.?? { env.puzzle.api.round.find(_, puzzle) } flatMap {
|
||||
renderShowWithRound(puzzle, theme, _)
|
||||
}
|
||||
|
||||
private def renderShowWithRound(puzzle: Puz, theme: ThemeOrAny, round: Option[PuzzleRound])(implicit
|
||||
private def renderShowWithRound(puzzle: Puz, theme: PuzzleTheme, round: Option[PuzzleRound])(implicit
|
||||
ctx: Context
|
||||
) =
|
||||
ctx.me.?? { env.puzzle.api.round.find(_, puzzle) } flatMap { round =>
|
||||
|
@ -45,8 +43,8 @@ final class Puzzle(
|
|||
_.map(_.id) ?? env.puzzle.api.puzzle.find
|
||||
}) { puzzle =>
|
||||
negotiate(
|
||||
html = renderShow(puzzle, none),
|
||||
api = _ => renderJson(puzzle, none) map { Ok(_) }
|
||||
html = renderShow(puzzle, PuzzleTheme.any),
|
||||
api = _ => renderJson(puzzle, PuzzleTheme.any) map { Ok(_) }
|
||||
) map NoCache
|
||||
}
|
||||
}
|
||||
|
@ -55,19 +53,13 @@ final class Puzzle(
|
|||
def home =
|
||||
Open { implicit ctx =>
|
||||
NoBot {
|
||||
nextPuzzleForMe() flatMap {
|
||||
renderShowWithRound(_, none, none)
|
||||
val theme = PuzzleTheme.any
|
||||
nextPuzzleForMe(theme.key) flatMap {
|
||||
renderShowWithRound(_, theme, none)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
def show(id: String) =
|
||||
Open { implicit ctx =>
|
||||
NoBot {
|
||||
OptionFuResult(env.puzzle.api.puzzle find Puz.Id(id)) { renderShow(_, none) }
|
||||
}
|
||||
}
|
||||
|
||||
def load(id: String) =
|
||||
Open { implicit ctx =>
|
||||
NoBot {
|
||||
|
@ -78,10 +70,10 @@ final class Puzzle(
|
|||
}
|
||||
}
|
||||
|
||||
private def nextPuzzleForMe(theme: Option[PuzzleTheme.Key] = None)(implicit ctx: Context): Fu[Puz] =
|
||||
private def nextPuzzleForMe(theme: PuzzleTheme.Key)(implicit ctx: Context): Fu[Puz] =
|
||||
ctx.me match {
|
||||
case _ => env.puzzle.anon.getOneFor(theme) orFail "Couldn't find a puzzle for anon!"
|
||||
// case Some(me) => env.puzzle.cursor.nextPuzzleFor(me)
|
||||
case Some(me) => env.puzzle.cursor.nextPuzzleFor(me, theme)
|
||||
case None => env.puzzle.anon.getOneFor(theme) orFail "Couldn't find a puzzle for anon!"
|
||||
}
|
||||
|
||||
def complete(themeStr: String, id: String) =
|
||||
|
@ -89,8 +81,8 @@ final class Puzzle(
|
|||
NoBot {
|
||||
implicit val req = ctx.body
|
||||
OptionFuResult(env.puzzle.api.puzzle find Puz.Id(id)) { puzzle =>
|
||||
val theme = PuzzleTheme.find(themeStr)
|
||||
lila.mon.puzzle.round.attempt(ctx.isAuth, theme.fold("any")(_.key.value)).increment()
|
||||
val theme = PuzzleTheme findOrAny themeStr
|
||||
lila.mon.puzzle.round.attempt(ctx.isAuth, theme.key.value).increment()
|
||||
env.puzzle.forms.round
|
||||
.bindFromRequest()
|
||||
.fold(
|
||||
|
@ -102,13 +94,14 @@ final class Puzzle(
|
|||
isStudent <- env.clas.api.student.isStudent(me.id)
|
||||
(round, perf) <- env.puzzle.finisher(
|
||||
puzzle = puzzle,
|
||||
theme = theme.key,
|
||||
user = me,
|
||||
result = Result(resultInt == 1),
|
||||
isStudent = isStudent
|
||||
)
|
||||
_ = env.puzzle.cursor.onComplete(round, theme.map(_.key))
|
||||
next <- nextPuzzleForMe(theme.map(_.key))
|
||||
nextJson <- renderJson(next, none)
|
||||
_ = env.puzzle.cursor.onComplete(round, theme.key)
|
||||
next <- nextPuzzleForMe(theme.key)
|
||||
nextJson <- renderJson(next, theme)
|
||||
} yield Ok(
|
||||
Json.obj(
|
||||
"round" -> Json
|
||||
|
@ -198,12 +191,16 @@ final class Puzzle(
|
|||
}
|
||||
}
|
||||
|
||||
def byTheme(theme: String) = Open { implicit ctx =>
|
||||
PuzzleTheme.find(theme) match {
|
||||
def show(themeOrId: String) = Open { implicit ctx =>
|
||||
PuzzleTheme.find(themeOrId) match {
|
||||
case None if themeOrId.size == 5 =>
|
||||
NoBot {
|
||||
OptionFuResult(env.puzzle.api.puzzle find Puz.Id(themeOrId)) { renderShow(_, PuzzleTheme.any) }
|
||||
}
|
||||
case None => Redirect(routes.Puzzle.home()).fuccess
|
||||
case Some(theme) =>
|
||||
nextPuzzleForMe(theme.key.some) flatMap {
|
||||
renderShowWithRound(_, theme.key.some, none)
|
||||
nextPuzzleForMe(theme.key) flatMap {
|
||||
renderShowWithRound(_, theme, none)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -36,6 +36,7 @@ object bits {
|
|||
trans.wasThisPuzzleAnyGood,
|
||||
trans.pleaseVotePuzzle,
|
||||
trans.thankYou,
|
||||
trans.puzzleId,
|
||||
trans.ratingX,
|
||||
trans.playedXTimes,
|
||||
trans.continueTraining,
|
||||
|
|
|
@ -20,12 +20,17 @@ object theme {
|
|||
h1("Puzzle themes"),
|
||||
div(cls := "puzzle-themes")(
|
||||
themes map { pt =>
|
||||
a(cls := "puzzle-themes__link", href := routes.Puzzle.byTheme(pt.theme.key.value))(
|
||||
strong(
|
||||
pt.theme.name(),
|
||||
em(pt.count.localize)
|
||||
),
|
||||
span(pt.theme.description())
|
||||
val url =
|
||||
if (pt.theme == PuzzleTheme.any) routes.Puzzle.home()
|
||||
else routes.Puzzle.show(pt.theme.key.value)
|
||||
a(cls := "puzzle-themes__link", href := url)(
|
||||
span(
|
||||
strong(
|
||||
pt.theme.name(),
|
||||
em(pt.count.localize)
|
||||
),
|
||||
span(pt.theme.description())
|
||||
)
|
||||
)
|
||||
}
|
||||
)
|
||||
|
|
|
@ -84,9 +84,7 @@ GET /training/export/gif/thumbnail/:id.gif controllers.Export.puzzleThumbnail(
|
|||
GET /training/batch controllers.Puzzle.batchSelect
|
||||
POST /training/batch controllers.Puzzle.batchSolve
|
||||
GET /training/themes controllers.Puzzle.themes
|
||||
GET /training/:theme controllers.Puzzle.byTheme(theme: String)
|
||||
GET /training/$id<\w{5}> controllers.Puzzle.show(id: String)
|
||||
GET /training/$id<\w{5}>/load controllers.Puzzle.load(id: String)
|
||||
GET /training/:themeOrId controllers.Puzzle.show(themeOrId: String)
|
||||
POST /training/$id<\w{5}>/vote controllers.Puzzle.vote(id: String)
|
||||
POST /training/complete/:theme/$id<\w{5}> controllers.Puzzle.complete(theme: String, id: String)
|
||||
|
||||
|
|
|
@ -351,6 +351,7 @@ val `yourPuzzleRatingX` = new I18nKey("yourPuzzleRatingX")
|
|||
val `findTheBestMoveForWhite` = new I18nKey("findTheBestMoveForWhite")
|
||||
val `findTheBestMoveForBlack` = new I18nKey("findTheBestMoveForBlack")
|
||||
val `toTrackYourProgress` = new I18nKey("toTrackYourProgress")
|
||||
val `puzzleId` = new I18nKey("puzzleId")
|
||||
val `puzzleOfTheDay` = new I18nKey("puzzleOfTheDay")
|
||||
val `clickToSolve` = new I18nKey("clickToSolve")
|
||||
val `goodMove` = new I18nKey("goodMove")
|
||||
|
@ -1867,6 +1868,8 @@ val `veryLong` = new I18nKey("puzzleTheme:veryLong")
|
|||
val `veryLongDescription` = new I18nKey("puzzleTheme:veryLongDescription")
|
||||
val `zugzwang` = new I18nKey("puzzleTheme:zugzwang")
|
||||
val `zugzwangDescription` = new I18nKey("puzzleTheme:zugzwangDescription")
|
||||
val `healthyMix` = new I18nKey("puzzleTheme:healthyMix")
|
||||
val `healthyMixDescription` = new I18nKey("puzzleTheme:healthyMixDescription")
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
@ -18,7 +18,7 @@ final class JsonView(
|
|||
|
||||
def apply(
|
||||
puzzle: Puzzle,
|
||||
theme: Option[PuzzleTheme.Key],
|
||||
theme: PuzzleTheme.Key,
|
||||
user: Option[User],
|
||||
round: Option[PuzzleRound] = None
|
||||
): Fu[JsObject] = {
|
||||
|
@ -29,9 +29,9 @@ final class JsonView(
|
|||
Json
|
||||
.obj(
|
||||
"game" -> gameJson,
|
||||
"puzzle" -> puzzleJson(puzzle)
|
||||
"puzzle" -> puzzleJson(puzzle),
|
||||
"theme" -> theme
|
||||
)
|
||||
.add("theme" -> theme)
|
||||
.add("user" -> user.map(userJson))
|
||||
}
|
||||
}
|
||||
|
|
|
@ -15,16 +15,16 @@ final class PuzzleAnon(colls: PuzzleColls, cacheApi: CacheApi, pathApi: PuzzlePa
|
|||
|
||||
import BsonHandlers._
|
||||
|
||||
def getOneFor(theme: Option[PuzzleTheme.Key]): Fu[Option[Puzzle]] =
|
||||
def getOneFor(theme: PuzzleTheme.Key): Fu[Option[Puzzle]] =
|
||||
pool get theme map ThreadLocalRandom.oneOf
|
||||
|
||||
private val poolSize = 50
|
||||
|
||||
private val pool =
|
||||
cacheApi[Option[PuzzleTheme.Key], Vector[Puzzle]](initialCapacity = 32, name = "puzzle.byTheme.anon") {
|
||||
cacheApi[PuzzleTheme.Key, Vector[Puzzle]](initialCapacity = 32, name = "puzzle.byTheme.anon") {
|
||||
_.refreshAfterWrite(2 minutes)
|
||||
.buildAsyncFuture { theme =>
|
||||
theme.fold(fuccess(Int.MaxValue))(pathApi.countPuzzlesByTheme) flatMap { count =>
|
||||
pathApi countPuzzlesByTheme theme flatMap { count =>
|
||||
val tier =
|
||||
if (count > 3000) PuzzlePath.tier.top
|
||||
else PuzzlePath.tier.all
|
||||
|
@ -34,7 +34,7 @@ final class PuzzleAnon(colls: PuzzleColls, cacheApi: CacheApi, pathApi: PuzzlePa
|
|||
else 0 to 9999
|
||||
val selector =
|
||||
$doc(
|
||||
"_id" $startsWith s"${theme | PuzzleTheme.anyKey}_${tier}_",
|
||||
"_id" $startsWith s"${theme}_${tier}_",
|
||||
"min" $gte ratingRange.min,
|
||||
"max" $lte ratingRange.max
|
||||
)
|
||||
|
|
|
@ -66,10 +66,7 @@ final private[puzzle] class PuzzleApi(
|
|||
def sortedWithCount: Fu[List[PuzzleTheme.WithCount]] =
|
||||
pathApi.countsByTheme map { counts =>
|
||||
PuzzleTheme.sorted flatMap { pt =>
|
||||
counts.getOrElse(pt.key, 0) match {
|
||||
case 0 => Nil
|
||||
case count => List(PuzzleTheme.WithCount(pt, count))
|
||||
}
|
||||
counts.get(pt.key) ?? { count => List(PuzzleTheme.WithCount(pt, count)) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
package lila.puzzle
|
||||
|
||||
import reactivemongo.api.bson.BSONRegex
|
||||
import scala.concurrent.duration._
|
||||
import scala.concurrent.ExecutionContext
|
||||
import scala.util.chaining._
|
||||
|
@ -10,7 +11,7 @@ import lila.rating.{ Perf, PerfType }
|
|||
import lila.user.{ User, UserRepo }
|
||||
|
||||
private case class PuzzleCursor(
|
||||
theme: Option[PuzzleTheme.Key],
|
||||
theme: PuzzleTheme.Key,
|
||||
path: Puzzle.PathId,
|
||||
previousPaths: Set[Puzzle.PathId],
|
||||
positionInPath: Int
|
||||
|
@ -41,7 +42,7 @@ final class PuzzleCursorApi(colls: PuzzleColls, cacheApi: CacheApi, userRepo: Us
|
|||
case class PuzzleFound(puzzle: Puzzle) extends NextPuzzleResult
|
||||
}
|
||||
|
||||
def nextPuzzleFor(user: User, theme: Option[PuzzleTheme.Key], isRetry: Boolean = false): Fu[Puzzle] =
|
||||
def nextPuzzleFor(user: User, theme: PuzzleTheme.Key, isRetry: Boolean = false): Fu[Puzzle] =
|
||||
continueOrCreateCursorFor(user, theme) flatMap { cursor =>
|
||||
import NextPuzzleResult._
|
||||
nextPuzzleResult(user, cursor.pp) flatMap {
|
||||
|
@ -118,7 +119,7 @@ final class PuzzleCursorApi(colls: PuzzleColls, cacheApi: CacheApi, userRepo: Us
|
|||
}
|
||||
}
|
||||
|
||||
def onComplete(round: PuzzleRound, theme: Option[PuzzleTheme.Key]): Unit =
|
||||
def onComplete(round: PuzzleRound, theme: PuzzleTheme.Key): Unit =
|
||||
cursors.getIfPresent(round.userId) foreach {
|
||||
_.filter(_.theme == theme) foreach { cursor =>
|
||||
// yes, even if the completed puzzle was not the current cursor puzzle
|
||||
|
@ -131,38 +132,42 @@ final class PuzzleCursorApi(colls: PuzzleColls, cacheApi: CacheApi, userRepo: Us
|
|||
_.expireAfterWrite(1 hour).buildAsync()
|
||||
)
|
||||
|
||||
private[puzzle] def currentCursorOf(user: User, theme: Option[PuzzleTheme.Key]): Fu[PuzzleCursor] =
|
||||
private[puzzle] def currentCursorOf(user: User, theme: PuzzleTheme.Key): Fu[PuzzleCursor] =
|
||||
cursors.getFuture(user.id, _ => createCursorFor(user, theme))
|
||||
|
||||
private[puzzle] def continueOrCreateCursorFor(
|
||||
user: User,
|
||||
theme: Option[PuzzleTheme.Key]
|
||||
theme: PuzzleTheme.Key
|
||||
): Fu[PuzzleCursor] =
|
||||
currentCursorOf(user, theme) flatMap { current =>
|
||||
if (current.theme == theme) fuccess(current)
|
||||
else createCursorFor(user, theme) tap { cursors.put(user.id, _) }
|
||||
}
|
||||
|
||||
private def createCursorFor(user: User, theme: Option[PuzzleTheme.Key]): Fu[PuzzleCursor] =
|
||||
private def createCursorFor(user: User, theme: PuzzleTheme.Key): Fu[PuzzleCursor] =
|
||||
nextPathIdFor(user.id, theme, Set.empty)
|
||||
.orFail(s"No puzzle path found for ${user.id}, theme: $theme")
|
||||
.dmap(pathId => PuzzleCursor(theme, pathId, Set.empty, 0))
|
||||
|
||||
private def nextPathIdFor(
|
||||
userId: User.ID,
|
||||
theme: Option[PuzzleTheme.Key],
|
||||
theme: PuzzleTheme.Key,
|
||||
previousPaths: Set[PathId]
|
||||
): Fu[Option[PathId]] =
|
||||
userRepo.perfOf(userId, PerfType.Puzzle).dmap(_ | Perf.default) flatMap { perf =>
|
||||
colls.path {
|
||||
_.aggregateOne() { framework =>
|
||||
import framework._
|
||||
val tier = "top"
|
||||
Match(
|
||||
$doc(
|
||||
"tier" -> "top",
|
||||
"_id" ->
|
||||
$doc(
|
||||
"$regex" -> BSONRegex(s"^${theme}_$tier", ""),
|
||||
$nin(previousPaths)
|
||||
),
|
||||
"min" $lte perf.glicko.rating,
|
||||
"max" $gt perf.glicko.rating,
|
||||
"_id" $nin previousPaths
|
||||
"max" $gt perf.glicko.rating
|
||||
)
|
||||
) -> List(
|
||||
Project($id(true)),
|
||||
|
|
|
@ -3,6 +3,7 @@ package lila.puzzle
|
|||
import org.goochjs.glicko2.{ Rating, RatingCalculator, RatingPeriodResults }
|
||||
import org.joda.time.DateTime
|
||||
import scala.util.chaining._
|
||||
import cats.implicits._
|
||||
|
||||
import lila.common.Bus
|
||||
import lila.db.AsyncColl
|
||||
|
@ -20,7 +21,13 @@ final private[puzzle] class PuzzleFinisher(
|
|||
|
||||
import BsonHandlers._
|
||||
|
||||
def apply(puzzle: Puzzle, user: User, result: Result, isStudent: Boolean): Fu[(PuzzleRound, Perf)] =
|
||||
def apply(
|
||||
puzzle: Puzzle,
|
||||
theme: PuzzleTheme.Key,
|
||||
user: User,
|
||||
result: Result,
|
||||
isStudent: Boolean
|
||||
): Fu[(PuzzleRound, Perf)] =
|
||||
api.round.find(user, puzzle) flatMap { prevRound =>
|
||||
val now = DateTime.now
|
||||
val formerUserRating = user.perfs.puzzle.intRating
|
||||
|
@ -44,13 +51,14 @@ final private[puzzle] class PuzzleFinisher(
|
|||
updateRatings(userRating, puzzleRating, result.glicko)
|
||||
val newPuzzleGlicko = user.perfs.puzzle.established
|
||||
.option {
|
||||
Glicko(
|
||||
val g = Glicko(
|
||||
rating = puzzleRating.getRating
|
||||
.atMost(puzzle.glicko.rating + Glicko.maxRatingDelta)
|
||||
.atLeast(puzzle.glicko.rating - Glicko.maxRatingDelta),
|
||||
deviation = puzzleRating.getRatingDeviation,
|
||||
volatility = puzzleRating.getVolatility
|
||||
)
|
||||
if (theme == PuzzleTheme.any.key) g else g.average(puzzle.glicko)
|
||||
}
|
||||
.filter(_.sanityCheck)
|
||||
val round = PuzzleRound(
|
||||
|
@ -61,7 +69,10 @@ final private[puzzle] class PuzzleFinisher(
|
|||
weight = none
|
||||
)
|
||||
val userPerf =
|
||||
user.perfs.puzzle.addOrReset(_.puzzle.crazyGlicko, s"puzzle ${puzzle.id}")(userRating, now)
|
||||
user.perfs.puzzle.addOrReset(_.puzzle.crazyGlicko, s"puzzle ${puzzle.id}")(userRating, now) pipe {
|
||||
p =>
|
||||
if (theme == PuzzleTheme.any.key) p else p.averageGlicko(user.perfs.puzzle)
|
||||
}
|
||||
(round, newPuzzleGlicko, userPerf)
|
||||
}
|
||||
api.round.upsert(round) zip
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
package lila.puzzle
|
||||
|
||||
import scala.concurrent.ExecutionContext
|
||||
import scala.concurrent.duration._
|
||||
import scala.concurrent.ExecutionContext
|
||||
|
||||
import lila.db.dsl._
|
||||
import lila.memo.CacheApi
|
||||
|
@ -43,6 +43,10 @@ final private class PuzzlePathApi(
|
|||
count <- obj int "count"
|
||||
} yield PuzzleTheme.Key(key) -> count
|
||||
}.toMap
|
||||
}.flatMap { themed =>
|
||||
colls.puzzle(_.countAll) map { all =>
|
||||
themed + (PuzzleTheme.any.key -> all.toInt)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -11,9 +11,10 @@ object PuzzleTheme {
|
|||
|
||||
case class WithCount(theme: PuzzleTheme, count: Int)
|
||||
|
||||
val anyKey = Key("any")
|
||||
val any = PuzzleTheme(Key("any"), i.healthyMix, i.healthyMixDescription)
|
||||
|
||||
val sorted: List[PuzzleTheme] = List(
|
||||
any,
|
||||
PuzzleTheme(Key("advancedPawn"), i.advancedPawn, i.advancedPawnDescription),
|
||||
PuzzleTheme(Key("attackingF2F7"), i.attackingF2F7, i.attackingF2F7Description),
|
||||
PuzzleTheme(Key("attraction"), i.attraction, i.attractionDescription),
|
||||
|
@ -56,5 +57,7 @@ object PuzzleTheme {
|
|||
|
||||
def find(key: String) = byKey get Key(key)
|
||||
|
||||
def findOrAny(key: String) = find(key) | any
|
||||
|
||||
implicit val keyIso = lila.common.Iso.string[Key](Key.apply, _.value)
|
||||
}
|
||||
|
|
|
@ -68,4 +68,6 @@
|
|||
<string name="veryLongDescription">Four moves or more to win.</string>
|
||||
<string name="zugzwang">Zugzwang</string>
|
||||
<string name="zugzwangDescription">The opponent is limited in the moves they can make, and all moves worsen their position.</string>
|
||||
<string name="healthyMix">Healthy mix</string>
|
||||
<string name="healthyMixDescription">A bit of everything. You don't know what you expect, so you remain ready for everything!</string>
|
||||
</resources>
|
||||
|
|
|
@ -460,6 +460,7 @@ computer analysis, game chat and shareable URL.</string>
|
|||
<string name="findTheBestMoveForWhite">Find the best move for white.</string>
|
||||
<string name="findTheBestMoveForBlack">Find the best move for black.</string>
|
||||
<string name="toTrackYourProgress">To track your progress:</string>
|
||||
<string name="puzzleId">Puzzle %s</string>
|
||||
<string name="puzzleOfTheDay">Puzzle of the day</string>
|
||||
<string name="clickToSolve">Click to solve</string>
|
||||
<string name="goodMove">Good move</string>
|
||||
|
|
|
@ -60,19 +60,19 @@
|
|||
|
||||
&__side {
|
||||
display: grid;
|
||||
grid-template-areas: 'metas' 'user';
|
||||
grid-template-areas: 'metas' 'user' 'config';
|
||||
grid-gap: $block-gap;
|
||||
|
||||
@include breakpoint($mq-x-small) {
|
||||
grid-template-columns: 1fr 1fr;
|
||||
grid-template-areas: 'metas user';
|
||||
grid-template-columns: 1fr 1fr 1fr;
|
||||
grid-template-areas: 'metas user' 'config';
|
||||
}
|
||||
|
||||
|
||||
@include breakpoint($mq-x-large) {
|
||||
grid-template-columns: 1fr;
|
||||
grid-template-rows: min-content;
|
||||
grid-template-areas: 'metas' 'user';
|
||||
grid-template-rows: min-content min-content;
|
||||
grid-template-areas: 'metas' 'user' 'config';
|
||||
justify-self: end;
|
||||
min-width: 250px;
|
||||
max-width: 350px;
|
||||
|
@ -86,5 +86,9 @@
|
|||
&__user {
|
||||
grid-area: user;
|
||||
}
|
||||
|
||||
&__config {
|
||||
grid-area: config;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,12 +1,29 @@
|
|||
.puzzle-themes {
|
||||
|
||||
&__link {
|
||||
@extend %box-padding-horiz;
|
||||
display: block;
|
||||
@extend %flex-center-nowrap, %box-padding-horiz;
|
||||
padding-top: 2em;
|
||||
padding-bottom: 2em;
|
||||
|
||||
&::before {
|
||||
@extend %data-icon;
|
||||
content: '-';
|
||||
color: $c-font-dimmer;
|
||||
flex: 0 0 2em;
|
||||
margin-right: 1vw;
|
||||
text-align: center;
|
||||
font-size: 4.5em;
|
||||
}
|
||||
&:hover {
|
||||
background: mix($c-bg-box, $c-link, 90%);
|
||||
&::before {
|
||||
color: $c-primary;
|
||||
}
|
||||
}
|
||||
|
||||
> span {
|
||||
flex: 1 1 100%;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
strong {
|
||||
|
|
|
@ -5,16 +5,16 @@
|
|||
align-self: start;
|
||||
background: $c-bg-box;
|
||||
|
||||
.spinner {
|
||||
margin: 4rem auto;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
padding: 2vmin;
|
||||
|
||||
.hidden {
|
||||
color: $c-font-dimmer;
|
||||
}
|
||||
|
||||
.infos {
|
||||
@extend %flex-center;
|
||||
|
||||
|
@ -29,10 +29,6 @@
|
|||
padding-bottom: 2vh;
|
||||
border-bottom: $border;
|
||||
margin-bottom: 2vh;
|
||||
|
||||
.hidden {
|
||||
opacity: 0.7;
|
||||
}
|
||||
}
|
||||
|
||||
.players {
|
||||
|
@ -70,17 +66,22 @@
|
|||
.rp.down {
|
||||
color: $c-bad;
|
||||
}
|
||||
}
|
||||
|
||||
canvas {
|
||||
width: 100%;
|
||||
&__config {
|
||||
@extend %box-neat;
|
||||
align-self: start;
|
||||
background: $c-bg-box;
|
||||
padding: 2vmin;
|
||||
|
||||
@if $theme == "transp" {
|
||||
opacity: .5;
|
||||
&__setting {
|
||||
display: flex;
|
||||
.switch {
|
||||
margin-right: 1em;
|
||||
}
|
||||
label {
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#jqstooltip {
|
||||
box-sizing: content-box;
|
||||
}
|
||||
|
|
|
@ -24,6 +24,7 @@ export default function(opts: PuzzleOpts, redraw: Redraw): Controller {
|
|||
|
||||
let vm: Vm = {} as Vm;
|
||||
let data: PuzzleData, tree: TreeWrapper, ceval: CevalCtrl, moveTest: MoveTestFn;
|
||||
const autoNext = storedProp('puzzle.autoNext', false)
|
||||
const ground = prop<CgApi | undefined>(undefined) as Prop<CgApi>;
|
||||
const threatMode = prop(false);
|
||||
|
||||
|
@ -55,7 +56,6 @@ export default function(opts: PuzzleOpts, redraw: Redraw): Controller {
|
|||
tree = treeBuild(pgnToTree(data.game.pgn));
|
||||
const initialPath = treePath.fromNodeList(treeOps.mainlineNodeList(tree.root));
|
||||
vm.mode = 'play';
|
||||
vm.loading = false;
|
||||
vm.round = undefined;
|
||||
vm.justPlayed = undefined;
|
||||
vm.resultSent = false;
|
||||
|
@ -229,19 +229,20 @@ export default function(opts: PuzzleOpts, redraw: Redraw): Controller {
|
|||
}
|
||||
}
|
||||
|
||||
function sendResult(win: boolean): void {
|
||||
function sendResult(win: boolean, andPause: boolean = false): void {
|
||||
if (vm.resultSent) return;
|
||||
vm.resultSent = true;
|
||||
nbToVoteCall(Math.max(0, parseInt(nbToVoteCall()) - 1));
|
||||
xhr.complete(data.puzzle.id, data.theme, win).then((res: PuzzleResult | undefined) => {
|
||||
xhr.complete(data.puzzle.id, data.theme, win).then((res: PuzzleResult) => {
|
||||
if (res?.next.user && data.user) {
|
||||
data.user.rating = res.next.user.rating;
|
||||
data.user.provisional = res.next.user.provisional;
|
||||
vm.round = res.round;
|
||||
vm.next = res.next;
|
||||
}
|
||||
if (win) speech.success();
|
||||
redraw();
|
||||
vm.next = res.next;
|
||||
if (!andPause && autoNext()) nextPuzzle();
|
||||
else redraw();
|
||||
});
|
||||
}
|
||||
|
||||
|
@ -371,8 +372,7 @@ export default function(opts: PuzzleOpts, redraw: Redraw): Controller {
|
|||
}
|
||||
|
||||
function viewSolution(): void {
|
||||
if (!vm.canViewSolution) return;
|
||||
sendResult(false);
|
||||
sendResult(false, true);
|
||||
vm.mode = 'view';
|
||||
mergeSolution(tree, vm.initialPath, data.puzzle.solution, vm.pov);
|
||||
reorderChildren(vm.initialPath, true);
|
||||
|
@ -453,6 +453,7 @@ export default function(opts: PuzzleOpts, redraw: Redraw): Controller {
|
|||
getCeval,
|
||||
pref: opts.pref,
|
||||
trans: lichess.trans(opts.i18n),
|
||||
autoNext,
|
||||
outcome,
|
||||
toggleCeval,
|
||||
toggleThreatMode,
|
||||
|
|
|
@ -1,11 +1,12 @@
|
|||
import { Outcome } from 'chessops/types';
|
||||
import { Api as CgApi } from 'chessground/api';
|
||||
import { CevalCtrl, NodeEvals } from 'ceval';
|
||||
import { Config as CgConfig } from 'chessground/config';
|
||||
import { Outcome } from 'chessops/types';
|
||||
import { Prop } from 'common';
|
||||
import { Role, Move } from 'chessops/types';
|
||||
import { TreeWrapper } from 'tree';
|
||||
import { VNode } from 'snabbdom/vnode';
|
||||
import { Api as CgApi } from 'chessground/api';
|
||||
import { Config as CgConfig } from 'chessground/config';
|
||||
import { Role, Move } from 'chessops/types';
|
||||
import {StoredBooleanProp} from 'common/storage';
|
||||
|
||||
export type MaybeVNode = VNode | string | null | undefined;
|
||||
export type MaybeVNodes = MaybeVNode[];
|
||||
|
@ -50,6 +51,7 @@ export interface Controller extends KeyboardController {
|
|||
pref: PuzzlePrefs;
|
||||
userMove(orig: Key, dest: Key): void;
|
||||
promotion: any;
|
||||
autoNext: StoredBooleanProp;
|
||||
|
||||
path?: Tree.Path;
|
||||
autoScrollRequested?: boolean;
|
||||
|
@ -62,7 +64,6 @@ export interface Vm {
|
|||
mainline: Tree.Node[];
|
||||
pov: Color;
|
||||
mode: 'play' | 'view' | 'try';
|
||||
loading: boolean;
|
||||
round?: PuzzleRound;
|
||||
next?: PuzzleData;
|
||||
justPlayed?: Key;
|
||||
|
@ -100,7 +101,7 @@ export interface PuzzlePrefs {
|
|||
|
||||
export interface PuzzleData {
|
||||
puzzle: Puzzle;
|
||||
theme?: ThemeKey;
|
||||
theme: ThemeKey;
|
||||
game: PuzzleGame;
|
||||
user: PuzzleUser | undefined;
|
||||
}
|
||||
|
|
|
@ -1,6 +1,4 @@
|
|||
import { h } from 'snabbdom';
|
||||
import { Hooks } from 'snabbdom/hooks';
|
||||
import { VNode } from 'snabbdom/vnode';
|
||||
|
||||
export function bindMobileMousedown(el: HTMLElement, f: (e: Event) => any, redraw?: () => void): void {
|
||||
for (const mousedownEvent of ['touchstart', 'mousedown']) {
|
||||
|
@ -33,11 +31,3 @@ export function dataIcon(icon: string) {
|
|||
'data-icon': icon
|
||||
};
|
||||
}
|
||||
|
||||
export function spinner(): VNode {
|
||||
return h('div.spinner', [
|
||||
h('svg', { attrs: { viewBox: '0 0 40 40' } }, [
|
||||
h('circle', {
|
||||
attrs: { cx: 20, cy: 20, r: 18, fill: 'none' }
|
||||
})])]);
|
||||
}
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
import { h } from 'snabbdom'
|
||||
import { VNode } from 'snabbdom/vnode';
|
||||
import afterView from './after';
|
||||
import { bind, spinner } from '../util';
|
||||
import { bind } from '../util';
|
||||
import { Controller, MaybeVNode } from '../interfaces';
|
||||
|
||||
function viewSolution(ctrl: Controller): VNode {
|
||||
|
@ -66,12 +66,7 @@ function fail(ctrl: Controller): VNode {
|
|||
]);
|
||||
}
|
||||
|
||||
function loading(): VNode {
|
||||
return h('div.puzzle__feedback.loading', spinner());
|
||||
}
|
||||
|
||||
export default function(ctrl: Controller): MaybeVNode {
|
||||
if (ctrl.vm.loading) return loading();
|
||||
if (ctrl.vm.mode === 'view') return afterView(ctrl);
|
||||
if (ctrl.vm.lastFeedback === 'init') return initial(ctrl);
|
||||
if (ctrl.vm.lastFeedback === 'good') return good(ctrl);
|
||||
|
|
|
@ -87,7 +87,8 @@ export default function(ctrl: Controller): VNode {
|
|||
}, [
|
||||
h('aside.puzzle__side', [
|
||||
side.puzzleBox(ctrl),
|
||||
side.userBox(ctrl)
|
||||
side.userBox(ctrl),
|
||||
side.config(ctrl)
|
||||
]),
|
||||
h('div.puzzle__board.main-board' + (ctrl.pref.blindfold ? '.blindfold' : ''), {
|
||||
hook: 'ontouchstart' in window ? undefined : bind('wheel', e => wheel(ctrl, e as WheelEvent))
|
||||
|
|
|
@ -16,6 +16,9 @@ function puzzleInfos(ctrl: Controller, puzzle: Puzzle): VNode {
|
|||
return h('div.infos.puzzle', {
|
||||
attrs: dataIcon('-')
|
||||
}, [h('div', [
|
||||
h('p', ctrl.trans.vdom('puzzleId', h('a', {
|
||||
attrs: { href: `/training/${puzzle.id}` }
|
||||
}, '#' + puzzle.id))),
|
||||
h('p', ctrl.trans.vdom('ratingX', ctrl.vm.mode === 'play' ? h('span.hidden', ctrl.trans.noarg('hidden')) : h('strong', puzzle.rating))),
|
||||
h('p', ctrl.trans.vdom('playedXTimes', h('strong', numberFormat(puzzle.plays))))
|
||||
])]);
|
||||
|
@ -25,7 +28,7 @@ function gameInfos(ctrl: Controller, game: PuzzleGame, puzzle: Puzzle): VNode {
|
|||
return h('div.infos', {
|
||||
attrs: dataIcon(game.perf.icon)
|
||||
}, [h('div', [
|
||||
h('p', ctrl.trans.vdom('fromGameLink', h('a', {
|
||||
h('p', ctrl.trans.vdom('fromGameLink', ctrl.vm.mode == 'play' ? h('span.hidden', ctrl.trans.noarg('hidden')) : h('a', {
|
||||
attrs: { href: `/${game.id}/${ctrl.vm.pov}#${puzzle.initialPly}` }
|
||||
}, '#' + game.id))),
|
||||
h('p', [
|
||||
|
@ -55,3 +58,25 @@ export function userBox(ctrl: Controller): MaybeVNode {
|
|||
])))
|
||||
]);
|
||||
}
|
||||
|
||||
export function config(ctrl: Controller): MaybeVNode {
|
||||
const id = 'puzzle-toggle-autonext';
|
||||
return h('div.puzzle__side__config', [
|
||||
h('div.puzzle__side__config__setting', [
|
||||
h('div.switch', [
|
||||
h(`input#${id}.cmn-toggle.cmn-toggle--subtle`, {
|
||||
attrs: {
|
||||
type: 'checkbox',
|
||||
checked: ctrl.autoNext()
|
||||
},
|
||||
hook: {
|
||||
insert: vnode => (vnode.elm as HTMLElement).addEventListener('change', () =>
|
||||
ctrl.autoNext(!ctrl.autoNext()))
|
||||
}
|
||||
}),
|
||||
h('label', { attrs: { 'for': id } })
|
||||
]),
|
||||
h('label', { attrs: { 'for': id } }, 'Jump to next puzzle immediately')
|
||||
])
|
||||
]);
|
||||
}
|
||||
|
|
Loading…
Reference in New Issue