diff --git a/migrations/Version20260906115952.php b/migrations/Version20260906115952.php index ebcb8d7..8926b1a 100644 --- a/migrations/Version20260906115952.php +++ b/migrations/Version20260906115952.php @@ -19,8 +19,8 @@ final class Version20260906115952 extends AbstractMigration public function up(Schema $schema): void { - $this->addSql('CREATE TABLE `user` (id INT AUTO_INCREMENT NOT NULL, email VARCHAR(180) NOT NULL, roles JSON NOT NULL, password VARCHAR(255) NOT NULL, UNIQUE INDEX UNIQ_8D93D649E7927C74 (email), PRIMARY KEY (id)) DEFAULT CHARACTER SET utf8mb4'); - $this->addSql('CREATE TABLE messenger_messages (id BIGINT AUTO_INCREMENT NOT NULL, body LONGTEXT NOT NULL, headers LONGTEXT NOT NULL, queue_name VARCHAR(190) NOT NULL, created_at DATETIME NOT NULL, available_at DATETIME NOT NULL, delivered_at DATETIME DEFAULT NULL, INDEX IDX_75EA56E0FB7336F0E3BD61CE16BA31DBBF396750 (queue_name, available_at, delivered_at, id), PRIMARY KEY (id)) DEFAULT CHARACTER SET utf8mb4'); + $this->addSql('CREATE TABLE `user` (id INT AUTO_INCREMENT NOT NULL, email VARCHAR(180) NOT NULL, roles JSON NOT NULL, password VARCHAR(255) NOT NULL, UNIQUE INDEX UNIQ_8D93D649E7927C74 (email), PRIMARY KEY (id)) DEFAULT CHARACTER SET utf8mb4 ENGINE = InnoDB'); + $this->addSql('CREATE TABLE messenger_messages (id BIGINT AUTO_INCREMENT NOT NULL, body LONGTEXT NOT NULL, headers LONGTEXT NOT NULL, queue_name VARCHAR(190) NOT NULL, created_at DATETIME NOT NULL, available_at DATETIME NOT NULL, delivered_at DATETIME DEFAULT NULL, INDEX IDX_75EA56E0FB7336F0E3BD61CE16BA31DBBF396750 (queue_name, available_at, delivered_at, id), PRIMARY KEY (id)) DEFAULT CHARACTER SET utf8mb4 ENGINE = InnoDB'); } public function down(Schema $schema): void diff --git a/migrations/Version20260906125931.php b/migrations/Version20260906125931.php index 800bba6..5394cb1 100644 --- a/migrations/Version20260906125931.php +++ b/migrations/Version20260906125931.php @@ -19,7 +19,7 @@ final class Version20260906125931 extends AbstractMigration public function up(Schema $schema): void { - $this->addSql('CREATE TABLE npc (id INT AUTO_INCREMENT NOT NULL, family VARCHAR(120) NOT NULL, name VARCHAR(160) NOT NULL, age SMALLINT DEFAULT NULL, race VARCHAR(255) NOT NULL, occupation VARCHAR(160) DEFAULT NULL, relationships LONGTEXT DEFAULT NULL, village_status VARCHAR(255) NOT NULL, blood_cart_involvement VARCHAR(255) NOT NULL, mystery_importance VARCHAR(255) NOT NULL, game_notes LONGTEXT DEFAULT NULL, updated_at DATETIME DEFAULT NULL, UNIQUE INDEX uniq_npc_family_name (family, name), PRIMARY KEY (id)) DEFAULT CHARACTER SET utf8mb4'); + $this->addSql('CREATE TABLE npc (id INT AUTO_INCREMENT NOT NULL, family VARCHAR(120) NOT NULL, name VARCHAR(160) NOT NULL, age SMALLINT DEFAULT NULL, race VARCHAR(255) NOT NULL, occupation VARCHAR(160) DEFAULT NULL, relationships LONGTEXT DEFAULT NULL, village_status VARCHAR(255) NOT NULL, blood_cart_involvement VARCHAR(255) NOT NULL, mystery_importance VARCHAR(255) NOT NULL, game_notes LONGTEXT DEFAULT NULL, updated_at DATETIME DEFAULT NULL, UNIQUE INDEX uniq_npc_family_name (family, name), PRIMARY KEY (id)) DEFAULT CHARACTER SET utf8mb4 ENGINE = InnoDB'); } public function down(Schema $schema): void diff --git a/migrations/Version20260906131635.php b/migrations/Version20260906131635.php new file mode 100644 index 0000000..2f297be --- /dev/null +++ b/migrations/Version20260906131635.php @@ -0,0 +1,37 @@ +addSql('ALTER TABLE npc ADD father_id INT DEFAULT NULL, ADD mother_id INT DEFAULT NULL'); + $this->addSql('ALTER TABLE npc ADD CONSTRAINT FK_468C762C2055B9A2 FOREIGN KEY (father_id) REFERENCES npc (id) ON DELETE SET NULL'); + $this->addSql('ALTER TABLE npc ADD CONSTRAINT FK_468C762CB78A354D FOREIGN KEY (mother_id) REFERENCES npc (id) ON DELETE SET NULL'); + $this->addSql('CREATE INDEX IDX_468C762C2055B9A2 ON npc (father_id)'); + $this->addSql('CREATE INDEX IDX_468C762CB78A354D ON npc (mother_id)'); + } + + public function down(Schema $schema): void + { + $this->addSql('ALTER TABLE npc DROP FOREIGN KEY FK_468C762C2055B9A2'); + $this->addSql('ALTER TABLE npc DROP FOREIGN KEY FK_468C762CB78A354D'); + $this->addSql('DROP INDEX IDX_468C762C2055B9A2 ON npc'); + $this->addSql('DROP INDEX IDX_468C762CB78A354D ON npc'); + $this->addSql('ALTER TABLE npc DROP father_id, DROP mother_id'); + } +} diff --git a/src/Command/LinkNpcParentsCommand.php b/src/Command/LinkNpcParentsCommand.php new file mode 100644 index 0000000..d919c04 --- /dev/null +++ b/src/Command/LinkNpcParentsCommand.php @@ -0,0 +1,211 @@ + '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 $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 $byFull + * @param array> $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 $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)); + } +} diff --git a/src/Entity/Npc.php b/src/Entity/Npc.php index 88a4c4e..4f89eb4 100644 --- a/src/Entity/Npc.php +++ b/src/Entity/Npc.php @@ -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; diff --git a/src/Form/NpcType.php b/src/Form/NpcType.php index 50859ae..ba0a908 100644 --- a/src/Form/NpcType.php +++ b/src/Form/NpcType.php @@ -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)) diff --git a/templates/npc/edit.html.twig b/templates/npc/edit.html.twig index 0f737e4..9ea6420 100644 --- a/templates/npc/edit.html.twig +++ b/templates/npc/edit.html.twig @@ -29,6 +29,10 @@ {{ form_row(form.villageStatus) }} {{ form_row(form.bloodCartInvolvement) }} {{ form_row(form.mysteryImportance) }} +
+
{{ form_row(form.father) }}
+
{{ form_row(form.mother) }}
+
{{ form_row(form.relationships) }}