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/
|
worlds/
|
||||||
__pycache__/
|
__pycache__/
|
||||||
.env
|
.env
|
||||||
|
|
|
@ -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,13 +65,15 @@ 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
|
||||||
|
|
||||||
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)
|
logger.debug("logic did not match attributes: %s", rule.match)
|
||||||
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),
|
||||||
)
|
)
|
||||||
|
|
|
@ -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."
|
||||||
|
|
|
@ -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 ""
|
||||||
|
|
|
@ -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")
|
||||||
|
|
||||||
|
|
|
@ -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)}
|
||||||
|
|
|
@ -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) {
|
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) {
|
||||||
|
|
|
@ -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