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,133 @@
1
+ "use client";
2
+
3
+ import { useEffect, useRef } from "react";
4
+ import type { Map as MapboxMap, Marker } from "mapbox-gl";
5
+
6
+ interface MiniMapProps {
7
+ lon: number;
8
+ lat: number;
9
+ heading: number;
10
+ }
11
+
12
+ const MAPBOX_TOKEN = process.env.NEXT_PUBLIC_MAPBOX_TOKEN ?? "";
13
+
14
+ export default function MiniMap({ lon, lat, heading }: MiniMapProps) {
15
+ const containerRef = useRef<HTMLDivElement>(null);
16
+ const mapRef = useRef<MapboxMap | null>(null);
17
+ const markerRef = useRef<Marker | null>(null);
18
+ // Inner arrow element we rotate — the marker wrapper is owned by Mapbox for positioning
19
+ const arrowRef = useRef<HTMLDivElement | null>(null);
20
+
21
+ // Initialize map once
22
+ useEffect(() => {
23
+ if (!containerRef.current || !MAPBOX_TOKEN) return;
24
+ let mounted = true;
25
+
26
+ async function init() {
27
+ const mapboxgl = (await import("mapbox-gl")).default;
28
+
29
+ if (!document.querySelector("#mapbox-css")) {
30
+ const link = document.createElement("link");
31
+ link.id = "mapbox-css";
32
+ link.rel = "stylesheet";
33
+ link.href = "https://api.mapbox.com/mapbox-gl-js/v3.19.1/mapbox-gl.css";
34
+ document.head.appendChild(link);
35
+ }
36
+
37
+ if (!mounted || !containerRef.current) return;
38
+ mapboxgl.accessToken = MAPBOX_TOKEN;
39
+
40
+ const map = new mapboxgl.Map({
41
+ container: containerRef.current,
42
+ style: "mapbox://styles/mapbox/satellite-streets-v12",
43
+ center: [lon, lat],
44
+ zoom: 12,
45
+ bearing: 0,
46
+ pitch: 0,
47
+ interactive: false,
48
+ attributionControl: false,
49
+ });
50
+
51
+ mapRef.current = map;
52
+
53
+ // Outer wrapper (just a transparent hit-area — Mapbox positions this)
54
+ const wrapper = document.createElement("div");
55
+ wrapper.style.cssText = "width:24px;height:24px;position:relative;";
56
+
57
+ // Inner arrow — this is what we rotate, so Mapbox's translate() stays intact
58
+ const arrow = document.createElement("div");
59
+ arrow.style.cssText = `
60
+ width: 24px;
61
+ height: 24px;
62
+ background: #E35930;
63
+ clip-path: polygon(50% 0%, 18% 100%, 50% 78%, 82% 100%);
64
+ transform-origin: 50% 50%;
65
+ filter: drop-shadow(0 0 5px rgba(227,89,48,0.9));
66
+ transition: transform 0.15s linear;
67
+ `;
68
+ wrapper.appendChild(arrow);
69
+ arrowRef.current = arrow;
70
+
71
+ map.on("load", () => {
72
+ if (!mounted) return;
73
+ const marker = new mapboxgl.Marker({ element: wrapper, anchor: "center" })
74
+ .setLngLat([lon, lat])
75
+ .addTo(map);
76
+ markerRef.current = marker;
77
+ });
78
+ }
79
+
80
+ init().catch(console.error);
81
+
82
+ return () => {
83
+ mounted = false;
84
+ try { mapRef.current?.remove(); } catch { /* ignore */ }
85
+ mapRef.current = null;
86
+ markerRef.current = null;
87
+ arrowRef.current = null;
88
+ };
89
+ }, []); // eslint-disable-line react-hooks/exhaustive-deps
90
+
91
+ // Update position and heading each render
92
+ useEffect(() => {
93
+ const map = mapRef.current;
94
+ const marker = markerRef.current;
95
+ const arrow = arrowRef.current;
96
+ if (!map || !marker || !arrow) return;
97
+
98
+ marker.setLngLat([lon, lat]);
99
+
100
+ // Rotate only the arrow child — leaves Mapbox's position transform on the wrapper untouched
101
+ arrow.style.transform = `rotate(${heading}deg)`;
102
+
103
+ // Smoothly pan the map to follow aircraft
104
+ map.easeTo({ center: [lon, lat], duration: 200, easing: (t) => t });
105
+ }, [lon, lat, heading]);
106
+
107
+ if (!MAPBOX_TOKEN) return null;
108
+
109
+ return (
110
+ <div
111
+ className="relative rounded-2xl overflow-hidden shadow-2xl"
112
+ style={{
113
+ width: 180,
114
+ height: 180,
115
+ border: "1px solid rgba(255,255,255,0.12)",
116
+ boxShadow: "0 4px 32px rgba(0,0,0,0.7), 0 0 0 1px rgba(227,89,48,0.15)",
117
+ }}
118
+ >
119
+ <div ref={containerRef} className="w-full h-full" />
120
+
121
+ {/* Compass rose */}
122
+ <div className="absolute top-1.5 left-1/2 -translate-x-1/2 text-[8px] text-white/70 font-bold tracking-widest pointer-events-none select-none drop-shadow-[0_1px_2px_rgba(0,0,0,0.9)]">N</div>
123
+ <div className="absolute bottom-7 left-1/2 -translate-x-1/2 text-[8px] text-white/50 font-bold tracking-widest pointer-events-none select-none drop-shadow-[0_1px_2px_rgba(0,0,0,0.9)]">S</div>
124
+ <div className="absolute left-1.5 top-1/2 -translate-y-1/2 text-[8px] text-white/50 font-bold tracking-widest pointer-events-none select-none drop-shadow-[0_1px_2px_rgba(0,0,0,0.9)]">W</div>
125
+ <div className="absolute right-1.5 top-1/2 -translate-y-1/2 text-[8px] text-white/50 font-bold tracking-widest pointer-events-none select-none drop-shadow-[0_1px_2px_rgba(0,0,0,0.9)]">E</div>
126
+
127
+ {/* Label */}
128
+ <div className="absolute bottom-1.5 left-1/2 -translate-x-1/2 text-[8px] text-white/60 font-mono tracking-widest bg-black/70 px-2 py-0.5 rounded-full pointer-events-none whitespace-nowrap">
129
+ SOLANApolis
130
+ </div>
131
+ </div>
132
+ );
133
+ }
@@ -0,0 +1,383 @@
1
+ "use client";
2
+
3
+ import { useState, useEffect, useRef, useCallback, Component, type ReactNode } from "react";
4
+ import { useQuery, useMutation } from "convex/react";
5
+ import { useStream } from "@convex-dev/persistent-text-streaming/react";
6
+ import { api } from "../../convex/_generated/api";
7
+ import type { StreamId } from "@convex-dev/persistent-text-streaming";
8
+ import type { Doc } from "../../convex/_generated/dataModel";
9
+
10
+ /** Catches Convex "no provider" errors so the whole page doesn't crash */
11
+ class ConvexErrorBoundary extends Component<{ children: ReactNode }, { hasError: boolean }> {
12
+ state = { hasError: false };
13
+ static getDerivedStateFromError() { return { hasError: true }; }
14
+ render() {
15
+ if (this.state.hasError) return null;
16
+ return this.props.children;
17
+ }
18
+ }
19
+
20
+ interface GameChatProps {
21
+ walletAddress: string | null;
22
+ displayName?: string | null;
23
+ hasBuilding: boolean;
24
+ }
25
+
26
+ const CONVEX_SITE_URL = process.env.NEXT_PUBLIC_CONVEX_SITE_URL || "https://original-ibex-124.convex.site";
27
+
28
+ function truncAddr(addr: string | null | undefined): string {
29
+ if (!addr) return "unknown";
30
+ return addr.length <= 10 ? addr : addr.slice(0, 4) + "\u2026" + addr.slice(-4);
31
+ }
32
+
33
+ function nameHue(addr: string | null | undefined): number {
34
+ const safe = addr || "unknown";
35
+ let h = 0;
36
+ for (let i = 0; i < safe.length; i++) h = ((h << 5) - h + safe.charCodeAt(i)) | 0;
37
+ return Math.abs(h) % 360;
38
+ }
39
+
40
+ function ago(ts: number): string {
41
+ const s = Math.floor((Date.now() - ts) / 1000);
42
+ if (s < 10) return "now";
43
+ if (s < 60) return `${s}s`;
44
+ if (s < 3600) return `${Math.floor(s / 60)}m`;
45
+ return `${Math.floor(s / 3600)}h`;
46
+ }
47
+
48
+ /** Renders a single AI message with streaming support */
49
+ function AiMessage({
50
+ msg,
51
+ isDriven,
52
+ }: {
53
+ msg: Doc<"chat_messages">;
54
+ isDriven: boolean;
55
+ }) {
56
+ const streamId = msg.responseStreamId as StreamId | undefined;
57
+ const { text, status } = useStream(
58
+ api.streaming.getStreamBody,
59
+ new URL(`${CONVEX_SITE_URL}/ai-chat-stream`),
60
+ isDriven,
61
+ streamId,
62
+ );
63
+ const displayText = text || (status === "pending" ? "Thinking..." : msg.message || "...");
64
+
65
+ return (
66
+ <div className="flex flex-col items-start">
67
+ <div className="flex items-center gap-1 mb-0.5">
68
+ <div className="w-1.5 h-1.5 rounded-full shrink-0 bg-gradient-to-r from-amber-400 to-orange-500" />
69
+ <span className="text-[9px] font-semibold text-amber-400/90">
70
+ {msg.display_name || "Helios AI"}
71
+ </span>
72
+ <span className="text-[7px] text-amber-400/30">AI</span>
73
+ <span className="text-[7px] text-white/10">{ago(msg._creationTime)}</span>
74
+ </div>
75
+ <div className="max-w-[85%] px-2.5 py-1.5 rounded-lg text-[11px] leading-relaxed break-words bg-amber-500/[0.08] text-amber-100/70 rounded-bl-sm border border-amber-500/[0.08]">
76
+ {displayText}
77
+ {status === "streaming" && (
78
+ <span className="inline-block w-1 h-3 ml-0.5 bg-amber-400/50 animate-pulse" />
79
+ )}
80
+ {status === "error" && (
81
+ <span className="text-red-400/50 text-[9px] ml-1">(error)</span>
82
+ )}
83
+ </div>
84
+ </div>
85
+ );
86
+ }
87
+
88
+ /** Renders an NPC agent message (no streaming) */
89
+ function NPCMessage({ msg }: { msg: Doc<"chat_messages"> }) {
90
+ const wallet = msg.wallet_address || "npc";
91
+ const c = `hsl(${nameHue(wallet)},55%,65%)`;
92
+
93
+ return (
94
+ <div className="flex flex-col items-start">
95
+ <div className="flex items-center gap-1 mb-0.5">
96
+ <div className="w-1.5 h-1.5 rounded-full shrink-0 bg-gradient-to-r from-violet-400 to-fuchsia-500" />
97
+ <span className="text-[9px] font-semibold" style={{ color: c }}>
98
+ {msg.display_name || "NPC"}
99
+ </span>
100
+ <span className="text-[7px] text-violet-400/40">NPC</span>
101
+ <span className="text-[7px] text-white/10">{ago(msg._creationTime)}</span>
102
+ </div>
103
+ <div className="max-w-[85%] px-2.5 py-1.5 rounded-lg text-[11px] leading-relaxed break-words bg-violet-500/[0.08] text-violet-100/70 rounded-bl-sm border border-violet-500/[0.08]">
104
+ {msg.message}
105
+ </div>
106
+ </div>
107
+ );
108
+ }
109
+
110
+ /** Renders a player message */
111
+ function PlayerMessage({
112
+ msg,
113
+ walletAddress,
114
+ }: {
115
+ msg: Doc<"chat_messages">;
116
+ walletAddress: string | null;
117
+ }) {
118
+ const wallet = msg.wallet_address || "unknown";
119
+ const me = walletAddress ? wallet === walletAddress : false;
120
+ const c = `hsl(${nameHue(wallet)},65%,60%)`;
121
+
122
+ return (
123
+ <div className={`flex flex-col ${me ? "items-end" : "items-start"}`}>
124
+ <div className="flex items-center gap-1 mb-0.5">
125
+ <div className="w-1.5 h-1.5 rounded-full shrink-0" style={{ background: c }} />
126
+ <span className="text-[9px] font-semibold" style={{ color: c }}>
127
+ {msg.display_name || truncAddr(wallet)}
128
+ </span>
129
+ <span className="text-[7px] text-white/10">{ago(msg._creationTime)}</span>
130
+ </div>
131
+ <div
132
+ className={`max-w-[80%] px-2.5 py-1 rounded-lg text-[11px] leading-relaxed break-words ${me
133
+ ? "bg-[#E35930]/12 text-white/80 rounded-br-sm"
134
+ : "bg-white/[0.04] text-white/60 rounded-bl-sm"
135
+ }`}
136
+ >
137
+ {msg.message}
138
+ </div>
139
+ </div>
140
+ );
141
+ }
142
+
143
+ export default function GameChat(props: GameChatProps) {
144
+ return (
145
+ <ConvexErrorBoundary>
146
+ <GameChatInner {...props} />
147
+ </ConvexErrorBoundary>
148
+ );
149
+ }
150
+
151
+ function GameChatInner({ walletAddress, displayName, hasBuilding }: GameChatProps) {
152
+ const messages = useQuery(api.chat.listMessages);
153
+ const sendMessageMutation = useMutation(api.chat.sendMessage);
154
+
155
+ const [drivenStreamIds, setDrivenStreamIds] = useState<Set<string>>(new Set());
156
+ const [txt, setTxt] = useState("");
157
+ const [open, setOpen] = useState(false);
158
+ const [busy, setBusy] = useState(false);
159
+ const [unread, setUnread] = useState(0);
160
+ const scrollEl = useRef<HTMLDivElement>(null);
161
+ const openRef = useRef(false);
162
+ const prevCountRef = useRef(0);
163
+ openRef.current = open;
164
+
165
+ // Track unread messages
166
+ useEffect(() => {
167
+ if (!messages) return;
168
+ const count = messages.length;
169
+ if (count > prevCountRef.current && !openRef.current) {
170
+ setUnread((c) => c + (count - prevCountRef.current));
171
+ }
172
+ prevCountRef.current = count;
173
+ }, [messages]);
174
+
175
+ // Auto-scroll to bottom
176
+ useEffect(() => {
177
+ if (open && scrollEl.current) {
178
+ scrollEl.current.scrollTop = scrollEl.current.scrollHeight;
179
+ }
180
+ }, [messages, open]);
181
+
182
+ // Clear unread when opened
183
+ useEffect(() => {
184
+ if (open) setUnread(0);
185
+ }, [open]);
186
+
187
+ // Re-render timestamps periodically
188
+ const [, setTick] = useState(0);
189
+ useEffect(() => {
190
+ const interval = setInterval(() => setTick((t) => t + 1), 30_000);
191
+ return () => clearInterval(interval);
192
+ }, []);
193
+
194
+ const send = useCallback(async () => {
195
+ if (!walletAddress || !hasBuilding || !txt.trim() || busy) return;
196
+ const text = txt.trim().slice(0, 280);
197
+ setTxt("");
198
+ setBusy(true);
199
+ try {
200
+ const result = await sendMessageMutation({
201
+ wallet_address: walletAddress,
202
+ display_name: displayName || truncAddr(walletAddress),
203
+ message: text,
204
+ });
205
+ // Mark this stream as driven by us so we initiate the HTTP stream
206
+ if (result?.streamId) {
207
+ setDrivenStreamIds((prev) => new Set(prev).add(result.streamId));
208
+ }
209
+ } catch (e: unknown) {
210
+ console.warn("[Chat] send error:", e instanceof Error ? e.message : e);
211
+ setTxt(text);
212
+ } finally {
213
+ setBusy(false);
214
+ }
215
+ }, [walletAddress, hasBuilding, displayName, txt, busy, sendMessageMutation]);
216
+
217
+ const onKey = useCallback(
218
+ (e: React.KeyboardEvent) => {
219
+ e.stopPropagation();
220
+ if (e.key === "Enter" && !e.shiftKey) {
221
+ e.preventDefault();
222
+ send();
223
+ }
224
+ },
225
+ [send],
226
+ );
227
+
228
+ const onKeyUp = useCallback((e: React.KeyboardEvent) => {
229
+ e.stopPropagation();
230
+ }, []);
231
+
232
+ const connected = messages !== undefined;
233
+ const rtState = connected ? "connected" : "connecting";
234
+
235
+ return (
236
+ <>
237
+ {/* Toggle */}
238
+ <div className="fixed bottom-4 left-4 sm:left-5 z-30">
239
+ <button
240
+ onClick={() => setOpen((o) => !o)}
241
+ className="flex items-center gap-2 bg-black/50 backdrop-blur-xl border border-white/[0.08] rounded-xl px-3 py-2 transition-all group hover:bg-black/70 cursor-pointer"
242
+ title={
243
+ !walletAddress
244
+ ? "Read city chat \u2014 connect wallet + claim building to send"
245
+ : hasBuilding
246
+ ? "Toggle chat"
247
+ : "Read-only chat \u2014 claim a building to send messages"
248
+ }
249
+ >
250
+ <span className="text-sm group-hover:scale-110 transition-transform">
251
+ {"\uD83D\uDCAC"}
252
+ </span>
253
+ <span className="text-[11px] text-white/40 font-medium group-hover:text-white/60">
254
+ City Chat
255
+ </span>
256
+ {rtState === "connected" ? (
257
+ <span className="w-1.5 h-1.5 rounded-full bg-emerald-400/80" title="Live" />
258
+ ) : (
259
+ <span
260
+ className="w-1.5 h-1.5 rounded-full bg-amber-400/80 animate-pulse"
261
+ title="Connecting"
262
+ />
263
+ )}
264
+ {unread > 0 && (
265
+ <span className="min-w-[16px] h-4 bg-[#E35930] rounded-full flex items-center justify-center px-1">
266
+ <span className="text-[8px] font-bold text-white leading-none">
267
+ {unread > 99 ? "99+" : unread}
268
+ </span>
269
+ </span>
270
+ )}
271
+ </button>
272
+ </div>
273
+
274
+ {/* Chat panel */}
275
+ {open && (
276
+ <div
277
+ className="fixed z-40 w-[calc(100vw-1.5rem)] sm:w-80 bg-black/80 backdrop-blur-2xl border border-white/[0.08] rounded-2xl overflow-hidden flex flex-col shadow-2xl"
278
+ style={{
279
+ bottom: 54,
280
+ left: 12,
281
+ maxHeight: "min(430px, calc(100vh - 140px))",
282
+ }}
283
+ >
284
+ {/* Header */}
285
+ <div className="px-3 py-2 border-b border-white/[0.06] flex items-center justify-between shrink-0">
286
+ <div className="flex items-center gap-2">
287
+ <span className="text-xs">{"\uD83D\uDCAC"}</span>
288
+ <span className="text-xs font-bold text-white/80">City Chat</span>
289
+ <span
290
+ className={`text-[8px] px-1.5 py-0.5 rounded ${connected
291
+ ? "bg-emerald-500/15 text-emerald-300/80"
292
+ : "bg-amber-500/15 text-amber-300/80"
293
+ }`}
294
+ >
295
+ {connected ? "LIVE" : "CONNECTING"}
296
+ </span>
297
+ </div>
298
+ <div className="flex items-center gap-1.5">
299
+ {walletAddress && (
300
+ <span className="text-[8px] text-white/25 font-mono bg-white/[0.04] rounded px-1.5 py-0.5">
301
+ {truncAddr(walletAddress)}
302
+ </span>
303
+ )}
304
+ <button
305
+ onClick={() => setOpen(false)}
306
+ className="w-5 h-5 flex items-center justify-center rounded hover:bg-white/10 text-white/25 hover:text-white/50 text-xs cursor-pointer"
307
+ >
308
+ {"\u2715"}
309
+ </button>
310
+ </div>
311
+ </div>
312
+
313
+ {/* Messages */}
314
+ <div
315
+ ref={scrollEl}
316
+ className="flex-1 overflow-y-auto px-3 py-2 space-y-1.5"
317
+ style={{ minHeight: 100 }}
318
+ >
319
+ {!connected ? (
320
+ <div className="text-center text-white/15 text-[10px] py-6">
321
+ <div className="w-4 h-4 border-2 border-white/10 border-t-[#E35930]/40 rounded-full animate-spin mx-auto mb-2" />
322
+ Connecting to Convex...
323
+ </div>
324
+ ) : messages.length === 0 ? (
325
+ <div className="text-center text-white/15 text-[10px] py-6">
326
+ <div className="mb-1">{"\uD83D\uDC4B"}</div>
327
+ No messages yet — be the first to say hello!
328
+ </div>
329
+ ) : (
330
+ messages.map((m) =>
331
+ m.is_ai && m.responseStreamId ? (
332
+ <AiMessage
333
+ key={m._id}
334
+ msg={m}
335
+ isDriven={drivenStreamIds.has(m.responseStreamId || "")}
336
+ />
337
+ ) : m.is_ai ? (
338
+ <NPCMessage key={m._id} msg={m} />
339
+ ) : (
340
+ <PlayerMessage key={m._id} msg={m} walletAddress={walletAddress} />
341
+ ),
342
+ )
343
+ )}
344
+ </div>
345
+
346
+ {/* Input */}
347
+ {walletAddress && hasBuilding ? (
348
+ <div className="px-3 py-2 border-t border-white/[0.04] shrink-0">
349
+ <div className="flex gap-1.5">
350
+ <input
351
+ type="text"
352
+ value={txt}
353
+ onChange={(e) => setTxt(e.target.value)}
354
+ onKeyDown={onKey}
355
+ onKeyUp={onKeyUp}
356
+ placeholder="Say something..."
357
+ maxLength={280}
358
+ className="flex-1 bg-white/[0.05] border border-white/[0.06] rounded-lg px-2.5 py-1.5 text-[11px] text-white/70 placeholder:text-white/15 outline-none focus:border-[#E35930]/20 transition-colors"
359
+ disabled={busy}
360
+ />
361
+ <button
362
+ onClick={send}
363
+ disabled={!txt.trim() || busy}
364
+ className="w-8 h-8 flex items-center justify-center bg-[#E35930]/15 hover:bg-[#E35930]/25 border border-[#E35930]/15 rounded-lg text-xs text-[#E35930] disabled:opacity-20 cursor-pointer transition-colors"
365
+ >
366
+ {"\u2191"}
367
+ </button>
368
+ </div>
369
+ </div>
370
+ ) : (
371
+ <div className="px-3 py-2.5 border-t border-white/[0.04] text-center shrink-0">
372
+ <p className="text-[9px] text-white/20">
373
+ {!walletAddress
374
+ ? "Read-only \u2014 connect wallet + claim building to chat"
375
+ : "Read-only \u2014 claim a building to start chatting"}
376
+ </p>
377
+ </div>
378
+ )}
379
+ </div>
380
+ )}
381
+ </>
382
+ );
383
+ }