Private
Public Access
1
0

new: usr: add initialization bonus points' system to the gameplay #5

This commit is contained in:
2026-04-18 12:57:20 +02:00
parent 0cc9cdaf07
commit 25f2aaab8c
15 changed files with 946 additions and 52 deletions

View File

@@ -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 ## License
LGPL-3.0 — see [LICENSE](LICENSE) for details. LGPL-3.0 — see [LICENSE](LICENSE) for details.

View File

@@ -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;
}
}

View File

@@ -19,5 +19,6 @@
@import 'mineseeker/grid'; @import 'mineseeker/grid';
@import 'mineseeker/back-button'; @import 'mineseeker/back-button';
@import 'mineseeker/timer'; @import 'mineseeker/timer';
@import 'mineseeker/bonus-box';
@import 'mineseeker/responsive'; @import 'mineseeker/responsive';
@import 'mineseeker/waiting-dialog'; @import 'mineseeker/waiting-dialog';

View File

@@ -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 }) => (
<button
type="button"
className={`bonus-box ${color}-bonus`}
onClick={onClick}
title={title || 'View bonus statistics'}
aria-label={`${color} bonus points: ${points}`}
>
<i className="fa fa-star bonus-box__icon" />
<span className="bonus-box__value">{points}</span>
</button>
);
export default BonusBox;

View File

@@ -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 }) => (
<div className={`bsd-column bsd-column--${color}`}>
<div className="bsd-column-header">
<span className="bsd-column-name">{formatPlayerName(player.name)}</span>
<span className="bsd-column-total">
<i className="fa fa-star" />
{player.bonusPoints}
</span>
</div>
<ul className="bsd-stats">
{Object.entries(BONUS_LABELS).map(([key, { label, desc }]) => (
<li key={key} className="bsd-stat">
<div className="bsd-stat-text">
<span className="bsd-stat-label">{label}</span>
<span className="bsd-stat-desc">{desc}</span>
</div>
<span className="bsd-stat-value">{player.bonusStats?.[key] ?? 0}</span>
</li>
))}
</ul>
</div>
);
const BonusStatsDialog = ({ open, onClose, red, blue }) => (
<Dialog open={open} onClose={onClose} sx={DIALOG_SX}>
<div className="bsd">
<div className="bsd-header">
<div className="bsd-header-text">
<span className="bsd-label">Scoring</span>
<h2 className="bsd-title">
<i className="fa fa-star" />
Bonus Statistics
</h2>
</div>
<button className="bsd-close" onClick={onClose} aria-label="Close">
<i className="fa fa-times" />
</button>
</div>
<div className="bsd-body">
<PlayerColumn color="red" player={red} />
<PlayerColumn color="blue" player={blue} />
</div>
<p className="bsd-note">
Bonus points are awarded alongside the main score for skillful play.
</p>
</div>
</Dialog>
);
export default BonusStatsDialog;

View File

@@ -9,6 +9,8 @@
import React, { useEffect, useRef, useState } from 'react'; import React, { useEffect, useRef, useState } from 'react';
import { useGame } from '@mine-contexts'; import { useGame } from '@mine-contexts';
import BonusBox from './BonusBox';
import BonusStatsDialog from './BonusStatsDialog';
const renderAvatar = player => { const renderAvatar = player => {
if (!player.registered) return null; if (!player.registered) return null;
@@ -27,6 +29,7 @@ const GameTimer = () => {
const [redTime, setRedTime] = useState(0); const [redTime, setRedTime] = useState(0);
const [blueTime, setBlueTime] = useState(0); const [blueTime, setBlueTime] = useState(0);
const [isRunning, setIsRunning] = useState(false); const [isRunning, setIsRunning] = useState(false);
const [bonusDialogOpen, setBonusDialogOpen] = useState(false);
const timerIntervalRef = useRef(null); const timerIntervalRef = useRef(null);
const gameStartedRef = useRef(false); const gameStartedRef = useRef(false);
@@ -160,8 +163,12 @@ const GameTimer = () => {
return `${String(mins).padStart(2, '0')}:${String(secs).padStart(2, '0')}`; return `${String(mins).padStart(2, '0')}:${String(secs).padStart(2, '0')}`;
}; };
const openBonusDialog = () => setBonusDialogOpen(true);
const closeBonusDialog = () => setBonusDialogOpen(false);
return ( return (
<div className="game-timer-container"> <div className="game-timer-container">
<BonusBox color="red" points={red.bonusPoints ?? 0} onClick={openBonusDialog} />
<div className={`game-timer red-timer ${!activePlayer ? 'active' : ''}`}> <div className={`game-timer red-timer ${!activePlayer ? 'active' : ''}`}>
{renderAvatar(red)} {renderAvatar(red)}
<i className={`fa ${!activePlayer ? 'fa-hourglass-half' : 'fa-hourglass-start'} timer-icon`} /> <i className={`fa ${!activePlayer ? 'fa-hourglass-half' : 'fa-hourglass-start'} timer-icon`} />
@@ -172,6 +179,8 @@ const GameTimer = () => {
<i className={`fa ${activePlayer ? 'fa-hourglass-half' : 'fa-hourglass-start'} timer-icon`} /> <i className={`fa ${activePlayer ? 'fa-hourglass-half' : 'fa-hourglass-start'} timer-icon`} />
<span className="timer-display">{formatTime(blueTime)}</span> <span className="timer-display">{formatTime(blueTime)}</span>
</div> </div>
<BonusBox color="blue" points={blue.bonusPoints ?? 0} onClick={openBonusDialog} />
<BonusStatsDialog open={bonusDialogOpen} onClose={closeBonusDialog} red={red} blue={blue} />
</div> </div>
); );
}; };

View File

@@ -7,12 +7,14 @@
* file that was distributed with this source code. * file that was distributed with this source code.
*/ */
import React from 'react'; import React, { Fragment, useState } from 'react';
import { useGame } from '@mine-contexts'; import { useGame } from '@mine-contexts';
import User from './User'; import User from './User';
import BonusStatsDialog from '../BonusStatsDialog';
const UserControl = ({ resign }) => { const UserControl = ({ resign }) => {
const { webPlayer, activePlayer, mines, foundMines, red, blue, onBombToggle } = useGame(); const { webPlayer, activePlayer, mines, foundMines, red, blue, onBombToggle } = useGame();
const [bonusDialogOpen, setBonusDialogOpen] = useState(false);
const activeColor = activePlayer ? 'blue' : 'red'; const activeColor = activePlayer ? 'blue' : 'red';
const resignClass = 'resign' + (activeColor !== webPlayer ? ' disabled' : ''); const resignClass = 'resign' + (activeColor !== webPlayer ? ' disabled' : '');
const minesClass = 'active-mines' + (foundMines ? ' found-mine' : ''); const minesClass = 'active-mines' + (foundMines ? ' found-mine' : '');
@@ -24,11 +26,17 @@ const UserControl = ({ resign }) => {
} }
}; };
const handleBonusClick = () => {
setBonusDialogOpen(true);
};
return ( return (
<Fragment>
<div className="users"> <div className="users">
<User <User
color="blue" webPlayer={webPlayer} {...blue} color="blue" webPlayer={webPlayer} {...blue}
onClickBombSelector={() => handleBombClick('blue', 1)} onClickBombSelector={() => handleBombClick('blue', 1)}
onBonusClick={handleBonusClick}
/> />
<div className="active-mines-container"> <div className="active-mines-container">
<i className="fa fa-star" /> <i className="fa fa-star" />
@@ -42,12 +50,20 @@ const UserControl = ({ resign }) => {
<User <User
color="red" webPlayer={webPlayer} {...red} color="red" webPlayer={webPlayer} {...red}
onClickBombSelector={() => handleBombClick('red', 0)} onClickBombSelector={() => handleBombClick('red', 0)}
onBonusClick={handleBonusClick}
/> />
<button className={resignClass} onClick={resign}> <button className={resignClass} onClick={resign}>
<div className="resign-shine" /> <div className="resign-shine" />
Resign Resign
</button> </button>
</div> </div>
<BonusStatsDialog
open={bonusDialogOpen}
onClose={() => setBonusDialogOpen(false)}
red={red}
blue={blue}
/>
</Fragment>
); );
} }

View File

@@ -132,7 +132,18 @@ export const GameProvider = ({ children }) => {
}; };
const applyStep = stepData => { 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) { if (isBomb) {
sounds.current.bomb.play(); sounds.current.bomb.play();
@@ -176,6 +187,18 @@ export const GameProvider = ({ children }) => {
syncBlue(p => ({ ...p, mines: 'blue' === player ? bp : p.mines })); 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 })); syncRed(p => ({ ...p, enabledBomb: rp <= bp }));
syncBlue(p => ({ ...p, enabledBomb: bp <= rp })); syncBlue(p => ({ ...p, enabledBomb: bp <= rp }));

View File

@@ -34,9 +34,23 @@ export const IMAGES = {
bombPos: (hor, vert) => `${IMG}bg-bomb-${hor}-${vert}-outbg.png`, 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 = { export const PLAYER_DEF = {
name: '...', desc: '', active: false, mines: 0, haveBomb: true, enabledBomb: true, name: '...', desc: '', active: false, mines: 0, haveBomb: true, enabledBomb: true,
registered: false, avatar: null, registered: false, avatar: null,
bonusPoints: 0, bonusStats: { ...BONUS_STATS_DEF },
}; };
export const DESC = { export const DESC = {

View File

@@ -7,4 +7,4 @@
* file that was distributed with this source code. * 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';

47
docs/README.md Normal file
View File

@@ -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`

View File

@@ -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

View File

@@ -62,6 +62,18 @@ class PlayedGame
#[Column(length: 7, nullable: true)] #[Column(length: 7, nullable: true)]
private ?string $resign = null; 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)] #[Column(type: Types::DATETIME_MUTABLE, nullable: true)]
private ?DateTime $created = null; private ?DateTime $created = null;
@@ -222,6 +234,46 @@ class PlayedGame
$this->resign = $resign; $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 public function getCreated(): ?DateTime
{ {
return $this->created; return $this->created;
@@ -247,3 +299,5 @@ class PlayedGame
return $this->steps; return $this->steps;
} }
} }

View File

@@ -0,0 +1,48 @@
<?php declare(strict_types=1);
/**
* 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.
*/
namespace App\Migrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Class Version20260418104430
*
* @package App\Migrations
* @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. 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');
}
}

View File

@@ -44,12 +44,12 @@ use Symfony\Component\Security\Core\User\UserInterface;
readonly class TopicManager implements TopicManagerInterface readonly class TopicManager implements TopicManagerInterface
{ {
public function __construct( public function __construct(
private EntityManagerInterface $em,
private HubInterface $hub, private HubInterface $hub,
private EntityManagerInterface $entityManager,
private LoggerInterface $logger, private LoggerInterface $logger,
private CacheManager $cacheManager,
private PlayedGameRepository $playedGameRepository, private PlayedGameRepository $playedGameRepository,
private UserRepository $userRepository, private UserRepository $userRepository,
private CacheManager $cacheManager,
) { ) {
} }
@@ -96,8 +96,8 @@ readonly class TopicManager implements TopicManagerInterface
if ($count === 1) { if ($count === 1) {
// One player waiting — mark as active and announce to the lobby // One player waiting — mark as active and announce to the lobby
$playedGame->setUpdated(new DateTime()); $playedGame->setUpdated(new DateTime());
$this->entityManager->persist($playedGame); $this->em->persist($playedGame);
$this->entityManager->flush(); $this->em->flush();
$displayName = $users['red'] ?: $users['redAnon'] ?: $users['blue'] ?: $users['blueAnon'] ?: 'Unknown'; $displayName = $users['red'] ?: $users['redAnon'] ?: $users['blue'] ?: $users['blueAnon'] ?: 'Unknown';
$this->publishToLobby([ $this->publishToLobby([
@@ -121,8 +121,8 @@ readonly class TopicManager implements TopicManagerInterface
$users = $this->getUserCollection($playedGame); $users = $this->getUserCollection($playedGame);
if ($this->getPlayerCount($users) === 1) { if ($this->getPlayerCount($users) === 1) {
$playedGame->setUpdated(new DateTime('2000-01-01 00:00:00')); $playedGame->setUpdated(new DateTime('2000-01-01 00:00:00'));
$this->entityManager->persist($playedGame); $this->em->persist($playedGame);
$this->entityManager->flush(); $this->em->flush();
$this->publishToLobby(['action' => 'leave', 'gameAssoc' => $gameAssoc]); $this->publishToLobby(['action' => 'leave', 'gameAssoc' => $gameAssoc]);
} }
} }
@@ -176,25 +176,40 @@ readonly class TopicManager implements TopicManagerInterface
$playedGame = $this->getPlayedGame($gameAssoc); $playedGame = $this->getPlayedGame($gameAssoc);
$grid = $this->loadGrid($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); $alreadyRevealed = $this->buildRevealedMap($playedGame);
// Determine which cells to reveal for this step /** Determine which cells to reveal for this step */
if ($isBomb) { if ($isBomb) {
$revealedCells = $this->getBombRevealedCells($grid, $coords[0], $coords[1], $alreadyRevealed); $revealedCells = $this->getBombRevealedCells($grid, $coords[0], $coords[1], $alreadyRevealed);
} elseif ('m' === ($grid[$coords[0]][$coords[1]] ?? null)) { } 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']]; $revealedCells = [['row' => $coords[0], 'col' => $coords[1], 'value' => 'm']];
} else { } else {
$revealedCells = $this->floodFill($grid, $coords[0], $coords[1], $alreadyRevealed); $revealedCells = $this->floodFill($grid, $coords[0], $coords[1], $alreadyRevealed);
} }
$minesFound = count(array_filter($revealedCells, static fn($c) => 'm' === $c['value'])); $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); $redPoints = ($playedGame->getRedPoints() ?? 0) + ('red' === $player ? $minesFound : 0);
$bluePoints = ($playedGame->getBluePoints() ?? 0) + ('blue' === $player ? $minesFound : 0); $bluePoints = ($playedGame->getBluePoints() ?? 0) + ('blue' === $player ? $minesFound : 0);
$gameOver = $redPoints > 25 || $bluePoints > 25; $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 = []; $leftMines = [];
if ($gameOver) { if ($gameOver) {
$finalRevealed = $alreadyRevealed; $finalRevealed = $alreadyRevealed;
@@ -204,7 +219,7 @@ readonly class TopicManager implements TopicManagerInterface
$leftMines = $this->getLeftMines($grid, $finalRevealed); $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); $users = $this->getUserCollection($playedGame);
$count = $this->getPlayerCount($users); $count = $this->getPlayerCount($users);
@@ -221,6 +236,10 @@ readonly class TopicManager implements TopicManagerInterface
'resign' => null, 'resign' => null,
'gameOver' => $gameOver, 'gameOver' => $gameOver,
'leftMines' => $leftMines, 'leftMines' => $leftMines,
'redBonusPoints' => $bonusData['redBonusPoints'],
'blueBonusPoints' => $bonusData['blueBonusPoints'],
'redBonusStats' => $bonusData['redBonusStats'],
'blueBonusStats' => $bonusData['blueBonusStats'],
]; ];
try { try {
@@ -260,6 +279,155 @@ readonly class TopicManager implements TopicManagerInterface
return $grid; 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). * BFS flood-fill starting at (row, col).
* Reveals the clicked cell plus all connected zero-value cells and their non-mine borders. * 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 = $this->getPlayedGame($gameAssoc);
$playedGame->setResign($color); $playedGame->setResign($color);
$this->entityManager->persist($playedGame); $this->em->persist($playedGame);
$this->entityManager->flush(); $this->em->flush();
} }
private function saveStepToDb( private function saveStepToDb(
@@ -425,6 +593,7 @@ readonly class TopicManager implements TopicManagerInterface
array $revealedCells, array $revealedCells,
int $redPoints, int $redPoints,
int $bluePoints, int $bluePoints,
array $bonusData = []
): void { ): void {
try { try {
$playedGame = $this->getPlayedGame($gameAssoc); $playedGame = $this->getPlayedGame($gameAssoc);
@@ -437,16 +606,24 @@ readonly class TopicManager implements TopicManagerInterface
$step->setRevealedCells($revealedCells); $step->setRevealedCells($revealedCells);
$step->setPlayedGame($playedGame); $step->setPlayedGame($playedGame);
$step->setCreated(new DateTime()); $step->setCreated(new DateTime());
$this->entityManager->persist($step); $this->em->persist($step);
$playedGame->setRedPoints($redPoints); $playedGame->setRedPoints($redPoints);
$playedGame->setBluePoints($bluePoints); $playedGame->setBluePoints($bluePoints);
$playedGame->setRedExplodedBomb((bool)$event['bomb'] && 'red' === $player ? true : null); $playedGame->setRedExplodedBomb((bool)$event['bomb'] && 'red' === $player ? true : null);
$playedGame->setBlueExplodedBomb((bool)$event['bomb'] && 'blue' === $player ? true : null); $playedGame->setBlueExplodedBomb((bool)$event['bomb'] && 'blue' === $player ? true : null);
$playedGame->setUpdated(new DateTime()); $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) { } catch (Exception $e) {
$this->logger->error($e->getMessage()); $this->logger->error($e->getMessage());
} }
@@ -465,8 +642,8 @@ readonly class TopicManager implements TopicManagerInterface
? $this->saveRegisteredUser($userName, $count, $playedGame) ? $this->saveRegisteredUser($userName, $count, $playedGame)
: $this->saveAnonUser($userName, $count, $playedGame, $request); : $this->saveAnonUser($userName, $count, $playedGame, $request);
$this->entityManager->persist($playedGame); $this->em->persist($playedGame);
$this->entityManager->flush(); $this->em->flush();
return $this->getUserCollection($playedGame); return $this->getUserCollection($playedGame);
} }
@@ -499,7 +676,7 @@ readonly class TopicManager implements TopicManagerInterface
$anon->setCountry($this->extractCountry($request)); $anon->setCountry($this->extractCountry($request));
$anon->setUserAgent($request->headers->get('User-Agent')); $anon->setUserAgent($request->headers->get('User-Agent'));
$anon->setConnTimestamp(new DateTime()); $anon->setConnTimestamp(new DateTime());
$this->entityManager->persist($anon); $this->em->persist($anon);
if ($count === 1) { if ($count === 1) {
$random = random_int(0, 1); $random = random_int(0, 1);