5 Commits

10 changed files with 9447 additions and 2 deletions

697
data/cypher-list.yaml Normal file
View File

@@ -0,0 +1,697 @@
categories:
generic_fantasy: Fantasy/Fairy Tale
fantasy: Fantasy
modern: Modern/Romance
scifi: Science Fiction/Post-Apocalyptic
horror: Horror
superhero: Superhero
forms:
generic_fantasy:
list:
- Potion
- Scroll
- Runeplate
- Tattoo
- Charm
- Powder
- Crystal
- Book
fantasy:
include: generic_fantasy
list:
- min_roll: 1
max-roll: 2
name: Bone runeplate
- min_roll: 3
max-roll: 4
name: Book page
- min_roll: 5
max-roll: 7
name: Bottle of powder
- min_roll: 8
max-roll: 9
name: Brand
- min_roll: 10
max-roll: 12
name: Brick
- min_roll: 13
max-roll: 15
name: Carved bone
- min_roll: 16
max-roll: 18
name: Carved stick
- min_roll: 19
max-roll: 20
name: Carved tooth
- min_roll: 21
max-roll: 23
name: Chalky potion
- min_roll: 30
max-roll: 33
name: Clay runeplate
- min_roll: 34
max-roll: 37
name: Crystal
- min_roll: 38
max-roll: 39
name: Elaborate scar
- min_roll: 40
max-roll: 42
name: Envelope of powder
- min_roll: 43
max-roll: 44
name: Fuming potion
- min_roll: 45
max-roll: 47
name: Glass
- min_roll: 48
max-roll: 50
name: Leaf
- min_roll: 51
max-roll: 54
name: Leather scroll
- min_roll: 55
max-roll: 57
name: Metal runeplate
- min_roll: 58
max-roll: 60
name: Oily potion
- min_roll: 61
max-roll: 62
name: Paper scroll
- min_roll: 63
max-roll: 66
name: Papyrus scroll
- min_roll: 67
max-roll: 71
name: Parchment scroll
- min_roll: 72
max-roll: 74
name: Pouch of powder
- min_roll: 75
max-roll: 76
name: Skin drawing
- min_roll: 77
max-roll: 80
name: Stone
- min_roll: 81
max-roll: 82
name: Tattoo
- min_roll: 83
max-roll: 85
name: Thick potion
- min_roll: 86
max-roll: 88
name: Tube of power
- min_roll: 89
max-roll: 92
name: Vellum scroll
- min_roll: 93
max-roll: 96
name: Watery potion
- min_roll: 97
max-roll: 00
name: Wood runeplate
modern:
list:
- Drug
- Virus
- Smartphone app
scifi:
list:
- Drug
- Computer program
- Crystal
- Gadget
- Virus
- Biological implant
- Mechanical implant
- Nanotechnological injection
horror:
list:
- Burrowing worm or insect
- Page from a forbidden book
- Horrific image
superhero:
include: all
cyphers:
manifest:
- min_roll: 1
max-roll: 3
name: Adhesion
level: d6
- min_roll: 4
max-roll: 5
name: Antivenom
- min_roll: 6
max-roll: 9
name: Armor reinforcer
- min_roll: 10
max-roll: 11
name: Attractor
- min_roll: 12
max-roll: 13
name: Blackout
- min_roll: 14
max-roll: 15
name: Catholicon
- min_roll: 16
max-roll: 17
name: Curse bringer
- min_roll: 18
max-roll: 19
name: Death bringer
- min_roll: 20
max-roll: 22
name: Density
- min_roll: 23
max-roll: 26
name: Detonation
- min_roll: 27
max-roll: 29
name: Detonation (flash)
- min_roll: 30
max-roll: 31
name: Detonation (massive)
- min_roll: 32
max-roll: 34
name: Detonation (pressure)
- min_roll: 35
max-roll: 36
name: Detonation (sonic)
- min_roll: 37
max-roll: 38
name: Detonation (spawn)
- min_roll: 39
max-roll: 41
name: Detonation (web)
- min_roll: 42
max-roll: 44
name: Equipment cache
- min_roll: 45
max-roll: 46
name: Fireproofing
- min_roll: 47
max-roll: 49
name: Friction reducer
- min_roll: 50
max-roll: 52
name: Gas bomb
- min_roll: 53
max-roll: 55
name: Hunter/seeker
- min_roll: 56
max-roll: 57
name: Infiltrator
- min_roll: 58
max-roll: 60
name: Information sensor
- min_roll: 61
max-roll: 63
name: Metal death
- min_roll: 64
max-roll: 65
name: Nullification ray
- min_roll: 66
max-roll: 68
name: Poison (emotion)
- min_roll: 69
max-roll: 70
name: Poison (mind disrupting)
- min_roll: 71
max-roll: 73
name: Radiation spike
- min_roll: 74
max-roll: 76
name: Remote viewer
- min_roll: 77
max-roll: 79
name: Shocker
- min_roll: 80
max-roll: 82
name: Sleep inducer
- min_roll: 83
max-roll: 85
name: Sniper module
- min_roll: 86
max-roll: 88
name: Solvent
- min_roll: 89
max-roll: 90
name: Spy
- min_roll: 91
max-roll: 92
name: Tracer
- min_roll: 93
max-roll: 94
name: Uninterruptible power source
- min_roll: 95
max-roll: 96
name: Warmth
- min_roll: 97
max-roll: 98
name: Water adapter
- min_roll: 99
max-roll: 100
name: X-ray viewer
fantastic:
- min_roll: 01
max-roll: 01
name: Age taker
- min_roll: 02
max-roll: 02
name: Banishing
- min_roll: 03
max-roll: 04
name: Blinking
- min_roll: 05
max-roll: 05
name: Chemical factory
- min_roll: 06
max-roll: 06
name: Comprehension
- min_roll: 07
max-roll: 08
name: Condition remover
- min_roll: 09
max-roll: 09
name: Controlled blinking
- min_roll: 10
max-roll: 10
name: Detonation (creature)
- min_roll: 11
max-roll: 11
name: Detonation (desiccating)
- min_roll: 12
max-roll: 12
name: Detonation (gravity)
- min_roll: 13
max-roll: 13
name: Detonation (gravity inversion)
- min_roll: 14
max-roll: 14
name: Detonation (matter disruption)
- min_roll: 15
max-roll: 15
name: Detonation (singularity)
- min_roll: 16
max-roll: 16
name: Disguise module
- min_roll: 17
max-roll: 17
name: Disrupting
- min_roll: 18
max-roll: 18
name: Farsight
- min_roll: 19
max-roll: 19
name: Flame-retardant wall
- min_roll: 20
max-roll: 20
name: Force cube
- min_roll: 21
max-roll: 22
name: Force field
- min_roll: 23
max-roll: 23
name: Force screen projector
- min_roll: 24
max-roll: 24
name: Force shield projector
- min_roll: 25
max-roll: 25
name: Frigid wall
- min_roll: 26
max-roll: 27
name: Gravity nullifier
- min_roll: 28
max-roll: 28
name: Gravity-nullifying application
- min_roll: 29
max-roll: 30
name: Heat attack
- min_roll: 31
max-roll: 31
name: Image projector
- min_roll: 32
max-roll: 32
name: Inferno wall
- min_roll: 33
max-roll: 34
name: Instant servant
- min_roll: 35
max-roll: 35
name: Instant shelter
- min_roll: 36
max-roll: 36
name: Lightning wall
- min_roll: 37
max-roll: 38
name: Machine control
- min_roll: 39
max-roll: 39
name: Magnetic attack drill
- min_roll: 40
max-roll: 40
name: Magnetic master
- min_roll: 41
max-roll: 41
name: Magnetic shield
- min_roll: 42
max-roll: 42
name: Manipulation beam
- min_roll: 43
max-roll: 43
name: Matter transference ray
- min_roll: 44
max-roll: 44
name: Memory switch
- min_roll: 45
max-roll: 45
name: Mental scrambler
- min_roll: 46
max-roll: 46
name: Mind meld
- min_roll: 47
max-roll: 47
name: Mind-restricting wall
- min_roll: 48
max-roll: 49
name: Monoblade
- min_roll: 50
max-roll: 50
name: Monohorn
- min_roll: 51
max-roll: 51
name: Null field
- min_roll: 52
max-roll: 53
name: Personal environment field
- min_roll: 54
max-roll: 55
name: Phase changer
- min_roll: 56
max-roll: 56
name: Phase disruptor
- min_roll: 57
max-roll: 57
name: Poison (explosive)
- min_roll: 58
max-roll: 58
name: Poison (mind controlling)
- min_roll: 59
max-roll: 59
name: Psychic communique
- min_roll: 60
max-roll: 60
name: Ray emitter
- min_roll: 61
max-roll: 61
name: Ray emitter (command)
- min_roll: 62
max-roll: 62
name: Ray emitter (fear)
- min_roll: 63
max-roll: 63
name: Ray emitter (friend slaying)
- min_roll: 64
max-roll: 64
name: Ray emitter (mind disrupting)
- min_roll: 65
max-roll: 65
name: Ray emitter (numbing)
- min_roll: 66
max-roll: 66
name: Ray emitter (paralysis)
- min_roll: 67
max-roll: 67
name: Reality spike
- min_roll: 68
max-roll: 68
name: Repair unit
- min_roll: 69
max-roll: 69
name: Repeater
- min_roll: 70
max-roll: 71
name: Retaliation
- min_roll: 72
max-roll: 72
name: Sheen
- min_roll: 73
max-roll: 74
name: Shock attack
- min_roll: 75
max-roll: 75
name: Slave maker
- min_roll: 76
max-roll: 76
name: Sonic hole
- min_roll: 77
max-roll: 78
name: Sound dampener
- min_roll: 79
max-roll: 79
name: Spatial warp
- min_roll: 80
max-roll: 80
name: Stasis keeper
- min_roll: 81
max-roll: 81
name: Subdual field
- min_roll: 82
max-roll: 83
name: Telepathy
- min_roll: 84
max-roll: 84
name: Teleporter (bounder)
- min_roll: 85
max-roll: 85
name: Teleporter (interstellar)
- min_roll: 86
max-roll: 86
name: Teleporter (planetary)
- min_roll: 87
max-roll: 87
name: Teleporter (traveler)
- min_roll: 88
max-roll: 88
name: Temporal viewer
- min_roll: 89
max-roll: 89
name: Time dilation (defensive)
- min_roll: 90
max-roll: 90
name: Time dilation (offensive)
- min_roll: 91
max-roll: 91
name: Trick embedder
- min_roll: 92
max-roll: 92
name: Vanisher
- min_roll: 93
max-roll: 94
name: Visage changer
- min_roll: 95
max-roll: 95
name: Visual displacement device
- min_roll: 96
max-roll: 96
name: Vocal translator
- min_roll: 97
max-roll: 98
name: Weapon enhancement
- min_roll: 99
max-roll: 99
name: Wings
- min_roll: 00
max-roll: 00
name: Zero point field
subtle:
- min_roll: 1
max-roll: 4
name: Analeptic
- min_roll: 5
max-roll: 7
name: Best tool
- min_roll: 8
max-roll: 10
name: Burst of speed
- min_roll: 11
max-roll: 13
name: Contingent activator
- min_roll: 14
max-roll: 17
name: Curative
- min_roll: 18
max-roll: 20
name: Darksight
- min_roll: 21
max-roll: 23
name: Disarm
- min_roll: 24
max-roll: 26
name: Eagleseye
- min_roll: 27
max-roll: 29
name: Effect resistance
- min_roll: 30
max-roll: 32
name: Effort enhancer (combat)
- min_roll: 33
max-roll: 35
name: Effort enhancer (noncombat)
- min_roll: 36
max-roll: 39
name: Enduring shield
- min_roll: 40
max-roll: 42
name: Intellect booster
- min_roll: 43
max-roll: 45
name: Intelligence enhancement
- min_roll: 46
max-roll: 48
name: Knowledge enhancement
- min_roll: 49
max-roll: 51
name: Meditation aid
- min_roll: 52
max-roll: 54
name: Mind stabilizer
- min_roll: 55
max-roll: 57
name: Motion sensor
- min_roll: 58
max-roll: 60
name: Nutrition and hydration
- min_roll: 61
max-roll: 63
name: Perfect memory
- min_roll: 64
max-roll: 66
name: Perfection
- min_roll: 67
max-roll: 69
name: Reflex enhancer
- min_roll: 70
max-roll: 73
name: Rejuvenator
- min_roll: 74
max-roll: 76
name: Remembering
- min_roll: 77
max-roll: 79
name: Repel
- min_roll: 80
max-roll: 82
name: Secret
- min_roll: 83
max-roll: 85
name: Skill boost
- min_roll: 86
max-roll: 88
name: Speed boost
- min_roll: 89
max-roll: 91
name: Stim
- min_roll: 92
max-roll: 94
name: Strength boost
- min_roll: 95
max-roll: 97
name: Strength enhancer
- min_roll: 98
max-roll: 100
name: Tissue regeneration
fantasy:
- min_roll: 1
max-roll: 5
name: Acid resistance
- min_roll: 6
max-roll: 11
name: Animal control
- min_roll: 12
max-roll: 18
name: Beast shape
- min_roll: 19
max-roll: 27
name: Cold resistance
- min_roll: 28
max-roll: 34
name: Demon ward
- min_roll: 35
max-roll: 39
name: Dragon ward
- min_roll: 40
max-roll: 44
name: Electricity resistance
- min_roll: 45
max-roll: 48
name: Elemental conjuration
- min_roll: 49
max-roll: 57
name: Fire resistance
- min_roll: 58
max-roll: 61
name: Giant size
- min_roll: 62
max-roll: 65
name: Instant boat
- min_roll: 66
max-roll: 68
name: Instant tower
- min_roll: 69
max-roll: 72
name: Lycanthrope ward
- min_roll: 73
max-roll: 76
name: Penultimate key
- min_roll: 77
max-roll: 82
name: Poison resistance
- min_roll: 83
max-roll: 86
name: Restorative aura
- min_roll: 87
max-roll: 89
name: Thought listening
- min_roll: 90
max-roll: 93
name: Tiny size
- min_roll: 94
max-roll: 98
name: Undead ward
- min_roll: 99
max-roll: 00
name: Walking corpse
superhero:
- min_roll: 01
max-roll: 10
name: Area boost
- min_roll: 11
max-roll: 20
name: Burst boost
- min_roll: 21
max-roll: 30
name: Damage boost
- min_roll: 31
max-roll: 40
name: Efficacy boost
- min_roll: 41
max-roll: 50
name: Energy boost
- min_roll: 51
max-roll: 60
name: Range boost
- min_roll: 61
max-roll: 80
name: Shift boost
- min_roll: 81
max-roll: 90
name: Stunt boost
- min_roll: 91
max-roll: 00
name: Target boost

2435
data/og-csrd-artifacts.yaml Normal file

File diff suppressed because it is too large Load Diff

5899
data/og-csrd-cyphers.yaml Normal file

File diff suppressed because it is too large Load Diff

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-FileContributor: Gergely Polonkai
# #
# SPDX-License-Identifier: GPL-3.0-or-later # 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" description = "YAML parser and emitter for Python"
optional = false optional = false
python-versions = ">=3.8" python-versions = ">=3.8"
groups = ["dev"] groups = ["main", "dev"]
files = [ 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_10_9_x86_64.whl", hash = "sha256:0a9a2848a5b7feac301353437eb7d5957887edbf81d56e903999a75a3d743086"},
{file = "PyYAML-6.0.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:29717114e51c84ddfba879543fb232a6ed60086602313ca38cce623c1d62cfbf"}, {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"}, {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]] [[package]]
name = "typing-extensions" name = "typing-extensions"
version = "4.13.2" version = "4.13.2"
@@ -1042,4 +1054,4 @@ files = [
[metadata] [metadata]
lock-version = "2.1" lock-version = "2.1"
python-versions = "~3.13" 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" readme = "README.md"
requires-python = "~3.13" requires-python = "~3.13"
dependencies = [ dependencies = [
"pyyaml (>=6.0.2,<7.0.0)"
] ]
[tool.poetry.group.dev.dependencies] [tool.poetry.group.dev.dependencies]
@@ -27,6 +28,7 @@ pytest = "^8.3.5"
pytest-cov = "^6.1.1" pytest-cov = "^6.1.1"
pytest-mock = "^3.14.0" pytest-mock = "^3.14.0"
reuse = "^5.0.2" reuse = "^5.0.2"
types-pyyaml = "^6.0.12.20250516"
[tool.black] [tool.black]
line-length = 120 line-length = 120

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'"