1
0
Fork 0

use the same events on the react client

This commit is contained in:
Sean Sube 2024-05-09 23:45:10 -05:00
parent 94e02ebfe1
commit 90912d2bfe
Signed by: ssube
GPG Key ID: 3EED7B957D362AF1
11 changed files with 198 additions and 188 deletions

View File

@ -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

View File

@ -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)

View File

@ -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,19 +167,19 @@ 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):
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(
@ -194,41 +194,49 @@ class AdventureClient(Client):
def launch_bot():
def bot_main():
global client
intents = Intents.default()
# intents.message_content = True
client = AdventureClient(intents=intents)
def bot_main():
if not client:
raise ValueError("No Discord client available")
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,45 +261,51 @@ 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:
def embed_from_action(event: ActionEvent | ReplyEvent):
action_embed = Embed(title=event.room.name, description=event.actor.name)
if isinstance(event, ActionEvent):
@ -305,20 +319,41 @@ def bot_action(event: ActionEvent | ReplyEvent):
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

View File

@ -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

View File

@ -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

View File

@ -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(

View File

@ -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)

View File

@ -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)
if isinstance(message, str):
event = StatusEvent(text=message)
else:
event = message
for callback in callbacks:
callback(event)

View File

@ -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);
}

View File

@ -10,17 +10,17 @@ export interface EventItemProps {
focusRef?: MutableRefObject<any>;
}
export function ActionItem(props: EventItemProps) {
export function ActionEventItem(props: EventItemProps) {
const { event } = props;
const { actor, room, type } = event;
const content = formatters[type](event);
return <ListItem alignItems="flex-start" ref={props.focusRef}>
<ListItemAvatar>
<Avatar alt={actor} src="/static/images/avatar/1.jpg" />
<Avatar alt={actor.name} src="/static/images/avatar/1.jpg" />
</ListItemAvatar>
<ListItemText
primary={room}
primary={room.name}
secondary={
<React.Fragment>
<Typography
@ -29,7 +29,7 @@ export function ActionItem(props: EventItemProps) {
variant="body2"
color="text.primary"
>
{actor}
{actor.name}
</Typography>
{content}
</React.Fragment>
@ -38,7 +38,7 @@ export function ActionItem(props: EventItemProps) {
</ListItem>;
}
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) {
</ListItem>;
}
export function MessageItem(props: EventItemProps) {
export function ReplyEventItem(props: EventItemProps) {
const { event } = props;
const { message } = event;
const { text } = event;
return <ListItem alignItems="flex-start" ref={props.focusRef}>
<ListItemAvatar>
@ -80,26 +80,26 @@ export function MessageItem(props: EventItemProps) {
variant="body2"
color="text.primary"
>
{message}
{text}
</Typography>
}
/>
</ListItem>;
}
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 <ListItem alignItems="flex-start" ref={props.focusRef}>
@ -129,13 +129,13 @@ export function EventItem(props: EventItemProps) {
switch (type) {
case 'action':
case 'result':
return <ActionItem event={event} focusRef={props.focusRef} />;
case 'event':
return <MessageItem event={event} focusRef={props.focusRef} />;
return <ActionEventItem event={event} focusRef={props.focusRef} />;
case 'reply':
return <ReplyEventItem event={event} focusRef={props.focusRef} />;
case 'player':
return <PlayerItem event={event} focusRef={props.focusRef} />;
case 'world':
return <WorldItem event={event} focusRef={props.focusRef} />;
return <PlayerEventItem event={event} focusRef={props.focusRef} />;
case 'snapshot':
return <SnapshotEventItem event={event} focusRef={props.focusRef} />;
default:
return <ListItem ref={props.focusRef}>
<ListItemText primary={`Unknown event type: ${type}`} />

View File

@ -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));
const action = formatAction(data);
return `Starting turn: ${action}`;
} catch (err) {
return `Error parsing input: ${err}`;
}
}
export function formatResult(data: any) {