diff --git a/ui/common/src/snabbdom.ts b/ui/common/src/snabbdom.ts index ead7974af763e..746a199fac3d0 100644 --- a/ui/common/src/snabbdom.ts +++ b/ui/common/src/snabbdom.ts @@ -42,11 +42,11 @@ export type LooseVNodes = (MaybeVNode | boolean)[]; type LooseVNode = VNodeChildElement | boolean; type VNodeKids = LooseVNode | LooseVNode[]; -function filterKids(children: VNodeKids): VNodeChildElement[] { - return (Array.isArray(children) ? children : [children]).filter( - x => (x && x !== true) || x === '', // '' may be falsy but it's a valid VNode - ) as VNodeChildElement[]; -} +// '' may be falsy but it's a valid VNode +const kidFilter = (x: any): boolean => (x && x !== true) || x === ''; + +const filterKids = (children: VNodeKids): VNodeChildElement[] => + (Array.isArray(children) ? children : [children]).filter(kidFilter) as VNodeChildElement[]; /* obviate need for some ternary expressions in renders. Allows looseH('div', [ kids && h('div', 'kid') ]) diff --git a/ui/puzzle/src/autoShape.ts b/ui/puzzle/src/autoShape.ts index 8b018dc2977c8..5e98eca04c4c1 100644 --- a/ui/puzzle/src/autoShape.ts +++ b/ui/puzzle/src/autoShape.ts @@ -1,16 +1,16 @@ import { winningChances, CevalCtrl } from 'ceval'; import { DrawModifiers, DrawShape } from 'chessground/draw'; -import { Vm } from './interfaces'; import { Api as CgApi } from 'chessground/api'; import { opposite, parseUci, makeSquare } from 'chessops/util'; import { NormalMove } from 'chessops/types'; interface Opts { - vm: Vm; + node: Tree.Node; + showComputer(): boolean; ceval: CevalCtrl; ground: CgApi; - nextNodeBest?: Uci; - threatMode: boolean; + nextNodeBest(): Uci | undefined; + threatMode(): boolean; } function makeAutoShapesFromUci( @@ -30,16 +30,16 @@ function makeAutoShapesFromUci( } export default function (opts: Opts): DrawShape[] { - const n = opts.vm.node, + const n = opts.node, hovering = opts.ceval.hovering(), color = n.fen.includes(' w ') ? 'white' : 'black'; let shapes: DrawShape[] = []; if (hovering && hovering.fen === n.fen) shapes = shapes.concat(makeAutoShapesFromUci(color, hovering.uci, 'paleBlue')); - if (opts.vm.showAutoShapes() && opts.vm.showComputer()) { + if (opts.showComputer()) { if (n.eval) shapes = shapes.concat(makeAutoShapesFromUci(color, n.eval.best!, 'paleGreen')); if (!hovering) { - let nextBest: Uci | undefined = opts.nextNodeBest; + let nextBest: Uci | undefined = opts.nextNodeBest(); if (!nextBest && opts.ceval.enabled() && n.ceval) nextBest = n.ceval.pvs[0].moves[0]; if (nextBest) shapes = shapes.concat(makeAutoShapesFromUci(color, nextBest, 'paleBlue')); if ( @@ -47,7 +47,7 @@ export default function (opts: Opts): DrawShape[] { n.ceval && n.ceval.pvs && n.ceval.pvs[1] && - !(opts.threatMode && n.threat && n.threat.pvs[2]) + !(opts.threatMode() && n.threat && n.threat.pvs[2]) ) { n.ceval.pvs.forEach(function (pv) { if (pv.moves[0] === nextBest) return; @@ -62,7 +62,7 @@ export default function (opts: Opts): DrawShape[] { } } } - if (opts.ceval.enabled() && opts.threatMode && n.threat) { + if (opts.ceval.enabled() && opts.threatMode() && n.threat) { if (n.threat.pvs[1]) { shapes = shapes.concat(makeAutoShapesFromUci(opposite(color), n.threat.pvs[0].moves[0], 'paleRed')); n.threat.pvs.slice(1).forEach(function (pv) { diff --git a/ui/puzzle/src/control.ts b/ui/puzzle/src/control.ts index ce060c90f69ad..36578220460f2 100644 --- a/ui/puzzle/src/control.ts +++ b/ui/puzzle/src/control.ts @@ -1,26 +1,26 @@ import { path as treePath } from 'tree'; -import { KeyboardController } from './interfaces'; +import PuzzleCtrl from './ctrl'; -export function canGoForward(ctrl: KeyboardController): boolean { - return ctrl.vm.node.children.length > 0; +export function canGoForward(ctrl: PuzzleCtrl): boolean { + return ctrl.node.children.length > 0; } -export function next(ctrl: KeyboardController): void { - const child = ctrl.vm.node.children[0]; +export function next(ctrl: PuzzleCtrl): void { + const child = ctrl.node.children[0]; if (!child) return; - ctrl.userJump(ctrl.vm.path + child.id); + ctrl.userJump(ctrl.path + child.id); } -export function prev(ctrl: KeyboardController): void { - ctrl.userJump(treePath.init(ctrl.vm.path)); +export function prev(ctrl: PuzzleCtrl): void { + ctrl.userJump(treePath.init(ctrl.path)); } -export function last(ctrl: KeyboardController): void { - const toInit = !treePath.contains(ctrl.vm.path, ctrl.vm.initialPath); - ctrl.userJump(toInit ? ctrl.vm.initialPath : treePath.fromNodeList(ctrl.vm.mainline)); +export function last(ctrl: PuzzleCtrl): void { + const toInit = !treePath.contains(ctrl.path, ctrl.initialPath); + ctrl.userJump(toInit ? ctrl.initialPath : treePath.fromNodeList(ctrl.mainline)); } -export function first(ctrl: KeyboardController): void { - const toInit = ctrl.vm.path !== ctrl.vm.initialPath && treePath.contains(ctrl.vm.path, ctrl.vm.initialPath); - ctrl.userJump(toInit ? ctrl.vm.initialPath : treePath.root); +export function first(ctrl: PuzzleCtrl): void { + const toInit = ctrl.path !== ctrl.initialPath && treePath.contains(ctrl.path, ctrl.initialPath); + ctrl.userJump(toInit ? ctrl.initialPath : treePath.root); } diff --git a/ui/puzzle/src/ctrl.ts b/ui/puzzle/src/ctrl.ts index 32cd87f528f18..0c029d7a5b32a 100644 --- a/ui/puzzle/src/ctrl.ts +++ b/ui/puzzle/src/ctrl.ts @@ -6,7 +6,7 @@ import moveTest from './moveTest'; import PuzzleSession from './session'; import PuzzleStreak from './streak'; import throttle from 'common/throttle'; -import { Vm, Controller, PuzzleOpts, PuzzleData, MoveTest, ThemeKey, ReplayEnd } from './interfaces'; +import { PuzzleOpts, PuzzleData, MoveTest, ThemeKey, ReplayEnd, NvuiPlugin, PuzzleRound } from './interfaces'; import { Api as CgApi } from 'chessground/api'; import { build as treeBuild, ops as treeOps, path as treePath, TreeWrapper } from 'tree'; import { Chess, normalizeMove } from 'chessops/chess'; @@ -15,156 +15,204 @@ import { Config as CgConfig } from 'chessground/config'; import { CevalCtrl } from 'ceval'; import { makeVoiceMove, VoiceMove, RootCtrl as VoiceRoot } from 'voice'; import { ctrl as makeKeyboardMove, KeyboardMove, RootController as KeyboardRoot } from 'keyboardMove'; -import { defer } from 'common/defer'; -import { defined, prop, Prop, propWithEffect, toggle } from 'common'; +import { Deferred, defer } from 'common/defer'; +import { defined, prop, Prop, propWithEffect, Toggle, toggle } from 'common'; import { makeSanAndPlay } from 'chessops/san'; import { parseFen, makeFen } from 'chessops/fen'; import { parseSquare, parseUci, makeSquare, makeUci, opposite } from 'chessops/util'; import { pgnToTree, mergeSolution } from './moveTree'; import { PromotionCtrl } from 'chess/promotion'; import { Role, Move, Outcome } from 'chessops/types'; -import { storedBooleanProp } from 'common/storage'; +import { StoredProp, storedBooleanProp, storedBooleanPropWithEffect } from 'common/storage'; import { fromNodeList } from 'tree/dist/path'; import { last } from 'tree/dist/ops'; import { uciToMove } from 'chessground/util'; import { Redraw } from 'common/snabbdom'; +import { ParentCtrl } from 'ceval/src/types'; + +export default class PuzzleCtrl implements ParentCtrl { + data: PuzzleData; + next: Deferred = defer(); + trans: Trans; + tree: TreeWrapper; + ceval: CevalCtrl; + autoNext: StoredProp; + rated: StoredProp; + ground: Prop = prop(undefined) as Prop; + threatMode: Toggle = toggle(false); + streak?: PuzzleStreak; + streakFailStorage = lichess.storage.make('puzzle.streak.fail'); + session: PuzzleSession; + menu: Toggle; + flipped = toggle(false); + keyboardMove?: KeyboardMove; + voiceMove?: VoiceMove; + promotion: PromotionCtrl; + keyboardHelp: Prop; + cgConfig?: CgConfig; + path: Tree.Path; + node: Tree.Node; + nodeList: Tree.Node[]; + mainline: Tree.Node[]; + initialPath: Tree.Path; + initialNode: Tree.Node; + pov: Color; + mode: 'play' | 'view' | 'try'; + round?: PuzzleRound; + justPlayed?: Key; + resultSent: boolean; + lastFeedback: 'init' | 'fail' | 'win' | 'good' | 'retry'; + canViewSolution = toggle(false); + autoScrollRequested: boolean; + autoScrollNow: boolean; + voteDisabled?: boolean; + isDaily: boolean; + + constructor( + readonly opts: PuzzleOpts, + readonly redraw: Redraw, + readonly nvui?: NvuiPlugin, + ) { + this.trans = lichess.trans(opts.i18n); + this.rated = storedBooleanPropWithEffect('puzzle.rated', true, this.redraw); + this.autoNext = storedBooleanProp( + `puzzle.autoNext${opts.data.streak ? '.streak' : ''}`, + !!opts.data.streak, + ); + this.streak = opts.data.streak ? new PuzzleStreak(opts.data) : undefined; + if (this.streak) { + opts.data = { ...opts.data, ...this.streak.data.current }; + this.streakFailStorage.listen(_ => this.failStreak(this.streak!)); + } + this.session = new PuzzleSession(opts.data.angle.key, opts.data.user?.id, !!opts.data.streak); + this.menu = toggle(false, redraw); -export default function (opts: PuzzleOpts, redraw: Redraw): Controller { - const vm: Vm = { - next: defer(), - } as Vm; - let data: PuzzleData, tree: TreeWrapper, ceval: CevalCtrl; - const hasStreak = !!opts.data.streak; - const autoNext = storedBooleanProp(`puzzle.autoNext${hasStreak ? '.streak' : ''}`, hasStreak); - const rated = storedBooleanProp('puzzle.rated', true); - const ground = prop(undefined) as Prop; - const threatMode = prop(false); - const streak = opts.data.streak ? new PuzzleStreak(opts.data) : undefined; - const streakFailStorage = lichess.storage.make('puzzle.streak.fail'); - if (streak) { - opts.data = { - ...opts.data, - ...streak.data.current, - }; - streakFailStorage.listen(_ => failStreak(streak)); - } - const session = new PuzzleSession(opts.data.angle.key, opts.data.user?.id, hasStreak); + this.initiate(opts.data); + this.promotion = new PromotionCtrl( + this.withGround, + () => this.withGround(g => g.set(this.cgConfig!)), + redraw, + ); - const menu = toggle(false, redraw); + this.keyboardHelp = propWithEffect(location.hash === '#keyboard', this.redraw); + keyboard(this); - // required by ceval - vm.showComputer = () => vm.mode === 'view'; - vm.showAutoShapes = () => true; + // If the page loads while being hidden (like when changing settings), + // chessground is not displayed, and the first move is not fully applied. + // Make sure chessground is fully shown when the page goes back to being visible. + document.addEventListener('visibilitychange', () => + lichess.requestIdleCallback(() => this.jump(this.path), 500), + ); + + lichess.pubsub.on('zen', () => { + const zen = $('body').toggleClass('zen').hasClass('zen'); + window.dispatchEvent(new Event('resize')); + if (!$('body').hasClass('zen-auto')) xhr.setZen(zen); + }); + $('body').addClass('playing'); // for zen + $('#zentog').on('click', () => lichess.pubsub.emit('zen')); + } - const loadSound = (file: string, volume?: number) => { + private loadSound = (file: string, volume?: number) => { lichess.sound.load(file, `${lichess.sound.baseUrl}/${file}`); return () => lichess.sound.play(file, volume); }; - const sound = { - good: loadSound('lisp/PuzzleStormGood', 0.7), - end: loadSound('lisp/PuzzleStormEnd', 1), + sound = { + good: this.loadSound('lisp/PuzzleStormGood', 0.7), + end: this.loadSound('lisp/PuzzleStormEnd', 1), }; - let flipped = false; - - function setPath(path: Tree.Path): void { - vm.path = path; - vm.nodeList = tree.getNodeList(path); - vm.node = treeOps.last(vm.nodeList)!; - vm.mainline = treeOps.mainlineNodeList(tree.root); - } - - let keyboardMove: KeyboardMove | undefined; - let voiceMove: VoiceMove | undefined; + setPath = (path: Tree.Path): void => { + this.path = path; + this.nodeList = this.tree.getNodeList(path); + this.node = treeOps.last(this.nodeList)!; + this.mainline = treeOps.mainlineNodeList(this.tree.root); + }; - function setChessground(this: Controller, cg: CgApi): void { - ground(cg); + setChessground = (cg: CgApi): void => { + this.ground(cg); const makeRoot = () => ({ data: { game: { variant: { key: 'standard' } }, - player: { color: vm.pov }, + player: { color: this.pov }, }, chessground: cg, - sendMove: playUserMove, - auxMove: auxMove, + sendMove: this.playUserMove, + auxMove: this.auxMove, redraw: this.redraw, - flipNow: flip, - userJumpPlyDelta, - next: nextPuzzle, - vote, - solve: viewSolution, + flipNow: this.flip, + userJumpPlyDelta: this.userJumpPlyDelta, + next: this.nextPuzzle, + vote: this.vote, + solve: this.viewSolution, }); - if (opts.pref.voiceMove) - this.voiceMove = voiceMove = makeVoiceMove(makeRoot() as VoiceRoot, this.vm.node.fen); - if (opts.pref.keyboardMove) - this.keyboardMove = keyboardMove = makeKeyboardMove(makeRoot() as KeyboardRoot, { - fen: this.vm.node.fen, - }); + if (this.opts.pref.voiceMove) this.voiceMove = makeVoiceMove(makeRoot() as VoiceRoot, this.node.fen); + if (this.opts.pref.keyboardMove) + this.keyboardMove = makeKeyboardMove(makeRoot() as KeyboardRoot, { fen: this.node.fen }); requestAnimationFrame(() => this.redraw()); - } + }; + + pref = this.opts.pref; - function withGround(f: (cg: CgApi) => A): A | undefined { - const g = ground(); + withGround = (f: (cg: CgApi) => A): A | undefined => { + const g = this.ground(); return g && f(g); - } + }; - function initiate(fromData: PuzzleData): void { - data = fromData; - tree = treeBuild(pgnToTree(data.game.pgn.split(' '))); - const initialPath = treePath.fromNodeList(treeOps.mainlineNodeList(tree.root)); - vm.mode = 'play'; - vm.next = defer(); - vm.round = undefined; - vm.justPlayed = undefined; - vm.resultSent = false; - vm.lastFeedback = 'init'; - vm.initialPath = initialPath; - vm.initialNode = tree.nodeAtPath(initialPath); - vm.pov = vm.initialNode.ply % 2 == 1 ? 'black' : 'white'; - vm.isDaily = location.href.endsWith('/daily'); - - setPath(lichess.blindMode ? initialPath : treePath.init(initialPath)); + initiate = (fromData: PuzzleData): void => { + this.data = fromData; + this.tree = treeBuild(pgnToTree(this.data.game.pgn.split(' '))); + const initialPath = treePath.fromNodeList(treeOps.mainlineNodeList(this.tree.root)); + this.mode = 'play'; + this.next = defer(); + this.round = undefined; + this.justPlayed = undefined; + this.resultSent = false; + this.lastFeedback = 'init'; + this.initialPath = initialPath; + this.initialNode = this.tree.nodeAtPath(initialPath); + this.pov = this.initialNode.ply % 2 == 1 ? 'black' : 'white'; + this.isDaily = location.href.endsWith('/daily'); + + this.setPath(lichess.blindMode ? initialPath : treePath.init(initialPath)); setTimeout( () => { - jump(initialPath); - redraw(); + this.jump(initialPath); + this.redraw(); }, - opts.pref.animation.duration > 0 ? 500 : 0, + this.opts.pref.animation.duration > 0 ? 500 : 0, ); // just to delay button display - vm.canViewSolution = false; - if (!vm.canViewSolution) { - setTimeout( - () => { - vm.canViewSolution = true; - redraw(); - }, - rated() ? 4000 : 1000, - ); - } + setTimeout( + () => { + this.canViewSolution(true); + this.redraw(); + }, + this.rated() ? 4000 : 1000, + ); - withGround(g => { + this.withGround(g => { g.selectSquare(null); g.setAutoShapes([]); g.setShapes([]); - showGround(g); + this.showGround(g); }); - instanciateCeval(); - } + this.instanciateCeval(); + }; - function position(): Chess { - const setup = parseFen(vm.node.fen).unwrap(); + position = (): Chess => { + const setup = parseFen(this.node.fen).unwrap(); return Chess.fromSetup(setup).unwrap(); - } + }; - function makeCgOpts(): CgConfig { - const node = vm.node; + makeCgOpts = (): CgConfig => { + const node = this.node; const color: Color = node.ply % 2 === 0 ? 'white' : 'black'; - const dests = chessgroundDests(position()); - const nextNode = vm.node.children[0]; - const canMove = vm.mode === 'view' || (color === vm.pov && (!nextNode || nextNode.puzzle == 'fail')); + const dests = chessgroundDests(this.position()); + const nextNode = this.node.children[0]; + const canMove = this.mode === 'view' || (color === this.pov && (!nextNode || nextNode.puzzle == 'fail')); const movable = canMove ? { color: dests.size > 0 ? color : undefined, @@ -176,7 +224,7 @@ export default function (opts: PuzzleOpts, redraw: Redraw): Controller { }; const config = { fen: node.fen, - orientation: flipped ? opposite(vm.pov) : vm.pov, + orientation: this.flipped() ? opposite(this.pov) : this.pov, turnColor: color, movable: movable, premovable: { @@ -185,63 +233,56 @@ export default function (opts: PuzzleOpts, redraw: Redraw): Controller { check: !!node.check, lastMove: uciToMove(node.uci), }; - if (node.ply >= vm.initialNode.ply) { - if (vm.mode !== 'view' && color !== vm.pov && !nextNode) { - config.movable.color = vm.pov; + if (node.ply >= this.initialNode.ply) { + if (this.mode !== 'view' && color !== this.pov && !nextNode) { + config.movable.color = this.pov; config.premovable.enabled = true; } } - vm.cgConfig = config; + this.cgConfig = config; return config; - } + }; - function showGround(g: CgApi): void { - g.set(makeCgOpts()); - } + showGround = (g: CgApi): void => g.set(this.makeCgOpts()); - function auxMove(orig: Key, dest: Key, role?: Role) { - if (role) playUserMove(orig, dest, role); + auxMove = (orig: Key, dest: Key, role?: Role) => { + if (role) this.playUserMove(orig, dest, role); else - withGround(g => { + this.withGround(g => { g.move(orig, dest); g.state.movable.dests = undefined; g.state.turnColor = opposite(g.state.turnColor); }); - } + }; - function userMove(orig: Key, dest: Key): void { - vm.justPlayed = orig; - if (!promotion.start(orig, dest, { submit: playUserMove, show: voiceMove?.promotionHook() })) - playUserMove(orig, dest); - voiceMove?.update(vm.node.fen, true); - keyboardMove?.update({ fen: vm.node.fen }); - } + userMove = (orig: Key, dest: Key): void => { + this.justPlayed = orig; + if ( + !this.promotion.start(orig, dest, { submit: this.playUserMove, show: this.voiceMove?.promotionHook() }) + ) + this.playUserMove(orig, dest); + this.voiceMove?.update(this.node.fen, true); + this.keyboardMove?.update({ fen: this.node.fen }); + }; - function playUci(uci: Uci): void { - sendMove(parseUci(uci)!); - } + playUci = (uci: Uci): void => this.sendMove(parseUci(uci)!); - function playUciList(uciList: Uci[]): void { - uciList.forEach(playUci); - } + playUciList = (uciList: Uci[]): void => uciList.forEach(this.playUci); - function playUserMove(orig: Key, dest: Key, promotion?: Role): void { - sendMove({ + playUserMove = (orig: Key, dest: Key, promotion?: Role): void => + this.sendMove({ from: parseSquare(orig)!, to: parseSquare(dest)!, promotion, }); - } - function sendMove(move: Move): void { - sendMoveAt(vm.path, position(), move); - } + sendMove = (move: Move): void => this.sendMoveAt(this.path, this.position(), move); - function sendMoveAt(path: Tree.Path, pos: Chess, move: Move): void { + sendMoveAt = (path: Tree.Path, pos: Chess, move: Move): void => { move = normalizeMove(pos, move); const san = makeSanAndPlay(pos, move); const check = pos.isCheck() ? pos.board.kingOf(pos.turn) : undefined; - addNode( + this.addNode( { ply: 2 * (pos.fullmoves - 1) + (pos.turn == 'white' ? 0 : 1), fen: makeFen(pos.toSetup()), @@ -253,149 +294,149 @@ export default function (opts: PuzzleOpts, redraw: Redraw): Controller { }, path, ); - } + }; - function addNode(node: Tree.Node, path: Tree.Path): void { - const newPath = tree.addNode(node, path)!; - jump(newPath); - withGround(g => g.playPremove()); + addNode = (node: Tree.Node, path: Tree.Path): void => { + const newPath = this.tree.addNode(node, path)!; + this.jump(newPath); + this.withGround(g => g.playPremove()); - const progress = moveTest(vm, data.puzzle); + const progress = moveTest(this); if (progress === 'fail') lichess.sound.say('incorrect'); - if (progress) applyProgress(progress); - reorderChildren(path); - redraw(); - } + if (progress) this.applyProgress(progress); + this.reorderChildren(path); + this.redraw(); + }; - function reorderChildren(path: Tree.Path, recursive?: boolean): void { - const node = tree.nodeAtPath(path); + reorderChildren = (path: Tree.Path, recursive?: boolean): void => { + const node = this.tree.nodeAtPath(path); node.children.sort((c1, _) => { const p = c1.puzzle; if (p == 'fail') return 1; if (p == 'good' || p == 'win') return -1; return 0; }); - if (recursive) node.children.forEach(child => reorderChildren(path + child.id, true)); - } + if (recursive) node.children.forEach(child => this.reorderChildren(path + child.id, true)); + }; - function instantRevertUserMove(): void { - withGround(g => { + private instantRevertUserMove = (): void => { + this.withGround(g => { g.cancelPremove(); g.selectSquare(null); }); - jump(treePath.init(vm.path)); - redraw(); - } + this.jump(treePath.init(this.path)); + this.redraw(); + }; - function revertUserMove(): void { - if (lichess.blindMode) instantRevertUserMove(); - else setTimeout(instantRevertUserMove, 100); - } + revertUserMove = (): void => { + if (lichess.blindMode) this.instantRevertUserMove(); + else setTimeout(this.instantRevertUserMove, 100); + }; - function applyProgress(progress: undefined | 'fail' | 'win' | MoveTest): void { + applyProgress = (progress: undefined | 'fail' | 'win' | MoveTest): void => { if (progress === 'fail') { - vm.lastFeedback = 'fail'; - revertUserMove(); - if (vm.mode === 'play') { - if (streak) { - failStreak(streak); - streakFailStorage.fire(); + this.lastFeedback = 'fail'; + this.revertUserMove(); + if (this.mode === 'play') { + if (this.streak) { + this.failStreak(this.streak); + this.streakFailStorage.fire(); } else { - vm.canViewSolution = true; - vm.mode = 'try'; - sendResult(false); + this.canViewSolution(true); + this.mode = 'try'; + this.sendResult(false); } } } else if (progress == 'win') { - if (streak) sound.good(); - vm.lastFeedback = 'win'; - if (vm.mode != 'view') { - const sent = vm.mode == 'play' ? sendResult(true) : Promise.resolve(); - vm.mode = 'view'; - withGround(showGround); - sent.then(_ => (autoNext() ? nextPuzzle() : startCeval())); + if (this.streak) this.sound.good(); + this.lastFeedback = 'win'; + if (this.mode != 'view') { + const sent = this.mode == 'play' ? this.sendResult(true) : Promise.resolve(); + this.mode = 'view'; + this.withGround(this.showGround); + sent.then(_ => (this.autoNext() ? this.nextPuzzle() : this.startCeval())); } } else if (progress) { - vm.lastFeedback = 'good'; + this.lastFeedback = 'good'; setTimeout( () => { const pos = Chess.fromSetup(parseFen(progress.fen).unwrap()).unwrap(); - sendMoveAt(progress.path, pos, progress.move); + this.sendMoveAt(progress.path, pos, progress.move); }, - opts.pref.animation.duration * (autoNext() ? 1 : 1.5), + this.opts.pref.animation.duration * (this.autoNext() ? 1 : 1.5), ); } - } + }; - function failStreak(streak: PuzzleStreak): void { - vm.mode = 'view'; + failStreak = (streak: PuzzleStreak): void => { + this.mode = 'view'; streak.onComplete(false); - setTimeout(viewSolution, 500); - sound.end(); - } + setTimeout(this.viewSolution, 500); + this.sound.end(); + }; - async function sendResult(win: boolean): Promise { - if (vm.resultSent) return Promise.resolve(); - vm.resultSent = true; - session.complete(data.puzzle.id, win); + sendResult = async (win: boolean): Promise => { + if (this.resultSent) return Promise.resolve(); + this.resultSent = true; + this.session.complete(this.data.puzzle.id, win); const res = await xhr.complete( - data.puzzle.id, - data.angle.key, + this.data.puzzle.id, + this.data.angle.key, win, - rated, - data.replay, - streak, - opts.settings.color, + this.rated, + this.data.replay, + this.streak, + this.opts.settings.color, ); const next = res.next; - if (next?.user && data.user) { - data.user.rating = next.user.rating; - data.user.provisional = next.user.provisional; - vm.round = res.round; - if (res.round?.ratingDiff) session.setRatingDiff(data.puzzle.id, res.round.ratingDiff); + if (next?.user && this.data.user) { + this.data.user.rating = next.user.rating; + this.data.user.provisional = next.user.provisional; + this.round = res.round; + if (res.round?.ratingDiff) this.session.setRatingDiff(this.data.puzzle.id, res.round.ratingDiff); } if (win) lichess.sound.say('Success!'); if (next) { - vm.next.resolve(data.replay && res.replayComplete ? data.replay : next); - if (streak && win) streak.onComplete(true, res.next); + this.next.resolve(this.data.replay && res.replayComplete ? this.data.replay : next); + if (this.streak && win) this.streak.onComplete(true, res.next); } - redraw(); + this.redraw(); if (!next) { - if (!data.replay) { + if (!this.data.replay) { alert('No more puzzles available! Try another theme.'); lichess.redirect('/training/themes'); } } - } + }; - const isPuzzleData = (d: PuzzleData | ReplayEnd): d is PuzzleData => 'puzzle' in d; + private isPuzzleData = (d: PuzzleData | ReplayEnd): d is PuzzleData => 'puzzle' in d; - function nextPuzzle(): void { - if (streak && vm.lastFeedback != 'win') return; - if (vm.mode !== 'view') return; + nextPuzzle = (): void => { + if (this.streak && this.lastFeedback != 'win') return; + if (this.mode !== 'view') return; - ceval.stop(); - vm.next.promise.then(n => { - if (isPuzzleData(n)) { - initiate(n); - redraw(); + this.ceval.stop(); + this.next.promise.then(n => { + if (this.isPuzzleData(n)) { + this.initiate(n); + this.redraw(); } }); - if (data.replay && vm.round === undefined) { - lichess.redirect(`/training/dashboard/${data.replay.days}`); + if (this.data.replay && this.round === undefined) { + lichess.redirect(`/training/dashboard/${this.data.replay.days}`); } - if (!streak && !data.replay) { - const path = router.withLang(`/training/${data.angle.key}`); + if (!this.streak && !this.data.replay) { + const path = router.withLang(`/training/${this.data.angle.key}`); if (location.pathname != path) history.replaceState(null, '', path); } - } + }; - function instanciateCeval(): void { - if (ceval) ceval.destroy(); - ceval = new CevalCtrl({ - redraw, + instanciateCeval = (): void => { + this.ceval?.destroy(); + this.ceval = new CevalCtrl({ + redraw: this.redraw, variant: { short: 'Std', name: 'Standard', @@ -403,285 +444,184 @@ export default function (opts: PuzzleOpts, redraw: Redraw): Controller { }, initialFen: undefined, // always standard starting position possible: true, - emit: function (ev, work) { - tree.updateAt(work.path, function (node) { + emit: (ev, work) => { + this.tree.updateAt(work.path, node => { if (work.threatMode) { const threat = ev as Tree.LocalEval; if (!node.threat || node.threat.depth <= threat.depth) node.threat = threat; } else if (!node.ceval || node.ceval.depth <= ev.depth) node.ceval = ev; - if (work.path === vm.path) { - setAutoShapes(); - redraw(); + if (work.path === this.path) { + this.setAutoShapes(); + this.redraw(); } }); }, - setAutoShapes: setAutoShapes, + setAutoShapes: this.setAutoShapes, }); - } + }; - function setAutoShapes(): void { - withGround(g => { + setAutoShapes = (): void => + this.withGround(g => g.setAutoShapes( computeAutoShapes({ - vm: vm, - ceval: ceval, + ...this, ground: g, - threatMode: threatMode(), - nextNodeBest: nextNodeBest(), + node: this.node, }), - ); - }); - } + ), + ); - function canUseCeval(): boolean { - return vm.mode === 'view' && !outcome(); - } + canUseCeval = (): boolean => this.mode === 'view' && !this.outcome(); - function startCeval(): void { - if (ceval.enabled() && canUseCeval()) doStartCeval(); - } - - const doStartCeval = throttle(800, function () { - ceval.start(vm.path, vm.nodeList, threatMode()); - }); + startCeval = (): void => { + if (this.ceval.enabled() && this.canUseCeval()) this.doStartCeval(); + }; - const nextNodeBest = () => treeOps.withMainlineChild(vm.node, n => n.eval?.best); + private doStartCeval = throttle(800, () => this.ceval.start(this.path, this.nodeList, this.threatMode())); - const getCeval = () => ceval; + nextNodeBest = () => treeOps.withMainlineChild(this.node, n => n.eval?.best); - function toggleCeval(): void { - ceval.toggle(); - setAutoShapes(); - startCeval(); - if (!ceval.enabled()) threatMode(false); - vm.autoScrollRequested = true; - redraw(); - } + toggleCeval = (): void => { + this.ceval.toggle(); + this.setAutoShapes(); + this.startCeval(); + if (!this.ceval.enabled()) this.threatMode(false); + this.autoScrollRequested = true; + this.redraw(); + }; - function restartCeval(): void { - ceval.stop(); - startCeval(); - redraw(); - } + restartCeval = (): void => { + this.ceval.stop(); + this.startCeval(); + this.redraw(); + }; - function toggleThreatMode(): void { - if (vm.node.check) return; - if (!ceval.enabled()) ceval.toggle(); - if (!ceval.enabled()) return; - threatMode(!threatMode()); - setAutoShapes(); - startCeval(); - redraw(); - } + toggleThreatMode = (): void => { + if (this.node.check) return; + if (!this.ceval.enabled()) this.ceval.toggle(); + if (!this.ceval.enabled()) return; + this.threatMode.toggle(); + this.setAutoShapes(); + this.startCeval(); + this.redraw(); + }; - function outcome(): Outcome | undefined { - return position().outcome(); - } + outcome = (): Outcome | undefined => this.position().outcome(); - function jump(path: Tree.Path): void { - const pathChanged = path !== vm.path, - isForwardStep = pathChanged && path.length === vm.path.length + 2; - setPath(path); - withGround(showGround); + jump = (path: Tree.Path): void => { + const pathChanged = path !== this.path, + isForwardStep = pathChanged && path.length === this.path.length + 2; + this.setPath(path); + this.withGround(this.showGround); if (pathChanged) { if (isForwardStep) { - lichess.sound.saySan(vm.node.san); - lichess.sound.move(vm.node); + lichess.sound.saySan(this.node.san); + lichess.sound.move(this.node); } - threatMode(false); - ceval.stop(); - startCeval(); + this.threatMode(false); + this.ceval.stop(); + this.startCeval(); } - promotion.cancel(); - vm.justPlayed = undefined; - vm.autoScrollRequested = true; - keyboardMove?.update({ fen: vm.node.fen }); - voiceMove?.update(vm.node.fen, true); - lichess.pubsub.emit('ply', vm.node.ply); - } + this.promotion.cancel(); + this.justPlayed = undefined; + this.autoScrollRequested = true; + this.keyboardMove?.update({ fen: this.node.fen }); + this.voiceMove?.update(this.node.fen, true); + lichess.pubsub.emit('ply', this.node.ply); + }; - function userJump(path: Tree.Path): void { - if (tree.nodeAtPath(path)?.puzzle == 'fail' && vm.mode != 'view') return; - withGround(g => g.selectSquare(null)); - jump(path); - } + userJump = (path: Tree.Path): void => { + if (this.tree.nodeAtPath(path)?.puzzle == 'fail' && this.mode != 'view') return; + this.withGround(g => g.selectSquare(null)); + this.jump(path); + }; - function userJumpPlyDelta(plyDelta: Ply) { + userJumpPlyDelta = (plyDelta: Ply) => { // ensure we are jumping to a valid ply - let maxValidPly = vm.mainline.length - 1; - if (last(vm.mainline)?.puzzle == 'fail' && vm.mode != 'view') maxValidPly -= 1; - const newPly = Math.min(Math.max(vm.node.ply + plyDelta, 0), maxValidPly); - userJump(fromNodeList(vm.mainline.slice(0, newPly + 1))); - } + let maxValidPly = this.mainline.length - 1; + if (last(this.mainline)?.puzzle == 'fail' && this.mode != 'view') maxValidPly -= 1; + const newPly = Math.min(Math.max(this.node.ply + plyDelta, 0), maxValidPly); + this.userJump(fromNodeList(this.mainline.slice(0, newPly + 1))); + }; - function viewSolution(): void { - sendResult(false); - vm.mode = 'view'; - mergeSolution(tree, vm.initialPath, data.puzzle.solution, vm.pov); - reorderChildren(vm.initialPath, true); + viewSolution = (): void => { + this.sendResult(false); + this.mode = 'view'; + mergeSolution(this.tree, this.initialPath, this.data.puzzle.solution, this.pov); + this.reorderChildren(this.initialPath, true); // try to play the solution next move - const next = vm.node.children[0]; - if (next && next.puzzle === 'good') userJump(vm.path + next.id); + const next = this.node.children[0]; + if (next && next.puzzle === 'good') this.userJump(this.path + next.id); else { - const firstGoodPath = treeOps.takePathWhile(vm.mainline, node => node.puzzle != 'good'); - if (firstGoodPath) userJump(firstGoodPath + tree.nodeAtPath(firstGoodPath).children[0].id); + const firstGoodPath = treeOps.takePathWhile(this.mainline, node => node.puzzle != 'good'); + if (firstGoodPath) this.userJump(firstGoodPath + this.tree.nodeAtPath(firstGoodPath).children[0].id); } - vm.autoScrollRequested = true; - vm.voteDisabled = true; - redraw(); - startCeval(); + this.autoScrollRequested = true; + this.voteDisabled = true; + this.redraw(); + this.startCeval(); setTimeout(() => { - vm.voteDisabled = false; - redraw(); + this.voteDisabled = false; + this.redraw(); }, 500); - } + }; - const skip = () => { - if (!streak || !streak.data.skip || vm.mode != 'play') return; - streak.skip(); - userJump(treePath.fromNodeList(vm.mainline)); - const moveIndex = treePath.size(vm.path) - treePath.size(vm.initialPath); - const solution = data.puzzle.solution[moveIndex]; - playUci(solution); - playBestMove(); + skip = () => { + if (!this.streak || !this.streak.data.skip || this.mode != 'play') return; + this.streak.skip(); + this.userJump(treePath.fromNodeList(this.mainline)); + const moveIndex = treePath.size(this.path) - treePath.size(this.initialPath); + const solution = this.data.puzzle.solution[moveIndex]; + this.playUci(solution); + this.playBestMove(); }; - const flip = () => { - flipped = !flipped; - withGround(g => g.toggleOrientation()); - redraw(); + flip = () => { + this.flipped.toggle(); + this.withGround(g => g.toggleOrientation()); + this.redraw(); }; - const vote = (v: boolean) => { - if (!vm.voteDisabled) { - xhr.vote(data.puzzle.id, v); - nextPuzzle(); + vote = (v: boolean) => { + if (!this.voteDisabled) { + xhr.vote(this.data.puzzle.id, v); + this.nextPuzzle(); } }; - const voteTheme = (theme: ThemeKey, v: boolean) => { - if (vm.round) { - vm.round.themes = vm.round.themes || {}; - if (v === vm.round.themes[theme]) { - delete vm.round.themes[theme]; - xhr.voteTheme(data.puzzle.id, theme, undefined); + voteTheme = (theme: ThemeKey, v: boolean) => { + if (this.round) { + this.round.themes = this.round.themes || {}; + if (v === this.round.themes[theme]) { + delete this.round.themes[theme]; + xhr.voteTheme(this.data.puzzle.id, theme, undefined); } else { - 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); + if (v || this.data.puzzle.themes.includes(theme)) this.round.themes[theme] = v; + else delete this.round.themes[theme]; + xhr.voteTheme(this.data.puzzle.id, theme, v); } - redraw(); + this.redraw(); } }; - initiate(opts.data); - - const promotion = new PromotionCtrl(withGround, () => withGround(g => g.set(vm.cgConfig)), redraw); - - function playBestMove(): void { - const uci = nextNodeBest() || (vm.node.ceval && vm.node.ceval.pvs[0].moves[0]); - if (uci) playUci(uci); - } - - const keyboardHelp = propWithEffect(location.hash === '#keyboard', redraw); - keyboard({ - vm, - userJump, - getCeval, - toggleCeval, - toggleThreatMode, - redraw, - playBestMove, - flip, - flipped: () => flipped, - nextPuzzle, - keyboardHelp, - }); - - // If the page loads while being hidden (like when changing settings), - // chessground is not displayed, and the first move is not fully applied. - // Make sure chessground is fully shown when the page goes back to being visible. - document.addEventListener('visibilitychange', () => lichess.requestIdleCallback(() => jump(vm.path), 500)); - - lichess.pubsub.on('zen', () => { - const zen = $('body').toggleClass('zen').hasClass('zen'); - window.dispatchEvent(new Event('resize')); - if (!$('body').hasClass('zen-auto')) { - xhr.setZen(zen); - } - }); - $('body').addClass('playing'); // for zen - $('#zentog').on('click', () => lichess.pubsub.emit('zen')); - - return { - vm, - getData() { - return data; - }, - getTree() { - return tree; - }, - setChessground, - ground, - makeCgOpts, - voiceMove, - keyboardMove, - keyboardHelp, - userJump, - viewSolution, - nextPuzzle, - vote, - voteTheme, - getCeval, - pref: opts.pref, - settings: opts.settings, - trans: lichess.trans(opts.i18n), - autoNext, - autoNexting: () => vm.lastFeedback == 'win' && autoNext(), - rated, - toggleRated: () => { - rated(!rated()); - redraw(); - }, - outcome, - toggleCeval, - toggleThreatMode, - threatMode, - currentEvals() { - return { client: vm.node.ceval }; - }, - nextNodeBest, - userMove, - playUci, - playUciList, - showEvalGauge() { - return vm.showComputer() && ceval.enabled() && !outcome(); - }, - getOrientation() { - return withGround(g => g.state.orientation)!; - }, - getNode() { - return vm.node; - }, - showComputer: vm.showComputer, - promotion, - redraw, - ongoing: false, - playBestMove, - session, - allThemes: opts.themes && { - dynamic: opts.themes.dynamic.split(' '), - static: new Set(opts.themes.static.split(' ')), - }, - streak, - skip, - flip, - flipped: () => flipped, - showRatings: opts.showRatings, - menu, - restartCeval: restartCeval, - clearCeval: restartCeval, + playBestMove = (): void => { + const uci = this.nextNodeBest() || (this.node.ceval && this.node.ceval.pvs[0].moves[0]); + if (uci) this.playUci(uci); + }; + autoNexting = () => this.lastFeedback == 'win' && this.autoNext(); + currentEvals = () => ({ client: this.node.ceval }); + showEvalGauge = () => this.showComputer() && this.ceval.enabled() && !this.outcome(); + getOrientation = () => this.withGround(g => g.state.orientation)!; + allThemes = this.opts.themes && { + dynamic: this.opts.themes.dynamic.split(' '), + static: new Set(this.opts.themes.static.split(' ')), }; + toggleRated = () => this.rated(!this.rated()); + // implement cetal ParentCtrl: + getCeval = () => this.ceval; + ongoing = false; + getNode = () => this.node; + showComputer = () => this.mode === 'view'; } diff --git a/ui/puzzle/src/interfaces.ts b/ui/puzzle/src/interfaces.ts index 325e43912e555..44d709e5a3656 100644 --- a/ui/puzzle/src/interfaces.ts +++ b/ui/puzzle/src/interfaces.ts @@ -1,123 +1,23 @@ -import PuzzleSession from './session'; -import { Api as CgApi } from 'chessground/api'; -import { CevalCtrl, NodeEvals } from 'ceval'; -import { Config as CgConfig } from 'chessground/config'; -import { Deferred } from 'common/defer'; -import { Outcome, Move } from 'chessops/types'; -import { Prop, Toggle } from 'common'; -import { StoredProp } from 'common/storage'; -import { TreeWrapper } from 'tree'; +import { Move } from 'chessops/types'; import { VNode } from 'snabbdom'; -import PuzzleStreak from './streak'; -import { PromotionCtrl } from 'chess/promotion'; -import { KeyboardMove } from 'keyboardMove'; -import { VoiceMove } from 'voice'; import * as Prefs from 'common/prefs'; import perfIcons from 'common/perfIcons'; -import { Redraw } from 'common/snabbdom'; +import PuzzleCtrl from './ctrl'; export type PuzzleId = string; -export interface KeyboardController { - vm: Vm; - redraw: Redraw; - userJump(path: Tree.Path): void; - getCeval(): CevalCtrl; - toggleCeval(): void; - toggleThreatMode(): void; - playBestMove(): void; - flip(): void; - flipped(): boolean; - nextPuzzle(): void; - keyboardHelp: Prop; -} - export type ThemeKey = string; export interface AllThemes { dynamic: ThemeKey[]; static: Set; } -export interface Controller extends KeyboardController { - nextNodeBest(): string | undefined; - disableThreatMode?: Prop; - outcome(): Outcome | undefined; - mandatoryCeval?: Prop; - showEvalGauge: Prop; - currentEvals(): NodeEvals; - ongoing: boolean; - playUci(uci: string): void; - playUciList(uciList: string[]): void; - getOrientation(): Color; - threatMode: Prop; - getNode(): Tree.Node; - showComputer(): boolean; - trans: Trans; - getData(): PuzzleData; - getTree(): TreeWrapper; - ground: Prop; - setChessground(cg: CgApi): void; - makeCgOpts(): CgConfig; - viewSolution(): void; - nextPuzzle(): void; - vote(v: boolean): void; - voteTheme(theme: ThemeKey, v: boolean): void; - pref: PuzzlePrefs; - settings: PuzzleSettings; - userMove(orig: Key, dest: Key): void; - promotion: PromotionCtrl; - autoNext: StoredProp; - autoNexting: () => boolean; - rated: StoredProp; - toggleRated: () => void; - session: PuzzleSession; - allThemes?: AllThemes; - showRatings: boolean; - keyboardMove?: KeyboardMove; - voiceMove?: VoiceMove; - - streak?: PuzzleStreak; - skip(): void; - - path?: Tree.Path; - autoScrollRequested?: boolean; - - nvui?: NvuiPlugin; - menu: Toggle; - restartCeval(): void; - clearCeval(): void; -} - export interface NvuiPlugin { - render(ctrl: Controller): VNode; + render(ctrl: PuzzleCtrl): VNode; } export type ReplayEnd = PuzzleReplay; -export interface Vm { - path: Tree.Path; - nodeList: Tree.Node[]; - node: Tree.Node; - mainline: Tree.Node[]; - pov: Color; - mode: 'play' | 'view' | 'try'; - round?: PuzzleRound; - next: Deferred; - justPlayed?: Key; - resultSent: boolean; - lastFeedback: 'init' | 'fail' | 'win' | 'good' | 'retry'; - initialPath: Tree.Path; - initialNode: Tree.Node; - canViewSolution: boolean; - autoScrollRequested: boolean; - autoScrollNow: boolean; - voteDisabled?: boolean; - cgConfig: CgConfig; - showComputer(): boolean; - showAutoShapes(): boolean; - isDaily: boolean; -} - export type PuzzleDifficulty = 'easiest' | 'easier' | 'normal' | 'harder' | 'hardest'; export interface PuzzleSettings { diff --git a/ui/puzzle/src/keyboard.ts b/ui/puzzle/src/keyboard.ts index 59ec666fd01da..6c9b8187e9274 100644 --- a/ui/puzzle/src/keyboard.ts +++ b/ui/puzzle/src/keyboard.ts @@ -1,8 +1,8 @@ import * as control from './control'; -import { Controller, KeyboardController } from './interfaces'; +import PuzzleCtrl from './ctrl'; import { snabDialog } from 'common/dialog'; -export default (ctrl: KeyboardController) => +export default (ctrl: PuzzleCtrl) => lichess.mousetrap .bind(['left', 'k'], () => { control.prev(ctrl); @@ -23,8 +23,8 @@ export default (ctrl: KeyboardController) => .bind('l', ctrl.toggleCeval) .bind('x', ctrl.toggleThreatMode) .bind('space', () => { - if (ctrl.vm.mode === 'view') { - if (ctrl.getCeval().enabled()) ctrl.playBestMove(); + if (ctrl.mode === 'view') { + if (ctrl.ceval.enabled()) ctrl.playBestMove(); else ctrl.toggleCeval(); } }) @@ -33,7 +33,7 @@ export default (ctrl: KeyboardController) => .bind('f', ctrl.flip) .bind('n', ctrl.nextPuzzle); -export const view = (ctrl: Controller) => +export const view = (ctrl: PuzzleCtrl) => snabDialog({ class: 'help', htmlUrl: '/training/help', diff --git a/ui/puzzle/src/main.ts b/ui/puzzle/src/main.ts index 11691331e8a7b..f439c2d7cdecd 100644 --- a/ui/puzzle/src/main.ts +++ b/ui/puzzle/src/main.ts @@ -1,5 +1,5 @@ import { attributesModule, classModule, init } from 'snabbdom'; -import makeCtrl from './ctrl'; +import PuzzleCtrl from './ctrl'; import menuHover from 'common/menuHover'; import view from './view/main'; import { PuzzleOpts, NvuiPlugin } from './interfaces'; @@ -8,8 +8,8 @@ const patch = init([classModule, attributesModule]); export async function initModule(opts: PuzzleOpts) { const element = document.querySelector('main.puzzle') as HTMLElement; - const ctrl = makeCtrl(opts, redraw); - ctrl.nvui = lichess.blindMode ? await lichess.asset.loadEsm('puzzle.nvui') : undefined; + const nvui = lichess.blindMode ? await lichess.asset.loadEsm('puzzle.nvui') : undefined; + const ctrl = new PuzzleCtrl(opts, redraw, nvui); const blueprint = view(ctrl); element.innerHTML = ''; diff --git a/ui/puzzle/src/moveTest.ts b/ui/puzzle/src/moveTest.ts index 0dee8f4ad1888..282a509832b7d 100644 --- a/ui/puzzle/src/moveTest.ts +++ b/ui/puzzle/src/moveTest.ts @@ -1,7 +1,8 @@ import { altCastles } from 'chess'; import { parseUci } from 'chessops/util'; import { path as pathOps } from 'tree'; -import { Vm, Puzzle, MoveTest } from './interfaces'; +import { MoveTest } from './interfaces'; +import PuzzleCtrl from './ctrl'; type MoveTestReturn = undefined | 'fail' | 'win' | MoveTest; @@ -11,36 +12,36 @@ function isAltCastle(str: string): str is AltCastle { return str in altCastles; } -export default function moveTest(vm: Vm, puzzle: Puzzle): MoveTestReturn { - if (vm.mode === 'view') return; - if (!pathOps.contains(vm.path, vm.initialPath)) return; +export default function moveTest(ctrl: PuzzleCtrl): MoveTestReturn { + if (ctrl.mode === 'view') return; + if (!pathOps.contains(ctrl.path, ctrl.initialPath)) return; - const playedByColor = vm.node.ply % 2 === 1 ? 'white' : 'black'; - if (playedByColor !== vm.pov) return; + const playedByColor = ctrl.node.ply % 2 === 1 ? 'white' : 'black'; + if (playedByColor !== ctrl.pov) return; - const nodes = vm.nodeList.slice(pathOps.size(vm.initialPath) + 1).map(node => ({ + const nodes = ctrl.nodeList.slice(pathOps.size(ctrl.initialPath) + 1).map(node => ({ uci: node.uci, castle: node.san!.startsWith('O-O'), checkmate: node.san!.endsWith('#'), })); for (const i in nodes) { - if (nodes[i].checkmate) return (vm.node.puzzle = 'win'); + if (nodes[i].checkmate) return (ctrl.node.puzzle = 'win'); const uci = nodes[i].uci!, - solUci = puzzle.solution[i]; + solUci = ctrl.data.puzzle.solution[i]; if (uci != solUci && (!nodes[i].castle || !isAltCastle(uci) || altCastles[uci] != solUci)) - return (vm.node.puzzle = 'fail'); + return (ctrl.node.puzzle = 'fail'); } - const nextUci = puzzle.solution[nodes.length]; - if (!nextUci) return (vm.node.puzzle = 'win'); + const nextUci = ctrl.data.puzzle.solution[nodes.length]; + if (!nextUci) return (ctrl.node.puzzle = 'win'); // from here we have a next move - vm.node.puzzle = 'good'; + ctrl.node.puzzle = 'good'; return { move: parseUci(nextUci)!, - fen: vm.node.fen, - path: vm.path, + fen: ctrl.node.fen, + path: ctrl.path, }; } diff --git a/ui/puzzle/src/plugins/nvui.ts b/ui/puzzle/src/plugins/nvui.ts index 805d1bd3e8931..2b74fa80afa62 100644 --- a/ui/puzzle/src/plugins/nvui.ts +++ b/ui/puzzle/src/plugins/nvui.ts @@ -1,5 +1,4 @@ import { h, VNode } from 'snabbdom'; -import { Controller } from '../interfaces'; import { puzzleBox, renderDifficultyForm, userBox } from '../view/side'; import theme from '../view/theme'; import { @@ -31,6 +30,7 @@ import * as control from '../control'; import { bind, onInsert } from 'common/snabbdom'; import { Api } from 'chessground/api'; import throttle from 'common/throttle'; +import PuzzleCtrl from '../ctrl'; const throttled = (sound: string) => throttle(100, () => lichess.sound.play(sound)); const selectSound = throttled('select'); @@ -45,7 +45,7 @@ export function initModule() { positionStyle = positionSetting(), boardStyle = boardSetting(); return { - render(ctrl: Controller): VNode { + render(ctrl: PuzzleCtrl): VNode { notify.redraw = ctrl.redraw; const ground = ctrl.ground() || @@ -58,11 +58,11 @@ export function initModule() { ctrl.ground(ground); return h( - `main.puzzle.puzzle--nvui.puzzle-${ctrl.getData().replay ? 'replay' : 'play'}${ + `main.puzzle.puzzle--nvui.puzzle-${ctrl.data.replay ? 'replay' : 'play'}${ ctrl.streak ? '.puzzle--streak' : '' }`, h('div.nvui', [ - h('h1', `Puzzle: ${ctrl.vm.pov} to play.`), + h('h1', `Puzzle: ${ctrl.pov} to play.`), h('h2', 'Puzzle info'), puzzleBox(ctrl), theme(ctrl), @@ -71,7 +71,7 @@ export function initModule() { h( 'p.moves', { attrs: { role: 'log', 'aria-live': 'off' } }, - renderMainline(ctrl.vm.mainline, ctrl.vm.path, moveStyle.get()), + renderMainline(ctrl.mainline, ctrl.path, moveStyle.get()), ), h('h2', 'Pieces'), h('div.pieces', renderPieces(ground.state.pieces, moveStyle.get())), @@ -102,7 +102,7 @@ export function initModule() { }, [ h('label', [ - ctrl.vm.mode === 'view' ? 'Command input' : `Find the best move for ${ctrl.vm.pov}.`, + ctrl.mode === 'view' ? 'Command input' : `Find the best move for ${ctrl.pov}.`, h('input.move.mousetrap', { attrs: { name: 'move', type: 'text', autocomplete: 'off', autofocus: true }, }), @@ -111,7 +111,7 @@ export function initModule() { ), notify.render(), h('h2', 'Actions'), - ctrl.vm.mode === 'view' ? afterActions(ctrl) : playActions(ctrl), + ctrl.mode === 'view' ? afterActions(ctrl) : playActions(ctrl), h('h2', 'Board'), h( 'div.board', @@ -119,15 +119,15 @@ export function initModule() { hook: onInsert(el => { const $board = $(el); const $buttons = $board.find('button'); - const steps = () => ctrl.getTree().getNodeList(ctrl.vm.path); + const steps = () => ctrl.tree.getNodeList(ctrl.path); const uciSteps = () => steps().filter(hasUci); const fenSteps = () => steps().map(step => step.fen); - const opponentColor = ctrl.vm.pov === 'white' ? 'black' : 'white'; + const opponentColor = ctrl.pov === 'white' ? 'black' : 'white'; $board.on( 'click', selectionHandler(() => opponentColor, selectSound), ); - $board.on('keydown', arrowKeyHandler(ctrl.vm.pov, borderSound)); + $board.on('keydown', arrowKeyHandler(ctrl.pov, borderSound)); $board.on('keypress', boardCommandsHandler()); $buttons.on( 'keypress', @@ -136,7 +136,7 @@ export function initModule() { $buttons.on( 'keypress', possibleMovesHandler( - ctrl.vm.pov, + ctrl.pov, () => ground.state.turnColor, ground.getFen, () => ground.state.pieces, @@ -151,7 +151,7 @@ export function initModule() { }, renderBoard( ground.state.pieces, - ctrl.vm.pov, + ctrl.pov, pieceStyle.get(), prefixStyle.get(), positionStyle.get(), @@ -166,7 +166,7 @@ export function initModule() { h('label', ['Piece prefix style', renderSetting(prefixStyle, ctrl.redraw)]), h('label', ['Show position', renderSetting(positionStyle, ctrl.redraw)]), h('label', ['Board layout', renderSetting(boardStyle, ctrl.redraw)]), - ...(!ctrl.getData().replay && !ctrl.streak + ...(!ctrl.data.replay && !ctrl.streak ? [h('h3', 'Puzzle Settings'), renderDifficultyForm(ctrl)] : []), h('h2', 'Keyboard shortcuts'), @@ -233,15 +233,15 @@ function hasUci(step: Tree.Node): step is StepWithUci { return step.uci !== undefined; } -function lastMove(ctrl: Controller, style: Style): string { - const node = ctrl.vm.node; +function lastMove(ctrl: PuzzleCtrl, style: Style): string { + const node = ctrl.node; if (node.ply === 0) return 'Initial position'; // make sure consecutive moves are different so that they get re-read return renderSan(node.san || '', node.uci, style) + (node.ply % 2 === 0 ? '' : ' '); } function onSubmit( - ctrl: Controller, + ctrl: PuzzleCtrl, notify: (txt: string) => void, style: () => Style, $input: Cash, @@ -252,10 +252,10 @@ function onSubmit( if (isShortCommand(input)) input = '/' + input; if (input[0] === '/') onCommand(ctrl, notify, input.slice(1), style()); else { - const uci = inputToLegalUci(input, ctrl.vm.node.fen, ground); + const uci = inputToLegalUci(input, ctrl.node.fen, ground); if (uci) { ctrl.playUci(uci); - switch (ctrl.vm.lastFeedback) { + switch (ctrl.lastFeedback) { case 'fail': notify(ctrl.trans.noarg('notTheMove')); break; @@ -274,12 +274,12 @@ function onSubmit( }; } -function isYourMove(ctrl: Controller) { - return ctrl.vm.node.children.length === 0 || ctrl.vm.node.children[0].puzzle === 'fail'; +function isYourMove(ctrl: PuzzleCtrl) { + return ctrl.node.children.length === 0 || ctrl.node.children[0].puzzle === 'fail'; } -function browseHint(ctrl: Controller): string[] { - if (ctrl.vm.mode !== 'view' && !isYourMove(ctrl)) return ['You browsed away from the latest position.']; +function browseHint(ctrl: PuzzleCtrl): string[] { + if (ctrl.mode !== 'view' && !isYourMove(ctrl)) return ['You browsed away from the latest position.']; else return []; } @@ -289,7 +289,7 @@ function isShortCommand(input: string): boolean { return shortCommands.includes(input.split(' ')[0].toLowerCase()); } -function onCommand(ctrl: Controller, notify: (txt: string) => void, c: string, style: Style): void { +function onCommand(ctrl: PuzzleCtrl, notify: (txt: string) => void, c: string, style: Style): void { const lowered = c.toLowerCase(); const pieces = ctrl.ground()!.state.pieces; if (lowered === 'l' || lowered === 'last') notify($('.lastMove').text()); @@ -302,9 +302,9 @@ function onCommand(ctrl: Controller, notify: (txt: string) => void, c: string, s ); } -function viewOrAdvanceSolution(ctrl: Controller, notify: (txt: string) => void): void { - if (ctrl.vm.mode === 'view') { - const node = ctrl.vm.node, +function viewOrAdvanceSolution(ctrl: PuzzleCtrl, notify: (txt: string) => void): void { + if (ctrl.mode === 'view') { + const node = ctrl.node, next = nextNode(node), nextNext = nextNode(next); if (isInSolution(next) || (isInSolution(node) && isInSolution(nextNext))) { @@ -329,26 +329,26 @@ function nextNode(node?: Tree.Node): Tree.Node | undefined { else return; } -function renderStreak(ctrl: Controller): VNode[] { +function renderStreak(ctrl: PuzzleCtrl): VNode[] { if (!ctrl.streak) return []; return [h('h2', 'Puzzle streak'), h('p', ctrl.streak.data.index || ctrl.trans.noarg('streakDescription'))]; } -function renderStatus(ctrl: Controller): string { - if (ctrl.vm.mode !== 'view') return 'Solving'; +function renderStatus(ctrl: PuzzleCtrl): string { + if (ctrl.mode !== 'view') return 'Solving'; else if (ctrl.streak) return `GAME OVER. Your streak: ${ctrl.streak.data.index}`; - else if (ctrl.vm.lastFeedback === 'win') return 'Puzzle solved!'; + else if (ctrl.lastFeedback === 'win') return 'Puzzle solved!'; else return 'Puzzle complete.'; } -function renderReplay(ctrl: Controller): string { - const replay = ctrl.getData().replay; +function renderReplay(ctrl: PuzzleCtrl): string { + const replay = ctrl.data.replay; if (!replay) return ''; - const i = replay.i + (ctrl.vm.mode === 'play' ? 0 : 1); - return `Replaying ${ctrl.trans.noarg(ctrl.getData().angle.key)} puzzles: ${i} of ${replay.of}`; + const i = replay.i + (ctrl.mode === 'play' ? 0 : 1); + return `Replaying ${ctrl.trans.noarg(ctrl.data.angle.key)} puzzles: ${i} of ${replay.of}`; } -function playActions(ctrl: Controller): VNode { +function playActions(ctrl: PuzzleCtrl): VNode { if (ctrl.streak) return button( ctrl.trans.noarg('skip'), @@ -359,8 +359,8 @@ function playActions(ctrl: Controller): VNode { else return h('div.actions_play', button('View the solution', ctrl.viewSolution)); } -function afterActions(ctrl: Controller): VNode { - const win = ctrl.vm.lastFeedback === 'win'; +function afterActions(ctrl: PuzzleCtrl): VNode { + const win = ctrl.lastFeedback === 'win'; return h( 'div.actions_after', ctrl.streak && !win @@ -369,17 +369,17 @@ function afterActions(ctrl: Controller): VNode { ); } -const renderVoteTutorial = (ctrl: Controller): VNode[] => - ctrl.session.isNew() && ctrl.getData().user?.provisional +const renderVoteTutorial = (ctrl: PuzzleCtrl): VNode[] => + ctrl.session.isNew() && ctrl.data.user?.provisional ? [h('p', ctrl.trans.noarg('didYouLikeThisPuzzle')), h('p', ctrl.trans.noarg('voteToLoadNextOne'))] : []; -function renderVote(ctrl: Controller): VNode[] { - if (!ctrl.getData().user || ctrl.autoNexting()) return []; +function renderVote(ctrl: PuzzleCtrl): VNode[] { + if (!ctrl.data.user || ctrl.autoNexting()) return []; return [ ...renderVoteTutorial(ctrl), - button('Thumbs up', () => ctrl.vote(true), undefined, ctrl.vm.voteDisabled), - button('Thumbs down', () => ctrl.vote(false), undefined, ctrl.vm.voteDisabled), + button('Thumbs up', () => ctrl.vote(true), undefined, ctrl.voteDisabled), + button('Thumbs down', () => ctrl.vote(false), undefined, ctrl.voteDisabled), ]; } diff --git a/ui/puzzle/src/view/after.ts b/ui/puzzle/src/view/after.ts index 4e991c429f8bc..df0a1f6aac20d 100644 --- a/ui/puzzle/src/view/after.ts +++ b/ui/puzzle/src/view/after.ts @@ -1,34 +1,34 @@ import * as licon from 'common/licon'; import { MaybeVNodes, bind, dataIcon, looseH as h } from 'common/snabbdom'; -import { Controller } from '../interfaces'; import { VNode } from 'snabbdom'; import * as router from 'common/router'; +import PuzzleCtrl from '../ctrl'; -const renderVote = (ctrl: Controller): VNode => +const renderVote = (ctrl: PuzzleCtrl): VNode => h( 'div.puzzle__vote', {}, !ctrl.autoNexting() && [ ctrl.session.isNew() && - ctrl.getData().user?.provisional && + ctrl.data.user?.provisional && h('div.puzzle__vote__help', [ h('p', ctrl.trans.noarg('didYouLikeThisPuzzle')), h('p', ctrl.trans.noarg('voteToLoadNextOne')), ]), - h('div.puzzle__vote__buttons', { class: { enabled: !ctrl.vm.voteDisabled } }, [ + h('div.puzzle__vote__buttons', { class: { enabled: !ctrl.voteDisabled } }, [ h('div.vote.vote-up', { hook: bind('click', () => ctrl.vote(true)) }), h('div.vote.vote-down', { hook: bind('click', () => ctrl.vote(false)) }), ]), ], ); -const renderContinue = (ctrl: Controller) => +const renderContinue = (ctrl: PuzzleCtrl) => h('a.continue', { hook: bind('click', ctrl.nextPuzzle) }, [ h('i', { attrs: dataIcon(licon.PlayTriangle) }), ctrl.trans.noarg('continueTraining'), ]); -const renderStreak = (ctrl: Controller): MaybeVNodes => [ +const renderStreak = (ctrl: PuzzleCtrl): MaybeVNodes => [ h('div.complete', [ h('span.game-over', 'GAME OVER'), h('span', ctrl.trans.vdom('yourStreakX', h('strong', ctrl.streak?.data.index))), @@ -39,9 +39,9 @@ const renderStreak = (ctrl: Controller): MaybeVNodes => [ ]), ]; -export default function (ctrl: Controller): VNode { - const data = ctrl.getData(); - const win = ctrl.vm.lastFeedback == 'win'; +export default function (ctrl: PuzzleCtrl): VNode { + const data = ctrl.data; + const win = ctrl.lastFeedback == 'win'; return h( 'div.puzzle__feedback.after', ctrl.streak && !win @@ -53,7 +53,7 @@ export default function (ctrl: Controller): VNode { h('a', { attrs: { 'data-icon': licon.Bullseye, - href: `/analysis/${ctrl.vm.node.fen.replace(/ /g, '_')}?color=${ctrl.vm.pov}#practice`, + href: `/analysis/${ctrl.node.fen.replace(/ /g, '_')}?color=${ctrl.pov}#practice`, title: ctrl.trans.noarg('playWithTheMachine'), target: '_blank', rel: 'noopener', diff --git a/ui/puzzle/src/view/boardMenu.ts b/ui/puzzle/src/view/boardMenu.ts index ee69dfb0bc53f..467474172d60a 100644 --- a/ui/puzzle/src/view/boardMenu.ts +++ b/ui/puzzle/src/view/boardMenu.ts @@ -1,9 +1,9 @@ import { h } from 'snabbdom'; import { menu as menuDropdown } from 'board/menu'; -import { Controller } from '../interfaces'; import { boolPrefXhrToggle } from 'common/controls'; +import PuzzleCtrl from '../ctrl'; -export default function (ctrl: Controller) { +export default function (ctrl: PuzzleCtrl) { return menuDropdown(ctrl.trans, ctrl.redraw, ctrl.menu, menu => [ h('section', [menu.flip(ctrl.trans.noarg('flipBoard'), ctrl.flipped(), ctrl.flip)]), h('section', [ diff --git a/ui/puzzle/src/view/chessground.ts b/ui/puzzle/src/view/chessground.ts index 9f6af3b286cec..1bba0f5543e3b 100644 --- a/ui/puzzle/src/view/chessground.ts +++ b/ui/puzzle/src/view/chessground.ts @@ -1,10 +1,10 @@ import resizeHandle from 'common/resize'; import { Config as CgConfig } from 'chessground/config'; -import { Controller } from '../interfaces'; import { h, VNode } from 'snabbdom'; import * as Prefs from 'common/prefs'; +import PuzzleCtrl from '../ctrl'; -export default function (ctrl: Controller): VNode { +export default function (ctrl: PuzzleCtrl): VNode { return h('div.cg-wrap', { hook: { insert: vnode => @@ -14,7 +14,7 @@ export default function (ctrl: Controller): VNode { }); } -export function makeConfig(ctrl: Controller): CgConfig { +export function makeConfig(ctrl: PuzzleCtrl): CgConfig { const opts = ctrl.makeCgOpts(); return { fen: opts.fen, @@ -42,7 +42,7 @@ export function makeConfig(ctrl: Controller): CgConfig { events: { move: ctrl.userMove, insert(elements) { - resizeHandle(elements, Prefs.ShowResizeHandle.Always, ctrl.vm.node.ply); + resizeHandle(elements, Prefs.ShowResizeHandle.Always, ctrl.node.ply); }, }, premovable: { diff --git a/ui/puzzle/src/view/feedback.ts b/ui/puzzle/src/view/feedback.ts index d880b395319cf..ef16b5b368af5 100644 --- a/ui/puzzle/src/view/feedback.ts +++ b/ui/puzzle/src/view/feedback.ts @@ -1,9 +1,9 @@ import { bind, MaybeVNode } from 'common/snabbdom'; import { h, VNode } from 'snabbdom'; -import { Controller } from '../interfaces'; import afterView from './after'; +import PuzzleCtrl from '../ctrl'; -const viewSolution = (ctrl: Controller): VNode => +const viewSolution = (ctrl: PuzzleCtrl): VNode => ctrl.streak ? h('div.view_solution.skip', { class: { show: !!ctrl.streak?.data.skip } }, [ h( @@ -12,7 +12,7 @@ const viewSolution = (ctrl: Controller): VNode => ctrl.trans.noarg('skip'), ), ]) - : h('div.view_solution', { class: { show: ctrl.vm.canViewSolution } }, [ + : h('div.view_solution', { class: { show: ctrl.canViewSolution() } }, [ h( 'a.button.button-empty', { hook: bind('click', ctrl.viewSolution) }, @@ -20,22 +20,22 @@ const viewSolution = (ctrl: Controller): VNode => ), ]); -const initial = (ctrl: Controller): VNode => +const initial = (ctrl: PuzzleCtrl): VNode => h('div.puzzle__feedback.play', [ h('div.player', [ - h('div.no-square', h('piece.king.' + ctrl.vm.pov)), + h('div.no-square', h('piece.king.' + ctrl.pov)), h('div.instruction', [ h('strong', ctrl.trans.noarg('yourTurn')), h( 'em', - ctrl.trans.noarg(ctrl.vm.pov === 'white' ? 'findTheBestMoveForWhite' : 'findTheBestMoveForBlack'), + ctrl.trans.noarg(ctrl.pov === 'white' ? 'findTheBestMoveForWhite' : 'findTheBestMoveForBlack'), ), ]), ]), viewSolution(ctrl), ]); -const good = (ctrl: Controller): VNode => +const good = (ctrl: PuzzleCtrl): VNode => h('div.puzzle__feedback.good', [ h('div.player', [ h('div.icon', '✓'), @@ -47,7 +47,7 @@ const good = (ctrl: Controller): VNode => viewSolution(ctrl), ]); -const fail = (ctrl: Controller): VNode => +const fail = (ctrl: PuzzleCtrl): VNode => h('div.puzzle__feedback.fail', [ h('div.player', [ h('div.icon', '✗'), @@ -59,9 +59,9 @@ const fail = (ctrl: Controller): VNode => viewSolution(ctrl), ]); -export default function (ctrl: Controller): MaybeVNode { - if (ctrl.vm.mode === 'view') return afterView(ctrl); - switch (ctrl.vm.lastFeedback) { +export default function (ctrl: PuzzleCtrl): MaybeVNode { + if (ctrl.mode === 'view') return afterView(ctrl); + switch (ctrl.lastFeedback) { case 'init': return initial(ctrl); case 'good': diff --git a/ui/puzzle/src/view/main.ts b/ui/puzzle/src/view/main.ts index 368a8d7f0cc7b..960e9006e4999 100644 --- a/ui/puzzle/src/view/main.ts +++ b/ui/puzzle/src/view/main.ts @@ -6,7 +6,6 @@ import chessground from './chessground'; import feedbackView from './feedback'; import * as licon from 'common/licon'; import { stepwiseScroll } from 'common/scroll'; -import { Controller } from '../interfaces'; import { VNode } from 'snabbdom'; import { onInsert, bindNonPassive, looseH as h } from 'common/snabbdom'; import { bindMobileMousedown } from 'common/device'; @@ -16,10 +15,10 @@ import { renderVoiceBar } from 'voice'; import { render as renderKeyboardMove } from 'keyboardMove'; import { toggleButton as boardMenuToggleButton } from 'board/menu'; import boardMenu from './boardMenu'; - import * as Prefs from 'common/prefs'; +import PuzzleCtrl from '../ctrl'; -const renderAnalyse = (ctrl: Controller): VNode => h('div.puzzle__moves.areplay', [treeView(ctrl)]); +const renderAnalyse = (ctrl: PuzzleCtrl): VNode => h('div.puzzle__moves.areplay', [treeView(ctrl)]); function dataAct(e: Event): string | null { const target = e.target as HTMLElement; @@ -30,10 +29,10 @@ function jumpButton(icon: string, effect: string, disabled: boolean, glowing = f return h('button.fbt', { class: { disabled, glowing }, attrs: { 'data-act': effect, 'data-icon': icon } }); } -function controls(ctrl: Controller): VNode { - const node = ctrl.vm.node; +function controls(ctrl: PuzzleCtrl): VNode { + const node = ctrl.node; const nextNode = node.children[0]; - const notOnLastMove = ctrl.vm.mode == 'play' && nextNode && nextNode.puzzle != 'fail'; + const notOnLastMove = ctrl.mode == 'play' && nextNode && nextNode.puzzle != 'fail'; return h('div.puzzle__controls.analyse-controls', [ h( 'div.jumps', @@ -62,16 +61,16 @@ function controls(ctrl: Controller): VNode { let cevalShown = false; -export default function (ctrl: Controller): VNode { +export default function (ctrl: PuzzleCtrl): VNode { if (ctrl.nvui) return ctrl.nvui.render(ctrl); - const showCeval = ctrl.vm.showComputer(), + const showCeval = ctrl.showComputer(), gaugeOn = ctrl.showEvalGauge(); if (cevalShown !== showCeval) { - if (!cevalShown) ctrl.vm.autoScrollNow = true; + if (!cevalShown) ctrl.autoScrollNow = true; cevalShown = showCeval; } return h( - `main.puzzle.puzzle-${ctrl.getData().replay ? 'replay' : 'play'}${ctrl.streak ? '.puzzle--streak' : ''}`, + `main.puzzle.puzzle-${ctrl.data.replay ? 'replay' : 'play'}${ctrl.streak ? '.puzzle--streak' : ''}`, { class: { 'gauge-on': gaugeOn }, hook: { @@ -140,13 +139,13 @@ export default function (ctrl: Controller): VNode { ); } -function session(ctrl: Controller) { +function session(ctrl: PuzzleCtrl) { const rounds = ctrl.session.get().rounds, - current = ctrl.getData().puzzle.id; + current = ctrl.data.puzzle.id; return h('div.puzzle__session', [ ...rounds.map(round => { const rd = - round.ratingDiff && ctrl.showRatings && round.ratingDiff > 0 + round.ratingDiff && ctrl.opts.showRatings && round.ratingDiff > 0 ? '+' + round.ratingDiff : round.ratingDiff; return h( diff --git a/ui/puzzle/src/view/side.ts b/ui/puzzle/src/view/side.ts index e4001814687f3..177cc095724c9 100644 --- a/ui/puzzle/src/view/side.ts +++ b/ui/puzzle/src/view/side.ts @@ -1,4 +1,4 @@ -import { Controller, Puzzle, PuzzleGame, PuzzleDifficulty } from '../interfaces'; +import { Puzzle, PuzzleGame, PuzzleDifficulty } from '../interfaces'; import * as licon from 'common/licon'; import { dataIcon, onInsert, MaybeVNode, looseH as h } from 'common/snabbdom'; import { VNode } from 'snabbdom'; @@ -7,30 +7,31 @@ import perfIcons from 'common/perfIcons'; import * as router from 'common/router'; import { userLink } from 'common/userLink'; import PuzzleStreak from '../streak'; +import PuzzleCtrl from '../ctrl'; -export function puzzleBox(ctrl: Controller): VNode { - const data = ctrl.getData(); +export function puzzleBox(ctrl: PuzzleCtrl): VNode { + const data = ctrl.data; return h('div.puzzle__side__metas', [ puzzleInfos(ctrl, data.puzzle), gameInfos(ctrl, data.game, data.puzzle), ]); } -const angleImg = (ctrl: Controller): string => { - const angle = ctrl.getData().angle; +const angleImg = (ctrl: PuzzleCtrl): string => { + const angle = ctrl.data.angle; const name = angle.opening ? 'opening' : angle.key.startsWith('mateIn') ? 'mate' : angle.key; return lichess.asset.url(`images/puzzle-themes/${name}.svg`); }; -const puzzleInfos = (ctrl: Controller, puzzle: Puzzle): VNode => +const puzzleInfos = (ctrl: PuzzleCtrl, puzzle: Puzzle): VNode => h('div.infos.puzzle', [ - h('img.infos__angle-img', { attrs: { src: angleImg(ctrl), alt: ctrl.getData().angle.name } }), + h('img.infos__angle-img', { attrs: { src: angleImg(ctrl), alt: ctrl.data.angle.name } }), h('div', [ h( 'p', ctrl.trans.vdom( 'puzzleId', - ctrl.streak && ctrl.vm.mode === 'play' + ctrl.streak && ctrl.mode === 'play' ? h('span.hidden', ctrl.trans.noarg('hidden')) : h( 'a', @@ -44,12 +45,12 @@ const puzzleInfos = (ctrl: Controller, puzzle: Puzzle): VNode => ), ), ), - ctrl.showRatings && + ctrl.opts.showRatings && h( 'p', ctrl.trans.vdom( 'ratingX', - !ctrl.streak && ctrl.vm.mode === 'play' + !ctrl.streak && ctrl.mode === 'play' ? h('span.hidden', ctrl.trans.noarg('hidden')) : h('strong', puzzle.rating), ), @@ -58,7 +59,7 @@ const puzzleInfos = (ctrl: Controller, puzzle: Puzzle): VNode => ]), ]); -function gameInfos(ctrl: Controller, game: PuzzleGame, puzzle: Puzzle): VNode { +function gameInfos(ctrl: PuzzleCtrl, game: PuzzleGame, puzzle: Puzzle): VNode { const gameName = `${game.clock} • ${game.perf.name}`; return h('div.infos', { attrs: dataIcon(perfIcons[game.perf.key]) }, [ h('div', [ @@ -66,15 +67,15 @@ function gameInfos(ctrl: Controller, game: PuzzleGame, puzzle: Puzzle): VNode { 'p', ctrl.trans.vdom( 'fromGameLink', - ctrl.vm.mode == 'play' + ctrl.mode == 'play' ? h('span', gameName) - : h('a', { attrs: { href: `/${game.id}/${ctrl.vm.pov}#${puzzle.initialPly}` } }, gameName), + : h('a', { attrs: { href: `/${game.id}/${ctrl.pov}#${puzzle.initialPly}` } }, gameName), ), ), h( 'div.players', game.players.map(p => { - const user = { ...p, rating: ctrl.showRatings ? p.rating : undefined, line: false }; + const user = { ...p, rating: ctrl.opts.showRatings ? p.rating : undefined, line: false }; return h('div.player.color-icon.is.text.' + p.color, userLink(user)); }), ), @@ -97,15 +98,15 @@ const renderStreak = (streak: PuzzleStreak, noarg: TransNoArg) => ), ); -export const userBox = (ctrl: Controller): VNode => { - const data = ctrl.getData(), +export const userBox = (ctrl: PuzzleCtrl): VNode => { + const data = ctrl.data, noarg = ctrl.trans.noarg; if (!data.user) return h('div.puzzle__side__user', [ h('p', noarg('toGetPersonalizedPuzzles')), h('a.button', { attrs: { href: router.withLang('/signup') } }, noarg('signUp')), ]); - const diff = ctrl.vm.round?.ratingDiff, + const diff = ctrl.round?.ratingDiff, ratedId = 'puzzle-toggle-rated'; return h('div.puzzle__side__user', [ !data.replay && @@ -114,7 +115,7 @@ export const userBox = (ctrl: Controller): VNode => { h('div.puzzle__side__config__toggle', [ h('div.switch', [ h(`input#${ratedId}.cmn-toggle.cmn-toggle--subtle`, { - attrs: { type: 'checkbox', checked: ctrl.rated(), disabled: ctrl.vm.lastFeedback != 'init' }, + attrs: { type: 'checkbox', checked: ctrl.rated(), disabled: ctrl.lastFeedback != 'init' }, hook: { insert: vnode => (vnode.elm as HTMLElement).addEventListener('change', ctrl.toggleRated), }, @@ -126,7 +127,7 @@ export const userBox = (ctrl: Controller): VNode => { h( 'div.puzzle__side__user__rating', ctrl.rated() - ? ctrl.showRatings && + ? ctrl.opts.showRatings && h('strong', [ data.user.rating - (diff || 0), ...(diff && diff > 0 ? [' ', h('good.rp', '+' + diff)] : []), @@ -137,7 +138,7 @@ export const userBox = (ctrl: Controller): VNode => { ]); }; -export const streakBox = (ctrl: Controller) => +export const streakBox = (ctrl: PuzzleCtrl) => h('div.puzzle__side__user', renderStreak(ctrl.streak!, ctrl.trans.noarg)); const difficulties: [PuzzleDifficulty, number][] = [ @@ -153,14 +154,14 @@ const colors = [ ['white', 'asWhite'], ]; -export function replay(ctrl: Controller): MaybeVNode { - const replay = ctrl.getData().replay; +export function replay(ctrl: PuzzleCtrl): MaybeVNode { + const replay = ctrl.data.replay; if (!replay) return; - const i = replay.i + (ctrl.vm.mode == 'play' ? 0 : 1); + const i = replay.i + (ctrl.mode == 'play' ? 0 : 1); return h('div.puzzle__side__replay', [ h('a', { attrs: { href: `/training/dashboard/${replay.days}` } }, [ '« ', - `Replaying ${ctrl.trans.noarg(ctrl.getData().angle.key)} puzzles`, + `Replaying ${ctrl.trans.noarg(ctrl.data.angle.key)} puzzles`, ]), h('div.puzzle__side__replay__bar', { attrs: { @@ -171,10 +172,10 @@ export function replay(ctrl: Controller): MaybeVNode { ]); } -export function config(ctrl: Controller): MaybeVNode { +export function config(ctrl: PuzzleCtrl): MaybeVNode { const autoNextId = 'puzzle-toggle-autonext', noarg = ctrl.trans.noarg, - data = ctrl.getData(); + data = ctrl.data; return h('div.puzzle__side__config', [ h('div.puzzle__side__config__toggle', [ h('div.switch', [ @@ -184,7 +185,7 @@ export function config(ctrl: Controller): MaybeVNode { insert: vnode => (vnode.elm as HTMLElement).addEventListener('change', () => { ctrl.autoNext(!ctrl.autoNext()); - if (ctrl.autoNext() && ctrl.vm.resultSent && !ctrl.streak) { + if (ctrl.autoNext() && ctrl.resultSent && !ctrl.streak) { ctrl.nextPuzzle(); } }), @@ -198,10 +199,10 @@ export function config(ctrl: Controller): MaybeVNode { ]); } -export const renderDifficultyForm = (ctrl: Controller): VNode => +export const renderDifficultyForm = (ctrl: PuzzleCtrl): VNode => h( 'form.puzzle__side__config__difficulty', - { attrs: { action: `/training/difficulty/${ctrl.getData().angle.key}`, method: 'post' } }, + { attrs: { action: `/training/difficulty/${ctrl.data.angle.key}`, method: 'post' } }, [ h('label', { attrs: { for: 'puzzle-difficulty' } }, ctrl.trans.noarg('difficultyLevel')), h( @@ -218,7 +219,7 @@ export const renderDifficultyForm = (ctrl: Controller): VNode => { attrs: { value: key, - selected: key == ctrl.settings.difficulty, + selected: key == ctrl.opts.settings.difficulty, title: !!delta && ctrl.trans.pluralSame( @@ -234,7 +235,7 @@ export const renderDifficultyForm = (ctrl: Controller): VNode => ], ); -export const renderColorForm = (ctrl: Controller): VNode => +export const renderColorForm = (ctrl: PuzzleCtrl): VNode => h( 'div.puzzle__side__config__color', h( @@ -242,9 +243,9 @@ export const renderColorForm = (ctrl: Controller): VNode => colors.map(([key, i18n]) => h('div', [ h( - `a.label.color-${key}${key === (ctrl.settings.color || 'random') ? '.active' : ''}`, + `a.label.color-${key}${key === (ctrl.opts.settings.color || 'random') ? '.active' : ''}`, { - attrs: { href: `/training/${ctrl.getData().angle.key}/${key}`, title: ctrl.trans.noarg(i18n) }, + attrs: { href: `/training/${ctrl.data.angle.key}/${key}`, title: ctrl.trans.noarg(i18n) }, }, h('i'), ), diff --git a/ui/puzzle/src/view/theme.ts b/ui/puzzle/src/view/theme.ts index 05c9ccf1a20cf..909c4c9b53a76 100644 --- a/ui/puzzle/src/view/theme.ts +++ b/ui/puzzle/src/view/theme.ts @@ -1,20 +1,20 @@ import * as licon from 'common/licon'; import * as router from 'common/router'; import { MaybeVNode, bind, dataIcon, looseH as h } from 'common/snabbdom'; -import { Controller } from '../interfaces'; import { VNode } from 'snabbdom'; import { renderColorForm } from './side'; +import PuzzleCtrl from '../ctrl'; const studyUrl = 'https://lichess.org/study/viiWlKjv'; -export default function theme(ctrl: Controller): MaybeVNode { - const data = ctrl.getData(), +export default function theme(ctrl: PuzzleCtrl): MaybeVNode { + const data = ctrl.data, angle = data.angle; - const showEditor = ctrl.vm.mode == 'view' && !ctrl.autoNexting(); + const showEditor = ctrl.mode == 'view' && !ctrl.autoNexting(); if (data.replay) return showEditor ? h('div.puzzle__side__theme', editor(ctrl)) : null; const puzzleMenu = (v: VNode): VNode => h('a', { attrs: { href: router.withLang(`/training/${angle.opening ? 'openings' : 'themes'}`) } }, v); - return !ctrl.streak && ctrl.vm.isDaily + return !ctrl.streak && ctrl.isDaily ? h( 'div.puzzle__side__theme.puzzle__side__theme--daily', puzzleMenu(h('h2', ctrl.trans.noarg('dailyPuzzle'))), @@ -43,10 +43,10 @@ export default function theme(ctrl: Controller): MaybeVNode { const invisibleThemes = new Set(['master', 'masterVsMaster', 'superGM']); -const editor = (ctrl: Controller): VNode[] => { - const data = ctrl.getData(), +const editor = (ctrl: PuzzleCtrl): VNode[] => { + const data = ctrl.data, trans = ctrl.trans.noarg, - votedThemes = ctrl.vm.round?.themes || {}; + votedThemes = ctrl.round?.themes || {}; const visibleThemes: string[] = data.puzzle.themes .filter(t => !invisibleThemes.has(t)) .concat(Object.keys(votedThemes).filter(t => votedThemes[t] && !data.puzzle.themes.includes(t))) diff --git a/ui/puzzle/src/view/tree.ts b/ui/puzzle/src/view/tree.ts index ec7d08d800f7c..9aaf8def95f54 100644 --- a/ui/puzzle/src/view/tree.ts +++ b/ui/puzzle/src/view/tree.ts @@ -3,11 +3,11 @@ import { defined } from 'common'; import throttle from 'common/throttle'; import { renderEval as normalizeEval } from 'ceval'; import { path as treePath } from 'tree'; -import { Controller } from '../interfaces'; import { MaybeVNode, LooseVNodes, looseH as h } from 'common/snabbdom'; +import PuzzleCtrl from '../ctrl'; interface Ctx { - ctrl: Controller; + ctrl: PuzzleCtrl; } interface RenderOpts { @@ -21,18 +21,18 @@ interface Glyph { symbol: string; } -const autoScroll = throttle(150, (ctrl: Controller, el) => { +const autoScroll = throttle(150, (ctrl: PuzzleCtrl, el) => { const cont = el.parentNode; const target = el.querySelector('.active'); if (!target) { - cont.scrollTop = ctrl.vm.path === treePath.root ? 0 : 99999; + cont.scrollTop = ctrl.path === treePath.root ? 0 : 99999; return; } cont.scrollTop = target.offsetTop - cont.offsetHeight / 2 + target.offsetHeight; }); function pathContains(ctx: Ctx, path: Tree.Path): boolean { - return treePath.contains(ctx.ctrl.vm.path, path); + return treePath.contains(ctx.ctrl.path, path); } function plyToTurn(ply: number): number { @@ -91,9 +91,9 @@ function renderMoveOf(ctx: Ctx, node: Tree.Node, opts: RenderOpts): VNode { function renderMainlineMoveOf(ctx: Ctx, node: Tree.Node, opts: RenderOpts): VNode { const path = opts.parentPath + node.id; const classes: Classes = { - active: path === ctx.ctrl.vm.path, - current: path === ctx.ctrl.vm.initialPath, - hist: node.ply < ctx.ctrl.vm.initialNode.ply, + active: path === ctx.ctrl.path, + current: path === ctx.ctrl.initialPath, + hist: node.ply < ctx.ctrl.initialNode.ply, }; if (node.puzzle) classes[node.puzzle] = true; return h('move', { attrs: { p: path }, class: classes }, renderMove(ctx, node)); @@ -129,7 +129,7 @@ function renderMove(ctx: Ctx, node: Tree.Node): LooseVNodes { function renderVariationMoveOf(ctx: Ctx, node: Tree.Node, opts: RenderOpts): VNode { const withIndex = opts.withIndex || node.ply % 2 === 1; const path = opts.parentPath + node.id; - const active = path === ctx.ctrl.vm.path; + const active = path === ctx.ctrl.path; const classes: Classes = { active, parent: !active && pathContains(ctx, path) }; if (node.puzzle) classes[node.puzzle] = true; return h('move', { attrs: { p: path }, class: classes }, [ @@ -159,8 +159,8 @@ function eventPath(e: Event): Tree.Path | null { return target.getAttribute('p') || (target.parentNode as HTMLElement).getAttribute('p'); } -export function render(ctrl: Controller): VNode { - const root = ctrl.getTree().root; +export function render(ctrl: PuzzleCtrl): VNode { + const root = ctrl.tree.root; const ctx = { ctrl: ctrl, showComputer: false }; return h( 'div.tview2.tview2-column', @@ -177,13 +177,13 @@ export function render(ctrl: Controller): VNode { }); }, postpatch: (_, vnode) => { - if (ctrl.vm.autoScrollNow) { + if (ctrl.autoScrollNow) { autoScroll(ctrl, vnode.elm as HTMLElement); - ctrl.vm.autoScrollNow = false; + ctrl.autoScrollNow = false; + ctrl.autoScrollRequested = false; + } else if (ctrl.autoScrollRequested) { + if (ctrl.path !== treePath.root) autoScroll(ctrl, vnode.elm as HTMLElement); ctrl.autoScrollRequested = false; - } else if (ctrl.vm.autoScrollRequested) { - if (ctrl.vm.path !== treePath.root) autoScroll(ctrl, vnode.elm as HTMLElement); - ctrl.vm.autoScrollRequested = false; } }, },