add Puzzle Streak

while on tramadol (for medical reasons)
streak
Thibault Duplessis 2021-03-28 18:09:26 +02:00
parent 26232a1b6b
commit 1a0813df4a
29 changed files with 570 additions and 309 deletions

View File

@ -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

View File

@ -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)
)
}

View File

@ -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(

View File

@ -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 {
???
}
}
}

View File

@ -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")
)

View File

@ -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)
}

View File

@ -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))

View File

@ -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

View File

@ -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) =

View File

@ -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")
}

View File

@ -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 =>

View File

@ -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(

View File

@ -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(

View File

@ -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)
}
}
}
}
}

View File

@ -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,

View File

@ -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)
}
}
}

View File

@ -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>

View File

@ -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 {

View File

@ -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;
}
}

View File

@ -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,
};
}

View File

@ -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;

View File

@ -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: [] }));

View File

@ -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;
};
}

View File

@ -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,
]),
]
);
}

View File

@ -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);

View File

@ -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}`,
},
}),
]);
}

View File

@ -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',
{

View File

@ -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])),

View File

@ -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() } : {}),
}),
});
}