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.
Files changed (202) hide show
  1. package/README.md +518 -0
  2. package/bin/solanapolis.js +197 -0
  3. package/convex/_generated/api.d.ts +175 -0
  4. package/convex/_generated/api.js +23 -0
  5. package/convex/_generated/dataModel.d.ts +60 -0
  6. package/convex/_generated/server.d.ts +143 -0
  7. package/convex/_generated/server.js +93 -0
  8. package/convex/agent/conversation.ts +352 -0
  9. package/convex/agent/embeddingsCache.ts +110 -0
  10. package/convex/agent/memory.ts +450 -0
  11. package/convex/agent/schema.ts +53 -0
  12. package/convex/aiChat.ts +54 -0
  13. package/convex/aiTown/agent.ts +382 -0
  14. package/convex/aiTown/agentDescription.ts +27 -0
  15. package/convex/aiTown/agentInputs.ts +155 -0
  16. package/convex/aiTown/agentOperations.ts +178 -0
  17. package/convex/aiTown/conversation.ts +395 -0
  18. package/convex/aiTown/conversationMembership.ts +38 -0
  19. package/convex/aiTown/game.ts +371 -0
  20. package/convex/aiTown/ids.ts +32 -0
  21. package/convex/aiTown/inputHandler.ts +9 -0
  22. package/convex/aiTown/inputs.ts +25 -0
  23. package/convex/aiTown/insertInput.ts +20 -0
  24. package/convex/aiTown/location.ts +32 -0
  25. package/convex/aiTown/main.ts +154 -0
  26. package/convex/aiTown/movement.ts +189 -0
  27. package/convex/aiTown/player.ts +310 -0
  28. package/convex/aiTown/playerDescription.ts +35 -0
  29. package/convex/aiTown/schema.ts +79 -0
  30. package/convex/aiTown/world.ts +65 -0
  31. package/convex/aiTown/worldMap.ts +74 -0
  32. package/convex/chat.ts +79 -0
  33. package/convex/constants.ts +78 -0
  34. package/convex/convex.config.ts +6 -0
  35. package/convex/crons.ts +89 -0
  36. package/convex/engine/abstractGame.ts +199 -0
  37. package/convex/engine/historicalObject.ts +355 -0
  38. package/convex/engine/schema.ts +56 -0
  39. package/convex/http.ts +36 -0
  40. package/convex/init.ts +110 -0
  41. package/convex/messages.ts +53 -0
  42. package/convex/npcCarAgents.ts +415 -0
  43. package/convex/schema.ts +61 -0
  44. package/convex/streaming.ts +23 -0
  45. package/convex/testing.ts +202 -0
  46. package/convex/tsconfig.json +18 -0
  47. package/convex/util/FastIntegerCompression.ts +221 -0
  48. package/convex/util/assertNever.ts +4 -0
  49. package/convex/util/asyncMap.ts +20 -0
  50. package/convex/util/compression.ts +71 -0
  51. package/convex/util/geometry.ts +132 -0
  52. package/convex/util/isSimpleObject.ts +11 -0
  53. package/convex/util/llm.ts +724 -0
  54. package/convex/util/minheap.ts +38 -0
  55. package/convex/util/object.ts +22 -0
  56. package/convex/util/sleep.ts +3 -0
  57. package/convex/util/types.ts +33 -0
  58. package/convex/util/xxhash.ts +228 -0
  59. package/convex/world.ts +257 -0
  60. package/data/animations/campfire.json +45 -0
  61. package/data/animations/gentlesparkle.json +37 -0
  62. package/data/animations/gentlesplash.json +61 -0
  63. package/data/animations/gentlewaterfall.json +61 -0
  64. package/data/animations/windmill.json +78 -0
  65. package/data/characters.ts +121 -0
  66. package/data/convertMap.js +74 -0
  67. package/data/gentle.js +330 -0
  68. package/data/spritesheets/f1.ts +75 -0
  69. package/data/spritesheets/f2.ts +75 -0
  70. package/data/spritesheets/f3.ts +75 -0
  71. package/data/spritesheets/f4.ts +75 -0
  72. package/data/spritesheets/f5.ts +75 -0
  73. package/data/spritesheets/f6.ts +75 -0
  74. package/data/spritesheets/f7.ts +75 -0
  75. package/data/spritesheets/f8.ts +75 -0
  76. package/data/spritesheets/p1.ts +59 -0
  77. package/data/spritesheets/p2.ts +59 -0
  78. package/data/spritesheets/p3.ts +59 -0
  79. package/data/spritesheets/player.ts +59 -0
  80. package/data/spritesheets/types.ts +26 -0
  81. package/eslint.config.mjs +37 -0
  82. package/next.config.ts +7 -0
  83. package/package.json +85 -0
  84. package/postcss.config.mjs +7 -0
  85. package/public/file.svg +1 -0
  86. package/public/globe.svg +1 -0
  87. package/public/helius-icon.svg +84 -0
  88. package/public/helius-logo.svg +85 -0
  89. package/public/next.svg +1 -0
  90. package/public/plane.glb +0 -0
  91. package/public/vercel.svg +1 -0
  92. package/public/window.svg +1 -0
  93. package/scripts/clear-city.ts +74 -0
  94. package/scripts/seed-wallets.ts +185 -0
  95. package/scripts/setup-webhook.ts +73 -0
  96. package/src/app/api/auth/callback/route.ts +6 -0
  97. package/src/app/api/auth/link-wallet/route.ts +6 -0
  98. package/src/app/api/auth/phantom/route.ts +6 -0
  99. package/src/app/api/broadcast-position/route.ts +59 -0
  100. package/src/app/api/leaderboard/route.ts +85 -0
  101. package/src/app/api/network-stats/route.ts +86 -0
  102. package/src/app/api/parcel-reward/route.ts +181 -0
  103. package/src/app/api/queue-status/route.ts +30 -0
  104. package/src/app/api/snapshots/route.ts +37 -0
  105. package/src/app/api/transactions/enhanced/route.ts +57 -0
  106. package/src/app/api/treasury/route.ts +83 -0
  107. package/src/app/api/wallet/[address]/balances/route.ts +124 -0
  108. package/src/app/api/wallet/[address]/identity/route.ts +32 -0
  109. package/src/app/api/wallet/[address]/route.ts +216 -0
  110. package/src/app/api/wallet/[address]/traded-tokens/route.ts +41 -0
  111. package/src/app/api/wallets/route.ts +68 -0
  112. package/src/app/api/webhooks/helius/route.ts +76 -0
  113. package/src/app/auth/callback/page.tsx +29 -0
  114. package/src/app/favicon.ico +0 -0
  115. package/src/app/globals.css +39 -0
  116. package/src/app/layout.tsx +43 -0
  117. package/src/app/page.tsx +16 -0
  118. package/src/components/AITownNPCs.tsx +206 -0
  119. package/src/components/ActivityFeed.tsx +189 -0
  120. package/src/components/AuthPanel.tsx +163 -0
  121. package/src/components/BeachScene.tsx +280 -0
  122. package/src/components/Building.tsx +138 -0
  123. package/src/components/CesiumFlight.tsx +1768 -0
  124. package/src/components/CesiumGlobe.tsx +616 -0
  125. package/src/components/CitizenCard.tsx +442 -0
  126. package/src/components/CitizenCardModal.tsx +153 -0
  127. package/src/components/CityGrid.tsx +313 -0
  128. package/src/components/CityLandmarks.tsx +427 -0
  129. package/src/components/CityScene.tsx +1289 -0
  130. package/src/components/CitySlotsBadge.tsx +68 -0
  131. package/src/components/CockpitHUD.tsx +460 -0
  132. package/src/components/ConvexWrapper.tsx +19 -0
  133. package/src/components/DubaiDistrict.tsx +630 -0
  134. package/src/components/FlightMiniMap.tsx +133 -0
  135. package/src/components/GameChat.tsx +383 -0
  136. package/src/components/GameHUD.tsx +393 -0
  137. package/src/components/Ground.tsx +14 -0
  138. package/src/components/HowItWorksModal.tsx +251 -0
  139. package/src/components/IngestionBanner.tsx +123 -0
  140. package/src/components/InstancedBuildings.tsx +316 -0
  141. package/src/components/InstancedCars.tsx +504 -0
  142. package/src/components/InstancedCityPlanes.tsx +259 -0
  143. package/src/components/InstancedHouses.tsx +246 -0
  144. package/src/components/InstancedLampPosts.tsx +201 -0
  145. package/src/components/InstancedResidentCars.tsx +357 -0
  146. package/src/components/InstancedRoadDashes.tsx +42 -0
  147. package/src/components/InstancedSkyscrapers.tsx +434 -0
  148. package/src/components/InstancedTrees.tsx +67 -0
  149. package/src/components/LeaderboardPanel.tsx +136 -0
  150. package/src/components/MultiplayerPlanes.tsx +128 -0
  151. package/src/components/NetworkStats.tsx +83 -0
  152. package/src/components/NewBuildingSpotlight.tsx +93 -0
  153. package/src/components/ParcelChallengeBanner.tsx +242 -0
  154. package/src/components/ParcelReward.tsx +191 -0
  155. package/src/components/Park.tsx +42 -0
  156. package/src/components/PhantomWrapper.tsx +22 -0
  157. package/src/components/PixelStreamViewer.tsx +335 -0
  158. package/src/components/PlaneMode.tsx +190 -0
  159. package/src/components/PlayerCar.tsx +211 -0
  160. package/src/components/PlayerPlane.tsx +255 -0
  161. package/src/components/ProjectileRenderer.tsx +249 -0
  162. package/src/components/QueueStatusBanner.tsx +86 -0
  163. package/src/components/RealPlayerTags.tsx +82 -0
  164. package/src/components/SceneLighting.tsx +382 -0
  165. package/src/components/SelectionBeam.tsx +59 -0
  166. package/src/components/SwapPanel.tsx +104 -0
  167. package/src/components/SwapParticles.tsx +237 -0
  168. package/src/components/TreasureGate.tsx +505 -0
  169. package/src/components/WalletPanel.tsx +421 -0
  170. package/src/components/WalletSearch.tsx +244 -0
  171. package/src/components/WelcomeOverlay.tsx +135 -0
  172. package/src/components/WindowTooltip.tsx +498 -0
  173. package/src/context/AuthContext.tsx +230 -0
  174. package/src/lib/bot-detection.ts +125 -0
  175. package/src/lib/building-math.ts +136 -0
  176. package/src/lib/building-shader.ts +253 -0
  177. package/src/lib/car-paths.ts +244 -0
  178. package/src/lib/car-system.ts +182 -0
  179. package/src/lib/city-constants.ts +29 -0
  180. package/src/lib/city-slots.ts +35 -0
  181. package/src/lib/city-zoning.ts +64 -0
  182. package/src/lib/collision-map.ts +147 -0
  183. package/src/lib/day-night.ts +252 -0
  184. package/src/lib/export-card.ts +28 -0
  185. package/src/lib/helius-webhook.ts +90 -0
  186. package/src/lib/helius.ts +74 -0
  187. package/src/lib/house-shader.ts +119 -0
  188. package/src/lib/mock-data.ts +56 -0
  189. package/src/lib/multiplayer-manager.ts +329 -0
  190. package/src/lib/plane-physics.ts +66 -0
  191. package/src/lib/player-car.ts +147 -0
  192. package/src/lib/player-plane.ts +200 -0
  193. package/src/lib/projectile-system.ts +272 -0
  194. package/src/lib/skyscraper-types.ts +52 -0
  195. package/src/lib/sound-engine.ts +464 -0
  196. package/src/lib/supabase-admin.ts +9 -0
  197. package/src/lib/supabase.ts +8 -0
  198. package/src/lib/swap-events.ts +70 -0
  199. package/src/middleware.ts +37 -0
  200. package/src/types/phantom.d.ts +16 -0
  201. package/src/types/wallet.ts +20 -0
  202. package/tsconfig.json +34 -0
@@ -0,0 +1,415 @@
1
+ // @ts-nocheck
2
+ import { v } from 'convex/values';
3
+ import OpenAI from 'openai';
4
+ import {
5
+ internalAction,
6
+ internalMutation,
7
+ internalQuery,
8
+ mutation,
9
+ query,
10
+ } from './_generated/server';
11
+ import { internal } from './_generated/api';
12
+
13
+ const AGENT_DECISION_INTERVAL_MS = 45_000;
14
+ const MAX_SYNC_CARS = 48;
15
+ const MAX_TICK_AGENTS = 12;
16
+
17
+ type AgentStyle = 'calm' | 'aggressive' | 'explorer' | 'cautious';
18
+ type RoutePreference = 'home' | 'explore' | 'mixed';
19
+ type DecisionSource = 'openrouter' | 'fallback';
20
+
21
+ type AgentDecision = {
22
+ style: AgentStyle;
23
+ mood: string;
24
+ thought: string;
25
+ speedMultiplier: number;
26
+ routePreference: RoutePreference;
27
+ source: DecisionSource;
28
+ };
29
+
30
+ const styleValidator = v.union(
31
+ v.literal('calm'),
32
+ v.literal('aggressive'),
33
+ v.literal('explorer'),
34
+ v.literal('cautious'),
35
+ );
36
+
37
+ const routePreferenceValidator = v.union(
38
+ v.literal('home'),
39
+ v.literal('explore'),
40
+ v.literal('mixed'),
41
+ );
42
+
43
+ const sourceValidator = v.union(v.literal('openrouter'), v.literal('fallback'));
44
+
45
+ const syncCarValidator = v.object({
46
+ walletAddress: v.string(),
47
+ displayName: v.optional(v.string()),
48
+ blockRow: v.optional(v.number()),
49
+ blockCol: v.optional(v.number()),
50
+ });
51
+
52
+ function clamp(value: number, min: number, max: number) {
53
+ return Math.max(min, Math.min(max, value));
54
+ }
55
+
56
+ function hashString(input: string) {
57
+ let h = 2166136261;
58
+ for (let i = 0; i < input.length; i++) {
59
+ h ^= input.charCodeAt(i);
60
+ h = Math.imul(h, 16777619);
61
+ }
62
+ return Math.abs(h >>> 0);
63
+ }
64
+
65
+ function pick<T>(arr: T[], seed: number) {
66
+ return arr[seed % arr.length];
67
+ }
68
+
69
+ function normalizeThought(text: string) {
70
+ return text.replace(/\s+/g, ' ').trim().slice(0, 120) || 'Cruising through Solanapolis.';
71
+ }
72
+
73
+ function parseJsonObject(text: string): Record<string, unknown> | null {
74
+ const start = text.indexOf('{');
75
+ const end = text.lastIndexOf('}');
76
+ if (start < 0 || end < 0 || end <= start) return null;
77
+ try {
78
+ return JSON.parse(text.slice(start, end + 1)) as Record<string, unknown>;
79
+ } catch {
80
+ return null;
81
+ }
82
+ }
83
+
84
+ function fallbackDecision(agent: { walletAddress: string; displayName?: string | null }, now: number): AgentDecision {
85
+ const tick = Math.floor(now / AGENT_DECISION_INTERVAL_MS);
86
+ const seed = hashString(`${agent.walletAddress}:${tick}`);
87
+
88
+ const style = pick<AgentStyle>(['calm', 'aggressive', 'explorer', 'cautious'], seed + 7);
89
+ const routePreference =
90
+ style === 'explorer'
91
+ ? 'explore'
92
+ : style === 'cautious'
93
+ ? 'home'
94
+ : pick<RoutePreference>(['home', 'mixed', 'explore'], seed + 13);
95
+
96
+ const mood = pick(
97
+ ['focused', 'curious', 'competitive', 'chill', 'alert', 'determined'],
98
+ seed + 23,
99
+ );
100
+
101
+ const thoughts = [
102
+ 'Avoiding traffic near the central skyline.',
103
+ 'Scanning for faster routes near the waterfront.',
104
+ 'Keeping a safe line between the tower blocks.',
105
+ 'Exploring side roads for better visibility.',
106
+ 'Maintaining pace and watching intersections.',
107
+ 'Cruising smoothly through Solanapolis avenues.',
108
+ ];
109
+
110
+ const styleBoost: Record<AgentStyle, number> = {
111
+ calm: 0.95,
112
+ aggressive: 1.22,
113
+ explorer: 1.08,
114
+ cautious: 0.82,
115
+ };
116
+
117
+ const variance = ((seed % 21) - 10) / 100; // -0.10..0.10
118
+ const speedMultiplier = clamp(styleBoost[style] + variance, 0.6, 1.6);
119
+
120
+ return {
121
+ style,
122
+ routePreference,
123
+ mood,
124
+ thought: normalizeThought(pick(thoughts, seed + 31)),
125
+ speedMultiplier,
126
+ source: 'fallback',
127
+ };
128
+ }
129
+
130
+ function coerceStyle(value: unknown, fallback: AgentStyle): AgentStyle {
131
+ if (value === 'calm' || value === 'aggressive' || value === 'explorer' || value === 'cautious') {
132
+ return value;
133
+ }
134
+ return fallback;
135
+ }
136
+
137
+ function coerceRoutePreference(value: unknown, fallback: RoutePreference): RoutePreference {
138
+ if (value === 'home' || value === 'explore' || value === 'mixed') return value;
139
+ return fallback;
140
+ }
141
+
142
+ function coerceNumber(value: unknown, fallback: number) {
143
+ if (typeof value !== 'number' || Number.isNaN(value)) return fallback;
144
+ return value;
145
+ }
146
+
147
+ async function getOpenRouterDecision(agent: {
148
+ walletAddress: string;
149
+ displayName?: string | null;
150
+ persona?: string | null;
151
+ }): Promise<AgentDecision | null> {
152
+ const apiKey = process.env.OPENROUTER_API_KEY || process.env.OPENAI_API_KEY;
153
+ if (!apiKey) return null;
154
+
155
+ const usingOpenRouter = !!process.env.OPENROUTER_API_KEY;
156
+ const model =
157
+ process.env.OPENROUTER_AGENT_MODEL ||
158
+ process.env.OPENROUTER_MODEL ||
159
+ (usingOpenRouter ? 'openai/gpt-4o-mini' : 'gpt-4.1-mini');
160
+
161
+ const client = new OpenAI({
162
+ apiKey,
163
+ baseURL: usingOpenRouter ? 'https://openrouter.ai/api/v1' : undefined,
164
+ });
165
+
166
+ const completion = await client.chat.completions.create({
167
+ model,
168
+ temperature: 0.8,
169
+ max_tokens: 180,
170
+ messages: [
171
+ {
172
+ role: 'system',
173
+ content:
174
+ 'You are an NPC driving-policy planner for Solanapolis. Return ONLY a JSON object with keys: style, mood, thought, speedMultiplier, routePreference. style must be one of calm/aggressive/explorer/cautious. routePreference must be one of home/explore/mixed. speedMultiplier must be a number between 0.6 and 1.6. thought should be short (max 120 chars).',
175
+ },
176
+ {
177
+ role: 'user',
178
+ content: `Wallet: ${agent.walletAddress}\nDisplay Name: ${agent.displayName || 'Unknown'}\nPersona: ${agent.persona || 'City driver'}\nPick a fresh driving decision for the next 45 seconds.`,
179
+ },
180
+ ],
181
+ response_format: { type: 'json_object' },
182
+ });
183
+
184
+ const text = completion.choices[0]?.message?.content ?? '';
185
+ const parsed = parseJsonObject(text);
186
+ if (!parsed) return null;
187
+
188
+ const fallback = fallbackDecision(agent, Date.now());
189
+
190
+ return {
191
+ style: coerceStyle(parsed.style, fallback.style),
192
+ routePreference: coerceRoutePreference(parsed.routePreference, fallback.routePreference),
193
+ mood:
194
+ typeof parsed.mood === 'string' && parsed.mood.trim().length > 0
195
+ ? parsed.mood.trim().slice(0, 40)
196
+ : fallback.mood,
197
+ thought:
198
+ typeof parsed.thought === 'string' ? normalizeThought(parsed.thought) : fallback.thought,
199
+ speedMultiplier: clamp(coerceNumber(parsed.speedMultiplier, fallback.speedMultiplier), 0.6, 1.6),
200
+ source: 'openrouter',
201
+ };
202
+ }
203
+
204
+ function defaultPersona(walletAddress: string) {
205
+ const seed = hashString(walletAddress);
206
+ const traits = ['precision-focused', 'route-optimizing', 'social', 'risk-aware', 'explorer'];
207
+ const goals = ['maintain flow', 'avoid collisions', 'scan for shortcuts', 'patrol city blocks'];
208
+ return `A ${pick(traits, seed)} autonomous city driver that tries to ${pick(goals, seed + 5)}.`;
209
+ }
210
+
211
+ export const syncAgents = mutation({
212
+ args: {
213
+ cars: v.array(syncCarValidator),
214
+ },
215
+ handler: async (ctx, args) => {
216
+ const now = Date.now();
217
+ const uniqueCars = new Map<string, (typeof args.cars)[number]>();
218
+ for (const car of args.cars.slice(0, MAX_SYNC_CARS)) {
219
+ uniqueCars.set(car.walletAddress, car);
220
+ }
221
+
222
+ for (const [, car] of uniqueCars) {
223
+ const existing = (await ctx.db
224
+ .query('npcCarAgents' as any)
225
+ .withIndex('by_wallet', (q) => q.eq('walletAddress', car.walletAddress))
226
+ .first()) as any;
227
+
228
+ if (existing) {
229
+ await ctx.db.patch(existing._id, {
230
+ displayName: car.displayName ?? existing.displayName,
231
+ homeBlockRow: typeof car.blockRow === 'number' ? car.blockRow : existing.homeBlockRow,
232
+ homeBlockCol: typeof car.blockCol === 'number' ? car.blockCol : existing.homeBlockCol,
233
+ lastSyncedAt: now,
234
+ active: true,
235
+ });
236
+ } else {
237
+ const fallback = fallbackDecision({ walletAddress: car.walletAddress, displayName: car.displayName }, now);
238
+ await ctx.db.insert('npcCarAgents' as any, {
239
+ walletAddress: car.walletAddress,
240
+ displayName: car.displayName,
241
+ homeBlockRow: car.blockRow,
242
+ homeBlockCol: car.blockCol,
243
+ persona: defaultPersona(car.walletAddress),
244
+ style: fallback.style,
245
+ mood: fallback.mood,
246
+ thought: fallback.thought,
247
+ speedMultiplier: fallback.speedMultiplier,
248
+ routePreference: fallback.routePreference,
249
+ source: 'fallback',
250
+ active: true,
251
+ lastDecisionAt: now - AGENT_DECISION_INTERVAL_MS,
252
+ lastSyncedAt: now,
253
+ updatedAt: now,
254
+ });
255
+ }
256
+ }
257
+
258
+ await ctx.scheduler.runAfter(0, (internal as any).npcCarAgents.tickDueAgents, {
259
+ maxAgents: MAX_TICK_AGENTS,
260
+ });
261
+
262
+ return { synced: uniqueCars.size };
263
+ },
264
+ });
265
+
266
+ export const listForWallets = query({
267
+ args: {
268
+ wallets: v.array(v.string()),
269
+ },
270
+ handler: async (ctx, args) => {
271
+ const out: Record<
272
+ string,
273
+ {
274
+ displayName: string | null;
275
+ style: AgentStyle;
276
+ mood: string;
277
+ thought: string;
278
+ speedMultiplier: number;
279
+ routePreference: RoutePreference;
280
+ updatedAt: number;
281
+ source: DecisionSource;
282
+ }
283
+ > = {};
284
+
285
+ const uniqueWallets = [...new Set(args.wallets)].slice(0, MAX_SYNC_CARS);
286
+ for (const walletAddress of uniqueWallets) {
287
+ const row = (await ctx.db
288
+ .query('npcCarAgents' as any)
289
+ .withIndex('by_wallet', (q) => q.eq('walletAddress', walletAddress))
290
+ .first()) as any;
291
+
292
+ if (!row) continue;
293
+ out[walletAddress] = {
294
+ displayName: row.displayName ?? null,
295
+ style: row.style,
296
+ mood: row.mood,
297
+ thought: row.thought,
298
+ speedMultiplier: row.speedMultiplier,
299
+ routePreference: row.routePreference,
300
+ updatedAt: row.updatedAt,
301
+ source: row.source,
302
+ };
303
+ }
304
+
305
+ return out;
306
+ },
307
+ });
308
+
309
+ export const getDueAgents = internalQuery({
310
+ args: {
311
+ now: v.number(),
312
+ limit: v.number(),
313
+ },
314
+ handler: async (ctx, args) => {
315
+ const dueBefore = args.now - AGENT_DECISION_INTERVAL_MS;
316
+ const limit = clamp(args.limit, 1, MAX_TICK_AGENTS);
317
+ const rows = (await ctx.db
318
+ .query('npcCarAgents' as any)
319
+ .withIndex('by_active_decision', (q: any) => q.eq('active', true).lte('lastDecisionAt', dueBefore))
320
+ .take(limit)) as any[];
321
+
322
+ return rows.map((row) => ({
323
+ _id: row._id,
324
+ walletAddress: row.walletAddress as string,
325
+ displayName: (row.displayName as string | undefined) ?? null,
326
+ persona: (row.persona as string | undefined) ?? null,
327
+ }));
328
+ },
329
+ });
330
+
331
+ export const applyDecision = internalMutation({
332
+ args: {
333
+ agentId: v.id('npcCarAgents'),
334
+ style: styleValidator,
335
+ mood: v.string(),
336
+ thought: v.string(),
337
+ speedMultiplier: v.number(),
338
+ routePreference: routePreferenceValidator,
339
+ source: sourceValidator,
340
+ decidedAt: v.number(),
341
+ },
342
+ handler: async (ctx, args) => {
343
+ const agent = (await ctx.db.get(args.agentId)) as any;
344
+ if (!agent) return;
345
+
346
+ await ctx.db.patch(args.agentId, {
347
+ style: args.style,
348
+ mood: args.mood,
349
+ thought: normalizeThought(args.thought),
350
+ speedMultiplier: clamp(args.speedMultiplier, 0.6, 1.6),
351
+ routePreference: args.routePreference,
352
+ source: args.source,
353
+ lastDecisionAt: args.decidedAt,
354
+ updatedAt: args.decidedAt,
355
+ });
356
+
357
+ await ctx.db.insert('npcCarDecisionLog' as any, {
358
+ walletAddress: agent.walletAddress,
359
+ style: args.style,
360
+ mood: args.mood,
361
+ thought: normalizeThought(args.thought),
362
+ speedMultiplier: clamp(args.speedMultiplier, 0.6, 1.6),
363
+ routePreference: args.routePreference,
364
+ source: args.source,
365
+ decisionAt: args.decidedAt,
366
+ });
367
+ },
368
+ });
369
+
370
+ export const tickDueAgents = internalAction({
371
+ args: {
372
+ maxAgents: v.optional(v.number()),
373
+ },
374
+ handler: async (ctx, args) => {
375
+ const now = Date.now();
376
+ const maxAgents = clamp(args.maxAgents ?? MAX_TICK_AGENTS, 1, MAX_TICK_AGENTS);
377
+
378
+ const dueAgents = (await ctx.runQuery((internal as any).npcCarAgents.getDueAgents, {
379
+ now,
380
+ limit: maxAgents,
381
+ })) as Array<{
382
+ _id: string;
383
+ walletAddress: string;
384
+ displayName?: string | null;
385
+ persona?: string | null;
386
+ }>;
387
+
388
+ let processed = 0;
389
+ for (const agent of dueAgents) {
390
+ const fallback = fallbackDecision(agent, now);
391
+ let decision = fallback;
392
+ try {
393
+ const llmDecision = await getOpenRouterDecision(agent);
394
+ if (llmDecision) decision = llmDecision;
395
+ } catch (err) {
396
+ console.warn('[npcCarAgents] OpenRouter decision failed, falling back:', err);
397
+ }
398
+
399
+ await ctx.runMutation((internal as any).npcCarAgents.applyDecision, {
400
+ agentId: agent._id,
401
+ style: decision.style,
402
+ mood: decision.mood,
403
+ thought: decision.thought,
404
+ speedMultiplier: decision.speedMultiplier,
405
+ routePreference: decision.routePreference,
406
+ source: decision.source,
407
+ decidedAt: now,
408
+ });
409
+
410
+ processed += 1;
411
+ }
412
+
413
+ return { processed };
414
+ },
415
+ });
@@ -0,0 +1,61 @@
1
+ import { defineSchema, defineTable } from "convex/server";
2
+ import { StreamIdValidator } from "@convex-dev/persistent-text-streaming";
3
+ import { v } from "convex/values";
4
+ import { agentTables } from "./agent/schema";
5
+ import { aiTownTables } from "./aiTown/schema";
6
+ import { conversationId, playerId } from "./aiTown/ids";
7
+ import { engineTables } from "./engine/schema";
8
+
9
+ export default defineSchema({
10
+ // === Heliopolis City Chat ===
11
+ chat_messages: defineTable({
12
+ wallet_address: v.string(),
13
+ display_name: v.optional(v.string()),
14
+ message: v.string(),
15
+ is_ai: v.optional(v.boolean()),
16
+ responseStreamId: v.optional(StreamIdValidator),
17
+ }),
18
+
19
+ // === AI Town: NPC Messages ===
20
+ messages: defineTable({
21
+ conversationId,
22
+ messageUuid: v.string(),
23
+ author: playerId,
24
+ text: v.string(),
25
+ worldId: v.optional(v.id("worlds")),
26
+ })
27
+ .index("conversationId", ["worldId", "conversationId"])
28
+ .index("messageUuid", ["conversationId", "messageUuid"]),
29
+
30
+ // === AI Town: Background Music ===
31
+ music: defineTable({
32
+ storageId: v.string(),
33
+ type: v.union(v.literal("background"), v.literal("player")),
34
+ }),
35
+
36
+ // === NPC Car Agents (LLM-driven driving behavior) ===
37
+ npcCarAgents: defineTable({
38
+ walletAddress: v.string(),
39
+ displayName: v.optional(v.string()),
40
+ homeBlockRow: v.optional(v.number()),
41
+ homeBlockCol: v.optional(v.number()),
42
+ persona: v.optional(v.string()),
43
+ style: v.string(),
44
+ mood: v.string(),
45
+ thought: v.string(),
46
+ speedMultiplier: v.number(),
47
+ routePreference: v.string(),
48
+ source: v.string(),
49
+ active: v.boolean(),
50
+ lastDecisionAt: v.number(),
51
+ lastSyncedAt: v.number(),
52
+ updatedAt: v.number(),
53
+ })
54
+ .index("by_wallet", ["walletAddress"])
55
+ .index("by_active_decision", ["active", "lastDecisionAt"]),
56
+
57
+ // === AI Town: Engine + Game + Agent tables ===
58
+ ...engineTables,
59
+ ...aiTownTables,
60
+ ...agentTables,
61
+ });
@@ -0,0 +1,23 @@
1
+ import {
2
+ PersistentTextStreaming,
3
+ StreamId,
4
+ StreamIdValidator,
5
+ } from "@convex-dev/persistent-text-streaming";
6
+ import { components } from "./_generated/api";
7
+ import { query } from "./_generated/server";
8
+
9
+ export const streamingComponent = new PersistentTextStreaming(
10
+ components.persistentTextStreaming,
11
+ );
12
+
13
+ export const getStreamBody = query({
14
+ args: {
15
+ streamId: StreamIdValidator,
16
+ },
17
+ handler: async (ctx, args) => {
18
+ return await streamingComponent.getStreamBody(
19
+ ctx,
20
+ args.streamId as StreamId,
21
+ );
22
+ },
23
+ });
@@ -0,0 +1,202 @@
1
+ import { Id, TableNames } from './_generated/dataModel';
2
+ import { internal } from './_generated/api';
3
+ import {
4
+ DatabaseReader,
5
+ internalAction,
6
+ internalMutation,
7
+ mutation,
8
+ query,
9
+ } from './_generated/server';
10
+ import { v } from 'convex/values';
11
+ import schema from './schema';
12
+ import { DELETE_BATCH_SIZE } from './constants';
13
+ import { kickEngine, startEngine, stopEngine } from './aiTown/main';
14
+ import { insertInput } from './aiTown/insertInput';
15
+ import { fetchEmbedding } from './util/llm';
16
+ import { chatCompletion } from './util/llm';
17
+ import { startConversationMessage } from './agent/conversation';
18
+ import { GameId } from './aiTown/ids';
19
+
20
+ // Clear all of the tables except for the embeddings cache.
21
+ const excludedTables: Array<TableNames> = ['embeddingsCache'];
22
+
23
+ export const wipeAllTables = internalMutation({
24
+ handler: async (ctx) => {
25
+ for (const tableName of Object.keys(schema.tables)) {
26
+ if (excludedTables.includes(tableName as TableNames)) {
27
+ continue;
28
+ }
29
+ await ctx.scheduler.runAfter(0, internal.testing.deletePage, { tableName, cursor: null });
30
+ }
31
+ },
32
+ });
33
+
34
+ export const deletePage = internalMutation({
35
+ args: {
36
+ tableName: v.string(),
37
+ cursor: v.union(v.string(), v.null()),
38
+ },
39
+ handler: async (ctx, args) => {
40
+ const results = await ctx.db
41
+ .query(args.tableName as TableNames)
42
+ .paginate({ cursor: args.cursor, numItems: DELETE_BATCH_SIZE });
43
+ for (const row of results.page) {
44
+ await ctx.db.delete(row._id);
45
+ }
46
+ if (!results.isDone) {
47
+ await ctx.scheduler.runAfter(0, internal.testing.deletePage, {
48
+ tableName: args.tableName,
49
+ cursor: results.continueCursor,
50
+ });
51
+ }
52
+ },
53
+ });
54
+
55
+ export const kick = internalMutation({
56
+ handler: async (ctx) => {
57
+ const { worldStatus } = await getDefaultWorld(ctx.db);
58
+ await kickEngine(ctx, worldStatus.worldId);
59
+ },
60
+ });
61
+
62
+ export const stopAllowed = query({
63
+ handler: async () => {
64
+ return !process.env.STOP_NOT_ALLOWED;
65
+ },
66
+ });
67
+
68
+ export const stop = mutation({
69
+ handler: async (ctx) => {
70
+ if (process.env.STOP_NOT_ALLOWED) throw new Error('Stop not allowed');
71
+ const { worldStatus, engine } = await getDefaultWorld(ctx.db);
72
+ if (worldStatus.status === 'inactive' || worldStatus.status === 'stoppedByDeveloper') {
73
+ if (engine.running) {
74
+ throw new Error(`Engine ${engine._id} isn't stopped?`);
75
+ }
76
+ console.debug(`World ${worldStatus.worldId} is already inactive`);
77
+ return;
78
+ }
79
+ console.log(`Stopping engine ${engine._id}...`);
80
+ await ctx.db.patch(worldStatus._id, { status: 'stoppedByDeveloper' });
81
+ await stopEngine(ctx, worldStatus.worldId);
82
+ },
83
+ });
84
+
85
+ export const resume = mutation({
86
+ handler: async (ctx) => {
87
+ const { worldStatus, engine } = await getDefaultWorld(ctx.db);
88
+ if (worldStatus.status === 'running') {
89
+ if (!engine.running) {
90
+ throw new Error(`Engine ${engine._id} isn't running?`);
91
+ }
92
+ console.debug(`World ${worldStatus.worldId} is already running`);
93
+ return;
94
+ }
95
+ console.log(
96
+ `Resuming engine ${engine._id} for world ${worldStatus.worldId} (state: ${worldStatus.status})...`,
97
+ );
98
+ await ctx.db.patch(worldStatus._id, { status: 'running' });
99
+ await startEngine(ctx, worldStatus.worldId);
100
+ },
101
+ });
102
+
103
+ export const archive = internalMutation({
104
+ handler: async (ctx) => {
105
+ const { worldStatus, engine } = await getDefaultWorld(ctx.db);
106
+ if (engine.running) {
107
+ throw new Error(`Engine ${engine._id} is still running!`);
108
+ }
109
+ console.log(`Archiving world ${worldStatus.worldId}...`);
110
+ await ctx.db.patch(worldStatus._id, { isDefault: false });
111
+ },
112
+ });
113
+
114
+ async function getDefaultWorld(db: DatabaseReader) {
115
+ const worldStatus = await db
116
+ .query('worldStatus')
117
+ .filter((q) => q.eq(q.field('isDefault'), true))
118
+ .first();
119
+ if (!worldStatus) {
120
+ throw new Error('No default world found');
121
+ }
122
+ const engine = await db.get(worldStatus.engineId);
123
+ if (!engine) {
124
+ throw new Error(`Engine ${worldStatus.engineId} not found`);
125
+ }
126
+ return { worldStatus, engine };
127
+ }
128
+
129
+ export const debugCreatePlayers = internalMutation({
130
+ args: {
131
+ numPlayers: v.number(),
132
+ },
133
+ handler: async (ctx, args) => {
134
+ const { worldStatus } = await getDefaultWorld(ctx.db);
135
+ for (let i = 0; i < args.numPlayers; i++) {
136
+ const inputId = await insertInput(ctx, worldStatus.worldId, 'join', {
137
+ name: `Robot${i}`,
138
+ description: `This player is a robot.`,
139
+ character: `f${1 + (i % 8)}`,
140
+ });
141
+ }
142
+ },
143
+ });
144
+
145
+ export const randomPositions = internalMutation({
146
+ handler: async (ctx) => {
147
+ const { worldStatus } = await getDefaultWorld(ctx.db);
148
+ const map = await ctx.db
149
+ .query('maps')
150
+ .withIndex('worldId', (q) => q.eq('worldId', worldStatus.worldId))
151
+ .unique();
152
+ if (!map) {
153
+ throw new Error(`No map for world ${worldStatus.worldId}`);
154
+ }
155
+ const world = await ctx.db.get(worldStatus.worldId);
156
+ if (!world) {
157
+ throw new Error(`No world for world ${worldStatus.worldId}`);
158
+ }
159
+ for (const player of world.players) {
160
+ await insertInput(ctx, world._id, 'moveTo', {
161
+ playerId: player.id,
162
+ destination: {
163
+ x: 1 + Math.floor(Math.random() * (map.width - 2)),
164
+ y: 1 + Math.floor(Math.random() * (map.height - 2)),
165
+ },
166
+ });
167
+ }
168
+ },
169
+ });
170
+
171
+ export const testEmbedding = internalAction({
172
+ args: { input: v.string() },
173
+ handler: async (_ctx, args) => {
174
+ return await fetchEmbedding(args.input);
175
+ },
176
+ });
177
+
178
+ export const testCompletion = internalAction({
179
+ args: {},
180
+ handler: async (ctx, args) => {
181
+ return await chatCompletion({
182
+ messages: [
183
+ { content: 'You are helpful', role: 'system' },
184
+ { content: 'Where is pizza?', role: 'user' },
185
+ ],
186
+ });
187
+ },
188
+ });
189
+
190
+ export const testConvo = internalAction({
191
+ args: {},
192
+ handler: async (ctx, args) => {
193
+ const a: any = (await startConversationMessage(
194
+ ctx,
195
+ 'm1707m46wmefpejw1k50rqz7856qw3ew' as Id<'worlds'>,
196
+ 'c:115' as GameId<'conversations'>,
197
+ 'p:0' as GameId<'players'>,
198
+ 'p:6' as GameId<'players'>,
199
+ )) as any;
200
+ return await a.readAll();
201
+ },
202
+ });