Merge branch 'puzzle-accessibility' of https://github.com/370417/lila

* 'puzzle-accessibility' of https://github.com/370417/lila:
  Fix build failure: build chess before nvui
  Move sanWriter to ui/chess
  Nvui for puzzles: notify success
  Nvui for puzzles: add streak & replay info, polish
  Nvui for puzzles: add voting, selecting difficulty
  Improve explanation of keyboard shortcuts
  Apply existing nvui css to puzzles
  Add board to puzzle nvui
  Add move list to puzzle nvui
  Display puzzle status and controls in nvui
  Nvui for puzzles: avoid i18n for consistency
  Nvui for puzzles: explain keyboard shortcuts
  Nvui for puzzles: improve invalid move error msg
  Nvui for puzzles: add move and command input
  Don't delay puzzle moves in accessibility mode
  Extract sanWriter and common nvui functions into ui/nvui
  Create nvui module for puzzles
pull/9158/head
Thibault Duplessis 2021-06-17 10:20:08 +02:00
commit 825281a6b0
20 changed files with 618 additions and 150 deletions

View File

@ -54,6 +54,9 @@ trait AssetHelper { self: I18nHelper with SecurityHelper =>
def analyseTag = jsModule("analysisBoard")
def analyseNvuiTag(implicit ctx: Context) = ctx.blind option jsModule("analysisBoard.nvui")
def puzzleTag = jsModule("puzzle")
def puzzleNvuiTag(implicit ctx: Context) = ctx.blind option jsModule("puzzle.nvui")
def captchaTag = jsModule("captcha")
def infiniteScrollTag = jsModule("infiniteScroll")
def chessgroundTag = jsAt("javascripts/vendor/chessground.min.js")

View File

@ -19,9 +19,13 @@ object show {
val isStreak = data.value.contains("streak")
views.html.base.layout(
title = if (isStreak) "Puzzle Streak" else trans.puzzles.txt(),
moreCss = cssTag("puzzle"),
moreCss = frag(
cssTag("puzzle"),
ctx.blind option cssTag("round.nvui")
),
moreJs = frag(
jsModule("puzzle"),
puzzleTag,
puzzleNvuiTag,
embedJsUnsafeLoadThen(s"""LichessPuzzle(${safeJsonValue(
Json
.obj(

View File

@ -78,6 +78,11 @@ interface Lichess {
): {
render(ctrl: any): any;
};
PuzzleNVUI?(
redraw: () => void
): {
render(ctrl: any): any;
};
playMusic(): any;
quietMode?: boolean;
keyboardMove?: any;

View File

@ -10,6 +10,8 @@ import {
renderSan,
renderPieces,
renderBoard,
renderMainline,
renderComments,
styleSetting,
pieceSetting,
prefixSetting,
@ -299,33 +301,6 @@ function requestAnalysisButton(ctrl: AnalyseController, inProgress: Prop<boolean
);
}
function renderMainline(nodes: Tree.Node[], currentPath: Tree.Path, style: Style) {
const res: Array<string | VNode> = [];
let path: Tree.Path = '';
nodes.forEach(node => {
if (!node.san || !node.uci) return;
path += node.id;
const content: MaybeVNodes = [
node.ply & 1 ? moveView.plyToTurn(node.ply) + ' ' : null,
renderSan(node.san, node.uci, style),
];
res.push(
h(
'move',
{
attrs: { p: path },
class: { active: path === currentPath },
},
content
)
);
res.push(renderComments(node, style));
res.push(', ');
if (node.ply % 2 === 0) res.push(h('br'));
});
return res;
}
function renderCurrentNode(node: Tree.Node, style: Style): string {
if (!node.san || !node.uci) return 'Initial position';
return [moveView.plyToTurn(node.ply), renderSan(node.san, node.uci, style), renderComments(node, style)]
@ -333,17 +308,6 @@ function renderCurrentNode(node: Tree.Node, style: Style): string {
.trim();
}
function renderComments(node: Tree.Node, style: Style): string {
if (!node.comments) return '';
return (node.comments || []).map(c => renderComment(c, style)).join('. ');
}
function renderComment(comment: Tree.Comment, style: Style): string {
return comment.by === 'lichess'
? comment.text.replace(/Best move was (.+)\./, (_, san) => 'Best move was ' + renderSan(san, undefined, style))
: comment.text;
}
function renderPlayer(ctrl: AnalyseController, player: Player) {
return player.ai ? ctrl.trans('aiNameLevelAiLevel', 'Stockfish', player.ai) : userHtml(ctrl, player);
}

View File

@ -14,13 +14,13 @@ cd "$(git rev-parse --show-toplevel)"
mkdir -p public/compiled
apps1="common"
apps2="chess ceval game tree chat nvui puz"
apps1="common chess"
apps2="ceval game tree chat nvui puz"
apps3="site swiss msg chat cli challenge notify learn insight editor puzzle round analyse lobby tournament tournamentSchedule tournamentCalendar simul dasher speech palantir serviceWorker dgt storm racer"
site_plugins="tvEmbed puzzleEmbed analyseEmbed user modUser clas coordinate captcha expandText team forum account coachShow coachForm challengePage checkout plan login passwordComplexity tourForm teamBattleForm gameSearch userComplete infiniteScroll flatpickr teamAdmin appeal modGames publicChats contact userGamesDownload modActivity"
round_plugins="nvui keyboardMove"
analyse_plugins="nvui studyTopicForm"
puzzle_plugins="dashboard"
puzzle_plugins="nvui dashboard"
if [ $mode == "upgrade" ]; then
yarn upgrade --non-interactive

View File

@ -1,5 +1,7 @@
import { piotr } from './piotr';
export { SanToUci, sanWriter } from './sanWriter';
export const initialFen: Fen = 'rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1';
export function fixCrazySan(san: San): San {

View File

@ -22,6 +22,7 @@
"typescript": "^4.1.3"
},
"dependencies": {
"chess": "2.0.0",
"snabbdom": "^3.0.1"
}
}

View File

@ -1,13 +1,14 @@
import { h, VNode } from 'snabbdom';
import { Pieces, Rank, File, files } from 'chessground/types';
import { h, VNode, VNodeChildren } from 'snabbdom';
import { Dests, Pieces, Rank, File, files } from 'chessground/types';
import { invRanks, allKeys } from 'chessground/util';
//import { Api } from 'chessground/api';
import { Api } from 'chessground/api';
import { Setting, makeSetting } from './setting';
import { parseFen } from 'chessops/fen';
import { chessgroundDests } from 'chessops/compat';
import { SquareName, RULES, Rules } from 'chessops/types';
import { setupPosition } from 'chessops/variant';
import { parseUci } from 'chessops/util';
import { SanToUci, sanWriter } from 'chess';
export type Style = 'uci' | 'san' | 'literate' | 'nato' | 'anna';
export type PieceStyle = 'letter' | 'white uppercase letter' | 'name' | 'white uppercase name';
@ -633,3 +634,82 @@ export function possibleMovesHandler(
return false;
};
}
const promotionRegex = /^([a-h]x?)?[a-h](1|8)=\w$/;
const uciPromotionRegex = /^([a-h][1-8])([a-h](1|8))[qrbn]$/;
function destsToUcis(dests: Dests) {
const ucis: string[] = [];
for (const [orig, d] of dests) {
if (d)
d.forEach(function (dest) {
ucis.push(orig + dest);
});
}
return ucis;
}
function sanToUci(san: string, legalSans: SanToUci): Uci | undefined {
if (san in legalSans) return legalSans[san];
const lowered = san.toLowerCase();
for (const i in legalSans) if (i.toLowerCase() === lowered) return legalSans[i];
return;
}
export function inputToLegalUci(input: string, fen: string, chessground: Api): string | undefined {
const legalUcis = destsToUcis(chessground.state.movable.dests!),
legalSans = sanWriter(fen, legalUcis);
let uci = sanToUci(input, legalSans) || input,
promotion = '';
if (input.match(promotionRegex)) {
uci = sanToUci(input.slice(0, -2), legalSans) || input;
promotion = input.slice(-1).toLowerCase();
} else if (input.match(uciPromotionRegex)) {
uci = input.slice(0, -1);
promotion = input.slice(-1).toLowerCase();
}
if (legalUcis.includes(uci.toLowerCase())) return uci + promotion;
else return;
}
export function renderMainline(nodes: Tree.Node[], currentPath: Tree.Path, style: Style) {
const res: Array<string | VNode> = [];
let path: Tree.Path = '';
nodes.forEach(node => {
if (!node.san || !node.uci) return;
path += node.id;
const content: VNodeChildren = [
node.ply & 1 ? plyToTurn(node.ply) + ' ' : null,
renderSan(node.san, node.uci, style),
];
res.push(
h(
'move',
{
attrs: { p: path },
class: { active: path === currentPath },
},
content
)
);
res.push(renderComments(node, style));
res.push(', ');
if (node.ply % 2 === 0) res.push(h('br'));
});
return res;
}
const plyToTurn = (ply: Ply): number => Math.floor((ply - 1) / 2) + 1;
export function renderComments(node: Tree.Node, style: Style): string {
if (!node.comments) return '';
return (node.comments || []).map(c => renderComment(c, style)).join('. ');
}
function renderComment(comment: Tree.Comment, style: Style): string {
return comment.by === 'lichess'
? comment.text.replace(/Best move was (.+)\./, (_, san) => 'Best move was ' + renderSan(san, undefined, style))
: comment.text;
}

View File

@ -19,9 +19,11 @@
"dependencies": {
"ceval": "2.0.0",
"chart.js": "^2.9",
"chess": "2.0.0",
"chessground": "^7.12.0",
"chessops": "^0.9.0",
"common": "2.0.0",
"nvui": "2.0.0",
"snabbdom": "^3.0.1",
"tree": "2.0.0"
},

View File

@ -11,4 +11,9 @@ export default rollupProject({
input: 'src/dashboard.ts',
output: 'puzzle.dashboard',
},
nvui: {
name: 'NVUI',
input: 'src/plugins/nvui.ts',
output: 'puzzle.nvui',
},
});

View File

@ -19,7 +19,17 @@ 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 { Redraw, Vm, Controller, PuzzleOpts, PuzzleData, PuzzleResult, MoveTest, ThemeKey } from './interfaces';
import {
Redraw,
Vm,
Controller,
PuzzleOpts,
PuzzleData,
PuzzleResult,
MoveTest,
ThemeKey,
NvuiPlugin,
} from './interfaces';
import { Role, Move, Outcome } from 'chessops/types';
import { storedProp } from 'common/storage';
@ -88,7 +98,7 @@ export default function (opts: PuzzleOpts, redraw: Redraw): Controller {
vm.initialNode = tree.nodeAtPath(initialPath);
vm.pov = vm.initialNode.ply % 2 == 1 ? 'black' : 'white';
setPath(treePath.init(initialPath));
setPath(lichess.PuzzleNVUI ? initialPath : treePath.init(initialPath));
setTimeout(() => {
jump(initialPath);
redraw();
@ -222,12 +232,15 @@ export default function (opts: PuzzleOpts, redraw: Redraw): Controller {
if (recursive) node.children.forEach(child => reorderChildren(path + child.id, true));
}
function instantRevertUserMove(): void {
withGround(g => g.cancelPremove());
userJump(treePath.init(vm.path));
redraw();
}
function revertUserMove(): void {
setTimeout(() => {
withGround(g => g.cancelPremove());
userJump(treePath.init(vm.path));
redraw();
}, 100);
if (lichess.PuzzleNVUI) instantRevertUserMove();
else setTimeout(instantRevertUserMove, 100);
}
function applyProgress(progress: undefined | 'fail' | 'win' | MoveTest): void {
@ -563,5 +576,6 @@ export default function (opts: PuzzleOpts, redraw: Redraw): Controller {
skip,
flip,
flipped: () => flipped,
nvui: lichess.PuzzleNVUI ? (lichess.PuzzleNVUI(redraw) as NvuiPlugin) : undefined,
};
}

View File

@ -72,6 +72,12 @@ export interface Controller extends KeyboardController {
path?: Tree.Path;
autoScrollRequested?: boolean;
nvui?: NvuiPlugin;
}
export interface NvuiPlugin {
render(ctrl: Controller): VNode;
}
export interface Vm {

View File

@ -0,0 +1,424 @@
import { h, VNode } from 'snabbdom';
import { Controller, Redraw } from '../interfaces';
import { puzzleBox, renderDifficultyForm, userBox } from '../view/side';
import theme from '../view/theme';
import {
arrowKeyHandler,
boardCommandsHandler,
boardSetting,
castlingFlavours,
inputToLegalUci,
lastCapturedCommandHandler,
pieceJumpingHandler,
pieceSetting,
positionJumpHandler,
positionSetting,
possibleMovesHandler,
prefixSetting,
renderBoard,
renderMainline,
renderPieces,
renderSan,
selectionHandler,
Style,
styleSetting,
} from 'nvui/chess';
import { Chessground } from 'chessground';
import { makeConfig } from '../view/chessground';
import { renderSetting } from 'nvui/setting';
import { Notify } from 'nvui/notify';
import { commands } from 'nvui/command';
import * as control from '../control';
import { bind, onInsert } from '../util';
import { Api } from 'chessground/api';
import throttle from 'common/throttle';
const throttled = (sound: string) => throttle(100, () => lichess.sound.play(sound));
const selectSound = throttled('select');
const wrapSound = throttled('wrapAround');
const borderSound = throttled('outOfBound');
const errorSound = throttled('error');
lichess.PuzzleNVUI = function (redraw: Redraw) {
const notify = new Notify(redraw),
moveStyle = styleSetting(),
prefixStyle = prefixSetting(),
pieceStyle = pieceSetting(),
positionStyle = positionSetting(),
boardStyle = boardSetting();
return {
render(ctrl: Controller): VNode {
const ground = ctrl.ground() || createGround(ctrl);
return h(
`main.puzzle.puzzle-${ctrl.getData().replay ? 'replay' : 'play'}${ctrl.streak ? '.puzzle--streak' : ''}`,
h('div.nvui', [
h('h1', `Puzzle: ${ctrl.vm.pov} to play.`),
h('h2', 'Puzzle info'),
puzzleBox(ctrl),
theme(ctrl),
!ctrl.streak ? userBox(ctrl) : null,
h('h2', 'Moves'),
h(
'p.moves',
{
attrs: {
role: 'log',
'aria-live': 'off',
},
},
renderMainline(ctrl.vm.mainline, ctrl.vm.path, moveStyle.get())
),
h('h2', 'Pieces'),
h('div.pieces', renderPieces(ground.state.pieces, moveStyle.get())),
h('h2', 'Puzzle status'),
h(
'div.status',
{
attrs: {
role: 'status',
'aria-live': 'polite',
'aria-atomic': 'true',
},
},
renderStatus(ctrl)
),
h('div.replay', renderReplay(ctrl)),
...(ctrl.streak ? renderStreak(ctrl) : []),
h('h2', 'Last move'),
h(
'p.lastMove',
{
attrs: {
'aria-live': 'assertive',
'aria-atomic': 'true',
},
},
lastMove(ctrl, moveStyle.get())
),
h('h2', 'Move form'),
h(
'form',
{
hook: onInsert(el => {
const $form = $(el),
$input = $form.find('.move').val('');
$input[0]!.focus();
$form.on('submit', onSubmit(ctrl, notify.set, moveStyle.get, $input, ground));
}),
},
[
h('label', [
ctrl.vm.mode === 'view' ? 'Command input' : `Find the best move for ${ctrl.vm.pov}.`,
h('input.move.mousetrap', {
attrs: {
name: 'move',
type: 'text',
autocomplete: 'off',
autofocus: true,
},
}),
]),
]
),
notify.render(),
h('h2', 'Actions'),
ctrl.vm.mode === 'view' ? afterActions(ctrl) : playActions(ctrl),
h('h2', 'Board'),
h(
'div.board',
{
hook: onInsert(el => {
const $board = $(el);
const $buttons = $board.find('button');
const steps = () => ctrl.getTree().getNodeList(ctrl.vm.path);
const uciSteps = () => steps().filter(hasUci);
const fenSteps = () => steps().map(step => step.fen);
const opponentColor = ctrl.vm.pov === 'white' ? 'black' : 'white';
$board.on('click', selectionHandler(opponentColor, selectSound));
$board.on('keypress', arrowKeyHandler(ctrl.vm.pov, borderSound));
$board.on('keypress', boardCommandsHandler());
$buttons.on('keypress', lastCapturedCommandHandler(fenSteps, pieceStyle.get(), prefixStyle.get()));
$buttons.on(
'keypress',
possibleMovesHandler(
ctrl.vm.pov,
() => ground.state.turnColor,
ground.getFen,
() => ground.state.pieces,
'standard',
() => ground.state.movable.dests,
uciSteps
)
);
$buttons.on('keypress', positionJumpHandler());
$buttons.on('keypress', pieceJumpingHandler(wrapSound, errorSound));
}),
},
renderBoard(
ground.state.pieces,
ctrl.vm.pov,
pieceStyle.get(),
prefixStyle.get(),
positionStyle.get(),
boardStyle.get()
)
),
h(
'div.boardstatus',
{
attrs: {
'aria-live': 'polite',
'aria-atomic': 'true',
},
},
''
),
h('h2', 'Settings'),
h('label', ['Move notation', renderSetting(moveStyle, ctrl.redraw)]),
h('h3', 'Board Settings'),
h('label', ['Piece style', renderSetting(pieceStyle, ctrl.redraw)]),
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.difficulty
? [h('h3', 'Puzzle Settings'), renderDifficultyForm(ctrl)]
: []),
h('h2', 'Keyboard shortcuts'),
h('p', [
'Left and right arrow keys or k and j: Navigate to the previous or next move.',
h('br'),
'Up and down arrow keys or 0 and $: Jump to the first or last move.',
]),
h('h2', 'Commands'),
h('p', [
'Type these commands in the move input.',
h('br'),
'v: View the solution.',
h('br'),
'l: Read last move.',
h('br'),
commands.piece.help,
h('br'),
commands.scan.help,
h('br'),
]),
h('h2', 'Board Mode commands'),
h('p', [
'Use these commands when focused on the board itself.',
h('br'),
'o: announce current position.',
h('br'),
"c: announce last move's captured piece.",
h('br'),
'l: announce last move.',
h('br'),
't: announce clocks.',
h('br'),
'm: announce possible moves for the selected piece.',
h('br'),
'shift+m: announce possible moves for the selected pieces which capture..',
h('br'),
'arrow keys: move left, right, up or down.',
h('br'),
'kqrbnp/KQRBNP: move forward/backward to a piece.',
h('br'),
'1-8: move to rank 1-8.',
h('br'),
'Shift+1-8: move to file a-h.',
h('br'),
]),
h('h2', 'Promotion'),
h('p', [
'Standard PGN notation selects the piece to promote to. Example: a8=n promotes to a knight.',
h('br'),
'Omission results in promotion to queen',
]),
])
);
},
};
};
interface StepWithUci extends Tree.Node {
uci: Uci;
}
function hasUci(step: Tree.Node): step is StepWithUci {
return step.uci !== undefined;
}
function lastMove(ctrl: Controller, style: Style): string {
const node = ctrl.vm.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 createGround(ctrl: Controller): Api {
const ground = Chessground(document.createElement('div'), {
...makeConfig(ctrl),
animation: { enabled: false },
drawable: { enabled: false },
coordinates: false,
});
ctrl.ground(ground);
return ground;
}
function onSubmit(
ctrl: Controller,
notify: (txt: string) => void,
style: () => Style,
$input: Cash,
ground: Api
): () => false {
return () => {
let input = castlingFlavours(($input.val() as string).trim());
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);
if (uci) {
ctrl.playUci(uci);
if (ctrl.vm.lastFeedback === 'fail') notify("That's not the move!");
else if (ctrl.vm.lastFeedback === 'win') notify('Success!');
} else {
notify([`Invalid move: ${input}`, ...browseHint(ctrl)].join('. '));
}
}
$input.val('');
return false;
};
}
function isYourMove(ctrl: Controller) {
return ctrl.vm.node.children.length === 0 || ctrl.vm.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.'];
else return [];
}
const shortCommands = ['l', 'last', 'p', 's', 'v'];
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 {
const lowered = c.toLowerCase();
const pieces = ctrl.ground()!.state.pieces;
if (lowered === 'l' || lowered === 'last') notify($('.lastMove').text());
else if (lowered === 'v') viewOrAdvanceSolution(ctrl, notify);
else
notify(commands.piece.apply(c, pieces, style) || commands.scan.apply(c, pieces, style) || `Invalid command: ${c}`);
}
function viewOrAdvanceSolution(ctrl: Controller, notify: (txt: string) => void): void {
if (ctrl.vm.mode === 'view') {
const node = ctrl.vm.node,
next = nextNode(node),
nextNext = nextNode(next);
if (isInSolution(next) || (isInSolution(node) && isInSolution(nextNext))) {
control.next(ctrl);
ctrl.redraw();
} else if (isInSolution(node)) {
notify('Puzzle complete!');
} else {
ctrl.viewSolution();
}
} else {
ctrl.viewSolution();
}
}
function isInSolution(node?: Tree.Node): boolean {
return !!node && (node.puzzle === 'good' || node.puzzle === 'win');
}
function nextNode(node?: Tree.Node): Tree.Node | undefined {
if (node?.children?.length) return node.children[0];
else return;
}
function renderStreak(ctrl: Controller): 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';
else if (ctrl.streak) return `GAME OVER. Your streak: ${ctrl.streak.data.index}`;
else if (ctrl.vm.lastFeedback === 'win') return 'Puzzle solved!';
else return 'Puzzle complete.';
}
function renderReplay(ctrl: Controller): string {
const replay = ctrl.getData().replay;
if (!replay) return '';
const i = replay.i + (ctrl.vm.mode === 'play' ? 0 : 1);
return `Replaying ${ctrl.trans.noarg(ctrl.getData().theme.key)} puzzles: ${i} of ${replay.of}`;
}
function playActions(ctrl: Controller): VNode {
if (ctrl.streak)
return button(
ctrl.trans.noarg('skip'),
ctrl.skip,
ctrl.trans.noarg('streakSkipExplanation'),
!ctrl.streak.data.skip
);
else return h('div.actions_play', button('View the solution', ctrl.viewSolution));
}
function afterActions(ctrl: Controller): VNode {
const win = ctrl.vm.lastFeedback === 'win';
return h(
'div.actions_after',
ctrl.streak && !win
? anchor(ctrl.trans.noarg('newStreak'), '/streak')
: [...renderVote(ctrl), button('Continue training', ctrl.nextPuzzle)]
);
}
const renderVoteTutorial = (ctrl: Controller): VNode[] =>
ctrl.session.isNew() && ctrl.getData().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 [];
return [
...renderVoteTutorial(ctrl),
button('Thumbs up', () => ctrl.vote(true), undefined, ctrl.vm.voteDisabled),
button('Thumbs down', () => ctrl.vote(false), undefined, ctrl.vm.voteDisabled),
];
}
function anchor(text: string, href: string): VNode {
return h(
'a',
{
attrs: { href },
},
text
);
}
function button(text: string, action: (e: Event) => void, title?: string, disabled?: boolean): VNode {
return h(
'button',
{
hook: bind('click', action),
attrs: {
...(title ? { title } : {}),
disabled: !!disabled,
},
},
text
);
}

View File

@ -14,7 +14,7 @@ export default function (ctrl: Controller): VNode {
});
}
function makeConfig(ctrl: Controller): CgConfig {
export function makeConfig(ctrl: Controller): CgConfig {
const opts = ctrl.makeCgOpts();
return {
fen: opts.fen,

View File

@ -74,6 +74,7 @@ function controls(ctrl: Controller): VNode {
let cevalShown = false;
export default function (ctrl: Controller): VNode {
if (ctrl.nvui) return ctrl.nvui.render(ctrl);
const showCeval = ctrl.vm.showComputer(),
gaugeOn = ctrl.showEvalGauge();
if (cevalShown !== showCeval) {

View File

@ -195,53 +195,7 @@ export function config(ctrl: Controller): MaybeVNode {
]),
h('label', { attrs: { for: id } }, ctrl.trans.noarg('jumpToNextPuzzleImmediately')),
]),
!ctrl.getData().replay && !ctrl.streak && ctrl.difficulty
? h(
'form.puzzle__side__config__difficulty',
{
attrs: {
action: `/training/difficulty/${ctrl.getData().theme.key}`,
method: 'post',
},
},
[
h(
'label',
{
attrs: { for: 'puzzle-difficulty' },
},
ctrl.trans.noarg('difficultyLevel')
),
h(
'select#puzzle-difficulty.puzzle__difficulty__selector',
{
attrs: { name: 'difficulty' },
hook: onInsert(elm =>
elm.addEventListener('change', () => (elm.parentNode as HTMLFormElement).submit())
),
},
difficulties.map(([key, delta]) =>
h(
'option',
{
attrs: {
value: key,
selected: key == ctrl.difficulty,
title:
!!delta &&
ctrl.trans.plural(
delta < 0 ? 'nbPointsBelowYourPuzzleRating' : 'nbPointsAboveYourPuzzleRating',
Math.abs(delta)
),
},
},
[ctrl.trans.noarg(key), delta ? ` (${delta > 0 ? '+' : ''}${delta})` : '']
)
)
),
]
)
: null,
!ctrl.getData().replay && !ctrl.streak && ctrl.difficulty ? renderDifficultyForm(ctrl) : null,
h('div.puzzle__side__config__toggles', [
h(
'a.puzzle__side__config__zen.button.button-empty',
@ -267,3 +221,49 @@ export function config(ctrl: Controller): MaybeVNode {
]),
]);
}
export function renderDifficultyForm(ctrl: Controller): VNode {
return h(
'form.puzzle__side__config__difficulty',
{
attrs: {
action: `/training/difficulty/${ctrl.getData().theme.key}`,
method: 'post',
},
},
[
h(
'label',
{
attrs: { for: 'puzzle-difficulty' },
},
ctrl.trans.noarg('difficultyLevel')
),
h(
'select#puzzle-difficulty.puzzle__difficulty__selector',
{
attrs: { name: 'difficulty' },
hook: onInsert(elm => elm.addEventListener('change', () => (elm.parentNode as HTMLFormElement).submit())),
},
difficulties.map(([key, delta]) =>
h(
'option',
{
attrs: {
value: key,
selected: key == ctrl.difficulty,
title:
!!delta &&
ctrl.trans.plural(
delta < 0 ? 'nbPointsBelowYourPuzzleRating' : 'nbPointsAboveYourPuzzleRating',
Math.abs(delta)
),
},
},
[ctrl.trans.noarg(key), delta ? ` (${delta > 0 ? '+' : ''}${delta})` : '']
)
)
),
]
);
}

View File

@ -18,6 +18,7 @@
"dependencies": {
"ab": "https://github.com/lichess-org/ab-stub",
"chat": "2.0.0",
"chess": "2.0.0",
"chessground": "^7.12.0",
"common": "2.0.0",
"game": "2.0.0",

View File

@ -1,5 +1,5 @@
import { Dests } from '../interfaces';
import { sanWriter, SanToUci } from './sanWriter';
import { sanWriter, SanToUci } from 'chess';
import { KeyboardMove } from '../keyboardMove';
const keyRegex = /^[a-h][1-8]$/;

View File

@ -1,5 +1,4 @@
import { h, VNode } from 'snabbdom';
import { sanWriter, SanToUci } from './sanWriter';
import RoundController from '../ctrl';
import { renderClock } from '../clock/clockView';
import { renderTableWatch, renderTablePlay, renderTableEnd } from '../view/table';
@ -9,7 +8,7 @@ import renderCorresClock from '../corresClock/corresClockView';
import { renderResult } from '../view/replay';
import { plyStep } from '../round';
import { onInsert } from '../util';
import { Step, Dests, Position, Redraw } from '../interfaces';
import { Step, Position, Redraw } from '../interfaces';
import * as game from 'game';
import {
renderSan,
@ -30,6 +29,7 @@ import {
castlingFlavours,
supportedVariant,
Style,
inputToLegalUci,
} from 'nvui/chess';
import { renderSetting } from 'nvui/setting';
import { Notify } from 'nvui/notify';
@ -288,9 +288,6 @@ lichess.RoundNVUI = function (redraw: Redraw) {
};
};
const promotionRegex = /^([a-h]x?)?[a-h](1|8)=\w$/;
const uciPromotionRegex = /^([a-h][1-8])([a-h](1|8))[qrbn]$/;
function onSubmit(ctrl: RoundController, notify: (txt: string) => void, style: () => Style, $input: Cash) {
return () => {
let input = castlingFlavours(($input.val() as string).trim());
@ -298,31 +295,8 @@ function onSubmit(ctrl: RoundController, notify: (txt: string) => void, style: (
if (input[0] === '/') onCommand(ctrl, notify, input.slice(1), style());
else {
const d = ctrl.data,
legalUcis = destsToUcis(ctrl.chessground.state.movable.dests!),
legalSans: SanToUci = sanWriter(plyStep(d, ctrl.ply).fen, legalUcis) as SanToUci;
let uci = sanToUci(input, legalSans) || input,
promotion = '';
if (input.match(promotionRegex)) {
uci = sanToUci(input.slice(0, -2), legalSans) || input;
promotion = input.slice(-1).toLowerCase();
} else if (input.match(uciPromotionRegex)) {
uci = input.slice(0, -1);
promotion = input.slice(-1).toLowerCase();
}
console.log(uci);
console.log(uci.slice(0, -1));
console.log(promotion);
console.log(legalSans);
if (legalUcis.includes(uci.toLowerCase()))
ctrl.socket.send(
'move',
{
u: uci + promotion,
},
{ ackable: true }
);
uci = inputToLegalUci(input, plyStep(d, ctrl.ply).fen, ctrl.chessground);
if (uci) ctrl.socket.send('move', { u: uci }, { ackable: true });
else notify(d.player.color === d.game.player ? `Invalid move: ${input}` : 'Not your turn');
}
$input.val('');
@ -361,24 +335,6 @@ function anyClock(ctrl: RoundController, position: Position) {
);
}
function destsToUcis(dests: Dests) {
const ucis: string[] = [];
for (const [orig, d] of dests) {
if (d)
d.forEach(function (dest) {
ucis.push(orig + dest);
});
}
return ucis;
}
function sanToUci(san: string, legalSans: SanToUci): Uci | undefined {
if (san in legalSans) return legalSans[san];
const lowered = san.toLowerCase();
for (const i in legalSans) if (i.toLowerCase() === lowered) return legalSans[i];
return;
}
function renderMoves(steps: Step[], style: Style) {
const res: Array<string | VNode> = [];
steps.forEach(s => {