diff --git a/adventure/server.py b/adventure/server.py index 8235fd2..4cdd42c 100644 --- a/adventure/server.py +++ b/adventure/server.py @@ -3,7 +3,7 @@ from collections import deque from json import dumps, loads from logging import getLogger from threading import Thread -from typing import Dict +from typing import Dict, Literal from uuid import uuid4 import websockets @@ -101,9 +101,8 @@ async def handler(websocket): set_actor_agent_for_name(actor.name, actor, player) # notify all clients that this character is now active - send_and_append( - {"type": "player", "name": character_name, "id": id} - ) + player_event(character_name, id, "join") + player_list() elif message_type == "input" and id in characters: player = characters[id] logger.info("queueing input for player %s: %s", player.name, data) @@ -121,11 +120,16 @@ async def handler(websocket): player = characters[id] del characters[id] + logger.info("Disconnecting player for %s", player.name) + player_event(player.name, id, "leave") + player_list() + actor, _ = get_actor_agent_for_name(player.name) - if actor: + if actor and player.fallback_agent: + logger.info("Restoring LLM agent for %s", player.name) set_actor_agent_for_name(player.name, actor, player.fallback_agent) - logger.info("Client disconnected") + logger.info("Client disconnected: %s", id) socket_thread = None @@ -200,3 +204,21 @@ def server_event(message: str): "type": "event", } send_and_append(json_broadcast) + + +def player_event(character: str, id: str, event: Literal["join", "leave"]): + json_broadcast = { + "type": "player", + "character": character, + "id": id, + "event": event, + } + send_and_append(json_broadcast) + + +def player_list(): + json_broadcast ={ + "type": "players", + "players": {player.name: player_id for player_id, player in characters.items()}, + } + send_and_append(json_broadcast) diff --git a/client/src/app.tsx b/client/src/app.tsx index 66b7f42..dae7902 100644 --- a/client/src/app.tsx +++ b/client/src/app.tsx @@ -18,6 +18,8 @@ import { Stack, Alert, Switch, + FormGroup, + FormControlLabel, } from '@mui/material'; import { Allotment } from 'allotment'; @@ -27,6 +29,7 @@ import { PlayerPanel } from './player.js'; import 'allotment/dist/style.css'; import './main.css'; +import { HistoryPanel } from './history.js'; const useWebSocket = (useWebSocketModule as any).default; @@ -38,11 +41,6 @@ const statusStrings = { [ReadyState.UNINSTANTIATED]: 'Unready', }; -export function interleave(arr: Array) { - // eslint-disable-next-line @typescript-eslint/no-magic-numbers - return arr.reduce((acc, val, idx) => acc.concat(val, ), []).slice(0, -1); -} - export interface AppProps { socketUrl: string; } @@ -77,12 +75,14 @@ export function DetailDialog(props: { setDetails: SetDetails; details: Maybe(false); + const [ autoScroll, setAutoScroll ] = useState(true); const [ detailEntity, setDetailEntity ] = useState>(undefined); const [ character, setCharacter ] = useState>(undefined); const [ clientId, setClientId ] = useState(''); const [ world, setWorld ] = useState>(undefined); const [ themeMode, setThemeMode ] = useState('light'); const [ history, setHistory ] = useState>([]); + const [ players, setPlayers ] = useState>({}); const { lastMessage, readyState, sendMessage } = useWebSocket(props.socketUrl); function setPlayer(actor: Maybe) { @@ -112,13 +112,15 @@ export function App(props: AppProps) { const data = JSON.parse(lastMessage.data); if (data.type === 'id') { + // unicast the client id to the player setClientId(data.id); return; } if (data.type === 'prompt') { + // prompts are broadcast to all players if (data.id === clientId) { - // notify the player and show the prompt + // only notify the active player setActiveTurn(true); } else { const message = `Waiting for ${data.character} to take their turn`; @@ -127,6 +129,11 @@ export function App(props: AppProps) { 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 @@ -134,17 +141,15 @@ export function App(props: AppProps) { setWorld(data.world); } - if (data.type === 'player' && data.id === clientId) { + if (doesExist(world) && data.type === 'player' && data.id === clientId && data.event === 'join') { // find the actor that matches the player name - const { name } = data; - // eslint-disable-next-line no-restricted-syntax - const actor = world?.rooms.flatMap((room) => room.actors).find((a) => a.name === name); + const { character: characterName } = data; + const actor = world.rooms.flatMap((room) => room.actors).find((a) => a.name === characterName); setCharacter(actor); } } }, [lastMessage]); - const items = history.map((item, index) => ); return @@ -156,24 +161,30 @@ export function App(props: AppProps) { Status: {connectionStatus} - setThemeMode(themeMode === 'dark' ? 'light' : 'dark')} - inputProps={{ 'aria-label': 'controlled' }} - sx={{ marginLeft: 'auto' }} - /> + + setThemeMode(themeMode === 'dark' ? 'light' : 'dark')} + inputProps={{ 'aria-label': 'controlled' }} + sx={{ marginLeft: 'auto' }} + />} label="Dark Mode" /> + setAutoScroll(autoScroll === false)} + inputProps={{ 'aria-label': 'controlled' }} + sx={{ marginLeft: 'auto' }} + />} label="Auto Scroll" /> + - + - - - {interleave(items)} - + + diff --git a/client/src/events.tsx b/client/src/events.tsx index c13ed11..2f828d8 100644 --- a/client/src/events.tsx +++ b/client/src/events.tsx @@ -1,11 +1,13 @@ import { ListItem, ListItemText, ListItemAvatar, Avatar, Typography } from '@mui/material'; -import React from 'react'; +import React, { MutableRefObject } from 'react'; import { formatters } from './format.js'; export interface EventItemProps { // eslint-disable-next-line @typescript-eslint/no-explicit-any event: any; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + focusRef?: MutableRefObject; } export function ActionItem(props: EventItemProps) { @@ -13,7 +15,7 @@ export function ActionItem(props: EventItemProps) { const { actor, room, type } = event; const content = formatters[type](event); - return + return @@ -41,7 +43,7 @@ export function WorldItem(props: EventItemProps) { const { step, world } = event; const { theme } = world; - return + return @@ -65,7 +67,7 @@ export function MessageItem(props: EventItemProps) { const { event } = props; const { message } = event; - return + return @@ -87,14 +89,25 @@ export function MessageItem(props: EventItemProps) { export function PlayerItem(props: EventItemProps) { const { event } = props; - const { name } = event; + const { character, event: innerEvent, id } = event; - return + let primary = ''; + let secondary = ''; + if (innerEvent === 'join') { + primary = 'New Player'; + secondary = `${id} is now playing as ${character}`; + } + if (innerEvent === 'leave') { + primary = 'Player Left'; + secondary = `${id} has left the game. ${character} is now controlled by an LLM`; + } + + return - + - Someone is playing as {name} + {secondary} } /> @@ -116,15 +129,15 @@ export function EventItem(props: EventItemProps) { switch (type) { case 'action': case 'result': - return ; + return ; case 'event': - return ; + return ; case 'player': - return ; + return ; case 'world': - return ; + return ; default: - return + return ; } diff --git a/client/src/history.tsx b/client/src/history.tsx new file mode 100644 index 0000000..189aa58 --- /dev/null +++ b/client/src/history.tsx @@ -0,0 +1,39 @@ +import { Divider, List } from '@mui/material'; +import React, { useEffect, useRef } from 'react'; +import { Maybe } from '@apextoaster/js-utils'; +import { EventItem } from './events'; + +export interface HistoryPanelProps { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + history: Array; + scroll: 'auto' | 'instant' | 'smooth' | false; +} + +export function HistoryPanel(props: HistoryPanelProps) { + const { history, scroll } = props; + const scrollRef = useRef>(undefined); + + useEffect(() => { + if (scrollRef.current && scroll !== false) { + scrollRef.current.scrollIntoView({ behavior: scroll as ScrollBehavior, block: 'end' }); + } + }, [scrollRef.current, props.scroll]); + + const items = history.map((item, index) => { + if (index === history.length - 1) { + return ; + } + + return ; + }); + + return + {interleave(items)} + ; +} + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export function interleave(arr: Array) { + // eslint-disable-next-line @typescript-eslint/no-magic-numbers + return arr.reduce((acc, val, idx) => acc.concat(val, ), []).slice(0, -1); +} diff --git a/client/src/player.tsx b/client/src/player.tsx index e8f4379..4139697 100644 --- a/client/src/player.tsx +++ b/client/src/player.tsx @@ -16,24 +16,36 @@ export function PlayerPanel(props: PlayerPanelProps) { // eslint-disable-next-line no-restricted-syntax if (!actor) { - return + return No player character ; } - return + return {activeTurn && It's your turn!} Playing as: {actor.name} {actor.backstory} - setInput(event.target.value)} /> + setInput(event.target.value)} + onKeyDown={(event) => { + if (event.key === 'Enter') { + sendInput(input); + setInput(''); + } + }} + /> diff --git a/client/src/world.tsx b/client/src/world.tsx index ceada81..4d7cf02 100644 --- a/client/src/world.tsx +++ b/client/src/world.tsx @@ -98,12 +98,16 @@ export function WorldPanel(props: { world: Maybe } & BaseEntityItemProps) // eslint-disable-next-line no-restricted-syntax if (!doesExist(world)) { - return - No world data available - ; + return + + + No world data available + + + ; } - return + return {world.name}