diff --git a/adventure/actions/base.py b/adventure/actions/base.py index 86d174d..cde29d7 100644 --- a/adventure/actions/base.py +++ b/adventure/actions/base.py @@ -7,6 +7,7 @@ from adventure.context import ( get_agent_for_actor, world_context, ) +from adventure.errors import ActionError from adventure.utils.conversation import loop_conversation from adventure.utils.search import ( find_actor_in_room, @@ -79,11 +80,11 @@ def action_move(direction: str) -> str: None, ) if not portal: - return f"You cannot move {direction} from here." + raise ActionError(f"You cannot move {direction} from here.") destination_room = find_room(action_world, portal.destination) if not destination_room: - return f"The {portal.destination} room does not exist." + raise ActionError(f"The {portal.destination} room does not exist.") broadcast( f"{action_actor.name} moves through {direction} to {destination_room.name}" @@ -106,7 +107,7 @@ def action_take(item: str) -> str: with action_context() as (action_room, action_actor): action_item = find_item_in_room(action_room, item) if not action_item: - return f"The {item} item is not in the room." + raise ActionError(f"The {item} item is not in the room.") broadcast(f"{action_actor.name} takes the {item} item") action_room.items.remove(action_item) @@ -127,13 +128,15 @@ def action_ask(character: str, question: str) -> str: # sanity checks question_actor, question_agent = get_actor_agent_for_name(character) if question_actor == action_actor: - return "You cannot ask yourself a question. Stop talking to yourself. Try another action." + raise ActionError( + "You cannot ask yourself a question. Stop talking to yourself. Try another action." + ) if not question_actor: - return f"The {character} character is not in the room." + raise ActionError(f"The {character} character is not in the room.") if not question_agent: - return f"The {character} character does not exist." + raise ActionError(f"The {character} character does not exist.") broadcast(f"{action_actor.name} asks {character}: {question}") first_prompt = ( @@ -183,13 +186,15 @@ def action_tell(character: str, message: str) -> str: # sanity checks question_actor, question_agent = get_actor_agent_for_name(character) if question_actor == action_actor: - return "You cannot tell yourself a message. Stop talking to yourself. Try another action." + raise ActionError( + "You cannot tell yourself a message. Stop talking to yourself. Try another action." + ) if not question_actor: - return f"The {character} character is not in the room." + raise ActionError(f"The {character} character is not in the room.") if not question_agent: - return f"The {character} character does not exist." + raise ActionError(f"The {character} character does not exist.") broadcast(f"{action_actor.name} tells {character}: {message}") first_prompt = ( @@ -236,11 +241,16 @@ def action_give(character: str, item: str) -> str: with action_context() as (action_room, action_actor): destination_actor = find_actor_in_room(action_room, character) if not destination_actor: - return f"The {character} character is not in the room." + raise ActionError(f"The {character} character is not in the room.") + + if destination_actor == action_actor: + raise ActionError( + "You cannot give an item to yourself. Try another action." + ) action_item = find_item_in_actor(action_actor, item) if not action_item: - return f"You do not have the {item} item in your inventory." + raise ActionError(f"You do not have the {item} item in your inventory.") broadcast(f"{action_actor.name} gives {character} the {item} item.") action_actor.items.remove(action_item) @@ -260,7 +270,7 @@ def action_drop(item: str) -> str: with action_context() as (action_room, action_actor): action_item = find_item_in_actor(action_actor, item) if not action_item: - return f"You do not have the {item} item in your inventory." + raise ActionError(f"You do not have the {item} item in your inventory.") broadcast(f"{action_actor.name} drops the {item} item") action_actor.items.remove(action_item) diff --git a/adventure/actions/optional.py b/adventure/actions/optional.py index af6e754..97acad1 100644 --- a/adventure/actions/optional.py +++ b/adventure/actions/optional.py @@ -13,6 +13,7 @@ from adventure.context import ( set_dungeon_master, world_context, ) +from adventure.errors import ActionError from adventure.generate import generate_item, generate_room, link_rooms from adventure.utils.effect import apply_effects from adventure.utils.search import find_actor_in_room @@ -47,7 +48,10 @@ def action_explore(direction: str) -> str: if direction in action_room.portals: dest_room = action_room.portals[direction] - return f"You cannot explore {direction} from here, that direction already leads to {dest_room}. Please use the move action to go there." + raise ActionError( + f"You cannot explore {direction} from here, that direction already leads to {dest_room}. " + "Please use the move action to go there." + ) try: systems = get_game_systems() @@ -118,7 +122,7 @@ def action_use(item: str, target: str) -> str: None, ) if not action_item: - return f"The {item} item is not available to use." + raise ActionError(f"The {item} item is not available to use.") if target == "self": target_actor = action_actor @@ -148,13 +152,15 @@ def action_use(item: str, target: str) -> str: ) if not chosen_effect: # TODO: should retry the question if the effect is not found - return f"The {chosen_name} effect is not available to apply." + raise ValueError(f"The {chosen_name} effect is not available to apply.") 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." + raise ValueError( + f"There was a problem applying the {chosen_name} effect while using the {item} item." + ) broadcast( f"{action_actor.name} uses the {chosen_name} effect of {item} on {target}" diff --git a/adventure/errors.py b/adventure/errors.py new file mode 100644 index 0000000..a5d5b4c --- /dev/null +++ b/adventure/errors.py @@ -0,0 +1,2 @@ +class ActionError(Exception): + pass diff --git a/adventure/generate.py b/adventure/generate.py index b59cad3..c1bf52a 100644 --- a/adventure/generate.py +++ b/adventure/generate.py @@ -1,5 +1,5 @@ from logging import getLogger -from random import choice, randint +from random import choice from typing import List, Tuple from packit.agent import Agent @@ -19,6 +19,7 @@ from adventure.models.effect import ( 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.effect import resolve_int_range from adventure.utils.search import ( list_actors, list_actors_in_room, @@ -108,9 +109,7 @@ def generate_room( actions = {} room = Room(name=name, description=desc, items=[], actors=[], actions=actions) - item_count = randint( - world_config.size.room_items.min, world_config.size.room_items.max - ) + item_count = resolve_int_range(world_config.size.room_items) or 0 broadcast_generated(f"Generating {item_count} items for room: {name}") for _ in range(item_count): @@ -127,9 +126,7 @@ def generate_room( except Exception: logger.exception("error generating item") - actor_count = randint( - world_config.size.room_actors.min, world_config.size.room_actors.max - ) + actor_count = resolve_int_range(world_config.size.room_actors) or 0 broadcast_generated(message=f"Generating {actor_count} actors for room: {name}") for _ in range(actor_count): @@ -265,9 +262,7 @@ def generate_item( item = Item(name=name, description=desc, actions=actions) generate_system_attributes(agent, world, item, systems) - effect_count = randint( - world_config.size.item_effects.min, world_config.size.item_effects.max - ) + effect_count = resolve_int_range(world_config.size.item_effects) or 0 broadcast_generated(message=f"Generating {effect_count} effects for item: {name}") for _ in range(effect_count): @@ -326,9 +321,7 @@ def generate_actor( generate_system_attributes(agent, world, actor, systems) # generate the actor's inventory - item_count = randint( - world_config.size.actor_items.min, world_config.size.actor_items.max - ) + item_count = resolve_int_range(world_config.size.actor_items) or 0 broadcast_generated(f"Generating {item_count} items for actor {name}") for k in range(item_count): @@ -470,9 +463,7 @@ def link_rooms( rooms = rooms or world.rooms for room in rooms: - num_portals = randint( - world_config.size.portals.min, world_config.size.portals.max - ) + num_portals = resolve_int_range(world_config.size.portals) or 0 if len(room.portals) >= num_portals: logger.info(f"room {room.name} already has enough portals") @@ -517,9 +508,7 @@ def generate_world( systems: List[GameSystem], room_count: int | None = None, ) -> World: - room_count = room_count or randint( - world_config.size.rooms.min, world_config.size.rooms.max - ) + room_count = room_count or resolve_int_range(world_config.size.rooms) or 0 broadcast_generated(message=f"Generating a {theme} with {room_count} rooms") world = World(name=name, rooms=[], theme=theme, order=[]) diff --git a/adventure/models/config.py b/adventure/models/config.py index 5f05914..7e70364 100644 --- a/adventure/models/config.py +++ b/adventure/models/config.py @@ -22,11 +22,11 @@ class BotConfig: @dataclass class RenderConfig: - cfg: IntRange + cfg: int | IntRange checkpoints: List[str] path: str sizes: Dict[str, Size] - steps: IntRange + steps: int | IntRange @dataclass @@ -47,12 +47,12 @@ class WorldActorConfig: @dataclass class WorldSizeConfig: - actor_items: IntRange - item_effects: IntRange - portals: IntRange - room_actors: IntRange - room_items: IntRange - rooms: IntRange + actor_items: int | IntRange + item_effects: int | IntRange + portals: int | IntRange + room_actors: int | IntRange + room_items: int | IntRange + rooms: int | IntRange @dataclass @@ -87,11 +87,11 @@ DEFAULT_CONFIG = Config( server=ServerConfig(websocket=WebsocketServerConfig(host="localhost", port=8001)), world=WorldConfig( actor=WorldActorConfig( - conversation_limit=3, + conversation_limit=2, ), size=WorldSizeConfig( actor_items=IntRange(min=0, max=2), - item_effects=IntRange(min=1, max=2), + item_effects=IntRange(min=1, max=1), portals=IntRange(min=1, max=3), rooms=IntRange(min=3, max=6), room_actors=IntRange(min=1, max=3), diff --git a/adventure/render/comfy.py b/adventure/render/comfy.py index ab9ce29..42c6310 100644 --- a/adventure/render/comfy.py +++ b/adventure/render/comfy.py @@ -28,6 +28,7 @@ from adventure.models.event import ( ResultEvent, StatusEvent, ) +from adventure.utils.random import resolve_int_range from .prompt import prompt_from_entity, prompt_from_event @@ -44,17 +45,11 @@ render_thread: Thread | None = None def generate_cfg(): - if render_config.cfg.min == render_config.cfg.max: - return render_config.cfg.min - - return randint(render_config.cfg.min, render_config.cfg.max) + return resolve_int_range(render_config.cfg) def generate_steps(): - if render_config.steps.min == render_config.steps.max: - return render_config.steps.min - - return randint(render_config.steps.min, render_config.steps.max) + return resolve_int_range(render_config.steps) def generate_batches( diff --git a/adventure/simulate.py b/adventure/simulate.py index d5aea04..e4c59dd 100644 --- a/adventure/simulate.py +++ b/adventure/simulate.py @@ -1,5 +1,6 @@ from functools import partial from itertools import count +from json import loads from logging import getLogger from math import inf from typing import Callable, Sequence @@ -91,6 +92,22 @@ def prompt_actor_action( if not room or not actor: raise ValueError("Room and actor must be set before parsing results") + # trim suffixes that are used elsewhere + value = value.removesuffix("END").strip() + + # fix unbalanced curly braces + if value.startswith("{") and not value.endswith("}"): + open_count = value.count("{") + close_count = value.count("}") + + if open_count > close_count: + fixed_value = value + ("}" * (open_count - close_count)) + try: + loads(fixed_value) + value = fixed_value + except Exception: + pass + if could_be_json(value): event = ActionEvent.from_json(value, room, actor) else: diff --git a/adventure/utils/conversation.py b/adventure/utils/conversation.py index 5bdacee..a2d96e1 100644 --- a/adventure/utils/conversation.py +++ b/adventure/utils/conversation.py @@ -11,6 +11,7 @@ from packit.utils import could_be_json from adventure.context import broadcast from adventure.models.config import DEFAULT_CONFIG from adventure.models.entity import Actor, Room +from adventure.models.event import ReplyEvent from .string import normalize_name @@ -125,10 +126,12 @@ def loop_conversation( if len(actors) != len(agents): raise ValueError("The number of actors and agents must match.") + # set up the keyword or length-limit compound condition _, condition_end, parse_end = make_keyword_condition(end_message) stop_length = partial(condition_threshold, max=max_length) stop_condition = condition_or(condition_end, stop_length) + # prepare a result parser looking for the echo function def result_parser(value: str, **kwargs) -> str: value = parse_end(value, **kwargs) @@ -140,6 +143,7 @@ def loop_conversation( return value.strip() + # prepare the loop state i = 0 last_actor = first_actor response = first_message @@ -163,8 +167,9 @@ def loop_conversation( ) response = result_parser(response) - logger.info(f"{actor.name} response: {response}") - broadcast(f"{actor.name} responds to {last_actor.name}: {response}") + logger.info(f"{actor.name} responds: {response}") + reply_event = ReplyEvent.from_text(response, room, actor) + broadcast(reply_event) # increment the step counter i += 1 diff --git a/adventure/utils/effect.py b/adventure/utils/effect.py index f38435f..5a25690 100644 --- a/adventure/utils/effect.py +++ b/adventure/utils/effect.py @@ -1,8 +1,6 @@ -import random from logging import getLogger from typing import List -from adventure.models.base import FloatRange, IntRange from adventure.models.effect import ( BooleanEffectPattern, BooleanEffectResult, @@ -23,6 +21,8 @@ from adventure.utils.attribute import ( prepend_value, ) +from .random import resolve_float_range, resolve_int_range, resolve_string_list + logger = getLogger(__name__) @@ -124,50 +124,6 @@ def effective_attributes( 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. diff --git a/adventure/utils/random.py b/adventure/utils/random.py new file mode 100644 index 0000000..7ee924e --- /dev/null +++ b/adventure/utils/random.py @@ -0,0 +1,48 @@ +import random +from typing import List + +from adventure.models.base import FloatRange, IntRange + + +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)