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

View File

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