more puzzle WIP

pull/7680/head
Thibault Duplessis 2020-12-05 10:37:13 +01:00
parent a86be49e10
commit 11c0bdfd51
16 changed files with 196 additions and 96 deletions

View File

@ -126,7 +126,7 @@ final class Puzzle(
def voteTheme(id: String, themeStr: String) =
AuthBody { implicit ctx => me =>
NoBot {
PuzzleTheme.find(themeStr) ?? { theme =>
PuzzleTheme.findDynamic(themeStr) ?? { theme =>
implicit val req = ctx.body
env.puzzle.forms.themeVote
.bindFromRequest()
@ -208,7 +208,10 @@ final class Puzzle(
def showWithTheme(themeKey: String, id: String) = Open { implicit ctx =>
NoBot {
val theme = PuzzleTheme.findOrAny(themeKey)
OptionFuResult(env.puzzle.api.puzzle find Puz.Id(id)) { renderShow(_, theme) }
OptionFuResult(env.puzzle.api.puzzle find Puz.Id(id)) { puzzle =>
if (puzzle.themes contains theme.key) renderShow(puzzle, theme)
else Redirect(routes.Puzzle.show(puzzle.id.value)).fuccess
}
}
}

View File

@ -2,10 +2,11 @@ package views
package html.puzzle
import play.api.i18n.Lang
import play.api.libs.json.{ JsObject, Json }
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
@ -18,11 +19,17 @@ object bits {
def jsI18n(implicit lang: Lang) = i18nJsObject(i18nKeys)
lazy val jsonThemes = JsObject(
PuzzleTheme.categorized.map { case (name, themes) =>
name.key -> Json.arr(themes.map(_.name.key))
lazy val jsonThemes = PuzzleTheme.all
.collect {
case t if t != PuzzleTheme.any => t.key
}
)
.partition(PuzzleTheme.staticThemes.contains) match {
case (static, dynamic) =>
Json.obj(
"dynamic" -> dynamic.map(_.value).sorted.mkString(" "),
"static" -> static.map(_.value).mkString(" ")
)
}
private val i18nKeys: List[MessageKey] = {
List(

View File

@ -18,12 +18,13 @@ object show {
moreJs = frag(
jsModule("puzzle"),
embedJsUnsafeLoadThen(s"""LichessPuzzle(${safeJsonValue(
Json.obj(
"data" -> data,
"pref" -> pref,
"i18n" -> bits.jsI18n,
"themes" -> bits.jsonThemes
)
Json
.obj(
"data" -> data,
"pref" -> pref,
"i18n" -> bits.jsI18n
)
.add("themes" -> ctx.isAuth.option(bits.jsonThemes))
)})""")
),
csp = defaultCsp.withWebAssembly.some,

View File

@ -2,7 +2,7 @@
* Only looks for puzzles with the `dirty` flag, and removes it.
*
* mongo <IP>:<PORT>/<DB> mongodb-puzzle-denormalize-themes.js
*
*
* Must run on the puzzle database.
* Should run every 5 minutes.
* Should complete within 10 seconds.
@ -15,9 +15,24 @@ const playColl = db.puzzle2_puzzle;
const roundColl = db.puzzle2_round;
const staticThemes = new Set([
"oneMove", "short", "long", "veryLong",
"mateIn1", "mateIn2", "mateIn3", "mateIn4", "mateIn5",
"enPassant"
'bishopEndgame',
'enPassant',
'endgame',
'knightEndgame',
'long',
'mateIn1',
'mateIn2',
'mateIn3',
'mateIn4',
'mateIn5',
'middlegame',
'oneMove',
'opening',
'pawnEndgame',
'queenEndgame',
'rookEndgame',
'short',
'veryLong'
]);
playColl.find({ dirty: true }, { themes: true }).forEach(p => {
@ -36,7 +51,7 @@ playColl.find({ dirty: true }, { themes: true }).forEach(p => {
themeMap[theme] = x.v * signum + (themeMap[theme] || 0);
});
const newThemes = new Set();
const newThemes = new Set(oldThemes.filter(t => staticThemes.has(t)));
Object.keys(themeMap).forEach(theme => {
if (themeMap[theme] > 0) newThemes.add(theme);
});

View File

@ -23,7 +23,7 @@ const tiers = [
const acceptableVoteSelect = {
vote: {
$gt: -150
$gt: -9150
}
};

View File

@ -17,7 +17,7 @@ object JsDump {
private type JsTrans = Iterable[(String, JsString)]
private def removeDbPrefix(key: MessageKey): String = {
def removeDbPrefix(key: MessageKey): String = {
val index = key.indexOf(':')
if (index > 0) key.drop(index + 1) else key
}

View File

@ -72,11 +72,11 @@ final private[puzzle] class PuzzleApi(
def vote(user: User, id: Puzzle.Id, theme: PuzzleTheme.Key, vote: Option[Boolean]): Funit =
round.find(user, id).thenPp flatMap {
_ ?? { round =>
round.themeVote(theme, vote.pp("vote")).pp ?? { newThemes =>
round.themeVote(theme, vote) ?? { newThemes =>
import PuzzleRound.{ BSONFields => F }
val update =
if (newThemes.isEmpty) $unset(F.themes, F.puzzle)
else $set(F.themes -> newThemes, F.puzzle -> id)
else $set(F.themes -> newThemes, F.puzzle -> id, F.weight -> 1)
colls.round(_.update.one($id(round.id), update)) zip
colls.puzzle(_.updateField($id(round.id.puzzleId), Puzzle.BSONFields.dirty, true)) void
}

View File

@ -54,7 +54,7 @@ object PuzzleRound {
val vote = "v"
val themes = "t"
val puzzle = "p" // only if themes is set!
val weight = "w"
val weight = "e"
val user = "u" // student denormalization
}
}

View File

@ -25,6 +25,8 @@ private case class PuzzleSession(
positionInPath = 0
)
def next = copy(positionInPath = positionInPath + 1)
override def toString = s"$path:$positionInPath"
}
final class PuzzleSessionApi(colls: PuzzleColls, cacheApi: CacheApi, userRepo: UserRepo)(implicit
@ -49,15 +51,15 @@ final class PuzzleSessionApi(colls: PuzzleColls, cacheApi: CacheApi, userRepo: U
continueOrCreateSessionFor(user, theme) flatMap { session =>
import NextPuzzleResult._
def switchPath(tier: PuzzleTier) =
nextPathIdFor(user.id, theme, tier, session.previousPaths) flatMap {
case None => fufail(s"No remaining puzzle path for ${user.id}")
case Some(pathId) =>
val newSession = session.switchTo(tier, pathId)
sessions.put(user.id, fuccess(newSession))
nextPuzzleFor(user, theme, retries = retries + 1)
nextPathIdFor(user, theme, tier, session.previousPaths) orElse {
session.previousPaths.nonEmpty ?? nextPathIdFor(user, theme, tier, Set.empty)
} orFail s"No puzzle path for ${user.id}" flatMap { pathId =>
val newSession = session.switchTo(tier, pathId)
sessions.put(user.id, fuccess(newSession))
nextPuzzleFor(user, theme, retries = retries + 1)
}
nextPuzzleResult(user, session).thenPp(session.path.value) flatMap {
nextPuzzleResult(user, session).thenPp(session.toString) flatMap {
case PathMissing | PathEnded if retries < 10 => switchPath(session.tier)
case PathMissing | PathEnded => fufail(s"Puzzle path missing or ended for ${user.id}")
case PuzzleMissing(id) =>
@ -111,7 +113,6 @@ final class PuzzleSessionApi(colls: PuzzleColls, cacheApi: CacheApi, userRepo: U
)
}.map { docOpt =>
import NextPuzzleResult._
// println(docOpt map lila.db.BSON.debug)
docOpt.fold[NextPuzzleResult](PathMissing) { doc =>
doc.getAsOpt[Puzzle.Id]("puzzleId").fold[NextPuzzleResult](PathEnded) { puzzleId =>
doc
@ -153,35 +154,33 @@ final class PuzzleSessionApi(colls: PuzzleColls, cacheApi: CacheApi, userRepo: U
}
private def createSessionFor(user: User, theme: PuzzleTheme.Key): Fu[PuzzleSession] =
nextPathIdFor(user.id, theme, PuzzleTier.Top, Set.empty)
nextPathIdFor(user, theme, PuzzleTier.Top, Set.empty)
.orFail(s"No puzzle path found for ${user.id}, theme: $theme")
.dmap(pathId => PuzzleSession(theme, PuzzleTier.Top, pathId, 0))
private def nextPathIdFor(
userId: User.ID,
user: User,
theme: PuzzleTheme.Key,
tier: PuzzleTier,
previousPaths: Set[PathId]
): Fu[Option[PathId]] =
userRepo.perfOf(userId, PerfType.Puzzle).dmap(_ | Perf.default) flatMap { perf =>
colls.path {
_.aggregateOne() { framework =>
import framework._
Match(
$doc(
"_id" ->
$doc(
"$regex" -> BSONRegex(s"^${theme}_${tier}", ""),
$nin(previousPaths)
),
"min" $lte perf.glicko.rating,
"max" $gt perf.glicko.rating
)
) -> List(
Project($id(true)),
Sample(1)
colls.path {
_.aggregateOne() { framework =>
import framework._
Match(
$doc(
"_id" ->
$doc(
"$regex" -> BSONRegex(s"^${theme}_${tier}", ""),
$nin(previousPaths)
),
"min" $lte user.perfs.puzzle.glicko.rating,
"max" $gt user.perfs.puzzle.glicko.rating
)
}.dmap(_.flatMap(_.getAsOpt[PathId]("_id")))
}
) -> List(
Project($id(true)),
Sample(1)
)
}.dmap(_.flatMap(_.getAsOpt[PathId]("_id")))
}
}

View File

@ -131,9 +131,33 @@ object PuzzleTheme {
t.key -> t
}.toMap
// themes that can't be voted by players
val staticThemes: Set[Key] = Set(
bishopEndgame,
enPassant,
endgame,
knightEndgame,
long,
mateIn1,
mateIn2,
mateIn3,
mateIn4,
mateIn5,
middlegame,
oneMove,
opening,
pawnEndgame,
queenEndgame,
rookEndgame,
short,
veryLong
).map(_.key)
def find(key: String) = byKey get Key(key)
def findOrAny(key: String) = find(key) | any
def findDynamic(key: String) = find(key).filterNot(t => staticThemes(t.key))
implicit val keyIso = lila.common.Iso.string[Key](Key.apply, _.value)
}

View File

@ -16,16 +16,16 @@ function selector(data: StudyPracticeData) {
h('option', {
attrs: { disabled: true, selected: true }
}, 'Practice list'),
...data.structure.map(function(section) {
return h('optgroup', {
...data.structure.map(section =>
h('optgroup', {
attrs: { label: section.name }
}, section.studies.map(function(study) {
return option(
}, section.studies.map(study =>
option(
section.id + '/' + study.slug + '/' + study.id,
'',
study.name);
}));
})
study.name)
))
)
]);
}

View File

@ -28,6 +28,7 @@
&__session {
grid-area: session;
align-self: start;
}
.eval-gauge {
@ -53,8 +54,11 @@
@include breakpoint($mq-col3) {
grid-template-areas: 'side . board gauge tools' '. . session . controls';
grid-template-areas:
'side . board gauge tools'
'side . session . controls';
grid-template-columns: $col3-uniboard-side $block-gap $col3-uniboard-width var(--gauge-gap) $col3-uniboard-table;
grid-template-rows: $col3-uniboard-width;
}
@ -65,13 +69,14 @@
@include breakpoint($mq-xx-small) {
grid-template-columns: 1fr 1fr;
grid-template-areas: 'metas user' 'metas theme' 'config theme';
grid-template-areas: 'metas user' 'metas theme' 'config theme' '. theme';
grid-template-rows: min-content min-content min-content;
}
@include breakpoint($mq-x-large) {
grid-template-columns: 1fr;
grid-template-rows: min-content min-content min-content;
grid-template-areas: 'metas' 'user' 'theme' 'config';
grid-template-rows: min-content min-content min-content;
justify-self: end;
min-width: 250px;
max-width: 350px;

View File

@ -122,12 +122,16 @@
&:hover {
background: mix($c-bg-box, $c-link, 90%);
}
&.strike a {
text-decoration: line-through;
}
}
}
&__votes {
@extend %flex-center-nowrap;
flex: 0 1 5em;
flex: 0 1 7em;
align-items: stretch;
text-align: center;
@ -154,7 +158,7 @@
@extend %data-icon;
content: 'h';
font-size: 1.2em;
font-size: 1.3em;
}
&.vote-down::before {
@ -172,4 +176,8 @@
background: $c-bad;
}
}
&__selector {
margin-top: 1em;
}
}

View File

@ -16,7 +16,7 @@ import { makeSanAndPlay } from 'chessops/san';
import { parseFen, makeFen } from 'chessops/fen';
import { parseSquare, parseUci, makeSquare, makeUci } from 'chessops/util';
import { pgnToTree, mergeSolution } from './moveTree';
import { Redraw, Vm, Controller, PuzzleOpts, PuzzleData, PuzzleResult, MoveTest } from './interfaces';
import { Redraw, Vm, Controller, PuzzleOpts, PuzzleData, PuzzleResult, MoveTest, ThemeKey } from './interfaces';
import { Role, Move, Outcome } from 'chessops/types';
import { storedProp } from 'common/storage';
import PuzzleSession from './session';
@ -389,18 +389,22 @@ export default function(opts: PuzzleOpts, redraw: Redraw): Controller {
startCeval();
}
const vote = throttle(1000, v => {
const vote = throttle(1000, (v: boolean) => {
xhr.vote(data.puzzle.id, v);
nextPuzzle();
});
const voteTheme = throttle(500, (theme, v) => {
const voteTheme = (theme: ThemeKey, v: boolean) => {
if (vm.round) {
vm.round.themes = vm.round.themes || {};
vm.round.themes[theme] = v;
if (v || data.puzzle.themes.includes(theme))
vm.round.themes[theme] = v;
else
delete vm.round.themes[theme];
xhr.voteTheme(data.puzzle.id, theme, v);
redraw();
}
});
}
initiate(opts.data);
@ -473,6 +477,10 @@ export default function(opts: PuzzleOpts, redraw: Redraw): Controller {
redraw,
ongoing: false,
playBestMove,
session
session,
allThemes: opts.themes && {
dynamic: opts.themes.dynamic.split(' '),
static: new Set(opts.themes.static.split(' '))
}
};
}

View File

@ -1,13 +1,13 @@
import PuzzleSession from './session';
import { Api as CgApi } from 'chessground/api';
import { CevalCtrl, NodeEvals } from 'ceval';
import { Config as CgConfig } from 'chessground/config';
import { Outcome } from 'chessops/types';
import { Prop } from 'common';
import { Role, Move } from 'chessops/types';
import { StoredBooleanProp } from 'common/storage';
import { TreeWrapper } from 'tree';
import { VNode } from 'snabbdom/vnode';
import { StoredBooleanProp } from 'common/storage';
import PuzzleSession from './session';
export type MaybeVNode = VNode | string | null | undefined;
export type MaybeVNodes = MaybeVNode[];
@ -25,6 +25,10 @@ export interface KeyboardController {
}
export type ThemeKey = string;
export interface AllThemes {
dynamic: ThemeKey[];
static: Set<ThemeKey>;
}
export interface Controller extends KeyboardController {
nextNodeBest(): string | undefined;
@ -53,6 +57,7 @@ export interface Controller extends KeyboardController {
promotion: any;
autoNext: StoredBooleanProp;
session: PuzzleSession;
allThemes?: AllThemes;
path?: Tree.Path;
autoScrollRequested?: boolean;
@ -84,6 +89,10 @@ export interface PuzzleOpts {
pref: PuzzlePrefs;
data: PuzzleData;
i18n: { [key: string]: string | undefined };
themes?: {
dynamic: string;
static: string;
}
}
export interface PuzzlePrefs {

View File

@ -1,20 +1,7 @@
import { bind } from '../util';
import { Controller } from '../interfaces';
import { h } from 'snabbdom';
import { VNode } from 'snabbdom/vnode';
import { bind } from '../util';
const staticThemes = new Set([
"enPassant",
"long",
"mateIn1",
"mateIn2",
"mateIn3",
"mateIn4",
"mateIn5",
"oneMove",
"short",
"veryLong"
]);
export default function theme(ctrl: Controller): VNode {
return h('div.puzzle__side__theme', [
@ -26,34 +13,68 @@ export default function theme(ctrl: Controller): VNode {
const editor = (ctrl: Controller): VNode => {
const data = ctrl.getData(),
user = data.user,
themes = ctrl.vm.round?.themes || {};
votedThemes = ctrl.vm.round?.themes || {};
const visibleThemes: string[] = data.puzzle.themes.concat(
Object.keys(votedThemes).filter(t => votedThemes[t] && !data.puzzle.themes.includes(t))
).sort()
return h('div.puzzle__themes', [
h('div.puzzle__themes_list', {
hook: bind('click', e => {
const target = e.target as HTMLElement;
const theme = target.getAttribute('data-theme');
if (theme) ctrl.voteTheme(theme, target.classList.contains('vote-up'));
}, ctrl.redraw)
}, data.puzzle.themes.map(key =>
h('div.puzzle__themes__list__entry', [
const vote = target.classList.contains('vote-up');
const votedThemes = ctrl.vm.round?.themes || {};
if (theme && votedThemes[theme] !== vote) ctrl.voteTheme(theme, vote);
})
}, visibleThemes.map(key =>
h('div.puzzle__themes__list__entry', {
class: {
strike: votedThemes[key] === false
}
}, [
h('a', {
attrs: {
href: `/training/${key}`,
title: ctrl.trans.noarg(`${key}Description`)
}
}, ctrl.trans.noarg(key)),
!user || staticThemes.has(key) ? null : h('div.puzzle__themes__votes', [
!ctrl.allThemes || ctrl.allThemes.static.has(key) ? null : h('div.puzzle__themes__votes', [
h('span.puzzle__themes__vote.vote-up', {
class: { active: themes[key] },
class: { active: votedThemes[key] },
attrs: { 'data-theme': key }
}),
h('span.puzzle__themes__vote.vote-down', {
class: { active: themes[key] === false },
class: { active: votedThemes[key] === false },
attrs: { 'data-theme': key }
})
])
])
))
)),
ctrl.allThemes ? h('select.puzzle__themes__selector', {
hook: {
...bind('change', e => {
const theme = (e.target as HTMLInputElement).value;
if (theme) ctrl.voteTheme(theme, true);
setTimeout(() => {
((e.target as HTMLInputElement).parentNode as HTMLSelectElement).value = '';
}, 500);
}),
postpatch(_, vnode) {
(vnode.elm as HTMLSelectElement).value = '';
}
}
}, [
h('option', {
attrs: { value: '', selected: true }
}, 'Add another theme'),
...ctrl.allThemes.dynamic.filter(t => !votedThemes[t]).map(theme =>
h('option', {
attrs: {
value: theme,
title: ctrl.trans.noarg(`${theme}Description`)
},
}, ctrl.trans.noarg(theme))
)
]) : null
]);
}