-
-
-
{mines}
-
+
+
+
handleBombClick('blue', 1)}
+ onBonusClick={handleBonusClick}
+ />
+
-
+
+ handleBombClick('red', 0)}
+ onBonusClick={handleBonusClick}
+ />
+
-
- handleBombClick('red', 0)}
+ setBonusDialogOpen(false)}
+ red={red}
+ blue={blue}
/>
-
-
+
);
}
diff --git a/assets/js/mine-seeker/contexts/GameProvider.jsx b/assets/js/mine-seeker/contexts/GameProvider.jsx
index 7660163..77a2630 100644
--- a/assets/js/mine-seeker/contexts/GameProvider.jsx
+++ b/assets/js/mine-seeker/contexts/GameProvider.jsx
@@ -132,7 +132,18 @@ export const GameProvider = ({ children }) => {
};
const applyStep = stepData => {
- const { player, bomb: isBomb, minesFound = 0, revealedCells = [], redPoints: rp, bluePoints: bp } = stepData;
+ const {
+ player,
+ bomb: isBomb,
+ minesFound = 0,
+ revealedCells = [],
+ redPoints: rp,
+ bluePoints: bp,
+ redBonusPoints = 0,
+ blueBonusPoints = 0,
+ redBonusStats = {},
+ blueBonusStats = {},
+ } = stepData;
if (isBomb) {
sounds.current.bomb.play();
@@ -176,6 +187,18 @@ export const GameProvider = ({ children }) => {
syncBlue(p => ({ ...p, mines: 'blue' === player ? bp : p.mines }));
}
+ // Update bonus points and stats
+ syncRed(p => ({
+ ...p,
+ bonusPoints: 'red' === player ? redBonusPoints : p.bonusPoints,
+ bonusStats: 'red' === player ? redBonusStats : p.bonusStats,
+ }));
+ syncBlue(p => ({
+ ...p,
+ bonusPoints: 'blue' === player ? blueBonusPoints : p.bonusPoints,
+ bonusStats: 'blue' === player ? blueBonusStats : p.bonusStats,
+ }));
+
syncRed(p => ({ ...p, enabledBomb: rp <= bp }));
syncBlue(p => ({ ...p, enabledBomb: bp <= rp }));
diff --git a/assets/js/mine-seeker/utils/constants.jsx b/assets/js/mine-seeker/utils/constants.jsx
index a6d1fc7..53e23dc 100644
--- a/assets/js/mine-seeker/utils/constants.jsx
+++ b/assets/js/mine-seeker/utils/constants.jsx
@@ -34,9 +34,23 @@ export const IMAGES = {
bombPos: (hor, vert) => `${IMG}bg-bomb-${hor}-${vert}-outbg.png`,
};
+export const BONUS_STATS_DEF = {
+ blindHits: 0, chainBest: 0, chainCurrent: 0, lastMineHits: 0, edgeMines: 0, biggestReveal: 0,
+};
+
+export const BONUS_LABELS = {
+ blindHits: { label: 'Blind hits', desc: 'Mines clicked with no revealed number nearby' },
+ chainBest: { label: 'Best chain', desc: 'Longest streak of consecutive mine-clicks' },
+ chainCurrent: { label: 'Current chain', desc: 'Active consecutive mine-click streak' },
+ lastMineHits: { label: 'Endgame mines', desc: 'Mines clicked while few remain on the board' },
+ edgeMines: { label: 'Edge mines', desc: 'Mines clicked on the board boundary' },
+ biggestReveal: { label: 'Biggest reveal', desc: 'Largest number of safe cells revealed in one click' },
+};
+
export const PLAYER_DEF = {
name: '...', desc: '', active: false, mines: 0, haveBomb: true, enabledBomb: true,
registered: false, avatar: null,
+ bonusPoints: 0, bonusStats: { ...BONUS_STATS_DEF },
};
export const DESC = {
diff --git a/assets/js/mine-seeker/utils/index.js b/assets/js/mine-seeker/utils/index.js
index fbb1db7..29e2997 100644
--- a/assets/js/mine-seeker/utils/index.js
+++ b/assets/js/mine-seeker/utils/index.js
@@ -7,4 +7,4 @@
* file that was distributed with this source code.
*/
-export { DESC, IMAGES, BOMB_SYMBOLS, PLAYER_DEF, bombRadius, initCells, patchCells } from './constants';
+export { DESC, IMAGES, BOMB_SYMBOLS, PLAYER_DEF, BONUS_STATS_DEF, BONUS_LABELS, bombRadius, initCells, patchCells } from './constants';
diff --git a/docs/README.md b/docs/README.md
new file mode 100644
index 0000000..f66765a
--- /dev/null
+++ b/docs/README.md
@@ -0,0 +1,47 @@
+# Mine-Seeker Game Documentation
+
+This directory contains comprehensive documentation about the Mine-Seeker game mechanics and implementation.
+
+## Game Mechanics
+
+### [Bonus Points System](./game-mechanics/BONUS_POINTS_SYSTEM.md)
+Complete reference for the bonus points system including:
+- All 6 bonus point types (Blind Hit, Chain Combo, Edge Mine, Endgame Mine, Safe Cell Bonus, Biggest Reveal)
+- Calculation rules and examples
+- Bonus statistics tracking
+- Player name formatting in dialogs
+- Database schema
+- Implementation notes
+- Testing checklist
+
+**Recommended for**: Developers working on bonus system, AI assistants implementing or debugging bonus features, understanding game scoring mechanics.
+
+---
+
+## Quick Reference
+
+### Bonus Points at a Glance
+| Bonus Type | Points | Condition |
+|-----------|--------|-----------|
+| Blind Hit | +2 | Mine with no revealed numbered neighbors |
+| Edge Mine | +1 | Mine on board boundary (row/col 0 or 15) |
+| Endgame Mine | +3 | Mine clicked when ≤10 mines remain |
+| Safe Cell | +0.5 each | ≥2 safe cells revealed (min requirement) |
+| Chain Combo | Tracked | Consecutive mine clicks (no safe clicks) |
+| Biggest Reveal | Tracked | Largest number of safe cells revealed |
+
+### Key Rules
+- Safe cell bonus only awarded for ≥2 cells minimum
+- Chain counter resets on any safe cell click
+- Endgame threshold: 51 - (redPoints + bluePoints) ≤ 10
+- Bonus stats are per-player and persist in database
+
+---
+
+## Files Using This Information
+- Backend: `/src/Util/TopicManager.php`
+- Frontend: `/assets/js/mine-seeker/contexts/GameProvider.jsx`
+- UI: `/assets/js/mine-seeker/components/BonusStatsDialog.jsx`
+- Constants: `/assets/js/mine-seeker/utils/constants.jsx`
+
+
diff --git a/docs/game-mechanics/BONUS_POINTS_SYSTEM.md b/docs/game-mechanics/BONUS_POINTS_SYSTEM.md
new file mode 100644
index 0000000..bf43b93
--- /dev/null
+++ b/docs/game-mechanics/BONUS_POINTS_SYSTEM.md
@@ -0,0 +1,133 @@
+# Mine-Seeker Bonus Points System
+
+## Overview
+The Mine-Seeker game includes a bonus points system that rewards skilled play. Bonus points are tracked separately from the main score and displayed in the "Bonus Statistics" dialog.
+
+## Bonus Point Types
+
+### 1. Blind Hit (+2 points)
+**When**: Click a mine with no revealed numbered neighbors around it.
+
+**Example**: Mine surrounded by unrevealed cells = +2 points
+
+---
+
+### 2. Chain Combo
+**When**: Click consecutive mines without clicking any safe cell in between.
+
+**Tracked as**:
+- `chainCurrent`: Current streak (resets when you click a safe cell)
+- `chainBest`: Longest streak achieved
+
+**Example**: Mine → Mine → Mine = chainBest becomes 3
+
+---
+
+### 3. Edge Mine (+1 point)
+**When**: Click a mine on the board boundary (row 0, row 15, col 0, or col 15).
+
+**Example**: Click a mine on the edge = +1 point
+
+---
+
+### 4. Endgame Mine (+3 points)
+**When**: Click a mine when 10 or fewer mines remain on the board.
+
+**Calculation**: `51 total mines - (red_points + blue_points) = mines_remaining`
+
+**Example**: When 8 mines remain, click one = +3 points
+
+---
+
+### 5. Safe Cell Bonus (+0.5 points per cell)
+**When**: Click a safe cell and reveal 2 or more cells.
+
+**Important**: Minimum 2 cells required. Single cell reveals = 0 points.
+
+**Examples**:
+- Reveal 1 safe cell = 0 points
+- Reveal 2 safe cells = 1.0 points
+- Reveal 11 safe cells = 5.5 points
+
+---
+
+### 6. Biggest Reveal (Tracking stat)
+**What**: Tracks the largest number of safe cells revealed in one click.
+
+**Example**: Largest reveal in a game = 15 cells shown in stats
+
+---
+
+## Bonus Statistics Display
+
+### Dialog Shows
+- Both players' bonus statistics side-by-side
+- Each stat with label, description, and value
+- Total bonus points per player
+
+### Player Name Rules
+- `anon_*` usernames → displays as "Anonymous"
+- Names longer than 10 chars → truncated to 7 chars + "..." (example: `VeryLongName` → `VeryLon...`)
+
+---
+
+## Tracked Statistics
+
+```javascript
+{
+ blindHits: 0, // Blind hit mines clicked
+ chainBest: 0, // Longest mine streak
+ chainCurrent: 0, // Current active streak
+ lastMineHits: 0, // Endgame mines clicked
+ edgeMines: 0, // Edge mine clicks
+ biggestReveal: 0 // Largest safe cell reveal
+}
+```
+
+---
+
+## Database
+- `red_bonus_points` (FLOAT) — Red player's total bonus points
+- `blue_bonus_points` (FLOAT) — Blue player's total bonus points
+- `red_bonus_stats` (JSON) — Red player's tracked stats
+- `blue_bonus_stats` (JSON) — Blue player's tracked stats
+
+---
+
+## 📋 Documentation Maintenance
+
+**IMPORTANT**: This documentation must be updated whenever:
+- New bonus types are added
+- Point values change
+- Bonus calculation logic changes
+- New stats are tracked
+- Bonus display rules change
+
+**Update these files**:
+1. This file (`BONUS_POINTS_SYSTEM.md`) — Update descriptions and examples
+2. Code comments in `/src/Util/TopicManager.php` — Explain calculation logic
+3. `/docs/README.md` — Update Quick Reference table if values change
+
+**Keep documentation**:
+- ✅ Simple and clear
+- ✅ With real code examples
+- ✅ Synchronized with actual code behavior
+- ✅ Updated before/after feature changes
+
+---
+
+## Implementation Files
+- Backend: `/src/Util/TopicManager.php` — Bonus calculation logic
+- Frontend: `/assets/js/mine-seeker/contexts/GameProvider.jsx` — State sync
+- UI: `/assets/js/mine-seeker/components/BonusStatsDialog.jsx` — Display dialog
+- Constants: `/assets/js/mine-seeker/utils/constants.jsx` — Labels and defaults
+
+---
+
+## Quick Checklist for Changes
+- [ ] Code changes implemented
+- [ ] This documentation updated
+- [ ] `/docs/README.md` Quick Reference table updated
+- [ ] Code comments added/updated
+- [ ] Examples updated to match new behavior
+
diff --git a/src/Entity/PlayedGame.php b/src/Entity/PlayedGame.php
index b8d2701..58fc423 100644
--- a/src/Entity/PlayedGame.php
+++ b/src/Entity/PlayedGame.php
@@ -62,6 +62,18 @@ class PlayedGame
#[Column(length: 7, nullable: true)]
private ?string $resign = null;
+ #[Column(nullable: true)]
+ private ?float $redBonusPoints = null;
+
+ #[Column(nullable: true)]
+ private ?float $blueBonusPoints = null;
+
+ #[Column(nullable: true)]
+ private ?array $redBonusStats = null;
+
+ #[Column(nullable: true)]
+ private ?array $blueBonusStats = null;
+
#[Column(type: Types::DATETIME_MUTABLE, nullable: true)]
private ?DateTime $created = null;
@@ -222,6 +234,46 @@ class PlayedGame
$this->resign = $resign;
}
+ public function getRedBonusPoints(): ?float
+ {
+ return $this->redBonusPoints;
+ }
+
+ public function setRedBonusPoints(?float $redBonusPoints): void
+ {
+ $this->redBonusPoints = $redBonusPoints;
+ }
+
+ public function getBlueBonusPoints(): ?float
+ {
+ return $this->blueBonusPoints;
+ }
+
+ public function setBlueBonusPoints(?float $blueBonusPoints): void
+ {
+ $this->blueBonusPoints = $blueBonusPoints;
+ }
+
+ public function getRedBonusStats(): ?array
+ {
+ return $this->redBonusStats;
+ }
+
+ public function setRedBonusStats(?array $redBonusStats): void
+ {
+ $this->redBonusStats = $redBonusStats;
+ }
+
+ public function getBlueBonusStats(): ?array
+ {
+ return $this->blueBonusStats;
+ }
+
+ public function setBlueBonusStats(?array $blueBonusStats): void
+ {
+ $this->blueBonusStats = $blueBonusStats;
+ }
+
public function getCreated(): ?DateTime
{
return $this->created;
@@ -247,3 +299,5 @@ class PlayedGame
return $this->steps;
}
}
+
+
diff --git a/src/Migrations/2026/04/Version20260418104430.php b/src/Migrations/2026/04/Version20260418104430.php
new file mode 100644
index 0000000..f679c54
--- /dev/null
+++ b/src/Migrations/2026/04/Version20260418104430.php
@@ -0,0 +1,48 @@
+
+ * @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. 18.
+ */
+final class Version20260418104430 extends AbstractMigration
+{
+ public function getDescription(): string
+ {
+ return 'Add bonus stats to the playing experience';
+ }
+
+ public function up(Schema $schema): void
+ {
+ $this->addSql('ALTER TABLE played_game ADD red_bonus_points DOUBLE PRECISION DEFAULT NULL');
+ $this->addSql('ALTER TABLE played_game ADD blue_bonus_points DOUBLE PRECISION DEFAULT NULL');
+ $this->addSql('ALTER TABLE played_game ADD red_bonus_stats JSON DEFAULT NULL');
+ $this->addSql('ALTER TABLE played_game ADD blue_bonus_stats JSON DEFAULT NULL');
+ }
+
+ public function down(Schema $schema): void
+ {
+ $this->addSql('ALTER TABLE played_game DROP red_bonus_points');
+ $this->addSql('ALTER TABLE played_game DROP blue_bonus_points');
+ $this->addSql('ALTER TABLE played_game DROP red_bonus_stats');
+ $this->addSql('ALTER TABLE played_game DROP blue_bonus_stats');
+ }
+}
diff --git a/src/Util/TopicManager.php b/src/Util/TopicManager.php
index 2b492c9..2ff1baa 100644
--- a/src/Util/TopicManager.php
+++ b/src/Util/TopicManager.php
@@ -44,12 +44,12 @@ use Symfony\Component\Security\Core\User\UserInterface;
readonly class TopicManager implements TopicManagerInterface
{
public function __construct(
+ private EntityManagerInterface $em,
private HubInterface $hub,
- private EntityManagerInterface $entityManager,
private LoggerInterface $logger,
+ private CacheManager $cacheManager,
private PlayedGameRepository $playedGameRepository,
private UserRepository $userRepository,
- private CacheManager $cacheManager,
) {
}
@@ -96,8 +96,8 @@ readonly class TopicManager implements TopicManagerInterface
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();
+ $this->em->persist($playedGame);
+ $this->em->flush();
$displayName = $users['red'] ?: $users['redAnon'] ?: $users['blue'] ?: $users['blueAnon'] ?: 'Unknown';
$this->publishToLobby([
@@ -121,8 +121,8 @@ readonly class TopicManager implements TopicManagerInterface
$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->em->persist($playedGame);
+ $this->em->flush();
$this->publishToLobby(['action' => 'leave', 'gameAssoc' => $gameAssoc]);
}
}
@@ -176,25 +176,40 @@ readonly class TopicManager implements TopicManagerInterface
$playedGame = $this->getPlayedGame($gameAssoc);
$grid = $this->loadGrid($gameAssoc);
- // Cells already revealed by previous steps (as "row,col" => true map)
+ /** Cells already revealed by previous steps (as "row,col" => true map) */
$alreadyRevealed = $this->buildRevealedMap($playedGame);
- // Determine which cells to reveal for this step
+ /** 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)
+ /** 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']));
+ $safeCellsFound = 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
+ /** Calculate bonus points and stats */
+ $bonusData = $this->calculateBonuses(
+ $playedGame,
+ $player,
+ $coords,
+ $grid,
+ $alreadyRevealed,
+ $minesFound,
+ $safeCellsFound,
+ $redPoints,
+ $bluePoints
+ );
+
+ /** Reveal remaining mines when the game ends */
$leftMines = [];
if ($gameOver) {
$finalRevealed = $alreadyRevealed;
@@ -204,23 +219,27 @@ readonly class TopicManager implements TopicManagerInterface
$leftMines = $this->getLeftMines($grid, $finalRevealed);
}
- $this->saveStepToDb($gameAssoc, $event, $player, $revealedCells, $redPoints, $bluePoints);
+ $this->saveStepToDb($gameAssoc, $event, $player, $revealedCells, $redPoints, $bluePoints, $bonusData);
$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,
+ 'coords' => $coords,
+ 'player' => $player,
+ 'bomb' => $isBomb,
+ 'revealedCells' => $revealedCells,
+ 'minesFound' => $minesFound,
+ 'redPoints' => $redPoints,
+ 'bluePoints' => $bluePoints,
+ 'resign' => null,
+ 'gameOver' => $gameOver,
+ 'leftMines' => $leftMines,
+ 'redBonusPoints' => $bonusData['redBonusPoints'],
+ 'blueBonusPoints' => $bonusData['blueBonusPoints'],
+ 'redBonusStats' => $bonusData['redBonusStats'],
+ 'blueBonusStats' => $bonusData['blueBonusStats'],
];
try {
@@ -260,6 +279,155 @@ readonly class TopicManager implements TopicManagerInterface
return $grid;
}
+ private function calculateBonuses(
+ PlayedGame $playedGame,
+ string $player,
+ array $coords,
+ array $grid,
+ array $alreadyRevealed,
+ int $minesFound,
+ int $safeCellsFound,
+ int $redPoints,
+ int $bluePoints
+ ): array {
+ /** Initialize or load existing bonus stats */
+ $redBonusStats = $playedGame->getRedBonusStats() ?? [
+ 'blindHits' => 0,
+ 'chainBest' => 0,
+ 'chainCurrent' => 0,
+ 'lastMineHits' => 0,
+ 'edgeMines' => 0,
+ 'biggestReveal' => 0,
+ ];
+ $blueBonusStats = $playedGame->getBlueBonusStats() ?? [
+ 'blindHits' => 0,
+ 'chainBest' => 0,
+ 'chainCurrent' => 0,
+ 'lastMineHits' => 0,
+ 'edgeMines' => 0,
+ 'biggestReveal' => 0,
+ ];
+
+ $redBonusPoints = $playedGame->getRedBonusPoints() ?? 0;
+ $blueBonusPoints = $playedGame->getBlueBonusPoints() ?? 0;
+
+ $isRed = 'red' === $player;
+ $currentStats = $isRed ? $redBonusStats : $blueBonusStats;
+ $bonusPoints = 0;
+
+ /** Track biggest reveal (safe cells count) if any safe cells were revealed */
+ if ($safeCellsFound > 0) {
+ if ($safeCellsFound > $currentStats['biggestReveal']) {
+ $currentStats['biggestReveal'] = $safeCellsFound;
+ }
+ }
+
+ /** Only calculate bonuses if mines were found */
+ if ($minesFound > 0) {
+ /** Check Blind Hit: the clicked mine cell has no revealed numbered neighbors */
+ if ($this->isBlindHit($coords, $grid, $alreadyRevealed)) {
+ $currentStats['blindHits']++;
+ $bonusPoints += 2;
+ }
+
+ /** Check Edge Mine: the clicked cell is on the boundary */
+ if ($this->isEdgeMine($coords)) {
+ $currentStats['edgeMines']++;
+ $bonusPoints += 1;
+ }
+
+ /** Check Endgame Mine: when few mines remain on the board */
+ $totalMinesOnBoard = 51;
+ $minesRevealed = $redPoints + $bluePoints;
+ $minesRemaining = $totalMinesOnBoard - $minesRevealed;
+
+ if ($minesRemaining <= 10) {
+ $currentStats['lastMineHits']++;
+ $bonusPoints += 3;
+ }
+
+ /** Chain combo: increment consecutive mine-click counter */
+ $currentStats['chainCurrent']++;
+ if ($currentStats['chainCurrent'] > $currentStats['chainBest']) {
+ $currentStats['chainBest'] = $currentStats['chainCurrent'];
+ }
+ } else {
+ /** No mines found - reset chain for this player */
+ $currentStats['chainCurrent'] = 0;
+ }
+
+ /**
+ * Add points for safe cells revealed (each safe cell revealed = +0.5 bonus point)
+ * Only award points if at least 2 safe cells were revealed
+ */
+ if ($safeCellsFound >= 2) {
+ $bonusPoints += ($safeCellsFound * 0.5);
+ }
+
+ /** Update the appropriate player's stats and points */
+ if ($isRed) {
+ $redBonusStats = $currentStats;
+ $redBonusPoints += $bonusPoints;
+ } else {
+ $blueBonusStats = $currentStats;
+ $blueBonusPoints += $bonusPoints;
+ }
+
+ /** Persist updated stats to the database */
+ $playedGame->setRedBonusStats($redBonusStats);
+ $playedGame->setBlueBonusStats($blueBonusStats);
+ $playedGame->setRedBonusPoints($redBonusPoints);
+ $playedGame->setBlueBonusPoints($blueBonusPoints);
+ $this->em->persist($playedGame);
+
+ return [
+ 'redBonusPoints' => $redBonusPoints,
+ 'blueBonusPoints' => $blueBonusPoints,
+ 'redBonusStats' => $redBonusStats,
+ 'blueBonusStats' => $blueBonusStats,
+ ];
+ }
+
+ /**
+ * Check if a mine was clicked with no revealed numbered neighbors (blind hit).
+ * Returns true if none of the 8 surrounding cells show a number.
+ */
+ private function isBlindHit(array $coords, array $grid, array $alreadyRevealed): bool
+ {
+ $row = $coords[0];
+ $col = $coords[1];
+ $dirs = [[-1, -1], [-1, 0], [-1, 1], [0, -1], [0, 1], [1, -1], [1, 0], [1, 1]];
+
+ foreach ($dirs as [$dr, $dc]) {
+ $nr = $row + $dr;
+ $nc = $col + $dc;
+ $key = $nr . ',' . $nc;
+
+ /** Check if neighbor is revealed and is a numbered cell (not a mine, not hidden) */
+ if (isset($alreadyRevealed[$key])) {
+ $val = $grid[$nr][$nc] ?? null;
+
+ /** If it's a number (0-8), not a mine, it's revealed and visible */
+ if (is_numeric($val) && $val >= 0) {
+ return false;
+ }
+ }
+ }
+
+ return true;
+ }
+
+ /**
+ * Check if a mine is on the edge/corner of the board.
+ */
+ private function isEdgeMine(array $coords): bool
+ {
+ $row = $coords[0];
+ $col = $coords[1];
+
+ return 0 === $row || $row === 15 || 0 === $col || $col === 15;
+ }
+
/**
* BFS flood-fill starting at (row, col).
* Reveals the clicked cell plus all connected zero-value cells and their non-mine borders.
@@ -414,8 +582,8 @@ readonly class TopicManager implements TopicManagerInterface
{
$playedGame = $this->getPlayedGame($gameAssoc);
$playedGame->setResign($color);
- $this->entityManager->persist($playedGame);
- $this->entityManager->flush();
+ $this->em->persist($playedGame);
+ $this->em->flush();
}
private function saveStepToDb(
@@ -425,6 +593,7 @@ readonly class TopicManager implements TopicManagerInterface
array $revealedCells,
int $redPoints,
int $bluePoints,
+ array $bonusData = []
): void {
try {
$playedGame = $this->getPlayedGame($gameAssoc);
@@ -437,16 +606,24 @@ readonly class TopicManager implements TopicManagerInterface
$step->setRevealedCells($revealedCells);
$step->setPlayedGame($playedGame);
$step->setCreated(new DateTime());
- $this->entityManager->persist($step);
+ $this->em->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();
+ /** Bonus data is already persisted in calculateBonuses, but we ensure it's up to date */
+ if (!empty($bonusData)) {
+ $playedGame->setRedBonusPoints($bonusData['redBonusPoints']);
+ $playedGame->setBlueBonusPoints($bonusData['blueBonusPoints']);
+ $playedGame->setRedBonusStats($bonusData['redBonusStats']);
+ $playedGame->setBlueBonusStats($bonusData['blueBonusStats']);
+ }
+
+ $this->em->persist($playedGame);
+ $this->em->flush();
} catch (Exception $e) {
$this->logger->error($e->getMessage());
}
@@ -465,8 +642,8 @@ readonly class TopicManager implements TopicManagerInterface
? $this->saveRegisteredUser($userName, $count, $playedGame)
: $this->saveAnonUser($userName, $count, $playedGame, $request);
- $this->entityManager->persist($playedGame);
- $this->entityManager->flush();
+ $this->em->persist($playedGame);
+ $this->em->flush();
return $this->getUserCollection($playedGame);
}
@@ -499,7 +676,7 @@ readonly class TopicManager implements TopicManagerInterface
$anon->setCountry($this->extractCountry($request));
$anon->setUserAgent($request->headers->get('User-Agent'));
$anon->setConnTimestamp(new DateTime());
- $this->entityManager->persist($anon);
+ $this->em->persist($anon);
if ($count === 1) {
$random = random_int(0, 1);