1
0
Fork 0

give each client a unique id, split up UI sections

This commit is contained in:
Sean Sube 2024-05-05 13:54:39 -05:00
parent f15390bd72
commit 580076335f
Signed by: ssube
GPG Key ID: 3EED7B957D362AF1
10 changed files with 452 additions and 240 deletions

2
.gitignore vendored
View File

@ -1,4 +1,4 @@
adventure/custom_*.py adventure/custom_*
worlds/ worlds/
__pycache__/ __pycache__/
.env .env

View File

@ -1,10 +1,10 @@
from functools import partial
from logging import getLogger from logging import getLogger
from random import random from random import random
from typing import Callable, Dict, List, Optional from typing import Callable, Dict, List, Optional
from functools import partial
from rule_engine import Rule
from pydantic import Field from pydantic import Field
from rule_engine import Rule
from yaml import Loader, load from yaml import Loader, load
from adventure.models import Actor, Item, Room, World, dataclass from adventure.models import Actor, Item, Room, World, dataclass
@ -65,9 +65,11 @@ def update_attributes(
if rule.rule: if rule.rule:
# TODO: pre-compile rules # TODO: pre-compile rules
rule_impl = Rule(rule.rule) rule_impl = Rule(rule.rule)
if not rule_impl.matches({ if not rule_impl.matches(
{
"attributes": typed_attributes, "attributes": typed_attributes,
}): }
):
logger.debug("logic rule did not match attributes: %s", rule.rule) logger.debug("logic rule did not match attributes: %s", rule.rule)
continue continue
@ -95,15 +97,25 @@ def update_attributes(
return attributes return attributes
def update_logic(world: World, step: int, rules: LogicTable, triggers: TriggerTable) -> None: def update_logic(
world: World, step: int, rules: LogicTable, triggers: TriggerTable
) -> None:
for room in world.rooms: for room in world.rooms:
room.attributes = update_attributes(room, room.attributes, rules=rules, triggers=triggers) room.attributes = update_attributes(
room, room.attributes, rules=rules, triggers=triggers
)
for actor in room.actors: for actor in room.actors:
actor.attributes = update_attributes(actor, actor.attributes, rules=rules, triggers=triggers) actor.attributes = update_attributes(
actor, actor.attributes, rules=rules, triggers=triggers
)
for item in actor.items: for item in actor.items:
item.attributes = update_attributes(item, item.attributes, rules=rules, triggers=triggers) item.attributes = update_attributes(
item, item.attributes, rules=rules, triggers=triggers
)
for item in room.items: for item in room.items:
item.attributes = update_attributes(item, item.attributes, rules=rules, triggers=triggers) item.attributes = update_attributes(
item, item.attributes, rules=rules, triggers=triggers
)
logger.info("updated world attributes") logger.info("updated world attributes")
@ -138,5 +150,5 @@ def init_from_file(filename: str):
logger.info("initialized logic system") logger.info("initialized logic system")
return ( return (
partial(update_logic, rules=logic_rules, triggers=logic_triggers), partial(update_logic, rules=logic_rules, triggers=logic_triggers),
partial(format_logic, rules=logic_rules) partial(format_logic, rules=logic_rules),
) )

View File

@ -89,7 +89,9 @@ def action_use(item: str, target: str) -> str:
""" """
_, action_room, action_actor = get_current_context() _, action_room, action_actor = get_current_context()
available_items = [item.name for item in action_actor.items] + [item.name for item in action_room.items] available_items = [item.name for item in action_actor.items] + [
item.name for item in action_room.items
]
if item not in available_items: if item not in available_items:
return f"The {item} item is not available to use." return f"The {item} item is not available to use."

View File

@ -1,11 +1,14 @@
from json import dumps from json import dumps
from readline import add_history from logging import getLogger
from queue import Queue from queue import Queue
from readline import add_history
from typing import Any, Callable, Dict, List, Sequence from typing import Any, Callable, Dict, List, Sequence
from langchain_core.messages import AIMessage, BaseMessage, HumanMessage from langchain_core.messages import AIMessage, BaseMessage, HumanMessage
from packit.utils import could_be_json from packit.utils import could_be_json
logger = getLogger(__name__)
class BasePlayer: class BasePlayer:
""" """
@ -95,6 +98,8 @@ class LocalPlayer(BasePlayer):
Ask the player for input. Ask the player for input.
""" """
logger.info("prompting local player: {self.name}")
formatted_prompt = prompt.format(**kwargs) formatted_prompt = prompt.format(**kwargs)
self.memory.append(HumanMessage(content=formatted_prompt)) self.memory.append(HumanMessage(content=formatted_prompt))
print(formatted_prompt) print(formatted_prompt)
@ -109,7 +114,9 @@ class RemotePlayer(BasePlayer):
input_queue: Queue[str] input_queue: Queue[str]
send_prompt: Callable[[str, str], bool] send_prompt: Callable[[str, str], bool]
def __init__(self, name: str, backstory: str, send_prompt: Callable[[str, str], bool]) -> None: def __init__(
self, name: str, backstory: str, send_prompt: Callable[[str, str], bool]
) -> None:
super().__init__(name, backstory) super().__init__(name, backstory)
self.input_queue = Queue() self.input_queue = Queue()
self.send_prompt = send_prompt self.send_prompt = send_prompt
@ -123,11 +130,12 @@ class RemotePlayer(BasePlayer):
self.memory.append(HumanMessage(content=formatted_prompt)) self.memory.append(HumanMessage(content=formatted_prompt))
try: try:
logger.info(f"prompting remote player: {self.name}")
if self.send_prompt(self.name, formatted_prompt): if self.send_prompt(self.name, formatted_prompt):
reply = self.input_queue.get(timeout=60) reply = self.input_queue.get(timeout=60)
logger.info(f"got reply from remote player: {reply}")
return self.parse_input(reply) return self.parse_input(reply)
except Exception: except Exception:
pass logger.exception("error getting reply from remote player")
# logger.warning("Failed to send prompt to remote player")
return "" return ""

View File

@ -3,11 +3,13 @@ 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, Tuple from typing import Dict
from uuid import uuid4
import websockets import websockets
from packit.agent import Agent
from adventure.context import get_actor_agent_for_name from adventure.context import get_actor_agent_for_name, set_actor_agent_for_name
from adventure.models import Actor, Room, World from adventure.models import Actor, Room, World
from adventure.player import RemotePlayer from adventure.player import RemotePlayer
from adventure.state import snapshot_world, world_json from adventure.state import snapshot_world, world_json
@ -16,20 +18,28 @@ logger = getLogger(__name__)
connected = set() connected = set()
characters: Dict[str, RemotePlayer] = {} characters: Dict[str, RemotePlayer] = {}
previous_agents: Dict[str, Agent] = {}
recent_events = deque(maxlen=100) recent_events = deque(maxlen=100)
recent_world = None recent_world = None
async def handler(websocket): async def handler(websocket):
logger.info("Client connected") id = uuid4().hex
logger.info("Client connected, given id: %s", id)
connected.add(websocket) connected.add(websocket)
async def next_turn(character: str, prompt: str) -> None: async def next_turn(character: str, prompt: str) -> None:
await websocket.send(connected, dumps({ await websocket.send(
"type": "turn", dumps(
{
"type": "prompt",
"id": id,
"character": character, "character": character,
"prompt": prompt, "prompt": prompt,
})) "actions": [],
}
),
)
def sync_turn(character: str, prompt: str) -> bool: def sync_turn(character: str, prompt: str) -> bool:
if websocket not in characters: if websocket not in characters:
@ -39,6 +49,8 @@ async def handler(websocket):
return True return True
try: try:
await websocket.send(dumps({"type": "id", "id": id}))
if recent_world: if recent_world:
await websocket.send(recent_world) await websocket.send(recent_world)
@ -55,26 +67,41 @@ async def handler(websocket):
try: try:
data = loads(message) data = loads(message)
if "become" in data: message_type = data.get("type", None)
if message_type == "player":
character = characters.get(websocket) character = characters.get(websocket)
if character: if character:
del characters[websocket] del characters[id]
character_name = data["become"] character_name = data["become"]
actor, _ = get_actor_agent_for_name(character_name) actor, llm_agent = get_actor_agent_for_name(character_name)
if not actor: if not actor:
logger.error(f"Failed to find actor {character_name}") logger.error(f"Failed to find actor {character_name}")
continue continue
if character_name in [player.name for player in characters.values()]: if character_name in [
player.name for player in characters.values()
]:
logger.error(f"Character {character_name} is already in use") logger.error(f"Character {character_name} is already in use")
continue continue
characters[websocket] = RemotePlayer(actor.name, actor.backstory, sync_turn) # player_name = data["player"]
player = RemotePlayer(actor.name, actor.backstory, sync_turn)
characters[id] = player
logger.info(f"Client {websocket} is now character {character_name}") logger.info(f"Client {websocket} is now character {character_name}")
elif websocket in characters:
player = characters[websocket] # swap out the LLM agent
player.input_queue.put(message) set_actor_agent_for_name(actor.name, actor, player)
previous_agents[actor.name] = llm_agent
# notify all clients that this character is now active
send_and_append(
{"type": "player", "name": character_name, "id": id}
)
elif message_type == "input" and id in characters:
player = characters[id]
logger.info("queueing input for player %s: %s", player.name, data)
player.input_queue.put(data["input"])
except Exception: except Exception:
logger.exception("Failed to parse message") logger.exception("Failed to parse message")
@ -83,9 +110,14 @@ async def handler(websocket):
connected.remove(websocket) connected.remove(websocket)
# TODO: swap out the character for the original agent # swap out the character for the original agent when they disconnect
if websocket in characters: if websocket in characters:
del characters[websocket] player = characters[id]
del characters[id]
actor, _ = get_actor_agent_for_name(player.name)
if actor:
set_actor_agent_for_name(player.name, actor, previous_agents[player.name])
logger.info("Client disconnected") logger.info("Client disconnected")

View File

@ -1,30 +1,31 @@
/* eslint-disable @typescript-eslint/no-explicit-any */ /* eslint-disable @typescript-eslint/no-explicit-any */
import React, { useState, useEffect, useRef, MutableRefObject, Fragment } from 'react'; import React, { useState, useEffect, Fragment } from 'react';
import useWebSocketModule, { ReadyState } from 'react-use-websocket'; import useWebSocketModule, { ReadyState } from 'react-use-websocket';
import { Maybe, doesExist } from '@apextoaster/js-utils'; import { Maybe, doesExist } from '@apextoaster/js-utils';
import List from '@mui/material/List'; import {
import ListItem from '@mui/material/ListItem'; Button,
import Divider from '@mui/material/Divider'; CssBaseline,
import ListItemText from '@mui/material/ListItemText'; Dialog,
import ListItemAvatar from '@mui/material/ListItemAvatar'; DialogContent,
import Typography from '@mui/material/Typography'; DialogTitle,
import Avatar from '@mui/material/Avatar'; PaletteMode,
import Container from '@mui/material/Container'; ThemeProvider,
import Stack from '@mui/material/Stack'; createTheme,
import Alert from '@mui/material/Alert'; List,
import Switch from '@mui/material/Switch'; Divider,
import { SimpleTreeView } from '@mui/x-tree-view/SimpleTreeView'; Typography,
import { TreeItem } from '@mui/x-tree-view/TreeItem'; Container,
import { Button, CssBaseline, Dialog, DialogContent, DialogTitle, PaletteMode, ThemeProvider, createTheme } from '@mui/material'; Stack,
Alert,
Switch,
} from '@mui/material';
import { formatters } from './format.js'; import { Room, Actor, Item, World, WorldPanel, SetDetails } from './world.js';
import { EventItem } from './events.js';
import { PlayerPanel } from './player.js';
const useWebSocket = (useWebSocketModule as any).default; const useWebSocket = (useWebSocketModule as any).default;
export interface EventItemProps {
event: any;
}
const statusStrings = { const statusStrings = {
[ReadyState.CONNECTING]: 'Connecting', [ReadyState.CONNECTING]: 'Connecting',
[ReadyState.OPEN]: 'Running', [ReadyState.OPEN]: 'Running',
@ -38,190 +39,10 @@ export function interleave(arr: Array<any>) {
return arr.reduce((acc, val, idx) => acc.concat(val, <Divider component='li' key={`sep-${idx}`} variant='inset' />), []).slice(0, -1); return arr.reduce((acc, val, idx) => acc.concat(val, <Divider component='li' key={`sep-${idx}`} variant='inset' />), []).slice(0, -1);
} }
export function ActionItem(props: EventItemProps) {
const { event } = props;
const { actor, room, type } = event;
const content = formatters[type](event);
return <ListItem alignItems="flex-start">
<ListItemAvatar>
<Avatar alt={actor} src="/static/images/avatar/1.jpg" />
</ListItemAvatar>
<ListItemText
primary={room}
secondary={
<React.Fragment>
<Typography
sx={{ display: 'block' }}
component="span"
variant="body2"
color="text.primary"
>
{actor}
</Typography>
{content}
</React.Fragment>
}
/>
</ListItem>;
}
export function WorldItem(props: EventItemProps) {
const { event } = props;
const { step, world } = event;
const { theme } = world;
return <ListItem alignItems="flex-start">
<ListItemAvatar>
<Avatar alt={step.toString()} src="/static/images/avatar/1.jpg" />
</ListItemAvatar>
<ListItemText
primary={theme}
secondary={
<Typography
sx={{ display: 'block' }}
component="span"
variant="body2"
color="text.primary"
>
Step {step}
</Typography>
}
/>
</ListItem>;
}
export function MessageItem(props: EventItemProps) {
const { event } = props;
const { message } = event;
return <ListItem alignItems="flex-start">
<ListItemAvatar>
<Avatar alt="System" src="/static/images/avatar/1.jpg" />
</ListItemAvatar>
<ListItemText
primary="System"
secondary={
<Typography
sx={{ display: 'block' }}
component="span"
variant="body2"
color="text.primary"
>
{message}
</Typography>
}
/>
</ListItem>;
}
export function EventItem(props: EventItemProps) {
const { event } = props;
const { type } = event;
switch (type) {
case 'action':
case 'result':
return <ActionItem event={event} />;
case 'event':
return <MessageItem event={event} />;
case 'world':
return <WorldItem event={event} />;
default:
return <ListItem>
<ListItemText primary={`Unknown event type: ${type}`} />
</ListItem>;
}
}
export interface AppProps { export interface AppProps {
socketUrl: string; socketUrl: string;
} }
export interface Item {
name: string;
description: string;
}
export interface Actor {
name: 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 function ItemItem(props: { item: Item; setDetails: SetDetails }) {
const { item, setDetails } = props;
return <TreeItem itemId={item.name} label={item.name}>
<TreeItem itemId={`${item.name}-details`} label="Details" onClick={() => setDetails(item)} />
</TreeItem>;
}
export function ActorItem(props: { actor: Actor; setDetails: SetDetails }) {
const { actor, setDetails } = props;
return <TreeItem itemId={actor.name} label={actor.name}>
<TreeItem itemId={`${actor.name}-details`} label="Details" onClick={() => setDetails(actor)} />
<TreeItem itemId={`${actor.name}-items`} label="Items">
{actor.items.map((item) => <ItemItem item={item} setDetails={setDetails} />)}
</TreeItem>
</TreeItem>;
}
export function RoomItem(props: { room: Room; setDetails: SetDetails }) {
const { room, setDetails } = props;
return <TreeItem itemId={room.name} label={room.name}>
<TreeItem itemId={`${room.name}-details`} label="Details" onClick={() => setDetails(room)} />
<TreeItem itemId={`${room.name}-actors`} label="Actors">
{room.actors.map((actor) => <ActorItem actor={actor} setDetails={setDetails} />)}
</TreeItem>
<TreeItem itemId={`${room.name}-items`} label="Items">
{room.items.map((item) => <ItemItem item={item} setDetails={setDetails} />)}
</TreeItem>
</TreeItem>;
}
export function WorldPanel(props: { world: Maybe<World>; setDetails: SetDetails }) {
const { world, setDetails } = props;
// eslint-disable-next-line no-restricted-syntax
if (!doesExist(world)) {
return <Typography variant="h4">
No world data available
</Typography>;
}
return <Stack direction="column" sx={{ minWidth: 600 }}>
<Typography variant="h4">
World: {world.name}
</Typography>
<Typography variant="h6">
Theme: {world.theme}
</Typography>
<SimpleTreeView>
{world.rooms.map((room) => <RoomItem room={room} setDetails={setDetails} />)}
</SimpleTreeView>
</Stack>;
}
export function EntityDetails(props: { entity: Maybe<Item | Actor | Room>; close: () => void }) { export function EntityDetails(props: { entity: Maybe<Item | Actor | Room>; close: () => void }) {
// eslint-disable-next-line no-restricted-syntax // eslint-disable-next-line no-restricted-syntax
if (!doesExist(props.entity)) { if (!doesExist(props.entity)) {
@ -251,11 +72,28 @@ export function DetailDialog(props: { setDetails: SetDetails; details: Maybe<Ite
} }
export function App(props: AppProps) { export function App(props: AppProps) {
const [ activeTurn, setActiveTurn ] = useState<boolean>(false);
const [ detailEntity, setDetailEntity ] = useState<Maybe<Item | Actor | Room>>(undefined); const [ detailEntity, setDetailEntity ] = useState<Maybe<Item | Actor | Room>>(undefined);
const [ character, setCharacter ] = useState<Maybe<Actor>>(undefined);
const [ clientId, setClientId ] = useState<string>('');
const [ world, setWorld ] = useState<Maybe<World>>(undefined); const [ world, setWorld ] = useState<Maybe<World>>(undefined);
const [ themeMode, setThemeMode ] = useState('light'); const [ themeMode, setThemeMode ] = useState('light');
const [ history, setHistory ] = useState<Array<string>>([]); const [ history, setHistory ] = useState<Array<string>>([]);
const { lastMessage, readyState } = useWebSocket(props.socketUrl); const { lastMessage, readyState, sendMessage } = useWebSocket(props.socketUrl);
function setPlayer(actor: Maybe<Actor>) {
setCharacter(actor);
if (doesExist(actor)) {
sendMessage(JSON.stringify({ type: 'player', become: actor.name }));
}
}
function sendInput(input: string) {
if (doesExist(character)) {
sendMessage(JSON.stringify({ type: 'input', input }));
}
}
const theme = createTheme({ const theme = createTheme({
palette: { palette: {
@ -268,12 +106,37 @@ export function App(props: AppProps) {
useEffect(() => { useEffect(() => {
if (doesExist(lastMessage)) { if (doesExist(lastMessage)) {
const data = JSON.parse(lastMessage.data); const data = JSON.parse(lastMessage.data);
if (data.type === 'id') {
setClientId(data.id);
return;
}
if (data.type === 'prompt') {
if (data.id === clientId) {
// notify the player and show the prompt
setActiveTurn(true);
} else {
const message = `Waiting for ${data.character} to take their turn`;
setHistory((prev) => prev.concat(message));
}
return;
}
setHistory((prev) => prev.concat(data)); setHistory((prev) => prev.concat(data));
// if we get a world event, update the last world state // if we get a world event, update the last world state
if (data.type === 'world') { if (data.type === 'world') {
setWorld(data.world); setWorld(data.world);
} }
if (data.type === 'player' && data.id === clientId) {
// 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);
setCharacter(actor);
}
} }
}, [lastMessage]); }, [lastMessage]);
@ -298,7 +161,10 @@ export function App(props: AppProps) {
</Stack> </Stack>
</Alert> </Alert>
<Stack direction="row"> <Stack direction="row">
<WorldPanel world={world} setDetails={setDetailEntity} /> <Stack direction="column" spacing={2} sx={{ minWidth: 600 }}>
<WorldPanel world={world} activeCharacter={character} setDetails={setDetailEntity} setPlayer={setPlayer} />
<PlayerPanel actor={character} activeTurn={activeTurn} setDetails={setDetailEntity} sendInput={sendInput} />
</Stack>
<Stack direction="column" sx={{ minWidth: 800 }}> <Stack direction="column" sx={{ minWidth: 800 }}>
<List sx={{ width: '100%', bgcolor: 'background.paper' }}> <List sx={{ width: '100%', bgcolor: 'background.paper' }}>
{interleave(items)} {interleave(items)}

131
client/src/events.tsx Normal file
View File

@ -0,0 +1,131 @@
import { ListItem, ListItemText, ListItemAvatar, Avatar, Typography } from '@mui/material';
import React from 'react';
import { formatters } from './format.js';
export interface EventItemProps {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
event: any;
}
export function ActionItem(props: EventItemProps) {
const { event } = props;
const { actor, room, type } = event;
const content = formatters[type](event);
return <ListItem alignItems="flex-start">
<ListItemAvatar>
<Avatar alt={actor} src="/static/images/avatar/1.jpg" />
</ListItemAvatar>
<ListItemText
primary={room}
secondary={
<React.Fragment>
<Typography
sx={{ display: 'block' }}
component="span"
variant="body2"
color="text.primary"
>
{actor}
</Typography>
{content}
</React.Fragment>
}
/>
</ListItem>;
}
export function WorldItem(props: EventItemProps) {
const { event } = props;
const { step, world } = event;
const { theme } = world;
return <ListItem alignItems="flex-start">
<ListItemAvatar>
<Avatar alt={step.toString()} src="/static/images/avatar/1.jpg" />
</ListItemAvatar>
<ListItemText
primary={theme}
secondary={
<Typography
sx={{ display: 'block' }}
component="span"
variant="body2"
color="text.primary"
>
Step {step}
</Typography>
}
/>
</ListItem>;
}
export function MessageItem(props: EventItemProps) {
const { event } = props;
const { message } = event;
return <ListItem alignItems="flex-start">
<ListItemAvatar>
<Avatar alt="System" src="/static/images/avatar/1.jpg" />
</ListItemAvatar>
<ListItemText
primary="System"
secondary={
<Typography
sx={{ display: 'block' }}
component="span"
variant="body2"
color="text.primary"
>
{message}
</Typography>
}
/>
</ListItem>;
}
export function PlayerItem(props: EventItemProps) {
const { event } = props;
const { name } = event;
return <ListItem alignItems="flex-start">
<ListItemAvatar>
<Avatar alt={name} src="/static/images/avatar/1.jpg" />
</ListItemAvatar>
<ListItemText
primary="New Player"
secondary={
<Typography
sx={{ display: 'block' }}
component="span"
variant="body2"
color="text.primary"
>
Someone is playing as {name}
</Typography>
}
/>
</ListItem>;
}
export function EventItem(props: EventItemProps) {
const { event } = props;
const { type } = event;
switch (type) {
case 'action':
case 'result':
return <ActionItem event={event} />;
case 'event':
return <MessageItem event={event} />;
case 'player':
return <PlayerItem event={event} />;
case 'world':
return <WorldItem event={event} />;
default:
return <ListItem>
<ListItemText primary={`Unknown event type: ${type}`} />
</ListItem>;
}
}

View File

@ -13,8 +13,12 @@ export function formatAction(data: any) {
} }
export function formatInput(data: any) { export function formatInput(data: any) {
try {
const action = formatAction(JSON.parse(data.input)); const action = formatAction(JSON.parse(data.input));
return `Starting turn: ${action}`; return `Starting turn: ${action}`;
} catch (err) {
return `Error parsing input: ${err}`;
}
} }
export function formatResult(data: any) { export function formatResult(data: any) {

40
client/src/player.tsx Normal file
View File

@ -0,0 +1,40 @@
import React, { useState } from 'react';
import { Maybe } from '@apextoaster/js-utils';
import { Alert, Button, Card, CardContent, Stack, TextField, Typography } from '@mui/material';
import { Actor, SetDetails } from './world.js';
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>
<CardContent>
<Typography variant="h6">No player character</Typography>
</CardContent>
</Card>;
}
return <Card>
<CardContent>
{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 label="Input" variant="outlined" fullWidth value={input} onChange={(event) => setInput(event.target.value)} />
<Button variant="contained" onClick={() => {
setInput('');
sendInput(input);
}}>Send</Button>
</Stack>
</CardContent>
</Card>;
}

117
client/src/world.tsx Normal file
View File

@ -0,0 +1,117 @@
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;
}
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;
}
export function formatLabel(name: string, active = false): string {
if (active) {
return `${name} (!)`;
}
return name;
}
export function ItemItem(props: { item: Item } & BaseEntityItemProps) {
const { item, setDetails } = props;
return <TreeItem itemId={item.name} label={item.name}>
<TreeItem itemId={`${item.name}-details`} label="Details" onClick={() => setDetails(item)} />
</TreeItem>;
}
export function ActorItem(props: { actor: Actor } & BaseEntityItemProps) {
const { actor, activeCharacter, setDetails, setPlayer } = props;
const active = doesExist(activeCharacter) && actor === activeCharacter;
const label = formatLabel(actor.name, active);
let playButton;
if (active === false) {
playButton = <TreeItem itemId={`${actor.name}-play`} label="Play!" onClick={() => setPlayer(actor)} />;
}
return <TreeItem itemId={actor.name} label={label}>
{playButton}
<TreeItem itemId={`${actor.name}-details`} label="Details" onClick={() => setDetails(actor)} />
<TreeItem itemId={`${actor.name}-items`} label="Items">
{actor.items.map((item) => <ItemItem key={item.name} item={item} activeCharacter={activeCharacter} setDetails={setDetails} setPlayer={setPlayer} />)}
</TreeItem>
</TreeItem>;
}
export function RoomItem(props: { room: Room } & BaseEntityItemProps) {
const { room, activeCharacter, setDetails, setPlayer } = props;
const active = doesExist(activeCharacter) && room.actors.some((it) => it === activeCharacter);
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}-actors`} label="Actors">
{room.actors.map((actor) => <ActorItem key={actor.name} actor={actor} activeCharacter={activeCharacter} setDetails={setDetails} 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} />)}
</TreeItem>
</TreeItem>;
}
export function WorldPanel(props: { world: Maybe<World> } & BaseEntityItemProps) {
const { world, activeCharacter, setDetails, setPlayer } = props;
// eslint-disable-next-line no-restricted-syntax
if (!doesExist(world)) {
return <Typography variant="h4">
No world data available
</Typography>;
}
return <Card>
<CardContent>
<Typography gutterBottom variant="h5" component="div">{world.name}</Typography>
<Typography variant="body1">
Theme: {world.theme}
</Typography>
<SimpleTreeView>
{world.rooms.map((room) => <RoomItem key={room.name} room={room} activeCharacter={activeCharacter} setDetails={setDetails} setPlayer={setPlayer} />)}
</SimpleTreeView>
</CardContent>
</Card>;
}