Combat tracker: multiple named encounters

- Encounter entity; Combatant now belongs to an encounter (ON DELETE
  CASCADE). Migration adds encounter_id, rehoming any existing combatants.
- /tracker lists encounters (name, count, last updated) with a "New
  encounter" form; /tracker/{id} is the HP tracker for one encounter.
- Encounters can be renamed, have their combatants cleared, or be deleted.
- The Enemies list gains an encounter picker next to "Add to tracker"
  (existing encounter or "— New encounter —", which prompts for a name);
  an encounter's "Add enemies" link pre-selects it via ?encounter=.
- Routes for combatant HP/remove moved under /tracker/combatant/ to keep
  the numeric {id} for encounters.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Frank
2026-09-06 19:24:37 +02:00
co-authored by Claude Sonnet 5
parent 9854e6c034
commit cbe4b0a0e1
11 changed files with 433 additions and 112 deletions
+6 -2
View File
@@ -6,6 +6,7 @@ use App\Entity\Enemy;
use App\Form\EnemyGameNotesType;
use App\Form\EnemyType;
use App\Repository\EnemyRepository;
use App\Repository\EncounterRepository;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
@@ -18,9 +19,12 @@ use Symfony\Component\Security\Http\Attribute\IsGranted;
class EnemyController extends AbstractController
{
#[Route('/enemies', name: 'app_enemies', methods: ['GET'])]
public function index(): Response
public function index(Request $request, EncounterRepository $encounters): Response
{
return $this->render('enemy/index.html.twig');
return $this->render('enemy/index.html.twig', [
'encounters' => $encounters->recent(),
'selected_encounter' => $request->query->get('encounter'),
]);
}
/**
+105 -34
View File
@@ -3,7 +3,8 @@
namespace App\Controller;
use App\Entity\Combatant;
use App\Repository\CombatantRepository;
use App\Entity\Encounter;
use App\Repository\EncounterRepository;
use App\Repository\EnemyRepository;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
@@ -16,25 +17,48 @@ use Symfony\Component\Security\Http\Attribute\IsGranted;
class TrackerController extends AbstractController
{
#[Route('/tracker', name: 'app_tracker', methods: ['GET'])]
public function index(CombatantRepository $repo): Response
public function index(EncounterRepository $repo): Response
{
return $this->render('tracker/index.html.twig', [
'combatants' => $repo->tracker(),
'encounters' => $repo->recent(),
]);
}
/**
* Add selected enemies (with quantities) from the enemy list to the tracker.
* Body: picks[i][id], picks[i][qty].
*/
#[Route('/tracker/new', name: 'app_tracker_new', methods: ['POST'])]
public function new(Request $request, EncounterRepository $repo, EntityManagerInterface $em): Response
{
if (!$this->isCsrfTokenValid('tracker-new', (string) $request->request->get('_token'))) {
return $this->redirectToRoute('app_tracker');
}
$encounter = new Encounter();
$encounter->setName($this->cleanName($request->request->get('name'), $repo));
$em->persist($encounter);
$em->flush();
return $this->redirectToRoute('app_tracker_show', ['id' => $encounter->getId()]);
}
#[Route('/tracker/add', name: 'app_tracker_add', methods: ['POST'])]
public function add(Request $request, EnemyRepository $enemies, CombatantRepository $repo, EntityManagerInterface $em): Response
public function add(Request $request, EnemyRepository $enemies, EncounterRepository $repo, EntityManagerInterface $em): Response
{
if (!$this->isCsrfTokenValid('track-add', (string) $request->request->get('_token'))) {
return $this->redirectToRoute('app_enemies');
}
$pos = $repo->nextPosition();
$target = (string) $request->request->get('encounter', 'new');
if ('new' === $target || '' === $target) {
$encounter = (new Encounter())->setName($this->cleanName($request->request->get('name'), $repo));
$em->persist($encounter);
} else {
$encounter = $repo->find((int) $target);
if (!$encounter) {
return $this->redirectToRoute('app_enemies');
}
}
$em->flush(); // so nextPosition / counts see a real id
$combatantRepo = $em->getRepository(Combatant::class);
$pos = $combatantRepo->nextPosition($encounter);
$added = 0;
foreach ($request->request->all('picks') as $pick) {
$enemy = isset($pick['id']) ? $enemies->find((int) $pick['id']) : null;
@@ -42,32 +66,80 @@ class TrackerController extends AbstractController
continue;
}
$qty = max(1, min(50, (int) ($pick['qty'] ?? 1)));
$existing = $repo->countFromEnemy($enemy);
$existing = $combatantRepo->countFromEnemy($encounter, $enemy);
for ($i = 1; $i <= $qty; ++$i) {
$n = $existing + $i;
$label = ($existing + $qty > 1) ? sprintf('%s %d', $enemy->getName(), $n) : $enemy->getName();
$label = ($existing + $qty > 1)
? sprintf('%s %d', $enemy->getName(), $existing + $i)
: $enemy->getName();
$c = (new Combatant())
->setEnemy($enemy)
->setLabel($label)
->setMaxHp($enemy->getHitPoints())
->setCurrentHp($enemy->getHitPoints())
->setPosition($pos++);
$em->persist($c);
$encounter->addCombatant(
(new Combatant())
->setEnemy($enemy)
->setLabel($label)
->setMaxHp($enemy->getHitPoints())
->setCurrentHp($enemy->getHitPoints())
->setPosition($pos++)
);
++$added;
}
}
$encounter->touch();
$em->flush();
if ($added) {
$this->addFlash('success', sprintf('Added %d combatant%s to the tracker.', $added, 1 === $added ? '' : 's'));
$this->addFlash('success', sprintf('Added %d combatant%s.', $added, 1 === $added ? '' : 's'));
}
return $this->redirectToRoute('app_tracker_show', ['id' => $encounter->getId()]);
}
#[Route('/tracker/{id}', name: 'app_tracker_show', requirements: ['id' => '\d+'], methods: ['GET'])]
public function show(Encounter $encounter): Response
{
return $this->render('tracker/show.html.twig', ['encounter' => $encounter]);
}
#[Route('/tracker/{id}/rename', name: 'app_tracker_rename', requirements: ['id' => '\d+'], methods: ['POST'])]
public function rename(Request $request, Encounter $encounter, EncounterRepository $repo, EntityManagerInterface $em): Response
{
if ($this->isCsrfTokenValid('tracker-op', (string) $request->request->get('_token'))) {
$encounter->setName($this->cleanName($request->request->get('name'), $repo));
$em->flush();
}
return $this->redirectToRoute('app_tracker_show', ['id' => $encounter->getId()]);
}
#[Route('/tracker/{id}/clear', name: 'app_tracker_clear', requirements: ['id' => '\d+'], methods: ['POST'])]
public function clear(Request $request, Encounter $encounter, EntityManagerInterface $em): Response
{
if ($this->isCsrfTokenValid('tracker-op', (string) $request->request->get('_token'))) {
foreach ($encounter->getCombatants()->toArray() as $c) {
$encounter->removeCombatant($c);
$em->remove($c);
}
$encounter->touch();
$em->flush();
$this->addFlash('success', 'Combatants cleared.');
}
return $this->redirectToRoute('app_tracker_show', ['id' => $encounter->getId()]);
}
#[Route('/tracker/{id}/delete', name: 'app_tracker_delete', requirements: ['id' => '\d+'], methods: ['POST'])]
public function delete(Request $request, Encounter $encounter, EntityManagerInterface $em): Response
{
if ($this->isCsrfTokenValid('tracker-op', (string) $request->request->get('_token'))) {
$em->remove($encounter);
$em->flush();
$this->addFlash('success', sprintf('Deleted "%s".', $encounter->getName()));
}
return $this->redirectToRoute('app_tracker');
}
#[Route('/tracker/{id}/hp', name: 'app_tracker_hp', requirements: ['id' => '\d+'], methods: ['POST'])]
#[Route('/tracker/combatant/{id}/hp', name: 'app_tracker_hp', requirements: ['id' => '\d+'], methods: ['POST'])]
public function hp(Request $request, Combatant $combatant, EntityManagerInterface $em): Response
{
$amount = abs((int) $request->request->get('amount', 0));
@@ -75,34 +147,33 @@ class TrackerController extends AbstractController
'heal' === $request->request->get('mode')
? $combatant->applyHealing($amount)
: $combatant->applyDamage($amount);
$combatant->getEncounter()?->touch();
$em->flush();
}
return $this->redirectToRoute('app_tracker');
return $this->redirectToRoute('app_tracker_show', ['id' => $combatant->getEncounter()?->getId()]);
}
#[Route('/tracker/{id}/remove', name: 'app_tracker_remove', requirements: ['id' => '\d+'], methods: ['POST'])]
public function remove(Request $request, Combatant $combatant, EntityManagerInterface $em): Response
#[Route('/tracker/combatant/{id}/remove', name: 'app_tracker_combatant_remove', requirements: ['id' => '\d+'], methods: ['POST'])]
public function removeCombatant(Request $request, Combatant $combatant, EntityManagerInterface $em): Response
{
$encounterId = $combatant->getEncounter()?->getId();
if ($this->isCsrfTokenValid('tracker-op', (string) $request->request->get('_token'))) {
$combatant->getEncounter()?->touch();
$em->remove($combatant);
$em->flush();
}
return $this->redirectToRoute('app_tracker');
return $this->redirectToRoute('app_tracker_show', ['id' => $encounterId]);
}
#[Route('/tracker/clear', name: 'app_tracker_clear', methods: ['POST'])]
public function clear(Request $request, CombatantRepository $repo, EntityManagerInterface $em): Response
private function cleanName(mixed $raw, EncounterRepository $repo): string
{
if ($this->isCsrfTokenValid('tracker-op', (string) $request->request->get('_token'))) {
foreach ($repo->findAll() as $c) {
$em->remove($c);
}
$em->flush();
$this->addFlash('success', 'Tracker cleared.');
$name = trim((string) $raw);
if ('' !== $name) {
return mb_substr($name, 0, 120);
}
return $this->redirectToRoute('app_tracker');
return 'Encounter '.(\count($repo->findAll()) + 1);
}
}
+16
View File
@@ -19,6 +19,10 @@ class Combatant
#[ORM\Column]
private ?int $id = null;
#[ORM\ManyToOne(targetEntity: Encounter::class, inversedBy: 'combatants')]
#[ORM\JoinColumn(name: 'encounter_id', nullable: false, onDelete: 'CASCADE')]
private ?Encounter $encounter = null;
#[ORM\ManyToOne(targetEntity: Enemy::class)]
#[ORM\JoinColumn(name: 'enemy_id', onDelete: 'SET NULL', nullable: true)]
private ?Enemy $enemy = null;
@@ -73,6 +77,18 @@ class Combatant
return $this->id;
}
public function getEncounter(): ?Encounter
{
return $this->encounter;
}
public function setEncounter(?Encounter $encounter): static
{
$this->encounter = $encounter;
return $this;
}
public function getEnemy(): ?Enemy
{
return $this->enemy;
+102
View File
@@ -0,0 +1,102 @@
<?php
namespace App\Entity;
use App\Repository\EncounterRepository;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Validator\Constraints as Assert;
/**
* A named combat: a list of combatants with their HP, kept for prep and reuse.
*/
#[ORM\Entity(repositoryClass: EncounterRepository::class)]
#[ORM\Table(name: 'encounter')]
#[ORM\HasLifecycleCallbacks]
class Encounter
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
private ?int $id = null;
#[ORM\Column(length: 120)]
#[Assert\NotBlank]
private string $name = '';
#[ORM\Column(type: Types::DATETIME_IMMUTABLE)]
private \DateTimeImmutable $createdAt;
#[ORM\Column(type: Types::DATETIME_IMMUTABLE)]
private \DateTimeImmutable $updatedAt;
/** @var Collection<int, Combatant> */
#[ORM\OneToMany(mappedBy: 'encounter', targetEntity: Combatant::class, cascade: ['persist', 'remove'], orphanRemoval: true)]
#[ORM\OrderBy(['position' => 'ASC', 'id' => 'ASC'])]
private Collection $combatants;
public function __construct()
{
$this->createdAt = new \DateTimeImmutable();
$this->updatedAt = new \DateTimeImmutable();
$this->combatants = new ArrayCollection();
}
#[ORM\PreUpdate]
public function touch(): void
{
$this->updatedAt = new \DateTimeImmutable();
}
public function getId(): ?int
{
return $this->id;
}
public function getName(): string
{
return $this->name;
}
public function setName(string $name): static
{
$this->name = $name;
return $this;
}
public function getCreatedAt(): \DateTimeImmutable
{
return $this->createdAt;
}
public function getUpdatedAt(): \DateTimeImmutable
{
return $this->updatedAt;
}
/** @return Collection<int, Combatant> */
public function getCombatants(): Collection
{
return $this->combatants;
}
public function addCombatant(Combatant $combatant): static
{
if (!$this->combatants->contains($combatant)) {
$this->combatants->add($combatant);
$combatant->setEncounter($this);
}
return $this;
}
public function removeCombatant(Combatant $combatant): static
{
$this->combatants->removeElement($combatant);
return $this;
}
}
+6 -10
View File
@@ -3,6 +3,7 @@
namespace App\Repository;
use App\Entity\Combatant;
use App\Entity\Encounter;
use App\Entity\Enemy;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
@@ -17,22 +18,17 @@ class CombatantRepository extends ServiceEntityRepository
parent::__construct($registry, Combatant::class);
}
/** @return list<Combatant> */
public function tracker(): array
{
return $this->findBy([], ['position' => 'ASC', 'id' => 'ASC']);
}
public function nextPosition(): int
public function nextPosition(Encounter $encounter): int
{
return 1 + (int) $this->createQueryBuilder('c')
->select('COALESCE(MAX(c.position), 0)')
->andWhere('c.encounter = :e')->setParameter('e', $encounter)
->getQuery()->getSingleScalarResult();
}
/** How many combatants already come from this enemy (for numbering). */
public function countFromEnemy(Enemy $enemy): int
/** How many combatants in this encounter already come from this enemy (for numbering). */
public function countFromEnemy(Encounter $encounter, Enemy $enemy): int
{
return (int) $this->count(['enemy' => $enemy]);
return (int) $this->count(['encounter' => $encounter, 'enemy' => $enemy]);
}
}
+24
View File
@@ -0,0 +1,24 @@
<?php
namespace App\Repository;
use App\Entity\Encounter;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
/**
* @extends ServiceEntityRepository<Encounter>
*/
class EncounterRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, Encounter::class);
}
/** @return list<Encounter> most-recent first */
public function recent(): array
{
return $this->findBy([], ['updatedAt' => 'DESC', 'id' => 'DESC']);
}
}