diff --git a/app/controllers/Puzzle.scala b/app/controllers/Puzzle.scala
index 845951e7b6..24c6063edc 100644
--- a/app/controllers/Puzzle.scala
+++ b/app/controllers/Puzzle.scala
@@ -8,7 +8,7 @@ import lila.api.Context
import lila.app._
import lila.common.config.MaxPerSecond
import lila.puzzle.PuzzleTheme
-import lila.puzzle.{ Result, PuzzleRound, Puzzle => Puz }
+import lila.puzzle.{ Result, PuzzleRound, PuzzleDifficulty, Puzzle => Puz }
final class Puzzle(
env: Env,
@@ -19,11 +19,12 @@ final class Puzzle(
env.puzzle.jsonView(puzzle = puzzle, theme = theme, user = ctx.me)
private def renderShow(puzzle: Puz, theme: PuzzleTheme)(implicit ctx: Context) =
- renderJson(puzzle, theme) map { json =>
- EnableSharedArrayBuffer(
- Ok(views.html.puzzle.show(puzzle, data = json, pref = env.puzzle.jsonView.pref(ctx.pref)))
- )
- }
+ renderJson(puzzle, theme) zip
+ ctx.me.??(u => env.puzzle.session.getDifficulty(u) dmap some) map { case (json, difficulty) =>
+ EnableSharedArrayBuffer(
+ Ok(views.html.puzzle.show(puzzle, json, env.puzzle.jsonView.pref(ctx.pref), difficulty))
+ )
+ }
def daily =
Open { implicit ctx =>
@@ -141,6 +142,21 @@ final class Puzzle(
}
}
+ def setDifficulty(theme: String) =
+ AuthBody { implicit ctx => me =>
+ NoBot {
+ implicit val req = ctx.body
+ env.puzzle.forms.difficulty
+ .bindFromRequest()
+ .fold(
+ jsonFormError,
+ diff =>
+ PuzzleDifficulty.find(diff) ?? { env.puzzle.session.setDifficulty(me, _) } inject
+ Redirect(routes.Puzzle.show(theme))
+ )
+ }
+ }
+
/* Mobile API: select a bunch of puzzles for offline use */
def batchSelect =
Auth { implicit ctx => me =>
diff --git a/app/views/puzzle/bits.scala b/app/views/puzzle/bits.scala
index 5d78412f78..7be069469c 100644
--- a/app/views/puzzle/bits.scala
+++ b/app/views/puzzle/bits.scala
@@ -6,9 +6,8 @@ import play.api.libs.json.{ JsArray, JsObject, JsString, Json }
import lila.app.templating.Environment._
import lila.app.ui.ScalatagsTemplate._
-import lila.i18n.JsDump
-import lila.i18n.MessageKey
-import lila.puzzle.PuzzleTheme
+import lila.i18n.{ JsDump, MessageKey }
+import lila.puzzle.{ PuzzleDifficulty, PuzzleTheme }
object bits {
@@ -55,6 +54,7 @@ object bits {
trans.puzzle.ratingX,
trans.puzzle.playedXTimes,
trans.puzzle.continueTraining,
+ trans.puzzle.difficultyLevel,
trans.puzzle.toTrackYourProgress,
trans.signUp,
trans.analysis,
@@ -70,6 +70,8 @@ object bits {
trans.gameOver,
trans.inLocalBrowser,
trans.toggleLocalEvaluation
- ) ::: PuzzleTheme.all.map(_.name) ::: PuzzleTheme.all.map(_.description)
+ ) ::: PuzzleTheme.all.map(_.name) :::
+ PuzzleTheme.all.map(_.description) :::
+ PuzzleDifficulty.all.map(_.name)
}.map(_.key)
}
diff --git a/app/views/puzzle/show.scala b/app/views/puzzle/show.scala
index 490a55988c..d78849b315 100644
--- a/app/views/puzzle/show.scala
+++ b/app/views/puzzle/show.scala
@@ -11,7 +11,14 @@ import controllers.routes
object show {
- def apply(puzzle: lila.puzzle.Puzzle, data: JsObject, pref: JsObject)(implicit ctx: Context) =
+ def apply(
+ puzzle: lila.puzzle.Puzzle,
+ data: JsObject,
+ pref: JsObject,
+ difficulty: Option[lila.puzzle.PuzzleDifficulty] = None
+ )(implicit
+ ctx: Context
+ ) =
views.html.base.layout(
title = trans.puzzles.txt(),
moreCss = cssTag("puzzle"),
@@ -25,6 +32,7 @@ object show {
"i18n" -> bits.jsI18n
)
.add("themes" -> ctx.isAuth.option(bits.jsonThemes))
+ .add("difficulty" -> difficulty.map(_.key))
)})""")
),
csp = defaultCsp.withWebAssembly.some,
diff --git a/bin/cron/mongodb-puzzle-regen-paths.js b/bin/cron/mongodb-puzzle-regen-paths.js
index 0a7c22d2b9..f6954f629c 100644
--- a/bin/cron/mongodb-puzzle-regen-paths.js
+++ b/bin/cron/mongodb-puzzle-regen-paths.js
@@ -89,7 +89,9 @@ function makeTier(theme, tierName, thresholdRatio) {
min: `${themeName}_${tierName}_${padRating(ratingMin)}`,
max: `${themeName}_${tierName}_${padRating(ratingMax)}`,
ids,
- // length: ids.length,
+ tier: tierName,
+ theme: themeName,
+ size: ids.length,
gen: generation
});
});
diff --git a/conf/routes b/conf/routes
index 1def61c843..ead3a91e9f 100644
--- a/conf/routes
+++ b/conf/routes
@@ -89,6 +89,7 @@ GET /training/:theme/$id<\w{5}> controllers.Puzzle.showWithTheme(theme: S
POST /training/$id<\w{5}>/vote controllers.Puzzle.vote(id: String)
POST /training/$id<\w{5}>/vote/:theme controllers.Puzzle.voteTheme(id: String, theme: String)
POST /training/complete/:theme/$id<\w{5}> controllers.Puzzle.complete(theme: String, id: String)
+POST /training/difficulty/:theme controllers.Puzzle.setDifficulty(theme: String)
# User Analysis
GET /analysis/help controllers.UserAnalysis.help
diff --git a/modules/i18n/src/main/I18nKeys.scala b/modules/i18n/src/main/I18nKeys.scala
index cee3332698..a9d8b09dc0 100644
--- a/modules/i18n/src/main/I18nKeys.scala
+++ b/modules/i18n/src/main/I18nKeys.scala
@@ -1802,6 +1802,12 @@ val `ratingX` = new I18nKey("puzzle:ratingX")
val `hidden` = new I18nKey("puzzle:hidden")
val `fromGameLink` = new I18nKey("puzzle:fromGameLink")
val `continueTraining` = new I18nKey("puzzle:continueTraining")
+val `difficultyLevel` = new I18nKey("puzzle:difficultyLevel")
+val `normal` = new I18nKey("puzzle:normal")
+val `easier` = new I18nKey("puzzle:easier")
+val `easiest` = new I18nKey("puzzle:easiest")
+val `harder` = new I18nKey("puzzle:harder")
+val `hardest` = new I18nKey("puzzle:hardest")
val `playedXTimes` = new I18nKey("puzzle:playedXTimes")
}
diff --git a/modules/puzzle/src/main/JsonView.scala b/modules/puzzle/src/main/JsonView.scala
index 939dd96fd2..d7179b12fa 100644
--- a/modules/puzzle/src/main/JsonView.scala
+++ b/modules/puzzle/src/main/JsonView.scala
@@ -18,7 +18,9 @@ final class JsonView(
import JsonView._
- def apply(puzzle: Puzzle, theme: PuzzleTheme, user: Option[User])(implicit lang: Lang): Fu[JsObject] = {
+ def apply(puzzle: Puzzle, theme: PuzzleTheme, user: Option[User])(implicit
+ lang: Lang
+ ): Fu[JsObject] = {
gameJson(
gameId = puzzle.gameId,
plies = puzzle.initialPly
diff --git a/modules/puzzle/src/main/PuzzleDifficulty.scala b/modules/puzzle/src/main/PuzzleDifficulty.scala
new file mode 100644
index 0000000000..f742de6a4c
--- /dev/null
+++ b/modules/puzzle/src/main/PuzzleDifficulty.scala
@@ -0,0 +1,23 @@
+package lila.puzzle
+
+import lila.i18n.{ I18nKey, I18nKeys => trans }
+
+sealed abstract class PuzzleDifficulty(val ratingDelta: Int, val name: I18nKey) {
+
+ lazy val key = toString.toLowerCase
+}
+
+object PuzzleDifficulty {
+ case object Easiest extends PuzzleDifficulty(-600, trans.puzzle.easiest)
+ case object Easier extends PuzzleDifficulty(-300, trans.puzzle.easier)
+ case object Normal extends PuzzleDifficulty(0, trans.puzzle.normal)
+ case object Harder extends PuzzleDifficulty(300, trans.puzzle.harder)
+ case object Hardest extends PuzzleDifficulty(600, trans.puzzle.hardest)
+
+ val all = List(Easiest, Easier, Normal, Harder, Hardest)
+ val default = Normal
+
+ def isExtreme(d: PuzzleDifficulty) = d == Easiest || d == Hardest
+
+ def find(str: String) = all.find(_.key == str)
+}
diff --git a/modules/puzzle/src/main/PuzzleForm.scala b/modules/puzzle/src/main/PuzzleForm.scala
index d9d773e3cc..01cd4a6884 100644
--- a/modules/puzzle/src/main/PuzzleForm.scala
+++ b/modules/puzzle/src/main/PuzzleForm.scala
@@ -3,6 +3,8 @@ package lila.puzzle
import play.api.data._
import play.api.data.Forms._
+import lila.common.Form.stringIn
+
object PuzzleForm {
val round = Form(
@@ -16,4 +18,8 @@ object PuzzleForm {
val themeVote = Form(
single("vote" -> optional(boolean))
)
+
+ val difficulty = Form(
+ single("difficulty" -> stringIn(PuzzleDifficulty.all.map(_.key).toSet))
+ )
}
diff --git a/modules/puzzle/src/main/PuzzlePath.scala b/modules/puzzle/src/main/PuzzlePath.scala
index 1b116a861b..fc9637f157 100644
--- a/modules/puzzle/src/main/PuzzlePath.scala
+++ b/modules/puzzle/src/main/PuzzlePath.scala
@@ -7,13 +7,13 @@ import lila.db.dsl._
import lila.memo.CacheApi
import lila.user.User
-private object PuzzlePath {
+object PuzzlePath {
case class Id(value: String) {
val parts = value split '_'
- def tier = PuzzleTier.from(~parts.lift(1))
+ private[puzzle] def tier = PuzzleTier.from(~parts.lift(1))
def theme = PuzzleTheme.findOrAny(~parts.headOption).key
}
@@ -38,37 +38,46 @@ final private class PuzzlePathApi(
user: User,
theme: PuzzleTheme.Key,
tier: PuzzleTier,
+ difficulty: PuzzleDifficulty,
previousPaths: Set[PuzzlePath.Id],
compromise: Int = 0
- ): Fu[Option[PuzzlePath.Id]] =
- colls.path {
- _.aggregateOne() { framework =>
- import framework._
- val rating = user.perfs.puzzle.glicko.intRating
- val ratingDelta = compromise match {
- case 0 => 0
- case 1 => 300
- case 2 => 800
- case _ => 2000
- }
- Match(
- $doc(
- "min" $lte f"${theme}_${tier}_${rating + ratingDelta}%04d",
- "max" $gt f"${theme}_${tier}_${rating - ratingDelta}%04d"
+ ): Fu[Option[PuzzlePath.Id]] = {
+ val actualTier =
+ if (tier == PuzzleTier.Top && PuzzleDifficulty.isExtreme(difficulty)) PuzzleTier.Good
+ else tier
+ colls
+ .path {
+ _.aggregateOne() { framework =>
+ import framework._
+ val rating = user.perfs.puzzle.glicko.intRating
+ val ratingDelta = compromise match {
+ case 0 => 0
+ case 1 => 300
+ case 2 => 800
+ case _ => 2000
+ }
+ Match(
+ $doc(
+ "min" $lte f"${theme}_${actualTier}_${rating + difficulty.ratingDelta + ratingDelta}%04d",
+ "max" $gt f"${theme}_${actualTier}_${rating + difficulty.ratingDelta - ratingDelta}%04d"
+ )
+ ) -> List(
+ Sample(1),
+ Project($id(true))
)
- ) -> List(
- Sample(1),
- Project($id(true))
- )
- }.dmap(_.flatMap(_.getAsOpt[PuzzlePath.Id]("_id")))
- } flatMap {
- case Some(path) => fuccess(path.some)
- case _ if tier == PuzzleTier.Top => nextFor(user, theme, PuzzleTier.Good, previousPaths)
- case _ if tier == PuzzleTier.Good && compromise == 2 =>
- nextFor(user, theme, PuzzleTier.All, previousPaths, compromise = 1)
- case _ if compromise < 4 => nextFor(user, theme, tier, previousPaths, compromise + 1)
- case _ => fuccess(none)
- }
+ }.dmap(_.flatMap(_.getAsOpt[PuzzlePath.Id]("_id")))
+ }
+ .flatMap {
+ case Some(path) => fuccess(path.some)
+ case _ if actualTier == PuzzleTier.Top =>
+ nextFor(user, theme, PuzzleTier.Good, difficulty, previousPaths)
+ case _ if actualTier == PuzzleTier.Good && compromise == 2 =>
+ nextFor(user, theme, PuzzleTier.All, difficulty, previousPaths, compromise = 1)
+ case _ if compromise < 4 =>
+ nextFor(user, theme, actualTier, difficulty, previousPaths, compromise + 1)
+ case _ => fuccess(none)
+ }
+ }
private val countByThemeCache =
cacheApi.unit[Map[PuzzleTheme.Key, Int]] {
@@ -77,9 +86,9 @@ final private class PuzzlePathApi(
colls.path {
_.aggregateList(Int.MaxValue) { framework =>
import framework._
- Match($doc("tier" -> "all")) -> List(
+ Match($doc("tier" -> "all", "theme" $ne PuzzleTheme.any.key)) -> List(
GroupField("theme")(
- "count" -> SumField("length")
+ "count" -> SumField("size")
)
)
}.map {
diff --git a/modules/puzzle/src/main/PuzzleSession.scala b/modules/puzzle/src/main/PuzzleSession.scala
index e0885823c7..505dcec173 100644
--- a/modules/puzzle/src/main/PuzzleSession.scala
+++ b/modules/puzzle/src/main/PuzzleSession.scala
@@ -10,7 +10,8 @@ import lila.memo.CacheApi
import lila.rating.{ Perf, PerfType }
import lila.user.{ User, UserRepo }
-private case class PuzzleSession(
+case class PuzzleSession(
+ difficulty: PuzzleDifficulty,
path: PuzzlePath.Id,
positionInPath: Int,
previousPaths: Set[PuzzlePath.Id] = Set.empty,
@@ -52,7 +53,7 @@ final class PuzzleSessionApi(
continueOrCreateSessionFor(user, theme) flatMap { session =>
import NextPuzzleResult._
def switchPath(tier: PuzzleTier) =
- pathApi.nextFor(user, theme, tier, session.previousPaths) orFail
+ pathApi.nextFor(user, theme, tier, session.difficulty, session.previousPaths) orFail
s"No puzzle path for ${user.id} $theme $tier" flatMap { pathId =>
val newSession = session.switchTo(pathId)
sessions.put(user.id, fuccess(newSession))
@@ -137,6 +138,16 @@ final class PuzzleSessionApi(
}
}
+ def getDifficulty(user: User): Fu[PuzzleDifficulty] =
+ sessions
+ .getIfPresent(user.id)
+ .fold[Fu[PuzzleDifficulty]](fuccess(PuzzleDifficulty.default))(_.dmap(_.difficulty))
+
+ def setDifficulty(user: User, difficulty: PuzzleDifficulty): Funit =
+ sessions.getIfPresent(user.id).fold(fuccess(PuzzleTheme.any.key))(_.dmap(_.path.theme)) flatMap { theme =>
+ createSessionFor(user, theme, difficulty).tap { sessions.put(user.id, _) }.void
+ }
+
private val sessions = cacheApi.notLoading[User.ID, PuzzleSession](32768, "puzzle.session")(
_.expireAfterWrite(1 hour).buildAsync()
)
@@ -147,12 +158,17 @@ final class PuzzleSessionApi(
): Fu[PuzzleSession] =
sessions.getFuture(user.id, _ => createSessionFor(user, theme)) flatMap { current =>
if (current.path.theme == theme) fuccess(current)
- else createSessionFor(user, theme) tap { sessions.put(user.id, _) }
+ else createSessionFor(user, theme, current.difficulty) tap { sessions.put(user.id, _) }
}
- private def createSessionFor(user: User, theme: PuzzleTheme.Key): Fu[PuzzleSession] =
+ private def createSessionFor(
+ user: User,
+ theme: PuzzleTheme.Key,
+ difficulty: PuzzleDifficulty = PuzzleDifficulty.default
+ ): Fu[PuzzleSession] =
pathApi
- .nextFor(user, theme, PuzzleTier.Top, Set.empty)
+ .nextFor(user, theme, PuzzleTier.Top, difficulty, Set.empty)
.orFail(s"No puzzle path found for ${user.id}, theme: $theme")
- .dmap(pathId => PuzzleSession(pathId, 0))
+ .dmap(pathId => PuzzleSession(difficulty, pathId, 0))
+
}
diff --git a/translation/source/puzzle.xml b/translation/source/puzzle.xml
index c8f0aa2099..fe98d03bc2 100644
--- a/translation/source/puzzle.xml
+++ b/translation/source/puzzle.xml
@@ -31,4 +31,10 @@
From game %s
Continue training
+ Difficulty level
+ Normal
+ Easier
+ Easiest
+ Harder
+ Hardest
diff --git a/ui/puzzle/css/_side.scss b/ui/puzzle/css/_side.scss
index 959abefd85..9b4f585602 100644
--- a/ui/puzzle/css/_side.scss
+++ b/ui/puzzle/css/_side.scss
@@ -73,7 +73,7 @@
background: $c-bg-box;
padding: 2vmin;
- &__setting {
+ &__jump {
@extend %flex-center-nowrap;
.switch {
@@ -84,6 +84,16 @@
cursor: pointer;
}
}
+
+ &__difficulty {
+ margin-top: 2vmin;
+ label {
+ margin-right: 1em;
+ }
+ select {
+ border: none;
+ }
+ }
}
&__theme {
diff --git a/ui/puzzle/src/ctrl.ts b/ui/puzzle/src/ctrl.ts
index 472d5e3b38..6e92daac25 100644
--- a/ui/puzzle/src/ctrl.ts
+++ b/ui/puzzle/src/ctrl.ts
@@ -454,6 +454,7 @@ export default function(opts: PuzzleOpts, redraw: Redraw): Controller {
voteTheme,
getCeval,
pref: opts.pref,
+ difficulty: opts.difficulty,
trans: lichess.trans(opts.i18n),
autoNext,
outcome,
diff --git a/ui/puzzle/src/interfaces.ts b/ui/puzzle/src/interfaces.ts
index 26b458fcc5..1cac7c954d 100644
--- a/ui/puzzle/src/interfaces.ts
+++ b/ui/puzzle/src/interfaces.ts
@@ -30,6 +30,8 @@ export interface AllThemes {
static: Set;
}
+export type PuzzleDifficulty = 'easiest' | 'easier' | 'normal' | 'harder' | 'hardest';
+
export interface Controller extends KeyboardController {
nextNodeBest(): string | undefined;
disableThreatMode?: Prop;
@@ -53,6 +55,7 @@ export interface Controller extends KeyboardController {
vote(v: boolean): void;
voteTheme(theme: ThemeKey, v: boolean): void;
pref: PuzzlePrefs;
+ difficulty?: PuzzleDifficulty;
userMove(orig: Key, dest: Key): void;
promotion: any;
autoNext: StoredBooleanProp;
@@ -89,6 +92,7 @@ export interface PuzzleOpts {
pref: PuzzlePrefs;
data: PuzzleData;
i18n: { [key: string]: string | undefined };
+ difficulty?: PuzzleDifficulty;
themes?: {
dynamic: string;
static: string;
diff --git a/ui/puzzle/src/view/side.ts b/ui/puzzle/src/view/side.ts
index cbdb7c4ea1..371348a17c 100644
--- a/ui/puzzle/src/view/side.ts
+++ b/ui/puzzle/src/view/side.ts
@@ -1,5 +1,5 @@
-import { Controller, Puzzle, PuzzleGame, MaybeVNode } from '../interfaces';
-import { dataIcon } from '../util';
+import { Controller, Puzzle, PuzzleGame, MaybeVNode, PuzzleDifficulty } from '../interfaces';
+import { dataIcon, onInsert } from '../util';
import { h } from 'snabbdom';
import { numberFormat } from 'common/number';
import { VNode } from 'snabbdom/vnode';
@@ -62,11 +62,13 @@ export function userBox(ctrl: Controller): VNode {
]);
}
+const difficulties: PuzzleDifficulty[] = ['easiest', 'easier', 'normal', 'harder', 'hardest'];
+
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.puzzle__side__config__jump', [
h('div.switch', [
h(`input#${id}.cmn-toggle.cmn-toggle--subtle`, {
attrs: {
@@ -81,6 +83,27 @@ export function config(ctrl: Controller): MaybeVNode {
h('label', { attrs: { 'for': id } })
]),
h('label', { attrs: { 'for': id } }, 'Jump to next puzzle immediately')
- ])
+ ]),
+ ctrl.difficulty ? h('form.puzzle__side__config__difficulty', {
+ attrs: {
+ action: `/training/difficulty/${ctrl.getData().theme.key}`,
+ method: 'post'
+ }
+ }, [
+ h('label', {
+ attrs: { for: 'puzzle-difficulty' },
+ }, ctrl.trans.noarg('difficultyLevel')),
+ h('select#puzzle-difficulty.puzzle__difficulty__selector', {
+ attrs: { name: 'difficulty' },
+ hook: onInsert(elm => elm.addEventListener('change', () => (elm.parentNode as HTMLFormElement).submit()))
+ }, difficulties.map(diff =>
+ h('option', {
+ attrs: {
+ value: diff,
+ selected: diff == ctrl.difficulty
+ },
+ }, ctrl.trans.noarg(diff))
+ ))
+ ]) : null
]);
}