diff --git a/gm_assistant/fate_chart.py b/gm_assistant/fate_chart.py new file mode 100644 index 0000000..38c21fb --- /dev/null +++ b/gm_assistant/fate_chart.py @@ -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 RPG’s 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 diff --git a/tests/test_fate_chart.py b/tests/test_fate_chart.py new file mode 100644 index 0000000..1a0d1d2 --- /dev/null +++ b/tests/test_fate_chart.py @@ -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