puzzle WIP

pull/7680/head
Thibault Duplessis 2020-11-28 19:37:57 +01:00
parent cd5f13ab02
commit f2b45b377d
23 changed files with 180 additions and 118 deletions

View File

@ -15,19 +15,17 @@ final class Puzzle(
apiC: => Api
) extends LilaController(env) {
private type ThemeOrAny = Option[PuzzleTheme.Key]
private def renderJson(puzzle: Puz, theme: ThemeOrAny, round: Option[PuzzleRound] = None)(implicit
private def renderJson(puzzle: Puz, theme: PuzzleTheme, round: Option[PuzzleRound] = None)(implicit
ctx: Context
): Fu[JsObject] =
env.puzzle.jsonView(puzzle = puzzle, theme = theme, user = ctx.me, round = round)
env.puzzle.jsonView(puzzle = puzzle, theme = theme.key, user = ctx.me, round = round)
private def renderShow(puzzle: Puz, theme: ThemeOrAny)(implicit ctx: Context) =
private def renderShow(puzzle: Puz, theme: PuzzleTheme)(implicit ctx: Context) =
ctx.me.?? { env.puzzle.api.round.find(_, puzzle) } flatMap {
renderShowWithRound(puzzle, theme, _)
}
private def renderShowWithRound(puzzle: Puz, theme: ThemeOrAny, round: Option[PuzzleRound])(implicit
private def renderShowWithRound(puzzle: Puz, theme: PuzzleTheme, round: Option[PuzzleRound])(implicit
ctx: Context
) =
ctx.me.?? { env.puzzle.api.round.find(_, puzzle) } flatMap { round =>
@ -45,8 +43,8 @@ final class Puzzle(
_.map(_.id) ?? env.puzzle.api.puzzle.find
}) { puzzle =>
negotiate(
html = renderShow(puzzle, none),
api = _ => renderJson(puzzle, none) map { Ok(_) }
html = renderShow(puzzle, PuzzleTheme.any),
api = _ => renderJson(puzzle, PuzzleTheme.any) map { Ok(_) }
) map NoCache
}
}
@ -55,19 +53,13 @@ final class Puzzle(
def home =
Open { implicit ctx =>
NoBot {
nextPuzzleForMe() flatMap {
renderShowWithRound(_, none, none)
val theme = PuzzleTheme.any
nextPuzzleForMe(theme.key) flatMap {
renderShowWithRound(_, theme, none)
}
}
}
def show(id: String) =
Open { implicit ctx =>
NoBot {
OptionFuResult(env.puzzle.api.puzzle find Puz.Id(id)) { renderShow(_, none) }
}
}
def load(id: String) =
Open { implicit ctx =>
NoBot {
@ -78,10 +70,10 @@ final class Puzzle(
}
}
private def nextPuzzleForMe(theme: Option[PuzzleTheme.Key] = None)(implicit ctx: Context): Fu[Puz] =
private def nextPuzzleForMe(theme: PuzzleTheme.Key)(implicit ctx: Context): Fu[Puz] =
ctx.me match {
case _ => env.puzzle.anon.getOneFor(theme) orFail "Couldn't find a puzzle for anon!"
// case Some(me) => env.puzzle.cursor.nextPuzzleFor(me)
case Some(me) => env.puzzle.cursor.nextPuzzleFor(me, theme)
case None => env.puzzle.anon.getOneFor(theme) orFail "Couldn't find a puzzle for anon!"
}
def complete(themeStr: String, id: String) =
@ -89,8 +81,8 @@ final class Puzzle(
NoBot {
implicit val req = ctx.body
OptionFuResult(env.puzzle.api.puzzle find Puz.Id(id)) { puzzle =>
val theme = PuzzleTheme.find(themeStr)
lila.mon.puzzle.round.attempt(ctx.isAuth, theme.fold("any")(_.key.value)).increment()
val theme = PuzzleTheme findOrAny themeStr
lila.mon.puzzle.round.attempt(ctx.isAuth, theme.key.value).increment()
env.puzzle.forms.round
.bindFromRequest()
.fold(
@ -102,13 +94,14 @@ final class Puzzle(
isStudent <- env.clas.api.student.isStudent(me.id)
(round, perf) <- env.puzzle.finisher(
puzzle = puzzle,
theme = theme.key,
user = me,
result = Result(resultInt == 1),
isStudent = isStudent
)
_ = env.puzzle.cursor.onComplete(round, theme.map(_.key))
next <- nextPuzzleForMe(theme.map(_.key))
nextJson <- renderJson(next, none)
_ = env.puzzle.cursor.onComplete(round, theme.key)
next <- nextPuzzleForMe(theme.key)
nextJson <- renderJson(next, theme)
} yield Ok(
Json.obj(
"round" -> Json
@ -198,12 +191,16 @@ final class Puzzle(
}
}
def byTheme(theme: String) = Open { implicit ctx =>
PuzzleTheme.find(theme) match {
def show(themeOrId: String) = Open { implicit ctx =>
PuzzleTheme.find(themeOrId) match {
case None if themeOrId.size == 5 =>
NoBot {
OptionFuResult(env.puzzle.api.puzzle find Puz.Id(themeOrId)) { renderShow(_, PuzzleTheme.any) }
}
case None => Redirect(routes.Puzzle.home()).fuccess
case Some(theme) =>
nextPuzzleForMe(theme.key.some) flatMap {
renderShowWithRound(_, theme.key.some, none)
nextPuzzleForMe(theme.key) flatMap {
renderShowWithRound(_, theme, none)
}
}
}

View File

@ -36,6 +36,7 @@ object bits {
trans.wasThisPuzzleAnyGood,
trans.pleaseVotePuzzle,
trans.thankYou,
trans.puzzleId,
trans.ratingX,
trans.playedXTimes,
trans.continueTraining,

View File

@ -20,12 +20,17 @@ object theme {
h1("Puzzle themes"),
div(cls := "puzzle-themes")(
themes map { pt =>
a(cls := "puzzle-themes__link", href := routes.Puzzle.byTheme(pt.theme.key.value))(
strong(
pt.theme.name(),
em(pt.count.localize)
),
span(pt.theme.description())
val url =
if (pt.theme == PuzzleTheme.any) routes.Puzzle.home()
else routes.Puzzle.show(pt.theme.key.value)
a(cls := "puzzle-themes__link", href := url)(
span(
strong(
pt.theme.name(),
em(pt.count.localize)
),
span(pt.theme.description())
)
)
}
)

View File

@ -84,9 +84,7 @@ GET /training/export/gif/thumbnail/:id.gif controllers.Export.puzzleThumbnail(
GET /training/batch controllers.Puzzle.batchSelect
POST /training/batch controllers.Puzzle.batchSolve
GET /training/themes controllers.Puzzle.themes
GET /training/:theme controllers.Puzzle.byTheme(theme: String)
GET /training/$id<\w{5}> controllers.Puzzle.show(id: String)
GET /training/$id<\w{5}>/load controllers.Puzzle.load(id: String)
GET /training/:themeOrId controllers.Puzzle.show(themeOrId: String)
POST /training/$id<\w{5}>/vote controllers.Puzzle.vote(id: String)
POST /training/complete/:theme/$id<\w{5}> controllers.Puzzle.complete(theme: String, id: String)

View File

@ -351,6 +351,7 @@ val `yourPuzzleRatingX` = new I18nKey("yourPuzzleRatingX")
val `findTheBestMoveForWhite` = new I18nKey("findTheBestMoveForWhite")
val `findTheBestMoveForBlack` = new I18nKey("findTheBestMoveForBlack")
val `toTrackYourProgress` = new I18nKey("toTrackYourProgress")
val `puzzleId` = new I18nKey("puzzleId")
val `puzzleOfTheDay` = new I18nKey("puzzleOfTheDay")
val `clickToSolve` = new I18nKey("clickToSolve")
val `goodMove` = new I18nKey("goodMove")
@ -1867,6 +1868,8 @@ val `veryLong` = new I18nKey("puzzleTheme:veryLong")
val `veryLongDescription` = new I18nKey("puzzleTheme:veryLongDescription")
val `zugzwang` = new I18nKey("puzzleTheme:zugzwang")
val `zugzwangDescription` = new I18nKey("puzzleTheme:zugzwangDescription")
val `healthyMix` = new I18nKey("puzzleTheme:healthyMix")
val `healthyMixDescription` = new I18nKey("puzzleTheme:healthyMixDescription")
}
}

View File

@ -18,7 +18,7 @@ final class JsonView(
def apply(
puzzle: Puzzle,
theme: Option[PuzzleTheme.Key],
theme: PuzzleTheme.Key,
user: Option[User],
round: Option[PuzzleRound] = None
): Fu[JsObject] = {
@ -29,9 +29,9 @@ final class JsonView(
Json
.obj(
"game" -> gameJson,
"puzzle" -> puzzleJson(puzzle)
"puzzle" -> puzzleJson(puzzle),
"theme" -> theme
)
.add("theme" -> theme)
.add("user" -> user.map(userJson))
}
}

View File

@ -15,16 +15,16 @@ final class PuzzleAnon(colls: PuzzleColls, cacheApi: CacheApi, pathApi: PuzzlePa
import BsonHandlers._
def getOneFor(theme: Option[PuzzleTheme.Key]): Fu[Option[Puzzle]] =
def getOneFor(theme: PuzzleTheme.Key): Fu[Option[Puzzle]] =
pool get theme map ThreadLocalRandom.oneOf
private val poolSize = 50
private val pool =
cacheApi[Option[PuzzleTheme.Key], Vector[Puzzle]](initialCapacity = 32, name = "puzzle.byTheme.anon") {
cacheApi[PuzzleTheme.Key, Vector[Puzzle]](initialCapacity = 32, name = "puzzle.byTheme.anon") {
_.refreshAfterWrite(2 minutes)
.buildAsyncFuture { theme =>
theme.fold(fuccess(Int.MaxValue))(pathApi.countPuzzlesByTheme) flatMap { count =>
pathApi countPuzzlesByTheme theme flatMap { count =>
val tier =
if (count > 3000) PuzzlePath.tier.top
else PuzzlePath.tier.all
@ -34,7 +34,7 @@ final class PuzzleAnon(colls: PuzzleColls, cacheApi: CacheApi, pathApi: PuzzlePa
else 0 to 9999
val selector =
$doc(
"_id" $startsWith s"${theme | PuzzleTheme.anyKey}_${tier}_",
"_id" $startsWith s"${theme}_${tier}_",
"min" $gte ratingRange.min,
"max" $lte ratingRange.max
)

View File

@ -66,10 +66,7 @@ final private[puzzle] class PuzzleApi(
def sortedWithCount: Fu[List[PuzzleTheme.WithCount]] =
pathApi.countsByTheme map { counts =>
PuzzleTheme.sorted flatMap { pt =>
counts.getOrElse(pt.key, 0) match {
case 0 => Nil
case count => List(PuzzleTheme.WithCount(pt, count))
}
counts.get(pt.key) ?? { count => List(PuzzleTheme.WithCount(pt, count)) }
}
}
}

View File

@ -1,5 +1,6 @@
package lila.puzzle
import reactivemongo.api.bson.BSONRegex
import scala.concurrent.duration._
import scala.concurrent.ExecutionContext
import scala.util.chaining._
@ -10,7 +11,7 @@ import lila.rating.{ Perf, PerfType }
import lila.user.{ User, UserRepo }
private case class PuzzleCursor(
theme: Option[PuzzleTheme.Key],
theme: PuzzleTheme.Key,
path: Puzzle.PathId,
previousPaths: Set[Puzzle.PathId],
positionInPath: Int
@ -41,7 +42,7 @@ final class PuzzleCursorApi(colls: PuzzleColls, cacheApi: CacheApi, userRepo: Us
case class PuzzleFound(puzzle: Puzzle) extends NextPuzzleResult
}
def nextPuzzleFor(user: User, theme: Option[PuzzleTheme.Key], isRetry: Boolean = false): Fu[Puzzle] =
def nextPuzzleFor(user: User, theme: PuzzleTheme.Key, isRetry: Boolean = false): Fu[Puzzle] =
continueOrCreateCursorFor(user, theme) flatMap { cursor =>
import NextPuzzleResult._
nextPuzzleResult(user, cursor.pp) flatMap {
@ -118,7 +119,7 @@ final class PuzzleCursorApi(colls: PuzzleColls, cacheApi: CacheApi, userRepo: Us
}
}
def onComplete(round: PuzzleRound, theme: Option[PuzzleTheme.Key]): Unit =
def onComplete(round: PuzzleRound, theme: PuzzleTheme.Key): Unit =
cursors.getIfPresent(round.userId) foreach {
_.filter(_.theme == theme) foreach { cursor =>
// yes, even if the completed puzzle was not the current cursor puzzle
@ -131,38 +132,42 @@ final class PuzzleCursorApi(colls: PuzzleColls, cacheApi: CacheApi, userRepo: Us
_.expireAfterWrite(1 hour).buildAsync()
)
private[puzzle] def currentCursorOf(user: User, theme: Option[PuzzleTheme.Key]): Fu[PuzzleCursor] =
private[puzzle] def currentCursorOf(user: User, theme: PuzzleTheme.Key): Fu[PuzzleCursor] =
cursors.getFuture(user.id, _ => createCursorFor(user, theme))
private[puzzle] def continueOrCreateCursorFor(
user: User,
theme: Option[PuzzleTheme.Key]
theme: PuzzleTheme.Key
): Fu[PuzzleCursor] =
currentCursorOf(user, theme) flatMap { current =>
if (current.theme == theme) fuccess(current)
else createCursorFor(user, theme) tap { cursors.put(user.id, _) }
}
private def createCursorFor(user: User, theme: Option[PuzzleTheme.Key]): Fu[PuzzleCursor] =
private def createCursorFor(user: User, theme: PuzzleTheme.Key): Fu[PuzzleCursor] =
nextPathIdFor(user.id, theme, Set.empty)
.orFail(s"No puzzle path found for ${user.id}, theme: $theme")
.dmap(pathId => PuzzleCursor(theme, pathId, Set.empty, 0))
private def nextPathIdFor(
userId: User.ID,
theme: Option[PuzzleTheme.Key],
theme: PuzzleTheme.Key,
previousPaths: Set[PathId]
): Fu[Option[PathId]] =
userRepo.perfOf(userId, PerfType.Puzzle).dmap(_ | Perf.default) flatMap { perf =>
colls.path {
_.aggregateOne() { framework =>
import framework._
val tier = "top"
Match(
$doc(
"tier" -> "top",
"_id" ->
$doc(
"$regex" -> BSONRegex(s"^${theme}_$tier", ""),
$nin(previousPaths)
),
"min" $lte perf.glicko.rating,
"max" $gt perf.glicko.rating,
"_id" $nin previousPaths
"max" $gt perf.glicko.rating
)
) -> List(
Project($id(true)),

View File

@ -3,6 +3,7 @@ package lila.puzzle
import org.goochjs.glicko2.{ Rating, RatingCalculator, RatingPeriodResults }
import org.joda.time.DateTime
import scala.util.chaining._
import cats.implicits._
import lila.common.Bus
import lila.db.AsyncColl
@ -20,7 +21,13 @@ final private[puzzle] class PuzzleFinisher(
import BsonHandlers._
def apply(puzzle: Puzzle, user: User, result: Result, isStudent: Boolean): Fu[(PuzzleRound, Perf)] =
def apply(
puzzle: Puzzle,
theme: PuzzleTheme.Key,
user: User,
result: Result,
isStudent: Boolean
): Fu[(PuzzleRound, Perf)] =
api.round.find(user, puzzle) flatMap { prevRound =>
val now = DateTime.now
val formerUserRating = user.perfs.puzzle.intRating
@ -44,13 +51,14 @@ final private[puzzle] class PuzzleFinisher(
updateRatings(userRating, puzzleRating, result.glicko)
val newPuzzleGlicko = user.perfs.puzzle.established
.option {
Glicko(
val g = Glicko(
rating = puzzleRating.getRating
.atMost(puzzle.glicko.rating + Glicko.maxRatingDelta)
.atLeast(puzzle.glicko.rating - Glicko.maxRatingDelta),
deviation = puzzleRating.getRatingDeviation,
volatility = puzzleRating.getVolatility
)
if (theme == PuzzleTheme.any.key) g else g.average(puzzle.glicko)
}
.filter(_.sanityCheck)
val round = PuzzleRound(
@ -61,7 +69,10 @@ final private[puzzle] class PuzzleFinisher(
weight = none
)
val userPerf =
user.perfs.puzzle.addOrReset(_.puzzle.crazyGlicko, s"puzzle ${puzzle.id}")(userRating, now)
user.perfs.puzzle.addOrReset(_.puzzle.crazyGlicko, s"puzzle ${puzzle.id}")(userRating, now) pipe {
p =>
if (theme == PuzzleTheme.any.key) p else p.averageGlicko(user.perfs.puzzle)
}
(round, newPuzzleGlicko, userPerf)
}
api.round.upsert(round) zip

View File

@ -1,7 +1,7 @@
package lila.puzzle
import scala.concurrent.ExecutionContext
import scala.concurrent.duration._
import scala.concurrent.ExecutionContext
import lila.db.dsl._
import lila.memo.CacheApi
@ -43,6 +43,10 @@ final private class PuzzlePathApi(
count <- obj int "count"
} yield PuzzleTheme.Key(key) -> count
}.toMap
}.flatMap { themed =>
colls.puzzle(_.countAll) map { all =>
themed + (PuzzleTheme.any.key -> all.toInt)
}
}
}
}

View File

@ -11,9 +11,10 @@ object PuzzleTheme {
case class WithCount(theme: PuzzleTheme, count: Int)
val anyKey = Key("any")
val any = PuzzleTheme(Key("any"), i.healthyMix, i.healthyMixDescription)
val sorted: List[PuzzleTheme] = List(
any,
PuzzleTheme(Key("advancedPawn"), i.advancedPawn, i.advancedPawnDescription),
PuzzleTheme(Key("attackingF2F7"), i.attackingF2F7, i.attackingF2F7Description),
PuzzleTheme(Key("attraction"), i.attraction, i.attractionDescription),
@ -56,5 +57,7 @@ object PuzzleTheme {
def find(key: String) = byKey get Key(key)
def findOrAny(key: String) = find(key) | any
implicit val keyIso = lila.common.Iso.string[Key](Key.apply, _.value)
}

View File

@ -68,4 +68,6 @@
<string name="veryLongDescription">Four moves or more to win.</string>
<string name="zugzwang">Zugzwang</string>
<string name="zugzwangDescription">The opponent is limited in the moves they can make, and all moves worsen their position.</string>
<string name="healthyMix">Healthy mix</string>
<string name="healthyMixDescription">A bit of everything. You don't know what you expect, so you remain ready for everything!</string>
</resources>

View File

@ -460,6 +460,7 @@ computer analysis, game chat and shareable URL.</string>
<string name="findTheBestMoveForWhite">Find the best move for white.</string>
<string name="findTheBestMoveForBlack">Find the best move for black.</string>
<string name="toTrackYourProgress">To track your progress:</string>
<string name="puzzleId">Puzzle %s</string>
<string name="puzzleOfTheDay">Puzzle of the day</string>
<string name="clickToSolve">Click to solve</string>
<string name="goodMove">Good move</string>

View File

@ -60,19 +60,19 @@
&__side {
display: grid;
grid-template-areas: 'metas' 'user';
grid-template-areas: 'metas' 'user' 'config';
grid-gap: $block-gap;
@include breakpoint($mq-x-small) {
grid-template-columns: 1fr 1fr;
grid-template-areas: 'metas user';
grid-template-columns: 1fr 1fr 1fr;
grid-template-areas: 'metas user' 'config';
}
@include breakpoint($mq-x-large) {
grid-template-columns: 1fr;
grid-template-rows: min-content;
grid-template-areas: 'metas' 'user';
grid-template-rows: min-content min-content;
grid-template-areas: 'metas' 'user' 'config';
justify-self: end;
min-width: 250px;
max-width: 350px;
@ -86,5 +86,9 @@
&__user {
grid-area: user;
}
&__config {
grid-area: config;
}
}
}

View File

@ -1,12 +1,29 @@
.puzzle-themes {
&__link {
@extend %box-padding-horiz;
display: block;
@extend %flex-center-nowrap, %box-padding-horiz;
padding-top: 2em;
padding-bottom: 2em;
&::before {
@extend %data-icon;
content: '-';
color: $c-font-dimmer;
flex: 0 0 2em;
margin-right: 1vw;
text-align: center;
font-size: 4.5em;
}
&:hover {
background: mix($c-bg-box, $c-link, 90%);
&::before {
color: $c-primary;
}
}
> span {
flex: 1 1 100%;
margin: 0;
}
strong {

View File

@ -5,16 +5,16 @@
align-self: start;
background: $c-bg-box;
.spinner {
margin: 4rem auto;
}
p {
margin: 0;
}
padding: 2vmin;
.hidden {
color: $c-font-dimmer;
}
.infos {
@extend %flex-center;
@ -29,10 +29,6 @@
padding-bottom: 2vh;
border-bottom: $border;
margin-bottom: 2vh;
.hidden {
opacity: 0.7;
}
}
.players {
@ -70,17 +66,22 @@
.rp.down {
color: $c-bad;
}
}
canvas {
width: 100%;
&__config {
@extend %box-neat;
align-self: start;
background: $c-bg-box;
padding: 2vmin;
@if $theme == "transp" {
opacity: .5;
&__setting {
display: flex;
.switch {
margin-right: 1em;
}
label {
cursor: pointer;
}
}
}
}
#jqstooltip {
box-sizing: content-box;
}

View File

@ -24,6 +24,7 @@ export default function(opts: PuzzleOpts, redraw: Redraw): Controller {
let vm: Vm = {} as Vm;
let data: PuzzleData, tree: TreeWrapper, ceval: CevalCtrl, moveTest: MoveTestFn;
const autoNext = storedProp('puzzle.autoNext', false)
const ground = prop<CgApi | undefined>(undefined) as Prop<CgApi>;
const threatMode = prop(false);
@ -55,7 +56,6 @@ export default function(opts: PuzzleOpts, redraw: Redraw): Controller {
tree = treeBuild(pgnToTree(data.game.pgn));
const initialPath = treePath.fromNodeList(treeOps.mainlineNodeList(tree.root));
vm.mode = 'play';
vm.loading = false;
vm.round = undefined;
vm.justPlayed = undefined;
vm.resultSent = false;
@ -229,19 +229,20 @@ export default function(opts: PuzzleOpts, redraw: Redraw): Controller {
}
}
function sendResult(win: boolean): void {
function sendResult(win: boolean, andPause: boolean = false): void {
if (vm.resultSent) return;
vm.resultSent = true;
nbToVoteCall(Math.max(0, parseInt(nbToVoteCall()) - 1));
xhr.complete(data.puzzle.id, data.theme, win).then((res: PuzzleResult | undefined) => {
xhr.complete(data.puzzle.id, data.theme, win).then((res: PuzzleResult) => {
if (res?.next.user && data.user) {
data.user.rating = res.next.user.rating;
data.user.provisional = res.next.user.provisional;
vm.round = res.round;
vm.next = res.next;
}
if (win) speech.success();
redraw();
vm.next = res.next;
if (!andPause && autoNext()) nextPuzzle();
else redraw();
});
}
@ -371,8 +372,7 @@ export default function(opts: PuzzleOpts, redraw: Redraw): Controller {
}
function viewSolution(): void {
if (!vm.canViewSolution) return;
sendResult(false);
sendResult(false, true);
vm.mode = 'view';
mergeSolution(tree, vm.initialPath, data.puzzle.solution, vm.pov);
reorderChildren(vm.initialPath, true);
@ -453,6 +453,7 @@ export default function(opts: PuzzleOpts, redraw: Redraw): Controller {
getCeval,
pref: opts.pref,
trans: lichess.trans(opts.i18n),
autoNext,
outcome,
toggleCeval,
toggleThreatMode,

View File

@ -1,11 +1,12 @@
import { Outcome } from 'chessops/types';
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 { TreeWrapper } from 'tree';
import { VNode } from 'snabbdom/vnode';
import { Api as CgApi } from 'chessground/api';
import { Config as CgConfig } from 'chessground/config';
import { Role, Move } from 'chessops/types';
import {StoredBooleanProp} from 'common/storage';
export type MaybeVNode = VNode | string | null | undefined;
export type MaybeVNodes = MaybeVNode[];
@ -50,6 +51,7 @@ export interface Controller extends KeyboardController {
pref: PuzzlePrefs;
userMove(orig: Key, dest: Key): void;
promotion: any;
autoNext: StoredBooleanProp;
path?: Tree.Path;
autoScrollRequested?: boolean;
@ -62,7 +64,6 @@ export interface Vm {
mainline: Tree.Node[];
pov: Color;
mode: 'play' | 'view' | 'try';
loading: boolean;
round?: PuzzleRound;
next?: PuzzleData;
justPlayed?: Key;
@ -100,7 +101,7 @@ export interface PuzzlePrefs {
export interface PuzzleData {
puzzle: Puzzle;
theme?: ThemeKey;
theme: ThemeKey;
game: PuzzleGame;
user: PuzzleUser | undefined;
}

View File

@ -1,6 +1,4 @@
import { h } from 'snabbdom';
import { Hooks } from 'snabbdom/hooks';
import { VNode } from 'snabbdom/vnode';
export function bindMobileMousedown(el: HTMLElement, f: (e: Event) => any, redraw?: () => void): void {
for (const mousedownEvent of ['touchstart', 'mousedown']) {
@ -33,11 +31,3 @@ export function dataIcon(icon: string) {
'data-icon': icon
};
}
export function spinner(): VNode {
return h('div.spinner', [
h('svg', { attrs: { viewBox: '0 0 40 40' } }, [
h('circle', {
attrs: { cx: 20, cy: 20, r: 18, fill: 'none' }
})])]);
}

View File

@ -1,7 +1,7 @@
import { h } from 'snabbdom'
import { VNode } from 'snabbdom/vnode';
import afterView from './after';
import { bind, spinner } from '../util';
import { bind } from '../util';
import { Controller, MaybeVNode } from '../interfaces';
function viewSolution(ctrl: Controller): VNode {
@ -66,12 +66,7 @@ function fail(ctrl: Controller): VNode {
]);
}
function loading(): VNode {
return h('div.puzzle__feedback.loading', spinner());
}
export default function(ctrl: Controller): MaybeVNode {
if (ctrl.vm.loading) return loading();
if (ctrl.vm.mode === 'view') return afterView(ctrl);
if (ctrl.vm.lastFeedback === 'init') return initial(ctrl);
if (ctrl.vm.lastFeedback === 'good') return good(ctrl);

View File

@ -87,7 +87,8 @@ export default function(ctrl: Controller): VNode {
}, [
h('aside.puzzle__side', [
side.puzzleBox(ctrl),
side.userBox(ctrl)
side.userBox(ctrl),
side.config(ctrl)
]),
h('div.puzzle__board.main-board' + (ctrl.pref.blindfold ? '.blindfold' : ''), {
hook: 'ontouchstart' in window ? undefined : bind('wheel', e => wheel(ctrl, e as WheelEvent))

View File

@ -16,6 +16,9 @@ function puzzleInfos(ctrl: Controller, puzzle: Puzzle): VNode {
return h('div.infos.puzzle', {
attrs: dataIcon('-')
}, [h('div', [
h('p', ctrl.trans.vdom('puzzleId', h('a', {
attrs: { href: `/training/${puzzle.id}` }
}, '#' + puzzle.id))),
h('p', ctrl.trans.vdom('ratingX', ctrl.vm.mode === 'play' ? h('span.hidden', ctrl.trans.noarg('hidden')) : h('strong', puzzle.rating))),
h('p', ctrl.trans.vdom('playedXTimes', h('strong', numberFormat(puzzle.plays))))
])]);
@ -25,7 +28,7 @@ function gameInfos(ctrl: Controller, game: PuzzleGame, puzzle: Puzzle): VNode {
return h('div.infos', {
attrs: dataIcon(game.perf.icon)
}, [h('div', [
h('p', ctrl.trans.vdom('fromGameLink', h('a', {
h('p', ctrl.trans.vdom('fromGameLink', ctrl.vm.mode == 'play' ? h('span.hidden', ctrl.trans.noarg('hidden')) : h('a', {
attrs: { href: `/${game.id}/${ctrl.vm.pov}#${puzzle.initialPly}` }
}, '#' + game.id))),
h('p', [
@ -55,3 +58,25 @@ export function userBox(ctrl: Controller): MaybeVNode {
])))
]);
}
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.switch', [
h(`input#${id}.cmn-toggle.cmn-toggle--subtle`, {
attrs: {
type: 'checkbox',
checked: ctrl.autoNext()
},
hook: {
insert: vnode => (vnode.elm as HTMLElement).addEventListener('change', () =>
ctrl.autoNext(!ctrl.autoNext()))
}
}),
h('label', { attrs: { 'for': id } })
]),
h('label', { attrs: { 'for': id } }, 'Jump to next puzzle immediately')
])
]);
}