From 90ca2b946d212f84b40b8a1ed8c3d209ca7ee84b Mon Sep 17 00:00:00 2001 From: thibaud-leclere Date: Mon, 30 Mar 2026 19:41:45 +0200 Subject: [PATCH] docs: add game persistence implementation plan Co-Authored-By: Claude Opus 4.6 (1M context) --- .../plans/2026-03-30-game-persistence.md | 846 ++++++++++++++++++ 1 file changed, 846 insertions(+) create mode 100644 docs/superpowers/plans/2026-03-30-game-persistence.md diff --git a/docs/superpowers/plans/2026-03-30-game-persistence.md b/docs/superpowers/plans/2026-03-30-game-persistence.md new file mode 100644 index 0000000..91d4462 --- /dev/null +++ b/docs/superpowers/plans/2026-03-30-game-persistence.md @@ -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 + */ + #[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 */ + 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 +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 + + */ +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 + + */ +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 +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, 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 +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 +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 %} +
+
+ + +
+
+ +
+ {% else %} +
+
+ + +
+
+ {% 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" +```