218 lines
7.3 KiB
PHP
218 lines
7.3 KiB
PHP
<?php
|
|
declare(strict_types=1);
|
|
|
|
require_once __DIR__ . '/db/config.php';
|
|
|
|
const TETRIS_ROOM_CODE_LENGTH = 6;
|
|
const TETRIS_ROOM_TTL_HOURS = 24;
|
|
|
|
function multiplayerEnsureSchema(): void
|
|
{
|
|
static $ready = false;
|
|
if ($ready) {
|
|
return;
|
|
}
|
|
|
|
db()->exec(
|
|
"CREATE TABLE IF NOT EXISTS tetris_rooms (
|
|
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
|
room_code VARCHAR(6) NOT NULL,
|
|
host_name VARCHAR(24) NOT NULL,
|
|
guest_name VARCHAR(24) DEFAULT NULL,
|
|
status ENUM('waiting', 'ready', 'closed') NOT NULL DEFAULT 'waiting',
|
|
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
|
expires_at TIMESTAMP NULL DEFAULT NULL,
|
|
UNIQUE KEY uniq_room_code (room_code),
|
|
KEY idx_status (status),
|
|
KEY idx_expires_at (expires_at),
|
|
KEY idx_updated_at (updated_at)
|
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci"
|
|
);
|
|
|
|
$ready = true;
|
|
}
|
|
|
|
function multiplayerSanitizePlayerName(string $value): string
|
|
{
|
|
$value = trim($value);
|
|
if ($value === '') {
|
|
throw new InvalidArgumentException('Enter your nickname first.');
|
|
}
|
|
|
|
if (!preg_match('/^[\p{L}\p{N} _.-]+$/u', $value)) {
|
|
throw new InvalidArgumentException('Nickname can use letters, numbers, spaces, dots, dashes, and underscores only.');
|
|
}
|
|
|
|
$length = function_exists('mb_strlen') ? mb_strlen($value) : strlen($value);
|
|
if ($length > 24) {
|
|
throw new InvalidArgumentException('Nickname must be 24 characters or fewer.');
|
|
}
|
|
|
|
return $value;
|
|
}
|
|
|
|
function multiplayerNormalizeRoomCode(string $value): string
|
|
{
|
|
$value = strtoupper(preg_replace('/[^A-Z0-9]+/', '', $value) ?? '');
|
|
if ($value === '') {
|
|
throw new InvalidArgumentException('Enter a room code.');
|
|
}
|
|
if (strlen($value) !== TETRIS_ROOM_CODE_LENGTH) {
|
|
throw new InvalidArgumentException('Room code must be exactly 6 characters.');
|
|
}
|
|
return $value;
|
|
}
|
|
|
|
function multiplayerFormatRoom(array $room): array
|
|
{
|
|
$createdAt = strtotime((string) ($room['created_at'] ?? ''));
|
|
$updatedAt = strtotime((string) ($room['updated_at'] ?? ''));
|
|
$expiresAt = strtotime((string) ($room['expires_at'] ?? ''));
|
|
$guestName = isset($room['guest_name']) ? trim((string) $room['guest_name']) : '';
|
|
$status = (string) ($room['status'] ?? 'waiting');
|
|
$playerCount = $guestName !== '' ? 2 : 1;
|
|
|
|
return [
|
|
'id' => (int) ($room['id'] ?? 0),
|
|
'room_code' => (string) ($room['room_code'] ?? ''),
|
|
'host_name' => (string) ($room['host_name'] ?? ''),
|
|
'guest_name' => $guestName !== '' ? $guestName : null,
|
|
'status' => $status,
|
|
'player_count' => $playerCount,
|
|
'is_ready' => $status === 'ready' && $playerCount >= 2,
|
|
'created_at_iso' => $createdAt ? gmdate(DATE_ATOM, $createdAt) : null,
|
|
'updated_at_iso' => $updatedAt ? gmdate(DATE_ATOM, $updatedAt) : null,
|
|
'expires_at_iso' => $expiresAt ? gmdate(DATE_ATOM, $expiresAt) : null,
|
|
];
|
|
}
|
|
|
|
function multiplayerFetchRoomByCode(string $roomCode): ?array
|
|
{
|
|
multiplayerEnsureSchema();
|
|
$roomCode = multiplayerNormalizeRoomCode($roomCode);
|
|
|
|
$stmt = db()->prepare(
|
|
'SELECT id, room_code, host_name, guest_name, status, created_at, updated_at, expires_at
|
|
FROM tetris_rooms
|
|
WHERE room_code = :room_code
|
|
AND (expires_at IS NULL OR expires_at >= UTC_TIMESTAMP())
|
|
LIMIT 1'
|
|
);
|
|
$stmt->bindValue(':room_code', $roomCode, PDO::PARAM_STR);
|
|
$stmt->execute();
|
|
$room = $stmt->fetch();
|
|
|
|
return $room ? multiplayerFormatRoom($room) : null;
|
|
}
|
|
|
|
function multiplayerGenerateRoomCode(): string
|
|
{
|
|
$alphabet = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789';
|
|
$maxIndex = strlen($alphabet) - 1;
|
|
$roomCode = '';
|
|
|
|
for ($i = 0; $i < TETRIS_ROOM_CODE_LENGTH; $i += 1) {
|
|
$roomCode .= $alphabet[random_int(0, $maxIndex)];
|
|
}
|
|
|
|
return $roomCode;
|
|
}
|
|
|
|
function multiplayerCreateRoom(string $hostName): array
|
|
{
|
|
multiplayerEnsureSchema();
|
|
$hostName = multiplayerSanitizePlayerName($hostName);
|
|
|
|
$expiresAt = gmdate('Y-m-d H:i:s', time() + (TETRIS_ROOM_TTL_HOURS * 3600));
|
|
|
|
for ($attempt = 0; $attempt < 12; $attempt += 1) {
|
|
$roomCode = multiplayerGenerateRoomCode();
|
|
try {
|
|
$stmt = db()->prepare(
|
|
'INSERT INTO tetris_rooms (room_code, host_name, status, expires_at)
|
|
VALUES (:room_code, :host_name, :status, :expires_at)'
|
|
);
|
|
$stmt->bindValue(':room_code', $roomCode, PDO::PARAM_STR);
|
|
$stmt->bindValue(':host_name', $hostName, PDO::PARAM_STR);
|
|
$stmt->bindValue(':status', 'waiting', PDO::PARAM_STR);
|
|
$stmt->bindValue(':expires_at', $expiresAt, PDO::PARAM_STR);
|
|
$stmt->execute();
|
|
|
|
$room = multiplayerFetchRoomByCode($roomCode);
|
|
if ($room) {
|
|
return $room;
|
|
}
|
|
} catch (PDOException $e) {
|
|
if ((string) $e->getCode() !== '23000') {
|
|
throw $e;
|
|
}
|
|
}
|
|
}
|
|
|
|
throw new RuntimeException('Unable to create a room right now. Please try again.');
|
|
}
|
|
|
|
function multiplayerJoinRoom(string $roomCode, string $guestName): array
|
|
{
|
|
multiplayerEnsureSchema();
|
|
$roomCode = multiplayerNormalizeRoomCode($roomCode);
|
|
$guestName = multiplayerSanitizePlayerName($guestName);
|
|
|
|
$pdo = db();
|
|
$pdo->beginTransaction();
|
|
|
|
try {
|
|
$stmt = $pdo->prepare(
|
|
'SELECT id, room_code, host_name, guest_name, status, created_at, updated_at, expires_at
|
|
FROM tetris_rooms
|
|
WHERE room_code = :room_code
|
|
AND (expires_at IS NULL OR expires_at >= UTC_TIMESTAMP())
|
|
LIMIT 1
|
|
FOR UPDATE'
|
|
);
|
|
$stmt->bindValue(':room_code', $roomCode, PDO::PARAM_STR);
|
|
$stmt->execute();
|
|
$room = $stmt->fetch();
|
|
|
|
if (!$room) {
|
|
throw new InvalidArgumentException('Room not found or already expired.');
|
|
}
|
|
|
|
$existingGuest = trim((string) ($room['guest_name'] ?? ''));
|
|
if ($existingGuest !== '' && strcasecmp($existingGuest, $guestName) !== 0) {
|
|
throw new InvalidArgumentException('This room already has two players.');
|
|
}
|
|
|
|
if ($existingGuest === '') {
|
|
$expiresAt = gmdate('Y-m-d H:i:s', time() + (TETRIS_ROOM_TTL_HOURS * 3600));
|
|
$update = $pdo->prepare(
|
|
'UPDATE tetris_rooms
|
|
SET guest_name = :guest_name,
|
|
status = :status,
|
|
expires_at = :expires_at
|
|
WHERE id = :id'
|
|
);
|
|
$update->bindValue(':guest_name', $guestName, PDO::PARAM_STR);
|
|
$update->bindValue(':status', 'ready', PDO::PARAM_STR);
|
|
$update->bindValue(':expires_at', $expiresAt, PDO::PARAM_STR);
|
|
$update->bindValue(':id', (int) $room['id'], PDO::PARAM_INT);
|
|
$update->execute();
|
|
}
|
|
|
|
$pdo->commit();
|
|
} catch (Throwable $e) {
|
|
if ($pdo->inTransaction()) {
|
|
$pdo->rollBack();
|
|
}
|
|
throw $e;
|
|
}
|
|
|
|
$freshRoom = multiplayerFetchRoomByCode($roomCode);
|
|
if (!$freshRoom) {
|
|
throw new RuntimeException('Room state could not be loaded.');
|
|
}
|
|
|
|
return $freshRoom;
|
|
}
|