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:
@@ -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));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user