Characters: seed the current party sheets, add skill expertise

The party sheets in assets/characters/ changed: Darrah_Oakwanderer.pdf is
gone, replaced by Darragh Fermwyr (same halfling ranger, renamed and
updated), Owayn Fermwyr (stout halfling fighter, BJ) and Dunnchadh
(ghostwise halfling druid, Circle of the Blighted, Rick).

- app:character:seed now carries those three sheets, read from the PDFs'
  form fields. Darragh's portrait is renamed to match his new name.
- New skill expertise field (double proficiency bonus), since Darragh's
  Deft Explorer gives him +7 Stealth and the sheet had nowhere to show
  it. Expertise implies proficiency; the sheet marks it with two dots,
  the edit form gets a checkbox list next to skill proficiencies.
- Migration adds skill_expertise, backfilling existing rows with [] so
  the NOT NULL JSON column works on tables that already have characters.

Tests: expertise math on the entity, and a catalog test that every seed
sheet hydrates into a Character that passes validation.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Frank
2026-10-04 12:50:24 +02:00
co-authored by Claude Opus 5.5
parent d4d8cae00f
commit 7a927e21a3
13 changed files with 196 additions and 13 deletions
+64 -8
View File
@@ -149,28 +149,84 @@ class SeedCharactersCommand extends Command
{
return [
[
'name' => 'Darragh Oakwanderer', 'playerName' => 'PH',
'name' => 'Darragh Fermwyr', 'playerName' => 'PH',
'race' => 'Halfling, stout', 'characterClass' => 'Ranger', 'level' => 2,
'background' => 'Urchin', 'alignment' => 'CN',
'armorClass' => 15, 'initiative' => 3, 'speed' => '25 ft.', 'hitDice' => '2d10',
'armorClass' => 15, 'initiative' => 3, 'speed' => '25 ft.',
'hitPointMaximum' => 16, 'hitDice' => '2d10',
'proficiencyBonus' => 2,
'strength' => 12, 'dexterity' => 17, 'constitution' => 14,
'intelligence' => 12, 'wisdom' => 13, 'charisma' => 8,
'intelligence' => 8, 'wisdom' => 13, 'charisma' => 12,
'savingThrowProficiencies' => ['Strength', 'Dexterity'],
'skillProficiencies' => ['Sleight of Hand', 'Stealth'],
'skillProficiencies' => ['Athletics', 'Perception', 'Sleight of Hand', 'Stealth', 'Survival'],
'skillExpertise' => ['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)'],
['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",
'otherProficienciesLanguages' => "Languages: Halfling, Common, Axion, Doile\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",
'featuresTraits' => "Stout Resilience\nLucky\nBrave\nNimble\n\nCity Secrets\n\nFavored Foe\nDeft Explorer (expertise in Stealth)\nFighting Style: Throwing\nSpellcasting",
'spellcasting' => "Spellcasting ability: Wisdom (spell save DC 11, spell attack bonus +3)\n\nLevel 1 (2 slots): Hunter's Mark, Beast Bond",
'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",
'appearance' => "Age 18, 3'0\", 40 lbs. Brown eyes, brown hair.",
'treasure' => '10 gp',
],
[
'name' => 'Owayn Fermwyr', 'playerName' => 'BJ',
'race' => 'Halfling, stout', 'characterClass' => 'Fighter', 'level' => 1,
'background' => 'Folk Hero', 'alignment' => 'NG',
'armorClass' => 18, 'initiative' => 2, 'speed' => '25 ft.',
'hitPointMaximum' => 13, 'hitDice' => '1d10',
'proficiencyBonus' => 2,
'strength' => 15, 'dexterity' => 14, 'constitution' => 16,
'intelligence' => 8, 'wisdom' => 13, 'charisma' => 8,
'savingThrowProficiencies' => ['Strength', 'Constitution'],
'skillProficiencies' => ['Animal Handling', 'Athletics', 'Perception', 'Survival'],
'attacks' => [
['name' => 'Warhammer', 'bonus' => '+4', 'damage' => '1d8+4', 'range' => 'Melee, versatile (1d10)'],
['name' => 'Light crossbow', 'bonus' => '+4', 'damage' => '1d8+2', 'range' => '80/320 ft.'],
['name' => 'Handaxe', 'bonus' => '+4', 'damage' => '1d6+4', 'range' => '20/60 ft. (×2 carried)'],
],
'otherProficienciesLanguages' => "Languages: Common, Halfling, Axion\n\nTools: Tinker's tools, Land vehicles",
'equipment' => "Chain mail (disadvantage on Stealth)\nShield\nBackpack\nBedroll\nMess kit\nTinderbox\n10 torches\n10 rations\nWaterskin\n50 ft. hempen rope\nTinker's tools\nShovel\nIron pot\nCommon clothes",
'featuresTraits' => "Lucky - When you roll a 1, reroll and take the new result\nBrave - Advantage against being frightened\nNimbleness - You can move through the space of any creature larger than you\nStout Resilience - Advantage on saves against poison, resistance against poison damage\n\nRustic Hospitality\n\nFighting Style: Dueling\nSecond Wind - Once per short rest, heal 1d10 + level as a bonus action",
'personalityTraits' => 'Thinking is for other people, I prefer action.',
'ideals' => 'I have to protect what I am responsible for.',
'bonds' => 'I protect my family no matter what.',
'flaws' => 'I often act before I think.',
'appearance' => 'Age 24. A large scar runs over the middle of his forehead, the top of his nose and his right cheek.',
'backstory' => "Owayn was born 24 years ago as the oldest of a set of twins (together with Aoife). This was a joyous occasion as twins mean good luck among the Oakwanderers.\n\nIn his early youth was nothing special, although his development was a bit slower than that of Aoife. He startes walking later, started to talk later and with many other skills it took him longer to master is. This made him let his sisters (Aoife and Noirhin (Nora)) do the talking when needed. Not that he was shy shy he just knew his sisters where better in this.\n\nFrom an early early age like liked being outside and helping his father and uncles around the farm. Lifting, carrying and fixing things. He look a particular liking to herding the families sheep and goats.\n\nAs the family grew and the house got more crowded Owayn often set out for longer tracks and sometimes even slept outside. When not busy on the farm he often went to the training court of the village to train with weapons as Penthorfils is a dangerous area.\nAs soon as he was of the appropriate age he joined the city militia and on times when there is less work on the farm he will join patrols. The family provided him with the gear for joining, paid by giving up their spot in line.\n\nHe tried to teach his younger sibling how to use weapons, but the results were mixed.\n\nWhen he was sixteen he took the goats and sheep to a particular meadow in the mountain, where certain herbs frow that make the goats milk taste better. On the second day on of his shepherd dogs, named Greyfur, started to bark aggressively. Owayn hurried toward the dog and saw a pack of wolves stalking the herd.\nThe pack of wolves was not deterred by the usual shouting and barking and attacked. In a vicious fight Owayn managed to kill the leader of the pack and the other dogs managed to chased of the rest. Unfortunately Greyfur died, something that Owayn still regrets.\nWounded Owayn let the herd back to village, where he was heralded as a hero for a short while. A large scar running over the middle of his forehead and over the top of his nose and over his right cheek is a reminder of this deed.\n\nDuring take time he was flirting with Carysh Gwehyd and after the fight with the wolf this seems to turning to love. But this slowly fizzled out as time went on. Carysh was deterred by the scar and a more redrawn Owayn trying to cope with the loss of Greyfur. Since that Owayn hasn't been in a relationship.",
'treasure' => '10 gp',
],
[
'name' => 'Dunnchadh', 'playerName' => 'Rick',
'race' => 'Halfling, ghostwise', 'characterClass' => 'Druid (Circle of the Blighted)', 'level' => 2,
'background' => 'The misunderstood', 'experiencePoints' => 300,
'armorClass' => 15, 'initiative' => 2, 'speed' => '25 ft.',
'hitPointMaximum' => 17, 'hitDice' => '2d8',
'proficiencyBonus' => 2,
'strength' => 8, 'dexterity' => 14, 'constitution' => 14,
'intelligence' => 14, 'wisdom' => 16, 'charisma' => 8,
'savingThrowProficiencies' => ['Intelligence', 'Wisdom'],
'skillProficiencies' => ['Intimidation', 'Nature', 'Perception', 'Sleight of Hand', 'Stealth'],
'attacks' => [
['name' => 'Thorn Whip', 'bonus' => '+5', 'damage' => '1d6 piercing', 'range' => 'Melee, 30 ft. (pull up to 10 ft. closer)'],
['name' => 'Primal Savagery', 'bonus' => '+5', 'damage' => '1d10 acid', 'range' => 'Melee (5 ft.)'],
],
'otherProficienciesLanguages' => "Languages: Common, Druidic, Halfling\n\nTools: Herbalism kit, Thieves' tools, Disguise kit\n\nArmor: Light, medium, shields\n\nWeapons: Club, dagger, dart, javelin, mace, quarterstaff, scimitar, sickle, sling, spear",
'equipment' => "Leather armor\nWooden shield\nMess kit\nBackpack, with:\n- Bedroll\n- 2 costumes\n- Thieves' tools\n- 5 days of rations\n- Waterskin\n- Disguise kit\n- Tinderbox\n- 5 torches\n- 50 ft. hempen rope",
'featuresTraits' => "Ghostwise Halfling\nLucky - When I roll a 1 on an attack roll, ability check, or saving throw, I can reroll the die and must use the new roll\nBrave - Advantage on saves against being frightened\nHalfling Nimbleness - I can move through the space of any creature that is of a size larger than me\nSilent Speech - I can speak telepathically to any one creature within 30 feet of me. It only understands me if we share a language\n\nFalse Identity - I have created a second identity that includes documentation, established acquaintances, and disguises that allow me to assume that persona. I can forge documents, including official papers and personal letters, as long as I have seen an example of the kind of document or the handwriting I am trying to copy\n\nDruidic\nBlighted Shape - Proficiency in Intimidation; my Wild Shape forms gain +2 AC and 60 ft. darkvision (or +60 ft. if they already have it)\nDefile Ground (1/short rest) - Bonus action: blight a 10 ft. radius within 60 ft. for 1 minute; difficult terrain for enemies, and creatures on it take an extra 1d4 necrotic the first time they're hit by an attack each turn. Bonus action to move it up to 30 ft.\nWild Shape (2/short rest) - Max CR 1/4, no flying or swimming speed, up to 1 hour",
'spellcasting' => "Spellcasting ability: Wisdom (spell save DC 13, spell attack bonus +5)\n\nCantrips: Thorn Whip, Primal Savagery\n\nLevel 1 (3 slots): prepares 5 spells from the druid list",
'personalityTraits' => 'I like to watch things wither and decay. It makes me feel alive.',
'ideals' => 'Ashes to ashes, dust to dust. The cycle must always continue.',
'bonds' => 'Better to be misunderstood than to forfeit my beliefs.',
'flaws' => 'I will be passive aggressive.',
'appearance' => 'Also goes by Duncan.',
],
];
}
+30 -2
View File
@@ -137,6 +137,10 @@ class Character
#[ORM\Column(type: Types::JSON)]
private array $skillProficiencies = [];
/** @var list<string> Skills (keys of self::SKILLS) with expertise: proficiency bonus counts double. */
#[ORM\Column(type: Types::JSON)]
private array $skillExpertise = [];
// --- Attacks: one row per weapon/spell attack, table-shaped on the sheet ---
/** @var list<array{name: string, bonus: string, damage: string, range: string}> */
@@ -233,12 +237,22 @@ class Character
return \in_array($skill, $this->skillProficiencies, true);
}
/** Expertise implies proficiency, even if the skill wasn't also ticked as proficient. */
public function hasExpertiseInSkill(string $skill): bool
{
return \in_array($skill, $this->skillExpertise, true);
}
public function skillModifier(string $skill): int
{
$ability = self::SKILLS[$skill] ?? 'Strength';
$proficiency = match (true) {
$this->hasExpertiseInSkill($skill) => 2 * $this->proficiencyBonus,
$this->isProficientInSkill($skill) => $this->proficiencyBonus,
default => 0,
};
return $this->abilityModifier($this->abilityScore($ability))
+ ($this->isProficientInSkill($skill) ? $this->proficiencyBonus : 0);
return $this->abilityModifier($this->abilityScore($ability)) + $proficiency;
}
public function passivePerception(): int
@@ -572,6 +586,20 @@ class Character
return $this;
}
/** @return list<string> */
public function getSkillExpertise(): array
{
return $this->skillExpertise;
}
/** @param list<string> $skillExpertise */
public function setSkillExpertise(array $skillExpertise): static
{
$this->skillExpertise = array_values($skillExpertise);
return $this;
}
/** @return list<array{name: string, bonus: string, damage: string, range: string}> */
public function getAttacks(): array
{
+8
View File
@@ -68,6 +68,14 @@ class CharacterType extends AbstractType
'required' => false,
'label' => 'Skill proficiencies',
])
->add('skillExpertise', ChoiceType::class, [
'choices' => array_combine(array_keys(Character::SKILLS), array_keys(Character::SKILLS)),
'multiple' => true,
'expanded' => true,
'required' => false,
'label' => 'Skill expertise',
'help' => 'Double proficiency bonus (e.g. Deft Explorer, Rogue Expertise)',
])
// Text blocks
->add('otherProficienciesLanguages', TextareaType::class, $block + ['label' => 'Other proficiencies & languages'])
->add('equipment', TextareaType::class, $block)