549 lines
16 KiB
TypeScript
549 lines
16 KiB
TypeScript
import * as speech from './speech';
|
|
import * as xhr from './xhr';
|
|
import computeAutoShapes from './autoShape';
|
|
import keyboard from './keyboard';
|
|
import makePromotion from './promotion';
|
|
import moveTest from './moveTest';
|
|
import PuzzleSession from './session';
|
|
import PuzzleStreak from './streak';
|
|
import throttle from 'common/throttle';
|
|
import { Api as CgApi } from 'chessground/api';
|
|
import { build as treeBuild, ops as treeOps, path as treePath, TreeWrapper } from 'tree';
|
|
import { Chess } from 'chessops/chess';
|
|
import { chessgroundDests, scalachessCharPair } from 'chessops/compat';
|
|
import { Config as CgConfig } from 'chessground/config';
|
|
import { ctrl as cevalCtrl, CevalCtrl } from 'ceval';
|
|
import { defer } from 'common/defer';
|
|
import { defined, prop, Prop } from 'common';
|
|
import { makeSanAndPlay } from 'chessops/san';
|
|
import { parseFen, makeFen } from 'chessops/fen';
|
|
import { parseSquare, parseUci, makeSquare, makeUci } from 'chessops/util';
|
|
import { pgnToTree, mergeSolution } from './moveTree';
|
|
import { Redraw, Vm, Controller, PuzzleOpts, PuzzleData, PuzzleResult, MoveTest, ThemeKey } from './interfaces';
|
|
import { Role, Move, Outcome } from 'chessops/types';
|
|
import { storedProp } from 'common/storage';
|
|
|
|
export default function (opts: PuzzleOpts, redraw: Redraw): Controller {
|
|
const vm: Vm = {
|
|
next: defer<PuzzleData>(),
|
|
} as Vm;
|
|
let data: PuzzleData, tree: TreeWrapper, ceval: CevalCtrl;
|
|
const hasStreak = !!opts.data.streak;
|
|
const autoNext = storedProp(`puzzle.autoNext${hasStreak ? '.streak' : ''}`, hasStreak);
|
|
const ground = prop<CgApi | undefined>(undefined) as Prop<CgApi>;
|
|
const threatMode = prop(false);
|
|
const streak = opts.data.streak ? new PuzzleStreak(opts.data) : undefined;
|
|
if (streak)
|
|
opts.data = {
|
|
...opts.data,
|
|
...streak.data.current,
|
|
};
|
|
const session = new PuzzleSession(opts.data.theme.key, opts.data.user?.id, hasStreak);
|
|
|
|
// required by ceval
|
|
vm.showComputer = () => vm.mode === 'view';
|
|
vm.showAutoShapes = () => true;
|
|
|
|
const throttleSound = (name: string) => throttle(100, () => lichess.sound.play(name));
|
|
const loadSound = (file: string, volume?: number, delay?: number) => {
|
|
setTimeout(() => lichess.sound.loadOggOrMp3(file, `${lichess.sound.baseUrl}/${file}`), delay || 1000);
|
|
return () => lichess.sound.play(file, volume);
|
|
};
|
|
const sound = {
|
|
move: throttleSound('move'),
|
|
capture: throttleSound('capture'),
|
|
check: throttleSound('check'),
|
|
good: loadSound('lisp/PuzzleStormGood', 0.7, 500),
|
|
end: loadSound('lisp/PuzzleStormEnd', 1, 1000),
|
|
};
|
|
|
|
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);
|
|
}
|
|
|
|
function withGround<A>(f: (cg: CgApi) => A): A | undefined {
|
|
const g = 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';
|
|
|
|
setPath(treePath.init(initialPath));
|
|
setTimeout(() => {
|
|
jump(initialPath);
|
|
redraw();
|
|
}, 500);
|
|
|
|
// just to delay button display
|
|
vm.canViewSolution = false;
|
|
setTimeout(() => {
|
|
vm.canViewSolution = true;
|
|
redraw();
|
|
}, 4000);
|
|
|
|
withGround(g => {
|
|
g.setAutoShapes([]);
|
|
g.setShapes([]);
|
|
showGround(g);
|
|
});
|
|
|
|
instanciateCeval();
|
|
}
|
|
|
|
function position(): Chess {
|
|
const setup = parseFen(vm.node.fen).unwrap();
|
|
return Chess.fromSetup(setup).unwrap();
|
|
}
|
|
|
|
function makeCgOpts(): CgConfig {
|
|
const node = vm.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 movable = canMove
|
|
? {
|
|
color: dests.size > 0 ? color : undefined,
|
|
dests,
|
|
}
|
|
: {
|
|
color: undefined,
|
|
dests: new Map(),
|
|
};
|
|
const config = {
|
|
fen: node.fen,
|
|
orientation: vm.pov,
|
|
turnColor: color,
|
|
movable: movable,
|
|
premovable: {
|
|
enabled: false,
|
|
},
|
|
check: !!node.check,
|
|
lastMove: uciToLastMove(node.uci),
|
|
};
|
|
if (node.ply >= vm.initialNode.ply) {
|
|
if (vm.mode !== 'view' && color !== vm.pov && !nextNode) {
|
|
config.movable.color = vm.pov;
|
|
config.premovable.enabled = true;
|
|
}
|
|
}
|
|
vm.cgConfig = config;
|
|
return config;
|
|
}
|
|
|
|
function showGround(g: CgApi): void {
|
|
g.set(makeCgOpts());
|
|
}
|
|
|
|
function userMove(orig: Key, dest: Key): void {
|
|
vm.justPlayed = orig;
|
|
if (!promotion.start(orig, dest, playUserMove)) playUserMove(orig, dest);
|
|
}
|
|
|
|
function playUci(uci: Uci): void {
|
|
sendMove(parseUci(uci)!);
|
|
}
|
|
|
|
function playUserMove(orig: Key, dest: Key, promotion?: Role): void {
|
|
sendMove({
|
|
from: parseSquare(orig)!,
|
|
to: parseSquare(dest)!,
|
|
promotion,
|
|
});
|
|
}
|
|
|
|
function sendMove(move: Move): void {
|
|
sendMoveAt(vm.path, position(), move);
|
|
}
|
|
|
|
function sendMoveAt(path: Tree.Path, pos: Chess, move: Move): void {
|
|
move = pos.normalizeMove(move);
|
|
const san = makeSanAndPlay(pos, move);
|
|
const check = pos.isCheck() ? pos.board.kingOf(pos.turn) : undefined;
|
|
addNode(
|
|
{
|
|
ply: 2 * (pos.fullmoves - 1) + (pos.turn == 'white' ? 0 : 1),
|
|
fen: makeFen(pos.toSetup()),
|
|
id: scalachessCharPair(move),
|
|
uci: makeUci(move),
|
|
san,
|
|
check: defined(check) ? makeSquare(check) : undefined,
|
|
children: [],
|
|
},
|
|
path
|
|
);
|
|
}
|
|
|
|
function uciToLastMove(uci: string | undefined): [Key, Key] | undefined {
|
|
// assuming standard chess
|
|
return defined(uci) ? [uci.substr(0, 2) as Key, uci.substr(2, 2) as Key] : undefined;
|
|
}
|
|
|
|
function addNode(node: Tree.Node, path: Tree.Path): void {
|
|
const newPath = tree.addNode(node, path)!;
|
|
jump(newPath);
|
|
withGround(g => g.playPremove());
|
|
|
|
const progress = moveTest(vm, data.puzzle);
|
|
if (progress) applyProgress(progress);
|
|
reorderChildren(path);
|
|
redraw();
|
|
speech.node(node, false);
|
|
}
|
|
|
|
function reorderChildren(path: Tree.Path, recursive?: boolean): void {
|
|
const node = 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));
|
|
}
|
|
|
|
function revertUserMove(): void {
|
|
setTimeout(() => {
|
|
withGround(g => g.cancelPremove());
|
|
userJump(treePath.init(vm.path));
|
|
redraw();
|
|
}, 100);
|
|
}
|
|
|
|
function applyProgress(progress: undefined | 'fail' | 'win' | MoveTest): void {
|
|
if (progress === 'fail') {
|
|
vm.lastFeedback = 'fail';
|
|
revertUserMove();
|
|
if (vm.mode === 'play') {
|
|
if (streak) {
|
|
vm.mode = 'view';
|
|
streak.onComplete(false);
|
|
setTimeout(viewSolution, 500);
|
|
sound.end();
|
|
} else {
|
|
vm.canViewSolution = true;
|
|
vm.mode = 'try';
|
|
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()));
|
|
}
|
|
} else if (progress) {
|
|
vm.lastFeedback = 'good';
|
|
setTimeout(() => {
|
|
const pos = Chess.fromSetup(parseFen(progress.fen).unwrap()).unwrap();
|
|
sendMoveAt(progress.path, pos, progress.move);
|
|
}, opts.pref.animation.duration * (autoNext() ? 1 : 1.5));
|
|
}
|
|
}
|
|
|
|
function sendResult(win: boolean): Promise<void> {
|
|
if (vm.resultSent) return Promise.resolve();
|
|
vm.resultSent = true;
|
|
session.complete(data.puzzle.id, win);
|
|
return xhr.complete(data.puzzle.id, data.theme.key, win, data.replay, streak).then((res: PuzzleResult) => {
|
|
if (res?.replayComplete && data.replay) return lichess.redirect(`/training/dashboard/${data.replay.days}`);
|
|
if (res?.next.user && data.user) {
|
|
data.user.rating = res.next.user.rating;
|
|
data.user.provisional = res.next.user.provisional;
|
|
vm.round = res.round;
|
|
if (res.round?.ratingDiff) session.setRatingDiff(data.puzzle.id, res.round.ratingDiff);
|
|
}
|
|
if (win) speech.success();
|
|
vm.next.resolve(res.next);
|
|
if (streak && win) streak.onComplete(true, res.next);
|
|
redraw();
|
|
});
|
|
}
|
|
|
|
function nextPuzzle(): void {
|
|
ceval.stop();
|
|
vm.next.promise.then(initiate).then(redraw);
|
|
|
|
if (!streak && !data.replay) {
|
|
const path = `/training/${data.theme.key}`;
|
|
if (location.pathname != path) history.replaceState(null, '', path);
|
|
}
|
|
}
|
|
|
|
function instanciateCeval(): void {
|
|
if (ceval) ceval.destroy();
|
|
ceval = cevalCtrl({
|
|
redraw,
|
|
storageKeyPrefix: 'puzzle',
|
|
multiPvDefault: 3,
|
|
variant: {
|
|
short: 'Std',
|
|
name: 'Standard',
|
|
key: 'standard',
|
|
},
|
|
standardMaterial: true,
|
|
possible: true,
|
|
emit: function (ev, work) {
|
|
tree.updateAt(work.path, function (node) {
|
|
if (work.threatMode) {
|
|
if (!node.threat || node.threat.depth <= ev.depth || node.threat.maxDepth < ev.maxDepth) node.threat = ev;
|
|
} else if (!node.ceval || node.ceval.depth <= ev.depth || node.ceval.maxDepth < ev.maxDepth) node.ceval = ev;
|
|
if (work.path === vm.path) {
|
|
setAutoShapes();
|
|
redraw();
|
|
}
|
|
});
|
|
},
|
|
setAutoShapes: setAutoShapes,
|
|
});
|
|
}
|
|
|
|
function setAutoShapes(): void {
|
|
withGround(g => {
|
|
g.setAutoShapes(
|
|
computeAutoShapes({
|
|
vm: vm,
|
|
ceval: ceval,
|
|
ground: g,
|
|
threatMode: threatMode(),
|
|
nextNodeBest: nextNodeBest(),
|
|
})
|
|
);
|
|
});
|
|
}
|
|
|
|
function canUseCeval(): boolean {
|
|
return vm.mode === 'view' && !outcome();
|
|
}
|
|
|
|
function startCeval(): void {
|
|
if (ceval.enabled() && canUseCeval()) doStartCeval();
|
|
}
|
|
|
|
const doStartCeval = throttle(800, function () {
|
|
ceval.start(vm.path, vm.nodeList, threatMode());
|
|
});
|
|
|
|
const nextNodeBest = () => treeOps.withMainlineChild(vm.node, n => n.eval?.best);
|
|
|
|
const getCeval = () => ceval;
|
|
|
|
function toggleCeval(): void {
|
|
ceval.toggle();
|
|
setAutoShapes();
|
|
startCeval();
|
|
if (!ceval.enabled()) threatMode(false);
|
|
vm.autoScrollRequested = true;
|
|
redraw();
|
|
}
|
|
|
|
function toggleThreatMode(): void {
|
|
if (vm.node.check) return;
|
|
if (!ceval.enabled()) ceval.toggle();
|
|
if (!ceval.enabled()) return;
|
|
threatMode(!threatMode());
|
|
setAutoShapes();
|
|
startCeval();
|
|
redraw();
|
|
}
|
|
|
|
function outcome(): Outcome | undefined {
|
|
return 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);
|
|
if (pathChanged) {
|
|
if (isForwardStep) {
|
|
if (!vm.node.uci) sound.move();
|
|
// initial position
|
|
else if (!vm.justPlayed || vm.node.uci.includes(vm.justPlayed)) {
|
|
if (vm.node.san!.includes('x')) sound.capture();
|
|
else sound.move();
|
|
}
|
|
if (/\+|#/.test(vm.node.san!)) sound.check();
|
|
}
|
|
threatMode(false);
|
|
ceval.stop();
|
|
startCeval();
|
|
}
|
|
promotion.cancel();
|
|
vm.justPlayed = undefined;
|
|
vm.autoScrollRequested = true;
|
|
lichess.pubsub.emit('ply', vm.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);
|
|
speech.node(vm.node, true);
|
|
}
|
|
|
|
function viewSolution(): void {
|
|
sendResult(false);
|
|
vm.mode = 'view';
|
|
mergeSolution(tree, vm.initialPath, data.puzzle.solution, vm.pov);
|
|
reorderChildren(vm.initialPath, true);
|
|
|
|
// try and play the solution next move
|
|
const next = vm.node.children[0];
|
|
if (next && next.puzzle === 'good') userJump(vm.path + next.id);
|
|
else {
|
|
const firstGoodPath = treeOps.takePathWhile(vm.mainline, node => node.puzzle != 'good');
|
|
if (firstGoodPath) userJump(firstGoodPath + tree.nodeAtPath(firstGoodPath).children[0].id);
|
|
}
|
|
|
|
vm.autoScrollRequested = true;
|
|
vm.voteDisabled = true;
|
|
redraw();
|
|
startCeval();
|
|
setTimeout(() => {
|
|
vm.voteDisabled = false;
|
|
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();
|
|
};
|
|
|
|
const vote = (v: boolean) => {
|
|
if (!vm.voteDisabled) {
|
|
xhr.vote(data.puzzle.id, v);
|
|
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);
|
|
} 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);
|
|
}
|
|
redraw();
|
|
}
|
|
};
|
|
|
|
initiate(opts.data);
|
|
|
|
const promotion = makePromotion(vm, ground, redraw);
|
|
|
|
function playBestMove(): void {
|
|
const uci = nextNodeBest() || (vm.node.ceval && vm.node.ceval.pvs[0].moves[0]);
|
|
if (uci) playUci(uci);
|
|
}
|
|
|
|
keyboard({
|
|
vm,
|
|
userJump,
|
|
getCeval,
|
|
toggleCeval,
|
|
toggleThreatMode,
|
|
redraw,
|
|
playBestMove,
|
|
});
|
|
|
|
// 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));
|
|
|
|
speech.setup();
|
|
|
|
lichess.pubsub.on('zen', () => {
|
|
const zen = !$('body').hasClass('zen');
|
|
$('body').toggleClass('zen', zen);
|
|
window.dispatchEvent(new Event('resize'));
|
|
xhr.setZen(zen);
|
|
});
|
|
$('body').addClass('playing'); // for zen
|
|
$('#zentog').on('click', () => lichess.pubsub.emit('zen'));
|
|
|
|
return {
|
|
vm,
|
|
getData() {
|
|
return data;
|
|
},
|
|
getTree() {
|
|
return tree;
|
|
},
|
|
ground,
|
|
makeCgOpts,
|
|
userJump,
|
|
viewSolution,
|
|
nextPuzzle,
|
|
vote,
|
|
voteTheme,
|
|
getCeval,
|
|
pref: opts.pref,
|
|
difficulty: opts.difficulty,
|
|
trans: lichess.trans(opts.i18n),
|
|
autoNext,
|
|
autoNexting: () => vm.lastFeedback == 'win' && autoNext(),
|
|
outcome,
|
|
toggleCeval,
|
|
toggleThreatMode,
|
|
threatMode,
|
|
currentEvals() {
|
|
return { client: vm.node.ceval };
|
|
},
|
|
nextNodeBest,
|
|
userMove,
|
|
playUci,
|
|
showEvalGauge() {
|
|
return vm.showComputer() && ceval.enabled();
|
|
},
|
|
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,
|
|
};
|
|
}
|