1
0
Fork 0

set up zustand state, optimize client somewhat

This commit is contained in:
Sean Sube 2024-05-11 17:38:07 -05:00
parent 416b3cc5d2
commit 7010da8ed2
Signed by: ssube
GPG Key ID: 3EED7B957D362AF1
16 changed files with 558 additions and 243 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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<Item | Actor | Room>; close: () => void }) {
export interface EntityDetailsProps {
entity: Maybe<Item | Actor | Room>;
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 <Fragment />;
}
return <Fragment>
<DialogTitle>{props.entity.name}</DialogTitle>
<DialogContent>
<DialogTitle>{entity.name}</DialogTitle>
<DialogContent dividers>
<Typography>
{props.entity.description}
{entity.description}
</Typography>
<Button onClick={() => props.close()}>Close</Button>
</DialogContent>
<DialogActions>
<Button onClick={close}>Close</Button>
</DialogActions>
</Fragment>;
}
export function DetailDialog(props: { setDetails: SetDetails; details: Maybe<Item | Actor | Room> }) {
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 <Dialog
open={doesExist(details)}
onClose={() => setDetails(undefined)}
open={doesExist(detailEntity)}
onClose={clearDetailEntity}
>
<EntityDetails entity={details} close={() => setDetails(undefined)} />
<EntityDetails entity={detailEntity} close={clearDetailEntity} />
</Dialog>;
}
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<boolean>(false);
const [ autoScroll, setAutoScroll ] = useState<boolean>(true);
const [ themeMode, setThemeMode ] = useState('light');
// client identity - slice 2
const [ clientId, setClientId ] = useState<string>('');
const [ clientName, setClientName ] = useState<string>('');
// world state - slice 3
const [ detailEntity, setDetailEntity ] = useState<Maybe<Item | Actor | Room>>(undefined);
const [ character, setCharacter ] = useState<Maybe<Actor>>(undefined);
const [ world, setWorld ] = useState<Maybe<World>>(undefined);
const [ players, setPlayers ] = useState<Record<string, string>>({});
const state = useStore(store, appStateSelector);
const { themeMode, setReadyState } = state;
// socket stuff
const [ history, setHistory ] = useState<Array<string>>([]);
const { lastMessage, readyState, sendMessage } = useWebSocket(props.socketUrl);
function setPlayer(actor: Maybe<Actor>) {
// 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 <ThemeProvider theme={theme}>
<CssBaseline />
<DetailDialog details={detailEntity} setDetails={setDetailEntity} />
<DetailDialog />
<Container maxWidth='xl'>
<Stack direction="column">
<Alert icon={false} severity="success">
<Stack direction="row" alignItems="center" gap={4}>
<Typography>
Status: {connectionStatus}
</Typography>
<FormGroup row>
<FormControlLabel control={<Switch
checked={themeMode === 'dark'}
onChange={() => setThemeMode(themeMode === 'dark' ? 'light' : 'dark')}
inputProps={{ 'aria-label': 'controlled' }}
sx={{ marginLeft: 'auto' }}
/>} label="Dark Mode" />
<FormControlLabel control={<Switch
checked={autoScroll}
onChange={() => setAutoScroll(autoScroll === false)}
inputProps={{ 'aria-label': 'controlled' }}
sx={{ marginLeft: 'auto' }}
/>} label="Auto Scroll" />
</FormGroup>
<FormGroup row>
<TextField
label="Player Name"
value={clientName}
onChange={(e) => setClientName(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
sendName(clientName);
}
}}
sx={{ marginLeft: 'auto' }}
/>
<IconButton onClick={() => sendName(clientName)}>
<Save />
</IconButton>
</FormGroup>
</Stack>
</Alert>
<Statusbar setName={setName} />
<Stack direction="row" spacing={2}>
<Allotment className='body-allotment'>
<Stack direction="column" spacing={2} sx={{ minWidth: 400 }} className="scroll-history">
<PlayerPanel actor={character} activeTurn={activeTurn} setDetails={setDetailEntity} sendInput={sendInput} />
<WorldPanel world={world} activeCharacter={character} setDetails={setDetailEntity} setPlayer={setPlayer} />
<PlayerPanel sendInput={sendInput} />
<WorldPanel setPlayer={setPlayer} />
</Stack>
<Stack direction="column" sx={{ minWidth: 600 }} className="scroll-history">
<HistoryPanel history={history} scroll={autoScroll ? 'instant' : false} />
<HistoryPanel />
</Stack>
</Allotment>
</Stack>

View File

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

View File

@ -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<any>;
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<Maybe<Element>>(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) {

View File

@ -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(<App socketUrl={`ws://${hostname}:8001/`} />);
root.render(<StrictMode><App socketUrl={`ws://${hostname}:8001/`} /></StrictMode>);
});

31
client/src/models.ts Normal file
View File

@ -0,0 +1,31 @@
export interface Item {
name: string;
description: string;
}
export interface Actor {
name: string;
backstory: string;
description: string;
items: Array<Item>;
}
export interface Room {
name: string;
description: string;
portals: Record<string, string>;
actors: Array<Actor>;
items: Array<Item>;
}
export interface World {
name: string;
order: Array<string>;
rooms: Array<Room>;
theme: string;
}
// TODO: copy event types from server
export interface GameEvent {
type: string;
}

View File

@ -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<Actor>;
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<string>('');
// eslint-disable-next-line no-restricted-syntax
if (!actor) {
if (doesExist(character)) {
return <Card style={{ minHeight: '6vh', overflow: 'auto' }}>
<CardContent>
<Typography variant="h6">No player character</Typography>
<Stack direction="column" spacing={2}>
{activeTurn && <Alert severity="warning">It's your turn!</Alert>}
<Typography variant="h6">Playing as: {character.name}</Typography>
<Typography variant="body1">{character.backstory}</Typography>
<Stack direction="row" spacing={2}>
<TextField
fullWidth
label="Input"
variant="outlined"
value={input}
onChange={(event) => setInput(event.target.value)}
onKeyDown={(event) => {
if (event.key === 'Enter') {
sendInput(input);
setInput('');
}
}}
/>
<Button variant="contained" onClick={() => {
sendInput(input);
setInput('');
}}>Send</Button>
</Stack>
</Stack>
</CardContent>
</Card>;
}
return <Card style={{ minHeight: '6vh', overflow: 'auto' }}>
<CardContent>
<Stack direction="column" spacing={2}>
{activeTurn && <Alert severity="warning">It's your turn!</Alert>}
<Typography variant="h6">Playing as: {actor.name}</Typography>
<Typography variant="body1">{actor.backstory}</Typography>
<Stack direction="row" spacing={2}>
<TextField
fullWidth
label="Input"
variant="outlined"
value={input}
onChange={(event) => setInput(event.target.value)}
onKeyDown={(event) => {
if (event.key === 'Enter') {
sendInput(input);
setInput('');
}
}}
/>
<Button variant="contained" onClick={() => {
sendInput(input);
setInput('');
}}>Send</Button>
</Stack>
</Stack>
<Typography variant="h6">No player character</Typography>
</CardContent>
</Card>;
}

103
client/src/status.tsx Normal file
View File

@ -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, string> = {
[ReadyState.CONNECTING]: 'Connecting',
[ReadyState.OPEN]: 'Running',
[ReadyState.CLOSING]: 'Closing',
[ReadyState.CLOSED]: 'Closed',
[ReadyState.UNINSTANTIATED]: 'Unready',
};
const statusColors: Record<ReadyState, AlertColor> = {
[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 <Alert icon={false} severity={statusColors[readyState as ReadyState]}>
<Stack direction="row" alignItems="center" gap={4}>
<Typography>
Status: {connectionStatus}
</Typography>
<FormGroup row>
<FormControlLabel control={<Switch
checked={themeMode === 'dark'}
onChange={() => setThemeMode(themeMode === 'dark' ? 'light' : 'dark')}
inputProps={{ 'aria-label': 'controlled' }}
sx={{ marginLeft: 'auto' }}
/>} label="Dark Mode" />
<FormControlLabel control={<Switch
checked={autoScroll}
onChange={() => setAutoScroll(autoScroll === false)}
inputProps={{ 'aria-label': 'controlled' }}
sx={{ marginLeft: 'auto' }}
/>} label="Auto Scroll" />
</FormGroup>
<FormGroup row>
<TextField
label="Player Name"
value={clientName}
onChange={(e) => setClientName(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
setName(clientName);
}
}}
sx={{ marginLeft: 'auto' }}
/>
<IconButton onClick={() => setName(clientName)}>
<Edit />
</IconButton>
</FormGroup>
<FormGroup row>
<FormControlLabel control={<IconButton onClick={() => download('events.json', JSON.stringify(eventHistory, undefined, 2))}>
<Save />
</IconButton>} label="Download History" />
</FormGroup>
</Stack>
</Alert>;
}

128
client/src/store.ts Normal file
View File

@ -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<Item | Actor | Room>;
eventHistory: Array<GameEvent>;
readyState: ReadyState;
themeMode: PaletteMode;
// setters
setAutoScroll: (autoScroll: boolean) => void;
setClientId: (clientId: string) => void;
setClientName: (name: string) => void;
setDetailEntity: (entity: Maybe<Item | Actor | Room>) => void;
setReadyState: (state: ReadyState) => void;
setThemeMode: (mode: PaletteMode) => void;
// misc helpers
appendEvent: (event: GameEvent) => void;
clearDetailEntity: () => void;
clearEventHistory: () => void;
}
export interface WorldState {
players: Record<string, string>;
world: Maybe<World>;
// setters
setPlayers: (players: Record<string, string>) => void;
setWorld: (world: Maybe<World>) => void;
}
export interface PlayerState {
activeTurn: boolean;
character: Maybe<Actor>;
// setters
setActiveTurn: (activeTurn: boolean) => void;
setCharacter: (character: Maybe<Actor>) => void;
// misc helpers
isPlaying: () => boolean;
}
export type StoreState = ClientState & WorldState & PlayerState;
export function createClientStore(): StateCreator<ClientState> {
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<WorldState> {
return (set) => ({
players: {},
world: undefined,
setPlayers: (players) => set({ players }),
setWorld: (world) => set({ world }),
});
}
export function createPlayerStore(): StateCreator<PlayerState> {
return (set) => ({
activeTurn: false,
character: undefined,
setActiveTurn: (activeTurn: boolean) => set({ activeTurn }),
setCharacter: (character: Maybe<Actor>) => set({ character }),
isPlaying() {
return doesExist(this.character);
},
});
}
export function createStateStore() {
return createStore<StoreState>((...args) => ({
...createClientStore()(...args),
...createWorldStore()(...args),
...createPlayerStore()(...args),
}));
}
// TODO: make this not global
export const store = createStateStore();
export const storeContext = createContext(store);

View File

@ -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<Item>;
}
export interface Room {
name: string;
description: string;
portals: Record<string, string>;
actors: Array<Actor>;
items: Array<Item>;
}
export interface World {
name: string;
order: Array<string>;
rooms: Array<Room>;
theme: string;
}
import { useStore } from 'zustand';
import { StoreState, store } from './store';
import { Actor, Item, Room, World } from './models';
export type SetDetails = (entity: Maybe<Item | Actor | Room>) => void;
export type SetPlayer = (actor: Maybe<Actor>) => void;
export interface BaseEntityItemProps {
activeCharacter: Maybe<Actor>;
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 <TreeItem itemId={item.name} label={item.name}>
<TreeItem itemId={`${item.name}-details`} label="Details" onClick={() => setDetails(item)} />
<TreeItem itemId={`${item.name}-details`} label="Details" onClick={() => setDetailEntity(item)} />
</TreeItem>;
}
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 <TreeItem itemId={actor.name} label={label}>
{playButton}
<TreeItem itemId={`${actor.name}-details`} label="Details" onClick={() => setDetails(actor)} />
<TreeItem itemId={`${actor.name}-details`} label="Details" onClick={() => setDetailEntity(actor)} />
<TreeItem itemId={`${actor.name}-items`} label="Items">
{actor.items.map((item) => <ItemItem key={item.name} item={item} activeCharacter={activeCharacter} setDetails={setDetails} setPlayer={setPlayer} />)}
{actor.items.map((item) => <ItemItem key={item.name} item={item} setPlayer={setPlayer} />)}
</TreeItem>
</TreeItem>;
}
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 <TreeItem itemId={room.name} label={label}>
<TreeItem itemId={`${room.name}-details`} label="Details" onClick={() => setDetails(room)} />
<TreeItem itemId={`${room.name}-details`} label="Details" onClick={() => setDetailEntity(room)} />
<TreeItem itemId={`${room.name}-actors`} label="Actors">
{room.actors.map((actor) => <ActorItem key={actor.name} actor={actor} activeCharacter={activeCharacter} setDetails={setDetails} setPlayer={setPlayer} />)}
{room.actors.map((actor) => <ActorItem key={actor.name} actor={actor} setPlayer={setPlayer} />)}
</TreeItem>
<TreeItem itemId={`${room.name}-items`} label="Items">
{room.items.map((item) => <ItemItem key={item.name} item={item} activeCharacter={activeCharacter} setDetails={setDetails} setPlayer={setPlayer} />)}
{room.items.map((item) => <ItemItem key={item.name} item={item} setPlayer={setPlayer} />)}
</TreeItem>
</TreeItem>;
}
export function WorldPanel(props: { world: Maybe<World> } & 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<World> } & BaseEntityItemProps)
Theme: {world.theme}
</Typography>
<SimpleTreeView>
{world.rooms.map((room) => <RoomItem key={room.name} room={room} activeCharacter={activeCharacter} setDetails={setDetails} setPlayer={setPlayer} />)}
{world.rooms.map((room) => <RoomItem key={room.name} room={room} setPlayer={setPlayer} />)}
</SimpleTreeView>
</CardContent>
</Card>;

View File

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

View File

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