Add Enemies section: D&D stat-block table + detail/edit pages

- Enemy entity: full 5e stat block (size/type/subtype/alignment, AC, HP,
  speed, six ability scores, CR/XP/prof, saves/skills/senses/languages,
  damage & condition modifiers, traits/actions/bonus/reactions/legendary,
  description, source) plus a DM game-notes field and updated_at.
- CreatureSize / CreatureType enums.
- /enemies: server-side DataTable (name, size, type, CR, AC, HP, notes),
  "Add enemy" button.
- /enemies/{id}: stat block rendered read-only + editable game notes.
- /enemies/new and /enemies/{id}/edit: the full stat-block form (no notes).
- "Enemies" nav item between NPC's and Storyline.
- Table left empty on purpose.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Frank
2026-09-06 16:36:17 +02:00
co-authored by Claude Sonnet 5
parent 574d903715
commit 7870be9dfb
12 changed files with 1234 additions and 0 deletions
+29
View File
@@ -0,0 +1,29 @@
<?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 Version20260906143452 extends AbstractMigration
{
public function getDescription(): string
{
return 'Create enemy table (D&D stat blocks + game notes)';
}
public function up(Schema $schema): void
{
$this->addSql('CREATE TABLE enemy (id INT AUTO_INCREMENT NOT NULL, name VARCHAR(160) NOT NULL, size VARCHAR(255) NOT NULL, type VARCHAR(255) NOT NULL, subtype VARCHAR(120) DEFAULT NULL, alignment VARCHAR(120) DEFAULT NULL, armor_class SMALLINT NOT NULL, armor_class_note VARCHAR(120) DEFAULT NULL, hit_points SMALLINT NOT NULL, hit_dice VARCHAR(60) DEFAULT NULL, speed VARCHAR(180) DEFAULT NULL, strength SMALLINT NOT NULL, dexterity SMALLINT NOT NULL, constitution SMALLINT NOT NULL, intelligence SMALLINT NOT NULL, wisdom SMALLINT NOT NULL, charisma SMALLINT NOT NULL, challenge_rating VARCHAR(12) DEFAULT NULL, experience_points INT DEFAULT NULL, proficiency_bonus SMALLINT DEFAULT NULL, saving_throws VARCHAR(180) DEFAULT NULL, skills VARCHAR(255) DEFAULT NULL, damage_vulnerabilities VARCHAR(255) DEFAULT NULL, damage_resistances VARCHAR(255) DEFAULT NULL, damage_immunities VARCHAR(255) DEFAULT NULL, condition_immunities VARCHAR(255) DEFAULT NULL, senses VARCHAR(255) DEFAULT NULL, languages VARCHAR(255) DEFAULT NULL, traits LONGTEXT DEFAULT NULL, actions LONGTEXT DEFAULT NULL, bonus_actions LONGTEXT DEFAULT NULL, reactions LONGTEXT DEFAULT NULL, legendary_actions LONGTEXT DEFAULT NULL, description LONGTEXT DEFAULT NULL, source VARCHAR(180) DEFAULT NULL, game_notes LONGTEXT DEFAULT NULL, updated_at DATETIME DEFAULT NULL, UNIQUE INDEX uniq_enemy_name (name), PRIMARY KEY (id)) DEFAULT CHARACTER SET utf8mb4 ENGINE = InnoDB');
}
public function down(Schema $schema): void
{
$this->addSql('DROP TABLE enemy');
}
}
+121
View File
@@ -0,0 +1,121 @@
<?php
namespace App\Controller;
use App\Entity\Enemy;
use App\Form\EnemyGameNotesType;
use App\Form\EnemyType;
use App\Repository\EnemyRepository;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
#[IsGranted('IS_AUTHENTICATED_FULLY')]
class EnemyController extends AbstractController
{
#[Route('/enemies', name: 'app_enemies', methods: ['GET'])]
public function index(): Response
{
return $this->render('enemy/index.html.twig');
}
/**
* Server-side processing endpoint for the DataTable on /enemies.
*/
#[Route('/enemies/data', name: 'app_enemies_data', methods: ['GET'])]
public function data(Request $request, EnemyRepository $repo): JsonResponse
{
$q = $request->query;
$draw = (int) $q->get('draw', 1);
$start = (int) $q->get('start', 0);
$length = (int) $q->get('length', 10);
$search = (string) ($q->all('search')['value'] ?? '');
$columns = $q->all('columns');
$order = $q->all('order')[0] ?? ['column' => 0, 'dir' => 'asc'];
$sortKey = $columns[(int) ($order['column'] ?? 0)]['data'] ?? 'name';
$sortDir = (string) ($order['dir'] ?? 'asc');
$result = $repo->findForDataTable($search, $sortKey, $sortDir, $start, $length);
$data = array_map(static fn (Enemy $e) => [
'id' => $e->getId(),
'name' => $e->getName(),
'size' => $e->getSize()->value,
'type' => $e->getType()->value,
'challengeRating' => $e->getChallengeRating(),
'armorClass' => $e->getArmorClass(),
'hitPoints' => $e->getHitPoints(),
'gameNotes' => $e->getGameNotes(),
'updatedAt' => $e->getUpdatedAt()?->format('Y-m-d H:i'),
], $result['data']);
return $this->json([
'draw' => $draw,
'recordsTotal' => $result['recordsTotal'],
'recordsFiltered' => $result['recordsFiltered'],
'data' => $data,
]);
}
/**
* In-play detail page: the stat block read-only, plus the game-notes field.
*/
#[Route('/enemies/{id}', name: 'app_enemies_show', requirements: ['id' => '\d+'], methods: ['GET', 'POST'])]
public function show(Request $request, Enemy $enemy, EntityManagerInterface $em): Response
{
$form = $this->createForm(EnemyGameNotesType::class, $enemy);
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
$em->flush();
$this->addFlash('success', 'Game notes saved.');
return $this->redirectToRoute('app_enemies_show', ['id' => $enemy->getId()]);
}
return $this->render('enemy/show.html.twig', [
'enemy' => $enemy,
'form' => $form,
]);
}
#[Route('/enemies/new', name: 'app_enemies_new', methods: ['GET', 'POST'])]
public function new(Request $request, EntityManagerInterface $em): Response
{
return $this->handleForm($request, $em, new Enemy());
}
#[Route('/enemies/{id}/edit', name: 'app_enemies_edit', requirements: ['id' => '\d+'], methods: ['GET', 'POST'])]
public function edit(Request $request, Enemy $enemy, EntityManagerInterface $em): Response
{
return $this->handleForm($request, $em, $enemy);
}
private function handleForm(Request $request, EntityManagerInterface $em, Enemy $enemy): Response
{
$isNew = null === $enemy->getId();
$form = $this->createForm(EnemyType::class, $enemy);
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
if ($isNew) {
$em->persist($enemy);
}
$em->flush();
$this->addFlash('success', sprintf('%s %s.', $isNew ? 'Added' : 'Saved', $enemy->getName()));
return $this->redirectToRoute('app_enemies_show', ['id' => $enemy->getId()]);
}
return $this->render('enemy/edit.html.twig', [
'enemy' => $enemy,
'form' => $form,
'is_new' => $isNew,
]);
}
}
+612
View File
@@ -0,0 +1,612 @@
<?php
namespace App\Entity;
use App\Enum\CreatureSize;
use App\Enum\CreatureType;
use App\Repository\EnemyRepository;
use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Validator\Constraints as Assert;
#[ORM\Entity(repositoryClass: EnemyRepository::class)]
#[ORM\Table(name: 'enemy')]
#[ORM\UniqueConstraint(name: 'uniq_enemy_name', columns: ['name'])]
#[ORM\HasLifecycleCallbacks]
class Enemy
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
private ?int $id = null;
// --- Identity ---------------------------------------------------------
#[ORM\Column(length: 160)]
#[Assert\NotBlank]
private string $name = '';
#[ORM\Column(enumType: CreatureSize::class)]
private CreatureSize $size = CreatureSize::Medium;
#[ORM\Column(enumType: CreatureType::class)]
private CreatureType $type = CreatureType::Humanoid;
#[ORM\Column(length: 120, nullable: true)]
private ?string $subtype = null;
#[ORM\Column(length: 120, nullable: true)]
private ?string $alignment = null;
// --- Defenses / core ------------------------------------------------
#[ORM\Column(type: Types::SMALLINT)]
#[Assert\PositiveOrZero]
private int $armorClass = 10;
#[ORM\Column(length: 120, nullable: true)]
private ?string $armorClassNote = null;
#[ORM\Column(type: Types::SMALLINT)]
#[Assert\PositiveOrZero]
private int $hitPoints = 1;
#[ORM\Column(length: 60, nullable: true)]
private ?string $hitDice = null;
#[ORM\Column(length: 180, nullable: true)]
private ?string $speed = null;
// --- Ability scores ------------------------------------------------
#[ORM\Column(type: Types::SMALLINT)]
#[Assert\Range(min: 1, max: 30)]
private int $strength = 10;
#[ORM\Column(type: Types::SMALLINT)]
#[Assert\Range(min: 1, max: 30)]
private int $dexterity = 10;
#[ORM\Column(type: Types::SMALLINT)]
#[Assert\Range(min: 1, max: 30)]
private int $constitution = 10;
#[ORM\Column(type: Types::SMALLINT)]
#[Assert\Range(min: 1, max: 30)]
private int $intelligence = 10;
#[ORM\Column(type: Types::SMALLINT)]
#[Assert\Range(min: 1, max: 30)]
private int $wisdom = 10;
#[ORM\Column(type: Types::SMALLINT)]
#[Assert\Range(min: 1, max: 30)]
private int $charisma = 10;
// --- Challenge ----------------------------------------------------
/** Fractional for weak creatures ("1/8", "1/4", "1/2"), so a string. */
#[ORM\Column(length: 12, nullable: true)]
private ?string $challengeRating = null;
#[ORM\Column(type: Types::INTEGER, nullable: true)]
private ?int $experiencePoints = null;
#[ORM\Column(type: Types::SMALLINT, nullable: true)]
private ?int $proficiencyBonus = null;
// --- Secondary stats (free text) --------------------------------
#[ORM\Column(length: 180, nullable: true)]
private ?string $savingThrows = null;
#[ORM\Column(length: 255, nullable: true)]
private ?string $skills = null;
#[ORM\Column(length: 255, nullable: true)]
private ?string $damageVulnerabilities = null;
#[ORM\Column(length: 255, nullable: true)]
private ?string $damageResistances = null;
#[ORM\Column(length: 255, nullable: true)]
private ?string $damageImmunities = null;
#[ORM\Column(length: 255, nullable: true)]
private ?string $conditionImmunities = null;
#[ORM\Column(length: 255, nullable: true)]
private ?string $senses = null;
#[ORM\Column(length: 255, nullable: true)]
private ?string $languages = null;
// --- Text blocks ------------------------------------------------
#[ORM\Column(type: Types::TEXT, nullable: true)]
private ?string $traits = null;
#[ORM\Column(type: Types::TEXT, nullable: true)]
private ?string $actions = null;
#[ORM\Column(type: Types::TEXT, nullable: true)]
private ?string $bonusActions = null;
#[ORM\Column(type: Types::TEXT, nullable: true)]
private ?string $reactions = null;
#[ORM\Column(type: Types::TEXT, nullable: true)]
private ?string $legendaryActions = null;
#[ORM\Column(type: Types::TEXT, nullable: true)]
private ?string $description = null;
#[ORM\Column(length: 180, nullable: true)]
private ?string $source = null;
// --- DM ------------------------------------------------------
/** DM's live notes, written during play. Never touched by an importer. */
#[ORM\Column(type: Types::TEXT, nullable: true)]
private ?string $gameNotes = null;
#[ORM\Column(type: Types::DATETIME_IMMUTABLE, nullable: true)]
private ?\DateTimeImmutable $updatedAt = null;
#[ORM\PrePersist]
#[ORM\PreUpdate]
public function touch(): void
{
$this->updatedAt = new \DateTimeImmutable();
}
/** e.g. "Large Fiend (demon), chaotic evil". */
public function getMetaLine(): string
{
$type = $this->type->value;
if ($this->subtype) {
$type .= ' ('.$this->subtype.')';
}
$line = $this->size->value.' '.$type;
if ($this->alignment) {
$line .= ', '.$this->alignment;
}
return $line;
}
public function abilityModifier(int $score): int
{
return (int) floor(($score - 10) / 2);
}
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 getSize(): CreatureSize
{
return $this->size;
}
public function setSize(CreatureSize $size): static
{
$this->size = $size;
return $this;
}
public function getType(): CreatureType
{
return $this->type;
}
public function setType(CreatureType $type): static
{
$this->type = $type;
return $this;
}
public function getSubtype(): ?string
{
return $this->subtype;
}
public function setSubtype(?string $subtype): static
{
$this->subtype = $subtype;
return $this;
}
public function getAlignment(): ?string
{
return $this->alignment;
}
public function setAlignment(?string $alignment): static
{
$this->alignment = $alignment;
return $this;
}
public function getArmorClass(): int
{
return $this->armorClass;
}
public function setArmorClass(int $armorClass): static
{
$this->armorClass = $armorClass;
return $this;
}
public function getArmorClassNote(): ?string
{
return $this->armorClassNote;
}
public function setArmorClassNote(?string $armorClassNote): static
{
$this->armorClassNote = $armorClassNote;
return $this;
}
public function getHitPoints(): int
{
return $this->hitPoints;
}
public function setHitPoints(int $hitPoints): static
{
$this->hitPoints = $hitPoints;
return $this;
}
public function getHitDice(): ?string
{
return $this->hitDice;
}
public function setHitDice(?string $hitDice): static
{
$this->hitDice = $hitDice;
return $this;
}
public function getSpeed(): ?string
{
return $this->speed;
}
public function setSpeed(?string $speed): static
{
$this->speed = $speed;
return $this;
}
public function getStrength(): int
{
return $this->strength;
}
public function setStrength(int $strength): static
{
$this->strength = $strength;
return $this;
}
public function getDexterity(): int
{
return $this->dexterity;
}
public function setDexterity(int $dexterity): static
{
$this->dexterity = $dexterity;
return $this;
}
public function getConstitution(): int
{
return $this->constitution;
}
public function setConstitution(int $constitution): static
{
$this->constitution = $constitution;
return $this;
}
public function getIntelligence(): int
{
return $this->intelligence;
}
public function setIntelligence(int $intelligence): static
{
$this->intelligence = $intelligence;
return $this;
}
public function getWisdom(): int
{
return $this->wisdom;
}
public function setWisdom(int $wisdom): static
{
$this->wisdom = $wisdom;
return $this;
}
public function getCharisma(): int
{
return $this->charisma;
}
public function setCharisma(int $charisma): static
{
$this->charisma = $charisma;
return $this;
}
public function getChallengeRating(): ?string
{
return $this->challengeRating;
}
public function setChallengeRating(?string $challengeRating): static
{
$this->challengeRating = $challengeRating;
return $this;
}
public function getExperiencePoints(): ?int
{
return $this->experiencePoints;
}
public function setExperiencePoints(?int $experiencePoints): static
{
$this->experiencePoints = $experiencePoints;
return $this;
}
public function getProficiencyBonus(): ?int
{
return $this->proficiencyBonus;
}
public function setProficiencyBonus(?int $proficiencyBonus): static
{
$this->proficiencyBonus = $proficiencyBonus;
return $this;
}
public function getSavingThrows(): ?string
{
return $this->savingThrows;
}
public function setSavingThrows(?string $savingThrows): static
{
$this->savingThrows = $savingThrows;
return $this;
}
public function getSkills(): ?string
{
return $this->skills;
}
public function setSkills(?string $skills): static
{
$this->skills = $skills;
return $this;
}
public function getDamageVulnerabilities(): ?string
{
return $this->damageVulnerabilities;
}
public function setDamageVulnerabilities(?string $damageVulnerabilities): static
{
$this->damageVulnerabilities = $damageVulnerabilities;
return $this;
}
public function getDamageResistances(): ?string
{
return $this->damageResistances;
}
public function setDamageResistances(?string $damageResistances): static
{
$this->damageResistances = $damageResistances;
return $this;
}
public function getDamageImmunities(): ?string
{
return $this->damageImmunities;
}
public function setDamageImmunities(?string $damageImmunities): static
{
$this->damageImmunities = $damageImmunities;
return $this;
}
public function getConditionImmunities(): ?string
{
return $this->conditionImmunities;
}
public function setConditionImmunities(?string $conditionImmunities): static
{
$this->conditionImmunities = $conditionImmunities;
return $this;
}
public function getSenses(): ?string
{
return $this->senses;
}
public function setSenses(?string $senses): static
{
$this->senses = $senses;
return $this;
}
public function getLanguages(): ?string
{
return $this->languages;
}
public function setLanguages(?string $languages): static
{
$this->languages = $languages;
return $this;
}
public function getTraits(): ?string
{
return $this->traits;
}
public function setTraits(?string $traits): static
{
$this->traits = $traits;
return $this;
}
public function getActions(): ?string
{
return $this->actions;
}
public function setActions(?string $actions): static
{
$this->actions = $actions;
return $this;
}
public function getBonusActions(): ?string
{
return $this->bonusActions;
}
public function setBonusActions(?string $bonusActions): static
{
$this->bonusActions = $bonusActions;
return $this;
}
public function getReactions(): ?string
{
return $this->reactions;
}
public function setReactions(?string $reactions): static
{
$this->reactions = $reactions;
return $this;
}
public function getLegendaryActions(): ?string
{
return $this->legendaryActions;
}
public function setLegendaryActions(?string $legendaryActions): static
{
$this->legendaryActions = $legendaryActions;
return $this;
}
public function getDescription(): ?string
{
return $this->description;
}
public function setDescription(?string $description): static
{
$this->description = $description;
return $this;
}
public function getSource(): ?string
{
return $this->source;
}
public function setSource(?string $source): static
{
$this->source = $source;
return $this;
}
public function getGameNotes(): ?string
{
return $this->gameNotes;
}
public function setGameNotes(?string $gameNotes): static
{
$this->gameNotes = $gameNotes;
return $this;
}
public function getUpdatedAt(): ?\DateTimeImmutable
{
return $this->updatedAt;
}
}
+18
View File
@@ -0,0 +1,18 @@
<?php
namespace App\Enum;
enum CreatureSize: string
{
case Tiny = 'Tiny';
case Small = 'Small';
case Medium = 'Medium';
case Large = 'Large';
case Huge = 'Huge';
case Gargantuan = 'Gargantuan';
public function label(): string
{
return $this->value;
}
}
+26
View File
@@ -0,0 +1,26 @@
<?php
namespace App\Enum;
enum CreatureType: string
{
case Aberration = 'Aberration';
case Beast = 'Beast';
case Celestial = 'Celestial';
case Construct = 'Construct';
case Dragon = 'Dragon';
case Elemental = 'Elemental';
case Fey = 'Fey';
case Fiend = 'Fiend';
case Giant = 'Giant';
case Humanoid = 'Humanoid';
case Monstrosity = 'Monstrosity';
case Ooze = 'Ooze';
case Plant = 'Plant';
case Undead = 'Undead';
public function label(): string
{
return $this->value;
}
}
+29
View File
@@ -0,0 +1,29 @@
<?php
namespace App\Form;
use App\Entity\Enemy;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\TextareaType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
/**
* The only editable thing on the Enemy detail page: the DM's live play notes.
*/
class EnemyGameNotesType extends AbstractType
{
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$builder->add('gameNotes', TextareaType::class, [
'required' => false,
'label' => false,
'attr' => ['rows' => 14, 'placeholder' => 'How this enemy played at the table…'],
]);
}
public function configureOptions(OptionsResolver $resolver): void
{
$resolver->setDefaults(['data_class' => Enemy::class]);
}
}
+76
View File
@@ -0,0 +1,76 @@
<?php
namespace App\Form;
use App\Entity\Enemy;
use App\Enum\CreatureSize;
use App\Enum\CreatureType;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\EnumType;
use Symfony\Component\Form\Extension\Core\Type\IntegerType;
use Symfony\Component\Form\Extension\Core\Type\TextareaType;
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
class EnemyType extends AbstractType
{
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$enum = static fn (string $class) => [
'class' => $class,
'choice_label' => static fn ($case) => $case->value,
];
$opt = ['required' => false];
$ability = ['attr' => ['min' => 1, 'max' => 30]];
$block = ['required' => false, 'attr' => ['rows' => 3]];
$builder
// Identity
->add('name', TextType::class)
->add('size', EnumType::class, $enum(CreatureSize::class))
->add('type', EnumType::class, $enum(CreatureType::class))
->add('subtype', TextType::class, $opt + ['help' => 'e.g. goblinoid, shapechanger'])
->add('alignment', TextType::class, $opt + ['help' => 'e.g. chaotic evil, unaligned'])
// Defenses
->add('armorClass', IntegerType::class)
->add('armorClassNote', TextType::class, $opt + ['label' => 'AC note', 'help' => 'e.g. natural armor'])
->add('hitPoints', IntegerType::class)
->add('hitDice', TextType::class, $opt + ['help' => 'e.g. 7d8 + 14'])
->add('speed', TextType::class, $opt + ['help' => 'e.g. 30 ft., fly 60 ft.'])
// Abilities
->add('strength', IntegerType::class, $ability)
->add('dexterity', IntegerType::class, $ability)
->add('constitution', IntegerType::class, $ability)
->add('intelligence', IntegerType::class, $ability)
->add('wisdom', IntegerType::class, $ability)
->add('charisma', IntegerType::class, $ability)
// Challenge
->add('challengeRating', TextType::class, $opt + ['label' => 'CR', 'help' => 'e.g. 1/4, 5'])
->add('experiencePoints', IntegerType::class, $opt + ['label' => 'XP'])
->add('proficiencyBonus', IntegerType::class, $opt)
// Secondary stats
->add('savingThrows', TextType::class, $opt + ['help' => 'e.g. Dex +5, Con +6'])
->add('skills', TextType::class, $opt + ['help' => 'e.g. Perception +4, Stealth +6'])
->add('damageVulnerabilities', TextType::class, $opt)
->add('damageResistances', TextType::class, $opt)
->add('damageImmunities', TextType::class, $opt)
->add('conditionImmunities', TextType::class, $opt)
->add('senses', TextType::class, $opt + ['help' => 'e.g. darkvision 60 ft., passive Perception 12'])
->add('languages', TextType::class, $opt)
// Text blocks
->add('traits', TextareaType::class, $block + ['help' => 'Special abilities'])
->add('actions', TextareaType::class, $block)
->add('bonusActions', TextareaType::class, $block)
->add('reactions', TextareaType::class, $block)
->add('legendaryActions', TextareaType::class, $block)
->add('description', TextareaType::class, ['required' => false, 'attr' => ['rows' => 4], 'help' => 'Lore / flavour'])
->add('source', TextType::class, $opt + ['help' => 'e.g. Monster Manual p. 195, homebrew']);
// gameNotes lives on the detail page, not this form.
}
public function configureOptions(OptionsResolver $resolver): void
{
$resolver->setDefaults(['data_class' => Enemy::class]);
}
}
+77
View File
@@ -0,0 +1,77 @@
<?php
namespace App\Repository;
use App\Entity\Enemy;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
/**
* @extends ServiceEntityRepository<Enemy>
*/
class EnemyRepository extends ServiceEntityRepository
{
/** Fields the DataTable may sort on (column key => DQL field). */
public const SORTABLE = [
'name' => 'e.name',
'size' => 'e.size',
'type' => 'e.type',
'challengeRating' => 'e.challengeRating',
'armorClass' => 'e.armorClass',
'hitPoints' => 'e.hitPoints',
'updatedAt' => 'e.updatedAt',
];
/** Fields the global search box matches against (LIKE, OR-ed). */
private const SEARCHABLE = [
'e.name', 'e.type', 'e.subtype', 'e.alignment', 'e.challengeRating',
'e.traits', 'e.actions', 'e.description', 'e.gameNotes', 'e.source',
];
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, Enemy::class);
}
/**
* @return array{data: list<Enemy>, recordsTotal: int, recordsFiltered: int}
*/
public function findForDataTable(
?string $search,
string $sortKey,
string $sortDir,
int $start,
int $length,
): array {
$sortField = self::SORTABLE[$sortKey] ?? 'e.name';
$sortDir = strtolower($sortDir) === 'desc' ? 'DESC' : 'ASC';
$length = $length < 0 ? 200 : min($length, 200);
$qb = $this->createQueryBuilder('e');
if (null !== $search && '' !== trim($search)) {
$ors = [];
foreach (self::SEARCHABLE as $i => $field) {
$ors[] = $field.' LIKE :s'.$i;
$qb->setParameter('s'.$i, '%'.trim($search).'%');
}
$qb->andWhere('('.implode(' OR ', $ors).')');
}
$filtered = (int) (clone $qb)->select('COUNT(e.id)')->getQuery()->getSingleScalarResult();
$rows = $qb
->orderBy($sortField, $sortDir)
->addOrderBy('e.name', 'ASC')
->setFirstResult(max(0, $start))
->setMaxResults($length)
->getQuery()
->getResult();
return [
'data' => $rows,
'recordsTotal' => $this->count([]),
'recordsFiltered' => $filtered,
];
}
}
+1
View File
@@ -29,6 +29,7 @@
{ route: 'app_home', label: 'Overview' }, { route: 'app_home', label: 'Overview' },
{ route: 'app_characters', label: 'Characters' }, { route: 'app_characters', label: 'Characters' },
{ route: 'app_npcs', label: "NPC's" }, { route: 'app_npcs', label: "NPC's" },
{ route: 'app_enemies', label: 'Enemies' },
{ route: 'app_storyline', label: 'Storyline' }, { route: 'app_storyline', label: 'Storyline' },
] %} ] %}
<ul class="navbar-nav me-auto"> <ul class="navbar-nav me-auto">
+114
View File
@@ -0,0 +1,114 @@
{% extends 'base.html.twig' %}
{% block title %}{{ is_new ? 'New enemy' : 'Edit ' ~ enemy.name }} · Enemies · dmtools{% endblock %}
{% block body %}
<div class="mb-3">
<a href="{{ is_new ? path('app_enemies') : path('app_enemies_show', {id: enemy.id}) }}"
class="text-decoration-none small">&larr; {{ is_new ? 'All enemies' : 'Back to ' ~ enemy.name }}</a>
<h1 class="h3 mb-0">{{ is_new ? 'New enemy' : 'Edit ' ~ enemy.name }}</h1>
<span class="text-body-secondary">Stat block — game notes live on the enemy page.</span>
</div>
{{ form_start(form) }}
<div class="row g-4" style="max-width: 60rem;">
<div class="col-md-6">
<div class="card shadow-sm h-100">
<div class="card-header">Identity</div>
<div class="card-body">
{{ form_row(form.name) }}
<div class="row">
<div class="col-6">{{ form_row(form.size) }}</div>
<div class="col-6">{{ form_row(form.type) }}</div>
</div>
{{ form_row(form.subtype) }}
{{ form_row(form.alignment) }}
</div>
</div>
</div>
<div class="col-md-6">
<div class="card shadow-sm h-100">
<div class="card-header">Defenses</div>
<div class="card-body">
<div class="row">
<div class="col-4">{{ form_row(form.armorClass) }}</div>
<div class="col-8">{{ form_row(form.armorClassNote) }}</div>
</div>
<div class="row">
<div class="col-4">{{ form_row(form.hitPoints) }}</div>
<div class="col-8">{{ form_row(form.hitDice) }}</div>
</div>
{{ form_row(form.speed) }}
</div>
</div>
</div>
<div class="col-12">
<div class="card shadow-sm">
<div class="card-header">Ability scores</div>
<div class="card-body">
<div class="row row-cols-3 row-cols-md-6 g-2">
<div>{{ form_row(form.strength) }}</div>
<div>{{ form_row(form.dexterity) }}</div>
<div>{{ form_row(form.constitution) }}</div>
<div>{{ form_row(form.intelligence) }}</div>
<div>{{ form_row(form.wisdom) }}</div>
<div>{{ form_row(form.charisma) }}</div>
</div>
</div>
</div>
</div>
<div class="col-md-6">
<div class="card shadow-sm h-100">
<div class="card-header">Challenge</div>
<div class="card-body">
<div class="row">
<div class="col-4">{{ form_row(form.challengeRating) }}</div>
<div class="col-4">{{ form_row(form.experiencePoints) }}</div>
<div class="col-4">{{ form_row(form.proficiencyBonus) }}</div>
</div>
{{ form_row(form.source) }}
</div>
</div>
</div>
<div class="col-md-6">
<div class="card shadow-sm h-100">
<div class="card-header">Defensive traits</div>
<div class="card-body">
{{ form_row(form.savingThrows) }}
{{ form_row(form.skills) }}
{{ form_row(form.damageVulnerabilities) }}
{{ form_row(form.damageResistances) }}
{{ form_row(form.damageImmunities) }}
{{ form_row(form.conditionImmunities) }}
{{ form_row(form.senses) }}
{{ form_row(form.languages) }}
</div>
</div>
</div>
<div class="col-12">
<div class="card shadow-sm">
<div class="card-header">Traits &amp; actions</div>
<div class="card-body">
{{ form_row(form.traits) }}
{{ form_row(form.actions) }}
{{ form_row(form.bonusActions) }}
{{ form_row(form.reactions) }}
{{ form_row(form.legendaryActions) }}
{{ form_row(form.description) }}
</div>
</div>
</div>
</div>
<div class="mt-3 d-flex gap-2">
<button class="btn btn-primary">{{ is_new ? 'Add enemy' : 'Save' }}</button>
<a href="{{ is_new ? path('app_enemies') : path('app_enemies_show', {id: enemy.id}) }}"
class="btn btn-outline-secondary">Cancel</a>
</div>
{{ form_end(form) }}
{% endblock %}
+33
View File
@@ -0,0 +1,33 @@
{% extends 'base.html.twig' %}
{% block title %}Enemies · dmtools{% endblock %}
{% block body %}
<div class="d-flex justify-content-between align-items-center mb-3">
<h1 class="h3 mb-0">Enemies</h1>
<a href="{{ path('app_enemies_new') }}" class="btn btn-primary btn-sm">Add enemy</a>
</div>
<div class="card shadow-sm">
<div class="card-body">
<table class="table table-striped table-hover align-middle datatable w-100"
data-datatable-src="{{ path('app_enemies_data') }}"
data-row-url="{{ path('app_enemies') }}/__ID__"
data-datatable-options='{"order": [[0, "asc"]]}'>
<thead>
<tr>
<th data-data="name" data-format="link">Name</th>
<th data-data="size">Size</th>
<th data-data="type">Type</th>
<th data-data="challengeRating">CR</th>
<th data-data="armorClass">AC</th>
<th data-data="hitPoints">HP</th>
<th data-data="gameNotes" data-format="truncate" data-orderable="false">Game notes</th>
<th data-data="updatedAt">Updated</th>
</tr>
</thead>
<tbody></tbody>
</table>
</div>
</div>
{% endblock %}
+98
View File
@@ -0,0 +1,98 @@
{% extends 'base.html.twig' %}
{% block title %}{{ enemy.name }} · Enemies · dmtools{% endblock %}
{% macro mod(n) %}{{ n >= 0 ? '+' ~ n : n }}{% endmacro %}
{% macro stat(label, score, m) %}
<div class="col text-center">
<div class="text-uppercase small text-body-secondary">{{ label }}</div>
<div class="fw-semibold">{{ score }} ({{ m }})</div>
</div>
{% endmacro %}
{% macro line(label, value) %}
{% if value %}
<div><span class="fw-semibold">{{ label }}.</span> {{ value }}</div>
{% endif %}
{% endmacro %}
{% macro section(title, body) %}
{% if body %}
<h2 class="h6 border-bottom pb-1 mt-3">{{ title }}</h2>
<div>{{ body|nl2br }}</div>
{% endif %}
{% endmacro %}
{% import _self as sb %}
{% block body %}
<div class="d-flex justify-content-between align-items-start mb-3 gap-3">
<div>
<a href="{{ path('app_enemies') }}" class="text-decoration-none small">&larr; All enemies</a>
<h1 class="h3 mb-0">{{ enemy.name }}</h1>
<span class="text-body-secondary fst-italic">{{ enemy.metaLine }}</span>
</div>
<a href="{{ path('app_enemies_edit', {id: enemy.id}) }}" class="btn btn-outline-secondary btn-sm">Edit stats</a>
</div>
<div class="row g-4">
<div class="col-lg-7">
<div class="card shadow-sm">
<div class="card-body">
{{ sb.line('Armor Class', enemy.armorClass ~ (enemy.armorClassNote ? ' (' ~ enemy.armorClassNote ~ ')' : '')) }}
{{ sb.line('Hit Points', enemy.hitPoints ~ (enemy.hitDice ? ' (' ~ enemy.hitDice ~ ')' : '')) }}
{{ sb.line('Speed', enemy.speed) }}
<div class="row row-cols-6 my-3 g-1">
{{ sb.stat('Str', enemy.strength, sb.mod(enemy.abilityModifier(enemy.strength))) }}
{{ sb.stat('Dex', enemy.dexterity, sb.mod(enemy.abilityModifier(enemy.dexterity))) }}
{{ sb.stat('Con', enemy.constitution, sb.mod(enemy.abilityModifier(enemy.constitution))) }}
{{ sb.stat('Int', enemy.intelligence, sb.mod(enemy.abilityModifier(enemy.intelligence))) }}
{{ sb.stat('Wis', enemy.wisdom, sb.mod(enemy.abilityModifier(enemy.wisdom))) }}
{{ sb.stat('Cha', enemy.charisma, sb.mod(enemy.abilityModifier(enemy.charisma))) }}
</div>
{{ sb.line('Saving Throws', enemy.savingThrows) }}
{{ sb.line('Skills', enemy.skills) }}
{{ sb.line('Damage Vulnerabilities', enemy.damageVulnerabilities) }}
{{ sb.line('Damage Resistances', enemy.damageResistances) }}
{{ sb.line('Damage Immunities', enemy.damageImmunities) }}
{{ sb.line('Condition Immunities', enemy.conditionImmunities) }}
{{ sb.line('Senses', enemy.senses) }}
{{ sb.line('Languages', enemy.languages ?? '—') }}
{{ sb.line('Challenge', enemy.challengeRating ? enemy.challengeRating ~ (enemy.experiencePoints ? ' (' ~ enemy.experiencePoints ~ ' XP)' : '') : null) }}
{{ sb.line('Proficiency Bonus', enemy.proficiencyBonus ? sb.mod(enemy.proficiencyBonus) : null) }}
{{ sb.section('Traits', enemy.traits) }}
{{ sb.section('Actions', enemy.actions) }}
{{ sb.section('Bonus Actions', enemy.bonusActions) }}
{{ sb.section('Reactions', enemy.reactions) }}
{{ sb.section('Legendary Actions', enemy.legendaryActions) }}
{{ sb.section('Description', enemy.description) }}
{% if enemy.source %}
<p class="text-body-secondary small mt-3 mb-0">Source: {{ enemy.source }}</p>
{% endif %}
</div>
</div>
</div>
<div class="col-lg-5">
<div class="card shadow-sm">
<div class="card-header d-flex justify-content-between align-items-center">
<span>Game notes</span>
{% if enemy.updatedAt %}
<span class="text-body-secondary small fw-normal">saved {{ enemy.updatedAt|date('Y-m-d H:i') }}</span>
{% endif %}
</div>
<div class="card-body">
{{ form_start(form) }}
{{ form_row(form.gameNotes) }}
<button class="btn btn-primary mt-2">Save notes</button>
{{ form_end(form) }}
</div>
</div>
</div>
</div>
{% endblock %}