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