diff --git a/.vscode/launch.json b/.vscode/launch.json index db9d4d8..7e3cbba 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -11,9 +11,11 @@ "program": "${file}", "args": [ "--world", - "worlds/cyber.json", - "--world-state", - "worlds/cyber-state.json" + "worlds/test-1.json", + "--rooms", + "2", + "--server", + "--optional-actions" ], "console": "integratedTerminal", "cwd": "${workspaceFolder}", diff --git a/adventure/actions.py b/adventure/actions.py index 4028442..602deb5 100644 --- a/adventure/actions.py +++ b/adventure/actions.py @@ -10,6 +10,7 @@ from adventure.search import ( find_item_in_room, find_room, ) +from adventure.utils.world import describe_entity logger = getLogger(__name__) @@ -26,28 +27,28 @@ def action_look(target: str) -> str: if target.lower() == action_room.name.lower(): broadcast(f"{action_actor.name} saw the {action_room.name} room") - return action_room.description + return describe_entity(action_room) target_actor = find_actor_in_room(action_room, target) if target_actor: broadcast( f"{action_actor.name} saw the {target_actor.name} actor in the {action_room.name} room" ) - return target_actor.description + return describe_entity(target_actor) target_item = find_item_in_room(action_room, target) if target_item: broadcast( f"{action_actor.name} saw the {target_item.name} item in the {action_room.name} room" ) - return target_item.description + return describe_entity(target_item) target_item = find_item_in_actor(action_actor, target) if target_item: broadcast( f"{action_actor.name} saw the {target_item.name} item in their inventory" ) - return target_item.description + return describe_entity(target_item) return "You do not see that item or character in the room." @@ -104,18 +105,13 @@ def action_ask(character: str, question: str) -> str: question: The question to ask them. """ # capture references to the current actor and room, because they will be overwritten - _, action_room, action_actor = get_current_context() - - if not action_actor or not action_room: - raise ValueError( - "The current actor and room must be set before calling action_ask" - ) + _world, _room, action_actor = get_current_context() # sanity checks - if character == action_actor.name: + question_actor, question_agent = get_actor_agent_for_name(character) + if question_actor == action_actor: return "You cannot ask yourself a question. Stop talking to yourself. Try another action." - question_actor, question_agent = get_actor_agent_for_name(character) if not question_actor: return f"The {character} character is not in the room." @@ -147,18 +143,13 @@ def action_tell(character: str, message: str) -> str: message: The message to tell them. """ # capture references to the current actor and room, because they will be overwritten - _, action_room, action_actor = get_current_context() - - if not action_actor or not action_room: - raise ValueError( - "The current actor and room must be set before calling action_tell" - ) + _world, _room, action_actor = get_current_context() # sanity checks - if character == action_actor.name: + question_actor, question_agent = get_actor_agent_for_name(character) + if question_actor == action_actor: return "You cannot tell yourself a message. Stop talking to yourself. Try another action." - question_actor, question_agent = get_actor_agent_for_name(character) if not question_actor: return f"The {character} character is not in the room." diff --git a/adventure/context.py b/adventure/context.py index 87dece3..a7a50d0 100644 --- a/adventure/context.py +++ b/adventure/context.py @@ -1,7 +1,8 @@ -from typing import Callable, Dict, Tuple +from typing import Callable, Dict, List, Sequence, Tuple from packit.agent import Agent +from adventure.game_system import GameSystem from adventure.models.entity import Actor, Room, World from adventure.models.event import GameEvent @@ -11,6 +12,7 @@ current_room: Room | None = None current_actor: Actor | None = None current_step = 0 dungeon_master: Agent | None = None +game_systems: List[GameSystem] = [] # TODO: where should this one go? @@ -71,6 +73,10 @@ def get_dungeon_master() -> Agent: return dungeon_master +def get_game_systems() -> List[GameSystem]: + return game_systems + + # endregion @@ -109,6 +115,11 @@ def set_dungeon_master(agent): dungeon_master = agent +def set_game_systems(systems: Sequence[GameSystem]): + global game_systems + game_systems = list(systems) + + # endregion diff --git a/adventure/game_system.py b/adventure/game_system.py new file mode 100644 index 0000000..68f2ba9 --- /dev/null +++ b/adventure/game_system.py @@ -0,0 +1,66 @@ +from enum import Enum +from typing import Callable, Protocol + +from packit.agent import Agent + +from adventure.models.entity import World, WorldEntity + + +class FormatPerspective(Enum): + FIRST_PERSON = "first-person" + SECOND_PERSON = "second-person" + THIRD_PERSON = "third-person" + + +# TODO: remove the attributes parameter from all of these +class SystemFormat(Protocol): + def __call__( + self, + entity: WorldEntity, + perspective: FormatPerspective = FormatPerspective.SECOND_PERSON, + ) -> str: + # TODO: should this return a list? + ... + + +class SystemGenerate(Protocol): + def __call__(self, agent: Agent, theme: str, entity: WorldEntity) -> None: + """ + Generate a new world entity based on the given theme and entity. + """ + ... + + +class SystemSimulate(Protocol): + def __call__(self, world: World, step: int) -> None: + """ + Simulate the world for the given step. + """ + ... + + +class GameSystem: + format: SystemFormat | None = None + generate: SystemGenerate | None = None + simulate: SystemSimulate | None = None + # render: TODO + + def __init__( + self, + format: SystemFormat | None = None, + generate: SystemGenerate | None = None, + simulate: SystemSimulate | None = None, + ): + self.format = format + self.generate = generate + self.simulate = simulate + + def __str__(self): + return f"GameSystem(format={format_callable(self.format)}, generate={format_callable(self.generate)}, simulate={format_callable(self.simulate)})" + + +def format_callable(fn: Callable | None) -> str: + if fn: + return f"{fn.__module__}:{fn.__name__}" + + return "None" diff --git a/adventure/generate.py b/adventure/generate.py index 926e410..0a689e5 100644 --- a/adventure/generate.py +++ b/adventure/generate.py @@ -4,8 +4,19 @@ from typing import List from packit.agent import Agent from packit.loops import loop_retry +from packit.utils import could_be_json -from adventure.models.entity import Actor, Item, Room, World, WorldEntity +from adventure.game_system import GameSystem +from adventure.models.entity import ( + Actor, + Effect, + Item, + NumberAttributeEffect, + Room, + StringAttributeEffect, + World, + WorldEntity, +) from adventure.models.event import EventCallback, GenerateEvent logger = getLogger(__name__) @@ -19,19 +30,22 @@ OPPOSITE_DIRECTIONS = { def duplicate_name_parser(existing_names: List[str]): - def name_parser(name: str, **kwargs): - logger.debug(f"validating name: {name}") + def name_parser(value: str, **kwargs): + print(f"validating generated name: {value}") - if name in existing_names: - raise ValueError(f'"{name}" has already been used.') + if value in existing_names: + raise ValueError(f'"{value}" has already been used.') - if '"' in name or ":" in name: + if could_be_json(value): + raise ValueError("The name cannot contain JSON or other commands.") + + if '"' in value or ":" in value: raise ValueError("The name cannot contain quotes or colons.") - if len(name) > 50: + if len(value) > 50: raise ValueError("The name cannot be longer than 50 characters.") - return name + return value return name_parser @@ -123,8 +137,23 @@ def generate_item( ) actions = {} + item = Item(name=name, description=desc, actions=actions) - return Item(name=name, description=desc, actions=actions) + effect_count = randint(1, 2) + callback_wrapper( + callback, message=f"Generating {effect_count} effects for item: {name}" + ) + + effects = [] + for i in range(effect_count): + try: + effect = generate_effect(agent, world_theme, entity=item, callback=callback) + effects.append(effect) + except Exception: + logger.exception("error generating effect") + + item.effects = effects + return item def generate_actor( @@ -171,6 +200,100 @@ def generate_actor( ) +def generate_effect( + agent: Agent, theme: str, entity: Item, callback: EventCallback | None = None +) -> Effect: + entity_type = entity.type + + existing_effects = [effect.name for effect in entity.effects] + + name = loop_retry( + agent, + "Generate one effect for an {entity_type} named {entity.name} that would make sense in the world of {theme}. " + "Only respond with the effect 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 effects on the same item. The existing effects are: {existing_effects}. " + "Some example effects are: 'fire', 'poison', 'frost', 'haste', 'slow', and 'heal'.", + context={ + "entity_type": entity_type, + "existing_effects": existing_effects, + "theme": theme, + }, + result_parser=duplicate_name_parser(existing_effects), + ) + callback_wrapper(callback, message=f"Generating effect: {name}") + + description = agent( + "Generate a detailed description of the {name} effect. What does it look like? What does it do? " + "How does it affect the target? Describe the effect from the perspective of an outside observer.", + name=name, + ) + + attribute_names = agent( + "Generate a list of attributes that the {name} effect modifies. " + "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, + ) + + attributes = [] + for attribute_name in attribute_names.split(","): + attribute_name = attribute_name.strip() + if attribute_name: + operation = 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." + "Choose from the following operations: {operations}", + name=name, + attribute_name=attribute_name, + operations=[ + "set", + "add", + "subtract", + "multiply", + "divide", + "append", + "prepend", + ], + ) + 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.", + name=name, + attribute_name=attribute_name, + ) + value = value.strip() + if value.isdigit(): + value = int(value) + 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 + ) + else: + attribute_effect = StringAttributeEffect( + name=attribute_name, operation=operation, value=value + ) + + attributes.append(attribute_effect) + + return Effect(name=name, description=description, 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, @@ -178,6 +301,7 @@ def generate_world( room_count: int | None = None, max_rooms: int = 5, callback: EventCallback | None = None, + systems: List[GameSystem] = [], ) -> World: room_count = room_count or randint(3, max_rooms) @@ -194,6 +318,7 @@ def generate_world( room = generate_room( agent, theme, existing_rooms=existing_rooms, callback=callback ) + generate_system_attributes(agent, theme, room, systems) callback_wrapper(callback, entity=room) rooms.append(room) existing_rooms.append(room.name) @@ -215,6 +340,7 @@ def generate_world( existing_items=existing_items, callback=callback, ) + generate_system_attributes(agent, theme, item, systems) callback_wrapper(callback, entity=item) room.items.append(item) @@ -236,6 +362,7 @@ def generate_world( existing_actors=existing_actors, callback=callback, ) + generate_system_attributes(agent, theme, actor, systems) callback_wrapper(callback, entity=actor) room.actors.append(actor) @@ -259,6 +386,7 @@ def generate_world( existing_items=existing_items, callback=callback, ) + generate_system_attributes(agent, theme, item, systems) callback_wrapper(callback, entity=item) actor.items.append(item) diff --git a/adventure/logic.py b/adventure/logic.py index de39817..9807a31 100644 --- a/adventure/logic.py +++ b/adventure/logic.py @@ -7,13 +7,12 @@ from pydantic import Field from rule_engine import Rule from yaml import Loader, load +from adventure.game_system import FormatPerspective, GameSystem from adventure.models.entity import ( - Actor, Attributes, AttributeValue, - Item, - Room, World, + WorldEntity, dataclass, ) from adventure.plugins import get_plugin_function @@ -44,16 +43,15 @@ class LogicTable: labels: Dict[str, Dict[AttributeValue, LogicLabel]] = Field(default_factory=dict) -LogicTrigger = Callable[[Room | Actor | Item, Attributes], Attributes] +LogicTrigger = Callable[[WorldEntity], None] TriggerTable = Dict[str, LogicTrigger] def update_attributes( - entity: Room | Actor | Item, - attributes: Attributes, + entity: WorldEntity, rules: LogicTable, triggers: TriggerTable, -) -> Attributes: +) -> None: entity_type = entity.__class__.__name__.lower() skip_groups = set() @@ -64,7 +62,7 @@ def update_attributes( continue typed_attributes = { - **attributes, + **entity.attributes, "type": entity_type, } @@ -93,52 +91,46 @@ def update_attributes( skip_groups.add(rule.group) for key in rule.remove or []: - attributes.pop(key, None) + entity.attributes.pop(key, None) if rule.set: - attributes.update(rule.set) + entity.attributes.update(rule.set) logger.info("logic set state: %s", rule.set) if rule.trigger: for trigger in rule.trigger: if trigger in triggers: - attributes = triggers[trigger](entity, attributes) - - return attributes + triggers[trigger](entity) def update_logic( world: World, step: int, rules: LogicTable, triggers: TriggerTable ) -> None: for room in world.rooms: - room.attributes = update_attributes( - room, room.attributes, rules=rules, triggers=triggers - ) + update_attributes(room, rules=rules, triggers=triggers) for actor in room.actors: - actor.attributes = update_attributes( - actor, actor.attributes, rules=rules, triggers=triggers - ) + update_attributes(actor, rules=rules, triggers=triggers) for item in actor.items: - item.attributes = update_attributes( - item, item.attributes, rules=rules, triggers=triggers - ) + update_attributes(item, rules=rules, triggers=triggers) for item in room.items: - item.attributes = update_attributes( - item, item.attributes, rules=rules, triggers=triggers - ) + update_attributes(item, rules=rules, triggers=triggers) logger.info("updated world attributes") -def format_logic(attributes: Attributes, rules: LogicTable, self=True) -> str: +def format_logic( + entity: WorldEntity, + rules: LogicTable, + perspective: FormatPerspective = FormatPerspective.SECOND_PERSON, +) -> str: labels = [] - for attribute, value in attributes.items(): + for attribute, value in entity.attributes.items(): if attribute in rules.labels and value in rules.labels[attribute]: label = rules.labels[attribute][value] - if self: + if perspective == FormatPerspective.SECOND_PERSON: labels.append(label.backstory) - elif label.description: + elif perspective == FormatPerspective.THIRD_PERSON and label.description: labels.append(label.description) else: logger.debug("label has no relevant description: %s", label) @@ -149,7 +141,7 @@ def format_logic(attributes: Attributes, rules: LogicTable, self=True) -> str: return " ".join(labels) -def init_from_file(filename: str): +def load_logic(filename: str): logger.info("loading logic from file: %s", filename) with open(filename) as file: logic_rules = LogicTable(**load(file, Loader=Loader)) @@ -161,9 +153,12 @@ def init_from_file(filename: str): logic_triggers[trigger] = get_plugin_function(trigger) logger.info("initialized logic system") - return ( - wraps(update_logic)( - partial(update_logic, rules=logic_rules, triggers=logic_triggers) - ), - wraps(format_logic)(partial(format_logic, rules=logic_rules)), + system_simulate = wraps(update_logic)( + partial(update_logic, rules=logic_rules, triggers=logic_triggers) + ) + system_format = wraps(format_logic)(partial(format_logic, rules=logic_rules)) + + return GameSystem( + format=system_format, + simulate=system_simulate, ) diff --git a/adventure/main.py b/adventure/main.py index d036423..859386d 100644 --- a/adventure/main.py +++ b/adventure/main.py @@ -9,6 +9,7 @@ from packit.utils import logger_with_colors from yaml import Loader, load from adventure.context import set_current_step, set_dungeon_master +from adventure.game_system import GameSystem from adventure.generate import generate_world from adventure.models.config import Config from adventure.models.entity import World, WorldState @@ -83,6 +84,7 @@ def parse_args(): ) parser.add_argument( "--max-rooms", + default=6, type=int, help="The maximum number of rooms to generate", ) @@ -113,7 +115,7 @@ def parse_args(): ) parser.add_argument( "--server", - type=str, + action="store_true", help="The address on which to run the server", ) parser.add_argument( @@ -220,8 +222,9 @@ def load_or_generate_world( save_world(world, world_file) # run the systems once to initialize everything - for system_update, _ in systems: - system_update(world, 0) + for system in systems: + if system.simulate: + system.simulate(world, 0) create_agents(world, memory=memory, players=players) return (world, world_state_file) @@ -299,18 +302,16 @@ def main(): extra_actions.extend(module_actions) # load extra systems from plugins - extra_systems = [] + extra_systems: List[GameSystem] = [] for system_name in args.systems or []: logger.info(f"loading extra systems from {system_name}") module_systems = load_plugin(system_name) - logger.info( - f"loaded extra systems: {[component.__name__ for system in module_systems for component in system]}" - ) + logger.info(f"loaded extra systems: {module_systems}") extra_systems.extend(module_systems) # make sure the server system runs after any updates if args.server: - extra_systems.append((server_system, None)) + extra_systems.append(GameSystem(simulate=server_system)) # load or generate the world world_prompt = get_world_prompt(args) @@ -323,7 +324,7 @@ def main(): logger.info("taking snapshot of world state") save_world_state(world, step, world_state_file) - extra_systems.append((snapshot_system, None)) + extra_systems.append(GameSystem(simulate=snapshot_system)) # hack: send a snapshot to the websocket server if args.server: diff --git a/adventure/models/entity.py b/adventure/models/entity.py index 3a31318..c9d1cd2 100644 --- a/adventure/models/entity.py +++ b/adventure/models/entity.py @@ -5,16 +5,51 @@ from pydantic import Field from .base import BaseModel, dataclass, uuid Actions = Dict[str, Callable] -AttributeValue = bool | int | str +AttributeValue = bool | float | int | str Attributes = Dict[str, AttributeValue] +@dataclass +class StringAttributeEffect: + name: str + operation: Literal["set", "append", "prepend"] + value: str + + +@dataclass +class NumberAttributeEffect: + name: str + operation: Literal["set", "add", "subtract", "multiply", "divide"] + # TODO: make this a range + value: int | float + + +@dataclass +class BooleanAttributeEffect: + name: str + operation: Literal["set", "toggle"] + value: bool + + +AttributeEffect = StringAttributeEffect | NumberAttributeEffect | BooleanAttributeEffect + + +@dataclass +class Effect(BaseModel): + name: str + description: str + attributes: list[AttributeEffect] = Field(default_factory=list) + id: str = Field(default_factory=uuid) + type: Literal["effect"] = "effect" + + @dataclass class Item(BaseModel): name: str description: str actions: Actions = Field(default_factory=dict) attributes: Attributes = Field(default_factory=dict) + effects: List[Effect] = Field(default_factory=list) items: List["Item"] = Field(default_factory=list) id: str = Field(default_factory=uuid) type: Literal["item"] = "item" diff --git a/adventure/optional_actions.py b/adventure/optional_actions.py index 3cb9e28..04c0c7c 100644 --- a/adventure/optional_actions.py +++ b/adventure/optional_actions.py @@ -13,6 +13,8 @@ from adventure.context import ( ) from adventure.generate import OPPOSITE_DIRECTIONS, generate_item, generate_room from adventure.search import find_actor_in_room +from adventure.utils.effect import apply_effect +from adventure.utils.world import describe_actor, describe_entity logger = getLogger(__name__) @@ -128,10 +130,35 @@ def action_use(item: str, target: str) -> str: if not target_actor: return f"The {target} character is not in the room." - broadcast(f"{action_actor.name} uses {item} on {target}") - outcome = dungeon_master( + effect_names = [effect.name for effect in action_item.effects] + chosen_name = dungeon_master( f"{action_actor.name} uses {item} on {target}. " - f"{action_actor.description}. {target_actor.description}. {action_item.description}. " + f"{item} has the following effects: {effect_names}. " + "Which effect should be applied? Specify the name of the effect to apply." + "Do not include the question or any JSON. Only include the name of the effect to apply." + ) + chosen_name = chosen_name.strip() + + chosen_effect = next( + ( + search_effect + for search_effect in action_item.effects + if search_effect.name == chosen_name + ), + None, + ) + if not chosen_effect: + # TODO: should retry the question if the effect is not found + return f"The {chosen_name} effect is not available to apply." + + apply_effect(chosen_effect, target_actor.attributes) + + broadcast( + f"{action_actor.name} uses the {chosen_name} effect of {item} on {target}" + ) + outcome = dungeon_master( + f"{action_actor.name} uses the {chosen_name} effect of {item} on {target}. " + f"{describe_actor(action_actor)}. {describe_actor(target_actor)}. {describe_entity(action_item)}. " f"What happens? How does {target} react? Be creative with the results. The outcome can be good, bad, or neutral." "Decide based on the characters involved and the item being used." "Specify the outcome of the action. Do not include the question or any JSON. Only include the outcome of the action." diff --git a/adventure/render_comfy.py b/adventure/render_comfy.py index bec483b..9660d54 100644 --- a/adventure/render_comfy.py +++ b/adventure/render_comfy.py @@ -27,6 +27,7 @@ from adventure.models.event import ( ResultEvent, StatusEvent, ) +from adventure.utils.world import describe_entity logger = getLogger(__name__) @@ -224,23 +225,26 @@ def generate_images( def prompt_from_event(event: GameEvent) -> str | None: if isinstance(event, ActionEvent): if event.item: - return f"{event.actor.name} uses the {event.item.name}. {event.item.description}. {event.actor.description}. {event.room.description}." + return ( + f"{event.actor.name} uses the {event.item.name}. {describe_entity(event.item)}. " + f"{describe_entity(event.actor)}. {describe_entity(event.room)}." + ) action_name = event.action.removeprefix("action_") - return f"{event.actor.name} uses {action_name}. {event.actor.description}. {event.room.description}." + return f"{event.actor.name} uses {action_name}. {describe_entity(event.actor)}. {describe_entity(event.room)}." if isinstance(event, ReplyEvent): return event.text if isinstance(event, ResultEvent): - return f"{event.result}. {event.actor.description}. {event.room.description}." + return f"{event.result}. {describe_entity(event.actor)}. {describe_entity(event.room)}." if isinstance(event, StatusEvent): if event.room: if event.actor: - return f"{event.text}. {event.actor.description}. {event.room.description}." + return f"{event.text}. {describe_entity(event.actor)}. {describe_entity(event.room)}." - return f"{event.text}. {event.room.description}." + return f"{event.text}. {describe_entity(event.room)}." return event.text @@ -248,7 +252,7 @@ def prompt_from_event(event: GameEvent) -> str | None: def prompt_from_entity(entity: WorldEntity) -> str: - return entity.description + return describe_entity(entity) def sanitize_name(name: str) -> str: diff --git a/adventure/sim_systems/__init__.py b/adventure/sim_systems/__init__.py index 6018af9..33341bb 100644 --- a/adventure/sim_systems/__init__.py +++ b/adventure/sim_systems/__init__.py @@ -2,7 +2,7 @@ from .hunger_actions import action_cook, action_eat from .hygiene_actions import action_wash from .sleeping_actions import action_sleep -from adventure.logic import init_from_file +from adventure.logic import load_logic LOGIC_FILES = [ "./adventure/sim_systems/environment_logic.yaml", @@ -26,4 +26,4 @@ def init_actions(): def init_logic(): - return [init_from_file(filename) for filename in LOGIC_FILES] + return [load_logic(filename) for filename in LOGIC_FILES] diff --git a/adventure/sim_systems/combat_actions.py b/adventure/sim_systems/combat_actions.py index 58fe6ac..2c30be9 100644 --- a/adventure/sim_systems/combat_actions.py +++ b/adventure/sim_systems/combat_actions.py @@ -5,6 +5,7 @@ from adventure.context import ( get_dungeon_master, ) from adventure.search import find_actor_in_room, find_item_in_room +from adventure.utils.world import describe_entity def action_attack(target: str) -> str: @@ -33,8 +34,8 @@ def action_attack(target: str) -> str: ) outcome = dungeon_master( - f"{action_actor.name} attacks {target} in the {action_room.name}. {action_room.description}." - f"{action_actor.description}. {target_actor.description}." + f"{action_actor.name} attacks {target} in the {action_room.name}. {describe_entity(action_room)}." + f"{describe_entity(action_actor)}. {describe_entity(target_actor)}." f"{target} reacts by {reaction}. What is the outcome of the attack? Describe the result in detail." ) @@ -46,8 +47,8 @@ def action_attack(target: str) -> str: return description elif target_item: outcome = dungeon_master( - f"{action_actor.name} attacks {target} in the {action_room.name}. {action_room.description}." - f"{action_actor.description}. {target_item.description}." + f"{action_actor.name} attacks {target} in the {action_room.name}. {describe_entity(action_room)}." + f"{describe_entity(action_actor)}. {describe_entity(target_item)}." f"What is the outcome of the attack? Describe the result in detail." ) @@ -78,8 +79,8 @@ def action_cast(target: str, spell: str) -> str: dungeon_master = get_dungeon_master() outcome = dungeon_master( - f"{action_actor.name} casts {spell} on {target} in the {action_room.name}. {action_room.description}." - f"{action_actor.description}. {target_actor.description if target_actor else target_item.description}." + f"{action_actor.name} casts {spell} on {target} in the {action_room.name}. {describe_entity(action_room)}." + f"{describe_entity(action_actor)}. {describe_entity(target_actor) if target_actor else describe_entity(target_item)}." f"What is the outcome of the spell? Describe the result in detail." ) diff --git a/adventure/sim_systems/hunger_logic.yaml b/adventure/sim_systems/hunger_logic.yaml index f9ab4ba..bc1f6af 100644 --- a/adventure/sim_systems/hunger_logic.yaml +++ b/adventure/sim_systems/hunger_logic.yaml @@ -1,6 +1,7 @@ rules: # cooking logic - - match: + - group: cooking + match: type: item edible: true cooked: false @@ -8,7 +9,8 @@ rules: set: spoiled: true - - match: + - group: cooking + match: type: item edible: true cooked: true @@ -17,7 +19,8 @@ rules: spoiled: true # hunger logic - - match: + - group: hunger + match: type: actor hunger: full chance: 0.1 @@ -25,13 +28,15 @@ rules: hunger: hungry # hunger initialization - - rule: | + - group: hunger + rule: | "hunger" not in attributes set: hunger: full # thirst logic - - match: + - group: thirst + match: type: actor thirst: hydrated chance: 0.1 @@ -39,7 +44,8 @@ rules: thirst: thirsty # thirst initialization - - rule: | + - group: thirst + rule: | "thirst" not in attributes set: thirst: hydrated diff --git a/adventure/sim_systems/hygiene_actions.py b/adventure/sim_systems/hygiene_actions.py index c187468..d14bf6e 100644 --- a/adventure/sim_systems/hygiene_actions.py +++ b/adventure/sim_systems/hygiene_actions.py @@ -1,4 +1,5 @@ from adventure.context import get_current_context, get_dungeon_master +from adventure.utils.world import describe_entity def action_wash(unused: bool) -> str: @@ -11,7 +12,7 @@ def action_wash(unused: bool) -> str: dungeon_master = get_dungeon_master() outcome = dungeon_master( - f"{action_actor.name} washes themselves in the {action_room.name}. {action_room.description}. {action_actor.description}" + f"{action_actor.name} washes themselves in the {action_room.name}. {describe_entity(action_room)}. {describe_entity(action_actor)}" f"{action_actor.name} was {hygiene} to start with. How clean are they after washing? Respond with 'clean' or 'dirty'." "If the room has a shower or running water, they should be cleaner. If the room is dirty, they should end up dirtier." ) diff --git a/adventure/sim_systems/sleeping_actions.py b/adventure/sim_systems/sleeping_actions.py index 850fed3..40403d4 100644 --- a/adventure/sim_systems/sleeping_actions.py +++ b/adventure/sim_systems/sleeping_actions.py @@ -1,4 +1,5 @@ from adventure.context import get_current_context, get_dungeon_master +from adventure.utils.world import describe_entity def action_sleep(unused: bool) -> str: @@ -10,7 +11,7 @@ def action_sleep(unused: bool) -> str: dungeon_master = get_dungeon_master() outcome = dungeon_master( - f"{action_actor.name} sleeps in the {action_room.name}. {action_room.description}. {action_actor.description}" + f"{action_actor.name} sleeps in the {action_room.name}. {describe_entity(action_room)}. {describe_entity(action_actor)}" "How rested are they? Respond with 'rested' or 'tired'." ) diff --git a/adventure/simulate.py b/adventure/simulate.py index 7e9f4b0..de999c1 100644 --- a/adventure/simulate.py +++ b/adventure/simulate.py @@ -1,5 +1,5 @@ from logging import getLogger -from typing import Callable, Sequence, Tuple +from typing import Callable, Sequence from packit.loops import loop_retry from packit.results import multi_function_or_str_result @@ -24,8 +24,10 @@ from adventure.context import ( set_current_room, set_current_step, set_current_world, + set_game_systems, ) -from adventure.models.entity import Attributes, World +from adventure.game_system import GameSystem +from adventure.models.entity import World from adventure.models.event import ( ActionEvent, EventCallback, @@ -34,6 +36,7 @@ from adventure.models.event import ( ResultEvent, StatusEvent, ) +from adventure.utils.world import describe_entity, format_attributes logger = getLogger(__name__) @@ -62,13 +65,12 @@ def simulate_world( world: World, steps: int = 10, actions: Sequence[Callable[..., str]] = [], - systems: Sequence[ - Tuple[Callable[[World, int], None], Callable[[Attributes], str] | None] - ] = [], callbacks: Sequence[EventCallback] = [], + systems: Sequence[GameSystem] = [], ): logger.info("Simulating the world") set_current_world(world) + set_game_systems(systems) # set up a broadcast callback def broadcast_callback(message: str | GameEvent): @@ -116,11 +118,7 @@ def simulate_world( room_items = [item.name for item in room.items] room_directions = list(room.portals.keys()) - actor_attributes = " ".join( - system_format(actor.attributes) - for _, system_format in systems - if system_format - ) + actor_attributes = format_attributes(actor) actor_items = [item.name for item in actor.items] def result_parser(value, agent, **kwargs): @@ -161,7 +159,7 @@ def simulate_world( "attributes": actor_attributes, "directions": room_directions, "room_name": room.name, - "room_description": room.description, + "room_description": describe_entity(room), "visible_actors": room_actors, "visible_items": room_items, }, @@ -176,7 +174,8 @@ def simulate_world( for callback in callbacks: callback(result_event) - for system_update, _ in systems: - system_update(world, current_step) + for system in systems: + if system.simulate: + system.simulate(world, current_step) set_current_step(current_step + 1) diff --git a/adventure/utils/attribute.py b/adventure/utils/attribute.py new file mode 100644 index 0000000..2834ed9 --- /dev/null +++ b/adventure/utils/attribute.py @@ -0,0 +1,120 @@ +from adventure.models.entity import Attributes, AttributeValue + + +def add_attribute(attributes: Attributes, name: str, value: int | float) -> Attributes: + """ + Add an attribute to a set of attributes. + """ + if name in attributes: + previous_value = attributes[name] + if isinstance(previous_value, str): + raise ValueError(f"Cannot add a number to a string attribute: {name}") + + attributes[name] = value + previous_value + else: + attributes[name] = value + + return attributes + + +def subtract_attribute( + attributes: Attributes, name: str, value: int | float +) -> Attributes: + """ + Subtract an attribute from a set of attributes. + """ + if name in attributes: + previous_value = attributes[name] + if isinstance(previous_value, str): + raise ValueError( + f"Cannot subtract a number from a string attribute: {name}" + ) + + attributes[name] = value - previous_value + else: + attributes[name] = -value + + return attributes + + +def multiply_attribute( + attributes: Attributes, name: str, value: int | float +) -> Attributes: + """ + Multiply an attribute in a set of attributes. + """ + if name in attributes: + previous_value = attributes[name] + if isinstance(previous_value, str): + raise ValueError(f"Cannot multiply a string attribute: {name}") + + attributes[name] = previous_value * value + else: + attributes[name] = 0 + + return attributes + + +def divide_attribute( + attributes: Attributes, name: str, value: int | float +) -> Attributes: + """ + Divide an attribute in a set of attributes. + """ + if name in attributes: + previous_value = attributes[name] + if isinstance(previous_value, str): + raise ValueError(f"Cannot divide a string attribute: {name}") + + attributes[name] = previous_value / value + else: + attributes[name] = 0 + + return attributes + + +def set_attribute( + attributes: Attributes, name: str, value: AttributeValue +) -> Attributes: + """ + Set an attribute in a set of attributes. + """ + attributes[name] = value + + return attributes + + +def append_attribute(attributes: Attributes, name: str, value: str) -> Attributes: + """ + Append a string to an attribute in a set of attributes. + """ + if name in attributes: + previous_value = attributes[name] + if isinstance(previous_value, str): + attributes[name] = previous_value + value + else: + raise ValueError( + f"Cannot append a string to a non-string attribute: {name}" + ) + else: + attributes[name] = value + + return attributes + + +def prepend_attribute(attributes: Attributes, name: str, value: str) -> Attributes: + """ + Prepend a string to an attribute in a set of attributes. + """ + if name in attributes: + previous_value = attributes[name] + if isinstance(previous_value, str): + attributes[name] = value + previous_value + else: + raise ValueError( + f"Cannot prepend a string to a non-string attribute: {name}" + ) + else: + attributes[name] = value + + return attributes diff --git a/adventure/utils/effect.py b/adventure/utils/effect.py new file mode 100644 index 0000000..8557fdb --- /dev/null +++ b/adventure/utils/effect.py @@ -0,0 +1,47 @@ +from typing import List + +from adventure.models.entity import Attributes, Effect +from adventure.utils.attribute import ( + add_attribute, + append_attribute, + divide_attribute, + multiply_attribute, + prepend_attribute, + set_attribute, + subtract_attribute, +) + + +def apply_effect(effect: Effect, attributes: Attributes) -> Attributes: + """ + Apply an effect to a set of attributes. + """ + for attribute in effect.attributes: + if attribute.operation == "set": + set_attribute(attributes, attribute.name, attribute.value) + elif attribute.operation == "add": + add_attribute(attributes, attribute.name, attribute.value) + elif attribute.operation == "subtract": + subtract_attribute(attributes, attribute.name, attribute.value) + elif attribute.operation == "multiply": + multiply_attribute(attributes, attribute.name, attribute.value) + elif attribute.operation == "divide": + divide_attribute(attributes, attribute.name, attribute.value) + elif attribute.operation == "append": + append_attribute(attributes, attribute.name, attribute.value) + elif attribute.operation == "prepend": + prepend_attribute(attributes, attribute.name, attribute.value) + else: + raise ValueError(f"Invalid operation: {attribute.operation}") + + return attributes + + +def apply_effects(effects: List[Effect], attributes: Attributes) -> Attributes: + """ + Apply a list of effects to a set of attributes. + """ + for effect in effects: + attributes = apply_effect(effect, attributes) + + return attributes diff --git a/adventure/utils/world.py b/adventure/utils/world.py new file mode 100644 index 0000000..5831bb0 --- /dev/null +++ b/adventure/utils/world.py @@ -0,0 +1,52 @@ +from logging import getLogger + +from adventure.context import get_game_systems +from adventure.game_system import FormatPerspective +from adventure.models.entity import Actor, WorldEntity + +logger = getLogger(__name__) + + +def describe_actor( + actor: Actor, perspective: FormatPerspective = FormatPerspective.SECOND_PERSON +) -> str: + attribute_descriptions = format_attributes(actor, perspective=perspective) + logger.info("describing actor: %s, %s", actor, attribute_descriptions) + + if perspective == FormatPerspective.SECOND_PERSON: + actor_description = actor.backstory + elif perspective == FormatPerspective.THIRD_PERSON: + actor_description = actor.description + else: + raise ValueError(f"Perspective {perspective} is not implemented") + + return f"{actor_description} {attribute_descriptions}" + + +def describe_static(entity: WorldEntity) -> str: + # TODO: include attributes + return entity.description + + +def describe_entity( + entity: WorldEntity, + perspective: FormatPerspective = FormatPerspective.SECOND_PERSON, +) -> str: + if isinstance(entity, Actor): + return describe_actor(entity, perspective) + + return describe_static(entity) + + +def format_attributes( + entity: WorldEntity, + perspective: FormatPerspective = FormatPerspective.SECOND_PERSON, +) -> str: + systems = get_game_systems() + attribute_descriptions = [ + system.format(entity, perspective=perspective) + for system in systems + if system.format + ] + + return f"{'. '.join(attribute_descriptions)}" diff --git a/client/src/events.tsx b/client/src/events.tsx index 960cf3b..2a8af84 100644 --- a/client/src/events.tsx +++ b/client/src/events.tsx @@ -187,6 +187,12 @@ export function RenderEventItem(props: EventItemProps) { const { images } = event; return + + + + + + Render {Object.entries(images).map(([name, image]) => openImage(image as string)}>