Private
Public Access
1
0
Files
MineSeeker/src/Util/TopicManager.php

589 lines
21 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php declare(strict_types=1);
/**
* This file is part of the SplendidBear Websites' projects.
*
* Copyright (c) 2019 @ www.splendidbear.org
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Util;
use App\Entity\Gamer;
use App\Entity\GridRow;
use App\Entity\PlayedGame;
use App\Entity\Step;
use App\Entity\User;
use App\Interfaces\TopicManagerInterface;
use App\Repository\PlayedGameRepository;
use App\Repository\UserRepository;
use Liip\ImagineBundle\Imagine\Cache\CacheManager;
use DateTimeInterface;
use DateTime;
use Doctrine\ORM\EntityManagerInterface;
use Exception;
use JsonException;
use Psr\Log\LoggerInterface;
use RuntimeException;
use Symfony\Component\Mercure\HubInterface;
use Symfony\Component\Mercure\Update;
use Symfony\Component\Security\Core\User\UserInterface;
/**
* Class TopicManager
*
* @package App\Util
* @author Lang <https://www.splendidbear.org>
* @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<string, true> $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 (015); clamped centre must be in 213
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<string, true> $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());
}
}
}