From 373fa6f78bb5f9a3785545e9d405efeac8f800d8 Mon Sep 17 00:00:00 2001 From: Frank Date: Mon, 7 Sep 2026 22:59:03 +0200 Subject: [PATCH] enemy:seed: pick creatures from a list, add Hearthburrow The command now prompts with a numbered multi-select list instead of seeding all monsters unconditionally. Names can also be passed as arguments (case-insensitive) and --all keeps the old bulk behaviour for non-interactive runs. Adds the homebrew Hearthburrow (Tiny Fey, CR 1/2) to the catalogue, with its Cottage Actions in the legendary-actions field and the Temper track in the description. Co-Authored-By: Claude Sonnet 5 --- src/Command/SeedEnemiesCommand.php | 120 ++++++++++++++++++++++++----- 1 file changed, 101 insertions(+), 19 deletions(-) diff --git a/src/Command/SeedEnemiesCommand.php b/src/Command/SeedEnemiesCommand.php index 1ad22b1..8e386af 100644 --- a/src/Command/SeedEnemiesCommand.php +++ b/src/Command/SeedEnemiesCommand.php @@ -9,6 +9,7 @@ use App\Repository\EnemyRepository; 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; @@ -16,10 +17,12 @@ use Symfony\Component\Console\Style\SymfonyStyle; #[AsCommand( name: 'app:enemy:seed', - description: 'Add a handful of SRD monsters to the enemies table for testing.', + description: 'Add ready-made creatures (SRD monsters and homebrew) to the enemies table.', )] class SeedEnemiesCommand extends Command { + private const ADD_ALL = 'All of them'; + public function __construct( private readonly EntityManagerInterface $em, private readonly EnemyRepository $enemies, @@ -29,57 +32,121 @@ class SeedEnemiesCommand extends Command protected function configure(): void { - $this->addOption('purge', null, InputOption::VALUE_NONE, 'Remove the seeded monsters instead of adding them'); + $this + ->addArgument('names', InputArgument::IS_ARRAY, 'Creature names to add (case-insensitive); prompts with a list if omitted') + ->addOption('all', null, InputOption::VALUE_NONE, 'Add every available creature without prompting') + ->addOption('purge', null, InputOption::VALUE_NONE, 'Remove the seeded creatures instead of adding them'); } protected function execute(InputInterface $input, OutputInterface $output): int { $io = new SymfonyStyle($input, $output); - $data = $this->monsters(); + + $catalog = []; + foreach ($this->creatures() as $row) { + $catalog[$row['name']] = $row; + } + $available = array_keys($catalog); if ($input->getOption('purge')) { $removed = 0; - foreach ($data as $row) { - if ($e = $this->enemies->findOneBy(['name' => $row['name']])) { + foreach ($available as $name) { + if ($e = $this->enemies->findOneBy(['name' => $name])) { $this->em->remove($e); ++$removed; } } $this->em->flush(); - $io->success(sprintf('Removed %d seeded monster(s).', $removed)); + $io->success(sprintf('Removed %d seeded creature(s).', $removed)); return Command::SUCCESS; } + $selected = $this->resolveSelection($input, $io, $available); + if (null === $selected) { + return Command::FAILURE; + } + $added = $skipped = 0; - foreach ($data as $row) { - if ($this->enemies->findOneBy(['name' => $row['name']])) { + foreach ($selected as $name) { + if ($this->enemies->findOneBy(['name' => $name])) { ++$skipped; continue; } - $e = new Enemy(); - foreach ($row as $prop => $value) { - $e->{'set'.ucfirst($prop)}( - 'size' === $prop ? CreatureSize::from($value) - : ('type' === $prop ? CreatureType::from($value) : $value) - ); - } - $this->em->persist($e); + $this->em->persist($this->hydrate($catalog[$name])); ++$added; } $this->em->flush(); - $io->success(sprintf('%d monster(s) added, %d already present.', $added, $skipped)); + $io->success(sprintf('%d creature(s) added, %d already present.', $added, $skipped)); return Command::SUCCESS; } /** - * SRD 5.1 stat blocks (open content). Trimmed to what the app stores. + * @param list $available + * + * @return list|null the chosen names, or null on an unknown-name error + */ + private function resolveSelection(InputInterface $input, SymfonyStyle $io, array $available): ?array + { + /** @var list $requested */ + $requested = $input->getArgument('names'); + + if ($requested) { + $selected = []; + foreach ($requested as $name) { + $match = null; + foreach ($available as $known) { + if (0 === strcasecmp($known, $name)) { + $match = $known; + break; + } + } + if (null === $match) { + $io->error(sprintf('Unknown creature "%s". Available: %s', $name, implode(', ', $available))); + + return null; + } + $selected[] = $match; + } + + return array_values(array_unique($selected)); + } + + if ($input->getOption('all') || !$input->isInteractive()) { + return $available; + } + + /** @var list $choice */ + $choice = $io->choice('Which creature(s) do you want to add?', [...$available, self::ADD_ALL], null, true); + + return \in_array(self::ADD_ALL, $choice, true) ? $available : $choice; + } + + /** + * @param array $row + */ + private function hydrate(array $row): Enemy + { + $e = new Enemy(); + foreach ($row as $prop => $value) { + $e->{'set'.ucfirst($prop)}( + 'size' === $prop ? CreatureSize::from($value) + : ('type' === $prop ? CreatureType::from($value) : $value) + ); + } + + return $e; + } + + /** + * Ready-made stat blocks, trimmed to what the app stores. SRD 5.1 entries are + * open content; "Homebrew" entries are campaign-specific. * * @return list> */ - private function monsters(): array + private function creatures(): array { return [ [ @@ -175,6 +242,21 @@ class SeedEnemiesCommand extends Command 'legendaryActions' => "The dragon can take 3 legendary actions, choosing from the options below. Only one legendary action can be used at a time and only at the end of another creature's turn. The dragon regains spent legendary actions at the start of its turn.\nDetect. The dragon makes a Wisdom (Perception) check.\nTail Attack. The dragon makes a tail attack.\nWing Attack (Costs 2 Actions). The dragon beats its wings. Each creature within 10 ft. must succeed on a DC 22 Dexterity saving throw or take 15 (2d6 + 8) bludgeoning damage and be knocked prone. The dragon can then fly up to half its flying speed.", 'source' => 'SRD 5.1', ], + [ + 'name' => 'Hearthburrow', 'size' => 'Tiny', 'type' => 'Fey', 'subtype' => 'household spirit', + 'alignment' => 'chaotic neutral', 'armorClass' => 13, + 'hitPoints' => 18, 'hitDice' => '4d4 + 8', 'speed' => '25 ft., climb 20 ft.', + 'strength' => 6, 'dexterity' => 16, 'constitution' => 14, 'intelligence' => 10, 'wisdom' => 14, 'charisma' => 13, + 'challengeRating' => '1/2', 'experiencePoints' => 100, 'proficiencyBonus' => 2, + 'savingThrows' => 'Dex +5', 'skills' => 'Perception +4, Sleight of Hand +5, Stealth +7', + 'senses' => 'darkvision 60 ft., passive Perception 14', + 'languages' => 'understands Common, speaks Sylvan', + 'traits' => "Household Camouflage. While inside a building, the hearthburrow has advantage on Dexterity (Stealth) checks.\nWall Scurry. It moves through openings as narrow as 2 inches without squeezing. If it ends its turn inside a wall, floor, chimney, or similar space, it must emerge at the start of its next turn.\nOffended Hospitality. The hearthburrow is not trying to kill anyone. Unless seriously injured or cornered, it frightens, annoys, and inconveniences intruders to drive them out rather than dealing lethal damage. If reduced below 6 hit points, it flees beneath the hearth.", + 'actions' => "Scratch. Melee Weapon Attack: +5 to hit, reach 5 ft., one creature. Hit: 5 (1d4 + 3) slashing damage.\nPilfer. The hearthburrow steals one Tiny unattended object, or one Tiny object carried by a creature within 5 ft. (DC 13 Dexterity saving throw to prevent). It favors keys, buttons, coins, cutlery, and anything shiny.\nMimic Voice. The hearthburrow imitates a short sound or phrase it has heard in the last 24 hours. A listener notices something is off with a DC 13 Wisdom (Insight) check. This deals no damage and exists purely to be annoying.", + 'legendaryActions' => "Cottage Actions. While inside its cottage, on initiative count 20 (losing initiative ties) the hearthburrow takes one Cottage Action. It can't use the same one two rounds in a row.\nFlying Crockery. A household object flies at one creature: DC 12 Dexterity saving throw or 3 (1d6) bludgeoning damage.\nPulled Rug. One creature standing on the floor: DC 12 Dexterity saving throw or fall prone.\nSlamming Shutter. A shutter, door, or cupboard slams; one creature within 5 ft. of it: DC 12 Dexterity saving throw or 2 (1d4) bludgeoning damage, plus a deafening bang.\nSnuffed Flame. One nonmagical candle, lantern, or fire in the cottage goes out and must be relit normally.\nChimney Soot. Soot bursts out in a 10-foot radius: each creature there makes a DC 12 Constitution saving throw or is blinded until the end of its next turn. The area is lightly obscured until a window is opened.\nWhere Did My Boot Go? One unattended Tiny or Small mundane object is relocated somewhere inconvenient (a boot up the chimney, a key inside a teapot). No saving throw.", + 'description' => "A tiny house-fey that has decided your lodging is its home. It does not fight to kill; it wants intruders gone.\n\nTemper. Track secretly from 0 to 6, starting at 4. 0 friendly, 1 curious, 2 mischievous, 3 annoyed, 4 angry, 5 furious, 6 attacks or drives everyone out.\n-1: leaving food, offering milk, repairing something in the cottage, speaking politely to the unseen creature.\n-2: correctly performing the old ritual.\n+1: breaking furniture, stealing from its collection, insulting or threatening it.\n+2: deliberately hurting the hearthburrow.\nRun it as a social and environmental encounter first; go to combat only if Temper reaches 6 or it is cornered.", + 'source' => 'Homebrew', + ], ]; } }