Compare commits
13 Commits
3c15c12255
...
c35e239450
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c35e239450 | ||
|
|
2f5ba701b6 | ||
|
|
96adefbb1e | ||
|
|
a6b3a93d5c | ||
|
|
884168aa49 | ||
|
|
ef155463ab | ||
|
|
1d7c215887 | ||
|
|
665233425a | ||
|
|
ff9a48448c | ||
|
|
55145c366f | ||
|
|
90ca2b946d | ||
|
|
1eb7817182 | ||
|
|
6cd6c1ed47 |
@@ -1,6 +1,6 @@
|
|||||||
vendor/
|
vendor/
|
||||||
var/
|
var/
|
||||||
.env.local
|
.env
|
||||||
.env.*.local
|
.env.*.local
|
||||||
/public/assets/
|
/public/assets/
|
||||||
/assets/vendor/
|
/assets/vendor/
|
||||||
|
|||||||
@@ -8,7 +8,4 @@ MESSENGER_TRANSPORT_DSN=doctrine://default?auto_setup=0
|
|||||||
|
|
||||||
MAILER_DSN=null://null
|
MAILER_DSN=null://null
|
||||||
|
|
||||||
TMDB_API_TOKEN=
|
|
||||||
|
|
||||||
SERVER_NAME=
|
SERVER_NAME=
|
||||||
|
|
||||||
|
|||||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -1,5 +1,5 @@
|
|||||||
###> symfony/framework-bundle ###
|
###> symfony/framework-bundle ###
|
||||||
/.env.local
|
/.env
|
||||||
/.env.local.php
|
/.env.local.php
|
||||||
/.env.*.local
|
/.env.*.local
|
||||||
/.env
|
/.env
|
||||||
|
|||||||
6
Makefile
6
Makefile
@@ -52,6 +52,12 @@ symfony\:secrets-set: ## Ajoute un secret Symfony (ex: make symfony:secrets-set
|
|||||||
symfony\:console: ## Lance une commande Symfony (ex: make symfony:console CMD="cache:clear")
|
symfony\:console: ## Lance une commande Symfony (ex: make symfony:console CMD="cache:clear")
|
||||||
docker compose exec app php bin/console $(CMD)
|
docker compose exec app php bin/console $(CMD)
|
||||||
|
|
||||||
|
php\:console: ## Lance bin/console avec arguments (ex: make php:console -- cache:clear --env=prod)
|
||||||
|
docker compose exec app php bin/console $(filter-out $@,$(MAKECMDGOALS))
|
||||||
|
|
||||||
|
%:
|
||||||
|
@:
|
||||||
|
|
||||||
symfony\:cache-clear: ## Vide le cache Symfony
|
symfony\:cache-clear: ## Vide le cache Symfony
|
||||||
docker compose exec app php bin/console cache:clear
|
docker compose exec app php bin/console cache:clear
|
||||||
|
|
||||||
|
|||||||
@@ -486,3 +486,57 @@ body {
|
|||||||
background: #f0fdf4;
|
background: #f0fdf4;
|
||||||
color: #16a34a;
|
color: #16a34a;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Game card ── */
|
||||||
|
|
||||||
|
.game-container {
|
||||||
|
max-width: fit-content;
|
||||||
|
margin: 56px auto;
|
||||||
|
padding: 24px 32px 32px;
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
box-shadow: 0 4px 32px var(--shadow-warm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.game-container #actors {
|
||||||
|
margin: 16px auto 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Game actions ── */
|
||||||
|
|
||||||
|
.game-actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-abandon {
|
||||||
|
padding: 7px 16px;
|
||||||
|
background: none;
|
||||||
|
color: #dc2626;
|
||||||
|
border: 1.5px solid #dc2626;
|
||||||
|
border-radius: 100px;
|
||||||
|
font-family: 'Inter', sans-serif;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: border-color 0.15s, color 0.15s, background 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-abandon:hover {
|
||||||
|
border-color: #dc2626;
|
||||||
|
color: #dc2626;
|
||||||
|
background: #fef2f2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.game-start-container {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
min-height: calc(100vh - 64px - 80px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-start {
|
||||||
|
padding: 14px 32px;
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ security:
|
|||||||
- { path: ^/login, roles: PUBLIC_ACCESS }
|
- { path: ^/login, roles: PUBLIC_ACCESS }
|
||||||
- { path: ^/register, roles: PUBLIC_ACCESS }
|
- { path: ^/register, roles: PUBLIC_ACCESS }
|
||||||
- { path: ^/$, roles: PUBLIC_ACCESS }
|
- { path: ^/$, roles: PUBLIC_ACCESS }
|
||||||
|
- { path: ^/game, roles: PUBLIC_ACCESS }
|
||||||
- { path: ^/, roles: ROLE_USER }
|
- { path: ^/, roles: ROLE_USER }
|
||||||
|
|
||||||
when@test:
|
when@test:
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ services:
|
|||||||
APP_ENV: dev
|
APP_ENV: dev
|
||||||
volumes:
|
volumes:
|
||||||
- .:/app
|
- .:/app
|
||||||
- vendor:/app/vendor
|
|
||||||
ports:
|
ports:
|
||||||
- "${PORT_80:-80}:80"
|
- "${PORT_80:-80}:80"
|
||||||
|
|
||||||
@@ -24,7 +23,6 @@ services:
|
|||||||
image: git.lclr.dev/thibaud-lclr/ltbxd-actorle/node:latest
|
image: git.lclr.dev/thibaud-lclr/ltbxd-actorle/node:latest
|
||||||
volumes:
|
volumes:
|
||||||
- .:/app
|
- .:/app
|
||||||
- vendor:/app/vendor
|
|
||||||
- node_modules:/app/node_modules
|
- node_modules:/app/node_modules
|
||||||
ports:
|
ports:
|
||||||
- "${PORT_5173:-5173}:5173"
|
- "${PORT_5173:-5173}:5173"
|
||||||
@@ -32,5 +30,4 @@ services:
|
|||||||
- app
|
- app
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
vendor:
|
|
||||||
node_modules:
|
node_modules:
|
||||||
|
|||||||
846
docs/superpowers/plans/2026-03-30-game-persistence.md
Normal file
846
docs/superpowers/plans/2026-03-30-game-persistence.md
Normal file
@@ -0,0 +1,846 @@
|
|||||||
|
# Game Grid Persistence — Implementation Plan
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||||
|
|
||||||
|
**Goal:** Persist game grids in the database so players can resume their current game, start new games explicitly, and abandon games in progress.
|
||||||
|
|
||||||
|
**Architecture:** Two new Doctrine entities (`Game`, `GameRow`) store the grid definition. A `GameGridGenerator` service encapsulates grid creation logic extracted from `HomepageController`. The homepage checks for an active game (via User or session) and renders either the grid or a "Start a game" button. A `GameController` handles start/abandon actions.
|
||||||
|
|
||||||
|
**Tech Stack:** Symfony 8, Doctrine ORM, PostgreSQL, Twig, React (unchanged components)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 1: Create the `Game` entity
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `src/Entity/Game.php`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Create the Game entity**
|
||||||
|
|
||||||
|
```php
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Entity;
|
||||||
|
|
||||||
|
use App\Repository\GameRepository;
|
||||||
|
use Doctrine\Common\Collections\ArrayCollection;
|
||||||
|
use Doctrine\Common\Collections\Collection;
|
||||||
|
use Doctrine\ORM\Mapping as ORM;
|
||||||
|
|
||||||
|
#[ORM\Entity(repositoryClass: GameRepository::class)]
|
||||||
|
class Game
|
||||||
|
{
|
||||||
|
public const string STATUS_IN_PROGRESS = 'in_progress';
|
||||||
|
public const string STATUS_ABANDONED = 'abandoned';
|
||||||
|
|
||||||
|
#[ORM\Id]
|
||||||
|
#[ORM\GeneratedValue]
|
||||||
|
#[ORM\Column]
|
||||||
|
private ?int $id = null;
|
||||||
|
|
||||||
|
#[ORM\ManyToOne(targetEntity: User::class)]
|
||||||
|
#[ORM\JoinColumn(nullable: true)]
|
||||||
|
private ?User $user = null;
|
||||||
|
|
||||||
|
#[ORM\ManyToOne(targetEntity: Actor::class)]
|
||||||
|
#[ORM\JoinColumn(nullable: false)]
|
||||||
|
private ?Actor $mainActor = null;
|
||||||
|
|
||||||
|
#[ORM\Column(length: 20)]
|
||||||
|
private string $status = self::STATUS_IN_PROGRESS;
|
||||||
|
|
||||||
|
#[ORM\Column]
|
||||||
|
private \DateTimeImmutable $startedAt;
|
||||||
|
|
||||||
|
#[ORM\Column(nullable: true)]
|
||||||
|
private ?\DateTimeImmutable $endedAt = null;
|
||||||
|
|
||||||
|
/** @var Collection<int, GameRow> */
|
||||||
|
#[ORM\OneToMany(targetEntity: GameRow::class, mappedBy: 'game', cascade: ['persist'], orphanRemoval: true)]
|
||||||
|
#[ORM\OrderBy(['rowOrder' => 'ASC'])]
|
||||||
|
private Collection $rows;
|
||||||
|
|
||||||
|
public function __construct()
|
||||||
|
{
|
||||||
|
$this->startedAt = new \DateTimeImmutable();
|
||||||
|
$this->rows = new ArrayCollection();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getId(): ?int
|
||||||
|
{
|
||||||
|
return $this->id;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getUser(): ?User
|
||||||
|
{
|
||||||
|
return $this->user;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setUser(?User $user): static
|
||||||
|
{
|
||||||
|
$this->user = $user;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getMainActor(): ?Actor
|
||||||
|
{
|
||||||
|
return $this->mainActor;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setMainActor(Actor $mainActor): static
|
||||||
|
{
|
||||||
|
$this->mainActor = $mainActor;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getStatus(): string
|
||||||
|
{
|
||||||
|
return $this->status;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setStatus(string $status): static
|
||||||
|
{
|
||||||
|
$this->status = $status;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getStartedAt(): \DateTimeImmutable
|
||||||
|
{
|
||||||
|
return $this->startedAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getEndedAt(): ?\DateTimeImmutable
|
||||||
|
{
|
||||||
|
return $this->endedAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setEndedAt(?\DateTimeImmutable $endedAt): static
|
||||||
|
{
|
||||||
|
$this->endedAt = $endedAt;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return Collection<int, GameRow> */
|
||||||
|
public function getRows(): Collection
|
||||||
|
{
|
||||||
|
return $this->rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function addRow(GameRow $row): static
|
||||||
|
{
|
||||||
|
if (!$this->rows->contains($row)) {
|
||||||
|
$this->rows->add($row);
|
||||||
|
$row->setGame($this);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function abandon(): static
|
||||||
|
{
|
||||||
|
$this->status = self::STATUS_ABANDONED;
|
||||||
|
$this->endedAt = new \DateTimeImmutable();
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/Entity/Game.php
|
||||||
|
git commit -m "feat: add Game entity"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 2: Create the `GameRow` entity
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `src/Entity/GameRow.php`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Create the GameRow entity**
|
||||||
|
|
||||||
|
```php
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Entity;
|
||||||
|
|
||||||
|
use App\Repository\GameRowRepository;
|
||||||
|
use Doctrine\ORM\Mapping as ORM;
|
||||||
|
|
||||||
|
#[ORM\Entity(repositoryClass: GameRowRepository::class)]
|
||||||
|
class GameRow
|
||||||
|
{
|
||||||
|
#[ORM\Id]
|
||||||
|
#[ORM\GeneratedValue]
|
||||||
|
#[ORM\Column]
|
||||||
|
private ?int $id = null;
|
||||||
|
|
||||||
|
#[ORM\ManyToOne(targetEntity: Game::class, inversedBy: 'rows')]
|
||||||
|
#[ORM\JoinColumn(nullable: false)]
|
||||||
|
private ?Game $game = null;
|
||||||
|
|
||||||
|
#[ORM\ManyToOne(targetEntity: Actor::class)]
|
||||||
|
#[ORM\JoinColumn(nullable: false)]
|
||||||
|
private ?Actor $actor = null;
|
||||||
|
|
||||||
|
#[ORM\Column]
|
||||||
|
private int $position;
|
||||||
|
|
||||||
|
#[ORM\Column]
|
||||||
|
private int $rowOrder;
|
||||||
|
|
||||||
|
public function getId(): ?int
|
||||||
|
{
|
||||||
|
return $this->id;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getGame(): ?Game
|
||||||
|
{
|
||||||
|
return $this->game;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setGame(Game $game): static
|
||||||
|
{
|
||||||
|
$this->game = $game;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getActor(): ?Actor
|
||||||
|
{
|
||||||
|
return $this->actor;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setActor(Actor $actor): static
|
||||||
|
{
|
||||||
|
$this->actor = $actor;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getPosition(): int
|
||||||
|
{
|
||||||
|
return $this->position;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setPosition(int $position): static
|
||||||
|
{
|
||||||
|
$this->position = $position;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getRowOrder(): int
|
||||||
|
{
|
||||||
|
return $this->rowOrder;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setRowOrder(int $rowOrder): static
|
||||||
|
{
|
||||||
|
$this->rowOrder = $rowOrder;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/Entity/GameRow.php
|
||||||
|
git commit -m "feat: add GameRow entity"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 3: Create the repositories
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `src/Repository/GameRepository.php`
|
||||||
|
- Create: `src/Repository/GameRowRepository.php`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Create GameRepository with findActiveForUser method**
|
||||||
|
|
||||||
|
```php
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Repository;
|
||||||
|
|
||||||
|
use App\Entity\Game;
|
||||||
|
use App\Entity\User;
|
||||||
|
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
|
||||||
|
use Doctrine\Persistence\ManagerRegistry;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @extends ServiceEntityRepository<Game>
|
||||||
|
*/
|
||||||
|
class GameRepository extends ServiceEntityRepository
|
||||||
|
{
|
||||||
|
public function __construct(ManagerRegistry $registry)
|
||||||
|
{
|
||||||
|
parent::__construct($registry, Game::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function findActiveForUser(User $user): ?Game
|
||||||
|
{
|
||||||
|
return $this->createQueryBuilder('g')
|
||||||
|
->andWhere('g.user = :user')
|
||||||
|
->andWhere('g.status = :status')
|
||||||
|
->setParameter('user', $user)
|
||||||
|
->setParameter('status', Game::STATUS_IN_PROGRESS)
|
||||||
|
->setMaxResults(1)
|
||||||
|
->getQuery()
|
||||||
|
->getOneOrNullResult();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Create GameRowRepository (empty, standard)**
|
||||||
|
|
||||||
|
```php
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Repository;
|
||||||
|
|
||||||
|
use App\Entity\GameRow;
|
||||||
|
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
|
||||||
|
use Doctrine\Persistence\ManagerRegistry;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @extends ServiceEntityRepository<GameRow>
|
||||||
|
*/
|
||||||
|
class GameRowRepository extends ServiceEntityRepository
|
||||||
|
{
|
||||||
|
public function __construct(ManagerRegistry $registry)
|
||||||
|
{
|
||||||
|
parent::__construct($registry, GameRow::class);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/Repository/GameRepository.php src/Repository/GameRowRepository.php
|
||||||
|
git commit -m "feat: add Game and GameRow repositories"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 4: Generate and run the database migration
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `migrations/Version2026033000001.php` (auto-generated)
|
||||||
|
|
||||||
|
- [ ] **Step 1: Generate the migration**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
make db:migration
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: A new migration file is created in `migrations/` with `CREATE TABLE game` and `CREATE TABLE game_row` statements plus foreign keys.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Review the generated migration**
|
||||||
|
|
||||||
|
Read the generated migration file. Verify it contains:
|
||||||
|
- `game` table with columns: `id`, `user_id` (nullable FK → user), `main_actor_id` (FK → actor), `status`, `started_at`, `ended_at` (nullable)
|
||||||
|
- `game_row` table with columns: `id`, `game_id` (FK → game), `actor_id` (FK → actor), `position`, `row_order`
|
||||||
|
- Proper foreign key constraints
|
||||||
|
|
||||||
|
- [ ] **Step 3: Run the migration**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
make db:migrate
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: Migration executes successfully.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add migrations/
|
||||||
|
git commit -m "feat: add migration for game and game_row tables"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 5: Create the `GameGridGenerator` service
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `src/Service/GameGridGenerator.php`
|
||||||
|
|
||||||
|
This extracts the grid generation logic from `HomepageController::index` (lines 25-72) into a dedicated service that creates and persists a `Game` with its `GameRow` entities.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Create the service**
|
||||||
|
|
||||||
|
```php
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Service;
|
||||||
|
|
||||||
|
use App\Entity\Actor;
|
||||||
|
use App\Entity\Game;
|
||||||
|
use App\Entity\GameRow;
|
||||||
|
use App\Entity\User;
|
||||||
|
use App\Repository\ActorRepository;
|
||||||
|
use Doctrine\ORM\EntityManagerInterface;
|
||||||
|
|
||||||
|
class GameGridGenerator
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly ActorRepository $actorRepository,
|
||||||
|
private readonly EntityManagerInterface $em,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function generate(?User $user = null): Game
|
||||||
|
{
|
||||||
|
$mainActor = $this->actorRepository->findOneRandom(4);
|
||||||
|
|
||||||
|
$game = new Game();
|
||||||
|
$game->setMainActor($mainActor);
|
||||||
|
$game->setUser($user);
|
||||||
|
|
||||||
|
$usedActors = [$mainActor->getId()];
|
||||||
|
$rowOrder = 0;
|
||||||
|
|
||||||
|
foreach (str_split(strtolower($mainActor->getName())) as $char) {
|
||||||
|
if (!preg_match('/[a-z]/', $char)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$tryFindActor = 0;
|
||||||
|
do {
|
||||||
|
$actor = $this->actorRepository->findOneRandom(4, $char);
|
||||||
|
++$tryFindActor;
|
||||||
|
} while (
|
||||||
|
in_array($actor->getId(), $usedActors)
|
||||||
|
|| $tryFindActor < 5
|
||||||
|
);
|
||||||
|
|
||||||
|
$usedActors[] = $actor->getId();
|
||||||
|
|
||||||
|
$row = new GameRow();
|
||||||
|
$row->setActor($actor);
|
||||||
|
$row->setPosition(strpos(strtolower($actor->getName()), $char));
|
||||||
|
$row->setRowOrder($rowOrder);
|
||||||
|
|
||||||
|
$game->addRow($row);
|
||||||
|
++$rowOrder;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->em->persist($game);
|
||||||
|
$this->em->flush();
|
||||||
|
|
||||||
|
return $game;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compute display data (grid, width, middle) from a Game entity for the React component.
|
||||||
|
*
|
||||||
|
* @return array{grid: list<array{actorName: string, actorId: int, pos: int}>, width: int, middle: int}
|
||||||
|
*/
|
||||||
|
public function computeGridData(Game $game): array
|
||||||
|
{
|
||||||
|
$leftSize = 0;
|
||||||
|
$rightSize = 0;
|
||||||
|
$grid = [];
|
||||||
|
|
||||||
|
foreach ($game->getRows() as $row) {
|
||||||
|
$actor = $row->getActor();
|
||||||
|
$pos = $row->getPosition();
|
||||||
|
|
||||||
|
if ($leftSize < $pos) {
|
||||||
|
$leftSize = $pos;
|
||||||
|
}
|
||||||
|
|
||||||
|
$rightSizeActor = strlen($actor->getName()) - $pos - 1;
|
||||||
|
if ($rightSize < $rightSizeActor) {
|
||||||
|
$rightSize = $rightSizeActor;
|
||||||
|
}
|
||||||
|
|
||||||
|
$grid[] = [
|
||||||
|
'actorName' => $actor->getName(),
|
||||||
|
'actorId' => $actor->getId(),
|
||||||
|
'pos' => $pos,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'grid' => $grid,
|
||||||
|
'width' => $rightSize + $leftSize + 1,
|
||||||
|
'middle' => $leftSize,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/Service/GameGridGenerator.php
|
||||||
|
git commit -m "feat: add GameGridGenerator service"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 6: Create the `GameController`
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `src/Controller/GameController.php`
|
||||||
|
- Modify: `config/packages/security.yaml` (add `/game` routes as PUBLIC_ACCESS)
|
||||||
|
|
||||||
|
- [ ] **Step 1: Create the controller**
|
||||||
|
|
||||||
|
```php
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Controller;
|
||||||
|
|
||||||
|
use App\Entity\Game;
|
||||||
|
use App\Entity\User;
|
||||||
|
use App\Repository\GameRepository;
|
||||||
|
use App\Service\GameGridGenerator;
|
||||||
|
use Doctrine\ORM\EntityManagerInterface;
|
||||||
|
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||||
|
use Symfony\Component\HttpFoundation\Request;
|
||||||
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
|
use Symfony\Component\Routing\Attribute\Route;
|
||||||
|
|
||||||
|
class GameController extends AbstractController
|
||||||
|
{
|
||||||
|
#[Route('/game/start', name: 'app_game_start', methods: ['POST'])]
|
||||||
|
public function start(
|
||||||
|
Request $request,
|
||||||
|
GameGridGenerator $generator,
|
||||||
|
GameRepository $gameRepository,
|
||||||
|
): Response {
|
||||||
|
$this->validateCsrfToken('game_start', $request);
|
||||||
|
|
||||||
|
/** @var User|null $user */
|
||||||
|
$user = $this->getUser();
|
||||||
|
|
||||||
|
// Check no game already in progress
|
||||||
|
if ($user) {
|
||||||
|
$existing = $gameRepository->findActiveForUser($user);
|
||||||
|
} else {
|
||||||
|
$gameId = $request->getSession()->get('current_game_id');
|
||||||
|
$existing = $gameId ? $gameRepository->find($gameId) : null;
|
||||||
|
if ($existing && $existing->getStatus() !== Game::STATUS_IN_PROGRESS) {
|
||||||
|
$existing = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($existing) {
|
||||||
|
return $this->redirectToRoute('app_homepage');
|
||||||
|
}
|
||||||
|
|
||||||
|
$game = $generator->generate($user);
|
||||||
|
|
||||||
|
if (!$user) {
|
||||||
|
$request->getSession()->set('current_game_id', $game->getId());
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->redirectToRoute('app_homepage');
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Route('/game/{id}/abandon', name: 'app_game_abandon', methods: ['POST'])]
|
||||||
|
public function abandon(
|
||||||
|
Game $game,
|
||||||
|
Request $request,
|
||||||
|
EntityManagerInterface $em,
|
||||||
|
): Response {
|
||||||
|
$this->validateCsrfToken('game_abandon', $request);
|
||||||
|
|
||||||
|
/** @var User|null $user */
|
||||||
|
$user = $this->getUser();
|
||||||
|
|
||||||
|
// Verify ownership
|
||||||
|
if ($user) {
|
||||||
|
if ($game->getUser() !== $user) {
|
||||||
|
throw $this->createAccessDeniedException();
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
$sessionGameId = $request->getSession()->get('current_game_id');
|
||||||
|
if ($game->getId() !== $sessionGameId) {
|
||||||
|
throw $this->createAccessDeniedException();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$game->abandon();
|
||||||
|
$em->flush();
|
||||||
|
|
||||||
|
if (!$user) {
|
||||||
|
$request->getSession()->remove('current_game_id');
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->redirectToRoute('app_homepage');
|
||||||
|
}
|
||||||
|
|
||||||
|
private function validateCsrfToken(string $tokenId, Request $request): void
|
||||||
|
{
|
||||||
|
$token = $request->request->get('_token');
|
||||||
|
if (!$this->isCsrfTokenValid($tokenId, $token)) {
|
||||||
|
throw $this->createAccessDeniedException('Invalid CSRF token.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Add `/game` routes to security access_control**
|
||||||
|
|
||||||
|
In `config/packages/security.yaml`, add a rule so `/game` routes are accessible to anonymous users (the controller handles auth logic itself):
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
access_control:
|
||||||
|
- { path: ^/login, roles: PUBLIC_ACCESS }
|
||||||
|
- { path: ^/register, roles: PUBLIC_ACCESS }
|
||||||
|
- { path: ^/$, roles: PUBLIC_ACCESS }
|
||||||
|
- { path: ^/game, roles: PUBLIC_ACCESS }
|
||||||
|
- { path: ^/, roles: ROLE_USER }
|
||||||
|
```
|
||||||
|
|
||||||
|
The new line `- { path: ^/game, roles: PUBLIC_ACCESS }` must go **before** the catch-all `^/` rule.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/Controller/GameController.php config/packages/security.yaml
|
||||||
|
git commit -m "feat: add GameController with start and abandon actions"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 7: Update `HomepageController` to use persisted games
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/Controller/HomepageController.php`
|
||||||
|
|
||||||
|
Replace the on-the-fly grid generation with a lookup of the active game.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Rewrite HomepageController::index**
|
||||||
|
|
||||||
|
Replace the entire content of `src/Controller/HomepageController.php` with:
|
||||||
|
|
||||||
|
```php
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Controller;
|
||||||
|
|
||||||
|
use App\Entity\Game;
|
||||||
|
use App\Entity\User;
|
||||||
|
use App\Repository\GameRepository;
|
||||||
|
use App\Service\GameGridGenerator;
|
||||||
|
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||||
|
use Symfony\Component\HttpFoundation\Request;
|
||||||
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
|
use Symfony\Component\Routing\Attribute\Route;
|
||||||
|
|
||||||
|
class HomepageController extends AbstractController
|
||||||
|
{
|
||||||
|
#[Route('/', name: 'app_homepage')]
|
||||||
|
public function index(
|
||||||
|
Request $request,
|
||||||
|
GameRepository $gameRepository,
|
||||||
|
GameGridGenerator $gridGenerator,
|
||||||
|
): Response {
|
||||||
|
/** @var User|null $user */
|
||||||
|
$user = $this->getUser();
|
||||||
|
|
||||||
|
$game = null;
|
||||||
|
|
||||||
|
if ($user) {
|
||||||
|
$game = $gameRepository->findActiveForUser($user);
|
||||||
|
} else {
|
||||||
|
$gameId = $request->getSession()->get('current_game_id');
|
||||||
|
if ($gameId) {
|
||||||
|
$game = $gameRepository->find($gameId);
|
||||||
|
if (!$game || $game->getStatus() !== Game::STATUS_IN_PROGRESS) {
|
||||||
|
$request->getSession()->remove('current_game_id');
|
||||||
|
$game = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!$game) {
|
||||||
|
return $this->render('homepage/index.html.twig', [
|
||||||
|
'game' => null,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$gridData = $gridGenerator->computeGridData($game);
|
||||||
|
|
||||||
|
return $this->render('homepage/index.html.twig', [
|
||||||
|
'game' => $game,
|
||||||
|
'grid' => $gridData['grid'],
|
||||||
|
'width' => $gridData['width'],
|
||||||
|
'middle' => $gridData['middle'],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/Controller/HomepageController.php
|
||||||
|
git commit -m "refactor: use persisted games in HomepageController"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 8: Update the homepage template
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `templates/homepage/index.html.twig`
|
||||||
|
- Modify: `assets/styles/app.css` (add game action bar styles)
|
||||||
|
|
||||||
|
- [ ] **Step 1: Update the template for 3 states**
|
||||||
|
|
||||||
|
Replace the content of `templates/homepage/index.html.twig` with:
|
||||||
|
|
||||||
|
```twig
|
||||||
|
{% extends 'base.html.twig' %}
|
||||||
|
|
||||||
|
{% block body %}
|
||||||
|
{% if game %}
|
||||||
|
<div class="game-actions">
|
||||||
|
<form method="post" action="{{ path('app_game_abandon', {id: game.id}) }}">
|
||||||
|
<input type="hidden" name="_token" value="{{ csrf_token('game_abandon') }}">
|
||||||
|
<button type="submit" class="btn btn-abandon">Abandonner</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div {{ react_component('GameGrid', {
|
||||||
|
grid: grid,
|
||||||
|
width: width,
|
||||||
|
middle: middle,
|
||||||
|
}) }}></div>
|
||||||
|
{% else %}
|
||||||
|
<div class="game-start-container">
|
||||||
|
<form method="post" action="{{ path('app_game_start') }}">
|
||||||
|
<input type="hidden" name="_token" value="{{ csrf_token('game_start') }}">
|
||||||
|
<button type="submit" class="btn btn-primary btn-start">Commencer une partie</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% endblock %}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Add CSS for game action bar and start button**
|
||||||
|
|
||||||
|
Add the following at the end of `assets/styles/app.css`:
|
||||||
|
|
||||||
|
```css
|
||||||
|
/* ── Game actions ── */
|
||||||
|
|
||||||
|
.game-actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
padding: 12px 40px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-abandon {
|
||||||
|
padding: 7px 16px;
|
||||||
|
background: none;
|
||||||
|
color: var(--text-muted);
|
||||||
|
border: 1.5px solid var(--border);
|
||||||
|
border-radius: 100px;
|
||||||
|
font-family: 'Inter', sans-serif;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: border-color 0.15s, color 0.15s, background 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-abandon:hover {
|
||||||
|
border-color: #dc2626;
|
||||||
|
color: #dc2626;
|
||||||
|
background: #fef2f2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.game-start-container {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
min-height: calc(100vh - 64px - 80px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-start {
|
||||||
|
padding: 14px 32px;
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add templates/homepage/index.html.twig assets/styles/app.css
|
||||||
|
git commit -m "feat: update homepage template with start/abandon game UI"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 9: Manual verification
|
||||||
|
|
||||||
|
- [ ] **Step 1: Start the dev environment**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
make dev:up
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run the migration**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
make db:migrate
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Test the flow as anonymous user**
|
||||||
|
|
||||||
|
1. Open `http://localhost` — should see "Commencer une partie" button
|
||||||
|
2. Click the button — should see a game grid with "Abandonner" button above
|
||||||
|
3. Refresh the page — the same grid should still be there
|
||||||
|
4. Click "Abandonner" — should redirect to homepage with the "Commencer une partie" button
|
||||||
|
|
||||||
|
- [ ] **Step 4: Test the flow as connected user**
|
||||||
|
|
||||||
|
1. Log in
|
||||||
|
2. Should see "Commencer une partie" button
|
||||||
|
3. Start a game — grid appears with "Abandonner"
|
||||||
|
4. Refresh — same grid persisted
|
||||||
|
5. Abandon — back to "Commencer une partie"
|
||||||
|
|
||||||
|
- [ ] **Step 5: Verify database**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose exec app php bin/console doctrine:query:sql "SELECT id, user_id, main_actor_id, status, started_at, ended_at FROM game ORDER BY id DESC LIMIT 5"
|
||||||
|
```
|
||||||
|
|
||||||
|
Verify games have correct status (`in_progress` → `abandoned` after abandon), timestamps, and user_id (null for anonymous, set for connected).
|
||||||
|
|
||||||
|
- [ ] **Step 6: Final commit (if any fixes needed)**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add -A
|
||||||
|
git commit -m "fix: address issues found during manual verification"
|
||||||
|
```
|
||||||
108
docs/superpowers/specs/2026-03-30-game-persistence-design.md
Normal file
108
docs/superpowers/specs/2026-03-30-game-persistence-design.md
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
# Game Grid Persistence
|
||||||
|
|
||||||
|
## Problem
|
||||||
|
|
||||||
|
Game grids are currently generated on the fly at every page load in `HomepageController`. There is no persistence — refreshing the page gives a new grid. Players cannot resume a game.
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Persist game grids so players can resume their current game. Display a "Start a game" button when no game is in progress. Allow players to abandon a game.
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
- Grid generation and persistence only
|
||||||
|
- No win/loss logic (will be added later)
|
||||||
|
- No saving of player input (filled letters)
|
||||||
|
|
||||||
|
## Data Model
|
||||||
|
|
||||||
|
### Entity: `Game`
|
||||||
|
|
||||||
|
| Field | Type | Description |
|
||||||
|
|-------------|-------------------------|------------------------------------------------|
|
||||||
|
| `id` | int (auto) | Primary key |
|
||||||
|
| `user` | ManyToOne → User, nullable | Null for anonymous players |
|
||||||
|
| `mainActor` | ManyToOne → Actor | The actor to guess |
|
||||||
|
| `status` | string | `in_progress` or `abandoned` (extensible to `won` later) |
|
||||||
|
| `startedAt` | DateTimeImmutable | When the game started |
|
||||||
|
| `endedAt` | DateTimeImmutable, nullable | When the game ended (abandon or win) |
|
||||||
|
|
||||||
|
### Entity: `GameRow`
|
||||||
|
|
||||||
|
| Field | Type | Description |
|
||||||
|
|------------|--------------------|------------------------------------------------|
|
||||||
|
| `id` | int (auto) | Primary key |
|
||||||
|
| `game` | ManyToOne → Game | Parent game |
|
||||||
|
| `actor` | ManyToOne → Actor | The actor for this row |
|
||||||
|
| `position` | int | Index of the target letter in the actor's name |
|
||||||
|
| `rowOrder` | int | Order of this row in the grid |
|
||||||
|
|
||||||
|
`GameRow` is a separate entity (not JSON) to allow attaching hints/clues per row in a future iteration.
|
||||||
|
|
||||||
|
## Service: `GameGridGenerator`
|
||||||
|
|
||||||
|
Extracted from the current `HomepageController::index` logic:
|
||||||
|
|
||||||
|
1. Select a random main actor via `ActorRepository::findOneRandom()`
|
||||||
|
2. For each letter (a-z) in the main actor's name, find a random actor containing that letter
|
||||||
|
3. Create a `Game` entity with `status = in_progress` and `startedAt = now`
|
||||||
|
4. Create `GameRow` entities with the actor, letter position, and row order
|
||||||
|
5. Persist and flush
|
||||||
|
6. Return the `Game` entity
|
||||||
|
|
||||||
|
Also provides a method to compute grid display data (width, middle) from a `Game` and its rows, for passing to the React component.
|
||||||
|
|
||||||
|
## Routes
|
||||||
|
|
||||||
|
### `GET /` — Homepage (modified)
|
||||||
|
|
||||||
|
Current behavior replaced:
|
||||||
|
|
||||||
|
1. If user is connected → query `GameRepository` for a `Game` with `status = in_progress` for this user
|
||||||
|
2. If anonymous → get `current_game_id` from the Symfony session, then look up the `Game` in DB
|
||||||
|
3. If a game is found and still `in_progress` → render the grid with an "Abandon" button above it
|
||||||
|
4. If no game is found → render a "Start a game" button
|
||||||
|
|
||||||
|
### `POST /game/start` — Start a new game (new)
|
||||||
|
|
||||||
|
1. Verify no game is already in progress for this player (connected or session)
|
||||||
|
2. Call `GameGridGenerator` to create the game
|
||||||
|
3. If user is connected → the game is linked to the user
|
||||||
|
4. If anonymous → store `$game->getId()` in session key `current_game_id`
|
||||||
|
5. Redirect to `/`
|
||||||
|
|
||||||
|
### `POST /game/{id}/abandon` — Abandon a game (new)
|
||||||
|
|
||||||
|
1. Verify the game belongs to the current player (user match or session match)
|
||||||
|
2. Set `status = abandoned` and `endedAt = now`
|
||||||
|
3. If anonymous → remove `current_game_id` from session
|
||||||
|
4. Redirect to `/`
|
||||||
|
|
||||||
|
Both POST routes use CSRF protection.
|
||||||
|
|
||||||
|
## Anonymous Session Handling
|
||||||
|
|
||||||
|
- Session key: `current_game_id` (stores the `Game` ID)
|
||||||
|
- Set on game creation, removed on abandon
|
||||||
|
- On homepage load: if the stored game ID no longer exists or is not `in_progress`, clean up the session key
|
||||||
|
- No migration of anonymous games on login — the anonymous game is lost when the user logs in. Acceptable since we don't save letter input.
|
||||||
|
|
||||||
|
## Frontend
|
||||||
|
|
||||||
|
### Template changes (`homepage/index.html.twig`)
|
||||||
|
|
||||||
|
Three states:
|
||||||
|
|
||||||
|
1. **No game in progress**: centered "Start a game" button (Twig form, POST to `/game/start`)
|
||||||
|
2. **Game in progress**: action bar above the grid with an "Abandon" button (Twig form, POST to `/game/{id}/abandon` with CSRF token), then the React `GameGrid` component below
|
||||||
|
3. **After abandon**: redirect to `/`, which shows state 1
|
||||||
|
|
||||||
|
### React components
|
||||||
|
|
||||||
|
No changes to `GameGrid`, `GameRow`, `LetterInput`, or `ActorPopover`. They receive the same props (`grid`, `width`, `middle`) — the data source changes from on-the-fly generation to database lookup.
|
||||||
|
|
||||||
|
## Future Extensibility
|
||||||
|
|
||||||
|
- **Win logic**: add `won` to the status enum, set `endedAt` on win
|
||||||
|
- **Hints/clues per row**: add a relation on `GameRow` (e.g., `GameRowHint` entity)
|
||||||
|
- **Game history/stats**: query `Game` entities by user with status filters
|
||||||
57
migrations/Version20260330174355.php
Normal file
57
migrations/Version20260330174355.php
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace DoctrineMigrations;
|
||||||
|
|
||||||
|
use Doctrine\DBAL\Schema\Schema;
|
||||||
|
use Doctrine\Migrations\AbstractMigration;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Auto-generated Migration: Please modify to your needs!
|
||||||
|
*/
|
||||||
|
final class Version20260330174355 extends AbstractMigration
|
||||||
|
{
|
||||||
|
public function getDescription(): string
|
||||||
|
{
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function up(Schema $schema): void
|
||||||
|
{
|
||||||
|
// this up() migration is auto-generated, please modify it to your needs
|
||||||
|
$this->addSql('CREATE TABLE game (id INT GENERATED BY DEFAULT AS IDENTITY NOT NULL, status VARCHAR(20) NOT NULL, started_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, ended_at TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL, user_id INT DEFAULT NULL, main_actor_id INT NOT NULL, PRIMARY KEY (id))');
|
||||||
|
$this->addSql('CREATE INDEX IDX_232B318CA76ED395 ON game (user_id)');
|
||||||
|
$this->addSql('CREATE INDEX IDX_232B318CC9F8E33F ON game (main_actor_id)');
|
||||||
|
$this->addSql('CREATE TABLE game_row (id INT GENERATED BY DEFAULT AS IDENTITY NOT NULL, position INT NOT NULL, row_order INT NOT NULL, game_id INT NOT NULL, actor_id INT NOT NULL, PRIMARY KEY (id))');
|
||||||
|
$this->addSql('CREATE INDEX IDX_9F6AE51EE48FD905 ON game_row (game_id)');
|
||||||
|
$this->addSql('CREATE INDEX IDX_9F6AE51E10DAF24A ON game_row (actor_id)');
|
||||||
|
$this->addSql('ALTER TABLE game ADD CONSTRAINT FK_232B318CA76ED395 FOREIGN KEY (user_id) REFERENCES "user" (id) NOT DEFERRABLE');
|
||||||
|
$this->addSql('ALTER TABLE game ADD CONSTRAINT FK_232B318CC9F8E33F FOREIGN KEY (main_actor_id) REFERENCES actor (id) NOT DEFERRABLE');
|
||||||
|
$this->addSql('ALTER TABLE game_row ADD CONSTRAINT FK_9F6AE51EE48FD905 FOREIGN KEY (game_id) REFERENCES game (id) NOT DEFERRABLE');
|
||||||
|
$this->addSql('ALTER TABLE game_row ADD CONSTRAINT FK_9F6AE51E10DAF24A FOREIGN KEY (actor_id) REFERENCES actor (id) NOT DEFERRABLE');
|
||||||
|
$this->addSql('COMMENT ON COLUMN import.created_at IS \'\'');
|
||||||
|
$this->addSql('COMMENT ON COLUMN import.completed_at IS \'\'');
|
||||||
|
$this->addSql('ALTER TABLE notification ALTER is_read DROP DEFAULT');
|
||||||
|
$this->addSql('COMMENT ON COLUMN notification.created_at IS \'\'');
|
||||||
|
$this->addSql('ALTER INDEX idx_a6b68b33a76ed395 RENAME TO IDX_FF9C0937A76ED395');
|
||||||
|
$this->addSql('ALTER INDEX idx_a6b68b338f93b6fc RENAME TO IDX_FF9C09378F93B6FC');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(Schema $schema): void
|
||||||
|
{
|
||||||
|
// this down() migration is auto-generated, please modify it to your needs
|
||||||
|
$this->addSql('ALTER TABLE game DROP CONSTRAINT FK_232B318CA76ED395');
|
||||||
|
$this->addSql('ALTER TABLE game DROP CONSTRAINT FK_232B318CC9F8E33F');
|
||||||
|
$this->addSql('ALTER TABLE game_row DROP CONSTRAINT FK_9F6AE51EE48FD905');
|
||||||
|
$this->addSql('ALTER TABLE game_row DROP CONSTRAINT FK_9F6AE51E10DAF24A');
|
||||||
|
$this->addSql('DROP TABLE game');
|
||||||
|
$this->addSql('DROP TABLE game_row');
|
||||||
|
$this->addSql('COMMENT ON COLUMN import.created_at IS \'(DC2Type:datetime_immutable)\'');
|
||||||
|
$this->addSql('COMMENT ON COLUMN import.completed_at IS \'(DC2Type:datetime_immutable)\'');
|
||||||
|
$this->addSql('ALTER TABLE notification ALTER is_read SET DEFAULT false');
|
||||||
|
$this->addSql('COMMENT ON COLUMN notification.created_at IS \'(DC2Type:datetime_immutable)\'');
|
||||||
|
$this->addSql('ALTER INDEX idx_ff9c09378f93b6fc RENAME TO idx_a6b68b338f93b6fc');
|
||||||
|
$this->addSql('ALTER INDEX idx_ff9c0937a76ed395 RENAME TO idx_a6b68b33a76ed395');
|
||||||
|
}
|
||||||
|
}
|
||||||
94
src/Controller/GameController.php
Normal file
94
src/Controller/GameController.php
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Controller;
|
||||||
|
|
||||||
|
use App\Entity\Game;
|
||||||
|
use App\Entity\User;
|
||||||
|
use App\Repository\GameRepository;
|
||||||
|
use App\Service\GameGridGenerator;
|
||||||
|
use Doctrine\ORM\EntityManagerInterface;
|
||||||
|
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||||
|
use Symfony\Component\HttpFoundation\Request;
|
||||||
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
|
use Symfony\Component\Routing\Attribute\Route;
|
||||||
|
|
||||||
|
class GameController extends AbstractController
|
||||||
|
{
|
||||||
|
#[Route('/game/start', name: 'app_game_start', methods: ['POST'])]
|
||||||
|
public function start(
|
||||||
|
Request $request,
|
||||||
|
GameGridGenerator $generator,
|
||||||
|
GameRepository $gameRepository,
|
||||||
|
): Response {
|
||||||
|
$this->validateCsrfToken('game_start', $request);
|
||||||
|
|
||||||
|
/** @var User|null $user */
|
||||||
|
$user = $this->getUser();
|
||||||
|
|
||||||
|
// Check no game already in progress
|
||||||
|
if ($user) {
|
||||||
|
$existing = $gameRepository->findActiveForUser($user);
|
||||||
|
} else {
|
||||||
|
$gameId = $request->getSession()->get('current_game_id');
|
||||||
|
$existing = $gameId ? $gameRepository->find($gameId) : null;
|
||||||
|
if ($existing && $existing->getStatus() !== Game::STATUS_IN_PROGRESS) {
|
||||||
|
$existing = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($existing) {
|
||||||
|
return $this->redirectToRoute('app_homepage');
|
||||||
|
}
|
||||||
|
|
||||||
|
$game = $generator->generate($user);
|
||||||
|
|
||||||
|
if (!$user) {
|
||||||
|
$request->getSession()->set('current_game_id', $game->getId());
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->redirectToRoute('app_homepage');
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Route('/game/{id}/abandon', name: 'app_game_abandon', methods: ['POST'])]
|
||||||
|
public function abandon(
|
||||||
|
Game $game,
|
||||||
|
Request $request,
|
||||||
|
EntityManagerInterface $em,
|
||||||
|
): Response {
|
||||||
|
$this->validateCsrfToken('game_abandon', $request);
|
||||||
|
|
||||||
|
/** @var User|null $user */
|
||||||
|
$user = $this->getUser();
|
||||||
|
|
||||||
|
// Verify ownership
|
||||||
|
if ($user) {
|
||||||
|
if ($game->getUser() !== $user) {
|
||||||
|
throw $this->createAccessDeniedException();
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
$sessionGameId = $request->getSession()->get('current_game_id');
|
||||||
|
if ($game->getId() !== $sessionGameId) {
|
||||||
|
throw $this->createAccessDeniedException();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$game->abandon();
|
||||||
|
$em->flush();
|
||||||
|
|
||||||
|
if (!$user) {
|
||||||
|
$request->getSession()->remove('current_game_id');
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->redirectToRoute('app_homepage');
|
||||||
|
}
|
||||||
|
|
||||||
|
private function validateCsrfToken(string $tokenId, Request $request): void
|
||||||
|
{
|
||||||
|
$token = $request->request->get('_token');
|
||||||
|
if (!$this->isCsrfTokenValid($tokenId, $token)) {
|
||||||
|
throw $this->createAccessDeniedException('Invalid CSRF token.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,77 +4,54 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
namespace App\Controller;
|
namespace App\Controller;
|
||||||
|
|
||||||
use App\Gateway\TMDBGateway;
|
use App\Entity\Game;
|
||||||
use App\Repository\ActorRepository;
|
use App\Entity\User;
|
||||||
use App\Repository\MovieRepository;
|
use App\Repository\GameRepository;
|
||||||
|
use App\Service\GameGridGenerator;
|
||||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||||
|
use Symfony\Component\HttpFoundation\Request;
|
||||||
use Symfony\Component\HttpFoundation\Response;
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
use Symfony\Component\Routing\Attribute\Route;
|
use Symfony\Component\Routing\Attribute\Route;
|
||||||
use Symfony\Component\Serializer\SerializerInterface;
|
|
||||||
|
|
||||||
class HomepageController extends AbstractController
|
class HomepageController extends AbstractController
|
||||||
{
|
{
|
||||||
public function __construct(
|
|
||||||
private readonly ActorRepository $actorRepository
|
|
||||||
) {}
|
|
||||||
|
|
||||||
#[Route('/', name: 'app_homepage')]
|
#[Route('/', name: 'app_homepage')]
|
||||||
public function index(SerializerInterface $serializer): Response
|
public function index(
|
||||||
{
|
Request $request,
|
||||||
// Final actor to be guessed
|
GameRepository $gameRepository,
|
||||||
$mainActor = $this->actorRepository->findOneRandom(4);
|
GameGridGenerator $gridGenerator,
|
||||||
|
): Response {
|
||||||
|
/** @var User|null $user */
|
||||||
|
$user = $this->getUser();
|
||||||
|
|
||||||
// Actors for the grid
|
$game = null;
|
||||||
$actors = [];
|
|
||||||
$leftSize = 0;
|
if ($user) {
|
||||||
$rightSize = 0;
|
$game = $gameRepository->findActiveForUser($user);
|
||||||
foreach (str_split(strtolower($mainActor->getName())) as $char) {
|
} else {
|
||||||
if (!preg_match('/[a-z]/', $char)) {
|
$gameId = $request->getSession()->get('current_game_id');
|
||||||
continue;
|
if ($gameId) {
|
||||||
|
$game = $gameRepository->find($gameId);
|
||||||
|
if (!$game || $game->getStatus() !== Game::STATUS_IN_PROGRESS) {
|
||||||
|
$request->getSession()->remove('current_game_id');
|
||||||
|
$game = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
$tryFindActor = 0;
|
if (!$game) {
|
||||||
do {
|
return $this->render('homepage/index.html.twig', [
|
||||||
$actor = $this->actorRepository->findOneRandom(4, $char);
|
'game' => null,
|
||||||
++$tryFindActor;
|
]);
|
||||||
} while (
|
|
||||||
$actor === $mainActor
|
|
||||||
|| in_array($actor, array_map(fn ($actorMap) => $actorMap['actor'], $actors))
|
|
||||||
|| $tryFindActor < 5
|
|
||||||
);
|
|
||||||
|
|
||||||
$actorData = [
|
|
||||||
'actor' => $actor,
|
|
||||||
'pos' => strpos($actor->getName(), $char),
|
|
||||||
];
|
|
||||||
|
|
||||||
if ($leftSize < $actorData['pos']) {
|
|
||||||
$leftSize = $actorData['pos'];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
$rightSizeActor = strlen($actor->getName()) - $actorData['pos'] - 1;
|
$gridData = $gridGenerator->computeGridData($game);
|
||||||
if ($rightSize < $rightSizeActor) {
|
|
||||||
$rightSize = $rightSizeActor;
|
|
||||||
}
|
|
||||||
|
|
||||||
$actors[] = $actorData;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Predict grid size
|
|
||||||
$width = $rightSize + $leftSize + 1;
|
|
||||||
$middle = $leftSize;
|
|
||||||
|
|
||||||
// Build JSON-serializable grid for React
|
|
||||||
$grid = array_map(fn (array $actorData) => [
|
|
||||||
'actorName' => $actorData['actor']->getName(),
|
|
||||||
'actorId' => $actorData['actor']->getId(),
|
|
||||||
'pos' => $actorData['pos'],
|
|
||||||
], $actors);
|
|
||||||
|
|
||||||
return $this->render('homepage/index.html.twig', [
|
return $this->render('homepage/index.html.twig', [
|
||||||
'grid' => $grid,
|
'game' => $game,
|
||||||
'width' => $width,
|
'grid' => $gridData['grid'],
|
||||||
'middle' => $middle,
|
'width' => $gridData['width'],
|
||||||
|
'middle' => $gridData['middle'],
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
132
src/Entity/Game.php
Normal file
132
src/Entity/Game.php
Normal file
@@ -0,0 +1,132 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Entity;
|
||||||
|
|
||||||
|
use App\Repository\GameRepository;
|
||||||
|
use Doctrine\Common\Collections\ArrayCollection;
|
||||||
|
use Doctrine\Common\Collections\Collection;
|
||||||
|
use Doctrine\ORM\Mapping as ORM;
|
||||||
|
|
||||||
|
#[ORM\Entity(repositoryClass: GameRepository::class)]
|
||||||
|
class Game
|
||||||
|
{
|
||||||
|
public const string STATUS_IN_PROGRESS = 'in_progress';
|
||||||
|
public const string STATUS_ABANDONED = 'abandoned';
|
||||||
|
|
||||||
|
#[ORM\Id]
|
||||||
|
#[ORM\GeneratedValue]
|
||||||
|
#[ORM\Column]
|
||||||
|
private ?int $id = null;
|
||||||
|
|
||||||
|
#[ORM\ManyToOne(targetEntity: User::class)]
|
||||||
|
#[ORM\JoinColumn(nullable: true)]
|
||||||
|
private ?User $user = null;
|
||||||
|
|
||||||
|
#[ORM\ManyToOne(targetEntity: Actor::class)]
|
||||||
|
#[ORM\JoinColumn(nullable: false)]
|
||||||
|
private ?Actor $mainActor = null;
|
||||||
|
|
||||||
|
#[ORM\Column(length: 20)]
|
||||||
|
private string $status = self::STATUS_IN_PROGRESS;
|
||||||
|
|
||||||
|
#[ORM\Column]
|
||||||
|
private \DateTimeImmutable $startedAt;
|
||||||
|
|
||||||
|
#[ORM\Column(nullable: true)]
|
||||||
|
private ?\DateTimeImmutable $endedAt = null;
|
||||||
|
|
||||||
|
/** @var Collection<int, GameRow> */
|
||||||
|
#[ORM\OneToMany(targetEntity: GameRow::class, mappedBy: 'game', cascade: ['persist'], orphanRemoval: true)]
|
||||||
|
#[ORM\OrderBy(['rowOrder' => 'ASC'])]
|
||||||
|
private Collection $rows;
|
||||||
|
|
||||||
|
public function __construct()
|
||||||
|
{
|
||||||
|
$this->startedAt = new \DateTimeImmutable();
|
||||||
|
$this->rows = new ArrayCollection();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getId(): ?int
|
||||||
|
{
|
||||||
|
return $this->id;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getUser(): ?User
|
||||||
|
{
|
||||||
|
return $this->user;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setUser(?User $user): static
|
||||||
|
{
|
||||||
|
$this->user = $user;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getMainActor(): ?Actor
|
||||||
|
{
|
||||||
|
return $this->mainActor;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setMainActor(Actor $mainActor): static
|
||||||
|
{
|
||||||
|
$this->mainActor = $mainActor;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getStatus(): string
|
||||||
|
{
|
||||||
|
return $this->status;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setStatus(string $status): static
|
||||||
|
{
|
||||||
|
$this->status = $status;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getStartedAt(): \DateTimeImmutable
|
||||||
|
{
|
||||||
|
return $this->startedAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getEndedAt(): ?\DateTimeImmutable
|
||||||
|
{
|
||||||
|
return $this->endedAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setEndedAt(?\DateTimeImmutable $endedAt): static
|
||||||
|
{
|
||||||
|
$this->endedAt = $endedAt;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return Collection<int, GameRow> */
|
||||||
|
public function getRows(): Collection
|
||||||
|
{
|
||||||
|
return $this->rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function addRow(GameRow $row): static
|
||||||
|
{
|
||||||
|
if (!$this->rows->contains($row)) {
|
||||||
|
$this->rows->add($row);
|
||||||
|
$row->setGame($this);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function abandon(): static
|
||||||
|
{
|
||||||
|
$this->status = self::STATUS_ABANDONED;
|
||||||
|
$this->endedAt = new \DateTimeImmutable();
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
}
|
||||||
84
src/Entity/GameRow.php
Normal file
84
src/Entity/GameRow.php
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Entity;
|
||||||
|
|
||||||
|
use App\Repository\GameRowRepository;
|
||||||
|
use Doctrine\ORM\Mapping as ORM;
|
||||||
|
|
||||||
|
#[ORM\Entity(repositoryClass: GameRowRepository::class)]
|
||||||
|
class GameRow
|
||||||
|
{
|
||||||
|
#[ORM\Id]
|
||||||
|
#[ORM\GeneratedValue]
|
||||||
|
#[ORM\Column]
|
||||||
|
private ?int $id = null;
|
||||||
|
|
||||||
|
#[ORM\ManyToOne(targetEntity: Game::class, inversedBy: 'rows')]
|
||||||
|
#[ORM\JoinColumn(nullable: false)]
|
||||||
|
private ?Game $game = null;
|
||||||
|
|
||||||
|
#[ORM\ManyToOne(targetEntity: Actor::class)]
|
||||||
|
#[ORM\JoinColumn(nullable: false)]
|
||||||
|
private ?Actor $actor = null;
|
||||||
|
|
||||||
|
#[ORM\Column]
|
||||||
|
private int $position;
|
||||||
|
|
||||||
|
#[ORM\Column]
|
||||||
|
private int $rowOrder;
|
||||||
|
|
||||||
|
public function getId(): ?int
|
||||||
|
{
|
||||||
|
return $this->id;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getGame(): ?Game
|
||||||
|
{
|
||||||
|
return $this->game;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setGame(Game $game): static
|
||||||
|
{
|
||||||
|
$this->game = $game;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getActor(): ?Actor
|
||||||
|
{
|
||||||
|
return $this->actor;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setActor(Actor $actor): static
|
||||||
|
{
|
||||||
|
$this->actor = $actor;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getPosition(): int
|
||||||
|
{
|
||||||
|
return $this->position;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setPosition(int $position): static
|
||||||
|
{
|
||||||
|
$this->position = $position;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getRowOrder(): int
|
||||||
|
{
|
||||||
|
return $this->rowOrder;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setRowOrder(int $rowOrder): static
|
||||||
|
{
|
||||||
|
$this->rowOrder = $rowOrder;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
}
|
||||||
39
src/EventListener/AbandonAnonymousGameOnLoginListener.php
Normal file
39
src/EventListener/AbandonAnonymousGameOnLoginListener.php
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\EventListener;
|
||||||
|
|
||||||
|
use App\Entity\Game;
|
||||||
|
use App\Repository\GameRepository;
|
||||||
|
use Doctrine\ORM\EntityManagerInterface;
|
||||||
|
use Symfony\Component\EventDispatcher\Attribute\AsEventListener;
|
||||||
|
use Symfony\Component\Security\Http\Event\LoginSuccessEvent;
|
||||||
|
|
||||||
|
#[AsEventListener]
|
||||||
|
class AbandonAnonymousGameOnLoginListener
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly GameRepository $gameRepository,
|
||||||
|
private readonly EntityManagerInterface $em,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function __invoke(LoginSuccessEvent $event): void
|
||||||
|
{
|
||||||
|
$session = $event->getRequest()->getSession();
|
||||||
|
$gameId = $session->get('current_game_id');
|
||||||
|
|
||||||
|
if (!$gameId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$game = $this->gameRepository->find($gameId);
|
||||||
|
|
||||||
|
if ($game && $game->getStatus() === Game::STATUS_IN_PROGRESS) {
|
||||||
|
$game->abandon();
|
||||||
|
$this->em->flush();
|
||||||
|
}
|
||||||
|
|
||||||
|
$session->remove('current_game_id');
|
||||||
|
}
|
||||||
|
}
|
||||||
33
src/Repository/GameRepository.php
Normal file
33
src/Repository/GameRepository.php
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Repository;
|
||||||
|
|
||||||
|
use App\Entity\Game;
|
||||||
|
use App\Entity\User;
|
||||||
|
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
|
||||||
|
use Doctrine\Persistence\ManagerRegistry;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @extends ServiceEntityRepository<Game>
|
||||||
|
*/
|
||||||
|
class GameRepository extends ServiceEntityRepository
|
||||||
|
{
|
||||||
|
public function __construct(ManagerRegistry $registry)
|
||||||
|
{
|
||||||
|
parent::__construct($registry, Game::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function findActiveForUser(User $user): ?Game
|
||||||
|
{
|
||||||
|
return $this->createQueryBuilder('g')
|
||||||
|
->andWhere('g.user = :user')
|
||||||
|
->andWhere('g.status = :status')
|
||||||
|
->setParameter('user', $user)
|
||||||
|
->setParameter('status', Game::STATUS_IN_PROGRESS)
|
||||||
|
->setMaxResults(1)
|
||||||
|
->getQuery()
|
||||||
|
->getOneOrNullResult();
|
||||||
|
}
|
||||||
|
}
|
||||||
20
src/Repository/GameRowRepository.php
Normal file
20
src/Repository/GameRowRepository.php
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Repository;
|
||||||
|
|
||||||
|
use App\Entity\GameRow;
|
||||||
|
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
|
||||||
|
use Doctrine\Persistence\ManagerRegistry;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @extends ServiceEntityRepository<GameRow>
|
||||||
|
*/
|
||||||
|
class GameRowRepository extends ServiceEntityRepository
|
||||||
|
{
|
||||||
|
public function __construct(ManagerRegistry $registry)
|
||||||
|
{
|
||||||
|
parent::__construct($registry, GameRow::class);
|
||||||
|
}
|
||||||
|
}
|
||||||
100
src/Service/GameGridGenerator.php
Normal file
100
src/Service/GameGridGenerator.php
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Service;
|
||||||
|
|
||||||
|
use App\Entity\Actor;
|
||||||
|
use App\Entity\Game;
|
||||||
|
use App\Entity\GameRow;
|
||||||
|
use App\Entity\User;
|
||||||
|
use App\Repository\ActorRepository;
|
||||||
|
use Doctrine\ORM\EntityManagerInterface;
|
||||||
|
|
||||||
|
class GameGridGenerator
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly ActorRepository $actorRepository,
|
||||||
|
private readonly EntityManagerInterface $em,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function generate(?User $user = null): Game
|
||||||
|
{
|
||||||
|
$mainActor = $this->actorRepository->findOneRandom(4);
|
||||||
|
|
||||||
|
$game = new Game();
|
||||||
|
$game->setMainActor($mainActor);
|
||||||
|
$game->setUser($user);
|
||||||
|
|
||||||
|
$usedActors = [$mainActor->getId()];
|
||||||
|
$rowOrder = 0;
|
||||||
|
|
||||||
|
foreach (str_split(strtolower($mainActor->getName())) as $char) {
|
||||||
|
if (!preg_match('/[a-z]/', $char)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$tryFindActor = 0;
|
||||||
|
do {
|
||||||
|
$actor = $this->actorRepository->findOneRandom(4, $char);
|
||||||
|
++$tryFindActor;
|
||||||
|
} while (
|
||||||
|
in_array($actor->getId(), $usedActors)
|
||||||
|
|| $tryFindActor < 5
|
||||||
|
);
|
||||||
|
|
||||||
|
$usedActors[] = $actor->getId();
|
||||||
|
|
||||||
|
$row = new GameRow();
|
||||||
|
$row->setActor($actor);
|
||||||
|
$row->setPosition(strpos(strtolower($actor->getName()), $char));
|
||||||
|
$row->setRowOrder($rowOrder);
|
||||||
|
|
||||||
|
$game->addRow($row);
|
||||||
|
++$rowOrder;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->em->persist($game);
|
||||||
|
$this->em->flush();
|
||||||
|
|
||||||
|
return $game;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compute display data (grid, width, middle) from a Game entity for the React component.
|
||||||
|
*
|
||||||
|
* @return array{grid: list<array{actorName: string, actorId: int, pos: int}>, width: int, middle: int}
|
||||||
|
*/
|
||||||
|
public function computeGridData(Game $game): array
|
||||||
|
{
|
||||||
|
$leftSize = 0;
|
||||||
|
$rightSize = 0;
|
||||||
|
$grid = [];
|
||||||
|
|
||||||
|
foreach ($game->getRows() as $row) {
|
||||||
|
$actor = $row->getActor();
|
||||||
|
$pos = $row->getPosition();
|
||||||
|
|
||||||
|
if ($leftSize < $pos) {
|
||||||
|
$leftSize = $pos;
|
||||||
|
}
|
||||||
|
|
||||||
|
$rightSizeActor = strlen($actor->getName()) - $pos - 1;
|
||||||
|
if ($rightSize < $rightSizeActor) {
|
||||||
|
$rightSize = $rightSizeActor;
|
||||||
|
}
|
||||||
|
|
||||||
|
$grid[] = [
|
||||||
|
'actorName' => $actor->getName(),
|
||||||
|
'actorId' => $actor->getId(),
|
||||||
|
'pos' => $pos,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'grid' => $grid,
|
||||||
|
'width' => $rightSize + $leftSize + 1,
|
||||||
|
'middle' => $leftSize,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,6 +5,14 @@
|
|||||||
<a href="{{ path('app_homepage') }}" class="navbar-brand">Actorle</a>
|
<a href="{{ path('app_homepage') }}" class="navbar-brand">Actorle</a>
|
||||||
</div>
|
</div>
|
||||||
<div class="navbar-right">
|
<div class="navbar-right">
|
||||||
|
{# Gitea repo #}
|
||||||
|
<a href="https://git.lclr.dev/thibaud-lclr/ltbxd-actorle" class="navbar-icon" target="_blank" rel="noopener noreferrer" title="Code source">
|
||||||
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<polyline points="16 18 22 12 16 6"/>
|
||||||
|
<polyline points="8 6 2 12 8 18"/>
|
||||||
|
</svg>
|
||||||
|
</a>
|
||||||
|
|
||||||
{# Notifications #}
|
{# Notifications #}
|
||||||
<div class="navbar-item" data-controller="dropdown">
|
<div class="navbar-item" data-controller="dropdown">
|
||||||
<button class="navbar-icon" data-action="click->dropdown#toggle click->notifications#markRead" data-dropdown-target="trigger">
|
<button class="navbar-icon" data-action="click->dropdown#toggle click->notifications#markRead" data-dropdown-target="trigger">
|
||||||
@@ -64,6 +72,12 @@
|
|||||||
<a href="{{ path('app_homepage') }}" class="navbar-brand">Actorle</a>
|
<a href="{{ path('app_homepage') }}" class="navbar-brand">Actorle</a>
|
||||||
</div>
|
</div>
|
||||||
<div class="navbar-right">
|
<div class="navbar-right">
|
||||||
|
<a href="https://git.lclr.dev/thibaud-lclr/ltbxd-actorle" class="navbar-icon" target="_blank" rel="noopener noreferrer" title="Code source">
|
||||||
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<polyline points="16 18 22 12 16 6"/>
|
||||||
|
<polyline points="8 6 2 12 8 18"/>
|
||||||
|
</svg>
|
||||||
|
</a>
|
||||||
<a href="{{ path('app_login') }}" class="btn btn-primary">Se connecter</a>
|
<a href="{{ path('app_login') }}" class="btn btn-primary">Se connecter</a>
|
||||||
</div>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
|
|||||||
@@ -1,9 +1,27 @@
|
|||||||
{% extends 'base.html.twig' %}
|
{% extends 'base.html.twig' %}
|
||||||
|
|
||||||
{% block body %}
|
{% block body %}
|
||||||
|
{% if game %}
|
||||||
|
<div class="game-container">
|
||||||
|
<div class="game-actions">
|
||||||
|
<form method="post" action="{{ path('app_game_abandon', {id: game.id}) }}">
|
||||||
|
<input type="hidden" name="_token" value="{{ csrf_token('game_abandon') }}">
|
||||||
|
<button type="submit" class="btn btn-abandon">Abandonner</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div {{ react_component('GameGrid', {
|
<div {{ react_component('GameGrid', {
|
||||||
grid: grid,
|
grid: grid,
|
||||||
width: width,
|
width: width,
|
||||||
middle: middle,
|
middle: middle,
|
||||||
}) }}></div>
|
}) }}></div>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="game-start-container">
|
||||||
|
<form method="post" action="{{ path('app_game_start') }}">
|
||||||
|
<input type="hidden" name="_token" value="{{ csrf_token('game_start') }}">
|
||||||
|
<button type="submit" class="btn btn-primary btn-start">Commencer une partie</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
Reference in New Issue
Block a user