lila/ui/storm/src/ctrl.ts

257 lines
7.6 KiB
TypeScript

import * as xhr from './xhr';
import config from './config';
import makePromotion from './promotion';
import { Api as CgApi } from 'chessground/api';
import { Chess } from 'chessops/chess';
import { chessgroundDests } from 'chessops/compat';
import { Config as CgConfig } from 'chessground/config';
import { getNow } from './util';
import { parseFen, makeFen } from 'chessops/fen';
import { parseUci, opposite } from 'chessops/util';
import { prop, Prop } from 'common';
import { Role } from 'chessground/types';
import { StormOpts, StormData, StormPuzzle, StormVm, Promotion, TimeMod, StormRun } from './interfaces';
export default class StormCtrl {
data: StormData;
vm: StormVm;
trans: Trans;
promotion: Promotion;
ground = prop<CgApi | false>(false) as Prop<CgApi | false>;
constructor(readonly opts: StormOpts, readonly redraw: () => void) {
this.data = opts.data;
this.trans = lichess.trans(opts.i18n);
this.vm = {
puzzleIndex: 0,
moveIndex: 0,
clock: config.clock.initial * 1000,
history: [],
combo: 0,
comboBest: 0,
modifier: {
moveAt: 0
},
run: {
startAt: 0,
moves: 0,
errors: 0
},
};
this.promotion = makePromotion(this.withGround, this.makeCgOpts, redraw);
this.checkDupTab();
setTimeout(this.hotkeys, 1000);
}
clockMillis = (): number | undefined =>
this.vm.run.startAt && Math.max(0, this.vm.run.startAt + this.vm.clock - getNow());
end = (): void => {
if (!this.vm.puzzleStartAt) return;
this.vm.history.reverse();
this.vm.run.endAt = getNow();
this.ground(false);
this.redraw();
this.sound.end();
xhr.record(this.runStats(), this.data.notAnExploit).then(res => {
this.vm.run.response = res;
this.redraw();
});
this.redrawSlow();
}
naturalFlag = () => {
this.pushToHistory(false);
this.end();
};
userMove = (orig: Key, dest: Key): void => {
if (!this.promotion.start(orig, dest, this.playUserMove)) this.playUserMove(orig, dest);
}
playUserMove = (orig: Key, dest: Key, promotion?: Role): void => {
if (!this.vm.run.moves) this.vm.run.startAt = getNow();
this.vm.run.moves++;
this.promotion.cancel();
const expected = this.line()[this.vm.moveIndex + 1];
const uci = `${orig}${dest}${promotion ? (promotion == 'knight' ? 'n' : promotion[0]) : ''}`;
const pos = this.position();
const move = parseUci(uci)!;
const capture = pos.board.occupied.has(move.to);
pos.play(move);
if (pos.isCheckmate() || uci == expected) {
this.vm.moveIndex++;
this.vm.combo++;
this.vm.comboBest = Math.max(this.vm.comboBest, this.vm.combo);
this.vm.modifier.moveAt = getNow();
const bonus = this.computeComboBonus();
if (bonus) {
this.vm.modifier.bonus = bonus;
this.vm.clock += bonus.seconds * 1000;
this.sound.bonus();
}
this.sound.move(capture);
if (this.vm.moveIndex == this.line().length - 1) {
this.pushToHistory(true);
this.vm.moveIndex = 0;
if (!this.incPuzzle()) this.end();
} else {
this.vm.moveIndex++;
}
} else {
lichess.sound.play('error');
this.pushToHistory(false);
this.vm.run.errors++;
this.vm.combo = 0;
this.vm.clock -= config.clock.malus * 1000;
this.vm.modifier.malus = {
seconds: config.clock.malus,
at: getNow()
};
if (!this.boundedClockMillis()) this.end();
else {
this.vm.moveIndex = 0;
if (!this.incPuzzle()) this.end();
}
}
this.redraw();
this.redrawQuick();
this.redrawSlow();
this.withGround(this.showGround);
};
private redrawQuick = () => setTimeout(this.redraw, 100);
private redrawSlow = () => setTimeout(this.redraw, 1000);
private computeComboBonus = (): TimeMod | undefined => {
if (this.comboPercent() == 0) {
const level = this.comboLevel();
if (level > 0) return {
seconds: config.combo.levels[level][1],
at: getNow()
};
}
return;
};
boundedClockMillis = () => this.vm.run.startAt ?
Math.max(0, this.vm.run.startAt + this.vm.clock - getNow()) :
this.vm.clock;
private pushToHistory = (win: boolean) => {
const now = getNow();
this.vm.history.push({
puzzle: this.puzzle(),
win,
millis: this.vm.puzzleStartAt ? now - this.vm.puzzleStartAt : 0
});
this.vm.puzzleStartAt = now;
};
private incPuzzle = (): boolean => {
if (this.vm.puzzleIndex < this.data.puzzles.length - 1) {
this.vm.puzzleIndex++;
return true;
}
return false;
}
puzzle = (): StormPuzzle => this.data.puzzles[this.vm.puzzleIndex];
line = (): Uci[] => this.puzzle().line.split(' ');
position = (): Chess => {
const pos = Chess.fromSetup(parseFen(this.puzzle().fen).unwrap()).unwrap();
this.line().slice(0, this.vm.moveIndex + 1).forEach(uci =>
pos.play(parseUci(uci)!)
);
return pos;
}
makeCgOpts = (): CgConfig => {
const puzzle = this.puzzle();
const pos = this.position();
const pov = opposite(parseFen(puzzle.fen).unwrap().turn);
const canMove = !this.vm.run.endAt;
return {
fen: makeFen(pos.toSetup()),
orientation: pov,
turnColor: pos.turn,
movable: canMove ? {
color: pov,
dests: chessgroundDests(pos)
} : undefined,
premovable: {
enabled: false
},
check: !!pos.isCheck(),
lastMove: this.uciToLastMove(this.line()[this.vm.moveIndex])
};
}
comboLevel = () => config.combo.levels.reduce((lvl, [threshold, _], index) => threshold <= this.vm.combo ? index : lvl, 0);
comboPercent = () => {
const lvl = this.comboLevel();
const levels = config.combo.levels;
const lastLevel = levels[levels.length - 1];
if (lvl >= levels.length - 1) {
const range = (lastLevel[0] - levels[levels.length - 2][0]);
return ((this.vm.combo - lastLevel[0]) / range) * 100 % 100;
}
const bounds = [levels[lvl][0], levels[lvl + 1][0]];
return Math.floor((this.vm.combo - bounds[0]) / (bounds[1] - bounds[0]) * 100);
};
countWins = (): number => this.vm.history.reduce((c, r) => c + (r.win ? 1 : 0), 0);
withGround = <A>(f: (cg: CgApi) => A): A | false => {
const g = this.ground();
return g && f(g);
}
runStats = (): StormRun => ({
puzzles: this.vm.history.length,
score: this.countWins(),
moves: this.vm.run.moves,
errors: this.vm.run.errors,
combo: this.vm.comboBest,
time: (this.vm.run.endAt! - this.vm.run.startAt) / 1000,
highest: this.vm.history.reduce((h, r) => r.win && r.puzzle.rating > h ? r.puzzle.rating : h, 0)
});
private showGround = (g: CgApi): void => g.set(this.makeCgOpts());
private uciToLastMove = (uci: string): [Key, Key] => [uci.substr(0, 2) as Key, uci.substr(2, 2) as Key];
private loadSound = (file: string, volume?: number) => {
lichess.sound.loadOggOrMp3(file, `${lichess.sound.baseUrl}/${file}`);
return () => lichess.sound.play(file, volume);
};
private sound = {
move: (take: boolean) => lichess.sound.play(take ? 'capture' : 'move'),
bonus: this.loadSound('other/ping', 0.8),
end: this.loadSound('other/gewonnen', 0.6)
};
private checkDupTab = () => {
const dupTabMsg = lichess.storage.make('storm.tab');
dupTabMsg.fire(this.data.puzzles[0].id);
dupTabMsg.listen(ev => {
if (!this.vm.run.startAt && ev.value == this.data.puzzles[0].id) {
this.vm.dupTab = true;
this.redraw();
}
});
}
private hotkeys = () => {
window.Mousetrap
.bind('space', () => location.reload())
.bind('return', this.end);
}
}