use chessops to validate positions in editor

pull/5670/head
Niklas Fiekas 2019-11-23 19:23:37 +01:00
parent 2d2529b5e5
commit f4ea296b48
7 changed files with 169 additions and 141 deletions

View File

@ -17,6 +17,7 @@
},
"dependencies": {
"chessground": "^7.6",
"chessops": "^0.1.0",
"snabbdom": "ornicar/snabbdom#0.7.1-lichess",
"common": "2.0.0"
}

View File

@ -1,23 +1,34 @@
import { EditorConfig, EditorData, EditorOptions, Selected, Redraw, OpeningPosition, CastlingSide } from './interfaces';
import * as editor from './editor';
import { read as fenRead } from 'chessground/fen';
import { EditorConfig, EditorOptions, EditorState, Selected, Redraw, OpeningPosition, CastlingToggle, CastlingToggles, CASTLING_TOGGLES } from './interfaces';
import { Api as CgApi } from 'chessground/api';
import { prop, Prop } from 'common';
import { Rules, Square } from 'chessops/types';
import { SquareSet } from 'chessops/squareSet';
import { Board } from 'chessops/board';
import { Setup, Material, RemainingChecks } from 'chessops/setup';
import { Castles, setupPosition } from 'chessops/variant';
import { makeFen, parseFen, parseCastlingFen, INITIAL_FEN, EMPTY_FEN } from 'chessops/fen';
import { defined, prop, Prop } from 'common';
export default class EditorCtrl {
cfg: EditorConfig;
data: EditorData;
options: EditorOptions;
trans: Trans;
selected: Prop<Selected>;
extraPositions: OpeningPosition[];
chessground: CgApi | undefined;
positionIndex: { [boardFen: string]: number };
redraw: Redraw;
selected: Prop<Selected>;
pockets: Material | undefined;
turn: Color;
unmovedRooks: SquareSet | undefined;
castlingToggles: CastlingToggles<boolean>;
epSquare: Square | undefined;
remainingChecks: RemainingChecks | undefined;
rules: Rules;
constructor(cfg: EditorConfig, redraw: Redraw) {
this.cfg = cfg;
this.data = editor.init(cfg);
this.options = cfg.options || {};
this.trans = window.lichess.trans(this.cfg.i18n);
@ -46,18 +57,71 @@ export default class EditorCtrl {
redraw();
});
this.castlingToggles = { K: false, Q: false, k: false, q: false };
this.rules = 'chess';
this.redraw = () => {};
this.setFen(cfg.fen);
this.redraw = redraw;
}
onChange(): void {
this.options.onChange && this.options.onChange(this.computeFen());
this.options.onChange && this.options.onChange(this.getLegalFen());
this.redraw();
}
computeFen(): string {
return this.chessground ?
editor.computeFen(this.data, this.chessground.getFen()) :
this.cfg.fen;
private castlingToggleFen(): string {
let fen = '';
for (const toggle of CASTLING_TOGGLES) {
if (this.castlingToggles[toggle]) fen += toggle;
}
return fen;
}
private getSetup(): Setup {
const boardFen = this.chessground ? this.chessground.getFen() : this.cfg.fen;
const board = parseFen(boardFen).unwrap(setup => setup.board, _ => Board.empty());
return {
board,
pockets: this.pockets,
turn: this.turn,
unmovedRooks: this.unmovedRooks || parseCastlingFen(board, this.castlingToggleFen()).unwrap(),
epSquare: this.epSquare,
remainingChecks: this.remainingChecks,
halfmoves: 0,
fullmoves: 1,
};
}
getFen(): string {
return makeFen(this.getSetup(), {promoted: this.rules == 'crazyhouse'});
}
getLegalFen(): string | undefined {
return setupPosition(this.rules, this.getSetup()).unwrap(pos => {
return makeFen(pos.toSetup(), {promoted: pos.rules == 'crazyhouse'});
}, _ => undefined);
}
isPlayable(): boolean {
return setupPosition(this.rules, this.getSetup()).unwrap(pos => !pos.isEnd(), _ => false);
}
getState(): EditorState {
return {
fen: this.getFen(),
legalFen: this.getLegalFen(),
playable: this.isPlayable(),
};
}
makeAnalysisUrl(legalFen: string): string {
return this.makeUrl('', legalFen); // TODO
}
makeUrl(baseUrl: string, fen: string): string {
return baseUrl + encodeURIComponent(fen).replace(/%20/g, '_').replace(/%2F/g, '/');
}
bottomColor(): Color {
@ -66,31 +130,23 @@ export default class EditorCtrl {
this.options.orientation || 'white';
}
setColor(letter: 'w' | 'b'): void {
this.data.color(letter);
setCastlingToggle(id: CastlingToggle, value: boolean): void {
if (this.castlingToggles[id] != value) this.unmovedRooks = undefined;
this.castlingToggles[id] = value;
this.onChange();
}
setCastle(id: CastlingSide, value: boolean): void {
this.data.castles[id](value);
setTurn(turn: Color): void {
this.turn = turn;
this.onChange();
}
startPosition(): void {
this.chessground!.set({
fen: 'start'
});
this.data.castles = editor.castlesAt(true);
this.data.color('w');
this.onChange();
this.setFen(INITIAL_FEN);
}
clearBoard(): void {
this.chessground!.set({
fen: '8/8/8/8/8/8/8/8'
});
this.data.castles = editor.castlesAt(false);
this.onChange();
this.setFen(EMPTY_FEN);
}
loadNewFen(fen: string | 'prompt'): void {
@ -98,31 +154,36 @@ export default class EditorCtrl {
fen = (prompt('Paste FEN position') || '').trim();
if (!fen) return;
}
this.changeFen(fen);
this.setFen(fen);
}
changeFen(fen: string) {
window.location.href = editor.makeUrl(this.data.baseUrl, fen);
setFen(fen: string): boolean {
return parseFen(fen).unwrap(setup => {
if (this.chessground) this.chessground.set({fen});
this.pockets = setup.pockets;
this.turn = setup.turn;
this.unmovedRooks = setup.unmovedRooks;
this.epSquare = setup.epSquare;
this.remainingChecks = setup.remainingChecks;
const castles = Castles.fromSetup(setup);
this.castlingToggles['K'] = defined(castles.rook.white.h);
this.castlingToggles['Q'] = defined(castles.rook.white.a);
this.castlingToggles['k'] = defined(castles.rook.white.h);
this.castlingToggles['q'] = defined(castles.rook.white.a);
this.onChange();
return true;
}, _ => false);
}
changeVariant(variant: VariantKey): void {
this.data.variant = variant;
this.redraw();
}
positionLooksLegit(): boolean {
const variant = this.data.variant;
if (variant === 'antichess') return true;
const pieces = this.chessground ? this.chessground.state.pieces : fenRead(this.cfg.fen);
const kings = {
white: 0,
black: 0
};
for (const pos in pieces) {
const piece = pieces[pos];
if (piece && piece.role === 'king') kings[piece.color]++;
}
return kings.white === (variant !== 'horde' ? 1 : 0) && kings.black === 1;
setRules(rules: Rules): void {
this.rules = rules;
if (rules != 'crazyhouse') this.pockets = undefined;
else if (!this.pockets) this.pockets = Material.empty();
if (rules != '3check') this.remainingChecks = undefined;
else if (!this.remainingChecks) this.remainingChecks = RemainingChecks.default();
this.onChange();
}
setOrientation(o: Color): void {

View File

@ -1,41 +0,0 @@
import { prop, Prop } from 'common';
import { EditorConfig, EditorData, Castles, CASTLING_SIDES } from './interfaces';
export function init(cfg: EditorConfig): EditorData {
return {
color: prop(cfg.color),
castles: {
K: prop(cfg.castles.K),
Q: prop(cfg.castles.Q),
k: prop(cfg.castles.k),
q: prop(cfg.castles.q),
},
baseUrl: cfg.baseUrl,
variant: 'standard'
};
}
export function castlesAt(v: boolean): Castles<Prop<boolean>> {
return {
K: prop(v),
Q: prop(v),
k: prop(v),
q: prop(v),
};
}
function fenMetadatas(data: EditorData): string {
let castles = '';
for (const side of CASTLING_SIDES) {
if (data.castles[side]()) castles += side;
}
return `${data.color()} ${castles.length ? castles : '-'} -`;
}
export function computeFen(data: EditorData, cgFen: string): string {
return `${cgFen} ${fenMetadatas(data)}`;
}
export function makeUrl(url: string, fen: string): string {
return url + encodeURIComponent(fen).replace(/%20/g, '_').replace(/%2F/g, '/');
}

View File

@ -1,12 +1,11 @@
import { Prop } from 'common';
import { Role } from 'chessground/types';
export type CastlingSide = 'K' | 'Q' | 'k' | 'q';
export type CastlingToggle = 'K' | 'Q' | 'k' | 'q';
export const CASTLING_SIDES: CastlingSide[] = ['K', 'Q', 'k', 'q'];
export const CASTLING_TOGGLES: CastlingToggle[] = ['K', 'Q', 'k', 'q'];
export type Castles<T> = {
[side in CastlingSide]: T;
export type CastlingToggles<T> = {
[side in CastlingToggle]: T;
};
export interface OpeningPosition {
@ -25,22 +24,19 @@ export interface EditorConfig {
};
embed: boolean;
positions?: OpeningPosition[];
color: 'w' | 'b';
i18n: any;
castles: Castles<boolean>;
}
export interface EditorOptions {
orientation?: Color;
onChange?: (fen: string) => void;
onChange?: (fen: string | undefined) => void;
inlineCastling?: boolean;
}
export interface EditorData {
baseUrl: string;
color: Prop<'w' | 'b'>;
castles: Castles<Prop<boolean>>;
variant: VariantKey;
export interface EditorState {
fen: string;
legalFen: string | undefined;
playable: boolean;
}
export type Redraw = () => void;

View File

@ -29,7 +29,8 @@ window.LichessEditor = (element: HTMLElement, config: EditorConfig) => {
vnode = patch(inner, view(ctrl));
return {
getFen: ctrl.computeFen.bind(ctrl),
getFen: ctrl.getFen.bind(ctrl),
getLegalFen: ctrl.getLegalFen.bind(ctrl),
setOrientation: ctrl.setOrientation.bind(ctrl)
};
};

View File

@ -3,20 +3,20 @@ import { VNode } from 'snabbdom/vnode';
import { MouchEvent, NumberPair } from 'chessground/types';
import { dragNewPiece } from 'chessground/drag';
import { eventPosition, opposite } from 'chessground/util';
import { Rules } from 'chessops/types';
import EditorCtrl from './ctrl';
import chessground from './chessground';
import * as editor from './editor';
import { OpeningPosition, Selected } from './interfaces';
import { OpeningPosition, Selected, CastlingToggle, EditorState } from './interfaces';
function castleCheckBox(ctrl: EditorCtrl, id: 'K' | 'Q' | 'k' | 'q', label: string, reversed: boolean): VNode {
function castleCheckBox(ctrl: EditorCtrl, id: CastlingToggle, label: string, reversed: boolean): VNode {
const input = h('input', {
attrs: {
type: 'checkbox',
checked: ctrl.data.castles[id](),
checked: ctrl.castlingToggles[id],
},
on: {
change(e) {
ctrl.setCastle(id, (e.target as HTMLInputElement).checked);
ctrl.setCastlingToggle(id, (e.target as HTMLInputElement).checked);
}
}
});
@ -27,7 +27,7 @@ function optgroup(name: string, opts: VNode[]): VNode {
return h('optgroup', { attrs: { label: name } }, opts);
}
function studyButton(ctrl: EditorCtrl, fen: string): VNode {
function studyButton(ctrl: EditorCtrl, state: EditorState): VNode {
return h('form', {
attrs: {
method: 'post',
@ -35,46 +35,46 @@ function studyButton(ctrl: EditorCtrl, fen: string): VNode {
}
}, [
h('input', { attrs: { type: 'hidden', name: 'orientation', value: ctrl.bottomColor() } }),
h('input', { attrs: { type: 'hidden', name: 'variant', value: ctrl.data.variant } }),
h('input', { attrs: { type: 'hidden', name: 'fen', value: fen } }),
h('input', { attrs: { type: 'hidden', name: 'variant', value: ctrl.rules } }),
h('input', { attrs: { type: 'hidden', name: 'fen', value: state.legalFen || '' } }),
h('button', {
attrs: {
type: 'submit',
'data-icon': '4',
disabled: !ctrl.positionLooksLegit(),
disabled: !state.legalFen,
},
class: {
button: true,
'button-empty': true,
text: true,
disabled: !ctrl.positionLooksLegit()
disabled: !state.legalFen,
}
}, 'Study')
]);
}
function variant2option(key: VariantKey, name: string, ctrl: EditorCtrl): VNode {
function variant2option(key: Rules, name: string, ctrl: EditorCtrl): VNode {
return h('option', {
attrs: {
value: key,
selected: key == ctrl.data.variant
selected: key == ctrl.rules
},
}, `${ctrl.trans.noarg('variant')} | ${name}`);
}
const allVariants: Array<[VariantKey, string]> = [
['standard', 'Standard'],
const allVariants: Array<[Rules, string]> = [
['chess', 'Standard'],
['antichess', 'Antichess'],
['atomic', 'Atomic'],
['crazyhouse', 'Crazyhouse'],
['horde', 'Horde'],
['kingOfTheHill', 'King of the Hill'],
['racingKings', 'Racing Kings'],
['threeCheck', 'Three-check'],
['kingofthehill', 'King of the Hill'],
['racingkings', 'Racing Kings'],
['3check', 'Three-check'],
];
function controls(ctrl: EditorCtrl, fen: string): VNode {
const positionIndex = ctrl.positionIndex[fen.split(' ')[0]];
function controls(ctrl: EditorCtrl, state: EditorState): VNode {
const positionIndex = ctrl.positionIndex[state.fen.split(' ')[0]];
const currentPosition = ctrl.cfg.positions && positionIndex !== -1 ? ctrl.cfg.positions[positionIndex] : null;
const position2option = function(pos: OpeningPosition): VNode {
return h('option', {
@ -84,8 +84,6 @@ function controls(ctrl: EditorCtrl, fen: string): VNode {
}
}, pos.eco ? `${pos.eco} ${pos.name}` : pos.name);
};
const selectedVariant = ctrl.data.variant;
const looksLegit = ctrl.positionLooksLegit();
return h('div.board-editor__tools', [
...(ctrl.cfg.embed || !ctrl.cfg.positions ? [] : [h('div', [
h('select.positions', {
@ -98,7 +96,7 @@ function controls(ctrl: EditorCtrl, fen: string): VNode {
optgroup(ctrl.trans.noarg('setTheBoard'), [
...(currentPosition ? [] : [h('option', {
attrs: {
value: fen,
value: state.fen,
selected: true
}
}, `- ${ctrl.trans.noarg('boardEditor')} -`)]),
@ -112,14 +110,14 @@ function controls(ctrl: EditorCtrl, fen: string): VNode {
h('select', {
on: {
change(e) {
ctrl.setColor((e.target as HTMLSelectElement).value as 'w' | 'b');
ctrl.setTurn((e.target as HTMLSelectElement).value as Color);
}
}
}, ['whitePlays', 'blackPlays'].map(function(key) {
return h('option', {
attrs: {
value: key[0],
selected: ctrl.data.color() === key[0]
value: key[0] == 'w' ? 'white' : 'black',
selected: ctrl.turn[0] === key[0]
}
}, ctrl.trans(key));
}))
@ -157,7 +155,7 @@ function controls(ctrl: EditorCtrl, fen: string): VNode {
attrs: { id: 'variants' },
on: {
change(e) {
ctrl.changeVariant((e.target as HTMLSelectElement).value as VariantKey);
ctrl.setRules((e.target as HTMLSelectElement).value as Rules);
}
}
}, allVariants.map(x => variant2option(x[0], x[1], ctrl)))
@ -175,39 +173,39 @@ function controls(ctrl: EditorCtrl, fen: string): VNode {
attrs: {
'data-icon': 'A',
rel: 'nofollow',
...(looksLegit ? { href: editor.makeUrl('/analysis/' + selectedVariant + '/', fen) } : {})
...(state.legalFen ? { href: ctrl.makeAnalysisUrl(state.legalFen) } : {})
},
class: {
button: true,
'button-empty': true,
text: true,
disabled: !looksLegit
disabled: !state.legalFen
}
}, ctrl.trans.noarg('analysis')),
h('a', {
class: {
button: true,
'button-empty': true,
disabled: !looksLegit || selectedVariant !== 'standard'
disabled: !state.playable || ctrl.rules !== 'chess'
},
on: {
click: () => {
if (ctrl.positionLooksLegit() && selectedVariant === 'standard') $.modal($('.continue-with'));
if (state.playable && ctrl.rules === 'chess') $.modal($('.continue-with'));
}
}
}, [h('span.text', { attrs: { 'data-icon' : 'U' } }, ctrl.trans.noarg('continueFromHere'))]),
studyButton(ctrl, fen)
studyButton(ctrl, state)
]),
h('div.continue-with.none', [
h('a.button', {
attrs: {
href: '/?fen=' + fen + '#ai',
href: '/?fen=' + state.legalFen + '#ai',
rel: 'nofollow'
}
}, ctrl.trans.noarg('playWithTheMachine')),
h('a.button', {
attrs: {
href: '/?fen=' + fen + '#friend',
href: '/?fen=' + state.legalFen + '#friend',
rel: 'nofollow'
}
}, ctrl.trans.noarg('playWithAFriend'))
@ -229,7 +227,7 @@ function inputs(ctrl: EditorCtrl, fen: string): VNode | undefined {
on: {
change(e) {
const value = (e.target as HTMLInputElement).value;
if (value !== fen) ctrl.changeFen(value);
if (value !== fen) ctrl.setFen(value);
}
}
})
@ -240,7 +238,7 @@ function inputs(ctrl: EditorCtrl, fen: string): VNode | undefined {
attrs: {
readonly: true,
spellcheck: false,
value: editor.makeUrl(ctrl.data.baseUrl, fen)
value: ctrl.makeUrl(ctrl.cfg.baseUrl, fen)
}
})
])
@ -328,7 +326,7 @@ function makeCursor(selected: Selected): string {
}
export default function(ctrl: EditorCtrl): VNode {
const fen = ctrl.computeFen();
const state = ctrl.getState();
const color = ctrl.bottomColor();
return h('div.board-editor', {
@ -339,7 +337,7 @@ export default function(ctrl: EditorCtrl): VNode {
sparePieces(ctrl, opposite(color), color, 'top'),
h('div.main-board', [chessground(ctrl)]),
sparePieces(ctrl, color, color, 'bottom'),
controls(ctrl, fen),
inputs(ctrl, fen)
controls(ctrl, state),
inputs(ctrl, state.fen)
]);
}

View File

@ -2,6 +2,11 @@
# yarn lockfile v1
"@badrap/result@^0.2.5":
version "0.2.5"
resolved "https://registry.yarnpkg.com/@badrap/result/-/result-0.2.5.tgz#d2594ab32ad4a9150d489ecddbf08fb9f6d71d11"
integrity sha512-gPu1w+LiYkcfPuRK2de68g14VFRGNSQ+CXF6GXIa+0NfUeacuHplJlXyyN57ihZXJ1kbbLYcWdeRUARX8htocA==
"@gulp-sourcemaps/identity-map@1.X":
version "1.0.2"
resolved "https://registry.yarnpkg.com/@gulp-sourcemaps/identity-map/-/identity-map-1.0.2.tgz#1e6fe5d8027b1f285dc0d31762f566bccd73d5a9"
@ -791,6 +796,13 @@ chessground@^7.6:
resolved "https://registry.yarnpkg.com/chessground/-/chessground-7.6.10.tgz#b837c11e27a8504c6f3ed06c659e8e2d8ea11f3e"
integrity sha512-kAnS6hy7NNdGLu6H1b2IOodwEHu9cDfaG/GVhh96KuBm2D9llZ2Ccf/j0sLwmMQctn3cIxOXV4yeRsT4/8szzg==
chessops@^0.1.0:
version "0.1.0"
resolved "https://registry.yarnpkg.com/chessops/-/chessops-0.1.0.tgz#4079c3997026f32bf5055431b2babf9a6814ec2d"
integrity sha512-9EicBwEJrz3zQWh0os1e7gpBwHMGB5mzx23xhKWCEfFknN3ub3t+V+qpKQ7WUXok+wGWkP8lQ6wzy3ffs14sdA==
dependencies:
"@badrap/result" "^0.2.5"
chokidar@^2.0.0, chokidar@^2.1.1:
version "2.1.8"
resolved "https://registry.yarnpkg.com/chokidar/-/chokidar-2.1.8.tgz#804b3a7b6a99358c3c5c61e71d8728f041cff917"