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:
@@ -8,6 +8,7 @@ use App\Game\Entity\Session;
|
|||||||
use App\Game\Enum\GameSettingType;
|
use App\Game\Enum\GameSettingType;
|
||||||
use App\Game\Enum\SessionSettingType;
|
use App\Game\Enum\SessionSettingType;
|
||||||
use App\Game\Enum\SessionStatus;
|
use App\Game\Enum\SessionStatus;
|
||||||
|
use App\Game\Event\PushMercureMessageEvent;
|
||||||
use App\Game\Repository\GameSettingRepository;
|
use App\Game\Repository\GameSettingRepository;
|
||||||
use App\Game\Repository\SessionRepository;
|
use App\Game\Repository\SessionRepository;
|
||||||
use App\Game\Repository\SessionSettingRepository;
|
use App\Game\Repository\SessionSettingRepository;
|
||||||
@@ -17,8 +18,7 @@ use Symfony\Component\Console\Attribute\AsCommand;
|
|||||||
use Symfony\Component\Console\Command\Command;
|
use Symfony\Component\Console\Command\Command;
|
||||||
use Symfony\Component\Console\Input\InputInterface;
|
use Symfony\Component\Console\Input\InputInterface;
|
||||||
use Symfony\Component\Console\Output\OutputInterface;
|
use Symfony\Component\Console\Output\OutputInterface;
|
||||||
use Symfony\Component\Mercure\HubInterface;
|
use Symfony\Contracts\EventDispatcher\EventDispatcherInterface;
|
||||||
use Symfony\Component\Mercure\Update;
|
|
||||||
|
|
||||||
#[AsCommand(
|
#[AsCommand(
|
||||||
name: 'app:hints:check',
|
name: 'app:hints:check',
|
||||||
@@ -46,7 +46,7 @@ final class SendMainframeHintsCommand extends Command
|
|||||||
private readonly GameSettingRepository $gameSettingRepository,
|
private readonly GameSettingRepository $gameSettingRepository,
|
||||||
private readonly PlayerService $playerService,
|
private readonly PlayerService $playerService,
|
||||||
private readonly EntityManagerInterface $entityManager,
|
private readonly EntityManagerInterface $entityManager,
|
||||||
private readonly HubInterface $hub,
|
private readonly EventDispatcherInterface $eventDispatcher,
|
||||||
) {
|
) {
|
||||||
parent::__construct();
|
parent::__construct();
|
||||||
}
|
}
|
||||||
@@ -100,12 +100,11 @@ final class SendMainframeHintsCommand extends Command
|
|||||||
$this->entityManager->persist($session);
|
$this->entityManager->persist($session);
|
||||||
$this->entityManager->flush();
|
$this->entityManager->flush();
|
||||||
|
|
||||||
try {
|
$this->eventDispatcher->dispatch(new PushMercureMessageEvent(
|
||||||
$topic = '/game/hub/' . $session->getId();
|
$session,
|
||||||
$this->hub->publish(new Update($topic, json_encode(['type' => 'game_finished', 'status' => 'lost'])));
|
['type' => 'game_finished', 'status' => 'lost'],
|
||||||
} catch (\Exception $e) {
|
'game_finished',
|
||||||
// Mercure might be down
|
));
|
||||||
}
|
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@@ -303,11 +302,12 @@ final class SendMainframeHintsCommand extends Command
|
|||||||
|
|
||||||
private function publishHint(Session $session, string $message, Player $player): void
|
private function publishHint(Session $session, string $message, Player $player): void
|
||||||
{
|
{
|
||||||
try {
|
$screen = $player->getScreen() ?? 0;
|
||||||
$topic = '/game/hub/' . $session->getId();
|
$this->eventDispatcher->dispatch(new PushMercureMessageEvent(
|
||||||
$this->hub->publish(new Update($topic, json_encode([$player->getScreen() ?? 0, $message, 'hint'])));
|
$session,
|
||||||
} catch (\Exception $e) {
|
[$screen, $message, 'hint'],
|
||||||
// Mercure might be down, but we don't want to crash the game
|
'hint',
|
||||||
}
|
$screen,
|
||||||
|
));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -58,7 +58,7 @@ final class GameAdminController extends AbstractController
|
|||||||
|
|
||||||
$playersLogs[] = [
|
$playersLogs[] = [
|
||||||
'username' => $player->getUser()->getUsername(),
|
'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),
|
'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),
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ namespace App\Game\Controller;
|
|||||||
|
|
||||||
use App\Game\Entity\Session;
|
use App\Game\Entity\Session;
|
||||||
use App\Game\Enum\SessionStatus;
|
use App\Game\Enum\SessionStatus;
|
||||||
|
use App\Game\Event\PushMercureMessageEvent;
|
||||||
use App\Game\Service\GameResponseService;
|
use App\Game\Service\GameResponseService;
|
||||||
use Doctrine\ORM\EntityManagerInterface;
|
use Doctrine\ORM\EntityManagerInterface;
|
||||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||||
@@ -12,8 +13,7 @@ use Symfony\Component\HttpFoundation\JsonResponse;
|
|||||||
use Symfony\Component\HttpFoundation\Request;
|
use Symfony\Component\HttpFoundation\Request;
|
||||||
use Symfony\Component\HttpFoundation\Response;
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
use Symfony\Component\Routing\Annotation\Route;
|
use Symfony\Component\Routing\Annotation\Route;
|
||||||
use Symfony\Component\Mercure\HubInterface;
|
use Symfony\Contracts\EventDispatcher\EventDispatcherInterface;
|
||||||
use Symfony\Component\Mercure\Update;
|
|
||||||
|
|
||||||
#[Route('/game/api', name: 'game_api_')]
|
#[Route('/game/api', name: 'game_api_')]
|
||||||
final class GameApiController extends AbstractController
|
final class GameApiController extends AbstractController
|
||||||
@@ -22,7 +22,7 @@ final class GameApiController extends AbstractController
|
|||||||
public function __construct(
|
public function __construct(
|
||||||
protected GameResponseService $gameResponseService,
|
protected GameResponseService $gameResponseService,
|
||||||
private EntityManagerInterface $entityManager,
|
private EntityManagerInterface $entityManager,
|
||||||
private HubInterface $hub,
|
private EventDispatcherInterface $eventDispatcher,
|
||||||
) {
|
) {
|
||||||
|
|
||||||
}
|
}
|
||||||
@@ -50,12 +50,11 @@ final class GameApiController extends AbstractController
|
|||||||
$this->entityManager->persist($session);
|
$this->entityManager->persist($session);
|
||||||
$this->entityManager->flush();
|
$this->entityManager->flush();
|
||||||
|
|
||||||
try {
|
$this->eventDispatcher->dispatch(new PushMercureMessageEvent(
|
||||||
$topic = '/game/hub/' . $session->getId();
|
$session,
|
||||||
$this->hub->publish(new Update($topic, json_encode(['type' => 'game_finished', 'status' => 'lost'])));
|
['type' => 'game_finished', 'status' => 'lost'],
|
||||||
} catch (\Exception $e) {
|
'game_finished',
|
||||||
// Mercure might be down
|
));
|
||||||
}
|
|
||||||
|
|
||||||
$isFinished = true;
|
$isFinished = true;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Game\Event;
|
||||||
|
|
||||||
|
use App\Game\Entity\Session;
|
||||||
|
use Symfony\Contracts\EventDispatcher\Event;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fired everywhere the game used to publish straight to the Mercure hub. Two
|
||||||
|
* listeners handle it: one publishes it to Mercure exactly as before, the other
|
||||||
|
* appends it to the affected player(s)' session activity log, so a player's full
|
||||||
|
* terminal history - not just their own commands - can be reconstructed later.
|
||||||
|
*/
|
||||||
|
final class PushMercureMessageEvent extends Event
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @param mixed $payload The exact value that will be json_encode()'d as the
|
||||||
|
* Mercure update's data - unchanged from what used to be
|
||||||
|
* passed straight to `new Update($topic, json_encode(...))`.
|
||||||
|
* @param string $eventType Short machine-readable label for the log (e.g. 'chat',
|
||||||
|
* 'hint', 'security_alert', 'game_finished').
|
||||||
|
* @param int|null $targetScreen Which player this is for: null means everyone in
|
||||||
|
* the session, an int targets that one player's
|
||||||
|
* screen only (e.g. a private /chat message).
|
||||||
|
*/
|
||||||
|
public function __construct(
|
||||||
|
public readonly Session $session,
|
||||||
|
public readonly mixed $payload,
|
||||||
|
public readonly string $eventType,
|
||||||
|
public readonly ?int $targetScreen = null,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Game\EventListener;
|
||||||
|
|
||||||
|
use App\Game\Event\PushMercureMessageEvent;
|
||||||
|
use App\Game\Service\SessionActivityLogger;
|
||||||
|
use Symfony\Component\EventDispatcher\Attribute\AsEventListener;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Appends the pushed message to the activity log of every player it was actually
|
||||||
|
* delivered to - everyone in the session for a broadcast, or just the one player
|
||||||
|
* for a targeted message (e.g. a private /chat, or a security alert). This is what
|
||||||
|
* lets a player's full terminal history be reconstructed later, not just the
|
||||||
|
* commands they typed themselves.
|
||||||
|
*/
|
||||||
|
#[AsEventListener]
|
||||||
|
final class LogMercureMessageListener
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly SessionActivityLogger $sessionActivityLogger,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
public function __invoke(PushMercureMessageEvent $event): void
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
foreach ($event->session->getPlayers() as $player) {
|
||||||
|
if ($event->targetScreen !== null && $player->getScreen() !== $event->targetScreen) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->sessionActivityLogger->log($player, 'push', [
|
||||||
|
'eventType' => $event->eventType,
|
||||||
|
'payload' => $event->payload,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
// A logging hiccup should never break gameplay
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Game\EventListener;
|
||||||
|
|
||||||
|
use App\Game\Event\PushMercureMessageEvent;
|
||||||
|
use Symfony\Component\EventDispatcher\Attribute\AsEventListener;
|
||||||
|
use Symfony\Component\Mercure\HubInterface;
|
||||||
|
use Symfony\Component\Mercure\Update;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Actually publishes the event's payload to the Mercure hub - this is the part
|
||||||
|
* that used to happen inline, directly at every $hub->publish() call site.
|
||||||
|
*/
|
||||||
|
#[AsEventListener]
|
||||||
|
final class PublishMercureMessageListener
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly HubInterface $hub,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
public function __invoke(PushMercureMessageEvent $event): void
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$topic = '/game/hub/' . $event->session->getId();
|
||||||
|
$this->hub->publish(new Update($topic, json_encode($event->payload)));
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
// Mercure might be down, but we don't want to crash the game
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -11,14 +11,14 @@ use App\Game\Entity\SessionSetting;
|
|||||||
use App\Game\Enum\GameStatus;
|
use App\Game\Enum\GameStatus;
|
||||||
use App\Game\Enum\SessionSettingType;
|
use App\Game\Enum\SessionSettingType;
|
||||||
use App\Game\Enum\SessionStatus;
|
use App\Game\Enum\SessionStatus;
|
||||||
|
use App\Game\Event\PushMercureMessageEvent;
|
||||||
use App\Game\Repository\GameRepository;
|
use App\Game\Repository\GameRepository;
|
||||||
use App\Game\Repository\LobbyMessageRepository;
|
use App\Game\Repository\LobbyMessageRepository;
|
||||||
use App\Game\Repository\SessionRepository;
|
use App\Game\Repository\SessionRepository;
|
||||||
use App\Tech\Entity\User;
|
use App\Tech\Entity\User;
|
||||||
use Doctrine\ORM\EntityManagerInterface;
|
use Doctrine\ORM\EntityManagerInterface;
|
||||||
use Symfony\Component\Security\Core\User\UserInterface;
|
use Symfony\Component\Security\Core\User\UserInterface;
|
||||||
use Symfony\Component\Mercure\HubInterface;
|
use Symfony\Contracts\EventDispatcher\EventDispatcherInterface;
|
||||||
use Symfony\Component\Mercure\Update;
|
|
||||||
|
|
||||||
final class GameDashboardService
|
final class GameDashboardService
|
||||||
{
|
{
|
||||||
@@ -31,7 +31,7 @@ final class GameDashboardService
|
|||||||
private readonly SessionRepository $sessionRepository,
|
private readonly SessionRepository $sessionRepository,
|
||||||
private readonly LobbyMessageRepository $lobbyMessageRepository,
|
private readonly LobbyMessageRepository $lobbyMessageRepository,
|
||||||
private readonly EntityManagerInterface $entityManager,
|
private readonly EntityManagerInterface $entityManager,
|
||||||
private readonly HubInterface $hub,
|
private readonly EventDispatcherInterface $eventDispatcher,
|
||||||
) {
|
) {
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -480,12 +480,11 @@ final class GameDashboardService
|
|||||||
|
|
||||||
private function publishLobbyEvent(Session $session, string $type, array $extra = []): void
|
private function publishLobbyEvent(Session $session, string $type, array $extra = []): void
|
||||||
{
|
{
|
||||||
try {
|
$this->eventDispatcher->dispatch(new PushMercureMessageEvent(
|
||||||
$topic = '/game/hub/' . $session->getId();
|
$session,
|
||||||
$this->hub->publish(new Update($topic, json_encode(array_merge(['type' => $type], $extra))));
|
array_merge(['type' => $type], $extra),
|
||||||
} catch (\Exception $e) {
|
$type,
|
||||||
// Mercure might be down, but we don't want to crash the game
|
));
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public function checkAllPlayersReady(Session $session): void
|
public function checkAllPlayersReady(Session $session): void
|
||||||
@@ -559,12 +558,7 @@ final class GameDashboardService
|
|||||||
|
|
||||||
$this->entityManager->flush();
|
$this->entityManager->flush();
|
||||||
|
|
||||||
try {
|
$this->eventDispatcher->dispatch(new PushMercureMessageEvent($session, ['type' => 'all_ready'], 'all_ready'));
|
||||||
$topic = '/game/hub/' . $session->getId();
|
|
||||||
$this->hub->publish(new Update($topic, json_encode(['type' => 'all_ready'])));
|
|
||||||
} catch (\Exception $e) {
|
|
||||||
// Mercure might be down, but we don't want to crash the game
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -8,12 +8,12 @@ use App\Game\Enum\SessionStatus;
|
|||||||
use App\Game\Entity\Player;
|
use App\Game\Entity\Player;
|
||||||
use App\Game\Entity\Session;
|
use App\Game\Entity\Session;
|
||||||
use App\Game\Entity\SessionSetting;
|
use App\Game\Entity\SessionSetting;
|
||||||
|
use App\Game\Event\PushMercureMessageEvent;
|
||||||
use App\Game\Repository\SessionSettingRepository;
|
use App\Game\Repository\SessionSettingRepository;
|
||||||
use App\Tech\Entity\User;
|
use App\Tech\Entity\User;
|
||||||
use Doctrine\ORM\EntityManagerInterface;
|
use Doctrine\ORM\EntityManagerInterface;
|
||||||
use Symfony\Bundle\SecurityBundle\Security;
|
use Symfony\Bundle\SecurityBundle\Security;
|
||||||
use Symfony\Component\Mercure\HubInterface;
|
use Symfony\Contracts\EventDispatcher\EventDispatcherInterface;
|
||||||
use Symfony\Component\Mercure\Update;
|
|
||||||
|
|
||||||
class GameResponseService
|
class GameResponseService
|
||||||
{
|
{
|
||||||
@@ -25,8 +25,9 @@ class GameResponseService
|
|||||||
private Security $security,
|
private Security $security,
|
||||||
private PlayerService $playerService,
|
private PlayerService $playerService,
|
||||||
private SessionSettingRepository $sessionSettingRepository,
|
private SessionSettingRepository $sessionSettingRepository,
|
||||||
private HubInterface $hub,
|
|
||||||
private EntityManagerInterface $entityManager,
|
private EntityManagerInterface $entityManager,
|
||||||
|
private EventDispatcherInterface $eventDispatcher,
|
||||||
|
private SessionActivityLogger $sessionActivityLogger,
|
||||||
private string $projectDir,
|
private string $projectDir,
|
||||||
) {
|
) {
|
||||||
}
|
}
|
||||||
@@ -72,7 +73,7 @@ class GameResponseService
|
|||||||
|
|
||||||
$this->enforceLockedFilesRemovalDeadline($player->getSession());
|
$this->enforceLockedFilesRemovalDeadline($player->getSession());
|
||||||
|
|
||||||
$this->logSessionActivity($player, 'PLAYER: ' . $message);
|
$this->sessionActivityLogger->log($player, 'player', ['content' => $message]);
|
||||||
|
|
||||||
$data = $this->handleLockedPlayer($message, $player);
|
$data = $this->handleLockedPlayer($message, $player);
|
||||||
|
|
||||||
@@ -98,28 +99,12 @@ class GameResponseService
|
|||||||
}
|
}
|
||||||
|
|
||||||
if ($responseLog !== '') {
|
if ($responseLog !== '') {
|
||||||
$this->logSessionActivity($player, 'SERVER: ' . trim($responseLog));
|
$this->sessionActivityLogger->log($player, 'server', ['content' => trim($responseLog)]);
|
||||||
}
|
}
|
||||||
|
|
||||||
return $data;
|
return $data;
|
||||||
}
|
}
|
||||||
|
|
||||||
private function logSessionActivity(Player $player, string $content): void
|
|
||||||
{
|
|
||||||
$sessionId = $player->getSession()->getId();
|
|
||||||
$logDir = $this->projectDir . '/var/log/sessions/' . $sessionId;
|
|
||||||
|
|
||||||
if (!is_dir($logDir)) {
|
|
||||||
mkdir($logDir, 0777, true);
|
|
||||||
}
|
|
||||||
|
|
||||||
$logFile = $logDir . '/' . $player->getLogFileBasename() . '.txt';
|
|
||||||
$timestamp = date('Y-m-d H:i:s');
|
|
||||||
$logMessage = sprintf("[%s] %s\n", $timestamp, $content);
|
|
||||||
|
|
||||||
file_put_contents($logFile, $logMessage, FILE_APPEND);
|
|
||||||
}
|
|
||||||
|
|
||||||
private function getRechten(Player $player): array
|
private function getRechten(Player $player): array
|
||||||
{
|
{
|
||||||
$settingName = SessionSettingType::tryFrom('RightsForPlayer' . $player->getScreen());
|
$settingName = SessionSettingType::tryFrom('RightsForPlayer' . $player->getScreen());
|
||||||
@@ -430,17 +415,19 @@ class GameResponseService
|
|||||||
|
|
||||||
$message = trim($message);
|
$message = trim($message);
|
||||||
|
|
||||||
$activeGame = $player->getSession()?->getId();
|
$session = $player->getSession();
|
||||||
|
|
||||||
if(is_null($activeGame))
|
if(is_null($session))
|
||||||
return false;
|
return false;
|
||||||
|
|
||||||
$topic = '/game/hub/' . $activeGame;
|
// A /chat targeted at one agent (e.g. "/chat 3 ...") must only ever be
|
||||||
try {
|
// logged for that agent, not broadcast into everyone's transcript.
|
||||||
$this->hub->publish(new Update($topic, json_encode([$sendTo, $message])));
|
$this->eventDispatcher->dispatch(new PushMercureMessageEvent(
|
||||||
} catch (\Exception $e) {
|
$session,
|
||||||
// Mercure might be down
|
[$sendTo, $message],
|
||||||
}
|
'chat',
|
||||||
|
(int)$sendTo === 0 ? null : (int)$sendTo,
|
||||||
|
));
|
||||||
|
|
||||||
$this->updateChatTracking($player, (int)$sendTo);
|
$this->updateChatTracking($player, (int)$sendTo);
|
||||||
|
|
||||||
@@ -480,15 +467,14 @@ class GameResponseService
|
|||||||
$this->entityManager->persist($setting);
|
$this->entityManager->persist($setting);
|
||||||
$this->entityManager->flush();
|
$this->entityManager->flush();
|
||||||
|
|
||||||
// Notify the player that their codes have changed
|
// Notify the player that their codes have changed - only this one (screen)
|
||||||
$topic = '/game/hub/' . $session->getId();
|
|
||||||
$notification = "Security Alert: One of your verify codes was shared and has been regenerated.";
|
$notification = "Security Alert: One of your verify codes was shared and has been regenerated.";
|
||||||
// We send it only to this player (screen)
|
$this->eventDispatcher->dispatch(new PushMercureMessageEvent(
|
||||||
try {
|
$session,
|
||||||
$this->hub->publish(new Update($topic, json_encode([$screen, $notification])));
|
[$screen, $notification],
|
||||||
} catch (\Exception $e) {
|
'security_alert',
|
||||||
// Mercure might be down
|
$screen,
|
||||||
}
|
));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -963,13 +949,8 @@ class GameResponseService
|
|||||||
$this->entityManager->persist($everyoneVerifiedSetting);
|
$this->entityManager->persist($everyoneVerifiedSetting);
|
||||||
$this->entityManager->flush();
|
$this->entityManager->flush();
|
||||||
|
|
||||||
$topic = '/game/hub/' . $session->getId();
|
|
||||||
$message = "Mainframe Help Modus: Agents Doyle, Vega and Lennox rapports have been updated with coded messages.";
|
$message = "Mainframe Help Modus: Agents Doyle, Vega and Lennox rapports have been updated with coded messages.";
|
||||||
try {
|
$this->eventDispatcher->dispatch(new PushMercureMessageEvent($session, [0, $message], 'help_modus_activated'));
|
||||||
$this->hub->publish(new Update($topic, json_encode([0, $message])));
|
|
||||||
} catch (\Exception $e) {
|
|
||||||
// Mercure might be down
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1155,12 +1136,11 @@ class GameResponseService
|
|||||||
$this->entityManager->persist($session);
|
$this->entityManager->persist($session);
|
||||||
$this->entityManager->flush();
|
$this->entityManager->flush();
|
||||||
|
|
||||||
$topic = '/game/hub/' . $session->getId();
|
$this->eventDispatcher->dispatch(new PushMercureMessageEvent(
|
||||||
try {
|
$session,
|
||||||
$this->hub->publish(new Update($topic, json_encode(['type' => 'game_finished', 'status' => 'won'])));
|
['type' => 'game_finished', 'status' => 'won'],
|
||||||
} catch (\Exception $e) {
|
'game_finished',
|
||||||
// Mercure might be down
|
));
|
||||||
}
|
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@@ -1245,13 +1225,8 @@ class GameResponseService
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!empty($stillLocked)) {
|
if (!empty($stillLocked)) {
|
||||||
$topic = '/game/hub/' . $session->getId();
|
|
||||||
$message = 'AI VIRUS: Integrity check complete. Restored files that were not fully purged in time.';
|
$message = 'AI VIRUS: Integrity check complete. Restored files that were not fully purged in time.';
|
||||||
try {
|
$this->eventDispatcher->dispatch(new PushMercureMessageEvent($session, [0, $message, 'virus'], 'virus_alert'));
|
||||||
$this->hub->publish(new Update($topic, json_encode([0, $message, 'virus'])));
|
|
||||||
} catch (\Exception $e) {
|
|
||||||
// Mercure might be down
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,46 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Game\Service;
|
||||||
|
|
||||||
|
use App\Game\Entity\Player;
|
||||||
|
use Symfony\Component\DependencyInjection\Attribute\Autowire;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Appends structured (JSON Lines) entries to a player's per-session activity log
|
||||||
|
* at var/log/sessions/{sessionId}/{player}.txt. Used both for a player's own
|
||||||
|
* commands (source: player/server) and for anything pushed to them over Mercure
|
||||||
|
* (source: push), so the file ends up as a single chronological, replayable
|
||||||
|
* transcript of everything that player saw.
|
||||||
|
*/
|
||||||
|
class SessionActivityLogger
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
#[Autowire('%kernel.project_dir%')]
|
||||||
|
private readonly string $projectDir,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
public function log(Player $player, string $source, array $data): void
|
||||||
|
{
|
||||||
|
$session = $player->getSession();
|
||||||
|
if ($session === null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$logDir = $this->projectDir . '/var/log/sessions/' . $session->getId();
|
||||||
|
if (!is_dir($logDir)) {
|
||||||
|
mkdir($logDir, 0777, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
$logFile = $logDir . '/' . $player->getLogFileBasename() . '.txt';
|
||||||
|
|
||||||
|
$entry = array_merge([
|
||||||
|
'ts' => (new \DateTimeImmutable())->format(DATE_ATOM),
|
||||||
|
'source' => $source,
|
||||||
|
], $data);
|
||||||
|
|
||||||
|
file_put_contents($logFile, json_encode($entry) . "\n", FILE_APPEND | LOCK_EX);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -98,7 +98,8 @@
|
|||||||
font-size: 0.85rem;
|
font-size: 0.85rem;
|
||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
">{{ playerLog.logs ?: 'No logs found for this player.' }}</pre>
|
">{% if playerLog.entries is empty %}No logs found for this player.{% else %}{% for entry in playerLog.entries %}{% if entry.time %}[{{ entry.time }}] {% endif %}{{ entry.line }}
|
||||||
|
{% endfor %}{% endif %}</pre>
|
||||||
</div>
|
</div>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ use App\Tech\Entity\User;
|
|||||||
use Doctrine\ORM\EntityManagerInterface;
|
use Doctrine\ORM\EntityManagerInterface;
|
||||||
use App\Game\Repository\GameRepository;
|
use App\Game\Repository\GameRepository;
|
||||||
use App\Game\Repository\SessionRepository;
|
use App\Game\Repository\SessionRepository;
|
||||||
use Symfony\Component\Mercure\HubInterface;
|
use Symfony\Contracts\EventDispatcher\EventDispatcherInterface;
|
||||||
use PHPUnit\Framework\TestCase;
|
use PHPUnit\Framework\TestCase;
|
||||||
|
|
||||||
class GameDashboardServiceTest extends TestCase
|
class GameDashboardServiceTest extends TestCase
|
||||||
@@ -25,7 +25,7 @@ class GameDashboardServiceTest extends TestCase
|
|||||||
private $gameRepository;
|
private $gameRepository;
|
||||||
private $sessionRepository;
|
private $sessionRepository;
|
||||||
private $lobbyMessageRepository;
|
private $lobbyMessageRepository;
|
||||||
private $hub;
|
private $eventDispatcher;
|
||||||
private $service;
|
private $service;
|
||||||
|
|
||||||
protected function setUp(): void
|
protected function setUp(): void
|
||||||
@@ -34,14 +34,14 @@ class GameDashboardServiceTest extends TestCase
|
|||||||
$this->gameRepository = $this->createMock(GameRepository::class);
|
$this->gameRepository = $this->createMock(GameRepository::class);
|
||||||
$this->sessionRepository = $this->createMock(SessionRepository::class);
|
$this->sessionRepository = $this->createMock(SessionRepository::class);
|
||||||
$this->lobbyMessageRepository = $this->createMock(LobbyMessageRepository::class);
|
$this->lobbyMessageRepository = $this->createMock(LobbyMessageRepository::class);
|
||||||
$this->hub = $this->createMock(HubInterface::class);
|
$this->eventDispatcher = $this->createMock(EventDispatcherInterface::class);
|
||||||
|
|
||||||
$this->service = new GameDashboardService(
|
$this->service = new GameDashboardService(
|
||||||
$this->gameRepository,
|
$this->gameRepository,
|
||||||
$this->sessionRepository,
|
$this->sessionRepository,
|
||||||
$this->lobbyMessageRepository,
|
$this->lobbyMessageRepository,
|
||||||
$this->entityManager,
|
$this->entityManager,
|
||||||
$this->hub,
|
$this->eventDispatcher,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -316,8 +316,9 @@ class GameDashboardServiceTest extends TestCase
|
|||||||
$this->entityManager->expects($this->atLeastOnce())
|
$this->entityManager->expects($this->atLeastOnce())
|
||||||
->method('flush');
|
->method('flush');
|
||||||
|
|
||||||
$this->hub->expects($this->once())
|
$this->eventDispatcher->expects($this->once())
|
||||||
->method('publish');
|
->method('dispatch')
|
||||||
|
->willReturnArgument(0);
|
||||||
|
|
||||||
$this->service->checkAllPlayersReady($session);
|
$this->service->checkAllPlayersReady($session);
|
||||||
|
|
||||||
|
|||||||
@@ -11,42 +11,62 @@ use App\Game\Enum\SessionSettingType;
|
|||||||
use App\Game\Repository\SessionSettingRepository;
|
use App\Game\Repository\SessionSettingRepository;
|
||||||
use App\Game\Service\GameResponseService;
|
use App\Game\Service\GameResponseService;
|
||||||
use App\Game\Service\PlayerService;
|
use App\Game\Service\PlayerService;
|
||||||
|
use App\Game\Service\SessionActivityLogger;
|
||||||
use App\Tech\Entity\User;
|
use App\Tech\Entity\User;
|
||||||
use Doctrine\ORM\EntityManagerInterface;
|
use Doctrine\ORM\EntityManagerInterface;
|
||||||
use PHPUnit\Framework\TestCase;
|
use PHPUnit\Framework\TestCase;
|
||||||
use Symfony\Bundle\SecurityBundle\Security;
|
use Symfony\Bundle\SecurityBundle\Security;
|
||||||
use Symfony\Component\Mercure\HubInterface;
|
use Symfony\Contracts\EventDispatcher\EventDispatcherInterface;
|
||||||
use Symfony\Component\Mercure\Update;
|
|
||||||
|
|
||||||
class GameResponseServiceChatVerifyCodeTest extends TestCase
|
class GameResponseServiceChatVerifyCodeTest extends TestCase
|
||||||
{
|
{
|
||||||
|
private string $tempDir;
|
||||||
private $security;
|
private $security;
|
||||||
private $playerService;
|
private $playerService;
|
||||||
private $sessionSettingRepository;
|
private $sessionSettingRepository;
|
||||||
private $hub;
|
private $eventDispatcher;
|
||||||
private $entityManager;
|
private $entityManager;
|
||||||
private $service;
|
private $service;
|
||||||
|
|
||||||
protected function setUp(): void
|
protected function setUp(): void
|
||||||
{
|
{
|
||||||
|
$this->tempDir = sys_get_temp_dir() . '/escapepage_test_' . uniqid();
|
||||||
|
mkdir($this->tempDir, 0777, true);
|
||||||
|
|
||||||
$this->security = $this->createMock(Security::class);
|
$this->security = $this->createMock(Security::class);
|
||||||
$this->playerService = $this->createMock(PlayerService::class);
|
$this->playerService = $this->createMock(PlayerService::class);
|
||||||
$this->sessionSettingRepository = $this->createMock(SessionSettingRepository::class);
|
$this->sessionSettingRepository = $this->createMock(SessionSettingRepository::class);
|
||||||
$this->hub = $this->createMock(HubInterface::class);
|
$this->eventDispatcher = $this->createMock(EventDispatcherInterface::class);
|
||||||
$this->entityManager = $this->createMock(EntityManagerInterface::class);
|
$this->entityManager = $this->createMock(EntityManagerInterface::class);
|
||||||
|
|
||||||
$this->service = new GameResponseService(
|
$this->service = new GameResponseService(
|
||||||
$this->security,
|
$this->security,
|
||||||
$this->playerService,
|
$this->playerService,
|
||||||
$this->sessionSettingRepository,
|
$this->sessionSettingRepository,
|
||||||
$this->hub,
|
|
||||||
$this->entityManager,
|
$this->entityManager,
|
||||||
'H:\escapepage'
|
$this->eventDispatcher,
|
||||||
|
new SessionActivityLogger($this->tempDir),
|
||||||
|
$this->tempDir
|
||||||
);
|
);
|
||||||
|
|
||||||
$_ENV['MERCURE_TOPIC_BASE'] = 'http://test';
|
$_ENV['MERCURE_TOPIC_BASE'] = 'http://test';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
protected function tearDown(): void
|
||||||
|
{
|
||||||
|
$this->removeDir($this->tempDir);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function removeDir(string $dir): void
|
||||||
|
{
|
||||||
|
if (!is_dir($dir)) return;
|
||||||
|
$files = array_diff(scandir($dir), ['.', '..']);
|
||||||
|
foreach ($files as $file) {
|
||||||
|
(is_dir("$dir/$file")) ? $this->removeDir("$dir/$file") : unlink("$dir/$file");
|
||||||
|
}
|
||||||
|
rmdir($dir);
|
||||||
|
}
|
||||||
|
|
||||||
public function testChatRegeneratesVerifyCodesIfShared(): void
|
public function testChatRegeneratesVerifyCodesIfShared(): void
|
||||||
{
|
{
|
||||||
$user = new User();
|
$user = new User();
|
||||||
@@ -63,6 +83,7 @@ class GameResponseServiceChatVerifyCodeTest extends TestCase
|
|||||||
$player->method('getUser')->willReturn($user);
|
$player->method('getUser')->willReturn($user);
|
||||||
$player->method('getScreen')->willReturn(1);
|
$player->method('getScreen')->willReturn(1);
|
||||||
$player->method('getSession')->willReturn($session);
|
$player->method('getSession')->willReturn($session);
|
||||||
|
$player->method('getLogFileBasename')->willReturn('testuser');
|
||||||
|
|
||||||
$this->security->method('getUser')->willReturn($user);
|
$this->security->method('getUser')->willReturn($user);
|
||||||
$this->playerService->method('GetCurrentlyActiveAsPlayer')->willReturn($player);
|
$this->playerService->method('GetCurrentlyActiveAsPlayer')->willReturn($player);
|
||||||
@@ -88,9 +109,11 @@ class GameResponseServiceChatVerifyCodeTest extends TestCase
|
|||||||
return null;
|
return null;
|
||||||
});
|
});
|
||||||
|
|
||||||
// Expect Mercure updates: 1 for chat, 1 for notification
|
// Expect 2 PushMercureMessageEvents dispatched: 1 for chat, 1 for the
|
||||||
$this->hub->expects($this->exactly(2))
|
// regenerated-code notification
|
||||||
->method('publish');
|
$this->eventDispatcher->expects($this->exactly(2))
|
||||||
|
->method('dispatch')
|
||||||
|
->willReturnArgument(0);
|
||||||
|
|
||||||
$this->entityManager->expects($this->once())
|
$this->entityManager->expects($this->once())
|
||||||
->method('flush');
|
->method('flush');
|
||||||
|
|||||||
@@ -10,11 +10,12 @@ use App\Game\Enum\SessionSettingType;
|
|||||||
use App\Game\Repository\SessionSettingRepository;
|
use App\Game\Repository\SessionSettingRepository;
|
||||||
use App\Game\Service\GameResponseService;
|
use App\Game\Service\GameResponseService;
|
||||||
use App\Game\Service\PlayerService;
|
use App\Game\Service\PlayerService;
|
||||||
|
use App\Game\Service\SessionActivityLogger;
|
||||||
use App\Tech\Entity\User;
|
use App\Tech\Entity\User;
|
||||||
use Doctrine\ORM\EntityManagerInterface;
|
use Doctrine\ORM\EntityManagerInterface;
|
||||||
use PHPUnit\Framework\TestCase;
|
use PHPUnit\Framework\TestCase;
|
||||||
use Symfony\Bundle\SecurityBundle\Security;
|
use Symfony\Bundle\SecurityBundle\Security;
|
||||||
use Symfony\Component\Mercure\HubInterface;
|
use Symfony\Component\EventDispatcher\EventDispatcher;
|
||||||
|
|
||||||
class SessionLoggingTest extends TestCase
|
class SessionLoggingTest extends TestCase
|
||||||
{
|
{
|
||||||
@@ -22,7 +23,6 @@ class SessionLoggingTest extends TestCase
|
|||||||
private $security;
|
private $security;
|
||||||
private $playerService;
|
private $playerService;
|
||||||
private $sessionSettingRepository;
|
private $sessionSettingRepository;
|
||||||
private $hub;
|
|
||||||
private $entityManager;
|
private $entityManager;
|
||||||
private $service;
|
private $service;
|
||||||
|
|
||||||
@@ -34,15 +34,15 @@ class SessionLoggingTest extends TestCase
|
|||||||
$this->security = $this->createMock(Security::class);
|
$this->security = $this->createMock(Security::class);
|
||||||
$this->playerService = $this->createMock(PlayerService::class);
|
$this->playerService = $this->createMock(PlayerService::class);
|
||||||
$this->sessionSettingRepository = $this->createMock(SessionSettingRepository::class);
|
$this->sessionSettingRepository = $this->createMock(SessionSettingRepository::class);
|
||||||
$this->hub = $this->createMock(HubInterface::class);
|
|
||||||
$this->entityManager = $this->createMock(EntityManagerInterface::class);
|
$this->entityManager = $this->createMock(EntityManagerInterface::class);
|
||||||
|
|
||||||
$this->service = new GameResponseService(
|
$this->service = new GameResponseService(
|
||||||
$this->security,
|
$this->security,
|
||||||
$this->playerService,
|
$this->playerService,
|
||||||
$this->sessionSettingRepository,
|
$this->sessionSettingRepository,
|
||||||
$this->hub,
|
|
||||||
$this->entityManager,
|
$this->entityManager,
|
||||||
|
new EventDispatcher(),
|
||||||
|
new SessionActivityLogger($this->tempDir),
|
||||||
$this->tempDir
|
$this->tempDir
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -96,8 +96,15 @@ class SessionLoggingTest extends TestCase
|
|||||||
$logFilePath = $this->tempDir . '/var/log/sessions/456/player1.txt';
|
$logFilePath = $this->tempDir . '/var/log/sessions/456/player1.txt';
|
||||||
$this->assertFileExists($logFilePath);
|
$this->assertFileExists($logFilePath);
|
||||||
|
|
||||||
$logContent = file_get_contents($logFilePath);
|
$lines = array_filter(explode("\n", file_get_contents($logFilePath)));
|
||||||
$this->assertStringContainsString('PLAYER: help', $logContent);
|
$entries = array_map(fn(string $line) => json_decode($line, true), $lines);
|
||||||
$this->assertStringContainsString('SERVER:', $logContent);
|
|
||||||
|
$playerEntry = current(array_filter($entries, fn(array $e) => $e['source'] === 'player'));
|
||||||
|
$this->assertNotFalse($playerEntry);
|
||||||
|
$this->assertSame('help', $playerEntry['content']);
|
||||||
|
|
||||||
|
$serverEntry = current(array_filter($entries, fn(array $e) => $e['source'] === 'server'));
|
||||||
|
$this->assertNotFalse($serverEntry);
|
||||||
|
$this->assertNotEmpty($serverEntry['content']);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user