diff --git a/adventure/actions/base.py b/adventure/actions/base.py index c15b265..3e18141 100644 --- a/adventure/actions/base.py +++ b/adventure/actions/base.py @@ -15,6 +15,7 @@ from adventure.utils.search import ( find_item_in_room, find_room, ) +from adventure.utils.string import normalize_name from adventure.utils.world import describe_entity logger = getLogger(__name__) @@ -31,7 +32,7 @@ def action_look(target: str) -> str: with action_context() as (action_room, action_actor): broadcast(f"{action_actor.name} looks at {target}") - if target.lower() == action_room.name.lower(): + if normalize_name(target) == normalize_name(action_room.name): broadcast(f"{action_actor.name} saw the {action_room.name} room") return describe_entity(action_room) @@ -69,7 +70,11 @@ def action_move(direction: str) -> str: with world_context() as (action_world, action_room, action_actor): portal = next( - (p for p in action_room.portals if p.name.lower() == direction.lower()), + ( + p + for p in action_room.portals + if normalize_name(p.name) == normalize_name(direction) + ), None, ) if not portal: diff --git a/adventure/context.py b/adventure/context.py index 6430a73..9635d23 100644 --- a/adventure/context.py +++ b/adventure/context.py @@ -19,6 +19,7 @@ from pyee.base import EventEmitter from adventure.game_system import GameSystem from adventure.models.entity import Actor, Room, World from adventure.models.event import GameEvent +from adventure.utils.string import normalize_name logger = getLogger(__name__) @@ -223,7 +224,7 @@ def get_actor_agent_for_name(name): ( (actor, agent) for actor, agent in actor_agents.values() - if actor.name.lower() == name.lower() + if normalize_name(actor.name) == normalize_name(name) ), (None, None), ) diff --git a/adventure/game_system.py b/adventure/game_system.py index d8806ed..bd1e4a2 100644 --- a/adventure/game_system.py +++ b/adventure/game_system.py @@ -1,9 +1,10 @@ from enum import Enum -from typing import Callable, Protocol +from typing import Protocol from packit.agent import Agent from adventure.models.entity import World, WorldEntity +from adventure.utils import format_callable class FormatPerspective(Enum): @@ -57,10 +58,5 @@ class GameSystem: def __str__(self): return f"GameSystem(format={format_callable(self.format)}, generate={format_callable(self.generate)}, simulate={format_callable(self.simulate)})" - -# TODO: move to utils -def format_callable(fn: Callable | None) -> str: - if fn: - return f"{fn.__module__}:{fn.__name__}" - - return "None" + def __repr__(self): + return str(self) diff --git a/adventure/generate.py b/adventure/generate.py index 6b89d55..ca59e2b 100644 --- a/adventure/generate.py +++ b/adventure/generate.py @@ -6,7 +6,7 @@ from packit.agent import Agent from packit.loops import loop_retry from packit.utils import could_be_json -from adventure.context import broadcast +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 ( @@ -21,11 +21,31 @@ from adventure.models.entity import ( WorldEntity, ) 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 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): @@ -62,19 +82,28 @@ def broadcast_generated( broadcast(event) +def generate_system_attributes( + agent: Agent, world: World, entity: WorldEntity, systems: List[GameSystem] +) -> None: + for system in systems: + if system.generate: + system.generate(agent, world.theme, entity) + + def generate_room( agent: Agent, - world_theme: str, - existing_rooms: List[str] = [], - systems: List[GameSystem] = [], + world: World, + systems: List[GameSystem], ) -> Room: + existing_rooms = [room.name for room in list_rooms(world)] + name = loop_retry( agent, "Generate one room, area, or location that would make sense in the world of {world_theme}. " "Only respond with the room name in title case, do not include the description or any other text. " 'Do not prefix the name with "the", do not wrap it in quotes. The existing rooms are: {existing_rooms}', context={ - "world_theme": world_theme, + "world_theme": world.theme, "existing_rooms": existing_rooms, }, result_parser=duplicate_name_parser(existing_rooms), @@ -88,27 +117,24 @@ 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 ) broadcast_generated(f"Generating {item_count} items for room: {name}") - items = [] - for j in range(item_count): - existing_items = [item.name for item in items] - + for _ in range(item_count): try: item = generate_item( agent, - world_theme, - dest_room=name, - existing_items=existing_items, + world, + systems=systems, + dest_room=room, ) - generate_system_attributes(agent, world_theme, item, systems) broadcast_generated(entity=item) - items.append(item) + room.items.append(item) except Exception: logger.exception("error generating item") @@ -117,35 +143,30 @@ def generate_room( ) broadcast_generated(message=f"Generating {actor_count} actors for room: {name}") - actors = [] - for j in range(actor_count): - existing_actors = [actor.name for actor in actors] - + for _ in range(actor_count): try: actor = generate_actor( agent, - world_theme, - dest_room=name, - existing_actors=existing_actors, + world, + systems=systems, + dest_room=room, ) - generate_system_attributes(agent, world_theme, actor, systems) broadcast_generated(entity=actor) - actors.append(actor) + room.actors.append(actor) except Exception: logger.exception("error generating actor") continue - return Room( - name=name, description=desc, items=items, actors=actors, actions=actions - ) + return room def generate_portals( agent: Agent, - world_theme: str, + world: World, source_room: Room, dest_room: Room, + systems: List[GameSystem], ) -> Tuple[Portal, Portal]: existing_source_portals = [portal.name for portal in source_room.portals] existing_dest_portals = [portal.name for portal in dest_room.portals] @@ -161,7 +182,7 @@ def generate_portals( "source_room": source_room.name, "dest_room": dest_room.name, "existing_portals": existing_source_portals, - "world_theme": world_theme, + "world_theme": world.theme, }, result_parser=duplicate_name_parser(existing_source_portals), ) @@ -179,7 +200,7 @@ def generate_portals( "source_room": source_room.name, "dest_room": dest_room.name, "existing_portals": existing_dest_portals, - "world_theme": world_theme, + "world_theme": world.theme, "outgoing_name": outgoing_name, }, result_parser=duplicate_name_parser(existing_dest_portals), @@ -192,26 +213,35 @@ def generate_portals( description=f"A {outgoing_name} leads to the {dest_room.name} room.", destination=dest_room.name, ) + generate_system_attributes(agent, world, outgoing_portal, systems) + incoming_portal = Portal( name=incoming_name, description=f"A {incoming_name} leads to the {source_room.name} room.", destination=source_room.name, ) + generate_system_attributes(agent, world, incoming_portal, systems) return (outgoing_portal, incoming_portal) def generate_item( agent: Agent, - world_theme: str, - dest_room: str | None = None, - dest_actor: str | None = None, - existing_items: List[str] = [], + world: World, + systems: List[GameSystem], + dest_room: Room | None = None, + dest_actor: Actor | None = None, ) -> Item: + existing_items = [ + item.name + for item in list_items( + world, include_actor_inventory=True, include_item_inventory=True + ) + ] if dest_actor: - dest_note = f"The item will be held by the {dest_actor} character" + dest_note = f"The item will be held by the {dest_actor.name} character" elif dest_room: - dest_note = f"The item will be placed in the {dest_room} room" + dest_note = f"The item will be placed in the {dest_room.name} room" else: dest_note = "The item will be placed in the world" @@ -225,7 +255,7 @@ def generate_item( context={ "dest_note": dest_note, "existing_items": existing_items, - "world_theme": world_theme, + "world_theme": world.theme, }, result_parser=duplicate_name_parser(existing_items), ) @@ -238,35 +268,30 @@ def generate_item( actions = {} 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 ) broadcast_generated(message=f"Generating {effect_count} effects for item: {name}") - effects = [] - for i in range(effect_count): - existing_effects = [effect.name for effect in effects] - + for _ in range(effect_count): try: - effect = generate_effect( - agent, world_theme, entity=item, existing_effects=existing_effects - ) - effects.append(effect) + effect = generate_effect(agent, world, entity=item) + item.effects.append(effect) except Exception: logger.exception("error generating effect") - item.effects = effects return item def generate_actor( agent: Agent, - world_theme: str, - dest_room: str, - existing_actors: List[str] = [], - systems: List[GameSystem] = [], + world: World, + systems: List[GameSystem], + dest_room: Room, ) -> Actor: + existing_actors = [actor.name for actor in list_actors(world)] name = loop_retry( agent, "Generate one person or creature that would make sense in the world of {world_theme}. " @@ -276,9 +301,9 @@ def generate_actor( "Do not include the name of the room. Do not give characters any duplicate names." "Do not create any duplicate characters. The existing characters are: {existing_actors}", context={ - "dest_room": dest_room, + "dest_room": dest_room.name, "existing_actors": existing_actors, - "world_theme": world_theme, + "world_theme": world.theme, }, result_parser=duplicate_name_parser(existing_actors), ) @@ -296,59 +321,38 @@ def generate_actor( name=name, ) + actor = Actor( + name=name, backstory=backstory, description=description, actions={}, items=[] + ) + 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 ) broadcast_generated(f"Generating {item_count} items for actor {name}") - items = [] for k in range(item_count): - existing_items = [item.name for item in items] - try: item = generate_item( agent, - world_theme, - dest_actor=name, - existing_items=existing_items, + world, + systems, + dest_actor=actor, ) - generate_system_attributes(agent, world_theme, item, systems) + generate_system_attributes(agent, world, item, systems) broadcast_generated(entity=item) - items.append(item) + actor.items.append(item) except Exception: logger.exception("error generating item") - return Actor( - name=name, - backstory=backstory, - description=description, - actions={}, - items=items, - ) + return actor -# TODO: move to utils -def try_parse_int(value: str) -> int | None: - try: - return int(value) - except ValueError: - return None - - -# TODO: move to utils -def try_parse_float(value: str) -> float | None: - try: - return float(value) - except ValueError: - return None - - -def generate_effect( - agent: Agent, theme: str, entity: Item, existing_effects: List[str] = [] -) -> Effect: +def generate_effect(agent: Agent, world: World, entity: Item) -> Effect: entity_type = entity.type + existing_effects = [effect.name for effect in entity.effects] name = loop_retry( agent, @@ -361,7 +365,7 @@ def generate_effect( "entity_name": entity.name, "entity_type": entity_type, "existing_effects": existing_effects, - "theme": theme, + "theme": world.theme, }, result_parser=duplicate_name_parser(existing_effects), ) @@ -374,52 +378,40 @@ def generate_effect( ) attribute_names = agent( - "Generate a list of attributes that the {name} effect modifies. " + "Generate a short list of attributes that the {name} effect modifies. Include 1 to 3 attributes. " "For example, 'heal' increases the target's 'health' attribute, while 'poison' decreases it. " "Use a comma-separated list of attribute names, such as 'health, strength, speed'. " "Only include the attribute names, do not include the question or any JSON.", 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 = attribute_name.strip() if attribute_name: - operation = agent( + 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. Give a single word." + "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}", - name=name, - attribute_name=attribute_name, - operations=[ - "set", - "add", - "subtract", - "multiply", - "divide", - "append", - "prepend", - ], + context={ + "name": name, + "attribute_name": attribute_name, + "operations": OPERATIONS, + }, + result_parser=operation_parser, ) - 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", - } - operation_type = PROMPT_OPERATION_TYPES[operation] operation_prompt = PROMPT_TYPE_FRAGMENTS[operation_type] @@ -453,43 +445,34 @@ def generate_effect( return Effect(name=name, description=description, attributes=attributes) -def generate_system_attributes( - agent: Agent, theme: str, entity: WorldEntity, systems: List[GameSystem] = [] -) -> None: - for system in systems: - if system.generate: - system.generate(agent, theme, entity) - - def generate_world( agent: Agent, name: str, theme: str, + systems: List[GameSystem], room_count: int | None = None, - systems: List[GameSystem] = [], ) -> 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 - rooms = [] - for i in range(room_count): - existing_rooms = [room.name for room in rooms] - + for _ in range(room_count): try: - room = generate_room(agent, theme, existing_rooms=existing_rooms) - generate_system_attributes(agent, theme, room, systems) + room = generate_room(agent, world, systems) + generate_system_attributes(agent, world, room, systems) broadcast_generated(entity=room) - rooms.append(room) + world.rooms.append(room) except Exception: logger.exception("error generating room") continue # generate portals to link the rooms together - for room in rooms: + for room in world.rooms: num_portals = randint( world_config.size.portals.min, world_config.size.portals.max ) @@ -502,23 +485,25 @@ def generate_world( message=f"Generating {num_portals} portals for room: {room.name}" ) - for i in range(num_portals): + for _ in range(num_portals): previous_destinations = [portal.destination for portal in room.portals] + [ room.name ] - remaining_rooms = [r for r in rooms if r.name not in previous_destinations] + remaining_rooms = [ + r for r in world.rooms if r.name not in previous_destinations + ] if len(remaining_rooms) == 0: logger.info(f"no more rooms to link to from {room.name}") break # TODO: prompt the DM to choose a destination room dest_room = choice( - [r for r in rooms if r.name not in previous_destinations] + [r for r in world.rooms if r.name not in previous_destinations] ) try: outgoing_portal, incoming_portal = generate_portals( - agent, theme, room, dest_room + agent, world, room, dest_room, systems ) room.portals.append(outgoing_portal) @@ -528,5 +513,5 @@ def generate_world( continue # ensure actors act in a stable order - order = [actor.name for room in rooms for actor in room.actors] - return World(name=name, rooms=rooms, theme=theme, order=order) + world.order = [actor.name for room in world.rooms for actor in room.actors] + return world diff --git a/adventure/main.py b/adventure/main.py index 11c8079..ed628a2 100644 --- a/adventure/main.py +++ b/adventure/main.py @@ -209,6 +209,7 @@ def load_or_generate_world(args, players, systems, world_prompt: WorldPrompt): world_builder, args.world, world_prompt.theme, + systems, room_count=args.rooms, ) save_world(world, world_file) diff --git a/adventure/models/config.py b/adventure/models/config.py index 1e60141..4afb552 100644 --- a/adventure/models/config.py +++ b/adventure/models/config.py @@ -88,8 +88,8 @@ DEFAULT_CONFIG = Config( server=ServerConfig(websocket=WebsocketServerConfig(host="localhost", port=8001)), world=WorldConfig( size=WorldSizeConfig( - actor_items=Range(min=1, max=3), - item_effects=Range(min=1, max=3), + 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), diff --git a/adventure/models/entity.py b/adventure/models/entity.py index 70311f0..bb9d8c0 100644 --- a/adventure/models/entity.py +++ b/adventure/models/entity.py @@ -110,4 +110,4 @@ class WorldState(BaseModel): type: Literal["world_state"] = "world_state" -WorldEntity = Room | Actor | Item +WorldEntity = Room | Actor | Item | Portal diff --git a/adventure/state.py b/adventure/state.py index 9674582..70181f9 100644 --- a/adventure/state.py +++ b/adventure/state.py @@ -38,7 +38,8 @@ def graph_world(world: World, step: int): graph_name = f"{path.basename(world.name)}-{step}" graph = graphviz.Digraph(graph_name, format="png") for room in world.rooms: - room_label = "\n".join([room.name, *[actor.name for actor in room.actors]]) + actors = [actor.name for actor in room.actors] + room_label = "\n".join([room.name, *actors]) graph.node(room.name, room_label) for portal in room.portals: graph.edge(room.name, portal.destination, label=portal.name) diff --git a/adventure/utils/search.py b/adventure/utils/search.py index 5a716ad..3025ef0 100644 --- a/adventure/utils/search.py +++ b/adventure/utils/search.py @@ -1,9 +1,13 @@ -from adventure.models.entity import Actor, Item, Room, World +from typing import Any, Generator + +from adventure.models.entity import Actor, Item, Portal, Room, World + +from .string import normalize_name def find_room(world: World, room_name: str) -> Room | None: for room in world.rooms: - if room.name.lower() == room_name.lower(): + if normalize_name(room.name) == normalize_name(room_name): return room return None @@ -20,7 +24,7 @@ def find_actor(world: World, actor_name: str) -> Actor | None: def find_actor_in_room(room: Room, actor_name: str) -> Actor | None: for actor in room.actors: - if actor.name.lower() == actor_name.lower(): + if normalize_name(actor.name) == normalize_name(actor_name): return actor return None @@ -46,7 +50,7 @@ def find_item_in_actor( actor: Actor, item_name: str, include_item_inventory=False ) -> Item | None: for item in actor.items: - if item.name.lower() == item_name.lower(): + if normalize_name(item.name) == normalize_name(item_name): return item if include_item_inventory: @@ -61,7 +65,7 @@ def find_item_in_container( container: Item, item_name: str, include_item_inventory=False ) -> Item | None: for item in container.items: - if item.name.lower() == item_name.lower(): + if normalize_name(item.name) == normalize_name(item_name): return item if include_item_inventory: @@ -79,7 +83,7 @@ def find_item_in_room( include_item_inventory=False, ) -> Item | None: for item in room.items: - if item.name.lower() == item_name.lower(): + if normalize_name(item.name) == normalize_name(item_name): return item if include_item_inventory: @@ -94,3 +98,43 @@ def find_item_in_room( return item return None + + +def list_rooms(world: World) -> Generator[Room, Any, None]: + for room in world.rooms: + yield room + + +def list_portals(world: World) -> Generator[Portal, Any, None]: + for room in world.rooms: + for portal in room.portals: + yield portal + + +def list_actors(world: World) -> Generator[Actor, Any, None]: + for room in world.rooms: + for actor in room.actors: + yield actor + + +def list_items( + world: World, include_actor_inventory=False, include_item_inventory=False +) -> 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: + 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/config.yml b/config.yml index 00c6634..5c87e85 100644 --- a/config.yml +++ b/config.yml @@ -21,4 +21,28 @@ render: height: 1024 steps: min: 30 - max: 50 \ No newline at end of file + max: 50 +server: + websocket: + host: 0.0.0.0 + port: 8001 +world: + size: + actor_items: + min: 0 + max: 3 + item_effects: + min: 0 + max: 1 + portals: + min: 1 + max: 3 + rooms: + min: 3 + max: 6 + room_actors: + min: 1 + max: 3 + room_items: + min: 0 + max: 3