diff --git a/config/packages/doctrine.yaml b/config/packages/doctrine.yaml index 3b9c7c1..4de7ba5 100644 --- a/config/packages/doctrine.yaml +++ b/config/packages/doctrine.yaml @@ -16,6 +16,7 @@ doctrine: auto_mapping: true schema_ignore_classes: - App\Entity\UserStats + - App\Entity\RecentBattle mappings: App: is_bundle: false diff --git a/src/Controller/ProfileController.php b/src/Controller/ProfileController.php index 99e0e08..b3e2513 100644 --- a/src/Controller/ProfileController.php +++ b/src/Controller/ProfileController.php @@ -16,9 +16,10 @@ 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\Entity\RecentBattle; use App\Repository\PlayedGameRepository; +use App\Repository\RecentBattleRepository; use App\Repository\UserStatsRepository; use App\Service\BattleCardGenerator; use App\Service\WebAuthnService; @@ -59,6 +60,7 @@ class ProfileController extends AbstractController private readonly LoggerInterface $logger, private readonly PlayedGameRepository $repo, private readonly UserStatsRepository $userStatsRepo, + private readonly RecentBattleRepository $recentBattleRepo, private readonly WebAuthnService $webAuthnService, private readonly ProfileGameDtoFactory $profileGameDtoFactory, private readonly ProfileChartDataFactory $profileChartDataFactory, @@ -74,10 +76,10 @@ class ProfileController extends AbstractController $userId = $user->id; $stats = ProfileStatsDto::fromUserStats($this->userStatsRepo->findByUserId($userId)); - $recent = $this->repo->findRecentFinishedForUser($user, 30); + $recent = $this->recentBattleRepo->findRecentForUser($userId, 30); $gamesData = array_map( - fn(PlayedGame $game): ProfileGameDto => $this->profileGameDtoFactory->create($game, $userId), + fn(RecentBattle $battle): ProfileGameDto => $this->profileGameDtoFactory->createFromRecentBattle($battle), $recent, ); diff --git a/src/Dto/ProfileGameDtoFactory.php b/src/Dto/ProfileGameDtoFactory.php index 945ef3e..cbd3dc1 100644 --- a/src/Dto/ProfileGameDtoFactory.php +++ b/src/Dto/ProfileGameDtoFactory.php @@ -11,6 +11,7 @@ namespace App\Dto; use App\Entity\PlayedGame; +use App\Entity\RecentBattle; use Liip\ImagineBundle\Imagine\Cache\CacheManager; /** @@ -91,4 +92,43 @@ final readonly class ProfileGameDtoFactory return 'draw'; } + + /** + * Build a ProfileGameDto directly from the recent_battles materialized view row. + * Avatar paths are still resolved via LiipImagine (they are stored as storage paths). + */ + public function createFromRecentBattle(RecentBattle $battle): ProfileGameDto + { + $myPts = $battle->isRed ? $battle->redPoints : $battle->bluePoints; + $oppPts = $battle->isRed ? $battle->bluePoints : $battle->redPoints; + + return new ProfileGameDto( + id: $battle->gameId, + uuid: $battle->uuid, + redName: $battle->redName, + blueName: $battle->blueName, + redAvatar: $battle->redAvatarPath + ? $this->cacheManager->generateUrl($battle->redAvatarPath, 'avatar_thumb') + : null, + blueAvatar: $battle->blueAvatarPath + ? $this->cacheManager->generateUrl($battle->blueAvatarPath, 'avatar_thumb') + : null, + redPoints: $battle->redPoints, + bluePoints: $battle->bluePoints, + redExplodedBomb: $battle->redExplodedBomb, + blueExplodedBomb: $battle->blueExplodedBomb, + resign: $battle->resign, + created: $battle->created?->format('Y-m-d H:i'), + date: $battle->updated?->format('Y-m-d H:i'), + isRed: $battle->isRed, + result: $battle->result, + myPoints: $myPts, + oppPoints: $oppPts, + redBonusPoints: $battle->redBonusPoints, + blueBonusPoints: $battle->blueBonusPoints, + redBonusStats: $battle->redBonusStats, + blueBonusStats: $battle->blueBonusStats, + ); + } + } diff --git a/src/Entity/RecentBattle.php b/src/Entity/RecentBattle.php new file mode 100644 index 0000000..ebd95ac --- /dev/null +++ b/src/Entity/RecentBattle.php @@ -0,0 +1,100 @@ + + * @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: RecentBattleRepository::class, readOnly: true), Table(name: 'recent_battles')] +class RecentBattle +{ + /** Composite PK: (user_id, game_id) — mapped via the unique index. */ + #[Id, Column] + public int $userId = 0; + + #[Id, Column] + public int $gameId = 0; + + #[Column(name: 'uuid')] + public string $uuid = ''; + + #[Column] + public bool $isRed = false; + + #[Column] + public string $redName = ''; + + #[Column] + public string $blueName = ''; + + #[Column(nullable: true)] + public ?string $redAvatarPath = null; + + #[Column(nullable: true)] + public ?string $blueAvatarPath = null; + + #[Column(nullable: true)] + public ?int $redPoints = null; + + #[Column(nullable: true)] + public ?int $bluePoints = null; + + #[Column(nullable: true)] + public ?bool $redExplodedBomb = null; + + #[Column(nullable: true)] + public ?bool $blueExplodedBomb = null; + + #[Column(nullable: true)] + public ?string $resign = null; + + #[Column] + public float $redBonusPoints = 0.0; + + #[Column] + public float $blueBonusPoints = 0.0; + + #[Column(type: Types::JSON)] + public array $redBonusStats = []; + + #[Column(type: Types::JSON)] + public array $blueBonusStats = []; + + #[Column] + public string $result = 'draw'; + + #[Column] + public bool $oppIsGuest = false; + + #[Column(type: Types::DATETIME_MUTABLE, nullable: true)] + public ?DateTime $created = null; + + #[Column(type: Types::DATETIME_MUTABLE, nullable: true)] + public ?DateTime $updated = null; +} diff --git a/src/Migrations/2026/04/Version20260420130000.php b/src/Migrations/2026/04/Version20260420130000.php new file mode 100644 index 0000000..f6a0dc0 --- /dev/null +++ b/src/Migrations/2026/04/Version20260420130000.php @@ -0,0 +1,124 @@ + + * @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 Version20260420130000 extends AbstractMigration +{ + public function getDescription(): string + { + return 'Create recent_battles materialized view for profile recent games list.'; + } + + public function up(Schema $schema): void + { + $this->addSql(' + CREATE MATERIALIZED VIEW recent_battles AS + SELECT + pg.id AS game_id, + pg.uuid::text AS uuid, + u.id AS user_id, + CASE WHEN pg.red_id = u.id THEN true ELSE false END AS is_red, + + COALESCE(ru.username, ra.user_name, \'Guest\') AS red_name, + COALESCE(bu.username, ba.user_name, \'Guest\') AS blue_name, + + ru.avatar_path AS red_avatar_path, + bu.avatar_path AS blue_avatar_path, + + pg.red_points, + pg.blue_points, + pg.red_exploded_bomb, + pg.blue_exploded_bomb, + pg.resign, + + COALESCE(pg.red_bonus_points, 0.0) AS red_bonus_points, + COALESCE(pg.blue_bonus_points, 0.0) AS blue_bonus_points, + COALESCE(pg.red_bonus_stats, \'[]\'::json) AS red_bonus_stats, + COALESCE(pg.blue_bonus_stats, \'[]\'::json) AS blue_bonus_stats, + + CASE + WHEN pg.red_id = u.id AND pg.resign = \'red\' THEN \'loss\' + WHEN pg.blue_id = u.id AND pg.resign = \'blue\' THEN \'loss\' + WHEN pg.red_id = u.id AND pg.resign = \'blue\' THEN \'win\' + WHEN pg.blue_id = u.id AND pg.resign = \'red\' THEN \'win\' + WHEN pg.red_id = u.id AND pg.red_points IS NOT NULL AND pg.blue_points IS NOT NULL + AND pg.red_points > pg.blue_points THEN \'win\' + WHEN pg.blue_id = u.id AND pg.blue_points IS NOT NULL AND pg.red_points IS NOT NULL + AND pg.blue_points > pg.red_points THEN \'win\' + WHEN pg.red_id = u.id AND pg.red_points IS NOT NULL AND pg.blue_points IS NOT NULL + AND pg.red_points < pg.blue_points THEN \'loss\' + WHEN pg.blue_id = u.id AND pg.blue_points IS NOT NULL AND pg.red_points IS NOT NULL + AND pg.blue_points < pg.red_points THEN \'loss\' + ELSE \'draw\' + END AS result, + + -- Whether the opponent in this game is an anonymous guest (no app_user account) + CASE + WHEN pg.red_id = u.id AND pg.blue_id IS NULL THEN true + WHEN pg.blue_id = u.id AND pg.red_id IS NULL THEN true + ELSE false + END AS opp_is_guest, + + pg.created, + pg.updated + FROM app_user u + JOIN played_game pg + ON pg.red_id = u.id + OR pg.blue_id = u.id + LEFT JOIN app_user ru ON ru.id = pg.red_id + LEFT JOIN app_user bu ON bu.id = pg.blue_id + LEFT JOIN gamer ra ON ra.id = pg.red_anon + LEFT JOIN gamer ba ON ba.id = pg.blue_anon + '); + + $this->addSql('CREATE UNIQUE INDEX idx_recent_battles_pk ON recent_battles (user_id, game_id)'); + $this->addSql('CREATE INDEX idx_recent_battles_user_upd ON recent_battles (user_id, updated DESC)'); + + $this->addSql(' + CREATE OR REPLACE FUNCTION refresh_recent_battles() + RETURNS TRIGGER AS $$ + BEGIN + REFRESH MATERIALIZED VIEW CONCURRENTLY recent_battles; + RETURN NULL; + END; + $$ LANGUAGE plpgsql + '); + + $this->addSql(' + CREATE TRIGGER trigger_refresh_recent_battles + AFTER INSERT OR UPDATE OR DELETE ON played_game + FOR EACH STATEMENT + EXECUTE FUNCTION refresh_recent_battles() + '); + } + + public function down(Schema $schema): void + { + $this->addSql('DROP TRIGGER IF EXISTS trigger_refresh_recent_battles ON played_game'); + $this->addSql('DROP FUNCTION IF EXISTS refresh_recent_battles()'); + $this->addSql('DROP MATERIALIZED VIEW IF EXISTS recent_battles'); + } +} diff --git a/src/Repository/RecentBattleRepository.php b/src/Repository/RecentBattleRepository.php new file mode 100644 index 0000000..a8f69d4 --- /dev/null +++ b/src/Repository/RecentBattleRepository.php @@ -0,0 +1,63 @@ + + * @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 RecentBattle|null find($id, $lockMode = null, $lockVersion = null) + * @method RecentBattle|null findOneBy(array $criteria, array $orderBy = null) + * @method RecentBattle[] findAll() + * @method RecentBattle[] findBy(array $criteria, array $orderBy = null, $limit = null, $offset = null) + */ +class RecentBattleRepository extends ServiceEntityRepository +{ + public function __construct(ManagerRegistry $registry) + { + parent::__construct($registry, RecentBattle::class); + } + + public function findRecentForUser(int $userId, int $limit = 30): array + { + return $this->createQueryBuilder('rb') + ->where('rb.userId = :uid') + ->setParameter('uid', $userId) + ->orderBy('rb.updated', 'DESC') + ->setMaxResults($limit) + ->getQuery() + ->getResult(); + } + + public function refreshMaterializedView(): void + { + try { + $this + ->getEntityManager() + ->getConnection() + ->executeStatement('REFRESH MATERIALIZED VIEW CONCURRENTLY recent_battles'); + } catch (Exception $e) { + throw new RuntimeException("Failed to refresh materialized view: {$e->getMessage()}", 0, $e); + } + } +} diff --git a/templates/Security/profile.html.twig b/templates/Security/profile.html.twig index 8455889..a282975 100644 --- a/templates/Security/profile.html.twig +++ b/templates/Security/profile.html.twig @@ -127,32 +127,16 @@