Compare commits
6 Commits
v2026.2.1-
...
v2026.2.2-
| Author | SHA1 | Date | |
|---|---|---|---|
| 247f437445 | |||
| 0e94367223 | |||
| a9ee28b395 | |||
| bd074c5c9d | |||
| 42c552c528 | |||
| 3b376e5386 |
16
CHANGELOG.md
16
CHANGELOG.md
@@ -1,6 +1,22 @@
|
||||
# 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)
|
||||
|
||||
### Changes
|
||||
|
||||
4
Makefile
4
Makefile
@@ -11,6 +11,7 @@ help:
|
||||
@echo " make down - Stop and remove containers/networks"
|
||||
@echo " make prune-everything - Prune volumes, networks and images (DANGEROUS!)"
|
||||
@echo " make db-reset - Reset the database (drop, create, migrate) (DANGEROUS!)"
|
||||
@echo " make ccp - Clear the production cache"
|
||||
|
||||
start:
|
||||
docker compose up -d
|
||||
@@ -51,3 +52,6 @@ db-reset:
|
||||
bin/console doctrine:database:drop --force --if-exists --no-interaction
|
||||
bin/console doctrine:database:create --if-not-exists --no-interaction
|
||||
bin/console doctrine:migrations:migrate --no-interaction
|
||||
|
||||
ccp:
|
||||
bin/console cache:clear --no-warmup --env=prod
|
||||
|
||||
@@ -7,9 +7,4 @@
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
$font-path: "/build/webfonts";
|
||||
|
||||
@import '@fortawesome/fontawesome-free/scss/fontawesome';
|
||||
@import '@fortawesome/fontawesome-free/scss/brands';
|
||||
@import '@fortawesome/fontawesome-free/scss/solid';
|
||||
@import '@fortawesome/fontawesome-free/scss/regular';
|
||||
@import '@fortawesome/fontawesome-free/css/all.min.css';
|
||||
|
||||
@@ -1,14 +1,15 @@
|
||||
.hero-auth {
|
||||
position: absolute;
|
||||
top: 28px;
|
||||
right: 36px;
|
||||
#hero-auth {
|
||||
padding: 20px;
|
||||
|
||||
.hero-auth {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 10px;
|
||||
z-index: 10;
|
||||
}
|
||||
}
|
||||
|
||||
.hero-auth-user {
|
||||
.hero-auth-user {
|
||||
font: 600 13px 'Rajdhani', sans-serif;
|
||||
color: rgba(149, 207, 245, 0.75);
|
||||
letter-spacing: 0.5px;
|
||||
@@ -17,6 +18,13 @@
|
||||
gap: 6px;
|
||||
|
||||
i { font-size: 15px; }
|
||||
}
|
||||
|
||||
@media screen and (max-width: 1100px) {
|
||||
.hero-auth {
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.hero-auth-btn {
|
||||
|
||||
@@ -32,4 +32,12 @@ main div.txt a {
|
||||
transition: color 180ms;
|
||||
|
||||
&:hover { color: #c5e8ff; }
|
||||
}
|
||||
}
|
||||
|
||||
main div.txt img {
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
main div.txt .img-container {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
@@ -14,7 +14,6 @@ footer {
|
||||
gap: 40px;
|
||||
}
|
||||
|
||||
// Left: brand block
|
||||
.footer-brand {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -55,7 +54,6 @@ footer {
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
// Right: navigation
|
||||
.footer-nav-label {
|
||||
font: 700 11px 'Rajdhani', sans-serif;
|
||||
text-transform: uppercase;
|
||||
@@ -91,7 +89,6 @@ footer {
|
||||
}
|
||||
}
|
||||
|
||||
// Bottom copyright bar
|
||||
.footer-copy {
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.05);
|
||||
padding: 16px 60px;
|
||||
@@ -112,4 +109,4 @@ footer {
|
||||
|
||||
&:hover { color: #95cff5; }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -97,4 +97,4 @@
|
||||
font-size: 20px;
|
||||
letter-spacing: 4px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,7 +17,6 @@ main {
|
||||
}
|
||||
|
||||
.mine-container {
|
||||
background: url("/images/bg-mineseeker-0-outbg.jpg") no-repeat;
|
||||
background-size: cover;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
@@ -54,4 +53,4 @@ main {
|
||||
|
||||
-webkit-border-radius: 10px;
|
||||
border-radius: 10px;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -78,8 +78,6 @@
|
||||
}
|
||||
|
||||
#mine-wrapper .grid .field-wrapper .field .field-corner {
|
||||
background: url('/images/bg-corner-outbg.png') no-repeat top left;
|
||||
background-size: 100%;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
@@ -206,4 +204,4 @@
|
||||
|
||||
#mine-wrapper .grid .field-wrapper .field img {
|
||||
width: 80%;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -320,7 +320,6 @@ footer nav ul li {
|
||||
}
|
||||
|
||||
footer nav ul li:nth-child(even) {
|
||||
width: 50px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
@@ -401,8 +400,4 @@ footer nav ul li a:hover {
|
||||
footer nav ul li {
|
||||
display: block;
|
||||
}
|
||||
|
||||
footer nav ul li:nth-child(even) {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -53,7 +53,10 @@ const GridField = memo(function GridField({ cell, onClick, onMouseEnter }) {
|
||||
/>
|
||||
)}
|
||||
<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) && (
|
||||
<div className="flag-mine">
|
||||
<img src={currentImage} alt="" />
|
||||
|
||||
@@ -33,7 +33,11 @@ services:
|
||||
MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD}
|
||||
MINIO_ENDPOINT: http://minio: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:
|
||||
- app_var:/app/var
|
||||
- caddy_data:/data
|
||||
|
||||
@@ -23,12 +23,14 @@ security:
|
||||
auth_code_parameter_name: _auth_code
|
||||
post_only: true
|
||||
default_target_path: MineSeekerBundle_homepage
|
||||
always_use_default_target_path: false
|
||||
prepare_on_login: true
|
||||
prepare_on_access_denied: true
|
||||
form_login:
|
||||
login_path: MineSeekerBundle_login
|
||||
check_path: MineSeekerBundle_login
|
||||
default_target_path: MineSeekerBundle_homepage
|
||||
always_use_default_target_path: false
|
||||
username_parameter: _username
|
||||
password_parameter: _password
|
||||
enable_csrf: true
|
||||
|
||||
@@ -45,7 +45,7 @@
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"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/"
|
||||
}
|
||||
}
|
||||
|
||||
BIN
public/images/privileges/battle.png
Normal file
BIN
public/images/privileges/battle.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 24 KiB |
BIN
public/images/privileges/history.png
Normal file
BIN
public/images/privileges/history.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 12 KiB |
BIN
public/images/privileges/security.png
Normal file
BIN
public/images/privileges/security.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 32 KiB |
BIN
public/images/privileges/shared-battle.png
Normal file
BIN
public/images/privileges/shared-battle.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 16 KiB |
BIN
public/images/privileges/stat.png
Normal file
BIN
public/images/privileges/stat.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 27 KiB |
@@ -111,6 +111,12 @@ class GameController extends AbstractController
|
||||
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
|
||||
{
|
||||
try {
|
||||
|
||||
@@ -64,7 +64,7 @@ class MercureController extends AbstractController
|
||||
#[Route('/api/game/join/{gameAssoc}', name: 'MineSeekerBundle_api_game_join', methods: ['POST'])]
|
||||
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]);
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@ use App\Form\ResetPasswordFormType;
|
||||
use App\Repository\UserRepository;
|
||||
use DateTime;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use LogicException;
|
||||
use Symfony\Bridge\Twig\Mime\TemplatedEmail;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\DependencyInjection\Attribute\Autowire;
|
||||
@@ -64,7 +65,7 @@ class SecurityController extends AbstractController
|
||||
#[Route('/logout', name: 'MineSeekerBundle_logout', methods: ['POST'])]
|
||||
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')]
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
|
||||
namespace App\Interfaces;
|
||||
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\Security\Core\User\UserInterface;
|
||||
|
||||
/**
|
||||
@@ -24,7 +25,7 @@ use Symfony\Component\Security\Core\User\UserInterface;
|
||||
*/
|
||||
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;
|
||||
|
||||
|
||||
@@ -18,14 +18,15 @@ use App\Entity\User;
|
||||
use App\Interfaces\TopicManagerInterface;
|
||||
use App\Repository\PlayedGameRepository;
|
||||
use App\Repository\UserRepository;
|
||||
use Liip\ImagineBundle\Imagine\Cache\CacheManager;
|
||||
use DateTimeInterface;
|
||||
use DateTime;
|
||||
use DateTimeInterface;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Exception;
|
||||
use JsonException;
|
||||
use Liip\ImagineBundle\Imagine\Cache\CacheManager;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use RuntimeException;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\Mercure\HubInterface;
|
||||
use Symfony\Component\Mercure\Update;
|
||||
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);
|
||||
if (null === $playedGame) {
|
||||
@@ -70,7 +71,7 @@ readonly class TopicManager implements TopicManagerInterface
|
||||
|
||||
/** Save the player to the database on a fresh join */
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -168,9 +169,6 @@ readonly class TopicManager implements TopicManagerInterface
|
||||
return $data;
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ //
|
||||
// Normal move
|
||||
// ------------------------------------------------------------------ //
|
||||
$coords = $event['coords'];
|
||||
$player = $event['player']; // 'red' | 'blue'
|
||||
$isBomb = (bool)$event['bomb'];
|
||||
@@ -243,10 +241,6 @@ readonly class TopicManager implements TopicManagerInterface
|
||||
return $data;
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ //
|
||||
// Grid helpers
|
||||
// ------------------------------------------------------------------ //
|
||||
|
||||
/** Load the grid rows from the database as a 2-D array. */
|
||||
private function loadGrid(string $gameAssoc): array
|
||||
{
|
||||
@@ -403,10 +397,6 @@ readonly class TopicManager implements TopicManagerInterface
|
||||
return $mines;
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ //
|
||||
// Database helpers
|
||||
// ------------------------------------------------------------------ //
|
||||
|
||||
private function getPlayedGame(string $gameAssoc): ?PlayedGame
|
||||
{
|
||||
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);
|
||||
|
||||
null !== $user
|
||||
? $this->saveRegisteredUser($userName, $count, $playedGame)
|
||||
: $this->saveAnonUser($userName, $count, $playedGame);
|
||||
: $this->saveAnonUser($userName, $count, $playedGame, $request);
|
||||
|
||||
$this->entityManager->persist($playedGame);
|
||||
$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 {
|
||||
$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());
|
||||
$this->entityManager->persist($anon);
|
||||
|
||||
@@ -518,8 +516,8 @@ readonly class TopicManager implements TopicManagerInterface
|
||||
|
||||
private function getUserCollection(PlayedGame $playedGame): array
|
||||
{
|
||||
$redUser = $playedGame->getRed();
|
||||
$blueUser = $playedGame->getBlue();
|
||||
$redUser = $playedGame->getRed();
|
||||
$blueUser = $playedGame->getBlue();
|
||||
|
||||
return [
|
||||
'red' => null !== $redUser ? $redUser->getUsername() : '',
|
||||
@@ -527,11 +525,11 @@ readonly class TopicManager implements TopicManagerInterface
|
||||
'redAnon' => null !== $playedGame->getRedAnon() ? $playedGame->getRedAnon()->getUserName() : '',
|
||||
'blueAnon' => null !== $playedGame->getBlueAnon() ? $playedGame->getBlueAnon()->getUserName() : '',
|
||||
'redAvatar' => null !== $redUser && null !== $redUser->getAvatarPath()
|
||||
? $this->cacheManager->generateUrl($redUser->getAvatarPath(), 'avatar_thumb')
|
||||
: null,
|
||||
? $this->cacheManager->generateUrl($redUser->getAvatarPath(), 'avatar_thumb')
|
||||
: null,
|
||||
'blueAvatar' => null !== $blueUser && null !== $blueUser->getAvatarPath()
|
||||
? $this->cacheManager->generateUrl($blueUser->getAvatarPath(), 'avatar_thumb')
|
||||
: null,
|
||||
? $this->cacheManager->generateUrl($blueUser->getAvatarPath(), 'avatar_thumb')
|
||||
: null,
|
||||
];
|
||||
}
|
||||
|
||||
@@ -585,4 +583,27 @@ readonly class TopicManager implements TopicManagerInterface
|
||||
$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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,9 +23,7 @@
|
||||
{% endblock %}
|
||||
|
||||
{% block header %}
|
||||
<section
|
||||
class="hero{% if app.request.attributes.get('_route') != 'MineSeekerBundle_homepage' %} hero--compact{% endif %}">
|
||||
|
||||
<section id="hero-auth">
|
||||
<div class="hero-auth">
|
||||
{% if is_granted("IS_AUTHENTICATED_REMEMBERED") %}
|
||||
<a href="{{ path('MineSeekerBundle_profile') }}" class="hero-auth-btn hero-auth-btn--profile">
|
||||
@@ -56,7 +54,10 @@
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section
|
||||
class="hero{% if app.request.attributes.get('_route') != 'MineSeekerBundle_homepage' %} hero--compact{% endif %}">
|
||||
<a class="hero-logo" href="{{ path('MineSeekerBundle_homepage') }}">
|
||||
<img src="{{ asset('images/mine-logo-txt.png') }}" alt="MineSeeker"/>
|
||||
</a>
|
||||
@@ -253,6 +254,7 @@
|
||||
<p class="footer-nav-label">Navigate</p>
|
||||
<ul>
|
||||
<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_privacy') }}">Privacy Policy</a></li>
|
||||
<li><a href="{{ path('MineSeekerBundle_contact') }}">Contact</a></li>
|
||||
|
||||
144
templates/Official/rules.html.twig
Normal file
144
templates/Official/rules.html.twig
Normal 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 — <strong>Red</strong> and <strong>Blue</strong> — 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×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 — 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>“It is your turn! Make a move”</em>; while you wait it reads <em>“Your buddy is making a move”</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 — no points are awarded — 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 & 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’s flag and scores <strong>one point</strong> for you.</li>
|
||||
<li>You keep the turn and may click again — rack up a streak while you’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×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> — the first player to claim more than half of the mines (26 of 51) wins immediately.</li>
|
||||
<li><strong>A player resigns</strong> — the remaining player wins.</li>
|
||||
<li><strong>Draw</strong> — 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’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 & 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 — 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’ 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 %}
|
||||
Reference in New Issue
Block a user