Private
Public Access
1
0

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:
2026-04-20 20:44:33 +02:00
parent 6be0d52fb7
commit 6a5ba84b5e
13 changed files with 827 additions and 502 deletions
+105
View 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
View 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
View 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
View 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
View 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
View 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
View 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);
}
}