mirror of
https://github.com/arthur-pbty/xiao.git
synced 2026-06-03 23:36:43 +02:00
377 lines
12 KiB
JavaScript
377 lines
12 KiB
JavaScript
const Command = require('../../framework/Command');
|
|
const jsChess = require('js-chess-engine');
|
|
const { createCanvas, loadImage } = require('canvas');
|
|
const moment = require('moment');
|
|
const validateFEN = require('fen-validator').default;
|
|
const { stripIndents } = require('common-tags');
|
|
const path = require('path');
|
|
const { verify, reactIfAble } = require('../../util/Util');
|
|
const { centerImagePart } = require('../../util/Canvas');
|
|
const { FAILURE_EMOJI_ID } = process.env;
|
|
const turnRegex = /^(?:((?:[A-H][1-8])|(?:[PKRQBN]))?([A-H]|X)?([A-H][1-8])(?:=([QRNB]))?)|(?:0-0(?:-0)?)$/;
|
|
const pieces = ['pawn', 'rook', 'knight', 'king', 'queen', 'bishop'];
|
|
const cols = ['A', 'B', 'C', 'D', 'E', 'F', 'G', 'H'];
|
|
|
|
module.exports = class ChessCommand extends Command {
|
|
constructor(client) {
|
|
super(client, {
|
|
name: 'chess',
|
|
group: 'games-mp',
|
|
memberName: 'chess',
|
|
description: 'Play a game of Chess with another user or the AI.',
|
|
game: true,
|
|
credit: [
|
|
{
|
|
name: 'Wikimedia Commons',
|
|
url: 'https://commons.wikimedia.org/wiki/Main_Page',
|
|
reason: 'Piece Images',
|
|
reasonURL: 'https://commons.wikimedia.org/wiki/Category:PNG_chess_pieces/Standard_transparent'
|
|
},
|
|
{
|
|
name: 'Chess.com',
|
|
url: 'https://www.chess.com/',
|
|
reason: 'Board Image'
|
|
}
|
|
],
|
|
args: [
|
|
{
|
|
key: 'opponent',
|
|
type: 'user'
|
|
},
|
|
{
|
|
key: 'time',
|
|
type: 'integer',
|
|
max: 120,
|
|
min: 0
|
|
},
|
|
{
|
|
key: 'fen',
|
|
type: 'string',
|
|
default: '',
|
|
validate: fen => validateFEN(fen)
|
|
}
|
|
]
|
|
});
|
|
|
|
this.images = null;
|
|
}
|
|
|
|
async run(msg, { opponent, time, fen }) {
|
|
if (opponent.id === msg.author.id) return msg.reply('You may not play against yourself.');
|
|
if (this.client.blacklist.user.includes(opponent.id)) return msg.reply('This user is blacklisted.');
|
|
if (!this.images) await this.loadImages();
|
|
if (!opponent.bot) {
|
|
await msg.say(`${opponent}, do you accept this challenge?`);
|
|
const verification = await verify(msg.channel, opponent);
|
|
if (!verification) return msg.say('Looks like they declined...');
|
|
}
|
|
const resumeGame = await this.client.redis.db.get(`chess-${msg.author.id}`);
|
|
let game;
|
|
let whiteTime = time === 0 ? Infinity : time * 60000;
|
|
let blackTime = time === 0 ? Infinity : time * 60000;
|
|
let whitePlayer = msg.author;
|
|
let blackPlayer = opponent;
|
|
if (resumeGame) {
|
|
await msg.reply(stripIndents`
|
|
You have a saved game, do you want to resume it?
|
|
**This will delete your saved game.**
|
|
`);
|
|
const verification = await verify(msg.channel, msg.author);
|
|
if (verification) {
|
|
try {
|
|
const data = JSON.parse(resumeGame);
|
|
game = new jsChess.Game(data.fen);
|
|
whiteTime = data.whiteTime === -1 ? Infinity : data.whiteTime;
|
|
blackTime = data.blackTime === -1 ? Infinity : data.blackTime;
|
|
whitePlayer = data.color === 'white' ? msg.author : opponent;
|
|
blackPlayer = data.color === 'black' ? msg.author : opponent;
|
|
await this.client.redis.db.del(`chess-${msg.author.id}`);
|
|
} catch {
|
|
await this.client.redis.db.del(`chess-${msg.author.id}`);
|
|
return msg.reply('An error occurred reading your saved game. It will be deleted.');
|
|
}
|
|
} else {
|
|
game = new jsChess.Game(fen || undefined);
|
|
}
|
|
} else {
|
|
game = new jsChess.Game(fen || undefined);
|
|
}
|
|
let prevPieces = null;
|
|
let saved = false;
|
|
while (!game.exportJson().isFinished && game.exportJson().halfMove <= 50) {
|
|
const gameState = game.exportJson();
|
|
const user = gameState.turn === 'black' ? blackPlayer : whitePlayer;
|
|
const userTime = gameState.turn === 'black' ? blackTime : whiteTime;
|
|
if (user.bot) {
|
|
prevPieces = Object.assign({}, game.exportJson().pieces);
|
|
const now = new Date();
|
|
game.aiMove(1);
|
|
const timeTaken = new Date() - now;
|
|
if (gameState.turn === 'black') blackTime -= timeTaken - 5000;
|
|
if (gameState.turn === 'white') whiteTime -= timeTaken - 5000;
|
|
} else {
|
|
const displayTime = userTime === Infinity ? 'Infinite' : moment.duration(userTime).format();
|
|
await msg.say(stripIndents`
|
|
${user}, what move do you want to make (ex. A1A2 or NC3)? Type \`end\` to forfeit.
|
|
You can save your game by typing \`save\`. Can't think of a move? Use \`play for me\`.
|
|
|
|
_You are ${gameState.check ? '**in check!**' : 'not in check.'}_
|
|
**Time Remaining: ${displayTime}** (Max 10min per turn)
|
|
**FEN:** \`${game.exportFEN()}\`
|
|
`, { files: [{ attachment: this.displayBoard(gameState, prevPieces), name: 'chess.png' }] });
|
|
prevPieces = Object.assign({}, game.exportJson().pieces);
|
|
const moves = game.moves();
|
|
const pickFilter = res => {
|
|
if (![msg.author.id, opponent.id].includes(res.author.id)) return false;
|
|
const choice = res.content.toUpperCase();
|
|
if (choice === 'END') return true;
|
|
if (choice === 'SAVE') return true;
|
|
if (choice === 'PLAY FOR ME') return true;
|
|
if (res.author.id !== user.id) return false;
|
|
const move = choice.match(turnRegex);
|
|
if (!move) return false;
|
|
const parsed = this.parseSAN(gameState, moves, move);
|
|
if (!parsed || !moves[parsed[0]] || !moves[parsed[0]].includes(parsed[1])) {
|
|
reactIfAble(res, res.author, FAILURE_EMOJI_ID, '❌');
|
|
return false;
|
|
}
|
|
return true;
|
|
};
|
|
const now = new Date();
|
|
const turn = await msg.channel.awaitMessages({
|
|
filter: pickFilter,
|
|
max: 1,
|
|
time: Math.min(userTime, 600000)
|
|
});
|
|
if (!turn.size) {
|
|
const timeTaken = new Date() - now;
|
|
if (userTime - timeTaken <= 0) {
|
|
return msg.say(`${user.id === msg.author.id ? opponent : msg.author} wins from timeout!`);
|
|
} else {
|
|
return msg.say(`${user}, the game has been ended. You cannot take more than 10 minutes.`);
|
|
}
|
|
}
|
|
if (turn.first().content.toLowerCase() === 'end') break;
|
|
if (turn.first().content.toLowerCase() === 'save') {
|
|
const { author } = turn.first();
|
|
const alreadySaved = await this.client.redis.db.get(`chess-${author.id}`);
|
|
if (alreadySaved) {
|
|
await msg.say('You already have a saved game, do you want to overwrite it?');
|
|
const verification = await verify(msg.channel, author);
|
|
if (!verification) continue;
|
|
}
|
|
if (gameState.turn === 'black') blackTime -= new Date() - now;
|
|
if (gameState.turn === 'white') whiteTime -= new Date() - now;
|
|
await this.client.redis.db.set(
|
|
`chess-${author.id}`,
|
|
this.exportGame(
|
|
game,
|
|
blackTime,
|
|
whiteTime,
|
|
whitePlayer.id === author.id ? 'white' : 'black'
|
|
)
|
|
);
|
|
saved = true;
|
|
break;
|
|
}
|
|
if (turn.first().content.toLowerCase() === 'play for me') {
|
|
game.aiMove(0);
|
|
} else {
|
|
const choice = this.parseSAN(gameState, moves, turn.first().content.toUpperCase().match(turnRegex));
|
|
const pawnMoved = gameState.pieces[choice[0]].toUpperCase() === 'P';
|
|
game.move(choice[0], choice[1]);
|
|
if (pawnMoved && choice[1].endsWith(gameState.turn === 'white' ? '8' : '1')) {
|
|
game.setPiece(choice[1], gameState.turn === 'white' ? choice[2] : choice[2].toLowerCase());
|
|
}
|
|
}
|
|
const timeTaken = new Date() - now;
|
|
if (gameState.turn === 'black') blackTime -= timeTaken - 5000;
|
|
if (gameState.turn === 'white') whiteTime -= timeTaken - 5000;
|
|
}
|
|
}
|
|
if (saved) {
|
|
return msg.say(stripIndents`
|
|
Game saved! Use ${this.usage(opponent.tag)} to resume it.
|
|
You do not have to use the same opponent to resume the game.
|
|
If you want to delete your saved game, use ${this.client.registry.commands.get('chess-delete').usage()}.
|
|
`);
|
|
}
|
|
const gameState = game.exportJson();
|
|
if (gameState.halfMove > 50) return msg.say('Due to the fifty move rule, this game is a draw.');
|
|
if (!gameState.isFinished) return msg.say('Game ended due to forfeit.');
|
|
if (!gameState.checkMate && gameState.isFinished) {
|
|
return msg.say('Stalemate! This game is a draw.', {
|
|
files: [{ attachment: this.displayBoard(gameState, prevPieces), name: 'chess.png' }]
|
|
});
|
|
}
|
|
const winner = gameState.turn === 'black' ? whitePlayer : blackPlayer;
|
|
return msg.say(`Checkmate! Congrats, ${winner}!`, {
|
|
files: [{ attachment: this.displayBoard(gameState, prevPieces), name: 'chess.png' }]
|
|
});
|
|
}
|
|
|
|
parseSAN(gameState, moves, move) {
|
|
if (!move) return null;
|
|
if (move[0] === '0-0') {
|
|
if (gameState.turn === 'white') {
|
|
if (gameState.castling.whiteShort) return ['E1', 'G1'];
|
|
return null;
|
|
} else if (gameState.turn === 'black') {
|
|
if (gameState.castling.blackShort) return ['E8', 'G8'];
|
|
return null;
|
|
}
|
|
}
|
|
if (move[0] === '0-0-0') {
|
|
if (gameState.turn === 'white') {
|
|
if (gameState.castling.whiteLong) return ['E1', 'C1'];
|
|
return null;
|
|
} else if (gameState.turn === 'black') {
|
|
if (gameState.castling.blackLong) return ['E8', 'C8'];
|
|
return null;
|
|
}
|
|
}
|
|
if (!move[3]) return null;
|
|
const initial = move[1] || 'P';
|
|
if (gameState.pieces[initial]) return [initial, move[3], move[4] || 'Q'];
|
|
const possiblePieces = Object.keys(gameState.pieces).filter(piece => {
|
|
if (this.pickImage(gameState.pieces[piece]).color !== gameState.turn) return false;
|
|
if (gameState.pieces[piece].toUpperCase() !== initial) return false;
|
|
if (move[2] && move[2] !== 'X' && !piece.startsWith(move[2])) return false;
|
|
if (move[4]) {
|
|
if (!piece.endsWith(gameState.turn === 'black' ? '2' : '7')) return false;
|
|
if (gameState.pieces[piece].toUpperCase() !== 'P') return false;
|
|
}
|
|
if (!moves[piece]) return false;
|
|
return moves[piece].includes(move[3]);
|
|
});
|
|
if (possiblePieces.length === 1) return [possiblePieces[0], move[3], move[4] || 'Q'];
|
|
return null;
|
|
}
|
|
|
|
displayBoard(gameState, prevPieces) {
|
|
const canvas = createCanvas(this.images.board.width, this.images.board.height);
|
|
const ctx = canvas.getContext('2d');
|
|
ctx.drawImage(this.images.board, 0, 0);
|
|
let w = 2;
|
|
let h = 3;
|
|
let row = 8;
|
|
let col = 0;
|
|
for (let i = 0; i < 64; i++) {
|
|
const piece = gameState.pieces[`${cols[col]}${row}`];
|
|
const prevGamePiece = prevPieces ? prevPieces[`${cols[col]}${row}`] : null;
|
|
if (piece) {
|
|
const parsed = this.pickImage(piece);
|
|
const img = this.images[parsed.color][parsed.name];
|
|
const { x, y, width, height } = centerImagePart(img, 62, 62, w, h);
|
|
if ((gameState.check || gameState.checkMate) && piece === (gameState.turn === 'white' ? 'K' : 'k')) {
|
|
ctx.fillStyle = 'red';
|
|
ctx.globalAlpha = 0.5;
|
|
ctx.fillRect(w, h, 62, 62);
|
|
ctx.globalAlpha = 1;
|
|
ctx.drawImage(img, x, y, width, height);
|
|
} else if (prevPieces && (!prevGamePiece || piece !== prevGamePiece)) {
|
|
ctx.fillStyle = 'yellow';
|
|
ctx.globalAlpha = 0.5;
|
|
ctx.fillRect(w, h, 62, 62);
|
|
ctx.globalAlpha = 1;
|
|
ctx.drawImage(img, x, y, width, height);
|
|
} else {
|
|
ctx.drawImage(img, x, y, width, height);
|
|
}
|
|
} else if (prevGamePiece) {
|
|
ctx.fillStyle = 'yellow';
|
|
ctx.globalAlpha = 0.5;
|
|
ctx.fillRect(w, h, 62, 62);
|
|
ctx.globalAlpha = 1;
|
|
}
|
|
w += 62;
|
|
col += 1;
|
|
if (col % 8 === 0 && col !== 0) {
|
|
w = 2;
|
|
col = 0;
|
|
h += 62;
|
|
row -= 1;
|
|
}
|
|
}
|
|
return canvas.toBuffer();
|
|
}
|
|
|
|
async loadImages() {
|
|
const images = { black: {}, white: {} };
|
|
images.board = await loadImage(path.join(__dirname, '..', '..', 'assets', 'images', 'chess', 'board.png'));
|
|
for (const piece of pieces) {
|
|
const blk = `black-${piece}.png`;
|
|
images.black[piece] = await loadImage(path.join(__dirname, '..', '..', 'assets', 'images', 'chess', blk));
|
|
const whi = `white-${piece}.png`;
|
|
images.white[piece] = await loadImage(path.join(__dirname, '..', '..', 'assets', 'images', 'chess', whi));
|
|
}
|
|
this.images = images;
|
|
return images;
|
|
}
|
|
|
|
pickImage(piece) {
|
|
let name;
|
|
let color;
|
|
switch (piece) {
|
|
case 'p':
|
|
name = 'pawn';
|
|
color = 'black';
|
|
break;
|
|
case 'n':
|
|
name = 'knight';
|
|
color = 'black';
|
|
break;
|
|
case 'b':
|
|
name = 'bishop';
|
|
color = 'black';
|
|
break;
|
|
case 'r':
|
|
name = 'rook';
|
|
color = 'black';
|
|
break;
|
|
case 'q':
|
|
name = 'queen';
|
|
color = 'black';
|
|
break;
|
|
case 'k':
|
|
name = 'king';
|
|
color = 'black';
|
|
break;
|
|
case 'P':
|
|
name = 'pawn';
|
|
color = 'white';
|
|
break;
|
|
case 'N':
|
|
name = 'knight';
|
|
color = 'white';
|
|
break;
|
|
case 'B':
|
|
name = 'bishop';
|
|
color = 'white';
|
|
break;
|
|
case 'R':
|
|
name = 'rook';
|
|
color = 'white';
|
|
break;
|
|
case 'Q':
|
|
name = 'queen';
|
|
color = 'white';
|
|
break;
|
|
case 'K':
|
|
name = 'king';
|
|
color = 'white';
|
|
break;
|
|
}
|
|
return { name, color };
|
|
}
|
|
|
|
exportGame(game, blackTime, whiteTime, playerColor) {
|
|
return JSON.stringify({
|
|
fen: game.exportFEN(),
|
|
blackTime: blackTime === Infinity ? -1 : blackTime,
|
|
whiteTime: whiteTime === Infinity ? -1 : whiteTime,
|
|
color: playerColor
|
|
});
|
|
}
|
|
};
|