diff --git a/migrations/Version20260906143452.php b/migrations/Version20260906143452.php new file mode 100644 index 0000000..93fea04 --- /dev/null +++ b/migrations/Version20260906143452.php @@ -0,0 +1,29 @@ +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'); + } +} diff --git a/src/Controller/EnemyController.php b/src/Controller/EnemyController.php new file mode 100644 index 0000000..e1df40b --- /dev/null +++ b/src/Controller/EnemyController.php @@ -0,0 +1,121 @@ +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, + ]); + } +} diff --git a/src/Entity/Enemy.php b/src/Entity/Enemy.php new file mode 100644 index 0000000..acb0c9b --- /dev/null +++ b/src/Entity/Enemy.php @@ -0,0 +1,612 @@ +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; + } +} diff --git a/src/Enum/CreatureSize.php b/src/Enum/CreatureSize.php new file mode 100644 index 0000000..2a795f8 --- /dev/null +++ b/src/Enum/CreatureSize.php @@ -0,0 +1,18 @@ +value; + } +} diff --git a/src/Enum/CreatureType.php b/src/Enum/CreatureType.php new file mode 100644 index 0000000..4ff5855 --- /dev/null +++ b/src/Enum/CreatureType.php @@ -0,0 +1,26 @@ +value; + } +} diff --git a/src/Form/EnemyGameNotesType.php b/src/Form/EnemyGameNotesType.php new file mode 100644 index 0000000..74a7dca --- /dev/null +++ b/src/Form/EnemyGameNotesType.php @@ -0,0 +1,29 @@ +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]); + } +} diff --git a/src/Form/EnemyType.php b/src/Form/EnemyType.php new file mode 100644 index 0000000..89d7f71 --- /dev/null +++ b/src/Form/EnemyType.php @@ -0,0 +1,76 @@ + [ + '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]); + } +} diff --git a/src/Repository/EnemyRepository.php b/src/Repository/EnemyRepository.php new file mode 100644 index 0000000..4fb34f9 --- /dev/null +++ b/src/Repository/EnemyRepository.php @@ -0,0 +1,77 @@ + + */ +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, 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, + ]; + } +} diff --git a/templates/base.html.twig b/templates/base.html.twig index 5de5bc7..9c73901 100644 --- a/templates/base.html.twig +++ b/templates/base.html.twig @@ -29,6 +29,7 @@ { route: 'app_home', label: 'Overview' }, { route: 'app_characters', label: 'Characters' }, { route: 'app_npcs', label: "NPC's" }, + { route: 'app_enemies', label: 'Enemies' }, { route: 'app_storyline', label: 'Storyline' }, ] %}