diff --git a/adventure/actions/optional.py b/adventure/actions/optional.py index 2f09dfb..0163f99 100644 --- a/adventure/actions/optional.py +++ b/adventure/actions/optional.py @@ -134,6 +134,7 @@ def action_use(item: str, target: str) -> str: return f"The {target} character is not in the room." effect_names = [effect.name for effect in action_item.effects] + # TODO: should use a retry loop and enum result parser chosen_name = dungeon_master( f"{action_character.name} uses {item} on {target}. " f"{item} has the following effects: {effect_names}. " @@ -151,9 +152,17 @@ def action_use(item: str, target: str) -> str: None, ) if not chosen_effect: - # TODO: should retry the question if the effect is not found raise ValueError(f"The {chosen_name} effect is not available to apply.") + if chosen_effect.uses is None: + pass + elif chosen_effect.uses == 0: + raise ActionError( + f"The {chosen_name} effect of {item} has no uses remaining." + ) + elif chosen_effect.uses > 0: + chosen_effect.uses -= 1 + try: apply_effects(target_character, [chosen_effect]) except Exception: diff --git a/adventure/actions/planning.py b/adventure/actions/planning.py index d31759e..277f90f 100644 --- a/adventure/actions/planning.py +++ b/adventure/actions/planning.py @@ -130,21 +130,33 @@ def schedule_event(name: str, turns: int): turns: The number of turns until the event happens. """ + # TODO: check for existing events with the same name + # TODO: limit the number of events that can be scheduled + with action_context() as (_, action_character): - # TODO: check for existing events with the same name event = CalendarEvent(name, turns) action_character.planner.calendar.events.append(event) return f"{name} is scheduled to happen in {turns} turns." -def check_calendar(unused: bool, count: int = 10): +def check_calendar(count: int): """ Read your calendar to see upcoming events that you have scheduled. + + Args: + count: The number of upcoming events to read. 5 is usually a good number. """ + count = min(count, character_config.event_limit) current_turn = get_current_step() with action_context() as (_, action_character): + if len(action_character.planner.calendar.events) == 0: + return ( + "You have no upcoming events scheduled. You can plan events with other characters or on your own. " + "Make sure to inform others about events that involve them." + ) + events = action_character.planner.calendar.events[:count] return "\n".join( [ diff --git a/adventure/context.py b/adventure/context.py index 3821cde..0c38cfc 100644 --- a/adventure/context.py +++ b/adventure/context.py @@ -207,7 +207,7 @@ def set_system_data(system: str, data: Any): # region search functions -def get_character_for_agent(agent): +def get_character_for_agent(agent: Agent) -> Character | None: return next( ( inner_character @@ -218,7 +218,7 @@ def get_character_for_agent(agent): ) -def get_agent_for_character(character): +def get_agent_for_character(character: Character) -> Agent | None: return next( ( inner_agent @@ -229,7 +229,9 @@ def get_agent_for_character(character): ) -def get_character_agent_for_name(name): +def get_character_agent_for_name( + name: str, +) -> Tuple[Character, Agent] | Tuple[None, None]: return next( ( (character, agent) diff --git a/adventure/generate.py b/adventure/generate.py index cc031d6..21771fe 100644 --- a/adventure/generate.py +++ b/adventure/generate.py @@ -284,6 +284,8 @@ def generate_character( world: World, systems: List[GameSystem], dest_room: Room, + additional_prompt: str = "", + detail_prompt: str = "", ) -> Character: existing_characters = [character.name for character in list_characters(world)] + [ character.name for character in list_characters_in_room(dest_room) @@ -291,13 +293,14 @@ def generate_character( name = loop_retry( agent, - "Generate one person or creature that would make sense in the world of {world_theme}. " - "The character will be placed in the {dest_room} room. " + "Generate a new character that would make sense in the world of {world_theme}. Characters can be a person, creature, or some other intelligent entity." + "The character will be placed in the {dest_room} room. {additional_prompt}. " "Only respond with the character 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. ' "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_characters}", context={ + "additional_prompt": additional_prompt, "dest_room": dest_room.name, "existing_characters": existing_characters, "world_theme": world.theme, @@ -308,14 +311,18 @@ def generate_character( broadcast_generated(message=f"Generating character: {name}") description = agent( - "Generate a detailed description of the {name} character. What do they look like? What are they wearing? " + "Generate a detailed description of the {name} character. {additional_prompt}. {detail_prompt}. What do they look like? What are they wearing? " "What are they doing? Describe their appearance from the perspective of an outside observer." "Do not include the room or any other characters in the description, because they will move around.", + additional_prompt=additional_prompt, + detail_prompt=detail_prompt, name=name, ) backstory = agent( - "Generate a backstory for the {name} character. Where are they from? What are they doing here? What are their " + "Generate a backstory for the {name} character. {additional_prompt}. {detail_prompt}. Where are they from? What are they doing here? What are their " 'goals? Make sure to phrase the backstory in the second person, starting with "you are" and speaking directly to {name}.', + additional_prompt=additional_prompt, + detail_prompt=detail_prompt, name=name, ) @@ -374,6 +381,31 @@ def generate_effect(agent: Agent, world: World, entity: Item) -> EffectPattern: name=name, ) + cooldown = loop_retry( + agent, + f"How many turns should the {name} effect wait before it can be used again? Enter a positive number to set a cooldown, or 0 for no cooldown. " + "Do not include any other text. Do not use JSON.", + context={ + "name": name, + }, + result_parser=int_result, + toolbox=None, + ) + + uses = loop_retry( + agent, + f"How many times can the {name} effect be used before it is exhausted? Enter a positive number to set a limit, or -1 for unlimited uses. " + "Do not include any other text. Do not use JSON.", + context={ + "name": name, + }, + result_parser=int_result, + toolbox=None, + ) + + if uses == -1: + uses = None + attribute_names = agent( "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. " @@ -453,8 +485,10 @@ def generate_effect(agent: Agent, world: World, entity: Item) -> EffectPattern: name, description, application, - duration=duration, attributes=attributes, + cooldown=cooldown, + duration=duration, + uses=uses, ) diff --git a/adventure/models/config.py b/adventure/models/config.py index 044d832..83cdb8b 100644 --- a/adventure/models/config.py +++ b/adventure/models/config.py @@ -20,6 +20,11 @@ class BotConfig: discord: DiscordBotConfig +@dataclass +class PromptConfig: + prompts: Dict[str, str] + + @dataclass class RenderConfig: cfg: int | IntRange @@ -74,6 +79,7 @@ class WorldConfig: @dataclass class Config: bot: BotConfig + prompt: PromptConfig render: RenderConfig server: ServerConfig world: WorldConfig @@ -81,6 +87,9 @@ class Config: DEFAULT_CONFIG = Config( bot=BotConfig(discord=DiscordBotConfig(channels=["adventure"])), + prompt=PromptConfig( + prompts={}, + ), render=RenderConfig( cfg=IntRange(min=5, max=8), checkpoints=[ diff --git a/adventure/models/effect.py b/adventure/models/effect.py index a1f8e42..cb0e479 100644 --- a/adventure/models/effect.py +++ b/adventure/models/effect.py @@ -50,8 +50,10 @@ class EffectPattern: name: str description: str application: Literal["permanent", "temporary"] - duration: int | IntRange | None = None attributes: List[AttributeEffectPattern] = Field(default_factory=list) + cooldown: int | None = None + duration: int | IntRange | None = None + uses: int | None = None id: str = Field(default_factory=uuid) type: Literal["effect_pattern"] = "effect_pattern" @@ -96,7 +98,7 @@ AttributeEffectResult = ( class EffectResult: name: str description: str - duration: int | None = None attributes: List[AttributeEffectResult] = Field(default_factory=list) + duration: int | None = None id: str = Field(default_factory=uuid) type: Literal["effect_result"] = "effect_result" diff --git a/adventure/utils/attribute.py b/adventure/utils/attribute.py index 026b025..7bc11d6 100644 --- a/adventure/utils/attribute.py +++ b/adventure/utils/attribute.py @@ -35,6 +35,18 @@ def prepend_value(value: AttributeValue, prefix: str) -> str: return prefix + str(value) +def value_contains(value: AttributeValue, substring: str) -> bool: + """ + Check if a string contains a substring. + """ + if not isinstance(value, str): + raise ValueError( + f"Cannot check for a substring in a non-string attribute: {value}" + ) + + return substring in value + + def add_attribute(attributes: Attributes, name: str, value: int | float) -> Attributes: """ Add an attribute to a set of attributes. diff --git a/adventure/utils/conversation.py b/adventure/utils/conversation.py index 1df673f..73eb1f3 100644 --- a/adventure/utils/conversation.py +++ b/adventure/utils/conversation.py @@ -13,7 +13,7 @@ from adventure.models.config import DEFAULT_CONFIG from adventure.models.entity import Character, Room from adventure.models.event import ReplyEvent -from .string import normalize_name +from .string import and_list, normalize_name logger = getLogger(__name__) @@ -59,32 +59,6 @@ def make_keyword_condition(end_message: str, keywords=["end", "stop"]): return set_end, condition_end, result_parser -def and_list(items: List[str]) -> str: - """ - Convert a list of items into a human-readable list. - """ - if not items: - return "nothing" - - if len(items) == 1: - return items[0] - - return f"{', '.join(items[:-1])}, and {items[-1]}" - - -def or_list(items: List[str]) -> str: - """ - Convert a list of items into a human-readable list. - """ - if not items: - return "nothing" - - if len(items) == 1: - return items[0] - - return f"{', '.join(items[:-1])}, or {items[-1]}" - - def summarize_room(room: Room, player: Character) -> str: """ Summarize a room for the player. diff --git a/adventure/utils/string.py b/adventure/utils/string.py index 9fe1c7f..425c092 100644 --- a/adventure/utils/string.py +++ b/adventure/utils/string.py @@ -1,4 +1,5 @@ from functools import lru_cache +from typing import List @lru_cache(maxsize=1024) @@ -6,3 +7,29 @@ def normalize_name(name: str) -> str: name = name.lower().strip() name = name.strip('"').strip("'") return name.removesuffix(".") + + +def and_list(items: List[str]) -> str: + """ + Convert a list of items into a human-readable list. + """ + if not items: + return "nothing" + + if len(items) == 1: + return items[0] + + return f"{', '.join(items[:-1])}, and {items[-1]}" + + +def or_list(items: List[str]) -> str: + """ + Convert a list of items into a human-readable list. + """ + if not items: + return "nothing" + + if len(items) == 1: + return items[0] + + return f"{', '.join(items[:-1])}, or {items[-1]}" diff --git a/client/src/events.tsx b/client/src/events.tsx index e3410ec..38bddfb 100644 --- a/client/src/events.tsx +++ b/client/src/events.tsx @@ -253,8 +253,10 @@ export function GenerateEventItem(props: EventItemProps) { const { event, renderEntity } = props; const { entity, name } = event; + let description = name; let renderButton; if (doesExist(entity)) { + description = `Finished generating ${entity.type} ${entity.name}: ${entity.description}`; renderButton = renderEntity(entity.type, entity.name)}> ; @@ -277,7 +279,7 @@ export function GenerateEventItem(props: EventItemProps) { variant="body2" color="text.primary" > - {name} + {description} } />