1
0
Fork 0

use DM to generate portals between rooms

This commit is contained in:
Sean Sube 2024-05-18 19:48:18 -05:00
parent ea5ac0cd10
commit 71d2be85f1
Signed by: ssube
GPG Key ID: 3EED7B957D362AF1
9 changed files with 293 additions and 142 deletions

View File

@ -68,19 +68,22 @@ def action_move(direction: str) -> str:
""" """
with world_context() as (action_world, action_room, action_actor): with world_context() as (action_world, action_room, action_actor):
destination_name = action_room.portals.get(direction.lower()) portal = next(
if not destination_name: (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." 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: 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) action_room.actors.remove(action_actor)
destination_room.actors.append(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: def action_take(item_name: str) -> str:

View File

@ -12,7 +12,7 @@ from adventure.context import (
set_dungeon_master, set_dungeon_master,
world_context, 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.effect import apply_effect
from adventure.utils.search import find_actor_in_room from adventure.utils.search import find_actor_in_room
from adventure.utils.world import describe_actor, describe_entity 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) action_world.rooms.append(new_room)
# link the rooms together # link the rooms together
action_room.portals[direction] = new_room.name # TODO: generate portals
new_room.portals[OPPOSITE_DIRECTIONS[direction]] = action_room.name
broadcast( broadcast(
f"{action_actor.name} explores {direction} of {action_room.name} and finds a new room: {new_room.name}" f"{action_actor.name} explores {direction} of {action_room.name} and finds a new room: {new_room.name}"

View File

@ -1,6 +1,6 @@
from logging import getLogger from logging import getLogger
from random import choice, randint from random import choice, randint
from typing import List 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
@ -8,11 +8,13 @@ from packit.utils import could_be_json
from adventure.context import broadcast from adventure.context import broadcast
from adventure.game_system import GameSystem from adventure.game_system import GameSystem
from adventure.models.config import DEFAULT_CONFIG, WorldConfig
from adventure.models.entity import ( from adventure.models.entity import (
Actor, Actor,
Effect, Effect,
Item, Item,
NumberAttributeEffect, NumberAttributeEffect,
Portal,
Room, Room,
StringAttributeEffect, StringAttributeEffect,
World, World,
@ -22,12 +24,7 @@ from adventure.models.event import GenerateEvent
logger = getLogger(__name__) logger = getLogger(__name__)
OPPOSITE_DIRECTIONS = { world_config: WorldConfig = DEFAULT_CONFIG.world
"north": "south",
"south": "north",
"east": "west",
"west": "east",
}
def duplicate_name_parser(existing_names: List[str]): def duplicate_name_parser(existing_names: List[str]):
@ -69,6 +66,7 @@ def generate_room(
agent: Agent, agent: Agent,
world_theme: str, world_theme: str,
existing_rooms: List[str] = [], existing_rooms: List[str] = [],
systems: List[GameSystem] = [],
) -> Room: ) -> Room:
name = loop_retry( name = loop_retry(
agent, agent,
@ -89,15 +87,120 @@ def generate_room(
name=name, name=name,
) )
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]
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( return Room(
name=name, description=desc, items=items, actors=actors, actions=actions 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( def generate_item(
agent: Agent, agent: Agent,
world_theme: str, world_theme: str,
@ -136,13 +239,19 @@ def generate_item(
actions = {} actions = {}
item = Item(name=name, description=desc, actions=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}") broadcast_generated(message=f"Generating {effect_count} effects for item: {name}")
effects = [] effects = []
for i in range(effect_count): for i in range(effect_count):
existing_effects = [effect.name for effect in effects]
try: try:
effect = generate_effect(agent, world_theme, entity=item) effect = generate_effect(
agent, world_theme, entity=item, existing_effects=existing_effects
)
effects.append(effect) effects.append(effect)
except Exception: except Exception:
logger.exception("error generating effect") logger.exception("error generating effect")
@ -156,6 +265,7 @@ def generate_actor(
world_theme: str, world_theme: str,
dest_room: str, dest_room: str,
existing_actors: List[str] = [], existing_actors: List[str] = [],
systems: List[GameSystem] = [],
) -> Actor: ) -> Actor:
name = loop_retry( name = loop_retry(
agent, agent,
@ -186,18 +296,59 @@ def generate_actor(
name=name, 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( return Actor(
name=name, name=name,
backstory=backstory, backstory=backstory,
description=description, description=description,
actions={}, actions={},
items=items,
) )
def generate_effect(agent: Agent, theme: str, entity: Item) -> Effect: # TODO: move to utils
entity_type = entity.type 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( name = loop_retry(
agent, agent,
@ -252,28 +403,50 @@ def generate_effect(agent: Agent, theme: str, entity: Item) -> Effect:
"prepend", "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( 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 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, name=name,
attribute_name=attribute_name, attribute_name=attribute_name,
) )
value = value.strip() value = value.strip()
if value.isdigit():
value = int(value) int_value = try_parse_int(value)
if int_value is not None:
attribute_effect = NumberAttributeEffect( attribute_effect = NumberAttributeEffect(
name=attribute_name, operation=operation, value=value name=attribute_name, operation=operation, value=int_value
)
elif value.isdecimal():
value = float(value)
attribute_effect = NumberAttributeEffect(
name=attribute_name, operation=operation, value=value
) )
else: else:
attribute_effect = StringAttributeEffect( float_value = try_parse_float(value)
name=attribute_name, operation=operation, value=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) attributes.append(attribute_effect)
@ -293,121 +466,67 @@ def generate_world(
name: str, name: str,
theme: str, theme: str,
room_count: int | None = None, room_count: int | None = None,
max_rooms: int = 5,
systems: List[GameSystem] = [], systems: List[GameSystem] = [],
) -> World: ) -> 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") 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 # generate the rooms
rooms = [] rooms = []
for i in range(room_count): for i in range(room_count):
existing_rooms = [room.name for room in rooms]
try: try:
room = generate_room(agent, theme, existing_rooms=existing_rooms) room = generate_room(agent, theme, existing_rooms=existing_rooms)
generate_system_attributes(agent, theme, room, systems) generate_system_attributes(agent, theme, room, systems)
broadcast_generated(entity=room) broadcast_generated(entity=room)
rooms.append(room) rooms.append(room)
existing_rooms.append(room.name)
except Exception: except Exception:
logger.exception("error generating room") logger.exception("error generating room")
continue 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 # generate portals to link the rooms together
for room in rooms: for room in rooms:
directions = ["north", "south", "east", "west"] num_portals = randint(
for direction in directions: world_config.size.portals.min, world_config.size.portals.max
if direction in room.portals: )
logger.debug(f"Room {room.name} already has a {direction} portal")
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 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 # ensure actors act in a stable order
order = [actor.name for room in rooms for actor in room.actors] order = [actor.name for room in rooms for actor in room.actors]
return World(name=name, rooms=rooms, theme=theme, order=order) return World(name=name, rooms=rooms, theme=theme, order=order)

View File

@ -91,12 +91,6 @@ def parse_args():
default="", default="",
help="Some additional flavor text for the generated world", 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( parser.add_argument(
"--optional-actions", "--optional-actions",
action="store_true", action="store_true",
@ -216,7 +210,6 @@ def load_or_generate_world(args, players, systems, world_prompt: WorldPrompt):
args.world, args.world,
world_prompt.theme, world_prompt.theme,
room_count=args.rooms, room_count=args.rooms,
max_rooms=args.max_rooms,
) )
save_world(world, world_file) save_world(world, world_file)

View File

@ -47,11 +47,27 @@ class ServerConfig:
websocket: WebsocketServerConfig 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 @dataclass
class Config: class Config:
bot: BotConfig bot: BotConfig
render: RenderConfig render: RenderConfig
server: ServerConfig server: ServerConfig
world: WorldConfig
DEFAULT_CONFIG = Config( DEFAULT_CONFIG = Config(
@ -69,5 +85,15 @@ DEFAULT_CONFIG = Config(
}, },
steps=Range(min=30, max=30), 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),
)
),
) )

View File

@ -67,6 +67,17 @@ class Actor(BaseModel):
type: Literal["actor"] = "actor" 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 @dataclass
class Room(BaseModel): class Room(BaseModel):
name: str name: str
@ -75,7 +86,7 @@ class Room(BaseModel):
actions: Actions = Field(default_factory=dict) actions: Actions = Field(default_factory=dict)
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: Dict[str, str] = Field(default_factory=dict) portals: List[Portal] = Field(default_factory=list)
id: str = Field(default_factory=uuid) id: str = Field(default_factory=uuid)
type: Literal["room"] = "room" type: Literal["room"] = "room"

View File

@ -280,7 +280,7 @@ def launch_server(config: WebsocketServerConfig):
async def server_main(): 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") logger.info("websocket server started")
await asyncio.Future() # run forever await asyncio.Future() # run forever

View File

@ -98,7 +98,7 @@ def simulate_world(
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 = list(room.portals.keys()) room_directions = [portal.name for portal in room.portals]
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]

View File

@ -40,8 +40,8 @@ def graph_world(world: World, step: int):
for room in world.rooms: for room in world.rooms:
room_label = "\n".join([room.name, *[actor.name for actor in room.actors]]) room_label = "\n".join([room.name, *[actor.name for actor in room.actors]])
graph.node(room.name, room_label) graph.node(room.name, room_label)
for direction, destination in room.portals.items(): for portal in room.portals:
graph.edge(room.name, destination, label=direction) graph.edge(room.name, portal.destination, label=portal.name)
graph_path = path.dirname(world.name) graph_path = path.dirname(world.name)
graph.render(directory=graph_path) graph.render(directory=graph_path)