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; }