RonitP1 commited on
Commit
b05b936
·
verified ·
1 Parent(s): 138f932

Upload 16 files

Browse files
Files changed (16) hide show
  1. .dockerignore +9 -0
  2. .env.example +9 -0
  3. .gitignore +8 -0
  4. App.tsx +1872 -0
  5. Dockerfile +28 -0
  6. README.md +20 -11
  7. favicon.svg +34 -0
  8. index.css +22 -0
  9. index.html +13 -0
  10. main.tsx +10 -0
  11. metadata.json +5 -0
  12. package-lock.json +0 -0
  13. package.json +36 -0
  14. server.ts +135 -0
  15. tsconfig.json +26 -0
  16. 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
- title: Stock Rivals
3
- emoji: 🌖
4
- colorFrom: pink
5
- colorTo: gray
6
- sdk: docker
7
- pinned: false
8
- license: mit
9
- ---
10
-
11
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
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
+ });