Private
Public Access
1
0

Compare commits

..

17 Commits

Author SHA1 Message Date
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
65 changed files with 2984 additions and 484 deletions

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,194 +1,403 @@
Changelog
=========
# Changelog
v2026.2.1 (2026-04-15)
----------------------
## v2026.2.2-9 (2026-04-18)
New
~~~
- Add Contact page with email sending behaviour #4. [Lang]
- Add timer for the acceptance of the challenge #4. [Lang]
- 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]
### New
Changes
~~~~~~~
- Change the shareable battle - add avatars to it - even on the og tags
#4. [Lang]
- Change text #4. [Lang]
- Add donation button #4. [Lang]
- Protect the gameplay with recaptcha #4. [Lang]
- The waiting dialog is uncloseable until the time is up #4. [Lang]
- Add share button to the overlay when the game ends #4. [Lang]
- Make fancy og tags - and create a special one for battle sharing #4.
[Lang]
- The user's avatar will be saved as a uuid.extension #4. [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]
* Add rules page #4. [Lang]
Fix
~~~
- Missing font-awesome icons on bare-metal environment #4. [Lang]
- Quickfix for email sending #4. [Lang]
### Fix
Other
~~~~~
- Hg: pkg: new version release !skipChangelog. [Lang]
- Pkg: usr: solve the not-working mailing on dev env under docker #4.
[Lang]
- Deploy version 1.1.0 !deploy #11. [Lang]
* 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]
1.1.0 (2019-10-26)
------------------
## v2026.2.1-8 (2026-04-18)
Changes
~~~~~~~
- Reinit project - disable redis module and make the project compatible
w/ PHP7.3 #2. [Lang]
### Fix
* Quickfix for https-only login - & add user data when the user is not logged in #4. [Lang]
0.4.0 (2019-10-26)
------------------
- 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]
## v2026.2.1-7 (2026-04-16)
### Changes
* Add consent checkbox to user's registration - and fix the sharing pics #4. [Lang]
* Add correct version numbering and CHANGELOG - and add the LICENSE #4. [Lang]
## v2026.2.1-6 (2026-04-16)
### Changes
* Update all texts on all pages - extend them with the game specific things #4. [Lang]
## v2026.2.1-5 (2026-04-16)
### Fix
* The meta tags does not have https scheme - nothing worked in configuration #4. [Lang]
## v2026.2.1-4 (2026-04-15)
### New
* Add notification email when a user is registered #4. [Lang]
### Changes
* Add notification on activation too #4. [Lang]
## v2026.2.1-2 (2026-04-15)
### Fix
* Another attempt to fix the email assets #4. [Lang]
## v2026.2.1-1 (2026-04-15)
### Fix
* The images does not shows in emails #4. [Lang]
## v2026.2.1-0 (2026-04-15)
### New
* Add Contact page with email sending behaviour #4. [Lang]
### Changes
* Add missing .env variable and increase the version number and add missing data from front-end and back-end deps descriptor #4. [Lang]
* Change the shareable battle - add avatars to it - even on the og tags #4. [Lang]
* Change text #4. [Lang]
### Fix
* The mailhog is crashed on development env #4. [Lang]
* The og tags did not have proper http schema - they should have https #4. [Lang]
## v2026.2.0-5 (2026-04-14)
### Changes
* Add donation button #4. [Lang]
## v2026.2.0-4 (2026-04-14)
### New
* Add timer for the acceptance of the challenge #4. [Lang]
### Changes
* Protect the gameplay with recaptcha #4. [Lang]
* The waiting dialog is uncloseable until the time is up #4. [Lang]
* Add share button to the overlay when the game ends #4. [Lang]
* Make fancy og tags - and create a special one for battle sharing #4. [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]

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
@@ -11,6 +11,9 @@ help:
@echo " make down - Stop and remove containers/networks"
@echo " make prune-everything - Prune volumes, networks and images (DANGEROUS!)"
@echo " make db-reset - Reset the database (drop, create, migrate) (DANGEROUS!)"
@echo " make ccp - Clear the production cache"
@echo " make cache-clear - Clear all caches (Vite, Symfony, node_modules)"
@echo " make og-cache-clear - Clear Open Graph cache only"
start:
docker compose up -d
@@ -51,3 +54,31 @@ db-reset:
bin/console doctrine:database:drop --force --if-exists --no-interaction
bin/console doctrine:database:create --if-not-exists --no-interaction
bin/console doctrine:migrations:migrate --no-interaction
ccp:
bin/console cache:clear --no-warmup --env=prod
cache-clear:
@echo "Clearing all caches..."
@rm -rf node_modules/.vite
@rm -rf .vite
@rm -rf var/og-cache
@php bin/console cache:clear --no-warmup
@echo "✓ Vite cache cleared"
@echo "✓ OG cache cleared"
@echo "✓ Symfony cache cleared"
@echo ""
@echo "Rebuilding assets..."
@bun run build
@echo ""
@echo "✓ All caches cleared and assets rebuilt!"
@echo " Next step: Refresh browser with Ctrl+Shift+R"
og-cache-clear:
@echo "Clearing Open Graph cache..."
@rm -rf var/og-cache
@echo "✓ OG cache cleared!"
@echo " Battle card images will be regenerated on next access"

View File

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

View File

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

View File

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

View File

@@ -33,3 +33,11 @@ main div.txt a {
&:hover { color: #c5e8ff; }
}
main div.txt img {
border-radius: 10px;
}
main div.txt .img-container {
text-align: center;
}

View File

@@ -45,33 +45,33 @@
}
// Bar chart — large, centre
i.fa-bar-chart {
font-size: 140px;
color: rgba(35, 111, 135, 0.35);
filter: drop-shadow(0 0 30px rgba(35, 111, 135, 0.3));
i.fa-chart-bar {
font-size: 160px;
color: rgba(35, 111, 135, 0.5);
filter: drop-shadow(0 0 40px rgba(35, 111, 135, 0.5));
}
// Trophy — top right
i.fa-trophy {
font-size: 64px;
top: 12px;
right: 30px;
color: rgba(246, 125, 82, 0.5);
filter: drop-shadow(0 0 16px rgba(246, 125, 82, 0.25));
font-size: 80px;
top: 0px;
right: 20px;
color: rgba(246, 125, 82, 0.7);
filter: drop-shadow(0 0 25px rgba(246, 125, 82, 0.4));
}
// History — bottom left
i.fa-history {
font-size: 52px;
bottom: 18px;
left: 30px;
color: rgba(149, 207, 245, 0.4);
filter: drop-shadow(0 0 12px rgba(149, 207, 245, 0.2));
// Clock history — bottom left
i.fa-clock-rotate-left {
font-size: 68px;
bottom: 0px;
left: 20px;
color: rgba(149, 207, 245, 0.65);
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-trophy { color: rgba(246, 125, 82, 0.75); }
&:hover i.fa-history { color: rgba(149, 207, 245, 0.65); }
&: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.9); filter: drop-shadow(0 0 35px rgba(246, 125, 82, 0.6)); }
&: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
@@ -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
.feature-block__text {
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) {
.feature-block__inner,
.feature-block--reverse .feature-block__inner {
@@ -199,4 +341,14 @@
.feature-block {
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;
}
// Left: brand block
.footer-brand {
display: flex;
flex-direction: column;
@@ -55,7 +54,6 @@ footer {
line-height: 1.5;
}
// Right: navigation
.footer-nav-label {
font: 700 11px 'Rajdhani', sans-serif;
text-transform: uppercase;
@@ -91,7 +89,6 @@ footer {
}
}
// Bottom copyright bar
.footer-copy {
border-top: 1px solid rgba(255, 255, 255, 0.05);
padding: 16px 60px;

View File

@@ -859,6 +859,104 @@
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 {
display: inline-flex;
align-items: center;

View File

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

View File

@@ -0,0 +1,242 @@
/*!*
* 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);
}
.bsd-stat-desc {
font-size: 11px;
color: rgba(255, 255, 255, 0.48);
line-height: 1.25;
}
.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 {
background: url('/images/bg-corner-outbg.png') no-repeat top left;
background-size: 100%;
width: 100%;
height: 100%;
}

View File

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

View File

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

View File

@@ -50,7 +50,7 @@ const RESULT_META = {
},
};
function Avatar({ name, color, avatarUrl }) {
function Avatar({ name, color, avatarUrl, bonusPoints = 0 }) {
const isRed = 'red' === color;
const initials = (name || '?').slice(0, 2).toUpperCase();
@@ -66,31 +66,53 @@ function Avatar({ name, color, avatarUrl }) {
const textColor = isRed ? '#f67d52' : '#95cff5';
return (
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 10 }}>
<div style={{
width: 72, height: 72, borderRadius: '50%',
background: avatarUrl ? 'transparent' : gradient,
border: `2px solid ${border}`,
boxShadow: glow,
display: 'flex', alignItems: 'center', justifyContent: 'center',
font: '800 24px \'Rajdhani\', sans-serif',
color: textColor,
letterSpacing: 2,
overflow: 'hidden',
}}
>
{avatarUrl ? (
<img
src={avatarUrl}
alt={name}
style={{
width: '100%',
height: '100%',
objectFit: 'cover',
}}
/>
) : (
initials
<div 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={{
@@ -210,13 +232,22 @@ export default function BattleDialog({ games }) {
</div>
</div>
<div className="bd-vs-panel">
<Avatar name={game.redName} color="red" avatarUrl={game.redAvatar} />
<Avatar name={game.redName} color="red" avatarUrl={game.redAvatar} bonusPoints={game.redBonusPoints > game.blueBonusPoints ? game.redBonusPoints : 0} />
<div className="bd-vs-center">
<div className="bd-vs-score">
<span className="bd-vs-score__red">{game.redPoints ?? '—'}</span>
<span className="bd-vs-score__sep">:</span>
<span className="bd-vs-score__blue">{game.bluePoints ?? '—'}</span>
</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-result-badge"
@@ -225,7 +256,7 @@ export default function BattleDialog({ games }) {
<i className={`fa ${meta.icon}`} /> {meta.label}
</div>
</div>
<Avatar name={game.blueName} color="blue" avatarUrl={game.blueAvatar} />
<Avatar name={game.blueName} color="blue" avatarUrl={game.blueAvatar} bonusPoints={game.blueBonusPoints > game.redBonusPoints ? game.blueBonusPoints : 0} />
</div>
<div className="bd-stats">
<StatRow icon="fa-calendar" label="Date" value={game.date ?? '—'} />
@@ -244,6 +275,62 @@ export default function BattleDialog({ games }) {
<StatRow icon="fa-clock-o" label="Started" value={game.created} />
)}
</div>
{(0 < game.redBonusPoints
|| 0 < game.blueBonusPoints
|| game.redBonusStats?.blindHits
|| game.blueBonusStats?.blindHits
) && (
<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" 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} />}
{!game.redBonusStats?.blindHits && !game.redBonusStats?.chainBest && !game.redBonusStats?.edgeMines && !game.redBonusStats?.lastMineHits && !game.redBonusStats?.biggestReveal
&& <StatRow icon="fa-minus-circle" label="Status" value="No bonuses" valueColor="rgba(255,255,255,0.3)" />}
</div>
</div>
{/* Blue Bonus */}
<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" 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} />}
{!game.blueBonusStats?.blindHits && !game.blueBonusStats?.chainBest && !game.blueBonusStats?.edgeMines && !game.blueBonusStats?.lastMineHits && !game.blueBonusStats?.biggestReveal
&& <StatRow icon="fa-minus-circle" label="Status" value="No bonuses" valueColor="rgba(255,255,255,0.3)" />}
</div>
</div>
</div>
</div>
)}
</div>
</Dialog>
</ThemeProvider>

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

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

View File

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

View File

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

View File

@@ -132,7 +132,18 @@ export const GameProvider = ({ children }) => {
};
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) {
sounds.current.bomb.play();
@@ -176,6 +187,18 @@ export const GameProvider = ({ children }) => {
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 }));
syncBlue(p => ({ ...p, enabledBomb: bp <= rp }));

View File

@@ -34,9 +34,23 @@ export const IMAGES = {
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 = {
name: '...', desc: '', active: false, mines: 0, haveBomb: true, enabledBomb: true,
registered: false, avatar: null,
bonusPoints: 0, bonusStats: { ...BONUS_STATS_DEF },
};
export const DESC = {

View File

@@ -7,4 +7,4 @@
* 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

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

View File

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

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

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

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

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

View File

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

View File

@@ -163,6 +163,10 @@ class ProfileController extends AbstractController
'result' => $result,
'myPoints' => $myPts,
'oppPoints' => $oppPts,
'redBonusPoints' => $game->getRedBonusPoints() ?? 0,
'blueBonusPoints' => $game->getBlueBonusPoints() ?? 0,
'redBonusStats' => $game->getRedBonusStats() ?? [],
'blueBonusStats' => $game->getBlueBonusStats() ?? [],
];
}, $recent),
'chartData' => [
@@ -197,6 +201,10 @@ class ProfileController extends AbstractController
$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') {
$summary = "$redName resigned — $blueName wins";
@@ -215,16 +223,20 @@ class ProfileController extends AbstractController
}
return $this->render('Game/battle_share.html.twig', [
'game' => $game,
'redName' => $redName,
'blueName' => $blueName,
'redPts' => $redPts,
'bluePts' => $bluePts,
'resign' => $resign,
'redAvatar' => $redAvatar,
'blueAvatar' => $blueAvatar,
'ogTitle' => "MineSeeker · $summary",
'ogDesc' => "Watch the battle replay: $summary — played on MineSeeker, the multiplayer minesweeper.",
'game' => $game,
'redName' => $redName,
'blueName' => $blueName,
'redPts' => $redPts,
'bluePts' => $bluePts,
'resign' => $resign,
'redAvatar' => $redAvatar,
'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.",
]);
}

View File

@@ -17,8 +17,10 @@ use App\Form\ResetPasswordFormType;
use App\Repository\UserRepository;
use DateTime;
use Doctrine\ORM\EntityManagerInterface;
use LogicException;
use Symfony\Bridge\Twig\Mime\TemplatedEmail;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Attribute\AsController;
@@ -41,6 +43,12 @@ use Symfony\Component\Security\Http\Authentication\AuthenticationUtils;
#[AsController]
class SecurityController extends AbstractController
{
public function __construct(
#[Autowire(env: 'APP_CONTACT_MAIL_ADDRESS')]
private readonly string $appContactMailAddress,
) {
}
#[Route('/login', name: 'MineSeekerBundle_login')]
public function login(AuthenticationUtils $authenticationUtils): Response
{
@@ -57,7 +65,7 @@ class SecurityController extends AbstractController
#[Route('/logout', name: 'MineSeekerBundle_logout', methods: ['POST'])]
public function logout(): never
{
throw new \LogicException('This action is intercepted by the security firewall.');
throw new LogicException('This action is intercepted by the security firewall.');
}
#[Route('/register', name: 'MineSeekerBundle_register')]
@@ -92,7 +100,7 @@ class SecurityController extends AbstractController
UrlGeneratorInterface::ABSOLUTE_URL,
);
// Ensure HTTPS scheme in production
/** Ensure HTTPS scheme in production */
if ($this->getParameter('kernel.environment') === 'prod') {
$activationUrl = str_replace('http://', 'https://', $activationUrl);
}
@@ -109,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());
return $this->redirectToRoute('MineSeekerBundle_register');
@@ -148,7 +169,7 @@ class SecurityController extends AbstractController
UrlGeneratorInterface::ABSOLUTE_URL,
);
// Ensure HTTPS scheme in production
/** Ensure HTTPS scheme in production */
if ($this->getParameter('kernel.environment') === 'prod') {
$resetUrl = str_replace('http://', 'https://', $resetUrl);
}
@@ -209,7 +230,7 @@ class SecurityController extends AbstractController
}
#[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]);
@@ -221,6 +242,19 @@ class SecurityController extends AbstractController
$user->setIsVerified(true)->setVerificationToken(null);
$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() . '!');
return $this->redirectToRoute('MineSeekerBundle_login');

View File

@@ -62,6 +62,18 @@ class PlayedGame
#[Column(length: 7, nullable: true)]
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)]
private ?DateTime $created = null;
@@ -222,6 +234,46 @@ class PlayedGame
$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
{
return $this->created;
@@ -247,3 +299,5 @@ class PlayedGame
return $this->steps;
}
}

View File

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

View File

@@ -12,6 +12,7 @@ namespace App\Form;
use App\Entity\User;
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\PasswordType;
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\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;
@@ -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);
}

View File

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

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

@@ -56,8 +56,9 @@ class BattleCardGenerator
{
$path = $this->cachePath((int)$game->getId());
// Always regenerate to ensure bonus points are included
if (is_file($path)) {
return $path;
unlink($path);
}
if (!is_dir($this->cacheDir)) {
@@ -154,6 +155,12 @@ class BattleCardGenerator
$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;
@@ -169,11 +176,11 @@ class BattleCardGenerator
}
if ($resultText !== '') {
$this->centeredText($im, $resultText, 30, self::WIDTH / 2, 460, $resultColor);
$this->centeredText($im, $resultText, 30, self::WIDTH / 2, 475, $resultColor);
}
if ($resign) {
$this->centeredText($im, ucfirst($resign) . ' resigned', 18, self::WIDTH / 2, 498, $muted);
$this->centeredText($im, ucfirst($resign) . ' resigned', 18, self::WIDTH / 2, 508, $muted);
}
$this->centeredText($im, 'mineseeker.hu', 16, self::WIDTH / 2, self::HEIGHT - 20, $muted);

View File

@@ -18,14 +18,15 @@ use App\Entity\User;
use App\Interfaces\TopicManagerInterface;
use App\Repository\PlayedGameRepository;
use App\Repository\UserRepository;
use Liip\ImagineBundle\Imagine\Cache\CacheManager;
use DateTimeInterface;
use DateTime;
use DateTimeInterface;
use Doctrine\ORM\EntityManagerInterface;
use Exception;
use JsonException;
use Liip\ImagineBundle\Imagine\Cache\CacheManager;
use Psr\Log\LoggerInterface;
use RuntimeException;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Mercure\HubInterface;
use Symfony\Component\Mercure\Update;
use Symfony\Component\Security\Core\User\UserInterface;
@@ -43,16 +44,16 @@ use Symfony\Component\Security\Core\User\UserInterface;
readonly class TopicManager implements TopicManagerInterface
{
public function __construct(
private EntityManagerInterface $em,
private HubInterface $hub,
private EntityManagerInterface $entityManager,
private LoggerInterface $logger,
private CacheManager $cacheManager,
private PlayedGameRepository $playedGameRepository,
private UserRepository $userRepository,
private CacheManager $cacheManager,
) {
}
public function subscribe(string $gameAssoc, string $userName, ?UserInterface $user): void
public function subscribe(string $gameAssoc, string $userName, ?UserInterface $user, Request $request): void
{
$playedGame = $this->getPlayedGame($gameAssoc);
if (null === $playedGame) {
@@ -70,7 +71,7 @@ readonly class TopicManager implements TopicManagerInterface
/** Save the player to the database on a fresh join */
if (!$isKnown && $count < 2) {
$users = $this->saveUserToDb($gameAssoc, $userName, $user, $count + 1);
$users = $this->saveUserToDb($gameAssoc, $userName, $user, $count + 1, $request);
$count = $this->getPlayerCount($users);
}
@@ -95,8 +96,8 @@ readonly class TopicManager implements TopicManagerInterface
if ($count === 1) {
// One player waiting — mark as active and announce to the lobby
$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';
$this->publishToLobby([
@@ -120,8 +121,8 @@ readonly class TopicManager implements TopicManagerInterface
$users = $this->getUserCollection($playedGame);
if ($this->getPlayerCount($users) === 1) {
$playedGame->setUpdated(new DateTime('2000-01-01 00:00:00'));
$this->entityManager->persist($playedGame);
$this->entityManager->flush();
$this->em->persist($playedGame);
$this->em->flush();
$this->publishToLobby(['action' => 'leave', 'gameAssoc' => $gameAssoc]);
}
}
@@ -168,9 +169,6 @@ readonly class TopicManager implements TopicManagerInterface
return $data;
}
// ------------------------------------------------------------------ //
// Normal move
// ------------------------------------------------------------------ //
$coords = $event['coords'];
$player = $event['player']; // 'red' | 'blue'
$isBomb = (bool)$event['bomb'];
@@ -178,25 +176,40 @@ readonly class TopicManager implements TopicManagerInterface
$playedGame = $this->getPlayedGame($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);
// Determine which cells to reveal for this step
/** Determine which cells to reveal for this step */
if ($isBomb) {
$revealedCells = $this->getBombRevealedCells($grid, $coords[0], $coords[1], $alreadyRevealed);
} 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']];
} else {
$revealedCells = $this->floodFill($grid, $coords[0], $coords[1], $alreadyRevealed);
}
$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);
$bluePoints = ($playedGame->getBluePoints() ?? 0) + ('blue' === $player ? $minesFound : 0);
$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 = [];
if ($gameOver) {
$finalRevealed = $alreadyRevealed;
@@ -206,23 +219,27 @@ readonly class TopicManager implements TopicManagerInterface
$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);
$count = $this->getPlayerCount($users);
$topic = 'mineseeker/channel/' . $gameAssoc;
$data = [
'coords' => $coords,
'player' => $player,
'bomb' => $isBomb,
'revealedCells' => $revealedCells,
'minesFound' => $minesFound,
'redPoints' => $redPoints,
'bluePoints' => $bluePoints,
'resign' => null,
'gameOver' => $gameOver,
'leftMines' => $leftMines,
'coords' => $coords,
'player' => $player,
'bomb' => $isBomb,
'revealedCells' => $revealedCells,
'minesFound' => $minesFound,
'redPoints' => $redPoints,
'bluePoints' => $bluePoints,
'resign' => null,
'gameOver' => $gameOver,
'leftMines' => $leftMines,
'redBonusPoints' => $bonusData['redBonusPoints'],
'blueBonusPoints' => $bonusData['blueBonusPoints'],
'redBonusStats' => $bonusData['redBonusStats'],
'blueBonusStats' => $bonusData['blueBonusStats'],
];
try {
@@ -243,10 +260,6 @@ readonly class TopicManager implements TopicManagerInterface
return $data;
}
// ------------------------------------------------------------------ //
// Grid helpers
// ------------------------------------------------------------------ //
/** Load the grid rows from the database as a 2-D array. */
private function loadGrid(string $gameAssoc): array
{
@@ -266,6 +279,155 @@ readonly class TopicManager implements TopicManagerInterface
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).
* Reveals the clicked cell plus all connected zero-value cells and their non-mine borders.
@@ -403,10 +565,6 @@ readonly class TopicManager implements TopicManagerInterface
return $mines;
}
// ------------------------------------------------------------------ //
// Database helpers
// ------------------------------------------------------------------ //
private function getPlayedGame(string $gameAssoc): ?PlayedGame
{
return $this->playedGameRepository->findOneByGameAssoc($gameAssoc);
@@ -424,8 +582,8 @@ readonly class TopicManager implements TopicManagerInterface
{
$playedGame = $this->getPlayedGame($gameAssoc);
$playedGame->setResign($color);
$this->entityManager->persist($playedGame);
$this->entityManager->flush();
$this->em->persist($playedGame);
$this->em->flush();
}
private function saveStepToDb(
@@ -435,6 +593,7 @@ readonly class TopicManager implements TopicManagerInterface
array $revealedCells,
int $redPoints,
int $bluePoints,
array $bonusData = []
): void {
try {
$playedGame = $this->getPlayedGame($gameAssoc);
@@ -447,31 +606,44 @@ readonly class TopicManager implements TopicManagerInterface
$step->setRevealedCells($revealedCells);
$step->setPlayedGame($playedGame);
$step->setCreated(new DateTime());
$this->entityManager->persist($step);
$this->em->persist($step);
$playedGame->setRedPoints($redPoints);
$playedGame->setBluePoints($bluePoints);
$playedGame->setRedExplodedBomb((bool)$event['bomb'] && 'red' === $player ? true : null);
$playedGame->setBlueExplodedBomb((bool)$event['bomb'] && 'blue' === $player ? true : null);
$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) {
$this->logger->error($e->getMessage());
}
}
private function saveUserToDb(string $gameAssoc, string $userName, ?UserInterface $user, int $count): array
{
private function saveUserToDb(
string $gameAssoc,
string $userName,
?UserInterface $user,
int $count,
Request $request
): array {
$playedGame = $this->getPlayedGame($gameAssoc);
null !== $user
? $this->saveRegisteredUser($userName, $count, $playedGame)
: $this->saveAnonUser($userName, $count, $playedGame);
: $this->saveAnonUser($userName, $count, $playedGame, $request);
$this->entityManager->persist($playedGame);
$this->entityManager->flush();
$this->em->persist($playedGame);
$this->em->flush();
return $this->getUserCollection($playedGame);
}
@@ -495,13 +667,16 @@ readonly class TopicManager implements TopicManagerInterface
}
}
private function saveAnonUser(string $userName, int $count, PlayedGame $playedGame): void
private function saveAnonUser(string $userName, int $count, PlayedGame $playedGame, Request $request): void
{
try {
$anon = new Gamer();
$anon->setUsername($userName);
$anon->setUserName($userName);
$anon->setIp($request->getClientIp());
$anon->setCountry($this->extractCountry($request));
$anon->setUserAgent($request->headers->get('User-Agent'));
$anon->setConnTimestamp(new DateTime());
$this->entityManager->persist($anon);
$this->em->persist($anon);
if ($count === 1) {
$random = random_int(0, 1);
@@ -518,8 +693,8 @@ readonly class TopicManager implements TopicManagerInterface
private function getUserCollection(PlayedGame $playedGame): array
{
$redUser = $playedGame->getRed();
$blueUser = $playedGame->getBlue();
$redUser = $playedGame->getRed();
$blueUser = $playedGame->getBlue();
return [
'red' => null !== $redUser ? $redUser->getUsername() : '',
@@ -527,11 +702,11 @@ readonly class TopicManager implements TopicManagerInterface
'redAnon' => null !== $playedGame->getRedAnon() ? $playedGame->getRedAnon()->getUserName() : '',
'blueAnon' => null !== $playedGame->getBlueAnon() ? $playedGame->getBlueAnon()->getUserName() : '',
'redAvatar' => null !== $redUser && null !== $redUser->getAvatarPath()
? $this->cacheManager->generateUrl($redUser->getAvatarPath(), 'avatar_thumb')
: null,
? $this->cacheManager->generateUrl($redUser->getAvatarPath(), 'avatar_thumb')
: null,
'blueAvatar' => null !== $blueUser && null !== $blueUser->getAvatarPath()
? $this->cacheManager->generateUrl($blueUser->getAvatarPath(), 'avatar_thumb')
: null,
? $this->cacheManager->generateUrl($blueUser->getAvatarPath(), 'avatar_thumb')
: null,
];
}
@@ -585,4 +760,27 @@ readonly class TopicManager implements TopicManagerInterface
$this->logger->error('Lobby publish error: ' . $e->getMessage());
}
}
private function extractCountry(Request $request): ?string
{
/** Common headers used by CDNs and proxies to pass country information */
$countryHeaders = [
'CF-IPCountry', // Cloudflare
'CloudFront-Viewer-Country', // AWS CloudFront
'X-Country-Code', // Custom header
'X-Geoip-Country', // Generic GeoIP header
];
foreach ($countryHeaders as $header) {
$country = $request->headers->get($header);
if (empty($country)) {
continue;
}
return substr($country, 0, 100);
}
return null;
}
}

View File

@@ -3,8 +3,8 @@
{% block title %} - Battle Report{% endblock %}
{% block metas %}
{%- set shareUrl = url('MineSeekerBundle_battle_share', { uuid: game.uuid }) -%}
{%- set _ogImage = url('MineSeekerBundle_og_battle', { uuid: game.uuid }) -%}
{%- set shareUrl = url('MineSeekerBundle_battle_share', { uuid: game.uuid }) | replace({'http://': 'https://'}) -%}
{%- set _ogImage = url('MineSeekerBundle_og_battle', { uuid: game.uuid }) | replace({'http://': 'https://'}) -%}
<meta property="og:url" content="{{ shareUrl }}"/>
<meta property="og:type" content="article"/>
<meta property="og:site_name" content="MineSeeker"/>
@@ -31,7 +31,7 @@
</div>
<div class="bshare-vs">
<div class="bshare-player bshare-player--red">
<div class="bshare-avatar bshare-avatar--red">
<div class="bshare-avatar bshare-avatar--red" style="position: relative;">
{% if redAvatar %}
<img src="{{ redAvatar|imagine_filter('avatar_thumb') }}"
alt="{{ redName }}"
@@ -39,6 +39,11 @@
{% 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>
<span class="bshare-player__name">{{ redName }}</span>
<span class="bshare-player__side">Red</span>
@@ -53,6 +58,15 @@
{% else %}
<div class="bshare-score bshare-score--na">— : —</div>
{% 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>
{% if resign == 'red' %}
<div class="bshare-badge bshare-badge--blue">
@@ -79,7 +93,7 @@
{% endif %}
</div>
<div class="bshare-player bshare-player--blue">
<div class="bshare-avatar bshare-avatar--blue">
<div class="bshare-avatar bshare-avatar--blue" style="position: relative;">
{% if blueAvatar %}
<img src="{{ blueAvatar|imagine_filter('avatar_thumb') }}"
alt="{{ blueName }}"
@@ -87,6 +101,11 @@
{% 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>
<span class="bshare-player__name">{{ blueName }}</span>
<span class="bshare-player__side">Blue</span>
@@ -118,6 +137,108 @@
</div>
{% endif %}
</div>
{# Bonus Stats Section #}
{% 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>
{# Blue Bonus #}
<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">
<a href="{{ path('MineSeekerBundle_gamePlay') }}" class="bshare-btn">
<i class="fas fa-play"></i> Play MineSeeker

View File

@@ -3,8 +3,8 @@
{% block title %} - The Game{% endblock %}
{% block metas %}
{%- set _ogImage = app.request.getSchemeAndHttpHost() ~ asset('images/mine-1600x627.png') -%}
<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:site_name" content="MineSeeker"/>
<meta property="og:locale" content="en_US"/>
@@ -23,9 +23,7 @@
{% endblock %}
{% block header %}
<section
class="hero{% if app.request.attributes.get('_route') != 'MineSeekerBundle_homepage' %} hero--compact{% endif %}">
<section id="hero-auth">
<div class="hero-auth">
{% if is_granted("IS_AUTHENTICATED_REMEMBERED") %}
<a href="{{ path('MineSeekerBundle_profile') }}" class="hero-auth-btn hero-auth-btn--profile">
@@ -56,7 +54,10 @@
</a>
{% endif %}
</div>
</section>
<section
class="hero{% if app.request.attributes.get('_route') != 'MineSeekerBundle_homepage' %} hero--compact{% endif %}">
<a class="hero-logo" href="{{ path('MineSeekerBundle_homepage') }}">
<img src="{{ asset('images/mine-logo-txt.png') }}" alt="MineSeeker"/>
</a>
@@ -95,7 +96,8 @@
<p class="feature-block__body">
Create a free account and every game you play gets recorded.
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>
{% if not is_granted("IS_AUTHENTICATED_REMEMBERED") %}
<a href="{{ path('MineSeekerBundle_register') }}" class="feature-block__cta">
@@ -125,6 +127,86 @@
</div>
</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">
<p class="tech-label">Built with</p>
<div class="tech-logos">
@@ -172,6 +254,7 @@
<p class="footer-nav-label">Navigate</p>
<ul>
<li><a href="{{ path('MineSeekerBundle_homepage') }}">Homepage</a></li>
<li><a href="{{ path('MineSeekerBundle_rules') }}">Game Rules</a></li>
<li><a href="{{ path('MineSeekerBundle_terms') }}">Terms of Use</a></li>
<li><a href="{{ path('MineSeekerBundle_privacy') }}">Privacy Policy</a></li>
<li><a href="{{ path('MineSeekerBundle_contact') }}">Contact</a></li>

View File

@@ -18,12 +18,12 @@
{% endblock %}
{% 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:title" content="Your friend challenges YOU!"/>
<meta property="og:description" content="Do you accept the challenge?"/>
<meta property="og:image"
content="{{ app.request.getSchemeAndHttpHost() }}{{ asset('/images/mine-1600x627.png') }}"/>
content="https://{{ app.request.host }}{{ asset('/images/mine-1600x627.png') }}"/>
{% endblock %}
{% block stylesheets %}

View File

@@ -3,8 +3,8 @@
{% block title %} - Contact{% endblock %}
{% block metas %}
{%- set _ogImage = app.request.getSchemeAndHttpHost() ~ asset('images/mine-1600x627.png') -%}
<meta property="og:url" content="{{ url('MineSeekerBundle_contact') }}"/>
{%- set _ogImage = 'https://' ~ app.request.host ~ asset('/images/mine-1600x627.png') -%}
<meta property="og:url" content="{{ url('MineSeekerBundle_contact') | replace({'http://': 'https://'}) }}"/>
<meta property="og:type" content="website"/>
<meta property="og:site_name" content="MineSeeker"/>
<meta property="og:title" content="Contact · MineSeeker"/>

View File

@@ -3,8 +3,8 @@
{% block title %} - Privacy Policy{% endblock %}
{% block metas %}
{%- set _ogImage = app.request.getSchemeAndHttpHost() ~ asset('images/mine-1600x627.png') -%}
<meta property="og:url" content="{{ url('MineSeekerBundle_privacy') }}"/>
{%- set _ogImage = 'https://' ~ app.request.host ~ asset('/images/mine-1600x627.png') -%}
<meta property="og:url" content="{{ url('MineSeekerBundle_privacy') | replace({'http://': 'https://'}) }}"/>
<meta property="og:type" content="website"/>
<meta property="og:site_name" content="MineSeeker"/>
<meta property="og:title" content="Privacy Policy · MineSeeker"/>
@@ -22,47 +22,133 @@
<div class="txt">
<h2>MineSeeker Privacy Policy</h2>
<p>Your privacy is important to us.</p>
<p>Your privacy is important to us. MineSeeker is built on principles of transparency and minimal data collection. We believe you should have full control over your personal information and understand exactly how it's used.</p>
<p>It is MineSeeker's policy to respect your privacy regarding any information we may collect while operating
our
website. Accordingly, we have developed this privacy policy in order for you to understand how we collect,
use,
communicate, disclose and otherwise make use of personal information. We have outlined our privacy policy
below.</p>
<p>This Privacy Policy explains how we collect, use, protect, and manage your personal information when you use MineSeeker.</p>
<h3>1. Our Privacy Principles</h3>
<ul>
<li>We will collect personal information by lawful and fair means and, where appropriate, with the knowledge
or
consent of the individual concerned.
</li>
<li>Before or at the time of collecting personal information, we will identify the purposes for which
information is being collected.
</li>
<li>We will collect and use personal information solely for fulfilling those purposes specified by us and
for
other ancillary purposes, unless we obtain the consent of the individual concerned or as required by
law.
</li>
<li>Personal data should be relevant to the purposes for which it is to be used, and, to the extent
necessary
for those purposes, should be accurate, complete, and up-to-date.
</li>
<li>We will protect personal information by using reasonable security safeguards against loss or theft, as
well
as unauthorized access, disclosure, copying, use or modification.
</li>
<li>We will make readily available to customers information about our policies and practices relating to the
management of personal information.
</li>
<li>We will only retain personal information for as long as necessary for the fulfilment of those
purposes.
</li>
<li>We collect personal information by lawful and fair means, with your knowledge and consent.</li>
<li>We collect and use personal information solely for the purposes specified and no others.</li>
<li>Personal data is kept accurate, complete, and up-to-date.</li>
<li>We protect personal information with reasonable security safeguards against loss, theft, unauthorized access, disclosure, and modification.</li>
<li>We retain personal information only for as long as necessary.</li>
<li>We are transparent about our data practices and policies.</li>
</ul>
<p>We are committed to conducting our business in accordance with these principles in order to ensure that the
confidentiality of personal information is protected and maintained. MineSeeker may change this privacy
policy
from time to time at MineSeeker's sole discretion.</p>
<h3>2. What Information We Collect</h3>
<p><strong>Account Information:</strong> When you create a MineSeeker account, we collect:</p>
<ul>
<li><strong>Username</strong> - Your unique identifier in the game (publicly visible to other players)</li>
<li><strong>Email address</strong> - Used only for account recovery and password resets (never publicly exposed)</li>
<li><strong>Password</strong> - Securely hashed and encrypted (we never store plain-text passwords)</li>
<li><strong>Optional profile data</strong> - Avatar image (if you choose to upload one)</li>
</ul>
<p><strong>Game Data:</strong> To provide multiplayer functionality, we automatically collect:</p>
<ul>
<li>Game statistics (win/loss records, game duration, board size preferences)</li>
<li>Game history (replay data for sharing past battles)</li>
<li>Timestamps of games played</li>
</ul>
<p><strong>Technical Information:</strong> We collect minimal technical data:</p>
<ul>
<li>IP address (for multiplayer game sessions only, not stored long-term)</li>
<li>Browser type and version (for compatibility purposes)</li>
<li>Device information (to optimize game performance)</li>
</ul>
<h3>3. What We Do NOT Collect</h3>
<ul>
<li><strong>❌ No social authentication:</strong> We don't use Facebook, Google, Twitter, or other social platforms. You create your own account.</li>
<li><strong>❌ No AI-generated data:</strong> We don't use AI to profile you or generate insights about your behavior.</li>
<li><strong>❌ No tracking cookies or analytics:</strong> We don't use third-party analytics tools to track your movement across the web.</li>
<li><strong>❌ No advertisement profiling:</strong> We don't sell or share your data with advertisers.</li>
<li><strong>❌ No location tracking:</strong> We don't collect or store your location data.</li>
</ul>
<h3>4. How We Use Your Information</h3>
<p>Your information is used exclusively for these purposes:</p>
<ul>
<li><strong>Account management:</strong> Creating and maintaining your account</li>
<li><strong>Game functionality:</strong> Matchmaking, real-time multiplayer connections, and game replay storage</li>
<li><strong>Security:</strong> Preventing fraud, cheating, and unauthorized access</li>
<li><strong>Communication:</strong> Sending password resets or important service updates (very rarely)</li>
<li><strong>Improvement:</strong> Analyzing game performance to fix bugs and improve server stability</li>
</ul>
<p>We will <strong>never</strong> use your data for marketing, profiling, or any purpose other than those listed above.</p>
<h3>5. Data Retention</h3>
<ul>
<li><strong>Active accounts:</strong> Your data is retained while your account is active.</li>
<li><strong>Account deletion:</strong> If you delete your account, we retain only anonymized game statistics (for leaderboards) and permanently delete all personal information within 30 days.</li>
<li><strong>Game data:</strong> Your game history and replays are retained indefinitely unless you explicitly request deletion.</li>
<li><strong>Technical logs:</strong> IP addresses and technical logs are automatically deleted after 90 days.</li>
</ul>
<h3>6. Data Security</h3>
<ul>
<li>All passwords are hashed using industry-standard encryption (bcrypt)</li>
<li>Communication between your device and our servers uses HTTPS/TLS encryption</li>
<li>Sensitive data is stored on secure, isolated servers</li>
<li>We regularly audit security and patch vulnerabilities</li>
<li>We conduct no data sharing with third parties unless required by law</li>
</ul>
<h3>7. Your Rights</h3>
<p>You have the right to:</p>
<ul>
<li><strong>Access:</strong> Request a copy of all personal data we hold about you</li>
<li><strong>Correction:</strong> Update or correct inaccurate information</li>
<li><strong>Deletion:</strong> Request deletion of your account and personal data (with 30-day retention for legal compliance)</li>
<li><strong>Data portability:</strong> Export your game data in a machine-readable format</li>
<li><strong>Withdraw consent:</strong> Opt out of non-essential data collection at any time</li>
</ul>
<p>To exercise any of these rights, contact us at <a href="mailto:privacy@mineseeker.hu">privacy@mineseeker.hu</a></p>
<h3>8. Third-Party Services</h3>
<p>We use the following third-party services (strictly necessary for game operation):</p>
<ul>
<li><strong>Mercure (WebSocket server):</strong> Real-time multiplayer connections</li>
<li><strong>Symfony framework:</strong> Web application backend</li>
<li><strong>Cloud storage:</strong> Game replays and user data (hosted in EU data centers)</li>
</ul>
<p>These services are contractually bound to respect your privacy and use data only for the purposes we specify.</p>
<h3>9. Legal Basis for Processing</h3>
<p>We process your personal data based on:</p>
<ul>
<li><strong>Contractual necessity:</strong> To provide the MineSeeker service you've agreed to use</li>
<li><strong>Legitimate interest:</strong> To maintain security and prevent fraud</li>
<li><strong>Compliance with law:</strong> When required by Hungarian or EU law</li>
</ul>
<h3>10. Contact & Disputes</h3>
<p>If you have questions about this privacy policy or wish to exercise your rights:</p>
<ul>
<li><strong>Email:</strong> <a href="mailto:privacy@mineseeker.hu">privacy@mineseeker.hu</a></li>
<li><strong>Mailing address:</strong> MineSeeker, Budapest, Hungary</li>
<li><strong>EU DPA disputes:</strong> If you're in the EU and believe we've violated GDPR, you may lodge a complaint with your local data protection authority</li>
</ul>
<h3>11. Policy Changes</h3>
<p>MineSeeker may update this privacy policy to reflect changes in our practices or applicable law. We will notify you of significant changes via email or by posting a notice on our website. Your continued use of MineSeeker after changes constitute acceptance of the updated policy.</p>
<p><strong>Last updated:</strong> {{ "now"|date("F j, Y") }}</p>
</div>
{% endblock %}

View File

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

View File

@@ -3,8 +3,8 @@
{% block title %} - Terms of Service{% endblock %}
{% block metas %}
{%- set _ogImage = app.request.getSchemeAndHttpHost() ~ asset('images/mine-1600x627.png') -%}
<meta property="og:url" content="{{ url('MineSeekerBundle_terms') }}"/>
{%- set _ogImage = 'https://' ~ app.request.host ~ asset('/images/mine-1600x627.png') -%}
<meta property="og:url" content="{{ url('MineSeekerBundle_terms') | replace({'http://': 'https://'}) }}"/>
<meta property="og:type" content="website"/>
<meta property="og:site_name" content="MineSeeker"/>
<meta property="og:title" content="Terms of Use · MineSeeker"/>
@@ -22,95 +22,141 @@
<div class="txt">
<h2>MineSeeker Terms of Service</h2>
<h3>1. Terms</h3>
<h3>1. Agreement to Terms</h3>
<p>By accessing the website at <a href="https://www.mineseeker.hu">https://www.mineseeker.hu</a>, you are
agreeing to be bound by these terms of service, all applicable laws and regulations, and agree that you are
responsible for compliance with any applicable local laws. If you do not agree with any of these terms, you
are
prohibited from using or accessing this site. The materials contained in this website are protected by
applicable copyright and trademark law.</p>
<p>By accessing and using MineSeeker (https://www.mineseeker.hu), you agree to be bound by these Terms of Service, all applicable laws and regulations, and agree that you are responsible for compliance with any applicable local laws. If you do not agree with any of these terms, you are prohibited from using this service. The materials and content contained in MineSeeker are protected by applicable copyright and trademark law.</p>
<h3>2. Use License</h3>
<ol type="a">
<li>
Permission is granted to temporarily download one copy of the materials (information or software) on
MineSeeker's website for personal, non-commercial transitory viewing only. This is the grant of a
license,
not a transfer of title, and under this license you may not:
<p>MineSeeker grants you a limited, non-exclusive, revocable license to access and play MineSeeker for personal, non-commercial purposes only. Under this license, you may not:</p>
<ol type="i">
<li>modify or copy the materials;</li>
<li>use the materials for any commercial purpose, or for any public display (commercial or
non-commercial);
</li>
<li>attempt to decompile or reverse engineer any software contained on MineSeeker's website;</li>
<li>remove any copyright or other proprietary notations from the materials; or</li>
<li>transfer the materials to another person or "mirror" the materials on any other server.</li>
<li>modify, copy, or reproduce the game code or assets</li>
<li>use MineSeeker for any commercial purpose or for any public display</li>
<li>attempt to decompile, reverse engineer, or hack the game software</li>
<li>remove any copyright, trademark, or proprietary notices</li>
<li>use automated tools, bots, or scripts to play the game (cheating)</li>
<li>use the game to harass, abuse, or harm other players</li>
<li>attempt to exploit game vulnerabilities for unfair advantage</li>
<li>mirror, redistribute, or host MineSeeker on any other server</li>
</ol>
</li>
<li>This license shall automatically terminate if you violate any of these restrictions and may be
terminated by
MineSeeker at any time. Upon terminating your viewing of these materials or upon the termination of this
license, you must destroy any downloaded materials in your possession whether in electronic or printed
format.
<li>
<p>This license automatically terminates if you violate any of these restrictions. MineSeeker reserves the right to terminate your account and access at any time for violating these terms or for any other reason. Upon termination, you must cease all use of the service.</p>
</li>
</ol>
<h3>3. Disclaimer</h3>
<h3>3. Account Responsibilities</h3>
<ul>
<li><strong>Account creation:</strong> You are responsible for providing accurate information when creating an account and for maintaining the confidentiality of your password.</li>
<li><strong>Account security:</strong> You are responsible for all activities that occur under your account. If you suspect unauthorized access, contact us immediately.</li>
<li><strong>Username conduct:</strong> Your username must not be offensive, defamatory, or violate anyone's rights. We reserve the right to force username changes.</li>
<li><strong>Account termination:</strong> You may delete your account at any time. Upon deletion, your personal information will be deleted within 30 days, but game statistics may be retained anonymously.</li>
</ul>
<h3>4. User Conduct & Prohibited Behavior</h3>
<p>You agree NOT to:</p>
<ul>
<li>Use cheats, hacks, exploits, or automation tools</li>
<li>Engage in harassment, abuse, threats, or hate speech toward other players</li>
<li>Share or distribute malware, viruses, or malicious code</li>
<li>Attempt to gain unauthorized access to MineSeeker systems</li>
<li>Use the game to spam, phish, or scam other users</li>
<li>Share another person's personal information without consent</li>
<li>Boost accounts, trade accounts, or share login credentials</li>
</ul>
<p>Violations of these rules may result in account suspension or permanent ban at our sole discretion.</p>
<h3>5. Intellectual Property Rights</h3>
<ul>
<li>All game content, including code, graphics, sound, and design, is the exclusive property of MineSeeker and protected by copyright.</li>
<li>Your gameplay and game statistics belong to you, but we retain the right to use anonymized gameplay data for improving the service.</li>
<li>Game replays you create belong to you and can be shared with other players or deleted at your discretion.</li>
<li>You grant MineSeeker a license to display your username and game achievements on public leaderboards.</li>
</ul>
<h3>6. Disclaimer of Warranties</h3>
<ol type="a">
<li>The materials on MineSeeker's website are provided on an 'as is' basis. MineSeeker makes no warranties,
expressed or implied, and hereby disclaims and negates all other warranties including, without
limitation,
implied warranties or conditions of merchantability, fitness for a particular purpose, or
non-infringement
of intellectual property or other violation of rights.
</li>
<li>Further, MineSeeker does not warrant or make any representations concerning the accuracy, likely
results, or
reliability of the use of the materials on its website or otherwise relating to such materials or on any
sites linked to this site.
<li>
<p>MineSeeker is provided on an "AS IS" and "AS AVAILABLE" basis. MineSeeker makes no warranties, express or implied, and hereby disclaims all other warranties including:</p>
<ul>
<li>Implied warranties of merchantability or fitness for a particular purpose</li>
<li>Warranties of accuracy, reliability, or uninterrupted service</li>
<li>Warranties of non-infringement of intellectual property rights</li>
</ul>
</li>
<li>MineSeeker does not warrant that the service will be error-free, secure, or meet your specific requirements.</li>
<li>MineSeeker is not responsible for delays, delivery failures, or any other issues caused by factors outside our control (internet outages, player hardware, etc.).</li>
</ol>
<h3>4. Limitations</h3>
<h3>7. Limitation of Liability</h3>
<p>In no event shall MineSeeker or its suppliers be liable for any damages (including, without limitation,
damages
for loss of data or profit, or due to business interruption) arising out of the use or inability to use the
materials on MineSeeker's website, even if MineSeeker or a MineSeeker authorized representative has been
notified orally or in writing of the possibility of such damage. Because some jurisdictions do not allow
limitations on implied warranties, or limitations of liability for consequential or incidental damages,
these
limitations may not apply to you.</p>
<p>In no event shall MineSeeker or its creators be liable for any damages arising out of the use or inability to use MineSeeker, including:</p>
<ul>
<li>Loss of data, game progress, or winnings</li>
<li>Loss of profit or business interruption</li>
<li>Personal injury or property damage</li>
<li>Any indirect, incidental, special, or consequential damages</li>
</ul>
<h3>5. Accuracy of materials</h3>
<p>Some jurisdictions do not allow limitation of liability, so these limitations may not apply to you.</p>
<p>The materials appearing on MineSeeker's website could include technical, typographical, or photographic
errors.
MineSeeker does not warrant that any of the materials on its website are accurate, complete or current.
MineSeeker may make changes to the materials contained on its website at any time without notice. However
MineSeeker does not make any commitment to update the materials.</p>
<h3>8. Content Accuracy & Changes</h3>
<h3>6. Links</h3>
<ul>
<li>MineSeeker may contain technical, typographical, or photographic errors. We don't warrant accuracy or completeness.</li>
<li>MineSeeker reserves the right to modify, update, or discontinue features without notice (we will attempt to notify users of major changes).</li>
<li>We may take servers offline for maintenance with minimal notice.</li>
<li>Game rules and mechanics may be adjusted for balance or fairness.</li>
</ul>
<p>MineSeeker has not reviewed all of the sites linked to its website and is not responsible for the contents of
any
such linked site. The inclusion of any link does not imply endorsement by MineSeeker of the site. Use of any
such linked website is at the user's own risk.</p>
<h3>9. External Links</h3>
<h3>7. Modifications</h3>
<p>MineSeeker may contain links to external websites. We are not responsible for the content, accuracy, or practices of linked sites. Use of linked websites is at your own risk and subject to their terms of service.</p>
<p>MineSeeker may revise these terms of service for its website at any time without notice. By using this
website
you are agreeing to be bound by the then current version of these terms of service.</p>
<h3>10. Termination of Service</h3>
<h3>8. Governing Law</h3>
<ul>
<li>MineSeeker may suspend or terminate your account if you violate these terms.</li>
<li>MineSeeker may discontinue the service at any time with reasonable notice (we will provide at least 30 days notice before full shutdown).</li>
<li>Upon termination, your right to use MineSeeker immediately ceases.</li>
</ul>
<p>These terms and conditions are governed by and construed in accordance with the laws of Budapest, Hungary and
you
irrevocably submit to the exclusive jurisdiction of the courts in that State or location.</p>
<h3>11. Modifications to Terms</h3>
<p>MineSeeker may revise these terms at any time without notice. Your continued use of the service after changes constitutes acceptance of the updated terms. We recommend reviewing these terms periodically.</p>
<h3>12. Governing Law & Jurisdiction</h3>
<p>These terms are governed by and construed in accordance with the laws of Budapest, Hungary, without regard to its conflicts of law principles. You irrevocably submit to the exclusive jurisdiction of the courts located in Budapest, Hungary.</p>
<h3>13. Dispute Resolution</h3>
<p>Before initiating legal action, you agree to attempt to resolve disputes through good-faith negotiation. If a dispute cannot be resolved informally, you may pursue legal remedies in the courts of Budapest, Hungary.</p>
<h3>14. Severability</h3>
<p>If any provision of these terms is found to be invalid or unenforceable, that provision shall be severed, and the remaining provisions shall remain in full force and effect.</p>
<h3>15. Entire Agreement</h3>
<p>These Terms of Service, together with our Privacy Policy, constitute the entire agreement between you and MineSeeker regarding your use of the service and supersede all prior agreements.</p>
<h3>16. Contact Information</h3>
<p>If you have questions or concerns about these terms:</p>
<ul>
<li><strong>Email:</strong> <a href="mailto:contact@mineseeker.hu">contact@mineseeker.hu</a></li>
<li><strong>Website:</strong> <a href="https://www.mineseeker.hu">https://www.mineseeker.hu</a></li>
<li><strong>Location:</strong> Budapest, Hungary</li>
</ul>
<p><strong>Last updated:</strong> {{ "now"|date("F j, Y") }}</p>
</div>
{% endblock %}

View File

@@ -3,7 +3,7 @@
{% block title %} - Forgot Password{% endblock %}
{% block metas %}
{%- set _ogImage = app.request.getSchemeAndHttpHost() ~ asset('images/mine-1600x627.png') -%}
{%- set _ogImage = 'https://' ~ app.request.host ~ asset('/images/mine-1600x627.png') -%}
<meta name="robots" content="noindex,nofollow"/>
<meta property="og:url" content="{{ app.request.uri }}"/>
<meta property="og:type" content="website"/>

View File

@@ -3,7 +3,7 @@
{% block title %} - Sign In{% endblock %}
{% block metas %}
{%- set _ogImage = app.request.getSchemeAndHttpHost() ~ asset('images/mine-1600x627.png') -%}
{%- set _ogImage = 'https://' ~ app.request.host ~ asset('/images/mine-1600x627.png') -%}
<meta name="robots" content="noindex,nofollow"/>
<meta property="og:url" content="{{ app.request.uri }}"/>
<meta property="og:type" content="website"/>

View File

@@ -3,9 +3,9 @@
{% block title %} - Profile{% endblock %}
{% block metas %}
{%- set _ogImage = app.request.getSchemeAndHttpHost() ~ asset('images/mine-1600x627.png') -%}
{%- set _ogImage = 'https://' ~ app.request.host ~ asset('/images/mine-1600x627.png') -%}
<meta name="robots" content="noindex,nofollow"/>
<meta property="og:url" content="{{ url('MineSeekerBundle_profile') }}"/>
<meta property="og:url" content="{{ url('MineSeekerBundle_profile') | replace({'http://': 'https://'}) }}"/>
<meta property="og:type" content="profile"/>
<meta property="og:site_name" content="MineSeeker"/>
<meta property="og:title" content="{{ app.user.username }} · MineSeeker"/>

View File

@@ -3,9 +3,9 @@
{% block title %} - Security Settings{% endblock %}
{% block metas %}
{%- set _ogImage = app.request.getSchemeAndHttpHost() ~ asset('images/mine-1600x627.png') -%}
{%- set _ogImage = 'https://' ~ app.request.host ~ asset('/images/mine-1600x627.png') -%}
<meta name="robots" content="noindex,nofollow"/>
<meta property="og:url" content="{{ url('MineSeekerBundle_profile_security') }}"/>
<meta property="og:url" content="{{ url('MineSeekerBundle_profile_security') | replace({'http://': 'https://'}) }}"/>
<meta property="og:type" content="website"/>
<meta property="og:site_name" content="MineSeeker"/>
<meta property="og:title" content="Security Settings · MineSeeker"/>

View File

@@ -3,7 +3,7 @@
{% block title %} - Register{% endblock %}
{% block metas %}
{%- set _ogImage = app.request.getSchemeAndHttpHost() ~ asset('images/mine-1600x627.png') -%}
{%- set _ogImage = 'https://' ~ app.request.host ~ asset('/images/mine-1600x627.png') -%}
<meta property="og:url" content="{{ app.request.uri }}"/>
<meta property="og:type" content="website"/>
<meta property="og:site_name" content="MineSeeker"/>
@@ -117,6 +117,25 @@
</div>
</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.consentGiven, {
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.consentGiven.vars.valid %}
{% for error in form.consentGiven.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-user-plus"></i> Create Account
</button>

View File

@@ -0,0 +1,92 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>User Account Activated</title>
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
line-height: 1.6;
color: #333;
max-width: 600px;
margin: 0 auto;
padding: 20px;
}
.header {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
padding: 30px;
border-radius: 8px 8px 0 0;
text-align: center;
}
.header h1 {
margin: 0;
font-size: 24px;
}
.content {
background: #f9f9f9;
padding: 30px;
border-radius: 0 0 8px 8px;
}
.field {
margin-bottom: 20px;
}
.field-label {
font-weight: 600;
color: #666;
font-size: 12px;
text-transform: uppercase;
letter-spacing: 0.5px;
margin-bottom: 5px;
}
.field-value {
background: white;
padding: 12px;
border-radius: 4px;
border-left: 3px solid #667eea;
}
.footer {
margin-top: 20px;
padding-top: 20px;
border-top: 1px solid #ddd;
font-size: 12px;
color: #999;
text-align: center;
}
</style>
</head>
<body>
<div class="header">
<h1>✅ User Account Activated</h1>
</div>
<div class="content">
<div class="field">
<div class="field-label">Username</div>
<div class="field-value">
<strong>{{ user.username }}</strong>
</div>
</div>
<div class="field">
<div class="field-label">Email</div>
<div class="field-value">
<a href="mailto:{{ user.email }}">{{ user.email }}</a>
</div>
</div>
<div class="field">
<div class="field-label">Details</div>
<div class="field-value">
<strong>Activated:</strong> {{ activatedAt|date('Y-m-d H:i:s') }}<br>
<strong>Status:</strong> ✓ Email Verified - Account Active<br>
<strong>Email Verified:</strong> Yes
</div>
</div>
<div class="footer">
A user has successfully verified their email and activated their account on MineSeeker.<br>
They can now play games immediately.
</div>
</div>
</body>
</html>

View File

@@ -0,0 +1,92 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>New User Registration</title>
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
line-height: 1.6;
color: #333;
max-width: 600px;
margin: 0 auto;
padding: 20px;
}
.header {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
padding: 30px;
border-radius: 8px 8px 0 0;
text-align: center;
}
.header h1 {
margin: 0;
font-size: 24px;
}
.content {
background: #f9f9f9;
padding: 30px;
border-radius: 0 0 8px 8px;
}
.field {
margin-bottom: 20px;
}
.field-label {
font-weight: 600;
color: #666;
font-size: 12px;
text-transform: uppercase;
letter-spacing: 0.5px;
margin-bottom: 5px;
}
.field-value {
background: white;
padding: 12px;
border-radius: 4px;
border-left: 3px solid #667eea;
}
.footer {
margin-top: 20px;
padding-top: 20px;
border-top: 1px solid #ddd;
font-size: 12px;
color: #999;
text-align: center;
}
</style>
</head>
<body>
<div class="header">
<h1>👤 New User Registration</h1>
</div>
<div class="content">
<div class="field">
<div class="field-label">Username</div>
<div class="field-value">
<strong>{{ user.username }}</strong>
</div>
</div>
<div class="field">
<div class="field-label">Email</div>
<div class="field-value">
<a href="mailto:{{ user.email }}">{{ user.email }}</a>
</div>
</div>
<div class="field">
<div class="field-label">Details</div>
<div class="field-value">
<strong>Registered:</strong> {{ registeredAt|date('Y-m-d H:i:s') }}<br>
<strong>Status:</strong> {% if user.isVerified %}✓ Verified{% else %}⏳ Awaiting Email Verification{% endif %}<br>
<strong>Email Verified:</strong> {% if user.isVerified %}Yes{% else %}No - activation link sent{% endif %}
</div>
</div>
<div class="footer">
A new user has registered on MineSeeker.<br>
User must verify their email before account is fully activated.
</div>
</div>
</body>
</html>