diff --git a/config/packages/doctrine.yaml b/config/packages/doctrine.yaml index bd90e03..3b9c7c1 100644 --- a/config/packages/doctrine.yaml +++ b/config/packages/doctrine.yaml @@ -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 diff --git a/src/Controller/ProfileController.php b/src/Controller/ProfileController.php index 0b0e427..99e0e08 100644 --- a/src/Controller/ProfileController.php +++ b/src/Controller/ProfileController.php @@ -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); } diff --git a/src/Dto/BattleShareDto.php b/src/Dto/BattleShareDto.php new file mode 100644 index 0000000..227e916 --- /dev/null +++ b/src/Dto/BattleShareDto.php @@ -0,0 +1,105 @@ + + * @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); + } +} diff --git a/src/Dto/ProfileChartDataDto.php b/src/Dto/ProfileChartDataDto.php new file mode 100644 index 0000000..ce72e21 --- /dev/null +++ b/src/Dto/ProfileChartDataDto.php @@ -0,0 +1,43 @@ + + * @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); + } +} diff --git a/src/Dto/ProfileChartDataFactory.php b/src/Dto/ProfileChartDataFactory.php new file mode 100644 index 0000000..5d9e788 --- /dev/null +++ b/src/Dto/ProfileChartDataFactory.php @@ -0,0 +1,111 @@ + + * @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]; + } +} diff --git a/src/Dto/ProfileGameDto.php b/src/Dto/ProfileGameDto.php new file mode 100644 index 0000000..5fe8b7d --- /dev/null +++ b/src/Dto/ProfileGameDto.php @@ -0,0 +1,56 @@ + + * @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); + } +} diff --git a/src/Dto/ProfileGameDtoFactory.php b/src/Dto/ProfileGameDtoFactory.php new file mode 100644 index 0000000..945ef3e --- /dev/null +++ b/src/Dto/ProfileGameDtoFactory.php @@ -0,0 +1,94 @@ + + * @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'; + } +} diff --git a/src/Dto/ProfileStatsDto.php b/src/Dto/ProfileStatsDto.php new file mode 100644 index 0000000..37ad510 --- /dev/null +++ b/src/Dto/ProfileStatsDto.php @@ -0,0 +1,82 @@ + + * @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, + ); + } +} diff --git a/src/Dto/ProfileViewDto.php b/src/Dto/ProfileViewDto.php new file mode 100644 index 0000000..3ae0d6d --- /dev/null +++ b/src/Dto/ProfileViewDto.php @@ -0,0 +1,37 @@ + + * @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); + } +} diff --git a/src/Entity/UserStats.php b/src/Entity/UserStats.php new file mode 100644 index 0000000..6be2cfa --- /dev/null +++ b/src/Entity/UserStats.php @@ -0,0 +1,89 @@ + + * @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; + } +} diff --git a/src/Migrations/2026/04/Version20260420120000.php b/src/Migrations/2026/04/Version20260420120000.php new file mode 100644 index 0000000..ccc05ba --- /dev/null +++ b/src/Migrations/2026/04/Version20260420120000.php @@ -0,0 +1,117 @@ + + * @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'); + } +} diff --git a/src/Repository/PlayedGameRepository.php b/src/Repository/PlayedGameRepository.php index 4adce9c..43d3352 100644 --- a/src/Repository/PlayedGameRepository.php +++ b/src/Repository/PlayedGameRepository.php @@ -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'); diff --git a/src/Repository/UserStatsRepository.php b/src/Repository/UserStatsRepository.php new file mode 100644 index 0000000..91401c4 --- /dev/null +++ b/src/Repository/UserStatsRepository.php @@ -0,0 +1,57 @@ + + * @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); + } + } +}