Private
Public Access
1
0

Compare commits

..

49 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
45a8e6b4a1 chg: dev: add consent checkbox to user's registration - and fix the sharing pics #4
All checks were successful
Deploy to Production / deploy (push) Successful in 15s
2026-04-16 11:56:10 +02:00
1f8e9c3c56 chg: pkg: add correct version numbering and CHANGELOG - and add the LICENSE #4 2026-04-16 11:35:53 +02:00
a511b86db8 chg: usr: update all texts on all pages - extend them with the game specific things #4
All checks were successful
Deploy to Production / deploy (push) Successful in 11s
2026-04-16 11:25:08 +02:00
1c0ad054bb chg: pkg: new version release !skipChangelog 2026-04-16 10:41:25 +02:00
5a8799bb7f fix: usr: the meta tags does not have https scheme - nothing worked in configuration #4
All checks were successful
Deploy to Production / deploy (push) Successful in 2m26s
2026-04-16 10:40:56 +02:00
6c443d8e86 chg: pkg: new version release !skipChangelog 2026-04-15 20:24:28 +02:00
8795fedda9 chg: usr: add notification on activation too #4
All checks were successful
Deploy to Production / deploy (push) Successful in 11s
2026-04-15 20:23:41 +02:00
588fb57299 new: usr: add notification email when a user is registered #4 2026-04-15 20:19:29 +02:00
eb345e17ca chg: pkg: new version release !skipChangelog
All checks were successful
Deploy to Production / deploy (push) Successful in 19s
2026-04-15 20:13:38 +02:00
c2693c4648 fix: usr: another attempt to fix the email assets #4
All checks were successful
Deploy to Production / deploy (push) Successful in 11s
2026-04-15 20:03:48 +02:00
43efc16562 fix: usr: the images does not shows in emails #4
All checks were successful
Deploy to Production / deploy (push) Successful in 15s
2026-04-15 19:50:14 +02:00
80d6440ece chg: pkg: new version release !skipChangelog
All checks were successful
Deploy to Production / deploy (push) Successful in 16s
2026-04-15 19:00:43 +02:00
5ee972f003 chg: pkg: add missing .env variable and increase the version number and add missing data from front-end and back-end deps descriptor #4
Some checks failed
Deploy to Production / deploy (push) Failing after 0s
2026-04-15 18:59:52 +02:00
6f3edb41ea new: usr: add Contact page with email sending behaviour #4
All checks were successful
Deploy to Production / deploy (push) Successful in 39s
2026-04-15 18:35:05 +02:00
c52939a7a3 chg: usr: change the shareable battle - add avatars to it - even on the og tags #4 2026-04-15 16:44:57 +02:00
573d409606 fix: pkg: the mailhog is crashed on development env #4 2026-04-15 14:45:44 +02:00
9a58bc9a5e chg: usr: change text #4 2026-04-15 14:38:25 +02:00
8780800dff fix: pkg: the og tags did not have proper http schema - they should have https #4 2026-04-15 14:33:53 +02:00
f442942faf chg: pkg: new version release !skipChangelog
All checks were successful
Deploy to Production / deploy (push) Successful in 25s
2026-04-14 21:51:17 +02:00
a61d881a4e chg: usr: add donation button #4 2026-04-14 21:50:58 +02:00
926b614136 chg: pkg: new version release !skipChangelog
All checks were successful
Deploy to Production / deploy (push) Successful in 23s
2026-04-14 21:08:27 +02:00
c0c84f4651 chg: dev: protect the gameplay with recaptcha #4 2026-04-14 21:07:54 +02:00
176e255037 chg: usr: the waiting dialog is uncloseable until the time is up #4 2026-04-14 21:04:05 +02:00
b134358e9e new: usr: add timer for the acceptance of the challenge #4 2026-04-14 20:30:18 +02:00
3525aaeeb7 fix: usr: missing font-awesome icons on bare-metal environment #4 2026-04-14 19:44:01 +02:00
af67ec3931 chg: usr: add share button to the overlay when the game ends #4 2026-04-14 19:37:42 +02:00
d515f42cfd chg: usr: make fancy og tags - and create a special one for battle sharing #4 2026-04-14 18:54:44 +02:00
5d6aff8d90 chg: dev: the user's avatar will be saved as a uuid.extension #4
All checks were successful
Deploy to Production / deploy (push) Successful in 10s
2026-04-14 16:53:16 +02:00
15ba26ccf2 chg: pkg: new version release !skipChangelog
All checks were successful
Deploy to Production / deploy (push) Successful in 3s
2026-04-14 16:47:15 +02:00
117 changed files with 6794 additions and 1222 deletions

View File

@@ -6,6 +6,12 @@
APP_ENV=dev APP_ENV=dev
APP_SECRET=changethis APP_SECRET=changethis
APP_NAME=mineseeker APP_NAME=mineseeker
# APP_PUBLIC_HOSTNAME: The public hostname for your application (used for generating absolute URLs in emails)
# For production, set this to your domain (e.g., mineseeker.com)
APP_PUBLIC_HOSTNAME=localhost
# TRUSTED_PROXIES: Only needed for bare-metal dev behind a reverse proxy
# For Docker development, this is set in compose.override.yaml
# For production, set in PROD_ENV_FILE Gitea secret (use 172.18.0.0/16 initially)
#TRUSTED_PROXIES=127.0.0.1,127.0.0.2 #TRUSTED_PROXIES=127.0.0.1,127.0.0.2
#TRUSTED_HOSTS=localhost,example.com #TRUSTED_HOSTS=localhost,example.com
###< symfony/framework-bundle ### ###< symfony/framework-bundle ###

32
.gitchangelog.rc Normal file
View File

@@ -0,0 +1,32 @@
ignore_regexps = [
r'@minor', r'!minor',
r'@cosmetic', r'!cosmetic',
r'@refactor', r'!refactor',
r'@wip', r'!wip',
r'^(.{3,3}\s*:)?\s*[fF]irst commit.?\s*$',
r'^$', ## ignore commits with empty messages
r'@skipChangelog', r'!skipChangelog', r'skipChangeLog', r'!skipChangeLog',
r'Merge branch', r'Merge remote-tracking branch', r'!deploy',
]
section_regexps = [
('New', [
r'^[nN]ew\s*:\s*((dev|use?r|pkg|test|doc)\s*:\s*)?([^\n]*)$',
]),
('Changes', [
r'^[cC]hg\s*:\s*((dev|use?r|pkg|test|doc)\s*:\s*)?([^\n]*)$',
]),
('Fix', [
r'^[fF]ix\s*:\s*((dev|use?r|pkg|test|doc)\s*:\s*)?([^\n]*)$',
]),
('Other', None ## Match all lines
),
]
body_process = ReSub(r'((^|\n)[A-Z]\w+(-\w+)*: .*(\n\s+.*)*)+$', r'') | strip
subject_process = (strip |
ReSub(r'^([cC]hg|[fF]ix|[nN]ew)\s*:\s*((dev|use?r|pkg|test|doc)\s*:\s*)?([^\n@]*)(@[a-z]+\s+)*$', r'\4') |
SetIfEmpty("No commit message.") | ucfirst | final_dot)
tag_filter_regexp = r'^(v)?[0-9]+\.[0-9]+(\.[0-9]+)?(\-[0-9]+)?$'
unreleased_version_label = "(unreleased)"
output_engine = mustache("markdown")
include_merge = True
revs = []

View File

@@ -1,177 +1,414 @@
Changelog # Changelog
=========
v2026.01 (2026-04-14) ## v2026.2.3-0 (2026-04-18)
---------------------
New ### New
~~~
- Registered users have avatars next to the timer #4. [Lang]
- Add opportunity to use profile picture. #4. [Lang]
- Add more stats and a dialog for the recent battle that can be
shareable #4. [Lang]
- Implement the 2FA authentication (TOTP and backup codes) #4. [Lang]
- Add beta logo to the corner #3. [Lang]
- Add mineseeker game to the symfony 4 project #3. [Lang]
- Upgrade to the latest symfony v4 #3. [Lang]
Changes * Add initialization bonus points' system to the gameplay #5. [Lang]
~~~~~~~
- Fix missing favicon #4. [Lang]
- Add modern Webauthn authentication #4. [Lang]
- Refactor all forms to have Symfony Form Types & Validation
Constrainsts - & implement Google ReCapthca v3 #4. [Lang]
- Add forgot password functionality #4. [Lang]
- Increase the minimum PHP version to the latest major - and massive
refactor on back-end, like Controllers and Repositories #4. [Lang]
- Redesign the resign dialog #4. [Lang]
- Re-implement the waiting for opponent dialog - refactor its gfx - &
add online user selection dialog #4. [Lang]
- Improve the gfx on homepage - implement login/register and activation
for authentication - and add the first version of profile page #4.
[Lang]
- Refactor and redesign the gfx on front-end #4. [Lang]
- Add timers to each player - renew the whole migration #4. [Lang]
- Use namespaces for front-end #4. [Lang]
- Replace webpack w/ vite & remove old, legacy jQuery from the code #4.
[Lang]
- More, massive refactor for front-end #4. [Lang]
- Massive refactor on front-end - and remove unnecessary deps #4. [Lang]
- Change the code style to fit the current standard #4. [Lang]
- Refactor to use Attributes instead of yaml markdown #4. [Lang]
- Outsource the Grid generation and interactions to the backend #4.
[Lang]
- Remove unnecessary variables and prune the Facebook registration
method #4. [Lang]
- Replace the legacy gos/web-socket-bundle & replace it with Mercure
protocol #4. [Lang]
- Created the first working solution since 7 yrs #4. [Lang]
- Make the first working version - the stepping is broken due to the
algorythm structure #4. [Lang]
- Change the composer default php minimum environment #3. [Lang]
- Change the default url to wss on frontend #3. [Lang]
- Refactor Rpc and Topic classes #3. [Lang]
- Refactor classes and reformat some layout #3. [Lang]
- Remove deprecated files #3. [Lang]
- Doc in README.md #3. [Lang]
- Gitignore a js.map file #2. [Lang]
Other ### Changes
~~~~~
- Hg: pkg: new version release !skipChangelog. [Lang] * Add extended data to battle reports and sharing image to make viewable bonus points #5. [Lang]
- Pkg: usr: solve the not-working mailing on dev env under docker #4.
[Lang]
- Deploy version 1.1.0 !deploy #11. [Lang]
1.1.0 (2019-10-26) ## v2026.2.2-9 (2026-04-18)
------------------
Changes ### New
~~~~~~~
- Reinit project - disable redis module and make the project compatible * Add rules page #4. [Lang]
w/ PHP7.3 #2. [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]
0.4.0 (2019-10-26) ## v2026.2.1-8 (2026-04-18)
------------------
- Change session driver to REDIS. [Lang] ### Fix
- Add created, updated field to db && improve graph design. [Lang]
- Cache setup && optimalize for google pagespeed && optimalize all * Quickfix for https-only login - & add user data when the user is not logged in #4. [Lang]
images. [Lang]
- Improve graph design on homepage && add footer and techs && add
official pages. [Lang] ## v2026.2.1-7 (2026-04-16)
- Bugfix mine websocket periodic mysql calling. [Lang]
- Bugfix hwioauth remember me && centralize hwioauth and facebook ### Changes
settings. [Lang]
- Centralize jquery && bugfix mysql auto-termination problem w/ user * Add consent checkbox to user's registration - and fix the sharing pics #4. [Lang]
auth. [Lang]
- Release beta4. [Lang] * Add correct version numbering and CHANGELOG - and add the LICENSE #4. [Lang]
- Gitignore npm debug log. [Lang]
- Add english lang everywhere && add snowfall && add centralized version
nbr && improve stylesheet && slack integration. [Lang] ## v2026.2.1-6 (2026-04-16)
- Bugfix #30 && random bg in game. [Lang]
- Add google analytics and facebook scripts && improve url share method ### Changes
w/ fb && enforce https in prod. [Lang]
- Reg and login buttons on index && remove list method && facebook * Update all texts on all pages - extend them with the game specific things #4. [Lang]
centralize. [Lang]
- Redesign user frontend. [Lang]
- Mods for performance; one js.min file on prod. [Lang] ## v2026.2.1-5 (2026-04-16)
- Improve webpack config for prod compile #23. [Lang]
- Ssl handling #22 && reconnection issues #20, #21. [Lang] ### Fix
- Facebook prod settings w/ app; hwi/HWIOAuthBundle. [Lang]
- Refact && game reconnection and restore w/o refresh #3 && bugfix bomb * The meta tags does not have https scheme - nothing worked in configuration #4. [Lang]
explosion on opponent mines #19. [Lang]
- Typo in rpc. [Lang]
- Handle prod mysql timeout && graphics improve. [Lang] ## v2026.2.1-4 (2026-04-15)
- Gitignore webpacked index.js. [Lang]
- Add production mods. [Lang] ### New
- Bugfix points saving and exploded bombs to db && you can resign #6.
[Lang] * Add notification email when a user is registered #4. [Lang]
- Bugfix resign button existence #11. [Lang]
- Bugfix opponent bomb btn buzz on hover #10. [Lang] ### Changes
- Bugfix points problem in the end #16. [Lang]
- Add desc to every user #9. [Lang] * Add notification on activation too #4. [Lang]
- Clipboard - not working #8. [Lang]
- Random player on start #5. [Lang]
- Show left mines after end #2 && reduce network traffic && better ## v2026.2.1-2 (2026-04-15)
active field checking method. [Lang]
- Some refactor #13. [Lang] ### Fix
- Bugfix grid field render #12. [Lang]
- Game ends after x mines. [Lang] * Another attempt to fix the email assets #4. [Lang]
- Add new sounds && refactor && new bg images && form redesigns. [Lang]
- Bugfix entities gridrow, grid && improve graph design on homepage.
[Lang] ## v2026.2.1-1 (2026-04-15)
- Some refactor && prod settings. [Lang]
- Improve graphics design in game. [Lang] ### Fix
- Bugfix grid row in entity. [Lang]
- Bugfix changePlayer after bomb explosion. [Lang] * The images does not shows in emails #4. [Lang]
- Improve game graph design. [Lang]
- Login and register form more design. [Lang]
- Add basic design to userbundle && refactor. [Lang] ## v2026.2.1-0 (2026-04-15)
- Add font-awesome. [Lang]
- Working user authentication w/ fb and plain login. [Lang] ### New
- Add facebook login module, hwi/HWIOAuthBundle. [Lang]
- Login && register form overrided. [Lang] * Add Contact page with email sending behaviour #4. [Lang]
- Js and config refactor. [Lang]
- Replace gridcol object to json array in db. [Lang] ### Changes
- Refactor. [Lang]
- Save steps and point info to db. [Lang] * Add missing .env variable and increase the version number and add missing data from front-end and back-end deps descriptor #4. [Lang]
- Save the step data to db. [Lang]
- Renamed the acme to mineseeker && handle when the user connection has * Change the shareable battle - add avatars to it - even on the og tags #4. [Lang]
been lost. [Lang]
- Add player names to UI. [Lang] * Change text #4. [Lang]
- Add overlay && game do not start until the opponent came. [Lang]
- Add base64 encryption to grid when it has been sended to server. ### Fix
[Lang]
- On click opponents bomb, you cannot target && refactor. [Lang] * The mailhog is crashed on development env #4. [Lang]
- Warning when player has been found more than 20 mines. [Lang]
- Bugfix center mine counter animation. [Lang] * The og tags did not have proper http schema - they should have https #4. [Lang]
- The opponent is the next when bomb is exploded. [Lang]
- Current username checked && refactor && remove players in channel when
they are more than 2. [Lang] ## v2026.2.0-5 (2026-04-14)
- Send bomb info and use it on opponent. [Lang]
- Add sounds w/ howler. [Lang] ### Changes
- Bugfix multiple empty fields w/ one click on opponent view. [Lang]
- Refact && remove sound and logging && bugfix BIGBUG - handleGridField * Add donation button #4. [Lang]
and showAppropriateFields sort order... [Lang]
- Create first working communication. [Lang]
- Create entities and repositories. [Lang] ## v2026.2.0-4 (2026-04-14)
- Changed websocket default port && debug RPC. [Lang]
- Created working session and client handler w/ websocket. [Lang] ### New
- Working websocket client and server w/o session handling and storage.
[Lang] * Add timer for the acceptance of the challenge #4. [Lang]
- Composer update. [Lang]
- Improve game && start sound creating. [Lang] ### Changes
- Refactor grid control and grid field. [Lang]
- Created basic game w/ table and animations. [Lang] * Protect the gameplay with recaptcha #4. [Lang]
- Websocket basic setup FE & BE && working basic game w/ react &&
webpack & babel config. [Lang] * The waiting dialog is uncloseable until the time is up #4. [Lang]
- Gitignore node_modules && add symlink to node_modules (just for
install) && basic react. [Lang] * Add share button to the overlay when the game ends #4. [Lang]
- Add react hello world. [Lang]
- Rename project in config. [Lang] * Make fancy og tags - and create a special one for battle sharing #4. [Lang]
- Initial commit && create project in symfony3. [Lang]
### Fix
* Missing font-awesome icons on bare-metal environment #4. [Lang]
## v2026.2.0-3 (2026-04-14)
### Changes
* The user's avatar will be saved as a uuid.extension #4. [Lang]
## v2026.2.0-1 (2026-04-14)
### Fix
* Quickfix for email sending #4. [Lang]
## v2026.2.0-0 (2026-04-14)
### New
* Registered users have avatars next to the timer #4. [Lang]
* Add opportunity to use profile picture. #4. [Lang]
* Add more stats and a dialog for the recent battle that can be shareable #4. [Lang]
* Implement the 2FA authentication (TOTP and backup codes) #4. [Lang]
* Add beta logo to the corner #3. [Lang]
* Add mineseeker game to the symfony 4 project #3. [Lang]
* Upgrade to the latest symfony v4 #3. [Lang]
### Changes
* Implement CD script to Gitea and add docs to the process #4. [Lang]
* Remove unnecessary cdn based fonts #4. [Lang]
* Update docs #4. [Lang]
* Add JWT generation script to make Mercure safe #4. [Lang]
* Fix missing favicon #4. [Lang]
* Make compatible the whole project with bare metal AND with docker #4. [Lang]
* Add modern Webauthn authentication #4. [Lang]
* Refactor all forms to have Symfony Form Types & Validation Constrainsts - & implement Google ReCapthca v3 #4. [Lang]
* Add forgot password functionality #4. [Lang]
* Increase the minimum PHP version to the latest major - and massive refactor on back-end, like Controllers and Repositories #4. [Lang]
* Redesign the resign dialog #4. [Lang]
* Re-implement the waiting for opponent dialog - refactor its gfx - & add online user selection dialog #4. [Lang]
* Improve the gfx on homepage - implement login/register and activation for authentication - and add the first version of profile page #4. [Lang]
* Refactor and redesign the gfx on front-end #4. [Lang]
* Upgrade to the latest LTS Symfony package and backend #4. [Lang]
* Add timers to each player - renew the whole migration #4. [Lang]
* Update the vite related stuff because CORS and React errors - reinit the miration #4. [Lang]
* Use namespaces for front-end #4. [Lang]
* Replace webpack w/ vite & remove old, legacy jQuery from the code #4. [Lang]
* More, massive refactor for front-end #4. [Lang]
* Massive refactor on front-end - and remove unnecessary deps #4. [Lang]
* Change the code style to fit the current standard #4. [Lang]
* Refactor to use Attributes instead of yaml markdown #4. [Lang]
* Outsource the Grid generation and interactions to the backend #4. [Lang]
* Remove unnecessary variables and prune the Facebook registration method #4. [Lang]
* Replace the legacy gos/web-socket-bundle & replace it with Mercure protocol #4. [Lang]
* Make a massive refactor to the backend and remove all unnecessary deps - and make small refactors for the frontend too #4. [Lang]
* Created the first working solution since 7 yrs #4. [Lang]
* Add some changes on BE - add eslint and editorconfig - and add some deps #4. [Lang]
* Make the first working version - the stepping is broken due to the algorythm structure #4. [Lang]
* Change the composer default php minimum environment #3. [Lang]
* Change the default url to wss on frontend #3. [Lang]
* Refactor Rpc and Topic classes #3. [Lang]
* Refactor classes and reformat some layout #3. [Lang]
* Remove deprecated files #3. [Lang]
* Doc in README.md #3. [Lang]
* Gitignore a js.map file #2. [Lang]
### Other
* Pkg: usr: solve the not-working mailing on dev env under docker #4. [Lang]
## 1.1.0 (2019-10-26)
### Changes
* Reinit project - disable redis module and make the project compatible w/ PHP7.3 #2. [Lang]
## 0.4.0 (2019-10-26)
### Other
* Change session driver to REDIS. [Lang]
* Add created, updated field to db && improve graph design. [Lang]
* Cache setup && optimalize for google pagespeed && optimalize all images. [Lang]
* Improve graph design on homepage && add footer and techs && add official pages. [Lang]
* Bugfix mine websocket periodic mysql calling. [Lang]
* Bugfix hwioauth remember me && centralize hwioauth and facebook settings. [Lang]
* Centralize jquery && bugfix mysql auto-termination problem w/ user auth. [Lang]
* Release beta4. [Lang]
* Gitignore npm debug log. [Lang]
* Add english lang everywhere && add snowfall && add centralized version nbr && improve stylesheet && slack integration. [Lang]
* Bugfix #30 && random bg in game. [Lang]
* Add google analytics and facebook scripts && improve url share method w/ fb && enforce https in prod. [Lang]
* Reg and login buttons on index && remove list method && facebook centralize. [Lang]
* Redesign user frontend. [Lang]
* Mods for performance; one js.min file on prod. [Lang]
* Improve webpack config for prod compile #23. [Lang]
* Ssl handling #22 && reconnection issues #20, #21. [Lang]
* Facebook prod settings w/ app; hwi/HWIOAuthBundle. [Lang]
* Refact && game reconnection and restore w/o refresh #3 && bugfix bomb explosion on opponent mines #19. [Lang]
* Typo in rpc. [Lang]
* Handle prod mysql timeout && graphics improve. [Lang]
* Gitignore webpacked index.js. [Lang]
* Add production mods. [Lang]
* Bugfix points saving and exploded bombs to db && you can resign #6. [Lang]
* Bugfix resign button existence #11. [Lang]
* Bugfix opponent bomb btn buzz on hover #10. [Lang]
* Bugfix points problem in the end #16. [Lang]
* Add desc to every user #9. [Lang]
* Clipboard - not working #8. [Lang]
* Random player on start #5. [Lang]
* Show left mines after end #2 && reduce network traffic && better active field checking method. [Lang]
* Some refactor #13. [Lang]
* Bugfix grid field render #12. [Lang]
* Game ends after x mines. [Lang]
* Add new sounds && refactor && new bg images && form redesigns. [Lang]
* Bugfix entities gridrow, grid && improve graph design on homepage. [Lang]
* Some refactor && prod settings. [Lang]
* Improve graphics design in game. [Lang]
* Bugfix grid row in entity. [Lang]
* Bugfix changePlayer after bomb explosion. [Lang]
* Improve game graph design. [Lang]
* Login and register form more design. [Lang]
* Add basic design to userbundle && refactor. [Lang]
* Add font-awesome. [Lang]
* Working user authentication w/ fb and plain login. [Lang]
* Add facebook login module, hwi/HWIOAuthBundle. [Lang]
* Login && register form overrided. [Lang]
* Js and config refactor. [Lang]
* Replace gridcol object to json array in db. [Lang]
* Refactor. [Lang]
* Save steps and point info to db. [Lang]
* Save the step data to db. [Lang]
* Renamed the acme to mineseeker && handle when the user connection has been lost. [Lang]
* Add player names to UI. [Lang]
* Add overlay && game do not start until the opponent came. [Lang]
* Add base64 encryption to grid when it has been sended to server. [Lang]
* On click opponents bomb, you cannot target && refactor. [Lang]
* Warning when player has been found more than 20 mines. [Lang]
* Bugfix center mine counter animation. [Lang]
* The opponent is the next when bomb is exploded. [Lang]
* Current username checked && refactor && remove players in channel when they are more than 2. [Lang]
* Send bomb info and use it on opponent. [Lang]
* Add sounds w/ howler. [Lang]
* Bugfix multiple empty fields w/ one click on opponent view. [Lang]
* Refact && remove sound and logging && bugfix BIGBUG - handleGridField and showAppropriateFields sort order... [Lang]
* Create first working communication. [Lang]
* Create entities and repositories. [Lang]
* Changed websocket default port && debug RPC. [Lang]
* Created working session and client handler w/ websocket. [Lang]
* Working websocket client and server w/o session handling and storage. [Lang]
* Composer update. [Lang]
* Improve game && start sound creating. [Lang]
* Refactor grid control and grid field. [Lang]
* Created basic game w/ table and animations. [Lang]
* Websocket basic setup FE & BE && working basic game w/ react && webpack & babel config. [Lang]
* Gitignore node_modules && add symlink to node_modules (just for install) && basic react. [Lang]
* Add react hello world. [Lang]
* Rename project in config. [Lang]
* Initial commit && create project in symfony3. [Lang]

View File

@@ -13,6 +13,10 @@
encode zstd br gzip encode zstd br gzip
# Forward scheme information to the PHP application
header X-Forwarded-Proto {scheme}
header X-Forwarded-Host {host}
mercure { mercure {
transport_url {$MERCURE_TRANSPORT_URL:bolt:///data/mercure.db} transport_url {$MERCURE_TRANSPORT_URL:bolt:///data/mercure.db}
publisher_jwt {$MERCURE_JWT_SECRET} HS256 publisher_jwt {$MERCURE_JWT_SECRET} HS256

View File

@@ -22,6 +22,12 @@ RUN install-php-extensions \
apcu \ apcu \
sodium sodium
RUN apt-get update && apt-get install -y --no-install-recommends \
fonts-dejavu-core \
fontconfig \
&& fc-cache -f -v \
&& rm -rf /var/lib/apt/lists/*
RUN mv "$PHP_INI_DIR/php.ini-production" "$PHP_INI_DIR/php.ini" RUN mv "$PHP_INI_DIR/php.ini-production" "$PHP_INI_DIR/php.ini"
RUN printf '[opcache]\nopcache.enable=1\nopcache.memory_consumption=256\nopcache.max_accelerated_files=20000\nopcache.validate_timestamps=0\n' \ RUN printf '[opcache]\nopcache.enable=1\nopcache.memory_consumption=256\nopcache.max_accelerated_files=20000\nopcache.validate_timestamps=0\n' \
> "$PHP_INI_DIR/conf.d/opcache.ini" > "$PHP_INI_DIR/conf.d/opcache.ini"

124
LICENSE Normal file
View File

@@ -0,0 +1,124 @@
GNU GENERAL PUBLIC LICENSE
Version 3, 29 June 2007
Copyright (C) 2026 SplendidBear (https://www.splendidbear.org)
MineSeeker is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
MineSeeker is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
---
TERMS AND CONDITIONS:
0. Definitions.
"This License" refers to version 3 of the GNU General Public License.
"Copyright" also means copyright-like laws that apply to other kinds of works.
"The Program" refers to any copyrightable work licensed under this License.
"You" refers to each licensee.
"Legal entities" means the union of the acting entity and all other entities
that control, are controlled by, or are under common control with that entity.
"Modify" means to copy from or adapt all or part of the work in a fashion
requiring copyright permission, other than the making of an exact copy.
1. Source Code.
The "source code" for a work means the preferred form of the work for making
modifications to it. "Object code" means any non-source form of a work.
2. Basic Permissions.
All rights granted under this License are granted for the term of copyright
on the Program. You are granted all permissions necessary to run, modify and
propagate covered works by this License.
3. Copyleft - Derivative Works.
If you modify the Program, your modified version must:
- Carry prominent notices stating that you have modified it
- License the entire work under this License or a compatible license
- Make the source code available to recipients
- Preserve all notices of previous licensing
4. Conveying Verbatim Copies.
You may convey verbatim copies of the Program's source code as you receive it,
provided that you:
- Keep intact all notices of authorship and licensing
- Give recipients access to the source code along with this License
- Do not modify anything except the License itself
5. Conveying Modified Source Versions.
You may convey a work based on the Program under this License provided that:
- The work must be licensed as a whole under this License
- You must give prominent notice of any modifications
- You must provide access to the Corresponding Source code
- You preserve all licensing notices
6. Conveying Non-Source Forms.
If you convey object code or compiled versions, you must also provide:
- The Corresponding Source code (in machine-readable form)
- A notice of the terms under which it is licensed
7. Additional Terms.
No additional restrictions may be placed on the exercise of the rights
granted or affirmed under this License.
8. Disclaimer of Warranty.
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
9. Limitation of Liability.
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING
ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF
THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS
OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR
THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
SUCH DAMAGES.
---
For the complete GPL-3.0-or-later license text, visit:
https://www.gnu.org/licenses/gpl-3.0.html
For more information about GNU GPL, visit:
https://www.gnu.org/licenses/
MineSeeker is a multiplayer minesweeper game inspired by MSN Messenger's game.
Project: https://www.mineseeker.hu
Author: SplendidBear (https://www.splendidbear.org)

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

@@ -151,6 +151,7 @@ services:
app: app:
environment: environment:
MAILER_DSN: smtp://mail:1025?verify_peer=0 MAILER_DSN: smtp://mail:1025?verify_peer=0
TRUSTED_PROXIES: "0.0.0.0/0"
mail: mail:
image: mailhog/mailhog:latest image: mailhog/mailhog:latest
ports: ports:
@@ -216,7 +217,7 @@ POSTGRES_VERSION=18
MINIO_ROOT_USER=mineseeker MINIO_ROOT_USER=mineseeker
MINIO_ROOT_PASSWORD="<strong password>" MINIO_ROOT_PASSWORD="<strong password>"
MINIO_ENDPOINT=http://minio:9000 MINIO_ENDPOINT=http://minio:9000
MINIO_PUBLIC_URL=https://minio.mineseeker.hu MINIO_PUBLIC_URL=https://aws.mineseeker.hu
MAILER_DSN=smtp://mail:25?verify_peer=0 MAILER_DSN=smtp://mail:25?verify_peer=0
MAIL_DOMAIN=mineseeker.hu MAIL_DOMAIN=mineseeker.hu
@@ -233,8 +234,13 @@ MERCURE_SUBSCRIBER_JWT="<generated by make mercure-jwt>"
APP_PUBLIC_HOSTNAME=mineseeker.hu APP_PUBLIC_HOSTNAME=mineseeker.hu
WEBAUTHN_RP_ID=mineseeker.hu WEBAUTHN_RP_ID=mineseeker.hu
WEBAUTHN_ORIGIN=https://mineseeker.hu WEBAUTHN_ORIGIN=https://mineseeker.hu
```
# OG Tags & Social Media Sharing (IMPORTANT for Docker deployments)
# TRUSTED_PROXIES: IP address (or range) of your reverse proxy (Caddy/Nginx)
# This ensures OG image tags use HTTPS URLs instead of HTTP
TRUSTED_PROXIES="172.18.0.0/16"
TRUSTED_HOSTS="mineseeker.hu,www.mineseeker.hu"
```
### Production server: one-time setup ### Production server: one-time setup
The server needs Docker, Git, and a self-hosted `act_runner` registered against the Gitea repository. Bun and Composer run inside the multi-stage Dockerfile, so they are not needed on the server. The server needs Docker, Git, and a self-hosted `act_runner` registered against the Gitea repository. Bun and Composer run inside the multi-stage Dockerfile, so they are not needed on the server.
@@ -254,7 +260,7 @@ make mercure-jwt
Copy the three printed values into the `PROD_ENV_FILE` secret. Copy the three printed values into the `PROD_ENV_FILE` secret.
#### 5. First deploy #### 3. First deploy
Trigger it by pushing the first tag: Trigger it by pushing the first tag:
@@ -265,7 +271,7 @@ git push origin v2026.01
This writes `.env`, builds the Docker image, starts all services, runs migrations, and initialises the MinIO buckets automatically via `minio_init`. This writes `.env`, builds the Docker image, starts all services, runs migrations, and initialises the MinIO buckets automatically via `minio_init`.
#### 6. Verify #### 4. Verify
```bash ```bash
docker compose ps # all services should be healthy/running docker compose ps # all services should be healthy/running
@@ -281,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: "/webfonts"; @import '@fortawesome/fontawesome-free/css/all.min.css';
@import '@fortawesome/fontawesome-free/scss/fontawesome';
@import '@fortawesome/fontawesome-free/scss/brands';
@import '@fortawesome/fontawesome-free/scss/solid';
@import '@fortawesome/fontawesome-free/scss/regular';

View File

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

View File

@@ -180,6 +180,41 @@
input[type="checkbox"] { accent-color: #236f87; } input[type="checkbox"] { accent-color: #236f87; }
} }
.auth-checkbox {
accent-color: #236f87;
cursor: pointer;
width: 18px;
height: 18px;
flex-shrink: 0;
}
.auth-checkbox-label {
display: flex;
align-items: flex-start;
cursor: pointer;
user-select: none;
font: 400 14px 'Rajdhani', sans-serif;
color: rgba(255, 255, 255, 0.6);
line-height: 1.5;
a {
color: #95cff5;
text-decoration: none;
font-weight: 600;
transition: color 180ms;
&:hover { color: #c5e8ff; }
}
}
textarea.auth-input {
padding: 11px 14px;
min-height: 120px;
resize: vertical;
font-family: 'Rajdhani', sans-serif;
line-height: 1.5;
}
.auth-submit { .auth-submit {
display: flex; display: flex;
align-items: center; align-items: center;

View File

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

View File

@@ -0,0 +1,72 @@
/*!*
* 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.
*/
.hero-donate-text {
position: relative;
z-index: 1;
font: 400 12px 'Rajdhani', sans-serif;
color: rgba(255, 255, 255, 0.5);
letter-spacing: 0.5px;
margin-top: 42px;
margin-bottom: 8px;
animation: rise 0.8s 0.5s cubic-bezier(0.22, 1, 0.36, 1) both;
}
.hero-donate {
position: relative;
display: inline-block;
font: 500 12px 'Rajdhani', sans-serif;
text-transform: uppercase;
letter-spacing: 1px;
text-decoration: none;
color: rgba(255, 184, 82, 0.75);
padding: 6px 14px;
border-radius: 2px;
border: 1px solid rgba(255, 184, 82, 0.25);
background: rgba(255, 140, 30, 0.05);
box-shadow:
0 0 0 1px rgba(255, 140, 30, 0.1),
0 0 8px rgba(255, 140, 30, 0.08);
transition: transform 200ms ease, box-shadow 200ms ease, color 200ms ease, background 200ms ease;
animation: rise 0.8s 0.58s cubic-bezier(0.22, 1, 0.36, 1) both;
}
.hero-donate::before {
content: '';
position: absolute;
inset: -2px;
border-radius: 3px;
background: rgba(255, 140, 30, 0.15);
filter: blur(8px);
opacity: 0.1;
z-index: -1;
transition: opacity 200ms ease;
}
.hero-donate:hover {
transform: translateY(-1px);
color: rgba(255, 200, 100, 0.9);
background: rgba(255, 140, 30, 0.1);
box-shadow:
0 0 0 1px rgba(255, 140, 30, 0.2),
0 0 12px rgba(255, 140, 30, 0.15);
}
.hero-donate:hover::before {
opacity: 0.2;
}
.hero-donate:active {
transform: translateY(0px);
}

View File

@@ -45,33 +45,33 @@
} }
// Bar chart — large, centre // Bar chart — large, centre
i.fa-bar-chart { i.fa-chart-bar {
font-size: 140px; font-size: 160px;
color: rgba(35, 111, 135, 0.35); color: rgba(35, 111, 135, 0.5);
filter: drop-shadow(0 0 30px rgba(35, 111, 135, 0.3)); filter: drop-shadow(0 0 40px rgba(35, 111, 135, 0.5));
} }
// Trophy — top right // Trophy — top right
i.fa-trophy { i.fa-trophy {
font-size: 64px; font-size: 80px;
top: 12px; top: 0px;
right: 30px; right: 20px;
color: rgba(246, 125, 82, 0.5); color: rgba(246, 125, 82, 0.7);
filter: drop-shadow(0 0 16px rgba(246, 125, 82, 0.25)); filter: drop-shadow(0 0 25px rgba(246, 125, 82, 0.4));
} }
// History — bottom left // Clock history — bottom left
i.fa-history { i.fa-clock-rotate-left {
font-size: 52px; font-size: 68px;
bottom: 18px; bottom: 0px;
left: 30px; left: 20px;
color: rgba(149, 207, 245, 0.4); color: rgba(149, 207, 245, 0.65);
filter: drop-shadow(0 0 12px rgba(149, 207, 245, 0.2)); filter: drop-shadow(0 0 20px rgba(149, 207, 245, 0.35));
} }
&:hover i.fa-bar-chart { color: rgba(35, 111, 135, 0.6); } &:hover i.fa-chart-bar { color: rgba(35, 111, 135, 0.8); filter: drop-shadow(0 0 50px rgba(35, 111, 135, 0.7)); }
&:hover i.fa-trophy { color: rgba(246, 125, 82, 0.75); } &:hover i.fa-trophy { color: rgba(246, 125, 82, 0.9); filter: drop-shadow(0 0 35px rgba(246, 125, 82, 0.6)); }
&:hover i.fa-history { color: rgba(149, 207, 245, 0.65); } &:hover i.fa-clock-rotate-left { color: rgba(149, 207, 245, 0.9); filter: drop-shadow(0 0 30px rgba(149, 207, 245, 0.5)); }
} }
// MSN visual // MSN visual
@@ -107,6 +107,98 @@
} }
} }
// Privacy visual
.feature-block__visual--privacy {
height: 260px;
gap: 0;
i {
position: absolute;
color: rgba(35, 111, 135, 0.5);
transition: color 300ms ease;
}
// Shield — centre, large
i.fa-shield {
font-size: 140px;
color: rgba(34, 197, 94, 0.3);
filter: drop-shadow(0 0 30px rgba(34, 197, 94, 0.25));
}
// Lock — top right
i.fa-lock {
font-size: 56px;
top: 20px;
right: 35px;
color: rgba(168, 85, 247, 0.5);
filter: drop-shadow(0 0 16px rgba(168, 85, 247, 0.2));
}
// Eye slash — bottom left
i.fa-eye-slash {
font-size: 48px;
bottom: 28px;
left: 40px;
color: rgba(59, 130, 246, 0.5);
filter: drop-shadow(0 0 12px rgba(59, 130, 246, 0.15));
}
&:hover i.fa-shield { color: rgba(34, 197, 94, 0.6); }
&:hover i.fa-lock { color: rgba(168, 85, 247, 0.75); }
&:hover i.fa-eye-slash { color: rgba(59, 130, 246, 0.7); }
}
// Practice visual
.feature-block__visual--practice {
height: 260px;
gap: 0;
i {
position: absolute;
color: rgba(35, 111, 135, 0.5);
transition: color 300ms ease;
}
// Laptop — centre, large
i.fa-laptop {
font-size: 140px;
color: rgba(251, 146, 60, 0.3);
filter: drop-shadow(0 0 30px rgba(251, 146, 60, 0.25));
}
// Linux — top left
i.fa-linux {
font-size: 48px;
top: 20px;
left: 35px;
color: rgba(245, 158, 11, 0.5);
filter: drop-shadow(0 0 16px rgba(245, 158, 11, 0.2));
}
// Apple — top right
i.fa-apple {
font-size: 56px;
top: 20px;
right: 35px;
color: rgba(156, 163, 175, 0.5);
filter: drop-shadow(0 0 16px rgba(156, 163, 175, 0.2));
}
// Windows — bottom left
i.fa-windows {
font-size: 48px;
bottom: 28px;
left: 40px;
color: rgba(59, 130, 246, 0.5);
filter: drop-shadow(0 0 12px rgba(59, 130, 246, 0.15));
}
&:hover i.fa-laptop { color: rgba(251, 146, 60, 0.6); }
&:hover i.fa-linux { color: rgba(245, 158, 11, 0.75); }
&:hover i.fa-apple { color: rgba(156, 163, 175, 0.75); }
&:hover i.fa-windows { color: rgba(59, 130, 246, 0.7); }
}
// Text side // Text side
.feature-block__text { .feature-block__text {
flex: 1; flex: 1;
@@ -161,6 +253,56 @@
} }
} }
.practice-links {
display: flex;
flex-direction: column;
gap: 12px;
margin-top: 24px;
}
.practice-link {
display: inline-flex;
align-items: center;
gap: 12px;
font: 700 13px 'Rajdhani', sans-serif;
text-transform: uppercase;
letter-spacing: 1px;
text-decoration: none;
color: rgba(149, 207, 245, 0.85);
border: 1px solid rgba(59, 130, 246, 0.3);
background: rgba(59, 130, 246, 0.08);
padding: 10px 18px;
border-radius: 4px;
transition: all 200ms ease;
width: fit-content;
&:hover {
background: rgba(59, 130, 246, 0.2);
border-color: rgba(59, 130, 246, 0.6);
color: #fff;
transform: translateX(4px);
box-shadow: 0 4px 12px rgba(59, 130, 246, 0.2);
}
}
.practice-link-icon {
width: 40px;
height: 40px;
border-radius: 8px;
object-fit: contain;
background: rgba(255, 255, 255, 0.08);
padding: 4px;
border: 1px solid rgba(255, 255, 255, 0.1);
flex-shrink: 0;
transition: all 200ms ease;
}
.practice-link:hover .practice-link-icon {
background: rgba(59, 130, 246, 0.15);
border-color: rgba(59, 130, 246, 0.4);
transform: scale(1.05);
}
@media screen and (max-width: 900px) { @media screen and (max-width: 900px) {
.feature-block__inner, .feature-block__inner,
.feature-block--reverse .feature-block__inner { .feature-block--reverse .feature-block__inner {
@@ -199,4 +341,14 @@
.feature-block { .feature-block {
padding: 60px 24px; padding: 60px 24px;
} }
}
.practice-links {
justify-content: center;
align-items: center;
}
.practice-link {
width: 100%;
justify-content: center;
}
}

View File

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

View File

@@ -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);
@@ -747,6 +899,13 @@
font: 800 24px 'Rajdhani', sans-serif; font: 800 24px 'Rajdhani', sans-serif;
letter-spacing: 2px; letter-spacing: 2px;
&__img {
width: 100%;
height: 100%;
border-radius: 50%;
object-fit: cover;
}
&--red { &--red {
background: linear-gradient(135deg, rgba(173, 10, 5, 0.6) 0%, rgba(246, 125, 82, 0.4) 100%); background: linear-gradient(135deg, rgba(173, 10, 5, 0.6) 0%, rgba(246, 125, 82, 0.4) 100%);
border: 2px solid rgba(173, 10, 5, 0.5); border: 2px solid rgba(173, 10, 5, 0.5);
@@ -852,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;
@@ -97,4 +101,4 @@
font-size: 20px; font-size: 20px;
letter-spacing: 4px; letter-spacing: 4px;
} }
} }

View File

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

View File

@@ -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%;
} }
@@ -206,4 +204,4 @@
#mine-wrapper .grid .field-wrapper .field img { #mine-wrapper .grid .field-wrapper .field img {
width: 80%; width: 80%;
} }

View File

@@ -21,21 +21,23 @@
} }
#mine-wrapper .game-wrapper .game-overlay .game-overlay-window { #mine-wrapper .game-wrapper .game-overlay .game-overlay-window {
background: linear-gradient(135deg, rgba(7, 9, 13, 0.98) 0%, rgba(10, 20, 35, 0.98) 100%); background: linear-gradient(135deg, rgba(7, 9, 13, 0.98) 0%, rgba(10, 20, 35, 0.98) 100%);
border: 2px solid rgba(35, 111, 135, 0.4); border: 2px solid rgba(35, 111, 135, 0.4);
backdrop-filter: blur(12px); backdrop-filter: blur(12px);
font-family: 'Rajdhani', sans-serif; font-family: 'Rajdhani', sans-serif;
color: #fff; color: #fff;
width: 100%; width: 100%;
max-width: 680px; max-width: 680px;
padding: 40px; padding: 40px;
border-radius: 16px; border-radius: 16px;
box-shadow: 0 25px 50px rgba(0, 0, 0, 0.7), 0 0 40px rgba(35, 111, 135, 0.15); box-shadow: 0 25px 50px rgba(0, 0, 0, 0.7), 0 0 40px rgba(35, 111, 135, 0.15);
display: flex; display: flex;
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 {
from { from {
@@ -49,12 +51,17 @@
} }
#mine-wrapper .game-wrapper .game-overlay .game-overlay-window h1 { #mine-wrapper .game-wrapper .game-overlay .game-overlay-window h1 {
font-weight: 800; font-weight: 800;
font-size: 32px; font-size: 32px;
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 {
font-size: 14px; font-size: 14px;
@@ -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;
@@ -259,12 +270,17 @@
} }
.waiting-option-desc { .waiting-option-desc {
font: 600 12px 'Rajdhani', sans-serif; font: 600 12px 'Rajdhani', sans-serif;
color: rgba(149, 207, 245, 0.75); color: rgba(149, 207, 245, 0.75);
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 {
display: flex; display: flex;
@@ -471,58 +487,192 @@
} }
#mine-wrapper .game-wrapper .game-overlay .game-overlay-window .share-copy-btn { #mine-wrapper .game-wrapper .game-overlay .game-overlay-window .share-copy-btn {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
gap: 9px; gap: 9px;
background: linear-gradient(to bottom, #236f87 0%, #1a5068 100%); background: linear-gradient(to bottom, #236f87 0%, #1a5068 100%);
border: 2px solid #2e7a9a; border: 2px solid #2e7a9a;
color: #e0f4ff; color: #e0f4ff;
font-family: 'Rajdhani', sans-serif; font-family: 'Rajdhani', sans-serif;
font-size: 13px; font-size: 13px;
font-weight: 800; font-weight: 800;
letter-spacing: 1px; letter-spacing: 1px;
text-transform: uppercase; text-transform: uppercase;
padding: 12px 24px; padding: 12px 24px;
border-radius: 8px; border-radius: 8px;
cursor: pointer; cursor: pointer;
transition: all 300ms cubic-bezier(0.34, 1.56, 0.64, 1); transition: all 300ms cubic-bezier(0.34, 1.56, 0.64, 1);
width: 100%; width: 100%;
position: relative; position: relative;
overflow: hidden; overflow: hidden;
box-shadow: 0 4px 12px rgba(35, 111, 135, 0.25); box-shadow: 0 4px 12px rgba(35, 111, 135, 0.25);
&::before { &::before {
content: ''; content: '';
position: absolute; position: absolute;
top: 0; top: 0;
left: -100%; left: -100%;
width: 100%; width: 100%;
height: 100%; height: 100%;
background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.2), transparent); background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.2), transparent);
transition: left 0.4s ease; transition: left 0.4s ease;
} }
&:hover { &:hover {
background: linear-gradient(to bottom, #2d8aa8 0%, #236f87 100%); background: linear-gradient(to bottom, #2d8aa8 0%, #236f87 100%);
border-color: #5ba4d4; border-color: #5ba4d4;
color: #fff; color: #fff;
box-shadow: 0 8px 24px rgba(35, 111, 135, 0.4); box-shadow: 0 8px 24px rgba(35, 111, 135, 0.4);
transform: translateY(-2px); transform: translateY(-2px);
&::before { &::before {
left: 100%; left: 100%;
} }
} }
&:active { &:active {
transform: translateY(0); transform: translateY(0);
} }
&.copied { &.copied {
background: linear-gradient(to bottom, #1a6844 0%, #135233 100%); background: linear-gradient(to bottom, #1a6844 0%, #135233 100%);
border-color: #2a9e60; border-color: #2a9e60;
color: #a0f0c0; color: #a0f0c0;
box-shadow: 0 4px 12px rgba(26, 104, 68, 0.4); box-shadow: 0 4px 12px rgba(26, 104, 68, 0.4);
} }
} }
#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 {
display: flex;
align-items: center;
justify-content: center;
gap: 9px;
background: linear-gradient(to bottom, #236f87 0%, #1a5068 100%);
border: 2px solid #2e7a9a;
color: #e0f4ff;
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(35, 111, 135, 0.25);
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, #2d8aa8 0%, #236f87 100%);
border-color: #5ba4d4;
color: #fff;
box-shadow: 0 8px 24px rgba(35, 111, 135, 0.4);
transform: translateY(-2px);
&::before {
left: 100%;
}
}
&:active {
transform: translateY(0);
}
&.copied {
background: linear-gradient(to bottom, #1a6844 0%, #135233 100%);
border-color: #2a9e60;
color: #a0f0c0;
box-shadow: 0 4px 12px rgba(26, 104, 68, 0.4);
}
i {
font-size: 15px;
}
}
#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

@@ -100,16 +100,18 @@
} }
#mine-wrapper .game-wrapper .users .user-container .user-name { #mine-wrapper .game-wrapper .users .user-container .user-name {
min-height: 30px; min-height: 30px;
font-weight: normal; font-weight: normal;
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 {
border-top: 1px dashed #0b3776; border-top: 1px dashed #0b3776;
@@ -139,10 +141,17 @@
} }
#mine-wrapper .game-wrapper .users .user-container .user-desc { #mine-wrapper .game-wrapper .users .user-container .user-desc {
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 {
color: #0b3776; color: #0b3776;
@@ -150,4 +159,4 @@
#mine-wrapper .game-wrapper .users .user-container.user-red .user-desc { #mine-wrapper .game-wrapper .users .user-container.user-red .user-desc {
color: #fdf612; color: #fdf612;
} }

View File

@@ -305,3 +305,43 @@
padding-top: 14px; padding-top: 14px;
border-top: 1px solid rgba(35, 111, 135, 0.14); border-top: 1px solid rgba(35, 111, 135, 0.14);
} }
.opd-header-actions {
.opd-refresh[disabled] {
opacity: 0.4;
cursor: not-allowed;
}
.opd-close[disabled] {
opacity: 0.4;
cursor: not-allowed;
}
}
.opd-waiting {
display: flex;
align-items: center;
gap: 10px;
padding: 14px 16px;
margin-bottom: 16px;
background: rgba(35, 111, 135, 0.07);
border: 1px solid rgba(35, 111, 135, 0.28);
border-radius: 8px;
color: #95cff5;
i {
font-size: 16px;
animation: opd-hourglass 1s ease-in-out infinite;
}
p {
margin: 0;
font: 600 14px 'Rajdhani', sans-serif;
letter-spacing: 0.5px;
}
}
@keyframes opd-hourglass {
0%, 100% { transform: rotate(0deg); }
50% { transform: rotate(180deg); }
}

View File

@@ -4,6 +4,7 @@
@use 'homepage/hero'; @use 'homepage/hero';
@use 'homepage/hero-compact'; @use 'homepage/hero-compact';
@use 'homepage/cta'; @use 'homepage/cta';
@use 'homepage/donate';
@use 'homepage/auth-bar'; @use 'homepage/auth-bar';
@use 'homepage/auth'; @use 'homepage/auth';
@use 'homepage/content'; @use 'homepage/content';

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' } });
@@ -35,7 +38,7 @@ const RESULT_META = {
icon: 'fa-trophy', icon: 'fa-trophy',
}, },
loss: { loss: {
label: 'Defeat', label: 'Defeated',
color: '#f67d52', color: '#f67d52',
bg: 'rgba(173,10,5,0.15)', bg: 'rgba(173,10,5,0.15)',
border: 'rgba(173,10,5,0.4)', border: 'rgba(173,10,5,0.4)',
@@ -50,92 +53,9 @@ const RESULT_META = {
}, },
}; };
function Avatar({ name, color }) {
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: gradient,
border: `2px solid ${border}`,
boxShadow: glow,
display: 'flex', alignItems: 'center', justifyContent: 'center',
font: '800 24px \'Rajdhani\', sans-serif',
color: textColor,
letterSpacing: 2,
}}
>
{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);
const [copied, setCopied] = useState(false); const [copied, setCopied] = useState(false);
useEffect(() => { useEffect(() => {
@@ -156,18 +76,39 @@ export default function BattleDialog({ games }) {
return <ThemeProvider theme={darkTheme}><Dialog open={false} sx={DIALOG_SX} /></ThemeProvider>; return <ThemeProvider theme={darkTheme}><Dialog open={false} sx={DIALOG_SX} /></ThemeProvider>;
} }
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.id}`; 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(() => {
setCopied(true); setCopied(true);
setTimeout(() => setCopied(false), 2200); setTimeout(() => setCopied(false), 2200);
}); });
}; };
return ( return (
@@ -182,28 +123,66 @@ export default function BattleDialog({ games }) {
</h2> </h2>
</div> </div>
<div style={{ display: 'flex', gap: 8 }}> <div style={{ display: 'flex', gap: 8 }}>
<button {canContinue ? (
className={`bd-share${copied ? ' bd-share--copied' : ''}`} <a
onClick={handleShare} className="bd-continue"
aria-label="Copy share link" href={playUrl}
title="Copy share link" aria-label="Continue the game"
> title="Continue the game"
<i className={`fa ${copied ? 'fa-check' : 'fa-share-alt'}`} /> >
{copied ? 'Copied!' : 'Share'} <i className="fa fa-play" />
</button> Continue
</a>
) : (
<button
className={`bd-share${copied ? ' bd-share--copied' : ''}`}
onClick={handleShare}
aria-label="Copy share link"
title="Copy share link"
>
<i className={`fa ${copied ? 'fa-check' : 'fa-share-alt'}`} />
{copied ? 'Copied!' : 'Share'}
</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" /> <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"
@@ -212,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" /> <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
icon="fa-balance-scale" label="Winning margin"
value={`${pointDiff} mine${1 === pointDiff ? '' : 's'}`} valueColor={winnerColor}
/>
)}
<StatRow <StatRow
icon="fa-bomb" label="Red hit a mine" 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

@@ -0,0 +1,83 @@
/**
* 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 { useEffect, useRef } from 'react';
/**
* ContactForm Component
*
* Handles reCAPTCHA v3 integration for the contact form.
* Intercepts form submission, executes reCAPTCHA, and submits the form with the token.
*
* @param {string} siteKey - Google reCAPTCHA site key
* @param {string} recaptchaFieldId - ID of the hidden recaptcha input field
*/
const ContactForm = ({ siteKey, recaptchaFieldId }) => {
const formRef = useRef(null);
const isSubmittingRef = useRef(false);
useEffect(() => {
const form = document.querySelector('.auth-form');
if (!form) {
console.warn('ContactForm: No .auth-form found');
return;
}
formRef.current = form;
const handleSubmit = e => {
e.preventDefault();
if (isSubmittingRef.current) {
return;
}
isSubmittingRef.current = true;
if ('undefined' !== typeof grecaptcha) {
grecaptcha.ready(() => {
grecaptcha
.execute(siteKey, { action: 'contact' })
.then(token => {
const recaptchaField = document.getElementById(recaptchaFieldId);
if (recaptchaField) {
recaptchaField.value = token;
} else {
console.error(`ContactForm: Recaptcha field with ID "${recaptchaFieldId}" not found`);
}
isSubmittingRef.current = false;
form.submit();
})
.catch(error => {
console.error('ContactForm: reCAPTCHA execution failed', error);
isSubmittingRef.current = false;
});
});
} else {
console.error('ContactForm: grecaptcha is not loaded');
isSubmittingRef.current = false;
}
};
form.addEventListener('submit', handleSubmit);
return () => {
if (formRef.current) {
formRef.current.removeEventListener('submit', handleSubmit);
}
};
}, [siteKey, recaptchaFieldId]);
return null;
};
export default ContactForm;

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

31
assets/js/contact.jsx Normal file
View File

@@ -0,0 +1,31 @@
/**
* 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 { createRoot } from 'react-dom/client';
import ContactForm from './components/ContactForm';
const wrapper = document.getElementById('contact-form-wrapper');
if (wrapper) {
const siteKey = wrapper.dataset.siteKey;
const recaptchaFieldId = wrapper.dataset.recaptchaFieldId;
if (siteKey && recaptchaFieldId) {
createRoot(wrapper).render(
<ContactForm
siteKey={siteKey}
recaptchaFieldId={recaptchaFieldId}
/>
);
} else {
console.error('ContactForm: Missing siteKey or recaptchaFieldId in data attributes');
}
}

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

@@ -0,0 +1,169 @@
/**
* 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, { useEffect, useState } from 'react';
const CAPTCHA_STORAGE_KEY = 'mineseeker_captcha_verified';
const CAPTCHA_TOKEN_KEY = 'mineseeker_captcha_token';
const RECAPTCHA_ACTION = 'mineseeker_play';
const CaptchaOverlay = ({ siteKey, onVerified, children }) => {
const [verified, setVerified] = useState(false);
const [loading, setLoading] = useState(false);
const [error, setError] = useState(false);
useEffect(() => {
const storedToken = sessionStorage.getItem(CAPTCHA_TOKEN_KEY);
const storedTime = sessionStorage.getItem(CAPTCHA_STORAGE_KEY);
if (storedToken && storedTime) {
const elapsed = (Date.now() - parseInt(storedTime)) / 1000;
if (110 > elapsed) {
const wrapper = document.getElementById('mine-wrapper');
if (wrapper) {
wrapper.dataset.captchaToken = storedToken;
}
setVerified(true);
onVerified?.();
return;
}
}
if (window.grecaptcha) {
window.grecaptcha.ready(() => {
window.grecaptcha
.execute(siteKey, { action: RECAPTCHA_ACTION })
.then(token => {
handleToken(token);
})
.catch(() => {
setError(true);
});
});
}
}, [siteKey, onVerified]);
const handleToken = token => {
const wrapper = document.getElementById('mine-wrapper');
if (wrapper) {
wrapper.dataset.captchaToken = token;
}
sessionStorage.setItem(CAPTCHA_TOKEN_KEY, token);
sessionStorage.setItem(CAPTCHA_STORAGE_KEY, Date.now().toString());
setVerified(true);
onVerified?.();
};
const handleClick = () => {
setLoading(true);
setError(false);
window.grecaptcha.ready(() => {
window.grecaptcha
.execute(siteKey, { action: RECAPTCHA_ACTION })
.then(token => {
handleToken(token);
setLoading(false);
})
.catch(() => {
setLoading(false);
setError(true);
setTimeout(() => setError(false), 2000);
});
});
};
if (verified) {
return <>{children}</>;
}
const overlayStyles = {
position: 'fixed',
top: 0,
left: 0,
width: '100%',
height: '100%',
background: 'rgba(7, 9, 13, 0.95)',
backdropFilter: 'blur(8px)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
zIndex: 1000,
};
const contentStyles = {
textAlign: 'center',
color: '#fff',
maxWidth: '400px',
padding: '40px',
};
const iconStyles = {
fontSize: '64px',
color: '#236f87',
marginBottom: '24px',
};
const h1Styles = {
font: '800 32px Rajdhani, sans-serif',
margin: '0 0 16px',
letterSpacing: '1px',
};
const pStyles = {
color: 'rgba(149, 207, 245, 0.7)',
font: '400 16px Rajdhani, sans-serif',
margin: '0 0 32px',
letterSpacing: '0.5px',
};
const buttonStyles = {
background: error
? 'linear-gradient(#8a2323 0%, #681a1a 100%)'
: loading
? 'linear-gradient(#236f87 0%, #1a5068 100%)'
: 'linear-gradient(#236f87 0%, #1a5068 100%)',
border: `2px solid ${error ? '#9a2e2e' : loading ? '#2e7a9a' : '#2e7a9a'}`,
borderRadius: '8px',
color: '#e0f4ff',
cursor: loading ? 'wait' : 'pointer',
font: '800 18px Rajdhani, sans-serif',
letterSpacing: '2px',
padding: '16px 40px',
textTransform: 'uppercase',
transition: 'all 0.3s ease',
display: 'inline-flex',
alignItems: 'center',
gap: '12px',
opacity: loading ? 0.7 : 1,
};
return (
<div style={overlayStyles}>
<div style={contentStyles}>
<div style={iconStyles}>
<i className="fa fa-shield-halved" />
</div>
<h1 style={h1Styles}>Ready to Play?</h1>
<p style={pStyles}>
Click below to verify you&apos;re human and start playing.
</p>
<button
style={buttonStyles}
onClick={handleClick}
disabled={loading}
>
<i className={`fa ${loading ? 'fa-spinner fa-spin' : error ? 'fa-exclamation-circle' : 'fa-play'}`} />
{loading ? 'Verifying...' : error ? 'Try Again' : 'Start Playing'}
</button>
</div>
</div>
);
};
export default CaptchaOverlay;

View File

@@ -0,0 +1,41 @@
/**
* 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 { Fragment, useEffect, useState } from 'react';
const ChallengeCountdown = ({ onAccept, onDecline, seconds = 30 }) => {
const [countdown, setCountdown] = useState(seconds);
useEffect(() => {
const interval = setInterval(() => {
setCountdown(prev => {
if (1 >= prev) {
clearInterval(interval);
onDecline();
return 0;
}
return prev - 1;
});
}, 1000);
return () => clearInterval(interval);
}, [onDecline]);
return (
<Fragment>
<p style={{ textAlign: 'center', marginBottom: 20, color: '#95cff5' }}>
You have {countdown} second{1 === countdown ? '' : 's'} to answer to the challenge!
</p>
<div className="resign">
<a onClick={onAccept}>Accept</a>
<a onClick={onDecline}>Decline</a>
</div>
</Fragment>
);
};
export default ChallengeCountdown;

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 (
@@ -26,6 +26,7 @@ export const GameBoard = ({ gameAssoc, gameInherited, isEnvDev }) => {
return ( return (
<GridControl <GridControl
gameAssoc={gameAssoc}
onClick={onClick} onClick={onClick}
resign={resign} resign={resign}
/> />

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

@@ -47,6 +47,7 @@ const OnlinePlayersDialog = ({ open, onClose, currentGameAssoc }) => {
const [snapshotLoaded, setSnapshotLoaded] = useState(false); const [snapshotLoaded, setSnapshotLoaded] = useState(false);
const [challengingGameAssoc, setChallengingGameAssoc] = useState(null); const [challengingGameAssoc, setChallengingGameAssoc] = useState(null);
const [declinedMsg, setDeclinedMsg] = useState(''); const [declinedMsg, setDeclinedMsg] = useState('');
const [waitingCountdown, setWaitingCountdown] = useState(0);
const declinedTimerRef = useRef(null); const declinedTimerRef = useRef(null);
const addPlayer = useCallback(entry => { const addPlayer = useCallback(entry => {
@@ -111,6 +112,7 @@ const OnlinePlayersDialog = ({ open, onClose, currentGameAssoc }) => {
setChallengingGameAssoc(null); setChallengingGameAssoc(null);
clearTimeout(declinedTimerRef.current); clearTimeout(declinedTimerRef.current);
setDeclinedMsg('Challenge was not accepted.'); setDeclinedMsg('Challenge was not accepted.');
setWaitingCountdown(0);
declinedTimerRef.current = setTimeout(() => setDeclinedMsg(''), 3500); declinedTimerRef.current = setTimeout(() => setDeclinedMsg(''), 3500);
}; };
window.addEventListener('challenge-declined', handler); window.addEventListener('challenge-declined', handler);
@@ -120,15 +122,30 @@ const OnlinePlayersDialog = ({ open, onClose, currentGameAssoc }) => {
}; };
}, []); }, []);
useEffect(() => {
if (!waitingCountdown) return;
const interval = setInterval(() => {
setWaitingCountdown(prev => {
if (1 >= prev) return 0;
return prev - 1;
});
}, 1000);
return () => clearInterval(interval);
}, [waitingCountdown]);
const handleChallenge = player => { const handleChallenge = player => {
if (challengingGameAssoc) return; if (challengingGameAssoc) return;
setChallengingGameAssoc(player.gameAssoc); setChallengingGameAssoc(player.gameAssoc);
setDeclinedMsg(''); setDeclinedMsg('');
setWaitingCountdown(30);
fetch('/api/game/challenge/' + player.gameAssoc, { fetch('/api/game/challenge/' + player.gameAssoc, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ challengerGameAssoc: currentGameAssoc }), body: JSON.stringify({ challengerGameAssoc: currentGameAssoc }),
}).catch(() => setChallengingGameAssoc(null)); }).catch(() => {
setChallengingGameAssoc(null);
setWaitingCountdown(0);
});
}; };
const visible = players const visible = players
@@ -147,7 +164,12 @@ const OnlinePlayersDialog = ({ open, onClose, currentGameAssoc }) => {
} }
return ( return (
<Dialog open={open} onClose={onClose} sx={DIALOG_SX}> <Dialog
open={open}
onClose={0 < waitingCountdown ? undefined : onClose}
disableEscapeKeyDown={0 < waitingCountdown}
sx={DIALOG_SX}
>
<div className="opd"> <div className="opd">
<div className="opd-header"> <div className="opd-header">
<div className="opd-header-text"> <div className="opd-header-text">
@@ -160,32 +182,44 @@ const OnlinePlayersDialog = ({ open, onClose, currentGameAssoc }) => {
<div className="opd-header-actions"> <div className="opd-header-actions">
<button <button
className={`opd-refresh${loading ? ' opd-refresh--spin' : ''}`} className={`opd-refresh${loading ? ' opd-refresh--spin' : ''}`}
onClick={() => setRefreshKey(k => k + 1)} onClick={() => { if (0 === waitingCountdown) setRefreshKey(k => k + 1); }}
disabled={loading} disabled={loading || 0 < waitingCountdown}
aria-label="Refresh" aria-label="Refresh"
title="Refresh list" title="Refresh list"
> >
<i className="fa fa-refresh" /> <i className="fa fa-refresh" />
</button> </button>
<button className="opd-close" onClick={onClose} aria-label="Close"> <button
className="opd-close"
onClick={() => { if (0 === waitingCountdown) onClose(); }}
disabled={0 < waitingCountdown}
aria-label="Close"
>
<i className="fa fa-times" /> <i className="fa fa-times" />
</button> </button>
</div> </div>
</div> </div>
<div className="opd-search-wrap"> {0 < waitingCountdown ? (
<i className="fa fa-search opd-search-icon" /> <div className="opd-waiting">
<input <i className="fa fa-hourglass-start" />
className="opd-search" <p>Waiting {waitingCountdown} second{1 === waitingCountdown ? '' : 's'} for opponent's answer...</p>
placeholder="Search by username…" </div>
value={search} ) : (
onChange={e => setSearch(e.target.value)} <div className="opd-search-wrap">
/> <i className="fa fa-search opd-search-icon" />
{search && ( <input
<button className="opd-search-clear" onClick={() => setSearch('')}> className="opd-search"
<i className="fa fa-times" /> placeholder="Search by username…"
</button> value={search}
)} onChange={e => setSearch(e.target.value)}
</div> />
{search && (
<button className="opd-search-clear" onClick={() => setSearch('')}>
<i className="fa fa-times" />
</button>
)}
</div>
)}
<div className="opd-list"> <div className="opd-list">
{loading && ( {loading && (
<div className="opd-empty"> <div className="opd-empty">
@@ -222,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,46 +9,55 @@
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>
<div className="waiting-divider"> {!inviteOnly && (
<span>OR</span> <Fragment>
</div> <div className="waiting-divider">
<div className="waiting-option"> <span>OR</span>
<div className="waiting-option-header"> </div>
<i className="fa fa-users" /> <div className="waiting-option">
<span>Challenge a Player</span> <div className="waiting-option-header">
</div> <i className="fa fa-users" />
<p className="waiting-option-desc">Browse online players and challenge them</p> <span>Challenge a Player</span>
<button </div>
className="browse-players-btn" <p className="waiting-option-desc">Browse online players and challenge them</p>
onClick={() => setDialogOpen(true)} <button
> className="browse-players-btn"
<i className="fa fa-search" /> onClick={() => setDialogOpen(true)}
Browse Players >
</button> <i className="fa fa-search" />
</div> Browse Players
</button>
</div>
</Fragment>
)}
</div> </div>
<OnlinePlayersDialog {!inviteOnly && (
open={dialogOpen} <OnlinePlayersDialog
onClose={() => setDialogOpen(false)} open={dialogOpen}
currentGameAssoc={currentGameAssoc} onClose={() => setDialogOpen(false)}
/> 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)
setCopied(true); .then(() => {
setTimeout(() => setCopied(false), 2500); setCopied(true);
}).catch(() => {}); setTimeout(() => setCopied(false), 2500);
})
.catch(() => null);
}; };
return ( return (

View File

@@ -7,20 +7,32 @@
* file that was distributed with this source code. * file that was distributed with this source code.
*/ */
import React, { Fragment } from 'react'; import React, { Fragment, useState } from 'react';
import { useGame } from '@mine-contexts'; import { useGame } from '@mine-contexts';
import GridField from './GridField'; import GridField from './GridField';
import UserControl from '../user/UserControl'; import UserControl from '../user/UserControl';
import GameTimer from '../GameTimer'; import GameTimer from '../GameTimer';
import { BOMB_SYMBOLS, bombRadius } from '@mine-utils'; import { BOMB_SYMBOLS, bombRadius } from '@mine-utils';
const GridControl = ({ onClick, resign }) => { const GridControl = ({ gameAssoc, onClick, resign }) => {
const { const {
overlay, overlayTitle, overlaySubTitle, overlay, overlayTitle, overlaySubTitle,
webPlayer, activePlayer, bombSelected, webPlayer, activePlayer, bombSelected,
cells, setCells, cells, setCells, endRef,
} = useGame(); } = useGame();
const [copied, setCopied] = useState(false);
const shareUrl = gameAssoc ? `${window.location.origin}/battle/${gameAssoc}` : null;
const isAuthenticated = '1' === document.getElementById('mine-wrapper')?.dataset.isAuthenticated;
const handleShare = () => {
if (!shareUrl) return;
navigator.clipboard.writeText(shareUrl).then(() => {
setCopied(true);
setTimeout(() => setCopied(false), 2200);
});
};
const handleHover = (row, col) => { const handleHover = (row, col) => {
if (!bombSelected) return; if (!bombSelected) return;
const activeColor = activePlayer ? 'blue' : 'red'; const activeColor = activePlayer ? 'blue' : 'red';
@@ -47,7 +59,33 @@ const GridControl = ({ onClick, resign }) => {
<div className={`game-overlay${overlay ? '' : ' hide'}`}> <div className={`game-overlay${overlay ? '' : ' hide'}`}>
<div className="game-overlay-window"> <div className="game-overlay-window">
<h1>{overlayTitle}</h1> <h1>{overlayTitle}</h1>
<h2>{overlaySubTitle}</h2> {'string' === typeof overlaySubTitle ? (
<h2>{overlaySubTitle}</h2>
) : (
overlaySubTitle
)}
{gameAssoc && endRef.current && (
<div className="game-overlay-actions">
<button
className={`game-overlay-share${copied ? ' copied' : ''}`}
onClick={handleShare}
title="Copy share link"
aria-label="Copy share link"
>
<i className={`fa ${copied ? 'fa-check' : 'fa-share-alt'}`} />
{copied ? 'Copied!' : 'Share Battle'}
</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>
<UserControl <UserControl

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

@@ -10,6 +10,7 @@
export { GameBoard } from './GameBoard'; export { GameBoard } from './GameBoard';
export { default as OnlinePlayersDialog } from './OnlinePlayersDialog'; export { default as OnlinePlayersDialog } from './OnlinePlayersDialog';
export { default as WaitingOverlayContent } from './WaitingOverlayContent'; export { default as WaitingOverlayContent } from './WaitingOverlayContent';
export { default as ChallengeCountdown } from './ChallengeCountdown';
export { default as GameTimer } from './GameTimer'; export { default as GameTimer } from './GameTimer';
export { default as GridControl } from './grid/GridControl'; export { default as GridControl } from './grid/GridControl';
export { default as GridField } from './grid/GridField'; export { default as GridField } from './grid/GridField';

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,30 +26,44 @@ const UserControl = ({ resign }) => {
} }
}; };
const handleBonusClick = () => {
setBonusDialogOpen(true);
};
return ( return (
<div className="users"> <Fragment>
<User <div className="users">
color="blue" webPlayer={webPlayer} {...blue} <User
onClickBombSelector={() => handleBombClick('blue', 1)} color="blue" webPlayer={webPlayer} {...blue}
/> onClickBombSelector={() => handleBombClick('blue', 1)}
<div className="active-mines-container"> onBonusClick={handleBonusClick}
<i className="fa fa-star" /> />
<div className={minesClass}> <div className="active-mines-container">
<div className="active-mines-nbr">{mines}</div> <i className="fa fa-star" />
<div className="active-mines-shine" /> <div className={minesClass}>
<div className="active-mines-nbr">{mines}</div>
<div className="active-mines-shine" />
</div>
<i className="fa fa-star" />
</div> </div>
<i className="fa fa-star" /> <div className="clear" />
<User
color="red" webPlayer={webPlayer} {...red}
onClickBombSelector={() => handleBombClick('red', 0)}
onBonusClick={handleBonusClick}
/>
<button className={resignClass} onClick={resign}>
<div className="resign-shine" />
Resign
</button>
</div> </div>
<div className="clear" /> <BonusStatsDialog
<User open={bonusDialogOpen}
color="red" webPlayer={webPlayer} {...red} onClose={() => setBonusDialogOpen(false)}
onClickBombSelector={() => handleBombClick('red', 0)} red={red}
blue={blue}
/> />
<button className={resignClass} onClick={resign}> </Fragment>
<div className="resign-shine" />
Resign
</button>
</div>
); );
} }

View File

@@ -41,6 +41,8 @@ export const GameProvider = ({ children }) => {
connectionLost, setConnectionLost, connectionLost, setConnectionLost,
} = useGameState(); } = useGameState();
const [gameUuid, setGameUuid] = React.useState(null);
const sounds = useRef({ const sounds = useRef({
click: new Howl({ src: ['/sound/click.mp3'] }), click: new Howl({ src: ['/sound/click.mp3'] }),
bomb: new Howl({ src: ['/sound/bomb.mp3'] }), bomb: new Howl({ src: ['/sound/bomb.mp3'] }),
@@ -130,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();
@@ -174,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 }));
@@ -202,8 +227,11 @@ export const GameProvider = ({ children }) => {
} }
}; };
const resignProcess = color => { const resignProcess = (color, uuid = null) => {
const wp = webPlayerRef.current; const wp = webPlayerRef.current;
if (uuid) {
setGameUuid(uuid);
}
showOverlay( showOverlay(
color === wp ? 'You have been give up' : 'Your opponent has been resigned', color === wp ? 'You have been give up' : 'Your opponent has been resigned',
color === wp ? 'You LOSE!' : 'You WIN!', color === wp ? 'You LOSE!' : 'You WIN!',
@@ -225,11 +253,11 @@ export const GameProvider = ({ children }) => {
value={{ value={{
// State (for rendering) // State (for rendering)
webPlayer, activePlayer, overlay, overlayTitle, overlaySubTitle, webPlayer, activePlayer, overlay, overlayTitle, overlaySubTitle,
mines, bombSelected, foundMines, red, blue, cells, gridReady, connectionLost, mines, bombSelected, foundMines, red, blue, cells, gridReady, connectionLost, gameUuid,
// Setters needed by useServerComm // Setters needed by useServerComm
setCells, setGridReady, 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,23 +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';
/** Handles all server communication: SSE (Mercure), REST calls, and the initialization lifecycle. */ 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, 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();
@@ -34,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 */
@@ -74,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;
<WaitingOverlayContent isTrueRestoredRef.current = isRestoredGame;
shareUrl={`${window.location.href}/${gameAssoc}`}
currentGameAssoc={gameAssoc} /** Store game finished status */
/> isGameFinishedRef.current = isGameFinished;
) : '');
setTimeout(() => revealedCells.forEach(cell => applyRevealedCell(cell, cell.player)), 0); /** 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
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}
/>
) : '';
}
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();
hideOverlay(); /**
* 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();
}
};
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;
@@ -125,29 +279,54 @@ 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 => {
const { challengerName, challengerGameAssoc } = payload; const { challengerName, challengerGameAssoc } = payload;
let declineTimeout = null;
const handleAccept = () => { const handleAccept = () => {
clearTimeout(declineTimeout);
fetch('/api/game/challenge/respond/' + challengerGameAssoc, { fetch('/api/game/challenge/respond/' + challengerGameAssoc, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
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 = () => {
clearTimeout(declineTimeout);
fetch('/api/game/challenge/respond/' + challengerGameAssoc, { fetch('/api/game/challenge/respond/' + challengerGameAssoc, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
@@ -159,15 +338,15 @@ const useServerCommunication = (gameAssoc, gameInherited, isEnvDev) => {
currentGameAssoc={gameAssoc} currentGameAssoc={gameAssoc}
/> />
) : ''); ) : '');
}).catch(() => {}); }).catch(() => {
});
}; };
declineTimeout = setTimeout(handleDecline, 30000);
showOverlay( showOverlay(
challengerName + ' wants to challenge you!', challengerName + ' wants to challenge you!',
<div className="resign"> <ChallengeCountdown onAccept={handleAccept} onDecline={handleDecline} />,
<a onClick={handleAccept}>Accept</a>
<a onClick={handleDecline}>Decline</a>
</div>,
); );
}; };
@@ -185,17 +364,22 @@ 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;
} }
applyStep(payload.data); applyStep(payload.data);
if (payload.data.uuid && !endRef.current) {
setGameUuid(payload.data.uuid);
}
makeGameEndIfItEnds(payload.data.bluePoints, payload.data.redPoints, false, payload.data.leftMines); makeGameEndIfItEnds(payload.data.bluePoints, payload.data.redPoints, false, payload.data.leftMines);
} else { } else {
resignProcess(payload.data.resign); resignProcess(payload.data.resign, payload.data.uuid);
} }
} }
}; };
@@ -204,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);
} }
@@ -230,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());
@@ -272,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();
@@ -282,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);
@@ -289,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
}, []); }, []);
@@ -312,6 +525,9 @@ const useServerCommunication = (gameAssoc, gameInherited, isEnvDev) => {
try { try {
const result = await stepMutation.mutateAsync(dataPack); const result = await stepMutation.mutateAsync(dataPack);
applyStep(result); applyStep(result);
if (result.uuid && !endRef.current) {
setGameUuid(result.uuid);
}
makeGameEndIfItEnds(result.bluePoints, result.redPoints, false, result.leftMines); makeGameEndIfItEnds(result.bluePoints, result.redPoints, false, result.leftMines);
} catch (e) { } catch (e) {
isEnvDev && console.error('Step error', e); isEnvDev && console.error('Step error', e);
@@ -321,8 +537,16 @@ const useServerCommunication = (gameAssoc, gameInherited, isEnvDev) => {
const clickResign = () => { const clickResign = () => {
const color = activePlayerRef.current ? 'blue' : 'red'; const color = activePlayerRef.current ? 'blue' : 'red';
const stepElapsed = getStepElapsed(activePlayerRef.current, isGameRunningRef.current); const stepElapsed = getStepElapsed(activePlayerRef.current, isGameRunningRef.current);
stepMutation.mutate({ resign: color, stepElapsed }); stepMutation.mutate(
resignProcess(webPlayerRef.current); { resign: color, stepElapsed },
{
onSuccess: result => {
if (result?.uuid && !endRef.current) {
resignProcess(webPlayerRef.current, result.uuid);
}
},
},
);
}; };
const resign = () => { const resign = () => {

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

@@ -11,6 +11,8 @@ services:
SERVER_NAME: ${SERVER_NAME:-:80} SERVER_NAME: ${SERVER_NAME:-:80}
APP_ENV: prod APP_ENV: prod
APP_SECRET: ${APP_SECRET} APP_SECRET: ${APP_SECRET}
APP_PUBLIC_HOSTNAME: ${APP_PUBLIC_HOSTNAME:-localhost}
APP_CONTACT_MAIL_ADDRESS: ${APP_CONTACT_MAIL_ADDRESS:-7system7@gmail.com}
DATABASE_URL: >- DATABASE_URL: >-
postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB}?serverVersion=${POSTGRES_VERSION}&charset=utf8 postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB}?serverVersion=${POSTGRES_VERSION}&charset=utf8
POSTGRES_URL: db POSTGRES_URL: db
@@ -31,6 +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}
# 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
@@ -88,6 +95,7 @@ services:
RELAYHOST_PASSWORD: ${MAIL_RELAYHOST_PASSWORD:-} RELAYHOST_PASSWORD: ${MAIL_RELAYHOST_PASSWORD:-}
volumes: volumes:
- postfix_spool:/var/spool/postfix - postfix_spool:/var/spool/postfix
- ./docker/aliases:/tmp/aliases:ro
db: db:
image: postgres:${POSTGRES_VERSION:-18}-alpine image: postgres:${POSTGRES_VERSION:-18}-alpine
restart: unless-stopped restart: unless-stopped
@@ -113,3 +121,5 @@ volumes:
caddy_config: caddy_config:
postfix_spool: postfix_spool:
minio_data: minio_data:

View File

@@ -1,94 +1,103 @@
{ {
"type": "project", "name": "splendid-bear/mineseeker",
"license": "proprietary", "version": "2026.2.1",
"require": { "license": "GPL-3.0-or-later",
"php": ">=8.5", "author": "https://www.splendidbear.org",
"ext-iconv": "*", "bugs": "https://source.splendidbear.org",
"ext-json": "*", "description": "This is a minesweeper game that is inspired from MSN Messenger's game.",
"doctrine/dbal": "^3.7", "minimum-stability": "dev",
"doctrine/doctrine-bundle": ">=2.11 <2.14", "type": "project",
"doctrine/doctrine-migrations-bundle": "^3.0", "prefer-stable": true,
"doctrine/orm": "^2.6", "private": true,
"endroid/qr-code": "^6.1", "require": {
"league/flysystem-aws-s3-v3": "^3.0", "php": ">=8.5",
"league/flysystem-bundle": "^3.6", "ext-iconv": "*",
"liip/imagine-bundle": "^2.13", "ext-json": "*",
"pentatrion/vite-bundle": "^8.2", "ext-gd": "*",
"scheb/2fa-backup-code": "^8.5", "doctrine/dbal": "^3.7",
"scheb/2fa-bundle": "^8.5", "doctrine/doctrine-bundle": ">=2.11 <2.14",
"scheb/2fa-totp": "^8.5", "doctrine/doctrine-migrations-bundle": "^3.0",
"symfony/console": "7.4.*", "doctrine/orm": "^2.6",
"symfony/flex": "^2.10.0", "endroid/qr-code": "^6.1",
"symfony/form": "7.4.*", "firebase/php-jwt": "^7.0",
"symfony/framework-bundle": "7.4.*", "league/flysystem-aws-s3-v3": "^3.0",
"symfony/http-client": "7.4.*", "league/flysystem-bundle": "^3.6",
"symfony/mailer": "7.4.*", "liip/imagine-bundle": "^2.13",
"symfony/mercure": "^0.6", "pentatrion/vite-bundle": "^8.2",
"symfony/mercure-bundle": "*", "scheb/2fa-backup-code": "^8.5",
"symfony/monolog-bundle": "^3.8", "scheb/2fa-bundle": "^8.5",
"symfony/security-bundle": "7.4.*", "scheb/2fa-totp": "^8.5",
"symfony/translation": "7.4.*", "symfony/console": "7.4.*",
"symfony/twig-bundle": "7.4.*", "symfony/flex": "^2.10.0",
"symfony/validator": "7.4.*", "symfony/form": "7.4.*",
"symfony/yaml": "7.4.*", "symfony/framework-bundle": "7.4.*",
"web-auth/webauthn-framework": "^5.2" "symfony/http-client": "7.4.*",
"symfony/mailer": "7.4.*",
"symfony/mercure": "^0.6",
"symfony/mercure-bundle": "*",
"symfony/monolog-bundle": "^3.8",
"symfony/security-bundle": "7.4.*",
"symfony/translation": "7.4.*",
"symfony/twig-bundle": "7.4.*",
"symfony/validator": "7.4.*",
"symfony/yaml": "7.4.*",
"web-auth/webauthn-framework": "^5.2"
},
"require-dev": {
"roave/security-advisories": "dev-master",
"symfony/dotenv": "7.4.*",
"symfony/maker-bundle": "^1.5",
"symfony/stopwatch": "7.4.*",
"symfony/web-profiler-bundle": "7.4.*"
},
"config": {
"preferred-install": {
"*": "dist"
}, },
"require-dev": { "sort-packages": true,
"firebase/php-jwt": "^7.0", "allow-plugins": {
"roave/security-advisories": "dev-master", "symfony/flex": true
"symfony/dotenv": "7.4.*",
"symfony/maker-bundle": "^1.5",
"symfony/stopwatch": "7.4.*",
"symfony/web-profiler-bundle": "7.4.*"
},
"config": {
"preferred-install": {
"*": "dist"
},
"sort-packages": true,
"allow-plugins": {
"symfony/flex": true
}
},
"autoload": {
"psr-4": {
"App\\": "src/"
}
},
"autoload-dev": {
"psr-4": {
"App\\Tests\\": "tests/"
}
},
"replace": {
"symfony/polyfill-iconv": "*",
"symfony/polyfill-ctype": "*",
"symfony/polyfill-php73": "*",
"symfony/polyfill-php72": "*",
"symfony/polyfill-php71": "*",
"symfony/polyfill-php70": "*",
"symfony/polyfill-php56": "*"
},
"scripts": {
"auto-scripts": {
"cache:clear": "symfony-cmd",
"assets:install --symlink --relative %PUBLIC_DIR%": "symfony-cmd"
},
"post-install-cmd": [
"@auto-scripts"
],
"post-update-cmd": [
"@auto-scripts"
]
},
"conflict": {
"symfony/symfony": "*",
"doctrine/persistence": "<1.3"
},
"extra": {
"symfony": {
"allow-contrib": false,
"require": "7.4.*"
}
} }
},
"autoload": {
"psr-4": {
"App\\": "src/"
}
},
"autoload-dev": {
"psr-4": {
"App\\Tests\\": "tests/"
}
},
"replace": {
"symfony/polyfill-iconv": "*",
"symfony/polyfill-ctype": "*",
"symfony/polyfill-php73": "*",
"symfony/polyfill-php72": "*",
"symfony/polyfill-php71": "*",
"symfony/polyfill-php70": "*",
"symfony/polyfill-php56": "*"
},
"scripts": {
"auto-scripts": {
"cache:clear": "symfony-cmd",
"assets:install --symlink --relative %PUBLIC_DIR%": "symfony-cmd"
},
"post-install-cmd": [
"@auto-scripts"
],
"post-update-cmd": [
"@auto-scripts"
]
},
"conflict": {
"symfony/symfony": "*",
"doctrine/persistence": "<1.3"
},
"extra": {
"symfony": {
"allow-contrib": false,
"require": "7.4.*"
}
}
} }

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

@@ -8,6 +8,13 @@ framework:
session: session:
handler_id: ~ handler_id: ~
# Trust headers from reverse proxy (Caddy)
# This ensures absolute_url() uses HTTPS scheme when behind a reverse proxy
# Production: TRUSTED_PROXIES from .env (Gitea secret)
# Development: TRUSTED_PROXIES from compose.override.yaml
trusted_proxies: '%env(TRUSTED_PROXIES)%'
trusted_headers: ['x-forwarded-for', 'x-forwarded-proto', 'x-forwarded-host', 'x-forwarded-port']
#esi: true #esi: true
#fragments: true #fragments: true
php_errors: php_errors:

View File

@@ -0,0 +1,8 @@
framework:
# In production with FrankenPHP, the reverse proxy (Caddy) is in the same container
# Requests come from 127.0.0.1, so we must trust that IP to process X-Forwarded-Proto headers
# TRUSTED_PROXIES is set in the .env file (stored in Gitea secrets)
# Typical value for Docker: 172.18.0.0/16 (or the specific Docker network CIDR)
# This must be provided by the PROD_ENV_FILE secret in Gitea
trusted_proxies: '%env(TRUSTED_PROXIES)%'
trusted_headers: ['x-forwarded-for', 'x-forwarded-proto', 'x-forwarded-host', 'x-forwarded-port']

View File

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

View File

@@ -25,6 +25,11 @@ services:
resource: '../src/Controller' resource: '../src/Controller'
tags: [ 'controller.service_arguments' ] tags: [ 'controller.service_arguments' ]
App\Service\BattleCardGenerator:
arguments:
$cacheDir: '%kernel.project_dir%/var/og-cache'
$minioMediaStorage: '@mineseeker.media.storage'
Aws\S3\S3Client: Aws\S3\S3Client:
arguments: arguments:
- version: 'latest' - version: 'latest'

5
docker/aliases Normal file
View File

@@ -0,0 +1,5 @@
# Postfix aliases file
# Mail addressed to system users are redirected to this address
postmaster: root
root: root

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

@@ -1,50 +1,51 @@
{ {
"name": "mine-seeker", "name": "mine-seeker",
"version": "1.0.0", "version": "2026.2.1",
"description": "Mine Seeker Game by system7", "author": "https://www.splendidbear.org",
"keywords": [ "license": "GPL-3.0-or-later",
"mine", "bugs": "https://source.splendidbear.org",
"seeker", "description": "Mine Seeker Game by system7",
"game", "private": true,
"multiplayer", "keywords": [
"websocket" "mine",
], "seeker",
"author": "Laszlo Lang <system7>", "game",
"license": "UNLICENSED", "multiplayer",
"private": true, "websocket"
"dependencies": { ],
"@emotion/react": "^11.14.0", "dependencies": {
"@emotion/styled": "^11.14.1", "@emotion/react": "^11.14.0",
"@fontsource/changa-one": "^5.2.8", "@emotion/styled": "^11.14.1",
"@fontsource/open-sans": "^5.2.7", "@fontsource/changa-one": "^5.2.8",
"@fontsource/rajdhani": "^5.2.7", "@fontsource/open-sans": "^5.2.7",
"@fortawesome/fontawesome-free": "^7.2.0", "@fontsource/rajdhani": "^5.2.7",
"@mui/material": "^9.0.0", "@fortawesome/fontawesome-free": "^7.2.0",
"@mui/x-charts": "^9.0.1", "@mui/material": "^9.0.0",
"@tanstack/react-query": "^5.0.0", "@mui/x-charts": "^9.0.2",
"howler": "^2.1.2", "@tanstack/react-query": "^5.99.0",
"lodash": "^4.18.1", "howler": "^2.2.4",
"prop-types": "^15.7.2", "lodash": "^4.18.1",
"react": "^19.0.0", "prop-types": "^15.8.1",
"react-dom": "^19.0.0" "react": "^19.2.5",
}, "react-dom": "^19.2.5"
"devDependencies": { },
"@eslint/eslintrc": "^3.3.5", "devDependencies": {
"@eslint/js": "^9.0.0", "@eslint/eslintrc": "^3.3.5",
"@stylistic/eslint-plugin": "^4.0.0", "@eslint/js": "^9.39.4",
"@vitejs/plugin-react": "^6.0.1", "@stylistic/eslint-plugin": "^4.4.1",
"eslint": "^9.0.0", "@vitejs/plugin-react": "^6.0.1",
"eslint-plugin-react": "^7.0.0", "eslint": "^9.39.4",
"eslint-plugin-react-hooks": "^5.0.0", "eslint-plugin-react": "^7.37.5",
"globals": "^15.0.0", "eslint-plugin-react-hooks": "^5.2.0",
"sass": "^1.77.0", "globals": "^15.15.0",
"vite": "^8.0.8", "sass": "^1.99.0",
"vite-plugin-symfony": "^8.2.4" "vite": "^8.0.8",
}, "vite-plugin-symfony": "^8.2.4"
"scripts": { },
"dev": "vite", "scripts": {
"watch": "vite build --watch", "dev": "vite",
"build": "vite build && cp -r node_modules/@fortawesome/fontawesome-free/webfonts public/build/webfonts", "watch": "vite build --watch",
"lint": "eslint assets/js/" "build": "vite build",
} "lint": "eslint assets/js/"
}
} }

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

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

@@ -25,7 +25,7 @@ if ($debug) {
} }
if ($trustedProxies = $_SERVER['TRUSTED_PROXIES'] ?? false) { if ($trustedProxies = $_SERVER['TRUSTED_PROXIES'] ?? false) {
Request::setTrustedProxies(explode(',', $trustedProxies), Request::HEADER_X_FORWARDED_ALL ^ Request::HEADER_X_FORWARDED_HOST); Request::setTrustedProxies(explode(',', $trustedProxies), Request::HEADER_X_FORWARDED_FOR | Request::HEADER_X_FORWARDED_HOST | Request::HEADER_X_FORWARDED_PROTO);
} }
if ($trustedHosts = $_SERVER['TRUSTED_HOSTS'] ?? false) { if ($trustedHosts = $_SERVER['TRUSTED_HOSTS'] ?? false) {

View File

@@ -10,10 +10,18 @@
namespace App\Controller; namespace App\Controller;
use App\Entity\ContactMessage;
use App\Form\ContactFormType;
use App\Service\Email\SendContactMailService;
use App\Service\MercureJwtService;
use App\Service\ResolveUserNamesService;
use Doctrine\ORM\EntityManagerInterface;
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\Response; use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Attribute\AsController; use Symfony\Component\HttpKernel\Attribute\AsController;
use Symfony\Component\Mailer\MailerInterface;
use Symfony\Component\Routing\Attribute\Route; use Symfony\Component\Routing\Attribute\Route;
/** /**
@@ -31,11 +39,12 @@ class GameController extends AbstractController
{ {
public function __construct( public function __construct(
#[Autowire(env: 'APP_ENV')] #[Autowire(env: 'APP_ENV')]
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,
private readonly SendContactMailService $contactMailService,
) { ) {
} }
@@ -47,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),
]); ]);
} }
@@ -69,9 +81,30 @@ class GameController extends AbstractController
} }
#[Route('/contact', name: 'MineSeekerBundle_contact')] #[Route('/contact', name: 'MineSeekerBundle_contact')]
public function contact(): Response public function contact(
{ Request $request,
return $this->render('Official/contact.html.twig'); EntityManagerInterface $em,
MailerInterface $mailer,
): Response {
$contactMessage = new ContactMessage();
$form = $this->createForm(ContactFormType::class, $contactMessage);
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
$contactMessage->setIpAddress($request->getClientIp());
$em->persist($contactMessage);
$em->flush();
$this->contactMailService->send($contactMessage);
$this->addFlash('contact_success', 'Thank you for your message! We will get back to you soon.');
return $this->redirectToRoute('MineSeekerBundle_contact');
}
return $this->render('Official/contact.html.twig', [
'form' => $form,
]);
} }
#[Route('/landing-page', name: 'MineSeekerBundle_landing')] #[Route('/landing-page', name: 'MineSeekerBundle_landing')]
@@ -79,4 +112,10 @@ class GameController extends AbstractController
{ {
return $this->render('Official/landing.html.twig'); return $this->render('Official/landing.html.twig');
} }
#[Route('/rules', name: 'MineSeekerBundle_rules')]
public function rules(): Response
{
return $this->render('Official/rules.html.twig');
}
} }

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;
@@ -39,8 +40,9 @@ use Symfony\Component\Routing\Attribute\Route;
class MercureController extends AbstractController 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
{ {
$payload = $this->rpcManager->getConnectInformation($gameAssoc); try {
$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
{ {
@@ -113,10 +135,10 @@ class MercureController extends AbstractController
$result = array_map(static function (PlayedGame $g): array { $result = array_map(static function (PlayedGame $g): array {
$name = match (true) { $name = match (true) {
null !== $g->getRed() => $g->getRed()->getUsername(), null !== $g->getRed() => $g->getRed()->getUsername(),
null !== $g->getRedAnon() => $g->getRedAnon()->getUserName(), null !== $g->getRedAnon() => $g->getRedAnon()->getUserName(),
null !== $g->getBlue() => $g->getBlue()->getUsername(), null !== $g->getBlue() => $g->getBlue()->getUsername(),
default => $g->getBlueAnon()?->getUserName() ?? 'Unknown', default => $g->getBlueAnon()?->getUserName() ?? 'Unknown',
}; };
return [ return [
@@ -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

@@ -10,20 +10,31 @@
namespace App\Controller; namespace App\Controller;
use App\Entity\PlayedGame;
use App\Entity\User; use App\Entity\User;
use App\Repository\PlayedGameRepository; use App\Repository\PlayedGameRepository;
use App\Service\BattleCardGenerator;
use App\Service\WebAuthnService; use App\Service\WebAuthnService;
use DateTime;
use Doctrine\ORM\EntityManagerInterface; use Doctrine\ORM\EntityManagerInterface;
use League\Flysystem\FilesystemException;
use League\Flysystem\FilesystemOperator; use League\Flysystem\FilesystemOperator;
use Liip\ImagineBundle\Imagine\Cache\CacheManager; use Liip\ImagineBundle\Imagine\Cache\CacheManager;
use Psr\Log\LoggerInterface;
use RuntimeException;
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\BinaryFileResponse;
use Symfony\Component\HttpFoundation\File\UploadedFile; use Symfony\Component\HttpFoundation\File\UploadedFile;
use Symfony\Component\HttpFoundation\JsonResponse; use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpFoundation\ResponseHeaderBag;
use Symfony\Component\HttpKernel\Attribute\AsController; use Symfony\Component\HttpKernel\Attribute\AsController;
use Symfony\Component\Routing\Attribute\Route; use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Uid\Uuid;
use Throwable;
use function count;
/** /**
* Class ProfileController * Class ProfileController
@@ -40,12 +51,13 @@ class ProfileController extends AbstractController
{ {
public function __construct( public function __construct(
private readonly PlayedGameRepository $repo, private readonly PlayedGameRepository $repo,
private readonly WebAuthnService $webAuthnService private readonly WebAuthnService $webAuthnService,
private readonly LoggerInterface $logger,
) { ) {
} }
#[Route('/profile', name: 'MineSeekerBundle_profile')] #[Route('/profile', name: 'MineSeekerBundle_profile')]
public function index(): Response public function index(CacheManager $cacheManager): Response
{ {
/** @var User $user */ /** @var User $user */
$user = $this->getUser(); $user = $this->getUser();
@@ -56,15 +68,15 @@ class ProfileController extends AbstractController
$losses = $this->repo->countLossesForUser($user); $losses = $this->repo->countLossesForUser($user);
$draws = $this->repo->countDrawsForUser($user); $draws = $this->repo->countDrawsForUser($user);
// Build monthly buckets for the last 6 months /** Build monthly buckets for the last 6 months */
$monthlyData = []; $monthlyData = [];
for ($i = 5; $i >= 0; $i--) { for ($i = 5; $i >= 0; $i--) {
$dt = new \DateTime("first day of -$i months midnight"); $dt = new DateTime("first day of -$i months midnight");
$key = $dt->format('Y-m'); $key = $dt->format('Y-m');
$monthlyData[$key] = ['label' => $dt->format('M'), 'wins' => 0, 'losses' => 0, 'draws' => 0]; $monthlyData[$key] = ['label' => $dt->format('M'), 'wins' => 0, 'losses' => 0, 'draws' => 0];
} }
$since = new \DateTime('first day of -5 months midnight'); $since = new DateTime('first day of -5 months midnight');
$recentGames = $this->repo->findFinishedForUserSince($user, $since); $recentGames = $this->repo->findFinishedForUserSince($user, $since);
$userId = $user->getId(); $userId = $user->getId();
@@ -100,19 +112,25 @@ 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(static function (\App\Entity\PlayedGame $game) use ($userId): array { 'gamesData' => array_map(function (PlayedGame $game) use ($userId, $cacheManager): array {
$isRed = $game->getRed()?->getId() === $userId; $isRed = $game->getRed()?->getId() === $userId;
$resign = $game->getResign(); $resign = $game->getResign();
$myColor = $isRed ? 'red' : 'blue'; $myColor = $isRed ? 'red' : 'blue';
@@ -128,12 +146,18 @@ class ProfileController extends AbstractController
elseif ($myPts < $oppPts) $result = 'loss'; elseif ($myPts < $oppPts) $result = 'loss';
} }
$redAvatarPath = $game->getRed()?->getAvatarPath();
$blueAvatarPath = $game->getBlue()?->getAvatarPath();
return [ return [
'id' => $game->getId(), 'id' => $game->getId(),
'uuid' => $game->getUuid()?->toRfc4122(),
'redName' => 'redName' =>
$game->getRed()?->getUsername() ?? $game->getRedAnon()?->getUserName() ?? 'Guest', $game->getRed()?->getUsername() ?? $game->getRedAnon()?->getUserName() ?? 'Guest',
'blueName' => 'blueName' =>
$game->getBlue()?->getUsername() ?? $game->getBlueAnon()?->getUserName() ?? 'Guest', $game->getBlue()?->getUsername() ?? $game->getBlueAnon()?->getUserName() ?? 'Guest',
'redAvatar' => $redAvatarPath ? $cacheManager->generateUrl($redAvatarPath, 'avatar_thumb') : null,
'blueAvatar' => $blueAvatarPath ? $cacheManager->generateUrl($blueAvatarPath, 'avatar_thumb') : null,
'redPoints' => $game->getRedPoints(), 'redPoints' => $game->getRedPoints(),
'bluePoints' => $game->getBluePoints(), 'bluePoints' => $game->getBluePoints(),
'redExplodedBomb' => $game->getRedExplodedBomb(), 'redExplodedBomb' => $game->getRedExplodedBomb(),
@@ -145,33 +169,72 @@ 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' => [
'months' => $months, 'months' => $months,
'wins' => array_column(array_values($monthlyData), 'wins'), 'wins' => array_column(array_values($monthlyData), 'wins'),
'losses' => array_column(array_values($monthlyData), 'losses'), 'losses' => array_column(array_values($monthlyData), 'losses'),
'draws' => array_column(array_values($monthlyData), 'draws'), 'draws' => array_column(array_values($monthlyData), 'draws'),
'pieWins' => $wins, 'pieWins' => $wins,
'pieLosses' => $losses, 'pieLosses' => $losses,
'pieDraws' => $draws, 'pieDraws' => $draws,
'recentGames' => $this->buildRecentGamesSeries($user, $userId),
], ],
]); ]);
} }
#[Route('/battle/{id}', name: 'MineSeekerBundle_battle_share', requirements: ['id' => '\d+'], methods: ['GET'])] /**
public function battleShare(int $id): Response * 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
{ {
$game = $this->repo->find($id); $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(
'/battle/{uuid}',
name: 'MineSeekerBundle_battle_share',
requirements: ['uuid' => '[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}'],
methods: ['GET'],
)]
public function battleShare(Uuid $uuid): Response
{
$game = $this->repo->findOneBy(['uuid' => $uuid]);
if (!$game) { if (!$game) {
throw $this->createNotFoundException('Battle not found.'); throw $this->createNotFoundException('Battle not found.');
} }
$redName = $game->getRed()?->getUsername() ?? $game->getRedAnon()?->getUserName() ?? 'Guest'; $redName = $game->getRed()?->getUsername() ?? ($game->getRedAnon() !== null ? 'Anonymous' : 'Guest');
$blueName = $game->getBlue()?->getUsername() ?? $game->getBlueAnon()?->getUserName() ?? 'Guest'; $blueName = $game->getBlue()?->getUsername() ?? ($game->getBlueAnon() !== null ? 'Anonymous' : 'Guest');
$redPts = $game->getRedPoints(); $redPts = $game->getRedPoints();
$bluePts = $game->getBluePoints(); $bluePts = $game->getBluePoints();
$resign = $game->getResign(); $resign = $game->getResign();
$redAvatar = $game->getRed()?->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";
@@ -190,17 +253,46 @@ class ProfileController extends AbstractController
} }
return $this->render('Game/battle_share.html.twig', [ return $this->render('Game/battle_share.html.twig', [
'game' => $game, 'game' => $game,
'redName' => $redName, 'redName' => $redName,
'blueName' => $blueName, 'blueName' => $blueName,
'redPts' => $redPts, 'redPts' => $redPts,
'bluePts' => $bluePts, 'bluePts' => $bluePts,
'resign' => $resign, 'resign' => $resign,
'ogTitle' => "MineSeeker · $summary", 'redAvatar' => $redAvatar,
'ogDesc' => "Watch the battle replay: $summary — played on MineSeeker, the multiplayer minesweeper.", 'blueAvatar' => $blueAvatar,
'redBonusPoints' => $redBonusPoints,
'blueBonusPoints' => $blueBonusPoints,
'redBonusStats' => $redBonusStats,
'blueBonusStats' => $blueBonusStats,
'ogTitle' => "MineSeeker · $summary",
'ogDesc' => "Watch the battle replay: $summary — played on MineSeeker, the multiplayer minesweeper.",
]); ]);
} }
#[Route(
'/og/battle/{uuid}.png',
name: 'MineSeekerBundle_og_battle',
requirements: ['uuid' => '[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}'],
methods: ['GET'],
)]
public function battleOgImage(Uuid $uuid, BattleCardGenerator $generator): BinaryFileResponse
{
$game = $this->repo->findOneBy(['uuid' => $uuid]);
if (!$game) {
throw $this->createNotFoundException();
}
$path = $generator->generate($game);
$response = new BinaryFileResponse($path);
$response->headers->set('Content-Type', 'image/png');
$response->setContentDisposition(ResponseHeaderBag::DISPOSITION_INLINE);
$response->setMaxAge(86400 * 30);
$response->setPublic();
return $response;
}
#[Route('/profile/avatar', name: 'MineSeekerBundle_profile_avatar', methods: ['POST'])] #[Route('/profile/avatar', name: 'MineSeekerBundle_profile_avatar', methods: ['POST'])]
public function uploadAvatar( public function uploadAvatar(
Request $request, Request $request,
@@ -228,23 +320,27 @@ class ProfileController extends AbstractController
} }
$ext = $file->guessExtension() ?? 'jpg'; $ext = $file->guessExtension() ?? 'jpg';
$newPath = sprintf('avatar/%d.%s', $user->getId(), $ext); $newPath = sprintf('avatar/%s.%s', Uuid::v4()->toRfc4122(), $ext);
$oldPath = $user->getAvatarPath(); $oldPath = $user->getAvatarPath();
// Remove old file and any cached thumbnails /** Remove old file and any cached thumbnails */
if ($oldPath) { if ($oldPath) {
if ($oldPath !== $newPath) { try {
try { $mediaStorage->delete($oldPath);
$mediaStorage->delete($oldPath); } catch (Throwable) {
} catch (\Throwable) { $this->logger->error('Unable to delete old avatar: ' . $oldPath);
}
} }
$cacheManager->remove($oldPath, 'avatar_thumb'); $cacheManager->remove($oldPath, 'avatar_thumb');
} }
// Upload original to MinIO media/avatar/ /** Upload original to MinIO media/avatar/ */
$stream = fopen($file->getPathname(), 'r'); $stream = fopen($file->getPathname(), 'rb');
$mediaStorage->writeStream($newPath, $stream); try {
$mediaStorage->writeStream($newPath, $stream);
} catch (FilesystemException $e) {
$this->logger->error('Unable to write new avatar: ' . $e->getMessage());
throw new RuntimeException('Unable to write new avatar: ' . $e->getMessage());
}
fclose($stream); fclose($stream);
$user->setAvatarPath($newPath); $user->setAvatarPath($newPath);
@@ -275,7 +371,7 @@ class ProfileController extends AbstractController
return $this->render('Security/profile_security.html.twig', [ return $this->render('Security/profile_security.html.twig', [
'credentials' => $credentialsData, 'credentials' => $credentialsData,
'isTotpEnabled' => $user->isTotpAuthenticationEnabled(), 'isTotpEnabled' => $user->isTotpAuthenticationEnabled(),
'backupCodesCount' => \count($user->getBackupCodes()), 'backupCodesCount' => count($user->getBackupCodes()),
]); ]);
} }
} }

View File

@@ -17,8 +17,10 @@ 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\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;
@@ -41,6 +43,12 @@ use Symfony\Component\Security\Http\Authentication\AuthenticationUtils;
#[AsController] #[AsController]
class SecurityController extends AbstractController class SecurityController extends AbstractController
{ {
public function __construct(
#[Autowire(env: 'APP_CONTACT_MAIL_ADDRESS')]
private readonly string $appContactMailAddress,
) {
}
#[Route('/login', name: 'MineSeekerBundle_login')] #[Route('/login', name: 'MineSeekerBundle_login')]
public function login(AuthenticationUtils $authenticationUtils): Response public function login(AuthenticationUtils $authenticationUtils): Response
{ {
@@ -57,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')]
@@ -92,6 +100,11 @@ class SecurityController extends AbstractController
UrlGeneratorInterface::ABSOLUTE_URL, UrlGeneratorInterface::ABSOLUTE_URL,
); );
/** Ensure HTTPS scheme in production */
if ($this->getParameter('kernel.environment') === 'prod') {
$activationUrl = str_replace('http://', 'https://', $activationUrl);
}
$mailer->send( $mailer->send(
new TemplatedEmail() new TemplatedEmail()
->from('noreply@mineseeker.hu') ->from('noreply@mineseeker.hu')
@@ -104,6 +117,19 @@ class SecurityController extends AbstractController
]) ])
); );
/** Send admin notification about new user registration */
$mailer->send(
new TemplatedEmail()
->from('noreply@mineseeker.hu')
->to($this->appContactMailAddress)
->subject('🎉 New User Registration: ' . $user->getUsername())
->htmlTemplate('emails/user_registration_notification.html.twig')
->context([
'user' => $user,
'registeredAt' => new DateTime(),
])
);
$this->addFlash('verify_email', $user->getEmail()); $this->addFlash('verify_email', $user->getEmail());
return $this->redirectToRoute('MineSeekerBundle_register'); return $this->redirectToRoute('MineSeekerBundle_register');
@@ -143,6 +169,11 @@ class SecurityController extends AbstractController
UrlGeneratorInterface::ABSOLUTE_URL, UrlGeneratorInterface::ABSOLUTE_URL,
); );
/** Ensure HTTPS scheme in production */
if ($this->getParameter('kernel.environment') === 'prod') {
$resetUrl = str_replace('http://', 'https://', $resetUrl);
}
$mailer->send( $mailer->send(
new TemplatedEmail() new TemplatedEmail()
->from('noreply@mineseeker.hu') ->from('noreply@mineseeker.hu')
@@ -199,7 +230,7 @@ class SecurityController extends AbstractController
} }
#[Route('/activate/{token}', name: 'MineSeekerBundle_activate')] #[Route('/activate/{token}', name: 'MineSeekerBundle_activate')]
public function activate(string $token, EntityManagerInterface $em): Response public function activate(string $token, EntityManagerInterface $em, MailerInterface $mailer): Response
{ {
$user = $em->getRepository(User::class)->findOneBy(['verificationToken' => $token]); $user = $em->getRepository(User::class)->findOneBy(['verificationToken' => $token]);
@@ -211,6 +242,19 @@ class SecurityController extends AbstractController
$user->setIsVerified(true)->setVerificationToken(null); $user->setIsVerified(true)->setVerificationToken(null);
$em->flush(); $em->flush();
/** Send admin notification about account activation */
$mailer->send(
new TemplatedEmail()
->from('noreply@mineseeker.hu')
->to($this->appContactMailAddress)
->subject('✅ User Account Activated: ' . $user->getUsername())
->htmlTemplate('emails/user_activation_notification.html.twig')
->context([
'user' => $user,
'activatedAt' => new DateTime(),
])
);
$this->addFlash('success', 'Your account is now active. Welcome, ' . $user->getUsername() . '!'); $this->addFlash('success', 'Your account is now active. Welcome, ' . $user->getUsername() . '!');
return $this->redirectToRoute('MineSeekerBundle_login'); return $this->redirectToRoute('MineSeekerBundle_login');

View File

@@ -0,0 +1,128 @@
<?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\Entity;
use App\Repository\ContactMessageRepository;
use DateTimeImmutable;
use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Mapping\Column;
use Doctrine\ORM\Mapping\Entity;
use Doctrine\ORM\Mapping\GeneratedValue;
use Doctrine\ORM\Mapping\Id;
use Doctrine\ORM\Mapping\Table;
/**
* Class ContactMessage
*
* @package App\Entity
* @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. 15.
*/
#[Entity(repositoryClass: ContactMessageRepository::class)]
#[Table(name: 'contact_messages')]
class ContactMessage
{
#[Id, GeneratedValue, Column]
private ?int $id = null;
#[Column]
private string $name;
#[Column]
private string $email;
#[Column(type: Types::TEXT)]
private string $content;
#[Column]
private bool $consent = false;
#[Column]
private DateTimeImmutable $createdAt;
#[Column(length: 45, nullable: true)]
private ?string $ipAddress = null;
public function __construct()
{
$this->createdAt = new DateTimeImmutable();
}
public function getId(): ?int
{
return $this->id;
}
public function getName(): string
{
return $this->name;
}
public function setName(string $name): self
{
$this->name = $name;
return $this;
}
public function getEmail(): string
{
return $this->email;
}
public function setEmail(string $email): self
{
$this->email = $email;
return $this;
}
public function getContent(): string
{
return $this->content;
}
public function setContent(string $content): self
{
$this->content = $content;
return $this;
}
public function isConsent(): bool
{
return $this->consent;
}
public function setConsent(bool $consent): self
{
$this->consent = $consent;
return $this;
}
public function getCreatedAt(): DateTimeImmutable
{
return $this->createdAt;
}
public function getIpAddress(): ?string
{
return $this->ipAddress;
}
public function setIpAddress(?string $ipAddress): self
{
$this->ipAddress = $ipAddress;
return $this;
}
}

View File

@@ -23,6 +23,7 @@ use Doctrine\ORM\Mapping\JoinColumn;
use Doctrine\ORM\Mapping\ManyToOne; use Doctrine\ORM\Mapping\ManyToOne;
use Doctrine\ORM\Mapping\OneToMany; use Doctrine\ORM\Mapping\OneToMany;
use Doctrine\ORM\Mapping\OneToOne; use Doctrine\ORM\Mapping\OneToOne;
use Symfony\Component\Uid\Uuid;
/** /**
* Class PlayedGame * Class PlayedGame
@@ -40,6 +41,9 @@ class PlayedGame
#[Id, GeneratedValue, Column] #[Id, GeneratedValue, Column]
private ?int $id = null; private ?int $id = null;
#[Column(type: 'uuid', unique: true)]
private ?Uuid $uuid = null;
#[Column(length: 50)] #[Column(length: 50)]
private ?string $gameAssoc = null; private ?string $gameAssoc = null;
@@ -58,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;
@@ -90,6 +106,7 @@ class PlayedGame
public function __construct() public function __construct()
{ {
$this->steps = new ArrayCollection(); $this->steps = new ArrayCollection();
$this->uuid = Uuid::v4();
} }
public function getId(): ?int public function getId(): ?int
@@ -97,6 +114,16 @@ class PlayedGame
return $this->id; return $this->id;
} }
public function getUuid(): ?Uuid
{
return $this->uuid;
}
public function setUuid(?Uuid $uuid): void
{
$this->uuid = $uuid;
}
public function getGameAssoc(): ?string public function getGameAssoc(): ?string
{ {
return $this->gameAssoc; return $this->gameAssoc;
@@ -207,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;
@@ -232,3 +299,5 @@ class PlayedGame
return $this->steps; return $this->steps;
} }
} }

View File

@@ -78,6 +78,9 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface, TotpTwo
#[Column(length: 255, nullable: true)] #[Column(length: 255, nullable: true)]
private ?string $avatarPath = null; private ?string $avatarPath = null;
#[Column(nullable: true)]
private ?bool $consentGiven = null;
public function getId(): ?int public function getId(): ?int
{ {
@@ -243,4 +246,15 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface, TotpTwo
$this->backupCodes = $backupCodes; $this->backupCodes = $backupCodes;
return $this; return $this;
} }
public function isConsentGiven(): ?bool
{
return $this->consentGiven;
}
public function setConsentGiven(?bool $consentGiven): self
{
$this->consentGiven = $consentGiven;
return $this;
}
} }

View File

@@ -0,0 +1,89 @@
<?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\Form;
use App\Entity\ContactMessage;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\CheckboxType;
use Symfony\Component\Form\Extension\Core\Type\EmailType;
use Symfony\Component\Form\Extension\Core\Type\TextareaType;
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
use Symfony\Component\Validator\Constraints\Email;
use Symfony\Component\Validator\Constraints\IsTrue;
use Symfony\Component\Validator\Constraints\Length;
use Symfony\Component\Validator\Constraints\NotBlank;
/**
* Class ContactFormType
*
* @package App\Form
* @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. 15.
*/
class ContactFormType extends AbstractType
{
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$builder
->add('name', TextType::class, [
'label' => 'Name',
'constraints' => [
new NotBlank(message: 'Please enter your name.'),
new Length(
min: 2,
max: 255,
minMessage: 'Name must be at least {{ limit }} characters.',
maxMessage: 'Name cannot be longer than {{ limit }} characters.',
),
],
])
->add('email', EmailType::class, [
'label' => 'Email',
'constraints' => [
new NotBlank(message: 'Please enter your email address.'),
new Email(message: 'Please enter a valid email address.'),
],
])
->add('content', TextareaType::class, [
'label' => 'Message',
'constraints' => [
new NotBlank(message: 'Please enter your message.'),
new Length(
min: 10,
max: 5000,
minMessage: 'Message must be at least {{ limit }} characters.',
maxMessage: 'Message cannot be longer than {{ limit }} characters.',
),
],
])
->add('consent', CheckboxType::class, [
'label' => 'I have read the Privacy and Data Processing Policy and I consent to the processing of my data.',
'mapped' => true,
'constraints' => [
new IsTrue(message: 'You must agree to the privacy policy to submit this form.'),
],
])
->add('recaptcha', RecaptchaType::class);
}
public function configureOptions(OptionsResolver $resolver): void
{
$resolver->setDefaults([
'data_class' => ContactMessage::class,
]);
}
}

View File

@@ -12,6 +12,7 @@ namespace App\Form;
use App\Entity\User; use App\Entity\User;
use Symfony\Component\Form\AbstractType; use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\CheckboxType;
use Symfony\Component\Form\Extension\Core\Type\EmailType; use Symfony\Component\Form\Extension\Core\Type\EmailType;
use Symfony\Component\Form\Extension\Core\Type\PasswordType; use Symfony\Component\Form\Extension\Core\Type\PasswordType;
use Symfony\Component\Form\Extension\Core\Type\RepeatedType; use Symfony\Component\Form\Extension\Core\Type\RepeatedType;
@@ -19,6 +20,7 @@ use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\Form\FormBuilderInterface; use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver; use Symfony\Component\OptionsResolver\OptionsResolver;
use Symfony\Component\Validator\Constraints\Email; use Symfony\Component\Validator\Constraints\Email;
use Symfony\Component\Validator\Constraints\IsTrue;
use Symfony\Component\Validator\Constraints\Length; use Symfony\Component\Validator\Constraints\Length;
use Symfony\Component\Validator\Constraints\NotBlank; use Symfony\Component\Validator\Constraints\NotBlank;
@@ -68,6 +70,13 @@ class RegistrationFormType extends AbstractType
), ),
], ],
]) ])
->add('consentGiven', CheckboxType::class, [
'label' => 'I have read the Privacy and Data Processing Policy and I consent to the processing of my data.',
'mapped' => true,
'constraints' => [
new IsTrue(message: 'You must agree to the privacy policy to create an account.'),
],
])
->add('recaptcha', RecaptchaType::class); ->add('recaptcha', RecaptchaType::class);
} }

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,47 @@
<?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 Version20260414000000
*
* @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. 14.
*/
final class Version20260414000000 extends AbstractMigration
{
public function getDescription(): string
{
return 'Add uuid column to played_game for shareable URLs';
}
public function up(Schema $schema): void
{
$this->addSql('ALTER TABLE played_game ADD uuid UUID DEFAULT NULL');
$this->addSql('UPDATE played_game SET uuid = gen_random_uuid() WHERE uuid IS NULL');
$this->addSql('ALTER TABLE played_game ADD CONSTRAINT played_game_uuid_unique UNIQUE (uuid)');
$this->addSql('ALTER TABLE played_game ALTER COLUMN uuid SET NOT NULL');
$this->addSql('COMMENT ON COLUMN played_game.uuid IS \'(DC2Type:uuid)\'');
}
public function down(Schema $schema): void
{
$this->addSql('ALTER TABLE played_game DROP uuid');
}
}

View File

@@ -0,0 +1,47 @@
<?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 Version20260415160446
*
* @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. 15.
*/
final class Version20260415160446 extends AbstractMigration
{
public function getDescription(): string
{
return 'Add contact mail storage support';
}
public function up(Schema $schema): void
{
$this->addSql('CREATE SEQUENCE contact_messages_id_seq INCREMENT BY 1 MINVALUE 1 START 1');
$this->addSql('CREATE TABLE contact_messages (id INT NOT NULL, name VARCHAR(255) NOT NULL, email VARCHAR(255) NOT NULL, content TEXT NOT NULL, consent BOOLEAN NOT NULL, created_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, ip_address VARCHAR(45) DEFAULT NULL, PRIMARY KEY(id))');
$this->addSql('COMMENT ON COLUMN contact_messages.created_at IS \'(DC2Type:datetime_immutable)\'');
$this->addSql('ALTER INDEX played_game_uuid_unique RENAME TO UNIQ_54BE8039D17F50A6');
}
public function down(Schema $schema): void
{
$this->addSql('DROP SEQUENCE contact_messages_id_seq CASCADE');
$this->addSql('DROP TABLE contact_messages');
$this->addSql('ALTER INDEX uniq_54be8039d17f50a6 RENAME TO played_game_uuid_unique');
}
}

View File

@@ -0,0 +1,42 @@
<?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 Version20260416094849
*
* @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. 16.
*/
final class Version20260416094849 extends AbstractMigration
{
public function getDescription(): string
{
return 'Add consent property to user entity for GDPR compliance';
}
public function up(Schema $schema): void
{
$this->addSql('ALTER TABLE app_user ADD consent_given BOOLEAN DEFAULT NULL');
}
public function down(Schema $schema): void
{
$this->addSql('ALTER TABLE app_user DROP consent_given');
}
}

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

@@ -0,0 +1,35 @@
<?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\Repository;
use App\Entity\ContactMessage;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
/**
* Class ContactMessageRepository
*
* @package App\Repository
* @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. 15.
*
* @extends ServiceEntityRepository<ContactMessage>
*/
class ContactMessageRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, ContactMessage::class);
}
}

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

@@ -0,0 +1,312 @@
<?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 Exception;
use GdImage;
use League\Flysystem\FilesystemOperator;
use Psr\Log\LoggerInterface;
use RuntimeException;
use Symfony\Component\Uid\Uuid;
/**
* Class BattleCardGenerator
*
* Generates a 1200x630 PNG battle card for Open Graph sharing.
*
* @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. 14.
*/
class BattleCardGenerator
{
private const int WIDTH = 1200;
private const int HEIGHT = 630;
private const string FONT = '/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf';
private const int AVATAR_SIZE = 120;
public function __construct(
private readonly string $cacheDir,
private readonly FilesystemOperator $minioMediaStorage,
private readonly LoggerInterface $logger,
) {
}
/** Returns a deterministic UUID v5 for the given battle ID — same battle always maps to the same filename. */
public function cachePath(int $battleId): string
{
$uuid = Uuid::v5(Uuid::fromString(Uuid::NAMESPACE_URL), 'mineseeker-battle-' . $battleId);
return $this->cacheDir . '/' . $uuid->toRfc4122() . '.png';
}
public function generate(PlayedGame $game): string
{
$path = $this->cachePath((int)$game->getId());
// Always regenerate to ensure bonus points are included
if (is_file($path)) {
unlink($path);
}
if (!is_dir($this->cacheDir)) {
if (
!mkdir($concurrentDirectory = $this->cacheDir, 0755, true)
&& !is_dir($concurrentDirectory)
) {
$this->logger->error(sprintf('Failed to create directory "%s" for battle card cache', $concurrentDirectory));
throw new RuntimeException(sprintf('Directory "%s" was not created', $concurrentDirectory));
}
}
$this->render($game, $path);
return $path;
}
private function render(PlayedGame $game, string $dest): void
{
$im = imagecreatetruecolor(self::WIDTH, self::HEIGHT);
/** Palette*/
$bg = imagecolorallocate($im, 13, 13, 28);
$dot = imagecolorallocate($im, 30, 30, 55);
$divider = imagecolorallocate($im, 40, 40, 70);
$white = imagecolorallocate($im, 230, 230, 240);
$muted = imagecolorallocate($im, 90, 90, 115);
$red = imagecolorallocate($im, 246, 125, 82);
$blue = imagecolorallocate($im, 149, 207, 245);
$gold = imagecolorallocate($im, 255, 200, 50);
/** Background*/
imagefill($im, 0, 0, $bg);
/** Dot-grid texture*/
for ($x = 40; $x < self::WIDTH; $x += 40) {
for ($y = 40; $y < self::HEIGHT; $y += 40) {
imagesetpixel($im, $x, $y, $dot);
}
}
/** Horizontal accent lines*/
imageline($im, 0, 90, self::WIDTH, 90, $divider);
imageline($im, 0, self::HEIGHT - 60, self::WIDTH, self::HEIGHT - 60, $divider);
/** Vertical centre divider*/
imageline($im, self::WIDTH / 2, 110, self::WIDTH / 2, self::HEIGHT - 80, $divider);
/** Resolve names*/
$redName = $game->getRed()?->getUsername()
?? ($game->getRedAnon() !== null ? 'Anonymous' : 'Guest');
$blueName = $game->getBlue()?->getUsername()
?? ($game->getBlueAnon() !== null ? 'Anonymous' : 'Guest');
$redPts = $game->getRedPoints();
$bluePts = $game->getBluePoints();
$resign = $game->getResign();
/** Winner*/
$winner = null;
if ($resign === 'red') {
$winner = 'blue';
} elseif ($resign === 'blue') {
$winner = 'red';
} elseif ($redPts !== null && $bluePts !== null) {
if ($redPts > $bluePts) $winner = 'red';
elseif ($bluePts > $redPts) $winner = 'blue';
else $winner = 'draw';
}
$this->centeredText($im, 'MineSeeker', 20, self::WIDTH / 2, 58, $muted);
/** RED and BLUE labels aligned with avatars horizontally*/
$this->centeredText($im, 'RED', 16, 220, 130, $red);
$this->centeredText($im, 'BLUE', 16, 980, 130, $blue);
/** Draw avatars below the team labels (moved down by 60px total: 200 → 260)*/
$redAvatar = $game->getRed()?->getAvatarPath();
$blueAvatar = $game->getBlue()?->getAvatarPath();
$this->drawAvatar($im, $redAvatar, 220, 260, $red, $redName);
$this->drawAvatar($im, $blueAvatar, 980, 260, $blue, $blueName);
$redColor = $winner === 'red' ? $gold : ($winner === 'draw' ? $white : $red);
$blueColor = $winner === 'blue' ? $gold : ($winner === 'draw' ? $white : $blue);
/** Truncate long usernames (max 10 chars + "...")*/
$redNameDisplay = mb_strlen($redName) > 10 ? mb_substr($redName, 0, 10) . '...' : $redName;
$blueNameDisplay = mb_strlen($blueName) > 10 ? mb_substr($blueName, 0, 10) . '...' : $blueName;
/** Player names lower below avatars (moved down by 60px total: 310 → 370)*/
$this->centeredTextFit($im, $redNameDisplay, 36, 220, 370, $redColor, 400);
$this->centeredTextFit($im, $blueNameDisplay, 36, 980, 370, $blueColor, 400);
$scoreText = $redPts !== null && $bluePts !== null ? $redPts . ' : ' . $bluePts : 'VS';
$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') {
$resultText = $redName . ' wins';
$resultColor = $gold;
} elseif ($winner === 'blue') {
$resultText = $blueName . ' wins';
$resultColor = $gold;
} elseif ($winner === 'draw') {
$resultText = 'Draw';
$resultColor = $muted;
} else {
$resultText = '';
$resultColor = $muted;
}
if ($resultText !== '') {
$this->centeredText($im, $resultText, 30, self::WIDTH / 2, 475, $resultColor);
}
if ($resign) {
$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);
imagepng($im, $dest);
imagedestroy($im);
}
/** Draw avatar or initials centered on $cx, $cy. */
private function drawAvatar(GdImage $im, ?string $avatarPath, int $cx, int $cy, int $color, string $name): void
{
$avatarImg = null;
/** Try to load avatar from MinIO if path exists*/
if ($avatarPath) {
try {
/** Remove 'avatar/' prefix if it exists since storage already has media/ prefix*/
$path = str_starts_with($avatarPath, 'avatar/') ? $avatarPath : 'avatar/' . $avatarPath;
$avatarData = $this->minioMediaStorage->read($path);
$avatarImg = imagecreatefromstring($avatarData);
} catch (Exception $e) {
/** Failed to load avatar, will use initials*/
$avatarImg = null;
}
}
$x = $cx - self::AVATAR_SIZE / 2;
$y = $cy - self::AVATAR_SIZE / 2;
if ($avatarImg) {
/** Draw circular avatar image*/
$mask = imagecreatetruecolor(self::AVATAR_SIZE, self::AVATAR_SIZE);
$transparent = imagecolorallocatealpha($mask, 0, 0, 0, 127);
imagefill($mask, 0, 0, $transparent);
/** Create circular mask*/
imagefilledellipse(
$mask,
self::AVATAR_SIZE / 2,
self::AVATAR_SIZE / 2,
self::AVATAR_SIZE,
self::AVATAR_SIZE,
imagecolorallocate($mask, 255, 255, 255),
);
/** Resize and crop avatar*/
$resized = imagecreatetruecolor(self::AVATAR_SIZE, self::AVATAR_SIZE);
imagealphablending($resized, false);
imagesavealpha($resized, true);
$bg = imagecolorallocatealpha($resized, 0, 0, 0, 127);
imagefill($resized, 0, 0, $bg);
$srcW = imagesx($avatarImg);
$srcH = imagesy($avatarImg);
$size = min($srcW, $srcH);
$srcX = ($srcW - $size) / 2;
$srcY = ($srcH - $size) / 2;
imagecopyresampled(
$resized,
$avatarImg,
0,
0,
(int)$srcX,
(int)$srcY,
self::AVATAR_SIZE,
self::AVATAR_SIZE,
$size,
$size,
);
/** Apply circular mask*/
for ($py = 0; $py < self::AVATAR_SIZE; $py++) {
for ($px = 0; $px < self::AVATAR_SIZE; $px++) {
$maskColor = imagecolorat($mask, $px, $py);
if (($maskColor >> 16) & 0xFF) {
$resizedColor = imagecolorat($resized, $px, $py);
imagesetpixel($im, (int)($x + $px), (int)($y + $py), $resizedColor);
}
}
}
imagedestroy($avatarImg);
imagedestroy($resized);
imagedestroy($mask);
} else {
/** Draw circular background with initials*/
imagefilledellipse($im, (int)$cx, (int)$cy, self::AVATAR_SIZE, self::AVATAR_SIZE, $color);
/** Draw initials */
$initials = mb_strtoupper(mb_substr($name, 0, 2));
$fontSize = 48;
$bbox = imagettfbbox($fontSize, 0, self::FONT, $initials);
$textW = $bbox[2] - $bbox[0];
$textH = $bbox[1] - $bbox[7];
$textX = $cx - $textW / 2;
$textY = $cy + $textH / 2;
$white = imagecolorallocate($im, 255, 255, 255);
imagettftext($im, $fontSize, 0, (int)$textX, (int)$textY, $white, self::FONT, $initials);
}
}
/** Render text centered on $cx. */
private function centeredText(GdImage $im, string $text, int $size, int $cx, int $y, int $color): void
{
$bbox = imagettfbbox($size, 0, self::FONT, $text);
$w = $bbox[2] - $bbox[0];
imagettftext($im, $size, 0, (int)($cx - $w / 2), $y, $color, self::FONT, $text);
}
/** Render text centered on $cx, shrinking font size to fit $maxWidth. */
private function centeredTextFit(
GdImage $im,
string $text,
int $size,
int $cx,
int $y,
int $color,
int $maxWidth
): void {
$bbox = imagettfbbox($size, 0, self::FONT, $text);
$w = $bbox[2] - $bbox[0];
if ($w > $maxWidth) {
$size = (int)($size * $maxWidth / $w);
}
$this->centeredText($im, $text, $size, $cx, $y, $color);
}
}

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,14 +13,18 @@ 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;
use JsonException; use JsonException;
use Psr\Log\LoggerInterface; use Psr\Log\LoggerInterface;
use Random\RandomException;
use RuntimeException; use RuntimeException;
use Symfony\Component\Uid\Uuid;
/** /**
* Class RpcManager * Class RpcManager
@@ -34,14 +38,15 @@ use RuntimeException;
*/ */
class RpcManager implements RpcManagerInterface class RpcManager implements RpcManagerInterface
{ {
private const ROWS = 16; private const int ROWS = 16;
private const COLS = 16; private const int COLS = 16;
private const 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,
) { ) {
} }
@@ -54,8 +59,17 @@ class RpcManager implements RpcManagerInterface
if (null === $playedGame) { if (null === $playedGame) {
try { try {
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());
@@ -66,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);
@@ -92,19 +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->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());
} }
@@ -117,25 +159,33 @@ class RpcManager implements RpcManagerInterface
*/ */
private function generateGrid(): array private function generateGrid(): array
{ {
// Build flat set: 51 mines ('m') + remaining water ('w') /** Build flat set: 51 mines ('m') + remaining water ('w') */
$set = array_merge( $set = array_merge(
array_fill(0, self::MINES, 'm'), array_fill(0, self::MINES, 'm'),
array_fill(0, self::ROWS * self::COLS - self::MINES, 'w'), array_fill(0, self::ROWS * self::COLS - self::MINES, 'w'),
); );
// Fisher-Yates shuffle /**
* Fisher-Yates 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--) {
$j = random_int(0, $i); try {
$j = random_int(0, $i);
} catch (RandomException $e) {
throw new RuntimeException('Failed to generate random index: ' . $e->getMessage());
}
[$set[$i], $set[$j]] = [$set[$j], $set[$i]]; [$set[$i], $set[$j]] = [$set[$j], $set[$i]];
} }
// Reshape to 2-D /** Reshape to 2-D */
$grid = []; $grid = [];
for ($r = 0; $r < self::ROWS; $r++) { for ($r = 0; $r < self::ROWS; $r++) {
$grid[$r] = array_slice($set, $r * self::COLS, self::COLS); $grid[$r] = array_slice($set, $r * self::COLS, self::COLS);
} }
// Replace 'w' with adjacent-mine count /** Replace 'w' with adjacent-mine count */
$dirs = [[-1, -1], [-1, 0], [-1, 1], [0, -1], [0, 1], [1, -1], [1, 0], [1, 1]]; $dirs = [[-1, -1], [-1, 0], [-1, 1], [0, -1], [0, 1], [1, -1], [1, 0], [1, 1]];
for ($r = 0; $r < self::ROWS; $r++) { for ($r = 0; $r < self::ROWS; $r++) {
for ($c = 0; $c < self::COLS; $c++) { for ($c = 0; $c < self::COLS; $c++) {
@@ -175,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,23 +221,27 @@ 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);
$topic = 'mineseeker/channel/' . $gameAssoc; $topic = 'mineseeker/channel/' . $gameAssoc;
$data = [ $data = [
'coords' => $coords, 'coords' => $coords,
'player' => $player, 'player' => $player,
'bomb' => $isBomb, 'bomb' => $isBomb,
'revealedCells' => $revealedCells, 'revealedCells' => $revealedCells,
'minesFound' => $minesFound, 'minesFound' => $minesFound,
'redPoints' => $redPoints, 'redPoints' => $redPoints,
'bluePoints' => $bluePoints, 'bluePoints' => $bluePoints,
'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);
@@ -518,8 +696,8 @@ readonly class TopicManager implements TopicManagerInterface
private function getUserCollection(PlayedGame $playedGame): array private function getUserCollection(PlayedGame $playedGame): array
{ {
$redUser = $playedGame->getRed(); $redUser = $playedGame->getRed();
$blueUser = $playedGame->getBlue(); $blueUser = $playedGame->getBlue();
return [ return [
'red' => null !== $redUser ? $redUser->getUsername() : '', 'red' => null !== $redUser ? $redUser->getUsername() : '',
@@ -527,11 +705,11 @@ readonly class TopicManager implements TopicManagerInterface
'redAnon' => null !== $playedGame->getRedAnon() ? $playedGame->getRedAnon()->getUserName() : '', 'redAnon' => null !== $playedGame->getRedAnon() ? $playedGame->getRedAnon()->getUserName() : '',
'blueAnon' => null !== $playedGame->getBlueAnon() ? $playedGame->getBlueAnon()->getUserName() : '', 'blueAnon' => null !== $playedGame->getBlueAnon() ? $playedGame->getBlueAnon()->getUserName() : '',
'redAvatar' => null !== $redUser && null !== $redUser->getAvatarPath() 'redAvatar' => null !== $redUser && null !== $redUser->getAvatarPath()
? $this->cacheManager->generateUrl($redUser->getAvatarPath(), 'avatar_thumb') ? $this->cacheManager->generateUrl($redUser->getAvatarPath(), 'avatar_thumb')
: null, : null,
'blueAvatar' => null !== $blueUser && null !== $blueUser->getAvatarPath() 'blueAvatar' => null !== $blueUser && null !== $blueUser->getAvatarPath()
? $this->cacheManager->generateUrl($blueUser->getAvatarPath(), 'avatar_thumb') ? $this->cacheManager->generateUrl($blueUser->getAvatarPath(), 'avatar_thumb')
: null, : null,
]; ];
} }
@@ -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

@@ -3,40 +3,51 @@
{% block title %} - Battle Report{% endblock %} {% block title %} - Battle Report{% endblock %}
{% block metas %} {% block metas %}
{% set shareUrl = url('MineSeekerBundle_battle_share', { id: game.id }) %} {%- set shareUrl = url('MineSeekerBundle_battle_share', { uuid: game.uuid }) | replace({'http://': 'https://'}) -%}
<meta property="og:url" content="{{ shareUrl }}"/> {%- set _ogImage = url('MineSeekerBundle_og_battle', { uuid: game.uuid }) | replace({'http://': 'https://'}) -%}
<meta property="og:type" content="website"/> <meta property="og:url" content="{{ shareUrl }}"/>
<meta property="og:title" content="{{ ogTitle }}"/> <meta property="og:type" content="article"/>
<meta property="og:site_name" content="MineSeeker"/>
<meta property="og:locale" content="en_US"/>
<meta property="og:title" content="{{ ogTitle }}"/>
<meta property="og:description" content="{{ ogDesc }}"/> <meta property="og:description" content="{{ ogDesc }}"/>
<meta property="og:image" content="{{ app.request.getSchemeAndHttpHost() }}{{ asset('images/mine-1600x627.png') }}"/> <meta property="og:image" content="{{ _ogImage }}"/>
<meta property="og:image:width" content="1600"/> <meta property="og:image:width" content="1600"/>
<meta property="og:image:height" content="627"/> <meta property="og:image:height" content="627"/>
<meta name="twitter:card" content="summary_large_image"/> <meta property="og:image:alt" content="{{ ogTitle }}"/>
<meta name="twitter:title" content="{{ ogTitle }}"/> <meta name="twitter:card" content="summary_large_image"/>
<meta name="twitter:site" content="@MineSeeker"/>
<meta name="twitter:title" content="{{ ogTitle }}"/>
<meta name="twitter:description" content="{{ ogDesc }}"/> <meta name="twitter:description" content="{{ ogDesc }}"/>
<meta name="twitter:image" content="{{ app.request.getSchemeAndHttpHost() }}{{ asset('images/mine-1600x627.png') }}"/> <meta name="twitter:image" content="{{ _ogImage }}"/>
<meta name="twitter:image:alt" content="{{ ogTitle }}"/>
{% endblock %} {% endblock %}
{% block body %} {% block body %}
<div class="bshare-page"> <div class="bshare-page">
<div class="bshare-card"> <div class="bshare-card">
<div class="bshare-card__eyebrow"> <div class="bshare-card__eyebrow">
<i class="fas fa-crosshairs"></i> Battle Report <i class="fas fa-crosshairs"></i> Battle Report
</div> </div>
{# VS Header #}
<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;">
{{ redName|slice(0,2)|upper }} {% if redAvatar %}
<img src="{{ redAvatar|imagine_filter('avatar_thumb') }}"
alt="{{ redName }}"
class="bshare-avatar__img">
{% else %}
{{ redName|slice(0,2)|upper }}
{% 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>
</div> </div>
<div class="bshare-vs__center"> <div class="bshare-vs__center">
{% if redPts is not null and bluePts is not null %} {% if redPts is not null and bluePts is not null %}
<div class="bshare-score"> <div class="bshare-score">
@@ -47,9 +58,16 @@
{% 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>
{# Result badge #}
{% if resign == 'red' %} {% if resign == 'red' %}
<div class="bshare-badge bshare-badge--blue"> <div class="bshare-badge bshare-badge--blue">
<i class="fas fa-trophy"></i> Blue wins <i class="fas fa-trophy"></i> Blue wins
@@ -74,18 +92,41 @@
{% endif %} {% endif %}
{% 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;">
{{ blueName|slice(0,2)|upper }} {% if blueAvatar %}
<img src="{{ blueAvatar|imagine_filter('avatar_thumb') }}"
alt="{{ blueName }}"
class="bshare-avatar__img">
{% else %}
{{ blueName|slice(0,2)|upper }}
{% 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 %}
{# Details #} {% 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">
@@ -93,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 %}
@@ -112,7 +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
@@ -121,8 +271,6 @@
<i class="fas fa-house"></i> Homepage <i class="fas fa-house"></i> Homepage
</a> </a>
</div> </div>
</div> </div>
</div> </div>
{% endblock %} {% endblock %}

View File

@@ -3,18 +3,27 @@
{% block title %} - The Game{% endblock %} {% block title %} - The Game{% endblock %}
{% block metas %} {% block metas %}
<meta property="og:url" content="{{ url('MineSeekerBundle_homepage') }}"/> {%- set _ogImage = 'https://' ~ app.request.host ~ asset('/images/mine-1600x627.png') -%}
<meta property="og:url" content="{{ url('MineSeekerBundle_homepage') | replace({'http://': 'https://'}) }}"/>
<meta property="og:type" content="website"/> <meta property="og:type" content="website"/>
<meta property="og:title" content="MineSeeker"/> <meta property="og:site_name" content="MineSeeker"/>
<meta property="og:description" content="A multiplayer minesweeper game"/> <meta property="og:locale" content="en_US"/>
<meta property="og:image" <meta property="og:title" content="MineSeeker — Multiplayer Minesweeper"/>
content="{{ app.request.getSchemeAndHttpHost() }}{{ asset('images/mine-1600x627.png') }}"/> <meta property="og:description"
content="Race a friend on a hidden minefield. Real-time 1v1 minesweeper in your browser — no account needed. Just play."/>
<meta property="og:image" content="{{ _ogImage }}"/>
<meta property="og:image:width" content="1600"/>
<meta property="og:image:height" content="627"/>
<meta property="og:image:alt" content="MineSeeker — Multiplayer Minesweeper"/>
<meta name="twitter:card" content="summary_large_image"/>
<meta name="twitter:title" content="MineSeeker — Multiplayer Minesweeper"/>
<meta name="twitter:description"
content="Race a friend on a hidden minefield. Real-time 1v1 minesweeper in your browser — no account needed. Just play."/>
<meta name="twitter:image" content="{{ _ogImage }}"/>
{% 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">
@@ -45,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>
@@ -61,6 +73,10 @@
<h1>No account needed.<br>Just play.</h1> <h1>No account needed.<br>Just play.</h1>
{% endif %} {% endif %}
<a href="{{ path('MineSeekerBundle_gamePlay') }}" class="hero-cta">Play Now</a> <a href="{{ path('MineSeekerBundle_gamePlay') }}" class="hero-cta">Play Now</a>
<p class="hero-donate-text">Love this game?</p>
<a href="https://ko-fi.com/splendidbear" target="_blank" rel="noopener" class="hero-donate">
Buy me a coffee
</a>
</div> </div>
</section> </section>
@@ -80,7 +96,8 @@
<p class="feature-block__body"> <p class="feature-block__body">
Create a free account and every game you play gets recorded. Create a free account and every game you play gets recorded.
Watch your win rate climb, revisit past battles, and prove your dominance Watch your win rate climb, revisit past battles, and prove your dominance
on the board — one detonation at a time. on the board — one detonation at a time. Share your greatest victories with friends,
replay epic showdowns, and celebrate your legendary moments.
</p> </p>
{% if not is_granted("IS_AUTHENTICATED_REMEMBERED") %} {% if not is_granted("IS_AUTHENTICATED_REMEMBERED") %}
<a href="{{ path('MineSeekerBundle_register') }}" class="feature-block__cta"> <a href="{{ path('MineSeekerBundle_register') }}" class="feature-block__cta">
@@ -110,6 +127,86 @@
</div> </div>
</section> </section>
<section class="feature-block feature-block--privacy">
<div class="feature-block__inner">
<div class="feature-block__visual feature-block__visual--privacy">
<i class="fas fa-shield"></i>
<i class="fas fa-lock"></i>
<i class="fas fa-eye-slash"></i>
</div>
<div class="feature-block__text">
<p class="feature-block__label">Your data, your control</p>
<h2 class="feature-block__title">Privacy by Design</h2>
<p class="feature-block__body">
We believe in transparency and simplicity. Your <strong>username</strong> is your identity here—
we never expose your email publicly. Forgot your password? No problem. We keep no social integrations,
no third-party tracking, and absolutely zero AI-generated content. Just a pure, clean game experience.
</p>
</div>
</div>
</section>
<section class="feature-block feature-block--reverse feature-block--practice">
<div class="feature-block__inner">
<div class="feature-block__visual feature-block__visual--practice">
<i class="fas fa-laptop"></i>
<i class="fab fa-linux"></i>
<i class="fab fa-apple"></i>
<i class="fab fa-windows"></i>
</div>
<div class="feature-block__text">
<p class="feature-block__label">Train & Compete</p>
<h2 class="feature-block__title">Multiplayer Online, Solo Practice</h2>
<p class="feature-block__body">
Love the challenge of real-time battles? Here, you'll compete live against friends and players worldwide.
Want to sharpen your skills solo first? Download our standalone versions for Windows, macOS, and Linux.
Practice on your own time, then bring your A-game online.
</p>
<div class="practice-links">
<a
href="https://flathub.org/en/apps/org.gnome.Mines"
target="_blank"
rel="noopener"
class="practice-link"
>
<img
src="{{ asset('images/another-games/gnome-mines.png') }}"
alt="Mines"
class="practice-link-icon"
/>
Linux (Flatpak) &bull; GNOME Mines
</a>
<a
href="https://apps.microsoft.com/detail/9wzdncrfhwcn?hl=en-US&gl=HU"
target="_blank"
rel="noopener"
class="practice-link"
>
<img
src="{{ asset('images/another-games/microsoft-minesweeper.png') }}"
alt="Microsoft Minesweeper"
class="practice-link-icon"
/>
Windows &bull; Microsoft Minesweeper
</a>
<a
href="https://apps.apple.com/us/app/minesweeper/id1404304731"
target="_blank"
rel="noopener"
class="practice-link"
>
<img
src="{{ asset('images/another-games/apple-minesweeper.png') }}"
alt="Minesweeper"
class="practice-link-icon"
/>
Apple &bull; Minesweeper
</a>
</div>
</div>
</div>
</section>
<div class="tech-section"> <div class="tech-section">
<p class="tech-label">Built with</p> <p class="tech-label">Built with</p>
<div class="tech-logos"> <div class="tech-logos">
@@ -128,10 +225,13 @@
<a href="https://vitejs.dev" target="_blank" rel="noopener" class="tech-link"> <a href="https://vitejs.dev" target="_blank" rel="noopener" class="tech-link">
<img src="{{ asset('images/technologies/vite.svg') }}" alt="Vite"/> <img src="{{ asset('images/technologies/vite.svg') }}" alt="Vite"/>
</a> </a>
<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.jetbrains.com/phpstorm" target="_blank" rel="noopener" class="tech-link"> <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">
<img src="{{ asset('images/technologies/phpstorm.svg') }}" alt="PHPStorm"/> <img src="{{ asset('images/technologies/phpstorm.svg') }}" alt="PHPStorm"/>
</a> </a>
<a href="https://archlinux.org" target="_blank" rel="noopener" class="tech-link"> <a href="https://archlinux.org" target="_blank" rel="noopener" class="tech-link">
@@ -157,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,24 +10,26 @@
<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 }}">
</div> </div>
</div> </div>
{% endblock %} {% endblock %}
{% block metas %} {% block metas %}
<meta property="og:url" content="{{ url('MineSeekerBundle_gamePlay') }}"/> <meta property="og:url" content="{{ url('MineSeekerBundle_gamePlay') | replace({'http://': 'https://'}) }}"/>
<meta property="og:type" content="website"/> <meta property="og:type" content="website"/>
<meta property="og:title" content="Your friend challenges YOU!"/> <meta property="og:title" content="Your friend challenges YOU!"/>
<meta property="og:description" content="Do you accept the challenge?"/> <meta property="og:description" content="Do you accept the challenge?"/>
<meta property="og:image" <meta property="og:image"
content="{{ app.request.getSchemeAndHttpHost() }}{{ asset('/images/mine-1600x627.png') }}"/> content="https://{{ app.request.host }}{{ asset('/images/mine-1600x627.png') }}"/>
{% endblock %} {% endblock %}
{% block stylesheets %} {% block stylesheets %}
{{ vite_entry_link_tags('mineseekerStyle') }} {{ vite_entry_link_tags('mineseekerStyle') }}
<style> <style>
.mine-container { .mine-container {
background: url('/images/bg-mineseeker-{{ random(1) }}-outbg.jpg') no-repeat; background: url('/images/bg-mineseeker-{{ random(1) }}-outbg.jpg') no-repeat;
@@ -37,5 +39,6 @@
{% block javascripts %} {% block javascripts %}
{{ parent() }} {{ parent() }}
<script src="https://www.google.com/recaptcha/api.js?render={{ recaptcha_site_key }}" async defer></script>
{{ vite_entry_script_tags('mineseeker', { dependency: 'react' }) }} {{ vite_entry_script_tags('mineseeker', { dependency: 'react' }) }}
{% endblock %} {% endblock %}

View File

@@ -2,12 +2,135 @@
{% block title %} - Contact{% endblock %} {% block title %} - Contact{% endblock %}
{% block body %} {% block metas %}
<div class="txt"> {%- set _ogImage = 'https://' ~ app.request.host ~ asset('/images/mine-1600x627.png') -%}
<h2>Contact and user support</h2> <meta property="og:url" content="{{ url('MineSeekerBundle_contact') | replace({'http://': 'https://'}) }}"/>
<meta property="og:type" content="website"/>
<h3>Under construction</h3> <meta property="og:site_name" content="MineSeeker"/>
<meta property="og:title" content="Contact · MineSeeker"/>
<a href="mailto:langlasz@gmail.com">langlasz@gmail.com</a> <meta property="og:description" content="Get in touch with the MineSeeker team."/>
</div> <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="Contact · MineSeeker"/>
<meta name="twitter:description" content="Get in touch with the MineSeeker team."/>
<meta name="twitter:image" content="{{ _ogImage }}"/>
{% endblock %}
{% block body %}
<div class="txt">
<h2 style="text-align: center;">Contact and user support</h2>
{% for message in app.flashes('contact_success') %}
<div class="auth-card auth-card--sent" style="margin: 20px auto; max-width: 600px;">
<div class="auth-sent-icon"><i class="far fa-envelope"></i></div>
<h3 style="color: #667eea; margin: 16px 0;">Message Sent!</h3>
<p class="auth-sent-note">{{ message }}</p>
<a href="{{ path('MineSeekerBundle_homepage') }}" class="auth-submit" style="text-decoration:none; margin-top:16px;">
Back to Home
</a>
</div>
{% else %}
<p style="text-align: center; color: #666; margin-bottom: 30px;">
Have a question, feedback, or need support? We'd love to hear from you!
</p>
<div class="auth-card" style="max-width: 600px; margin: 0 auto;">
{{ form_start(form, {attr: {class: 'auth-form'}}) }}
<div class="auth-field">
<label for="{{ form.name.vars.id }}" class="auth-label">Name *</label>
<div class="auth-input-wrap">
<i class="fas fa-user auth-input-icon"></i>
{{ form_widget(form.name, {
attr: {
class: 'auth-input' ~ (not form.name.vars.valid ? ' auth-input--error' : ''),
placeholder: 'Your name',
autofocus: true,
}
}) }}
</div>
{% if not form.name.vars.valid %}
{% for error in form.name.vars.errors %}
<p class="auth-field-error"><i class="fas fa-circle-exclamation"></i> {{ error.message }}</p>
{% endfor %}
{% endif %}
</div>
<div class="auth-field">
<label for="{{ form.email.vars.id }}" class="auth-label">Email *</label>
<div class="auth-input-wrap">
<i class="fas fa-envelope auth-input-icon"></i>
{{ form_widget(form.email, {
attr: {
class: 'auth-input' ~ (not form.email.vars.valid ? ' auth-input--error' : ''),
placeholder: 'your.email@example.com',
}
}) }}
</div>
{% if not form.email.vars.valid %}
{% for error in form.email.vars.errors %}
<p class="auth-field-error"><i class="fas fa-circle-exclamation"></i> {{ error.message }}</p>
{% endfor %}
{% endif %}
</div>
<div class="auth-field">
<label for="{{ form.content.vars.id }}" class="auth-label">Message *</label>
<div class="auth-input-wrap">
<i class="fas fa-comment-dots auth-input-icon" style="top: 16px;"></i>
{{ form_widget(form.content, {
attr: {
class: 'auth-input' ~ (not form.content.vars.valid ? ' auth-input--error' : ''),
placeholder: 'Tell us what\'s on your mind...',
rows: 6,
style: 'min-height: 150px; resize: vertical;'
}
}) }}
</div>
{% if not form.content.vars.valid %}
{% for error in form.content.vars.errors %}
<p class="auth-field-error"><i class="fas fa-circle-exclamation"></i> {{ error.message }}</p>
{% endfor %}
{% endif %}
</div>
<div class="auth-field">
<label class="auth-checkbox-label" style="display: flex; align-items: flex-start; cursor: pointer; user-select: none;">
{{ form_widget(form.consent, {
attr: {
class: 'auth-checkbox',
style: 'margin-right: 10px; margin-top: 3px;'
}
}) }}
<span style="flex: 1; font-size: 14px; line-height: 1.5; color: #666;">
I have read the <a href="{{ path('MineSeekerBundle_privacy') }}" target="_blank" style="color: #667eea; text-decoration: none;">Privacy and Data Processing Policy</a> and I consent to the processing of my data. *
</span>
</label>
{% if not form.consent.vars.valid %}
{% for error in form.consent.vars.errors %}
<p class="auth-field-error"><i class="fas fa-circle-exclamation"></i> {{ error.message }}</p>
{% endfor %}
{% endif %}
</div>
<button type="submit" class="auth-submit">
<i class="fas fa-paper-plane"></i> Send Message
</button>
{{ form_end(form) }}
</div>
{% endfor %}
</div>
{% endblock %}
{% block javascripts %}
{{ parent() }}
<script src="https://www.google.com/recaptcha/api.js?render={{ recaptcha_site_key }}" async defer></script>
{{ vite_entry_script_tags('contact', { dependency: 'react' }) }}
<div id="contact-form-wrapper"
data-site-key="{{ recaptcha_site_key }}"
data-recaptcha-field-id="{{ form.recaptcha.vars.id }}">
</div>
{% endblock %} {% endblock %}

Some files were not shown because too many files have changed in this diff Show More