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,230 @@
1
+ "use client";
2
+
3
+ import {
4
+ createContext,
5
+ useContext,
6
+ useEffect,
7
+ useState,
8
+ useCallback,
9
+ type ReactNode,
10
+ } from "react";
11
+ import { createClient } from "@/lib/supabase";
12
+ import type { User } from "@supabase/supabase-js";
13
+ import { usePhantom, useModal } from "@phantom/react-sdk";
14
+
15
+ interface Profile {
16
+ id: string;
17
+ wallet_address: string | null;
18
+ x_username: string | null;
19
+ x_avatar_url: string | null;
20
+ }
21
+
22
+ interface PhantomSdkAddress {
23
+ type?: string;
24
+ address?: string;
25
+ }
26
+
27
+ interface PhantomSdkUserLike {
28
+ solana?: {
29
+ address?: string;
30
+ };
31
+ addresses?: PhantomSdkAddress[];
32
+ }
33
+
34
+ interface AuthContextValue {
35
+ user: User | null;
36
+ profile: Profile | null;
37
+ loading: boolean;
38
+ connectPhantom: () => Promise<void>;
39
+ connectX: () => Promise<void>;
40
+ linkWallet: () => Promise<void>;
41
+ signOut: () => Promise<void>;
42
+ phantomAddress: string | null;
43
+ phantomConnected: boolean;
44
+ openPhantomModal: () => void;
45
+ closePhantomModal: () => void;
46
+ }
47
+
48
+ const AuthContext = createContext<AuthContextValue | null>(null);
49
+
50
+ export function AuthProvider({ children }: { children: ReactNode }) {
51
+ const [supabase] = useState(() => createClient());
52
+ const [user, setUser] = useState<User | null>(null);
53
+ const [profile, setProfile] = useState<Profile | null>(null);
54
+ const [loading, setLoading] = useState(true);
55
+
56
+ const { isConnected: phantomConnected, user: phantomUser } = usePhantom();
57
+ const { open: openPhantomModal, close: closePhantomModal } = useModal();
58
+ const phantomSdkUser = phantomUser as PhantomSdkUserLike | null;
59
+ const phantomAddress: string | null =
60
+ phantomSdkUser?.solana?.address ??
61
+ phantomSdkUser?.addresses?.find((a) => a.type === "solana")?.address ??
62
+ null;
63
+
64
+ const fetchProfile = useCallback(
65
+ async (userId: string) => {
66
+ const { data } = await supabase
67
+ .from("profiles")
68
+ .select("*")
69
+ .eq("id", userId)
70
+ .single();
71
+ setProfile(data);
72
+ },
73
+ [supabase]
74
+ );
75
+
76
+ useEffect(() => {
77
+ supabase.auth.getUser().then(({ data: { user } }) => {
78
+ setUser(user ?? null);
79
+ if (user) fetchProfile(user.id);
80
+ setLoading(false);
81
+ });
82
+
83
+ const {
84
+ data: { subscription },
85
+ } = supabase.auth.onAuthStateChange((_event, session) => {
86
+ const newUser = session?.user ?? null;
87
+ setUser(newUser);
88
+ if (newUser) {
89
+ fetchProfile(newUser.id);
90
+ } else {
91
+ setProfile(null);
92
+ }
93
+ });
94
+
95
+ return () => subscription.unsubscribe();
96
+ }, [supabase, fetchProfile]);
97
+
98
+ const connectPhantom = useCallback(async () => {
99
+ const provider = window.phantom?.solana;
100
+ if (!provider?.isPhantom) {
101
+ window.open("https://phantom.app/", "_blank");
102
+ return;
103
+ }
104
+
105
+ const { publicKey } = await provider.connect();
106
+ const walletAddress = publicKey.toString();
107
+
108
+ const timestamp = Date.now();
109
+ const message = `Sign in to Solanapolis\nWallet: ${walletAddress}\nTimestamp: ${timestamp}`;
110
+ const encodedMessage = new TextEncoder().encode(message);
111
+
112
+ const { signature } = await provider.signMessage(encodedMessage, "utf8");
113
+
114
+ // Encode as base64 for transport
115
+ const signatureBase64 = btoa(String.fromCharCode(...signature));
116
+ const publicKeyBase64 = btoa(String.fromCharCode(...publicKey.toBytes()));
117
+
118
+ const res = await fetch("/api/auth/phantom", {
119
+ method: "POST",
120
+ headers: { "Content-Type": "application/json" },
121
+ body: JSON.stringify({
122
+ walletAddress,
123
+ publicKey: publicKeyBase64,
124
+ signature: signatureBase64,
125
+ message,
126
+ timestamp,
127
+ }),
128
+ });
129
+
130
+ if (!res.ok) {
131
+ const err = await res.json();
132
+ throw new Error(err.error ?? "Phantom auth failed");
133
+ }
134
+
135
+ const { token_hash } = await res.json();
136
+
137
+ const { error } = await supabase.auth.verifyOtp({
138
+ token_hash,
139
+ type: "magiclink",
140
+ });
141
+
142
+ if (error) throw error;
143
+ }, [supabase]);
144
+
145
+ const connectX = useCallback(async () => {
146
+ // If already signed in (e.g. via Phantom), stash wallet so the callback
147
+ // can transfer it to the X-authenticated user, then sign out first.
148
+ if (user && profile?.wallet_address) {
149
+ document.cookie = `pendingWalletLink=${profile.wallet_address}; path=/; max-age=300; samesite=lax`;
150
+ await supabase.auth.signOut();
151
+ }
152
+
153
+ // Always use signInWithOAuth — avoids identity_already_exists errors
154
+ const { error } = await supabase.auth.signInWithOAuth({
155
+ provider: "x",
156
+ options: {
157
+ redirectTo: `${window.location.origin}/api/auth/callback`,
158
+ },
159
+ });
160
+ if (error) throw error;
161
+ }, [supabase, user, profile]);
162
+
163
+ const linkWallet = useCallback(async () => {
164
+ if (!user) return;
165
+
166
+ const provider = window.phantom?.solana;
167
+ if (!provider?.isPhantom) {
168
+ window.open("https://phantom.app/", "_blank");
169
+ return;
170
+ }
171
+
172
+ const { publicKey } = await provider.connect();
173
+ const walletAddress = publicKey.toString();
174
+
175
+ const timestamp = Date.now();
176
+ const message = `Link wallet to Solanapolis\nWallet: ${walletAddress}\nTimestamp: ${timestamp}`;
177
+ const encodedMessage = new TextEncoder().encode(message);
178
+
179
+ const { signature } = await provider.signMessage(encodedMessage, "utf8");
180
+
181
+ const signatureBase64 = btoa(String.fromCharCode(...signature));
182
+ const publicKeyBase64 = btoa(String.fromCharCode(...publicKey.toBytes()));
183
+
184
+ const res = await fetch("/api/auth/link-wallet", {
185
+ method: "POST",
186
+ headers: { "Content-Type": "application/json" },
187
+ body: JSON.stringify({
188
+ walletAddress,
189
+ publicKey: publicKeyBase64,
190
+ signature: signatureBase64,
191
+ message,
192
+ timestamp,
193
+ }),
194
+ });
195
+
196
+ if (!res.ok) {
197
+ const err = await res.json();
198
+ throw new Error(err.error ?? "Failed to link wallet");
199
+ }
200
+
201
+ // Refresh profile to pick up the new wallet_address
202
+ await fetchProfile(user.id);
203
+ }, [user, fetchProfile]);
204
+
205
+ const signOut = useCallback(async () => {
206
+ // Disconnect Phantom if connected
207
+ try {
208
+ await window.phantom?.solana?.disconnect();
209
+ } catch {
210
+ // ignore
211
+ }
212
+ await supabase.auth.signOut();
213
+ setUser(null);
214
+ setProfile(null);
215
+ }, [supabase]);
216
+
217
+ return (
218
+ <AuthContext.Provider
219
+ value={{ user, profile, loading, connectPhantom, connectX, linkWallet, signOut, phantomAddress, phantomConnected, openPhantomModal, closePhantomModal }}
220
+ >
221
+ {children}
222
+ </AuthContext.Provider>
223
+ );
224
+ }
225
+
226
+ export function useAuth() {
227
+ const ctx = useContext(AuthContext);
228
+ if (!ctx) throw new Error("useAuth must be used within AuthProvider");
229
+ return ctx;
230
+ }
@@ -0,0 +1,125 @@
1
+ const HELIUS_API_KEY = process.env.HELIUS_API_KEY!;
2
+ const HELIUS_RPC_URL = `https://mainnet.helius-rpc.com/?api-key=${HELIUS_API_KEY}`;
3
+
4
+ const BOT_DETECTION_TXS_PER_MINUTE = 20;
5
+ const MIN_TXS_FOR_BOT_CHECK = 100;
6
+
7
+ interface HeliusSignatureEntry {
8
+ signature: string;
9
+ blockTime: number | null;
10
+ }
11
+
12
+ async function fetchTransactionsFromHelius(
13
+ address: string,
14
+ limit: number,
15
+ sortOrder: "asc" | "desc",
16
+ ): Promise<HeliusSignatureEntry[]> {
17
+ const res = await fetch(HELIUS_RPC_URL, {
18
+ method: "POST",
19
+ headers: { "Content-Type": "application/json" },
20
+ body: JSON.stringify({
21
+ jsonrpc: "2.0",
22
+ id: 1,
23
+ method: "getTransactionsForAddress",
24
+ params: [
25
+ address,
26
+ {
27
+ sortOrder,
28
+ limit,
29
+ transactionDetails: "full",
30
+ encoding: "json",
31
+ maxSupportedTransactionVersion: 0,
32
+ filters: {
33
+ status: "succeeded",
34
+ tokenAccounts: "balanceChanged",
35
+ },
36
+ },
37
+ ],
38
+ }),
39
+ });
40
+
41
+ if (!res.ok) {
42
+ throw new Error(`Helius RPC error: ${res.status} ${res.statusText}`);
43
+ }
44
+
45
+ const json = await res.json();
46
+ if (json.error) {
47
+ throw new Error(`Helius RPC error: ${json.error.message}`);
48
+ }
49
+
50
+ return json.result?.data ?? [];
51
+ }
52
+
53
+ interface DensityResult {
54
+ isBot: boolean;
55
+ txsPerMinute: number;
56
+ label: string;
57
+ }
58
+
59
+ function checkBotDensity(
60
+ transactions: HeliusSignatureEntry[],
61
+ label: string,
62
+ ): DensityResult {
63
+ // Filter out transactions with no blockTime
64
+ const valid = transactions.filter((tx) => tx.blockTime != null && tx.blockTime > 0);
65
+ if (valid.length < 2) {
66
+ return { isBot: false, txsPerMinute: 0, label };
67
+ }
68
+
69
+ // Sort by blockTime ascending
70
+ valid.sort((a, b) => a.blockTime! - b.blockTime!);
71
+
72
+ const oldest = valid[0].blockTime!;
73
+ const newest = valid[valid.length - 1].blockTime!;
74
+ const timeSpanMinutes = (newest - oldest) / 60;
75
+
76
+ // All txns in the same block/second — treat as bot
77
+ if (timeSpanMinutes === 0) {
78
+ return { isBot: true, txsPerMinute: Infinity, label };
79
+ }
80
+
81
+ const txsPerMinute = valid.length / timeSpanMinutes;
82
+ return {
83
+ isBot: txsPerMinute >= BOT_DETECTION_TXS_PER_MINUTE,
84
+ txsPerMinute,
85
+ label,
86
+ };
87
+ }
88
+
89
+ export interface BotDetectionResult {
90
+ isBot: boolean;
91
+ reason?: string;
92
+ }
93
+
94
+ export async function detectBotActivity(
95
+ address: string,
96
+ ): Promise<BotDetectionResult> {
97
+ // Fetch oldest 100 transactions first
98
+ const oldest = await fetchTransactionsFromHelius(address, MIN_TXS_FOR_BOT_CHECK, "asc");
99
+
100
+ // If fewer than 100 total transactions, skip bot check
101
+ if (oldest.length < MIN_TXS_FOR_BOT_CHECK) {
102
+ return { isBot: false };
103
+ }
104
+
105
+ const oldestCheck = checkBotDensity(oldest, "oldest");
106
+ if (oldestCheck.isBot) {
107
+ return {
108
+ isBot: true,
109
+ reason: `High tx density in oldest transactions: ${oldestCheck.txsPerMinute.toFixed(1)} txns/min`,
110
+ };
111
+ }
112
+
113
+ // Fetch newest 100 transactions
114
+ const newest = await fetchTransactionsFromHelius(address, MIN_TXS_FOR_BOT_CHECK, "desc");
115
+
116
+ const newestCheck = checkBotDensity(newest, "newest");
117
+ if (newestCheck.isBot) {
118
+ return {
119
+ isBot: true,
120
+ reason: `High tx density in newest transactions: ${newestCheck.txsPerMinute.toFixed(1)} txns/min`,
121
+ };
122
+ }
123
+
124
+ return { isBot: false };
125
+ }
@@ -0,0 +1,136 @@
1
+ import { WalletBuilding, PlacedWallet } from "@/types/wallet";
2
+ import { CELL_SIZE, ROAD_WIDTH, BLOCK_SIZE, BLOCK_STRIDE, BLOCKS_PER_ROW, GRID_WORLD } from "./city-constants";
3
+ import { SkyscraperType } from "./skyscraper-types";
4
+ import { getBlockZone } from "./city-zoning";
5
+
6
+ // --- Height: txn count → floors (1–150) → world height ---
7
+
8
+ const FLOOR_HEIGHT = 0.3; // world units per floor
9
+
10
+ export function floors(txns: number): number {
11
+ // Map 10 → 500k into 0..1 in log-space, then apply x^2 easing
12
+ // so low-txn wallets stay short and only 100k+ get truly tall.
13
+ txns = Math.max(10, Math.min(txns, 500_000));
14
+ const x = (Math.log10(txns) - 1) / (Math.log10(500_000) - 1); // 0..1 over [10, 500k]
15
+ const eased = x * x; // quadratic — spreads out mid-to-high range
16
+ return Math.round(1 + eased * 149);
17
+ }
18
+
19
+ // --- Width: volume → 0.6–3.0 world units (scaled to fit cell grid) ---
20
+ // Spec maps 4–20 abstract units; we remap to 0.6–3.0 to fit CELL_SIZE=4
21
+
22
+ const WIDTH_WORLD_MIN = 0.6;
23
+ const WIDTH_WORLD_MAX = 3.0;
24
+
25
+ function buildingWidth(volume: number, maxVolume: number): number {
26
+ if (maxVolume <= 0) return WIDTH_WORLD_MIN;
27
+ const x = Math.log10(volume + 1) / Math.log10(maxVolume + 1);
28
+ const abstract = 4 + 16 * Math.sqrt(x); // 4–20
29
+ // Remap 4–20 → 0.6–3.0
30
+ const t = (abstract - 4) / 16;
31
+ return WIDTH_WORLD_MIN + t * (WIDTH_WORLD_MAX - WIDTH_WORLD_MIN);
32
+ }
33
+
34
+ // Depth = width (square footprint per spec)
35
+
36
+ // We need maxVolume across all wallets for the width formula.
37
+ // Compute it once and cache.
38
+ let cachedMaxVolume = 0;
39
+
40
+ export function setMaxVolume(wallets: WalletBuilding[]) {
41
+ cachedMaxVolume = 0;
42
+ for (const w of wallets) {
43
+ if (w.volumeTraded > cachedMaxVolume) cachedMaxVolume = w.volumeTraded;
44
+ }
45
+ }
46
+
47
+ export function getBuildingDimensions(wallet: WalletBuilding) {
48
+ const height = floors(wallet.txnCount) * FLOOR_HEIGHT;
49
+ const width = buildingWidth(wallet.volumeTraded, cachedMaxVolume);
50
+ const depth = width; // square footprint
51
+ return { height, width, depth };
52
+ }
53
+
54
+ // Color based on height — realistic urban palette
55
+ // Short buildings: warm sandstone/brick tones
56
+ // Mid-rise: concrete/tan
57
+ // Tall buildings: cool slate gray / steel blue
58
+ const COLOR_HEIGHT_MAX = 150 * FLOOR_HEIGHT; // 45 units (150 floors)
59
+
60
+ /** World-space position for a placed wallet (matches InstancedBuildings) */
61
+ export function getWalletWorldPosition(
62
+ w: PlacedWallet,
63
+ dims: { width: number; depth: number; height: number },
64
+ ): [number, number, number] {
65
+ const offset = -GRID_WORLD / 2 + BLOCK_SIZE / 2 + ROAD_WIDTH / 2;
66
+ const blockOriginX = offset + w.blockCol * BLOCK_STRIDE - BLOCK_SIZE / 2;
67
+ const blockOriginZ = offset + w.blockRow * BLOCK_STRIDE - BLOCK_SIZE / 2;
68
+ const localRow = Math.floor(w.localSlot / 4);
69
+ const localCol = w.localSlot % 4;
70
+ return [
71
+ blockOriginX + localCol * CELL_SIZE + dims.width / 2 + 0.5,
72
+ dims.height / 2,
73
+ blockOriginZ + localRow * CELL_SIZE + dims.depth / 2 + 0.5,
74
+ ];
75
+ }
76
+
77
+ // --- Window math ---
78
+
79
+ export function getWindowCols(buildingWidth: number): number {
80
+ return Math.max(2, Math.min(7, Math.round(buildingWidth * 2.5)));
81
+ }
82
+
83
+ export function getWindowFillRatio(uniqueTokensSwapped: number): number {
84
+ if (uniqueTokensSwapped <= 0) return 0;
85
+ return Math.max(0.02, Math.min(1.0, uniqueTokensSwapped / (uniqueTokensSwapped + 500)));
86
+ }
87
+
88
+ export function getLitRatio(latestBlocktime: number | null | undefined): number {
89
+ if (latestBlocktime == null) return 0.05;
90
+ const nowSeconds = Date.now() / 1000;
91
+ const ageDays = (nowSeconds - latestBlocktime) / 86400;
92
+ return Math.min(0.45, Math.max(0.05, 0.45 * Math.exp(-ageDays / 14)));
93
+ }
94
+
95
+ export function getInstanceSeed(address: string): number {
96
+ let hash = 0;
97
+ for (let i = 0; i < address.length; i++) {
98
+ hash = (hash * 31 + address.charCodeAt(i)) | 0;
99
+ }
100
+ return Math.abs(hash % 10007) / 10007;
101
+ }
102
+
103
+ export function getWindowRows(floorCount: number): number {
104
+ return Math.min(floorCount, 35);
105
+ }
106
+
107
+ // --- Skyscraper type assignment ---
108
+
109
+ /**
110
+ * Deterministically assign a skyscraper type to a downtown wallet.
111
+ * Non-downtown wallets always get "box".
112
+ */
113
+ export function getSkyscraperType(wallet: PlacedWallet): SkyscraperType {
114
+ const zone = getBlockZone(wallet.blockRow, wallet.blockCol, BLOCKS_PER_ROW);
115
+ if (zone.zone !== "downtown") return "box";
116
+
117
+ const seed = getInstanceSeed(wallet.address);
118
+ if (seed < 0.40) return "box";
119
+ if (seed < 0.55) return "setback";
120
+ if (seed < 0.70) return "twin";
121
+ if (seed < 0.85) return "cantilever";
122
+ return "spire";
123
+ }
124
+
125
+ export function getBuildingColor(height: number): string {
126
+ const t = Math.min(1, height / COLOR_HEIGHT_MAX);
127
+
128
+ // Short (t≈0): lighter concrete rgb(100, 98, 110)
129
+ // Mid (t≈0.5): mid slate rgb(82, 82, 105)
130
+ // Tall (t≈1): near ground tone rgb(65, 68, 95)
131
+ // Ground is #4a4a65 = rgb(74, 74, 101)
132
+ const r = Math.floor(100 - t * 35);
133
+ const g = Math.floor(98 - t * 30);
134
+ const b = Math.floor(110 - t * 15);
135
+ return `rgb(${r},${g},${b})`;
136
+ }