diff --git a/assets/controllers/tracker_picker_controller.js b/assets/controllers/tracker_picker_controller.js index 9c9d879..1831b38 100644 --- a/assets/controllers/tracker_picker_controller.js +++ b/assets/controllers/tracker_picker_controller.js @@ -25,11 +25,21 @@ export default class extends Controller { return; } + const select = this.element.querySelector('.encounter-select'); + let encounter = select ? select.value : 'new'; + let name = ''; + if (encounter === 'new') { + name = window.prompt('Name for the new encounter', 'Encounter'); + if (name === null) return; // cancelled + } + const form = document.createElement('form'); form.method = 'post'; form.action = this.urlValue; form.insertAdjacentHTML('beforeend', - ``); + `` + + `` + + ``); picks.forEach((p, i) => { form.insertAdjacentHTML('beforeend', `` + diff --git a/migrations/Version20260906172225.php b/migrations/Version20260906172225.php new file mode 100644 index 0000000..751355f --- /dev/null +++ b/migrations/Version20260906172225.php @@ -0,0 +1,41 @@ +addSql('CREATE TABLE encounter (id INT AUTO_INCREMENT NOT NULL, name VARCHAR(120) NOT NULL, created_at DATETIME NOT NULL, updated_at DATETIME NOT NULL, PRIMARY KEY (id)) DEFAULT CHARACTER SET utf8mb4 ENGINE = InnoDB'); + + // add nullable first, rehome any pre-existing combatants, then enforce + $this->addSql('ALTER TABLE combatant ADD encounter_id INT DEFAULT NULL'); + $this->addSql("INSERT INTO encounter (name, created_at, updated_at) SELECT 'Combat', NOW(), NOW() FROM DUAL WHERE EXISTS (SELECT 1 FROM combatant WHERE encounter_id IS NULL)"); + $this->addSql('UPDATE combatant SET encounter_id = (SELECT MAX(id) FROM encounter) WHERE encounter_id IS NULL'); + + $this->addSql('ALTER TABLE combatant ADD CONSTRAINT FK_807DA381D6E2FADC FOREIGN KEY (encounter_id) REFERENCES encounter (id) ON DELETE CASCADE'); + $this->addSql('CREATE INDEX IDX_807DA381D6E2FADC ON combatant (encounter_id)'); + $this->addSql('ALTER TABLE combatant CHANGE encounter_id encounter_id INT NOT NULL'); + } + + public function down(Schema $schema): void + { + $this->addSql('ALTER TABLE combatant DROP FOREIGN KEY FK_807DA381D6E2FADC'); + $this->addSql('DROP INDEX IDX_807DA381D6E2FADC ON combatant'); + $this->addSql('ALTER TABLE combatant DROP encounter_id'); + $this->addSql('DROP TABLE encounter'); + } +} diff --git a/src/Controller/EnemyController.php b/src/Controller/EnemyController.php index e1df40b..435f840 100644 --- a/src/Controller/EnemyController.php +++ b/src/Controller/EnemyController.php @@ -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'), + ]); } /** diff --git a/src/Controller/TrackerController.php b/src/Controller/TrackerController.php index 24b10d6..c687c6e 100644 --- a/src/Controller/TrackerController.php +++ b/src/Controller/TrackerController.php @@ -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); } } diff --git a/src/Entity/Combatant.php b/src/Entity/Combatant.php index a3115a3..9435471 100644 --- a/src/Entity/Combatant.php +++ b/src/Entity/Combatant.php @@ -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; diff --git a/src/Entity/Encounter.php b/src/Entity/Encounter.php new file mode 100644 index 0000000..9619412 --- /dev/null +++ b/src/Entity/Encounter.php @@ -0,0 +1,102 @@ + */ + #[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 */ + 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; + } +} diff --git a/src/Repository/CombatantRepository.php b/src/Repository/CombatantRepository.php index c4cd882..284778b 100644 --- a/src/Repository/CombatantRepository.php +++ b/src/Repository/CombatantRepository.php @@ -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 */ - 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]); } } diff --git a/src/Repository/EncounterRepository.php b/src/Repository/EncounterRepository.php new file mode 100644 index 0000000..d4c3461 --- /dev/null +++ b/src/Repository/EncounterRepository.php @@ -0,0 +1,24 @@ + + */ +class EncounterRepository extends ServiceEntityRepository +{ + public function __construct(ManagerRegistry $registry) + { + parent::__construct($registry, Encounter::class); + } + + /** @return list most-recent first */ + public function recent(): array + { + return $this->findBy([], ['updatedAt' => 'DESC', 'id' => 'DESC']); + } +} diff --git a/templates/enemy/index.html.twig b/templates/enemy/index.html.twig index d903b33..95a085e 100644 --- a/templates/enemy/index.html.twig +++ b/templates/enemy/index.html.twig @@ -5,10 +5,16 @@ {% block body %}

Enemies

-
+ diff --git a/templates/tracker/index.html.twig b/templates/tracker/index.html.twig index d8d289d..7d9ca82 100644 --- a/templates/tracker/index.html.twig +++ b/templates/tracker/index.html.twig @@ -4,77 +4,35 @@ {% block body %}
-

Combat tracker - {% if combatants %}· {{ combatants|length }}{% endif %} -

-
- Add from Enemies - {% if combatants %} -
- - -
- {% endif %} -
+

Encounters

+
+ + + +
- {% if not combatants %} + {% if not encounters %}
- No combatants yet. Go to Enemies, tick the ones you want (set a count for - multiples), and choose Add to tracker. + No encounters yet. Name one above, or go to Enemies, tick the combatants + you want and add them to a new encounter.
{% else %} -
- {% for c in combatants %} - {% set pct = c.hpPercent %} - {% set bar = c.isDown ? 'bg-secondary' : (pct <= 25 ? 'bg-danger' : (pct <= 50 ? 'bg-warning' : 'bg-success')) %} -
-
-
-
-
- {{ c.label }} - {% if c.isDown %}Down{% endif %} -
- {% if c.enemy %} - stat block - {% endif %} -
- -
-
- - {{ c.currentHp }} / {{ c.maxHp }} - -
-
-
-
-
- -
-
-
- - - -
-
- - -
-
-
-
-
-
+ {% endif %} diff --git a/templates/tracker/show.html.twig b/templates/tracker/show.html.twig new file mode 100644 index 0000000..bc51e2b --- /dev/null +++ b/templates/tracker/show.html.twig @@ -0,0 +1,93 @@ +{% extends 'base.html.twig' %} + +{% block title %}{{ encounter.name }} · Tracker · dmtools{% endblock %} + +{% block body %} +
+
+ ← All encounters +
+ + + +
+
+
+ Add enemies + {% if encounter.combatants|length %} +
+ + +
+ {% endif %} +
+ + +
+
+
+ + {% if not encounter.combatants|length %} +
+
+ No combatants. Use Add enemies to pick some from the list. +
+
+ {% else %} +
+ {% for c in encounter.combatants %} + {% set pct = c.hpPercent %} + {% set bar = c.isDown ? 'bg-secondary' : (pct <= 25 ? 'bg-danger' : (pct <= 50 ? 'bg-warning' : 'bg-success')) %} +
+
+
+
+
+ {{ c.label }} + {% if c.isDown %}Down{% endif %} +
+ {% if c.enemy %} + stat block + {% endif %} +
+ +
+
+ + {{ c.currentHp }} / {{ c.maxHp }} + +
+
+
+
+
+ +
+
+
+ + + +
+
+ + +
+
+
+
+
+
+ {% endfor %} +
+ {% endif %} +{% endblock %}