Add NPC database table with server-side DataTable and per-NPC game notes

- Npc entity (village spreadsheet columns) + game_notes text field for
  the DM's live play notes, plus updated_at touched on every change
- Race / VillageStatus / BloodCartInvolvement / MysteryImportance as
  string-backed PHP enums, rendered as dropdowns on the edit form
- /npcs: server-side DataTables (paging, global search and column sort
  all run in the backend via NpcRepository::findForDataTable); name cell
  links to the edit page, game notes shown truncated
- /npcs/{id}: Bootstrap-themed edit form (all fields + a large game
  notes textarea); Symfony forms now use the bootstrap_5 theme
- app.js: generic server-side DataTable wiring driven by data-* on the
  <table>/<th> (data-datatable-src, data-row-url, data-format)
- app:npc:import console command - upserts from data/npcs.csv by
  (family, name), never overwrites game_notes; --purge / --dry-run
- data/npcs.csv: 180 villagers exported from the working spreadsheet

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Frank
2026-09-06 15:04:39 +02:00
co-authored by Claude Sonnet 5
parent cc4fbf425d
commit 1ccab6ed68
15 changed files with 1010 additions and 9 deletions
+196
View File
@@ -0,0 +1,196 @@
<?php
namespace App\Command;
use App\Entity\Npc;
use App\Enum\BloodCartInvolvement;
use App\Enum\MysteryImportance;
use App\Enum\Race;
use App\Enum\VillageStatus;
use App\Repository\NpcRepository;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
#[AsCommand(
name: 'app:npc:import',
description: 'Import / refresh NPCs from a CSV export of the village spreadsheet.',
)]
class ImportNpcsCommand extends Command
{
/** CSV header => setter-ish key. */
private const COLUMNS = [
'Family', 'Name', 'Age', 'Race', 'Occupation',
'Relationships / Notes', 'Village Status', 'Blood Cart Involvement', 'Mystery Importance',
];
public function __construct(
private readonly EntityManagerInterface $em,
private readonly NpcRepository $npcs,
#[Autowire('%kernel.project_dir%')]
private readonly string $projectDir,
) {
parent::__construct();
}
protected function configure(): void
{
$this
->addArgument('file', InputArgument::OPTIONAL, 'CSV path', 'data/npcs.csv')
->addOption('purge', null, InputOption::VALUE_NONE, 'Delete NPCs not present in the file')
->addOption('dry-run', null, InputOption::VALUE_NONE, 'Report what would change without writing');
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
$dryRun = (bool) $input->getOption('dry-run');
$path = $input->getArgument('file');
if (!is_file($path) && is_file($this->projectDir.'/'.$path)) {
$path = $this->projectDir.'/'.$path;
}
if (!is_readable($path)) {
$io->error(sprintf('File not readable: %s', $path));
return Command::FAILURE;
}
$rows = $this->readCsv($path, $io);
if (null === $rows) {
return Command::FAILURE;
}
// Validate every enum value up front so we fail cleanly, not half-way.
$unknown = [];
foreach ($rows as $line => $r) {
foreach ([
[Race::class, $r['Race']],
[VillageStatus::class, $r['Village Status']],
[BloodCartInvolvement::class, $r['Blood Cart Involvement']],
[MysteryImportance::class, $r['Mystery Importance']],
] as [$enum, $value]) {
if (null === $enum::tryFrom((string) $value)) {
$unknown[] = sprintf('row %d: %s has no case for "%s"', $line + 2, $enum, $value);
}
}
}
if ($unknown) {
$io->error('Unknown enum values - add the case(s) or fix the file:');
$io->listing($unknown);
return Command::FAILURE;
}
$existing = [];
foreach ($this->npcs->findAll() as $npc) {
$existing[$this->key($npc->getFamily(), $npc->getName())] = $npc;
}
$created = $updated = 0;
$seen = [];
foreach ($rows as $r) {
$key = $this->key($r['Family'], $r['Name']);
$seen[$key] = true;
$npc = $existing[$key] ?? null;
if (null === $npc) {
$npc = (new Npc())->setFamily($r['Family'])->setName($r['Name']);
if (!$dryRun) {
$this->em->persist($npc);
}
++$created;
} else {
++$updated;
}
// NOTE: gameNotes is deliberately never written here.
$npc->setAge('' === $r['Age'] ? null : (int) $r['Age'])
->setRace(Race::from($r['Race']))
->setOccupation($r['Occupation'] ?: null)
->setRelationships($r['Relationships / Notes'] ?: null)
->setVillageStatus(VillageStatus::from($r['Village Status']))
->setBloodCartInvolvement(BloodCartInvolvement::from($r['Blood Cart Involvement']))
->setMysteryImportance(MysteryImportance::from($r['Mystery Importance']));
}
$purged = 0;
if ($input->getOption('purge')) {
foreach ($existing as $key => $npc) {
if (!isset($seen[$key])) {
if (!$dryRun) {
$this->em->remove($npc);
}
++$purged;
}
}
}
if (!$dryRun) {
$this->em->flush();
}
$io->success(sprintf(
'%s: %d created, %d updated%s (%d rows in file).',
$dryRun ? 'Dry run' : 'Imported',
$created,
$updated,
$input->getOption('purge') ? ", {$purged} purged" : '',
\count($rows),
));
return Command::SUCCESS;
}
/**
* @return list<array<string, string>>|null
*/
private function readCsv(string $path, SymfonyStyle $io): ?array
{
$fh = fopen($path, 'r');
$header = fgetcsv($fh, escape: '');
if (false === $header) {
$io->error('Empty file.');
fclose($fh);
return null;
}
$header = array_map('trim', $header);
$missing = array_diff(self::COLUMNS, $header);
if ($missing) {
$io->error('Missing columns: '.implode(', ', $missing));
fclose($fh);
return null;
}
$rows = [];
while (false !== ($line = fgetcsv($fh, escape: ''))) {
if (1 === \count($line) && (null === $line[0] || '' === trim((string) $line[0]))) {
continue; // blank line
}
$assoc = [];
foreach ($header as $i => $name) {
$assoc[$name] = trim((string) ($line[$i] ?? ''));
}
if ('' === $assoc['Family'] && '' === $assoc['Name']) {
continue;
}
$rows[] = $assoc;
}
fclose($fh);
return $rows;
}
private function key(string $family, string $name): string
{
return mb_strtolower(trim($family).'|'.trim($name));
}
}
+69 -1
View File
@@ -2,7 +2,13 @@
namespace App\Controller;
use App\Entity\Npc;
use App\Form\NpcType;
use App\Repository\NpcRepository;
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;
@@ -10,9 +16,71 @@ use Symfony\Component\Security\Http\Attribute\IsGranted;
#[IsGranted('IS_AUTHENTICATED_FULLY')]
class NpcController extends AbstractController
{
#[Route('/npcs', name: 'app_npcs')]
#[Route('/npcs', name: 'app_npcs', methods: ['GET'])]
public function index(): Response
{
return $this->render('npc/index.html.twig');
}
/**
* Server-side processing endpoint for the DataTable on /npcs.
* https://datatables.net/manual/server-side
*/
#[Route('/npcs/data', name: 'app_npcs_data', methods: ['GET'])]
public function data(Request $request, NpcRepository $repo): JsonResponse
{
$q = $request->query;
$draw = (int) $q->get('draw', 1);
$start = (int) $q->get('start', 0);
$length = (int) $q->get('length', 25);
$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'] ?? 'family';
$sortDir = (string) ($order['dir'] ?? 'asc');
$result = $repo->findForDataTable($search, $sortKey, $sortDir, $start, $length);
$data = array_map(static fn (Npc $n) => [
'id' => $n->getId(),
'family' => $n->getFamily(),
'name' => $n->getName(),
'age' => $n->getAge(),
'race' => $n->getRace()->value,
'occupation' => $n->getOccupation(),
'relationships' => $n->getRelationships(),
'villageStatus' => $n->getVillageStatus()->value,
'bloodCartInvolvement' => $n->getBloodCartInvolvement()->value,
'mysteryImportance' => $n->getMysteryImportance()->value,
'gameNotes' => $n->getGameNotes(),
'updatedAt' => $n->getUpdatedAt()?->format('Y-m-d H:i'),
], $result['data']);
return $this->json([
'draw' => $draw,
'recordsTotal' => $result['recordsTotal'],
'recordsFiltered' => $result['recordsFiltered'],
'data' => $data,
]);
}
#[Route('/npcs/{id}', name: 'app_npc_edit', requirements: ['id' => '\d+'], methods: ['GET', 'POST'])]
public function edit(Request $request, Npc $npc, EntityManagerInterface $em): Response
{
$form = $this->createForm(NpcType::class, $npc);
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
$em->flush();
$this->addFlash('success', sprintf('Saved %s.', $npc->getName()));
return $this->redirectToRoute('app_npc_edit', ['id' => $npc->getId()]);
}
return $this->render('npc/edit.html.twig', [
'npc' => $npc,
'form' => $form,
]);
}
}
+198
View File
@@ -0,0 +1,198 @@
<?php
namespace App\Entity;
use App\Enum\BloodCartInvolvement;
use App\Enum\MysteryImportance;
use App\Enum\Race;
use App\Enum\VillageStatus;
use App\Repository\NpcRepository;
use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Validator\Constraints as Assert;
#[ORM\Entity(repositoryClass: NpcRepository::class)]
#[ORM\UniqueConstraint(name: 'uniq_npc_family_name', columns: ['family', 'name'])]
#[ORM\HasLifecycleCallbacks]
class Npc
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
private ?int $id = null;
#[ORM\Column(length: 120)]
#[Assert\NotBlank]
private string $family = '';
#[ORM\Column(length: 160)]
#[Assert\NotBlank]
private string $name = '';
#[ORM\Column(type: Types::SMALLINT, nullable: true)]
#[Assert\PositiveOrZero]
private ?int $age = null;
#[ORM\Column(enumType: Race::class)]
private Race $race = Race::Human;
#[ORM\Column(length: 160, nullable: true)]
private ?string $occupation = null;
/** Free text - the spreadsheet's "Relationships / Notes" column. */
#[ORM\Column(type: Types::TEXT, nullable: true)]
private ?string $relationships = null;
#[ORM\Column(enumType: VillageStatus::class)]
private VillageStatus $villageStatus = VillageStatus::Established;
#[ORM\Column(enumType: BloodCartInvolvement::class)]
private BloodCartInvolvement $bloodCartInvolvement = BloodCartInvolvement::None;
#[ORM\Column(enumType: MysteryImportance::class)]
private MysteryImportance $mysteryImportance = MysteryImportance::Low;
/** DM's live notes, written during play. Never touched by the 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();
}
public function getId(): ?int
{
return $this->id;
}
public function getFamily(): string
{
return $this->family;
}
public function setFamily(string $family): static
{
$this->family = $family;
return $this;
}
public function getName(): string
{
return $this->name;
}
public function setName(string $name): static
{
$this->name = $name;
return $this;
}
public function getAge(): ?int
{
return $this->age;
}
public function setAge(?int $age): static
{
$this->age = $age;
return $this;
}
public function getRace(): Race
{
return $this->race;
}
public function setRace(Race $race): static
{
$this->race = $race;
return $this;
}
public function getOccupation(): ?string
{
return $this->occupation;
}
public function setOccupation(?string $occupation): static
{
$this->occupation = $occupation;
return $this;
}
public function getRelationships(): ?string
{
return $this->relationships;
}
public function setRelationships(?string $relationships): static
{
$this->relationships = $relationships;
return $this;
}
public function getVillageStatus(): VillageStatus
{
return $this->villageStatus;
}
public function setVillageStatus(VillageStatus $villageStatus): static
{
$this->villageStatus = $villageStatus;
return $this;
}
public function getBloodCartInvolvement(): BloodCartInvolvement
{
return $this->bloodCartInvolvement;
}
public function setBloodCartInvolvement(BloodCartInvolvement $bloodCartInvolvement): static
{
$this->bloodCartInvolvement = $bloodCartInvolvement;
return $this;
}
public function getMysteryImportance(): MysteryImportance
{
return $this->mysteryImportance;
}
public function setMysteryImportance(MysteryImportance $mysteryImportance): static
{
$this->mysteryImportance = $mysteryImportance;
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 BloodCartInvolvement: string
{
case None = 'None';
case Donor = 'Donor';
case OccasionalDonor = 'Occasional donor';
case BloodCollector = 'Blood collector';
case BloodCartDriver = 'Blood-cart driver';
case CouncilOversight = 'Council oversight';
public function label(): string
{
return $this->value;
}
}
+16
View File
@@ -0,0 +1,16 @@
<?php
namespace App\Enum;
enum MysteryImportance: string
{
case Low = 'Low';
case Medium = 'Medium';
case High = 'High';
case Critical = 'Critical';
public function label(): string
{
return $this->value;
}
}
+22
View File
@@ -0,0 +1,22 @@
<?php
namespace App\Enum;
enum Race: string
{
case Human = 'Human';
case Dwarf = 'Dwarf';
case Elf = 'Elf';
case HalfElf = 'Half-Elf';
case Halfling = 'Halfling';
case Gnome = 'Gnome';
case HalfOrc = 'Half-Orc';
case Tiefling = 'Tiefling';
case Dragonborn = 'Dragonborn';
case Other = 'Other';
public function label(): string
{
return $this->value;
}
}
+16
View File
@@ -0,0 +1,16 @@
<?php
namespace App\Enum;
enum VillageStatus: string
{
case Established = 'Established villager';
case Young = 'Young villager';
case Adopted = 'Adopted villager';
case NewlyEligibleDonor = 'Newly eligible donor';
public function label(): string
{
return $this->value;
}
}
+52
View File
@@ -0,0 +1,52 @@
<?php
namespace App\Form;
use App\Entity\Npc;
use App\Enum\BloodCartInvolvement;
use App\Enum\MysteryImportance;
use App\Enum\Race;
use App\Enum\VillageStatus;
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 NpcType extends AbstractType
{
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$enum = static fn (string $class) => [
'class' => $class,
'choice_label' => static fn ($case) => $case->value,
];
$builder
->add('family', TextType::class)
->add('name', TextType::class)
->add('age', IntegerType::class, ['required' => false])
->add('race', EnumType::class, $enum(Race::class))
->add('occupation', TextType::class, ['required' => false])
->add('relationships', TextareaType::class, [
'required' => false,
'label' => 'Relationships / Notes',
'attr' => ['rows' => 3],
])
->add('villageStatus', EnumType::class, $enum(VillageStatus::class))
->add('bloodCartInvolvement', EnumType::class, $enum(BloodCartInvolvement::class))
->add('mysteryImportance', EnumType::class, $enum(MysteryImportance::class))
->add('gameNotes', TextareaType::class, [
'required' => false,
'label' => 'Game notes',
'attr' => ['rows' => 10, 'placeholder' => 'What happened with this NPC during play…'],
]);
}
public function configureOptions(OptionsResolver $resolver): void
{
$resolver->setDefaults(['data_class' => Npc::class]);
}
}
+81
View File
@@ -0,0 +1,81 @@
<?php
namespace App\Repository;
use App\Entity\Npc;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
/**
* @extends ServiceEntityRepository<Npc>
*/
class NpcRepository extends ServiceEntityRepository
{
/** Fields the DataTable may sort on (column key => DQL field). */
public const SORTABLE = [
'family' => 'n.family',
'name' => 'n.name',
'age' => 'n.age',
'race' => 'n.race',
'occupation' => 'n.occupation',
'villageStatus' => 'n.villageStatus',
'bloodCartInvolvement' => 'n.bloodCartInvolvement',
'mysteryImportance' => 'n.mysteryImportance',
'updatedAt' => 'n.updatedAt',
];
/** Fields the global search box matches against (LIKE, OR-ed). */
private const SEARCHABLE = [
'n.family', 'n.name', 'n.occupation', 'n.relationships', 'n.gameNotes',
'n.race', 'n.villageStatus', 'n.bloodCartInvolvement', 'n.mysteryImportance',
];
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, Npc::class);
}
/**
* Server-side data for DataTables.
*
* @return array{data: list<Npc>, recordsTotal: int, recordsFiltered: int}
*/
public function findForDataTable(
?string $search,
string $sortKey,
string $sortDir,
int $start,
int $length,
): array {
$sortField = self::SORTABLE[$sortKey] ?? 'n.family';
$sortDir = strtolower($sortDir) === 'desc' ? 'DESC' : 'ASC';
$length = $length < 0 ? 200 : min($length, 200);
$qb = $this->createQueryBuilder('n');
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(n.id)')->getQuery()->getSingleScalarResult();
$rows = $qb
->orderBy($sortField, $sortDir)
->addOrderBy('n.name', 'ASC')
->setFirstResult(max(0, $start))
->setMaxResults($length)
->getQuery()
->getResult();
return [
'data' => $rows,
'recordsTotal' => $this->count([]),
'recordsFiltered' => $filtered,
];
}
}