give each client a unique id, split up UI sections
This commit is contained in:
parent
f15390bd72
commit
580076335f
|
@ -1,4 +1,4 @@
|
|||
adventure/custom_*.py
|
||||
adventure/custom_*
|
||||
worlds/
|
||||
__pycache__/
|
||||
.env
|
||||
|
|
|
@ -1,10 +1,10 @@
|
|||
from functools import partial
|
||||
from logging import getLogger
|
||||
from random import random
|
||||
from typing import Callable, Dict, List, Optional
|
||||
from functools import partial
|
||||
|
||||
from rule_engine import Rule
|
||||
from pydantic import Field
|
||||
from rule_engine import Rule
|
||||
from yaml import Loader, load
|
||||
|
||||
from adventure.models import Actor, Item, Room, World, dataclass
|
||||
|
@ -65,13 +65,15 @@ def update_attributes(
|
|||
if rule.rule:
|
||||
# TODO: pre-compile rules
|
||||
rule_impl = Rule(rule.rule)
|
||||
if not rule_impl.matches({
|
||||
if not rule_impl.matches(
|
||||
{
|
||||
"attributes": typed_attributes,
|
||||
}):
|
||||
}
|
||||
):
|
||||
logger.debug("logic rule did not match attributes: %s", rule.rule)
|
||||
continue
|
||||
|
||||
if rule.match and not(rule.match.items() <= typed_attributes.items()):
|
||||
if rule.match and not (rule.match.items() <= typed_attributes.items()):
|
||||
logger.debug("logic did not match attributes: %s", rule.match)
|
||||
continue
|
||||
|
||||
|
@ -95,15 +97,25 @@ def update_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:
|
||||
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:
|
||||
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:
|
||||
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:
|
||||
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")
|
||||
|
||||
|
@ -138,5 +150,5 @@ def init_from_file(filename: str):
|
|||
logger.info("initialized logic system")
|
||||
return (
|
||||
partial(update_logic, rules=logic_rules, triggers=logic_triggers),
|
||||
partial(format_logic, rules=logic_rules)
|
||||
partial(format_logic, rules=logic_rules),
|
||||
)
|
||||
|
|
|
@ -89,7 +89,9 @@ def action_use(item: str, target: str) -> str:
|
|||
"""
|
||||
_, 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:
|
||||
return f"The {item} item is not available to use."
|
||||
|
|
|
@ -1,11 +1,14 @@
|
|||
from json import dumps
|
||||
from readline import add_history
|
||||
from logging import getLogger
|
||||
from queue import Queue
|
||||
from readline import add_history
|
||||
from typing import Any, Callable, Dict, List, Sequence
|
||||
|
||||
from langchain_core.messages import AIMessage, BaseMessage, HumanMessage
|
||||
from packit.utils import could_be_json
|
||||
|
||||
logger = getLogger(__name__)
|
||||
|
||||
|
||||
class BasePlayer:
|
||||
"""
|
||||
|
@ -95,6 +98,8 @@ class LocalPlayer(BasePlayer):
|
|||
Ask the player for input.
|
||||
"""
|
||||
|
||||
logger.info("prompting local player: {self.name}")
|
||||
|
||||
formatted_prompt = prompt.format(**kwargs)
|
||||
self.memory.append(HumanMessage(content=formatted_prompt))
|
||||
print(formatted_prompt)
|
||||
|
@ -109,7 +114,9 @@ class RemotePlayer(BasePlayer):
|
|||
input_queue: Queue[str]
|
||||
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)
|
||||
self.input_queue = Queue()
|
||||
self.send_prompt = send_prompt
|
||||
|
@ -123,11 +130,12 @@ class RemotePlayer(BasePlayer):
|
|||
self.memory.append(HumanMessage(content=formatted_prompt))
|
||||
|
||||
try:
|
||||
logger.info(f"prompting remote player: {self.name}")
|
||||
if self.send_prompt(self.name, formatted_prompt):
|
||||
reply = self.input_queue.get(timeout=60)
|
||||
logger.info(f"got reply from remote player: {reply}")
|
||||
return self.parse_input(reply)
|
||||
except Exception:
|
||||
pass
|
||||
logger.exception("error getting reply from remote player")
|
||||
|
||||
# logger.warning("Failed to send prompt to remote player")
|
||||
return ""
|
||||
|
|
|
@ -3,11 +3,13 @@ from collections import deque
|
|||
from json import dumps, loads
|
||||
from logging import getLogger
|
||||
from threading import Thread
|
||||
from typing import Dict, Tuple
|
||||
from typing import Dict
|
||||
from uuid import uuid4
|
||||
|
||||
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.player import RemotePlayer
|
||||
from adventure.state import snapshot_world, world_json
|
||||
|
@ -16,20 +18,28 @@ logger = getLogger(__name__)
|
|||
|
||||
connected = set()
|
||||
characters: Dict[str, RemotePlayer] = {}
|
||||
previous_agents: Dict[str, Agent] = {}
|
||||
recent_events = deque(maxlen=100)
|
||||
recent_world = None
|
||||
|
||||
|
||||
async def handler(websocket):
|
||||
logger.info("Client connected")
|
||||
id = uuid4().hex
|
||||
logger.info("Client connected, given id: %s", id)
|
||||
connected.add(websocket)
|
||||
|
||||
async def next_turn(character: str, prompt: str) -> None:
|
||||
await websocket.send(connected, dumps({
|
||||
"type": "turn",
|
||||
await websocket.send(
|
||||
dumps(
|
||||
{
|
||||
"type": "prompt",
|
||||
"id": id,
|
||||
"character": character,
|
||||
"prompt": prompt,
|
||||
}))
|
||||
"actions": [],
|
||||
}
|
||||
),
|
||||
)
|
||||
|
||||
def sync_turn(character: str, prompt: str) -> bool:
|
||||
if websocket not in characters:
|
||||
|
@ -39,6 +49,8 @@ async def handler(websocket):
|
|||
return True
|
||||
|
||||
try:
|
||||
await websocket.send(dumps({"type": "id", "id": id}))
|
||||
|
||||
if recent_world:
|
||||
await websocket.send(recent_world)
|
||||
|
||||
|
@ -55,26 +67,41 @@ async def handler(websocket):
|
|||
|
||||
try:
|
||||
data = loads(message)
|
||||
if "become" in data:
|
||||
message_type = data.get("type", None)
|
||||
if message_type == "player":
|
||||
character = characters.get(websocket)
|
||||
if character:
|
||||
del characters[websocket]
|
||||
del characters[id]
|
||||
|
||||
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:
|
||||
logger.error(f"Failed to find actor {character_name}")
|
||||
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")
|
||||
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}")
|
||||
elif websocket in characters:
|
||||
player = characters[websocket]
|
||||
player.input_queue.put(message)
|
||||
|
||||
# swap out the LLM agent
|
||||
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:
|
||||
logger.exception("Failed to parse message")
|
||||
|
@ -83,9 +110,14 @@ async def handler(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:
|
||||
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")
|
||||
|
||||
|
|
|
@ -1,30 +1,31 @@
|
|||
/* 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 { Maybe, doesExist } from '@apextoaster/js-utils';
|
||||
import List from '@mui/material/List';
|
||||
import ListItem from '@mui/material/ListItem';
|
||||
import Divider from '@mui/material/Divider';
|
||||
import ListItemText from '@mui/material/ListItemText';
|
||||
import ListItemAvatar from '@mui/material/ListItemAvatar';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import Avatar from '@mui/material/Avatar';
|
||||
import Container from '@mui/material/Container';
|
||||
import Stack from '@mui/material/Stack';
|
||||
import Alert from '@mui/material/Alert';
|
||||
import Switch from '@mui/material/Switch';
|
||||
import { SimpleTreeView } from '@mui/x-tree-view/SimpleTreeView';
|
||||
import { TreeItem } from '@mui/x-tree-view/TreeItem';
|
||||
import { Button, CssBaseline, Dialog, DialogContent, DialogTitle, PaletteMode, ThemeProvider, createTheme } from '@mui/material';
|
||||
import {
|
||||
Button,
|
||||
CssBaseline,
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogTitle,
|
||||
PaletteMode,
|
||||
ThemeProvider,
|
||||
createTheme,
|
||||
List,
|
||||
Divider,
|
||||
Typography,
|
||||
Container,
|
||||
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;
|
||||
|
||||
export interface EventItemProps {
|
||||
event: any;
|
||||
}
|
||||
|
||||
const statusStrings = {
|
||||
[ReadyState.CONNECTING]: 'Connecting',
|
||||
[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);
|
||||
}
|
||||
|
||||
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 {
|
||||
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 }) {
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
if (!doesExist(props.entity)) {
|
||||
|
@ -251,11 +72,28 @@ export function DetailDialog(props: { setDetails: SetDetails; details: Maybe<Ite
|
|||
}
|
||||
|
||||
export function App(props: AppProps) {
|
||||
const [ activeTurn, setActiveTurn ] = useState<boolean>(false);
|
||||
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 [ themeMode, setThemeMode ] = useState('light');
|
||||
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({
|
||||
palette: {
|
||||
|
@ -268,12 +106,37 @@ export function App(props: AppProps) {
|
|||
useEffect(() => {
|
||||
if (doesExist(lastMessage)) {
|
||||
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));
|
||||
|
||||
// if we get a world event, update the last world state
|
||||
if (data.type === '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]);
|
||||
|
||||
|
@ -298,7 +161,10 @@ export function App(props: AppProps) {
|
|||
</Stack>
|
||||
</Alert>
|
||||
<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 }}>
|
||||
<List sx={{ width: '100%', bgcolor: 'background.paper' }}>
|
||||
{interleave(items)}
|
||||
|
|
|
@ -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>;
|
||||
}
|
||||
}
|
|
@ -13,8 +13,12 @@ export function formatAction(data: any) {
|
|||
}
|
||||
|
||||
export function formatInput(data: any) {
|
||||
try {
|
||||
const action = formatAction(JSON.parse(data.input));
|
||||
return `Starting turn: ${action}`;
|
||||
} catch (err) {
|
||||
return `Error parsing input: ${err}`;
|
||||
}
|
||||
}
|
||||
|
||||
export function formatResult(data: any) {
|
||||
|
|
|
@ -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>;
|
||||
}
|
|
@ -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>;
|
||||
}
|
Loading…
Reference in New Issue