solanapolis 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +518 -0
- package/bin/solanapolis.js +197 -0
- package/convex/_generated/api.d.ts +175 -0
- package/convex/_generated/api.js +23 -0
- package/convex/_generated/dataModel.d.ts +60 -0
- package/convex/_generated/server.d.ts +143 -0
- package/convex/_generated/server.js +93 -0
- package/convex/agent/conversation.ts +352 -0
- package/convex/agent/embeddingsCache.ts +110 -0
- package/convex/agent/memory.ts +450 -0
- package/convex/agent/schema.ts +53 -0
- package/convex/aiChat.ts +54 -0
- package/convex/aiTown/agent.ts +382 -0
- package/convex/aiTown/agentDescription.ts +27 -0
- package/convex/aiTown/agentInputs.ts +155 -0
- package/convex/aiTown/agentOperations.ts +178 -0
- package/convex/aiTown/conversation.ts +395 -0
- package/convex/aiTown/conversationMembership.ts +38 -0
- package/convex/aiTown/game.ts +371 -0
- package/convex/aiTown/ids.ts +32 -0
- package/convex/aiTown/inputHandler.ts +9 -0
- package/convex/aiTown/inputs.ts +25 -0
- package/convex/aiTown/insertInput.ts +20 -0
- package/convex/aiTown/location.ts +32 -0
- package/convex/aiTown/main.ts +154 -0
- package/convex/aiTown/movement.ts +189 -0
- package/convex/aiTown/player.ts +310 -0
- package/convex/aiTown/playerDescription.ts +35 -0
- package/convex/aiTown/schema.ts +79 -0
- package/convex/aiTown/world.ts +65 -0
- package/convex/aiTown/worldMap.ts +74 -0
- package/convex/chat.ts +79 -0
- package/convex/constants.ts +78 -0
- package/convex/convex.config.ts +6 -0
- package/convex/crons.ts +89 -0
- package/convex/engine/abstractGame.ts +199 -0
- package/convex/engine/historicalObject.ts +355 -0
- package/convex/engine/schema.ts +56 -0
- package/convex/http.ts +36 -0
- package/convex/init.ts +110 -0
- package/convex/messages.ts +53 -0
- package/convex/npcCarAgents.ts +415 -0
- package/convex/schema.ts +61 -0
- package/convex/streaming.ts +23 -0
- package/convex/testing.ts +202 -0
- package/convex/tsconfig.json +18 -0
- package/convex/util/FastIntegerCompression.ts +221 -0
- package/convex/util/assertNever.ts +4 -0
- package/convex/util/asyncMap.ts +20 -0
- package/convex/util/compression.ts +71 -0
- package/convex/util/geometry.ts +132 -0
- package/convex/util/isSimpleObject.ts +11 -0
- package/convex/util/llm.ts +724 -0
- package/convex/util/minheap.ts +38 -0
- package/convex/util/object.ts +22 -0
- package/convex/util/sleep.ts +3 -0
- package/convex/util/types.ts +33 -0
- package/convex/util/xxhash.ts +228 -0
- package/convex/world.ts +257 -0
- package/data/animations/campfire.json +45 -0
- package/data/animations/gentlesparkle.json +37 -0
- package/data/animations/gentlesplash.json +61 -0
- package/data/animations/gentlewaterfall.json +61 -0
- package/data/animations/windmill.json +78 -0
- package/data/characters.ts +121 -0
- package/data/convertMap.js +74 -0
- package/data/gentle.js +330 -0
- package/data/spritesheets/f1.ts +75 -0
- package/data/spritesheets/f2.ts +75 -0
- package/data/spritesheets/f3.ts +75 -0
- package/data/spritesheets/f4.ts +75 -0
- package/data/spritesheets/f5.ts +75 -0
- package/data/spritesheets/f6.ts +75 -0
- package/data/spritesheets/f7.ts +75 -0
- package/data/spritesheets/f8.ts +75 -0
- package/data/spritesheets/p1.ts +59 -0
- package/data/spritesheets/p2.ts +59 -0
- package/data/spritesheets/p3.ts +59 -0
- package/data/spritesheets/player.ts +59 -0
- package/data/spritesheets/types.ts +26 -0
- package/eslint.config.mjs +37 -0
- package/next.config.ts +7 -0
- package/package.json +85 -0
- package/postcss.config.mjs +7 -0
- package/public/file.svg +1 -0
- package/public/globe.svg +1 -0
- package/public/helius-icon.svg +84 -0
- package/public/helius-logo.svg +85 -0
- package/public/next.svg +1 -0
- package/public/plane.glb +0 -0
- package/public/vercel.svg +1 -0
- package/public/window.svg +1 -0
- package/scripts/clear-city.ts +74 -0
- package/scripts/seed-wallets.ts +185 -0
- package/scripts/setup-webhook.ts +73 -0
- package/src/app/api/auth/callback/route.ts +6 -0
- package/src/app/api/auth/link-wallet/route.ts +6 -0
- package/src/app/api/auth/phantom/route.ts +6 -0
- package/src/app/api/broadcast-position/route.ts +59 -0
- package/src/app/api/leaderboard/route.ts +85 -0
- package/src/app/api/network-stats/route.ts +86 -0
- package/src/app/api/parcel-reward/route.ts +181 -0
- package/src/app/api/queue-status/route.ts +30 -0
- package/src/app/api/snapshots/route.ts +37 -0
- package/src/app/api/transactions/enhanced/route.ts +57 -0
- package/src/app/api/treasury/route.ts +83 -0
- package/src/app/api/wallet/[address]/balances/route.ts +124 -0
- package/src/app/api/wallet/[address]/identity/route.ts +32 -0
- package/src/app/api/wallet/[address]/route.ts +216 -0
- package/src/app/api/wallet/[address]/traded-tokens/route.ts +41 -0
- package/src/app/api/wallets/route.ts +68 -0
- package/src/app/api/webhooks/helius/route.ts +76 -0
- package/src/app/auth/callback/page.tsx +29 -0
- package/src/app/favicon.ico +0 -0
- package/src/app/globals.css +39 -0
- package/src/app/layout.tsx +43 -0
- package/src/app/page.tsx +16 -0
- package/src/components/AITownNPCs.tsx +206 -0
- package/src/components/ActivityFeed.tsx +189 -0
- package/src/components/AuthPanel.tsx +163 -0
- package/src/components/BeachScene.tsx +280 -0
- package/src/components/Building.tsx +138 -0
- package/src/components/CesiumFlight.tsx +1768 -0
- package/src/components/CesiumGlobe.tsx +616 -0
- package/src/components/CitizenCard.tsx +442 -0
- package/src/components/CitizenCardModal.tsx +153 -0
- package/src/components/CityGrid.tsx +313 -0
- package/src/components/CityLandmarks.tsx +427 -0
- package/src/components/CityScene.tsx +1289 -0
- package/src/components/CitySlotsBadge.tsx +68 -0
- package/src/components/CockpitHUD.tsx +460 -0
- package/src/components/ConvexWrapper.tsx +19 -0
- package/src/components/DubaiDistrict.tsx +630 -0
- package/src/components/FlightMiniMap.tsx +133 -0
- package/src/components/GameChat.tsx +383 -0
- package/src/components/GameHUD.tsx +393 -0
- package/src/components/Ground.tsx +14 -0
- package/src/components/HowItWorksModal.tsx +251 -0
- package/src/components/IngestionBanner.tsx +123 -0
- package/src/components/InstancedBuildings.tsx +316 -0
- package/src/components/InstancedCars.tsx +504 -0
- package/src/components/InstancedCityPlanes.tsx +259 -0
- package/src/components/InstancedHouses.tsx +246 -0
- package/src/components/InstancedLampPosts.tsx +201 -0
- package/src/components/InstancedResidentCars.tsx +357 -0
- package/src/components/InstancedRoadDashes.tsx +42 -0
- package/src/components/InstancedSkyscrapers.tsx +434 -0
- package/src/components/InstancedTrees.tsx +67 -0
- package/src/components/LeaderboardPanel.tsx +136 -0
- package/src/components/MultiplayerPlanes.tsx +128 -0
- package/src/components/NetworkStats.tsx +83 -0
- package/src/components/NewBuildingSpotlight.tsx +93 -0
- package/src/components/ParcelChallengeBanner.tsx +242 -0
- package/src/components/ParcelReward.tsx +191 -0
- package/src/components/Park.tsx +42 -0
- package/src/components/PhantomWrapper.tsx +22 -0
- package/src/components/PixelStreamViewer.tsx +335 -0
- package/src/components/PlaneMode.tsx +190 -0
- package/src/components/PlayerCar.tsx +211 -0
- package/src/components/PlayerPlane.tsx +255 -0
- package/src/components/ProjectileRenderer.tsx +249 -0
- package/src/components/QueueStatusBanner.tsx +86 -0
- package/src/components/RealPlayerTags.tsx +82 -0
- package/src/components/SceneLighting.tsx +382 -0
- package/src/components/SelectionBeam.tsx +59 -0
- package/src/components/SwapPanel.tsx +104 -0
- package/src/components/SwapParticles.tsx +237 -0
- package/src/components/TreasureGate.tsx +505 -0
- package/src/components/WalletPanel.tsx +421 -0
- package/src/components/WalletSearch.tsx +244 -0
- package/src/components/WelcomeOverlay.tsx +135 -0
- package/src/components/WindowTooltip.tsx +498 -0
- package/src/context/AuthContext.tsx +230 -0
- package/src/lib/bot-detection.ts +125 -0
- package/src/lib/building-math.ts +136 -0
- package/src/lib/building-shader.ts +253 -0
- package/src/lib/car-paths.ts +244 -0
- package/src/lib/car-system.ts +182 -0
- package/src/lib/city-constants.ts +29 -0
- package/src/lib/city-slots.ts +35 -0
- package/src/lib/city-zoning.ts +64 -0
- package/src/lib/collision-map.ts +147 -0
- package/src/lib/day-night.ts +252 -0
- package/src/lib/export-card.ts +28 -0
- package/src/lib/helius-webhook.ts +90 -0
- package/src/lib/helius.ts +74 -0
- package/src/lib/house-shader.ts +119 -0
- package/src/lib/mock-data.ts +56 -0
- package/src/lib/multiplayer-manager.ts +329 -0
- package/src/lib/plane-physics.ts +66 -0
- package/src/lib/player-car.ts +147 -0
- package/src/lib/player-plane.ts +200 -0
- package/src/lib/projectile-system.ts +272 -0
- package/src/lib/skyscraper-types.ts +52 -0
- package/src/lib/sound-engine.ts +464 -0
- package/src/lib/supabase-admin.ts +9 -0
- package/src/lib/supabase.ts +8 -0
- package/src/lib/swap-events.ts +70 -0
- package/src/middleware.ts +37 -0
- package/src/types/phantom.d.ts +16 -0
- package/src/types/wallet.ts +20 -0
- package/tsconfig.json +34 -0
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
import { v } from 'convex/values';
|
|
2
|
+
import { internalAction } from '../_generated/server';
|
|
3
|
+
import { WorldMap, serializedWorldMap } from './worldMap';
|
|
4
|
+
import { rememberConversation } from '../agent/memory';
|
|
5
|
+
import { GameId, agentId, conversationId, playerId } from './ids';
|
|
6
|
+
import {
|
|
7
|
+
continueConversationMessage,
|
|
8
|
+
leaveConversationMessage,
|
|
9
|
+
startConversationMessage,
|
|
10
|
+
} from '../agent/conversation';
|
|
11
|
+
import { assertNever } from '../util/assertNever';
|
|
12
|
+
import { serializedAgent } from './agent';
|
|
13
|
+
import { ACTIVITIES, ACTIVITY_COOLDOWN, CONVERSATION_COOLDOWN } from '../constants';
|
|
14
|
+
import { api, internal } from '../_generated/api';
|
|
15
|
+
import { sleep } from '../util/sleep';
|
|
16
|
+
import { serializedPlayer } from './player';
|
|
17
|
+
|
|
18
|
+
export const agentRememberConversation = internalAction({
|
|
19
|
+
args: {
|
|
20
|
+
worldId: v.id('worlds'),
|
|
21
|
+
playerId,
|
|
22
|
+
agentId,
|
|
23
|
+
conversationId,
|
|
24
|
+
operationId: v.string(),
|
|
25
|
+
},
|
|
26
|
+
handler: async (ctx, args) => {
|
|
27
|
+
await rememberConversation(
|
|
28
|
+
ctx,
|
|
29
|
+
args.worldId,
|
|
30
|
+
args.agentId as GameId<'agents'>,
|
|
31
|
+
args.playerId as GameId<'players'>,
|
|
32
|
+
args.conversationId as GameId<'conversations'>,
|
|
33
|
+
);
|
|
34
|
+
await sleep(Math.random() * 1000);
|
|
35
|
+
await ctx.runMutation(api.aiTown.main.sendInput, {
|
|
36
|
+
worldId: args.worldId,
|
|
37
|
+
name: 'finishRememberConversation',
|
|
38
|
+
args: {
|
|
39
|
+
agentId: args.agentId,
|
|
40
|
+
operationId: args.operationId,
|
|
41
|
+
},
|
|
42
|
+
});
|
|
43
|
+
},
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
export const agentGenerateMessage = internalAction({
|
|
47
|
+
args: {
|
|
48
|
+
worldId: v.id('worlds'),
|
|
49
|
+
playerId,
|
|
50
|
+
agentId,
|
|
51
|
+
conversationId,
|
|
52
|
+
otherPlayerId: playerId,
|
|
53
|
+
operationId: v.string(),
|
|
54
|
+
type: v.union(v.literal('start'), v.literal('continue'), v.literal('leave')),
|
|
55
|
+
messageUuid: v.string(),
|
|
56
|
+
},
|
|
57
|
+
handler: async (ctx, args) => {
|
|
58
|
+
let completionFn;
|
|
59
|
+
switch (args.type) {
|
|
60
|
+
case 'start':
|
|
61
|
+
completionFn = startConversationMessage;
|
|
62
|
+
break;
|
|
63
|
+
case 'continue':
|
|
64
|
+
completionFn = continueConversationMessage;
|
|
65
|
+
break;
|
|
66
|
+
case 'leave':
|
|
67
|
+
completionFn = leaveConversationMessage;
|
|
68
|
+
break;
|
|
69
|
+
default:
|
|
70
|
+
assertNever(args.type);
|
|
71
|
+
}
|
|
72
|
+
const text = await completionFn(
|
|
73
|
+
ctx,
|
|
74
|
+
args.worldId,
|
|
75
|
+
args.conversationId as GameId<'conversations'>,
|
|
76
|
+
args.playerId as GameId<'players'>,
|
|
77
|
+
args.otherPlayerId as GameId<'players'>,
|
|
78
|
+
);
|
|
79
|
+
|
|
80
|
+
await ctx.runMutation(internal.aiTown.agent.agentSendMessage, {
|
|
81
|
+
worldId: args.worldId,
|
|
82
|
+
conversationId: args.conversationId,
|
|
83
|
+
agentId: args.agentId,
|
|
84
|
+
playerId: args.playerId,
|
|
85
|
+
text,
|
|
86
|
+
messageUuid: args.messageUuid,
|
|
87
|
+
leaveConversation: args.type === 'leave',
|
|
88
|
+
operationId: args.operationId,
|
|
89
|
+
});
|
|
90
|
+
},
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
export const agentDoSomething = internalAction({
|
|
94
|
+
args: {
|
|
95
|
+
worldId: v.id('worlds'),
|
|
96
|
+
player: v.object(serializedPlayer),
|
|
97
|
+
agent: v.object(serializedAgent),
|
|
98
|
+
map: v.object(serializedWorldMap),
|
|
99
|
+
otherFreePlayers: v.array(v.object(serializedPlayer)),
|
|
100
|
+
operationId: v.string(),
|
|
101
|
+
},
|
|
102
|
+
handler: async (ctx, args) => {
|
|
103
|
+
const { player, agent } = args;
|
|
104
|
+
const map = new WorldMap(args.map);
|
|
105
|
+
const now = Date.now();
|
|
106
|
+
// Don't try to start a new conversation if we were just in one.
|
|
107
|
+
const justLeftConversation =
|
|
108
|
+
agent.lastConversation && now < agent.lastConversation + CONVERSATION_COOLDOWN;
|
|
109
|
+
// Don't try again if we recently tried to find someone to invite.
|
|
110
|
+
const recentlyAttemptedInvite =
|
|
111
|
+
agent.lastInviteAttempt && now < agent.lastInviteAttempt + CONVERSATION_COOLDOWN;
|
|
112
|
+
const recentActivity = player.activity && now < player.activity.until + ACTIVITY_COOLDOWN;
|
|
113
|
+
// Decide whether to do an activity or wander somewhere.
|
|
114
|
+
if (!player.pathfinding) {
|
|
115
|
+
if (recentActivity || justLeftConversation) {
|
|
116
|
+
await sleep(Math.random() * 1000);
|
|
117
|
+
await ctx.runMutation(api.aiTown.main.sendInput, {
|
|
118
|
+
worldId: args.worldId,
|
|
119
|
+
name: 'finishDoSomething',
|
|
120
|
+
args: {
|
|
121
|
+
operationId: args.operationId,
|
|
122
|
+
agentId: agent.id,
|
|
123
|
+
destination: wanderDestination(map),
|
|
124
|
+
},
|
|
125
|
+
});
|
|
126
|
+
return;
|
|
127
|
+
} else {
|
|
128
|
+
// TODO: have LLM choose the activity & emoji
|
|
129
|
+
const activity = ACTIVITIES[Math.floor(Math.random() * ACTIVITIES.length)];
|
|
130
|
+
await sleep(Math.random() * 1000);
|
|
131
|
+
await ctx.runMutation(api.aiTown.main.sendInput, {
|
|
132
|
+
worldId: args.worldId,
|
|
133
|
+
name: 'finishDoSomething',
|
|
134
|
+
args: {
|
|
135
|
+
operationId: args.operationId,
|
|
136
|
+
agentId: agent.id,
|
|
137
|
+
activity: {
|
|
138
|
+
description: activity.description,
|
|
139
|
+
emoji: activity.emoji,
|
|
140
|
+
until: Date.now() + activity.duration,
|
|
141
|
+
},
|
|
142
|
+
},
|
|
143
|
+
});
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
const invitee =
|
|
148
|
+
justLeftConversation || recentlyAttemptedInvite
|
|
149
|
+
? undefined
|
|
150
|
+
: await ctx.runQuery(internal.aiTown.agent.findConversationCandidate, {
|
|
151
|
+
now,
|
|
152
|
+
worldId: args.worldId,
|
|
153
|
+
player: args.player,
|
|
154
|
+
otherFreePlayers: args.otherFreePlayers,
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
// TODO: We hit a lot of OCC errors on sending inputs in this file. It's
|
|
158
|
+
// easy for them to get scheduled at the same time and line up in time.
|
|
159
|
+
await sleep(Math.random() * 1000);
|
|
160
|
+
await ctx.runMutation(api.aiTown.main.sendInput, {
|
|
161
|
+
worldId: args.worldId,
|
|
162
|
+
name: 'finishDoSomething',
|
|
163
|
+
args: {
|
|
164
|
+
operationId: args.operationId,
|
|
165
|
+
agentId: args.agent.id,
|
|
166
|
+
invitee,
|
|
167
|
+
},
|
|
168
|
+
});
|
|
169
|
+
},
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
function wanderDestination(worldMap: WorldMap) {
|
|
173
|
+
// Wander someonewhere at least one tile away from the edge.
|
|
174
|
+
return {
|
|
175
|
+
x: 1 + Math.floor(Math.random() * (worldMap.width - 2)),
|
|
176
|
+
y: 1 + Math.floor(Math.random() * (worldMap.height - 2)),
|
|
177
|
+
};
|
|
178
|
+
}
|
|
@@ -0,0 +1,395 @@
|
|
|
1
|
+
import { ObjectType, v } from 'convex/values';
|
|
2
|
+
import { GameId, parseGameId } from './ids';
|
|
3
|
+
import { conversationId, playerId } from './ids';
|
|
4
|
+
import { Player } from './player';
|
|
5
|
+
import { inputHandler } from './inputHandler';
|
|
6
|
+
|
|
7
|
+
import { TYPING_TIMEOUT, CONVERSATION_DISTANCE } from '../constants';
|
|
8
|
+
import { distance, normalize, vector } from '../util/geometry';
|
|
9
|
+
import { Point } from '../util/types';
|
|
10
|
+
import { Game } from './game';
|
|
11
|
+
import { stopPlayer, blocked, movePlayer } from './movement';
|
|
12
|
+
import { ConversationMembership, serializedConversationMembership } from './conversationMembership';
|
|
13
|
+
import { parseMap, serializeMap } from '../util/object';
|
|
14
|
+
|
|
15
|
+
export class Conversation {
|
|
16
|
+
id: GameId<'conversations'>;
|
|
17
|
+
creator: GameId<'players'>;
|
|
18
|
+
created: number;
|
|
19
|
+
isTyping?: {
|
|
20
|
+
playerId: GameId<'players'>;
|
|
21
|
+
messageUuid: string;
|
|
22
|
+
since: number;
|
|
23
|
+
};
|
|
24
|
+
lastMessage?: {
|
|
25
|
+
author: GameId<'players'>;
|
|
26
|
+
timestamp: number;
|
|
27
|
+
};
|
|
28
|
+
numMessages: number;
|
|
29
|
+
participants: Map<GameId<'players'>, ConversationMembership>;
|
|
30
|
+
|
|
31
|
+
constructor(serialized: SerializedConversation) {
|
|
32
|
+
const { id, creator, created, isTyping, lastMessage, numMessages, participants } = serialized;
|
|
33
|
+
this.id = parseGameId('conversations', id);
|
|
34
|
+
this.creator = parseGameId('players', creator);
|
|
35
|
+
this.created = created;
|
|
36
|
+
this.isTyping = isTyping && {
|
|
37
|
+
playerId: parseGameId('players', isTyping.playerId),
|
|
38
|
+
messageUuid: isTyping.messageUuid,
|
|
39
|
+
since: isTyping.since,
|
|
40
|
+
};
|
|
41
|
+
this.lastMessage = lastMessage && {
|
|
42
|
+
author: parseGameId('players', lastMessage.author),
|
|
43
|
+
timestamp: lastMessage.timestamp,
|
|
44
|
+
};
|
|
45
|
+
this.numMessages = numMessages;
|
|
46
|
+
this.participants = parseMap(participants, ConversationMembership, (m) => m.playerId);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
tick(game: Game, now: number) {
|
|
50
|
+
if (this.isTyping && this.isTyping.since + TYPING_TIMEOUT < now) {
|
|
51
|
+
delete this.isTyping;
|
|
52
|
+
}
|
|
53
|
+
if (this.participants.size !== 2) {
|
|
54
|
+
console.warn(`Conversation ${this.id} has ${this.participants.size} participants`);
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
const [playerId1, playerId2] = [...this.participants.keys()];
|
|
58
|
+
const member1 = this.participants.get(playerId1)!;
|
|
59
|
+
const member2 = this.participants.get(playerId2)!;
|
|
60
|
+
|
|
61
|
+
const player1 = game.world.players.get(playerId1)!;
|
|
62
|
+
const player2 = game.world.players.get(playerId2)!;
|
|
63
|
+
|
|
64
|
+
const playerDistance = distance(player1?.position, player2?.position);
|
|
65
|
+
|
|
66
|
+
// If the players are both in the "walkingOver" state and they're sufficiently close, transition both
|
|
67
|
+
// of them to "participating" and stop their paths.
|
|
68
|
+
if (member1.status.kind === 'walkingOver' && member2.status.kind === 'walkingOver') {
|
|
69
|
+
if (playerDistance < CONVERSATION_DISTANCE) {
|
|
70
|
+
console.log(`Starting conversation between ${player1.id} and ${player2.id}`);
|
|
71
|
+
|
|
72
|
+
// First, stop the two players from moving.
|
|
73
|
+
stopPlayer(player1);
|
|
74
|
+
stopPlayer(player2);
|
|
75
|
+
|
|
76
|
+
member1.status = { kind: 'participating', started: now };
|
|
77
|
+
member2.status = { kind: 'participating', started: now };
|
|
78
|
+
|
|
79
|
+
// Try to move the first player to grid point nearest the other player.
|
|
80
|
+
const neighbors = (p: Point) => [
|
|
81
|
+
{ x: p.x + 1, y: p.y },
|
|
82
|
+
{ x: p.x - 1, y: p.y },
|
|
83
|
+
{ x: p.x, y: p.y + 1 },
|
|
84
|
+
{ x: p.x, y: p.y - 1 },
|
|
85
|
+
];
|
|
86
|
+
const floorPos1 = { x: Math.floor(player1.position.x), y: Math.floor(player1.position.y) };
|
|
87
|
+
const p1Candidates = neighbors(floorPos1).filter((p) => !blocked(game, now, p, player1.id));
|
|
88
|
+
p1Candidates.sort((a, b) => distance(a, player2.position) - distance(b, player2.position));
|
|
89
|
+
if (p1Candidates.length > 0) {
|
|
90
|
+
const p1Candidate = p1Candidates[0];
|
|
91
|
+
|
|
92
|
+
// Try to move the second player to the grid point nearest the first player's
|
|
93
|
+
// destination.
|
|
94
|
+
const p2Candidates = neighbors(p1Candidate).filter(
|
|
95
|
+
(p) => !blocked(game, now, p, player2.id),
|
|
96
|
+
);
|
|
97
|
+
p2Candidates.sort(
|
|
98
|
+
(a, b) => distance(a, player2.position) - distance(b, player2.position),
|
|
99
|
+
);
|
|
100
|
+
if (p2Candidates.length > 0) {
|
|
101
|
+
const p2Candidate = p2Candidates[0];
|
|
102
|
+
movePlayer(game, now, player1, p1Candidate, true);
|
|
103
|
+
movePlayer(game, now, player2, p2Candidate, true);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// Orient the two players towards each other if they're not moving.
|
|
110
|
+
if (member1.status.kind === 'participating' && member2.status.kind === 'participating') {
|
|
111
|
+
const v = normalize(vector(player1.position, player2.position));
|
|
112
|
+
if (!player1.pathfinding && v) {
|
|
113
|
+
player1.facing = v;
|
|
114
|
+
}
|
|
115
|
+
if (!player2.pathfinding && v) {
|
|
116
|
+
player2.facing.dx = -v.dx;
|
|
117
|
+
player2.facing.dy = -v.dy;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
static start(game: Game, now: number, player: Player, invitee: Player) {
|
|
123
|
+
if (player.id === invitee.id) {
|
|
124
|
+
throw new Error(`Can't invite yourself to a conversation`);
|
|
125
|
+
}
|
|
126
|
+
// Ensure the players still exist.
|
|
127
|
+
if ([...game.world.conversations.values()].find((c) => c.participants.has(player.id))) {
|
|
128
|
+
const reason = `Player ${player.id} is already in a conversation`;
|
|
129
|
+
console.log(reason);
|
|
130
|
+
return { error: reason };
|
|
131
|
+
}
|
|
132
|
+
if ([...game.world.conversations.values()].find((c) => c.participants.has(invitee.id))) {
|
|
133
|
+
const reason = `Player ${player.id} is already in a conversation`;
|
|
134
|
+
console.log(reason);
|
|
135
|
+
return { error: reason };
|
|
136
|
+
}
|
|
137
|
+
const conversationId = game.allocId('conversations');
|
|
138
|
+
console.log(`Creating conversation ${conversationId}`);
|
|
139
|
+
game.world.conversations.set(
|
|
140
|
+
conversationId,
|
|
141
|
+
new Conversation({
|
|
142
|
+
id: conversationId,
|
|
143
|
+
created: now,
|
|
144
|
+
creator: player.id,
|
|
145
|
+
numMessages: 0,
|
|
146
|
+
participants: [
|
|
147
|
+
{ playerId: player.id, invited: now, status: { kind: 'walkingOver' } },
|
|
148
|
+
{ playerId: invitee.id, invited: now, status: { kind: 'invited' } },
|
|
149
|
+
],
|
|
150
|
+
}),
|
|
151
|
+
);
|
|
152
|
+
return { conversationId };
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
setIsTyping(now: number, player: Player, messageUuid: string) {
|
|
156
|
+
if (this.isTyping) {
|
|
157
|
+
if (this.isTyping.playerId !== player.id) {
|
|
158
|
+
throw new Error(`Player ${this.isTyping.playerId} is already typing in ${this.id}`);
|
|
159
|
+
}
|
|
160
|
+
return;
|
|
161
|
+
}
|
|
162
|
+
this.isTyping = { playerId: player.id, messageUuid, since: now };
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
acceptInvite(game: Game, player: Player) {
|
|
166
|
+
const member = this.participants.get(player.id);
|
|
167
|
+
if (!member) {
|
|
168
|
+
throw new Error(`Player ${player.id} not in conversation ${this.id}`);
|
|
169
|
+
}
|
|
170
|
+
if (member.status.kind !== 'invited') {
|
|
171
|
+
throw new Error(
|
|
172
|
+
`Invalid membership status for ${player.id}:${this.id}: ${JSON.stringify(member)}`,
|
|
173
|
+
);
|
|
174
|
+
}
|
|
175
|
+
member.status = { kind: 'walkingOver' };
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
rejectInvite(game: Game, now: number, player: Player) {
|
|
179
|
+
const member = this.participants.get(player.id);
|
|
180
|
+
if (!member) {
|
|
181
|
+
throw new Error(`Player ${player.id} not in conversation ${this.id}`);
|
|
182
|
+
}
|
|
183
|
+
if (member.status.kind !== 'invited') {
|
|
184
|
+
throw new Error(
|
|
185
|
+
`Rejecting invite in wrong membership state: ${this.id}:${player.id}: ${JSON.stringify(
|
|
186
|
+
member,
|
|
187
|
+
)}`,
|
|
188
|
+
);
|
|
189
|
+
}
|
|
190
|
+
this.stop(game, now);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
stop(game: Game, now: number) {
|
|
194
|
+
delete this.isTyping;
|
|
195
|
+
for (const [playerId, member] of this.participants.entries()) {
|
|
196
|
+
const agent = [...game.world.agents.values()].find((a) => a.playerId === playerId);
|
|
197
|
+
if (agent) {
|
|
198
|
+
agent.lastConversation = now;
|
|
199
|
+
agent.toRemember = this.id;
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
game.world.conversations.delete(this.id);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
leave(game: Game, now: number, player: Player) {
|
|
206
|
+
const member = this.participants.get(player.id);
|
|
207
|
+
if (!member) {
|
|
208
|
+
throw new Error(`Couldn't find membership for ${this.id}:${player.id}`);
|
|
209
|
+
}
|
|
210
|
+
this.stop(game, now);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
serialize(): SerializedConversation {
|
|
214
|
+
const { id, creator, created, isTyping, lastMessage, numMessages } = this;
|
|
215
|
+
return {
|
|
216
|
+
id,
|
|
217
|
+
creator,
|
|
218
|
+
created,
|
|
219
|
+
isTyping,
|
|
220
|
+
lastMessage,
|
|
221
|
+
numMessages,
|
|
222
|
+
participants: serializeMap(this.participants),
|
|
223
|
+
};
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
export const serializedConversation = {
|
|
228
|
+
id: conversationId,
|
|
229
|
+
creator: playerId,
|
|
230
|
+
created: v.number(),
|
|
231
|
+
isTyping: v.optional(
|
|
232
|
+
v.object({
|
|
233
|
+
playerId,
|
|
234
|
+
messageUuid: v.string(),
|
|
235
|
+
since: v.number(),
|
|
236
|
+
}),
|
|
237
|
+
),
|
|
238
|
+
lastMessage: v.optional(
|
|
239
|
+
v.object({
|
|
240
|
+
author: playerId,
|
|
241
|
+
timestamp: v.number(),
|
|
242
|
+
}),
|
|
243
|
+
),
|
|
244
|
+
numMessages: v.number(),
|
|
245
|
+
participants: v.array(v.object(serializedConversationMembership)),
|
|
246
|
+
};
|
|
247
|
+
export type SerializedConversation = ObjectType<typeof serializedConversation>;
|
|
248
|
+
|
|
249
|
+
export const conversationInputs = {
|
|
250
|
+
// Start a conversation, inviting the specified player.
|
|
251
|
+
// Conversations can only have two participants for now,
|
|
252
|
+
// so we don't have a separate "invite" input.
|
|
253
|
+
startConversation: inputHandler({
|
|
254
|
+
args: {
|
|
255
|
+
playerId,
|
|
256
|
+
invitee: playerId,
|
|
257
|
+
},
|
|
258
|
+
handler: (game: Game, now: number, args): GameId<'conversations'> => {
|
|
259
|
+
const playerId = parseGameId('players', args.playerId);
|
|
260
|
+
const player = game.world.players.get(playerId);
|
|
261
|
+
if (!player) {
|
|
262
|
+
throw new Error(`Invalid player ID: ${playerId}`);
|
|
263
|
+
}
|
|
264
|
+
const inviteeId = parseGameId('players', args.invitee);
|
|
265
|
+
const invitee = game.world.players.get(inviteeId);
|
|
266
|
+
if (!invitee) {
|
|
267
|
+
throw new Error(`Invalid player ID: ${inviteeId}`);
|
|
268
|
+
}
|
|
269
|
+
console.log(`Starting ${playerId} ${inviteeId}...`);
|
|
270
|
+
const { conversationId, error } = Conversation.start(game, now, player, invitee);
|
|
271
|
+
if (!conversationId) {
|
|
272
|
+
// TODO: pass it back to the client for them to show an error.
|
|
273
|
+
throw new Error(error);
|
|
274
|
+
}
|
|
275
|
+
return conversationId;
|
|
276
|
+
},
|
|
277
|
+
}),
|
|
278
|
+
|
|
279
|
+
startTyping: inputHandler({
|
|
280
|
+
args: {
|
|
281
|
+
playerId,
|
|
282
|
+
conversationId,
|
|
283
|
+
messageUuid: v.string(),
|
|
284
|
+
},
|
|
285
|
+
handler: (game: Game, now: number, args): null => {
|
|
286
|
+
const playerId = parseGameId('players', args.playerId);
|
|
287
|
+
const player = game.world.players.get(playerId);
|
|
288
|
+
if (!player) {
|
|
289
|
+
throw new Error(`Invalid player ID: ${playerId}`);
|
|
290
|
+
}
|
|
291
|
+
const conversationId = parseGameId('conversations', args.conversationId);
|
|
292
|
+
const conversation = game.world.conversations.get(conversationId);
|
|
293
|
+
if (!conversation) {
|
|
294
|
+
throw new Error(`Invalid conversation ID: ${conversationId}`);
|
|
295
|
+
}
|
|
296
|
+
if (conversation.isTyping && conversation.isTyping.playerId !== playerId) {
|
|
297
|
+
throw new Error(
|
|
298
|
+
`Player ${conversation.isTyping.playerId} is already typing in ${conversationId}`,
|
|
299
|
+
);
|
|
300
|
+
}
|
|
301
|
+
conversation.isTyping = { playerId, messageUuid: args.messageUuid, since: now };
|
|
302
|
+
return null;
|
|
303
|
+
},
|
|
304
|
+
}),
|
|
305
|
+
|
|
306
|
+
finishSendingMessage: inputHandler({
|
|
307
|
+
args: {
|
|
308
|
+
playerId,
|
|
309
|
+
conversationId,
|
|
310
|
+
timestamp: v.number(),
|
|
311
|
+
},
|
|
312
|
+
handler: (game: Game, now: number, args): null => {
|
|
313
|
+
const playerId = parseGameId('players', args.playerId);
|
|
314
|
+
const conversationId = parseGameId('conversations', args.conversationId);
|
|
315
|
+
const conversation = game.world.conversations.get(conversationId);
|
|
316
|
+
if (!conversation) {
|
|
317
|
+
throw new Error(`Invalid conversation ID: ${conversationId}`);
|
|
318
|
+
}
|
|
319
|
+
if (conversation.isTyping && conversation.isTyping.playerId === playerId) {
|
|
320
|
+
delete conversation.isTyping;
|
|
321
|
+
}
|
|
322
|
+
conversation.lastMessage = { author: playerId, timestamp: args.timestamp };
|
|
323
|
+
conversation.numMessages++;
|
|
324
|
+
return null;
|
|
325
|
+
},
|
|
326
|
+
}),
|
|
327
|
+
|
|
328
|
+
// Accept an invite to a conversation, which puts the
|
|
329
|
+
// player in the "walkingOver" state until they're close
|
|
330
|
+
// enough to the other participant.
|
|
331
|
+
acceptInvite: inputHandler({
|
|
332
|
+
args: {
|
|
333
|
+
playerId,
|
|
334
|
+
conversationId,
|
|
335
|
+
},
|
|
336
|
+
handler: (game: Game, now: number, args): null => {
|
|
337
|
+
const playerId = parseGameId('players', args.playerId);
|
|
338
|
+
const player = game.world.players.get(playerId);
|
|
339
|
+
if (!player) {
|
|
340
|
+
throw new Error(`Invalid player ID ${playerId}`);
|
|
341
|
+
}
|
|
342
|
+
const conversationId = parseGameId('conversations', args.conversationId);
|
|
343
|
+
const conversation = game.world.conversations.get(conversationId);
|
|
344
|
+
if (!conversation) {
|
|
345
|
+
throw new Error(`Invalid conversation ID ${conversationId}`);
|
|
346
|
+
}
|
|
347
|
+
conversation.acceptInvite(game, player);
|
|
348
|
+
return null;
|
|
349
|
+
},
|
|
350
|
+
}),
|
|
351
|
+
|
|
352
|
+
// Reject the invite. Eventually we might add a message
|
|
353
|
+
// that explains why!
|
|
354
|
+
rejectInvite: inputHandler({
|
|
355
|
+
args: {
|
|
356
|
+
playerId,
|
|
357
|
+
conversationId,
|
|
358
|
+
},
|
|
359
|
+
handler: (game: Game, now: number, args): null => {
|
|
360
|
+
const playerId = parseGameId('players', args.playerId);
|
|
361
|
+
const player = game.world.players.get(playerId);
|
|
362
|
+
if (!player) {
|
|
363
|
+
throw new Error(`Invalid player ID ${playerId}`);
|
|
364
|
+
}
|
|
365
|
+
const conversationId = parseGameId('conversations', args.conversationId);
|
|
366
|
+
const conversation = game.world.conversations.get(conversationId);
|
|
367
|
+
if (!conversation) {
|
|
368
|
+
throw new Error(`Invalid conversation ID ${conversationId}`);
|
|
369
|
+
}
|
|
370
|
+
conversation.rejectInvite(game, now, player);
|
|
371
|
+
return null;
|
|
372
|
+
},
|
|
373
|
+
}),
|
|
374
|
+
// Leave a conversation.
|
|
375
|
+
leaveConversation: inputHandler({
|
|
376
|
+
args: {
|
|
377
|
+
playerId,
|
|
378
|
+
conversationId,
|
|
379
|
+
},
|
|
380
|
+
handler: (game: Game, now: number, args): null => {
|
|
381
|
+
const playerId = parseGameId('players', args.playerId);
|
|
382
|
+
const player = game.world.players.get(playerId);
|
|
383
|
+
if (!player) {
|
|
384
|
+
throw new Error(`Invalid player ID ${playerId}`);
|
|
385
|
+
}
|
|
386
|
+
const conversationId = parseGameId('conversations', args.conversationId);
|
|
387
|
+
const conversation = game.world.conversations.get(conversationId);
|
|
388
|
+
if (!conversation) {
|
|
389
|
+
throw new Error(`Invalid conversation ID ${conversationId}`);
|
|
390
|
+
}
|
|
391
|
+
conversation.leave(game, now, player);
|
|
392
|
+
return null;
|
|
393
|
+
},
|
|
394
|
+
}),
|
|
395
|
+
};
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { ObjectType, v } from 'convex/values';
|
|
2
|
+
import { GameId, parseGameId, playerId } from './ids';
|
|
3
|
+
|
|
4
|
+
export const serializedConversationMembership = {
|
|
5
|
+
playerId,
|
|
6
|
+
invited: v.number(),
|
|
7
|
+
status: v.union(
|
|
8
|
+
v.object({ kind: v.literal('invited') }),
|
|
9
|
+
v.object({ kind: v.literal('walkingOver') }),
|
|
10
|
+
v.object({ kind: v.literal('participating'), started: v.number() }),
|
|
11
|
+
),
|
|
12
|
+
};
|
|
13
|
+
export type SerializedConversationMembership = ObjectType<typeof serializedConversationMembership>;
|
|
14
|
+
|
|
15
|
+
export class ConversationMembership {
|
|
16
|
+
playerId: GameId<'players'>;
|
|
17
|
+
invited: number;
|
|
18
|
+
status:
|
|
19
|
+
| { kind: 'invited' }
|
|
20
|
+
| { kind: 'walkingOver' }
|
|
21
|
+
| { kind: 'participating'; started: number };
|
|
22
|
+
|
|
23
|
+
constructor(serialized: SerializedConversationMembership) {
|
|
24
|
+
const { playerId, invited, status } = serialized;
|
|
25
|
+
this.playerId = parseGameId('players', playerId);
|
|
26
|
+
this.invited = invited;
|
|
27
|
+
this.status = status;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
serialize(): SerializedConversationMembership {
|
|
31
|
+
const { playerId, invited, status } = this;
|
|
32
|
+
return {
|
|
33
|
+
playerId,
|
|
34
|
+
invited,
|
|
35
|
+
status,
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
}
|