From 2aaf5314547aee34b676dd6012354d9c91a1ae76 Mon Sep 17 00:00:00 2001 From: Sean Sube Date: Thu, 23 May 2024 21:57:21 -0500 Subject: [PATCH] support for temporary and immediate/permanent effects --- adventure/actions/optional.py | 13 +- adventure/generate.py | 124 ++++--- adventure/logic.py | 5 +- adventure/models/base.py | 20 +- adventure/models/config.py | 41 +-- adventure/models/effect.py | 102 ++++++ adventure/models/entity.py | 44 +-- adventure/render/prompt.py | 3 +- adventure/simulate.py | 13 + adventure/systems/sim/environment_logic.yaml | 4 +- adventure/utils/attribute.py | 41 ++- adventure/utils/effect.py | 333 +++++++++++++++++-- 12 files changed, 568 insertions(+), 175 deletions(-) create mode 100644 adventure/models/effect.py diff --git a/adventure/actions/optional.py b/adventure/actions/optional.py index 7d0025c..dcb9019 100644 --- a/adventure/actions/optional.py +++ b/adventure/actions/optional.py @@ -14,8 +14,9 @@ from adventure.context import ( world_context, ) from adventure.generate import generate_item, generate_room, link_rooms -from adventure.utils.effect import apply_effect +from adventure.utils.effect import apply_effects from adventure.utils.search import find_actor_in_room +from adventure.utils.string import normalize_name from adventure.utils.world import describe_actor, describe_entity logger = getLogger(__name__) @@ -134,13 +135,13 @@ def action_use(item: str, target: str) -> str: "Which effect should be applied? Specify the name of the effect to apply." "Do not include the question or any JSON. Only include the name of the effect to apply." ) - chosen_name = chosen_name.strip() + chosen_name = normalize_name(chosen_name) chosen_effect = next( ( search_effect for search_effect in action_item.effects - if search_effect.name == chosen_name + if normalize_name(search_effect.name) == chosen_name ), None, ) @@ -148,7 +149,11 @@ def action_use(item: str, target: str) -> str: # TODO: should retry the question if the effect is not found return f"The {chosen_name} effect is not available to apply." - apply_effect(chosen_effect, target_actor.attributes) + try: + apply_effects(target_actor, [chosen_effect]) + except Exception: + logger.exception("error applying effect: %s", chosen_effect) + return f"There was a problem applying the {chosen_name} effect." broadcast( f"{action_actor.name} uses the {chosen_name} effect of {item} on {target}" diff --git a/adventure/generate.py b/adventure/generate.py index 0c3afeb..65dd4aa 100644 --- a/adventure/generate.py +++ b/adventure/generate.py @@ -4,22 +4,19 @@ from typing import List, Tuple from packit.agent import Agent from packit.loops import loop_retry +from packit.results import enum_result, int_result from packit.utils import could_be_json from adventure.context import broadcast, set_current_world from adventure.game_system import GameSystem from adventure.models.config import DEFAULT_CONFIG, WorldConfig -from adventure.models.entity import ( - Actor, - Effect, - Item, - NumberAttributeEffect, - Portal, - Room, - StringAttributeEffect, - World, - WorldEntity, +from adventure.models.effect import ( + EffectPattern, + FloatEffectPattern, + IntEffectPattern, + StringEffectPattern, ) +from adventure.models.entity import Actor, Item, Portal, Room, World, WorldEntity from adventure.models.event import GenerateEvent from adventure.utils import try_parse_float, try_parse_int from adventure.utils.search import ( @@ -36,24 +33,6 @@ logger = getLogger(__name__) world_config: WorldConfig = DEFAULT_CONFIG.world -PROMPT_TYPE_FRAGMENTS = { - "both": "Enter a positive or negative number, or a string value", - "number": "Enter a positive or negative number", - "string": "Enter a string value", -} - -PROMPT_OPERATION_TYPES = { - "set": "both", - "add": "number", - "subtract": "number", - "multiply": "number", - "divide": "number", - "append": "string", - "prepend": "string", -} - -OPERATIONS = list(PROMPT_OPERATION_TYPES.keys()) - def duplicate_name_parser(existing_names: List[str]): def name_parser(value: str, **kwargs): @@ -369,7 +348,7 @@ def generate_actor( return actor -def generate_effect(agent: Agent, world: World, entity: Item) -> Effect: +def generate_effect(agent: Agent, world: World, entity: Item) -> EffectPattern: entity_type = entity.type existing_effects = [effect.name for effect in entity.effects] @@ -405,65 +384,78 @@ def generate_effect(agent: Agent, world: World, entity: Item) -> Effect: name=name, ) - def operation_parser(value: str, **kwargs): - if value not in OPERATIONS: - raise ValueError( - f'"{value}" is not a valid operation. Choose from: {OPERATIONS}' - ) - - return value - attributes = [] for attribute_name in attribute_names.split(","): attribute_name = normalize_name(attribute_name) if attribute_name: - operation = loop_retry( - agent, - f"How does the {name} effect modify the {attribute_name} attribute? " - "For example, 'heal' might 'add' to the 'health' attribute, while 'poison' might 'subtract' from it." - "Another example is 'writing' might 'set' the 'text' attribute, while 'break' might 'set' the 'condition' attribute." - "Reply with the operation only, without any other text. Respond with a single word for the list of operations." - "Choose from the following operations: {operations}", - context={ - "name": name, - "attribute_name": attribute_name, - "operations": OPERATIONS, - }, - result_parser=operation_parser, - toolbox=None, - ) - - operation_type = PROMPT_OPERATION_TYPES[operation] - operation_prompt = PROMPT_TYPE_FRAGMENTS[operation_type] - value = agent( f"How much does the {name} effect modify the {attribute_name} attribute? " - "For example, heal might add '10' to the health attribute, while poison might subtract '5' from it." - f"{operation_prompt}. Do not include any other text. Do not use JSON.", + "For example, heal might add 10 to the health attribute, while poison might remove -5 from it." + "Enter a positive number to increase the attribute or a negative number to decrease it. " + "Do not include any other text. Do not use JSON.", name=name, attribute_name=attribute_name, ) value = value.strip() + # TODO: support more than just set: offset and multiply int_value = try_parse_int(value) if int_value is not None: - attribute_effect = NumberAttributeEffect( - name=attribute_name, operation=operation, value=int_value - ) + attribute_effect = IntEffectPattern(name=attribute_name, set=int_value) else: float_value = try_parse_float(value) if float_value is not None: - attribute_effect = NumberAttributeEffect( - name=attribute_name, operation=operation, value=float_value + attribute_effect = FloatEffectPattern( + name=attribute_name, set=float_value ) else: - attribute_effect = StringAttributeEffect( - name=attribute_name, operation=operation, value=value + attribute_effect = StringEffectPattern( + name=attribute_name, set=value ) attributes.append(attribute_effect) - return Effect(name=name, description=description, attributes=attributes) + duration = loop_retry( + agent, + f"How many turns does the {name} effect last? Enter a positive number to set a duration, or 0 for an instant effect. " + "Do not include any other text. Do not use JSON.", + context={ + "name": name, + }, + result_parser=int_result, + ) + + def parse_application(value: str, **kwargs) -> str: + value = enum_result(value, ["temporary", "permanent"]) + if value: + return value + + raise ValueError("The application must be 'temporary' or 'permanent'.") + + application = loop_retry( + agent, + ( + f"How should the {name} effect be applied? Respond with 'temporary' for a temporary effect that lasts for a duration, " + "or 'permanent' for a permanent effect that immediately modifies the target. " + "For example, a healing potion would be a permanent effect that increases health every turn, " + "while bleeding would be a temporary effect that decreases health every turn. " + "A haste potion would be a temporary effect that increases speed for a duration, " + "while a slow spell would be a temporary effect that decreases speed for a duration. " + "Do not include any other text. Do not use JSON." + ), + context={ + "name": name, + }, + result_parser=parse_application, + ) + + return EffectPattern( + name, + description, + application, + duration=duration, + attributes=attributes, + ) def link_rooms( diff --git a/adventure/logic.py b/adventure/logic.py index 4fdc9f4..404315a 100644 --- a/adventure/logic.py +++ b/adventure/logic.py @@ -173,7 +173,10 @@ def load_logic(filename: str): for rule in logic_rules.rules: if rule.trigger: for trigger in rule.trigger: - logic_triggers[trigger] = get_plugin_function(trigger) + function_name = ( + trigger if isinstance(trigger, str) else trigger.function + ) + logic_triggers[trigger] = get_plugin_function(function_name) logger.info("initialized logic system") system_simulate = wraps(update_logic)( diff --git a/adventure/models/base.py b/adventure/models/base.py index 179f397..eb6ad1d 100644 --- a/adventure/models/base.py +++ b/adventure/models/base.py @@ -1,4 +1,4 @@ -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Dict from uuid import uuid4 if TYPE_CHECKING: @@ -7,6 +7,10 @@ else: from pydantic.dataclasses import dataclass as dataclass # noqa +AttributeValue = bool | float | int | str +Attributes = Dict[str, AttributeValue] + + class BaseModel: type: str id: str @@ -14,3 +18,17 @@ class BaseModel: def uuid() -> str: return uuid4().hex + + +@dataclass +class FloatRange: + min: float + max: float + interval: float = 1.0 + + +@dataclass +class IntRange: + min: int + max: int + interval: int = 1 diff --git a/adventure/models/config.py b/adventure/models/config.py index 4afb552..40f5174 100644 --- a/adventure/models/config.py +++ b/adventure/models/config.py @@ -1,13 +1,6 @@ from typing import Dict, List -from .base import dataclass - - -@dataclass -class Range: - min: int - max: int - interval: int = 1 +from .base import IntRange, dataclass @dataclass @@ -29,11 +22,11 @@ class BotConfig: @dataclass class RenderConfig: - cfg: Range + cfg: IntRange checkpoints: List[str] path: str sizes: Dict[str, Size] - steps: Range + steps: IntRange @dataclass @@ -49,12 +42,12 @@ class ServerConfig: @dataclass class WorldSizeConfig: - actor_items: Range - item_effects: Range - portals: Range - room_actors: Range - room_items: Range - rooms: Range + actor_items: IntRange + item_effects: IntRange + portals: IntRange + room_actors: IntRange + room_items: IntRange + rooms: IntRange @dataclass @@ -73,7 +66,7 @@ class Config: DEFAULT_CONFIG = Config( bot=BotConfig(discord=DiscordBotConfig(channels=["adventure"])), render=RenderConfig( - cfg=Range(min=5, max=8), + cfg=IntRange(min=5, max=8), checkpoints=[ "diffusion-sdxl-dynavision-0-5-5-7.safetensors", ], @@ -83,17 +76,17 @@ DEFAULT_CONFIG = Config( "portrait": Size(width=768, height=1024), "square": Size(width=768, height=768), }, - steps=Range(min=30, max=30), + steps=IntRange(min=30, max=30), ), server=ServerConfig(websocket=WebsocketServerConfig(host="localhost", port=8001)), world=WorldConfig( size=WorldSizeConfig( - actor_items=Range(min=0, max=2), - item_effects=Range(min=1, max=2), - portals=Range(min=1, max=3), - rooms=Range(min=3, max=6), - room_actors=Range(min=1, max=3), - room_items=Range(min=1, max=3), + actor_items=IntRange(min=0, max=2), + item_effects=IntRange(min=1, max=2), + portals=IntRange(min=1, max=3), + rooms=IntRange(min=3, max=6), + room_actors=IntRange(min=1, max=3), + room_items=IntRange(min=1, max=3), ) ), ) diff --git a/adventure/models/effect.py b/adventure/models/effect.py new file mode 100644 index 0000000..a1f8e42 --- /dev/null +++ b/adventure/models/effect.py @@ -0,0 +1,102 @@ +from typing import List, Literal + +from pydantic import Field + +from .base import FloatRange, IntRange, dataclass, uuid + + +@dataclass +class StringEffectPattern: + name: str + append: str | List[str] | None = None + prepend: str | List[str] | None = None + set: str | List[str] | None = None + + +@dataclass +class FloatEffectPattern: + name: str + set: float | FloatRange | None = None + offset: float | FloatRange | None = None + multiply: float | FloatRange | None = None + + +@dataclass +class IntEffectPattern: + name: str + set: int | IntRange | None = None + offset: int | IntRange | None = None + multiply: float | FloatRange | None = None + + +@dataclass +class BooleanEffectPattern: + name: str + set: bool | None = None + toggle: bool | None = None + + +AttributeEffectPattern = ( + StringEffectPattern | FloatEffectPattern | IntEffectPattern | BooleanEffectPattern +) + + +@dataclass +class EffectPattern: + """ + TODO: should this be an EffectTemplate? + """ + + name: str + description: str + application: Literal["permanent", "temporary"] + duration: int | IntRange | None = None + attributes: List[AttributeEffectPattern] = Field(default_factory=list) + id: str = Field(default_factory=uuid) + type: Literal["effect_pattern"] = "effect_pattern" + + +@dataclass +class BooleanEffectResult: + name: str + set: bool | None = None + toggle: bool | None = None + + +@dataclass +class FloatEffectResult: + name: str + set: float | None = None + offset: float | None = None + multiply: float | None = None + + +@dataclass +class IntEffectResult: + name: str + set: int | None = None + offset: int | None = None + multiply: float | None = None # still needs to be a float for decimals/division + + +@dataclass +class StringEffectResult: + name: str + append: str | None = None + prepend: str | None = None + set: str | None = None + + +AttributeEffectResult = ( + BooleanEffectResult | FloatEffectResult | IntEffectResult | StringEffectResult +) + + +@dataclass +class EffectResult: + name: str + description: str + duration: int | None = None + attributes: List[AttributeEffectResult] = Field(default_factory=list) + id: str = Field(default_factory=uuid) + type: Literal["effect_result"] = "effect_result" diff --git a/adventure/models/entity.py b/adventure/models/entity.py index bb9d8c0..0742e14 100644 --- a/adventure/models/entity.py +++ b/adventure/models/entity.py @@ -2,45 +2,10 @@ from typing import Callable, Dict, List, Literal from pydantic import Field -from .base import BaseModel, dataclass, uuid +from .base import Attributes, BaseModel, dataclass, uuid +from .effect import EffectPattern, EffectResult Actions = Dict[str, Callable] -AttributeValue = bool | float | int | str -Attributes = Dict[str, AttributeValue] - - -@dataclass -class StringAttributeEffect: - name: str - operation: Literal["set", "append", "prepend"] - value: str - - -@dataclass -class NumberAttributeEffect: - name: str - operation: Literal["set", "add", "subtract", "multiply", "divide"] - # TODO: make this a range - value: int | float - - -@dataclass -class BooleanAttributeEffect: - name: str - operation: Literal["set", "toggle"] - value: bool - - -AttributeEffect = StringAttributeEffect | NumberAttributeEffect | BooleanAttributeEffect - - -@dataclass -class Effect(BaseModel): - name: str - description: str - attributes: list[AttributeEffect] = Field(default_factory=list) - id: str = Field(default_factory=uuid) - type: Literal["effect"] = "effect" @dataclass @@ -48,8 +13,9 @@ class Item(BaseModel): name: str description: str actions: Actions = Field(default_factory=dict) + active_effects: List[EffectResult] = Field(default_factory=list) attributes: Attributes = Field(default_factory=dict) - effects: List[Effect] = Field(default_factory=list) + effects: List[EffectPattern] = Field(default_factory=list) items: List["Item"] = Field(default_factory=list) id: str = Field(default_factory=uuid) type: Literal["item"] = "item" @@ -61,6 +27,7 @@ class Actor(BaseModel): backstory: str description: str actions: Actions = Field(default_factory=dict) + active_effects: List[EffectResult] = Field(default_factory=list) attributes: Attributes = Field(default_factory=dict) items: List[Item] = Field(default_factory=list) id: str = Field(default_factory=uuid) @@ -84,6 +51,7 @@ class Room(BaseModel): description: str actors: List[Actor] = Field(default_factory=list) actions: Actions = Field(default_factory=dict) + active_effects: List[EffectResult] = Field(default_factory=list) attributes: Attributes = Field(default_factory=dict) items: List[Item] = Field(default_factory=list) portals: List[Portal] = Field(default_factory=list) diff --git a/adventure/render/prompt.py b/adventure/render/prompt.py index be6bd07..118ec03 100644 --- a/adventure/render/prompt.py +++ b/adventure/render/prompt.py @@ -117,7 +117,7 @@ def scene_from_event(event: GameEvent) -> str | None: def scene_from_entity(entity: WorldEntity) -> str: logger.debug("generating scene from entity: %s", entity) - return f"Describe the {entity.type} called {entity.name}. {describe_entity(entity)}" + return f"Describe the {entity.type} named {entity.name} in vivid, visual terms. {describe_entity(entity)}" def make_example_prompts(keywords: List[str], k=5, q=10) -> List[str]: @@ -162,6 +162,7 @@ def generate_prompt_from_scene(scene: str, example_prompts: List[str]) -> str: "Reply with a comma-separated list of keywords that summarize the visual details of the scene." "Make sure you describe the location, all of the characters, and any items present using keywords and phrases. " "Be creative with the details. Avoid using proper nouns or character names. Describe any actions being taken. " + "Describe the characters first, then the location, then the other visual details and general atmosphere. " "Do not include the question or any JSON. Only include the list of keywords on a single line.", examples=example_prompts, scene=scene, diff --git a/adventure/simulate.py b/adventure/simulate.py index e51069f..19af9b6 100644 --- a/adventure/simulate.py +++ b/adventure/simulate.py @@ -31,6 +31,7 @@ from adventure.context import ( from adventure.game_system import GameSystem from adventure.models.entity import World from adventure.models.event import ActionEvent, ReplyEvent, ResultEvent +from adventure.utils.effect import is_active_effect from adventure.utils.search import find_room_with_actor from adventure.utils.world import describe_entity, format_attributes @@ -97,6 +98,16 @@ def simulate_world( logger.error(f"Actor {actor_name} is not in a room") continue + # decrement effects on the actor and remove any that have expired + for effect in actor.active_effects: + if effect.duration is not None: + effect.duration -= 1 + + actor.active_effects[:] = [ + effect for effect in actor.active_effects if is_active_effect(effect) + ] + + # collect data for the prompt room_actors = [actor.name for actor in room.actors] room_items = [item.name for item in room.items] room_directions = [portal.name for portal in room.portals] @@ -104,6 +115,7 @@ def simulate_world( actor_attributes = format_attributes(actor) actor_items = [item.name for item in actor.items] + # set up a result parser for the agent def result_parser(value, agent, **kwargs): if not room or not actor: raise ValueError( @@ -119,6 +131,7 @@ def simulate_world( return world_result_parser(value, agent, **kwargs) + # prompt and act logger.info("starting turn for actor: %s", actor_name) result = loop_retry( agent, diff --git a/adventure/systems/sim/environment_logic.yaml b/adventure/systems/sim/environment_logic.yaml index 4e2c661..8f12868 100644 --- a/adventure/systems/sim/environment_logic.yaml +++ b/adventure/systems/sim/environment_logic.yaml @@ -22,14 +22,14 @@ rules: type: room temperature: hot chance: 0.2 - trigger: [adventure.sim_systems.environment_triggers:hot_room] + trigger: [adventure.systems.sim.environment_triggers:hot_room] - group: environment-temperature match: type: room temperature: cold chance: 0.2 - trigger: [adventure.sim_systems.environment_triggers:cold_room] + trigger: [adventure.systems.sim.environment_triggers:cold_room] labels: - match: diff --git a/adventure/utils/attribute.py b/adventure/utils/attribute.py index 2834ed9..026b025 100644 --- a/adventure/utils/attribute.py +++ b/adventure/utils/attribute.py @@ -1,4 +1,38 @@ -from adventure.models.entity import Attributes, AttributeValue +from adventure.models.base import Attributes, AttributeValue + + +def add_value(value: AttributeValue, offset: int | float) -> AttributeValue: + """ + Add an offset to a value. + """ + if isinstance(value, str): + raise ValueError(f"Cannot add a number to a string attribute: {value}") + + return value + offset + + +def multiply_value(value: AttributeValue, factor: int | float) -> AttributeValue: + """ + Multiply a value by a factor. + """ + if isinstance(value, str): + raise ValueError(f"Cannot multiply a string attribute: {value}") + + return value * factor + + +def append_value(value: AttributeValue, suffix: str) -> str: + """ + Append a suffix to a string. + """ + return str(value) + suffix + + +def prepend_value(value: AttributeValue, prefix: str) -> str: + """ + Prepend a prefix to a string. + """ + return prefix + str(value) def add_attribute(attributes: Attributes, name: str, value: int | float) -> Attributes: @@ -7,10 +41,7 @@ def add_attribute(attributes: Attributes, name: str, value: int | float) -> Attr """ if name in attributes: previous_value = attributes[name] - if isinstance(previous_value, str): - raise ValueError(f"Cannot add a number to a string attribute: {name}") - - attributes[name] = value + previous_value + attributes[name] = add_value(previous_value, value) else: attributes[name] = value diff --git a/adventure/utils/effect.py b/adventure/utils/effect.py index 8557fdb..4bac0af 100644 --- a/adventure/utils/effect.py +++ b/adventure/utils/effect.py @@ -1,47 +1,314 @@ +import random +from logging import getLogger from typing import List -from adventure.models.entity import Attributes, Effect +from adventure.models.base import FloatRange, IntRange +from adventure.models.effect import ( + BooleanEffectPattern, + BooleanEffectResult, + EffectPattern, + EffectResult, + FloatEffectPattern, + FloatEffectResult, + IntEffectPattern, + IntEffectResult, + StringEffectPattern, + StringEffectResult, +) +from adventure.models.entity import Actor, Attributes from adventure.utils.attribute import ( - add_attribute, - append_attribute, - divide_attribute, - multiply_attribute, - prepend_attribute, - set_attribute, - subtract_attribute, + add_value, + append_value, + multiply_value, + prepend_value, ) +logger = getLogger(__name__) -def apply_effect(effect: Effect, attributes: Attributes) -> Attributes: + +def effective_boolean(attributes: Attributes, effect: BooleanEffectResult) -> bool: + """ + Apply a boolean effect to a value. + """ + + if effect.set is not None: + return effect.set + + value = attributes.get(effect.name, False) + + if effect.toggle is not None: + return not value + + return bool(value) + + +def effective_float(attributes: Attributes, effect: FloatEffectResult) -> float: + """ + Apply a float effect to a value. + """ + + if effect.set is not None: + return effect.set + + value = attributes.get(effect.name, 0.0) + + if effect.offset is not None: + value = add_value(value, effect.offset) + + if effect.multiply is not None: + value = multiply_value(value, effect.multiply) + + return float(value) + + +def effective_int(attributes: Attributes, effect: IntEffectResult) -> int: + """ + Apply an integer effect to a value. + """ + + if effect.set is not None: + return effect.set + + value = attributes.get(effect.name, 0) + + if effect.offset is not None: + value = add_value(value, effect.offset) + + if effect.multiply is not None: + value = multiply_value(value, effect.multiply) + + return int(value) + + +def effective_string(attributes: Attributes, effect: StringEffectResult) -> str: + """ + Apply a string effect to a value. + """ + + if effect.set: + return effect.set + + value = attributes.get(effect.name, "") + + if effect.append: + value = append_value(value, effect.append) + + if effect.prepend: + value = prepend_value(value, effect.prepend) + + return str(value) + + +def effective_attributes( + effects: List[EffectResult], base_attributes: Attributes +) -> Attributes: """ Apply an effect to a set of attributes. """ - for attribute in effect.attributes: - if attribute.operation == "set": - set_attribute(attributes, attribute.name, attribute.value) - elif attribute.operation == "add": - add_attribute(attributes, attribute.name, attribute.value) - elif attribute.operation == "subtract": - subtract_attribute(attributes, attribute.name, attribute.value) - elif attribute.operation == "multiply": - multiply_attribute(attributes, attribute.name, attribute.value) - elif attribute.operation == "divide": - divide_attribute(attributes, attribute.name, attribute.value) - elif attribute.operation == "append": - append_attribute(attributes, attribute.name, attribute.value) - elif attribute.operation == "prepend": - prepend_attribute(attributes, attribute.name, attribute.value) - else: - raise ValueError(f"Invalid operation: {attribute.operation}") - return attributes + attributes = base_attributes.copy() - -def apply_effects(effects: List[Effect], attributes: Attributes) -> Attributes: - """ - Apply a list of effects to a set of attributes. - """ for effect in effects: - attributes = apply_effect(effect, attributes) + for attribute in effect.attributes: + if isinstance(attribute, BooleanEffectResult): + attributes[attribute.name] = effective_boolean(attributes, attribute) + elif isinstance(attribute, FloatEffectResult): + attributes[attribute.name] = effective_float(attributes, attribute) + elif isinstance(attribute, IntEffectResult): + attributes[attribute.name] = effective_int(attributes, attribute) + elif isinstance(attribute, StringEffectResult): + attributes[attribute.name] = effective_string(attributes, attribute) + else: + raise ValueError(f"Invalid operation: {attribute.operation}") return attributes + + +def resolve_float_range(range: float | FloatRange | None) -> float | None: + """ + Resolve a float range to a single value. + """ + + if range is None: + return None + + if isinstance( + range, (float, int) + ): # int is not really necessary here, but mypy complains without it + return range + + return random.uniform(range.min, range.max) + + +def resolve_int_range(range: int | IntRange | None) -> int | None: + """ + Resolve an integer range to a single value. + """ + + if range is None: + return None + + if isinstance(range, int): + return range + + return random.randint(range.min, range.max) + + +def resolve_string_list(result: str | List[str] | None) -> str | None: + """ + Resolve a string result to a single value. + """ + + if result is None: + return None + + if isinstance(result, str): + return result + + return random.choice(result) + + +def resolve_boolean_effect(effect: BooleanEffectPattern) -> BooleanEffectResult: + """ + Apply a boolean effect pattern to a set of attributes. + """ + + return BooleanEffectResult( + name=effect.name, + set=effect.set, + toggle=effect.toggle, + ) + + +def resolve_float_effect(effect: FloatEffectPattern) -> FloatEffectResult: + """ + Apply a float effect pattern to a set of attributes. + """ + + return FloatEffectResult( + name=effect.name, + set=resolve_float_range(effect.set), + offset=resolve_float_range(effect.offset), + multiply=resolve_float_range(effect.multiply), + ) + + +def resolve_int_effect(effect: IntEffectPattern) -> IntEffectResult: + """ + Apply an integer effect pattern to a set of attributes. + """ + + return IntEffectResult( + name=effect.name, + set=resolve_int_range(effect.set), + offset=resolve_int_range(effect.offset), + multiply=resolve_float_range(effect.multiply), + ) + + +def resolve_string_effect(effect: StringEffectPattern) -> StringEffectResult: + """ + Apply a string effect pattern to a set of attributes. + """ + + return StringEffectResult( + name=effect.name, + set=resolve_string_list(effect.set), + append=resolve_string_list(effect.append), + prepend=resolve_string_list(effect.prepend), + ) + + +def resolve_effects(effects: List[EffectPattern]) -> List[EffectResult]: + """ + Generate results for a set of effect patterns, rolling all of the random values. + """ + + results = [] + + for effect in effects: + attributes = [] + for attribute in effect.attributes: + if isinstance(attribute, BooleanEffectPattern): + result = resolve_boolean_effect(attribute) + elif isinstance(attribute, FloatEffectPattern): + result = resolve_float_effect(attribute) + elif isinstance(attribute, IntEffectPattern): + result = resolve_int_effect(attribute) + elif isinstance(attribute, StringEffectPattern): + result = resolve_string_effect(attribute) + else: + raise ValueError(f"Invalid operation: {attribute.operation}") + attributes.append(result) + + duration = resolve_int_range(effect.duration) + + result = EffectResult( + name=effect.name, + description=effect.description, + duration=duration, + attributes=attributes, + ) + results.append(result) + + return results + + +def is_active_effect(effect: EffectResult) -> bool: + """ + Determine if an effect is active. + """ + + return effect.duration is None or effect.duration > 0 + + +def apply_permanent_results( + attributes: Attributes, effects: List[EffectResult] +) -> Attributes: + """ + Permanently apply a set of effects to a set of attributes. + """ + + for effect in effects: + for attribute in effect.attributes: + if isinstance(attribute, BooleanEffectResult): + attributes[attribute.name] = effective_boolean(attributes, attribute) + elif isinstance(attribute, FloatEffectResult): + attributes[attribute.name] = effective_float(attributes, attribute) + elif isinstance(attribute, IntEffectResult): + attributes[attribute.name] = effective_int(attributes, attribute) + elif isinstance(attribute, StringEffectResult): + attributes[attribute.name] = effective_string(attributes, attribute) + else: + raise ValueError(f"Invalid operation: {attribute.operation}") + + return attributes + + +def apply_permanent_effects( + attributes: Attributes, effects: List[EffectPattern] +) -> Attributes: + """ + Permanently apply a set of effects to a set of attributes. + """ + + results = resolve_effects(effects) + return apply_permanent_results(attributes, results) + + +def apply_effects(target: Actor, effects: List[EffectPattern]) -> None: + """ + Apply a set of effects to a set of attributes. + """ + + permanent_effects = [ + effect for effect in effects if effect.application == "permanent" + ] + permanent_effects = resolve_effects(permanent_effects) + target.attributes = apply_permanent_results(target.attributes, permanent_effects) + + temporary_effects = [ + effect for effect in effects if effect.application == "temporary" + ] + temporary_effects = resolve_effects(temporary_effects) + target.active_effects.extend(temporary_effects)