Scrutinizer GitHub App not installed

We could not synchronize checks via GitHub's checks API since Scrutinizer's GitHub App is not installed for this repository.

Install GitHub App

ChessGame::hasEnded()   A
last analyzed

Complexity

Conditions 2
Paths 2

Size

Total Lines 2
Code Lines 1

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 2
eloc 1
nc 2
nop 0
dl 0
loc 2
rs 10
c 0
b 0
f 0
1
<?php declare(strict_types=1);
2
3
namespace Smr\Chess;
4
5
use AbstractSmrPlayer;
6
use Exception;
7
use Smr\Database;
8
use Smr\Epoch;
9
use Smr\Exceptions\UserError;
10
use Smr\Pages\Player\Chess\MatchPlay;
11
use Smr\Pages\Player\Chess\MatchResignProcessor;
12
use SmrAccount;
13
use SmrPlayer;
14
15
class ChessGame {
16
17
	public const END_RESIGN = 0;
18
	public const END_CANCEL = 1;
19
20
	/** @var array<int, self> */
21
	protected static array $CACHE_CHESS_GAMES = [];
22
23
	private Database $db;
24
25
	private readonly int $whiteID;
26
	private readonly int $blackID;
27
	private readonly int $gameID;
28
	private readonly int $startDate;
29
	private ?int $endDate;
30
	private int $winner;
31
32
	/** @var array<mixed> */
33
	private array $hasMoved;
34
	/** @var array<int, array<int, ?ChessPiece>> */
35
	private array $board;
36
	/** @var array<string> */
37
	private array $moves;
38
39
	/** @var ?array<string, array<string, int>> */
40
	private ?array $lastMove = null;
41
42
	/**
43
	 * @return array<self>
44
	 */
45
	public static function getNPCMoveGames(bool $forceUpdate = false): array {
46
		$db = Database::getInstance();
47
		$dbResult = $db->read('SELECT chess_game_id
48
					FROM npc_logins
49
					JOIN account USING(login)
50
					JOIN chess_game ON account_id = black_id OR account_id = white_id
51
					WHERE end_time > ' . Epoch::time() . ' OR end_time IS NULL;');
52
		$games = [];
53
		foreach ($dbResult->records() as $dbRecord) {
54
			$game = self::getChessGame($dbRecord->getInt('chess_game_id'), $forceUpdate);
55
			if ($game->getCurrentTurnAccount()->isNPC()) {
56
				$games[] = $game;
57
			}
58
		}
59
		return $games;
60
	}
61
62
	/**
63
	 * @return array<self>
64
	 */
65
	public static function getOngoingPlayerGames(AbstractSmrPlayer $player): array {
66
		$db = Database::getInstance();
67
		$dbResult = $db->read('SELECT chess_game_id FROM chess_game WHERE game_id = ' . $db->escapeNumber($player->getGameID()) . ' AND (black_id = ' . $db->escapeNumber($player->getAccountID()) . ' OR white_id = ' . $db->escapeNumber($player->getAccountID()) . ') AND (end_time > ' . Epoch::time() . ' OR end_time IS NULL);');
68
		$games = [];
69
		foreach ($dbResult->records() as $dbRecord) {
70
			$games[] = self::getChessGame($dbRecord->getInt('chess_game_id'));
71
		}
72
		return $games;
73
	}
74
75
	public static function getChessGame(int $chessGameID, bool $forceUpdate = false): self {
76
		if ($forceUpdate || !isset(self::$CACHE_CHESS_GAMES[$chessGameID])) {
77
			self::$CACHE_CHESS_GAMES[$chessGameID] = new self($chessGameID);
78
		}
79
		return self::$CACHE_CHESS_GAMES[$chessGameID];
80
	}
81
82
	public function __construct(private readonly int $chessGameID) {
83
		$this->db = Database::getInstance();
84
		$dbResult = $this->db->read('SELECT *
85
						FROM chess_game
86
						WHERE chess_game_id=' . $this->db->escapeNumber($chessGameID));
87
		if (!$dbResult->hasRecord()) {
88
			throw new Exception('Chess game not found: ' . $chessGameID);
89
		}
90
		$dbRecord = $dbResult->record();
91
		$this->gameID = $dbRecord->getInt('game_id');
0 ignored issues
show
Bug introduced by
The property gameID is declared read-only in Smr\Chess\ChessGame.
Loading history...
92
		$this->startDate = $dbRecord->getInt('start_time');
0 ignored issues
show
Bug introduced by
The property startDate is declared read-only in Smr\Chess\ChessGame.
Loading history...
93
		$this->endDate = $dbRecord->getNullableInt('end_time');
94
		$this->whiteID = $dbRecord->getInt('white_id');
0 ignored issues
show
Bug introduced by
The property whiteID is declared read-only in Smr\Chess\ChessGame.
Loading history...
95
		$this->blackID = $dbRecord->getInt('black_id');
0 ignored issues
show
Bug introduced by
The property blackID is declared read-only in Smr\Chess\ChessGame.
Loading history...
96
		$this->winner = $dbRecord->getInt('winner_id');
97
		$this->resetHasMoved();
98
	}
99
100
	/**
101
	 * @param array<int, array<int, ?ChessPiece>> $board
102
	 */
103
	public static function isValidCoord(int $x, int $y, array $board): bool {
104
		return $y < count($board) && $y >= 0 && $x < count($board[$y]) && $x >= 0;
105
	}
106
107
	/**
108
	 * @param array<int, array<int, ?ChessPiece>> $board
109
	 * @param array<mixed> $hasMoved
110
	 */
111
	public static function isPlayerChecked(array $board, array $hasMoved, Colour $colour): bool {
112
		foreach ($board as $row) {
113
			foreach ($row as $p) {
114
				if ($p != null && $p->colour != $colour && $p->isAttacking($board, $hasMoved, true)) {
115
					return true;
116
				}
117
			}
118
		}
119
		return false;
120
	}
121
122
	private function resetHasMoved(): void {
123
		$this->hasMoved = [
124
			Colour::White->value => [
125
				ChessPiece::KING => false,
126
				ChessPiece::ROOK => [
127
					'Queen' => false,
128
					'King' => false,
129
				],
130
			],
131
			Colour::Black->value => [
132
				ChessPiece::KING => false,
133
				ChessPiece::ROOK => [
134
					'Queen' => false,
135
					'King' => false,
136
				],
137
			],
138
			ChessPiece::PAWN => [-1, -1],
139
		];
140
	}
141
142
	public function rerunGame(bool $debugInfo = false): void {
143
		$db = Database::getInstance();
144
145
		$db->write('UPDATE chess_game
146
					SET end_time = NULL, winner_id = 0
147
					WHERE chess_game_id=' . $this->db->escapeNumber($this->chessGameID) . ';');
148
		$db->write('DELETE FROM chess_game_pieces WHERE chess_game_id = ' . $this->db->escapeNumber($this->chessGameID) . ';');
149
		self::insertPieces($this->chessGameID);
150
151
		$dbResult = $db->read('SELECT * FROM chess_game_moves WHERE chess_game_id = ' . $this->db->escapeNumber($this->chessGameID) . ' ORDER BY move_id;');
152
		$db->write('DELETE FROM chess_game_moves WHERE chess_game_id = ' . $this->db->escapeNumber($this->chessGameID) . ';');
153
		$this->moves = [];
154
		unset($this->board);
155
		unset($this->endDate);
156
		unset($this->winner);
157
		$this->resetHasMoved();
158
159
		try {
160
			foreach ($dbResult->records() as $dbRecord) {
161
				$start_x = $dbRecord->getInt('start_x');
162
				$start_y = $dbRecord->getInt('start_y');
163
				$end_x = $dbRecord->getInt('end_x');
164
				$end_y = $dbRecord->getInt('end_y');
165
				$colour = $dbRecord->getInt('move_id') % 2 == 1 ? Colour::White : Colour::Black;
166
				$promotePieceID = $dbRecord->getInt('promote_piece_id');
167
				if ($debugInfo === true) {
168
					echo 'x=', $start_x, ', y=', $start_y, ', endX=', $end_x, ', endY=', $end_y, ', colour=', $colour->name, EOL;
0 ignored issues
show
Bug introduced by
The property name does not seem to exist on Smr\Chess\Colour.
Loading history...
169
				}
170
				if ($this->tryMove($start_x, $start_y, $end_x, $end_y, $colour, $promotePieceID) != 0) {
171
					break;
172
				}
173
			}
174
		} catch (Exception $e) {
175
			if ($debugInfo === true) {
176
				echo $e->getMessage() . EOL . $e->getTraceAsString() . EOL;
177
			}
178
			// We probably tried an invalid move - move on.
179
		}
180
	}
181
182
	/**
183
	 * @return array<int, array<int, ?ChessPiece>>
184
	 */
185
	public function getBoard(): array {
186
		if (!isset($this->board)) {
187
			$dbResult = $this->db->read('SELECT * FROM chess_game_pieces WHERE chess_game_id=' . $this->db->escapeNumber($this->chessGameID) . ';');
188
			$pieces = [];
189
			foreach ($dbResult->records() as $dbRecord) {
190
				$pieces[] = new ChessPiece(Colour::from($dbRecord->getString('colour')), $dbRecord->getInt('piece_id'), $dbRecord->getInt('x'), $dbRecord->getInt('y'), $dbRecord->getInt('piece_no'));
191
			}
192
			$this->board = $this->parsePieces($pieces);
193
		}
194
		return $this->board;
195
	}
196
197
	/**
198
	 * Get the board from black's perspective
199
	 *
200
	 * @return array<int, array<int, ?ChessPiece>>
201
	 */
202
	public function getBoardReversed(): array {
203
		// Need to reverse both the rows and the files to rotate the board
204
		$board = array_reverse($this->getBoard(), true);
205
		foreach ($board as $key => $row) {
206
			$board[$key] = array_reverse($row, true);
207
		}
208
		return $board;
209
	}
210
211
	/**
212
	 * @return ?array<string, array<string, int>>
213
	 */
214
	public function getLastMove(): ?array {
215
		$this->getMoves();
216
		return $this->lastMove;
217
	}
218
219
	/**
220
	 * Determines if a board square is part of the last move
221
	 * (returns true for both the 'To' and 'From' squares).
222
	 */
223
	public function isLastMoveSquare(int $x, int $y): bool {
224
		$lastMove = $this->getLastMove();
225
		if ($lastMove === null) {
226
			return false;
227
		}
228
		return ($x == $lastMove['From']['X'] && $y == $lastMove['From']['Y']) || ($x == $lastMove['To']['X'] && $y == $lastMove['To']['Y']);
229
	}
230
231
	/**
232
	 * @return array<string>
233
	 */
234
	public function getMoves(): array {
235
		if (!isset($this->moves)) {
236
			$dbResult = $this->db->read('SELECT * FROM chess_game_moves WHERE chess_game_id = ' . $this->db->escapeNumber($this->chessGameID) . ' ORDER BY move_id;');
237
			$this->moves = [];
238
			$mate = false;
239
			foreach ($dbResult->records() as $dbRecord) {
240
				$pieceTakenID = $dbRecord->getNullableInt('piece_taken');
241
				$promotionPieceID = $dbRecord->getNullableInt('promote_piece_id');
242
				$this->moves[] = $this->createMove(
243
					$dbRecord->getInt('piece_id'),
244
					$dbRecord->getInt('start_x'),
245
					$dbRecord->getInt('start_y'),
246
					$dbRecord->getInt('end_x'),
247
					$dbRecord->getInt('end_y'),
248
					$pieceTakenID,
249
					$dbRecord->getNullableString('checked'),
250
					$dbRecord->getInt('move_id') % 2 == 1 ? Colour::White : Colour::Black,
251
					$dbRecord->getNullableString('castling'),
252
					$dbRecord->getBoolean('en_passant'),
253
					$promotionPieceID
254
				);
255
				$mate = $dbRecord->getNullableString('checked') == 'MATE';
256
			}
257
			if (!$mate && $this->hasEnded()) {
258
				if ($this->getWinner() != 0) {
259
					$this->moves[] = ($this->getWinner() == $this->getWhiteID() ? 'Black' : 'White') . ' Resigned.';
260
				} elseif (count($this->moves) < 2) {
261
					$this->moves[] = 'Game Cancelled.';
262
				} else {
263
					$this->moves[] = 'Game Drawn.';
264
				}
265
			}
266
		}
267
		return $this->moves;
268
	}
269
270
	public function getFENString(): string {
271
		$fen = '';
272
		$board = $this->getBoard();
273
		$blanks = 0;
274
		for ($y = 0; $y < 8; $y++) {
275
			if ($y > 0) {
276
				$fen .= '/';
277
			}
278
			for ($x = 0; $x < 8; $x++) {
279
				if ($board[$y][$x] === null) {
280
					$blanks++;
281
				} else {
282
					if ($blanks > 0) {
283
						$fen .= $blanks;
284
						$blanks = 0;
285
					}
286
					$fen .= $board[$y][$x]->getPieceLetter();
287
				}
288
			}
289
			if ($blanks > 0) {
290
				$fen .= $blanks;
291
				$blanks = 0;
292
			}
293
		}
294
		$fen .= match ($this->getCurrentTurnColour()) {
295
			Colour::White => ' w ',
296
			Colour::Black => ' b ',
297
		};
298
299
		// Castling
300
		$castling = '';
301
		foreach (Colour::cases() as $colour) {
302
			if ($this->hasMoved[$colour->value][ChessPiece::KING] !== true) {
303
				if ($this->hasMoved[$colour->value][ChessPiece::ROOK]['King'] !== true) {
304
					$castling .= ChessPiece::getLetterForPiece(ChessPiece::KING, $colour);
305
				}
306
				if ($this->hasMoved[$colour->value][ChessPiece::ROOK]['Queen'] !== true) {
307
					$castling .= ChessPiece::getLetterForPiece(ChessPiece::QUEEN, $colour);
308
				}
309
			}
310
		}
311
		if ($castling == '') {
312
			$castling = '-';
313
		}
314
		$fen .= $castling . ' ';
315
316
		// En passant
317
		[$pawnX, $pawnY] = $this->hasMoved[ChessPiece::PAWN];
318
		if ($pawnX != -1) {
319
			$fen .= chr(ord('a') + $pawnX);
320
			$fen .= match ($pawnY) {
321
				3 => '6',
322
				4 => '3',
323
				default => throw new Exception('Invalid en passant rank: ' . $pawnY),
324
			};
325
		} else {
326
			$fen .= '-';
327
		}
328
		$fen .= ' 0 ' . floor(count($this->moves) / 2);
329
330
		return $fen;
331
	}
332
333
	/**
334
	 * @param array<ChessPiece> $pieces
335
	 * @return array<int, array<int, ?ChessPiece>>
336
	 */
337
	private static function parsePieces(array $pieces): array {
338
		$row = array_fill(0, 8, null);
339
		$board = array_fill(0, 8, $row);
340
		foreach ($pieces as $piece) {
341
			if ($board[$piece->y][$piece->x] != null) {
342
				throw new Exception('Two pieces found in the same tile.');
343
			}
344
			$board[$piece->y][$piece->x] = $piece;
345
		}
346
		return $board;
347
	}
348
349
	/**
350
	 * @return array<ChessPiece>
351
	 */
352
	public static function getStandardGame(): array {
353
		return [
354
				new ChessPiece(Colour::Black, ChessPiece::ROOK, 0, 0),
355
				new ChessPiece(Colour::Black, ChessPiece::KNIGHT, 1, 0),
356
				new ChessPiece(Colour::Black, ChessPiece::BISHOP, 2, 0),
357
				new ChessPiece(Colour::Black, ChessPiece::QUEEN, 3, 0),
358
				new ChessPiece(Colour::Black, ChessPiece::KING, 4, 0),
359
				new ChessPiece(Colour::Black, ChessPiece::BISHOP, 5, 0),
360
				new ChessPiece(Colour::Black, ChessPiece::KNIGHT, 6, 0),
361
				new ChessPiece(Colour::Black, ChessPiece::ROOK, 7, 0),
362
363
				new ChessPiece(Colour::Black, ChessPiece::PAWN, 0, 1),
364
				new ChessPiece(Colour::Black, ChessPiece::PAWN, 1, 1),
365
				new ChessPiece(Colour::Black, ChessPiece::PAWN, 2, 1),
366
				new ChessPiece(Colour::Black, ChessPiece::PAWN, 3, 1),
367
				new ChessPiece(Colour::Black, ChessPiece::PAWN, 4, 1),
368
				new ChessPiece(Colour::Black, ChessPiece::PAWN, 5, 1),
369
				new ChessPiece(Colour::Black, ChessPiece::PAWN, 6, 1),
370
				new ChessPiece(Colour::Black, ChessPiece::PAWN, 7, 1),
371
372
				new ChessPiece(Colour::White, ChessPiece::PAWN, 0, 6),
373
				new ChessPiece(Colour::White, ChessPiece::PAWN, 1, 6),
374
				new ChessPiece(Colour::White, ChessPiece::PAWN, 2, 6),
375
				new ChessPiece(Colour::White, ChessPiece::PAWN, 3, 6),
376
				new ChessPiece(Colour::White, ChessPiece::PAWN, 4, 6),
377
				new ChessPiece(Colour::White, ChessPiece::PAWN, 5, 6),
378
				new ChessPiece(Colour::White, ChessPiece::PAWN, 6, 6),
379
				new ChessPiece(Colour::White, ChessPiece::PAWN, 7, 6),
380
381
				new ChessPiece(Colour::White, ChessPiece::ROOK, 0, 7),
382
				new ChessPiece(Colour::White, ChessPiece::KNIGHT, 1, 7),
383
				new ChessPiece(Colour::White, ChessPiece::BISHOP, 2, 7),
384
				new ChessPiece(Colour::White, ChessPiece::QUEEN, 3, 7),
385
				new ChessPiece(Colour::White, ChessPiece::KING, 4, 7),
386
				new ChessPiece(Colour::White, ChessPiece::BISHOP, 5, 7),
387
				new ChessPiece(Colour::White, ChessPiece::KNIGHT, 6, 7),
388
				new ChessPiece(Colour::White, ChessPiece::ROOK, 7, 7),
389
			];
390
	}
391
392
	public static function insertNewGame(int $startDate, ?int $endDate, AbstractSmrPlayer $whitePlayer, AbstractSmrPlayer $blackPlayer): int {
393
		$db = Database::getInstance();
394
		$chessGameID = $db->insert('chess_game', [
395
			'start_time' => $db->escapeNumber($startDate),
396
			'end_time' => $endDate === null ? 'NULL' : $db->escapeNumber($endDate),
397
			'white_id' => $db->escapeNumber($whitePlayer->getAccountID()),
398
			'black_id' => $db->escapeNumber($blackPlayer->getAccountID()),
399
			'game_id' => $db->escapeNumber($whitePlayer->getGameID()),
400
		]);
401
402
		self::insertPieces($chessGameID);
403
		return $chessGameID;
404
	}
405
406
	private static function insertPieces(int $chessGameID): void {
407
		$db = Database::getInstance();
408
		$pieces = self::getStandardGame();
409
		foreach ($pieces as $p) {
410
			$db->insert('chess_game_pieces', [
411
				'chess_game_id' => $db->escapeNumber($chessGameID),
412
				'colour' => $db->escapeString($p->colour->value),
413
				'piece_id' => $db->escapeNumber($p->pieceID),
414
				'x' => $db->escapeNumber($p->x),
415
				'y' => $db->escapeNumber($p->y),
416
			]);
417
		}
418
	}
419
420
	private function createMove(int $pieceID, int $startX, int $startY, int $endX, int $endY, ?int $pieceTaken, ?string $checking, Colour $playerColour, ?string $castling, bool $enPassant, ?int $promotionPieceID): string {
421
		// This move will be set as the most recent move
422
		$this->lastMove = [
423
			'From' => ['X' => $startX, 'Y' => $startY],
424
			'To' => ['X' => $endX, 'Y' => $endY],
425
		];
426
427
		$otherPlayerColour = $playerColour->opposite();
428
		if ($pieceID == ChessPiece::KING) {
429
			$this->hasMoved[$playerColour->value][ChessPiece::KING] = true;
430
		}
431
		// Check if the piece moving is a rook and mark it as moved to stop castling.
432
		if ($pieceID == ChessPiece::ROOK && ($startX == 0 || $startX == 7) && ($startY == ($playerColour == Colour::White ? 7 : 0))) {
433
			$this->hasMoved[$playerColour->value][ChessPiece::ROOK][$startX == 0 ? 'Queen' : 'King'] = true;
434
		}
435
		// Check if we've taken a rook and marked them as moved, if they've already moved this does nothing, but if they were taken before moving this stops an issue with trying to castle with a non-existent castle.
436
		if ($pieceTaken == ChessPiece::ROOK && ($endX == 0 || $endX == 7) && $endY == ($otherPlayerColour == Colour::White ? 7 : 0)) {
437
			$this->hasMoved[$otherPlayerColour->value][ChessPiece::ROOK][$endX == 0 ? 'Queen' : 'King'] = true;
438
		}
439
		if ($pieceID == ChessPiece::PAWN && ($startY == 1 || $startY == 6) && ($endY == 3 || $endY == 4)) {
440
			$this->hasMoved[ChessPiece::PAWN] = [$endX, $endY];
441
		} else {
442
			$this->hasMoved[ChessPiece::PAWN] = [-1, -1];
443
		}
444
		return ($castling == 'Queen' ? '0-0-0' : ($castling == 'King' ? '0-0' : ''))
445
			. ChessPiece::getSymbolForPiece($pieceID, $playerColour)
446
			. chr(ord('a') + $startX)
447
			. (8 - $startY)
448
			. ' '
449
			. ($pieceTaken === null ? '' : ChessPiece::getSymbolForPiece($pieceTaken, $otherPlayerColour))
450
			. chr(ord('a') + $endX)
451
			. (8 - $endY)
452
			. ($promotionPieceID === null ? '' : ChessPiece::getSymbolForPiece($promotionPieceID, $playerColour))
453
			. ' '
454
			. ($checking === null ? '' : ($checking == 'CHECK' ? '+' : '++'))
455
			. ($enPassant ? ' e.p.' : '');
456
	}
457
458
	public function isCheckmated(Colour $colour): bool {
459
		$king = null;
460
		foreach ($this->board as $row) {
461
			foreach ($row as $piece) {
462
				if ($piece != null && $piece->pieceID == ChessPiece::KING && $piece->colour == $colour) {
463
					$king = $piece;
464
					break;
465
				}
466
			}
467
		}
468
		if ($king === null) {
469
			throw new Exception('Could not find the king: game id = ' . $this->chessGameID);
470
		}
471
		if (!self::isPlayerChecked($this->board, $this->hasMoved, $colour)) {
472
			return false;
473
		}
474
		foreach ($this->board as $row) {
475
			foreach ($row as $piece) {
476
				if ($piece != null && $piece->colour == $colour) {
477
					$moves = $piece->getPossibleMoves($this->board, $this->hasMoved);
478
					//There are moves we can make, we are clearly not checkmated.
479
					if (count($moves) > 0) {
480
						return false;
481
					}
482
				}
483
			}
484
		}
485
		return true;
486
	}
487
488
	/**
489
	 * @return array{Type: string, X: int, ToX: int}|false
490
	 */
491
	public static function isCastling(int $x, int $toX): array|false {
492
		$movement = $toX - $x;
493
		return match ($movement) {
494
			-2 => ['Type' => 'Queen', 'X' => 0, 'ToX' => 3],
495
			2 => ['Type' => 'King', 'X' => 7, 'ToX' => 5],
496
			default => false,
497
		};
498
	}
499
500
	/**
501
	 * @param array<int, array<int, ?ChessPiece>> $board
502
	 * @param array<mixed> $hasMoved
503
	 * @return array<string, mixed>
504
	 */
505
	public static function movePiece(array &$board, array &$hasMoved, int $x, int $y, int $toX, int $toY, int $pawnPromotionPiece = ChessPiece::QUEEN): array {
506
		if (!self::isValidCoord($x, $y, $board)) {
507
			throw new Exception('Invalid from coordinates, x=' . $x . ', y=' . $y);
508
		}
509
		if (!self::isValidCoord($toX, $toY, $board)) {
510
			throw new Exception('Invalid to coordinates, x=' . $toX . ', y=' . $toY);
511
		}
512
		$pieceTaken = $board[$toY][$toX];
513
		$board[$toY][$toX] = $board[$y][$x];
514
		$p = $board[$toY][$toX];
515
		$board[$y][$x] = null;
516
		if ($p === null) {
517
			throw new Exception('Trying to move non-existent piece: ' . var_export($board, true));
518
		}
519
		$p->x = $toX;
520
		$p->y = $toY;
521
522
		$oldPawnMovement = $hasMoved[ChessPiece::PAWN];
523
		$nextPawnMovement = [-1, -1];
524
		$castling = false;
525
		$enPassant = false;
526
		$rookMoved = false;
527
		$rookTaken = false;
528
		$pawnPromotion = false;
529
		if ($p->pieceID == ChessPiece::KING) {
530
			//Castling?
531
			$castling = self::isCastling($x, $toX);
532
			if ($castling !== false) {
533
				$hasMoved[$p->colour->value][ChessPiece::KING] = true;
534
				$hasMoved[$p->colour->value][ChessPiece::ROOK][$castling['Type']] = true;
535
				if ($board[$y][$castling['X']] === null) {
536
					throw new Exception('Cannot castle with non-existent rook.');
537
				}
538
				$board[$toY][$castling['ToX']] = $board[$y][$castling['X']];
539
				$board[$toY][$castling['ToX']]->x = $castling['ToX'];
540
				$board[$y][$castling['X']] = null;
541
			}
542
		} elseif ($p->pieceID == ChessPiece::PAWN) {
543
			if ($toY == 0 || $toY == 7) {
544
				$pawnPromotion = $p->promote($pawnPromotionPiece, $board);
545
			} elseif (($y == 1 || $y == 6) && ($toY == 3 || $toY == 4)) {
546
				//Double move to track?
547
				$nextPawnMovement = [$toX, $toY];
548
			} elseif ($hasMoved[ChessPiece::PAWN][0] == $toX &&
549
					($hasMoved[ChessPiece::PAWN][1] == 3 && $toY == 2 || $hasMoved[ChessPiece::PAWN][1] == 4 && $toY == 5)) {
550
				//En passant?
551
				$enPassant = true;
552
				$pieceTaken = $board[$hasMoved[ChessPiece::PAWN][1]][$hasMoved[ChessPiece::PAWN][0]];
553
				if ($board[$hasMoved[ChessPiece::PAWN][1]][$hasMoved[ChessPiece::PAWN][0]] === null) {
554
					throw new Exception('Cannot en passant a non-existent pawn.');
555
				}
556
				$board[$hasMoved[ChessPiece::PAWN][1]][$hasMoved[ChessPiece::PAWN][0]] = null;
557
			}
558
		} elseif ($p->pieceID == ChessPiece::ROOK && ($x == 0 || $x == 7) && $y == ($p->colour == Colour::White ? 7 : 0)) {
559
			//Rook moved?
560
			if ($hasMoved[$p->colour->value][ChessPiece::ROOK][$x == 0 ? 'Queen' : 'King'] === false) {
561
				// We set rook moved in here as it's used for move info.
562
				$rookMoved = $x == 0 ? 'Queen' : 'King';
563
				$hasMoved[$p->colour->value][ChessPiece::ROOK][$rookMoved] = true;
564
			}
565
		}
566
		// Check if we've taken a rook and marked them as moved, if they've already moved this does nothing, but if they were taken before moving this stops an issue with trying to castle with a non-existent castle.
567
		if ($pieceTaken != null && $pieceTaken->pieceID == ChessPiece::ROOK && ($toX == 0 || $toX == 7) && $toY == ($pieceTaken->colour == Colour::White ? 7 : 0)) {
568
			if ($hasMoved[$pieceTaken->colour->value][ChessPiece::ROOK][$toX == 0 ? 'Queen' : 'King'] === false) {
569
				$rookTaken = $toX == 0 ? 'Queen' : 'King';
570
				$hasMoved[$pieceTaken->colour->value][ChessPiece::ROOK][$rookTaken] = true;
571
			}
572
		}
573
574
		$hasMoved[ChessPiece::PAWN] = $nextPawnMovement;
575
		return [
576
			'Castling' => $castling,
577
			'PieceTaken' => $pieceTaken,
578
			'EnPassant' => $enPassant,
579
			'RookMoved' => $rookMoved,
580
			'RookTaken' => $rookTaken,
581
			'OldPawnMovement' => $oldPawnMovement,
582
			'PawnPromotion' => $pawnPromotion,
583
		];
584
	}
585
586
	/**
587
	 * @param string $move Algebraic notation like "b2b4"
588
	 */
589
	public function tryAlgebraicMove(string $move): void {
590
		if (strlen($move) != 4 && strlen($move) != 5) {
591
			throw new Exception('Move of length "' . strlen($move) . '" is not valid, full move: ' . $move);
592
		}
593
		$file = $move[0];
594
		$rank = str2int($move[1]);
0 ignored issues
show
Bug introduced by
The function str2int was not found. Maybe you did not declare it correctly or list all dependencies? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-call  annotation

594
		$rank = /** @scrutinizer ignore-call */ str2int($move[1]);
Loading history...
595
		$toFile = $move[2];
596
		$toRank = str2int($move[3]);
597
598
		$aVal = ord('a');
599
		$x = ord($file) - $aVal;
600
		$toX = ord($toFile) - $aVal;
601
		$y = $rank - 1;
602
		$toY = $toRank - 1;
603
604
		$pawnPromotionPiece = ChessPiece::QUEEN;
605
		if (isset($move[4])) {
606
			$pawnPromotionPiece = ChessPiece::getPieceForLetter($move[4]);
607
		}
608
		$this->tryMove($x, $y, $toX, $toY, $this->getCurrentTurnColour(), $pawnPromotionPiece);
609
	}
610
611
	public function tryMove(int $x, int $y, int $toX, int $toY, Colour $forColour, int $pawnPromotionPiece): string {
612
		if ($this->hasEnded()) {
613
			throw new UserError('This game is already over');
614
		}
615
		if ($this->getCurrentTurnColour() != $forColour) {
616
			throw new UserError('It is not your turn to move');
617
		}
618
		$lastTurnPlayer = $this->getCurrentTurnPlayer();
619
		$this->getBoard();
620
		$p = $this->board[$y][$x];
621
		if ($p === null || $p->colour != $forColour) {
622
			throw new UserError('There is no piece on that square');
623
		}
624
625
		$moves = $p->getPossibleMoves($this->board, $this->hasMoved, $forColour);
626
		$moveIsLegal = false;
627
		foreach ($moves as $move) {
628
			if ($move[0] == $toX && $move[1] == $toY) {
629
				$moveIsLegal = true;
630
				break;
631
			}
632
		}
633
		if (!$moveIsLegal) {
634
			throw new UserError('That move is not legal');
635
		}
636
637
		$chessType = $this->isNPCGame() ? 'Chess (NPC)' : 'Chess';
638
		$currentPlayer = $this->getCurrentTurnPlayer();
639
640
		$moveInfo = self::movePiece($this->board, $this->hasMoved, $x, $y, $toX, $toY, $pawnPromotionPiece);
641
642
		//We have taken the move, we should refresh $p
643
		$p = $this->board[$toY][$toX];
644
645
		$pieceTakenID = null;
646
		if ($moveInfo['PieceTaken'] != null) {
647
			$pieceTakenID = $moveInfo['PieceTaken']->pieceID;
648
			if ($moveInfo['PieceTaken']->pieceID == ChessPiece::KING) {
649
				throw new Exception('King was taken.');
650
			}
651
		}
652
653
		$pieceID = $p->pieceID;
654
		$pieceNo = $p->pieceNo;
655
		$promotionPieceID = null;
656
		if ($moveInfo['PawnPromotion'] !== false) {
657
			$p->pieceID = $moveInfo['PawnPromotion']['PieceID'];
658
			$p->pieceNo = $moveInfo['PawnPromotion']['PieceNo'];
659
			$promotionPieceID = $p->pieceID;
660
		}
661
662
		$checking = null;
663
		if ($p->isAttacking($this->board, $this->hasMoved, true)) {
664
			$checking = 'CHECK';
665
		}
666
		if ($this->isCheckmated($p->colour->opposite())) {
667
			$checking = 'MATE';
668
		}
669
670
		$castlingType = $moveInfo['Castling'] === false ? null : $moveInfo['Castling']['Type'];
671
672
		$this->getMoves(); // make sure $this->moves is initialized
673
		$this->moves[] = $this->createMove($pieceID, $x, $y, $toX, $toY, $pieceTakenID, $checking, $this->getCurrentTurnColour(), $castlingType, $moveInfo['EnPassant'], $promotionPieceID);
674
		if (self::isPlayerChecked($this->board, $this->hasMoved, $p->colour)) {
675
			throw new UserError('You cannot end your turn in check');
676
		}
677
678
		$otherPlayer = $this->getCurrentTurnPlayer();
679
		if ($moveInfo['PawnPromotion'] !== false) {
680
			$piecePromotedSymbol = $p->getPieceSymbol();
681
			$currentPlayer->increaseHOF(1, [$chessType, 'Moves', 'Own Pawns Promoted', 'Total'], HOF_PUBLIC);
682
			$otherPlayer->increaseHOF(1, [$chessType, 'Moves', 'Opponent Pawns Promoted', 'Total'], HOF_PUBLIC);
683
			$currentPlayer->increaseHOF(1, [$chessType, 'Moves', 'Own Pawns Promoted', $piecePromotedSymbol], HOF_PUBLIC);
684
			$otherPlayer->increaseHOF(1, [$chessType, 'Moves', 'Opponent Pawns Promoted', $piecePromotedSymbol], HOF_PUBLIC);
685
		}
686
687
		$this->db->insert('chess_game_moves', [
688
			'chess_game_id' => $this->db->escapeNumber($this->chessGameID),
689
			'piece_id' => $this->db->escapeNumber($pieceID),
690
			'start_x' => $this->db->escapeNumber($x),
691
			'start_y' => $this->db->escapeNumber($y),
692
			'end_x' => $this->db->escapeNumber($toX),
693
			'end_y' => $this->db->escapeNumber($toY),
694
			'checked' => $this->db->escapeString($checking, true),
695
			'piece_taken' => $moveInfo['PieceTaken'] === null ? 'NULL' : $this->db->escapeNumber($moveInfo['PieceTaken']->pieceID),
696
			'castling' => $this->db->escapeString($castlingType, true),
697
			'en_passant' => $this->db->escapeBoolean($moveInfo['EnPassant']),
698
			'promote_piece_id' => $moveInfo['PawnPromotion'] == false ? 'NULL' : $this->db->escapeNumber($moveInfo['PawnPromotion']['PieceID']),
699
		]);
700
701
		$currentPlayer->increaseHOF(1, [$chessType, 'Moves', 'Total Taken'], HOF_PUBLIC);
702
		if ($moveInfo['PieceTaken'] != null) {
703
			// Get the owner of the taken piece
704
			$this->db->write('DELETE FROM chess_game_pieces
705
							WHERE chess_game_id=' . $this->db->escapeNumber($this->chessGameID) . ' AND colour=' . $this->db->escapeString($moveInfo['PieceTaken']->colour->value) . ' AND piece_id=' . $this->db->escapeNumber($moveInfo['PieceTaken']->pieceID) . ' AND piece_no=' . $this->db->escapeNumber($moveInfo['PieceTaken']->pieceNo) . ';');
706
707
			$pieceTakenSymbol = $moveInfo['PieceTaken']->getPieceSymbol();
708
			$currentPlayer->increaseHOF(1, [$chessType, 'Moves', 'Opponent Pieces Taken', 'Total'], HOF_PUBLIC);
709
			$otherPlayer->increaseHOF(1, [$chessType, 'Moves', 'Own Pieces Taken', 'Total'], HOF_PUBLIC);
710
			$currentPlayer->increaseHOF(1, [$chessType, 'Moves', 'Opponent Pieces Taken', $pieceTakenSymbol], HOF_PUBLIC);
711
			$otherPlayer->increaseHOF(1, [$chessType, 'Moves', 'Own Pieces Taken', $pieceTakenSymbol], HOF_PUBLIC);
712
		}
713
714
		$this->db->write('UPDATE chess_game_pieces
715
					SET x=' . $this->db->escapeNumber($toX) . ', y=' . $this->db->escapeNumber($toY) .
716
						($moveInfo['PawnPromotion'] !== false ? ', piece_id=' . $this->db->escapeNumber($moveInfo['PawnPromotion']['PieceID']) . ', piece_no=' . $this->db->escapeNumber($moveInfo['PawnPromotion']['PieceNo']) : '') . '
717
					WHERE chess_game_id=' . $this->db->escapeNumber($this->chessGameID) . ' AND colour=' . $this->db->escapeString($p->colour->value) . ' AND piece_id=' . $this->db->escapeNumber($pieceID) . ' AND piece_no=' . $this->db->escapeNumber($pieceNo) . ';');
718
		if ($moveInfo['Castling'] !== false) {
719
			$this->db->write('UPDATE chess_game_pieces
720
						SET x=' . $this->db->escapeNumber($moveInfo['Castling']['ToX']) . '
721
						WHERE chess_game_id=' . $this->db->escapeNumber($this->chessGameID) . ' AND colour=' . $this->db->escapeString($p->colour->value) . ' AND x = ' . $this->db->escapeNumber($moveInfo['Castling']['X']) . ' AND y = ' . $this->db->escapeNumber($y) . ';');
722
		}
723
724
		if ($checking == 'MATE') {
725
			$message = 'You have checkmated your opponent, congratulations!';
726
			$this->setWinner($this->getColourID($forColour));
727
			SmrPlayer::sendMessageFromCasino($lastTurnPlayer->getGameID(), $this->getCurrentTurnAccountID(), 'You have just lost [chess=' . $this->getChessGameID() . '] against [player=' . $lastTurnPlayer->getPlayerID() . '].');
728
		} else {
729
			$message = ''; // non-mating valid move, no message
730
			SmrPlayer::sendMessageFromCasino($lastTurnPlayer->getGameID(), $this->getCurrentTurnAccountID(), 'It is now your turn in [chess=' . $this->getChessGameID() . '] against [player=' . $lastTurnPlayer->getPlayerID() . '].');
731
			if ($checking == 'CHECK') {
732
				$currentPlayer->increaseHOF(1, [$chessType, 'Moves', 'Check Given'], HOF_PUBLIC);
733
				$otherPlayer->increaseHOF(1, [$chessType, 'Moves', 'Check Received'], HOF_PUBLIC);
734
			}
735
		}
736
		$currentPlayer->saveHOF();
737
		$otherPlayer->saveHOF();
738
		return $message;
739
	}
740
741
	public function getChessGameID(): int {
742
		return $this->chessGameID;
743
	}
744
745
	public function getStartDate(): int {
746
		return $this->startDate;
747
	}
748
749
	public function getGameID(): int {
750
		return $this->gameID;
751
	}
752
753
	public function getWhitePlayer(): AbstractSmrPlayer {
754
		return SmrPlayer::getPlayer($this->whiteID, $this->getGameID());
755
	}
756
757
	public function getWhiteID(): int {
758
		return $this->whiteID;
759
	}
760
761
	public function getBlackPlayer(): AbstractSmrPlayer {
762
		return SmrPlayer::getPlayer($this->blackID, $this->getGameID());
763
	}
764
765
	public function getBlackID(): int {
766
		return $this->blackID;
767
	}
768
769
	public function getColourID(Colour $colour): int {
770
		return match ($colour) {
771
			Colour::White => $this->getWhiteID(),
772
			Colour::Black => $this->getBlackID(),
773
		};
774
	}
775
776
	public function getColourPlayer(Colour $colour): AbstractSmrPlayer {
777
		return SmrPlayer::getPlayer($this->getColourID($colour), $this->getGameID());
778
	}
779
780
	public function getColourForAccountID(int $accountID): Colour {
781
		return match ($accountID) {
782
			$this->getWhiteID() => Colour::White,
783
			$this->getBlackID() => Colour::Black,
784
			default => throw new Exception('Account ID is not in this chess game: ' . $accountID),
785
		};
786
	}
787
788
	/**
789
	 * Is the given account ID one of the two players of this game?
790
	 */
791
	public function isPlayer(int $accountID): bool {
792
		return $accountID === $this->getWhiteID() || $accountID === $this->getBlackID();
793
	}
794
795
	public function hasEnded(): bool {
796
		return $this->endDate !== null && $this->endDate <= Epoch::time();
797
	}
798
799
	public function getWinner(): int {
800
		return $this->winner;
801
	}
802
803
	/**
804
	 * @return array<string, AbstractSmrPlayer>
805
	 */
806
	public function setWinner(int $accountID): array {
807
		$this->winner = $accountID;
808
		$this->endDate = Epoch::time();
809
		$this->db->write('UPDATE chess_game
810
						SET end_time=' . $this->db->escapeNumber(Epoch::time()) . ', winner_id=' . $this->db->escapeNumber($this->winner) . '
811
						WHERE chess_game_id=' . $this->db->escapeNumber($this->chessGameID) . ';');
812
		$winnerColour = $this->getColourForAccountID($accountID);
813
		$winningPlayer = $this->getColourPlayer($winnerColour);
814
		$losingPlayer = $this->getColourPlayer($winnerColour->opposite());
815
		$chessType = $this->isNPCGame() ? 'Chess (NPC)' : 'Chess';
816
		$winningPlayer->increaseHOF(1, [$chessType, 'Games', 'Won'], HOF_PUBLIC);
817
		$losingPlayer->increaseHOF(1, [$chessType, 'Games', 'Lost'], HOF_PUBLIC);
818
		return ['Winner' => $winningPlayer, 'Loser' => $losingPlayer];
819
	}
820
821
	/**
822
	 * @return array<mixed>
823
	 */
824
	public function &getHasMoved(): array {
825
		return $this->hasMoved;
826
	}
827
828
	public function getCurrentTurnColour(): Colour {
829
		return count($this->getMoves()) % 2 == 0 ? Colour::White : Colour::Black;
830
	}
831
832
	public function getCurrentTurnAccountID(): int {
833
		return count($this->getMoves()) % 2 == 0 ? $this->whiteID : $this->blackID;
834
	}
835
836
	public function getCurrentTurnPlayer(): AbstractSmrPlayer {
837
		return SmrPlayer::getPlayer($this->getCurrentTurnAccountID(), $this->getGameID());
838
	}
839
840
	public function getCurrentTurnAccount(): SmrAccount {
841
		return SmrAccount::getAccount($this->getCurrentTurnAccountID());
842
	}
843
844
	public function getWhiteAccount(): SmrAccount {
845
		return SmrAccount::getAccount($this->getWhiteID());
846
	}
847
848
	public function getBlackAccount(): SmrAccount {
849
		return SmrAccount::getAccount($this->getBlackID());
850
	}
851
852
	public function isCurrentTurn(int $accountID): bool {
853
		return $accountID == $this->getCurrentTurnAccountID();
854
	}
855
856
	public function isNPCGame(): bool {
857
		return $this->getWhiteAccount()->isNPC() || $this->getBlackAccount()->isNPC();
858
	}
859
860
	/**
861
	 * @return self::END_*
862
	 */
863
	public function resign(int $accountID): int {
864
		if ($this->hasEnded() || !$this->isPlayer($accountID)) {
865
			throw new Exception('Invalid resign conditions');
866
		}
867
868
		// If only 1 person has moved then just end the game.
869
		if (count($this->getMoves()) < 2) {
870
			$this->endDate = Epoch::time();
871
			$this->db->write('UPDATE chess_game
872
							SET end_time=' . $this->db->escapeNumber(Epoch::time()) . '
873
							WHERE chess_game_id=' . $this->db->escapeNumber($this->chessGameID) . ';');
874
			return self::END_CANCEL;
875
		}
876
877
		$loserColour = $this->getColourForAccountID($accountID);
878
		$winnerAccountID = $this->getColourID($loserColour->opposite());
879
		$results = $this->setWinner($winnerAccountID);
880
		$chessType = $this->isNPCGame() ? 'Chess (NPC)' : 'Chess';
881
		$results['Loser']->increaseHOF(1, [$chessType, 'Games', 'Resigned'], HOF_PUBLIC);
882
		SmrPlayer::sendMessageFromCasino($results['Winner']->getGameID(), $results['Winner']->getAccountID(), '[player=' . $results['Loser']->getPlayerID() . '] just resigned against you in [chess=' . $this->getChessGameID() . '].');
883
		return self::END_RESIGN;
884
	}
885
886
	public function getPlayGameHREF(): string {
887
		return (new MatchPlay($this->chessGameID))->href();
888
	}
889
890
	public function getResignHREF(): string {
891
		return (new MatchResignProcessor($this->chessGameID))->href();
892
	}
893
894
}
895