From 8a6fcfc7a5d77d09043835a9d6ba4af68fa1d426 Mon Sep 17 00:00:00 2001 From: Sean Sube Date: Sun, 19 May 2024 13:09:52 -0500 Subject: [PATCH] normalize names, reverse player function syntax, link new rooms --- adventure/actions/optional.py | 22 +++--- adventure/generate.py | 81 +++++++++++++++-------- adventure/player.py | 23 +++---- adventure/server/websocket.py | 6 +- adventure/simulate.py | 3 +- adventure/systems/rpg/crafting_actions.py | 12 +++- adventure/utils/__init__.py | 22 ++++++ adventure/utils/search.py | 70 ++++++++++++++++++-- adventure/utils/string.py | 8 +++ 9 files changed, 181 insertions(+), 66 deletions(-) create mode 100644 adventure/utils/__init__.py create mode 100644 adventure/utils/string.py diff --git a/adventure/actions/optional.py b/adventure/actions/optional.py index 0a88247..7d0025c 100644 --- a/adventure/actions/optional.py +++ b/adventure/actions/optional.py @@ -8,11 +8,12 @@ from adventure.context import ( broadcast, get_agent_for_actor, get_dungeon_master, + get_game_systems, has_dungeon_master, set_dungeon_master, world_context, ) -from adventure.generate import generate_item, generate_room +from adventure.generate import generate_item, generate_room, link_rooms from adventure.utils.effect import apply_effect from adventure.utils.search import find_actor_in_room from adventure.utils.world import describe_actor, describe_entity @@ -37,7 +38,7 @@ def action_explore(direction: str) -> str: Explore the room in a new direction. You can only explore directions that do not already have a portal. Args: - direction: The direction to explore: north, south, east, or west. + direction: The direction to explore. For example: inside, outside, upstairs, downstairs, trapdoor, portal, etc. """ with world_context() as (action_world, action_room, action_actor): @@ -47,15 +48,13 @@ def action_explore(direction: str) -> str: 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." - existing_rooms = [room.name for room in action_world.rooms] try: - new_room = generate_room( - dungeon_master, action_world.theme, existing_rooms=existing_rooms - ) + systems = get_game_systems() + new_room = generate_room(dungeon_master, action_world, systems) action_world.rooms.append(new_room) # link the rooms together - # TODO: generate portals + link_rooms(dungeon_master, action_world, systems, [new_room]) broadcast( f"{action_actor.name} explores {direction} of {action_room.name} and finds a new room: {new_room.name}" @@ -79,14 +78,13 @@ def action_search(unused: bool) -> str: "You find nothing hidden in the room. There is no room for more items." ) - existing_items = [item.name for item in action_room.items] - try: + systems = get_game_systems() new_item = generate_item( dungeon_master, - action_world.theme, - existing_items=existing_items, - dest_room=action_room.name, + action_world, + systems, + dest_room=action_room, ) action_room.items.append(new_item) diff --git a/adventure/generate.py b/adventure/generate.py index ca59e2b..178fc91 100644 --- a/adventure/generate.py +++ b/adventure/generate.py @@ -22,7 +22,15 @@ from adventure.models.entity import ( ) from adventure.models.event import GenerateEvent from adventure.utils import try_parse_float, try_parse_int -from adventure.utils.search import list_actors, list_items, list_rooms +from adventure.utils.search import ( + list_actors, + list_actors_in_room, + list_items, + list_items_in_actor, + list_items_in_room, + list_rooms, +) +from adventure.utils.string import normalize_name logger = getLogger(__name__) @@ -238,10 +246,13 @@ def generate_item( world, include_actor_inventory=True, include_item_inventory=True ) ] + if dest_actor: dest_note = f"The item will be held by the {dest_actor.name} character" + existing_items += [item.name for item in list_items_in_actor(dest_actor)] elif dest_room: dest_note = f"The item will be placed in the {dest_room.name} room" + existing_items += [item.name for item in list_items_in_room(dest_room)] else: dest_note = "The item will be placed in the world" @@ -291,7 +302,10 @@ def generate_actor( systems: List[GameSystem], dest_room: Room, ) -> Actor: - existing_actors = [actor.name for actor in list_actors(world)] + existing_actors = [actor.name for actor in list_actors(world)] + [ + actor.name for actor in list_actors_in_room(dest_room) + ] + name = loop_retry( agent, "Generate one person or creature that would make sense in the world of {world_theme}. " @@ -395,7 +409,7 @@ def generate_effect(agent: Agent, world: World, entity: Item) -> Effect: attributes = [] for attribute_name in attribute_names.split(","): - attribute_name = attribute_name.strip() + attribute_name = normalize_name(attribute_name) if attribute_name: operation = loop_retry( agent, @@ -445,34 +459,15 @@ def generate_effect(agent: Agent, world: World, entity: Item) -> Effect: return Effect(name=name, description=description, attributes=attributes) -def generate_world( +def link_rooms( agent: Agent, - name: str, - theme: str, + world: 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 - ) + rooms: List[Room] | None = None, +) -> None: + rooms = rooms or world.rooms - broadcast_generated(message=f"Generating a {theme} with {room_count} rooms") - world = World(name=name, rooms=[], theme=theme, order=[]) - set_current_world(world) - - # generate the rooms - for _ in range(room_count): - try: - room = generate_room(agent, world, systems) - generate_system_attributes(agent, world, room, systems) - broadcast_generated(entity=room) - world.rooms.append(room) - except Exception: - logger.exception("error generating room") - continue - - # generate portals to link the rooms together - for room in world.rooms: + for room in rooms: num_portals = randint( world_config.size.portals.min, world_config.size.portals.max ) @@ -512,6 +507,36 @@ def generate_world( logger.exception("error generating portal") continue + +def generate_world( + agent: Agent, + name: str, + theme: str, + 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 + ) + + broadcast_generated(message=f"Generating a {theme} with {room_count} rooms") + world = World(name=name, rooms=[], theme=theme, order=[]) + set_current_world(world) + + # generate the rooms + for _ in range(room_count): + try: + room = generate_room(agent, world, systems) + generate_system_attributes(agent, world, room, systems) + broadcast_generated(entity=room) + world.rooms.append(room) + except Exception: + logger.exception("error generating room") + continue + + # generate portals to link the rooms together + link_rooms(agent, world, systems) + # ensure actors act in a stable order world.order = [actor.name for room in world.rooms for actor in room.actors] return world diff --git a/adventure/player.py b/adventure/player.py index cabb683..853ebff 100644 --- a/adventure/player.py +++ b/adventure/player.py @@ -6,7 +6,6 @@ from typing import Any, Callable, Dict, List, Optional, Sequence from langchain_core.messages import AIMessage, BaseMessage, HumanMessage from packit.agent import Agent -from packit.utils import could_be_json from adventure.context import action_context from adventure.models.event import PromptEvent @@ -92,18 +91,7 @@ class BasePlayer: return self(prompt, **context) - def parse_input(self, reply: str): - # if the reply starts with a tilde, it is a literal response and should be returned without the tilde - if reply.startswith("~"): - reply = reply[1:] - self.memory.append(AIMessage(content=reply)) - return reply - - # if the reply is JSON or a special command, return it as-is - if could_be_json(reply) or reply.lower() in ["end", ""]: - self.memory.append(AIMessage(content=reply)) - return reply - + def parse_pseudo_function(self, reply: str): # turn other replies into a JSON function call action, *param_rest = reply.split(":", 1) param_str = ",".join(param_rest or []) @@ -133,9 +121,16 @@ class BasePlayer: "parameters": params, } ) - self.memory.append(AIMessage(content=reply_json)) return reply_json + def parse_input(self, reply: str): + # if the reply starts with a tilde, it is a function response and should be parsed without the tilde + if reply.startswith("~"): + reply = self.parse_pseudo_function(reply[1:]) + + self.memory.append(AIMessage(content=reply)) + return reply + def __call__(self, prompt: str, **kwargs) -> str: raise NotImplementedError("Subclasses must implement this method") diff --git a/adventure/server/websocket.py b/adventure/server/websocket.py index 4e8a75b..e45cec6 100644 --- a/adventure/server/websocket.py +++ b/adventure/server/websocket.py @@ -203,6 +203,10 @@ async def handler(websocket): logger.info("client disconnected: %s", id) +def find_recent_event(event_id: str) -> GameEvent | None: + return next((e for e in recent_events if e.id == event_id), None) + + def render_input(data): world = get_current_world() if not world: @@ -211,7 +215,7 @@ def render_input(data): if "event" in data: event_id = data["event"] - event = next((e for e in recent_events if e.id == event_id), None) + event = find_recent_event(event_id) if event: render_event(event) else: diff --git a/adventure/simulate.py b/adventure/simulate.py index 34c83a3..e51069f 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.search import find_room_with_actor from adventure.utils.world import describe_entity, format_attributes logger = getLogger(__name__) @@ -91,7 +92,7 @@ def simulate_world( logger.error(f"Agent or actor not found for name {actor_name}") continue - room = next((room for room in world.rooms if actor in room.actors), None) + room = find_room_with_actor(world, actor) if not room: logger.error(f"Actor {actor_name} is not in a room") continue diff --git a/adventure/systems/rpg/crafting_actions.py b/adventure/systems/rpg/crafting_actions.py index 4f734f2..dd3d154 100644 --- a/adventure/systems/rpg/crafting_actions.py +++ b/adventure/systems/rpg/crafting_actions.py @@ -1,6 +1,11 @@ from random import randint -from adventure.context import broadcast, get_dungeon_master, world_context +from adventure.context import ( + broadcast, + get_dungeon_master, + get_game_systems, + world_context, +) from adventure.generate import generate_item from adventure.models.base import dataclass from adventure.models.entity import Item @@ -64,9 +69,10 @@ def action_craft(item_name: str) -> str: new_item = Item(**vars(result_item)) # Copying the item else: dungeon_master = get_dungeon_master() + systems = get_game_systems() new_item = generate_item( - dungeon_master, action_world.theme - ) # TODO: pass recipe item + dungeon_master, action_world, systems + ) # TODO: pass crafting recipe and generate from that action_actor.items.append(new_item) diff --git a/adventure/utils/__init__.py b/adventure/utils/__init__.py new file mode 100644 index 0000000..372515f --- /dev/null +++ b/adventure/utils/__init__.py @@ -0,0 +1,22 @@ +from typing import Callable + + +def try_parse_int(value: str) -> int | None: + try: + return int(value) + except ValueError: + return None + + +def try_parse_float(value: str) -> float | None: + try: + return float(value) + except ValueError: + return None + + +def format_callable(fn: Callable | None) -> str: + if fn: + return f"{fn.__module__}:{fn.__name__}" + + return "None" diff --git a/adventure/utils/search.py b/adventure/utils/search.py index 3025ef0..f7f1503 100644 --- a/adventure/utils/search.py +++ b/adventure/utils/search.py @@ -100,6 +100,26 @@ def find_item_in_room( return None +def find_room_with_actor(world: World, actor: Actor) -> Room | None: + for room in world.rooms: + for room_actor in room.actors: + if normalize_name(actor.name) == normalize_name(room_actor.name): + return room + + return None + + +def find_containing_room(world: World, entity: Room | Actor | Item) -> Room | None: + if isinstance(entity, Room): + return entity + + for room in world.rooms: + if entity in room.actors or entity in room.items: + return room + + return None + + def list_rooms(world: World) -> Generator[Room, Any, None]: for room in world.rooms: yield room @@ -118,14 +138,8 @@ def list_actors(world: World) -> Generator[Actor, Any, None]: def list_items( - world: World, include_actor_inventory=False, include_item_inventory=False + world: World, include_actor_inventory=True, include_item_inventory=True ) -> Generator[Item, Any, None]: - def list_items_in_container(container: Item) -> Generator[Item, Any, None]: - for item in container.items: - yield item - - if include_item_inventory: - yield from list_items_in_container(item) for room in world.rooms: for item in room.items: @@ -138,3 +152,45 @@ def list_items( for actor in room.actors: for item in actor.items: yield item + + +def list_actors_in_room(room: Room) -> Generator[Actor, Any, None]: + for actor in room.actors: + yield actor + + +def list_items_in_actor( + actor: Actor, include_item_inventory=True +) -> Generator[Item, Any, None]: + for item in actor.items: + yield item + + if include_item_inventory: + yield from list_items_in_container(item) + + +def list_items_in_container( + container: Item, include_item_inventory=True +) -> Generator[Item, Any, None]: + for item in container.items: + yield item + + if include_item_inventory: + yield from list_items_in_container(item) + + +def list_items_in_room( + room: Room, + include_actor_inventory=True, + include_item_inventory=True, +) -> Generator[Item, Any, None]: + for item in room.items: + yield item + + if include_item_inventory: + yield from list_items_in_container(item) + + if include_actor_inventory: + for actor in room.actors: + for item in actor.items: + yield item diff --git a/adventure/utils/string.py b/adventure/utils/string.py new file mode 100644 index 0000000..9fe1c7f --- /dev/null +++ b/adventure/utils/string.py @@ -0,0 +1,8 @@ +from functools import lru_cache + + +@lru_cache(maxsize=1024) +def normalize_name(name: str) -> str: + name = name.lower().strip() + name = name.strip('"').strip("'") + return name.removesuffix(".")