diff --git a/.github/issues.json b/.github/issues.json index 4fa1a25..32960f8 100644 --- a/.github/issues.json +++ b/.github/issues.json @@ -1,7 +1,2 @@ [ - { - "title": "(feat): Advantage dices", - "body": "Implement advantage and disadvantage mechanics for dice rolls.\n\nAdvantage: roll two dice of the same type and keep the highest result.\nDisadvantage: roll two dice of the same type and keep the lowest result.\n\nThis is a common mechanic in tabletop RPG systems such as D&D 5e and should integrate naturally with the existing `RollDiceInput` and `RollDiceUseCase` pipeline.", - "labels": ["enhancement"] - } ] \ No newline at end of file diff --git a/.gitignore b/.gitignore index a2f2663..4e64840 100644 --- a/.gitignore +++ b/.gitignore @@ -2,4 +2,5 @@ coverage .phpunit.result.cache obsidian -.obsidian \ No newline at end of file +.obsidian +index.php \ No newline at end of file diff --git a/.vscode/tasks.json b/.vscode/tasks.json index 73de7f0..250ade3 100644 --- a/.vscode/tasks.json +++ b/.vscode/tasks.json @@ -11,7 +11,7 @@ "group": "tests" }, "runOptions": { - "runOn": "folderOpen" + "runOn": "default" } }, { @@ -24,7 +24,7 @@ "group": "analysis" }, "runOptions": { - "runOn": "folderOpen" + "runOn": "default" } } ] diff --git a/src/Application/UseCase/Dice/RollDice/RollDiceInput.php b/src/Application/UseCase/Dice/RollDice/RollDiceInput.php index 3b97804..134c59b 100644 --- a/src/Application/UseCase/Dice/RollDice/RollDiceInput.php +++ b/src/Application/UseCase/Dice/RollDice/RollDiceInput.php @@ -6,37 +6,44 @@ use Eco\Error; use Eco\Result; +use RPGPlayground\Domain\Enums\Roll\RollAttribute; use RPGPlayground\Domain\ValueObjects\Dice; -use RPGPlayground\Domain\ValueObjects\DiceModifier; +use RPGPlayground\Domain\ValueObjects\Roll\RollModifier; final class RollDiceInput { /** * @param Dice $dice The dice to roll - * @param array $modifiers The modifiers to apply to the roll + * @param array $modifiers The modifiers to apply to the roll * @param int $multiplier The number of times to roll the dice + * @param RollAttribute $attribute The attributes to apply to the roll * @throws \InvalidArgumentException */ private function __construct( public readonly Dice $dice, public readonly array $modifiers, public readonly int $multiplier = 1, + public readonly ?RollAttribute $attribute = null, ) {} /** * @param Dice $dice The dice to roll - * @param array $modifiers The modifiers to apply to the roll + * @param array $modifiers The modifiers to apply to the roll * @param int $multiplier The number of times to roll the dice * @return Result * */ - public static function create(Dice $dice, array $modifiers = [], int $multiplier = 1): Result - { + public static function create( + Dice $dice, + array $modifiers = [], + int $multiplier = 1, + ?RollAttribute $attribute = null, + ): Result { try { if ($multiplier < 1) { throw new \InvalidArgumentException('Multiplier must be greater than or equal to 1.'); } - return Result::ok(new self($dice, $modifiers, $multiplier)); + return Result::ok(new self($dice, $modifiers, $multiplier, $attribute)); } catch (\InvalidArgumentException $e) { return Result::fail(Error::generic($e->getMessage())); } diff --git a/src/Application/UseCase/Dice/RollDice/RollDiceUseCase.php b/src/Application/UseCase/Dice/RollDice/RollDiceUseCase.php index 057eb10..a38c5fe 100644 --- a/src/Application/UseCase/Dice/RollDice/RollDiceUseCase.php +++ b/src/Application/UseCase/Dice/RollDice/RollDiceUseCase.php @@ -4,11 +4,11 @@ namespace RPGPlayground\Application\UseCase\Dice\RollDice; -use Eco\Error; use Eco\Result; use RPGPlayground\Application\UseCase\Dice\RollDice\RollDiceInput; use RPGPlayground\Application\UseCase\Dice\RollDice\RollDiceOutput; use RPGPlayground\Domain\Actions\Dice\RollDiceAction; +use RPGPlayground\Domain\Enums\Roll\RollAttribute; final class RollDiceUseCase { @@ -16,15 +16,18 @@ final class RollDiceUseCase * @param RollDiceInput $input * @return Result * @throws \Random\RandomException if the system entropy source fails. - * @throws \LogicException if an invalid symbol somehow bypasses DiceModifier::fromString. + * @throws \LogicException if an invalid symbol somehow bypasses RollModifier::fromString. */ public static function handle(RollDiceInput $input): Result { - $total = 0; + $rolls = self::roll($input); - for ($i = 0; $i < $input->multiplier; $i++) { - $total += RollDiceAction::roll($input->dice); - } + $total = $input->attribute !== null + ? match ($input->attribute) { + RollAttribute::Advantage => max($rolls), + RollAttribute::Disadvantage => min($rolls), + } + : array_sum($rolls); foreach ($input->modifiers as $modifier) { $total = $modifier->apply($total); @@ -32,4 +35,24 @@ public static function handle(RollDiceInput $input): Result return Result::ok(new RollDiceOutput($total)); } + + /** + * @param RollDiceInput $input + * @return array + * @throws \Random\RandomException if the system entropy source fails. + */ + private static function roll(RollDiceInput $input): array + { + $rolls = []; + + for ($i = 0; $i < $input->multiplier; $i++) { + $rolls[] = RollDiceAction::roll($input->dice); + } + + if ($input->attribute !== null) { + $rolls[] = RollDiceAction::roll($input->dice); + } + + return $rolls; + } } diff --git a/src/Core/Exceptions/DomainException.php b/src/Core/Exceptions/DomainException.php deleted file mode 100644 index d96c205..0000000 --- a/src/Core/Exceptions/DomainException.php +++ /dev/null @@ -1,9 +0,0 @@ -unwrap(); diff --git a/tests/Application/UseCase/Dice/RollDice/RollDiceUseCaseTest.php b/tests/Application/UseCase/Dice/RollDice/RollDiceUseCaseTest.php index c9d4842..401981c 100644 --- a/tests/Application/UseCase/Dice/RollDice/RollDiceUseCaseTest.php +++ b/tests/Application/UseCase/Dice/RollDice/RollDiceUseCaseTest.php @@ -8,8 +8,9 @@ use RPGPlayground\Application\UseCase\Dice\RollDice\RollDiceInput; use RPGPlayground\Application\UseCase\Dice\RollDice\RollDiceOutput; use RPGPlayground\Application\UseCase\Dice\RollDice\RollDiceUseCase; +use RPGPlayground\Domain\Enums\Roll\RollAttribute; use RPGPlayground\Domain\ValueObjects\Dice; -use RPGPlayground\Domain\ValueObjects\DiceModifier; +use RPGPlayground\Domain\ValueObjects\Roll\RollModifier; final class RollDiceUseCaseTest extends TestCase { @@ -75,7 +76,7 @@ public function test_single_multiplier_matches_d1_roll(): void public function test_addition_modifier_is_applied(): void { // D1 always rolls 1 → +4 = 5 - $input = $this->makeInput(new Dice(1), modifiers: [DiceModifier::fromString('+4')]); + $input = $this->makeInput(new Dice(1), modifiers: [RollModifier::fromString('+4')]); $output = RollDiceUseCase::handle($input)->unwrap(); $this->assertSame(5, $output->rollValue); @@ -84,7 +85,7 @@ public function test_addition_modifier_is_applied(): void public function test_subtraction_modifier_is_applied(): void { // D1 always rolls 1 → -1 = 0 - $input = $this->makeInput(new Dice(1), modifiers: [DiceModifier::fromString('-1')]); + $input = $this->makeInput(new Dice(1), modifiers: [RollModifier::fromString('-1')]); $output = RollDiceUseCase::handle($input)->unwrap(); $this->assertSame(0, $output->rollValue); @@ -93,7 +94,7 @@ public function test_subtraction_modifier_is_applied(): void public function test_multiplication_modifier_is_applied(): void { // D1 always rolls 1 → x3 = 3 - $input = $this->makeInput(new Dice(1), modifiers: [DiceModifier::fromString('x3')]); + $input = $this->makeInput(new Dice(1), modifiers: [RollModifier::fromString('x3')]); $output = RollDiceUseCase::handle($input)->unwrap(); $this->assertSame(3, $output->rollValue); @@ -102,7 +103,7 @@ public function test_multiplication_modifier_is_applied(): void public function test_division_modifier_is_applied(): void { // 3x D1 = 3 → /3 = 1 - $input = $this->makeInput(new Dice(1), modifiers: [DiceModifier::fromString('/3')], multiplier: 3); + $input = $this->makeInput(new Dice(1), modifiers: [RollModifier::fromString('/3')], multiplier: 3); $output = RollDiceUseCase::handle($input)->unwrap(); $this->assertSame(1, $output->rollValue); @@ -112,8 +113,8 @@ public function test_multiple_modifiers_are_applied_in_order(): void { // D1 = 1 → +9 = 10 → /2 = 5 $input = $this->makeInput(new Dice(1), modifiers: [ - DiceModifier::fromString('+9'), - DiceModifier::fromString('/2'), + RollModifier::fromString('+9'), + RollModifier::fromString('/2'), ]); $output = RollDiceUseCase::handle($input)->unwrap(); @@ -123,25 +124,98 @@ public function test_multiple_modifiers_are_applied_in_order(): void public function test_no_modifiers_returns_raw_roll(): void { - // D1 always 1, no modifiers $input = $this->makeInput(new Dice(1)); $output = RollDiceUseCase::handle($input)->unwrap(); $this->assertSame(1, $output->rollValue); } + // ------------------------------------------------------------------------- + // Advantage + // ------------------------------------------------------------------------- + + public function test_advantage_returns_highest_of_two_d1_rolls(): void + { + // D1 always rolls 1 — max([1, 1]) = 1 + $input = $this->makeInput(new Dice(1), attribute: RollAttribute::Advantage); + $output = RollDiceUseCase::handle($input)->unwrap(); + + $this->assertSame(1, $output->rollValue); + } + + public function test_advantage_result_is_within_dice_range(): void + { + $input = $this->makeInput(new Dice(20), attribute: RollAttribute::Advantage); + $output = RollDiceUseCase::handle($input)->unwrap(); + + $this->assertGreaterThanOrEqual(1, $output->rollValue); + $this->assertLessThanOrEqual(20, $output->rollValue); + } + + public function test_advantage_with_modifier_applied_after(): void + { + // D1 advantage = max([1, 1]) = 1 → +4 = 5 + $input = $this->makeInput( + new Dice(1), + modifiers: [RollModifier::fromString('+4')], + attribute: RollAttribute::Advantage, + ); + $output = RollDiceUseCase::handle($input)->unwrap(); + + $this->assertSame(5, $output->rollValue); + } + + // ------------------------------------------------------------------------- + // Disadvantage + // ------------------------------------------------------------------------- + + public function test_disadvantage_returns_lowest_of_two_d1_rolls(): void + { + // D1 always rolls 1 — min([1, 1]) = 1 + $input = $this->makeInput(new Dice(1), attribute: RollAttribute::Disadvantage); + $output = RollDiceUseCase::handle($input)->unwrap(); + + $this->assertSame(1, $output->rollValue); + } + + public function test_disadvantage_result_is_within_dice_range(): void + { + $input = $this->makeInput(new Dice(20), attribute: RollAttribute::Disadvantage); + $output = RollDiceUseCase::handle($input)->unwrap(); + + $this->assertGreaterThanOrEqual(1, $output->rollValue); + $this->assertLessThanOrEqual(20, $output->rollValue); + } + + public function test_disadvantage_with_modifier_applied_after(): void + { + // D1 disadvantage = min([1, 1]) = 1 → +4 = 5 + $input = $this->makeInput( + new Dice(1), + modifiers: [RollModifier::fromString('+4')], + attribute: RollAttribute::Disadvantage, + ); + $output = RollDiceUseCase::handle($input)->unwrap(); + + $this->assertSame(5, $output->rollValue); + } + // ------------------------------------------------------------------------- // Helpers // ------------------------------------------------------------------------- /** - * @param Dice $dice - * @param array $modifiers - * @param int $multiplier - * @return RollDiceInput + * @param Dice $dice + * @param array $modifiers + * @param int $multiplier + * @param RollAttribute|null $attribute */ - private function makeInput(Dice $dice, array $modifiers = [], int $multiplier = 1): RollDiceInput - { - return RollDiceInput::create($dice, $modifiers, $multiplier)->unwrap(); + private function makeInput( + Dice $dice, + array $modifiers = [], + int $multiplier = 1, + ?RollAttribute $attribute = null, + ): RollDiceInput { + return RollDiceInput::create($dice, $modifiers, $multiplier, $attribute)->unwrap(); } } diff --git a/tests/Domain/Enums/Roll/RollAttributeTest.php b/tests/Domain/Enums/Roll/RollAttributeTest.php new file mode 100644 index 0000000..990b6b7 --- /dev/null +++ b/tests/Domain/Enums/Roll/RollAttributeTest.php @@ -0,0 +1,23 @@ +assertTrue(RollAttribute::Advantage->isAdvantage()); + $this->assertFalse(RollAttribute::Advantage->isDisadvantage()); + } + + public function test_is_disadvantage(): void + { + $this->assertFalse(RollAttribute::Disadvantage->isAdvantage()); + $this->assertTrue(RollAttribute::Disadvantage->isDisadvantage()); + } +} diff --git a/tests/Domain/ValueObjects/DiceModifierTest.php b/tests/Domain/ValueObjects/Roll/RollModifierTest.php similarity index 81% rename from tests/Domain/ValueObjects/DiceModifierTest.php rename to tests/Domain/ValueObjects/Roll/RollModifierTest.php index 5ef5997..d5fc32e 100644 --- a/tests/Domain/ValueObjects/DiceModifierTest.php +++ b/tests/Domain/ValueObjects/Roll/RollModifierTest.php @@ -2,12 +2,12 @@ declare(strict_types=1); -namespace RPGPlayground\Tests\Domain\ValueObjects; +namespace RPGPlayground\Tests\Domain\ValueObjects\Roll; use PHPUnit\Framework\TestCase; -use RPGPlayground\Domain\ValueObjects\DiceModifier; +use RPGPlayground\Domain\ValueObjects\Roll\RollModifier; -final class DiceModifierTest extends TestCase +final class RollModifierTest extends TestCase { // ------------------------------------------------------------------------- // fromString — happy path @@ -19,7 +19,7 @@ public function test_from_string_parses_symbol_and_value( string $expectedSymbol, int $expectedValue, ): void { - $dm = DiceModifier::fromString($modifier); + $dm = RollModifier::fromString($modifier); $this->assertSame($expectedSymbol, $dm->symbol); $this->assertSame($expectedValue, $dm->value); @@ -49,7 +49,7 @@ public function test_from_string_throws_on_invalid_symbol(): void $this->expectException(\InvalidArgumentException::class); $this->expectExceptionMessage('Invalid modifier symbol: o'); - DiceModifier::fromString('o2'); + RollModifier::fromString('o2'); } #[\PHPUnit\Framework\Attributes\DataProvider('invalidSymbolProvider')] @@ -57,7 +57,7 @@ public function test_from_string_throws_for_unknown_symbols(string $modifier): v { $this->expectException(\InvalidArgumentException::class); - DiceModifier::fromString($modifier); + RollModifier::fromString($modifier); } /** @@ -80,35 +80,35 @@ public static function invalidSymbolProvider(): array public function test_apply_addition(): void { - $result = DiceModifier::fromString('+5')->apply(10); + $result = RollModifier::fromString('+5')->apply(10); $this->assertSame(15, $result); } public function test_apply_subtraction(): void { - $result = DiceModifier::fromString('-3')->apply(10); + $result = RollModifier::fromString('-3')->apply(10); $this->assertSame(7, $result); } public function test_apply_multiplication_star(): void { - $result = DiceModifier::fromString('*2')->apply(10); + $result = RollModifier::fromString('*2')->apply(10); $this->assertSame(20, $result); } public function test_apply_multiplication_x(): void { - $result = DiceModifier::fromString('x2')->apply(10); + $result = RollModifier::fromString('x2')->apply(10); $this->assertSame(20, $result); } public function test_apply_division_slash(): void { - $result = DiceModifier::fromString('/4')->apply(20); + $result = RollModifier::fromString('/4')->apply(20); $this->assertSame(5, $result); } @@ -120,14 +120,14 @@ public function test_apply_division_slash(): void public function test_apply_division_rounds_up_with_ceil(): void { // 10 / 3 = 3.33... → ceil → 4 - $result = DiceModifier::fromString('/3')->apply(10); + $result = RollModifier::fromString('/3')->apply(10); $this->assertSame(4, $result); } public function test_apply_subtraction_can_go_negative(): void { - $result = DiceModifier::fromString('-15')->apply(10); + $result = RollModifier::fromString('-15')->apply(10); $this->assertSame(-5, $result); }