diff --git a/adventure/discord_bot.py b/adventure/discord_bot.py index c158a4e..bbca266 100644 --- a/adventure/discord_bot.py +++ b/adventure/discord_bot.py @@ -339,7 +339,7 @@ def embed_from_result(event: ResultEvent): def embed_from_player(event: PlayerEvent): if event.status == "join": - title = "New Player" + title = "Player Joined" description = f"{event.client} is now playing as {event.character}" else: title = "Player Left" diff --git a/adventure/models/event.py b/adventure/models/event.py index 465ff99..957169c 100644 --- a/adventure/models/event.py +++ b/adventure/models/event.py @@ -70,10 +70,6 @@ class PromptEvent: room: Room actor: Actor - @staticmethod - def from_text(prompt: str, room: Room, actor: Actor) -> "PromptEvent": - return PromptEvent(prompt=prompt, room=room, actor=actor) - @dataclass class ReplyEvent: diff --git a/adventure/player.py b/adventure/player.py index e60fb08..1140022 100644 --- a/adventure/player.py +++ b/adventure/player.py @@ -8,6 +8,7 @@ from langchain_core.messages import AIMessage, BaseMessage, HumanMessage from packit.agent import Agent from packit.utils import could_be_json +from adventure.context import get_current_context from adventure.models.event import PromptEvent logger = getLogger(__name__) @@ -182,7 +183,10 @@ class RemotePlayer(BasePlayer): formatted_prompt = prompt.format(**kwargs) self.memory.append(HumanMessage(content=formatted_prompt)) - prompt_event = PromptEvent.from_text(formatted_prompt, None, None) + _, current_room, current_actor = get_current_context() + prompt_event = PromptEvent( + prompt=formatted_prompt, room=current_room, actor=current_actor + ) try: logger.info(f"prompting remote player: {self.name}") diff --git a/adventure/server.py b/adventure/server.py index b18af62..833ac73 100644 --- a/adventure/server.py +++ b/adventure/server.py @@ -3,7 +3,7 @@ from collections import deque from json import dumps, loads from logging import getLogger from threading import Thread -from typing import Dict, Literal +from typing import Any, Dict, Literal from uuid import uuid4 import websockets @@ -157,6 +157,8 @@ async def handler(websocket): break connected.remove(websocket) + if id in player_names: + del player_names[id] # swap out the character for the original agent when they disconnect player = get_player(id) @@ -173,11 +175,10 @@ async def handler(websocket): logger.info("Restoring LLM agent for %s", player.name) set_actor_agent(player.name, actor, player.fallback_agent) - logger.info("Client disconnected: %s", player_name) + logger.info("Client disconnected: %s", id) socket_thread = None -static_thread = None def server_json(obj): @@ -195,7 +196,7 @@ def send_and_append(message): def launch_server(): - global socket_thread, static_thread + global socket_thread def run_sockets(): asyncio.run(server_main()) @@ -222,7 +223,7 @@ def server_system(world: World, step: int): def server_event(event: GameEvent): - json_event = RootModel[event.__class__](event).model_dump() + json_event: Dict[str, Any] = RootModel[event.__class__](event).model_dump() json_event["type"] = event.type send_and_append(json_event) diff --git a/client/package.json b/client/package.json index 0353488..4600985 100644 --- a/client/package.json +++ b/client/package.json @@ -27,7 +27,8 @@ "react-i18next": "^12.2.0", "react-use": "^17.4.3", "react-use-websocket": "^4.8.1", - "tslib": "^2.6.2" + "tslib": "^2.6.2", + "zustand": "^4.5.2" }, "devDependencies": { "@mochajs/multi-reporter": "^1.1.0", diff --git a/client/src/app.tsx b/client/src/app.tsx index c3b4ca9..e4b96ca 100644 --- a/client/src/app.tsx +++ b/client/src/app.tsx @@ -1,223 +1,185 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ -import React, { useState, useEffect, Fragment } from 'react'; -import useWebSocketModule, { ReadyState } from 'react-use-websocket'; import { Maybe, doesExist } from '@apextoaster/js-utils'; import { Button, + Container, CssBaseline, Dialog, + DialogActions, DialogContent, DialogTitle, - PaletteMode, - ThemeProvider, - createTheme, - List, - Divider, - Typography, - Container, Stack, - Alert, - Switch, - FormGroup, - FormControlLabel, - TextField, - IconButton, + ThemeProvider, + Typography, + createTheme, } from '@mui/material'; import { Allotment } from 'allotment'; +import React, { Fragment, useEffect } from 'react'; +import useWebSocketModule from 'react-use-websocket'; +import { useStore } from 'zustand'; -import { Room, Actor, Item, World, WorldPanel, SetDetails } from './world.js'; -import { EventItem } from './events.js'; +import { HistoryPanel } from './history.js'; +import { Actor, Item, Room } from './models.js'; import { PlayerPanel } from './player.js'; +import { store, StoreState } from './store.js'; +import { WorldPanel } from './world.js'; +import { Statusbar } from './status.js'; import 'allotment/dist/style.css'; import './main.css'; -import { HistoryPanel } from './history.js'; -import { set } from 'lodash'; -import { CheckBox, Save } from '@mui/icons-material'; const useWebSocket = (useWebSocketModule as any).default; -const statusStrings = { - [ReadyState.CONNECTING]: 'Connecting', - [ReadyState.OPEN]: 'Running', - [ReadyState.CLOSING]: 'Closing', - [ReadyState.CLOSED]: 'Closed', - [ReadyState.UNINSTANTIATED]: 'Unready', -}; - export interface AppProps { socketUrl: string; } -export function EntityDetails(props: { entity: Maybe; close: () => void }) { +export interface EntityDetailsProps { + entity: Maybe; + close: () => void; +} + +export function EntityDetails(props: EntityDetailsProps) { + const { entity, close } = props; + // eslint-disable-next-line no-restricted-syntax - if (!doesExist(props.entity)) { + if (!doesExist(entity)) { return ; } return - {props.entity.name} - + {entity.name} + - {props.entity.description} + {entity.description} - + + + ; } -export function DetailDialog(props: { setDetails: SetDetails; details: Maybe }) { - const { details, setDetails } = props; +export function detailStateSelector(s: StoreState) { + return { + detailEntity: s.detailEntity, + clearDetailEntity: s.clearDetailEntity, + }; +} + +export function DetailDialog() { + const state = useStore(store, detailStateSelector); + const { detailEntity, clearDetailEntity } = state; return setDetails(undefined)} + open={doesExist(detailEntity)} + onClose={clearDetailEntity} > - setDetails(undefined)} /> + ; } +export function appStateSelector(s: StoreState) { + return { + themeMode: s.themeMode, + setReadyState: s.setReadyState, + }; +} + export function App(props: AppProps) { - // client state - slice 1 - const [ activeTurn, setActiveTurn ] = useState(false); - const [ autoScroll, setAutoScroll ] = useState(true); - const [ themeMode, setThemeMode ] = useState('light'); - - // client identity - slice 2 - const [ clientId, setClientId ] = useState(''); - const [ clientName, setClientName ] = useState(''); - - // world state - slice 3 - const [ detailEntity, setDetailEntity ] = useState>(undefined); - const [ character, setCharacter ] = useState>(undefined); - const [ world, setWorld ] = useState>(undefined); - const [ players, setPlayers ] = useState>({}); + const state = useStore(store, appStateSelector); + const { themeMode, setReadyState } = state; // socket stuff - const [ history, setHistory ] = useState>([]); const { lastMessage, readyState, sendMessage } = useWebSocket(props.socketUrl); function setPlayer(actor: Maybe) { - // do not setCharacter until the server confirms the player change + // do not call setCharacter until the server confirms the player change if (doesExist(actor)) { sendMessage(JSON.stringify({ type: 'player', become: actor.name })); } } function sendInput(input: string) { + const { character, setActiveTurn } = store.getState(); if (doesExist(character)) { sendMessage(JSON.stringify({ type: 'input', input })); setActiveTurn(false); } } - function sendName(name: string) { - sendMessage(JSON.stringify({ type: 'player', name: clientName })); + function setName(name: string) { + const { setClientName } = store.getState(); + sendMessage(JSON.stringify({ type: 'player', name })); setClientName(name); } const theme = createTheme({ palette: { - mode: themeMode as PaletteMode, + mode: themeMode, }, }); - const connectionStatus = statusStrings[readyState as ReadyState]; - useEffect(() => { + const { setClientId, setActiveTurn, setPlayers, appendEvent, setWorld, world, clientId, setCharacter } = store.getState(); if (doesExist(lastMessage)) { - const data = JSON.parse(lastMessage.data); + const event = JSON.parse(lastMessage.data); - if (data.type === 'id') { - // unicast the client id to the player - setClientId(data.id); - return; + // handle special events + switch (event.type) { + case 'id': + // unicast the client id to the player, do not append to history + setClientId(event.id); + return; + case 'prompt': + // prompts are broadcast to all players + if (event.client === clientId) { + // only notify the active player + setActiveTurn(true); + break; + } else { + setActiveTurn(false); + return; + } + case 'player': + if (event.status === 'join' && doesExist(world) && event.client === clientId) { + const { character: characterName } = event; + const actor = world.rooms.flatMap((room) => room.actors).find((a) => a.name === characterName); + setCharacter(actor); + } + break; + case 'players': + setPlayers(event.players); + return; + case 'snapshot': + setWorld(event.world); + break; + default: + // this is not concerning, other events are kept in history and displayed } - if (data.type === 'prompt') { - // prompts are broadcast to all players - if (data.client === clientId) { - // only notify the active player - setActiveTurn(true); - } else { - const message = `Waiting for ${data.character} to take their turn`; - setHistory((prev) => prev.concat(message)); - } - return; - } - - if (data.type === 'players') { - setPlayers(data.players); - return; - } - - setHistory((prev) => prev.concat(data)); - - // if we get a world event, update the last world state - if (data.type === 'snapshot') { - setWorld(data.world); - } - - if (doesExist(world) && data.type === 'player' && data.id === clientId && data.event === 'join') { - // find the actor that matches the player name - const { character: characterName } = data; - const actor = world.rooms.flatMap((room) => room.actors).find((a) => a.name === characterName); - setCharacter(actor); - } + appendEvent(event); } }, [lastMessage]); + useEffect(() => { + setReadyState(readyState); + }, [readyState]); return - + - - - - Status: {connectionStatus} - - - setThemeMode(themeMode === 'dark' ? 'light' : 'dark')} - inputProps={{ 'aria-label': 'controlled' }} - sx={{ marginLeft: 'auto' }} - />} label="Dark Mode" /> - setAutoScroll(autoScroll === false)} - inputProps={{ 'aria-label': 'controlled' }} - sx={{ marginLeft: 'auto' }} - />} label="Auto Scroll" /> - - - setClientName(e.target.value)} - onKeyDown={(e) => { - if (e.key === 'Enter') { - sendName(clientName); - } - }} - sx={{ marginLeft: 'auto' }} - /> - sendName(clientName)}> - - - - - + - - + + - + diff --git a/client/src/events.tsx b/client/src/events.tsx index 3099f7f..a8295b7 100644 --- a/client/src/events.tsx +++ b/client/src/events.tsx @@ -94,7 +94,7 @@ export function PlayerEventItem(props: EventItemProps) { let primary = ''; let secondary = ''; if (status === 'join') { - primary = 'New Player'; + primary = 'Player Joined'; secondary = `${client} is now playing as ${character}`; } if (status === 'leave') { diff --git a/client/src/history.tsx b/client/src/history.tsx index 189aa58..c73a32e 100644 --- a/client/src/history.tsx +++ b/client/src/history.tsx @@ -1,23 +1,30 @@ +import { Maybe } from '@apextoaster/js-utils'; import { Divider, List } from '@mui/material'; import React, { useEffect, useRef } from 'react'; -import { Maybe } from '@apextoaster/js-utils'; +import { useStore } from 'zustand'; import { EventItem } from './events'; +import { store, StoreState } from './store'; -export interface HistoryPanelProps { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - history: Array; - scroll: 'auto' | 'instant' | 'smooth' | false; +export function historyStateSelector(s: StoreState) { + return { + history: s.eventHistory, + scroll: s.autoScroll, + }; } -export function HistoryPanel(props: HistoryPanelProps) { - const { history, scroll } = props; +export function HistoryPanel() { + const state = useStore(store, historyStateSelector); + const { history, scroll } = state; + const scrollRef = useRef>(undefined); + const scrollBehavior = state.scroll ? 'smooth' : 'auto'; + useEffect(() => { if (scrollRef.current && scroll !== false) { - scrollRef.current.scrollIntoView({ behavior: scroll as ScrollBehavior, block: 'end' }); + scrollRef.current.scrollIntoView({ behavior: scrollBehavior, block: 'end' }); } - }, [scrollRef.current, props.scroll]); + }, [scrollRef.current, scrollBehavior]); const items = history.map((item, index) => { if (index === history.length - 1) { diff --git a/client/src/main.tsx b/client/src/main.tsx index f4d0edc..bacffba 100644 --- a/client/src/main.tsx +++ b/client/src/main.tsx @@ -1,6 +1,6 @@ import { doesExist } from '@apextoaster/js-utils'; import { createRoot } from 'react-dom/client'; -import React from 'react'; +import React, { StrictMode } from 'react'; import { App } from './app.js'; @@ -14,5 +14,5 @@ window.addEventListener('DOMContentLoaded', () => { const hostname = window.location.hostname; const root = createRoot(history); - root.render(); + root.render(); }); diff --git a/client/src/models.ts b/client/src/models.ts new file mode 100644 index 0000000..59c28f7 --- /dev/null +++ b/client/src/models.ts @@ -0,0 +1,31 @@ +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; +} + +// TODO: copy event types from server +export interface GameEvent { + type: string; +} diff --git a/client/src/player.tsx b/client/src/player.tsx index 4139697..b6c0242 100644 --- a/client/src/player.tsx +++ b/client/src/player.tsx @@ -1,54 +1,60 @@ -import React, { useState } from 'react'; -import { Maybe } from '@apextoaster/js-utils'; +import { doesExist } from '@apextoaster/js-utils'; import { Alert, Button, Card, CardContent, Stack, TextField, Typography } from '@mui/material'; -import { Actor, SetDetails } from './world.js'; +import React, { useState } from 'react'; +import { useStore } from 'zustand'; +import { store, StoreState } from './store'; export interface PlayerPanelProps { - actor: Maybe; - activeTurn: boolean; - setDetails: SetDetails; sendInput: (input: string) => void; } +export function playerStateSelector(s: StoreState) { + return { + character: s.character, + activeTurn: s.activeTurn, + }; +} + export function PlayerPanel(props: PlayerPanelProps) { - const { actor, activeTurn, sendInput } = props; + const state = useStore(store, playerStateSelector); + const { character, activeTurn } = state; + const { sendInput } = props; const [input, setInput] = useState(''); - // eslint-disable-next-line no-restricted-syntax - if (!actor) { + if (doesExist(character)) { return - No player character + + {activeTurn && It's your turn!} + Playing as: {character.name} + {character.backstory} + + setInput(event.target.value)} + onKeyDown={(event) => { + if (event.key === 'Enter') { + sendInput(input); + setInput(''); + } + }} + /> + + + ; } return - - {activeTurn && It's your turn!} - Playing as: {actor.name} - {actor.backstory} - - setInput(event.target.value)} - onKeyDown={(event) => { - if (event.key === 'Enter') { - sendInput(input); - setInput(''); - } - }} - /> - - - + No player character ; } diff --git a/client/src/status.tsx b/client/src/status.tsx new file mode 100644 index 0000000..f61660c --- /dev/null +++ b/client/src/status.tsx @@ -0,0 +1,103 @@ +import { Edit, Save } from '@mui/icons-material'; +import { Alert, AlertColor, FormControlLabel, FormGroup, IconButton, Stack, Switch, TextField, Typography } from '@mui/material'; +import React from 'react'; +import { ReadyState } from 'react-use-websocket'; +import { useStore } from 'zustand'; +import { StoreState, store } from './store.js'; + +const statusStrings: Record = { + [ReadyState.CONNECTING]: 'Connecting', + [ReadyState.OPEN]: 'Running', + [ReadyState.CLOSING]: 'Closing', + [ReadyState.CLOSED]: 'Closed', + [ReadyState.UNINSTANTIATED]: 'Unready', +}; + +const statusColors: Record = { + [ReadyState.CONNECTING]: 'info', + [ReadyState.OPEN]: 'success', + [ReadyState.CLOSING]: 'warning', + [ReadyState.CLOSED]: 'warning', + [ReadyState.UNINSTANTIATED]: 'warning', +}; + +function download(filename: string, text: string) { + const element = document.createElement('a'); + element.setAttribute('href', 'data:text/plain;charset=utf-8,' + encodeURIComponent(text)); + element.setAttribute('download', filename); + + element.style.display = 'none'; + document.body.appendChild(element); + + element.click(); + + document.body.removeChild(element); +} + +export function statusbarStateSelector(s: StoreState) { + return { + autoScroll: s.autoScroll, + clientName: s.clientName, + readyState: s.readyState, + themeMode: s.themeMode, + setAutoScroll: s.setAutoScroll, + setClientName: s.setClientName, + setThemeMode: s.setThemeMode, + eventHistory: s.eventHistory, + }; +} + +export interface StatusbarProps { + setName: (name: string) => void; +} + +export function Statusbar(props: StatusbarProps) { + const { setName } = props; + const state = useStore(store, statusbarStateSelector); + const { autoScroll, clientName, readyState, themeMode, setAutoScroll, setClientName, setThemeMode, eventHistory } = state; + + const connectionStatus = statusStrings[readyState as ReadyState]; + + return + + + Status: {connectionStatus} + + + setThemeMode(themeMode === 'dark' ? 'light' : 'dark')} + inputProps={{ 'aria-label': 'controlled' }} + sx={{ marginLeft: 'auto' }} + />} label="Dark Mode" /> + setAutoScroll(autoScroll === false)} + inputProps={{ 'aria-label': 'controlled' }} + sx={{ marginLeft: 'auto' }} + />} label="Auto Scroll" /> + + + setClientName(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Enter') { + setName(clientName); + } + }} + sx={{ marginLeft: 'auto' }} + /> + setName(clientName)}> + + + + + download('events.json', JSON.stringify(eventHistory, undefined, 2))}> + + } label="Download History" /> + + + ; +} diff --git a/client/src/store.ts b/client/src/store.ts new file mode 100644 index 0000000..c35edb8 --- /dev/null +++ b/client/src/store.ts @@ -0,0 +1,128 @@ +import { createContext } from 'react'; +import { createStore, StateCreator } from 'zustand'; + +import { doesExist, Maybe } from '@apextoaster/js-utils'; +import { PaletteMode } from '@mui/material'; +import { ReadyState } from 'react-use-websocket'; +import { Actor, GameEvent, Item, Room, World } from './models'; + +export interface ClientState { + autoScroll: boolean; + clientId: string; + clientName: string; + detailEntity: Maybe; + eventHistory: Array; + readyState: ReadyState; + themeMode: PaletteMode; + + // setters + setAutoScroll: (autoScroll: boolean) => void; + setClientId: (clientId: string) => void; + setClientName: (name: string) => void; + setDetailEntity: (entity: Maybe) => void; + setReadyState: (state: ReadyState) => void; + setThemeMode: (mode: PaletteMode) => void; + + // misc helpers + appendEvent: (event: GameEvent) => void; + clearDetailEntity: () => void; + clearEventHistory: () => void; +} + +export interface WorldState { + players: Record; + world: Maybe; + + // setters + setPlayers: (players: Record) => void; + setWorld: (world: Maybe) => void; +} + +export interface PlayerState { + activeTurn: boolean; + character: Maybe; + + // setters + setActiveTurn: (activeTurn: boolean) => void; + setCharacter: (character: Maybe) => void; + + // misc helpers + isPlaying: () => boolean; +} + +export type StoreState = ClientState & WorldState & PlayerState; + +export function createClientStore(): StateCreator { + return (set) => ({ + autoScroll: true, + clientId: '', + clientName: '', + detailEntity: undefined, + eventHistory: [], + readyState: ReadyState.UNINSTANTIATED, + themeMode: 'light', + setAutoScroll(autoScroll) { + set({ autoScroll }); + }, + setClientId(clientId) { + set({ clientId }); + }, + setClientName(clientName) { + set({ clientName }); + }, + setDetailEntity(detailEntity) { + set({ detailEntity }); + }, + setReadyState(state) { + set({ readyState: state }); + }, + setThemeMode(themeMode) { + set({ themeMode }); + }, + appendEvent(event) { + set((state) => { + const history = state.eventHistory.concat(event); + return { eventHistory: history }; + }); + }, + clearDetailEntity() { + set({ detailEntity: undefined }); + }, + clearEventHistory() { + set({ eventHistory: [] }); + }, + }); +} + +export function createWorldStore(): StateCreator { + return (set) => ({ + players: {}, + world: undefined, + setPlayers: (players) => set({ players }), + setWorld: (world) => set({ world }), + }); +} + +export function createPlayerStore(): StateCreator { + return (set) => ({ + activeTurn: false, + character: undefined, + setActiveTurn: (activeTurn: boolean) => set({ activeTurn }), + setCharacter: (character: Maybe) => set({ character }), + isPlaying() { + return doesExist(this.character); + }, + }); +} + +export function createStateStore() { + return createStore((...args) => ({ + ...createClientStore()(...args), + ...createWorldStore()(...args), + ...createPlayerStore()(...args), + })); +} + +// TODO: make this not global +export const store = createStateStore(); +export const storeContext = createContext(store); diff --git a/client/src/world.tsx b/client/src/world.tsx index 4d7cf02..02b2331 100644 --- a/client/src/world.tsx +++ b/client/src/world.tsx @@ -1,42 +1,17 @@ +import { Maybe, doesExist } from '@apextoaster/js-utils'; +import { Card, CardContent, Typography } from '@mui/material'; 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; -} +import { useStore } from 'zustand'; +import { StoreState, store } from './store'; +import { Actor, Item, Room, World } from './models'; export type SetDetails = (entity: Maybe) => void; export type SetPlayer = (actor: Maybe) => void; export interface BaseEntityItemProps { - activeCharacter: Maybe; - setDetails: SetDetails; setPlayer: SetPlayer; } @@ -48,18 +23,36 @@ export function formatLabel(name: string, active = false): string { return name; } +export function itemStateSelector(s: StoreState) { + return { + character: s.character, + setDetailEntity: s.setDetailEntity, + }; +} + +export function worldStateSelector(s: StoreState) { + return { + world: s.world, + }; +} + export function ItemItem(props: { item: Item } & BaseEntityItemProps) { - const { item, setDetails } = props; + const { item } = props; + const state = useStore(store, itemStateSelector); + const { setDetailEntity } = state; return - setDetails(item)} /> + setDetailEntity(item)} /> ; } export function ActorItem(props: { actor: Actor } & BaseEntityItemProps) { - const { actor, activeCharacter, setDetails, setPlayer } = props; + const { actor, setPlayer } = props; + const state = useStore(store, itemStateSelector); + const { character, setDetailEntity } = state; - const active = doesExist(activeCharacter) && actor.name === activeCharacter.name; + // TODO: include other players + const active = doesExist(character) && actor.name === character.name; const label = formatLabel(actor.name, active); let playButton; @@ -69,32 +62,36 @@ export function ActorItem(props: { actor: Actor } & BaseEntityItemProps) { return {playButton} - setDetails(actor)} /> + setDetailEntity(actor)} /> - {actor.items.map((item) => )} + {actor.items.map((item) => )} ; } export function RoomItem(props: { room: Room } & BaseEntityItemProps) { - const { room, activeCharacter, setDetails, setPlayer } = props; + const { room, setPlayer } = props; + const state = useStore(store, itemStateSelector); + const { character, setDetailEntity } = state; - const active = doesExist(activeCharacter) && room.actors.some((it) => it.name === activeCharacter.name); + const active = doesExist(character) && room.actors.some((it) => it.name === character.name); const label = formatLabel(room.name, active); return - setDetails(room)} /> + setDetailEntity(room)} /> - {room.actors.map((actor) => )} + {room.actors.map((actor) => )} - {room.items.map((item) => )} + {room.items.map((item) => )} ; } -export function WorldPanel(props: { world: Maybe } & BaseEntityItemProps) { - const { world, activeCharacter, setDetails, setPlayer } = props; +export function WorldPanel(props: BaseEntityItemProps) { + const { setPlayer } = props; + const state = useStore(store, worldStateSelector); + const { world } = state; // eslint-disable-next-line no-restricted-syntax if (!doesExist(world)) { @@ -114,7 +111,7 @@ export function WorldPanel(props: { world: Maybe } & BaseEntityItemProps) Theme: {world.theme} - {world.rooms.map((room) => )} + {world.rooms.map((room) => )} ; diff --git a/client/yarn.lock b/client/yarn.lock index d0b31c4..588a59f 100644 --- a/client/yarn.lock +++ b/client/yarn.lock @@ -3444,6 +3444,11 @@ use-resize-observer@^9.0.0: dependencies: "@juggle/resize-observer" "^3.3.1" +use-sync-external-store@1.2.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/use-sync-external-store/-/use-sync-external-store-1.2.0.tgz#7dbefd6ef3fe4e767a0cf5d7287aacfb5846928a" + integrity sha512-eEgnFxGQ1Ife9bzYs6VLi8/4X6CObHMw9Qr9tPY43iKwsPw8xE8+EFsf/2cFZ5S3esXgpWgtSCtLNS41F+sKPA== + v8-to-istanbul@^9.0.0: version "9.2.0" resolved "https://registry.yarnpkg.com/v8-to-istanbul/-/v8-to-istanbul-9.2.0.tgz#2ed7644a245cddd83d4e087b9b33b3e62dfd10ad" @@ -3568,3 +3573,10 @@ yocto-queue@^0.1.0: version "0.1.0" resolved "https://registry.yarnpkg.com/yocto-queue/-/yocto-queue-0.1.0.tgz#0294eb3dee05028d31ee1a5fa2c556a6aaf10a1b" integrity sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q== + +zustand@^4.5.2: + version "4.5.2" + resolved "https://registry.yarnpkg.com/zustand/-/zustand-4.5.2.tgz#fddbe7cac1e71d45413b3682cdb47b48034c3848" + integrity sha512-2cN1tPkDVkwCy5ickKrI7vijSjPksFRfqS6237NzT0vqSsztTNnQdHw9mmN7uBdk3gceVXU0a+21jFzFzAc9+g== + dependencies: + use-sync-external-store "1.2.0" diff --git a/docs/events.md b/docs/events.md index 9ce3e1b..74ca5af 100644 --- a/docs/events.md +++ b/docs/events.md @@ -16,6 +16,7 @@ - [Reply Events](#reply-events) - [Result Events](#result-events) - [Status Events](#status-events) + - [Snapshot Events](#snapshot-events) - [Server-specific Events](#server-specific-events) - [Websocket Server Events](#websocket-server-events) - [Websocket New Client](#websocket-new-client) @@ -38,7 +39,7 @@ Player events use the following schema: ```yaml -event: "player" +type: "player" status: string character: string client: string @@ -49,7 +50,7 @@ client: string Player join events have a `status` of `join`: ```yaml -event: "player" +type: "player" status: "join" character: string client: string @@ -60,7 +61,7 @@ client: string Player leave events have a `status` of `leave`: ```yaml -event: "player" +type: "player" status: "leave" character: string client: string @@ -74,8 +75,8 @@ Generate events are sent every time an entity's name is generated and again when added to the world. ```yaml -event: "generate" -name: str +type: "generate" +name: string entity: Room | Actor | Item | None ``` @@ -87,14 +88,80 @@ more frequent progress updates when generating with slow models. ### Action Events +The action event is fired after player or actor input has been processed and any JSON function calls have been parsed. + +```yaml +type: "action" +action: string +parameters: dict +room: Room +actor: Actor +item: Item | None +``` + ### Prompt Events +The prompt event is fired when a character's turn starts and their input is needed for the next action. + +```yaml +type: "prompt" +prompt: string +room: Room +actor: Actor +``` + ### Reply Events +The reply event is fired when a character has been asked a question or told a message and replies. + +```yaml +type: "reply" +text: string +room: Room +actor: Actor +``` + ### Result Events +The result event is fired after a character has taken an action and contains the results of that action. + +```yaml +type: "result" +result: string +room: Room +actor: Actor +``` + +The result is related to the most recent action for the same actor, although not every action will have a result - they +may have a reply or error instead. + ### Status Events +The status event is fired for general events in the world and messages about other characters. + +```yaml +type: "status" +text: string +room: Room | None +actor: Actor | None +``` + +### Snapshot Events + +The snapshot event is fired at the end of each turn and contains a complete snapshot of the world. + +```yaml +type: "snapshot" +world: Dict +memory: Dict +step: int +``` + +This is primarily used to save the world state, but can also be used to sync clients and populate the world menu. + +The `world` and `memory` fields within the snapshot event have already been serialized to JSON-compatible dictionaries, +because they may contain complex classes and implementation details of the underlying LLM. + ## Server-specific Events ### Websocket Server Events