2026-04-09 20:21:01 +02:00
|
|
|
|
<?php declare(strict_types=1);
|
2019-10-27 18:51:28 +01:00
|
|
|
|
/**
|
|
|
|
|
|
* 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;
|
2026-04-10 12:23:21 +02:00
|
|
|
|
use App\Entity\GridRow;
|
2019-10-27 18:51:28 +01:00
|
|
|
|
use App\Entity\PlayedGame;
|
|
|
|
|
|
use App\Entity\Step;
|
2026-04-09 22:00:53 +02:00
|
|
|
|
use App\Entity\User;
|
2026-04-09 20:21:01 +02:00
|
|
|
|
use App\Interfaces\TopicManagerInterface;
|
2026-04-12 08:01:46 +02:00
|
|
|
|
use App\Repository\PlayedGameRepository;
|
|
|
|
|
|
use App\Repository\UserRepository;
|
2026-04-11 22:20:21 +02:00
|
|
|
|
use DateTimeInterface;
|
2019-10-27 18:51:28 +01:00
|
|
|
|
use DateTime;
|
|
|
|
|
|
use Doctrine\ORM\EntityManagerInterface;
|
|
|
|
|
|
use Exception;
|
2026-04-09 22:00:53 +02:00
|
|
|
|
use JsonException;
|
2019-10-27 18:51:28 +01:00
|
|
|
|
use Psr\Log\LoggerInterface;
|
2026-04-10 12:57:03 +02:00
|
|
|
|
use RuntimeException;
|
2026-04-09 22:00:53 +02:00
|
|
|
|
use Symfony\Component\Mercure\HubInterface;
|
|
|
|
|
|
use Symfony\Component\Mercure\Update;
|
|
|
|
|
|
use Symfony\Component\Security\Core\User\UserInterface;
|
2019-10-27 18:51:28 +01:00
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* Class TopicManager
|
|
|
|
|
|
*
|
2026-04-09 20:21:01 +02:00
|
|
|
|
* @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.
|
2019-10-27 18:51:28 +01:00
|
|
|
|
*/
|
2026-04-12 08:01:46 +02:00
|
|
|
|
readonly class TopicManager implements TopicManagerInterface
|
2019-10-27 18:51:28 +01:00
|
|
|
|
{
|
|
|
|
|
|
public function __construct(
|
2026-04-12 08:01:46 +02:00
|
|
|
|
private HubInterface $hub,
|
|
|
|
|
|
private EntityManagerInterface $entityManager,
|
|
|
|
|
|
private LoggerInterface $logger,
|
|
|
|
|
|
private PlayedGameRepository $playedGameRepository,
|
|
|
|
|
|
private UserRepository $userRepository,
|
2026-04-09 22:00:53 +02:00
|
|
|
|
) {
|
2019-10-27 18:51:28 +01:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-09 22:00:53 +02:00
|
|
|
|
public function subscribe(string $gameAssoc, string $userName, ?UserInterface $user): void
|
2019-10-27 18:51:28 +01:00
|
|
|
|
{
|
2026-04-09 22:00:53 +02:00
|
|
|
|
$playedGame = $this->getPlayedGame($gameAssoc);
|
|
|
|
|
|
if (null === $playedGame) {
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-10 12:57:03 +02:00
|
|
|
|
$users = $this->getUserCollection($playedGame);
|
|
|
|
|
|
$count = $this->getPlayerCount($users);
|
2026-04-09 22:00:53 +02:00
|
|
|
|
$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());
|
2019-10-27 18:51:28 +01:00
|
|
|
|
}
|
2026-04-11 22:20:21 +02:00
|
|
|
|
|
|
|
|
|
|
// ── 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]);
|
|
|
|
|
|
}
|
2019-10-27 18:51:28 +01:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-09 22:00:53 +02:00
|
|
|
|
public function unSubscribe(string $gameAssoc, string $userName): void
|
2019-10-27 18:51:28 +01:00
|
|
|
|
{
|
2026-04-11 22:20:21 +02:00
|
|
|
|
// 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]);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-09 22:00:53 +02:00
|
|
|
|
$topic = 'mineseeker/channel/' . $gameAssoc;
|
|
|
|
|
|
|
|
|
|
|
|
$this->hub->publish(new Update(
|
|
|
|
|
|
$topic,
|
|
|
|
|
|
json_encode(['msg' => $userName . ' has left ' . $topic])
|
|
|
|
|
|
));
|
2019-10-27 18:51:28 +01:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-10 12:23:21 +02:00
|
|
|
|
/**
|
|
|
|
|
|
* 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
|
2019-10-27 18:51:28 +01:00
|
|
|
|
{
|
2026-04-10 12:23:21 +02:00
|
|
|
|
if (null !== $event['resign']) {
|
|
|
|
|
|
$this->saveResignToDb($gameAssoc, $event['resign']);
|
|
|
|
|
|
|
|
|
|
|
|
$playedGame = $this->getPlayedGame($gameAssoc);
|
2026-04-10 12:57:03 +02:00
|
|
|
|
$users = $this->getUserCollection($playedGame);
|
|
|
|
|
|
$count = $this->getPlayerCount($users);
|
|
|
|
|
|
$topic = 'mineseeker/channel/' . $gameAssoc;
|
2026-04-10 12:23:21 +02:00
|
|
|
|
|
|
|
|
|
|
$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'
|
2026-04-10 12:57:03 +02:00
|
|
|
|
$isBomb = (bool)$event['bomb'];
|
2026-04-09 22:00:53 +02:00
|
|
|
|
|
|
|
|
|
|
$playedGame = $this->getPlayedGame($gameAssoc);
|
2026-04-10 12:57:03 +02:00
|
|
|
|
$grid = $this->loadGrid($gameAssoc);
|
2026-04-10 12:23:21 +02:00
|
|
|
|
|
|
|
|
|
|
// 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);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-10 12:57:03 +02:00
|
|
|
|
$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;
|
2026-04-10 12:23:21 +02:00
|
|
|
|
|
|
|
|
|
|
// 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);
|
|
|
|
|
|
|
2026-04-09 22:00:53 +02:00
|
|
|
|
$users = $this->getUserCollection($playedGame);
|
|
|
|
|
|
$count = $this->getPlayerCount($users);
|
|
|
|
|
|
$topic = 'mineseeker/channel/' . $gameAssoc;
|
|
|
|
|
|
|
2026-04-10 12:23:21 +02:00
|
|
|
|
$data = [
|
|
|
|
|
|
'coords' => $coords,
|
|
|
|
|
|
'player' => $player,
|
|
|
|
|
|
'bomb' => $isBomb,
|
|
|
|
|
|
'revealedCells' => $revealedCells,
|
|
|
|
|
|
'minesFound' => $minesFound,
|
|
|
|
|
|
'redPoints' => $redPoints,
|
|
|
|
|
|
'bluePoints' => $bluePoints,
|
|
|
|
|
|
'resign' => null,
|
|
|
|
|
|
'gameOver' => $gameOver,
|
|
|
|
|
|
'leftMines' => $leftMines,
|
|
|
|
|
|
];
|
|
|
|
|
|
|
2026-04-09 22:00:53 +02:00
|
|
|
|
try {
|
|
|
|
|
|
$this->hub->publish(new Update(
|
|
|
|
|
|
$topic,
|
|
|
|
|
|
json_encode([
|
|
|
|
|
|
'userTopicId' => $userName,
|
|
|
|
|
|
'channel' => $topic,
|
|
|
|
|
|
'user' => $userName,
|
|
|
|
|
|
'userCnt' => $count,
|
2026-04-10 12:23:21 +02:00
|
|
|
|
'data' => $data,
|
2026-04-09 22:00:53 +02:00
|
|
|
|
], JSON_THROW_ON_ERROR)
|
|
|
|
|
|
));
|
|
|
|
|
|
} catch (JsonException $e) {
|
|
|
|
|
|
throw new RuntimeException($e->getMessage());
|
|
|
|
|
|
}
|
2026-04-10 12:23:21 +02:00
|
|
|
|
|
|
|
|
|
|
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.
|
|
|
|
|
|
*
|
2026-04-10 12:57:03 +02:00
|
|
|
|
* @param array<string, true> $visited Map of "row,col" already revealed; updated in-place.
|
2026-04-10 12:23:21 +02:00
|
|
|
|
*/
|
|
|
|
|
|
private function floodFill(array $grid, int $row, int $col, array &$visited): array
|
|
|
|
|
|
{
|
|
|
|
|
|
$cells = [];
|
|
|
|
|
|
$queue = [[$row, $col]];
|
2026-04-10 12:57:03 +02:00
|
|
|
|
$dirs = [[-1, -1], [-1, 0], [-1, 1], [0, -1], [0, 1], [1, -1], [1, 0], [1, 1]];
|
2026-04-10 12:23:21 +02:00
|
|
|
|
|
|
|
|
|
|
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]) {
|
2026-04-10 12:57:03 +02:00
|
|
|
|
$nr = $r + $dr;
|
|
|
|
|
|
$nc = $c + $dc;
|
2026-04-10 12:23:21 +02:00
|
|
|
|
$nKey = $nr . ',' . $nc;
|
|
|
|
|
|
if (!isset($visited[$nKey]) && isset($grid[$nr][$nc])) {
|
|
|
|
|
|
$queue[] = [$nr, $nc];
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return $cells;
|
2019-10-27 18:51:28 +01:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-10 12:23:21 +02:00
|
|
|
|
/**
|
|
|
|
|
|
* 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 [
|
2026-04-10 12:57:03 +02:00
|
|
|
|
[$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],
|
2026-04-10 12:23:21 +02:00
|
|
|
|
[$row + 2, $col + 2], [$row - 2, $col + 1], [$row - 2, $col - 1],
|
2026-04-10 12:57:03 +02:00
|
|
|
|
[$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],
|
2026-04-10 12:23:21 +02:00
|
|
|
|
[$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);
|
2026-04-10 12:57:03 +02:00
|
|
|
|
$visited = $alreadyRevealed;
|
|
|
|
|
|
$cells = [];
|
2026-04-10 12:23:21 +02:00
|
|
|
|
|
|
|
|
|
|
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;
|
2026-04-10 12:57:03 +02:00
|
|
|
|
$cells[] = ['row' => $r, 'col' => $c, 'value' => 'm'];
|
2026-04-10 12:23:21 +02:00
|
|
|
|
} else {
|
|
|
|
|
|
// flood-fill handles the zero-cascade and deduplication via $visited
|
|
|
|
|
|
$newCells = $this->floodFill($grid, $r, $c, $visited);
|
2026-04-10 12:57:03 +02:00
|
|
|
|
$cells = array_merge($cells, $newCells);
|
2026-04-10 12:23:21 +02:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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 = [];
|
2026-04-10 17:57:26 +02:00
|
|
|
|
|
2026-04-10 12:23:21 +02:00
|
|
|
|
foreach ($grid as $r => $row) {
|
|
|
|
|
|
foreach ($row as $c => $value) {
|
2026-04-10 17:57:26 +02:00
|
|
|
|
if ('m' === $value && !isset($alreadyRevealed["$r,$c"])) {
|
2026-04-10 12:23:21 +02:00
|
|
|
|
$mines[] = ['row' => $r, 'col' => $c];
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return $mines;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ------------------------------------------------------------------ //
|
|
|
|
|
|
// Database helpers
|
|
|
|
|
|
// ------------------------------------------------------------------ //
|
|
|
|
|
|
|
2026-04-09 22:00:53 +02:00
|
|
|
|
private function getPlayedGame(string $gameAssoc): ?PlayedGame
|
2019-10-27 18:51:28 +01:00
|
|
|
|
{
|
2026-04-12 08:01:46 +02:00
|
|
|
|
return $this->playedGameRepository->findOneByGameAssoc($gameAssoc);
|
2026-04-09 22:00:53 +02:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
private function getPlayerCount(array $users): int
|
|
|
|
|
|
{
|
2026-04-10 12:57:03 +02:00
|
|
|
|
$red = '' !== $users['red'] || '' !== $users['redAnon'] ? 1 : 0;
|
2026-04-09 22:00:53 +02:00
|
|
|
|
$blue = '' !== $users['blue'] || '' !== $users['blueAnon'] ? 1 : 0;
|
|
|
|
|
|
|
|
|
|
|
|
return $red + $blue;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
private function saveResignToDb(string $gameAssoc, string $color): void
|
|
|
|
|
|
{
|
|
|
|
|
|
$playedGame = $this->getPlayedGame($gameAssoc);
|
2019-10-27 18:51:28 +01:00
|
|
|
|
$playedGame->setResign($color);
|
|
|
|
|
|
$this->entityManager->persist($playedGame);
|
|
|
|
|
|
$this->entityManager->flush();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-10 12:23:21 +02:00
|
|
|
|
private function saveStepToDb(
|
|
|
|
|
|
string $gameAssoc,
|
|
|
|
|
|
array $event,
|
|
|
|
|
|
string $player,
|
|
|
|
|
|
array $revealedCells,
|
|
|
|
|
|
int $redPoints,
|
|
|
|
|
|
int $bluePoints,
|
|
|
|
|
|
): void {
|
2019-10-27 18:51:28 +01:00
|
|
|
|
try {
|
2026-04-09 22:00:53 +02:00
|
|
|
|
$playedGame = $this->getPlayedGame($gameAssoc);
|
2019-10-27 18:51:28 +01:00
|
|
|
|
|
|
|
|
|
|
$step = new Step();
|
|
|
|
|
|
$step->setRow($event['coords'][0]);
|
|
|
|
|
|
$step->setCol($event['coords'][1]);
|
2026-04-10 12:57:03 +02:00
|
|
|
|
$step->setWBomb((bool)$event['bomb']);
|
2026-04-10 12:23:21 +02:00
|
|
|
|
$step->setPlayer($player);
|
|
|
|
|
|
$step->setRevealedCells($revealedCells);
|
2019-10-27 18:51:28 +01:00
|
|
|
|
$step->setPlayedGame($playedGame);
|
|
|
|
|
|
$step->setCreated(new DateTime());
|
|
|
|
|
|
$this->entityManager->persist($step);
|
|
|
|
|
|
|
2026-04-10 12:23:21 +02:00
|
|
|
|
$playedGame->setRedPoints($redPoints);
|
|
|
|
|
|
$playedGame->setBluePoints($bluePoints);
|
2026-04-10 12:57:03 +02:00
|
|
|
|
$playedGame->setRedExplodedBomb((bool)$event['bomb'] && 'red' === $player ? true : null);
|
|
|
|
|
|
$playedGame->setBlueExplodedBomb((bool)$event['bomb'] && 'blue' === $player ? true : null);
|
2019-10-27 18:51:28 +01:00
|
|
|
|
$playedGame->setUpdated(new DateTime());
|
|
|
|
|
|
$this->entityManager->persist($playedGame);
|
|
|
|
|
|
|
|
|
|
|
|
$this->entityManager->flush();
|
|
|
|
|
|
} catch (Exception $e) {
|
|
|
|
|
|
$this->logger->error($e->getMessage());
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-09 22:00:53 +02:00
|
|
|
|
private function saveUserToDb(string $gameAssoc, string $userName, ?UserInterface $user, int $count): array
|
2019-10-27 18:51:28 +01:00
|
|
|
|
{
|
2026-04-09 22:00:53 +02:00
|
|
|
|
$playedGame = $this->getPlayedGame($gameAssoc);
|
2019-10-27 18:51:28 +01:00
|
|
|
|
|
2026-04-09 22:00:53 +02:00
|
|
|
|
null !== $user
|
2019-10-27 18:51:28 +01:00
|
|
|
|
? $this->saveRegisteredUser($userName, $count, $playedGame)
|
|
|
|
|
|
: $this->saveAnonUser($userName, $count, $playedGame);
|
|
|
|
|
|
|
|
|
|
|
|
$this->entityManager->persist($playedGame);
|
|
|
|
|
|
$this->entityManager->flush();
|
|
|
|
|
|
|
|
|
|
|
|
return $this->getUserCollection($playedGame);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-09 22:00:53 +02:00
|
|
|
|
private function saveRegisteredUser(string $userName, int $count, PlayedGame $playedGame): void
|
2019-10-27 18:51:28 +01:00
|
|
|
|
{
|
2026-04-09 22:00:53 +02:00
|
|
|
|
/** @var User $user */
|
2026-04-12 08:01:46 +02:00
|
|
|
|
$user = $this->userRepository->findOneByUsername($userName);
|
2019-10-27 18:51:28 +01:00
|
|
|
|
|
|
|
|
|
|
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());
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-09 22:00:53 +02:00
|
|
|
|
private function saveAnonUser(string $userName, int $count, PlayedGame $playedGame): void
|
2019-10-27 18:51:28 +01:00
|
|
|
|
{
|
|
|
|
|
|
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
|
|
|
|
|
|
{
|
2026-04-09 20:21:01 +02:00
|
|
|
|
return [
|
2026-04-10 12:57:03 +02:00
|
|
|
|
'red' => null !== $playedGame->getRed() ? $playedGame->getRed()->getUsername() : '',
|
|
|
|
|
|
'blue' => null !== $playedGame->getBlue() ? $playedGame->getBlue()->getUsername() : '',
|
|
|
|
|
|
'redAnon' => null !== $playedGame->getRedAnon() ? $playedGame->getRedAnon()->getUserName() : '',
|
2026-04-09 22:00:53 +02:00
|
|
|
|
'blueAnon' => null !== $playedGame->getBlueAnon() ? $playedGame->getBlueAnon()->getUserName() : '',
|
2026-04-09 20:21:01 +02:00
|
|
|
|
];
|
2019-10-27 18:51:28 +01:00
|
|
|
|
}
|
2026-04-11 22:20:21 +02:00
|
|
|
|
|
2026-04-12 08:01:46 +02:00
|
|
|
|
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());
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-11 22:20:21 +02:00
|
|
|
|
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());
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-04-10 12:57:03 +02:00
|
|
|
|
}
|