parent
26232a1b6b
commit
1a0813df4a
|
@ -155,7 +155,6 @@ final class LilaComponents(ctx: ApplicationLoader.Context) extends BuiltInCompon
|
|||
lazy val dgt: DgtCtrl = wire[DgtCtrl]
|
||||
lazy val storm: Storm = wire[Storm]
|
||||
lazy val racer: Racer = wire[Racer]
|
||||
lazy val streak: Streak = wire[Streak]
|
||||
lazy val bulkPairing: BulkPairing = wire[BulkPairing]
|
||||
|
||||
// eagerly wire up all controllers
|
||||
|
|
|
@ -29,17 +29,15 @@ final class Puzzle(
|
|||
ctx: Context
|
||||
): Fu[JsObject] =
|
||||
if (apiVersion.exists(!_.puzzleV2))
|
||||
env.puzzle.jsonView.bc(puzzle = puzzle, theme = theme, user = newUser orElse ctx.me)
|
||||
env.puzzle.jsonView.bc(puzzle = puzzle, user = newUser orElse ctx.me)
|
||||
else
|
||||
env.puzzle.jsonView(puzzle = puzzle, theme = theme, replay = replay, user = newUser orElse ctx.me)
|
||||
env.puzzle.jsonView(puzzle = puzzle, theme = theme.some, replay = replay, user = newUser orElse ctx.me)
|
||||
|
||||
private def renderShow(
|
||||
puzzle: Puz,
|
||||
theme: PuzzleTheme,
|
||||
replay: Option[PuzzleReplay] = None
|
||||
)(implicit
|
||||
ctx: Context
|
||||
) =
|
||||
)(implicit ctx: Context) =
|
||||
renderJson(puzzle, theme, replay) zip
|
||||
ctx.me.??(u => env.puzzle.session.getDifficulty(u) dmap some) map { case (json, difficulty) =>
|
||||
EnableSharedArrayBuffer(
|
||||
|
@ -66,7 +64,7 @@ final class Puzzle(
|
|||
Action.async { implicit req =>
|
||||
env.puzzle.daily.get flatMap {
|
||||
_.fold(NotFound.fuccess) { daily =>
|
||||
JsonOk(env.puzzle.jsonView(daily.puzzle, PuzzleTheme.mix, none, none, withTheme = false)(reqLang))
|
||||
JsonOk(env.puzzle.jsonView(daily.puzzle, none, none, none)(reqLang))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -124,66 +122,96 @@ final class Puzzle(
|
|||
jsonFormError,
|
||||
data =>
|
||||
{
|
||||
ctx.me match {
|
||||
case Some(me) =>
|
||||
env.puzzle.finisher(id, theme.key, me, data.result) flatMap {
|
||||
_ ?? { case (round, perf) =>
|
||||
val newUser = me.copy(perfs = me.perfs.copy(puzzle = perf))
|
||||
for {
|
||||
_ <- env.puzzle.session.onComplete(round, theme.key)
|
||||
json <-
|
||||
if (mobileBc) fuccess {
|
||||
env.puzzle.jsonView.bc.userJson(perf.intRating) ++ Json.obj(
|
||||
"round" -> Json.obj(
|
||||
"ratingDiff" -> 0,
|
||||
"win" -> data.result.win
|
||||
),
|
||||
"voted" -> round.vote
|
||||
)
|
||||
}
|
||||
else
|
||||
data.replayDays match {
|
||||
case None =>
|
||||
for {
|
||||
next <- nextPuzzleForMe(theme.key)
|
||||
nextJson <- renderJson(next, theme, none, newUser.some)
|
||||
} yield Json.obj(
|
||||
"round" -> env.puzzle.jsonView.roundJson(me, round, perf),
|
||||
"next" -> nextJson
|
||||
)
|
||||
case Some(replayDays) =>
|
||||
for {
|
||||
_ <- env.puzzle.replay.onComplete(round, replayDays, theme.key)
|
||||
next <- env.puzzle.replay(me, replayDays, theme.key)
|
||||
json <- next match {
|
||||
case None => fuccess(Json.obj("replayComplete" -> true))
|
||||
case Some((puzzle, replay)) =>
|
||||
renderJson(puzzle, theme, replay.some) map { nextJson =>
|
||||
Json.obj(
|
||||
"round" -> env.puzzle.jsonView.roundJson(me, round, perf),
|
||||
"next" -> nextJson
|
||||
)
|
||||
}
|
||||
}
|
||||
} yield json
|
||||
}
|
||||
} yield json
|
||||
}
|
||||
data.puzzleId match {
|
||||
case Some(streakNextId) =>
|
||||
env.puzzle.api.puzzle.find(streakNextId) flatMap {
|
||||
case None => fuccess(Json.obj("streakComplete" -> true))
|
||||
case Some(puzzle) =>
|
||||
renderJson(puzzle, theme) map { nextJson =>
|
||||
Json.obj("next" -> nextJson)
|
||||
}
|
||||
}
|
||||
case None =>
|
||||
env.puzzle.finisher.incPuzzlePlays(id)
|
||||
if (mobileBc) fuccess(Json.obj("user" -> false))
|
||||
else
|
||||
nextPuzzleForMe(theme.key) flatMap {
|
||||
renderJson(_, theme)
|
||||
} map { json =>
|
||||
Json.obj("next" -> json)
|
||||
}
|
||||
ctx.me match {
|
||||
case Some(me) =>
|
||||
env.puzzle.finisher(id, theme.key, me, data.result) flatMap {
|
||||
_ ?? { case (round, perf) =>
|
||||
val newUser = me.copy(perfs = me.perfs.copy(puzzle = perf))
|
||||
for {
|
||||
_ <- env.puzzle.session.onComplete(round, theme.key)
|
||||
json <-
|
||||
if (mobileBc) fuccess {
|
||||
env.puzzle.jsonView.bc.userJson(perf.intRating) ++ Json.obj(
|
||||
"round" -> Json.obj(
|
||||
"ratingDiff" -> 0,
|
||||
"win" -> data.result.win
|
||||
),
|
||||
"voted" -> round.vote
|
||||
)
|
||||
}
|
||||
else
|
||||
data.replayDays match {
|
||||
case None =>
|
||||
for {
|
||||
next <- nextPuzzleForMe(theme.key)
|
||||
nextJson <- renderJson(next, theme, none, newUser.some)
|
||||
} yield Json.obj(
|
||||
"round" -> env.puzzle.jsonView.roundJson(me, round, perf),
|
||||
"next" -> nextJson
|
||||
)
|
||||
case Some(replayDays) =>
|
||||
for {
|
||||
_ <- env.puzzle.replay.onComplete(round, replayDays, theme.key)
|
||||
next <- env.puzzle.replay(me, replayDays, theme.key)
|
||||
json <- next match {
|
||||
case None => fuccess(Json.obj("replayComplete" -> true))
|
||||
case Some((puzzle, replay)) =>
|
||||
renderJson(puzzle, theme, replay.some) map { nextJson =>
|
||||
Json.obj(
|
||||
"round" -> env.puzzle.jsonView.roundJson(me, round, perf),
|
||||
"next" -> nextJson
|
||||
)
|
||||
}
|
||||
}
|
||||
} yield json
|
||||
}
|
||||
} yield json
|
||||
}
|
||||
}
|
||||
case None =>
|
||||
env.puzzle.finisher.incPuzzlePlays(id)
|
||||
if (mobileBc) fuccess(Json.obj("user" -> false))
|
||||
else
|
||||
nextPuzzleForMe(theme.key) flatMap {
|
||||
renderJson(_, theme)
|
||||
} map { json =>
|
||||
Json.obj("next" -> json)
|
||||
}
|
||||
}
|
||||
}
|
||||
} dmap JsonOk
|
||||
)
|
||||
}
|
||||
|
||||
def streak =
|
||||
Open { implicit ctx =>
|
||||
NoBot {
|
||||
env.puzzle.streak.apply flatMap {
|
||||
_ ?? { case (streak, puzzle) =>
|
||||
env.puzzle.jsonView(puzzle = puzzle, PuzzleTheme.mix.some, none, user = ctx.me) map { preJson =>
|
||||
val json = preJson ++ Json.obj("streak" -> streak.ids.map(_.value))
|
||||
EnableSharedArrayBuffer(
|
||||
Ok(
|
||||
views.html.puzzle
|
||||
.show(puzzle, json, env.puzzle.jsonView.pref(ctx.pref), none)
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
def vote(id: String) =
|
||||
AuthBody { implicit ctx => me =>
|
||||
NoBot {
|
||||
|
@ -350,7 +378,7 @@ final class Puzzle(
|
|||
html = notFound,
|
||||
_ =>
|
||||
OptionFuOk(Puz.numericalId(nid) ?? env.puzzle.api.puzzle.find) { puz =>
|
||||
env.puzzle.jsonView.bc(puzzle = puz, theme = PuzzleTheme.mix, user = ctx.me)
|
||||
env.puzzle.jsonView.bc(puzzle = puz, user = ctx.me)
|
||||
}.dmap(_ as JSON)
|
||||
)
|
||||
}
|
||||
|
|
|
@ -11,7 +11,7 @@ final class Storm(env: Env)(implicit mat: akka.stream.Materializer) extends Lila
|
|||
def home =
|
||||
Open { implicit ctx =>
|
||||
NoBot {
|
||||
env.storm.selector.easySet flatMap { puzzles =>
|
||||
env.storm.selector.apply flatMap { puzzles =>
|
||||
ctx.userId.?? { u => env.storm.highApi.get(u) dmap some } map { high =>
|
||||
NoCache {
|
||||
Ok(
|
||||
|
|
|
@ -1,19 +0,0 @@
|
|||
package controllers
|
||||
|
||||
import views._
|
||||
|
||||
import lila.api.Context
|
||||
import lila.app._
|
||||
|
||||
final class Streak(
|
||||
env: Env,
|
||||
puzzleC: => Puzzle
|
||||
) extends LilaController(env) {
|
||||
|
||||
def home =
|
||||
Open { implicit ctx =>
|
||||
NoBot {
|
||||
???
|
||||
}
|
||||
}
|
||||
}
|
|
@ -40,6 +40,7 @@ object topnav {
|
|||
div(role := "group")(
|
||||
a(href := routes.Puzzle.home)(trans.puzzles()),
|
||||
a(href := routes.Puzzle.dashboard(30, "home"))(trans.puzzle.puzzleDashboard()),
|
||||
a(href := routes.Puzzle.streak)("Puzzle Streak"),
|
||||
a(href := routes.Storm.home)("Puzzle Storm"),
|
||||
a(href := routes.Racer.home)("Puzzle Racer")
|
||||
)
|
||||
|
|
|
@ -17,7 +17,9 @@ object bits {
|
|||
def daily(p: lila.puzzle.Puzzle, fen: chess.format.FEN, lastMove: String) =
|
||||
views.html.board.bits.mini(fen, p.color, lastMove)(span)
|
||||
|
||||
def jsI18n(implicit lang: Lang) = i18nJsObject(i18nKeys) +
|
||||
def jsI18n(streak: Boolean)(implicit lang: Lang) = i18nJsObject {
|
||||
i18nKeys ++ streak.??(streakI18nKeys)
|
||||
} +
|
||||
(PuzzleTheme.enPassant.key.value -> JsString(PuzzleTheme.enPassant.name.txt()(lila.i18n.defaultLang)))
|
||||
|
||||
lazy val jsonThemes = PuzzleTheme.all
|
||||
|
@ -100,4 +102,13 @@ object bits {
|
|||
PuzzleTheme.all.map(_.description) :::
|
||||
PuzzleDifficulty.all.map(_.name)
|
||||
}.map(_.key)
|
||||
|
||||
private val streakI18nKeys: List[MessageKey] =
|
||||
List(
|
||||
trans.storm.skip,
|
||||
trans.puzzle.yourStreakX,
|
||||
trans.puzzle.streakSkipExplanation,
|
||||
trans.puzzle.continueTheStreak,
|
||||
trans.puzzle.newStreak
|
||||
).map(_.key)
|
||||
}
|
||||
|
|
|
@ -28,7 +28,7 @@ object show {
|
|||
.obj(
|
||||
"data" -> data,
|
||||
"pref" -> pref,
|
||||
"i18n" -> bits.jsI18n
|
||||
"i18n" -> bits.jsI18n(streak = data.value.contains("streak"))
|
||||
)
|
||||
.add("themes" -> ctx.isAuth.option(bits.jsonThemes))
|
||||
.add("difficulty" -> difficulty.map(_.key))
|
||||
|
|
|
@ -109,7 +109,7 @@ POST /training/complete/:theme/$id<\w{5}> controllers.Puzzle.complete(theme: S
|
|||
POST /training/difficulty/:theme controllers.Puzzle.setDifficulty(theme: String)
|
||||
|
||||
# Puzzle Streak
|
||||
GET /streak controllers.Streak.home
|
||||
GET /streak controllers.Puzzle.streak
|
||||
|
||||
# Puzzle Storm
|
||||
GET /storm controllers.Storm.home
|
||||
|
|
|
@ -501,11 +501,10 @@ object mon {
|
|||
}
|
||||
object storm {
|
||||
object selector {
|
||||
def time(section: String) = timer("storm.selector.time").withTag("section", section)
|
||||
def count(section: String) = histogram("storm.selector.count").withTag("section", section)
|
||||
def rating(section: String) = histogram("storm.selector.rating").withTag("section", section)
|
||||
def ratingSlice(section: String, index: Int) =
|
||||
histogram("storm.selector.ratingSlice").withTags(Map("section" -> section, "index" -> index))
|
||||
val time = timer("storm.selector.time").withoutTags()
|
||||
val count = histogram("storm.selector.count").withoutTags()
|
||||
val rating = histogram("storm.selector.rating").withoutTags()
|
||||
def ratingSlice(index: Int) = histogram("storm.selector.ratingSlice").withTag("index", index)
|
||||
}
|
||||
object run {
|
||||
def score(auth: Boolean) = histogram("storm.run.score").withTag("auth", auth)
|
||||
|
@ -523,7 +522,14 @@ object mon {
|
|||
"auth" -> auth
|
||||
)
|
||||
)
|
||||
|
||||
}
|
||||
object streak {
|
||||
object selector {
|
||||
val time = timer("streak.selector.time").withoutTags()
|
||||
val count = histogram("streak.selector.count").withoutTags()
|
||||
val rating = histogram("streak.selector.rating").withoutTags()
|
||||
def ratingSlice(index: Int) = histogram("streak.selector.ratingSlice").withTag("index", index)
|
||||
}
|
||||
}
|
||||
object game {
|
||||
def finish(variant: String, speed: String, source: String, mode: String, status: String) =
|
||||
|
|
|
@ -1843,6 +1843,10 @@ val `strengths` = new I18nKey("puzzle:strengths")
|
|||
val `history` = new I18nKey("puzzle:history")
|
||||
val `solved` = new I18nKey("puzzle:solved")
|
||||
val `failed` = new I18nKey("puzzle:failed")
|
||||
val `yourStreakX` = new I18nKey("puzzle:yourStreakX")
|
||||
val `streakSkipExplanation` = new I18nKey("puzzle:streakSkipExplanation")
|
||||
val `continueTheStreak` = new I18nKey("puzzle:continueTheStreak")
|
||||
val `newStreak` = new I18nKey("puzzle:newStreak")
|
||||
val `playedXTimes` = new I18nKey("puzzle:playedXTimes")
|
||||
}
|
||||
|
||||
|
|
|
@ -74,6 +74,8 @@ final class Env(
|
|||
|
||||
lazy val history = wire[PuzzleHistoryApi]
|
||||
|
||||
lazy val streak = wire[PuzzleStreakApi]
|
||||
|
||||
def cli =
|
||||
new lila.common.Cli {
|
||||
def process = { case "puzzle" :: "delete" :: id :: Nil =>
|
||||
|
|
|
@ -19,10 +19,9 @@ final class JsonView(
|
|||
|
||||
def apply(
|
||||
puzzle: Puzzle,
|
||||
theme: PuzzleTheme,
|
||||
theme: Option[PuzzleTheme],
|
||||
replay: Option[PuzzleReplay],
|
||||
user: Option[User],
|
||||
withTheme: Boolean = true
|
||||
user: Option[User]
|
||||
)(implicit
|
||||
lang: Lang
|
||||
): Fu[JsObject] = {
|
||||
|
@ -40,17 +39,18 @@ final class JsonView(
|
|||
.add("replay" -> replay.map(replayJson))
|
||||
.add(
|
||||
"theme",
|
||||
withTheme option
|
||||
theme.map { t =>
|
||||
Json
|
||||
.obj(
|
||||
"key" -> theme.key,
|
||||
"key" -> t.key,
|
||||
"name" -> {
|
||||
if (theme == PuzzleTheme.mix) lila.i18n.I18nKeys.puzzle.puzzleThemes.txt()
|
||||
else theme.name.txt()
|
||||
if (t == PuzzleTheme.mix) lila.i18n.I18nKeys.puzzle.puzzleThemes.txt()
|
||||
else t.name.txt()
|
||||
},
|
||||
"desc" -> theme.description.txt()
|
||||
"desc" -> t.description.txt()
|
||||
)
|
||||
.add("chapter" -> PuzzleTheme.studyChapterIds.get(theme.key))
|
||||
.add("chapter" -> PuzzleTheme.studyChapterIds.get(t.key))
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
@ -126,7 +126,7 @@ final class JsonView(
|
|||
|
||||
object bc {
|
||||
|
||||
def apply(puzzle: Puzzle, theme: PuzzleTheme, user: Option[User])(implicit
|
||||
def apply(puzzle: Puzzle, user: Option[User])(implicit
|
||||
lang: Lang
|
||||
): Fu[JsObject] = {
|
||||
gameJson(
|
||||
|
|
|
@ -7,14 +7,16 @@ import lila.common.Form.{ numberIn, stringIn }
|
|||
|
||||
object PuzzleForm {
|
||||
|
||||
case class RoundData(win: Boolean, replayDays: Option[Int]) {
|
||||
def result = Result(win)
|
||||
case class RoundData(win: Boolean, replayDays: Option[Int], streakId: Option[String]) {
|
||||
def result = Result(win)
|
||||
def puzzleId = streakId flatMap Puzzle.toId
|
||||
}
|
||||
|
||||
val round = Form(
|
||||
mapping(
|
||||
"win" -> boolean,
|
||||
"replayDays" -> optional(numberIn(PuzzleDashboard.dayChoices))
|
||||
"replayDays" -> optional(numberIn(PuzzleDashboard.dayChoices)),
|
||||
"streakId" -> optional(nonEmptyText)
|
||||
)(RoundData.apply)(RoundData.unapply)
|
||||
)
|
||||
|
||||
|
@ -35,7 +37,7 @@ object PuzzleForm {
|
|||
val round = Form(
|
||||
mapping(
|
||||
"win" -> text
|
||||
)(w => RoundData(w == "1" || w == "true", none))(r => none)
|
||||
)(w => RoundData(w == "1" || w == "true", none, none))(r => none)
|
||||
)
|
||||
|
||||
val vote = Form(
|
||||
|
|
|
@ -1,3 +1,137 @@
|
|||
package lila.puzzle
|
||||
|
||||
final class PuzzleStreak {}
|
||||
import org.apache.http.protocol
|
||||
import scala.concurrent.duration._
|
||||
import scala.concurrent.ExecutionContext
|
||||
|
||||
import lila.db.dsl._
|
||||
import lila.memo.CacheApi
|
||||
import reactivemongo.api.ReadPreference
|
||||
|
||||
case class PuzzleStreak(ids: List[Puzzle.Id])
|
||||
|
||||
final class PuzzleStreakApi(colls: PuzzleColls, cacheApi: CacheApi)(implicit ec: ExecutionContext) {
|
||||
|
||||
import BsonHandlers._
|
||||
|
||||
def apply: Fu[Option[(PuzzleStreak, Puzzle)]] = current.get {} flatMap {
|
||||
case id :: ids =>
|
||||
colls.puzzle(_.byId[Puzzle](id.value)) map {
|
||||
_ map { p => PuzzleStreak(id :: ids) -> p }
|
||||
}
|
||||
case _ => fuccess(none)
|
||||
}
|
||||
|
||||
/* for path boundaries:
|
||||
* 800, 900, 1000, 1100, 1200, 1270, 1340, 1410, 1480, 1550, 1620,
|
||||
* 1690, 1760, 1830, 1900, 2000, 2100, 2200, 2350, 2500, 2650, 2800
|
||||
*/
|
||||
private val highestBoundary = 2800
|
||||
private val buckets = List(
|
||||
1000 -> 5,
|
||||
1150 -> 6,
|
||||
1300 -> 7,
|
||||
1450 -> 8,
|
||||
1600 -> 9,
|
||||
1750 -> 10,
|
||||
1900 -> 12,
|
||||
2050 -> 15,
|
||||
2199 -> 17,
|
||||
2349 -> 19,
|
||||
2499 -> 21,
|
||||
2649 -> 23,
|
||||
2799 -> 25,
|
||||
highestBoundary -> 27
|
||||
)
|
||||
private val poolSize = buckets.map(_._2).sum
|
||||
private val theme = lila.puzzle.PuzzleTheme.mix.key.value
|
||||
private val tier = lila.puzzle.PuzzleTier.Good.key
|
||||
private val maxDeviation = 110
|
||||
|
||||
private val current = cacheApi.unit[List[Puzzle.Id]] {
|
||||
_.refreshAfterWrite(30 seconds)
|
||||
.buildAsyncFuture { _ =>
|
||||
colls
|
||||
.path {
|
||||
_.aggregateList(poolSize) { framework =>
|
||||
import framework._
|
||||
Facet(
|
||||
buckets.map { case (rating, nbPuzzles) =>
|
||||
rating.toString -> List(
|
||||
Match(
|
||||
$doc(
|
||||
"min" $lte f"${theme}_${tier}_${rating}%04d",
|
||||
"max" $gte f"${theme}_${tier}_${if (rating == highestBoundary) 9999 else rating}%04d"
|
||||
)
|
||||
),
|
||||
Sample(if (rating > 2300) 5 else 1),
|
||||
Project($doc("_id" -> false, "ids" -> true)),
|
||||
UnwindField("ids"),
|
||||
// ensure we have enough after filtering deviation
|
||||
Sample(nbPuzzles * 4),
|
||||
PipelineOperator(
|
||||
$doc(
|
||||
"$lookup" -> $doc(
|
||||
"from" -> colls.puzzle.name.value,
|
||||
"as" -> "puzzle",
|
||||
"let" -> $doc("id" -> "$ids"),
|
||||
"pipeline" -> $arr(
|
||||
$doc(
|
||||
"$match" -> $doc(
|
||||
"$expr" -> $doc(
|
||||
"$and" -> $arr(
|
||||
$doc("$eq" -> $arr("$_id", "$$id")),
|
||||
$doc("$lte" -> $arr("$glicko.d", maxDeviation))
|
||||
)
|
||||
)
|
||||
)
|
||||
),
|
||||
$doc(
|
||||
"$project" -> $doc(
|
||||
"glicko.r" -> true
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
),
|
||||
UnwindField("puzzle"),
|
||||
Sample(nbPuzzles),
|
||||
ReplaceRootField("puzzle")
|
||||
)
|
||||
}
|
||||
) -> List(
|
||||
Project($doc("all" -> $doc("$setUnion" -> buckets.map(r => s"$$${r._1}")))),
|
||||
UnwindField("all"),
|
||||
ReplaceRootField("all"),
|
||||
Sort(Ascending("glicko.r"))
|
||||
)
|
||||
}.map {
|
||||
_.flatMap(_.getAsOpt[Puzzle.Id]("_id"))
|
||||
}
|
||||
}
|
||||
.mon(_.streak.selector.time)
|
||||
.addEffect(monitor)
|
||||
}
|
||||
}
|
||||
|
||||
private def monitor(ids: List[Puzzle.Id]): Unit =
|
||||
colls.puzzle(_.byIds[Puzzle](ids.map(_.value), ReadPreference.secondaryPreferred)) foreach { puzzles =>
|
||||
val nb = puzzles.size
|
||||
lila.mon.streak.selector.count.record(nb)
|
||||
if (nb < poolSize * 0.9)
|
||||
logger.warn(s"Streak selector wanted $poolSize puzzles, only got $nb")
|
||||
if (nb > 1) {
|
||||
val rest = puzzles.toVector drop 1
|
||||
lila.common.Maths.mean(rest.map(_.glicko.intRating)) foreach { r =>
|
||||
lila.mon.streak.selector.rating.record(r.toInt).unit
|
||||
}
|
||||
(0 to poolSize by 10) foreach { i =>
|
||||
val slice = rest drop i take 10
|
||||
lila.common.Maths.mean(slice.map(_.glicko.intRating)) foreach { r =>
|
||||
lila.mon.streak.selector.ratingSlice(i).record(r.toInt)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -38,7 +38,7 @@ final class RacerApi(colls: RacerColls, selector: StormSelector, cacheApi: Cache
|
|||
}
|
||||
|
||||
def create(player: RacerPlayer.Id, countdownSeconds: Int): Fu[RacerRace.Id] =
|
||||
selector.easySet map { puzzles =>
|
||||
selector.apply map { puzzles =>
|
||||
val race = RacerRace
|
||||
.make(
|
||||
owner = player,
|
||||
|
|
|
@ -17,11 +17,7 @@ final class StormSelector(colls: PuzzleColls, cacheApi: CacheApi)(implicit ec: E
|
|||
|
||||
import StormBsonHandlers._
|
||||
|
||||
def easySet: Fu[List[StormPuzzle]] = currentEasy.get {}
|
||||
|
||||
def fullSet: Fu[List[StormPuzzle]] = easySet flatMap { easy =>
|
||||
currentHard.get {} map { easy ::: _ }
|
||||
}
|
||||
def apply: Fu[List[StormPuzzle]] = current.get {}
|
||||
|
||||
private val theme = lila.puzzle.PuzzleTheme.mix.key.value
|
||||
private val tier = lila.puzzle.PuzzleTier.Good.key
|
||||
|
@ -31,138 +27,116 @@ final class StormSelector(colls: PuzzleColls, cacheApi: CacheApi)(implicit ec: E
|
|||
* 800, 900, 1000, 1100, 1200, 1270, 1340, 1410, 1480, 1550, 1620,
|
||||
* 1690, 1760, 1830, 1900, 2000, 2100, 2200, 2350, 2500, 2650, 2800
|
||||
*/
|
||||
private val ratingBuckets =
|
||||
List(
|
||||
1000 -> 7,
|
||||
1150 -> 7,
|
||||
1300 -> 8,
|
||||
1450 -> 9,
|
||||
1600 -> 10,
|
||||
1750 -> 11,
|
||||
1900 -> 13,
|
||||
2050 -> 15,
|
||||
2199 -> 17,
|
||||
2349 -> 19,
|
||||
2499 -> 21
|
||||
)
|
||||
private val poolSize = ratingBuckets.foldLeft(0) { case (acc, (_, nb)) =>
|
||||
acc + nb
|
||||
}
|
||||
|
||||
private val currentEasy = cacheApi.unit[List[StormPuzzle]] {
|
||||
private val current = cacheApi.unit[List[StormPuzzle]] {
|
||||
_.refreshAfterWrite(6 seconds)
|
||||
.buildAsyncFuture { _ =>
|
||||
fetchPuzzlesForBuckets(
|
||||
"easy",
|
||||
List(
|
||||
1000 -> 7,
|
||||
1150 -> 7,
|
||||
1300 -> 8,
|
||||
1450 -> 9,
|
||||
1600 -> 10,
|
||||
1750 -> 11,
|
||||
1900 -> 13,
|
||||
2050 -> 15,
|
||||
2199 -> 17,
|
||||
2349 -> 19,
|
||||
2499 -> 21
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private val currentHard = cacheApi.unit[List[StormPuzzle]] {
|
||||
_.refreshAfterWrite(5 minutes)
|
||||
.buildAsyncFuture { _ =>
|
||||
fetchPuzzlesForBuckets(
|
||||
"hard",
|
||||
List(
|
||||
2649 -> 22,
|
||||
2799 -> 23
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private def fetchPuzzlesForBuckets(section: String, buckets: List[(Int, Int)]) = {
|
||||
val poolSize = buckets.map(_._2).sum
|
||||
colls
|
||||
.path {
|
||||
_.aggregateList(poolSize) { framework =>
|
||||
import framework._
|
||||
val fenColorRegex = $doc(
|
||||
"$regexMatch" -> $doc(
|
||||
"input" -> "$fen",
|
||||
"regex" -> { if (scala.util.Random.nextBoolean()) " w " else " b " }
|
||||
)
|
||||
)
|
||||
Facet(
|
||||
buckets.map { case (rating, nbPuzzles) =>
|
||||
println(
|
||||
lila.db.BSON debug $doc(
|
||||
"min" $lte f"${theme}_${tier}_${rating}%04d",
|
||||
"max" $gte f"${theme}_${tier}_${if (rating > 2700) 9999 else rating}%04d"
|
||||
colls
|
||||
.path {
|
||||
_.aggregateList(poolSize) { framework =>
|
||||
import framework._
|
||||
val fenColorRegex = $doc(
|
||||
"$regexMatch" -> $doc(
|
||||
"input" -> "$fen",
|
||||
"regex" -> { if (scala.util.Random.nextBoolean()) " w " else " b " }
|
||||
)
|
||||
)
|
||||
rating.toString -> List(
|
||||
Match(
|
||||
$doc(
|
||||
"min" $lte f"${theme}_${tier}_${rating}%04d",
|
||||
"max" $gte f"${theme}_${tier}_${if (rating > 2700) 9999 else rating}%04d"
|
||||
)
|
||||
),
|
||||
Sample(1),
|
||||
Project($doc("_id" -> false, "ids" -> true)),
|
||||
UnwindField("ids"),
|
||||
// ensure we have enough after filtering deviation & color
|
||||
Sample(nbPuzzles * 7),
|
||||
PipelineOperator(
|
||||
$doc(
|
||||
"$lookup" -> $doc(
|
||||
"from" -> colls.puzzle.name.value,
|
||||
"as" -> "puzzle",
|
||||
"let" -> $doc("id" -> "$ids"),
|
||||
"pipeline" -> $arr(
|
||||
$doc(
|
||||
"$match" -> $doc(
|
||||
"$expr" -> $doc(
|
||||
"$and" -> $arr(
|
||||
$doc("$eq" -> $arr("$_id", "$$id")),
|
||||
$doc("$lte" -> $arr("$glicko.d", maxDeviation)),
|
||||
fenColorRegex
|
||||
Facet(
|
||||
ratingBuckets.map { case (rating, nbPuzzles) =>
|
||||
rating.toString -> List(
|
||||
Match(
|
||||
$doc(
|
||||
"min" $lte f"${theme}_${tier}_${rating}%04d",
|
||||
"max" $gte f"${theme}_${tier}_${rating}%04d"
|
||||
)
|
||||
),
|
||||
Sample(1),
|
||||
Project($doc("_id" -> false, "ids" -> true)),
|
||||
UnwindField("ids"),
|
||||
// ensure we have enough after filtering deviation & color
|
||||
Sample(nbPuzzles * 7),
|
||||
PipelineOperator(
|
||||
$doc(
|
||||
"$lookup" -> $doc(
|
||||
"from" -> colls.puzzle.name.value,
|
||||
"as" -> "puzzle",
|
||||
"let" -> $doc("id" -> "$ids"),
|
||||
"pipeline" -> $arr(
|
||||
$doc(
|
||||
"$match" -> $doc(
|
||||
"$expr" -> $doc(
|
||||
"$and" -> $arr(
|
||||
$doc("$eq" -> $arr("$_id", "$$id")),
|
||||
$doc("$lte" -> $arr("$glicko.d", maxDeviation)),
|
||||
fenColorRegex
|
||||
)
|
||||
)
|
||||
)
|
||||
),
|
||||
$doc(
|
||||
"$project" -> $doc(
|
||||
"fen" -> true,
|
||||
"line" -> true,
|
||||
"rating" -> $doc("$toInt" -> "$glicko.r")
|
||||
)
|
||||
)
|
||||
)
|
||||
),
|
||||
$doc(
|
||||
"$project" -> $doc(
|
||||
"fen" -> true,
|
||||
"line" -> true,
|
||||
"rating" -> $doc("$toInt" -> "$glicko.r")
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
),
|
||||
UnwindField("puzzle"),
|
||||
Sample(nbPuzzles),
|
||||
ReplaceRootField("puzzle")
|
||||
)
|
||||
),
|
||||
UnwindField("puzzle"),
|
||||
Sample(nbPuzzles),
|
||||
ReplaceRootField("puzzle")
|
||||
}
|
||||
) -> List(
|
||||
Project($doc("all" -> $doc("$setUnion" -> ratingBuckets.map(r => s"$$${r._1}")))),
|
||||
UnwindField("all"),
|
||||
ReplaceRootField("all"),
|
||||
Sort(Ascending("rating"))
|
||||
)
|
||||
}.map {
|
||||
_.flatMap(StormPuzzleBSONReader.readOpt)
|
||||
}
|
||||
) -> List(
|
||||
Project($doc("all" -> $doc("$setUnion" -> buckets.map(r => s"$$${r._1}")))),
|
||||
UnwindField("all"),
|
||||
ReplaceRootField("all"),
|
||||
Sort(Ascending("rating"))
|
||||
)
|
||||
}.map {
|
||||
_.flatMap(StormPuzzleBSONReader.readOpt)
|
||||
}
|
||||
}
|
||||
.mon(_.storm.selector.time(section))
|
||||
.addEffect { puzzles =>
|
||||
monitor(section, puzzles.toVector, poolSize)
|
||||
}
|
||||
.mon(_.storm.selector.time)
|
||||
.addEffect { puzzles =>
|
||||
monitor(puzzles.toVector, poolSize)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private def monitor(section: String, puzzles: Vector[StormPuzzle], poolSize: Int): Unit = {
|
||||
private def monitor(puzzles: Vector[StormPuzzle], poolSize: Int): Unit = {
|
||||
val nb = puzzles.size
|
||||
lila.mon.storm.selector.count(section).record(nb)
|
||||
lila.mon.storm.selector.count.record(nb)
|
||||
if (nb < poolSize * 0.9)
|
||||
logger.warn(s"Selector wanted $poolSize puzzles, only got $nb")
|
||||
if (nb > 1) {
|
||||
val rest = puzzles.toVector drop 1
|
||||
lila.common.Maths.mean(rest.map(_.rating)) foreach { r =>
|
||||
lila.mon.storm.selector.rating(section).record(r.toInt).unit
|
||||
lila.mon.storm.selector.rating.record(r.toInt).unit
|
||||
}
|
||||
(0 to poolSize by 10) foreach { i =>
|
||||
val slice = rest drop i take 10
|
||||
lila.common.Maths.mean(slice.map(_.rating)) foreach { r =>
|
||||
lila.mon.storm.selector.ratingSlice(section, i).record(r.toInt)
|
||||
lila.mon.storm.selector.ratingSlice(i).record(r.toInt)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -50,4 +50,8 @@
|
|||
<string name="history">Puzzle history</string>
|
||||
<string name="solved">solved</string>
|
||||
<string name="failed">failed</string>
|
||||
<string name="yourStreakX">Your streak: %s</string>
|
||||
<string name="streakSkipExplanation">Skip this move to preserve your streak! Only works once per run.</string>
|
||||
<string name="continueTheStreak">Continue the streak</string>
|
||||
<string name="newStreak">New streak</string>
|
||||
</resources>
|
||||
|
|
|
@ -32,6 +32,17 @@
|
|||
flex: 1 1 50%;
|
||||
font-size: 1.3em;
|
||||
white-space: nowrap;
|
||||
|
||||
.game-over {
|
||||
letter-spacing: 0.5ch;
|
||||
border-bottom: $border;
|
||||
padding-bottom: 0.5em;
|
||||
margin-bottom: 0.5em;
|
||||
}
|
||||
|
||||
.puzzle--streak & {
|
||||
@extend %flex-column;
|
||||
}
|
||||
}
|
||||
|
||||
.puzzle__more {
|
||||
|
|
|
@ -44,11 +44,12 @@
|
|||
background: $c-bg-box;
|
||||
padding: 2vmin;
|
||||
|
||||
&__rating {
|
||||
&__rating,
|
||||
&__streak {
|
||||
strong {
|
||||
@extend %flex-center;
|
||||
|
||||
font-size: 3em;
|
||||
display: block;
|
||||
text-align: center;
|
||||
font-size: 3.5em;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -5,6 +5,7 @@ import keyboard from './keyboard';
|
|||
import makePromotion from './promotion';
|
||||
import moveTest from './moveTest';
|
||||
import PuzzleSession from './session';
|
||||
import PuzzleStreak from './streak';
|
||||
import throttle from 'common/throttle';
|
||||
import { Api as CgApi } from 'chessground/api';
|
||||
import { build as treeBuild, ops as treeOps, path as treePath, TreeWrapper } from 'tree';
|
||||
|
@ -27,20 +28,28 @@ export default function (opts: PuzzleOpts, redraw: Redraw): Controller {
|
|||
next: defer<PuzzleData>(),
|
||||
} as Vm;
|
||||
let data: PuzzleData, tree: TreeWrapper, ceval: CevalCtrl;
|
||||
const autoNext = storedProp('puzzle.autoNext', false);
|
||||
const hasStreak = !!opts.data.streak;
|
||||
const autoNext = storedProp(`puzzle.autoNext${hasStreak ? '.streak' : ''}`, hasStreak);
|
||||
const ground = prop<CgApi | undefined>(undefined) as Prop<CgApi>;
|
||||
const threatMode = prop(false);
|
||||
const session = new PuzzleSession(opts.data.theme.key, opts.data.user?.id);
|
||||
const session = new PuzzleSession(opts.data.theme.key, opts.data.user?.id, hasStreak);
|
||||
const streak = opts.data.streak && new PuzzleStreak(opts.data.streak);
|
||||
|
||||
// required by ceval
|
||||
vm.showComputer = () => vm.mode === 'view';
|
||||
vm.showAutoShapes = () => true;
|
||||
|
||||
const throttleSound = (name: string) => throttle(100, () => lichess.sound.play(name));
|
||||
const loadSound = (file: string, volume?: number, delay?: number) => {
|
||||
setTimeout(() => lichess.sound.loadOggOrMp3(file, `${lichess.sound.baseUrl}/${file}`), delay || 1000);
|
||||
return () => lichess.sound.play(file, volume);
|
||||
};
|
||||
const sound = {
|
||||
move: throttleSound('move'),
|
||||
capture: throttleSound('capture'),
|
||||
check: throttleSound('check'),
|
||||
good: loadSound('lisp/PuzzleStormGood', 0.9, 500),
|
||||
end: loadSound('lisp/PuzzleStormEnd', 1, 1000),
|
||||
};
|
||||
|
||||
function setPath(path: Tree.Path): void {
|
||||
|
@ -216,11 +225,22 @@ export default function (opts: PuzzleOpts, redraw: Redraw): Controller {
|
|||
vm.lastFeedback = 'fail';
|
||||
revertUserMove();
|
||||
if (vm.mode === 'play') {
|
||||
vm.canViewSolution = true;
|
||||
vm.mode = 'try';
|
||||
sendResult(false);
|
||||
if (streak) {
|
||||
vm.mode = 'view';
|
||||
streak.onComplete(false);
|
||||
setTimeout(viewSolution, 500);
|
||||
sound.end();
|
||||
} else {
|
||||
vm.canViewSolution = true;
|
||||
vm.mode = 'try';
|
||||
sendResult(false);
|
||||
}
|
||||
}
|
||||
} else if (progress == 'win') {
|
||||
if (streak) {
|
||||
streak.onComplete(true);
|
||||
sound.good();
|
||||
}
|
||||
vm.lastFeedback = 'win';
|
||||
if (vm.mode != 'view') {
|
||||
const sent = vm.mode == 'play' ? sendResult(true) : Promise.resolve();
|
||||
|
@ -241,7 +261,7 @@ export default function (opts: PuzzleOpts, redraw: Redraw): Controller {
|
|||
if (vm.resultSent) return Promise.resolve();
|
||||
vm.resultSent = true;
|
||||
session.complete(data.puzzle.id, win);
|
||||
return xhr.complete(data.puzzle.id, data.theme.key, win, data.replay).then((res: PuzzleResult) => {
|
||||
return xhr.complete(data.puzzle.id, data.theme.key, win, data.replay, streak).then((res: PuzzleResult) => {
|
||||
if (res?.replayComplete && data.replay) return lichess.redirect(`/training/dashboard/${data.replay.days}`);
|
||||
if (res?.next.user && data.user) {
|
||||
data.user.rating = res.next.user.rating;
|
||||
|
@ -259,7 +279,7 @@ export default function (opts: PuzzleOpts, redraw: Redraw): Controller {
|
|||
ceval.stop();
|
||||
vm.next.promise.then(initiate).then(redraw);
|
||||
|
||||
if (!data.replay) {
|
||||
if (!streak && !data.replay) {
|
||||
const path = `/training/${data.theme.key}`;
|
||||
if (location.pathname != path) history.replaceState(null, '', path);
|
||||
}
|
||||
|
@ -319,16 +339,9 @@ export default function (opts: PuzzleOpts, redraw: Redraw): Controller {
|
|||
ceval.start(vm.path, vm.nodeList, threatMode());
|
||||
});
|
||||
|
||||
function nextNodeBest() {
|
||||
return treeOps.withMainlineChild(vm.node, function (n) {
|
||||
// return n.eval ? n.eval.pvs[0].moves[0] : null;
|
||||
return n.eval ? n.eval.best : undefined;
|
||||
});
|
||||
}
|
||||
const nextNodeBest = () => treeOps.withMainlineChild(vm.node, n => n.eval?.best);
|
||||
|
||||
function getCeval() {
|
||||
return ceval;
|
||||
}
|
||||
const getCeval = () => ceval;
|
||||
|
||||
function toggleCeval(): void {
|
||||
ceval.toggle();
|
||||
|
@ -409,6 +422,16 @@ export default function (opts: PuzzleOpts, redraw: Redraw): Controller {
|
|||
}, 500);
|
||||
}
|
||||
|
||||
const skip = () => {
|
||||
if (!streak || !streak.skipAvailable || vm.mode != 'play') return;
|
||||
streak.skip();
|
||||
userJump(treePath.fromNodeList(vm.mainline));
|
||||
const moveIndex = treePath.size(vm.path) - treePath.size(vm.initialPath);
|
||||
const solution = data.puzzle.solution[moveIndex];
|
||||
playUci(solution);
|
||||
playBestMove();
|
||||
};
|
||||
|
||||
const vote = (v: boolean) => {
|
||||
if (!vm.voteDisabled) {
|
||||
xhr.vote(data.puzzle.id, v);
|
||||
|
@ -516,5 +539,7 @@ export default function (opts: PuzzleOpts, redraw: Redraw): Controller {
|
|||
dynamic: opts.themes.dynamic.split(' '),
|
||||
static: new Set(opts.themes.static.split(' ')),
|
||||
},
|
||||
streak,
|
||||
skip,
|
||||
};
|
||||
}
|
||||
|
|
|
@ -9,9 +9,11 @@ import { Role, Move } from 'chessops/types';
|
|||
import { StoredBooleanProp } from 'common/storage';
|
||||
import { TreeWrapper } from 'tree';
|
||||
import { VNode } from 'snabbdom/vnode';
|
||||
import PuzzleStreak from './streak';
|
||||
|
||||
export type MaybeVNode = VNode | string | null | undefined;
|
||||
export type MaybeVNodes = MaybeVNode[];
|
||||
export type PuzzleId = string;
|
||||
|
||||
export type Redraw = () => void;
|
||||
|
||||
|
@ -64,6 +66,9 @@ export interface Controller extends KeyboardController {
|
|||
session: PuzzleSession;
|
||||
allThemes?: AllThemes;
|
||||
|
||||
streak?: PuzzleStreak;
|
||||
skip(): void;
|
||||
|
||||
path?: Tree.Path;
|
||||
autoScrollRequested?: boolean;
|
||||
}
|
||||
|
@ -128,6 +133,7 @@ export interface PuzzleData {
|
|||
game: PuzzleGame;
|
||||
user: PuzzleUser | undefined;
|
||||
replay?: PuzzleReplay;
|
||||
streak?: PuzzleId[];
|
||||
}
|
||||
|
||||
export interface PuzzleReplay {
|
||||
|
@ -162,7 +168,7 @@ export interface PuzzleUser {
|
|||
}
|
||||
|
||||
export interface Puzzle {
|
||||
id: string;
|
||||
id: PuzzleId;
|
||||
solution: Uci[];
|
||||
rating: number;
|
||||
plays: number;
|
||||
|
|
|
@ -1,3 +1,4 @@
|
|||
import { prop } from 'common';
|
||||
import { storedJsonProp } from 'common/storage';
|
||||
import { ThemeKey } from './interfaces';
|
||||
|
||||
|
@ -16,7 +17,7 @@ export default class PuzzleSession {
|
|||
maxSize = 100;
|
||||
maxAge = 1000 * 3600;
|
||||
|
||||
constructor(readonly theme: ThemeKey, readonly userId?: string) {}
|
||||
constructor(readonly theme: ThemeKey, readonly userId: string | undefined, readonly streak: boolean) {}
|
||||
|
||||
default = () => ({
|
||||
theme: this.theme,
|
||||
|
@ -24,7 +25,9 @@ export default class PuzzleSession {
|
|||
at: Date.now(),
|
||||
});
|
||||
|
||||
store = storedJsonProp<Store>(`puzzle.session.${this.userId || 'anon'}`, this.default);
|
||||
store = this.streak
|
||||
? prop(this.default())
|
||||
: storedJsonProp<Store>(`puzzle.session.${this.userId || 'anon'}`, this.default);
|
||||
|
||||
clear = () => this.update(s => ({ ...s, rounds: [] }));
|
||||
|
||||
|
|
|
@ -0,0 +1,20 @@
|
|||
import { PuzzleId } from './interfaces';
|
||||
|
||||
export default class PuzzleStreak {
|
||||
current: number = 0;
|
||||
skipAvailable: boolean = true;
|
||||
fail: boolean = false;
|
||||
|
||||
constructor(readonly ids: PuzzleId[]) {}
|
||||
|
||||
onComplete = (win: boolean) => {
|
||||
if (win) this.current++;
|
||||
else this.fail = true;
|
||||
};
|
||||
|
||||
currentId = () => this.ids[this.current];
|
||||
|
||||
skip = (): void => {
|
||||
this.skipAvailable = false;
|
||||
};
|
||||
}
|
|
@ -1,5 +1,5 @@
|
|||
import { bind, dataIcon } from '../util';
|
||||
import { Controller } from '../interfaces';
|
||||
import { Controller, MaybeVNodes } from '../interfaces';
|
||||
import { h } from 'snabbdom';
|
||||
import { VNode } from 'snabbdom/vnode';
|
||||
|
||||
|
@ -43,28 +43,49 @@ const renderContinue = (ctrl: Controller) =>
|
|||
[h('i', { attrs: dataIcon('G') }), ctrl.trans.noarg('continueTraining')]
|
||||
);
|
||||
|
||||
const renderStreak = (ctrl: Controller): MaybeVNodes => [
|
||||
h('div.complete', [
|
||||
h('span.game-over', 'GAME OVER'),
|
||||
h('span', ctrl.trans.vdom('yourStreakX', h('strong', ctrl.streak?.current))),
|
||||
]),
|
||||
h(
|
||||
'a.continue',
|
||||
{
|
||||
attrs: { href: '/streak' },
|
||||
},
|
||||
[h('i', { attrs: dataIcon('G') }), ctrl.trans('newStreak')]
|
||||
),
|
||||
];
|
||||
|
||||
export default function (ctrl: Controller): VNode {
|
||||
const data = ctrl.getData();
|
||||
return h('div.puzzle__feedback.after', [
|
||||
h('div.complete', ctrl.trans.noarg(ctrl.vm.lastFeedback == 'win' ? 'puzzleSuccess' : 'puzzleComplete')),
|
||||
data.user ? renderVote(ctrl) : renderContinue(ctrl),
|
||||
h('div.puzzle__more', [
|
||||
h('a', {
|
||||
attrs: {
|
||||
'data-icon': '',
|
||||
href: `/analysis/${ctrl.vm.node.fen.replace(/ /g, '_')}?color=${ctrl.vm.pov}#practice`,
|
||||
title: ctrl.trans.noarg('playWithTheMachine'),
|
||||
},
|
||||
}),
|
||||
ctrl.getData().user
|
||||
? h(
|
||||
'a',
|
||||
{
|
||||
hook: bind('click', ctrl.nextPuzzle),
|
||||
},
|
||||
ctrl.trans.noarg('continueTraining')
|
||||
)
|
||||
: undefined,
|
||||
]),
|
||||
]);
|
||||
const win = ctrl.vm.lastFeedback == 'win';
|
||||
return h(
|
||||
'div.puzzle__feedback.after',
|
||||
ctrl.streak && !win
|
||||
? renderStreak(ctrl)
|
||||
: [
|
||||
h('div.complete', ctrl.trans.noarg(win ? 'puzzleSuccess' : 'puzzleComplete')),
|
||||
data.user ? renderVote(ctrl) : renderContinue(ctrl),
|
||||
h('div.puzzle__more', [
|
||||
h('a', {
|
||||
attrs: {
|
||||
'data-icon': '',
|
||||
href: `/analysis/${ctrl.vm.node.fen.replace(/ /g, '_')}?color=${ctrl.vm.pov}#practice`,
|
||||
title: ctrl.trans.noarg('playWithTheMachine'),
|
||||
target: '_blank',
|
||||
},
|
||||
}),
|
||||
data.user
|
||||
? h(
|
||||
'a',
|
||||
{
|
||||
hook: bind('click', ctrl.nextPuzzle),
|
||||
},
|
||||
ctrl.trans.noarg(ctrl.streak ? 'continueTheStreak' : 'continueTraining')
|
||||
)
|
||||
: undefined,
|
||||
]),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
|
|
@ -4,26 +4,44 @@ import { Controller, MaybeVNode } from '../interfaces';
|
|||
import { h } from 'snabbdom';
|
||||
import { VNode } from 'snabbdom/vnode';
|
||||
|
||||
function viewSolution(ctrl: Controller): VNode {
|
||||
return h(
|
||||
'div.view_solution',
|
||||
{
|
||||
class: { show: ctrl.vm.canViewSolution },
|
||||
},
|
||||
[
|
||||
h(
|
||||
'a.button.button-empty',
|
||||
const viewSolution = (ctrl: Controller): VNode =>
|
||||
ctrl.streak
|
||||
? h(
|
||||
'div.view_solution.skip',
|
||||
{
|
||||
hook: bind('click', ctrl.viewSolution),
|
||||
class: { show: !!ctrl.streak?.skipAvailable },
|
||||
},
|
||||
ctrl.trans.noarg('viewTheSolution')
|
||||
),
|
||||
]
|
||||
);
|
||||
}
|
||||
[
|
||||
h(
|
||||
'a.button.button-empty',
|
||||
{
|
||||
hook: bind('click', ctrl.skip),
|
||||
attrs: {
|
||||
title: ctrl.trans.noarg('streakSkipExplanation'),
|
||||
},
|
||||
},
|
||||
ctrl.trans.noarg('skip')
|
||||
),
|
||||
]
|
||||
)
|
||||
: h(
|
||||
'div.view_solution',
|
||||
{
|
||||
class: { show: ctrl.vm.canViewSolution },
|
||||
},
|
||||
[
|
||||
h(
|
||||
'a.button.button-empty',
|
||||
{
|
||||
hook: bind('click', ctrl.viewSolution),
|
||||
},
|
||||
ctrl.trans.noarg('viewTheSolution')
|
||||
),
|
||||
]
|
||||
);
|
||||
|
||||
function initial(ctrl: Controller): VNode {
|
||||
return h('div.puzzle__feedback.play', [
|
||||
const initial = (ctrl: Controller): VNode =>
|
||||
h('div.puzzle__feedback.play', [
|
||||
h('div.player', [
|
||||
h('div.no-square', h('piece.king.' + ctrl.vm.pov)),
|
||||
h('div.instruction', [
|
||||
|
@ -33,20 +51,18 @@ function initial(ctrl: Controller): VNode {
|
|||
]),
|
||||
viewSolution(ctrl),
|
||||
]);
|
||||
}
|
||||
|
||||
function good(ctrl: Controller): VNode {
|
||||
return h('div.puzzle__feedback.good', [
|
||||
const good = (ctrl: Controller): VNode =>
|
||||
h('div.puzzle__feedback.good', [
|
||||
h('div.player', [
|
||||
h('div.icon', '✓'),
|
||||
h('div.instruction', [h('strong', ctrl.trans.noarg('bestMove')), h('em', ctrl.trans.noarg('keepGoing'))]),
|
||||
]),
|
||||
viewSolution(ctrl),
|
||||
]);
|
||||
}
|
||||
|
||||
function fail(ctrl: Controller): VNode {
|
||||
return h('div.puzzle__feedback.fail', [
|
||||
const fail = (ctrl: Controller): VNode =>
|
||||
h('div.puzzle__feedback.fail', [
|
||||
h('div.player', [
|
||||
h('div.icon', '✗'),
|
||||
h('div.instruction', [
|
||||
|
@ -56,7 +72,6 @@ function fail(ctrl: Controller): VNode {
|
|||
]),
|
||||
viewSolution(ctrl),
|
||||
]);
|
||||
}
|
||||
|
||||
export default function (ctrl: Controller): MaybeVNode {
|
||||
if (ctrl.vm.mode === 'view') return afterView(ctrl);
|
||||
|
|
|
@ -82,7 +82,7 @@ export default function (ctrl: Controller): VNode {
|
|||
cevalShown = showCeval;
|
||||
}
|
||||
return h(
|
||||
`main.puzzle.puzzle-${ctrl.getData().replay ? 'replay' : 'play'}`,
|
||||
`main.puzzle.puzzle-${ctrl.getData().replay ? 'replay' : 'play'}${ctrl.streak ? '.puzzle--streak' : ''}`,
|
||||
{
|
||||
class: { 'gauge-on': gaugeOn },
|
||||
hook: {
|
||||
|
@ -148,6 +148,7 @@ function session(ctrl: Controller) {
|
|||
},
|
||||
attrs: {
|
||||
href: `/training/${ctrl.session.theme}/${round.id}`,
|
||||
...(ctrl.streak ? { target: '_blank' } : {}),
|
||||
},
|
||||
},
|
||||
rd
|
||||
|
@ -162,9 +163,11 @@ function session(ctrl: Controller) {
|
|||
})
|
||||
: h('a.result-cursor.current', {
|
||||
key: current,
|
||||
attrs: {
|
||||
href: `/training/${ctrl.session.theme}/${current}`,
|
||||
},
|
||||
attrs: ctrl.streak
|
||||
? {}
|
||||
: {
|
||||
href: `/training/${ctrl.session.theme}/${current}`,
|
||||
},
|
||||
}),
|
||||
]);
|
||||
}
|
||||
|
|
|
@ -23,7 +23,10 @@ function puzzleInfos(ctrl: Controller, puzzle: Puzzle): VNode {
|
|||
h(
|
||||
'a',
|
||||
{
|
||||
attrs: { href: `/training/${puzzle.id}` },
|
||||
attrs: {
|
||||
href: `/training/${puzzle.id}`,
|
||||
...(ctrl.streak ? { target: '_blank' } : {}),
|
||||
},
|
||||
},
|
||||
'#' + puzzle.id
|
||||
)
|
||||
|
@ -33,7 +36,9 @@ function puzzleInfos(ctrl: Controller, puzzle: Puzzle): VNode {
|
|||
'p',
|
||||
ctrl.trans.vdom(
|
||||
'ratingX',
|
||||
ctrl.vm.mode === 'play' ? h('span.hidden', ctrl.trans.noarg('hidden')) : h('strong', puzzle.rating)
|
||||
!ctrl.streak && 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)))),
|
||||
|
@ -101,17 +106,19 @@ export function userBox(ctrl: Controller): VNode {
|
|||
]);
|
||||
const diff = ctrl.vm.round?.ratingDiff;
|
||||
return h('div.puzzle__side__user', [
|
||||
h(
|
||||
'div.puzzle__side__user__rating',
|
||||
ctrl.trans.vdom(
|
||||
'yourPuzzleRatingX',
|
||||
h('strong', [
|
||||
data.user.rating - (diff || 0),
|
||||
...(diff && diff > 0 ? [' ', h('good.rp', '+' + diff)] : []),
|
||||
...(diff && diff < 0 ? [' ', h('bad.rp', '−' + -diff)] : []),
|
||||
])
|
||||
)
|
||||
),
|
||||
ctrl.streak
|
||||
? h('div.puzzle__side__user__streak', ctrl.trans.vdom('yourStreakX', h('strong', ctrl.streak.current)))
|
||||
: h(
|
||||
'div.puzzle__side__user__rating',
|
||||
ctrl.trans.vdom(
|
||||
'yourPuzzleRatingX',
|
||||
h('strong', [
|
||||
data.user.rating - (diff || 0),
|
||||
...(diff && diff > 0 ? [' ', h('good.rp', '+' + diff)] : []),
|
||||
...(diff && diff < 0 ? [' ', h('bad.rp', '−' + -diff)] : []),
|
||||
])
|
||||
)
|
||||
),
|
||||
]);
|
||||
}
|
||||
|
||||
|
@ -159,7 +166,7 @@ export function config(ctrl: Controller): MaybeVNode {
|
|||
]),
|
||||
h('label', { attrs: { for: id } }, ctrl.trans.noarg('jumpToNextPuzzleImmediately')),
|
||||
]),
|
||||
!ctrl.getData().replay && ctrl.difficulty
|
||||
!ctrl.getData().replay && !ctrl.streak && ctrl.difficulty
|
||||
? h(
|
||||
'form.puzzle__side__config__difficulty',
|
||||
{
|
||||
|
|
|
@ -7,7 +7,7 @@ const studyUrl = 'https://lichess.org/study/viiWlKjv';
|
|||
|
||||
export default function theme(ctrl: Controller): MaybeVNode {
|
||||
const t = ctrl.getData().theme;
|
||||
return ctrl.getData().replay
|
||||
return ctrl.streak || ctrl.getData().replay
|
||||
? null
|
||||
: h('div.puzzle__side__theme', [
|
||||
h('a', { attrs: { href: '/training/themes' } }, h('h2', ['« ', t.name])),
|
||||
|
|
|
@ -1,19 +1,22 @@
|
|||
import * as xhr from 'common/xhr';
|
||||
import { PuzzleReplay, PuzzleResult, ThemeKey } from './interfaces';
|
||||
import { defined } from 'common';
|
||||
import PuzzleStreak from './streak';
|
||||
import throttle from 'common/throttle';
|
||||
import { defined } from 'common';
|
||||
import { PuzzleReplay, PuzzleResult, ThemeKey } from './interfaces';
|
||||
|
||||
export function complete(
|
||||
puzzleId: string,
|
||||
theme: ThemeKey,
|
||||
win: boolean,
|
||||
replay?: PuzzleReplay
|
||||
replay?: PuzzleReplay,
|
||||
streak?: PuzzleStreak
|
||||
): Promise<PuzzleResult | undefined> {
|
||||
return xhr.json(`/training/complete/${theme}/${puzzleId}`, {
|
||||
method: 'POST',
|
||||
body: xhr.form({
|
||||
win,
|
||||
...(replay ? { replayDays: replay.days } : {}),
|
||||
...(streak ? { streakId: streak.currentId() } : {}),
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
|
Loading…
Reference in New Issue