set up zustand state, optimize client somewhat
This commit is contained in:
parent
416b3cc5d2
commit
7010da8ed2
|
@ -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"
|
||||
|
|
|
@ -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:
|
||||
|
|
|
@ -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}")
|
||||
|
|
|
@ -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)
|
||||
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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);
|
||||
// handle special events
|
||||
switch (event.type) {
|
||||
case 'id':
|
||||
// unicast the client id to the player, do not append to history
|
||||
setClientId(event.id);
|
||||
return;
|
||||
}
|
||||
|
||||
if (data.type === 'prompt') {
|
||||
case 'prompt':
|
||||
// prompts are broadcast to all players
|
||||
if (data.client === clientId) {
|
||||
if (event.client === clientId) {
|
||||
// only notify the active player
|
||||
setActiveTurn(true);
|
||||
break;
|
||||
} else {
|
||||
const message = `Waiting for ${data.character} to take their turn`;
|
||||
setHistory((prev) => prev.concat(message));
|
||||
}
|
||||
setActiveTurn(false);
|
||||
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;
|
||||
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
|
||||
}
|
||||
|
||||
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>
|
||||
|
|
|
@ -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') {
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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>);
|
||||
});
|
||||
|
|
|
@ -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;
|
||||
}
|
|
@ -1,34 +1,33 @@
|
|||
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 PlayerPanel(props: PlayerPanelProps) {
|
||||
const { actor, activeTurn, sendInput } = props;
|
||||
const [input, setInput] = useState<string>('');
|
||||
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
if (!actor) {
|
||||
return <Card style={{ minHeight: '6vh', overflow: 'auto' }}>
|
||||
<CardContent>
|
||||
<Typography variant="h6">No player character</Typography>
|
||||
</CardContent>
|
||||
</Card>;
|
||||
export function playerStateSelector(s: StoreState) {
|
||||
return {
|
||||
character: s.character,
|
||||
activeTurn: s.activeTurn,
|
||||
};
|
||||
}
|
||||
|
||||
export function PlayerPanel(props: PlayerPanelProps) {
|
||||
const state = useStore(store, playerStateSelector);
|
||||
const { character, activeTurn } = state;
|
||||
const { sendInput } = props;
|
||||
const [input, setInput] = useState<string>('');
|
||||
|
||||
if (doesExist(character)) {
|
||||
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>
|
||||
<Typography variant="h6">Playing as: {character.name}</Typography>
|
||||
<Typography variant="body1">{character.backstory}</Typography>
|
||||
<Stack direction="row" spacing={2}>
|
||||
<TextField
|
||||
fullWidth
|
||||
|
@ -52,3 +51,10 @@ export function PlayerPanel(props: PlayerPanelProps) {
|
|||
</CardContent>
|
||||
</Card>;
|
||||
}
|
||||
|
||||
return <Card style={{ minHeight: '6vh', overflow: 'auto' }}>
|
||||
<CardContent>
|
||||
<Typography variant="h6">No player character</Typography>
|
||||
</CardContent>
|
||||
</Card>;
|
||||
}
|
||||
|
|
|
@ -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>;
|
||||
}
|
|
@ -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);
|
|
@ -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>;
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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
|
||||
|
|
Loading…
Reference in New Issue