import React, { useState, useEffect, useRef, useMemo } from 'react'; import { io, Socket } from 'socket.io-client'; import { motion, AnimatePresence } from 'motion/react'; import { TrendingUp, TrendingDown, Users, Wallet, ArrowRight, Play, Plus, Minus, RefreshCw, Trophy, Info, Zap, Landmark, Radio, Building2, Cpu, CreditCard, Code, Flame, Droplets, Bolt, Coins, Globe, Activity, Shield, X } from 'lucide-react'; const STOCK_ICONS: Record = { Zap, Landmark, Radio, Building2, Cpu, CreditCard, Code, Flame, Droplets, Bolt, Coins, Globe, Activity }; // --- Constants --- const INITIAL_CASH = 800000; const MIN_BUY_AMOUNT = 1000; const INITIAL_STOCK_PRICE = 100; const ROUNDS_COUNT = 5; const TURNS_PER_ROUND = 3; const MIN_STOCK_PRICE = 10; const CARD_VALUES = [-15, -10, -5, 5, 10, 15, 30]; const MARKET_CAP_PER_STOCK = 200000; type WindfallType = 'SHARE_SUSPENDED' | 'LOAN_STOCK_MATURED' | 'DEBENTURE' | 'RIGHTS_ISSUE'; const WINDFALL_DETAILS: Record = { SHARE_SUSPENDED: { name: 'Share Suspended', icon: '🔒', description: 'Revert a company price to start of turn.', label: 'Play Share Suspended' }, LOAN_STOCK_MATURED: { name: 'Loan Stock Matured', icon: '💰', description: 'Receive ₹1,00,000 cash.', label: 'Claim Loan Stock Matured (+₹1,00,000)' }, DEBENTURE: { name: 'Debenture', icon: '📜', description: 'Redeem insolvent shares at opening price.', label: 'Play Debenture — Redeem Bankrupt Shares at Opening Price' }, RIGHTS_ISSUE: { name: 'Rights Issue', icon: '📋', description: 'Buy 1 share for every 2 at ₹10.', label: 'Play Rights Issue' }, }; const STOCKS = [ { 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' }, { 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' }, { 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' }, { 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' }, { 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' }, { 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' }, { 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' }, { 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' }, ]; // --- Types --- type Stock = { id: string; name: string; price: number; history: number[]; icon: string; availableShares: number; color: string; bgColor: string; borderColor: string; cardGradient: string; isInsolvent: boolean; chairmanId?: string; }; type GameCard = { stockId?: string; value?: number; windfallType?: WindfallType; }; type Player = { id: string; playerId: string; name: string; cash: number; portfolio: Record; cards: GameCard[]; playedCards?: GameCard[]; isHost: boolean; isReady: boolean; lastAction?: string; }; type RevealStep = { stockId: string; originalCards: { playerId: string, value: number }[]; vetoedCard?: { playerId: string, value: number }; directorDiscarded?: { playerId: string, value: number }; finalChange: number; newPrice: number; recovered?: boolean; becameInsolvent?: boolean; }; type GameState = { status: 'setup' | 'lobby' | 'playing' | 'reveal' | 'ended'; players: Player[]; stocks: Stock[]; round: number; turn: number; currentPlayerIndex: number; hostId: string; roomId: string; turnActionsCount: number; maxPlayers?: number; maxRounds?: number; revealSteps?: RevealStep[]; windfallDeck: WindfallType[]; suspendedStockId?: string; pendingRightsIssue?: { initiatorId: string; stockId: string; decisions: Record; // playerId -> true/false/null }; }; // --- Game Logic Helpers --- const generateCards = (windfallDeck: WindfallType[]) => { const cards: GameCard[] = []; // Helper for weighted selection (higher index = higher probability) const getWeightedIndex = (length: number) => { const totalWeight = (length * (length + 1)) / 2; let r = Math.random() * totalWeight; for (let i = 0; i < length; i++) { const weight = i + 1; if (r < weight) return i; r -= weight; } return length - 1; }; for (let i = 0; i < 10; i++) { // 10% chance of a windfall card if deck is not empty if (Math.random() < 0.1 && windfallDeck.length > 0) { cards.push({ windfallType: windfallDeck.pop() }); } else { // 1. Weighted Stock Selection (higher index = more likely) const stockIndex = getWeightedIndex(STOCKS.length); const stock = STOCKS[stockIndex]; // 2. Dynamic Caps based on stock index // Wockhardt (index 0): min -5, max 10 // Infosys (index 7): min -15, max 30 // Linear interpolation: min = -5 - (index * 10/7), max = 10 + (index * 20/7) const minCap = -5 - (stockIndex * (10 / 7)); const maxCap = 10 + (stockIndex * (20 / 7)); // 3. Filter and Weighted Value Selection (higher value = more likely) const validValues = CARD_VALUES.filter(v => v >= minCap && v <= maxCap); const valueIndex = getWeightedIndex(validValues.length); const value = validValues[valueIndex]; cards.push({ stockId: stock.id, value }); } } return cards; }; const shuffle = (array: T[]): T[] => { const newArray = [...array]; for (let i = newArray.length - 1; i > 0; i--) { const j = Math.floor(Math.random() * (i + 1)); [newArray[i], newArray[j]] = [newArray[j], newArray[i]]; } return newArray; }; const processAction = (state: GameState, playerId: string, action: any): GameState => { const newState = JSON.parse(JSON.stringify(state)) as GameState; const player = newState.players.find(p => p.id === playerId); if (!player) return state; if (action.type === 'buy') { const stock = newState.stocks.find(s => s.id === action.stockId); if (!stock) return state; if (stock.isInsolvent) { player.lastAction = `Failed: ${stock.id} is Insolvent`; return newState; } if (player.cash >= stock.price * action.amount && action.amount >= MIN_BUY_AMOUNT && action.amount % 1000 === 0 && stock.availableShares >= action.amount) { const oldShares = player.portfolio[action.stockId] || 0; const newShares = oldShares + action.amount; player.cash -= stock.price * action.amount; player.portfolio[action.stockId] = newShares; stock.availableShares -= action.amount; player.lastAction = `Bought ${action.amount} ${stock.id}`; // Check for Chairman if (newShares >= 100000 && !stock.chairmanId) { stock.chairmanId = player.id; } } } else if (action.type === 'sell') { const stock = newState.stocks.find(s => s.id === action.stockId); if (!stock) return state; if (stock.isInsolvent) { player.lastAction = `Failed: ${stock.id} is Insolvent`; return newState; } const owned = player.portfolio[action.stockId] || 0; if (owned >= action.amount && action.amount % 1000 === 0) { player.cash += stock.price * action.amount; player.portfolio[action.stockId] = owned - action.amount; stock.availableShares += action.amount; player.lastAction = `Sold ${action.amount} ${stock.id}`; // If Chairman sells below 100k, they lose it? // Rule says "first to reach 1,00,000 gets it; in a tie, the player who reached it first keeps it." // Usually Chairman is lost if you drop below. Let's assume they lose it. if (player.id === stock.chairmanId && player.portfolio[action.stockId] < 100000) { stock.chairmanId = undefined; // Check if anyone else qualifies now? const nextChairman = newState.players .filter(p => (p.portfolio[action.stockId] || 0) >= 100000) .sort((a, b) => 0) // We don't have time history, so just pick one or leave empty [0]; if (nextChairman) stock.chairmanId = nextChairman.id; } } } else if (action.type === 'pass') { player.lastAction = 'Passed'; } else if (action.type === 'play_windfall') { const cardIndex = player.cards.findIndex(c => c.windfallType === action.cardType); if (cardIndex === -1) return state; if (action.cardType === 'LOAN_STOCK_MATURED') { player.cash += 100000; player.lastAction = 'Played Loan Stock Matured (+₹1,00,000)'; player.cards.splice(cardIndex, 1); } else if (action.cardType === 'DEBENTURE') { let totalRedeemed = 0; newState.stocks.forEach(stock => { if (stock.isInsolvent) { const shares = player.portfolio[stock.id] || 0; if (shares > 0) { const initialStock = STOCKS.find(s => s.id === stock.id); const openingPrice = initialStock?.initialPrice || 100; const amount = shares * openingPrice; player.cash += amount; player.portfolio[stock.id] = 0; stock.availableShares += shares; totalRedeemed += amount; } } }); player.lastAction = `Played Debenture (Redeemed ₹${totalRedeemed.toLocaleString()})`; player.cards.splice(cardIndex, 1); } else if (action.cardType === 'RIGHTS_ISSUE') { const stock = newState.stocks.find(s => s.id === action.stockId); if (stock) { newState.pendingRightsIssue = { initiatorId: playerId, stockId: action.stockId, decisions: {} }; newState.players.forEach(p => { if ((p.portfolio[action.stockId] || 0) > 0) { newState.pendingRightsIssue!.decisions[p.id] = null; } }); player.lastAction = `Initiated Rights Issue for ${stock.id}`; // We don't remove the card yet, we'll remove it when the rights issue is finalized // Actually, let's remove it now to prevent multiple initiations player.cards.splice(cardIndex, 1); } } else if (action.cardType === 'SHARE_SUSPENDED') { const stock = newState.stocks.find(s => s.id === action.stockId); if (stock) { newState.suspendedStockId = stock.id; const oldPrice = stock.history.length > 1 ? stock.history[stock.history.length - 2] : stock.price; stock.price = oldPrice; stock.history[stock.history.length - 1] = oldPrice; player.lastAction = `Suspended ${stock.id} price movement`; player.cards.splice(cardIndex, 1); } } return newState; } else if (action.type === 'rights_issue_decision') { if (!newState.pendingRightsIssue) return state; newState.pendingRightsIssue.decisions[playerId] = action.participate; const allDecided = Object.values(newState.pendingRightsIssue.decisions).every(d => d !== null); if (allDecided) { const stockId = newState.pendingRightsIssue.stockId; const stock = newState.stocks.find(s => s.id === stockId)!; const initiatorIndex = newState.players.findIndex(p => p.id === newState.pendingRightsIssue!.initiatorId); const playersOrder = [ ...newState.players.slice(initiatorIndex), ...newState.players.slice(0, initiatorIndex) ]; playersOrder.forEach(p => { if (newState.pendingRightsIssue!.decisions[p.id]) { const currentShares = p.portfolio[stockId] || 0; const requestedShares = Math.floor(currentShares / 2000) * 1000; // Round down (e.g. 13,000 -> 6,000) const actualShares = Math.min(requestedShares, stock.availableShares); const cost = actualShares * 10; if (p.cash >= cost && actualShares > 0) { p.cash -= cost; p.portfolio[stockId] = currentShares + actualShares; stock.availableShares -= actualShares; } } }); newState.pendingRightsIssue = undefined; } return newState; } // Move to next player newState.currentPlayerIndex = (newState.currentPlayerIndex + 1) % newState.players.length; newState.turnActionsCount += 1; // Check if turn is over if (newState.turnActionsCount >= newState.players.length) { if (newState.turn < TURNS_PER_ROUND) { // Move to next turn within the same round newState.turn += 1; newState.turnActionsCount = 0; newState.currentPlayerIndex = 0; } else { // End of round: reveal prices newState.status = 'reveal'; } } return newState; }; const calculateReveal = (state: GameState): GameState => { const newState = JSON.parse(JSON.stringify(state)) as GameState; const revealSteps: RevealStep[] = []; newState.stocks.forEach(stock => { const originalCards: { playerId: string, value: number }[] = []; newState.players.forEach(p => { const cardsToReveal = p.cards; cardsToReveal.filter(c => c.stockId === stock.id).forEach(c => { originalCards.push({ playerId: p.id, value: c.value! }); }); }); let cardsToSum = [...originalCards]; let vetoedCard: { playerId: string, value: number } | undefined; let directorDiscarded: { playerId: string, value: number } | undefined; // 1. Chairman Privilege (Priority) if (stock.chairmanId) { const negativeCards = cardsToSum.filter(c => c.value < 0).sort((a, b) => a.value - b.value); if (negativeCards.length > 0) { vetoedCard = negativeCards[0]; const index = cardsToSum.findIndex(c => c === vetoedCard); if (index !== -1) cardsToSum.splice(index, 1); } } // 2. Director Privilege const directors = newState.players.filter(p => { const shares = p.portfolio[stock.id] || 0; return shares >= 50000 && shares < 100000 && p.id !== stock.chairmanId; }); directors.forEach(director => { const directorCards = cardsToSum.filter(c => c.playerId === director.id); if (directorCards.length > 0) { const worstCard = directorCards.sort((a, b) => a.value - b.value)[0]; directorDiscarded = worstCard; const index = cardsToSum.findIndex(c => c === worstCard); if (index !== -1) cardsToSum.splice(index, 1); } }); const totalChange = cardsToSum.reduce((sum, c) => sum + c.value, 0); const oldPrice = stock.price; let newPrice = stock.price + totalChange; let recovered = false; let becameInsolvent = false; if (stock.isInsolvent) { if (totalChange > 0) { newPrice = 1; stock.isInsolvent = false; recovered = true; } else { newPrice = 0; } } else { if (newPrice <= 0) { newPrice = 0; stock.isInsolvent = true; becameInsolvent = true; } } stock.price = newPrice; stock.history.push(stock.price); revealSteps.push({ stockId: stock.id, originalCards, vetoedCard, directorDiscarded, finalChange: totalChange, newPrice, recovered, becameInsolvent }); }); newState.revealSteps = revealSteps; return newState; }; const startNextTurn = (state: GameState): GameState => { const newState = JSON.parse(JSON.stringify(state)) as GameState; // Reset for next round newState.turnActionsCount = 0; newState.currentPlayerIndex = 0; newState.suspendedStockId = undefined; // Clear suspension for next turn newState.revealSteps = undefined; // Clear previous reveal newState.players.forEach(p => { p.lastAction = undefined; p.playedCards = []; // Clear accumulated cards p.cards = generateCards(newState.windfallDeck); }); newState.turn = 1; newState.round += 1; if (newState.round > (newState.maxRounds || ROUNDS_COUNT)) { newState.status = 'ended'; } else { newState.status = 'playing'; } return newState; }; // --- Helper Components --- const TickerBackground = () => { const tickerItems = useMemo(() => { return [...STOCKS, ...STOCKS].map((stock, i) => ({ ...stock, price: 100 + Math.floor(Math.random() * 500), change: (Math.random() * 10 - 5).toFixed(2) })); }, []); return (
{[0, 1, 2].map((row) => (
{tickerItems.map((item, i) => (
{item.id} ₹{item.price} = 0 ? 'text-emerald-900' : 'text-rose-900'}`}> {parseFloat(item.change) >= 0 ? '▲' : '▼'} {Math.abs(parseFloat(item.change))}%
))}
))}
); }; const STOCK_CARD_COLORS: Record = { WOCKHARDT: 'from-pink-600 to-pink-900 border-pink-400/30', HDFC: 'from-rose-600 to-rose-900 border-rose-400/30', TATA: 'from-amber-600 to-amber-900 border-amber-400/30', ITC: 'from-emerald-600 to-emerald-900 border-emerald-400/30', ONGC: 'from-orange-600 to-orange-900 border-orange-400/30', SBI: 'from-violet-600 to-violet-900 border-violet-400/30', REL: 'from-blue-600 to-blue-900 border-blue-400/30', INFOSYS: 'from-emerald-600 to-emerald-900 border-emerald-400/30', }; const GameCardUI: React.FC<{ card: GameCard, index: number, total: number, isHovered: boolean, onHover: (index: number | null) => void, isPlayable?: boolean, onPlay?: (stockId?: string) => void, gameState?: GameState }> = ({ card, index, total, isHovered, onHover, isPlayable, onPlay, gameState }) => { const [showTargetSelector, setShowTargetSelector] = useState(false); const isWindfall = !!card.windfallType; const stock = !isWindfall ? STOCKS.find(s => s.id === card.stockId) : null; const Icon = isWindfall ? Zap : (STOCK_ICONS[stock?.icon || 'Activity'] || Activity); const windfallDetail = isWindfall ? WINDFALL_DETAILS[card.windfallType!] : null; const cardColorClass = isWindfall ? 'from-amber-500 to-amber-900 border-amber-400/30' : (stock?.cardGradient || 'from-zinc-600 to-zinc-900 border-zinc-400/30'); return (
onHover(index)} onMouseLeave={() => { onHover(null); setShowTargetSelector(false); }} > { // On mobile, first click shows info (via isHovered in CardHand) // If already hovered/showing info, we don't need to do anything special here // as the Play button will be visible in the tooltip }} 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}`} style={{ transformOrigin: 'center center', touchAction: 'none' }} > {/* Uno-style oval background */}
{isWindfall ? ( {windfallDetail?.icon} ) : ( = 0 ? 'text-emerald-600' : 'text-rose-600'}`}> {card.value! > 0 ? '+' : ''}{card.value} )}

{isWindfall ? windfallDetail?.name : stock?.id}

{/* Inner border */}
{/* Info Tooltip / Action Overlay */} {isHovered && ( e.stopPropagation()} >
{isWindfall ? ( ) : ( )}

{isWindfall ? 'Windfall Card' : 'Market Intel'}

{isWindfall ? windfallDetail?.name : `${stock?.name} Intel`}

{isWindfall ? windfallDetail?.description : `This card will shift ${stock?.name}'s price by ${card.value! > 0 ? '+' : ''}${card.value} at the end of the turn.`}

{isWindfall && isPlayable && (
{!showTargetSelector ? ( ) : (
{gameState?.stocks.map(s => ( ))}
)}
)}
{/* Tooltip Arrow */}
)}
); }; const CardHand = ({ cards, isMyTurn, gameState, onPlayWindfall, selectedStockId, status, mePortfolio }: { cards: GameCard[], isMyTurn?: boolean, gameState?: GameState, onPlayWindfall?: (type: WindfallType, stockId?: string) => void, selectedStockId?: string, status?: string, mePortfolio?: Record }) => { const [hoveredIndex, setHoveredIndex] = useState(null); const [stickyIndex, setStickyIndex] = useState(null); useEffect(() => { const handleClickOutside = () => setStickyIndex(null); window.addEventListener('click', handleClickOutside); return () => window.removeEventListener('click', handleClickOutside); }, []); if (!Array.isArray(cards)) return null; // Sort cards: stock cards by stockId, windfall cards at the end const sortedCards = [...cards].sort((a, b) => { if (a.windfallType && !b.windfallType) return 1; if (!a.windfallType && b.windfallType) return -1; if (a.windfallType && b.windfallType) return a.windfallType.localeCompare(b.windfallType); return (a.stockId || '').localeCompare(b.stockId || ''); }); return (
{sortedCards.map((card, i) => { const isPlayable = (isMyTurn && ( (card.windfallType === 'LOAN_STOCK_MATURED') || (card.windfallType === 'DEBENTURE' && gameState?.stocks.some(s => s.isInsolvent && (mePortfolio?.[s.id] || 0) > 0)) || (card.windfallType === 'RIGHTS_ISSUE' && selectedStockId) )) || (status === 'reveal' && card.windfallType === 'SHARE_SUSPENDED'); return (
{ e.stopPropagation(); if (stickyIndex === i) setStickyIndex(null); else setStickyIndex(i); }} > { if (card.windfallType) { onPlayWindfall?.(card.windfallType, targetId || selectedStockId); setStickyIndex(null); setHoveredIndex(null); } }} />
); })}
); }; // --- Main Component --- export default function App() { const [username, setUsername] = useState(''); const [roomId, setRoomId] = useState(''); const [maxPlayers, setMaxPlayers] = useState(10); const [maxRounds, setMaxRounds] = useState(5); const [socket, setSocket] = useState(null); const [gameState, setGameState] = useState(null); const gameStateRef = useRef(null); const [myId, setMyId] = useState(''); const [showPrivacy, setShowPrivacy] = useState(false); const [persistentPlayerId] = useState(() => { const saved = localStorage.getItem('stock_rivals_player_id'); if (saved) return saved; const newId = Math.random().toString(36).substring(2, 15); localStorage.setItem('stock_rivals_player_id', newId); return newId; }); const [error, setError] = useState(''); // Sync ref with state useEffect(() => { gameStateRef.current = gameState; }, [gameState]); // Local state for trading const [selectedStockId, setSelectedStockId] = useState(STOCKS[0].id); const [tradeAmount, setTradeAmount] = useState(1000); const roomRef = useRef(''); const usernameRef = useRef(''); useEffect(() => { roomRef.current = roomId; }, [roomId]); useEffect(() => { usernameRef.current = username; }, [username]); const isHost = gameState?.hostId === myId; const me = gameState?.players.find(p => p.playerId === persistentPlayerId); const isMyTurn = gameState?.status === 'playing' && gameState.players[gameState.currentPlayerIndex]?.playerId === persistentPlayerId; const totalPortfolioValue = useMemo(() => { if (!me || !gameState) return 0; return Object.entries(me.portfolio).reduce((sum: number, [id, amt]: [string, number]) => { const stock = gameState.stocks.find(s => s.id === id); return sum + (stock ? stock.price * amt : 0); }, 0); }, [me, gameState?.stocks]); // --- Socket Connection --- useEffect(() => { const newSocket = io({ reconnection: true, reconnectionAttempts: 10, reconnectionDelay: 1000, timeout: 60000, }); setSocket(newSocket); setMyId(newSocket.id || ''); newSocket.on('connect', () => { setMyId(newSocket.id || ''); // If we were in a room, re-join if (roomRef.current && usernameRef.current) { newSocket.emit('join', { roomId: roomRef.current, username: usernameRef.current, playerId: persistentPlayerId }); } }); newSocket.on('disconnect', (reason) => { console.log('Disconnected:', reason); if (reason === 'io server disconnect') { newSocket.connect(); // manually reconnect } }); newSocket.on('reconnect', (attemptNumber) => { console.log('Reconnected after', attemptNumber, 'attempts'); }); newSocket.on('lobby_update', ({ roomId: serverRoomId, players, hostId, maxPlayers: serverMaxPlayers }) => { setGameState(prev => ({ ...(prev || { status: 'lobby', players: [], stocks: [], round: 1, turn: 1, currentPlayerIndex: 0, hostId: '', roomId: serverRoomId, turnActionsCount: 0 }), roomId: serverRoomId, maxPlayers: serverMaxPlayers, players: players.map((p: any) => ({ ...p, cash: INITIAL_CASH, portfolio: {}, cards: [], playedCards: [], isHost: p.id === hostId })), hostId })); }); newSocket.on('start_game', (state) => { setGameState(state); }); newSocket.on('state_update', (state) => { setGameState(state); }); newSocket.on('error_message', (msg) => { setError(msg); setTimeout(() => setError(''), 3000); }); return () => { newSocket.disconnect(); }; }, []); // --- Host Logic: Process Actions --- useEffect(() => { if (!isHost || !socket) return; const handleAction = ({ playerId, action }: { playerId: string, action: any }) => { if (!gameStateRef.current) return; try { const nextState = processAction(gameStateRef.current, playerId, action); socket.emit('state_update', { roomId: gameStateRef.current.roomId, state: nextState }); } catch (err) { console.error("Error processing action:", err); } }; socket.on('action_received', handleAction); return () => { socket.off('action_received', handleAction); }; }, [isHost, socket]); // --- Handlers --- const handleHost = () => { if (!username) return setError('Enter username'); const id = Math.random().toString(36).substring(2, 7).toUpperCase(); setRoomId(id); socket?.emit('join', { roomId: id, username, maxPlayers, playerId: persistentPlayerId }); }; const handleJoin = () => { if (!username || !roomId) return setError('Enter username and room ID'); socket?.emit('join', { roomId, username, playerId: persistentPlayerId }); }; const handleStartGame = () => { if (!isHost || !gameState) return; const initialWindfallDeck = shuffle([ 'SHARE_SUSPENDED', 'SHARE_SUSPENDED', 'LOAN_STOCK_MATURED', 'LOAN_STOCK_MATURED', 'DEBENTURE', 'DEBENTURE', 'RIGHTS_ISSUE', 'RIGHTS_ISSUE' ] as WindfallType[]); const players = gameState.players.map(p => ({ ...p, cash: INITIAL_CASH, portfolio: {}, playedCards: [], cards: generateCards(initialWindfallDeck) })); const initialState: GameState = { ...gameState, status: 'playing', roomId, maxRounds, windfallDeck: initialWindfallDeck, stocks: STOCKS.map(s => ({ id: s.id, name: s.name, icon: s.icon, price: s.initialPrice, history: [s.initialPrice], availableShares: MARKET_CAP_PER_STOCK, color: s.color, bgColor: s.bgColor, borderColor: s.borderColor, isInsolvent: false })), players, round: 1, turn: 1, currentPlayerIndex: 0, turnActionsCount: 0 }; socket?.emit('start_game', { roomId, initialState }); }; const sendAction = (action: any) => { const isSpecialAction = action.type === 'rights_issue_decision' || (action.type === 'play_windfall' && action.cardType === 'SHARE_SUSPENDED'); if (!isMyTurn && !isSpecialAction) return; socket?.emit('action', { roomId: gameState?.roomId, action }); }; const handleRevealNext = () => { if (!isHost || !gameState) return; let nextState; if (!gameState.revealSteps || gameState.revealSteps.length === 0) { nextState = calculateReveal(gameState); } else { nextState = startNextTurn(gameState); } socket?.emit('state_update', { roomId: gameState.roomId, state: nextState }); }; // --- UI Components --- if (!gameState || gameState.status === 'setup') { return (

STOCK
RIVALS

The Ultimate Trading Floor

setUsername(e.target.value)} placeholder="CALLSIGN" 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" />
Network Join
setRoomId(e.target.value.toUpperCase())} placeholder="ROOM_ID" 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" />
{error &&

{error}

}
{showPrivacy && (

Privacy Policy

1. Data Collection

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.

2. Cookies & Local Storage

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.

3. Third-Party Services

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.

4. Data Security

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.

5. Updates

We may update this policy from time to time. Continued use of the game constitutes acceptance of the updated terms.

Last Updated: April 2026

)}
); } if (gameState.status === 'lobby') { return (

Session Ready

ID: {gameState.roomId}

{gameState.players.length}/{gameState.maxPlayers || 10}

Manifest / Active Players

{gameState.players.map((p, i) => (
{p.name[0].toUpperCase()}
{p.name} {p.id === myId &&

Local Client

}
{p.isHost && (
Host
)} ))}
{isHost ? ( ) : (

Synchronizing with Host...

)}
); } if (gameState.status === 'playing' || gameState.status === 'reveal') { const currentStock = gameState.stocks.find(s => s.id === selectedStockId)!; const myPortfolio = me?.portfolio[selectedStockId] || 0; const currentPlayer = gameState.players[gameState.currentPlayerIndex]; return (
{/* Rights Issue Participation Prompt */} {gameState.pendingRightsIssue && gameState.pendingRightsIssue.decisions[myId] === null && (

Rights Issue Opportunity

A Rights Issue has been initiated for {gameState.pendingRightsIssue.stockId}. You can buy 1 additional share for every 2 you hold at ₹10/share.

Your Current Holding

{(me?.portfolio[gameState.pendingRightsIssue.stockId] || 0).toLocaleString()} Shares

Potential: +{(Math.floor((me?.portfolio[gameState.pendingRightsIssue.stockId] || 0) / 2000) * 1000).toLocaleString()} @ ₹10

)} {/* Header */}

Round

{gameState.round}/ {ROUNDS_COUNT}

Turn

{gameState.turn}/ {TURNS_PER_ROUND}

STOCKRIVALS

Portfolio Value

₹{totalPortfolioValue.toLocaleString()}

Liquid Capital

₹{me?.cash.toLocaleString()}

{/* Recent Activity Feed */}

Live Transaction Feed

Turn {currentPlayer.name}

{gameState.players.map(p => (
{p.name[0]}

{p.name}

{p.lastAction || 'Waiting for move...'}

))}
{gameState.status === 'playing' ? (
{/* Stock List - Tabular Format */}
â–²

Market Board

{gameState.stocks.map(stock => ( ))} {gameState.stocks.map(stock => { const initialStock = STOCKS.find(s => s.id === stock.id); return ( ); })} {gameState.stocks.map(stock => { const diff = stock.history.length > 1 ? stock.price - stock.history[stock.history.length - 2] : 0; return ( ); })}
Metric
Start ₹{initialStock?.initialPrice}
Value
₹{stock.price} {diff !== 0 && ( 0 ? 'text-emerald-500' : 'text-rose-500'}`}> {diff > 0 ? '+' : ''}{diff} )}
{/* Insider Intel / Your Hand */}

Your Cards

Market Intel

Confidential
sendAction({ type: 'play_windfall', cardType: type, stockId })} selectedStockId={selectedStockId} status={gameState.status} mePortfolio={me?.portfolio} />

Hover to inspect cards • Values aggregate at reveal

{/* Trading Actions - Moved here for better mobile flow */}

Asset Focus

{currentStock.name}

Position

{myPortfolio.toLocaleString()}SHRS

Valuation

₹{currentStock.price}

Market Supply

{currentStock.availableShares.toLocaleString()}

{ const val = parseInt(e.target.value); if (!isNaN(val)) setTradeAmount(Math.max(0, val)); else if (e.target.value === '') setTradeAmount(0); }} 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" />
{currentStock.availableShares < tradeAmount && (

Market Cap Reached (Max 2,00,000 Shares)

)}
{/* Sidebar Info */}

Portfolio Value

₹{totalPortfolioValue.toLocaleString()}

Net Worth

₹{((me?.cash || 0) + totalPortfolioValue).toLocaleString()}

{/* Your Holdings Section */}

Your Holdings

{me && Object.entries(me.portfolio as Record).filter(([_, amt]) => (amt as number) > 0).length > 0 ? ( (Object.entries(me.portfolio as Record) as [string, number][]) .filter(([_, amt]) => amt > 0) .map(([stockId, amt]) => { const stock = gameState.stocks.find(s => s.id === stockId); if (!stock) return null; const Icon = STOCK_ICONS[stock.icon] || Activity; return (

{stock.name}

{amt.toLocaleString()} Shares

₹{(amt * stock.price).toLocaleString()}

₹{stock.price}/ea

); }) ) : (

No active positions

)}
) : ( /* Reveal Phase */
Market Correction Phase

THE REVEAL

Aggregating Global Insider Data

{gameState.revealSteps?.map((step, i) => { const stock = STOCKS.find(s => s.id === step.stockId)!; const Icon = STOCK_ICONS[stock.icon] || Activity; return ( {/* Card Aesthetic Elements */}

{stock.id}

{stock.name}

{step.originalCards.map((card, idx) => { const player = gameState.players.find(p => p.id === card.playerId); const isVetoed = step.vetoedCard === card; const isDiscarded = step.directorDiscarded === card; return (
{player?.name} = 0 ? 'text-emerald-400' : 'text-rose-400'}`}> {card.value > 0 ? '+' : ''}{card.value}
); })} {step.recovered && (

RECOVERED

)} {step.becameInsolvent && (

INSOLVENT

)}

Shift

= 0 ? 'text-emerald-400' : 'text-rose-400'}`}> {step.finalChange > 0 ? '+' : ''}{step.finalChange}

Price

₹{step.newPrice}

); })}
{/* Player Hand in Reveal Phase */}

Your Portfolio & Intel

Strategic Assets

sendAction({ type: 'play_windfall', cardType: type, stockId })} status={gameState.status} mePortfolio={me?.portfolio} />
{isHost && (
)}
)}
{/* Footer Leaderboard */}

Live Standing / Net Worth Valuation

{gameState.players .map(p => { const portfolioValue = Object.entries(p.portfolio).reduce((sum: number, [id, amt]) => { const price = gameState.stocks.find(s => s.id === id)?.price || 0; return sum + (price * (amt as number)); }, 0); return { ...p, netWorth: p.cash + portfolioValue }; }) .sort((a, b) => b.netWorth - a.netWorth) .map((p, i) => ( #{i + 1}

{p.name}

₹{p.netWorth.toLocaleString()}

))}
); } if (gameState.status === 'ended') { const leaderboard = gameState.players .map(p => { const portfolioValue = Object.entries(p.portfolio).reduce((sum: number, [id, amt]) => { const price = gameState.stocks.find(s => s.id === id)?.price || 0; return sum + (price * (amt as number)); }, 0); return { ...p, netWorth: p.cash + portfolioValue }; }) .sort((a, b) => b.netWorth - a.netWorth); return (

Game Over

Final Standings

{leaderboard.map((p, i) => (
0{i + 1}

{p.name}

Portfolio King

₹{p.netWorth.toLocaleString()}

Net Worth

))}
); } return null; }