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