Private
Public Access
1
0

Compare commits

...

22 Commits

Author SHA1 Message Date
6484133199 new: dev: initialize the GTK client #11 2026-04-28 08:28:51 +02:00
199bb7e525 chg: pkg: fix all eslint issues - & add example of the testing env #10 2026-04-28 08:10:18 +02:00
5daaf71ae7 chg: pkg: new version release !skipChangelog 2026-04-23 21:42:21 +02:00
0aeec47996 new: pkg: add tracking code for the app #10
All checks were successful
Deploy to Production / deploy (push) Successful in 30s
2026-04-23 21:41:47 +02:00
3d67b8f2d9 chg: pkg: new version release !skipChangelog 2026-04-22 12:15:29 +02:00
dd9a190fd9 fix: usr: the error message cannot be seen during avatar changing #10
All checks were successful
Deploy to Production / deploy (push) Successful in 3m7s
2026-04-22 12:15:06 +02:00
f5e5019ea8 chg: pkg: new version release !skipChangelog 2026-04-21 22:47:04 +02:00
3f51eb5db6 chg: usr: increase the 2 MB avatar maximum file size to 10 MB #10
All checks were successful
Deploy to Production / deploy (push) Successful in 29s
2026-04-21 22:46:44 +02:00
55ef7c9301 chg: pkg: new version release !skipChangelog 2026-04-21 21:05:54 +02:00
ddfa395c6b chg: pkg: upgrade front-end & back-end deps to the latest available version #10
All checks were successful
Deploy to Production / deploy (push) Successful in 31s
2026-04-21 20:13:00 +02:00
8694627817 chg: pkg: new version release !skipChangelog 2026-04-21 18:21:17 +02:00
f796819af4 chg: pkg: new version release !skipChangelog 2026-04-21 18:12:32 +02:00
b209ad4220 chg: pkg: the original CI/CD workflow is restored - the work with tests is postponed #10
Some checks failed
Deploy to Production / test (push) Failing after 6s
Deploy to Production / deploy (push) Successful in 33s
2026-04-21 18:11:54 +02:00
df1eefdfe0 chg: pkg: new version release !skipChangelog
Some checks failed
CI - Tests / tests (push) Failing after 2m43s
CI - Tests / lint (push) Failing after 8s
2026-04-21 18:00:05 +02:00
7aaaf120b2 chg: usr: add sound when the game start #10 2026-04-21 17:59:18 +02:00
e48d651eb5 new: pkg: add CI/CD improvements - add new CI workflow - & improve the deployment w/ tests #10 2026-04-21 17:58:05 +02:00
d704be5bff new: pkg: add test cases to back-end w/ real database connection in it #10 2026-04-21 17:56:04 +02:00
6bf908b43e chg: dev: create AGENTS.md file for future maintenance #9 2026-04-21 14:30:38 +02:00
085e010907 chg: dev: remove the wrongly implemented font installation in docker - & replace it with static font on BattleCardGenerator (it solves the shareable image problem on bare-metal too) #8 2026-04-21 14:18:59 +02:00
8935216525 chg: pkg: new version release !skipChangelog 2026-04-21 13:58:08 +02:00
1d8efa4e61 chg: usr: fine-tune the recent battle list #8
All checks were successful
Deploy to Production / deploy (push) Successful in 27s
2026-04-21 13:57:44 +02:00
69fce52bed chg: pkg: new version release !skipChangelog 2026-04-21 11:47:58 +02:00
91 changed files with 9884 additions and 249 deletions

3
.env.test Normal file
View File

@@ -0,0 +1,3 @@
# define your env variables for the test env here
KERNEL_CLASS='App\Kernel'
APP_SECRET='$ecretf0rt3st'

142
.gitea/workflows/ci.yml-bak Normal file
View File

@@ -0,0 +1,142 @@
name: CI - Tests
on:
push:
branches:
- master
- develop
pull_request:
branches:
- master
- develop
jobs:
tests:
runs-on: splendid-bear
services:
postgres:
image: postgres:18-alpine
env:
POSTGRES_USER: mineseeker_test
POSTGRES_PASSWORD: test_password
POSTGRES_DB: mineseeker_test
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
ports:
- 5432:5432
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup PHP 8.3
uses: shivammathur/setup-php@v2
with:
php-version: '8.3'
extensions: pdo_pgsql, gd, intl, zip, sodium
coverage: none
tools: composer:v2
- name: Validate composer.json
run: composer validate --strict
- name: Cache Composer dependencies
uses: actions/cache@v4
with:
path: vendor
key: ${{ runner.os }}-composer-${{ hashFiles('**/composer.lock') }}
restore-keys: |
${{ runner.os }}-composer-
- name: Install PHP dependencies
run: composer install --prefer-dist --no-progress --no-interaction
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
- name: Cache node modules
uses: actions/cache@v4
with:
path: node_modules
key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }}
restore-keys: |
${{ runner.os }}-node-
- name: Install Node dependencies
run: npm ci
- name: Build assets
run: npm run build
- name: Create .env.test file
run: |
cat > .env.test << 'ENVEOF'
APP_ENV=test
APP_SECRET=test-secret-key-for-ci-testing-only
DATABASE_URL="postgresql://mineseeker_test:test_password@localhost:5432/mineseeker_test?serverVersion=18&charset=utf8"
KERNEL_CLASS='App\Kernel'
SYMFONY_DEPRECATIONS_HELPER=999999
PANTHER_APP_ENV=panther
PANTHER_ERROR_SCREENSHOT_DIR=./var/error-screenshots
ENVEOF
- name: Setup test database
run: make test-db-setup
- name: Run PHPUnit tests
run: vendor/bin/phpunit --testdox --colors=always
- name: Run PHPUnit tests with coverage (optional)
if: github.event_name == 'pull_request'
run: |
XDEBUG_MODE=coverage vendor/bin/phpunit --coverage-text --coverage-clover=coverage.xml
- name: Upload coverage reports (optional)
if: github.event_name == 'pull_request'
uses: codecov/codecov-action@v4
with:
file: ./coverage.xml
flags: unittests
name: phpunit-coverage
fail_ci_if_error: false
lint:
runs-on: splendid-bear
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup PHP 8.3
uses: shivammathur/setup-php@v2
with:
php-version: '8.3'
tools: composer:v2, phpstan
- name: Install PHP dependencies
run: composer install --prefer-dist --no-progress --no-interaction
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
- name: Install Node dependencies
run: npm ci
- name: Run ESLint
run: npm run lint || true
- name: Check code style (PHP)
run: |
if [ -f "vendor/bin/php-cs-fixer" ]; then
vendor/bin/php-cs-fixer fix --dry-run --diff
else
echo "PHP-CS-Fixer not installed, skipping..."
fi

View File

@@ -0,0 +1,126 @@
name: Deploy to Production
on:
push:
tags:
- 'v*'
jobs:
test:
runs-on: splendid-bear
services:
postgres:
image: postgres:18-alpine
env:
POSTGRES_USER: mineseeker_test
POSTGRES_PASSWORD: test_password
POSTGRES_DB: mineseeker_test
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
ports:
- 5432:5432
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup PHP 8.3
uses: shivammathur/setup-php@v2
with:
php-version: '8.3'
extensions: pdo_pgsql, gd, intl, zip, sodium
coverage: none
tools: composer:v2
- name: Install PHP dependencies
run: composer install --prefer-dist --no-progress --no-interaction
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
- name: Install Node dependencies
run: npm ci
- name: Build assets
run: npm run build
- name: Create .env.test file
run: |
cat > .env.test << 'ENVEOF'
APP_ENV=test
APP_SECRET=test-secret-key-for-ci-testing-only
DATABASE_URL="postgresql://mineseeker_test:test_password@localhost:5432/mineseeker_test?serverVersion=18&charset=utf8"
KERNEL_CLASS='App\Kernel'
SYMFONY_DEPRECATIONS_HELPER=999999
ENVEOF
- name: Setup test database
run: make test-db-setup
- name: Run PHPUnit tests
run: vendor/bin/phpunit --testdox --colors=always --stop-on-failure
deploy:
needs: test
runs-on: splendid-bear
steps:
- name: Checkout tag
env:
GITEA_TOKEN: ${{ gitea.token }}
run: |
set -e
export HOME=/tmp
git config --global credential.helper '!f() { echo "username=oauth2"; echo "password=$GITEA_TOKEN"; }; f'
git config --global --add safe.directory "${{ vars.PROD_APP_DIR }}"
cd "${{ vars.PROD_APP_DIR }}"
git remote set-url origin "${{ gitea.server_url }}/${{ gitea.repository }}.git"
git fetch --tags --force
git checkout "${{ gitea.ref_name }}"
- name: Write .env
env:
PROD_ENV_FILE: ${{ secrets.PROD_ENV_FILE }}
run: |
printf '%s' "$PROD_ENV_FILE" > "${{ vars.PROD_APP_DIR }}/.env"
- name: Build image
run: |
cd "${{ vars.PROD_APP_DIR }}"
docker compose build
- name: Run database migrations
run: |
cd "${{ vars.PROD_APP_DIR }}"
docker compose run --rm app php bin/console doctrine:migrations:migrate --no-interaction
- name: Clear cache
run: |
cd "${{ vars.PROD_APP_DIR }}"
docker compose run --rm app php bin/console cache:clear --env=prod
- name: Start services
run: |
cd "${{ vars.PROD_APP_DIR }}"
docker compose up -d
- name: Health check
run: |
sleep 5
curl -f http://localhost:10080/ || exit 1
- name: Notify deployment success
if: success()
run: |
echo "✅ Deployment successful for tag ${{ gitea.ref_name }}"
- name: Notify deployment failure
if: failure()
run: |
echo "❌ Deployment failed for tag ${{ gitea.ref_name }}"
exit 1

5
.gitignore vendored
View File

@@ -14,3 +14,8 @@ nohup.out
/var/ /var/
/vendor/ /vendor/
###< symfony/framework-bundle ### ###< symfony/framework-bundle ###
###> phpunit/phpunit ###
/phpunit.xml
/.phpunit.cache/
###< phpunit/phpunit ###

464
AGENTS.md Normal file
View File

@@ -0,0 +1,464 @@
# AI Agent Guidelines for MineSeeker
This document provides guidelines and context for AI coding agents working on the MineSeeker project.
## Project Overview
**MineSeeker** is a real-time multiplayer 1v1 minesweeper game built with Symfony (PHP) and React. Players compete to claim mines on a shared 16×16 grid, with the first to reach 26 mines winning.
### Tech Stack
- **Backend:** Symfony 7.2 (PHP 8.3)
- **Frontend:** React 18, Vite 6
- **Database:** PostgreSQL 17 with materialized views
- **Storage:** MinIO (S3-compatible)
- **Real-time:** Mercure (Server-Sent Events)
- **Styling:** SCSS, MUI (Material-UI), Emotion
- **Fonts:** @fontsource packages (web), Carlito-Bold.ttf (server-side images)
### Key Features
**Core Gameplay:**
- **Multiplayer-focused:** 1v1 competitive gameplay where players race to claim more mines than their opponent
- **Win condition:** First player to claim 26 out of 51 mines wins
- **Real-time updates:** WebSocket-like gameplay using Mercure (Server-Sent Events)
- **Game restoration:** Players can resume unfinished games from where they left off
- **Bonus points system:** Rewards skilled play (blind hits, chain combos, edge mines, endgame mines, safe cell reveals)
**User Features:**
- **Authentication:** Password + optional TOTP + optional WebAuthn passkeys
- **Anonymous play:** Guest players can play without creating an account
- **Profile statistics:** Detailed stats including wins, losses, draws, win rate, average score, total mines hit, and bonus points
- **Battle history:** View and replay past games move-by-move
**Sharing & Social:**
- **Battle reports:** Shareable public pages for each completed game (`/battle/{uuid}`)
- **OG image generation:** Automatic creation of 1200×630 PNG images for social media sharing (using PHP GD)
- **Open Graph tags:** Battle share pages include rich preview cards with player names, avatars, scores, and bonus points
---
## Architecture Overview
### Backend (Symfony)
```
src/
├── Controller/ # HTTP endpoints (game, profile, battle sharing)
├── Entity/ # Doctrine ORM entities
├── Repository/ # Database queries (uses QueryBuilder, not raw SQL)
├── Service/ # Business logic (BattleCardGenerator, WebAuthn, Email)
├── Dto/ # Data Transfer Objects (immutable, readonly)
├── Util/ # Game logic (TopicManager for Mercure)
└── Migrations/ # Database schema changes
```
**Important patterns:**
- Use Doctrine ORM QueryBuilder (not raw SQL) in repositories
- DTOs are `final readonly` classes with constructor property promotion
- Services use dependency injection via `config/services.yaml`
- Materialized views for performance (auto-refreshed via triggers)
### Frontend (React)
```
assets/
├── js/
│ ├── mine-seeker/ # Main game bundle (self-contained)
│ │ ├── MineSeeker.jsx # Root component, wraps GameProvider + QueryClientProvider
│ │ ├── components/ # Game-specific components
│ │ │ ├── GameBoard.jsx # Main game board grid
│ │ │ ├── GameTimer.jsx # Game timer display
│ │ │ ├── BonusBox.jsx # Bonus points indicator
│ │ │ ├── BonusStatsDialog.jsx # Bonus statistics modal
│ │ │ ├── CaptchaOverlay.jsx # Captcha challenge overlay
│ │ │ ├── ChallengeCountdown.jsx # Challenge timer
│ │ │ ├── OnlinePlayersDialog.jsx # Online players list
│ │ │ ├── WaitingOverlayContent.jsx # Waiting for opponent
│ │ │ ├── grid/ # Grid-related components (cells, mines)
│ │ │ ├── profile/ # In-game profile components (PlayerColumn)
│ │ │ ├── timer/ # Timer-related components
│ │ │ └── user/ # User-related components
│ │ ├── contexts/ # React Context API
│ │ │ ├── GameContext.jsx # Game state context
│ │ │ └── GameProvider.jsx # Context provider with state logic
│ │ ├── hooks/ # Custom React hooks
│ │ │ ├── useGameDataProvider.js # React Query data provider
│ │ │ ├── useGameRefs.jsx # Refs for DOM elements
│ │ │ ├── useGameState.jsx # Game state management
│ │ │ ├── useServerCommunication.jsx # Mercure SSE connection
│ │ │ └── useStepTimer.jsx # Step-by-step timer
│ │ └── utils/ # Game-specific utilities
│ │ └── constants.jsx # Game constants, colors, defaults
│ ├── components/ # Shared UI components
│ ├── utils/ # Shared utilities
│ ├── profile.jsx # Profile page entry
│ ├── passkey.jsx # Passkey management entry
│ └── contact.jsx # Contact form entry
├── css/
│ └── homepage/ # SCSS partials (imported by style.homepage.scss)
└── fonts/
└── Carlito-Bold.ttf # TTF font for PHP GD image generation
```
**Important patterns:**
- Vite aliases: `@mine-components`, `@mine-contexts`, `@mine-hooks`, `@mine-utils`, `@global-components`, `@global-utils`
- React Query only available inside `mine-seeker` bundle
- Avoid circular dependencies (e.g., don't import from `@global-components` inside `components/` directory)
- PropTypes required on all components
---
## Common Tasks
### Adding a New Feature
1. **Backend:**
- Create migration for schema changes
- Add/update entities and repositories
- Create DTOs for data transfer
- Add controller endpoints
- Update service configuration if needed
2. **Frontend:**
- Create components in appropriate bundle
- Add PropTypes to all components
- Use existing hooks and utilities
- Follow styled-components pattern for MUI customization
3. **Documentation:**
- Update relevant docs in `docs/` folder
- Add examples if introducing new patterns
### Database Changes
- Always create migrations: `bin/console make:migration`
- Use Doctrine QueryBuilder in repositories (not raw SQL)
- For PostgreSQL-specific features (materialized views, triggers), use raw SQL in migrations only
- Materialized views should auto-refresh via triggers
### Styling
- **Web fonts:** Use `@fontsource` packages (WOFF/WOFF2)
- **Server-side images:** Use TTF fonts in `assets/fonts/` (PHP GD requires TTF)
- **CSS:** Create SCSS partials in `assets/css/homepage/`, import in main file
- **Components:** Use Emotion styled-components or CSS classes
### File Headers
All PHP and JS/JSX files should have this header:
```php
<?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.
*/
```
```javascript
/**
* 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.
*/
```
---
## Coding Standards
### PHP
- **PSR Standards:** Follow PSR-1, PSR-12 coding standards
- **Type declarations:** Use strict types (`declare(strict_types=1)`)
- **Property promotion:** Use constructor property promotion for DTOs and services
- **Readonly:** Use `readonly` for immutable properties
- **Final:** Mark DTOs as `final`
- **Doctrine:** Use QueryBuilder with `expr()` methods, not string concatenation
- **Null safety:** Use null coalescing `??` and null-safe operator `?->`
- **Formatting:** 4-space indentation, opening braces on same line for methods/classes
**Example DTO:**
```php
final readonly class ProfileGameDto implements JsonSerializable
{
public function __construct(
public ?int $id,
public string $redName,
public string $blueName,
public bool $bothRegistered,
) {}
}
```
### JavaScript/React
- **Components:** Functional components with hooks
- **PropTypes:** Required on all components
- **Imports:** Use aliases (`@global-components`, `@mine-hooks`, etc.)
- **State:** Use `useState`, `useEffect`, `useCallback`, `useMemo` appropriately
- **Avoid:** Circular dependencies, especially with barrel exports
**Example component:**
```javascript
import React, { useState } from 'react';
import { string, number } from 'prop-types';
export const MyComponent = ({ title, count }) => {
const [value, setValue] = useState(0);
return <div>{title}: {count + value}</div>;
};
MyComponent.propTypes = {
title: string.isRequired,
count: number.isRequired,
};
```
---
## Important Files & Locations
### Configuration
- `config/services.yaml` - Service definitions and parameters
- `vite.config.js` - Vite build config, aliases
- `.env` - Environment variables (not in git)
- `composer.json` - PHP dependencies
- `package.json` - Node dependencies
### Key Services
- `BattleCardGenerator` - Generates 1200×630 PNG OG images using PHP GD
- `TopicManager` - Mercure topic management and game logic
- `WebAuthnService` - Passkey authentication
- `Email services` - Various email senders in `src/Service/Email/`
### Important Entities
- `User` - Registered users
- `PlayedGame` - Game records with moves, grid, scores
- `RecentBattle` - Read-only entity from materialized view
- `UserStats` - Read-only entity from materialized view
### Documentation
- `docs/game-mechanics/BONUS_POINTS_SYSTEM.md` - Bonus points reference
- `docs/FONTS.md` - Font usage and management
- `AGENTS.md` - This file
- `CHANGELOG.md` - Project changelog
---
## Common Pitfalls
### ❌ Don't Do
- **Don't use raw SQL in repositories** (use Doctrine QueryBuilder)
- **Don't create circular imports** (e.g., importing `@global-components` from within `components/`)
- **Don't use system fonts directly** (bundle TTF in `assets/fonts/`)
- **Don't skip PropTypes** on React components
- **Don't use `var`** in JavaScript (use `const` or `let`)
- **Don't define variables after using them** (hoisting issues with `const`)
### ✅ Do
- **Use Doctrine QueryBuilder** with `expr()` methods
- **Import components directly** to avoid circular dependencies
- **Bundle fonts in project** for portability
- **Add PropTypes** to all components
- **Use `const` for immutable values**, `let` for mutable
- **Define variables before using them**
---
## Keeping Components in Sync
### Battle Report Display
The battle report/statistics appear in **two places** that must be kept synchronized:
#### 1. BattleDialog Component (React)
**File:** `assets/js/components/BattleDialog.jsx`
- React dialog component shown on profile page
- Uses Material-UI and styled-components
- Displays game stats, bonus points, winner information
#### 2. Battle Share Page (Twig)
**File:** `templates/Game/battle_share.html.twig`
- Public battle share page (accessible via `/battle/{uuid}`)
- Server-rendered HTML with SCSS styling
- Shows same information as BattleDialog
### Synchronization Rules
**When updating BattleDialog.jsx, also update battle_share.html.twig:**
✅ Adding new stats or fields
✅ Changing display logic (winner calculation, formatting)
✅ Modifying bonus points display
✅ Updating labels or text
**Keep in sync:**
- Game outcome logic (win/loss/draw/abandoned)
- Bonus points formatting
- Player name display
- Score display
- Stats and metadata shown
**Example:** If you add a new stat to BattleDialog showing "fastest mine claim time", you must also add it to battle_share.html.twig so both displays show the same information.
---
## Testing & Building
### Backend
```bash
# Run migrations
bin/console doctrine:migrations:migrate
# Clear cache
bin/console cache:clear
# Refresh materialized views
bin/console dbal:run-sql "REFRESH MATERIALIZED VIEW CONCURRENTLY recent_battles"
```
### Frontend
```bash
# Development build with watch
npm run dev
# Production build
npm run build
# After changing fonts
rm -rf var/og-cache/*
```
---
## Git Workflow
### Commit Messages
Follow conventional commits:
- `feat: add bonus points to battle cards`
- `fix: resolve circular dependency in BattleDialog`
- `refactor: use Doctrine QueryBuilder in RecentBattleRepository`
- `docs: add AGENTS.md for AI coding agents`
- `chore: update dependencies`
### Creating Pull Requests
When creating PRs, include:
1. **Summary** - What was changed and why
2. **Changes** - List of modified files/components
3. **Testing** - How to verify the changes
4. **Screenshots** - For UI changes
---
## Performance Considerations
### Database
- Use materialized views for expensive queries (profile stats, recent battles)
- Auto-refresh materialized views via triggers on source table changes
- Index frequently queried columns
- Use `COALESCE()` for nullable aggregates
### Frontend
- Lazy load large components
- Use React Query for data fetching and caching
- Avoid unnecessary re-renders (use `useMemo`, `useCallback`)
- Bundle code per entry point (profile, passkey, contact, game)
### Images
- Battle card images are cached in `var/og-cache/`
- Images regenerated when games change (via deterministic UUID)
- Use appropriate image sizes (1200×630 for OG images)
---
## Security
### Authentication
- Password + TOTP (optional) + WebAuthn passkeys (optional)
- Backup codes for TOTP recovery
- Session-based authentication with `IS_AUTHENTICATED_REMEMBERED`
### Data Access
- Controllers check `denyAccessUnlessGranted()`
- Materialized views filter by `user_id`
- Guest players use separate `Gamer` entity (no `User` account)
### Input Validation
- Symfony forms for user input
- File upload validation (size, MIME type)
- WebAuthn challenge validation
---
## Useful Commands
```bash
# Symfony
bin/console debug:container ServiceName # Inspect service configuration
bin/console debug:router # List all routes
bin/console make:migration # Create new migration
bin/console doctrine:migrations:list # List migrations
# Database
bin/console dbal:run-sql "SELECT * FROM ..." # Run SQL query
# Assets
npm run build # Build production assets
npm run dev # Build with watch mode
# Git
git log --oneline --graph # View commit history
git diff origin/main...HEAD # See changes since main
```
---
## Need Help?
- **Bonus Points:** See `docs/game-mechanics/BONUS_POINTS_SYSTEM.md`
- **Fonts:** See `docs/FONTS.md`
- **Symfony Docs:** https://symfony.com/doc/current/
- **React Docs:** https://react.dev/
- **Doctrine ORM:** https://www.doctrine-project.org/projects/doctrine-orm/en/current/
---
## Version History
- **2026-04-21:** Initial AGENTS.md created
- Document common patterns, pitfalls, and project structure
- Include coding standards and examples
---
**Happy coding! 🚀**

View File

@@ -1,6 +1,93 @@
# Changelog # Changelog
## v2026.2.9-0 (2026-04-23)
### New
* Add tracking code for the app #10. [Lang]
## v2026.2.8-3 (2026-04-22)
### Fix
* The error message cannot be seen during avatar changing #10. [Lang]
## v2026.2.8-2 (2026-04-21)
### Changes
* Increase the 2 MB avatar maximum file size to 10 MB #10. [Lang]
## v2026.2.8-1 (2026-04-21)
### Changes
* Upgrade front-end & back-end deps to the latest available version #10. [Lang]
## v2026.2.8-0 (2026-04-21)
### New
* Add CI/CD improvements - add new CI workflow - & improve the deployment w/ tests #10. [Lang]
* Add test cases to back-end w/ real database connection in it #10. [Lang]
### Changes
* The original CI/CD workflow is restored - the work with tests is postponed #10. [Lang]
* Add sound when the game start #10. [Lang]
* Create AGENTS.md file for future maintenance #9. [Lang]
* Remove the wrongly implemented font installation in docker - & replace it with static font on BattleCardGenerator (it solves the shareable image problem on bare-metal too) #8. [Lang]
## v2026.2.7-1 (2026-04-21)
### Changes
* Fine-tune the recent battle list #8. [Lang]
## v2026.2.7-0 (2026-04-21)
### Changes
* Small changes on docs - and improve text on homepage #8. [Lang]
* Massive refactor on front-end for unification and readiness #8. [Lang]
* Update all doc blocks on back-end #8. [Lang]
* Small refactors on back-end #8. [Lang]
* Add RecentBattle entity that is a Materialized View to speed up the view - and further refactor on ProfileController #8. [Lang]
* Create the UserStats entity what is a Materialized View to store Profile stats for every user - & massive ProfileController refactor #8. [Lang]
* Refactor the SecurityController #7. [Lang]
* Refactor the code - there was unnecessary codes and wrongly formatted or designed code that are related to Repositories #7. [Lang]
* Upgrade the doctrine related back-end pkgs to the latest available version #7. [Lang]
* Add filter to the Profile page's recent plays and an infite list too #7. [Lang]
* Upgrade to the latest doctrine pkg on back-end #7. [Lang]
### Fix
* Do not hide the end-game overlay ever #8. [Lang]
* The username was not recognized properly #7. [Lang]
## v2026.2.6-1 (2026-04-19) ## v2026.2.6-1 (2026-04-19)
### Fix ### Fix

View File

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

View File

@@ -1,4 +1,4 @@
.PHONY: help start start-build stop build down ps logs prune-everything db-reset mercure-jwt cache-clear og-cache-clear .PHONY: help start start-build stop build down ps logs prune-everything db-reset mercure-jwt cache-clear og-cache-clear test-db-setup test-db-reset test
.DEFAULT_GOAL := help .DEFAULT_GOAL := help
@@ -14,6 +14,9 @@ help:
@echo " make ccp - Clear the production cache" @echo " make ccp - Clear the production cache"
@echo " make cache-clear - Clear all caches (Vite, Symfony, node_modules)" @echo " make cache-clear - Clear all caches (Vite, Symfony, node_modules)"
@echo " make og-cache-clear - Clear Open Graph cache only" @echo " make og-cache-clear - Clear Open Graph cache only"
@echo " make test-db-setup - One-time setup: Create test database and run migrations"
@echo " make test-db-reset - Reset test database (drop, create, migrate)"
@echo " make test - Run PHPUnit tests"
start: start:
docker compose up -d docker compose up -d
@@ -80,5 +83,23 @@ og-cache-clear:
@echo "✓ OG cache cleared!" @echo "✓ OG cache cleared!"
@echo " Battle card images will be regenerated on next access" @echo " Battle card images will be regenerated on next access"
test-db-setup:
@echo "Setting up test database..."
@bin/console dbal:run-sql "SELECT 1 FROM pg_database WHERE datname='mineseeker_test'" 2>/dev/null | grep -q 1 || \
(bin/console dbal:run-sql "CREATE DATABASE mineseeker_test" && echo "✓ Database 'mineseeker_test' created")
@bin/console doctrine:migrations:migrate --env=test --no-interaction --allow-no-migration 2>&1 | grep -v "WARNING" || true
@echo "✓ Test database setup complete!"
@echo " Database: mineseeker_test"
@echo " Run tests with: make test"
test-db-reset:
@echo "Resetting test database..."
@bin/console dbal:run-sql "DROP DATABASE IF EXISTS mineseeker_test" --quiet
@bin/console dbal:run-sql "CREATE DATABASE mineseeker_test" --quiet
@bin/console doctrine:migrations:migrate --env=test --no-interaction --quiet
@echo "✓ Test database reset complete!"
@echo " Database: mineseeker_test"
@echo " Run tests with: make test"
test:
@php -d memory_limit=512M bin/phpunit --testdox --colors=always

View File

@@ -287,11 +287,15 @@ git push origin v2026.01
--- ---
## Game Documentation ## Documentation
For detailed information about game mechanics, bonus systems, and scoring rules, see the [docs](./docs/) directory: For detailed information about game mechanics, bonus systems, fonts, testing, and other technical details, see the [docs](./docs/) directory:
- **[AI Agent Guidelines](./AGENTS.md)** — Comprehensive guide for AI coding agents working on this project
- **[Bonus Points System](./docs/game-mechanics/BONUS_POINTS_SYSTEM.md)** — Complete reference for all bonus point types, calculation rules, and implementation details - **[Bonus Points System](./docs/game-mechanics/BONUS_POINTS_SYSTEM.md)** — Complete reference for all bonus point types, calculation rules, and implementation details
- **[Fonts](./docs/FONTS.md)** — TrueType fonts used for server-side image generation
- **[Testing Guide](./docs/testing/TESTING.md)** — Complete testing setup with Foundry factories, database isolation, and best practices
- **[Factory Documentation](./docs/testing/FACTORIES.md)** — Detailed API reference for all test data factories
--- ---
@@ -300,3 +304,76 @@ For detailed information about game mechanics, bonus systems, and scoring rules,
LGPL-3.0 — see [LICENSE](LICENSE) for details. LGPL-3.0 — see [LICENSE](LICENSE) for details.
&copy; 2026 [SplendidBear](https://www.splendidbear.org) &copy; 2026 [SplendidBear](https://www.splendidbear.org)
---
## Testing & CI/CD
MineSeeker has a comprehensive test suite with **71 automated tests** and continuous integration/deployment pipelines.
### Quick Start
```bash
# Setup test database (first time only)
make test-db-setup
# Run all tests
make test
# Run with documentation output
vendor/bin/phpunit --testdox
```
### Test Suite
- **71 tests** with **227 assertions**
- **Controller tests** - HTTP endpoints, authentication, routing
- **DTO tests** - Data serialization and calculations
- **Entity tests** - Domain logic and defaults
- **Service tests** - Business logic and external APIs
- **Integration tests** - Foundry factories and database isolation
**Test execution time:** ~6-8 seconds
### Continuous Integration
**Automated testing** runs on every push/pull request:
```yaml
# .gitea/workflows/ci.yml-bak
✓ PHP 8.3 setup with all extensions
✓ PostgreSQL 18 service container
✓ Composer and npm dependency installation
✓ Asset building with Vite
✓ Database migrations
✓ Full test suite execution
✓ Code linting (ESLint, PHP-CS-Fixer)
```
### Continuous Deployment
**Automated deployment** on version tags (e.g., `v1.2.3`):
```yaml
# .gitea/workflows/deploy.yml
1. Run full test suite (blocks deployment if fails)
2. Checkout tagged version
3. Build Docker image
4. Run database migrations
5. Restart services
6. Health check verification
```
**Deploy to production:**
```bash
git tag -a v1.2.3 -m "Release version 1.2.3"
git push origin v1.2.3
```
### Documentation
- **[Testing Guide](docs/testing/TESTING.md)** - Comprehensive testing documentation
- **[Factory Reference](docs/testing/FACTORIES.md)** - Foundry factory API
- **[CI/CD Guide](docs/CI_CD.md)** - Pipeline configuration and workflows
---

View File

@@ -28,6 +28,21 @@
box-shadow: 0 8px 48px rgba(0, 0, 0, 0.4); box-shadow: 0 8px 48px rgba(0, 0, 0, 0.4);
} }
#profile-avatar-root {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
}
.profile-avatar__error {
font-size: 11px;
color: #e57373;
text-align: center;
max-width: 120px;
line-height: 1.3;
}
.profile-avatar { .profile-avatar {
position: relative; position: relative;
width: 80px; width: 80px;

Binary file not shown.

View File

@@ -7,7 +7,7 @@
* file that was distributed with this source code. * file that was distributed with this source code.
*/ */
import React, { useMemo, useRef } from 'react'; import React, { Fragment, useMemo, useRef } from 'react';
import { string } from 'prop-types'; import { string } from 'prop-types';
import { useProfileDataProvider } from '@mine-hooks/useGameDataProvider'; import { useProfileDataProvider } from '@mine-hooks/useGameDataProvider';
@@ -16,6 +16,8 @@ export const AvatarUpload = ({ uploadUrl, initialThumbUrl, initials }) => {
const [thumbUrl, setThumbUrl] = React.useState(initialThumbUrl || null); const [thumbUrl, setThumbUrl] = React.useState(initialThumbUrl || null);
const { uploadAvatarMutation: { isPending, error, mutate } } = useProfileDataProvider(); const { uploadAvatarMutation: { isPending, error, mutate } } = useProfileDataProvider();
const errorMessage = useMemo(() => error?.message ?? null, [error]);
const handleChange = e => { const handleChange = e => {
const file = e.target.files?.[0]; const file = e.target.files?.[0];
if (!file) return; if (!file) return;
@@ -40,9 +42,8 @@ export const AvatarUpload = ({ uploadUrl, initialThumbUrl, initials }) => {
}); });
}; };
const errorMessage = useMemo(() => error?.message ?? null, [error]);
return ( return (
<Fragment>
<div <div
className={`profile-avatar${isPending ? ' profile-avatar--loading' : ''}`} className={`profile-avatar${isPending ? ' profile-avatar--loading' : ''}`}
title="Click to change profile picture" title="Click to change profile picture"
@@ -62,10 +63,11 @@ export const AvatarUpload = ({ uploadUrl, initialThumbUrl, initials }) => {
style={{ display: 'none' }} style={{ display: 'none' }}
onChange={handleChange} onChange={handleChange}
/> />
{errorMessage && <div className="profile-avatar__error">{errorMessage}</div>}
</div> </div>
{errorMessage && <div className="profile-avatar__error">{errorMessage}</div>}
</Fragment>
); );
} };
AvatarUpload.propTypes = { AvatarUpload.propTypes = {
uploadUrl: string.isRequired, uploadUrl: string.isRequired,

View File

@@ -12,7 +12,9 @@ import { array } from 'prop-types';
import { formatDuration } from '@global-utils/format'; import { formatDuration } from '@global-utils/format';
import Dialog from '@mui/material/Dialog'; import Dialog from '@mui/material/Dialog';
import { createTheme, styled, ThemeProvider } from '@mui/material/styles'; import { createTheme, styled, ThemeProvider } from '@mui/material/styles';
import { Avatar, BonusPoints, StatRow } from '@global-components'; import { Avatar } from './battle-dialog/Avatar';
import { BonusPoints } from './battle-dialog/BonusPoints';
import { StatRow } from './battle-dialog/StatRow';
const darkTheme = createTheme({ palette: { mode: 'dark' } }); const darkTheme = createTheme({ palette: { mode: 'dark' } });
@@ -69,8 +71,10 @@ export const BattleDialog = ({ games }) => {
const endReason = resign const endReason = resign
? `${resign.charAt(0).toUpperCase() + resign.slice(1)} resigned` ? `${resign.charAt(0).toUpperCase() + resign.slice(1)} resigned`
: 26 <= maxPoints ? 'Points' : 'Abandoned'; : 26 <= maxPoints ? 'Points' : 'Abandoned';
const bothRegistered = game.bothRegistered;
const shareUrl = `${window.location.origin}/battle/${game.uuid}`; const shareUrl = `${window.location.origin}/battle/${game.uuid}`;
const canContinue = !resign && 26 > maxPoints; const canContinue = bothRegistered && !resign && 26 > maxPoints;
const canShare = !canContinue;
const playUrl = `${window.location.origin}/play/${game.uuid}`; const playUrl = `${window.location.origin}/play/${game.uuid}`;
const duration = formatDuration(game.created, game.date); const duration = formatDuration(game.created, game.date);
@@ -108,7 +112,7 @@ export const BattleDialog = ({ games }) => {
<i className="fa fa-play" /> <i className="fa fa-play" />
Continue Continue
</a> </a>
) : ( ) : canShare ? (
<button <button
className={`bd-share${copied ? ' bd-share--copied' : ''}`} className={`bd-share${copied ? ' bd-share--copied' : ''}`}
onClick={handleShare} onClick={handleShare}
@@ -118,7 +122,7 @@ export const BattleDialog = ({ games }) => {
<i className={`fa ${copied ? 'fa-check' : 'fa-share-alt'}`} /> <i className={`fa ${copied ? 'fa-check' : 'fa-share-alt'}`} />
{copied ? 'Copied!' : 'Share'} {copied ? 'Copied!' : 'Share'}
</button> </button>
)} ) : null}
<button className="bd-close" onClick={() => setOpen(false)} aria-label="Close"> <button className="bd-close" onClick={() => setOpen(false)} aria-label="Close">
<i className="fa fa-times" /> <i className="fa fa-times" />
</button> </button>

View File

@@ -49,6 +49,7 @@ export const GameProvider = ({ children }) => {
mine: new Howl({ src: ['/sound/mine.mp3'] }), mine: new Howl({ src: ['/sound/mine.mp3'] }),
warning: new Howl({ src: ['/sound/warning.mp3'] }), warning: new Howl({ src: ['/sound/warning.mp3'] }),
won: new Howl({ src: ['/sound/won.mp3'] }), won: new Howl({ src: ['/sound/won.mp3'] }),
starting: new Howl({ src: ['/sound/starting.mp3'] }),
}); });
/** Sync helpers (keep ref + state in lockstep) */ /** Sync helpers (keep ref + state in lockstep) */
@@ -269,6 +270,8 @@ export const GameProvider = ({ children }) => {
makeGameEndIfItEnds, resignProcess, makeGameEndIfItEnds, resignProcess,
/** UI action */ /** UI action */
onBombToggle, onBombToggle,
/** Sounds */
sounds,
}} }}
> >
{children} {children}

View File

@@ -22,7 +22,7 @@ const useServerCommunication = (gameAssoc, gameInherited, opponentName, isEnvDev
/** Sync helpers */ /** Sync helpers */
syncWebPlayer, syncActivePlayer, syncBombSelected, syncConnLost, syncRed, syncBlue, syncWebPlayer, syncActivePlayer, syncBombSelected, syncConnLost, syncRed, syncBlue,
/** Game logic */ /** Game logic */
showOverlay, hideOverlay, applyStep, makeGameEndIfItEnds, resignProcess, showOverlay, hideOverlay, applyStep, makeGameEndIfItEnds, resignProcess, sounds,
/** Current cells snapshot (for active-check in onClick) */ /** Current cells snapshot (for active-check in onClick) */
cells, cells,
} = useGame(); } = useGame();
@@ -212,6 +212,7 @@ const useServerCommunication = (gameAssoc, gameInherited, opponentName, isEnvDev
*/ */
if (!endRef.current && (!isTrueRestoredRef.current || 0 !== opponentLastSeenRef.current)) { if (!endRef.current && (!isTrueRestoredRef.current || 0 !== opponentLastSeenRef.current)) {
hideOverlay(); hideOverlay();
sounds.current.starting.play();
} }
}; };

4
bin/phpunit Executable file
View File

@@ -0,0 +1,4 @@
#!/usr/bin/env php
<?php
require dirname(__DIR__).'/vendor/phpunit/phpunit/phpunit';

114
bun.lock
View File

@@ -38,27 +38,27 @@
"packages": { "packages": {
"@babel/code-frame": ["@babel/code-frame@7.29.0", "http://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", { "dependencies": { "@babel/helper-validator-identifier": "^7.28.5", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw=="], "@babel/code-frame": ["@babel/code-frame@7.29.0", "http://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", { "dependencies": { "@babel/helper-validator-identifier": "^7.28.5", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw=="],
"@babel/compat-data": ["@babel/compat-data@7.29.0", "", {}, "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg=="], "@babel/compat-data": ["@babel/compat-data@7.29.0", "http://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.0.tgz", {}, "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg=="],
"@babel/core": ["@babel/core@7.29.0", "", { "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", "@babel/helper-compilation-targets": "^7.28.6", "@babel/helper-module-transforms": "^7.28.6", "@babel/helpers": "^7.28.6", "@babel/parser": "^7.29.0", "@babel/template": "^7.28.6", "@babel/traverse": "^7.29.0", "@babel/types": "^7.29.0", "@jridgewell/remapping": "^2.3.5", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", "json5": "^2.2.3", "semver": "^6.3.1" } }, "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA=="], "@babel/core": ["@babel/core@7.29.0", "http://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz", { "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", "@babel/helper-compilation-targets": "^7.28.6", "@babel/helper-module-transforms": "^7.28.6", "@babel/helpers": "^7.28.6", "@babel/parser": "^7.29.0", "@babel/template": "^7.28.6", "@babel/traverse": "^7.29.0", "@babel/types": "^7.29.0", "@jridgewell/remapping": "^2.3.5", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", "json5": "^2.2.3", "semver": "^6.3.1" } }, "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA=="],
"@babel/generator": ["@babel/generator@7.29.1", "http://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz", { "dependencies": { "@babel/parser": "^7.29.0", "@babel/types": "^7.29.0", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" } }, "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw=="], "@babel/generator": ["@babel/generator@7.29.1", "http://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz", { "dependencies": { "@babel/parser": "^7.29.0", "@babel/types": "^7.29.0", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" } }, "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw=="],
"@babel/helper-compilation-targets": ["@babel/helper-compilation-targets@7.28.6", "", { "dependencies": { "@babel/compat-data": "^7.28.6", "@babel/helper-validator-option": "^7.27.1", "browserslist": "^4.24.0", "lru-cache": "^5.1.1", "semver": "^6.3.1" } }, "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA=="], "@babel/helper-compilation-targets": ["@babel/helper-compilation-targets@7.28.6", "http://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz", { "dependencies": { "@babel/compat-data": "^7.28.6", "@babel/helper-validator-option": "^7.27.1", "browserslist": "^4.24.0", "lru-cache": "^5.1.1", "semver": "^6.3.1" } }, "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA=="],
"@babel/helper-globals": ["@babel/helper-globals@7.28.0", "http://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", {}, "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw=="], "@babel/helper-globals": ["@babel/helper-globals@7.28.0", "http://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", {}, "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw=="],
"@babel/helper-module-imports": ["@babel/helper-module-imports@7.28.6", "http://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz", { "dependencies": { "@babel/traverse": "^7.28.6", "@babel/types": "^7.28.6" } }, "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw=="], "@babel/helper-module-imports": ["@babel/helper-module-imports@7.28.6", "http://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz", { "dependencies": { "@babel/traverse": "^7.28.6", "@babel/types": "^7.28.6" } }, "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw=="],
"@babel/helper-module-transforms": ["@babel/helper-module-transforms@7.28.6", "", { "dependencies": { "@babel/helper-module-imports": "^7.28.6", "@babel/helper-validator-identifier": "^7.28.5", "@babel/traverse": "^7.28.6" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA=="], "@babel/helper-module-transforms": ["@babel/helper-module-transforms@7.28.6", "http://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz", { "dependencies": { "@babel/helper-module-imports": "^7.28.6", "@babel/helper-validator-identifier": "^7.28.5", "@babel/traverse": "^7.28.6" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA=="],
"@babel/helper-string-parser": ["@babel/helper-string-parser@7.27.1", "http://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", {}, "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA=="], "@babel/helper-string-parser": ["@babel/helper-string-parser@7.27.1", "http://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", {}, "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA=="],
"@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.28.5", "http://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", {}, "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q=="], "@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.28.5", "http://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", {}, "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q=="],
"@babel/helper-validator-option": ["@babel/helper-validator-option@7.27.1", "", {}, "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg=="], "@babel/helper-validator-option": ["@babel/helper-validator-option@7.27.1", "http://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", {}, "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg=="],
"@babel/helpers": ["@babel/helpers@7.29.2", "", { "dependencies": { "@babel/template": "^7.28.6", "@babel/types": "^7.29.0" } }, "sha512-HoGuUs4sCZNezVEKdVcwqmZN8GoHirLUcLaYVNBK2J0DadGtdcqgr3BCbvH8+XUo4NGjNl3VOtSjEKNzqfFgKw=="], "@babel/helpers": ["@babel/helpers@7.29.2", "http://registry.npmjs.org/@babel/helpers/-/helpers-7.29.2.tgz", { "dependencies": { "@babel/template": "^7.28.6", "@babel/types": "^7.29.0" } }, "sha512-HoGuUs4sCZNezVEKdVcwqmZN8GoHirLUcLaYVNBK2J0DadGtdcqgr3BCbvH8+XUo4NGjNl3VOtSjEKNzqfFgKw=="],
"@babel/parser": ["@babel/parser@7.29.2", "http://registry.npmjs.org/@babel/parser/-/parser-7.29.2.tgz", { "dependencies": { "@babel/types": "^7.29.0" }, "bin": "./bin/babel-parser.js" }, "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA=="], "@babel/parser": ["@babel/parser@7.29.2", "http://registry.npmjs.org/@babel/parser/-/parser-7.29.2.tgz", { "dependencies": { "@babel/types": "^7.29.0" }, "bin": "./bin/babel-parser.js" }, "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA=="],
@@ -106,19 +106,19 @@
"@eslint-community/regexpp": ["@eslint-community/regexpp@4.12.2", "http://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", {}, "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew=="], "@eslint-community/regexpp": ["@eslint-community/regexpp@4.12.2", "http://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", {}, "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew=="],
"@eslint/config-array": ["@eslint/config-array@0.23.5", "", { "dependencies": { "@eslint/object-schema": "^3.0.5", "debug": "^4.3.1", "minimatch": "^10.2.4" } }, "sha512-Y3kKLvC1dvTOT+oGlqNQ1XLqK6D1HU2YXPc52NmAlJZbMMWDzGYXMiPRJ8TYD39muD/OTjlZmNJ4ib7dvSrMBA=="], "@eslint/config-array": ["@eslint/config-array@0.23.5", "http://registry.npmjs.org/@eslint/config-array/-/config-array-0.23.5.tgz", { "dependencies": { "@eslint/object-schema": "^3.0.5", "debug": "^4.3.1", "minimatch": "^10.2.4" } }, "sha512-Y3kKLvC1dvTOT+oGlqNQ1XLqK6D1HU2YXPc52NmAlJZbMMWDzGYXMiPRJ8TYD39muD/OTjlZmNJ4ib7dvSrMBA=="],
"@eslint/config-helpers": ["@eslint/config-helpers@0.5.5", "", { "dependencies": { "@eslint/core": "^1.2.1" } }, "sha512-eIJYKTCECbP/nsKaaruF6LW967mtbQbsw4JTtSVkUQc9MneSkbrgPJAbKl9nWr0ZeowV8BfsarBmPpBzGelA2w=="], "@eslint/config-helpers": ["@eslint/config-helpers@0.5.5", "http://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.5.5.tgz", { "dependencies": { "@eslint/core": "^1.2.1" } }, "sha512-eIJYKTCECbP/nsKaaruF6LW967mtbQbsw4JTtSVkUQc9MneSkbrgPJAbKl9nWr0ZeowV8BfsarBmPpBzGelA2w=="],
"@eslint/core": ["@eslint/core@1.2.1", "", { "dependencies": { "@types/json-schema": "^7.0.15" } }, "sha512-MwcE1P+AZ4C6DWlpin/OmOA54mmIZ/+xZuJiQd4SyB29oAJjN30UW9wkKNptW2ctp4cEsvhlLY/CsQ1uoHDloQ=="], "@eslint/core": ["@eslint/core@1.2.1", "http://registry.npmjs.org/@eslint/core/-/core-1.2.1.tgz", { "dependencies": { "@types/json-schema": "^7.0.15" } }, "sha512-MwcE1P+AZ4C6DWlpin/OmOA54mmIZ/+xZuJiQd4SyB29oAJjN30UW9wkKNptW2ctp4cEsvhlLY/CsQ1uoHDloQ=="],
"@eslint/eslintrc": ["@eslint/eslintrc@3.3.5", "http://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.5.tgz", { "dependencies": { "ajv": "^6.14.0", "debug": "^4.3.2", "espree": "^10.0.1", "globals": "^14.0.0", "ignore": "^5.2.0", "import-fresh": "^3.2.1", "js-yaml": "^4.1.1", "minimatch": "^3.1.5", "strip-json-comments": "^3.1.1" } }, "sha512-4IlJx0X0qftVsN5E+/vGujTRIFtwuLbNsVUe7TO6zYPDR1O6nFwvwhIKEKSrl6dZchmYBITazxKoUYOjdtjlRg=="], "@eslint/eslintrc": ["@eslint/eslintrc@3.3.5", "http://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.5.tgz", { "dependencies": { "ajv": "^6.14.0", "debug": "^4.3.2", "espree": "^10.0.1", "globals": "^14.0.0", "ignore": "^5.2.0", "import-fresh": "^3.2.1", "js-yaml": "^4.1.1", "minimatch": "^3.1.5", "strip-json-comments": "^3.1.1" } }, "sha512-4IlJx0X0qftVsN5E+/vGujTRIFtwuLbNsVUe7TO6zYPDR1O6nFwvwhIKEKSrl6dZchmYBITazxKoUYOjdtjlRg=="],
"@eslint/js": ["@eslint/js@10.0.1", "", { "peerDependencies": { "eslint": "^10.0.0" }, "optionalPeers": ["eslint"] }, "sha512-zeR9k5pd4gxjZ0abRoIaxdc7I3nDktoXZk2qOv9gCNWx3mVwEn32VRhyLaRsDiJjTs0xq/T8mfPtyuXu7GWBcA=="], "@eslint/js": ["@eslint/js@10.0.1", "http://registry.npmjs.org/@eslint/js/-/js-10.0.1.tgz", { "peerDependencies": { "eslint": "^10.0.0" }, "optionalPeers": ["eslint"] }, "sha512-zeR9k5pd4gxjZ0abRoIaxdc7I3nDktoXZk2qOv9gCNWx3mVwEn32VRhyLaRsDiJjTs0xq/T8mfPtyuXu7GWBcA=="],
"@eslint/object-schema": ["@eslint/object-schema@3.0.5", "", {}, "sha512-vqTaUEgxzm+YDSdElad6PiRoX4t8VGDjCtt05zn4nU810UIx/uNEV7/lZJ6KwFThKZOzOxzXy48da+No7HZaMw=="], "@eslint/object-schema": ["@eslint/object-schema@3.0.5", "http://registry.npmjs.org/@eslint/object-schema/-/object-schema-3.0.5.tgz", {}, "sha512-vqTaUEgxzm+YDSdElad6PiRoX4t8VGDjCtt05zn4nU810UIx/uNEV7/lZJ6KwFThKZOzOxzXy48da+No7HZaMw=="],
"@eslint/plugin-kit": ["@eslint/plugin-kit@0.7.1", "", { "dependencies": { "@eslint/core": "^1.2.1", "levn": "^0.4.1" } }, "sha512-rZAP3aVgB9ds9KOeUSL+zZ21hPmo8dh6fnIFwRQj5EAZl9gzR7wxYbYXYysAM8CTqGmUGyp2S4kUdV17MnGuWQ=="], "@eslint/plugin-kit": ["@eslint/plugin-kit@0.7.1", "http://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.7.1.tgz", { "dependencies": { "@eslint/core": "^1.2.1", "levn": "^0.4.1" } }, "sha512-rZAP3aVgB9ds9KOeUSL+zZ21hPmo8dh6fnIFwRQj5EAZl9gzR7wxYbYXYysAM8CTqGmUGyp2S4kUdV17MnGuWQ=="],
"@fontsource/changa-one": ["@fontsource/changa-one@5.2.8", "http://registry.npmjs.org/@fontsource/changa-one/-/changa-one-5.2.8.tgz", {}, "sha512-gagiU8sMWLs9ejh41NmrYlGdgasSYWFegz5/+22WqYdJVS9HZbaUEXj/6jj3ZKgi+dQZT0kYI+Nha2UQGbO/mA=="], "@fontsource/changa-one": ["@fontsource/changa-one@5.2.8", "http://registry.npmjs.org/@fontsource/changa-one/-/changa-one-5.2.8.tgz", {}, "sha512-gagiU8sMWLs9ejh41NmrYlGdgasSYWFegz5/+22WqYdJVS9HZbaUEXj/6jj3ZKgi+dQZT0kYI+Nha2UQGbO/mA=="],
@@ -138,7 +138,7 @@
"@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "http://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="], "@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "http://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="],
"@jridgewell/remapping": ["@jridgewell/remapping@2.3.5", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ=="], "@jridgewell/remapping": ["@jridgewell/remapping@2.3.5", "http://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ=="],
"@jridgewell/resolve-uri": ["@jridgewell/resolve-uri@3.1.2", "http://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", {}, "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw=="], "@jridgewell/resolve-uri": ["@jridgewell/resolve-uri@3.1.2", "http://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", {}, "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw=="],
@@ -168,7 +168,7 @@
"@mui/x-internals": ["@mui/x-internals@9.0.0", "http://registry.npmjs.org/@mui/x-internals/-/x-internals-9.0.0.tgz", { "dependencies": { "@babel/runtime": "^7.28.6", "@mui/utils": "9.0.0", "reselect": "^5.1.1", "use-sync-external-store": "^1.6.0" }, "peerDependencies": { "react": "^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-E/4rdg69JjhyybpPGypCjAKSKLLnSdCFM+O6P/nkUg47+qt3uftxQEhjQO53rcn6ahHl6du/uNZ9BLgeY6kYxQ=="], "@mui/x-internals": ["@mui/x-internals@9.0.0", "http://registry.npmjs.org/@mui/x-internals/-/x-internals-9.0.0.tgz", { "dependencies": { "@babel/runtime": "^7.28.6", "@mui/utils": "9.0.0", "reselect": "^5.1.1", "use-sync-external-store": "^1.6.0" }, "peerDependencies": { "react": "^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-E/4rdg69JjhyybpPGypCjAKSKLLnSdCFM+O6P/nkUg47+qt3uftxQEhjQO53rcn6ahHl6du/uNZ9BLgeY6kYxQ=="],
"@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.3", "http://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.3.tgz", { "dependencies": { "@tybys/wasm-util": "^0.10.1" }, "peerDependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1" } }, "sha512-xK9sGVbJWYb08+mTJt3/YV24WxvxpXcXtP6B172paPZ+Ts69Re9dAr7lKwJoeIx8OoeuimEiRZ7umkiUVClmmQ=="], "@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.4", "", { "dependencies": { "@tybys/wasm-util": "^0.10.1" }, "peerDependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1" } }, "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow=="],
"@nodelib/fs.scandir": ["@nodelib/fs.scandir@2.1.5", "http://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", { "dependencies": { "@nodelib/fs.stat": "2.0.5", "run-parallel": "^1.1.9" } }, "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g=="], "@nodelib/fs.scandir": ["@nodelib/fs.scandir@2.1.5", "http://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", { "dependencies": { "@nodelib/fs.stat": "2.0.5", "run-parallel": "^1.1.9" } }, "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g=="],
@@ -176,7 +176,7 @@
"@nodelib/fs.walk": ["@nodelib/fs.walk@1.2.8", "http://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", { "dependencies": { "@nodelib/fs.scandir": "2.1.5", "fastq": "^1.6.0" } }, "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg=="], "@nodelib/fs.walk": ["@nodelib/fs.walk@1.2.8", "http://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", { "dependencies": { "@nodelib/fs.scandir": "2.1.5", "fastq": "^1.6.0" } }, "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg=="],
"@oxc-project/types": ["@oxc-project/types@0.124.0", "http://registry.npmjs.org/@oxc-project/types/-/types-0.124.0.tgz", {}, "sha512-VBFWMTBvHxS11Z5Lvlr3IWgrwhMTXV+Md+EQF0Xf60+wAdsGFTBx7X7K/hP4pi8N7dcm1RvcHwDxZ16Qx8keUg=="], "@oxc-project/types": ["@oxc-project/types@0.126.0", "", {}, "sha512-oGfVtjAgwQVVpfBrbtk4e1XDyWHRFta6BS3GWVzrF8xYBT2VGQAk39yJS/wFSMrZqoiCU4oghT3Ch0HaHGIHcQ=="],
"@parcel/watcher": ["@parcel/watcher@2.5.6", "http://registry.npmjs.org/@parcel/watcher/-/watcher-2.5.6.tgz", { "dependencies": { "detect-libc": "^2.0.3", "is-glob": "^4.0.3", "node-addon-api": "^7.0.0", "picomatch": "^4.0.3" }, "optionalDependencies": { "@parcel/watcher-android-arm64": "2.5.6", "@parcel/watcher-darwin-arm64": "2.5.6", "@parcel/watcher-darwin-x64": "2.5.6", "@parcel/watcher-freebsd-x64": "2.5.6", "@parcel/watcher-linux-arm-glibc": "2.5.6", "@parcel/watcher-linux-arm-musl": "2.5.6", "@parcel/watcher-linux-arm64-glibc": "2.5.6", "@parcel/watcher-linux-arm64-musl": "2.5.6", "@parcel/watcher-linux-x64-glibc": "2.5.6", "@parcel/watcher-linux-x64-musl": "2.5.6", "@parcel/watcher-win32-arm64": "2.5.6", "@parcel/watcher-win32-ia32": "2.5.6", "@parcel/watcher-win32-x64": "2.5.6" } }, "sha512-tmmZ3lQxAe/k/+rNnXQRawJ4NjxO2hqiOLTHvWchtGZULp4RyFeh6aU4XdOYBFe2KE1oShQTv4AblOs2iOrNnQ=="], "@parcel/watcher": ["@parcel/watcher@2.5.6", "http://registry.npmjs.org/@parcel/watcher/-/watcher-2.5.6.tgz", { "dependencies": { "detect-libc": "^2.0.3", "is-glob": "^4.0.3", "node-addon-api": "^7.0.0", "picomatch": "^4.0.3" }, "optionalDependencies": { "@parcel/watcher-android-arm64": "2.5.6", "@parcel/watcher-darwin-arm64": "2.5.6", "@parcel/watcher-darwin-x64": "2.5.6", "@parcel/watcher-freebsd-x64": "2.5.6", "@parcel/watcher-linux-arm-glibc": "2.5.6", "@parcel/watcher-linux-arm-musl": "2.5.6", "@parcel/watcher-linux-arm64-glibc": "2.5.6", "@parcel/watcher-linux-arm64-musl": "2.5.6", "@parcel/watcher-linux-x64-glibc": "2.5.6", "@parcel/watcher-linux-x64-musl": "2.5.6", "@parcel/watcher-win32-arm64": "2.5.6", "@parcel/watcher-win32-ia32": "2.5.6", "@parcel/watcher-win32-x64": "2.5.6" } }, "sha512-tmmZ3lQxAe/k/+rNnXQRawJ4NjxO2hqiOLTHvWchtGZULp4RyFeh6aU4XdOYBFe2KE1oShQTv4AblOs2iOrNnQ=="],
@@ -210,39 +210,39 @@
"@popperjs/core": ["@popperjs/core@2.11.8", "http://registry.npmjs.org/@popperjs/core/-/core-2.11.8.tgz", {}, "sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A=="], "@popperjs/core": ["@popperjs/core@2.11.8", "http://registry.npmjs.org/@popperjs/core/-/core-2.11.8.tgz", {}, "sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A=="],
"@rolldown/binding-android-arm64": ["@rolldown/binding-android-arm64@1.0.0-rc.15", "http://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.15.tgz", { "os": "android", "cpu": "arm64" }, "sha512-YYe6aWruPZDtHNpwu7+qAHEMbQ/yRl6atqb/AhznLTnD3UY99Q1jE7ihLSahNWkF4EqRPVC4SiR4O0UkLK02tA=="], "@rolldown/binding-android-arm64": ["@rolldown/binding-android-arm64@1.0.0-rc.16", "", { "os": "android", "cpu": "arm64" }, "sha512-rhY3k7Bsae9qQfOtph2Pm2jZEA+s8Gmjoz4hhmx70K9iMQ/ddeae+xhRQcM5IuVx5ry1+bGfkvMn7D6MJggVSA=="],
"@rolldown/binding-darwin-arm64": ["@rolldown/binding-darwin-arm64@1.0.0-rc.15", "http://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0-rc.15.tgz", { "os": "darwin", "cpu": "arm64" }, "sha512-oArR/ig8wNTPYsXL+Mzhs0oxhxfuHRfG7Ikw7jXsw8mYOtk71W0OkF2VEVh699pdmzjPQsTjlD1JIOoHkLP1Fg=="], "@rolldown/binding-darwin-arm64": ["@rolldown/binding-darwin-arm64@1.0.0-rc.16", "", { "os": "darwin", "cpu": "arm64" }, "sha512-rNz0yK078yrNn3DrdgN+PKiMOW8HfQ92jQiXxwX8yW899ayV00MLVdaCNeVBhG/TbH3ouYVObo8/yrkiectkcQ=="],
"@rolldown/binding-darwin-x64": ["@rolldown/binding-darwin-x64@1.0.0-rc.15", "http://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0-rc.15.tgz", { "os": "darwin", "cpu": "x64" }, "sha512-YzeVqOqjPYvUbJSWJ4EDL8ahbmsIXQpgL3JVipmN+MX0XnXMeWomLN3Fb+nwCmP/jfyqte5I3XRSm7OfQrbyxw=="], "@rolldown/binding-darwin-x64": ["@rolldown/binding-darwin-x64@1.0.0-rc.16", "", { "os": "darwin", "cpu": "x64" }, "sha512-r/OmdR00HmD4i79Z//xO06uEPOq5hRXdhw7nzkxQxwSavs3PSHa1ijntdpOiZ2mzOQ3fVVu8C1M19FoNM+dMUQ=="],
"@rolldown/binding-freebsd-x64": ["@rolldown/binding-freebsd-x64@1.0.0-rc.15", "http://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0-rc.15.tgz", { "os": "freebsd", "cpu": "x64" }, "sha512-9Erhx956jeQ0nNTyif1+QWAXDRD38ZNjr//bSHrt6wDwB+QkAfl2q6Mn1k6OBPerznjRmbM10lgRb1Pli4xZPw=="], "@rolldown/binding-freebsd-x64": ["@rolldown/binding-freebsd-x64@1.0.0-rc.16", "", { "os": "freebsd", "cpu": "x64" }, "sha512-KcRE5w8h0OnjUatG8pldyD14/CQ5Phs1oxfR+3pKDjboHRo9+MkqQaiIZlZRpsxC15paeXme/I127tUa9TXJ6g=="],
"@rolldown/binding-linux-arm-gnueabihf": ["@rolldown/binding-linux-arm-gnueabihf@1.0.0-rc.15", "http://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0-rc.15.tgz", { "os": "linux", "cpu": "arm" }, "sha512-cVwk0w8QbZJGTnP/AHQBs5yNwmpgGYStL88t4UIaqcvYJWBfS0s3oqVLZPwsPU6M0zlW4GqjP0Zq5MnAGwFeGA=="], "@rolldown/binding-linux-arm-gnueabihf": ["@rolldown/binding-linux-arm-gnueabihf@1.0.0-rc.16", "", { "os": "linux", "cpu": "arm" }, "sha512-bT0guA1bpxEJ/ZhTRniQf7rNF8ybvXOuWbNIeLABaV5NGjx4EtOWBTSRGWFU9ZWVkPOZ+HNFP8RMcBokBiZ0Kg=="],
"@rolldown/binding-linux-arm64-gnu": ["@rolldown/binding-linux-arm64-gnu@1.0.0-rc.15", "http://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0-rc.15.tgz", { "os": "linux", "cpu": "arm64" }, "sha512-eBZ/u8iAK9SoHGanqe/jrPnY0JvBN6iXbVOsbO38mbz+ZJsaobExAm1Iu+rxa4S1l2FjG0qEZn4Rc6X8n+9M+w=="], "@rolldown/binding-linux-arm64-gnu": ["@rolldown/binding-linux-arm64-gnu@1.0.0-rc.16", "", { "os": "linux", "cpu": "arm64" }, "sha512-+tHktCHWV8BDQSjemUqm/Jl/TPk3QObCTIjmdDy/nlupcujZghmKK2962LYrqFpWu+ai01AN/REOH3NEpqvYQg=="],
"@rolldown/binding-linux-arm64-musl": ["@rolldown/binding-linux-arm64-musl@1.0.0-rc.15", "http://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0-rc.15.tgz", { "os": "linux", "cpu": "arm64" }, "sha512-ZvRYMGrAklV9PEkgt4LQM6MjQX2P58HPAuecwYObY2DhS2t35R0I810bKi0wmaYORt6m/2Sm+Z+nFgb0WhXNcQ=="], "@rolldown/binding-linux-arm64-musl": ["@rolldown/binding-linux-arm64-musl@1.0.0-rc.16", "", { "os": "linux", "cpu": "arm64" }, "sha512-3fPzdREH806oRLxpTWW1Gt4tQHs0TitZFOECB2xzCFLPKnSOy90gwA7P29cksYilFO6XVRY1kzga0cL2nRjKPg=="],
"@rolldown/binding-linux-ppc64-gnu": ["@rolldown/binding-linux-ppc64-gnu@1.0.0-rc.15", "http://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.0-rc.15.tgz", { "os": "linux", "cpu": "ppc64" }, "sha512-VDpgGBzgfg5hLg+uBpCLoFG5kVvEyafmfxGUV0UHLcL5irxAK7PKNeC2MwClgk6ZAiNhmo9FLhRYgvMmedLtnQ=="], "@rolldown/binding-linux-ppc64-gnu": ["@rolldown/binding-linux-ppc64-gnu@1.0.0-rc.16", "", { "os": "linux", "cpu": "ppc64" }, "sha512-EKwI1tSrLs7YVw+JPJT/G2dJQ1jl9qlTTTEG0V2Ok/RdOenRfBw2PQdLPyjhIu58ocdBfP7vIRN/pvMsPxs/AQ=="],
"@rolldown/binding-linux-s390x-gnu": ["@rolldown/binding-linux-s390x-gnu@1.0.0-rc.15", "http://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.0-rc.15.tgz", { "os": "linux", "cpu": "s390x" }, "sha512-y1uXY3qQWCzcPgRJATPSOUP4tCemh4uBdY7e3EZbVwCJTY3gLJWnQABgeUetvED+bt1FQ01OeZwvhLS2bpNrAQ=="], "@rolldown/binding-linux-s390x-gnu": ["@rolldown/binding-linux-s390x-gnu@1.0.0-rc.16", "", { "os": "linux", "cpu": "s390x" }, "sha512-Uknladnb3Sxqu6SEcqBldQyJUpk8NleooZEc0MbRBJ4inEhRYWZX0NJu12vNf2mqAq7gsofAxHrGghiUYjhaLQ=="],
"@rolldown/binding-linux-x64-gnu": ["@rolldown/binding-linux-x64-gnu@1.0.0-rc.15", "http://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0-rc.15.tgz", { "os": "linux", "cpu": "x64" }, "sha512-023bTPBod7J3Y/4fzAN6QtpkSABR0rigtrwaP+qSEabUh5zf6ELr9Nc7GujaROuPY3uwdSIXWrvhn1KxOvurWA=="], "@rolldown/binding-linux-x64-gnu": ["@rolldown/binding-linux-x64-gnu@1.0.0-rc.16", "", { "os": "linux", "cpu": "x64" }, "sha512-FIb8+uG49sZBtLTn+zt1AJ20TqVcqWeSIyoVt0or7uAWesgKaHbiBh6OpA/k9v0LTt+PTrb1Lao133kP4uVxkg=="],
"@rolldown/binding-linux-x64-musl": ["@rolldown/binding-linux-x64-musl@1.0.0-rc.15", "http://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0-rc.15.tgz", { "os": "linux", "cpu": "x64" }, "sha512-witB2O0/hU4CgfOOKUoeFgQ4GktPi1eEbAhaLAIpgD6+ZnhcPkUtPsoKKHRzmOoWPZue46IThdSgdo4XneOLYw=="], "@rolldown/binding-linux-x64-musl": ["@rolldown/binding-linux-x64-musl@1.0.0-rc.16", "", { "os": "linux", "cpu": "x64" }, "sha512-RuERhF9/EgWxZEXYWCOaViUWHIboceK4/ivdtQ3R0T44NjLkIIlGIAVAuCddFxsZ7vnRHtNQUrt2vR2n2slB2w=="],
"@rolldown/binding-openharmony-arm64": ["@rolldown/binding-openharmony-arm64@1.0.0-rc.15", "http://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0-rc.15.tgz", { "os": "none", "cpu": "arm64" }, "sha512-UCL68NJ0Ud5zRipXZE9dF5PmirzJE4E4BCIOOssEnM7wLDsxjc6Qb0sGDxTNRTP53I6MZpygyCpY8Aa8sPfKPg=="], "@rolldown/binding-openharmony-arm64": ["@rolldown/binding-openharmony-arm64@1.0.0-rc.16", "", { "os": "none", "cpu": "arm64" }, "sha512-mXcXnvd9GpazCxeUCCnZ2+YF7nut+ZOEbE4GtaiPtyY6AkhZWbK70y1KK3j+RDhjVq5+U8FySkKRb/+w0EeUwA=="],
"@rolldown/binding-wasm32-wasi": ["@rolldown/binding-wasm32-wasi@1.0.0-rc.15", "http://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0-rc.15.tgz", { "dependencies": { "@emnapi/core": "1.9.2", "@emnapi/runtime": "1.9.2", "@napi-rs/wasm-runtime": "^1.1.3" }, "cpu": "none" }, "sha512-ApLruZq/ig+nhaE7OJm4lDjayUnOHVUa77zGeqnqZ9pn0ovdVbbNPerVibLXDmWeUZXjIYIT8V3xkT58Rm9u5Q=="], "@rolldown/binding-wasm32-wasi": ["@rolldown/binding-wasm32-wasi@1.0.0-rc.16", "", { "dependencies": { "@emnapi/core": "1.9.2", "@emnapi/runtime": "1.9.2", "@napi-rs/wasm-runtime": "^1.1.4" }, "cpu": "none" }, "sha512-3Q2KQxnC8IJOLqXmUMoYwyIPZU9hzRbnHaoV3Euz+VVnjZKcY8ktnNP8T9R4/GGQtb27C/UYKABxesKWb8lsvQ=="],
"@rolldown/binding-win32-arm64-msvc": ["@rolldown/binding-win32-arm64-msvc@1.0.0-rc.15", "http://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0-rc.15.tgz", { "os": "win32", "cpu": "arm64" }, "sha512-KmoUoU7HnN+Si5YWJigfTws1jz1bKBYDQKdbLspz0UaqjjFkddHsqorgiW1mxcAj88lYUE6NC/zJNwT+SloqtA=="], "@rolldown/binding-win32-arm64-msvc": ["@rolldown/binding-win32-arm64-msvc@1.0.0-rc.16", "", { "os": "win32", "cpu": "arm64" }, "sha512-tj7XRemQcOcFwv7qhpUxMTBbI5mWMlE4c1Omhg5+h8GuLXzyj8HviYgR+bB2DMDgRqUE+jiDleqSCRjx4aYk/Q=="],
"@rolldown/binding-win32-x64-msvc": ["@rolldown/binding-win32-x64-msvc@1.0.0-rc.15", "http://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0-rc.15.tgz", { "os": "win32", "cpu": "x64" }, "sha512-3P2A8L+x75qavWLe/Dll3EYBJLQmtkJN8rfh+U/eR3MqMgL/h98PhYI+JFfXuDPgPeCB7iZAKiqii5vqOvnA0g=="], "@rolldown/binding-win32-x64-msvc": ["@rolldown/binding-win32-x64-msvc@1.0.0-rc.16", "", { "os": "win32", "cpu": "x64" }, "sha512-PH5DRZT+F4f2PTXRXR8uJxnBq2po/xFtddyabTJVJs/ZYVHqXPEgNIr35IHTEa6bpa0Q8Awg+ymkTaGnKITw4g=="],
"@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-rc.7", "http://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.7.tgz", {}, "sha512-qujRfC8sFVInYSPPMLQByRh7zhwkGFS4+tyMQ83srV1qrxL4g8E2tyxVVyxd0+8QeBM1mIk9KbWxkegRr76XzA=="], "@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-rc.7", "http://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.7.tgz", {}, "sha512-qujRfC8sFVInYSPPMLQByRh7zhwkGFS4+tyMQ83srV1qrxL4g8E2tyxVVyxd0+8QeBM1mIk9KbWxkegRr76XzA=="],
"@stylistic/eslint-plugin": ["@stylistic/eslint-plugin@5.10.0", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.9.1", "@typescript-eslint/types": "^8.56.0", "eslint-visitor-keys": "^4.2.1", "espree": "^10.4.0", "estraverse": "^5.3.0", "picomatch": "^4.0.3" }, "peerDependencies": { "eslint": "^9.0.0 || ^10.0.0" } }, "sha512-nPK52ZHvot8Ju/0A4ucSX1dcPV2/1clx0kLcH5wDmrE4naKso7TUC/voUyU1O9OTKTrR6MYip6LP0ogEMQ9jPQ=="], "@stylistic/eslint-plugin": ["@stylistic/eslint-plugin@5.10.0", "http://registry.npmjs.org/@stylistic/eslint-plugin/-/eslint-plugin-5.10.0.tgz", { "dependencies": { "@eslint-community/eslint-utils": "^4.9.1", "@typescript-eslint/types": "^8.56.0", "eslint-visitor-keys": "^4.2.1", "espree": "^10.4.0", "estraverse": "^5.3.0", "picomatch": "^4.0.3" }, "peerDependencies": { "eslint": "^9.0.0 || ^10.0.0" } }, "sha512-nPK52ZHvot8Ju/0A4ucSX1dcPV2/1clx0kLcH5wDmrE4naKso7TUC/voUyU1O9OTKTrR6MYip6LP0ogEMQ9jPQ=="],
"@tanstack/query-core": ["@tanstack/query-core@5.99.2", "http://registry.npmjs.org/@tanstack/query-core/-/query-core-5.99.2.tgz", {}, "sha512-1HunU0bXVsR1ZJMZbcOPE6VtaBJxsW809RE9xPe4Gz7MlB0GWwQvuTPhMoEmQ/hIzFKJ/DWAuttIe7BOaWx0tA=="], "@tanstack/query-core": ["@tanstack/query-core@5.99.2", "http://registry.npmjs.org/@tanstack/query-core/-/query-core-5.99.2.tgz", {}, "sha512-1HunU0bXVsR1ZJMZbcOPE6VtaBJxsW809RE9xPe4Gz7MlB0GWwQvuTPhMoEmQ/hIzFKJ/DWAuttIe7BOaWx0tA=="],
@@ -270,7 +270,7 @@
"@types/d3-timer": ["@types/d3-timer@3.0.2", "http://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz", {}, "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw=="], "@types/d3-timer": ["@types/d3-timer@3.0.2", "http://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz", {}, "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw=="],
"@types/esrecurse": ["@types/esrecurse@4.3.1", "", {}, "sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw=="], "@types/esrecurse": ["@types/esrecurse@4.3.1", "http://registry.npmjs.org/@types/esrecurse/-/esrecurse-4.3.1.tgz", {}, "sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw=="],
"@types/estree": ["@types/estree@1.0.8", "http://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="], "@types/estree": ["@types/estree@1.0.8", "http://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="],
@@ -318,7 +318,7 @@
"balanced-match": ["balanced-match@1.0.0", "http://registry.npmjs.org/balanced-match/-/balanced-match-1.0.0.tgz", {}, "sha512-9Y0g0Q8rmSt+H33DfKv7FOc3v+iRI+o1lbzt8jGcIosYW37IIW/2XVYq5NPdmaD5NQ59Nk26Kl/vZbwW9Fr8vg=="], "balanced-match": ["balanced-match@1.0.0", "http://registry.npmjs.org/balanced-match/-/balanced-match-1.0.0.tgz", {}, "sha512-9Y0g0Q8rmSt+H33DfKv7FOc3v+iRI+o1lbzt8jGcIosYW37IIW/2XVYq5NPdmaD5NQ59Nk26Kl/vZbwW9Fr8vg=="],
"baseline-browser-mapping": ["baseline-browser-mapping@2.10.20", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-1AaXxEPfXT+GvTBJFuy4yXVHWJBXa4OdbIebGN/wX5DlsIkU0+wzGnd2lOzokSk51d5LUmqjgBLRLlypLUqInQ=="], "baseline-browser-mapping": ["baseline-browser-mapping@2.10.20", "http://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.20.tgz", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-1AaXxEPfXT+GvTBJFuy4yXVHWJBXa4OdbIebGN/wX5DlsIkU0+wzGnd2lOzokSk51d5LUmqjgBLRLlypLUqInQ=="],
"bezier-easing": ["bezier-easing@2.1.0", "http://registry.npmjs.org/bezier-easing/-/bezier-easing-2.1.0.tgz", {}, "sha512-gbIqZ/eslnUFC1tjEvtz0sgx+xTK20wDnYMIA27VA04R7w6xxXQPZDbibjA9DTWZRA2CXtwHykkVzlCaAJAZig=="], "bezier-easing": ["bezier-easing@2.1.0", "http://registry.npmjs.org/bezier-easing/-/bezier-easing-2.1.0.tgz", {}, "sha512-gbIqZ/eslnUFC1tjEvtz0sgx+xTK20wDnYMIA27VA04R7w6xxXQPZDbibjA9DTWZRA2CXtwHykkVzlCaAJAZig=="],
@@ -326,7 +326,7 @@
"braces": ["braces@3.0.3", "http://registry.npmjs.org/braces/-/braces-3.0.3.tgz", { "dependencies": { "fill-range": "^7.1.1" } }, "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA=="], "braces": ["braces@3.0.3", "http://registry.npmjs.org/braces/-/braces-3.0.3.tgz", { "dependencies": { "fill-range": "^7.1.1" } }, "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA=="],
"browserslist": ["browserslist@4.28.2", "", { "dependencies": { "baseline-browser-mapping": "^2.10.12", "caniuse-lite": "^1.0.30001782", "electron-to-chromium": "^1.5.328", "node-releases": "^2.0.36", "update-browserslist-db": "^1.2.3" }, "bin": { "browserslist": "cli.js" } }, "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg=="], "browserslist": ["browserslist@4.28.2", "http://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz", { "dependencies": { "baseline-browser-mapping": "^2.10.12", "caniuse-lite": "^1.0.30001782", "electron-to-chromium": "^1.5.328", "node-releases": "^2.0.36", "update-browserslist-db": "^1.2.3" }, "bin": { "browserslist": "cli.js" } }, "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg=="],
"call-bind": ["call-bind@1.0.9", "http://registry.npmjs.org/call-bind/-/call-bind-1.0.9.tgz", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "es-define-property": "^1.0.1", "get-intrinsic": "^1.3.0", "set-function-length": "^1.2.2" } }, "sha512-a/hy+pNsFUTR+Iz8TCJvXudKVLAnz/DyeSUo10I5yvFDQJBFU2s9uqQpoSrJlroHUKoKqzg+epxyP9lqFdzfBQ=="], "call-bind": ["call-bind@1.0.9", "http://registry.npmjs.org/call-bind/-/call-bind-1.0.9.tgz", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "es-define-property": "^1.0.1", "get-intrinsic": "^1.3.0", "set-function-length": "^1.2.2" } }, "sha512-a/hy+pNsFUTR+Iz8TCJvXudKVLAnz/DyeSUo10I5yvFDQJBFU2s9uqQpoSrJlroHUKoKqzg+epxyP9lqFdzfBQ=="],
@@ -336,7 +336,7 @@
"callsites": ["callsites@3.1.0", "http://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", {}, "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ=="], "callsites": ["callsites@3.1.0", "http://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", {}, "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ=="],
"caniuse-lite": ["caniuse-lite@1.0.30001788", "", {}, "sha512-6q8HFp+lOQtcf7wBK+uEenxymVWkGKkjFpCvw5W25cmMwEDU45p1xQFBQv8JDlMMry7eNxyBaR+qxgmTUZkIRQ=="], "caniuse-lite": ["caniuse-lite@1.0.30001788", "http://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001788.tgz", {}, "sha512-6q8HFp+lOQtcf7wBK+uEenxymVWkGKkjFpCvw5W25cmMwEDU45p1xQFBQv8JDlMMry7eNxyBaR+qxgmTUZkIRQ=="],
"chokidar": ["chokidar@4.0.3", "http://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", { "dependencies": { "readdirp": "^4.0.1" } }, "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA=="], "chokidar": ["chokidar@4.0.3", "http://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", { "dependencies": { "readdirp": "^4.0.1" } }, "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA=="],
@@ -344,7 +344,7 @@
"concat-map": ["concat-map@0.0.1", "http://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", {}, "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg=="], "concat-map": ["concat-map@0.0.1", "http://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", {}, "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg=="],
"convert-source-map": ["convert-source-map@2.0.0", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="], "convert-source-map": ["convert-source-map@2.0.0", "http://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="],
"cosmiconfig": ["cosmiconfig@7.1.0", "http://registry.npmjs.org/cosmiconfig/-/cosmiconfig-7.1.0.tgz", { "dependencies": { "@types/parse-json": "^4.0.0", "import-fresh": "^3.2.1", "parse-json": "^5.0.0", "path-type": "^4.0.0", "yaml": "^1.10.0" } }, "sha512-AdmX6xUzdNASswsFtmwSt7Vj8po9IuqXm0UXz7QKPuEUmPB4XyjGfaAr2PSuELMwkRMVH1EpIkX5bTZGRB3eCA=="], "cosmiconfig": ["cosmiconfig@7.1.0", "http://registry.npmjs.org/cosmiconfig/-/cosmiconfig-7.1.0.tgz", { "dependencies": { "@types/parse-json": "^4.0.0", "import-fresh": "^3.2.1", "parse-json": "^5.0.0", "path-type": "^4.0.0", "yaml": "^1.10.0" } }, "sha512-AdmX6xUzdNASswsFtmwSt7Vj8po9IuqXm0UXz7QKPuEUmPB4XyjGfaAr2PSuELMwkRMVH1EpIkX5bTZGRB3eCA=="],
@@ -394,7 +394,7 @@
"dunder-proto": ["dunder-proto@1.0.1", "http://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="], "dunder-proto": ["dunder-proto@1.0.1", "http://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="],
"electron-to-chromium": ["electron-to-chromium@1.5.340", "", {}, "sha512-908qahOGocRMinT2nM3ajCEM99H4iPdv84eagPP3FfZy/1ZGeOy2CZYzjhms81ckOPCXPlW7LkY4XpxD8r1DrA=="], "electron-to-chromium": ["electron-to-chromium@1.5.340", "http://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.340.tgz", {}, "sha512-908qahOGocRMinT2nM3ajCEM99H4iPdv84eagPP3FfZy/1ZGeOy2CZYzjhms81ckOPCXPlW7LkY4XpxD8r1DrA=="],
"error-ex": ["error-ex@1.3.4", "http://registry.npmjs.org/error-ex/-/error-ex-1.3.4.tgz", { "dependencies": { "is-arrayish": "^0.2.1" } }, "sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ=="], "error-ex": ["error-ex@1.3.4", "http://registry.npmjs.org/error-ex/-/error-ex-1.3.4.tgz", { "dependencies": { "is-arrayish": "^0.2.1" } }, "sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ=="],
@@ -414,17 +414,17 @@
"es-to-primitive": ["es-to-primitive@1.3.0", "http://registry.npmjs.org/es-to-primitive/-/es-to-primitive-1.3.0.tgz", { "dependencies": { "is-callable": "^1.2.7", "is-date-object": "^1.0.5", "is-symbol": "^1.0.4" } }, "sha512-w+5mJ3GuFL+NjVtJlvydShqE1eN3h3PbI7/5LAsYJP/2qtuMXjfL2LpHSRqo4b4eSF5K/DH1JXKUAHSB2UW50g=="], "es-to-primitive": ["es-to-primitive@1.3.0", "http://registry.npmjs.org/es-to-primitive/-/es-to-primitive-1.3.0.tgz", { "dependencies": { "is-callable": "^1.2.7", "is-date-object": "^1.0.5", "is-symbol": "^1.0.4" } }, "sha512-w+5mJ3GuFL+NjVtJlvydShqE1eN3h3PbI7/5LAsYJP/2qtuMXjfL2LpHSRqo4b4eSF5K/DH1JXKUAHSB2UW50g=="],
"escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="], "escalade": ["escalade@3.2.0", "http://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="],
"escape-string-regexp": ["escape-string-regexp@4.0.0", "http://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", {}, "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA=="], "escape-string-regexp": ["escape-string-regexp@4.0.0", "http://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", {}, "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA=="],
"eslint": ["eslint@10.2.1", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.2", "@eslint/config-array": "^0.23.5", "@eslint/config-helpers": "^0.5.5", "@eslint/core": "^1.2.1", "@eslint/plugin-kit": "^0.7.1", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", "@humanwhocodes/retry": "^0.4.2", "@types/estree": "^1.0.6", "ajv": "^6.14.0", "cross-spawn": "^7.0.6", "debug": "^4.3.2", "escape-string-regexp": "^4.0.0", "eslint-scope": "^9.1.2", "eslint-visitor-keys": "^5.0.1", "espree": "^11.2.0", "esquery": "^1.7.0", "esutils": "^2.0.2", "fast-deep-equal": "^3.1.3", "file-entry-cache": "^8.0.0", "find-up": "^5.0.0", "glob-parent": "^6.0.2", "ignore": "^5.2.0", "imurmurhash": "^0.1.4", "is-glob": "^4.0.0", "json-stable-stringify-without-jsonify": "^1.0.1", "minimatch": "^10.2.4", "natural-compare": "^1.4.0", "optionator": "^0.9.3" }, "peerDependencies": { "jiti": "*" }, "optionalPeers": ["jiti"], "bin": { "eslint": "bin/eslint.js" } }, "sha512-wiyGaKsDgqXvF40P8mDwiUp/KQjE1FdrIEJsM8PZ3XCiniTMXS3OHWWUe5FI5agoCnr8x4xPrTDZuxsBlNHl+Q=="], "eslint": ["eslint@10.2.1", "http://registry.npmjs.org/eslint/-/eslint-10.2.1.tgz", { "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.2", "@eslint/config-array": "^0.23.5", "@eslint/config-helpers": "^0.5.5", "@eslint/core": "^1.2.1", "@eslint/plugin-kit": "^0.7.1", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", "@humanwhocodes/retry": "^0.4.2", "@types/estree": "^1.0.6", "ajv": "^6.14.0", "cross-spawn": "^7.0.6", "debug": "^4.3.2", "escape-string-regexp": "^4.0.0", "eslint-scope": "^9.1.2", "eslint-visitor-keys": "^5.0.1", "espree": "^11.2.0", "esquery": "^1.7.0", "esutils": "^2.0.2", "fast-deep-equal": "^3.1.3", "file-entry-cache": "^8.0.0", "find-up": "^5.0.0", "glob-parent": "^6.0.2", "ignore": "^5.2.0", "imurmurhash": "^0.1.4", "is-glob": "^4.0.0", "json-stable-stringify-without-jsonify": "^1.0.1", "minimatch": "^10.2.4", "natural-compare": "^1.4.0", "optionator": "^0.9.3" }, "peerDependencies": { "jiti": "*" }, "optionalPeers": ["jiti"], "bin": { "eslint": "bin/eslint.js" } }, "sha512-wiyGaKsDgqXvF40P8mDwiUp/KQjE1FdrIEJsM8PZ3XCiniTMXS3OHWWUe5FI5agoCnr8x4xPrTDZuxsBlNHl+Q=="],
"eslint-plugin-react": ["eslint-plugin-react@7.37.5", "http://registry.npmjs.org/eslint-plugin-react/-/eslint-plugin-react-7.37.5.tgz", { "dependencies": { "array-includes": "^3.1.8", "array.prototype.findlast": "^1.2.5", "array.prototype.flatmap": "^1.3.3", "array.prototype.tosorted": "^1.1.4", "doctrine": "^2.1.0", "es-iterator-helpers": "^1.2.1", "estraverse": "^5.3.0", "hasown": "^2.0.2", "jsx-ast-utils": "^2.4.1 || ^3.0.0", "minimatch": "^3.1.2", "object.entries": "^1.1.9", "object.fromentries": "^2.0.8", "object.values": "^1.2.1", "prop-types": "^15.8.1", "resolve": "^2.0.0-next.5", "semver": "^6.3.1", "string.prototype.matchall": "^4.0.12", "string.prototype.repeat": "^1.0.0" }, "peerDependencies": { "eslint": "^3 || ^4 || ^5 || ^6 || ^7 || ^8 || ^9.7" } }, "sha512-Qteup0SqU15kdocexFNAJMvCJEfa2xUKNV4CC1xsVMrIIqEy3SQ/rqyxCWNzfrd3/ldy6HMlD2e0JDVpDg2qIA=="], "eslint-plugin-react": ["eslint-plugin-react@7.37.5", "http://registry.npmjs.org/eslint-plugin-react/-/eslint-plugin-react-7.37.5.tgz", { "dependencies": { "array-includes": "^3.1.8", "array.prototype.findlast": "^1.2.5", "array.prototype.flatmap": "^1.3.3", "array.prototype.tosorted": "^1.1.4", "doctrine": "^2.1.0", "es-iterator-helpers": "^1.2.1", "estraverse": "^5.3.0", "hasown": "^2.0.2", "jsx-ast-utils": "^2.4.1 || ^3.0.0", "minimatch": "^3.1.2", "object.entries": "^1.1.9", "object.fromentries": "^2.0.8", "object.values": "^1.2.1", "prop-types": "^15.8.1", "resolve": "^2.0.0-next.5", "semver": "^6.3.1", "string.prototype.matchall": "^4.0.12", "string.prototype.repeat": "^1.0.0" }, "peerDependencies": { "eslint": "^3 || ^4 || ^5 || ^6 || ^7 || ^8 || ^9.7" } }, "sha512-Qteup0SqU15kdocexFNAJMvCJEfa2xUKNV4CC1xsVMrIIqEy3SQ/rqyxCWNzfrd3/ldy6HMlD2e0JDVpDg2qIA=="],
"eslint-plugin-react-hooks": ["eslint-plugin-react-hooks@7.1.1", "", { "dependencies": { "@babel/core": "^7.24.4", "@babel/parser": "^7.24.4", "hermes-parser": "^0.25.1", "zod": "^3.25.0 || ^4.0.0", "zod-validation-error": "^3.5.0 || ^4.0.0" }, "peerDependencies": { "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0 || ^10.0.0" } }, "sha512-f2I7Gw6JbvCexzIInuSbZpfdQ44D7iqdWX01FKLvrPgqxoE7oMj8clOfto8U6vYiz4yd5oKu39rRSVOe1zRu0g=="], "eslint-plugin-react-hooks": ["eslint-plugin-react-hooks@7.1.1", "http://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-7.1.1.tgz", { "dependencies": { "@babel/core": "^7.24.4", "@babel/parser": "^7.24.4", "hermes-parser": "^0.25.1", "zod": "^3.25.0 || ^4.0.0", "zod-validation-error": "^3.5.0 || ^4.0.0" }, "peerDependencies": { "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0 || ^10.0.0" } }, "sha512-f2I7Gw6JbvCexzIInuSbZpfdQ44D7iqdWX01FKLvrPgqxoE7oMj8clOfto8U6vYiz4yd5oKu39rRSVOe1zRu0g=="],
"eslint-scope": ["eslint-scope@9.1.2", "", { "dependencies": { "@types/esrecurse": "^4.3.1", "@types/estree": "^1.0.8", "esrecurse": "^4.3.0", "estraverse": "^5.2.0" } }, "sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ=="], "eslint-scope": ["eslint-scope@9.1.2", "http://registry.npmjs.org/eslint-scope/-/eslint-scope-9.1.2.tgz", { "dependencies": { "@types/esrecurse": "^4.3.1", "@types/estree": "^1.0.8", "esrecurse": "^4.3.0", "estraverse": "^5.2.0" } }, "sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ=="],
"eslint-visitor-keys": ["eslint-visitor-keys@4.2.1", "http://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", {}, "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ=="], "eslint-visitor-keys": ["eslint-visitor-keys@4.2.1", "http://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", {}, "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ=="],
@@ -476,7 +476,7 @@
"generator-function": ["generator-function@2.0.1", "http://registry.npmjs.org/generator-function/-/generator-function-2.0.1.tgz", {}, "sha512-SFdFmIJi+ybC0vjlHN0ZGVGHc3lgE0DxPAT0djjVg+kjOnSqclqmj0KQ7ykTOLP6YxoqOvuAODGdcHJn+43q3g=="], "generator-function": ["generator-function@2.0.1", "http://registry.npmjs.org/generator-function/-/generator-function-2.0.1.tgz", {}, "sha512-SFdFmIJi+ybC0vjlHN0ZGVGHc3lgE0DxPAT0djjVg+kjOnSqclqmj0KQ7ykTOLP6YxoqOvuAODGdcHJn+43q3g=="],
"gensync": ["gensync@1.0.0-beta.2", "", {}, "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg=="], "gensync": ["gensync@1.0.0-beta.2", "http://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", {}, "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg=="],
"get-intrinsic": ["get-intrinsic@1.3.0", "http://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.1", "function-bind": "^1.1.2", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-symbols": "^1.1.0", "hasown": "^2.0.2", "math-intrinsics": "^1.1.0" } }, "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ=="], "get-intrinsic": ["get-intrinsic@1.3.0", "http://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.1", "function-bind": "^1.1.2", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-symbols": "^1.1.0", "hasown": "^2.0.2", "math-intrinsics": "^1.1.0" } }, "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ=="],
@@ -486,7 +486,7 @@
"glob-parent": ["glob-parent@6.0.2", "http://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", { "dependencies": { "is-glob": "^4.0.3" } }, "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A=="], "glob-parent": ["glob-parent@6.0.2", "http://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", { "dependencies": { "is-glob": "^4.0.3" } }, "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A=="],
"globals": ["globals@17.5.0", "", {}, "sha512-qoV+HK2yFl/366t2/Cb3+xxPUo5BuMynomoDmiaZBIdbs+0pYbjfZU+twLhGKp4uCZ/+NbtpVepH5bGCxRyy2g=="], "globals": ["globals@17.5.0", "http://registry.npmjs.org/globals/-/globals-17.5.0.tgz", {}, "sha512-qoV+HK2yFl/366t2/Cb3+xxPUo5BuMynomoDmiaZBIdbs+0pYbjfZU+twLhGKp4uCZ/+NbtpVepH5bGCxRyy2g=="],
"globalthis": ["globalthis@1.0.4", "http://registry.npmjs.org/globalthis/-/globalthis-1.0.4.tgz", { "dependencies": { "define-properties": "^1.2.1", "gopd": "^1.0.1" } }, "sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ=="], "globalthis": ["globalthis@1.0.4", "http://registry.npmjs.org/globalthis/-/globalthis-1.0.4.tgz", { "dependencies": { "define-properties": "^1.2.1", "gopd": "^1.0.1" } }, "sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ=="],
@@ -504,9 +504,9 @@
"hasown": ["hasown@2.0.2", "http://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ=="], "hasown": ["hasown@2.0.2", "http://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ=="],
"hermes-estree": ["hermes-estree@0.25.1", "", {}, "sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw=="], "hermes-estree": ["hermes-estree@0.25.1", "http://registry.npmjs.org/hermes-estree/-/hermes-estree-0.25.1.tgz", {}, "sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw=="],
"hermes-parser": ["hermes-parser@0.25.1", "", { "dependencies": { "hermes-estree": "0.25.1" } }, "sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA=="], "hermes-parser": ["hermes-parser@0.25.1", "http://registry.npmjs.org/hermes-parser/-/hermes-parser-0.25.1.tgz", { "dependencies": { "hermes-estree": "0.25.1" } }, "sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA=="],
"hoist-non-react-statics": ["hoist-non-react-statics@3.3.2", "http://registry.npmjs.org/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz", { "dependencies": { "react-is": "^16.7.0" } }, "sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw=="], "hoist-non-react-statics": ["hoist-non-react-statics@3.3.2", "http://registry.npmjs.org/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz", { "dependencies": { "react-is": "^16.7.0" } }, "sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw=="],
@@ -596,7 +596,7 @@
"json-stable-stringify-without-jsonify": ["json-stable-stringify-without-jsonify@1.0.1", "http://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", {}, "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw=="], "json-stable-stringify-without-jsonify": ["json-stable-stringify-without-jsonify@1.0.1", "http://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", {}, "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw=="],
"json5": ["json5@2.2.3", "", { "bin": { "json5": "lib/cli.js" } }, "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg=="], "json5": ["json5@2.2.3", "http://registry.npmjs.org/json5/-/json5-2.2.3.tgz", { "bin": { "json5": "lib/cli.js" } }, "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg=="],
"jsx-ast-utils": ["jsx-ast-utils@3.3.5", "http://registry.npmjs.org/jsx-ast-utils/-/jsx-ast-utils-3.3.5.tgz", { "dependencies": { "array-includes": "^3.1.6", "array.prototype.flat": "^1.3.1", "object.assign": "^4.1.4", "object.values": "^1.1.6" } }, "sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ=="], "jsx-ast-utils": ["jsx-ast-utils@3.3.5", "http://registry.npmjs.org/jsx-ast-utils/-/jsx-ast-utils-3.3.5.tgz", { "dependencies": { "array-includes": "^3.1.6", "array.prototype.flat": "^1.3.1", "object.assign": "^4.1.4", "object.values": "^1.1.6" } }, "sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ=="],
@@ -636,7 +636,7 @@
"loose-envify": ["loose-envify@1.4.0", "http://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", { "dependencies": { "js-tokens": "^3.0.0 || ^4.0.0" }, "bin": "cli.js" }, "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q=="], "loose-envify": ["loose-envify@1.4.0", "http://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", { "dependencies": { "js-tokens": "^3.0.0 || ^4.0.0" }, "bin": "cli.js" }, "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q=="],
"lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="], "lru-cache": ["lru-cache@5.1.1", "http://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="],
"math-intrinsics": ["math-intrinsics@1.1.0", "http://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="], "math-intrinsics": ["math-intrinsics@1.1.0", "http://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="],
@@ -658,7 +658,7 @@
"node-exports-info": ["node-exports-info@1.6.0", "http://registry.npmjs.org/node-exports-info/-/node-exports-info-1.6.0.tgz", { "dependencies": { "array.prototype.flatmap": "^1.3.3", "es-errors": "^1.3.0", "object.entries": "^1.1.9", "semver": "^6.3.1" } }, "sha512-pyFS63ptit/P5WqUkt+UUfe+4oevH+bFeIiPPdfb0pFeYEu/1ELnJu5l+5EcTKYL5M7zaAa7S8ddywgXypqKCw=="], "node-exports-info": ["node-exports-info@1.6.0", "http://registry.npmjs.org/node-exports-info/-/node-exports-info-1.6.0.tgz", { "dependencies": { "array.prototype.flatmap": "^1.3.3", "es-errors": "^1.3.0", "object.entries": "^1.1.9", "semver": "^6.3.1" } }, "sha512-pyFS63ptit/P5WqUkt+UUfe+4oevH+bFeIiPPdfb0pFeYEu/1ELnJu5l+5EcTKYL5M7zaAa7S8ddywgXypqKCw=="],
"node-releases": ["node-releases@2.0.37", "", {}, "sha512-1h5gKZCF+pO/o3Iqt5Jp7wc9rH3eJJ0+nh/CIoiRwjRxde/hAHyLPXYN4V3CqKAbiZPSeJFSWHmJsbkicta0Eg=="], "node-releases": ["node-releases@2.0.37", "http://registry.npmjs.org/node-releases/-/node-releases-2.0.37.tgz", {}, "sha512-1h5gKZCF+pO/o3Iqt5Jp7wc9rH3eJJ0+nh/CIoiRwjRxde/hAHyLPXYN4V3CqKAbiZPSeJFSWHmJsbkicta0Eg=="],
"object-assign": ["object-assign@4.1.1", "http://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", {}, "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg=="], "object-assign": ["object-assign@4.1.1", "http://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", {}, "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg=="],
@@ -700,7 +700,7 @@
"possible-typed-array-names": ["possible-typed-array-names@1.1.0", "http://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", {}, "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg=="], "possible-typed-array-names": ["possible-typed-array-names@1.1.0", "http://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", {}, "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg=="],
"postcss": ["postcss@8.5.9", "http://registry.npmjs.org/postcss/-/postcss-8.5.9.tgz", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-7a70Nsot+EMX9fFU3064K/kdHWZqGVY+BADLyXc8Dfv+mTLLVl6JzJpPaCZ2kQL9gIJvKXSLMHhqdRRjwQeFtw=="], "postcss": ["postcss@8.5.10", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-pMMHxBOZKFU6HgAZ4eyGnwXF/EvPGGqUr0MnZ5+99485wwW41kW91A4LOGxSHhgugZmSChL5AlElNdwlNgcnLQ=="],
"prelude-ls": ["prelude-ls@1.2.1", "http://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", {}, "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g=="], "prelude-ls": ["prelude-ls@1.2.1", "http://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", {}, "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g=="],
@@ -732,7 +732,7 @@
"reusify": ["reusify@1.1.0", "http://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", {}, "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw=="], "reusify": ["reusify@1.1.0", "http://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", {}, "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw=="],
"rolldown": ["rolldown@1.0.0-rc.15", "http://registry.npmjs.org/rolldown/-/rolldown-1.0.0-rc.15.tgz", { "dependencies": { "@oxc-project/types": "=0.124.0", "@rolldown/pluginutils": "1.0.0-rc.15" }, "optionalDependencies": { "@rolldown/binding-android-arm64": "1.0.0-rc.15", "@rolldown/binding-darwin-arm64": "1.0.0-rc.15", "@rolldown/binding-darwin-x64": "1.0.0-rc.15", "@rolldown/binding-freebsd-x64": "1.0.0-rc.15", "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.15", "@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.15", "@rolldown/binding-linux-arm64-musl": "1.0.0-rc.15", "@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.15", "@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.15", "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.15", "@rolldown/binding-linux-x64-musl": "1.0.0-rc.15", "@rolldown/binding-openharmony-arm64": "1.0.0-rc.15", "@rolldown/binding-wasm32-wasi": "1.0.0-rc.15", "@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.15", "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.15" }, "bin": { "rolldown": "bin/cli.mjs" } }, "sha512-Ff31guA5zT6WjnGp0SXw76X6hzGRk/OQq2hE+1lcDe+lJdHSgnSX6nK3erbONHyCbpSj9a9E+uX/OvytZoWp2g=="], "rolldown": ["rolldown@1.0.0-rc.16", "", { "dependencies": { "@oxc-project/types": "=0.126.0", "@rolldown/pluginutils": "1.0.0-rc.16" }, "optionalDependencies": { "@rolldown/binding-android-arm64": "1.0.0-rc.16", "@rolldown/binding-darwin-arm64": "1.0.0-rc.16", "@rolldown/binding-darwin-x64": "1.0.0-rc.16", "@rolldown/binding-freebsd-x64": "1.0.0-rc.16", "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.16", "@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.16", "@rolldown/binding-linux-arm64-musl": "1.0.0-rc.16", "@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.16", "@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.16", "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.16", "@rolldown/binding-linux-x64-musl": "1.0.0-rc.16", "@rolldown/binding-openharmony-arm64": "1.0.0-rc.16", "@rolldown/binding-wasm32-wasi": "1.0.0-rc.16", "@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.16", "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.16" }, "bin": { "rolldown": "bin/cli.mjs" } }, "sha512-rzi5WqKzEZw3SooTt7cgm4eqIoujPIyGcJNGFL7iPEuajQw7vxMHUkXylu4/vhCkJGXsgRmxqMKXUpT6FEgl0g=="],
"run-parallel": ["run-parallel@1.2.0", "http://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", { "dependencies": { "queue-microtask": "^1.2.2" } }, "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA=="], "run-parallel": ["run-parallel@1.2.0", "http://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", { "dependencies": { "queue-microtask": "^1.2.2" } }, "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA=="],
@@ -810,13 +810,13 @@
"unbox-primitive": ["unbox-primitive@1.1.0", "http://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.1.0.tgz", { "dependencies": { "call-bound": "^1.0.3", "has-bigints": "^1.0.2", "has-symbols": "^1.1.0", "which-boxed-primitive": "^1.1.1" } }, "sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw=="], "unbox-primitive": ["unbox-primitive@1.1.0", "http://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.1.0.tgz", { "dependencies": { "call-bound": "^1.0.3", "has-bigints": "^1.0.2", "has-symbols": "^1.1.0", "which-boxed-primitive": "^1.1.1" } }, "sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw=="],
"update-browserslist-db": ["update-browserslist-db@1.2.3", "", { "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" }, "peerDependencies": { "browserslist": ">= 4.21.0" }, "bin": { "update-browserslist-db": "cli.js" } }, "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w=="], "update-browserslist-db": ["update-browserslist-db@1.2.3", "http://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", { "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" }, "peerDependencies": { "browserslist": ">= 4.21.0" }, "bin": { "update-browserslist-db": "cli.js" } }, "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w=="],
"uri-js": ["uri-js@4.4.1", "http://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", { "dependencies": { "punycode": "^2.1.0" } }, "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg=="], "uri-js": ["uri-js@4.4.1", "http://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", { "dependencies": { "punycode": "^2.1.0" } }, "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg=="],
"use-sync-external-store": ["use-sync-external-store@1.6.0", "http://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", { "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w=="], "use-sync-external-store": ["use-sync-external-store@1.6.0", "http://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", { "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w=="],
"vite": ["vite@8.0.8", "http://registry.npmjs.org/vite/-/vite-8.0.8.tgz", { "dependencies": { "lightningcss": "^1.32.0", "picomatch": "^4.0.4", "postcss": "^8.5.8", "rolldown": "1.0.0-rc.15", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "@vitejs/devtools": "^0.1.0", "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "@vitejs/devtools", "esbuild", "jiti", "less", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-dbU7/iLVa8KZALJyLOBOQ88nOXtNG8vxKuOT4I2mD+Ya70KPceF4IAmDsmU0h1Qsn5bPrvsY9HJstCRh3hG6Uw=="], "vite": ["vite@8.0.9", "", { "dependencies": { "lightningcss": "^1.32.0", "picomatch": "^4.0.4", "postcss": "^8.5.10", "rolldown": "1.0.0-rc.16", "tinyglobby": "^0.2.16" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "@vitejs/devtools": "^0.1.0", "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "@vitejs/devtools", "esbuild", "jiti", "less", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-t7g7GVRpMXjNpa67HaVWI/8BWtdVIQPCL2WoozXXA7LBGEFK4AkkKkHx2hAQf5x1GZSlcmEDPkVLSGahxnEEZw=="],
"vite-plugin-symfony": ["vite-plugin-symfony@8.2.4", "http://registry.npmjs.org/vite-plugin-symfony/-/vite-plugin-symfony-8.2.4.tgz", { "dependencies": { "debug": "^4.4.1", "fast-glob": "^3.3.3", "picocolors": "^1.1.1", "sirv": "^3.0.1" }, "peerDependencies": { "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" } }, "sha512-ph98EMPx8FhA6QIp43ZiK0zjODV9jumB7EHXfZEhRme2lo9oBa9sAXCNCCIvdVk/m9EWkNZpcRBjequjXZiSuA=="], "vite-plugin-symfony": ["vite-plugin-symfony@8.2.4", "http://registry.npmjs.org/vite-plugin-symfony/-/vite-plugin-symfony-8.2.4.tgz", { "dependencies": { "debug": "^4.4.1", "fast-glob": "^3.3.3", "picocolors": "^1.1.1", "sirv": "^3.0.1" }, "peerDependencies": { "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" } }, "sha512-ph98EMPx8FhA6QIp43ZiK0zjODV9jumB7EHXfZEhRme2lo9oBa9sAXCNCCIvdVk/m9EWkNZpcRBjequjXZiSuA=="],
@@ -832,15 +832,15 @@
"word-wrap": ["word-wrap@1.2.5", "http://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", {}, "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA=="], "word-wrap": ["word-wrap@1.2.5", "http://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", {}, "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA=="],
"yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="], "yallist": ["yallist@3.1.1", "http://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="],
"yaml": ["yaml@1.10.3", "http://registry.npmjs.org/yaml/-/yaml-1.10.3.tgz", {}, "sha512-vIYeF1u3CjlhAFekPPAk2h/Kv4T3mAkMox5OymRiJQB0spDP10LHvt+K7G9Ny6NuuMAb25/6n1qyUjAcGNf/AA=="], "yaml": ["yaml@1.10.3", "http://registry.npmjs.org/yaml/-/yaml-1.10.3.tgz", {}, "sha512-vIYeF1u3CjlhAFekPPAk2h/Kv4T3mAkMox5OymRiJQB0spDP10LHvt+K7G9Ny6NuuMAb25/6n1qyUjAcGNf/AA=="],
"yocto-queue": ["yocto-queue@0.1.0", "http://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="], "yocto-queue": ["yocto-queue@0.1.0", "http://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="],
"zod": ["zod@4.3.6", "", {}, "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg=="], "zod": ["zod@4.3.6", "http://registry.npmjs.org/zod/-/zod-4.3.6.tgz", {}, "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg=="],
"zod-validation-error": ["zod-validation-error@4.0.2", "", { "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" } }, "sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ=="], "zod-validation-error": ["zod-validation-error@4.0.2", "http://registry.npmjs.org/zod-validation-error/-/zod-validation-error-4.0.2.tgz", { "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" } }, "sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ=="],
"@emotion/babel-plugin/convert-source-map": ["convert-source-map@1.9.0", "http://registry.npmjs.org/convert-source-map/-/convert-source-map-1.9.0.tgz", {}, "sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A=="], "@emotion/babel-plugin/convert-source-map": ["convert-source-map@1.9.0", "http://registry.npmjs.org/convert-source-map/-/convert-source-map-1.9.0.tgz", {}, "sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A=="],
@@ -854,7 +854,7 @@
"eslint/eslint-visitor-keys": ["eslint-visitor-keys@5.0.1", "http://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", {}, "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA=="], "eslint/eslint-visitor-keys": ["eslint-visitor-keys@5.0.1", "http://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", {}, "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA=="],
"eslint/espree": ["espree@11.2.0", "", { "dependencies": { "acorn": "^8.16.0", "acorn-jsx": "^5.3.2", "eslint-visitor-keys": "^5.0.1" } }, "sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw=="], "eslint/espree": ["espree@11.2.0", "http://registry.npmjs.org/espree/-/espree-11.2.0.tgz", { "dependencies": { "acorn": "^8.16.0", "acorn-jsx": "^5.3.2", "eslint-visitor-keys": "^5.0.1" } }, "sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw=="],
"eslint/minimatch": ["minimatch@10.2.5", "http://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", { "dependencies": { "brace-expansion": "^5.0.5" } }, "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg=="], "eslint/minimatch": ["minimatch@10.2.5", "http://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", { "dependencies": { "brace-expansion": "^5.0.5" } }, "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg=="],
@@ -866,7 +866,7 @@
"prop-types/react-is": ["react-is@16.13.1", "http://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", {}, "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="], "prop-types/react-is": ["react-is@16.13.1", "http://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", {}, "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="],
"rolldown/@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-rc.15", "http://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.15.tgz", {}, "sha512-UromN0peaE53IaBRe9W7CjrZgXl90fqGpK+mIZbA3qSTeYqg3pqpROBdIPvOG3F5ereDHNwoHBI2e50n1BDr1g=="], "rolldown/@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-rc.16", "", {}, "sha512-45+YtqxLYKDWQouLKCrpIZhke+nXxhsw+qAHVzHDVwttyBlHNBVs2K25rDXrZzhpTp9w1FlAlvweV1H++fdZoA=="],
"@eslint/config-array/minimatch/brace-expansion": ["brace-expansion@5.0.5", "http://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ=="], "@eslint/config-array/minimatch/brace-expansion": ["brace-expansion@5.0.5", "http://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ=="],

View File

@@ -33,9 +33,9 @@
"symfony/framework-bundle": "7.4.*", "symfony/framework-bundle": "7.4.*",
"symfony/http-client": "7.4.*", "symfony/http-client": "7.4.*",
"symfony/mailer": "7.4.*", "symfony/mailer": "7.4.*",
"symfony/mercure": "^0.6", "symfony/mercure": "^0.7",
"symfony/mercure-bundle": "*", "symfony/mercure-bundle": "^0.4",
"symfony/monolog-bundle": "^3.8", "symfony/monolog-bundle": "^4.0",
"symfony/security-bundle": "7.4.*", "symfony/security-bundle": "7.4.*",
"symfony/translation": "7.4.*", "symfony/translation": "7.4.*",
"symfony/twig-bundle": "7.4.*", "symfony/twig-bundle": "7.4.*",
@@ -44,11 +44,16 @@
"web-auth/webauthn-framework": "^5.2" "web-auth/webauthn-framework": "^5.2"
}, },
"require-dev": { "require-dev": {
"dama/doctrine-test-bundle": "^8.6",
"phpunit/phpunit": "^13.1",
"roave/security-advisories": "dev-master", "roave/security-advisories": "dev-master",
"symfony/browser-kit": "7.4.*",
"symfony/css-selector": "7.4.*",
"symfony/dotenv": "7.4.*", "symfony/dotenv": "7.4.*",
"symfony/maker-bundle": "^1.5", "symfony/maker-bundle": "^1.5",
"symfony/stopwatch": "7.4.*", "symfony/stopwatch": "7.4.*",
"symfony/web-profiler-bundle": "7.4.*" "symfony/web-profiler-bundle": "7.4.*",
"zenstruck/foundry": "^2.9"
}, },
"config": { "config": {
"preferred-install": { "preferred-install": {

2619
composer.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -16,4 +16,6 @@ return [
Scheb\TwoFactorBundle\SchebTwoFactorBundle::class => ['all' => true], Scheb\TwoFactorBundle\SchebTwoFactorBundle::class => ['all' => true],
League\FlysystemBundle\FlysystemBundle::class => ['all' => true], League\FlysystemBundle\FlysystemBundle::class => ['all' => true],
Liip\ImagineBundle\LiipImagineBundle::class => ['all' => true], Liip\ImagineBundle\LiipImagineBundle::class => ['all' => true],
Zenstruck\Foundry\ZenstruckFoundryBundle::class => ['dev' => true, 'test' => true],
DAMA\DoctrineTestBundle\DAMADoctrineTestBundle::class => ['test' => true],
]; ];

View File

@@ -1,4 +1,4 @@
framework: framework:
test: true test: true
session: session:
storage_id: session.storage.mock_file storage_factory_id: session.storage.factory.mock_file

View File

@@ -0,0 +1,16 @@
when@dev: &dev
# See full configuration: https://symfony.com/bundles/ZenstruckFoundryBundle/current/index.html#full-default-bundle-configuration
zenstruck_foundry:
persistence:
# Flush only once per call of `PersistentObjectFactory::create()`
flush_once: true
# If you use the `make:factory --test` command, you may need to uncomment the following.
# See https://symfony.com/bundles/ZenstruckFoundryBundle/current/index.html#generate
#services:
# App\Tests\Factory\:
# resource: '%kernel.project_dir%/tests/Factory/'
# autowire: true
# autoconfigure: true
when@test: *dev

View File

@@ -28,6 +28,7 @@ services:
App\Service\BattleCardGenerator: App\Service\BattleCardGenerator:
arguments: arguments:
$cacheDir: '%kernel.project_dir%/var/og-cache' $cacheDir: '%kernel.project_dir%/var/og-cache'
$fontPath: '%kernel.project_dir%/assets/fonts/Carlito-Bold.ttf'
$minioMediaStorage: '@mineseeker.media.storage' $minioMediaStorage: '@mineseeker.media.storage'
Aws\S3\S3Client: Aws\S3\S3Client:

443
docs/CI_CD.md Normal file
View File

@@ -0,0 +1,443 @@
# CI/CD Integration Guide
This document explains how automated tests are integrated into the MineSeeker deployment pipeline using Gitea Actions.
## Overview
MineSeeker uses **Gitea Actions** (GitHub Actions compatible) for continuous integration and deployment:
- **CI Pipeline** (`.gitea/workflows/ci.yml`) - Runs on every push/PR to main/develop
- **CD Pipeline** (`.gitea/workflows/deploy.yml`) - Runs on version tags, includes tests before deployment
---
## CI Workflow (Continuous Integration)
**Trigger:** Push or Pull Request to `main` or `develop` branches
**File:** `.gitea/workflows/ci.yml`
### Jobs
#### 1. **Tests Job**
Runs the full PHPUnit test suite with:
- PostgreSQL 18 service container
- PHP 8.3 with required extensions
- Composer dependency installation
- Node.js for asset building
- Database setup and migrations
- 71 PHPUnit tests with testdox output
**Steps:**
1. Checkout code
2. Setup PHP 8.3 with extensions (pdo_pgsql, gd, intl, zip, sodium)
3. Validate `composer.json`
4. Cache and install Composer dependencies
5. Setup Node.js 20 and install dependencies
6. Build frontend assets with Vite
7. Create `.env.test` configuration
8. Setup test database with migrations
9. Run PHPUnit tests with `--testdox` output
10. (Optional) Generate coverage report on PRs
#### 2. **Lint Job**
Code quality checks:
- ESLint for JavaScript/JSX
- PHP-CS-Fixer (if installed)
---
## CD Workflow (Continuous Deployment)
**Trigger:** Push of version tags (e.g., `v1.0.0`, `v1.2.3`)
**File:** `.gitea/workflows/deploy.yml`
### Jobs
#### 1. **Test Job** (Pre-deployment)
**Critical:** Deployment only proceeds if tests pass.
- Runs same test suite as CI workflow
- Uses `--stop-on-failure` flag for fast feedback
- Blocks deployment on any test failure
#### 2. **Deploy Job** (Production Deployment)
**Depends on:** `test` job must complete successfully
**Steps:**
1. Checkout tagged version
2. Write production `.env` from secrets
3. Build Docker image
4. Run database migrations
5. Clear production cache
6. Start/restart services with `docker compose up -d`
7. Health check (curl to verify app is running)
8. Notify success/failure
---
## Configuration Requirements
### Gitea Repository Variables
Set these in Gitea repository settings:
```
PROD_APP_DIR=/path/to/production/app
```
### Gitea Repository Secrets
```
PROD_ENV_FILE=<contents of production .env file>
```
### Test Database Configuration
The CI/CD pipeline uses a PostgreSQL service container with these credentials:
```env
POSTGRES_USER=mineseeker_test
POSTGRES_PASSWORD=test_password
POSTGRES_DB=mineseeker_test
```
---
## Running Tests Locally
Before pushing, run tests locally to catch issues early:
```bash
# Setup test database (first time only)
make test-db-setup
# Run tests
make test
# Run with testdox output
vendor/bin/phpunit --testdox
# Run with coverage
XDEBUG_MODE=coverage vendor/bin/phpunit --coverage-html=var/coverage
```
---
## Deployment Workflow
### Standard Deployment Process
1. **Develop features** on feature branches
2. **Open Pull Request** to `develop` or `main`
- CI workflow runs automatically
- Tests must pass before merge
3. **Merge PR** after review and passing tests
4. **Create version tag** when ready to deploy:
```bash
git tag -a v1.2.3 -m "Release version 1.2.3"
git push origin v1.2.3
```
5. **Deployment runs automatically:**
- Tests run first
- If tests pass, Docker image builds
- Migrations run
- Services restart
- Health check verifies deployment
### Rollback Process
If deployment fails or issues are discovered:
```bash
# Tag and deploy previous stable version
git push origin v1.2.2
# Or SSH to production and manually rollback
cd /path/to/production/app
git checkout v1.2.2
docker compose build
docker compose up -d
```
---
## Test Suite Details
### Test Coverage
The CI/CD pipeline runs **71 tests** across:
- **Controller Tests** (29 tests)
- GameController, ProfileController, SecurityController
- HTTP status codes, authentication, routing
- **DTO Tests** (9 tests)
- ProfileGameDto, ProfileStatsDto, ProfileChartDataDto
- Serialization, null handling, calculations
- **Entity Tests** (8 tests)
- UserStats calculations and defaults
- **Service Tests** (13 tests)
- MercureJwtService, RecaptchaService
- Token generation, API verification
- **Integration Tests** (12 tests)
- Factory usage examples
- Database isolation
### Test Execution Time
Typical test suite runtime: **6-8 seconds**
---
## Best Practices
### Before Committing
✅ **Always run tests locally:**
```bash
make test
```
✅ **Check code style:**
```bash
vendor/bin/php-cs-fixer fix --dry-run
npm run lint
```
✅ **Verify assets build:**
```bash
npm run build
```
### When Creating PRs
✅ **Wait for CI checks** to pass before requesting review
✅ **Fix failing tests** immediately - don't merge broken code
✅ **Review test output** in Gitea Actions logs
### When Deploying
✅ **Tag semantic versions:** `v1.2.3` (major.minor.patch)
✅ **Write meaningful tag messages:**
```bash
git tag -a v1.2.3 -m "Add bonus points to battle cards, fix avatar upload bug"
```
✅ **Monitor deployment logs** in Gitea Actions
✅ **Verify health check** passes after deployment
✅ **Test critical features** in production after deployment
---
## Troubleshooting
### Tests Pass Locally but Fail in CI
**Possible causes:**
1. **Database state:** CI uses fresh database, local may have leftover data
```bash
make test-db-reset # Reset local test database
```
2. **Environment differences:** Check `.env.test` matches CI configuration
3. **Cached dependencies:** CI caches may be stale
- Clear cache in Gitea Actions settings
- Or add `--no-cache` to composer install
### Deployment Fails After Tests Pass
**Common issues:**
1. **Migration conflicts:** Manually run migrations on production
```bash
docker compose run --rm app php bin/console doctrine:migrations:migrate
```
2. **Missing environment variables:** Check `PROD_ENV_FILE` secret is up-to-date
3. **Docker build errors:** Check Dockerfile and build context
4. **Health check timeout:** Increase sleep time or check application startup
### Database Migration Issues
If migrations fail during deployment:
```bash
# SSH to production server
cd /path/to/production/app
# Check migration status
docker compose run --rm app php bin/console doctrine:migrations:status
# Manually run migrations
docker compose run --rm app php bin/console doctrine:migrations:migrate --no-interaction
# If migration is stuck, mark as executed
docker compose run --rm app php bin/console doctrine:migrations:version YYYYMMDDHHMMSS --add
```
---
## Monitoring and Notifications
### Viewing CI/CD Logs
1. Go to Gitea repository
2. Click **Actions** tab
3. Select workflow run
4. View detailed logs for each step
### Setting Up Notifications
**Gitea webhook notifications:**
Configure webhooks in repository settings to notify:
- Slack/Discord when builds fail
- Email on deployment success/failure
- Custom endpoints for monitoring systems
**Example webhook payload:**
```json
{
"event": "workflow_run",
"repository": "Mine",
"workflow": "deploy.yml",
"status": "success",
"tag": "v1.2.3"
}
```
---
## Advanced Configuration
### Running Tests with Coverage
Enable coverage in CI (requires Xdebug):
```yaml
- name: Run tests with coverage
run: |
XDEBUG_MODE=coverage vendor/bin/phpunit \
--coverage-clover=coverage.xml \
--coverage-html=var/coverage
```
### Parallel Test Execution
For larger test suites, use ParaTest:
```bash
composer require --dev brianium/paratest
```
```yaml
- name: Run tests in parallel
run: vendor/bin/paratest --processes=4
```
### Database Seeding for E2E Tests
Add step before tests:
```yaml
- name: Seed test data
run: |
php bin/console doctrine:fixtures:load --no-interaction --env=test
```
---
## Security Considerations
### Secrets Management
❗ **Never commit secrets to repository**
✅ **Use Gitea secrets** for sensitive data:
- `PROD_ENV_FILE` - Production environment variables
- Database credentials
- API keys
✅ **Rotate secrets regularly**
✅ **Use environment-specific secrets** (staging, production)
### Database Security
✅ **Test database is isolated** - No production data access
✅ **Credentials are ephemeral** - Service containers use temporary passwords
✅ **No data persistence** - Test database recreated on each run
---
## Performance Optimization
### Caching Strategies
CI/CD workflows cache:
1. **Composer dependencies** - `vendor/` directory
2. **Node modules** - `node_modules/` directory
3. **Docker layers** - Image build cache
### Reducing Build Time
✅ **Use `composer install --no-dev`** in production builds
✅ **Multi-stage Docker builds** - Separate assets from PHP
✅ **Parallel jobs** - Tests and linting run concurrently
✅ **Skip unnecessary steps** - Use conditionals (`if:` statements)
---
## Future Enhancements
### Planned Improvements
- [ ] Automated browser tests with Symfony Panther
- [ ] Visual regression testing for UI changes
- [ ] Performance benchmarking in CI
- [ ] Automated security scanning (Symfony Security Checker)
- [ ] Staging environment deployments before production
- [ ] Blue-green deployment strategy
- [ ] Automated rollback on health check failure
---
## References
- **Gitea Actions Documentation:** https://docs.gitea.com/usage/actions/overview
- **GitHub Actions Reference:** https://docs.github.com/en/actions (compatible syntax)
- **PHPUnit Documentation:** https://phpunit.de/documentation.html
- **Symfony Testing:** https://symfony.com/doc/current/testing.html
- **Docker Compose:** https://docs.docker.com/compose/
---
**Last Updated:** 2026-04-21
**MineSeeker Version:** 1.0.0
**CI/CD Platform:** Gitea Actions

47
docs/FONTS.md Normal file
View File

@@ -0,0 +1,47 @@
# Font Files
This directory contains TrueType Font (TTF) files used for server-side image generation with PHP GD.
## Carlito-Bold.ttf
- **Font:** Carlito Bold
- **Source:** Google Fonts (Carlito Project)
- **License:** SIL Open Font License 1.1
- **URL:** https://github.com/googlefonts/carlito
- **Usage:** Used by `BattleCardGenerator` service for generating battle card OG images
- **Note:** Carlito is a metric-compatible font family to Calibri
## Why TTF instead of @fontsource?
The `@fontsource` npm packages provide WOFF/WOFF2 files for web usage, but PHP's GD library (`imagettftext()`) requires TrueType Font (TTF) files for server-side text rendering.
## Alternatives
If you want to use a different font:
1. **Install system fonts:**
```bash
# Find available TTF fonts
find /usr/share/fonts -name "*.ttf" -type f
# Copy desired font
cp /usr/share/fonts/path/to/Font-Bold.ttf assets/fonts/
```
2. **Download from Google Fonts:**
```bash
# Visit https://fonts.google.com
# Download the font family
# Extract the TTF file from the zip
```
3. **Update service configuration:**
Edit `config/services.yaml` and update the `$fontPath` parameter.
## Cache Clearing
After changing fonts, clear the OG image cache:
```bash
rm -rf var/og-cache/*
```

View File

@@ -1,6 +1,6 @@
# Mine-Seeker Game Documentation # Mine-Seeker Game Documentation
This directory contains comprehensive documentation about the Mine-Seeker game mechanics and implementation. This directory contains comprehensive documentation about the Mine-Seeker game mechanics, testing, and deployment.
## Game Mechanics ## Game Mechanics
@@ -18,8 +18,94 @@ Complete reference for the bonus points system including:
--- ---
## Testing
### [Testing Guide](./testing/TESTING.md)
Complete testing setup and best practices including:
- Test environment configuration
- Database isolation with DAMA Doctrine Test Bundle
- Test database setup (`mineseeker_test`)
- Modern PHPUnit attributes (`#[Test]`, `#[TestDox]`)
- Writing effective tests
- Running tests (all, specific, with coverage)
- Best practices (AAA pattern, factories, type hints)
- CI/CD integration examples
- Troubleshooting guide
**Recommended for**: All developers, understanding test infrastructure, writing new tests, debugging test failures.
### [Factory Documentation](./testing/FACTORIES.md)
Detailed API reference for Zenstruck Foundry factories:
- All available factories (User, Gamer, PlayedGame, Step, Grid, etc.)
- Factory usage examples
- Creating related entities
- Repository access
- Database isolation explanation
- Best practices and patterns
**Recommended for**: Writing tests with test data, understanding factory patterns, creating complex test scenarios.
### [Quick Reference](./testing/QUICK_REFERENCE.md)
Quick command reference for testing:
- Common test commands
- Factory usage cheat sheet
- Troubleshooting tips
**Recommended for**: Quick lookups during development.
---
## CI/CD & Deployment
### [CI/CD Integration Guide](./CI_CD.md)
Comprehensive guide for continuous integration and deployment:
- Gitea Actions workflows (CI and CD pipelines)
- Automated testing on every push/PR
- Deployment process with pre-deployment testing
- Configuration requirements (secrets, variables)
- Rollback procedures
- Security considerations
- Performance optimization
- Monitoring and notifications
- Troubleshooting deployment issues
**Recommended for**: Understanding deployment pipeline, setting up CI/CD, debugging deployment failures, production operations.
### [CI/CD Quick Reference](./testing/CI_CD_QUICK_REFERENCE.md)
Quick reference for CI/CD operations:
- Common commands (testing, deployment, rollback)
- Workflow diagrams (CI and CD pipelines)
- Status badges for README
- Common issues and solutions
- Environment variables
- Monitoring links
**Recommended for**: Quick deployments, troubleshooting, day-to-day operations.
---
## Technical Documentation
### [Fonts](./FONTS.md)
TrueType fonts used for server-side image generation:
- Font file locations
- Usage in battle card generation
- Web vs. server-side font formats
**Recommended for**: Working on OG image generation, understanding font rendering.
---
## Quick Reference ## Quick Reference
### Test Suite Overview
| Metric | Value |
|--------|-------|
| Total Tests | 71 |
| Total Assertions | 227 |
| Execution Time | ~6-8 seconds |
| Coverage | Controller, DTO, Entity, Service, Integration |
### Bonus Points at a Glance ### Bonus Points at a Glance
| Bonus Type | Points | Condition | | Bonus Type | Points | Condition |
|-----------|--------|-----------| |-----------|--------|-----------|
@@ -30,18 +116,80 @@ Complete reference for the bonus points system including:
| Chain Combo | Tracked | Consecutive mine clicks (no safe clicks) | | Chain Combo | Tracked | Consecutive mine clicks (no safe clicks) |
| Biggest Reveal | Tracked | Largest number of safe cells revealed | | Biggest Reveal | Tracked | Largest number of safe cells revealed |
### Key Rules ### Available Test Factories
- Safe cell bonus only awarded for ≥2 cells minimum | Factory | Entity | Purpose |
- Chain counter resets on any safe cell click |---------|--------|---------|
- Endgame threshold: 51 - (redPoints + bluePoints) ≤ 10 | `UserFactory` | `User` | Registered users |
- Bonus stats are per-player and persist in database | `GamerFactory` | `Gamer` | Anonymous/guest players |
| `PlayedGameFactory` | `PlayedGame` | Game records |
| `StepFactory` | `Step` | Game moves |
| `GridFactory` | `Grid` | Game grids (16×16) |
| `GridRowFactory` | `GridRow` | Grid rows |
| `WebAuthnCredentialFactory` | `WebAuthnCredential` | Passkey credentials |
| `ContactMessageFactory` | `ContactMessage` | Contact messages |
### Running Tests
```bash
# Setup (first time only)
make test-db-setup
# Run all tests
make test
# Run with documentation output
vendor/bin/phpunit --testdox
# Specific file
vendor/bin/phpunit tests/Controller/ProfileControllerTest.php
# With coverage (requires Xdebug/PCOV)
XDEBUG_MODE=coverage vendor/bin/phpunit --coverage-html var/coverage
```
### CI/CD Commands
```bash
# Deploy to production
git tag -a v1.2.3 -m "Release version 1.2.3"
git push origin v1.2.3
# View CI/CD runs
# https://source.splendidbear.org/SplendidBear-Websites/Mine/actions
# Rollback deployment
git push origin v1.2.2 # Deploy previous version
```
--- ---
## Files Using This Information ## Files Using This Information
### Game Mechanics
- Backend: `/src/Util/TopicManager.php` - Backend: `/src/Util/TopicManager.php`
- Frontend: `/assets/js/mine-seeker/contexts/GameProvider.jsx` - Frontend: `/assets/js/mine-seeker/contexts/GameProvider.jsx`
- UI: `/assets/js/mine-seeker/components/BonusStatsDialog.jsx` - UI: `/assets/js/mine-seeker/components/BonusStatsDialog.jsx`
- Constants: `/assets/js/mine-seeker/utils/constants.jsx` - Constants: `/assets/js/mine-seeker/utils/constants.jsx`
### Testing
- Test Base Class: `/tests/WebTestCase.php`
- Factories: `/tests/Factory/`
- Example Tests: `/tests/Integration/FactoryExampleTest.php`
- PHPUnit Config: `/phpunit.dist.xml`
- Bundle Config: `/config/bundles.php`
### CI/CD
- CI Workflow: `/.gitea/workflows/ci.yml`
- CD Workflow: `/.gitea/workflows/deploy.yml`
- Dockerfile: `/Dockerfile`
- Docker Compose: `/compose.yaml`
---
## Additional Resources
- **[Main README](../README.md)** - Project overview, installation, deployment
- **[AI Agent Guidelines](../AGENTS.md)** - Comprehensive guide for AI coding agents
- **[Zenstruck Foundry](https://symfony.com/bundles/ZenstruckFoundryBundle/current/index.html)** - Official Foundry documentation
- **[PHPUnit](https://phpunit.de/)** - PHPUnit documentation
- **[Symfony Testing](https://symfony.com/doc/current/testing.html)** - Symfony testing guide
- **[Gitea Actions](https://docs.gitea.com/usage/actions/overview)** - Gitea Actions documentation
- **[Docker Compose](https://docs.docker.com/compose/)** - Docker Compose reference

265
docs/testing/FACTORIES.md Normal file
View File

@@ -0,0 +1,265 @@
# Foundry Factories for MineSeeker
Factory classes for creating test data. For general Foundry usage, see [Zenstruck Foundry Docs](https://symfony.com/bundles/ZenstruckFoundryBundle/current/index.html).
---
## Available Factories
All factories are in `tests/Factory/`:
| Factory | Entity | Use For |
|---------|--------|---------|
| `UserFactory` | `User` | Registered users with auth |
| `GamerFactory` | `Gamer` | Anonymous/guest players |
| `PlayedGameFactory` | `PlayedGame` | Game records |
| `StepFactory` | `Step` | Individual game moves |
| `GridFactory` | `Grid` | 16×16 game grids |
| `GridRowFactory` | `GridRow` | Grid row data |
| `WebAuthnCredentialFactory` | `WebAuthnCredential` | Passkey credentials |
| `ContactMessageFactory` | `ContactMessage` | Contact form messages |
---
## Quick Examples
### UserFactory
```php
// Basic user
$user = UserFactory::createOne();
// Unverified user
$user = UserFactory::createOne(['isVerified' => false]);
// Admin user
$user = UserFactory::createOne(['roles' => ['ROLE_ADMIN']]);
// Multiple users
UserFactory::createMany(5);
```
### GamerFactory
```php
// Anonymous gamer
$gamer = GamerFactory::new()->anonymous()->create();
```
### PlayedGameFactory
```php
// Game with registered players
$game = PlayedGameFactory::new()
->withRegisteredPlayers()
->create();
// Game with anonymous players
$game = PlayedGameFactory::new()
->withAnonymousPlayers()
->create();
// Red wins
$game = PlayedGameFactory::new()
->withRegisteredPlayers()
->redWins()
->create();
// Blue wins
$game = PlayedGameFactory::new()
->withRegisteredPlayers()
->blueWins()
->create();
// Resigned game
$game = PlayedGameFactory::new()
->resigned('red')
->create();
```
### StepFactory
```php
// Mine hit
$step = StepFactory::new()
->forPlayer('red')
->mine()
->create(['playedGame' => $game]);
// Safe cell
$step = StepFactory::new()
->forPlayer('blue')
->safe()
->create(['playedGame' => $game]);
// With revealed cells
$step = StepFactory::new()
->withRevealedCells([
['row' => 5, 'col' => 5],
['row' => 5, 'col' => 6],
])
->create(['playedGame' => $game]);
```
### GridFactory
```php
// Full 16×16 grid
$grid = GridFactory::createOne(['playedGame' => $game]);
// Automatically creates 16 rows
self::assertCount(16, $grid->gridRow);
```
### WebAuthnCredentialFactory
```php
// Basic credential
$credential = WebAuthnCredentialFactory::createOne(['user' => $user]);
// Named credential, recently used
$credential = WebAuthnCredentialFactory::new()
->withName('YubiKey 5C')
->recentlyUsed()
->create(['user' => $user]);
```
### ContactMessageFactory
```php
// Basic message
$message = ContactMessageFactory::createOne();
// Without consent
$message = ContactMessageFactory::new()
->withoutConsent()
->create();
```
---
## Factory API Reference
### Common Methods (All Factories)
```php
// Create single entity
Factory::createOne([
'property' => 'value',
]);
// Create multiple
Factory::createMany(5);
// Create with factory methods
Factory::new()
->customMethod()
->create(['property' => 'value']);
// Access repository
Factory::repository()->findAll();
Factory::repository()->count([]);
Factory::repository()->findBy(['field' => 'value']);
```
### PlayedGameFactory Methods
| Method | Description |
|--------|-------------|
| `withRegisteredPlayers()` | Creates game with 2 registered users |
| `withAnonymousPlayers()` | Creates game with 2 anonymous gamers |
| `withMixedPlayers()` | One registered, one anonymous |
| `redWins()` | Red has 26 points |
| `blueWins()` | Blue has 26 points |
| `resigned(string $player)` | Set resignation ('red' or 'blue') |
### StepFactory Methods
| Method | Description |
|--------|-------------|
| `forPlayer(string $player)` | Set player ('red' or 'blue') |
| `mine()` | Step hits a mine |
| `safe()` | Step reveals safe cell |
| `withRevealedCells(array $cells)` | Set revealed cells array |
### GamerFactory Methods
| Method | Description |
|--------|-------------|
| `anonymous()` | Set username as "Guest_12345" |
### WebAuthnCredentialFactory Methods
| Method | Description |
|--------|-------------|
| `withName(string $name)` | Set credential name |
| `recentlyUsed()` | Set lastUsedAt to now |
### ContactMessageFactory Methods
| Method | Description |
|--------|-------------|
| `withoutConsent()` | Set consent to false |
| `anonymous()` | Remove IP address |
---
## MineSeeker-Specific Patterns
### Complete Game Setup
```php
// Create full game with steps and grid
$game = PlayedGameFactory::new()
->withRegisteredPlayers()
->create();
// Add steps
StepFactory::createMany(10, [
'playedGame' => $game,
'player' => 'red',
]);
// Add grid
$grid = GridFactory::createOne(['playedGame' => $game]);
```
### Testing Battle History
```php
// Create multiple finished games for a user
$user = UserFactory::createOne();
PlayedGameFactory::new()
->redWins()
->create(['red' => $user]);
PlayedGameFactory::new()
->blueWins()
->create(['blue' => $user]);
```
---
## Database Isolation
Tests automatically run in isolated transactions:
```php
public function testDatabaseIsolation(): void
{
UserFactory::createMany(5);
self::assertCount(5, UserFactory::repository()->findAll());
// Automatically rolled back after test
}
```
No manual cleanup needed. Each test starts with a clean database.
---
## External Resources
- **[Zenstruck Foundry Documentation](https://symfony.com/bundles/ZenstruckFoundryBundle/current/index.html)** - Complete Foundry guide
- **[DAMA Doctrine Test Bundle](https://github.com/dmaicher/doctrine-test-bundle)** - Transaction isolation details

336
docs/testing/TESTING.md Normal file
View File

@@ -0,0 +1,336 @@
# Testing Guide for MineSeeker
MineSeeker-specific testing setup and workflows. For general PHPUnit/Symfony testing, see [Symfony Testing Docs](https://symfony.com/doc/current/testing.html).
## Example of the current tests
```shell
$ bin/phpunit (master->origin/master|✚1…2⚑1)
PHPUnit 13.1.7 by Sebastian Bergmann and contributors.
Runtime: PHP 8.5.5
Configuration: /var/www/splendid/Mine/phpunit.dist.xml
................................................................. 65 / 71 ( 91%)
...... 71 / 71 (100%)
Time: 00:07.319, Memory: 86.50 MB
OK (71 tests, 227 assertions)
Faker seed used: 918823
```
## Quick Start
```bash
# One-time setup
make test-db-setup
# Run tests
make test
```
---
## Test Database Configuration
MineSeeker uses a **separate test database** (`mineseeker_test`) with automatic transaction rollback for isolated tests.
### Stack
- **PHPUnit 13** - Testing framework
- **[Zenstruck Foundry](https://symfony.com/bundles/ZenstruckFoundryBundle/current/index.html)** - Factory library for test fixtures
- **[DAMA Doctrine Test Bundle](https://github.com/dmaicher/doctrine-test-bundle)** - Transaction isolation (rollback after each test)
### Configuration
**`phpunit.dist.xml`**:
```xml
<env name="DATABASE_URL" value="postgresql://...mineseeker_test..." />
<env name="DAMA_DISABLE_STATIC_CONNECTION" value="0" />
```
**`config/bundles.php`**:
```php
Zenstruck\Foundry\ZenstruckFoundryBundle::class => ['dev' => true, 'test' => true],
DAMA\DoctrineTestBundle\DAMADoctrineTestBundle::class => ['test' => true],
```
---
## Setup
### One-time Setup
```bash
make test-db-setup
```
Creates `mineseeker_test` database and runs migrations.
### Reset Test Database
```bash
make test-db-reset
```
Drops and recreates test database (useful after schema changes).
---
## Running Tests
```bash
# All tests
make test
# Specific file
vendor/bin/phpunit tests/Controller/ProfileControllerTest.php
# Specific method
vendor/bin/phpunit --filter testCreateUser
# With test names
vendor/bin/phpunit --testdox
```
---
## MineSeeker Factories
See **[FACTORIES.md](./FACTORIES.md)** for complete factory API.
### Available Factories
| Factory | Entity | Location |
|---------|--------|----------|
| `UserFactory` | Registered users | `tests/Factory/UserFactory.php` |
| `GamerFactory` | Anonymous players | `tests/Factory/GamerFactory.php` |
| `PlayedGameFactory` | Game records | `tests/Factory/PlayedGameFactory.php` |
| `StepFactory` | Game moves | `tests/Factory/StepFactory.php` |
| `GridFactory` | 16×16 game grids | `tests/Factory/GridFactory.php` |
| `GridRowFactory` | Grid rows | `tests/Factory/GridRowFactory.php` |
| `WebAuthnCredentialFactory` | Passkey credentials | `tests/Factory/WebAuthnCredentialFactory.php` |
| `ContactMessageFactory` | Contact messages | `tests/Factory/ContactMessageFactory.php` |
### Example
```php
use App\Tests\Factory\PlayedGameFactory;
use App\Tests\WebTestCase;
class GameTest extends WebTestCase
{
public function testGameCreation(): void
{
$game = PlayedGameFactory::new()
->withRegisteredPlayers()
->redWins()
->create();
self::assertEquals(26, $game->redPoints);
}
}
```
---
## Test Structure
All tests extend `App\Tests\WebTestCase`:
```php
<?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\Tests\Controller;
use App\Tests\Factory\UserFactory;
use App\Tests\WebTestCase;
class MyControllerTest extends WebTestCase
{
public function testExample(): void
{
$user = UserFactory::createOne();
$client = static::createClient();
$client->request('GET', '/profile');
self::assertResponseRedirects('/login');
}
}
```
**Important**: Always extend `App\Tests\WebTestCase`, not `Symfony\Bundle\FrameworkBundle\Test\WebTestCase`.
---
## Directory Structure
```
tests/
├── Controller/ # HTTP endpoint tests
├── Dto/ # Data Transfer Object tests
├── Entity/ # Entity logic tests
├── Service/ # Service layer tests
├── Integration/ # Integration tests
├── Factory/ # Foundry factories
├── WebTestCase.php # Base test class
└── bootstrap.php # PHPUnit bootstrap
```
---
## MineSeeker-Specific Patterns
### Testing Game Flow
```php
public function testRedPlayerWinsGame(): void
{
$game = PlayedGameFactory::new()
->withRegisteredPlayers()
->create();
// Create steps to simulate gameplay
for ($i = 0; $i < 26; $i++) {
StepFactory::new()
->forPlayer('red')
->mine()
->create(['playedGame' => $game]);
}
self::assertEquals(26, $game->redPoints);
}
```
### Testing Battle Sharing
```php
public function testBattleSharePageReturnsValidGame(): void
{
$game = PlayedGameFactory::new()
->withRegisteredPlayers()
->redWins()
->create();
$client = static::createClient();
$client->request('GET', '/battle/' . $game->uuid);
self::assertResponseIsSuccessful();
self::assertSelectorTextContains('h1', 'Battle Report');
}
```
### Testing Authentication
```php
public function testProfileRequiresAuthentication(): void
{
$client = static::createClient();
$client->request('GET', '/profile');
self::assertResponseRedirects('/login');
}
public function testAuthenticatedUserCanAccessProfile(): void
{
$user = UserFactory::createOne();
$client = static::createClient();
$client->loginUser($user->_real());
$client->request('GET', '/profile');
self::assertResponseIsSuccessful();
}
```
---
## Troubleshooting
### Tests interfering with each other?
Ensure you extend `App\Tests\WebTestCase`, not the base Symfony class.
### Schema out of sync?
```bash
make test-db-reset
```
### Memory limit errors?
```bash
php -d memory_limit=1024M vendor/bin/phpunit
```
Or increase in `phpunit.dist.xml`:
```xml
<ini name="memory_limit" value="1024M"/>
```
### Test database doesn't exist?
```bash
make test-db-setup
```
---
## External Resources
- **[Symfony Testing](https://symfony.com/doc/current/testing.html)** - Symfony testing guide
- **[Zenstruck Foundry](https://symfony.com/bundles/ZenstruckFoundryBundle/current/index.html)** - Factory documentation
- **[DAMA Doctrine Test Bundle](https://github.com/dmaicher/doctrine-test-bundle)** - Transaction isolation
- **[PHPUnit](https://docs.phpunit.de/)** - PHPUnit documentation
---
## Modern PHPUnit Attributes
MineSeeker tests use modern PHP 8 attributes instead of method name prefixes:
```php
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\Attributes\TestDox;
#[TestDox('Security Controller')]
class SecurityControllerTest extends WebTestCase
{
#[Test]
#[TestDox('Login page loads successfully with form fields')]
public function loginPageLoadsSuccessfully(): void
{
// Test implementation
}
}
```
**Benefits:**
- ✅ More readable method names (no `test` prefix required)
- ✅ Self-documenting with `TestDox` descriptions
- ✅ Better IDE support and refactoring
- ✅ Cleaner `--testdox` output
**Run with documentation:**
```bash
vendor/bin/phpunit --testdox
```
Output:
```
Security Controller
✔ Login page loads successfully with form fields
✔ Login page has links to register and forgot password
✔ Register page loads successfully with form
```

8
gtk-client/.env.example Normal file
View File

@@ -0,0 +1,8 @@
# MineSeeker GTK4 Desktop Client — configuration
# Copy this file to .env and fill in your values.
# Base URL of the MineSeeker server (no trailing slash)
MINESEEKER_BASE_URL=https://mineseeker.example.com
# Public Mercure hub URL (SSE endpoint)
MINESEEKER_MERCURE_URL=https://mineseeker.example.com/.well-known/mercure

11
gtk-client/.gitignore vendored Normal file
View File

@@ -0,0 +1,11 @@
.env
__pycache__/
*.pyc
*.pyo
*.pyd
.Python
*.egg-info/
dist/
build/
.venv/
venv/

47
gtk-client/main.py Normal file
View File

@@ -0,0 +1,47 @@
"""
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 sys
import logging
import gi
gi.require_version("Gtk", "4.0")
gi.require_version("Adw", "1")
gi.require_version("Gst", "1.0")
from gi.repository import Gtk, Adw, Gst, GLib
# Validate config early (raises EnvironmentError if .env is missing)
from mineseeker import config # noqa: F401
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
)
class MineSeekerApp(Adw.Application):
def __init__(self) -> None:
super().__init__(application_id="org.splendidbear.mineseeker")
self.connect("activate", self._on_activate)
def _on_activate(self, app: Adw.Application) -> None:
# Import here so GTK/Adw is already initialised before building widgets
from mineseeker.ui.app_window import AppWindow
window = AppWindow(application=app)
window.present()
def main() -> int:
Gst.init(None)
app = MineSeekerApp()
return app.run(sys.argv)
if __name__ == "__main__":
sys.exit(main())

View File

View File

View File

@@ -0,0 +1,93 @@
"""
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.
"""
from __future__ import annotations
from mineseeker.api import client
class AuthError(Exception):
"""Raised on authentication failure."""
class TotpRequired(Exception):
"""Raised when the server requires a TOTP code after password login."""
def login(username: str, password: str) -> None:
"""
Authenticate with username + password via the dedicated JSON endpoint
POST /api/auth/login, which bypasses the reCAPTCHA gate.
Raises:
TotpRequired server confirmed credentials but TOTP is required next.
AuthError credentials wrong, account inactive, or server error.
"""
session = client.get_session()
resp = session.post(
client.url("/api/auth/login"),
json={"username": username, "password": password},
# The endpoint sets a session cookie; follow any redirects
allow_redirects=True,
)
# Non-2xx means a hard server error (500 etc.) — let it propagate
if resp.status_code >= 500:
resp.raise_for_status()
data = resp.json()
if not data.get("success"):
raise AuthError(data.get("error", "Login failed."))
if data.get("requiresTwoFactor"):
raise TotpRequired()
def submit_totp(code: str) -> None:
"""
Submit the 6-digit TOTP code after login() raises TotpRequired.
The scheb/2fa bundle processes POST /2fa_check directly as a firewall
listener — no CSRF token required, no JSON body. The code goes as a
form-encoded field named _auth_code, same as the browser form.
The LoginCaptchaListener already skips /2fa_check paths.
Raises:
AuthError if the code is wrong or the session is no longer in
IS_AUTHENTICATED_2FA_IN_PROGRESS state.
"""
session = client.get_session()
resp = session.post(
client.url("/2fa_check"),
data={"_auth_code": code},
headers={"Content-Type": "application/x-www-form-urlencoded"},
allow_redirects=True,
)
resp.raise_for_status()
# If we land back on /2fa the code was wrong
if "/2fa" in resp.url:
raise AuthError("Invalid authentication code.")
def login_as_guest() -> None:
"""
Start an anonymous session.
A GET to the homepage is enough for Symfony to create a session and
assign the anon_<session_id> identity used by ResolveUserNamesService.
"""
session = client.get_session()
resp = session.get(client.url("/"), headers={"Accept": "text/html"})
resp.raise_for_status()
def logout() -> None:
"""Discard the local session (client-side only)."""
client.reset_session()

View File

@@ -0,0 +1,52 @@
"""
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.
"""
from __future__ import annotations
import requests
from mineseeker import config
# Module-level singleton session shared by all API modules.
# Holds cookies (Symfony session cookie after login) across all requests.
_session: requests.Session | None = None
def get_session() -> requests.Session:
global _session
if _session is None:
_session = requests.Session()
_session.headers.update({
"Accept": "application/json",
"Content-Type": "application/json",
})
return _session
def reset_session() -> None:
"""Discard the current session (logout / new guest session)."""
global _session
_session = None
def url(path: str) -> str:
"""Build an absolute URL from a server-relative path."""
return f"{config.BASE_URL}/{path.lstrip('/')}"
def get(path: str, **kwargs) -> requests.Response:
resp = get_session().get(url(path), **kwargs)
resp.raise_for_status()
return resp
def post(path: str, json: dict | None = None, **kwargs) -> requests.Response:
resp = get_session().post(url(path), json=json, **kwargs)
resp.raise_for_status()
return resp

View File

@@ -0,0 +1,117 @@
"""
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.
"""
from __future__ import annotations
import base64
import json
from mineseeker.api import client
def fetch_token() -> dict:
"""
GET /api/game/token
Returns { "mercureJwt": str, "gameAssoc": str }
"""
resp = client.get("/api/game/token")
return resp.json()
def connect(game_assoc: str) -> dict:
"""
GET /api/game/connect/{gameAssoc}
Returns the decoded connect-information dict.
"""
resp = client.get(f"/api/game/connect/{game_assoc}")
raw = resp.text.strip()
decoded = base64.b64decode(raw).decode("utf-8")
return json.loads(decoded)
def start(game_assoc: str) -> bool:
"""POST /api/game/start — initialise the grid for a new game."""
resp = client.post("/api/game/start", json={"gameAssoc": game_assoc})
return bool(resp.json().get("success", False))
def join(game_assoc: str) -> bool:
"""POST /api/game/join/{gameAssoc} — announce this player's presence."""
resp = client.post(f"/api/game/join/{game_assoc}", json={})
return bool(resp.json().get("success", False))
def step(
game_assoc: str,
coords: list[int],
player: str,
bomb: bool,
resign: str | None,
step_elapsed: float,
) -> dict:
"""
POST /api/game/step/{gameAssoc}
Returns the full step result dict published by TopicManager::publish().
"""
resp = client.post(
f"/api/game/step/{game_assoc}",
json={
"coords": coords,
"player": player,
"bomb": bomb,
"resign": resign,
"stepElapsed": step_elapsed,
},
)
return resp.json()
def leave(game_assoc: str) -> None:
"""POST /api/game/leave/{gameAssoc} — fire-and-forget on window close."""
try:
client.post(f"/api/game/leave/{game_assoc}", json={})
except Exception:
pass # best-effort
def heartbeat(game_assoc: str, color: str) -> None:
"""POST /api/game/heartbeat/{gameAssoc} — keep-alive ping."""
try:
client.post(f"/api/game/heartbeat/{game_assoc}", json={"color": color})
except Exception:
pass # best-effort
def waiting() -> list[dict]:
"""GET /api/game/waiting — list of waiting players in the lobby."""
resp = client.get("/api/game/waiting")
return resp.json()
def challenge(target_game_assoc: str, challenger_game_assoc: str) -> None:
"""POST /api/game/challenge/{targetGameAssoc}"""
client.post(
f"/api/game/challenge/{target_game_assoc}",
json={"challengerGameAssoc": challenger_game_assoc},
)
def challenge_respond(
challenger_game_assoc: str,
accepted: bool,
target_game_assoc: str,
) -> None:
"""POST /api/game/challenge/respond/{challengerGameAssoc}"""
client.post(
f"/api/game/challenge/respond/{challenger_game_assoc}",
json={
"accepted": accepted,
"targetGameAssoc": target_game_assoc,
},
)

View File

@@ -0,0 +1,163 @@
"""
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.
"""
from __future__ import annotations
import json
import logging
import threading
import time
from collections.abc import Callable
from typing import Any
import requests
from gi.repository import GLib
from mineseeker import config
from mineseeker.api import client
from mineseeker.constants import SSE_RECONNECT_INITIAL, SSE_RECONNECT_MAX
log = logging.getLogger(__name__)
class SseListener:
"""
Opens a Mercure SSE connection in a daemon thread and dispatches
parsed JSON messages back to the GTK main thread via GLib.idle_add().
Message routing mirrors useServerCommunication.jsx handleMercureMessage():
payload.type == "challenge" → on_challenge(payload)
payload.type == "challenge-response" → on_challenge_response(payload)
payload.type == "heartbeat" → on_heartbeat(payload)
"data" key present → on_topic(payload)
"msg" key present → on_unsubscribe(payload)
(none of the above) → on_subscribe(payload)
"""
def __init__(
self,
game_assoc: str,
mercure_jwt: str,
*,
on_subscribe: Callable[[dict], Any] | None = None,
on_unsubscribe: Callable[[dict], Any] | None = None,
on_topic: Callable[[dict], Any] | None = None,
on_challenge: Callable[[dict], Any] | None = None,
on_challenge_response: Callable[[dict], Any] | None = None,
on_heartbeat: Callable[[dict], Any] | None = None,
) -> None:
self._game_assoc = game_assoc
self._mercure_jwt = mercure_jwt
self._handlers = {
"subscribe": on_subscribe,
"unsubscribe": on_unsubscribe,
"topic": on_topic,
"challenge": on_challenge,
"challenge-response": on_challenge_response,
"heartbeat": on_heartbeat,
}
self._stop_event = threading.Event()
self._thread: threading.Thread | None = None
# ------------------------------------------------------------------
# Public control
# ------------------------------------------------------------------
def start(self) -> None:
"""Start the background SSE listener thread."""
self._stop_event.clear()
self._thread = threading.Thread(
target=self._run, daemon=True, name="sse-listener"
)
self._thread.start()
def stop(self) -> None:
"""Signal the background thread to stop."""
self._stop_event.set()
# ------------------------------------------------------------------
# Background thread
# ------------------------------------------------------------------
def _build_url(self) -> str:
topic = f"mineseeker/channel/{self._game_assoc}"
return f"{config.MERCURE_URL}?topic={topic}"
def _run(self) -> None:
backoff = SSE_RECONNECT_INITIAL
while not self._stop_event.is_set():
try:
self._stream()
backoff = SSE_RECONNECT_INITIAL # reset on clean disconnect
except Exception as exc:
if self._stop_event.is_set():
break
log.warning("SSE connection lost (%s), reconnecting in %.1fs", exc, backoff)
time.sleep(backoff)
backoff = min(backoff * 2, SSE_RECONNECT_MAX)
def _stream(self) -> None:
"""Open the SSE stream and process events until stopped or error."""
url = self._build_url()
headers = {
"Authorization": f"Bearer {self._mercure_jwt}",
"Accept": "text/event-stream",
"Cache-Control": "no-cache",
}
# Use the requests session from client.py so cookies are included
resp = client.get_session().get(
url, headers=headers, stream=True, timeout=(10, None)
)
resp.raise_for_status()
# Parse the raw SSE stream manually (sseclient-py would also work
# but avoids an extra dependency on GLib-aware loops)
data_lines: list[str] = []
for raw_line in resp.iter_lines(decode_unicode=True):
if self._stop_event.is_set():
break
if raw_line.startswith("data:"):
data_lines.append(raw_line[5:].lstrip(" "))
elif raw_line == "" and data_lines:
# Empty line signals end of event — dispatch it
payload_str = "\n".join(data_lines)
data_lines = []
try:
payload = json.loads(payload_str)
GLib.idle_add(self._dispatch, payload)
except json.JSONDecodeError:
log.debug("Non-JSON SSE data ignored: %s", payload_str)
def _dispatch(self, payload: dict) -> bool:
"""Called on the GTK main thread via GLib.idle_add."""
msg_type = payload.get("type")
if msg_type == "challenge":
self._call("challenge", payload)
elif msg_type == "challenge-response":
self._call("challenge-response", payload)
elif msg_type == "heartbeat":
self._call("heartbeat", payload)
elif "data" in payload:
self._call("topic", payload)
elif "msg" in payload:
self._call("unsubscribe", payload)
else:
self._call("subscribe", payload)
return GLib.SOURCE_REMOVE # run once only
def _call(self, key: str, payload: dict) -> None:
handler = self._handlers.get(key)
if handler is not None:
try:
handler(payload)
except Exception:
log.exception("Error in SSE handler '%s'", key)

View File

@@ -0,0 +1,108 @@
"""
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.
"""
from __future__ import annotations
import io
import logging
import gi
gi.require_version("GdkPixbuf", "2.0")
gi.require_version("Gst", "1.0")
from gi.repository import GdkPixbuf, Gst
from mineseeker import config
from mineseeker.api import client
from mineseeker.constants import IMAGE_NAMES, SOUND_NAMES
log = logging.getLogger(__name__)
# ---------------------------------------------------------------------------
# Image cache { filename: GdkPixbuf.Pixbuf }
# ---------------------------------------------------------------------------
_images: dict[str, GdkPixbuf.Pixbuf] = {}
def load_images(cell_size: int = 40) -> None:
"""
Fetch all game images from the server and cache them as GdkPixbuf.Pixbuf.
Call once at startup (blocking; run in a thread if you want a splash screen).
"""
for name in IMAGE_NAMES:
url = f"{config.BASE_URL}/images/{name}"
try:
resp = client.get_session().get(url, timeout=10)
resp.raise_for_status()
loader = GdkPixbuf.PixbufLoader()
loader.write(resp.content)
loader.close()
pixbuf = loader.get_pixbuf()
# Scale to the cell size used by the grid widget
pixbuf = pixbuf.scale_simple(cell_size, cell_size, GdkPixbuf.InterpType.BILINEAR)
_images[name] = pixbuf
except Exception as exc:
log.warning("Could not load image %s: %s", name, exc)
def get_image(name: str) -> GdkPixbuf.Pixbuf | None:
"""Return a cached Pixbuf by filename, or None if not loaded."""
return _images.get(name)
def get_image_or_fallback(name: str, fallback: str) -> GdkPixbuf.Pixbuf | None:
return _images.get(name) or _images.get(fallback)
# ---------------------------------------------------------------------------
# Sound — via GStreamer playbin
# ---------------------------------------------------------------------------
_sounds: dict[str, str] = {} # { key: URI }
def load_sounds() -> None:
"""
Build the URI map for the six game sound effects.
GStreamer will stream them on-demand from the server.
"""
Gst.init(None)
for filename in SOUND_NAMES:
key = filename.split(".")[0] # "click", "bomb", etc.
_sounds[key] = f"{config.BASE_URL}/sound/{filename}"
def play_sound(key: str) -> None:
"""
Play a sound by key ("click", "mine", "warning", "bomb", "won", "starting").
Each call spawns a fresh GStreamer playbin — fire-and-forget.
"""
uri = _sounds.get(key)
if not uri:
return
try:
player = Gst.ElementFactory.make("playbin", None)
if player is None:
return
player.set_property("uri", uri)
player.set_state(Gst.State.PLAYING)
# Connect to bus to clean up after playback
bus = player.get_bus()
bus.add_signal_watch()
def _on_message(bus, msg, player=player):
if msg.type in (Gst.MessageType.EOS, Gst.MessageType.ERROR):
player.set_state(Gst.State.NULL)
bus.remove_signal_watch()
return True
bus.connect("message", _on_message)
except Exception as exc:
log.debug("Sound play failed (%s): %s", key, exc)

View File

@@ -0,0 +1,30 @@
"""
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 os
from pathlib import Path
from dotenv import load_dotenv
# Load .env from the gtk-client/ directory (parent of this package)
_env_path = Path(__file__).parent.parent / ".env"
load_dotenv(dotenv_path=_env_path)
BASE_URL: str = os.environ.get("MINESEEKER_BASE_URL", "").rstrip("/")
MERCURE_URL: str = os.environ.get("MINESEEKER_MERCURE_URL", "").rstrip("/")
if not BASE_URL:
raise EnvironmentError(
"MINESEEKER_BASE_URL is not set. "
"Copy gtk-client/.env.example to gtk-client/.env and fill in the values."
)
if not MERCURE_URL:
# Fall back to <BASE_URL>/.well-known/mercure if not explicitly set
MERCURE_URL = f"{BASE_URL}/.well-known/mercure"

View File

@@ -0,0 +1,90 @@
"""
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.
"""
# Grid dimensions
GRID_ROWS: int = 16
GRID_COLS: int = 16
GRID_SIZE: int = GRID_ROWS * GRID_COLS # 256
# Game rules
TOTAL_MINES: int = 51
WIN_THRESHOLD: int = 26 # first player to reach this wins
# Cell pixel size in the grid widget (each cell rendered as a square)
CELL_SIZE: int = 40
# Player colours (match backend "red" / "blue" strings)
PLAYER_RED: str = "red"
PLAYER_BLUE: str = "blue"
# Bomb reveal diamond half-width (matches PHP getBombRadius / JS bombRadius)
BOMB_RADIUS: int = 2
# Heartbeat interval in milliseconds (mirrors JS 1500 ms)
HEARTBEAT_INTERVAL_MS: int = 1500
# SSE reconnect back-off (seconds)
SSE_RECONNECT_INITIAL: float = 1.0
SSE_RECONNECT_MAX: float = 30.0
# Bonus stat display labels (mirrors JS BONUS_LABELS)
BONUS_LABELS: dict[str, str] = {
"blindHits": "Blind Hits",
"chainBest": "Best Chain",
"chainCurrent": "Current Chain",
"lastMineHits": "Endgame Mines",
"edgeMines": "Edge Mines",
"biggestReveal": "Biggest Reveal",
}
# Image URL path fragments served from BASE_URL/images/
IMAGE_NAMES: list[str] = [
"bg-target-outbg.png",
"bg-bomb-outbg.png",
"bg-bomb-disabled-outbg.png",
"bg-bomb-exploded-outbg.png",
"bg-bomb-empty-outbg.png",
"bg-left-mine-outbg.png",
"bg-cursor-red-outbg.png",
"bg-cursor-blue-outbg.png",
"bg-figure-red-outbg.png",
"bg-figure-blue-outbg.png",
"bg-flag-red-outbg.png",
"bg-flag-blue-outbg.png",
"bg-last-red-outbg.png",
"bg-last-blue-outbg.png",
"bg-wave-1-outbg.png",
"bg-wave-2-outbg.png",
"bg-corner-outbg.png",
"bg-bomb-top-left-outbg.png",
"bg-bomb-top-center-outbg.png",
"bg-bomb-top-right-outbg.png",
"bg-bomb-middle-left-outbg.png",
"bg-bomb-middle-center-outbg.png",
"bg-bomb-middle-right-outbg.png",
"bg-bomb-bottom-left-outbg.png",
"bg-bomb-bottom-center-outbg.png",
"bg-bomb-bottom-right-outbg.png",
]
# Sound file names served from BASE_URL/sound/
SOUND_NAMES: list[str] = [
"click.mp3",
"bomb.mp3",
"mine.mp3",
"warning.mp3",
"won.mp3",
"starting.mp3",
]
# Bomb position image name helper
# horizontal: "top" | "middle" | "bottom"
# vertical: "left" | "center" | "right"
def bomb_pos_image(horizontal: str, vertical: str) -> str:
return f"bg-bomb-{horizontal}-{vertical}-outbg.png"

View File

View File

@@ -0,0 +1,267 @@
"""
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.
"""
from __future__ import annotations
import random
from dataclasses import dataclass, field
from typing import Any
from mineseeker.constants import GRID_ROWS, GRID_COLS, WIN_THRESHOLD
# ---------------------------------------------------------------------------
# Cell representation
# ---------------------------------------------------------------------------
@dataclass
class Cell:
"""Mirrors a single entry in the JS cells array."""
row: int
col: int
# "hidden" | "safe" | "mine"
state: str = "hidden"
# Numeric adjacent-mine count (0-8) or "m" for mine, None when unknown
value: Any = None
# "red", "blue", or None — which player claimed this mine
owner: str | None = None
# Whether this was the last cell clicked (for highlight)
is_last: bool = False
# Wave background variant (1 or 2) for unrevealed cells
wave: int = 1
# Bomb position overlay strings ("top"/"middle"/"bottom", "left"/"center"/"right") or None
bomb_h: str | None = None
bomb_v: str | None = None
def _init_cells() -> list[list[Cell]]:
"""Create the initial 16×16 grid of hidden cells (random wave image)."""
return [
[Cell(row=r, col=c, wave=random.choice([1, 1, 2]))
for c in range(GRID_COLS)]
for r in range(GRID_ROWS)
]
# ---------------------------------------------------------------------------
# Bonus stats
# ---------------------------------------------------------------------------
@dataclass
class BonusStats:
blind_hits: int = 0
chain_best: int = 0
chain_current: int = 0
last_mine_hits: int = 0
edge_mines: int = 0
biggest_reveal: int = 0
@classmethod
def from_dict(cls, d: dict) -> "BonusStats":
return cls(
blind_hits=d.get("blindHits", 0),
chain_best=d.get("chainBest", 0),
chain_current=d.get("chainCurrent", 0),
last_mine_hits=d.get("lastMineHits", 0),
edge_mines=d.get("edgeMines", 0),
biggest_reveal=d.get("biggestReveal", 0),
)
# ---------------------------------------------------------------------------
# Player snapshot
# ---------------------------------------------------------------------------
@dataclass
class PlayerState:
name: str = ""
anon_name: str = ""
avatar_url: str | None = None
mines: int = 0
bonus_points: float = 0.0
bonus_stats: BonusStats = field(default_factory=BonusStats)
have_bomb: bool = True
bomb_used: bool = False
@property
def display_name(self) -> str:
return self.name or self.anon_name or "Guest"
@property
def bomb_enabled(self) -> bool:
"""Mirrors JS: bomb only enabled when player is NOT ahead."""
return self.have_bomb and not self.bomb_used
# ---------------------------------------------------------------------------
# Game state
# ---------------------------------------------------------------------------
@dataclass
class GameState:
cells: list[list[Cell]] = field(default_factory=_init_cells)
red: PlayerState = field(default_factory=PlayerState)
blue: PlayerState = field(default_factory=PlayerState)
# Whose turn it is ("red" or "blue") — blue always starts
turn: str = "blue"
# Has the game ended?
finished: bool = False
# Winner ("red", "blue", "draw", or None)
winner: str | None = None
# Resignation ("red" or "blue" resigned, or None)
resigned: str | None = None
# Shareable UUID assigned by the server after the first step
uuid: str | None = None
# The last step coordinates per player { "red": (r,c) | None, ... }
last_step: dict[str, tuple[int, int] | None] = field(
default_factory=lambda: {"red": None, "blue": None}
)
# ---------------------------------------------------------------------------
# Apply a step result from the server
# ---------------------------------------------------------------------------
def apply_step(self, data: dict) -> None:
"""
Update state from a step payload (TopicManager::publish() result).
Mirrors JS applyStep() in useServerCommunication.jsx.
"""
if data.get("resign"):
self.resigned = data["resign"]
self.finished = True
self.winner = "blue" if data["resign"] == "red" else "red"
if data.get("uuid"):
self.uuid = data["uuid"]
return
player: str = data.get("player", "")
coords = data.get("coords")
if coords:
self.last_step[player] = (coords[0], coords[1])
# Clear previous last-step highlights for this player
for row in self.cells:
for cell in row:
if cell.is_last and cell.owner == player:
cell.is_last = False
self.cells[coords[0]][coords[1]].is_last = True
# Reveal cells
for rc in data.get("revealedCells", []):
r, c, v = rc["row"], rc["col"], rc["value"]
cell = self.cells[r][c]
if v == "m":
cell.state = "mine"
cell.value = "m"
cell.owner = player
else:
cell.state = "safe"
cell.value = v
# Reveal leftover mines at game end
for rc in data.get("leftMines", []):
r, c = rc["row"], rc["col"]
cell = self.cells[r][c]
cell.state = "mine"
cell.value = "m"
# Scores
self.red.mines = data.get("redPoints", self.red.mines)
self.blue.mines = data.get("bluePoints", self.blue.mines)
self.red.bonus_points = data.get("redBonusPoints", self.red.bonus_points)
self.blue.bonus_points = data.get("blueBonusPoints", self.blue.bonus_points)
if "redBonusStats" in data:
self.red.bonus_stats = BonusStats.from_dict(data["redBonusStats"])
if "blueBonusStats" in data:
self.blue.bonus_stats = BonusStats.from_dict(data["blueBonusStats"])
if data.get("bomb"):
if player == "red":
self.red.bomb_used = True
else:
self.blue.bomb_used = True
if data.get("uuid") and not self.finished:
self.uuid = data["uuid"]
# Win check
if self.red.mines >= WIN_THRESHOLD:
self.finished = True
self.winner = "red"
elif self.blue.mines >= WIN_THRESHOLD:
self.finished = True
self.winner = "blue"
# Advance turn (switches after every move)
if not self.finished:
self.turn = "blue" if player == "red" else "red"
# ---------------------------------------------------------------------------
# Restore from server connect information
# ---------------------------------------------------------------------------
def apply_connect(self, data: dict) -> None:
"""
Restore an existing game from the /api/game/connect payload.
Mirrors JS wInit() in useServerCommunication.jsx.
"""
if not data.get("users"):
return # fresh game, nothing to restore
users = data["users"]
self.red.name = users.get("red", "")
self.red.anon_name = users.get("redAnon", "")
self.blue.name = users.get("blue", "")
self.blue.anon_name = users.get("blueAnon", "")
self.red.mines = data.get("redPoints", 0)
self.blue.mines = data.get("bluePoints", 0)
self.red.bonus_points = data.get("redBonusPoints", 0.0)
self.blue.bonus_points = data.get("blueBonusPoints", 0.0)
if data.get("redBonusStats"):
self.red.bonus_stats = BonusStats.from_dict(data["redBonusStats"])
if data.get("blueBonusStats"):
self.blue.bonus_stats = BonusStats.from_dict(data["blueBonusStats"])
# Restore revealed cells (enriched with player colour)
for rc in data.get("revealedCells") or []:
r, c, v = rc["row"], rc["col"], rc["value"]
p = rc.get("player")
cell = self.cells[r][c]
if v == "m":
cell.state = "mine"
cell.value = "m"
cell.owner = p
else:
cell.state = "safe"
cell.value = v
# Restore last-step highlights
last = data.get("lastStep", {})
for color in ("red", "blue"):
ls = last.get(color)
if ls:
r, c = ls["row"], ls["col"]
self.last_step[color] = (r, c)
self.cells[r][c].is_last = True
# Determine whose turn it is from mostRecentStep
mrs = data.get("mostRecentStep")
if mrs:
self.turn = "blue" if mrs["player"] == "red" else "red"
self.finished = bool(data.get("gameFinished", False))

View File

@@ -0,0 +1,45 @@
"""
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.
"""
from __future__ import annotations
from dataclasses import dataclass, field
@dataclass
class Session:
"""Holds the current player's identity and game association."""
# Username (real user) or "anon_<session_id>" for guests
username: str = ""
# Whether this is an authenticated (non-guest) user
is_authenticated: bool = False
# Current game association UUID
game_assoc: str = ""
# "red" or "blue" — assigned when both players are subscribed
color: str = ""
# Mercure subscriber JWT for the current game
mercure_jwt: str = ""
# Module-level singleton; reset on logout
_session: Session = Session()
def get() -> Session:
return _session
def reset() -> None:
global _session
_session = Session()

View File

View File

@@ -0,0 +1,109 @@
"""
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.
"""
from __future__ import annotations
import gi
gi.require_version("Gtk", "4.0")
gi.require_version("Adw", "1")
from gi.repository import Gtk, Adw
from mineseeker.ui.login_page import LoginPage
from mineseeker.ui.totp_page import TotpPage
from mineseeker.ui.lobby_page import LobbyPage
from mineseeker.ui.game_page import GamePage
class AppWindow(Adw.ApplicationWindow):
"""
Main application window containing a Gtk.Stack that navigates between:
- "login" : LoginPage
- "totp" : TotpPage
- "lobby" : LobbyPage
- "game" : GamePage (replaced on each new game)
"""
def __init__(self, application: Adw.Application) -> None:
super().__init__(application=application)
self.set_title("MineSeeker")
self.set_default_size(980, 680)
# Stack — child names serve as page IDs
self._stack = Gtk.Stack()
self._stack.set_transition_type(Gtk.StackTransitionType.CROSSFADE)
self._stack.set_transition_duration(200)
# Build pages
self._login_page = LoginPage(on_success=self._on_login_success, on_guest=self._on_guest)
self._totp_page = TotpPage(on_success=self._on_totp_success, on_back=self._show_login)
self._lobby_page = LobbyPage(on_game_start=self._on_game_start)
self._game_page: GamePage | None = None
self._stack.add_named(self._login_page, "login")
self._stack.add_named(self._totp_page, "totp")
self._stack.add_named(self._lobby_page, "lobby")
# Wrap in a NavigationView-style container using Adw.ToolbarView
toolbar_view = Adw.ToolbarView()
header = Adw.HeaderBar()
toolbar_view.add_top_bar(header)
toolbar_view.set_content(self._stack)
self.set_content(toolbar_view)
# Start on the login page
self._stack.set_visible_child_name("login")
# ------------------------------------------------------------------
# Navigation helpers
# ------------------------------------------------------------------
def _show_login(self) -> None:
self._stack.set_visible_child_name("login")
def _show_totp(self) -> None:
self._stack.set_visible_child_name("totp")
def _show_lobby(self) -> None:
self._lobby_page.refresh()
self._stack.set_visible_child_name("lobby")
# ------------------------------------------------------------------
# Callbacks from child pages
# ------------------------------------------------------------------
def _on_login_success(self, needs_totp: bool) -> None:
if needs_totp:
self._show_totp()
else:
self._show_lobby()
def _on_guest(self) -> None:
self._show_lobby()
def _on_totp_success(self) -> None:
self._show_lobby()
def _on_game_start(self, game_assoc: str, mercure_jwt: str, color: str) -> None:
"""Replace or create the GamePage and switch to it."""
# Remove previous game page if present
if self._game_page is not None:
self._stack.remove(self._game_page)
self._game_page = GamePage(
game_assoc=game_assoc,
mercure_jwt=mercure_jwt,
color=color,
on_leave=self._on_game_leave,
)
self._stack.add_named(self._game_page, "game")
self._stack.set_visible_child_name("game")
def _on_game_leave(self) -> None:
self._show_lobby()

View File

@@ -0,0 +1,96 @@
"""
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.
"""
from __future__ import annotations
import gi
gi.require_version("Gtk", "4.0")
gi.require_version("Adw", "1")
from gi.repository import Gtk, Adw
from mineseeker.state.game_state import BonusStats
from mineseeker.constants import BONUS_LABELS
class BonusDialog(Adw.Dialog):
"""Modal dialog displaying bonus stats for both players."""
def __init__(
self,
parent: Gtk.Widget,
red_name: str,
blue_name: str,
red_points: float,
blue_points: float,
red_stats: BonusStats,
blue_stats: BonusStats,
) -> None:
super().__init__()
self.set_title("Bonus Statistics")
self.set_content_width(480)
box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=0)
header = Adw.HeaderBar()
box.append(header)
content = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=16)
content.set_margin_top(16)
content.set_margin_bottom(16)
content.set_margin_start(16)
content.set_margin_end(16)
# Totals row
totals = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=0)
totals.add_css_class("card")
red_total = Gtk.Label(label=f"{red_name}: {red_points:.1f} pts")
red_total.add_css_class("red-player")
red_total.set_hexpand(True)
red_total.set_xalign(0)
red_total.set_margin_start(12)
red_total.set_margin_top(8)
red_total.set_margin_bottom(8)
totals.append(red_total)
blue_total = Gtk.Label(label=f"{blue_name}: {blue_points:.1f} pts")
blue_total.add_css_class("blue-player")
blue_total.set_hexpand(True)
blue_total.set_xalign(1)
blue_total.set_margin_end(12)
totals.append(blue_total)
content.append(totals)
# Per-stat rows
group = Adw.PreferencesGroup(title="Breakdown")
stat_fields = [
("blind_hits", red_stats.blind_hits, blue_stats.blind_hits),
("chain_best", red_stats.chain_best, blue_stats.chain_best),
("last_mine_hits",red_stats.last_mine_hits,blue_stats.last_mine_hits),
("edge_mines", red_stats.edge_mines, blue_stats.edge_mines),
("biggest_reveal",red_stats.biggest_reveal,blue_stats.biggest_reveal),
]
key_map = {
"blind_hits": "blindHits",
"chain_best": "chainBest",
"last_mine_hits": "lastMineHits",
"edge_mines": "edgeMines",
"biggest_reveal": "biggestReveal",
}
for field_name, rv, bv in stat_fields:
label = BONUS_LABELS.get(key_map[field_name], field_name)
row = Adw.ActionRow(title=label)
row.set_subtitle(f"Red: {rv} Blue: {bv}")
group.add(row)
content.append(group)
box.append(content)
self.set_child(box)
self.present(parent)

View File

@@ -0,0 +1,477 @@
"""
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.
"""
from __future__ import annotations
import threading
import time
from collections.abc import Callable
import gi
gi.require_version("Gtk", "4.0")
gi.require_version("Adw", "1")
from gi.repository import Gtk, Adw, GLib
from mineseeker.api import game as game_api
from mineseeker.api.sse import SseListener
from mineseeker import assets
from mineseeker.constants import HEARTBEAT_INTERVAL_MS, WIN_THRESHOLD, PLAYER_RED, PLAYER_BLUE
from mineseeker.state.game_state import GameState
from mineseeker.state import session as session_mod
from mineseeker.ui.grid_widget import GridWidget
from mineseeker.ui.player_panel import PlayerPanel
from mineseeker.ui.bonus_dialog import BonusDialog
from mineseeker.ui.result_overlay import ResultOverlay
class GamePage(Gtk.Overlay):
"""
Full game screen.
Layout:
[RedPanel] [GridWidget] [BluePanel]
An Overlay places the ResultOverlay on top when the game ends.
"""
def __init__(
self,
game_assoc: str,
mercure_jwt: str,
color: str,
on_leave: Callable[[], None],
) -> None:
super().__init__()
self._game_assoc = game_assoc
self._mercure_jwt = mercure_jwt
self._color = color # "red" | "blue" | "" (determined by subscribe)
self._on_leave = on_leave
self._state = GameState()
self._bomb_mode = False
self._step_start: float = time.monotonic()
self._heartbeat_source: int | None = None
# --- Layout ---
main_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=0)
main_box.set_hexpand(True)
main_box.set_vexpand(True)
# Red player panel (left)
self._red_panel = PlayerPanel(
color=PLAYER_RED,
is_local=(color == PLAYER_RED),
on_bomb_toggle=self._on_bomb_toggle,
on_resign=self._on_resign,
)
main_box.append(self._red_panel)
# Centre column: status bar + grid
centre = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=0)
centre.set_hexpand(True)
centre.set_vexpand(True)
# Status / turn label
self._status_label = Gtk.Label(label="Connecting…")
self._status_label.add_css_class("dim-label")
self._status_label.set_margin_top(8)
self._status_label.set_margin_bottom(8)
centre.append(self._status_label)
# Grid in a scrolled window so it never clips on small screens
scrolled = Gtk.ScrolledWindow()
scrolled.set_policy(Gtk.PolicyType.AUTOMATIC, Gtk.PolicyType.AUTOMATIC)
scrolled.set_hexpand(True)
scrolled.set_vexpand(True)
self._grid = GridWidget(on_cell_click=self._on_cell_click)
scrolled.set_child(self._grid)
centre.append(scrolled)
# Bonus button row
bonus_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8)
bonus_box.set_halign(Gtk.Align.CENTER)
bonus_box.set_margin_top(6)
bonus_box.set_margin_bottom(6)
bonus_btn = Gtk.Button(label="Bonus Stats")
bonus_btn.add_css_class("flat")
bonus_btn.connect("clicked", self._show_bonus_dialog)
bonus_box.append(bonus_btn)
leave_btn = Gtk.Button(label="Leave")
leave_btn.add_css_class("flat")
leave_btn.connect("clicked", self._on_leave_clicked)
bonus_box.append(leave_btn)
centre.append(bonus_box)
main_box.append(centre)
# Blue player panel (right)
self._blue_panel = PlayerPanel(
color=PLAYER_BLUE,
is_local=(color == PLAYER_BLUE),
on_bomb_toggle=self._on_bomb_toggle,
on_resign=self._on_resign,
)
main_box.append(self._blue_panel)
# Result overlay
self._result_overlay = ResultOverlay(
on_play_again=self._on_play_again,
on_lobby=self._on_lobby,
)
self.set_child(main_box)
self.add_overlay(self._result_overlay)
# Start async init
threading.Thread(target=self._init_game, daemon=True).start()
# ------------------------------------------------------------------
# Game initialisation
# ------------------------------------------------------------------
def _init_game(self) -> None:
"""Connect, start SSE, join the channel, start/restore the game."""
try:
# 1. Fetch existing game state
connect_data = game_api.connect(self._game_assoc)
GLib.idle_add(self._apply_connect_data, connect_data)
# 2. Start the SSE listener
self._sse = SseListener(
game_assoc=self._game_assoc,
mercure_jwt=self._mercure_jwt,
on_subscribe=self._on_subscribe,
on_unsubscribe=self._on_unsubscribe,
on_topic=self._on_topic,
on_challenge=self._on_challenge,
on_challenge_response=self._on_challenge_response,
on_heartbeat=self._on_heartbeat,
)
self._sse.start()
# 3. Join (announces presence via Mercure)
game_api.join(self._game_assoc)
# 4. If no existing game, create the grid
if not connect_data.get("users"):
game_api.start(self._game_assoc)
# 5. Start heartbeat
GLib.idle_add(self._start_heartbeat)
except Exception as e:
GLib.idle_add(self._set_status, f"Error: {e}")
def _apply_connect_data(self, data: dict) -> bool:
self._state.apply_connect(data)
self._refresh_panels()
self._grid.set_state(self._state)
return GLib.SOURCE_REMOVE
# ------------------------------------------------------------------
# SSE handlers (called on GTK main thread via GLib.idle_add)
# ------------------------------------------------------------------
def _on_subscribe(self, payload: dict) -> None:
"""Two players connected → start game."""
users = payload.get("users", {})
user_cnt = payload.get("userCnt", 0)
# Determine our colour if not yet assigned
if not self._color:
sess = session_mod.get()
my_name = sess.username
if my_name == users.get("blue") or my_name == users.get("blueAnon"):
self._color = PLAYER_BLUE
else:
self._color = PLAYER_RED
sess.color = self._color
# Update player names from subscribe payload
self._state.red.name = users.get("red", "")
self._state.red.anon_name = users.get("redAnon", "")
self._state.blue.name = users.get("blue", "")
self._state.blue.anon_name = users.get("blueAnon", "")
if user_cnt == 2:
self._set_status("Game started!")
assets.play_sound("starting")
else:
self._set_status("Waiting for opponent…")
self._refresh_panels()
def _on_unsubscribe(self, payload: dict) -> None:
self._set_status("Opponent left the game.")
def _on_topic(self, payload: dict) -> None:
"""A step was made — apply it and refresh."""
data = payload.get("data", {})
if not data:
return
player = data.get("player", "")
is_mine = data.get("revealedCells") and any(
rc.get("value") == "m" for rc in data["revealedCells"]
)
# Play sounds
if data.get("resign"):
assets.play_sound("won")
elif is_mine:
my_state = self._state.red if player == PLAYER_RED else self._state.blue
if my_state.mines > 20:
assets.play_sound("warning")
else:
assets.play_sound("mine")
else:
assets.play_sound("click")
self._state.apply_step(data)
self._grid.refresh()
self._refresh_panels()
if self._state.finished:
self._show_result()
# uuid from server
if data.get("uuid"):
session_mod.get().game_assoc = data["uuid"]
def _on_challenge(self, payload: dict) -> None:
"""Incoming challenge — show accept/decline dialog."""
challenger_name = payload.get("challengerName", "Someone")
challenger_assoc = payload.get("challengerGameAssoc", "")
GLib.idle_add(self._show_challenge_dialog, challenger_name, challenger_assoc)
def _on_challenge_response(self, payload: dict) -> None:
if payload.get("accepted"):
# Switch to the new game assoc
new_assoc = payload.get("targetGameAssoc", "")
if new_assoc:
GLib.idle_add(self._redirect_to_game, new_assoc)
def _on_heartbeat(self, payload: dict) -> None:
# Heartbeat from opponent received — game is live
pass
# ------------------------------------------------------------------
# Cell click / resign
# ------------------------------------------------------------------
def _on_cell_click(self, row: int, col: int, bomb_mode: bool) -> None:
if self._state.finished:
return
if self._state.turn != self._color:
return # not our turn
elapsed = time.monotonic() - self._step_start
self._step_start = time.monotonic()
threading.Thread(
target=self._send_step,
args=(row, col, bomb_mode, elapsed),
daemon=True,
).start()
def _send_step(self, row: int, col: int, bomb: bool, elapsed: float) -> None:
try:
result = game_api.step(
game_assoc=self._game_assoc,
coords=[row, col],
player=self._color,
bomb=bomb,
resign=None,
step_elapsed=elapsed,
)
GLib.idle_add(self._apply_step_result, result)
except Exception as e:
GLib.idle_add(self._set_status, f"Step error: {e}")
def _apply_step_result(self, data: dict) -> bool:
self._state.apply_step(data)
self._grid.refresh()
self._refresh_panels()
if self._bomb_mode:
self._bomb_mode = False
self._grid.set_bomb_mode(False)
local_panel = self._red_panel if self._color == PLAYER_RED else self._blue_panel
local_panel.reset_bomb_toggle()
if self._state.finished:
self._show_result()
return GLib.SOURCE_REMOVE
def _on_resign(self) -> None:
threading.Thread(target=self._send_resign, daemon=True).start()
def _send_resign(self) -> None:
try:
result = game_api.step(
game_assoc=self._game_assoc,
coords=[0, 0],
player=self._color,
bomb=False,
resign=self._color,
step_elapsed=0,
)
GLib.idle_add(self._apply_step_result, result)
except Exception:
pass
# ------------------------------------------------------------------
# Bomb toggle
# ------------------------------------------------------------------
def _on_bomb_toggle(self, active: bool) -> None:
self._bomb_mode = active
self._grid.set_bomb_mode(active)
# ------------------------------------------------------------------
# Heartbeat
# ------------------------------------------------------------------
def _start_heartbeat(self) -> bool:
interval_s = HEARTBEAT_INTERVAL_MS / 1000.0
self._heartbeat_source = GLib.timeout_add(
HEARTBEAT_INTERVAL_MS,
self._send_heartbeat,
)
return GLib.SOURCE_REMOVE
def _send_heartbeat(self) -> bool:
if self._color:
threading.Thread(
target=game_api.heartbeat,
args=(self._game_assoc, self._color),
daemon=True,
).start()
return GLib.SOURCE_CONTINUE # repeat
# ------------------------------------------------------------------
# Result / game over
# ------------------------------------------------------------------
def _show_result(self) -> None:
assets.play_sound("won")
self._result_overlay.show_result(
winner=self._state.winner,
resigned=self._state.resigned,
local_color=self._color,
red_mines=self._state.red.mines,
blue_mines=self._state.blue.mines,
red_name=self._state.red.display_name,
blue_name=self._state.blue.display_name,
)
self._stop_heartbeat()
def _stop_heartbeat(self) -> None:
if self._heartbeat_source is not None:
GLib.source_remove(self._heartbeat_source)
self._heartbeat_source = None
# ------------------------------------------------------------------
# Navigation callbacks
# ------------------------------------------------------------------
def _on_play_again(self) -> None:
self._leave_game()
self._on_leave()
def _on_lobby(self) -> None:
self._leave_game()
self._on_leave()
def _on_leave_clicked(self, *_) -> None:
self._leave_game()
self._on_leave()
def _leave_game(self) -> None:
self._stop_heartbeat()
if hasattr(self, "_sse"):
self._sse.stop()
threading.Thread(
target=game_api.leave, args=(self._game_assoc,), daemon=True
).start()
def _redirect_to_game(self, new_assoc: str) -> bool:
# Challenge accepted — leave current and open new game page
self._leave_game()
self._on_leave()
return GLib.SOURCE_REMOVE
# ------------------------------------------------------------------
# Challenge dialog
# ------------------------------------------------------------------
def _show_challenge_dialog(self, challenger_name: str, challenger_assoc: str) -> bool:
dialog = Adw.AlertDialog(
heading=f"Challenge from {challenger_name}",
body="Do you accept the challenge?",
)
dialog.add_response("decline", "Decline")
dialog.add_response("accept", "Accept")
dialog.set_response_appearance("accept", Adw.ResponseAppearance.SUGGESTED)
dialog.connect(
"response",
lambda d, resp: self._on_challenge_response_dialog(resp, challenger_assoc),
)
dialog.present(self)
return GLib.SOURCE_REMOVE
def _on_challenge_response_dialog(self, response: str, challenger_assoc: str) -> None:
accepted = response == "accept"
threading.Thread(
target=game_api.challenge_respond,
args=(challenger_assoc, accepted, self._game_assoc),
daemon=True,
).start()
# ------------------------------------------------------------------
# Bonus dialog
# ------------------------------------------------------------------
def _show_bonus_dialog(self, *_) -> None:
BonusDialog(
parent=self,
red_name=self._state.red.display_name,
blue_name=self._state.blue.display_name,
red_points=self._state.red.bonus_points,
blue_points=self._state.blue.bonus_points,
red_stats=self._state.red.bonus_stats,
blue_stats=self._state.blue.bonus_stats,
)
# ------------------------------------------------------------------
# Helpers
# ------------------------------------------------------------------
def _refresh_panels(self) -> None:
is_red_turn = self._state.turn == PLAYER_RED
self._red_panel.update(self._state.red, is_red_turn)
self._blue_panel.update(self._state.blue, not is_red_turn)
self._grid.set_state(self._state)
self._update_status_label()
def _update_status_label(self) -> None:
if self._state.finished:
self._status_label.set_label("Game over")
elif not self._color:
self._status_label.set_label("Connecting…")
elif self._state.turn == self._color:
self._status_label.set_label("Your turn")
else:
opponent = self._state.blue if self._color == PLAYER_RED else self._state.red
self._status_label.set_label(f"{opponent.display_name}'s turn")
def _set_status(self, message: str) -> bool:
self._status_label.set_label(message)
return GLib.SOURCE_REMOVE

View File

@@ -0,0 +1,239 @@
"""
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.
"""
from __future__ import annotations
from collections.abc import Callable
from typing import Any
import gi
gi.require_version("Gtk", "4.0")
gi.require_version("Gdk", "4.0")
from gi.repository import Gtk, Gdk, GdkPixbuf
from mineseeker import assets
from mineseeker.constants import (
GRID_ROWS, GRID_COLS, CELL_SIZE, PLAYER_RED, PLAYER_BLUE, bomb_pos_image
)
from mineseeker.state.game_state import GameState, Cell
# ---------------------------------------------------------------------------
# Bomb diamond radius (mirrors JS bombRadius() / PHP getBombRadius())
# ---------------------------------------------------------------------------
def _bomb_cells(row: int, col: int) -> list[tuple[int, int]]:
"""Return all cells within the 5×5 diamond centred at (row, col)."""
result = []
for dr in range(-2, 3):
for dc in range(-2, 3):
if abs(dr) + abs(dc) <= 2:
r, c = row + dr, col + dc
if 0 <= r < GRID_ROWS and 0 <= c < GRID_COLS:
result.append((r, c))
return result
def _bomb_pos(dr: int, dc: int) -> tuple[str, str]:
"""Map (delta_row, delta_col) to (horizontal, vertical) overlay names."""
h = "top" if dr < 0 else ("bottom" if dr > 0 else "middle")
v = "left" if dc < 0 else ("right" if dc > 0 else "center")
return h, v
# ---------------------------------------------------------------------------
# GridWidget
# ---------------------------------------------------------------------------
class GridWidget(Gtk.DrawingArea):
"""
16×16 minesweeper grid rendered with Cairo + GdkPixbuf tile images.
Signals emitted (via callbacks, not GObject signals for simplicity):
on_cell_click(row, col, bomb_mode) — user clicked a cell
"""
def __init__(self, on_cell_click: Callable[[int, int, bool], None]) -> None:
super().__init__()
self._on_cell_click = on_cell_click
self._state: GameState | None = None
self._bomb_mode: bool = False
self._hover: tuple[int, int] | None = None # (row, col) under cursor
width = CELL_SIZE * GRID_COLS
height = CELL_SIZE * GRID_ROWS
self.set_content_width(width)
self.set_content_height(height)
self.set_draw_func(self._draw)
# Click gesture
click = Gtk.GestureClick()
click.connect("pressed", self._on_pressed)
self.add_controller(click)
# Motion controller for bomb hover preview
motion = Gtk.EventControllerMotion()
motion.connect("motion", self._on_motion)
motion.connect("leave", self._on_leave)
self.add_controller(motion)
# ------------------------------------------------------------------
# Public API
# ------------------------------------------------------------------
def set_state(self, state: GameState) -> None:
self._state = state
self.queue_draw()
def set_bomb_mode(self, active: bool) -> None:
self._bomb_mode = active
self.queue_draw()
def refresh(self) -> None:
self.queue_draw()
# ------------------------------------------------------------------
# Drawing
# ------------------------------------------------------------------
def _draw(self, area, cr, width, height) -> None:
if self._state is None:
return
for r in range(GRID_ROWS):
for c in range(GRID_COLS):
cell = self._state.cells[r][c]
x = c * CELL_SIZE
y = r * CELL_SIZE
self._draw_cell(cr, x, y, cell, r, c)
# Bomb hover diamond overlay
if self._bomb_mode and self._hover:
hr, hc = self._hover
for (br, bc) in _bomb_cells(hr, hc):
dr, dc = br - hr, bc - hc
h_pos, v_pos = _bomb_pos(dr, dc)
img_name = bomb_pos_image(h_pos, v_pos)
pixbuf = assets.get_image(img_name)
if pixbuf:
self._paint_pixbuf(cr, bc * CELL_SIZE, br * CELL_SIZE, pixbuf)
def _draw_cell(self, cr, x: int, y: int, cell: Cell, row: int, col: int) -> None:
cs = CELL_SIZE
if cell.state == "hidden":
# Wave background
wave_name = f"bg-wave-{cell.wave}-outbg.png"
pixbuf = assets.get_image(wave_name) or assets.get_image("bg-wave-1-outbg.png")
if pixbuf:
self._paint_pixbuf(cr, x, y, pixbuf)
else:
# Fallback: solid dark tile
cr.set_source_rgb(0.15, 0.15, 0.25)
cr.rectangle(x, y, cs, cs)
cr.fill()
elif cell.state == "safe":
# Light tile with number
cr.set_source_rgb(0.85, 0.85, 0.85)
cr.rectangle(x, y, cs, cs)
cr.fill()
# Draw thin border
cr.set_source_rgb(0.6, 0.6, 0.6)
cr.set_line_width(0.5)
cr.rectangle(x + 0.5, y + 0.5, cs - 1, cs - 1)
cr.stroke()
if cell.value and cell.value != 0:
self._draw_number(cr, x, y, cs, int(cell.value))
elif cell.state == "mine":
# Mine flag — show the appropriate player flag
color = cell.owner or "red"
flag_name = f"bg-flag-{color}-outbg.png"
pixbuf = assets.get_image(flag_name)
if pixbuf:
self._paint_pixbuf(cr, x, y, pixbuf)
else:
# Fallback colour
if color == "red":
cr.set_source_rgb(0.8, 0.1, 0.1)
else:
cr.set_source_rgb(0.1, 0.3, 0.9)
cr.rectangle(x, y, cs, cs)
cr.fill()
# Last-step highlight overlay
if cell.is_last:
color = cell.owner or (self._state.turn if self._state else "red")
last_name = f"bg-last-{color}-outbg.png"
pixbuf = assets.get_image(last_name)
if pixbuf:
self._paint_pixbuf(cr, x, y, pixbuf)
# Target overlay on hover (non-bomb)
if not self._bomb_mode and self._hover == (row, col):
pixbuf = assets.get_image("bg-target-outbg.png")
if pixbuf:
self._paint_pixbuf(cr, x, y, pixbuf)
@staticmethod
def _paint_pixbuf(cr, x: int, y: int, pixbuf: GdkPixbuf.Pixbuf) -> None:
Gdk.cairo_set_source_pixbuf(cr, pixbuf, x, y)
cr.paint()
# Number colours matching standard minesweeper conventions
_NUM_COLOURS = {
1: (0.0, 0.0, 1.0),
2: (0.0, 0.5, 0.0),
3: (1.0, 0.0, 0.0),
4: (0.0, 0.0, 0.5),
5: (0.5, 0.0, 0.0),
6: (0.0, 0.5, 0.5),
7: (0.0, 0.0, 0.0),
8: (0.5, 0.5, 0.5),
}
def _draw_number(self, cr, x: int, y: int, cs: int, value: int) -> None:
r, g, b = self._NUM_COLOURS.get(value, (0, 0, 0))
cr.set_source_rgb(r, g, b)
cr.select_font_face("Sans", 0, 1) # normal, bold
cr.set_font_size(cs * 0.55)
text = str(value)
ext = cr.text_extents(text)
tx = x + (cs - ext.width) / 2 - ext.x_bearing
ty = y + (cs + ext.height) / 2 - ext.y_bearing - ext.height
cr.move_to(tx, ty)
cr.show_text(text)
# ------------------------------------------------------------------
# Input handlers
# ------------------------------------------------------------------
def _cell_at(self, px: float, py: float) -> tuple[int, int] | None:
col = int(px // CELL_SIZE)
row = int(py // CELL_SIZE)
if 0 <= row < GRID_ROWS and 0 <= col < GRID_COLS:
return row, col
return None
def _on_pressed(self, gesture, n_press, x, y) -> None:
pos = self._cell_at(x, y)
if pos and self._state and not self._state.finished:
self._on_cell_click(pos[0], pos[1], self._bomb_mode)
def _on_motion(self, controller, x, y) -> None:
pos = self._cell_at(x, y)
if pos != self._hover:
self._hover = pos
self.queue_draw()
def _on_leave(self, controller) -> None:
if self._hover is not None:
self._hover = None
self.queue_draw()

View File

@@ -0,0 +1,185 @@
"""
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.
"""
from __future__ import annotations
import threading
from collections.abc import Callable
import gi
gi.require_version("Gtk", "4.0")
gi.require_version("Adw", "1")
from gi.repository import Gtk, Adw, GLib
from mineseeker.api import game as game_api
from mineseeker import assets
from mineseeker.state import session as session_mod
class LobbyPage(Gtk.Box):
"""
Lobby screen — shows waiting players and a "New Game" button.
Flow:
- "New Game" → fetch token → start game → on_game_start()
- Click a waiting player → challenge them → on_game_start() when accepted
"""
def __init__(self, on_game_start: Callable[[str, str, str], None]) -> None:
super().__init__(orientation=Gtk.Orientation.VERTICAL, spacing=0)
self._on_game_start = on_game_start
self._waiting: list[dict] = []
# Header bar action area
header_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8)
header_box.set_margin_top(12)
header_box.set_margin_bottom(12)
header_box.set_margin_start(16)
header_box.set_margin_end(16)
title = Gtk.Label(label="Lobby")
title.add_css_class("title-2")
title.set_hexpand(True)
title.set_xalign(0)
header_box.append(title)
self._refresh_btn = Gtk.Button(label="Refresh")
self._refresh_btn.connect("clicked", lambda *_: self.refresh())
header_box.append(self._refresh_btn)
new_game_btn = Gtk.Button(label="New Game")
new_game_btn.add_css_class("suggested-action")
new_game_btn.connect("clicked", self._on_new_game)
header_box.append(new_game_btn)
self.append(header_box)
self.append(Gtk.Separator(orientation=Gtk.Orientation.HORIZONTAL))
# Waiting players list
scrolled = Gtk.ScrolledWindow()
scrolled.set_vexpand(True)
scrolled.set_policy(Gtk.PolicyType.NEVER, Gtk.PolicyType.AUTOMATIC)
self._list_box = Gtk.ListBox()
self._list_box.set_selection_mode(Gtk.SelectionMode.NONE)
self._list_box.add_css_class("boxed-list")
self._list_box.set_margin_top(12)
self._list_box.set_margin_bottom(12)
self._list_box.set_margin_start(16)
self._list_box.set_margin_end(16)
scrolled.set_child(self._list_box)
self.append(scrolled)
self._status_label = Gtk.Label(label="No players waiting.")
self._status_label.add_css_class("dim-label")
self._status_label.set_margin_top(24)
self._status_label.set_visible(True)
self.append(self._status_label)
def refresh(self) -> None:
"""Fetch waiting players list from the server."""
self._refresh_btn.set_sensitive(False)
threading.Thread(target=self._do_refresh, daemon=True).start()
def _do_refresh(self) -> None:
try:
waiting = game_api.waiting()
GLib.idle_add(self._update_list, waiting)
except Exception:
GLib.idle_add(self._refresh_btn.set_sensitive, True)
def _update_list(self, waiting: list[dict]) -> bool:
self._waiting = waiting
# Clear existing rows
while True:
row = self._list_box.get_first_child()
if row is None:
break
self._list_box.remove(row)
my_assoc = session_mod.get().game_assoc
for player in waiting:
if player["gameAssoc"] == my_assoc:
continue # don't show ourselves
row = self._make_player_row(player)
self._list_box.append(row)
has_players = bool([p for p in waiting if p.get("gameAssoc") != my_assoc])
self._status_label.set_visible(not has_players)
self._refresh_btn.set_sensitive(True)
return GLib.SOURCE_REMOVE
def _make_player_row(self, player: dict) -> Adw.ActionRow:
row = Adw.ActionRow()
row.set_title(player.get("name", "Guest"))
row.set_subtitle(f"Waiting since {player.get('since', '')[:19].replace('T', ' ')}")
challenge_btn = Gtk.Button(label="Challenge")
challenge_btn.add_css_class("flat")
challenge_btn.set_valign(Gtk.Align.CENTER)
challenge_btn.connect(
"clicked",
lambda _btn, p=player: self._on_challenge(p),
)
row.add_suffix(challenge_btn)
return row
def _on_new_game(self, *_) -> None:
threading.Thread(target=self._do_new_game, daemon=True).start()
def _do_new_game(self) -> None:
try:
token_data = game_api.fetch_token()
game_assoc = token_data["gameAssoc"]
mercure_jwt = token_data["mercureJwt"]
sess = session_mod.get()
sess.game_assoc = game_assoc
sess.mercure_jwt = mercure_jwt
sess.color = "red" # first player always red
# Load images while we wait for an opponent
assets.load_images()
GLib.idle_add(self._on_game_start, game_assoc, mercure_jwt, "red")
except Exception as e:
GLib.idle_add(self._show_error_toast, str(e))
def _on_challenge(self, player: dict) -> None:
threading.Thread(
target=self._do_challenge, args=(player,), daemon=True
).start()
def _do_challenge(self, player: dict) -> None:
try:
token_data = game_api.fetch_token()
game_assoc = token_data["gameAssoc"]
mercure_jwt = token_data["mercureJwt"]
sess = session_mod.get()
sess.game_assoc = game_assoc
sess.mercure_jwt = mercure_jwt
game_api.challenge(
target_game_assoc=player["gameAssoc"],
challenger_game_assoc=game_assoc,
)
assets.load_images()
# GamePage will determine color from subscribe payload
GLib.idle_add(self._on_game_start, game_assoc, mercure_jwt, "")
except Exception as e:
GLib.idle_add(self._show_error_toast, str(e))
def _show_error_toast(self, message: str) -> bool:
# Find the nearest Adw.ToastOverlay ancestor if available, otherwise print
print(f"[LobbyPage] Error: {message}")
return GLib.SOURCE_REMOVE

View File

@@ -0,0 +1,146 @@
"""
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.
"""
from __future__ import annotations
import threading
from collections.abc import Callable
import gi
gi.require_version("Gtk", "4.0")
gi.require_version("Adw", "1")
from gi.repository import Gtk, Adw, GLib
from mineseeker.api.auth import login, login_as_guest, TotpRequired, AuthError
from mineseeker import assets
class LoginPage(Gtk.Box):
"""Username + password login form with a 'Play as Guest' option."""
def __init__(
self,
on_success: Callable[[bool], None],
on_guest: Callable[[], None],
) -> None:
super().__init__(orientation=Gtk.Orientation.VERTICAL, spacing=0)
self._on_success = on_success
self._on_guest = on_guest
self.set_valign(Gtk.Align.CENTER)
self.set_halign(Gtk.Align.CENTER)
clamp = Adw.Clamp()
clamp.set_maximum_size(360)
inner = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=16)
inner.set_margin_top(32)
inner.set_margin_bottom(32)
inner.set_margin_start(16)
inner.set_margin_end(16)
# Title
title = Gtk.Label(label="MineSeeker")
title.add_css_class("title-1")
inner.append(title)
subtitle = Gtk.Label(label="Sign in to play")
subtitle.add_css_class("dim-label")
inner.append(subtitle)
# Credentials group
group = Adw.PreferencesGroup()
self._username_row = Adw.EntryRow(title="Username")
group.add(self._username_row)
self._password_row = Adw.PasswordEntryRow(title="Password")
self._password_row.connect("entry-activated", self._on_login_clicked)
group.add(self._password_row)
inner.append(group)
# Error label
self._error_label = Gtk.Label(label="")
self._error_label.add_css_class("error")
self._error_label.set_visible(False)
inner.append(self._error_label)
# Login button
self._login_btn = Gtk.Button(label="Sign In")
self._login_btn.add_css_class("suggested-action")
self._login_btn.add_css_class("pill")
self._login_btn.connect("clicked", self._on_login_clicked)
inner.append(self._login_btn)
# Separator
sep = Gtk.Separator(orientation=Gtk.Orientation.HORIZONTAL)
inner.append(sep)
# Guest button
guest_btn = Gtk.Button(label="Play as Guest")
guest_btn.add_css_class("pill")
guest_btn.connect("clicked", self._on_guest_clicked)
inner.append(guest_btn)
clamp.set_child(inner)
self.append(clamp)
def _set_busy(self, busy: bool) -> None:
self._login_btn.set_sensitive(not busy)
self._username_row.set_sensitive(not busy)
self._password_row.set_sensitive(not busy)
if busy:
self._error_label.set_visible(False)
def _show_error(self, message: str) -> None:
self._error_label.set_label(message)
self._error_label.set_visible(True)
def _on_login_clicked(self, *_) -> None:
username = self._username_row.get_text().strip()
password = self._password_row.get_text()
if not username or not password:
self._show_error("Please enter username and password.")
return
self._set_busy(True)
threading.Thread(
target=self._do_login, args=(username, password), daemon=True
).start()
def _do_login(self, username: str, password: str) -> None:
try:
login(username, password)
# Load assets after successful authentication
assets.load_sounds()
GLib.idle_add(self._on_success, False)
except TotpRequired:
assets.load_sounds()
GLib.idle_add(self._on_success, True)
except AuthError as e:
GLib.idle_add(self._handle_auth_error, str(e))
except Exception as e:
GLib.idle_add(self._handle_auth_error, f"Connection error: {e}")
def _handle_auth_error(self, message: str) -> bool:
self._set_busy(False)
self._show_error(message)
return GLib.SOURCE_REMOVE
def _on_guest_clicked(self, *_) -> None:
self._set_busy(True)
threading.Thread(target=self._do_guest, daemon=True).start()
def _do_guest(self) -> None:
try:
login_as_guest()
assets.load_sounds()
GLib.idle_add(self._on_guest)
except Exception as e:
GLib.idle_add(self._handle_auth_error, f"Could not start guest session: {e}")

View File

@@ -0,0 +1,116 @@
"""
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.
"""
from __future__ import annotations
from collections.abc import Callable
import gi
gi.require_version("Gtk", "4.0")
gi.require_version("Adw", "1")
from gi.repository import Gtk, Adw
from mineseeker.state.game_state import PlayerState
from mineseeker.constants import WIN_THRESHOLD
class PlayerPanel(Gtk.Box):
"""
Vertical sidebar panel showing one player's info:
- Name + colour indicator
- Mine count (e.g. "12 / 26")
- Bonus points
- Bomb toggle button
- Resign button (only for the local player)
"""
def __init__(
self,
color: str,
is_local: bool,
on_bomb_toggle: Callable[[bool], None],
on_resign: Callable[[], None],
) -> None:
super().__init__(orientation=Gtk.Orientation.VERTICAL, spacing=8)
self._color = color
self._is_local = is_local
self._on_bomb_toggle = on_bomb_toggle
self._on_resign = on_resign
self._bomb_active = False
self.set_margin_top(12)
self.set_margin_bottom(12)
self.set_margin_start(12)
self.set_margin_end(12)
self.set_valign(Gtk.Align.START)
# Colour dot + name
name_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=6)
dot = Gtk.Label(label="")
dot.add_css_class("red-player" if color == "red" else "blue-player")
name_box.append(dot)
self._name_label = Gtk.Label(label="Waiting…")
self._name_label.add_css_class("title-4")
self._name_label.set_ellipsize(3) # PANGO_ELLIPSIZE_END
name_box.append(self._name_label)
self.append(name_box)
# Mine count
self._mine_label = Gtk.Label(label=f"0 / {WIN_THRESHOLD}")
self._mine_label.add_css_class("title-2")
self.append(self._mine_label)
# Bonus points
self._bonus_label = Gtk.Label(label="Bonus: 0")
self._bonus_label.add_css_class("dim-label")
self.append(self._bonus_label)
# Bomb button — only meaningful for local player
if is_local:
self._bomb_btn = Gtk.ToggleButton(label="Bomb")
self._bomb_btn.set_sensitive(False)
self._bomb_btn.connect("toggled", self._on_bomb_toggled)
self.append(self._bomb_btn)
sep = Gtk.Separator(orientation=Gtk.Orientation.HORIZONTAL)
self.append(sep)
resign_btn = Gtk.Button(label="Resign")
resign_btn.add_css_class("destructive-action")
resign_btn.connect("clicked", lambda *_: self._on_resign())
self.append(resign_btn)
# ------------------------------------------------------------------
# Update from state
# ------------------------------------------------------------------
def update(self, player: PlayerState, is_turn: bool) -> None:
self._name_label.set_label(player.display_name)
self._mine_label.set_label(f"{player.mines} / {WIN_THRESHOLD}")
self._bonus_label.set_label(f"Bonus: {player.bonus_points:.1f}")
if self._is_local and hasattr(self, "_bomb_btn"):
can_use = player.bomb_enabled and not player.bomb_used and is_turn
self._bomb_btn.set_sensitive(can_use)
if player.bomb_used:
self._bomb_btn.set_label("Bomb Used")
def set_bomb_enabled(self, enabled: bool) -> None:
if self._is_local and hasattr(self, "_bomb_btn"):
self._bomb_btn.set_sensitive(enabled)
def reset_bomb_toggle(self) -> None:
"""Deactivate the bomb toggle (after a bomb move is sent)."""
if self._is_local and hasattr(self, "_bomb_btn"):
self._bomb_btn.set_active(False)
def _on_bomb_toggled(self, btn: Gtk.ToggleButton) -> None:
self._bomb_active = btn.get_active()
self._on_bomb_toggle(self._bomb_active)

View File

@@ -0,0 +1,102 @@
"""
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.
"""
from __future__ import annotations
from collections.abc import Callable
import gi
gi.require_version("Gtk", "4.0")
gi.require_version("Adw", "1")
from gi.repository import Gtk, Adw
class ResultOverlay(Gtk.Box):
"""
Translucent overlay shown at game end.
Displays the winner, final scores, and action buttons.
"""
def __init__(
self,
on_play_again: Callable[[], None],
on_lobby: Callable[[], None],
) -> None:
super().__init__(orientation=Gtk.Orientation.VERTICAL, spacing=16)
self._on_play_again = on_play_again
self._on_lobby = on_lobby
self.set_visible(False)
self.set_valign(Gtk.Align.CENTER)
self.set_halign(Gtk.Align.CENTER)
self.set_margin_top(16)
self.set_margin_bottom(16)
self.set_margin_start(16)
self.set_margin_end(16)
self.add_css_class("card")
self._title_label = Gtk.Label(label="")
self._title_label.add_css_class("title-1")
self.append(self._title_label)
self._subtitle_label = Gtk.Label(label="")
self._subtitle_label.add_css_class("title-3")
self.append(self._subtitle_label)
self._score_label = Gtk.Label(label="")
self._score_label.add_css_class("dim-label")
self.append(self._score_label)
btn_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8)
btn_box.set_halign(Gtk.Align.CENTER)
play_again_btn = Gtk.Button(label="Play Again")
play_again_btn.add_css_class("suggested-action")
play_again_btn.add_css_class("pill")
play_again_btn.connect("clicked", lambda *_: self._on_play_again())
btn_box.append(play_again_btn)
lobby_btn = Gtk.Button(label="Back to Lobby")
lobby_btn.add_css_class("pill")
lobby_btn.connect("clicked", lambda *_: self._on_lobby())
btn_box.append(lobby_btn)
self.append(btn_box)
def show_result(
self,
winner: str | None,
resigned: str | None,
local_color: str,
red_mines: int,
blue_mines: int,
red_name: str,
blue_name: str,
) -> None:
if resigned:
loser_name = red_name if resigned == "red" else blue_name
self._title_label.set_label("Resignation")
self._subtitle_label.set_label(f"{loser_name} resigned.")
elif winner == "draw" or winner is None:
self._title_label.set_label("Draw!")
self._subtitle_label.set_label("Equal mines — it's a draw.")
elif winner == local_color:
self._title_label.set_label("You Win!")
self._subtitle_label.set_label("Congratulations!")
else:
self._title_label.set_label("You Lose")
self._subtitle_label.set_label("Better luck next time.")
self._score_label.set_label(
f"{red_name}: {red_mines} mines · {blue_name}: {blue_mines} mines"
)
self.set_visible(True)
def hide_result(self) -> None:
self.set_visible(False)

View File

@@ -0,0 +1,112 @@
"""
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.
"""
from __future__ import annotations
import threading
from collections.abc import Callable
import gi
gi.require_version("Gtk", "4.0")
gi.require_version("Adw", "1")
from gi.repository import Gtk, Adw, GLib
from mineseeker.api.auth import submit_totp, AuthError
class TotpPage(Gtk.Box):
"""6-digit TOTP code entry shown after a successful password login."""
def __init__(
self,
on_success: Callable[[], None],
on_back: Callable[[], None],
) -> None:
super().__init__(orientation=Gtk.Orientation.VERTICAL, spacing=0)
self._on_success = on_success
self._on_back = on_back
self.set_valign(Gtk.Align.CENTER)
self.set_halign(Gtk.Align.CENTER)
clamp = Adw.Clamp()
clamp.set_maximum_size(360)
inner = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=16)
inner.set_margin_top(32)
inner.set_margin_bottom(32)
inner.set_margin_start(16)
inner.set_margin_end(16)
title = Gtk.Label(label="Two-Factor Authentication")
title.add_css_class("title-2")
inner.append(title)
subtitle = Gtk.Label(label="Enter the 6-digit code from your authenticator app.")
subtitle.set_wrap(True)
subtitle.add_css_class("dim-label")
inner.append(subtitle)
group = Adw.PreferencesGroup()
self._code_row = Adw.EntryRow(title="Authentication Code")
self._code_row.set_input_purpose(Gtk.InputPurpose.DIGITS)
self._code_row.connect("entry-activated", self._on_verify_clicked)
group.add(self._code_row)
inner.append(group)
self._error_label = Gtk.Label(label="")
self._error_label.add_css_class("error")
self._error_label.set_visible(False)
inner.append(self._error_label)
self._verify_btn = Gtk.Button(label="Verify")
self._verify_btn.add_css_class("suggested-action")
self._verify_btn.add_css_class("pill")
self._verify_btn.connect("clicked", self._on_verify_clicked)
inner.append(self._verify_btn)
back_btn = Gtk.Button(label="Back to Login")
back_btn.add_css_class("pill")
back_btn.connect("clicked", lambda *_: self._on_back())
inner.append(back_btn)
clamp.set_child(inner)
self.append(clamp)
def _set_busy(self, busy: bool) -> None:
self._verify_btn.set_sensitive(not busy)
self._code_row.set_sensitive(not busy)
if busy:
self._error_label.set_visible(False)
def _show_error(self, message: str) -> None:
self._error_label.set_label(message)
self._error_label.set_visible(True)
def _on_verify_clicked(self, *_) -> None:
code = self._code_row.get_text().strip()
if len(code) != 6 or not code.isdigit():
self._show_error("Code must be exactly 6 digits.")
return
self._set_busy(True)
threading.Thread(target=self._do_verify, args=(code,), daemon=True).start()
def _do_verify(self, code: str) -> None:
try:
submit_totp(code)
GLib.idle_add(self._on_success)
except AuthError as e:
GLib.idle_add(self._handle_error, str(e))
except Exception as e:
GLib.idle_add(self._handle_error, f"Connection error: {e}")
def _handle_error(self, message: str) -> bool:
self._set_busy(False)
self._show_error(message)
return GLib.SOURCE_REMOVE

View File

@@ -0,0 +1,17 @@
# PyGObject is NOT installable from PyPI into a plain venv.
# It must come from your system package manager, e.g.:
# Debian/Ubuntu: sudo apt install python3-gi python3-gi-cairo gir1.2-gtk-4.0 gir1.2-adw-1
# Fedora: sudo dnf install python3-gobject gtk4 libadwaita
# Arch: sudo pacman -S python-gobject gtk4 libadwaita
#
# Create the venv with --system-site-packages so the system gi module is visible:
# python3 -m venv --system-site-packages .venv
#
# Then install only the pure-Python deps below:
requests>=2.31.0
sseclient-py>=1.8.0
python-dotenv>=1.0.0
# GStreamer for sound is also a system package:
# Debian/Ubuntu: sudo apt install gstreamer1.0-plugins-good gstreamer1.0-libav python3-gst-1.0
# Fedora: sudo dnf install gstreamer1-plugins-good python3-gstreamer1

31
gtk-client/run.sh Executable file
View File

@@ -0,0 +1,31 @@
#!/usr/bin/env bash
# Convenience launcher for the MineSeeker GTK4 desktop client.
# Creates the venv on first run, then launches main.py.
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
VENV="$SCRIPT_DIR/.venv"
PYTHON="$VENV/bin/python"
# ── Create venv if missing ──────────────────────────────────────────────────
if [ ! -f "$PYTHON" ]; then
echo "[run.sh] Creating venv with --system-site-packages…"
python3 -m venv --system-site-packages "$VENV"
fi
# ── Install / update pure-Python deps ──────────────────────────────────────
echo "[run.sh] Installing dependencies…"
"$VENV/bin/pip" install --quiet requests sseclient-py python-dotenv
# ── Check .env ──────────────────────────────────────────────────────────────
if [ ! -f "$SCRIPT_DIR/.env" ]; then
echo ""
echo "ERROR: $SCRIPT_DIR/.env not found."
echo "Copy .env.example to .env and set MINESEEKER_BASE_URL."
exit 1
fi
# ── Launch ──────────────────────────────────────────────────────────────────
echo "[run.sh] Starting MineSeeker…"
exec "$PYTHON" "$SCRIPT_DIR/main.py" "$@"

View File

@@ -39,7 +39,7 @@
"eslint-plugin-react-hooks": "7.1.1", "eslint-plugin-react-hooks": "7.1.1",
"globals": "17.5.0", "globals": "17.5.0",
"sass": "^1.99.0", "sass": "^1.99.0",
"vite": "^8.0.8", "vite": "^8.0.9",
"vite-plugin-symfony": "^8.2.4" "vite-plugin-symfony": "^8.2.4"
}, },
"scripts": { "scripts": {

48
phpunit.dist.xml Normal file
View File

@@ -0,0 +1,48 @@
<?xml version="1.0" encoding="UTF-8"?>
<!-- https://phpunit.readthedocs.io/en/latest/configuration.html -->
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="vendor/phpunit/phpunit/phpunit.xsd"
colors="true"
failOnDeprecation="true"
failOnNotice="true"
failOnWarning="true"
bootstrap="tests/bootstrap.php"
cacheDirectory=".phpunit.cache"
>
<php>
<ini name="display_errors" value="1" />
<ini name="error_reporting" value="-1" />
<server name="APP_ENV" value="test" force="true" />
<server name="SHELL_VERBOSITY" value="-1" />
<env name="SYMFONY_DEPRECATIONS_HELPER" value="disabled" />
<env name="DATABASE_URL" value="postgresql://system7:bazmeg@127.0.0.1:15432/mineseeker_test?serverVersion=16&amp;charset=utf8" />
<env name="DAMA_DISABLE_STATIC_CONNECTION" value="0" />
</php>
<testsuites>
<testsuite name="Project Test Suite">
<directory>tests</directory>
</testsuite>
</testsuites>
<source ignoreSuppressionOfDeprecations="true"
ignoreIndirectDeprecations="true"
restrictNotices="true"
restrictWarnings="true"
>
<include>
<directory>src</directory>
</include>
<deprecationTrigger>
<method>Doctrine\Deprecations\Deprecation::trigger</method>
<method>Doctrine\Deprecations\Deprecation::delegateTriggerToBackend</method>
<function>trigger_deprecation</function>
</deprecationTrigger>
</source>
<extensions>
<bootstrap class="Zenstruck\Foundry\PHPUnit\FoundryExtension" />
</extensions>
</phpunit>

BIN
public/sound/starting.mp3 Normal file

Binary file not shown.

View File

@@ -0,0 +1,106 @@
<?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\Controller;
use App\Entity\User;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Attribute\AsController;
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
use Symfony\Component\Routing\Attribute\Route;
/**
* Class ApiAuthController
*
* Provides a JSON login endpoint for native desktop clients.
* This endpoint is intentionally exempt from the reCAPTCHA listener
* because desktop clients cannot execute reCAPTCHA v3.
*
* After a successful password login, if the user has TOTP enabled the response
* returns { requiresTwoFactor: true }. The client must then POST the 6-digit
* code to the standard /2fa_check endpoint (which is already exempt from
* the reCAPTCHA listener via LoginCaptchaListener).
*
* @package App\Controller
* @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. 26.
*/
#[AsController]
class ApiAuthController extends AbstractController
{
public function __construct(
private readonly EntityManagerInterface $em,
private readonly UserPasswordHasherInterface $passwordHasher,
private readonly Security $security,
) {
}
/**
* POST /api/auth/login
*
* Request body (JSON): { "username": "...", "password": "..." }
*
* Responses:
* 200 { "success": true, "requiresTwoFactor": false }
* 200 { "success": true, "requiresTwoFactor": true }
* 400 { "success": false, "error": "..." }
* 401 { "success": false, "error": "..." }
*/
#[Route('/api/auth/login', name: 'MineSeekerBundle_api_auth_login', methods: ['POST'])]
public function login(Request $request): JsonResponse
{
$data = $request->toArray();
$username = trim($data['username'] ?? '');
$password = $data['password'] ?? '';
if ($username === '' || $password === '') {
return $this->json(
['success' => false, 'error' => 'Username and password are required.'],
Response::HTTP_BAD_REQUEST
);
}
/** @var User|null $user */
$user = $this->em->getRepository(User::class)->findOneBy(['username' => $username]);
if ($user === null || !$this->passwordHasher->isPasswordValid($user, $password)) {
return $this->json(
['success' => false, 'error' => 'Invalid username or password.'],
Response::HTTP_UNAUTHORIZED
);
}
if (!$user->isVerified) {
return $this->json(
['success' => false, 'error' => 'Account not yet activated. Check your email.'],
Response::HTTP_UNAUTHORIZED
);
}
// Log the user in via the Symfony security system.
// If TOTP is enabled, scheb/2fa will place the session into
// IS_AUTHENTICATED_2FA_IN_PROGRESS state, and the client must
// complete 2FA by POSTing the code to /2fa_check.
$this->security->login($user, 'form_login');
return $this->json([
'success' => true,
'requiresTwoFactor' => $user->isTotpAuthenticationEnabled(),
]);
}
}

View File

@@ -12,6 +12,7 @@ namespace App\Controller;
use App\Entity\PlayedGame; use App\Entity\PlayedGame;
use App\Repository\PlayedGameRepository; use App\Repository\PlayedGameRepository;
use App\Service\MercureJwtService;
use App\Service\ResolveUserNamesService; use App\Service\ResolveUserNamesService;
use App\Util\RpcManager; use App\Util\RpcManager;
use App\Util\TopicManager; use App\Util\TopicManager;
@@ -23,6 +24,7 @@ use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Attribute\AsController; use Symfony\Component\HttpKernel\Attribute\AsController;
use Symfony\Component\Routing\Attribute\Route; use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Uid\Uuid;
/** /**
* Class MercureController * Class MercureController
@@ -45,9 +47,28 @@ class MercureController extends AbstractController
private readonly TopicManager $topicManager, private readonly TopicManager $topicManager,
private readonly RpcManager $rpcManager, private readonly RpcManager $rpcManager,
private readonly ResolveUserNamesService $userNamesService, private readonly ResolveUserNamesService $userNamesService,
private readonly MercureJwtService $mercureJwtService,
) { ) {
} }
/**
* Returns a fresh Mercure subscriber JWT and a new gameAssoc UUID.
* Intended for native desktop clients that cannot parse the JWT from HTML.
*
* Response: { "mercureJwt": "<jwt>", "gameAssoc": "<uuid>" }
*/
#[Route('/api/game/token', name: 'MineSeekerBundle_api_game_token', methods: ['GET'])]
public function token(): JsonResponse
{
$gameAssoc = Uuid::v4()->toRfc4122();
$userName = $this->userNamesService->resolveUserName();
return $this->json([
'mercureJwt' => $this->mercureJwtService->mintSubscriberToken($gameAssoc, $userName),
'gameAssoc' => $gameAssoc,
]);
}
#[Route('/api/game/start', name: 'MineSeekerBundle_api_game_start', methods: ['POST'])] #[Route('/api/game/start', name: 'MineSeekerBundle_api_game_start', methods: ['POST'])]
public function start(Request $request): JsonResponse public function start(Request $request): JsonResponse
{ {

View File

@@ -27,6 +27,7 @@ use Doctrine\ORM\EntityManagerInterface;
use League\Flysystem\FilesystemException; use League\Flysystem\FilesystemException;
use League\Flysystem\FilesystemOperator; use League\Flysystem\FilesystemOperator;
use Liip\ImagineBundle\Imagine\Cache\CacheManager; use Liip\ImagineBundle\Imagine\Cache\CacheManager;
use Liip\ImagineBundle\Service\FilterService;
use Psr\Log\LoggerInterface; use Psr\Log\LoggerInterface;
use RuntimeException; use RuntimeException;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
@@ -140,6 +141,7 @@ class ProfileController extends AbstractController
Request $request, Request $request,
EntityManagerInterface $em, EntityManagerInterface $em,
CacheManager $cacheManager, CacheManager $cacheManager,
FilterService $filterService,
#[Autowire(service: 'mineseeker.media.storage')] FilesystemOperator $mediaStorage, #[Autowire(service: 'mineseeker.media.storage')] FilesystemOperator $mediaStorage,
): JsonResponse { ): JsonResponse {
$this->denyAccessUnlessGranted('IS_AUTHENTICATED_REMEMBERED'); $this->denyAccessUnlessGranted('IS_AUTHENTICATED_REMEMBERED');
@@ -153,8 +155,8 @@ class ProfileController extends AbstractController
return $this->json(['error' => 'No file uploaded.'], 400); return $this->json(['error' => 'No file uploaded.'], 400);
} }
if ($file->getSize() > 2 * 1024 * 1024) { if ($file->getSize() > 10 * 1024 * 1024) {
return $this->json(['error' => 'File is too large. Maximum 2 MB.'], 400); return $this->json(['error' => 'File is too large. Maximum 10 MB.'], 400);
} }
$allowed = ['image/jpeg', 'image/png', 'image/gif', 'image/webp']; $allowed = ['image/jpeg', 'image/png', 'image/gif', 'image/webp'];
@@ -182,6 +184,7 @@ class ProfileController extends AbstractController
$mediaStorage->writeStream($newPath, $stream); $mediaStorage->writeStream($newPath, $stream);
} catch (FilesystemException $e) { } catch (FilesystemException $e) {
$this->logger->error('Unable to write new avatar: ' . $e->getMessage()); $this->logger->error('Unable to write new avatar: ' . $e->getMessage());
fclose($stream);
throw new RuntimeException('Unable to write new avatar: ' . $e->getMessage()); throw new RuntimeException('Unable to write new avatar: ' . $e->getMessage());
} }
fclose($stream); fclose($stream);
@@ -190,7 +193,7 @@ class ProfileController extends AbstractController
$em->flush(); $em->flush();
return $this->json([ return $this->json([
'thumbUrl' => $cacheManager->generateUrl($newPath, 'avatar_thumb'), 'thumbUrl' => $filterService->getUrlOfFilteredImage($newPath, 'avatar_thumb'),
]); ]);
} }

View File

@@ -46,6 +46,7 @@ final readonly class ProfileGameDto implements JsonSerializable
public float $blueBonusPoints, public float $blueBonusPoints,
public array $redBonusStats, public array $redBonusStats,
public array $blueBonusStats, public array $blueBonusStats,
public bool $bothRegistered,
) { ) {
} }

View File

@@ -62,6 +62,7 @@ final readonly class ProfileGameDtoFactory
blueBonusPoints: $game->blueBonusPoints ?? 0.0, blueBonusPoints: $game->blueBonusPoints ?? 0.0,
redBonusStats: $game->redBonusStats ?? [], redBonusStats: $game->redBonusStats ?? [],
blueBonusStats: $game->blueBonusStats ?? [], blueBonusStats: $game->blueBonusStats ?? [],
bothRegistered: $game->red !== null && $game->blue !== null,
); );
} }
@@ -128,6 +129,7 @@ final readonly class ProfileGameDtoFactory
blueBonusPoints: $battle->blueBonusPoints, blueBonusPoints: $battle->blueBonusPoints,
redBonusStats: $battle->redBonusStats, redBonusStats: $battle->redBonusStats,
blueBonusStats: $battle->blueBonusStats, blueBonusStats: $battle->blueBonusStats,
bothRegistered: !$battle->oppIsGuest,
); );
} }

View File

@@ -40,9 +40,19 @@ class RecentBattleRepository extends ServiceEntityRepository
public function findRecentForUser(int $userId, int $limit = 30): array public function findRecentForUser(int $userId, int $limit = 30): array
{ {
return $this->createQueryBuilder('rb') $qb = $this->createQueryBuilder('rb');
->where('rb.userId = :uid')
return $qb
->where($qb->expr()->eq('rb.userId', ':uid'))
->andWhere(
$qb->expr()->orX(
$qb->expr()->eq('rb.oppIsGuest', ':false'),
$qb->expr()->isNotNull('rb.redPoints'),
$qb->expr()->isNotNull('rb.bluePoints')
),
)
->setParameter('uid', $userId) ->setParameter('uid', $userId)
->setParameter('false', false)
->orderBy('rb.updated', 'DESC') ->orderBy('rb.updated', 'DESC')
->setMaxResults($limit) ->setMaxResults($limit)
->getQuery() ->getQuery()

View File

@@ -34,11 +34,11 @@ final class BattleCardGenerator
{ {
private const int WIDTH = 1200; private const int WIDTH = 1200;
private const int HEIGHT = 630; private const int HEIGHT = 630;
private const string FONT = '/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf';
private const int AVATAR_SIZE = 120; private const int AVATAR_SIZE = 120;
public function __construct( public function __construct(
private readonly string $cacheDir, private readonly string $cacheDir,
private readonly string $fontPath,
private readonly FilesystemOperator $minioMediaStorage, private readonly FilesystemOperator $minioMediaStorage,
private readonly LoggerInterface $logger, private readonly LoggerInterface $logger,
) { ) {
@@ -159,7 +159,7 @@ final class BattleCardGenerator
$redBonusPoints = $game->redBonusPoints ?? 0; $redBonusPoints = $game->redBonusPoints ?? 0;
$blueBonusPoints = $game->blueBonusPoints ?? 0; $blueBonusPoints = $game->blueBonusPoints ?? 0;
$bonusText = number_format((float)$redBonusPoints, 1, '.', '') . ' * : * ' . number_format((float)$blueBonusPoints, 1, '.', ''); $bonusText = number_format((float)$redBonusPoints, 1, '.', '') . ' * : * ' . number_format((float)$blueBonusPoints, 1, '.', '');
$this->centeredText($im, $bonusText, 24, self::WIDTH / 2, 425, $gold); $this->centeredText($im, $bonusText, 24, self::WIDTH / 2, 445, $gold);
if ($winner === 'red') { if ($winner === 'red') {
$resultText = $redName . ' wins'; $resultText = $redName . ' wins';
@@ -176,11 +176,11 @@ final class BattleCardGenerator
} }
if ($resultText !== '') { if ($resultText !== '') {
$this->centeredText($im, $resultText, 30, self::WIDTH / 2, 475, $resultColor); $this->centeredText($im, $resultText, 30, self::WIDTH / 2, 495, $resultColor);
} }
if ($resign) { if ($resign) {
$this->centeredText($im, ucfirst($resign) . ' resigned', 18, self::WIDTH / 2, 508, $muted); $this->centeredText($im, ucfirst($resign) . ' resigned', 18, self::WIDTH / 2, 528, $muted);
} }
$this->centeredText($im, 'mineseeker.hu', 16, self::WIDTH / 2, self::HEIGHT - 20, $muted); $this->centeredText($im, 'mineseeker.hu', 16, self::WIDTH / 2, self::HEIGHT - 20, $muted);
@@ -273,23 +273,23 @@ final class BattleCardGenerator
/** Draw initials */ /** Draw initials */
$initials = mb_strtoupper(mb_substr($name, 0, 2)); $initials = mb_strtoupper(mb_substr($name, 0, 2));
$fontSize = 48; $fontSize = 48;
$bbox = imagettfbbox($fontSize, 0, self::FONT, $initials); $bbox = imagettfbbox($fontSize, 0, $this->fontPath, $initials);
$textW = $bbox[2] - $bbox[0]; $textW = $bbox[2] - $bbox[0];
$textH = $bbox[1] - $bbox[7]; $textH = $bbox[1] - $bbox[7];
$textX = $cx - $textW / 2; $textX = $cx - $textW / 2;
$textY = $cy + $textH / 2; $textY = $cy + $textH / 2;
$white = imagecolorallocate($im, 255, 255, 255); $white = imagecolorallocate($im, 255, 255, 255);
imagettftext($im, $fontSize, 0, (int)$textX, (int)$textY, $white, self::FONT, $initials); imagettftext($im, $fontSize, 0, (int)$textX, (int)$textY, $white, $this->fontPath, $initials);
} }
} }
/** Render text centered on $cx. */ /** Render text centered on $cx. */
private function centeredText(GdImage $im, string $text, int $size, int $cx, int $y, int $color): void private function centeredText(GdImage $im, string $text, int $size, int $cx, int $y, int $color): void
{ {
$bbox = imagettfbbox($size, 0, self::FONT, $text); $bbox = imagettfbbox($size, 0, $this->fontPath, $text);
$w = $bbox[2] - $bbox[0]; $w = $bbox[2] - $bbox[0];
imagettftext($im, $size, 0, (int)($cx - $w / 2), $y, $color, self::FONT, $text); imagettftext($im, $size, 0, (int)($cx - $w / 2), $y, $color, $this->fontPath, $text);
} }
/** Render text centered on $cx, shrinking font size to fit $maxWidth. */ /** Render text centered on $cx, shrinking font size to fit $maxWidth. */
@@ -302,7 +302,7 @@ final class BattleCardGenerator
int $color, int $color,
int $maxWidth int $maxWidth
): void { ): void {
$bbox = imagettfbbox($size, 0, self::FONT, $text); $bbox = imagettfbbox($size, 0, $this->fontPath, $text);
$w = $bbox[2] - $bbox[0]; $w = $bbox[2] - $bbox[0];
if ($w > $maxWidth) { if ($w > $maxWidth) {
$size = (int)($size * $maxWidth / $w); $size = (int)($size * $maxWidth / $w);

33
src/Story/AppStory.php Normal file
View File

@@ -0,0 +1,33 @@
<?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\Story;
use Zenstruck\Foundry\Attribute\AsFixture;
use Zenstruck\Foundry\Story;
/**
* Class AppStory
*
* @package App\Story
* @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. 21.
*/
#[AsFixture(name: 'main')]
final class AppStory extends Story
{
public function build(): void
{
// SomeFactory::createOne();
}
}

View File

@@ -26,12 +26,13 @@ use Symfony\Component\Validator\Exception\UnexpectedTypeException;
* @link www.splendidbear.org * @link www.splendidbear.org
* @since 2026. 04. 12. * @since 2026. 04. 12.
*/ */
class RecaptchaValidator extends ConstraintValidator final class RecaptchaValidator extends ConstraintValidator
{ {
public function __construct( public function __construct(
private readonly RecaptchaService $recaptcha, private readonly RecaptchaService $recaptcha,
private readonly RequestStack $requestStack, private readonly RequestStack $requestStack,
) {} ) {
}
public function validate(mixed $value, Constraint $constraint): void public function validate(mixed $value, Constraint $constraint): void
{ {
@@ -40,9 +41,9 @@ class RecaptchaValidator extends ConstraintValidator
} }
$request = $this->requestStack->getCurrentRequest(); $request = $this->requestStack->getCurrentRequest();
$remoteIp = $request !== null ? ((string) $request->getClientIp()) : ''; $remoteIp = $request !== null ? ((string)$request->getClientIp()) : '';
if ($this->recaptcha->verify((string) $value, $remoteIp)) { if ($this->recaptcha->verify((string)$value, $remoteIp)) {
return; return;
} }

View File

@@ -2,6 +2,15 @@
"cocur/slugify": { "cocur/slugify": {
"version": "v3.1" "version": "v3.1"
}, },
"dama/doctrine-test-bundle": {
"version": "8.6",
"recipe": {
"repo": "github.com/symfony/recipes-contrib",
"branch": "main",
"version": "8.3",
"ref": "dfc51177476fb39d014ed89944cde53dc3326d23"
}
},
"doctrine/collections": { "doctrine/collections": {
"version": "v1.5.0" "version": "v1.5.0"
}, },
@@ -120,6 +129,21 @@
"ref": "3a6673f248f8fc1dd364dadfef4c5b381d1efab6" "ref": "3a6673f248f8fc1dd364dadfef4c5b381d1efab6"
} }
}, },
"phpunit/phpunit": {
"version": "13.1",
"recipe": {
"repo": "github.com/symfony/recipes",
"branch": "main",
"version": "11.1",
"ref": "ca0bc067abfb40a8de1b2561b96cbfc2b833c314"
},
"files": [
".env.test",
"phpunit.dist.xml",
"tests/bootstrap.php",
"bin/phpunit"
]
},
"psr/cache": { "psr/cache": {
"version": "1.0.1" "version": "1.0.1"
}, },
@@ -469,5 +493,18 @@
}, },
"zendframework/zend-eventmanager": { "zendframework/zend-eventmanager": {
"version": "3.2.1" "version": "3.2.1"
},
"zenstruck/foundry": {
"version": "2.9",
"recipe": {
"repo": "github.com/symfony/recipes",
"branch": "main",
"version": "2.7",
"ref": "9bd824886195ebe64a24438db1b6d17eb833ac56"
},
"files": [
"config/packages/zenstruck_foundry.yaml",
"src/Story/AppStory.php"
]
} }
} }

View File

@@ -2,7 +2,9 @@
{% macro stat_val(value, suffix) %} {% macro stat_val(value, suffix) %}
{%- set abbr = value >= 1000 -%} {%- set abbr = value >= 1000 -%}
<span class="profile-stat__value"{% if abbr %} title="{{ value }}"{% endif %}>{% if abbr %}{{ (value / 1000)|round(1, 'floor') }}k{% else %}{{ value }}{% endif %}{% if suffix %}<small>{{ suffix }}</small>{% endif %}</span> <span
class="profile-stat__value"{% if abbr %} title="{{ value }}"{% endif %}>{% if abbr %}{{ (value / 1000)|round(1, 'floor') }}k{% else %}{{ value }}{% endif %}{% if suffix %}
<small>{{ suffix }}</small>{% endif %}</span>
{% endmacro %} {% endmacro %}
{% block title %} - Profile{% endblock %} {% block title %} - Profile{% endblock %}
@@ -31,7 +33,7 @@
<div class="profile-header"> <div class="profile-header">
<div id="profile-avatar-root" <div id="profile-avatar-root"
data-upload-url="{{ path('MineSeekerBundle_profile_avatar') }}" data-upload-url="{{ path('MineSeekerBundle_profile_avatar') }}"
data-thumb-url="{{ app.user.avatarPath ? app.user.avatarPath|imagine_filter('avatar_thumb') : '' }}" data-thumb-url="{{ app.user.avatarPath ? path('liip_imagine_filter', {path: app.user.avatarPath, filter: 'avatar_thumb'}) : '' }}"
data-initials="{{ app.user.username|slice(0, 2)|upper }}"> data-initials="{{ app.user.username|slice(0, 2)|upper }}">
</div> </div>
<div class="profile-info"> <div class="profile-info">
@@ -138,7 +140,9 @@
and (my_points > 25 or opp_points > 25)) %} and (my_points > 25 or opp_points > 25)) %}
{% set is_anonymous = game.oppIsGuest %} {% set is_anonymous = game.oppIsGuest %}
<div class="profile-game profile-game--{{ result }}{% if not is_finished and not is_anonymous %} profile-game--ongoing{% elseif is_anonymous %} profile-game--abandoned{% endif %}{% if loop.index0 >= 5 %} profile-game--hidden{% endif %}" data-game-index="{{ loop.index0 }}"> <div
class="profile-game profile-game--{{ result }}{% if not is_finished and not is_anonymous %} profile-game--ongoing{% elseif is_anonymous %} profile-game--abandoned{% endif %}{% if loop.index0 >= 5 %} profile-game--hidden{% endif %}"
data-game-index="{{ loop.index0 }}">
<span class="profile-game__badge"> <span class="profile-game__badge">
{% if is_finished %} {% if is_finished %}
{{ result == 'win' ? 'Win' : (result == 'loss' ? 'Loss' : 'Draw') }} {{ result == 'win' ? 'Win' : (result == 'loss' ? 'Loss' : 'Draw') }}

View File

@@ -28,6 +28,20 @@
<link rel="icon" href="{{ asset('/images/favicon/favicon.ico') }}" type="image/x-icon"> <link rel="icon" href="{{ asset('/images/favicon/favicon.ico') }}" type="image/x-icon">
{% block metas %}{% endblock %} {% block metas %}{% endblock %}
<title>MineSeeker{% block title %}{% endblock %}</title> <title>MineSeeker{% block title %}{% endblock %}</title>
<script
defer src="https://umami.splendidbear.org/script.js"
data-website-id="825e02a9-d675-4cbd-9e68-72b98de2e4e9"
>
</script>
<script
defer
src="https://umami.splendidbear.org/recorder.js"
data-website-id="825e02a9-d675-4cbd-9e68-72b98de2e4e9"
data-sample-rate="0.15"
data-mask-level="moderate"
data-max-duration="300000"
>
</script>
{% block stylesheets %}{% endblock %} {% block stylesheets %}{% endblock %}
</head> </head>

View File

@@ -0,0 +1,145 @@
<?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\Tests\Controller;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\Attributes\TestDox;
use Symfony\Bundle\FrameworkBundle\KernelBrowser;
use App\Tests\WebTestCase;
use Symfony\Component\HttpFoundation\Response;
/**
* Class GameControllerTest
*
* @package App\Tests\Controller
* @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. 21.
*/
#[TestDox('Game Controller')]
class GameControllerTest extends WebTestCase
{
private KernelBrowser $client;
protected function setUp(): void
{
parent::setUp();
$this->client = static::createClient();
}
#[Test]
#[TestDox('Homepage loads successfully with navigation links')]
public function homepageLoadsSuccessfully(): void
{
$crawler = $this->client->request('GET', '/');
self::assertResponseStatusCodeSame(Response::HTTP_OK);
self::assertSelectorExists('h1');
self::assertSelectorExists('a[href="/play"]');
}
#[Test]
#[TestDox('Play page loads successfully')]
public function playPageLoadsSuccessfully(): void
{
$crawler = $this->client->request('GET', '/play');
self::assertResponseStatusCodeSame(Response::HTTP_OK);
self::assertResponseHasHeader('Content-Type');
}
#[Test]
#[TestDox('Play page with game association loads successfully')]
public function playPageWithGameAssocLoadsSuccessfully(): void
{
$crawler = $this->client->request('GET', '/play/testgame123');
self::assertResponseStatusCodeSame(Response::HTTP_OK);
self::assertResponseHasHeader('Content-Type');
}
#[Test]
#[TestDox('Privacy policy page loads successfully')]
public function privacyPolicyPageLoadsSuccessfully(): void
{
$crawler = $this->client->request('GET', '/privacy-policy');
self::assertResponseStatusCodeSame(Response::HTTP_OK);
self::assertSelectorExists('h1');
}
#[Test]
#[TestDox('Terms of service page loads successfully')]
public function termsOfServicePageLoadsSuccessfully(): void
{
$crawler = $this->client->request('GET', '/terms-of-service');
self::assertResponseStatusCodeSame(Response::HTTP_OK);
self::assertSelectorExists('h1');
}
#[Test]
#[TestDox('Contact page loads successfully with form')]
public function contactPageLoadsSuccessfully(): void
{
$crawler = $this->client->request('GET', '/contact');
self::assertResponseStatusCodeSame(Response::HTTP_OK);
self::assertSelectorExists('form');
self::assertSelectorExists('button[type="submit"]');
}
#[Test]
#[TestDox('Landing page loads successfully with play link')]
public function landingPageLoadsSuccessfully(): void
{
$crawler = $this->client->request('GET', '/landing-page');
self::assertResponseStatusCodeSame(Response::HTTP_OK);
self::assertSelectorExists('h1');
self::assertSelectorExists('a[href="/play"]');
}
#[Test]
#[TestDox('Rules page loads successfully')]
public function rulesPageLoadsSuccessfully(): void
{
$crawler = $this->client->request('GET', '/rules');
self::assertResponseStatusCodeSame(Response::HTTP_OK);
self::assertSelectorExists('h1');
}
#[Test]
#[TestDox('Homepage contains navigation links to play, login, and register')]
public function homepageContainsNavigationLinks(): void
{
$crawler = $this->client->request('GET', '/');
self::assertResponseIsSuccessful();
self::assertSelectorExists('a[href="/play"]');
self::assertSelectorExists('a[href="/login"]');
self::assertSelectorExists('a[href="/register"]');
}
#[Test]
#[TestDox('Play page has correct meta tags for SEO and social sharing')]
public function playPageHasCorrectMetaTags(): void
{
$this->client->request('GET', '/play');
self::assertResponseIsSuccessful();
self::assertSelectorExists('meta[name="description"]');
self::assertSelectorExists('meta[property="og:title"]');
}
}

View File

@@ -0,0 +1,146 @@
<?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\Tests\Controller;
use App\Tests\Factory\PlayedGameFactory;
use App\Tests\Factory\UserFactory;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\Attributes\TestDox;
use Symfony\Bundle\FrameworkBundle\KernelBrowser;
use App\Tests\WebTestCase;
use Symfony\Component\HttpFoundation\Response;
/**
* Class ProfileControllerTest
*
* @package App\Tests\Controller
* @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. 21.
*/
#[TestDox('Profile Controller')]
class ProfileControllerTest extends WebTestCase
{
private KernelBrowser $client;
protected function setUp(): void
{
parent::setUp();
$this->client = static::createClient();
}
#[Test]
#[TestDox('Profile page requires authentication')]
public function profilePageRequiresAuthentication(): void
{
$this->client->request('GET', '/profile');
self::assertResponseRedirects('/login');
}
#[Test]
#[TestDox('Profile security page requires authentication')]
public function profileSecurityPageRequiresAuthentication(): void
{
$this->client->request('GET', '/profile/security');
self::assertResponseRedirects('/login');
}
#[Test]
#[TestDox('Profile avatar upload requires authentication')]
public function profileAvatarPageRequiresAuthentication(): void
{
$this->client->request('POST', '/profile/avatar');
self::assertResponseRedirects('/login');
}
#[Test]
#[TestDox('Battle share page returns 404 for non-existent game')]
public function battleSharePageReturns404ForNonExistentGame(): void
{
$uuid = '00000000-0000-4000-a000-000000000000';
$this->client->request('GET', '/battle/' . $uuid);
self::assertResponseStatusCodeSame(Response::HTTP_NOT_FOUND);
}
#[Test]
#[TestDox('Battle share page displays valid game details')]
public function battleSharePageShowsValidGame(): void
{
$game = PlayedGameFactory::new()
->withRegisteredPlayers()
->redWins()
->create();
$crawler = $this->client->request('GET', '/battle/' . $game->uuid);
self::assertResponseIsSuccessful();
self::assertSelectorExists('h1');
self::assertGreaterThan(0, $crawler->filter('body')->count());
}
#[Test]
#[TestDox('Battle share page has Open Graph meta tags for social sharing')]
public function battleSharePageHasOgMetaTags(): void
{
$game = PlayedGameFactory::new()
->withRegisteredPlayers()
->redWins()
->create();
$this->client->request('GET', '/battle/' . $game->uuid);
self::assertResponseIsSuccessful();
self::assertSelectorExists('meta[property="og:title"]');
self::assertSelectorExists('meta[property="og:image"]');
self::assertSelectorExists('meta[property="og:description"]');
self::assertSelectorExists('meta[property="og:type"]');
}
#[Test]
#[TestDox('OG battle image returns 404 for non-existent game')]
public function ogBattleImageReturns404ForNonExistentGame(): void
{
$uuid = '00000000-0000-4000-a000-000000000000';
$this->client->request('GET', '/og/battle/' . $uuid . '.png');
self::assertResponseStatusCodeSame(Response::HTTP_NOT_FOUND);
}
#[Test]
#[TestDox('OG battle image generates valid PNG for existing game')]
public function ogBattleImageReturnsImageForValidGame(): void
{
$game = PlayedGameFactory::new()
->withRegisteredPlayers()
->redWins()
->create();
$this->client->request('GET', '/og/battle/' . $game->uuid . '.png');
self::assertResponseIsSuccessful();
self::assertResponseHeaderSame('Content-Type', 'image/png');
}
#[Test]
#[TestDox('Battle share page returns 404 for invalid UUID format')]
public function battleSharePageWithInvalidUuidFormat(): void
{
$this->client->request('GET', '/battle/invalid-uuid');
self::assertResponseStatusCodeSame(Response::HTTP_NOT_FOUND);
}
}

View File

@@ -0,0 +1,148 @@
<?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\Tests\Controller;
use App\Tests\Factory\UserFactory;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\Attributes\TestDox;
use Symfony\Bundle\FrameworkBundle\KernelBrowser;
use App\Tests\WebTestCase;
use Symfony\Component\HttpFoundation\Response;
/**
* Class SecurityControllerTest
*
* @package App\Tests\Controller
* @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. 21.
*/
#[TestDox('Security Controller')]
class SecurityControllerTest extends WebTestCase
{
private KernelBrowser $client;
protected function setUp(): void
{
parent::setUp();
$this->client = static::createClient();
}
#[Test]
#[TestDox('Login page loads successfully with form fields')]
public function loginPageLoadsSuccessfully(): void
{
$crawler = $this->client->request('GET', '/login');
self::assertResponseStatusCodeSame(Response::HTTP_OK);
self::assertSelectorExists('form');
self::assertSelectorExists('input[name="_username"]');
self::assertSelectorExists('input[name="_password"]');
self::assertSelectorExists('button[type="submit"]');
}
#[Test]
#[TestDox('Login page has links to register and forgot password')]
public function loginPageHasRegisterLink(): void
{
$crawler = $this->client->request('GET', '/login');
self::assertResponseIsSuccessful();
self::assertSelectorExists('a[href="/register"]');
self::assertSelectorExists('a[href="/forgot-password"]');
}
#[Test]
#[TestDox('Register page loads successfully with form')]
public function registerPageLoadsSuccessfully(): void
{
$crawler = $this->client->request('GET', '/register');
self::assertResponseStatusCodeSame(Response::HTTP_OK);
self::assertSelectorExists('form');
self::assertSelectorExists('button[type="submit"]');
}
#[Test]
#[TestDox('Register page has link to login')]
public function registerPageHasLoginLink(): void
{
$crawler = $this->client->request('GET', '/register');
self::assertResponseIsSuccessful();
self::assertSelectorExists('a[href="/login"]');
}
#[Test]
#[TestDox('Forgot password page loads successfully with form')]
public function forgotPasswordPageLoadsSuccessfully(): void
{
$crawler = $this->client->request('GET', '/forgot-password');
self::assertResponseStatusCodeSame(Response::HTTP_OK);
self::assertSelectorExists('form');
self::assertSelectorExists('button[type="submit"]');
}
#[Test]
#[TestDox('Account activation with invalid token redirects to login')]
public function activateWithInvalidTokenShowsError(): void
{
$this->client->request('GET', '/activate/invalid-token');
self::assertResponseRedirects('/login');
}
#[Test]
#[TestDox('Password reset with invalid token redirects to forgot password')]
public function resetPasswordWithInvalidTokenShowsError(): void
{
$this->client->request('GET', '/reset-password/invalid-token');
self::assertResponseRedirects('/forgot-password');
}
#[Test]
#[TestDox('Authenticated user is redirected from login page to homepage')]
public function authenticatedUserRedirectsFromLogin(): void
{
$user = UserFactory::createOne();
$this->client->loginUser($user->_real());
$this->client->request('GET', '/login');
self::assertResponseRedirects('/');
}
#[Test]
#[TestDox('Authenticated user is redirected from register page to homepage')]
public function authenticatedUserRedirectsFromRegister(): void
{
$user = UserFactory::createOne();
$this->client->loginUser($user->_real());
$this->client->request('GET', '/register');
self::assertResponseRedirects('/');
}
#[Test]
#[TestDox('Login form has remember me checkbox')]
public function loginFormHasRememberMeCheckbox(): void
{
$crawler = $this->client->request('GET', '/login');
self::assertResponseIsSuccessful();
self::assertSelectorExists('input[name="_remember_me"]');
}
}

View File

@@ -0,0 +1,89 @@
<?php declare(strict_types=1);
/**
* This file is part of the SplendidBear Websites' projects.
*
* Copyright (c) 2026 @ www.splendidbear.org
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Tests\Dto;
use App\Dto\ProfileChartDataDto;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\Attributes\TestDox;
use PHPUnit\Framework\TestCase;
/**
* Class ProfileChartDataDtoTest
*
* @package App\Tests\Dto
* @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. 21.
*/
#[TestDox('Profile Chart Data Dto')]
class ProfileChartDataDtoTest extends TestCase
{
#[Test]
#[TestDox('Json serialize returns all properties')]
public function jsonSerializeReturnsAllProperties(): void
{
$months = ['Jan', 'Feb', 'Mar'];
$wins = [5, 8, 10];
$losses = [2, 3, 4];
$draws = [1, 0, 1];
$recentGames = [
['id' => 1, 'result' => 'win'],
['id' => 2, 'result' => 'loss'],
];
$dto = new ProfileChartDataDto(
months: $months,
wins: $wins,
losses: $losses,
draws: $draws,
pieWins: 23,
pieLosses: 9,
pieDraws: 2,
recentGames: $recentGames,
);
$json = $dto->jsonSerialize();
$this->assertSame($months, $json['months']);
$this->assertSame($wins, $json['wins']);
$this->assertSame($losses, $json['losses']);
$this->assertSame($draws, $json['draws']);
$this->assertSame(23, $json['pieWins']);
$this->assertSame(9, $json['pieLosses']);
$this->assertSame(2, $json['pieDraws']);
$this->assertSame($recentGames, $json['recentGames']);
}
#[Test]
#[TestDox('Constructor with empty arrays')]
public function constructorWithEmptyArrays(): void
{
$dto = new ProfileChartDataDto(
months: [],
wins: [],
losses: [],
draws: [],
pieWins: 0,
pieLosses: 0,
pieDraws: 0,
recentGames: [],
);
$json = $dto->jsonSerialize();
$this->assertSame([], $json['months']);
$this->assertSame([], $json['wins']);
$this->assertSame([], $json['recentGames']);
$this->assertSame(0, $json['pieWins']);
}
}

View File

@@ -0,0 +1,126 @@
<?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\Tests\Dto;
use App\Dto\ProfileGameDto;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\Attributes\TestDox;
use PHPUnit\Framework\TestCase;
/**
* Class ProfileGameDtoTest
*
* @package App\Tests\Dto
* @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. 21.
*/
#[TestDox('Profile Game Dto')]
class ProfileGameDtoTest extends TestCase
{
#[Test]
#[TestDox('Json serialize returns all properties')]
public function jsonSerializeReturnsAllProperties(): void
{
$dto = new ProfileGameDto(
id: 1,
uuid: '550e8400-e29b-41d4-a716-446655440000',
redName: 'RedPlayer',
blueName: 'BluePlayer',
redAvatar: '/uploads/avatars/red.png',
blueAvatar: '/uploads/avatars/blue.png',
redPoints: 10,
bluePoints: 8,
redExplodedBomb: false,
blueExplodedBomb: true,
resign: null,
created: '2026-04-20 10:00',
date: '2026-04-20 10:30',
isRed: true,
result: 'win',
myPoints: 10,
oppPoints: 8,
redBonusPoints: 5.5,
blueBonusPoints: 2.0,
redBonusStats: ['blindHits' => 2, 'chainBest' => 3],
blueBonusStats: ['blindHits' => 1, 'chainBest' => 2],
bothRegistered: true,
);
$json = $dto->jsonSerialize();
$this->assertSame(1, $json['id']);
$this->assertSame('550e8400-e29b-41d4-a716-446655440000', $json['uuid']);
$this->assertSame('RedPlayer', $json['redName']);
$this->assertSame('BluePlayer', $json['blueName']);
$this->assertSame('/uploads/avatars/red.png', $json['redAvatar']);
$this->assertSame('/uploads/avatars/blue.png', $json['blueAvatar']);
$this->assertSame(10, $json['redPoints']);
$this->assertSame(8, $json['bluePoints']);
$this->assertFalse($json['redExplodedBomb']);
$this->assertTrue($json['blueExplodedBomb']);
$this->assertNull($json['resign']);
$this->assertSame('2026-04-20 10:00', $json['created']);
$this->assertSame('2026-04-20 10:30', $json['date']);
$this->assertTrue($json['isRed']);
$this->assertSame('win', $json['result']);
$this->assertSame(10, $json['myPoints']);
$this->assertSame(8, $json['oppPoints']);
$this->assertSame(5.5, $json['redBonusPoints']);
$this->assertSame(2.0, $json['blueBonusPoints']);
$this->assertSame(['blindHits' => 2, 'chainBest' => 3], $json['redBonusStats']);
$this->assertSame(['blindHits' => 1, 'chainBest' => 2], $json['blueBonusStats']);
$this->assertTrue($json['bothRegistered']);
}
#[Test]
#[TestDox('Json serialize with null values')]
public function jsonSerializeWithNullValues(): void
{
$dto = new ProfileGameDto(
id: null,
uuid: null,
redName: 'Guest',
blueName: 'Guest',
redAvatar: null,
blueAvatar: null,
redPoints: null,
bluePoints: null,
redExplodedBomb: null,
blueExplodedBomb: null,
resign: null,
created: null,
date: null,
isRed: false,
result: 'draw',
myPoints: null,
oppPoints: null,
redBonusPoints: 0.0,
blueBonusPoints: 0.0,
redBonusStats: [],
blueBonusStats: [],
bothRegistered: false,
);
$json = $dto->jsonSerialize();
$this->assertNull($json['id']);
$this->assertNull($json['uuid']);
$this->assertNull($json['redAvatar']);
$this->assertNull($json['blueAvatar']);
$this->assertNull($json['redPoints']);
$this->assertNull($json['bluePoints']);
$this->assertSame('draw', $json['result']);
$this->assertFalse($json['bothRegistered']);
}
}

View File

@@ -0,0 +1,134 @@
<?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\Tests\Dto;
use App\Dto\ProfileStatsDto;
use App\Entity\UserStats;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\Attributes\TestDox;
use PHPUnit\Framework\TestCase;
/**
* Class ProfileStatsDtoTest
*
* @package App\Tests\Dto
* @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. 21.
*/
#[TestDox('Profile Stats Dto')]
class ProfileStatsDtoTest extends TestCase
{
#[Test]
#[TestDox('From user stats with valid stats')]
public function fromUserStatsWithValidStats(): void
{
$userStats = new UserStats();
$userStats->userId = 1;
$userStats->totalGames = 100;
$userStats->wins = 60;
$userStats->losses = 30;
$userStats->draws = 10;
$userStats->totalMines = 500;
$userStats->totalBonusPoints = '150.5';
$userStats->avgBonus = '2.5';
$userStats->bestChain = 5;
$userStats->blindHits = 20;
$userStats->edgeMines = 15;
$userStats->gamesWithScores = 90;
$dto = ProfileStatsDto::fromUserStats($userStats);
$this->assertSame(100, $dto->total);
$this->assertSame(60, $dto->wins);
$this->assertSame(30, $dto->losses);
$this->assertSame(10, $dto->draws);
$this->assertSame(500, $dto->minesHit);
$this->assertSame(67, $dto->winRate); // 60/90 * 100 = 67
$this->assertSame(6, $dto->avgScore); // 500/90 = 5.56 -> 6
$this->assertSame(150.5, $dto->bonusPoints);
$this->assertSame(2.5, $dto->avgBonus);
$this->assertSame(5, $dto->bestChain);
$this->assertSame(20, $dto->blindHits);
$this->assertSame(15, $dto->edgeMines);
}
#[Test]
#[TestDox('From user stats with null returns empty')]
public function fromUserStatsWithNullReturnsEmpty(): void
{
$dto = ProfileStatsDto::fromUserStats(null);
$this->assertSame(0, $dto->total);
$this->assertSame(0, $dto->wins);
$this->assertSame(0, $dto->losses);
$this->assertSame(0, $dto->draws);
$this->assertSame(0, $dto->minesHit);
$this->assertSame(0, $dto->winRate);
$this->assertSame(0, $dto->avgScore);
$this->assertSame(0.0, $dto->bonusPoints);
$this->assertSame(0.0, $dto->avgBonus);
$this->assertSame(0, $dto->bestChain);
$this->assertSame(0, $dto->blindHits);
$this->assertSame(0, $dto->edgeMines);
}
#[Test]
#[TestDox('Empty returns default values')]
public function emptyReturnsDefaultValues(): void
{
$dto = ProfileStatsDto::empty();
$this->assertSame(0, $dto->total);
$this->assertSame(0, $dto->wins);
$this->assertSame(0, $dto->losses);
$this->assertSame(0, $dto->draws);
$this->assertSame(0, $dto->minesHit);
$this->assertSame(0, $dto->winRate);
$this->assertSame(0, $dto->avgScore);
$this->assertSame(0.0, $dto->bonusPoints);
$this->assertSame(0.0, $dto->avgBonus);
$this->assertSame(0, $dto->bestChain);
$this->assertSame(0, $dto->blindHits);
$this->assertSame(0, $dto->edgeMines);
}
#[Test]
#[TestDox('Win rate calculation with no games with scores')]
public function winRateCalculationWithNoGamesWithScores(): void
{
$userStats = new UserStats();
$userStats->totalGames = 10;
$userStats->wins = 5;
$userStats->losses = 5;
$userStats->draws = 0;
$userStats->gamesWithScores = 0;
$dto = ProfileStatsDto::fromUserStats($userStats);
$this->assertSame(0, $dto->winRate);
}
#[Test]
#[TestDox('Avg score calculation with no games with scores')]
public function avgScoreCalculationWithNoGamesWithScores(): void
{
$userStats = new UserStats();
$userStats->totalMines = 100;
$userStats->gamesWithScores = 0;
$dto = ProfileStatsDto::fromUserStats($userStats);
$this->assertSame(0, $dto->avgScore);
}
}

View File

@@ -0,0 +1,142 @@
<?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\Tests\Entity;
use App\Entity\UserStats;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\Attributes\TestDox;
use PHPUnit\Framework\TestCase;
/**
* Class UserStatsTest
*
* @package App\Tests\Entity
* @author Lang <https://www.splendidbear.org>
* @category Class
* @license https://www.gnu.org/licenses/lgpl-3.0.en.html GNU Lesser General Public License
* @link www.splendidbear.org
* @since 2026. 04. 21.
*/
#[TestDox('User Stats')]
class UserStatsTest extends TestCase
{
#[Test]
#[TestDox('Get win rate calculates correctly')]
public function getWinRateCalculatesCorrectly(): void
{
$stats = new UserStats();
$stats->wins = 60;
$stats->gamesWithScores = 100;
$result = $stats->getWinRate();
$this->assertSame(60, $result);
}
#[Test]
#[TestDox('Get win rate with zero games returns zero')]
public function getWinRateWithZeroGamesReturnsZero(): void
{
$stats = new UserStats();
$stats->wins = 10;
$stats->gamesWithScores = 0;
$result = $stats->getWinRate();
$this->assertSame(0, $result);
}
#[Test]
#[TestDox('Get win rate rounds correctly')]
public function getWinRateRoundsCorrectly(): void
{
$stats = new UserStats();
$stats->wins = 33;
$stats->gamesWithScores = 100;
$result = $stats->getWinRate();
$this->assertSame(33, $result);
}
#[Test]
#[TestDox('Get avg score calculates correctly')]
public function getAvgScoreCalculatesCorrectly(): void
{
$stats = new UserStats();
$stats->totalMines = 550;
$stats->gamesWithScores = 100;
$result = $stats->getAvgScore();
$this->assertSame(6, $result); // 550/100 = 5.5 -> 6
}
#[Test]
#[TestDox('Get avg score with zero games returns zero')]
public function getAvgScoreWithZeroGamesReturnsZero(): void
{
$stats = new UserStats();
$stats->totalMines = 100;
$stats->gamesWithScores = 0;
$result = $stats->getAvgScore();
$this->assertSame(0, $result);
}
#[Test]
#[TestDox('Get avg score rounds down')]
public function getAvgScoreRoundsDown(): void
{
$stats = new UserStats();
$stats->totalMines = 101;
$stats->gamesWithScores = 100;
$result = $stats->getAvgScore();
$this->assertSame(1, $result); // 101/100 = 1.01 -> 1
}
#[Test]
#[TestDox('Get avg score rounds up')]
public function getAvgScoreRoundsUp(): void
{
$stats = new UserStats();
$stats->totalMines = 151;
$stats->gamesWithScores = 100;
$result = $stats->getAvgScore();
$this->assertSame(2, $result); // 151/100 = 1.51 -> 2
}
#[Test]
#[TestDox('Default values')]
public function defaultValues(): void
{
$stats = new UserStats();
$this->assertSame(0, $stats->userId);
$this->assertSame(0, $stats->totalGames);
$this->assertSame(0, $stats->wins);
$this->assertSame(0, $stats->losses);
$this->assertSame(0, $stats->draws);
$this->assertSame(0, $stats->totalMines);
$this->assertSame('0.0', $stats->totalBonusPoints);
$this->assertSame('0.0', $stats->avgBonus);
$this->assertSame(0, $stats->bestChain);
$this->assertSame(0, $stats->blindHits);
$this->assertSame(0, $stats->edgeMines);
$this->assertSame(0, $stats->gamesWithScores);
$this->assertNull($stats->lastGameAt);
}
}

View File

@@ -0,0 +1,55 @@
<?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\Tests\Factory;
use App\Entity\ContactMessage;
use Zenstruck\Foundry\Persistence\PersistentProxyObjectFactory;
/**
* Class ContactMessageFactory
*
* @package App\Tests\Factory
* @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. 21.
*
* @extends PersistentProxyObjectFactory<ContactMessage>
*/
class ContactMessageFactory extends PersistentProxyObjectFactory
{
protected function defaults(): array
{
return [
'name' => self::faker()->name(),
'email' => self::faker()->safeEmail(),
'content' => self::faker()->paragraph(3),
'consent' => true,
'ipAddress' => self::faker()->ipv4(),
];
}
public static function class(): string
{
return ContactMessage::class;
}
public function withoutConsent(): self
{
return $this->with(['consent' => false]);
}
public function anonymous(): self
{
return $this->with(['ipAddress' => null]);
}
}

View File

@@ -0,0 +1,51 @@
<?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\Tests\Factory;
use App\Entity\Gamer;
use DateTime;
use Zenstruck\Foundry\Persistence\PersistentProxyObjectFactory;
/**
* Class GamerFactory
*
* @package App\Tests\Factory
* @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. 21.
*
* @extends PersistentProxyObjectFactory<Gamer>
*/
class GamerFactory extends PersistentProxyObjectFactory
{
protected function defaults(): array
{
return [
'userName' => self::faker()->userName(),
'ip' => self::faker()->ipv4(),
'country' => self::faker()->countryCode(),
'userAgent' => self::faker()->userAgent(),
'connTimestamp' => new DateTime(),
];
}
public static function class(): string
{
return Gamer::class;
}
public function anonymous(): self
{
return $this->with(['userName' => sprintf('Guest_%d', self::faker()->randomNumber(5))]);
}
}

View File

@@ -0,0 +1,53 @@
<?php declare(strict_types=1);
/**
* This file is part of the SplendidBear Websites' projects.
*
* Copyright (c) 2026 @ www.splendidbear.org
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Tests\Factory;
use App\Entity\Grid;
use App\Entity\GridRow;
use Zenstruck\Foundry\Persistence\PersistentProxyObjectFactory;
/**
* Class GridFactory
*
* @package App\Tests\Factory
* @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. 21.
*
* @extends PersistentProxyObjectFactory<Grid>
*/
class GridFactory extends PersistentProxyObjectFactory
{
protected function defaults(): array
{
return [
'playedGame' => PlayedGameFactory::new(),
];
}
public static function class(): string
{
return Grid::class;
}
protected function initialize(): static
{
return $this->afterInstantiate(function (Grid $grid): void {
for ($i = 0; $i < 16; $i++) {
/** @var GridRow $factory */
$factory = GridRowFactory::new()->create()->_real();
$grid->addGridRow($factory);
}
});
}
}

View File

@@ -0,0 +1,52 @@
<?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\Tests\Factory;
use App\Entity\GridRow;
use Zenstruck\Foundry\Persistence\PersistentProxyObjectFactory;
/**
* Class GridRowFactory
*
* @package App\Tests\Factory
* @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. 21.
*
* @extends PersistentProxyObjectFactory<GridRow>
*/
class GridRowFactory extends PersistentProxyObjectFactory
{
protected function defaults(): array
{
$columns = [];
for ($i = 0; $i < 16; $i++) {
$columns[] = self::faker()->numberBetween(0, 8);
}
return [
'gridCol' => $columns,
];
}
public static function class(): string
{
return GridRow::class;
}
public function withColumns(array $columns): self
{
return $this->with(['gridCol' => $columns]);
}
}

View File

@@ -0,0 +1,108 @@
<?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\Tests\Factory;
use App\Entity\PlayedGame;
use DateTime;
use Symfony\Component\Uid\Uuid;
use Zenstruck\Foundry\Persistence\PersistentProxyObjectFactory;
/**
* Class PlayedGameFactory
*
* @package App\Tests\Factory
* @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. 21.
*
* @extends PersistentProxyObjectFactory<PlayedGame>
*/
class PlayedGameFactory extends PersistentProxyObjectFactory
{
protected function defaults(): array
{
return [
'uuid' => Uuid::v4(),
'gameAssoc' => self::faker()->uuid(),
'redPoints' => self::faker()->numberBetween(0, 51),
'bluePoints' => self::faker()->numberBetween(0, 51),
'redExplodedBomb' => false,
'blueExplodedBomb' => false,
'resign' => null,
'redBonusPoints' => self::faker()->randomFloat(2, 0, 100),
'blueBonusPoints' => self::faker()->randomFloat(2, 0, 100),
'redBonusStats' => null,
'blueBonusStats' => null,
'created' => new DateTime(),
'updated' => new DateTime(),
];
}
public static function class(): string
{
return PlayedGame::class;
}
public function withRegisteredPlayers(): self
{
return $this->with([
'red' => UserFactory::new(),
'blue' => UserFactory::new(),
]);
}
public function withAnonymousPlayers(): self
{
return $this->with([
'redAnon' => GamerFactory::new(),
'blueAnon' => GamerFactory::new(),
]);
}
public function withMixedPlayers(): self
{
return $this->with([
'red' => UserFactory::new(),
'blueAnon' => GamerFactory::new(),
]);
}
public function finished(): self
{
return $this->with([
'redPoints' => 26,
'bluePoints' => 25,
]);
}
public function redWins(): self
{
return $this->with([
'redPoints' => 26,
'bluePoints' => self::faker()->numberBetween(0, 25),
]);
}
public function blueWins(): self
{
return $this->with([
'redPoints' => self::faker()->numberBetween(0, 25),
'bluePoints' => 26,
]);
}
public function resigned(string $player): self
{
return $this->with(['resign' => $player]);
}
}

View File

@@ -0,0 +1,68 @@
<?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\Tests\Factory;
use App\Entity\Step;
use DateTime;
use Zenstruck\Foundry\Persistence\PersistentProxyObjectFactory;
/**
* Class StepFactory
*
* @package App\Tests\Factory
* @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. 21.
*
* @extends PersistentProxyObjectFactory<Step>
*/
class StepFactory extends PersistentProxyObjectFactory
{
protected function defaults(): array
{
return [
'row' => self::faker()->numberBetween(0, 15),
'col' => self::faker()->numberBetween(0, 15),
'wBomb' => self::faker()->boolean(),
'player' => self::faker()->randomElement(['red', 'blue']),
'revealedCells' => null,
'playedGame' => PlayedGameFactory::new(),
'created' => new DateTime(),
];
}
public static function class(): string
{
return Step::class;
}
public function mine(): self
{
return $this->with(['wBomb' => true]);
}
public function safe(): self
{
return $this->with(['wBomb' => false]);
}
public function forPlayer(string $player): self
{
return $this->with(['player' => $player]);
}
public function withRevealedCells(array $cells): self
{
return $this->with(['revealedCells' => $cells]);
}
}

View File

@@ -0,0 +1,60 @@
<?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\Tests\Factory;
use App\Entity\User;
use Zenstruck\Foundry\Persistence\PersistentProxyObjectFactory;
/**
* Class UserFactory
*
* @package App\Tests\Factory
* @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. 21.
*
* @extends PersistentProxyObjectFactory<User>
*/
class UserFactory extends PersistentProxyObjectFactory
{
protected function defaults(): array
{
return [
'username' => self::faker()->unique()->userName(),
'email' => self::faker()->unique()->safeEmail(),
'password' => 'hashedpassword',
'isVerified' => true,
'roles' => [],
];
}
public static function class(): string
{
return User::class;
}
public function withPassword(string $hashedPassword): self
{
return $this->with(['password' => $hashedPassword]);
}
public function unverified(): self
{
return $this->with(['isVerified' => false]);
}
public function withRoles(array $roles): self
{
return $this->with(['roles' => $roles]);
}
}

View File

@@ -0,0 +1,62 @@
<?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\Tests\Factory;
use App\Entity\WebAuthnCredential;
use DateTime;
use Zenstruck\Foundry\Persistence\PersistentProxyObjectFactory;
/**
* Class WebAuthnCredentialFactory
*
* @package App\Tests\Factory
* @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. 21.
*
* @extends PersistentProxyObjectFactory<WebAuthnCredential>
*/
class WebAuthnCredentialFactory extends PersistentProxyObjectFactory
{
protected function defaults(): array
{
return [
'user' => UserFactory::new(),
'credentialData' => json_encode([
'type' => 'public-key',
'id' => base64_encode(self::faker()->uuid()),
'transports' => ['usb', 'nfc'],
], JSON_THROW_ON_ERROR),
'credentialName' => self::faker()->words(3, true),
'createdAt' => new DateTime(),
'lastUsedAt' => null,
'isBackupEligible' => self::faker()->boolean(),
'isBackupAuthenticated' => self::faker()->boolean(),
];
}
public static function class(): string
{
return WebAuthnCredential::class;
}
public function withName(string $name): self
{
return $this->with(['credentialName' => $name]);
}
public function recentlyUsed(): self
{
return $this->with(['lastUsedAt' => new DateTime()]);
}
}

View File

@@ -0,0 +1,211 @@
<?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\Tests\Integration;
use App\Entity\PlayedGame;
use App\Entity\User;
use App\Tests\Factory\ContactMessageFactory;
use App\Tests\Factory\GamerFactory;
use App\Tests\Factory\GridFactory;
use App\Tests\Factory\PlayedGameFactory;
use App\Tests\Factory\StepFactory;
use App\Tests\Factory\UserFactory;
use App\Tests\Factory\WebAuthnCredentialFactory;
use App\Tests\WebTestCase;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\Attributes\TestDox;
/**
* Class FactoryExampleTest
*
* Example test demonstrating Foundry factory usage
*
* @package App\Tests\Integration
* @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. 21.
*/
#[TestDox('Factory Examples')]
class FactoryExampleTest extends WebTestCase
{
#[Test]
#[TestDox('Creates a verified user with UserFactory')]
public function createUser(): void
{
$user = UserFactory::createOne();
self::assertInstanceOf(User::class, $user->_real());
self::assertNotNull($user->username);
self::assertNotNull($user->email);
self::assertTrue($user->isVerified);
}
#[Test]
#[TestDox('Creates an unverified user')]
public function createUnverifiedUser(): void
{
$user = UserFactory::createOne(['isVerified' => false]);
self::assertFalse($user->isVerified);
}
#[Test]
#[TestDox('Creates multiple users at once')]
public function createMultipleUsers(): void
{
UserFactory::createMany(5);
self::assertCount(5, UserFactory::repository()->findAll());
}
#[Test]
#[TestDox('Creates an anonymous gamer with guest username')]
public function createAnonymousGamer(): void
{
$gamer = GamerFactory::new()->anonymous()->create();
self::assertStringStartsWith('Guest_', $gamer->userName);
self::assertNotNull($gamer->ip);
self::assertNotNull($gamer->connTimestamp);
}
#[Test]
#[TestDox('Creates a game with two registered players')]
public function createGameWithRegisteredPlayers(): void
{
$game = PlayedGameFactory::new()
->withRegisteredPlayers()
->create();
self::assertInstanceOf(PlayedGame::class, $game->_real());
self::assertNotNull($game->red);
self::assertNotNull($game->blue);
self::assertNull($game->redAnon);
self::assertNull($game->blueAnon);
}
#[Test]
#[TestDox('Creates a game with two anonymous players')]
public function createGameWithAnonymousPlayers(): void
{
$game = PlayedGameFactory::new()
->withAnonymousPlayers()
->create();
self::assertNull($game->red);
self::assertNull($game->blue);
self::assertNotNull($game->redAnon);
self::assertNotNull($game->blueAnon);
}
#[Test]
#[TestDox('Creates a finished game where red player wins')]
public function createFinishedGame(): void
{
$game = PlayedGameFactory::new()
->withRegisteredPlayers()
->redWins()
->create();
self::assertEquals(26, $game->redPoints);
self::assertLessThan(26, $game->bluePoints);
}
#[Test]
#[TestDox('Creates a game with multiple steps from both players')]
public function createGameWithSteps(): void
{
$game = PlayedGameFactory::new()
->withRegisteredPlayers()
->create();
StepFactory::new()
->forPlayer('red')
->mine()
->create(['playedGame' => $game]);
StepFactory::new()
->forPlayer('red')
->mine()
->create(['playedGame' => $game]);
StepFactory::new()
->forPlayer('red')
->mine()
->create(['playedGame' => $game]);
StepFactory::new()
->forPlayer('blue')
->safe()
->create(['playedGame' => $game]);
StepFactory::new()
->forPlayer('blue')
->safe()
->create(['playedGame' => $game]);
self::assertCount(5, StepFactory::repository()->findAll());
}
#[Test]
#[TestDox('Creates a game with a 16x16 grid')]
public function createGameWithGrid(): void
{
$game = PlayedGameFactory::new()
->withRegisteredPlayers()
->create();
$grid = GridFactory::createOne(['playedGame' => $game]);
self::assertCount(16, $grid->gridRow);
}
#[Test]
#[TestDox('Creates a WebAuthn credential for a user')]
public function createWebAuthnCredential(): void
{
$user = UserFactory::createOne();
$credential = WebAuthnCredentialFactory::new()
->withName('YubiKey 5C')
->recentlyUsed()
->create(['user' => $user]);
self::assertEquals($user->_real(), $credential->user);
self::assertEquals('YubiKey 5C', $credential->credentialName);
self::assertNotNull($credential->lastUsedAt);
}
#[Test]
#[TestDox('Creates a contact message with consent')]
public function createContactMessage(): void
{
$message = ContactMessageFactory::createOne();
self::assertNotNull($message->name);
self::assertNotNull($message->email);
self::assertNotNull($message->content);
self::assertTrue($message->consent);
self::assertNotNull($message->createdAt);
}
#[Test]
#[TestDox('Tests are isolated - database is reset between tests')]
public function databaseIsolation(): void
{
UserFactory::createMany(3);
self::assertCount(3, UserFactory::repository()->findAll());
/** Database will be reset before next test due to ResetDatabase trait */
}
}

108
tests/README.md Normal file
View File

@@ -0,0 +1,108 @@
# Tests Directory
This directory contains all test files for the MineSeeker project.
## Quick Start
```bash
# Run all tests (recommended)
make test
# Or with PHPUnit directly:
vendor/bin/phpunit
# Run specific test file
vendor/bin/phpunit tests/Controller/ProfileControllerTest.php
# Run with filter
vendor/bin/phpunit --filter testCreateUser
```
## Documentation
For comprehensive testing documentation, see:
- **[Testing Guide](../docs/testing/TESTING.md)** - Complete testing setup, best practices, troubleshooting
- **[Factory Documentation](../docs/testing/FACTORIES.md)** - Detailed factory API reference
## Directory Structure
```
tests/
├── Controller/ # HTTP endpoint tests
├── Dto/ # Data Transfer Object tests
├── Entity/ # Entity logic tests
├── Service/ # Service layer tests
├── Integration/ # Integration tests (example: FactoryExampleTest.php)
├── Factory/ # Foundry factory classes
│ ├── UserFactory.php
│ ├── GamerFactory.php
│ ├── PlayedGameFactory.php
│ ├── StepFactory.php
│ ├── GridFactory.php
│ ├── GridRowFactory.php
│ ├── WebAuthnCredentialFactory.php
│ └── ContactMessageFactory.php
├── WebTestCase.php # Base test class (extends this!)
├── bootstrap.php # PHPUnit bootstrap
└── README.md # This file
```
## Quick Factory Examples
```php
use App\Tests\Factory\UserFactory;
use App\Tests\Factory\PlayedGameFactory;
use App\Tests\WebTestCase;
class MyTest extends WebTestCase
{
public function testExample(): void
{
/** Create user */
$user = UserFactory::createOne();
/** Create game with registered players */
$game = PlayedGameFactory::new()
->withRegisteredPlayers()
->redWins()
->create();
/** Create multiple entities */
UserFactory::createMany(5);
/** Access repository */
$users = UserFactory::repository()->findAll();
}
}
```
## Important Notes
- **Always extend `App\Tests\WebTestCase`** - provides database isolation
- **Use factories** - don't manually create entities with `new Entity()`
- **Test database** - Tests run on `mineseeker_test`, never production
- **Automatic rollback** - Each test is wrapped in a transaction
## Test Database Setup (One-time)
```bash
# Create test database
bin/console dbal:run-sql "CREATE DATABASE mineseeker_test"
# Run migrations
bin/console doctrine:migrations:migrate --env=test --no-interaction
```
## Troubleshooting
### Tests interfering with each other?
→ Make sure your test extends `App\Tests\WebTestCase`
### Database schema out of sync?
→ Run `bin/console doctrine:migrations:migrate --env=test`
### Memory limit errors?
→ Run `php -d memory_limit=512M vendor/bin/phpunit`
For more troubleshooting, see [Testing Guide](../docs/testing/TESTING.md#troubleshooting).

View File

@@ -0,0 +1,109 @@
<?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\Tests\Service;
use App\Service\MercureJwtService;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\Attributes\TestDox;
use PHPUnit\Framework\TestCase;
/**
* Class MercureJwtServiceTest
*
* @package App\Tests\Service
* @author Lang <https://www.splendidbear.org>
* @category Class
* @license https://www.gnu.org/licenses/lgpl-3.0.en.html GNU Lesser General Public License
* @link www.splendidbear.org
* @since 2026. 04. 21.
*/
#[TestDox('Mercure Jwt Service')]
class MercureJwtServiceTest extends TestCase
{
/** JWT HS256 requires at least 32 characters for the secret key */
private const SECRET = 'test-mercure-secret-key-12345678901234567890';
private MercureJwtService $service;
protected function setUp(): void
{
parent::setUp();
$this->service = new MercureJwtService(self::SECRET);
}
#[Test]
#[TestDox('Mint subscriber token returns valid jwt')]
public function mintSubscriberTokenReturnsValidJwt(): void
{
$token = $this->service->mintSubscriberToken('game123', 'player1');
$this->assertIsString($token);
$this->assertNotEmpty($token);
/** Token should have 3 parts (header.payload.signature) */
$parts = explode('.', $token);
$this->assertCount(3, $parts);
}
#[Test]
#[TestDox('Mint subscriber token with different game assoc')]
public function mintSubscriberTokenWithDifferentGameAssoc(): void
{
$token1 = $this->service->mintSubscriberToken('gameA', 'player1');
$token2 = $this->service->mintSubscriberToken('gameB', 'player1');
/** Different gameAssoc should result in different tokens */
$this->assertNotSame($token1, $token2);
}
#[Test]
#[TestDox('Mint subscriber token with different user names')]
public function mintSubscriberTokenWithDifferentUserNames(): void
{
$token1 = $this->service->mintSubscriberToken('game1', 'playerA');
$token2 = $this->service->mintSubscriberToken('game1', 'playerB');
/** Different usernames should result in different tokens */
$this->assertNotSame($token1, $token2);
}
#[Test]
#[TestDox('Mint subscriber token contains proper structure')]
public function mintSubscriberTokenContainsProperStructure(): void
{
$token = $this->service->mintSubscriberToken('game123', 'testplayer');
/** Decode without verification to check structure */
$parts = explode('.', $token);
/** Decode payload (middle part) */
$payload = json_decode(base64_decode($parts[1] . str_repeat('=', (4 - strlen($parts[1]) % 4))), true);
$this->assertIsArray($payload);
$this->assertArrayHasKey('mercure', $payload);
$this->assertArrayHasKey('subscribe', $payload['mercure']);
$this->assertArrayHasKey('payload', $payload['mercure']);
}
#[Test]
#[TestDox('Mint subscriber token payload contains correct data')]
public function mintSubscriberTokenPayloadContainsCorrectData(): void
{
$token = $this->service->mintSubscriberToken('test-game', 'test-user');
$parts = explode('.', $token);
$payload = json_decode(base64_decode($parts[1] . str_repeat('=', (4 - strlen($parts[1]) % 4))), true);
$this->assertSame('test-user', $payload['mercure']['payload']['username']);
$this->assertSame('test-game', $payload['mercure']['payload']['gameAssoc']);
$this->assertContains('*', $payload['mercure']['subscribe']);
}
}

View File

@@ -0,0 +1,188 @@
<?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\Tests\Service;
use App\Service\RecaptchaService;
use PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\Attributes\TestDox;
use PHPUnit\Framework\TestCase;
use Psr\Log\LoggerInterface;
use Symfony\Component\HttpClient\MockHttpClient;
use Symfony\Component\HttpClient\Response\MockResponse;
use Symfony\Contracts\HttpClient\HttpClientInterface;
/**
* Class RecaptchaServiceTest
*
* @package App\Tests\Service
* @author Lang <https://www.splendidbear.org>
* @category Class
* @license https://www.gnu.org/licenses/lgpl-3.0.en.html GNU Lesser General Public License
* @link www.splendidbear.org
* @since 2026. 04. 21.
*/
#[AllowMockObjectsWithoutExpectations]
#[TestDox('Recaptcha Service')]
class RecaptchaServiceTest extends TestCase
{
private const SECRET_KEY = 'test-secret-key';
#[Test]
#[TestDox('Verify returns false for empty token')]
public function verifyReturnsFalseForEmptyToken(): void
{
$httpClient = $this->createMock(HttpClientInterface::class);
$logger = $this->createMock(LoggerInterface::class);
$service = new RecaptchaService($httpClient, $logger, self::SECRET_KEY);
$result = $service->verify('');
$this->assertFalse($result);
}
#[Test]
#[TestDox('Verify returns false when api returns failure')]
public function verifyReturnsFalseWhenApiReturnsFailure(): void
{
$mockResponse = new MockResponse(json_encode([
'success' => false,
'error-codes' => ['invalid-input-secret'],
], JSON_THROW_ON_ERROR));
$httpClient = new MockHttpClient($mockResponse);
$logger = $this->createMock(LoggerInterface::class);
$logger->expects($this->once())->method('info');
$service = new RecaptchaService($httpClient, $logger, self::SECRET_KEY);
$result = $service->verify('invalid-token');
$this->assertFalse($result);
}
#[Test]
#[TestDox('Verify returns true when api returns success and high score')]
public function verifyReturnsTrueWhenApiReturnsSuccessAndHighScore(): void
{
$mockResponse = new MockResponse(json_encode([
'success' => true,
'score' => 0.8,
'hostname' => 'test.com',
], JSON_THROW_ON_ERROR));
$httpClient = new MockHttpClient($mockResponse);
$logger = $this->createMock(LoggerInterface::class);
$logger->expects($this->once())->method('info');
$service = new RecaptchaService($httpClient, $logger, self::SECRET_KEY);
$result = $service->verify('valid-token');
$this->assertTrue($result);
}
#[Test]
#[TestDox('Verify returns false when score below threshold')]
public function verifyReturnsFalseWhenScoreBelowThreshold(): void
{
$mockResponse = new MockResponse(json_encode([
'success' => true,
'score' => 0.3,
'hostname' => 'test.com',
], JSON_THROW_ON_ERROR));
$httpClient = new MockHttpClient($mockResponse);
$logger = $this->createMock(LoggerInterface::class);
$logger->expects($this->once())->method('info');
$service = new RecaptchaService($httpClient, $logger, self::SECRET_KEY);
$result = $service->verify('low-score-token');
$this->assertFalse($result);
}
#[Test]
#[TestDox('Verify returns false when api throws exception')]
public function verifyReturnsFalseWhenApiThrowsException(): void
{
$mockResponse = new MockResponse('', ['http_code' => 500]);
$httpClient = new MockHttpClient($mockResponse);
$logger = $this->createMock(LoggerInterface::class);
$logger->expects($this->once())->method('error');
$service = new RecaptchaService($httpClient, $logger, self::SECRET_KEY);
$result = $service->verify('test-token');
$this->assertFalse($result);
}
#[Test]
#[TestDox('Verify includes remote ip when provided')]
public function verifyIncludesRemoteIpWhenProvided(): void
{
$mockResponse = new MockResponse(json_encode([
'success' => true,
'score' => 0.9,
], JSON_THROW_ON_ERROR));
$httpClient = new MockHttpClient($mockResponse);
$logger = $this->createMock(LoggerInterface::class);
$logger->expects($this->once())->method('info');
$service = new RecaptchaService($httpClient, $logger, self::SECRET_KEY);
$result = $service->verify('test-token', '192.168.1.1');
$this->assertTrue($result);
}
#[Test]
#[TestDox('Verify with score at threshold')]
public function verifyWithScoreAtThreshold(): void
{
$mockResponse = new MockResponse(json_encode([
'success' => true,
'score' => 0.5,
], JSON_THROW_ON_ERROR));
$httpClient = new MockHttpClient($mockResponse);
$logger = $this->createMock(LoggerInterface::class);
$service = new RecaptchaService($httpClient, $logger, self::SECRET_KEY);
$result = $service->verify('threshold-token');
$this->assertTrue($result);
}
#[Test]
#[TestDox('Verify with no score fails')]
public function verifyWithNoScoreFails(): void
{
$mockResponse = new MockResponse(json_encode([
'success' => true,
], JSON_THROW_ON_ERROR));
$httpClient = new MockHttpClient($mockResponse);
$logger = $this->createMock(LoggerInterface::class);
$service = new RecaptchaService($httpClient, $logger, self::SECRET_KEY);
$result = $service->verify('no-score-token');
$this->assertFalse($result);
}
}

21
tests/WebTestCase.php Normal file
View File

@@ -0,0 +1,21 @@
<?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\Tests;
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase as BaseWebTestCase;
use Zenstruck\Foundry\Test\Factories;
use Zenstruck\Foundry\Test\ResetDatabase;
abstract class WebTestCase extends BaseWebTestCase
{
use ResetDatabase;
use Factories;
}

21
tests/bootstrap.php Normal file
View File

@@ -0,0 +1,21 @@
<?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.
*/
use Symfony\Component\Dotenv\Dotenv;
require dirname(__DIR__).'/vendor/autoload.php';
if (method_exists(Dotenv::class, 'bootEnv')) {
new Dotenv()->bootEnv(dirname(__DIR__).'/.env');
}
if ($_SERVER['APP_DEBUG']) {
umask(0000);
}