Compare commits

...

5 Commits

9 changed files with 483 additions and 3 deletions

View File

@ -8,6 +8,7 @@ import random
import re
DICE_DESCRIPTOR_PATTERN = re.compile(r"^([0-9]*d[0-9]+)([+-]([0-9]+|[0-9]*d[0-9]+))*$")
D666_COMBINATIONS = [(0, 1, 2), (0, 2, 1), (1, 0, 2), (1, 2, 0), (2, 0, 1), (2, 1, 0)]
class Die:
@ -96,3 +97,26 @@ class Die:
result += self.modifier
return result
def d666(strict_order: bool = False) -> list[int]:
"""Roll three six sided dice and use them as separate digits
d666 is a special die type often used in random tables; here you dont roll one 666 sided die, but three 6-sided
ones, then use the three values of the three digits of the final result.
:param strict_order: if ``True``, use the dics in strict order. Otherwise, return all possible combinations.
:returns: the list of all valid combinations
"""
roll_values = [random.randint(1, 6) for _ in range(3)]
results = []
combinations = [(0, 1, 2)] if strict_order else D666_COMBINATIONS
for a, b, c in combinations:
result = roll_values[a] * 100 + roll_values[b] * 10 + roll_values[c]
if result not in results:
results.append(result)
return results

192
gm_assistant/fate_chart.py Normal file
View File

@ -0,0 +1,192 @@
# SPDX-FileCopyrightText: 2025 2025
# SPDX-FileContributor: Gergely Polonkai
#
# SPDX-License-Identifier: GPL-3.0-or-later
"""Calculate the odds using Mythic RPGs Fate Chart"""
from enum import Enum
from .dice import Die
class FateOutcome(Enum):
"""Possible outcomes"""
EXC_YES = "eyes"
YES = "yes"
NO = "no"
EXC_NO = "eno"
class FateOdds(Enum):
"""Odds of a “yes” answer"""
IMPOSSIBLE = 0
NO_WAY = 1
VERY_UNLIKELY = 2
UNLIKELY = 3
FIFTY_FIFTY = 4
SOMEWHAT_LIKELY = 5
LIKELY = 6
VERY_LIKELY = 7
NEAR_SURE_THING = 8
SURE_THING = 9
HAS_TO_BE = 10
HAS_TO_BE2 = 11
TABLE: dict[FateOdds, list[tuple[int, int, int]]] = {
FateOdds.IMPOSSIBLE: [
(0, -20, 77),
(0, 0, 81),
(0, 0, 81),
(1, 5, 82),
(1, 5, 82),
(2, 10, 83),
(3, 15, 84),
(5, 25, 86),
(10, 50, 91),
],
FateOdds.NO_WAY: [
(0, 0, 81),
(1, 5, 82),
(1, 5, 82),
(2, 10, 83),
(3, 15, 84),
(5, 25, 86),
(7, 35, 88),
(10, 50, 91),
(15, 75, 96),
],
FateOdds.VERY_UNLIKELY: [
(1, 5, 82),
(1, 5, 82),
(2, 10, 83),
(3, 15, 84),
(5, 25, 86),
(9, 45, 90),
(10, 50, 91),
(13, 65, 94),
(16, 85, 97),
],
FateOdds.UNLIKELY: [
(1, 5, 82),
(2, 10, 83),
(3, 15, 84),
(4, 20, 85),
(7, 35, 88),
(10, 50, 91),
(11, 55, 92),
(15, 75, 96),
(18, 90, 99),
],
FateOdds.FIFTY_FIFTY: [
(2, 10, 83),
(3, 15, 84),
(5, 25, 86),
(7, 35, 88),
(10, 50, 91),
(13, 65, 94),
(15, 75, 96),
(16, 85, 97),
(19, 95, 100),
],
FateOdds.SOMEWHAT_LIKELY: [
(4, 20, 85),
(5, 25, 86),
(9, 45, 90),
(10, 50, 91),
(13, 65, 94),
(16, 80, 97),
(16, 85, 97),
(18, 90, 99),
(19, 95, 100),
],
FateOdds.LIKELY: [
(5, 25, 86),
(7, 35, 88),
(10, 50, 91),
(11, 55, 92),
(15, 75, 96),
(16, 85, 97),
(18, 90, 99),
(19, 95, 100),
(20, 100, 0),
],
FateOdds.VERY_LIKELY: [
(9, 45, 90),
(10, 50, 91),
(13, 65, 94),
(15, 75, 96),
(16, 85, 97),
(18, 90, 99),
(19, 95, 100),
(19, 95, 100),
(21, 105, 0),
],
FateOdds.NEAR_SURE_THING: [
(10, 50, 91),
(11, 55, 92),
(15, 75, 96),
(16, 80, 97),
(18, 90, 99),
(19, 95, 100),
(19, 95, 100),
(20, 100, 0),
(23, 115, 0),
],
FateOdds.SURE_THING: [
(11, 55, 92),
(13, 65, 94),
(16, 80, 97),
(16, 85, 97),
(18, 90, 99),
(19, 95, 100),
(19, 95, 100),
(22, 110, 0),
(25, 125, 0),
],
FateOdds.HAS_TO_BE: [
(16, 80, 97),
(16, 85, 97),
(18, 90, 99),
(19, 95, 100),
(19, 95, 100),
(20, 100, 0),
(20, 100, 0),
(26, 130, 0),
(26, 145, 0),
],
FateOdds.HAS_TO_BE2: [
(18, 90, 99),
(19, 95, 100),
(19, 95, 100),
(20, 100, 0),
(22, 110, 0),
(24, 120, 0),
(24, 120, 0),
(30, 150, 0),
(29, 165, 0),
],
}
def decide(odds: FateOdds, chaos_level: int = 5) -> FateOutcome:
"""Decide the outcome of a situation using the Fate Chart"""
# Chaos levels range from 1 and 9 (both included), but list indices start at 0
chaos_level -= 1
eyes, yes, eno = TABLE[odds][chaos_level]
value = Die("d100").roll()
if value <= eyes:
return FateOutcome.EXC_YES
if value <= yes:
return FateOutcome.YES
if value >= eno:
return FateOutcome.EXC_NO
return FateOutcome.NO

View File

@ -2,3 +2,52 @@
# SPDX-FileContributor: Gergely Polonkai
#
# SPDX-License-Identifier: GPL-3.0-or-later
"""Oracle classes and related functions"""
from pathlib import Path
from typing import Any, Type
import yaml
from .base import Oracle
from .object_generator import ObjectGeneratorOracle
from .random_choice import RandomChoiceOracle
def generate_type_classes(class_list: dict[str, Any]) -> dict[str, Type[Oracle]]:
"""Generate a dictionary of oracle type handlers"""
ret: dict[str, Type[Oracle]] = {}
for klass in class_list.values():
if not isinstance(klass, type) or klass == Oracle or not issubclass(klass, Oracle):
continue
if klass.TYPE_MARKER in ret:
raise KeyError(
f"{ret[klass.TYPE_MARKER].__name__} is already registered as a handler for {klass.TYPE_MARKER}"
)
ret[klass.TYPE_MARKER] = klass
return ret
TYPE_CLASSES: dict[str, Type[Oracle]] = generate_type_classes(globals())
def load_oracle_from_yaml(file_path: str | Path) -> Oracle:
"""Create an Oracle from a YAML file"""
with open(file_path, "r", encoding="utf-8") as fhand:
data = yaml.safe_load(fhand)
if not isinstance(data, dict):
raise TypeError("Oracle data must be a YAML object")
if (generator_type := data.get("type")) not in TYPE_CLASSES:
raise KeyError(f"No information on how to handle {generator_type} data")
handler_class = TYPE_CLASSES[generator_type]
return handler_class(data)

16
poetry.lock generated
View File

@ -873,7 +873,7 @@ version = "6.0.2"
description = "YAML parser and emitter for Python"
optional = false
python-versions = ">=3.8"
groups = ["dev"]
groups = ["main", "dev"]
files = [
{file = "PyYAML-6.0.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:0a9a2848a5b7feac301353437eb7d5957887edbf81d56e903999a75a3d743086"},
{file = "PyYAML-6.0.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:29717114e51c84ddfba879543fb232a6ed60086602313ca38cce623c1d62cfbf"},
@ -994,6 +994,18 @@ files = [
{file = "tomlkit-0.13.2.tar.gz", hash = "sha256:fff5fe59a87295b278abd31bec92c15d9bc4a06885ab12bcea52c71119392e79"},
]
[[package]]
name = "types-pyyaml"
version = "6.0.12.20250516"
description = "Typing stubs for PyYAML"
optional = false
python-versions = ">=3.9"
groups = ["dev"]
files = [
{file = "types_pyyaml-6.0.12.20250516-py3-none-any.whl", hash = "sha256:8478208feaeb53a34cb5d970c56a7cd76b72659442e733e268a94dc72b2d0530"},
{file = "types_pyyaml-6.0.12.20250516.tar.gz", hash = "sha256:9f21a70216fc0fa1b216a8176db5f9e0af6eb35d2f2932acb87689d03a5bf6ba"},
]
[[package]]
name = "typing-extensions"
version = "4.13.2"
@ -1042,4 +1054,4 @@ files = [
[metadata]
lock-version = "2.1"
python-versions = "~3.13"
content-hash = "c778f03ed4db825597abdfa53d9dc90fed168d978a929383922ab8acabe71f54"
content-hash = "4450e2a03c5ea31eee3b6ddbb097737a778911e8ea1d4d389229a39596ffa9d9"

View File

@ -14,6 +14,7 @@ license = {text = "GPL-3.0-or-later"}
readme = "README.md"
requires-python = "~3.13"
dependencies = [
"pyyaml (>=6.0.2,<7.0.0)"
]
[tool.poetry.group.dev.dependencies]
@ -27,6 +28,7 @@ pytest = "^8.3.5"
pytest-cov = "^6.1.1"
pytest-mock = "^3.14.0"
reuse = "^5.0.2"
types-pyyaml = "^6.0.12.20250516"
[tool.black]
line-length = 120

View File

@ -7,7 +7,7 @@
import pytest
from pytest_mock import MockerFixture
from gm_assistant.dice import Die
from gm_assistant.dice import Die, d666
def test_parse_empty() -> None:
@ -143,3 +143,45 @@ def test_roll_complex(mocker: MockerFixture) -> None:
mocker.call(1, 4),
]
)
def test_d666_strict_order(mocker: MockerFixture) -> None:
"""Test ``d666()`` with strict dice order"""
mocked_randint = mocker.patch("gm_assistant.dice.random.randint", side_effect=[2, 4, 6])
assert d666(strict_order=True) == [246]
assert mocked_randint.call_count == 3
mocked_randint.assert_has_calls([mocker.call(1, 6), mocker.call(1, 6), mocker.call(1, 6)])
def test_d666_any_order(mocker: MockerFixture) -> None:
"""Test ``d666()`` without strict order"""
mocked_randint = mocker.patch("gm_assistant.dice.random.randint", side_effect=[2, 4, 6])
assert d666(strict_order=False) == [246, 264, 426, 462, 624, 642]
assert mocked_randint.call_count == 3
mocked_randint.assert_has_calls([mocker.call(1, 6), mocker.call(1, 6), mocker.call(1, 6)])
@pytest.mark.parametrize("dice_values", [pytest.param((1, 1, 2)), pytest.param((1, 2, 1)), pytest.param((2, 1, 1))])
def test_d666_equal_dice(dice_values: tuple[int, int, int], mocker: MockerFixture) -> None:
"""Test ``d666()`` when some dice are equal"""
mocked_randint = mocker.patch("gm_assistant.dice.random.randint", side_effect=dice_values)
assert set(d666()) == set((112, 121, 211))
assert mocked_randint.call_count == 3
mocked_randint.assert_has_calls([mocker.call(1, 6), mocker.call(1, 6), mocker.call(1, 6)])
@pytest.mark.parametrize("strict_order", (True, False), ids=("strict", "any"))
def test_d666_all_equal(strict_order: bool, mocker: MockerFixture) -> None:
"""Test ``d666()`` when all dice are equal"""
mocked_randint = mocker.patch("gm_assistant.dice.random.randint", return_value=5)
assert d666(strict_order=strict_order) == [555]
assert mocked_randint.call_count == 3
mocked_randint.assert_has_calls([mocker.call(1, 6), mocker.call(1, 6), mocker.call(1, 6)])

51
tests/test_fate_chart.py Normal file
View File

@ -0,0 +1,51 @@
# SPDX-FileCopyrightText: 2025 2025
# SPDX-FileContributor: Gergely Polonkai
#
# SPDX-License-Identifier: GPL-3.0-or-later
"""Tests for the Fate Chart module"""
import pytest
from pytest_mock import MockerFixture
from gm_assistant.fate_chart import FateOdds, FateOutcome, decide
@pytest.mark.parametrize("odds", FateOdds)
@pytest.mark.parametrize("chaos_level", range(9))
def test_all_valid_values(odds: FateOdds, chaos_level: int) -> None:
"""Test if every valid value yields some result"""
assert isinstance(decide(odds, chaos_level=chaos_level), FateOutcome)
# WARNING
#
# Tests below this line rely on exact values in the fate_chart module
def test_exceptional_yes(mocker: MockerFixture) -> None:
"""Test if exceptional yes result is returned"""
mocker.patch("gm_assistant.fate_chart.Die.roll", return_value=10)
assert decide(FateOdds.FIFTY_FIFTY, chaos_level=5) == FateOutcome.EXC_YES
def test_yes(mocker: MockerFixture) -> None:
"""Test if yes result is returned"""
mocker.patch("gm_assistant.fate_chart.Die.roll", return_value=50)
assert decide(FateOdds.FIFTY_FIFTY, chaos_level=5) == FateOutcome.YES
def test_exceptional_no(mocker: MockerFixture) -> None:
"""Test if exceptional no result is returned"""
mocker.patch("gm_assistant.fate_chart.Die.roll", return_value=91)
assert decide(FateOdds.FIFTY_FIFTY, chaos_level=5) == FateOutcome.EXC_NO
def test_no(mocker: MockerFixture) -> None:
"""Test if exceptional no result is returned"""
mocker.patch("gm_assistant.fate_chart.Die.roll", return_value=51)
assert decide(FateOdds.FIFTY_FIFTY, chaos_level=5) == FateOutcome.NO

View File

@ -0,0 +1,55 @@
# SPDX-FileCopyrightText: 2025 2025
# SPDX-FileContributor: Gergely Polonkai
#
# SPDX-License-Identifier: GPL-3.0-or-later
"""Tests for the ``load_oracle_from_yaml`` function"""
import pytest
from pytest_mock import MockerFixture
from gm_assistant.oracle import load_oracle_from_yaml
from gm_assistant.oracle.random_choice import RandomChoiceOracle
def test_non_object(mocker: MockerFixture) -> None:
"""Test loading something that is not a YAML object"""
mocker.patch("gm_assistant.oracle.open")
mocker.patch("gm_assistant.oracle.yaml.safe_load", return_value=[])
with pytest.raises(TypeError) as ctx:
load_oracle_from_yaml("test_file")
assert str(ctx.value) == "Oracle data must be a YAML object"
# WARNING
#
# Tests below this line rely on specific classes getting loaded into gm_assistant.oracle.TYPE_CLASSES
def test_unknown_type(mocker: MockerFixture) -> None:
"""Test loading an oracle with an unknown type"""
mocker.patch("gm_assistant.oracle.open")
mocker.patch("gm_assistant.oracle.yaml.safe_load", return_value={"type": "something-non-existing"})
with pytest.raises(KeyError) as ctx:
load_oracle_from_yaml("test_file")
assert str(ctx.value) == "'No information on how to handle something-non-existing data'"
def test_load_oracle(mocker: MockerFixture) -> None:
"""Test loading a specific oracle"""
mocker.patch("gm_assistant.oracle.open")
mocker.patch(
"gm_assistant.oracle.yaml.safe_load",
return_value={"type": "random-choice", "name": "Test Oracle", "source": "Test Source", "choices": ["A", "B"]},
)
oracle = load_oracle_from_yaml("test_file")
assert isinstance(oracle, RandomChoiceOracle)
assert oracle.choices == ["A", "B"]

View File

@ -0,0 +1,53 @@
# SPDX-FileCopyrightText: 2025 2025
# SPDX-FileContributor: Gergely Polonkai
#
# SPDX-License-Identifier: GPL-3.0-or-later
"""Tests for the type class lister"""
import pytest
from gm_assistant.oracle import generate_type_classes
from gm_assistant.oracle.base import Oracle
from gm_assistant.oracle.object_generator import ObjectGeneratorOracle
class _TestOracle(Oracle):
"""Test oracle class that has the same marker as ObjectGeneratorOracle"""
TYPE_MARKER = ObjectGeneratorOracle.TYPE_MARKER
def generate(self) -> str: # pragma: no cover
return ""
def test_generate_empty() -> None:
"""Test generating the type class list from an empty dictionary"""
assert generate_type_classes({}) == {} # pylint: disable=use-implicit-booleaness-not-comparison
def test_nontype_not_present() -> None:
"""Test that non-types dont get included in the results"""
assert generate_type_classes({"test": True}) == {} # pylint: disable=use-implicit-booleaness-not-comparison
def test_non_oracle_not_present() -> None:
"""Test that non-oracle types dont get included in the results"""
assert generate_type_classes({"test": dict}) == {} # pylint: disable=use-implicit-booleaness-not-comparison
def test_oracle_not_present() -> None:
"""Test that the ``Oracle`` class doesnt get included in the results"""
assert generate_type_classes({"oracle": Oracle}) == {} # pylint: disable=use-implicit-booleaness-not-comparison
def test_duplace_type_marker() -> None:
"""Test if ``generate_type_classes`` raises an error if a type marker appears twice"""
with pytest.raises(KeyError) as ctx:
generate_type_classes({"ObjectGeneratorOracle": ObjectGeneratorOracle, "TestOracle": _TestOracle})
assert str(ctx.value) == "'ObjectGeneratorOracle is already registered as a handler for object-generator'"