From a3cb7c3e4b8b980c3670c9de790b07a6c974042e Mon Sep 17 00:00:00 2001 From: Sean Sube Date: Sat, 25 May 2024 15:18:40 -0500 Subject: [PATCH] add thought stage with planning, add quest system --- adventure/actions/base.py | 4 +- adventure/actions/planning.py | 135 +++++++++++++++++ adventure/actions/quest.py | 58 ++++++++ adventure/context.py | 10 ++ adventure/game_system.py | 34 ++++- adventure/generate.py | 11 +- adventure/main.py | 55 ++++--- adventure/models/entity.py | 10 ++ adventure/models/planning.py | 22 +++ adventure/server/websocket.py | 2 +- adventure/simulate.py | 239 +++++++++++++++++++++--------- adventure/{ => systems}/logic.py | 21 ++- adventure/systems/quest.py | 233 +++++++++++++++++++++++++++++ adventure/systems/rpg/__init__.py | 2 +- adventure/systems/sim/__init__.py | 2 +- adventure/utils/effect.py | 16 +- adventure/utils/file.py | 9 ++ adventure/utils/search.py | 45 ++++-- adventure/utils/systems.py | 14 ++ client/src/details.tsx | 79 ++++++++-- client/src/models.ts | 16 ++ 21 files changed, 896 insertions(+), 121 deletions(-) create mode 100644 adventure/actions/planning.py create mode 100644 adventure/actions/quest.py create mode 100644 adventure/models/planning.py rename adventure/{ => systems}/logic.py (90%) create mode 100644 adventure/systems/quest.py create mode 100644 adventure/utils/file.py create mode 100644 adventure/utils/systems.py diff --git a/adventure/actions/base.py b/adventure/actions/base.py index 7ee2e22..f313d5c 100644 --- a/adventure/actions/base.py +++ b/adventure/actions/base.py @@ -84,7 +84,9 @@ def action_move(direction: str) -> str: if not destination_room: return f"The {portal.destination} room does not exist." - broadcast(f"{action_actor.name} moves {direction} to {destination_room.name}") + broadcast( + f"{action_actor.name} moves through {direction} to {destination_room.name}" + ) action_room.actors.remove(action_actor) destination_room.actors.append(action_actor) diff --git a/adventure/actions/planning.py b/adventure/actions/planning.py new file mode 100644 index 0000000..355f251 --- /dev/null +++ b/adventure/actions/planning.py @@ -0,0 +1,135 @@ +from adventure.context import action_context, get_current_step +from adventure.models.planning import CalendarEvent + + +def take_note(fact: str): + """ + Remember a fact by recording it in your notes. Facts are critical information about yourself and others that you + have learned during your adventures. You can review your notes at any time to help you make decisions. + + Args: + fact: The fact to remember. + """ + + with action_context() as (_, action_actor): + if fact in action_actor.planner.notes: + return "You already know that." + + action_actor.planner.notes.append(fact) + return "You make a note of that." + + +def read_notes(unused: bool, count: int = 10): + """ + Read your notes to review the facts that you have learned during your adventures. + + Args: + count: The number of recent notes to read. 10 is usually a good number. + """ + + facts = get_recent_notes(count=count) + return "\n".join(facts) + + +def erase_notes(prefix: str) -> str: + """ + Erase notes that start with a specific prefix. + + Args: + prefix: The prefix to match notes against. + """ + + with action_context() as (_, action_actor): + matches = [ + note for note in action_actor.planner.notes if note.startswith(prefix) + ] + if not matches: + return "No notes found with that prefix." + + action_actor.planner.notes[:] = [ + note for note in action_actor.planner.notes if note not in matches + ] + return f"Erased {len(matches)} notes." + + +def replace_note(old: str, new: str) -> str: + """ + Replace a note with a new note. + + Args: + old: The old note to replace. + new: The new note to replace it with. + """ + + with action_context() as (_, action_actor): + if old not in action_actor.planner.notes: + return "Note not found." + + action_actor.planner.notes[:] = [ + new if note == old else note for note in action_actor.planner.notes + ] + return "Note replaced." + + +def schedule_event(name: str, turns: int): + """ + Schedule an event to happen at a specific turn. Events are important occurrences that can affect the world in + significant ways. You will be notified about upcoming events so you can plan accordingly. + + Args: + name: The name of the event. + turns: The number of turns until the event happens. + """ + + with action_context() as (_, action_actor): + # TODO: check for existing events with the same name + event = CalendarEvent(name, turns) + action_actor.planner.calendar.events.append(event) + return f"{name} is scheduled to happen in {turns} turns." + + +def read_calendar(unused: bool, count: int = 10): + """ + Read your calendar to see upcoming events that you have scheduled. + """ + + current_turn = get_current_step() + + with action_context() as (_, action_actor): + events = action_actor.planner.calendar.events[:count] + return "\n".join( + [ + f"{event.name} will happen in {event.turn - current_turn} turns" + for event in events + ] + ) + + +def get_upcoming_events(turns: int = 3): + """ + Get a list of upcoming events within a certain number of turns. + + Args: + turns: The number of turns to look ahead for events. + """ + + current_turn = get_current_step() + + with action_context() as (_, action_actor): + calendar = action_actor.planner.calendar + # TODO: sort events by turn + return [ + event for event in calendar.events if event.turn - current_turn <= turns + ] + + +def get_recent_notes(count: int = 3): + """ + Get the most recent facts from your notes. + + Args: + history: The number of recent facts to retrieve. + """ + + with action_context() as (_, action_actor): + return action_actor.planner.notes[-count:] diff --git a/adventure/actions/quest.py b/adventure/actions/quest.py new file mode 100644 index 0000000..8ea9d2f --- /dev/null +++ b/adventure/actions/quest.py @@ -0,0 +1,58 @@ +from adventure.context import action_context, get_system_data +from adventure.systems.quest import ( + QUEST_SYSTEM, + complete_quest, + get_active_quest, + get_quests_for_actor, + set_active_quest, +) +from adventure.utils.search import find_actor_in_room + + +def accept_quest(actor: str, quest: str) -> str: + """ + Accept and start a quest being given by another character. + """ + + with action_context() as (action_room, action_actor): + quests = get_system_data(QUEST_SYSTEM) + if not quests: + return "No quests available." + + target_actor = find_actor_in_room(action_room, actor) + if not target_actor: + return f"{actor} is not in the room." + + available_quests = get_quests_for_actor(quests, target_actor) + + for available_quest in available_quests: + if available_quest.name == quest: + set_active_quest(quests, action_actor, available_quest) + return f"You have accepted the quest: {quest}" + + return f"{actor} does not have the quest: {quest}" + + +def submit_quest(actor: str) -> str: + """ + Submit your active quest to the quest giver. If you have completed the quest, you will be rewarded. + """ + + with action_context() as (action_room, action_actor): + quests = get_system_data(QUEST_SYSTEM) + if not quests: + return "No quests available." + + active_quest = get_active_quest(quests, action_actor) + if not active_quest: + return "You do not have an active quest." + + target_actor = find_actor_in_room(action_room, actor) + if not target_actor: + return f"{actor} is not in the room." + + if active_quest.giver.actor == target_actor.name: + complete_quest(quests, action_actor, active_quest) + return f"You have completed the quest: {active_quest.name}" + + return f"{actor} is not the quest giver for your active quest." diff --git a/adventure/context.py b/adventure/context.py index 9635d23..66719b1 100644 --- a/adventure/context.py +++ b/adventure/context.py @@ -2,6 +2,7 @@ from contextlib import contextmanager from logging import getLogger from types import UnionType from typing import ( + Any, Callable, Dict, List, @@ -33,6 +34,7 @@ dungeon_master: Agent | None = None # game context event_emitter = EventEmitter() game_systems: List[GameSystem] = [] +system_data: Dict[str, Any] = {} # TODO: where should this one go? @@ -155,6 +157,10 @@ def get_game_systems() -> List[GameSystem]: return game_systems +def get_system_data(system: str) -> Any | None: + return system_data.get(system) + + # endregion @@ -193,6 +199,10 @@ def set_game_systems(systems: Sequence[GameSystem]): game_systems = list(systems) +def set_system_data(system: str, data: Any): + system_data[system] = data + + # endregion diff --git a/adventure/game_system.py b/adventure/game_system.py index bd1e4a2..9e38ee7 100644 --- a/adventure/game_system.py +++ b/adventure/game_system.py @@ -1,10 +1,9 @@ from enum import Enum -from typing import Protocol +from typing import Any, Callable, Protocol from packit.agent import Agent from adventure.models.entity import World, WorldEntity -from adventure.utils import format_callable class FormatPerspective(Enum): @@ -31,32 +30,59 @@ class SystemGenerate(Protocol): ... +class SystemInitialize(Protocol): + def __call__(self, world: World) -> Any: + """ + Initialize the system for the given world. + """ + ... + + class SystemSimulate(Protocol): - def __call__(self, world: World, step: int) -> None: + def __call__(self, world: World, step: int, data: Any | None = None) -> None: """ Simulate the world for the given step. """ ... +class SystemData: + load: Callable[[str], Any] + save: Callable[[str, Any], None] + + def __init__(self, load: Callable[[str], Any], save: Callable[[str, Any], None]): + self.load = load + self.save = save + + class GameSystem: + name: str + data: SystemData | None = None format: SystemFormat | None = None generate: SystemGenerate | None = None + initialize: SystemInitialize | None = None simulate: SystemSimulate | None = None # render: TODO def __init__( self, + name: str, + *, + data: SystemData | None = None, format: SystemFormat | None = None, generate: SystemGenerate | None = None, + initialize: SystemInitialize | None = None, simulate: SystemSimulate | None = None, ): + self.name = name + self.data = data self.format = format self.generate = generate + self.initialize = initialize self.simulate = simulate def __str__(self): - return f"GameSystem(format={format_callable(self.format)}, generate={format_callable(self.generate)}, simulate={format_callable(self.simulate)})" + return f"GameSystem({self.name})" def __repr__(self): return str(self) diff --git a/adventure/generate.py b/adventure/generate.py index 65dd4aa..b59cad3 100644 --- a/adventure/generate.py +++ b/adventure/generate.py @@ -7,7 +7,7 @@ from packit.loops import loop_retry from packit.results import enum_result, int_result from packit.utils import could_be_json -from adventure.context import broadcast, set_current_world +from adventure.context import broadcast, set_current_world, set_system_data from adventure.game_system import GameSystem from adventure.models.config import DEFAULT_CONFIG, WorldConfig from adventure.models.effect import ( @@ -74,6 +74,7 @@ def generate_system_attributes( ) -> None: for system in systems: if system.generate: + # TODO: pass the whole world system.generate(agent, world.theme, entity) @@ -423,6 +424,7 @@ def generate_effect(agent: Agent, world: World, entity: Item) -> EffectPattern: "name": name, }, result_parser=int_result, + toolbox=None, ) def parse_application(value: str, **kwargs) -> str: @@ -447,6 +449,7 @@ def generate_effect(agent: Agent, world: World, entity: Item) -> EffectPattern: "name": name, }, result_parser=parse_application, + toolbox=None, ) return EffectPattern( @@ -522,6 +525,12 @@ def generate_world( world = World(name=name, rooms=[], theme=theme, order=[]) set_current_world(world) + # initialize the systems + for system in systems: + if system.initialize: + data = system.initialize(world) + set_system_data(system.name, data) + # generate the rooms for _ in range(room_count): try: diff --git a/adventure/main.py b/adventure/main.py index ed628a2..d633cae 100644 --- a/adventure/main.py +++ b/adventure/main.py @@ -6,14 +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 yaml import Loader, load - -from adventure.context import subscribe - - -def load_yaml(file): - return load(file, Loader=Loader) +from adventure.context import get_system_data, set_system_data +from adventure.utils.file import load_yaml # configure logging LOG_PATH = "logging.json" @@ -34,7 +29,7 @@ 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.context import set_current_step, set_dungeon_master, subscribe from adventure.game_system import GameSystem from adventure.generate import generate_world from adventure.models.config import DEFAULT_CONFIG, Config @@ -176,7 +171,34 @@ def get_world_prompt(args) -> WorldPrompt: ) -def load_or_generate_world(args, players, systems, world_prompt: WorldPrompt): +def load_or_initialize_system_data(args, systems: List[GameSystem], world: World): + for system in systems: + if system.data: + system_data_file = f"{args.world}.{system.name}.json" + + data = None + if path.exists(system_data_file): + logger.info(f"loading system data from {system_data_file}") + data = system.data.load(system_data_file) + else: + logger.info(f"no system data found at {system_data_file}") + if system.initialize: + data = system.initialize(world) + + set_system_data(system.name, data) + + +def save_system_data(args, systems: List[GameSystem]): + for system in systems: + if system.data: + system_data_file = f"{args.world}.{system.name}.json" + logger.info(f"saving system data to {system_data_file}") + system.data.save(system_data_file, get_system_data(system.name)) + + +def load_or_generate_world( + args, players, systems: List[GameSystem], world_prompt: WorldPrompt +): world_file = args.world + ".json" world_state_file = args.state or (args.world + ".state.json") @@ -187,6 +209,7 @@ def load_or_generate_world(args, players, systems, world_prompt: WorldPrompt): state = WorldState(**load_yaml(f)) set_current_step(state.step) + load_or_initialize_system_data(args, systems, state.world) memory = state.memory world = state.world @@ -194,6 +217,8 @@ def load_or_generate_world(args, players, systems, world_prompt: WorldPrompt): logger.info(f"loading world from {world_file}") with open(world_file, "r") as f: world = World(**load_yaml(f)) + + load_or_initialize_system_data(args, systems, world) else: logger.info(f"generating a new world using theme: {world_prompt.theme}") llm = agent_easy_connect() @@ -213,11 +238,7 @@ def load_or_generate_world(args, players, systems, world_prompt: WorldPrompt): room_count=args.rooms, ) save_world(world, world_file) - - # run the systems once to initialize everything - for system in systems: - if system.simulate: - system.simulate(world, 0) + save_system_data(args, systems) create_agents(world, memory=memory, players=players) return (world, world_state_file) @@ -296,7 +317,7 @@ def main(): if args.server: from adventure.server.websocket import server_system - extra_systems.append(GameSystem(simulate=server_system)) + extra_systems.append(GameSystem(name="server", simulate=server_system)) # load or generate the world world_prompt = get_world_prompt(args) @@ -305,11 +326,11 @@ def main(): ) # make sure the snapshot system runs last - def snapshot_system(world: World, step: int) -> None: + def snapshot_system(world: World, step: int, data: None = None) -> None: logger.info("taking snapshot of world state") save_world_state(world, step, world_state_file) - extra_systems.append(GameSystem(simulate=snapshot_system)) + extra_systems.append(GameSystem(name="snapshot", 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 0742e14..36dc47f 100644 --- a/adventure/models/entity.py +++ b/adventure/models/entity.py @@ -4,6 +4,7 @@ from pydantic import Field from .base import Attributes, BaseModel, dataclass, uuid from .effect import EffectPattern, EffectResult +from .planning import Planner Actions = Dict[str, Callable] @@ -26,6 +27,7 @@ class Actor(BaseModel): name: str backstory: str description: str + planner: Planner = Field(default_factory=Planner) actions: Actions = Field(default_factory=dict) active_effects: List[EffectResult] = Field(default_factory=list) attributes: Attributes = Field(default_factory=dict) @@ -79,3 +81,11 @@ class WorldState(BaseModel): WorldEntity = Room | Actor | Item | Portal + + +@dataclass +class EntityReference: + actor: str | None = None + item: str | None = None + portal: str | None = None + room: str | None = None diff --git a/adventure/models/planning.py b/adventure/models/planning.py new file mode 100644 index 0000000..3dd3020 --- /dev/null +++ b/adventure/models/planning.py @@ -0,0 +1,22 @@ +from typing import List + +from pydantic import Field + +from adventure.models.base import dataclass + + +@dataclass +class CalendarEvent: + name: str + turn: int + + +@dataclass +class Calendar: + events: List[CalendarEvent] = Field(default_factory=list) + + +@dataclass +class Planner: + calendar: Calendar = Field(default_factory=Calendar) + notes: List[str] = Field(default_factory=list) diff --git a/adventure/server/websocket.py b/adventure/server/websocket.py index cc490e9..3a109c4 100644 --- a/adventure/server/websocket.py +++ b/adventure/server/websocket.py @@ -296,7 +296,7 @@ async def server_main(): await asyncio.Future() # run forever -def server_system(world: World, step: int): +def server_system(world: World, step: int, data: Any | None = None): global last_snapshot id = uuid4().hex # TODO: should a server be allowed to generate event IDs? json_state = { diff --git a/adventure/simulate.py b/adventure/simulate.py index 19af9b6..336bd7b 100644 --- a/adventure/simulate.py +++ b/adventure/simulate.py @@ -1,9 +1,12 @@ +from functools import partial from itertools import count from logging import getLogger from math import inf from typing import Callable, Sequence -from packit.loops import loop_retry +from packit.agent import Agent +from packit.conditions import condition_or, condition_threshold, make_flag_condition +from packit.loops import loop_reduce, loop_retry from packit.results import multi_function_or_str_result from packit.toolbox import Toolbox from packit.utils import could_be_json @@ -16,6 +19,16 @@ from adventure.actions.base import ( action_take, action_tell, ) +from adventure.actions.planning import ( + erase_notes, + get_recent_notes, + get_upcoming_events, + read_calendar, + read_notes, + replace_note, + schedule_event, + take_note, +) from adventure.context import ( broadcast, get_actor_agent_for_name, @@ -29,10 +42,11 @@ from adventure.context import ( set_game_systems, ) from adventure.game_system import GameSystem -from adventure.models.entity import World +from adventure.models.entity import Actor, Room, World from adventure.models.event import ActionEvent, ReplyEvent, ResultEvent -from adventure.utils.effect import is_active_effect +from adventure.utils.effect import expire_effects from adventure.utils.search import find_room_with_actor +from adventure.utils.string import normalize_name from adventure.utils.world import describe_entity, format_attributes logger = getLogger(__name__) @@ -58,13 +72,136 @@ def world_result_parser(value, agent, **kwargs): return multi_function_or_str_result(value, agent=agent, **kwargs) +def prompt_actor_action(room, actor, agent, action_names, action_toolbox) -> str: + # collect data for the prompt + room_actors = [actor.name for actor in room.actors] + room_items = [item.name for item in room.items] + room_directions = [portal.name for portal in room.portals] + + actor_attributes = format_attributes(actor) + # actor_effects = [effect.name for effect in actor.active_effects] + actor_items = [item.name for item in actor.items] + + # set up a result parser for the agent + def result_parser(value, agent, **kwargs): + if not room or not actor: + raise ValueError("Room and actor must be set before parsing results") + + if could_be_json(value): + event = ActionEvent.from_json(value, room, actor) + else: + event = ReplyEvent.from_text(value, room, actor) + + broadcast(event) + + return world_result_parser(value, agent, **kwargs) + + # prompt and act + logger.info("starting turn for actor: %s", actor.name) + result = loop_retry( + agent, + ( + "You are currently in {room_name}. {room_description}. {attributes}. " + "The room contains the following characters: {visible_actors}. " + "The room contains the following items: {visible_items}. " + "Your inventory contains the following items: {actor_items}." + "You can take the following actions: {actions}. " + "You can move in the following directions: {directions}. " + "What will you do next? Reply with a JSON function call, calling one of the actions." + "You can only perform one action per turn. What is your next action?" + ), + context={ + "actions": action_names, + "actor_items": actor_items, + "attributes": actor_attributes, + "directions": room_directions, + "room_name": room.name, + "room_description": describe_entity(room), + "visible_actors": room_actors, + "visible_items": room_items, + }, + result_parser=result_parser, + toolbox=action_toolbox, + ) + + logger.debug(f"{actor.name} step result: {result}") + if agent.memory: + # TODO: make sure this is not duplicating memories and wasting space + agent.memory.append(result) + + return result + + +def prompt_actor_think( + room: Room, actor: Actor, agent: Agent, planner_toolbox: Toolbox +) -> str: + recent_notes = get_recent_notes() + upcoming_events = get_upcoming_events() + + if len(recent_notes) > 0: + notes = "\n".join(recent_notes) + notes_prompt = f"Your recent notes are: {notes}\n" + else: + notes_prompt = "You have no recent notes.\n" + + if len(upcoming_events) > 0: + current_step = get_current_step() + events = [ + f"{event.name} in {event.turn - current_step} turns" + for event in upcoming_events + ] + events = "\n".join(events) + events_prompt = f"Upcoming events are: {events}\n" + else: + events_prompt = "You have no upcoming events.\n" + + event_count = len(actor.planner.calendar.events) + note_count = len(actor.planner.notes) + + logger.info("starting planning for actor: %s", actor.name) + set_end, condition_end = make_flag_condition() + + def result_parser(value, **kwargs): + if normalize_name(value) == "end": + set_end() + + return multi_function_or_str_result(value, **kwargs) + + stop_condition = condition_or(condition_end, partial(condition_threshold, max=3)) + + result = loop_reduce( + agent, + "You are about to take your turn. Plan your next action carefully. " + "You can check your notes for important facts or check your calendar for upcoming events. You have {note_count} notes. " + "Try to keep your notes accurate. Replace or erase old notes if they are no longer accurate or useful. " + "If you have upcoming events with other characters, schedule them on your calendar. You have {event_count} calendar events. " + "Think about your goals and any quests that you are working on, and plan your next action accordingly. " + "You can perform up to 3 planning actions in a single turn. When you are done planning, reply with 'END'." + "{notes_prompt} {events_prompt}", + context={ + "event_count": event_count, + "events_prompt": events_prompt, + "note_count": note_count, + "notes_prompt": notes_prompt, + }, + result_parser=result_parser, + stop_condition=stop_condition, + toolbox=planner_toolbox, + ) + + if agent.memory: + agent.memory.append(result) + + return result + + def simulate_world( world: World, steps: float | int = inf, actions: Sequence[Callable[..., str]] = [], systems: Sequence[GameSystem] = [], ): - logger.info("Simulating the world") + logger.info("simulating the world") set_current_world(world) set_game_systems(systems) @@ -82,6 +219,18 @@ def simulate_world( ) action_names = action_tools.list_tools() + # build a toolbox for the planners + planner_toolbox = Toolbox( + [ + take_note, + read_notes, + replace_note, + erase_notes, + schedule_event, + read_calendar, + ] + ) + # simulate each actor for i in count(): current_step = get_current_step() @@ -90,79 +239,31 @@ def simulate_world( for actor_name in world.order: actor, agent = get_actor_agent_for_name(actor_name) if not agent or not actor: - logger.error(f"Agent or actor not found for name {actor_name}") + logger.error(f"agent or actor not found for name {actor_name}") continue room = find_room_with_actor(world, actor) if not room: - logger.error(f"Actor {actor_name} is not in a room") + logger.error(f"actor {actor_name} is not in a room") continue + # prep context + set_current_room(room) + set_current_actor(actor) + # decrement effects on the actor and remove any that have expired - for effect in actor.active_effects: - if effect.duration is not None: - effect.duration -= 1 + expire_effects(actor) + # TODO: expire calendar events - actor.active_effects[:] = [ - effect for effect in actor.active_effects if is_active_effect(effect) - ] - - # collect data for the prompt - room_actors = [actor.name for actor in room.actors] - room_items = [item.name for item in room.items] - room_directions = [portal.name for portal in room.portals] - - actor_attributes = format_attributes(actor) - actor_items = [item.name for item in actor.items] - - # set up a result parser for the agent - def result_parser(value, agent, **kwargs): - if not room or not actor: - raise ValueError( - "Room and actor must be set before parsing results" - ) - - if could_be_json(value): - event = ActionEvent.from_json(value, room, actor) - else: - event = ReplyEvent.from_text(value, room, actor) - - broadcast(event) - - return world_result_parser(value, agent, **kwargs) - - # prompt and act - logger.info("starting turn for actor: %s", actor_name) - result = loop_retry( - agent, - ( - "You are currently in {room_name}. {room_description}. {attributes}. " - "The room contains the following characters: {visible_actors}. " - "The room contains the following items: {visible_items}. " - "Your inventory contains the following items: {actor_items}." - "You can take the following actions: {actions}. " - "You can move in the following directions: {directions}. " - "What will you do next? Reply with a JSON function call, calling one of the actions." - "You can only perform one action per turn. What is your next action?" - ), - context={ - "actions": action_names, - "actor_items": actor_items, - "attributes": actor_attributes, - "directions": room_directions, - "room_name": room.name, - "room_description": describe_entity(room), - "visible_actors": room_actors, - "visible_items": room_items, - }, - result_parser=result_parser, - toolbox=action_tools, - ) - - logger.debug(f"{actor.name} step result: {result}") - if agent.memory: - agent.memory.append(result) + # give the actor a chance to think and check their planner + if agent.memory and len(agent.memory) > 0: + try: + thoughts = prompt_actor_think(room, actor, agent, planner_toolbox) + logger.debug(f"{actor.name} thinks: {thoughts}") + except Exception: + logger.exception(f"error during planning for actor {actor.name}") + result = prompt_actor_action(room, actor, agent, action_names, action_tools) result_event = ResultEvent(result=result, room=room, actor=actor) broadcast(result_event) @@ -171,6 +272,6 @@ def simulate_world( system.simulate(world, current_step) set_current_step(current_step + 1) - if i > steps: + if i >= steps: logger.info("reached step limit at world step %s", current_step + 1) break diff --git a/adventure/logic.py b/adventure/systems/logic.py similarity index 90% rename from adventure/logic.py rename to adventure/systems/logic.py index 404315a..39f7110 100644 --- a/adventure/logic.py +++ b/adventure/systems/logic.py @@ -1,7 +1,8 @@ from functools import partial, wraps from logging import getLogger +from os import path from random import random -from typing import Dict, List, Optional, Protocol +from typing import Any, Dict, List, Optional, Protocol from pydantic import Field from rule_engine import Rule @@ -128,7 +129,12 @@ def update_attributes( def update_logic( - world: World, step: int, rules: LogicTable, triggers: TriggerTable + world: World, + step: int, + data: Any | None = None, + *, + rules: LogicTable, + triggers: TriggerTable ) -> None: for room in world.rooms: update_attributes(room, rules=rules, triggers=triggers) @@ -165,7 +171,9 @@ def format_logic( def load_logic(filename: str): - logger.info("loading logic from file: %s", filename) + system_name = "logic-" + path.splitext(path.basename(filename))[0] + logger.info("loading logic from file %s as system %s", filename, system_name) + with open(filename) as file: logic_rules = LogicTable(**load(file, Loader=Loader)) logic_triggers = {} @@ -179,12 +187,17 @@ def load_logic(filename: str): logic_triggers[trigger] = get_plugin_function(function_name) logger.info("initialized logic system") + system_format = wraps(format_logic)(partial(format_logic, rules=logic_rules)) + system_initialize = wraps(load_logic)( + partial(update_logic, step=0, rules=logic_rules, triggers=logic_triggers) + ) 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( + name=system_name, format=system_format, + initialize=system_initialize, simulate=system_simulate, ) diff --git a/adventure/systems/quest.py b/adventure/systems/quest.py new file mode 100644 index 0000000..93d9e04 --- /dev/null +++ b/adventure/systems/quest.py @@ -0,0 +1,233 @@ +from logging import getLogger +from typing import Dict, List, Literal, Optional + +from packit.agent import Agent +from pydantic import Field + +from adventure.context import get_system_data +from adventure.game_system import GameSystem, SystemData +from adventure.models.base import Attributes, dataclass, uuid +from adventure.models.entity import ( + Actor, + EntityReference, + Item, + Room, + World, + WorldEntity, +) +from adventure.systems.logic import match_logic +from adventure.utils.search import ( + find_entity_reference, + find_item_in_container, + find_item_in_room, +) +from adventure.utils.systems import load_system_data, save_system_data + +logger = getLogger(__name__) + + +QUEST_SYSTEM = "quest" + + +@dataclass +class QuestGoalContains: + """ + Quest goal for any kind of fetch quest, including delivery and escort quests. + + Valid combinations are: + - container: Room and items: List[Actor | Item] + - container: Actor and items: List[Item] + """ + + container: EntityReference + contents: List[EntityReference] = Field(default_factory=list) + type: Literal["contains"] = "contains" + + +@dataclass +class QuestGoalAttributes: + """ + Quest goal for any kind of attribute quest, including spell casting and item usage. + """ + + target: EntityReference + match: Optional[Attributes] = None + rule: Optional[str] = None + type: Literal["attributes"] = "attributes" + + +QuestGoal = QuestGoalAttributes | QuestGoalContains + + +@dataclass +class QuestReward: + pass + + +@dataclass +class Quest: + name: str + description: str + giver: EntityReference + goal: QuestGoal + reward: QuestReward + type: Literal["quest"] = "quest" + id: str = Field(default_factory=uuid) + + +@dataclass +class QuestData: + active: Dict[str, Quest] + available: Dict[str, List[Quest]] + completed: Dict[str, List[Quest]] + + +# region quest completion +def is_quest_complete(world: World, quest: Quest) -> bool: + """ + Check if the given quest is complete. + """ + + if quest.goal.type == "contains": + container = find_entity_reference(world, quest.goal.container) + if not container: + raise ValueError(f"quest container not found: {quest.goal.container}") + + for content in quest.goal.contents: + if isinstance(container, Room): + if content.item: + if not find_item_in_room(container, content.item): + return False + elif isinstance(container, (Actor, Item)): + if content.item: + if not find_item_in_container(container, content.item): + return False + else: + logger.warning(f"unsupported container type: {container}") + return False + + return True + elif quest.goal.type == "attributes": + target = find_entity_reference(world, quest.goal.target) + if not target: + raise ValueError(f"quest target not found: {quest.goal.target}") + + if match_logic(target, quest.goal): + return True + + return False + + +# endregion + + +# region state management +def get_quests_for_actor(quests: QuestData, actor: Actor) -> List[Quest]: + """ + Get all quests for the given actor. + """ + return quests.available.get(actor.name, []) + + +def set_active_quest(quests: QuestData, actor: Actor, quest: Quest) -> None: + """ + Set the active quest for the given actor. + """ + quests.active[actor.name] = quest + + +def get_active_quest(quests: QuestData, actor: Actor) -> Quest | None: + """ + Get the active quest for the given actor. + """ + return quests.active.get(actor.name) + + +def complete_quest(quests: QuestData, actor: Actor, quest: Quest) -> None: + """ + Complete the given quest for the given actor. + """ + if quest in quests.available.get(actor.name, []): + quests.available[actor.name].remove(quest) + + if quest == quests.active.get(actor.name, None): + del quests.active[actor.name] + + if actor.name not in quests.completed: + quests.completed[actor.name] = [] + + quests.completed[actor.name].append(quest) + + +# endregion + + +def initialize_quests(world: World) -> QuestData: + """ + Initialize quests for the world. + """ + + logger.info("initializing quest data for world %s", world.name) + return QuestData(active={}, available={}, completed={}) + + +def generate_quests(agent: Agent, theme: str, entity: WorldEntity) -> None: + """ + Generate new quests for the world. + """ + + quests: QuestData | None = get_system_data(QUEST_SYSTEM) + if not quests: + raise ValueError("Quest data is required for quest generation") + + if isinstance(entity, Actor): + available_quests = get_quests_for_actor(quests, entity) + if len(available_quests) == 0: + logger.info(f"generating new quest for {entity.name}") + # TODO: generate one new quest + + +def simulate_quests(world: World, step: int, data: QuestData | None = None) -> None: + """ + 1. Check for any completed quests. + 2. Update any active quests. + 3. Generate any new quests. + """ + + if not data: + # TODO: initialize quest data for worlds that don't have it + raise ValueError("Quest data is required for simulation") + + for room in world.rooms: + for actor in room.actors: + active_quest = get_active_quest(data, actor) + if active_quest: + logger.info(f"simulating quest for {actor.name}: {active_quest.name}") + if is_quest_complete(world, active_quest): + logger.info(f"quest complete for {actor.name}: {active_quest.name}") + complete_quest(data, actor, active_quest) + + +def load_quest_data(file: str) -> QuestData: + logger.info(f"loading quest data from {file}") + return load_system_data(QuestData, file) + + +def save_quest_data(file: str, data: QuestData) -> None: + logger.info(f"saving quest data to {file}") + return save_system_data(QuestData, file, data) + + +def init() -> List[GameSystem]: + return [ + GameSystem( + QUEST_SYSTEM, + data=SystemData( + load=load_quest_data, + save=save_quest_data, + ), + generate=generate_quests, + initialize=initialize_quests, + simulate=simulate_quests, + ) + ] diff --git a/adventure/systems/rpg/__init__.py b/adventure/systems/rpg/__init__.py index 35aa6a8..d64a1ed 100644 --- a/adventure/systems/rpg/__init__.py +++ b/adventure/systems/rpg/__init__.py @@ -3,7 +3,7 @@ from .language_actions import action_read from .magic_actions import action_cast from .movement_actions import action_climb -from adventure.logic import load_logic +from adventure.systems.logic import load_logic LOGIC_FILES = [ "./adventure/systems/rpg/weather_logic.yaml", diff --git a/adventure/systems/sim/__init__.py b/adventure/systems/sim/__init__.py index 591602a..8f1ef7e 100644 --- a/adventure/systems/sim/__init__.py +++ b/adventure/systems/sim/__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 load_logic +from adventure.systems.logic import load_logic LOGIC_FILES = [ "./adventure/systems/sim/environment_logic.yaml", diff --git a/adventure/utils/effect.py b/adventure/utils/effect.py index 4bac0af..f38435f 100644 --- a/adventure/utils/effect.py +++ b/adventure/utils/effect.py @@ -298,7 +298,7 @@ def apply_permanent_effects( def apply_effects(target: Actor, effects: List[EffectPattern]) -> None: """ - Apply a set of effects to a set of attributes. + Apply a set of effects to an actor and their attributes. """ permanent_effects = [ @@ -312,3 +312,17 @@ def apply_effects(target: Actor, effects: List[EffectPattern]) -> None: ] temporary_effects = resolve_effects(temporary_effects) target.active_effects.extend(temporary_effects) + + +def expire_effects(target: Actor) -> None: + """ + Decrement the duration of effects on an actor and remove any that have expired. + """ + + for effect in target.active_effects: + if effect.duration is not None: + effect.duration -= 1 + + target.active_effects[:] = [ + effect for effect in target.active_effects if is_active_effect(effect) + ] diff --git a/adventure/utils/file.py b/adventure/utils/file.py new file mode 100644 index 0000000..0dc6a6e --- /dev/null +++ b/adventure/utils/file.py @@ -0,0 +1,9 @@ +from yaml import Loader, dump, load + + +def load_yaml(file): + return load(file, Loader=Loader) + + +def save_yaml(file, data): + return dump(data, file) diff --git a/adventure/utils/search.py b/adventure/utils/search.py index adfce7f..37d7d40 100644 --- a/adventure/utils/search.py +++ b/adventure/utils/search.py @@ -1,6 +1,14 @@ from typing import Any, Generator -from adventure.models.entity import Actor, Item, Portal, Room, World +from adventure.models.entity import ( + Actor, + EntityReference, + Item, + Portal, + Room, + World, + WorldEntity, +) from .string import normalize_name @@ -59,20 +67,11 @@ def find_item( def find_item_in_actor( actor: Actor, item_name: str, include_item_inventory=False ) -> Item | None: - for item in actor.items: - if normalize_name(item.name) == normalize_name(item_name): - return item - - if include_item_inventory: - item = find_item_in_container(item, item_name, include_item_inventory) - if item: - return item - - return None + return find_item_in_container(actor, item_name, include_item_inventory) def find_item_in_container( - container: Item, item_name: str, include_item_inventory=False + container: Actor | Item, item_name: str, include_item_inventory=False ) -> Item | None: for item in container.items: if normalize_name(item.name) == normalize_name(item_name): @@ -130,6 +129,28 @@ def find_containing_room(world: World, entity: Room | Actor | Item) -> Room | No return None +def find_entity_reference( + world: World, reference: EntityReference +) -> WorldEntity | None: + """ + Resolve an entity reference to an entity in the world. + """ + + if reference.room: + return find_room(world, reference.room) + + if reference.actor: + return find_actor(world, reference.actor) + + if reference.item: + return find_item(world, reference.item) + + if reference.portal: + return find_portal(world, reference.portal) + + return None + + def list_rooms(world: World) -> Generator[Room, Any, None]: for room in world.rooms: yield room diff --git a/adventure/utils/systems.py b/adventure/utils/systems.py new file mode 100644 index 0000000..71e9d73 --- /dev/null +++ b/adventure/utils/systems.py @@ -0,0 +1,14 @@ +from pydantic import RootModel + +from adventure.utils.file import load_yaml, save_yaml + + +def load_system_data(cls, file): + with load_yaml(file) as data: + return cls(**data) + + +def save_system_data(cls, file, model): + data = RootModel[cls](model).model_dump() + with open(file, "w") as f: + save_yaml(f, data) diff --git a/client/src/details.tsx b/client/src/details.tsx index 539d7e1..7ac5e64 100644 --- a/client/src/details.tsx +++ b/client/src/details.tsx @@ -1,13 +1,31 @@ import { Maybe, doesExist } from '@apextoaster/js-utils'; -import { Button, Dialog, DialogActions, DialogContent, DialogTitle, Typography } from '@mui/material'; +import { + Button, + Dialog, + DialogActions, + DialogContent, + DialogTitle, + List, + ListItem, + ListItemText, + Paper, + Stack, + Table, + TableBody, + TableCell, + TableContainer, + TableHead, + TableRow, + Typography, +} from '@mui/material'; import { instance as graphviz } from '@viz-js/viz'; import React, { Fragment, useEffect } from 'react'; import { useStore } from 'zustand'; -import { Actor, Item, Room, World } from './models'; +import { Actor, Attributes, Item, Portal, Room, World } from './models'; import { StoreState, store } from './store'; export interface EntityDetailsProps { - entity: Maybe; + entity: Maybe; onClose: () => void; onRender: (type: string, entity: string) => void; } @@ -20,15 +38,58 @@ export function EntityDetails(props: EntityDetailsProps) { return ; } + const { description, name, type } = entity; + + let attributes: Attributes = {}; + let planner; + + if (type === 'actor') { + const actor = entity as Actor; + attributes = actor.attributes; + planner = actor.planner; + } + + if (type === 'item') { + const item = entity as Item; + attributes = item.attributes; + } + return - {entity.name} + {name} - - {entity.description} - + + + {description} + + + + + + Attribute + Value + + + + {Object.entries(attributes).map(([key, value]) => ( + + {key} + {value} + + ))} + +
+
+ {doesExist(planner) && + {planner.notes.map((note: string) => ( + + + + ))} + } +
- +
; @@ -94,7 +155,7 @@ export function DetailDialog(props: DetailDialogProps) { >{details}; } -export function isWorld(entity: Maybe): entity is World { +export function isWorld(entity: Maybe): entity is World { return doesExist(entity) && doesExist(Object.getOwnPropertyDescriptor(entity, 'theme')); } diff --git a/client/src/models.ts b/client/src/models.ts index 1385bbf..3c6bd81 100644 --- a/client/src/models.ts +++ b/client/src/models.ts @@ -1,7 +1,20 @@ +export type Attributes = Record; + +export interface CalendarEvent { + name: string; + turn: number; +} + +export interface Planner { + calendar: Array; + notes: Array; +} + export interface Item { type: 'item'; name: string; description: string; + attributes: Attributes; } export interface Actor { @@ -10,6 +23,8 @@ export interface Actor { backstory: string; description: string; items: Array; + attributes: Attributes; + planner: Planner; } export interface Portal { @@ -26,6 +41,7 @@ export interface Room { actors: Array; items: Array; portals: Array; + attributes: Attributes; } export interface World {