1
0
Fork 0

improve search, prevent duplicate names, validate effect operations

This commit is contained in:
Sean Sube 2024-05-18 23:30:17 -05:00
parent 71d2be85f1
commit 2bb842a559
Signed by: ssube
GPG Key ID: 3EED7B957D362AF1
10 changed files with 218 additions and 161 deletions

View File

@ -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:

View File

@ -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),
)

View File

@ -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)

View File

@ -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

View File

@ -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)

View File

@ -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),

View File

@ -110,4 +110,4 @@ class WorldState(BaseModel):
type: Literal["world_state"] = "world_state"
WorldEntity = Room | Actor | Item
WorldEntity = Room | Actor | Item | Portal

View File

@ -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)

View File

@ -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

View File

@ -21,4 +21,28 @@ render:
height: 1024
steps:
min: 30
max: 50
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