Add father/mother links to NPCs, derived from the relationships text

- Npc gains self-referencing father/mother (nullable, ON DELETE SET NULL);
  editable as dropdowns on the per-NPC form
- app:npc:link-parents parses "son/daughter/child of X [and Y]" from the
  Relationships / Notes column, resolves the names within the same family,
  and works out which parent is father vs mother from husband/wife/widow(er)
  and son/daughter/sister/brother cues (plus a small name-gender table for
  the few the text can't disambiguate). --dry-run / --force. 92/180 linked,
  none ambiguous.
- migrations now pin ENGINE=InnoDB - the local WAMP MariaDB defaults to
  MyISAM, which silently drops foreign keys

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Frank
2026-09-06 15:21:03 +02:00
co-authored by Claude Sonnet 5
parent 3c622d7dd8
commit 5bf25d1802
7 changed files with 302 additions and 4 deletions
+211
View File
@@ -0,0 +1,211 @@
<?php
namespace App\Command;
use App\Entity\Npc;
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\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
#[AsCommand(
name: 'app:npc:link-parents',
description: "Derive each NPC's father/mother from the free-text \"Relationships / Notes\" column.",
)]
class LinkNpcParentsCommand extends Command
{
/**
* "Son of X", "Daughter of X and Y", "Youngest child of X and Y".
* The lookbehind keeps "grandson of ..." from matching.
*/
private const RE = '/(?<![a-z])(?:son|daughter|child) of ([A-Z][\p{L}\'-]+)(?:\s+and\s+([A-Z][\p{L}\'-]+))?/iu';
/** First names whose gender the relationship text can't reveal. */
private const NAME_GENDER = [
'Hilda' => 'f', 'Celyn' => 'f', 'Alda' => 'f', 'Sella' => 'f', 'Maris' => 'f',
'Poppy' => 'f', 'Nella' => 'f', 'Sera' => 'f', 'Fara' => 'f', 'Anya' => 'f',
'Jessa' => 'f', 'Vara' => 'f', 'Mira' => 'f', 'Maeve' => 'f', 'Talia' => 'f',
'Marek' => 'm', 'Aric' => 'm', 'Ralf' => 'm', 'Finn' => 'm', 'Kellen' => 'm',
'Garran' => 'm', 'Korin' => 'm', 'Evan' => 'm', 'Ronan' => 'm', 'Orrin' => 'm',
'Bramble' => 'm', 'Arlen' => 'm', 'Corvin' => 'm', 'Jory' => 'm', 'Gideon' => 'm',
];
public function __construct(
private readonly EntityManagerInterface $em,
private readonly NpcRepository $npcs,
) {
parent::__construct();
}
protected function configure(): void
{
$this
->addOption('force', null, InputOption::VALUE_NONE, 'Overwrite father/mother links that are already set')
->addOption('dry-run', null, InputOption::VALUE_NONE, 'Report only, write nothing');
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
$force = (bool) $input->getOption('force');
$dryRun = (bool) $input->getOption('dry-run');
/** @var list<Npc> $all */
$all = $this->npcs->findAll();
// index by "family|firstname" for resolution, and by "family|fullname"
$byFull = $byFirst = [];
foreach ($all as $n) {
$byFull[$this->k($n->getFamily(), $n->getName())] = $n;
$first = explode(' ', trim($n->getName()))[0];
$byFirst[$this->k($n->getFamily(), $first)][] = $n;
}
$rows = [];
$linked = $assumed = $unresolved = 0;
foreach ($all as $npc) {
$text = (string) $npc->getRelationships();
if (!preg_match(self::RE, $text, $m)) {
continue;
}
$names = array_values(array_filter([$m[1] ?? null, $m[2] ?? null]));
$resolved = [];
$problem = null;
foreach ($names as $first) {
$cand = $this->resolve($npc, $first, $byFull, $byFirst);
if (null === $cand) {
$problem = sprintf('no "%s" in %s', $first, $npc->getFamily());
break;
}
$resolved[] = $cand;
}
if ($problem) {
$rows[] = [$npc->getName(), '', '', $problem];
++$unresolved;
continue;
}
[$father, $mother, $note] = $this->assign($resolved);
if (null === $father && null === $mother) {
$rows[] = [$npc->getName(), '', '', 'could not tell father from mother'];
++$unresolved;
continue;
}
if ('assumed' === $note) {
++$assumed;
}
$changed = false;
if ($father && ($force || null === $npc->getFather())) {
$npc->setFather($father);
$changed = true;
}
if ($mother && ($force || null === $npc->getMother())) {
$npc->setMother($mother);
$changed = true;
}
if ($changed) {
++$linked;
}
$rows[] = [
$npc->getName(),
$npc->getFather()?->getName() ?? '',
$npc->getMother()?->getName() ?? '',
'assumed' === $note ? 'order assumed (first = father)' : ($changed ? '' : 'already set'),
];
}
if (!$dryRun) {
$this->em->flush();
}
$io->table(['NPC', 'Father', 'Mother', 'Note'], $rows);
$io->success(sprintf(
'%s: %d linked, %d with assumed parent order, %d unresolved.',
$dryRun ? 'Dry run' : 'Done',
$linked,
$assumed,
$unresolved,
));
return Command::SUCCESS;
}
/**
* @param array<string, Npc> $byFull
* @param array<string, list<Npc>> $byFirst
*/
private function resolve(Npc $child, string $first, array $byFull, array $byFirst): ?Npc
{
$exact = $byFull[$this->k($child->getFamily(), $first.' '.$child->getFamily())] ?? null;
if ($exact && $exact !== $child) {
return $exact;
}
$cands = array_values(array_filter(
$byFirst[$this->k($child->getFamily(), $first)] ?? [],
static fn (Npc $n) => $n !== $child,
));
return 1 === \count($cands) ? $cands[0] : null;
}
/**
* @param list<Npc> $parents 1 or 2 resolved NPCs
*
* @return array{0: ?Npc, 1: ?Npc, 2: string} [father, mother, note]
*/
private function assign(array $parents): array
{
if (1 === \count($parents)) {
$g = $this->gender($parents[0]);
return match ($g) {
'm' => [$parents[0], null, ''],
'f' => [null, $parents[0], ''],
default => [null, null, ''],
};
}
[$a, $b] = $parents;
$ga = $this->gender($a);
$gb = $this->gender($b);
if ('m' === $ga || 'f' === $gb) {
return [$a, $b, ''];
}
if ('f' === $ga || 'm' === $gb) {
return [$b, $a, ''];
}
return [$a, $b, 'assumed'];
}
private function gender(Npc $n): ?string
{
$t = ' '.strtolower((string) $n->getRelationships()).' ';
foreach (['husband of', 'widower', ' son of', ' brother of'] as $needle) {
if (str_contains($t, $needle)) {
return 'm';
}
}
foreach (['wife of', 'widow', ' daughter of', ' sister of', ' mother of', ' midwife'] as $needle) {
if (str_contains($t, $needle)) {
return 'f';
}
}
return self::NAME_GENDER[explode(' ', trim($n->getName()))[0]] ?? null;
}
private function k(string $a, string $b): string
{
return mb_strtolower(trim($a).'|'.trim($b));
}
}
+32
View File
@@ -43,6 +43,14 @@ class Npc
#[ORM\Column(type: Types::TEXT, nullable: true)]
private ?string $relationships = null;
#[ORM\ManyToOne(targetEntity: self::class)]
#[ORM\JoinColumn(name: 'father_id', onDelete: 'SET NULL')]
private ?Npc $father = null;
#[ORM\ManyToOne(targetEntity: self::class)]
#[ORM\JoinColumn(name: 'mother_id', onDelete: 'SET NULL')]
private ?Npc $mother = null;
#[ORM\Column(enumType: VillageStatus::class)]
private VillageStatus $villageStatus = VillageStatus::Established;
@@ -143,6 +151,30 @@ class Npc
return $this;
}
public function getFather(): ?Npc
{
return $this->father;
}
public function setFather(?Npc $father): static
{
$this->father = $father;
return $this;
}
public function getMother(): ?Npc
{
return $this->mother;
}
public function setMother(?Npc $mother): static
{
$this->mother = $mother;
return $this;
}
public function getVillageStatus(): VillageStatus
{
return $this->villageStatus;
+15 -1
View File
@@ -7,6 +7,8 @@ use App\Enum\BloodCartInvolvement;
use App\Enum\MysteryImportance;
use App\Enum\Race;
use App\Enum\VillageStatus;
use App\Repository\NpcRepository;
use Symfony\Bridge\Doctrine\Form\Type\EntityType;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\EnumType;
use Symfony\Component\Form\Extension\Core\Type\IntegerType;
@@ -34,7 +36,19 @@ class NpcType extends AbstractType
'required' => false,
'label' => 'Relationships / Notes',
'attr' => ['rows' => 3],
])
]);
$parent = [
'class' => Npc::class,
'required' => false,
'placeholder' => '— unknown —',
'choice_label' => static fn (Npc $n) => sprintf('%s (%s)', $n->getName(), $n->getFamily()),
'query_builder' => static fn (NpcRepository $r) => $r->createQueryBuilder('n')
->orderBy('n.family', 'ASC')->addOrderBy('n.name', 'ASC'),
];
$builder
->add('father', EntityType::class, $parent)
->add('mother', EntityType::class, $parent)
->add('villageStatus', EnumType::class, $enum(VillageStatus::class))
->add('bloodCartInvolvement', EnumType::class, $enum(BloodCartInvolvement::class))
->add('mysteryImportance', EnumType::class, $enum(MysteryImportance::class))