feat: Add a dice rolling class
This commit is contained in:
		
							
								
								
									
										98
									
								
								gm_assistant/dice.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										98
									
								
								gm_assistant/dice.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,98 @@ | |||||||
|  | # SPDX-FileCopyrightText: 2025 2025 | ||||||
|  | # SPDX-FileContributor: Gergely Polonkai | ||||||
|  | # | ||||||
|  | # SPDX-License-Identifier: GPL-3.0-or-later | ||||||
|  | """dice -- Utilities to deal with dice rolls""" | ||||||
|  |  | ||||||
|  | import random | ||||||
|  | import re | ||||||
|  |  | ||||||
|  | DICE_DESCRIPTOR_PATTERN = re.compile(r"^([0-9]*d[0-9]+)([+-]([0-9]+|[0-9]*d[0-9]+))*$") | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class Die: | ||||||
|  |     """A generic die rolling class""" | ||||||
|  |  | ||||||
|  |     def __init__(self, descriptor: str) -> None: | ||||||
|  |         self.positive_pool, self.negative_pool, self.modifier = self.parse(descriptor) | ||||||
|  |  | ||||||
|  |     @staticmethod | ||||||
|  |     def parse(descriptor: str) -> tuple[list[int], list[int], int]: | ||||||
|  |         """Parse dice descriptors such as ``d6`` or ``3d8+2`` | ||||||
|  |  | ||||||
|  |         :param descriptor: the dice descriptor | ||||||
|  |         :returns: a tuple with the number and types of posititve dice, the number and types of negative dice, and the | ||||||
|  |             constant modifier | ||||||
|  |         """ | ||||||
|  |  | ||||||
|  |         normalized_descriptor = "".join(c for c in descriptor if c != " ") | ||||||
|  |  | ||||||
|  |         if not normalized_descriptor: | ||||||
|  |             raise ValueError("Empty string cannot be parsed") | ||||||
|  |  | ||||||
|  |         if not DICE_DESCRIPTOR_PATTERN.match(normalized_descriptor): | ||||||
|  |             raise ValueError(f"Invalid dice descriptor {descriptor}") | ||||||
|  |  | ||||||
|  |         sign = "+" | ||||||
|  |         positive_pool = [] | ||||||
|  |         negative_pool = [] | ||||||
|  |         modifier = 0 | ||||||
|  |  | ||||||
|  |         while normalized_descriptor: | ||||||
|  |             if (new_sign := normalized_descriptor[0]) in ("+", "-"): | ||||||
|  |                 sign = new_sign | ||||||
|  |                 normalized_descriptor = normalized_descriptor[1:] | ||||||
|  |                 continue | ||||||
|  |  | ||||||
|  |             match = re.search(r"^([0-9]*)(d?)([0-9]+)", normalized_descriptor) | ||||||
|  |             # This will always pass as we already checked the descriptor against DICE_DESCRIPTOR_PATTERN; we need this | ||||||
|  |             # assertion to prevent mypy warnings | ||||||
|  |             assert match | ||||||
|  |  | ||||||
|  |             multiplier, has_d, die_max = match.groups() | ||||||
|  |             normalized_descriptor = normalized_descriptor[match.end() :] | ||||||
|  |  | ||||||
|  |             if has_d: | ||||||
|  |                 count = int(multiplier) if multiplier else 1 | ||||||
|  |                 dice = [int(die_max) for _ in range(count)] | ||||||
|  |  | ||||||
|  |                 if sign == "+": | ||||||
|  |                     positive_pool.extend(dice) | ||||||
|  |                 elif sign == "-":  # pragma: no branch | ||||||
|  |                     negative_pool.extend(dice) | ||||||
|  |             else: | ||||||
|  |                 value = int(die_max) | ||||||
|  |  | ||||||
|  |                 if sign == "-": | ||||||
|  |                     value = 0 - value | ||||||
|  |  | ||||||
|  |                 modifier += value | ||||||
|  |  | ||||||
|  |         return positive_pool, negative_pool, modifier | ||||||
|  |  | ||||||
|  |     @property | ||||||
|  |     def minimum(self) -> int: | ||||||
|  |         """The lowest number this dice roller can yield""" | ||||||
|  |  | ||||||
|  |         return len(self.positive_pool) - sum(self.negative_pool) + self.modifier | ||||||
|  |  | ||||||
|  |     @property | ||||||
|  |     def maximum(self) -> int: | ||||||
|  |         """The highest number this dice roller can yield""" | ||||||
|  |  | ||||||
|  |         return sum(self.positive_pool) - len(self.negative_pool) + self.modifier | ||||||
|  |  | ||||||
|  |     def roll(self) -> int: | ||||||
|  |         """Roll the dice in this roller’s dice pool and return the result""" | ||||||
|  |  | ||||||
|  |         result = 0 | ||||||
|  |  | ||||||
|  |         for die in self.positive_pool: | ||||||
|  |             result += random.randint(1, die) | ||||||
|  |  | ||||||
|  |         for die in self.negative_pool: | ||||||
|  |             result -= random.randint(1, die) | ||||||
|  |  | ||||||
|  |         result += self.modifier | ||||||
|  |  | ||||||
|  |         return result | ||||||
							
								
								
									
										145
									
								
								tests/test_dice.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										145
									
								
								tests/test_dice.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,145 @@ | |||||||
|  | # SPDX-FileCopyrightText: 2025 2025 | ||||||
|  | # SPDX-FileContributor: Gergely Polonkai | ||||||
|  | # | ||||||
|  | # SPDX-License-Identifier: GPL-3.0-or-later | ||||||
|  | """Tests for the dice roller""" | ||||||
|  |  | ||||||
|  | import pytest | ||||||
|  | from pytest_mock import MockerFixture | ||||||
|  |  | ||||||
|  | from gm_assistant.dice import Die | ||||||
|  |  | ||||||
|  |  | ||||||
|  | def test_parse_empty() -> None: | ||||||
|  |     """Test parsing an empty string""" | ||||||
|  |  | ||||||
|  |     with pytest.raises(ValueError): | ||||||
|  |         Die.parse("") | ||||||
|  |  | ||||||
|  |     with pytest.raises(ValueError): | ||||||
|  |         Die.parse(" ") | ||||||
|  |  | ||||||
|  |  | ||||||
|  | @pytest.mark.parametrize("descriptor", [pytest.param("12", id="nodie")]) | ||||||
|  | def test_invalid(descriptor: str) -> None: | ||||||
|  |     """Test parsing invalid descriptors""" | ||||||
|  |  | ||||||
|  |     with pytest.raises(ValueError): | ||||||
|  |         Die.parse(descriptor) | ||||||
|  |  | ||||||
|  |  | ||||||
|  | @pytest.mark.parametrize( | ||||||
|  |     "descriptor,expected", | ||||||
|  |     [ | ||||||
|  |         pytest.param("d6", ([6], [], 0), id="single"), | ||||||
|  |         pytest.param("2d6", ([6, 6], [], 0), id="multiple"), | ||||||
|  |         pytest.param("d12 + 1", ([12], [], 1), id="modifiedsingle"), | ||||||
|  |         pytest.param("3d12 - 2", ([12, 12, 12], [], -2), id="modifiedmultiple"), | ||||||
|  |         pytest.param("2d6+3d8-d4+1", ([6, 6, 8, 8, 8], [4], 1), id="complex"), | ||||||
|  |     ], | ||||||
|  | ) | ||||||
|  | def test_valid(descriptor: str, expected: tuple[list[int], list[int], int]) -> None: | ||||||
|  |     """Test parsing valid descriptors""" | ||||||
|  |  | ||||||
|  |     results = Die.parse(descriptor) | ||||||
|  |  | ||||||
|  |     assert results == expected | ||||||
|  |  | ||||||
|  |  | ||||||
|  | @pytest.mark.parametrize( | ||||||
|  |     "descriptor,expected", | ||||||
|  |     [ | ||||||
|  |         pytest.param("d6", 1, id="single"), | ||||||
|  |         pytest.param("2d6", 2, id="multiple"), | ||||||
|  |         pytest.param("d12 + 1", 2, id="modifiedsingle"), | ||||||
|  |         pytest.param("3d12 - 2", 1, id="modifiedmultiple"), | ||||||
|  |         pytest.param("2d6+3d8-d4+1", 2, id="complex"), | ||||||
|  |     ], | ||||||
|  | ) | ||||||
|  | def test_minimum(descriptor: str, expected: int) -> None: | ||||||
|  |     """Test the ``minimum`` property""" | ||||||
|  |  | ||||||
|  |     assert Die(descriptor).minimum == expected | ||||||
|  |  | ||||||
|  |  | ||||||
|  | @pytest.mark.parametrize( | ||||||
|  |     "descriptor,expected", | ||||||
|  |     [ | ||||||
|  |         pytest.param("d6", 6, id="single"), | ||||||
|  |         pytest.param("2d6", 12, id="multiple"), | ||||||
|  |         pytest.param("d12 + 1", 13, id="modifiedsingle"), | ||||||
|  |         pytest.param("3d12 - 2", 34, id="modifiedmultiple"), | ||||||
|  |         pytest.param("2d6+3d8-d4+1", 36, id="complex"), | ||||||
|  |     ], | ||||||
|  | ) | ||||||
|  | def test_maximum(descriptor: str, expected: int) -> None: | ||||||
|  |     """Test the ``maxiimum`` property""" | ||||||
|  |  | ||||||
|  |     assert Die(descriptor).maximum == expected | ||||||
|  |  | ||||||
|  |  | ||||||
|  | def test_roll_single(mocker: MockerFixture) -> None: | ||||||
|  |     """Test rolling a single die""" | ||||||
|  |  | ||||||
|  |     mocked_randint = mocker.patch("gm_assistant.dice.random.randint", return_value=3) | ||||||
|  |  | ||||||
|  |     die = Die("d6") | ||||||
|  |  | ||||||
|  |     assert die.roll() == 3 | ||||||
|  |     mocked_randint.assert_called_once_with(1, 6) | ||||||
|  |  | ||||||
|  |  | ||||||
|  | def test_roll_multiple(mocker: MockerFixture) -> None: | ||||||
|  |     """Test rolling multiple of the same die""" | ||||||
|  |  | ||||||
|  |     mocked_randint = mocker.patch("gm_assistant.dice.random.randint", side_effect=[3, 2]) | ||||||
|  |  | ||||||
|  |     die = Die("2d6") | ||||||
|  |  | ||||||
|  |     assert die.roll() == 5 | ||||||
|  |     assert mocked_randint.call_count == 2 | ||||||
|  |     mocked_randint.assert_has_calls([mocker.call(1, 6), mocker.call(1, 6)]) | ||||||
|  |  | ||||||
|  |  | ||||||
|  | def test_roll_modified_single(mocker: MockerFixture) -> None: | ||||||
|  |     """Test rolling a single dice with a modifier""" | ||||||
|  |  | ||||||
|  |     mocked_randint = mocker.patch("gm_assistant.dice.random.randint", return_value=3) | ||||||
|  |  | ||||||
|  |     die = Die("d12+1") | ||||||
|  |  | ||||||
|  |     assert die.roll() == 4 | ||||||
|  |     mocked_randint.assert_called_once_with(1, 12) | ||||||
|  |  | ||||||
|  |  | ||||||
|  | def test_roll_modified_multiple(mocker: MockerFixture) -> None: | ||||||
|  |     """Test rolling multiple of the same die with a modifier""" | ||||||
|  |  | ||||||
|  |     mocked_randint = mocker.patch("gm_assistant.dice.random.randint", side_effect=[3, 2]) | ||||||
|  |  | ||||||
|  |     die = Die("2d12-1") | ||||||
|  |  | ||||||
|  |     assert die.roll() == 4 | ||||||
|  |     assert mocked_randint.call_count == 2 | ||||||
|  |     mocked_randint.assert_has_calls([mocker.call(1, 12), mocker.call(1, 12)]) | ||||||
|  |  | ||||||
|  |  | ||||||
|  | def test_roll_complex(mocker: MockerFixture) -> None: | ||||||
|  |     """Test rolling a complex die heap""" | ||||||
|  |  | ||||||
|  |     mocked_randint = mocker.patch("gm_assistant.dice.random.randint", side_effect=[3, 2, 5, 8, 1, 2]) | ||||||
|  |  | ||||||
|  |     die = Die("2d6-1+3d8-d4+2") | ||||||
|  |  | ||||||
|  |     assert die.roll() == 18 | ||||||
|  |     assert mocked_randint.call_count == 6 | ||||||
|  |     mocked_randint.assert_has_calls( | ||||||
|  |         [ | ||||||
|  |             mocker.call(1, 6), | ||||||
|  |             mocker.call(1, 6), | ||||||
|  |             mocker.call(1, 8), | ||||||
|  |             mocker.call(1, 8), | ||||||
|  |             mocker.call(1, 8), | ||||||
|  |             mocker.call(1, 4), | ||||||
|  |         ] | ||||||
|  |     ) | ||||||
| @@ -1,11 +0,0 @@ | |||||||
| # SPDX-FileCopyrightText: 2025 2025 |  | ||||||
| # SPDX-FileContributor: Gergely Polonkai |  | ||||||
| # |  | ||||||
| # SPDX-License-Identifier: GPL-3.0-or-later |  | ||||||
| """Stub test so the test job doesn’t fail""" |  | ||||||
|  |  | ||||||
|  |  | ||||||
| def test_stub() -> None: |  | ||||||
|     """Stub test so the test job doesn’t fail""" |  | ||||||
|  |  | ||||||
|     assert True |  | ||||||
		Reference in New Issue
	
	Block a user