diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000000000000000000000000000000000000..7d0ff1ba09d986d0c0220ed0ae8ed776bd55159d --- /dev/null +++ b/.gitattributes @@ -0,0 +1,5 @@ +full[[:space:]]gaame[[:space:]]table[[:space:]]page.png filter=lfs diff=lfs merge=lfs -text +Home.png filter=lfs diff=lfs merge=lfs -text +room[[:space:]]created.png filter=lfs diff=lfs merge=lfs -text +table[[:space:]]creating.png filter=lfs diff=lfs merge=lfs -text +table.png filter=lfs diff=lfs merge=lfs -text diff --git a/AppProvider.jsx b/AppProvider.jsx new file mode 100644 index 0000000000000000000000000000000000000000..51b1f2300c1f6ebbb6b1ddfe4c47cd2d5fc6a445 --- /dev/null +++ b/AppProvider.jsx @@ -0,0 +1,21 @@ +import type { ReactNode } from "react"; +import { Toaster } from "sonner"; + +interface Props { + children: ReactNode; +} + +/** + * A provider wrapping the whole app. + * + * You can add multiple providers here by nesting them, + * and they will all be applied to the app. + */ +export const AppProvider = ({ children }: Props) => { + return ( + <> + {children} + + + ); +}; diff --git a/CasinoTable3D.jsx b/CasinoTable3D.jsx new file mode 100644 index 0000000000000000000000000000000000000000..661e6c09fccaf05e02a4d13a5171cc0b298c1240 --- /dev/null +++ b/CasinoTable3D.jsx @@ -0,0 +1,128 @@ +import React from 'react'; + +interface Props { + children?: React.ReactNode; + tableColor?: 'green' | 'red-brown'; +} + +export const CasinoTable3D: React.FC = ({ children, tableColor = 'green' }) => { + console.log('🎨 CasinoTable3D rendering with color:', tableColor); + + // Calculate colors directly - no useMemo to ensure instant updates + const mainColor = tableColor === 'green' ? '#15803d' : '#6b2f2f'; + const gradientColor = tableColor === 'green' + ? 'linear-gradient(135deg, #15803d 0%, #16a34a 50%, #15803d 100%)' + : 'linear-gradient(135deg, #4a1f1f 0%, #6b2f2f 50%, #4a1f1f 100%)'; + + // Edge/border color (darker than main) + const edgeColor = tableColor === 'green' + ? 'linear-gradient(135deg, #14532d 0%, #15803d 50%, #14532d 100%)' + : 'linear-gradient(135deg, #4a1f1f 0%, #6b2f2f 50%, #4a1f1f 100%)'; + + return ( +
+ {/* Ambient lighting effects */} +
+
+ + {/* 3D Table Container */} +
+ {/* Main Casino Table - GREEN/RED-BROWN FELT */} +
+ {/* Felt Texture Overlay */} +
+ + {/* Table Edge (Padded Leather) */} +
+ + {/* Game Area Markings with Gold Lines - REMOVE EMPTY BOXES */} +
+ {/* Center playing field */} +
+ {/* Center Meld/Declaration Area */} +
+
+ PLAY AREA +
+
+
+ + {/* Corner decorative lines */} +
+
+
+
+
+ + {/* Content Layer - Game UI */} +
+ {children} +
+ + {/* Table Lighting - Spotlight effect */} +
+
+
+
+ ); +}; diff --git a/ChatPanel.jsx b/ChatPanel.jsx new file mode 100644 index 0000000000000000000000000000000000000000..54aebabad9cc9fa4b5f1e20768eb54c0e6a6482f --- /dev/null +++ b/ChatPanel.jsx @@ -0,0 +1,150 @@ +import React, { useState, useEffect, useRef } from 'react'; +import { apiClient } from 'app'; +import { MessageResponse } from 'types'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { ScrollArea } from '@/components/ui/scroll-area'; +import { MessageCircle, Send, X } from 'lucide-react'; + +interface Props { + tableId: string; + userId: string; + isOpen: boolean; + onToggle: () => void; +} + +export const ChatPanel: React.FC = ({ tableId, userId, isOpen, onToggle }) => { + const [messages, setMessages] = useState([]); + const [newMessage, setNewMessage] = useState(''); + const [sending, setSending] = useState(false); + const scrollRef = useRef(null); + + useEffect(() => { + loadMessages(); + const interval = setInterval(loadMessages, 2000); // Poll every 2s + return () => clearInterval(interval); + }, [tableId]); + + useEffect(() => { + if (scrollRef.current) { + scrollRef.current.scrollTop = scrollRef.current.scrollHeight; + } + }, [messages]); + + const loadMessages = async () => { + try { + const response = await apiClient.get_messages({ table_id: tableId }); + const data = await response.json(); + setMessages(data.messages || []); + } catch (error) { + console.error('Failed to load messages:', error); + } + }; + + const sendMessage = async () => { + if (!newMessage.trim() || sending) return; + + setSending(true); + try { + await apiClient.send_message({ table_id: tableId, message: newMessage.trim() }); + setNewMessage(''); + await loadMessages(); + } catch (error) { + console.error('Failed to send message:', error); + } finally { + setSending(false); + } + }; + + const handleKeyPress = (e: React.KeyboardEvent) => { + if (e.key === 'Enter' && !e.shiftKey) { + e.preventDefault(); + sendMessage(); + } + }; + + return ( + <> + {/* Toggle Button */} + {!isOpen && ( + + )} + + {/* Chat Sidebar */} + {isOpen && ( +
+ {/* Header */} +
+

+ + Table Chat +

+ +
+ + {/* Messages */} + +
+ {messages.map((msg) => ( +
+ {!msg.is_system && ( +
+ {msg.user_id === userId ? 'You' : msg.user_email?.split('@')[0] || 'Player'} + {msg.private_to && (Private)} +
+ )} +
+ {msg.message} +
+
+ {new Date(msg.created_at).toLocaleTimeString()} +
+
+ ))} +
+
+ + {/* Input */} +
+
+ setNewMessage(e.target.value)} + onKeyPress={handleKeyPress} + placeholder="Type a message..." + disabled={sending} + className="flex-1 bg-slate-900 border-slate-700 text-white" + /> + +
+
+
+ )} + + ); +}; diff --git a/ChatSidebar.jsx b/ChatSidebar.jsx new file mode 100644 index 0000000000000000000000000000000000000000..3da4113ac2b615632ec33bf2f46cee9f950bbaed --- /dev/null +++ b/ChatSidebar.jsx @@ -0,0 +1,256 @@ +import { useState, useEffect, useRef } from 'react'; +import { apiClient } from 'app'; +import { ChatMessage } from 'types'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { ScrollArea } from '@/components/ui/scroll-area'; +import { MessageCircle, X, Send, Lock, MessageSquare, ChevronRight } from 'lucide-react'; +import { toast } from 'sonner'; + +interface Props { + tableId: string; + currentUserId: string; + players: Array<{ userId: string; displayName: string }>; +} + +export default function ChatSidebar({ tableId, currentUserId, players }: Props) { + const [isOpen, setIsOpen] = useState(false); + const [messages, setMessages] = useState([]); + const [messageText, setMessageText] = useState(''); + const [recipient, setRecipient] = useState(null); + const [unreadCount, setUnreadCount] = useState(0); + const scrollRef = useRef(null); + const inputRef = useRef(null); + + // Auto-scroll to bottom when new messages arrive + useEffect(() => { + if (scrollRef.current) { + scrollRef.current.scrollTop = scrollRef.current.scrollHeight; + } + }, [messages]); + + // Poll for new messages every 2 seconds + useEffect(() => { + const fetchMessages = async () => { + if (!tableId) return; + try { + const response = await apiClient.get_messages({ table_id: tableId }); + const data = await response.json(); + + setMessages(data.messages || []); + + // Update unread count if sidebar is closed + if (!isOpen) { + const newMessages = (data.messages || []).filter((msg: ChatMessage) => + msg.user_id !== currentUserId && + new Date(msg.timestamp).getTime() > Date.now() - 4000 // Match polling interval + ); + setUnreadCount(prev => prev + newMessages.length); + } + } catch (error) { + console.error('Failed to fetch chat messages:', error); + } + }; + + fetchMessages(); + const interval = setInterval(fetchMessages, 4000); // Increased from 2000ms to 4000ms (4 seconds) + return () => clearInterval(interval); + }, [tableId, isOpen, currentUserId]); + + // Clear unread count when sidebar opens + useEffect(() => { + if (isOpen) { + setUnreadCount(0); + } + }, [isOpen]); + + const sendMessage = async () => { + if (!messageText.trim()) return; + + try { + const response = await apiClient.send_message({ + table_id: tableId, + message: messageText, + is_private: !!recipient, + recipient_id: recipient || undefined, + }); + + const newMessage = await response.json(); + setMessages([...messages, newMessage]); + setMessageText(''); + setRecipient(null); + } catch (error) { + console.error('Failed to send message:', error); + toast.error('Failed to send message'); + } + }; + + const handleKeyPress = (e: React.KeyboardEvent) => { + if (e.key === 'Enter' && !e.shiftKey) { + e.preventDefault(); + sendMessage(); + } + }; + + // Handle @ mentions + const handleInputChange = (value: string) => { + setMessageText(value); + + // Check for @ mention at the start + const mentionMatch = value.match(/^@(\w+)/); + if (mentionMatch) { + const mentionedName = mentionMatch[1].toLowerCase(); + const player = players.find(p => + p.displayName.toLowerCase().startsWith(mentionedName) + ); + if (player && player.userId !== currentUserId) { + setRecipient(player.userId); + } + } else { + setRecipient(null); + } + }; + + const getRecipientName = () => { + if (!recipient) return null; + return players.find(p => p.userId === recipient)?.displayName; + }; + + const formatTimestamp = (timestamp: string) => { + const date = new Date(timestamp); + return date.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit' }); + }; + + return ( + <> + {/* Toggle Button - Matches GameRules style */} + {!isOpen && ( + + )} + + {/* Sidebar Panel */} + {isOpen && ( +
+ {/* Header */} +
+

+ + Chat +

+ +
+ + {/* Messages */} + +
+ {messages.map((msg) => { + const isOwnMessage = msg.user_id === currentUserId; + const isPrivate = msg.is_private; + const isRecipient = msg.recipient_id === currentUserId; + const isSender = msg.user_id === currentUserId; + + return ( +
+
+ {isPrivate && } + {msg.sender_name} + {formatTimestamp(msg.created_at)} +
+
+ {isPrivate && (isSender || isRecipient) && ( +
+ {isSender + ? `To: ${players.find(p => p.userId === msg.recipient_id)?.displayName}` + : 'Private message'} +
+ )} +

{msg.message}

+
+
+ ); + })} +
+
+ + {/* Input Area */} +
+ {recipient && ( +
+ + Private message to {getRecipientName()} + +
+ )} + +
+ handleInputChange(e.target.value)} + onKeyPress={handleKeyPress} + placeholder="Type a message... (@name for private)" + className="flex-1" + /> + +
+ +

+ Tip: Type @username to send a private message +

+
+
+ )} + + ); +} diff --git a/CreateTable.jsx b/CreateTable.jsx new file mode 100644 index 0000000000000000000000000000000000000000..59fc392527bed6a39e1c29bd3ea1df84662fa40b --- /dev/null +++ b/CreateTable.jsx @@ -0,0 +1,349 @@ +import React, { useState, useEffect } from 'react'; +import { useNavigate, useSearchParams } from 'react-router-dom'; +import { Button } from '@/components/ui/button'; +import { Card } from '@/components/ui/card'; +import { Input } from '@/components/ui/input'; +import { ArrowLeft, Users, Trophy, Crown, Copy, Check } from 'lucide-react'; +import { toast } from 'sonner'; +import apiclient from '../apiclient'; +import type { CreateTableRequest } from '../apiclient/data-contracts'; +import { useUser } from '@stackframe/react'; + +interface VariantConfig { + id: string; + title: string; + wildJokerMode: 'no_joker' | 'close_joker' | 'open_joker'; + description: string; + color: string; +} + +const variantConfigs: Record = { + no_wildcard: { + id: 'no_wildcard', + title: 'No Wild Card', + wildJokerMode: 'no_joker', + description: 'Pure classic rummy with printed jokers only', + color: 'from-blue-500 to-cyan-500' + }, + open_wildcard: { + id: 'open_wildcard', + title: 'Open Wild Card', + wildJokerMode: 'open_joker', + description: 'Traditional rummy with wild card revealed at start', + color: 'from-green-500 to-emerald-500' + }, + close_wildcard: { + id: 'close_wildcard', + title: 'Close Wild Card', + wildJokerMode: 'close_joker', + description: 'Advanced variant - wild card reveals after first sequence', + color: 'from-purple-500 to-pink-500' + } +}; + +export default function CreateTable() { + const navigate = useNavigate(); + const [sp] = useSearchParams(); + const user = useUser(); + const variantId = sp.get('variant') || 'open_wildcard'; + const variant = variantConfigs[variantId] || variantConfigs.open_wildcard; + + const [playerName, setPlayerName] = useState('Player'); + const [maxPlayers, setMaxPlayers] = useState(4); + const [disqualifyScore, setDisqualifyScore] = useState(200); + const [aceValue, setAceValue] = useState<1 | 10>(10); + const [creating, setCreating] = useState(false); + const [generatedCode, setGeneratedCode] = useState(''); + const [tableId, setTableId] = useState(null); + const [copied, setCopied] = useState(false); + + useEffect(() => { + if (user?.displayName) { + setPlayerName(user.displayName); + } + }, [user]); + + const handleCreateRoom = async () => { + if (!playerName.trim()) { + toast.error('Enter your name'); + return; + } + + // STRICT VALIDATION - prevent constraint violations + if (aceValue !== 1 && aceValue !== 10) { + toast.error(`Invalid ace value: ${aceValue}. Please refresh your browser (Ctrl+Shift+R)`); + console.error('❌ INVALID ACE VALUE:', aceValue, 'Expected: 1 or 10'); + return; + } + if (!variant.wildJokerMode || variant.wildJokerMode === '') { + toast.error('Invalid game mode. Please refresh your browser (Ctrl+Shift+R)'); + console.error('❌ INVALID WILD JOKER MODE:', variant.wildJokerMode); + return; + } + + setCreating(true); + try { + const body: CreateTableRequest = { + max_players: maxPlayers, + disqualify_score: disqualifyScore, + wild_joker_mode: variant.wildJokerMode, + ace_value: aceValue, + }; + + // 🔍 DETAILED FRONTEND LOGGING - Check console! + console.log('🎯 [CREATE TABLE DEBUG]', { + variantId, + variantTitle: variant.title, + wildJokerMode: variant.wildJokerMode, + wildJokerModeType: typeof variant.wildJokerMode, + aceValue, + aceValueType: typeof aceValue, + fullBody: body, + timestamp: new Date().toISOString() + }); + + const res = await apiclient.create_table(body); + const data = await res.json(); + + setGeneratedCode(data.code); + setTableId(data.table_id); + toast.success('Room created successfully!'); + } catch (e: any) { + console.error('❌ [CREATE TABLE ERROR]', e); + const errorMsg = e?.error?.detail || e?.message || 'Failed to create room'; + toast.error(`Error: ${errorMsg}. Try refreshing (Ctrl+Shift+R)`); + } finally { + setCreating(false); + } + }; + + const handleStartGame = () => { + if (tableId) { + navigate(`/Table?tableId=${tableId}`); + } + }; + + const copyToClipboard = () => { + if (!generatedCode) return; + navigator.clipboard.writeText(generatedCode); + setCopied(true); + toast.success('Code copied to clipboard!'); + setTimeout(() => setCopied(false), 2000); + }; + + if (generatedCode && tableId) { + return ( +
+ {/* Header */} +
+
+

{variant.title}

+

{variant.description}

+
+
+ +
+ +
+
+ +
+

Room Created!

+

Share this code with your friends

+
+ + {/* Room Code Display */} +
+

Room Code

+

+ {generatedCode} +

+ +
+ + {/* Room Details */} +
+
+ Host: + {playerName} +
+
+ Game Mode: + {variant.title} +
+
+ Max Players: + {maxPlayers} +
+
+ Disqualify Score: + {disqualifyScore} pts +
+
+ Ace Value: + {aceValue} pt{aceValue === 1 ? '' : 's'} +
+
+ + {/* Actions */} +
+ + +
+
+
+
+ ); + } + + return ( +
+ {/* Header */} +
+
+ +

{variant.title}

+

{variant.description}

+
+
+ +
+ +

Create Private Room

+ +
+ {/* Player Name */} +
+ + setPlayerName(e.target.value)} + placeholder="Enter your name" + className="bg-slate-900/50 border-slate-600 text-white placeholder:text-slate-500" + /> +
+ + {/* Max Players */} +
+ + +
+ + {/* Disqualification Score */} +
+ + +

Players reaching this score will be disqualified

+
+ + {/* Ace Value */} +
+ +
+ + +
+
+ + {/* Create Button */} + +
+
+
+
+ ); +} diff --git a/GameHistory.jsx b/GameHistory.jsx new file mode 100644 index 0000000000000000000000000000000000000000..94167c28d1fda42a1742b8cd586511c449a55d7a --- /dev/null +++ b/GameHistory.jsx @@ -0,0 +1,132 @@ +import React, { useState, useEffect } from 'react'; +import { apiClient } from 'app'; +import { Button } from '@/components/ui/button'; +import { ScrollArea } from '@/components/ui/scroll-area'; +import { History, X, Trophy, AlertTriangle } from 'lucide-react'; + +interface Props { + tableId: string; +} + +export const GameHistory: React.FC = ({ tableId }) => { + const [isOpen, setIsOpen] = useState(false); + const [history, setHistory] = useState(null); + const [loading, setLoading] = useState(false); + + useEffect(() => { + if (isOpen) { + loadHistory(); + } + }, [isOpen, tableId]); + + const loadHistory = async () => { + setLoading(true); + try { + const response = await apiClient.get_round_history({ table_id: tableId }); + const data = await response.json(); + setHistory(data); + } catch (error) { + console.error('Failed to load history:', error); + } finally { + setLoading(false); + } + }; + + return ( + <> + {/* Toggle Button */} + + + {/* History Panel */} + {isOpen && ( +
+ {/* Header */} +
+

+ + Game History +

+ +
+ + {/* Content */} + + {loading ? ( +
Loading...
+ ) : history ? ( +
+ {/* Disqualified Players */} + {history.disqualified_players?.length > 0 && ( +
+

+ + Disqualified +

+ {history.disqualified_players.map((p: any) => ( +
+ {p.email?.split('@')[0]} - {p.cumulative_score} pts +
+ ))} +
+ )} + + {/* Near Disqualification */} + {history.near_disqualification?.length > 0 && ( +
+

+ + At Risk +

+ {history.near_disqualification.map((p: any) => ( +
+ {p.email?.split('@')[0]} - {p.cumulative_score} pts +
+ ))} +
+ )} + + {/* Round History */} +
+

Rounds ({history.total_rounds})

+
+ {history.rounds?.map((round: any) => ( +
+
Round {round.round}
+
+ {round.players?.map((p: any, idx: number) => ( +
+ + {idx === 0 && } + + {p.user_email?.split('@')[0]} + + + + {p.points} pts + +
+ ))} +
+
+ ))} +
+
+
+ ) : ( +
No history available
+ )} +
+
+ )} + + ); +}; diff --git a/GameRules.jsx b/GameRules.jsx new file mode 100644 index 0000000000000000000000000000000000000000..7b74a6ab4b1363776ea398d2ac85fff69924b66c --- /dev/null +++ b/GameRules.jsx @@ -0,0 +1,101 @@ +import React, { useState } from 'react'; +import { X, ChevronRight, ChevronDown } from 'lucide-react'; + +interface Props { + defaultOpen?: boolean; +} + +export const GameRules: React.FC = ({ defaultOpen = false }) => { + const [isOpen, setIsOpen] = useState(defaultOpen); + + if (!isOpen) { + return ( + + ); + } + + return ( +
+
+

+ + 13 Card Rummy Rules +

+ +
+
+
+

Objective

+

+ Arrange your 13 cards into valid sequences and sets, then declare to win the round. +

+
+ +
+

Valid Melds

+
    +
  • + + Pure Sequence: 3+ consecutive cards of same suit (no jokers). Required to declare! +
  • +
  • + + Impure Sequence: 3+ consecutive cards, jokers allowed. +
  • +
  • + + Set: 3-4 cards of same rank, different suits. +
  • +
+
+ +
+

Turn Flow

+
    +
  1. Draw 1 card (from stock or discard pile)
  2. +
  3. Arrange cards into melds if needed
  4. +
  5. Discard 1 card
  6. +
  7. If you have valid melds, click "Declare" to win
  8. +
+
+ +
+

Declaration Rules

+
    +
  • • Must have at least 1 pure sequence
  • +
  • • Must have at least 2 total sequences/sets
  • +
  • • All 13 cards must be in valid melds
  • +
+
+ +
+

Scoring

+
    +
  • • Winner scores 0 points
  • +
  • • Others score sum of ungrouped cards (max 80)
  • +
  • • A, K, Q, J = 10 points each
  • +
  • • Number cards = face value
  • +
  • • Jokers = 0 points
  • +
+
+ +
+

+ Disqualification: First player to reach the target score (default 200) is eliminated. +

+
+
+
+ ); +}; diff --git a/HandStrip.jsx b/HandStrip.jsx new file mode 100644 index 0000000000000000000000000000000000000000..a71e7f7cf85f99d9190e72b7a56d1f715690ee11 --- /dev/null +++ b/HandStrip.jsx @@ -0,0 +1,144 @@ +import React, { useState } from "react"; +import type { RoundMeResponse } from "../apiclient/data-contracts"; +import { PlayingCard } from "./PlayingCard"; + +export interface Props { + hand: RoundMeResponse["hand"]; + onCardClick?: (card: RoundMeResponse["hand"][number], index: number) => void; + selectedIndex?: number; + highlightIndex?: number; + onReorder?: (reorderedHand: RoundMeResponse["hand"]) => void; +} + +export const HandStrip: React.FC = ({ hand, onCardClick, selectedIndex, highlightIndex, onReorder }) => { + const [draggedIndex, setDraggedIndex] = useState(null); + const [dropTargetIndex, setDropTargetIndex] = useState(null); + const [touchStartIndex, setTouchStartIndex] = useState(null); + const [touchPosition, setTouchPosition] = useState<{ x: number; y: number } | null>(null); + + // Mouse/Desktop drag handlers + const handleDragStart = (e: React.DragEvent, index: number) => { + setDraggedIndex(index); + e.dataTransfer.effectAllowed = "move"; + const card = hand[index]; + e.dataTransfer.setData('card', JSON.stringify(card)); + }; + + const handleDragOver = (e: React.DragEvent, index: number) => { + e.preventDefault(); + if (draggedIndex !== null && draggedIndex !== index) { + setDropTargetIndex(index); + } + }; + + const handleDragLeave = () => { + setDropTargetIndex(null); + }; + + const handleDrop = (e: React.DragEvent, dropIndex: number) => { + e.preventDefault(); + if (draggedIndex === null || draggedIndex === dropIndex) { + setDraggedIndex(null); + setDropTargetIndex(null); + return; + } + + const newHand = [...hand]; + const [draggedCard] = newHand.splice(draggedIndex, 1); + newHand.splice(dropIndex, 0, draggedCard); + + if (onReorder) { + onReorder(newHand); + } + + setDraggedIndex(null); + setDropTargetIndex(null); + }; + + const handleDragEnd = () => { + setDraggedIndex(null); + setDropTargetIndex(null); + }; + + // Touch/Mobile handlers + const handleTouchStart = (e: React.TouchEvent, index: number) => { + const touch = e.touches[0]; + setTouchStartIndex(index); + setDraggedIndex(index); + setTouchPosition({ x: touch.clientX, y: touch.clientY }); + console.log('📱 Touch drag started for card', index); + }; + + const handleTouchMove = (e: React.TouchEvent) => { + if (touchStartIndex === null) return; + e.preventDefault(); // Prevent scrolling while dragging + const touch = e.touches[0]; + setTouchPosition({ x: touch.clientX, y: touch.clientY }); + + // Find which card is under the touch + const element = document.elementFromPoint(touch.clientX, touch.clientY); + const cardElement = element?.closest('[data-card-index]'); + if (cardElement) { + const targetIndex = Number(cardElement.getAttribute('data-card-index')); + if (targetIndex !== touchStartIndex) { + setDropTargetIndex(targetIndex); + console.log('📱 Dragging over card', targetIndex); + } + } + }; + + const handleTouchEnd = () => { + console.log('📱 Touch drag ended', { touchStartIndex, dropTargetIndex }); + if (touchStartIndex !== null && dropTargetIndex !== null && touchStartIndex !== dropTargetIndex) { + const newHand = [...hand]; + const [draggedCard] = newHand.splice(touchStartIndex, 1); + newHand.splice(dropTargetIndex, 0, draggedCard); + + if (onReorder) { + console.log('📱 Reordering hand'); + onReorder(newHand); + } + } + + setTouchStartIndex(null); + setDraggedIndex(null); + setDropTargetIndex(null); + setTouchPosition(null); + }; + + return ( +
+
+ {hand.map((card, idx) => ( +
handleDragStart(e, idx)} + onDragOver={(e) => handleDragOver(e, idx)} + onDragLeave={handleDragLeave} + onDrop={(e) => handleDrop(e, idx)} + onDragEnd={handleDragEnd} + onTouchStart={(e) => handleTouchStart(e, idx)} + onTouchMove={handleTouchMove} + onTouchEnd={handleTouchEnd} + className={`transition-all duration-200 ${ + idx === draggedIndex ? 'opacity-50 scale-95' : '' + } ${ + idx === dropTargetIndex ? 'scale-105 ring-2 ring-amber-400' : '' + }`} + > + onCardClick(card, idx) : undefined} + selected={selectedIndex === idx} + /> + {idx === highlightIndex && ( +
+ )} +
+ ))} +
+
+ ); +}; diff --git a/HistoryTable.jsx b/HistoryTable.jsx new file mode 100644 index 0000000000000000000000000000000000000000..eca43eb1bcdb4785de55f7cc0addf0ddd6d1d36a --- /dev/null +++ b/HistoryTable.jsx @@ -0,0 +1,121 @@ +import { useState, useEffect } from 'react'; +import { apiClient } from 'app'; +import { ScrollArea } from '@/components/ui/scroll-area'; +import { Trophy, Users, Crown, XCircle } from 'lucide-react'; +import { toast } from 'sonner'; + +interface HistoryEntry { + round_number: number; + winner_user_id: string | null; + winner_name: string | null; + disqualified_users: string[]; + completed_at: string; +} + +interface Props { + tableId: string; +} + +export default function HistoryTable({ tableId }: Props) { + const [history, setHistory] = useState([]); + const [loading, setLoading] = useState(true); + + useEffect(() => { + const fetchHistory = async () => { + try { + const response = await apiClient.get_round_history({ table_id: tableId }); + const data = await response.json(); + setHistory(data.rounds || []); + } catch (error) { + console.error('Failed to fetch round history:', error); + toast.error('Failed to load game history'); + } finally { + setLoading(false); + } + }; + + fetchHistory(); + }, [tableId]); + + const formatTimestamp = (timestamp: string) => { + const date = new Date(timestamp); + return date.toLocaleString('en-US', { + month: 'short', + day: 'numeric', + hour: '2-digit', + minute: '2-digit' + }); + }; + + if (loading) { + return ( +
+
+
+ ); + } + + if (history.length === 0) { + return ( +
+ +

No rounds completed yet

+
+ ); + } + + return ( + +
+ {history.map((round) => ( +
+
+
+
+ +
+ Round {round.round_number} +
+ + {formatTimestamp(round.completed_at)} + +
+ + {/* Winner */} + {round.winner_user_id && ( +
+ + + Winner: {round.winner_name || round.winner_user_id.slice(0, 8)} + +
+ )} + + {/* Disqualified Players */} + {round.disqualified_users.length > 0 && ( +
+ +
+

Disqualified:

+
+ {round.disqualified_users.map((userId) => ( + + {userId.slice(0, 8)} + + ))} +
+
+
+ )} +
+ ))} +
+
+ ); +} diff --git a/Home.jsx b/Home.jsx new file mode 100644 index 0000000000000000000000000000000000000000..deb204a559bc3ac4e89e1b5ea52749ff61eecbbd --- /dev/null +++ b/Home.jsx @@ -0,0 +1,287 @@ +import React, { useState } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { Button } from '@/components/ui/button'; +import { Card } from '@/components/ui/card'; +import { Input } from '@/components/ui/input'; +import { Sparkles, Eye, EyeOff, LogIn, User2, LogOut } from 'lucide-react'; +import { toast } from 'sonner'; +import apiclient from '../apiclient'; +import type { JoinByCodeRequest } from '../apiclient/data-contracts'; +import { useUser } from '@stackframe/react'; +import { stackClientApp } from 'app/auth'; + +interface GameVariant { + id: string; + title: string; + description: string; + features: string[]; + icon: React.ReactNode; + color: string; +} + +const gameVariants: GameVariant[] = [ + { + id: 'no_wildcard', + title: 'No Wild Card', + description: 'Pure classic rummy with printed jokers only', + features: [ + 'No wild joker cards', + 'Only printed jokers', + 'Simpler strategy', + 'Perfect for beginners' + ], + icon: , + color: 'from-blue-500 to-cyan-500' + }, + { + id: 'open_wildcard', + title: 'Open Wild Card', + description: 'Traditional rummy with wild card revealed at start', + features: [ + 'Wild card shown immediately', + 'Traditional gameplay', + 'Strategic substitutions', + 'Classic experience' + ], + icon: , + color: 'from-green-500 to-emerald-500' + }, + { + id: 'close_wildcard', + title: 'Close Wild Card', + description: 'Advanced variant - wild card reveals after first sequence', + features: [ + 'Hidden wild card initially', + 'Reveals after pure sequence', + 'Advanced strategy', + 'Maximum challenge' + ], + icon: , + color: 'from-purple-500 to-pink-500' + } +]; + +export default function App() { + const navigate = useNavigate(); + const user = useUser(); + const [roomCode, setRoomCode] = useState(''); + const [playerName, setPlayerName] = useState(''); + const [joining, setJoining] = useState(false); + + const handleSelectVariant = (variantId: string) => { + navigate(`/CreateTable?variant=${variantId}`); + }; + + const handleJoinRoom = async () => { + if (!user) { + toast.error('Please sign in to join a room'); + return; + } + if (!playerName.trim() || roomCode.trim().length !== 6) { + toast.error('Enter your name and a valid 6-letter code'); + return; + } + setJoining(true); + try { + const body: JoinByCodeRequest = { code: roomCode.trim().toUpperCase() }; + const res = await apiclient.join_table_by_code(body); + + // Check if response is ok + if (!res.ok) { + const errorData = await res.json().catch(() => ({ detail: 'Unknown error' })); + const errorMsg = errorData.detail || errorData.message || 'Failed to join room'; + toast.error(`Join failed: ${errorMsg}`); + setJoining(false); + return; + } + + const data = await res.json(); + + toast.success(`Joined table! Seat ${data.seat}`); + navigate(`/Table?tableId=${data.table_id}`); + } catch (e: any) { + console.error('Join room error:', e); + const errorMsg = e.message || e.detail || 'Failed to join room. Check the code and try again.'; + toast.error(errorMsg); + } finally { + setJoining(false); + } + }; + + const handleSignIn = () => { + stackClientApp.redirectToSignIn(); + }; + + const handleSignOut = async () => { + await stackClientApp.signOut(); + toast.success('Signed out successfully'); + }; + + return ( +
+ {/* Header */} +
+
+
+
+

Rummy Room

+

Choose your game variant

+
+ + {/* Auth Section */} +
+ {user ? ( +
+ {/* Profile Picture */} +
+ +
+ + {/* User Name */} +
+ + {user.displayName || 'Player'} + + + {user.primaryEmail} + +
+ + {/* Sign Out Button */} + +
+ ) : ( + + )} +
+
+
+
+ + {/* Game Variants Grid */} +
+
+ {gameVariants.map((variant) => ( + handleSelectVariant(variant.id)} + > + {/* Gradient Background */} +
+ +
+ {/* Icon */} +
+ {variant.icon} +
+ + {/* Title */} +

{variant.title}

+ + {/* Description */} +

{variant.description}

+ + {/* Features */} +
    + {variant.features.map((feature, idx) => ( +
  • +
    + {feature} +
  • + ))} +
+ + {/* Button */} + +
+ + ))} +
+ + {/* Join Table Section */} +
+
+

Join a Friend's Game

+

Enter the 6-letter room code shared by your friend

+
+ + +
+
+ +
+
+

Join Room

+

Enter details to join the table

+
+
+ +
+
+ + setPlayerName(e.target.value)} + placeholder="Enter your name" + className="bg-slate-900/50 border-slate-600 text-white placeholder:text-slate-500" + /> +
+ +
+ + setRoomCode(e.target.value.toUpperCase())} + placeholder="Enter 6-letter code" + maxLength={6} + className="bg-slate-900/50 border-slate-600 text-white placeholder:text-slate-500 uppercase tracking-wider text-lg font-mono text-center" + /> +
+ + + + {!user && ( +

Please sign in to join a room

+ )} +
+
+
+
+
+ ); +} diff --git a/Home.png b/Home.png new file mode 100644 index 0000000000000000000000000000000000000000..7f8fc16d964e7bcac89a2e1f4efd172642ccc3bb --- /dev/null +++ b/Home.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:0066c3f61bf1e9e6b47e7ab8190a0b4108fd313a5205de3b7da7e333832c7cc3 +size 319430 diff --git a/PlayerProfile.jsx b/PlayerProfile.jsx new file mode 100644 index 0000000000000000000000000000000000000000..dab23796bf91750196282e5e0b6b3e091d28f447 --- /dev/null +++ b/PlayerProfile.jsx @@ -0,0 +1,60 @@ +import React from 'react'; +import { User } from 'lucide-react'; + +interface Props { + position: string; + name: string; + profilePic?: string | null; + isActive?: boolean; + isCurrentUser?: boolean; +} + +export const PlayerProfile: React.FC = ({ position, name, profilePic, isActive = false, isCurrentUser = false }) => { + return ( +
+ {/* Avatar with Profile Picture */} +
+ {profilePic ? ( + {name} + ) : ( + + )} +
+ + {/* Name */} +
+

+ {name} +

+

+ {position} +

+
+ + {/* Active Turn Indicator */} + {isActive && ( +
+ )} +
+ ); +}; diff --git a/PlayingCard.jsx b/PlayingCard.jsx new file mode 100644 index 0000000000000000000000000000000000000000..4f5ffee2cb456a67c6a46812be23637d57788000 --- /dev/null +++ b/PlayingCard.jsx @@ -0,0 +1,67 @@ +import React from "react"; +import type { CardView } from "../apiclient/data-contracts"; + +export interface Props { + card: CardView; + onClick?: () => void; + selected?: boolean; +} + +const suitSymbols: Record = { + H: "♥", + D: "♦", + S: "♠", + C: "♣", +}; + +const suitColors: Record = { + H: "text-red-500", + D: "text-red-500", + S: "text-gray-900", + C: "text-gray-900", +}; + +export const PlayingCard: React.FC = ({ card, onClick, selected }) => { + const isJoker = card.joker || card.rank === "JOKER"; + const suit = card.suit || ""; + const suitSymbol = suitSymbols[suit] || ""; + const suitColor = suitColors[suit] || "text-gray-900 dark:text-gray-100"; + + return ( +
+
+ {isJoker ? ( +
+
+
JOKER
+
+ ) : ( + <> +
+ {card.rank} +
+
+ {suitSymbol} +
+
+ {card.rank} +
+
+ {card.rank} +
+ + )} +
+
+ ); +}; diff --git a/PointsTable.jsx b/PointsTable.jsx new file mode 100644 index 0000000000000000000000000000000000000000..afc3255772e01cf533a9ba323dca6c1e432324ec --- /dev/null +++ b/PointsTable.jsx @@ -0,0 +1,115 @@ +import React, { useState } from 'react'; +import { ChevronDown, ChevronUp, Trophy } from 'lucide-react'; +import type { TableInfoResponse } from '../apiclient/data-contracts'; + +export interface Props { + info: TableInfoResponse; + roundHistory: Array<{ + round_number: number; + winner_user_id: string | null; + scores: Record; + }>; +} + +export const PointsTable: React.FC = ({ info, roundHistory }) => { + const [isOpen, setIsOpen] = useState(true); + + // Calculate cumulative scores + const cumulativeScores: Record = {}; + info.players.forEach(player => { + cumulativeScores[player.user_id] = []; + }); + + let runningTotals: Record = {}; + info.players.forEach(player => { + runningTotals[player.user_id] = 0; + }); + + roundHistory.forEach(round => { + info.players.forEach(player => { + const roundScore = round.scores[player.user_id] || 0; + runningTotals[player.user_id] += roundScore; + cumulativeScores[player.user_id].push(runningTotals[player.user_id]); + }); + }); + + if (roundHistory.length === 0) { + return null; + } + + return ( +
+ {/* Header */} + + + {/* Content */} + {isOpen && ( +
+
+ + + + + {roundHistory.map((round, idx) => ( + + ))} + + + + + {info.players.map(player => { + const totalScore = runningTotals[player.user_id] || 0; + return ( + + + {roundHistory.map((round, idx) => { + const isWinner = round.winner_user_id === player.user_id; + const roundScore = round.scores[player.user_id] || 0; + return ( + + ); + })} + + + ); + })} + +
Player + R{round.round_number} + + Total +
+
+ {player.display_name || 'Player'} +
+
+
+ + {roundScore} + + {isWinner && } +
+
+ + {totalScore} + +
+
+
+ )} +
+ ); +}; diff --git a/ProfileCard.jsx b/ProfileCard.jsx new file mode 100644 index 0000000000000000000000000000000000000000..8c3523610262bf4a4662f314b0e82ff6f92d4b86 --- /dev/null +++ b/ProfileCard.jsx @@ -0,0 +1,73 @@ +import React, { useEffect, useState } from "react"; +import apiclient from "../apiclient"; +import type { GetMyProfileData } from "../apiclient/data-contracts"; +import { useUser } from "@stackframe/react"; +import { useNavigate } from "react-router-dom"; +import { toast } from "sonner"; + +export interface Props {} + +export const ProfileCard: React.FC = () => { + const user = useUser(); + const navigate = useNavigate(); + const [loading, setLoading] = useState(false); + const [profile, setProfile] = useState(null); + const [error, setError] = useState(null); + + useEffect(() => { + const load = async () => { + if (!user) return; // not signed in yet + setLoading(true); + setError(null); + try { + const res = await apiclient.get_my_profile(); + const data = await res.json(); + setProfile(data); + } catch (e: any) { + setError("Failed to load profile"); + } finally { + setLoading(false); + } + }; + load(); + }, [user?.id]); + + if (!user) { + return ( +
+
+

Sign in to manage your profile.

+ +
+
+ ); + } + + return ( +
+
+

Your Profile

+ {loading && Loading…} +
+ {error &&

{error}

} +
+
+ + +
+ {profile && ( +

User ID: {profile.user_id}

+ )} +
+
+ ); +}; diff --git a/ScoreboardModal.jsx b/ScoreboardModal.jsx new file mode 100644 index 0000000000000000000000000000000000000000..89d242ffc4b4eeefe6db84c9986d112f8a93a8f8 --- /dev/null +++ b/ScoreboardModal.jsx @@ -0,0 +1,248 @@ +import React, { useState } from 'react'; +import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog"; +import { Button } from "@/components/ui/button"; +import { Trophy, Crown } from "lucide-react"; +import { PlayingCard } from "./PlayingCard"; +import type { RevealedHandsResponse } from "../apiclient/data-contracts"; +import { toast } from 'sonner'; +import apiclient from '../apiclient'; + +export interface Props { + isOpen: boolean; + onClose: () => void; + data: RevealedHandsResponse | null; + players: Array<{ user_id: string; display_name?: string | null }>; + currentUserId: string; + tableId: string; + hostUserId: string; + onNextRound?: () => void; +} + +export const ScoreboardModal: React.FC = ({ isOpen, onClose, data, players, currentUserId, tableId, hostUserId, onNextRound }) => { + const [startingNextRound, setStartingNextRound] = useState(false); + + if (!data) return null; + + // Sort players by score (lowest first, as lower is better) + const sortedPlayers = players + .filter(p => data.scores[p.user_id] !== undefined) + .map(p => ({ + ...p, + score: data.scores[p.user_id], + cards: data.revealed_hands[p.user_id] || [], + organized: data.organized_melds?.[p.user_id] || null, + isWinner: p.user_id === data.winner_user_id + })) + .sort((a, b) => a.score - b.score); + + const winnerName = sortedPlayers.find(p => p.isWinner)?.display_name || "Winner"; + const isHost = currentUserId === hostUserId; + + const handleStartNextRound = async () => { + setStartingNextRound(true); + try { + await apiclient.start_next_round({ table_id: tableId }); + toast.success('Starting next round!'); + onClose(); + if (onNextRound) onNextRound(); + } catch (error: any) { + const errorMessage = error?.error?.detail || error?.message || 'Failed to start next round'; + toast.error(errorMessage); + } finally { + setStartingNextRound(false); + } + }; + + return ( + + + + + + Round {data.round_number} Complete! + + + +
+ {/* Winner announcement */} +
+
+ + {winnerName} wins with {sortedPlayers[0]?.score || 0} points! +
+
+ + {/* Players list */} +
+ {sortedPlayers.map((player, idx) => ( +
+
+
+ {player.isWinner && } + + {idx + 1}. {player.display_name || `Player ${player.user_id.slice(0, 8)}`} + + {player.user_id === currentUserId && ( + You + )} +
+
+ {player.score} pts +
+
+ + {/* Organized melds display */} + {player.organized ? ( +
+
+ Hand Organization (4-3-3-3 Format): +
+ + {/* Pure Sequences - HIGHEST PRIORITY */} + {player.organized.pure_sequences?.length > 0 && ( +
+
+ ✓ PURE SEQUENCE + (No Jokers) +
+
+ {player.organized.pure_sequences.map((meld: any[], meldIdx: number) => ( +
+
+ Meld {meldIdx + 1} ({meld.length} cards) +
+
+ {meld.map((card: any, cardIdx: number) => ( +
+ +
+ ))} +
+
+ ))} +
+
+ )} + + {/* Impure Sequences - MEDIUM PRIORITY */} + {player.organized.impure_sequences?.length > 0 && ( +
+
+ ✓ IMPURE SEQUENCE + (With Jokers) +
+
+ {player.organized.impure_sequences.map((meld: any[], meldIdx: number) => ( +
+
+ Meld {meldIdx + 1} ({meld.length} cards) +
+
+ {meld.map((card: any, cardIdx: number) => ( +
+ +
+ ))} +
+
+ ))} +
+
+ )} + + {/* Sets - LOWER PRIORITY */} + {player.organized.sets?.length > 0 && ( +
+
+ ✓ SET + (Same Rank, Different Suits) +
+
+ {player.organized.sets.map((meld: any[], meldIdx: number) => ( +
+
+ Meld {meldIdx + 1} ({meld.length} cards) +
+
+ {meld.map((card: any, cardIdx: number) => ( +
+ +
+ ))} +
+
+ ))} +
+
+ )} + + {/* Ungrouped cards (deadwood) - PENALTY */} + {player.organized.ungrouped?.length > 0 && ( +
+
+ ✗ DEADWOOD + (Ungrouped - Counts as Points) +
+
+
+ {player.organized.ungrouped.length} ungrouped card(s) +
+
+ {player.organized.ungrouped.map((card: any, cardIdx: number) => ( +
+ +
+ ))} +
+
+
+ )} + + {/* Summary */} +
+ Total: {(player.organized.pure_sequences?.length || 0) + + (player.organized.impure_sequences?.length || 0) + + (player.organized.sets?.length || 0)} valid melds, + {player.organized.ungrouped?.length || 0} deadwood cards +
+
+ ) : ( + // Fallback: show all cards if no organization +
+ {player.cards.map((card: any, idx: number) => ( +
+ +
+ ))} +
+ )} +
+ ))} +
+ +
+ {isHost && ( + + )} + +
+
+
+
+ ); +}; diff --git a/SpectateControls.jsx b/SpectateControls.jsx new file mode 100644 index 0000000000000000000000000000000000000000..a91fe3cef6771ee9ca4fd02576af63366f82b806 --- /dev/null +++ b/SpectateControls.jsx @@ -0,0 +1,122 @@ +import { useState } from 'react'; +import { Button } from '@/components/ui/button'; +import { Eye, Check, X } from 'lucide-react'; +import { toast } from 'sonner'; +import { apiClient } from 'app'; + +interface Props { + tableId: string; + currentUserId: string; + isEliminated: boolean; + spectateRequests: string[]; + isHost: boolean; + players: Array<{ user_id: string; display_name?: string | null }>; +} + +export default function SpectateControls({ + tableId, + currentUserId, + isEliminated, + spectateRequests, + isHost, + players +}: Props) { + const [requesting, setRequesting] = useState(false); + const [granting, setGranting] = useState(null); + + const handleRequestSpectate = async () => { + setRequesting(true); + try { + await apiClient.request_spectate({ table_id: tableId }); + toast.success('Spectate request sent to host'); + } catch (error) { + toast.error('Failed to request spectate access'); + } finally { + setRequesting(false); + } + }; + + const handleGrantSpectate = async (userId: string, granted: boolean) => { + setGranting(userId); + try { + await apiClient.grant_spectate({ + table_id: tableId, + user_id: userId, + granted + }); + toast.success(granted ? 'Spectate access granted' : 'Spectate access denied'); + } catch (error) { + toast.error('Failed to process spectate request'); + } finally { + setGranting(null); + } + }; + + const getUserName = (userId: string) => { + const player = players.find(p => p.user_id === userId); + return player?.display_name || userId.slice(0, 8); + }; + + return ( +
+ {/* Eliminated Player - Request to Spectate */} + {isEliminated && ( +
+
+ +

You've been eliminated

+
+

+ Request permission from the host to spectate the remaining players +

+ +
+ )} + + {/* Host - Pending Spectate Requests */} + {isHost && spectateRequests.length > 0 && ( +
+
+ +

Spectate Requests

+ + {spectateRequests.length} + +
+
+ {spectateRequests.map((userId) => ( +
+ {getUserName(userId)} +
+ + +
+
+ ))} +
+
+ )} +
+ ); +} diff --git a/Table.jsx b/Table.jsx new file mode 100644 index 0000000000000000000000000000000000000000..7d1183d93a2c8a307dec29a14574b53c96b0778b --- /dev/null +++ b/Table.jsx @@ -0,0 +1,2081 @@ + + + +import React, { useEffect, useMemo, useState } from "react"; +import { useSearchParams, useNavigate } from "react-router-dom"; +import apiclient from "../apiclient"; +import type { GetTableInfoParams, TableInfoResponse, StartGameRequest, GetRoundMeParams, RoundMeResponse, DrawRequest, DiscardRequest, DiscardCard, DeclareRequest, ScoreboardResponse, RoundScoreboardParams, GetRevealedHandsParams, RevealedHandsResponse, LockSequenceRequest, GrantSpectateRequest } from "../apiclient/data-contracts"; +import { Copy, Check, Crown, User2, Play, ArrowDown, Trash2, Trophy, X, ChevronDown, ChevronUp, LogOut, Mic, MicOff, UserX, Eye } from "lucide-react"; +import { toast } from "sonner"; +import { HandStrip } from "components/HandStrip"; +import { useUser } from "@stackframe/react"; +import { TableDiagram } from "components/TableDiagram"; +import { GameRules } from 'components/GameRules'; +import { CasinoTable3D } from 'components/CasinoTable3D'; +import { PlayerProfile } from 'components/PlayerProfile'; +import { PlayingCard } from "components/PlayingCard"; +import { Button } from "@/components/ui/button"; +import { ScoreboardModal } from "components/ScoreboardModal"; +import { WildJokerRevealModal } from "components/WildJokerRevealModal"; +import { PointsTable } from "components/PointsTable"; +import { parseCardCode } from "utils/cardCodeUtils"; +import ChatSidebar from "components/ChatSidebar"; +import VoicePanel from 'components/VoicePanel'; +import SpectateControls from 'components/SpectateControls'; +import HistoryTable from 'components/HistoryTable'; + +// CardBack component with red checkered pattern +const CardBack = ({ className = "" }: { className?: string }) => ( +
+
+
+
+
+); + +// Helper component for a 3-card meld slot box +interface MeldSlotBoxProps { + title: string; + slots: (RoundMeResponse["hand"][number] | null)[]; + setSlots: (slots: (RoundMeResponse["hand"][number] | null)[]) => void; + myRound: RoundMeResponse | null; + setMyRound: (round: RoundMeResponse) => void; + isLocked?: boolean; + onToggleLock?: () => void; + tableId: string; + onRefresh: () => void; + hideLockButton?: boolean; + gameMode?: string; // Add game mode prop +} + +const MeldSlotBox = ({ title, slots, setSlots, myRound, setMyRound, isLocked = false, onToggleLock, tableId, onRefresh, hideLockButton, gameMode }: MeldSlotBoxProps) => { + const [locking, setLocking] = useState(false); + const [showRevealModal, setShowRevealModal] = useState(false); + const [revealedRank, setRevealedRank] = useState(null); + + const handleSlotDrop = (slotIndex: number, cardData: string) => { + if (!myRound || isLocked) { + if (isLocked) toast.error('Unlock meld first to modify'); + return; + } + + const card = JSON.parse(cardData); + + // Check if slot is already occupied + if (slots[slotIndex] !== null) { + toast.error('Slot already occupied'); + return; + } + + // Place card in slot + const newSlots = [...slots]; + newSlots[slotIndex] = card; + setSlots(newSlots); + toast.success(`Card placed in ${title} slot ${slotIndex + 1}`); + }; + + const handleSlotClick = (slotIndex: number) => { + if (!myRound || slots[slotIndex] === null || isLocked) { + if (isLocked) toast.error('Unlock meld first to modify'); + return; + } + + // Return card to hand + const card = slots[slotIndex]!; + const newSlots = [...slots]; + newSlots[slotIndex] = null; + setSlots(newSlots); + toast.success('Card returned to hand'); + }; + + const handleLockSequence = async () => { + console.log('🔒 Lock button clicked!'); + console.log('Slots:', slots); + console.log('Table ID:', tableId); + + // Validate that all 3 slots are filled + const cards = slots.filter(s => s !== null); + console.log('Filled cards:', cards); + + if (cards.length !== 3) { + console.log('❌ Not enough cards:', cards.length); + toast.error('Fill all 3 slots to lock a sequence'); + return; + } + + console.log('✅ Starting lock sequence API call...'); + setLocking(true); + try { + // Safely map cards with explicit null-checking + const meldCards = cards.map(card => { + if (!card) { + throw new Error('Null card found in meld'); + } + return { + rank: card.rank, + suit: card.suit || null + }; + }); + + const body: LockSequenceRequest = { + table_id: tableId, + meld: meldCards + }; + console.log('Request body:', body); + + console.log('📡 Calling apiclient.lock_sequence...'); + const res = await apiclient.lock_sequence(body); + console.log('✅ API response received:', res); + console.log('Response status:', res.status); + console.log('Response ok:', res.ok); + + const data = await res.json(); + console.log('📦 Response data:', data); + + if (data.success) { + toast.success(data.message); + if (onToggleLock) onToggleLock(); // Lock the meld in UI + + // Show flip animation popup if wild joker was just revealed + if (data.wild_joker_revealed && data.wild_joker_rank) { + setRevealedRank(data.wild_joker_rank); + setShowRevealModal(true); + setTimeout(() => fetchRoundMe(), 500); // Refresh to show revealed wild joker + } + } else { + toast.error(data.message); + } + } catch (err: any) { + console.log('❌ Lock sequence error:'); + console.log(err?.status, err?.statusText, '-', err?.error?.detail || 'An unexpected error occurred.'); + console.log('Error type:', typeof err); + console.log('Error name:', err?.name); + console.log('Error message:', err?.message); + console.log('Error stack:', err?.stack); + toast.error(err?.error?.detail || err?.message || 'Failed to lock sequence'); + } finally { + setLocking(false); + console.log('🏁 Lock sequence attempt completed'); + } + }; + + return ( + <> +
+
+

{title} (3 cards)

+
+ {/* Only show lock button if game mode uses wild jokers */} + {!isLocked && gameMode !== 'no_joker' && ( + + )} + {onToggleLock && ( + + )} +
+
+
+ {slots.map((card, i) => ( +
{ e.preventDefault(); e.currentTarget.classList.add('ring-2', 'ring-purple-400'); }} + onDragLeave={(e) => { e.currentTarget.classList.remove('ring-2', 'ring-purple-400'); }} + onDrop={(e) => { + e.preventDefault(); + e.currentTarget.classList.remove('ring-2', 'ring-purple-400'); + const cardData = e.dataTransfer.getData('card'); + if (cardData) handleSlotDrop(i, cardData); + }} + onClick={() => handleSlotClick(i)} + className="w-[60px] h-[84px] border-2 border-dashed border-muted-foreground/20 rounded bg-background/50 flex items-center justify-center cursor-pointer hover:border-purple-400/50 transition-all" + > + {card ? ( +
+ {}} /> +
+ ) : ( + {i + 1} + )} +
+ ))} +
+
+ + {/* Wild Joker Reveal Modal */} + {revealedRank && ( + setShowRevealModal(false)} + wildJokerRank={revealedRank} + /> + )} + + ); +}; + +// Helper component for 4-card leftover slot box +interface LeftoverSlotBoxProps { + slots: (RoundMeResponse["hand"][number] | null)[]; + setSlots: (slots: (RoundMeResponse["hand"][number] | null)[]) => void; + myRound: RoundMeResponse | null; + setMyRound: (round: RoundMeResponse) => void; + isLocked?: boolean; + onToggleLock?: () => void; + tableId: string; + onRefresh: () => void; + gameMode?: string; // Add game mode prop +} + +const LeftoverSlotBox = ({ slots, setSlots, myRound, setMyRound, isLocked = false, onToggleLock, tableId, onRefresh, gameMode }: LeftoverSlotBoxProps) => { + const [locking, setLocking] = useState(false); + const [showRevealModal, setShowRevealModal] = useState(false); + const [revealedRank, setRevealedRank] = useState(null); + + const handleSlotDrop = (slotIndex: number, cardData: string) => { + if (!myRound || isLocked) return; + + const card = JSON.parse(cardData); + + // Check if slot is already occupied + if (slots[slotIndex] !== null) { + toast.error('Slot already occupied'); + return; + } + + // Place card in slot + const newSlots = [...slots]; + newSlots[slotIndex] = card; + setSlots(newSlots); + toast.success(`Card placed in leftover slot ${slotIndex + 1}`); + }; + + const handleSlotClick = (slotIndex: number) => { + if (!myRound || slots[slotIndex] === null) return; + + // Return card to hand + const card = slots[slotIndex]!; + const newSlots = [...slots]; + newSlots[slotIndex] = null; + setSlots(newSlots); + toast.success('Card returned to hand'); + }; + + const handleLockSequence = async () => { + console.log('🔒 Lock button clicked (4-card)!'); + console.log('Slots:', slots); + console.log('Table ID:', tableId); + + // Validate that all 4 slots are filled + const cards = slots.filter(s => s !== null); + console.log('Filled cards:', cards); + + if (cards.length !== 4) { + console.log('❌ Not enough cards:', cards.length); + toast.error('Fill all 4 slots to lock a sequence'); + return; + } + + console.log('✅ Starting lock sequence API call (4-card)...'); + setLocking(true); + try { + // Safely map cards with explicit null-checking + const meldCards = cards.map(card => { + if (!card) { + throw new Error('Null card found in meld'); + } + return { + rank: card.rank, + suit: card.suit || null + }; + }); + + const body: LockSequenceRequest = { + table_id: tableId, + meld: meldCards + }; + console.log('Request body:', body); + + console.log('📡 Calling apiclient.lock_sequence...'); + const res = await apiclient.lock_sequence(body); + console.log('✅ API response received:', res); + console.log('Response status:', res.status); + console.log('Response ok:', res.ok); + + const data = await res.json(); + console.log('📦 Response data:', data); + + if (data.success) { + toast.success(data.message); + if (onToggleLock) onToggleLock(); // Lock the meld in UI + + // Show flip animation popup if wild joker was just revealed + if (data.wild_joker_revealed && data.wild_joker_rank) { + setRevealedRank(data.wild_joker_rank); + setShowRevealModal(true); + } + + onRefresh(); // Refresh to get updated wild joker status + } else { + toast.error(data.message); + } + } catch (err: any) { + console.log('❌ Lock sequence error (4-card):'); + console.log(err?.status, err?.statusText, '-', err?.error?.detail || 'An unexpected error occurred.'); + console.log('Error type:', typeof err); + console.log('Error name:', err?.name); + console.log('Error message:', err?.message); + console.log('Error stack:', err?.stack); + console.log('Full error object:', err); + toast.error(err?.error?.detail || err?.message || 'Failed to lock sequence'); + } finally { + setLocking(false); + console.log('🏁 Lock sequence attempt completed (4-card)'); + } + }; + + return ( + <> +
+
+

Leftover / 4-Card Seq

+
+ {/* Only show lock button if game mode uses wild jokers */} + {!isLocked && gameMode !== 'no_joker' && ( + + )} + {onToggleLock && ( + + )} +
+
+
+ {slots.map((card, i) => ( +
{ e.preventDefault(); e.currentTarget.classList.add('ring-2', 'ring-blue-400'); }} + onDragLeave={(e) => { e.currentTarget.classList.remove('ring-2', 'ring-blue-400'); }} + onDrop={(e) => { + e.preventDefault(); + e.currentTarget.classList.remove('ring-2', 'ring-blue-400'); + const cardData = e.dataTransfer.getData('card'); + if (cardData) handleSlotDrop(i, cardData); + }} + onClick={() => handleSlotClick(i)} + className="w-[60px] h-[84px] border-2 border-dashed border-muted-foreground/20 rounded bg-background/50 flex items-center justify-center cursor-pointer hover:border-blue-400/50 transition-all" + > + {card ? ( +
+ {}} /> +
+ ) : ( + {i + 1} + )} +
+ ))} +
+
+ + {/* Wild Joker Reveal Modal */} + {revealedRank && ( + setShowRevealModal(false)} + wildJokerRank={revealedRank} + /> + )} + + ); +}; + +export default function Table() { + const navigate = useNavigate(); + const [sp] = useSearchParams(); + const user = useUser(); + const tableId = sp.get("tableId"); + + // State + const [loading, setLoading] = useState(true); + const [info, setInfo] = useState(null); + const [myRound, setMyRound] = useState(null); + const [copied, setCopied] = useState(false); + const [acting, setActing] = useState(false); + const [starting, setStarting] = useState(false); + const [scoreboard, setScoreboard] = useState(null); + const [showScoreboard, setShowScoreboard] = useState(false); + const [showWildJokerReveal, setShowWildJokerReveal] = useState(false); + const [revealedWildJoker, setRevealedWildJoker] = useState(null); + const [roundHistory, setRoundHistory] = useState([]); + const [tableColor, setTableColor] = useState<'green' | 'red-brown'>('green'); + const [voiceMuted, setVoiceMuted] = useState(false); + const [droppingGame, setDroppingGame] = useState(false); + const [spectateRequested, setSpectateRequested] = useState(false); + const [spectateRequests, setSpectateRequests] = useState([]); + const [showScoreboardModal, setShowScoreboardModal] = useState(false); + const [showScoreboardPanel, setShowScoreboardPanel] = useState(false); + const [revealedHands, setRevealedHands] = useState(null); + + // DEBUG: Monitor tableId changes and URL + useEffect(() => { + console.log('🔍 Table Component - tableId from URL:', tableId); + console.log('🔍 Full URL search params:', sp.toString()); + console.log('🔍 Current full URL:', window.location.href); + if (!tableId) { + console.error('❌ CRITICAL: tableId is missing from URL!'); + console.error('This could be caused by:'); + console.error(' 1. Browser navigation/refresh losing URL params'); + console.error(' 2. React Router navigation without tableId'); + console.error(' 3. Component remounting unexpectedly'); + } + }, [tableId, sp]); + + const [selectedCard, setSelectedCard] = useState<{ rank: string; suit: string | null; joker: boolean } | null>(null); + const [lastDrawnCard, setLastDrawnCard] = useState<{ rank: string; suit: string | null } | null>(null); + const [hasDrawn, setHasDrawn] = useState(false); + const [pureSeq, setPureSeq] = useState<{ rank: string; suit: string | null; joker: boolean }[]>([]); + const [meld1, setMeld1] = useState<(RoundMeResponse["hand"][number] | null)[]>([null, null, null]); + const [meld2, setMeld2] = useState<(RoundMeResponse["hand"][number] | null)[]>([null, null, null]); + const [meld3, setMeld3] = useState<(RoundMeResponse["hand"][number] | null)[]>([null, null, null]); + const [leftover, setLeftover] = useState<(RoundMeResponse["hand"][number] | null)[]>([null, null, null, null]); + const [prevRoundFinished, setPrevRoundFinished] = useState(null); + const [showPointsTable, setShowPointsTable] = useState(true); + + // Table Info box state + const [tableInfoVisible, setTableInfoVisible] = useState(true); + const [tableInfoMinimized, setTableInfoMinimized] = useState(false); + const [activeTab, setActiveTab] = useState<'info' | 'history' | 'spectate'>('info'); + console.log('🎨 Table.tsx render - current tableColor:', tableColor); + + // Meld lock state + const [meldLocks, setMeldLocks] = useState<{ + meld1: boolean; + meld2: boolean; + meld3: boolean; + leftover: boolean; + }>({ meld1: false, meld2: false, meld3: false, leftover: false }); + + // Load locked melds from localStorage on mount + useEffect(() => { + if (!tableId) return; + const storageKey = `rummy_melds_${tableId}`; + const saved = localStorage.getItem(storageKey); + if (saved) { + try { + const { meld1: m1, meld2: m2, meld3: m3, leftover: lo, locks } = JSON.parse(saved); + if (locks.meld1) setMeld1(m1); + if (locks.meld2) setMeld2(m2); + if (locks.meld3) setMeld3(m3); + if (locks.leftover) setLeftover(lo); + setMeldLocks(locks); + } catch (e) { + console.error('Failed to load melds from localStorage:', e); + } + } + }, [tableId]); + + // Save locked melds to localStorage whenever they change + useEffect(() => { + if (!tableId) return; + const storageKey = `rummy_melds_${tableId}`; + const data = { + meld1, + meld2, + meld3, + leftover, + locks: meldLocks + }; + localStorage.setItem(storageKey, JSON.stringify(data)); + }, [tableId, meld1, meld2, meld3, leftover, meldLocks]); + + // Toggle lock for a specific meld + const toggleMeldLock = (meldName: 'meld1' | 'meld2' | 'meld3' | 'leftover') => { + setMeldLocks(prev => ({ + ...prev, + [meldName]: !prev[meldName] + })); + toast.success(`${meldName} ${!meldLocks[meldName] ? 'locked' : 'unlocked'}`); + }; + + // Debug user object + useEffect(() => { + if (user) { + console.log('User object:', { id: user.id, sub: user.id, displayName: user.displayName }); + } + }, [user]); + + // Get cards that are placed in slots (not in hand anymore) + const placedCards = useMemo(() => { + const placed = [...meld1, ...meld2, ...meld3, ...leftover].filter(c => c !== null) as RoundMeResponse["hand"]; + return placed; + }, [meld1, meld2, meld3, leftover]); + + // Filter hand to exclude placed cards - FIX for duplicate cards + // Track which cards are used by counting occurrences + const availableHand = useMemo(() => { + if (!myRound) return []; + + // Count how many times each card (rank+suit combo) is placed in melds + const placedCounts = new Map(); + placedCards.forEach(card => { + const key = `${card.rank}-${card.suit || 'null'}`; + placedCounts.set(key, (placedCounts.get(key) || 0) + 1); + }); + + // Filter hand, keeping track of how many of each card we've already filtered + const seenCounts = new Map(); + return myRound.hand.filter(handCard => { + const key = `${handCard.rank}-${handCard.suit || 'null'}`; + const placedCount = placedCounts.get(key) || 0; + const seenCount = seenCounts.get(key) || 0; + + if (seenCount < placedCount) { + // This card should be filtered out (it's in a meld) + seenCounts.set(key, seenCount + 1); + return false; + } + return true; + }); + }, [myRound, placedCards]); + + const refresh = async () => { + if (!tableId) { + console.error('❌ refresh() called without tableId'); + return; + } + try { + const query: GetTableInfoParams = { table_id: tableId }; + const res = await apiclient.get_table_info(query); + + if (!res.ok) { + console.error('❌ get_table_info failed with status:', res.status); + // DO NOT navigate away - just log the error + toast.error('Failed to refresh table info'); + setLoading(false); + return; + } + + const data = await res.json(); + + // Check if turn changed + const turnChanged = info?.active_user_id !== data.active_user_id; + console.log('🔄 Refresh:', { + prevActiveUser: info?.active_user_id, + newActiveUser: data.active_user_id, + turnChanged + }); + + setInfo(data); + // If playing, also fetch my hand + if (data.status === "playing") { + const r: GetRoundMeParams = { table_id: tableId }; + const rr = await apiclient.get_round_me(r); + + if (!rr.ok) { + console.error('❌ get_round_me failed with status:', rr.status); + toast.error('Failed to refresh hand'); + setLoading(false); + return; + } + + const roundData = await rr.json(); + setMyRound(roundData); + + // ALWAYS sync hasDrawn with actual hand length + // 14 cards = player has drawn, 13 cards = player hasn't drawn yet + const newHasDrawn = roundData.hand.length === 14; + console.log('🔄 Syncing hasDrawn with hand length:', { + handLength: roundData.hand.length, + newHasDrawn, + previousHasDrawn: hasDrawn + }); + setHasDrawn(newHasDrawn); + } + + // Clear loading state after successful fetch + setLoading(false); + } catch (e) { + console.error("❌ Failed to refresh:", e); + // DO NOT call navigate("/") here - this would cause auto-leave! + toast.error('Connection error - retrying...'); + setLoading(false); + } + }; + + const fetchRoundHistory = async () => { + if (!info?.table_id) return; + try { + const response = await apiclient.get_round_history({ table_id: info.table_id }); + const data = await response.json(); + setRoundHistory(data.rounds || []); + } catch (error) { + console.error("Failed to fetch round history:", error); + } + }; + + // Auto-refresh table info and round data every 15s instead of 5s + useEffect(() => { + if (!tableId) return; + + const interval = setInterval(() => { + refresh(); + }, 15000); // Changed from 5000 to 15000 + + return () => clearInterval(interval); + }, [tableId]); + + // Initial load on mount + useEffect(() => { + if (!tableId) return; + refresh(); + }, [tableId]); + + const canStart = useMemo(() => { + if (!info || !user) return false; + const seated = info.players.length; + const isHost = user.id === info.host_user_id; + return info.status === "waiting" && seated >= 2 && isHost; + }, [info, user]); + + const isMyTurn = useMemo(() => { + if (!user) return false; + const userId = user.id; + console.log('Turn check - active_user_id:', info?.active_user_id, 'user.id:', userId, 'match:', info?.active_user_id === userId); + return info?.active_user_id === userId; + }, [info, user]); + + // Reset hasDrawn when turn changes + useEffect(() => { + console.log('Turn state changed - isMyTurn:', isMyTurn, 'hasDrawn:', hasDrawn); + if (!isMyTurn) { + console.log('Not my turn - clearing all selection state'); + setHasDrawn(false); + setSelectedCard(null); + setLastDrawnCard(null); + } + }, [isMyTurn]); + + const onCopy = () => { + if (!info?.code) return; + navigator.clipboard.writeText(info.code); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + }; + + const onStart = async () => { + if (!info || !tableId) return; + console.log("Starting game for table:", tableId, "User:", user?.id, "Host:", info.host_user_id); + setStarting(true); + try { + const body: StartGameRequest = { table_id: tableId }; + console.log("Calling start_game API with body:", body); + const res = await apiclient.start_game(body); + console.log("Start game response status:", res.status); + if (!res.ok) { + const errorText = await res.text(); + console.error("Start game failed:", errorText); + toast.error(`Failed to start game: ${errorText}`); + return; + } + const data = await res.json(); + toast.success(`Round #${data.number} started`); + await refresh(); + } catch (e: any) { + console.error("Start game error:", e); + toast.error(e?.message || "Failed to start game"); + } finally { + setStarting(false); + } + }; + + const onDrawStock = async () => { + if (!tableId || !isMyTurn || hasDrawn) return; + setActing(true); + try { + const body: DrawRequest = { table_id: tableId }; + const res = await apiclient.draw_stock(body); + const data = await res.json(); + // Find the new card by comparing lengths + const newCard = data.hand.find((card: any) => + !myRound?.hand.some(c => c.rank === card.rank && c.suit === card.suit) + ); + if (newCard) { + setLastDrawnCard({ rank: newCard.rank, suit: newCard.suit }); + } + setMyRound(data); + setHasDrawn(true); + toast.success("Drew from stock"); + } catch (e: any) { + toast.error("Failed to draw from stock"); + } finally { + setActing(false); + } + }; + + const onDrawDiscard = async () => { + if (!tableId || !isMyTurn || hasDrawn) return; + setActing(true); + try { + const body: DrawRequest = { table_id: tableId }; + const res = await apiclient.draw_discard(body); + const data = await res.json(); + // The card being drawn is the CURRENT discard_top (before the draw) + if (myRound?.discard_top) { + // Parse the card code properly (e.g., "7S" -> rank="7", suit="S") + const code = myRound.discard_top; + let rank: string; + let suit: string | null; + + if (code === 'JOKER') { + rank = 'JOKER'; + suit = null; + } else { + // Last char is suit, rest is rank + suit = code.slice(-1); + rank = code.slice(0, -1); + } + + setLastDrawnCard({ rank, suit }); + } + setMyRound(data); + setHasDrawn(true); + toast.success("Drew from discard pile"); + } catch (e: any) { + toast.error("Failed to draw from discard"); + } finally { + setActing(false); + } + }; + + const onDiscard = async () => { + if (!tableId || !selectedCard || !hasDrawn) return; + setActing(true); + try { + const body: DiscardRequest = { table_id: tableId, card: selectedCard }; + const res = await apiclient.discard_card(body); + const data = await res.json(); + toast.success("Card discarded. Next player's turn."); + setSelectedCard(null); + setLastDrawnCard(null); + setHasDrawn(false); + await refresh(); + } catch (e: any) { + toast.error("Failed to discard card"); + } finally { + setActing(false); + } + }; + + const fetchRevealedHands = async () => { + console.log("📊 Fetching revealed hands..."); + let lastError: any = null; + for (let attempt = 1; attempt <= 3; attempt++) { + try { + const resp = await apiclient.get_revealed_hands({ table_id: tableId! }); + if (!resp.ok) { + const errorText = await resp.text(); + console.error(`❌ API returned error (attempt ${attempt}/3):`, { status: resp.status, body: errorText }); + lastError = { status: resp.status, message: errorText }; + if (attempt < 3 && resp.status === 400) { + console.log(`⏳ Waiting 500ms before retry ${attempt + 1}...`); + await new Promise((resolve) => setTimeout(resolve, 500)); + continue; + } else { + break; + } + } + const data = await resp.json(); + console.log("✅ Revealed hands fetched:", data); + setRevealedHands(data); + setShowScoreboardModal(true); // ← CHANGED: Set modal state to true + setShowScoreboardPanel(true); + return data; + } catch (error: any) { + console.error(`❌ Error fetching revealed hands (attempt ${attempt}/3):`, error); + lastError = error; + if (attempt < 3) { + console.log(`⏳ Waiting 500ms before retry ${attempt + 1}...`); + await new Promise((resolve) => setTimeout(resolve, 500)); + } else { + break; + } + } + } + const errorMsg = lastError?.message || lastError?.status || "Network error"; + toast.error(`Failed to load scoreboard: ${errorMsg}`); + console.error("🚨 Final scoreboard error:", lastError); + return null; + }; + + const onDeclare = async () => { + console.log('🎯 Declare clicked'); + if (!meld1 || !meld2 || !meld3) { + toast.error('Please create all 3 melds before declaring'); + return; + } + + // Count cards in melds INCLUDING leftover as meld4 + const m1 = meld1?.length || 0; + const m2 = meld2?.length || 0; + const m3 = meld3?.length || 0; + const m4 = leftover?.length || 0; + const totalPlaced = m1 + m2 + m3 + m4; + + // Identify leftover cards (not in any meld OR leftover slot) + const allMeldCards = [...(meld1 || []), ...(meld2 || []), ...(meld3 || []), ...(leftover || [])]; + const unplacedCards = myRound?.hand.filter(card => { + const cardKey = `${card.rank}-${card.suit || 'null'}`; + return !allMeldCards.some(m => `${m.rank}-${m.suit || 'null'}` === cardKey); + }) || []; + + if (totalPlaced !== 13) { + const unplacedCount = unplacedCards.length; + const unplacedDisplay = unplacedCards + .map(c => `${c.rank}${c.suit || ''}`) + .join(', '); + + toast.error( + `You must place all 13 cards in melds. Currently ${totalPlaced}/13 cards placed.\n\n` + + `Unplaced ${unplacedCount} card${unplacedCount > 1 ? 's' : ''}: ${unplacedDisplay}\n\n` + + `Place these in Meld 1, Meld 2, Meld 3, or Leftover slots.`, + { duration: 6000 } + ); + console.log(`❌ Not all 13 cards placed. Total: ${totalPlaced}`); + return; + } + + console.log('🎯 DECLARE BUTTON CLICKED!'); + console.log('tableId:', tableId); + console.log('isMyTurn:', isMyTurn); + console.log('hasDrawn:', hasDrawn); + console.log('hand length:', myRound?.hand.length); + + if (!tableId) return; + if (!isMyTurn) { + toast.error("It's not your turn!"); + return; + } + + // Check if player has drawn (must have 14 cards) + const handLength = myRound?.hand.length || 0; + if (handLength !== 14) { + toast.error( + `You must draw a card before declaring!\n` + + `You have ${handLength} cards, but need 14 cards (13 to meld + 1 to discard).`, + { duration: 5000 } + ); + return; + } + + // Collect meld groups (filter out null slots) + const groups: RoundMeResponse["hand"][] = []; + + const meld1Cards = meld1?.filter(c => c !== null) as RoundMeResponse["hand"]; + if (meld1Cards.length > 0) groups.push(meld1Cards); + + const meld2Cards = meld2?.filter(c => c !== null) as RoundMeResponse["hand"]; + if (meld2Cards.length > 0) groups.push(meld2Cards); + + const meld3Cards = meld3?.filter(c => c !== null) as RoundMeResponse["hand"]; + if (meld3Cards.length > 0) groups.push(meld3Cards); + + const leftoverCards = leftover?.filter(c => c !== null) as RoundMeResponse["hand"]; + if (leftoverCards.length > 0) groups.push(leftoverCards); + + // Skip to API call - validation already done above + console.log('✅ All checks passed, preparing API call...'); + setActing(true); + try { + // Transform CardView to DiscardCard (remove 'code' field) + const discardGroups = groups.map(group => + group.map(card => ({ + rank: card.rank, + suit: card.suit, + joker: card.joker + })) + ); + + const body: DeclareRequest = { table_id: tableId, groups: discardGroups }; + console.log('📤 Sending declare request:', JSON.stringify(body, null, 2)); + console.log('📡 About to call apiclient.declare()...'); + const res = await apiclient.declare(body); + console.log('📨 Received response:', res); + + if (res.ok) { + const data = await res.json(); + console.log("✅ DECLARE COMPLETED:", data); + + // Show appropriate message based on valid/invalid + if (data.status === 'valid') { + toast.success(`🏆 Valid declaration! You win round #${data.round_number} with 0 points!`); + } else { + toast.warning(`⚠️ Invalid declaration! You received 80 penalty points for round #${data.round_number}`); + } + + console.log('🎯 Fetching revealed hands...'); + await fetchRevealedHands(); + console.log('✅ Revealed hands fetched'); + + // Log state right after fetch + console.log("🔍 POST-FETCH STATE CHECK:"); + console.log(" - showScoreboardModal:", showScoreboardModal); + console.log(" - revealedHands:", revealedHands); + } else { + // Handle HTTP errors from backend + let errorMessage = 'Failed to declare'; + try { + const errorData = await res.json(); + errorMessage = errorData.detail || errorData.message || errorMessage; + } catch { + const errorText = await res.text(); + errorMessage = errorText || errorMessage; + } + console.log('❌ Backend error:', errorMessage); + toast.error(`❌ ${errorMessage}`, { duration: 5000 }); + } + } catch (error: any) { + // Network errors or other exceptions + console.error('🚨 DECLARE EXCEPTION CAUGHT:'); + console.error(' Error object:', error); + console.error(' Error type:', typeof error); + console.error(' Error constructor:', error?.constructor?.name); + console.error(' Error message:', error?.message); + console.error(' Error stack:', error?.stack); + console.error(' Error keys:', Object.keys(error || {})); + console.error(' Full error JSON:', JSON.stringify(error, Object.getOwnPropertyNames(error))); + + // Try to get more details about the request that failed + if (error.response) { + console.error(' Response status:', error.response.status); + console.error(' Response data:', error.response.data); + } + if (error.request) { + console.error(' Request:', error.request); + } + + // Extract actual error message from Response object or other error types + let errorMsg = 'Network error'; + + // PRIORITY 1: Check if it's a Response object + if (error instanceof Response) { + try { + const errorData = await error.json(); + errorMsg = errorData.detail || errorData.message || 'Failed to declare'; + } catch { + try { + const errorText = await error.text(); + errorMsg = errorText || 'Failed to declare'; + } catch { + errorMsg = 'Failed to declare'; + } + } + } + // PRIORITY 2: Check for error.message + else if (error?.message) { + errorMsg = error.message; + } + // PRIORITY 3: Check if it's a string + else if (typeof error === 'string') { + errorMsg = error; + } + // PRIORITY 4: Try toString (but avoid [object Object]) + else if (error?.toString && typeof error.toString === 'function') { + const stringified = error.toString(); + if (stringified !== '[object Object]' && stringified !== '[object Response]') { + errorMsg = stringified; + } + } + + toast.error(`❌ Failed to declare: ${errorMsg}`, { duration: 5000 }); + } finally { + setActing(false); + } + }; + + const onNextRound = async () => { + if (!tableId || !info) return; + setStarting(true); + try { + const body = { table_id: tableId }; + const res = await apiclient.start_next_round(body); + const data = await res.json(); + toast.success(`Round #${data.number} started!`); + await refresh(); + } catch (e: any) { + toast.error(e?.message || "Failed to start next round"); + } finally { + setStarting(false); + } + }; + + // Drop game handler + const onDropGame = async () => { + if (!tableId || droppingGame) return; + setDroppingGame(true); + try { + const body = { table_id: tableId }; + const res = await apiclient.drop_game(body); + await res.json(); + toast.success("You have dropped from the game (20 point penalty)"); + await refresh(); + } catch (e: any) { + toast.error(e?.message || "Failed to drop game"); + } finally { + setDroppingGame(false); + } + }; + + // Spectate handlers + const requestSpectate = async (playerId: string) => { + if (!tableId || spectateRequested) return; + setSpectateRequested(true); + try { + const body = { table_id: tableId, player_id: playerId }; + await apiclient.request_spectate(body); + toast.success("Spectate request sent"); + } catch (e: any) { + toast.error(e?.message || "Failed to request spectate"); + } + }; + + const grantSpectate = async (spectatorId: string) => { + if (!tableId) return; + try { + const body: GrantSpectateRequest = { table_id: tableId, spectator_id: spectatorId, granted: true }; + await apiclient.grant_spectate(body); + setSpectateRequests(prev => prev.filter(id => id !== spectatorId)); + toast.success("Spectate access granted"); + } catch (e: any) { + toast.error(e?.message || "Failed to grant spectate"); + } + }; + + // Voice control handlers + const toggleVoiceMute = async () => { + if (!tableId || !user) return; + try { + const body = { table_id: tableId, user_id: user.id, muted: !voiceMuted }; + await apiclient.mute_player(body); + setVoiceMuted(!voiceMuted); + toast.success(voiceMuted ? "Unmuted" : "Muted"); + } catch (e: any) { + toast.error(e?.message || "Failed to toggle mute"); + } + }; + + const onCardSelect = (card: RoundMeResponse["hand"][number], idx: number) => { + if (!hasDrawn) return; + setSelectedCard({ rank: card.rank, suit: card.suit || null, joker: card.joker || false }); + }; + + const onReorderHand = (reorderedHand: RoundMeResponse["hand"]) => { + if (myRound) { + setMyRound({ ...myRound, hand: reorderedHand }); + } + }; + + const onSelectCard = (card: DiscardCard) => { + if (!hasDrawn) return; + setSelectedCard(card); + }; + + const onClearMelds = () => { + setMeld1([null, null, null]); + setMeld2([null, null, null]); + setMeld3([null, null, null]); + setLeftover([null, null, null, null]); + toast.success('Melds cleared'); + }; + + // Debug logging for button visibility + useEffect(() => { + console.log('🔍 Discard Button Visibility Check:', { + isMyTurn, + hasDrawn, + selectedCard, + handLength: myRound?.hand.length, + showDiscardButton: isMyTurn && hasDrawn && selectedCard !== null, + user_id: user?.id, + active_user_id: info?.active_user_id + }); + }, [isMyTurn, hasDrawn, selectedCard, myRound, user, info]); + + if (!tableId) { + return ( +
+
+

Missing tableId.

+ +
+
+ ); + } + + return ( +
+
+ {/* Collapsible Game Rules - positioned top right */} + + + {/* Remove the separate PointsTable component - it's now inside Table Info */} + +
+
+

Table

+
+ {/* Voice Mute Toggle */} + {info?.status === 'playing' && ( + + )} + + {/* Drop Game Button (only before first draw) */} + {info?.status === 'playing' && !hasDrawn && ( + + )} + + +
+
+ + {/* Responsive Layout: Single column on mobile, two columns on desktop */} +
+ {/* Main Game Area */} +
+ {loading &&

Loading…

} + {!loading && info && ( +
+
+
+

Room Code

+

{info.code}

+
+ +
+ +
+

Players

+
+ {info.players.map((p) => ( +
+
+ +
+
+

Seat {p.seat}

+

{p.display_name || p.user_id.slice(0,6)}

+
+ {p.user_id === info.host_user_id && ( + + Host + + )} + {info.status === "playing" && p.user_id === info.active_user_id && ( + Active + )} +
+ ))} +
+
+ + {info.current_round_number && myRound && ( +
+
+
+

Round #{info.current_round_number}

+ {isMyTurn ? ( +

Your turn!

+ ) : ( +

Wait for your turn

+ )} +
+
+
+ Stock: {myRound.stock_count} +
+ {myRound.discard_top && ( +
+ Discard Top: {myRound.discard_top} +
+ )} +
+ Wild Joker:{" "} + {myRound.wild_joker_revealed ? ( + {myRound.wild_joker_rank} + ) : ( + ??? + )} +
+
+
+ + {/* Table Color Picker */} +
+ Table Color: +
+ + {/* 3D Casino Table - Contains ONLY Stock, Discard, Wild Joker */} + + {/* Player Positions Around Table - Only show if player exists */} +
+ {/* Top players (P2, P3, P4) */} +
+ {info?.players?.[1] && } + {info?.players?.[2] && } + {info?.players?.[3] && } +
+ + {/* Left player (P1) */} + {info?.players?.[0] && ( +
+ +
+ )} + + {/* Right player (P5) */} + {info?.players?.[4] && ( +
+ +
+ )} + + {/* Bottom player (current user) */} +
+ +
+
+ + {/* Cards ON the Table Surface - HORIZONTAL ROW */} +
+
+ {/* Stock Pile - NOW CLICKABLE */} +
+ +
+

STOCK PILE

+ {myRound.stock_count > 0 && ( +
+ {myRound.stock_count} cards +
+ )} +
+
+ + {/* Wild Joker Card - only show if game mode uses wild jokers */} + {info?.game_mode !== 'no_joker' && ( +
+
+ {myRound.wild_joker_revealed && myRound.wild_joker_rank ? ( +
+ {myRound.wild_joker_rank} + All {myRound.wild_joker_rank}s +
+ ) : ( +
+ 🃏 +
+ )} +
+

+ {myRound.wild_joker_revealed ? ( + WILD JOKER + ) : ( + WILD JOKER + )} +

+
+ )} + + {/* Discard Pile */} +
+ +

DISCARD PILE

+
+
+
+
+ + {/* Meld Grouping Zone - Outside the 3D table with clean design */} +
+

+ {hasDrawn ? "Organize your 13 cards into melds (drag cards to slots)" : "Organize melds (draw a card first)"} +

+ + {/* Three 3-card meld boxes */} +
+ {/* Meld 1 - with lock button */} + toggleMeldLock('meld1')} + tableId={tableId} + onRefresh={refresh} + gameMode={info?.game_mode} + /> + {/* Meld 2 - no lock button */} + toggleMeldLock('meld2')} + tableId={tableId} + onRefresh={refresh} + hideLockButton={true} + gameMode={info?.game_mode} + /> + {/* Meld 3 - no lock button */} + toggleMeldLock('meld3')} + tableId={tableId} + onRefresh={refresh} + hideLockButton={true} + gameMode={info?.game_mode} + /> +
+ + {/* Leftover cards */} + toggleMeldLock('leftover')} + tableId={tableId} + onRefresh={refresh} + gameMode={info?.game_mode} + /> + + {/* Clear melds button only */} + {hasDrawn && ( +
+ +
+ )} +
+ + {/* Hand */} +
+

+ Your Hand ({availableHand.length} cards) + {lastDrawnCard && ★ New card highlighted} +

+ c.rank === selectedCard.rank && c.suit === selectedCard.suit + ) : undefined} + highlightIndex={lastDrawnCard ? availableHand.findIndex( + c => c.rank === lastDrawnCard.rank && c.suit === lastDrawnCard.suit + ) : undefined} + onReorder={onReorderHand} + /> + + {/* Discard Button - Only shown when card is selected */} + {isMyTurn && hasDrawn && selectedCard && ( +
+

+ ✓ Card selected - Click to discard +

+ +
+ )} +
+ + {/* Declare & Discard Actions - When turn is active */} + {isMyTurn && hasDrawn && ( +
+

Organize 13 cards into valid melds, then declare. The 14th card will be auto-discarded.

+
+ + {selectedCard && ( + + )} +
+
+ )} +
+ )} + + {/* Scoreboard Display */} + {scoreboard && info?.status === "finished" && ( +
+
+
+ +

Round #{scoreboard.round_number} Complete!

+
+ + {scoreboard.winner_user_id && ( +
+

+ 🎉 Winner: {info.players.find(p => p.user_id === scoreboard.winner_user_id)?.display_name || "Unknown"} +

+
+ )} + +
+ + + + + + + + + {scoreboard.scores + .sort((a, b) => a.points - b.points) + .map((score, idx) => { + const player = info.players.find(p => p.user_id === score.user_id); + const isWinner = score.user_id === scoreboard.winner_user_id; + return ( + + + + + ); + })} + +
PlayerPoints
+
+ {isWinner && } + + {player?.display_name || score.user_id.slice(0, 6)} + +
+
+ {score.points} +
+
+
+ + {/* Next Round Button */} + {user && info.host_user_id === user.id && ( +
+ +
+ )} + {user && info.host_user_id !== user.id && ( +

Waiting for host to start next round...

+ )} +
+ )} + + {/* Show Next Round button if user is host and round is complete */} + {info?.status === 'round_complete' && info?.host_id === user?.id && ( +
+ +
+ )} +
+ )} +
+ + {/* Sidebar - Table Info with Round History */} + {tableInfoVisible && ( +
+ {/* Header with Minimize/Close */} +
+

+ {tableInfoMinimized ? 'Table' : 'Table Info'} +

+
+ + +
+
+ + {/* Content - only show when not minimized */} + {!tableInfoMinimized && ( +
+ {loading &&

Loading…

} + {!loading && info && ( + <> + {/* Room Code */} +
+

Room Code

+
+ + {info.code} + + +
+
+ + {/* Players */} +
+

Players ({info.players.length})

+
+ {info.players.map((p) => ( +
+
+ +
+
+

Seat {p.seat}

+

{p.display_name || p.user_id.slice(0,6)}

+
+ {p.user_id === info.host_user_id && ( + + Host + + )} + {info.status === "playing" && p.user_id === info.active_user_id && ( + Active + )} +
+ ))} +
+
+ + {/* Status */} +
+

Status: {info?.status ?? "-"}

+ {user && info.host_user_id === user.id && ( + + )} + {info && info.status === "waiting" && user && user.id !== info.host_user_id && ( +

Waiting for host to start...

+ )} +
+ + {/* Round History & Points Table */} + {roundHistory.length > 0 && ( +
+

Round History

+
+ + + + + {roundHistory.map((round, idx) => ( + + ))} + + + + + {info.players.map((player) => { + let runningTotal = 0; + return ( + + + {roundHistory.map((round, idx) => { + const isWinner = round.winner_user_id === player.user_id; + const roundScore = round.scores[player.user_id] || 0; + runningTotal += roundScore; + return ( + + ); + })} + + + ); + })} + +
Player + R{round.round_number} + + Total +
+
+ {player.display_name || 'Player'} +
+
+
+ + {roundScore} + + {isWinner && } +
+
+ {runningTotal} +
+
+
+ )} + + )} +
+ )} +
+ )} + + {/* Show Table Info button when closed */} + {!tableInfoVisible && ( + + )} + + {/* Spectate Requests Panel (Host Only) */} + {info?.host_user_id === user?.id && spectateRequests.length > 0 && ( +
+

Spectate Requests

+
+ {spectateRequests.map(userId => ( +
+ {userId.slice(0, 8)}... +
+ + +
+
+ ))} +
+
+ )} +
+ + {/* Scoreboard Modal */} + setShowScoreboardModal(false)} + data={revealedHands} + players={info?.players || []} + currentUserId={user?.id || ''} + tableId={tableId || ''} + hostUserId={info?.host_user_id || ''} + onNextRound={() => { + setShowScoreboardModal(false); + onNextRound(); + }} + /> + + {/* Side Panel for Scoreboard - Legacy */} + {showScoreboardPanel && revealedHands && ( +
+
+
+

Round Results

+ +
+ + {/* Round Scores */} +
+

Scores

+ {Object.entries(revealedHands.scores || {}).map(([uid, score]: [string, any]) => { + const playerName = revealedHands.player_names?.[uid] || "Unknown"; + return ( +
+ + {playerName} + + + {score} pts + +
+ ); + })} +
+ + {/* All Players' Hands */} +
+ {Object.entries(revealedHands.organized_melds || {}).map(([uid, melds]: [string, any]) => { + const playerName = revealedHands.player_names?.[uid] || "Unknown"; + const playerScore = revealedHands.scores?.[uid] || 0; + const isWinner = playerScore === 0; + + return ( +
+
+

+ {playerName} + {isWinner && " 🏆"} +

+ + {playerScore} pts + +
+ + {melds && melds.length > 0 ? ( +
+ {melds.map((meld: any, idx: number) => { + const meldType = meld.type || "unknown"; + let bgColor = "bg-gray-700"; + let borderColor = "border-gray-600"; + let label = "Cards"; + + if (meldType === "pure") { + bgColor = "bg-blue-900/40"; + borderColor = "border-blue-500"; + label = "Pure Sequence"; + } else if (meldType === "impure") { + bgColor = "bg-purple-900/40"; + borderColor = "border-purple-500"; + label = "Impure Sequence"; + } else if (meldType === "set") { + bgColor = "bg-orange-900/40"; + borderColor = "border-orange-500"; + label = "Set"; + } + + return ( +
+
{label}
+
+ {(meld.cards || []).map((card: any, cardIdx: number) => ( +
+ {card.name || card.code || "??"} +
+ ))} +
+
+ ); + })} +
+ ) : ( +
No melds
+ )} +
+ ); + })} +
+ + {/* Next Round Button */} + {revealedHands.can_start_next && ( + + )} +
+
+ )} + + {/* Chat Sidebar - Fixed position */} + {user && info && tableId && ( + ({ + userId: p.user_id, + displayName: p.display_name || p.user_id.slice(0, 6) + }))} + /> + )} + + {/* Voice Panel - Fixed position */} + {user && info && tableId && ( + + )} +
+
+
+ ); +} diff --git a/TableDiagram.jsx b/TableDiagram.jsx new file mode 100644 index 0000000000000000000000000000000000000000..cbfb470a80711c80321ccaf611cef2650eda88a6 --- /dev/null +++ b/TableDiagram.jsx @@ -0,0 +1,90 @@ +import React from "react"; +import type { PlayerInfo } from "../apiclient/data-contracts"; +import { User2 } from "lucide-react"; + +export interface Props { + players: PlayerInfo[]; + activeUserId?: string | null; + currentUserId?: string; +} + +export const TableDiagram: React.FC = ({ players, activeUserId, currentUserId }) => { + // Position players around the table perimeter in a circular pattern + const getSeatPosition = (seat: number, totalSeats: number) => { + // Calculate angle for circular positioning + const angleStep = 360 / totalSeats; + const angle = angleStep * (seat - 1) - 90; // Start from top (12 o'clock) + + // Convert polar to cartesian coordinates + const radius = 45; // % from center + const x = 50 + radius * Math.cos((angle * Math.PI) / 180); + const y = 50 + radius * Math.sin((angle * Math.PI) / 180); + + return { x, y, angle }; + }; + + return ( +
+ {/* Player positions around the table */} + {players.map((player) => { + const { x, y } = getSeatPosition(player.seat, players.length); + const isActive = player.user_id === activeUserId; + const isCurrent = player.user_id === currentUserId; + + return ( +
+
+
+ {player.profile_image_url ? ( + {player.display_name + ) : ( + + )} +
+
+
+ {isCurrent ? "You" : player.display_name?.slice(0, 10) || `Player ${player.seat}`} +
+
Seat {player.seat}
+
+ {isActive && ( +
+ )} +
+
+ ); + })} +
+ ); +}; diff --git a/VoiceControls.jsx b/VoiceControls.jsx new file mode 100644 index 0000000000000000000000000000000000000000..e738c4be0d7749235ebdc0bdb5530b46598d1224 --- /dev/null +++ b/VoiceControls.jsx @@ -0,0 +1,56 @@ +import React, { useState } from 'react'; +import { Mic, MicOff, Video, VideoOff, PhoneOff } from 'lucide-react'; +import { Button } from '@/components/ui/button'; + +interface Props { + tableId: string; + onToggleAudio: (enabled: boolean) => void; + onToggleVideo: (enabled: boolean) => void; +} + +export const VoiceControls: React.FC = ({ tableId, onToggleAudio, onToggleVideo }) => { + const [audioEnabled, setAudioEnabled] = useState(false); + const [videoEnabled, setVideoEnabled] = useState(false); + + const toggleAudio = () => { + const newState = !audioEnabled; + setAudioEnabled(newState); + onToggleAudio(newState); + }; + + const toggleVideo = () => { + const newState = !videoEnabled; + setVideoEnabled(newState); + onToggleVideo(newState); + }; + + return ( +
+ {/* Audio Toggle */} + + + {/* Video Toggle */} + +
+ ); +}; diff --git a/VoicePanel.jsx b/VoicePanel.jsx new file mode 100644 index 0000000000000000000000000000000000000000..434ac8daa0d05e7cdc6775a1e64e3ceb1282b6cc --- /dev/null +++ b/VoicePanel.jsx @@ -0,0 +1,276 @@ +import { useState, useEffect } from 'react'; +import { apiClient } from 'app'; +import { Button } from '@/components/ui/button'; +import { ScrollArea } from '@/components/ui/scroll-area'; +import { Phone, PhoneOff, Mic, MicOff, Volume2, X, Users, ChevronRight } from 'lucide-react'; +import { toast } from 'sonner'; + +interface Participant { + user_id: string; + display_name: string; + is_muted: boolean; + is_speaking: boolean; +} + +interface Props { + tableId: string; + currentUserId: string; + isHost: boolean; + players: Array<{ user_id: string; display_name?: string | null }>; +} + +export default function VoicePanel({ tableId, currentUserId, isHost, players }: Props) { + const [isOpen, setIsOpen] = useState(false); + const [inCall, setInCall] = useState(false); + const [participants, setParticipants] = useState([]); + const [myMuted, setMyMuted] = useState(false); + + // Poll for voice participants every 2 seconds when in call + useEffect(() => { + if (!inCall) return; + + const fetchParticipants = async () => { + try { + const res = await apiClient.get_voice_participants({ table_id: tableId }); + const data = await res.json(); + setParticipants(data.participants || []); + } catch (error) { + console.error('Failed to fetch voice participants:', error); + } + }; + + fetchParticipants(); + const interval = setInterval(fetchParticipants, 2000); + return () => clearInterval(interval); + }, [tableId, inCall]); + + const toggleCall = () => { + setInCall(!inCall); + if (!inCall) { + setIsOpen(true); + toast.success('Joined voice call'); + } else { + toast.success('Left voice call'); + } + }; + + const toggleMyMute = async () => { + try { + await apiClient.mute_player({ + table_id: tableId, + user_id: currentUserId, + muted: !myMuted + }); + setMyMuted(!myMuted); + toast.success(myMuted ? 'Unmuted' : 'Muted'); + } catch (error) { + toast.error('Failed to toggle mute'); + } + }; + + const mutePlayer = async (userId: string, muted: boolean) => { + if (!isHost) return; + try { + await apiClient.mute_player({ + table_id: tableId, + user_id: userId, + muted + }); + toast.success(`Player ${muted ? 'muted' : 'unmuted'}`); + } catch (error) { + toast.error('Failed to mute player'); + } + }; + + const muteAll = async () => { + if (!isHost) return; + try { + await apiClient.update_table_voice_settings({ + table_id: tableId, + mute_all: true + }); + toast.success('All players muted'); + } catch (error) { + toast.error('Failed to mute all'); + } + }; + + // Floating call button when panel is closed + if (!isOpen) { + return ( + + ); + } + + return ( +
+ {/* Header */} +
+
+ +

Voice Call

+ {inCall && ( + + Live + + )} +
+ +
+ + {/* Participants List */} + + {!inCall && ( +
+ +

Join the voice call to see participants

+
+ )} + {inCall && participants.length === 0 && ( +
+

Waiting for others to join...

+
+ )} + {inCall && participants.length > 0 && ( +
+ {participants.map((participant) => { + const isMe = participant.user_id === currentUserId; + return ( +
+
+ {/* Avatar with speaking indicator */} +
+
+ + {(participant.display_name || participant.user_id).slice(0, 2).toUpperCase()} + +
+ {participant.is_speaking && ( + + )} +
+ + {/* Name */} +
+

+ {participant.display_name || participant.user_id.slice(0, 8)} + {isMe && ' (You)'} +

+ {participant.is_speaking && ( +

Speaking...

+ )} +
+
+ + {/* Mute controls */} +
+ {participant.is_muted ? ( + + ) : ( + + )} + {isHost && !isMe && ( + + )} +
+
+ ); + })} +
+ )} +
+ + {/* Footer Controls */} +
+ {/* Host controls */} + {isHost && inCall && ( + + )} + + {/* Main call controls */} +
+ {inCall && ( + + )} + +
+
+
+ ); +} diff --git a/WildJokerRevealModal.jsx b/WildJokerRevealModal.jsx new file mode 100644 index 0000000000000000000000000000000000000000..fd02e2808fe1c1b968a7f5c95f140adc600e4823 --- /dev/null +++ b/WildJokerRevealModal.jsx @@ -0,0 +1,128 @@ +import React, { useEffect, useState } from "react"; +import { Dialog, DialogContent } from "@/components/ui/dialog"; + +interface Props { + isOpen: boolean; + onClose: () => void; + wildJokerRank: string; +} + +export const WildJokerRevealModal: React.FC = ({ isOpen, onClose, wildJokerRank }) => { + const [isFlipping, setIsFlipping] = useState(false); + + // Start flip animation shortly after modal opens + useEffect(() => { + if (isOpen) { + setIsFlipping(false); + const timer = setTimeout(() => setIsFlipping(true), 300); + return () => clearTimeout(timer); + } + }, [isOpen]); + + const formatCardDisplay = (rank: string) => { + // Extract suit from rank if present (e.g., "7S" -> rank="7", suit="S") + const suitChar = rank.slice(-1); + const suits = ['S', 'H', 'D', 'C']; + + if (suits.includes(suitChar)) { + const cardRank = rank.slice(0, -1); + const suitSymbol = suitChar === "S" ? "♠" : suitChar === "H" ? "♥" : suitChar === "D" ? "♦" : "♣"; + const suitColor = suitChar === "H" || suitChar === "D" ? "text-red-600" : "text-gray-900"; + return { rank: cardRank, suitSymbol, suitColor }; + } + + // Just rank without suit + return { rank, suitSymbol: "♠", suitColor: "text-gray-900" }; + }; + + const { rank, suitSymbol, suitColor } = formatCardDisplay(wildJokerRank); + + return ( + + +
+

Wild Joker Revealed!

+ + {/* Card flip container */} +
+
+
+ {/* Card Back */} +
+
+
🃏
+
+
+ + {/* Card Front */} +
+
+
+ {rank} +
+
+ {suitSymbol} +
+
+
+
+
+
+ +

+ All {rank} cards are now wild jokers! +

+ + +
+
+ + +
+ ); +}; diff --git a/call chat rules button.png b/call chat rules button.png new file mode 100644 index 0000000000000000000000000000000000000000..b48a57d107f86291da0494d6b5165f33af3314dc Binary files /dev/null and b/call chat rules button.png differ diff --git a/cardCodeUtils.ts b/cardCodeUtils.ts new file mode 100644 index 0000000000000000000000000000000000000000..4992727880c7f9fddc29d27082134f21d38c6795 --- /dev/null +++ b/cardCodeUtils.ts @@ -0,0 +1,31 @@ +/** + * Parse a card code like "7S" into a CardView object + * + * @param code - The card code string (e.g., "7S", "KH", "JOKER") + * @returns Object with rank, suit, joker flag, and original code + */ +export const parseCardCode = (code: string): { rank: string; suit: string | null; joker: boolean; code: string } => { + if (!code) return { rank: '', suit: null, joker: false, code: '' }; + + // Try to parse as JSON first (in case it's already an object) + try { + const parsed = JSON.parse(code); + if (parsed.rank) return parsed; + } catch {} + + // Handle joker cards + if (code === 'JOKER') { + return { rank: 'JOKER', suit: null, joker: true, code }; + } + + // Parse standard card codes (e.g., "7S" -> rank="7", suit="S") + const suit = code.slice(-1); + const rank = code.slice(0, -1) || code; + + return { + rank, + suit: suit && ['S', 'H', 'D', 'C'].includes(suit) ? suit : null, + joker: false, + code + }; +}; diff --git a/cardHelpers.ts b/cardHelpers.ts new file mode 100644 index 0000000000000000000000000000000000000000..e2e84dbbc2e3fa7cd062ba8ba14102d68cfe3848 --- /dev/null +++ b/cardHelpers.ts @@ -0,0 +1,17 @@ +/** + * Format a card code (e.g., "7S") into display components + */ +export const formatCardDisplay = (cardCode: string) => { + const suitChar = cardCode.slice(-1); + const suits = ['S', 'H', 'D', 'C']; + + if (suits.includes(suitChar)) { + const rank = cardCode.slice(0, -1); + const suitSymbol = suitChar === "S" ? "♠" : suitChar === "H" ? "♥" : suitChar === "D" ? "♦" : "♣"; + const suitColor = suitChar === "H" || suitChar === "D" ? "text-red-600" : "text-gray-900"; + return { rank, suitSymbol, suitColor }; + } + + // Just rank without suit + return { rank: cardCode, suitSymbol: "♠", suitColor: "text-gray-900" }; +}; diff --git a/chat.js b/chat.js new file mode 100644 index 0000000000000000000000000000000000000000..384285db9ed6cf8a8498434a86eb7c0ab4a8faa7 --- /dev/null +++ b/chat.js @@ -0,0 +1,186 @@ +import os +from fastapi import APIRouter, HTTPException +from pydantic import BaseModel +import asyncpg +from app.auth import AuthorizedUser + +router = APIRouter() + +# Database connection helper +async def get_db_connection(): + """Create and return a database connection.""" + return await asyncpg.connect(os.environ.get("DATABASE_URL")) + +# Pydantic models +class SendMessageRequest(BaseModel): + """Request to send a chat message.""" + table_id: str + message: str + is_private: bool = False + recipient_id: str | None = None + +class ChatMessage(BaseModel): + """Chat message response.""" + id: int + table_id: str + user_id: str + sender_name: str + message: str + is_private: bool + recipient_id: str | None + created_at: str + +class GetMessagesParams(BaseModel): + """Parameters for retrieving chat messages.""" + table_id: str + limit: int = 100 + before_id: int | None = None # For pagination + +class GetMessagesResponse(BaseModel): + """Response containing chat messages.""" + messages: list[ChatMessage] + has_more: bool + +@router.post("/chat/send") +async def send_message(body: SendMessageRequest, user: AuthorizedUser) -> ChatMessage: + """ + Send a chat message (public or private). + + - Public messages are visible to all players at the table + - Private messages are only visible to sender and recipient + """ + conn = await get_db_connection() + try: + # Verify user is part of the table + player = await conn.fetchrow( + """ + SELECT display_name FROM rummy_table_players + WHERE table_id = $1 AND user_id = $2 + """, + body.table_id, + user.sub + ) + + if not player: + raise HTTPException(status_code=403, detail="You are not part of this table") + + # If private message, verify recipient exists at table + if body.is_private and body.recipient_id: + recipient = await conn.fetchrow( + """ + SELECT user_id FROM rummy_table_players + WHERE table_id = $1 AND user_id = $2 + """, + body.table_id, + body.recipient_id + ) + + if not recipient: + raise HTTPException(status_code=400, detail="Recipient is not part of this table") + + # Insert message + row = await conn.fetchrow( + """ + INSERT INTO chat_messages (table_id, user_id, message, is_private, recipient_id) + VALUES ($1, $2, $3, $4, $5) + RETURNING id, table_id, user_id, message, is_private, recipient_id, created_at + """, + body.table_id, + user.sub, + body.message, + body.is_private, + body.recipient_id + ) + + return ChatMessage( + id=row["id"], + table_id=row["table_id"], + user_id=row["user_id"], + sender_name=player["display_name"] or "Anonymous", + message=row["message"], + is_private=row["is_private"], + recipient_id=row["recipient_id"], + created_at=row["created_at"].isoformat() + ) + finally: + await conn.close() + +@router.get("/chat/messages") +async def get_messages(table_id: str, limit: int = 100, before_id: int | None = None, user: AuthorizedUser = None) -> GetMessagesResponse: + """ + Retrieve chat messages for a table. + + Returns public messages and private messages where user is sender or recipient. + Supports pagination with before_id parameter. + """ + conn = await get_db_connection() + try: + # Verify user is part of the table + player = await conn.fetchrow( + """ + SELECT user_id FROM rummy_table_players + WHERE table_id = $1 AND user_id = $2 + """, + table_id, + user.sub + ) + + if not player: + raise HTTPException(status_code=403, detail="You are not part of this table") + + # Build query for messages + # Get public messages OR private messages where user is sender or recipient + query = """ + SELECT + cm.id, cm.table_id, cm.user_id, cm.message, + cm.is_private, cm.recipient_id, cm.created_at, + rtp.display_name as sender_name + FROM chat_messages cm + JOIN rummy_table_players rtp ON cm.table_id = rtp.table_id AND cm.user_id = rtp.user_id + WHERE cm.table_id = $1 + AND ( + cm.is_private = FALSE + OR cm.user_id = $2 + OR cm.recipient_id = $2 + ) + """ + + params = [table_id, user.sub] + + # Add pagination + if before_id: + query += " AND cm.id < $3" + params.append(before_id) + + query += " ORDER BY cm.created_at DESC, cm.id DESC LIMIT $" + str(len(params) + 1) + params.append(limit + 1) # Fetch one extra to check if there are more + + rows = await conn.fetch(query, *params) + + # Check if there are more messages + has_more = len(rows) > limit + messages_data = rows[:limit] if has_more else rows + + messages = [ + ChatMessage( + id=row["id"], + table_id=row["table_id"], + user_id=row["user_id"], + sender_name=row["sender_name"] or "Anonymous", + message=row["message"], + is_private=row["is_private"], + recipient_id=row["recipient_id"], + created_at=row["created_at"].isoformat() + ) + for row in messages_data + ] + + # Reverse to get chronological order (oldest first) + messages.reverse() + + return GetMessagesResponse( + messages=messages, + has_more=has_more + ) + finally: + await conn.close() diff --git a/cn.ts b/cn.ts new file mode 100644 index 0000000000000000000000000000000000000000..a5ef193506d07d0459fec4f187af08283094d7c8 --- /dev/null +++ b/cn.ts @@ -0,0 +1,6 @@ +import { clsx, type ClassValue } from "clsx"; +import { twMerge } from "tailwind-merge"; + +export function cn(...inputs: ClassValue[]) { + return twMerge(clsx(inputs)); +} diff --git a/db.js b/db.js new file mode 100644 index 0000000000000000000000000000000000000000..b4cb5ae6b307de1cd505fa75bf011b2be52a9a22 --- /dev/null +++ b/db.js @@ -0,0 +1,48 @@ +# Simple asyncpg connection helper for the app +# Uses DATABASE_URL from environment. Provides a shared connection pool. + +import os +import asyncpg +from typing import Optional + +_pool: Optional[asyncpg.Pool] = None + + +async def get_pool() -> asyncpg.Pool: + """Get or create a global asyncpg pool. + + The pool is cached in module state to avoid recreating it for each request. + """ + global _pool + if _pool is None: + dsn = os.environ.get("DATABASE_URL") + if not dsn: + # In Riff, DATABASE_URL is provided as a secret in both dev and prod + raise RuntimeError("DATABASE_URL is not configured") + # Min pool size 1 to keep footprint small; adjust later if needed + _pool = await asyncpg.create_pool(dsn=dsn, min_size=1, max_size=10) + return _pool + + +async def fetchrow(query: str, *args): + pool = await get_pool() + async with pool.acquire() as conn: + return await conn.fetchrow(query, *args) + + +async def fetch(query: str, *args): + pool = await get_pool() + async with pool.acquire() as conn: + return await conn.fetch(query, *args) + + +async def execute(query: str, *args) -> str: + pool = await get_pool() + async with pool.acquire() as conn: + return await conn.execute(query, *args) + + +async def executemany(query: str, args_list): + pool = await get_pool() + async with pool.acquire() as conn: + return await conn.executemany(query, args_list) diff --git a/declare discard buttons.png b/declare discard buttons.png new file mode 100644 index 0000000000000000000000000000000000000000..25c83dc9867b4931f451f81278095ce096eaf836 Binary files /dev/null and b/declare discard buttons.png differ diff --git a/default-theme.ts b/default-theme.ts new file mode 100644 index 0000000000000000000000000000000000000000..8342115f2a8d2a5213ca7a145e94c80fd696b83a --- /dev/null +++ b/default-theme.ts @@ -0,0 +1 @@ +export const DEFAULT_THEME = "dark"; diff --git a/full gaame table page.png b/full gaame table page.png new file mode 100644 index 0000000000000000000000000000000000000000..1dac05fea7cfb323d33627432e6977983ece22c0 --- /dev/null +++ b/full gaame table page.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:9f3c204d01b1324d4955ba2feaaf746dea52ddd26cd65a9eaa9a5ec2ca684d3e +size 176179 diff --git a/game.js b/game.js new file mode 100644 index 0000000000000000000000000000000000000000..f77348d2b0be2e06c39cfcc8ff3b240b8cbccca3 --- /dev/null +++ b/game.js @@ -0,0 +1,1601 @@ + +# Game API - Optimized for <0.40s response times +# Last reload: 2025-11-10 19:35 IST + +from fastapi import APIRouter, HTTPException +from pydantic import BaseModel +from typing import List, Optional +from app.auth import AuthorizedUser +from app.libs.db import fetchrow, fetch, execute +import uuid +import json +import random +import string +from app.libs.scoring import ( + is_sequence, + is_pure_sequence, + is_set, + calculate_deadwood_points, + auto_organize_hand, +) +from app.libs.rummy_models import DeckConfig, deal_initial, StartRoundResponse +import time + +router = APIRouter() + + +class CreateTableRequest(BaseModel): + max_players: int = 4 + disqualify_score: int = 200 + wild_joker_mode: str = "open_joker" # "no_joker", "close_joker", or "open_joker" + ace_value: int = 10 # 1 or 10 + + +class CreateTableResponse(BaseModel): + table_id: str + code: str + + +@router.post("/tables") +async def create_table(body: CreateTableRequest, user: AuthorizedUser) -> CreateTableResponse: + table_id = str(uuid.uuid4()) + # Generate short 6-character alphanumeric code + code = ''.join(random.choices(string.ascii_uppercase + string.digits, k=6)) + + # Single optimized query: create table, fetch profile, and add host as player + result = await fetchrow( + """ + WITH new_table AS ( + INSERT INTO public.rummy_tables (id, code, host_user_id, max_players, disqualify_score, wild_joker_mode, ace_value) + VALUES ($1, $2, $3, $4, $5, $6, $7) + RETURNING id, code + ), + profile_data AS ( + SELECT + COALESCE(display_name, 'Player-' || SUBSTRING($3, LENGTH($3) - 5)) AS display_name, + avatar_url + FROM public.profiles + WHERE user_id = $3 + UNION ALL + SELECT + 'Player-' || SUBSTRING($3, LENGTH($3) - 5) AS display_name, + NULL AS avatar_url + WHERE NOT EXISTS (SELECT 1 FROM public.profiles WHERE user_id = $3) + LIMIT 1 + ), + new_player AS ( + INSERT INTO public.rummy_table_players (table_id, user_id, seat, display_name, profile_image_url) + SELECT $1, $3, 1, profile_data.display_name, profile_data.avatar_url + FROM profile_data + RETURNING seat + ) + SELECT new_table.id, new_table.code + FROM new_table + """, + table_id, + code, + user.sub, + body.max_players, + body.disqualify_score, + body.wild_joker_mode, + body.ace_value, + ) + + return CreateTableResponse(table_id=result["id"], code=result["code"]) + + +class JoinTableRequest(BaseModel): + table_id: str + + +class JoinTableResponse(BaseModel): + table_id: str + seat: int + + +@router.post("/tables/join") +async def join_table(body: JoinTableRequest, user: AuthorizedUser) -> JoinTableResponse: + # Verify table exists and not full + tbl = await fetchrow( + "SELECT id, max_players, status FROM public.rummy_tables WHERE id = $1", + body.table_id, + ) + if not tbl: + raise HTTPException(status_code=404, detail="Table not found") + if tbl["status"] != "waiting": + raise HTTPException(status_code=400, detail="Cannot join: round already started") + + existing = await fetch( + "SELECT seat FROM public.rummy_table_players WHERE table_id = $1 ORDER BY seat", + body.table_id, + ) + used_seats = {r["seat"] for r in existing} + next_seat = 1 + while next_seat in used_seats: + next_seat += 1 + if next_seat > tbl["max_players"]: + raise HTTPException(status_code=400, detail="Table is full") + + # Fetch player display name from profiles table + profile = await fetchrow( + "SELECT display_name FROM public.profiles WHERE user_id = $1", + user.sub + ) + display_name = profile["display_name"] if profile else f"Player-{user.sub[-6:]}" + + await execute( + """ + INSERT INTO public.rummy_table_players (table_id, user_id, seat, display_name) + VALUES ($1, $2, $3, $4) + ON CONFLICT (table_id, user_id) DO NOTHING + """, + body.table_id, + user.sub, + next_seat, + display_name, + ) + return JoinTableResponse(table_id=body.table_id, seat=next_seat) + + +class JoinByCodeRequest(BaseModel): + code: str + + +@router.post("/tables/join-by-code") +async def join_table_by_code(body: JoinByCodeRequest, user: AuthorizedUser) -> JoinTableResponse: + # Verify table exists and get info + tbl = await fetchrow( + "SELECT id, max_players, status FROM public.rummy_tables WHERE code = $1", + body.code.upper() + ) + if not tbl: + raise HTTPException(status_code=404, detail="Table code not found") + if tbl["status"] != "waiting": + raise HTTPException(status_code=400, detail="Cannot join: round already started") + + # Get existing seats + existing = await fetch( + "SELECT seat FROM public.rummy_table_players WHERE table_id = $1 ORDER BY seat", + tbl["id"] + ) + used_seats = {r["seat"] for r in existing} + + # Find next available seat + next_seat = 1 + while next_seat in used_seats: + next_seat += 1 + if next_seat > tbl["max_players"]: + raise HTTPException(status_code=400, detail="Table is full") + + # Fetch player display name from profiles table + profile = await fetchrow( + "SELECT display_name FROM public.profiles WHERE user_id = $1", + user.sub + ) + display_name = profile["display_name"] if profile else f"Player-{user.sub[-6:]}" + + # Add player + await execute( + """INSERT INTO public.rummy_table_players (table_id, user_id, seat, display_name) + VALUES ($1, $2, $3, $4) + ON CONFLICT (table_id, user_id) DO NOTHING""", + tbl["id"], user.sub, next_seat, display_name + ) + + return JoinTableResponse(table_id=tbl["id"], seat=next_seat) + + +class StartGameRequest(BaseModel): + table_id: str + seed: Optional[int] = None + + +@router.post("/start-game") +async def start_game(body: StartGameRequest, user: AuthorizedUser) -> StartRoundResponse: + # Confirm user in table and fetch host + status + game settings + tbl = await fetchrow( + """ + SELECT t.id, t.status, t.host_user_id, t.wild_joker_mode, t.ace_value + FROM public.rummy_tables t + WHERE t.id = $1 + """, + body.table_id, + ) + if not tbl: + raise HTTPException(status_code=404, detail="Table not found") + + member = await fetchrow( + "SELECT 1 FROM public.rummy_table_players WHERE table_id = $1 AND user_id = $2", + body.table_id, + user.sub, + ) + if not member: + raise HTTPException(status_code=403, detail="Not part of the table") + + if tbl["status"] != "waiting": + raise HTTPException(status_code=400, detail="Game already started") + + if tbl["host_user_id"] != user.sub: + raise HTTPException(status_code=403, detail="Only host can start the game") + + players = await fetch( + """ + SELECT user_id + FROM public.rummy_table_players + WHERE table_id = $1 AND is_spectator = false + ORDER BY seat ASC + """, + body.table_id, + ) + user_ids = [r["user_id"] for r in players] + if len(user_ids) < 2: + raise HTTPException(status_code=400, detail="Need at least 2 players to start") + + cfg = DeckConfig(decks=2, include_printed_jokers=True) + deal = deal_initial(user_ids, cfg, body.seed) + + round_id = str(uuid.uuid4()) + number = 1 + + # Game mode logic: + # - no_joker: no wild joker at all + # - close_joker: wild joker exists but hidden initially + # - open_joker: wild joker revealed immediately + game_mode = tbl["wild_joker_mode"] + wild_joker_rank = None + + if game_mode in ["close_joker", "open_joker"]: + # Randomly select wild joker rank (excluding JOKER itself) + all_ranks = ['2', '3', '4', '5', '6', '7', '8', '9', '10', 'J', 'Q', 'K', 'A'] + wild_joker_rank = random.choice(all_ranks) + + hands_serialized = {uid: [c.model_dump() for c in cards] for uid, cards in deal.hands.items()} + stock_serialized = [c.model_dump() for c in deal.stock] + discard_serialized = [c.model_dump() for c in deal.discard] + + await execute( + """ + INSERT INTO public.rummy_rounds (id, table_id, number, printed_joker, wild_joker_rank, stock, discard, hands, active_user_id, game_mode, ace_value) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) + """, + round_id, + body.table_id, + number, + None, + wild_joker_rank, + json.dumps(stock_serialized), + json.dumps(discard_serialized), + json.dumps(hands_serialized), + user_ids[0], + game_mode, + tbl["ace_value"], + ) + + await execute( + "UPDATE public.rummy_tables SET status = 'playing', updated_at = now() WHERE id = $1", + body.table_id, + ) + + discard_top = None + if len(discard_serialized) > 0: + top = discard_serialized[-1] + if top.get("joker") and top.get("rank") == "JOKER": + discard_top = "JOKER" + else: + discard_top = f"{top.get('rank')}{top.get('suit') or ''}" + + return StartRoundResponse( + round_id=round_id, + table_id=body.table_id, + number=number, + active_user_id=user_ids[0], + stock_count=len(stock_serialized), + discard_top=discard_top, + ) + + +# -------- Table info (for lobby/table screen polling) -------- +class PlayerInfo(BaseModel): + user_id: str + seat: int + display_name: Optional[str] = None + profile_image_url: Optional[str] = None + + +class TableInfoResponse(BaseModel): + table_id: str + code: str + status: str + host_user_id: str + max_players: int + disqualify_score: int + game_mode: str + ace_value: int + players: List[PlayerInfo] + current_round_number: Optional[int] = None + active_user_id: Optional[str] = None + + +@router.get("/tables/info") +async def get_table_info(table_id: str, user: AuthorizedUser) -> TableInfoResponse: + """Return basic table state with players and current round info. + Only accessible to the host or seated players. + """ + # Single CTE query combining all data fetches + result = await fetchrow( + """ + WITH table_data AS ( + SELECT id, code, status, host_user_id, max_players, disqualify_score, wild_joker_mode, ace_value + FROM public.rummy_tables + WHERE id = $1 + ), + membership_check AS ( + SELECT EXISTS( + SELECT 1 FROM public.rummy_table_players + WHERE table_id = $1 AND user_id = $2 + ) AS is_member + ), + players_data AS ( + SELECT user_id, seat, display_name, profile_image_url + FROM public.rummy_table_players + WHERE table_id = $1 AND is_spectator = false + ORDER BY seat ASC + ), + last_round_data AS ( + SELECT number, active_user_id + FROM public.rummy_rounds + WHERE table_id = $1 + ORDER BY number DESC + LIMIT 1 + ) + SELECT + t.*, + m.is_member, + COALESCE( + json_agg( + json_build_object( + 'user_id', p.user_id, + 'seat', p.seat, + 'display_name', p.display_name, + 'profile_image_url', p.profile_image_url + ) ORDER BY p.seat + ) FILTER (WHERE p.user_id IS NOT NULL), + '[]' + ) AS players_json, + r.number AS round_number, + r.active_user_id + FROM table_data t + CROSS JOIN membership_check m + LEFT JOIN players_data p ON true + LEFT JOIN last_round_data r ON true + GROUP BY t.id, t.code, t.status, t.host_user_id, t.max_players, t.disqualify_score, + t.wild_joker_mode, t.ace_value, m.is_member, r.number, r.active_user_id + """, + table_id, + user.sub, + ) + + if not result or not result["id"]: + raise HTTPException(status_code=404, detail="Table not found") + + # Check access (host or member) + if result["host_user_id"] != user.sub and not result["is_member"]: + raise HTTPException(status_code=403, detail="You don't have access to this table") + + # Parse players JSON and build response + import json + players_data = json.loads(result["players_json"]) + + players = [ + PlayerInfo( + user_id=p["user_id"], + seat=p["seat"], + display_name=p["display_name"], + profile_image_url=p.get("profile_image_url") + ) + for p in players_data + ] + + return TableInfoResponse( + table_id=result["id"], + code=result["code"], + status=result["status"], + host_user_id=result["host_user_id"], + max_players=result["max_players"], + disqualify_score=result["disqualify_score"], + game_mode=result["wild_joker_mode"], + ace_value=result["ace_value"], + players=players, + current_round_number=result["round_number"], + active_user_id=result["active_user_id"], + ) + + +# -------- Round: My hand -------- +class CardView(BaseModel): + rank: str + suit: Optional[str] = None + joker: bool = False + code: str + + +class RoundMeResponse(BaseModel): + table_id: str + round_number: int + hand: List[CardView] + stock_count: int + discard_top: Optional[str] = None + wild_joker_revealed: bool = False + wild_joker_rank: Optional[str] = None + finished_at: Optional[str] = None + + +@router.get("/round/me") +async def get_round_me(table_id: str, user: AuthorizedUser) -> RoundMeResponse: + """Get current round info for the authenticated user - OPTIMIZED""" + start = time.time() + + # Verify membership + member = await fetchrow( + "SELECT 1 FROM rummy_table_players WHERE table_id = $1 AND user_id = $2", + table_id, user.sub + ) + if not member: + raise HTTPException(status_code=403, detail="Not part of this table") + + # Get table info + table = await fetchrow( + "SELECT wild_joker_mode, ace_value FROM rummy_tables WHERE id = $1", + table_id + ) + + # Get latest round + rnd = await fetchrow( + """SELECT id, number, printed_joker, wild_joker_rank, stock, discard, hands, active_user_id + FROM rummy_rounds + WHERE table_id = $1 + ORDER BY number DESC + LIMIT 1""", + table_id + ) + + if not rnd: + elapsed = time.time() - start + return RoundMeResponse( + table_id=table_id, + round_number=0, + hand=[], + stock_count=0, + discard_top=None, + wild_joker_revealed=False, + wild_joker_rank=None, + finished_at=None + ) + + hands = json.loads(rnd["hands"]) if rnd["hands"] else {} + my_hand_data = hands.get(user.sub, []) + + stock = json.loads(rnd["stock"]) if rnd["stock"] else [] + discard = json.loads(rnd["discard"]) if rnd["discard"] else [] + discard_top_str = None + if discard: + last = discard[-1] + if last.get("joker") and last.get("rank") == "JOKER": + discard_top_str = "JOKER" + else: + discard_top_str = f"{last.get('rank')}{last.get('suit') or ''}" + + # Convert to CardView + def to_code(card: dict) -> str: + if card.get("joker") and card.get("rank") == "JOKER": + return "JOKER" + return f"{card.get('rank')}{card.get('suit') or ''}" + + hand_view = [ + CardView(rank=c.get("rank"), suit=c.get("suit"), joker=bool(c.get("joker")), code=to_code(c)) + for c in my_hand_data + ] + + elapsed = time.time() - start + return RoundMeResponse( + table_id=table_id, + round_number=rnd["number"], + hand=hand_view, + stock_count=len(stock), + discard_top=discard_top_str, + wild_joker_revealed=False, # Will fix this logic later + wild_joker_rank=rnd["wild_joker_rank"], + finished_at=None + ) + + +# -------- Lock Sequence for Wild Joker Reveal -------- +class CardData(BaseModel): + rank: str + suit: Optional[str] = None + +class LockSequenceRequest(BaseModel): + table_id: str + meld: List[CardData] # Array of cards forming the sequence + + +class LockSequenceResponse(BaseModel): + success: bool + message: str + wild_joker_revealed: bool + wild_joker_rank: Optional[str] = None + + +@router.post("/lock-sequence") +async def lock_sequence(body: LockSequenceRequest, user: AuthorizedUser) -> LockSequenceResponse: + """Validate a sequence and reveal wild joker if it's the player's first pure sequence.""" + try: + user_id = user.sub + table_id = body.table_id + # Convert Pydantic CardData objects to dicts for validation functions + meld = [card.model_dump() for card in body.meld] + + # Get current round - USE number DESC for consistency with other endpoints + round_row = await fetchrow( + """ + SELECT id, table_id, wild_joker_rank, players_with_first_sequence + FROM rummy_rounds + WHERE table_id = $1 + ORDER BY number DESC + LIMIT 1 + """, + table_id + ) + + if not round_row: + raise HTTPException(status_code=404, detail="No active round") + + wild_joker_rank = round_row['wild_joker_rank'] + # Parse players_with_first_sequence as JSON list + players_with_seq_raw = round_row['players_with_first_sequence'] + if players_with_seq_raw is None: + players_with_seq = [] + elif isinstance(players_with_seq_raw, str): + players_with_seq = json.loads(players_with_seq_raw) + elif isinstance(players_with_seq_raw, list): + players_with_seq = players_with_seq_raw + else: + players_with_seq = [] + + # Check if user already revealed wild joker + if user_id in players_with_seq: + return LockSequenceResponse( + success=False, + message="✅ You already revealed the wild joker!", + wild_joker_revealed=False, + wild_joker_rank=None + ) + + # Check if THIS player has already revealed their wild joker + has_wild_joker_revealed = user_id in players_with_seq + + # First check if it's a valid sequence + if not is_sequence(meld, wild_joker_rank, has_wild_joker_revealed): + return LockSequenceResponse( + success=False, + message="❌ Invalid sequence - cards must be consecutive in same suit", + wild_joker_revealed=False, + wild_joker_rank=None + ) + + # Then check if it's a PURE sequence (no jokers) + if not is_pure_sequence(meld, wild_joker_rank, has_wild_joker_revealed): + return LockSequenceResponse( + success=False, + message="❌ Only pure sequences can reveal wild joker (no jokers allowed)", + wild_joker_revealed=False, + wild_joker_rank=None + ) + + # Add user to players_with_first_sequence + new_players = list(set(players_with_seq + [user_id])) + await execute( + "UPDATE rummy_rounds SET players_with_first_sequence = $1 WHERE id = $2", + json.dumps(new_players), round_row['id'] + ) + + return LockSequenceResponse( + success=True, + message="✅ Pure sequence locked! Wild Joker revealed!", + wild_joker_revealed=True, + wild_joker_rank=wild_joker_rank + ) + except Exception as e: + raise HTTPException(status_code=500, detail=f"Internal error: {str(e)}") + + +# -------- Core turn actions: draw stock/discard and discard a card -------- +class DrawRequest(BaseModel): + table_id: str + + +class DiscardCard(BaseModel): + rank: str + suit: Optional[str] = None + joker: Optional[bool] = None + + +class DiscardRequest(BaseModel): + table_id: str + card: DiscardCard + + +class DiscardResponse(BaseModel): + table_id: str + round_number: int + hand: List[CardView] + stock_count: int + discard_top: Optional[str] + next_active_user_id: str + + +async def _get_latest_round(table_id: str): + return await fetchrow( + """ + SELECT id, number, stock, discard, hands, active_user_id, finished_at, wild_joker_rank, ace_value, players_with_first_sequence + FROM public.rummy_rounds + WHERE table_id = $1 + ORDER BY number DESC + LIMIT 1 + """, + table_id, + ) + + +async def _assert_member(table_id: str, user_id: str): + membership = await fetchrow( + "SELECT 1 FROM public.rummy_table_players WHERE table_id = $1 AND user_id = $2", + table_id, + user_id, + ) + if not membership: + raise HTTPException(status_code=403, detail="Not part of this table") + + +def _serialize_card_code(card: dict) -> str: + if card.get("joker") and card.get("rank") == "JOKER": + return "JOKER" + return f"{card.get('rank')}{card.get('suit') or ''}" + + +def _hand_view(cards: List[dict]) -> List[CardView]: + return [ + CardView( + rank=c.get("rank"), + suit=c.get("suit"), + joker=bool(c.get("joker")), + code=_serialize_card_code(c), + ) + for c in cards + ] + + +@router.post("/draw/stock") +async def draw_stock(body: DrawRequest, user: AuthorizedUser) -> RoundMeResponse: + start_time = time.time() + # Single query: validate + fetch + update in one transaction + result = await fetchrow( + """ + WITH table_check AS ( + SELECT t.id, t.status, + EXISTS(SELECT 1 FROM public.rummy_table_players WHERE table_id = $1 AND user_id = $2) AS is_member + FROM public.rummy_tables t + WHERE t.id = $1 + ), + round_data AS ( + SELECT id, number, stock, hands, discard, active_user_id, finished_at + FROM public.rummy_rounds + WHERE table_id = $1 + ORDER BY number DESC + LIMIT 1 + ) + SELECT t.id, t.status, t.is_member, r.id AS round_id, r.number, r.stock, r.hands, r.discard, r.active_user_id, r.finished_at + FROM table_check t + LEFT JOIN round_data r ON true + """, + body.table_id, + user.sub, + ) + + if not result or not result["id"]: + raise HTTPException(status_code=404, detail="Table not found") + if result["status"] != "playing": + raise HTTPException(status_code=400, detail="Game not in playing state") + if not result["is_member"]: + raise HTTPException(status_code=403, detail="Not part of the table") + if not result["round_id"]: + raise HTTPException(status_code=404, detail="No active round") + if result["active_user_id"] != user.sub: + raise HTTPException(status_code=403, detail="Not your turn") + + # Parse JSON fields + hands = json.loads(result["hands"]) if isinstance(result["hands"], str) else result["hands"] + stock = json.loads(result["stock"]) if isinstance(result["stock"], str) else result["stock"] + discard = json.loads(result["discard"]) if isinstance(result["discard"], str) else result["discard"] + + my = hands.get(user.sub) + if my is None: + raise HTTPException(status_code=404, detail="No hand for this player") + if len(my) != 13: + raise HTTPException(status_code=400, detail="You must discard before drawing again") + if not stock: + raise HTTPException(status_code=400, detail="Stock is empty") + + drawn = stock.pop() # take top + my.append(drawn) + + await execute( + """ + UPDATE public.rummy_rounds + SET stock = $1::jsonb, hands = $2::jsonb, updated_at = now() + WHERE id = $3 + """, + json.dumps(stock), + json.dumps(hands), + result["round_id"], + ) + + return RoundMeResponse( + table_id=body.table_id, + round_number=result["number"], + hand=_hand_view(my), + stock_count=len(stock), + discard_top=_serialize_card_code(discard[-1]) if discard else None, + finished_at=result["finished_at"].isoformat() if result["finished_at"] else None, + ) + + +@router.post("/draw/discard") +async def draw_discard(body: DrawRequest, user: AuthorizedUser) -> RoundMeResponse: + start_time = time.time() + # Single query: validate + fetch + update in one transaction + result = await fetchrow( + """ + WITH table_check AS ( + SELECT t.id, t.status, + EXISTS(SELECT 1 FROM public.rummy_table_players WHERE table_id = $1 AND user_id = $2) AS is_member + FROM public.rummy_tables t + WHERE t.id = $1 + ), + round_data AS ( + SELECT id, number, stock, hands, discard, active_user_id, finished_at + FROM public.rummy_rounds + WHERE table_id = $1 + ORDER BY number DESC + LIMIT 1 + ) + SELECT t.id, t.status, t.is_member, r.id AS round_id, r.number, r.stock, r.hands, r.discard, r.active_user_id, r.finished_at + FROM table_check t + LEFT JOIN round_data r ON true + """, + body.table_id, + user.sub, + ) + + if not result or not result["id"]: + raise HTTPException(status_code=404, detail="Table not found") + if result["status"] != "playing": + raise HTTPException(status_code=400, detail="Game not in playing state") + if not result["is_member"]: + raise HTTPException(status_code=403, detail="Not part of the table") + if not result["round_id"]: + raise HTTPException(status_code=404, detail="No active round") + if result["active_user_id"] != user.sub: + raise HTTPException(status_code=403, detail="Not your turn") + + # Parse JSON fields + hands = json.loads(result["hands"]) if isinstance(result["hands"], str) else result["hands"] + stock = json.loads(result["stock"]) if isinstance(result["stock"], str) else result["stock"] + discard = json.loads(result["discard"]) if isinstance(result["discard"], str) else result["discard"] + + my = hands.get(user.sub) + if my is None: + raise HTTPException(status_code=404, detail="No hand for this player") + if len(my) != 13: + raise HTTPException(status_code=400, detail="You must discard before drawing again") + if not discard: + raise HTTPException(status_code=400, detail="Discard pile is empty") + + drawn = discard.pop() + my.append(drawn) + + await execute( + """ + UPDATE public.rummy_rounds + SET discard = $1::jsonb, hands = $2::jsonb, updated_at = now() + WHERE id = $3 + """, + json.dumps(discard), + json.dumps(hands), + result["round_id"], + ) + + return RoundMeResponse( + table_id=body.table_id, + round_number=result["number"], + hand=_hand_view(my), + stock_count=len(stock), + discard_top=_serialize_card_code(discard[-1]) if discard else None, + finished_at=result["finished_at"].isoformat() if result["finished_at"] else None, + ) + + +@router.post("/discard") +async def discard_card(body: DiscardRequest, user: AuthorizedUser) -> DiscardResponse: + start_time = time.time() + # Single query: validate + fetch seats + round data + result = await fetchrow( + """ + WITH table_check AS ( + SELECT t.id, t.status, + EXISTS(SELECT 1 FROM public.rummy_table_players WHERE table_id = $1 AND user_id = $2) AS is_member + FROM public.rummy_tables t + WHERE t.id = $1 + ), + round_data AS ( + SELECT id, number, stock, hands, discard, active_user_id + FROM public.rummy_rounds + WHERE table_id = $1 + ORDER BY number DESC + LIMIT 1 + ), + seat_order AS ( + SELECT user_id, seat + FROM public.rummy_table_players + WHERE table_id = $1 AND is_spectator = false + ORDER BY seat ASC + ) + SELECT + t.id, t.status, t.is_member, + r.id AS round_id, r.number, r.stock, r.hands, r.discard, r.active_user_id, + json_agg(s.user_id ORDER BY s.seat) AS user_order + FROM table_check t + LEFT JOIN round_data r ON true + LEFT JOIN seat_order s ON true + GROUP BY t.id, t.status, t.is_member, r.id, r.number, r.stock, r.hands, r.discard, r.active_user_id + """, + body.table_id, + user.sub, + ) + + if not result or not result["id"]: + raise HTTPException(status_code=404, detail="Table not found") + if result["status"] != "playing": + raise HTTPException(status_code=400, detail="Game not in playing state") + if not result["is_member"]: + raise HTTPException(status_code=403, detail="Not part of the table") + if not result["round_id"]: + raise HTTPException(status_code=404, detail="No active round") + if result["active_user_id"] != user.sub: + raise HTTPException(status_code=403, detail="Not your turn") + + # Parse JSON fields + hands = json.loads(result["hands"]) if isinstance(result["hands"], str) else result["hands"] + stock = json.loads(result["stock"]) if isinstance(result["stock"], str) else result["stock"] + discard = json.loads(result["discard"]) if isinstance(result["discard"], str) else result["discard"] + order = json.loads(result["user_order"]) if isinstance(result["user_order"], str) else result["user_order"] + + my = hands.get(user.sub) + if my is None: + raise HTTPException(status_code=404, detail="No hand for this player") + if len(my) != 14: + raise HTTPException(status_code=400, detail="You must draw first before discarding") + + # Remove first matching card + idx_to_remove = None + for i, c in enumerate(my): + if ( + c.get("rank") == body.card.rank + and (c.get("suit") or None) == (body.card.suit or None) + and bool(c.get("joker")) == bool(body.card.joker) + ): + idx_to_remove = i + break + if idx_to_remove is None: + raise HTTPException(status_code=400, detail="Card not found in hand") + + removed = my.pop(idx_to_remove) + discard.append(removed) + + # Find next active user + if user.sub not in order: + raise HTTPException(status_code=400, detail="Player has no seat") + cur_idx = order.index(user.sub) + next_user = order[(cur_idx + 1) % len(order)] + + await execute( + """ + UPDATE public.rummy_rounds + SET discard = $1::jsonb, hands = $2::jsonb, active_user_id = $3, updated_at = now() + WHERE id = $4 + """, + json.dumps(discard), + json.dumps(hands), + next_user, + result["round_id"], + ) + + return DiscardResponse( + table_id=body.table_id, + round_number=result["number"], + hand=_hand_view(my), + stock_count=len(stock), + discard_top=_serialize_card_code(discard[-1]) if discard else None, + next_active_user_id=next_user, + ) + + +# -------- Declaration and scoring -------- +class DeclareRequest(BaseModel): + table_id: str + # For now, accept a simple client-declared payload; server will validate later + # We'll store player's grouped melds as-is and compute naive score 0 if valid later + groups: Optional[List[List[DiscardCard]]] = None + + +class DeclareResponse(BaseModel): + table_id: str + round_number: int + declared_by: str + status: str + + +class ScoreEntry(BaseModel): + user_id: str + points: int + + +class ScoreboardResponse(BaseModel): + table_id: str + round_number: int + scores: List[ScoreEntry] + winner_user_id: Optional[str] = None + + +@router.post("/declare") +async def declare(body: DeclareRequest, user: AuthorizedUser) -> DeclareResponse: + try: + # Declare endpoint - validates meld groups (13 cards) not full hand (can be 14 after draw) + # Only the active player can declare for now + tbl = await fetchrow( + "SELECT id, status FROM public.rummy_tables WHERE id = $1", + body.table_id, + ) + if not tbl: + raise HTTPException(status_code=404, detail="Table not found") + if tbl["status"] != "playing": + raise HTTPException(status_code=400, detail="Game not in playing state") + await _assert_member(body.table_id, user.sub) + + rnd = await _get_latest_round(body.table_id) + if not rnd: + raise HTTPException(status_code=404, detail="No active round") + if rnd["active_user_id"] != user.sub: + raise HTTPException(status_code=403, detail="Only active player may declare") + + # Parse JSON fields from database + hands = json.loads(rnd["hands"]) if isinstance(rnd["hands"], str) else rnd["hands"] + + # Get wild joker rank and ace value for validation and scoring + wild_joker_rank = rnd["wild_joker_rank"] + ace_value = rnd.get("ace_value", 10) # Default to 10 if not set + + # Check if player has revealed wild joker + players_with_first_sequence = rnd.get("players_with_first_sequence") or [] + if isinstance(players_with_first_sequence, str): + try: + players_with_first_sequence = json.loads(players_with_first_sequence) + except: + players_with_first_sequence = [] + has_wild_joker_revealed = user.sub in players_with_first_sequence + + # Get declarer's hand + declarer_hand = hands.get(user.sub) + if not declarer_hand: + raise HTTPException(status_code=404, detail="No hand found for player") + + # Check that player has exactly 14 cards (must have drawn before declaring) + if len(declarer_hand) != 14: + raise HTTPException( + status_code=400, + detail=f"Must have exactly 14 cards to declare. You have {len(declarer_hand)} cards. Please draw a card first." + ) + + # Validate hand if groups are provided + is_valid = False + validation_reason = "" + if body.groups: + # Check that groups contain exactly 13 cards total + total_cards_in_groups = sum(len(group) for group in body.groups) + if total_cards_in_groups != 13: + raise HTTPException( + status_code=400, + detail=f"Groups must contain exactly 13 cards. You provided {total_cards_in_groups} cards." + ) + + # Extract the 14th card (leftover) from hand that's not in groups + # Build count map of declared cards + declared_counts = {} + for group in body.groups: + for card in group: + card_dict = card.model_dump() if hasattr(card, 'model_dump') else card + key = f"{card_dict['rank']}-{card_dict.get('suit', 'null')}" + declared_counts[key] = declared_counts.get(key, 0) + 1 + + # Find the 14th card (not in declared melds) + auto_discard_card = None + temp_counts = declared_counts.copy() + for card in declarer_hand: + key = f"{card['rank']}-{card.get('suit', 'null')}" + if key not in temp_counts or temp_counts[key] == 0: + auto_discard_card = card + break + else: + temp_counts[key] -= 1 + + if not auto_discard_card: + raise HTTPException(status_code=500, detail="Could not identify 14th card") + + # Remove card from declarer's hand + updated_hand = [c for c in declarer_hand if c != auto_discard_card] + hands[user.sub] = updated_hand + + # Add to discard pile + discard_pile = json.loads(rnd["discard"]) if isinstance(rnd["discard"], str) else (rnd["discard"] or []) + discard_pile.append(auto_discard_card) + + # Update game state with auto-discard + await execute( + "UPDATE public.rummy_rounds SET hands = $1::jsonb, discard = $2::jsonb WHERE id = $3", + json.dumps(hands), + json.dumps(discard_pile), + rnd["id"] + ) + + # Valid declaration: declarer gets 0 points, others get deadwood points + scores: dict = {} + organized_melds_all_players = {} + for uid, cards in hands.items(): + if uid == user.sub: + scores[uid] = 0 + # Store winner's declared melds - categorize them properly + winner_pure_seqs = [] + winner_seqs = [] + winner_sets = [] + for group in body.groups: + # Convert DiscardCard to dict for JSON serialization + group_dicts = [card.model_dump() if hasattr(card, 'model_dump') else card for card in group] + if is_pure_sequence(group_dicts, wild_joker_rank, has_wild_joker_revealed): + winner_pure_seqs.append(group_dicts) + elif is_sequence(group_dicts, wild_joker_rank, has_wild_joker_revealed): + winner_seqs.append(group_dicts) + elif is_set(group_dicts, wild_joker_rank, has_wild_joker_revealed): + winner_sets.append(group_dicts) + + organized_melds_all_players[uid] = { + "pure_sequences": winner_pure_seqs, + "sequences": winner_seqs, + "sets": winner_sets, + "deadwood": [] + } + else: + # Auto-organize opponent's hand to find best possible melds + opponent_has_revealed = uid in players_with_first_sequence + opponent_melds, opponent_leftover = auto_organize_hand( + cards, wild_joker_rank, opponent_has_revealed + ) + # Score only the ungrouped deadwood cards + scores[uid] = calculate_deadwood_points( + opponent_leftover, wild_joker_rank, opponent_has_revealed, ace_value + ) + # Convert opponent melds to plain dicts and categorize them + opponent_melds_dicts = [ + [card.dict() if hasattr(card, 'dict') else card for card in meld] + for meld in opponent_melds + ] + opponent_leftover_dicts = [ + card.dict() if hasattr(card, 'dict') else card for card in opponent_leftover + ] + + # Categorize opponent melds + opp_pure_seqs = [] + opp_seqs = [] + opp_sets = [] + for meld in opponent_melds_dicts: + if is_pure_sequence(meld, wild_joker_rank, opponent_has_revealed): + opp_pure_seqs.append(meld) + elif is_sequence(meld, wild_joker_rank, opponent_has_revealed): + opp_seqs.append(meld) + elif is_set(meld, wild_joker_rank, opponent_has_revealed): + opp_sets.append(meld) + + # Store opponent's auto-organized melds + organized_melds_all_players[uid] = { + "pure_sequences": opp_pure_seqs, + "sequences": opp_seqs, + "sets": opp_sets, + "deadwood": opponent_leftover_dicts + } + else: + # Invalid declaration: declarer gets FULL hand deadwood points (80 cap), others get 0 + has_revealed = user.sub in players_with_first_sequence + declarer_deadwood_pts = calculate_deadwood_points(declarer_hand, wild_joker_rank, has_revealed, ace_value) + for uid, cards in hands.items(): + if uid == user.sub: + scores[uid] = min(declarer_deadwood_pts, 80) # Cap at 80 + # Store declarer's ungrouped cards as all deadwood + declarer_cards_dicts = [ + card.dict() if hasattr(card, 'dict') else card for card in declarer_hand + ] + organized_melds_all_players[uid] = { + "pure_sequences": [], + "sequences": [], + "sets": [], + "deadwood": declarer_cards_dicts + } + else: + scores[uid] = 0 + # Opponents don't lose points when someone else's declaration fails + organized_melds_all_players[uid] = { + "pure_sequences": [], + "sequences": [], + "sets": [], + "deadwood": [] + } + + # Store the declaration with validation status + declaration_data = { + "groups": [[card.model_dump() if hasattr(card, 'model_dump') else card for card in group] for group in body.groups] if body.groups else [], + "valid": is_valid, + "reason": validation_reason, + "revealed_hands": hands, # Already plain dicts from JSON parse + "organized_melds": organized_melds_all_players + } + + await execute( + """ + UPDATE public.rummy_rounds + SET winner_user_id = $1, scores = $2::jsonb, declarations = jsonb_set(COALESCE(declarations, '{}'::jsonb), $3, $4::jsonb, true), finished_at = now(), updated_at = now() + WHERE id = $5 + """, + user.sub if is_valid else None, # Only set winner if valid + json.dumps(scores), # Convert dict to JSON string for JSONB + [user.sub], + json.dumps(declaration_data), # Convert dict to JSON string for JSONB + rnd["id"], + ) + + # Return success response (valid or invalid declaration both complete the round) + return DeclareResponse( + table_id=body.table_id, + round_number=rnd["number"], + declared_by=user.sub, + status="valid" if is_valid else "invalid" + ) + except HTTPException: + raise # Re-raise HTTP exceptions as-is + except Exception as e: + raise HTTPException(status_code=500, detail=f"Internal error: {str(e)}") + + +class RevealedHandsResponse(BaseModel): + table_id: str + round_number: int + winner_user_id: Optional[str] = None + revealed_hands: dict[str, List[dict]] # user_id -> list of cards + organized_melds: dict[str, dict] # user_id -> {pure_sequences: [...], impure_sequences: [...], sets: [...], ungrouped: [...]} + scores: dict[str, int] # user_id -> points + player_names: dict[str, str] # user_id -> display_name + is_finished: bool + + +@router.get("/round/revealed-hands") +async def get_revealed_hands(table_id: str, user: AuthorizedUser) -> RevealedHandsResponse: + """Get all players' revealed hands and organized melds after declaration.""" + try: + # Fetch the current round + rnd = await fetchrow( + """ + SELECT id, number, finished_at, declarations, hands, scores, winner_user_id + FROM public.rummy_rounds + WHERE table_id=$1 + ORDER BY number DESC + LIMIT 1 + """, + table_id + ) + + if not rnd: + raise HTTPException(status_code=404, detail="No round found") + + if not rnd["finished_at"]: + raise HTTPException(status_code=400, detail="Round not finished") + + # Get player information for names + players_rows = await fetch( + """ + SELECT user_id, display_name + FROM public.rummy_table_players + WHERE table_id=$1 + """, + table_id + ) + player_names = {p["user_id"]: p["display_name"] or "Player" for p in players_rows} + + # Extract data from the round + revealed_hands = rnd.get("hands", {}) + scores = rnd.get("scores", {}) + declarations = rnd.get("declarations", {}) + + # Extract organized_melds from declarations + organized_melds = {} + for uid, decl_data in declarations.items(): + if isinstance(decl_data, dict) and "organized_melds" in decl_data: + organized_melds[uid] = decl_data["organized_melds"] + else: + organized_melds[uid] = { + "pure_sequences": [], + "sequences": [], + "sets": [], + "deadwood": [] + } + + try: + response = RevealedHandsResponse( + table_id=table_id, + round_number=rnd["number"], + winner_user_id=rnd["winner_user_id"], + revealed_hands=revealed_hands, + organized_melds=organized_melds, + scores=scores, + player_names=player_names, + is_finished=rnd["finished_at"] is not None + ) + return response + except Exception as e: + raise HTTPException(status_code=500, detail=f"Failed to construct response: {str(e)}") + except HTTPException: + raise + except Exception as e: + raise HTTPException(status_code=500, detail=f"Endpoint failed: {str(e)}") + + +@router.get("/round/scoreboard") +async def round_scoreboard(table_id: str, user: AuthorizedUser) -> ScoreboardResponse: + await _assert_member(table_id, user.sub) + rnd = await fetchrow( + """ + SELECT number, scores, winner_user_id, points_accumulated + FROM public.rummy_rounds + WHERE table_id = $1 + ORDER BY number DESC + LIMIT 1 + """, + table_id, + ) + if not rnd: + raise HTTPException(status_code=404, detail="No round found") + + scores = rnd["scores"] or {} + + # CRITICAL: Accumulate round scores to total_points ONLY ONCE + # Check if points have already been accumulated for this round + if not rnd.get("points_accumulated", False): + for user_id, round_points in scores.items(): + await execute( + """UPDATE public.rummy_table_players + SET total_points = total_points + $1 + WHERE table_id = $2 AND user_id = $3""", + int(round_points), + table_id, + user_id + ) + + # Mark this round as accumulated + await execute( + """UPDATE public.rummy_rounds + SET points_accumulated = TRUE + WHERE table_id = $1 AND number = $2""", + table_id, + rnd["number"] + ) + + entries = [ScoreEntry(user_id=uid, points=int(val)) for uid, val in scores.items()] + return ScoreboardResponse( + table_id=table_id, + round_number=rnd["number"], + scores=entries, + winner_user_id=rnd["winner_user_id"], + ) + + +class NextRoundRequest(BaseModel): + table_id: str + + +class NextRoundResponse(BaseModel): + table_id: str + number: int + active_user_id: str + + +@router.post("/round/next") +async def start_next_round(body: NextRoundRequest, user: AuthorizedUser) -> NextRoundResponse: + # Host only for next-round + tbl = await fetchrow( + "SELECT id, host_user_id, status, disqualify_score FROM public.rummy_tables WHERE id = $1", + body.table_id, + ) + if not tbl: + raise HTTPException(status_code=404, detail="Table not found") + await _assert_member(body.table_id, user.sub) + if tbl["host_user_id"] != user.sub: + raise HTTPException(status_code=403, detail="Only host can start next round") + + # Check last round is finished + last = await fetchrow( + """ + SELECT id, number, finished_at + FROM public.rummy_rounds + WHERE table_id = $1 + ORDER BY number DESC + LIMIT 1 + """, + body.table_id, + ) + if not last or not last["finished_at"]: + raise HTTPException(status_code=400, detail="Last round not finished yet") + + # Disqualify any players reaching threshold + th = int(tbl["disqualify_score"]) + players = await fetch( + "SELECT user_id, total_points FROM public.rummy_table_players WHERE table_id = $1 ORDER BY seat ASC", + body.table_id, + ) + active_user_ids = [] + for p in players: + uid = p["user_id"] + total = int(p["total_points"]) + if total >= th: + await execute( + "UPDATE public.rummy_table_players SET disqualified = true, eliminated_at = now() WHERE table_id = $1 AND user_id = $2", + body.table_id, + uid, + ) + else: + active_user_ids.append(uid) + + if len(active_user_ids) < 2: + # End table + await execute("UPDATE public.rummy_tables SET status = 'finished', updated_at = now() WHERE id = $1", body.table_id) + raise HTTPException(status_code=400, detail="Not enough players for next round; table finished") + + # Create new round with fresh deal, rotate starting player (winner starts) + cfg = DeckConfig(decks=2, include_printed_jokers=True) + deal = deal_initial(active_user_ids, cfg, None) + + new_round_id = str(uuid.uuid4()) + next_round_number = int(last["number"]) + 1 + + hands_serialized = {uid: [c.model_dump() for c in cards] for uid, cards in deal.hands.items()} + stock_serialized = [c.model_dump() for c in deal.stock] + discard_serialized = [c.model_dump() for c in deal.discard] + + # Fetch table settings including game mode and ace value + tbl = await fetchrow( + "SELECT id, status, host_user_id, max_players, wild_joker_mode, ace_value FROM public.rummy_tables WHERE id = $1", + body.table_id, + ) + + wild_joker_mode = tbl["wild_joker_mode"] + ace_value = tbl["ace_value"] + + # Determine wild joker based on game mode + if wild_joker_mode == "no_joker": + wild_joker_rank = None # No wild joker in this mode + else: + # Pick a random wild joker rank (excluding printed joker) + ranks = ['2', '3', '4', '5', '6', '7', '8', '9', '10', 'J', 'Q', 'K', 'A'] + wild_joker_rank = random.choice(ranks) + + await execute( + """ + INSERT INTO public.rummy_rounds ( + id, table_id, number, printed_joker, wild_joker_rank, + stock, discard, hands, active_user_id, game_mode, ace_value + ) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) + """, + new_round_id, + body.table_id, + next_round_number, + None, + wild_joker_rank, + json.dumps(stock_serialized), + json.dumps(discard_serialized), + json.dumps(hands_serialized), + active_user_ids[0], + wild_joker_mode, + ace_value, + ) + + await execute( + "UPDATE public.rummy_tables SET status = 'playing', updated_at = now() WHERE id = $1", + body.table_id, + ) + + return NextRoundResponse( + table_id=body.table_id, + number=next_round_number, + active_user_id=active_user_ids[0], + ) + +@router.get("/round/history") +async def get_round_history(table_id: str, user: AuthorizedUser): + """Get all completed round history for the current table.""" + # Single CTE query combining all checks and data fetches with JOINs + rows = await fetch( + """ + WITH table_check AS ( + SELECT id FROM public.rummy_tables WHERE id = $1 + ), + membership_check AS ( + SELECT EXISTS ( + SELECT 1 FROM public.rummy_table_players + WHERE table_id = $1 AND user_id = $2 + ) AS is_member + ), + player_names AS ( + SELECT user_id, display_name + FROM public.rummy_table_players + WHERE table_id = $1 + ) + SELECT + r.number AS round_number, + r.winner_user_id, + r.scores, + COALESCE( + json_object_agg( + p.user_id, + COALESCE(p.display_name, 'Player') + ) FILTER (WHERE p.user_id IS NOT NULL), + '{}' + ) AS player_names_map, + (SELECT is_member FROM membership_check) AS is_member, + (SELECT id FROM table_check) AS table_exists + FROM public.rummy_rounds r + LEFT JOIN player_names p ON true + WHERE r.table_id = $1 AND r.finished_at IS NOT NULL + GROUP BY r.id, r.number, r.winner_user_id, r.scores + ORDER BY r.number ASC + """, + table_id, + user.sub + ) + + if not rows or rows[0]["table_exists"] is None: + raise HTTPException(status_code=404, detail="Table not found") + + if not rows[0]["is_member"]: + raise HTTPException(status_code=403, detail="You don't have access to this table") + + # Build round history + import json + round_history = [] + for row in rows: + player_names = json.loads(row["player_names_map"]) + scores_dict = row["scores"] or {} + players_list = [ + { + "user_id": user_id, + "player_name": player_names.get(user_id, "Player"), + "score": score + } + for user_id, score in scores_dict.items() + ] + # Sort by score ascending (winner has lowest score) + players_list.sort(key=lambda p: p["score"]) + + round_history.append({ + "round_number": row["round_number"], + "winner_user_id": row["winner_user_id"], + "players": players_list + }) + + return {"rounds": round_history} + + +# ===== DROP ENDPOINT ===== + +class DropRequest(BaseModel): + table_id: str + +class DropResponse(BaseModel): + success: bool + penalty_points: int + +@router.post("/game/drop") +async def drop_game(body: DropRequest, user: AuthorizedUser) -> DropResponse: + """Player drops before first draw (20pt penalty, 2+ players).""" + result = await fetchrow( + """WITH round_data AS ( + SELECT id, hands, active_user_id + FROM public.rummy_rounds + WHERE table_id = $1 + ORDER BY number DESC LIMIT 1 + ), + player_count AS ( + SELECT COUNT(*) as cnt + FROM public.rummy_table_players + WHERE table_id = $1 AND is_spectator = false + ) + SELECT r.id, r.hands, r.active_user_id, p.cnt as player_count + FROM round_data r, player_count p""", + body.table_id + ) + + if not result: + raise HTTPException(status_code=404, detail="No active round") + if result["player_count"] < 2: + raise HTTPException(status_code=400, detail="Need 2+ players to drop") + + hands = json.loads(result["hands"]) if isinstance(result["hands"], str) else result["hands"] + my_hand = hands.get(user.sub) + if not my_hand or len(my_hand) != 13: + raise HTTPException(status_code=400, detail="Can only drop before drawing first card") + + await execute( + """UPDATE public.rummy_table_players + SET is_spectator = true, total_points = total_points + 20, eliminated_at = now() + WHERE table_id = $1 AND user_id = $2""", + body.table_id, user.sub + ) + + return DropResponse(success=True, penalty_points=20) + + +# ===== SPECTATE ENDPOINTS ===== + +class SpectateRequest(BaseModel): + table_id: str + player_id: str + +class GrantSpectateRequest(BaseModel): + table_id: str + spectator_id: str + granted: bool + +@router.post("/game/request-spectate") +async def request_spectate(body: SpectateRequest, user: AuthorizedUser): + """Request permission to spectate a player.""" + spectator = await fetchrow( + "SELECT is_spectator FROM public.rummy_table_players WHERE table_id = $1 AND user_id = $2", + body.table_id, user.sub + ) + if not spectator or not spectator["is_spectator"]: + raise HTTPException(status_code=403, detail="Must be eliminated to spectate") + + await execute( + """INSERT INTO public.spectate_permissions (table_id, spectator_id, player_id, granted) + VALUES ($1, $2, $3, false) + ON CONFLICT DO NOTHING""", + body.table_id, user.sub, body.player_id + ) + return {"success": True} + +@router.post("/game/grant-spectate") +async def grant_spectate(body: GrantSpectateRequest, user: AuthorizedUser): + """Player grants/denies spectate permission.""" + await execute( + """UPDATE public.spectate_permissions + SET granted = $1 + WHERE table_id = $2 AND spectator_id = $3 AND player_id = $4""", + body.granted, body.table_id, body.spectator_id, user.sub + ) + return {"success": True} + diff --git a/head.html b/head.html new file mode 100644 index 0000000000000000000000000000000000000000..61ac5851fd12fab72280c6bb50292daad2d9b897 --- /dev/null +++ b/head.html @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/health.js b/health.js new file mode 100644 index 0000000000000000000000000000000000000000..6d0b6ae093a65e5cd23d1920ef30c79954dc295f --- /dev/null +++ b/health.js @@ -0,0 +1,13 @@ +from fastapi import APIRouter +from pydantic import BaseModel + +router = APIRouter() + + +class HealthResponse(BaseModel): + ok: bool + + +@router.get("/health-check") +def health_check() -> HealthResponse: + return HealthResponse(ok=True) diff --git a/index.css b/index.css new file mode 100644 index 0000000000000000000000000000000000000000..a4da1c1f7f2704fbde4738fbfdd9522b8cbcc6ce --- /dev/null +++ b/index.css @@ -0,0 +1,101 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; + +@layer base { + :root { + --background: 0 0% 100%; + --foreground: 240 10% 3.9%; + --card: 0 0% 100%; + --card-foreground: 240 10% 3.9%; + --popover: 0 0% 100%; + --popover-foreground: 240 10% 3.9%; + --primary: 240 5.9% 10%; + --primary-foreground: 0 0% 98%; + --secondary: 240 4.8% 95.9%; + --secondary-foreground: 240 5.9% 10%; + --muted: 240 4.8% 95.9%; + --muted-foreground: 240 3.8% 46.1%; + --accent: 240 4.8% 95.9%; + --accent-foreground: 240 5.9% 10%; + --destructive: 0 84.2% 60.2%; + --destructive-foreground: 0 0% 98%; + --border: 240 5.9% 90%; + --input: 240 5.9% 90%; + --ring: 240 5.9% 10%; + --radius: 0.5rem; + --chart-1: 12 76% 61%; + --chart-2: 173 58% 39%; + --chart-3: 197 37% 24%; + --chart-4: 43 74% 66%; + --chart-5: 27 87% 67%; + } + + .dark { + /* Rich dark gaming background - deep charcoal */ + --background: 220 15% 12%; + --foreground: 0 0% 98%; + + /* Card surfaces - slightly lighter for contrast */ + --card: 220 13% 16%; + --card-foreground: 0 0% 98%; + + /* Popover surfaces */ + --popover: 220 13% 14%; + --popover-foreground: 0 0% 98%; + + /* Primary - felt-green table surface inspiration */ + --primary: 150 40% 35%; + --primary-foreground: 0 0% 98%; + + /* Secondary - neutral darker tones */ + --secondary: 220 12% 22%; + --secondary-foreground: 0 0% 98%; + + /* Muted elements - subtle backgrounds */ + --muted: 220 12% 20%; + --muted-foreground: 220 8% 65%; + + /* Accent - warm accent for impure melds and highlights */ + --accent: 25 75% 55%; + --accent-foreground: 220 15% 12%; + + /* Destructive actions */ + --destructive: 0 70% 50%; + --destructive-foreground: 0 0% 98%; + + /* Borders - subtle separation */ + --border: 220 12% 24%; + --input: 220 12% 24%; + + /* Ring/focus - felt-green tint */ + --ring: 150 40% 45%; + + /* Chart colors - distinct for game stats */ + --chart-1: 150 50% 45%; + --chart-2: 200 60% 50%; + --chart-3: 25 75% 55%; + --chart-4: 280 50% 60%; + --chart-5: 45 80% 60%; + } +} + + +@layer base { + * { + @apply border-border; + } + body { + @apply bg-background text-foreground; + font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarell', sans-serif; + font-feature-settings: "rlig" 1, "calt" 1, "tnum" 1; + font-weight: 400; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + } + + h1, h2, h3, h4, h5, h6 { + font-weight: 500; + } +} + diff --git a/ing game feautures.png b/ing game feautures.png new file mode 100644 index 0000000000000000000000000000000000000000..8673527b37e68bee0672c82fdee2f3d54d7fc60f Binary files /dev/null and b/ing game feautures.png differ diff --git a/lobby.png b/lobby.png new file mode 100644 index 0000000000000000000000000000000000000000..9c44c75ffd275daae1b4630f017feb7520e8a8e3 Binary files /dev/null and b/lobby.png differ diff --git a/meld section.png b/meld section.png new file mode 100644 index 0000000000000000000000000000000000000000..46ba6fca55165c8bdd2fb00dd09e69632aba957b Binary files /dev/null and b/meld section.png differ diff --git a/profiles.js b/profiles.js new file mode 100644 index 0000000000000000000000000000000000000000..507419327ad6b8cc2f71817df5712ab1935129ea --- /dev/null +++ b/profiles.js @@ -0,0 +1,43 @@ +from fastapi import APIRouter, HTTPException +from pydantic import BaseModel +from app.auth import AuthorizedUser +from app.libs.db import execute +import os +import requests + +router = APIRouter() + +class UserProfile(BaseModel): + user_id: str + display_name: str | None + avatar_url: str | None + +@router.get("/me") +async def get_my_profile(user: AuthorizedUser) -> UserProfile: + user_id = user.sub + # Stack Auth may not provide display_name or profile_image_url + display_name = getattr(user, 'display_name', None) or getattr(user, 'client_metadata', {}).get('display_name') or 'Player' + avatar_url = getattr(user, 'profile_image_url', None) or getattr(user, 'client_metadata', {}).get('avatar_url') or '' + + print(f"🔄 Syncing profile for user {user_id}: {display_name} - {avatar_url}") + + # Insert or update profile in database + await execute( + """ + INSERT INTO profiles (user_id, display_name, avatar_url) + VALUES ($1, $2, $3) + ON CONFLICT (user_id) DO UPDATE + SET display_name = EXCLUDED.display_name, + avatar_url = EXCLUDED.avatar_url, + updated_at = NOW() + """, + user_id, display_name, avatar_url + ) + + print(f"✅ Profile synced successfully for {user_id}") + + return UserProfile( + user_id=user_id, + display_name=display_name, + avatar_url=avatar_url + ) diff --git a/room created.png b/room created.png new file mode 100644 index 0000000000000000000000000000000000000000..6acf69e0cc8d3c6dd8bc146e3c4215a135fa69d4 --- /dev/null +++ b/room created.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:b7da03158c68bf54487dd33e16f2fd52da16addbbdf831570d8517c6df27c3f9 +size 178389 diff --git a/rummy_engine.js b/rummy_engine.js new file mode 100644 index 0000000000000000000000000000000000000000..b10088663eed393f377567fd9a821aae17664ea7 --- /dev/null +++ b/rummy_engine.js @@ -0,0 +1,147 @@ +"""Core rummy game engine with validation and scoring logic""" +import asyncpg +import random +from typing import List, Dict, Tuple, Optional + +# Card deck constants +SUITS = ['H', 'D', 'C', 'S'] # Hearts, Diamonds, Clubs, Spades +RANKS = ['A', '2', '3', '4', '5', '6', '7', '8', '9', '10', 'J', 'Q', 'K'] + +def create_deck(num_decks: int = 2) -> List[str]: + """Create shuffled deck with jokers""" + deck = [] + for _ in range(num_decks): + # Regular cards + for suit in SUITS: + for rank in RANKS: + deck.append(f"{rank}{suit}") + # Printed jokers (2 per deck) + deck.append("JKR") + deck.append("JKR") + + random.shuffle(deck) + return deck + +def calculate_card_points(card: str) -> int: + """Calculate points for a single card""" + if card == "JKR": + return 0 + + rank = card[:-1] # Remove suit + if rank in ['J', 'Q', 'K', 'A']: + return 10 + return int(rank) + +def validate_sequence(cards: List[str]) -> Tuple[bool, bool]: + """Validate if cards form a sequence. Returns (is_valid, is_pure)""" + if len(cards) < 3: + return False, False + + # Check for jokers + has_joker = any(c == "JKR" for c in cards) + + # Extract suits and ranks + suits = [c[-1] for c in cards if c != "JKR"] + ranks = [c[:-1] for c in cards if c != "JKR"] + + # All non-joker cards must be same suit + if len(set(suits)) > 1: + return False, False + + # Check consecutive ranks + rank_order = {'A': 1, '2': 2, '3': 3, '4': 4, '5': 5, '6': 6, '7': 7, '8': 8, '9': 9, '10': 10, 'J': 11, 'Q': 12, 'K': 13} + rank_values = sorted([rank_order[r] for r in ranks]) + + # With jokers, check if remaining cards can form sequence + if has_joker: + # Simple validation for now + return True, False + + # Pure sequence check + for i in range(1, len(rank_values)): + if rank_values[i] != rank_values[i-1] + 1: + return False, False + + return True, True + +def validate_set(cards: List[str]) -> bool: + """Validate if cards form a set (same rank, different suits)""" + if len(cards) < 3: + return False + + # Remove jokers for validation + non_jokers = [c for c in cards if c != "JKR"] + if len(non_jokers) < 2: + return False + + # All non-joker cards must have same rank + ranks = [c[:-1] for c in non_jokers] + if len(set(ranks)) > 1: + return False + + # All non-joker cards must have different suits + suits = [c[-1] for c in non_jokers] + if len(suits) != len(set(suits)): + return False + + return True + +async def deal_initial_hands(conn: asyncpg.Connection, table_id: str, round_num: int, player_ids: List[str]): + """Deal initial 13 cards to each player for a new round""" + num_players = len(player_ids) + deck = create_deck(num_decks=2 if num_players <= 4 else 3) + + # Deal 13 cards to each player + for i, player_id in enumerate(player_ids): + hand = deck[i*13:(i+1)*13] + await conn.execute( + """ + INSERT INTO player_rounds (table_id, round_number, user_id, hand, drawn_card, has_drawn, status) + VALUES ($1, $2, $3, $4, NULL, FALSE, 'playing') + ON CONFLICT (table_id, round_number, user_id) + DO UPDATE SET hand = $4, drawn_card = NULL, has_drawn = FALSE, status = 'playing' + """, + table_id, round_num, player_id, hand + ) + + # Remaining cards go to stock pile + stock_pile = deck[num_players*13:] + + # Initialize round state + await conn.execute( + """ + INSERT INTO round_state (table_id, round_number, stock_pile, discard_pile, current_turn_index) + VALUES ($1, $2, $3, '{}', 0) + ON CONFLICT (table_id, round_number) + DO UPDATE SET stock_pile = $3, discard_pile = '{}', current_turn_index = 0 + """, + table_id, round_num, stock_pile + ) + +async def validate_declaration(conn: asyncpg.Connection, table_id: str, round_num: int, user_id: str) -> Tuple[bool, int, str]: + """Validate a player's declaration. Returns (is_valid, points, message)""" + # Get player's melds + melds = await conn.fetchval( + "SELECT locked_sequences FROM player_rounds WHERE table_id = $1 AND round_number = $2 AND user_id = $3", + table_id, round_num, user_id + ) + + if not melds: + return False, 0, "No melds declared" + + # Must have at least 2 melds with one pure sequence + if len(melds) < 2: + return False, 0, "Need at least 2 melds (1 pure sequence + 1 other)" + + has_pure_sequence = False + for meld in melds: + is_valid, is_pure = validate_sequence(meld) + if is_valid and is_pure: + has_pure_sequence = True + break + + if not has_pure_sequence: + return False, 0, "Must have at least one pure sequence" + + # Valid declaration = 0 points + return True, 0, "Valid declaration!" diff --git a/rummy_models.js b/rummy_models.js new file mode 100644 index 0000000000000000000000000000000000000000..d68c63a8dd4ead262cbe3daf27020f29d76a415f --- /dev/null +++ b/rummy_models.js @@ -0,0 +1,107 @@ +# Data models and helpers for Rummy engine +# These are pure-Python helpers used by FastAPI endpoints. +from __future__ import annotations +from pydantic import BaseModel, Field +from typing import List, Literal, Optional, Dict, Tuple +import random + +Rank = Literal["A","2","3","4","5","6","7","8","9","10","J","Q","K", "JOKER"] +Suit = Literal["S","H","D","C"] + +class Card(BaseModel): + rank: Rank + suit: Optional[Suit] = None # Jokers have no suit + joker: bool = False # True if joker (printed or wild) + + def code(self) -> str: + if self.joker and self.rank == "JOKER": + return "JOKER" + return f"{self.rank}{self.suit or ''}" + +class DeckConfig(BaseModel): + decks: int = 2 # standard: 2 decks for up to 6 players + include_printed_jokers: bool = True + +RANKS: List[Rank] = ["A","2","3","4","5","6","7","8","9","10","J","Q","K"] +SUITS: List[Suit] = ["S","H","D","C"] + +class ShuffledDeck(BaseModel): + cards: List[Card] + + def draw(self) -> Card: + return self.cards.pop() # draw from top (end) + + +def build_deck(cfg: DeckConfig) -> List[Card]: + cards: List[Card] = [] + for _ in range(cfg.decks): + for s in SUITS: + for r in RANKS: + cards.append(Card(rank=r, suit=s, joker=False)) + if cfg.include_printed_jokers: + # Two printed jokers per deck typical + cards.append(Card(rank="JOKER", suit=None, joker=True)) + cards.append(Card(rank="JOKER", suit=None, joker=True)) + return cards + + +def fair_shuffle(cards: List[Card], seed: Optional[int] = None) -> ShuffledDeck: + rnd = random.Random(seed) + # Use Fisher-Yates via random.shuffle + cards_copy = list(cards) + rnd.shuffle(cards_copy) + return ShuffledDeck(cards=cards_copy) + + +class DealResult(BaseModel): + hands: Dict[str, List[Card]] # user_id -> 13 cards + stock: List[Card] + discard: List[Card] + printed_joker: Optional[Card] + + +def deal_initial(user_ids: List[str], cfg: DeckConfig, seed: Optional[int] = None) -> DealResult: + deck = fair_shuffle(build_deck(cfg), seed) + # Flip printed joker from stock top (non-player) + printed_joker: Optional[Card] = None + + # Deal 13 to each player, round-robin + hands: Dict[str, List[Card]] = {u: [] for u in user_ids} + # Pre-draw a printed joker to reveal if present (optional rule) + # We'll reveal the first printed joker encountered when drawing discard initial card + + # Draw initial discard card + discard: List[Card] = [] + + # Distribute 13 cards + for i in range(13): + for u in user_ids: + hands[u].append(deck.draw()) + + # Reveal top card to discard; if joker, keep discarding until a non-joker to start + while True: + if not deck.cards: + break + top = deck.draw() + if top.joker: + discard.append(top) + continue + discard.append(top) + break + + return DealResult(hands=hands, stock=deck.cards, discard=discard, printed_joker=printed_joker) + + +class StartRoundRequest(BaseModel): + table_id: str + user_ids: List[str] = Field(min_items=2, max_items=6) + disqualify_score: int = 200 + seed: Optional[int] = None + +class StartRoundResponse(BaseModel): + round_id: str + table_id: str + number: int + active_user_id: str + stock_count: int + discard_top: Optional[str] diff --git a/scoreboard.jpg b/scoreboard.jpg new file mode 100644 index 0000000000000000000000000000000000000000..21d4aea85b6dc8ed92294981f53a82ff9fcb0306 Binary files /dev/null and b/scoreboard.jpg differ diff --git a/scoring.js b/scoring.js new file mode 100644 index 0000000000000000000000000000000000000000..eb5af277b56769a01e8151e38d425c7b7ba6da9a --- /dev/null +++ b/scoring.js @@ -0,0 +1,471 @@ + + + + +# Simple Rummy scoring utilities +# Points: Face cards (J,Q,K,A)=10, 2-10 face value, jokers=0. Cap per hand: 80. +from __future__ import annotations +from typing import List, Dict, Tuple, Optional, Union + +# Card dict shape: {rank: str, suit: str | None, joker: bool} +# Can also be Pydantic models with rank, suit, joker attributes + +RANK_POINTS = { + "A": 10, # Default, can be overridden + "K": 10, + "Q": 10, + "J": 10, + "10": 10, + "9": 9, + "8": 8, + "7": 7, + "6": 6, + "5": 5, + "4": 4, + "3": 3, + "2": 2, +} + +RANK_ORDER = ["A", "2", "3", "4", "5", "6", "7", "8", "9", "10", "J", "Q", "K"] + + +def _get_card_attr(card: Union[dict, object], attr: str, default=None): + """Get attribute from card whether it's a dict or Pydantic model.""" + if isinstance(card, dict): + return card.get(attr, default) + return getattr(card, attr, default) + + +def _is_joker_card(card: dict | tuple, wild_joker_rank: str | None, has_wild_joker_revealed: bool = True) -> bool: + """Check if a card is a joker (printed or wild). + + Args: + card: Card as dict or tuple + wild_joker_rank: The rank that acts as wild joker + has_wild_joker_revealed: Whether wild joker is revealed to this player + + Returns: + True if card is a joker (printed joker or matches wild joker rank IF revealed) + """ + rank = _get_card_attr(card, "rank") + + # Printed jokers are always jokers + if rank == "JOKER": + return True + + # Wild joker cards only act as jokers if revealed + if has_wild_joker_revealed and wild_joker_rank and rank == wild_joker_rank: + return True + + return False + + +def card_points(card: Union[dict, object], ace_value: int = 10) -> int: + """Calculate points for a single card. + + Args: + card: Card as dict or Pydantic model + ace_value: Point value for Aces (1 or 10) + """ + if _get_card_attr(card, "joker"): + return 0 + rank = _get_card_attr(card, "rank") + if rank == "A": + return ace_value + return RANK_POINTS.get(rank, 0) + + +def naive_hand_points(hand: List[Union[dict, object]]) -> int: + # Naive pre-validation: full sum capped to 80 + total = sum(card_points(c) for c in hand) + return min(total, 80) + + +def is_sequence( + cards: list[dict | tuple], + wild_joker_rank: str | None = None, + has_wild_joker_revealed: bool = True +) -> bool: + """Check if cards form a valid sequence (consecutive ranks, same suit).""" + if len(cards) < 3: + return False + + # All cards must have the same suit (excluding jokers) + suits = [_get_card_attr(c, "suit") for c in cards if not _is_joker_card(c, wild_joker_rank, has_wild_joker_revealed)] + if not suits or len(set(suits)) > 1: + return False + + # Get non-joker cards + joker_count = sum(1 for c in cards if _is_joker_card(c, wild_joker_rank, has_wild_joker_revealed)) + non_jokers = [c for c in cards if not _is_joker_card(c, wild_joker_rank, has_wild_joker_revealed)] + + if len(non_jokers) < 2: + return False + + # Get rank indices for non-joker cards + rank_indices = sorted([RANK_ORDER.index(_get_card_attr(c, "rank")) for c in non_jokers]) + + # Check for normal consecutive sequence + first_idx = rank_indices[0] + last_idx = rank_indices[-1] + required_span = last_idx - first_idx + 1 + + if required_span <= len(cards): + return True + + # Check for wrap-around sequence (Ace can be high: Q-K-A or K-A-2) + # If we have an Ace (index 0) and high cards (J, Q, K at indices 10, 11, 12) + if 0 in rank_indices and any(idx >= 10 for idx in rank_indices): + # Try treating Ace as high (after King) + # Create alternate indices where Ace = 13 + alt_indices = [idx if idx != 0 else 13 for idx in rank_indices] + alt_indices.sort() + alt_span = alt_indices[-1] - alt_indices[0] + 1 + + if alt_span <= len(cards): + return True + + return False + + +def is_pure_sequence( + cards: list[dict | tuple], + wild_joker_rank: str | None = None, + has_wild_joker_revealed: bool = True +) -> bool: + """Check if cards form a pure sequence (no jokers as substitutes). + + Wild joker cards in their natural position (same suit, consecutive rank) are allowed. + Only reject if wild joker is used as a substitute. + """ + if not is_sequence(cards, wild_joker_rank, has_wild_joker_revealed): + return False + + # Check for printed jokers (always impure) + if any(_get_card_attr(c, "rank") == "JOKER" for c in cards): + return False + + # If wild joker not revealed, all cards are treated as natural + if not has_wild_joker_revealed or not wild_joker_rank: + return True + + # Check if any wild joker cards are used as substitutes (not in natural position) + suit = None + for c in cards: + c_suit = _get_card_attr(c, "suit") + if suit is None: + suit = c_suit + # All cards must have same suit for a sequence + if c_suit != suit: + return False + + # Get rank indices for all cards + rank_indices = [] + for c in cards: + rank = _get_card_attr(c, "rank") + if rank in RANK_ORDER: + rank_indices.append(RANK_ORDER.index(rank)) + + if len(rank_indices) != len(cards): + return False + + rank_indices.sort() + + # Check if this is a consecutive sequence + is_consecutive = all( + rank_indices[i+1] - rank_indices[i] == 1 + for i in range(len(rank_indices) - 1) + ) + + # Check for wrap-around (Q-K-A or K-A-2) + is_wraparound = False + if 0 in rank_indices and any(idx >= 10 for idx in rank_indices): + alt_indices = [idx if idx != 0 else 13 for idx in rank_indices] + alt_indices.sort() + is_wraparound = all( + alt_indices[i+1] - alt_indices[i] == 1 + for i in range(len(alt_indices) - 1) + ) + + # If cards form a natural consecutive sequence, all wild jokers are in natural positions + if is_consecutive or is_wraparound: + return True + + # Otherwise, sequence has gaps - must be using wild jokers as substitutes + return False + + +def is_set( + cards: list[dict | tuple], + wild_joker_rank: str | None = None, + has_wild_joker_revealed: bool = True +) -> bool: + """Check if cards form a valid set (3-4 cards of same rank, different suits).""" + if len(cards) < 3 or len(cards) > 4: + return False + + # All non-joker cards must have the same rank + ranks = [_get_card_attr(c, "rank") for c in cards if not _is_joker_card(c, wild_joker_rank, has_wild_joker_revealed)] + if not ranks or len(set(ranks)) > 1: + return False + + # All non-joker cards must have different suits + suits = [_get_card_attr(c, "suit") for c in cards if not _is_joker_card(c, wild_joker_rank, has_wild_joker_revealed) and _get_card_attr(c, "suit")] + if len(suits) != len(set(suits)): + return False + + return True + + +def validate_hand( + melds: list[list[dict | tuple]], + leftover: list[dict | tuple], + wild_joker_rank: str | None = None, + has_wild_joker_revealed: bool = True +) -> dict: + """Validate a complete 13-card hand declaration.""" + # After drawing, player has 14 cards. They organize 13 into melds and discard the 14th. + # So we don't check hand length, only that melds contain exactly 13 cards. + + if not melds: + return False, "No meld groups provided" + + # Check total cards in groups equals 13 + total_cards = sum(len(g) for g in melds) + if total_cards != 13: + return False, f"Meld groups must contain exactly 13 cards, found {total_cards}" + + # Check for at least one pure sequence + has_pure_sequence = False + valid_sequences = 0 + valid_sets = 0 + + for group in melds: + if len(group) < 3: + return False, f"Each meld must have at least 3 cards, found {len(group)}" + + is_seq = is_sequence(group, wild_joker_rank, has_wild_joker_revealed) + is_pure_seq = is_pure_sequence(group, wild_joker_rank, has_wild_joker_revealed) + is_valid_set = is_set(group, wild_joker_rank, has_wild_joker_revealed) + + if is_pure_seq: + has_pure_sequence = True + valid_sequences += 1 + elif is_seq: + valid_sequences += 1 + elif is_valid_set: + valid_sets += 1 + else: + cards_str = ', '.join([f"{_get_card_attr(c, 'rank')}{_get_card_attr(c, 'suit') or ''}" for c in group]) + return False, f"Invalid meld: [{cards_str}] is neither a valid sequence nor set" + + if not has_pure_sequence: + return False, "Must have at least one pure sequence (no jokers)" + + if len(melds) < 2: + return False, "Must have at least 2 melds" + + return True, "Valid hand" + + +def calculate_deadwood_points( + cards: list[dict | tuple], + wild_joker_rank: str | None = None, + has_wild_joker_revealed: bool = True, + ace_value: int = 10 +) -> int: + """Calculate points for ungrouped/invalid cards. + + Args: + cards: List of cards + wild_joker_rank: The rank that acts as wild joker + has_wild_joker_revealed: Whether wild joker is revealed + ace_value: Point value for Aces (1 or 10) + """ + total = 0 + for card in cards: + if _is_joker_card(card, wild_joker_rank, has_wild_joker_revealed): + total += 0 # Jokers are worth 0 + else: + total += card_points(card, ace_value) + return min(total, 80) + + +def auto_organize_hand( + hand: list[dict | tuple], + wild_joker_rank: str | None = None, + has_wild_joker_revealed: bool = True +) -> tuple[list[list[dict | tuple]], list[dict | tuple]]: + """ + Automatically organize a hand into best possible melds and leftover cards. + Used for scoring opponents when someone declares. + + Returns: + (melds, leftover_cards) + """ + if not hand or len(hand) == 0: + return [], [] + + remaining = list(hand) + melds = [] + + # Helper to try forming sequences + def try_form_sequence(cards_pool: list) -> list | None: + """Try to find a valid sequence from cards pool.""" + for i in range(len(cards_pool)): + for j in range(i + 1, len(cards_pool)): + for k in range(j + 1, len(cards_pool)): + group = [cards_pool[i], cards_pool[j], cards_pool[k]] + if is_sequence(group, wild_joker_rank, has_wild_joker_revealed): + # Try to extend to 4 cards + for m in range(len(cards_pool)): + if m not in [i, j, k]: + extended = group + [cards_pool[m]] + if is_sequence(extended, wild_joker_rank, has_wild_joker_revealed): + return extended + return group + return None + + # Helper to try forming sets + def try_form_set(cards_pool: list) -> list | None: + """Try to find a valid set from cards pool.""" + for i in range(len(cards_pool)): + for j in range(i + 1, len(cards_pool)): + for k in range(j + 1, len(cards_pool)): + group = [cards_pool[i], cards_pool[j], cards_pool[k]] + if is_set(group, wild_joker_rank, has_wild_joker_revealed): + # Try to extend to 4 cards + for m in range(len(cards_pool)): + if m not in [i, j, k]: + extended = group + [cards_pool[m]] + if is_set(extended, wild_joker_rank, has_wild_joker_revealed): + return extended + return group + return None + + # First pass: try to form pure sequences (highest priority) + while True: + seq = try_form_sequence(remaining) + if seq and is_pure_sequence(seq, wild_joker_rank, has_wild_joker_revealed): + melds.append(seq) + for card in seq: + remaining.remove(card) + else: + break + + # Second pass: form any sequences + while True: + seq = try_form_sequence(remaining) + if seq: + melds.append(seq) + for card in seq: + remaining.remove(card) + else: + break + + # Third pass: form sets + while True: + set_group = try_form_set(remaining) + if set_group: + melds.append(set_group) + for card in set_group: + remaining.remove(card) + else: + break + + return melds, remaining + + +def organize_hand_by_melds(hand: List[Union[dict, object]]) -> Dict[str, List[List[Union[dict, object]]]]: + """ + Organize a hand into meld groups for display. + Returns cards grouped by meld type for easy verification. + + Returns: + { + 'pure_sequences': [[card, card, card], ...], + 'impure_sequences': [[card, card, card], ...], + 'sets': [[card, card, card], ...], + 'ungrouped': [card, card, ...] + } + """ + if not hand: + return { + 'pure_sequences': [], + 'impure_sequences': [], + 'sets': [], + 'ungrouped': [] + } + + remaining_cards = list(hand) + pure_seqs = [] + impure_seqs = [] + sets_list = [] + + # Helper to find best meld of specific type + def find_meld_of_type(cards: List, meld_type: str) -> Optional[List]: + if len(cards) < 3: + return None + + # Try all combinations of 3 and 4 cards + for size in [4, 3]: # Try 4-card melds first + if len(cards) < size: + continue + for i in range(len(cards) - size + 1): + group = cards[i:i+size] + if meld_type == 'pure_seq' and is_pure_sequence(group): + return group + elif meld_type == 'impure_seq' and is_sequence(group) and not is_pure_sequence(group): + return group + elif meld_type == 'set' and is_set(group): + return group + + # Try all combinations (not just consecutive) + from itertools import combinations + for size in [4, 3]: + if len(cards) < size: + continue + for combo in combinations(range(len(cards)), size): + group = [cards[idx] for idx in combo] + if meld_type == 'pure_seq' and is_pure_sequence(group): + return group + elif meld_type == 'impure_seq' and is_sequence(group) and not is_pure_sequence(group): + return group + elif meld_type == 'set' and is_set(group): + return group + + return None + + # 1. Find pure sequences first (highest priority) + while len(remaining_cards) >= 3: + meld = find_meld_of_type(remaining_cards, 'pure_seq') + if not meld: + break + pure_seqs.append(meld) + for card in meld: + remaining_cards.remove(card) + + # 2. Find impure sequences + while len(remaining_cards) >= 3: + meld = find_meld_of_type(remaining_cards, 'impure_seq') + if not meld: + break + impure_seqs.append(meld) + for card in meld: + remaining_cards.remove(card) + + # 3. Find sets + while len(remaining_cards) >= 3: + meld = find_meld_of_type(remaining_cards, 'set') + if not meld: + break + sets_list.append(meld) + for card in meld: + remaining_cards.remove(card) + + return { + 'pure_sequences': pure_seqs, + 'impure_sequences': impure_seqs, + 'sets': sets_list, + 'ungrouped': remaining_cards + } diff --git a/table creating.png b/table creating.png new file mode 100644 index 0000000000000000000000000000000000000000..f7478d7a2c850e404cd76597261c143c4baf668e --- /dev/null +++ b/table creating.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:c697d3440f82f497231cd58f337b692d6110f1966fb9abeeb39250ba4dfd9702 +size 115901 diff --git a/table.png b/table.png new file mode 100644 index 0000000000000000000000000000000000000000..efc1575b1ac0f08de3bec27d0deb40f118560d9e --- /dev/null +++ b/table.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:d061da4b9bc952165c77830a8710044474d20f28fdbcb66f3effdcb75a1ea948 +size 361168 diff --git a/tailwind.config.js b/tailwind.config.js new file mode 100644 index 0000000000000000000000000000000000000000..cf4874ee250005380711f79e5faaf2c11b3d5d53 --- /dev/null +++ b/tailwind.config.js @@ -0,0 +1,79 @@ +/** @type {import('tailwindcss').Config} */ +export default { + darkMode: ["class"], + content: ["./index.html", "./src/**/*.{js,ts,jsx,tsx}"], + theme: { + extend: { + borderRadius: { + lg: "var(--radius)", + md: "calc(var(--radius) - 2px)", + sm: "calc(var(--radius) - 4px)", + }, + colors: { + background: "hsl(var(--background))", + foreground: "hsl(var(--foreground))", + card: { + DEFAULT: "hsl(var(--card))", + foreground: "hsl(var(--card-foreground))", + }, + popover: { + DEFAULT: "hsl(var(--popover))", + foreground: "hsl(var(--popover-foreground))", + }, + primary: { + DEFAULT: "hsl(var(--primary))", + foreground: "hsl(var(--primary-foreground))", + }, + secondary: { + DEFAULT: "hsl(var(--secondary))", + foreground: "hsl(var(--secondary-foreground))", + }, + muted: { + DEFAULT: "hsl(var(--muted))", + foreground: "hsl(var(--muted-foreground))", + }, + accent: { + DEFAULT: "hsl(var(--accent))", + foreground: "hsl(var(--accent-foreground))", + }, + destructive: { + DEFAULT: "hsl(var(--destructive))", + foreground: "hsl(var(--destructive-foreground))", + }, + border: "hsl(var(--border))", + input: "hsl(var(--input))", + ring: "hsl(var(--ring))", + chart: { + 1: "hsl(var(--chart-1))", + 2: "hsl(var(--chart-2))", + 3: "hsl(var(--chart-3))", + 4: "hsl(var(--chart-4))", + 5: "hsl(var(--chart-5))", + }, + }, + keyframes: { + "accordion-down": { + from: { + height: "0", + }, + to: { + height: "var(--radix-accordion-content-height)", + }, + }, + "accordion-up": { + from: { + height: "var(--radix-accordion-content-height)", + }, + to: { + height: "0", + }, + }, + }, + animation: { + "accordion-down": "accordion-down 0.2s ease-out", + "accordion-up": "accordion-up 0.2s ease-out", + }, + }, + }, + plugins: [require("tailwindcss-animate"), require("@tailwindcss/typography")], +}; diff --git a/voice.js b/voice.js new file mode 100644 index 0000000000000000000000000000000000000000..acc9688cb7006d57b3d677e52cfacb1b273740c5 --- /dev/null +++ b/voice.js @@ -0,0 +1,130 @@ +"""Voice and video call system for table communication""" +from fastapi import APIRouter, HTTPException, Depends +from pydantic import BaseModel +import asyncpg +import os +from typing import List, Optional +from app.auth import AuthorizedUser + +router = APIRouter() + +class VoiceSettingsRequest(BaseModel): + table_id: str + audio_enabled: bool + video_enabled: bool + +class MutePlayerRequest(BaseModel): + table_id: str + target_user_id: str + muted: bool + +class TableVoiceSettingsRequest(BaseModel): + table_id: str + voice_enabled: bool + +@router.post("/settings") +async def update_voice_settings(body: VoiceSettingsRequest, user: AuthorizedUser): + """Update user's voice/video settings for a table""" + conn = await asyncpg.connect(os.environ.get("DATABASE_URL")) + + try: + await conn.execute( + """UPDATE table_players + SET audio_enabled = $1, video_enabled = $2 + WHERE table_id = $3 AND user_id = $4""", + body.audio_enabled, + body.video_enabled, + body.table_id, + user.sub + ) + + return {"success": True} + + finally: + await conn.close() + +@router.post("/mute-player") +async def mute_player(body: MutePlayerRequest, user: AuthorizedUser): + """Host can mute/unmute other players""" + conn = await asyncpg.connect(os.environ.get("DATABASE_URL")) + + try: + # Verify user is host + is_host = await conn.fetchval( + "SELECT host_id = $1 FROM tables WHERE id = $2", + user.sub, + body.table_id + ) + + if not is_host: + raise HTTPException(status_code=403, detail="Only host can mute players") + + await conn.execute( + """UPDATE table_players + SET audio_enabled = $1 + WHERE table_id = $2 AND user_id = $3""", + not body.muted, + body.table_id, + body.target_user_id + ) + + return {"success": True} + + finally: + await conn.close() + +@router.post("/table-settings") +async def update_table_voice_settings(body: TableVoiceSettingsRequest, user: AuthorizedUser): + """Host can enable/disable voice for entire table""" + conn = await asyncpg.connect(os.environ.get("DATABASE_URL")) + + try: + # Verify user is host + is_host = await conn.fetchval( + "SELECT host_id = $1 FROM tables WHERE id = $2", + user.sub, + body.table_id + ) + + if not is_host: + raise HTTPException(status_code=403, detail="Only host can change table voice settings") + + await conn.execute( + "UPDATE tables SET voice_enabled = $1 WHERE id = $2", + body.voice_enabled, + body.table_id + ) + + return {"success": True} + + finally: + await conn.close() + +@router.get("/participants/{table_id}") +async def get_voice_participants(table_id: str, user: AuthorizedUser): + """Get all voice participants and their settings""" + conn = await asyncpg.connect(os.environ.get("DATABASE_URL")) + + try: + participants = await conn.fetch( + """SELECT tp.user_id, p.display_name, tp.is_muted, tp.is_speaking + FROM rummy_table_players tp + LEFT JOIN profiles p ON tp.user_id = p.user_id + WHERE tp.table_id = $1""", + table_id + ) + + return { + "participants": [ + { + "user_id": p['user_id'], + "display_name": p['display_name'] or p['user_id'][:8], + "is_muted": p['is_muted'] or False, + "is_speaking": p['is_speaking'] or False + } + for p in participants + ] + } + + finally: + await conn.close() diff --git a/your hand section in game.png b/your hand section in game.png new file mode 100644 index 0000000000000000000000000000000000000000..b48d9059d17874c5f63c9c6f50847474e61b357c Binary files /dev/null and b/your hand section in game.png differ