Route every Mercure publish through an event, log pushes per-player

Every $hub->publish() call site (chat, hints, security alerts, virus
alerts, game_finished, and the pre-game lobby events) now dispatches
a PushMercureMessageEvent instead of publishing directly. Two
listeners handle it: PublishMercureMessageListener does the actual
Mercure publish exactly as before (same wire format, no client
changes needed), and LogMercureMessageListener appends it to the
activity log of whichever player(s) it was actually delivered to.

A private /chat to one agent only gets logged for that agent, never
broadcast into everyone's transcript - the event carries an explicit
targetScreen (null = everyone) rather than leaving listeners to guess
from the payload shape.

The per-player log format moved from flat text to JSON Lines (one
timestamped, structured entry per line) via a new shared
SessionActivityLogger service, since a flat string can't carry an
event's type or exact payload - both needed for a future feature to
replay a player's full session history, not just their own commands,
on page reload. The admin session log viewer now parses and
formats these entries back into readable lines.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Frank
2026-08-18 19:47:53 +02:00
co-authored by Claude Sonnet 5
parent aba79251e0
commit a00899e444
13 changed files with 333 additions and 118 deletions
+59 -1
View File
@@ -58,7 +58,7 @@ final class GameAdminController extends AbstractController
$playersLogs[] = [
'username' => $player->getUser()->getUsername(),
'logs' => file_exists($logFile) ? file_get_contents($logFile) : '',
'entries' => file_exists($logFile) ? $this->parseActivityLog($logFile) : [],
];
}
@@ -68,4 +68,62 @@ final class GameAdminController extends AbstractController
'lobbyMessages' => $lobbyMessageRepository->findForSession($session),
]);
}
/**
* @return array<int, array{time: string, line: string}>
*/
private function parseActivityLog(string $logFile): array
{
$lines = [];
foreach (file($logFile, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) ?: [] as $rawLine) {
$entry = json_decode($rawLine, true);
if (!is_array($entry) || !isset($entry['ts'], $entry['source'])) {
// Fall back to showing whatever couldn't be parsed, rather than dropping it silently.
$lines[] = ['time' => '', 'line' => $rawLine];
continue;
}
$time = (\DateTimeImmutable::createFromFormat(DATE_ATOM, $entry['ts']) ?: null)?->format('Y-m-d H:i:s') ?? $entry['ts'];
$lines[] = ['time' => $time, 'line' => $this->formatActivityLogLine($entry)];
}
return $lines;
}
private function formatActivityLogLine(array $entry): string
{
$source = $entry['source'] ?? '';
if ($source === 'player') {
return 'PLAYER: ' . (string)($entry['content'] ?? '');
}
if ($source === 'server') {
return 'SERVER: ' . (string)($entry['content'] ?? '');
}
if ($source === 'push') {
return $this->formatPushLine((string)($entry['eventType'] ?? ''), $entry['payload'] ?? null);
}
return json_encode($entry);
}
private function formatPushLine(string $eventType, mixed $payload): string
{
return match ($eventType) {
'chat' => is_array($payload) && ($payload[0] ?? 0) !== 0
? 'CHAT (private): ' . ($payload[1] ?? '')
: 'CHAT: ' . (is_array($payload) ? ($payload[1] ?? '') : ''),
'hint' => 'HINT: ' . (is_array($payload) ? ($payload[1] ?? '') : ''),
'security_alert' => 'SECURITY ALERT: ' . (is_array($payload) ? ($payload[1] ?? '') : ''),
'virus_alert' => 'VIRUS: ' . (is_array($payload) ? ($payload[1] ?? '') : ''),
'help_modus_activated' => 'MAINFRAME: ' . (is_array($payload) ? ($payload[1] ?? '') : ''),
'game_finished' => 'GAME FINISHED: ' . (is_array($payload) ? ($payload['status'] ?? '') : ''),
default => 'PUSH (' . $eventType . '): ' . json_encode($payload),
};
}
}
+8 -9
View File
@@ -5,6 +5,7 @@ namespace App\Game\Controller;
use App\Game\Entity\Session;
use App\Game\Enum\SessionStatus;
use App\Game\Event\PushMercureMessageEvent;
use App\Game\Service\GameResponseService;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
@@ -12,8 +13,7 @@ use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Annotation\Route;
use Symfony\Component\Mercure\HubInterface;
use Symfony\Component\Mercure\Update;
use Symfony\Contracts\EventDispatcher\EventDispatcherInterface;
#[Route('/game/api', name: 'game_api_')]
final class GameApiController extends AbstractController
@@ -22,7 +22,7 @@ final class GameApiController extends AbstractController
public function __construct(
protected GameResponseService $gameResponseService,
private EntityManagerInterface $entityManager,
private HubInterface $hub,
private EventDispatcherInterface $eventDispatcher,
) {
}
@@ -50,12 +50,11 @@ final class GameApiController extends AbstractController
$this->entityManager->persist($session);
$this->entityManager->flush();
try {
$topic = '/game/hub/' . $session->getId();
$this->hub->publish(new Update($topic, json_encode(['type' => 'game_finished', 'status' => 'lost'])));
} catch (\Exception $e) {
// Mercure might be down
}
$this->eventDispatcher->dispatch(new PushMercureMessageEvent(
$session,
['type' => 'game_finished', 'status' => 'lost'],
'game_finished',
));
$isFinished = true;
}