1
0
Fork 0

support for temporary and immediate/permanent effects
Run Docker Build / build (push) Failing after 12s Details
Run Python Build / build (push) Failing after 17s Details

This commit is contained in:
Sean Sube 2024-05-23 21:57:21 -05:00
parent 0859bca2fd
commit 2aaf531454
Signed by: ssube
GPG Key ID: 3EED7B957D362AF1
12 changed files with 568 additions and 175 deletions

View File

@ -14,8 +14,9 @@ from adventure.context import (
world_context, world_context,
) )
from adventure.generate import generate_item, generate_room, link_rooms 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.search import find_actor_in_room
from adventure.utils.string import normalize_name
from adventure.utils.world import describe_actor, describe_entity from adventure.utils.world import describe_actor, describe_entity
logger = getLogger(__name__) 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." "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." "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( chosen_effect = next(
( (
search_effect search_effect
for search_effect in action_item.effects for search_effect in action_item.effects
if search_effect.name == chosen_name if normalize_name(search_effect.name) == chosen_name
), ),
None, None,
) )
@ -148,7 +149,11 @@ def action_use(item: str, target: str) -> str:
# TODO: should retry the question if the effect is not found # TODO: should retry the question if the effect is not found
return f"The {chosen_name} effect is not available to apply." 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( broadcast(
f"{action_actor.name} uses the {chosen_name} effect of {item} on {target}" f"{action_actor.name} uses the {chosen_name} effect of {item} on {target}"

View File

@ -4,22 +4,19 @@ from typing import List, Tuple
from packit.agent import Agent from packit.agent import Agent
from packit.loops import loop_retry from packit.loops import loop_retry
from packit.results import enum_result, int_result
from packit.utils import could_be_json from packit.utils import could_be_json
from adventure.context import broadcast, set_current_world from adventure.context import broadcast, set_current_world
from adventure.game_system import GameSystem from adventure.game_system import GameSystem
from adventure.models.config import DEFAULT_CONFIG, WorldConfig from adventure.models.config import DEFAULT_CONFIG, WorldConfig
from adventure.models.entity import ( from adventure.models.effect import (
Actor, EffectPattern,
Effect, FloatEffectPattern,
Item, IntEffectPattern,
NumberAttributeEffect, StringEffectPattern,
Portal,
Room,
StringAttributeEffect,
World,
WorldEntity,
) )
from adventure.models.entity import Actor, Item, Portal, Room, World, WorldEntity
from adventure.models.event import GenerateEvent from adventure.models.event import GenerateEvent
from adventure.utils import try_parse_float, try_parse_int from adventure.utils import try_parse_float, try_parse_int
from adventure.utils.search import ( from adventure.utils.search import (
@ -36,24 +33,6 @@ logger = getLogger(__name__)
world_config: WorldConfig = DEFAULT_CONFIG.world 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 duplicate_name_parser(existing_names: List[str]):
def name_parser(value: str, **kwargs): def name_parser(value: str, **kwargs):
@ -369,7 +348,7 @@ def generate_actor(
return 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 entity_type = entity.type
existing_effects = [effect.name for effect in entity.effects] 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, 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 = [] attributes = []
for attribute_name in attribute_names.split(","): for attribute_name in attribute_names.split(","):
attribute_name = normalize_name(attribute_name) attribute_name = normalize_name(attribute_name)
if 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( value = agent(
f"How much does the {name} effect modify the {attribute_name} attribute? " 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." "For example, heal might add 10 to the health attribute, while poison might remove -5 from it."
f"{operation_prompt}. Do not include any other text. Do not use JSON.", "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, name=name,
attribute_name=attribute_name, attribute_name=attribute_name,
) )
value = value.strip() value = value.strip()
# TODO: support more than just set: offset and multiply
int_value = try_parse_int(value) int_value = try_parse_int(value)
if int_value is not None: if int_value is not None:
attribute_effect = NumberAttributeEffect( attribute_effect = IntEffectPattern(name=attribute_name, set=int_value)
name=attribute_name, operation=operation, value=int_value
)
else: else:
float_value = try_parse_float(value) float_value = try_parse_float(value)
if float_value is not None: if float_value is not None:
attribute_effect = NumberAttributeEffect( attribute_effect = FloatEffectPattern(
name=attribute_name, operation=operation, value=float_value name=attribute_name, set=float_value
) )
else: else:
attribute_effect = StringAttributeEffect( attribute_effect = StringEffectPattern(
name=attribute_name, operation=operation, value=value name=attribute_name, set=value
) )
attributes.append(attribute_effect) 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( def link_rooms(

View File

@ -173,7 +173,10 @@ def load_logic(filename: str):
for rule in logic_rules.rules: for rule in logic_rules.rules:
if rule.trigger: if rule.trigger:
for trigger in 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") logger.info("initialized logic system")
system_simulate = wraps(update_logic)( system_simulate = wraps(update_logic)(

View File

@ -1,4 +1,4 @@
from typing import TYPE_CHECKING from typing import TYPE_CHECKING, Dict
from uuid import uuid4 from uuid import uuid4
if TYPE_CHECKING: if TYPE_CHECKING:
@ -7,6 +7,10 @@ else:
from pydantic.dataclasses import dataclass as dataclass # noqa from pydantic.dataclasses import dataclass as dataclass # noqa
AttributeValue = bool | float | int | str
Attributes = Dict[str, AttributeValue]
class BaseModel: class BaseModel:
type: str type: str
id: str id: str
@ -14,3 +18,17 @@ class BaseModel:
def uuid() -> str: def uuid() -> str:
return uuid4().hex return uuid4().hex
@dataclass
class FloatRange:
min: float
max: float
interval: float = 1.0
@dataclass
class IntRange:
min: int
max: int
interval: int = 1

View File

@ -1,13 +1,6 @@
from typing import Dict, List from typing import Dict, List
from .base import dataclass from .base import IntRange, dataclass
@dataclass
class Range:
min: int
max: int
interval: int = 1
@dataclass @dataclass
@ -29,11 +22,11 @@ class BotConfig:
@dataclass @dataclass
class RenderConfig: class RenderConfig:
cfg: Range cfg: IntRange
checkpoints: List[str] checkpoints: List[str]
path: str path: str
sizes: Dict[str, Size] sizes: Dict[str, Size]
steps: Range steps: IntRange
@dataclass @dataclass
@ -49,12 +42,12 @@ class ServerConfig:
@dataclass @dataclass
class WorldSizeConfig: class WorldSizeConfig:
actor_items: Range actor_items: IntRange
item_effects: Range item_effects: IntRange
portals: Range portals: IntRange
room_actors: Range room_actors: IntRange
room_items: Range room_items: IntRange
rooms: Range rooms: IntRange
@dataclass @dataclass
@ -73,7 +66,7 @@ class Config:
DEFAULT_CONFIG = Config( DEFAULT_CONFIG = Config(
bot=BotConfig(discord=DiscordBotConfig(channels=["adventure"])), bot=BotConfig(discord=DiscordBotConfig(channels=["adventure"])),
render=RenderConfig( render=RenderConfig(
cfg=Range(min=5, max=8), cfg=IntRange(min=5, max=8),
checkpoints=[ checkpoints=[
"diffusion-sdxl-dynavision-0-5-5-7.safetensors", "diffusion-sdxl-dynavision-0-5-5-7.safetensors",
], ],
@ -83,17 +76,17 @@ DEFAULT_CONFIG = Config(
"portrait": Size(width=768, height=1024), "portrait": Size(width=768, height=1024),
"square": Size(width=768, height=768), "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)), server=ServerConfig(websocket=WebsocketServerConfig(host="localhost", port=8001)),
world=WorldConfig( world=WorldConfig(
size=WorldSizeConfig( size=WorldSizeConfig(
actor_items=Range(min=0, max=2), actor_items=IntRange(min=0, max=2),
item_effects=Range(min=1, max=2), item_effects=IntRange(min=1, max=2),
portals=Range(min=1, max=3), portals=IntRange(min=1, max=3),
rooms=Range(min=3, max=6), rooms=IntRange(min=3, max=6),
room_actors=Range(min=1, max=3), room_actors=IntRange(min=1, max=3),
room_items=Range(min=1, max=3), room_items=IntRange(min=1, max=3),
) )
), ),
) )

102
adventure/models/effect.py Normal file
View File

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

View File

@ -2,45 +2,10 @@ from typing import Callable, Dict, List, Literal
from pydantic import Field 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] 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 @dataclass
@ -48,8 +13,9 @@ class Item(BaseModel):
name: str name: str
description: str description: str
actions: Actions = Field(default_factory=dict) actions: Actions = Field(default_factory=dict)
active_effects: List[EffectResult] = Field(default_factory=list)
attributes: Attributes = Field(default_factory=dict) 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) items: List["Item"] = Field(default_factory=list)
id: str = Field(default_factory=uuid) id: str = Field(default_factory=uuid)
type: Literal["item"] = "item" type: Literal["item"] = "item"
@ -61,6 +27,7 @@ class Actor(BaseModel):
backstory: str backstory: str
description: str description: str
actions: Actions = Field(default_factory=dict) actions: Actions = Field(default_factory=dict)
active_effects: List[EffectResult] = Field(default_factory=list)
attributes: Attributes = Field(default_factory=dict) attributes: Attributes = Field(default_factory=dict)
items: List[Item] = Field(default_factory=list) items: List[Item] = Field(default_factory=list)
id: str = Field(default_factory=uuid) id: str = Field(default_factory=uuid)
@ -84,6 +51,7 @@ class Room(BaseModel):
description: str description: str
actors: List[Actor] = Field(default_factory=list) actors: List[Actor] = Field(default_factory=list)
actions: Actions = Field(default_factory=dict) actions: Actions = Field(default_factory=dict)
active_effects: List[EffectResult] = Field(default_factory=list)
attributes: Attributes = Field(default_factory=dict) attributes: Attributes = Field(default_factory=dict)
items: List[Item] = Field(default_factory=list) items: List[Item] = Field(default_factory=list)
portals: List[Portal] = Field(default_factory=list) portals: List[Portal] = Field(default_factory=list)

View File

@ -117,7 +117,7 @@ def scene_from_event(event: GameEvent) -> str | None:
def scene_from_entity(entity: WorldEntity) -> str: def scene_from_entity(entity: WorldEntity) -> str:
logger.debug("generating scene from entity: %s", entity) 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]: 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." "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. " "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. " "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.", "Do not include the question or any JSON. Only include the list of keywords on a single line.",
examples=example_prompts, examples=example_prompts,
scene=scene, scene=scene,

View File

@ -31,6 +31,7 @@ from adventure.context import (
from adventure.game_system import GameSystem from adventure.game_system import GameSystem
from adventure.models.entity import World from adventure.models.entity import World
from adventure.models.event import ActionEvent, ReplyEvent, ResultEvent 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.search import find_room_with_actor
from adventure.utils.world import describe_entity, format_attributes 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") logger.error(f"Actor {actor_name} is not in a room")
continue 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_actors = [actor.name for actor in room.actors]
room_items = [item.name for item in room.items] room_items = [item.name for item in room.items]
room_directions = [portal.name for portal in room.portals] room_directions = [portal.name for portal in room.portals]
@ -104,6 +115,7 @@ def simulate_world(
actor_attributes = format_attributes(actor) actor_attributes = format_attributes(actor)
actor_items = [item.name for item in actor.items] actor_items = [item.name for item in actor.items]
# set up a result parser for the agent
def result_parser(value, agent, **kwargs): def result_parser(value, agent, **kwargs):
if not room or not actor: if not room or not actor:
raise ValueError( raise ValueError(
@ -119,6 +131,7 @@ def simulate_world(
return world_result_parser(value, agent, **kwargs) return world_result_parser(value, agent, **kwargs)
# prompt and act
logger.info("starting turn for actor: %s", actor_name) logger.info("starting turn for actor: %s", actor_name)
result = loop_retry( result = loop_retry(
agent, agent,

View File

@ -22,14 +22,14 @@ rules:
type: room type: room
temperature: hot temperature: hot
chance: 0.2 chance: 0.2
trigger: [adventure.sim_systems.environment_triggers:hot_room] trigger: [adventure.systems.sim.environment_triggers:hot_room]
- group: environment-temperature - group: environment-temperature
match: match:
type: room type: room
temperature: cold temperature: cold
chance: 0.2 chance: 0.2
trigger: [adventure.sim_systems.environment_triggers:cold_room] trigger: [adventure.systems.sim.environment_triggers:cold_room]
labels: labels:
- match: - match:

View File

@ -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: 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: if name in attributes:
previous_value = attributes[name] previous_value = attributes[name]
if isinstance(previous_value, str): attributes[name] = add_value(previous_value, value)
raise ValueError(f"Cannot add a number to a string attribute: {name}")
attributes[name] = value + previous_value
else: else:
attributes[name] = value attributes[name] = value

View File

@ -1,47 +1,314 @@
import random
from logging import getLogger
from typing import List 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 ( from adventure.utils.attribute import (
add_attribute, add_value,
append_attribute, append_value,
divide_attribute, multiply_value,
multiply_attribute, prepend_value,
prepend_attribute,
set_attribute,
subtract_attribute,
) )
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. Apply an effect to a set of attributes.
""" """
attributes = base_attributes.copy()
for effect in effects:
for attribute in effect.attributes: for attribute in effect.attributes:
if attribute.operation == "set": if isinstance(attribute, BooleanEffectResult):
set_attribute(attributes, attribute.name, attribute.value) attributes[attribute.name] = effective_boolean(attributes, attribute)
elif attribute.operation == "add": elif isinstance(attribute, FloatEffectResult):
add_attribute(attributes, attribute.name, attribute.value) attributes[attribute.name] = effective_float(attributes, attribute)
elif attribute.operation == "subtract": elif isinstance(attribute, IntEffectResult):
subtract_attribute(attributes, attribute.name, attribute.value) attributes[attribute.name] = effective_int(attributes, attribute)
elif attribute.operation == "multiply": elif isinstance(attribute, StringEffectResult):
multiply_attribute(attributes, attribute.name, attribute.value) attributes[attribute.name] = effective_string(attributes, attribute)
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: else:
raise ValueError(f"Invalid operation: {attribute.operation}") raise ValueError(f"Invalid operation: {attribute.operation}")
return attributes return attributes
def apply_effects(effects: List[Effect], attributes: Attributes) -> Attributes: def resolve_float_range(range: float | FloatRange | None) -> float | None:
""" """
Apply a list of effects to a set of attributes. 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: for effect in effects:
attributes = apply_effect(effect, attributes) 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 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)