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,189 @@
|
|
|
1
|
+
import { movementSpeed } from '../../data/characters';
|
|
2
|
+
import { COLLISION_THRESHOLD } from '../constants';
|
|
3
|
+
import { compressPath, distance, manhattanDistance, pointsEqual } from '../util/geometry';
|
|
4
|
+
import { MinHeap } from '../util/minheap';
|
|
5
|
+
import { Point, Vector } from '../util/types';
|
|
6
|
+
import { Game } from './game';
|
|
7
|
+
import { GameId } from './ids';
|
|
8
|
+
import { Player } from './player';
|
|
9
|
+
import { WorldMap } from './worldMap';
|
|
10
|
+
|
|
11
|
+
type PathCandidate = {
|
|
12
|
+
position: Point;
|
|
13
|
+
facing?: Vector;
|
|
14
|
+
t: number;
|
|
15
|
+
length: number;
|
|
16
|
+
cost: number;
|
|
17
|
+
prev?: PathCandidate;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
export function stopPlayer(player: Player) {
|
|
21
|
+
delete player.pathfinding;
|
|
22
|
+
player.speed = 0;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function movePlayer(
|
|
26
|
+
game: Game,
|
|
27
|
+
now: number,
|
|
28
|
+
player: Player,
|
|
29
|
+
destination: Point,
|
|
30
|
+
allowInConversation?: boolean,
|
|
31
|
+
) {
|
|
32
|
+
if (Math.floor(destination.x) !== destination.x || Math.floor(destination.y) !== destination.y) {
|
|
33
|
+
throw new Error(`Non-integral destination: ${JSON.stringify(destination)}`);
|
|
34
|
+
}
|
|
35
|
+
const { position } = player;
|
|
36
|
+
// Close enough to current position or destination => no-op.
|
|
37
|
+
if (pointsEqual(position, destination)) {
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
// Don't allow players in a conversation to move.
|
|
41
|
+
const inConversation = [...game.world.conversations.values()].some(
|
|
42
|
+
(c) => c.participants.get(player.id)?.status.kind === 'participating',
|
|
43
|
+
);
|
|
44
|
+
if (inConversation && !allowInConversation) {
|
|
45
|
+
throw new Error(`Can't move when in a conversation. Leave the conversation first!`);
|
|
46
|
+
}
|
|
47
|
+
player.pathfinding = {
|
|
48
|
+
destination: destination,
|
|
49
|
+
started: now,
|
|
50
|
+
state: {
|
|
51
|
+
kind: 'needsPath',
|
|
52
|
+
},
|
|
53
|
+
};
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function findRoute(game: Game, now: number, player: Player, destination: Point) {
|
|
58
|
+
const minDistances: PathCandidate[][] = [];
|
|
59
|
+
const explore = (current: PathCandidate): Array<PathCandidate> => {
|
|
60
|
+
const { x, y } = current.position;
|
|
61
|
+
const neighbors = [];
|
|
62
|
+
|
|
63
|
+
// If we're not on a grid point, first try to move horizontally
|
|
64
|
+
// or vertically to a grid point. Note that this can create very small
|
|
65
|
+
// deltas between the current position and the nearest grid point so
|
|
66
|
+
// be careful to preserve the `facing` vectors rather than trying to
|
|
67
|
+
// derive them anew.
|
|
68
|
+
if (x !== Math.floor(x)) {
|
|
69
|
+
neighbors.push(
|
|
70
|
+
{ position: { x: Math.floor(x), y }, facing: { dx: -1, dy: 0 } },
|
|
71
|
+
{ position: { x: Math.floor(x) + 1, y }, facing: { dx: 1, dy: 0 } },
|
|
72
|
+
);
|
|
73
|
+
}
|
|
74
|
+
if (y !== Math.floor(y)) {
|
|
75
|
+
neighbors.push(
|
|
76
|
+
{ position: { x, y: Math.floor(y) }, facing: { dx: 0, dy: -1 } },
|
|
77
|
+
{ position: { x, y: Math.floor(y) + 1 }, facing: { dx: 0, dy: 1 } },
|
|
78
|
+
);
|
|
79
|
+
}
|
|
80
|
+
// Otherwise, just move to adjacent grid points.
|
|
81
|
+
if (x == Math.floor(x) && y == Math.floor(y)) {
|
|
82
|
+
neighbors.push(
|
|
83
|
+
{ position: { x: x + 1, y }, facing: { dx: 1, dy: 0 } },
|
|
84
|
+
{ position: { x: x - 1, y }, facing: { dx: -1, dy: 0 } },
|
|
85
|
+
{ position: { x, y: y + 1 }, facing: { dx: 0, dy: 1 } },
|
|
86
|
+
{ position: { x, y: y - 1 }, facing: { dx: 0, dy: -1 } },
|
|
87
|
+
);
|
|
88
|
+
}
|
|
89
|
+
const next = [];
|
|
90
|
+
for (const { position, facing } of neighbors) {
|
|
91
|
+
const segmentLength = distance(current.position, position);
|
|
92
|
+
const length = current.length + segmentLength;
|
|
93
|
+
if (blocked(game, now, position, player.id)) {
|
|
94
|
+
continue;
|
|
95
|
+
}
|
|
96
|
+
const remaining = manhattanDistance(position, destination);
|
|
97
|
+
const path = {
|
|
98
|
+
position,
|
|
99
|
+
facing,
|
|
100
|
+
// Movement speed is in tiles per second.
|
|
101
|
+
t: current.t + (segmentLength / movementSpeed) * 1000,
|
|
102
|
+
length,
|
|
103
|
+
cost: length + remaining,
|
|
104
|
+
prev: current,
|
|
105
|
+
};
|
|
106
|
+
const existingMin = minDistances[position.y]?.[position.x];
|
|
107
|
+
if (existingMin && existingMin.cost <= path.cost) {
|
|
108
|
+
continue;
|
|
109
|
+
}
|
|
110
|
+
minDistances[position.y] ??= [];
|
|
111
|
+
minDistances[position.y][position.x] = path;
|
|
112
|
+
next.push(path);
|
|
113
|
+
}
|
|
114
|
+
return next;
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
const startingLocation = player.position;
|
|
118
|
+
const startingPosition = { x: startingLocation.x, y: startingLocation.y };
|
|
119
|
+
let current: PathCandidate | undefined = {
|
|
120
|
+
position: startingPosition,
|
|
121
|
+
facing: player.facing,
|
|
122
|
+
t: now,
|
|
123
|
+
length: 0,
|
|
124
|
+
cost: manhattanDistance(startingPosition, destination),
|
|
125
|
+
prev: undefined,
|
|
126
|
+
};
|
|
127
|
+
let bestCandidate = current;
|
|
128
|
+
const minheap = MinHeap<PathCandidate>((p0, p1) => p0.cost > p1.cost);
|
|
129
|
+
while (current) {
|
|
130
|
+
if (pointsEqual(current.position, destination)) {
|
|
131
|
+
break;
|
|
132
|
+
}
|
|
133
|
+
if (
|
|
134
|
+
manhattanDistance(current.position, destination) <
|
|
135
|
+
manhattanDistance(bestCandidate.position, destination)
|
|
136
|
+
) {
|
|
137
|
+
bestCandidate = current;
|
|
138
|
+
}
|
|
139
|
+
for (const candidate of explore(current)) {
|
|
140
|
+
minheap.push(candidate);
|
|
141
|
+
}
|
|
142
|
+
current = minheap.pop();
|
|
143
|
+
}
|
|
144
|
+
let newDestination = null;
|
|
145
|
+
if (!current) {
|
|
146
|
+
if (bestCandidate.length === 0) {
|
|
147
|
+
return null;
|
|
148
|
+
}
|
|
149
|
+
current = bestCandidate;
|
|
150
|
+
newDestination = current.position;
|
|
151
|
+
}
|
|
152
|
+
const densePath = [];
|
|
153
|
+
let facing = current.facing!;
|
|
154
|
+
while (current) {
|
|
155
|
+
densePath.push({ position: current.position, t: current.t, facing });
|
|
156
|
+
facing = current.facing!;
|
|
157
|
+
current = current.prev;
|
|
158
|
+
}
|
|
159
|
+
densePath.reverse();
|
|
160
|
+
|
|
161
|
+
return { path: compressPath(densePath), newDestination };
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
export function blocked(game: Game, now: number, pos: Point, playerId?: GameId<'players'>) {
|
|
165
|
+
const otherPositions = [...game.world.players.values()]
|
|
166
|
+
.filter((p) => p.id !== playerId)
|
|
167
|
+
.map((p) => p.position);
|
|
168
|
+
return blockedWithPositions(pos, otherPositions, game.worldMap);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
export function blockedWithPositions(position: Point, otherPositions: Point[], map: WorldMap) {
|
|
172
|
+
if (isNaN(position.x) || isNaN(position.y)) {
|
|
173
|
+
throw new Error(`NaN position in ${JSON.stringify(position)}`);
|
|
174
|
+
}
|
|
175
|
+
if (position.x < 0 || position.y < 0 || position.x >= map.width || position.y >= map.height) {
|
|
176
|
+
return 'out of bounds';
|
|
177
|
+
}
|
|
178
|
+
for (const layer of map.objectTiles) {
|
|
179
|
+
if (layer[Math.floor(position.x)][Math.floor(position.y)] !== -1) {
|
|
180
|
+
return 'world blocked';
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
for (const otherPosition of otherPositions) {
|
|
184
|
+
if (distance(otherPosition, position) < COLLISION_THRESHOLD) {
|
|
185
|
+
return 'player';
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
return null;
|
|
189
|
+
}
|
|
@@ -0,0 +1,310 @@
|
|
|
1
|
+
import { Infer, ObjectType, v } from 'convex/values';
|
|
2
|
+
import { Point, Vector, path, point, vector } from '../util/types';
|
|
3
|
+
import { GameId, parseGameId } from './ids';
|
|
4
|
+
import { playerId } from './ids';
|
|
5
|
+
import {
|
|
6
|
+
PATHFINDING_TIMEOUT,
|
|
7
|
+
PATHFINDING_BACKOFF,
|
|
8
|
+
HUMAN_IDLE_TOO_LONG,
|
|
9
|
+
MAX_HUMAN_PLAYERS,
|
|
10
|
+
MAX_PATHFINDS_PER_STEP,
|
|
11
|
+
} from '../constants';
|
|
12
|
+
import { pointsEqual, pathPosition } from '../util/geometry';
|
|
13
|
+
import { Game } from './game';
|
|
14
|
+
import { stopPlayer, findRoute, blocked, movePlayer } from './movement';
|
|
15
|
+
import { inputHandler } from './inputHandler';
|
|
16
|
+
import { characters } from '../../data/characters';
|
|
17
|
+
import { PlayerDescription } from './playerDescription';
|
|
18
|
+
|
|
19
|
+
const pathfinding = v.object({
|
|
20
|
+
destination: point,
|
|
21
|
+
started: v.number(),
|
|
22
|
+
state: v.union(
|
|
23
|
+
v.object({
|
|
24
|
+
kind: v.literal('needsPath'),
|
|
25
|
+
}),
|
|
26
|
+
v.object({
|
|
27
|
+
kind: v.literal('waiting'),
|
|
28
|
+
until: v.number(),
|
|
29
|
+
}),
|
|
30
|
+
v.object({
|
|
31
|
+
kind: v.literal('moving'),
|
|
32
|
+
path,
|
|
33
|
+
}),
|
|
34
|
+
),
|
|
35
|
+
});
|
|
36
|
+
export type Pathfinding = Infer<typeof pathfinding>;
|
|
37
|
+
|
|
38
|
+
export const activity = v.object({
|
|
39
|
+
description: v.string(),
|
|
40
|
+
emoji: v.optional(v.string()),
|
|
41
|
+
until: v.number(),
|
|
42
|
+
});
|
|
43
|
+
export type Activity = Infer<typeof activity>;
|
|
44
|
+
|
|
45
|
+
export const serializedPlayer = {
|
|
46
|
+
id: playerId,
|
|
47
|
+
human: v.optional(v.string()),
|
|
48
|
+
pathfinding: v.optional(pathfinding),
|
|
49
|
+
activity: v.optional(activity),
|
|
50
|
+
|
|
51
|
+
// The last time they did something.
|
|
52
|
+
lastInput: v.number(),
|
|
53
|
+
|
|
54
|
+
position: point,
|
|
55
|
+
facing: vector,
|
|
56
|
+
speed: v.number(),
|
|
57
|
+
};
|
|
58
|
+
export type SerializedPlayer = ObjectType<typeof serializedPlayer>;
|
|
59
|
+
|
|
60
|
+
export class Player {
|
|
61
|
+
id: GameId<'players'>;
|
|
62
|
+
human?: string;
|
|
63
|
+
pathfinding?: Pathfinding;
|
|
64
|
+
activity?: Activity;
|
|
65
|
+
|
|
66
|
+
lastInput: number;
|
|
67
|
+
|
|
68
|
+
position: Point;
|
|
69
|
+
facing: Vector;
|
|
70
|
+
speed: number;
|
|
71
|
+
|
|
72
|
+
constructor(serialized: SerializedPlayer) {
|
|
73
|
+
const { id, human, pathfinding, activity, lastInput, position, facing, speed } = serialized;
|
|
74
|
+
this.id = parseGameId('players', id);
|
|
75
|
+
this.human = human;
|
|
76
|
+
this.pathfinding = pathfinding;
|
|
77
|
+
this.activity = activity;
|
|
78
|
+
this.lastInput = lastInput;
|
|
79
|
+
this.position = position;
|
|
80
|
+
this.facing = facing;
|
|
81
|
+
this.speed = speed;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
tick(game: Game, now: number) {
|
|
85
|
+
if (this.human && this.lastInput < now - HUMAN_IDLE_TOO_LONG) {
|
|
86
|
+
this.leave(game, now);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
tickPathfinding(game: Game, now: number) {
|
|
91
|
+
// There's nothing to do if we're not moving.
|
|
92
|
+
const { pathfinding, position } = this;
|
|
93
|
+
if (!pathfinding) {
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// Stop pathfinding if we've reached our destination.
|
|
98
|
+
if (pathfinding.state.kind === 'moving' && pointsEqual(pathfinding.destination, position)) {
|
|
99
|
+
stopPlayer(this);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// Stop pathfinding if we've timed out.
|
|
103
|
+
if (pathfinding.started + PATHFINDING_TIMEOUT < now) {
|
|
104
|
+
console.warn(`Timing out pathfinding for ${this.id}`);
|
|
105
|
+
stopPlayer(this);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// Transition from "waiting" to "needsPath" if we're past the deadline.
|
|
109
|
+
if (pathfinding.state.kind === 'waiting' && pathfinding.state.until < now) {
|
|
110
|
+
pathfinding.state = { kind: 'needsPath' };
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// Perform pathfinding if needed.
|
|
114
|
+
if (pathfinding.state.kind === 'needsPath' && game.numPathfinds < MAX_PATHFINDS_PER_STEP) {
|
|
115
|
+
game.numPathfinds++;
|
|
116
|
+
if (game.numPathfinds === MAX_PATHFINDS_PER_STEP) {
|
|
117
|
+
console.warn(`Reached max pathfinds for this step`);
|
|
118
|
+
}
|
|
119
|
+
const route = findRoute(game, now, this, pathfinding.destination);
|
|
120
|
+
if (route === null) {
|
|
121
|
+
console.log(`Failed to route to ${JSON.stringify(pathfinding.destination)}`);
|
|
122
|
+
stopPlayer(this);
|
|
123
|
+
} else {
|
|
124
|
+
if (route.newDestination) {
|
|
125
|
+
console.warn(
|
|
126
|
+
`Updating destination from ${JSON.stringify(
|
|
127
|
+
pathfinding.destination,
|
|
128
|
+
)} to ${JSON.stringify(route.newDestination)}`,
|
|
129
|
+
);
|
|
130
|
+
pathfinding.destination = route.newDestination;
|
|
131
|
+
}
|
|
132
|
+
pathfinding.state = { kind: 'moving', path: route.path };
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
tickPosition(game: Game, now: number) {
|
|
138
|
+
// There's nothing to do if we're not moving.
|
|
139
|
+
if (!this.pathfinding || this.pathfinding.state.kind !== 'moving') {
|
|
140
|
+
this.speed = 0;
|
|
141
|
+
return;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// Compute a candidate new position and check if it collides
|
|
145
|
+
// with anything.
|
|
146
|
+
const candidate = pathPosition(this.pathfinding.state.path as any, now);
|
|
147
|
+
if (!candidate) {
|
|
148
|
+
console.warn(`Path out of range of ${now} for ${this.id}`);
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
const { position, facing, velocity } = candidate;
|
|
152
|
+
const collisionReason = blocked(game, now, position, this.id);
|
|
153
|
+
if (collisionReason !== null) {
|
|
154
|
+
const backoff = Math.random() * PATHFINDING_BACKOFF;
|
|
155
|
+
console.warn(`Stopping path for ${this.id}, waiting for ${backoff}ms: ${collisionReason}`);
|
|
156
|
+
this.pathfinding.state = {
|
|
157
|
+
kind: 'waiting',
|
|
158
|
+
until: now + backoff,
|
|
159
|
+
};
|
|
160
|
+
return;
|
|
161
|
+
}
|
|
162
|
+
// Update the player's location.
|
|
163
|
+
this.position = position;
|
|
164
|
+
this.facing = facing;
|
|
165
|
+
this.speed = velocity;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
static join(
|
|
169
|
+
game: Game,
|
|
170
|
+
now: number,
|
|
171
|
+
name: string,
|
|
172
|
+
character: string,
|
|
173
|
+
description: string,
|
|
174
|
+
tokenIdentifier?: string,
|
|
175
|
+
) {
|
|
176
|
+
if (tokenIdentifier) {
|
|
177
|
+
let numHumans = 0;
|
|
178
|
+
for (const player of game.world.players.values()) {
|
|
179
|
+
if (player.human) {
|
|
180
|
+
numHumans++;
|
|
181
|
+
}
|
|
182
|
+
if (player.human === tokenIdentifier) {
|
|
183
|
+
throw new Error(`You are already in this game!`);
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
if (numHumans >= MAX_HUMAN_PLAYERS) {
|
|
187
|
+
throw new Error(`Only ${MAX_HUMAN_PLAYERS} human players allowed at once.`);
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
let position;
|
|
191
|
+
for (let attempt = 0; attempt < 10; attempt++) {
|
|
192
|
+
const candidate = {
|
|
193
|
+
x: Math.floor(Math.random() * game.worldMap.width),
|
|
194
|
+
y: Math.floor(Math.random() * game.worldMap.height),
|
|
195
|
+
};
|
|
196
|
+
if (blocked(game, now, candidate)) {
|
|
197
|
+
continue;
|
|
198
|
+
}
|
|
199
|
+
position = candidate;
|
|
200
|
+
break;
|
|
201
|
+
}
|
|
202
|
+
if (!position) {
|
|
203
|
+
throw new Error(`Failed to find a free position!`);
|
|
204
|
+
}
|
|
205
|
+
const facingOptions = [
|
|
206
|
+
{ dx: 1, dy: 0 },
|
|
207
|
+
{ dx: -1, dy: 0 },
|
|
208
|
+
{ dx: 0, dy: 1 },
|
|
209
|
+
{ dx: 0, dy: -1 },
|
|
210
|
+
];
|
|
211
|
+
const facing = facingOptions[Math.floor(Math.random() * facingOptions.length)];
|
|
212
|
+
if (!characters.find((c) => c.name === character)) {
|
|
213
|
+
throw new Error(`Invalid character: ${character}`);
|
|
214
|
+
}
|
|
215
|
+
const playerId = game.allocId('players');
|
|
216
|
+
game.world.players.set(
|
|
217
|
+
playerId,
|
|
218
|
+
new Player({
|
|
219
|
+
id: playerId,
|
|
220
|
+
human: tokenIdentifier,
|
|
221
|
+
lastInput: now,
|
|
222
|
+
position,
|
|
223
|
+
facing,
|
|
224
|
+
speed: 0,
|
|
225
|
+
}),
|
|
226
|
+
);
|
|
227
|
+
game.playerDescriptions.set(
|
|
228
|
+
playerId,
|
|
229
|
+
new PlayerDescription({
|
|
230
|
+
playerId,
|
|
231
|
+
character,
|
|
232
|
+
description,
|
|
233
|
+
name,
|
|
234
|
+
}),
|
|
235
|
+
);
|
|
236
|
+
game.descriptionsModified = true;
|
|
237
|
+
return playerId;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
leave(game: Game, now: number) {
|
|
241
|
+
// Stop our conversation if we're leaving the game.
|
|
242
|
+
const conversation = [...game.world.conversations.values()].find((c) =>
|
|
243
|
+
c.participants.has(this.id),
|
|
244
|
+
);
|
|
245
|
+
if (conversation) {
|
|
246
|
+
conversation.stop(game, now);
|
|
247
|
+
}
|
|
248
|
+
game.world.players.delete(this.id);
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
serialize(): SerializedPlayer {
|
|
252
|
+
const { id, human, pathfinding, activity, lastInput, position, facing, speed } = this;
|
|
253
|
+
return {
|
|
254
|
+
id,
|
|
255
|
+
human,
|
|
256
|
+
pathfinding,
|
|
257
|
+
activity,
|
|
258
|
+
lastInput,
|
|
259
|
+
position,
|
|
260
|
+
facing,
|
|
261
|
+
speed,
|
|
262
|
+
};
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
export const playerInputs = {
|
|
267
|
+
join: inputHandler({
|
|
268
|
+
args: {
|
|
269
|
+
name: v.string(),
|
|
270
|
+
character: v.string(),
|
|
271
|
+
description: v.string(),
|
|
272
|
+
tokenIdentifier: v.optional(v.string()),
|
|
273
|
+
},
|
|
274
|
+
handler: (game, now, args) => {
|
|
275
|
+
Player.join(game, now, args.name, args.character, args.description, args.tokenIdentifier);
|
|
276
|
+
return null;
|
|
277
|
+
},
|
|
278
|
+
}),
|
|
279
|
+
leave: inputHandler({
|
|
280
|
+
args: { playerId },
|
|
281
|
+
handler: (game, now, args) => {
|
|
282
|
+
const playerId = parseGameId('players', args.playerId);
|
|
283
|
+
const player = game.world.players.get(playerId);
|
|
284
|
+
if (!player) {
|
|
285
|
+
throw new Error(`Invalid player ID ${playerId}`);
|
|
286
|
+
}
|
|
287
|
+
player.leave(game, now);
|
|
288
|
+
return null;
|
|
289
|
+
},
|
|
290
|
+
}),
|
|
291
|
+
moveTo: inputHandler({
|
|
292
|
+
args: {
|
|
293
|
+
playerId,
|
|
294
|
+
destination: v.union(point, v.null()),
|
|
295
|
+
},
|
|
296
|
+
handler: (game, now, args) => {
|
|
297
|
+
const playerId = parseGameId('players', args.playerId);
|
|
298
|
+
const player = game.world.players.get(playerId);
|
|
299
|
+
if (!player) {
|
|
300
|
+
throw new Error(`Invalid player ID ${playerId}`);
|
|
301
|
+
}
|
|
302
|
+
if (args.destination) {
|
|
303
|
+
movePlayer(game, now, player, args.destination);
|
|
304
|
+
} else {
|
|
305
|
+
stopPlayer(player);
|
|
306
|
+
}
|
|
307
|
+
return null;
|
|
308
|
+
},
|
|
309
|
+
}),
|
|
310
|
+
};
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { ObjectType, v } from 'convex/values';
|
|
2
|
+
import { GameId, parseGameId, playerId } from './ids';
|
|
3
|
+
|
|
4
|
+
export const serializedPlayerDescription = {
|
|
5
|
+
playerId,
|
|
6
|
+
name: v.string(),
|
|
7
|
+
description: v.string(),
|
|
8
|
+
character: v.string(),
|
|
9
|
+
};
|
|
10
|
+
export type SerializedPlayerDescription = ObjectType<typeof serializedPlayerDescription>;
|
|
11
|
+
|
|
12
|
+
export class PlayerDescription {
|
|
13
|
+
playerId: GameId<'players'>;
|
|
14
|
+
name: string;
|
|
15
|
+
description: string;
|
|
16
|
+
character: string;
|
|
17
|
+
|
|
18
|
+
constructor(serialized: SerializedPlayerDescription) {
|
|
19
|
+
const { playerId, name, description, character } = serialized;
|
|
20
|
+
this.playerId = parseGameId('players', playerId);
|
|
21
|
+
this.name = name;
|
|
22
|
+
this.description = description;
|
|
23
|
+
this.character = character;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
serialize(): SerializedPlayerDescription {
|
|
27
|
+
const { playerId, name, description, character } = this;
|
|
28
|
+
return {
|
|
29
|
+
playerId,
|
|
30
|
+
name,
|
|
31
|
+
description,
|
|
32
|
+
character,
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { v } from 'convex/values';
|
|
2
|
+
import { defineTable } from 'convex/server';
|
|
3
|
+
import { serializedPlayer } from './player';
|
|
4
|
+
import { serializedPlayerDescription } from './playerDescription';
|
|
5
|
+
import { serializedAgent } from './agent';
|
|
6
|
+
import { serializedAgentDescription } from './agentDescription';
|
|
7
|
+
import { serializedWorld } from './world';
|
|
8
|
+
import { serializedWorldMap } from './worldMap';
|
|
9
|
+
import { serializedConversation } from './conversation';
|
|
10
|
+
import { conversationId, playerId } from './ids';
|
|
11
|
+
|
|
12
|
+
export const aiTownTables = {
|
|
13
|
+
// This table has a single document that stores all players, conversations, and agents. This
|
|
14
|
+
// data is small and changes regularly over time.
|
|
15
|
+
worlds: defineTable({ ...serializedWorld }),
|
|
16
|
+
|
|
17
|
+
// Worlds can be started or stopped by the developer or paused for inactivity, and this
|
|
18
|
+
// infrequently changing document tracks this world state.
|
|
19
|
+
worldStatus: defineTable({
|
|
20
|
+
worldId: v.id('worlds'),
|
|
21
|
+
isDefault: v.boolean(),
|
|
22
|
+
engineId: v.id('engines'),
|
|
23
|
+
lastViewed: v.number(),
|
|
24
|
+
status: v.union(v.literal('running'), v.literal('stoppedByDeveloper'), v.literal('inactive')),
|
|
25
|
+
}).index('worldId', ['worldId']),
|
|
26
|
+
|
|
27
|
+
// This table contains the map data for a given world. Since it's a bit larger than the player
|
|
28
|
+
// state and infrequently changes, we store it in a separate table.
|
|
29
|
+
maps: defineTable({
|
|
30
|
+
worldId: v.id('worlds'),
|
|
31
|
+
...serializedWorldMap,
|
|
32
|
+
}).index('worldId', ['worldId']),
|
|
33
|
+
|
|
34
|
+
// Human readable text describing players and agents that's stored in separate tables, just like `maps`.
|
|
35
|
+
playerDescriptions: defineTable({
|
|
36
|
+
worldId: v.id('worlds'),
|
|
37
|
+
...serializedPlayerDescription,
|
|
38
|
+
}).index('worldId', ['worldId', 'playerId']),
|
|
39
|
+
agentDescriptions: defineTable({
|
|
40
|
+
worldId: v.id('worlds'),
|
|
41
|
+
...serializedAgentDescription,
|
|
42
|
+
}).index('worldId', ['worldId', 'agentId']),
|
|
43
|
+
|
|
44
|
+
//The game engine doesn't want to track players that have left or conversations that are over, since
|
|
45
|
+
// it wants to keep its managed state small. However, we may want to look at old conversations in the
|
|
46
|
+
// UI or from the agent code. So, whenever we delete an entry from within the world's document, we
|
|
47
|
+
// "archive" it within these tables.
|
|
48
|
+
archivedPlayers: defineTable({ worldId: v.id('worlds'), ...serializedPlayer }).index('worldId', [
|
|
49
|
+
'worldId',
|
|
50
|
+
'id',
|
|
51
|
+
]),
|
|
52
|
+
archivedConversations: defineTable({
|
|
53
|
+
worldId: v.id('worlds'),
|
|
54
|
+
id: conversationId,
|
|
55
|
+
creator: playerId,
|
|
56
|
+
created: v.number(),
|
|
57
|
+
ended: v.number(),
|
|
58
|
+
lastMessage: serializedConversation.lastMessage,
|
|
59
|
+
numMessages: serializedConversation.numMessages,
|
|
60
|
+
participants: v.array(playerId),
|
|
61
|
+
}).index('worldId', ['worldId', 'id']),
|
|
62
|
+
archivedAgents: defineTable({ worldId: v.id('worlds'), ...serializedAgent }).index('worldId', [
|
|
63
|
+
'worldId',
|
|
64
|
+
'id',
|
|
65
|
+
]),
|
|
66
|
+
|
|
67
|
+
// The agent layer wants to know what the last (completed) conversation was between two players,
|
|
68
|
+
// so this table represents a labelled graph indicating which players have talked to each other.
|
|
69
|
+
participatedTogether: defineTable({
|
|
70
|
+
worldId: v.id('worlds'),
|
|
71
|
+
conversationId,
|
|
72
|
+
player1: playerId,
|
|
73
|
+
player2: playerId,
|
|
74
|
+
ended: v.number(),
|
|
75
|
+
})
|
|
76
|
+
.index('edge', ['worldId', 'player1', 'player2', 'ended'])
|
|
77
|
+
.index('conversation', ['worldId', 'player1', 'conversationId'])
|
|
78
|
+
.index('playerHistory', ['worldId', 'player1', 'ended']),
|
|
79
|
+
};
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { ObjectType, v } from 'convex/values';
|
|
2
|
+
import { Conversation, serializedConversation } from './conversation';
|
|
3
|
+
import { Player, serializedPlayer } from './player';
|
|
4
|
+
import { Agent, serializedAgent } from './agent';
|
|
5
|
+
import { GameId, parseGameId, playerId } from './ids';
|
|
6
|
+
import { parseMap } from '../util/object';
|
|
7
|
+
|
|
8
|
+
export const historicalLocations = v.array(
|
|
9
|
+
v.object({
|
|
10
|
+
playerId,
|
|
11
|
+
location: v.bytes(),
|
|
12
|
+
}),
|
|
13
|
+
);
|
|
14
|
+
|
|
15
|
+
export const serializedWorld = {
|
|
16
|
+
nextId: v.number(),
|
|
17
|
+
conversations: v.array(v.object(serializedConversation)),
|
|
18
|
+
players: v.array(v.object(serializedPlayer)),
|
|
19
|
+
agents: v.array(v.object(serializedAgent)),
|
|
20
|
+
historicalLocations: v.optional(historicalLocations),
|
|
21
|
+
};
|
|
22
|
+
export type SerializedWorld = ObjectType<typeof serializedWorld>;
|
|
23
|
+
|
|
24
|
+
export class World {
|
|
25
|
+
nextId: number;
|
|
26
|
+
conversations: Map<GameId<'conversations'>, Conversation>;
|
|
27
|
+
players: Map<GameId<'players'>, Player>;
|
|
28
|
+
agents: Map<GameId<'agents'>, Agent>;
|
|
29
|
+
historicalLocations?: Map<GameId<'players'>, ArrayBuffer>;
|
|
30
|
+
|
|
31
|
+
constructor(serialized: SerializedWorld) {
|
|
32
|
+
const { nextId, historicalLocations } = serialized;
|
|
33
|
+
|
|
34
|
+
this.nextId = nextId;
|
|
35
|
+
this.conversations = parseMap(serialized.conversations, Conversation, (c) => c.id);
|
|
36
|
+
this.players = parseMap(serialized.players, Player, (p) => p.id);
|
|
37
|
+
this.agents = parseMap(serialized.agents, Agent, (a) => a.id);
|
|
38
|
+
|
|
39
|
+
if (historicalLocations) {
|
|
40
|
+
this.historicalLocations = new Map();
|
|
41
|
+
for (const { playerId, location } of historicalLocations) {
|
|
42
|
+
this.historicalLocations.set(parseGameId('players', playerId), location);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
playerConversation(player: Player): Conversation | undefined {
|
|
48
|
+
return [...this.conversations.values()].find((c) => c.participants.has(player.id));
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
serialize(): SerializedWorld {
|
|
52
|
+
return {
|
|
53
|
+
nextId: this.nextId,
|
|
54
|
+
conversations: [...this.conversations.values()].map((c) => c.serialize()),
|
|
55
|
+
players: [...this.players.values()].map((p) => p.serialize()),
|
|
56
|
+
agents: [...this.agents.values()].map((a) => a.serialize()),
|
|
57
|
+
historicalLocations:
|
|
58
|
+
this.historicalLocations &&
|
|
59
|
+
[...this.historicalLocations.entries()].map(([playerId, location]) => ({
|
|
60
|
+
playerId,
|
|
61
|
+
location,
|
|
62
|
+
})),
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
}
|