Private
Public Access
1
0

Compare commits

...

20 Commits

Author SHA1 Message Date
ba8a0befb0 new: pkg: add Firebase deps to back-end #7
All checks were successful
Deploy to Production / deploy (push) Successful in 14s
2026-04-19 18:32:45 +02:00
5ac291de81 new: usr: add missing buttons for overlays #7 2026-04-19 18:22:28 +02:00
991b114a3c new: usr: a new feature came up - the abandoned plays can be restored, if both users are registered users #7 2026-04-19 18:04:01 +02:00
c79584c7d2 chg: usr: fix the '0' in Battle reports #6 2026-04-19 09:25:58 +02:00
e77c8a8f7c chg: usr: fix missing icons on "Battle report" #6 2026-04-19 09:10:17 +02:00
c2308ba408 fix: usr: the bomb using was not recorded correctly - the old data will be corrupted #6 2026-04-19 09:05:53 +02:00
e5a22cdfe3 chg: pkg: upgrade fe deps #5
All checks were successful
Deploy to Production / deploy (push) Successful in 17s
2026-04-18 22:12:20 +02:00
09b0d21621 new: usr: add new profile charts and stats - & add new logo to the tech stack #5 2026-04-18 22:12:07 +02:00
9aef27a0eb chg: usr: improve the Battle reports to change unnecessary data with interesting data #5 2026-04-18 17:56:50 +02:00
c00ed57240 fix: dev: the react is crashing on some cases #5 2026-04-18 17:53:02 +02:00
ef4cf6ef69 chg: pkg: new version release !skipChangelog 2026-04-18 13:45:10 +02:00
dc9c5f6545 chg: usr: add extended data to battle reports and sharing image to make viewable bonus points #5
All checks were successful
Deploy to Production / deploy (push) Successful in 12s
2026-04-18 13:44:15 +02:00
25f2aaab8c new: usr: add initialization bonus points' system to the gameplay #5 2026-04-18 12:57:20 +02:00
0cc9cdaf07 chg: pkg: new version release !skipChangelog 2026-04-18 11:44:18 +02:00
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
73 changed files with 3293 additions and 635 deletions

View File

@@ -1,6 +1,46 @@
# Changelog # Changelog
## v2026.2.3-0 (2026-04-18)
### New
* Add initialization bonus points' system to the gameplay #5. [Lang]
### Changes
* Add extended data to battle reports and sharing image to make viewable bonus points #5. [Lang]
## v2026.2.2-9 (2026-04-18)
### New
* Add rules page #4. [Lang]
### Fix
* The font-awesome simplifying to work on bare-metal - & fix all warnings at build time #4. [Lang]
* The css problem had been solved on reponsive gfx on homepage #4. [Lang]
## 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

@@ -1,4 +1,4 @@
.PHONY: help start start-build stop build down ps logs prune-everything db-reset mercure-jwt .PHONY: help start start-build stop build down ps logs prune-everything db-reset mercure-jwt cache-clear og-cache-clear
.DEFAULT_GOAL := help .DEFAULT_GOAL := help
@@ -11,6 +11,9 @@ 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"
@echo " make cache-clear - Clear all caches (Vite, Symfony, node_modules)"
@echo " make og-cache-clear - Clear Open Graph cache only"
start: start:
docker compose up -d docker compose up -d
@@ -51,3 +54,31 @@ 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
cache-clear:
@echo "Clearing all caches..."
@rm -rf node_modules/.vite
@rm -rf .vite
@rm -rf var/og-cache
@php bin/console cache:clear --no-warmup
@echo "✓ Vite cache cleared"
@echo "✓ OG cache cleared"
@echo "✓ Symfony cache cleared"
@echo ""
@echo "Rebuilding assets..."
@bun run build
@echo ""
@echo "✓ All caches cleared and assets rebuilt!"
@echo " Next step: Refresh browser with Ctrl+Shift+R"
og-cache-clear:
@echo "Clearing Open Graph cache..."
@rm -rf var/og-cache
@echo "✓ OG cache cleared!"
@echo " Battle card images will be regenerated on next access"

View File

@@ -287,6 +287,14 @@ git push origin v2026.01
--- ---
## Game Documentation
For detailed information about game mechanics, bonus systems, and scoring rules, see the [docs](./docs/) directory:
- **[Bonus Points System](./docs/game-mechanics/BONUS_POINTS_SYSTEM.md)** — Complete reference for all bonus point types, calculation rules, and implementation details
---
## License ## License
LGPL-3.0 — see [LICENSE](LICENSE) for details. LGPL-3.0 — see [LICENSE](LICENSE) for details.

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,9 +1,10 @@
#hero-auth {
padding: 20px;
.hero-auth { .hero-auth {
position: absolute;
top: 28px;
right: 36px;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: flex-end;
gap: 10px; gap: 10px;
z-index: 10; z-index: 10;
} }
@@ -19,6 +20,13 @@
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 {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;

View File

@@ -33,3 +33,11 @@ main div.txt a {
&: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;

View File

@@ -210,11 +210,43 @@
} }
} }
&--best { &--bonus {
border-color: rgba(255, 215, 0, 0.15); border-color: rgba(255, 215, 0, 0.18);
&:hover { &:hover {
border-color: rgba(255, 215, 0, 0.4); border-color: rgba(255, 215, 0, 0.45);
}
}
&--avg-bonus {
border-color: rgba(230, 184, 60, 0.18);
&:hover {
border-color: rgba(230, 184, 60, 0.45);
}
}
&--chain {
border-color: rgba(94, 232, 154, 0.15);
&:hover {
border-color: rgba(94, 232, 154, 0.4);
}
}
&--blind {
border-color: rgba(255, 140, 90, 0.15);
&:hover {
border-color: rgba(255, 140, 90, 0.4);
}
}
&--edge {
border-color: rgba(168, 210, 255, 0.15);
&:hover {
border-color: rgba(168, 210, 255, 0.4);
} }
} }
} }
@@ -248,8 +280,24 @@
color: rgba(80, 200, 220, 0.35); color: rgba(80, 200, 220, 0.35);
} }
.profile-stat--best & { .profile-stat--bonus & {
color: rgba(255, 215, 0, 0.3); color: rgba(255, 215, 0, 0.35);
}
.profile-stat--avg-bonus & {
color: rgba(230, 184, 60, 0.3);
}
.profile-stat--chain & {
color: rgba(94, 232, 154, 0.3);
}
.profile-stat--blind & {
color: rgba(255, 140, 90, 0.3);
}
.profile-stat--edge & {
color: rgba(168, 210, 255, 0.3);
} }
} }
@@ -289,9 +337,25 @@
color: #50c8dc; color: #50c8dc;
} }
.profile-stat--best & { .profile-stat--bonus & {
color: #ffd700; color: #ffd700;
} }
.profile-stat--avg-bonus & {
color: #e6b83c;
}
.profile-stat--chain & {
color: #5ee89a;
}
.profile-stat--blind & {
color: #ff8c5a;
}
.profile-stat--edge & {
color: #a8d2ff;
}
} }
.profile-stat__label { .profile-stat__label {
@@ -371,7 +435,7 @@
.profile-game { .profile-game {
display: grid; display: grid;
grid-template-columns: 26px 76px 22px 1fr 18px auto; grid-template-columns: 60px 76px 22px 1fr 18px auto;
align-items: center; align-items: center;
gap: 10px; gap: 10px;
padding: 11px 16px; padding: 11px 16px;
@@ -400,17 +464,27 @@
&--draw { &--draw {
border-left-color: rgba(149, 207, 245, 0.25); border-left-color: rgba(149, 207, 245, 0.25);
} }
&--ongoing {
border-left-color: rgba(255, 193, 7, 0.4);
opacity: 0.85;
}
} }
.profile-game__badge { .profile-game__badge {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
width: 20px; width: 100%;
min-width: 0;
height: 20px; height: 20px;
border-radius: 4px; border-radius: 4px;
font: 800 10px 'Rajdhani', sans-serif; font: 800 10px 'Rajdhani', sans-serif;
letter-spacing: 0; letter-spacing: 0;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
gap: 4px;
.profile-game--win & { .profile-game--win & {
background: rgba(42, 158, 96, 0.18); background: rgba(42, 158, 96, 0.18);
@@ -426,12 +500,49 @@
background: rgba(149, 207, 245, 0.1); background: rgba(149, 207, 245, 0.1);
color: rgba(149, 207, 245, 0.65); color: rgba(149, 207, 245, 0.65);
} }
.profile-game--ongoing & {
background: rgba(255, 193, 7, 0.12);
color: #ffc107;
display: flex;
align-items: center;
justify-content: center;
gap: 6px;
&::before {
content: '';
display: inline-block;
width: 14px;
height: 14px;
border: 2px solid transparent;
border-top-color: #ffc107;
border-right-color: #ffc107;
border-radius: 50%;
animation: spin 1s linear infinite;
flex-shrink: 0;
}
}
.profile-game--abandoned & {
background: rgba(107, 114, 126, 0.18);
color: #6b727e;
}
}
@keyframes spin {
to {
transform: rotate(360deg);
}
} }
.profile-game__score { .profile-game__score {
font: 700 14px 'Rajdhani', sans-serif; font: 700 14px 'Rajdhani', sans-serif;
color: #fff; color: #fff;
letter-spacing: 1px; letter-spacing: 1px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
min-width: 0;
} }
.profile-game__vs { .profile-game__vs {
@@ -461,21 +572,23 @@
letter-spacing: 0.5px; letter-spacing: 0.5px;
text-align: right; text-align: right;
white-space: nowrap; white-space: nowrap;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
} }
.profile-charts { .profile-charts {
display: flex; display: grid;
flex-wrap: wrap; grid-template-columns: 1fr 1fr;
gap: 20px; gap: 20px;
} }
.profile-chart-block { .profile-chart-block {
flex: 1 1 300px; min-width: 0;
background: rgba(255, 255, 255, 0.03); background: rgba(255, 255, 255, 0.03);
border: 1px solid rgba(35, 111, 135, 0.2); border: 1px solid rgba(35, 111, 135, 0.2);
border-radius: 10px; border-radius: 10px;
padding: 24px 20px 16px; padding: 24px 20px 16px;
backdrop-filter: blur(4px);
box-shadow: 0 8px 48px rgba(0, 0, 0, 0.4); box-shadow: 0 8px 48px rgba(0, 0, 0, 0.4);
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -484,12 +597,25 @@
.profile-section__title { .profile-section__title {
margin: 0; margin: 0;
} }
&--wide {
grid-column: 1 / -1;
.profile-chart-inner {
justify-content: stretch;
overflow: hidden;
> * {
width: 100% !important;
}
}
}
} }
.profile-chart-inner { .profile-chart-inner {
display: flex; display: flex;
justify-content: center; justify-content: center;
overflow: auto; overflow: hidden;
svg text { svg text {
font-family: 'Rajdhani', sans-serif !important; font-family: 'Rajdhani', sans-serif !important;
@@ -564,6 +690,32 @@
} }
} }
.bd-continue {
background: linear-gradient(135deg, rgba(42, 158, 96, 0.35) 0%, rgba(94, 232, 154, 0.35) 100%);
border: 1px solid rgba(94, 232, 154, 0.6);
border-radius: 6px;
color: #5ee89a;
height: 32px;
padding: 0 14px;
display: flex;
align-items: center;
gap: 6px;
cursor: pointer;
font: 700 11px 'Rajdhani', sans-serif;
text-transform: uppercase;
letter-spacing: 1.5px;
text-decoration: none;
transition: all 180ms ease;
white-space: nowrap;
box-shadow: 0 0 14px rgba(94, 232, 154, 0.25);
&:hover {
background: linear-gradient(135deg, rgba(42, 158, 96, 0.55) 0%, rgba(94, 232, 154, 0.55) 100%);
color: #fff;
box-shadow: 0 0 20px rgba(94, 232, 154, 0.45);
}
}
.bd-close { .bd-close {
background: rgba(255, 255, 255, 0.05); background: rgba(255, 255, 255, 0.05);
border: 1px solid rgba(255, 255, 255, 0.1); border: 1px solid rgba(255, 255, 255, 0.1);
@@ -859,6 +1011,104 @@
flex-wrap: wrap; flex-wrap: wrap;
} }
.bshare-bonus {
padding: 28px 28px 0;
border-top: 1px solid rgba(255, 255, 255, 0.08);
}
.bshare-bonus__title {
font: 700 13px 'Rajdhani', sans-serif;
text-transform: uppercase;
letter-spacing: 2px;
color: #ffd700;
margin-bottom: 20px;
display: flex;
align-items: center;
gap: 8px;
i { font-size: 14px; }
}
.bshare-bonus__grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 24px;
margin-bottom: 28px;
}
.bshare-bonus__player {
padding: 16px;
border-radius: 8px;
border: 1px solid rgba(255, 255, 255, 0.08);
background: rgba(255, 255, 255, 0.02);
&--red {
border-color: rgba(246, 125, 82, 0.15);
background: rgba(246, 125, 82, 0.04);
}
&--blue {
border-color: rgba(149, 207, 245, 0.15);
background: rgba(149, 207, 245, 0.04);
}
}
.bshare-bonus__header {
display: flex;
align-items: baseline;
gap: 8px;
margin-bottom: 16px;
padding-bottom: 12px;
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
}
.bshare-bonus__points {
font: 700 24px 'Rajdhani', sans-serif;
background: linear-gradient(135deg, #ffd700, #ffed4e);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
.bshare-bonus__label {
font: 700 11px 'Rajdhani', sans-serif;
text-transform: uppercase;
letter-spacing: 1px;
color: rgba(255, 215, 0, 0.7);
}
.bshare-bonus__stats {
display: flex;
flex-direction: column;
gap: 10px;
}
.bshare-bonus__stat {
display: flex;
justify-content: space-between;
align-items: center;
font-size: 12px;
gap: 8px;
}
.bshare-bonus__stat-label {
color: rgba(255, 255, 255, 0.6);
font: 500 11px 'Rajdhani', sans-serif;
text-transform: capitalize;
}
.bshare-bonus__stat-value {
font: 700 13px 'Rajdhani', sans-serif;
color: rgba(255, 215, 0, 0.9);
min-width: 24px;
text-align: right;
}
.bshare-bonus__stat--empty {
color: rgba(255, 255, 255, 0.4);
font-size: 11px;
}
.bshare-btn { .bshare-btn {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;

View File

@@ -24,6 +24,10 @@
grid-template-columns: repeat(2, 1fr); grid-template-columns: repeat(2, 1fr);
} }
.profile-charts {
grid-template-columns: 1fr;
}
.profile-header { .profile-header {
flex-direction: column; flex-direction: column;
text-align: center; text-align: center;

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;

View File

@@ -0,0 +1,250 @@
/*!*
* This file is part of the SplendidBear Websites' projects.
*
* Copyright (c) 2026 @ www.splendidbear.org
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
#mine-wrapper .bonus-box {
display: flex;
align-items: center;
justify-content: center;
gap: 6px;
min-width: 58px;
padding: 6px 12px;
border-radius: 8px;
border: 2px solid transparent;
background: #07090d;
font-family: 'Rajdhani', sans-serif;
font-weight: bold;
cursor: pointer;
transition: all 0.25s ease;
align-self: stretch;
&:hover {
transform: translateY(-1px);
filter: brightness(1.15);
}
&:active {
transform: translateY(0);
}
}
#mine-wrapper .bonus-box.red-bonus {
background: linear-gradient(to bottom, #2a0502 0%, #4a1510 100%);
border-color: rgba(246, 125, 82, 0.4);
color: rgba(246, 125, 82, 0.85);
&:hover {
border-color: rgba(246, 125, 82, 0.85);
box-shadow: 0 0 12px rgba(173, 10, 5, 0.6);
}
}
#mine-wrapper .bonus-box.blue-bonus {
background: linear-gradient(to bottom, #050f18 0%, #0f2838 100%);
border-color: rgba(149, 207, 245, 0.4);
color: rgba(149, 207, 245, 0.85);
&:hover {
border-color: rgba(149, 207, 245, 0.85);
box-shadow: 0 0 12px rgba(35, 111, 135, 0.6);
}
}
#mine-wrapper .bonus-box__icon {
font-size: 13px;
opacity: 0.9;
}
#mine-wrapper .bonus-box__value {
font-family: 'Courier New', monospace;
font-size: 16px;
letter-spacing: 1px;
}
.bsd {
display: flex;
flex-direction: column;
font-family: 'Rajdhani', sans-serif;
}
.bsd-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 18px 22px 14px;
border-bottom: 1px solid rgba(35, 111, 135, 0.3);
}
.bsd-header-text {
display: flex;
flex-direction: column;
gap: 2px;
}
.bsd-label {
font-size: 11px;
letter-spacing: 2px;
color: rgba(149, 207, 245, 0.7);
text-transform: uppercase;
}
.bsd-title {
margin: 0;
font-size: 20px;
font-weight: 700;
display: flex;
align-items: center;
gap: 10px;
color: #fff;
.fa {
color: #f6d572;
}
}
.bsd-close {
background: transparent;
border: 1px solid rgba(35, 111, 135, 0.4);
border-radius: 6px;
color: rgba(255, 255, 255, 0.7);
width: 32px;
height: 32px;
cursor: pointer;
transition: all 0.2s ease;
&:hover {
color: #fff;
border-color: rgba(149, 207, 245, 0.8);
}
}
.bsd-body {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 14px;
padding: 18px 22px;
}
.bsd-column {
border-radius: 10px;
padding: 14px;
border: 1px solid transparent;
background: rgba(255, 255, 255, 0.02);
}
.bsd-column--red {
border-color: rgba(246, 125, 82, 0.35);
background: linear-gradient(to bottom, rgba(74, 6, 3, 0.35), rgba(107, 37, 21, 0.15));
}
.bsd-column--blue {
border-color: rgba(149, 207, 245, 0.35);
background: linear-gradient(to bottom, rgba(11, 37, 48, 0.35), rgba(22, 61, 85, 0.15));
}
.bsd-column-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 10px;
padding-bottom: 10px;
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
}
.bsd-column-name {
font-weight: 700;
font-size: 16px;
color: #fff;
}
.bsd-column-total {
display: inline-flex;
align-items: center;
gap: 6px;
font-family: 'Courier New', monospace;
font-size: 18px;
font-weight: 700;
color: #f6d572;
.fa {
font-size: 14px;
}
}
.bsd-stats {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 8px;
}
.bsd-stat {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
padding: 6px 2px;
border-bottom: 1px dashed rgba(255, 255, 255, 0.06);
&:last-child {
border-bottom: none;
}
}
.bsd-stat-text {
display: flex;
flex-direction: column;
gap: 1px;
min-width: 0;
}
.bsd-stat-label {
font-size: 13px;
font-weight: 600;
color: rgba(255, 255, 255, 0.92);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.bsd-stat-desc {
font-size: 11px;
color: rgba(255, 255, 255, 0.48);
line-height: 1.25;
overflow: hidden;
text-overflow: ellipsis;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
}
.bsd-stat-value {
font-family: 'Courier New', monospace;
font-size: 16px;
font-weight: 700;
color: #fff;
min-width: 24px;
text-align: right;
}
.bsd-note {
margin: 0;
padding: 12px 22px 18px;
font-size: 12px;
color: rgba(255, 255, 255, 0.45);
text-align: center;
font-style: italic;
}
@media (max-width: 520px) {
.bsd-body {
grid-template-columns: 1fr;
}
}

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%;
} }

View File

@@ -35,6 +35,8 @@
flex-direction: column; flex-direction: column;
gap: 0; gap: 0;
animation: slideUp 0.5s cubic-bezier(0.34, 1.56, 0.64, 1); animation: slideUp 0.5s cubic-bezier(0.34, 1.56, 0.64, 1);
overflow: hidden;
max-height: 90vh;
} }
@keyframes slideUp { @keyframes slideUp {
@@ -54,6 +56,11 @@
color: #fff; color: #fff;
margin: 0 0 50px 0; margin: 0 0 50px 0;
letter-spacing: 1px; letter-spacing: 1px;
overflow: hidden;
text-overflow: ellipsis;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
} }
#mine-wrapper .game-wrapper .game-overlay .game-overlay-window h2 { #mine-wrapper .game-wrapper .game-overlay .game-overlay-window h2 {
@@ -183,6 +190,10 @@
width: 100%; width: 100%;
animation: fadeInUp 0.6s ease-out 0.2s both; animation: fadeInUp 0.6s ease-out 0.2s both;
&.waiting-options--invite-only {
grid-template-columns: 1fr;
}
@media (max-width: 600px) { @media (max-width: 600px) {
grid-template-columns: 1fr; grid-template-columns: 1fr;
gap: 20px; gap: 20px;
@@ -264,6 +275,11 @@
margin: 0; margin: 0;
letter-spacing: 0.4px; letter-spacing: 0.4px;
line-height: 1.4; line-height: 1.4;
overflow: hidden;
text-overflow: ellipsis;
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
} }
.waiting-divider { .waiting-divider {
@@ -527,6 +543,19 @@
} }
} }
#mine-wrapper .game-wrapper .game-overlay .game-overlay-window .game-overlay-actions {
display: flex;
align-items: stretch;
gap: 12px;
margin-top: 20px;
width: 100%;
> * {
flex: 1 1 0;
margin-top: 0 !important;
}
}
#mine-wrapper .game-wrapper .game-overlay .game-overlay-window .game-overlay-share { #mine-wrapper .game-wrapper .game-overlay .game-overlay-window .game-overlay-share {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -590,3 +619,60 @@
} }
} }
#mine-wrapper .game-wrapper .game-overlay .game-overlay-window .game-overlay-profile {
display: flex;
align-items: center;
justify-content: center;
gap: 9px;
background: linear-gradient(to bottom, #1a6844 0%, #135233 100%);
border: 2px solid #2a9e60;
color: #d0ffe0;
font-family: 'Rajdhani', sans-serif;
font-size: 13px;
font-weight: 800;
letter-spacing: 1px;
text-transform: uppercase;
padding: 12px 24px;
border-radius: 8px;
cursor: pointer;
transition: all 300ms cubic-bezier(0.34, 1.56, 0.64, 1);
width: 100%;
margin-top: 20px;
position: relative;
overflow: hidden;
box-shadow: 0 4px 12px rgba(42, 158, 96, 0.25);
text-decoration: none;
z-index: 10;
&::before {
content: '';
position: absolute;
top: 0;
left: -100%;
width: 100%;
height: 100%;
background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.2), transparent);
transition: left 0.4s ease;
}
&:hover {
background: linear-gradient(to bottom, #238f5c 0%, #1a6844 100%);
border-color: #5ee89a;
color: #fff;
box-shadow: 0 8px 24px rgba(42, 158, 96, 0.4);
transform: translateY(-2px);
&::before {
left: 100%;
}
}
&:active {
transform: translateY(0);
}
i {
font-size: 15px;
}
}

View File

@@ -105,10 +105,12 @@
text-align: center; text-align: center;
white-space: nowrap; white-space: nowrap;
text-overflow: ellipsis; text-overflow: ellipsis;
padding: 3px 0; padding: 3px 5px;
margin: 0 5px; margin: 0;
overflow: hidden; overflow: hidden;
word-break: break-word;
max-width: 100%;
} }
#mine-wrapper .game-wrapper .users .user-container.user-blue .user-name { #mine-wrapper .game-wrapper .users .user-container.user-blue .user-name {
@@ -142,6 +144,13 @@
height: 65px; height: 65px;
font-size: 14px; font-size: 14px;
text-align: center; text-align: center;
overflow: hidden;
text-overflow: ellipsis;
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
word-break: break-word;
padding: 0 5px;
} }
#mine-wrapper .game-wrapper .users .user-container.user-blue .user-desc { #mine-wrapper .game-wrapper .users .user-container.user-blue .user-desc {

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

@@ -19,5 +19,6 @@
@import 'mineseeker/grid'; @import 'mineseeker/grid';
@import 'mineseeker/back-button'; @import 'mineseeker/back-button';
@import 'mineseeker/timer'; @import 'mineseeker/timer';
@import 'mineseeker/bonus-box';
@import 'mineseeker/responsive'; @import 'mineseeker/responsive';
@import 'mineseeker/waiting-dialog'; @import 'mineseeker/waiting-dialog';

View File

@@ -17,5 +17,6 @@ createRoot(wrapper).render(
<MineSeeker <MineSeeker
env={wrapper.dataset.env} env={wrapper.dataset.env}
gameId={wrapper.dataset.gameId} gameId={wrapper.dataset.gameId}
opponentName={wrapper.dataset.opponentName || ''}
/>, />,
); );

View File

@@ -1,6 +1,9 @@
import React, { useEffect, useState } from 'react'; import React, { useEffect, useState } from 'react';
import Dialog from '@mui/material/Dialog'; import Dialog from '@mui/material/Dialog';
import { createTheme, ThemeProvider } from '@mui/material/styles'; import { createTheme, ThemeProvider } from '@mui/material/styles';
import Avatar from './battle-dialog/Avatar';
import StatRow from './battle-dialog/StatRow';
import BonusPoints from './battle-dialog/BonusPoints';
const darkTheme = createTheme({ palette: { mode: 'dark' } }); const darkTheme = createTheme({ palette: { mode: 'dark' } });
@@ -50,102 +53,6 @@ const RESULT_META = {
}, },
}; };
function Avatar({ name, color, avatarUrl }) {
const isRed = 'red' === color;
const initials = (name || '?').slice(0, 2).toUpperCase();
const gradient = isRed
? 'linear-gradient(135deg, rgba(173,10,5,0.6) 0%, rgba(246,125,82,0.4) 100%)'
: 'linear-gradient(135deg, rgba(35,111,135,0.6) 0%, rgba(41,128,185,0.4) 100%)';
const glow = isRed
? '0 0 0 3px rgba(173,10,5,0.2), 0 0 28px rgba(173,10,5,0.35)'
: '0 0 0 3px rgba(35,111,135,0.2), 0 0 28px rgba(35,111,135,0.35)';
const border = isRed
? 'rgba(173,10,5,0.5)'
: 'rgba(35,111,135,0.5)';
const textColor = isRed ? '#f67d52' : '#95cff5';
return (
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 10 }}>
<div style={{
width: 72, height: 72, borderRadius: '50%',
background: avatarUrl ? 'transparent' : gradient,
border: `2px solid ${border}`,
boxShadow: glow,
display: 'flex', alignItems: 'center', justifyContent: 'center',
font: '800 24px \'Rajdhani\', sans-serif',
color: textColor,
letterSpacing: 2,
overflow: 'hidden',
}}
>
{avatarUrl ? (
<img
src={avatarUrl}
alt={name}
style={{
width: '100%',
height: '100%',
objectFit: 'cover',
}}
/>
) : (
initials
)}
</div>
<span style={{
font: '700 15px \'Rajdhani\', sans-serif',
color: textColor,
letterSpacing: 1,
maxWidth: 120, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap',
textAlign: 'center',
}}
>
{name}
</span>
<span style={{
font: '600 10px \'Rajdhani\', sans-serif',
textTransform: 'uppercase',
letterSpacing: 2,
color: 'rgba(255,255,255,0.3)',
}}
>
{isRed ? 'Red' : 'Blue'}
</span>
</div>
);
}
function StatRow({ icon, label, value, valueColor }) {
return (
<div style={{
display: 'flex', alignItems: 'center',
gap: 10, padding: '9px 0',
borderBottom: '1px solid rgba(255,255,255,0.05)',
}}
>
<i className={`fa ${icon}`} style={{ width: 16, color: 'rgba(149,207,245,0.4)', fontSize: 13 }} />
<span style={{
font: '500 13px \'Rajdhani\', sans-serif',
color: 'rgba(255,255,255,0.45)',
flex: 1,
letterSpacing: 0.5,
}}
>
{label}
</span>
<span style={{
font: '700 13px \'Rajdhani\', sans-serif',
color: valueColor || 'rgba(255,255,255,0.75)',
letterSpacing: 0.5,
}}
>
{value}
</span>
</div>
);
}
export default function BattleDialog({ games }) { export default function BattleDialog({ games }) {
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const [game, setGame] = useState(null); const [game, setGame] = useState(null);
@@ -171,10 +78,31 @@ export default function BattleDialog({ games }) {
const meta = RESULT_META[game.result] ?? RESULT_META.draw; const meta = RESULT_META[game.result] ?? RESULT_META.draw;
const resign = game.resign; const resign = game.resign;
const maxPoints = Math.max(game.redPoints ?? 0, game.bluePoints ?? 0);
const endReason = resign const endReason = resign
? `${resign.charAt(0).toUpperCase() + resign.slice(1)} resigned` ? `${resign.charAt(0).toUpperCase() + resign.slice(1)} resigned`
: 'Points'; : 26 <= maxPoints ? 'Points' : 'Abandoned';
const shareUrl = `${window.location.origin}/battle/${game.uuid}`; const shareUrl = `${window.location.origin}/battle/${game.uuid}`;
const canContinue = !resign && 26 > maxPoints;
const playUrl = `${window.location.origin}/play/${game.uuid}`;
const formatDuration = (from, to) => {
if (!from || !to) return null;
const diffMs = new Date(to.replace(' ', 'T')) - new Date(from.replace(' ', 'T'));
if (isNaN(diffMs) || 0 >= diffMs) return null;
const totalSec = Math.floor(diffMs / 1000);
const h = Math.floor(totalSec / 3600);
const m = Math.floor((totalSec % 3600) / 60);
const s = totalSec % 60;
if (0 < h) return `${h}h ${m}m ${s}s`;
if (0 < m) return `${m}m ${s}s`;
return `${s}s`;
};
const duration = formatDuration(game.created, game.date);
const pointDiff = Math.abs((game.redPoints ?? 0) - (game.bluePoints ?? 0));
const winnerColor = (game.redPoints ?? 0) > (game.bluePoints ?? 0) ? '#f67d52'
: (game.bluePoints ?? 0) > (game.redPoints ?? 0) ? '#95cff5'
: 'rgba(255,255,255,0.45)';
const handleShare = () => { const handleShare = () => {
navigator.clipboard.writeText(shareUrl).then(() => { navigator.clipboard.writeText(shareUrl).then(() => {
@@ -195,6 +123,17 @@ export default function BattleDialog({ games }) {
</h2> </h2>
</div> </div>
<div style={{ display: 'flex', gap: 8 }}> <div style={{ display: 'flex', gap: 8 }}>
{canContinue ? (
<a
className="bd-continue"
href={playUrl}
aria-label="Continue the game"
title="Continue the game"
>
<i className="fa fa-play" />
Continue
</a>
) : (
<button <button
className={`bd-share${copied ? ' bd-share--copied' : ''}`} className={`bd-share${copied ? ' bd-share--copied' : ''}`}
onClick={handleShare} onClick={handleShare}
@@ -204,19 +143,46 @@ export default function BattleDialog({ games }) {
<i className={`fa ${copied ? 'fa-check' : 'fa-share-alt'}`} /> <i className={`fa ${copied ? 'fa-check' : 'fa-share-alt'}`} />
{copied ? 'Copied!' : 'Share'} {copied ? 'Copied!' : 'Share'}
</button> </button>
)}
<button className="bd-close" onClick={() => setOpen(false)} aria-label="Close"> <button className="bd-close" onClick={() => setOpen(false)} aria-label="Close">
<i className="fa fa-times" /> <i className="fa fa-times" />
</button> </button>
</div> </div>
</div> </div>
<div className="bd-vs-panel"> <div className="bd-vs-panel">
<Avatar name={game.redName} color="red" avatarUrl={game.redAvatar} /> <Avatar
name={game.redName} color="red" avatarUrl={game.redAvatar}
bonusPoints={game.redBonusPoints > game.blueBonusPoints ? game.redBonusPoints : 0}
/>
<div className="bd-vs-center"> <div className="bd-vs-center">
<div className="bd-vs-score"> <div className="bd-vs-score">
<span className="bd-vs-score__red">{game.redPoints ?? '—'}</span> <span className="bd-vs-score__red">{game.redPoints ?? '—'}</span>
<span className="bd-vs-score__sep">:</span> <span className="bd-vs-score__sep">:</span>
<span className="bd-vs-score__blue">{game.bluePoints ?? '—'}</span> <span className="bd-vs-score__blue">{game.bluePoints ?? '—'}</span>
</div> </div>
<div className="bd-vs-score" style={{ marginBottom: 8 }}>
<span style={{
font: '700 13px \'Rajdhani\', sans-serif',
color: '#f67d52',
display: 'flex',
alignItems: 'center',
gap: 4,
}}
>
<i className="fa fa-star" style={{ fontSize: 11 }} /> {(game.redBonusPoints ?? 0).toFixed(1)}
</span>
<span className="bd-vs-score__sep">:</span>
<span style={{
font: '700 13px \'Rajdhani\', sans-serif',
color: '#95cff5',
display: 'flex',
alignItems: 'center',
gap: 4,
}}
>
{(game.blueBonusPoints ?? 0).toFixed(1)} <i className="fa fa-star" style={{ fontSize: 11 }} />
</span>
</div>
<div className="bd-vs-label">VS</div> <div className="bd-vs-label">VS</div>
<div <div
className="bd-result-badge" className="bd-result-badge"
@@ -225,25 +191,40 @@ export default function BattleDialog({ games }) {
<i className={`fa ${meta.icon}`} /> {meta.label} <i className={`fa ${meta.icon}`} /> {meta.label}
</div> </div>
</div> </div>
<Avatar name={game.blueName} color="blue" avatarUrl={game.blueAvatar} /> <Avatar
name={game.blueName} color="blue" avatarUrl={game.blueAvatar}
bonusPoints={game.blueBonusPoints > game.redBonusPoints ? game.blueBonusPoints : 0}
/>
</div> </div>
<div className="bd-stats"> <div className="bd-stats">
<StatRow icon="fa-calendar" label="Date" value={game.date ?? '—'} /> <StatRow icon="fa-calendar" label="Date" value={game.date ?? '—'} />
{game.created && game.date && game.created !== game.date && (
<StatRow icon="fa-clock" label="Started" value={game.created} />
)}
{duration && (
<StatRow icon="fa-hourglass-half" label="Match duration" value={duration} />
)}
<StatRow icon="fa-flag-checkered" label="End reason" value={endReason} /> <StatRow icon="fa-flag-checkered" label="End reason" value={endReason} />
{0 < pointDiff && (
<StatRow <StatRow
icon="fa-bomb" label="Red hit a mine" icon="fa-balance-scale" label="Winning margin"
value={`${pointDiff} mine${1 === pointDiff ? '' : 's'}`} valueColor={winnerColor}
/>
)}
<StatRow
icon="fa-bomb" label="Red used bomb"
value={game.redExplodedBomb ? 'Yes' : 'No'} value={game.redExplodedBomb ? 'Yes' : 'No'}
valueColor={game.redExplodedBomb ? '#f67d52' : 'rgba(255,255,255,0.45)'} valueColor={game.redExplodedBomb ? '#f67d52' : 'rgba(255,255,255,0.45)'}
/> />
<StatRow <StatRow
icon="fa-bomb" label="Blue hit a mine" icon="fa-bomb" label="Blue used bomb"
value={game.blueExplodedBomb ? 'Yes' : 'No'} value={game.blueExplodedBomb ? 'Yes' : 'No'}
valueColor={game.blueExplodedBomb ? '#f67d52' : 'rgba(255,255,255,0.45)'} valueColor={game.blueExplodedBomb ? '#95cff5' : 'rgba(255,255,255,0.45)'}
/> />
{game.created && game.date && game.created !== game.date && (
<StatRow icon="fa-clock-o" label="Started" value={game.created} />
)}
</div> </div>
<BonusPoints
game={game}
/>
</div> </div>
</Dialog> </Dialog>
</ThemeProvider> </ThemeProvider>

View File

@@ -1,5 +1,6 @@
import React from 'react'; import React from 'react';
import { BarChart } from '@mui/x-charts/BarChart'; import { BarChart } from '@mui/x-charts/BarChart';
import { LineChart } from '@mui/x-charts/LineChart';
import { PieChart } from '@mui/x-charts/PieChart'; import { PieChart } from '@mui/x-charts/PieChart';
import { createTheme, ThemeProvider } from '@mui/material/styles'; import { createTheme, ThemeProvider } from '@mui/material/styles';
@@ -16,6 +17,8 @@ const darkTheme = createTheme({
const WIN_COLOR = '#5ee89a'; const WIN_COLOR = '#5ee89a';
const LOSS_COLOR = '#f67d52'; const LOSS_COLOR = '#f67d52';
const DRAW_COLOR = '#95cff5'; const DRAW_COLOR = '#95cff5';
const MINES_COLOR = '#f67d52';
const BONUS_COLOR = '#ffd700';
const axisStyle = { const axisStyle = {
tickLabelStyle: { fill: 'rgba(255,255,255,0.4)', fontSize: 11, fontFamily: '\'Rajdhani\', sans-serif' }, tickLabelStyle: { fill: 'rgba(255,255,255,0.4)', fontSize: 11, fontFamily: '\'Rajdhani\', sans-serif' },
@@ -23,10 +26,12 @@ const axisStyle = {
}; };
export default function ProfileCharts({ chartData }) { export default function ProfileCharts({ chartData }) {
const { months, wins, losses, draws, pieWins, pieLosses, pieDraws } = chartData; const { months, wins, losses, draws, pieWins, pieLosses, pieDraws, recentGames } = chartData;
const total = pieWins + pieLosses + pieDraws; const total = pieWins + pieLosses + pieDraws;
const hasBars = wins.some(v => 0 < v) || losses.some(v => 0 < v) || draws.some(v => 0 < v); const hasBars = wins.some(v => 0 < v) || losses.some(v => 0 < v) || draws.some(v => 0 < v);
const hasRecent = recentGames
&& (recentGames.mines?.some(v => 0 < v) || recentGames.bonus?.some(v => 0 < v));
return ( return (
<ThemeProvider theme={darkTheme}> <ThemeProvider theme={darkTheme}>
@@ -97,6 +102,36 @@ export default function ProfileCharts({ chartData }) {
</div> </div>
</div> </div>
)} )}
{hasRecent && (
<div className="profile-chart-block profile-chart-block--wide">
<h2 className="profile-section__title">
<i className="fa fa-line-chart" /> Last {recentGames.labels.length} games mines & bonus
</h2>
<div className="profile-chart-inner">
<LineChart
xAxis={[{ scaleType: 'band', data: recentGames.labels, ...axisStyle }]}
yAxis={[{ ...axisStyle }]}
series={[
{ data: recentGames.mines, label: 'Mines hit', color: MINES_COLOR },
{ data: recentGames.bonus, label: 'Bonus points', color: BONUS_COLOR },
]}
slotProps={{
legend: {
labelStyle: {
fill: 'rgba(255,255,255,0.55)',
fontSize: 13,
fontFamily: '\'Rajdhani\', sans-serif',
},
},
}}
borderRadius={3}
height={220}
margin={{ top: 10, bottom: 30, left: 40, right: 140 }}
/>
</div>
</div>
)}
</div> </div>
</ThemeProvider> </ThemeProvider>
); );

View File

@@ -0,0 +1,98 @@
/**
* This file is part of the SplendidBear Websites' projects.
*
* Copyright (c) 2026 @ www.splendidbear.org
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
import React from 'react';
export default function Avatar({ name, color, avatarUrl, bonusPoints = 0 }) {
const isRed = 'red' === color;
const initials = (name || '?').slice(0, 2).toUpperCase();
const gradient = isRed
? 'linear-gradient(135deg, rgba(173,10,5,0.6) 0%, rgba(246,125,82,0.4) 100%)'
: 'linear-gradient(135deg, rgba(35,111,135,0.6) 0%, rgba(41,128,185,0.4) 100%)';
const glow = isRed
? '0 0 0 3px rgba(173,10,5,0.2), 0 0 28px rgba(173,10,5,0.35)'
: '0 0 0 3px rgba(35,111,135,0.2), 0 0 28px rgba(35,111,135,0.35)';
const border = isRed
? 'rgba(173,10,5,0.5)'
: 'rgba(35,111,135,0.5)';
const textColor = isRed ? '#f67d52' : '#95cff5';
return (
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 10, position: 'relative' }}>
<div style={{ position: 'relative' }}>
<div style={{
width: 72, height: 72, borderRadius: '50%',
background: avatarUrl ? 'transparent' : gradient,
border: `2px solid ${border}`,
boxShadow: glow,
display: 'flex', alignItems: 'center', justifyContent: 'center',
font: '800 24px \'Rajdhani\', sans-serif',
color: textColor,
letterSpacing: 2,
overflow: 'hidden',
}}
>
{avatarUrl ? (
<img
src={avatarUrl}
alt={name}
style={{
width: '100%',
height: '100%',
objectFit: 'cover',
}}
/>
) : (
initials
)}
</div>
{0 < bonusPoints && (
<div style={{
position: 'absolute',
bottom: -6,
right: -6,
background: '#ffd700',
borderRadius: '50%',
width: 28,
height: 28,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
boxShadow: '0 0 12px rgba(255, 215, 0, 0.6), 0 0 0 2px rgba(7, 9, 13, 1)',
border: '2px solid rgba(0,0,0,0.5)',
zIndex: 10,
}}
>
<i className="fa fa-star" style={{ color: '#000', fontSize: 14 }} />
</div>
)}
</div>
<span style={{
font: '700 15px \'Rajdhani\', sans-serif',
color: textColor,
letterSpacing: 1,
maxWidth: 120, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap',
textAlign: 'center',
}}
>
{name}
</span>
<span style={{
font: '600 10px \'Rajdhani\', sans-serif',
textTransform: 'uppercase',
letterSpacing: 2,
color: 'rgba(255,255,255,0.3)',
}}
>
{isRed ? 'Red' : 'Blue'}
</span>
</div>
);
}

View File

@@ -0,0 +1,139 @@
import { useMemo } from 'react';
import StatRow from './StatRow';
export default function BonusPoints({ game }) {
const hasBonuspoints = useMemo(
() => 0 < game?.redBonusPoints
|| 0 < game?.blueBonusPoints
|| game?.redBonusStats?.blindHits
|| game?.blueBonusStats?.blindHits,
[
game?.blueBonusPoints,
game?.blueBonusStats?.blindHits,
game?.redBonusPoints,
game?.redBonusStats?.blindHits,
],
);
const hasRedNoBonuses = useMemo(
() => !game.redBonusStats?.blindHits
&& !game.redBonusStats?.chainBest
&& !game.redBonusStats?.edgeMines
&& !game.redBonusStats?.lastMineHits
&& !game.redBonusStats?.biggestReveal,
[
game.redBonusStats?.biggestReveal,
game.redBonusStats?.blindHits,
game.redBonusStats?.chainBest,
game.redBonusStats?.edgeMines,
game.redBonusStats?.lastMineHits,
],
);
const hasBlueNoBonuses = useMemo(
() => !game.blueBonusStats?.blindHits
&& !game.blueBonusStats?.chainBest
&& !game.blueBonusStats?.edgeMines
&& !game.blueBonusStats?.lastMineHits
&& !game.blueBonusStats?.biggestReveal,
[
game.blueBonusStats?.biggestReveal,
game.blueBonusStats?.blindHits,
game.blueBonusStats?.chainBest,
game.blueBonusStats?.edgeMines,
game.blueBonusStats?.lastMineHits,
],
);
if (!hasBonuspoints) {
return '';
}
return (
<div style={{
padding: '16px 20px 0',
borderTop: '1px solid rgba(255,255,255,0.08)',
marginTop: 16,
marginBottom: 16,
}}
>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 16 }}>
{/* Red Bonus */}
<div style={{
padding: 16,
border: '1px solid rgba(173,10,5,0.2)',
borderRadius: 6,
background: 'rgba(173,10,5,0.05)',
}}
>
<span style={{
font: '700 12px \'Rajdhani\', sans-serif',
textTransform: 'uppercase',
letterSpacing: 2,
color: '#ffd700',
display: 'block',
marginBottom: 12,
}}
>
<i className="fa fa-star" style={{ marginRight: 8 }} /> Red Bonus Statistics
</span>
<div style={{ display: 'flex', flexDirection: 'column', gap: 0 }}>
<StatRow
icon="fa-star" label="Total Bonus Points" value={(game.redBonusPoints ?? 0).toFixed(1)}
valueColor="#ffd700"
/>
{0 < game.redBonusStats?.blindHits
&& <StatRow icon="fa-bullseye" label="Blind hits" value={game.redBonusStats.blindHits} />}
{0 < game.redBonusStats?.chainBest
&& <StatRow icon="fa-link" label="Best chain" value={game.redBonusStats.chainBest} />}
{0 < game.redBonusStats?.edgeMines
&& <StatRow icon="fa-border-all" label="Edge mines" value={game.redBonusStats.edgeMines} />}
{0 < game.redBonusStats?.lastMineHits
&& <StatRow icon="fa-hourglass-end" label="Endgame mines" value={game.redBonusStats.lastMineHits} />}
{0 < game.redBonusStats?.biggestReveal
&& <StatRow icon="fa-expand" label="Biggest reveal" value={game.redBonusStats.biggestReveal} />}
{hasRedNoBonuses
&& <StatRow icon="fa-minus-circle" label="Status" value="No bonuses" valueColor="rgba(255,255,255,0.3)" />}
</div>
</div>
<div style={{
padding: 16,
border: '1px solid rgba(149,207,245,0.2)',
borderRadius: 6,
background: 'rgba(149,207,245,0.05)',
}}
>
<span style={{
font: '700 12px \'Rajdhani\', sans-serif',
textTransform: 'uppercase',
letterSpacing: 2,
color: '#ffd700',
display: 'block',
marginBottom: 12,
}}
>
<i className="fa fa-star" style={{ marginRight: 8 }} /> Blue Bonus Statistics
</span>
<div style={{ display: 'flex', flexDirection: 'column', gap: 0 }}>
<StatRow
icon="fa-star" label="Total Bonus Points" value={(game.blueBonusPoints ?? 0).toFixed(1)}
valueColor="#ffd700"
/>
{0 < game.blueBonusStats?.blindHits
&& <StatRow icon="fa-bullseye" label="Blind hits" value={game.blueBonusStats.blindHits} />}
{0 < game.blueBonusStats?.chainBest
&& <StatRow icon="fa-link" label="Best chain" value={game.blueBonusStats.chainBest} />}
{0 < game.blueBonusStats?.edgeMines
&& <StatRow icon="fa-border-all" label="Edge mines" value={game.blueBonusStats.edgeMines} />}
{0 < game.blueBonusStats?.lastMineHits
&& <StatRow icon="fa-hourglass-end" label="Endgame mines" value={game.blueBonusStats.lastMineHits} />}
{0 < game.blueBonusStats?.biggestReveal
&& <StatRow icon="fa-expand" label="Biggest reveal" value={game.blueBonusStats.biggestReveal} />}
{hasBlueNoBonuses
&& <StatRow icon="fa-minus-circle" label="Status" value="No bonuses" valueColor="rgba(255,255,255,0.3)" />}
</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,40 @@
/**
* This file is part of the SplendidBear Websites' projects.
*
* Copyright (c) 2026 @ www.splendidbear.org
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
import React from 'react';
export default function StatRow({ icon, label, value, valueColor }) {
return (
<div style={{
display: 'flex', alignItems: 'center',
gap: 10, padding: '9px 0',
borderBottom: '1px solid rgba(255,255,255,0.05)',
}}
>
<i className={`fa ${icon}`} style={{ width: 16, color: 'rgba(149,207,245,0.4)', fontSize: 13 }} />
<span style={{
font: '500 13px \'Rajdhani\', sans-serif',
color: 'rgba(255,255,255,0.45)',
flex: 1,
letterSpacing: 0.5,
}}
>
{label}
</span>
<span style={{
font: '700 13px \'Rajdhani\', sans-serif',
color: valueColor || 'rgba(255,255,255,0.75)',
letterSpacing: 0.5,
}}
>
{value}
</span>
</div>
);
}

View File

@@ -14,7 +14,7 @@ import { GameBoard } from '@mine-components';
const queryClient = new QueryClient(); const queryClient = new QueryClient();
const MineSeeker = ({ env, gameId }) => { const MineSeeker = ({ env, gameId, opponentName = '' }) => {
const isEnvDev = 'dev' === env; const isEnvDev = 'dev' === env;
const gameAssoc = useRef('' !== gameId ? gameId : crypto.randomUUID()).current; const gameAssoc = useRef('' !== gameId ? gameId : crypto.randomUUID()).current;
const gameInherited = '' !== gameId; const gameInherited = '' !== gameId;
@@ -25,6 +25,7 @@ const MineSeeker = ({ env, gameId }) => {
<GameBoard <GameBoard
gameAssoc={gameAssoc} gameAssoc={gameAssoc}
gameInherited={gameInherited} gameInherited={gameInherited}
opponentName={opponentName}
isEnvDev={isEnvDev} isEnvDev={isEnvDev}
/> />
</GameProvider> </GameProvider>

View File

@@ -0,0 +1,25 @@
/**
* This file is part of the SplendidBear Websites' projects.
*
* Copyright (c) 2026 @ www.splendidbear.org
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
import React from 'react';
const BonusBox = ({ color, points, onClick, title }) => (
<button
type="button"
className={`bonus-box ${color}-bonus`}
onClick={onClick}
title={title || 'View bonus statistics'}
aria-label={`${color} bonus points: ${points}`}
>
<i className="fa fa-star bonus-box__icon" />
<span className="bonus-box__value">{points}</span>
</button>
);
export default BonusBox;

View File

@@ -0,0 +1,97 @@
/**
* This file is part of the SplendidBear Websites' projects.
*
* Copyright (c) 2026 @ www.splendidbear.org
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
import React from 'react';
import Dialog from '@mui/material/Dialog';
import { BONUS_LABELS } from '@mine-utils';
const DIALOG_SX = {
'& .MuiDialog-paper': {
background: '#07090d',
backgroundImage: `
linear-gradient(rgba(35, 111, 135, 0.08) 1px, transparent 1px),
linear-gradient(90deg, rgba(35, 111, 135, 0.08) 1px, transparent 1px)
`,
backgroundSize: '46px 46px',
border: '1px solid rgba(35, 111, 135, 0.4)',
borderRadius: '12px',
boxShadow: '0 0 80px rgba(35, 111, 135, 0.15), 0 32px 80px rgba(0, 0, 0, 0.9)',
width: '560px',
maxWidth: '94vw',
overflow: 'hidden',
color: '#fff',
},
'& .MuiBackdrop-root': {
background: 'rgba(2, 4, 8, 0.88)',
backdropFilter: 'blur(4px)',
},
};
const formatPlayerName = name => {
if (name && name.startsWith('anon_')) {
return 'Anonymous';
}
if (name && 10 < name.length) {
return name.substring(0, 7) + '...';
}
return name || 'Unknown';
};
const PlayerColumn = ({ color, player }) => (
<div className={`bsd-column bsd-column--${color}`}>
<div className="bsd-column-header">
<span className="bsd-column-name">{formatPlayerName(player.name)}</span>
<span className="bsd-column-total">
<i className="fa fa-star" />
{player.bonusPoints}
</span>
</div>
<ul className="bsd-stats">
{Object.entries(BONUS_LABELS).map(([key, { label, desc }]) => (
<li key={key} className="bsd-stat">
<div className="bsd-stat-text">
<span className="bsd-stat-label">{label}</span>
<span className="bsd-stat-desc">{desc}</span>
</div>
<span className="bsd-stat-value">{player.bonusStats?.[key] ?? 0}</span>
</li>
))}
</ul>
</div>
);
const BonusStatsDialog = ({ open, onClose, red, blue }) => (
<Dialog open={open} onClose={onClose} sx={DIALOG_SX}>
<div className="bsd">
<div className="bsd-header">
<div className="bsd-header-text">
<span className="bsd-label">Scoring</span>
<h2 className="bsd-title">
<i className="fa fa-star" />
Bonus Statistics
</h2>
</div>
<button className="bsd-close" onClick={onClose} aria-label="Close">
<i className="fa fa-times" />
</button>
</div>
<div className="bsd-body">
<PlayerColumn color="red" player={red} />
<PlayerColumn color="blue" player={blue} />
</div>
<p className="bsd-note">
Bonus points are awarded alongside the main score for skillful play.
</p>
</div>
</Dialog>
);
export default BonusStatsDialog;

View File

@@ -12,9 +12,9 @@ import { useGame } from '@mine-contexts';
import { useServerCommunication } from '@mine-hooks'; import { useServerCommunication } from '@mine-hooks';
import GridControl from './grid/GridControl'; import GridControl from './grid/GridControl';
export const GameBoard = ({ gameAssoc, gameInherited, isEnvDev }) => { export const GameBoard = ({ gameAssoc, gameInherited, opponentName = '', isEnvDev }) => {
const { gridReady } = useGame(); const { gridReady } = useGame();
const { onClick, resign } = useServerCommunication(gameAssoc, gameInherited, isEnvDev); const { onClick, resign } = useServerCommunication(gameAssoc, gameInherited, opponentName, isEnvDev);
if (!gridReady) { if (!gridReady) {
return ( return (

View File

@@ -9,6 +9,8 @@
import React, { useEffect, useRef, useState } from 'react'; import React, { useEffect, useRef, useState } from 'react';
import { useGame } from '@mine-contexts'; import { useGame } from '@mine-contexts';
import BonusBox from './BonusBox';
import BonusStatsDialog from './BonusStatsDialog';
const renderAvatar = player => { const renderAvatar = player => {
if (!player.registered) return null; if (!player.registered) return null;
@@ -27,6 +29,7 @@ const GameTimer = () => {
const [redTime, setRedTime] = useState(0); const [redTime, setRedTime] = useState(0);
const [blueTime, setBlueTime] = useState(0); const [blueTime, setBlueTime] = useState(0);
const [isRunning, setIsRunning] = useState(false); const [isRunning, setIsRunning] = useState(false);
const [bonusDialogOpen, setBonusDialogOpen] = useState(false);
const timerIntervalRef = useRef(null); const timerIntervalRef = useRef(null);
const gameStartedRef = useRef(false); const gameStartedRef = useRef(false);
@@ -160,8 +163,12 @@ const GameTimer = () => {
return `${String(mins).padStart(2, '0')}:${String(secs).padStart(2, '0')}`; return `${String(mins).padStart(2, '0')}:${String(secs).padStart(2, '0')}`;
}; };
const openBonusDialog = () => setBonusDialogOpen(true);
const closeBonusDialog = () => setBonusDialogOpen(false);
return ( return (
<div className="game-timer-container"> <div className="game-timer-container">
<BonusBox color="red" points={red.bonusPoints ?? 0} onClick={openBonusDialog} />
<div className={`game-timer red-timer ${!activePlayer ? 'active' : ''}`}> <div className={`game-timer red-timer ${!activePlayer ? 'active' : ''}`}>
{renderAvatar(red)} {renderAvatar(red)}
<i className={`fa ${!activePlayer ? 'fa-hourglass-half' : 'fa-hourglass-start'} timer-icon`} /> <i className={`fa ${!activePlayer ? 'fa-hourglass-half' : 'fa-hourglass-start'} timer-icon`} />
@@ -172,6 +179,8 @@ const GameTimer = () => {
<i className={`fa ${activePlayer ? 'fa-hourglass-half' : 'fa-hourglass-start'} timer-icon`} /> <i className={`fa ${activePlayer ? 'fa-hourglass-half' : 'fa-hourglass-start'} timer-icon`} />
<span className="timer-display">{formatTime(blueTime)}</span> <span className="timer-display">{formatTime(blueTime)}</span>
</div> </div>
<BonusBox color="blue" points={blue.bonusPoints ?? 0} onClick={openBonusDialog} />
<BonusStatsDialog open={bonusDialogOpen} onClose={closeBonusDialog} red={red} blue={blue} />
</div> </div>
); );
}; };

View File

@@ -256,7 +256,7 @@ const OnlinePlayersDialog = ({ open, onClose, currentGameAssoc }) => {
<div className="opd-info"> <div className="opd-info">
<span className="opd-name">{player.name}</span> <span className="opd-name">{player.name}</span>
<span className="opd-since"> <span className="opd-since">
<i className="fa fa-clock-o" /> <i className="fa fa-clock" />
{' '}Waiting {formatSince(player.since)} {' '}Waiting {formatSince(player.since)}
</span> </span>
</div> </div>

View File

@@ -9,22 +9,27 @@
import { Fragment, useState } from 'react'; import { Fragment, useState } from 'react';
import { OnlinePlayersDialog } from '@mine-components'; import { OnlinePlayersDialog } from '@mine-components';
const WaitingOverlayContent = ({ shareUrl, currentGameAssoc }) => { const WaitingOverlayContent = ({ shareUrl, currentGameAssoc, opponentName = '', inviteOnly = false }) => {
const [dialogOpen, setDialogOpen] = useState(false); const [dialogOpen, setDialogOpen] = useState(false);
const inviteHeader = inviteOnly && opponentName
? `Invite ${opponentName}`
: 'Invite a Friend';
return ( return (
<Fragment> <Fragment>
<div className="waiting-options"> <div className={`waiting-options${inviteOnly ? ' waiting-options--invite-only' : ''}`}>
<div className="waiting-option"> <div className="waiting-option">
<div className="waiting-option-header"> <div className="waiting-option-header">
<i className="fa fa-link" /> <i className="fa fa-link" />
<span>Invite a Friend</span> <span>{inviteHeader}</span>
</div> </div>
<p className="waiting-option-desc">Share this link with your opponent</p> <p className="waiting-option-desc">Share this link with your opponent</p>
<ShareLinkBox <ShareLinkBox
url={shareUrl} url={shareUrl}
/> />
</div> </div>
{!inviteOnly && (
<Fragment>
<div className="waiting-divider"> <div className="waiting-divider">
<span>OR</span> <span>OR</span>
</div> </div>
@@ -42,13 +47,17 @@ const WaitingOverlayContent = ({ shareUrl, currentGameAssoc }) => {
Browse Players Browse Players
</button> </button>
</div> </div>
</Fragment>
)}
</div> </div>
{!inviteOnly && (
<OnlinePlayersDialog <OnlinePlayersDialog
open={dialogOpen} open={dialogOpen}
onClose={() => setDialogOpen(false)} onClose={() => setDialogOpen(false)}
currentGameAssoc={currentGameAssoc} currentGameAssoc={currentGameAssoc}
/> />
)}
</Fragment> </Fragment>
); );
}; };
@@ -57,10 +66,12 @@ const ShareLinkBox = ({ url }) => {
const [copied, setCopied] = useState(false); const [copied, setCopied] = useState(false);
const handleCopy = () => { const handleCopy = () => {
navigator.clipboard.writeText(url).then(() => { navigator.clipboard.writeText(url)
.then(() => {
setCopied(true); setCopied(true);
setTimeout(() => setCopied(false), 2500); setTimeout(() => setCopied(false), 2500);
}).catch(() => {}); })
.catch(() => null);
}; };
return ( return (

View File

@@ -23,6 +23,7 @@ const GridControl = ({ gameAssoc, onClick, resign }) => {
const [copied, setCopied] = useState(false); const [copied, setCopied] = useState(false);
const shareUrl = gameAssoc ? `${window.location.origin}/battle/${gameAssoc}` : null; const shareUrl = gameAssoc ? `${window.location.origin}/battle/${gameAssoc}` : null;
const isAuthenticated = '1' === document.getElementById('mine-wrapper')?.dataset.isAuthenticated;
const handleShare = () => { const handleShare = () => {
if (!shareUrl) return; if (!shareUrl) return;
@@ -64,6 +65,7 @@ const GridControl = ({ gameAssoc, onClick, resign }) => {
overlaySubTitle overlaySubTitle
)} )}
{gameAssoc && endRef.current && ( {gameAssoc && endRef.current && (
<div className="game-overlay-actions">
<button <button
className={`game-overlay-share${copied ? ' copied' : ''}`} className={`game-overlay-share${copied ? ' copied' : ''}`}
onClick={handleShare} onClick={handleShare}
@@ -73,6 +75,16 @@ const GridControl = ({ gameAssoc, onClick, resign }) => {
<i className={`fa ${copied ? 'fa-check' : 'fa-share-alt'}`} /> <i className={`fa ${copied ? 'fa-check' : 'fa-share-alt'}`} />
{copied ? 'Copied!' : 'Share Battle'} {copied ? 'Copied!' : 'Share Battle'}
</button> </button>
<a
className="game-overlay-profile"
href={isAuthenticated ? '/profile' : '/'}
title={isAuthenticated ? 'Go to your profile' : 'Go to homepage'}
aria-label={isAuthenticated ? 'Go to your profile' : 'Go to homepage'}
>
<i className={`fa ${isAuthenticated ? 'fa-user' : 'fa-house'}`} />
{isAuthenticated ? 'My Profile' : 'Homepage'}
</a>
</div>
)} )}
</div> </div>
</div> </div>

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

@@ -7,12 +7,14 @@
* file that was distributed with this source code. * file that was distributed with this source code.
*/ */
import React from 'react'; import React, { Fragment, useState } from 'react';
import { useGame } from '@mine-contexts'; import { useGame } from '@mine-contexts';
import User from './User'; import User from './User';
import BonusStatsDialog from '../BonusStatsDialog';
const UserControl = ({ resign }) => { const UserControl = ({ resign }) => {
const { webPlayer, activePlayer, mines, foundMines, red, blue, onBombToggle } = useGame(); const { webPlayer, activePlayer, mines, foundMines, red, blue, onBombToggle } = useGame();
const [bonusDialogOpen, setBonusDialogOpen] = useState(false);
const activeColor = activePlayer ? 'blue' : 'red'; const activeColor = activePlayer ? 'blue' : 'red';
const resignClass = 'resign' + (activeColor !== webPlayer ? ' disabled' : ''); const resignClass = 'resign' + (activeColor !== webPlayer ? ' disabled' : '');
const minesClass = 'active-mines' + (foundMines ? ' found-mine' : ''); const minesClass = 'active-mines' + (foundMines ? ' found-mine' : '');
@@ -24,11 +26,17 @@ const UserControl = ({ resign }) => {
} }
}; };
const handleBonusClick = () => {
setBonusDialogOpen(true);
};
return ( return (
<Fragment>
<div className="users"> <div className="users">
<User <User
color="blue" webPlayer={webPlayer} {...blue} color="blue" webPlayer={webPlayer} {...blue}
onClickBombSelector={() => handleBombClick('blue', 1)} onClickBombSelector={() => handleBombClick('blue', 1)}
onBonusClick={handleBonusClick}
/> />
<div className="active-mines-container"> <div className="active-mines-container">
<i className="fa fa-star" /> <i className="fa fa-star" />
@@ -42,12 +50,20 @@ const UserControl = ({ resign }) => {
<User <User
color="red" webPlayer={webPlayer} {...red} color="red" webPlayer={webPlayer} {...red}
onClickBombSelector={() => handleBombClick('red', 0)} onClickBombSelector={() => handleBombClick('red', 0)}
onBonusClick={handleBonusClick}
/> />
<button className={resignClass} onClick={resign}> <button className={resignClass} onClick={resign}>
<div className="resign-shine" /> <div className="resign-shine" />
Resign Resign
</button> </button>
</div> </div>
<BonusStatsDialog
open={bonusDialogOpen}
onClose={() => setBonusDialogOpen(false)}
red={red}
blue={blue}
/>
</Fragment>
); );
} }

View File

@@ -132,7 +132,18 @@ export const GameProvider = ({ children }) => {
}; };
const applyStep = stepData => { const applyStep = stepData => {
const { player, bomb: isBomb, minesFound = 0, revealedCells = [], redPoints: rp, bluePoints: bp } = stepData; const {
player,
bomb: isBomb,
minesFound = 0,
revealedCells = [],
redPoints: rp,
bluePoints: bp,
redBonusPoints = 0,
blueBonusPoints = 0,
redBonusStats = {},
blueBonusStats = {},
} = stepData;
if (isBomb) { if (isBomb) {
sounds.current.bomb.play(); sounds.current.bomb.play();
@@ -176,6 +187,18 @@ export const GameProvider = ({ children }) => {
syncBlue(p => ({ ...p, mines: 'blue' === player ? bp : p.mines })); syncBlue(p => ({ ...p, mines: 'blue' === player ? bp : p.mines }));
} }
// Update bonus points and stats
syncRed(p => ({
...p,
bonusPoints: 'red' === player ? redBonusPoints : p.bonusPoints,
bonusStats: 'red' === player ? redBonusStats : p.bonusStats,
}));
syncBlue(p => ({
...p,
bonusPoints: 'blue' === player ? blueBonusPoints : p.bonusPoints,
bonusStats: 'blue' === player ? blueBonusStats : p.bonusStats,
}));
syncRed(p => ({ ...p, enabledBomb: rp <= bp })); syncRed(p => ({ ...p, enabledBomb: rp <= bp }));
syncBlue(p => ({ ...p, enabledBomb: bp <= rp })); syncBlue(p => ({ ...p, enabledBomb: bp <= rp }));
@@ -234,7 +257,7 @@ export const GameProvider = ({ children }) => {
// Setters needed by useServerComm // Setters needed by useServerComm
setCells, setGridReady, setGameUuid, setCells, setGridReady, setGameUuid,
// Refs (needed by useServerComm for async-safe reads) // Refs (needed by useServerComm for async-safe reads)
webPlayerRef, activePlayerRef, bombSelectedRef, connectionLostRef, endRef, webPlayerRef, activePlayerRef, bombSelectedRef, connectionLostRef, lastClickedRef, endRef,
// Sync helpers // Sync helpers
syncWebPlayer, syncActivePlayer, syncBombSelected, syncConnLost, syncRed, syncBlue, syncWebPlayer, syncActivePlayer, syncBombSelected, syncConnLost, syncRed, syncBlue,
// Game logic called by useServerComm // Game logic called by useServerComm

View File

@@ -10,24 +10,20 @@
import React, { useEffect, useRef } from 'react'; import React, { useEffect, useRef } from 'react';
import { useMutation, useQuery } from '@tanstack/react-query'; import { useMutation, useQuery } from '@tanstack/react-query';
import { useGame } from '@mine-contexts'; import { useGame } from '@mine-contexts';
import { DESC } from '@mine-utils'; import { DESC, IMAGES } from '@mine-utils';
import useStepTimer from './useStepTimer'; import useStepTimer from './useStepTimer';
import { WaitingOverlayContent } from '@mine-components'; import { ChallengeCountdown, WaitingOverlayContent } from '@mine-components';
import { ChallengeCountdown } from '@mine-components'; const useServerCommunication = (gameAssoc, gameInherited, opponentName, isEnvDev) => {
const useServerCommunication = (gameAssoc, gameInherited, isEnvDev) => {
const { const {
/** Async-safe refs */ /** Async-safe refs */
webPlayerRef, activePlayerRef, bombSelectedRef, connectionLostRef, endRef, webPlayerRef, activePlayerRef, bombSelectedRef, connectionLostRef, lastClickedRef, endRef,
/** State setters */ /** State setters */
setGridReady, setGameUuid, setCells, setGridReady, setGameUuid,
/** Sync helpers */ /** Sync helpers */
syncWebPlayer, syncActivePlayer, syncBombSelected, syncConnLost, syncRed, syncBlue, syncWebPlayer, syncActivePlayer, syncBombSelected, syncConnLost, syncRed, syncBlue,
/** Game logic */ /** Game logic */
showOverlay, hideOverlay, showOverlay, hideOverlay, applyStep, makeGameEndIfItEnds, resignProcess,
applyRevealedCell, applyStep,
makeGameEndIfItEnds, resignProcess,
/** Current cells snapshot (for active-check in onClick) */ /** Current cells snapshot (for active-check in onClick) */
cells, cells,
} = useGame(); } = useGame();
@@ -35,9 +31,16 @@ const useServerCommunication = (gameAssoc, gameInherited, isEnvDev) => {
const eventSourceRef = useRef(null); const eventSourceRef = useRef(null);
const rpcUsersRef = useRef(null); const rpcUsersRef = useRef(null);
const stepCacheRef = useRef([]); const stepCacheRef = useRef([]);
const lastStepRef = useRef(null);
const isGameFinishedRef = useRef(false);
const { getStepElapsed, resetStepTimer, startNewTurn } = useStepTimer(); const { getStepElapsed, resetStepTimer, startNewTurn } = useStepTimer();
const isGameRunningRef = useRef(false); const isGameRunningRef = useRef(false);
const lastActivePlayerRef = useRef(null); const lastActivePlayerRef = useRef(null);
const heartbeatPubIntervalRef = useRef(null);
const opponentLastSeenRef = useRef(0);
const isTrueRestoredRef = useRef(false);
const HEARTBEAT_INTERVAL_MS = 1500;
/** REST mutations / queries */ /** REST mutations / queries */
@@ -75,43 +78,193 @@ const useServerCommunication = (gameAssoc, gameInherited, isEnvDev) => {
/** Game-start helpers (triggered by server events) */ /** Game-start helpers (triggered by server events) */
const wInit = (revealedCells = []) => { const wInit = (revealedCells = [], lastStep = {}, gameState = {}, isGameFinished = false) => {
setGridReady(true); /** Detect if this is a restored game */
showOverlay('Choose an opponent!', gameAssoc ? ( const isRestoredGame = 0 < revealedCells.length;
isTrueRestoredRef.current = isRestoredGame;
/** Store game finished status */
isGameFinishedRef.current = isGameFinished;
/** Apply game state (points, bonus) immediately for restored games */
if (0 < Object.keys(gameState).length) {
const {
redPoints = 0,
bluePoints = 0,
redBonusPoints = 0,
blueBonusPoints = 0,
redBonusStats = {},
blueBonusStats = {},
} = gameState;
syncRed(p => ({
...p,
mines: redPoints,
bonusPoints: redBonusPoints,
bonusStats: redBonusStats,
}));
syncBlue(p => ({
...p,
mines: bluePoints,
bonusPoints: blueBonusPoints,
bonusStats: blueBonusStats,
}));
}
/** Apply revealed cells immediately (not in setTimeout) */
if (0 < revealedCells.length) {
setCells(prev => {
let next = prev.map(r => [...r]);
revealedCells.forEach(({ row, col, value, player }) => {
if (next[row][col].active) return;
/** Check if this cell is the last step for either player */
const isRedLastStep = lastStep.red && lastStep.red.player === player && lastStep.red.row === row && lastStep.red.col === col;
const isBlueLastStep = lastStep.blue && lastStep.blue.player === player && lastStep.blue.row === row && lastStep.blue.col === col;
const patch = 'm' === value
? { currentImage: IMAGES.flag(player), currentObj: 'm', active: true }
: { currentImage: value, currentObj: value, active: true };
if (isRedLastStep || isBlueLastStep) {
patch.lastClickedRed = 'red' === player;
patch.lastClickedBlue = 'blue' === player;
}
next[row][col] = { ...next[row][col], ...patch };
});
return next;
});
}
/** Update the lastClickedRef so applyStep knows about it */
if (lastStep.red) {
lastClickedRef.current = {
...lastClickedRef.current,
red: [lastStep.red.row, lastStep.red.col],
};
}
if (lastStep.blue) {
lastClickedRef.current = {
...lastClickedRef.current,
blue: [lastStep.blue.row, lastStep.blue.col],
};
}
/** Determine overlay message */
let overlayTitle, overlaySubtitle;
if (isGameFinished) {
/** Game is finished - show game over message */
const redPoints = gameState.redPoints ?? 0;
const bluePoints = gameState.bluePoints ?? 0;
const winner = redPoints > bluePoints ? 'Red' : 'Blue';
overlayTitle = `${winner} wins the game!`;
overlaySubtitle = 'Play again!';
/** Mark the game as ended */
endRef.current = true;
} else if (isRestoredGame) {
overlayTitle = 'Waiting for opponent to reconnect...';
overlaySubtitle = gameAssoc ? (
<WaitingOverlayContent <WaitingOverlayContent
shareUrl={`${window.location.href}/${gameAssoc}`} shareUrl={`${window.location.origin}/play/${gameAssoc}`}
currentGameAssoc={gameAssoc}
opponentName={opponentName}
inviteOnly
/>
) : (
<div style={{ textAlign: 'center', padding: '20px' }}>
<p>Waiting for opponent to join...</p>
</div>
);
} else {
overlayTitle = 'Choose an opponent!';
overlaySubtitle = gameAssoc ? (
<WaitingOverlayContent
shareUrl={`${window.location.origin}/battle/${gameAssoc}`}
currentGameAssoc={gameAssoc} currentGameAssoc={gameAssoc}
/> />
) : ''); ) : '';
setTimeout(() => revealedCells.forEach(cell => applyRevealedCell(cell, cell.player)), 0); }
showOverlay(overlayTitle, overlaySubtitle);
/** Use Promise.resolve to defer setGridReady slightly to ensure overlay is rendered first */
Promise.resolve().then(() => setGridReady(true));
}; };
const makeGameStart = payload => { const makeGameStart = (payload, lastStep = {}) => {
syncActivePlayer(1); /** Don't start a finished game */
if (isGameFinishedRef.current) {
return;
}
/** If game is being restored and has a most recent step, determine starter based on that */
let starterIsBlue;
/** lastStepRef contains the single most recent step from the server */
if (lastStepRef.current && lastStepRef.current.player) {
/** The NEXT player is opposite of who made the last step */
starterIsBlue = 'red' === lastStepRef.current.player; // If red played last, blue plays next
} else {
/** New game: blue always starts */
starterIsBlue = true;
}
const starterColor = starterIsBlue ? 'blue' : 'red';
const starterVal = starterIsBlue ? 1 : 0;
const starterDesc = starterColor === webPlayerRef.current ? DESC.you : DESC.buddy;
syncActivePlayer(starterVal);
syncRed(p => ({ syncRed(p => ({
...p, ...p,
name: payload.users.red || payload.users.redAnon || p.name, name: payload.users.red || payload.users.redAnon || p.name,
registered: !!payload.users.red, registered: !!payload.users.red,
avatar: payload.users.redAvatar ?? null, avatar: payload.users.redAvatar ?? null,
desc: 'red' === starterColor ? starterDesc : '',
active: 'red' === starterColor,
})); }));
syncBlue(p => ({ syncBlue(p => ({
...p, ...p,
name: payload.users.blue || payload.users.blueAnon || p.name, name: payload.users.blue || payload.users.blueAnon || p.name,
registered: !!payload.users.blue, registered: !!payload.users.blue,
avatar: payload.users.blueAvatar ?? null, avatar: payload.users.blueAvatar ?? null,
desc: 'blue' === webPlayerRef.current ? DESC.you : DESC.buddy, desc: 'blue' === starterColor ? starterDesc : '',
active: true, active: 'blue' === starterColor,
})); }));
isGameRunningRef.current = true; isGameRunningRef.current = true;
lastActivePlayerRef.current = 1; // Blue starts lastActivePlayerRef.current = starterVal;
startNewTurn(); startNewTurn();
resetStepTimer(); resetStepTimer();
/**
* For a truly restored game, keep the "Waiting for opponent..." overlay
* up until we actually see a heartbeat from the other player.
*/
if (!isTrueRestoredRef.current || 0 !== opponentLastSeenRef.current) {
hideOverlay(); hideOverlay();
}
};
const publishHeartbeat = () => {
const me = webPlayerRef.current;
if (!me || endRef.current) return;
fetch('/api/game/heartbeat/' + gameAssoc, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ color: me }),
}).catch(e => isEnvDev && console.warn('Heartbeat publish failed', e));
};
const startHeartbeat = () => {
if (heartbeatPubIntervalRef.current) return;
publishHeartbeat();
heartbeatPubIntervalRef.current = setInterval(publishHeartbeat, HEARTBEAT_INTERVAL_MS);
};
const stopHeartbeat = () => {
if (heartbeatPubIntervalRef.current) {
clearInterval(heartbeatPubIntervalRef.current);
heartbeatPubIntervalRef.current = null;
}
}; };
/** Mercure / SSE message handlers */ /** Mercure / SSE message handlers */
const wSubscribe = (payload, rpcUsers = null) => { const wSubscribe = (payload, rpcUsers = null, lastStep = null) => {
isEnvDev && console.info((payload.user ?? 'user') + ' subscribed'); isEnvDev && console.info((payload.user ?? 'user') + ' subscribed');
const firstUser = !rpcUsers; const firstUser = !rpcUsers;
@@ -126,13 +279,34 @@ const useServerCommunication = (gameAssoc, gameInherited, isEnvDev) => {
&& (!connectionLostRef.current && (!connectionLostRef.current
|| (connectionLostRef.current && false === activePlayerRef.current && !endRef.current)) || (connectionLostRef.current && false === activePlayerRef.current && !endRef.current))
) { ) {
makeGameStart(payload); makeGameStart(payload, lastStep);
} }
}; };
const wUnsubscribe = payload => { const wUnsubscribe = payload => {
isEnvDev && console.info(payload.msg); isEnvDev && console.info(payload.msg);
showOverlay('The connection has been lost w/ your friend...', 'Please, restart the game!'); const isAuthenticated = '1' === document.getElementById('mine-wrapper')?.dataset.isAuthenticated;
const redirectPath = isAuthenticated ? '/profile' : '/';
const buttonText = isAuthenticated ? 'My Profile' : 'Homepage';
const buttonIcon = isAuthenticated ? 'fa-user' : 'fa-house';
showOverlay(
'The connection has been lost w/ your friend...',
(
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: '12px', width: '100%' }}>
<p style={{ margin: 0 }}>Please, restart the game!</p>
<a
className="game-overlay-profile"
href={redirectPath}
title={isAuthenticated ? 'Go to your profile' : 'Go to homepage'}
aria-label={isAuthenticated ? 'Go to your profile' : 'Go to homepage'}
>
<i className={`fa ${buttonIcon}`} />
{buttonText}
</a>
</div>
),
);
}; };
const wChallenge = payload => { const wChallenge = payload => {
@@ -147,7 +321,8 @@ const useServerCommunication = (gameAssoc, gameInherited, isEnvDev) => {
body: JSON.stringify({ accepted: true, targetGameAssoc: gameAssoc }), body: JSON.stringify({ accepted: true, targetGameAssoc: gameAssoc }),
}).then(() => { }).then(() => {
showOverlay('Challenge accepted!', 'Waiting for the challenger to join...'); showOverlay('Challenge accepted!', 'Waiting for the challenger to join...');
}).catch(() => {}); }).catch(() => {
});
}; };
const handleDecline = () => { const handleDecline = () => {
@@ -163,7 +338,8 @@ const useServerCommunication = (gameAssoc, gameInherited, isEnvDev) => {
currentGameAssoc={gameAssoc} currentGameAssoc={gameAssoc}
/> />
) : ''); ) : '');
}).catch(() => {}); }).catch(() => {
});
}; };
declineTimeout = setTimeout(handleDecline, 30000); declineTimeout = setTimeout(handleDecline, 30000);
@@ -188,8 +364,10 @@ const useServerCommunication = (gameAssoc, gameInherited, isEnvDev) => {
isEnvDev && console.warn(payload.user + ' stepped to ' + payload.data.coords.join(',')); isEnvDev && console.warn(payload.user + ' stepped to ' + payload.data.coords.join(','));
syncBombSelected(payload.data.bomb); syncBombSelected(payload.data.bomb);
// Detect if turn switched (other player made a move) /**
// After their move, it's now our turn (or the opposite player's turn) * Detect if turn switched (other player made a move)
* After their move, it's now our turn (or the opposite player's turn)
*/
if (lastActivePlayerRef.current !== activePlayerRef.current) { if (lastActivePlayerRef.current !== activePlayerRef.current) {
startNewTurn(); startNewTurn();
lastActivePlayerRef.current = activePlayerRef.current; lastActivePlayerRef.current = activePlayerRef.current;
@@ -210,13 +388,23 @@ const useServerCommunication = (gameAssoc, gameInherited, isEnvDev) => {
if (undefined !== payload.type) { if (undefined !== payload.type) {
if ('challenge' === payload.type) wChallenge(payload); if ('challenge' === payload.type) wChallenge(payload);
else if ('challenge-response' === payload.type) wChallengeResponse(payload); else if ('challenge-response' === payload.type) wChallengeResponse(payload);
else if ('heartbeat' === payload.type) {
const me = webPlayerRef.current;
if (me && payload.color && payload.color !== me) {
const wasFirst = 0 === opponentLastSeenRef.current;
opponentLastSeenRef.current = Date.now();
if (wasFirst && isTrueRestoredRef.current) {
hideOverlay();
}
}
}
return; return;
} }
if (undefined !== payload.data) { if (undefined !== payload.data) {
wTopic(payload); wTopic(payload);
} else if (undefined === payload.msg) { } else if (undefined === payload.msg) {
wSubscribe(payload, rpcUsersRef.current); wSubscribe(payload, rpcUsersRef.current, lastStepRef.current);
} else { } else {
wUnsubscribe(payload); wUnsubscribe(payload);
} }
@@ -236,8 +424,8 @@ const useServerCommunication = (gameAssoc, gameInherited, isEnvDev) => {
const url = new URL(hubUrl, window.location.origin); const url = new URL(hubUrl, window.location.origin);
url.searchParams.append('topic', 'mineseeker/channel/' + gameAssoc); url.searchParams.append('topic', 'mineseeker/channel/' + gameAssoc);
if (subscriberJwt) url.searchParams.append('authorization', subscriberJwt);
if (subscriberJwt) url.searchParams.append('authorization', subscriberJwt);
if (eventSourceRef.current) eventSourceRef.current.close(); if (eventSourceRef.current) eventSourceRef.current.close();
const es = new EventSource(url.toString()); const es = new EventSource(url.toString());
@@ -278,8 +466,22 @@ const useServerCommunication = (gameAssoc, gameInherited, isEnvDev) => {
} }
rpcUsersRef.current = serverData.users; rpcUsersRef.current = serverData.users;
lastStepRef.current = serverData.mostRecentStep || null;
/** Pass game state (points, bonus) to wInit */
const gameState = {
redPoints: serverData.redPoints ?? 0,
bluePoints: serverData.bluePoints ?? 0,
redBonusPoints: serverData.redBonusPoints ?? 0,
blueBonusPoints: serverData.blueBonusPoints ?? 0,
redBonusStats: serverData.redBonusStats ?? {},
blueBonusStats: serverData.blueBonusStats ?? {},
};
const isGameFinished = serverData.gameFinished ?? false;
wInit(serverData.revealedCells || [], serverData.lastStep || {}, gameState, isGameFinished);
/** Open event source after showing overlay */
openEventSource(); openEventSource();
wInit(serverData.revealedCells || []);
} else { } else {
await startMutation.mutateAsync(); await startMutation.mutateAsync();
openEventSource(); openEventSource();
@@ -288,6 +490,7 @@ const useServerCommunication = (gameAssoc, gameInherited, isEnvDev) => {
isEnvDev && console.info('Connection initialised — joining channel'); isEnvDev && console.info('Connection initialised — joining channel');
await joinMutation.mutateAsync(); await joinMutation.mutateAsync();
startHeartbeat();
} catch (e) { } catch (e) {
isEnvDev && console.error('Connection error', e); isEnvDev && console.error('Connection error', e);
setTimeout(() => window.location.reload(), 500); setTimeout(() => window.location.reload(), 500);
@@ -295,6 +498,10 @@ const useServerCommunication = (gameAssoc, gameInherited, isEnvDev) => {
})(); })();
window.addEventListener('pagehide', () => navigator.sendBeacon('/api/game/leave/' + gameAssoc)); window.addEventListener('pagehide', () => navigator.sendBeacon('/api/game/leave/' + gameAssoc));
return () => {
stopHeartbeat();
};
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, []); }, []);
@@ -338,7 +545,7 @@ const useServerCommunication = (gameAssoc, gameInherited, isEnvDev) => {
resignProcess(webPlayerRef.current, result.uuid); resignProcess(webPlayerRef.current, result.uuid);
} }
}, },
} },
); );
}; };

View File

@@ -34,9 +34,23 @@ export const IMAGES = {
bombPos: (hor, vert) => `${IMG}bg-bomb-${hor}-${vert}-outbg.png`, bombPos: (hor, vert) => `${IMG}bg-bomb-${hor}-${vert}-outbg.png`,
}; };
export const BONUS_STATS_DEF = {
blindHits: 0, chainBest: 0, chainCurrent: 0, lastMineHits: 0, edgeMines: 0, biggestReveal: 0,
};
export const BONUS_LABELS = {
blindHits: { label: 'Blind hits', desc: 'Mines clicked with no revealed number nearby' },
chainBest: { label: 'Best chain', desc: 'Longest streak of consecutive mine-clicks' },
chainCurrent: { label: 'Current chain', desc: 'Active consecutive mine-click streak' },
lastMineHits: { label: 'Endgame mines', desc: 'Mines clicked while few remain on the board' },
edgeMines: { label: 'Edge mines', desc: 'Mines clicked on the board boundary' },
biggestReveal: { label: 'Biggest reveal', desc: 'Largest number of safe cells revealed in one click' },
};
export const PLAYER_DEF = { export const PLAYER_DEF = {
name: '...', desc: '', active: false, mines: 0, haveBomb: true, enabledBomb: true, name: '...', desc: '', active: false, mines: 0, haveBomb: true, enabledBomb: true,
registered: false, avatar: null, registered: false, avatar: null,
bonusPoints: 0, bonusStats: { ...BONUS_STATS_DEF },
}; };
export const DESC = { export const DESC = {

View File

@@ -7,4 +7,4 @@
* file that was distributed with this source code. * file that was distributed with this source code.
*/ */
export { DESC, IMAGES, BOMB_SYMBOLS, PLAYER_DEF, bombRadius, initCells, patchCells } from './constants'; export { DESC, IMAGES, BOMB_SYMBOLS, PLAYER_DEF, BONUS_STATS_DEF, BONUS_LABELS, bombRadius, initCells, patchCells } from './constants';

View File

@@ -110,7 +110,7 @@
"@fontsource/changa-one": ["@fontsource/changa-one@5.2.8", "http://registry.npmjs.org/@fontsource/changa-one/-/changa-one-5.2.8.tgz", {}, "sha512-gagiU8sMWLs9ejh41NmrYlGdgasSYWFegz5/+22WqYdJVS9HZbaUEXj/6jj3ZKgi+dQZT0kYI+Nha2UQGbO/mA=="], "@fontsource/changa-one": ["@fontsource/changa-one@5.2.8", "http://registry.npmjs.org/@fontsource/changa-one/-/changa-one-5.2.8.tgz", {}, "sha512-gagiU8sMWLs9ejh41NmrYlGdgasSYWFegz5/+22WqYdJVS9HZbaUEXj/6jj3ZKgi+dQZT0kYI+Nha2UQGbO/mA=="],
"@fontsource/open-sans": ["@fontsource/open-sans@5.2.7", "", {}, "sha512-8yfgDYjE5O0vmTPdrcjV35y4yMnctsokmi9gN49Gcsr0sjzkMkR97AnKDe6OqZh2SFkYlR28fxOvi21bYEgMSw=="], "@fontsource/open-sans": ["@fontsource/open-sans@5.2.7", "http://registry.npmjs.org/@fontsource/open-sans/-/open-sans-5.2.7.tgz", {}, "sha512-8yfgDYjE5O0vmTPdrcjV35y4yMnctsokmi9gN49Gcsr0sjzkMkR97AnKDe6OqZh2SFkYlR28fxOvi21bYEgMSw=="],
"@fontsource/rajdhani": ["@fontsource/rajdhani@5.2.7", "http://registry.npmjs.org/@fontsource/rajdhani/-/rajdhani-5.2.7.tgz", {}, "sha512-7Gy10U688fCdeFfYKebUF2TZotdgH/ghKyMsseXPmB60lpaUHC8aoCSJl5/OpZ+KHKSU2TqBfKfteVkcIXxTAQ=="], "@fontsource/rajdhani": ["@fontsource/rajdhani@5.2.7", "http://registry.npmjs.org/@fontsource/rajdhani/-/rajdhani-5.2.7.tgz", {}, "sha512-7Gy10U688fCdeFfYKebUF2TZotdgH/ghKyMsseXPmB60lpaUHC8aoCSJl5/OpZ+KHKSU2TqBfKfteVkcIXxTAQ=="],
@@ -146,11 +146,11 @@
"@mui/utils": ["@mui/utils@9.0.0", "http://registry.npmjs.org/@mui/utils/-/utils-9.0.0.tgz", { "dependencies": { "@babel/runtime": "^7.29.2", "@mui/types": "^9.0.0", "@types/prop-types": "^15.7.15", "clsx": "^2.1.1", "prop-types": "^15.8.1", "react-is": "^19.2.4" }, "peerDependencies": { "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0", "react": "^17.0.0 || ^18.0.0 || ^19.0.0" }, "optionalPeers": ["@types/react"] }, "sha512-bQcqyg/gjULUqTuyUjSAFr6LQGLvtkNtDbJerAtoUn9kGZ0hg5QJiN1PLHMLbeFpe3te1831uq7GFl2ITokGdg=="], "@mui/utils": ["@mui/utils@9.0.0", "http://registry.npmjs.org/@mui/utils/-/utils-9.0.0.tgz", { "dependencies": { "@babel/runtime": "^7.29.2", "@mui/types": "^9.0.0", "@types/prop-types": "^15.7.15", "clsx": "^2.1.1", "prop-types": "^15.8.1", "react-is": "^19.2.4" }, "peerDependencies": { "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0", "react": "^17.0.0 || ^18.0.0 || ^19.0.0" }, "optionalPeers": ["@types/react"] }, "sha512-bQcqyg/gjULUqTuyUjSAFr6LQGLvtkNtDbJerAtoUn9kGZ0hg5QJiN1PLHMLbeFpe3te1831uq7GFl2ITokGdg=="],
"@mui/x-charts": ["@mui/x-charts@9.0.1", "http://registry.npmjs.org/@mui/x-charts/-/x-charts-9.0.1.tgz", { "dependencies": { "@babel/runtime": "^7.28.6", "@mui/utils": "9.0.0", "@mui/x-charts-vendor": "^9.0.0", "@mui/x-internal-gestures": "^9.0.0", "@mui/x-internals": "^9.0.0", "bezier-easing": "^2.1.0", "clsx": "^2.1.1", "prop-types": "^15.8.1", "reselect": "^5.1.1", "use-sync-external-store": "^1.6.0" }, "peerDependencies": { "@emotion/react": "^11.9.0", "@emotion/styled": "^11.8.1", "@mui/material": "^7.3.0 || ^9.0.0", "@mui/system": "^7.3.0 || ^9.0.0", "react": "^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0" }, "optionalPeers": ["@emotion/react", "@emotion/styled"] }, "sha512-0LyhlGhUm07wGJY0d0U+hSljGS1EHKWgPBsTJ/lBNGDrNc4DI9zSbp4h802LN/eLwMUVXJSI7DH2W3Ef3WsqnQ=="], "@mui/x-charts": ["@mui/x-charts@9.0.2", "", { "dependencies": { "@babel/runtime": "^7.28.6", "@mui/utils": "9.0.0", "@mui/x-charts-vendor": "^9.0.0", "@mui/x-internal-gestures": "^9.0.2", "@mui/x-internals": "^9.0.0", "bezier-easing": "^2.1.0", "clsx": "^2.1.1", "prop-types": "^15.8.1", "reselect": "^5.1.1", "use-sync-external-store": "^1.6.0" }, "peerDependencies": { "@emotion/react": "^11.9.0", "@emotion/styled": "^11.8.1", "@mui/material": "^7.3.0 || ^9.0.0", "@mui/system": "^7.3.0 || ^9.0.0", "react": "^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0" }, "optionalPeers": ["@emotion/react", "@emotion/styled"] }, "sha512-bKgjGD+uJbDN/g7tMjVmlNdm+iM4UkCJoYruQmgpQ0l+cip8Kn4kmn1iD//rZ35an+LdWaUZ4MHvMzV76D6EJw=="],
"@mui/x-charts-vendor": ["@mui/x-charts-vendor@9.0.0", "http://registry.npmjs.org/@mui/x-charts-vendor/-/x-charts-vendor-9.0.0.tgz", { "dependencies": { "@babel/runtime": "^7.28.6", "@types/d3-array": "^3.2.2", "@types/d3-color": "^3.1.3", "@types/d3-format": "^3.0.4", "@types/d3-interpolate": "^3.0.4", "@types/d3-path": "^3.1.1", "@types/d3-scale": "^4.0.9", "@types/d3-shape": "^3.1.8", "@types/d3-time": "^3.0.4", "@types/d3-time-format": "^4.0.3", "@types/d3-timer": "^3.0.2", "d3-array": "^3.2.4", "d3-color": "^3.1.0", "d3-format": "^3.1.2", "d3-interpolate": "^3.0.1", "d3-path": "^3.1.0", "d3-scale": "^4.0.2", "d3-shape": "^3.2.0", "d3-time": "^3.1.0", "d3-time-format": "^4.1.0", "d3-timer": "^3.0.1", "flatqueue": "^3.0.0", "internmap": "^2.0.3" } }, "sha512-Do91i+fZiNj/4LN5oaGpJoutolzDBDwdfw6tHrx2LKXDMCRlaImCfreLbdbkk7dFsi9fuIP7hWiMV4vDJKPJTA=="], "@mui/x-charts-vendor": ["@mui/x-charts-vendor@9.0.0", "http://registry.npmjs.org/@mui/x-charts-vendor/-/x-charts-vendor-9.0.0.tgz", { "dependencies": { "@babel/runtime": "^7.28.6", "@types/d3-array": "^3.2.2", "@types/d3-color": "^3.1.3", "@types/d3-format": "^3.0.4", "@types/d3-interpolate": "^3.0.4", "@types/d3-path": "^3.1.1", "@types/d3-scale": "^4.0.9", "@types/d3-shape": "^3.1.8", "@types/d3-time": "^3.0.4", "@types/d3-time-format": "^4.0.3", "@types/d3-timer": "^3.0.2", "d3-array": "^3.2.4", "d3-color": "^3.1.0", "d3-format": "^3.1.2", "d3-interpolate": "^3.0.1", "d3-path": "^3.1.0", "d3-scale": "^4.0.2", "d3-shape": "^3.2.0", "d3-time": "^3.1.0", "d3-time-format": "^4.1.0", "d3-timer": "^3.0.1", "flatqueue": "^3.0.0", "internmap": "^2.0.3" } }, "sha512-Do91i+fZiNj/4LN5oaGpJoutolzDBDwdfw6tHrx2LKXDMCRlaImCfreLbdbkk7dFsi9fuIP7hWiMV4vDJKPJTA=="],
"@mui/x-internal-gestures": ["@mui/x-internal-gestures@9.0.0", "http://registry.npmjs.org/@mui/x-internal-gestures/-/x-internal-gestures-9.0.0.tgz", { "dependencies": { "@babel/runtime": "^7.28.6" } }, "sha512-+fW1EUai25GJbivGRsi3GX4GYsSvzFPvUEjmMgB4POkRBDjrEZNaLdVWfapT6DlWv/Vfbi08bYSuyvhPXGMZjw=="], "@mui/x-internal-gestures": ["@mui/x-internal-gestures@9.0.2", "", { "dependencies": { "@babel/runtime": "^7.28.6" } }, "sha512-xCp99a7cSb7iH1bj4G524ooMOFe92H8m/rONCUiKyj7LvV1YUGzTfHgJysQgDCZJqHYaW7YAGLvwMUyEMZVzqQ=="],
"@mui/x-internals": ["@mui/x-internals@9.0.0", "http://registry.npmjs.org/@mui/x-internals/-/x-internals-9.0.0.tgz", { "dependencies": { "@babel/runtime": "^7.28.6", "@mui/utils": "9.0.0", "reselect": "^5.1.1", "use-sync-external-store": "^1.6.0" }, "peerDependencies": { "react": "^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-E/4rdg69JjhyybpPGypCjAKSKLLnSdCFM+O6P/nkUg47+qt3uftxQEhjQO53rcn6ahHl6du/uNZ9BLgeY6kYxQ=="], "@mui/x-internals": ["@mui/x-internals@9.0.0", "http://registry.npmjs.org/@mui/x-internals/-/x-internals-9.0.0.tgz", { "dependencies": { "@babel/runtime": "^7.28.6", "@mui/utils": "9.0.0", "reselect": "^5.1.1", "use-sync-external-store": "^1.6.0" }, "peerDependencies": { "react": "^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-E/4rdg69JjhyybpPGypCjAKSKLLnSdCFM+O6P/nkUg47+qt3uftxQEhjQO53rcn6ahHl6du/uNZ9BLgeY6kYxQ=="],
@@ -230,9 +230,9 @@
"@stylistic/eslint-plugin": ["@stylistic/eslint-plugin@4.4.1", "http://registry.npmjs.org/@stylistic/eslint-plugin/-/eslint-plugin-4.4.1.tgz", { "dependencies": { "@typescript-eslint/utils": "^8.32.1", "eslint-visitor-keys": "^4.2.0", "espree": "^10.3.0", "estraverse": "^5.3.0", "picomatch": "^4.0.2" }, "peerDependencies": { "eslint": ">=9.0.0" } }, "sha512-CEigAk7eOLyHvdgmpZsKFwtiqS2wFwI1fn4j09IU9GmD4euFM4jEBAViWeCqaNLlbX2k2+A/Fq9cje4HQBXuJQ=="], "@stylistic/eslint-plugin": ["@stylistic/eslint-plugin@4.4.1", "http://registry.npmjs.org/@stylistic/eslint-plugin/-/eslint-plugin-4.4.1.tgz", { "dependencies": { "@typescript-eslint/utils": "^8.32.1", "eslint-visitor-keys": "^4.2.0", "espree": "^10.3.0", "estraverse": "^5.3.0", "picomatch": "^4.0.2" }, "peerDependencies": { "eslint": ">=9.0.0" } }, "sha512-CEigAk7eOLyHvdgmpZsKFwtiqS2wFwI1fn4j09IU9GmD4euFM4jEBAViWeCqaNLlbX2k2+A/Fq9cje4HQBXuJQ=="],
"@tanstack/query-core": ["@tanstack/query-core@5.97.0", "http://registry.npmjs.org/@tanstack/query-core/-/query-core-5.97.0.tgz", {}, "sha512-QdpLP5VzVMgo4VtaPppRA2W04UFjIqX+bxke/ZJhE5cfd5UPkRzqIAJQt9uXkQJjqE8LBOMbKv7f8HCsZltXlg=="], "@tanstack/query-core": ["@tanstack/query-core@5.99.0", "", {}, "sha512-3Jv3WQG0BCcH7G+7lf/bP8QyBfJOXeY+T08Rin3GZ1bshvwlbPt7NrDHMEzGdKIOmOzvIQmxjk28YEQX60k7pQ=="],
"@tanstack/react-query": ["@tanstack/react-query@5.97.0", "http://registry.npmjs.org/@tanstack/react-query/-/react-query-5.97.0.tgz", { "dependencies": { "@tanstack/query-core": "5.97.0" }, "peerDependencies": { "react": "^18 || ^19" } }, "sha512-y4So4eGcQoK2WVMAcDNZE9ofB/p5v1OlKvtc1F3uqHwrtifobT7q+ZnXk2mRkc8E84HKYSlAE9z6HXl2V0+ySQ=="], "@tanstack/react-query": ["@tanstack/react-query@5.99.0", "", { "dependencies": { "@tanstack/query-core": "5.99.0" }, "peerDependencies": { "react": "^18 || ^19" } }, "sha512-OY2bCqPemT1LlqJ8Y2CUau4KELnIhhG9Ol3ZndPbdnB095pRbPo1cHuXTndg8iIwtoHTgwZjyaDnQ0xD0mYwAw=="],
"@tybys/wasm-util": ["@tybys/wasm-util@0.10.1", "http://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg=="], "@tybys/wasm-util": ["@tybys/wasm-util@0.10.1", "http://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg=="],
@@ -500,7 +500,7 @@
"hoist-non-react-statics": ["hoist-non-react-statics@3.3.2", "http://registry.npmjs.org/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz", { "dependencies": { "react-is": "^16.7.0" } }, "sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw=="], "hoist-non-react-statics": ["hoist-non-react-statics@3.3.2", "http://registry.npmjs.org/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz", { "dependencies": { "react-is": "^16.7.0" } }, "sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw=="],
"howler": ["howler@2.1.2", "http://registry.npmjs.org/howler/-/howler-2.1.2.tgz", {}, "sha512-oKrTFaVXsDRoB/jik7cEpWKTj7VieoiuzMYJ7E/EU5ayvmpRhumCv3YQ3823zi9VTJkSWAhbryHnlZAionGAJg=="], "howler": ["howler@2.2.4", "", {}, "sha512-iARIBPgcQrwtEr+tALF+rapJ8qSc+Set2GJQl7xT1MQzWaVkFebdJhR3alVlSiUf5U7nAANKuj3aWpwerocD5w=="],
"ignore": ["ignore@5.3.2", "http://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="], "ignore": ["ignore@5.3.2", "http://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="],
@@ -690,7 +690,7 @@
"prelude-ls": ["prelude-ls@1.2.1", "http://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", {}, "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g=="], "prelude-ls": ["prelude-ls@1.2.1", "http://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", {}, "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g=="],
"prop-types": ["prop-types@15.7.2", "http://registry.npmjs.org/prop-types/-/prop-types-15.7.2.tgz", { "dependencies": { "loose-envify": "^1.4.0", "object-assign": "^4.1.1", "react-is": "^16.8.1" } }, "sha512-8QQikdH7//R2vurIJSutZ1smHYTcLpRWEOlHnzcWHmBYrOGUysKwSsrC89BCiFj3CbrfJ/nXFdJepOVrY1GCHQ=="], "prop-types": ["prop-types@15.8.1", "http://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", { "dependencies": { "loose-envify": "^1.4.0", "object-assign": "^4.1.1", "react-is": "^16.13.1" } }, "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg=="],
"punycode": ["punycode@2.3.1", "http://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="], "punycode": ["punycode@2.3.1", "http://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="],
@@ -830,18 +830,6 @@
"@eslint/eslintrc/globals": ["globals@14.0.0", "http://registry.npmjs.org/globals/-/globals-14.0.0.tgz", {}, "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ=="], "@eslint/eslintrc/globals": ["globals@14.0.0", "http://registry.npmjs.org/globals/-/globals-14.0.0.tgz", {}, "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ=="],
"@mui/material/prop-types": ["prop-types@15.8.1", "http://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", { "dependencies": { "loose-envify": "^1.4.0", "object-assign": "^4.1.1", "react-is": "^16.13.1" } }, "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg=="],
"@mui/private-theming/prop-types": ["prop-types@15.8.1", "http://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", { "dependencies": { "loose-envify": "^1.4.0", "object-assign": "^4.1.1", "react-is": "^16.13.1" } }, "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg=="],
"@mui/styled-engine/prop-types": ["prop-types@15.8.1", "http://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", { "dependencies": { "loose-envify": "^1.4.0", "object-assign": "^4.1.1", "react-is": "^16.13.1" } }, "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg=="],
"@mui/system/prop-types": ["prop-types@15.8.1", "http://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", { "dependencies": { "loose-envify": "^1.4.0", "object-assign": "^4.1.1", "react-is": "^16.13.1" } }, "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg=="],
"@mui/utils/prop-types": ["prop-types@15.8.1", "http://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", { "dependencies": { "loose-envify": "^1.4.0", "object-assign": "^4.1.1", "react-is": "^16.13.1" } }, "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg=="],
"@mui/x-charts/prop-types": ["prop-types@15.8.1", "http://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", { "dependencies": { "loose-envify": "^1.4.0", "object-assign": "^4.1.1", "react-is": "^16.13.1" } }, "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg=="],
"@typescript-eslint/typescript-estree/minimatch": ["minimatch@10.2.5", "http://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", { "dependencies": { "brace-expansion": "^5.0.5" } }, "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg=="], "@typescript-eslint/typescript-estree/minimatch": ["minimatch@10.2.5", "http://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", { "dependencies": { "brace-expansion": "^5.0.5" } }, "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg=="],
"@typescript-eslint/typescript-estree/semver": ["semver@7.7.4", "http://registry.npmjs.org/semver/-/semver-7.7.4.tgz", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="], "@typescript-eslint/typescript-estree/semver": ["semver@7.7.4", "http://registry.npmjs.org/semver/-/semver-7.7.4.tgz", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="],
@@ -850,38 +838,18 @@
"babel-plugin-macros/resolve": ["resolve@1.22.12", "http://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz", { "dependencies": { "es-errors": "^1.3.0", "is-core-module": "^2.16.1", "path-parse": "^1.0.7", "supports-preserve-symlinks-flag": "^1.0.0" }, "bin": { "resolve": "bin/resolve" } }, "sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA=="], "babel-plugin-macros/resolve": ["resolve@1.22.12", "http://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz", { "dependencies": { "es-errors": "^1.3.0", "is-core-module": "^2.16.1", "path-parse": "^1.0.7", "supports-preserve-symlinks-flag": "^1.0.0" }, "bin": { "resolve": "bin/resolve" } }, "sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA=="],
"eslint-plugin-react/prop-types": ["prop-types@15.8.1", "http://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", { "dependencies": { "loose-envify": "^1.4.0", "object-assign": "^4.1.1", "react-is": "^16.13.1" } }, "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg=="],
"fast-glob/glob-parent": ["glob-parent@5.1.2", "http://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="], "fast-glob/glob-parent": ["glob-parent@5.1.2", "http://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="],
"hoist-non-react-statics/react-is": ["react-is@16.13.1", "http://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", {}, "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="], "hoist-non-react-statics/react-is": ["react-is@16.13.1", "http://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", {}, "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="],
"micromatch/picomatch": ["picomatch@2.3.2", "http://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", {}, "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA=="], "micromatch/picomatch": ["picomatch@2.3.2", "http://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", {}, "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA=="],
"prop-types/react-is": ["react-is@16.11.0", "http://registry.npmjs.org/react-is/-/react-is-16.11.0.tgz", {}, "sha512-gbBVYR2p8mnriqAwWx9LbuUrShnAuSCNnuPGyc7GJrMVQtPDAh8iLpv7FRuMPFb56KkaVZIYSz1PrjI9q0QPCw=="], "prop-types/react-is": ["react-is@16.13.1", "http://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", {}, "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="],
"react-transition-group/prop-types": ["prop-types@15.8.1", "http://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", { "dependencies": { "loose-envify": "^1.4.0", "object-assign": "^4.1.1", "react-is": "^16.13.1" } }, "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg=="],
"rolldown/@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-rc.15", "http://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.15.tgz", {}, "sha512-UromN0peaE53IaBRe9W7CjrZgXl90fqGpK+mIZbA3qSTeYqg3pqpROBdIPvOG3F5ereDHNwoHBI2e50n1BDr1g=="], "rolldown/@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-rc.15", "http://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.15.tgz", {}, "sha512-UromN0peaE53IaBRe9W7CjrZgXl90fqGpK+mIZbA3qSTeYqg3pqpROBdIPvOG3F5ereDHNwoHBI2e50n1BDr1g=="],
"@mui/material/prop-types/react-is": ["react-is@16.13.1", "http://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", {}, "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="],
"@mui/private-theming/prop-types/react-is": ["react-is@16.13.1", "http://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", {}, "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="],
"@mui/styled-engine/prop-types/react-is": ["react-is@16.13.1", "http://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", {}, "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="],
"@mui/system/prop-types/react-is": ["react-is@16.13.1", "http://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", {}, "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="],
"@mui/utils/prop-types/react-is": ["react-is@16.13.1", "http://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", {}, "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="],
"@mui/x-charts/prop-types/react-is": ["react-is@16.13.1", "http://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", {}, "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="],
"@typescript-eslint/typescript-estree/minimatch/brace-expansion": ["brace-expansion@5.0.5", "http://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ=="], "@typescript-eslint/typescript-estree/minimatch/brace-expansion": ["brace-expansion@5.0.5", "http://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ=="],
"eslint-plugin-react/prop-types/react-is": ["react-is@16.13.1", "http://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", {}, "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="],
"react-transition-group/prop-types/react-is": ["react-is@16.13.1", "http://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", {}, "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="],
"@typescript-eslint/typescript-estree/minimatch/brace-expansion/balanced-match": ["balanced-match@4.0.4", "http://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="], "@typescript-eslint/typescript-estree/minimatch/brace-expansion/balanced-match": ["balanced-match@4.0.4", "http://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="],
} }
} }

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

@@ -19,6 +19,7 @@
"doctrine/doctrine-migrations-bundle": "^3.0", "doctrine/doctrine-migrations-bundle": "^3.0",
"doctrine/orm": "^2.6", "doctrine/orm": "^2.6",
"endroid/qr-code": "^6.1", "endroid/qr-code": "^6.1",
"firebase/php-jwt": "^7.0",
"league/flysystem-aws-s3-v3": "^3.0", "league/flysystem-aws-s3-v3": "^3.0",
"league/flysystem-bundle": "^3.6", "league/flysystem-bundle": "^3.6",
"liip/imagine-bundle": "^2.13", "liip/imagine-bundle": "^2.13",
@@ -43,7 +44,6 @@
"web-auth/webauthn-framework": "^5.2" "web-auth/webauthn-framework": "^5.2"
}, },
"require-dev": { "require-dev": {
"firebase/php-jwt": "^7.0",
"roave/security-advisories": "dev-master", "roave/security-advisories": "dev-master",
"symfony/dotenv": "7.4.*", "symfony/dotenv": "7.4.*",
"symfony/maker-bundle": "^1.5", "symfony/maker-bundle": "^1.5",

137
composer.lock generated
View File

@@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically" "This file is @generated automatically"
], ],
"content-hash": "68e0a67890fc1a4a01f1f2154b477054", "content-hash": "cd6be4d237e7c8f70cc45e42e14eac8a",
"packages": [ "packages": [
{ {
"name": "aws/aws-crt-php", "name": "aws/aws-crt-php",
@@ -1780,6 +1780,70 @@
], ],
"time": "2026-02-05T07:01:58+00:00" "time": "2026-02-05T07:01:58+00:00"
}, },
{
"name": "firebase/php-jwt",
"version": "v7.0.5",
"source": {
"type": "git",
"url": "https://github.com/googleapis/php-jwt.git",
"reference": "47ad26bab5e7c70ae8a6f08ed25ff83631121380"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/googleapis/php-jwt/zipball/47ad26bab5e7c70ae8a6f08ed25ff83631121380",
"reference": "47ad26bab5e7c70ae8a6f08ed25ff83631121380",
"shasum": ""
},
"require": {
"php": "^8.0"
},
"require-dev": {
"guzzlehttp/guzzle": "^7.4",
"phpfastcache/phpfastcache": "^9.2",
"phpspec/prophecy-phpunit": "^2.0",
"phpunit/phpunit": "^9.5",
"psr/cache": "^2.0||^3.0",
"psr/http-client": "^1.0",
"psr/http-factory": "^1.0"
},
"suggest": {
"ext-sodium": "Support EdDSA (Ed25519) signatures",
"paragonie/sodium_compat": "Support EdDSA (Ed25519) signatures when libsodium is not present"
},
"type": "library",
"autoload": {
"psr-4": {
"Firebase\\JWT\\": "src"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"BSD-3-Clause"
],
"authors": [
{
"name": "Neuman Vong",
"email": "neuman+pear@twilio.com",
"role": "Developer"
},
{
"name": "Anant Narayanan",
"email": "anant@php.net",
"role": "Developer"
}
],
"description": "A simple library to encode and decode JSON Web Tokens (JWT) in PHP. Should conform to the current spec.",
"homepage": "https://github.com/firebase/php-jwt",
"keywords": [
"jwt",
"php"
],
"support": {
"issues": "https://github.com/googleapis/php-jwt/issues",
"source": "https://github.com/googleapis/php-jwt/tree/v7.0.5"
},
"time": "2026-04-01T20:38:03+00:00"
},
{ {
"name": "guzzlehttp/guzzle", "name": "guzzlehttp/guzzle",
"version": "7.10.0", "version": "7.10.0",
@@ -9848,70 +9912,6 @@
} }
], ],
"packages-dev": [ "packages-dev": [
{
"name": "firebase/php-jwt",
"version": "v7.0.5",
"source": {
"type": "git",
"url": "https://github.com/googleapis/php-jwt.git",
"reference": "47ad26bab5e7c70ae8a6f08ed25ff83631121380"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/googleapis/php-jwt/zipball/47ad26bab5e7c70ae8a6f08ed25ff83631121380",
"reference": "47ad26bab5e7c70ae8a6f08ed25ff83631121380",
"shasum": ""
},
"require": {
"php": "^8.0"
},
"require-dev": {
"guzzlehttp/guzzle": "^7.4",
"phpfastcache/phpfastcache": "^9.2",
"phpspec/prophecy-phpunit": "^2.0",
"phpunit/phpunit": "^9.5",
"psr/cache": "^2.0||^3.0",
"psr/http-client": "^1.0",
"psr/http-factory": "^1.0"
},
"suggest": {
"ext-sodium": "Support EdDSA (Ed25519) signatures",
"paragonie/sodium_compat": "Support EdDSA (Ed25519) signatures when libsodium is not present"
},
"type": "library",
"autoload": {
"psr-4": {
"Firebase\\JWT\\": "src"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"BSD-3-Clause"
],
"authors": [
{
"name": "Neuman Vong",
"email": "neuman+pear@twilio.com",
"role": "Developer"
},
{
"name": "Anant Narayanan",
"email": "anant@php.net",
"role": "Developer"
}
],
"description": "A simple library to encode and decode JSON Web Tokens (JWT) in PHP. Should conform to the current spec.",
"homepage": "https://github.com/firebase/php-jwt",
"keywords": [
"jwt",
"php"
],
"support": {
"issues": "https://github.com/googleapis/php-jwt/issues",
"source": "https://github.com/googleapis/php-jwt/tree/v7.0.5"
},
"time": "2026-04-01T20:38:03+00:00"
},
{ {
"name": "nikic/php-parser", "name": "nikic/php-parser",
"version": "v5.7.0", "version": "v5.7.0",
@@ -11289,16 +11289,17 @@
} }
], ],
"aliases": [], "aliases": [],
"minimum-stability": "stable", "minimum-stability": "dev",
"stability-flags": { "stability-flags": {
"roave/security-advisories": 20 "roave/security-advisories": 20
}, },
"prefer-stable": false, "prefer-stable": true,
"prefer-lowest": false, "prefer-lowest": false,
"platform": { "platform": {
"php": ">=8.5", "php": ">=8.5",
"ext-iconv": "*", "ext-iconv": "*",
"ext-json": "*" "ext-json": "*",
"ext-gd": "*"
}, },
"platform-dev": {}, "platform-dev": {},
"plugin-api-version": "2.9.0" "plugin-api-version": "2.9.0"

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

47
docs/README.md Normal file
View File

@@ -0,0 +1,47 @@
# Mine-Seeker Game Documentation
This directory contains comprehensive documentation about the Mine-Seeker game mechanics and implementation.
## Game Mechanics
### [Bonus Points System](./game-mechanics/BONUS_POINTS_SYSTEM.md)
Complete reference for the bonus points system including:
- All 6 bonus point types (Blind Hit, Chain Combo, Edge Mine, Endgame Mine, Safe Cell Bonus, Biggest Reveal)
- Calculation rules and examples
- Bonus statistics tracking
- Player name formatting in dialogs
- Database schema
- Implementation notes
- Testing checklist
**Recommended for**: Developers working on bonus system, AI assistants implementing or debugging bonus features, understanding game scoring mechanics.
---
## Quick Reference
### Bonus Points at a Glance
| Bonus Type | Points | Condition |
|-----------|--------|-----------|
| Blind Hit | +2 | Mine with no revealed numbered neighbors |
| Edge Mine | +1 | Mine on board boundary (row/col 0 or 15) |
| Endgame Mine | +3 | Mine clicked when ≤10 mines remain |
| Safe Cell | +0.5 each | ≥2 safe cells revealed (min requirement) |
| Chain Combo | Tracked | Consecutive mine clicks (no safe clicks) |
| Biggest Reveal | Tracked | Largest number of safe cells revealed |
### Key Rules
- Safe cell bonus only awarded for ≥2 cells minimum
- Chain counter resets on any safe cell click
- Endgame threshold: 51 - (redPoints + bluePoints) ≤ 10
- Bonus stats are per-player and persist in database
---
## Files Using This Information
- Backend: `/src/Util/TopicManager.php`
- Frontend: `/assets/js/mine-seeker/contexts/GameProvider.jsx`
- UI: `/assets/js/mine-seeker/components/BonusStatsDialog.jsx`
- Constants: `/assets/js/mine-seeker/utils/constants.jsx`

View File

@@ -0,0 +1,168 @@
# Mine-Seeker Bonus Points System
## Overview
The Mine-Seeker game includes a bonus points system that rewards skilled play. Bonus points are tracked separately from the main score and displayed in the "Bonus Statistics" dialog.
## Bonus Point Types
### 1. Blind Hit (+2 points)
**When**: Click a mine with no revealed numbered neighbors around it.
**Example**: Mine surrounded by unrevealed cells = +2 points
---
### 2. Chain Combo
**When**: Click consecutive mines without clicking any safe cell in between.
**Tracked as**:
- `chainCurrent`: Current streak (resets when you click a safe cell)
- `chainBest`: Longest streak achieved
**Example**: Mine → Mine → Mine = chainBest becomes 3
---
### 3. Edge Mine (+1 point)
**When**: Click a mine on the board boundary (row 0, row 15, col 0, or col 15).
**Example**: Click a mine on the edge = +1 point
---
### 4. Endgame Mine (+3 points)
**When**: Click a mine when 10 or fewer mines remain on the board.
**Calculation**: `51 total mines - (red_points + blue_points) = mines_remaining`
**Example**: When 8 mines remain, click one = +3 points
---
### 5. Safe Cell Bonus (+0.5 points per cell)
**When**: Click a safe cell and reveal 2 or more cells.
**Important**: Minimum 2 cells required. Single cell reveals = 0 points.
**Examples**:
- Reveal 1 safe cell = 0 points
- Reveal 2 safe cells = 1.0 points
- Reveal 11 safe cells = 5.5 points
---
### 6. Biggest Reveal (Tracking stat)
**What**: Tracks the largest number of safe cells revealed in one click.
**Example**: Largest reveal in a game = 15 cells shown in stats
---
## Bonus Statistics Display
### Dialog Shows
- Both players' bonus statistics side-by-side
- Each stat with label, description, and value
- Total bonus points per player
### Player Name Rules
- `anon_*` usernames → displays as "Anonymous"
- Names longer than 10 chars → truncated to 7 chars + "..." (example: `VeryLongName``VeryLon...`)
---
## Tracked Statistics
```javascript
{
blindHits: 0, // Blind hit mines clicked
chainBest: 0, // Longest mine streak
chainCurrent: 0, // Current active streak
lastMineHits: 0, // Endgame mines clicked
edgeMines: 0, // Edge mine clicks
biggestReveal: 0 // Largest safe cell reveal
}
```
---
## Database
- `red_bonus_points` (FLOAT) — Red player's total bonus points
- `blue_bonus_points` (FLOAT) — Blue player's total bonus points
- `red_bonus_stats` (JSON) — Red player's tracked stats
- `blue_bonus_stats` (JSON) — Blue player's tracked stats
---
## 📋 Documentation Maintenance
**IMPORTANT**: This documentation must be updated whenever:
- New bonus types are added
- Point values change
- Bonus calculation logic changes
- New stats are tracked
- Bonus display rules change
**Update these files**:
1. This file (`BONUS_POINTS_SYSTEM.md`) — Update descriptions and examples
2. Code comments in `/src/Util/TopicManager.php` — Explain calculation logic
3. `/docs/README.md` — Update Quick Reference table if values change
**Keep documentation**:
- ✅ Simple and clear
- ✅ With real code examples
- ✅ Synchronized with actual code behavior
- ✅ Updated before/after feature changes
---
## Implementation Files
- Backend: `/src/Util/TopicManager.php` — Bonus calculation logic
- Frontend: `/assets/js/mine-seeker/contexts/GameProvider.jsx` — State sync
- UI: `/assets/js/mine-seeker/components/BonusStatsDialog.jsx` — Display dialog
- Constants: `/assets/js/mine-seeker/utils/constants.jsx` — Labels and defaults
---
## Battle Report Display Components
**IMPORTANT**: The Bonus Statistics display appears in **two places** that must be kept in sync:
### 1. Public Battle Share Page
**File**: `/templates/Game/battle_share.html.twig`
- Displays via `bshare-bonus` CSS classes
- Backend data passed from `ProfileController::battleShare()`
- Shows bonus stats as HTML table format
### 2. Profile Dialog (BattleDialog component)
**File**: `/assets/js/components/BattleDialog.jsx`
- React component using Material-UI Dialog
- Displays inside the match details modal on profile page
- Shows bonus stats using `StatRow` components in side-by-side boxes
### Synchronization Requirements
When making changes to the bonus statistics display, update **BOTH** files:
1. **Update logic/data** → Edit both template and component
2. **Change stat names** → Update both BONUS_LABELS and both display files
3. **Modify formatting** → Keep visual consistency between both displays
4. **Add new stats** → Add to both the `.twig` template AND the `.jsx` component
**Checklist for changes**:
- [ ] Update `/src/Util/TopicManager.php` if bonus calculation changes
- [ ] Update `/templates/Game/battle_share.html.twig` for public display
- [ ] Update `/assets/js/components/BattleDialog.jsx` for profile dialog
- [ ] Update `/assets/js/mine-seeker/utils/constants.jsx` if adding new stats
- [ ] Test both displays show identical data
---
## Quick Checklist for Changes
- [ ] Code changes implemented
- [ ] This documentation updated
- [ ] `/docs/README.md` Quick Reference table updated
- [ ] Code comments added/updated
- [ ] Examples updated to match new behavior
- [ ] Both battle report displays tested

View File

@@ -21,31 +21,31 @@
"@fontsource/rajdhani": "^5.2.7", "@fontsource/rajdhani": "^5.2.7",
"@fortawesome/fontawesome-free": "^7.2.0", "@fortawesome/fontawesome-free": "^7.2.0",
"@mui/material": "^9.0.0", "@mui/material": "^9.0.0",
"@mui/x-charts": "^9.0.1", "@mui/x-charts": "^9.0.2",
"@tanstack/react-query": "^5.0.0", "@tanstack/react-query": "^5.99.0",
"howler": "^2.1.2", "howler": "^2.2.4",
"lodash": "^4.18.1", "lodash": "^4.18.1",
"prop-types": "^15.7.2", "prop-types": "^15.8.1",
"react": "^19.0.0", "react": "^19.2.5",
"react-dom": "^19.0.0" "react-dom": "^19.2.5"
}, },
"devDependencies": { "devDependencies": {
"@eslint/eslintrc": "^3.3.5", "@eslint/eslintrc": "^3.3.5",
"@eslint/js": "^9.0.0", "@eslint/js": "^9.39.4",
"@stylistic/eslint-plugin": "^4.0.0", "@stylistic/eslint-plugin": "^4.4.1",
"@vitejs/plugin-react": "^6.0.1", "@vitejs/plugin-react": "^6.0.1",
"eslint": "^9.0.0", "eslint": "^9.39.4",
"eslint-plugin-react": "^7.0.0", "eslint-plugin-react": "^7.37.5",
"eslint-plugin-react-hooks": "^5.0.0", "eslint-plugin-react-hooks": "^5.2.0",
"globals": "^15.0.0", "globals": "^15.15.0",
"sass": "^1.77.0", "sass": "^1.99.0",
"vite": "^8.0.8", "vite": "^8.0.8",
"vite-plugin-symfony": "^8.2.4" "vite-plugin-symfony": "^8.2.4"
}, },
"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

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xml:space="preserve" width="576.095" height="593.844" viewBox="0 0 432.071 445.383"><g style="fill-rule:nonzero;clip-rule:nonzero;fill:none;stroke:#fff;stroke-width:12.4651;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4"><path d="M323.205 324.227c2.833-23.601 1.984-27.062 19.563-23.239l4.463.392c13.517.615 31.199-2.174 41.587-7 22.362-10.376 35.622-27.7 13.572-23.148-50.297 10.376-53.755-6.655-53.755-6.655 53.111-78.803 75.313-178.836 56.149-203.322-52.27-66.789-142.748-35.206-144.262-34.386l-.482.089c-9.938-2.062-21.06-3.294-33.554-3.496-22.761-.374-40.032 5.967-53.133 15.904 0 0-161.408-66.498-153.899 83.628 1.597 31.936 45.777 241.655 98.47 178.31 19.259-23.163 37.871-42.748 37.871-42.748 9.242 6.14 20.307 9.272 31.912 8.147l.897-.765c-.281 2.876-.157 5.689.359 9.019-13.572 15.167-9.584 17.83-36.723 23.416-27.457 5.659-11.326 15.734-.797 18.367 12.768 3.193 42.305 7.716 62.268-20.224l-.795 3.188c5.325 4.26 4.965 30.619 5.72 49.452.756 18.834 2.017 36.409 5.856 46.771 3.839 10.36 8.369 37.05 44.036 29.406 29.809-6.388 52.6-15.582 54.677-101.107" style="fill:#000;stroke:#000;stroke-width:37.3953;stroke-linecap:butt;stroke-linejoin:miter"/><path stroke="none" d="M402.395 271.23c-50.302 10.376-53.76-6.655-53.76-6.655 53.111-78.808 75.313-178.843 56.153-203.326-52.27-66.785-142.752-35.2-144.262-34.38l-.486.087c-9.938-2.063-21.06-3.292-33.56-3.496-22.761-.373-40.026 5.967-53.127 15.902 0 0-161.411-66.495-153.904 83.63 1.597 31.938 45.776 241.657 98.471 178.312 19.26-23.163 37.869-42.748 37.869-42.748 9.243 6.14 20.308 9.272 31.908 8.147l.901-.765c-.28 2.876-.152 5.689.361 9.019-13.575 15.167-9.586 17.83-36.723 23.416-27.459 5.659-11.328 15.734-.796 18.367 12.768 3.193 42.307 7.716 62.266-20.224l-.796 3.188c5.319 4.26 9.054 27.711 8.428 48.969s-1.044 35.854 3.147 47.254 8.368 37.05 44.042 29.406c29.809-6.388 45.256-22.942 47.405-50.555 1.525-19.631 4.976-16.729 5.194-34.28l2.768-8.309c3.192-26.611.507-35.196 18.872-31.203l4.463.392c13.517.615 31.208-2.174 41.591-7 22.358-10.376 35.618-27.7 13.573-23.148z" style=""/><path d="M215.866 286.484c-1.385 49.516.348 99.377 5.193 111.495 4.848 12.118 15.223 35.688 50.9 28.045 29.806-6.39 40.651-18.756 45.357-46.051 3.466-20.082 10.148-75.854 11.005-87.281M173.104 38.256S11.583-27.76 19.092 122.365c1.597 31.938 45.779 241.664 98.473 178.316 19.256-23.166 36.671-41.335 36.671-41.335M260.349 26.207c-5.591 1.753 89.848-34.889 144.087 34.417 19.159 24.484-3.043 124.519-56.153 203.329"/><path d="M348.282 263.953s3.461 17.036 53.764 6.653c22.04-4.552 8.776 12.774-13.577 23.155-18.345 8.514-59.474 10.696-60.146-1.069-1.729-30.355 21.647-21.133 19.96-28.739-1.525-6.85-11.979-13.573-18.894-30.338-6.037-14.633-82.796-126.849 21.287-110.183 3.813-.789-27.146-99.002-124.553-100.599-97.385-1.597-94.19 119.762-94.19 119.762" style="stroke-linejoin:bevel"/><path d="M188.604 274.334c-13.577 15.166-9.584 17.829-36.723 23.417-27.459 5.66-11.326 15.733-.797 18.365 12.768 3.195 42.307 7.718 62.266-20.229 6.078-8.509-.036-22.086-8.385-25.547-4.034-1.671-9.428-3.765-16.361 3.994"/><path d="M187.715 274.069c-1.368-8.917 2.93-19.528 7.536-31.942 6.922-18.626 22.893-37.255 10.117-96.339-9.523-44.029-73.396-9.163-73.436-3.193-.039 5.968 2.889 30.26-1.067 58.548-5.162 36.913 23.488 68.132 56.479 64.938"/><path d="M172.517 141.7c-.288 2.039 3.733 7.48 8.976 8.207 5.234.73 9.714-3.522 9.998-5.559.284-2.039-3.732-4.285-8.977-5.015-5.237-.731-9.719.333-9.996 2.367z" style="fill:#fff;stroke-width:4.155;stroke-linecap:butt;stroke-linejoin:miter"/><path d="M331.941 137.543c.284 2.039-3.732 7.48-8.976 8.207-5.238.73-9.718-3.522-10.005-5.559-.277-2.039 3.74-4.285 8.979-5.015s9.718.333 10.002 2.368z" style="fill:#fff;stroke-width:2.0775;stroke-linecap:butt;stroke-linejoin:miter"/><path d="M350.676 123.432c.863 15.994-3.445 26.888-3.988 43.914-.804 24.748 11.799 53.074-7.191 81.435"/></g></svg>

After

Width:  |  Height:  |  Size: 3.8 KiB

View File

@@ -12,16 +12,15 @@ namespace App\Controller;
use App\Entity\ContactMessage; use App\Entity\ContactMessage;
use App\Form\ContactFormType; use App\Form\ContactFormType;
use App\Service\Email\SendContactMailService;
use App\Service\MercureJwtService;
use App\Service\ResolveUserNamesService;
use Doctrine\ORM\EntityManagerInterface; use Doctrine\ORM\EntityManagerInterface;
use Psr\Log\LoggerInterface;
use RuntimeException;
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;
use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Attribute\AsController; use Symfony\Component\HttpKernel\Attribute\AsController;
use Symfony\Component\Mailer\Exception\TransportExceptionInterface;
use Symfony\Component\Mailer\MailerInterface; use Symfony\Component\Mailer\MailerInterface;
use Symfony\Component\Routing\Attribute\Route; use Symfony\Component\Routing\Attribute\Route;
@@ -43,11 +42,9 @@ class GameController extends AbstractController
private readonly string $env, private readonly string $env,
#[Autowire(env: 'MERCURE_PUBLIC_URL')] #[Autowire(env: 'MERCURE_PUBLIC_URL')]
private readonly string $mercurePublicUrl, private readonly string $mercurePublicUrl,
#[Autowire(env: 'MERCURE_SUBSCRIBER_JWT')] private readonly MercureJwtService $mercureJwtService,
private readonly string $mercureSubscriberJwt, private readonly ResolveUserNamesService $opponentNameService,
#[Autowire(env: 'APP_CONTACT_MAIL_ADDRESS')] private readonly SendContactMailService $contactMailService,
private readonly string $appContactMailAddress,
private readonly LoggerInterface $logger,
) { ) {
} }
@@ -59,12 +56,15 @@ class GameController extends AbstractController
#[Route('/play', name: 'MineSeekerBundle_gamePlay')] #[Route('/play', name: 'MineSeekerBundle_gamePlay')]
#[Route('/play/{gameAssoc}', name: 'MineSeekerBundle_gamePlayWId')] #[Route('/play/{gameAssoc}', name: 'MineSeekerBundle_gamePlayWId')]
public function play(): Response public function play(?string $gameAssoc = null): Response
{ {
return $this->render('Game/play.html.twig', [ return $this->render('Game/play.html.twig', [
'env' => $this->env, 'env' => $this->env,
'mercure_hub_url' => $this->mercurePublicUrl, 'mercure_hub_url' => $this->mercurePublicUrl,
'mercure_subscriber_jwt' => $this->mercureSubscriberJwt, 'mercure_subscriber_jwt' => $this->mercureJwtService->mintSubscriberToken(
$gameAssoc ?? '', $this->opponentNameService->resolveUserName(),
),
'opponent_name' => $this->opponentNameService->opponentName($gameAssoc),
]); ]);
} }
@@ -92,9 +92,11 @@ class GameController extends AbstractController
if ($form->isSubmitted() && $form->isValid()) { if ($form->isSubmitted() && $form->isValid()) {
$contactMessage->setIpAddress($request->getClientIp()); $contactMessage->setIpAddress($request->getClientIp());
$em->persist($contactMessage); $em->persist($contactMessage);
$em->flush(); $em->flush();
$this->sendMail($mailer, $contactMessage);
$this->contactMailService->send($contactMessage);
$this->addFlash('contact_success', 'Thank you for your message! We will get back to you soon.'); $this->addFlash('contact_success', 'Thank you for your message! We will get back to you soon.');
return $this->redirectToRoute('MineSeekerBundle_contact'); return $this->redirectToRoute('MineSeekerBundle_contact');
@@ -111,30 +113,9 @@ class GameController extends AbstractController
return $this->render('Official/landing.html.twig'); return $this->render('Official/landing.html.twig');
} }
public function sendMail(MailerInterface $mailer, ContactMessage $contactMessage): void #[Route('/rules', name: 'MineSeekerBundle_rules')]
public function rules(): Response
{ {
try { return $this->render('Official/rules.html.twig');
$mailer->send(
new TemplatedEmail()
->from('noreply@mineseeker.hu')
->to($this->appContactMailAddress)
->replyTo($contactMessage->getEmail())
->subject('New Contact Message from ' . $contactMessage->getName())
->htmlTemplate('emails/contact_notification.html.twig')
->context(['message' => $contactMessage])
);
} catch (\Exception $e) {
$this->logger->error('Failed to send contact notification email: ' . $e->getMessage(), [
'exception' => $e,
'message' => $contactMessage,
]);
throw new RuntimeException('Failed to send contact notification email: ' . $e->getMessage());
} catch (TransportExceptionInterface $e) {
$this->logger->error('Failed to send contact notification email: ' . $e->getMessage(), [
'exception' => $e,
'message' => $contactMessage,
]);
throw new RuntimeException('Failed to send contact notification email: ' . $e->getMessage());
}
} }
} }

View File

@@ -12,6 +12,7 @@ namespace App\Controller;
use App\Entity\PlayedGame; use App\Entity\PlayedGame;
use App\Repository\PlayedGameRepository; use App\Repository\PlayedGameRepository;
use App\Service\ResolveUserNamesService;
use App\Util\RpcManager; use App\Util\RpcManager;
use App\Util\TopicManager; use App\Util\TopicManager;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
@@ -41,6 +42,7 @@ class MercureController extends AbstractController
public function __construct( public function __construct(
private readonly TopicManager $topicManager, private readonly TopicManager $topicManager,
private readonly RpcManager $rpcManager, private readonly RpcManager $rpcManager,
private readonly ResolveUserNamesService $userNamesService,
) { ) {
} }
@@ -56,15 +58,18 @@ class MercureController extends AbstractController
#[Route('/api/game/connect/{gameAssoc}', name: 'MineSeekerBundle_api_game_connect', methods: ['GET'])] #[Route('/api/game/connect/{gameAssoc}', name: 'MineSeekerBundle_api_game_connect', methods: ['GET'])]
public function connect(string $gameAssoc): Response public function connect(string $gameAssoc): Response
{ {
try {
$payload = $this->rpcManager->getConnectInformation($gameAssoc); $payload = $this->rpcManager->getConnectInformation($gameAssoc);
return new Response($payload, Response::HTTP_OK, ['Content-Type' => 'text/plain']); return new Response($payload, Response::HTTP_OK, ['Content-Type' => 'text/plain']);
} catch (\Exception $e) {
return new Response('', Response::HTTP_INTERNAL_SERVER_ERROR);
}
} }
#[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): JsonResponse
{ {
$this->topicManager->subscribe($gameAssoc, $this->resolveUserName($request), $this->getUser()); $this->topicManager->subscribe($gameAssoc, $this->userNamesService->resolveUserName());
return $this->json(['success' => true]); return $this->json(['success' => true]);
} }
@@ -72,15 +77,15 @@ class MercureController extends AbstractController
#[Route('/api/game/step/{gameAssoc}', name: 'MineSeekerBundle_api_game_step', methods: ['POST'])] #[Route('/api/game/step/{gameAssoc}', name: 'MineSeekerBundle_api_game_step', methods: ['POST'])]
public function step(string $gameAssoc, Request $request): JsonResponse public function step(string $gameAssoc, Request $request): JsonResponse
{ {
$result = $this->topicManager->publish($gameAssoc, $this->resolveUserName($request), $request->toArray()); $result = $this->topicManager->publish($gameAssoc, $this->userNamesService->resolveUserName(), $request->toArray());
return $this->json($result); return $this->json($result);
} }
#[Route('/api/game/leave/{gameAssoc}', name: 'MineSeekerBundle_api_game_leave', methods: ['POST'])] #[Route('/api/game/leave/{gameAssoc}', name: 'MineSeekerBundle_api_game_leave', methods: ['POST'])]
public function leave(string $gameAssoc, Request $request): JsonResponse public function leave(string $gameAssoc): JsonResponse
{ {
$this->topicManager->unSubscribe($gameAssoc, $this->resolveUserName($request)); $this->topicManager->unSubscribe($gameAssoc, $this->userNamesService->resolveUserName());
return $this->json(['success' => true]); return $this->json(['success' => true]);
} }
@@ -95,7 +100,11 @@ class MercureController extends AbstractController
return $this->json(['success' => true]); return $this->json(['success' => true]);
} }
#[Route('/api/game/challenge/respond/{challengerGameAssoc}', name: 'MineSeekerBundle_api_game_challenge_respond', methods: ['POST'])] #[Route(
'/api/game/challenge/respond/{challengerGameAssoc}',
name: 'MineSeekerBundle_api_game_challenge_respond',
methods: ['POST'],
)]
public function challengeRespond(string $challengerGameAssoc, Request $request): JsonResponse public function challengeRespond(string $challengerGameAssoc, Request $request): JsonResponse
{ {
$data = $request->toArray(); $data = $request->toArray();
@@ -106,6 +115,19 @@ class MercureController extends AbstractController
return $this->json(['success' => true]); return $this->json(['success' => true]);
} }
#[Route('/api/game/heartbeat/{gameAssoc}', name: 'MineSeekerBundle_api_game_heartbeat', methods: ['POST'])]
public function heartbeat(string $gameAssoc, Request $request): JsonResponse
{
$data = $request->toArray();
$color = $data['color'] ?? '';
if ('red' !== $color && 'blue' !== $color) {
return $this->json(['success' => false], Response::HTTP_BAD_REQUEST);
}
$this->topicManager->publishHeartbeat($gameAssoc, $color);
return $this->json(['success' => true]);
}
#[Route('/api/game/waiting', name: 'MineSeekerBundle_api_game_waiting', methods: ['GET'])] #[Route('/api/game/waiting', name: 'MineSeekerBundle_api_game_waiting', methods: ['GET'])]
public function waiting(PlayedGameRepository $repo): JsonResponse public function waiting(PlayedGameRepository $repo): JsonResponse
{ {
@@ -128,20 +150,4 @@ class MercureController extends AbstractController
return $this->json($result); return $this->json($result);
} }
private function resolveUserName(Request $request): string
{
$user = $this->getUser();
if (null !== $user) {
return $user->getUserIdentifier();
}
$sessionId = $request->getSession()->getId();
if (empty($sessionId)) {
$sessionId = bin2hex(random_bytes(16));
}
return 'anon_' . $sessionId;
}
} }

View File

@@ -112,16 +112,22 @@ class ProfileController extends AbstractController
$months = array_column(array_values($monthlyData), 'label'); $months = array_column(array_values($monthlyData), 'label');
$bonus = $this->repo->findBonusStatsForUser($user);
return $this->render('Security/profile.html.twig', [ return $this->render('Security/profile.html.twig', [
'stats' => [ 'stats' => [
'total' => $total, 'total' => $total,
'wins' => $wins, 'wins' => $wins,
'losses' => $losses, 'losses' => $losses,
'draws' => $draws, 'draws' => $draws,
'bombs' => $this->repo->countBombsForUser($user), 'minesHit' => $this->repo->findTotalMinesForUser($user),
'winRate' => $total > 0 ? (int)round($wins / $total * 100) : 0, 'winRate' => $total > 0 ? (int)round($wins / $total * 100) : 0,
'avgScore' => $this->repo->findAvgScoreForUser($user), 'avgScore' => $this->repo->findAvgScoreForUser($user),
'bestScore' => $this->repo->findBestScoreForUser($user), 'bonusPoints' => $bonus['totalBonusPoints'],
'avgBonus' => $bonus['avgBonusPoints'],
'bestChain' => $bonus['bestChain'],
'blindHits' => $bonus['totalBlindHits'],
'edgeMines' => $bonus['totalEdgeMines'],
], ],
'recent' => ($recent = $this->repo->findRecentFinishedForUser($user)), 'recent' => ($recent = $this->repo->findRecentFinishedForUser($user)),
'gamesData' => array_map(function (PlayedGame $game) use ($userId, $cacheManager): array { 'gamesData' => array_map(function (PlayedGame $game) use ($userId, $cacheManager): array {
@@ -163,6 +169,10 @@ class ProfileController extends AbstractController
'result' => $result, 'result' => $result,
'myPoints' => $myPts, 'myPoints' => $myPts,
'oppPoints' => $oppPts, 'oppPoints' => $oppPts,
'redBonusPoints' => $game->getRedBonusPoints() ?? 0,
'blueBonusPoints' => $game->getBlueBonusPoints() ?? 0,
'redBonusStats' => $game->getRedBonusStats() ?? [],
'blueBonusStats' => $game->getBlueBonusStats() ?? [],
]; ];
}, $recent), }, $recent),
'chartData' => [ 'chartData' => [
@@ -173,10 +183,34 @@ class ProfileController extends AbstractController
'pieWins' => $wins, 'pieWins' => $wins,
'pieLosses' => $losses, 'pieLosses' => $losses,
'pieDraws' => $draws, '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->getRed()?->getId() === $userId;
$labels[] = '#' . ($i + 1);
$mines[] = (int) ($isRed ? $game->getRedPoints() : $game->getBluePoints());
$bonus[] = (float) ($isRed ? $game->getRedBonusPoints() : $game->getBlueBonusPoints()) ?: 0;
}
return ['labels' => $labels, 'mines' => $mines, 'bonus' => $bonus];
}
#[Route( #[Route(
'/battle/{uuid}', '/battle/{uuid}',
name: 'MineSeekerBundle_battle_share', name: 'MineSeekerBundle_battle_share',
@@ -197,6 +231,10 @@ class ProfileController extends AbstractController
$resign = $game->getResign(); $resign = $game->getResign();
$redAvatar = $game->getRed()?->getAvatarPath(); $redAvatar = $game->getRed()?->getAvatarPath();
$blueAvatar = $game->getBlue()?->getAvatarPath(); $blueAvatar = $game->getBlue()?->getAvatarPath();
$redBonusPoints = $game->getRedBonusPoints() ?? 0;
$blueBonusPoints = $game->getBlueBonusPoints() ?? 0;
$redBonusStats = $game->getRedBonusStats() ?? [];
$blueBonusStats = $game->getBlueBonusStats() ?? [];
if ($resign === 'red') { if ($resign === 'red') {
$summary = "$redName resigned — $blueName wins"; $summary = "$redName resigned — $blueName wins";
@@ -223,6 +261,10 @@ class ProfileController extends AbstractController
'resign' => $resign, 'resign' => $resign,
'redAvatar' => $redAvatar, 'redAvatar' => $redAvatar,
'blueAvatar' => $blueAvatar, 'blueAvatar' => $blueAvatar,
'redBonusPoints' => $redBonusPoints,
'blueBonusPoints' => $blueBonusPoints,
'redBonusStats' => $redBonusStats,
'blueBonusStats' => $blueBonusStats,
'ogTitle' => "MineSeeker · $summary", 'ogTitle' => "MineSeeker · $summary",
'ogDesc' => "Watch the battle replay: $summary — played on MineSeeker, the multiplayer minesweeper.", 'ogDesc' => "Watch the battle replay: $summary — played on MineSeeker, the multiplayer minesweeper.",
]); ]);

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

@@ -62,6 +62,18 @@ class PlayedGame
#[Column(length: 7, nullable: true)] #[Column(length: 7, nullable: true)]
private ?string $resign = null; private ?string $resign = null;
#[Column(nullable: true)]
private ?float $redBonusPoints = null;
#[Column(nullable: true)]
private ?float $blueBonusPoints = null;
#[Column(nullable: true)]
private ?array $redBonusStats = null;
#[Column(nullable: true)]
private ?array $blueBonusStats = null;
#[Column(type: Types::DATETIME_MUTABLE, nullable: true)] #[Column(type: Types::DATETIME_MUTABLE, nullable: true)]
private ?DateTime $created = null; private ?DateTime $created = null;
@@ -222,6 +234,46 @@ class PlayedGame
$this->resign = $resign; $this->resign = $resign;
} }
public function getRedBonusPoints(): ?float
{
return $this->redBonusPoints;
}
public function setRedBonusPoints(?float $redBonusPoints): void
{
$this->redBonusPoints = $redBonusPoints;
}
public function getBlueBonusPoints(): ?float
{
return $this->blueBonusPoints;
}
public function setBlueBonusPoints(?float $blueBonusPoints): void
{
$this->blueBonusPoints = $blueBonusPoints;
}
public function getRedBonusStats(): ?array
{
return $this->redBonusStats;
}
public function setRedBonusStats(?array $redBonusStats): void
{
$this->redBonusStats = $redBonusStats;
}
public function getBlueBonusStats(): ?array
{
return $this->blueBonusStats;
}
public function setBlueBonusStats(?array $blueBonusStats): void
{
$this->blueBonusStats = $blueBonusStats;
}
public function getCreated(): ?DateTime public function getCreated(): ?DateTime
{ {
return $this->created; return $this->created;
@@ -247,3 +299,5 @@ class PlayedGame
return $this->steps; return $this->steps;
} }
} }

View File

@@ -10,8 +10,6 @@
namespace App\Interfaces; namespace App\Interfaces;
use Symfony\Component\Security\Core\User\UserInterface;
/** /**
* Interface TopicManagerInterface * Interface TopicManagerInterface
* *
@@ -24,7 +22,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): void;
public function unSubscribe(string $gameAssoc, string $userName): void; public function unSubscribe(string $gameAssoc, string $userName): void;

View File

@@ -0,0 +1,48 @@
<?php declare(strict_types=1);
/**
* This file is part of the SplendidBear Websites' projects.
*
* Copyright (c) 2026 @ www.splendidbear.org
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Migrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Class Version20260418104430
*
* @package App\Migrations
* @author Lang <https://www.splendidbear.org>
* @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. 18.
*/
final class Version20260418104430 extends AbstractMigration
{
public function getDescription(): string
{
return 'Add bonus stats to the playing experience';
}
public function up(Schema $schema): void
{
$this->addSql('ALTER TABLE played_game ADD red_bonus_points DOUBLE PRECISION DEFAULT NULL');
$this->addSql('ALTER TABLE played_game ADD blue_bonus_points DOUBLE PRECISION DEFAULT NULL');
$this->addSql('ALTER TABLE played_game ADD red_bonus_stats JSON DEFAULT NULL');
$this->addSql('ALTER TABLE played_game ADD blue_bonus_stats JSON DEFAULT NULL');
}
public function down(Schema $schema): void
{
$this->addSql('ALTER TABLE played_game DROP red_bonus_points');
$this->addSql('ALTER TABLE played_game DROP blue_bonus_points');
$this->addSql('ALTER TABLE played_game DROP red_bonus_stats');
$this->addSql('ALTER TABLE played_game DROP blue_bonus_stats');
}
}

View File

@@ -260,6 +260,21 @@ class PlayedGameRepository extends ServiceEntityRepository
} }
} }
public function findTotalMinesForUser(User $user): int
{
$conn = $this->getEntityManager()->getConnection();
$result = $conn->executeQuery(
'SELECT
COALESCE(SUM(CASE WHEN g.red_id = :uid THEN g.red_points ELSE g.blue_points END), 0) AS total_pts
FROM played_game g
WHERE (g.red_id = :uid OR g.blue_id = :uid)',
['uid' => $user->getId()],
)->fetchAssociative();
return (int) ($result['total_pts'] ?? 0);
}
public function findAvgScoreForUser(User $user): int public function findAvgScoreForUser(User $user): int
{ {
$conn = $this->getEntityManager()->getConnection(); $conn = $this->getEntityManager()->getConnection();
@@ -284,6 +299,49 @@ class PlayedGameRepository extends ServiceEntityRepository
return (int) round((float) $result['total_pts'] / (int) $result['total_games']); return (int) round((float) $result['total_pts'] / (int) $result['total_games']);
} }
/**
* Aggregates bonus points and bonus stats across all finished games for a user.
*
* @return array{totalBonusPoints:float,avgBonusPoints:float,bestChain:int,totalBlindHits:int,totalEdgeMines:int}
*/
public function findBonusStatsForUser(User $user): array
{
$userId = $user->getId();
$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->getRed()?->getId() === $userId;
$totalBonusPoints += (float) (($isRed ? $game->getRedBonusPoints() : $game->getBlueBonusPoints()) ?? 0.0);
$stats = ($isRed ? $game->getRedBonusStats() : $game->getBlueBonusStats()) ?? [];
$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 public function findBestScoreForUser(User $user): int
{ {
try { try {

View File

@@ -10,9 +10,12 @@
namespace App\Repository; namespace App\Repository;
use App\Entity\PlayedGame;
use App\Entity\Step; use App\Entity\Step;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository; use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\ORM\NonUniqueResultException;
use Doctrine\Persistence\ManagerRegistry; use Doctrine\Persistence\ManagerRegistry;
use RuntimeException;
/** /**
* Class StepRepository * Class StepRepository
@@ -35,4 +38,47 @@ class StepRepository extends ServiceEntityRepository
{ {
parent::__construct($registry, Step::class); parent::__construct($registry, Step::class);
} }
public function findMostRecent(PlayedGame $playedGame): ?Step
{
try {
return $this->createQueryBuilder('s')
->andWhere('s.playedGame = :game')
->setParameter('game', $playedGame)
->orderBy('s.created', 'DESC')
->setMaxResults(1)
->getQuery()
->getOneOrNullResult();
} catch (NonUniqueResultException $e) {
throw new RuntimeException(
sprintf(
'Expected at most one result for the most recent step of game ID %d, but got multiple.',
$playedGame->getId(),
),
0,
$e,
);
}
}
public function findMostRecentForPlayer(PlayedGame $playedGame, string $player): ?Step
{
try {
return $this->createQueryBuilder('s')
->andWhere('s.playedGame = :game')
->andWhere('s.player = :player')
->setParameter('game', $playedGame)
->setParameter('player', $player)
->orderBy('s.created', 'DESC')
->setMaxResults(1)
->getQuery()
->getOneOrNullResult();
} catch (NonUniqueResultException $e) {
throw new RuntimeException(
'Expected at most one result for the most recent step of player "%s" in game ID %d, but got multiple.',
0,
$e,
);
}
}
} }

View File

@@ -56,8 +56,9 @@ class BattleCardGenerator
{ {
$path = $this->cachePath((int)$game->getId()); $path = $this->cachePath((int)$game->getId());
// Always regenerate to ensure bonus points are included
if (is_file($path)) { if (is_file($path)) {
return $path; unlink($path);
} }
if (!is_dir($this->cacheDir)) { if (!is_dir($this->cacheDir)) {
@@ -154,6 +155,12 @@ class BattleCardGenerator
$scoreText = $redPts !== null && $bluePts !== null ? $redPts . ' : ' . $bluePts : 'VS'; $scoreText = $redPts !== null && $bluePts !== null ? $redPts . ' : ' . $bluePts : 'VS';
$this->centeredText($im, $scoreText, 72, self::WIDTH / 2, 390, $white); $this->centeredText($im, $scoreText, 72, self::WIDTH / 2, 390, $white);
/** Bonus points below score*/
$redBonusPoints = $game->getRedBonusPoints() ?? 0;
$blueBonusPoints = $game->getBlueBonusPoints() ?? 0;
$bonusText = number_format((float)$redBonusPoints, 1, '.', '') . ' * : * ' . number_format((float)$blueBonusPoints, 1, '.', '');
$this->centeredText($im, $bonusText, 24, self::WIDTH / 2, 425, $gold);
if ($winner === 'red') { if ($winner === 'red') {
$resultText = $redName . ' wins'; $resultText = $redName . ' wins';
$resultColor = $gold; $resultColor = $gold;
@@ -169,11 +176,11 @@ class BattleCardGenerator
} }
if ($resultText !== '') { if ($resultText !== '') {
$this->centeredText($im, $resultText, 30, self::WIDTH / 2, 460, $resultColor); $this->centeredText($im, $resultText, 30, self::WIDTH / 2, 475, $resultColor);
} }
if ($resign) { if ($resign) {
$this->centeredText($im, ucfirst($resign) . ' resigned', 18, self::WIDTH / 2, 498, $muted); $this->centeredText($im, ucfirst($resign) . ' resigned', 18, self::WIDTH / 2, 508, $muted);
} }
$this->centeredText($im, 'mineseeker.hu', 16, self::WIDTH / 2, self::HEIGHT - 20, $muted); $this->centeredText($im, 'mineseeker.hu', 16, self::WIDTH / 2, self::HEIGHT - 20, $muted);

View File

@@ -0,0 +1,67 @@
<?php declare(strict_types=1);
/**
* This file is part of the SplendidBear Websites' projects.
*
* Copyright (c) 2026 @ www.splendidbear.org
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Service\Email;
use App\Entity\ContactMessage;
use Psr\Log\LoggerInterface;
use RuntimeException;
use Symfony\Bridge\Twig\Mime\TemplatedEmail;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\Mailer\Exception\TransportExceptionInterface;
use Symfony\Component\Mailer\MailerInterface;
/**
* Class SendContactMailService
*
* @package App\Service\Email
* @author Lang <https://www.splendidbear.org>
* @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. 19.
*/
readonly final class SendContactMailService
{
public function __construct(
#[Autowire(env: 'APP_CONTACT_MAIL_ADDRESS')]
private string $appContactMailAddress,
private LoggerInterface $logger,
private MailerInterface $mailer,
) {
}
public function send(ContactMessage $contactMessage): void
{
try {
$this->mailer->send(
new TemplatedEmail()
->from('noreply@mineseeker.hu')
->to($this->appContactMailAddress)
->replyTo($contactMessage->getEmail())
->subject('New Contact Message from ' . $contactMessage->getName())
->htmlTemplate('emails/contact_notification.html.twig')
->context(['message' => $contactMessage])
);
} catch (\Exception $e) {
$this->logger->error('Failed to send contact notification email: ' . $e->getMessage(), [
'exception' => $e,
'message' => $contactMessage,
]);
throw new RuntimeException('Failed to send contact notification email: ' . $e->getMessage());
} catch (TransportExceptionInterface $e) {
$this->logger->error('Failed to send contact notification email: ' . $e->getMessage(), [
'exception' => $e,
'message' => $contactMessage,
]);
throw new RuntimeException('Failed to send contact notification email: ' . $e->getMessage());
}
}
}

View File

@@ -0,0 +1,53 @@
<?php declare(strict_types=1);
/**
* This file is part of the SplendidBear Websites' projects.
*
* Copyright (c) 2026 @ www.splendidbear.org
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Service;
use Firebase\JWT\JWT;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
/**
* Class MercureJwtService
*
* Mints Mercure subscriber JWTs carrying an identifying payload so the hub's
* /subscriptions endpoint can report which known player is connected.
*
* @package App\Service
* @author Lang <https://www.splendidbear.org>
* @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. 19.
*/
final readonly class MercureJwtService
{
public function __construct(
#[Autowire(env: 'MERCURE_JWT_SECRET')]
private string $secret,
) {
}
public function mintSubscriberToken(string $gameAssoc, string $userName): string
{
return JWT::encode(
[
'mercure' => [
'subscribe' => ['*'],
'payload' => [
'username' => $userName,
'gameAssoc' => $gameAssoc,
],
],
],
$this->secret,
'HS256'
);
}
}

View File

@@ -0,0 +1,91 @@
<?php declare(strict_types=1);
/**
* This file is part of the SplendidBear Websites' projects.
*
* Copyright (c) 2026 @ www.splendidbear.org
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Service;
use App\Entity\PlayedGame;
use App\Repository\PlayedGameRepository;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\HttpFoundation\RequestStack;
/**
* Class ResolveUserNamesService
*
* This only works when a restored game is started
*
* @package App\Service
* @author Lang <https://www.splendidbear.org>
* @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. 19.
*/
readonly final class ResolveUserNamesService
{
public function __construct(
private RequestStack $requestStack,
private Security $security,
private PlayedGameRepository $playedGameRepository,
) {
}
public function opponentName(?string $gameAssoc = null): string
{
$userName = $this->resolveUserName();
if (null === $gameAssoc) {
return '';
}
if (null === $game = $this->playedGameRepository->findOneByGameAssoc($gameAssoc)) {
return '';
}
return $this->resolveOpponentName($game, $userName);
}
public function resolveUserName(): string
{
$user = $this->security->getUser();
if (null !== $user) {
return $user->getUserIdentifier();
}
$session = $this->requestStack->getCurrentRequest()->getSession();
if (!$session->isStarted()) {
$session->start();
}
return "anon_{$session->getId()}";
}
private function resolveOpponentName(PlayedGame $game, string $myUserName): string
{
$redName = $game->getRed()?->getUsername();
$blueName = $game->getBlue()?->getUsername();
$redAnonName = $game->getRedAnon()?->getUserName();
$blueAnonName = $game->getBlueAnon()?->getUserName();
$isRed = $myUserName === $redName || $myUserName === $redAnonName;
$isBlue = $myUserName === $blueName || $myUserName === $blueAnonName;
if ($isRed) {
return $blueName ?? ('' !== ($blueAnonName ?? '') ? 'Guest' : '');
}
if ($isBlue) {
return $redName ?? ('' !== ($redAnonName ?? '') ? 'Guest' : '');
}
return '';
}
}

View File

@@ -13,8 +13,10 @@ namespace App\Util;
use App\Entity\Grid; use App\Entity\Grid;
use App\Entity\GridRow; use App\Entity\GridRow;
use App\Entity\PlayedGame; use App\Entity\PlayedGame;
use App\Entity\Step;
use App\Interfaces\RpcManagerInterface; use App\Interfaces\RpcManagerInterface;
use App\Repository\PlayedGameRepository; use App\Repository\PlayedGameRepository;
use App\Repository\StepRepository;
use DateTime; use DateTime;
use Doctrine\ORM\EntityManagerInterface; use Doctrine\ORM\EntityManagerInterface;
use Exception; use Exception;
@@ -41,9 +43,10 @@ class RpcManager implements RpcManagerInterface
private const int MINES = 51; private const int MINES = 51;
public function __construct( public function __construct(
private readonly EntityManagerInterface $entityManager, private readonly EntityManagerInterface $em,
private readonly LoggerInterface $logger, private readonly LoggerInterface $logger,
private readonly PlayedGameRepository $playedGameRepository, private readonly PlayedGameRepository $playedGameRepository,
private readonly StepRepository $stepRepository,
) { ) {
} }
@@ -58,6 +61,15 @@ class RpcManager implements RpcManagerInterface
return base64_encode(json_encode([ return base64_encode(json_encode([
'users' => null, 'users' => null,
'revealedCells' => null, 'revealedCells' => null,
'lastStep' => ['red' => null, 'blue' => null],
'mostRecentStep' => null,
'redPoints' => 0,
'bluePoints' => 0,
'redBonusPoints' => 0,
'blueBonusPoints' => 0,
'redBonusStats' => [],
'blueBonusStats' => [],
'gameFinished' => false,
], JSON_THROW_ON_ERROR)); ], JSON_THROW_ON_ERROR));
} catch (JsonException $e) { } catch (JsonException $e) {
throw new RuntimeException($e->getMessage()); throw new RuntimeException($e->getMessage());
@@ -68,15 +80,42 @@ class RpcManager implements RpcManagerInterface
$revealedCells = $this->aggregateRevealedCells($playedGame); $revealedCells = $this->aggregateRevealedCells($playedGame);
try { try {
$redPoints = $playedGame->getRedPoints() ?? 0;
$bluePoints = $playedGame->getBluePoints() ?? 0;
$gameFinished = $redPoints > 25 || $bluePoints > 25;
return base64_encode(json_encode([ return base64_encode(json_encode([
'users' => $users, 'users' => $users,
'revealedCells' => $revealedCells, 'revealedCells' => $revealedCells,
'lastStep' => $this->getLastStepPerPlayer($playedGame),
'mostRecentStep' => $this->getMostRecentStep($playedGame),
'redPoints' => $redPoints,
'bluePoints' => $bluePoints,
'redBonusPoints' => $playedGame->getRedBonusPoints() ?? 0,
'blueBonusPoints' => $playedGame->getBlueBonusPoints() ?? 0,
'redBonusStats' => $playedGame->getRedBonusStats() ?? [],
'blueBonusStats' => $playedGame->getBlueBonusStats() ?? [],
'gameFinished' => $gameFinished,
], JSON_THROW_ON_ERROR)); ], JSON_THROW_ON_ERROR));
} catch (JsonException $e) { } catch (JsonException $e) {
throw new RuntimeException($e->getMessage()); throw new RuntimeException($e->getMessage());
} }
} }
/**
* Get the most recent step of the game (if any).
* Returns an array with player, row, col information or null if no steps exist.
*/
private function getMostRecentStep(PlayedGame $playedGame): ?array
{
try {
return $this->stepToArray($this->stepRepository->findMostRecent($playedGame));
} catch (Exception $e) {
$this->logger->error('Error getting most recent step: ' . $e->getMessage());
return null;
}
}
public function saveGrid(string $gameAssoc): bool public function saveGrid(string $gameAssoc): bool
{ {
$existingGame = $this->playedGameRepository->findOneByGameAssoc($gameAssoc); $existingGame = $this->playedGameRepository->findOneByGameAssoc($gameAssoc);
@@ -94,20 +133,20 @@ class RpcManager implements RpcManagerInterface
$gridRow = new GridRow(); $gridRow = new GridRow();
$gridRow->setGridCol($row); $gridRow->setGridCol($row);
$gridRow->setGrid($grid); $gridRow->setGrid($grid);
$this->entityManager->persist($gridRow); $this->em->persist($gridRow);
} }
$grid->setPlayedGame($playedGame); $grid->setPlayedGame($playedGame);
$this->entityManager->persist($grid); $this->em->persist($grid);
$playedGame->setGameAssoc($gameAssoc); $playedGame->setGameAssoc($gameAssoc);
$playedGame->setUuid(Uuid::fromString($gameAssoc)); $playedGame->setUuid(Uuid::fromString($gameAssoc));
$playedGame->setGrid($grid); $playedGame->setGrid($grid);
$playedGame->setCreated(new DateTime()); $playedGame->setCreated(new DateTime());
$playedGame->setUpdated(new DateTime()); $playedGame->setUpdated(new DateTime());
$this->entityManager->persist($playedGame); $this->em->persist($playedGame);
$this->entityManager->flush(); $this->em->flush();
} catch (Exception $e) { } catch (Exception $e) {
$this->logger->error($e->getMessage()); $this->logger->error($e->getMessage());
} }
@@ -128,6 +167,7 @@ class RpcManager implements RpcManagerInterface
/** /**
* Fisher-Yates shuffle * Fisher-Yates shuffle
*
* @see https://en.wikipedia.org/wiki/Fisher%E2%80%93Yates_shuffle * @see https://en.wikipedia.org/wiki/Fisher%E2%80%93Yates_shuffle
*/ */
for ($i = count($set) - 1; $i > 0; $i--) { for ($i = count($set) - 1; $i > 0; $i--) {
@@ -185,6 +225,37 @@ class RpcManager implements RpcManagerInterface
return $all; return $all;
} }
/**
* Get the last step for each player.
* Returns an array with 'red' and 'blue' keys, each containing row, col information or null if no steps exist for
* that player.
*/
private function getLastStepPerPlayer(PlayedGame $playedGame): array
{
try {
return [
'red' => $this->stepToArray($this->stepRepository->findMostRecentForPlayer($playedGame, 'red')),
'blue' => $this->stepToArray($this->stepRepository->findMostRecentForPlayer($playedGame, 'blue')),
];
} catch (Exception $e) {
$this->logger->error('Error getting last step per player: ' . $e->getMessage());
return ['red' => null, 'blue' => null];
}
}
private function stepToArray(?Step $step): ?array
{
if (null === $step) {
return null;
}
return [
'player' => $step->getPlayer(),
'row' => (int)$step->getRow(),
'col' => (int)$step->getCol(),
];
}
private function getUserCollection(PlayedGame $playedGame): array private function getUserCollection(PlayedGame $playedGame): array
{ {
return [ return [

View File

@@ -18,17 +18,17 @@ 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\RequestStack;
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;
/** /**
* Class TopicManager * Class TopicManager
@@ -43,18 +43,20 @@ use Symfony\Component\Security\Core\User\UserInterface;
readonly class TopicManager implements TopicManagerInterface readonly class TopicManager implements TopicManagerInterface
{ {
public function __construct( public function __construct(
private EntityManagerInterface $em,
private HubInterface $hub, private HubInterface $hub,
private EntityManagerInterface $entityManager,
private LoggerInterface $logger, private LoggerInterface $logger,
private CacheManager $cacheManager,
private PlayedGameRepository $playedGameRepository, private PlayedGameRepository $playedGameRepository,
private UserRepository $userRepository, private UserRepository $userRepository,
private CacheManager $cacheManager, private RequestStack $requestStack,
) { ) {
} }
public function subscribe(string $gameAssoc, string $userName, ?UserInterface $user): void public function subscribe(string $gameAssoc, string $userName): void
{ {
$playedGame = $this->getPlayedGame($gameAssoc); $playedGame = $this->getPlayedGame($gameAssoc);
if (null === $playedGame) { if (null === $playedGame) {
return; return;
} }
@@ -70,7 +72,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, $count + 1);
$count = $this->getPlayerCount($users); $count = $this->getPlayerCount($users);
} }
@@ -95,8 +97,9 @@ readonly class TopicManager implements TopicManagerInterface
if ($count === 1) { if ($count === 1) {
// One player waiting — mark as active and announce to the lobby // One player waiting — mark as active and announce to the lobby
$playedGame->setUpdated(new DateTime()); $playedGame->setUpdated(new DateTime());
$this->entityManager->persist($playedGame);
$this->entityManager->flush(); $this->em->persist($playedGame);
$this->em->flush();
$displayName = $users['red'] ?: $users['redAnon'] ?: $users['blue'] ?: $users['blueAnon'] ?: 'Unknown'; $displayName = $users['red'] ?: $users['redAnon'] ?: $users['blue'] ?: $users['blueAnon'] ?: 'Unknown';
$this->publishToLobby([ $this->publishToLobby([
@@ -120,8 +123,8 @@ readonly class TopicManager implements TopicManagerInterface
$users = $this->getUserCollection($playedGame); $users = $this->getUserCollection($playedGame);
if ($this->getPlayerCount($users) === 1) { if ($this->getPlayerCount($users) === 1) {
$playedGame->setUpdated(new DateTime('2000-01-01 00:00:00')); $playedGame->setUpdated(new DateTime('2000-01-01 00:00:00'));
$this->entityManager->persist($playedGame); $this->em->persist($playedGame);
$this->entityManager->flush(); $this->em->flush();
$this->publishToLobby(['action' => 'leave', 'gameAssoc' => $gameAssoc]); $this->publishToLobby(['action' => 'leave', 'gameAssoc' => $gameAssoc]);
} }
} }
@@ -168,9 +171,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'];
@@ -178,25 +178,40 @@ readonly class TopicManager implements TopicManagerInterface
$playedGame = $this->getPlayedGame($gameAssoc); $playedGame = $this->getPlayedGame($gameAssoc);
$grid = $this->loadGrid($gameAssoc); $grid = $this->loadGrid($gameAssoc);
// Cells already revealed by previous steps (as "row,col" => true map) /** Cells already revealed by previous steps (as "row,col" => true map) */
$alreadyRevealed = $this->buildRevealedMap($playedGame); $alreadyRevealed = $this->buildRevealedMap($playedGame);
// Determine which cells to reveal for this step /** Determine which cells to reveal for this step */
if ($isBomb) { if ($isBomb) {
$revealedCells = $this->getBombRevealedCells($grid, $coords[0], $coords[1], $alreadyRevealed); $revealedCells = $this->getBombRevealedCells($grid, $coords[0], $coords[1], $alreadyRevealed);
} elseif ('m' === ($grid[$coords[0]][$coords[1]] ?? null)) { } elseif ('m' === ($grid[$coords[0]][$coords[1]] ?? null)) {
// Direct click on a mine — reveal it immediately (flood-fill skips mines) /** Direct click on a mine — reveal it immediately (flood-fill skips mines) */
$revealedCells = [['row' => $coords[0], 'col' => $coords[1], 'value' => 'm']]; $revealedCells = [['row' => $coords[0], 'col' => $coords[1], 'value' => 'm']];
} else { } else {
$revealedCells = $this->floodFill($grid, $coords[0], $coords[1], $alreadyRevealed); $revealedCells = $this->floodFill($grid, $coords[0], $coords[1], $alreadyRevealed);
} }
$minesFound = count(array_filter($revealedCells, static fn($c) => 'm' === $c['value'])); $minesFound = count(array_filter($revealedCells, static fn($c) => 'm' === $c['value']));
$safeCellsFound = count(array_filter($revealedCells, static fn($c) => 'm' !== $c['value']));
$redPoints = ($playedGame->getRedPoints() ?? 0) + ('red' === $player ? $minesFound : 0); $redPoints = ($playedGame->getRedPoints() ?? 0) + ('red' === $player ? $minesFound : 0);
$bluePoints = ($playedGame->getBluePoints() ?? 0) + ('blue' === $player ? $minesFound : 0); $bluePoints = ($playedGame->getBluePoints() ?? 0) + ('blue' === $player ? $minesFound : 0);
$gameOver = $redPoints > 25 || $bluePoints > 25; $gameOver = $redPoints > 25 || $bluePoints > 25;
// Reveal remaining mines when the game ends /** Calculate bonus points and stats */
$bonusData = $this->calculateBonuses(
$playedGame,
$player,
$coords,
$grid,
$alreadyRevealed,
$minesFound,
$safeCellsFound,
$redPoints,
$bluePoints
);
/** Reveal remaining mines when the game ends */
$leftMines = []; $leftMines = [];
if ($gameOver) { if ($gameOver) {
$finalRevealed = $alreadyRevealed; $finalRevealed = $alreadyRevealed;
@@ -206,7 +221,7 @@ readonly class TopicManager implements TopicManagerInterface
$leftMines = $this->getLeftMines($grid, $finalRevealed); $leftMines = $this->getLeftMines($grid, $finalRevealed);
} }
$this->saveStepToDb($gameAssoc, $event, $player, $revealedCells, $redPoints, $bluePoints); $this->saveStepToDb($gameAssoc, $event, $player, $revealedCells, $redPoints, $bluePoints, $bonusData);
$users = $this->getUserCollection($playedGame); $users = $this->getUserCollection($playedGame);
$count = $this->getPlayerCount($users); $count = $this->getPlayerCount($users);
@@ -223,6 +238,10 @@ readonly class TopicManager implements TopicManagerInterface
'resign' => null, 'resign' => null,
'gameOver' => $gameOver, 'gameOver' => $gameOver,
'leftMines' => $leftMines, 'leftMines' => $leftMines,
'redBonusPoints' => $bonusData['redBonusPoints'],
'blueBonusPoints' => $bonusData['blueBonusPoints'],
'redBonusStats' => $bonusData['redBonusStats'],
'blueBonusStats' => $bonusData['blueBonusStats'],
]; ];
try { try {
@@ -243,10 +262,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
{ {
@@ -266,6 +281,155 @@ readonly class TopicManager implements TopicManagerInterface
return $grid; return $grid;
} }
private function calculateBonuses(
PlayedGame $playedGame,
string $player,
array $coords,
array $grid,
array $alreadyRevealed,
int $minesFound,
int $safeCellsFound,
int $redPoints,
int $bluePoints
): array {
/** Initialize or load existing bonus stats */
$redBonusStats = $playedGame->getRedBonusStats() ?? [
'blindHits' => 0,
'chainBest' => 0,
'chainCurrent' => 0,
'lastMineHits' => 0,
'edgeMines' => 0,
'biggestReveal' => 0,
];
$blueBonusStats = $playedGame->getBlueBonusStats() ?? [
'blindHits' => 0,
'chainBest' => 0,
'chainCurrent' => 0,
'lastMineHits' => 0,
'edgeMines' => 0,
'biggestReveal' => 0,
];
$redBonusPoints = $playedGame->getRedBonusPoints() ?? 0;
$blueBonusPoints = $playedGame->getBlueBonusPoints() ?? 0;
$isRed = 'red' === $player;
$currentStats = $isRed ? $redBonusStats : $blueBonusStats;
$bonusPoints = 0;
/** Track biggest reveal (safe cells count) if any safe cells were revealed */
if ($safeCellsFound > 0) {
if ($safeCellsFound > $currentStats['biggestReveal']) {
$currentStats['biggestReveal'] = $safeCellsFound;
}
}
/** Only calculate bonuses if mines were found */
if ($minesFound > 0) {
/** Check Blind Hit: the clicked mine cell has no revealed numbered neighbors */
if ($this->isBlindHit($coords, $grid, $alreadyRevealed)) {
$currentStats['blindHits']++;
$bonusPoints += 2;
}
/** Check Edge Mine: the clicked cell is on the boundary */
if ($this->isEdgeMine($coords)) {
$currentStats['edgeMines']++;
$bonusPoints += 1;
}
/** Check Endgame Mine: when few mines remain on the board */
$totalMinesOnBoard = 51;
$minesRevealed = $redPoints + $bluePoints;
$minesRemaining = $totalMinesOnBoard - $minesRevealed;
if ($minesRemaining <= 10) {
$currentStats['lastMineHits']++;
$bonusPoints += 3;
}
/** Chain combo: increment consecutive mine-click counter */
$currentStats['chainCurrent']++;
if ($currentStats['chainCurrent'] > $currentStats['chainBest']) {
$currentStats['chainBest'] = $currentStats['chainCurrent'];
}
} else {
/** No mines found - reset chain for this player */
$currentStats['chainCurrent'] = 0;
}
/**
* Add points for safe cells revealed (each safe cell revealed = +0.5 bonus point)
* Only award points if at least 2 safe cells were revealed
*/
if ($safeCellsFound >= 2) {
$bonusPoints += ($safeCellsFound * 0.5);
}
/** Update the appropriate player's stats and points */
if ($isRed) {
$redBonusStats = $currentStats;
$redBonusPoints += $bonusPoints;
} else {
$blueBonusStats = $currentStats;
$blueBonusPoints += $bonusPoints;
}
/** Persist updated stats to the database */
$playedGame->setRedBonusStats($redBonusStats);
$playedGame->setBlueBonusStats($blueBonusStats);
$playedGame->setRedBonusPoints($redBonusPoints);
$playedGame->setBlueBonusPoints($blueBonusPoints);
$this->em->persist($playedGame);
return [
'redBonusPoints' => $redBonusPoints,
'blueBonusPoints' => $blueBonusPoints,
'redBonusStats' => $redBonusStats,
'blueBonusStats' => $blueBonusStats,
];
}
/**
* Check if a mine was clicked with no revealed numbered neighbors (blind hit).
* Returns true if none of the 8 surrounding cells show a number.
*/
private function isBlindHit(array $coords, array $grid, array $alreadyRevealed): bool
{
$row = $coords[0];
$col = $coords[1];
$dirs = [[-1, -1], [-1, 0], [-1, 1], [0, -1], [0, 1], [1, -1], [1, 0], [1, 1]];
foreach ($dirs as [$dr, $dc]) {
$nr = $row + $dr;
$nc = $col + $dc;
$key = $nr . ',' . $nc;
/** Check if neighbor is revealed and is a numbered cell (not a mine, not hidden) */
if (isset($alreadyRevealed[$key])) {
$val = $grid[$nr][$nc] ?? null;
/** If it's a number (0-8), not a mine, it's revealed and visible */
if (is_numeric($val) && $val >= 0) {
return false;
}
}
}
return true;
}
/**
* Check if a mine is on the edge/corner of the board.
*/
private function isEdgeMine(array $coords): bool
{
$row = $coords[0];
$col = $coords[1];
return 0 === $row || $row === 15 || 0 === $col || $col === 15;
}
/** /**
* BFS flood-fill starting at (row, col). * BFS flood-fill starting at (row, col).
* Reveals the clicked cell plus all connected zero-value cells and their non-mine borders. * Reveals the clicked cell plus all connected zero-value cells and their non-mine borders.
@@ -403,10 +567,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);
@@ -424,8 +584,8 @@ readonly class TopicManager implements TopicManagerInterface
{ {
$playedGame = $this->getPlayedGame($gameAssoc); $playedGame = $this->getPlayedGame($gameAssoc);
$playedGame->setResign($color); $playedGame->setResign($color);
$this->entityManager->persist($playedGame); $this->em->persist($playedGame);
$this->entityManager->flush(); $this->em->flush();
} }
private function saveStepToDb( private function saveStepToDb(
@@ -435,6 +595,7 @@ readonly class TopicManager implements TopicManagerInterface
array $revealedCells, array $revealedCells,
int $redPoints, int $redPoints,
int $bluePoints, int $bluePoints,
array $bonusData = []
): void { ): void {
try { try {
$playedGame = $this->getPlayedGame($gameAssoc); $playedGame = $this->getPlayedGame($gameAssoc);
@@ -447,31 +608,44 @@ readonly class TopicManager implements TopicManagerInterface
$step->setRevealedCells($revealedCells); $step->setRevealedCells($revealedCells);
$step->setPlayedGame($playedGame); $step->setPlayedGame($playedGame);
$step->setCreated(new DateTime()); $step->setCreated(new DateTime());
$this->entityManager->persist($step); $this->em->persist($step);
$playedGame->setRedPoints($redPoints); $playedGame->setRedPoints($redPoints);
$playedGame->setBluePoints($bluePoints); $playedGame->setBluePoints($bluePoints);
$playedGame->setRedExplodedBomb((bool)$event['bomb'] && 'red' === $player ? true : null); if ((bool)$event['bomb']) {
$playedGame->setBlueExplodedBomb((bool)$event['bomb'] && 'blue' === $player ? true : null); if ('red' === $player) {
$playedGame->setRedExplodedBomb(true);
} elseif ('blue' === $player) {
$playedGame->setBlueExplodedBomb(true);
}
}
$playedGame->setUpdated(new DateTime()); $playedGame->setUpdated(new DateTime());
$this->entityManager->persist($playedGame);
$this->entityManager->flush(); /** Bonus data is already persisted in calculateBonuses, but we ensure it's up to date */
if (!empty($bonusData)) {
$playedGame->setRedBonusPoints($bonusData['redBonusPoints']);
$playedGame->setBlueBonusPoints($bonusData['blueBonusPoints']);
$playedGame->setRedBonusStats($bonusData['redBonusStats']);
$playedGame->setBlueBonusStats($bonusData['blueBonusStats']);
}
$this->em->persist($playedGame);
$this->em->flush();
} catch (Exception $e) { } catch (Exception $e) {
$this->logger->error($e->getMessage()); $this->logger->error($e->getMessage());
} }
} }
private function saveUserToDb(string $gameAssoc, string $userName, ?UserInterface $user, int $count): array private function saveUserToDb(string $gameAssoc, string $userName, int $count): array
{ {
$playedGame = $this->getPlayedGame($gameAssoc); $playedGame = $this->getPlayedGame($gameAssoc);
null !== $user null !== $this->requestStack->getCurrentRequest()->getUser()
? $this->saveRegisteredUser($userName, $count, $playedGame) ? $this->saveRegisteredUser($userName, $count, $playedGame)
: $this->saveAnonUser($userName, $count, $playedGame); : $this->saveAnonUser($userName, $count, $playedGame);
$this->entityManager->persist($playedGame); $this->em->persist($playedGame);
$this->entityManager->flush(); $this->em->flush();
return $this->getUserCollection($playedGame); return $this->getUserCollection($playedGame);
} }
@@ -499,9 +673,13 @@ readonly class TopicManager implements TopicManagerInterface
{ {
try { try {
$anon = new Gamer(); $anon = new Gamer();
$anon->setUsername($userName); $anon->setUserName($userName);
$anon->setIp($this->requestStack->getCurrentRequest()->getClientIp());
$anon->setCountry($this->extractCountry());
$anon->setUserAgent($this->requestStack->getCurrentRequest()->headers->get('User-Agent'));
$anon->setConnTimestamp(new DateTime()); $anon->setConnTimestamp(new DateTime());
$this->entityManager->persist($anon);
$this->em->persist($anon);
if ($count === 1) { if ($count === 1) {
$random = random_int(0, 1); $random = random_int(0, 1);
@@ -539,6 +717,7 @@ readonly class TopicManager implements TopicManagerInterface
{ {
$challengerGame = $this->getPlayedGame($challengerGameAssoc); $challengerGame = $this->getPlayedGame($challengerGameAssoc);
$challengerName = 'Unknown'; $challengerName = 'Unknown';
if (null !== $challengerGame) { if (null !== $challengerGame) {
$users = $this->getUserCollection($challengerGame); $users = $this->getUserCollection($challengerGame);
$challengerName = $users['red'] ?: $users['redAnon'] ?: $users['blue'] ?: $users['blueAnon'] ?: 'Unknown'; $challengerName = $users['red'] ?: $users['redAnon'] ?: $users['blue'] ?: $users['blueAnon'] ?: 'Unknown';
@@ -574,6 +753,22 @@ readonly class TopicManager implements TopicManagerInterface
} }
} }
public function publishHeartbeat(string $gameAssoc, string $color): void
{
try {
$this->hub->publish(new Update(
'mineseeker/channel/' . $gameAssoc,
json_encode([
'type' => 'heartbeat',
'color' => $color,
'ts' => (int)(microtime(true) * 1000),
], JSON_THROW_ON_ERROR)
));
} catch (JsonException $e) {
$this->logger->error('Heartbeat publish error: ' . $e->getMessage());
}
}
private function publishToLobby(array $data): void private function publishToLobby(array $data): void
{ {
try { try {
@@ -585,4 +780,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(): ?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 = $this->requestStack->getCurrentRequest()->headers->get($header);
if (empty($country)) {
continue;
}
return substr($country, 0, 100);
}
return null;
}
} }

View File

@@ -31,7 +31,7 @@
</div> </div>
<div class="bshare-vs"> <div class="bshare-vs">
<div class="bshare-player bshare-player--red"> <div class="bshare-player bshare-player--red">
<div class="bshare-avatar bshare-avatar--red"> <div class="bshare-avatar bshare-avatar--red" style="position: relative;">
{% if redAvatar %} {% if redAvatar %}
<img src="{{ redAvatar|imagine_filter('avatar_thumb') }}" <img src="{{ redAvatar|imagine_filter('avatar_thumb') }}"
alt="{{ redName }}" alt="{{ redName }}"
@@ -39,6 +39,11 @@
{% else %} {% else %}
{{ redName|slice(0,2)|upper }} {{ redName|slice(0,2)|upper }}
{% endif %} {% endif %}
{% if redBonusPoints > blueBonusPoints %}
<div style="position: absolute; bottom: -6px; right: -6px; background: #ffd700; border-radius: 50%; width: 28px; height: 28px; display: flex; align-items: center; justify-content: center; box-shadow: 0 0 12px rgba(255, 215, 0, 0.6), 0 0 0 2px rgba(7, 9, 13, 1); border: 2px solid rgba(0,0,0,0.5); z-index: 10;">
<i class="fas fa-star" style="color: #000; font-size: 14px;"></i>
</div>
{% endif %}
</div> </div>
<span class="bshare-player__name">{{ redName }}</span> <span class="bshare-player__name">{{ redName }}</span>
<span class="bshare-player__side">Red</span> <span class="bshare-player__side">Red</span>
@@ -53,6 +58,15 @@
{% else %} {% else %}
<div class="bshare-score bshare-score--na">— : —</div> <div class="bshare-score bshare-score--na">— : —</div>
{% endif %} {% endif %}
<div style="display: flex; justify-content: center; gap: 0; align-items: center; margin-bottom: 8px;">
<span style="font: 700 13px 'Rajdhani', sans-serif; color: #f67d52; display: flex; align-items: center; gap: 4px;">
<i class="fas fa-star" style="font-size: 11px;"></i> {{ (redBonusPoints ?? 0)|number_format(1, '.', '') }}
</span>
<span style="font: 700 13px 'Rajdhani', sans-serif; color: rgba(255,255,255,0.3); margin: 0 8px;">:</span>
<span style="font: 700 13px 'Rajdhani', sans-serif; color: #95cff5; display: flex; align-items: center; gap: 4px;">
{{ (blueBonusPoints ?? 0)|number_format(1, '.', '') }} <i class="fas fa-star" style="font-size: 11px;"></i>
</span>
</div>
<div class="bshare-vs__label">VS</div> <div class="bshare-vs__label">VS</div>
{% if resign == 'red' %} {% if resign == 'red' %}
<div class="bshare-badge bshare-badge--blue"> <div class="bshare-badge bshare-badge--blue">
@@ -79,7 +93,7 @@
{% endif %} {% endif %}
</div> </div>
<div class="bshare-player bshare-player--blue"> <div class="bshare-player bshare-player--blue">
<div class="bshare-avatar bshare-avatar--blue"> <div class="bshare-avatar bshare-avatar--blue" style="position: relative;">
{% if blueAvatar %} {% if blueAvatar %}
<img src="{{ blueAvatar|imagine_filter('avatar_thumb') }}" <img src="{{ blueAvatar|imagine_filter('avatar_thumb') }}"
alt="{{ blueName }}" alt="{{ blueName }}"
@@ -87,11 +101,32 @@
{% else %} {% else %}
{{ blueName|slice(0,2)|upper }} {{ blueName|slice(0,2)|upper }}
{% endif %} {% endif %}
{% if blueBonusPoints > redBonusPoints %}
<div style="position: absolute; bottom: -6px; right: -6px; background: #ffd700; border-radius: 50%; width: 28px; height: 28px; display: flex; align-items: center; justify-content: center; box-shadow: 0 0 12px rgba(255, 215, 0, 0.6), 0 0 0 2px rgba(7, 9, 13, 1); border: 2px solid rgba(0,0,0,0.5); z-index: 10;">
<i class="fas fa-star" style="color: #000; font-size: 14px;"></i>
</div>
{% endif %}
</div> </div>
<span class="bshare-player__name">{{ blueName }}</span> <span class="bshare-player__name">{{ blueName }}</span>
<span class="bshare-player__side">Blue</span> <span class="bshare-player__side">Blue</span>
</div> </div>
</div> </div>
{% set durationSec = (game.created and game.updated) ? (game.updated|date('U') - game.created|date('U')) : 0 %}
{% set durationStr = '' %}
{% if durationSec > 0 %}
{% set h = (durationSec / 3600)|round(0, 'floor') %}
{% set m = ((durationSec % 3600) / 60)|round(0, 'floor') %}
{% set s = durationSec % 60 %}
{% if h > 0 %}
{% set durationStr = h ~ 'h ' ~ m ~ 'm ' ~ s ~ 's' %}
{% elseif m > 0 %}
{% set durationStr = m ~ 'm ' ~ s ~ 's' %}
{% else %}
{% set durationStr = s ~ 's' %}
{% endif %}
{% endif %}
{% set pointDiff = (redPts|default(0) - bluePts|default(0))|abs %}
{% set winnerName = redPts|default(0) > bluePts|default(0) ? redName : (bluePts|default(0) > redPts|default(0) ? blueName : null) %}
<div class="bshare-details"> <div class="bshare-details">
{% if resign %} {% if resign %}
<div class="bshare-detail"> <div class="bshare-detail">
@@ -99,16 +134,28 @@
<span>{{ resign|capitalize }} resigned</span> <span>{{ resign|capitalize }} resigned</span>
</div> </div>
{% endif %} {% endif %}
{% if durationStr %}
<div class="bshare-detail">
<i class="fas fa-hourglass-half"></i>
<span>Match duration: {{ durationStr }}</span>
</div>
{% endif %}
{% if pointDiff > 0 and winnerName %}
<div class="bshare-detail">
<i class="fas fa-balance-scale"></i>
<span>{{ winnerName }} won by {{ pointDiff }} mine{{ pointDiff == 1 ? '' : 's' }}</span>
</div>
{% endif %}
{% if game.redExplodedBomb %} {% if game.redExplodedBomb %}
<div class="bshare-detail bshare-detail--bomb"> <div class="bshare-detail bshare-detail--bomb">
<i class="fas fa-bomb"></i> <i class="fas fa-bomb"></i>
<span>{{ redName }} hit a mine</span> <span>{{ redName }} used their bomb</span>
</div> </div>
{% endif %} {% endif %}
{% if game.blueExplodedBomb %} {% if game.blueExplodedBomb %}
<div class="bshare-detail bshare-detail--bomb"> <div class="bshare-detail bshare-detail--bomb">
<i class="fas fa-bomb"></i> <i class="fas fa-bomb"></i>
<span>{{ blueName }} hit a mine</span> <span>{{ blueName }} used their bomb</span>
</div> </div>
{% endif %} {% endif %}
{% if game.updated %} {% if game.updated %}
@@ -118,6 +165,104 @@
</div> </div>
{% endif %} {% endif %}
</div> </div>
{% set hasRedStats = redBonusStats is not empty and (redBonusStats.blindHits or redBonusStats.chainBest or redBonusStats.edgeMines or redBonusStats.lastMineHits or redBonusStats.biggestReveal) %}
{% set hasBlueStats = blueBonusStats is not empty and (blueBonusStats.blindHits or blueBonusStats.chainBest or blueBonusStats.edgeMines or blueBonusStats.lastMineHits or blueBonusStats.biggestReveal) %}
{% if redBonusPoints > 0 or blueBonusPoints > 0 or hasRedStats or hasBlueStats %}
<div class="bshare-bonus">
<div class="bshare-bonus__title">
<i class="fas fa-star"></i> Bonus Statistics
</div>
<div class="bshare-bonus__grid">
{# Red Bonus #}
<div class="bshare-bonus__player bshare-bonus__player--red">
<div class="bshare-bonus__header">
<span class="bshare-bonus__points">{{ redBonusPoints|number_format(1, '.', '') }}</span>
<span class="bshare-bonus__label">pts</span>
</div>
<div class="bshare-bonus__stats">
{% if redBonusStats is not empty and redBonusStats.blindHits %}
<div class="bshare-bonus__stat">
<span class="bshare-bonus__stat-label">Blind hits</span>
<span class="bshare-bonus__stat-value">{{ redBonusStats.blindHits }}</span>
</div>
{% endif %}
{% if redBonusStats is not empty and redBonusStats.chainBest %}
<div class="bshare-bonus__stat">
<span class="bshare-bonus__stat-label">Best chain</span>
<span class="bshare-bonus__stat-value">{{ redBonusStats.chainBest }}</span>
</div>
{% endif %}
{% if redBonusStats is not empty and redBonusStats.edgeMines %}
<div class="bshare-bonus__stat">
<span class="bshare-bonus__stat-label">Edge mines</span>
<span class="bshare-bonus__stat-value">{{ redBonusStats.edgeMines }}</span>
</div>
{% endif %}
{% if redBonusStats is not empty and redBonusStats.lastMineHits %}
<div class="bshare-bonus__stat">
<span class="bshare-bonus__stat-label">Endgame mines</span>
<span class="bshare-bonus__stat-value">{{ redBonusStats.lastMineHits }}</span>
</div>
{% endif %}
{% if redBonusStats is not empty and redBonusStats.biggestReveal %}
<div class="bshare-bonus__stat">
<span class="bshare-bonus__stat-label">Biggest reveal</span>
<span class="bshare-bonus__stat-value">{{ redBonusStats.biggestReveal }}</span>
</div>
{% endif %}
{% if not hasRedStats %}
<div class="bshare-bonus__stat bshare-bonus__stat--empty">
<span class="bshare-bonus__stat-label">No bonuses earned</span>
</div>
{% endif %}
</div>
</div>
<div class="bshare-bonus__player bshare-bonus__player--blue">
<div class="bshare-bonus__header">
<span class="bshare-bonus__points">{{ blueBonusPoints|number_format(1, '.', '') }}</span>
<span class="bshare-bonus__label">pts</span>
</div>
<div class="bshare-bonus__stats">
{% if blueBonusStats is not empty and blueBonusStats.blindHits %}
<div class="bshare-bonus__stat">
<span class="bshare-bonus__stat-label">Blind hits</span>
<span class="bshare-bonus__stat-value">{{ blueBonusStats.blindHits }}</span>
</div>
{% endif %}
{% if blueBonusStats is not empty and blueBonusStats.chainBest %}
<div class="bshare-bonus__stat">
<span class="bshare-bonus__stat-label">Best chain</span>
<span class="bshare-bonus__stat-value">{{ blueBonusStats.chainBest }}</span>
</div>
{% endif %}
{% if blueBonusStats is not empty and blueBonusStats.edgeMines %}
<div class="bshare-bonus__stat">
<span class="bshare-bonus__stat-label">Edge mines</span>
<span class="bshare-bonus__stat-value">{{ blueBonusStats.edgeMines }}</span>
</div>
{% endif %}
{% if blueBonusStats is not empty and blueBonusStats.lastMineHits %}
<div class="bshare-bonus__stat">
<span class="bshare-bonus__stat-label">Endgame mines</span>
<span class="bshare-bonus__stat-value">{{ blueBonusStats.lastMineHits }}</span>
</div>
{% endif %}
{% if blueBonusStats is not empty and blueBonusStats.biggestReveal %}
<div class="bshare-bonus__stat">
<span class="bshare-bonus__stat-label">Biggest reveal</span>
<span class="bshare-bonus__stat-value">{{ blueBonusStats.biggestReveal }}</span>
</div>
{% endif %}
{% if not hasBlueStats %}
<div class="bshare-bonus__stat bshare-bonus__stat--empty">
<span class="bshare-bonus__stat-label">No bonuses earned</span>
</div>
{% endif %}
</div>
</div>
</div>
</div>
{% endif %}
<div class="bshare-cta"> <div class="bshare-cta">
<a href="{{ path('MineSeekerBundle_gamePlay') }}" class="bshare-btn"> <a href="{{ path('MineSeekerBundle_gamePlay') }}" class="bshare-btn">
<i class="fas fa-play"></i> Play MineSeeker <i class="fas fa-play"></i> Play MineSeeker

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>
@@ -227,6 +228,9 @@
<a href="https://bun.sh" target="_blank" rel="noopener" class="tech-link"> <a href="https://bun.sh" target="_blank" rel="noopener" class="tech-link">
<img src="{{ asset('images/technologies/bun.svg') }}" alt="Bun"/> <img src="{{ asset('images/technologies/bun.svg') }}" alt="Bun"/>
</a> </a>
<a href="https://www.postgresql.org" target="_blank" rel="noopener" class="tech-link">
<img src="{{ asset('images/technologies/postgresql.svg') }}" alt="PostgreSQL"/>
</a>
<a href="https://www.jetbrains.com/phpstorm" target="_blank" rel="noopener" class="tech-link"> <a href="https://www.jetbrains.com/phpstorm" target="_blank" rel="noopener" class="tech-link">
<img src="{{ asset('images/technologies/phpstorm.svg') }}" alt="PHPStorm"/> <img src="{{ asset('images/technologies/phpstorm.svg') }}" alt="PHPStorm"/>
</a> </a>
@@ -253,6 +257,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

@@ -10,6 +10,8 @@
<div id="mine-wrapper" <div id="mine-wrapper"
data-env="{{ env }}" data-env="{{ env }}"
data-game-id="{{ app.request.get('gameAssoc') }}" data-game-id="{{ app.request.get('gameAssoc') }}"
data-opponent-name="{{ opponent_name }}"
data-is-authenticated="{{ app.user ? '1' : '0' }}"
data-mercure-hub-url="{{ mercure_hub_url }}" data-mercure-hub-url="{{ mercure_hub_url }}"
data-mercure-subscriber-jwt="{{ mercure_subscriber_jwt }}" data-mercure-subscriber-jwt="{{ mercure_subscriber_jwt }}"
data-recaptcha-site-key="{{ recaptcha_site_key }}"> data-recaptcha-site-key="{{ recaptcha_site_key }}">

View File

@@ -128,7 +128,7 @@
{% block javascripts %} {% block javascripts %}
{{ parent() }} {{ parent() }}
<script src="https://www.google.com/recaptcha/api.js?render={{ recaptcha_site_key }}" async defer></script> <script src="https://www.google.com/recaptcha/api.js?render={{ recaptcha_site_key }}" async defer></script>
{{ vite_entry_script_tags('contact') }} {{ vite_entry_script_tags('contact', { dependency: 'react' }) }}
<div id="contact-form-wrapper" <div id="contact-form-wrapper"
data-site-key="{{ recaptcha_site_key }}" data-site-key="{{ recaptcha_site_key }}"
data-recaptcha-field-id="{{ form.recaptcha.vars.id }}"> data-recaptcha-field-id="{{ form.recaptcha.vars.id }}">

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 %}

View File

@@ -136,5 +136,5 @@
</script> </script>
{{ vite_entry_script_tags('passkey') }} {{ vite_entry_script_tags('passkey', { dependency: 'react' }) }}
{% endblock %} {% endblock %}

View File

@@ -1,5 +1,10 @@
{% extends 'Game/index.html.twig' %} {% extends 'Game/index.html.twig' %}
{% macro stat_val(value, suffix) %}
{%- set abbr = value >= 1000 -%}
<span class="profile-stat__value"{% if abbr %} title="{{ value }}"{% endif %}>{% if abbr %}{{ (value / 1000)|round(1, 'floor') }}k{% else %}{{ value }}{% endif %}{% if suffix %}<small>{{ suffix }}</small>{% endif %}</span>
{% endmacro %}
{% block title %} - Profile{% endblock %} {% block title %} - Profile{% endblock %}
{% block metas %} {% block metas %}
@@ -45,44 +50,64 @@
<div class="profile-stats"> <div class="profile-stats">
<div class="profile-stat"> <div class="profile-stat">
<i class="fas fa-gamepad profile-stat__icon"></i> <i class="fas fa-gamepad profile-stat__icon"></i>
<span class="profile-stat__value">{{ stats.total }}</span> {{ _self.stat_val(stats.total) }}
<span class="profile-stat__label">Games played</span> <span class="profile-stat__label">Games played</span>
</div> </div>
<div class="profile-stat profile-stat--win"> <div class="profile-stat profile-stat--win">
<i class="fas fa-trophy profile-stat__icon"></i> <i class="fas fa-trophy profile-stat__icon"></i>
<span class="profile-stat__value">{{ stats.wins }}</span> {{ _self.stat_val(stats.wins) }}
<span class="profile-stat__label">Victories</span> <span class="profile-stat__label">Victories</span>
</div> </div>
<div class="profile-stat profile-stat--loss"> <div class="profile-stat profile-stat--loss">
<i class="fas fa-flag profile-stat__icon"></i> <i class="fas fa-flag profile-stat__icon"></i>
<span class="profile-stat__value">{{ stats.losses }}</span> {{ _self.stat_val(stats.losses) }}
<span class="profile-stat__label">Defeats</span> <span class="profile-stat__label">Defeats</span>
</div> </div>
<div class="profile-stat profile-stat--draw"> <div class="profile-stat profile-stat--draw">
<i class="fas fa-minus profile-stat__icon"></i> <i class="fas fa-minus profile-stat__icon"></i>
<span class="profile-stat__value">{{ stats.draws }}</span> {{ _self.stat_val(stats.draws) }}
<span class="profile-stat__label">Draws</span> <span class="profile-stat__label">Draws</span>
</div> </div>
<div class="profile-stat profile-stat--rate"> <div class="profile-stat profile-stat--rate">
<i class="fas fa-percent profile-stat__icon"></i> <i class="fas fa-percent profile-stat__icon"></i>
<span class="profile-stat__value">{{ stats.winRate }}<small>%</small></span> {{ _self.stat_val(stats.winRate, '%') }}
<span class="profile-stat__label">Win rate</span> <span class="profile-stat__label">Win rate</span>
</div> </div>
<div class="profile-stat profile-stat--avg"> <div class="profile-stat profile-stat--avg">
<i class="fas fa-chart-line profile-stat__icon"></i> <i class="fas fa-chart-line profile-stat__icon"></i>
<span class="profile-stat__value">{{ stats.avgScore }}</span> {{ _self.stat_val(stats.avgScore) }}
<span class="profile-stat__label">Avg score</span> <span class="profile-stat__label">Avg score</span>
</div> </div>
<div class="profile-stat profile-stat--best">
<i class="fas fa-star profile-stat__icon"></i>
<span class="profile-stat__value">{{ stats.bestScore }}</span>
<span class="profile-stat__label">Best score</span>
</div>
<div class="profile-stat profile-stat--bomb"> <div class="profile-stat profile-stat--bomb">
<i class="fas fa-bomb profile-stat__icon"></i> <i class="fas fa-bomb profile-stat__icon"></i>
<span class="profile-stat__value">{{ stats.bombs }}</span> {{ _self.stat_val(stats.minesHit) }}
<span class="profile-stat__label">Mines hit</span> <span class="profile-stat__label">Mines hit</span>
</div> </div>
<div class="profile-stat profile-stat--bonus">
<i class="fas fa-star profile-stat__icon"></i>
{{ _self.stat_val(stats.bonusPoints) }}
<span class="profile-stat__label">Bonus points</span>
</div>
<div class="profile-stat profile-stat--avg-bonus">
<i class="fas fa-chart-simple profile-stat__icon"></i>
{{ _self.stat_val(stats.avgBonus) }}
<span class="profile-stat__label">Avg bonus</span>
</div>
<div class="profile-stat profile-stat--chain">
<i class="fas fa-link profile-stat__icon"></i>
{{ _self.stat_val(stats.bestChain) }}
<span class="profile-stat__label">Best chain</span>
</div>
<div class="profile-stat profile-stat--blind">
<i class="fas fa-bullseye profile-stat__icon"></i>
{{ _self.stat_val(stats.blindHits) }}
<span class="profile-stat__label">Blind hits</span>
</div>
<div class="profile-stat profile-stat--edge">
<i class="fas fa-border-style profile-stat__icon"></i>
{{ _self.stat_val(stats.edgeMines) }}
<span class="profile-stat__label">Edge mines</span>
</div>
</div> </div>
{% if stats.total > 0 %} {% if stats.total > 0 %}
@@ -104,21 +129,35 @@
{% set opp_anon = is_red ? game.blueAnon : game.redAnon %} {% set opp_anon = is_red ? game.blueAnon : game.redAnon %}
{% set result = 'draw' %} {% set result = 'draw' %}
{% set is_finished = false %}
{% set is_anonymous = not opp and opp_anon %}
{% if game.resign == (is_red ? 'red' : 'blue') %} {% if game.resign == (is_red ? 'red' : 'blue') %}
{% set result = 'loss' %} {% set result = 'loss' %}
{% set is_finished = true %}
{% elseif game.resign == (is_red ? 'blue' : 'red') %} {% elseif game.resign == (is_red ? 'blue' : 'red') %}
{% set result = 'win' %} {% set result = 'win' %}
{% set is_finished = true %}
{% elseif my_points is not null and opp_points is not null %} {% elseif my_points is not null and opp_points is not null %}
{% if my_points > opp_points %} {% if my_points > opp_points %}
{% set result = 'win' %} {% set result = 'win' %}
{% set is_finished = (my_points > 25 or opp_points > 25) %}
{% elseif my_points < opp_points %} {% elseif my_points < opp_points %}
{% set result = 'loss' %} {% set result = 'loss' %}
{% set is_finished = (my_points > 25 or opp_points > 25) %}
{% else %}
{% set is_finished = (my_points > 25 or opp_points > 25) %}
{% endif %} {% endif %}
{% endif %} {% endif %}
<div class="profile-game profile-game--{{ result }}" data-game-index="{{ loop.index0 }}"> <div class="profile-game profile-game--{{ result }}{% if not is_finished and not is_anonymous %} profile-game--ongoing{% elseif is_anonymous %} profile-game--abandoned{% endif %}" data-game-index="{{ loop.index0 }}">
<span class="profile-game__badge"> <span class="profile-game__badge">
{{ result == 'win' ? 'W' : (result == 'loss' ? 'L' : 'D') }} {% if is_finished %}
{{ result == 'win' ? 'Win' : (result == 'loss' ? 'Loss' : 'Draw') }}
{% elseif is_anonymous %}
Abandoned
{% else %}
Ongoing
{% endif %}
</span> </span>
<span class="profile-game__score"> <span class="profile-game__score">
{{ my_points ?? '—' }} : {{ opp_points ?? '—' }} {{ my_points ?? '—' }} : {{ opp_points ?? '—' }}
@@ -154,5 +193,5 @@
{% block javascripts %} {% block javascripts %}
{{ parent() }} {{ parent() }}
{{ vite_entry_script_tags('profile') }} {{ vite_entry_script_tags('profile', { dependency: 'react' }) }}
{% endblock %} {% endblock %}

View File

@@ -133,5 +133,5 @@
{% block javascripts %} {% block javascripts %}
{{ parent() }} {{ parent() }}
{{ vite_entry_script_tags('passkey') }} {{ vite_entry_script_tags('passkey', { dependency: 'react' }) }}
{% endblock %} {% endblock %}