diff --git a/README.md b/README.md index c9f66a3..d9ece09 100644 --- a/README.md +++ b/README.md @@ -287,6 +287,14 @@ git push origin v2026.01 --- +## Game Documentation + +For detailed information about game mechanics, bonus systems, and scoring rules, see the [docs](./docs/) directory: + +- **[Bonus Points System](./docs/game-mechanics/BONUS_POINTS_SYSTEM.md)** — Complete reference for all bonus point types, calculation rules, and implementation details + +--- + ## License LGPL-3.0 — see [LICENSE](LICENSE) for details. diff --git a/assets/css/mineseeker/_bonus-box.scss b/assets/css/mineseeker/_bonus-box.scss new file mode 100644 index 0000000..ff00ce5 --- /dev/null +++ b/assets/css/mineseeker/_bonus-box.scss @@ -0,0 +1,242 @@ +/*!* + * This file is part of the SplendidBear Websites' projects. + * + * Copyright (c) 2026 @ www.splendidbear.org + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +#mine-wrapper .bonus-box { + display: flex; + align-items: center; + justify-content: center; + gap: 6px; + min-width: 58px; + padding: 6px 12px; + border-radius: 8px; + border: 2px solid transparent; + background: #07090d; + font-family: 'Rajdhani', sans-serif; + font-weight: bold; + cursor: pointer; + transition: all 0.25s ease; + align-self: stretch; + + &:hover { + transform: translateY(-1px); + filter: brightness(1.15); + } + + &:active { + transform: translateY(0); + } +} + +#mine-wrapper .bonus-box.red-bonus { + background: linear-gradient(to bottom, #2a0502 0%, #4a1510 100%); + border-color: rgba(246, 125, 82, 0.4); + color: rgba(246, 125, 82, 0.85); + + &:hover { + border-color: rgba(246, 125, 82, 0.85); + box-shadow: 0 0 12px rgba(173, 10, 5, 0.6); + } +} + +#mine-wrapper .bonus-box.blue-bonus { + background: linear-gradient(to bottom, #050f18 0%, #0f2838 100%); + border-color: rgba(149, 207, 245, 0.4); + color: rgba(149, 207, 245, 0.85); + + &:hover { + border-color: rgba(149, 207, 245, 0.85); + box-shadow: 0 0 12px rgba(35, 111, 135, 0.6); + } +} + +#mine-wrapper .bonus-box__icon { + font-size: 13px; + opacity: 0.9; +} + +#mine-wrapper .bonus-box__value { + font-family: 'Courier New', monospace; + font-size: 16px; + letter-spacing: 1px; +} + +.bsd { + display: flex; + flex-direction: column; + font-family: 'Rajdhani', sans-serif; +} + +.bsd-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 18px 22px 14px; + border-bottom: 1px solid rgba(35, 111, 135, 0.3); +} + +.bsd-header-text { + display: flex; + flex-direction: column; + gap: 2px; +} + +.bsd-label { + font-size: 11px; + letter-spacing: 2px; + color: rgba(149, 207, 245, 0.7); + text-transform: uppercase; +} + +.bsd-title { + margin: 0; + font-size: 20px; + font-weight: 700; + display: flex; + align-items: center; + gap: 10px; + color: #fff; + + .fa { + color: #f6d572; + } +} + +.bsd-close { + background: transparent; + border: 1px solid rgba(35, 111, 135, 0.4); + border-radius: 6px; + color: rgba(255, 255, 255, 0.7); + width: 32px; + height: 32px; + cursor: pointer; + transition: all 0.2s ease; + + &:hover { + color: #fff; + border-color: rgba(149, 207, 245, 0.8); + } +} + +.bsd-body { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 14px; + padding: 18px 22px; +} + +.bsd-column { + border-radius: 10px; + padding: 14px; + border: 1px solid transparent; + background: rgba(255, 255, 255, 0.02); +} + +.bsd-column--red { + border-color: rgba(246, 125, 82, 0.35); + background: linear-gradient(to bottom, rgba(74, 6, 3, 0.35), rgba(107, 37, 21, 0.15)); +} + +.bsd-column--blue { + border-color: rgba(149, 207, 245, 0.35); + background: linear-gradient(to bottom, rgba(11, 37, 48, 0.35), rgba(22, 61, 85, 0.15)); +} + +.bsd-column-header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 10px; + padding-bottom: 10px; + border-bottom: 1px solid rgba(255, 255, 255, 0.1); +} + +.bsd-column-name { + font-weight: 700; + font-size: 16px; + color: #fff; +} + +.bsd-column-total { + display: inline-flex; + align-items: center; + gap: 6px; + font-family: 'Courier New', monospace; + font-size: 18px; + font-weight: 700; + color: #f6d572; + + .fa { + font-size: 14px; + } +} + +.bsd-stats { + list-style: none; + margin: 0; + padding: 0; + display: flex; + flex-direction: column; + gap: 8px; +} + +.bsd-stat { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + padding: 6px 2px; + border-bottom: 1px dashed rgba(255, 255, 255, 0.06); + + &:last-child { + border-bottom: none; + } +} + +.bsd-stat-text { + display: flex; + flex-direction: column; + gap: 1px; + min-width: 0; +} + +.bsd-stat-label { + font-size: 13px; + font-weight: 600; + color: rgba(255, 255, 255, 0.92); +} + +.bsd-stat-desc { + font-size: 11px; + color: rgba(255, 255, 255, 0.48); + line-height: 1.25; +} + +.bsd-stat-value { + font-family: 'Courier New', monospace; + font-size: 16px; + font-weight: 700; + color: #fff; + min-width: 24px; + text-align: right; +} + +.bsd-note { + margin: 0; + padding: 12px 22px 18px; + font-size: 12px; + color: rgba(255, 255, 255, 0.45); + text-align: center; + font-style: italic; +} + +@media (max-width: 520px) { + .bsd-body { + grid-template-columns: 1fr; + } +} diff --git a/assets/css/style.mineseeker.scss b/assets/css/style.mineseeker.scss index a4b5bec..34d835b 100644 --- a/assets/css/style.mineseeker.scss +++ b/assets/css/style.mineseeker.scss @@ -19,5 +19,6 @@ @import 'mineseeker/grid'; @import 'mineseeker/back-button'; @import 'mineseeker/timer'; +@import 'mineseeker/bonus-box'; @import 'mineseeker/responsive'; @import 'mineseeker/waiting-dialog'; diff --git a/assets/js/mine-seeker/components/BonusBox.jsx b/assets/js/mine-seeker/components/BonusBox.jsx new file mode 100644 index 0000000..1683fd6 --- /dev/null +++ b/assets/js/mine-seeker/components/BonusBox.jsx @@ -0,0 +1,25 @@ +/** + * This file is part of the SplendidBear Websites' projects. + * + * Copyright (c) 2026 @ www.splendidbear.org + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +import React from 'react'; + +const BonusBox = ({ color, points, onClick, title }) => ( + +); + +export default BonusBox; diff --git a/assets/js/mine-seeker/components/BonusStatsDialog.jsx b/assets/js/mine-seeker/components/BonusStatsDialog.jsx new file mode 100644 index 0000000..c2b7240 --- /dev/null +++ b/assets/js/mine-seeker/components/BonusStatsDialog.jsx @@ -0,0 +1,97 @@ +/** + * This file is part of the SplendidBear Websites' projects. + * + * Copyright (c) 2026 @ www.splendidbear.org + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +import React from 'react'; +import Dialog from '@mui/material/Dialog'; +import { BONUS_LABELS } from '@mine-utils'; + +const DIALOG_SX = { + '& .MuiDialog-paper': { + background: '#07090d', + backgroundImage: ` + linear-gradient(rgba(35, 111, 135, 0.08) 1px, transparent 1px), + linear-gradient(90deg, rgba(35, 111, 135, 0.08) 1px, transparent 1px) + `, + backgroundSize: '46px 46px', + border: '1px solid rgba(35, 111, 135, 0.4)', + borderRadius: '12px', + boxShadow: '0 0 80px rgba(35, 111, 135, 0.15), 0 32px 80px rgba(0, 0, 0, 0.9)', + width: '560px', + maxWidth: '94vw', + overflow: 'hidden', + color: '#fff', + }, + '& .MuiBackdrop-root': { + background: 'rgba(2, 4, 8, 0.88)', + backdropFilter: 'blur(4px)', + }, +}; + +const formatPlayerName = name => { + if (name && name.startsWith('anon_')) { + return 'Anonymous'; + } + + if (name && 10 < name.length) { + return name.substring(0, 7) + '...'; + } + + return name || 'Unknown'; +}; + +const PlayerColumn = ({ color, player }) => ( +
+
+ {formatPlayerName(player.name)} + + + {player.bonusPoints} + +
+
    + {Object.entries(BONUS_LABELS).map(([key, { label, desc }]) => ( +
  • +
    + {label} + {desc} +
    + {player.bonusStats?.[key] ?? 0} +
  • + ))} +
+
+); + +const BonusStatsDialog = ({ open, onClose, red, blue }) => ( + +
+
+
+ Scoring +

+ + Bonus Statistics +

+
+ +
+
+ + +
+

+ Bonus points are awarded alongside the main score for skillful play. +

+
+
+); + +export default BonusStatsDialog; diff --git a/assets/js/mine-seeker/components/GameTimer.jsx b/assets/js/mine-seeker/components/GameTimer.jsx index 46bf5c1..e615a6c 100644 --- a/assets/js/mine-seeker/components/GameTimer.jsx +++ b/assets/js/mine-seeker/components/GameTimer.jsx @@ -9,6 +9,8 @@ import React, { useEffect, useRef, useState } from 'react'; import { useGame } from '@mine-contexts'; +import BonusBox from './BonusBox'; +import BonusStatsDialog from './BonusStatsDialog'; const renderAvatar = player => { if (!player.registered) return null; @@ -27,6 +29,7 @@ const GameTimer = () => { const [redTime, setRedTime] = useState(0); const [blueTime, setBlueTime] = useState(0); const [isRunning, setIsRunning] = useState(false); + const [bonusDialogOpen, setBonusDialogOpen] = useState(false); const timerIntervalRef = useRef(null); const gameStartedRef = useRef(false); @@ -160,8 +163,12 @@ const GameTimer = () => { return `${String(mins).padStart(2, '0')}:${String(secs).padStart(2, '0')}`; }; + const openBonusDialog = () => setBonusDialogOpen(true); + const closeBonusDialog = () => setBonusDialogOpen(false); + return (
+
{renderAvatar(red)} @@ -172,6 +179,8 @@ const GameTimer = () => { {formatTime(blueTime)}
+ +
); }; diff --git a/assets/js/mine-seeker/components/user/UserControl.jsx b/assets/js/mine-seeker/components/user/UserControl.jsx index 38cc65b..3dcce1d 100644 --- a/assets/js/mine-seeker/components/user/UserControl.jsx +++ b/assets/js/mine-seeker/components/user/UserControl.jsx @@ -7,12 +7,14 @@ * file that was distributed with this source code. */ -import React from 'react'; +import React, { Fragment, useState } from 'react'; import { useGame } from '@mine-contexts'; import User from './User'; +import BonusStatsDialog from '../BonusStatsDialog'; const UserControl = ({ resign }) => { const { webPlayer, activePlayer, mines, foundMines, red, blue, onBombToggle } = useGame(); + const [bonusDialogOpen, setBonusDialogOpen] = useState(false); const activeColor = activePlayer ? 'blue' : 'red'; const resignClass = 'resign' + (activeColor !== webPlayer ? ' disabled' : ''); const minesClass = 'active-mines' + (foundMines ? ' found-mine' : ''); @@ -24,30 +26,44 @@ const UserControl = ({ resign }) => { } }; + const handleBonusClick = () => { + setBonusDialogOpen(true); + }; + return ( -
- handleBombClick('blue', 1)} - /> -
- -
-
{mines}
-
+ +
+ handleBombClick('blue', 1)} + onBonusClick={handleBonusClick} + /> +
+ +
+
{mines}
+
+
+
- +
+ 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);