From 1453038f6dc615b0774b68037d9b4df4aae4d4b1 Mon Sep 17 00:00:00 2001 From: Sean Sube Date: Sat, 18 May 2024 16:20:47 -0500 Subject: [PATCH] fix logging, make config optional, fix event attributes --- .vscode/launch.json | 2 +- adventure/bot_discord.py | 4 +- adventure/context.py | 26 ++++++++- adventure/generate.py | 10 ++-- adventure/main.py | 49 ++++++++++------ adventure/models/config.py | 19 ++++++ adventure/render_comfy.py | 16 +----- adventure/rpg_systems/crafting_actions.py | 70 +++++++++++++++++++++++ adventure/rpg_systems/language_actions.py | 22 +++++++ adventure/rpg_systems/magic_actions.py | 36 ++++++++++++ adventure/rpg_systems/movement_actions.py | 36 ++++++++++++ adventure/simulate.py | 12 +++- 12 files changed, 258 insertions(+), 44 deletions(-) create mode 100644 adventure/rpg_systems/crafting_actions.py create mode 100644 adventure/rpg_systems/language_actions.py create mode 100644 adventure/rpg_systems/magic_actions.py create mode 100644 adventure/rpg_systems/movement_actions.py diff --git a/.vscode/launch.json b/.vscode/launch.json index 7e3cbba..52c7ac0 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -11,7 +11,7 @@ "program": "${file}", "args": [ "--world", - "worlds/test-1.json", + "worlds/test-3.json", "--rooms", "2", "--server", diff --git a/adventure/bot_discord.py b/adventure/bot_discord.py index ff67f50..3599b2e 100644 --- a/adventure/bot_discord.py +++ b/adventure/bot_discord.py @@ -13,7 +13,7 @@ from adventure.context import ( get_current_world, set_actor_agent, ) -from adventure.models.config import DiscordBotConfig +from adventure.models.config import DiscordBotConfig, DEFAULT_CONFIG from adventure.models.event import ( ActionEvent, GameEvent, @@ -36,7 +36,7 @@ from adventure.render_comfy import render_event logger = getLogger(__name__) client = None -bot_config: DiscordBotConfig = DiscordBotConfig(channels=["bots"]) +bot_config: DiscordBotConfig = DEFAULT_CONFIG.bot.discord active_tasks = set() event_messages: Dict[str, str | GameEvent] = {} diff --git a/adventure/context.py b/adventure/context.py index a7a50d0..47538ef 100644 --- a/adventure/context.py +++ b/adventure/context.py @@ -1,17 +1,25 @@ from typing import Callable, Dict, List, Sequence, Tuple +from contextlib import contextmanager from packit.agent import Agent +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 +# TODO: replace with event emitter and a context manager current_broadcast: Callable[[str | GameEvent], None] | None = None + +# world context +current_step = 0 current_world: World | None = None current_room: Room | None = None current_actor: Actor | None = None -current_step = 0 dungeon_master: Agent | None = None + +# game context +event_emitter = EventEmitter() game_systems: List[GameSystem] = [] @@ -28,7 +36,23 @@ def has_dungeon_master(): return dungeon_master is not None +# region context manager +# TODO +# endregion + + # region context getters +def get_action_context() -> Tuple[Room, Actor]: + if not current_room: + raise ValueError("The current room must be set before calling action functions") + if not current_actor: + raise ValueError( + "The current actor must be set before calling action functions" + ) + + return (current_room, current_actor) + + def get_current_context() -> Tuple[World, Room, Actor]: if not current_world: raise ValueError( diff --git a/adventure/generate.py b/adventure/generate.py index 0a689e5..291810d 100644 --- a/adventure/generate.py +++ b/adventure/generate.py @@ -209,12 +209,13 @@ def generate_effect( name = loop_retry( agent, - "Generate one effect for an {entity_type} named {entity.name} that would make sense in the world of {theme}. " + "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_name": entity.name, "entity_type": entity_type, "existing_effects": existing_effects, "theme": theme, @@ -245,6 +246,7 @@ def generate_effect( 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." "Choose from the following operations: {operations}", name=name, attribute_name=attribute_name, @@ -260,8 +262,8 @@ def generate_effect( ) 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.", + "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.", name=name, attribute_name=attribute_name, ) @@ -283,7 +285,7 @@ def generate_effect( attributes.append(attribute_effect) - return Effect(name=name, description=description, attributes=[]) + return Effect(name=name, description=description, attributes=attributes) def generate_system_attributes( diff --git a/adventure/main.py b/adventure/main.py index 859386d..ddeb1fb 100644 --- a/adventure/main.py +++ b/adventure/main.py @@ -6,19 +6,9 @@ from typing import List from dotenv import load_dotenv from packit.agent import Agent, agent_easy_connect from packit.utils import logger_with_colors +from pyee.base import EventEmitter 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 -from adventure.models.event import EventCallback, GameEvent, GenerateEvent -from adventure.models.files import PromptFile, WorldPrompt -from adventure.plugins import load_plugin -from adventure.simulate import simulate_world -from adventure.state import create_agents, save_world, save_world_state - def load_yaml(file): return load(file, Loader=Loader) @@ -42,6 +32,18 @@ logger = logger_with_colors(__name__) # , level="DEBUG") load_dotenv(environ.get("ADVENTURE_ENV", ".env"), override=True) +if True: + 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, DEFAULT_CONFIG + from adventure.models.entity import World, WorldState + from adventure.models.event import EventCallback, GameEvent, GenerateEvent + from adventure.models.files import PromptFile, WorldPrompt + from adventure.plugins import load_plugin + from adventure.simulate import simulate_world + from adventure.state import create_agents, save_world, save_world_state + # start the debugger, if needed if environ.get("DEBUG", "false").lower() == "true": @@ -52,6 +54,13 @@ if environ.get("DEBUG", "false").lower() == "true": debugpy.wait_for_client() +def int_or_inf(value: str) -> float | int: + if value == "inf": + return float("inf") + + return int(value) + + # main def parse_args(): import argparse @@ -68,8 +77,7 @@ def parse_args(): parser.add_argument( "--config", type=str, - default="config.yml", - help="The file to load the configuration from", + help="The file to load additional configuration from", ) parser.add_argument( "--discord", @@ -91,7 +99,7 @@ def parse_args(): parser.add_argument( "--optional-actions", action="store_true", - help="Whether to include optional actions", + help="Whether to include optional actions in the simulation", ) parser.add_argument( "--player", @@ -101,7 +109,7 @@ def parse_args(): parser.add_argument( "--render", action="store_true", - help="Whether to render the simulation", + help="Whether to run the render thread", ) parser.add_argument( "--render-generated", @@ -116,7 +124,7 @@ def parse_args(): parser.add_argument( "--server", action="store_true", - help="The address on which to run the server", + help="Whether to run the websocket server", ) parser.add_argument( "--state", @@ -125,7 +133,7 @@ def parse_args(): ) parser.add_argument( "--steps", - type=int, + type=int_or_inf, default=10, help="The number of simulation steps to run", ) @@ -233,8 +241,11 @@ def load_or_generate_world( def main(): args = parse_args() - with open(args.config, "r") as f: - config = Config(**load_yaml(f)) + if args.config: + with open(args.config, "r") as f: + config = Config(**load_yaml(f)) + else: + config = DEFAULT_CONFIG players = [] if args.player: diff --git a/adventure/models/config.py b/adventure/models/config.py index 566a66f..b2220cd 100644 --- a/adventure/models/config.py +++ b/adventure/models/config.py @@ -7,6 +7,7 @@ from .base import dataclass class Range: min: int max: int + interval: int = 1 @dataclass @@ -39,3 +40,21 @@ class RenderConfig: class Config: bot: BotConfig render: RenderConfig + + +DEFAULT_CONFIG = Config( + bot=BotConfig(discord=DiscordBotConfig(channels=["adventure"])), + render=RenderConfig( + cfg=Range(min=5, max=8), + checkpoints=[ + "diffusion-sdxl-dynavision-0-5-5-7.safetensors", + ], + path="/tmp/adventure-images", + sizes={ + "landscape": Size(width=1024, height=768), + "portrait": Size(width=768, height=1024), + "square": Size(width=768, height=768), + }, + steps=Range(min=30, max=30), + ), +) diff --git a/adventure/render_comfy.py b/adventure/render_comfy.py index 9660d54..e68672e 100644 --- a/adventure/render_comfy.py +++ b/adventure/render_comfy.py @@ -17,7 +17,7 @@ from jinja2 import Environment, FileSystemLoader, select_autoescape from PIL import Image from adventure.context import broadcast -from adventure.models.config import Range, RenderConfig, Size +from adventure.models.config import RenderConfig, DEFAULT_CONFIG from adventure.models.entity import WorldEntity from adventure.models.event import ( ActionEvent, @@ -33,19 +33,7 @@ logger = getLogger(__name__) server_address = environ["COMFY_API"] client_id = uuid4().hex -render_config: RenderConfig = RenderConfig( - cfg=Range(min=5, max=8), - checkpoints=[ - "diffusion-sdxl-dynavision-0-5-5-7.safetensors", - ], - path="/tmp/adventure-images", - sizes={ - "landscape": Size(width=1024, height=768), - "portrait": Size(width=768, height=1024), - "square": Size(width=768, height=768), - }, - steps=Range(min=30, max=30), -) +render_config: RenderConfig = DEFAULT_CONFIG.render # requests to generate images for game events diff --git a/adventure/rpg_systems/crafting_actions.py b/adventure/rpg_systems/crafting_actions.py new file mode 100644 index 0000000..bd7cee3 --- /dev/null +++ b/adventure/rpg_systems/crafting_actions.py @@ -0,0 +1,70 @@ +from random import randint +from adventure.context import broadcast, get_current_context, get_dungeon_master +from adventure.generate import generate_item +from adventure.models.entity import Item +from adventure.models.base import dataclass + + +@dataclass +class Recipe: + ingredients: list[str] + result: str + difficulty: int + + +recipes = { + "potion": Recipe(["herb", "water"], "potion", 5), + "sword": Recipe(["metal", "wood"], "sword", 10), +} + + +def action_craft(item_name: str) -> str: + """ + Craft an item using available recipes and inventory items. + + Args: + item_name: The name of the item to craft. + """ + action_world, _, action_actor = get_current_context() + + if item_name not in recipes: + return f"There is no recipe to craft a {item_name}." + + recipe = recipes[item_name] + + # Check if the actor has the required skill level + skill = randint(1, 20) + if skill < recipe.difficulty: + return f"You need a crafting skill level of {recipe.difficulty} to craft {item_name}." + + # Collect inventory items names + inventory_items = {item.name for item in action_actor.items} + + # Check for sufficient ingredients + missing_items = [item for item in recipe.ingredients if item not in inventory_items] + if missing_items: + return f"You are missing {' and '.join(missing_items)} to craft {item_name}." + + # Deduct the ingredients from inventory + for ingredient in recipe.ingredients: + item_to_remove = next( + item for item in action_actor.items if item.name == ingredient + ) + action_actor.items.remove(item_to_remove) + + # Create and add the crafted item to inventory + result_item = next( + (item for item in action_actor.items if item.name == recipe.result), None + ) + if result_item: + new_item = Item(**vars(result_item)) # Copying the item + else: + dungeon_master = get_dungeon_master() + new_item = generate_item( + dungeon_master, action_world.theme + ) # TODO: pass recipe item + + action_actor.items.append(new_item) + + broadcast(f"{action_actor.name} crafts a {item_name}.") + return f"You successfully craft a {item_name}." diff --git a/adventure/rpg_systems/language_actions.py b/adventure/rpg_systems/language_actions.py new file mode 100644 index 0000000..84201e7 --- /dev/null +++ b/adventure/rpg_systems/language_actions.py @@ -0,0 +1,22 @@ +from adventure.context import broadcast, get_current_context +from adventure.search import find_item_in_actor + + +def action_read(item_name: str) -> str: + """ + Read an item like a book or a sign. + + Args: + item_name: The name of the item to read. + """ + _, _, action_actor = get_current_context() + + item = find_item_in_actor(action_actor, item_name) + if not item: + return f"You do not have a {item_name} to read." + + if "text" in item.attributes: + broadcast(f"{action_actor.name} reads {item_name}") + return str(item.attributes["text"]) + else: + return f"The {item_name} has nothing to read." diff --git a/adventure/rpg_systems/magic_actions.py b/adventure/rpg_systems/magic_actions.py new file mode 100644 index 0000000..fdc39ec --- /dev/null +++ b/adventure/rpg_systems/magic_actions.py @@ -0,0 +1,36 @@ +from random import randint +from adventure.context import broadcast, get_current_context, get_dungeon_master +from adventure.search import find_actor_in_room + + +def action_cast(spell: str, target: str) -> str: + """ + Cast a spell on a target. + + Args: + spell: The name of the spell to cast. + target: The target of the spell. + """ + _, action_room, action_actor = get_current_context() + + target_actor = find_actor_in_room(action_room, target) + dungeon_master = get_dungeon_master() + + # Check for spell availability and mana costs + if spell not in action_actor.attributes["spells"]: + return f"You do not know the spell '{spell}'." + if action_actor.attributes["mana"] < action_actor.attributes["spells"][spell]: + return "You do not have enough mana to cast this spell." + + action_actor.attributes["mana"] -= action_actor.attributes["spells"][spell] + # Get flavor text from the dungeon master + flavor_text = dungeon_master(f"Describe the effects of {spell} on {target}.") + broadcast(f"{action_actor.name} casts {spell} on {target}. {flavor_text}") + + # Apply effects based on the spell + if spell == "heal" and target_actor: + heal_amount = randint(10, 30) + target_actor.attributes["health"] += heal_amount + return f"{target} is healed for {heal_amount} points." + + return f"{spell} was successfully cast on {target}." diff --git a/adventure/rpg_systems/movement_actions.py b/adventure/rpg_systems/movement_actions.py new file mode 100644 index 0000000..5822003 --- /dev/null +++ b/adventure/rpg_systems/movement_actions.py @@ -0,0 +1,36 @@ +from random import randint +from adventure.context import broadcast, get_current_context, get_dungeon_master +from adventure.search import find_item_in_room + + +def action_climb(target: str) -> str: + """ + Climb a structure or natural feature. + + Args: + target: The object or feature to climb. + """ + _, action_room, action_actor = get_current_context() + + dungeon_master = get_dungeon_master() + # Assume 'climbable' is an attribute that marks climbable targets + climbable_feature = find_item_in_room(action_room, target) + + if climbable_feature and climbable_feature.attributes.get("climbable", False): + climb_difficulty = int(climbable_feature.attributes.get("difficulty", 5)) + climb_roll = randint(1, 20) + + # Get flavor text for the climb attempt + flavor_text = dungeon_master( + f"Describe {action_actor.name}'s attempt to climb {target}." + ) + if climb_roll > climb_difficulty: + broadcast( + f"{action_actor.name} successfully climbs the {target}. {flavor_text}" + ) + return f"You successfully climb the {target}." + else: + broadcast(f"{action_actor.name} fails to climb the {target}. {flavor_text}") + return f"You fail to climb the {target}." + else: + return f"The {target} is not climbable." diff --git a/adventure/simulate.py b/adventure/simulate.py index de999c1..bf5f771 100644 --- a/adventure/simulate.py +++ b/adventure/simulate.py @@ -1,5 +1,7 @@ +from itertools import count from logging import getLogger from typing import Callable, Sequence +from math import inf from packit.loops import loop_retry from packit.results import multi_function_or_str_result @@ -63,7 +65,7 @@ def world_result_parser(value, agent, **kwargs): def simulate_world( world: World, - steps: int = 10, + steps: float | int = inf, actions: Sequence[Callable[..., str]] = [], callbacks: Sequence[EventCallback] = [], systems: Sequence[GameSystem] = [], @@ -100,9 +102,10 @@ def simulate_world( action_names = action_tools.list_tools() # simulate each actor - for i in range(steps): + for i in count(): current_step = get_current_step() - logger.info(f"Simulating step {current_step}") + logger.info(f"simulating step {i} of {steps} (world step {current_step})") + for actor_name in world.order: actor, agent = get_actor_agent_for_name(actor_name) if not agent or not actor: @@ -179,3 +182,6 @@ def simulate_world( system.simulate(world, current_step) set_current_step(current_step + 1) + if i > steps: + logger.info("reached step limit at world step %s", current_step + 1) + break