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
+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;
}
}