Jofthomas HF staff commited on
Commit
409830c
β€’
1 Parent(s): 9bbddc7

Multiplayer with HF

Browse files
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
- <FreezeButton />
 
 
 
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
+ });