new: usr: add initialization bonus points' system to the gameplay #5
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
48
src/Migrations/2026/04/Version20260418104430.php
Normal file
48
src/Migrations/2026/04/Version20260418104430.php
Normal 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');
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user