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,434 @@
1
+ "use client";
2
+
3
+ import { useRef, useEffect, useCallback, useMemo } from "react";
4
+ import { useFrame, ThreeEvent } from "@react-three/fiber";
5
+ import * as THREE from "three";
6
+ import { PlacedWallet } from "@/types/wallet";
7
+ import {
8
+ getBuildingDimensions,
9
+ getBuildingColor,
10
+ getWindowCols,
11
+ getWindowFillRatio,
12
+ getLitRatio,
13
+ getInstanceSeed,
14
+ getWindowRows,
15
+ getWalletWorldPosition,
16
+ } from "@/lib/building-math";
17
+ import { createBuildingMaterial } from "@/lib/building-shader";
18
+ import { createHouseMaterial } from "@/lib/house-shader";
19
+ import { SkyscraperType, TierDef, SKYSCRAPER_DEFS } from "@/lib/skyscraper-types";
20
+ import { WindowHoverInfo } from "./WindowTooltip";
21
+
22
+ interface InstancedSkyscrapersProps {
23
+ wallets: PlacedWallet[];
24
+ skyscraperTypes: SkyscraperType[]; // parallel to wallets
25
+ onSelectWallet: (wallet: PlacedWallet, position: [number, number, number]) => void;
26
+ onHoverWindow?: (info: WindowHoverInfo | null) => void;
27
+ timeRef: React.MutableRefObject<number>;
28
+ selectedAddress?: string | null;
29
+ }
30
+
31
+ // ---- Precomputed data per wallet ----
32
+ interface WalletData {
33
+ wallet: PlacedWallet;
34
+ type: Exclude<SkyscraperType, "box">;
35
+ dims: { width: number; height: number; depth: number };
36
+ basePos: [number, number, number]; // ground-level center (y = height/2 in original, but we'll compute per-tier)
37
+ color: string;
38
+ seed: number;
39
+ yRotation: number; // 0, π/2, π, or 3π/2
40
+ }
41
+
42
+ // One InstancedMesh per (type, tierIndex) — all wallets of the same type share instances
43
+ interface TierGroup {
44
+ type: Exclude<SkyscraperType, "box">;
45
+ tierIndex: number;
46
+ tierDef: TierDef;
47
+ walletIndices: number[]; // indices into walletData array
48
+ }
49
+
50
+ const _dummy = new THREE.Object3D();
51
+ const _color = new THREE.Color();
52
+
53
+ export default function InstancedSkyscrapers({
54
+ wallets,
55
+ skyscraperTypes,
56
+ onSelectWallet,
57
+ onHoverWindow,
58
+ timeRef,
59
+ selectedAddress,
60
+ }: InstancedSkyscrapersProps) {
61
+ const timeUniformRef = useRef<{ value: number }>({ value: 0 });
62
+ const elapsedUniformRef = useRef<{ value: number }>({ value: 0 });
63
+ const buildingMat = useMemo(
64
+ () => createBuildingMaterial(timeUniformRef.current, elapsedUniformRef.current),
65
+ [],
66
+ );
67
+ const solidMat = useMemo(
68
+ () => createHouseMaterial(timeUniformRef.current, 0.85, 1.0),
69
+ [],
70
+ );
71
+
72
+ // Refs for all tier meshes — stored as flat array, indexed by tierGroups order
73
+ const meshRefs = useRef<(THREE.InstancedMesh | null)[]>([]);
74
+ const hoveredWalletIdx = useRef<number | null>(null);
75
+
76
+ // Precompute wallet data
77
+ const walletData: WalletData[] = useMemo(() => {
78
+ return wallets.map((w, i) => {
79
+ const type = skyscraperTypes[i] as Exclude<SkyscraperType, "box">;
80
+ const dims = getBuildingDimensions(w);
81
+ const basePos = getWalletWorldPosition(w, dims);
82
+ const color = getBuildingColor(dims.height);
83
+ const seed = getInstanceSeed(w.address);
84
+ // Deterministic 90° rotation: 0, 1, 2, or 3 quarter-turns
85
+ const yRotation = Math.floor(seed * 4) * (Math.PI / 2);
86
+ return { wallet: w, type, dims, basePos, color, seed, yRotation };
87
+ });
88
+ }, [wallets, skyscraperTypes]);
89
+
90
+ // Group wallets by (type, tierIndex)
91
+ const tierGroups: TierGroup[] = useMemo(() => {
92
+ const groups: TierGroup[] = [];
93
+ const typeSet = new Set(walletData.map((d) => d.type));
94
+
95
+ for (const type of typeSet) {
96
+ const tiers = SKYSCRAPER_DEFS[type];
97
+ for (let ti = 0; ti < tiers.length; ti++) {
98
+ const walletIndices: number[] = [];
99
+ for (let wi = 0; wi < walletData.length; wi++) {
100
+ if (walletData[wi].type === type) walletIndices.push(wi);
101
+ }
102
+ if (walletIndices.length > 0) {
103
+ groups.push({ type, tierIndex: ti, tierDef: tiers[ti], walletIndices });
104
+ }
105
+ }
106
+ }
107
+ return groups;
108
+ }, [walletData]);
109
+
110
+ // Build a reverse lookup: for each tier group mesh index + instance id → walletData index
111
+ const meshToWallet = useMemo(() => {
112
+ return tierGroups.map((g) => g.walletIndices);
113
+ }, [tierGroups]);
114
+
115
+ // Also build: for each walletData index → list of (meshIdx, instanceId) so we can highlight all tiers
116
+ const walletToMeshInstances = useMemo(() => {
117
+ const map: { meshIdx: number; instanceId: number }[][] = new Array(walletData.length);
118
+ for (let i = 0; i < walletData.length; i++) map[i] = [];
119
+ for (let mi = 0; mi < tierGroups.length; mi++) {
120
+ const indices = tierGroups[mi].walletIndices;
121
+ for (let ii = 0; ii < indices.length; ii++) {
122
+ map[indices[ii]].push({ meshIdx: mi, instanceId: ii });
123
+ }
124
+ }
125
+ return map;
126
+ }, [tierGroups, walletData.length]);
127
+
128
+ // Set instance matrices and attributes for all tier meshes
129
+ useEffect(() => {
130
+ for (let mi = 0; mi < tierGroups.length; mi++) {
131
+ const mesh = meshRefs.current[mi];
132
+ const group = tierGroups[mi];
133
+ if (!mesh) continue;
134
+
135
+ const { tierDef, walletIndices } = group;
136
+ const count = walletIndices.length;
137
+
138
+ const colors = new Float32Array(count * 3);
139
+ const windowColsArr = new Float32Array(count);
140
+ const fillRatioArr = new Float32Array(count);
141
+ const litRatioArr = new Float32Array(count);
142
+ const seedArr = new Float32Array(count);
143
+ const floorsArr = new Float32Array(count);
144
+ const highlightArr = new Float32Array(count);
145
+
146
+ for (let ii = 0; ii < count; ii++) {
147
+ const wd = walletData[walletIndices[ii]];
148
+ const { dims, basePos, color, seed, yRotation, wallet: w } = wd;
149
+
150
+ // Compute tier dimensions in world space
151
+ const tierW = dims.width * tierDef.widthFrac;
152
+ const tierD = dims.depth * tierDef.depthFrac;
153
+ const tierH = dims.height * tierDef.heightFrac;
154
+
155
+ // Compute tier center position
156
+ // basePos[1] is dims.height/2 (center of full building box)
157
+ // We need ground-level Y = 0, then offset by tier
158
+ const groundY = 0;
159
+ const tierCenterY = groundY + dims.height * tierDef.yBaseFrac + tierH / 2;
160
+
161
+ // Apply X/Z offsets (relative to building dims), rotated by yRotation
162
+ const rawOffsetX = dims.width * tierDef.xOffsetFrac;
163
+ const rawOffsetZ = dims.depth * tierDef.zOffsetFrac;
164
+ const cosR = Math.cos(yRotation);
165
+ const sinR = Math.sin(yRotation);
166
+ const rotatedOffsetX = rawOffsetX * cosR - rawOffsetZ * sinR;
167
+ const rotatedOffsetZ = rawOffsetX * sinR + rawOffsetZ * cosR;
168
+
169
+ _dummy.position.set(
170
+ basePos[0] + rotatedOffsetX,
171
+ tierCenterY,
172
+ basePos[2] + rotatedOffsetZ,
173
+ );
174
+ _dummy.scale.set(tierW, tierH, tierD);
175
+ _dummy.rotation.set(0, yRotation, 0);
176
+ _dummy.updateMatrix();
177
+ mesh.setMatrixAt(ii, _dummy.matrix);
178
+
179
+ // Color
180
+ _color.set(color);
181
+ colors[ii * 3] = _color.r;
182
+ colors[ii * 3 + 1] = _color.g;
183
+ colors[ii * 3 + 2] = _color.b;
184
+
185
+ if (tierDef.hasWindows) {
186
+ // Window attributes — computed from TIER dimensions for correct density
187
+ windowColsArr[ii] = getWindowCols(tierW);
188
+ fillRatioArr[ii] = getWindowFillRatio(w.uniqueTokensSwapped ?? 0);
189
+ litRatioArr[ii] = getLitRatio(w.latestBlocktime);
190
+ seedArr[ii] = seed;
191
+ floorsArr[ii] = getWindowRows(Math.round(tierH / 0.3)); // floors in this tier
192
+ }
193
+ }
194
+
195
+ mesh.instanceMatrix.needsUpdate = true;
196
+
197
+ if (tierDef.hasWindows) {
198
+ mesh.geometry.setAttribute(
199
+ "instanceBuildingColor",
200
+ new THREE.InstancedBufferAttribute(colors, 3),
201
+ );
202
+ mesh.geometry.setAttribute(
203
+ "instanceHighlight",
204
+ new THREE.InstancedBufferAttribute(highlightArr, 1),
205
+ );
206
+ mesh.geometry.setAttribute(
207
+ "instanceWindowCols",
208
+ new THREE.InstancedBufferAttribute(windowColsArr, 1),
209
+ );
210
+ mesh.geometry.setAttribute(
211
+ "instanceFillRatio",
212
+ new THREE.InstancedBufferAttribute(fillRatioArr, 1),
213
+ );
214
+ mesh.geometry.setAttribute(
215
+ "instanceLitRatio",
216
+ new THREE.InstancedBufferAttribute(litRatioArr, 1),
217
+ );
218
+ mesh.geometry.setAttribute(
219
+ "instanceSeed",
220
+ new THREE.InstancedBufferAttribute(seedArr, 1),
221
+ );
222
+ mesh.geometry.setAttribute(
223
+ "instanceFloors",
224
+ new THREE.InstancedBufferAttribute(floorsArr, 1),
225
+ );
226
+ } else {
227
+ // Solid-color tiers use instanceColor for the house-shader
228
+ mesh.instanceColor = new THREE.InstancedBufferAttribute(colors, 3);
229
+ mesh.geometry.setAttribute(
230
+ "instanceHighlight",
231
+ new THREE.InstancedBufferAttribute(highlightArr, 1),
232
+ );
233
+ }
234
+ }
235
+ }, [tierGroups, walletData]);
236
+
237
+ // Update time uniforms
238
+ useFrame((state) => {
239
+ timeUniformRef.current.value = timeRef.current;
240
+ elapsedUniformRef.current.value = state.clock.elapsedTime;
241
+ });
242
+
243
+ // ---- Highlight helpers ----
244
+ const setWalletHighlight = useCallback(
245
+ (walletIdx: number, value: number) => {
246
+ const entries = walletToMeshInstances[walletIdx];
247
+ if (!entries) return;
248
+ for (const { meshIdx, instanceId } of entries) {
249
+ const mesh = meshRefs.current[meshIdx];
250
+ if (!mesh) continue;
251
+ const attr = mesh.geometry.getAttribute("instanceHighlight") as THREE.InstancedBufferAttribute;
252
+ if (attr) {
253
+ attr.setX(instanceId, value);
254
+ attr.needsUpdate = true;
255
+ }
256
+ }
257
+ },
258
+ [walletToMeshInstances],
259
+ );
260
+
261
+ // ---- Resolve which wallet was hit ----
262
+ const resolveWallet = useCallback(
263
+ (meshIdx: number, instanceId: number): number | undefined => {
264
+ const indices = meshToWallet[meshIdx];
265
+ return indices?.[instanceId];
266
+ },
267
+ [meshToWallet],
268
+ );
269
+
270
+ // ---- Pointer handlers ----
271
+ const makePointerMove = useCallback(
272
+ (meshIdx: number) => (e: ThreeEvent<PointerEvent>) => {
273
+ e.stopPropagation();
274
+ const id = e.instanceId;
275
+ if (id === undefined) {
276
+ onHoverWindow?.(null);
277
+ return;
278
+ }
279
+
280
+ const walletIdx = resolveWallet(meshIdx, id);
281
+ if (walletIdx === undefined) return;
282
+
283
+ const prev = hoveredWalletIdx.current;
284
+ if (prev !== walletIdx) {
285
+ if (prev !== null) setWalletHighlight(prev, 0.0);
286
+ setWalletHighlight(walletIdx, 1.0);
287
+ hoveredWalletIdx.current = walletIdx;
288
+ document.body.style.cursor = "pointer";
289
+ }
290
+
291
+ if (!onHoverWindow) return;
292
+
293
+ const wd = walletData[walletIdx];
294
+ const w = wd.wallet;
295
+
296
+ if (w.address !== selectedAddress) {
297
+ onHoverWindow({
298
+ address: w.address,
299
+ tokenIndex: 0,
300
+ screenX: e.nativeEvent.clientX,
301
+ screenY: e.nativeEvent.clientY,
302
+ mode: "building",
303
+ identityName: w.identityName,
304
+ identityType: w.identityType,
305
+ identityCategory: w.identityCategory,
306
+ });
307
+ return;
308
+ }
309
+
310
+ // Selected building — window-level tooltip on windowed tiers
311
+ const tierDef = tierGroups[meshIdx]?.tierDef;
312
+ if (!tierDef?.hasWindows) {
313
+ onHoverWindow(null);
314
+ return;
315
+ }
316
+
317
+ const faceNormal = e.face?.normal;
318
+ if (!faceNormal || Math.abs(faceNormal.y) > 0.5) {
319
+ onHoverWindow(null);
320
+ return;
321
+ }
322
+
323
+ // Approximate: use tier dimensions for local coord conversion
324
+ const tierW = wd.dims.width * tierDef.widthFrac;
325
+ const tierH = wd.dims.height * tierDef.heightFrac;
326
+ const tierD = wd.dims.depth * tierDef.depthFrac;
327
+ const tierCenterY = wd.dims.height * tierDef.yBaseFrac + tierH / 2;
328
+
329
+ const rawOffsetX = wd.dims.width * tierDef.xOffsetFrac;
330
+ const rawOffsetZ = wd.dims.depth * tierDef.zOffsetFrac;
331
+ const cosR = Math.cos(wd.yRotation);
332
+ const sinR = Math.sin(wd.yRotation);
333
+ const rotatedOffsetX = rawOffsetX * cosR - rawOffsetZ * sinR;
334
+ const rotatedOffsetZ = rawOffsetX * sinR + rawOffsetZ * cosR;
335
+
336
+ const tierCenterX = wd.basePos[0] + rotatedOffsetX;
337
+ const tierCenterZ = wd.basePos[2] + rotatedOffsetZ;
338
+
339
+ // Transform hit point into tier-local coords, accounting for rotation
340
+ const dx = e.point.x - tierCenterX;
341
+ const dz = e.point.z - tierCenterZ;
342
+ // Un-rotate
343
+ const localX = (dx * cosR + dz * sinR) / tierW;
344
+ const localY = (e.point.y - tierCenterY) / tierH;
345
+ const localZ = (-dx * sinR + dz * cosR) / tierD;
346
+
347
+ // Determine face UV — same approach as InstancedBuildings
348
+ // Need to figure out which local face was hit by examining the un-rotated normal
349
+ const nx = faceNormal.x * cosR + faceNormal.z * sinR;
350
+ let faceU: number, faceV: number;
351
+ if (Math.abs(nx) > 0.5) {
352
+ faceU = localZ + 0.5;
353
+ faceV = localY + 0.5;
354
+ } else {
355
+ faceU = localX + 0.5;
356
+ faceV = localY + 0.5;
357
+ }
358
+
359
+ const cols = getWindowCols(tierW);
360
+ const rows = getWindowRows(Math.round(tierH / 0.3));
361
+ if (cols < 1 || rows < 1) { onHoverWindow(null); return; }
362
+
363
+ const colIdx = Math.floor(faceU * cols);
364
+ const rowIdx = Math.floor(faceV * rows);
365
+ const cellU = faceU * cols - colIdx;
366
+ const cellV = faceV * rows - rowIdx;
367
+
368
+ if (!(cellU > 0.2 && cellU < 0.8 && cellV > 0.2 && cellV < 0.8)) {
369
+ onHoverWindow(null);
370
+ return;
371
+ }
372
+
373
+ const tokenIndex = Math.abs(
374
+ (colIdx * 127 + rowIdx * 311 + Math.floor(wd.seed * 10007)) | 0,
375
+ );
376
+
377
+ onHoverWindow({
378
+ address: w.address,
379
+ tokenIndex,
380
+ screenX: e.nativeEvent.clientX,
381
+ screenY: e.nativeEvent.clientY,
382
+ mode: "token",
383
+ });
384
+ },
385
+ [walletData, tierGroups, resolveWallet, setWalletHighlight, onHoverWindow, selectedAddress],
386
+ );
387
+
388
+ const handlePointerOut = useCallback(() => {
389
+ const prev = hoveredWalletIdx.current;
390
+ if (prev !== null) {
391
+ setWalletHighlight(prev, 0.0);
392
+ }
393
+ hoveredWalletIdx.current = null;
394
+ document.body.style.cursor = "default";
395
+ onHoverWindow?.(null);
396
+ }, [setWalletHighlight, onHoverWindow]);
397
+
398
+ const makeClick = useCallback(
399
+ (meshIdx: number) => (e: ThreeEvent<MouseEvent>) => {
400
+ e.stopPropagation();
401
+ const id = e.instanceId;
402
+ if (id === undefined) return;
403
+ const walletIdx = resolveWallet(meshIdx, id);
404
+ if (walletIdx === undefined) return;
405
+ const wd = walletData[walletIdx];
406
+ onSelectWallet(wd.wallet, wd.basePos);
407
+ },
408
+ [walletData, resolveWallet, onSelectWallet],
409
+ );
410
+
411
+ if (wallets.length === 0) return null;
412
+
413
+ return (
414
+ <group>
415
+ {tierGroups.map((group, mi) => (
416
+ <instancedMesh
417
+ key={`${group.type}-${group.tierIndex}-${group.walletIndices.length}`}
418
+ ref={(el) => { meshRefs.current[mi] = el; }}
419
+ args={[undefined!, undefined!, group.walletIndices.length]}
420
+ frustumCulled={false}
421
+ onPointerMove={makePointerMove(mi)}
422
+ onPointerOut={handlePointerOut}
423
+ onClick={makeClick(mi)}
424
+ >
425
+ <boxGeometry args={[1, 1, 1]} />
426
+ <primitive
427
+ object={group.tierDef.hasWindows ? buildingMat : solidMat}
428
+ attach="material"
429
+ />
430
+ </instancedMesh>
431
+ ))}
432
+ </group>
433
+ );
434
+ }
@@ -0,0 +1,67 @@
1
+ "use client";
2
+
3
+ import { useRef, useEffect, useMemo } from "react";
4
+ import * as THREE from "three";
5
+
6
+ interface TreeData {
7
+ pos: [number, number, number];
8
+ scale: number;
9
+ }
10
+
11
+ /**
12
+ * Renders all trees in the scene using two InstancedMesh draw calls
13
+ * (one for trunks, one for canopies) instead of 2 meshes per tree.
14
+ */
15
+ export default function InstancedTrees({ trees }: { trees: TreeData[] }) {
16
+ const trunkRef = useRef<THREE.InstancedMesh>(null);
17
+ const canopyRef = useRef<THREE.InstancedMesh>(null);
18
+ const dummy = useMemo(() => new THREE.Object3D(), []);
19
+
20
+ useEffect(() => {
21
+ const trunk = trunkRef.current;
22
+ const canopy = canopyRef.current;
23
+ if (!trunk || !canopy) return;
24
+
25
+ for (let i = 0; i < trees.length; i++) {
26
+ const { pos, scale } = trees[i];
27
+
28
+ // Position-based variation (matches original Tree component logic)
29
+ const seed = Math.abs(pos[0] * 73.1 + pos[2] * 37.7);
30
+ const v = 0.85 + ((seed % 100) / 100) * 0.3;
31
+
32
+ const trunkH = 0.6 * scale * v;
33
+ const canopyH = 1.4 * scale * v;
34
+
35
+ // Trunk instance
36
+ dummy.position.set(pos[0], trunkH / 2, pos[2]);
37
+ dummy.rotation.set(0, 0, 0);
38
+ dummy.scale.set(scale, scale * v, scale);
39
+ dummy.updateMatrix();
40
+ trunk.setMatrixAt(i, dummy.matrix);
41
+
42
+ // Canopy instance
43
+ dummy.position.set(pos[0], trunkH + canopyH / 2.5, pos[2]);
44
+ dummy.scale.set(scale * v, scale * v, scale * v);
45
+ dummy.updateMatrix();
46
+ canopy.setMatrixAt(i, dummy.matrix);
47
+ }
48
+
49
+ trunk.instanceMatrix.needsUpdate = true;
50
+ canopy.instanceMatrix.needsUpdate = true;
51
+ }, [trees, dummy]);
52
+
53
+ if (trees.length === 0) return null;
54
+
55
+ return (
56
+ <>
57
+ <instancedMesh ref={trunkRef} args={[undefined!, undefined!, trees.length]} frustumCulled={false}>
58
+ <cylinderGeometry args={[0.08, 0.096, 0.6, 6]} />
59
+ <meshStandardMaterial color="#6b4a35" />
60
+ </instancedMesh>
61
+ <instancedMesh ref={canopyRef} args={[undefined!, undefined!, trees.length]} frustumCulled={false}>
62
+ <coneGeometry args={[0.6, 1.4, 7]} />
63
+ <meshStandardMaterial color="#2e8b40" />
64
+ </instancedMesh>
65
+ </>
66
+ );
67
+ }
@@ -0,0 +1,136 @@
1
+ "use client";
2
+
3
+ import { useEffect, useState, useCallback } from "react";
4
+
5
+ interface LeaderboardEntry {
6
+ wallet_address: string;
7
+ best_score: number;
8
+ total_score: number;
9
+ total_hits: number;
10
+ total_sessions: number;
11
+ total_playtime_s: number;
12
+ }
13
+
14
+ interface LeaderboardPanelProps {
15
+ visible: boolean;
16
+ currentWallet?: string | null;
17
+ currentScore?: number;
18
+ }
19
+
20
+ function truncateAddress(addr: string): string {
21
+ if (addr.length <= 10) return addr;
22
+ return addr.slice(0, 4) + "…" + addr.slice(-4);
23
+ }
24
+
25
+ function formatPlaytime(secs: number): string {
26
+ if (secs < 60) return `${secs}s`;
27
+ const mins = Math.floor(secs / 60);
28
+ if (mins < 60) return `${mins}m`;
29
+ return `${Math.floor(mins / 60)}h ${mins % 60}m`;
30
+ }
31
+
32
+ export default function LeaderboardPanel({ visible, currentWallet, currentScore }: LeaderboardPanelProps) {
33
+ const [entries, setEntries] = useState<LeaderboardEntry[]>([]);
34
+ const [onlineCount, setOnlineCount] = useState(0);
35
+ const [loading, setLoading] = useState(false);
36
+
37
+ const fetchLeaderboard = useCallback(async () => {
38
+ try {
39
+ setLoading(true);
40
+ const res = await fetch("/api/leaderboard?limit=10");
41
+ if (!res.ok) return;
42
+ const data = await res.json();
43
+ setEntries(data.leaderboard ?? []);
44
+ setOnlineCount(data.onlineCount ?? 0);
45
+ } catch {
46
+ // Silently fail
47
+ } finally {
48
+ setLoading(false);
49
+ }
50
+ }, []);
51
+
52
+ useEffect(() => {
53
+ if (!visible) return;
54
+ fetchLeaderboard();
55
+ const interval = setInterval(fetchLeaderboard, 15000); // refresh every 15s
56
+ return () => clearInterval(interval);
57
+ }, [visible, fetchLeaderboard]);
58
+
59
+ if (!visible) return null;
60
+
61
+ return (
62
+ <div className="fixed bottom-4 right-4 z-50 w-72 bg-black/70 backdrop-blur-xl border border-white/[0.08] rounded-2xl overflow-hidden">
63
+ {/* Header */}
64
+ <div className="px-4 py-3 border-b border-white/[0.06]">
65
+ <div className="flex items-center justify-between">
66
+ <h3 className="text-sm font-bold text-white/90 tracking-tight">🏆 Leaderboard</h3>
67
+ <div className="flex items-center gap-1.5">
68
+ <div className="w-2 h-2 rounded-full bg-emerald-400 animate-pulse" />
69
+ <span className="text-[10px] text-white/40">{onlineCount} online</span>
70
+ </div>
71
+ </div>
72
+ </div>
73
+
74
+ {/* Leaderboard rows */}
75
+ <div className="max-h-48 overflow-y-auto">
76
+ {loading && entries.length === 0 ? (
77
+ <div className="px-4 py-6 text-center text-white/30 text-xs">Loading...</div>
78
+ ) : entries.length === 0 ? (
79
+ <div className="px-4 py-6 text-center text-white/30 text-xs">No scores yet — be the first!</div>
80
+ ) : (
81
+ entries.map((entry, i) => {
82
+ const isMe = currentWallet && entry.wallet_address === currentWallet;
83
+ const rankEmoji = i === 0 ? "🥇" : i === 1 ? "🥈" : i === 2 ? "🥉" : `${i + 1}.`;
84
+
85
+ return (
86
+ <div
87
+ key={entry.wallet_address}
88
+ className={`flex items-center gap-2 px-4 py-2 border-b border-white/[0.04] transition-colors ${
89
+ isMe ? "bg-[#E35930]/10" : "hover:bg-white/[0.02]"
90
+ }`}
91
+ >
92
+ <span className="text-xs w-6 text-center">{rankEmoji}</span>
93
+ <div className="flex-1 min-w-0">
94
+ <div className="flex items-center gap-1.5">
95
+ <span className={`text-xs font-medium truncate ${isMe ? "text-[#E35930]" : "text-white/70"}`}>
96
+ {truncateAddress(entry.wallet_address)}
97
+ </span>
98
+ {isMe && (
99
+ <span className="text-[8px] bg-[#E35930]/20 text-[#E35930] px-1 rounded">YOU</span>
100
+ )}
101
+ </div>
102
+ <div className="flex items-center gap-2 mt-0.5">
103
+ <span className="text-[9px] text-white/25">
104
+ {entry.total_hits} hits
105
+ </span>
106
+ <span className="text-[9px] text-white/25">
107
+ {entry.total_sessions} games
108
+ </span>
109
+ <span className="text-[9px] text-white/25">
110
+ {formatPlaytime(entry.total_playtime_s)}
111
+ </span>
112
+ </div>
113
+ </div>
114
+ <span className="text-sm font-bold text-white/80 tabular-nums">
115
+ {entry.best_score.toLocaleString()}
116
+ </span>
117
+ </div>
118
+ );
119
+ })
120
+ )}
121
+ </div>
122
+
123
+ {/* Current session score (if playing) */}
124
+ {currentScore !== undefined && currentScore > 0 && (
125
+ <div className="px-4 py-2 border-t border-white/[0.06] bg-[#E35930]/5">
126
+ <div className="flex items-center justify-between">
127
+ <span className="text-[10px] text-white/40">Current session</span>
128
+ <span className="text-sm font-bold text-[#E35930] tabular-nums">
129
+ {currentScore.toLocaleString()}
130
+ </span>
131
+ </div>
132
+ </div>
133
+ )}
134
+ </div>
135
+ );
136
+ }