Spaces:
Running
on
CPU Upgrade
Running
on
CPU Upgrade
Multiplayer with HF
Browse files- Dockerfile +9 -0
- README.md +1 -0
- patches/App.tsx +6 -1
- patches/InteractButton.tsx +88 -0
- patches/OAuthLogin.tsx +57 -0
- patches/PixiGame.tsx +132 -0
- patches/PlayerDetails.tsx +265 -0
- patches/hf.svg +18 -0
- patches/run.sh +6 -0
- patches/world.ts +273 -0
Dockerfile
CHANGED
@@ -22,6 +22,7 @@ RUN git clone https://github.com/a16z-infra/ai-town.git . && \
|
|
22 |
git checkout f005c46d1759b47bb3ade8d41952a713c4faf331
|
23 |
|
24 |
RUN npm install --include=dev @huggingface/inference
|
|
|
25 |
|
26 |
RUN curl -L -O https://github.com/get-convex/convex-backend/releases/download/precompiled-2024-05-07-13337fd/convex-local-backend-x86_64-unknown-linux-gnu.zip && \
|
27 |
unzip convex-local-backend-x86_64-unknown-linux-gnu.zip
|
@@ -30,9 +31,17 @@ COPY ./patches/llm.ts ./convex/util/
|
|
30 |
COPY ./patches/vite.config.ts ./
|
31 |
COPY ./patches/constants.ts ./patches/music.ts ./convex/
|
32 |
COPY ./patches/characters.ts ./patches/gentle.js ./data/
|
|
|
33 |
COPY ./patches/PixiStaticMap.tsx ./src/components/
|
34 |
COPY ./patches/Button.tsx ./src/components/buttons/Button.tsx
|
|
|
|
|
35 |
COPY ./patches/App.tsx ./src/App.tsx
|
|
|
|
|
|
|
|
|
|
|
36 |
COPY ./patches/run.sh ./
|
37 |
|
38 |
CMD ["./run.sh"]
|
|
|
22 |
git checkout f005c46d1759b47bb3ade8d41952a713c4faf331
|
23 |
|
24 |
RUN npm install --include=dev @huggingface/inference
|
25 |
+
RUN npm install --include=dev @huggingface/hub
|
26 |
|
27 |
RUN curl -L -O https://github.com/get-convex/convex-backend/releases/download/precompiled-2024-05-07-13337fd/convex-local-backend-x86_64-unknown-linux-gnu.zip && \
|
28 |
unzip convex-local-backend-x86_64-unknown-linux-gnu.zip
|
|
|
31 |
COPY ./patches/vite.config.ts ./
|
32 |
COPY ./patches/constants.ts ./patches/music.ts ./convex/
|
33 |
COPY ./patches/characters.ts ./patches/gentle.js ./data/
|
34 |
+
COPY ./patches/PixiGame.tsx ./src/components/PixiGame.tsx
|
35 |
COPY ./patches/PixiStaticMap.tsx ./src/components/
|
36 |
COPY ./patches/Button.tsx ./src/components/buttons/Button.tsx
|
37 |
+
COPY ./patches/InteractButton.tsx ./src/components/buttons/InteractButton.tsx
|
38 |
+
COPY ./patches/OAuthLogin.tsx ./src/components/buttons/OAuthLogin.tsx
|
39 |
COPY ./patches/App.tsx ./src/App.tsx
|
40 |
+
COPY ./patches/world.ts ./convex/world.ts
|
41 |
+
COPY ./patches/PlayerDetails.tsx ./src/components/PlayerDetails.tsx
|
42 |
+
|
43 |
+
COPY ./patches/hf.svg ./assets/hf.svg
|
44 |
+
|
45 |
COPY ./patches/run.sh ./
|
46 |
|
47 |
CMD ["./run.sh"]
|
README.md
CHANGED
@@ -9,6 +9,7 @@ pinned: false
|
|
9 |
disable_embedding: true
|
10 |
# header: mini
|
11 |
short_description: AI Town on HuggingFace
|
|
|
12 |
---
|
13 |
|
14 |
# AI Town π π»π on Hugging Face π€
|
|
|
9 |
disable_embedding: true
|
10 |
# header: mini
|
11 |
short_description: AI Town on HuggingFace
|
12 |
+
hf_oauth: true
|
13 |
---
|
14 |
|
15 |
# AI Town π π»π on Hugging Face π€
|
patches/App.tsx
CHANGED
@@ -16,6 +16,7 @@ import InteractButton from './components/buttons/InteractButton.tsx';
|
|
16 |
import FreezeButton from './components/FreezeButton.tsx';
|
17 |
import { MAX_HUMAN_PLAYERS } from '../convex/constants.ts';
|
18 |
import PoweredByConvex from './components/PoweredByConvex.tsx';
|
|
|
19 |
|
20 |
export default function Home() {
|
21 |
const [helpModalOpen, setHelpModalOpen] = useState(false);
|
@@ -90,7 +91,10 @@ export default function Home() {
|
|
90 |
|
91 |
<footer className="justify-end bottom-0 left-0 w-full flex items-center mt-4 gap-3 p-6 flex-wrap pointer-events-none">
|
92 |
<div className="flex gap-4 flex-grow pointer-events-none">
|
93 |
-
|
|
|
|
|
|
|
94 |
<MusicButton />
|
95 |
<Button href="https://github.com/a16z-infra/ai-town" imgUrl={starImg}>
|
96 |
Star
|
@@ -99,6 +103,7 @@ export default function Home() {
|
|
99 |
<Button imgUrl={helpImg} onClick={() => setHelpModalOpen(true)}>
|
100 |
Help
|
101 |
</Button>
|
|
|
102 |
</div>
|
103 |
<a href="https://a16z.com" target="_blank">
|
104 |
<img className="w-8 h-8 pointer-events-auto" src={a16zImg} alt="a16z" />
|
|
|
16 |
import FreezeButton from './components/FreezeButton.tsx';
|
17 |
import { MAX_HUMAN_PLAYERS } from '../convex/constants.ts';
|
18 |
import PoweredByConvex from './components/PoweredByConvex.tsx';
|
19 |
+
import OAuthLogin from './components/buttons/OAuthLogin.tsx';
|
20 |
|
21 |
export default function Home() {
|
22 |
const [helpModalOpen, setHelpModalOpen] = useState(false);
|
|
|
91 |
|
92 |
<footer className="justify-end bottom-0 left-0 w-full flex items-center mt-4 gap-3 p-6 flex-wrap pointer-events-none">
|
93 |
<div className="flex gap-4 flex-grow pointer-events-none">
|
94 |
+
{/*
|
95 |
+
Users shall not be able freeze in multiplayer
|
96 |
+
<FreezeButton />
|
97 |
+
*/}
|
98 |
<MusicButton />
|
99 |
<Button href="https://github.com/a16z-infra/ai-town" imgUrl={starImg}>
|
100 |
Star
|
|
|
103 |
<Button imgUrl={helpImg} onClick={() => setHelpModalOpen(true)}>
|
104 |
Help
|
105 |
</Button>
|
106 |
+
<OAuthLogin />
|
107 |
</div>
|
108 |
<a href="https://a16z.com" target="_blank">
|
109 |
<img className="w-8 h-8 pointer-events-auto" src={a16zImg} alt="a16z" />
|
patches/InteractButton.tsx
ADDED
@@ -0,0 +1,88 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import Button from './Button';
|
2 |
+
import { toast } from 'react-toastify';
|
3 |
+
import interactImg from '../../../assets/interact.svg';
|
4 |
+
import { useConvex, useMutation, useQuery } from 'convex/react';
|
5 |
+
import { api } from '../../../convex/_generated/api';
|
6 |
+
// import { SignInButton } from '@clerk/clerk-react';
|
7 |
+
import { ConvexError } from 'convex/values';
|
8 |
+
import { Id } from '../../../convex/_generated/dataModel';
|
9 |
+
import { useCallback } from 'react';
|
10 |
+
import { waitForInput } from '../../hooks/sendInput';
|
11 |
+
import { useServerGame } from '../../hooks/serverGame';
|
12 |
+
|
13 |
+
export default function InteractButton() {
|
14 |
+
// const { isAuthenticated } = useConvexAuth();
|
15 |
+
const worldStatus = useQuery(api.world.defaultWorldStatus);
|
16 |
+
const worldId = worldStatus?.worldId;
|
17 |
+
const game = useServerGame(worldId);
|
18 |
+
const oauth = JSON.parse(localStorage.getItem('oauth'));
|
19 |
+
const oauthToken = oauth ? oauth.userInfo.fullname : undefined;
|
20 |
+
console.log(oauthToken)
|
21 |
+
const humanTokenIdentifier = useQuery(api.world.userStatus, worldId ? { worldId, oauthToken } : 'skip');
|
22 |
+
const userPlayerId =
|
23 |
+
game && [...game.world.players.values()].find((p) => p.human === humanTokenIdentifier)?.id;
|
24 |
+
const join = useMutation(api.world.joinWorld);
|
25 |
+
const leave = useMutation(api.world.leaveWorld);
|
26 |
+
const isPlaying = !!userPlayerId;
|
27 |
+
|
28 |
+
const convex = useConvex();
|
29 |
+
const joinInput = useCallback(
|
30 |
+
async (worldId: Id<'worlds'>) => {
|
31 |
+
let inputId;
|
32 |
+
try {
|
33 |
+
inputId = await join({ worldId, oauthToken });
|
34 |
+
} catch (e: any) {
|
35 |
+
if (e instanceof ConvexError) {
|
36 |
+
toast.error(e.data);
|
37 |
+
return;
|
38 |
+
}
|
39 |
+
throw e;
|
40 |
+
}
|
41 |
+
try {
|
42 |
+
await waitForInput(convex, inputId);
|
43 |
+
} catch (e: any) {
|
44 |
+
toast.error(e.message);
|
45 |
+
}
|
46 |
+
},
|
47 |
+
[convex, join, oauthToken],
|
48 |
+
);
|
49 |
+
|
50 |
+
|
51 |
+
const joinOrLeaveGame = () => {
|
52 |
+
if (
|
53 |
+
!worldId ||
|
54 |
+
// || !isAuthenticated
|
55 |
+
game === undefined
|
56 |
+
) {
|
57 |
+
return;
|
58 |
+
}
|
59 |
+
if (isPlaying) {
|
60 |
+
console.log(`Leaving game for player ${userPlayerId}`);
|
61 |
+
void leave({ worldId , oauthToken});
|
62 |
+
} else {
|
63 |
+
console.log(`Joining game`);
|
64 |
+
void joinInput(worldId);
|
65 |
+
}
|
66 |
+
};
|
67 |
+
// if (!isAuthenticated || game === undefined) {
|
68 |
+
// return (
|
69 |
+
// <SignInButton>
|
70 |
+
// <button className="button text-white shadow-solid text-2xl pointer-events-auto">
|
71 |
+
// <div className="inline-block bg-clay-700">
|
72 |
+
// <span>
|
73 |
+
// <div className="inline-flex h-full items-center gap-4">
|
74 |
+
// <img className="w-4 h-4 sm:w-[30px] sm:h-[30px]" src={interactImg} />
|
75 |
+
// Interact
|
76 |
+
// </div>
|
77 |
+
// </span>
|
78 |
+
// </div>
|
79 |
+
// </button>
|
80 |
+
// </SignInButton>
|
81 |
+
// );
|
82 |
+
// }
|
83 |
+
return (
|
84 |
+
<Button imgUrl={interactImg} onClick={joinOrLeaveGame}>
|
85 |
+
{isPlaying ? 'Leave' : 'Interact'}
|
86 |
+
</Button>
|
87 |
+
);
|
88 |
+
}
|
patches/OAuthLogin.tsx
ADDED
@@ -0,0 +1,57 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import { useState, useEffect } from 'react';
|
2 |
+
import Button from './Button';
|
3 |
+
import hf from '../../../assets/hf.svg';
|
4 |
+
import { oauthLoginUrl, oauthHandleRedirectIfPresent } from '@huggingface/hub';
|
5 |
+
|
6 |
+
const OAuthLogin = () => {
|
7 |
+
const [isSignedIn, setIsSignedIn] = useState(false);
|
8 |
+
|
9 |
+
useEffect(() => {
|
10 |
+
const checkAuthStatus = async () => {
|
11 |
+
let oauthResult = localStorage.getItem('oauth');
|
12 |
+
if (oauthResult) {
|
13 |
+
try {
|
14 |
+
oauthResult = JSON.parse(oauthResult);
|
15 |
+
} catch {
|
16 |
+
oauthResult = null;
|
17 |
+
}
|
18 |
+
}
|
19 |
+
|
20 |
+
if (!oauthResult) {
|
21 |
+
oauthResult = await oauthHandleRedirectIfPresent();
|
22 |
+
if (oauthResult) {
|
23 |
+
localStorage.setItem('oauth', JSON.stringify(oauthResult));
|
24 |
+
}
|
25 |
+
}
|
26 |
+
|
27 |
+
setIsSignedIn(!!oauthResult);
|
28 |
+
};
|
29 |
+
|
30 |
+
checkAuthStatus();
|
31 |
+
}, []);
|
32 |
+
|
33 |
+
const handleSignIn = async () => {
|
34 |
+
let clientId = import.meta.env.VITE_OAUTH_CLIENT_ID;
|
35 |
+
window.location.href = await oauthLoginUrl({ clientId });
|
36 |
+
};
|
37 |
+
|
38 |
+
const handleSignOut = () => {
|
39 |
+
localStorage.removeItem('oauth');
|
40 |
+
window.location.href = window.location.href.replace(/\?.*$/, '');
|
41 |
+
setIsSignedIn(false);
|
42 |
+
};
|
43 |
+
|
44 |
+
return (
|
45 |
+
<>
|
46 |
+
{isSignedIn ? (
|
47 |
+
<Button id="signout" imgUrl={hf} onClick={handleSignOut}>Sign out</Button>
|
48 |
+
) : (
|
49 |
+
<Button id="signin" imgUrl={hf} onClick={handleSignIn}>
|
50 |
+
Sign in with Hugging Face
|
51 |
+
</Button>
|
52 |
+
)}
|
53 |
+
</>
|
54 |
+
);
|
55 |
+
};
|
56 |
+
|
57 |
+
export default OAuthLogin;
|
patches/PixiGame.tsx
ADDED
@@ -0,0 +1,132 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import * as PIXI from 'pixi.js';
|
2 |
+
import { useApp } from '@pixi/react';
|
3 |
+
import { Player, SelectElement } from './Player.tsx';
|
4 |
+
import { useEffect, useRef, useState } from 'react';
|
5 |
+
import { PixiStaticMap } from './PixiStaticMap.tsx';
|
6 |
+
import PixiViewport from './PixiViewport.tsx';
|
7 |
+
import { Viewport } from 'pixi-viewport';
|
8 |
+
import { Id } from '../../convex/_generated/dataModel';
|
9 |
+
import { useQuery } from 'convex/react';
|
10 |
+
import { api } from '../../convex/_generated/api.js';
|
11 |
+
import { useSendInput } from '../hooks/sendInput.ts';
|
12 |
+
import { toastOnError } from '../toasts.ts';
|
13 |
+
import { DebugPath } from './DebugPath.tsx';
|
14 |
+
import { PositionIndicator } from './PositionIndicator.tsx';
|
15 |
+
import { SHOW_DEBUG_UI } from './Game.tsx';
|
16 |
+
import { ServerGame } from '../hooks/serverGame.ts';
|
17 |
+
|
18 |
+
export const PixiGame = (props: {
|
19 |
+
worldId: Id<'worlds'>;
|
20 |
+
engineId: Id<'engines'>;
|
21 |
+
game: ServerGame;
|
22 |
+
historicalTime: number | undefined;
|
23 |
+
width: number;
|
24 |
+
height: number;
|
25 |
+
setSelectedElement: SelectElement;
|
26 |
+
}) => {
|
27 |
+
// PIXI setup.
|
28 |
+
const pixiApp = useApp();
|
29 |
+
const viewportRef = useRef<Viewport | undefined>();
|
30 |
+
const oauth = JSON.parse(localStorage.getItem('oauth'));
|
31 |
+
const oauthToken = oauth ? oauth.userInfo.fullname : undefined;
|
32 |
+
const humanTokenIdentifier = useQuery(api.world.userStatus, { worldId: props.worldId, oauthToken }) ?? null;
|
33 |
+
const humanPlayerId = [...props.game.world.players.values()].find(
|
34 |
+
(p) => p.human === humanTokenIdentifier,
|
35 |
+
)?.id;
|
36 |
+
|
37 |
+
const moveTo = useSendInput(props.engineId, 'moveTo');
|
38 |
+
|
39 |
+
// Interaction for clicking on the world to navigate.
|
40 |
+
const dragStart = useRef<{ screenX: number; screenY: number } | null>(null);
|
41 |
+
const onMapPointerDown = (e: any) => {
|
42 |
+
// https://pixijs.download/dev/docs/PIXI.FederatedPointerEvent.html
|
43 |
+
dragStart.current = { screenX: e.screenX, screenY: e.screenY };
|
44 |
+
};
|
45 |
+
|
46 |
+
const [lastDestination, setLastDestination] = useState<{
|
47 |
+
x: number;
|
48 |
+
y: number;
|
49 |
+
t: number;
|
50 |
+
} | null>(null);
|
51 |
+
const onMapPointerUp = async (e: any) => {
|
52 |
+
if (dragStart.current) {
|
53 |
+
const { screenX, screenY } = dragStart.current;
|
54 |
+
dragStart.current = null;
|
55 |
+
const [dx, dy] = [screenX - e.screenX, screenY - e.screenY];
|
56 |
+
const dist = Math.sqrt(dx * dx + dy * dy);
|
57 |
+
if (dist > 10) {
|
58 |
+
console.log(`Skipping navigation on drag event (${dist}px)`);
|
59 |
+
return;
|
60 |
+
}
|
61 |
+
}
|
62 |
+
if (!humanPlayerId) {
|
63 |
+
return;
|
64 |
+
}
|
65 |
+
const viewport = viewportRef.current;
|
66 |
+
if (!viewport) {
|
67 |
+
return;
|
68 |
+
}
|
69 |
+
const gameSpacePx = viewport.toWorld(e.screenX, e.screenY);
|
70 |
+
const tileDim = props.game.worldMap.tileDim;
|
71 |
+
const gameSpaceTiles = {
|
72 |
+
x: gameSpacePx.x / tileDim,
|
73 |
+
y: gameSpacePx.y / tileDim,
|
74 |
+
};
|
75 |
+
setLastDestination({ t: Date.now(), ...gameSpaceTiles });
|
76 |
+
const roundedTiles = {
|
77 |
+
x: Math.floor(gameSpaceTiles.x),
|
78 |
+
y: Math.floor(gameSpaceTiles.y),
|
79 |
+
};
|
80 |
+
console.log(`Moving to ${JSON.stringify(roundedTiles)}`);
|
81 |
+
await toastOnError(moveTo({ playerId: humanPlayerId, destination: roundedTiles }));
|
82 |
+
};
|
83 |
+
const { width, height, tileDim } = props.game.worldMap;
|
84 |
+
const players = [...props.game.world.players.values()];
|
85 |
+
|
86 |
+
// Zoom on the userβs avatar when it is created
|
87 |
+
useEffect(() => {
|
88 |
+
if (!viewportRef.current || humanPlayerId === undefined) return;
|
89 |
+
|
90 |
+
const humanPlayer = props.game.world.players.get(humanPlayerId)!;
|
91 |
+
viewportRef.current.animate({
|
92 |
+
position: new PIXI.Point(humanPlayer.position.x * tileDim, humanPlayer.position.y * tileDim),
|
93 |
+
scale: 1.5,
|
94 |
+
});
|
95 |
+
}, [humanPlayerId]);
|
96 |
+
|
97 |
+
return (
|
98 |
+
<PixiViewport
|
99 |
+
app={pixiApp}
|
100 |
+
screenWidth={props.width}
|
101 |
+
screenHeight={props.height}
|
102 |
+
worldWidth={width * tileDim}
|
103 |
+
worldHeight={height * tileDim}
|
104 |
+
viewportRef={viewportRef}
|
105 |
+
>
|
106 |
+
<PixiStaticMap
|
107 |
+
map={props.game.worldMap}
|
108 |
+
onpointerup={onMapPointerUp}
|
109 |
+
onpointerdown={onMapPointerDown}
|
110 |
+
/>
|
111 |
+
{players.map(
|
112 |
+
(p) =>
|
113 |
+
// Only show the path for the human player in non-debug mode.
|
114 |
+
(SHOW_DEBUG_UI || p.id === humanPlayerId) && (
|
115 |
+
<DebugPath key={`path-${p.id}`} player={p} tileDim={tileDim} />
|
116 |
+
),
|
117 |
+
)}
|
118 |
+
{lastDestination && <PositionIndicator destination={lastDestination} tileDim={tileDim} />}
|
119 |
+
{players.map((p) => (
|
120 |
+
<Player
|
121 |
+
key={`player-${p.id}`}
|
122 |
+
game={props.game}
|
123 |
+
player={p}
|
124 |
+
isViewer={p.id === humanPlayerId}
|
125 |
+
onClick={props.setSelectedElement}
|
126 |
+
historicalTime={props.historicalTime}
|
127 |
+
/>
|
128 |
+
))}
|
129 |
+
</PixiViewport>
|
130 |
+
);
|
131 |
+
};
|
132 |
+
export default PixiGame;
|
patches/PlayerDetails.tsx
ADDED
@@ -0,0 +1,265 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import { useQuery } from 'convex/react';
|
2 |
+
import { api } from '../../convex/_generated/api';
|
3 |
+
import { Id } from '../../convex/_generated/dataModel';
|
4 |
+
import closeImg from '../../assets/close.svg';
|
5 |
+
import { SelectElement } from './Player';
|
6 |
+
import { Messages } from './Messages';
|
7 |
+
import { toastOnError } from '../toasts';
|
8 |
+
import { useSendInput } from '../hooks/sendInput';
|
9 |
+
import { Player } from '../../convex/aiTown/player';
|
10 |
+
import { GameId } from '../../convex/aiTown/ids';
|
11 |
+
import { ServerGame } from '../hooks/serverGame';
|
12 |
+
|
13 |
+
export default function PlayerDetails({
|
14 |
+
worldId,
|
15 |
+
engineId,
|
16 |
+
game,
|
17 |
+
playerId,
|
18 |
+
setSelectedElement,
|
19 |
+
scrollViewRef,
|
20 |
+
}: {
|
21 |
+
worldId: Id<'worlds'>;
|
22 |
+
engineId: Id<'engines'>;
|
23 |
+
game: ServerGame;
|
24 |
+
playerId?: GameId<'players'>;
|
25 |
+
setSelectedElement: SelectElement;
|
26 |
+
scrollViewRef: React.RefObject<HTMLDivElement>;
|
27 |
+
}) {
|
28 |
+
const oauth = JSON.parse(localStorage.getItem('oauth'));
|
29 |
+
const oauthToken = oauth ? oauth.userInfo.fullname : undefined;
|
30 |
+
const humanTokenIdentifier = useQuery(api.world.userStatus, { worldId, oauthToken });
|
31 |
+
|
32 |
+
const players = [...game.world.players.values()];
|
33 |
+
const humanPlayer = players.find((p) => p.human === humanTokenIdentifier);
|
34 |
+
const humanConversation = humanPlayer ? game.world.playerConversation(humanPlayer) : undefined;
|
35 |
+
// Always select the other player if we're in a conversation with them.
|
36 |
+
if (humanPlayer && humanConversation) {
|
37 |
+
const otherPlayerIds = [...humanConversation.participants.keys()].filter(
|
38 |
+
(p) => p !== humanPlayer.id,
|
39 |
+
);
|
40 |
+
playerId = otherPlayerIds[0];
|
41 |
+
}
|
42 |
+
|
43 |
+
const player = playerId && game.world.players.get(playerId);
|
44 |
+
const playerConversation = player && game.world.playerConversation(player);
|
45 |
+
|
46 |
+
const previousConversation = useQuery(
|
47 |
+
api.world.previousConversation,
|
48 |
+
playerId ? { worldId, playerId } : 'skip',
|
49 |
+
);
|
50 |
+
|
51 |
+
const playerDescription = playerId && game.playerDescriptions.get(playerId);
|
52 |
+
|
53 |
+
const startConversation = useSendInput(engineId, 'startConversation');
|
54 |
+
const acceptInvite = useSendInput(engineId, 'acceptInvite');
|
55 |
+
const rejectInvite = useSendInput(engineId, 'rejectInvite');
|
56 |
+
const leaveConversation = useSendInput(engineId, 'leaveConversation');
|
57 |
+
|
58 |
+
if (!playerId) {
|
59 |
+
return (
|
60 |
+
<div className="h-full text-xl flex text-center items-center p-4">
|
61 |
+
Click on an agent on the map to see chat history.
|
62 |
+
</div>
|
63 |
+
);
|
64 |
+
}
|
65 |
+
if (!player) {
|
66 |
+
return null;
|
67 |
+
}
|
68 |
+
const isMe = humanPlayer && player.id === humanPlayer.id;
|
69 |
+
const canInvite = !isMe && !playerConversation && humanPlayer && !humanConversation;
|
70 |
+
const sameConversation =
|
71 |
+
!isMe &&
|
72 |
+
humanPlayer &&
|
73 |
+
humanConversation &&
|
74 |
+
playerConversation &&
|
75 |
+
humanConversation.id === playerConversation.id;
|
76 |
+
|
77 |
+
const humanStatus =
|
78 |
+
humanPlayer && humanConversation && humanConversation.participants.get(humanPlayer.id)?.status;
|
79 |
+
const playerStatus = playerConversation && playerConversation.participants.get(playerId)?.status;
|
80 |
+
|
81 |
+
const haveInvite = sameConversation && humanStatus?.kind === 'invited';
|
82 |
+
const waitingForAccept =
|
83 |
+
sameConversation && playerConversation.participants.get(playerId)?.status.kind === 'invited';
|
84 |
+
const waitingForNearby =
|
85 |
+
sameConversation && playerStatus?.kind === 'walkingOver' && humanStatus?.kind === 'walkingOver';
|
86 |
+
|
87 |
+
const inConversationWithMe =
|
88 |
+
sameConversation &&
|
89 |
+
playerStatus?.kind === 'participating' &&
|
90 |
+
humanStatus?.kind === 'participating';
|
91 |
+
|
92 |
+
const onStartConversation = async () => {
|
93 |
+
if (!humanPlayer || !playerId) {
|
94 |
+
return;
|
95 |
+
}
|
96 |
+
console.log(`Starting conversation`);
|
97 |
+
await toastOnError(startConversation({ playerId: humanPlayer.id, invitee: playerId }));
|
98 |
+
};
|
99 |
+
const onAcceptInvite = async () => {
|
100 |
+
if (!humanPlayer || !humanConversation || !playerId) {
|
101 |
+
return;
|
102 |
+
}
|
103 |
+
await toastOnError(
|
104 |
+
acceptInvite({
|
105 |
+
playerId: humanPlayer.id,
|
106 |
+
conversationId: humanConversation.id,
|
107 |
+
}),
|
108 |
+
);
|
109 |
+
};
|
110 |
+
const onRejectInvite = async () => {
|
111 |
+
if (!humanPlayer || !humanConversation) {
|
112 |
+
return;
|
113 |
+
}
|
114 |
+
await toastOnError(
|
115 |
+
rejectInvite({
|
116 |
+
playerId: humanPlayer.id,
|
117 |
+
conversationId: humanConversation.id,
|
118 |
+
}),
|
119 |
+
);
|
120 |
+
};
|
121 |
+
const onLeaveConversation = async () => {
|
122 |
+
if (!humanPlayer || !inConversationWithMe || !humanConversation) {
|
123 |
+
return;
|
124 |
+
}
|
125 |
+
await toastOnError(
|
126 |
+
leaveConversation({
|
127 |
+
playerId: humanPlayer.id,
|
128 |
+
conversationId: humanConversation.id,
|
129 |
+
}),
|
130 |
+
);
|
131 |
+
};
|
132 |
+
// const pendingSuffix = (inputName: string) =>
|
133 |
+
// [...inflightInputs.values()].find((i) => i.name === inputName) ? ' opacity-50' : '';
|
134 |
+
|
135 |
+
const pendingSuffix = (s: string) => '';
|
136 |
+
return (
|
137 |
+
<>
|
138 |
+
<div className="flex gap-4">
|
139 |
+
<div className="box w-3/4 sm:w-full mr-auto">
|
140 |
+
<h2 className="bg-brown-700 p-2 font-display text-2xl sm:text-4xl tracking-wider shadow-solid text-center">
|
141 |
+
{playerDescription?.name}
|
142 |
+
</h2>
|
143 |
+
</div>
|
144 |
+
<a
|
145 |
+
className="button text-white shadow-solid text-2xl cursor-pointer pointer-events-auto"
|
146 |
+
onClick={() => setSelectedElement(undefined)}
|
147 |
+
>
|
148 |
+
<h2 className="h-full bg-clay-700">
|
149 |
+
<img className="w-4 h-4 sm:w-5 sm:h-5" src={closeImg} />
|
150 |
+
</h2>
|
151 |
+
</a>
|
152 |
+
</div>
|
153 |
+
{canInvite && (
|
154 |
+
<a
|
155 |
+
className={
|
156 |
+
'mt-6 button text-white shadow-solid text-xl cursor-pointer pointer-events-auto' +
|
157 |
+
pendingSuffix('startConversation')
|
158 |
+
}
|
159 |
+
onClick={onStartConversation}
|
160 |
+
>
|
161 |
+
<div className="h-full bg-clay-700 text-center">
|
162 |
+
<span>Start conversation</span>
|
163 |
+
</div>
|
164 |
+
</a>
|
165 |
+
)}
|
166 |
+
{waitingForAccept && (
|
167 |
+
<a className="mt-6 button text-white shadow-solid text-xl cursor-pointer pointer-events-auto opacity-50">
|
168 |
+
<div className="h-full bg-clay-700 text-center">
|
169 |
+
<span>Waiting for accept...</span>
|
170 |
+
</div>
|
171 |
+
</a>
|
172 |
+
)}
|
173 |
+
{waitingForNearby && (
|
174 |
+
<a className="mt-6 button text-white shadow-solid text-xl cursor-pointer pointer-events-auto opacity-50">
|
175 |
+
<div className="h-full bg-clay-700 text-center">
|
176 |
+
<span>Walking over...</span>
|
177 |
+
</div>
|
178 |
+
</a>
|
179 |
+
)}
|
180 |
+
{inConversationWithMe && (
|
181 |
+
<a
|
182 |
+
className={
|
183 |
+
'mt-6 button text-white shadow-solid text-xl cursor-pointer pointer-events-auto' +
|
184 |
+
pendingSuffix('leaveConversation')
|
185 |
+
}
|
186 |
+
onClick={onLeaveConversation}
|
187 |
+
>
|
188 |
+
<div className="h-full bg-clay-700 text-center">
|
189 |
+
<span>Leave conversation</span>
|
190 |
+
</div>
|
191 |
+
</a>
|
192 |
+
)}
|
193 |
+
{haveInvite && (
|
194 |
+
<>
|
195 |
+
<a
|
196 |
+
className={
|
197 |
+
'mt-6 button text-white shadow-solid text-xl cursor-pointer pointer-events-auto' +
|
198 |
+
pendingSuffix('acceptInvite')
|
199 |
+
}
|
200 |
+
onClick={onAcceptInvite}
|
201 |
+
>
|
202 |
+
<div className="h-full bg-clay-700 text-center">
|
203 |
+
<span>Accept</span>
|
204 |
+
</div>
|
205 |
+
</a>
|
206 |
+
<a
|
207 |
+
className={
|
208 |
+
'mt-6 button text-white shadow-solid text-xl cursor-pointer pointer-events-auto' +
|
209 |
+
pendingSuffix('rejectInvite')
|
210 |
+
}
|
211 |
+
onClick={onRejectInvite}
|
212 |
+
>
|
213 |
+
<div className="h-full bg-clay-700 text-center">
|
214 |
+
<span>Reject</span>
|
215 |
+
</div>
|
216 |
+
</a>
|
217 |
+
</>
|
218 |
+
)}
|
219 |
+
{!playerConversation && player.activity && player.activity.until > Date.now() && (
|
220 |
+
<div className="box flex-grow mt-6">
|
221 |
+
<h2 className="bg-brown-700 text-base sm:text-lg text-center">
|
222 |
+
{player.activity.description}
|
223 |
+
</h2>
|
224 |
+
</div>
|
225 |
+
)}
|
226 |
+
<div className="desc my-6">
|
227 |
+
<p className="leading-tight -m-4 bg-brown-700 text-base sm:text-sm">
|
228 |
+
{!isMe && playerDescription?.description}
|
229 |
+
{isMe && <i>This is you!</i>}
|
230 |
+
{!isMe && inConversationWithMe && (
|
231 |
+
<>
|
232 |
+
<br />
|
233 |
+
<br />(<i>Conversing with you!</i>)
|
234 |
+
</>
|
235 |
+
)}
|
236 |
+
</p>
|
237 |
+
</div>
|
238 |
+
{!isMe && playerConversation && playerStatus?.kind === 'participating' && (
|
239 |
+
<Messages
|
240 |
+
worldId={worldId}
|
241 |
+
engineId={engineId}
|
242 |
+
inConversationWithMe={inConversationWithMe ?? false}
|
243 |
+
conversation={{ kind: 'active', doc: playerConversation }}
|
244 |
+
humanPlayer={humanPlayer}
|
245 |
+
scrollViewRef={scrollViewRef}
|
246 |
+
/>
|
247 |
+
)}
|
248 |
+
{!playerConversation && previousConversation && (
|
249 |
+
<>
|
250 |
+
<div className="box flex-grow">
|
251 |
+
<h2 className="bg-brown-700 text-lg text-center">Previous conversation</h2>
|
252 |
+
</div>
|
253 |
+
<Messages
|
254 |
+
worldId={worldId}
|
255 |
+
engineId={engineId}
|
256 |
+
inConversationWithMe={false}
|
257 |
+
conversation={{ kind: 'archived', doc: previousConversation }}
|
258 |
+
humanPlayer={humanPlayer}
|
259 |
+
scrollViewRef={scrollViewRef}
|
260 |
+
/>
|
261 |
+
</>
|
262 |
+
)}
|
263 |
+
</>
|
264 |
+
);
|
265 |
+
}
|
patches/hf.svg
ADDED
patches/run.sh
CHANGED
@@ -17,5 +17,11 @@ else
|
|
17 |
export VITE_CONVEX_URL=https://$SPACE_HOST/backend.convex.cloud
|
18 |
fi
|
19 |
|
|
|
|
|
|
|
|
|
|
|
|
|
20 |
npm run dev:frontend -- --host 0.0.0.0 &
|
21 |
run_convex_command dev
|
|
|
17 |
export VITE_CONVEX_URL=https://$SPACE_HOST/backend.convex.cloud
|
18 |
fi
|
19 |
|
20 |
+
export VITE_OAUTH_CLIENT_ID=$OAUTH_CLIENT_ID
|
21 |
+
# Unsure if the following are necessary
|
22 |
+
# export OAUTH_CLIENT_SECRET=$OAUTH_CLIENT_SECRET
|
23 |
+
# export OAUTH_SCOPES=$OAUTH_SCOPES
|
24 |
+
# export OPENID_PROVIDER_URL=$OPENID_PROVIDER_URL
|
25 |
+
|
26 |
npm run dev:frontend -- --host 0.0.0.0 &
|
27 |
run_convex_command dev
|
patches/world.ts
ADDED
@@ -0,0 +1,273 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import { ConvexError, v } from 'convex/values';
|
2 |
+
import { internalMutation, mutation, query } from './_generated/server';
|
3 |
+
import { characters } from '../data/characters';
|
4 |
+
import { Descriptions } from '../data/characters';
|
5 |
+
import { insertInput } from './aiTown/insertInput';
|
6 |
+
import {
|
7 |
+
DEFAULT_NAME,
|
8 |
+
ENGINE_ACTION_DURATION,
|
9 |
+
IDLE_WORLD_TIMEOUT,
|
10 |
+
WORLD_HEARTBEAT_INTERVAL,
|
11 |
+
} from './constants';
|
12 |
+
import { playerId } from './aiTown/ids';
|
13 |
+
import { kickEngine, startEngine, stopEngine } from './aiTown/main';
|
14 |
+
import { engineInsertInput } from './engine/abstractGame';
|
15 |
+
|
16 |
+
export const defaultWorldStatus = query({
|
17 |
+
handler: async (ctx) => {
|
18 |
+
const worldStatus = await ctx.db
|
19 |
+
.query('worldStatus')
|
20 |
+
.filter((q) => q.eq(q.field('isDefault'), true))
|
21 |
+
.first();
|
22 |
+
return worldStatus;
|
23 |
+
},
|
24 |
+
});
|
25 |
+
|
26 |
+
export const heartbeatWorld = mutation({
|
27 |
+
args: {
|
28 |
+
worldId: v.id('worlds'),
|
29 |
+
},
|
30 |
+
handler: async (ctx, args) => {
|
31 |
+
const worldStatus = await ctx.db
|
32 |
+
.query('worldStatus')
|
33 |
+
.withIndex('worldId', (q) => q.eq('worldId', args.worldId))
|
34 |
+
.first();
|
35 |
+
if (!worldStatus) {
|
36 |
+
throw new Error(`Invalid world ID: ${args.worldId}`);
|
37 |
+
}
|
38 |
+
const now = Date.now();
|
39 |
+
|
40 |
+
// Skip the update (and then potentially make the transaction readonly)
|
41 |
+
// if it's been viewed sufficiently recently..
|
42 |
+
if (!worldStatus.lastViewed || worldStatus.lastViewed < now - WORLD_HEARTBEAT_INTERVAL / 2) {
|
43 |
+
await ctx.db.patch(worldStatus._id, {
|
44 |
+
lastViewed: Math.max(worldStatus.lastViewed ?? now, now),
|
45 |
+
});
|
46 |
+
}
|
47 |
+
|
48 |
+
// Restart inactive worlds, but leave worlds explicitly stopped by the developer alone.
|
49 |
+
if (worldStatus.status === 'stoppedByDeveloper') {
|
50 |
+
console.debug(`World ${worldStatus._id} is stopped by developer, not restarting.`);
|
51 |
+
}
|
52 |
+
if (worldStatus.status === 'inactive') {
|
53 |
+
console.log(`Restarting inactive world ${worldStatus._id}...`);
|
54 |
+
await ctx.db.patch(worldStatus._id, { status: 'running' });
|
55 |
+
await startEngine(ctx, worldStatus.worldId);
|
56 |
+
}
|
57 |
+
},
|
58 |
+
});
|
59 |
+
|
60 |
+
export const stopInactiveWorlds = internalMutation({
|
61 |
+
handler: async (ctx) => {
|
62 |
+
const cutoff = Date.now() - IDLE_WORLD_TIMEOUT;
|
63 |
+
const worlds = await ctx.db.query('worldStatus').collect();
|
64 |
+
for (const worldStatus of worlds) {
|
65 |
+
if (cutoff < worldStatus.lastViewed || worldStatus.status !== 'running') {
|
66 |
+
continue;
|
67 |
+
}
|
68 |
+
console.log(`Stopping inactive world ${worldStatus._id}`);
|
69 |
+
await ctx.db.patch(worldStatus._id, { status: 'inactive' });
|
70 |
+
await stopEngine(ctx, worldStatus.worldId);
|
71 |
+
}
|
72 |
+
},
|
73 |
+
});
|
74 |
+
|
75 |
+
export const restartDeadWorlds = internalMutation({
|
76 |
+
handler: async (ctx) => {
|
77 |
+
const now = Date.now();
|
78 |
+
|
79 |
+
// Restart an engine if it hasn't run for 2x its action duration.
|
80 |
+
const engineTimeout = now - ENGINE_ACTION_DURATION * 2;
|
81 |
+
const worlds = await ctx.db.query('worldStatus').collect();
|
82 |
+
for (const worldStatus of worlds) {
|
83 |
+
if (worldStatus.status !== 'running') {
|
84 |
+
continue;
|
85 |
+
}
|
86 |
+
const engine = await ctx.db.get(worldStatus.engineId);
|
87 |
+
if (!engine) {
|
88 |
+
throw new Error(`Invalid engine ID: ${worldStatus.engineId}`);
|
89 |
+
}
|
90 |
+
if (engine.currentTime && engine.currentTime < engineTimeout) {
|
91 |
+
console.warn(`Restarting dead engine ${engine._id}...`);
|
92 |
+
await kickEngine(ctx, worldStatus.worldId);
|
93 |
+
}
|
94 |
+
}
|
95 |
+
},
|
96 |
+
});
|
97 |
+
|
98 |
+
export const userStatus = query({
|
99 |
+
args: {
|
100 |
+
worldId: v.id('worlds'),
|
101 |
+
oauthToken: v.optional(v.string()),
|
102 |
+
|
103 |
+
},
|
104 |
+
handler: async (ctx, args) => {
|
105 |
+
const { worldId, oauthToken } = args;
|
106 |
+
|
107 |
+
if (!oauthToken) {
|
108 |
+
return null;
|
109 |
+
}
|
110 |
+
console.log("oauthToken", oauthToken)
|
111 |
+
return oauthToken;
|
112 |
+
},
|
113 |
+
});
|
114 |
+
|
115 |
+
export const joinWorld = mutation({
|
116 |
+
args: {
|
117 |
+
worldId: v.id('worlds'),
|
118 |
+
oauthToken: v.optional(v.string()),
|
119 |
+
|
120 |
+
},
|
121 |
+
handler: async (ctx, args) => {
|
122 |
+
const { worldId, oauthToken } = args;
|
123 |
+
|
124 |
+
if (!oauthToken) {
|
125 |
+
throw new ConvexError(`Not logged in`);
|
126 |
+
}
|
127 |
+
// if (!identity) {
|
128 |
+
// throw new ConvexError(`Not logged in`);
|
129 |
+
// }
|
130 |
+
// const name =
|
131 |
+
// identity.givenName || identity.nickname || (identity.email && identity.email.split('@')[0]);
|
132 |
+
const name = oauthToken;
|
133 |
+
|
134 |
+
// if (!name) {
|
135 |
+
// throw new ConvexError(`Missing name on ${JSON.stringify(identity)}`);
|
136 |
+
// }
|
137 |
+
const world = await ctx.db.get(args.worldId);
|
138 |
+
if (!world) {
|
139 |
+
throw new ConvexError(`Invalid world ID: ${args.worldId}`);
|
140 |
+
}
|
141 |
+
// Select a random character description
|
142 |
+
const randomCharacter = Descriptions[Math.floor(Math.random() * Descriptions.length)];
|
143 |
+
|
144 |
+
return await insertInput(ctx, world._id, 'join', {
|
145 |
+
name: oauthToken,
|
146 |
+
character: randomCharacter.character,
|
147 |
+
description: "This is you !",
|
148 |
+
tokenIdentifier: oauthToken,
|
149 |
+
});
|
150 |
+
},
|
151 |
+
});
|
152 |
+
|
153 |
+
|
154 |
+
export const leaveWorld = mutation({
|
155 |
+
args: {
|
156 |
+
worldId: v.id('worlds'),
|
157 |
+
oauthToken: v.optional(v.string()),
|
158 |
+
},
|
159 |
+
handler: async (ctx, args) => {
|
160 |
+
const { worldId, oauthToken } = args;
|
161 |
+
|
162 |
+
|
163 |
+
console.log('OAuth Name:', oauthToken);
|
164 |
+
if (!oauthToken) {
|
165 |
+
throw new ConvexError(`Not logged in`);
|
166 |
+
}
|
167 |
+
|
168 |
+
const world = await ctx.db.get(args.worldId);
|
169 |
+
if (!world) {
|
170 |
+
throw new Error(`Invalid world ID: ${args.worldId}`);
|
171 |
+
}
|
172 |
+
// const existingPlayer = world.players.find((p) => p.human === tokenIdentifier);
|
173 |
+
const existingPlayer = world.players.find((p) => p.human === oauthToken);
|
174 |
+
if (!existingPlayer) {
|
175 |
+
return;
|
176 |
+
}
|
177 |
+
await insertInput(ctx, world._id, 'leave', {
|
178 |
+
playerId: existingPlayer.id,
|
179 |
+
});
|
180 |
+
},
|
181 |
+
});
|
182 |
+
|
183 |
+
export const sendWorldInput = mutation({
|
184 |
+
args: {
|
185 |
+
engineId: v.id('engines'),
|
186 |
+
name: v.string(),
|
187 |
+
args: v.any(),
|
188 |
+
},
|
189 |
+
handler: async (ctx, args) => {
|
190 |
+
// const identity = await ctx.auth.getUserIdentity();
|
191 |
+
// if (!identity) {
|
192 |
+
// throw new Error(`Not logged in`);
|
193 |
+
// }
|
194 |
+
return await engineInsertInput(ctx, args.engineId, args.name as any, args.args);
|
195 |
+
},
|
196 |
+
});
|
197 |
+
|
198 |
+
export const worldState = query({
|
199 |
+
args: {
|
200 |
+
worldId: v.id('worlds'),
|
201 |
+
},
|
202 |
+
handler: async (ctx, args) => {
|
203 |
+
const world = await ctx.db.get(args.worldId);
|
204 |
+
if (!world) {
|
205 |
+
throw new Error(`Invalid world ID: ${args.worldId}`);
|
206 |
+
}
|
207 |
+
const worldStatus = await ctx.db
|
208 |
+
.query('worldStatus')
|
209 |
+
.withIndex('worldId', (q) => q.eq('worldId', world._id))
|
210 |
+
.unique();
|
211 |
+
if (!worldStatus) {
|
212 |
+
throw new Error(`Invalid world status ID: ${world._id}`);
|
213 |
+
}
|
214 |
+
const engine = await ctx.db.get(worldStatus.engineId);
|
215 |
+
if (!engine) {
|
216 |
+
throw new Error(`Invalid engine ID: ${worldStatus.engineId}`);
|
217 |
+
}
|
218 |
+
return { world, engine };
|
219 |
+
},
|
220 |
+
});
|
221 |
+
|
222 |
+
export const gameDescriptions = query({
|
223 |
+
args: {
|
224 |
+
worldId: v.id('worlds'),
|
225 |
+
},
|
226 |
+
handler: async (ctx, args) => {
|
227 |
+
const playerDescriptions = await ctx.db
|
228 |
+
.query('playerDescriptions')
|
229 |
+
.withIndex('worldId', (q) => q.eq('worldId', args.worldId))
|
230 |
+
.collect();
|
231 |
+
const agentDescriptions = await ctx.db
|
232 |
+
.query('agentDescriptions')
|
233 |
+
.withIndex('worldId', (q) => q.eq('worldId', args.worldId))
|
234 |
+
.collect();
|
235 |
+
const worldMap = await ctx.db
|
236 |
+
.query('maps')
|
237 |
+
.withIndex('worldId', (q) => q.eq('worldId', args.worldId))
|
238 |
+
.first();
|
239 |
+
if (!worldMap) {
|
240 |
+
throw new Error(`No map for world: ${args.worldId}`);
|
241 |
+
}
|
242 |
+
return { worldMap, playerDescriptions, agentDescriptions };
|
243 |
+
},
|
244 |
+
});
|
245 |
+
|
246 |
+
export const previousConversation = query({
|
247 |
+
args: {
|
248 |
+
worldId: v.id('worlds'),
|
249 |
+
playerId,
|
250 |
+
},
|
251 |
+
handler: async (ctx, args) => {
|
252 |
+
// Walk the player's history in descending order, looking for a nonempty
|
253 |
+
// conversation.
|
254 |
+
const members = ctx.db
|
255 |
+
.query('participatedTogether')
|
256 |
+
.withIndex('playerHistory', (q) => q.eq('worldId', args.worldId).eq('player1', args.playerId))
|
257 |
+
.order('desc');
|
258 |
+
|
259 |
+
for await (const member of members) {
|
260 |
+
const conversation = await ctx.db
|
261 |
+
.query('archivedConversations')
|
262 |
+
.withIndex('worldId', (q) => q.eq('worldId', args.worldId).eq('id', member.conversationId))
|
263 |
+
.unique();
|
264 |
+
if (!conversation) {
|
265 |
+
throw new Error(`Invalid conversation ID: ${member.conversationId}`);
|
266 |
+
}
|
267 |
+
if (conversation.numMessages > 0) {
|
268 |
+
return conversation;
|
269 |
+
}
|
270 |
+
}
|
271 |
+
return null;
|
272 |
+
},
|
273 |
+
});
|