* @category Class * @license https://www.gnu.org/licenses/lgpl-3.0.en.html GNU Lesser General Public License * @link www.splendidbear.org * @since 2026. 04. 09. */ readonly class TopicManager implements TopicManagerInterface { public function __construct( private HubInterface $hub, private EntityManagerInterface $entityManager, private LoggerInterface $logger, private PlayedGameRepository $playedGameRepository, private UserRepository $userRepository, private CacheManager $cacheManager, ) { } public function subscribe(string $gameAssoc, string $userName, ?UserInterface $user): void { $playedGame = $this->getPlayedGame($gameAssoc); if (null === $playedGame) { return; } $users = $this->getUserCollection($playedGame); $count = $this->getPlayerCount($users); $isKnown = in_array($userName, array_filter(array_values($users)), true); /** Reject a third player who is not a reconnecting player */ if ($count >= 2 && !$isKnown) { return; } /** Save the player to the database on a fresh join */ if (!$isKnown && $count < 2) { $users = $this->saveUserToDb($gameAssoc, $userName, $user, $count + 1); $count = $this->getPlayerCount($users); } $topic = 'mineseeker/channel/' . $gameAssoc; try { $this->hub->publish(new Update( $topic, json_encode([ 'userTopicId' => $userName, 'channel' => $topic, 'user' => $userName, 'userCnt' => $count, 'users' => $users, ], JSON_THROW_ON_ERROR) )); } catch (JsonException $e) { throw new RuntimeException($e->getMessage()); } // ── Lobby updates ────────────────────────────────────────────────── if ($count === 1) { // One player waiting — mark as active and announce to the lobby $playedGame->setUpdated(new DateTime()); $this->entityManager->persist($playedGame); $this->entityManager->flush(); $displayName = $users['red'] ?: $users['redAnon'] ?: $users['blue'] ?: $users['blueAnon'] ?: 'Unknown'; $this->publishToLobby([ 'action' => 'join', 'gameAssoc' => $gameAssoc, 'name' => $displayName, 'since' => $playedGame->getCreated()?->format(DateTimeInterface::ATOM) ?? '', ]); } elseif ($count === 2) { // Both players joined — remove from lobby $this->publishToLobby(['action' => 'leave', 'gameAssoc' => $gameAssoc]); } } public function unSubscribe(string $gameAssoc, string $userName): void { // If the game was still waiting for a second player, stamp it as abandoned // so it no longer appears in the waiting-games query, and remove from lobby. $playedGame = $this->getPlayedGame($gameAssoc); if (null !== $playedGame) { $users = $this->getUserCollection($playedGame); if ($this->getPlayerCount($users) === 1) { $playedGame->setUpdated(new DateTime('2000-01-01 00:00:00')); $this->entityManager->persist($playedGame); $this->entityManager->flush(); $this->publishToLobby(['action' => 'leave', 'gameAssoc' => $gameAssoc]); } } $topic = 'mineseeker/channel/' . $gameAssoc; $this->hub->publish(new Update( $topic, json_encode(['msg' => $userName . ' has left ' . $topic]) )); } /** * Resolve the revealed cells for a step, persist it, and broadcast via Mercure. * Returns the step result data (same shape as what is broadcast in `data`). */ public function publish(string $gameAssoc, string $userName, array $event): array { if (null !== $event['resign']) { $this->saveResignToDb($gameAssoc, $event['resign']); $playedGame = $this->getPlayedGame($gameAssoc); $users = $this->getUserCollection($playedGame); $count = $this->getPlayerCount($users); $topic = 'mineseeker/channel/' . $gameAssoc; $data = ['resign' => $event['resign'], 'coords' => null]; try { $this->hub->publish(new Update( $topic, json_encode([ 'userTopicId' => $userName, 'channel' => $topic, 'user' => $userName, 'userCnt' => $count, 'data' => $data, ], JSON_THROW_ON_ERROR) )); } catch (JsonException $e) { throw new RuntimeException($e->getMessage()); } return $data; } // ------------------------------------------------------------------ // // Normal move // ------------------------------------------------------------------ // $coords = $event['coords']; $player = $event['player']; // 'red' | 'blue' $isBomb = (bool)$event['bomb']; $playedGame = $this->getPlayedGame($gameAssoc); $grid = $this->loadGrid($gameAssoc); // Cells already revealed by previous steps (as "row,col" => true map) $alreadyRevealed = $this->buildRevealedMap($playedGame); // Determine which cells to reveal for this step if ($isBomb) { $revealedCells = $this->getBombRevealedCells($grid, $coords[0], $coords[1], $alreadyRevealed); } elseif ('m' === ($grid[$coords[0]][$coords[1]] ?? null)) { // Direct click on a mine — reveal it immediately (flood-fill skips mines) $revealedCells = [['row' => $coords[0], 'col' => $coords[1], 'value' => 'm']]; } else { $revealedCells = $this->floodFill($grid, $coords[0], $coords[1], $alreadyRevealed); } $minesFound = count(array_filter($revealedCells, static fn($c) => 'm' === $c['value'])); $redPoints = ($playedGame->getRedPoints() ?? 0) + ('red' === $player ? $minesFound : 0); $bluePoints = ($playedGame->getBluePoints() ?? 0) + ('blue' === $player ? $minesFound : 0); $gameOver = $redPoints > 25 || $bluePoints > 25; // Reveal remaining mines when the game ends $leftMines = []; if ($gameOver) { $finalRevealed = $alreadyRevealed; foreach ($revealedCells as $c) { $finalRevealed[$c['row'] . ',' . $c['col']] = true; } $leftMines = $this->getLeftMines($grid, $finalRevealed); } $this->saveStepToDb($gameAssoc, $event, $player, $revealedCells, $redPoints, $bluePoints); $users = $this->getUserCollection($playedGame); $count = $this->getPlayerCount($users); $topic = 'mineseeker/channel/' . $gameAssoc; $data = [ 'coords' => $coords, 'player' => $player, 'bomb' => $isBomb, 'revealedCells' => $revealedCells, 'minesFound' => $minesFound, 'redPoints' => $redPoints, 'bluePoints' => $bluePoints, 'resign' => null, 'gameOver' => $gameOver, 'leftMines' => $leftMines, ]; try { $this->hub->publish(new Update( $topic, json_encode([ 'userTopicId' => $userName, 'channel' => $topic, 'user' => $userName, 'userCnt' => $count, 'data' => $data, ], JSON_THROW_ON_ERROR) )); } catch (JsonException $e) { throw new RuntimeException($e->getMessage()); } return $data; } // ------------------------------------------------------------------ // // Grid helpers // ------------------------------------------------------------------ // /** Load the grid rows from the database as a 2-D array. */ private function loadGrid(string $gameAssoc): array { $playedGame = $this->getPlayedGame($gameAssoc); $gridEntity = $playedGame?->getGrid(); if (null === $gridEntity) { return []; } $grid = []; /** @var GridRow $row */ foreach ($gridEntity->getGridRow() as $row) { $grid[] = $row->getGridCol(); } return $grid; } /** * BFS flood-fill starting at (row, col). * Reveals the clicked cell plus all connected zero-value cells and their non-mine borders. * Mines are never added to the result. * * @param array $visited Map of "row,col" already revealed; updated in-place. */ private function floodFill(array $grid, int $row, int $col, array &$visited): array { $cells = []; $queue = [[$row, $col]]; $dirs = [[-1, -1], [-1, 0], [-1, 1], [0, -1], [0, 1], [1, -1], [1, 0], [1, 1]]; while (!empty($queue)) { [$r, $c] = array_shift($queue); $key = $r . ',' . $c; if (isset($visited[$key])) { continue; } $visited[$key] = true; if (!isset($grid[$r][$c])) { continue; } $value = $grid[$r][$c]; // Mines are never cascade-revealed if ('m' === $value) { continue; } $cells[] = ['row' => $r, 'col' => $c, 'value' => $value]; // Only expand neighbours for zero-cells if (0 === $value) { foreach ($dirs as [$dr, $dc]) { $nr = $r + $dr; $nc = $c + $dc; $nKey = $nr . ',' . $nc; if (!isset($visited[$nKey]) && isset($grid[$nr][$nc])) { $queue[] = [$nr, $nc]; } } } } return $cells; } /** * Compute the 25-cell bomb radius (mirrors the JS getBombRadius logic). */ private function getBombRadius(int $row, int $col): array { $max = 13; // grid is 16×16 (0–15); clamped centre must be in 2–13 if (!($row > 1 && $row < 14 && $col > 1 && $col < 14)) { $row = max(2, min($row, $max)); $col = max(2, min($col, $max)); } return [ [$row, $col], [$row - 2, $col - 2], [$row - 2, $col], [$row - 2, $col + 2], [$row, $col - 2], [$row, $col + 2], [$row + 2, $col - 2], [$row + 2, $col], [$row + 2, $col + 2], [$row - 2, $col + 1], [$row - 2, $col - 1], [$row - 1, $col - 2], [$row - 1, $col - 1], [$row - 1, $col], [$row - 1, $col + 1], [$row - 1, $col + 2], [$row, $col - 1], [$row, $col + 1], [$row + 1, $col - 2], [$row + 1, $col - 1], [$row + 1, $col], [$row + 1, $col + 1], [$row + 1, $col + 2], [$row + 2, $col - 1], [$row + 2, $col + 1], ]; } /** * Reveal cells hit by a bomb. Direct mine hits are revealed (flagged); * non-mine cells trigger a normal flood-fill (so zero-cells still cascade). */ private function getBombRevealedCells(array $grid, int $row, int $col, array $alreadyRevealed): array { $bombCells = $this->getBombRadius($row, $col); $visited = $alreadyRevealed; $cells = []; foreach ($bombCells as [$r, $c]) { $key = $r . ',' . $c; if (isset($visited[$key]) || !isset($grid[$r][$c])) { continue; } if ('m' === $grid[$r][$c]) { $visited[$key] = true; $cells[] = ['row' => $r, 'col' => $c, 'value' => 'm']; } else { // flood-fill handles the zero-cascade and deduplication via $visited $newCells = $this->floodFill($grid, $r, $c, $visited); $cells = array_merge($cells, $newCells); } } return $cells; } /** * Build a "row,col" => true map from every previously saved step. */ private function buildRevealedMap(PlayedGame $playedGame): array { $map = []; foreach ($playedGame->getSteps() as $step) { foreach ($step->getRevealedCells() ?? [] as $cell) { $map[$cell['row'] . ',' . $cell['col']] = true; } } return $map; } /** * Return coordinates of mines that have NOT yet been revealed. * * @param array $alreadyRevealed */ private function getLeftMines(array $grid, array $alreadyRevealed): array { $mines = []; foreach ($grid as $r => $row) { foreach ($row as $c => $value) { if ('m' === $value && !isset($alreadyRevealed["$r,$c"])) { $mines[] = ['row' => $r, 'col' => $c]; } } } return $mines; } // ------------------------------------------------------------------ // // Database helpers // ------------------------------------------------------------------ // private function getPlayedGame(string $gameAssoc): ?PlayedGame { return $this->playedGameRepository->findOneByGameAssoc($gameAssoc); } private function getPlayerCount(array $users): int { $red = '' !== $users['red'] || '' !== $users['redAnon'] ? 1 : 0; $blue = '' !== $users['blue'] || '' !== $users['blueAnon'] ? 1 : 0; return $red + $blue; } private function saveResignToDb(string $gameAssoc, string $color): void { $playedGame = $this->getPlayedGame($gameAssoc); $playedGame->setResign($color); $this->entityManager->persist($playedGame); $this->entityManager->flush(); } private function saveStepToDb( string $gameAssoc, array $event, string $player, array $revealedCells, int $redPoints, int $bluePoints, ): void { try { $playedGame = $this->getPlayedGame($gameAssoc); $step = new Step(); $step->setRow($event['coords'][0]); $step->setCol($event['coords'][1]); $step->setWBomb((bool)$event['bomb']); $step->setPlayer($player); $step->setRevealedCells($revealedCells); $step->setPlayedGame($playedGame); $step->setCreated(new DateTime()); $this->entityManager->persist($step); $playedGame->setRedPoints($redPoints); $playedGame->setBluePoints($bluePoints); $playedGame->setRedExplodedBomb((bool)$event['bomb'] && 'red' === $player ? true : null); $playedGame->setBlueExplodedBomb((bool)$event['bomb'] && 'blue' === $player ? true : null); $playedGame->setUpdated(new DateTime()); $this->entityManager->persist($playedGame); $this->entityManager->flush(); } catch (Exception $e) { $this->logger->error($e->getMessage()); } } private function saveUserToDb(string $gameAssoc, string $userName, ?UserInterface $user, int $count): array { $playedGame = $this->getPlayedGame($gameAssoc); null !== $user ? $this->saveRegisteredUser($userName, $count, $playedGame) : $this->saveAnonUser($userName, $count, $playedGame); $this->entityManager->persist($playedGame); $this->entityManager->flush(); return $this->getUserCollection($playedGame); } private function saveRegisteredUser(string $userName, int $count, PlayedGame $playedGame): void { /** @var User $user */ $user = $this->userRepository->findOneByUsername($userName); try { if ($count === 1) { $random = random_int(0, 1); !$random ? $playedGame->setRed($user) : $playedGame->setBlue($user); } else { null === $playedGame->getRed() && null === $playedGame->getRedAnon() ? $playedGame->setRed($user) : $playedGame->setBlue($user); } } catch (Exception $e) { $this->logger->error($e->getMessage()); } } private function saveAnonUser(string $userName, int $count, PlayedGame $playedGame): void { try { $anon = new Gamer(); $anon->setUsername($userName); $anon->setConnTimestamp(new DateTime()); $this->entityManager->persist($anon); if ($count === 1) { $random = random_int(0, 1); !$random ? $playedGame->setRedAnon($anon) : $playedGame->setBlueAnon($anon); } else { null === $playedGame->getRed() && null === $playedGame->getRedAnon() ? $playedGame->setRedAnon($anon) : $playedGame->setBlueAnon($anon); } } catch (Exception $e) { $this->logger->error($e->getMessage()); } } private function getUserCollection(PlayedGame $playedGame): array { $redUser = $playedGame->getRed(); $blueUser = $playedGame->getBlue(); return [ 'red' => null !== $redUser ? $redUser->getUsername() : '', 'blue' => null !== $blueUser ? $blueUser->getUsername() : '', 'redAnon' => null !== $playedGame->getRedAnon() ? $playedGame->getRedAnon()->getUserName() : '', 'blueAnon' => null !== $playedGame->getBlueAnon() ? $playedGame->getBlueAnon()->getUserName() : '', 'redAvatar' => null !== $redUser && null !== $redUser->getAvatarPath() ? $this->cacheManager->generateUrl($redUser->getAvatarPath(), 'avatar_thumb') : null, 'blueAvatar' => null !== $blueUser && null !== $blueUser->getAvatarPath() ? $this->cacheManager->generateUrl($blueUser->getAvatarPath(), 'avatar_thumb') : null, ]; } public function publishChallenge(string $targetGameAssoc, string $challengerGameAssoc): void { $challengerGame = $this->getPlayedGame($challengerGameAssoc); $challengerName = 'Unknown'; if (null !== $challengerGame) { $users = $this->getUserCollection($challengerGame); $challengerName = $users['red'] ?: $users['redAnon'] ?: $users['blue'] ?: $users['blueAnon'] ?: 'Unknown'; } try { $this->hub->publish(new Update( 'mineseeker/channel/' . $targetGameAssoc, json_encode([ 'type' => 'challenge', 'challengerName' => $challengerName, 'challengerGameAssoc' => $challengerGameAssoc, ], JSON_THROW_ON_ERROR) )); } catch (JsonException $e) { $this->logger->error('Challenge publish error: ' . $e->getMessage()); } } public function publishChallengeResponse(string $challengerGameAssoc, bool $accepted, string $targetGameAssoc): void { try { $this->hub->publish(new Update( 'mineseeker/channel/' . $challengerGameAssoc, json_encode([ 'type' => 'challenge-response', 'accepted' => $accepted, 'targetGameAssoc' => $targetGameAssoc, ], JSON_THROW_ON_ERROR) )); } catch (JsonException $e) { $this->logger->error('Challenge response publish error: ' . $e->getMessage()); } } private function publishToLobby(array $data): void { try { $this->hub->publish(new Update( 'mineseeker/lobby', json_encode($data, JSON_THROW_ON_ERROR) )); } catch (JsonException $e) { $this->logger->error('Lobby publish error: ' . $e->getMessage()); } } }