Add shared multiplayer gear puzzle (Chamber of Motion)
Players open a no-login link and see live cursors and dragged gear pieces synced in real time, backed by a small Workerman WebSocket relay (src/Command/PuzzleRelayCommand.php) run alongside symfony serve - no Docker/Mercure hub needed. The board renders the wall artwork with a spare-gears tray, and a "Reset puzzle" button (visible only to logged-in users, verified server-side via a signed token) lets the DM wipe the shared state for everyone. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,236 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Command;
|
||||
|
||||
use App\Puzzle\PuzzleResetTokenSigner;
|
||||
use Symfony\Component\Console\Attribute\AsCommand;
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
use Symfony\Component\DependencyInjection\Attribute\Autowire;
|
||||
use Workerman\Connection\TcpConnection;
|
||||
use Workerman\Timer;
|
||||
use Workerman\Worker;
|
||||
|
||||
/**
|
||||
* A small WebSocket relay that lets several browsers looking at the same
|
||||
* puzzle room see each other's cursor and drag the same pieces around.
|
||||
*
|
||||
* Rooms live only in this process's memory (no database, no cache): if the
|
||||
* relay restarts, every room resets. Run it separately from the web server,
|
||||
* e.g. `symfony console app:puzzle-relay` next to `symfony serve`.
|
||||
*/
|
||||
#[AsCommand(
|
||||
name: 'app:puzzle-relay',
|
||||
description: 'Runs the WebSocket relay that syncs cursors and dragged pieces across the shared puzzle screens.',
|
||||
)]
|
||||
final class PuzzleRelayCommand extends Command
|
||||
{
|
||||
/**
|
||||
* Clients ping every 15s (see puzzle_room_controller.js); a connection
|
||||
* that's gone quiet for longer than this is presumed dead (closed tab,
|
||||
* lost network, phone asleep) and gets dropped so it stops showing up
|
||||
* as a ghost cursor/piece-holder for everyone else.
|
||||
*/
|
||||
private const HEARTBEAT_TIMEOUT_SECONDS = 45;
|
||||
|
||||
/** @var array<string, array<int, TcpConnection>> room => connection id => connection */
|
||||
private array $connections = [];
|
||||
|
||||
/** @var array<string, array<int, array{clientId: string, name: string, color: string}>> room => connection id => presence */
|
||||
private array $presence = [];
|
||||
|
||||
/** @var array<string, array<string, array{x: float, y: float}>> room => piece id => position */
|
||||
private array $pieces = [];
|
||||
|
||||
public function __construct(
|
||||
#[Autowire(env: 'PUZZLE_RELAY_LISTEN')]
|
||||
private readonly string $listenAddress,
|
||||
#[Autowire('%kernel.project_dir%')]
|
||||
private readonly string $projectDir,
|
||||
private readonly PuzzleResetTokenSigner $resetTokenSigner,
|
||||
) {
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||
{
|
||||
$output->writeln("Puzzle relay listening on ws://{$this->listenAddress} (Ctrl+C to stop)");
|
||||
|
||||
$logDir = $this->projectDir.'/var/log';
|
||||
if (!is_dir($logDir)) {
|
||||
mkdir($logDir, 0777, true);
|
||||
}
|
||||
Worker::$logFile = $logDir.'/puzzle-relay.log';
|
||||
Worker::$pidFile = $logDir.'/puzzle-relay.pid';
|
||||
Worker::$stdoutFile = $logDir.'/puzzle-relay-stdout.log';
|
||||
|
||||
$worker = new Worker("websocket://{$this->listenAddress}");
|
||||
$worker->onConnect = static function (TcpConnection $connection): void {
|
||||
$connection->lastSeenAt = time();
|
||||
};
|
||||
$worker->onMessage = $this->onMessage(...);
|
||||
$worker->onClose = $this->onClose(...);
|
||||
$worker->onWorkerStart = function () use ($worker): void {
|
||||
Timer::add(10, fn () => $this->reapDeadConnections($worker));
|
||||
};
|
||||
|
||||
Worker::runAll();
|
||||
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
private function onMessage(TcpConnection $connection, string $data): void
|
||||
{
|
||||
$message = json_decode($data, true);
|
||||
if (!\is_array($message) || !\is_string($message['type'] ?? null)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$connection->lastSeenAt = time();
|
||||
|
||||
match ($message['type']) {
|
||||
'join' => $this->handleJoin($connection, $message),
|
||||
'cursor' => $this->handleCursor($connection, $message),
|
||||
'piece' => $this->handlePiece($connection, $message),
|
||||
'reset' => $this->handleReset($connection, $message),
|
||||
'ping' => null,
|
||||
default => null,
|
||||
};
|
||||
}
|
||||
|
||||
private function reapDeadConnections(Worker $worker): void
|
||||
{
|
||||
$cutoff = time() - self::HEARTBEAT_TIMEOUT_SECONDS;
|
||||
foreach ($worker->connections as $connection) {
|
||||
if (($connection->lastSeenAt ?? 0) < $cutoff) {
|
||||
$connection->close();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** @param array<string, mixed> $message */
|
||||
private function handleJoin(TcpConnection $connection, array $message): void
|
||||
{
|
||||
$room = $this->roomFrom($message);
|
||||
if (null === $room) {
|
||||
return;
|
||||
}
|
||||
|
||||
$connection->room = $room;
|
||||
$connection->clientId = (string) ($message['clientId'] ?? '');
|
||||
$connection->name = mb_substr((string) ($message['name'] ?? 'Player'), 0, 40);
|
||||
$connection->color = (string) ($message['color'] ?? '#8a8a8a');
|
||||
|
||||
$this->connections[$room][$connection->id] = $connection;
|
||||
$this->presence[$room][$connection->id] = [
|
||||
'clientId' => $connection->clientId,
|
||||
'name' => $connection->name,
|
||||
'color' => $connection->color,
|
||||
];
|
||||
|
||||
$connection->send(json_encode([
|
||||
'type' => 'state',
|
||||
'pieces' => $this->pieces[$room] ?? new \stdClass(),
|
||||
'clients' => array_values($this->presence[$room]),
|
||||
], \JSON_THROW_ON_ERROR));
|
||||
|
||||
$this->broadcastPresence($room);
|
||||
}
|
||||
|
||||
/** @param array<string, mixed> $message */
|
||||
private function handleCursor(TcpConnection $connection, array $message): void
|
||||
{
|
||||
$room = $connection->room ?? null;
|
||||
if (!\is_string($room)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->broadcast($room, [
|
||||
'type' => 'cursor',
|
||||
'clientId' => $connection->clientId,
|
||||
'name' => $connection->name,
|
||||
'color' => $connection->color,
|
||||
'x' => (float) ($message['x'] ?? 0),
|
||||
'y' => (float) ($message['y'] ?? 0),
|
||||
], except: $connection->id);
|
||||
}
|
||||
|
||||
/** @param array<string, mixed> $message */
|
||||
private function handlePiece(TcpConnection $connection, array $message): void
|
||||
{
|
||||
$room = $connection->room ?? null;
|
||||
$id = $message['id'] ?? null;
|
||||
if (!\is_string($room) || !\is_string($id)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$x = (float) ($message['x'] ?? 0);
|
||||
$y = (float) ($message['y'] ?? 0);
|
||||
$this->pieces[$room][$id] = ['x' => $x, 'y' => $y];
|
||||
|
||||
$this->broadcast($room, [
|
||||
'type' => 'piece',
|
||||
'id' => $id,
|
||||
'x' => $x,
|
||||
'y' => $y,
|
||||
'phase' => \in_array($message['phase'] ?? null, ['move', 'drop'], true) ? $message['phase'] : 'move',
|
||||
'clientId' => $connection->clientId,
|
||||
], except: $connection->id);
|
||||
}
|
||||
|
||||
/** @param array<string, mixed> $message */
|
||||
private function handleReset(TcpConnection $connection, array $message): void
|
||||
{
|
||||
$room = $connection->room ?? null;
|
||||
$token = $message['resetToken'] ?? null;
|
||||
if (!\is_string($room) || !\is_string($token) || !$this->resetTokenSigner->verify($room, $token)) {
|
||||
return;
|
||||
}
|
||||
|
||||
unset($this->pieces[$room]);
|
||||
|
||||
$this->broadcast($room, ['type' => 'reset']);
|
||||
}
|
||||
|
||||
private function onClose(TcpConnection $connection): void
|
||||
{
|
||||
$room = $connection->room ?? null;
|
||||
if (!\is_string($room)) {
|
||||
return;
|
||||
}
|
||||
|
||||
unset($this->connections[$room][$connection->id], $this->presence[$room][$connection->id]);
|
||||
$this->broadcastPresence($room);
|
||||
}
|
||||
|
||||
private function broadcastPresence(string $room): void
|
||||
{
|
||||
$this->broadcast($room, [
|
||||
'type' => 'presence',
|
||||
'clients' => array_values($this->presence[$room] ?? []),
|
||||
]);
|
||||
}
|
||||
|
||||
/** @param array<string, mixed> $payload */
|
||||
private function broadcast(string $room, array $payload, ?int $except = null): void
|
||||
{
|
||||
$encoded = json_encode($payload, \JSON_THROW_ON_ERROR);
|
||||
foreach ($this->connections[$room] ?? [] as $id => $connection) {
|
||||
if ($id === $except) {
|
||||
continue;
|
||||
}
|
||||
$connection->send($encoded);
|
||||
}
|
||||
}
|
||||
|
||||
/** @param array<string, mixed> $message */
|
||||
private function roomFrom(array $message): ?string
|
||||
{
|
||||
$room = $message['room'] ?? null;
|
||||
|
||||
return \is_string($room) && '' !== $room ? $room : null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Controller;
|
||||
|
||||
use App\Puzzle\PuzzleResetTokenSigner;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\DependencyInjection\Attribute\Autowire;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
|
||||
/**
|
||||
* Shared, no-login puzzle screens: everyone with the link sees the same
|
||||
* board, the same pieces, and each other's cursors, synced over the
|
||||
* WebSocket relay in {@see \App\Command\PuzzleRelayCommand}.
|
||||
*/
|
||||
final class PuzzleController extends AbstractController
|
||||
{
|
||||
public function __construct(
|
||||
#[Autowire(env: 'PUZZLE_RELAY_PUBLIC_URL')]
|
||||
private readonly string $relayPublicUrl,
|
||||
private readonly PuzzleResetTokenSigner $resetTokenSigner,
|
||||
) {
|
||||
}
|
||||
|
||||
#[Route('/puzzle/{room}', name: 'app_puzzle_room', requirements: ['room' => '[a-z0-9-]+'], methods: ['GET'])]
|
||||
public function room(string $room): Response
|
||||
{
|
||||
return $this->render('puzzle/room.html.twig', [
|
||||
'room' => $room,
|
||||
'relayPublicUrl' => $this->relayPublicUrl,
|
||||
// Only a logged-in user gets a token that lets them reset the shared
|
||||
// board; anonymous players can play but not wipe it for everyone.
|
||||
'resetToken' => $this->getUser() ? $this->resetTokenSigner->sign($room) : null,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Puzzle;
|
||||
|
||||
use Symfony\Component\DependencyInjection\Attribute\Autowire;
|
||||
|
||||
/**
|
||||
* Signs/verifies the token that lets a client reset a shared puzzle room.
|
||||
* Used by both {@see \App\Controller\PuzzleController} (issues it to logged-in
|
||||
* users) and {@see \App\Command\PuzzleRelayCommand} (a public WebSocket server
|
||||
* outside Symfony's session/security system, so it must check the signature
|
||||
* itself rather than trusting the client).
|
||||
*/
|
||||
final class PuzzleResetTokenSigner
|
||||
{
|
||||
public function __construct(
|
||||
#[Autowire(env: 'APP_SECRET')]
|
||||
private readonly string $secret,
|
||||
) {
|
||||
}
|
||||
|
||||
public function sign(string $room): string
|
||||
{
|
||||
return hash_hmac('sha256', $room, $this->secret);
|
||||
}
|
||||
|
||||
public function verify(string $room, string $token): bool
|
||||
{
|
||||
return hash_equals($this->sign($room), $token);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user