chg: dev: create the UserStats entity what is a Materialized View to store Profile stats for every user - & massive ProfileController refactor #8
This commit is contained in:
@@ -14,6 +14,8 @@ doctrine:
|
||||
enable_native_lazy_objects: true
|
||||
naming_strategy: doctrine.orm.naming_strategy.underscore
|
||||
auto_mapping: true
|
||||
schema_ignore_classes:
|
||||
- App\Entity\UserStats
|
||||
mappings:
|
||||
App:
|
||||
is_bundle: false
|
||||
|
||||
@@ -10,12 +10,18 @@
|
||||
|
||||
namespace App\Controller;
|
||||
|
||||
use App\Dto\BattleShareDto;
|
||||
use App\Dto\ProfileChartDataFactory;
|
||||
use App\Dto\ProfileGameDto;
|
||||
use App\Dto\ProfileGameDtoFactory;
|
||||
use App\Dto\ProfileStatsDto;
|
||||
use App\Dto\ProfileViewDto;
|
||||
use App\Entity\PlayedGame;
|
||||
use App\Entity\User;
|
||||
use App\Repository\PlayedGameRepository;
|
||||
use App\Repository\UserStatsRepository;
|
||||
use App\Service\BattleCardGenerator;
|
||||
use App\Service\WebAuthnService;
|
||||
use DateTime;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use League\Flysystem\FilesystemException;
|
||||
use League\Flysystem\FilesystemOperator;
|
||||
@@ -50,165 +56,41 @@ use function count;
|
||||
class ProfileController extends AbstractController
|
||||
{
|
||||
public function __construct(
|
||||
private readonly PlayedGameRepository $repo,
|
||||
private readonly WebAuthnService $webAuthnService,
|
||||
private readonly LoggerInterface $logger,
|
||||
private readonly LoggerInterface $logger,
|
||||
private readonly PlayedGameRepository $repo,
|
||||
private readonly UserStatsRepository $userStatsRepo,
|
||||
private readonly WebAuthnService $webAuthnService,
|
||||
private readonly ProfileGameDtoFactory $profileGameDtoFactory,
|
||||
private readonly ProfileChartDataFactory $profileChartDataFactory,
|
||||
) {
|
||||
}
|
||||
|
||||
#[Route('/profile', name: 'MineSeekerBundle_profile')]
|
||||
public function index(CacheManager $cacheManager): Response
|
||||
public function index(): Response
|
||||
{
|
||||
/** @var User $user */
|
||||
$user = $this->getUser();
|
||||
$this->denyAccessUnlessGranted('IS_AUTHENTICATED_REMEMBERED');
|
||||
|
||||
$total = $this->repo->countFinishedForUser($user);
|
||||
$wins = $this->repo->countWinsForUser($user);
|
||||
$losses = $this->repo->countLossesForUser($user);
|
||||
$draws = $this->repo->countDrawsForUser($user);
|
||||
|
||||
/** Build monthly buckets for the last 6 months */
|
||||
$monthlyData = [];
|
||||
for ($i = 5; $i >= 0; $i--) {
|
||||
$dt = new DateTime("first day of -$i months midnight");
|
||||
$key = $dt->format('Y-m');
|
||||
$monthlyData[$key] = ['label' => $dt->format('M'), 'wins' => 0, 'losses' => 0, 'draws' => 0];
|
||||
}
|
||||
|
||||
$since = new DateTime('first day of -5 months midnight');
|
||||
$recentGames = $this->repo->findFinishedForUserSince($user, $since);
|
||||
$userId = $user->id;
|
||||
$stats = ProfileStatsDto::fromUserStats($this->userStatsRepo->findByUserId($userId));
|
||||
$recent = $this->repo->findRecentFinishedForUser($user, 30);
|
||||
|
||||
foreach ($recentGames as $game) {
|
||||
if (!$game->updated) {
|
||||
continue;
|
||||
}
|
||||
$gamesData = array_map(
|
||||
fn(PlayedGame $game): ProfileGameDto => $this->profileGameDtoFactory->create($game, $userId),
|
||||
$recent,
|
||||
);
|
||||
|
||||
$month = $game->updated->format('Y-m');
|
||||
if (!isset($monthlyData[$month])) {
|
||||
continue;
|
||||
}
|
||||
$chartData = $this->profileChartDataFactory->buildChartData($user, $userId, $stats);
|
||||
|
||||
$isRed = $game->red?->id === $userId;
|
||||
$myPts = $isRed ? $game->redPoints : $game->bluePoints;
|
||||
$oppPts = $isRed ? $game->bluePoints : $game->redPoints;
|
||||
$resign = $game->resign;
|
||||
$myColor = $isRed ? 'red' : 'blue';
|
||||
$oppColor = $isRed ? 'blue' : 'red';
|
||||
$view = new ProfileViewDto(
|
||||
stats: $stats,
|
||||
recent: $recent,
|
||||
gamesData: $gamesData,
|
||||
chartData: $chartData,
|
||||
);
|
||||
|
||||
$result = 'draws';
|
||||
if ($resign === $myColor) {
|
||||
$result = 'losses';
|
||||
} elseif ($resign === $oppColor) {
|
||||
$result = 'wins';
|
||||
} elseif ($myPts !== null && $oppPts !== null) {
|
||||
if ($myPts > $oppPts) $result = 'wins';
|
||||
elseif ($myPts < $oppPts) $result = 'losses';
|
||||
}
|
||||
|
||||
$monthlyData[$month][$result]++;
|
||||
}
|
||||
|
||||
$months = array_column(array_values($monthlyData), 'label');
|
||||
|
||||
$bonus = $this->repo->findBonusStatsForUser($user);
|
||||
|
||||
return $this->render('Security/profile.html.twig', [
|
||||
'stats' => [
|
||||
'total' => $total,
|
||||
'wins' => $wins,
|
||||
'losses' => $losses,
|
||||
'draws' => $draws,
|
||||
'minesHit' => $this->repo->findTotalMinesForUser($user),
|
||||
'winRate' => $total > 0 ? (int)round($wins / $total * 100) : 0,
|
||||
'avgScore' => $this->repo->findAvgScoreForUser($user),
|
||||
'bonusPoints' => $bonus['totalBonusPoints'],
|
||||
'avgBonus' => $bonus['avgBonusPoints'],
|
||||
'bestChain' => $bonus['bestChain'],
|
||||
'blindHits' => $bonus['totalBlindHits'],
|
||||
'edgeMines' => $bonus['totalEdgeMines'],
|
||||
],
|
||||
'recent' => ($recent = $this->repo->findRecentFinishedForUser($user, 30)),
|
||||
'gamesData' => array_map(function (PlayedGame $game) use ($userId, $cacheManager): array {
|
||||
$isRed = $game->red?->id === $userId;
|
||||
$resign = $game->resign;
|
||||
$myColor = $isRed ? 'red' : 'blue';
|
||||
$oppColor = $isRed ? 'blue' : 'red';
|
||||
$myPts = $isRed ? $game->redPoints : $game->bluePoints;
|
||||
$oppPts = $isRed ? $game->bluePoints : $game->redPoints;
|
||||
$result = 'draw';
|
||||
|
||||
if ($resign === $myColor) $result = 'loss';
|
||||
elseif ($resign === $oppColor) $result = 'win';
|
||||
elseif ($myPts !== null && $oppPts !== null) {
|
||||
if ($myPts > $oppPts) $result = 'win';
|
||||
elseif ($myPts < $oppPts) $result = 'loss';
|
||||
}
|
||||
|
||||
$redAvatarPath = $game->red?->avatarPath;
|
||||
$blueAvatarPath = $game->blue?->avatarPath;
|
||||
|
||||
return [
|
||||
'id' => $game->id,
|
||||
'uuid' => $game->uuid?->toRfc4122(),
|
||||
'redName' =>
|
||||
$game->red?->getUsername() ?? $game->redAnon?->userName ?? 'Guest',
|
||||
'blueName' =>
|
||||
$game->blue?->getUsername() ?? $game->blueAnon?->userName ?? 'Guest',
|
||||
'redAvatar' => $redAvatarPath ? $cacheManager->generateUrl($redAvatarPath, 'avatar_thumb') : null,
|
||||
'blueAvatar' => $blueAvatarPath ? $cacheManager->generateUrl($blueAvatarPath, 'avatar_thumb') : null,
|
||||
'redPoints' => $game->redPoints,
|
||||
'bluePoints' => $game->bluePoints,
|
||||
'redExplodedBomb' => $game->redExplodedBomb,
|
||||
'blueExplodedBomb' => $game->blueExplodedBomb,
|
||||
'resign' => $resign,
|
||||
'created' => $game->created?->format('Y-m-d H:i'),
|
||||
'date' => $game->updated?->format('Y-m-d H:i'),
|
||||
'isRed' => $isRed,
|
||||
'result' => $result,
|
||||
'myPoints' => $myPts,
|
||||
'oppPoints' => $oppPts,
|
||||
'redBonusPoints' => $game->redBonusPoints ?? 0,
|
||||
'blueBonusPoints' => $game->blueBonusPoints ?? 0,
|
||||
'redBonusStats' => $game->redBonusStats ?? [],
|
||||
'blueBonusStats' => $game->blueBonusStats ?? [],
|
||||
];
|
||||
}, $recent),
|
||||
'chartData' => [
|
||||
'months' => $months,
|
||||
'wins' => array_column(array_values($monthlyData), 'wins'),
|
||||
'losses' => array_column(array_values($monthlyData), 'losses'),
|
||||
'draws' => array_column(array_values($monthlyData), 'draws'),
|
||||
'pieWins' => $wins,
|
||||
'pieLosses' => $losses,
|
||||
'pieDraws' => $draws,
|
||||
'recentGames' => $this->buildRecentGamesSeries($user, $userId),
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build per-game data for the last 15 finished games, oldest → newest.
|
||||
*
|
||||
* @return array{labels:string[],mines:int[],bonus:float[]}
|
||||
*/
|
||||
private function buildRecentGamesSeries(User $user, int $userId): array
|
||||
{
|
||||
$recent = $this->repo->findRecentFinishedForUser($user, 15);
|
||||
$recent = array_reverse($recent);
|
||||
|
||||
$labels = [];
|
||||
$mines = [];
|
||||
$bonus = [];
|
||||
foreach ($recent as $i => $game) {
|
||||
$isRed = $game->red?->id === $userId;
|
||||
$labels[] = '#' . ($i + 1);
|
||||
$mines[] = (int)($isRed ? $game->redPoints : $game->bluePoints);
|
||||
$bonus[] = (float)($isRed ? $game->redBonusPoints : $game->blueBonusPoints) ?: 0;
|
||||
}
|
||||
|
||||
return ['labels' => $labels, 'mines' => $mines, 'bonus' => $bonus];
|
||||
return $this->render('Security/profile.html.twig', $view->toTemplateContext());
|
||||
}
|
||||
|
||||
#[Route(
|
||||
@@ -219,55 +101,13 @@ class ProfileController extends AbstractController
|
||||
)]
|
||||
public function battleShare(Uuid $uuid): Response
|
||||
{
|
||||
$game = $this->repo->findOneBy(['uuid' => $uuid]);
|
||||
$game = $this->repo->findOneByUuid($uuid);
|
||||
|
||||
if (!$game) {
|
||||
throw $this->createNotFoundException('Battle not found.');
|
||||
}
|
||||
|
||||
$redName = $game->red?->getUsername() ?? ($game->redAnon !== null ? 'Anonymous' : 'Guest');
|
||||
$blueName = $game->blue?->getUsername() ?? ($game->blueAnon !== null ? 'Anonymous' : 'Guest');
|
||||
$redPts = $game->redPoints;
|
||||
$bluePts = $game->bluePoints;
|
||||
$resign = $game->resign;
|
||||
$redAvatar = $game->red?->avatarPath;
|
||||
$blueAvatar = $game->blue?->avatarPath;
|
||||
$redBonusPoints = $game->redBonusPoints ?? 0;
|
||||
$blueBonusPoints = $game->blueBonusPoints ?? 0;
|
||||
$redBonusStats = $game->redBonusStats ?? [];
|
||||
$blueBonusStats = $game->blueBonusStats ?? [];
|
||||
|
||||
if ($resign === 'red') {
|
||||
$summary = "$redName resigned — $blueName wins";
|
||||
} elseif ($resign === 'blue') {
|
||||
$summary = "$blueName resigned — $redName wins";
|
||||
} elseif ($redPts !== null && $bluePts !== null) {
|
||||
if ($redPts > $bluePts) {
|
||||
$summary = "$redName defeated $blueName ($redPts – $bluePts)";
|
||||
} elseif ($bluePts > $redPts) {
|
||||
$summary = "$blueName defeated $redName ($bluePts – $redPts)";
|
||||
} else {
|
||||
$summary = "$redName and $blueName drew ($redPts – $bluePts)";
|
||||
}
|
||||
} else {
|
||||
$summary = "$redName vs $blueName";
|
||||
}
|
||||
|
||||
return $this->render('Game/battle_share.html.twig', [
|
||||
'game' => $game,
|
||||
'redName' => $redName,
|
||||
'blueName' => $blueName,
|
||||
'redPts' => $redPts,
|
||||
'bluePts' => $bluePts,
|
||||
'resign' => $resign,
|
||||
'redAvatar' => $redAvatar,
|
||||
'blueAvatar' => $blueAvatar,
|
||||
'redBonusPoints' => $redBonusPoints,
|
||||
'blueBonusPoints' => $blueBonusPoints,
|
||||
'redBonusStats' => $redBonusStats,
|
||||
'blueBonusStats' => $blueBonusStats,
|
||||
'ogTitle' => "MineSeeker · $summary",
|
||||
'ogDesc' => "Watch the battle replay: $summary — played on MineSeeker, the multiplayer minesweeper.",
|
||||
]);
|
||||
return $this->render('Game/battle_share.html.twig', BattleShareDto::fromPlayedGame($game)->toTemplateContext());
|
||||
}
|
||||
|
||||
#[Route(
|
||||
@@ -306,6 +146,7 @@ class ProfileController extends AbstractController
|
||||
$user = $this->getUser();
|
||||
|
||||
$file = $request->files->get('avatar');
|
||||
|
||||
if (!$file instanceof UploadedFile) {
|
||||
return $this->json(['error' => 'No file uploaded.'], 400);
|
||||
}
|
||||
|
||||
105
src/Dto/BattleShareDto.php
Normal file
105
src/Dto/BattleShareDto.php
Normal file
@@ -0,0 +1,105 @@
|
||||
<?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\Dto;
|
||||
|
||||
use App\Entity\PlayedGame;
|
||||
|
||||
/**
|
||||
* Class BattleShareDto
|
||||
*
|
||||
* @package App\Dto
|
||||
* @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. 20.
|
||||
*/
|
||||
final readonly class BattleShareDto
|
||||
{
|
||||
public function __construct(
|
||||
public PlayedGame $game,
|
||||
public string $redName,
|
||||
public string $blueName,
|
||||
public ?int $redPts,
|
||||
public ?int $bluePts,
|
||||
public ?string $resign,
|
||||
public ?string $redAvatar,
|
||||
public ?string $blueAvatar,
|
||||
public float $redBonusPoints,
|
||||
public float $blueBonusPoints,
|
||||
public array $redBonusStats,
|
||||
public array $blueBonusStats,
|
||||
public string $ogTitle,
|
||||
public string $ogDesc,
|
||||
) {
|
||||
}
|
||||
|
||||
public static function fromPlayedGame(PlayedGame $game): self
|
||||
{
|
||||
$redName = $game->red?->getUsername() ?? ($game->redAnon !== null ? 'Anonymous' : 'Guest');
|
||||
$blueName = $game->blue?->getUsername() ?? ($game->blueAnon !== null ? 'Anonymous' : 'Guest');
|
||||
$redPts = $game->redPoints;
|
||||
$bluePts = $game->bluePoints;
|
||||
$resign = $game->resign;
|
||||
|
||||
$summary = self::buildSummary($redName, $blueName, $redPts, $bluePts, $resign);
|
||||
|
||||
return new self(
|
||||
game: $game,
|
||||
redName: $redName,
|
||||
blueName: $blueName,
|
||||
redPts: $redPts,
|
||||
bluePts: $bluePts,
|
||||
resign: $resign,
|
||||
redAvatar: $game->red?->avatarPath,
|
||||
blueAvatar: $game->blue?->avatarPath,
|
||||
redBonusPoints: (float)($game->redBonusPoints ?? 0),
|
||||
blueBonusPoints: (float)($game->blueBonusPoints ?? 0),
|
||||
redBonusStats: $game->redBonusStats ?? [],
|
||||
blueBonusStats: $game->blueBonusStats ?? [],
|
||||
ogTitle: "MineSeeker · $summary",
|
||||
ogDesc: "Watch the battle replay: $summary — played on MineSeeker, the multiplayer minesweeper.",
|
||||
);
|
||||
}
|
||||
|
||||
private static function buildSummary(
|
||||
string $redName,
|
||||
string $blueName,
|
||||
?int $redPts,
|
||||
?int $bluePts,
|
||||
?string $resign,
|
||||
): string {
|
||||
if ($resign === 'red') {
|
||||
return "$redName resigned — $blueName wins";
|
||||
}
|
||||
|
||||
if ($resign === 'blue') {
|
||||
return "$blueName resigned — $redName wins";
|
||||
}
|
||||
|
||||
if ($redPts !== null && $bluePts !== null) {
|
||||
if ($redPts > $bluePts) {
|
||||
return "$redName defeated $blueName ($redPts – $bluePts)";
|
||||
}
|
||||
if ($bluePts > $redPts) {
|
||||
return "$blueName defeated $redName ($bluePts – $redPts)";
|
||||
}
|
||||
return "$redName and $blueName drew ($redPts – $bluePts)";
|
||||
}
|
||||
|
||||
return "$redName vs $blueName";
|
||||
}
|
||||
|
||||
public function toTemplateContext(): array
|
||||
{
|
||||
return get_object_vars($this);
|
||||
}
|
||||
}
|
||||
43
src/Dto/ProfileChartDataDto.php
Normal file
43
src/Dto/ProfileChartDataDto.php
Normal file
@@ -0,0 +1,43 @@
|
||||
<?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\Dto;
|
||||
|
||||
use JsonSerializable;
|
||||
|
||||
/**
|
||||
* Class ProfileChartDataDto
|
||||
*
|
||||
* @package App\Dto
|
||||
* @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. 20.
|
||||
*/
|
||||
final readonly class ProfileChartDataDto implements JsonSerializable
|
||||
{
|
||||
public function __construct(
|
||||
public array $months,
|
||||
public array $wins,
|
||||
public array $losses,
|
||||
public array $draws,
|
||||
public int $pieWins,
|
||||
public int $pieLosses,
|
||||
public int $pieDraws,
|
||||
public array $recentGames,
|
||||
) {
|
||||
}
|
||||
|
||||
public function jsonSerialize(): array
|
||||
{
|
||||
return get_object_vars($this);
|
||||
}
|
||||
}
|
||||
111
src/Dto/ProfileChartDataFactory.php
Normal file
111
src/Dto/ProfileChartDataFactory.php
Normal file
@@ -0,0 +1,111 @@
|
||||
<?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\Dto;
|
||||
|
||||
use App\Entity\User;
|
||||
use App\Repository\PlayedGameRepository;
|
||||
use DateTime;
|
||||
|
||||
/**
|
||||
* Class ProfileChartDataFactory
|
||||
*
|
||||
* @package App\Dto
|
||||
* @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. 20.
|
||||
*/
|
||||
readonly class ProfileChartDataFactory
|
||||
{
|
||||
public function __construct(private PlayedGameRepository $repo) { }
|
||||
|
||||
public function buildChartData(User $user, int $userId, ProfileStatsDto $stats): ProfileChartDataDto
|
||||
{
|
||||
$monthlyData = $this->buildMonthlyData($user, $userId);
|
||||
|
||||
return new ProfileChartDataDto(
|
||||
months: array_column(array_values($monthlyData), 'label'),
|
||||
wins: array_column(array_values($monthlyData), 'wins'),
|
||||
losses: array_column(array_values($monthlyData), 'losses'),
|
||||
draws: array_column(array_values($monthlyData), 'draws'),
|
||||
pieWins: $stats->wins,
|
||||
pieLosses: $stats->losses,
|
||||
pieDraws: $stats->draws,
|
||||
recentGames: $this->buildRecentGamesSeries($user, $userId),
|
||||
);
|
||||
}
|
||||
|
||||
private function buildMonthlyData(User $user, int $userId): array
|
||||
{
|
||||
$monthlyData = [];
|
||||
|
||||
for ($i = 5; $i >= 0; $i--) {
|
||||
$dt = new DateTime("first day of -$i months midnight");
|
||||
$key = $dt->format('Y-m');
|
||||
$monthlyData[$key] = ['label' => $dt->format('M'), 'wins' => 0, 'losses' => 0, 'draws' => 0];
|
||||
}
|
||||
|
||||
$since = new DateTime('first day of -5 months midnight');
|
||||
$recentGames = $this->repo->findFinishedForUserSince($user, $since);
|
||||
|
||||
foreach ($recentGames as $game) {
|
||||
if (!$game->updated) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$month = $game->updated->format('Y-m');
|
||||
if (!isset($monthlyData[$month])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$isRed = $game->red?->id === $userId;
|
||||
$myPts = $isRed ? $game->redPoints : $game->bluePoints;
|
||||
$oppPts = $isRed ? $game->bluePoints : $game->redPoints;
|
||||
$resign = $game->resign;
|
||||
$myColor = $isRed ? 'red' : 'blue';
|
||||
$oppColor = $isRed ? 'blue' : 'red';
|
||||
|
||||
$result = 'draws';
|
||||
if ($resign === $myColor) {
|
||||
$result = 'losses';
|
||||
} elseif ($resign === $oppColor) {
|
||||
$result = 'wins';
|
||||
} elseif ($myPts !== null && $oppPts !== null) {
|
||||
if ($myPts > $oppPts) $result = 'wins';
|
||||
elseif ($myPts < $oppPts) $result = 'losses';
|
||||
}
|
||||
|
||||
$monthlyData[$month][$result]++;
|
||||
}
|
||||
|
||||
return $monthlyData;
|
||||
}
|
||||
|
||||
private function buildRecentGamesSeries(User $user, int $userId): array
|
||||
{
|
||||
$recent = $this->repo->findRecentFinishedForUser($user, 15);
|
||||
$recent = array_reverse($recent);
|
||||
|
||||
$labels = [];
|
||||
$mines = [];
|
||||
$bonus = [];
|
||||
|
||||
foreach ($recent as $i => $game) {
|
||||
$isRed = $game->red?->id === $userId;
|
||||
$labels[] = '#' . ($i + 1);
|
||||
$mines[] = (int)($isRed ? $game->redPoints : $game->bluePoints);
|
||||
$bonus[] = (float)($isRed ? $game->redBonusPoints : $game->blueBonusPoints) ?: 0;
|
||||
}
|
||||
|
||||
return ['labels' => $labels, 'mines' => $mines, 'bonus' => $bonus];
|
||||
}
|
||||
}
|
||||
56
src/Dto/ProfileGameDto.php
Normal file
56
src/Dto/ProfileGameDto.php
Normal file
@@ -0,0 +1,56 @@
|
||||
<?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\Dto;
|
||||
|
||||
use JsonSerializable;
|
||||
|
||||
/**
|
||||
* Class ProfileGameDto
|
||||
*
|
||||
* @package App\Dto
|
||||
* @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. 20.
|
||||
*/
|
||||
final readonly class ProfileGameDto implements JsonSerializable
|
||||
{
|
||||
public function __construct(
|
||||
public ?int $id,
|
||||
public ?string $uuid,
|
||||
public string $redName,
|
||||
public string $blueName,
|
||||
public ?string $redAvatar,
|
||||
public ?string $blueAvatar,
|
||||
public ?int $redPoints,
|
||||
public ?int $bluePoints,
|
||||
public ?bool $redExplodedBomb,
|
||||
public ?bool $blueExplodedBomb,
|
||||
public ?string $resign,
|
||||
public ?string $created,
|
||||
public ?string $date,
|
||||
public bool $isRed,
|
||||
public string $result,
|
||||
public ?int $myPoints,
|
||||
public ?int $oppPoints,
|
||||
public float $redBonusPoints,
|
||||
public float $blueBonusPoints,
|
||||
public array $redBonusStats,
|
||||
public array $blueBonusStats,
|
||||
) {
|
||||
}
|
||||
|
||||
public function jsonSerialize(): array
|
||||
{
|
||||
return get_object_vars($this);
|
||||
}
|
||||
}
|
||||
94
src/Dto/ProfileGameDtoFactory.php
Normal file
94
src/Dto/ProfileGameDtoFactory.php
Normal file
@@ -0,0 +1,94 @@
|
||||
<?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\Dto;
|
||||
|
||||
use App\Entity\PlayedGame;
|
||||
use Liip\ImagineBundle\Imagine\Cache\CacheManager;
|
||||
|
||||
/**
|
||||
* Class ProfileGameDtoFactory
|
||||
*
|
||||
* @package App\Dto
|
||||
* @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. 20.
|
||||
*/
|
||||
final readonly class ProfileGameDtoFactory
|
||||
{
|
||||
public function __construct(private CacheManager $cacheManager) { }
|
||||
|
||||
public function create(PlayedGame $game, int $userId): ProfileGameDto
|
||||
{
|
||||
$isRed = $game->red?->id === $userId;
|
||||
$resign = $game->resign;
|
||||
$myColor = $isRed ? 'red' : 'blue';
|
||||
$oppColor = $isRed ? 'blue' : 'red';
|
||||
$myPts = $isRed ? $game->redPoints : $game->bluePoints;
|
||||
$oppPts = $isRed ? $game->bluePoints : $game->redPoints;
|
||||
|
||||
$redAvatarPath = $game->red?->avatarPath;
|
||||
$blueAvatarPath = $game->blue?->avatarPath;
|
||||
|
||||
return new ProfileGameDto(
|
||||
id: $game->id,
|
||||
uuid: $game->uuid?->toRfc4122(),
|
||||
redName: $game->red?->getUsername() ?? $game->redAnon?->userName ?? 'Guest',
|
||||
blueName: $game->blue?->getUsername() ?? $game->blueAnon?->userName ?? 'Guest',
|
||||
redAvatar: $redAvatarPath ? $this->cacheManager->generateUrl($redAvatarPath, 'avatar_thumb') : null,
|
||||
blueAvatar: $blueAvatarPath ? $this->cacheManager->generateUrl($blueAvatarPath, 'avatar_thumb') : null,
|
||||
redPoints: $game->redPoints,
|
||||
bluePoints: $game->bluePoints,
|
||||
redExplodedBomb: $game->redExplodedBomb,
|
||||
blueExplodedBomb: $game->blueExplodedBomb,
|
||||
resign: $resign,
|
||||
created: $game->created?->format('Y-m-d H:i'),
|
||||
date: $game->updated?->format('Y-m-d H:i'),
|
||||
isRed: $isRed,
|
||||
result: $this->resolveResult($resign, $myColor, $oppColor, $myPts, $oppPts),
|
||||
myPoints: $myPts,
|
||||
oppPoints: $oppPts,
|
||||
redBonusPoints: $game->redBonusPoints ?? 0.0,
|
||||
blueBonusPoints: $game->blueBonusPoints ?? 0.0,
|
||||
redBonusStats: $game->redBonusStats ?? [],
|
||||
blueBonusStats: $game->blueBonusStats ?? [],
|
||||
);
|
||||
}
|
||||
|
||||
private function resolveResult(
|
||||
?string $resign,
|
||||
string $myColor,
|
||||
string $oppColor,
|
||||
?int $myPts,
|
||||
?int $oppPts,
|
||||
): string {
|
||||
if ($resign === $myColor) {
|
||||
return 'loss';
|
||||
}
|
||||
|
||||
if ($resign === $oppColor) {
|
||||
return 'win';
|
||||
}
|
||||
|
||||
if ($myPts !== null && $oppPts !== null) {
|
||||
if ($myPts > $oppPts) {
|
||||
return 'win';
|
||||
}
|
||||
|
||||
if ($myPts < $oppPts) {
|
||||
return 'loss';
|
||||
}
|
||||
}
|
||||
|
||||
return 'draw';
|
||||
}
|
||||
}
|
||||
82
src/Dto/ProfileStatsDto.php
Normal file
82
src/Dto/ProfileStatsDto.php
Normal file
@@ -0,0 +1,82 @@
|
||||
<?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\Dto;
|
||||
|
||||
use App\Entity\UserStats;
|
||||
|
||||
/**
|
||||
* Class ProfileStatsDto
|
||||
*
|
||||
* @package App\Dto
|
||||
* @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. 20.
|
||||
*/
|
||||
final readonly class ProfileStatsDto
|
||||
{
|
||||
public function __construct(
|
||||
public int $total,
|
||||
public int $wins,
|
||||
public int $losses,
|
||||
public int $draws,
|
||||
public int $minesHit,
|
||||
public int $winRate,
|
||||
public int $avgScore,
|
||||
public float $bonusPoints,
|
||||
public float $avgBonus,
|
||||
public int $bestChain,
|
||||
public int $blindHits,
|
||||
public int $edgeMines,
|
||||
) {
|
||||
}
|
||||
|
||||
public static function fromUserStats(?UserStats $stats): self
|
||||
{
|
||||
if ($stats === null) {
|
||||
return self::empty();
|
||||
}
|
||||
|
||||
return new self(
|
||||
total: $stats->totalGames,
|
||||
wins: $stats->wins,
|
||||
losses: $stats->losses,
|
||||
draws: $stats->draws,
|
||||
minesHit: $stats->totalMines,
|
||||
winRate: $stats->getWinRate(),
|
||||
avgScore: $stats->getAvgScore(),
|
||||
bonusPoints: (float)$stats->totalBonusPoints,
|
||||
avgBonus: (float)$stats->avgBonus,
|
||||
bestChain: $stats->bestChain,
|
||||
blindHits: $stats->blindHits,
|
||||
edgeMines: $stats->edgeMines,
|
||||
);
|
||||
}
|
||||
|
||||
public static function empty(): self
|
||||
{
|
||||
return new self(
|
||||
total: 0,
|
||||
wins: 0,
|
||||
losses: 0,
|
||||
draws: 0,
|
||||
minesHit: 0,
|
||||
winRate: 0,
|
||||
avgScore: 0,
|
||||
bonusPoints: 0.0,
|
||||
avgBonus: 0.0,
|
||||
bestChain: 0,
|
||||
blindHits: 0,
|
||||
edgeMines: 0,
|
||||
);
|
||||
}
|
||||
}
|
||||
37
src/Dto/ProfileViewDto.php
Normal file
37
src/Dto/ProfileViewDto.php
Normal file
@@ -0,0 +1,37 @@
|
||||
<?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\Dto;
|
||||
|
||||
/**
|
||||
* Class ProfileViewDto
|
||||
*
|
||||
* @package App\Dto
|
||||
* @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. 20.
|
||||
*/
|
||||
final readonly class ProfileViewDto
|
||||
{
|
||||
public function __construct(
|
||||
public ProfileStatsDto $stats,
|
||||
public array $recent,
|
||||
public array $gamesData,
|
||||
public ProfileChartDataDto $chartData,
|
||||
) {
|
||||
}
|
||||
|
||||
public function toTemplateContext(): array
|
||||
{
|
||||
return get_object_vars($this);
|
||||
}
|
||||
}
|
||||
89
src/Entity/UserStats.php
Normal file
89
src/Entity/UserStats.php
Normal file
@@ -0,0 +1,89 @@
|
||||
<?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\Entity;
|
||||
|
||||
use App\Repository\UserStatsRepository;
|
||||
use DateTime;
|
||||
use Doctrine\DBAL\Types\Types;
|
||||
use Doctrine\ORM\Mapping\Column;
|
||||
use Doctrine\ORM\Mapping\Entity;
|
||||
use Doctrine\ORM\Mapping\Id;
|
||||
use Doctrine\ORM\Mapping\Table;
|
||||
|
||||
/**
|
||||
* Class UserStats
|
||||
*
|
||||
* Read-only entity mapped to the user_stats materialized view.
|
||||
*
|
||||
* @package App\Entity
|
||||
* @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. 20.
|
||||
*/
|
||||
#[Entity(repositoryClass: UserStatsRepository::class, readOnly: true), Table]
|
||||
class UserStats
|
||||
{
|
||||
#[Id, Column(name: 'user_id')]
|
||||
public int $userId = 0;
|
||||
|
||||
#[Column]
|
||||
public int $totalGames = 0;
|
||||
|
||||
#[Column]
|
||||
public int $wins = 0;
|
||||
|
||||
#[Column]
|
||||
public int $losses = 0;
|
||||
|
||||
#[Column]
|
||||
public int $draws = 0;
|
||||
|
||||
#[Column]
|
||||
public int $totalMines = 0;
|
||||
|
||||
#[Column(type: Types::DECIMAL, precision: 10, scale: 1)]
|
||||
public string $totalBonusPoints = '0.0';
|
||||
|
||||
#[Column(type: Types::DECIMAL, precision: 10, scale: 1)]
|
||||
public string $avgBonus = '0.0';
|
||||
|
||||
#[Column]
|
||||
public int $bestChain = 0;
|
||||
|
||||
#[Column]
|
||||
public int $blindHits = 0;
|
||||
|
||||
#[Column]
|
||||
public int $edgeMines = 0;
|
||||
|
||||
#[Column]
|
||||
public int $gamesWithScores = 0;
|
||||
|
||||
#[Column(type: Types::DATETIME_MUTABLE, nullable: true)]
|
||||
public ?DateTime $lastGameAt = null;
|
||||
|
||||
|
||||
public function getWinRate(): int
|
||||
{
|
||||
return $this->gamesWithScores > 0
|
||||
? (int)round($this->wins / $this->gamesWithScores * 100)
|
||||
: 0;
|
||||
}
|
||||
|
||||
public function getAvgScore(): int
|
||||
{
|
||||
return $this->gamesWithScores > 0
|
||||
? (int)round($this->totalMines / $this->gamesWithScores)
|
||||
: 0;
|
||||
}
|
||||
}
|
||||
117
src/Migrations/2026/04/Version20260420120000.php
Normal file
117
src/Migrations/2026/04/Version20260420120000.php
Normal file
@@ -0,0 +1,117 @@
|
||||
<?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 Version20260420120000
|
||||
*
|
||||
* Creates user_stats materialized view for ProfileController optimization.
|
||||
*
|
||||
* @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. 20.
|
||||
*/
|
||||
final class Version20260420120000 extends AbstractMigration
|
||||
{
|
||||
public function getDescription(): string
|
||||
{
|
||||
return 'Create user_stats materialized view for profile statistics.';
|
||||
}
|
||||
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
$this->addSql('
|
||||
CREATE MATERIALIZED VIEW user_stats AS
|
||||
SELECT
|
||||
u.id AS user_id,
|
||||
COUNT(CASE WHEN pg.red_id = u.id OR pg.blue_id = u.id THEN 1 END) AS total_games,
|
||||
COUNT(CASE WHEN
|
||||
pg.red_id = u.id AND (pg.red_points > pg.blue_points AND pg.resign IS NULL)
|
||||
OR pg.blue_id = u.id AND (pg.blue_points > pg.red_points AND pg.resign IS NULL)
|
||||
OR pg.red_id = u.id AND pg.resign = \'blue\'
|
||||
OR pg.blue_id = u.id AND pg.resign = \'red\'
|
||||
THEN 1 END) AS wins,
|
||||
COUNT(CASE WHEN
|
||||
pg.red_id = u.id AND (pg.blue_points > pg.red_points AND pg.resign IS NULL)
|
||||
OR pg.blue_id = u.id AND (pg.red_points > pg.blue_points AND pg.resign IS NULL)
|
||||
OR pg.red_id = u.id AND pg.resign = \'red\'
|
||||
OR pg.blue_id = u.id AND pg.resign = \'blue\'
|
||||
THEN 1 END) AS losses,
|
||||
COUNT(CASE WHEN
|
||||
(pg.red_id = u.id OR pg.blue_id = u.id)
|
||||
AND pg.red_points IS NOT NULL
|
||||
AND pg.blue_points IS NOT NULL
|
||||
AND pg.resign IS NULL
|
||||
AND pg.red_points = pg.blue_points
|
||||
THEN 1 END) AS draws,
|
||||
COALESCE(SUM(
|
||||
CASE WHEN pg.red_id = u.id THEN pg.red_points ELSE pg.blue_points END
|
||||
), 0) AS total_mines,
|
||||
COALESCE(SUM(
|
||||
CASE WHEN pg.red_id = u.id THEN pg.red_bonus_points ELSE pg.blue_bonus_points END
|
||||
), 0)::numeric(10,1) AS total_bonus_points,
|
||||
COALESCE(AVG(
|
||||
CASE WHEN pg.red_id = u.id THEN pg.red_bonus_points ELSE pg.blue_bonus_points END
|
||||
), 0)::numeric(10,1) AS avg_bonus,
|
||||
COALESCE(MAX(
|
||||
CASE WHEN pg.red_id = u.id THEN (pg.red_bonus_stats->>\'chainBest\')::int ELSE (pg.blue_bonus_stats->>\'chainBest\')::int END
|
||||
), 0) AS best_chain,
|
||||
COALESCE(SUM(
|
||||
CASE WHEN pg.red_id = u.id THEN (pg.red_bonus_stats->>\'blindHits\')::int ELSE (pg.blue_bonus_stats->>\'blindHits\')::int END
|
||||
), 0) AS blind_hits,
|
||||
COALESCE(SUM(
|
||||
CASE WHEN pg.red_id = u.id THEN (pg.red_bonus_stats->>\'edgeMines\')::int ELSE (pg.blue_bonus_stats->>\'edgeMines\')::int END
|
||||
), 0) AS edge_mines,
|
||||
COUNT(CASE WHEN
|
||||
pg.red_id = u.id AND pg.red_points IS NOT NULL
|
||||
OR pg.blue_id = u.id AND pg.blue_points IS NOT NULL
|
||||
THEN 1 END) AS games_with_scores,
|
||||
MAX(pg.updated) AS last_game_at
|
||||
FROM app_user u
|
||||
LEFT JOIN played_game pg ON (pg.red_id = u.id OR pg.blue_id = u.id)
|
||||
AND (pg.red_points IS NOT NULL OR pg.blue_points IS NOT NULL OR pg.resign IS NOT NULL)
|
||||
GROUP BY u.id
|
||||
');
|
||||
|
||||
$this->addSql('CREATE UNIQUE INDEX idx_user_stats_user_id ON user_stats (user_id)');
|
||||
$this->addSql('CREATE INDEX idx_user_stats_last_game_at ON user_stats (last_game_at DESC)');
|
||||
|
||||
$this->addSql('
|
||||
CREATE OR REPLACE FUNCTION refresh_user_stats()
|
||||
RETURNS TRIGGER AS $$
|
||||
BEGIN
|
||||
REFRESH MATERIALIZED VIEW CONCURRENTLY user_stats;
|
||||
RETURN NULL;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql
|
||||
');
|
||||
|
||||
$this->addSql('
|
||||
CREATE TRIGGER trigger_refresh_user_stats
|
||||
AFTER INSERT OR UPDATE OR DELETE ON played_game
|
||||
FOR EACH STATEMENT
|
||||
EXECUTE FUNCTION refresh_user_stats()
|
||||
');
|
||||
}
|
||||
|
||||
public function down(Schema $schema): void
|
||||
{
|
||||
$this->addSql('DROP TRIGGER IF EXISTS trigger_refresh_user_stats ON played_game');
|
||||
$this->addSql('DROP FUNCTION IF EXISTS refresh_user_stats()');
|
||||
$this->addSql('DROP MATERIALIZED VIEW IF EXISTS user_stats');
|
||||
}
|
||||
}
|
||||
@@ -14,12 +14,7 @@ use App\Entity\PlayedGame;
|
||||
use App\Entity\User;
|
||||
use DateTime;
|
||||
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
|
||||
use Doctrine\DBAL\Types\Types;
|
||||
use Doctrine\ORM\NonUniqueResultException;
|
||||
use Doctrine\ORM\NoResultException;
|
||||
use Doctrine\Persistence\ManagerRegistry;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use RuntimeException;
|
||||
|
||||
/**
|
||||
* Class PlayedGameRepository
|
||||
@@ -36,319 +31,15 @@ use RuntimeException;
|
||||
* @method PlayedGame[] findAll()
|
||||
* @method PlayedGame[] findBy(array $criteria, array $orderBy = null, $limit = null, $offset = null)
|
||||
* @method PlayedGame|null findOneByGameAssoc(mixed $gameAssoc)
|
||||
* @method PlayedGame|null findOneByUuid(\Symfony\Component\Uid\Uuid $uuid)
|
||||
*/
|
||||
class PlayedGameRepository extends ServiceEntityRepository
|
||||
{
|
||||
public function __construct(ManagerRegistry $registry, private readonly LoggerInterface $logger)
|
||||
public function __construct(ManagerRegistry $registry)
|
||||
{
|
||||
parent::__construct($registry, PlayedGame::class);
|
||||
}
|
||||
|
||||
public function countFinishedForUser(User $user): int
|
||||
{
|
||||
$qb = $this->createQueryBuilder('g');
|
||||
|
||||
try {
|
||||
return (int)$qb
|
||||
->select('COUNT(g.id)')
|
||||
->where($qb->expr()->andX(
|
||||
$qb->expr()->orX(
|
||||
$qb->expr()->eq('g.red', ':u'),
|
||||
$qb->expr()->eq('g.blue', ':u'),
|
||||
),
|
||||
$qb->expr()->orX(
|
||||
$qb->expr()->isNotNull('g.redPoints'),
|
||||
$qb->expr()->isNotNull('g.resign'),
|
||||
),
|
||||
))
|
||||
->setParameter('u', $user)
|
||||
->getQuery()
|
||||
->getSingleScalarResult();
|
||||
} catch (NoResultException $e) {
|
||||
$this->logger->error($e->getMessage());
|
||||
throw new RuntimeException(
|
||||
"Unexpectedly no result found when counting finished games for user: {$user->getUsername()}",
|
||||
0,
|
||||
$e,
|
||||
);
|
||||
} catch (NonUniqueResultException $e) {
|
||||
$this->logger->error($e->getMessage());
|
||||
throw new RuntimeException(
|
||||
"Unexpectedly multiple results found when counting finished games for user: {$user->getUsername()}",
|
||||
0,
|
||||
$e,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
public function countWinsForUser(User $user): int
|
||||
{
|
||||
$qb = $this->createQueryBuilder('g');
|
||||
|
||||
try {
|
||||
return (int)$qb
|
||||
->select('COUNT(g.id)')
|
||||
->where($qb->expr()->orX(
|
||||
$qb->expr()->andX(
|
||||
$qb->expr()->eq('g.red', ':u'),
|
||||
$qb->expr()->gt('g.redPoints', 'g.bluePoints'),
|
||||
$qb->expr()->isNull('g.resign'),
|
||||
),
|
||||
$qb->expr()->andX(
|
||||
$qb->expr()->eq('g.blue', ':u'),
|
||||
$qb->expr()->gt('g.bluePoints', 'g.redPoints'),
|
||||
$qb->expr()->isNull('g.resign'),
|
||||
),
|
||||
$qb->expr()->andX(
|
||||
$qb->expr()->eq('g.red', ':u'),
|
||||
$qb->expr()->eq('g.resign', ':blue'),
|
||||
),
|
||||
$qb->expr()->andX(
|
||||
$qb->expr()->eq('g.blue', ':u'),
|
||||
$qb->expr()->eq('g.resign', ':red'),
|
||||
),
|
||||
))
|
||||
->setParameter('u', $user)
|
||||
->setParameter('blue', 'blue')
|
||||
->setParameter('red', 'red')
|
||||
->getQuery()
|
||||
->getSingleScalarResult();
|
||||
} catch (NoResultException $e) {
|
||||
$this->logger->error($e->getMessage());
|
||||
throw new RuntimeException(
|
||||
"Unexpectedly no result found when counting wins for user: {$user->getUsername()}",
|
||||
0,
|
||||
$e,
|
||||
);
|
||||
} catch (NonUniqueResultException $e) {
|
||||
$this->logger->error($e->getMessage());
|
||||
throw new RuntimeException(
|
||||
"Unexpectedly multiple results found when counting wins for user: {$user->getUsername()}",
|
||||
0,
|
||||
$e,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
public function countLossesForUser(User $user): int
|
||||
{
|
||||
$qb = $this->createQueryBuilder('g');
|
||||
|
||||
try {
|
||||
return (int)$qb
|
||||
->select('COUNT(g.id)')
|
||||
->where($qb->expr()->orX(
|
||||
$qb->expr()->andX(
|
||||
$qb->expr()->eq('g.red', ':u'),
|
||||
$qb->expr()->gt('g.bluePoints', 'g.redPoints'),
|
||||
$qb->expr()->isNull('g.resign'),
|
||||
),
|
||||
$qb->expr()->andX(
|
||||
$qb->expr()->eq('g.blue', ':u'),
|
||||
$qb->expr()->gt('g.redPoints', 'g.bluePoints'),
|
||||
$qb->expr()->isNull('g.resign'),
|
||||
),
|
||||
$qb->expr()->andX(
|
||||
$qb->expr()->eq('g.red', ':u'),
|
||||
$qb->expr()->eq('g.resign', ':red'),
|
||||
),
|
||||
$qb->expr()->andX(
|
||||
$qb->expr()->eq('g.blue', ':u'),
|
||||
$qb->expr()->eq('g.resign', ':blue'),
|
||||
),
|
||||
))
|
||||
->setParameter('u', $user)
|
||||
->setParameter('red', 'red')
|
||||
->setParameter('blue', 'blue')
|
||||
->getQuery()
|
||||
->getSingleScalarResult();
|
||||
} catch (NoResultException $e) {
|
||||
$this->logger->error($e->getMessage());
|
||||
throw new RuntimeException(
|
||||
"Unexpectedly no result found when counting losses for user: {$user->getUsername()}",
|
||||
0,
|
||||
$e,
|
||||
);
|
||||
} catch (NonUniqueResultException $e) {
|
||||
$this->logger->error($e->getMessage());
|
||||
throw new RuntimeException(
|
||||
"Unexpectedly multiple results found when counting losses for user: {$user->getUsername()}",
|
||||
0,
|
||||
$e,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
public function countBombsForUser(User $user): int
|
||||
{
|
||||
$qb = $this->createQueryBuilder('g');
|
||||
|
||||
try {
|
||||
return (int)$qb
|
||||
->select('COUNT(g.id)')
|
||||
->where($qb->expr()->orX(
|
||||
$qb->expr()->andX(
|
||||
$qb->expr()->eq('g.red', ':u'),
|
||||
$qb->expr()->eq('g.redExplodedBomb', ':true'),
|
||||
),
|
||||
$qb->expr()->andX(
|
||||
$qb->expr()->eq('g.blue', ':u'),
|
||||
$qb->expr()->eq('g.blueExplodedBomb', ':true'),
|
||||
),
|
||||
))
|
||||
->setParameter('true', true, Types::BOOLEAN)
|
||||
->setParameter('u', $user)
|
||||
->getQuery()
|
||||
->getSingleScalarResult();
|
||||
} catch (NoResultException $e) {
|
||||
$this->logger->error($e->getMessage());
|
||||
throw new RuntimeException(
|
||||
"Unexpectedly no result found when counting bombs for user: {$user->getUsername()}",
|
||||
0,
|
||||
$e,
|
||||
);
|
||||
} catch (NonUniqueResultException $e) {
|
||||
$this->logger->error($e->getMessage());
|
||||
throw new RuntimeException(
|
||||
"Unexpectedly multiple results found when counting bombs for user: {$user->getUsername()}",
|
||||
0,
|
||||
$e,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
public function countDrawsForUser(User $user): int
|
||||
{
|
||||
$qb = $this->createQueryBuilder('g');
|
||||
|
||||
return (int)$qb
|
||||
->select('COUNT(g.id)')
|
||||
->where($qb->expr()->andX(
|
||||
$qb->expr()->orX(
|
||||
$qb->expr()->eq('g.red', ':u'),
|
||||
$qb->expr()->eq('g.blue', ':u'),
|
||||
),
|
||||
$qb->expr()->isNotNull('g.redPoints'),
|
||||
$qb->expr()->isNotNull('g.bluePoints'),
|
||||
$qb->expr()->isNull('g.resign'),
|
||||
'g.redPoints = g.bluePoints',
|
||||
))
|
||||
->setParameter('u', $user)
|
||||
->getQuery()
|
||||
->getSingleScalarResult();
|
||||
}
|
||||
|
||||
public function findTotalMinesForUser(User $user): int
|
||||
{
|
||||
$qb = $this->createQueryBuilder('g');
|
||||
|
||||
return (int)$qb
|
||||
->select('COALESCE(SUM(CASE WHEN g.red = :u THEN g.redPoints ELSE g.bluePoints END), 0)')
|
||||
->where($qb->expr()->orX(
|
||||
$qb->expr()->eq('g.red', ':u'),
|
||||
$qb->expr()->eq('g.blue', ':u'),
|
||||
))
|
||||
->setParameter('u', $user)
|
||||
->getQuery()
|
||||
->getSingleScalarResult();
|
||||
}
|
||||
|
||||
public function findAvgScoreForUser(User $user): int
|
||||
{
|
||||
$qb = $this->createQueryBuilder('g');
|
||||
|
||||
/** @var array{totalPts: int|string|null, totalGames: int|string} $row */
|
||||
$row = $qb
|
||||
->select('SUM(CASE WHEN g.red = :u THEN g.redPoints ELSE g.bluePoints END) AS totalPts')
|
||||
->addSelect('COUNT(g.id) AS totalGames')
|
||||
->where($qb->expr()->orX(
|
||||
$qb->expr()->andX(
|
||||
$qb->expr()->eq('g.red', ':u'),
|
||||
$qb->expr()->isNotNull('g.redPoints'),
|
||||
),
|
||||
$qb->expr()->andX(
|
||||
$qb->expr()->eq('g.blue', ':u'),
|
||||
$qb->expr()->isNotNull('g.bluePoints'),
|
||||
),
|
||||
))
|
||||
->setParameter('u', $user)
|
||||
->getQuery()
|
||||
->getSingleResult();
|
||||
|
||||
if ((int)$row['totalGames'] === 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return (int)round((float)$row['totalPts'] / (int)$row['totalGames']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Aggregates bonus points and bonus stats across all finished games for a user.
|
||||
*/
|
||||
public function findBonusStatsForUser(User $user): array
|
||||
{
|
||||
$userId = $user->id;
|
||||
$qb = $this->createQueryBuilder('g');
|
||||
$qb->where($qb->expr()->orX(
|
||||
$qb->expr()->eq('g.red', ':u'),
|
||||
$qb->expr()->eq('g.blue', ':u'),
|
||||
))->setParameter('u', $user);
|
||||
|
||||
/** @var PlayedGame[] $games */
|
||||
$games = $qb->getQuery()->getResult();
|
||||
|
||||
$totalBonusPoints = 0.0;
|
||||
$bestChain = 0;
|
||||
$totalBlindHits = 0;
|
||||
$totalEdgeMines = 0;
|
||||
$gameCount = 0;
|
||||
|
||||
foreach ($games as $game) {
|
||||
$isRed = $game->red?->id === $userId;
|
||||
$totalBonusPoints += (float)(($isRed ? $game->redBonusPoints : $game->blueBonusPoints) ?? 0.0);
|
||||
|
||||
$stats = ($isRed ? $game->redBonusStats : $game->blueBonusStats) ?? [];
|
||||
$bestChain = max($bestChain, (int)($stats['chainBest'] ?? 0));
|
||||
$totalBlindHits += (int)($stats['blindHits'] ?? 0);
|
||||
$totalEdgeMines += (int)($stats['edgeMines'] ?? 0);
|
||||
$gameCount++;
|
||||
}
|
||||
|
||||
return [
|
||||
'totalBonusPoints' => round($totalBonusPoints, 1),
|
||||
'avgBonusPoints' => 0 < $gameCount ? round($totalBonusPoints / $gameCount, 1) : 0.0,
|
||||
'bestChain' => $bestChain,
|
||||
'totalBlindHits' => $totalBlindHits,
|
||||
'totalEdgeMines' => $totalEdgeMines,
|
||||
];
|
||||
}
|
||||
|
||||
public function findBestScoreForUser(User $user): int
|
||||
{
|
||||
try {
|
||||
$qbRed = $this->createQueryBuilder('g');
|
||||
$maxRed = (int)$qbRed
|
||||
->select('MAX(g.redPoints)')
|
||||
->where($qbRed->expr()->eq('g.red', ':u'))
|
||||
->setParameter('u', $user)
|
||||
->getQuery()
|
||||
->getSingleScalarResult();
|
||||
|
||||
$qbBlue = $this->createQueryBuilder('g');
|
||||
$maxBlue = (int)$qbBlue
|
||||
->select('MAX(g.bluePoints)')
|
||||
->where($qbBlue->expr()->eq('g.blue', ':u'))
|
||||
->setParameter('u', $user)
|
||||
->getQuery()
|
||||
->getSingleScalarResult();
|
||||
|
||||
return max($maxRed, $maxBlue);
|
||||
} catch (NoResultException|NonUniqueResultException $e) {
|
||||
$this->logger->error($e->getMessage());
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
public function findFinishedForUserSince(User $user, DateTime $since): array
|
||||
{
|
||||
$qb = $this->createQueryBuilder('g');
|
||||
|
||||
57
src/Repository/UserStatsRepository.php
Normal file
57
src/Repository/UserStatsRepository.php
Normal file
@@ -0,0 +1,57 @@
|
||||
<?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\Repository;
|
||||
|
||||
use App\Entity\UserStats;
|
||||
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
|
||||
use Doctrine\DBAL\Exception;
|
||||
use Doctrine\Persistence\ManagerRegistry;
|
||||
use RuntimeException;
|
||||
|
||||
/**
|
||||
* Class UserStatsRepository
|
||||
*
|
||||
* @package App\Repository
|
||||
* @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. 20.
|
||||
*
|
||||
* @method UserStats|null find($id, $lockMode = null, $lockVersion = null)
|
||||
* @method UserStats|null findOneBy(array $criteria, array $orderBy = null)
|
||||
* @method UserStats[] findAll()
|
||||
* @method UserStats[] findBy(array $criteria, array $orderBy = null, $limit = null, $offset = null)
|
||||
*/
|
||||
class UserStatsRepository extends ServiceEntityRepository
|
||||
{
|
||||
public function __construct(ManagerRegistry $registry)
|
||||
{
|
||||
parent::__construct($registry, UserStats::class);
|
||||
}
|
||||
|
||||
public function findByUserId(int $userId): ?UserStats
|
||||
{
|
||||
return $this->find($userId);
|
||||
}
|
||||
|
||||
public function refreshMaterializedView(): void
|
||||
{
|
||||
try {
|
||||
$this
|
||||
->getEntityManager()
|
||||
->getConnection()
|
||||
->executeStatement('REFRESH MATERIALIZED VIEW CONCURRENTLY user_stats');
|
||||
} catch (Exception $e) {
|
||||
throw new RuntimeException("Failed to refresh materialized view: {$e->getMessage()}", 0, $e);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user