feat: Create the Object Generator Oracle

This commit is contained in:
Gergely Polonkai 2025-05-21 23:45:16 +02:00
parent d556159f2a
commit 248d7a27af
No known key found for this signature in database
GPG Key ID: 38F402C8471DDE93
2 changed files with 279 additions and 0 deletions

View File

@ -0,0 +1,99 @@
# SPDX-FileCopyrightText: 2025 2025
# SPDX-FileContributor: Gergely Polonkai
#
# SPDX-License-Identifier: GPL-3.0-or-later
"""Object Generator Oracle"""
from typing import Any
from ..dice import Die
from .base import Oracle
class ObjectGeneratorProperty: # pylint: disable=too-few-public-methods
"""Property for the Object Generator Oracle"""
def __init__(self, property_data: dict[str, Any]) -> None:
self.question: str | None = None
self.die: Die | None = None
self.results: dict[int, str] = {}
self.parse_and_validate(property_data)
def parse_and_validate(self, property_data: dict[str, Any]) -> None:
"""Parse and validate property data"""
if "question" not in property_data:
raise ValueError("Missing question value")
self.question = property_data["question"]
if "roll-type" not in property_data:
raise ValueError("Missing roll-type value")
self.die = Die(property_data["roll-type"])
if "results" not in property_data:
raise ValueError("Missing results value")
new_results: dict[int, str | None] = {
roll_value: None for roll_value in range(self.die.minimum, self.die.maximum + 1)
}
for idx, result_data in enumerate(property_data["results"]):
if "min-roll" not in result_data:
raise ValueError(f"Missing min-roll value in result {idx}")
min_roll = int(result_data["min-roll"])
max_roll = int(result_data.get("max-roll", min_roll))
if max_roll < min_roll:
raise ValueError(f"max-roll cannot be smaller than min-roll in result {idx}")
if "value" not in result_data:
raise ValueError(f"Missing value value in result {idx}")
for roll_value in range(min_roll, max_roll + 1):
if new_results[roll_value] is not None:
raise ValueError(f"Roll value {roll_value} is already registered in result {idx}")
new_results[roll_value] = result_data["value"]
if any(value is None for value in new_results.values()):
raise ValueError(f"Not all roll values yield a result for property {self.question}")
# We just checked that every member of new_results is a string, so its safe to ignore type checking here
self.results = new_results # type: ignore[assignment]
def generate(self) -> str:
"""Generate a random value for this property"""
assert self.die
value = self.die.roll()
answer = self.results[value]
return f"""*{self.question}*
{answer}"""
class ObjectGeneratorOracle(Oracle):
"""Oracle that can generate objects"""
TYPE_MARKER = "object-generator"
def __init__(self, oracle_data: dict[str, Any]) -> None:
self.properties: list[ObjectGeneratorProperty] = []
super().__init__(oracle_data)
def parse_and_validate(self, oracle_data: dict[str, Any]) -> None:
super().parse_and_validate(oracle_data)
if "properties" not in oracle_data:
raise KeyError("properties")
self.properties = [ObjectGeneratorProperty(prop_data) for prop_data in oracle_data["properties"]]
def generate(self) -> str:
return "\n\n".join(prop.generate() for prop in self.properties)

View File

@ -0,0 +1,180 @@
# SPDX-FileCopyrightText: 2025 2025
# SPDX-FileContributor: Gergely Polonkai
#
# SPDX-License-Identifier: GPL-3.0-or-later
"""Tests for the Object Generator Oracle"""
from copy import deepcopy
from typing import Literal
import pytest
from pytest_mock import MockerFixture
from gm_assistant.dice import Die
from gm_assistant.oracle.object_generator import ObjectGeneratorOracle, ObjectGeneratorProperty
TEST_PROPERTY_DATA = {
"question": "Test Question",
"roll-type": "d6",
"results": [
{"min-roll": 1, "max-roll": 2, "value": "Value 1-2"},
{"min-roll": 3, "value": "Value 3"},
{"min-roll": 4, "max-roll": 6, "value": "Value 4-6"},
],
}
TEST_ORACLE_DATA = {
"name": "Test Oracle",
"source": "Test Source",
"type": "object-generator",
"properties": [
TEST_PROPERTY_DATA,
{
"question": "Second Question",
"roll-type": "d2",
"results": [
{"min-roll": 1, "value": "Value 1"},
{"min-roll": 2, "value": "Value 2"},
],
},
],
}
@pytest.mark.parametrize("missing", ["question", "roll-type", "results", "min-roll", "value"])
def test_property_missing_data(missing: Literal["question", "roll-type", "results", "min-roll", "value"]) -> None:
"""Test initialising a property with missing data"""
prop_data = deepcopy(TEST_PROPERTY_DATA)
if missing in ("min-roll", "value"):
del prop_data["results"][0][missing] # type: ignore[attr-defined]
else:
del prop_data[missing]
with pytest.raises(ValueError) as ctx:
ObjectGeneratorProperty(prop_data)
if missing in ("min-roll", "value"):
assert str(ctx.value) == f"Missing {missing} value in result 0"
else:
assert str(ctx.value) == f"Missing {missing} value"
def test_property_result_max_smaller_than_min() -> None:
"""Test initialising a property with a maximum roll smaller than the minimum roll"""
prop_data = deepcopy(TEST_PROPERTY_DATA)
prop_data["results"][0]["min-roll"] = 3 # type: ignore[index]
with pytest.raises(ValueError) as ctx:
ObjectGeneratorProperty(prop_data)
assert str(ctx.value) == "max-roll cannot be smaller than min-roll in result 0"
def test_property_missing_roll_value() -> None:
"""Test initialising a property with a missing roll value"""
prop_data = deepcopy(TEST_PROPERTY_DATA)
prop_data["results"] = [prop_data["results"][0]]
with pytest.raises(ValueError) as ctx:
ObjectGeneratorProperty(prop_data)
assert str(ctx.value) == "Not all roll values yield a result for property Test Question"
def test_property_multiple_roll_value() -> None:
"""Test initialising a property with a roll value appearing twice"""
prop_data = deepcopy(TEST_PROPERTY_DATA)
prop_data["results"].append({"min-roll": 2, "value": "Second Value"}) # type: ignore[attr-defined]
with pytest.raises(ValueError) as ctx:
ObjectGeneratorProperty(prop_data)
assert str(ctx.value) == "Roll value 2 is already registered in result 3"
def test_property_init(mocker: MockerFixture) -> None:
"""Test initialising a property with valid data"""
mocked_die = Die("d6")
mocked_die_class = mocker.patch("gm_assistant.oracle.object_generator.Die", return_value=mocked_die)
prop = ObjectGeneratorProperty(TEST_PROPERTY_DATA)
assert prop.question == "Test Question"
assert prop.die == mocked_die
assert prop.results == {
1: "Value 1-2",
2: "Value 1-2",
3: "Value 3",
4: "Value 4-6",
5: "Value 4-6",
6: "Value 4-6",
}
mocked_die_class.assert_called_once_with("d6")
def test_property_generate(mocker: MockerFixture) -> None:
"""Test the ObjectGeneratorProperty.generate() method"""
mocked_roll = mocker.patch("gm_assistant.oracle.object_generator.Die.roll", return_value=3)
prop = ObjectGeneratorProperty(TEST_PROPERTY_DATA)
expected_output = """*Test Question*
Value 3"""
assert prop.generate() == expected_output
mocked_roll.assert_called_once_with()
def test_oracle_init_missing_properties() -> None:
"""Test initialising an Oracle with properties missing"""
oracle_data = deepcopy(TEST_ORACLE_DATA)
del oracle_data["properties"]
with pytest.raises(KeyError) as ctx:
ObjectGeneratorOracle(oracle_data)
assert str(ctx.value) == "'properties'"
def test_oracle_init() -> None:
"""Test initialising an Oracle with valid data"""
oracle = ObjectGeneratorOracle(TEST_ORACLE_DATA)
assert oracle.properties[0].question == "Test Question"
assert oracle.properties[1].question == "Second Question"
def test_oracle_generate(mocker: MockerFixture) -> None:
"""Test the Oracles ``generate`` method"""
mocked_die1 = Die("d6")
mocker.patch.object(mocked_die1, "roll", return_value=5)
mocked_die2 = Die("d2")
mocker.patch.object(mocked_die2, "roll", return_value=1)
mocked_die_class = mocker.patch("gm_assistant.oracle.object_generator.Die", side_effect=[mocked_die1, mocked_die2])
oracle = ObjectGeneratorOracle(TEST_ORACLE_DATA)
expected_output = """*Test Question*
Value 4-6
*Second Question*
Value 1"""
assert oracle.generate() == expected_output
assert mocked_die_class.call_count == 2
mocked_die_class.assert_has_calls([mocker.call("d6"), mocker.call("d2")])
# pylint: disable=no-member
mocked_die1.roll.assert_called_once_with() # type: ignore[attr-defined]
mocked_die2.roll.assert_called_once_with() # type: ignore[attr-defined]