diff --git a/README.md b/README.md index e41f562..bf4cf0f 100644 --- a/README.md +++ b/README.md @@ -125,19 +125,20 @@ python3 -m adventure.main \ # --systems adventure.custom_systems:init_logic ``` -This will generate a relatively small world with 3 rooms or areas, run for 30 steps, then shut down. The world will be -saved to a file named `worlds/outback-animals-1.json` and the state will be saved after each step to another file named -`worlds/outback-animals-1.state.json`. The world can be stopped at any time by pressing Ctrl-C, although the step in -progress will be lost. The saved state can be resumed and played for any number of additional steps. +This will generate a relatively small world with 3 rooms or areas, run for 30 steps, then shut down. + +The world will be saved to a file named `worlds/outback-animals-1.json` and the state will be saved after each step to +another file named `worlds/outback-animals-1.state.json`. The world can be stopped at any time by pressing Ctrl-C, +although the step in progress will be lost. The saved state can be resumed and played for any number of additional +steps by running the server again with the same arguments. > Note: `module.name:function_name` and `path/filename.yml:key` are patterns you will see repeated throughout TaleWeave AI. > They indicate a Python module and function within it, or a data file and key within it, respectively. The `sim_systems` provide many mechanics from popular life simulations, including hunger, thirst, exhaustion, and mood. Custom actions and systems can be used to provide any other mechanics that are desired for your setting. The logic -system uses a combination of Python and YAML to build complex systems that add and modify the attributes on rooms, -characters, and items. Attributes can become sentences and fragments in the character prompt and entity description, -allowing the logic to influence the language models. +system uses a combination of Python and YAML to modify the prompts connected to rooms, characters, and items in the +world, influencing the behavior of the language models. ## Documentation diff --git a/adventure/context.py b/adventure/context.py index 770494f..3dc3c32 100644 --- a/adventure/context.py +++ b/adventure/context.py @@ -3,8 +3,9 @@ from typing import Callable, Dict, Tuple from packit.agent import Agent from adventure.models.entity import Actor, Room, World +from adventure.models.event import GameEvent -current_broadcast: Callable[[str], None] | None = None +current_broadcast: Callable[[str | GameEvent], None] | None = None current_world: World | None = None current_room: Room | None = None current_actor: Actor | None = None @@ -16,7 +17,7 @@ dungeon_master: Agent | None = None actor_agents: Dict[str, Tuple[Actor, Agent]] = {} -def broadcast(message: str): +def broadcast(message: str | GameEvent): if current_broadcast: current_broadcast(message) diff --git a/adventure/discord_bot.py b/adventure/discord_bot.py index 94dcc97..4c1d64b 100644 --- a/adventure/discord_bot.py +++ b/adventure/discord_bot.py @@ -3,11 +3,11 @@ from os import environ from queue import Queue from re import sub from threading import Thread -from typing import Tuple from discord import Client, Embed, File, Intents from adventure.context import ( + broadcast, get_actor_agent_for_name, get_current_world, set_actor_agent, @@ -16,6 +16,7 @@ from adventure.models.event import ( ActionEvent, GameEvent, GenerateEvent, + PlayerEvent, PromptEvent, ReplyEvent, ResultEvent, @@ -28,7 +29,7 @@ logger = getLogger(__name__) client = None active_tasks = set() -prompt_queue: Queue[Tuple[GameEvent, Embed | str]] = Queue() +event_queue: Queue[GameEvent] = Queue() def remove_tags(text: str) -> str: @@ -156,8 +157,7 @@ class AdventureClient(Client): event.prompt, ) - # TODO: build an embed from the prompt - prompt_queue.put((event, event.prompt)) + event_queue.put(event) return True player = RemotePlayer( @@ -167,25 +167,25 @@ class AdventureClient(Client): set_player(user_name, player) logger.info(f"{user_name} has joined the game as {actor.name}!") - await message.channel.send( - f"{user_name} has joined the game as {actor.name}!" - ) - return - - if message.content.startswith("!leave"): - # TODO: revert to LLM agent - logger.info(f"{user_name} has left the game!") - await message.channel.send(f"{user_name} has left the game!") - return + join_event = PlayerEvent("join", character_name, user_name) + return broadcast(join_event) player = get_player(user_name) - if player and isinstance(player, RemotePlayer): - content = remove_tags(message.content) - player.input_queue.put(content) - logger.info( - f"Received message from {user_name} for {player.name}: {content}" - ) - return + if player: + if message.content.startswith("!leave"): + # TODO: check if player is playing + # TODO: revert to LLM agent + logger.info(f"{user_name} has left the game!") + leave_event = PlayerEvent("leave", player.name, user_name) + return broadcast(leave_event) + + if isinstance(player, RemotePlayer): + content = remove_tags(message.content) + player.input_queue.put(content) + logger.info( + f"Received message from {user_name} for {player.name}: {content}" + ) + return await message.channel.send( "You are not currently playing Adventure! Type `!join` to start playing!" @@ -194,41 +194,49 @@ class AdventureClient(Client): def launch_bot(): + global client + + intents = Intents.default() + # intents.message_content = True + + client = AdventureClient(intents=intents) + def bot_main(): - global client + if not client: + raise ValueError("No Discord client available") - intents = Intents.default() - # intents.message_content = True - - client = AdventureClient(intents=intents) client.run(environ["DISCORD_TOKEN"]) - def prompt_main(): + def send_main(): from time import sleep while True: sleep(0.1) - if prompt_queue.empty(): + if event_queue.empty(): + # logger.debug("no events to prompt") continue if len(active_tasks) > 0: + logger.debug("waiting for active tasks to complete") continue - event, prompt = prompt_queue.get() - logger.info("Prompting for event %s: %s", event, prompt) + event = event_queue.get() + logger.info("broadcasting event %s", event.type) if client: - prompt_task = client.loop.create_task(broadcast_event(prompt)) - active_tasks.add(prompt_task) - prompt_task.add_done_callback(active_tasks.discard) + event_task = client.loop.create_task(broadcast_event(event)) + active_tasks.add(event_task) + event_task.add_done_callback(active_tasks.discard) + else: + logger.warning("no Discord client available") bot_thread = Thread(target=bot_main, daemon=True) bot_thread.start() - prompt_thread = Thread(target=prompt_main, daemon=True) - prompt_thread.start() + send_thread = Thread(target=send_main, daemon=True) + send_thread.start() - return [bot_thread, prompt_thread] + return [bot_thread, send_thread] def stop_bot(): @@ -253,72 +261,99 @@ def get_active_channels(): ] -async def broadcast_event(message: str | Embed): +def bot_event(event: GameEvent): + event_queue.put(event) + + +async def broadcast_event(message: str | GameEvent): if not client: - logger.warning("No Discord client available") + logger.warning("no Discord client available") return active_channels = get_active_channels() if not active_channels: - logger.warning("No active channels") + logger.warning("no active channels") return for channel in active_channels: if isinstance(message, str): - logger.info("Broadcasting to channel %s: %s", channel, message) + logger.info("broadcasting to channel %s: %s", channel, message) await channel.send(content=message) - elif isinstance(message, Embed): + elif isinstance(message, GameEvent): + embed = embed_from_event(message) logger.info( - "Broadcasting to channel %s: %s - %s", + "broadcasting to channel %s: %s - %s", channel, - message.title, - message.description, + embed.title, + embed.description, ) - await channel.send(embed=message) + await channel.send(embed=embed) -def bot_event(event: GameEvent): +def embed_from_event(event: GameEvent) -> Embed: if isinstance(event, GenerateEvent): - bot_generate(event) + return embed_from_generate(event) elif isinstance(event, ResultEvent): - bot_result(event) + return embed_from_result(event) elif isinstance(event, (ActionEvent, ReplyEvent)): - bot_action(event) + return embed_from_action(event) elif isinstance(event, StatusEvent): - pass + return embed_from_status(event) + elif isinstance(event, PlayerEvent): + return embed_from_player(event) else: - logger.warning("Unknown event type: %s", event) + logger.warning("unknown event type: %s", event) -def bot_action(event: ActionEvent | ReplyEvent): - try: - action_embed = Embed(title=event.room.name, description=event.actor.name) +def embed_from_action(event: ActionEvent | ReplyEvent): + action_embed = Embed(title=event.room.name, description=event.actor.name) - if isinstance(event, ActionEvent): - action_name = event.action.replace("action_", "").title() - action_parameters = event.parameters + if isinstance(event, ActionEvent): + action_name = event.action.replace("action_", "").title() + action_parameters = event.parameters - action_embed.add_field(name="Action", value=action_name) + action_embed.add_field(name="Action", value=action_name) - for key, value in action_parameters.items(): - action_embed.add_field(name=key.replace("_", " ").title(), value=value) - else: - action_embed.add_field(name="Message", value=event.text) + for key, value in action_parameters.items(): + action_embed.add_field(name=key.replace("_", " ").title(), value=value) + else: + action_embed.add_field(name="Message", value=event.text) - prompt_queue.put((event, action_embed)) - except Exception as e: - logger.error("Failed to broadcast action: %s", e) + return action_embed -def bot_generate(event: GenerateEvent): - prompt_queue.put((event, event.name)) +def embed_from_generate(event: GenerateEvent) -> Embed: + generate_embed = Embed(title="Generating", description=event.name) + return generate_embed -def bot_result(event: ResultEvent): +def embed_from_result(event: ResultEvent): text = event.result if len(text) > 1000: text = text[:1000] + "..." result_embed = Embed(title=event.room.name, description=event.actor.name) result_embed.add_field(name="Result", value=text) - prompt_queue.put((event, result_embed)) + return result_embed + + +def embed_from_player(event: PlayerEvent): + if event.status == "join": + title = "New Player" + description = f"{event.client} is now playing as {event.character}" + else: + title = "Player Left" + description = f"{event.client} has left the game, {event.character} will be played by the AI" + + player_embed = Embed(title=title, description=description) + return player_embed + + +def embed_from_status(event: StatusEvent): + # TODO: add room and actor + status_embed = Embed( + title=event.room.name if event.room else "", + description=event.actor.name if event.actor else "", + ) + status_embed.add_field(name="Status", value=event.text) + return status_embed diff --git a/adventure/main.py b/adventure/main.py index 37af3e3..2b818fe 100644 --- a/adventure/main.py +++ b/adventure/main.py @@ -41,6 +41,15 @@ logger = logger_with_colors(__name__, level="DEBUG") load_dotenv(environ.get("ADVENTURE_ENV", ".env"), override=True) +# start the debugger, if needed +if environ.get("DEBUG", "false").lower() == "true": + import debugpy + + debugpy.listen(5679) + logger.info("waiting for debugger to attach...") + debugpy.wait_for_client() + + # main def parse_args(): import argparse diff --git a/adventure/models/event.py b/adventure/models/event.py index f87b593..465ff99 100644 --- a/adventure/models/event.py +++ b/adventure/models/event.py @@ -1,5 +1,5 @@ from json import loads -from typing import Callable, Dict, Literal +from typing import Any, Callable, Dict, List, Literal from .base import dataclass from .entity import Actor, Item, Room, WorldEntity @@ -11,7 +11,7 @@ class BaseEvent: A base event class. """ - event: str + type: str @dataclass @@ -20,7 +20,7 @@ class GenerateEvent: A new entity has been generated. """ - event = "generate" + type = "generate" name: str entity: WorldEntity | None = None @@ -39,9 +39,9 @@ class ActionEvent: An actor has taken an action. """ - event = "action" + type = "action" action: str - parameters: Dict[str, str] + parameters: Dict[str, bool | float | int | str] room: Room actor: Actor @@ -65,7 +65,7 @@ class PromptEvent: A prompt for an actor to take an action. """ - event = "prompt" + type = "prompt" prompt: str room: Room actor: Actor @@ -83,7 +83,7 @@ class ReplyEvent: This is the non-JSON version of an ActionEvent. """ - event = "text" + type = "reply" text: str room: Room actor: Actor @@ -99,7 +99,7 @@ class ResultEvent: A result of an action. """ - event = "result" + type = "result" result: str room: Room actor: Actor @@ -111,19 +111,34 @@ class StatusEvent: A status broadcast event with text. """ - event = "status" + type = "status" text: str room: Room | None = None actor: Actor | None = None +@dataclass +class SnapshotEvent: + """ + A snapshot of the world state. + + This one is slightly unusual, because the world has already been dumped to a JSON-compatible dictionary. + That is especially important for the memory, which is a dictionary of actor names to lists of messages. + """ + + type = "snapshot" + world: Dict[str, Any] + memory: Dict[str, List[Any]] + step: int + + @dataclass class PlayerEvent: """ A player joining or leaving the game. """ - event = "player" + type = "player" status: Literal["join", "leave"] character: str client: str diff --git a/adventure/optional_actions.py b/adventure/optional_actions.py index ca65505..70907bb 100644 --- a/adventure/optional_actions.py +++ b/adventure/optional_actions.py @@ -30,7 +30,7 @@ if not has_dungeon_master(): def action_explore(direction: str) -> str: """ - Explore the room in a new direction. + Explore the room in a new direction. You can only explore directions that do not already have a portal. Args: direction: The direction to explore: north, south, east, or west. @@ -44,7 +44,7 @@ def action_explore(direction: str) -> str: if direction in current_room.portals: dest_room = current_room.portals[direction] - return f"You cannot explore {direction} from here, that direction leads to {dest_room}." + return f"You cannot explore {direction} from here, that direction already leads to {dest_room}. Please use the move action to go there." existing_rooms = [room.name for room in current_world.rooms] new_room = generate_room( diff --git a/adventure/server.py b/adventure/server.py index 1512ee3..b83ba92 100644 --- a/adventure/server.py +++ b/adventure/server.py @@ -7,18 +7,11 @@ from typing import Literal from uuid import uuid4 import websockets +from pydantic import RootModel -from adventure.context import get_actor_agent_for_name, set_actor_agent -from adventure.models.entity import Actor, Room, World -from adventure.models.event import ( - ActionEvent, - GameEvent, - GenerateEvent, - PromptEvent, - ReplyEvent, - ResultEvent, - StatusEvent, -) +from adventure.context import broadcast, get_actor_agent_for_name, set_actor_agent +from adventure.models.entity import Actor, Item, Room, World +from adventure.models.event import GameEvent, PlayerEvent, PromptEvent from adventure.player import ( RemotePlayer, get_player, @@ -46,7 +39,7 @@ async def handler(websocket): dumps( { "type": "prompt", - "id": id, + "client": id, "character": character, "prompt": prompt, "actions": [], @@ -153,10 +146,7 @@ static_thread = None def server_json(obj): - if isinstance(obj, Actor): - return obj.name - - if isinstance(obj, Room): + if isinstance(obj, (Actor, Item, Room)): return obj.name return world_json(obj) @@ -191,68 +181,26 @@ def server_system(world: World, step: int): global last_snapshot json_state = { **snapshot_world(world, step), - "type": "world", + "type": "snapshot", } last_snapshot = send_and_append(json_state) -def server_result(room: Room, actor: Actor, action: str): - json_action = { - "actor": actor, - "result": action, - "room": room, - "type": "result", - } - send_and_append(json_action) - - -def server_action(room: Room, actor: Actor, message: str): - json_input = { - "actor": actor, - "input": message, - "room": room, - "type": "action", - } - send_and_append(json_input) - - -def server_generate(event: GenerateEvent): - json_broadcast = { - "name": event.name, - "type": "generate", - } - send_and_append(json_broadcast) - - def server_event(event: GameEvent): - if isinstance(event, GenerateEvent): - return server_generate(event) - elif isinstance(event, ActionEvent): - return server_action(event.room, event.actor, event.action) - elif isinstance(event, ReplyEvent): - return server_action(event.room, event.actor, event.text) - elif isinstance(event, ResultEvent): - return server_result(event.room, event.actor, event.result) - elif isinstance(event, StatusEvent): - pass - else: - logger.warning("Unknown event type: %s", event) + json_event = RootModel[event.__class__](event).model_dump() + json_event["type"] = event.type + send_and_append(json_event) -def player_event(character: str, id: str, event: Literal["join", "leave"]): - json_broadcast = { - "type": "player", - "character": character, - "id": id, - "event": event, - } - send_and_append(json_broadcast) +def player_event(character: str, client: str, status: Literal["join", "leave"]): + event = PlayerEvent(status=status, character=character, client=client) + broadcast(event) def player_list(): - players = {value: key for key, value in list_players()} json_broadcast = { "type": "players", - "players": players, + "players": list_players(), } + # TODO: broadcast this send_and_append(json_broadcast) diff --git a/adventure/simulate.py b/adventure/simulate.py index aaa4141..7e9f4b0 100644 --- a/adventure/simulate.py +++ b/adventure/simulate.py @@ -29,6 +29,7 @@ from adventure.models.entity import Attributes, World from adventure.models.event import ( ActionEvent, EventCallback, + GameEvent, ReplyEvent, ResultEvent, StatusEvent, @@ -70,9 +71,13 @@ def simulate_world( set_current_world(world) # set up a broadcast callback - def broadcast_callback(message): + def broadcast_callback(message: str | GameEvent): logger.info(message) - event = StatusEvent(text=message) + if isinstance(message, str): + event = StatusEvent(text=message) + else: + event = message + for callback in callbacks: callback(event) diff --git a/client/src/app.tsx b/client/src/app.tsx index dae7902..fecd7ae 100644 --- a/client/src/app.tsx +++ b/client/src/app.tsx @@ -119,7 +119,7 @@ export function App(props: AppProps) { if (data.type === 'prompt') { // prompts are broadcast to all players - if (data.id === clientId) { + if (data.client === clientId) { // only notify the active player setActiveTurn(true); } else { @@ -137,7 +137,7 @@ export function App(props: AppProps) { setHistory((prev) => prev.concat(data)); // if we get a world event, update the last world state - if (data.type === 'world') { + if (data.type === 'snapshot') { setWorld(data.world); } diff --git a/client/src/events.tsx b/client/src/events.tsx index 2f828d8..3099f7f 100644 --- a/client/src/events.tsx +++ b/client/src/events.tsx @@ -10,17 +10,17 @@ export interface EventItemProps { focusRef?: MutableRefObject; } -export function ActionItem(props: EventItemProps) { +export function ActionEventItem(props: EventItemProps) { const { event } = props; const { actor, room, type } = event; const content = formatters[type](event); return - + - {actor} + {actor.name} {content} @@ -38,7 +38,7 @@ export function ActionItem(props: EventItemProps) { ; } -export function WorldItem(props: EventItemProps) { +export function SnapshotEventItem(props: EventItemProps) { const { event } = props; const { step, world } = event; const { theme } = world; @@ -63,9 +63,9 @@ export function WorldItem(props: EventItemProps) { ; } -export function MessageItem(props: EventItemProps) { +export function ReplyEventItem(props: EventItemProps) { const { event } = props; - const { message } = event; + const { text } = event; return @@ -80,26 +80,26 @@ export function MessageItem(props: EventItemProps) { variant="body2" color="text.primary" > - {message} + {text} } /> ; } -export function PlayerItem(props: EventItemProps) { +export function PlayerEventItem(props: EventItemProps) { const { event } = props; - const { character, event: innerEvent, id } = event; + const { character, status, client } = event; let primary = ''; let secondary = ''; - if (innerEvent === 'join') { + if (status === 'join') { primary = 'New Player'; - secondary = `${id} is now playing as ${character}`; + secondary = `${client} is now playing as ${character}`; } - if (innerEvent === 'leave') { + if (status === 'leave') { primary = 'Player Left'; - secondary = `${id} has left the game. ${character} is now controlled by an LLM`; + secondary = `${client} has left the game. ${character} is now controlled by an LLM`; } return @@ -129,13 +129,13 @@ export function EventItem(props: EventItemProps) { switch (type) { case 'action': case 'result': - return ; - case 'event': - return ; + return ; + case 'reply': + return ; case 'player': - return ; - case 'world': - return ; + return ; + case 'snapshot': + return ; default: return diff --git a/client/src/format.ts b/client/src/format.ts index b9927d3..35c755d 100644 --- a/client/src/format.ts +++ b/client/src/format.ts @@ -6,19 +6,15 @@ export function formatActionName(name: string) { } export function formatAction(data: any) { - const actionName = formatActionName(data.function); + const actionName = formatActionName(data.action); const actionParameters = data.parameters; return `Action: ${actionName} - ${Object.entries(actionParameters).map(([key, value]) => `${key}: ${value}`).join(', ')}`; } export function formatInput(data: any) { - try { - const action = formatAction(JSON.parse(data.input)); - return `Starting turn: ${action}`; - } catch (err) { - return `Error parsing input: ${err}`; - } + const action = formatAction(data); + return `Starting turn: ${action}`; } export function formatResult(data: any) {