diff --git a/Modules/Shadowrun5e/app/Http/Controllers/CharactersController.php b/Modules/Shadowrun5e/app/Http/Controllers/CharactersController.php index ec7edfe49..669ae4258 100644 --- a/Modules/Shadowrun5e/app/Http/Controllers/CharactersController.php +++ b/Modules/Shadowrun5e/app/Http/Controllers/CharactersController.php @@ -842,11 +842,13 @@ function (Rulebook $_value, string $key) use ($selectedBooks): bool { ] ); case 'review': + $character->validate(); return view( 'shadowrun5e::character', [ 'character' => $character, 'currentStep' => 'review', + // @phpstan-ignore argument.type 'errors' => new MessageBag($character->errors ?? []), 'nextStep' => $this->nextStep('review', $character), 'previousStep' => $this->previousStep('review', $character), diff --git a/Modules/Shadowrun5e/app/Models/AdeptPowerArray.php b/Modules/Shadowrun5e/app/Models/AdeptPowerArray.php index 860b1a711..69957df0b 100644 --- a/Modules/Shadowrun5e/app/Models/AdeptPowerArray.php +++ b/Modules/Shadowrun5e/app/Models/AdeptPowerArray.php @@ -16,14 +16,14 @@ final class AdeptPowerArray extends ArrayObject { /** * Add a power to the array. - * @param AdeptPower $power + * @param AdeptPower $value * @throws TypeError */ #[Override] - public function offsetSet(mixed $index = null, $power = null): void + public function offsetSet(mixed $key = null, $value = null): void { - if ($power instanceof AdeptPower) { - parent::offsetSet($index, $power); + if ($value instanceof AdeptPower) { + parent::offsetSet($key, $value); return; } throw new TypeError('AdeptPowerArray only accepts AdeptPower objects'); diff --git a/Modules/Shadowrun5e/app/Models/Armor.php b/Modules/Shadowrun5e/app/Models/Armor.php index 66772753e..36327b743 100644 --- a/Modules/Shadowrun5e/app/Models/Armor.php +++ b/Modules/Shadowrun5e/app/Models/Armor.php @@ -73,7 +73,23 @@ final class Armor implements Stringable /** * List of all armor. - * @var ?array + * @var ?array, + * cost: int, + * description: string, + * effects?: array, + * features: array, + * id: string, + * name: string, + * page: int, + * rating?: int, + * ruleset: string, + * stack-rating: int, + * wireless-effects?: array + * }> */ public static ?array $armor; diff --git a/Modules/Shadowrun5e/app/Models/Character.php b/Modules/Shadowrun5e/app/Models/Character.php index 01eafbada..d01537142 100644 --- a/Modules/Shadowrun5e/app/Models/Character.php +++ b/Modules/Shadowrun5e/app/Models/Character.php @@ -188,7 +188,7 @@ protected static function booted(): void { static::addGlobalScope( 'shadowrun5e', - function (Builder $builder): void { + static function (Builder $builder): void { $builder->where('system', 'shadowrun5e'); } ); @@ -550,7 +550,7 @@ public function getMartialArtsStyles(): MartialArtsStyleArray public function getMartialArtsTechniques(): MartialArtsTechniqueArray { $techniques = new MartialArtsTechniqueArray(); - if (!isset($this->martialArts, $this->martialArts['techniques'])) { + if (!isset($this->martialArts['techniques'])) { return $techniques; } foreach ($this->martialArts['techniques'] as $technique) { @@ -837,7 +837,7 @@ public function socialLimit(): Attribute public function getSpells(): SpellArray { $spells = new SpellArray(); - if (!isset($this->magics, $this->magics['spells'])) { + if (!isset($this->magics['spells'])) { return $spells; } foreach ($this->magics['spells'] as $spell) { @@ -860,7 +860,7 @@ public function getSpells(): SpellArray public function getSpirits(): SpiritArray { $spirits = new SpiritArray(); - if (!isset($this->magics, $this->magics['spirits'])) { + if (!isset($this->magics['spirits'])) { return $spirits; } foreach ($this->magics['spirits'] as $spirit) { @@ -886,7 +886,7 @@ public function getSpirits(): SpiritArray public function getSprites(): SpriteArray { $sprites = new SpriteArray(); - if (!isset($this->technomancer, $this->technomancer['sprites'])) { + if (!isset($this->technomancer['sprites'])) { return $sprites; } foreach ($this->technomancer['sprites'] as $sprite) { @@ -911,7 +911,7 @@ public function getSprites(): SpriteArray */ public function getTradition(): ?Tradition { - if (!isset($this->magics, $this->magics['tradition'])) { + if (!isset($this->magics['tradition'])) { return null; } try { diff --git a/Modules/Shadowrun5e/app/Models/PartialCharacter.php b/Modules/Shadowrun5e/app/Models/PartialCharacter.php index 0e4e3dd38..4bfde1d2f 100644 --- a/Modules/Shadowrun5e/app/Models/PartialCharacter.php +++ b/Modules/Shadowrun5e/app/Models/PartialCharacter.php @@ -5,26 +5,34 @@ namespace Modules\Shadowrun5e\Models; use Illuminate\Database\Eloquent\Factories\Factory; +use Illuminate\Database\Eloquent\Model; use Modules\Shadowrun5e\Database\Factories\PartialCharacterFactory; use Override; use Stringable; +use function sprintf; + /** * Representation of a character currently being built. * @method static self create(array $attributes) + * @mixin Model */ class PartialCharacter extends Character implements Stringable { - protected const int DEFAULT_MAX_ATTRIBUTE = 6; + protected const string PRIORITY_STANDARD = 'standard'; + protected const string PRIORITY_SUM_TO_TEN = 'sum-to-ten'; + protected const string PRIORITY_KARMA = 'karma'; - /** @var string */ - protected $connection = 'mongodb'; + protected const int DEFAULT_MAX_ATTRIBUTE = 6; /** @var string */ protected $table = 'characters-partial'; - /** @var array> */ + + /** @var array|string> */ public array $errors = []; + protected string $priority_method; + /** * Return the starting maximum for a character based on their metatype and * qualities. @@ -75,7 +83,7 @@ public function getStartingMaximumAttribute(string $attribute): int */ public function isMagicallyActive(): bool { - return isset($this->priorities, $this->priorities['magic']) + return isset($this->priorities['magic']) && 'technomancer' !== $this->priorities['magic']; } @@ -84,7 +92,7 @@ public function isMagicallyActive(): bool */ public function isTechnomancer(): bool { - return isset($this->priorities, $this->priorities['magic']) + return isset($this->priorities['magic']) && 'technomancer' === $this->priorities['magic']; } @@ -109,4 +117,187 @@ public function newFromBuilder( // @phpstan-ignore return.type return $character; } + + /** + * Validate the character against Shadowrun 5E's rules. + * + * Stores any errors or warnings in the errors property, similar to how + * HeroLab or Chummer import does. + */ + public function validate(): void + { + $this->errors = array_merge( + $this->errors ?? [], + $this->validatePriorities(), + $this->validateNativeLanguage(), + $this->validateAttributes(), + ); + } + + /** + * @return array + */ + protected function validatePriorities(): array + { + $errors = []; + if (!isset($this->priorities['a']) && !isset($this->priorities['metatypePriority'])) { + $errors[] = 'You must choose priorities.'; + return $errors; + } + if (!isset($this->priorities['metatype'])) { + $errors[] = 'You must choose a metatype.'; + } + + if (isset($this->priorities['a'])) { + $this->priority_method = self::PRIORITY_STANDARD; + if ( + !isset($this->priorities['b']) + || !isset($this->priorities['c']) + || !isset($this->priorities['d']) + || !isset($this->priorities['e']) + ) { + $errors[] = 'You must allocate all priorities.'; + } + } else { + $this->priority_method = self::PRIORITY_SUM_TO_TEN; + $sumToTen = 10; + $priorities = [ + 'metatypePriority', + 'magicPriority', + 'attributePriority', + 'skillPriority', + 'resourcePriority', + ]; + foreach ($priorities as $priority) { + if (!isset($this->priorities[$priority])) { + $errors[] = 'You must allocate the ' + . str_replace('P', ' p', $priority) + . ' on the ' + . 'priorities page.'; + continue; + } + switch ($this->priorities[$priority]) { + case 'E': + // E priority is worth zero. + break; + case 'D': + --$sumToTen; + break; + case 'C': + $sumToTen -= 2; + break; + case 'B': + $sumToTen -= 3; + break; + case 'A': + $sumToTen -= 4; + break; + } + } + if ($sumToTen > 0) { + $errors[] = 'You haven\'t allocated all sum-to-ten priority points.'; + } elseif ($sumToTen < 0) { + $errors[] = 'You have allocated too many sum-to-ten priority points.'; + } + } + return $errors; + } + + /** + * @return array + */ + protected function validateNativeLanguage(): array + { + $nativeLanguages = 0; + /** @var KnowledgeSkill $knowledge */ + foreach ($this->getKnowledgeSkills() as $knowledge) { + if ('language' !== $knowledge->category) { + continue; + } + if ('N' !== $knowledge->level) { + continue; + } + ++$nativeLanguages; + } + $bilingual = false; + foreach ($this->getQualities() as $quality) { + if ('bilingual' === $quality->id) { + $bilingual = true; + break; + } + } + + if ($bilingual && 2 !== $nativeLanguages) { + return ['You haven\'t chosen two native languages for your bilingual quality']; + } + + if (0 === $nativeLanguages) { + return ['You must choose a native language']; + } + + if (!$bilingual && 1 !== $nativeLanguages) { + return ['You can only have one native language']; + } + return []; + } + + /** + * @return array + */ + protected function validateAttributes(): array + { + $errors = []; + $attributePoints = 0; + if (self::PRIORITY_STANDARD === ($this->priority_method ?? 'unknown')) { + switch (array_search('attributes', $this->priorities ?? [], true)) { + case 'a': + $attributePoints = 24; + break; + case 'b': + $attributePoints = 20; + break; + case 'c': + $attributePoints = 16; + break; + case 'd': + $attributePoints = 14; + break; + case 'e': + $attributePoints = 12; + break; + } + } else { + if (!isset($this->priorities['attributePriority'])) { + return []; + } + switch ($this->priorities['attributePriority']) { + case 'A': + $attributePoints = 24; + break; + case 'B': + $attributePoints = 20; + break; + case 'C': + $attributePoints = 16; + break; + case 'D': + $attributePoints = 14; + break; + case 'E': + $attributePoints = 12; + break; + } + } + + $attributePoints = $attributePoints - $this->body - $this->agility + - $this->reaction - $this->strength - $this->willpower + - $this->logic - $this->intuition - $this->charisma; + if (0 < $attributePoints) { + $errors[] = sprintf( + 'You have %d unspent attribute points', + $attributePoints, + ); + } + return $errors; + } } diff --git a/Modules/Shadowrun5e/data/armor.php b/Modules/Shadowrun5e/data/armor.php index d5d28c734..4912df98e 100644 --- a/Modules/Shadowrun5e/data/armor.php +++ b/Modules/Shadowrun5e/data/armor.php @@ -11,6 +11,7 @@ 'availability' => '', 'capacity' => , 'chummer-id' => '', + 'container-type' => ['armor', 'audio', 'vision'], 'cost' => , 'description' => '', 'effects' => [], diff --git a/Modules/Shadowrun5e/data/qualities.php b/Modules/Shadowrun5e/data/qualities.php index 1d8498a75..cd26ebf77 100644 --- a/Modules/Shadowrun5e/data/qualities.php +++ b/Modules/Shadowrun5e/data/qualities.php @@ -129,6 +129,13 @@ 'ruleset' => 'chrome-flesh', 'severity' => 'Cyberware', ], + 'bilingual' => [ + 'id' => 'bilingual', + 'description' => 'Bilingual description.', + 'incompatible-with' => ['bilingual'], + 'karma' => -5, + 'name' => 'Bilingual', + ], 'exceptional-attribute-body' => [ 'id' => 'exceptional-attribute-body', 'attribute' => 'Body', diff --git a/Modules/Shadowrun5e/tests/Feature/Http/Controllers/CharactersControllerTest.php b/Modules/Shadowrun5e/tests/Feature/Http/Controllers/CharactersControllerTest.php index 2ca860592..9e32018a6 100644 --- a/Modules/Shadowrun5e/tests/Feature/Http/Controllers/CharactersControllerTest.php +++ b/Modules/Shadowrun5e/tests/Feature/Http/Controllers/CharactersControllerTest.php @@ -1484,7 +1484,6 @@ public function testLoadKnowledgeSkillsMagical(): void public function testLoadKnowledgeSkillsResonance(): void { $user = User::factory()->create(); - $character = PartialCharacter::factory()->create([ 'priorities' => [ 'magic' => 'technomancer', diff --git a/Modules/Shadowrun5e/tests/Feature/Models/AdeptPowerArrayTest.php b/Modules/Shadowrun5e/tests/Feature/Models/AdeptPowerArrayTest.php index b2130a573..f8a92bae5 100644 --- a/Modules/Shadowrun5e/tests/Feature/Models/AdeptPowerArrayTest.php +++ b/Modules/Shadowrun5e/tests/Feature/Models/AdeptPowerArrayTest.php @@ -68,7 +68,7 @@ public function testAddWrongTypeDoesntAdd(): void { try { // @phpstan-ignore argument.type - $this->powers->offsetSet(power: new stdClass()); + $this->powers->offsetSet(value: new stdClass()); } catch (TypeError) { // Ignored } diff --git a/Modules/Shadowrun5e/tests/Feature/Models/PartialCharacterTest.php b/Modules/Shadowrun5e/tests/Feature/Models/PartialCharacterTest.php index 7500dc2b2..e8366d003 100644 --- a/Modules/Shadowrun5e/tests/Feature/Models/PartialCharacterTest.php +++ b/Modules/Shadowrun5e/tests/Feature/Models/PartialCharacterTest.php @@ -11,6 +11,8 @@ use PHPUnit\Framework\Attributes\Small; use Tests\TestCase; +use function sprintf; + #[Group('shadowrun')] #[Group('shadowrun5e')] #[Small] @@ -156,4 +158,332 @@ public function testGetMaximumAttributes( $character->getStartingMaximumAttribute($attribute) ); } + + /** + * Test validate on an empty partial character. + */ + public function testValidateEmpty(): void + { + $character = new PartialCharacter(); + $character->validate(); + self::assertSame( + [ + 'You must choose priorities.', + 'You must choose a native language', + ], + $character->errors + ); + } + + /** + * Test validate on a partial character with no metatype. + */ + public function testValidateNoMetatype(): void + { + $character = new PartialCharacter([ + 'priorities' => [ + 'a' => 'metatype', + ], + 'knowledgeSkills' => [ + ['name' => 'English', 'category' => 'language', 'level' => 'N'], + ], + ]); + $character->validate(); + self::assertSame( + [ + 'You must choose a metatype.', + 'You must allocate all priorities.', + ], + $character->errors + ); + } + + /** + * Test a normal priority character with too many native languages. + */ + public function testValidateTooManyNativeLanguages(): void + { + $character = new PartialCharacter([ + 'priorities' => [ + 'a' => 'metatype', + 'b' => 'resources', + 'c' => 'magic', + 'd' => 'attributes', + 'e' => 'skills', + 'metatype' => 'human', + ], + 'agility' => 6, + 'body' => 6, + 'charisma' => 6, + 'reaction' => 1, + 'strength' => 1, + 'willpower' => 1, + 'logic' => 1, + 'intuition' => 2, + 'knowledgeSkills' => [ + ['name' => 'English', 'category' => 'language', 'level' => 'N'], + ['name' => 'Spanish', 'category' => 'language', 'level' => 'N'], + ['name' => 'Orkish', 'category' => 'language', 'level' => 2], + ], + ]); + $character->validate(); + self::assertSame( + [ + 'You can only have one native language', + ], + $character->errors + ); + } + + /** + * Test validating native languages with the bilingual quality. + */ + public function testValidateBilingual(): void + { + $character = new PartialCharacter([ + 'knowledgeSkills' => [ + ['name' => 'English', 'category' => 'language', 'level' => 'N'], + ['name' => 'Spanish', 'category' => 'language', 'level' => 'N'], + ['name' => 'Orkish', 'category' => 'language', 'level' => 2], + ], + 'priorities' => [ + 'a' => 'metatype', + 'b' => 'resources', + 'c' => 'magic', + 'd' => 'attributes', + 'e' => 'skills', + 'metatype' => 'human', + ], + 'agility' => 6, + 'body' => 6, + 'charisma' => 6, + 'reaction' => 1, + 'strength' => 1, + 'willpower' => 1, + 'logic' => 1, + 'intuition' => 2, + 'qualities' => [ + ['id' => 'bilingual'], + ], + ]); + $character->validate(); + self::assertEmpty($character->errors); + } + + /** + * Test validating the bilingual quality without enough native languages. + */ + public function testValidateBilingualNotEnoughLanguages(): void + { + $character = new PartialCharacter([ + 'knowledgeSkills' => [ + ['name' => 'English', 'category' => 'language', 'level' => 'N'], + ['name' => 'Bars', 'category' => 'street', 'level' => 2], + ], + 'priorities' => [ + 'a' => 'metatype', + 'b' => 'resources', + 'c' => 'magic', + 'd' => 'attributes', + 'e' => 'skills', + 'metatype' => 'human', + ], + 'agility' => 6, + 'body' => 6, + 'charisma' => 6, + 'reaction' => 1, + 'strength' => 1, + 'willpower' => 1, + 'logic' => 1, + 'intuition' => 2, + 'qualities' => [ + ['id' => 'bilingual'], + ], + ]); + $character->validate(); + self::assertSame( + ['You haven\'t chosen two native languages for your bilingual quality'], + $character->errors + ); + } + + /** + * Test validating a sum-to-ten character that hasn't assigned all + * priorities. + */ + public function testValidateSumToTenMissing(): void + { + $character = new PartialCharacter([ + 'priorities' => [ + 'metatypePriority' => 'frank', + ], + 'knowledgeSkills' => [ + ['name' => 'English', 'category' => 'language', 'level' => 'N'], + ], + ]); + $character->validate(); + self::assertSame( + [ + 'You must choose a metatype.', + 'You must allocate the magic priority on the priorities page.', + 'You must allocate the attribute priority on the priorities page.', + 'You must allocate the skill priority on the priorities page.', + 'You must allocate the resource priority on the priorities page.', + 'You haven\'t allocated all sum-to-ten priority points.', + ], + $character->errors + ); + } + + /** + * Test validating a sum-to-ten character that has overspent. + */ + public function testValidateSumToTenOverspent(): void + { + $character = new PartialCharacter([ + 'priorities' => [ + 'metatypePriority' => 'A', + 'magicPriority' => 'A', + 'attributePriority' => 'A', + 'skillPriority' => 'A', + 'resourcePriority' => 'A', + 'metatype' => 'elf', + ], + 'agility' => 6, + 'body' => 6, + 'charisma' => 6, + 'reaction' => 1, + 'strength' => 1, + 'willpower' => 1, + 'logic' => 1, + 'intuition' => 2, + 'knowledgeSkills' => [ + ['name' => 'English', 'category' => 'language', 'level' => 'N'], + ], + ]); + $character->validate(); + self::assertSame( + [ + 'You have allocated too many sum-to-ten priority points.', + ], + $character->errors + ); + } + + /** + * Test validating a sum-to-ten character that chose attributes correctly. + */ + public function testValidateSumToTen(): void + { + $character = new PartialCharacter([ + 'priorities' => [ + 'metatypePriority' => 'A', + 'magicPriority' => 'B', + 'attributePriority' => 'C', + 'skillPriority' => 'D', + 'resourcePriority' => 'E', + 'metatype' => 'elf', + ], + 'agility' => 6, + 'body' => 6, + 'charisma' => 6, + 'reaction' => 1, + 'strength' => 1, + 'willpower' => 1, + 'logic' => 1, + 'intuition' => 2, + 'knowledgeSkills' => [ + ['name' => 'English', 'category' => 'language', 'level' => 'N'], + ], + ]); + $character->validate(); + self::assertEmpty($character->errors); + } + + /** + * @return Iterator> + */ + public static function unspentSumToTenAttributesProvider(): Iterator + { + yield ['A', 'B', 'C', 'D', 'E', 16]; + yield ['B', 'C', 'D', 'E', 'A', 14]; + yield ['C', 'D', 'E', 'A', 'B', 12]; + yield ['D', 'E', 'A', 'B', 'C', 24]; + yield ['E', 'A', 'B', 'C', 'D', 20]; + } + + #[DataProvider('unspentSumToTenAttributesProvider')] + public function testValidateAttributesUnspentSumToTen( + string $metatype, + string $magic, + string $attribute, + string $skill, + string $resource, + int $remaining, + ): void { + $character = new PartialCharacter([ + 'priorities' => [ + 'metatypePriority' => $metatype, + 'magicPriority' => $magic, + 'attributePriority' => $attribute, + 'skillPriority' => $skill, + 'resourcePriority' => $resource, + 'metatype' => 'elf', + ], + 'knowledgeSkills' => [ + ['name' => 'English', 'category' => 'language', 'level' => 'N'], + ], + ]); + $character->validate(); + self::assertSame( + [sprintf('You have %d unspent attribute points', $remaining)], + $character->errors, + ); + } + + /** + * @return Iterator> + */ + public static function unspentStandardAttributesProvider(): Iterator + { + yield ['attributes', 'skills', 'magic', 'metatype', 'resources', 24]; + yield ['skills', 'magic', 'metatype', 'resources', 'attributes', 12]; + yield ['magic', 'metatype', 'resources', 'attributes', 'skills', 14]; + yield ['metatype', 'resources', 'attributes', 'skills', 'magic', 16]; + yield ['resources', 'attributes', 'skills', 'magic', 'metatype', 20]; + } + + #[DataProvider('unspentStandardAttributesProvider')] + public function testValidateAttributesUnspentStandard( + string $a, + string $b, + string $c, + string $d, + string $e, + int $remaining, + ): void { + $character = new PartialCharacter([ + 'priorities' => [ + 'a' => $a, + 'b' => $b, + 'c' => $c, + 'd' => $d, + 'e' => $e, + 'metatype' => 'elf', + ], + 'knowledgeSkills' => [ + ['name' => 'English', 'category' => 'language', 'level' => 'N'], + ], + ]); + $character->validate(); + self::assertSame( + [sprintf('You have %d unspent attribute points', $remaining)], + $character->errors, + ); + } }