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):
|
def embed_from_player(event: PlayerEvent):
|
||||||
if event.status == "join":
|
if event.status == "join":
|
||||||
title = "New Player"
|
title = "Player Joined"
|
||||||
description = f"{event.client} is now playing as {event.character}"
|
description = f"{event.client} is now playing as {event.character}"
|
||||||
else:
|
else:
|
||||||
title = "Player Left"
|
title = "Player Left"
|
||||||
|
|
|
@ -70,10 +70,6 @@ class PromptEvent:
|
||||||
room: Room
|
room: Room
|
||||||
actor: Actor
|
actor: Actor
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def from_text(prompt: str, room: Room, actor: Actor) -> "PromptEvent":
|
|
||||||
return PromptEvent(prompt=prompt, room=room, actor=actor)
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class ReplyEvent:
|
class ReplyEvent:
|
||||||
|
|
|
@ -8,6 +8,7 @@ from langchain_core.messages import AIMessage, BaseMessage, HumanMessage
|
||||||
from packit.agent import Agent
|
from packit.agent import Agent
|
||||||
from packit.utils import could_be_json
|
from packit.utils import could_be_json
|
||||||
|
|
||||||
|
from adventure.context import get_current_context
|
||||||
from adventure.models.event import PromptEvent
|
from adventure.models.event import PromptEvent
|
||||||
|
|
||||||
logger = getLogger(__name__)
|
logger = getLogger(__name__)
|
||||||
|
@ -182,7 +183,10 @@ class RemotePlayer(BasePlayer):
|
||||||
formatted_prompt = prompt.format(**kwargs)
|
formatted_prompt = prompt.format(**kwargs)
|
||||||
self.memory.append(HumanMessage(content=formatted_prompt))
|
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:
|
try:
|
||||||
logger.info(f"prompting remote player: {self.name}")
|
logger.info(f"prompting remote player: {self.name}")
|
||||||
|
|
|
@ -3,7 +3,7 @@ from collections import deque
|
||||||
from json import dumps, loads
|
from json import dumps, loads
|
||||||
from logging import getLogger
|
from logging import getLogger
|
||||||
from threading import Thread
|
from threading import Thread
|
||||||
from typing import Dict, Literal
|
from typing import Any, Dict, Literal
|
||||||
from uuid import uuid4
|
from uuid import uuid4
|
||||||
|
|
||||||
import websockets
|
import websockets
|
||||||
|
@ -157,6 +157,8 @@ async def handler(websocket):
|
||||||
break
|
break
|
||||||
|
|
||||||
connected.remove(websocket)
|
connected.remove(websocket)
|
||||||
|
if id in player_names:
|
||||||
|
del player_names[id]
|
||||||
|
|
||||||
# swap out the character for the original agent when they disconnect
|
# swap out the character for the original agent when they disconnect
|
||||||
player = get_player(id)
|
player = get_player(id)
|
||||||
|
@ -173,11 +175,10 @@ async def handler(websocket):
|
||||||
logger.info("Restoring LLM agent for %s", player.name)
|
logger.info("Restoring LLM agent for %s", player.name)
|
||||||
set_actor_agent(player.name, actor, player.fallback_agent)
|
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
|
socket_thread = None
|
||||||
static_thread = None
|
|
||||||
|
|
||||||
|
|
||||||
def server_json(obj):
|
def server_json(obj):
|
||||||
|
@ -195,7 +196,7 @@ def send_and_append(message):
|
||||||
|
|
||||||
|
|
||||||
def launch_server():
|
def launch_server():
|
||||||
global socket_thread, static_thread
|
global socket_thread
|
||||||
|
|
||||||
def run_sockets():
|
def run_sockets():
|
||||||
asyncio.run(server_main())
|
asyncio.run(server_main())
|
||||||
|
@ -222,7 +223,7 @@ def server_system(world: World, step: int):
|
||||||
|
|
||||||
|
|
||||||
def server_event(event: GameEvent):
|
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
|
json_event["type"] = event.type
|
||||||
send_and_append(json_event)
|
send_and_append(json_event)
|
||||||
|
|
||||||
|
|
|
@ -27,7 +27,8 @@
|
||||||
"react-i18next": "^12.2.0",
|
"react-i18next": "^12.2.0",
|
||||||
"react-use": "^17.4.3",
|
"react-use": "^17.4.3",
|
||||||
"react-use-websocket": "^4.8.1",
|
"react-use-websocket": "^4.8.1",
|
||||||
"tslib": "^2.6.2"
|
"tslib": "^2.6.2",
|
||||||
|
"zustand": "^4.5.2"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@mochajs/multi-reporter": "^1.1.0",
|
"@mochajs/multi-reporter": "^1.1.0",
|
||||||
|
|
|
@ -1,223 +1,185 @@
|
||||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
/* 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 { Maybe, doesExist } from '@apextoaster/js-utils';
|
||||||
import {
|
import {
|
||||||
Button,
|
Button,
|
||||||
|
Container,
|
||||||
CssBaseline,
|
CssBaseline,
|
||||||
Dialog,
|
Dialog,
|
||||||
|
DialogActions,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
DialogTitle,
|
DialogTitle,
|
||||||
PaletteMode,
|
|
||||||
ThemeProvider,
|
|
||||||
createTheme,
|
|
||||||
List,
|
|
||||||
Divider,
|
|
||||||
Typography,
|
|
||||||
Container,
|
|
||||||
Stack,
|
Stack,
|
||||||
Alert,
|
ThemeProvider,
|
||||||
Switch,
|
Typography,
|
||||||
FormGroup,
|
createTheme,
|
||||||
FormControlLabel,
|
|
||||||
TextField,
|
|
||||||
IconButton,
|
|
||||||
} from '@mui/material';
|
} from '@mui/material';
|
||||||
import { Allotment } from 'allotment';
|
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 { HistoryPanel } from './history.js';
|
||||||
import { EventItem } from './events.js';
|
import { Actor, Item, Room } from './models.js';
|
||||||
import { PlayerPanel } from './player.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 'allotment/dist/style.css';
|
||||||
import './main.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 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 {
|
export interface AppProps {
|
||||||
socketUrl: string;
|
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
|
// eslint-disable-next-line no-restricted-syntax
|
||||||
if (!doesExist(props.entity)) {
|
if (!doesExist(entity)) {
|
||||||
return <Fragment />;
|
return <Fragment />;
|
||||||
}
|
}
|
||||||
|
|
||||||
return <Fragment>
|
return <Fragment>
|
||||||
<DialogTitle>{props.entity.name}</DialogTitle>
|
<DialogTitle>{entity.name}</DialogTitle>
|
||||||
<DialogContent>
|
<DialogContent dividers>
|
||||||
<Typography>
|
<Typography>
|
||||||
{props.entity.description}
|
{entity.description}
|
||||||
</Typography>
|
</Typography>
|
||||||
<Button onClick={() => props.close()}>Close</Button>
|
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
|
<DialogActions>
|
||||||
|
<Button onClick={close}>Close</Button>
|
||||||
|
</DialogActions>
|
||||||
</Fragment>;
|
</Fragment>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function DetailDialog(props: { setDetails: SetDetails; details: Maybe<Item | Actor | Room> }) {
|
export function detailStateSelector(s: StoreState) {
|
||||||
const { details, setDetails } = props;
|
return {
|
||||||
|
detailEntity: s.detailEntity,
|
||||||
|
clearDetailEntity: s.clearDetailEntity,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DetailDialog() {
|
||||||
|
const state = useStore(store, detailStateSelector);
|
||||||
|
const { detailEntity, clearDetailEntity } = state;
|
||||||
|
|
||||||
return <Dialog
|
return <Dialog
|
||||||
open={doesExist(details)}
|
open={doesExist(detailEntity)}
|
||||||
onClose={() => setDetails(undefined)}
|
onClose={clearDetailEntity}
|
||||||
>
|
>
|
||||||
<EntityDetails entity={details} close={() => setDetails(undefined)} />
|
<EntityDetails entity={detailEntity} close={clearDetailEntity} />
|
||||||
</Dialog>;
|
</Dialog>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function appStateSelector(s: StoreState) {
|
||||||
|
return {
|
||||||
|
themeMode: s.themeMode,
|
||||||
|
setReadyState: s.setReadyState,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export function App(props: AppProps) {
|
export function App(props: AppProps) {
|
||||||
// client state - slice 1
|
const state = useStore(store, appStateSelector);
|
||||||
const [ activeTurn, setActiveTurn ] = useState<boolean>(false);
|
const { themeMode, setReadyState } = state;
|
||||||
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>>({});
|
|
||||||
|
|
||||||
// socket stuff
|
// socket stuff
|
||||||
const [ history, setHistory ] = useState<Array<string>>([]);
|
|
||||||
const { lastMessage, readyState, sendMessage } = useWebSocket(props.socketUrl);
|
const { lastMessage, readyState, sendMessage } = useWebSocket(props.socketUrl);
|
||||||
|
|
||||||
function setPlayer(actor: Maybe<Actor>) {
|
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)) {
|
if (doesExist(actor)) {
|
||||||
sendMessage(JSON.stringify({ type: 'player', become: actor.name }));
|
sendMessage(JSON.stringify({ type: 'player', become: actor.name }));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function sendInput(input: string) {
|
function sendInput(input: string) {
|
||||||
|
const { character, setActiveTurn } = store.getState();
|
||||||
if (doesExist(character)) {
|
if (doesExist(character)) {
|
||||||
sendMessage(JSON.stringify({ type: 'input', input }));
|
sendMessage(JSON.stringify({ type: 'input', input }));
|
||||||
setActiveTurn(false);
|
setActiveTurn(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function sendName(name: string) {
|
function setName(name: string) {
|
||||||
sendMessage(JSON.stringify({ type: 'player', name: clientName }));
|
const { setClientName } = store.getState();
|
||||||
|
sendMessage(JSON.stringify({ type: 'player', name }));
|
||||||
setClientName(name);
|
setClientName(name);
|
||||||
}
|
}
|
||||||
|
|
||||||
const theme = createTheme({
|
const theme = createTheme({
|
||||||
palette: {
|
palette: {
|
||||||
mode: themeMode as PaletteMode,
|
mode: themeMode,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const connectionStatus = statusStrings[readyState as ReadyState];
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
const { setClientId, setActiveTurn, setPlayers, appendEvent, setWorld, world, clientId, setCharacter } = store.getState();
|
||||||
if (doesExist(lastMessage)) {
|
if (doesExist(lastMessage)) {
|
||||||
const data = JSON.parse(lastMessage.data);
|
const event = JSON.parse(lastMessage.data);
|
||||||
|
|
||||||
if (data.type === 'id') {
|
// handle special events
|
||||||
// unicast the client id to the player
|
switch (event.type) {
|
||||||
setClientId(data.id);
|
case 'id':
|
||||||
return;
|
// 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') {
|
appendEvent(event);
|
||||||
// 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);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}, [lastMessage]);
|
}, [lastMessage]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setReadyState(readyState);
|
||||||
|
}, [readyState]);
|
||||||
|
|
||||||
return <ThemeProvider theme={theme}>
|
return <ThemeProvider theme={theme}>
|
||||||
<CssBaseline />
|
<CssBaseline />
|
||||||
<DetailDialog details={detailEntity} setDetails={setDetailEntity} />
|
<DetailDialog />
|
||||||
<Container maxWidth='xl'>
|
<Container maxWidth='xl'>
|
||||||
<Stack direction="column">
|
<Stack direction="column">
|
||||||
<Alert icon={false} severity="success">
|
<Statusbar setName={setName} />
|
||||||
<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>
|
|
||||||
<Stack direction="row" spacing={2}>
|
<Stack direction="row" spacing={2}>
|
||||||
<Allotment className='body-allotment'>
|
<Allotment className='body-allotment'>
|
||||||
<Stack direction="column" spacing={2} sx={{ minWidth: 400 }} className="scroll-history">
|
<Stack direction="column" spacing={2} sx={{ minWidth: 400 }} className="scroll-history">
|
||||||
<PlayerPanel actor={character} activeTurn={activeTurn} setDetails={setDetailEntity} sendInput={sendInput} />
|
<PlayerPanel sendInput={sendInput} />
|
||||||
<WorldPanel world={world} activeCharacter={character} setDetails={setDetailEntity} setPlayer={setPlayer} />
|
<WorldPanel setPlayer={setPlayer} />
|
||||||
</Stack>
|
</Stack>
|
||||||
<Stack direction="column" sx={{ minWidth: 600 }} className="scroll-history">
|
<Stack direction="column" sx={{ minWidth: 600 }} className="scroll-history">
|
||||||
<HistoryPanel history={history} scroll={autoScroll ? 'instant' : false} />
|
<HistoryPanel />
|
||||||
</Stack>
|
</Stack>
|
||||||
</Allotment>
|
</Allotment>
|
||||||
</Stack>
|
</Stack>
|
||||||
|
|
|
@ -94,7 +94,7 @@ export function PlayerEventItem(props: EventItemProps) {
|
||||||
let primary = '';
|
let primary = '';
|
||||||
let secondary = '';
|
let secondary = '';
|
||||||
if (status === 'join') {
|
if (status === 'join') {
|
||||||
primary = 'New Player';
|
primary = 'Player Joined';
|
||||||
secondary = `${client} is now playing as ${character}`;
|
secondary = `${client} is now playing as ${character}`;
|
||||||
}
|
}
|
||||||
if (status === 'leave') {
|
if (status === 'leave') {
|
||||||
|
|
|
@ -1,23 +1,30 @@
|
||||||
|
import { Maybe } from '@apextoaster/js-utils';
|
||||||
import { Divider, List } from '@mui/material';
|
import { Divider, List } from '@mui/material';
|
||||||
import React, { useEffect, useRef } from 'react';
|
import React, { useEffect, useRef } from 'react';
|
||||||
import { Maybe } from '@apextoaster/js-utils';
|
import { useStore } from 'zustand';
|
||||||
import { EventItem } from './events';
|
import { EventItem } from './events';
|
||||||
|
import { store, StoreState } from './store';
|
||||||
|
|
||||||
export interface HistoryPanelProps {
|
export function historyStateSelector(s: StoreState) {
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
return {
|
||||||
history: Array<any>;
|
history: s.eventHistory,
|
||||||
scroll: 'auto' | 'instant' | 'smooth' | false;
|
scroll: s.autoScroll,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function HistoryPanel(props: HistoryPanelProps) {
|
export function HistoryPanel() {
|
||||||
const { history, scroll } = props;
|
const state = useStore(store, historyStateSelector);
|
||||||
|
const { history, scroll } = state;
|
||||||
|
|
||||||
const scrollRef = useRef<Maybe<Element>>(undefined);
|
const scrollRef = useRef<Maybe<Element>>(undefined);
|
||||||
|
|
||||||
|
const scrollBehavior = state.scroll ? 'smooth' : 'auto';
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (scrollRef.current && scroll !== false) {
|
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) => {
|
const items = history.map((item, index) => {
|
||||||
if (index === history.length - 1) {
|
if (index === history.length - 1) {
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
import { doesExist } from '@apextoaster/js-utils';
|
import { doesExist } from '@apextoaster/js-utils';
|
||||||
import { createRoot } from 'react-dom/client';
|
import { createRoot } from 'react-dom/client';
|
||||||
import React from 'react';
|
import React, { StrictMode } from 'react';
|
||||||
|
|
||||||
import { App } from './app.js';
|
import { App } from './app.js';
|
||||||
|
|
||||||
|
@ -14,5 +14,5 @@ window.addEventListener('DOMContentLoaded', () => {
|
||||||
|
|
||||||
const hostname = window.location.hostname;
|
const hostname = window.location.hostname;
|
||||||
const root = createRoot(history);
|
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,54 +1,60 @@
|
||||||
import React, { useState } from 'react';
|
import { doesExist } from '@apextoaster/js-utils';
|
||||||
import { Maybe } from '@apextoaster/js-utils';
|
|
||||||
import { Alert, Button, Card, CardContent, Stack, TextField, Typography } from '@mui/material';
|
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 {
|
export interface PlayerPanelProps {
|
||||||
actor: Maybe<Actor>;
|
|
||||||
activeTurn: boolean;
|
|
||||||
setDetails: SetDetails;
|
|
||||||
sendInput: (input: string) => void;
|
sendInput: (input: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function playerStateSelector(s: StoreState) {
|
||||||
|
return {
|
||||||
|
character: s.character,
|
||||||
|
activeTurn: s.activeTurn,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export function PlayerPanel(props: PlayerPanelProps) {
|
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>('');
|
const [input, setInput] = useState<string>('');
|
||||||
|
|
||||||
// eslint-disable-next-line no-restricted-syntax
|
if (doesExist(character)) {
|
||||||
if (!actor) {
|
|
||||||
return <Card style={{ minHeight: '6vh', overflow: 'auto' }}>
|
return <Card style={{ minHeight: '6vh', overflow: 'auto' }}>
|
||||||
<CardContent>
|
<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>
|
</CardContent>
|
||||||
</Card>;
|
</Card>;
|
||||||
}
|
}
|
||||||
|
|
||||||
return <Card style={{ minHeight: '6vh', overflow: 'auto' }}>
|
return <Card style={{ minHeight: '6vh', overflow: 'auto' }}>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<Stack direction="column" spacing={2}>
|
<Typography variant="h6">No player character</Typography>
|
||||||
{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>
|
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>;
|
</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 { SimpleTreeView } from '@mui/x-tree-view/SimpleTreeView';
|
||||||
import { TreeItem } from '@mui/x-tree-view/TreeItem';
|
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';
|
import React from 'react';
|
||||||
|
|
||||||
export interface Item {
|
import { useStore } from 'zustand';
|
||||||
name: string;
|
import { StoreState, store } from './store';
|
||||||
description: string;
|
import { Actor, Item, Room, World } from './models';
|
||||||
}
|
|
||||||
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
export type SetDetails = (entity: Maybe<Item | Actor | Room>) => void;
|
export type SetDetails = (entity: Maybe<Item | Actor | Room>) => void;
|
||||||
export type SetPlayer = (actor: Maybe<Actor>) => void;
|
export type SetPlayer = (actor: Maybe<Actor>) => void;
|
||||||
|
|
||||||
export interface BaseEntityItemProps {
|
export interface BaseEntityItemProps {
|
||||||
activeCharacter: Maybe<Actor>;
|
|
||||||
setDetails: SetDetails;
|
|
||||||
setPlayer: SetPlayer;
|
setPlayer: SetPlayer;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -48,18 +23,36 @@ export function formatLabel(name: string, active = false): string {
|
||||||
return name;
|
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) {
|
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}>
|
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>;
|
</TreeItem>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ActorItem(props: { actor: Actor } & BaseEntityItemProps) {
|
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);
|
const label = formatLabel(actor.name, active);
|
||||||
|
|
||||||
let playButton;
|
let playButton;
|
||||||
|
@ -69,32 +62,36 @@ export function ActorItem(props: { actor: Actor } & BaseEntityItemProps) {
|
||||||
|
|
||||||
return <TreeItem itemId={actor.name} label={label}>
|
return <TreeItem itemId={actor.name} label={label}>
|
||||||
{playButton}
|
{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">
|
<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>
|
||||||
</TreeItem>;
|
</TreeItem>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function RoomItem(props: { room: Room } & BaseEntityItemProps) {
|
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);
|
const label = formatLabel(room.name, active);
|
||||||
|
|
||||||
return <TreeItem itemId={room.name} label={label}>
|
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">
|
<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>
|
||||||
<TreeItem itemId={`${room.name}-items`} label="Items">
|
<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>
|
||||||
</TreeItem>;
|
</TreeItem>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function WorldPanel(props: { world: Maybe<World> } & BaseEntityItemProps) {
|
export function WorldPanel(props: BaseEntityItemProps) {
|
||||||
const { world, activeCharacter, setDetails, setPlayer } = props;
|
const { setPlayer } = props;
|
||||||
|
const state = useStore(store, worldStateSelector);
|
||||||
|
const { world } = state;
|
||||||
|
|
||||||
// eslint-disable-next-line no-restricted-syntax
|
// eslint-disable-next-line no-restricted-syntax
|
||||||
if (!doesExist(world)) {
|
if (!doesExist(world)) {
|
||||||
|
@ -114,7 +111,7 @@ export function WorldPanel(props: { world: Maybe<World> } & BaseEntityItemProps)
|
||||||
Theme: {world.theme}
|
Theme: {world.theme}
|
||||||
</Typography>
|
</Typography>
|
||||||
<SimpleTreeView>
|
<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>
|
</SimpleTreeView>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>;
|
</Card>;
|
||||||
|
|
|
@ -3444,6 +3444,11 @@ use-resize-observer@^9.0.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
"@juggle/resize-observer" "^3.3.1"
|
"@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:
|
v8-to-istanbul@^9.0.0:
|
||||||
version "9.2.0"
|
version "9.2.0"
|
||||||
resolved "https://registry.yarnpkg.com/v8-to-istanbul/-/v8-to-istanbul-9.2.0.tgz#2ed7644a245cddd83d4e087b9b33b3e62dfd10ad"
|
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"
|
version "0.1.0"
|
||||||
resolved "https://registry.yarnpkg.com/yocto-queue/-/yocto-queue-0.1.0.tgz#0294eb3dee05028d31ee1a5fa2c556a6aaf10a1b"
|
resolved "https://registry.yarnpkg.com/yocto-queue/-/yocto-queue-0.1.0.tgz#0294eb3dee05028d31ee1a5fa2c556a6aaf10a1b"
|
||||||
integrity sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==
|
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)
|
- [Reply Events](#reply-events)
|
||||||
- [Result Events](#result-events)
|
- [Result Events](#result-events)
|
||||||
- [Status Events](#status-events)
|
- [Status Events](#status-events)
|
||||||
|
- [Snapshot Events](#snapshot-events)
|
||||||
- [Server-specific Events](#server-specific-events)
|
- [Server-specific Events](#server-specific-events)
|
||||||
- [Websocket Server Events](#websocket-server-events)
|
- [Websocket Server Events](#websocket-server-events)
|
||||||
- [Websocket New Client](#websocket-new-client)
|
- [Websocket New Client](#websocket-new-client)
|
||||||
|
@ -38,7 +39,7 @@
|
||||||
Player events use the following schema:
|
Player events use the following schema:
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
event: "player"
|
type: "player"
|
||||||
status: string
|
status: string
|
||||||
character: string
|
character: string
|
||||||
client: string
|
client: string
|
||||||
|
@ -49,7 +50,7 @@ client: string
|
||||||
Player join events have a `status` of `join`:
|
Player join events have a `status` of `join`:
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
event: "player"
|
type: "player"
|
||||||
status: "join"
|
status: "join"
|
||||||
character: string
|
character: string
|
||||||
client: string
|
client: string
|
||||||
|
@ -60,7 +61,7 @@ client: string
|
||||||
Player leave events have a `status` of `leave`:
|
Player leave events have a `status` of `leave`:
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
event: "player"
|
type: "player"
|
||||||
status: "leave"
|
status: "leave"
|
||||||
character: string
|
character: string
|
||||||
client: 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.
|
added to the world.
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
event: "generate"
|
type: "generate"
|
||||||
name: str
|
name: string
|
||||||
entity: Room | Actor | Item | None
|
entity: Room | Actor | Item | None
|
||||||
```
|
```
|
||||||
|
|
||||||
|
@ -87,14 +88,80 @@ more frequent progress updates when generating with slow models.
|
||||||
|
|
||||||
### Action Events
|
### 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
|
### 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
|
### 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
|
### 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
|
### 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
|
## Server-specific Events
|
||||||
|
|
||||||
### Websocket Server Events
|
### Websocket Server Events
|
||||||
|
|
Loading…
Reference in New Issue