494 lines
18 KiB
PHP
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';
|
|
}
|