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::createMove()   F
last analyzed

Complexity

Conditions 26
Paths 4608

Size

Total Lines 36
Code Lines 26

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 26
eloc 26
nc 4608
nop 11
dl 0
loc 36
rs 0
c 0
b 0
f 0

How to fix   Complexity    Many Parameters   

Long Method

Small methods make your code easier to understand, in particular if combined with a good name. Besides, if your method is small, finding a good name is usually much easier.

For example, if you find yourself adding comments to a method's body, this is usually a good sign to extract the commented part to a new method, and use the comment as a starting point when coming up with a good name for this new method.

Commonly applied refactorings include:

Many Parameters

Methods with many parameters are not only hard to understand, but their parameters also often become inconsistent when you need more, or different data.

There are several approaches to avoid long parameter lists:

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