Spaces:
Running
Running
Upload 16 files
Browse files- .dockerignore +9 -0
- .env.example +9 -0
- .gitignore +8 -0
- App.tsx +1872 -0
- Dockerfile +28 -0
- README.md +20 -11
- favicon.svg +34 -0
- index.css +22 -0
- index.html +13 -0
- main.tsx +10 -0
- metadata.json +5 -0
- package-lock.json +0 -0
- package.json +36 -0
- server.ts +135 -0
- tsconfig.json +26 -0
- vite.config.ts +24 -0
.dockerignore
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
node_modules
|
| 2 |
+
dist
|
| 3 |
+
.env
|
| 4 |
+
.env.local
|
| 5 |
+
.git
|
| 6 |
+
.gitignore
|
| 7 |
+
README.md
|
| 8 |
+
Dockerfile
|
| 9 |
+
.dockerignore
|
.env.example
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# GEMINI_API_KEY: Required for Gemini AI API calls.
|
| 2 |
+
# AI Studio automatically injects this at runtime from user secrets.
|
| 3 |
+
# Users configure this via the Secrets panel in the AI Studio UI.
|
| 4 |
+
GEMINI_API_KEY="MY_GEMINI_API_KEY"
|
| 5 |
+
|
| 6 |
+
# APP_URL: The URL where this applet is hosted.
|
| 7 |
+
# AI Studio automatically injects this at runtime with the Cloud Run service URL.
|
| 8 |
+
# Used for self-referential links, OAuth callbacks, and API endpoints.
|
| 9 |
+
APP_URL="MY_APP_URL"
|
.gitignore
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
node_modules/
|
| 2 |
+
build/
|
| 3 |
+
dist/
|
| 4 |
+
coverage/
|
| 5 |
+
.DS_Store
|
| 6 |
+
*.log
|
| 7 |
+
.env*
|
| 8 |
+
!.env.example
|
App.tsx
ADDED
|
@@ -0,0 +1,1872 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import React, { useState, useEffect, useRef, useMemo } from 'react';
|
| 2 |
+
import { io, Socket } from 'socket.io-client';
|
| 3 |
+
import { motion, AnimatePresence } from 'motion/react';
|
| 4 |
+
import {
|
| 5 |
+
TrendingUp,
|
| 6 |
+
TrendingDown,
|
| 7 |
+
Users,
|
| 8 |
+
Wallet,
|
| 9 |
+
ArrowRight,
|
| 10 |
+
Play,
|
| 11 |
+
Plus,
|
| 12 |
+
Minus,
|
| 13 |
+
RefreshCw,
|
| 14 |
+
Trophy,
|
| 15 |
+
Info,
|
| 16 |
+
Zap,
|
| 17 |
+
Landmark,
|
| 18 |
+
Radio,
|
| 19 |
+
Building2,
|
| 20 |
+
Cpu,
|
| 21 |
+
CreditCard,
|
| 22 |
+
Code,
|
| 23 |
+
Flame,
|
| 24 |
+
Droplets,
|
| 25 |
+
Bolt,
|
| 26 |
+
Coins,
|
| 27 |
+
Globe,
|
| 28 |
+
Activity,
|
| 29 |
+
Shield,
|
| 30 |
+
X
|
| 31 |
+
} from 'lucide-react';
|
| 32 |
+
|
| 33 |
+
const STOCK_ICONS: Record<string, any> = {
|
| 34 |
+
Zap,
|
| 35 |
+
Landmark,
|
| 36 |
+
Radio,
|
| 37 |
+
Building2,
|
| 38 |
+
Cpu,
|
| 39 |
+
CreditCard,
|
| 40 |
+
Code,
|
| 41 |
+
Flame,
|
| 42 |
+
Droplets,
|
| 43 |
+
Bolt,
|
| 44 |
+
Coins,
|
| 45 |
+
Globe,
|
| 46 |
+
Activity
|
| 47 |
+
};
|
| 48 |
+
|
| 49 |
+
// --- Constants ---
|
| 50 |
+
const INITIAL_CASH = 800000;
|
| 51 |
+
const MIN_BUY_AMOUNT = 1000;
|
| 52 |
+
const INITIAL_STOCK_PRICE = 100;
|
| 53 |
+
const ROUNDS_COUNT = 5;
|
| 54 |
+
const TURNS_PER_ROUND = 3;
|
| 55 |
+
const MIN_STOCK_PRICE = 10;
|
| 56 |
+
const CARD_VALUES = [-15, -10, -5, 5, 10, 15, 30];
|
| 57 |
+
const MARKET_CAP_PER_STOCK = 200000;
|
| 58 |
+
|
| 59 |
+
type WindfallType = 'SHARE_SUSPENDED' | 'LOAN_STOCK_MATURED' | 'DEBENTURE' | 'RIGHTS_ISSUE';
|
| 60 |
+
|
| 61 |
+
const WINDFALL_DETAILS: Record<WindfallType, { name: string, icon: string, description: string, label: string }> = {
|
| 62 |
+
SHARE_SUSPENDED: {
|
| 63 |
+
name: 'Share Suspended',
|
| 64 |
+
icon: '🔒',
|
| 65 |
+
description: 'Revert a company price to start of turn.',
|
| 66 |
+
label: 'Play Share Suspended'
|
| 67 |
+
},
|
| 68 |
+
LOAN_STOCK_MATURED: {
|
| 69 |
+
name: 'Loan Stock Matured',
|
| 70 |
+
icon: '💰',
|
| 71 |
+
description: 'Receive ₹1,00,000 cash.',
|
| 72 |
+
label: 'Claim Loan Stock Matured (+₹1,00,000)'
|
| 73 |
+
},
|
| 74 |
+
DEBENTURE: {
|
| 75 |
+
name: 'Debenture',
|
| 76 |
+
icon: '📜',
|
| 77 |
+
description: 'Redeem insolvent shares at opening price.',
|
| 78 |
+
label: 'Play Debenture — Redeem Bankrupt Shares at Opening Price'
|
| 79 |
+
},
|
| 80 |
+
RIGHTS_ISSUE: {
|
| 81 |
+
name: 'Rights Issue',
|
| 82 |
+
icon: '📋',
|
| 83 |
+
description: 'Buy 1 share for every 2 at ₹10.',
|
| 84 |
+
label: 'Play Rights Issue'
|
| 85 |
+
},
|
| 86 |
+
};
|
| 87 |
+
|
| 88 |
+
const STOCKS = [
|
| 89 |
+
{ id: 'WOCKHARDT', name: 'Wockhardt', icon: 'Activity', initialPrice: 20, color: 'text-pink-500', bgColor: 'bg-pink-500/10', borderColor: 'border-pink-500/20', cardGradient: 'from-pink-600 to-pink-900 border-pink-400/30' },
|
| 90 |
+
{ id: 'HDFC', name: 'HDFC', icon: 'Landmark', initialPrice: 25, color: 'text-red-500', bgColor: 'bg-red-500/10', borderColor: 'border-red-500/20', cardGradient: 'from-red-600 to-red-900 border-red-400/30' },
|
| 91 |
+
{ id: 'TATA', name: 'Tata', icon: 'Zap', initialPrice: 30, color: 'text-yellow-500', bgColor: 'bg-yellow-500/10', borderColor: 'border-yellow-500/20', cardGradient: 'from-yellow-600 to-yellow-900 border-yellow-400/30' },
|
| 92 |
+
{ id: 'ITC', name: 'ITC', icon: 'Flame', initialPrice: 40, color: 'text-emerald-500', bgColor: 'bg-emerald-500/10', borderColor: 'border-emerald-500/20', cardGradient: 'from-emerald-600 to-emerald-900 border-emerald-400/30' },
|
| 93 |
+
{ id: 'ONGC', name: 'ONGC', icon: 'Droplets', initialPrice: 55, color: 'text-orange-500', bgColor: 'bg-orange-500/10', borderColor: 'border-orange-500/20', cardGradient: 'from-orange-600 to-orange-900 border-orange-400/30' },
|
| 94 |
+
{ id: 'SBI', name: 'SBI', icon: 'Building2', initialPrice: 60, color: 'text-violet-500', bgColor: 'bg-violet-500/10', borderColor: 'border-violet-500/20', cardGradient: 'from-violet-600 to-violet-900 border-violet-400/30' },
|
| 95 |
+
{ id: 'REL', name: 'Rel', icon: 'Zap', initialPrice: 75, color: 'text-indigo-500', bgColor: 'bg-indigo-500/10', borderColor: 'border-indigo-500/20', cardGradient: 'from-indigo-600 to-indigo-900 border-indigo-400/30' },
|
| 96 |
+
{ id: 'INFOSYS', name: 'Infosys', icon: 'Cpu', initialPrice: 80, color: 'text-blue-500', bgColor: 'bg-blue-500/10', borderColor: 'border-blue-500/20', cardGradient: 'from-blue-600 to-blue-900 border-blue-400/30' },
|
| 97 |
+
];
|
| 98 |
+
|
| 99 |
+
// --- Types ---
|
| 100 |
+
type Stock = {
|
| 101 |
+
id: string;
|
| 102 |
+
name: string;
|
| 103 |
+
price: number;
|
| 104 |
+
history: number[];
|
| 105 |
+
icon: string;
|
| 106 |
+
availableShares: number;
|
| 107 |
+
color: string;
|
| 108 |
+
bgColor: string;
|
| 109 |
+
borderColor: string;
|
| 110 |
+
cardGradient: string;
|
| 111 |
+
isInsolvent: boolean;
|
| 112 |
+
chairmanId?: string;
|
| 113 |
+
};
|
| 114 |
+
|
| 115 |
+
type GameCard = {
|
| 116 |
+
stockId?: string;
|
| 117 |
+
value?: number;
|
| 118 |
+
windfallType?: WindfallType;
|
| 119 |
+
};
|
| 120 |
+
|
| 121 |
+
type Player = {
|
| 122 |
+
id: string;
|
| 123 |
+
playerId: string;
|
| 124 |
+
name: string;
|
| 125 |
+
cash: number;
|
| 126 |
+
portfolio: Record<string, number>;
|
| 127 |
+
cards: GameCard[];
|
| 128 |
+
playedCards?: GameCard[];
|
| 129 |
+
isHost: boolean;
|
| 130 |
+
isReady: boolean;
|
| 131 |
+
lastAction?: string;
|
| 132 |
+
};
|
| 133 |
+
|
| 134 |
+
type RevealStep = {
|
| 135 |
+
stockId: string;
|
| 136 |
+
originalCards: { playerId: string, value: number }[];
|
| 137 |
+
vetoedCard?: { playerId: string, value: number };
|
| 138 |
+
directorDiscarded?: { playerId: string, value: number };
|
| 139 |
+
finalChange: number;
|
| 140 |
+
newPrice: number;
|
| 141 |
+
recovered?: boolean;
|
| 142 |
+
becameInsolvent?: boolean;
|
| 143 |
+
};
|
| 144 |
+
|
| 145 |
+
type GameState = {
|
| 146 |
+
status: 'setup' | 'lobby' | 'playing' | 'reveal' | 'ended';
|
| 147 |
+
players: Player[];
|
| 148 |
+
stocks: Stock[];
|
| 149 |
+
round: number;
|
| 150 |
+
turn: number;
|
| 151 |
+
currentPlayerIndex: number;
|
| 152 |
+
hostId: string;
|
| 153 |
+
roomId: string;
|
| 154 |
+
turnActionsCount: number;
|
| 155 |
+
maxPlayers?: number;
|
| 156 |
+
maxRounds?: number;
|
| 157 |
+
revealSteps?: RevealStep[];
|
| 158 |
+
windfallDeck: WindfallType[];
|
| 159 |
+
suspendedStockId?: string;
|
| 160 |
+
pendingRightsIssue?: {
|
| 161 |
+
initiatorId: string;
|
| 162 |
+
stockId: string;
|
| 163 |
+
decisions: Record<string, boolean | null>; // playerId -> true/false/null
|
| 164 |
+
};
|
| 165 |
+
};
|
| 166 |
+
|
| 167 |
+
// --- Game Logic Helpers ---
|
| 168 |
+
const generateCards = (windfallDeck: WindfallType[]) => {
|
| 169 |
+
const cards: GameCard[] = [];
|
| 170 |
+
|
| 171 |
+
// Helper for weighted selection (higher index = higher probability)
|
| 172 |
+
const getWeightedIndex = (length: number) => {
|
| 173 |
+
const totalWeight = (length * (length + 1)) / 2;
|
| 174 |
+
let r = Math.random() * totalWeight;
|
| 175 |
+
for (let i = 0; i < length; i++) {
|
| 176 |
+
const weight = i + 1;
|
| 177 |
+
if (r < weight) return i;
|
| 178 |
+
r -= weight;
|
| 179 |
+
}
|
| 180 |
+
return length - 1;
|
| 181 |
+
};
|
| 182 |
+
|
| 183 |
+
for (let i = 0; i < 10; i++) {
|
| 184 |
+
// 10% chance of a windfall card if deck is not empty
|
| 185 |
+
if (Math.random() < 0.1 && windfallDeck.length > 0) {
|
| 186 |
+
cards.push({ windfallType: windfallDeck.pop() });
|
| 187 |
+
} else {
|
| 188 |
+
// 1. Weighted Stock Selection (higher index = more likely)
|
| 189 |
+
const stockIndex = getWeightedIndex(STOCKS.length);
|
| 190 |
+
const stock = STOCKS[stockIndex];
|
| 191 |
+
|
| 192 |
+
// 2. Dynamic Caps based on stock index
|
| 193 |
+
// Wockhardt (index 0): min -5, max 10
|
| 194 |
+
// Infosys (index 7): min -15, max 30
|
| 195 |
+
// Linear interpolation: min = -5 - (index * 10/7), max = 10 + (index * 20/7)
|
| 196 |
+
const minCap = -5 - (stockIndex * (10 / 7));
|
| 197 |
+
const maxCap = 10 + (stockIndex * (20 / 7));
|
| 198 |
+
|
| 199 |
+
// 3. Filter and Weighted Value Selection (higher value = more likely)
|
| 200 |
+
const validValues = CARD_VALUES.filter(v => v >= minCap && v <= maxCap);
|
| 201 |
+
const valueIndex = getWeightedIndex(validValues.length);
|
| 202 |
+
const value = validValues[valueIndex];
|
| 203 |
+
|
| 204 |
+
cards.push({ stockId: stock.id, value });
|
| 205 |
+
}
|
| 206 |
+
}
|
| 207 |
+
return cards;
|
| 208 |
+
};
|
| 209 |
+
|
| 210 |
+
const shuffle = <T,>(array: T[]): T[] => {
|
| 211 |
+
const newArray = [...array];
|
| 212 |
+
for (let i = newArray.length - 1; i > 0; i--) {
|
| 213 |
+
const j = Math.floor(Math.random() * (i + 1));
|
| 214 |
+
[newArray[i], newArray[j]] = [newArray[j], newArray[i]];
|
| 215 |
+
}
|
| 216 |
+
return newArray;
|
| 217 |
+
};
|
| 218 |
+
|
| 219 |
+
const processAction = (state: GameState, playerId: string, action: any): GameState => {
|
| 220 |
+
const newState = JSON.parse(JSON.stringify(state)) as GameState;
|
| 221 |
+
const player = newState.players.find(p => p.id === playerId);
|
| 222 |
+
if (!player) return state;
|
| 223 |
+
|
| 224 |
+
if (action.type === 'buy') {
|
| 225 |
+
const stock = newState.stocks.find(s => s.id === action.stockId);
|
| 226 |
+
if (!stock) return state;
|
| 227 |
+
|
| 228 |
+
if (stock.isInsolvent) {
|
| 229 |
+
player.lastAction = `Failed: ${stock.id} is Insolvent`;
|
| 230 |
+
return newState;
|
| 231 |
+
}
|
| 232 |
+
|
| 233 |
+
if (player.cash >= stock.price * action.amount &&
|
| 234 |
+
action.amount >= MIN_BUY_AMOUNT &&
|
| 235 |
+
action.amount % 1000 === 0 &&
|
| 236 |
+
stock.availableShares >= action.amount) {
|
| 237 |
+
|
| 238 |
+
const oldShares = player.portfolio[action.stockId] || 0;
|
| 239 |
+
const newShares = oldShares + action.amount;
|
| 240 |
+
|
| 241 |
+
player.cash -= stock.price * action.amount;
|
| 242 |
+
player.portfolio[action.stockId] = newShares;
|
| 243 |
+
stock.availableShares -= action.amount;
|
| 244 |
+
player.lastAction = `Bought ${action.amount} ${stock.id}`;
|
| 245 |
+
|
| 246 |
+
// Check for Chairman
|
| 247 |
+
if (newShares >= 100000 && !stock.chairmanId) {
|
| 248 |
+
stock.chairmanId = player.id;
|
| 249 |
+
}
|
| 250 |
+
}
|
| 251 |
+
} else if (action.type === 'sell') {
|
| 252 |
+
const stock = newState.stocks.find(s => s.id === action.stockId);
|
| 253 |
+
if (!stock) return state;
|
| 254 |
+
|
| 255 |
+
if (stock.isInsolvent) {
|
| 256 |
+
player.lastAction = `Failed: ${stock.id} is Insolvent`;
|
| 257 |
+
return newState;
|
| 258 |
+
}
|
| 259 |
+
|
| 260 |
+
const owned = player.portfolio[action.stockId] || 0;
|
| 261 |
+
if (owned >= action.amount && action.amount % 1000 === 0) {
|
| 262 |
+
player.cash += stock.price * action.amount;
|
| 263 |
+
player.portfolio[action.stockId] = owned - action.amount;
|
| 264 |
+
stock.availableShares += action.amount;
|
| 265 |
+
player.lastAction = `Sold ${action.amount} ${stock.id}`;
|
| 266 |
+
|
| 267 |
+
// If Chairman sells below 100k, they lose it?
|
| 268 |
+
// Rule says "first to reach 1,00,000 gets it; in a tie, the player who reached it first keeps it."
|
| 269 |
+
// Usually Chairman is lost if you drop below. Let's assume they lose it.
|
| 270 |
+
if (player.id === stock.chairmanId && player.portfolio[action.stockId] < 100000) {
|
| 271 |
+
stock.chairmanId = undefined;
|
| 272 |
+
// Check if anyone else qualifies now?
|
| 273 |
+
const nextChairman = newState.players
|
| 274 |
+
.filter(p => (p.portfolio[action.stockId] || 0) >= 100000)
|
| 275 |
+
.sort((a, b) => 0) // We don't have time history, so just pick one or leave empty
|
| 276 |
+
[0];
|
| 277 |
+
if (nextChairman) stock.chairmanId = nextChairman.id;
|
| 278 |
+
}
|
| 279 |
+
}
|
| 280 |
+
} else if (action.type === 'pass') {
|
| 281 |
+
player.lastAction = 'Passed';
|
| 282 |
+
} else if (action.type === 'play_windfall') {
|
| 283 |
+
const cardIndex = player.cards.findIndex(c => c.windfallType === action.cardType);
|
| 284 |
+
if (cardIndex === -1) return state;
|
| 285 |
+
|
| 286 |
+
if (action.cardType === 'LOAN_STOCK_MATURED') {
|
| 287 |
+
player.cash += 100000;
|
| 288 |
+
player.lastAction = 'Played Loan Stock Matured (+₹1,00,000)';
|
| 289 |
+
player.cards.splice(cardIndex, 1);
|
| 290 |
+
} else if (action.cardType === 'DEBENTURE') {
|
| 291 |
+
let totalRedeemed = 0;
|
| 292 |
+
newState.stocks.forEach(stock => {
|
| 293 |
+
if (stock.isInsolvent) {
|
| 294 |
+
const shares = player.portfolio[stock.id] || 0;
|
| 295 |
+
if (shares > 0) {
|
| 296 |
+
const initialStock = STOCKS.find(s => s.id === stock.id);
|
| 297 |
+
const openingPrice = initialStock?.initialPrice || 100;
|
| 298 |
+
const amount = shares * openingPrice;
|
| 299 |
+
player.cash += amount;
|
| 300 |
+
player.portfolio[stock.id] = 0;
|
| 301 |
+
stock.availableShares += shares;
|
| 302 |
+
totalRedeemed += amount;
|
| 303 |
+
}
|
| 304 |
+
}
|
| 305 |
+
});
|
| 306 |
+
player.lastAction = `Played Debenture (Redeemed ₹${totalRedeemed.toLocaleString()})`;
|
| 307 |
+
player.cards.splice(cardIndex, 1);
|
| 308 |
+
} else if (action.cardType === 'RIGHTS_ISSUE') {
|
| 309 |
+
const stock = newState.stocks.find(s => s.id === action.stockId);
|
| 310 |
+
if (stock) {
|
| 311 |
+
newState.pendingRightsIssue = {
|
| 312 |
+
initiatorId: playerId,
|
| 313 |
+
stockId: action.stockId,
|
| 314 |
+
decisions: {}
|
| 315 |
+
};
|
| 316 |
+
newState.players.forEach(p => {
|
| 317 |
+
if ((p.portfolio[action.stockId] || 0) > 0) {
|
| 318 |
+
newState.pendingRightsIssue!.decisions[p.id] = null;
|
| 319 |
+
}
|
| 320 |
+
});
|
| 321 |
+
player.lastAction = `Initiated Rights Issue for ${stock.id}`;
|
| 322 |
+
// We don't remove the card yet, we'll remove it when the rights issue is finalized
|
| 323 |
+
// Actually, let's remove it now to prevent multiple initiations
|
| 324 |
+
player.cards.splice(cardIndex, 1);
|
| 325 |
+
}
|
| 326 |
+
} else if (action.cardType === 'SHARE_SUSPENDED') {
|
| 327 |
+
const stock = newState.stocks.find(s => s.id === action.stockId);
|
| 328 |
+
if (stock) {
|
| 329 |
+
newState.suspendedStockId = stock.id;
|
| 330 |
+
const oldPrice = stock.history.length > 1 ? stock.history[stock.history.length - 2] : stock.price;
|
| 331 |
+
stock.price = oldPrice;
|
| 332 |
+
stock.history[stock.history.length - 1] = oldPrice;
|
| 333 |
+
player.lastAction = `Suspended ${stock.id} price movement`;
|
| 334 |
+
player.cards.splice(cardIndex, 1);
|
| 335 |
+
}
|
| 336 |
+
}
|
| 337 |
+
return newState;
|
| 338 |
+
} else if (action.type === 'rights_issue_decision') {
|
| 339 |
+
if (!newState.pendingRightsIssue) return state;
|
| 340 |
+
newState.pendingRightsIssue.decisions[playerId] = action.participate;
|
| 341 |
+
|
| 342 |
+
const allDecided = Object.values(newState.pendingRightsIssue.decisions).every(d => d !== null);
|
| 343 |
+
if (allDecided) {
|
| 344 |
+
const stockId = newState.pendingRightsIssue.stockId;
|
| 345 |
+
const stock = newState.stocks.find(s => s.id === stockId)!;
|
| 346 |
+
const initiatorIndex = newState.players.findIndex(p => p.id === newState.pendingRightsIssue!.initiatorId);
|
| 347 |
+
const playersOrder = [
|
| 348 |
+
...newState.players.slice(initiatorIndex),
|
| 349 |
+
...newState.players.slice(0, initiatorIndex)
|
| 350 |
+
];
|
| 351 |
+
|
| 352 |
+
playersOrder.forEach(p => {
|
| 353 |
+
if (newState.pendingRightsIssue!.decisions[p.id]) {
|
| 354 |
+
const currentShares = p.portfolio[stockId] || 0;
|
| 355 |
+
const requestedShares = Math.floor(currentShares / 2000) * 1000; // Round down (e.g. 13,000 -> 6,000)
|
| 356 |
+
const actualShares = Math.min(requestedShares, stock.availableShares);
|
| 357 |
+
const cost = actualShares * 10;
|
| 358 |
+
|
| 359 |
+
if (p.cash >= cost && actualShares > 0) {
|
| 360 |
+
p.cash -= cost;
|
| 361 |
+
p.portfolio[stockId] = currentShares + actualShares;
|
| 362 |
+
stock.availableShares -= actualShares;
|
| 363 |
+
}
|
| 364 |
+
}
|
| 365 |
+
});
|
| 366 |
+
|
| 367 |
+
newState.pendingRightsIssue = undefined;
|
| 368 |
+
}
|
| 369 |
+
return newState;
|
| 370 |
+
}
|
| 371 |
+
|
| 372 |
+
// Move to next player
|
| 373 |
+
newState.currentPlayerIndex = (newState.currentPlayerIndex + 1) % newState.players.length;
|
| 374 |
+
newState.turnActionsCount += 1;
|
| 375 |
+
|
| 376 |
+
// Check if turn is over
|
| 377 |
+
if (newState.turnActionsCount >= newState.players.length) {
|
| 378 |
+
if (newState.turn < TURNS_PER_ROUND) {
|
| 379 |
+
// Move to next turn within the same round
|
| 380 |
+
newState.turn += 1;
|
| 381 |
+
newState.turnActionsCount = 0;
|
| 382 |
+
newState.currentPlayerIndex = 0;
|
| 383 |
+
} else {
|
| 384 |
+
// End of round: reveal prices
|
| 385 |
+
newState.status = 'reveal';
|
| 386 |
+
}
|
| 387 |
+
}
|
| 388 |
+
|
| 389 |
+
return newState;
|
| 390 |
+
};
|
| 391 |
+
|
| 392 |
+
const calculateReveal = (state: GameState): GameState => {
|
| 393 |
+
const newState = JSON.parse(JSON.stringify(state)) as GameState;
|
| 394 |
+
const revealSteps: RevealStep[] = [];
|
| 395 |
+
|
| 396 |
+
newState.stocks.forEach(stock => {
|
| 397 |
+
const originalCards: { playerId: string, value: number }[] = [];
|
| 398 |
+
newState.players.forEach(p => {
|
| 399 |
+
const cardsToReveal = p.cards;
|
| 400 |
+
cardsToReveal.filter(c => c.stockId === stock.id).forEach(c => {
|
| 401 |
+
originalCards.push({ playerId: p.id, value: c.value! });
|
| 402 |
+
});
|
| 403 |
+
});
|
| 404 |
+
|
| 405 |
+
let cardsToSum = [...originalCards];
|
| 406 |
+
let vetoedCard: { playerId: string, value: number } | undefined;
|
| 407 |
+
let directorDiscarded: { playerId: string, value: number } | undefined;
|
| 408 |
+
|
| 409 |
+
// 1. Chairman Privilege (Priority)
|
| 410 |
+
if (stock.chairmanId) {
|
| 411 |
+
const negativeCards = cardsToSum.filter(c => c.value < 0).sort((a, b) => a.value - b.value);
|
| 412 |
+
if (negativeCards.length > 0) {
|
| 413 |
+
vetoedCard = negativeCards[0];
|
| 414 |
+
const index = cardsToSum.findIndex(c => c === vetoedCard);
|
| 415 |
+
if (index !== -1) cardsToSum.splice(index, 1);
|
| 416 |
+
}
|
| 417 |
+
}
|
| 418 |
+
|
| 419 |
+
// 2. Director Privilege
|
| 420 |
+
const directors = newState.players.filter(p => {
|
| 421 |
+
const shares = p.portfolio[stock.id] || 0;
|
| 422 |
+
return shares >= 50000 && shares < 100000 && p.id !== stock.chairmanId;
|
| 423 |
+
});
|
| 424 |
+
|
| 425 |
+
directors.forEach(director => {
|
| 426 |
+
const directorCards = cardsToSum.filter(c => c.playerId === director.id);
|
| 427 |
+
if (directorCards.length > 0) {
|
| 428 |
+
const worstCard = directorCards.sort((a, b) => a.value - b.value)[0];
|
| 429 |
+
directorDiscarded = worstCard;
|
| 430 |
+
const index = cardsToSum.findIndex(c => c === worstCard);
|
| 431 |
+
if (index !== -1) cardsToSum.splice(index, 1);
|
| 432 |
+
}
|
| 433 |
+
});
|
| 434 |
+
|
| 435 |
+
const totalChange = cardsToSum.reduce((sum, c) => sum + c.value, 0);
|
| 436 |
+
const oldPrice = stock.price;
|
| 437 |
+
let newPrice = stock.price + totalChange;
|
| 438 |
+
let recovered = false;
|
| 439 |
+
let becameInsolvent = false;
|
| 440 |
+
|
| 441 |
+
if (stock.isInsolvent) {
|
| 442 |
+
if (totalChange > 0) {
|
| 443 |
+
newPrice = 1;
|
| 444 |
+
stock.isInsolvent = false;
|
| 445 |
+
recovered = true;
|
| 446 |
+
} else {
|
| 447 |
+
newPrice = 0;
|
| 448 |
+
}
|
| 449 |
+
} else {
|
| 450 |
+
if (newPrice <= 0) {
|
| 451 |
+
newPrice = 0;
|
| 452 |
+
stock.isInsolvent = true;
|
| 453 |
+
becameInsolvent = true;
|
| 454 |
+
}
|
| 455 |
+
}
|
| 456 |
+
|
| 457 |
+
stock.price = newPrice;
|
| 458 |
+
stock.history.push(stock.price);
|
| 459 |
+
|
| 460 |
+
revealSteps.push({
|
| 461 |
+
stockId: stock.id,
|
| 462 |
+
originalCards,
|
| 463 |
+
vetoedCard,
|
| 464 |
+
directorDiscarded,
|
| 465 |
+
finalChange: totalChange,
|
| 466 |
+
newPrice,
|
| 467 |
+
recovered,
|
| 468 |
+
becameInsolvent
|
| 469 |
+
});
|
| 470 |
+
});
|
| 471 |
+
|
| 472 |
+
newState.revealSteps = revealSteps;
|
| 473 |
+
|
| 474 |
+
return newState;
|
| 475 |
+
};
|
| 476 |
+
|
| 477 |
+
const startNextTurn = (state: GameState): GameState => {
|
| 478 |
+
const newState = JSON.parse(JSON.stringify(state)) as GameState;
|
| 479 |
+
|
| 480 |
+
// Reset for next round
|
| 481 |
+
newState.turnActionsCount = 0;
|
| 482 |
+
newState.currentPlayerIndex = 0;
|
| 483 |
+
newState.suspendedStockId = undefined; // Clear suspension for next turn
|
| 484 |
+
newState.revealSteps = undefined; // Clear previous reveal
|
| 485 |
+
|
| 486 |
+
newState.players.forEach(p => {
|
| 487 |
+
p.lastAction = undefined;
|
| 488 |
+
p.playedCards = []; // Clear accumulated cards
|
| 489 |
+
p.cards = generateCards(newState.windfallDeck);
|
| 490 |
+
});
|
| 491 |
+
|
| 492 |
+
newState.turn = 1;
|
| 493 |
+
newState.round += 1;
|
| 494 |
+
|
| 495 |
+
if (newState.round > (newState.maxRounds || ROUNDS_COUNT)) {
|
| 496 |
+
newState.status = 'ended';
|
| 497 |
+
} else {
|
| 498 |
+
newState.status = 'playing';
|
| 499 |
+
}
|
| 500 |
+
|
| 501 |
+
return newState;
|
| 502 |
+
};
|
| 503 |
+
|
| 504 |
+
// --- Helper Components ---
|
| 505 |
+
const TickerBackground = () => {
|
| 506 |
+
const tickerItems = useMemo(() => {
|
| 507 |
+
return [...STOCKS, ...STOCKS].map((stock, i) => ({
|
| 508 |
+
...stock,
|
| 509 |
+
price: 100 + Math.floor(Math.random() * 500),
|
| 510 |
+
change: (Math.random() * 10 - 5).toFixed(2)
|
| 511 |
+
}));
|
| 512 |
+
}, []);
|
| 513 |
+
|
| 514 |
+
return (
|
| 515 |
+
<div className="fixed inset-0 overflow-hidden pointer-events-none z-0 opacity-20">
|
| 516 |
+
<div className="absolute top-0 left-0 w-full h-full flex flex-col justify-around py-10">
|
| 517 |
+
{[0, 1, 2].map((row) => (
|
| 518 |
+
<div key={row} className="flex whitespace-nowrap overflow-hidden">
|
| 519 |
+
<motion.div
|
| 520 |
+
animate={{ x: row % 2 === 0 ? [0, -1000] : [-1000, 0] }}
|
| 521 |
+
transition={{
|
| 522 |
+
duration: 30 + row * 5,
|
| 523 |
+
repeat: Infinity,
|
| 524 |
+
ease: "linear"
|
| 525 |
+
}}
|
| 526 |
+
className="flex gap-12 items-center"
|
| 527 |
+
>
|
| 528 |
+
{tickerItems.map((item, i) => (
|
| 529 |
+
<div key={`${row}-${i}`} className="flex items-center gap-3 font-mono">
|
| 530 |
+
<span className="text-zinc-700 font-black text-4xl">{item.id}</span>
|
| 531 |
+
<span className="text-zinc-800 text-2xl">₹{item.price}</span>
|
| 532 |
+
<span className={`text-xl font-bold ${parseFloat(item.change) >= 0 ? 'text-emerald-900' : 'text-rose-900'}`}>
|
| 533 |
+
{parseFloat(item.change) >= 0 ? '▲' : '▼'} {Math.abs(parseFloat(item.change))}%
|
| 534 |
+
</span>
|
| 535 |
+
</div>
|
| 536 |
+
))}
|
| 537 |
+
</motion.div>
|
| 538 |
+
</div>
|
| 539 |
+
))}
|
| 540 |
+
</div>
|
| 541 |
+
</div>
|
| 542 |
+
);
|
| 543 |
+
};
|
| 544 |
+
|
| 545 |
+
const STOCK_CARD_COLORS: Record<string, string> = {
|
| 546 |
+
WOCKHARDT: 'from-pink-600 to-pink-900 border-pink-400/30',
|
| 547 |
+
HDFC: 'from-rose-600 to-rose-900 border-rose-400/30',
|
| 548 |
+
TATA: 'from-amber-600 to-amber-900 border-amber-400/30',
|
| 549 |
+
ITC: 'from-emerald-600 to-emerald-900 border-emerald-400/30',
|
| 550 |
+
ONGC: 'from-orange-600 to-orange-900 border-orange-400/30',
|
| 551 |
+
SBI: 'from-violet-600 to-violet-900 border-violet-400/30',
|
| 552 |
+
REL: 'from-blue-600 to-blue-900 border-blue-400/30',
|
| 553 |
+
INFOSYS: 'from-emerald-600 to-emerald-900 border-emerald-400/30',
|
| 554 |
+
};
|
| 555 |
+
|
| 556 |
+
const GameCardUI: React.FC<{
|
| 557 |
+
card: GameCard,
|
| 558 |
+
index: number,
|
| 559 |
+
total: number,
|
| 560 |
+
isHovered: boolean,
|
| 561 |
+
onHover: (index: number | null) => void,
|
| 562 |
+
isPlayable?: boolean,
|
| 563 |
+
onPlay?: (stockId?: string) => void,
|
| 564 |
+
gameState?: GameState
|
| 565 |
+
}> = ({ card, index, total, isHovered, onHover, isPlayable, onPlay, gameState }) => {
|
| 566 |
+
const [showTargetSelector, setShowTargetSelector] = useState(false);
|
| 567 |
+
const isWindfall = !!card.windfallType;
|
| 568 |
+
const stock = !isWindfall ? STOCKS.find(s => s.id === card.stockId) : null;
|
| 569 |
+
const Icon = isWindfall
|
| 570 |
+
? Zap
|
| 571 |
+
: (STOCK_ICONS[stock?.icon || 'Activity'] || Activity);
|
| 572 |
+
|
| 573 |
+
const windfallDetail = isWindfall ? WINDFALL_DETAILS[card.windfallType!] : null;
|
| 574 |
+
const cardColorClass = isWindfall
|
| 575 |
+
? 'from-amber-500 to-amber-900 border-amber-400/30'
|
| 576 |
+
: (stock?.cardGradient || 'from-zinc-600 to-zinc-900 border-zinc-400/30');
|
| 577 |
+
|
| 578 |
+
return (
|
| 579 |
+
<div
|
| 580 |
+
className="relative group"
|
| 581 |
+
onMouseEnter={() => onHover(index)}
|
| 582 |
+
onMouseLeave={() => {
|
| 583 |
+
onHover(null);
|
| 584 |
+
setShowTargetSelector(false);
|
| 585 |
+
}}
|
| 586 |
+
>
|
| 587 |
+
<motion.div
|
| 588 |
+
layout
|
| 589 |
+
initial={{ y: 50, opacity: 0 }}
|
| 590 |
+
animate={{
|
| 591 |
+
y: isHovered ? -15 : 0,
|
| 592 |
+
opacity: 1,
|
| 593 |
+
scale: isHovered ? 1.1 : 1,
|
| 594 |
+
zIndex: isHovered ? 100 : index,
|
| 595 |
+
}}
|
| 596 |
+
transition={{
|
| 597 |
+
type: 'spring',
|
| 598 |
+
stiffness: 300,
|
| 599 |
+
damping: 20,
|
| 600 |
+
delay: index * 0.02
|
| 601 |
+
}}
|
| 602 |
+
whileTap={{ scale: 1.2 }}
|
| 603 |
+
onClick={() => {
|
| 604 |
+
// On mobile, first click shows info (via isHovered in CardHand)
|
| 605 |
+
// If already hovered/showing info, we don't need to do anything special here
|
| 606 |
+
// as the Play button will be visible in the tooltip
|
| 607 |
+
}}
|
| 608 |
+
className={`relative w-16 h-24 md:w-24 md:h-36 rounded-xl md:rounded-2xl border-2 shadow-2xl flex flex-col items-center justify-center p-1.5 md:p-3 cursor-pointer overflow-hidden bg-gradient-to-br ${cardColorClass}`}
|
| 609 |
+
style={{
|
| 610 |
+
transformOrigin: 'center center',
|
| 611 |
+
touchAction: 'none'
|
| 612 |
+
}}
|
| 613 |
+
>
|
| 614 |
+
{/* Uno-style oval background */}
|
| 615 |
+
<div className="absolute inset-0 flex items-center justify-center opacity-20 pointer-events-none">
|
| 616 |
+
<div className="w-[120%] h-[70%] bg-white rounded-[100%] rotate-[-45deg]" />
|
| 617 |
+
</div>
|
| 618 |
+
|
| 619 |
+
<div className="flex flex-col items-center gap-0.5 md:gap-1 relative z-10 text-center">
|
| 620 |
+
<div className="w-8 h-8 md:w-14 md:h-14 rounded-full flex items-center justify-center bg-white shadow-xl border-2 border-black/5">
|
| 621 |
+
{isWindfall ? (
|
| 622 |
+
<span className="text-xs md:text-2xl">{windfallDetail?.icon}</span>
|
| 623 |
+
) : (
|
| 624 |
+
<span className={`text-xs md:text-2xl font-black font-mono ${card.value! >= 0 ? 'text-emerald-600' : 'text-rose-600'}`}>
|
| 625 |
+
{card.value! > 0 ? '+' : ''}{card.value}
|
| 626 |
+
</span>
|
| 627 |
+
)}
|
| 628 |
+
</div>
|
| 629 |
+
<p className="text-[7px] md:text-[9px] font-black text-white uppercase tracking-tighter drop-shadow-md mt-0.5 md:mt-1">
|
| 630 |
+
{isWindfall ? windfallDetail?.name : stock?.id}
|
| 631 |
+
</p>
|
| 632 |
+
<Icon size={10} className="text-white/70 mt-0.5 md:mt-1" />
|
| 633 |
+
</div>
|
| 634 |
+
|
| 635 |
+
{/* Inner border */}
|
| 636 |
+
<div className="absolute inset-2 border border-white/20 rounded-xl pointer-events-none" />
|
| 637 |
+
</motion.div>
|
| 638 |
+
|
| 639 |
+
{/* Info Tooltip / Action Overlay */}
|
| 640 |
+
<AnimatePresence>
|
| 641 |
+
{isHovered && (
|
| 642 |
+
<motion.div
|
| 643 |
+
initial={{ opacity: 0, y: 10, scale: 0.9, x: '-50%' }}
|
| 644 |
+
animate={{ opacity: 1, y: 0, scale: 1, x: '-50%' }}
|
| 645 |
+
exit={{ opacity: 0, y: 10, scale: 0.9, x: '-50%' }}
|
| 646 |
+
className="absolute bottom-full left-1/2 mb-4 w-56 bg-zinc-900/95 backdrop-blur-xl border border-white/10 rounded-2xl p-4 shadow-2xl z-[200] pointer-events-auto text-center"
|
| 647 |
+
onClick={(e) => e.stopPropagation()}
|
| 648 |
+
>
|
| 649 |
+
<div className="space-y-3">
|
| 650 |
+
<div className="flex items-center justify-center gap-2">
|
| 651 |
+
{isWindfall ? (
|
| 652 |
+
<Zap size={14} className="text-amber-500" />
|
| 653 |
+
) : (
|
| 654 |
+
<Info size={14} className="text-zinc-400" />
|
| 655 |
+
)}
|
| 656 |
+
<p className={`text-[10px] font-black uppercase tracking-widest ${isWindfall ? 'text-amber-500' : 'text-zinc-400'}`}>
|
| 657 |
+
{isWindfall ? 'Windfall Card' : 'Market Intel'}
|
| 658 |
+
</p>
|
| 659 |
+
</div>
|
| 660 |
+
|
| 661 |
+
<h4 className="text-sm font-black text-white uppercase tracking-tight">
|
| 662 |
+
{isWindfall ? windfallDetail?.name : `${stock?.name} Intel`}
|
| 663 |
+
</h4>
|
| 664 |
+
|
| 665 |
+
<p className="text-[10px] text-zinc-400 leading-relaxed font-medium">
|
| 666 |
+
{isWindfall
|
| 667 |
+
? windfallDetail?.description
|
| 668 |
+
: `This card will shift ${stock?.name}'s price by ${card.value! > 0 ? '+' : ''}${card.value} at the end of the turn.`}
|
| 669 |
+
</p>
|
| 670 |
+
|
| 671 |
+
{isWindfall && isPlayable && (
|
| 672 |
+
<div className="pt-2 border-t border-white/5 space-y-2">
|
| 673 |
+
{!showTargetSelector ? (
|
| 674 |
+
<button
|
| 675 |
+
onClick={() => {
|
| 676 |
+
if (card.windfallType === 'SHARE_SUSPENDED') {
|
| 677 |
+
setShowTargetSelector(true);
|
| 678 |
+
} else {
|
| 679 |
+
onPlay?.();
|
| 680 |
+
}
|
| 681 |
+
}}
|
| 682 |
+
className="w-full py-2.5 rounded-xl bg-amber-500 text-zinc-950 text-[10px] font-black uppercase tracking-widest hover:bg-amber-400 transition-colors shadow-lg shadow-amber-500/20"
|
| 683 |
+
>
|
| 684 |
+
Play Card
|
| 685 |
+
</button>
|
| 686 |
+
) : (
|
| 687 |
+
<div className="grid grid-cols-2 gap-1.5">
|
| 688 |
+
{gameState?.stocks.map(s => (
|
| 689 |
+
<button
|
| 690 |
+
key={s.id}
|
| 691 |
+
onClick={() => onPlay?.(s.id)}
|
| 692 |
+
className="py-1.5 px-2 rounded-lg bg-white/5 hover:bg-white/10 border border-white/10 text-[8px] font-black text-white uppercase tracking-tighter transition-all"
|
| 693 |
+
>
|
| 694 |
+
{s.id}
|
| 695 |
+
</button>
|
| 696 |
+
))}
|
| 697 |
+
<button
|
| 698 |
+
onClick={() => setShowTargetSelector(false)}
|
| 699 |
+
className="col-span-2 py-1.5 rounded-lg bg-rose-500/10 text-rose-500 text-[8px] font-black uppercase tracking-widest mt-1"
|
| 700 |
+
>
|
| 701 |
+
Cancel
|
| 702 |
+
</button>
|
| 703 |
+
</div>
|
| 704 |
+
)}
|
| 705 |
+
</div>
|
| 706 |
+
)}
|
| 707 |
+
</div>
|
| 708 |
+
|
| 709 |
+
{/* Tooltip Arrow */}
|
| 710 |
+
<div className="absolute top-full left-1/2 -translate-x-1/2 border-8 border-transparent border-t-zinc-900/95" />
|
| 711 |
+
</motion.div>
|
| 712 |
+
)}
|
| 713 |
+
</AnimatePresence>
|
| 714 |
+
</div>
|
| 715 |
+
);
|
| 716 |
+
};
|
| 717 |
+
|
| 718 |
+
const CardHand = ({
|
| 719 |
+
cards,
|
| 720 |
+
isMyTurn,
|
| 721 |
+
gameState,
|
| 722 |
+
onPlayWindfall,
|
| 723 |
+
selectedStockId,
|
| 724 |
+
status,
|
| 725 |
+
mePortfolio
|
| 726 |
+
}: {
|
| 727 |
+
cards: GameCard[],
|
| 728 |
+
isMyTurn?: boolean,
|
| 729 |
+
gameState?: GameState,
|
| 730 |
+
onPlayWindfall?: (type: WindfallType, stockId?: string) => void,
|
| 731 |
+
selectedStockId?: string,
|
| 732 |
+
status?: string,
|
| 733 |
+
mePortfolio?: Record<string, number>
|
| 734 |
+
}) => {
|
| 735 |
+
const [hoveredIndex, setHoveredIndex] = useState<number | null>(null);
|
| 736 |
+
const [stickyIndex, setStickyIndex] = useState<number | null>(null);
|
| 737 |
+
|
| 738 |
+
useEffect(() => {
|
| 739 |
+
const handleClickOutside = () => setStickyIndex(null);
|
| 740 |
+
window.addEventListener('click', handleClickOutside);
|
| 741 |
+
return () => window.removeEventListener('click', handleClickOutside);
|
| 742 |
+
}, []);
|
| 743 |
+
|
| 744 |
+
if (!Array.isArray(cards)) return null;
|
| 745 |
+
|
| 746 |
+
// Sort cards: stock cards by stockId, windfall cards at the end
|
| 747 |
+
const sortedCards = [...cards].sort((a, b) => {
|
| 748 |
+
if (a.windfallType && !b.windfallType) return 1;
|
| 749 |
+
if (!a.windfallType && b.windfallType) return -1;
|
| 750 |
+
if (a.windfallType && b.windfallType) return a.windfallType.localeCompare(b.windfallType);
|
| 751 |
+
return (a.stockId || '').localeCompare(b.stockId || '');
|
| 752 |
+
});
|
| 753 |
+
|
| 754 |
+
return (
|
| 755 |
+
<div className="flex flex-wrap justify-center items-center gap-1.5 md:gap-3 px-1 md:px-2 mt-4 md:mt-8 mb-2 md:mb-4">
|
| 756 |
+
<AnimatePresence mode="popLayout">
|
| 757 |
+
{sortedCards.map((card, i) => {
|
| 758 |
+
const isPlayable = (isMyTurn && (
|
| 759 |
+
(card.windfallType === 'LOAN_STOCK_MATURED') ||
|
| 760 |
+
(card.windfallType === 'DEBENTURE' && gameState?.stocks.some(s => s.isInsolvent && (mePortfolio?.[s.id] || 0) > 0)) ||
|
| 761 |
+
(card.windfallType === 'RIGHTS_ISSUE' && selectedStockId)
|
| 762 |
+
)) || (status === 'reveal' && card.windfallType === 'SHARE_SUSPENDED');
|
| 763 |
+
|
| 764 |
+
return (
|
| 765 |
+
<div
|
| 766 |
+
key={`${card.stockId}-${card.value}-${card.windfallType}-${i}`}
|
| 767 |
+
onClick={(e) => {
|
| 768 |
+
e.stopPropagation();
|
| 769 |
+
if (stickyIndex === i) setStickyIndex(null);
|
| 770 |
+
else setStickyIndex(i);
|
| 771 |
+
}}
|
| 772 |
+
>
|
| 773 |
+
<GameCardUI
|
| 774 |
+
card={card}
|
| 775 |
+
index={i}
|
| 776 |
+
total={sortedCards.length}
|
| 777 |
+
isHovered={hoveredIndex === i || stickyIndex === i}
|
| 778 |
+
onHover={setHoveredIndex}
|
| 779 |
+
isPlayable={isPlayable}
|
| 780 |
+
gameState={gameState}
|
| 781 |
+
onPlay={(targetId) => {
|
| 782 |
+
if (card.windfallType) {
|
| 783 |
+
onPlayWindfall?.(card.windfallType, targetId || selectedStockId);
|
| 784 |
+
setStickyIndex(null);
|
| 785 |
+
setHoveredIndex(null);
|
| 786 |
+
}
|
| 787 |
+
}}
|
| 788 |
+
/>
|
| 789 |
+
</div>
|
| 790 |
+
);
|
| 791 |
+
})}
|
| 792 |
+
</AnimatePresence>
|
| 793 |
+
</div>
|
| 794 |
+
);
|
| 795 |
+
};
|
| 796 |
+
|
| 797 |
+
// --- Main Component ---
|
| 798 |
+
export default function App() {
|
| 799 |
+
const [username, setUsername] = useState('');
|
| 800 |
+
const [roomId, setRoomId] = useState('');
|
| 801 |
+
const [maxPlayers, setMaxPlayers] = useState(10);
|
| 802 |
+
const [maxRounds, setMaxRounds] = useState(5);
|
| 803 |
+
const [socket, setSocket] = useState<Socket | null>(null);
|
| 804 |
+
const [gameState, setGameState] = useState<GameState | null>(null);
|
| 805 |
+
const gameStateRef = useRef<GameState | null>(null);
|
| 806 |
+
const [myId, setMyId] = useState('');
|
| 807 |
+
const [showPrivacy, setShowPrivacy] = useState(false);
|
| 808 |
+
const [persistentPlayerId] = useState(() => {
|
| 809 |
+
const saved = localStorage.getItem('stock_rivals_player_id');
|
| 810 |
+
if (saved) return saved;
|
| 811 |
+
const newId = Math.random().toString(36).substring(2, 15);
|
| 812 |
+
localStorage.setItem('stock_rivals_player_id', newId);
|
| 813 |
+
return newId;
|
| 814 |
+
});
|
| 815 |
+
const [error, setError] = useState('');
|
| 816 |
+
|
| 817 |
+
// Sync ref with state
|
| 818 |
+
useEffect(() => {
|
| 819 |
+
gameStateRef.current = gameState;
|
| 820 |
+
}, [gameState]);
|
| 821 |
+
|
| 822 |
+
// Local state for trading
|
| 823 |
+
const [selectedStockId, setSelectedStockId] = useState(STOCKS[0].id);
|
| 824 |
+
const [tradeAmount, setTradeAmount] = useState(1000);
|
| 825 |
+
|
| 826 |
+
const roomRef = useRef('');
|
| 827 |
+
const usernameRef = useRef('');
|
| 828 |
+
|
| 829 |
+
useEffect(() => {
|
| 830 |
+
roomRef.current = roomId;
|
| 831 |
+
}, [roomId]);
|
| 832 |
+
|
| 833 |
+
useEffect(() => {
|
| 834 |
+
usernameRef.current = username;
|
| 835 |
+
}, [username]);
|
| 836 |
+
|
| 837 |
+
const isHost = gameState?.hostId === myId;
|
| 838 |
+
const me = gameState?.players.find(p => p.playerId === persistentPlayerId);
|
| 839 |
+
const isMyTurn = gameState?.status === 'playing' && gameState.players[gameState.currentPlayerIndex]?.playerId === persistentPlayerId;
|
| 840 |
+
|
| 841 |
+
const totalPortfolioValue = useMemo(() => {
|
| 842 |
+
if (!me || !gameState) return 0;
|
| 843 |
+
return Object.entries(me.portfolio).reduce((sum: number, [id, amt]: [string, number]) => {
|
| 844 |
+
const stock = gameState.stocks.find(s => s.id === id);
|
| 845 |
+
return sum + (stock ? stock.price * amt : 0);
|
| 846 |
+
}, 0);
|
| 847 |
+
}, [me, gameState?.stocks]);
|
| 848 |
+
|
| 849 |
+
// --- Socket Connection ---
|
| 850 |
+
useEffect(() => {
|
| 851 |
+
const newSocket = io({
|
| 852 |
+
reconnection: true,
|
| 853 |
+
reconnectionAttempts: 10,
|
| 854 |
+
reconnectionDelay: 1000,
|
| 855 |
+
timeout: 60000,
|
| 856 |
+
});
|
| 857 |
+
setSocket(newSocket);
|
| 858 |
+
setMyId(newSocket.id || '');
|
| 859 |
+
|
| 860 |
+
newSocket.on('connect', () => {
|
| 861 |
+
setMyId(newSocket.id || '');
|
| 862 |
+
// If we were in a room, re-join
|
| 863 |
+
if (roomRef.current && usernameRef.current) {
|
| 864 |
+
newSocket.emit('join', {
|
| 865 |
+
roomId: roomRef.current,
|
| 866 |
+
username: usernameRef.current,
|
| 867 |
+
playerId: persistentPlayerId
|
| 868 |
+
});
|
| 869 |
+
}
|
| 870 |
+
});
|
| 871 |
+
|
| 872 |
+
newSocket.on('disconnect', (reason) => {
|
| 873 |
+
console.log('Disconnected:', reason);
|
| 874 |
+
if (reason === 'io server disconnect') {
|
| 875 |
+
newSocket.connect(); // manually reconnect
|
| 876 |
+
}
|
| 877 |
+
});
|
| 878 |
+
|
| 879 |
+
newSocket.on('reconnect', (attemptNumber) => {
|
| 880 |
+
console.log('Reconnected after', attemptNumber, 'attempts');
|
| 881 |
+
});
|
| 882 |
+
|
| 883 |
+
newSocket.on('lobby_update', ({ roomId: serverRoomId, players, hostId, maxPlayers: serverMaxPlayers }) => {
|
| 884 |
+
setGameState(prev => ({
|
| 885 |
+
...(prev || {
|
| 886 |
+
status: 'lobby',
|
| 887 |
+
players: [],
|
| 888 |
+
stocks: [],
|
| 889 |
+
round: 1,
|
| 890 |
+
turn: 1,
|
| 891 |
+
currentPlayerIndex: 0,
|
| 892 |
+
hostId: '',
|
| 893 |
+
roomId: serverRoomId,
|
| 894 |
+
turnActionsCount: 0
|
| 895 |
+
}),
|
| 896 |
+
roomId: serverRoomId,
|
| 897 |
+
maxPlayers: serverMaxPlayers,
|
| 898 |
+
players: players.map((p: any) => ({
|
| 899 |
+
...p,
|
| 900 |
+
cash: INITIAL_CASH,
|
| 901 |
+
portfolio: {},
|
| 902 |
+
cards: [],
|
| 903 |
+
playedCards: [],
|
| 904 |
+
isHost: p.id === hostId
|
| 905 |
+
})),
|
| 906 |
+
hostId
|
| 907 |
+
}));
|
| 908 |
+
});
|
| 909 |
+
|
| 910 |
+
newSocket.on('start_game', (state) => {
|
| 911 |
+
setGameState(state);
|
| 912 |
+
});
|
| 913 |
+
|
| 914 |
+
newSocket.on('state_update', (state) => {
|
| 915 |
+
setGameState(state);
|
| 916 |
+
});
|
| 917 |
+
|
| 918 |
+
newSocket.on('error_message', (msg) => {
|
| 919 |
+
setError(msg);
|
| 920 |
+
setTimeout(() => setError(''), 3000);
|
| 921 |
+
});
|
| 922 |
+
|
| 923 |
+
return () => {
|
| 924 |
+
newSocket.disconnect();
|
| 925 |
+
};
|
| 926 |
+
}, []);
|
| 927 |
+
|
| 928 |
+
// --- Host Logic: Process Actions ---
|
| 929 |
+
useEffect(() => {
|
| 930 |
+
if (!isHost || !socket) return;
|
| 931 |
+
|
| 932 |
+
const handleAction = ({ playerId, action }: { playerId: string, action: any }) => {
|
| 933 |
+
if (!gameStateRef.current) return;
|
| 934 |
+
try {
|
| 935 |
+
const nextState = processAction(gameStateRef.current, playerId, action);
|
| 936 |
+
socket.emit('state_update', { roomId: gameStateRef.current.roomId, state: nextState });
|
| 937 |
+
} catch (err) {
|
| 938 |
+
console.error("Error processing action:", err);
|
| 939 |
+
}
|
| 940 |
+
};
|
| 941 |
+
|
| 942 |
+
socket.on('action_received', handleAction);
|
| 943 |
+
return () => {
|
| 944 |
+
socket.off('action_received', handleAction);
|
| 945 |
+
};
|
| 946 |
+
}, [isHost, socket]);
|
| 947 |
+
|
| 948 |
+
// --- Handlers ---
|
| 949 |
+
const handleHost = () => {
|
| 950 |
+
if (!username) return setError('Enter username');
|
| 951 |
+
const id = Math.random().toString(36).substring(2, 7).toUpperCase();
|
| 952 |
+
setRoomId(id);
|
| 953 |
+
socket?.emit('join', { roomId: id, username, maxPlayers, playerId: persistentPlayerId });
|
| 954 |
+
};
|
| 955 |
+
|
| 956 |
+
const handleJoin = () => {
|
| 957 |
+
if (!username || !roomId) return setError('Enter username and room ID');
|
| 958 |
+
socket?.emit('join', { roomId, username, playerId: persistentPlayerId });
|
| 959 |
+
};
|
| 960 |
+
|
| 961 |
+
const handleStartGame = () => {
|
| 962 |
+
if (!isHost || !gameState) return;
|
| 963 |
+
const initialWindfallDeck = shuffle([
|
| 964 |
+
'SHARE_SUSPENDED', 'SHARE_SUSPENDED',
|
| 965 |
+
'LOAN_STOCK_MATURED', 'LOAN_STOCK_MATURED',
|
| 966 |
+
'DEBENTURE', 'DEBENTURE',
|
| 967 |
+
'RIGHTS_ISSUE', 'RIGHTS_ISSUE'
|
| 968 |
+
] as WindfallType[]);
|
| 969 |
+
|
| 970 |
+
const players = gameState.players.map(p => ({
|
| 971 |
+
...p,
|
| 972 |
+
cash: INITIAL_CASH,
|
| 973 |
+
portfolio: {},
|
| 974 |
+
playedCards: [],
|
| 975 |
+
cards: generateCards(initialWindfallDeck)
|
| 976 |
+
}));
|
| 977 |
+
|
| 978 |
+
const initialState: GameState = {
|
| 979 |
+
...gameState,
|
| 980 |
+
status: 'playing',
|
| 981 |
+
roomId,
|
| 982 |
+
maxRounds,
|
| 983 |
+
windfallDeck: initialWindfallDeck,
|
| 984 |
+
stocks: STOCKS.map(s => ({
|
| 985 |
+
id: s.id,
|
| 986 |
+
name: s.name,
|
| 987 |
+
icon: s.icon,
|
| 988 |
+
price: s.initialPrice,
|
| 989 |
+
history: [s.initialPrice],
|
| 990 |
+
availableShares: MARKET_CAP_PER_STOCK,
|
| 991 |
+
color: s.color,
|
| 992 |
+
bgColor: s.bgColor,
|
| 993 |
+
borderColor: s.borderColor,
|
| 994 |
+
isInsolvent: false
|
| 995 |
+
})),
|
| 996 |
+
players,
|
| 997 |
+
round: 1,
|
| 998 |
+
turn: 1,
|
| 999 |
+
currentPlayerIndex: 0,
|
| 1000 |
+
turnActionsCount: 0
|
| 1001 |
+
};
|
| 1002 |
+
socket?.emit('start_game', { roomId, initialState });
|
| 1003 |
+
};
|
| 1004 |
+
|
| 1005 |
+
const sendAction = (action: any) => {
|
| 1006 |
+
const isSpecialAction = action.type === 'rights_issue_decision' || (action.type === 'play_windfall' && action.cardType === 'SHARE_SUSPENDED');
|
| 1007 |
+
if (!isMyTurn && !isSpecialAction) return;
|
| 1008 |
+
socket?.emit('action', { roomId: gameState?.roomId, action });
|
| 1009 |
+
};
|
| 1010 |
+
|
| 1011 |
+
const handleRevealNext = () => {
|
| 1012 |
+
if (!isHost || !gameState) return;
|
| 1013 |
+
let nextState;
|
| 1014 |
+
if (!gameState.revealSteps || gameState.revealSteps.length === 0) {
|
| 1015 |
+
nextState = calculateReveal(gameState);
|
| 1016 |
+
} else {
|
| 1017 |
+
nextState = startNextTurn(gameState);
|
| 1018 |
+
}
|
| 1019 |
+
socket?.emit('state_update', { roomId: gameState.roomId, state: nextState });
|
| 1020 |
+
};
|
| 1021 |
+
|
| 1022 |
+
// --- UI Components ---
|
| 1023 |
+
|
| 1024 |
+
if (!gameState || gameState.status === 'setup') {
|
| 1025 |
+
return (
|
| 1026 |
+
<div className="min-h-screen bg-zinc-950 text-zinc-100 flex items-center justify-center p-6 font-sans selection:bg-orange-500/30 overflow-hidden">
|
| 1027 |
+
<TickerBackground />
|
| 1028 |
+
|
| 1029 |
+
<div className="fixed inset-0 overflow-hidden pointer-events-none opacity-20">
|
| 1030 |
+
<div className="absolute -top-[10%] -left-[10%] w-[40%] h-[40%] bg-orange-600 rounded-full blur-[120px]" />
|
| 1031 |
+
<div className="absolute -bottom-[10%] -right-[10%] w-[40%] h-[40%] bg-zinc-800 rounded-full blur-[120px]" />
|
| 1032 |
+
</div>
|
| 1033 |
+
|
| 1034 |
+
<motion.div
|
| 1035 |
+
initial={{ opacity: 0, y: 20 }}
|
| 1036 |
+
animate={{ opacity: 1, y: 0 }}
|
| 1037 |
+
className="w-full max-w-md space-y-12 relative z-10"
|
| 1038 |
+
>
|
| 1039 |
+
<div className="text-center space-y-4">
|
| 1040 |
+
<h1 className="text-7xl font-black tracking-tighter italic text-white uppercase leading-[0.8] font-display">
|
| 1041 |
+
STOCK<br />
|
| 1042 |
+
<span className="text-orange-500">RIVALS</span>
|
| 1043 |
+
</h1>
|
| 1044 |
+
<p className="text-zinc-500 text-xs font-mono uppercase tracking-[0.4em] pt-2">The Ultimate Trading Floor</p>
|
| 1045 |
+
</div>
|
| 1046 |
+
|
| 1047 |
+
<div className="space-y-6 bg-zinc-900/40 backdrop-blur-xl p-6 md:p-8 rounded-3xl md:rounded-[2.5rem] border border-white/5 shadow-2xl">
|
| 1048 |
+
<div className="grid grid-cols-2 gap-4">
|
| 1049 |
+
<div className="space-y-3">
|
| 1050 |
+
<label className="text-[10px] uppercase tracking-[0.2em] text-zinc-500 font-black ml-1">Identity</label>
|
| 1051 |
+
<input
|
| 1052 |
+
value={username}
|
| 1053 |
+
onChange={e => setUsername(e.target.value)}
|
| 1054 |
+
placeholder="CALLSIGN"
|
| 1055 |
+
className="w-full bg-white/5 border border-white/5 rounded-2xl p-3 md:p-4 text-zinc-100 focus:ring-2 focus:ring-orange-500/50 transition-all font-mono placeholder:text-zinc-700 outline-none"
|
| 1056 |
+
/>
|
| 1057 |
+
</div>
|
| 1058 |
+
<div className="space-y-3">
|
| 1059 |
+
<label className="text-[10px] uppercase tracking-[0.2em] text-zinc-500 font-black ml-1">Max Players</label>
|
| 1060 |
+
<select
|
| 1061 |
+
value={maxPlayers}
|
| 1062 |
+
onChange={e => setMaxPlayers(parseInt(e.target.value))}
|
| 1063 |
+
className="w-full bg-white/5 border border-white/5 rounded-2xl p-3 md:p-4 text-zinc-100 focus:ring-2 focus:ring-orange-500/50 transition-all font-mono outline-none appearance-none cursor-pointer"
|
| 1064 |
+
>
|
| 1065 |
+
{[...Array(11)].map((_, i) => (
|
| 1066 |
+
<option key={i + 2} value={i + 2} className="bg-zinc-900">{i + 2}</option>
|
| 1067 |
+
))}
|
| 1068 |
+
</select>
|
| 1069 |
+
</div>
|
| 1070 |
+
</div>
|
| 1071 |
+
|
| 1072 |
+
<div className="space-y-3">
|
| 1073 |
+
<label className="text-[10px] uppercase tracking-[0.2em] text-zinc-500 font-black ml-1">Number of Rounds</label>
|
| 1074 |
+
<select
|
| 1075 |
+
value={maxRounds}
|
| 1076 |
+
onChange={e => setMaxRounds(parseInt(e.target.value))}
|
| 1077 |
+
className="w-full bg-white/5 border border-white/5 rounded-2xl p-3 md:p-4 text-zinc-100 focus:ring-2 focus:ring-orange-500/50 transition-all font-mono outline-none appearance-none cursor-pointer"
|
| 1078 |
+
>
|
| 1079 |
+
{[3, 5, 7, 10, 12, 15, 20].map((r) => (
|
| 1080 |
+
<option key={r} value={r} className="bg-zinc-900">{r} Rounds</option>
|
| 1081 |
+
))}
|
| 1082 |
+
</select>
|
| 1083 |
+
</div>
|
| 1084 |
+
|
| 1085 |
+
<div className="pt-4 space-y-4">
|
| 1086 |
+
<button
|
| 1087 |
+
onClick={handleHost}
|
| 1088 |
+
className="w-full bg-orange-600 hover:bg-orange-500 text-white font-black py-3 md:py-4 rounded-2xl transition-all flex items-center justify-center gap-3 group shadow-lg shadow-orange-900/20"
|
| 1089 |
+
>
|
| 1090 |
+
HOST SESSION <Play size={18} fill="currentColor" className="group-hover:translate-x-1 transition-transform" />
|
| 1091 |
+
</button>
|
| 1092 |
+
|
| 1093 |
+
<div className="relative py-2 flex items-center">
|
| 1094 |
+
<div className="flex-grow border-t border-white/5"></div>
|
| 1095 |
+
<span className="flex-shrink mx-4 text-[9px] text-zinc-600 font-black uppercase tracking-[0.3em]">Network Join</span>
|
| 1096 |
+
<div className="flex-grow border-t border-white/5"></div>
|
| 1097 |
+
</div>
|
| 1098 |
+
|
| 1099 |
+
<div className="flex gap-2">
|
| 1100 |
+
<input
|
| 1101 |
+
value={roomId}
|
| 1102 |
+
onChange={e => setRoomId(e.target.value.toUpperCase())}
|
| 1103 |
+
placeholder="ROOM_ID"
|
| 1104 |
+
className="flex-1 min-w-0 bg-white/5 border border-white/5 rounded-2xl p-3 md:p-4 text-zinc-100 focus:ring-2 focus:ring-orange-500/50 transition-all font-mono text-center placeholder:text-zinc-700 outline-none text-sm"
|
| 1105 |
+
/>
|
| 1106 |
+
<button
|
| 1107 |
+
onClick={handleJoin}
|
| 1108 |
+
className="w-20 md:w-24 flex-none bg-zinc-100 hover:bg-white text-zinc-950 font-black rounded-2xl transition-all uppercase tracking-widest text-[10px]"
|
| 1109 |
+
>
|
| 1110 |
+
Join
|
| 1111 |
+
</button>
|
| 1112 |
+
</div>
|
| 1113 |
+
</div>
|
| 1114 |
+
{error && <p className="text-red-500 text-[10px] text-center font-mono font-bold uppercase tracking-widest animate-pulse">{error}</p>}
|
| 1115 |
+
</div>
|
| 1116 |
+
|
| 1117 |
+
<div className="text-center">
|
| 1118 |
+
<button
|
| 1119 |
+
onClick={() => setShowPrivacy(true)}
|
| 1120 |
+
className="text-[10px] text-zinc-600 hover:text-orange-500 transition-colors font-black uppercase tracking-[0.3em] flex items-center justify-center gap-2 mx-auto"
|
| 1121 |
+
>
|
| 1122 |
+
<Shield size={12} /> Privacy Policy
|
| 1123 |
+
</button>
|
| 1124 |
+
</div>
|
| 1125 |
+
</motion.div>
|
| 1126 |
+
|
| 1127 |
+
<AnimatePresence>
|
| 1128 |
+
{showPrivacy && (
|
| 1129 |
+
<motion.div
|
| 1130 |
+
initial={{ opacity: 0 }}
|
| 1131 |
+
animate={{ opacity: 1 }}
|
| 1132 |
+
exit={{ opacity: 0 }}
|
| 1133 |
+
className="fixed inset-0 z-[100] bg-zinc-950/90 backdrop-blur-xl flex items-center justify-center p-6"
|
| 1134 |
+
>
|
| 1135 |
+
<motion.div
|
| 1136 |
+
initial={{ scale: 0.9, y: 20 }}
|
| 1137 |
+
animate={{ scale: 1, y: 0 }}
|
| 1138 |
+
exit={{ scale: 0.9, y: 20 }}
|
| 1139 |
+
className="bg-zinc-900 border border-white/10 rounded-[2.5rem] max-w-2xl w-full max-h-[80vh] overflow-hidden flex flex-col shadow-2xl"
|
| 1140 |
+
>
|
| 1141 |
+
<div className="p-8 border-b border-white/5 flex justify-between items-center bg-white/5">
|
| 1142 |
+
<div className="flex items-center gap-3">
|
| 1143 |
+
<Shield className="text-orange-500" size={24} />
|
| 1144 |
+
<h2 className="text-2xl font-black italic uppercase tracking-tighter text-white">Privacy Policy</h2>
|
| 1145 |
+
</div>
|
| 1146 |
+
<button onClick={() => setShowPrivacy(false)} className="text-zinc-500 hover:text-white transition-colors">
|
| 1147 |
+
<X size={24} />
|
| 1148 |
+
</button>
|
| 1149 |
+
</div>
|
| 1150 |
+
<div className="p-8 overflow-y-auto scrollbar-hide space-y-6 text-zinc-400 font-sans text-sm leading-relaxed">
|
| 1151 |
+
<section>
|
| 1152 |
+
<h3 className="text-white font-black uppercase tracking-widest text-xs mb-2">1. Data Collection</h3>
|
| 1153 |
+
<p>Stock Rivals is a real-time multiplayer game. We collect minimal data required for gameplay, including your chosen callsign and game-related actions. We do not collect personal identifiable information (PII) like your real name, address, or phone number unless explicitly provided.</p>
|
| 1154 |
+
</section>
|
| 1155 |
+
<section>
|
| 1156 |
+
<h3 className="text-white font-black uppercase tracking-widest text-xs mb-2">2. Cookies & Local Storage</h3>
|
| 1157 |
+
<p>We use local storage and cookies to maintain your session, remember your player identity across reconnections, and store basic game preferences. These are essential for the technical operation of the game.</p>
|
| 1158 |
+
</section>
|
| 1159 |
+
<section>
|
| 1160 |
+
<h3 className="text-white font-black uppercase tracking-widest text-xs mb-2">3. Third-Party Services</h3>
|
| 1161 |
+
<p>We use Socket.IO for real-time communication. In the future, we may integrate third-party advertising services (like Google AdSense) or analytics tools. These services may collect data such as your IP address and browser information to serve relevant ads or improve game performance.</p>
|
| 1162 |
+
</section>
|
| 1163 |
+
<section>
|
| 1164 |
+
<h3 className="text-white font-black uppercase tracking-widest text-xs mb-2">4. Data Security</h3>
|
| 1165 |
+
<p>While we strive to protect your game data, no method of transmission over the internet is 100% secure. By using Stock Rivals, you acknowledge that you provide your data at your own risk.</p>
|
| 1166 |
+
</section>
|
| 1167 |
+
<section>
|
| 1168 |
+
<h3 className="text-white font-black uppercase tracking-widest text-xs mb-2">5. Updates</h3>
|
| 1169 |
+
<p>We may update this policy from time to time. Continued use of the game constitutes acceptance of the updated terms.</p>
|
| 1170 |
+
</section>
|
| 1171 |
+
<div className="pt-4 border-t border-white/5">
|
| 1172 |
+
<p className="text-[10px] text-zinc-600 font-black uppercase tracking-widest">Last Updated: April 2026</p>
|
| 1173 |
+
</div>
|
| 1174 |
+
</div>
|
| 1175 |
+
</motion.div>
|
| 1176 |
+
</motion.div>
|
| 1177 |
+
)}
|
| 1178 |
+
</AnimatePresence>
|
| 1179 |
+
</div>
|
| 1180 |
+
);
|
| 1181 |
+
}
|
| 1182 |
+
|
| 1183 |
+
if (gameState.status === 'lobby') {
|
| 1184 |
+
return (
|
| 1185 |
+
<div className="min-h-screen bg-zinc-950 text-zinc-100 p-6 flex flex-col items-center justify-center font-sans overflow-hidden">
|
| 1186 |
+
<TickerBackground />
|
| 1187 |
+
|
| 1188 |
+
<div className="fixed inset-0 overflow-hidden pointer-events-none opacity-10">
|
| 1189 |
+
<div className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-full h-full bg-orange-500 rounded-full blur-[200px]" />
|
| 1190 |
+
</div>
|
| 1191 |
+
|
| 1192 |
+
<div className="w-full max-w-md space-y-10 relative z-10">
|
| 1193 |
+
<div className="flex justify-between items-end">
|
| 1194 |
+
<div>
|
| 1195 |
+
<div className="flex items-center gap-2 mb-2">
|
| 1196 |
+
<div className="w-2 h-2 rounded-full bg-orange-500 animate-pulse" />
|
| 1197 |
+
<p className="text-[10px] text-orange-500 font-black uppercase tracking-[0.3em]">Session Ready</p>
|
| 1198 |
+
</div>
|
| 1199 |
+
<h2 className="text-5xl font-black italic uppercase tracking-tighter font-display">ID: {gameState.roomId}</h2>
|
| 1200 |
+
</div>
|
| 1201 |
+
<div className="bg-white/5 px-4 py-2 rounded-2xl border border-white/5 flex items-center gap-3 backdrop-blur-md">
|
| 1202 |
+
<Users size={16} className="text-zinc-500" />
|
| 1203 |
+
<span className="text-sm font-mono font-black">{gameState.players.length}<span className="text-zinc-600">/{gameState.maxPlayers || 10}</span></span>
|
| 1204 |
+
</div>
|
| 1205 |
+
</div>
|
| 1206 |
+
|
| 1207 |
+
<div className="bg-zinc-900/40 backdrop-blur-xl rounded-[2.5rem] border border-white/5 overflow-hidden shadow-2xl">
|
| 1208 |
+
<div className="p-6 border-b border-white/5 bg-white/5">
|
| 1209 |
+
<p className="text-[10px] text-zinc-500 font-black uppercase tracking-[0.2em]">Manifest / Active Players</p>
|
| 1210 |
+
</div>
|
| 1211 |
+
<div className="divide-y divide-white/5 max-h-[40vh] overflow-y-auto scrollbar-hide">
|
| 1212 |
+
{gameState.players.map((p, i) => (
|
| 1213 |
+
<motion.div
|
| 1214 |
+
initial={{ opacity: 0, x: -20 }}
|
| 1215 |
+
animate={{ opacity: 1, x: 0 }}
|
| 1216 |
+
transition={{ delay: i * 0.1 }}
|
| 1217 |
+
key={p.id}
|
| 1218 |
+
className="p-6 flex justify-between items-center group hover:bg-white/5 transition-colors"
|
| 1219 |
+
>
|
| 1220 |
+
<div className="flex items-center gap-4">
|
| 1221 |
+
<div className="w-12 h-12 rounded-2xl bg-zinc-800 border border-white/5 flex items-center justify-center text-lg font-black text-zinc-400 group-hover:border-orange-500/30 transition-colors">
|
| 1222 |
+
{p.name[0].toUpperCase()}
|
| 1223 |
+
</div>
|
| 1224 |
+
<div>
|
| 1225 |
+
<span className={`text-lg font-black italic uppercase tracking-tight ${p.id === myId ? 'text-orange-500' : 'text-zinc-200'}`}>
|
| 1226 |
+
{p.name}
|
| 1227 |
+
</span>
|
| 1228 |
+
{p.id === myId && <p className="text-[8px] font-black text-zinc-600 uppercase tracking-widest mt-0.5">Local Client</p>}
|
| 1229 |
+
</div>
|
| 1230 |
+
</div>
|
| 1231 |
+
{p.isHost && (
|
| 1232 |
+
<div className="flex items-center gap-2 bg-orange-500/10 px-3 py-1.5 rounded-xl border border-orange-500/20">
|
| 1233 |
+
<div className="w-1.5 h-1.5 rounded-full bg-orange-500" />
|
| 1234 |
+
<span className="text-[9px] text-orange-500 font-black uppercase tracking-widest">Host</span>
|
| 1235 |
+
</div>
|
| 1236 |
+
)}
|
| 1237 |
+
</motion.div>
|
| 1238 |
+
))}
|
| 1239 |
+
</div>
|
| 1240 |
+
</div>
|
| 1241 |
+
|
| 1242 |
+
{isHost ? (
|
| 1243 |
+
<button
|
| 1244 |
+
onClick={handleStartGame}
|
| 1245 |
+
disabled={gameState.players.length < 2}
|
| 1246 |
+
className={`w-full py-6 rounded-[2rem] font-black uppercase tracking-[0.2em] transition-all shadow-2xl ${
|
| 1247 |
+
gameState.players.length >= 2
|
| 1248 |
+
? 'bg-orange-600 hover:bg-orange-500 text-white scale-100 hover:scale-[1.02] active:scale-95'
|
| 1249 |
+
: 'bg-zinc-800 text-zinc-600 cursor-not-allowed opacity-50'
|
| 1250 |
+
}`}
|
| 1251 |
+
>
|
| 1252 |
+
Open Market
|
| 1253 |
+
</button>
|
| 1254 |
+
) : (
|
| 1255 |
+
<div className="text-center p-8 bg-white/5 rounded-[2rem] border border-white/5 border-dashed">
|
| 1256 |
+
<div className="flex flex-col items-center gap-4">
|
| 1257 |
+
<RefreshCw size={24} className="animate-spin text-orange-500/50" />
|
| 1258 |
+
<p className="text-xs text-zinc-500 font-mono font-bold uppercase tracking-[0.2em]">Synchronizing with Host...</p>
|
| 1259 |
+
</div>
|
| 1260 |
+
</div>
|
| 1261 |
+
)}
|
| 1262 |
+
</div>
|
| 1263 |
+
</div>
|
| 1264 |
+
);
|
| 1265 |
+
}
|
| 1266 |
+
|
| 1267 |
+
if (gameState.status === 'playing' || gameState.status === 'reveal') {
|
| 1268 |
+
const currentStock = gameState.stocks.find(s => s.id === selectedStockId)!;
|
| 1269 |
+
const myPortfolio = me?.portfolio[selectedStockId] || 0;
|
| 1270 |
+
const currentPlayer = gameState.players[gameState.currentPlayerIndex];
|
| 1271 |
+
|
| 1272 |
+
return (
|
| 1273 |
+
<div className="min-h-screen bg-zinc-950 text-zinc-100 font-sans flex flex-col selection:bg-orange-500/30 overflow-hidden relative">
|
| 1274 |
+
<TickerBackground />
|
| 1275 |
+
|
| 1276 |
+
{/* Rights Issue Participation Prompt */}
|
| 1277 |
+
{gameState.pendingRightsIssue && gameState.pendingRightsIssue.decisions[myId] === null && (
|
| 1278 |
+
<div className="fixed inset-0 z-[200] bg-zinc-950/80 backdrop-blur-md flex items-center justify-center p-6">
|
| 1279 |
+
<motion.div
|
| 1280 |
+
initial={{ scale: 0.9, opacity: 0 }}
|
| 1281 |
+
animate={{ scale: 1, opacity: 1 }}
|
| 1282 |
+
className="bg-zinc-900 border border-white/10 p-8 rounded-[2.5rem] max-w-md w-full shadow-2xl text-center space-y-6"
|
| 1283 |
+
>
|
| 1284 |
+
<div className="w-16 h-16 bg-emerald-500/20 rounded-2xl flex items-center justify-center mx-auto">
|
| 1285 |
+
<Plus size={32} className="text-emerald-500" />
|
| 1286 |
+
</div>
|
| 1287 |
+
<div>
|
| 1288 |
+
<h3 className="text-2xl font-black italic uppercase tracking-tighter text-white">Rights Issue Opportunity</h3>
|
| 1289 |
+
<p className="text-zinc-500 text-xs font-mono mt-2">
|
| 1290 |
+
A Rights Issue has been initiated for <span className="text-white font-bold">{gameState.pendingRightsIssue.stockId}</span>.
|
| 1291 |
+
You can buy 1 additional share for every 2 you hold at <span className="text-emerald-500 font-bold">₹10/share</span>.
|
| 1292 |
+
</p>
|
| 1293 |
+
</div>
|
| 1294 |
+
<div className="bg-white/5 p-4 rounded-2xl border border-white/5">
|
| 1295 |
+
<p className="text-[10px] text-zinc-500 font-black uppercase tracking-widest mb-1">Your Current Holding</p>
|
| 1296 |
+
<p className="text-xl font-black font-mono">{(me?.portfolio[gameState.pendingRightsIssue.stockId] || 0).toLocaleString()} Shares</p>
|
| 1297 |
+
<p className="text-[10px] text-emerald-500 font-bold mt-1">
|
| 1298 |
+
Potential: +{(Math.floor((me?.portfolio[gameState.pendingRightsIssue.stockId] || 0) / 2000) * 1000).toLocaleString()} @ ₹10
|
| 1299 |
+
</p>
|
| 1300 |
+
</div>
|
| 1301 |
+
<div className="grid grid-cols-2 gap-4">
|
| 1302 |
+
<button
|
| 1303 |
+
onClick={() => sendAction({ type: 'rights_issue_decision', participate: true })}
|
| 1304 |
+
className="bg-emerald-600 hover:bg-emerald-500 text-white font-black py-4 rounded-2xl transition-all uppercase text-xs"
|
| 1305 |
+
>
|
| 1306 |
+
Participate
|
| 1307 |
+
</button>
|
| 1308 |
+
<button
|
| 1309 |
+
onClick={() => sendAction({ type: 'rights_issue_decision', participate: false })}
|
| 1310 |
+
className="bg-zinc-800 hover:bg-zinc-700 text-zinc-400 font-black py-4 rounded-2xl transition-all uppercase text-xs"
|
| 1311 |
+
>
|
| 1312 |
+
Decline
|
| 1313 |
+
</button>
|
| 1314 |
+
</div>
|
| 1315 |
+
</motion.div>
|
| 1316 |
+
</div>
|
| 1317 |
+
)}
|
| 1318 |
+
|
| 1319 |
+
{/* Header */}
|
| 1320 |
+
<div className="p-4 bg-zinc-900/40 border-b border-white/5 sticky top-0 z-20 backdrop-blur-xl">
|
| 1321 |
+
<div className="max-w-6xl mx-auto flex justify-between items-center">
|
| 1322 |
+
<div className="flex items-center gap-6">
|
| 1323 |
+
<div className="flex items-center gap-2">
|
| 1324 |
+
<div className="bg-zinc-800/50 border border-white/5 px-4 py-2 rounded-2xl">
|
| 1325 |
+
<p className="text-[8px] text-zinc-500 font-black uppercase tracking-[0.2em] mb-0.5">Round</p>
|
| 1326 |
+
<p className="text-xl font-black italic leading-none font-display text-orange-500">{gameState.round}<span className="text-zinc-600 text-sm not-italic ml-1">/ {ROUNDS_COUNT}</span></p>
|
| 1327 |
+
</div>
|
| 1328 |
+
<div className="bg-zinc-800/50 border border-white/5 px-4 py-2 rounded-2xl">
|
| 1329 |
+
<p className="text-[8px] text-zinc-500 font-black uppercase tracking-[0.2em] mb-0.5">Turn</p>
|
| 1330 |
+
<p className="text-xl font-black italic leading-none font-display text-white">{gameState.turn}<span className="text-zinc-600 text-sm not-italic ml-1">/ {TURNS_PER_ROUND}</span></p>
|
| 1331 |
+
</div>
|
| 1332 |
+
</div>
|
| 1333 |
+
</div>
|
| 1334 |
+
|
| 1335 |
+
<div className="hidden md:block text-center">
|
| 1336 |
+
<h1 className="text-xl font-black italic tracking-tighter uppercase font-display">
|
| 1337 |
+
STOCK<span className="text-orange-500">RIVALS</span>
|
| 1338 |
+
</h1>
|
| 1339 |
+
</div>
|
| 1340 |
+
|
| 1341 |
+
<div className="flex items-center gap-3">
|
| 1342 |
+
<div className="text-right hidden sm:block bg-white/5 border border-white/5 px-5 py-2 rounded-2xl">
|
| 1343 |
+
<p className="text-[9px] text-zinc-500 font-black uppercase tracking-[0.2em] mb-0.5">Portfolio Value</p>
|
| 1344 |
+
<p className="text-xl font-black font-mono">₹{totalPortfolioValue.toLocaleString()}</p>
|
| 1345 |
+
</div>
|
| 1346 |
+
<div className="text-right bg-orange-500/10 border border-orange-500/20 px-5 py-2 rounded-2xl">
|
| 1347 |
+
<p className="text-[9px] text-orange-500/70 font-black uppercase tracking-[0.2em] mb-0.5">Liquid Capital</p>
|
| 1348 |
+
<p className="text-xl font-black font-mono">₹{me?.cash.toLocaleString()}</p>
|
| 1349 |
+
</div>
|
| 1350 |
+
</div>
|
| 1351 |
+
</div>
|
| 1352 |
+
</div>
|
| 1353 |
+
|
| 1354 |
+
<div className="flex-1 max-w-6xl w-full mx-auto p-4 md:p-8 space-y-8">
|
| 1355 |
+
{/* Recent Activity Feed */}
|
| 1356 |
+
<div className="bg-zinc-900/40 backdrop-blur-xl rounded-[2rem] p-4 border border-white/5 shadow-xl overflow-hidden">
|
| 1357 |
+
<div className="flex items-center justify-between mb-3 px-2">
|
| 1358 |
+
<div className="flex items-center gap-3">
|
| 1359 |
+
<Radio size={14} className="text-orange-500 animate-pulse" />
|
| 1360 |
+
<p className="text-[10px] text-zinc-500 font-black uppercase tracking-[0.3em]">Live Transaction Feed</p>
|
| 1361 |
+
</div>
|
| 1362 |
+
<div className="flex items-center gap-2 bg-orange-500/10 px-3 py-1 rounded-full border border-orange-500/20">
|
| 1363 |
+
<p className="text-[10px] text-orange-500 font-black uppercase tracking-widest">
|
| 1364 |
+
Turn {currentPlayer.name}
|
| 1365 |
+
</p>
|
| 1366 |
+
</div>
|
| 1367 |
+
</div>
|
| 1368 |
+
<div className="flex gap-4 overflow-x-auto pb-2 scrollbar-hide px-2">
|
| 1369 |
+
{gameState.players.map(p => (
|
| 1370 |
+
<div key={p.id} className="flex-none bg-white/5 border border-white/5 rounded-xl px-4 py-2 flex items-center gap-3 min-w-[200px]">
|
| 1371 |
+
<div className="w-8 h-8 rounded-lg bg-zinc-800 flex items-center justify-center text-xs font-black text-zinc-400">
|
| 1372 |
+
{p.name[0]}
|
| 1373 |
+
</div>
|
| 1374 |
+
<div>
|
| 1375 |
+
<p className="text-[10px] font-black text-white uppercase tracking-tight">{p.name}</p>
|
| 1376 |
+
<p className={`text-[9px] font-bold uppercase truncate ${p.lastAction?.includes('Failed') ? 'text-rose-500' : 'text-emerald-500'}`}>
|
| 1377 |
+
{p.lastAction || 'Waiting for move...'}
|
| 1378 |
+
</p>
|
| 1379 |
+
</div>
|
| 1380 |
+
</div>
|
| 1381 |
+
))}
|
| 1382 |
+
</div>
|
| 1383 |
+
</div>
|
| 1384 |
+
|
| 1385 |
+
{gameState.status === 'playing' ? (
|
| 1386 |
+
<div className="grid grid-cols-1 lg:grid-cols-12 gap-8">
|
| 1387 |
+
{/* Stock List - Tabular Format */}
|
| 1388 |
+
<div className="lg:col-span-8 space-y-4">
|
| 1389 |
+
<div className="flex flex-col items-center">
|
| 1390 |
+
<div className="flex items-center gap-2 mb-4">
|
| 1391 |
+
<span className="text-zinc-500 text-[10px]">▲</span>
|
| 1392 |
+
<h3 className="text-[10px] text-zinc-500 font-black uppercase tracking-[0.4em]">Market Board</h3>
|
| 1393 |
+
</div>
|
| 1394 |
+
|
| 1395 |
+
<div className="w-full bg-zinc-900/40 backdrop-blur-xl rounded-[1.5rem] p-2 md:p-6 border border-white/5 shadow-xl overflow-x-auto scrollbar-hide">
|
| 1396 |
+
<table className="w-full text-left border-collapse table-fixed">
|
| 1397 |
+
<thead>
|
| 1398 |
+
<tr className="border-b border-white/5">
|
| 1399 |
+
<th className="py-2 px-1 text-[7px] md:text-[10px] text-zinc-500 font-black uppercase tracking-tighter w-14 md:w-32">Metric</th>
|
| 1400 |
+
{gameState.stocks.map(stock => (
|
| 1401 |
+
<th key={stock.id} className="py-2 px-0.5 text-center">
|
| 1402 |
+
<button
|
| 1403 |
+
onClick={() => setSelectedStockId(stock.id)}
|
| 1404 |
+
className={`text-[7px] md:text-[10px] font-black uppercase tracking-tighter transition-all truncate w-full ${selectedStockId === stock.id ? 'text-orange-500 scale-110' : 'text-zinc-400 hover:text-white'}`}
|
| 1405 |
+
>
|
| 1406 |
+
{stock.name}
|
| 1407 |
+
</button>
|
| 1408 |
+
</th>
|
| 1409 |
+
))}
|
| 1410 |
+
</tr>
|
| 1411 |
+
</thead>
|
| 1412 |
+
<tbody className="divide-y divide-white/5">
|
| 1413 |
+
<tr>
|
| 1414 |
+
<td className="py-2 px-1 text-[7px] md:text-[10px] text-zinc-500 font-black uppercase tracking-tighter">Start</td>
|
| 1415 |
+
{gameState.stocks.map(stock => {
|
| 1416 |
+
const initialStock = STOCKS.find(s => s.id === stock.id);
|
| 1417 |
+
return (
|
| 1418 |
+
<td key={stock.id} className="py-2 px-0.5 text-center font-mono text-[8px] md:text-sm text-zinc-400">
|
| 1419 |
+
₹{initialStock?.initialPrice}
|
| 1420 |
+
</td>
|
| 1421 |
+
);
|
| 1422 |
+
})}
|
| 1423 |
+
</tr>
|
| 1424 |
+
<tr>
|
| 1425 |
+
<td className="py-2 px-1 text-[7px] md:text-[10px] text-zinc-500 font-black uppercase tracking-tighter">Value</td>
|
| 1426 |
+
{gameState.stocks.map(stock => {
|
| 1427 |
+
const diff = stock.history.length > 1 ? stock.price - stock.history[stock.history.length - 2] : 0;
|
| 1428 |
+
return (
|
| 1429 |
+
<td key={stock.id} className="py-2 px-0.5 text-center">
|
| 1430 |
+
<div className="flex flex-col items-center">
|
| 1431 |
+
<span className={`text-[9px] md:text-lg font-black font-mono ${stock.isInsolvent ? 'text-rose-500 line-through' : 'text-white'}`}>
|
| 1432 |
+
₹{stock.price}
|
| 1433 |
+
</span>
|
| 1434 |
+
{diff !== 0 && (
|
| 1435 |
+
<span className={`text-[7px] md:text-[10px] font-black font-mono ${diff > 0 ? 'text-emerald-500' : 'text-rose-500'}`}>
|
| 1436 |
+
{diff > 0 ? '+' : ''}{diff}
|
| 1437 |
+
</span>
|
| 1438 |
+
)}
|
| 1439 |
+
</div>
|
| 1440 |
+
</td>
|
| 1441 |
+
);
|
| 1442 |
+
})}
|
| 1443 |
+
</tr>
|
| 1444 |
+
</tbody>
|
| 1445 |
+
</table>
|
| 1446 |
+
</div>
|
| 1447 |
+
</div>
|
| 1448 |
+
|
| 1449 |
+
{/* Insider Intel / Your Hand */}
|
| 1450 |
+
<div className="bg-zinc-900/40 backdrop-blur-xl rounded-[1.5rem] md:rounded-[2.5rem] p-3 md:p-6 border border-white/5 shadow-2xl">
|
| 1451 |
+
<div className="flex items-center justify-between mb-2 md:mb-4">
|
| 1452 |
+
<div className="flex items-center gap-1.5 md:gap-2">
|
| 1453 |
+
<div className="w-6 h-6 md:w-8 md:h-8 rounded-lg md:rounded-xl bg-orange-500/20 flex items-center justify-center">
|
| 1454 |
+
<Info size={14} className="text-orange-500" />
|
| 1455 |
+
</div>
|
| 1456 |
+
<div>
|
| 1457 |
+
<p className="text-[8px] md:text-[10px] text-zinc-500 font-black uppercase tracking-[0.2em] mb-0.5">Your Cards</p>
|
| 1458 |
+
<p className="text-xs md:text-sm font-black uppercase tracking-tight font-display">Market Intel</p>
|
| 1459 |
+
</div>
|
| 1460 |
+
</div>
|
| 1461 |
+
<span className="text-[7px] md:text-[8px] bg-orange-500/10 text-orange-500 px-2 md:px-3 py-0.5 md:py-1 rounded-full font-black uppercase tracking-widest border border-orange-500/20">Confidential</span>
|
| 1462 |
+
</div>
|
| 1463 |
+
|
| 1464 |
+
<CardHand
|
| 1465 |
+
cards={me?.cards || []}
|
| 1466 |
+
isMyTurn={isMyTurn}
|
| 1467 |
+
gameState={gameState}
|
| 1468 |
+
onPlayWindfall={(type, stockId) => sendAction({ type: 'play_windfall', cardType: type, stockId })}
|
| 1469 |
+
selectedStockId={selectedStockId}
|
| 1470 |
+
status={gameState.status}
|
| 1471 |
+
mePortfolio={me?.portfolio}
|
| 1472 |
+
/>
|
| 1473 |
+
|
| 1474 |
+
<div className="text-center mt-4">
|
| 1475 |
+
<p className="text-[8px] text-zinc-600 font-black uppercase tracking-[0.3em]">Hover to inspect cards • Values aggregate at reveal</p>
|
| 1476 |
+
</div>
|
| 1477 |
+
</div>
|
| 1478 |
+
|
| 1479 |
+
{/* Trading Actions - Moved here for better mobile flow */}
|
| 1480 |
+
<div className="bg-zinc-900/40 backdrop-blur-xl rounded-[2.5rem] p-6 md:p-8 border border-white/5 shadow-2xl">
|
| 1481 |
+
<div className="mb-8">
|
| 1482 |
+
<div className="flex justify-between items-start mb-4">
|
| 1483 |
+
<div>
|
| 1484 |
+
<p className="text-[10px] text-zinc-500 font-black uppercase tracking-[0.2em] mb-1">Asset Focus</p>
|
| 1485 |
+
<h4 className="text-3xl font-black italic font-display text-white">{currentStock.name}</h4>
|
| 1486 |
+
</div>
|
| 1487 |
+
<div className="bg-white/5 p-3 rounded-2xl border border-white/5">
|
| 1488 |
+
<TrendingUp size={20} className="text-orange-500/50" />
|
| 1489 |
+
</div>
|
| 1490 |
+
</div>
|
| 1491 |
+
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
|
| 1492 |
+
<div className="bg-white/5 p-4 rounded-2xl border border-white/5">
|
| 1493 |
+
<p className="text-[8px] text-zinc-500 font-black uppercase tracking-widest mb-1">Position</p>
|
| 1494 |
+
<p className="text-lg font-black font-mono">{myPortfolio.toLocaleString()}<span className="text-[10px] text-zinc-600 ml-1">SHRS</span></p>
|
| 1495 |
+
</div>
|
| 1496 |
+
<div className="bg-white/5 p-4 rounded-2xl border border-white/5">
|
| 1497 |
+
<p className="text-[8px] text-zinc-500 font-black uppercase tracking-widest mb-1">Valuation</p>
|
| 1498 |
+
<p className="text-lg font-black font-mono text-orange-500">₹{currentStock.price}</p>
|
| 1499 |
+
</div>
|
| 1500 |
+
<div className="bg-white/5 p-4 rounded-2xl border border-white/5">
|
| 1501 |
+
<p className="text-[8px] text-zinc-500 font-black uppercase tracking-widest mb-1">Market Supply</p>
|
| 1502 |
+
<p className="text-lg font-black font-mono text-zinc-400">{currentStock.availableShares.toLocaleString()}</p>
|
| 1503 |
+
</div>
|
| 1504 |
+
</div>
|
| 1505 |
+
</div>
|
| 1506 |
+
|
| 1507 |
+
<div className="space-y-4">
|
| 1508 |
+
<div className="flex items-center gap-1 md:gap-2 bg-zinc-800/50 rounded-2xl p-1.5 md:p-2 border border-zinc-700/50">
|
| 1509 |
+
<button
|
| 1510 |
+
onClick={() => setTradeAmount(Math.max(MIN_BUY_AMOUNT, tradeAmount - 1000))}
|
| 1511 |
+
className="p-2 md:p-3 hover:bg-zinc-700/50 rounded-xl transition-colors text-zinc-400 hover:text-white flex-none"
|
| 1512 |
+
>
|
| 1513 |
+
<Minus size={16} className="md:w-[18px] md:h-[18px]"/>
|
| 1514 |
+
</button>
|
| 1515 |
+
<input
|
| 1516 |
+
type="number"
|
| 1517 |
+
step="1000"
|
| 1518 |
+
value={tradeAmount}
|
| 1519 |
+
onChange={(e) => {
|
| 1520 |
+
const val = parseInt(e.target.value);
|
| 1521 |
+
if (!isNaN(val)) setTradeAmount(Math.max(0, val));
|
| 1522 |
+
else if (e.target.value === '') setTradeAmount(0);
|
| 1523 |
+
}}
|
| 1524 |
+
className="flex-1 min-w-0 bg-transparent border-none text-center font-mono font-black text-lg md:text-xl focus:ring-0 text-white p-0"
|
| 1525 |
+
/>
|
| 1526 |
+
<button
|
| 1527 |
+
onClick={() => setTradeAmount(tradeAmount + 1000)}
|
| 1528 |
+
className="p-2 md:p-3 hover:bg-zinc-700/50 rounded-xl transition-colors text-zinc-400 hover:text-white flex-none"
|
| 1529 |
+
>
|
| 1530 |
+
<Plus size={16} className="md:w-[18px] md:h-[18px]"/>
|
| 1531 |
+
</button>
|
| 1532 |
+
</div>
|
| 1533 |
+
|
| 1534 |
+
<div className="flex gap-2">
|
| 1535 |
+
<button
|
| 1536 |
+
onClick={() => {
|
| 1537 |
+
if (me) {
|
| 1538 |
+
const maxAffordable = Math.floor(me.cash / currentStock.price);
|
| 1539 |
+
const maxAvailable = currentStock.availableShares;
|
| 1540 |
+
const maxPossible = Math.min(maxAffordable, maxAvailable);
|
| 1541 |
+
const roundedMax = Math.floor(maxPossible / 1000) * 1000;
|
| 1542 |
+
setTradeAmount(Math.max(MIN_BUY_AMOUNT, roundedMax));
|
| 1543 |
+
}
|
| 1544 |
+
}}
|
| 1545 |
+
className="flex-1 py-3 rounded-xl bg-white/5 hover:bg-white/10 border border-white/5 text-[9px] font-black uppercase tracking-[0.2em] text-zinc-400 transition-all"
|
| 1546 |
+
>
|
| 1547 |
+
Max Buy
|
| 1548 |
+
</button>
|
| 1549 |
+
<button
|
| 1550 |
+
onClick={() => {
|
| 1551 |
+
if (me) setTradeAmount(myPortfolio);
|
| 1552 |
+
}}
|
| 1553 |
+
className="flex-1 py-3 rounded-xl bg-white/5 hover:bg-white/10 border border-white/5 text-[9px] font-black uppercase tracking-[0.2em] text-zinc-400 transition-all"
|
| 1554 |
+
>
|
| 1555 |
+
Max Sell
|
| 1556 |
+
</button>
|
| 1557 |
+
</div>
|
| 1558 |
+
|
| 1559 |
+
<div className="grid grid-cols-2 gap-4 pt-2">
|
| 1560 |
+
<button
|
| 1561 |
+
disabled={!isMyTurn || me!.cash < currentStock.price * tradeAmount || tradeAmount < MIN_BUY_AMOUNT || tradeAmount % 1000 !== 0 || currentStock.availableShares < tradeAmount || currentStock.isInsolvent}
|
| 1562 |
+
onClick={() => sendAction({ type: 'buy', stockId: selectedStockId, amount: tradeAmount })}
|
| 1563 |
+
className="bg-emerald-600 hover:bg-emerald-500 disabled:opacity-10 disabled:grayscale text-white font-black py-5 rounded-2xl transition-all uppercase text-xs shadow-xl shadow-emerald-900/20 active:scale-95"
|
| 1564 |
+
>
|
| 1565 |
+
{currentStock.isInsolvent ? 'Insolvent' : 'Execute Buy'}
|
| 1566 |
+
</button>
|
| 1567 |
+
<button
|
| 1568 |
+
disabled={!isMyTurn || myPortfolio < tradeAmount || tradeAmount <= 0 || tradeAmount % 1000 !== 0 || currentStock.isInsolvent}
|
| 1569 |
+
onClick={() => sendAction({ type: 'sell', stockId: selectedStockId, amount: tradeAmount })}
|
| 1570 |
+
className="bg-rose-600 hover:bg-rose-500 disabled:opacity-10 disabled:grayscale text-white font-black py-5 rounded-2xl transition-all uppercase text-xs shadow-xl shadow-rose-900/20 active:scale-95"
|
| 1571 |
+
>
|
| 1572 |
+
{currentStock.isInsolvent ? 'Insolvent' : 'Execute Sell'}
|
| 1573 |
+
</button>
|
| 1574 |
+
</div>
|
| 1575 |
+
{currentStock.availableShares < tradeAmount && (
|
| 1576 |
+
<p className="text-[10px] text-rose-500 font-bold text-center uppercase tracking-widest animate-pulse">
|
| 1577 |
+
Market Cap Reached (Max 2,00,000 Shares)
|
| 1578 |
+
</p>
|
| 1579 |
+
)}
|
| 1580 |
+
<button
|
| 1581 |
+
disabled={!isMyTurn}
|
| 1582 |
+
onClick={() => sendAction({ type: 'pass' })}
|
| 1583 |
+
className="w-full bg-zinc-800 hover:bg-zinc-700 disabled:opacity-30 text-zinc-400 font-black py-4 rounded-2xl transition-all uppercase text-[9px] tracking-[0.3em] border border-white/5"
|
| 1584 |
+
>
|
| 1585 |
+
Hold Position / Pass
|
| 1586 |
+
</button>
|
| 1587 |
+
</div>
|
| 1588 |
+
</div>
|
| 1589 |
+
</div>
|
| 1590 |
+
|
| 1591 |
+
{/* Sidebar Info */}
|
| 1592 |
+
<div className="lg:col-span-4 space-y-6">
|
| 1593 |
+
<div className="bg-zinc-900/40 backdrop-blur-xl rounded-[2.5rem] p-6 md:p-8 border border-white/5 shadow-2xl sticky top-28 space-y-6">
|
| 1594 |
+
<div className="bg-white/5 p-5 rounded-2xl border border-white/5">
|
| 1595 |
+
<div className="flex justify-between items-center mb-1">
|
| 1596 |
+
<p className="text-[10px] text-zinc-500 font-black uppercase tracking-[0.2em]">Portfolio Value</p>
|
| 1597 |
+
<Wallet size={14} className="text-orange-500/50" />
|
| 1598 |
+
</div>
|
| 1599 |
+
<p className="text-3xl font-black font-mono text-white">₹{totalPortfolioValue.toLocaleString()}</p>
|
| 1600 |
+
<div className="flex justify-between items-center mt-2 pt-2 border-t border-white/5">
|
| 1601 |
+
<p className="text-[8px] text-zinc-600 font-black uppercase tracking-widest">Net Worth</p>
|
| 1602 |
+
<p className="text-xs font-black font-mono text-orange-500">₹{((me?.cash || 0) + totalPortfolioValue).toLocaleString()}</p>
|
| 1603 |
+
</div>
|
| 1604 |
+
</div>
|
| 1605 |
+
|
| 1606 |
+
{/* Your Holdings Section */}
|
| 1607 |
+
<div className="space-y-4">
|
| 1608 |
+
<div className="flex items-center gap-2 px-2">
|
| 1609 |
+
<div className="w-1.5 h-1.5 rounded-full bg-emerald-500" />
|
| 1610 |
+
<p className="text-[10px] text-zinc-500 font-black uppercase tracking-[0.2em]">Your Holdings</p>
|
| 1611 |
+
</div>
|
| 1612 |
+
<div className="space-y-2">
|
| 1613 |
+
{me && Object.entries(me.portfolio as Record<string, number>).filter(([_, amt]) => (amt as number) > 0).length > 0 ? (
|
| 1614 |
+
(Object.entries(me.portfolio as Record<string, number>) as [string, number][])
|
| 1615 |
+
.filter(([_, amt]) => amt > 0)
|
| 1616 |
+
.map(([stockId, amt]) => {
|
| 1617 |
+
const stock = gameState.stocks.find(s => s.id === stockId);
|
| 1618 |
+
if (!stock) return null;
|
| 1619 |
+
const Icon = STOCK_ICONS[stock.icon] || Activity;
|
| 1620 |
+
return (
|
| 1621 |
+
<div key={stockId} className="bg-white/5 border border-white/5 rounded-2xl p-4 flex items-center justify-between group hover:bg-white/10 transition-all">
|
| 1622 |
+
<div className="flex items-center gap-3">
|
| 1623 |
+
<div className={`w-10 h-10 rounded-xl flex items-center justify-center bg-white/5 border border-white/5 ${stock.color}`}>
|
| 1624 |
+
<Icon size={18} />
|
| 1625 |
+
</div>
|
| 1626 |
+
<div>
|
| 1627 |
+
<p className="text-[10px] font-black text-white uppercase tracking-tight">{stock.name}</p>
|
| 1628 |
+
<p className="text-[9px] font-bold text-zinc-500 uppercase tracking-widest">{amt.toLocaleString()} Shares</p>
|
| 1629 |
+
</div>
|
| 1630 |
+
</div>
|
| 1631 |
+
<div className="text-right">
|
| 1632 |
+
<p className="text-sm font-black font-mono text-white">₹{(amt * stock.price).toLocaleString()}</p>
|
| 1633 |
+
<p className="text-[8px] font-bold text-zinc-600 uppercase tracking-widest">₹{stock.price}/ea</p>
|
| 1634 |
+
</div>
|
| 1635 |
+
</div>
|
| 1636 |
+
);
|
| 1637 |
+
})
|
| 1638 |
+
) : (
|
| 1639 |
+
<div className="text-center py-8 bg-white/5 rounded-2xl border border-white/5 border-dashed">
|
| 1640 |
+
<p className="text-[9px] text-zinc-600 font-black uppercase tracking-widest">No active positions</p>
|
| 1641 |
+
</div>
|
| 1642 |
+
)}
|
| 1643 |
+
</div>
|
| 1644 |
+
</div>
|
| 1645 |
+
</div>
|
| 1646 |
+
</div>
|
| 1647 |
+
</div>
|
| 1648 |
+
) : (
|
| 1649 |
+
/* Reveal Phase */
|
| 1650 |
+
<div className="space-y-12 py-12">
|
| 1651 |
+
<div className="text-center space-y-4">
|
| 1652 |
+
<motion.div
|
| 1653 |
+
initial={{ scale: 0.5, opacity: 0 }}
|
| 1654 |
+
animate={{ scale: 1, opacity: 1 }}
|
| 1655 |
+
className="inline-block bg-orange-500/10 border border-orange-500/20 px-6 py-2 rounded-full mb-2"
|
| 1656 |
+
>
|
| 1657 |
+
<span className="text-xs font-black uppercase tracking-[0.4em] text-orange-500">Market Correction Phase</span>
|
| 1658 |
+
</motion.div>
|
| 1659 |
+
<h2 className="text-7xl font-black italic text-white uppercase tracking-tighter font-display leading-none">THE REVEAL</h2>
|
| 1660 |
+
<p className="text-zinc-500 font-mono text-xs uppercase tracking-[0.5em]">Aggregating Global Insider Data</p>
|
| 1661 |
+
</div>
|
| 1662 |
+
|
| 1663 |
+
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-6">
|
| 1664 |
+
{gameState.revealSteps?.map((step, i) => {
|
| 1665 |
+
const stock = STOCKS.find(s => s.id === step.stockId)!;
|
| 1666 |
+
const Icon = STOCK_ICONS[stock.icon] || Activity;
|
| 1667 |
+
return (
|
| 1668 |
+
<motion.div
|
| 1669 |
+
initial={{ opacity: 0, y: 20 }}
|
| 1670 |
+
animate={{ opacity: 1, y: 0 }}
|
| 1671 |
+
transition={{ delay: i * 0.1 }}
|
| 1672 |
+
key={step.stockId}
|
| 1673 |
+
className={`relative rounded-[2.5rem] border-2 p-6 transition-all text-left flex flex-col justify-between overflow-hidden bg-gradient-to-br ${stock.cardGradient} border-white/10 shadow-2xl`}
|
| 1674 |
+
>
|
| 1675 |
+
{/* Card Aesthetic Elements */}
|
| 1676 |
+
<div className="absolute inset-0 flex items-center justify-center opacity-10 pointer-events-none">
|
| 1677 |
+
<div className="w-[120%] h-[70%] bg-white rounded-[100%] rotate-[-45deg]" />
|
| 1678 |
+
</div>
|
| 1679 |
+
<div className="absolute inset-2 border border-white/10 rounded-[1.5rem] pointer-events-none" />
|
| 1680 |
+
|
| 1681 |
+
<div className="flex items-center gap-3 mb-6 relative z-10">
|
| 1682 |
+
<div className="w-10 h-10 rounded-xl flex items-center justify-center bg-white/20 border border-white/20">
|
| 1683 |
+
<Icon size={20} className="text-white" />
|
| 1684 |
+
</div>
|
| 1685 |
+
<div>
|
| 1686 |
+
<p className="text-[10px] font-black uppercase tracking-widest leading-none mb-1 text-white/70">{stock.id}</p>
|
| 1687 |
+
<h4 className="text-lg font-black italic font-display leading-none text-white">{stock.name}</h4>
|
| 1688 |
+
</div>
|
| 1689 |
+
</div>
|
| 1690 |
+
|
| 1691 |
+
<div className="space-y-2 relative z-10">
|
| 1692 |
+
{step.originalCards.map((card, idx) => {
|
| 1693 |
+
const player = gameState.players.find(p => p.id === card.playerId);
|
| 1694 |
+
const isVetoed = step.vetoedCard === card;
|
| 1695 |
+
const isDiscarded = step.directorDiscarded === card;
|
| 1696 |
+
|
| 1697 |
+
return (
|
| 1698 |
+
<div key={idx} className={`flex justify-between items-center text-[9px] font-mono p-1.5 rounded-lg border ${
|
| 1699 |
+
isVetoed ? 'bg-rose-500/40 border-rose-500/60 line-through opacity-50' :
|
| 1700 |
+
isDiscarded ? 'bg-amber-500/40 border-amber-500/60 line-through opacity-50' :
|
| 1701 |
+
'bg-black/20 border-white/5'
|
| 1702 |
+
}`}>
|
| 1703 |
+
<span className="text-white/70 font-bold uppercase tracking-tighter truncate max-w-[80px]">
|
| 1704 |
+
{player?.name}
|
| 1705 |
+
</span>
|
| 1706 |
+
<span className={`font-black ${card.value >= 0 ? 'text-emerald-400' : 'text-rose-400'}`}>
|
| 1707 |
+
{card.value > 0 ? '+' : ''}{card.value}
|
| 1708 |
+
</span>
|
| 1709 |
+
</div>
|
| 1710 |
+
);
|
| 1711 |
+
})}
|
| 1712 |
+
|
| 1713 |
+
{step.recovered && (
|
| 1714 |
+
<div className="bg-emerald-500/40 border border-emerald-500/60 p-1.5 rounded-lg text-center mt-2">
|
| 1715 |
+
<p className="text-[8px] font-black text-white uppercase tracking-widest">RECOVERED</p>
|
| 1716 |
+
</div>
|
| 1717 |
+
)}
|
| 1718 |
+
|
| 1719 |
+
{step.becameInsolvent && (
|
| 1720 |
+
<div className="bg-rose-500/40 border border-rose-500/60 p-1.5 rounded-lg text-center mt-2">
|
| 1721 |
+
<p className="text-[8px] font-black text-white uppercase tracking-widest">INSOLVENT</p>
|
| 1722 |
+
</div>
|
| 1723 |
+
)}
|
| 1724 |
+
|
| 1725 |
+
<div className="pt-4 mt-4 border-t border-white/20 flex justify-between items-end">
|
| 1726 |
+
<div>
|
| 1727 |
+
<p className="text-[7px] text-white/50 font-black uppercase tracking-widest mb-1">Shift</p>
|
| 1728 |
+
<span className={`text-3xl font-black font-display italic ${step.finalChange >= 0 ? 'text-emerald-400' : 'text-rose-400'}`}>
|
| 1729 |
+
{step.finalChange > 0 ? '+' : ''}{step.finalChange}
|
| 1730 |
+
</span>
|
| 1731 |
+
</div>
|
| 1732 |
+
<div className="text-right">
|
| 1733 |
+
<p className="text-[7px] text-white/50 font-black uppercase tracking-widest mb-1">Price</p>
|
| 1734 |
+
<p className="text-lg font-black font-mono text-white">₹{step.newPrice}</p>
|
| 1735 |
+
</div>
|
| 1736 |
+
</div>
|
| 1737 |
+
</div>
|
| 1738 |
+
</motion.div>
|
| 1739 |
+
);
|
| 1740 |
+
})}
|
| 1741 |
+
</div>
|
| 1742 |
+
|
| 1743 |
+
{/* Player Hand in Reveal Phase */}
|
| 1744 |
+
<div className="max-w-4xl mx-auto pt-12 border-t border-white/5">
|
| 1745 |
+
<div className="text-center mb-6">
|
| 1746 |
+
<p className="text-[10px] text-zinc-500 font-black uppercase tracking-[0.3em] mb-1">Your Portfolio & Intel</p>
|
| 1747 |
+
<h3 className="text-xl font-black italic uppercase tracking-tighter text-white">Strategic Assets</h3>
|
| 1748 |
+
</div>
|
| 1749 |
+
<CardHand
|
| 1750 |
+
cards={me?.cards || []}
|
| 1751 |
+
gameState={gameState}
|
| 1752 |
+
onPlayWindfall={(type, stockId) => sendAction({ type: 'play_windfall', cardType: type, stockId })}
|
| 1753 |
+
status={gameState.status}
|
| 1754 |
+
mePortfolio={me?.portfolio}
|
| 1755 |
+
/>
|
| 1756 |
+
</div>
|
| 1757 |
+
|
| 1758 |
+
|
| 1759 |
+
{isHost && (
|
| 1760 |
+
<div className="flex justify-center pt-12">
|
| 1761 |
+
<button
|
| 1762 |
+
onClick={handleRevealNext}
|
| 1763 |
+
className="bg-zinc-100 hover:bg-white text-zinc-950 font-black px-16 py-6 rounded-[2rem] shadow-2xl transition-all flex items-center gap-4 group scale-100 hover:scale-105 active:scale-95"
|
| 1764 |
+
>
|
| 1765 |
+
{(!gameState.revealSteps || gameState.revealSteps.length === 0) ? (
|
| 1766 |
+
<>REVEAL MARKET <Zap size={18} fill="currentColor" /></>
|
| 1767 |
+
) : (
|
| 1768 |
+
<>NEXT TRADING CYCLE <ArrowRight className="group-hover:translate-x-2 transition-transform" /></>
|
| 1769 |
+
)}
|
| 1770 |
+
</button>
|
| 1771 |
+
</div>
|
| 1772 |
+
)}
|
| 1773 |
+
</div>
|
| 1774 |
+
)}
|
| 1775 |
+
</div>
|
| 1776 |
+
|
| 1777 |
+
{/* Footer Leaderboard */}
|
| 1778 |
+
<div className="p-4 bg-zinc-900/40 border-t border-white/5 backdrop-blur-xl">
|
| 1779 |
+
<div className="max-w-6xl mx-auto">
|
| 1780 |
+
<div className="flex items-center gap-2 mb-4 px-1">
|
| 1781 |
+
<div className="w-1.5 h-1.5 rounded-full bg-orange-500" />
|
| 1782 |
+
<p className="text-[9px] text-zinc-500 font-black uppercase tracking-[0.3em]">Live Standing / Net Worth Valuation</p>
|
| 1783 |
+
</div>
|
| 1784 |
+
<div className="flex gap-4 overflow-x-auto pb-2 scrollbar-hide">
|
| 1785 |
+
{gameState.players
|
| 1786 |
+
.map(p => {
|
| 1787 |
+
const portfolioValue = Object.entries(p.portfolio).reduce((sum: number, [id, amt]) => {
|
| 1788 |
+
const price = gameState.stocks.find(s => s.id === id)?.price || 0;
|
| 1789 |
+
return sum + (price * (amt as number));
|
| 1790 |
+
}, 0);
|
| 1791 |
+
return { ...p, netWorth: p.cash + portfolioValue };
|
| 1792 |
+
})
|
| 1793 |
+
.sort((a, b) => b.netWorth - a.netWorth)
|
| 1794 |
+
.map((p, i) => (
|
| 1795 |
+
<motion.div
|
| 1796 |
+
layout
|
| 1797 |
+
key={p.id}
|
| 1798 |
+
className={`flex-shrink-0 px-6 py-3 rounded-2xl border flex items-center gap-4 transition-all ${
|
| 1799 |
+
i === 0
|
| 1800 |
+
? 'bg-orange-500/10 border-orange-500/30'
|
| 1801 |
+
: 'bg-white/5 border-white/5'
|
| 1802 |
+
}`}
|
| 1803 |
+
>
|
| 1804 |
+
<span className={`text-sm font-black italic font-display ${i === 0 ? 'text-orange-500' : 'text-zinc-600'}`}>#{i + 1}</span>
|
| 1805 |
+
<div>
|
| 1806 |
+
<p className="text-[10px] font-black uppercase tracking-tight leading-none mb-1">{p.name}</p>
|
| 1807 |
+
<p className="text-sm font-black font-mono text-zinc-100">₹{p.netWorth.toLocaleString()}</p>
|
| 1808 |
+
</div>
|
| 1809 |
+
</motion.div>
|
| 1810 |
+
))}
|
| 1811 |
+
</div>
|
| 1812 |
+
</div>
|
| 1813 |
+
</div>
|
| 1814 |
+
</div>
|
| 1815 |
+
);
|
| 1816 |
+
}
|
| 1817 |
+
|
| 1818 |
+
if (gameState.status === 'ended') {
|
| 1819 |
+
const leaderboard = gameState.players
|
| 1820 |
+
.map(p => {
|
| 1821 |
+
const portfolioValue = Object.entries(p.portfolio).reduce((sum: number, [id, amt]) => {
|
| 1822 |
+
const price = gameState.stocks.find(s => s.id === id)?.price || 0;
|
| 1823 |
+
return sum + (price * (amt as number));
|
| 1824 |
+
}, 0);
|
| 1825 |
+
return { ...p, netWorth: p.cash + portfolioValue };
|
| 1826 |
+
})
|
| 1827 |
+
.sort((a, b) => b.netWorth - a.netWorth);
|
| 1828 |
+
|
| 1829 |
+
return (
|
| 1830 |
+
<div className="min-h-screen bg-zinc-950 text-zinc-100 p-6 flex flex-col items-center justify-center font-sans">
|
| 1831 |
+
<motion.div
|
| 1832 |
+
initial={{ opacity: 0, scale: 0.9 }}
|
| 1833 |
+
animate={{ opacity: 1, scale: 1 }}
|
| 1834 |
+
className="w-full max-w-lg space-y-8"
|
| 1835 |
+
>
|
| 1836 |
+
<div className="text-center space-y-4">
|
| 1837 |
+
<Trophy size={80} className="mx-auto text-orange-500 drop-shadow-[0_0_20px_rgba(249,115,22,0.4)]" />
|
| 1838 |
+
<h1 className="text-6xl font-black italic uppercase tracking-tighter">Game Over</h1>
|
| 1839 |
+
<p className="text-zinc-500 font-mono tracking-[0.3em] uppercase">Final Standings</p>
|
| 1840 |
+
</div>
|
| 1841 |
+
|
| 1842 |
+
<div className="bg-zinc-900 rounded-3xl border border-zinc-800 overflow-hidden shadow-2xl">
|
| 1843 |
+
{leaderboard.map((p, i) => (
|
| 1844 |
+
<div key={p.id} className={`p-6 flex justify-between items-center ${i === 0 ? 'bg-orange-500/10 border-b border-orange-500/20' : 'border-b border-zinc-800/50'}`}>
|
| 1845 |
+
<div className="flex items-center gap-4">
|
| 1846 |
+
<span className={`text-2xl font-black italic ${i === 0 ? 'text-orange-500' : 'text-zinc-600'}`}>0{i + 1}</span>
|
| 1847 |
+
<div>
|
| 1848 |
+
<h3 className="text-xl font-black italic">{p.name}</h3>
|
| 1849 |
+
<p className="text-[10px] text-zinc-500 font-bold uppercase tracking-widest">Portfolio King</p>
|
| 1850 |
+
</div>
|
| 1851 |
+
</div>
|
| 1852 |
+
<div className="text-right">
|
| 1853 |
+
<p className="text-2xl font-black text-zinc-100">₹{p.netWorth.toLocaleString()}</p>
|
| 1854 |
+
<p className="text-[10px] text-zinc-500 font-bold uppercase tracking-widest">Net Worth</p>
|
| 1855 |
+
</div>
|
| 1856 |
+
</div>
|
| 1857 |
+
))}
|
| 1858 |
+
</div>
|
| 1859 |
+
|
| 1860 |
+
<button
|
| 1861 |
+
onClick={() => window.location.reload()}
|
| 1862 |
+
className="w-full bg-zinc-100 hover:bg-white text-zinc-950 font-black py-5 rounded-2xl transition-all uppercase tracking-widest shadow-xl"
|
| 1863 |
+
>
|
| 1864 |
+
Play Again
|
| 1865 |
+
</button>
|
| 1866 |
+
</motion.div>
|
| 1867 |
+
</div>
|
| 1868 |
+
);
|
| 1869 |
+
}
|
| 1870 |
+
|
| 1871 |
+
return null;
|
| 1872 |
+
}
|
Dockerfile
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Use Node.js alpine image for a lightweight container
|
| 2 |
+
FROM node:20-alpine
|
| 3 |
+
|
| 4 |
+
# Set the working directory inside the container
|
| 5 |
+
WORKDIR /app
|
| 6 |
+
|
| 7 |
+
# Copy package.json and package-lock.json first to leverage Docker's cache
|
| 8 |
+
COPY package*.json ./
|
| 9 |
+
|
| 10 |
+
# Install all dependencies (we need devDependencies as well since we build and use 'tsx' to run)
|
| 11 |
+
RUN npm install
|
| 12 |
+
|
| 13 |
+
# Copy the rest of the application source code
|
| 14 |
+
COPY . .
|
| 15 |
+
|
| 16 |
+
# Build the client-side Vite application (creates the /app/dist folder)
|
| 17 |
+
RUN npm run build
|
| 18 |
+
|
| 19 |
+
# Set environment to production
|
| 20 |
+
ENV NODE_ENV=production
|
| 21 |
+
|
| 22 |
+
# Hugging Face Spaces defaults to exposing port 7860.
|
| 23 |
+
# The container must listen on port 7860.
|
| 24 |
+
ENV PORT=7860
|
| 25 |
+
EXPOSE 7860
|
| 26 |
+
|
| 27 |
+
# Start the full-stack server using our node/tsx setup
|
| 28 |
+
CMD ["npm", "start"]
|
README.md
CHANGED
|
@@ -1,11 +1,20 @@
|
|
| 1 |
-
|
| 2 |
-
|
| 3 |
-
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
---
|
| 10 |
-
|
| 11 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<div align="center">
|
| 2 |
+
<img width="1200" height="475" alt="GHBanner" src="https://ai.google.dev/static/site-assets/images/share-ais-513315318.png" />
|
| 3 |
+
</div>
|
| 4 |
+
|
| 5 |
+
# Run and deploy your AI Studio app
|
| 6 |
+
|
| 7 |
+
This contains everything you need to run your app locally.
|
| 8 |
+
|
| 9 |
+
View your app in AI Studio: https://ai.studio/apps/bba69d62-d86d-441c-bcb6-da3a689f8808
|
| 10 |
+
|
| 11 |
+
## Run Locally
|
| 12 |
+
|
| 13 |
+
**Prerequisites:** Node.js
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
1. Install dependencies:
|
| 17 |
+
`npm install`
|
| 18 |
+
2. Set the `GEMINI_API_KEY` in [.env.local](.env.local) to your Gemini API key
|
| 19 |
+
3. Run the app:
|
| 20 |
+
`npm run dev`
|
favicon.svg
ADDED
|
|
index.css
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800;900&family=JetBrains+Mono:wght@400;500;700&family=Outfit:wght@400;500;600;700;800;900&display=swap');
|
| 2 |
+
@import "tailwindcss";
|
| 3 |
+
|
| 4 |
+
@theme {
|
| 5 |
+
--font-sans: "Inter", ui-sans-serif, system-ui, sans-serif;
|
| 6 |
+
--font-display: "Outfit", sans-serif;
|
| 7 |
+
--font-mono: "JetBrains Mono", ui-monospace, SFMono-Regular, monospace;
|
| 8 |
+
}
|
| 9 |
+
|
| 10 |
+
@layer base {
|
| 11 |
+
body {
|
| 12 |
+
@apply bg-zinc-950 text-zinc-100 antialiased;
|
| 13 |
+
}
|
| 14 |
+
}
|
| 15 |
+
|
| 16 |
+
.scrollbar-hide::-webkit-scrollbar {
|
| 17 |
+
display: none;
|
| 18 |
+
}
|
| 19 |
+
.scrollbar-hide {
|
| 20 |
+
-ms-overflow-style: none;
|
| 21 |
+
scrollbar-width: none;
|
| 22 |
+
}
|
index.html
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!doctype html>
|
| 2 |
+
<html lang="en">
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="UTF-8" />
|
| 5 |
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
| 6 |
+
<title>Stock Rivals</title>
|
| 7 |
+
</head>
|
| 8 |
+
<body>
|
| 9 |
+
<div id="root"></div>
|
| 10 |
+
<script type="module" src="/src/main.tsx"></script>
|
| 11 |
+
</body>
|
| 12 |
+
</html>
|
| 13 |
+
|
main.tsx
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import {StrictMode} from 'react';
|
| 2 |
+
import {createRoot} from 'react-dom/client';
|
| 3 |
+
import App from './App.tsx';
|
| 4 |
+
import './index.css';
|
| 5 |
+
|
| 6 |
+
createRoot(document.getElementById('root')!).render(
|
| 7 |
+
<StrictMode>
|
| 8 |
+
<App />
|
| 9 |
+
</StrictMode>,
|
| 10 |
+
);
|
metadata.json
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"name": "Stock Rivals",
|
| 3 |
+
"description": "A high-stakes, turn-based multiplayer stock trading game where strategy meets market volatility.",
|
| 4 |
+
"requestFramePermissions": []
|
| 5 |
+
}
|
package-lock.json
ADDED
|
The diff for this file is too large to render.
See raw diff
|
|
|
package.json
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"name": "react-example",
|
| 3 |
+
"private": true,
|
| 4 |
+
"version": "0.0.0",
|
| 5 |
+
"type": "module",
|
| 6 |
+
"scripts": {
|
| 7 |
+
"dev": "tsx server.ts",
|
| 8 |
+
"build": "vite build",
|
| 9 |
+
"start": "tsx server.ts",
|
| 10 |
+
"clean": "rm -rf dist",
|
| 11 |
+
"lint": "tsc --noEmit"
|
| 12 |
+
},
|
| 13 |
+
"dependencies": {
|
| 14 |
+
"@google/genai": "^1.29.0",
|
| 15 |
+
"@tailwindcss/vite": "^4.1.14",
|
| 16 |
+
"@vitejs/plugin-react": "^5.0.4",
|
| 17 |
+
"dotenv": "^17.2.3",
|
| 18 |
+
"express": "^4.21.2",
|
| 19 |
+
"lucide-react": "^0.546.0",
|
| 20 |
+
"motion": "^12.23.24",
|
| 21 |
+
"react": "^19.0.0",
|
| 22 |
+
"react-dom": "^19.0.0",
|
| 23 |
+
"socket.io": "^4.8.3",
|
| 24 |
+
"socket.io-client": "^4.8.3",
|
| 25 |
+
"tsx": "^4.21.0",
|
| 26 |
+
"vite": "^6.2.0"
|
| 27 |
+
},
|
| 28 |
+
"devDependencies": {
|
| 29 |
+
"@types/express": "^4.17.21",
|
| 30 |
+
"@types/node": "^22.14.0",
|
| 31 |
+
"autoprefixer": "^10.4.21",
|
| 32 |
+
"tailwindcss": "^4.1.14",
|
| 33 |
+
"typescript": "~5.8.2",
|
| 34 |
+
"vite": "^6.2.0"
|
| 35 |
+
}
|
| 36 |
+
}
|
server.ts
ADDED
|
@@ -0,0 +1,135 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import express from "express";
|
| 2 |
+
import { createServer } from "http";
|
| 3 |
+
import { Server } from "socket.io";
|
| 4 |
+
import path from "path";
|
| 5 |
+
import https from "https";
|
| 6 |
+
|
| 7 |
+
async function startServer() {
|
| 8 |
+
const app = express();
|
| 9 |
+
const httpServer = createServer(app);
|
| 10 |
+
const io = new Server(httpServer, {
|
| 11 |
+
pingTimeout: 60000, // 60 seconds to wait for pong
|
| 12 |
+
pingInterval: 25000, // ping every 25 seconds to keep alive
|
| 13 |
+
cors: {
|
| 14 |
+
origin: "*",
|
| 15 |
+
methods: ["GET", "POST"]
|
| 16 |
+
},
|
| 17 |
+
});
|
| 18 |
+
|
| 19 |
+
// Ping every 14 minutes to prevent spin-down (if URL is provided)
|
| 20 |
+
const APP_URL = process.env.APP_URL || process.env.RAILWAY_STATIC_URL;
|
| 21 |
+
if (APP_URL) {
|
| 22 |
+
setInterval(() => {
|
| 23 |
+
const url = APP_URL.startsWith('http') ? APP_URL : `https://${APP_URL}`;
|
| 24 |
+
https.get(url, (res) => {
|
| 25 |
+
console.log(`Keep-alive ping to ${url} status:`, res.statusCode);
|
| 26 |
+
}).on('error', (err) => {
|
| 27 |
+
console.error(`Keep-alive ping to ${url} error:`, err.message);
|
| 28 |
+
});
|
| 29 |
+
}, 840000);
|
| 30 |
+
}
|
| 31 |
+
|
| 32 |
+
const PORT = parseInt(process.env.PORT || "3000");
|
| 33 |
+
|
| 34 |
+
// Room state management (minimal, just to relay)
|
| 35 |
+
const rooms = new Map();
|
| 36 |
+
|
| 37 |
+
io.on("connection", (socket) => {
|
| 38 |
+
console.log("User connected:", socket.id);
|
| 39 |
+
|
| 40 |
+
socket.on("join", ({ roomId, username, maxPlayers, playerId }) => {
|
| 41 |
+
if (!rooms.has(roomId)) {
|
| 42 |
+
rooms.set(roomId, {
|
| 43 |
+
hostId: socket.id,
|
| 44 |
+
hostPlayerId: playerId,
|
| 45 |
+
players: [],
|
| 46 |
+
maxPlayers: maxPlayers || 10
|
| 47 |
+
});
|
| 48 |
+
}
|
| 49 |
+
|
| 50 |
+
const room = rooms.get(roomId);
|
| 51 |
+
const existingPlayer = room.players.find(p => p.playerId === playerId);
|
| 52 |
+
|
| 53 |
+
if (existingPlayer) {
|
| 54 |
+
// Reconnection
|
| 55 |
+
existingPlayer.id = socket.id;
|
| 56 |
+
existingPlayer.name = username || existingPlayer.name;
|
| 57 |
+
socket.join(roomId);
|
| 58 |
+
|
| 59 |
+
// If they were host, update hostId to new socket.id
|
| 60 |
+
if (room.hostPlayerId === playerId) {
|
| 61 |
+
room.hostId = socket.id;
|
| 62 |
+
}
|
| 63 |
+
} else {
|
| 64 |
+
// New join
|
| 65 |
+
if (room.players.length >= room.maxPlayers) {
|
| 66 |
+
socket.emit("error_message", "Room is full");
|
| 67 |
+
return;
|
| 68 |
+
}
|
| 69 |
+
socket.join(roomId);
|
| 70 |
+
room.players.push({ id: socket.id, playerId, name: username });
|
| 71 |
+
}
|
| 72 |
+
|
| 73 |
+
io.to(roomId).emit("lobby_update", {
|
| 74 |
+
roomId,
|
| 75 |
+
players: room.players,
|
| 76 |
+
hostId: room.hostId,
|
| 77 |
+
maxPlayers: room.maxPlayers
|
| 78 |
+
});
|
| 79 |
+
});
|
| 80 |
+
|
| 81 |
+
socket.on("start_game", ({ roomId, initialState }) => {
|
| 82 |
+
io.to(roomId).emit("start_game", initialState);
|
| 83 |
+
});
|
| 84 |
+
|
| 85 |
+
socket.on("action", ({ roomId, action }) => {
|
| 86 |
+
const room = rooms.get(roomId);
|
| 87 |
+
if (room) {
|
| 88 |
+
io.to(room.hostId).emit("action_received", { playerId: socket.id, action });
|
| 89 |
+
}
|
| 90 |
+
});
|
| 91 |
+
|
| 92 |
+
socket.on("state_update", ({ roomId, state }) => {
|
| 93 |
+
io.to(roomId).emit("state_update", state);
|
| 94 |
+
});
|
| 95 |
+
|
| 96 |
+
socket.on("disconnect", () => {
|
| 97 |
+
console.log("User disconnected:", socket.id);
|
| 98 |
+
// We don't immediately remove players to allow reconnection.
|
| 99 |
+
// We only clean up if the room becomes completely empty or after a long timeout.
|
| 100 |
+
// For this simple implementation, we'll just leave them in the room.
|
| 101 |
+
// A more robust version would mark them as 'offline'.
|
| 102 |
+
});
|
| 103 |
+
});
|
| 104 |
+
|
| 105 |
+
if (process.env.NODE_ENV !== "production") {
|
| 106 |
+
const { createServer: createViteServer } = await import("vite");
|
| 107 |
+
const vite = await createViteServer({
|
| 108 |
+
server: { middlewareMode: true },
|
| 109 |
+
appType: "spa",
|
| 110 |
+
});
|
| 111 |
+
app.use(vite.middlewares);
|
| 112 |
+
} else {
|
| 113 |
+
const distPath = path.join(process.cwd(), "dist");
|
| 114 |
+
app.use(express.static(distPath));
|
| 115 |
+
app.get("*", (req, res) => {
|
| 116 |
+
const indexPath = path.join(distPath, "index.html");
|
| 117 |
+
res.sendFile(indexPath, (err) => {
|
| 118 |
+
if (err) {
|
| 119 |
+
res.status(500).send("Build artifacts not found. Please run 'npm run build' first.");
|
| 120 |
+
}
|
| 121 |
+
});
|
| 122 |
+
});
|
| 123 |
+
}
|
| 124 |
+
|
| 125 |
+
// Health check route
|
| 126 |
+
app.get("/api/health", (req, res) => {
|
| 127 |
+
res.json({ status: "ok", mode: process.env.NODE_ENV || 'development' });
|
| 128 |
+
});
|
| 129 |
+
|
| 130 |
+
httpServer.listen(PORT, "0.0.0.0", () => {
|
| 131 |
+
console.log(`Server running on port ${PORT} in ${process.env.NODE_ENV || 'development'} mode`);
|
| 132 |
+
});
|
| 133 |
+
}
|
| 134 |
+
|
| 135 |
+
startServer();
|
tsconfig.json
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"compilerOptions": {
|
| 3 |
+
"target": "ES2022",
|
| 4 |
+
"experimentalDecorators": true,
|
| 5 |
+
"useDefineForClassFields": false,
|
| 6 |
+
"module": "ESNext",
|
| 7 |
+
"lib": [
|
| 8 |
+
"ES2022",
|
| 9 |
+
"DOM",
|
| 10 |
+
"DOM.Iterable"
|
| 11 |
+
],
|
| 12 |
+
"skipLibCheck": true,
|
| 13 |
+
"moduleResolution": "bundler",
|
| 14 |
+
"isolatedModules": true,
|
| 15 |
+
"moduleDetection": "force",
|
| 16 |
+
"allowJs": true,
|
| 17 |
+
"jsx": "react-jsx",
|
| 18 |
+
"paths": {
|
| 19 |
+
"@/*": [
|
| 20 |
+
"./*"
|
| 21 |
+
]
|
| 22 |
+
},
|
| 23 |
+
"allowImportingTsExtensions": true,
|
| 24 |
+
"noEmit": true
|
| 25 |
+
}
|
| 26 |
+
}
|
vite.config.ts
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import tailwindcss from '@tailwindcss/vite';
|
| 2 |
+
import react from '@vitejs/plugin-react';
|
| 3 |
+
import path from 'path';
|
| 4 |
+
import {defineConfig, loadEnv} from 'vite';
|
| 5 |
+
|
| 6 |
+
export default defineConfig(({mode}) => {
|
| 7 |
+
const env = loadEnv(mode, '.', '');
|
| 8 |
+
return {
|
| 9 |
+
plugins: [react(), tailwindcss()],
|
| 10 |
+
define: {
|
| 11 |
+
'process.env.GEMINI_API_KEY': JSON.stringify(env.GEMINI_API_KEY),
|
| 12 |
+
},
|
| 13 |
+
resolve: {
|
| 14 |
+
alias: {
|
| 15 |
+
'@': path.resolve(__dirname, '.'),
|
| 16 |
+
},
|
| 17 |
+
},
|
| 18 |
+
server: {
|
| 19 |
+
// HMR is disabled in AI Studio via DISABLE_HMR env var.
|
| 20 |
+
// Do not modifyâfile watching is disabled to prevent flickering during agent edits.
|
| 21 |
+
hmr: process.env.DISABLE_HMR !== 'true',
|
| 22 |
+
},
|
| 23 |
+
};
|
| 24 |
+
});
|