diff --git a/.gitignore b/.gitignore index 76254db..3e7caac 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,4 @@ -adventure/custom_*.py +adventure/custom_* worlds/ __pycache__/ .env diff --git a/adventure/logic.py b/adventure/logic.py index d5acf41..07e1c48 100644 --- a/adventure/logic.py +++ b/adventure/logic.py @@ -1,10 +1,10 @@ +from functools import partial from logging import getLogger from random import random from typing import Callable, Dict, List, Optional -from functools import partial -from rule_engine import Rule from pydantic import Field +from rule_engine import Rule from yaml import Loader, load from adventure.models import Actor, Item, Room, World, dataclass @@ -65,13 +65,15 @@ def update_attributes( if rule.rule: # TODO: pre-compile rules rule_impl = Rule(rule.rule) - if not rule_impl.matches({ - "attributes": typed_attributes, - }): + if not rule_impl.matches( + { + "attributes": typed_attributes, + } + ): logger.debug("logic rule did not match attributes: %s", rule.rule) continue - if rule.match and not(rule.match.items() <= typed_attributes.items()): + if rule.match and not (rule.match.items() <= typed_attributes.items()): logger.debug("logic did not match attributes: %s", rule.match) continue @@ -95,15 +97,25 @@ def update_attributes( return attributes -def update_logic(world: World, step: int, rules: LogicTable, triggers: TriggerTable) -> None: +def update_logic( + world: World, step: int, rules: LogicTable, triggers: TriggerTable +) -> None: for room in world.rooms: - room.attributes = update_attributes(room, room.attributes, rules=rules, triggers=triggers) + room.attributes = update_attributes( + room, room.attributes, rules=rules, triggers=triggers + ) for actor in room.actors: - actor.attributes = update_attributes(actor, actor.attributes, rules=rules, triggers=triggers) + actor.attributes = update_attributes( + actor, actor.attributes, rules=rules, triggers=triggers + ) for item in actor.items: - item.attributes = update_attributes(item, item.attributes, rules=rules, triggers=triggers) + item.attributes = update_attributes( + item, item.attributes, rules=rules, triggers=triggers + ) for item in room.items: - item.attributes = update_attributes(item, item.attributes, rules=rules, triggers=triggers) + item.attributes = update_attributes( + item, item.attributes, rules=rules, triggers=triggers + ) logger.info("updated world attributes") @@ -138,5 +150,5 @@ def init_from_file(filename: str): logger.info("initialized logic system") return ( partial(update_logic, rules=logic_rules, triggers=logic_triggers), - partial(format_logic, rules=logic_rules) + partial(format_logic, rules=logic_rules), ) diff --git a/adventure/optional_actions.py b/adventure/optional_actions.py index 94c96d6..5befc9c 100644 --- a/adventure/optional_actions.py +++ b/adventure/optional_actions.py @@ -89,7 +89,9 @@ def action_use(item: str, target: str) -> str: """ _, action_room, action_actor = get_current_context() - available_items = [item.name for item in action_actor.items] + [item.name for item in action_room.items] + available_items = [item.name for item in action_actor.items] + [ + item.name for item in action_room.items + ] if item not in available_items: return f"The {item} item is not available to use." diff --git a/adventure/player.py b/adventure/player.py index 9a80271..c5184e5 100644 --- a/adventure/player.py +++ b/adventure/player.py @@ -1,11 +1,14 @@ from json import dumps -from readline import add_history +from logging import getLogger from queue import Queue +from readline import add_history from typing import Any, Callable, Dict, List, Sequence from langchain_core.messages import AIMessage, BaseMessage, HumanMessage from packit.utils import could_be_json +logger = getLogger(__name__) + class BasePlayer: """ @@ -95,6 +98,8 @@ class LocalPlayer(BasePlayer): Ask the player for input. """ + logger.info("prompting local player: {self.name}") + formatted_prompt = prompt.format(**kwargs) self.memory.append(HumanMessage(content=formatted_prompt)) print(formatted_prompt) @@ -109,7 +114,9 @@ class RemotePlayer(BasePlayer): input_queue: Queue[str] send_prompt: Callable[[str, str], bool] - def __init__(self, name: str, backstory: str, send_prompt: Callable[[str, str], bool]) -> None: + def __init__( + self, name: str, backstory: str, send_prompt: Callable[[str, str], bool] + ) -> None: super().__init__(name, backstory) self.input_queue = Queue() self.send_prompt = send_prompt @@ -123,11 +130,12 @@ class RemotePlayer(BasePlayer): self.memory.append(HumanMessage(content=formatted_prompt)) try: + logger.info(f"prompting remote player: {self.name}") if self.send_prompt(self.name, formatted_prompt): reply = self.input_queue.get(timeout=60) + logger.info(f"got reply from remote player: {reply}") return self.parse_input(reply) except Exception: - pass + logger.exception("error getting reply from remote player") - # logger.warning("Failed to send prompt to remote player") return "" diff --git a/adventure/server.py b/adventure/server.py index c9f0711..f49af21 100644 --- a/adventure/server.py +++ b/adventure/server.py @@ -3,11 +3,13 @@ from collections import deque from json import dumps, loads from logging import getLogger from threading import Thread -from typing import Dict, Tuple +from typing import Dict +from uuid import uuid4 import websockets +from packit.agent import Agent -from adventure.context import get_actor_agent_for_name +from adventure.context import get_actor_agent_for_name, set_actor_agent_for_name from adventure.models import Actor, Room, World from adventure.player import RemotePlayer from adventure.state import snapshot_world, world_json @@ -16,20 +18,28 @@ logger = getLogger(__name__) connected = set() characters: Dict[str, RemotePlayer] = {} +previous_agents: Dict[str, Agent] = {} recent_events = deque(maxlen=100) recent_world = None async def handler(websocket): - logger.info("Client connected") + id = uuid4().hex + logger.info("Client connected, given id: %s", id) connected.add(websocket) async def next_turn(character: str, prompt: str) -> None: - await websocket.send(connected, dumps({ - "type": "turn", - "character": character, - "prompt": prompt, - })) + await websocket.send( + dumps( + { + "type": "prompt", + "id": id, + "character": character, + "prompt": prompt, + "actions": [], + } + ), + ) def sync_turn(character: str, prompt: str) -> bool: if websocket not in characters: @@ -39,6 +49,8 @@ async def handler(websocket): return True try: + await websocket.send(dumps({"type": "id", "id": id})) + if recent_world: await websocket.send(recent_world) @@ -55,26 +67,41 @@ async def handler(websocket): try: data = loads(message) - if "become" in data: + message_type = data.get("type", None) + if message_type == "player": character = characters.get(websocket) if character: - del characters[websocket] + del characters[id] character_name = data["become"] - actor, _ = get_actor_agent_for_name(character_name) + actor, llm_agent = get_actor_agent_for_name(character_name) if not actor: logger.error(f"Failed to find actor {character_name}") continue - if character_name in [player.name for player in characters.values()]: + if character_name in [ + player.name for player in characters.values() + ]: logger.error(f"Character {character_name} is already in use") continue - characters[websocket] = RemotePlayer(actor.name, actor.backstory, sync_turn) + # player_name = data["player"] + player = RemotePlayer(actor.name, actor.backstory, sync_turn) + characters[id] = player logger.info(f"Client {websocket} is now character {character_name}") - elif websocket in characters: - player = characters[websocket] - player.input_queue.put(message) + + # swap out the LLM agent + set_actor_agent_for_name(actor.name, actor, player) + previous_agents[actor.name] = llm_agent + + # notify all clients that this character is now active + send_and_append( + {"type": "player", "name": character_name, "id": id} + ) + elif message_type == "input" and id in characters: + player = characters[id] + logger.info("queueing input for player %s: %s", player.name, data) + player.input_queue.put(data["input"]) except Exception: logger.exception("Failed to parse message") @@ -83,9 +110,14 @@ async def handler(websocket): connected.remove(websocket) - # TODO: swap out the character for the original agent + # swap out the character for the original agent when they disconnect if websocket in characters: - del characters[websocket] + player = characters[id] + del characters[id] + + actor, _ = get_actor_agent_for_name(player.name) + if actor: + set_actor_agent_for_name(player.name, actor, previous_agents[player.name]) logger.info("Client disconnected") diff --git a/client/src/app.tsx b/client/src/app.tsx index 1cd0317..883268a 100644 --- a/client/src/app.tsx +++ b/client/src/app.tsx @@ -1,30 +1,31 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ -import React, { useState, useEffect, useRef, MutableRefObject, Fragment } from 'react'; +import React, { useState, useEffect, Fragment } from 'react'; import useWebSocketModule, { ReadyState } from 'react-use-websocket'; import { Maybe, doesExist } from '@apextoaster/js-utils'; -import List from '@mui/material/List'; -import ListItem from '@mui/material/ListItem'; -import Divider from '@mui/material/Divider'; -import ListItemText from '@mui/material/ListItemText'; -import ListItemAvatar from '@mui/material/ListItemAvatar'; -import Typography from '@mui/material/Typography'; -import Avatar from '@mui/material/Avatar'; -import Container from '@mui/material/Container'; -import Stack from '@mui/material/Stack'; -import Alert from '@mui/material/Alert'; -import Switch from '@mui/material/Switch'; -import { SimpleTreeView } from '@mui/x-tree-view/SimpleTreeView'; -import { TreeItem } from '@mui/x-tree-view/TreeItem'; -import { Button, CssBaseline, Dialog, DialogContent, DialogTitle, PaletteMode, ThemeProvider, createTheme } from '@mui/material'; +import { + Button, + CssBaseline, + Dialog, + DialogContent, + DialogTitle, + PaletteMode, + ThemeProvider, + createTheme, + List, + Divider, + Typography, + Container, + Stack, + Alert, + Switch, + } from '@mui/material'; -import { formatters } from './format.js'; +import { Room, Actor, Item, World, WorldPanel, SetDetails } from './world.js'; +import { EventItem } from './events.js'; +import { PlayerPanel } from './player.js'; const useWebSocket = (useWebSocketModule as any).default; -export interface EventItemProps { - event: any; -} - const statusStrings = { [ReadyState.CONNECTING]: 'Connecting', [ReadyState.OPEN]: 'Running', @@ -38,190 +39,10 @@ export function interleave(arr: Array) { return arr.reduce((acc, val, idx) => acc.concat(val, ), []).slice(0, -1); } -export function ActionItem(props: EventItemProps) { - const { event } = props; - const { actor, room, type } = event; - const content = formatters[type](event); - - return - - - - - - {actor} - - {content} - - } - /> - ; -} - -export function WorldItem(props: EventItemProps) { - const { event } = props; - const { step, world } = event; - const { theme } = world; - - return - - - - - Step {step} - - } - /> - ; -} - -export function MessageItem(props: EventItemProps) { - const { event } = props; - const { message } = event; - - return - - - - - {message} - - } - /> - ; -} - -export function EventItem(props: EventItemProps) { - const { event } = props; - const { type } = event; - - switch (type) { - case 'action': - case 'result': - return ; - case 'event': - return ; - case 'world': - return ; - default: - return - - ; - } -} - export interface AppProps { socketUrl: string; } -export interface Item { - name: string; - description: string; -} - -export interface Actor { - name: string; - description: string; - items: Array; -} - -export interface Room { - name: string; - description: string; - portals: Record; - actors: Array; - items: Array; -} - -export interface World { - name: string; - order: Array; - rooms: Array; - theme: string; -} - -export type SetDetails = (entity: Maybe) => void; - -export function ItemItem(props: { item: Item; setDetails: SetDetails }) { - const { item, setDetails } = props; - - return - setDetails(item)} /> - ; -} - -export function ActorItem(props: { actor: Actor; setDetails: SetDetails }) { - const { actor, setDetails } = props; - - return - setDetails(actor)} /> - - {actor.items.map((item) => )} - - ; -} - -export function RoomItem(props: { room: Room; setDetails: SetDetails }) { - const { room, setDetails } = props; - - return - setDetails(room)} /> - - {room.actors.map((actor) => )} - - - {room.items.map((item) => )} - - ; -} - -export function WorldPanel(props: { world: Maybe; setDetails: SetDetails }) { - const { world, setDetails } = props; - - // eslint-disable-next-line no-restricted-syntax - if (!doesExist(world)) { - return - No world data available - ; - } - - return - - World: {world.name} - - - Theme: {world.theme} - - - {world.rooms.map((room) => )} - - ; -} - export function EntityDetails(props: { entity: Maybe; close: () => void }) { // eslint-disable-next-line no-restricted-syntax if (!doesExist(props.entity)) { @@ -251,11 +72,28 @@ export function DetailDialog(props: { setDetails: SetDetails; details: Maybe(false); const [ detailEntity, setDetailEntity ] = useState>(undefined); + const [ character, setCharacter ] = useState>(undefined); + const [ clientId, setClientId ] = useState(''); const [ world, setWorld ] = useState>(undefined); const [ themeMode, setThemeMode ] = useState('light'); const [ history, setHistory ] = useState>([]); - const { lastMessage, readyState } = useWebSocket(props.socketUrl); + const { lastMessage, readyState, sendMessage } = useWebSocket(props.socketUrl); + + function setPlayer(actor: Maybe) { + setCharacter(actor); + + if (doesExist(actor)) { + sendMessage(JSON.stringify({ type: 'player', become: actor.name })); + } + } + + function sendInput(input: string) { + if (doesExist(character)) { + sendMessage(JSON.stringify({ type: 'input', input })); + } + } const theme = createTheme({ palette: { @@ -268,12 +106,37 @@ export function App(props: AppProps) { useEffect(() => { if (doesExist(lastMessage)) { const data = JSON.parse(lastMessage.data); + + if (data.type === 'id') { + setClientId(data.id); + return; + } + + if (data.type === 'prompt') { + if (data.id === clientId) { + // notify the player and show the prompt + setActiveTurn(true); + } else { + const message = `Waiting for ${data.character} to take their turn`; + setHistory((prev) => prev.concat(message)); + } + return; + } + setHistory((prev) => prev.concat(data)); // if we get a world event, update the last world state if (data.type === 'world') { setWorld(data.world); } + + if (data.type === 'player' && data.id === clientId) { + // find the actor that matches the player name + const { name } = data; + // eslint-disable-next-line no-restricted-syntax + const actor = world?.rooms.flatMap((room) => room.actors).find((a) => a.name === name); + setCharacter(actor); + } } }, [lastMessage]); @@ -298,7 +161,10 @@ export function App(props: AppProps) { - + + + + {interleave(items)} diff --git a/client/src/events.tsx b/client/src/events.tsx new file mode 100644 index 0000000..c13ed11 --- /dev/null +++ b/client/src/events.tsx @@ -0,0 +1,131 @@ +import { ListItem, ListItemText, ListItemAvatar, Avatar, Typography } from '@mui/material'; +import React from 'react'; + +import { formatters } from './format.js'; + +export interface EventItemProps { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + event: any; +} + +export function ActionItem(props: EventItemProps) { + const { event } = props; + const { actor, room, type } = event; + const content = formatters[type](event); + + return + + + + + + {actor} + + {content} + + } + /> + ; +} + +export function WorldItem(props: EventItemProps) { + const { event } = props; + const { step, world } = event; + const { theme } = world; + + return + + + + + Step {step} + + } + /> + ; +} + +export function MessageItem(props: EventItemProps) { + const { event } = props; + const { message } = event; + + return + + + + + {message} + + } + /> + ; +} + +export function PlayerItem(props: EventItemProps) { + const { event } = props; + const { name } = event; + + return + + + + + Someone is playing as {name} + + } + /> + ; +} + +export function EventItem(props: EventItemProps) { + const { event } = props; + const { type } = event; + + switch (type) { + case 'action': + case 'result': + return ; + case 'event': + return ; + case 'player': + return ; + case 'world': + return ; + default: + return + + ; + } +} diff --git a/client/src/format.ts b/client/src/format.ts index 9c5579f..b9927d3 100644 --- a/client/src/format.ts +++ b/client/src/format.ts @@ -13,8 +13,12 @@ export function formatAction(data: any) { } export function formatInput(data: any) { - const action = formatAction(JSON.parse(data.input)); - return `Starting turn: ${action}`; + try { + const action = formatAction(JSON.parse(data.input)); + return `Starting turn: ${action}`; + } catch (err) { + return `Error parsing input: ${err}`; + } } export function formatResult(data: any) { diff --git a/client/src/player.tsx b/client/src/player.tsx new file mode 100644 index 0000000..df3c80a --- /dev/null +++ b/client/src/player.tsx @@ -0,0 +1,40 @@ +import React, { useState } from 'react'; +import { Maybe } from '@apextoaster/js-utils'; +import { Alert, Button, Card, CardContent, Stack, TextField, Typography } from '@mui/material'; +import { Actor, SetDetails } from './world.js'; + +export interface PlayerPanelProps { + actor: Maybe; + activeTurn: boolean; + setDetails: SetDetails; + sendInput: (input: string) => void; +} + +export function PlayerPanel(props: PlayerPanelProps) { + const { actor, activeTurn, sendInput } = props; + const [input, setInput] = useState(''); + + // eslint-disable-next-line no-restricted-syntax + if (!actor) { + return + + No player character + + ; + } + + return + + {activeTurn && It's your turn!} + Playing as: {actor.name} + {actor.backstory} + + setInput(event.target.value)} /> + + + + ; +} diff --git a/client/src/world.tsx b/client/src/world.tsx new file mode 100644 index 0000000..8032100 --- /dev/null +++ b/client/src/world.tsx @@ -0,0 +1,117 @@ +import { SimpleTreeView } from '@mui/x-tree-view/SimpleTreeView'; +import { TreeItem } from '@mui/x-tree-view/TreeItem'; +import { Card, CardContent, CardHeader, Stack, Typography } from '@mui/material'; +import { Maybe, doesExist } from '@apextoaster/js-utils'; +import React from 'react'; + +export interface Item { + name: string; + description: string; +} + +export interface Actor { + name: string; + backstory: string; + description: string; + items: Array; +} + +export interface Room { + name: string; + description: string; + portals: Record; + actors: Array; + items: Array; +} + +export interface World { + name: string; + order: Array; + rooms: Array; + theme: string; +} + +export type SetDetails = (entity: Maybe) => void; +export type SetPlayer = (actor: Maybe) => void; + +export interface BaseEntityItemProps { + activeCharacter: Maybe; + setDetails: SetDetails; + setPlayer: SetPlayer; +} + +export function formatLabel(name: string, active = false): string { + if (active) { + return `${name} (!)`; + } + + return name; +} + +export function ItemItem(props: { item: Item } & BaseEntityItemProps) { + const { item, setDetails } = props; + + return + setDetails(item)} /> + ; +} + +export function ActorItem(props: { actor: Actor } & BaseEntityItemProps) { + const { actor, activeCharacter, setDetails, setPlayer } = props; + + const active = doesExist(activeCharacter) && actor === activeCharacter; + const label = formatLabel(actor.name, active); + + let playButton; + if (active === false) { + playButton = setPlayer(actor)} />; + } + + return + {playButton} + setDetails(actor)} /> + + {actor.items.map((item) => )} + + ; +} + +export function RoomItem(props: { room: Room } & BaseEntityItemProps) { + const { room, activeCharacter, setDetails, setPlayer } = props; + + const active = doesExist(activeCharacter) && room.actors.some((it) => it === activeCharacter); + const label = formatLabel(room.name, active); + + return + setDetails(room)} /> + + {room.actors.map((actor) => )} + + + {room.items.map((item) => )} + + ; +} + +export function WorldPanel(props: { world: Maybe } & BaseEntityItemProps) { + const { world, activeCharacter, setDetails, setPlayer } = props; + + // eslint-disable-next-line no-restricted-syntax + if (!doesExist(world)) { + return + No world data available + ; + } + + return + + {world.name} + + Theme: {world.theme} + + + {world.rooms.map((room) => )} + + + ; +}