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