Private
Public Access
1
0

chg: dev: outsource the Grid generation and interactions to the backend #4

This commit is contained in:
2026-04-10 12:23:21 +02:00
parent 76143fca3e
commit fe2de91e91
9 changed files with 586 additions and 554 deletions

View File

@@ -33,6 +33,10 @@ use RuntimeException;
*/
class RpcManager implements RpcManagerInterface
{
private const ROWS = 16;
private const COLS = 16;
private const MINES = 51;
public function __construct(
private readonly EntityManagerInterface $entityManager,
private readonly LoggerInterface $logger,
@@ -42,54 +46,61 @@ class RpcManager implements RpcManagerInterface
public function getConnectInformation($params): string
{
$gameAssoc = is_array($params) ? $params[0] : $params;
$grid = $this->getGrid($gameAssoc);
$users = null !== $grid ? $this->getUsers($gameAssoc) : null;
$playedGame = $this->entityManager
->getRepository(PlayedGame::class)
->findOneByGameAssoc($gameAssoc);
if (null === $playedGame) {
try {
return base64_encode(json_encode([
'users' => null,
'revealedCells' => null,
], JSON_THROW_ON_ERROR));
} catch (JsonException $e) {
throw new RuntimeException($e->getMessage());
}
}
$users = $this->getUserCollection($playedGame);
$revealedCells = $this->aggregateRevealedCells($playedGame);
try {
return base64_encode(json_encode([
'grid' => $grid,
'users' => $users,
], JSON_THROW_ON_ERROR, 512));
'users' => $users,
'revealedCells' => $revealedCells,
], JSON_THROW_ON_ERROR));
} catch (JsonException $e) {
throw new RuntimeException($e->getMessage());
}
}
public function saveGrid($data): bool
public function saveGrid(string $gameAssoc): bool
{
$existingGame = $this->entityManager
->getRepository(PlayedGame::class)
->findOneByGameAssoc($data[1]);
->findOneByGameAssoc($gameAssoc);
if (null !== $existingGame) {
return true;
}
$grid2d = $this->generateGrid();
$playedGame = new PlayedGame();
$grid = new Grid();
try {
$rows = json_decode(base64_decode($data[0]), true, 512, JSON_THROW_ON_ERROR);
} catch (JsonException $e) {
throw new RuntimeException($e->getMessage());
}
$grid = new Grid();
try {
foreach ($rows as $row) {
foreach ($grid2d as $row) {
$gridRow = new GridRow();
$gridRow->setGridCol($row);
/** Save Row */
$gridRow->setGrid($grid);
$this->entityManager->persist($gridRow);
}
/** Save Grid */
$grid->setPlayedGame($playedGame);
$this->entityManager->persist($grid);
/** Save PlayedGame */
$playedGame->setGameAssoc($data[1]);
$playedGame->setGameAssoc($gameAssoc);
$playedGame->setGrid($grid);
$playedGame->setCreated(new DateTime());
$playedGame->setUpdated(new DateTime());
@@ -104,77 +115,75 @@ class RpcManager implements RpcManagerInterface
}
/**
* It gets the current Grid by PlayedGame/gameAssoc
*
* @param $gameAssoc
*
* @return array
* Generate a random 16×16 grid with 51 mines and adjacent-mine numbers.
*/
private function getGrid($gameAssoc): ?array
private function generateGrid(): array
{
$gridCols = array();
// Build flat set: 51 mines ('m') + remaining water ('w')
$set = array_merge(
array_fill(0, self::MINES, 'm'),
array_fill(0, self::ROWS * self::COLS - self::MINES, 'w'),
);
try {
$this->entityManager->clear();
/** @var PlayedGame $playedGame */
$playedGame = $this->entityManager
->getRepository(PlayedGame::class)
->findOneByGameAssoc($gameAssoc);
if (null === $playedGame) {
return null;
}
if (null === $rows = $playedGame->getGrid()) {
return null;
}
$rows = $rows->getGridRow();
/** @var GridRow $row */
foreach ($rows as $row) {
$gridCols[] = $row->getGridCol();
}
return $gridCols;
} catch (Exception $e) {
$this->logger->error($e->getMessage());
// Fisher-Yates shuffle
for ($i = count($set) - 1; $i > 0; $i--) {
$j = random_int(0, $i);
[$set[$i], $set[$j]] = [$set[$j], $set[$i]];
}
return null;
// Reshape to 2-D
$grid = [];
for ($r = 0; $r < self::ROWS; $r++) {
$grid[$r] = array_slice($set, $r * self::COLS, self::COLS);
}
// Replace 'w' with adjacent-mine count
$dirs = [[-1, -1], [-1, 0], [-1, 1], [0, -1], [0, 1], [1, -1], [1, 0], [1, 1]];
for ($r = 0; $r < self::ROWS; $r++) {
for ($c = 0; $c < self::COLS; $c++) {
if ('w' !== $grid[$r][$c]) {
continue;
}
$count = 0;
foreach ($dirs as [$dr, $dc]) {
if (isset($grid[$r + $dr][$c + $dc]) && 'm' === $grid[$r + $dr][$c + $dc]) {
$count++;
}
}
$grid[$r][$c] = $count;
}
}
return $grid;
}
/**
* Get the Users by PlayedGame
*
* @param $gameAssoc
*
* @return array
* Collect all cells revealed so far, enriched with the player colour from each Step.
*/
private function getUsers($gameAssoc): array
private function aggregateRevealedCells(PlayedGame $playedGame): array
{
return $this->getUserCollection(
$this->entityManager
->getRepository(PlayedGame::class)
->findOneByGameAssoc($gameAssoc)
);
$all = [];
foreach ($playedGame->getSteps() as $step) {
if (null === $step->getRevealedCells()) {
continue;
}
$player = $step->getPlayer();
foreach ($step->getRevealedCells() as $cell) {
$all[] = array_merge($cell, ['player' => $player]);
}
}
return $all;
}
/**
* Get user collection from PlayedGame entity
*
* @param PlayedGame $playedGame
*
* @return array
*/
private function getUserCollection(PlayedGame $playedGame): array
{
return [
'red' => null !== $playedGame->getRed() ? $playedGame->getRed()->getUsername() : '',
'blue' => null !== $playedGame->getBlue() ? $playedGame->getBlue()->getUsername() : '',
'red' => null !== $playedGame->getRed() ? $playedGame->getRed()->getUsername() : '',
'blue' => null !== $playedGame->getBlue() ? $playedGame->getBlue()->getUsername() : '',
'redAnon' => null !== $playedGame->getRedAnon() ? $playedGame->getRedAnon()->getUserName() : '',
'blueAnon' => null !== $playedGame->getBlueAnon() ? $playedGame->getBlueAnon()->getUserName() : '',
'blueAnon' => null !== $playedGame->getBlueAnon()? $playedGame->getBlueAnon()->getUserName(): '',
];
}
}
}

View File

@@ -11,6 +11,7 @@
namespace App\Util;
use App\Entity\Gamer;
use App\Entity\GridRow;
use App\Entity\PlayedGame;
use App\Entity\Step;
use App\Entity\User;
@@ -51,8 +52,8 @@ class TopicManager implements TopicManagerInterface
return;
}
$users = $this->getUserCollection($playedGame);
$count = $this->getPlayerCount($users);
$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 */
@@ -94,17 +95,97 @@ class TopicManager implements TopicManagerInterface
));
}
public function publish(string $gameAssoc, string $userName, array $event): void
/**
* 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
{
null === $event['resign']
? $this->saveStepToDb($gameAssoc, $event)
: $this->saveResignToDb($gameAssoc, $event['resign']);
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,
@@ -113,14 +194,179 @@ class TopicManager implements TopicManagerInterface
'channel' => $topic,
'user' => $userName,
'userCnt' => $count,
'data' => $event,
'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->entityManager
@@ -130,7 +376,7 @@ class TopicManager implements TopicManagerInterface
private function getPlayerCount(array $users): int
{
$red = '' !== $users['red'] || '' !== $users['redAnon'] ? 1 : 0;
$red = '' !== $users['red'] || '' !== $users['redAnon'] ? 1 : 0;
$blue = '' !== $users['blue'] || '' !== $users['blueAnon'] ? 1 : 0;
return $red + $blue;
@@ -139,30 +385,36 @@ class TopicManager implements TopicManagerInterface
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): void
{
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($event['bomb']);
$step->setWBomb((bool) $event['bomb']);
$step->setPlayer($player);
$step->setRevealedCells($revealedCells);
$step->setPlayedGame($playedGame);
$step->setCreated(new DateTime());
$this->entityManager->persist($step);
$playedGame->setBluePoints($event['bluePoints']);
$playedGame->setRedPoints($event['redPoints']);
$playedGame->setBlueExplodedBomb($event['blueExplodedBomb'] ? true : null);
$playedGame->setRedExplodedBomb($event['redExplodedBomb'] ? true : null);
$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);
@@ -231,10 +483,10 @@ class TopicManager implements TopicManagerInterface
private function getUserCollection(PlayedGame $playedGame): array
{
return [
'red' => null !== $playedGame->getRed() ? $playedGame->getRed()->getUsername() : '',
'blue' => null !== $playedGame->getBlue() ? $playedGame->getBlue()->getUsername() : '',
'redAnon' => null !== $playedGame->getRedAnon() ? $playedGame->getRedAnon()->getUserName() : '',
'red' => null !== $playedGame->getRed() ? $playedGame->getRed()->getUsername() : '',
'blue' => null !== $playedGame->getBlue() ? $playedGame->getBlue()->getUsername() : '',
'redAnon' => null !== $playedGame->getRedAnon() ? $playedGame->getRedAnon()->getUserName() : '',
'blueAnon' => null !== $playedGame->getBlueAnon() ? $playedGame->getBlueAnon()->getUserName() : '',
];
}
}
}