puzzle difficulty selector

pull/7680/head
Thibault Duplessis 2020-12-06 11:45:55 +01:00
parent b9d3264474
commit 6d5a1498a5
16 changed files with 191 additions and 56 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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