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