feat: Create a module for Mythic RPG’s Fate Chart

This commit is contained in:
Gergely Polonkai 2025-05-23 23:48:50 +02:00
parent bf69d7463e
commit 4d92935fda
No known key found for this signature in database
GPG Key ID: 2D2885533B869ED4
2 changed files with 243 additions and 0 deletions

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

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