puzzle difficulty selector
parent
b9d3264474
commit
6d5a1498a5
|
@ -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 =>
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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")
|
||||
}
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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)
|
||||
}
|
|
@ -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))
|
||||
)
|
||||
}
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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))
|
||||
|
||||
}
|
||||
|
|
|
@ -31,4 +31,10 @@
|
|||
</plurals>
|
||||
<string name="fromGameLink">From game %s</string>
|
||||
<string name="continueTraining">Continue training</string>
|
||||
<string name="difficultyLevel">Difficulty level</string>
|
||||
<string name="normal">Normal</string>
|
||||
<string name="easier">Easier</string>
|
||||
<string name="easiest">Easiest</string>
|
||||
<string name="harder">Harder</string>
|
||||
<string name="hardest">Hardest</string>
|
||||
</resources>
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -30,6 +30,8 @@ export interface AllThemes {
|
|||
static: Set<ThemeKey>;
|
||||
}
|
||||
|
||||
export type PuzzleDifficulty = 'easiest' | 'easier' | 'normal' | 'harder' | 'hardest';
|
||||
|
||||
export interface Controller extends KeyboardController {
|
||||
nextNodeBest(): string | undefined;
|
||||
disableThreatMode?: Prop<boolean>;
|
||||
|
@ -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;
|
||||
|
|
|
@ -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
|
||||
]);
|
||||
}
|
||||
|
|
Loading…
Reference in New Issue