diff --git a/adventure/actions/base.py b/adventure/actions/base.py index f0aaf60..c15b265 100644 --- a/adventure/actions/base.py +++ b/adventure/actions/base.py @@ -68,19 +68,22 @@ def action_move(direction: str) -> str: """ with world_context() as (action_world, action_room, action_actor): - destination_name = action_room.portals.get(direction.lower()) - if not destination_name: + portal = next( + (p for p in action_room.portals if p.name.lower() == direction.lower()), + None, + ) + if not portal: return f"You cannot move {direction} from here." - destination_room = find_room(action_world, destination_name) + destination_room = find_room(action_world, portal.destination) if not destination_room: - return f"The {destination_name} room does not exist." + return f"The {portal.destination} room does not exist." - broadcast(f"{action_actor.name} moves {direction} to {destination_name}") + broadcast(f"{action_actor.name} moves {direction} to {destination_room.name}") action_room.actors.remove(action_actor) destination_room.actors.append(action_actor) - return f"You move {direction} and arrive at {destination_name}." + return f"You move {direction} and arrive at {destination_room.name}." def action_take(item_name: str) -> str: diff --git a/adventure/actions/optional.py b/adventure/actions/optional.py index 0d49dca..0a88247 100644 --- a/adventure/actions/optional.py +++ b/adventure/actions/optional.py @@ -12,7 +12,7 @@ from adventure.context import ( set_dungeon_master, world_context, ) -from adventure.generate import OPPOSITE_DIRECTIONS, generate_item, generate_room +from adventure.generate import generate_item, generate_room 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 @@ -55,8 +55,7 @@ def action_explore(direction: str) -> str: action_world.rooms.append(new_room) # link the rooms together - action_room.portals[direction] = new_room.name - new_room.portals[OPPOSITE_DIRECTIONS[direction]] = action_room.name + # TODO: generate portals broadcast( f"{action_actor.name} explores {direction} of {action_room.name} and finds a new room: {new_room.name}" diff --git a/adventure/generate.py b/adventure/generate.py index 61a532b..6b89d55 100644 --- a/adventure/generate.py +++ b/adventure/generate.py @@ -1,6 +1,6 @@ from logging import getLogger from random import choice, randint -from typing import List +from typing import List, Tuple from packit.agent import Agent from packit.loops import loop_retry @@ -8,11 +8,13 @@ from packit.utils import could_be_json from adventure.context import broadcast 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, @@ -22,12 +24,7 @@ from adventure.models.event import GenerateEvent logger = getLogger(__name__) -OPPOSITE_DIRECTIONS = { - "north": "south", - "south": "north", - "east": "west", - "west": "east", -} +world_config: WorldConfig = DEFAULT_CONFIG.world def duplicate_name_parser(existing_names: List[str]): @@ -69,6 +66,7 @@ def generate_room( agent: Agent, world_theme: str, existing_rooms: List[str] = [], + systems: List[GameSystem] = [], ) -> Room: name = loop_retry( agent, @@ -89,15 +87,120 @@ def generate_room( name=name, ) - items = [] - actors = [] 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] + + try: + item = generate_item( + agent, + world_theme, + dest_room=name, + existing_items=existing_items, + ) + generate_system_attributes(agent, world_theme, item, systems) + broadcast_generated(entity=item) + + items.append(item) + except Exception: + logger.exception("error generating item") + + actor_count = randint( + world_config.size.room_actors.min, world_config.size.room_actors.max + ) + 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] + + try: + actor = generate_actor( + agent, + world_theme, + dest_room=name, + existing_actors=existing_actors, + ) + generate_system_attributes(agent, world_theme, actor, systems) + broadcast_generated(entity=actor) + + actors.append(actor) + except Exception: + logger.exception("error generating actor") + continue + return Room( name=name, description=desc, items=items, actors=actors, actions=actions ) +def generate_portals( + agent: Agent, + world_theme: str, + source_room: Room, + dest_room: Room, +) -> 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] + + outgoing_name = loop_retry( + agent, + "Generate the name of a portal that leads from the {source_room} room to the {dest_room} room and fits the world theme of {world_theme}. " + "Some example portal names are: 'door', 'gate', 'archway', 'staircase', 'trapdoor', 'mirror', and 'magic circle'. " + "Only respond with the portal name in title case, do not include a description or any other text. " + 'Do not prefix the name with "the", do not wrap it in quotes. Use a unique name. ' + "Do not create any duplicate portals in the same room. The existing portals are: {existing_portals}", + context={ + "source_room": source_room.name, + "dest_room": dest_room.name, + "existing_portals": existing_source_portals, + "world_theme": world_theme, + }, + result_parser=duplicate_name_parser(existing_source_portals), + ) + broadcast_generated(message=f"Generating portal: {outgoing_name}") + + incoming_name = loop_retry( + agent, + "Generate the opposite name of the portal that leads from the {dest_room} room to the {source_room} room. " + "The name should be the opposite of the {outgoing_name} portal and should fit the world theme of {world_theme}. " + "Some example portal names are: 'door', 'gate', 'archway', 'staircase', 'trapdoor', 'mirror', and 'magic circle'. " + "Only respond with the portal name in title case, do not include a description or any other text. " + 'Do not prefix the name with "the", do not wrap it in quotes. Use a unique name. ' + "Do not create any duplicate portals in the same room. The existing portals are: {existing_portals}", + context={ + "source_room": source_room.name, + "dest_room": dest_room.name, + "existing_portals": existing_dest_portals, + "world_theme": world_theme, + "outgoing_name": outgoing_name, + }, + result_parser=duplicate_name_parser(existing_dest_portals), + ) + + broadcast_generated(message=f"Linking {outgoing_name} to {incoming_name}") + + outgoing_portal = Portal( + name=outgoing_name, + description=f"A {outgoing_name} leads to the {dest_room.name} room.", + destination=dest_room.name, + ) + incoming_portal = Portal( + name=incoming_name, + description=f"A {incoming_name} leads to the {source_room.name} room.", + destination=source_room.name, + ) + + return (outgoing_portal, incoming_portal) + + def generate_item( agent: Agent, world_theme: str, @@ -136,13 +239,19 @@ def generate_item( actions = {} item = Item(name=name, description=desc, actions=actions) - effect_count = randint(1, 2) + 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] + try: - effect = generate_effect(agent, world_theme, entity=item) + effect = generate_effect( + agent, world_theme, entity=item, existing_effects=existing_effects + ) effects.append(effect) except Exception: logger.exception("error generating effect") @@ -156,6 +265,7 @@ def generate_actor( world_theme: str, dest_room: str, existing_actors: List[str] = [], + systems: List[GameSystem] = [], ) -> Actor: name = loop_retry( agent, @@ -186,18 +296,59 @@ def generate_actor( name=name, ) + # 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, + ) + generate_system_attributes(agent, world_theme, item, systems) + broadcast_generated(entity=item) + + items.append(item) + except Exception: + logger.exception("error generating item") + return Actor( name=name, backstory=backstory, description=description, actions={}, + items=items, ) -def generate_effect(agent: Agent, theme: str, entity: Item) -> Effect: - entity_type = entity.type +# TODO: move to utils +def try_parse_int(value: str) -> int | None: + try: + return int(value) + except ValueError: + return None - existing_effects = [effect.name for effect in entity.effects] + +# 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: + entity_type = entity.type name = loop_retry( agent, @@ -252,28 +403,50 @@ def generate_effect(agent: Agent, theme: str, entity: Item) -> Effect: "prepend", ], ) + + 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] + 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." - "Enter a positive or negative number, or a string value. Do not include any other text. Do not use JSON.", + f"{operation_prompt}. Do not include any other text. Do not use JSON.", name=name, attribute_name=attribute_name, ) value = value.strip() - if value.isdigit(): - value = int(value) + + int_value = try_parse_int(value) + if int_value is not None: attribute_effect = NumberAttributeEffect( - name=attribute_name, operation=operation, value=value - ) - elif value.isdecimal(): - value = float(value) - attribute_effect = NumberAttributeEffect( - name=attribute_name, operation=operation, value=value + name=attribute_name, operation=operation, value=int_value ) else: - attribute_effect = StringAttributeEffect( - name=attribute_name, operation=operation, value=value - ) + float_value = try_parse_float(value) + if float_value is not None: + attribute_effect = NumberAttributeEffect( + name=attribute_name, operation=operation, value=float_value + ) + else: + attribute_effect = StringAttributeEffect( + name=attribute_name, operation=operation, value=value + ) attributes.append(attribute_effect) @@ -293,121 +466,67 @@ def generate_world( name: str, theme: str, room_count: int | None = None, - max_rooms: int = 5, systems: List[GameSystem] = [], ) -> World: - room_count = room_count or randint(3, max_rooms) + 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") - existing_actors: List[str] = [] - existing_items: List[str] = [] - existing_rooms: List[str] = [] - # generate the rooms rooms = [] for i in range(room_count): + existing_rooms = [room.name for room in rooms] + try: room = generate_room(agent, theme, existing_rooms=existing_rooms) generate_system_attributes(agent, theme, room, systems) broadcast_generated(entity=room) rooms.append(room) - existing_rooms.append(room.name) except Exception: logger.exception("error generating room") continue - item_count = randint(1, 3) - broadcast_generated(f"Generating {item_count} items for room: {room.name}") - - for j in range(item_count): - try: - item = generate_item( - agent, - theme, - dest_room=room.name, - existing_items=existing_items, - ) - generate_system_attributes(agent, theme, item, systems) - broadcast_generated(entity=item) - - room.items.append(item) - existing_items.append(item.name) - except Exception: - logger.exception("error generating item") - - actor_count = randint(1, 3) - broadcast_generated( - message=f"Generating {actor_count} actors for room: {room.name}" - ) - - for j in range(actor_count): - try: - actor = generate_actor( - agent, - theme, - dest_room=room.name, - existing_actors=existing_actors, - ) - generate_system_attributes(agent, theme, actor, systems) - broadcast_generated(entity=actor) - - room.actors.append(actor) - existing_actors.append(actor.name) - except Exception: - logger.exception("error generating actor") - continue - - # generate the actor's inventory - item_count = randint(0, 2) - broadcast_generated(f"Generating {item_count} items for actor {actor.name}") - - for k in range(item_count): - try: - item = generate_item( - agent, - theme, - dest_room=room.name, - existing_items=existing_items, - ) - generate_system_attributes(agent, theme, item, systems) - broadcast_generated(entity=item) - - actor.items.append(item) - existing_items.append(item.name) - except Exception: - logger.exception("error generating item") - # generate portals to link the rooms together for room in rooms: - directions = ["north", "south", "east", "west"] - for direction in directions: - if direction in room.portals: - logger.debug(f"Room {room.name} already has a {direction} portal") + num_portals = randint( + world_config.size.portals.min, world_config.size.portals.max + ) + + if len(room.portals) >= num_portals: + logger.info(f"room {room.name} already has enough portals") + continue + + broadcast_generated( + message=f"Generating {num_portals} portals for room: {room.name}" + ) + + for i 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] + 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] + ) + + try: + outgoing_portal, incoming_portal = generate_portals( + agent, theme, room, dest_room + ) + + room.portals.append(outgoing_portal) + dest_room.portals.append(incoming_portal) + except Exception: + logger.exception("error generating portal") continue - opposite_direction = OPPOSITE_DIRECTIONS[direction] - - if randint(0, 1): - dest_room = choice([r for r in rooms if r.name != room.name]) - - # make sure not to create duplicate links - if room.name in dest_room.portals.values(): - logger.debug( - f"Room {dest_room.name} already has a portal to {room.name}" - ) - continue - - if opposite_direction in dest_room.portals: - logger.debug( - f"Room {dest_room.name} already has a {opposite_direction} portal" - ) - continue - - # create bidirectional links - room.portals[direction] = dest_room.name - dest_room.portals[OPPOSITE_DIRECTIONS[direction]] = room.name - # 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) diff --git a/adventure/main.py b/adventure/main.py index d7d4f2c..11c8079 100644 --- a/adventure/main.py +++ b/adventure/main.py @@ -91,12 +91,6 @@ def parse_args(): default="", help="Some additional flavor text for the generated world", ) - parser.add_argument( - "--max-rooms", - default=6, - type=int, - help="The maximum number of rooms to generate", - ) parser.add_argument( "--optional-actions", action="store_true", @@ -216,7 +210,6 @@ def load_or_generate_world(args, players, systems, world_prompt: WorldPrompt): args.world, world_prompt.theme, room_count=args.rooms, - max_rooms=args.max_rooms, ) save_world(world, world_file) diff --git a/adventure/models/config.py b/adventure/models/config.py index 9b14b66..1e60141 100644 --- a/adventure/models/config.py +++ b/adventure/models/config.py @@ -47,11 +47,27 @@ class ServerConfig: websocket: WebsocketServerConfig +@dataclass +class WorldSizeConfig: + actor_items: Range + item_effects: Range + portals: Range + room_actors: Range + room_items: Range + rooms: Range + + +@dataclass +class WorldConfig: + size: WorldSizeConfig + + @dataclass class Config: bot: BotConfig render: RenderConfig server: ServerConfig + world: WorldConfig DEFAULT_CONFIG = Config( @@ -69,5 +85,15 @@ DEFAULT_CONFIG = Config( }, steps=Range(min=30, max=30), ), - server=ServerConfig(websocket=WebsocketServerConfig(host="localhost", port=8000)), + 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), + 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), + ) + ), ) diff --git a/adventure/models/entity.py b/adventure/models/entity.py index c9d1cd2..70311f0 100644 --- a/adventure/models/entity.py +++ b/adventure/models/entity.py @@ -67,6 +67,17 @@ class Actor(BaseModel): type: Literal["actor"] = "actor" +@dataclass +class Portal(BaseModel): + name: str + description: str + destination: str + actions: Actions = Field(default_factory=dict) + attributes: Attributes = Field(default_factory=dict) + id: str = Field(default_factory=uuid) + type: Literal["portal"] = "portal" + + @dataclass class Room(BaseModel): name: str @@ -75,7 +86,7 @@ class Room(BaseModel): actions: Actions = Field(default_factory=dict) attributes: Attributes = Field(default_factory=dict) items: List[Item] = Field(default_factory=list) - portals: Dict[str, str] = Field(default_factory=dict) + portals: List[Portal] = Field(default_factory=list) id: str = Field(default_factory=uuid) type: Literal["room"] = "room" diff --git a/adventure/server/websocket.py b/adventure/server/websocket.py index 2c37aa8..4e8a75b 100644 --- a/adventure/server/websocket.py +++ b/adventure/server/websocket.py @@ -280,7 +280,7 @@ def launch_server(config: WebsocketServerConfig): async def server_main(): - async with websockets.serve(handler, "", 8001): + async with websockets.serve(handler, server_config.host, server_config.port): logger.info("websocket server started") await asyncio.Future() # run forever diff --git a/adventure/simulate.py b/adventure/simulate.py index 955ad8e..34c83a3 100644 --- a/adventure/simulate.py +++ b/adventure/simulate.py @@ -98,7 +98,7 @@ def simulate_world( room_actors = [actor.name for actor in room.actors] room_items = [item.name for item in room.items] - room_directions = list(room.portals.keys()) + room_directions = [portal.name for portal in room.portals] actor_attributes = format_attributes(actor) actor_items = [item.name for item in actor.items] diff --git a/adventure/state.py b/adventure/state.py index e438f12..9674582 100644 --- a/adventure/state.py +++ b/adventure/state.py @@ -40,8 +40,8 @@ def graph_world(world: World, step: int): for room in world.rooms: room_label = "\n".join([room.name, *[actor.name for actor in room.actors]]) graph.node(room.name, room_label) - for direction, destination in room.portals.items(): - graph.edge(room.name, destination, label=direction) + for portal in room.portals: + graph.edge(room.name, portal.destination, label=portal.name) graph_path = path.dirname(world.name) graph.render(directory=graph_path)