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

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

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
{
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);