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:
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
Before Width: | Height: | Size: 513 KiB After Width: | Height: | Size: 513 KiB |
@@ -0,0 +1,32 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace DoctrineMigrations;
|
||||||
|
|
||||||
|
use Doctrine\DBAL\Schema\Schema;
|
||||||
|
use Doctrine\Migrations\AbstractMigration;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Characters: skill expertise (double proficiency bonus).
|
||||||
|
*/
|
||||||
|
final class Version20261004104738 extends AbstractMigration
|
||||||
|
{
|
||||||
|
public function getDescription(): string
|
||||||
|
{
|
||||||
|
return 'Add skill_expertise to character_sheet';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function up(Schema $schema): void
|
||||||
|
{
|
||||||
|
// Existing rows need a valid JSON value before the column can be NOT NULL.
|
||||||
|
$this->addSql('ALTER TABLE character_sheet ADD skill_expertise JSON DEFAULT NULL');
|
||||||
|
$this->addSql("UPDATE character_sheet SET skill_expertise = '[]'");
|
||||||
|
$this->addSql('ALTER TABLE character_sheet MODIFY skill_expertise JSON NOT NULL');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(Schema $schema): void
|
||||||
|
{
|
||||||
|
$this->addSql('ALTER TABLE character_sheet DROP skill_expertise');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -149,28 +149,84 @@ class SeedCharactersCommand extends Command
|
|||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
[
|
[
|
||||||
'name' => 'Darragh Oakwanderer', 'playerName' => 'PH',
|
'name' => 'Darragh Fermwyr', 'playerName' => 'PH',
|
||||||
'race' => 'Halfling, stout', 'characterClass' => 'Ranger', 'level' => 2,
|
'race' => 'Halfling, stout', 'characterClass' => 'Ranger', 'level' => 2,
|
||||||
'background' => 'Urchin', 'alignment' => 'CN',
|
'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,
|
'proficiencyBonus' => 2,
|
||||||
'strength' => 12, 'dexterity' => 17, 'constitution' => 14,
|
'strength' => 12, 'dexterity' => 17, 'constitution' => 14,
|
||||||
'intelligence' => 12, 'wisdom' => 13, 'charisma' => 8,
|
'intelligence' => 8, 'wisdom' => 13, 'charisma' => 12,
|
||||||
'savingThrowProficiencies' => ['Strength', 'Dexterity'],
|
'savingThrowProficiencies' => ['Strength', 'Dexterity'],
|
||||||
'skillProficiencies' => ['Sleight of Hand', 'Stealth'],
|
'skillProficiencies' => ['Athletics', 'Perception', 'Sleight of Hand', 'Stealth', 'Survival'],
|
||||||
|
'skillExpertise' => ['Stealth'],
|
||||||
'attacks' => [
|
'attacks' => [
|
||||||
['name' => 'Dart', 'bonus' => '+5', 'damage' => '1d4+5', 'range' => '20/60 ft. (×10 carried)'],
|
['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' => '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",
|
'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",
|
'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, Cure Wounds",
|
'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',
|
'personalityTraits' => 'I eat like a pig and have bad manners',
|
||||||
'ideals' => "I help the people who help me, that's what keeps us alive",
|
'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",
|
'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",
|
'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.',
|
||||||
],
|
],
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -137,6 +137,10 @@ class Character
|
|||||||
#[ORM\Column(type: Types::JSON)]
|
#[ORM\Column(type: Types::JSON)]
|
||||||
private array $skillProficiencies = [];
|
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 ---
|
// --- Attacks: one row per weapon/spell attack, table-shaped on the sheet ---
|
||||||
|
|
||||||
/** @var list<array{name: string, bonus: string, damage: string, range: string}> */
|
/** @var list<array{name: string, bonus: string, damage: string, range: string}> */
|
||||||
@@ -233,12 +237,22 @@ class Character
|
|||||||
return \in_array($skill, $this->skillProficiencies, true);
|
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
|
public function skillModifier(string $skill): int
|
||||||
{
|
{
|
||||||
$ability = self::SKILLS[$skill] ?? 'Strength';
|
$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))
|
return $this->abilityModifier($this->abilityScore($ability)) + $proficiency;
|
||||||
+ ($this->isProficientInSkill($skill) ? $this->proficiencyBonus : 0);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public function passivePerception(): int
|
public function passivePerception(): int
|
||||||
@@ -572,6 +586,20 @@ class Character
|
|||||||
return $this;
|
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}> */
|
/** @return list<array{name: string, bonus: string, damage: string, range: string}> */
|
||||||
public function getAttacks(): array
|
public function getAttacks(): array
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -68,6 +68,14 @@ class CharacterType extends AbstractType
|
|||||||
'required' => false,
|
'required' => false,
|
||||||
'label' => 'Skill proficiencies',
|
'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
|
// Text blocks
|
||||||
->add('otherProficienciesLanguages', TextareaType::class, $block + ['label' => 'Other proficiencies & languages'])
|
->add('otherProficienciesLanguages', TextareaType::class, $block + ['label' => 'Other proficiencies & languages'])
|
||||||
->add('equipment', TextareaType::class, $block)
|
->add('equipment', TextareaType::class, $block)
|
||||||
|
|||||||
@@ -60,8 +60,9 @@
|
|||||||
<thead><tr><th>Skill</th><th>Abl.</th><th>Mod</th></tr></thead>
|
<thead><tr><th>Skill</th><th>Abl.</th><th>Mod</th></tr></thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{% for skill, ability in constant('App\\Entity\\Character::SKILLS') %}
|
{% for skill, ability in constant('App\\Entity\\Character::SKILLS') %}
|
||||||
<tr class="{{ c.isProficientInSkill(skill) ? 'is-proficient' }}">
|
{% set proficient = c.isProficientInSkill(skill) or c.hasExpertiseInSkill(skill) %}
|
||||||
<td>{{ skill }}{{ c.isProficientInSkill(skill) ? ' ●' }}</td>
|
<tr class="{{ proficient ? 'is-proficient' }}">
|
||||||
|
<td>{{ skill }}{{ c.hasExpertiseInSkill(skill) ? ' ●●' : (proficient ? ' ●') }}</td>
|
||||||
<td>{{ ability|slice(0, 3) }}</td>
|
<td>{{ ability|slice(0, 3) }}</td>
|
||||||
<td>{{ sb.mod(c.skillModifier(skill)) }}</td>
|
<td>{{ sb.mod(c.skillModifier(skill)) }}</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|||||||
@@ -71,7 +71,8 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="col-md-4">{{ form_row(form.savingThrowProficiencies) }}</div>
|
<div class="col-md-4">{{ form_row(form.savingThrowProficiencies) }}</div>
|
||||||
<div class="col-md-8">{{ form_row(form.skillProficiencies) }}</div>
|
<div class="col-md-4">{{ form_row(form.skillProficiencies) }}</div>
|
||||||
|
<div class="col-md-4">{{ form_row(form.skillExpertise) }}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,43 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Tests\Character;
|
||||||
|
|
||||||
|
use App\Command\SeedCharactersCommand;
|
||||||
|
use App\Entity\Character;
|
||||||
|
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
|
||||||
|
use Symfony\Component\Validator\Validator\ValidatorInterface;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Every ready-made sheet in app:character:seed must hydrate into a Character
|
||||||
|
* that passes validation - a typo'd field name or an over-long value would
|
||||||
|
* otherwise only surface when someone runs the command on testing/live.
|
||||||
|
*/
|
||||||
|
class SeedCharactersCatalogTest extends KernelTestCase
|
||||||
|
{
|
||||||
|
public function testEveryCatalogSheetHydratesIntoAValidCharacter(): void
|
||||||
|
{
|
||||||
|
self::bootKernel();
|
||||||
|
$command = self::getContainer()->get(SeedCharactersCommand::class);
|
||||||
|
$validator = self::getContainer()->get(ValidatorInterface::class);
|
||||||
|
|
||||||
|
$sheets = (new \ReflectionMethod($command, 'sheets'))->invoke($command);
|
||||||
|
$hydrate = new \ReflectionMethod($command, 'hydrate');
|
||||||
|
|
||||||
|
$names = array_column($sheets, 'name');
|
||||||
|
self::assertSame(['Darragh Fermwyr', 'Owayn Fermwyr', 'Dunnchadh'], $names);
|
||||||
|
|
||||||
|
foreach ($sheets as $row) {
|
||||||
|
/** @var Character $c */
|
||||||
|
$c = $hydrate->invoke($command, $row);
|
||||||
|
$violations = $validator->validate($c);
|
||||||
|
self::assertCount(0, $violations, $row['name'].': '.$violations);
|
||||||
|
|
||||||
|
foreach ($c->getSkillProficiencies() as $skill) {
|
||||||
|
self::assertArrayHasKey($skill, Character::SKILLS, $row['name']);
|
||||||
|
}
|
||||||
|
foreach ($c->getSavingThrowProficiencies() as $ability) {
|
||||||
|
self::assertContains($ability, Character::ABILITIES, $row['name']);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -42,6 +42,20 @@ class CharacterTest extends TestCase
|
|||||||
self::assertSame(1, $c->skillModifier('Perception'));
|
self::assertSame(1, $c->skillModifier('Perception'));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function testExpertiseDoublesProficiencyBonus(): void
|
||||||
|
{
|
||||||
|
$c = $this->darragh()->setSkillExpertise(['Stealth']);
|
||||||
|
|
||||||
|
// Dex 17 -> +3, expertise (2 x +2 prof) -> +7
|
||||||
|
self::assertSame(7, $c->skillModifier('Stealth'));
|
||||||
|
// Sleight of Hand stays plain proficient -> +5
|
||||||
|
self::assertSame(5, $c->skillModifier('Sleight of Hand'));
|
||||||
|
|
||||||
|
// Expertise alone implies proficiency
|
||||||
|
$c->setSkillProficiencies([])->setSkillExpertise(['Perception']);
|
||||||
|
self::assertSame(5, $c->skillModifier('Perception')); // Wis +1, 2 x +2
|
||||||
|
}
|
||||||
|
|
||||||
public function testSaveModifierAddsProficiencyOnlyWhenProficient(): void
|
public function testSaveModifierAddsProficiencyOnlyWhenProficient(): void
|
||||||
{
|
{
|
||||||
$c = $this->darragh();
|
$c = $this->darragh();
|
||||||
|
|||||||
Reference in New Issue
Block a user