From 3fa6997517ccb20563d1bf5452a0c12fe679e922 Mon Sep 17 00:00:00 2001 From: Gergely Polonkai Date: Wed, 21 May 2025 15:04:52 +0200 Subject: [PATCH] feat: Add a dice rolling class --- gm_assistant/dice.py | 98 +++++++++++++++++++++++++++++ tests/test_dice.py | 145 +++++++++++++++++++++++++++++++++++++++++++ tests/test_stub.py | 11 ---- 3 files changed, 243 insertions(+), 11 deletions(-) create mode 100644 gm_assistant/dice.py create mode 100644 tests/test_dice.py delete mode 100644 tests/test_stub.py diff --git a/gm_assistant/dice.py b/gm_assistant/dice.py new file mode 100644 index 0000000..f5f3945 --- /dev/null +++ b/gm_assistant/dice.py @@ -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 diff --git a/tests/test_dice.py b/tests/test_dice.py new file mode 100644 index 0000000..984c51a --- /dev/null +++ b/tests/test_dice.py @@ -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), + ] + ) diff --git a/tests/test_stub.py b/tests/test_stub.py deleted file mode 100644 index 614fc5a..0000000 --- a/tests/test_stub.py +++ /dev/null @@ -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