solanapolis 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +518 -0
- package/bin/solanapolis.js +197 -0
- package/convex/_generated/api.d.ts +175 -0
- package/convex/_generated/api.js +23 -0
- package/convex/_generated/dataModel.d.ts +60 -0
- package/convex/_generated/server.d.ts +143 -0
- package/convex/_generated/server.js +93 -0
- package/convex/agent/conversation.ts +352 -0
- package/convex/agent/embeddingsCache.ts +110 -0
- package/convex/agent/memory.ts +450 -0
- package/convex/agent/schema.ts +53 -0
- package/convex/aiChat.ts +54 -0
- package/convex/aiTown/agent.ts +382 -0
- package/convex/aiTown/agentDescription.ts +27 -0
- package/convex/aiTown/agentInputs.ts +155 -0
- package/convex/aiTown/agentOperations.ts +178 -0
- package/convex/aiTown/conversation.ts +395 -0
- package/convex/aiTown/conversationMembership.ts +38 -0
- package/convex/aiTown/game.ts +371 -0
- package/convex/aiTown/ids.ts +32 -0
- package/convex/aiTown/inputHandler.ts +9 -0
- package/convex/aiTown/inputs.ts +25 -0
- package/convex/aiTown/insertInput.ts +20 -0
- package/convex/aiTown/location.ts +32 -0
- package/convex/aiTown/main.ts +154 -0
- package/convex/aiTown/movement.ts +189 -0
- package/convex/aiTown/player.ts +310 -0
- package/convex/aiTown/playerDescription.ts +35 -0
- package/convex/aiTown/schema.ts +79 -0
- package/convex/aiTown/world.ts +65 -0
- package/convex/aiTown/worldMap.ts +74 -0
- package/convex/chat.ts +79 -0
- package/convex/constants.ts +78 -0
- package/convex/convex.config.ts +6 -0
- package/convex/crons.ts +89 -0
- package/convex/engine/abstractGame.ts +199 -0
- package/convex/engine/historicalObject.ts +355 -0
- package/convex/engine/schema.ts +56 -0
- package/convex/http.ts +36 -0
- package/convex/init.ts +110 -0
- package/convex/messages.ts +53 -0
- package/convex/npcCarAgents.ts +415 -0
- package/convex/schema.ts +61 -0
- package/convex/streaming.ts +23 -0
- package/convex/testing.ts +202 -0
- package/convex/tsconfig.json +18 -0
- package/convex/util/FastIntegerCompression.ts +221 -0
- package/convex/util/assertNever.ts +4 -0
- package/convex/util/asyncMap.ts +20 -0
- package/convex/util/compression.ts +71 -0
- package/convex/util/geometry.ts +132 -0
- package/convex/util/isSimpleObject.ts +11 -0
- package/convex/util/llm.ts +724 -0
- package/convex/util/minheap.ts +38 -0
- package/convex/util/object.ts +22 -0
- package/convex/util/sleep.ts +3 -0
- package/convex/util/types.ts +33 -0
- package/convex/util/xxhash.ts +228 -0
- package/convex/world.ts +257 -0
- package/data/animations/campfire.json +45 -0
- package/data/animations/gentlesparkle.json +37 -0
- package/data/animations/gentlesplash.json +61 -0
- package/data/animations/gentlewaterfall.json +61 -0
- package/data/animations/windmill.json +78 -0
- package/data/characters.ts +121 -0
- package/data/convertMap.js +74 -0
- package/data/gentle.js +330 -0
- package/data/spritesheets/f1.ts +75 -0
- package/data/spritesheets/f2.ts +75 -0
- package/data/spritesheets/f3.ts +75 -0
- package/data/spritesheets/f4.ts +75 -0
- package/data/spritesheets/f5.ts +75 -0
- package/data/spritesheets/f6.ts +75 -0
- package/data/spritesheets/f7.ts +75 -0
- package/data/spritesheets/f8.ts +75 -0
- package/data/spritesheets/p1.ts +59 -0
- package/data/spritesheets/p2.ts +59 -0
- package/data/spritesheets/p3.ts +59 -0
- package/data/spritesheets/player.ts +59 -0
- package/data/spritesheets/types.ts +26 -0
- package/eslint.config.mjs +37 -0
- package/next.config.ts +7 -0
- package/package.json +85 -0
- package/postcss.config.mjs +7 -0
- package/public/file.svg +1 -0
- package/public/globe.svg +1 -0
- package/public/helius-icon.svg +84 -0
- package/public/helius-logo.svg +85 -0
- package/public/next.svg +1 -0
- package/public/plane.glb +0 -0
- package/public/vercel.svg +1 -0
- package/public/window.svg +1 -0
- package/scripts/clear-city.ts +74 -0
- package/scripts/seed-wallets.ts +185 -0
- package/scripts/setup-webhook.ts +73 -0
- package/src/app/api/auth/callback/route.ts +6 -0
- package/src/app/api/auth/link-wallet/route.ts +6 -0
- package/src/app/api/auth/phantom/route.ts +6 -0
- package/src/app/api/broadcast-position/route.ts +59 -0
- package/src/app/api/leaderboard/route.ts +85 -0
- package/src/app/api/network-stats/route.ts +86 -0
- package/src/app/api/parcel-reward/route.ts +181 -0
- package/src/app/api/queue-status/route.ts +30 -0
- package/src/app/api/snapshots/route.ts +37 -0
- package/src/app/api/transactions/enhanced/route.ts +57 -0
- package/src/app/api/treasury/route.ts +83 -0
- package/src/app/api/wallet/[address]/balances/route.ts +124 -0
- package/src/app/api/wallet/[address]/identity/route.ts +32 -0
- package/src/app/api/wallet/[address]/route.ts +216 -0
- package/src/app/api/wallet/[address]/traded-tokens/route.ts +41 -0
- package/src/app/api/wallets/route.ts +68 -0
- package/src/app/api/webhooks/helius/route.ts +76 -0
- package/src/app/auth/callback/page.tsx +29 -0
- package/src/app/favicon.ico +0 -0
- package/src/app/globals.css +39 -0
- package/src/app/layout.tsx +43 -0
- package/src/app/page.tsx +16 -0
- package/src/components/AITownNPCs.tsx +206 -0
- package/src/components/ActivityFeed.tsx +189 -0
- package/src/components/AuthPanel.tsx +163 -0
- package/src/components/BeachScene.tsx +280 -0
- package/src/components/Building.tsx +138 -0
- package/src/components/CesiumFlight.tsx +1768 -0
- package/src/components/CesiumGlobe.tsx +616 -0
- package/src/components/CitizenCard.tsx +442 -0
- package/src/components/CitizenCardModal.tsx +153 -0
- package/src/components/CityGrid.tsx +313 -0
- package/src/components/CityLandmarks.tsx +427 -0
- package/src/components/CityScene.tsx +1289 -0
- package/src/components/CitySlotsBadge.tsx +68 -0
- package/src/components/CockpitHUD.tsx +460 -0
- package/src/components/ConvexWrapper.tsx +19 -0
- package/src/components/DubaiDistrict.tsx +630 -0
- package/src/components/FlightMiniMap.tsx +133 -0
- package/src/components/GameChat.tsx +383 -0
- package/src/components/GameHUD.tsx +393 -0
- package/src/components/Ground.tsx +14 -0
- package/src/components/HowItWorksModal.tsx +251 -0
- package/src/components/IngestionBanner.tsx +123 -0
- package/src/components/InstancedBuildings.tsx +316 -0
- package/src/components/InstancedCars.tsx +504 -0
- package/src/components/InstancedCityPlanes.tsx +259 -0
- package/src/components/InstancedHouses.tsx +246 -0
- package/src/components/InstancedLampPosts.tsx +201 -0
- package/src/components/InstancedResidentCars.tsx +357 -0
- package/src/components/InstancedRoadDashes.tsx +42 -0
- package/src/components/InstancedSkyscrapers.tsx +434 -0
- package/src/components/InstancedTrees.tsx +67 -0
- package/src/components/LeaderboardPanel.tsx +136 -0
- package/src/components/MultiplayerPlanes.tsx +128 -0
- package/src/components/NetworkStats.tsx +83 -0
- package/src/components/NewBuildingSpotlight.tsx +93 -0
- package/src/components/ParcelChallengeBanner.tsx +242 -0
- package/src/components/ParcelReward.tsx +191 -0
- package/src/components/Park.tsx +42 -0
- package/src/components/PhantomWrapper.tsx +22 -0
- package/src/components/PixelStreamViewer.tsx +335 -0
- package/src/components/PlaneMode.tsx +190 -0
- package/src/components/PlayerCar.tsx +211 -0
- package/src/components/PlayerPlane.tsx +255 -0
- package/src/components/ProjectileRenderer.tsx +249 -0
- package/src/components/QueueStatusBanner.tsx +86 -0
- package/src/components/RealPlayerTags.tsx +82 -0
- package/src/components/SceneLighting.tsx +382 -0
- package/src/components/SelectionBeam.tsx +59 -0
- package/src/components/SwapPanel.tsx +104 -0
- package/src/components/SwapParticles.tsx +237 -0
- package/src/components/TreasureGate.tsx +505 -0
- package/src/components/WalletPanel.tsx +421 -0
- package/src/components/WalletSearch.tsx +244 -0
- package/src/components/WelcomeOverlay.tsx +135 -0
- package/src/components/WindowTooltip.tsx +498 -0
- package/src/context/AuthContext.tsx +230 -0
- package/src/lib/bot-detection.ts +125 -0
- package/src/lib/building-math.ts +136 -0
- package/src/lib/building-shader.ts +253 -0
- package/src/lib/car-paths.ts +244 -0
- package/src/lib/car-system.ts +182 -0
- package/src/lib/city-constants.ts +29 -0
- package/src/lib/city-slots.ts +35 -0
- package/src/lib/city-zoning.ts +64 -0
- package/src/lib/collision-map.ts +147 -0
- package/src/lib/day-night.ts +252 -0
- package/src/lib/export-card.ts +28 -0
- package/src/lib/helius-webhook.ts +90 -0
- package/src/lib/helius.ts +74 -0
- package/src/lib/house-shader.ts +119 -0
- package/src/lib/mock-data.ts +56 -0
- package/src/lib/multiplayer-manager.ts +329 -0
- package/src/lib/plane-physics.ts +66 -0
- package/src/lib/player-car.ts +147 -0
- package/src/lib/player-plane.ts +200 -0
- package/src/lib/projectile-system.ts +272 -0
- package/src/lib/skyscraper-types.ts +52 -0
- package/src/lib/sound-engine.ts +464 -0
- package/src/lib/supabase-admin.ts +9 -0
- package/src/lib/supabase.ts +8 -0
- package/src/lib/swap-events.ts +70 -0
- package/src/middleware.ts +37 -0
- package/src/types/phantom.d.ts +16 -0
- package/src/types/wallet.ts +20 -0
- package/tsconfig.json +34 -0
|
@@ -0,0 +1,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
|
+
}
|