1263 lines
59 KiB
TypeScript
1263 lines
59 KiB
TypeScript
import { Chess } from 'chessops/chess';
|
|
import { INITIAL_FEN, makeFen, parseFen } from 'chessops/fen';
|
|
import { makeSan, parseSan } from 'chessops/san';
|
|
import { NormalMove } from 'chessops/types';
|
|
import { board } from 'chessops/debug';
|
|
import { defaultSetup, fen, makeUci, parseUci } from 'chessops';
|
|
|
|
export default function (token: string) {
|
|
const root = document.getElementById('dgt-play-zone') as HTMLDivElement;
|
|
const consoleOutput = document.getElementById('dgt-play-zone-log') as HTMLPreElement;
|
|
|
|
/**
|
|
* CONFIGURATION VALUES
|
|
*/
|
|
const liveChessURL = localStorage.getItem('dgt-livechess-url');
|
|
const announceAllMoves = localStorage.getItem('dgt-speech-announce-all-moves') == 'true';
|
|
const verbose = localStorage.getItem('dgt-verbose') == 'true';
|
|
const announceMoveFormat = localStorage.getItem('dgt-speech-announce-move-format')
|
|
? localStorage.getItem('dgt-speech-announce-move-format')
|
|
: 'san';
|
|
const speechSynthesisOn = localStorage.getItem('dgt-speech-synthesis') == 'true';
|
|
const voice = localStorage.getItem('dgt-speech-voice');
|
|
let keywords = {
|
|
K: 'King',
|
|
Q: 'Queen',
|
|
R: 'Rook',
|
|
B: 'Bishop',
|
|
N: 'Knight',
|
|
P: 'Pawn',
|
|
x: 'Takes',
|
|
'+': 'Check',
|
|
'#': 'Checkmate',
|
|
'(=)': 'Game ends in draw',
|
|
'O-O-O': 'Castles queenside',
|
|
'O-O': 'Castles kingside',
|
|
white: 'White',
|
|
black: 'Black',
|
|
'wins by': 'wins by',
|
|
timeout: 'timeout',
|
|
resignation: 'resignation',
|
|
illegal: 'illegal',
|
|
move: 'move',
|
|
};
|
|
try {
|
|
if (JSON.parse(localStorage.getItem('dgt-speech-keywords')!).K.length > 0) {
|
|
keywords = JSON.parse(localStorage.getItem('dgt-speech-keywords')!);
|
|
} else {
|
|
console.warn('JSON Object for Speech Keywords seems incomplete. Using English default.');
|
|
}
|
|
} catch (error) {
|
|
console.error('Invalid JSON Object for Speech Keywords. Using English default. ' + error);
|
|
}
|
|
|
|
//Lichess Integration with Board API
|
|
|
|
/**
|
|
* GLOBAL VARIABLES - Lichess Connectivity
|
|
*/
|
|
const time = new Date(); //A Global time object
|
|
let currentGameId = ''; //Track which is the current Game, in case there are several open games
|
|
let currentGameColor = ''; //Track which color is being currently played by the player. 'white' or 'black'
|
|
let me: { id: string; username: string }; //Track my information
|
|
const gameInfoMap = new Map(); //A collection of key values to store game immutable information of all open games
|
|
const gameStateMap = new Map(); //A collection of key values to store the changing state of all open games
|
|
const gameConnectionMap = new Map<string, { connected: boolean; lastEvent: number }>(); //A collection of key values to store the network status of a game
|
|
const gameChessBoardMap = new Map<string, Chess>(); //A collection of chessops Boards representing the current board of the games
|
|
let eventSteamStatus = { connected: false, lastEvent: time.getTime() }; //An object to store network status of the main eventStream
|
|
const keywordsBase = [
|
|
'white',
|
|
'black',
|
|
'K',
|
|
'Q',
|
|
'R',
|
|
'B',
|
|
'N',
|
|
'P',
|
|
'x',
|
|
'+',
|
|
'#',
|
|
'(=)',
|
|
'O-O-O',
|
|
'O-O',
|
|
'wins by',
|
|
'timeout',
|
|
'resignation',
|
|
'illegal',
|
|
'move',
|
|
];
|
|
let lastSanMove: { player: string; move: string; by: string }; //Track last move in SAN format. This is because there is no easy way to keep history of san moves
|
|
/**
|
|
* Global Variables for DGT Board Connection (JACM)
|
|
*/
|
|
let localBoard: Chess = startingPosition(); //Board with valid moves played on Lichess and DGT Board. May be half-move behind Lichess or half-move in advance
|
|
let DGTgameId = ''; //Used to track if DGT board was setup already with the lichess currentGameId
|
|
let boards = Array<{ serialnr: string; state: string }>(); //An array to store all the board recognized by DGT LiveChess
|
|
let liveChessConnection: WebSocket; //Connection Object to LiveChess through websocket
|
|
let isLiveChessConnected = false; //Used to track if a board there is a connection to DGT Live Chess
|
|
let currentSerialnr = '0'; //Public property to store the current serial number of the DGT Board in case there is more than one
|
|
//subscription stores the information about the board being connected, most importantly the serialnr
|
|
const subscription = { id: 2, call: 'subscribe', param: { feed: 'eboardevent', id: 1, param: { serialnr: '' } } };
|
|
let lastLegalParam: { board: string; san: string[] }; //This can help prevent duplicate moves from LiveChess being detected as move from the other side, like a duplicate O-O
|
|
let lastLiveChessBoard: string; //Store last Board received by LiveChess
|
|
/***
|
|
* Bind console output to HTML pre Element
|
|
*/
|
|
rewireLoggingToElement(consoleOutput, root, true);
|
|
function rewireLoggingToElement(eleLocator: HTMLPreElement, eleOverflowLocator: HTMLDivElement, autoScroll: boolean) {
|
|
//Clear the console
|
|
eleLocator.innerHTML = '';
|
|
//Bind to all types of console messages
|
|
fixLoggingFunc('log');
|
|
fixLoggingFunc('debug');
|
|
fixLoggingFunc('warn');
|
|
fixLoggingFunc('error');
|
|
fixLoggingFunc('info');
|
|
fixLoggingFunc('table');
|
|
|
|
function fixLoggingFunc(name: string) {
|
|
console['old' + name] = console[name];
|
|
//Rewire function
|
|
console[name] = function () {
|
|
//Return a promise so execution is not delayed by string manipulation
|
|
return new Promise<void>(resolve => {
|
|
let output = '';
|
|
for (let i = 0; i < arguments.length; i++) {
|
|
const arg = arguments[i];
|
|
if (arg == '*' || arg == ':') {
|
|
output += arg;
|
|
} else {
|
|
output += '</br><span class="log-' + typeof arg + ' log-' + name + '">';
|
|
if (typeof arg === 'object') {
|
|
output += JSON.stringify(arg);
|
|
} else {
|
|
output += arg;
|
|
}
|
|
output += '</span> ';
|
|
}
|
|
}
|
|
//Added to keep on-screen log small
|
|
const maxLogBytes = verbose ? -1048576 : -8192;
|
|
let isScrolledToBottom = false;
|
|
if (autoScroll) {
|
|
isScrolledToBottom =
|
|
eleOverflowLocator.scrollHeight - eleOverflowLocator.clientHeight <= eleOverflowLocator.scrollTop + 1;
|
|
}
|
|
eleLocator.innerHTML = eleLocator.innerHTML.slice(maxLogBytes) + output;
|
|
if (isScrolledToBottom) {
|
|
eleOverflowLocator.scrollTop = eleOverflowLocator.scrollHeight - eleOverflowLocator.clientHeight;
|
|
}
|
|
//Call original function
|
|
try {
|
|
console['old' + name].apply(undefined, arguments);
|
|
} catch {
|
|
console['olderror'].apply(undefined, ['Error when logging']);
|
|
}
|
|
resolve();
|
|
});
|
|
};
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Wait some time without blocking other code
|
|
*
|
|
* @param {number} ms - The number of milliseconds to sleep
|
|
*/
|
|
function sleep(ms = 0) {
|
|
return new Promise(r => setTimeout(r, ms));
|
|
}
|
|
|
|
/**
|
|
* GET /api/account
|
|
*
|
|
* Get my profile
|
|
*
|
|
* Shows Public information about the logged in user.
|
|
*
|
|
* Example
|
|
* {"id":"andrescavallin","username":"andrescavallin","online":true,"perfs":{"blitz":{"games":0,"rating":1500,"rd":350,"prog":0,"prov":true},"bullet":{"games":0,"rating":1500,"rd":350,"prog":0,"prov":true},"correspondence":{"games":0,"rating":1500,"rd":350,"prog":0,"prov":true},"classical":{"games":0,"rating":1500,"rd":350,"prog":0,"prov":true},"rapid":{"games":0,"rating":1500,"rd":350,"prog":0,"prov":true}},"createdAt":1599930231644,"seenAt":1599932744930,"playTime":{"total":0,"tv":0},"url":"http://localhost:9663/@/andrescavallin","nbFollowing":0,"nbFollowers":0,"count":{"all":0,"rated":0,"ai":0,"draw":0,"drawH":0,"loss":0,"lossH":0,"win":0,"winH":0,"bookmark":0,"playing":0,"import":0,"me":0},"followable":true,"following":false,"blocking":false,"followsYou":false}
|
|
* */
|
|
function getProfile() {
|
|
//Log intention
|
|
if (verbose) console.log('getProfile - About to call /api/account');
|
|
fetch('/api/account', {
|
|
headers: { Authorization: 'Bearer ' + token },
|
|
})
|
|
.then(r => r.json())
|
|
.then(data => {
|
|
//Store my profile
|
|
me = data;
|
|
//Log raw data received
|
|
if (verbose) console.log('/api/account Response:' + JSON.stringify(data));
|
|
//Display Title + UserName . Title may be undefined
|
|
console.log('┌─────────────────────────────────────────────────────┐');
|
|
console.log('│ ' + (typeof data.title == 'undefined' ? '' : data.title) + ' ' + data.username);
|
|
//Display performance ratings
|
|
console.table(data.perfs);
|
|
})
|
|
.catch(err => {
|
|
console.error('getProfile - Error. ' + err.message);
|
|
});
|
|
}
|
|
|
|
/**
|
|
GET /api/stream/event
|
|
Stream incoming events
|
|
|
|
Stream the events reaching a lichess user in real time as ndjson.
|
|
|
|
Each line is a JSON object containing a type field. Possible values are:
|
|
|
|
challenge Incoming challenge
|
|
gameStart Start of a game
|
|
gameFinish to signal that game ended
|
|
When the stream opens, all current challenges and games are sent.
|
|
|
|
Examples:
|
|
{"type":"gameStart","game":{"id":"kjKzl2MO"}}
|
|
{"type":"challenge","challenge":{"id":"WTr3JNcm","status":"created","challenger":{"id":"andrescavallin","name":"andrescavallin","title":null,"rating":1362,"provisional":true,"online":true,"lag":3},"destUser":{"id":"godking666","name":"Godking666","title":null,"rating":1910,"online":true,"lag":3},"variant":{"key":"standard","name":"Standard","short":"Std"},"rated":false,"speed":"rapid","timeControl":{"type":"clock","limit":900,"increment":10,"show":"15+10"},"color":"white","perf":{"icon":"","name":"Rapid"}}}
|
|
{"type":"gameFinish","game":{"id":"MhG878ij"}}
|
|
*/
|
|
async function connectToEventStream() {
|
|
//Log intention
|
|
if (verbose) console.log('connectToEventStream - About to call /api/stream/event');
|
|
const response = await fetch('/api/stream/event', {
|
|
headers: { Authorization: 'Bearer ' + token },
|
|
});
|
|
//Sadly TextDecoderStream is not supported on FireFox so a decoder is needed
|
|
//const reader = response.body!.pipeThrough(new TextDecoderStream()).getReader();
|
|
const reader = response.body!.getReader();
|
|
const decoder = new TextDecoder();
|
|
while (true) {
|
|
const { value, done } = await reader.read();
|
|
if (done) break;
|
|
if (verbose && value!.length > 1) console.log('connectToEventStream - Chunk received', decoder.decode(value));
|
|
//Update connection status
|
|
eventSteamStatus = { connected: true, lastEvent: time.getTime() };
|
|
//Response may contain several JSON objects on the same chunk separated by \n . This may create an empty element at the end.
|
|
const jsonArray = value ? decoder.decode(value).split('\n') : [];
|
|
for (let i = 0; i < jsonArray.length; i++) {
|
|
//Skip empty elements that may have happened with the .split('\n')
|
|
if (jsonArray[i].length > 2) {
|
|
try {
|
|
const data = JSON.parse(jsonArray[i]);
|
|
//JSON data found, let's check if this is a game that started. field type is mandatory except on http 4xx
|
|
if (data.type == 'gameStart') {
|
|
if (verbose) console.log('connectToEventStream - gameStart event arrived. GameId: ' + data.game.id);
|
|
try {
|
|
//Connect to that game's stream
|
|
connectToGameStream(data.game.id);
|
|
} catch (error) {
|
|
//This will trigger if connectToGameStream fails
|
|
console.error('connectToEventStream - Failed to connect to game stream. ' + error);
|
|
}
|
|
} else if (data.type == 'challenge') {
|
|
//Challenge received
|
|
//TODO
|
|
} else if (data.type == 'gameFinish') {
|
|
//Game Finished
|
|
//TODO Handle this event
|
|
} else if (response.status >= 400) {
|
|
console.warn('connectToEventStream - ' + data.error);
|
|
}
|
|
} catch (error) {
|
|
console.error('connectToEventStream - Unable to parse JSON or Unexpected error. ' + error);
|
|
}
|
|
} else {
|
|
//Signal that some empty message arrived. This is normal to keep the connection alive.
|
|
if (verbose) console.log('*'); //process.stdout.write("*"); Replace to support browser
|
|
}
|
|
}
|
|
}
|
|
|
|
console.warn('connectToEventStream - Event Stream ended by server');
|
|
//Update connection status
|
|
eventSteamStatus = { connected: false, lastEvent: time.getTime() };
|
|
}
|
|
|
|
/**
|
|
Stream Board game state
|
|
|
|
GET /api/board/game/stream/{gameId}
|
|
|
|
Stream the state of a game being played with the Board API, as ndjson.
|
|
Use this endpoint to get updates about the game in real-time, with a single request.
|
|
Each line is a JSON object containing a type field. Possible values are:
|
|
|
|
gameFull Full game data. All values are immutable, except for the state field.
|
|
gameState Current state of the game. Immutable values not included. Sent when a move is played, a draw is offered, or when the game ends.
|
|
chatLine Chat message sent by a user in the room "player" or "spectator".
|
|
The first line is always of type gameFull.
|
|
|
|
Examples:
|
|
|
|
New Game
|
|
{"id":"972RKuuq","variant":{"key":"standard","name":"Standard","short":"Std"},"clock":{"initial":900000,"increment":10000},"speed":"rapid","perf":{"name":"Rapid"},"rated":false,"createdAt":1586647003562,"white":{"id":"godking666","name":"Godking666","title":null,"rating":1761},"black":{"id":"andrescavallin","name":"andrescavallin","title":null,"rating":1362,"provisional":true},"initialFen":"startpos","type":"gameFull","state":{"type":"gameState","moves":"e2e4","wtime":900000,"btime":900000,"winc":10000,"binc":10000,"wdraw":false,"bdraw":false,"status":"started"}}
|
|
First Move
|
|
{"type":"gameState","moves":"e2e4","wtime":900000,"btime":900000,"winc":10000,"binc":10000,"wdraw":false,"bdraw":false,"status":"started"}
|
|
Middle Game
|
|
{"type":"gameState","moves":"e2e4 c7c6 g1f3 d7d5 e4e5 c8f5 d2d4 e7e6 h2h3 f5e4 b1d2 f8b4 c2c3 b4a5 d2e4 d5e4 f3d2 d8h4 g2g3 h4e7 d2e4 e7d7 e4d6 e8f8 d1f3 g8h6 c1h6 h8g8 h6g5 a5c7 e1c1 c7d6 e5d6 d7d6 g5f4 d6d5 f3d5 c6d5 f4d6 f8e8 d6b8 a8b8 f1b5 e8f8 h1e1 f8e7 d1d3 a7a6 b5a4 g8c8 a4b3 b7b5 b3d5 e7f8","wtime":903960,"btime":847860,"winc":10000,"binc":10000,"wdraw":false,"bdraw":false,"status":"started"}
|
|
After reconnect
|
|
{"id":"ZQDjy4sa","variant":{"key":"standard","name":"Standard","short":"Std"},"clock":{"initial":900000,"increment":10000},"speed":"rapid","perf":{"name":"Rapid"},"rated":true,"createdAt":1586643869056,"white":{"id":"gg60","name":"gg60","title":null,"rating":1509},"black":{"id":"andrescavallin","name":"andrescavallin","title":null,"rating":1433,"provisional":true},"initialFen":"startpos","type":"gameFull","state":{"type":"gameState","moves":"e2e4 c7c6 g1f3 d7d5 e4e5 c8f5 d2d4 e7e6 h2h3 f5e4 b1d2 f8b4 c2c3 b4a5 d2e4 d5e4 f3d2 d8h4 g2g3 h4e7 d2e4 e7d7 e4d6 e8f8 d1f3 g8h6 c1h6 h8g8 h6g5 a5c7 e1c1 c7d6 e5d6 d7d6 g5f4 d6d5 f3d5 c6d5 f4d6 f8e8 d6b8 a8b8 f1b5 e8f8 h1e1 f8e7 d1d3 a7a6 b5a4 g8c8 a4b3 b7b5 b3d5 e7f8 d5b3 a6a5 a2a3 a5a4 b3a2 f7f6 e1e6 f8f7 e6b6","wtime":912940,"btime":821720,"winc":10000,"binc":10000,"wdraw":false,"bdraw":false,"status":"resign","winner":"white"}}
|
|
Draw Offered
|
|
{"type":"gameState","moves":"e2e4 c7c6","wtime":880580,"btime":900000,"winc":10000,"binc":10000,"wdraw":false,"bdraw":true,"status":"started"}
|
|
After draw accepted
|
|
{"type":"gameState","moves":"e2e4 c7c6","wtime":865460,"btime":900000,"winc":10000,"binc":10000,"wdraw":false,"bdraw":false,"status":"draw"}
|
|
Out of Time
|
|
{"type":"gameState","moves":"e2e3 e7e5","wtime":0,"btime":900000,"winc":10000,"binc":10000,"wdraw":false,"bdraw":false,"status":"outoftime","winner":"black"}
|
|
Mate
|
|
{"type":"gameState","moves":"e2e4 e7e5 f1c4 d7d6 d1f3 b8c6 f3f7","wtime":900480,"btime":907720,"winc":10000,"binc":10000,"wdraw":false,"bdraw":false,"status":"mate"}
|
|
Promotion
|
|
{"type":"gameState","moves":"e2e4 b8c6 g1f3 c6d4 f1c4 e7e5 d2d3 d7d5 f3d4 f7f6 c4d5 f6f5 f2f3 g7g6 e1g1 c7c6 d5b3 d8d5 e4d5 a8b8 d4e6 f8b4 e6c7 e8e7 d5d6 e7f6 d6d7 b4f8 d7d8q","wtime":2147483647,"btime":2147483647,"winc":0,"binc":0,"wdraw":false,"bdraw":false,"status":"started"}
|
|
@param {string} gameId - The alphanumeric identifier of the game to be tracked
|
|
*/
|
|
|
|
async function connectToGameStream(gameId: string) {
|
|
//Log intention
|
|
if (verbose) console.log('connectToGameStream - About to call /api/board/game/stream/' + gameId);
|
|
const response = await fetch('/api/board/game/stream/' + gameId, {
|
|
headers: { Authorization: 'Bearer ' + token },
|
|
});
|
|
//Again, TextDecoderStream is not supported on FireFox
|
|
//const reader = response.body?.pipeThrough(new TextDecoderStream()).getReader();
|
|
const reader = response.body!.getReader();
|
|
const decoder = new TextDecoder();
|
|
while (reader) {
|
|
//while (true)
|
|
const { value, done } = await reader.read();
|
|
if (done) break;
|
|
//Log raw data received
|
|
if (verbose && value!.length > 1)
|
|
console.log('connectToGameStream - board game stream received:', decoder.decode(value));
|
|
//Update connection status
|
|
gameConnectionMap.set(gameId, { connected: true, lastEvent: time.getTime() });
|
|
//Response may contain several JSON objects on the same chunk separated by \n . This may create an empty element at the end.
|
|
const jsonArray = decoder.decode(value)!.split('\n');
|
|
for (let i = 0; i < jsonArray.length; i++) {
|
|
//Skip empty elements that may have happened with the .split('\n')
|
|
if (jsonArray[i].length > 2) {
|
|
try {
|
|
const data = JSON.parse(jsonArray[i]);
|
|
//The first line is always of type gameFull.
|
|
if (data.type == 'gameFull') {
|
|
if (!verbose) console.clear();
|
|
//Log game Summary
|
|
//logGameSummary(data);
|
|
//Store game inmutable information on the gameInfoMap dictionary collection
|
|
gameInfoMap.set(gameId, data);
|
|
//Store game state on the gameStateMap dictionary collection
|
|
gameStateMap.set(gameId, data.state);
|
|
//Update the ChessBoard to the ChessBoard Map
|
|
initializeChessBoard(gameId, data);
|
|
//Log the state. Note that we are doing this after storing the state and initializing the chessops board
|
|
logGameState(gameId);
|
|
//Call chooseCurrentGame to determine if this stream will be the new current game
|
|
chooseCurrentGame();
|
|
} else if (data.type == 'gameState') {
|
|
if (!verbose) console.clear();
|
|
//Update the ChessBoard Map
|
|
updateChessBoard(gameId, gameStateMap.get(gameId), data);
|
|
//Update game state with most recent state
|
|
gameStateMap.set(gameId, data);
|
|
//Log the state. Note that we are doing this after storing the state and updating the chessops board
|
|
//Update for Multiple Game Support. Log only current game
|
|
if (gameId == currentGameId) {
|
|
logGameState(gameId);
|
|
} else {
|
|
if (verbose) console.log('connectToGameStream - State received was not for current game.');
|
|
}
|
|
} else if (data.type == 'chatLine') {
|
|
//Received chat line
|
|
//TODO
|
|
} else if (response.status >= 400) {
|
|
console.log('connectToGameStream - ' + data.error);
|
|
}
|
|
} catch (error) {
|
|
console.error('connectToGameStream - No valid game data or Unexpected error. ' + error);
|
|
}
|
|
} else {
|
|
//Signal that some empty message arrived
|
|
if (verbose) console.log(':'); //process.stdout.write(":"); Changed to support browser
|
|
}
|
|
}
|
|
}
|
|
//End Stream output.end();
|
|
console.warn('connectToGameStream - Game ' + gameId + ' Stream ended.');
|
|
//Update connection state
|
|
gameConnectionMap.set(gameId, { connected: false, lastEvent: time.getTime() });
|
|
}
|
|
|
|
/**
|
|
* Return a string representation of the remaining time on the clock
|
|
*
|
|
* @param {number} timer - Numeric representation of remaining time
|
|
*
|
|
* @returns {String} - String representation of numeric time
|
|
*/
|
|
function formattedTimer(timer: number): string {
|
|
// Pad function to pad with 0 to 2 or 3 digits, default is 2
|
|
const pad = (n: number, z = 2) => `00${n}`.slice(-z);
|
|
return pad((timer / 3.6e6) | 0) + ':' + pad(((timer % 3.6e6) / 6e4) | 0) + ':' + pad(((timer % 6e4) / 1000) | 0); //+ '.' + pad(timer % 1000, 3);
|
|
}
|
|
|
|
/**
|
|
* mainLoop() is a function that tries to keep the streams connected at all times, up to a maximum of 20 retries
|
|
*/
|
|
async function lichessConnectionLoop() {
|
|
//Program ends after 20 re-connection attempts
|
|
for (let attempts = 0; attempts < 20; attempts++) {
|
|
//Connect to main event stream
|
|
connectToEventStream();
|
|
//On the first time, if there are no games, it may take several seconds to receive data so lets wait a bit. Also give some time to connect to started games
|
|
await sleep(5000);
|
|
//Now enter a loop to monitor the connection
|
|
do {
|
|
//sleep 5 seconds and just listen to events
|
|
await sleep(5000);
|
|
//Check if any started games are disconnected
|
|
for (const [gameId, networkState] of gameConnectionMap) {
|
|
if (!networkState.connected && gameStateMap.get(gameId).status == 'started') {
|
|
//Game is not connected and has not finished, reconnect
|
|
if (verbose) console.log(`Started game is disconnected. Attempting reconnection for gameId: ${gameId}`);
|
|
connectToGameStream(gameId);
|
|
}
|
|
}
|
|
} while (eventSteamStatus.connected);
|
|
//This means event stream is not connected
|
|
console.warn('No connection to event stream. Attempting re-connection. Attempt: ' + attempts);
|
|
}
|
|
console.error('No connection to event stream after maximum number of attempts 20. Reload page to start again.');
|
|
}
|
|
|
|
/**
|
|
* This function will update the currentGameId with a valid active game
|
|
* and then will attach this game to the DGT Board
|
|
* It requires that all maps are up to date: gameInfoMap, gameStateMap, gameConnectionMap and gameChessBoardMap
|
|
*/
|
|
async function chooseCurrentGame() {
|
|
//Determine new value for currentGameId. First create an array with only the started games
|
|
//So then there is none or more than one started game
|
|
const playableGames = playableGamesArray();
|
|
//If there is only one started game, then its easy
|
|
/*
|
|
if (playableGames.length == 1) {
|
|
currentGameId = playableGames[0].gameId;
|
|
attachCurrentGameIdToDGTBoard(); //Let the board know which color the player is actually playing and setup the position
|
|
console.log('Active game updated. currentGameId: ' + currentGameId);
|
|
}
|
|
else
|
|
*/
|
|
if (playableGames.length == 0) {
|
|
console.log(
|
|
'No started playable games, challenges or games are disconnected. Please start a new game or fix connection.'
|
|
);
|
|
//TODO What happens if the games reconnect and this move is not sent?
|
|
} else {
|
|
if (playableGames.length > 1) {
|
|
console.warn('Multiple active games detected. Current game will be selected based on board position.');
|
|
console.table(playableGames);
|
|
}
|
|
//Wait a few seconds until board position is received from LiveChess. Max 10 seconds.
|
|
for (let w = 0; w < 10; w++) {
|
|
if (lastLiveChessBoard !== undefined) break;
|
|
await sleep(1000);
|
|
}
|
|
if (verbose) console.log(`LiveChess FEN: ${lastLiveChessBoard}`);
|
|
//Don't default to any game until position matches
|
|
let index = -1;
|
|
for (let i = 0; i < playableGames.length; i++) {
|
|
//makeBoardFen return only the board, ideal for comparison
|
|
const tmpFEN = fen.makeBoardFen(gameChessBoardMap.get(playableGames[i].gameId)!.board);
|
|
if (verbose) console.log(`GameId: ${playableGames[i].gameId} FEN: ${tmpFEN}`);
|
|
if (tmpFEN == lastLiveChessBoard) {
|
|
index = i;
|
|
}
|
|
}
|
|
if (index == -1) {
|
|
console.error('Position on board does not match any ongoing game.');
|
|
//No position match found
|
|
if (
|
|
gameStateMap.has(currentGameId) &&
|
|
gameConnectionMap.get(currentGameId)!.connected &&
|
|
gameStateMap.get(currentGameId).status == 'started'
|
|
) {
|
|
//No match found but there is a valid currentGameId , so keep it
|
|
if (verbose)
|
|
console.log(
|
|
'chooseCurrentGame - Board will remain attached to current game. currentGameId: ' + currentGameId
|
|
);
|
|
} else {
|
|
//No match and No valid current game but there are active games
|
|
console.warn('Fix position and reload or start a new game. Automatically retrying in 5 seconds...');
|
|
await sleep(5000);
|
|
chooseCurrentGame();
|
|
}
|
|
} else {
|
|
//Position match found
|
|
if (currentGameId != playableGames[Number(index)].gameId) {
|
|
//This is the happy path, board matches and game needs to be updated
|
|
if (verbose)
|
|
console.log('chooseCurrentGame - Position matched to gameId: ' + playableGames[Number(index)].gameId);
|
|
currentGameId = playableGames[Number(index)].gameId;
|
|
attachCurrentGameIdToDGTBoard(); //Let the board know which color the player is actually playing and setup the position
|
|
console.log('Active game updated. currentGameId: ' + currentGameId);
|
|
} else {
|
|
//The board matches currentGameId . No need to do anything.
|
|
if (verbose)
|
|
console.log(
|
|
'chooseCurrentGame - Board will remain attached to current game. currentGameId: ' + currentGameId
|
|
);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Initialize a ChessBoard when connecting or re-connecting to a game
|
|
*
|
|
* @param {string} gameId - The gameId of the game to store on the board
|
|
* @param {Object} data - The gameFull event from lichess.org
|
|
*/
|
|
function initializeChessBoard(gameId: string, data: { initialFen: string; state: { moves: string } }) {
|
|
try {
|
|
let initialFen: string = INITIAL_FEN;
|
|
if (data.initialFen != 'startpos') initialFen = data.initialFen;
|
|
const setup = parseFen(initialFen).unwrap();
|
|
const chess: Chess = Chess.fromSetup(setup).unwrap();
|
|
const moves = data.state.moves.split(' ');
|
|
for (let i = 0; i < moves.length; i++) {
|
|
if (moves[i] != '') {
|
|
//Make any move that may have been already played on the ChessBoard. Useful when reconnecting
|
|
const uciMove = <NormalMove>parseUci(moves[i]);
|
|
const normalizedMove = chess.normalizeMove(uciMove); //This is because chessops uses UCI_960
|
|
if (normalizedMove && chess.isLegal(normalizedMove)) chess.play(normalizedMove);
|
|
}
|
|
}
|
|
//Store the ChessBoard on the ChessBoardMap
|
|
gameChessBoardMap.set(gameId, chess);
|
|
if (verbose) console.log(`initializeChessBoard - New Board for gameId: ${gameId}`);
|
|
if (verbose) console.log(board(chess.board));
|
|
if (verbose) console.log(chess.turn + "'s turn");
|
|
} catch (error) {
|
|
console.error(`initializeChessBoard - Error: ${error}`);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Update the ChessBoard for the specified gameId with the new moves on newState since the last stored state
|
|
*
|
|
* @param {string} gameId - The gameId of the game to store on the board
|
|
* @param {Object} currentState - The state stored on the gameStateMap
|
|
* @param {Object} newState - The new state not yet stored
|
|
*/
|
|
function updateChessBoard(gameId: string, currentState: { moves: string }, newState: { moves: string }) {
|
|
try {
|
|
const chess = gameChessBoardMap.get(gameId);
|
|
if (chess) {
|
|
let pendingMoves: string;
|
|
if (!currentState.moves) {
|
|
//No prior moves. Use the new moves
|
|
pendingMoves = newState.moves;
|
|
} else {
|
|
//Get all the moves on the newState that are not present on the currentState
|
|
pendingMoves = newState.moves.substring(currentState.moves.length, newState.moves.length);
|
|
}
|
|
const moves = pendingMoves.split(' ');
|
|
for (let i = 0; i < moves.length; i++) {
|
|
if (moves[i] != '') {
|
|
//Make the new move
|
|
const uciMove = <NormalMove>parseUci(moves[i]);
|
|
const normalizedMove = chess.normalizeMove(uciMove); //This is because chessops uses UCI_960
|
|
if (normalizedMove && chess.isLegal(normalizedMove)) {
|
|
//This is a good chance to get the move in SAN format
|
|
if (chess.turn == 'black')
|
|
lastSanMove = {
|
|
player: 'black',
|
|
move: makeSan(chess, normalizedMove),
|
|
by: gameInfoMap.get(currentGameId).black.id,
|
|
};
|
|
else
|
|
lastSanMove = {
|
|
player: 'white',
|
|
move: makeSan(chess, normalizedMove),
|
|
by: gameInfoMap.get(currentGameId).white.id,
|
|
};
|
|
chess.play(normalizedMove);
|
|
}
|
|
}
|
|
}
|
|
//Store the ChessBoard on the ChessBoardMap
|
|
if (verbose) console.log(`updateChessBoard - Updated Board for gameId: ${gameId}`);
|
|
if (verbose) console.log(board(chess.board));
|
|
if (verbose) console.log(chess.turn + "'s turn");
|
|
}
|
|
} catch (error) {
|
|
console.error(`updateChessBoard - Error: ${error}`);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Utility function to update which color is being played with the board
|
|
*/
|
|
function attachCurrentGameIdToDGTBoard() {
|
|
//Every times a new game is connected clear the console except on verbose
|
|
if (!verbose) consoleOutput.innerHTML = '';
|
|
//
|
|
if (me.id == gameInfoMap.get(currentGameId).white.id) currentGameColor = 'white';
|
|
else currentGameColor = 'black';
|
|
//Send the position to LiveChess for synchronization
|
|
sendBoardToLiveChess(gameChessBoardMap.get(currentGameId)!);
|
|
}
|
|
|
|
/**
|
|
* Iterate the gameConnectionMap dictionary and return an arrays containing only the games that can be played with the board
|
|
* @returns {Array} - Array containing a summary of playable games
|
|
*/
|
|
function playableGamesArray(): Array<{
|
|
gameId: string;
|
|
versus: string;
|
|
'vs rating': string;
|
|
'game rating': string;
|
|
Timer: string;
|
|
'Last Move': string;
|
|
}> {
|
|
const playableGames: Array<{
|
|
gameId: string;
|
|
versus: string;
|
|
'vs rating': string;
|
|
'game rating': string;
|
|
Timer: string;
|
|
'Last Move': string;
|
|
}> = [];
|
|
const keys = Array.from(gameConnectionMap.keys());
|
|
//The for each iterator is not used since we don't want to continue execution. We want a synchronous result
|
|
//for (let [gameId, networkState] of gameConnectionMap) {
|
|
// if (gameConnectionMap.get(gameId).connected && gameStateMap.get(gameId).status == "started") {
|
|
for (let i = 0; i < keys.length; i++) {
|
|
if (gameConnectionMap.get(keys[i])?.connected && gameStateMap.get(keys[i])?.status == 'started') {
|
|
//Game is good for commands
|
|
const gameInfo = gameInfoMap.get(keys[i]);
|
|
//var gameState = gameStateMap.get(keys[i]);
|
|
const lastMove = getLastUCIMove(keys[i]);
|
|
const versus =
|
|
gameInfo.black.id == me.id
|
|
? (gameInfo.white.title !== null ? gameInfo.white.title : '@') + ' ' + gameInfo.white.name
|
|
: (gameInfo.black.title !== null ? gameInfo.black.title : '@') + ' ' + gameInfo.black.name;
|
|
playableGames.push({
|
|
gameId: gameInfo.id,
|
|
versus: versus,
|
|
'vs rating': gameInfo.black.id == me.id ? gameInfo.white.rating : gameInfo.black.rating,
|
|
'game rating': gameInfo.variant.short + ' ' + (gameInfo.rated ? 'rated' : 'unrated'),
|
|
Timer:
|
|
gameInfo.speed +
|
|
' ' +
|
|
(gameInfo.clock !== null
|
|
? String(gameInfo.clock.initial / 60000) + "'+" + String(gameInfo.clock.increment / 1000) + "''"
|
|
: '∞'),
|
|
'Last Move': lastMove.player + ' ' + lastMove.move + ' by ' + lastMove.by,
|
|
});
|
|
}
|
|
}
|
|
return playableGames;
|
|
}
|
|
|
|
/**
|
|
* Display the state as stored in the Dictionary collection
|
|
*
|
|
* @param {string} gameId - The alphanumeric identifier of the game for which state is going to be shown
|
|
*/
|
|
function logGameState(gameId: string) {
|
|
if (gameStateMap.has(gameId) && gameInfoMap.has(gameId)) {
|
|
const gameInfo = gameInfoMap.get(gameId);
|
|
const gameState = gameStateMap.get(gameId);
|
|
const lastMove = getLastUCIMove(gameId);
|
|
console.log(''); //process.stdout.write("\n"); Changed to support browser
|
|
/* Log before migrating to browser
|
|
if (verbose) console.table({
|
|
'Title': { white: ((gameInfo.white.title !== null) ? gameInfo.white.title : '@'), black: ((gameInfo.black.title !== null) ? gameInfo.black.title : '@'), game: 'Id: ' + gameInfo.id },
|
|
'Username': { white: gameInfo.white.name, black: gameInfo.black.name, game: 'Status: ' + gameState.status },
|
|
'Rating': { white: gameInfo.white.rating, black: gameInfo.black.rating, game: gameInfo.variant.short + ' ' + (gameInfo.rated ? 'rated' : 'unrated') },
|
|
'Timer': { white: formattedTimer(gameState.wtime), black: formattedTimer(gameState.btime), game: gameInfo.speed + ' ' + ((gameInfo.clock !== null) ? (String(gameInfo.clock.initial / 60000) + "'+" + String(gameInfo.clock.increment / 1000) + "''") : '∞') },
|
|
'Last Move': { white: (lastMove.player == 'white' ? lastMove.move : '?'), black: (lastMove.player == 'black' ? lastMove.move : '?'), game: lastMove.player },
|
|
});
|
|
*/
|
|
const innerTable =
|
|
`<table class="dgt-table"><tr><th> - </th><th>Title</th><th>Username</th><th>Rating</th><th>Timer</th><th>Last Move</th><th>gameId: ${gameInfo.id}</th></tr>` +
|
|
`<tr><td>White</td><td>${gameInfo.white.title !== null ? gameInfo.white.title : '@'}</td><td>${
|
|
gameInfo.white.name
|
|
}</td><td>${gameInfo.white.rating}</td><td>${formattedTimer(gameState.wtime)}</td><td>${
|
|
lastMove.player == 'white' ? lastMove.move : '?'
|
|
}</td><td>${
|
|
gameInfo.speed +
|
|
' ' +
|
|
(gameInfo.clock !== null
|
|
? String(gameInfo.clock.initial / 60000) + "'+" + String(gameInfo.clock.increment / 1000) + "''"
|
|
: '∞')
|
|
}</td></tr>` +
|
|
`<tr><td>Black</td><td>${gameInfo.black.title !== null ? gameInfo.black.title : '@'}</td><td>${
|
|
gameInfo.black.name
|
|
}</td><td>${gameInfo.black.rating}</td><td>${formattedTimer(gameState.btime)}</td><td>${
|
|
lastMove.player == 'black' ? lastMove.move : '?'
|
|
}</td><td>Status: ${gameState.status}</td></tr>`;
|
|
console.log(innerTable);
|
|
switch (gameState.status) {
|
|
case 'started':
|
|
//Announce the last move
|
|
if (me.id !== lastMove.by || announceAllMoves) {
|
|
announcePlay(lastMove);
|
|
}
|
|
break;
|
|
case 'outoftime':
|
|
announceWinner(
|
|
keywords[gameState.winner],
|
|
'flag',
|
|
keywords[gameState.winner] + ' ' + keywords['wins by'] + ' ' + keywords['timeout']
|
|
);
|
|
break;
|
|
case 'resign':
|
|
announceWinner(
|
|
keywords[gameState.winner],
|
|
'resign',
|
|
keywords[gameState.winner] + ' ' + keywords['wins by'] + ' ' + keywords['resignation']
|
|
);
|
|
break;
|
|
case 'mate':
|
|
announceWinner(
|
|
keywords[lastMove.player],
|
|
'mate',
|
|
keywords[lastMove.player] + ' ' + keywords['wins by'] + ' ' + keywords['#']
|
|
);
|
|
break;
|
|
case 'draw':
|
|
announceWinner('draw', 'draw', keywords['(=)']);
|
|
break;
|
|
default:
|
|
console.log(`Unknown status received: ${gameState.status}`);
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Peeks a game state and calculates who played the last move and what move it was
|
|
*
|
|
* @param {string} gameId - The alphanumeric identifier of the game where the last move is going to be calculated
|
|
*
|
|
* @return {Object} - The move in JSON
|
|
*/
|
|
function getLastUCIMove(gameId: string): { player: string; move: string; by: string } {
|
|
if (gameStateMap.has(gameId) && gameInfoMap.has(gameId)) {
|
|
const gameInfo = gameInfoMap.get(gameId);
|
|
const gameState = gameStateMap.get(gameId);
|
|
//This is the original code that does not used chessops objects and can be used to get the UCI move but not SAN.
|
|
if (String(gameState.moves).length > 1) {
|
|
const moves = gameState.moves.split(' ');
|
|
if (verbose)
|
|
console.log(`getLastUCIMove - ${moves.length} moves detected. Last one: ${moves[moves.length - 1]}`);
|
|
if (moves.length % 2 == 0) return { player: 'black', move: moves[moves.length - 1], by: gameInfo.black.id };
|
|
else return { player: 'white', move: moves[moves.length - 1], by: gameInfo.white.id };
|
|
}
|
|
}
|
|
if (verbose) console.log('getLastUCIMove - No moves.');
|
|
return { player: 'none', move: 'none', by: 'none' };
|
|
}
|
|
|
|
/**
|
|
* Feedback the user about the detected move
|
|
*
|
|
* @param lastMove JSON object with the move information
|
|
* @param wtime Remaining time for white
|
|
* @param btime Remaining time for black
|
|
*/
|
|
function announcePlay(lastMove: { player: string; move: string; by: string }) {
|
|
//ttsSay(lastMove.player);
|
|
//Now play it using text to speech library
|
|
let moveText: string;
|
|
if (announceMoveFormat && announceMoveFormat.toLowerCase() == 'san' && lastSanMove) {
|
|
moveText = lastSanMove.move;
|
|
ttsSay(replaceKeywords(padBeforeNumbers(lastSanMove.move)));
|
|
} else {
|
|
moveText = lastMove.move;
|
|
ttsSay(padBeforeNumbers(lastMove.move));
|
|
}
|
|
if (lastMove.player == 'white') {
|
|
console.log('<span class="dgt-white-move">' + moveText + ' by White' + '</span>');
|
|
} else {
|
|
console.log('<span class="dgt-black-move">' + moveText + ' by Black' + '</span>');
|
|
}
|
|
//TODO
|
|
//Give feedback on running out of time
|
|
}
|
|
|
|
function announceWinner(winner: string, status: string, message: string) {
|
|
if (winner == 'white') {
|
|
console.log(' ' + status + ' - ' + message);
|
|
} else {
|
|
console.log(' ' + status + ' - ' + message);
|
|
}
|
|
//Now play message using text to speech library
|
|
ttsSay(replaceKeywords(message.toLowerCase()));
|
|
}
|
|
|
|
function announceInvalidMove() {
|
|
if (currentGameColor == 'white') {
|
|
console.warn(' [ X X ] - Illegal move by white.');
|
|
} else {
|
|
console.warn(' [ X X ] - Illegal move by black.');
|
|
}
|
|
//Now play it using text to speech library
|
|
ttsSay(replaceKeywords('illegal move'));
|
|
}
|
|
|
|
async function connectToLiveChess() {
|
|
let SANMove: string; //a move in san format returned by liveChess
|
|
//Open the WebSocket
|
|
liveChessConnection = new WebSocket(liveChessURL ? liveChessURL : 'ws://localhost:1982/api/v1.0');
|
|
|
|
//Attach Events
|
|
liveChessConnection.onopen = () => {
|
|
isLiveChessConnected = true;
|
|
if (verbose) console.info('Websocket onopen: Connection to LiveChess was sucessful');
|
|
liveChessConnection.send('{"id":1,"call":"eboards"}');
|
|
};
|
|
|
|
liveChessConnection.onerror = () => {
|
|
console.error('Websocket ERROR: ');
|
|
};
|
|
|
|
liveChessConnection.onclose = () => {
|
|
console.error('Websocket to LiveChess disconnected');
|
|
//Clear the value of current serial number this serves as a disconnected status
|
|
currentSerialnr = '0';
|
|
//Set connection state to false
|
|
isLiveChessConnected = false;
|
|
DGTgameId = '';
|
|
};
|
|
|
|
liveChessConnection.onmessage = async e => {
|
|
if (verbose) console.info('Websocket onmessage with data:' + e.data);
|
|
const message = JSON.parse(e.data);
|
|
//Store last board if received
|
|
if (message.response == 'feed' && !!message.param.board) {
|
|
lastLiveChessBoard = message.param.board;
|
|
}
|
|
if (message.response == 'call' && message.id == '1') {
|
|
//Get the list of available boards on LiveChess
|
|
boards = message.param;
|
|
console.table(boards);
|
|
if (verbose) console.info(boards[0].serialnr);
|
|
//TODO
|
|
//we need to be able to handle more than one board
|
|
//for now using the first board found
|
|
//Update the base subscription message with the serial number
|
|
currentSerialnr = boards[0].serialnr;
|
|
subscription.param.param.serialnr = currentSerialnr;
|
|
if (verbose) console.info('Websocket onmessage[call]: board serial number updated to: ' + currentSerialnr);
|
|
if (verbose) console.info('Webscoket - about to send the following message \n' + JSON.stringify(subscription));
|
|
liveChessConnection.send(JSON.stringify(subscription));
|
|
//Check if the board is properly connected
|
|
if (boards[0].state != 'ACTIVE' && boards[0].state != 'INACTIVE')
|
|
// "NOTRESPONDING" || "DELAYED"
|
|
console.error(`Board with serial ${currentSerialnr} is not properly connected. Please fix`);
|
|
//Send setup with stating position
|
|
|
|
if (
|
|
gameStateMap.has(currentGameId) &&
|
|
gameConnectionMap.get(currentGameId)!.connected &&
|
|
gameStateMap.get(currentGameId).status == 'started'
|
|
) {
|
|
//There is a game in progress, setup the board as per lichess board
|
|
if (currentGameId != DGTgameId) {
|
|
//We know we have not synchronized yet
|
|
if (verbose) console.info('There is a game in progress, calling liveChessBoardSetUp...');
|
|
sendBoardToLiveChess(gameChessBoardMap.get(currentGameId)!);
|
|
}
|
|
}
|
|
} else if (message.response == 'feed' && !!message.param.san) {
|
|
//Received move from board
|
|
if (verbose) console.info('onmessage - san: ' + message.param.san);
|
|
//get last move known to lichess and avoid calling multiple times this function
|
|
const lastMove = getLastUCIMove(currentGameId);
|
|
if (message.param.san.length == 0) {
|
|
if (verbose) console.info('onmessage - san is empty');
|
|
} else if (
|
|
lastLegalParam !== undefined &&
|
|
JSON.stringify(lastLegalParam.san) == JSON.stringify(message.param.san)
|
|
) {
|
|
//Prevent duplicates since LiveChess may send the same move twice
|
|
//It looks like a duplicate, so just ignore it
|
|
if (verbose) console.info('onmessage - Duplicate position and san move received and will be ignored');
|
|
} else {
|
|
//A move was received
|
|
//Get all the moves on the param.san that are not present on lastLegalParam.san
|
|
//it is possible to receive two new moves on the message. Don't assume only the last move is pending.
|
|
let movesToProcess = 1;
|
|
if (lastLegalParam !== undefined) movesToProcess = message.param.san.length - lastLegalParam.san.length;
|
|
//Check border case in which DGT Board LiveChess detects the wrong move while pieces are still on the air
|
|
if (movesToProcess > 1) {
|
|
if (verbose)
|
|
console.warn('onmessage - Multiple moves received on single message - movesToProcess: ' + movesToProcess);
|
|
if (localBoard.turn == currentGameColor) {
|
|
//If more than one move is received when its the DGT board player's turn this may be a invalid move
|
|
//Move will be quarantined by 2.5 seconds
|
|
const quarantinedlastLegalParam = lastLegalParam;
|
|
await sleep(2500);
|
|
//Check if a different move was received and processed during quarantine
|
|
if (JSON.stringify(lastLegalParam.san) != JSON.stringify(quarantinedlastLegalParam.san)) {
|
|
//lastLegalParam was altered, this mean a new move was received from LiveChess during quarantine
|
|
console.warn(
|
|
'onmessage - Invalid moved quarantined and not sent to lichess. Newer move interpretration received.'
|
|
);
|
|
return;
|
|
}
|
|
//There is a chance that the same move came twice and quarantined twice before updating lastLegalParam
|
|
else if (
|
|
lastLegalParam !== undefined &&
|
|
JSON.stringify(lastLegalParam.san) == JSON.stringify(message.param.san)
|
|
) {
|
|
//It looks like a duplicate, so just ignore it
|
|
if (verbose)
|
|
console.info(
|
|
'onmessage - Duplicate position and san move received after quarantine and will be ignored'
|
|
);
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
//Update the lastLegalParam object to to help prevent duplicates and detect when more than one move is received
|
|
lastLegalParam = message.param;
|
|
for (let i = movesToProcess; i > 0; i--) {
|
|
//Get first move to process, usually the last since movesToProcess is usually 1
|
|
SANMove = String(message.param.san[message.param.san.length - i]).trim();
|
|
if (verbose) console.info('onmessage - SANMove = ' + SANMove);
|
|
const moveObject = <NormalMove | undefined>parseSan(localBoard, SANMove); //get move from DGT LiveChess
|
|
//if valid move on local chessops
|
|
if (moveObject && localBoard.isLegal(moveObject)) {
|
|
if (verbose) console.info('onmessage - Move is legal');
|
|
//if received move.color == this.currentGameColor
|
|
if (localBoard.turn == currentGameColor) {
|
|
//This is a valid new move send it to lichess
|
|
if (verbose) console.info('onmessage - Valid Move played: ' + SANMove);
|
|
await validateAndSendBoardMove(moveObject);
|
|
//Update the lastSanMove
|
|
lastSanMove = { player: localBoard.turn, move: SANMove, by: me.id };
|
|
//Play the move on local board to keep it in sync
|
|
localBoard.play(moveObject);
|
|
} else if (compareMoves(lastMove.move, moveObject)) {
|
|
//This is a valid adjustment - Just making the move from Lichess
|
|
if (verbose) console.info('onmessage - Valid Adjustment: ' + SANMove);
|
|
//no need to send anything to Lichess moveObject required
|
|
//lastSanMove will be updated once this move comes back from lichess
|
|
//Play the move on local board to keep it in sync
|
|
localBoard.play(moveObject);
|
|
} else {
|
|
//Invalid Adjustment. Move was legal but does not match last move received from Lichess
|
|
console.error('onmessage - Invalid Adjustment was made');
|
|
if (compareMoves(lastMove.move, moveObject)) {
|
|
console.error('onmessage - Played move has not been received by Lichess.');
|
|
} else {
|
|
console.error('onmessage - Expected:' + lastMove.move + ' by ' + lastMove.player);
|
|
console.error('onmessage - Detected:' + makeUci(moveObject) + ' by ' + localBoard.turn);
|
|
}
|
|
announceInvalidMove();
|
|
await sleep(1000);
|
|
//Repeat last game state announcement
|
|
announcePlay(lastMove);
|
|
}
|
|
} else {
|
|
//Move was valid on DGT Board but not legal on localBoard
|
|
if (verbose) console.info('onmessage - Move is NOT legal');
|
|
if (lastMove.move == SANMove) {
|
|
//This is fine, the same last move was received again and seems illegal
|
|
if (verbose) console.warn('onmessage - Move received is the same as the last move played: ' + SANMove);
|
|
} else if (SANMove.startsWith('O-')) {
|
|
//This is may be fine, sometimes castling triggers twice and second time is invalid
|
|
if (verbose) console.warn('onmessage - Castling may be duplicated as the last move played: ' + SANMove);
|
|
} else {
|
|
//Receiving a legal move on DGT Board but invalid move on localBoard signals a de-synchronization
|
|
if (verbose)
|
|
console.error(
|
|
'onmessage - invalidMove - Position Mismatch between DGT Board and internal in-memory Board. SAN: ' +
|
|
SANMove
|
|
);
|
|
announceInvalidMove();
|
|
console.info(board(localBoard.board));
|
|
}
|
|
}
|
|
} //end for
|
|
} //end else - move was received
|
|
} else if (message.response == 'feed') {
|
|
//feed received but not san
|
|
//No moves received, this may be an out of snc problem or just the starting position
|
|
if (verbose) console.info('onmessage - No move received on feed event.');
|
|
//TODO THIS MAY REQUIRE RE-SYNCHRONIZATION BETWEEN LICHESS AND DGT BOARD
|
|
}
|
|
};
|
|
}
|
|
|
|
async function DGTliveChessConnectionLoop() {
|
|
//Attempt connection right away
|
|
connectToLiveChess();
|
|
//Program ends after 20 re-connection attempts
|
|
for (let attempts = 0; attempts < 20; attempts++) {
|
|
do {
|
|
//Just sleep five seconds while there is a valid currentSerialnr
|
|
await sleep(5000);
|
|
} while (currentSerialnr != '0' && isLiveChessConnected);
|
|
//currentSerialnr is 0 so still no connection to board. Retry
|
|
if (!isLiveChessConnected) {
|
|
console.warn('No connection to DGT Live Chess. Attempting re-connection. Attempt: ' + attempts);
|
|
connectToLiveChess();
|
|
} else {
|
|
//Websocket is fine but still no board detected
|
|
console.warn(
|
|
'Connection to DGT Live Chess is Fine but no board is detected. Attempting re-connection. Attempt: ' +
|
|
attempts
|
|
);
|
|
liveChessConnection.send('{"id":1,"call":"eboards"}');
|
|
}
|
|
}
|
|
console.error('No connection to DGT Live Chess after maximum number of attempts (20). Reload page to start again.');
|
|
}
|
|
|
|
/**
|
|
* Synchronizes the position on Lichess with the position on the board
|
|
* If the position does not match, no moves will be received from LiveChess
|
|
* @param chess - The chessops Chess object with the position on Lichess
|
|
*/
|
|
async function sendBoardToLiveChess(chess: Chess) {
|
|
const fen = makeFen(chess.toSetup());
|
|
const setupMessage = {
|
|
id: 3,
|
|
call: 'call',
|
|
param: {
|
|
id: 1,
|
|
method: 'setup',
|
|
param: {
|
|
fen: fen,
|
|
},
|
|
},
|
|
};
|
|
if (verbose) console.log('setUp -: ' + JSON.stringify(setupMessage));
|
|
if (isLiveChessConnected && currentSerialnr != '0') {
|
|
liveChessConnection.send(JSON.stringify(setupMessage));
|
|
//Store the gameId so we now we already synchronized
|
|
DGTgameId = currentGameId;
|
|
//Initialize localBoard too so it matched what was sent to LiveChess
|
|
localBoard = chess.clone();
|
|
//Reset other DGT Board tracking variables otherwise last move from DGT may be incorrect
|
|
lastLegalParam = { board: '', san: [] };
|
|
if (verbose) console.log('setUp -: Sent.');
|
|
} else {
|
|
console.error('WebSocket is not open or is not ready to receive setup - cannot send setup command.');
|
|
console.error(
|
|
`isLiveChessConnected: ${isLiveChessConnected} - DGTgameId: ${DGTgameId} - currentSerialnr: ${currentSerialnr} - currentGameId: ${currentGameId}`
|
|
);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* This function handles sending the move to the right lichess game.
|
|
* If more than one game is being played, it will ask which game to connect to,
|
|
* waiting for user input. This block causes the method to become async
|
|
*
|
|
* @param {Object} boardMove - The move in chessops format or string if in lichess format
|
|
*/
|
|
async function validateAndSendBoardMove(boardMove: NormalMove) {
|
|
//While there is not an active game, keep trying to find one so the move is not lost
|
|
while (
|
|
!(
|
|
gameStateMap.has(currentGameId) &&
|
|
gameConnectionMap.get(currentGameId)!.connected &&
|
|
gameStateMap.get(currentGameId).status == 'started'
|
|
)
|
|
) {
|
|
//Wait a few seconds to see if the games reconnects or starts and give some space to other code to run
|
|
console.warn('validateAndSendBoardMove - Cannot send move while disconnected. Re-Trying in 2 seconds...');
|
|
await sleep(2000);
|
|
//Now attempt to select for which game is this command intended
|
|
await chooseCurrentGame();
|
|
}
|
|
//Now send the move
|
|
const command = makeUci(boardMove);
|
|
sendMove(currentGameId, command);
|
|
}
|
|
|
|
/**
|
|
* Make a Board move
|
|
*
|
|
* /api/board/game/{gameId}/move/{move}
|
|
*
|
|
* Make a move in a game being played with the Board API.
|
|
* The move can also contain a draw offer/agreement.
|
|
*
|
|
* @param {string} gameId - The gameId for the active game
|
|
* @param {string} uciMove - The move un UCI format
|
|
*/
|
|
function sendMove(gameId: string, uciMove: string) {
|
|
//prevent sending empty moves
|
|
if (uciMove.length > 1) {
|
|
//Log intention
|
|
//Automatically decline draws when making a move
|
|
const url = `/api/board/game/${gameId}/move/${uciMove}?offeringDraw=false`;
|
|
if (verbose) console.log('sendMove - About to call ' + url);
|
|
fetch(url, {
|
|
method: 'POST',
|
|
headers: { Authorization: 'Bearer ' + token },
|
|
})
|
|
.then(response => {
|
|
try {
|
|
if (response.status == 200 || response.status == 201) {
|
|
//Move successfully sent
|
|
if (verbose) console.log('sendMove - Move successfully sent.');
|
|
} else {
|
|
response.json().then(errorJson => {
|
|
console.error('sendMove - Failed to send move. ' + errorJson.error);
|
|
});
|
|
}
|
|
} catch (error) {
|
|
console.error('sendMove - Unexpected error. ' + error);
|
|
}
|
|
})
|
|
.catch(err => {
|
|
console.error('sendMove - Error. ' + err.message);
|
|
});
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Replaces letters with full name of the pieces or move name
|
|
* @param sanMove The move in san format
|
|
*
|
|
* @returns {String} - The San move with words instead of letters
|
|
*/
|
|
function replaceKeywords(sanMove) {
|
|
let extendedSanMove = sanMove;
|
|
for (let i = 0; i < keywordsBase.length; i++) {
|
|
try {
|
|
extendedSanMove = extendedSanMove.replace(keywordsBase[i], ' ' + keywords[keywordsBase[i]].toLowerCase() + ' ');
|
|
} catch (error) {
|
|
console.error(`raplaceKeywords - Error replacing keyword. ${keywordsBase[i]} . ${error}`);
|
|
}
|
|
}
|
|
return extendedSanMove;
|
|
}
|
|
|
|
/**
|
|
*
|
|
* @param moveString The move in SAN or UCI
|
|
*
|
|
* @returns {String} - The move with spaces before the numbers for better TTS
|
|
*/
|
|
function padBeforeNumbers(moveString: string) {
|
|
let paddedMoveString = '';
|
|
for (const c of moveString) {
|
|
Number.isInteger(+c) ? (paddedMoveString += ` ${c} `) : (paddedMoveString += c);
|
|
}
|
|
return paddedMoveString;
|
|
}
|
|
|
|
/**
|
|
* GLOBAL VARIABLES
|
|
*/
|
|
async function ttsSay(text: string) {
|
|
//Check if Voice is disabled
|
|
if (verbose) console.log('TTS - for text: ' + text);
|
|
if (!speechSynthesisOn) return;
|
|
const utterThis = new SpeechSynthesisUtterance(text);
|
|
const selectedOption = voice;
|
|
const availableVoices = speechSynthesis.getVoices();
|
|
for (let i = 0; i < availableVoices.length; i++) {
|
|
if (availableVoices[i].name === selectedOption) {
|
|
utterThis.voice = availableVoices[i];
|
|
break;
|
|
}
|
|
}
|
|
//utterThis.pitch = pitch.value;
|
|
utterThis.rate = 0.6;
|
|
speechSynthesis.speak(utterThis);
|
|
}
|
|
|
|
function startingPosition(): Chess {
|
|
return Chess.fromSetup(defaultSetup()).unwrap();
|
|
}
|
|
|
|
/**
|
|
* Compare moves in different formats.
|
|
* Fixes issue in which chessops return UCI_960 for castling instead of plain UCI
|
|
* @param lastMove - the move a string received from lichess
|
|
* @param moveObject - the move in chessops format after applyng the SAN to localBoard
|
|
* @returns {Boolean} - True if the moves are the same
|
|
*/
|
|
function compareMoves(lastMove: string, moveObject: NormalMove): boolean {
|
|
try {
|
|
const uciMove = makeUci(moveObject);
|
|
if (verbose) console.log(`Comparing ${lastMove} with ${uciMove}`);
|
|
if (lastMove == uciMove) {
|
|
//it's the same move
|
|
return true;
|
|
}
|
|
if (verbose) console.log('Moves look different. Check if this is a castling mismatch.');
|
|
const castlingSide = localBoard.castlingSide(moveObject);
|
|
if (lastMove.length > 2 && castlingSide) {
|
|
//It was a castling so it still may be the same move
|
|
if (lastMove.startsWith(uciMove.substring(0, 2))) {
|
|
//it was the same starting position for the king
|
|
if (
|
|
lastMove.startsWith('e1g1') ||
|
|
lastMove.startsWith('e1c1') ||
|
|
lastMove.startsWith('e8c8') ||
|
|
lastMove.startsWith('e8g8')
|
|
) {
|
|
//and the last move looks like a castling too
|
|
return true;
|
|
}
|
|
}
|
|
}
|
|
} catch (err) {
|
|
console.warn('compareMoves - ' + err);
|
|
}
|
|
return false;
|
|
}
|
|
|
|
/*
|
|
function opponent(): { color: string, id: string, name: string } {
|
|
//"white":{"id":"godking666","name":"Godking666","title":null,"rating":1761},"black":{"id":"andrescavallin","name":"andrescavallin","title":null
|
|
if (gameInfoMap.get(currentGameId).white.id == me.id)
|
|
return { color: 'black', id: gameInfoMap.get(currentGameId).black.id, name: gameInfoMap.get(currentGameId).black.name };
|
|
else
|
|
return { color: 'white', id: gameInfoMap.get(currentGameId).white.id, name: gameInfoMap.get(currentGameId).white.name };
|
|
}
|
|
*/
|
|
|
|
function start() {
|
|
console.log('');
|
|
console.log(' ,...., ▄████▄ ██░ ██ ▓█████ ██████ ██████ ');
|
|
console.log(' ,::::::< ▒██▀ ▀█ ▓██░ ██▒▓█ ▀ ▒██ ▒ ▒██ ▒ ');
|
|
console.log(' ,::/^\\"``. ▒▓█ ▄ ▒██▀▀██░▒███ ░ ▓██▄ ░ ▓██▄ ');
|
|
console.log(' ,::/, ` e`. ▒▓▓▄ ▄██▒░▓█ ░██ ▒▓█ ▄ ▒ ██▒ ▒ ██▒ ');
|
|
console.log(" ,::; | '. ▒ ▓███▀ ░░▓█▒░██▓░▒████▒▒██████▒▒▒██████▒▒ ");
|
|
console.log(' ,::| ___,-. c) ░ ░▒ ▒ ░ ▒ ░░▒░▒░░ ▒░ ░▒ ▒▓▒ ▒ ░▒ ▒▓▒ ▒ ░ ');
|
|
console.log(" ;::| \\ '-' ░ ▒ ▒ ░▒░ ░ ░ ░ ░░ ░▒ ░ ░░ ░▒ ░ ░ ");
|
|
console.log(' ;::| \\ ░ ░ ░░ ░ ░ ░ ░ ░ ░ ░ ░ ');
|
|
console.log(' ;::| _.=`\\ ░ ░ ░ ░ ░ ░ ░ ░ ░ ');
|
|
console.log(' `;:|.=` _.=`\\ ░ ');
|
|
console.log(" '|_.=` __\\ ");
|
|
console.log(' `\\_..==`` / Lichess.org - DGT Electronic Board Connector ');
|
|
console.log(" .'.___.-'. Developed by Andres Cavallin and Juan Cavallin ");
|
|
console.log(' / \\ v1.0.7 ');
|
|
console.log("jgs('--......--') ");
|
|
console.log(" /'--......--'\\ ");
|
|
console.log(' `"--......--"` ');
|
|
}
|
|
|
|
/**
|
|
* Show the profile and then
|
|
* Start the Main Loop
|
|
*/
|
|
start();
|
|
getProfile();
|
|
lichessConnectionLoop();
|
|
DGTliveChessConnectionLoop();
|
|
}
|