feat: Add a dice rolling class
This commit is contained in:
parent
f211f73fc7
commit
3fa6997517
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
|
Loading…
x
Reference in New Issue
Block a user