39321-vm/api/multiplayer.php
2026-03-25 17:19:10 +00:00

494 lines
18 KiB
PHP

<?php
declare(strict_types=1);
header('Content-Type: application/json');
require_once __DIR__ . '/../db/config.php';
require_once __DIR__ . '/../db/migrate.php';
run_migrations();
cleanupMultiplayerState();
try {
$payload = getRequestPayload();
$action = strtolower((string) ($payload['action'] ?? $_GET['action'] ?? ''));
if ($action === '') {
jsonResponse(['success' => false, 'error' => 'Missing action.'], 400);
}
switch ($action) {
case 'create_room':
ensurePostRequest();
createRoom($payload);
break;
case 'join_room':
ensurePostRequest();
joinRoom($payload);
break;
case 'update_state':
ensurePostRequest();
updatePlayerState($payload);
break;
case 'get_state':
getRoomState($payload);
break;
case 'leave_room':
ensurePostRequest();
leaveRoom($payload);
break;
default:
jsonResponse(['success' => false, 'error' => 'Unsupported action.'], 400);
}
} catch (Throwable $exception) {
error_log('Multiplayer API error: ' . $exception->getMessage());
jsonResponse(['success' => false, 'error' => 'Internal server error.'], 500);
}
function getRequestPayload(): array
{
$rawBody = file_get_contents('php://input');
$jsonBody = json_decode($rawBody ?: '', true);
if (is_array($jsonBody)) {
return $jsonBody;
}
if (!empty($_POST)) {
return $_POST;
}
return $_GET;
}
function ensurePostRequest(): void
{
if (($_SERVER['REQUEST_METHOD'] ?? 'GET') !== 'POST') {
jsonResponse(['success' => false, 'error' => 'POST required.'], 405);
}
}
function jsonResponse(array $payload, int $statusCode = 200): void
{
http_response_code($statusCode);
echo json_encode($payload, JSON_UNESCAPED_SLASHES);
exit;
}
function normalizeDisplayName(?string $displayName, int $slot = 0): string
{
$name = trim((string) $displayName);
$name = preg_replace('/\s+/', ' ', $name ?? '') ?? '';
if ($name === '') {
return $slot > 0 ? 'Player ' . $slot : 'Player';
}
$name = function_exists('mb_substr') ? mb_substr($name, 0, 48) : substr($name, 0, 48);
$name = strip_tags($name);
return trim($name) !== '' ? $name : ($slot > 0 ? 'Player ' . $slot : 'Player');
}
function normalizeRoomCode(?string $roomCode): string
{
return strtoupper(preg_replace('/[^A-Z0-9]/', '', (string) $roomCode) ?? '');
}
function generateRoomCode(PDO $pdo): string
{
$alphabet = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789';
$checkStmt = $pdo->prepare('SELECT 1 FROM tetris_rooms WHERE room_code = :room_code LIMIT 1');
for ($attempt = 0; $attempt < 12; $attempt++) {
$code = '';
for ($i = 0; $i < 6; $i++) {
$code .= $alphabet[random_int(0, strlen($alphabet) - 1)];
}
$checkStmt->bindValue(':room_code', $code, PDO::PARAM_STR);
$checkStmt->execute();
if (!$checkStmt->fetchColumn()) {
return $code;
}
}
throw new RuntimeException('Could not allocate a unique room code.');
}
function generatePlayerToken(): string
{
return bin2hex(random_bytes(24));
}
function cleanupMultiplayerState(): void
{
$pdo = db();
$pdo->exec("UPDATE tetris_room_players SET connection_status = 'disconnected' WHERE last_seen_at < (UTC_TIMESTAMP() - INTERVAL 2 MINUTE)");
$pdo->exec("UPDATE tetris_rooms SET status = 'closed' WHERE updated_at < (UTC_TIMESTAMP() - INTERVAL 12 HOUR)");
}
function createRoom(array $payload): void
{
$pdo = db();
$roomCode = generateRoomCode($pdo);
$playerToken = generatePlayerToken();
$displayName = normalizeDisplayName((string) ($payload['display_name'] ?? ''), 1);
$pdo->beginTransaction();
try {
$insertRoom = $pdo->prepare('INSERT INTO tetris_rooms (room_code, status, expires_at) VALUES (:room_code, :status, (UTC_TIMESTAMP() + INTERVAL 12 HOUR))');
$insertRoom->bindValue(':room_code', $roomCode, PDO::PARAM_STR);
$insertRoom->bindValue(':status', 'waiting', PDO::PARAM_STR);
$insertRoom->execute();
$roomId = (int) $pdo->lastInsertId();
$insertPlayer = $pdo->prepare(
'INSERT INTO tetris_room_players (room_id, player_token, player_slot, display_name, connection_status, game_status, last_seen_at) '
. 'VALUES (:room_id, :player_token, :player_slot, :display_name, :connection_status, :game_status, UTC_TIMESTAMP())'
);
$insertPlayer->bindValue(':room_id', $roomId, PDO::PARAM_INT);
$insertPlayer->bindValue(':player_token', $playerToken, PDO::PARAM_STR);
$insertPlayer->bindValue(':player_slot', 1, PDO::PARAM_INT);
$insertPlayer->bindValue(':display_name', $displayName, PDO::PARAM_STR);
$insertPlayer->bindValue(':connection_status', 'connected', PDO::PARAM_STR);
$insertPlayer->bindValue(':game_status', 'ready', PDO::PARAM_STR);
$insertPlayer->execute();
$playerId = (int) $pdo->lastInsertId();
$updateRoom = $pdo->prepare('UPDATE tetris_rooms SET host_player_id = :host_player_id WHERE id = :room_id');
$updateRoom->bindValue(':host_player_id', $playerId, PDO::PARAM_INT);
$updateRoom->bindValue(':room_id', $roomId, PDO::PARAM_INT);
$updateRoom->execute();
$pdo->commit();
} catch (Throwable $exception) {
if ($pdo->inTransaction()) {
$pdo->rollBack();
}
throw $exception;
}
jsonResponse([
'success' => true,
'room' => [
'code' => $roomCode,
'status' => 'waiting',
'player_count' => 1,
],
'self' => [
'token' => $playerToken,
'slot' => 1,
'display_name' => $displayName,
],
]);
}
function joinRoom(array $payload): void
{
$roomCode = normalizeRoomCode((string) ($payload['room_code'] ?? ''));
if ($roomCode === '') {
jsonResponse(['success' => false, 'error' => 'Room code is required.'], 422);
}
$pdo = db();
$pdo->beginTransaction();
try {
$roomStmt = $pdo->prepare('SELECT * FROM tetris_rooms WHERE room_code = :room_code LIMIT 1 FOR UPDATE');
$roomStmt->bindValue(':room_code', $roomCode, PDO::PARAM_STR);
$roomStmt->execute();
$room = $roomStmt->fetch();
if (!$room || $room['status'] === 'closed') {
if ($pdo->inTransaction()) {
$pdo->rollBack();
}
jsonResponse(['success' => false, 'error' => 'Room not found.'], 404);
}
$countStmt = $pdo->prepare('SELECT COUNT(*) FROM tetris_room_players WHERE room_id = :room_id');
$countStmt->bindValue(':room_id', (int) $room['id'], PDO::PARAM_INT);
$countStmt->execute();
$playerCount = (int) $countStmt->fetchColumn();
if ($playerCount >= 2) {
if ($pdo->inTransaction()) {
$pdo->rollBack();
}
jsonResponse(['success' => false, 'error' => 'Room is full.'], 409);
}
$playerToken = generatePlayerToken();
$displayName = normalizeDisplayName((string) ($payload['display_name'] ?? ''), 2);
$insertPlayer = $pdo->prepare(
'INSERT INTO tetris_room_players (room_id, player_token, player_slot, display_name, connection_status, game_status, last_seen_at) '
. 'VALUES (:room_id, :player_token, :player_slot, :display_name, :connection_status, :game_status, UTC_TIMESTAMP())'
);
$insertPlayer->bindValue(':room_id', (int) $room['id'], PDO::PARAM_INT);
$insertPlayer->bindValue(':player_token', $playerToken, PDO::PARAM_STR);
$insertPlayer->bindValue(':player_slot', 2, PDO::PARAM_INT);
$insertPlayer->bindValue(':display_name', $displayName, PDO::PARAM_STR);
$insertPlayer->bindValue(':connection_status', 'connected', PDO::PARAM_STR);
$insertPlayer->bindValue(':game_status', 'ready', PDO::PARAM_STR);
$insertPlayer->execute();
$updateRoom = $pdo->prepare('UPDATE tetris_rooms SET status = :status, expires_at = (UTC_TIMESTAMP() + INTERVAL 12 HOUR) WHERE id = :room_id');
$updateRoom->bindValue(':status', 'active', PDO::PARAM_STR);
$updateRoom->bindValue(':room_id', (int) $room['id'], PDO::PARAM_INT);
$updateRoom->execute();
$pdo->commit();
} catch (Throwable $exception) {
if ($pdo->inTransaction()) {
$pdo->rollBack();
}
throw $exception;
}
jsonResponse([
'success' => true,
'room' => [
'code' => $roomCode,
'status' => 'active',
'player_count' => 2,
],
'self' => [
'token' => $playerToken,
'slot' => 2,
'display_name' => $displayName,
],
]);
}
function updatePlayerState(array $payload): void
{
$roomCode = normalizeRoomCode((string) ($payload['room_code'] ?? ''));
$playerToken = trim((string) ($payload['player_token'] ?? ''));
if ($roomCode === '' || $playerToken === '') {
jsonResponse(['success' => false, 'error' => 'Room code and player token are required.'], 422);
}
$boardStateJson = encodeNullableJson($payload['board'] ?? null);
$pieceStateJson = encodeNullableJson($payload['piece'] ?? null);
$metaJson = encodeNullableJson($payload['meta'] ?? null);
$score = max(0, (int) ($payload['score'] ?? 0));
$linesCleared = max(0, (int) ($payload['lines'] ?? 0));
$level = max(1, (int) ($payload['level'] ?? 1));
$gameStatus = normalizeGameStatus((string) ($payload['game_status'] ?? 'ready'));
$pdo = db();
$player = fetchPlayerForRoom($pdo, $roomCode, $playerToken);
if (!$player) {
jsonResponse(['success' => false, 'error' => 'Player or room not found.'], 404);
}
$updatePlayer = $pdo->prepare(
'UPDATE tetris_room_players SET board_state_json = :board_state_json, piece_state_json = :piece_state_json, meta_json = :meta_json, '
. 'score = :score, lines_cleared = :lines_cleared, level = :level, game_status = :game_status, connection_status = :connection_status, '
. 'last_seen_at = UTC_TIMESTAMP() WHERE id = :player_id'
);
$updatePlayer->bindValue(':board_state_json', $boardStateJson, $boardStateJson === null ? PDO::PARAM_NULL : PDO::PARAM_STR);
$updatePlayer->bindValue(':piece_state_json', $pieceStateJson, $pieceStateJson === null ? PDO::PARAM_NULL : PDO::PARAM_STR);
$updatePlayer->bindValue(':meta_json', $metaJson, $metaJson === null ? PDO::PARAM_NULL : PDO::PARAM_STR);
$updatePlayer->bindValue(':score', $score, PDO::PARAM_INT);
$updatePlayer->bindValue(':lines_cleared', $linesCleared, PDO::PARAM_INT);
$updatePlayer->bindValue(':level', $level, PDO::PARAM_INT);
$updatePlayer->bindValue(':game_status', $gameStatus, PDO::PARAM_STR);
$updatePlayer->bindValue(':connection_status', 'connected', PDO::PARAM_STR);
$updatePlayer->bindValue(':player_id', (int) $player['player_id'], PDO::PARAM_INT);
$updatePlayer->execute();
$roomStatus = 'active';
$updateRoom = $pdo->prepare('UPDATE tetris_rooms SET status = :status, expires_at = (UTC_TIMESTAMP() + INTERVAL 12 HOUR) WHERE id = :room_id');
$updateRoom->bindValue(':status', $roomStatus, PDO::PARAM_STR);
$updateRoom->bindValue(':room_id', (int) $player['room_id'], PDO::PARAM_INT);
$updateRoom->execute();
jsonResponse([
'success' => true,
'room' => [
'code' => $roomCode,
'status' => $roomStatus,
'updated_at' => gmdate('c'),
],
]);
}
function getRoomState(array $payload): void
{
$roomCode = normalizeRoomCode((string) ($payload['room_code'] ?? ''));
$playerToken = trim((string) ($payload['player_token'] ?? ''));
if ($roomCode === '' || $playerToken === '') {
jsonResponse(['success' => false, 'error' => 'Room code and player token are required.'], 422);
}
$pdo = db();
$self = fetchPlayerForRoom($pdo, $roomCode, $playerToken);
if (!$self) {
jsonResponse(['success' => false, 'error' => 'Player or room not found.'], 404);
}
$touchStmt = $pdo->prepare('UPDATE tetris_room_players SET last_seen_at = UTC_TIMESTAMP(), connection_status = :connection_status WHERE id = :player_id');
$touchStmt->bindValue(':connection_status', 'connected', PDO::PARAM_STR);
$touchStmt->bindValue(':player_id', (int) $self['player_id'], PDO::PARAM_INT);
$touchStmt->execute();
$playersStmt = $pdo->prepare(
'SELECT p.id, p.player_slot, p.display_name, p.connection_status, p.game_status, p.score, p.lines_cleared, p.level, '
. 'p.board_state_json, p.piece_state_json, p.meta_json, p.last_seen_at, r.room_code, r.status AS room_status, r.updated_at AS room_updated_at '
. 'FROM tetris_room_players p '
. 'INNER JOIN tetris_rooms r ON r.id = p.room_id '
. 'WHERE r.id = :room_id '
. 'ORDER BY p.player_slot ASC'
);
$playersStmt->bindValue(':room_id', (int) $self['room_id'], PDO::PARAM_INT);
$playersStmt->execute();
$rows = $playersStmt->fetchAll();
$players = [];
foreach ($rows as $row) {
$players[] = [
'slot' => (int) $row['player_slot'],
'display_name' => (string) $row['display_name'],
'connection_status' => (string) $row['connection_status'],
'game_status' => (string) $row['game_status'],
'score' => (int) $row['score'],
'lines' => (int) $row['lines_cleared'],
'level' => (int) $row['level'],
'board' => decodeNullableJson($row['board_state_json'] ?? null),
'piece' => decodeNullableJson($row['piece_state_json'] ?? null),
'meta' => decodeNullableJson($row['meta_json'] ?? null),
'last_seen_at' => (string) $row['last_seen_at'],
'is_self' => ((int) ($row['player_slot'] ?? 0)) === ((int) $self['player_slot']),
];
}
$playerCount = count($players);
$connectedCount = 0;
foreach ($players as $player) {
if ($player['connection_status'] === 'connected') {
$connectedCount++;
}
}
$roomStatus = $playerCount >= 2 ? 'active' : 'waiting';
if ($connectedCount === 0) {
$roomStatus = 'closed';
}
$roomUpdateStmt = $pdo->prepare('UPDATE tetris_rooms SET status = :status, expires_at = (UTC_TIMESTAMP() + INTERVAL 12 HOUR) WHERE id = :room_id');
$roomUpdateStmt->bindValue(':status', $roomStatus, PDO::PARAM_STR);
$roomUpdateStmt->bindValue(':room_id', (int) $self['room_id'], PDO::PARAM_INT);
$roomUpdateStmt->execute();
jsonResponse([
'success' => true,
'room' => [
'code' => $roomCode,
'status' => $roomStatus,
'player_count' => $playerCount,
],
'self' => [
'slot' => (int) $self['player_slot'],
'display_name' => (string) $self['display_name'],
],
'players' => $players,
]);
}
function leaveRoom(array $payload): void
{
$roomCode = normalizeRoomCode((string) ($payload['room_code'] ?? ''));
$playerToken = trim((string) ($payload['player_token'] ?? ''));
if ($roomCode === '' || $playerToken === '') {
jsonResponse(['success' => false, 'error' => 'Room code and player token are required.'], 422);
}
$pdo = db();
$player = fetchPlayerForRoom($pdo, $roomCode, $playerToken);
if (!$player) {
jsonResponse(['success' => false, 'error' => 'Player or room not found.'], 404);
}
$disconnectStmt = $pdo->prepare('UPDATE tetris_room_players SET connection_status = :connection_status, game_status = :game_status, last_seen_at = UTC_TIMESTAMP() WHERE id = :player_id');
$disconnectStmt->bindValue(':connection_status', 'disconnected', PDO::PARAM_STR);
$disconnectStmt->bindValue(':game_status', 'paused', PDO::PARAM_STR);
$disconnectStmt->bindValue(':player_id', (int) $player['player_id'], PDO::PARAM_INT);
$disconnectStmt->execute();
$countConnectedStmt = $pdo->prepare('SELECT COUNT(*) FROM tetris_room_players WHERE room_id = :room_id AND connection_status = :connection_status');
$countConnectedStmt->bindValue(':room_id', (int) $player['room_id'], PDO::PARAM_INT);
$countConnectedStmt->bindValue(':connection_status', 'connected', PDO::PARAM_STR);
$countConnectedStmt->execute();
$connectedCount = (int) $countConnectedStmt->fetchColumn();
$roomStatus = $connectedCount > 0 ? 'active' : 'closed';
$updateRoom = $pdo->prepare('UPDATE tetris_rooms SET status = :status WHERE id = :room_id');
$updateRoom->bindValue(':status', $roomStatus, PDO::PARAM_STR);
$updateRoom->bindValue(':room_id', (int) $player['room_id'], PDO::PARAM_INT);
$updateRoom->execute();
jsonResponse([
'success' => true,
'room' => [
'code' => $roomCode,
'status' => $roomStatus,
],
]);
}
function fetchPlayerForRoom(PDO $pdo, string $roomCode, string $playerToken): ?array
{
$stmt = $pdo->prepare(
'SELECT p.id AS player_id, p.player_slot, p.display_name, p.room_id '
. 'FROM tetris_room_players p '
. 'INNER JOIN tetris_rooms r ON r.id = p.room_id '
. 'WHERE r.room_code = :room_code AND p.player_token = :player_token LIMIT 1'
);
$stmt->bindValue(':room_code', $roomCode, PDO::PARAM_STR);
$stmt->bindValue(':player_token', $playerToken, PDO::PARAM_STR);
$stmt->execute();
$row = $stmt->fetch();
return is_array($row) ? $row : null;
}
function encodeNullableJson($value): ?string
{
if ($value === null || $value === '') {
return null;
}
return json_encode($value, JSON_UNESCAPED_SLASHES);
}
function decodeNullableJson($value)
{
if ($value === null || $value === '') {
return null;
}
return json_decode((string) $value, true);
}
function normalizeGameStatus(string $gameStatus): string
{
$normalized = strtolower(trim($gameStatus));
$allowed = ['ready', 'playing', 'paused', 'game_over'];
return in_array($normalized, $allowed, true) ? $normalized : 'ready';
}