Compare commits
22 Commits
v2026.2.7-
...
11_gtk_cli
| Author | SHA1 | Date | |
|---|---|---|---|
| 6484133199 | |||
| 199bb7e525 | |||
| 5daaf71ae7 | |||
| 0aeec47996 | |||
| 3d67b8f2d9 | |||
| dd9a190fd9 | |||
| f5e5019ea8 | |||
| 3f51eb5db6 | |||
| 55ef7c9301 | |||
| ddfa395c6b | |||
| 8694627817 | |||
| f796819af4 | |||
| b209ad4220 | |||
| df1eefdfe0 | |||
| 7aaaf120b2 | |||
| e48d651eb5 | |||
| d704be5bff | |||
| 6bf908b43e | |||
| 085e010907 | |||
| 8935216525 | |||
| 1d8efa4e61 | |||
| 69fce52bed |
3
.env.test
Normal file
3
.env.test
Normal 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
142
.gitea/workflows/ci.yml-bak
Normal 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
|
||||
126
.gitea/workflows/deploy.yml-bak
Normal file
126
.gitea/workflows/deploy.yml-bak
Normal 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
5
.gitignore
vendored
@@ -14,3 +14,8 @@ nohup.out
|
||||
/var/
|
||||
/vendor/
|
||||
###< symfony/framework-bundle ###
|
||||
|
||||
###> phpunit/phpunit ###
|
||||
/phpunit.xml
|
||||
/.phpunit.cache/
|
||||
###< phpunit/phpunit ###
|
||||
|
||||
464
AGENTS.md
Normal file
464
AGENTS.md
Normal 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! 🚀**
|
||||
87
CHANGELOG.md
87
CHANGELOG.md
@@ -1,6 +1,93 @@
|
||||
# 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)
|
||||
|
||||
### Fix
|
||||
|
||||
@@ -22,13 +22,8 @@ RUN install-php-extensions \
|
||||
apcu \
|
||||
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 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' \
|
||||
> "$PHP_INI_DIR/conf.d/opcache.ini"
|
||||
|
||||
|
||||
23
Makefile
23
Makefile
@@ -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
|
||||
|
||||
@@ -14,6 +14,9 @@ help:
|
||||
@echo " make ccp - Clear the production cache"
|
||||
@echo " make cache-clear - Clear all caches (Vite, Symfony, node_modules)"
|
||||
@echo " make og-cache-clear - Clear Open Graph cache only"
|
||||
@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:
|
||||
docker compose up -d
|
||||
@@ -80,5 +83,23 @@ og-cache-clear:
|
||||
@echo "✓ OG cache cleared!"
|
||||
@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
|
||||
|
||||
81
README.md
81
README.md
@@ -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
|
||||
- **[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.
|
||||
|
||||
© 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
|
||||
|
||||
---
|
||||
|
||||
@@ -28,6 +28,21 @@
|
||||
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 {
|
||||
position: relative;
|
||||
width: 80px;
|
||||
|
||||
BIN
assets/fonts/Carlito-Bold.ttf
Normal file
BIN
assets/fonts/Carlito-Bold.ttf
Normal file
Binary file not shown.
@@ -7,7 +7,7 @@
|
||||
* 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 { useProfileDataProvider } from '@mine-hooks/useGameDataProvider';
|
||||
|
||||
@@ -16,6 +16,8 @@ export const AvatarUpload = ({ uploadUrl, initialThumbUrl, initials }) => {
|
||||
const [thumbUrl, setThumbUrl] = React.useState(initialThumbUrl || null);
|
||||
const { uploadAvatarMutation: { isPending, error, mutate } } = useProfileDataProvider();
|
||||
|
||||
const errorMessage = useMemo(() => error?.message ?? null, [error]);
|
||||
|
||||
const handleChange = e => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
@@ -40,32 +42,32 @@ export const AvatarUpload = ({ uploadUrl, initialThumbUrl, initials }) => {
|
||||
});
|
||||
};
|
||||
|
||||
const errorMessage = useMemo(() => error?.message ?? null, [error]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`profile-avatar${isPending ? ' profile-avatar--loading' : ''}`}
|
||||
title="Click to change profile picture"
|
||||
onClick={() => inputRef.current?.click()}
|
||||
>
|
||||
{thumbUrl
|
||||
? <img src={thumbUrl} alt={initials} className="profile-avatar__img" />
|
||||
: <span className="profile-avatar__initials">{initials}</span>
|
||||
}
|
||||
<div className="profile-avatar__overlay">
|
||||
<i className="fa fa-camera" />
|
||||
<Fragment>
|
||||
<div
|
||||
className={`profile-avatar${isPending ? ' profile-avatar--loading' : ''}`}
|
||||
title="Click to change profile picture"
|
||||
onClick={() => inputRef.current?.click()}
|
||||
>
|
||||
{thumbUrl
|
||||
? <img src={thumbUrl} alt={initials} className="profile-avatar__img" />
|
||||
: <span className="profile-avatar__initials">{initials}</span>
|
||||
}
|
||||
<div className="profile-avatar__overlay">
|
||||
<i className="fa fa-camera" />
|
||||
</div>
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="file"
|
||||
accept="image/jpeg,image/png,image/gif,image/webp"
|
||||
style={{ display: 'none' }}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
</div>
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="file"
|
||||
accept="image/jpeg,image/png,image/gif,image/webp"
|
||||
style={{ display: 'none' }}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
{errorMessage && <div className="profile-avatar__error">{errorMessage}</div>}
|
||||
</div>
|
||||
</Fragment>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
AvatarUpload.propTypes = {
|
||||
uploadUrl: string.isRequired,
|
||||
|
||||
@@ -12,7 +12,9 @@ import { array } from 'prop-types';
|
||||
import { formatDuration } from '@global-utils/format';
|
||||
import Dialog from '@mui/material/Dialog';
|
||||
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' } });
|
||||
|
||||
@@ -69,8 +71,10 @@ export const BattleDialog = ({ games }) => {
|
||||
const endReason = resign
|
||||
? `${resign.charAt(0).toUpperCase() + resign.slice(1)} resigned`
|
||||
: 26 <= maxPoints ? 'Points' : 'Abandoned';
|
||||
const bothRegistered = game.bothRegistered;
|
||||
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 duration = formatDuration(game.created, game.date);
|
||||
@@ -108,7 +112,7 @@ export const BattleDialog = ({ games }) => {
|
||||
<i className="fa fa-play" />
|
||||
Continue
|
||||
</a>
|
||||
) : (
|
||||
) : canShare ? (
|
||||
<button
|
||||
className={`bd-share${copied ? ' bd-share--copied' : ''}`}
|
||||
onClick={handleShare}
|
||||
@@ -118,7 +122,7 @@ export const BattleDialog = ({ games }) => {
|
||||
<i className={`fa ${copied ? 'fa-check' : 'fa-share-alt'}`} />
|
||||
{copied ? 'Copied!' : 'Share'}
|
||||
</button>
|
||||
)}
|
||||
) : null}
|
||||
<button className="bd-close" onClick={() => setOpen(false)} aria-label="Close">
|
||||
<i className="fa fa-times" />
|
||||
</button>
|
||||
|
||||
@@ -49,6 +49,7 @@ export const GameProvider = ({ children }) => {
|
||||
mine: new Howl({ src: ['/sound/mine.mp3'] }),
|
||||
warning: new Howl({ src: ['/sound/warning.mp3'] }),
|
||||
won: new Howl({ src: ['/sound/won.mp3'] }),
|
||||
starting: new Howl({ src: ['/sound/starting.mp3'] }),
|
||||
});
|
||||
|
||||
/** Sync helpers (keep ref + state in lockstep) */
|
||||
@@ -269,6 +270,8 @@ export const GameProvider = ({ children }) => {
|
||||
makeGameEndIfItEnds, resignProcess,
|
||||
/** UI action */
|
||||
onBombToggle,
|
||||
/** Sounds */
|
||||
sounds,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
|
||||
@@ -22,7 +22,7 @@ const useServerCommunication = (gameAssoc, gameInherited, opponentName, isEnvDev
|
||||
/** Sync helpers */
|
||||
syncWebPlayer, syncActivePlayer, syncBombSelected, syncConnLost, syncRed, syncBlue,
|
||||
/** Game logic */
|
||||
showOverlay, hideOverlay, applyStep, makeGameEndIfItEnds, resignProcess,
|
||||
showOverlay, hideOverlay, applyStep, makeGameEndIfItEnds, resignProcess, sounds,
|
||||
/** Current cells snapshot (for active-check in onClick) */
|
||||
cells,
|
||||
} = useGame();
|
||||
@@ -212,6 +212,7 @@ const useServerCommunication = (gameAssoc, gameInherited, opponentName, isEnvDev
|
||||
*/
|
||||
if (!endRef.current && (!isTrueRestoredRef.current || 0 !== opponentLastSeenRef.current)) {
|
||||
hideOverlay();
|
||||
sounds.current.starting.play();
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
4
bin/phpunit
Executable file
4
bin/phpunit
Executable file
@@ -0,0 +1,4 @@
|
||||
#!/usr/bin/env php
|
||||
<?php
|
||||
|
||||
require dirname(__DIR__).'/vendor/phpunit/phpunit/phpunit';
|
||||
114
bun.lock
114
bun.lock
@@ -38,27 +38,27 @@
|
||||
"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/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/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-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-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=="],
|
||||
|
||||
@@ -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/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/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=="],
|
||||
|
||||
@@ -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/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=="],
|
||||
|
||||
@@ -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=="],
|
||||
|
||||
"@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=="],
|
||||
|
||||
@@ -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=="],
|
||||
|
||||
"@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=="],
|
||||
|
||||
@@ -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=="],
|
||||
|
||||
"@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=="],
|
||||
|
||||
"@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=="],
|
||||
|
||||
@@ -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/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=="],
|
||||
|
||||
@@ -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=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
@@ -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=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
@@ -336,7 +336,7 @@
|
||||
|
||||
"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=="],
|
||||
|
||||
@@ -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=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
@@ -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=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
@@ -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=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
"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-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=="],
|
||||
|
||||
@@ -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=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
@@ -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=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
@@ -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=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
@@ -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=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
@@ -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=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
@@ -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-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=="],
|
||||
|
||||
@@ -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=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
@@ -732,7 +732,7 @@
|
||||
|
||||
"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=="],
|
||||
|
||||
@@ -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=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
@@ -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=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
@@ -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/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=="],
|
||||
|
||||
@@ -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=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
|
||||
@@ -33,9 +33,9 @@
|
||||
"symfony/framework-bundle": "7.4.*",
|
||||
"symfony/http-client": "7.4.*",
|
||||
"symfony/mailer": "7.4.*",
|
||||
"symfony/mercure": "^0.6",
|
||||
"symfony/mercure-bundle": "*",
|
||||
"symfony/monolog-bundle": "^3.8",
|
||||
"symfony/mercure": "^0.7",
|
||||
"symfony/mercure-bundle": "^0.4",
|
||||
"symfony/monolog-bundle": "^4.0",
|
||||
"symfony/security-bundle": "7.4.*",
|
||||
"symfony/translation": "7.4.*",
|
||||
"symfony/twig-bundle": "7.4.*",
|
||||
@@ -44,11 +44,16 @@
|
||||
"web-auth/webauthn-framework": "^5.2"
|
||||
},
|
||||
"require-dev": {
|
||||
"dama/doctrine-test-bundle": "^8.6",
|
||||
"phpunit/phpunit": "^13.1",
|
||||
"roave/security-advisories": "dev-master",
|
||||
"symfony/browser-kit": "7.4.*",
|
||||
"symfony/css-selector": "7.4.*",
|
||||
"symfony/dotenv": "7.4.*",
|
||||
"symfony/maker-bundle": "^1.5",
|
||||
"symfony/stopwatch": "7.4.*",
|
||||
"symfony/web-profiler-bundle": "7.4.*"
|
||||
"symfony/web-profiler-bundle": "7.4.*",
|
||||
"zenstruck/foundry": "^2.9"
|
||||
},
|
||||
"config": {
|
||||
"preferred-install": {
|
||||
|
||||
2619
composer.lock
generated
2619
composer.lock
generated
File diff suppressed because it is too large
Load Diff
@@ -16,4 +16,6 @@ return [
|
||||
Scheb\TwoFactorBundle\SchebTwoFactorBundle::class => ['all' => true],
|
||||
League\FlysystemBundle\FlysystemBundle::class => ['all' => true],
|
||||
Liip\ImagineBundle\LiipImagineBundle::class => ['all' => true],
|
||||
Zenstruck\Foundry\ZenstruckFoundryBundle::class => ['dev' => true, 'test' => true],
|
||||
DAMA\DoctrineTestBundle\DAMADoctrineTestBundle::class => ['test' => true],
|
||||
];
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
framework:
|
||||
test: true
|
||||
session:
|
||||
storage_id: session.storage.mock_file
|
||||
storage_factory_id: session.storage.factory.mock_file
|
||||
|
||||
16
config/packages/zenstruck_foundry.yaml
Normal file
16
config/packages/zenstruck_foundry.yaml
Normal 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
|
||||
@@ -28,6 +28,7 @@ services:
|
||||
App\Service\BattleCardGenerator:
|
||||
arguments:
|
||||
$cacheDir: '%kernel.project_dir%/var/og-cache'
|
||||
$fontPath: '%kernel.project_dir%/assets/fonts/Carlito-Bold.ttf'
|
||||
$minioMediaStorage: '@mineseeker.media.storage'
|
||||
|
||||
Aws\S3\S3Client:
|
||||
|
||||
443
docs/CI_CD.md
Normal file
443
docs/CI_CD.md
Normal 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
47
docs/FONTS.md
Normal 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/*
|
||||
```
|
||||
160
docs/README.md
160
docs/README.md
@@ -1,6 +1,6 @@
|
||||
# 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
|
||||
|
||||
@@ -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
|
||||
|
||||
### 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 Type | Points | Condition |
|
||||
|-----------|--------|-----------|
|
||||
@@ -30,18 +116,80 @@ Complete reference for the bonus points system including:
|
||||
| Chain Combo | Tracked | Consecutive mine clicks (no safe clicks) |
|
||||
| Biggest Reveal | Tracked | Largest number of safe cells revealed |
|
||||
|
||||
### Key Rules
|
||||
- Safe cell bonus only awarded for ≥2 cells minimum
|
||||
- Chain counter resets on any safe cell click
|
||||
- Endgame threshold: 51 - (redPoints + bluePoints) ≤ 10
|
||||
- Bonus stats are per-player and persist in database
|
||||
### Available Test Factories
|
||||
| Factory | Entity | Purpose |
|
||||
|---------|--------|---------|
|
||||
| `UserFactory` | `User` | Registered users |
|
||||
| `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
|
||||
|
||||
### Game Mechanics
|
||||
- Backend: `/src/Util/TopicManager.php`
|
||||
- Frontend: `/assets/js/mine-seeker/contexts/GameProvider.jsx`
|
||||
- UI: `/assets/js/mine-seeker/components/BonusStatsDialog.jsx`
|
||||
- Constants: `/assets/js/mine-seeker/utils/constants.jsx`
|
||||
|
||||
### 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
265
docs/testing/FACTORIES.md
Normal 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
336
docs/testing/TESTING.md
Normal 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
8
gtk-client/.env.example
Normal 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
11
gtk-client/.gitignore
vendored
Normal file
@@ -0,0 +1,11 @@
|
||||
.env
|
||||
__pycache__/
|
||||
*.pyc
|
||||
*.pyo
|
||||
*.pyd
|
||||
.Python
|
||||
*.egg-info/
|
||||
dist/
|
||||
build/
|
||||
.venv/
|
||||
venv/
|
||||
47
gtk-client/main.py
Normal file
47
gtk-client/main.py
Normal 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())
|
||||
0
gtk-client/mineseeker/__init__.py
Normal file
0
gtk-client/mineseeker/__init__.py
Normal file
0
gtk-client/mineseeker/api/__init__.py
Normal file
0
gtk-client/mineseeker/api/__init__.py
Normal file
93
gtk-client/mineseeker/api/auth.py
Normal file
93
gtk-client/mineseeker/api/auth.py
Normal 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()
|
||||
52
gtk-client/mineseeker/api/client.py
Normal file
52
gtk-client/mineseeker/api/client.py
Normal 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
|
||||
117
gtk-client/mineseeker/api/game.py
Normal file
117
gtk-client/mineseeker/api/game.py
Normal 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,
|
||||
},
|
||||
)
|
||||
163
gtk-client/mineseeker/api/sse.py
Normal file
163
gtk-client/mineseeker/api/sse.py
Normal 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)
|
||||
108
gtk-client/mineseeker/assets.py
Normal file
108
gtk-client/mineseeker/assets.py
Normal 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)
|
||||
30
gtk-client/mineseeker/config.py
Normal file
30
gtk-client/mineseeker/config.py
Normal 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"
|
||||
90
gtk-client/mineseeker/constants.py
Normal file
90
gtk-client/mineseeker/constants.py
Normal 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"
|
||||
0
gtk-client/mineseeker/state/__init__.py
Normal file
0
gtk-client/mineseeker/state/__init__.py
Normal file
267
gtk-client/mineseeker/state/game_state.py
Normal file
267
gtk-client/mineseeker/state/game_state.py
Normal 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))
|
||||
45
gtk-client/mineseeker/state/session.py
Normal file
45
gtk-client/mineseeker/state/session.py
Normal 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()
|
||||
0
gtk-client/mineseeker/ui/__init__.py
Normal file
0
gtk-client/mineseeker/ui/__init__.py
Normal file
109
gtk-client/mineseeker/ui/app_window.py
Normal file
109
gtk-client/mineseeker/ui/app_window.py
Normal 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()
|
||||
96
gtk-client/mineseeker/ui/bonus_dialog.py
Normal file
96
gtk-client/mineseeker/ui/bonus_dialog.py
Normal 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)
|
||||
477
gtk-client/mineseeker/ui/game_page.py
Normal file
477
gtk-client/mineseeker/ui/game_page.py
Normal 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
|
||||
239
gtk-client/mineseeker/ui/grid_widget.py
Normal file
239
gtk-client/mineseeker/ui/grid_widget.py
Normal 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()
|
||||
185
gtk-client/mineseeker/ui/lobby_page.py
Normal file
185
gtk-client/mineseeker/ui/lobby_page.py
Normal 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
|
||||
146
gtk-client/mineseeker/ui/login_page.py
Normal file
146
gtk-client/mineseeker/ui/login_page.py
Normal 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}")
|
||||
116
gtk-client/mineseeker/ui/player_panel.py
Normal file
116
gtk-client/mineseeker/ui/player_panel.py
Normal 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)
|
||||
102
gtk-client/mineseeker/ui/result_overlay.py
Normal file
102
gtk-client/mineseeker/ui/result_overlay.py
Normal 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)
|
||||
112
gtk-client/mineseeker/ui/totp_page.py
Normal file
112
gtk-client/mineseeker/ui/totp_page.py
Normal 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
|
||||
17
gtk-client/requirements.txt
Normal file
17
gtk-client/requirements.txt
Normal 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
31
gtk-client/run.sh
Executable 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" "$@"
|
||||
@@ -39,7 +39,7 @@
|
||||
"eslint-plugin-react-hooks": "7.1.1",
|
||||
"globals": "17.5.0",
|
||||
"sass": "^1.99.0",
|
||||
"vite": "^8.0.8",
|
||||
"vite": "^8.0.9",
|
||||
"vite-plugin-symfony": "^8.2.4"
|
||||
},
|
||||
"scripts": {
|
||||
|
||||
48
phpunit.dist.xml
Normal file
48
phpunit.dist.xml
Normal 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&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
BIN
public/sound/starting.mp3
Normal file
Binary file not shown.
106
src/Controller/ApiAuthController.php
Normal file
106
src/Controller/ApiAuthController.php
Normal 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(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,7 @@ namespace App\Controller;
|
||||
|
||||
use App\Entity\PlayedGame;
|
||||
use App\Repository\PlayedGameRepository;
|
||||
use App\Service\MercureJwtService;
|
||||
use App\Service\ResolveUserNamesService;
|
||||
use App\Util\RpcManager;
|
||||
use App\Util\TopicManager;
|
||||
@@ -23,6 +24,7 @@ use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\HttpKernel\Attribute\AsController;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
use Symfony\Component\Uid\Uuid;
|
||||
|
||||
/**
|
||||
* Class MercureController
|
||||
@@ -45,9 +47,28 @@ class MercureController extends AbstractController
|
||||
private readonly TopicManager $topicManager,
|
||||
private readonly RpcManager $rpcManager,
|
||||
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'])]
|
||||
public function start(Request $request): JsonResponse
|
||||
{
|
||||
|
||||
@@ -27,6 +27,7 @@ use Doctrine\ORM\EntityManagerInterface;
|
||||
use League\Flysystem\FilesystemException;
|
||||
use League\Flysystem\FilesystemOperator;
|
||||
use Liip\ImagineBundle\Imagine\Cache\CacheManager;
|
||||
use Liip\ImagineBundle\Service\FilterService;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use RuntimeException;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
@@ -140,6 +141,7 @@ class ProfileController extends AbstractController
|
||||
Request $request,
|
||||
EntityManagerInterface $em,
|
||||
CacheManager $cacheManager,
|
||||
FilterService $filterService,
|
||||
#[Autowire(service: 'mineseeker.media.storage')] FilesystemOperator $mediaStorage,
|
||||
): JsonResponse {
|
||||
$this->denyAccessUnlessGranted('IS_AUTHENTICATED_REMEMBERED');
|
||||
@@ -153,8 +155,8 @@ class ProfileController extends AbstractController
|
||||
return $this->json(['error' => 'No file uploaded.'], 400);
|
||||
}
|
||||
|
||||
if ($file->getSize() > 2 * 1024 * 1024) {
|
||||
return $this->json(['error' => 'File is too large. Maximum 2 MB.'], 400);
|
||||
if ($file->getSize() > 10 * 1024 * 1024) {
|
||||
return $this->json(['error' => 'File is too large. Maximum 10 MB.'], 400);
|
||||
}
|
||||
|
||||
$allowed = ['image/jpeg', 'image/png', 'image/gif', 'image/webp'];
|
||||
@@ -182,6 +184,7 @@ class ProfileController extends AbstractController
|
||||
$mediaStorage->writeStream($newPath, $stream);
|
||||
} catch (FilesystemException $e) {
|
||||
$this->logger->error('Unable to write new avatar: ' . $e->getMessage());
|
||||
fclose($stream);
|
||||
throw new RuntimeException('Unable to write new avatar: ' . $e->getMessage());
|
||||
}
|
||||
fclose($stream);
|
||||
@@ -190,7 +193,7 @@ class ProfileController extends AbstractController
|
||||
$em->flush();
|
||||
|
||||
return $this->json([
|
||||
'thumbUrl' => $cacheManager->generateUrl($newPath, 'avatar_thumb'),
|
||||
'thumbUrl' => $filterService->getUrlOfFilteredImage($newPath, 'avatar_thumb'),
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
@@ -46,6 +46,7 @@ final readonly class ProfileGameDto implements JsonSerializable
|
||||
public float $blueBonusPoints,
|
||||
public array $redBonusStats,
|
||||
public array $blueBonusStats,
|
||||
public bool $bothRegistered,
|
||||
) {
|
||||
}
|
||||
|
||||
|
||||
@@ -62,6 +62,7 @@ final readonly class ProfileGameDtoFactory
|
||||
blueBonusPoints: $game->blueBonusPoints ?? 0.0,
|
||||
redBonusStats: $game->redBonusStats ?? [],
|
||||
blueBonusStats: $game->blueBonusStats ?? [],
|
||||
bothRegistered: $game->red !== null && $game->blue !== null,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -128,6 +129,7 @@ final readonly class ProfileGameDtoFactory
|
||||
blueBonusPoints: $battle->blueBonusPoints,
|
||||
redBonusStats: $battle->redBonusStats,
|
||||
blueBonusStats: $battle->blueBonusStats,
|
||||
bothRegistered: !$battle->oppIsGuest,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -40,9 +40,19 @@ class RecentBattleRepository extends ServiceEntityRepository
|
||||
|
||||
public function findRecentForUser(int $userId, int $limit = 30): array
|
||||
{
|
||||
return $this->createQueryBuilder('rb')
|
||||
->where('rb.userId = :uid')
|
||||
$qb = $this->createQueryBuilder('rb');
|
||||
|
||||
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('false', false)
|
||||
->orderBy('rb.updated', 'DESC')
|
||||
->setMaxResults($limit)
|
||||
->getQuery()
|
||||
|
||||
@@ -34,11 +34,11 @@ final class BattleCardGenerator
|
||||
{
|
||||
private const int WIDTH = 1200;
|
||||
private const int HEIGHT = 630;
|
||||
private const string FONT = '/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf';
|
||||
private const int AVATAR_SIZE = 120;
|
||||
|
||||
public function __construct(
|
||||
private readonly string $cacheDir,
|
||||
private readonly string $fontPath,
|
||||
private readonly FilesystemOperator $minioMediaStorage,
|
||||
private readonly LoggerInterface $logger,
|
||||
) {
|
||||
@@ -159,7 +159,7 @@ final class BattleCardGenerator
|
||||
$redBonusPoints = $game->redBonusPoints ?? 0;
|
||||
$blueBonusPoints = $game->blueBonusPoints ?? 0;
|
||||
$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') {
|
||||
$resultText = $redName . ' wins';
|
||||
@@ -176,11 +176,11 @@ final class BattleCardGenerator
|
||||
}
|
||||
|
||||
if ($resultText !== '') {
|
||||
$this->centeredText($im, $resultText, 30, self::WIDTH / 2, 475, $resultColor);
|
||||
$this->centeredText($im, $resultText, 30, self::WIDTH / 2, 495, $resultColor);
|
||||
}
|
||||
|
||||
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);
|
||||
@@ -273,23 +273,23 @@ final class BattleCardGenerator
|
||||
/** Draw initials */
|
||||
$initials = mb_strtoupper(mb_substr($name, 0, 2));
|
||||
$fontSize = 48;
|
||||
$bbox = imagettfbbox($fontSize, 0, self::FONT, $initials);
|
||||
$bbox = imagettfbbox($fontSize, 0, $this->fontPath, $initials);
|
||||
$textW = $bbox[2] - $bbox[0];
|
||||
$textH = $bbox[1] - $bbox[7];
|
||||
$textX = $cx - $textW / 2;
|
||||
$textY = $cy + $textH / 2;
|
||||
|
||||
$white = imagecolorallocate($im, 255, 255, 255);
|
||||
imagettftext($im, $fontSize, 0, (int)$textX, (int)$textY, $white, self::FONT, $initials);
|
||||
imagettftext($im, $fontSize, 0, (int)$textX, (int)$textY, $white, $this->fontPath, $initials);
|
||||
}
|
||||
}
|
||||
|
||||
/** Render text centered on $cx. */
|
||||
private function centeredText(GdImage $im, string $text, int $size, int $cx, int $y, int $color): void
|
||||
{
|
||||
$bbox = imagettfbbox($size, 0, self::FONT, $text);
|
||||
$bbox = imagettfbbox($size, 0, $this->fontPath, $text);
|
||||
$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. */
|
||||
@@ -302,7 +302,7 @@ final class BattleCardGenerator
|
||||
int $color,
|
||||
int $maxWidth
|
||||
): void {
|
||||
$bbox = imagettfbbox($size, 0, self::FONT, $text);
|
||||
$bbox = imagettfbbox($size, 0, $this->fontPath, $text);
|
||||
$w = $bbox[2] - $bbox[0];
|
||||
if ($w > $maxWidth) {
|
||||
$size = (int)($size * $maxWidth / $w);
|
||||
|
||||
33
src/Story/AppStory.php
Normal file
33
src/Story/AppStory.php
Normal 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();
|
||||
}
|
||||
}
|
||||
@@ -26,12 +26,13 @@ use Symfony\Component\Validator\Exception\UnexpectedTypeException;
|
||||
* @link www.splendidbear.org
|
||||
* @since 2026. 04. 12.
|
||||
*/
|
||||
class RecaptchaValidator extends ConstraintValidator
|
||||
final class RecaptchaValidator extends ConstraintValidator
|
||||
{
|
||||
public function __construct(
|
||||
private readonly RecaptchaService $recaptcha,
|
||||
private readonly RequestStack $requestStack,
|
||||
) {}
|
||||
private readonly RequestStack $requestStack,
|
||||
) {
|
||||
}
|
||||
|
||||
public function validate(mixed $value, Constraint $constraint): void
|
||||
{
|
||||
@@ -39,10 +40,10 @@ class RecaptchaValidator extends ConstraintValidator
|
||||
throw new UnexpectedTypeException($constraint, Recaptcha::class);
|
||||
}
|
||||
|
||||
$request = $this->requestStack->getCurrentRequest();
|
||||
$remoteIp = $request !== null ? ((string) $request->getClientIp()) : '';
|
||||
$request = $this->requestStack->getCurrentRequest();
|
||||
$remoteIp = $request !== null ? ((string)$request->getClientIp()) : '';
|
||||
|
||||
if ($this->recaptcha->verify((string) $value, $remoteIp)) {
|
||||
if ($this->recaptcha->verify((string)$value, $remoteIp)) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
37
symfony.lock
37
symfony.lock
@@ -2,6 +2,15 @@
|
||||
"cocur/slugify": {
|
||||
"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": {
|
||||
"version": "v1.5.0"
|
||||
},
|
||||
@@ -120,6 +129,21 @@
|
||||
"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": {
|
||||
"version": "1.0.1"
|
||||
},
|
||||
@@ -469,5 +493,18 @@
|
||||
},
|
||||
"zendframework/zend-eventmanager": {
|
||||
"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"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,9 @@
|
||||
|
||||
{% macro stat_val(value, suffix) %}
|
||||
{%- set abbr = value >= 1000 -%}
|
||||
<span class="profile-stat__value"{% if abbr %} title="{{ value }}"{% endif %}>{% if abbr %}{{ (value / 1000)|round(1, 'floor') }}k{% else %}{{ value }}{% endif %}{% if suffix %}<small>{{ suffix }}</small>{% endif %}</span>
|
||||
<span
|
||||
class="profile-stat__value"{% if abbr %} title="{{ value }}"{% endif %}>{% if abbr %}{{ (value / 1000)|round(1, 'floor') }}k{% else %}{{ value }}{% endif %}{% if suffix %}
|
||||
<small>{{ suffix }}</small>{% endif %}</span>
|
||||
{% endmacro %}
|
||||
|
||||
{% block title %} - Profile{% endblock %}
|
||||
@@ -31,7 +33,7 @@
|
||||
<div class="profile-header">
|
||||
<div id="profile-avatar-root"
|
||||
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 }}">
|
||||
</div>
|
||||
<div class="profile-info">
|
||||
@@ -134,11 +136,13 @@
|
||||
{% set result = game.result %}
|
||||
|
||||
{% set is_finished = game.resign is not null
|
||||
or (my_points is not null and opp_points is not null
|
||||
and (my_points > 25 or opp_points > 25)) %}
|
||||
or (my_points is not null and opp_points is not null
|
||||
and (my_points > 25 or opp_points > 25)) %}
|
||||
{% 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">
|
||||
{% if is_finished %}
|
||||
{{ result == 'win' ? 'Win' : (result == 'loss' ? 'Loss' : 'Draw') }}
|
||||
|
||||
@@ -28,6 +28,20 @@
|
||||
<link rel="icon" href="{{ asset('/images/favicon/favicon.ico') }}" type="image/x-icon">
|
||||
{% block metas %}{% endblock %}
|
||||
<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 %}
|
||||
</head>
|
||||
|
||||
|
||||
145
tests/Controller/GameControllerTest.php
Normal file
145
tests/Controller/GameControllerTest.php
Normal 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"]');
|
||||
}
|
||||
}
|
||||
146
tests/Controller/ProfileControllerTest.php
Normal file
146
tests/Controller/ProfileControllerTest.php
Normal 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);
|
||||
}
|
||||
}
|
||||
148
tests/Controller/SecurityControllerTest.php
Normal file
148
tests/Controller/SecurityControllerTest.php
Normal 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"]');
|
||||
}
|
||||
}
|
||||
89
tests/Dto/ProfileChartDataDtoTest.php
Normal file
89
tests/Dto/ProfileChartDataDtoTest.php
Normal 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']);
|
||||
}
|
||||
}
|
||||
126
tests/Dto/ProfileGameDtoTest.php
Normal file
126
tests/Dto/ProfileGameDtoTest.php
Normal 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']);
|
||||
}
|
||||
}
|
||||
134
tests/Dto/ProfileStatsDtoTest.php
Normal file
134
tests/Dto/ProfileStatsDtoTest.php
Normal 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);
|
||||
}
|
||||
}
|
||||
142
tests/Entity/UserStatsTest.php
Normal file
142
tests/Entity/UserStatsTest.php
Normal 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);
|
||||
}
|
||||
}
|
||||
55
tests/Factory/ContactMessageFactory.php
Normal file
55
tests/Factory/ContactMessageFactory.php
Normal 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]);
|
||||
}
|
||||
}
|
||||
51
tests/Factory/GamerFactory.php
Normal file
51
tests/Factory/GamerFactory.php
Normal 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))]);
|
||||
}
|
||||
}
|
||||
53
tests/Factory/GridFactory.php
Normal file
53
tests/Factory/GridFactory.php
Normal 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);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
52
tests/Factory/GridRowFactory.php
Normal file
52
tests/Factory/GridRowFactory.php
Normal 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]);
|
||||
}
|
||||
}
|
||||
108
tests/Factory/PlayedGameFactory.php
Normal file
108
tests/Factory/PlayedGameFactory.php
Normal 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]);
|
||||
}
|
||||
}
|
||||
68
tests/Factory/StepFactory.php
Normal file
68
tests/Factory/StepFactory.php
Normal 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]);
|
||||
}
|
||||
}
|
||||
60
tests/Factory/UserFactory.php
Normal file
60
tests/Factory/UserFactory.php
Normal 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]);
|
||||
}
|
||||
}
|
||||
62
tests/Factory/WebAuthnCredentialFactory.php
Normal file
62
tests/Factory/WebAuthnCredentialFactory.php
Normal 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()]);
|
||||
}
|
||||
}
|
||||
211
tests/Integration/FactoryExampleTest.php
Normal file
211
tests/Integration/FactoryExampleTest.php
Normal 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
108
tests/README.md
Normal 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).
|
||||
109
tests/Service/MercureJwtServiceTest.php
Normal file
109
tests/Service/MercureJwtServiceTest.php
Normal 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']);
|
||||
}
|
||||
}
|
||||
188
tests/Service/RecaptchaServiceTest.php
Normal file
188
tests/Service/RecaptchaServiceTest.php
Normal 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
21
tests/WebTestCase.php
Normal 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
21
tests/bootstrap.php
Normal 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);
|
||||
}
|
||||
Reference in New Issue
Block a user