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