Private
Public Access
1
0

Compare commits

..

6 Commits

Author SHA1 Message Date
247f437445 fix: pkg: the font-awesome simplifying to work on bare-metal - & fix all warnings at build time #4
All checks were successful
Deploy to Production / deploy (push) Successful in 14s
2026-04-18 11:42:46 +02:00
0e94367223 new: usr: add rules page #4 2026-04-18 11:11:52 +02:00
a9ee28b395 fix: usr: the css problem had been solved on reponsive gfx on homepage #4 2026-04-18 10:34:46 +02:00
bd074c5c9d chg: pkg: new version release !skipChangelog 2026-04-18 08:49:59 +02:00
42c552c528 fix: usr: quickfix for https-only login - & add user data when the user is not logged in #4
All checks were successful
Deploy to Production / deploy (push) Successful in 1m55s
2026-04-18 08:49:10 +02:00
3b376e5386 chg: pkg: new version release !skipChangelog 2026-04-16 11:56:30 +02:00
26 changed files with 267 additions and 63 deletions

View File

@@ -1,6 +1,22 @@
# Changelog # Changelog
## v2026.2.1-8 (2026-04-18)
### Fix
* Quickfix for https-only login - & add user data when the user is not logged in #4. [Lang]
## v2026.2.1-7 (2026-04-16)
### Changes
* Add consent checkbox to user's registration - and fix the sharing pics #4. [Lang]
* Add correct version numbering and CHANGELOG - and add the LICENSE #4. [Lang]
## v2026.2.1-6 (2026-04-16) ## v2026.2.1-6 (2026-04-16)
### Changes ### Changes

View File

@@ -11,6 +11,7 @@ help:
@echo " make down - Stop and remove containers/networks" @echo " make down - Stop and remove containers/networks"
@echo " make prune-everything - Prune volumes, networks and images (DANGEROUS!)" @echo " make prune-everything - Prune volumes, networks and images (DANGEROUS!)"
@echo " make db-reset - Reset the database (drop, create, migrate) (DANGEROUS!)" @echo " make db-reset - Reset the database (drop, create, migrate) (DANGEROUS!)"
@echo " make ccp - Clear the production cache"
start: start:
docker compose up -d docker compose up -d
@@ -51,3 +52,6 @@ db-reset:
bin/console doctrine:database:drop --force --if-exists --no-interaction bin/console doctrine:database:drop --force --if-exists --no-interaction
bin/console doctrine:database:create --if-not-exists --no-interaction bin/console doctrine:database:create --if-not-exists --no-interaction
bin/console doctrine:migrations:migrate --no-interaction bin/console doctrine:migrations:migrate --no-interaction
ccp:
bin/console cache:clear --no-warmup --env=prod

View File

@@ -7,9 +7,4 @@
* file that was distributed with this source code. * file that was distributed with this source code.
*/ */
$font-path: "/build/webfonts"; @import '@fortawesome/fontawesome-free/css/all.min.css';
@import '@fortawesome/fontawesome-free/scss/fontawesome';
@import '@fortawesome/fontawesome-free/scss/brands';
@import '@fortawesome/fontawesome-free/scss/solid';
@import '@fortawesome/fontawesome-free/scss/regular';

View File

@@ -1,14 +1,15 @@
.hero-auth { #hero-auth {
position: absolute; padding: 20px;
top: 28px;
right: 36px; .hero-auth {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: flex-end;
gap: 10px; gap: 10px;
z-index: 10; z-index: 10;
} }
.hero-auth-user { .hero-auth-user {
font: 600 13px 'Rajdhani', sans-serif; font: 600 13px 'Rajdhani', sans-serif;
color: rgba(149, 207, 245, 0.75); color: rgba(149, 207, 245, 0.75);
letter-spacing: 0.5px; letter-spacing: 0.5px;
@@ -17,6 +18,13 @@
gap: 6px; gap: 6px;
i { font-size: 15px; } i { font-size: 15px; }
}
@media screen and (max-width: 1100px) {
.hero-auth {
justify-content: center;
}
}
} }
.hero-auth-btn { .hero-auth-btn {

View File

@@ -32,4 +32,12 @@ main div.txt a {
transition: color 180ms; transition: color 180ms;
&:hover { color: #c5e8ff; } &:hover { color: #c5e8ff; }
} }
main div.txt img {
border-radius: 10px;
}
main div.txt .img-container {
text-align: center;
}

View File

@@ -14,7 +14,6 @@ footer {
gap: 40px; gap: 40px;
} }
// Left: brand block
.footer-brand { .footer-brand {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -55,7 +54,6 @@ footer {
line-height: 1.5; line-height: 1.5;
} }
// Right: navigation
.footer-nav-label { .footer-nav-label {
font: 700 11px 'Rajdhani', sans-serif; font: 700 11px 'Rajdhani', sans-serif;
text-transform: uppercase; text-transform: uppercase;
@@ -91,7 +89,6 @@ footer {
} }
} }
// Bottom copyright bar
.footer-copy { .footer-copy {
border-top: 1px solid rgba(255, 255, 255, 0.05); border-top: 1px solid rgba(255, 255, 255, 0.05);
padding: 16px 60px; padding: 16px 60px;
@@ -112,4 +109,4 @@ footer {
&:hover { color: #95cff5; } &:hover { color: #95cff5; }
} }
} }

View File

@@ -97,4 +97,4 @@
font-size: 20px; font-size: 20px;
letter-spacing: 4px; letter-spacing: 4px;
} }
} }

View File

@@ -17,7 +17,6 @@ main {
} }
.mine-container { .mine-container {
background: url("/images/bg-mineseeker-0-outbg.jpg") no-repeat;
background-size: cover; background-size: cover;
display: flex; display: flex;
justify-content: center; justify-content: center;
@@ -54,4 +53,4 @@ main {
-webkit-border-radius: 10px; -webkit-border-radius: 10px;
border-radius: 10px; border-radius: 10px;
} }

View File

@@ -78,8 +78,6 @@
} }
#mine-wrapper .grid .field-wrapper .field .field-corner { #mine-wrapper .grid .field-wrapper .field .field-corner {
background: url('/images/bg-corner-outbg.png') no-repeat top left;
background-size: 100%;
width: 100%; width: 100%;
height: 100%; height: 100%;
} }
@@ -206,4 +204,4 @@
#mine-wrapper .grid .field-wrapper .field img { #mine-wrapper .grid .field-wrapper .field img {
width: 80%; width: 80%;
} }

View File

@@ -320,7 +320,6 @@ footer nav ul li {
} }
footer nav ul li:nth-child(even) { footer nav ul li:nth-child(even) {
width: 50px;
text-align: center; text-align: center;
} }
@@ -401,8 +400,4 @@ footer nav ul li a:hover {
footer nav ul li { footer nav ul li {
display: block; display: block;
} }
footer nav ul li:nth-child(even) {
display: none;
}
} }

View File

@@ -53,7 +53,10 @@ const GridField = memo(function GridField({ cell, onClick, onMouseEnter }) {
/> />
)} )}
<div className={fieldClass}> <div className={fieldClass}>
<div className="field-corner"> <div
style={{ background: "url('/images/bg-corner-outbg.png') no-repeat top left / 100% 100%" }}
className="field-corner"
>
{isNaN(currentImage) && ( {isNaN(currentImage) && (
<div className="flag-mine"> <div className="flag-mine">
<img src={currentImage} alt="" /> <img src={currentImage} alt="" />

View File

@@ -33,7 +33,11 @@ services:
MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD} MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD}
MINIO_ENDPOINT: http://minio:9000 MINIO_ENDPOINT: http://minio:9000
MINIO_PUBLIC_URL: ${MINIO_PUBLIC_URL:-http://localhost:9000} MINIO_PUBLIC_URL: ${MINIO_PUBLIC_URL:-http://localhost:9000}
TRUSTED_PROXIES: ${TRUSTED_PROXIES} # IMPORTANT: Set TRUSTED_PROXIES to your reverse proxy IP in production.
# For Docker on same host, use: 172.17.0.1 (default bridge) or 172.16.0.0/12 (overlay network)
# For Kubernetes or external proxy, use the proxy's IP address.
# WARNING: Using 0.0.0.0/0 is insecure in production environments!
TRUSTED_PROXIES: ${TRUSTED_PROXIES:-127.0.0.1}
volumes: volumes:
- app_var:/app/var - app_var:/app/var
- caddy_data:/data - caddy_data:/data

View File

@@ -23,12 +23,14 @@ security:
auth_code_parameter_name: _auth_code auth_code_parameter_name: _auth_code
post_only: true post_only: true
default_target_path: MineSeekerBundle_homepage default_target_path: MineSeekerBundle_homepage
always_use_default_target_path: false
prepare_on_login: true prepare_on_login: true
prepare_on_access_denied: true prepare_on_access_denied: true
form_login: form_login:
login_path: MineSeekerBundle_login login_path: MineSeekerBundle_login
check_path: MineSeekerBundle_login check_path: MineSeekerBundle_login
default_target_path: MineSeekerBundle_homepage default_target_path: MineSeekerBundle_homepage
always_use_default_target_path: false
username_parameter: _username username_parameter: _username
password_parameter: _password password_parameter: _password
enable_csrf: true enable_csrf: true

View File

@@ -45,7 +45,7 @@
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
"watch": "vite build --watch", "watch": "vite build --watch",
"build": "vite build && cp -r node_modules/@fortawesome/fontawesome-free/webfonts public/build/webfonts", "build": "vite build",
"lint": "eslint assets/js/" "lint": "eslint assets/js/"
} }
} }

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

View File

@@ -111,6 +111,12 @@ class GameController extends AbstractController
return $this->render('Official/landing.html.twig'); return $this->render('Official/landing.html.twig');
} }
#[Route('/rules', name: 'MineSeekerBundle_rules')]
public function rules(): Response
{
return $this->render('Official/rules.html.twig');
}
public function sendMail(MailerInterface $mailer, ContactMessage $contactMessage): void public function sendMail(MailerInterface $mailer, ContactMessage $contactMessage): void
{ {
try { try {

View File

@@ -64,7 +64,7 @@ class MercureController extends AbstractController
#[Route('/api/game/join/{gameAssoc}', name: 'MineSeekerBundle_api_game_join', methods: ['POST'])] #[Route('/api/game/join/{gameAssoc}', name: 'MineSeekerBundle_api_game_join', methods: ['POST'])]
public function join(string $gameAssoc, Request $request): JsonResponse public function join(string $gameAssoc, Request $request): JsonResponse
{ {
$this->topicManager->subscribe($gameAssoc, $this->resolveUserName($request), $this->getUser()); $this->topicManager->subscribe($gameAssoc, $this->resolveUserName($request), $this->getUser(), $request);
return $this->json(['success' => true]); return $this->json(['success' => true]);
} }

View File

@@ -17,6 +17,7 @@ use App\Form\ResetPasswordFormType;
use App\Repository\UserRepository; use App\Repository\UserRepository;
use DateTime; use DateTime;
use Doctrine\ORM\EntityManagerInterface; use Doctrine\ORM\EntityManagerInterface;
use LogicException;
use Symfony\Bridge\Twig\Mime\TemplatedEmail; use Symfony\Bridge\Twig\Mime\TemplatedEmail;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\DependencyInjection\Attribute\Autowire; use Symfony\Component\DependencyInjection\Attribute\Autowire;
@@ -64,7 +65,7 @@ class SecurityController extends AbstractController
#[Route('/logout', name: 'MineSeekerBundle_logout', methods: ['POST'])] #[Route('/logout', name: 'MineSeekerBundle_logout', methods: ['POST'])]
public function logout(): never public function logout(): never
{ {
throw new \LogicException('This action is intercepted by the security firewall.'); throw new LogicException('This action is intercepted by the security firewall.');
} }
#[Route('/register', name: 'MineSeekerBundle_register')] #[Route('/register', name: 'MineSeekerBundle_register')]

View File

@@ -10,6 +10,7 @@
namespace App\Interfaces; namespace App\Interfaces;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Security\Core\User\UserInterface; use Symfony\Component\Security\Core\User\UserInterface;
/** /**
@@ -24,7 +25,7 @@ use Symfony\Component\Security\Core\User\UserInterface;
*/ */
interface TopicManagerInterface interface TopicManagerInterface
{ {
public function subscribe(string $gameAssoc, string $userName, ?UserInterface $user): void; public function subscribe(string $gameAssoc, string $userName, ?UserInterface $user, Request $request): void;
public function unSubscribe(string $gameAssoc, string $userName): void; public function unSubscribe(string $gameAssoc, string $userName): void;

View File

@@ -18,14 +18,15 @@ use App\Entity\User;
use App\Interfaces\TopicManagerInterface; use App\Interfaces\TopicManagerInterface;
use App\Repository\PlayedGameRepository; use App\Repository\PlayedGameRepository;
use App\Repository\UserRepository; use App\Repository\UserRepository;
use Liip\ImagineBundle\Imagine\Cache\CacheManager;
use DateTimeInterface;
use DateTime; use DateTime;
use DateTimeInterface;
use Doctrine\ORM\EntityManagerInterface; use Doctrine\ORM\EntityManagerInterface;
use Exception; use Exception;
use JsonException; use JsonException;
use Liip\ImagineBundle\Imagine\Cache\CacheManager;
use Psr\Log\LoggerInterface; use Psr\Log\LoggerInterface;
use RuntimeException; use RuntimeException;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Mercure\HubInterface; use Symfony\Component\Mercure\HubInterface;
use Symfony\Component\Mercure\Update; use Symfony\Component\Mercure\Update;
use Symfony\Component\Security\Core\User\UserInterface; use Symfony\Component\Security\Core\User\UserInterface;
@@ -52,7 +53,7 @@ readonly class TopicManager implements TopicManagerInterface
) { ) {
} }
public function subscribe(string $gameAssoc, string $userName, ?UserInterface $user): void public function subscribe(string $gameAssoc, string $userName, ?UserInterface $user, Request $request): void
{ {
$playedGame = $this->getPlayedGame($gameAssoc); $playedGame = $this->getPlayedGame($gameAssoc);
if (null === $playedGame) { if (null === $playedGame) {
@@ -70,7 +71,7 @@ readonly class TopicManager implements TopicManagerInterface
/** Save the player to the database on a fresh join */ /** Save the player to the database on a fresh join */
if (!$isKnown && $count < 2) { if (!$isKnown && $count < 2) {
$users = $this->saveUserToDb($gameAssoc, $userName, $user, $count + 1); $users = $this->saveUserToDb($gameAssoc, $userName, $user, $count + 1, $request);
$count = $this->getPlayerCount($users); $count = $this->getPlayerCount($users);
} }
@@ -168,9 +169,6 @@ readonly class TopicManager implements TopicManagerInterface
return $data; return $data;
} }
// ------------------------------------------------------------------ //
// Normal move
// ------------------------------------------------------------------ //
$coords = $event['coords']; $coords = $event['coords'];
$player = $event['player']; // 'red' | 'blue' $player = $event['player']; // 'red' | 'blue'
$isBomb = (bool)$event['bomb']; $isBomb = (bool)$event['bomb'];
@@ -243,10 +241,6 @@ readonly class TopicManager implements TopicManagerInterface
return $data; return $data;
} }
// ------------------------------------------------------------------ //
// Grid helpers
// ------------------------------------------------------------------ //
/** Load the grid rows from the database as a 2-D array. */ /** Load the grid rows from the database as a 2-D array. */
private function loadGrid(string $gameAssoc): array private function loadGrid(string $gameAssoc): array
{ {
@@ -403,10 +397,6 @@ readonly class TopicManager implements TopicManagerInterface
return $mines; return $mines;
} }
// ------------------------------------------------------------------ //
// Database helpers
// ------------------------------------------------------------------ //
private function getPlayedGame(string $gameAssoc): ?PlayedGame private function getPlayedGame(string $gameAssoc): ?PlayedGame
{ {
return $this->playedGameRepository->findOneByGameAssoc($gameAssoc); return $this->playedGameRepository->findOneByGameAssoc($gameAssoc);
@@ -462,13 +452,18 @@ readonly class TopicManager implements TopicManagerInterface
} }
} }
private function saveUserToDb(string $gameAssoc, string $userName, ?UserInterface $user, int $count): array private function saveUserToDb(
{ string $gameAssoc,
string $userName,
?UserInterface $user,
int $count,
Request $request
): array {
$playedGame = $this->getPlayedGame($gameAssoc); $playedGame = $this->getPlayedGame($gameAssoc);
null !== $user null !== $user
? $this->saveRegisteredUser($userName, $count, $playedGame) ? $this->saveRegisteredUser($userName, $count, $playedGame)
: $this->saveAnonUser($userName, $count, $playedGame); : $this->saveAnonUser($userName, $count, $playedGame, $request);
$this->entityManager->persist($playedGame); $this->entityManager->persist($playedGame);
$this->entityManager->flush(); $this->entityManager->flush();
@@ -495,11 +490,14 @@ readonly class TopicManager implements TopicManagerInterface
} }
} }
private function saveAnonUser(string $userName, int $count, PlayedGame $playedGame): void private function saveAnonUser(string $userName, int $count, PlayedGame $playedGame, Request $request): void
{ {
try { try {
$anon = new Gamer(); $anon = new Gamer();
$anon->setUsername($userName); $anon->setUserName($userName);
$anon->setIp($request->getClientIp());
$anon->setCountry($this->extractCountry($request));
$anon->setUserAgent($request->headers->get('User-Agent'));
$anon->setConnTimestamp(new DateTime()); $anon->setConnTimestamp(new DateTime());
$this->entityManager->persist($anon); $this->entityManager->persist($anon);
@@ -518,8 +516,8 @@ readonly class TopicManager implements TopicManagerInterface
private function getUserCollection(PlayedGame $playedGame): array private function getUserCollection(PlayedGame $playedGame): array
{ {
$redUser = $playedGame->getRed(); $redUser = $playedGame->getRed();
$blueUser = $playedGame->getBlue(); $blueUser = $playedGame->getBlue();
return [ return [
'red' => null !== $redUser ? $redUser->getUsername() : '', 'red' => null !== $redUser ? $redUser->getUsername() : '',
@@ -527,11 +525,11 @@ readonly class TopicManager implements TopicManagerInterface
'redAnon' => null !== $playedGame->getRedAnon() ? $playedGame->getRedAnon()->getUserName() : '', 'redAnon' => null !== $playedGame->getRedAnon() ? $playedGame->getRedAnon()->getUserName() : '',
'blueAnon' => null !== $playedGame->getBlueAnon() ? $playedGame->getBlueAnon()->getUserName() : '', 'blueAnon' => null !== $playedGame->getBlueAnon() ? $playedGame->getBlueAnon()->getUserName() : '',
'redAvatar' => null !== $redUser && null !== $redUser->getAvatarPath() 'redAvatar' => null !== $redUser && null !== $redUser->getAvatarPath()
? $this->cacheManager->generateUrl($redUser->getAvatarPath(), 'avatar_thumb') ? $this->cacheManager->generateUrl($redUser->getAvatarPath(), 'avatar_thumb')
: null, : null,
'blueAvatar' => null !== $blueUser && null !== $blueUser->getAvatarPath() 'blueAvatar' => null !== $blueUser && null !== $blueUser->getAvatarPath()
? $this->cacheManager->generateUrl($blueUser->getAvatarPath(), 'avatar_thumb') ? $this->cacheManager->generateUrl($blueUser->getAvatarPath(), 'avatar_thumb')
: null, : null,
]; ];
} }
@@ -585,4 +583,27 @@ readonly class TopicManager implements TopicManagerInterface
$this->logger->error('Lobby publish error: ' . $e->getMessage()); $this->logger->error('Lobby publish error: ' . $e->getMessage());
} }
} }
private function extractCountry(Request $request): ?string
{
/** Common headers used by CDNs and proxies to pass country information */
$countryHeaders = [
'CF-IPCountry', // Cloudflare
'CloudFront-Viewer-Country', // AWS CloudFront
'X-Country-Code', // Custom header
'X-Geoip-Country', // Generic GeoIP header
];
foreach ($countryHeaders as $header) {
$country = $request->headers->get($header);
if (empty($country)) {
continue;
}
return substr($country, 0, 100);
}
return null;
}
} }

View File

@@ -23,9 +23,7 @@
{% endblock %} {% endblock %}
{% block header %} {% block header %}
<section <section id="hero-auth">
class="hero{% if app.request.attributes.get('_route') != 'MineSeekerBundle_homepage' %} hero--compact{% endif %}">
<div class="hero-auth"> <div class="hero-auth">
{% if is_granted("IS_AUTHENTICATED_REMEMBERED") %} {% if is_granted("IS_AUTHENTICATED_REMEMBERED") %}
<a href="{{ path('MineSeekerBundle_profile') }}" class="hero-auth-btn hero-auth-btn--profile"> <a href="{{ path('MineSeekerBundle_profile') }}" class="hero-auth-btn hero-auth-btn--profile">
@@ -56,7 +54,10 @@
</a> </a>
{% endif %} {% endif %}
</div> </div>
</section>
<section
class="hero{% if app.request.attributes.get('_route') != 'MineSeekerBundle_homepage' %} hero--compact{% endif %}">
<a class="hero-logo" href="{{ path('MineSeekerBundle_homepage') }}"> <a class="hero-logo" href="{{ path('MineSeekerBundle_homepage') }}">
<img src="{{ asset('images/mine-logo-txt.png') }}" alt="MineSeeker"/> <img src="{{ asset('images/mine-logo-txt.png') }}" alt="MineSeeker"/>
</a> </a>
@@ -253,6 +254,7 @@
<p class="footer-nav-label">Navigate</p> <p class="footer-nav-label">Navigate</p>
<ul> <ul>
<li><a href="{{ path('MineSeekerBundle_homepage') }}">Homepage</a></li> <li><a href="{{ path('MineSeekerBundle_homepage') }}">Homepage</a></li>
<li><a href="{{ path('MineSeekerBundle_rules') }}">Game Rules</a></li>
<li><a href="{{ path('MineSeekerBundle_terms') }}">Terms of Use</a></li> <li><a href="{{ path('MineSeekerBundle_terms') }}">Terms of Use</a></li>
<li><a href="{{ path('MineSeekerBundle_privacy') }}">Privacy Policy</a></li> <li><a href="{{ path('MineSeekerBundle_privacy') }}">Privacy Policy</a></li>
<li><a href="{{ path('MineSeekerBundle_contact') }}">Contact</a></li> <li><a href="{{ path('MineSeekerBundle_contact') }}">Contact</a></li>

View File

@@ -0,0 +1,144 @@
{% extends 'Game/index.html.twig' %}
{% block title %} - Game Rules{% endblock %}
{% block metas %}
{%- set _ogImage = 'https://' ~ app.request.host ~ asset('/images/mine-1600x627.png') -%}
<meta property="og:url" content="{{ url('MineSeekerBundle_rules') | replace({'http://': 'https://'}) }}"/>
<meta property="og:type" content="website"/>
<meta property="og:site_name" content="MineSeeker"/>
<meta property="og:title" content="Game Rules · MineSeeker"/>
<meta property="og:description" content="Learn how to play MineSeeker and discover what you unlock by creating a free account."/>
<meta property="og:image" content="{{ _ogImage }}"/>
<meta property="og:image:width" content="1600"/>
<meta property="og:image:height" content="627"/>
<meta name="twitter:card" content="summary_large_image"/>
<meta name="twitter:title" content="Game Rules · MineSeeker"/>
<meta name="twitter:description" content="Learn how to play MineSeeker and discover what you unlock by creating a free account."/>
<meta name="twitter:image" content="{{ _ogImage }}"/>
{% endblock %}
{% block body %}
<div class="txt">
<h2>MineSeeker Game Rules</h2>
<p>MineSeeker is a real-time 1v1 twist on the classic minesweeper formula. Two players &mdash; <strong>Red</strong> and <strong>Blue</strong> &mdash; race over the same hidden minefield, taking turns to <strong>hunt the mines</strong>. Each mine you detonate is claimed in your colour and scores a point. The first player to claim the majority of the mines wins.</p>
<h3>1. The Board</h3>
<ul>
<li>The playing field is a <strong>16&times;16 grid</strong> of covered cells.</li>
<li><strong>51 mines</strong> are hidden randomly across the board at the start of each match.</li>
<li>Every non-mine cell displays a number indicating how many of its eight neighbours contain a mine &mdash; these numbers are your clues. Cells with no adjacent mines are empty.</li>
</ul>
<h3>2. Turn Order</h3>
<ul>
<li>Players alternate turns. On your turn the status bar reads <em>&ldquo;It is your turn! Make a move&rdquo;</em>; while you wait it reads <em>&ldquo;Your buddy is making a move&rdquo;</em>.</li>
<li>On your turn you must perform exactly one action: reveal a cell, flag/unflag a cell, or deploy your bomb.</li>
<li><strong>If you hit a mine, you keep your turn</strong> and may click again. Your turn only ends when you reveal a safe cell (or use your bomb).</li>
</ul>
<h3>3. Revealing Cells</h3>
<ul>
<li><strong>Left-click</strong> a covered cell to reveal it.</li>
<li>Revealing a <strong>numbered cell</strong> just uncovers the clue &mdash; no points are awarded &mdash; and your turn ends.</li>
<li>Revealing an <strong>empty cell</strong> triggers a <strong>flood-fill</strong> that opens all connected empty cells and their numbered borders in a single move. Flood-fill will never step onto a mine, so empty-area sweeps are always safe.</li>
<li><strong>Right-click</strong> a covered cell to place a flag where you suspect a mine. Flagged cells cannot be revealed until unflagged.</li>
</ul>
<h3>4. Claiming Mines &amp; Scoring</h3>
<ul>
<li>Clicking a mine is the <strong>goal</strong> of the game, not a failure. The mine is marked with your colour&rsquo;s flag and scores <strong>one point</strong> for you.</li>
<li>You keep the turn and may click again &mdash; rack up a streak while you&rsquo;re hot.</li>
<li>Your turn only ends when you finally reveal a safe cell (or deploy your bomb).</li>
</ul>
<h3>5. The Bomb</h3>
<ul>
<li>Each player carries <strong>one bomb</strong> per match.</li>
<li>Detonating your bomb clears a <strong>5&times;5 blast radius</strong> (25 cells) around the target. <strong>Every mine inside the radius is claimed for your colour and adds to your score.</strong> Numbered cells in the radius are also revealed.</li>
<li>The bomb consumes your turn and can only be used once. Save it for a dense patch of suspected mines to burst ahead on the scoreboard.</li>
</ul>
<h3>6. Winning the Match</h3>
<p>A match ends in one of three ways:</p>
<ul>
<li><strong>Majority reached</strong> &mdash; the first player to claim more than half of the mines (26 of 51) wins immediately.</li>
<li><strong>A player resigns</strong> &mdash; the remaining player wins.</li>
<li><strong>Draw</strong> &mdash; if neither player reaches the majority and scores end up equal, the match is recorded as a draw.</li>
</ul>
<h3>7. Playing as a Guest</h3>
<p>No account is required to play. Just open the game, share the match link with a friend, and play. Guest matches are not saved to a history and carry no stats.</p>
<h2 style="margin-top: 40px;">Registered User Privileges</h2>
<p>Creating a free account unlocks everything the guest experience leaves behind. Registration takes under a minute and your email is only used for account recovery.</p>
<h3>1. Persistent Game History</h3>
<ul>
<li>Every match you play is recorded with timestamps, the full move list, the final grid, and your opponent&rsquo;s name.</li>
<li>Replay past battles cell-by-cell and share them with a public UUID link so friends can watch your finest detonations.</li>
</ul>
<div class="img-container">
<img style="margin-top: 15px;" src="{{ asset('images/privileges/history.png') }}" alt="Recent Game History" />
</div>
<h3>2. Player Statistics</h3>
<ul>
<li>Total games, wins, losses, and draws.</li>
<li>Win rate percentage, average score, personal best score, and total mines hit.</li>
<li>A 6-month trend dashboard charting wins, losses, and draws per month.</li>
</ul>
<div class="img-container">
<img style="margin-top: 15px;" src="{{ asset('images/privileges/stat.png') }}" alt="Statistics" />
</div>
<h3>3. Profile &amp; Identity</h3>
<ul>
<li>Upload a custom <strong>avatar</strong> that appears next to your username on the board and in the shared battles.</li>
<li>Your username is reserved &mdash; no one else can take it.</li>
</ul>
<h3>4. Account Security</h3>
<ul>
<li><strong>Two-factor authentication (TOTP):</strong> Protect your account with an authenticator app and a set of one-time backup codes.</li>
<li><strong>WebAuthn passkeys:</strong> Register one or more hardware/biometric security keys for passwordless sign-in.</li>
<li>A dedicated <strong>Security</strong> dashboard to manage backup codes, review registered credentials, and rotate them at any time.</li>
</ul>
<div class="img-container">
<img style="margin-top: 15px;" src="{{ asset('images/privileges/security.png') }}" alt="Security Dashboard" />
</div>
<h3>5. Shareable Battle Pages</h3>
<ul>
<li>Each recorded match gets a public page with both players&rsquo; names, avatars, final scores, the outcome, and a compact summary of how it played out.</li>
<li>Perfect for proving that impossible last-turn comeback.</li>
</ul>
<div class="img-container">
<img style="margin-top: 15px;" src="{{ asset('images/privileges/battle.png') }}" alt="Shareable Battle Pages" />
</div>
<div class="img-container">
<img style="margin-top: 15px;" src="{{ asset('images/privileges/shared-battle.png') }}" alt="Shared Battle Page" />
</div>
<p style="margin-top: 32px;">Ready to level up? <a href="{{ path('MineSeekerBundle_register') }}">Create your free account</a> or <a href="{{ path('MineSeekerBundle_gamePlay') }}">jump straight into a match</a>.</p>
</div>
{% endblock %}