Add app:character:seed so party sheets can reach every environment

Darragh Oakwanderer never made it past the local database - the
Characters migration only creates the empty table, and I'd added him
with a one-off script that was never committed. This closes that gap
the same way app:enemy:seed does: a small catalog of ready-made sheets,
picked interactively or by name, idempotent (skips what's already
there), with --purge to remove them.

Verified against the local DB: purge, add, and a repeat add (correctly
reports 0 added / 1 already present) all produce exactly the row I'd
checked by hand earlier.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Frank
2026-09-22 09:02:00 +02:00
co-authored by Claude Sonnet 5
parent 9052218927
commit d4d8cae00f
+177
View File
@@ -0,0 +1,177 @@
<?php
namespace App\Command;
use App\Entity\Character;
use App\Repository\CharacterRepository;
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;
use Symfony\Component\Console\Style\SymfonyStyle;
/**
* Brings the party's character sheets onto an environment. Unlike the enemy
* seed command this isn't reference data - it's the players' own sheets -
* but the same "pick from a list, idempotent, skip what's already there"
* mechanism is the reproducible way to get them onto testing/live without
* retyping the whole sheet into the web form.
*/
#[AsCommand(
name: 'app:character:seed',
description: 'Add ready-made party character sheets to the characters table.',
)]
class SeedCharactersCommand extends Command
{
private const ADD_ALL = 'All of them';
public function __construct(
private readonly EntityManagerInterface $em,
private readonly CharacterRepository $characters,
) {
parent::__construct();
}
protected function configure(): void
{
$this
->addArgument('names', InputArgument::IS_ARRAY, 'Character names to add (case-insensitive); prompts with a list if omitted')
->addOption('all', null, InputOption::VALUE_NONE, 'Add every available character without prompting')
->addOption('purge', null, InputOption::VALUE_NONE, 'Remove the seeded characters instead of adding them');
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
$catalog = [];
foreach ($this->sheets() as $row) {
$catalog[$row['name']] = $row;
}
$available = array_keys($catalog);
if ($input->getOption('purge')) {
$removed = 0;
foreach ($available as $name) {
if ($c = $this->characters->findOneBy(['name' => $name])) {
$this->em->remove($c);
++$removed;
}
}
$this->em->flush();
$io->success(sprintf('Removed %d seeded character(s).', $removed));
return Command::SUCCESS;
}
$selected = $this->resolveSelection($input, $io, $available);
if (null === $selected) {
return Command::FAILURE;
}
$added = $skipped = 0;
foreach ($selected as $name) {
if ($this->characters->findOneBy(['name' => $name])) {
++$skipped;
continue;
}
$this->em->persist($this->hydrate($catalog[$name]));
++$added;
}
$this->em->flush();
$io->success(sprintf('%d character(s) added, %d already present.', $added, $skipped));
return Command::SUCCESS;
}
/**
* @param list<string> $available
*
* @return list<string>|null the chosen names, or null on an unknown-name error
*/
private function resolveSelection(InputInterface $input, SymfonyStyle $io, array $available): ?array
{
/** @var list<string> $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 character "%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<string> $choice */
$choice = $io->choice('Which character(s) do you want to add?', [...$available, self::ADD_ALL], null, true);
return \in_array(self::ADD_ALL, $choice, true) ? $available : $choice;
}
/**
* @param array<string, mixed> $row
*/
private function hydrate(array $row): Character
{
$c = new Character();
foreach ($row as $prop => $value) {
$c->{'set'.ucfirst($prop)}($value);
}
return $c;
}
/**
* @return list<array<string, mixed>>
*/
private function sheets(): array
{
return [
[
'name' => 'Darragh Oakwanderer', 'playerName' => 'PH',
'race' => 'Halfling, stout', 'characterClass' => 'Ranger', 'level' => 2,
'background' => 'Urchin', 'alignment' => 'CN',
'armorClass' => 15, 'initiative' => 3, 'speed' => '25 ft.', 'hitDice' => '2d10',
'proficiencyBonus' => 2,
'strength' => 12, 'dexterity' => 17, 'constitution' => 14,
'intelligence' => 12, 'wisdom' => 13, 'charisma' => 8,
'savingThrowProficiencies' => ['Strength', 'Dexterity'],
'skillProficiencies' => ['Sleight of Hand', 'Stealth'],
'attacks' => [
['name' => 'Dart', 'bonus' => '+5', 'damage' => '1d4+5', 'range' => '20/60 ft. (×10 carried)'],
['name' => 'Dagger', 'bonus' => '+5', 'damage' => '1d4+3(5)', 'range' => '20/60 ft. (×3 carried)'],
['name' => 'Spear', 'bonus' => '+3', 'damage' => '1d8+6(1+3)', 'range' => '20/60 ft. (×1 carried)'],
],
'otherProficienciesLanguages' => "Languages: Halfling, Common\n\nTools: Disguise kit, Thieves' tools",
'equipment' => "Small knife\nMap of the city\nPet bees\nToken of parents\nCommon clothes\nStudded leather armor\nSpear\n10 darts\n3 daggers\nExplorer's pack",
'featuresTraits' => "Stout Resilience\nLucky\nBrave\nNimble\n\nCity Secrets\n\nFavored Foe\nDeft Explorer\nFighting Style: Throwing\nSpellcasting",
'spellcasting' => "Spellcasting ability: Wisdom (spell save DC 11, spell attack bonus +3)\n\nLevel 1 (2 slots): Hunter's Mark, Cure Wounds",
'personalityTraits' => 'I eat like a pig and have bad manners',
'ideals' => "I help the people who help me, that's what keeps us alive",
'bonds' => "My town is my home and I'll fight to defend it",
'flaws' => "It's not stealing if I need it more than someone else",
],
];
}
}