Private
Public Access
1
0

chg: dev: increase the minimum PHP version to the latest major - and massive refactor on back-end, like Controllers and Repositories #4

This commit is contained in:
2026-04-12 08:01:46 +02:00
parent 92bfa5b301
commit c0dcc2896a
12 changed files with 511 additions and 104 deletions

View File

@@ -255,13 +255,45 @@
transition: all 200ms ease;
flex-shrink: 0;
&:hover {
&:hover:not(:disabled) {
background: linear-gradient(to bottom, rgba(45, 138, 168, 0.9) 0%, rgba(35, 111, 135, 0.95) 100%);
border-color: rgba(149, 207, 245, 0.5);
color: #fff;
box-shadow: 0 0 14px rgba(35, 111, 135, 0.5);
transform: translateY(-1px);
}
&:disabled {
cursor: default;
opacity: 0.55;
}
&.opd-join--waiting {
background: linear-gradient(to bottom, rgba(26, 80, 104, 0.6) 0%, rgba(15, 50, 70, 0.7) 100%);
border-color: rgba(35, 111, 135, 0.3);
color: rgba(149, 207, 245, 0.6);
opacity: 1;
letter-spacing: 1px;
}
}
.opd-declined {
font: 600 12px 'Rajdhani', sans-serif;
color: rgba(255, 120, 120, 0.85);
letter-spacing: 0.5px;
padding: 8px 12px;
margin-bottom: 8px;
border-radius: 6px;
border: 1px solid rgba(180, 60, 60, 0.3);
background: rgba(180, 60, 60, 0.08);
display: flex;
align-items: center;
gap: 7px;
i {
font-size: 14px;
flex-shrink: 0;
}
}
.opd-note {

View File

@@ -7,7 +7,7 @@
* file that was distributed with this source code.
*/
import React, { useCallback, useEffect, useState } from 'react';
import React, { useCallback, useEffect, useRef, useState } from 'react';
import Dialog from '@mui/material/Dialog';
const DIALOG_SX = {
@@ -45,6 +45,9 @@ const OnlinePlayersDialog = ({ open, onClose, currentGameAssoc }) => {
const [loading, setLoading] = useState(false);
const [refreshKey, setRefreshKey] = useState(0);
const [snapshotLoaded, setSnapshotLoaded] = useState(false);
const [challengingGameAssoc, setChallengingGameAssoc] = useState(null);
const [declinedMsg, setDeclinedMsg] = useState('');
const declinedTimerRef = useRef(null);
const addPlayer = useCallback(entry => {
setPlayers(prev =>
@@ -103,6 +106,31 @@ const OnlinePlayersDialog = ({ open, onClose, currentGameAssoc }) => {
return () => es.close();
}, [open, snapshotLoaded, addPlayer, removePlayer, currentGameAssoc]);
useEffect(() => {
const handler = () => {
setChallengingGameAssoc(null);
clearTimeout(declinedTimerRef.current);
setDeclinedMsg('Challenge was not accepted.');
declinedTimerRef.current = setTimeout(() => setDeclinedMsg(''), 3500);
};
window.addEventListener('challenge-declined', handler);
return () => {
window.removeEventListener('challenge-declined', handler);
clearTimeout(declinedTimerRef.current);
};
}, []);
const handleChallenge = player => {
if (challengingGameAssoc) return;
setChallengingGameAssoc(player.gameAssoc);
setDeclinedMsg('');
fetch('/api/game/challenge/' + player.gameAssoc, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ challengerGameAssoc: currentGameAssoc }),
}).catch(() => setChallengingGameAssoc(null));
};
const visible = players
.filter(p => p.gameAssoc !== currentGameAssoc)
.filter(p => p.name.toLowerCase().includes(search.toLowerCase()));
@@ -177,7 +205,16 @@ const OnlinePlayersDialog = ({ open, onClose, currentGameAssoc }) => {
</div>
)}
{!loading && shown.map(player => (
{declinedMsg && (
<div className="opd-declined">
<i className="fa fa-times-circle" />
{' '}{declinedMsg}
</div>
)}
{!loading && shown.map(player => {
const isWaiting = challengingGameAssoc === player.gameAssoc;
return (
<div key={player.gameAssoc} className="opd-row">
<div className="opd-avatar">
{player.name.slice(0, 2).toUpperCase()}
@@ -189,12 +226,17 @@ const OnlinePlayersDialog = ({ open, onClose, currentGameAssoc }) => {
{' '}Waiting {formatSince(player.since)}
</span>
</div>
<a className="opd-join" href={`/play/${player.gameAssoc}`}>
<i className="fa fa-play" />
Join
</a>
<button
className={`opd-join${isWaiting ? ' opd-join--waiting' : ''}`}
onClick={() => handleChallenge(player)}
disabled={!!challengingGameAssoc}
>
<i className={`fa ${isWaiting ? 'fa-spinner fa-spin' : 'fa-play'}`} />
{isWaiting ? 'Waiting...' : 'Join'}
</button>
</div>
))}
);
})}
</div>
{!loading && hasMore && (

View File

@@ -130,6 +130,51 @@ const useServerCommunication = (gameAssoc, gameInherited, isEnvDev) => {
showOverlay('The connection has been lost w/ your friend...', 'Please, restart the game!');
};
const wChallenge = payload => {
const { challengerName, challengerGameAssoc } = payload;
const handleAccept = () => {
fetch('/api/game/challenge/respond/' + challengerGameAssoc, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ accepted: true, targetGameAssoc: gameAssoc }),
}).then(() => {
showOverlay('Challenge accepted!', 'Waiting for the challenger to join...');
}).catch(() => {});
};
const handleDecline = () => {
fetch('/api/game/challenge/respond/' + challengerGameAssoc, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ accepted: false, targetGameAssoc: gameAssoc }),
}).then(() => {
showOverlay('We are waiting for your opponent...', gameAssoc ? (
<WaitingOverlayContent
shareUrl={window.location.origin + '/play/' + gameAssoc}
currentGameAssoc={gameAssoc}
/>
) : '');
}).catch(() => {});
};
showOverlay(
challengerName + ' wants to challenge you!',
<div className="resign">
<a onClick={handleAccept}>Accept</a>
<a onClick={handleDecline}>Decline</a>
</div>,
);
};
const wChallengeResponse = payload => {
if (payload.accepted) {
window.location.href = '/play/' + payload.targetGameAssoc;
} else {
window.dispatchEvent(new CustomEvent('challenge-declined'));
}
};
const wTopic = payload => {
if (webPlayerRef.current !== payload.data.player) {
if (null === payload.data.resign) {
@@ -152,6 +197,12 @@ const useServerCommunication = (gameAssoc, gameInherited, isEnvDev) => {
};
const handleMercureMessage = payload => {
if (undefined !== payload.type) {
if ('challenge' === payload.type) wChallenge(payload);
else if ('challenge-response' === payload.type) wChallengeResponse(payload);
return;
}
if (undefined !== payload.data) {
wTopic(payload);
} else if (undefined === payload.msg) {

View File

@@ -2,7 +2,7 @@
"type": "project",
"license": "proprietary",
"require": {
"php": ">=8.2",
"php": ">=8.5",
"ext-iconv": "*",
"ext-json": "*",
"doctrine/dbal": "^3.7",

View File

@@ -13,6 +13,7 @@ namespace App\Controller;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Attribute\AsController;
use Symfony\Component\Routing\Attribute\Route;
/**
@@ -25,6 +26,7 @@ use Symfony\Component\Routing\Attribute\Route;
* @link www.splendidbear.org
* @since 2026. 04. 09.
*/
#[AsController]
class GameController extends AbstractController
{
public function __construct(

View File

@@ -18,6 +18,7 @@ use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Attribute\AsController;
use Symfony\Component\Routing\Attribute\Route;
/**
@@ -34,6 +35,7 @@ use Symfony\Component\Routing\Attribute\Route;
* @link www.splendidbear.org
* @since 2026. 04. 09.
*/
#[AsController]
class MercureController extends AbstractController
{
public function __construct(
@@ -83,6 +85,27 @@ class MercureController extends AbstractController
return $this->json(['success' => true]);
}
#[Route('/api/game/challenge/{targetGameAssoc}', name: 'MineSeekerBundle_api_game_challenge', methods: ['POST'])]
public function challenge(string $targetGameAssoc, Request $request): JsonResponse
{
$data = $request->toArray();
$challengerGameAssoc = $data['challengerGameAssoc'] ?? '';
$this->topicManager->publishChallenge($targetGameAssoc, $challengerGameAssoc);
return $this->json(['success' => true]);
}
#[Route('/api/game/challenge/respond/{challengerGameAssoc}', name: 'MineSeekerBundle_api_game_challenge_respond', methods: ['POST'])]
public function challengeRespond(string $challengerGameAssoc, Request $request): JsonResponse
{
$data = $request->toArray();
$accepted = (bool)($data['accepted'] ?? false);
$targetGameAssoc = $data['targetGameAssoc'] ?? '';
$this->topicManager->publishChallengeResponse($challengerGameAssoc, $accepted, $targetGameAssoc);
return $this->json(['success' => true]);
}
#[Route('/api/game/waiting', name: 'MineSeekerBundle_api_game_waiting', methods: ['GET'])]
public function waiting(PlayedGameRepository $repo): JsonResponse
{

View File

@@ -11,9 +11,10 @@
namespace App\Controller;
use App\Entity\User;
use Doctrine\ORM\EntityManagerInterface;
use App\Repository\PlayedGameRepository;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Attribute\AsController;
use Symfony\Component\Routing\Attribute\Route;
/**
@@ -26,58 +27,26 @@ use Symfony\Component\Routing\Attribute\Route;
* @link www.splendidbear.org
* @since 2026. 04. 11.
*/
#[AsController]
class ProfileController extends AbstractController
{
#[Route('/profile', name: 'MineSeekerBundle_profile')]
public function index(EntityManagerInterface $em): Response
{
$this->denyAccessUnlessGranted('IS_AUTHENTICATED_REMEMBERED');
public function __construct(private readonly PlayedGameRepository $repo) { }
#[Route('/profile', name: 'MineSeekerBundle_profile')]
public function index(): Response
{
/** @var User $user */
$user = $this->getUser();
$finished = '(g.redPoints IS NOT NULL OR g.resign IS NOT NULL)';
$total = (int) $em->createQuery(
"SELECT COUNT(g.id) FROM App\Entity\PlayedGame g
WHERE (g.red = :u OR g.blue = :u) AND {$finished}"
)->setParameter('u', $user)->getSingleScalarResult();
$wins = (int) $em->createQuery(
"SELECT COUNT(g.id) FROM App\Entity\PlayedGame g WHERE (
(g.red = :u AND g.redPoints > g.bluePoints AND g.resign IS NULL) OR
(g.blue = :u AND g.bluePoints > g.redPoints AND g.resign IS NULL) OR
(g.red = :u AND g.resign = 'blue') OR
(g.blue = :u AND g.resign = 'red')
)"
)->setParameter('u', $user)->getSingleScalarResult();
$losses = (int) $em->createQuery(
"SELECT COUNT(g.id) FROM App\Entity\PlayedGame g WHERE (
(g.red = :u AND g.bluePoints > g.redPoints AND g.resign IS NULL) OR
(g.blue = :u AND g.redPoints > g.bluePoints AND g.resign IS NULL) OR
(g.red = :u AND g.resign = 'red') OR
(g.blue = :u AND g.resign = 'blue')
)"
)->setParameter('u', $user)->getSingleScalarResult();
$bombs = (int) $em->createQuery(
"SELECT COUNT(g.id) FROM App\Entity\PlayedGame g WHERE
(g.red = :u AND g.redExplodedBomb = true) OR
(g.blue = :u AND g.blueExplodedBomb = true)"
)->setParameter('u', $user)->getSingleScalarResult();
$recent = $em->createQuery(
"SELECT g FROM App\Entity\PlayedGame g
LEFT JOIN g.red rr LEFT JOIN g.blue bb
LEFT JOIN g.redAnon ra LEFT JOIN g.blueAnon ba
WHERE (g.red = :u OR g.blue = :u) AND {$finished}
ORDER BY g.updated DESC"
)->setParameter('u', $user)->setMaxResults(10)->getResult();
$this->denyAccessUnlessGranted('IS_AUTHENTICATED_REMEMBERED');
return $this->render('Security/profile.html.twig', [
'stats' => compact('total', 'wins', 'losses', 'bombs'),
'recent' => $recent,
'stats' => [
'total' => $this->repo->countFinishedForUser($user),
'wins' => $this->repo->countWinsForUser($user),
'losses' => $this->repo->countLossesForUser($user),
'bombs' => $this->repo->countBombsForUser($user),
],
'recent' => $this->repo->findRecentFinishedForUser($user),
]);
}
}

View File

@@ -16,6 +16,7 @@ use Symfony\Bridge\Twig\Mime\TemplatedEmail;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Attribute\AsController;
use Symfony\Component\Mailer\MailerInterface;
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
use Symfony\Component\Routing\Attribute\Route;
@@ -32,6 +33,7 @@ use Symfony\Component\Security\Http\Authentication\AuthenticationUtils;
* @link www.splendidbear.org
* @since 2026. 04. 11.
*/
#[AsController]
class SecurityController extends AbstractController
{
#[Route('/login', name: 'MineSeekerBundle_login')]
@@ -93,7 +95,7 @@ class SecurityController extends AbstractController
if (empty($errors)) {
$token = bin2hex(random_bytes(32));
$user = (new User())
$user = new User()
->setUsername($username)
->setEmail($email)
->setIsVerified(false)
@@ -111,7 +113,7 @@ class SecurityController extends AbstractController
);
$mailer->send(
(new TemplatedEmail())
new TemplatedEmail()
->from('noreply@mineseeker.ninja')
->to($email)
->subject('Activate your MineSeeker account')

View File

@@ -11,9 +11,14 @@
namespace App\Repository;
use App\Entity\PlayedGame;
use App\Entity\User;
use DateTime;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\ORM\NonUniqueResultException;
use Doctrine\ORM\NoResultException;
use Doctrine\Persistence\ManagerRegistry;
use Psr\Log\LoggerInterface;
use RuntimeException;
/**
* Class PlayedGameRepository
@@ -29,35 +34,265 @@ use Doctrine\Persistence\ManagerRegistry;
* @method PlayedGame|null findOneBy(array $criteria, array $orderBy = null)
* @method PlayedGame[] findAll()
* @method PlayedGame[] findBy(array $criteria, array $orderBy = null, $limit = null, $offset = null)
* @method PlayedGame|null findOneByGameAssoc($gameAssoc)
*/
class PlayedGameRepository extends ServiceEntityRepository
{
/**
* PlayedGameRepository constructor.
*
* @param ManagerRegistry $registry
*/
public function __construct(ManagerRegistry $registry)
public function __construct(ManagerRegistry $registry, private readonly LoggerInterface $logger)
{
parent::__construct($registry, PlayedGame::class);
}
public function findOneByGameAssoc(string $gameAssoc): ?PlayedGame
{
$qb = $this->createQueryBuilder('p');
try {
return $qb
->where($qb->expr()->eq('p.gameAssoc', ':gameAssoc'))
->setParameter('gameAssoc', $gameAssoc)
->getQuery()
->getOneOrNullResult();
} catch (NonUniqueResultException $e) {
$this->logger->error($e->getMessage());
throw new RuntimeException(
"Unexpectedly multiple results found when looking up gameAssoc: $gameAssoc",
0,
$e,
);
}
}
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('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,
);
}
}
/**
* @return PlayedGame[]
*/
public function findRecentFinishedForUser(User $user, int $limit = 10): array
{
$qb = $this->createQueryBuilder('g');
return $qb
->addSelect('rr', 'bb', 'ra', 'ba')
->leftJoin('g.red', 'rr')
->leftJoin('g.blue', 'bb')
->leftJoin('g.redAnon', 'ra')
->leftJoin('g.blueAnon', 'ba')
->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)
->orderBy('g.updated', 'DESC')
->setMaxResults($limit)
->getQuery()
->getResult();
}
public function findWaitingGames(int $limit = 20): array
{
// Any legitimately waiting game was updated within the last 10 minutes.
// Abandoned games are stamped with updated = 2000-01-01, so they fail this filter.
$cutoff = new DateTime('-10 minutes');
$qb = $this->createQueryBuilder('p');
return $this->createQueryBuilder('p')
->where('p.resign IS NULL')
->andWhere('p.updated > :cutoff')
->andWhere(
'(p.red IS NOT NULL OR p.redAnon IS NOT NULL) AND (p.blue IS NULL AND p.blueAnon IS NULL)
OR (p.blue IS NOT NULL OR p.blueAnon IS NOT NULL) AND (p.red IS NULL AND p.redAnon IS NULL)'
)
return $qb
->where($qb->expr()->isNull('p.resign'))
->andWhere($qb->expr()->gt('p.updated', ':cutoff'))
->andWhere($qb->expr()->orX(
$qb->expr()->andX(
$qb->expr()->orX(
$qb->expr()->isNotNull('p.red'),
$qb->expr()->isNotNull('p.redAnon'),
),
$qb->expr()->isNull('p.blue'),
$qb->expr()->isNull('p.blueAnon'),
),
$qb->expr()->andX(
$qb->expr()->orX(
$qb->expr()->isNotNull('p.blue'),
$qb->expr()->isNotNull('p.blueAnon'),
),
$qb->expr()->isNull('p.red'),
$qb->expr()->isNull('p.redAnon'),
),
))
->orderBy('p.updated', 'DESC')
->setParameter('cutoff', $cutoff)
->setParameter('cutoff', new DateTime('-10 minutes'))
->setMaxResults($limit)
->getQuery()
->getResult();

View File

@@ -12,7 +12,10 @@ namespace App\Repository;
use App\Entity\User;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\ORM\NonUniqueResultException;
use Doctrine\Persistence\ManagerRegistry;
use Psr\Log\LoggerInterface;
use RuntimeException;
use Symfony\Component\Security\Core\Exception\UnsupportedUserException;
use Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface;
use Symfony\Component\Security\Core\User\PasswordUpgraderInterface;
@@ -31,14 +34,25 @@ use Symfony\Component\Security\Core\User\PasswordUpgraderInterface;
*/
class UserRepository extends ServiceEntityRepository implements PasswordUpgraderInterface
{
public function __construct(ManagerRegistry $registry)
public function __construct(ManagerRegistry $registry, private readonly LoggerInterface $logger)
{
parent::__construct($registry, User::class);
}
public function findOneByUsername(string $username): ?User
{
return $this->findOneBy(['username' => $username]);
$qb = $this->createQueryBuilder('u');
try {
return $qb
->where($qb->expr()->eq('u.username', ':username'))
->setParameter('username', $username)
->getQuery()
->getOneOrNullResult();
} catch (NonUniqueResultException $e) {
$this->logger->error($e->getMessage());
throw new RuntimeException("Multiple users found with the same username: $username", 0, $e);
}
}
public function upgradePassword(PasswordAuthenticatedUserInterface $user, string $newHashedPassword): void

View File

@@ -14,6 +14,7 @@ use App\Entity\Grid;
use App\Entity\GridRow;
use App\Entity\PlayedGame;
use App\Interfaces\RpcManagerInterface;
use App\Repository\PlayedGameRepository;
use DateTime;
use Doctrine\ORM\EntityManagerInterface;
use Exception;
@@ -40,6 +41,7 @@ class RpcManager implements RpcManagerInterface
public function __construct(
private readonly EntityManagerInterface $entityManager,
private readonly LoggerInterface $logger,
private readonly PlayedGameRepository $playedGameRepository,
) {
}
@@ -47,9 +49,7 @@ class RpcManager implements RpcManagerInterface
{
$gameAssoc = is_array($params) ? $params[0] : $params;
$playedGame = $this->entityManager
->getRepository(PlayedGame::class)
->findOneByGameAssoc($gameAssoc);
$playedGame = $this->playedGameRepository->findOneByGameAssoc($gameAssoc);
if (null === $playedGame) {
try {
@@ -77,9 +77,7 @@ class RpcManager implements RpcManagerInterface
public function saveGrid(string $gameAssoc): bool
{
$existingGame = $this->entityManager
->getRepository(PlayedGame::class)
->findOneByGameAssoc($gameAssoc);
$existingGame = $this->playedGameRepository->findOneByGameAssoc($gameAssoc);
if (null !== $existingGame) {
return true;

View File

@@ -16,6 +16,8 @@ use App\Entity\PlayedGame;
use App\Entity\Step;
use App\Entity\User;
use App\Interfaces\TopicManagerInterface;
use App\Repository\PlayedGameRepository;
use App\Repository\UserRepository;
use DateTimeInterface;
use DateTime;
use Doctrine\ORM\EntityManagerInterface;
@@ -37,12 +39,14 @@ use Symfony\Component\Security\Core\User\UserInterface;
* @link www.splendidbear.org
* @since 2026. 04. 09.
*/
class TopicManager implements TopicManagerInterface
readonly class TopicManager implements TopicManagerInterface
{
public function __construct(
private readonly HubInterface $hub,
private readonly EntityManagerInterface $entityManager,
private readonly LoggerInterface $logger
private HubInterface $hub,
private EntityManagerInterface $entityManager,
private LoggerInterface $logger,
private PlayedGameRepository $playedGameRepository,
private UserRepository $userRepository,
) {
}
@@ -403,9 +407,7 @@ class TopicManager implements TopicManagerInterface
private function getPlayedGame(string $gameAssoc): ?PlayedGame
{
return $this->entityManager
->getRepository(PlayedGame::class)
->findOneByGameAssoc($gameAssoc);
return $this->playedGameRepository->findOneByGameAssoc($gameAssoc);
}
private function getPlayerCount(array $users): int
@@ -475,9 +477,7 @@ class TopicManager implements TopicManagerInterface
private function saveRegisteredUser(string $userName, int $count, PlayedGame $playedGame): void
{
/** @var User $user */
$user = $this->entityManager
->getRepository(User::class)
->findOneByUsername($userName);
$user = $this->userRepository->findOneByUsername($userName);
try {
if ($count === 1) {
@@ -524,6 +524,45 @@ class TopicManager implements TopicManagerInterface
];
}
public function publishChallenge(string $targetGameAssoc, string $challengerGameAssoc): void
{
$challengerGame = $this->getPlayedGame($challengerGameAssoc);
$challengerName = 'Unknown';
if (null !== $challengerGame) {
$users = $this->getUserCollection($challengerGame);
$challengerName = $users['red'] ?: $users['redAnon'] ?: $users['blue'] ?: $users['blueAnon'] ?: 'Unknown';
}
try {
$this->hub->publish(new Update(
'mineseeker/channel/' . $targetGameAssoc,
json_encode([
'type' => 'challenge',
'challengerName' => $challengerName,
'challengerGameAssoc' => $challengerGameAssoc,
], JSON_THROW_ON_ERROR)
));
} catch (JsonException $e) {
$this->logger->error('Challenge publish error: ' . $e->getMessage());
}
}
public function publishChallengeResponse(string $challengerGameAssoc, bool $accepted, string $targetGameAssoc): void
{
try {
$this->hub->publish(new Update(
'mineseeker/channel/' . $challengerGameAssoc,
json_encode([
'type' => 'challenge-response',
'accepted' => $accepted,
'targetGameAssoc' => $targetGameAssoc,
], JSON_THROW_ON_ERROR)
));
} catch (JsonException $e) {
$this->logger->error('Challenge response publish error: ' . $e->getMessage());
}
}
private function publishToLobby(array $data): void
{
try {