39315-vm/multiplayer_data.php
2026-03-25 15:17:29 +00:00

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