This view is limited to 50 files because it contains too many changes.  See the raw diff here.
.gitattributes ADDED
@@ -0,0 +1,5 @@
 
 
 
 
 
 
1
+ full[[:space:]]gaame[[:space:]]table[[:space:]]page.png filter=lfs diff=lfs merge=lfs -text
2
+ Home.png filter=lfs diff=lfs merge=lfs -text
3
+ room[[:space:]]created.png filter=lfs diff=lfs merge=lfs -text
4
+ table[[:space:]]creating.png filter=lfs diff=lfs merge=lfs -text
5
+ table.png filter=lfs diff=lfs merge=lfs -text
AppProvider.jsx ADDED
@@ -0,0 +1,21 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import type { ReactNode } from "react";
2
+ import { Toaster } from "sonner";
3
+
4
+ interface Props {
5
+ children: ReactNode;
6
+ }
7
+
8
+ /**
9
+ * A provider wrapping the whole app.
10
+ *
11
+ * You can add multiple providers here by nesting them,
12
+ * and they will all be applied to the app.
13
+ */
14
+ export const AppProvider = ({ children }: Props) => {
15
+ return (
16
+ <>
17
+ {children}
18
+ <Toaster richColors position="top-right" />
19
+ </>
20
+ );
21
+ };
CasinoTable3D.jsx ADDED
@@ -0,0 +1,128 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React from 'react';
2
+
3
+ interface Props {
4
+ children?: React.ReactNode;
5
+ tableColor?: 'green' | 'red-brown';
6
+ }
7
+
8
+ export const CasinoTable3D: React.FC<Props> = ({ children, tableColor = 'green' }) => {
9
+ console.log('🎨 CasinoTable3D rendering with color:', tableColor);
10
+
11
+ // Calculate colors directly - no useMemo to ensure instant updates
12
+ const mainColor = tableColor === 'green' ? '#15803d' : '#6b2f2f';
13
+ const gradientColor = tableColor === 'green'
14
+ ? 'linear-gradient(135deg, #15803d 0%, #16a34a 50%, #15803d 100%)'
15
+ : 'linear-gradient(135deg, #4a1f1f 0%, #6b2f2f 50%, #4a1f1f 100%)';
16
+
17
+ // Edge/border color (darker than main)
18
+ const edgeColor = tableColor === 'green'
19
+ ? 'linear-gradient(135deg, #14532d 0%, #15803d 50%, #14532d 100%)'
20
+ : 'linear-gradient(135deg, #4a1f1f 0%, #6b2f2f 50%, #4a1f1f 100%)';
21
+
22
+ return (
23
+ <div className="relative w-full h-full bg-gradient-to-b from-slate-950 via-slate-900 to-slate-950 py-8" data-table-color={tableColor}>
24
+ {/* Ambient lighting effects */}
25
+ <div className="absolute inset-0 bg-[radial-gradient(ellipse_at_top,_var(--tw-gradient-stops))] from-emerald-900/20 via-transparent to-transparent pointer-events-none" />
26
+ <div className="absolute inset-0 bg-[radial-gradient(ellipse_at_bottom,_var(--tw-gradient-stops))] from-amber-900/10 via-transparent to-transparent pointer-events-none" />
27
+
28
+ {/* 3D Table Container */}
29
+ <div className="relative w-full h-full flex items-center justify-center px-8" style={{
30
+ perspective: '1200px',
31
+ perspectiveOrigin: '50% 50%'
32
+ }}>
33
+ {/* Main Casino Table - GREEN/RED-BROWN FELT */}
34
+ <div
35
+ className="relative w-full max-w-3xl aspect-square max-h-[500px] rounded-[40px] shadow-2xl"
36
+ style={{
37
+ transform: 'rotateX(20deg) rotateZ(0deg)',
38
+ transformStyle: 'preserve-3d',
39
+ backgroundColor: mainColor,
40
+ backgroundImage: gradientColor,
41
+ boxShadow: `
42
+ 0 40px 80px rgba(0, 0, 0, 0.6),
43
+ inset 0 2px 4px rgba(255, 255, 255, 0.1),
44
+ inset 0 -2px 4px rgba(0, 0, 0, 0.3)
45
+ `
46
+ }}
47
+ >
48
+ {/* Felt Texture Overlay */}
49
+ <div
50
+ className="absolute inset-0 rounded-[40px] opacity-30 mix-blend-overlay pointer-events-none"
51
+ style={{
52
+ backgroundImage: `
53
+ repeating-linear-gradient(
54
+ 45deg,
55
+ transparent,
56
+ transparent 2px,
57
+ rgba(0, 0, 0, 0.03) 2px,
58
+ rgba(0, 0, 0, 0.03) 4px
59
+ ),
60
+ repeating-linear-gradient(
61
+ -45deg,
62
+ transparent,
63
+ transparent 2px,
64
+ rgba(0, 0, 0, 0.03) 2px,
65
+ rgba(0, 0, 0, 0.03) 4px
66
+ )
67
+ `,
68
+ }}
69
+ />
70
+
71
+ {/* Table Edge (Padded Leather) */}
72
+ <div
73
+ className="absolute -inset-4 rounded-[44px] -z-10"
74
+ style={{
75
+ background: edgeColor,
76
+ boxShadow: `
77
+ 0 8px 16px rgba(0, 0, 0, 0.4),
78
+ inset 0 2px 4px rgba(255, 255, 255, 0.1)
79
+ `
80
+ }}
81
+ />
82
+
83
+ {/* Game Area Markings with Gold Lines - REMOVE EMPTY BOXES */}
84
+ <div className="absolute inset-0 rounded-[40px] overflow-hidden">
85
+ {/* Center playing field */}
86
+ <div className="absolute inset-[10%] border-2 border-amber-500/40 rounded-3xl" style={{
87
+ boxShadow: '0 0 20px rgba(251, 191, 36, 0.2)'
88
+ }}>
89
+ {/* Center Meld/Declaration Area */}
90
+ <div className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-[60%] h-[40%] border-2 border-dashed border-amber-400/40 rounded-2xl" style={{
91
+ boxShadow: '0 0 10px rgba(251, 191, 36, 0.15)'
92
+ }}>
93
+ <div className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 text-xs font-bold text-amber-300/40 tracking-widest"
94
+ style={{ textShadow: '0 2px 4px rgba(0, 0, 0, 0.5)' }}
95
+ >
96
+ PLAY AREA
97
+ </div>
98
+ </div>
99
+ </div>
100
+
101
+ {/* Corner decorative lines */}
102
+ <div className="absolute top-8 left-8 w-16 h-16 border-l-2 border-t-2 border-amber-500/30 rounded-tl-2xl" />
103
+ <div className="absolute top-8 right-8 w-16 h-16 border-r-2 border-t-2 border-amber-500/30 rounded-tr-2xl" />
104
+ <div className="absolute bottom-8 left-8 w-16 h-16 border-l-2 border-b-2 border-amber-500/30 rounded-bl-2xl" />
105
+ <div className="absolute bottom-8 right-8 w-16 h-16 border-r-2 border-b-2 border-amber-500/30 rounded-br-2xl" />
106
+ </div>
107
+
108
+ {/* Content Layer - Game UI */}
109
+ <div className="relative w-full h-full" style={{
110
+ transform: 'translateZ(20px)',
111
+ transformStyle: 'preserve-3d'
112
+ }}>
113
+ {children}
114
+ </div>
115
+
116
+ {/* Table Lighting - Spotlight effect */}
117
+ <div
118
+ className="absolute inset-0 rounded-[40px] pointer-events-none"
119
+ style={{
120
+ background: 'radial-gradient(ellipse at center, rgba(255, 255, 255, 0.08) 0%, transparent 70%)',
121
+ mixBlendMode: 'overlay'
122
+ }}
123
+ />
124
+ </div>
125
+ </div>
126
+ </div>
127
+ );
128
+ };
ChatPanel.jsx ADDED
@@ -0,0 +1,150 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React, { useState, useEffect, useRef } from 'react';
2
+ import { apiClient } from 'app';
3
+ import { MessageResponse } from 'types';
4
+ import { Button } from '@/components/ui/button';
5
+ import { Input } from '@/components/ui/input';
6
+ import { ScrollArea } from '@/components/ui/scroll-area';
7
+ import { MessageCircle, Send, X } from 'lucide-react';
8
+
9
+ interface Props {
10
+ tableId: string;
11
+ userId: string;
12
+ isOpen: boolean;
13
+ onToggle: () => void;
14
+ }
15
+
16
+ export const ChatPanel: React.FC<Props> = ({ tableId, userId, isOpen, onToggle }) => {
17
+ const [messages, setMessages] = useState<MessageResponse[]>([]);
18
+ const [newMessage, setNewMessage] = useState('');
19
+ const [sending, setSending] = useState(false);
20
+ const scrollRef = useRef<HTMLDivElement>(null);
21
+
22
+ useEffect(() => {
23
+ loadMessages();
24
+ const interval = setInterval(loadMessages, 2000); // Poll every 2s
25
+ return () => clearInterval(interval);
26
+ }, [tableId]);
27
+
28
+ useEffect(() => {
29
+ if (scrollRef.current) {
30
+ scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
31
+ }
32
+ }, [messages]);
33
+
34
+ const loadMessages = async () => {
35
+ try {
36
+ const response = await apiClient.get_messages({ table_id: tableId });
37
+ const data = await response.json();
38
+ setMessages(data.messages || []);
39
+ } catch (error) {
40
+ console.error('Failed to load messages:', error);
41
+ }
42
+ };
43
+
44
+ const sendMessage = async () => {
45
+ if (!newMessage.trim() || sending) return;
46
+
47
+ setSending(true);
48
+ try {
49
+ await apiClient.send_message({ table_id: tableId, message: newMessage.trim() });
50
+ setNewMessage('');
51
+ await loadMessages();
52
+ } catch (error) {
53
+ console.error('Failed to send message:', error);
54
+ } finally {
55
+ setSending(false);
56
+ }
57
+ };
58
+
59
+ const handleKeyPress = (e: React.KeyboardEvent) => {
60
+ if (e.key === 'Enter' && !e.shiftKey) {
61
+ e.preventDefault();
62
+ sendMessage();
63
+ }
64
+ };
65
+
66
+ return (
67
+ <>
68
+ {/* Toggle Button */}
69
+ {!isOpen && (
70
+ <button
71
+ onClick={onToggle}
72
+ className="fixed right-4 bottom-4 bg-green-600 hover:bg-green-700 text-white p-4 rounded-full shadow-lg z-50 transition-all"
73
+ title="Open Chat"
74
+ >
75
+ <MessageCircle className="w-6 h-6" />
76
+ </button>
77
+ )}
78
+
79
+ {/* Chat Sidebar */}
80
+ {isOpen && (
81
+ <div className="fixed right-0 top-0 h-full w-80 bg-slate-900 border-l border-slate-700 shadow-2xl z-50 flex flex-col">
82
+ {/* Header */}
83
+ <div className="flex items-center justify-between p-4 border-b border-slate-700 bg-slate-800">
84
+ <h3 className="font-bold text-white flex items-center gap-2">
85
+ <MessageCircle className="w-5 h-5 text-green-500" />
86
+ Table Chat
87
+ </h3>
88
+ <Button variant="ghost" size="sm" onClick={onToggle}>
89
+ <X className="w-4 h-4" />
90
+ </Button>
91
+ </div>
92
+
93
+ {/* Messages */}
94
+ <ScrollArea className="flex-1 p-4" ref={scrollRef}>
95
+ <div className="space-y-3">
96
+ {messages.map((msg) => (
97
+ <div
98
+ key={msg.id}
99
+ className={`p-3 rounded-lg ${
100
+ msg.user_id === userId
101
+ ? 'bg-green-600/20 border border-green-600/30 ml-8'
102
+ : msg.is_system
103
+ ? 'bg-amber-600/20 border border-amber-600/30 text-center'
104
+ : 'bg-slate-800 border border-slate-700 mr-8'
105
+ }`}
106
+ >
107
+ {!msg.is_system && (
108
+ <div className="text-xs text-slate-400 mb-1">
109
+ {msg.user_id === userId ? 'You' : msg.user_email?.split('@')[0] || 'Player'}
110
+ {msg.private_to && <span className="ml-2 text-amber-400">(Private)</span>}
111
+ </div>
112
+ )}
113
+ <div className={`text-sm ${
114
+ msg.is_system ? 'text-amber-300 font-medium' : 'text-white'
115
+ }`}>
116
+ {msg.message}
117
+ </div>
118
+ <div className="text-xs text-slate-500 mt-1">
119
+ {new Date(msg.created_at).toLocaleTimeString()}
120
+ </div>
121
+ </div>
122
+ ))}
123
+ </div>
124
+ </ScrollArea>
125
+
126
+ {/* Input */}
127
+ <div className="p-4 border-t border-slate-700 bg-slate-800">
128
+ <div className="flex gap-2">
129
+ <Input
130
+ value={newMessage}
131
+ onChange={(e) => setNewMessage(e.target.value)}
132
+ onKeyPress={handleKeyPress}
133
+ placeholder="Type a message..."
134
+ disabled={sending}
135
+ className="flex-1 bg-slate-900 border-slate-700 text-white"
136
+ />
137
+ <Button
138
+ onClick={sendMessage}
139
+ disabled={!newMessage.trim() || sending}
140
+ className="bg-green-600 hover:bg-green-700"
141
+ >
142
+ <Send className="w-4 h-4" />
143
+ </Button>
144
+ </div>
145
+ </div>
146
+ </div>
147
+ )}
148
+ </>
149
+ );
150
+ };
ChatSidebar.jsx ADDED
@@ -0,0 +1,256 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useState, useEffect, useRef } from 'react';
2
+ import { apiClient } from 'app';
3
+ import { ChatMessage } from 'types';
4
+ import { Button } from '@/components/ui/button';
5
+ import { Input } from '@/components/ui/input';
6
+ import { ScrollArea } from '@/components/ui/scroll-area';
7
+ import { MessageCircle, X, Send, Lock, MessageSquare, ChevronRight } from 'lucide-react';
8
+ import { toast } from 'sonner';
9
+
10
+ interface Props {
11
+ tableId: string;
12
+ currentUserId: string;
13
+ players: Array<{ userId: string; displayName: string }>;
14
+ }
15
+
16
+ export default function ChatSidebar({ tableId, currentUserId, players }: Props) {
17
+ const [isOpen, setIsOpen] = useState(false);
18
+ const [messages, setMessages] = useState<ChatMessage[]>([]);
19
+ const [messageText, setMessageText] = useState('');
20
+ const [recipient, setRecipient] = useState<string | null>(null);
21
+ const [unreadCount, setUnreadCount] = useState(0);
22
+ const scrollRef = useRef<HTMLDivElement>(null);
23
+ const inputRef = useRef<HTMLInputElement>(null);
24
+
25
+ // Auto-scroll to bottom when new messages arrive
26
+ useEffect(() => {
27
+ if (scrollRef.current) {
28
+ scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
29
+ }
30
+ }, [messages]);
31
+
32
+ // Poll for new messages every 2 seconds
33
+ useEffect(() => {
34
+ const fetchMessages = async () => {
35
+ if (!tableId) return;
36
+ try {
37
+ const response = await apiClient.get_messages({ table_id: tableId });
38
+ const data = await response.json();
39
+
40
+ setMessages(data.messages || []);
41
+
42
+ // Update unread count if sidebar is closed
43
+ if (!isOpen) {
44
+ const newMessages = (data.messages || []).filter((msg: ChatMessage) =>
45
+ msg.user_id !== currentUserId &&
46
+ new Date(msg.timestamp).getTime() > Date.now() - 4000 // Match polling interval
47
+ );
48
+ setUnreadCount(prev => prev + newMessages.length);
49
+ }
50
+ } catch (error) {
51
+ console.error('Failed to fetch chat messages:', error);
52
+ }
53
+ };
54
+
55
+ fetchMessages();
56
+ const interval = setInterval(fetchMessages, 4000); // Increased from 2000ms to 4000ms (4 seconds)
57
+ return () => clearInterval(interval);
58
+ }, [tableId, isOpen, currentUserId]);
59
+
60
+ // Clear unread count when sidebar opens
61
+ useEffect(() => {
62
+ if (isOpen) {
63
+ setUnreadCount(0);
64
+ }
65
+ }, [isOpen]);
66
+
67
+ const sendMessage = async () => {
68
+ if (!messageText.trim()) return;
69
+
70
+ try {
71
+ const response = await apiClient.send_message({
72
+ table_id: tableId,
73
+ message: messageText,
74
+ is_private: !!recipient,
75
+ recipient_id: recipient || undefined,
76
+ });
77
+
78
+ const newMessage = await response.json();
79
+ setMessages([...messages, newMessage]);
80
+ setMessageText('');
81
+ setRecipient(null);
82
+ } catch (error) {
83
+ console.error('Failed to send message:', error);
84
+ toast.error('Failed to send message');
85
+ }
86
+ };
87
+
88
+ const handleKeyPress = (e: React.KeyboardEvent) => {
89
+ if (e.key === 'Enter' && !e.shiftKey) {
90
+ e.preventDefault();
91
+ sendMessage();
92
+ }
93
+ };
94
+
95
+ // Handle @ mentions
96
+ const handleInputChange = (value: string) => {
97
+ setMessageText(value);
98
+
99
+ // Check for @ mention at the start
100
+ const mentionMatch = value.match(/^@(\w+)/);
101
+ if (mentionMatch) {
102
+ const mentionedName = mentionMatch[1].toLowerCase();
103
+ const player = players.find(p =>
104
+ p.displayName.toLowerCase().startsWith(mentionedName)
105
+ );
106
+ if (player && player.userId !== currentUserId) {
107
+ setRecipient(player.userId);
108
+ }
109
+ } else {
110
+ setRecipient(null);
111
+ }
112
+ };
113
+
114
+ const getRecipientName = () => {
115
+ if (!recipient) return null;
116
+ return players.find(p => p.userId === recipient)?.displayName;
117
+ };
118
+
119
+ const formatTimestamp = (timestamp: string) => {
120
+ const date = new Date(timestamp);
121
+ return date.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit' });
122
+ };
123
+
124
+ return (
125
+ <>
126
+ {/* Toggle Button - Matches GameRules style */}
127
+ {!isOpen && (
128
+ <button
129
+ onClick={() => {
130
+ setIsOpen(true);
131
+ setUnreadCount(0);
132
+ }}
133
+ className="fixed top-20 right-4 z-40 bg-blue-800 hover:bg-blue-700 text-blue-100 px-3 py-2 rounded-lg shadow-lg flex items-center gap-2 text-sm transition-all"
134
+ >
135
+ <ChevronRight className="w-4 h-4" />
136
+ Chat
137
+ {unreadCount > 0 && (
138
+ <span className="ml-1 bg-red-500 text-white text-xs font-bold rounded-full w-5 h-5 flex items-center justify-center">
139
+ {unreadCount > 9 ? '9+' : unreadCount}
140
+ </span>
141
+ )}
142
+ </button>
143
+ )}
144
+
145
+ {/* Sidebar Panel */}
146
+ {isOpen && (
147
+ <div className="fixed right-0 top-0 h-full w-80 bg-background border-l border-border shadow-lg z-50 flex flex-col">
148
+ {/* Header */}
149
+ <div className="p-4 border-b border-border flex items-center justify-between">
150
+ <h2 className="text-lg font-semibold flex items-center gap-2">
151
+ <MessageCircle className="h-5 w-5" />
152
+ Chat
153
+ </h2>
154
+ <Button
155
+ onClick={() => setIsOpen(false)}
156
+ variant="ghost"
157
+ size="icon"
158
+ >
159
+ <X className="h-4 w-4" />
160
+ </Button>
161
+ </div>
162
+
163
+ {/* Messages */}
164
+ <ScrollArea className="flex-1 p-4" ref={scrollRef}>
165
+ <div className="space-y-3">
166
+ {messages.map((msg) => {
167
+ const isOwnMessage = msg.user_id === currentUserId;
168
+ const isPrivate = msg.is_private;
169
+ const isRecipient = msg.recipient_id === currentUserId;
170
+ const isSender = msg.user_id === currentUserId;
171
+
172
+ return (
173
+ <div
174
+ key={msg.id}
175
+ className={`flex flex-col ${
176
+ isOwnMessage ? 'items-end' : 'items-start'
177
+ }`}
178
+ >
179
+ <div className="flex items-center gap-1 text-xs text-muted-foreground mb-1">
180
+ {isPrivate && <Lock className="h-3 w-3" />}
181
+ <span className="font-medium">{msg.sender_name}</span>
182
+ <span>{formatTimestamp(msg.created_at)}</span>
183
+ </div>
184
+ <div
185
+ className={`max-w-[85%] rounded-lg px-3 py-2 ${
186
+ isOwnMessage
187
+ ? 'bg-primary text-primary-foreground'
188
+ : 'bg-muted'
189
+ } ${
190
+ isPrivate
191
+ ? 'border-2 border-yellow-500 dark:border-yellow-600'
192
+ : ''
193
+ }`}
194
+ >
195
+ {isPrivate && (isSender || isRecipient) && (
196
+ <div className="text-xs italic opacity-80 mb-1">
197
+ {isSender
198
+ ? `To: ${players.find(p => p.userId === msg.recipient_id)?.displayName}`
199
+ : 'Private message'}
200
+ </div>
201
+ )}
202
+ <p className="text-sm break-words">{msg.message}</p>
203
+ </div>
204
+ </div>
205
+ );
206
+ })}
207
+ </div>
208
+ </ScrollArea>
209
+
210
+ {/* Input Area */}
211
+ <div className="p-4 border-t border-border">
212
+ {recipient && (
213
+ <div className="mb-2 flex items-center gap-2 text-xs bg-yellow-500/10 text-yellow-600 dark:text-yellow-500 px-2 py-1 rounded">
214
+ <Lock className="h-3 w-3" />
215
+ <span>Private message to {getRecipientName()}</span>
216
+ <Button
217
+ variant="ghost"
218
+ size="icon"
219
+ className="h-4 w-4 ml-auto"
220
+ onClick={() => {
221
+ setRecipient(null);
222
+ setMessageText('');
223
+ }}
224
+ >
225
+ <X className="h-3 w-3" />
226
+ </Button>
227
+ </div>
228
+ )}
229
+
230
+ <div className="flex items-center gap-2">
231
+ <Input
232
+ ref={inputRef}
233
+ value={messageText}
234
+ onChange={(e) => handleInputChange(e.target.value)}
235
+ onKeyPress={handleKeyPress}
236
+ placeholder="Type a message... (@name for private)"
237
+ className="flex-1"
238
+ />
239
+ <Button
240
+ onClick={sendMessage}
241
+ disabled={!messageText.trim()}
242
+ size="icon"
243
+ >
244
+ <Send className="h-4 w-4" />
245
+ </Button>
246
+ </div>
247
+
248
+ <p className="text-xs text-muted-foreground mt-2">
249
+ Tip: Type @username to send a private message
250
+ </p>
251
+ </div>
252
+ </div>
253
+ )}
254
+ </>
255
+ );
256
+ }
CreateTable.jsx ADDED
@@ -0,0 +1,349 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React, { useState, useEffect } from 'react';
2
+ import { useNavigate, useSearchParams } from 'react-router-dom';
3
+ import { Button } from '@/components/ui/button';
4
+ import { Card } from '@/components/ui/card';
5
+ import { Input } from '@/components/ui/input';
6
+ import { ArrowLeft, Users, Trophy, Crown, Copy, Check } from 'lucide-react';
7
+ import { toast } from 'sonner';
8
+ import apiclient from '../apiclient';
9
+ import type { CreateTableRequest } from '../apiclient/data-contracts';
10
+ import { useUser } from '@stackframe/react';
11
+
12
+ interface VariantConfig {
13
+ id: string;
14
+ title: string;
15
+ wildJokerMode: 'no_joker' | 'close_joker' | 'open_joker';
16
+ description: string;
17
+ color: string;
18
+ }
19
+
20
+ const variantConfigs: Record<string, VariantConfig> = {
21
+ no_wildcard: {
22
+ id: 'no_wildcard',
23
+ title: 'No Wild Card',
24
+ wildJokerMode: 'no_joker',
25
+ description: 'Pure classic rummy with printed jokers only',
26
+ color: 'from-blue-500 to-cyan-500'
27
+ },
28
+ open_wildcard: {
29
+ id: 'open_wildcard',
30
+ title: 'Open Wild Card',
31
+ wildJokerMode: 'open_joker',
32
+ description: 'Traditional rummy with wild card revealed at start',
33
+ color: 'from-green-500 to-emerald-500'
34
+ },
35
+ close_wildcard: {
36
+ id: 'close_wildcard',
37
+ title: 'Close Wild Card',
38
+ wildJokerMode: 'close_joker',
39
+ description: 'Advanced variant - wild card reveals after first sequence',
40
+ color: 'from-purple-500 to-pink-500'
41
+ }
42
+ };
43
+
44
+ export default function CreateTable() {
45
+ const navigate = useNavigate();
46
+ const [sp] = useSearchParams();
47
+ const user = useUser();
48
+ const variantId = sp.get('variant') || 'open_wildcard';
49
+ const variant = variantConfigs[variantId] || variantConfigs.open_wildcard;
50
+
51
+ const [playerName, setPlayerName] = useState('Player');
52
+ const [maxPlayers, setMaxPlayers] = useState(4);
53
+ const [disqualifyScore, setDisqualifyScore] = useState(200);
54
+ const [aceValue, setAceValue] = useState<1 | 10>(10);
55
+ const [creating, setCreating] = useState(false);
56
+ const [generatedCode, setGeneratedCode] = useState('');
57
+ const [tableId, setTableId] = useState<string | null>(null);
58
+ const [copied, setCopied] = useState(false);
59
+
60
+ useEffect(() => {
61
+ if (user?.displayName) {
62
+ setPlayerName(user.displayName);
63
+ }
64
+ }, [user]);
65
+
66
+ const handleCreateRoom = async () => {
67
+ if (!playerName.trim()) {
68
+ toast.error('Enter your name');
69
+ return;
70
+ }
71
+
72
+ // STRICT VALIDATION - prevent constraint violations
73
+ if (aceValue !== 1 && aceValue !== 10) {
74
+ toast.error(`Invalid ace value: ${aceValue}. Please refresh your browser (Ctrl+Shift+R)`);
75
+ console.error('❌ INVALID ACE VALUE:', aceValue, 'Expected: 1 or 10');
76
+ return;
77
+ }
78
+ if (!variant.wildJokerMode || variant.wildJokerMode === '') {
79
+ toast.error('Invalid game mode. Please refresh your browser (Ctrl+Shift+R)');
80
+ console.error('❌ INVALID WILD JOKER MODE:', variant.wildJokerMode);
81
+ return;
82
+ }
83
+
84
+ setCreating(true);
85
+ try {
86
+ const body: CreateTableRequest = {
87
+ max_players: maxPlayers,
88
+ disqualify_score: disqualifyScore,
89
+ wild_joker_mode: variant.wildJokerMode,
90
+ ace_value: aceValue,
91
+ };
92
+
93
+ // 🔍 DETAILED FRONTEND LOGGING - Check console!
94
+ console.log('🎯 [CREATE TABLE DEBUG]', {
95
+ variantId,
96
+ variantTitle: variant.title,
97
+ wildJokerMode: variant.wildJokerMode,
98
+ wildJokerModeType: typeof variant.wildJokerMode,
99
+ aceValue,
100
+ aceValueType: typeof aceValue,
101
+ fullBody: body,
102
+ timestamp: new Date().toISOString()
103
+ });
104
+
105
+ const res = await apiclient.create_table(body);
106
+ const data = await res.json();
107
+
108
+ setGeneratedCode(data.code);
109
+ setTableId(data.table_id);
110
+ toast.success('Room created successfully!');
111
+ } catch (e: any) {
112
+ console.error('❌ [CREATE TABLE ERROR]', e);
113
+ const errorMsg = e?.error?.detail || e?.message || 'Failed to create room';
114
+ toast.error(`Error: ${errorMsg}. Try refreshing (Ctrl+Shift+R)`);
115
+ } finally {
116
+ setCreating(false);
117
+ }
118
+ };
119
+
120
+ const handleStartGame = () => {
121
+ if (tableId) {
122
+ navigate(`/Table?tableId=${tableId}`);
123
+ }
124
+ };
125
+
126
+ const copyToClipboard = () => {
127
+ if (!generatedCode) return;
128
+ navigator.clipboard.writeText(generatedCode);
129
+ setCopied(true);
130
+ toast.success('Code copied to clipboard!');
131
+ setTimeout(() => setCopied(false), 2000);
132
+ };
133
+
134
+ if (generatedCode && tableId) {
135
+ return (
136
+ <div className="min-h-screen bg-gradient-to-br from-slate-900 via-slate-800 to-slate-900">
137
+ {/* Header */}
138
+ <div className="border-b border-slate-700/50 bg-slate-900/50 backdrop-blur">
139
+ <div className="max-w-3xl mx-auto px-6 py-6">
140
+ <h1 className="text-3xl font-bold text-white tracking-tight">{variant.title}</h1>
141
+ <p className="text-slate-400 mt-1">{variant.description}</p>
142
+ </div>
143
+ </div>
144
+
145
+ <div className="max-w-3xl mx-auto px-6 py-12">
146
+ <Card className="bg-slate-800/50 border-slate-700 p-8">
147
+ <div className="text-center mb-8">
148
+ <div className="inline-flex items-center justify-center w-20 h-20 rounded-full bg-green-500/20 mb-4">
149
+ <Crown className="w-10 h-10 text-green-400" />
150
+ </div>
151
+ <h2 className="text-2xl font-bold text-white mb-2">Room Created!</h2>
152
+ <p className="text-slate-400">Share this code with your friends</p>
153
+ </div>
154
+
155
+ {/* Room Code Display */}
156
+ <div className="bg-gradient-to-br from-slate-900/80 to-slate-800/80 border-2 border-slate-600 rounded-xl p-8 mb-8">
157
+ <p className="text-sm text-slate-400 text-center mb-3">Room Code</p>
158
+ <p className="text-5xl font-bold text-center text-white tracking-wider mb-6 font-mono">
159
+ {generatedCode}
160
+ </p>
161
+ <Button
162
+ onClick={copyToClipboard}
163
+ className="w-full bg-slate-700 hover:bg-slate-600 text-white"
164
+ >
165
+ {copied ? (
166
+ <>
167
+ <Check className="w-5 h-5 mr-2" />
168
+ Copied!
169
+ </>
170
+ ) : (
171
+ <>
172
+ <Copy className="w-5 h-5 mr-2" />
173
+ Copy Code
174
+ </>
175
+ )}
176
+ </Button>
177
+ </div>
178
+
179
+ {/* Room Details */}
180
+ <div className="space-y-3 mb-8 bg-slate-900/50 rounded-lg p-6">
181
+ <div className="flex justify-between text-sm">
182
+ <span className="text-slate-400">Host:</span>
183
+ <span className="text-white font-medium">{playerName}</span>
184
+ </div>
185
+ <div className="flex justify-between text-sm">
186
+ <span className="text-slate-400">Game Mode:</span>
187
+ <span className="text-white font-medium">{variant.title}</span>
188
+ </div>
189
+ <div className="flex justify-between text-sm">
190
+ <span className="text-slate-400">Max Players:</span>
191
+ <span className="text-white font-medium">{maxPlayers}</span>
192
+ </div>
193
+ <div className="flex justify-between text-sm">
194
+ <span className="text-slate-400">Disqualify Score:</span>
195
+ <span className="text-white font-medium">{disqualifyScore} pts</span>
196
+ </div>
197
+ <div className="flex justify-between text-sm">
198
+ <span className="text-slate-400">Ace Value:</span>
199
+ <span className="text-white font-medium">{aceValue} pt{aceValue === 1 ? '' : 's'}</span>
200
+ </div>
201
+ </div>
202
+
203
+ {/* Actions */}
204
+ <div className="flex gap-3">
205
+ <Button
206
+ onClick={() => {
207
+ setGeneratedCode('');
208
+ setTableId(null);
209
+ navigate('/');
210
+ }}
211
+ variant="outline"
212
+ className="flex-1 border-slate-600 text-slate-300 hover:bg-slate-700"
213
+ >
214
+ Cancel
215
+ </Button>
216
+ <Button
217
+ onClick={handleStartGame}
218
+ className={`flex-1 bg-gradient-to-r ${variant.color} hover:opacity-90 text-white font-semibold`}
219
+ >
220
+ Go to Table
221
+ </Button>
222
+ </div>
223
+ </Card>
224
+ </div>
225
+ </div>
226
+ );
227
+ }
228
+
229
+ return (
230
+ <div className="min-h-screen bg-gradient-to-br from-slate-900 via-slate-800 to-slate-900">
231
+ {/* Header */}
232
+ <div className="border-b border-slate-700/50 bg-slate-900/50 backdrop-blur">
233
+ <div className="max-w-3xl mx-auto px-6 py-6">
234
+ <Button
235
+ onClick={() => navigate('/')}
236
+ variant="ghost"
237
+ className="text-slate-400 hover:text-white mb-4"
238
+ >
239
+ <ArrowLeft className="w-4 h-4 mr-2" />
240
+ Back to Game Selection
241
+ </Button>
242
+ <h1 className="text-3xl font-bold text-white tracking-tight">{variant.title}</h1>
243
+ <p className="text-slate-400 mt-1">{variant.description}</p>
244
+ </div>
245
+ </div>
246
+
247
+ <div className="max-w-3xl mx-auto px-6 py-12">
248
+ <Card className="bg-slate-800/50 border-slate-700 p-8">
249
+ <h2 className="text-2xl font-bold text-white mb-6">Create Private Room</h2>
250
+
251
+ <div className="space-y-6">
252
+ {/* Player Name */}
253
+ <div>
254
+ <label className="block text-sm font-medium text-slate-300 mb-2">
255
+ Your Name
256
+ </label>
257
+ <Input
258
+ type="text"
259
+ value={playerName}
260
+ onChange={(e) => setPlayerName(e.target.value)}
261
+ placeholder="Enter your name"
262
+ className="bg-slate-900/50 border-slate-600 text-white placeholder:text-slate-500"
263
+ />
264
+ </div>
265
+
266
+ {/* Max Players */}
267
+ <div>
268
+ <label className="block text-sm font-medium text-slate-300 mb-2">
269
+ <Users className="w-4 h-4 inline mr-2" />
270
+ Maximum Players
271
+ </label>
272
+ <select
273
+ value={maxPlayers}
274
+ onChange={(e) => setMaxPlayers(Number(e.target.value))}
275
+ className="w-full px-4 py-2 bg-slate-900/50 border border-slate-600 rounded-lg text-white focus:outline-none focus:ring-2 focus:ring-slate-500"
276
+ >
277
+ <option value={2}>2 Players</option>
278
+ <option value={3}>3 Players</option>
279
+ <option value={4}>4 Players</option>
280
+ <option value={5}>5 Players</option>
281
+ <option value={6}>6 Players</option>
282
+ </select>
283
+ </div>
284
+
285
+ {/* Disqualification Score */}
286
+ <div>
287
+ <label className="block text-sm font-medium text-slate-300 mb-2">
288
+ <Trophy className="w-4 h-4 inline mr-2" />
289
+ Disqualification Score
290
+ </label>
291
+ <select
292
+ value={disqualifyScore}
293
+ onChange={(e) => setDisqualifyScore(Number(e.target.value))}
294
+ className="w-full px-4 py-2 bg-slate-900/50 border border-slate-600 rounded-lg text-white focus:outline-none focus:ring-2 focus:ring-slate-500"
295
+ >
296
+ <option value={200}>200 Points</option>
297
+ <option value={300}>300 Points</option>
298
+ <option value={400}>400 Points</option>
299
+ <option value={500}>500 Points</option>
300
+ <option value={600}>600 Points</option>
301
+ </select>
302
+ <p className="text-xs text-slate-500 mt-1">Players reaching this score will be disqualified</p>
303
+ </div>
304
+
305
+ {/* Ace Value */}
306
+ <div>
307
+ <label className="block text-sm font-medium text-slate-300 mb-3">
308
+ Ace Point Value
309
+ </label>
310
+ <div className="grid grid-cols-2 gap-3">
311
+ <button
312
+ onClick={() => setAceValue(1)}
313
+ className={`p-4 rounded-lg border-2 transition-all ${
314
+ aceValue === 1
315
+ ? `border-transparent bg-gradient-to-r ${variant.color} text-white`
316
+ : 'border-slate-600 bg-slate-900/50 text-slate-300 hover:border-slate-500'
317
+ }`}
318
+ >
319
+ <div className="text-2xl font-bold">1</div>
320
+ <div className="text-xs mt-1 opacity-80">Point</div>
321
+ </button>
322
+ <button
323
+ onClick={() => setAceValue(10)}
324
+ className={`p-4 rounded-lg border-2 transition-all ${
325
+ aceValue === 10
326
+ ? `border-transparent bg-gradient-to-r ${variant.color} text-white`
327
+ : 'border-slate-600 bg-slate-900/50 text-slate-300 hover:border-slate-500'
328
+ }`}
329
+ >
330
+ <div className="text-2xl font-bold">10</div>
331
+ <div className="text-xs mt-1 opacity-80">Points</div>
332
+ </button>
333
+ </div>
334
+ </div>
335
+
336
+ {/* Create Button */}
337
+ <Button
338
+ onClick={handleCreateRoom}
339
+ disabled={creating || !playerName.trim()}
340
+ className={`w-full bg-gradient-to-r ${variant.color} hover:opacity-90 text-white font-semibold py-6 text-lg mt-8`}
341
+ >
342
+ {creating ? 'Creating Room...' : 'Create Room'}
343
+ </Button>
344
+ </div>
345
+ </Card>
346
+ </div>
347
+ </div>
348
+ );
349
+ }
GameHistory.jsx ADDED
@@ -0,0 +1,132 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React, { useState, useEffect } from 'react';
2
+ import { apiClient } from 'app';
3
+ import { Button } from '@/components/ui/button';
4
+ import { ScrollArea } from '@/components/ui/scroll-area';
5
+ import { History, X, Trophy, AlertTriangle } from 'lucide-react';
6
+
7
+ interface Props {
8
+ tableId: string;
9
+ }
10
+
11
+ export const GameHistory: React.FC<Props> = ({ tableId }) => {
12
+ const [isOpen, setIsOpen] = useState(false);
13
+ const [history, setHistory] = useState<any>(null);
14
+ const [loading, setLoading] = useState(false);
15
+
16
+ useEffect(() => {
17
+ if (isOpen) {
18
+ loadHistory();
19
+ }
20
+ }, [isOpen, tableId]);
21
+
22
+ const loadHistory = async () => {
23
+ setLoading(true);
24
+ try {
25
+ const response = await apiClient.get_round_history({ table_id: tableId });
26
+ const data = await response.json();
27
+ setHistory(data);
28
+ } catch (error) {
29
+ console.error('Failed to load history:', error);
30
+ } finally {
31
+ setLoading(false);
32
+ }
33
+ };
34
+
35
+ return (
36
+ <>
37
+ {/* Toggle Button */}
38
+ <Button
39
+ onClick={() => setIsOpen(!isOpen)}
40
+ className="fixed top-20 right-4 bg-slate-700 hover:bg-slate-600 z-40"
41
+ title="View Game History"
42
+ >
43
+ <History className="w-4 h-4 mr-2" />
44
+ History
45
+ </Button>
46
+
47
+ {/* History Panel */}
48
+ {isOpen && (
49
+ <div className="fixed right-0 top-0 h-full w-96 bg-slate-900 border-l border-slate-700 shadow-2xl z-50 flex flex-col">
50
+ {/* Header */}
51
+ <div className="flex items-center justify-between p-4 border-b border-slate-700 bg-slate-800">
52
+ <h3 className="font-bold text-white flex items-center gap-2">
53
+ <History className="w-5 h-5 text-amber-500" />
54
+ Game History
55
+ </h3>
56
+ <Button variant="ghost" size="sm" onClick={() => setIsOpen(false)}>
57
+ <X className="w-4 h-4" />
58
+ </Button>
59
+ </div>
60
+
61
+ {/* Content */}
62
+ <ScrollArea className="flex-1 p-4">
63
+ {loading ? (
64
+ <div className="text-center text-slate-400 py-8">Loading...</div>
65
+ ) : history ? (
66
+ <div className="space-y-6">
67
+ {/* Disqualified Players */}
68
+ {history.disqualified_players?.length > 0 && (
69
+ <div className="bg-red-900/20 border border-red-700 rounded-lg p-4">
70
+ <h4 className="font-bold text-red-400 mb-2 flex items-center gap-2">
71
+ <AlertTriangle className="w-4 h-4" />
72
+ Disqualified
73
+ </h4>
74
+ {history.disqualified_players.map((p: any) => (
75
+ <div key={p.user_id} className="text-sm text-red-300">
76
+ {p.email?.split('@')[0]} - {p.cumulative_score} pts
77
+ </div>
78
+ ))}
79
+ </div>
80
+ )}
81
+
82
+ {/* Near Disqualification */}
83
+ {history.near_disqualification?.length > 0 && (
84
+ <div className="bg-amber-900/20 border border-amber-700 rounded-lg p-4">
85
+ <h4 className="font-bold text-amber-400 mb-2 flex items-center gap-2">
86
+ <AlertTriangle className="w-4 h-4" />
87
+ At Risk
88
+ </h4>
89
+ {history.near_disqualification.map((p: any) => (
90
+ <div key={p.user_id} className="text-sm text-amber-300">
91
+ {p.email?.split('@')[0]} - {p.cumulative_score} pts
92
+ </div>
93
+ ))}
94
+ </div>
95
+ )}
96
+
97
+ {/* Round History */}
98
+ <div>
99
+ <h4 className="font-bold text-white mb-3">Rounds ({history.total_rounds})</h4>
100
+ <div className="space-y-4">
101
+ {history.rounds?.map((round: any) => (
102
+ <div key={round.round} className="bg-slate-800 border border-slate-700 rounded-lg p-3">
103
+ <div className="font-bold text-green-400 mb-2">Round {round.round}</div>
104
+ <div className="space-y-1">
105
+ {round.players?.map((p: any, idx: number) => (
106
+ <div key={p.user_id} className="flex items-center justify-between text-sm">
107
+ <span className="flex items-center gap-2">
108
+ {idx === 0 && <Trophy className="w-3 h-3 text-amber-400" />}
109
+ <span className={idx === 0 ? 'text-amber-300 font-bold' : 'text-slate-300'}>
110
+ {p.user_email?.split('@')[0]}
111
+ </span>
112
+ </span>
113
+ <span className={idx === 0 ? 'text-green-400 font-bold' : 'text-red-400'}>
114
+ {p.points} pts
115
+ </span>
116
+ </div>
117
+ ))}
118
+ </div>
119
+ </div>
120
+ ))}
121
+ </div>
122
+ </div>
123
+ </div>
124
+ ) : (
125
+ <div className="text-center text-slate-400 py-8">No history available</div>
126
+ )}
127
+ </ScrollArea>
128
+ </div>
129
+ )}
130
+ </>
131
+ );
132
+ };
GameRules.jsx ADDED
@@ -0,0 +1,101 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React, { useState } from 'react';
2
+ import { X, ChevronRight, ChevronDown } from 'lucide-react';
3
+
4
+ interface Props {
5
+ defaultOpen?: boolean;
6
+ }
7
+
8
+ export const GameRules: React.FC<Props> = ({ defaultOpen = false }) => {
9
+ const [isOpen, setIsOpen] = useState(defaultOpen);
10
+
11
+ if (!isOpen) {
12
+ return (
13
+ <button
14
+ onClick={() => setIsOpen(true)}
15
+ className="fixed top-32 right-4 z-40 bg-green-800 hover:bg-green-700 text-green-100 px-3 py-2 rounded-lg shadow-lg flex items-center gap-2 text-sm"
16
+ >
17
+ <ChevronRight className="w-4 h-4" />
18
+ Game Rules
19
+ </button>
20
+ );
21
+ }
22
+
23
+ return (
24
+ <div className="fixed top-32 right-4 z-40 w-80 bg-background border border-border rounded-lg shadow-xl">
25
+ <div className="flex items-center justify-between p-3 border-b border-border bg-green-900/20">
26
+ <h3 className="font-semibold text-green-100 flex items-center gap-2">
27
+ <ChevronDown className="w-4 h-4" />
28
+ 13 Card Rummy Rules
29
+ </h3>
30
+ <button
31
+ onClick={() => setIsOpen(false)}
32
+ className="text-muted-foreground hover:text-foreground"
33
+ >
34
+ <X className="w-4 h-4" />
35
+ </button>
36
+ </div>
37
+ <div className="p-4 space-y-3 text-sm max-h-[60vh] overflow-y-auto">
38
+ <div>
39
+ <h4 className="font-medium text-foreground mb-1">Objective</h4>
40
+ <p className="text-muted-foreground text-xs">
41
+ Arrange your 13 cards into valid sequences and sets, then declare to win the round.
42
+ </p>
43
+ </div>
44
+
45
+ <div>
46
+ <h4 className="font-medium text-foreground mb-1">Valid Melds</h4>
47
+ <ul className="space-y-1 text-xs text-muted-foreground">
48
+ <li className="flex gap-2">
49
+ <span className="text-blue-400">•</span>
50
+ <span><strong className="text-blue-400">Pure Sequence:</strong> 3+ consecutive cards of same suit (no jokers). <em className="text-amber-400">Required to declare!</em></span>
51
+ </li>
52
+ <li className="flex gap-2">
53
+ <span className="text-purple-400">•</span>
54
+ <span><strong className="text-purple-400">Impure Sequence:</strong> 3+ consecutive cards, jokers allowed.</span>
55
+ </li>
56
+ <li className="flex gap-2">
57
+ <span className="text-purple-400">•</span>
58
+ <span><strong className="text-purple-400">Set:</strong> 3-4 cards of same rank, different suits.</span>
59
+ </li>
60
+ </ul>
61
+ </div>
62
+
63
+ <div>
64
+ <h4 className="font-medium text-foreground mb-1">Turn Flow</h4>
65
+ <ol className="space-y-1 text-xs text-muted-foreground list-decimal list-inside">
66
+ <li>Draw 1 card (from stock or discard pile)</li>
67
+ <li>Arrange cards into melds if needed</li>
68
+ <li>Discard 1 card</li>
69
+ <li>If you have valid melds, click "Declare" to win</li>
70
+ </ol>
71
+ </div>
72
+
73
+ <div>
74
+ <h4 className="font-medium text-foreground mb-1">Declaration Rules</h4>
75
+ <ul className="space-y-1 text-xs text-muted-foreground">
76
+ <li>• Must have at least 1 pure sequence</li>
77
+ <li>• Must have at least 2 total sequences/sets</li>
78
+ <li>• All 13 cards must be in valid melds</li>
79
+ </ul>
80
+ </div>
81
+
82
+ <div>
83
+ <h4 className="font-medium text-foreground mb-1">Scoring</h4>
84
+ <ul className="space-y-1 text-xs text-muted-foreground">
85
+ <li>• Winner scores 0 points</li>
86
+ <li>• Others score sum of ungrouped cards (max 80)</li>
87
+ <li>• A, K, Q, J = 10 points each</li>
88
+ <li>• Number cards = face value</li>
89
+ <li>• Jokers = 0 points</li>
90
+ </ul>
91
+ </div>
92
+
93
+ <div className="bg-amber-900/20 border border-amber-700/50 rounded p-2">
94
+ <p className="text-xs text-amber-200">
95
+ <strong>Disqualification:</strong> First player to reach the target score (default 200) is eliminated.
96
+ </p>
97
+ </div>
98
+ </div>
99
+ </div>
100
+ );
101
+ };
HandStrip.jsx ADDED
@@ -0,0 +1,144 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React, { useState } from "react";
2
+ import type { RoundMeResponse } from "../apiclient/data-contracts";
3
+ import { PlayingCard } from "./PlayingCard";
4
+
5
+ export interface Props {
6
+ hand: RoundMeResponse["hand"];
7
+ onCardClick?: (card: RoundMeResponse["hand"][number], index: number) => void;
8
+ selectedIndex?: number;
9
+ highlightIndex?: number;
10
+ onReorder?: (reorderedHand: RoundMeResponse["hand"]) => void;
11
+ }
12
+
13
+ export const HandStrip: React.FC<Props> = ({ hand, onCardClick, selectedIndex, highlightIndex, onReorder }) => {
14
+ const [draggedIndex, setDraggedIndex] = useState<number | null>(null);
15
+ const [dropTargetIndex, setDropTargetIndex] = useState<number | null>(null);
16
+ const [touchStartIndex, setTouchStartIndex] = useState<number | null>(null);
17
+ const [touchPosition, setTouchPosition] = useState<{ x: number; y: number } | null>(null);
18
+
19
+ // Mouse/Desktop drag handlers
20
+ const handleDragStart = (e: React.DragEvent, index: number) => {
21
+ setDraggedIndex(index);
22
+ e.dataTransfer.effectAllowed = "move";
23
+ const card = hand[index];
24
+ e.dataTransfer.setData('card', JSON.stringify(card));
25
+ };
26
+
27
+ const handleDragOver = (e: React.DragEvent, index: number) => {
28
+ e.preventDefault();
29
+ if (draggedIndex !== null && draggedIndex !== index) {
30
+ setDropTargetIndex(index);
31
+ }
32
+ };
33
+
34
+ const handleDragLeave = () => {
35
+ setDropTargetIndex(null);
36
+ };
37
+
38
+ const handleDrop = (e: React.DragEvent, dropIndex: number) => {
39
+ e.preventDefault();
40
+ if (draggedIndex === null || draggedIndex === dropIndex) {
41
+ setDraggedIndex(null);
42
+ setDropTargetIndex(null);
43
+ return;
44
+ }
45
+
46
+ const newHand = [...hand];
47
+ const [draggedCard] = newHand.splice(draggedIndex, 1);
48
+ newHand.splice(dropIndex, 0, draggedCard);
49
+
50
+ if (onReorder) {
51
+ onReorder(newHand);
52
+ }
53
+
54
+ setDraggedIndex(null);
55
+ setDropTargetIndex(null);
56
+ };
57
+
58
+ const handleDragEnd = () => {
59
+ setDraggedIndex(null);
60
+ setDropTargetIndex(null);
61
+ };
62
+
63
+ // Touch/Mobile handlers
64
+ const handleTouchStart = (e: React.TouchEvent, index: number) => {
65
+ const touch = e.touches[0];
66
+ setTouchStartIndex(index);
67
+ setDraggedIndex(index);
68
+ setTouchPosition({ x: touch.clientX, y: touch.clientY });
69
+ console.log('📱 Touch drag started for card', index);
70
+ };
71
+
72
+ const handleTouchMove = (e: React.TouchEvent) => {
73
+ if (touchStartIndex === null) return;
74
+ e.preventDefault(); // Prevent scrolling while dragging
75
+ const touch = e.touches[0];
76
+ setTouchPosition({ x: touch.clientX, y: touch.clientY });
77
+
78
+ // Find which card is under the touch
79
+ const element = document.elementFromPoint(touch.clientX, touch.clientY);
80
+ const cardElement = element?.closest('[data-card-index]');
81
+ if (cardElement) {
82
+ const targetIndex = Number(cardElement.getAttribute('data-card-index'));
83
+ if (targetIndex !== touchStartIndex) {
84
+ setDropTargetIndex(targetIndex);
85
+ console.log('📱 Dragging over card', targetIndex);
86
+ }
87
+ }
88
+ };
89
+
90
+ const handleTouchEnd = () => {
91
+ console.log('📱 Touch drag ended', { touchStartIndex, dropTargetIndex });
92
+ if (touchStartIndex !== null && dropTargetIndex !== null && touchStartIndex !== dropTargetIndex) {
93
+ const newHand = [...hand];
94
+ const [draggedCard] = newHand.splice(touchStartIndex, 1);
95
+ newHand.splice(dropTargetIndex, 0, draggedCard);
96
+
97
+ if (onReorder) {
98
+ console.log('📱 Reordering hand');
99
+ onReorder(newHand);
100
+ }
101
+ }
102
+
103
+ setTouchStartIndex(null);
104
+ setDraggedIndex(null);
105
+ setDropTargetIndex(null);
106
+ setTouchPosition(null);
107
+ };
108
+
109
+ return (
110
+ <div className="w-full overflow-x-auto">
111
+ <div className="grid grid-cols-3 sm:grid-cols-5 md:grid-cols-7 gap-2 py-4">
112
+ {hand.map((card, idx) => (
113
+ <div
114
+ key={`${card.code}-${idx}`}
115
+ data-card-index={idx}
116
+ draggable={!!onReorder}
117
+ onDragStart={(e) => handleDragStart(e, idx)}
118
+ onDragOver={(e) => handleDragOver(e, idx)}
119
+ onDragLeave={handleDragLeave}
120
+ onDrop={(e) => handleDrop(e, idx)}
121
+ onDragEnd={handleDragEnd}
122
+ onTouchStart={(e) => handleTouchStart(e, idx)}
123
+ onTouchMove={handleTouchMove}
124
+ onTouchEnd={handleTouchEnd}
125
+ className={`transition-all duration-200 ${
126
+ idx === draggedIndex ? 'opacity-50 scale-95' : ''
127
+ } ${
128
+ idx === dropTargetIndex ? 'scale-105 ring-2 ring-amber-400' : ''
129
+ }`}
130
+ >
131
+ <PlayingCard
132
+ card={card}
133
+ onClick={onCardClick ? () => onCardClick(card, idx) : undefined}
134
+ selected={selectedIndex === idx}
135
+ />
136
+ {idx === highlightIndex && (
137
+ <div className="absolute -top-1 -right-1 w-3 h-3 bg-amber-400 rounded-full animate-ping" />
138
+ )}
139
+ </div>
140
+ ))}
141
+ </div>
142
+ </div>
143
+ );
144
+ };
HistoryTable.jsx ADDED
@@ -0,0 +1,121 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useState, useEffect } from 'react';
2
+ import { apiClient } from 'app';
3
+ import { ScrollArea } from '@/components/ui/scroll-area';
4
+ import { Trophy, Users, Crown, XCircle } from 'lucide-react';
5
+ import { toast } from 'sonner';
6
+
7
+ interface HistoryEntry {
8
+ round_number: number;
9
+ winner_user_id: string | null;
10
+ winner_name: string | null;
11
+ disqualified_users: string[];
12
+ completed_at: string;
13
+ }
14
+
15
+ interface Props {
16
+ tableId: string;
17
+ }
18
+
19
+ export default function HistoryTable({ tableId }: Props) {
20
+ const [history, setHistory] = useState<HistoryEntry[]>([]);
21
+ const [loading, setLoading] = useState(true);
22
+
23
+ useEffect(() => {
24
+ const fetchHistory = async () => {
25
+ try {
26
+ const response = await apiClient.get_round_history({ table_id: tableId });
27
+ const data = await response.json();
28
+ setHistory(data.rounds || []);
29
+ } catch (error) {
30
+ console.error('Failed to fetch round history:', error);
31
+ toast.error('Failed to load game history');
32
+ } finally {
33
+ setLoading(false);
34
+ }
35
+ };
36
+
37
+ fetchHistory();
38
+ }, [tableId]);
39
+
40
+ const formatTimestamp = (timestamp: string) => {
41
+ const date = new Date(timestamp);
42
+ return date.toLocaleString('en-US', {
43
+ month: 'short',
44
+ day: 'numeric',
45
+ hour: '2-digit',
46
+ minute: '2-digit'
47
+ });
48
+ };
49
+
50
+ if (loading) {
51
+ return (
52
+ <div className="flex items-center justify-center p-8">
53
+ <div className="animate-spin rounded-full h-8 w-8 border-b-2 border-green-500" />
54
+ </div>
55
+ );
56
+ }
57
+
58
+ if (history.length === 0) {
59
+ return (
60
+ <div className="text-center p-8 text-slate-400">
61
+ <Trophy className="w-12 h-12 mx-auto mb-3 opacity-50" />
62
+ <p className="text-sm">No rounds completed yet</p>
63
+ </div>
64
+ );
65
+ }
66
+
67
+ return (
68
+ <ScrollArea className="h-[400px]">
69
+ <div className="space-y-3 p-4">
70
+ {history.map((round) => (
71
+ <div
72
+ key={round.round_number}
73
+ className="bg-slate-800/50 border border-slate-700 rounded-lg p-4 hover:bg-slate-800/70 transition-colors"
74
+ >
75
+ <div className="flex items-center justify-between mb-2">
76
+ <div className="flex items-center gap-2">
77
+ <div className="bg-green-900/30 rounded-full p-2">
78
+ <Trophy className="w-4 h-4 text-green-400" />
79
+ </div>
80
+ <span className="font-semibold text-white">Round {round.round_number}</span>
81
+ </div>
82
+ <span className="text-xs text-slate-400">
83
+ {formatTimestamp(round.completed_at)}
84
+ </span>
85
+ </div>
86
+
87
+ {/* Winner */}
88
+ {round.winner_user_id && (
89
+ <div className="flex items-center gap-2 mb-2">
90
+ <Crown className="w-4 h-4 text-amber-400" />
91
+ <span className="text-sm text-amber-400 font-medium">
92
+ Winner: {round.winner_name || round.winner_user_id.slice(0, 8)}
93
+ </span>
94
+ </div>
95
+ )}
96
+
97
+ {/* Disqualified Players */}
98
+ {round.disqualified_users.length > 0 && (
99
+ <div className="flex items-start gap-2">
100
+ <XCircle className="w-4 h-4 text-red-400 mt-0.5" />
101
+ <div className="flex-1">
102
+ <p className="text-xs text-red-400 font-medium mb-1">Disqualified:</p>
103
+ <div className="flex flex-wrap gap-1">
104
+ {round.disqualified_users.map((userId) => (
105
+ <span
106
+ key={userId}
107
+ className="text-xs bg-red-900/30 text-red-300 px-2 py-0.5 rounded"
108
+ >
109
+ {userId.slice(0, 8)}
110
+ </span>
111
+ ))}
112
+ </div>
113
+ </div>
114
+ </div>
115
+ )}
116
+ </div>
117
+ ))}
118
+ </div>
119
+ </ScrollArea>
120
+ );
121
+ }
Home.jsx ADDED
@@ -0,0 +1,287 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React, { useState } from 'react';
2
+ import { useNavigate } from 'react-router-dom';
3
+ import { Button } from '@/components/ui/button';
4
+ import { Card } from '@/components/ui/card';
5
+ import { Input } from '@/components/ui/input';
6
+ import { Sparkles, Eye, EyeOff, LogIn, User2, LogOut } from 'lucide-react';
7
+ import { toast } from 'sonner';
8
+ import apiclient from '../apiclient';
9
+ import type { JoinByCodeRequest } from '../apiclient/data-contracts';
10
+ import { useUser } from '@stackframe/react';
11
+ import { stackClientApp } from 'app/auth';
12
+
13
+ interface GameVariant {
14
+ id: string;
15
+ title: string;
16
+ description: string;
17
+ features: string[];
18
+ icon: React.ReactNode;
19
+ color: string;
20
+ }
21
+
22
+ const gameVariants: GameVariant[] = [
23
+ {
24
+ id: 'no_wildcard',
25
+ title: 'No Wild Card',
26
+ description: 'Pure classic rummy with printed jokers only',
27
+ features: [
28
+ 'No wild joker cards',
29
+ 'Only printed jokers',
30
+ 'Simpler strategy',
31
+ 'Perfect for beginners'
32
+ ],
33
+ icon: <EyeOff className="w-12 h-12" />,
34
+ color: 'from-blue-500 to-cyan-500'
35
+ },
36
+ {
37
+ id: 'open_wildcard',
38
+ title: 'Open Wild Card',
39
+ description: 'Traditional rummy with wild card revealed at start',
40
+ features: [
41
+ 'Wild card shown immediately',
42
+ 'Traditional gameplay',
43
+ 'Strategic substitutions',
44
+ 'Classic experience'
45
+ ],
46
+ icon: <Eye className="w-12 h-12" />,
47
+ color: 'from-green-500 to-emerald-500'
48
+ },
49
+ {
50
+ id: 'close_wildcard',
51
+ title: 'Close Wild Card',
52
+ description: 'Advanced variant - wild card reveals after first sequence',
53
+ features: [
54
+ 'Hidden wild card initially',
55
+ 'Reveals after pure sequence',
56
+ 'Advanced strategy',
57
+ 'Maximum challenge'
58
+ ],
59
+ icon: <Sparkles className="w-12 h-12" />,
60
+ color: 'from-purple-500 to-pink-500'
61
+ }
62
+ ];
63
+
64
+ export default function App() {
65
+ const navigate = useNavigate();
66
+ const user = useUser();
67
+ const [roomCode, setRoomCode] = useState('');
68
+ const [playerName, setPlayerName] = useState('');
69
+ const [joining, setJoining] = useState(false);
70
+
71
+ const handleSelectVariant = (variantId: string) => {
72
+ navigate(`/CreateTable?variant=${variantId}`);
73
+ };
74
+
75
+ const handleJoinRoom = async () => {
76
+ if (!user) {
77
+ toast.error('Please sign in to join a room');
78
+ return;
79
+ }
80
+ if (!playerName.trim() || roomCode.trim().length !== 6) {
81
+ toast.error('Enter your name and a valid 6-letter code');
82
+ return;
83
+ }
84
+ setJoining(true);
85
+ try {
86
+ const body: JoinByCodeRequest = { code: roomCode.trim().toUpperCase() };
87
+ const res = await apiclient.join_table_by_code(body);
88
+
89
+ // Check if response is ok
90
+ if (!res.ok) {
91
+ const errorData = await res.json().catch(() => ({ detail: 'Unknown error' }));
92
+ const errorMsg = errorData.detail || errorData.message || 'Failed to join room';
93
+ toast.error(`Join failed: ${errorMsg}`);
94
+ setJoining(false);
95
+ return;
96
+ }
97
+
98
+ const data = await res.json();
99
+
100
+ toast.success(`Joined table! Seat ${data.seat}`);
101
+ navigate(`/Table?tableId=${data.table_id}`);
102
+ } catch (e: any) {
103
+ console.error('Join room error:', e);
104
+ const errorMsg = e.message || e.detail || 'Failed to join room. Check the code and try again.';
105
+ toast.error(errorMsg);
106
+ } finally {
107
+ setJoining(false);
108
+ }
109
+ };
110
+
111
+ const handleSignIn = () => {
112
+ stackClientApp.redirectToSignIn();
113
+ };
114
+
115
+ const handleSignOut = async () => {
116
+ await stackClientApp.signOut();
117
+ toast.success('Signed out successfully');
118
+ };
119
+
120
+ return (
121
+ <div className="min-h-screen bg-gradient-to-br from-slate-900 via-slate-800 to-slate-900">
122
+ {/* Header */}
123
+ <div className="border-b border-slate-700/50 bg-slate-900/50 backdrop-blur">
124
+ <div className="max-w-7xl mx-auto px-6 py-6">
125
+ <div className="flex items-center justify-between">
126
+ <div>
127
+ <h1 className="text-4xl font-bold text-white tracking-tight">Rummy Room</h1>
128
+ <p className="text-slate-400 mt-2">Choose your game variant</p>
129
+ </div>
130
+
131
+ {/* Auth Section */}
132
+ <div className="flex items-center gap-4">
133
+ {user ? (
134
+ <div className="flex items-center gap-3">
135
+ {/* Profile Picture */}
136
+ <div className="w-10 h-10 rounded-full border-2 border-green-500 overflow-hidden bg-slate-700 flex items-center justify-center">
137
+ <User2 className="w-5 h-5 text-white" />
138
+ </div>
139
+
140
+ {/* User Name */}
141
+ <div className="flex flex-col">
142
+ <span className="text-sm font-medium text-white">
143
+ {user.displayName || 'Player'}
144
+ </span>
145
+ <span className="text-xs text-slate-400">
146
+ {user.primaryEmail}
147
+ </span>
148
+ </div>
149
+
150
+ {/* Sign Out Button */}
151
+ <Button
152
+ onClick={handleSignOut}
153
+ variant="outline"
154
+ size="sm"
155
+ className="border-slate-600 text-slate-300 hover:bg-slate-700 hover:text-white"
156
+ >
157
+ <LogOut className="w-4 h-4 mr-2" />
158
+ Sign Out
159
+ </Button>
160
+ </div>
161
+ ) : (
162
+ <Button
163
+ onClick={handleSignIn}
164
+ className="bg-gradient-to-r from-green-500 to-emerald-500 hover:opacity-90 text-white font-semibold"
165
+ >
166
+ <LogIn className="w-4 h-4 mr-2" />
167
+ Sign In
168
+ </Button>
169
+ )}
170
+ </div>
171
+ </div>
172
+ </div>
173
+ </div>
174
+
175
+ {/* Game Variants Grid */}
176
+ <div className="max-w-7xl mx-auto px-6 py-12">
177
+ <div className="grid grid-cols-1 md:grid-cols-3 gap-8">
178
+ {gameVariants.map((variant) => (
179
+ <Card
180
+ key={variant.id}
181
+ className="group relative overflow-hidden bg-slate-800/50 border-slate-700 hover:border-slate-600 transition-all duration-300 hover:scale-105 cursor-pointer"
182
+ onClick={() => handleSelectVariant(variant.id)}
183
+ >
184
+ {/* Gradient Background */}
185
+ <div className={`absolute inset-0 bg-gradient-to-br ${variant.color} opacity-10 group-hover:opacity-20 transition-opacity`} />
186
+
187
+ <div className="relative p-8">
188
+ {/* Icon */}
189
+ <div className={`w-20 h-20 rounded-2xl bg-gradient-to-br ${variant.color} flex items-center justify-center text-white mb-6 group-hover:scale-110 transition-transform`}>
190
+ {variant.icon}
191
+ </div>
192
+
193
+ {/* Title */}
194
+ <h2 className="text-2xl font-bold text-white mb-3">{variant.title}</h2>
195
+
196
+ {/* Description */}
197
+ <p className="text-slate-400 mb-6">{variant.description}</p>
198
+
199
+ {/* Features */}
200
+ <ul className="space-y-2 mb-8">
201
+ {variant.features.map((feature, idx) => (
202
+ <li key={idx} className="flex items-center text-sm text-slate-300">
203
+ <div className={`w-1.5 h-1.5 rounded-full bg-gradient-to-r ${variant.color} mr-3`} />
204
+ {feature}
205
+ </li>
206
+ ))}
207
+ </ul>
208
+
209
+ {/* Button */}
210
+ <Button
211
+ className={`w-full bg-gradient-to-r ${variant.color} hover:opacity-90 text-white font-semibold`}
212
+ onClick={(e) => {
213
+ e.stopPropagation();
214
+ handleSelectVariant(variant.id);
215
+ }}
216
+ >
217
+ Play {variant.title}
218
+ </Button>
219
+ </div>
220
+ </Card>
221
+ ))}
222
+ </div>
223
+
224
+ {/* Join Table Section */}
225
+ <div className="mt-16">
226
+ <div className="text-center mb-8">
227
+ <h2 className="text-3xl font-bold text-white mb-2">Join a Friend's Game</h2>
228
+ <p className="text-slate-400">Enter the 6-letter room code shared by your friend</p>
229
+ </div>
230
+
231
+ <Card className="max-w-2xl mx-auto bg-slate-800/50 border-slate-700 p-8">
232
+ <div className="flex items-center gap-3 mb-6">
233
+ <div className="w-12 h-12 rounded-xl bg-gradient-to-br from-orange-500 to-red-500 flex items-center justify-center">
234
+ <LogIn className="w-6 h-6 text-white" />
235
+ </div>
236
+ <div>
237
+ <h3 className="text-xl font-semibold text-white">Join Room</h3>
238
+ <p className="text-sm text-slate-400">Enter details to join the table</p>
239
+ </div>
240
+ </div>
241
+
242
+ <div className="space-y-4">
243
+ <div>
244
+ <label className="block text-sm font-medium text-slate-300 mb-2">
245
+ Your Name
246
+ </label>
247
+ <Input
248
+ type="text"
249
+ value={playerName}
250
+ onChange={(e) => setPlayerName(e.target.value)}
251
+ placeholder="Enter your name"
252
+ className="bg-slate-900/50 border-slate-600 text-white placeholder:text-slate-500"
253
+ />
254
+ </div>
255
+
256
+ <div>
257
+ <label className="block text-sm font-medium text-slate-300 mb-2">
258
+ Room Code
259
+ </label>
260
+ <Input
261
+ type="text"
262
+ value={roomCode}
263
+ onChange={(e) => setRoomCode(e.target.value.toUpperCase())}
264
+ placeholder="Enter 6-letter code"
265
+ maxLength={6}
266
+ className="bg-slate-900/50 border-slate-600 text-white placeholder:text-slate-500 uppercase tracking-wider text-lg font-mono text-center"
267
+ />
268
+ </div>
269
+
270
+ <Button
271
+ onClick={handleJoinRoom}
272
+ disabled={joining || !playerName.trim() || roomCode.length !== 6 || !user}
273
+ className="w-full bg-gradient-to-r from-orange-500 to-red-500 hover:opacity-90 text-white font-semibold py-6 text-lg"
274
+ >
275
+ {joining ? 'Joining...' : 'Join Game'}
276
+ </Button>
277
+
278
+ {!user && (
279
+ <p className="text-sm text-amber-400 text-center">Please sign in to join a room</p>
280
+ )}
281
+ </div>
282
+ </Card>
283
+ </div>
284
+ </div>
285
+ </div>
286
+ );
287
+ }
Home.png ADDED

Git LFS Details

  • SHA256: 0066c3f61bf1e9e6b47e7ab8190a0b4108fd313a5205de3b7da7e333832c7cc3
  • Pointer size: 131 Bytes
  • Size of remote file: 319 kB
PlayerProfile.jsx ADDED
@@ -0,0 +1,60 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React from 'react';
2
+ import { User } from 'lucide-react';
3
+
4
+ interface Props {
5
+ position: string;
6
+ name: string;
7
+ profilePic?: string | null;
8
+ isActive?: boolean;
9
+ isCurrentUser?: boolean;
10
+ }
11
+
12
+ export const PlayerProfile: React.FC<Props> = ({ position, name, profilePic, isActive = false, isCurrentUser = false }) => {
13
+ return (
14
+ <div
15
+ className={`pointer-events-auto flex flex-col items-center gap-2 p-3 rounded-lg transition-all ${
16
+ isActive
17
+ ? 'bg-amber-500/90 border-2 border-amber-300 shadow-lg shadow-amber-500/50 scale-110'
18
+ : isCurrentUser
19
+ ? 'bg-blue-900/80 border-2 border-blue-500/50'
20
+ : 'bg-slate-800/80 border-2 border-slate-600/50'
21
+ }`}
22
+ >
23
+ {/* Avatar with Profile Picture */}
24
+ <div className={`w-12 h-12 rounded-full flex items-center justify-center overflow-hidden ${
25
+ isActive
26
+ ? 'bg-amber-400 border-2 border-white'
27
+ : isCurrentUser
28
+ ? 'bg-blue-600 border-2 border-blue-400'
29
+ : 'bg-slate-700 border-2 border-slate-500'
30
+ }`}>
31
+ {profilePic ? (
32
+ <img src={profilePic} alt={name} className="w-full h-full object-cover" />
33
+ ) : (
34
+ <User className={`w-6 h-6 ${
35
+ isActive ? 'text-white' : isCurrentUser ? 'text-blue-200' : 'text-slate-300'
36
+ }`} />
37
+ )}
38
+ </div>
39
+
40
+ {/* Name */}
41
+ <div className="text-center">
42
+ <p className={`text-xs font-bold ${
43
+ isActive ? 'text-amber-100' : isCurrentUser ? 'text-blue-100' : 'text-slate-200'
44
+ }`}>
45
+ {name}
46
+ </p>
47
+ <p className={`text-[10px] ${
48
+ isActive ? 'text-amber-200' : isCurrentUser ? 'text-blue-300' : 'text-slate-400'
49
+ }`}>
50
+ {position}
51
+ </p>
52
+ </div>
53
+
54
+ {/* Active Turn Indicator */}
55
+ {isActive && (
56
+ <div className="absolute -top-2 -right-2 w-5 h-5 bg-green-500 border-2 border-white rounded-full animate-pulse" />
57
+ )}
58
+ </div>
59
+ );
60
+ };
PlayingCard.jsx ADDED
@@ -0,0 +1,67 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React from "react";
2
+ import type { CardView } from "../apiclient/data-contracts";
3
+
4
+ export interface Props {
5
+ card: CardView;
6
+ onClick?: () => void;
7
+ selected?: boolean;
8
+ }
9
+
10
+ const suitSymbols: Record<string, string> = {
11
+ H: "♥",
12
+ D: "♦",
13
+ S: "♠",
14
+ C: "♣",
15
+ };
16
+
17
+ const suitColors: Record<string, string> = {
18
+ H: "text-red-500",
19
+ D: "text-red-500",
20
+ S: "text-gray-900",
21
+ C: "text-gray-900",
22
+ };
23
+
24
+ export const PlayingCard: React.FC<Props> = ({ card, onClick, selected }) => {
25
+ const isJoker = card.joker || card.rank === "JOKER";
26
+ const suit = card.suit || "";
27
+ const suitSymbol = suitSymbols[suit] || "";
28
+ const suitColor = suitColors[suit] || "text-gray-900 dark:text-gray-100";
29
+
30
+ return (
31
+ <div
32
+ onClick={onClick}
33
+ className={`
34
+ relative w-full aspect-[2/3] min-w-[60px] max-w-[100px] rounded-lg bg-white dark:bg-gray-100
35
+ border-2 shadow-md transition-all cursor-pointer touch-manipulation
36
+ hover:scale-105 hover:shadow-lg active:scale-95
37
+ ${selected ? "border-amber-500 ring-2 ring-amber-400 scale-105" : "border-gray-300"}
38
+ ${onClick ? "" : "cursor-default"}
39
+ `}
40
+ title={card.code}
41
+ >
42
+ <div className="absolute inset-0 p-1 sm:p-2 flex flex-col items-center justify-center">
43
+ {isJoker ? (
44
+ <div className="text-center">
45
+ <div className="text-2xl font-bold text-purple-600">★</div>
46
+ <div className="text-xs font-semibold text-purple-600 mt-1">JOKER</div>
47
+ </div>
48
+ ) : (
49
+ <>
50
+ <div className={`text-xs font-bold ${suitColor} absolute top-1 left-2`}>
51
+ {card.rank}
52
+ </div>
53
+ <div className={`text-3xl font-bold ${suitColor}`}>
54
+ {suitSymbol}
55
+ </div>
56
+ <div className={`text-lg font-bold ${suitColor} mt-1`}>
57
+ {card.rank}
58
+ </div>
59
+ <div className={`text-xs font-bold ${suitColor} absolute bottom-1 right-2 rotate-180`}>
60
+ {card.rank}
61
+ </div>
62
+ </>
63
+ )}
64
+ </div>
65
+ </div>
66
+ );
67
+ };
PointsTable.jsx ADDED
@@ -0,0 +1,115 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React, { useState } from 'react';
2
+ import { ChevronDown, ChevronUp, Trophy } from 'lucide-react';
3
+ import type { TableInfoResponse } from '../apiclient/data-contracts';
4
+
5
+ export interface Props {
6
+ info: TableInfoResponse;
7
+ roundHistory: Array<{
8
+ round_number: number;
9
+ winner_user_id: string | null;
10
+ scores: Record<string, number>;
11
+ }>;
12
+ }
13
+
14
+ export const PointsTable: React.FC<Props> = ({ info, roundHistory }) => {
15
+ const [isOpen, setIsOpen] = useState(true);
16
+
17
+ // Calculate cumulative scores
18
+ const cumulativeScores: Record<string, number[]> = {};
19
+ info.players.forEach(player => {
20
+ cumulativeScores[player.user_id] = [];
21
+ });
22
+
23
+ let runningTotals: Record<string, number> = {};
24
+ info.players.forEach(player => {
25
+ runningTotals[player.user_id] = 0;
26
+ });
27
+
28
+ roundHistory.forEach(round => {
29
+ info.players.forEach(player => {
30
+ const roundScore = round.scores[player.user_id] || 0;
31
+ runningTotals[player.user_id] += roundScore;
32
+ cumulativeScores[player.user_id].push(runningTotals[player.user_id]);
33
+ });
34
+ });
35
+
36
+ if (roundHistory.length === 0) {
37
+ return null;
38
+ }
39
+
40
+ return (
41
+ <div className="fixed top-4 right-4 z-20 bg-card border border-border rounded-lg shadow-lg w-96">
42
+ {/* Header */}
43
+ <button
44
+ onClick={() => setIsOpen(!isOpen)}
45
+ className="w-full flex items-center justify-between p-3 hover:bg-accent/50 transition-colors rounded-t-lg"
46
+ >
47
+ <div className="flex items-center gap-2">
48
+ <Trophy className="w-4 h-4 text-yellow-500" />
49
+ <span className="font-semibold text-foreground">Points Table</span>
50
+ </div>
51
+ {isOpen ? (
52
+ <ChevronUp className="w-4 h-4 text-muted-foreground" />
53
+ ) : (
54
+ <ChevronDown className="w-4 h-4 text-muted-foreground" />
55
+ )}
56
+ </button>
57
+
58
+ {/* Content */}
59
+ {isOpen && (
60
+ <div className="p-3 border-t border-border max-h-96 overflow-y-auto">
61
+ <div className="overflow-x-auto">
62
+ <table className="w-full text-sm">
63
+ <thead>
64
+ <tr className="border-b border-border">
65
+ <th className="text-left py-2 px-2 font-semibold text-foreground">Player</th>
66
+ {roundHistory.map((round, idx) => (
67
+ <th key={idx} className="text-center py-2 px-2 font-semibold text-foreground">
68
+ R{round.round_number}
69
+ </th>
70
+ ))}
71
+ <th className="text-right py-2 px-2 font-semibold text-yellow-600 dark:text-yellow-500">
72
+ Total
73
+ </th>
74
+ </tr>
75
+ </thead>
76
+ <tbody>
77
+ {info.players.map(player => {
78
+ const totalScore = runningTotals[player.user_id] || 0;
79
+ return (
80
+ <tr key={player.user_id} className="border-b border-border/50 hover:bg-accent/30">
81
+ <td className="py-2 px-2 text-foreground">
82
+ <div className="flex items-center gap-1">
83
+ {player.display_name || 'Player'}
84
+ </div>
85
+ </td>
86
+ {roundHistory.map((round, idx) => {
87
+ const isWinner = round.winner_user_id === player.user_id;
88
+ const roundScore = round.scores[player.user_id] || 0;
89
+ return (
90
+ <td key={idx} className="text-center py-2 px-2">
91
+ <div className="flex flex-col items-center">
92
+ <span className={isWinner ? 'text-green-600 dark:text-green-500 font-semibold' : 'text-muted-foreground'}>
93
+ {roundScore}
94
+ </span>
95
+ {isWinner && <Trophy className="w-3 h-3 text-yellow-500" />}
96
+ </div>
97
+ </td>
98
+ );
99
+ })}
100
+ <td className="text-right py-2 px-2">
101
+ <span className="font-bold text-yellow-600 dark:text-yellow-500">
102
+ {totalScore}
103
+ </span>
104
+ </td>
105
+ </tr>
106
+ );
107
+ })}
108
+ </tbody>
109
+ </table>
110
+ </div>
111
+ </div>
112
+ )}
113
+ </div>
114
+ );
115
+ };
ProfileCard.jsx ADDED
@@ -0,0 +1,73 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React, { useEffect, useState } from "react";
2
+ import apiclient from "../apiclient";
3
+ import type { GetMyProfileData } from "../apiclient/data-contracts";
4
+ import { useUser } from "@stackframe/react";
5
+ import { useNavigate } from "react-router-dom";
6
+ import { toast } from "sonner";
7
+
8
+ export interface Props {}
9
+
10
+ export const ProfileCard: React.FC<Props> = () => {
11
+ const user = useUser();
12
+ const navigate = useNavigate();
13
+ const [loading, setLoading] = useState(false);
14
+ const [profile, setProfile] = useState<GetMyProfileData | null>(null);
15
+ const [error, setError] = useState<string | null>(null);
16
+
17
+ useEffect(() => {
18
+ const load = async () => {
19
+ if (!user) return; // not signed in yet
20
+ setLoading(true);
21
+ setError(null);
22
+ try {
23
+ const res = await apiclient.get_my_profile();
24
+ const data = await res.json();
25
+ setProfile(data);
26
+ } catch (e: any) {
27
+ setError("Failed to load profile");
28
+ } finally {
29
+ setLoading(false);
30
+ }
31
+ };
32
+ load();
33
+ }, [user?.id]);
34
+
35
+ if (!user) {
36
+ return (
37
+ <div className="bg-card border border-border rounded-lg p-4">
38
+ <div className="flex items-center justify-between gap-3">
39
+ <p className="text-sm text-muted-foreground">Sign in to manage your profile.</p>
40
+ <button
41
+ onClick={() => navigate("/auth/sign-in")}
42
+ className="px-3 py-1.5 bg-primary text-primary-foreground rounded-md text-sm hover:bg-primary/90"
43
+ >
44
+ Sign in
45
+ </button>
46
+ </div>
47
+ </div>
48
+ );
49
+ }
50
+
51
+ return (
52
+ <div className="bg-card border border-border rounded-lg p-4">
53
+ <div className="flex items-center justify-between mb-3">
54
+ <h3 className="text-lg font-medium">Your Profile</h3>
55
+ {loading && <span className="text-xs text-muted-foreground">Loading…</span>}
56
+ </div>
57
+ {error && <p className="text-sm text-red-400 mb-2">{error}</p>}
58
+ <div className="space-y-2">
59
+ <div>
60
+ <label className="block text-xs text-muted-foreground mb-1">Display Name</label>
61
+ <input
62
+ value={profile?.display_name || user?.displayName || 'Anonymous'}
63
+ readOnly
64
+ className="w-full px-3 py-2 bg-muted/50 border border-input rounded-md text-foreground cursor-not-allowed"
65
+ />
66
+ </div>
67
+ {profile && (
68
+ <p className="text-xs text-muted-foreground">User ID: <span className="text-foreground">{profile.user_id}</span></p>
69
+ )}
70
+ </div>
71
+ </div>
72
+ );
73
+ };
ScoreboardModal.jsx ADDED
@@ -0,0 +1,248 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React, { useState } from 'react';
2
+ import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
3
+ import { Button } from "@/components/ui/button";
4
+ import { Trophy, Crown } from "lucide-react";
5
+ import { PlayingCard } from "./PlayingCard";
6
+ import type { RevealedHandsResponse } from "../apiclient/data-contracts";
7
+ import { toast } from 'sonner';
8
+ import apiclient from '../apiclient';
9
+
10
+ export interface Props {
11
+ isOpen: boolean;
12
+ onClose: () => void;
13
+ data: RevealedHandsResponse | null;
14
+ players: Array<{ user_id: string; display_name?: string | null }>;
15
+ currentUserId: string;
16
+ tableId: string;
17
+ hostUserId: string;
18
+ onNextRound?: () => void;
19
+ }
20
+
21
+ export const ScoreboardModal: React.FC<Props> = ({ isOpen, onClose, data, players, currentUserId, tableId, hostUserId, onNextRound }) => {
22
+ const [startingNextRound, setStartingNextRound] = useState(false);
23
+
24
+ if (!data) return null;
25
+
26
+ // Sort players by score (lowest first, as lower is better)
27
+ const sortedPlayers = players
28
+ .filter(p => data.scores[p.user_id] !== undefined)
29
+ .map(p => ({
30
+ ...p,
31
+ score: data.scores[p.user_id],
32
+ cards: data.revealed_hands[p.user_id] || [],
33
+ organized: data.organized_melds?.[p.user_id] || null,
34
+ isWinner: p.user_id === data.winner_user_id
35
+ }))
36
+ .sort((a, b) => a.score - b.score);
37
+
38
+ const winnerName = sortedPlayers.find(p => p.isWinner)?.display_name || "Winner";
39
+ const isHost = currentUserId === hostUserId;
40
+
41
+ const handleStartNextRound = async () => {
42
+ setStartingNextRound(true);
43
+ try {
44
+ await apiclient.start_next_round({ table_id: tableId });
45
+ toast.success('Starting next round!');
46
+ onClose();
47
+ if (onNextRound) onNextRound();
48
+ } catch (error: any) {
49
+ const errorMessage = error?.error?.detail || error?.message || 'Failed to start next round';
50
+ toast.error(errorMessage);
51
+ } finally {
52
+ setStartingNextRound(false);
53
+ }
54
+ };
55
+
56
+ return (
57
+ <Dialog open={isOpen} onOpenChange={onClose}>
58
+ <DialogContent className="max-w-4xl max-h-[90vh] overflow-y-auto bg-slate-900 border-amber-600/30">
59
+ <DialogHeader>
60
+ <DialogTitle className="flex items-center gap-3 text-2xl text-amber-400">
61
+ <Trophy className="w-8 h-8 text-yellow-400" />
62
+ Round {data.round_number} Complete!
63
+ </DialogTitle>
64
+ </DialogHeader>
65
+
66
+ <div className="space-y-6 mt-4">
67
+ {/* Winner announcement */}
68
+ <div className="bg-gradient-to-r from-yellow-900/30 to-amber-900/30 border border-yellow-600/40 rounded-lg p-4 text-center">
69
+ <div className="flex items-center justify-center gap-2 text-xl font-bold text-yellow-300">
70
+ <Crown className="w-6 h-6" />
71
+ {winnerName} wins with {sortedPlayers[0]?.score || 0} points!
72
+ </div>
73
+ </div>
74
+
75
+ {/* Players list */}
76
+ <div className="space-y-4">
77
+ {sortedPlayers.map((player, idx) => (
78
+ <div
79
+ key={player.user_id}
80
+ className={`rounded-lg border p-4 ${
81
+ player.isWinner
82
+ ? 'bg-yellow-950/20 border-yellow-600/50'
83
+ : 'bg-slate-800/50 border-slate-700'
84
+ }`}
85
+ >
86
+ <div className="flex items-center justify-between mb-3">
87
+ <div className="flex items-center gap-2">
88
+ {player.isWinner && <Crown className="w-5 h-5 text-yellow-400" />}
89
+ <span className="font-semibold text-lg text-slate-200">
90
+ {idx + 1}. {player.display_name || `Player ${player.user_id.slice(0, 8)}`}
91
+ </span>
92
+ {player.user_id === currentUserId && (
93
+ <span className="text-xs bg-blue-600/30 text-blue-300 px-2 py-1 rounded">You</span>
94
+ )}
95
+ </div>
96
+ <div className="text-xl font-bold text-amber-400">
97
+ {player.score} pts
98
+ </div>
99
+ </div>
100
+
101
+ {/* Organized melds display */}
102
+ {player.organized ? (
103
+ <div className="space-y-3">
104
+ <div className="text-sm font-semibold text-slate-300 mb-2">
105
+ Hand Organization (4-3-3-3 Format):
106
+ </div>
107
+
108
+ {/* Pure Sequences - HIGHEST PRIORITY */}
109
+ {player.organized.pure_sequences?.length > 0 && (
110
+ <div>
111
+ <div className="text-xs font-bold text-emerald-400 mb-1 flex items-center gap-2">
112
+ <span className="bg-emerald-900/50 px-2 py-0.5 rounded">✓ PURE SEQUENCE</span>
113
+ <span className="text-xs text-slate-400">(No Jokers)</span>
114
+ </div>
115
+ <div className="space-y-2">
116
+ {player.organized.pure_sequences.map((meld: any[], meldIdx: number) => (
117
+ <div key={`pure-${meldIdx}`} className="border-2 border-emerald-600/40 bg-emerald-950/30 rounded-lg p-2">
118
+ <div className="text-[10px] text-emerald-300 mb-1">
119
+ Meld {meldIdx + 1} ({meld.length} cards)
120
+ </div>
121
+ <div className="flex gap-1 flex-wrap">
122
+ {meld.map((card: any, cardIdx: number) => (
123
+ <div key={cardIdx} className="transform scale-75 origin-top-left">
124
+ <PlayingCard card={card} />
125
+ </div>
126
+ ))}
127
+ </div>
128
+ </div>
129
+ ))}
130
+ </div>
131
+ </div>
132
+ )}
133
+
134
+ {/* Impure Sequences - MEDIUM PRIORITY */}
135
+ {player.organized.impure_sequences?.length > 0 && (
136
+ <div>
137
+ <div className="text-xs font-bold text-blue-400 mb-1 flex items-center gap-2">
138
+ <span className="bg-blue-900/50 px-2 py-0.5 rounded">✓ IMPURE SEQUENCE</span>
139
+ <span className="text-xs text-slate-400">(With Jokers)</span>
140
+ </div>
141
+ <div className="space-y-2">
142
+ {player.organized.impure_sequences.map((meld: any[], meldIdx: number) => (
143
+ <div key={`impure-${meldIdx}`} className="border-2 border-blue-600/40 bg-blue-950/30 rounded-lg p-2">
144
+ <div className="text-[10px] text-blue-300 mb-1">
145
+ Meld {meldIdx + 1} ({meld.length} cards)
146
+ </div>
147
+ <div className="flex gap-1 flex-wrap">
148
+ {meld.map((card: any, cardIdx: number) => (
149
+ <div key={cardIdx} className="transform scale-75 origin-top-left">
150
+ <PlayingCard card={card} />
151
+ </div>
152
+ ))}
153
+ </div>
154
+ </div>
155
+ ))}
156
+ </div>
157
+ </div>
158
+ )}
159
+
160
+ {/* Sets - LOWER PRIORITY */}
161
+ {player.organized.sets?.length > 0 && (
162
+ <div>
163
+ <div className="text-xs font-bold text-purple-400 mb-1 flex items-center gap-2">
164
+ <span className="bg-purple-900/50 px-2 py-0.5 rounded">✓ SET</span>
165
+ <span className="text-xs text-slate-400">(Same Rank, Different Suits)</span>
166
+ </div>
167
+ <div className="space-y-2">
168
+ {player.organized.sets.map((meld: any[], meldIdx: number) => (
169
+ <div key={`set-${meldIdx}`} className="border-2 border-purple-600/40 bg-purple-950/30 rounded-lg p-2">
170
+ <div className="text-[10px] text-purple-300 mb-1">
171
+ Meld {meldIdx + 1} ({meld.length} cards)
172
+ </div>
173
+ <div className="flex gap-1 flex-wrap">
174
+ {meld.map((card: any, cardIdx: number) => (
175
+ <div key={cardIdx} className="transform scale-75 origin-top-left">
176
+ <PlayingCard card={card} />
177
+ </div>
178
+ ))}
179
+ </div>
180
+ </div>
181
+ ))}
182
+ </div>
183
+ </div>
184
+ )}
185
+
186
+ {/* Ungrouped cards (deadwood) - PENALTY */}
187
+ {player.organized.ungrouped?.length > 0 && (
188
+ <div>
189
+ <div className="text-xs font-bold text-red-400 mb-1 flex items-center gap-2">
190
+ <span className="bg-red-900/50 px-2 py-0.5 rounded">✗ DEADWOOD</span>
191
+ <span className="text-xs text-slate-400">(Ungrouped - Counts as Points)</span>
192
+ </div>
193
+ <div className="border-2 border-red-600/40 bg-red-950/30 rounded-lg p-2">
194
+ <div className="text-[10px] text-red-300 mb-1">
195
+ {player.organized.ungrouped.length} ungrouped card(s)
196
+ </div>
197
+ <div className="flex gap-1 flex-wrap">
198
+ {player.organized.ungrouped.map((card: any, cardIdx: number) => (
199
+ <div key={cardIdx} className="transform scale-75 origin-top-left">
200
+ <PlayingCard card={card} />
201
+ </div>
202
+ ))}
203
+ </div>
204
+ </div>
205
+ </div>
206
+ )}
207
+
208
+ {/* Summary */}
209
+ <div className="text-xs text-slate-400 pt-2 border-t border-slate-700">
210
+ Total: {(player.organized.pure_sequences?.length || 0) +
211
+ (player.organized.impure_sequences?.length || 0) +
212
+ (player.organized.sets?.length || 0)} valid melds,
213
+ {player.organized.ungrouped?.length || 0} deadwood cards
214
+ </div>
215
+ </div>
216
+ ) : (
217
+ // Fallback: show all cards if no organization
218
+ <div className="flex gap-1 flex-wrap">
219
+ {player.cards.map((card: any, idx: number) => (
220
+ <div key={idx} className="transform scale-75 origin-top-left">
221
+ <PlayingCard card={card} />
222
+ </div>
223
+ ))}
224
+ </div>
225
+ )}
226
+ </div>
227
+ ))}
228
+ </div>
229
+
230
+ <div className="flex gap-3 justify-end">
231
+ {isHost && (
232
+ <Button
233
+ onClick={handleStartNextRound}
234
+ disabled={startingNextRound}
235
+ className="bg-green-600 hover:bg-green-700 font-semibold"
236
+ >
237
+ {startingNextRound ? 'Starting...' : '🎮 Start Next Round'}
238
+ </Button>
239
+ )}
240
+ <Button onClick={onClose} className="bg-amber-600 hover:bg-amber-700">
241
+ Close
242
+ </Button>
243
+ </div>
244
+ </div>
245
+ </DialogContent>
246
+ </Dialog>
247
+ );
248
+ };
SpectateControls.jsx ADDED
@@ -0,0 +1,122 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useState } from 'react';
2
+ import { Button } from '@/components/ui/button';
3
+ import { Eye, Check, X } from 'lucide-react';
4
+ import { toast } from 'sonner';
5
+ import { apiClient } from 'app';
6
+
7
+ interface Props {
8
+ tableId: string;
9
+ currentUserId: string;
10
+ isEliminated: boolean;
11
+ spectateRequests: string[];
12
+ isHost: boolean;
13
+ players: Array<{ user_id: string; display_name?: string | null }>;
14
+ }
15
+
16
+ export default function SpectateControls({
17
+ tableId,
18
+ currentUserId,
19
+ isEliminated,
20
+ spectateRequests,
21
+ isHost,
22
+ players
23
+ }: Props) {
24
+ const [requesting, setRequesting] = useState(false);
25
+ const [granting, setGranting] = useState<string | null>(null);
26
+
27
+ const handleRequestSpectate = async () => {
28
+ setRequesting(true);
29
+ try {
30
+ await apiClient.request_spectate({ table_id: tableId });
31
+ toast.success('Spectate request sent to host');
32
+ } catch (error) {
33
+ toast.error('Failed to request spectate access');
34
+ } finally {
35
+ setRequesting(false);
36
+ }
37
+ };
38
+
39
+ const handleGrantSpectate = async (userId: string, granted: boolean) => {
40
+ setGranting(userId);
41
+ try {
42
+ await apiClient.grant_spectate({
43
+ table_id: tableId,
44
+ user_id: userId,
45
+ granted
46
+ });
47
+ toast.success(granted ? 'Spectate access granted' : 'Spectate access denied');
48
+ } catch (error) {
49
+ toast.error('Failed to process spectate request');
50
+ } finally {
51
+ setGranting(null);
52
+ }
53
+ };
54
+
55
+ const getUserName = (userId: string) => {
56
+ const player = players.find(p => p.user_id === userId);
57
+ return player?.display_name || userId.slice(0, 8);
58
+ };
59
+
60
+ return (
61
+ <div className="space-y-4">
62
+ {/* Eliminated Player - Request to Spectate */}
63
+ {isEliminated && (
64
+ <div className="bg-slate-800/50 border border-slate-700 rounded-lg p-4">
65
+ <div className="flex items-center gap-2 mb-2">
66
+ <Eye className="w-5 h-5 text-blue-400" />
67
+ <h3 className="font-semibold text-white">You've been eliminated</h3>
68
+ </div>
69
+ <p className="text-sm text-slate-300 mb-3">
70
+ Request permission from the host to spectate the remaining players
71
+ </p>
72
+ <Button
73
+ onClick={handleRequestSpectate}
74
+ disabled={requesting}
75
+ className="w-full bg-blue-600 hover:bg-blue-700"
76
+ >
77
+ <Eye className="w-4 h-4 mr-2" />
78
+ {requesting ? 'Requesting...' : 'Request to Spectate'}
79
+ </Button>
80
+ </div>
81
+ )}
82
+
83
+ {/* Host - Pending Spectate Requests */}
84
+ {isHost && spectateRequests.length > 0 && (
85
+ <div className="bg-slate-800/50 border border-slate-700 rounded-lg p-4">
86
+ <div className="flex items-center gap-2 mb-3">
87
+ <Eye className="w-5 h-5 text-amber-400" />
88
+ <h3 className="font-semibold text-white">Spectate Requests</h3>
89
+ <span className="ml-auto bg-amber-500 text-white text-xs font-bold rounded-full w-6 h-6 flex items-center justify-center">
90
+ {spectateRequests.length}
91
+ </span>
92
+ </div>
93
+ <div className="space-y-2">
94
+ {spectateRequests.map((userId) => (
95
+ <div key={userId} className="flex items-center justify-between bg-slate-700/50 rounded p-3">
96
+ <span className="text-white font-medium">{getUserName(userId)}</span>
97
+ <div className="flex gap-2">
98
+ <Button
99
+ onClick={() => handleGrantSpectate(userId, true)}
100
+ disabled={granting === userId}
101
+ size="sm"
102
+ className="bg-green-600 hover:bg-green-700"
103
+ >
104
+ <Check className="w-4 h-4" />
105
+ </Button>
106
+ <Button
107
+ onClick={() => handleGrantSpectate(userId, false)}
108
+ disabled={granting === userId}
109
+ size="sm"
110
+ variant="destructive"
111
+ >
112
+ <X className="w-4 h-4" />
113
+ </Button>
114
+ </div>
115
+ </div>
116
+ ))}
117
+ </div>
118
+ </div>
119
+ )}
120
+ </div>
121
+ );
122
+ }
Table.jsx ADDED
@@ -0,0 +1,2081 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+
2
+
3
+
4
+ import React, { useEffect, useMemo, useState } from "react";
5
+ import { useSearchParams, useNavigate } from "react-router-dom";
6
+ import apiclient from "../apiclient";
7
+ import type { GetTableInfoParams, TableInfoResponse, StartGameRequest, GetRoundMeParams, RoundMeResponse, DrawRequest, DiscardRequest, DiscardCard, DeclareRequest, ScoreboardResponse, RoundScoreboardParams, GetRevealedHandsParams, RevealedHandsResponse, LockSequenceRequest, GrantSpectateRequest } from "../apiclient/data-contracts";
8
+ import { Copy, Check, Crown, User2, Play, ArrowDown, Trash2, Trophy, X, ChevronDown, ChevronUp, LogOut, Mic, MicOff, UserX, Eye } from "lucide-react";
9
+ import { toast } from "sonner";
10
+ import { HandStrip } from "components/HandStrip";
11
+ import { useUser } from "@stackframe/react";
12
+ import { TableDiagram } from "components/TableDiagram";
13
+ import { GameRules } from 'components/GameRules';
14
+ import { CasinoTable3D } from 'components/CasinoTable3D';
15
+ import { PlayerProfile } from 'components/PlayerProfile';
16
+ import { PlayingCard } from "components/PlayingCard";
17
+ import { Button } from "@/components/ui/button";
18
+ import { ScoreboardModal } from "components/ScoreboardModal";
19
+ import { WildJokerRevealModal } from "components/WildJokerRevealModal";
20
+ import { PointsTable } from "components/PointsTable";
21
+ import { parseCardCode } from "utils/cardCodeUtils";
22
+ import ChatSidebar from "components/ChatSidebar";
23
+ import VoicePanel from 'components/VoicePanel';
24
+ import SpectateControls from 'components/SpectateControls';
25
+ import HistoryTable from 'components/HistoryTable';
26
+
27
+ // CardBack component with red checkered pattern
28
+ const CardBack = ({ className = "" }: { className?: string }) => (
29
+ <div className={`relative bg-white rounded-lg border-2 border-gray-300 shadow-lg ${className}`}>
30
+ <div className="absolute inset-0 rounded-lg overflow-hidden">
31
+ <div
32
+ className="w-full h-full"
33
+ style={{
34
+ background:
35
+ "repeating-linear-gradient(45deg, #dc2626 0px, #dc2626 10px, white 10px, white 20px)",
36
+ }}
37
+ />
38
+ </div>
39
+ </div>
40
+ );
41
+
42
+ // Helper component for a 3-card meld slot box
43
+ interface MeldSlotBoxProps {
44
+ title: string;
45
+ slots: (RoundMeResponse["hand"][number] | null)[];
46
+ setSlots: (slots: (RoundMeResponse["hand"][number] | null)[]) => void;
47
+ myRound: RoundMeResponse | null;
48
+ setMyRound: (round: RoundMeResponse) => void;
49
+ isLocked?: boolean;
50
+ onToggleLock?: () => void;
51
+ tableId: string;
52
+ onRefresh: () => void;
53
+ hideLockButton?: boolean;
54
+ gameMode?: string; // Add game mode prop
55
+ }
56
+
57
+ const MeldSlotBox = ({ title, slots, setSlots, myRound, setMyRound, isLocked = false, onToggleLock, tableId, onRefresh, hideLockButton, gameMode }: MeldSlotBoxProps) => {
58
+ const [locking, setLocking] = useState(false);
59
+ const [showRevealModal, setShowRevealModal] = useState(false);
60
+ const [revealedRank, setRevealedRank] = useState<string | null>(null);
61
+
62
+ const handleSlotDrop = (slotIndex: number, cardData: string) => {
63
+ if (!myRound || isLocked) {
64
+ if (isLocked) toast.error('Unlock meld first to modify');
65
+ return;
66
+ }
67
+
68
+ const card = JSON.parse(cardData);
69
+
70
+ // Check if slot is already occupied
71
+ if (slots[slotIndex] !== null) {
72
+ toast.error('Slot already occupied');
73
+ return;
74
+ }
75
+
76
+ // Place card in slot
77
+ const newSlots = [...slots];
78
+ newSlots[slotIndex] = card;
79
+ setSlots(newSlots);
80
+ toast.success(`Card placed in ${title} slot ${slotIndex + 1}`);
81
+ };
82
+
83
+ const handleSlotClick = (slotIndex: number) => {
84
+ if (!myRound || slots[slotIndex] === null || isLocked) {
85
+ if (isLocked) toast.error('Unlock meld first to modify');
86
+ return;
87
+ }
88
+
89
+ // Return card to hand
90
+ const card = slots[slotIndex]!;
91
+ const newSlots = [...slots];
92
+ newSlots[slotIndex] = null;
93
+ setSlots(newSlots);
94
+ toast.success('Card returned to hand');
95
+ };
96
+
97
+ const handleLockSequence = async () => {
98
+ console.log('🔒 Lock button clicked!');
99
+ console.log('Slots:', slots);
100
+ console.log('Table ID:', tableId);
101
+
102
+ // Validate that all 3 slots are filled
103
+ const cards = slots.filter(s => s !== null);
104
+ console.log('Filled cards:', cards);
105
+
106
+ if (cards.length !== 3) {
107
+ console.log('❌ Not enough cards:', cards.length);
108
+ toast.error('Fill all 3 slots to lock a sequence');
109
+ return;
110
+ }
111
+
112
+ console.log('✅ Starting lock sequence API call...');
113
+ setLocking(true);
114
+ try {
115
+ // Safely map cards with explicit null-checking
116
+ const meldCards = cards.map(card => {
117
+ if (!card) {
118
+ throw new Error('Null card found in meld');
119
+ }
120
+ return {
121
+ rank: card.rank,
122
+ suit: card.suit || null
123
+ };
124
+ });
125
+
126
+ const body: LockSequenceRequest = {
127
+ table_id: tableId,
128
+ meld: meldCards
129
+ };
130
+ console.log('Request body:', body);
131
+
132
+ console.log('📡 Calling apiclient.lock_sequence...');
133
+ const res = await apiclient.lock_sequence(body);
134
+ console.log('✅ API response received:', res);
135
+ console.log('Response status:', res.status);
136
+ console.log('Response ok:', res.ok);
137
+
138
+ const data = await res.json();
139
+ console.log('📦 Response data:', data);
140
+
141
+ if (data.success) {
142
+ toast.success(data.message);
143
+ if (onToggleLock) onToggleLock(); // Lock the meld in UI
144
+
145
+ // Show flip animation popup if wild joker was just revealed
146
+ if (data.wild_joker_revealed && data.wild_joker_rank) {
147
+ setRevealedRank(data.wild_joker_rank);
148
+ setShowRevealModal(true);
149
+ setTimeout(() => fetchRoundMe(), 500); // Refresh to show revealed wild joker
150
+ }
151
+ } else {
152
+ toast.error(data.message);
153
+ }
154
+ } catch (err: any) {
155
+ console.log('❌ Lock sequence error:');
156
+ console.log(err?.status, err?.statusText, '-', err?.error?.detail || 'An unexpected error occurred.');
157
+ console.log('Error type:', typeof err);
158
+ console.log('Error name:', err?.name);
159
+ console.log('Error message:', err?.message);
160
+ console.log('Error stack:', err?.stack);
161
+ toast.error(err?.error?.detail || err?.message || 'Failed to lock sequence');
162
+ } finally {
163
+ setLocking(false);
164
+ console.log('🏁 Lock sequence attempt completed');
165
+ }
166
+ };
167
+
168
+ return (
169
+ <>
170
+ <div className={`border border-dashed rounded p-2 ${
171
+ isLocked
172
+ ? 'border-amber-500/50 bg-amber-900/20'
173
+ : 'border-purple-500/30 bg-purple-900/10'
174
+ }`}>
175
+ <div className="flex items-center justify-between mb-2">
176
+ <p className="text-[10px] text-purple-400">{title} (3 cards)</p>
177
+ <div className="flex items-center gap-1">
178
+ {/* Only show lock button if game mode uses wild jokers */}
179
+ {!isLocked && gameMode !== 'no_joker' && (
180
+ <button
181
+ onClick={handleLockSequence}
182
+ disabled={locking || slots.filter(s => s !== null).length !== 3}
183
+ className="text-[10px] px-2 py-0.5 bg-green-700 text-green-100 rounded hover:bg-green-600 disabled:opacity-50 disabled:cursor-not-allowed"
184
+ title="Lock this sequence to reveal wild joker"
185
+ >
186
+ {locking ? '...' : '🔒 Lock'}
187
+ </button>
188
+ )}
189
+ {onToggleLock && (
190
+ <button
191
+ onClick={onToggleLock}
192
+ className={`text-[10px] px-1.5 py-0.5 rounded ${
193
+ isLocked
194
+ ? 'bg-amber-500/20 text-amber-400 hover:bg-amber-500/30'
195
+ : 'bg-gray-500/20 text-gray-400 hover:bg-gray-500/30'
196
+ }`}
197
+ title={isLocked ? 'Click to unlock' : 'Click to lock'}
198
+ >
199
+ {isLocked ? '🔒' : '🔓'}
200
+ </button>
201
+ )}
202
+ </div>
203
+ </div>
204
+ <div className="flex gap-1">
205
+ {slots.map((card, i) => (
206
+ <div
207
+ key={i}
208
+ onDragOver={(e) => { e.preventDefault(); e.currentTarget.classList.add('ring-2', 'ring-purple-400'); }}
209
+ onDragLeave={(e) => { e.currentTarget.classList.remove('ring-2', 'ring-purple-400'); }}
210
+ onDrop={(e) => {
211
+ e.preventDefault();
212
+ e.currentTarget.classList.remove('ring-2', 'ring-purple-400');
213
+ const cardData = e.dataTransfer.getData('card');
214
+ if (cardData) handleSlotDrop(i, cardData);
215
+ }}
216
+ onClick={() => handleSlotClick(i)}
217
+ 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"
218
+ >
219
+ {card ? (
220
+ <div className="scale-[0.6] origin-center">
221
+ <PlayingCard card={card} onClick={() => {}} />
222
+ </div>
223
+ ) : (
224
+ <span className="text-[10px] text-muted-foreground">{i + 1}</span>
225
+ )}
226
+ </div>
227
+ ))}
228
+ </div>
229
+ </div>
230
+
231
+ {/* Wild Joker Reveal Modal */}
232
+ {revealedRank && (
233
+ <WildJokerRevealModal
234
+ isOpen={showRevealModal}
235
+ onClose={() => setShowRevealModal(false)}
236
+ wildJokerRank={revealedRank}
237
+ />
238
+ )}
239
+ </>
240
+ );
241
+ };
242
+
243
+ // Helper component for 4-card leftover slot box
244
+ interface LeftoverSlotBoxProps {
245
+ slots: (RoundMeResponse["hand"][number] | null)[];
246
+ setSlots: (slots: (RoundMeResponse["hand"][number] | null)[]) => void;
247
+ myRound: RoundMeResponse | null;
248
+ setMyRound: (round: RoundMeResponse) => void;
249
+ isLocked?: boolean;
250
+ onToggleLock?: () => void;
251
+ tableId: string;
252
+ onRefresh: () => void;
253
+ gameMode?: string; // Add game mode prop
254
+ }
255
+
256
+ const LeftoverSlotBox = ({ slots, setSlots, myRound, setMyRound, isLocked = false, onToggleLock, tableId, onRefresh, gameMode }: LeftoverSlotBoxProps) => {
257
+ const [locking, setLocking] = useState(false);
258
+ const [showRevealModal, setShowRevealModal] = useState(false);
259
+ const [revealedRank, setRevealedRank] = useState<string | null>(null);
260
+
261
+ const handleSlotDrop = (slotIndex: number, cardData: string) => {
262
+ if (!myRound || isLocked) return;
263
+
264
+ const card = JSON.parse(cardData);
265
+
266
+ // Check if slot is already occupied
267
+ if (slots[slotIndex] !== null) {
268
+ toast.error('Slot already occupied');
269
+ return;
270
+ }
271
+
272
+ // Place card in slot
273
+ const newSlots = [...slots];
274
+ newSlots[slotIndex] = card;
275
+ setSlots(newSlots);
276
+ toast.success(`Card placed in leftover slot ${slotIndex + 1}`);
277
+ };
278
+
279
+ const handleSlotClick = (slotIndex: number) => {
280
+ if (!myRound || slots[slotIndex] === null) return;
281
+
282
+ // Return card to hand
283
+ const card = slots[slotIndex]!;
284
+ const newSlots = [...slots];
285
+ newSlots[slotIndex] = null;
286
+ setSlots(newSlots);
287
+ toast.success('Card returned to hand');
288
+ };
289
+
290
+ const handleLockSequence = async () => {
291
+ console.log('🔒 Lock button clicked (4-card)!');
292
+ console.log('Slots:', slots);
293
+ console.log('Table ID:', tableId);
294
+
295
+ // Validate that all 4 slots are filled
296
+ const cards = slots.filter(s => s !== null);
297
+ console.log('Filled cards:', cards);
298
+
299
+ if (cards.length !== 4) {
300
+ console.log('❌ Not enough cards:', cards.length);
301
+ toast.error('Fill all 4 slots to lock a sequence');
302
+ return;
303
+ }
304
+
305
+ console.log('✅ Starting lock sequence API call (4-card)...');
306
+ setLocking(true);
307
+ try {
308
+ // Safely map cards with explicit null-checking
309
+ const meldCards = cards.map(card => {
310
+ if (!card) {
311
+ throw new Error('Null card found in meld');
312
+ }
313
+ return {
314
+ rank: card.rank,
315
+ suit: card.suit || null
316
+ };
317
+ });
318
+
319
+ const body: LockSequenceRequest = {
320
+ table_id: tableId,
321
+ meld: meldCards
322
+ };
323
+ console.log('Request body:', body);
324
+
325
+ console.log('📡 Calling apiclient.lock_sequence...');
326
+ const res = await apiclient.lock_sequence(body);
327
+ console.log('✅ API response received:', res);
328
+ console.log('Response status:', res.status);
329
+ console.log('Response ok:', res.ok);
330
+
331
+ const data = await res.json();
332
+ console.log('📦 Response data:', data);
333
+
334
+ if (data.success) {
335
+ toast.success(data.message);
336
+ if (onToggleLock) onToggleLock(); // Lock the meld in UI
337
+
338
+ // Show flip animation popup if wild joker was just revealed
339
+ if (data.wild_joker_revealed && data.wild_joker_rank) {
340
+ setRevealedRank(data.wild_joker_rank);
341
+ setShowRevealModal(true);
342
+ }
343
+
344
+ onRefresh(); // Refresh to get updated wild joker status
345
+ } else {
346
+ toast.error(data.message);
347
+ }
348
+ } catch (err: any) {
349
+ console.log('❌ Lock sequence error (4-card):');
350
+ console.log(err?.status, err?.statusText, '-', err?.error?.detail || 'An unexpected error occurred.');
351
+ console.log('Error type:', typeof err);
352
+ console.log('Error name:', err?.name);
353
+ console.log('Error message:', err?.message);
354
+ console.log('Error stack:', err?.stack);
355
+ console.log('Full error object:', err);
356
+ toast.error(err?.error?.detail || err?.message || 'Failed to lock sequence');
357
+ } finally {
358
+ setLocking(false);
359
+ console.log('🏁 Lock sequence attempt completed (4-card)');
360
+ }
361
+ };
362
+
363
+ return (
364
+ <>
365
+ <div className={`border border-dashed rounded p-2 ${
366
+ isLocked
367
+ ? 'border-amber-500/50 bg-amber-900/20'
368
+ : 'border-blue-500/30 bg-blue-900/10'
369
+ }`}>
370
+ <div className="flex items-center justify-between mb-2">
371
+ <p className="text-[10px] text-blue-400">Leftover / 4-Card Seq</p>
372
+ <div className="flex items-center gap-1">
373
+ {/* Only show lock button if game mode uses wild jokers */}
374
+ {!isLocked && gameMode !== 'no_joker' && (
375
+ <button
376
+ onClick={handleLockSequence}
377
+ disabled={locking || slots.filter(s => s !== null).length !== 4}
378
+ className="text-[10px] px-2 py-0.5 bg-green-700 text-green-100 rounded hover:bg-green-600 disabled:opacity-50 disabled:cursor-not-allowed"
379
+ title="Lock this 4-card sequence to reveal wild joker"
380
+ >
381
+ {locking ? '...' : '🔒 Lock'}
382
+ </button>
383
+ )}
384
+ {onToggleLock && (
385
+ <button
386
+ onClick={onToggleLock}
387
+ className={`text-[10px] px-1.5 py-0.5 rounded ${
388
+ isLocked
389
+ ? 'bg-amber-500/20 text-amber-400 hover:bg-amber-500/30'
390
+ : 'bg-gray-500/20 text-gray-400 hover:bg-gray-500/30'
391
+ }`}
392
+ title={isLocked ? 'Click to unlock' : 'Click to lock'}
393
+ >
394
+ {isLocked ? '🔒' : '🔓'}
395
+ </button>
396
+ )}
397
+ </div>
398
+ </div>
399
+ <div className="flex gap-1">
400
+ {slots.map((card, i) => (
401
+ <div
402
+ key={i}
403
+ onDragOver={(e) => { e.preventDefault(); e.currentTarget.classList.add('ring-2', 'ring-blue-400'); }}
404
+ onDragLeave={(e) => { e.currentTarget.classList.remove('ring-2', 'ring-blue-400'); }}
405
+ onDrop={(e) => {
406
+ e.preventDefault();
407
+ e.currentTarget.classList.remove('ring-2', 'ring-blue-400');
408
+ const cardData = e.dataTransfer.getData('card');
409
+ if (cardData) handleSlotDrop(i, cardData);
410
+ }}
411
+ onClick={() => handleSlotClick(i)}
412
+ 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"
413
+ >
414
+ {card ? (
415
+ <div className="scale-[0.6] origin-center">
416
+ <PlayingCard card={card} onClick={() => {}} />
417
+ </div>
418
+ ) : (
419
+ <span className="text-[10px] text-muted-foreground">{i + 1}</span>
420
+ )}
421
+ </div>
422
+ ))}
423
+ </div>
424
+ </div>
425
+
426
+ {/* Wild Joker Reveal Modal */}
427
+ {revealedRank && (
428
+ <WildJokerRevealModal
429
+ isOpen={showRevealModal}
430
+ onClose={() => setShowRevealModal(false)}
431
+ wildJokerRank={revealedRank}
432
+ />
433
+ )}
434
+ </>
435
+ );
436
+ };
437
+
438
+ export default function Table() {
439
+ const navigate = useNavigate();
440
+ const [sp] = useSearchParams();
441
+ const user = useUser();
442
+ const tableId = sp.get("tableId");
443
+
444
+ // State
445
+ const [loading, setLoading] = useState(true);
446
+ const [info, setInfo] = useState<TableInfoResponse | null>(null);
447
+ const [myRound, setMyRound] = useState<RoundMeResponse | null>(null);
448
+ const [copied, setCopied] = useState(false);
449
+ const [acting, setActing] = useState(false);
450
+ const [starting, setStarting] = useState(false);
451
+ const [scoreboard, setScoreboard] = useState<ScoreboardResponse | null>(null);
452
+ const [showScoreboard, setShowScoreboard] = useState(false);
453
+ const [showWildJokerReveal, setShowWildJokerReveal] = useState(false);
454
+ const [revealedWildJoker, setRevealedWildJoker] = useState<string | null>(null);
455
+ const [roundHistory, setRoundHistory] = useState<any[]>([]);
456
+ const [tableColor, setTableColor] = useState<'green' | 'red-brown'>('green');
457
+ const [voiceMuted, setVoiceMuted] = useState(false);
458
+ const [droppingGame, setDroppingGame] = useState(false);
459
+ const [spectateRequested, setSpectateRequested] = useState(false);
460
+ const [spectateRequests, setSpectateRequests] = useState<string[]>([]);
461
+ const [showScoreboardModal, setShowScoreboardModal] = useState(false);
462
+ const [showScoreboardPanel, setShowScoreboardPanel] = useState(false);
463
+ const [revealedHands, setRevealedHands] = useState<any>(null);
464
+
465
+ // DEBUG: Monitor tableId changes and URL
466
+ useEffect(() => {
467
+ console.log('🔍 Table Component - tableId from URL:', tableId);
468
+ console.log('🔍 Full URL search params:', sp.toString());
469
+ console.log('🔍 Current full URL:', window.location.href);
470
+ if (!tableId) {
471
+ console.error('❌ CRITICAL: tableId is missing from URL!');
472
+ console.error('This could be caused by:');
473
+ console.error(' 1. Browser navigation/refresh losing URL params');
474
+ console.error(' 2. React Router navigation without tableId');
475
+ console.error(' 3. Component remounting unexpectedly');
476
+ }
477
+ }, [tableId, sp]);
478
+
479
+ const [selectedCard, setSelectedCard] = useState<{ rank: string; suit: string | null; joker: boolean } | null>(null);
480
+ const [lastDrawnCard, setLastDrawnCard] = useState<{ rank: string; suit: string | null } | null>(null);
481
+ const [hasDrawn, setHasDrawn] = useState(false);
482
+ const [pureSeq, setPureSeq] = useState<{ rank: string; suit: string | null; joker: boolean }[]>([]);
483
+ const [meld1, setMeld1] = useState<(RoundMeResponse["hand"][number] | null)[]>([null, null, null]);
484
+ const [meld2, setMeld2] = useState<(RoundMeResponse["hand"][number] | null)[]>([null, null, null]);
485
+ const [meld3, setMeld3] = useState<(RoundMeResponse["hand"][number] | null)[]>([null, null, null]);
486
+ const [leftover, setLeftover] = useState<(RoundMeResponse["hand"][number] | null)[]>([null, null, null, null]);
487
+ const [prevRoundFinished, setPrevRoundFinished] = useState<string | null>(null);
488
+ const [showPointsTable, setShowPointsTable] = useState(true);
489
+
490
+ // Table Info box state
491
+ const [tableInfoVisible, setTableInfoVisible] = useState(true);
492
+ const [tableInfoMinimized, setTableInfoMinimized] = useState(false);
493
+ const [activeTab, setActiveTab] = useState<'info' | 'history' | 'spectate'>('info');
494
+ console.log('🎨 Table.tsx render - current tableColor:', tableColor);
495
+
496
+ // Meld lock state
497
+ const [meldLocks, setMeldLocks] = useState<{
498
+ meld1: boolean;
499
+ meld2: boolean;
500
+ meld3: boolean;
501
+ leftover: boolean;
502
+ }>({ meld1: false, meld2: false, meld3: false, leftover: false });
503
+
504
+ // Load locked melds from localStorage on mount
505
+ useEffect(() => {
506
+ if (!tableId) return;
507
+ const storageKey = `rummy_melds_${tableId}`;
508
+ const saved = localStorage.getItem(storageKey);
509
+ if (saved) {
510
+ try {
511
+ const { meld1: m1, meld2: m2, meld3: m3, leftover: lo, locks } = JSON.parse(saved);
512
+ if (locks.meld1) setMeld1(m1);
513
+ if (locks.meld2) setMeld2(m2);
514
+ if (locks.meld3) setMeld3(m3);
515
+ if (locks.leftover) setLeftover(lo);
516
+ setMeldLocks(locks);
517
+ } catch (e) {
518
+ console.error('Failed to load melds from localStorage:', e);
519
+ }
520
+ }
521
+ }, [tableId]);
522
+
523
+ // Save locked melds to localStorage whenever they change
524
+ useEffect(() => {
525
+ if (!tableId) return;
526
+ const storageKey = `rummy_melds_${tableId}`;
527
+ const data = {
528
+ meld1,
529
+ meld2,
530
+ meld3,
531
+ leftover,
532
+ locks: meldLocks
533
+ };
534
+ localStorage.setItem(storageKey, JSON.stringify(data));
535
+ }, [tableId, meld1, meld2, meld3, leftover, meldLocks]);
536
+
537
+ // Toggle lock for a specific meld
538
+ const toggleMeldLock = (meldName: 'meld1' | 'meld2' | 'meld3' | 'leftover') => {
539
+ setMeldLocks(prev => ({
540
+ ...prev,
541
+ [meldName]: !prev[meldName]
542
+ }));
543
+ toast.success(`${meldName} ${!meldLocks[meldName] ? 'locked' : 'unlocked'}`);
544
+ };
545
+
546
+ // Debug user object
547
+ useEffect(() => {
548
+ if (user) {
549
+ console.log('User object:', { id: user.id, sub: user.id, displayName: user.displayName });
550
+ }
551
+ }, [user]);
552
+
553
+ // Get cards that are placed in slots (not in hand anymore)
554
+ const placedCards = useMemo(() => {
555
+ const placed = [...meld1, ...meld2, ...meld3, ...leftover].filter(c => c !== null) as RoundMeResponse["hand"];
556
+ return placed;
557
+ }, [meld1, meld2, meld3, leftover]);
558
+
559
+ // Filter hand to exclude placed cards - FIX for duplicate cards
560
+ // Track which cards are used by counting occurrences
561
+ const availableHand = useMemo(() => {
562
+ if (!myRound) return [];
563
+
564
+ // Count how many times each card (rank+suit combo) is placed in melds
565
+ const placedCounts = new Map<string, number>();
566
+ placedCards.forEach(card => {
567
+ const key = `${card.rank}-${card.suit || 'null'}`;
568
+ placedCounts.set(key, (placedCounts.get(key) || 0) + 1);
569
+ });
570
+
571
+ // Filter hand, keeping track of how many of each card we've already filtered
572
+ const seenCounts = new Map<string, number>();
573
+ return myRound.hand.filter(handCard => {
574
+ const key = `${handCard.rank}-${handCard.suit || 'null'}`;
575
+ const placedCount = placedCounts.get(key) || 0;
576
+ const seenCount = seenCounts.get(key) || 0;
577
+
578
+ if (seenCount < placedCount) {
579
+ // This card should be filtered out (it's in a meld)
580
+ seenCounts.set(key, seenCount + 1);
581
+ return false;
582
+ }
583
+ return true;
584
+ });
585
+ }, [myRound, placedCards]);
586
+
587
+ const refresh = async () => {
588
+ if (!tableId) {
589
+ console.error('❌ refresh() called without tableId');
590
+ return;
591
+ }
592
+ try {
593
+ const query: GetTableInfoParams = { table_id: tableId };
594
+ const res = await apiclient.get_table_info(query);
595
+
596
+ if (!res.ok) {
597
+ console.error('❌ get_table_info failed with status:', res.status);
598
+ // DO NOT navigate away - just log the error
599
+ toast.error('Failed to refresh table info');
600
+ setLoading(false);
601
+ return;
602
+ }
603
+
604
+ const data = await res.json();
605
+
606
+ // Check if turn changed
607
+ const turnChanged = info?.active_user_id !== data.active_user_id;
608
+ console.log('🔄 Refresh:', {
609
+ prevActiveUser: info?.active_user_id,
610
+ newActiveUser: data.active_user_id,
611
+ turnChanged
612
+ });
613
+
614
+ setInfo(data);
615
+ // If playing, also fetch my hand
616
+ if (data.status === "playing") {
617
+ const r: GetRoundMeParams = { table_id: tableId };
618
+ const rr = await apiclient.get_round_me(r);
619
+
620
+ if (!rr.ok) {
621
+ console.error('❌ get_round_me failed with status:', rr.status);
622
+ toast.error('Failed to refresh hand');
623
+ setLoading(false);
624
+ return;
625
+ }
626
+
627
+ const roundData = await rr.json();
628
+ setMyRound(roundData);
629
+
630
+ // ALWAYS sync hasDrawn with actual hand length
631
+ // 14 cards = player has drawn, 13 cards = player hasn't drawn yet
632
+ const newHasDrawn = roundData.hand.length === 14;
633
+ console.log('🔄 Syncing hasDrawn with hand length:', {
634
+ handLength: roundData.hand.length,
635
+ newHasDrawn,
636
+ previousHasDrawn: hasDrawn
637
+ });
638
+ setHasDrawn(newHasDrawn);
639
+ }
640
+
641
+ // Clear loading state after successful fetch
642
+ setLoading(false);
643
+ } catch (e) {
644
+ console.error("❌ Failed to refresh:", e);
645
+ // DO NOT call navigate("/") here - this would cause auto-leave!
646
+ toast.error('Connection error - retrying...');
647
+ setLoading(false);
648
+ }
649
+ };
650
+
651
+ const fetchRoundHistory = async () => {
652
+ if (!info?.table_id) return;
653
+ try {
654
+ const response = await apiclient.get_round_history({ table_id: info.table_id });
655
+ const data = await response.json();
656
+ setRoundHistory(data.rounds || []);
657
+ } catch (error) {
658
+ console.error("Failed to fetch round history:", error);
659
+ }
660
+ };
661
+
662
+ // Auto-refresh table info and round data every 15s instead of 5s
663
+ useEffect(() => {
664
+ if (!tableId) return;
665
+
666
+ const interval = setInterval(() => {
667
+ refresh();
668
+ }, 15000); // Changed from 5000 to 15000
669
+
670
+ return () => clearInterval(interval);
671
+ }, [tableId]);
672
+
673
+ // Initial load on mount
674
+ useEffect(() => {
675
+ if (!tableId) return;
676
+ refresh();
677
+ }, [tableId]);
678
+
679
+ const canStart = useMemo(() => {
680
+ if (!info || !user) return false;
681
+ const seated = info.players.length;
682
+ const isHost = user.id === info.host_user_id;
683
+ return info.status === "waiting" && seated >= 2 && isHost;
684
+ }, [info, user]);
685
+
686
+ const isMyTurn = useMemo(() => {
687
+ if (!user) return false;
688
+ const userId = user.id;
689
+ console.log('Turn check - active_user_id:', info?.active_user_id, 'user.id:', userId, 'match:', info?.active_user_id === userId);
690
+ return info?.active_user_id === userId;
691
+ }, [info, user]);
692
+
693
+ // Reset hasDrawn when turn changes
694
+ useEffect(() => {
695
+ console.log('Turn state changed - isMyTurn:', isMyTurn, 'hasDrawn:', hasDrawn);
696
+ if (!isMyTurn) {
697
+ console.log('Not my turn - clearing all selection state');
698
+ setHasDrawn(false);
699
+ setSelectedCard(null);
700
+ setLastDrawnCard(null);
701
+ }
702
+ }, [isMyTurn]);
703
+
704
+ const onCopy = () => {
705
+ if (!info?.code) return;
706
+ navigator.clipboard.writeText(info.code);
707
+ setCopied(true);
708
+ setTimeout(() => setCopied(false), 2000);
709
+ };
710
+
711
+ const onStart = async () => {
712
+ if (!info || !tableId) return;
713
+ console.log("Starting game for table:", tableId, "User:", user?.id, "Host:", info.host_user_id);
714
+ setStarting(true);
715
+ try {
716
+ const body: StartGameRequest = { table_id: tableId };
717
+ console.log("Calling start_game API with body:", body);
718
+ const res = await apiclient.start_game(body);
719
+ console.log("Start game response status:", res.status);
720
+ if (!res.ok) {
721
+ const errorText = await res.text();
722
+ console.error("Start game failed:", errorText);
723
+ toast.error(`Failed to start game: ${errorText}`);
724
+ return;
725
+ }
726
+ const data = await res.json();
727
+ toast.success(`Round #${data.number} started`);
728
+ await refresh();
729
+ } catch (e: any) {
730
+ console.error("Start game error:", e);
731
+ toast.error(e?.message || "Failed to start game");
732
+ } finally {
733
+ setStarting(false);
734
+ }
735
+ };
736
+
737
+ const onDrawStock = async () => {
738
+ if (!tableId || !isMyTurn || hasDrawn) return;
739
+ setActing(true);
740
+ try {
741
+ const body: DrawRequest = { table_id: tableId };
742
+ const res = await apiclient.draw_stock(body);
743
+ const data = await res.json();
744
+ // Find the new card by comparing lengths
745
+ const newCard = data.hand.find((card: any) =>
746
+ !myRound?.hand.some(c => c.rank === card.rank && c.suit === card.suit)
747
+ );
748
+ if (newCard) {
749
+ setLastDrawnCard({ rank: newCard.rank, suit: newCard.suit });
750
+ }
751
+ setMyRound(data);
752
+ setHasDrawn(true);
753
+ toast.success("Drew from stock");
754
+ } catch (e: any) {
755
+ toast.error("Failed to draw from stock");
756
+ } finally {
757
+ setActing(false);
758
+ }
759
+ };
760
+
761
+ const onDrawDiscard = async () => {
762
+ if (!tableId || !isMyTurn || hasDrawn) return;
763
+ setActing(true);
764
+ try {
765
+ const body: DrawRequest = { table_id: tableId };
766
+ const res = await apiclient.draw_discard(body);
767
+ const data = await res.json();
768
+ // The card being drawn is the CURRENT discard_top (before the draw)
769
+ if (myRound?.discard_top) {
770
+ // Parse the card code properly (e.g., "7S" -> rank="7", suit="S")
771
+ const code = myRound.discard_top;
772
+ let rank: string;
773
+ let suit: string | null;
774
+
775
+ if (code === 'JOKER') {
776
+ rank = 'JOKER';
777
+ suit = null;
778
+ } else {
779
+ // Last char is suit, rest is rank
780
+ suit = code.slice(-1);
781
+ rank = code.slice(0, -1);
782
+ }
783
+
784
+ setLastDrawnCard({ rank, suit });
785
+ }
786
+ setMyRound(data);
787
+ setHasDrawn(true);
788
+ toast.success("Drew from discard pile");
789
+ } catch (e: any) {
790
+ toast.error("Failed to draw from discard");
791
+ } finally {
792
+ setActing(false);
793
+ }
794
+ };
795
+
796
+ const onDiscard = async () => {
797
+ if (!tableId || !selectedCard || !hasDrawn) return;
798
+ setActing(true);
799
+ try {
800
+ const body: DiscardRequest = { table_id: tableId, card: selectedCard };
801
+ const res = await apiclient.discard_card(body);
802
+ const data = await res.json();
803
+ toast.success("Card discarded. Next player's turn.");
804
+ setSelectedCard(null);
805
+ setLastDrawnCard(null);
806
+ setHasDrawn(false);
807
+ await refresh();
808
+ } catch (e: any) {
809
+ toast.error("Failed to discard card");
810
+ } finally {
811
+ setActing(false);
812
+ }
813
+ };
814
+
815
+ const fetchRevealedHands = async () => {
816
+ console.log("📊 Fetching revealed hands...");
817
+ let lastError: any = null;
818
+ for (let attempt = 1; attempt <= 3; attempt++) {
819
+ try {
820
+ const resp = await apiclient.get_revealed_hands({ table_id: tableId! });
821
+ if (!resp.ok) {
822
+ const errorText = await resp.text();
823
+ console.error(`❌ API returned error (attempt ${attempt}/3):`, { status: resp.status, body: errorText });
824
+ lastError = { status: resp.status, message: errorText };
825
+ if (attempt < 3 && resp.status === 400) {
826
+ console.log(`⏳ Waiting 500ms before retry ${attempt + 1}...`);
827
+ await new Promise((resolve) => setTimeout(resolve, 500));
828
+ continue;
829
+ } else {
830
+ break;
831
+ }
832
+ }
833
+ const data = await resp.json();
834
+ console.log("✅ Revealed hands fetched:", data);
835
+ setRevealedHands(data);
836
+ setShowScoreboardModal(true); // ← CHANGED: Set modal state to true
837
+ setShowScoreboardPanel(true);
838
+ return data;
839
+ } catch (error: any) {
840
+ console.error(`❌ Error fetching revealed hands (attempt ${attempt}/3):`, error);
841
+ lastError = error;
842
+ if (attempt < 3) {
843
+ console.log(`⏳ Waiting 500ms before retry ${attempt + 1}...`);
844
+ await new Promise((resolve) => setTimeout(resolve, 500));
845
+ } else {
846
+ break;
847
+ }
848
+ }
849
+ }
850
+ const errorMsg = lastError?.message || lastError?.status || "Network error";
851
+ toast.error(`Failed to load scoreboard: ${errorMsg}`);
852
+ console.error("🚨 Final scoreboard error:", lastError);
853
+ return null;
854
+ };
855
+
856
+ const onDeclare = async () => {
857
+ console.log('🎯 Declare clicked');
858
+ if (!meld1 || !meld2 || !meld3) {
859
+ toast.error('Please create all 3 melds before declaring');
860
+ return;
861
+ }
862
+
863
+ // Count cards in melds INCLUDING leftover as meld4
864
+ const m1 = meld1?.length || 0;
865
+ const m2 = meld2?.length || 0;
866
+ const m3 = meld3?.length || 0;
867
+ const m4 = leftover?.length || 0;
868
+ const totalPlaced = m1 + m2 + m3 + m4;
869
+
870
+ // Identify leftover cards (not in any meld OR leftover slot)
871
+ const allMeldCards = [...(meld1 || []), ...(meld2 || []), ...(meld3 || []), ...(leftover || [])];
872
+ const unplacedCards = myRound?.hand.filter(card => {
873
+ const cardKey = `${card.rank}-${card.suit || 'null'}`;
874
+ return !allMeldCards.some(m => `${m.rank}-${m.suit || 'null'}` === cardKey);
875
+ }) || [];
876
+
877
+ if (totalPlaced !== 13) {
878
+ const unplacedCount = unplacedCards.length;
879
+ const unplacedDisplay = unplacedCards
880
+ .map(c => `${c.rank}${c.suit || ''}`)
881
+ .join(', ');
882
+
883
+ toast.error(
884
+ `You must place all 13 cards in melds. Currently ${totalPlaced}/13 cards placed.\n\n` +
885
+ `Unplaced ${unplacedCount} card${unplacedCount > 1 ? 's' : ''}: ${unplacedDisplay}\n\n` +
886
+ `Place these in Meld 1, Meld 2, Meld 3, or Leftover slots.`,
887
+ { duration: 6000 }
888
+ );
889
+ console.log(`❌ Not all 13 cards placed. Total: ${totalPlaced}`);
890
+ return;
891
+ }
892
+
893
+ console.log('🎯 DECLARE BUTTON CLICKED!');
894
+ console.log('tableId:', tableId);
895
+ console.log('isMyTurn:', isMyTurn);
896
+ console.log('hasDrawn:', hasDrawn);
897
+ console.log('hand length:', myRound?.hand.length);
898
+
899
+ if (!tableId) return;
900
+ if (!isMyTurn) {
901
+ toast.error("It's not your turn!");
902
+ return;
903
+ }
904
+
905
+ // Check if player has drawn (must have 14 cards)
906
+ const handLength = myRound?.hand.length || 0;
907
+ if (handLength !== 14) {
908
+ toast.error(
909
+ `You must draw a card before declaring!\n` +
910
+ `You have ${handLength} cards, but need 14 cards (13 to meld + 1 to discard).`,
911
+ { duration: 5000 }
912
+ );
913
+ return;
914
+ }
915
+
916
+ // Collect meld groups (filter out null slots)
917
+ const groups: RoundMeResponse["hand"][] = [];
918
+
919
+ const meld1Cards = meld1?.filter(c => c !== null) as RoundMeResponse["hand"];
920
+ if (meld1Cards.length > 0) groups.push(meld1Cards);
921
+
922
+ const meld2Cards = meld2?.filter(c => c !== null) as RoundMeResponse["hand"];
923
+ if (meld2Cards.length > 0) groups.push(meld2Cards);
924
+
925
+ const meld3Cards = meld3?.filter(c => c !== null) as RoundMeResponse["hand"];
926
+ if (meld3Cards.length > 0) groups.push(meld3Cards);
927
+
928
+ const leftoverCards = leftover?.filter(c => c !== null) as RoundMeResponse["hand"];
929
+ if (leftoverCards.length > 0) groups.push(leftoverCards);
930
+
931
+ // Skip to API call - validation already done above
932
+ console.log('✅ All checks passed, preparing API call...');
933
+ setActing(true);
934
+ try {
935
+ // Transform CardView to DiscardCard (remove 'code' field)
936
+ const discardGroups = groups.map(group =>
937
+ group.map(card => ({
938
+ rank: card.rank,
939
+ suit: card.suit,
940
+ joker: card.joker
941
+ }))
942
+ );
943
+
944
+ const body: DeclareRequest = { table_id: tableId, groups: discardGroups };
945
+ console.log('📤 Sending declare request:', JSON.stringify(body, null, 2));
946
+ console.log('📡 About to call apiclient.declare()...');
947
+ const res = await apiclient.declare(body);
948
+ console.log('📨 Received response:', res);
949
+
950
+ if (res.ok) {
951
+ const data = await res.json();
952
+ console.log("✅ DECLARE COMPLETED:", data);
953
+
954
+ // Show appropriate message based on valid/invalid
955
+ if (data.status === 'valid') {
956
+ toast.success(`🏆 Valid declaration! You win round #${data.round_number} with 0 points!`);
957
+ } else {
958
+ toast.warning(`⚠️ Invalid declaration! You received 80 penalty points for round #${data.round_number}`);
959
+ }
960
+
961
+ console.log('🎯 Fetching revealed hands...');
962
+ await fetchRevealedHands();
963
+ console.log('✅ Revealed hands fetched');
964
+
965
+ // Log state right after fetch
966
+ console.log("🔍 POST-FETCH STATE CHECK:");
967
+ console.log(" - showScoreboardModal:", showScoreboardModal);
968
+ console.log(" - revealedHands:", revealedHands);
969
+ } else {
970
+ // Handle HTTP errors from backend
971
+ let errorMessage = 'Failed to declare';
972
+ try {
973
+ const errorData = await res.json();
974
+ errorMessage = errorData.detail || errorData.message || errorMessage;
975
+ } catch {
976
+ const errorText = await res.text();
977
+ errorMessage = errorText || errorMessage;
978
+ }
979
+ console.log('❌ Backend error:', errorMessage);
980
+ toast.error(`❌ ${errorMessage}`, { duration: 5000 });
981
+ }
982
+ } catch (error: any) {
983
+ // Network errors or other exceptions
984
+ console.error('🚨 DECLARE EXCEPTION CAUGHT:');
985
+ console.error(' Error object:', error);
986
+ console.error(' Error type:', typeof error);
987
+ console.error(' Error constructor:', error?.constructor?.name);
988
+ console.error(' Error message:', error?.message);
989
+ console.error(' Error stack:', error?.stack);
990
+ console.error(' Error keys:', Object.keys(error || {}));
991
+ console.error(' Full error JSON:', JSON.stringify(error, Object.getOwnPropertyNames(error)));
992
+
993
+ // Try to get more details about the request that failed
994
+ if (error.response) {
995
+ console.error(' Response status:', error.response.status);
996
+ console.error(' Response data:', error.response.data);
997
+ }
998
+ if (error.request) {
999
+ console.error(' Request:', error.request);
1000
+ }
1001
+
1002
+ // Extract actual error message from Response object or other error types
1003
+ let errorMsg = 'Network error';
1004
+
1005
+ // PRIORITY 1: Check if it's a Response object
1006
+ if (error instanceof Response) {
1007
+ try {
1008
+ const errorData = await error.json();
1009
+ errorMsg = errorData.detail || errorData.message || 'Failed to declare';
1010
+ } catch {
1011
+ try {
1012
+ const errorText = await error.text();
1013
+ errorMsg = errorText || 'Failed to declare';
1014
+ } catch {
1015
+ errorMsg = 'Failed to declare';
1016
+ }
1017
+ }
1018
+ }
1019
+ // PRIORITY 2: Check for error.message
1020
+ else if (error?.message) {
1021
+ errorMsg = error.message;
1022
+ }
1023
+ // PRIORITY 3: Check if it's a string
1024
+ else if (typeof error === 'string') {
1025
+ errorMsg = error;
1026
+ }
1027
+ // PRIORITY 4: Try toString (but avoid [object Object])
1028
+ else if (error?.toString && typeof error.toString === 'function') {
1029
+ const stringified = error.toString();
1030
+ if (stringified !== '[object Object]' && stringified !== '[object Response]') {
1031
+ errorMsg = stringified;
1032
+ }
1033
+ }
1034
+
1035
+ toast.error(`❌ Failed to declare: ${errorMsg}`, { duration: 5000 });
1036
+ } finally {
1037
+ setActing(false);
1038
+ }
1039
+ };
1040
+
1041
+ const onNextRound = async () => {
1042
+ if (!tableId || !info) return;
1043
+ setStarting(true);
1044
+ try {
1045
+ const body = { table_id: tableId };
1046
+ const res = await apiclient.start_next_round(body);
1047
+ const data = await res.json();
1048
+ toast.success(`Round #${data.number} started!`);
1049
+ await refresh();
1050
+ } catch (e: any) {
1051
+ toast.error(e?.message || "Failed to start next round");
1052
+ } finally {
1053
+ setStarting(false);
1054
+ }
1055
+ };
1056
+
1057
+ // Drop game handler
1058
+ const onDropGame = async () => {
1059
+ if (!tableId || droppingGame) return;
1060
+ setDroppingGame(true);
1061
+ try {
1062
+ const body = { table_id: tableId };
1063
+ const res = await apiclient.drop_game(body);
1064
+ await res.json();
1065
+ toast.success("You have dropped from the game (20 point penalty)");
1066
+ await refresh();
1067
+ } catch (e: any) {
1068
+ toast.error(e?.message || "Failed to drop game");
1069
+ } finally {
1070
+ setDroppingGame(false);
1071
+ }
1072
+ };
1073
+
1074
+ // Spectate handlers
1075
+ const requestSpectate = async (playerId: string) => {
1076
+ if (!tableId || spectateRequested) return;
1077
+ setSpectateRequested(true);
1078
+ try {
1079
+ const body = { table_id: tableId, player_id: playerId };
1080
+ await apiclient.request_spectate(body);
1081
+ toast.success("Spectate request sent");
1082
+ } catch (e: any) {
1083
+ toast.error(e?.message || "Failed to request spectate");
1084
+ }
1085
+ };
1086
+
1087
+ const grantSpectate = async (spectatorId: string) => {
1088
+ if (!tableId) return;
1089
+ try {
1090
+ const body: GrantSpectateRequest = { table_id: tableId, spectator_id: spectatorId, granted: true };
1091
+ await apiclient.grant_spectate(body);
1092
+ setSpectateRequests(prev => prev.filter(id => id !== spectatorId));
1093
+ toast.success("Spectate access granted");
1094
+ } catch (e: any) {
1095
+ toast.error(e?.message || "Failed to grant spectate");
1096
+ }
1097
+ };
1098
+
1099
+ // Voice control handlers
1100
+ const toggleVoiceMute = async () => {
1101
+ if (!tableId || !user) return;
1102
+ try {
1103
+ const body = { table_id: tableId, user_id: user.id, muted: !voiceMuted };
1104
+ await apiclient.mute_player(body);
1105
+ setVoiceMuted(!voiceMuted);
1106
+ toast.success(voiceMuted ? "Unmuted" : "Muted");
1107
+ } catch (e: any) {
1108
+ toast.error(e?.message || "Failed to toggle mute");
1109
+ }
1110
+ };
1111
+
1112
+ const onCardSelect = (card: RoundMeResponse["hand"][number], idx: number) => {
1113
+ if (!hasDrawn) return;
1114
+ setSelectedCard({ rank: card.rank, suit: card.suit || null, joker: card.joker || false });
1115
+ };
1116
+
1117
+ const onReorderHand = (reorderedHand: RoundMeResponse["hand"]) => {
1118
+ if (myRound) {
1119
+ setMyRound({ ...myRound, hand: reorderedHand });
1120
+ }
1121
+ };
1122
+
1123
+ const onSelectCard = (card: DiscardCard) => {
1124
+ if (!hasDrawn) return;
1125
+ setSelectedCard(card);
1126
+ };
1127
+
1128
+ const onClearMelds = () => {
1129
+ setMeld1([null, null, null]);
1130
+ setMeld2([null, null, null]);
1131
+ setMeld3([null, null, null]);
1132
+ setLeftover([null, null, null, null]);
1133
+ toast.success('Melds cleared');
1134
+ };
1135
+
1136
+ // Debug logging for button visibility
1137
+ useEffect(() => {
1138
+ console.log('🔍 Discard Button Visibility Check:', {
1139
+ isMyTurn,
1140
+ hasDrawn,
1141
+ selectedCard,
1142
+ handLength: myRound?.hand.length,
1143
+ showDiscardButton: isMyTurn && hasDrawn && selectedCard !== null,
1144
+ user_id: user?.id,
1145
+ active_user_id: info?.active_user_id
1146
+ });
1147
+ }, [isMyTurn, hasDrawn, selectedCard, myRound, user, info]);
1148
+
1149
+ if (!tableId) {
1150
+ return (
1151
+ <div className="container mx-auto px-4 py-12">
1152
+ <div className="bg-card border border-border rounded-lg p-6">
1153
+ <p className="text-foreground mb-4">Missing tableId.</p>
1154
+ <button
1155
+ onClick={() => navigate("/")}
1156
+ className="px-4 py-2 bg-secondary text-secondary-foreground rounded-lg"
1157
+ >
1158
+ Go Home
1159
+ </button>
1160
+ </div>
1161
+ </div>
1162
+ );
1163
+ }
1164
+
1165
+ return (
1166
+ <div className="min-h-screen bg-gradient-to-b from-slate-950 via-slate-900 to-slate-950">
1167
+ <div className="relative">
1168
+ {/* Collapsible Game Rules - positioned top right */}
1169
+ <GameRules defaultOpen={false} />
1170
+
1171
+ {/* Remove the separate PointsTable component - it's now inside Table Info */}
1172
+
1173
+ <div className="max-w-7xl mx-auto">
1174
+ <div className="flex items-center justify-between mb-4">
1175
+ <h2 className="text-2xl font-semibold text-foreground">Table</h2>
1176
+ <div className="flex items-center gap-2">
1177
+ {/* Voice Mute Toggle */}
1178
+ {info?.status === 'playing' && (
1179
+ <button
1180
+ onClick={toggleVoiceMute}
1181
+ className={`inline-flex items-center gap-2 px-3 py-2 rounded-lg font-medium shadow-lg transition-colors ${
1182
+ voiceMuted
1183
+ ? 'bg-red-700 hover:bg-red-600 text-white'
1184
+ : 'bg-green-700 hover:bg-green-600 text-white'
1185
+ }`}
1186
+ title={voiceMuted ? 'Unmute' : 'Mute'}
1187
+ >
1188
+ {voiceMuted ? <MicOff className="w-5 h-5" /> : <Mic className="w-5 h-5" />}
1189
+ </button>
1190
+ )}
1191
+
1192
+ {/* Drop Game Button (only before first draw) */}
1193
+ {info?.status === 'playing' && !hasDrawn && (
1194
+ <button
1195
+ onClick={onDropGame}
1196
+ disabled={droppingGame}
1197
+ className="inline-flex items-center gap-2 px-3 py-2 bg-orange-700 hover:bg-orange-600 text-white rounded-lg font-medium shadow-lg transition-colors disabled:opacity-50"
1198
+ title="Drop game (20pt penalty)"
1199
+ >
1200
+ <UserX className="w-5 h-5" />
1201
+ {droppingGame ? 'Dropping...' : 'Drop'}
1202
+ </button>
1203
+ )}
1204
+
1205
+ <button
1206
+ onClick={() => navigate("/")}
1207
+ className="inline-flex items-center gap-2 px-4 py-2 bg-red-700 hover:bg-red-600 text-white rounded-lg font-medium shadow-lg transition-colors"
1208
+ >
1209
+ <LogOut className="w-5 h-5" />
1210
+ Leave Table
1211
+ </button>
1212
+ </div>
1213
+ </div>
1214
+
1215
+ {/* Responsive Layout: Single column on mobile, two columns on desktop */}
1216
+ <div className="grid gap-4 grid-cols-1 lg:grid-cols-[1fr,300px]">
1217
+ {/* Main Game Area */}
1218
+ <div className="bg-card border border-border rounded-lg p-4 order-2 lg:order-1">
1219
+ {loading && <p className="text-muted-foreground">Loading…</p>}
1220
+ {!loading && info && (
1221
+ <div className="space-y-4">
1222
+ <div className="flex items-center justify-between">
1223
+ <div>
1224
+ <p className="text-sm text-muted-foreground">Room Code</p>
1225
+ <p className="text-2xl font-bold tracking-wider text-green-400">{info.code}</p>
1226
+ </div>
1227
+ <button
1228
+ onClick={onCopy}
1229
+ className="inline-flex items-center gap-2 px-3 py-2 bg-green-800 text-green-100 rounded-lg hover:bg-green-700"
1230
+ >
1231
+ {copied ? (<><Check className="w-4 h-4"/> Copied</>) : (<><Copy className="w-4 h-4"/> Copy</>)}
1232
+ </button>
1233
+ </div>
1234
+
1235
+ <div className="border-t border-border pt-4">
1236
+ <p className="text-sm text-muted-foreground mb-2">Players</p>
1237
+ <div className="grid grid-cols-2 gap-3">
1238
+ {info.players.map((p) => (
1239
+ <div key={p.user_id} className={`flex items-center gap-2 bg-background px-2 py-1 rounded border border-border`}>
1240
+ <div className="w-8 h-8 rounded-full bg-green-800/50 flex items-center justify-center">
1241
+ <User2 className="w-4 h-4 text-green-200"/>
1242
+ </div>
1243
+ <div className="flex-1 min-w-0">
1244
+ <p className="text-foreground text-sm truncate">Seat {p.seat}</p>
1245
+ <p className="text-muted-foreground text-xs truncate">{p.display_name || p.user_id.slice(0,6)}</p>
1246
+ </div>
1247
+ {p.user_id === info.host_user_id && (
1248
+ <span className="inline-flex items-center gap-1 text-[10px] text-amber-400 bg-amber-900/20 px-1.5 py-0.5 rounded">
1249
+ <Crown className="w-3 h-3"/> Host
1250
+ </span>
1251
+ )}
1252
+ {info.status === "playing" && p.user_id === info.active_user_id && (
1253
+ <span className="text-xs text-amber-400 font-medium">Active</span>
1254
+ )}
1255
+ </div>
1256
+ ))}
1257
+ </div>
1258
+ </div>
1259
+
1260
+ {info.current_round_number && myRound && (
1261
+ <div className="border-t border-border pt-4 space-y-3">
1262
+ <div className="flex items-center justify-between">
1263
+ <div>
1264
+ <p className="text-sm text-muted-foreground">Round #{info.current_round_number}</p>
1265
+ {isMyTurn ? (
1266
+ <p className="text-amber-400 font-medium text-sm">Your turn!</p>
1267
+ ) : (
1268
+ <p className="text-muted-foreground text-sm">Wait for your turn</p>
1269
+ )}
1270
+ </div>
1271
+ <div className="flex gap-2 text-xs">
1272
+ <div className="bg-background border border-border rounded px-2 py-1">
1273
+ <span className="text-muted-foreground">Stock:</span> <span className="text-foreground font-medium">{myRound.stock_count}</span>
1274
+ </div>
1275
+ {myRound.discard_top && (
1276
+ <div className="bg-background border border-border rounded px-2 py-1">
1277
+ <span className="text-muted-foreground">Discard Top:</span> <span className="text-foreground font-medium">{myRound.discard_top}</span>
1278
+ </div>
1279
+ )}
1280
+ <div className="bg-gradient-to-r from-purple-900/50 to-pink-900/50 border border-purple-500/50 rounded px-2 py-1">
1281
+ <span className="text-purple-200">Wild Joker:</span>{" "}
1282
+ {myRound.wild_joker_revealed ? (
1283
+ <span className="text-pink-300 font-bold">{myRound.wild_joker_rank}</span>
1284
+ ) : (
1285
+ <span className="text-purple-400 font-medium">???</span>
1286
+ )}
1287
+ </div>
1288
+ </div>
1289
+ </div>
1290
+
1291
+ {/* Table Color Picker */}
1292
+ <div className="flex gap-2 items-center justify-end mb-2">
1293
+ <span className="text-xs text-muted-foreground">Table Color:</span>
1294
+ <button
1295
+ type="button"
1296
+ onClick={(e) => {
1297
+ e.preventDefault();
1298
+ e.stopPropagation();
1299
+ console.log('🎨 GREEN button clicked!');
1300
+ console.log('🎨 Before setState - tableColor:', tableColor);
1301
+ setTableColor('green');
1302
+ console.log('🎨 After setState - requested green');
1303
+ }}
1304
+ className={`pointer-events-auto w-8 h-8 rounded-full border-2 transition-all hover:scale-110 active:scale-95 cursor-pointer ${
1305
+ tableColor === 'green' ? 'border-amber-400 scale-110 shadow-lg' : 'border-slate-600'
1306
+ }`}
1307
+ style={{ backgroundColor: '#15803d' }}
1308
+ title="Green Felt"
1309
+ />
1310
+ <button
1311
+ type="button"
1312
+ onClick={(e) => {
1313
+ e.preventDefault();
1314
+ e.stopPropagation();
1315
+ console.log('🎨 RED-BROWN button clicked!');
1316
+ console.log('🎨 Before setState - tableColor:', tableColor);
1317
+ setTableColor('red-brown');
1318
+ console.log('🎨 After setState - requested red-brown');
1319
+ }}
1320
+ className={`pointer-events-auto w-8 h-8 rounded-full border-2 transition-all hover:scale-110 active:scale-95 cursor-pointer ${
1321
+ tableColor === 'red-brown' ? 'border-amber-400 scale-110 shadow-lg' : 'border-slate-600'
1322
+ }`}
1323
+ style={{ backgroundColor: '#6b2f2f' }}
1324
+ title="Red-Brown Felt"
1325
+ />
1326
+ </div>
1327
+
1328
+ {/* 3D Casino Table - Contains ONLY Stock, Discard, Wild Joker */}
1329
+ <CasinoTable3D tableColor={tableColor} key={tableColor}>
1330
+ {/* Player Positions Around Table - Only show if player exists */}
1331
+ <div className="absolute inset-0 pointer-events-none">
1332
+ {/* Top players (P2, P3, P4) */}
1333
+ <div className="absolute top-4 left-1/2 -translate-x-1/2 flex gap-8">
1334
+ {info?.players?.[1] && <PlayerProfile position="P2" name={info.players[1].display_name || 'Player 2'} profilePic={info.players[1].profile_image_url} isActive={info.players[1].user_id === info.active_user_id} />}
1335
+ {info?.players?.[2] && <PlayerProfile position="P3" name={info.players[2].display_name || 'Player 3'} profilePic={info.players[2].profile_image_url} isActive={info.players[2].user_id === info.active_user_id} />}
1336
+ {info?.players?.[3] && <PlayerProfile position="P4" name={info.players[3].display_name || 'Player 4'} profilePic={info.players[3].profile_image_url} isActive={info.players[3].user_id === info.active_user_id} />}
1337
+ </div>
1338
+
1339
+ {/* Left player (P1) */}
1340
+ {info?.players?.[0] && (
1341
+ <div className="absolute left-4 top-1/2 -translate-y-1/2">
1342
+ <PlayerProfile position="P1" name={info.players[0].display_name || 'Player 1'} profilePic={info.players[0].profile_image_url} isActive={info.players[0].user_id === info.active_user_id} />
1343
+ </div>
1344
+ )}
1345
+
1346
+ {/* Right player (P5) */}
1347
+ {info?.players?.[4] && (
1348
+ <div className="absolute right-4 top-1/2 -translate-y-1/2">
1349
+ <PlayerProfile position="P5" name={info.players[4].display_name || 'Player 5'} profilePic={info.players[4].profile_image_url} isActive={info.players[4].user_id === info.active_user_id} />
1350
+ </div>
1351
+ )}
1352
+
1353
+ {/* Bottom player (current user) */}
1354
+ <div className="absolute bottom-4 left-1/2 -translate-x-1/2">
1355
+ <PlayerProfile position="You" name={user?.displayName || 'You'} profilePic={undefined} isActive={isMyTurn} isCurrentUser={true} />
1356
+ </div>
1357
+ </div>
1358
+
1359
+ {/* Cards ON the Table Surface - HORIZONTAL ROW */}
1360
+ <div className="flex items-center justify-center h-full">
1361
+ <div className="flex gap-12 items-center justify-center">
1362
+ {/* Stock Pile - NOW CLICKABLE */}
1363
+ <div className="flex flex-col items-center gap-2">
1364
+ <button
1365
+ type="button"
1366
+ onClick={onDrawStock}
1367
+ disabled={!isMyTurn || hasDrawn || acting}
1368
+ className="relative w-[100px] h-[140px] transition-all duration-200 enabled:hover:scale-110 enabled:hover:-translate-y-2 disabled:opacity-40 disabled:cursor-not-allowed disabled:hover:scale-100 disabled:hover:translate-y-0 drop-shadow-2xl enabled:cursor-pointer"
1369
+ title={isMyTurn && !hasDrawn ? "Click to draw from stock" : ""}
1370
+ >
1371
+ {myRound.stock_count > 0 ? (
1372
+ <>
1373
+ <div className="absolute top-2 left-2 w-full h-full bg-red-800 border-2 border-red-900 rounded-lg shadow-lg transform rotate-3"></div>
1374
+ <div className="absolute top-1 left-1 w-full h-full bg-red-800 border-2 border-red-900 rounded-lg shadow-lg transform -rotate-2"></div>
1375
+ <div className="relative w-full h-full bg-red-800 border-2 border-red-900 rounded-lg shadow-2xl flex items-center justify-center">
1376
+ <span className="text-5xl">🃏</span>
1377
+ </div>
1378
+ </>
1379
+ ) : (
1380
+ <div className="w-full h-full border-2 border-dashed border-green-600/40 rounded-lg flex items-center justify-center text-green-600/50 text-sm">
1381
+ Empty
1382
+ </div>
1383
+ )}
1384
+ </button>
1385
+ <div className="flex flex-col items-center gap-1">
1386
+ <p className="text-xs font-bold text-amber-400 tracking-wide">STOCK PILE</p>
1387
+ {myRound.stock_count > 0 && (
1388
+ <div className="bg-black/90 text-amber-300 px-2 py-0.5 rounded-full text-xs font-bold border border-amber-500/50">
1389
+ {myRound.stock_count} cards
1390
+ </div>
1391
+ )}
1392
+ </div>
1393
+ </div>
1394
+
1395
+ {/* Wild Joker Card - only show if game mode uses wild jokers */}
1396
+ {info?.game_mode !== 'no_joker' && (
1397
+ <div className="flex flex-col items-center gap-2">
1398
+ <div className="w-[100px] h-[140px]">
1399
+ {myRound.wild_joker_revealed && myRound.wild_joker_rank ? (
1400
+ <div className="w-full h-full bg-white border-4 border-yellow-500 rounded-xl shadow-2xl transform rotate-3">
1401
+ <span className="text-4xl font-bold text-yellow-600">{myRound.wild_joker_rank}</span>
1402
+ <span className="text-xs text-gray-600 mt-2 font-semibold">All {myRound.wild_joker_rank}s</span>
1403
+ </div>
1404
+ ) : (
1405
+ <div className="w-full h-full bg-red-800 border-2 border-red-900 rounded-xl shadow-2xl flex items-center justify-center">
1406
+ <span className="text-5xl">🃏</span>
1407
+ </div>
1408
+ )}
1409
+ </div>
1410
+ <p className="text-xs font-bold text-amber-400 tracking-wide">
1411
+ {myRound.wild_joker_revealed ? (
1412
+ <span>WILD JOKER</span>
1413
+ ) : (
1414
+ <span>WILD JOKER</span>
1415
+ )}
1416
+ </p>
1417
+ </div>
1418
+ )}
1419
+
1420
+ {/* Discard Pile */}
1421
+ <div className="flex flex-col items-center gap-2">
1422
+ <button
1423
+ onClick={onDrawDiscard}
1424
+ disabled={!isMyTurn || hasDrawn || acting || !myRound.discard_top}
1425
+ className="relative w-[100px] h-[140px] transition-transform hover:scale-110 hover:-translate-y-2 disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:scale-100 disabled:hover:translate-y-0"
1426
+ title={isMyTurn && !hasDrawn && myRound.discard_top ? "Click to draw from discard" : myRound.discard_top ? "Not your turn" : "Discard pile empty"}
1427
+ >
1428
+ {myRound.discard_top ? (
1429
+ <>
1430
+ <div className="absolute top-0 left-0 w-full h-full bg-white rounded-xl border-2 border-gray-300 shadow-xl opacity-30 transform -translate-x-2 -translate-y-2" />
1431
+ <div className="absolute top-0 left-0 w-full h-full bg-white rounded-xl border-2 border-gray-300 shadow-xl opacity-50 transform -translate-x-1 -translate-y-1" />
1432
+
1433
+ <div className="relative w-full h-full bg-white rounded-xl border-4 border-black shadow-2xl flex flex-col items-center justify-between p-2">
1434
+ {(() => {
1435
+ const card = parseCardCode(myRound.discard_top);
1436
+ if (card.joker) {
1437
+ return (
1438
+ <div className="flex-1 flex flex-col items-center justify-center">
1439
+ <span className="text-6xl drop-shadow-lg">🃏</span>
1440
+ <span className="text-xs text-gray-800 mt-1 drop-shadow">JOKER</span>
1441
+ </div>
1442
+ );
1443
+ }
1444
+ const isRed = card.suit === 'H' || card.suit === 'D';
1445
+ const suitSymbol = card.suit ? { H: '♥', D: '♦', S: '♠', C: '♣' }[card.suit] : '';
1446
+ return (
1447
+ <>
1448
+ <span className={`text-xl font-black drop-shadow ${isRed ? 'text-red-600' : 'text-black'}`}>
1449
+ {card.rank}{suitSymbol}
1450
+ </span>
1451
+ <span className={`text-6xl drop-shadow-lg ${isRed ? 'text-red-600' : 'text-black'}`}>
1452
+ {suitSymbol}
1453
+ </span>
1454
+ <span className={`text-3xl font-black drop-shadow ${isRed ? 'text-red-600' : 'text-black'}`}>
1455
+ {card.rank}
1456
+ </span>
1457
+ </>
1458
+ );
1459
+ })()}
1460
+ </div>
1461
+ </>
1462
+ ) : (
1463
+ <div className="w-full h-full border-2 border-dashed border-gray-600 rounded-lg flex items-center justify-center text-gray-500 text-sm">
1464
+ Empty
1465
+ </div>
1466
+ )}
1467
+ </button>
1468
+ <p className="text-xs font-bold text-amber-400 tracking-wide">DISCARD PILE</p>
1469
+ </div>
1470
+ </div>
1471
+ </div>
1472
+ </CasinoTable3D>
1473
+
1474
+ {/* Meld Grouping Zone - Outside the 3D table with clean design */}
1475
+ <div className="bg-background/50 border border-dashed border-border rounded-lg p-4 mb-3 mt-6">
1476
+ <p className="text-sm text-muted-foreground mb-2">
1477
+ {hasDrawn ? "Organize your 13 cards into melds (drag cards to slots)" : "Organize melds (draw a card first)"}
1478
+ </p>
1479
+
1480
+ {/* Three 3-card meld boxes */}
1481
+ <div className="grid grid-cols-3 gap-2 mb-2">
1482
+ {/* Meld 1 - with lock button */}
1483
+ <MeldSlotBox
1484
+ title="Meld 1"
1485
+ slots={meld1}
1486
+ setSlots={setMeld1}
1487
+ myRound={myRound}
1488
+ setMyRound={setMyRound}
1489
+ isLocked={meldLocks.meld1}
1490
+ onToggleLock={() => toggleMeldLock('meld1')}
1491
+ tableId={tableId}
1492
+ onRefresh={refresh}
1493
+ gameMode={info?.game_mode}
1494
+ />
1495
+ {/* Meld 2 - no lock button */}
1496
+ <MeldSlotBox
1497
+ title="Meld 2"
1498
+ slots={meld2}
1499
+ setSlots={setMeld2}
1500
+ myRound={myRound}
1501
+ setMyRound={setMyRound}
1502
+ isLocked={meldLocks.meld2}
1503
+ onToggleLock={() => toggleMeldLock('meld2')}
1504
+ tableId={tableId}
1505
+ onRefresh={refresh}
1506
+ hideLockButton={true}
1507
+ gameMode={info?.game_mode}
1508
+ />
1509
+ {/* Meld 3 - no lock button */}
1510
+ <MeldSlotBox
1511
+ title="Meld 3"
1512
+ slots={meld3}
1513
+ setSlots={setMeld3}
1514
+ myRound={myRound}
1515
+ setMyRound={setMyRound}
1516
+ isLocked={meldLocks.meld3}
1517
+ onToggleLock={() => toggleMeldLock('meld3')}
1518
+ tableId={tableId}
1519
+ onRefresh={refresh}
1520
+ hideLockButton={true}
1521
+ gameMode={info?.game_mode}
1522
+ />
1523
+ </div>
1524
+
1525
+ {/* Leftover cards */}
1526
+ <LeftoverSlotBox
1527
+ slots={leftover}
1528
+ setSlots={setLeftover}
1529
+ myRound={myRound}
1530
+ setMyRound={setMyRound}
1531
+ isLocked={meldLocks.leftover}
1532
+ onToggleLock={() => toggleMeldLock('leftover')}
1533
+ tableId={tableId}
1534
+ onRefresh={refresh}
1535
+ gameMode={info?.game_mode}
1536
+ />
1537
+
1538
+ {/* Clear melds button only */}
1539
+ {hasDrawn && (
1540
+ <div className="flex gap-2 mt-3">
1541
+ <button
1542
+ onClick={onClearMelds}
1543
+ className="px-3 py-1.5 bg-red-700/70 text-red-100 rounded hover:bg-red-600 text-sm"
1544
+ >
1545
+ <Trash2 className="inline w-4 h-4 mr-1"/> Clear Melds
1546
+ </button>
1547
+ </div>
1548
+ )}
1549
+ </div>
1550
+
1551
+ {/* Hand */}
1552
+ <div className="bg-background border border-border rounded-lg p-4">
1553
+ <p className="text-sm text-muted-foreground mb-3">
1554
+ Your Hand ({availableHand.length} cards)
1555
+ {lastDrawnCard && <span className="ml-2 text-amber-400 text-xs">★ New card highlighted</span>}
1556
+ </p>
1557
+ <HandStrip
1558
+ hand={availableHand}
1559
+ onCardClick={hasDrawn ? onCardSelect : undefined}
1560
+ selectedIndex={selectedCard ? availableHand.findIndex(
1561
+ c => c.rank === selectedCard.rank && c.suit === selectedCard.suit
1562
+ ) : undefined}
1563
+ highlightIndex={lastDrawnCard ? availableHand.findIndex(
1564
+ c => c.rank === lastDrawnCard.rank && c.suit === lastDrawnCard.suit
1565
+ ) : undefined}
1566
+ onReorder={onReorderHand}
1567
+ />
1568
+
1569
+ {/* Discard Button - Only shown when card is selected */}
1570
+ {isMyTurn && hasDrawn && selectedCard && (
1571
+ <div className="bg-red-900/20 border-2 border-red-500/50 rounded-lg p-4 mt-4">
1572
+ <p className="text-red-200 text-sm mb-2 text-center">
1573
+ ✓ Card selected - Click to discard
1574
+ </p>
1575
+ <button
1576
+ onClick={onDiscard}
1577
+ disabled={acting}
1578
+ className="w-full inline-flex items-center gap-2 px-4 py-3 bg-red-600 text-white rounded-lg hover:bg-red-500 disabled:opacity-50 disabled:cursor-not-allowed text-sm font-medium"
1579
+ >
1580
+ <Trash2 className="w-5 h-5" />
1581
+ {acting ? "Discarding..." : `Discard ${selectedCard.rank}${selectedCard.suit || ''}`}
1582
+ </button>
1583
+ </div>
1584
+ )}
1585
+ </div>
1586
+
1587
+ {/* Declare & Discard Actions - When turn is active */}
1588
+ {isMyTurn && hasDrawn && (
1589
+ <div className="bg-blue-900/20 border border-blue-700/50 rounded-lg p-3 space-y-2">
1590
+ <p className="text-blue-200 text-sm font-medium">Organize 13 cards into valid melds, then declare. The 14th card will be auto-discarded.</p>
1591
+ <div className="flex gap-2">
1592
+ <button
1593
+ onClick={() => {
1594
+ console.log('🔴 DECLARE BUTTON CLICKED!');
1595
+ console.log('🔴 Button state:', { isMyTurn, hasDrawn, acting, tableId });
1596
+ console.log('🔴 Melds:', { meld1: meld1?.length, meld2: meld2?.length, meld3: meld3?.length, leftover: leftover?.length });
1597
+ onDeclare();
1598
+ }}
1599
+ disabled={acting}
1600
+ className="inline-flex items-center gap-2 px-4 py-3 bg-purple-700 text-purple-100 rounded hover:bg-purple-600 disabled:opacity-50 disabled:cursor-not-allowed text-sm font-medium"
1601
+ >
1602
+ <Trophy className="w-5 h-5" />
1603
+ {acting ? "Declaring..." : "Declare & Win"}
1604
+ </button>
1605
+ {selectedCard && (
1606
+ <button
1607
+ onClick={onDiscard}
1608
+ disabled={acting}
1609
+ className="inline-flex items-center gap-2 px-4 py-2 bg-red-700 text-red-100 rounded hover:bg-red-600 disabled:opacity-50 disabled:cursor-not-allowed text-sm font-medium"
1610
+ >
1611
+ Discard Selected
1612
+ </button>
1613
+ )}
1614
+ </div>
1615
+ </div>
1616
+ )}
1617
+ </div>
1618
+ )}
1619
+
1620
+ {/* Scoreboard Display */}
1621
+ {scoreboard && info?.status === "finished" && (
1622
+ <div className="border-t border-border pt-4 space-y-4">
1623
+ <div className="bg-gradient-to-br from-amber-900/20 to-yellow-900/20 border border-amber-500/30 rounded-lg p-6">
1624
+ <div className="flex items-center justify-center gap-3 mb-4">
1625
+ <Trophy className="w-8 h-8 text-amber-400" />
1626
+ <h3 className="text-2xl font-bold text-amber-400">Round #{scoreboard.round_number} Complete!</h3>
1627
+ </div>
1628
+
1629
+ {scoreboard.winner_user_id && (
1630
+ <div className="text-center mb-4">
1631
+ <p className="text-lg text-green-300">
1632
+ 🎉 Winner: {info.players.find(p => p.user_id === scoreboard.winner_user_id)?.display_name || "Unknown"}
1633
+ </p>
1634
+ </div>
1635
+ )}
1636
+
1637
+ <div className="bg-background/50 rounded-lg overflow-hidden">
1638
+ <table className="w-full">
1639
+ <thead>
1640
+ <tr className="bg-muted/50 border-b border-border">
1641
+ <th className="text-left px-4 py-2 text-sm font-medium text-foreground">Player</th>
1642
+ <th className="text-right px-4 py-2 text-sm font-medium text-foreground">Points</th>
1643
+ </tr>
1644
+ </thead>
1645
+ <tbody>
1646
+ {scoreboard.scores
1647
+ .sort((a, b) => a.points - b.points)
1648
+ .map((score, idx) => {
1649
+ const player = info.players.find(p => p.user_id === score.user_id);
1650
+ const isWinner = score.user_id === scoreboard.winner_user_id;
1651
+ return (
1652
+ <tr key={score.user_id} className={`border-b border-border/50 ${
1653
+ isWinner ? 'bg-green-900/20' : ''
1654
+ }`}>
1655
+ <td className="px-4 py-3 text-sm">
1656
+ <div className="flex items-center gap-2">
1657
+ {isWinner && <Trophy className="w-4 h-4 text-amber-400" />}
1658
+ <span className={isWinner ? 'text-green-300 font-medium' : 'text-foreground'}>
1659
+ {player?.display_name || score.user_id.slice(0, 6)}
1660
+ </span>
1661
+ </div>
1662
+ </td>
1663
+ <td className={`px-4 py-3 text-right text-sm ${
1664
+ isWinner ? 'text-green-400 font-bold' : 'text-foreground'
1665
+ }`}>
1666
+ {score.points}
1667
+ </td>
1668
+ </tr>
1669
+ );
1670
+ })}
1671
+ </tbody>
1672
+ </table>
1673
+ </div>
1674
+ </div>
1675
+
1676
+ {/* Next Round Button */}
1677
+ {user && info.host_user_id === user.id && (
1678
+ <div className="text-center">
1679
+ <button
1680
+ onClick={onNextRound}
1681
+ disabled={acting}
1682
+ className="inline-flex items-center gap-2 px-6 py-3 bg-green-700 text-green-100 rounded-lg hover:bg-green-600 font-medium"
1683
+ >
1684
+ <Play className="w-5 h-5" />
1685
+ {acting ? "Starting..." : "Start Next Round"}
1686
+ </button>
1687
+ </div>
1688
+ )}
1689
+ {user && info.host_user_id !== user.id && (
1690
+ <p className="text-center text-sm text-muted-foreground">Waiting for host to start next round...</p>
1691
+ )}
1692
+ </div>
1693
+ )}
1694
+
1695
+ {/* Show Next Round button if user is host and round is complete */}
1696
+ {info?.status === 'round_complete' && info?.host_id === user?.id && (
1697
+ <div className="flex justify-center mt-8">
1698
+ <Button
1699
+ onClick={onNextRound}
1700
+ disabled={acting}
1701
+ className="bg-amber-600 hover:bg-amber-700 text-white px-8 py-4 text-lg font-bold"
1702
+ >
1703
+ {acting ? 'Starting...' : '▶ Start Next Round'}
1704
+ </Button>
1705
+ </div>
1706
+ )}
1707
+ </div>
1708
+ )}
1709
+ </div>
1710
+
1711
+ {/* Sidebar - Table Info with Round History */}
1712
+ {tableInfoVisible && (
1713
+ <div className={`bg-card border border-border rounded-lg shadow-lg ${
1714
+ tableInfoMinimized ? 'w-auto' : 'order-1 lg:order-2'
1715
+ }`}>
1716
+ {/* Header with Minimize/Close */}
1717
+ <div className="flex items-center justify-between p-3 bg-muted/30 border-b border-border rounded-t-lg">
1718
+ <h3 className="text-sm font-semibold text-foreground">
1719
+ {tableInfoMinimized ? 'Table' : 'Table Info'}
1720
+ </h3>
1721
+ <div className="flex items-center gap-1">
1722
+ <button
1723
+ onClick={() => setTableInfoMinimized(!tableInfoMinimized)}
1724
+ className="p-1 hover:bg-muted rounded"
1725
+ title={tableInfoMinimized ? 'Expand' : 'Minimize'}
1726
+ >
1727
+ {tableInfoMinimized ? <ChevronDown className="w-4 h-4" /> : <ChevronUp className="w-4 h-4" />}
1728
+ </button>
1729
+ <button
1730
+ onClick={() => setTableInfoVisible(false)}
1731
+ className="p-1 hover:bg-muted rounded"
1732
+ title="Close"
1733
+ >
1734
+ <X className="w-4 h-4" />
1735
+ </button>
1736
+ </div>
1737
+ </div>
1738
+
1739
+ {/* Content - only show when not minimized */}
1740
+ {!tableInfoMinimized && (
1741
+ <div className="p-4 space-y-4 max-h-[80vh] overflow-y-auto">
1742
+ {loading && <p className="text-muted-foreground">Loading…</p>}
1743
+ {!loading && info && (
1744
+ <>
1745
+ {/* Room Code */}
1746
+ <div>
1747
+ <p className="text-sm text-muted-foreground">Room Code</p>
1748
+ <div className="flex items-center gap-2 mt-1">
1749
+ <code className="text-lg font-mono text-foreground bg-background px-3 py-1 rounded border border-border">
1750
+ {info.code}
1751
+ </code>
1752
+ <button
1753
+ onClick={() => {
1754
+ navigator.clipboard.writeText(info.code);
1755
+ toast.success("Code copied!");
1756
+ }}
1757
+ className="p-1.5 hover:bg-muted rounded"
1758
+ >
1759
+ <Copy className="w-4 h-4" />
1760
+ </button>
1761
+ </div>
1762
+ </div>
1763
+
1764
+ {/* Players */}
1765
+ <div>
1766
+ <p className="text-sm text-muted-foreground mb-2">Players ({info.players.length})</p>
1767
+ <div className="space-y-1.5">
1768
+ {info.players.map((p) => (
1769
+ <div
1770
+ key={p.user_id}
1771
+ className="flex items-center gap-2 text-sm bg-background px-2 py-1 rounded border border-border"
1772
+ >
1773
+ <div className="w-8 h-8 rounded-full bg-green-800/50 flex items-center justify-center">
1774
+ <User2 className="w-4 h-4 text-green-200"/>
1775
+ </div>
1776
+ <div className="flex-1 min-w-0">
1777
+ <p className="text-foreground text-sm truncate">Seat {p.seat}</p>
1778
+ <p className="text-muted-foreground text-xs truncate">{p.display_name || p.user_id.slice(0,6)}</p>
1779
+ </div>
1780
+ {p.user_id === info.host_user_id && (
1781
+ <span className="inline-flex items-center gap-1 text-[10px] text-amber-400 bg-amber-900/20 px-1.5 py-0.5 rounded">
1782
+ <Crown className="w-3 h-3"/> Host
1783
+ </span>
1784
+ )}
1785
+ {info.status === "playing" && p.user_id === info.active_user_id && (
1786
+ <span className="text-xs text-amber-400 font-medium">Active</span>
1787
+ )}
1788
+ </div>
1789
+ ))}
1790
+ </div>
1791
+ </div>
1792
+
1793
+ {/* Status */}
1794
+ <div className="border-t border-border pt-3">
1795
+ <p className="text-sm text-muted-foreground">Status: <span className="text-foreground font-medium">{info?.status ?? "-"}</span></p>
1796
+ {user && info.host_user_id === user.id && (
1797
+ <button
1798
+ onClick={onStart}
1799
+ disabled={!canStart || starting}
1800
+ className="inline-flex items-center gap-2 px-4 py-2 bg-primary text-primary-foreground rounded-lg disabled:opacity-50 mt-2"
1801
+ >
1802
+ <Play className="w-5 h-5" />
1803
+ {starting ? "Starting…" : "Start Game"}
1804
+ </button>
1805
+ )}
1806
+ {info && info.status === "waiting" && user && user.id !== info.host_user_id && (
1807
+ <p className="text-sm text-muted-foreground text-center py-2">Waiting for host to start...</p>
1808
+ )}
1809
+ </div>
1810
+
1811
+ {/* Round History & Points Table */}
1812
+ {roundHistory.length > 0 && (
1813
+ <div className="border-t border-border pt-3">
1814
+ <h4 className="text-sm font-semibold text-foreground mb-2">Round History</h4>
1815
+ <div className="overflow-x-auto">
1816
+ <table className="w-full text-xs">
1817
+ <thead>
1818
+ <tr className="border-b border-border">
1819
+ <th className="text-left py-2 px-2 font-semibold text-foreground">Player</th>
1820
+ {roundHistory.map((round, idx) => (
1821
+ <th key={idx} className="text-center py-2 px-1 font-semibold text-foreground">
1822
+ R{round.round_number}
1823
+ </th>
1824
+ ))}
1825
+ <th className="text-right py-2 px-2 font-semibold text-yellow-600 dark:text-yellow-500">
1826
+ Total
1827
+ </th>
1828
+ </tr>
1829
+ </thead>
1830
+ <tbody>
1831
+ {info.players.map((player) => {
1832
+ let runningTotal = 0;
1833
+ return (
1834
+ <tr key={player.user_id} className="border-b border-border/50">
1835
+ <td className="py-2 px-2 text-foreground">
1836
+ <div className="flex items-center gap-1">
1837
+ {player.display_name || 'Player'}
1838
+ </div>
1839
+ </td>
1840
+ {roundHistory.map((round, idx) => {
1841
+ const isWinner = round.winner_user_id === player.user_id;
1842
+ const roundScore = round.scores[player.user_id] || 0;
1843
+ runningTotal += roundScore;
1844
+ return (
1845
+ <td key={idx} className="text-center py-2 px-1">
1846
+ <div className="flex flex-col items-center">
1847
+ <span className={isWinner ? 'text-green-600 dark:text-green-500 font-semibold' : 'text-muted-foreground'}>
1848
+ {roundScore}
1849
+ </span>
1850
+ {isWinner && <Trophy className="w-3 h-3 text-yellow-500" />}
1851
+ </div>
1852
+ </td>
1853
+ );
1854
+ })}
1855
+ <td className="text-right py-2 px-2 font-bold text-yellow-600 dark:text-yellow-500">
1856
+ {runningTotal}
1857
+ </td>
1858
+ </tr>
1859
+ );
1860
+ })}
1861
+ </tbody>
1862
+ </table>
1863
+ </div>
1864
+ </div>
1865
+ )}
1866
+ </>
1867
+ )}
1868
+ </div>
1869
+ )}
1870
+ </div>
1871
+ )}
1872
+
1873
+ {/* Show Table Info button when closed */}
1874
+ {!tableInfoVisible && (
1875
+ <button
1876
+ onClick={() => setTableInfoVisible(true)}
1877
+ className="fixed top-20 right-4 z-20 bg-card border border-border rounded-lg shadow-lg px-4 py-2 hover:bg-accent/50 transition-colors"
1878
+ >
1879
+ Show Table Info
1880
+ </button>
1881
+ )}
1882
+
1883
+ {/* Spectate Requests Panel (Host Only) */}
1884
+ {info?.host_user_id === user?.id && spectateRequests.length > 0 && (
1885
+ <div className="absolute top-20 right-4 z-50 bg-slate-800 border border-slate-600 rounded-lg p-4 max-w-xs">
1886
+ <h3 className="text-sm font-bold text-amber-400 mb-2">Spectate Requests</h3>
1887
+ <div className="space-y-2">
1888
+ {spectateRequests.map(userId => (
1889
+ <div key={userId} className="flex items-center justify-between gap-2 bg-slate-900 p-2 rounded">
1890
+ <span className="text-xs text-slate-300 truncate">{userId.slice(0, 8)}...</span>
1891
+ <div className="flex gap-1">
1892
+ <Button
1893
+ onClick={() => grantSpectate(userId)}
1894
+ variant="outline"
1895
+ size="sm"
1896
+ className="h-6 px-2 text-xs"
1897
+ >
1898
+ Allow
1899
+ </Button>
1900
+ <Button
1901
+ onClick={() => setSpectateRequests(prev => prev.filter(id => id !== userId))}
1902
+ variant="destructive"
1903
+ size="sm"
1904
+ className="h-6 px-2 text-xs"
1905
+ >
1906
+ Deny
1907
+ </Button>
1908
+ </div>
1909
+ </div>
1910
+ ))}
1911
+ </div>
1912
+ </div>
1913
+ )}
1914
+ </div>
1915
+
1916
+ {/* Scoreboard Modal */}
1917
+ <ScoreboardModal
1918
+ isOpen={showScoreboardModal && !!revealedHands}
1919
+ onClose={() => setShowScoreboardModal(false)}
1920
+ data={revealedHands}
1921
+ players={info?.players || []}
1922
+ currentUserId={user?.id || ''}
1923
+ tableId={tableId || ''}
1924
+ hostUserId={info?.host_user_id || ''}
1925
+ onNextRound={() => {
1926
+ setShowScoreboardModal(false);
1927
+ onNextRound();
1928
+ }}
1929
+ />
1930
+
1931
+ {/* Side Panel for Scoreboard - Legacy */}
1932
+ {showScoreboardPanel && revealedHands && (
1933
+ <div className="fixed right-0 top-0 h-full w-96 bg-gray-900/95 border-l-2 border-yellow-500 shadow-2xl z-50 overflow-y-auto animate-slide-in-right">
1934
+ <div className="p-6">
1935
+ <div className="flex justify-between items-center mb-6">
1936
+ <h2 className="text-2xl font-bold text-yellow-400">Round Results</h2>
1937
+ <button
1938
+ onClick={() => setShowScoreboardPanel(false)}
1939
+ className="text-gray-400 hover:text-white transition-colors"
1940
+ >
1941
+ <svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
1942
+ <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
1943
+ </svg>
1944
+ </button>
1945
+ </div>
1946
+
1947
+ {/* Round Scores */}
1948
+ <div className="mb-6 p-4 bg-gray-800 rounded-lg border border-yellow-600">
1949
+ <h3 className="text-lg font-semibold text-yellow-400 mb-3">Scores</h3>
1950
+ {Object.entries(revealedHands.scores || {}).map(([uid, score]: [string, any]) => {
1951
+ const playerName = revealedHands.player_names?.[uid] || "Unknown";
1952
+ return (
1953
+ <div key={uid} className="flex justify-between py-2 border-b border-gray-700 last:border-0">
1954
+ <span className={uid === user?.id ? "text-yellow-400 font-semibold" : "text-gray-300"}>
1955
+ {playerName}
1956
+ </span>
1957
+ <span className={`font-bold ${score === 0 ? "text-green-400" : "text-red-400"}`}>
1958
+ {score} pts
1959
+ </span>
1960
+ </div>
1961
+ );
1962
+ })}
1963
+ </div>
1964
+
1965
+ {/* All Players' Hands */}
1966
+ <div className="space-y-6">
1967
+ {Object.entries(revealedHands.organized_melds || {}).map(([uid, melds]: [string, any]) => {
1968
+ const playerName = revealedHands.player_names?.[uid] || "Unknown";
1969
+ const playerScore = revealedHands.scores?.[uid] || 0;
1970
+ const isWinner = playerScore === 0;
1971
+
1972
+ return (
1973
+ <div key={uid} className="p-4 bg-gray-800 rounded-lg border-2" style={{
1974
+ borderColor: isWinner ? "#10b981" : "#6b7280"
1975
+ }}>
1976
+ <div className="flex justify-between items-center mb-3">
1977
+ <h4 className={`font-bold text-lg ${
1978
+ isWinner ? "text-green-400" : uid === user?.id ? "text-yellow-400" : "text-gray-300"
1979
+ }`}>
1980
+ {playerName}
1981
+ {isWinner && " 🏆"}
1982
+ </h4>
1983
+ <span className={`font-bold ${playerScore === 0 ? "text-green-400" : "text-red-400"}`}>
1984
+ {playerScore} pts
1985
+ </span>
1986
+ </div>
1987
+
1988
+ {melds && melds.length > 0 ? (
1989
+ <div className="space-y-3">
1990
+ {melds.map((meld: any, idx: number) => {
1991
+ const meldType = meld.type || "unknown";
1992
+ let bgColor = "bg-gray-700";
1993
+ let borderColor = "border-gray-600";
1994
+ let label = "Cards";
1995
+
1996
+ if (meldType === "pure") {
1997
+ bgColor = "bg-blue-900/40";
1998
+ borderColor = "border-blue-500";
1999
+ label = "Pure Sequence";
2000
+ } else if (meldType === "impure") {
2001
+ bgColor = "bg-purple-900/40";
2002
+ borderColor = "border-purple-500";
2003
+ label = "Impure Sequence";
2004
+ } else if (meldType === "set") {
2005
+ bgColor = "bg-orange-900/40";
2006
+ borderColor = "border-orange-500";
2007
+ label = "Set";
2008
+ }
2009
+
2010
+ return (
2011
+ <div key={idx} className={`p-3 rounded border ${bgColor} ${borderColor}`}>
2012
+ <div className="text-xs text-gray-400 mb-2">{label}</div>
2013
+ <div className="flex flex-wrap gap-2">
2014
+ {(meld.cards || []).map((card: any, cardIdx: number) => (
2015
+ <div key={cardIdx} className="text-sm font-mono bg-white text-gray-900 px-2 py-1 rounded">
2016
+ {card.name || card.code || "??"}
2017
+ </div>
2018
+ ))}
2019
+ </div>
2020
+ </div>
2021
+ );
2022
+ })}
2023
+ </div>
2024
+ ) : (
2025
+ <div className="text-gray-500 text-sm">No melds</div>
2026
+ )}
2027
+ </div>
2028
+ );
2029
+ })}
2030
+ </div>
2031
+
2032
+ {/* Next Round Button */}
2033
+ {revealedHands.can_start_next && (
2034
+ <button
2035
+ onClick={async () => {
2036
+ try {
2037
+ await apiclient.start_next_round();
2038
+ setShowScoreboardPanel(false);
2039
+ setRevealedHands(null);
2040
+ await refresh();
2041
+ toast.success("New round started!");
2042
+ } catch (error) {
2043
+ console.error("Error starting next round:", error);
2044
+ toast.error("Failed to start next round");
2045
+ }
2046
+ }}
2047
+ className="w-full mt-6 bg-green-600 hover:bg-green-700 text-white font-bold py-3 px-6 rounded-lg transition-colors"
2048
+ >
2049
+ Start Next Round
2050
+ </button>
2051
+ )}
2052
+ </div>
2053
+ </div>
2054
+ )}
2055
+
2056
+ {/* Chat Sidebar - Fixed position */}
2057
+ {user && info && tableId && (
2058
+ <ChatSidebar
2059
+ tableId={tableId}
2060
+ currentUserId={user.id}
2061
+ players={info.players.map(p => ({
2062
+ userId: p.user_id,
2063
+ displayName: p.display_name || p.user_id.slice(0, 6)
2064
+ }))}
2065
+ />
2066
+ )}
2067
+
2068
+ {/* Voice Panel - Fixed position */}
2069
+ {user && info && tableId && (
2070
+ <VoicePanel
2071
+ tableId={tableId}
2072
+ currentUserId={user.id}
2073
+ isHost={info.host_user_id === user.id}
2074
+ players={info.players}
2075
+ />
2076
+ )}
2077
+ </div>
2078
+ </div>
2079
+ </div>
2080
+ );
2081
+ }
TableDiagram.jsx ADDED
@@ -0,0 +1,90 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React from "react";
2
+ import type { PlayerInfo } from "../apiclient/data-contracts";
3
+ import { User2 } from "lucide-react";
4
+
5
+ export interface Props {
6
+ players: PlayerInfo[];
7
+ activeUserId?: string | null;
8
+ currentUserId?: string;
9
+ }
10
+
11
+ export const TableDiagram: React.FC<Props> = ({ players, activeUserId, currentUserId }) => {
12
+ // Position players around the table perimeter in a circular pattern
13
+ const getSeatPosition = (seat: number, totalSeats: number) => {
14
+ // Calculate angle for circular positioning
15
+ const angleStep = 360 / totalSeats;
16
+ const angle = angleStep * (seat - 1) - 90; // Start from top (12 o'clock)
17
+
18
+ // Convert polar to cartesian coordinates
19
+ const radius = 45; // % from center
20
+ const x = 50 + radius * Math.cos((angle * Math.PI) / 180);
21
+ const y = 50 + radius * Math.sin((angle * Math.PI) / 180);
22
+
23
+ return { x, y, angle };
24
+ };
25
+
26
+ return (
27
+ <div className="relative w-full h-full">
28
+ {/* Player positions around the table */}
29
+ {players.map((player) => {
30
+ const { x, y } = getSeatPosition(player.seat, players.length);
31
+ const isActive = player.user_id === activeUserId;
32
+ const isCurrent = player.user_id === currentUserId;
33
+
34
+ return (
35
+ <div
36
+ key={player.user_id}
37
+ className="absolute"
38
+ style={{
39
+ left: `${x}%`,
40
+ top: `${y}%`,
41
+ transform: 'translate(-50%, -50%)'
42
+ }}
43
+ >
44
+ <div className={`
45
+ flex flex-col items-center gap-2 p-3 rounded-xl transition-all backdrop-blur-sm
46
+ ${
47
+ isActive
48
+ ? "bg-amber-500/40 border-3 border-amber-400 ring-4 ring-amber-400/50 shadow-xl shadow-amber-400/50"
49
+ : "bg-green-900/60 border-2 border-green-700/80"
50
+ }
51
+ ${
52
+ isCurrent
53
+ ? "shadow-lg shadow-green-400/50"
54
+ : ""
55
+ }
56
+ `}>
57
+ <div className={`
58
+ w-14 h-14 rounded-full flex items-center justify-center border-2 overflow-hidden
59
+ ${
60
+ isActive
61
+ ? "bg-amber-500 border-amber-300"
62
+ : "bg-green-700 border-green-600"
63
+ }
64
+ `}>
65
+ {player.profile_image_url ? (
66
+ <img
67
+ src={player.profile_image_url}
68
+ alt={player.display_name || 'Player'}
69
+ className="w-full h-full object-cover"
70
+ />
71
+ ) : (
72
+ <User2 className="w-7 h-7 text-white" />
73
+ )}
74
+ </div>
75
+ <div className="text-center">
76
+ <div className="text-sm font-bold text-white truncate max-w-[80px] drop-shadow">
77
+ {isCurrent ? "You" : player.display_name?.slice(0, 10) || `Player ${player.seat}`}
78
+ </div>
79
+ <div className="text-xs text-green-200 font-medium">Seat {player.seat}</div>
80
+ </div>
81
+ {isActive && (
82
+ <div className="absolute -top-2 -right-2 w-4 h-4 bg-amber-400 rounded-full animate-pulse border-2 border-white" />
83
+ )}
84
+ </div>
85
+ </div>
86
+ );
87
+ })}
88
+ </div>
89
+ );
90
+ };
VoiceControls.jsx ADDED
@@ -0,0 +1,56 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React, { useState } from 'react';
2
+ import { Mic, MicOff, Video, VideoOff, PhoneOff } from 'lucide-react';
3
+ import { Button } from '@/components/ui/button';
4
+
5
+ interface Props {
6
+ tableId: string;
7
+ onToggleAudio: (enabled: boolean) => void;
8
+ onToggleVideo: (enabled: boolean) => void;
9
+ }
10
+
11
+ export const VoiceControls: React.FC<Props> = ({ tableId, onToggleAudio, onToggleVideo }) => {
12
+ const [audioEnabled, setAudioEnabled] = useState(false);
13
+ const [videoEnabled, setVideoEnabled] = useState(false);
14
+
15
+ const toggleAudio = () => {
16
+ const newState = !audioEnabled;
17
+ setAudioEnabled(newState);
18
+ onToggleAudio(newState);
19
+ };
20
+
21
+ const toggleVideo = () => {
22
+ const newState = !videoEnabled;
23
+ setVideoEnabled(newState);
24
+ onToggleVideo(newState);
25
+ };
26
+
27
+ return (
28
+ <div className="fixed bottom-4 left-4 flex gap-2 z-40">
29
+ {/* Audio Toggle */}
30
+ <Button
31
+ onClick={toggleAudio}
32
+ className={`rounded-full p-3 ${
33
+ audioEnabled
34
+ ? 'bg-green-600 hover:bg-green-700'
35
+ : 'bg-red-600 hover:bg-red-700'
36
+ }`}
37
+ title={audioEnabled ? 'Mute Microphone' : 'Unmute Microphone'}
38
+ >
39
+ {audioEnabled ? <Mic className="w-5 h-5" /> : <MicOff className="w-5 h-5" />}
40
+ </Button>
41
+
42
+ {/* Video Toggle */}
43
+ <Button
44
+ onClick={toggleVideo}
45
+ className={`rounded-full p-3 ${
46
+ videoEnabled
47
+ ? 'bg-green-600 hover:bg-green-700'
48
+ : 'bg-slate-600 hover:bg-slate-700'
49
+ }`}
50
+ title={videoEnabled ? 'Disable Camera' : 'Enable Camera'}
51
+ >
52
+ {videoEnabled ? <Video className="w-5 h-5" /> : <VideoOff className="w-5 h-5" />}
53
+ </Button>
54
+ </div>
55
+ );
56
+ };
VoicePanel.jsx ADDED
@@ -0,0 +1,276 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useState, useEffect } from 'react';
2
+ import { apiClient } from 'app';
3
+ import { Button } from '@/components/ui/button';
4
+ import { ScrollArea } from '@/components/ui/scroll-area';
5
+ import { Phone, PhoneOff, Mic, MicOff, Volume2, X, Users, ChevronRight } from 'lucide-react';
6
+ import { toast } from 'sonner';
7
+
8
+ interface Participant {
9
+ user_id: string;
10
+ display_name: string;
11
+ is_muted: boolean;
12
+ is_speaking: boolean;
13
+ }
14
+
15
+ interface Props {
16
+ tableId: string;
17
+ currentUserId: string;
18
+ isHost: boolean;
19
+ players: Array<{ user_id: string; display_name?: string | null }>;
20
+ }
21
+
22
+ export default function VoicePanel({ tableId, currentUserId, isHost, players }: Props) {
23
+ const [isOpen, setIsOpen] = useState(false);
24
+ const [inCall, setInCall] = useState(false);
25
+ const [participants, setParticipants] = useState<Participant[]>([]);
26
+ const [myMuted, setMyMuted] = useState(false);
27
+
28
+ // Poll for voice participants every 2 seconds when in call
29
+ useEffect(() => {
30
+ if (!inCall) return;
31
+
32
+ const fetchParticipants = async () => {
33
+ try {
34
+ const res = await apiClient.get_voice_participants({ table_id: tableId });
35
+ const data = await res.json();
36
+ setParticipants(data.participants || []);
37
+ } catch (error) {
38
+ console.error('Failed to fetch voice participants:', error);
39
+ }
40
+ };
41
+
42
+ fetchParticipants();
43
+ const interval = setInterval(fetchParticipants, 2000);
44
+ return () => clearInterval(interval);
45
+ }, [tableId, inCall]);
46
+
47
+ const toggleCall = () => {
48
+ setInCall(!inCall);
49
+ if (!inCall) {
50
+ setIsOpen(true);
51
+ toast.success('Joined voice call');
52
+ } else {
53
+ toast.success('Left voice call');
54
+ }
55
+ };
56
+
57
+ const toggleMyMute = async () => {
58
+ try {
59
+ await apiClient.mute_player({
60
+ table_id: tableId,
61
+ user_id: currentUserId,
62
+ muted: !myMuted
63
+ });
64
+ setMyMuted(!myMuted);
65
+ toast.success(myMuted ? 'Unmuted' : 'Muted');
66
+ } catch (error) {
67
+ toast.error('Failed to toggle mute');
68
+ }
69
+ };
70
+
71
+ const mutePlayer = async (userId: string, muted: boolean) => {
72
+ if (!isHost) return;
73
+ try {
74
+ await apiClient.mute_player({
75
+ table_id: tableId,
76
+ user_id: userId,
77
+ muted
78
+ });
79
+ toast.success(`Player ${muted ? 'muted' : 'unmuted'}`);
80
+ } catch (error) {
81
+ toast.error('Failed to mute player');
82
+ }
83
+ };
84
+
85
+ const muteAll = async () => {
86
+ if (!isHost) return;
87
+ try {
88
+ await apiClient.update_table_voice_settings({
89
+ table_id: tableId,
90
+ mute_all: true
91
+ });
92
+ toast.success('All players muted');
93
+ } catch (error) {
94
+ toast.error('Failed to mute all');
95
+ }
96
+ };
97
+
98
+ // Floating call button when panel is closed
99
+ if (!isOpen) {
100
+ return (
101
+ <button
102
+ onClick={() => setIsOpen(true)}
103
+ className={`fixed top-8 right-4 z-40 px-3 py-2 rounded-lg shadow-lg flex items-center gap-2 text-sm transition-all ${
104
+ inCall
105
+ ? 'bg-green-700 hover:bg-green-600 text-green-100 animate-pulse'
106
+ : 'bg-green-800 hover:bg-green-700 text-green-100'
107
+ }`}
108
+ >
109
+ <ChevronRight className="w-4 h-4" />
110
+ Call
111
+ {inCall && (
112
+ <span className="ml-1 w-2 h-2 bg-red-500 rounded-full animate-pulse" />
113
+ )}
114
+ </button>
115
+ );
116
+ }
117
+
118
+ return (
119
+ <div className="fixed bottom-4 right-4 z-30 w-80 bg-slate-900 border-2 border-slate-700 rounded-lg shadow-2xl">
120
+ {/* Header */}
121
+ <div className="flex items-center justify-between p-3 border-b border-slate-700 bg-slate-800">
122
+ <div className="flex items-center gap-2">
123
+ <Users className="w-5 h-5 text-green-400" />
124
+ <h3 className="font-semibold text-white">Voice Call</h3>
125
+ {inCall && (
126
+ <span className="px-2 py-0.5 text-xs bg-green-600 text-white rounded-full">
127
+ Live
128
+ </span>
129
+ )}
130
+ </div>
131
+ <button
132
+ onClick={() => setIsOpen(false)}
133
+ className="p-1 hover:bg-slate-700 rounded transition-colors"
134
+ >
135
+ <X className="w-5 h-5 text-slate-400" />
136
+ </button>
137
+ </div>
138
+
139
+ {/* Participants List */}
140
+ <ScrollArea className="h-64 p-3">
141
+ {!inCall && (
142
+ <div className="text-center py-8 text-slate-400">
143
+ <Phone className="w-12 h-12 mx-auto mb-3 opacity-50" />
144
+ <p className="text-sm">Join the voice call to see participants</p>
145
+ </div>
146
+ )}
147
+ {inCall && participants.length === 0 && (
148
+ <div className="text-center py-8 text-slate-400">
149
+ <p className="text-sm">Waiting for others to join...</p>
150
+ </div>
151
+ )}
152
+ {inCall && participants.length > 0 && (
153
+ <div className="space-y-2">
154
+ {participants.map((participant) => {
155
+ const isMe = participant.user_id === currentUserId;
156
+ return (
157
+ <div
158
+ key={participant.user_id}
159
+ className={`flex items-center justify-between p-3 rounded-lg transition-all ${
160
+ participant.is_speaking
161
+ ? 'bg-green-900/30 border-2 border-green-500 shadow-lg'
162
+ : 'bg-slate-800 border border-slate-700'
163
+ }`}
164
+ >
165
+ <div className="flex items-center gap-3">
166
+ {/* Avatar with speaking indicator */}
167
+ <div className="relative">
168
+ <div className={`w-10 h-10 rounded-full flex items-center justify-center ${
169
+ participant.is_speaking
170
+ ? 'bg-green-600 animate-pulse'
171
+ : 'bg-slate-700'
172
+ }`}>
173
+ <span className="text-white font-semibold text-sm">
174
+ {(participant.display_name || participant.user_id).slice(0, 2).toUpperCase()}
175
+ </span>
176
+ </div>
177
+ {participant.is_speaking && (
178
+ <Volume2 className="absolute -bottom-1 -right-1 w-4 h-4 text-green-400 animate-bounce" />
179
+ )}
180
+ </div>
181
+
182
+ {/* Name */}
183
+ <div>
184
+ <p className={`font-medium ${
185
+ isMe ? 'text-green-400' : 'text-white'
186
+ }`}>
187
+ {participant.display_name || participant.user_id.slice(0, 8)}
188
+ {isMe && ' (You)'}
189
+ </p>
190
+ {participant.is_speaking && (
191
+ <p className="text-xs text-green-400">Speaking...</p>
192
+ )}
193
+ </div>
194
+ </div>
195
+
196
+ {/* Mute controls */}
197
+ <div className="flex items-center gap-1">
198
+ {participant.is_muted ? (
199
+ <MicOff className="w-4 h-4 text-red-400" />
200
+ ) : (
201
+ <Mic className="w-4 h-4 text-green-400" />
202
+ )}
203
+ {isHost && !isMe && (
204
+ <Button
205
+ onClick={() => mutePlayer(participant.user_id, !participant.is_muted)}
206
+ variant="ghost"
207
+ size="sm"
208
+ className="h-6 px-2 text-xs"
209
+ >
210
+ {participant.is_muted ? 'Unmute' : 'Mute'}
211
+ </Button>
212
+ )}
213
+ </div>
214
+ </div>
215
+ );
216
+ })}
217
+ </div>
218
+ )}
219
+ </ScrollArea>
220
+
221
+ {/* Footer Controls */}
222
+ <div className="p-3 border-t border-slate-700 bg-slate-800 space-y-2">
223
+ {/* Host controls */}
224
+ {isHost && inCall && (
225
+ <Button
226
+ onClick={muteAll}
227
+ variant="outline"
228
+ size="sm"
229
+ className="w-full bg-slate-700 hover:bg-slate-600"
230
+ >
231
+ <MicOff className="w-4 h-4 mr-2" />
232
+ Mute All
233
+ </Button>
234
+ )}
235
+
236
+ {/* Main call controls */}
237
+ <div className="flex gap-2">
238
+ {inCall && (
239
+ <Button
240
+ onClick={toggleMyMute}
241
+ variant="outline"
242
+ className={`flex-1 ${
243
+ myMuted
244
+ ? 'bg-red-600 hover:bg-red-700 text-white'
245
+ : 'bg-green-600 hover:bg-green-700 text-white'
246
+ }`}
247
+ >
248
+ {myMuted ? <MicOff className="w-4 h-4 mr-2" /> : <Mic className="w-4 h-4 mr-2" />}
249
+ {myMuted ? 'Unmute' : 'Mute'}
250
+ </Button>
251
+ )}
252
+ <Button
253
+ onClick={toggleCall}
254
+ className={`flex-1 ${
255
+ inCall
256
+ ? 'bg-red-600 hover:bg-red-700'
257
+ : 'bg-green-600 hover:bg-green-700'
258
+ }`}
259
+ >
260
+ {inCall ? (
261
+ <>
262
+ <PhoneOff className="w-4 h-4 mr-2" />
263
+ Leave Call
264
+ </>
265
+ ) : (
266
+ <>
267
+ <Phone className="w-4 h-4 mr-2" />
268
+ Join Call
269
+ </>
270
+ )}
271
+ </Button>
272
+ </div>
273
+ </div>
274
+ </div>
275
+ );
276
+ }
WildJokerRevealModal.jsx ADDED
@@ -0,0 +1,128 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React, { useEffect, useState } from "react";
2
+ import { Dialog, DialogContent } from "@/components/ui/dialog";
3
+
4
+ interface Props {
5
+ isOpen: boolean;
6
+ onClose: () => void;
7
+ wildJokerRank: string;
8
+ }
9
+
10
+ export const WildJokerRevealModal: React.FC<Props> = ({ isOpen, onClose, wildJokerRank }) => {
11
+ const [isFlipping, setIsFlipping] = useState(false);
12
+
13
+ // Start flip animation shortly after modal opens
14
+ useEffect(() => {
15
+ if (isOpen) {
16
+ setIsFlipping(false);
17
+ const timer = setTimeout(() => setIsFlipping(true), 300);
18
+ return () => clearTimeout(timer);
19
+ }
20
+ }, [isOpen]);
21
+
22
+ const formatCardDisplay = (rank: string) => {
23
+ // Extract suit from rank if present (e.g., "7S" -> rank="7", suit="S")
24
+ const suitChar = rank.slice(-1);
25
+ const suits = ['S', 'H', 'D', 'C'];
26
+
27
+ if (suits.includes(suitChar)) {
28
+ const cardRank = rank.slice(0, -1);
29
+ const suitSymbol = suitChar === "S" ? "♠" : suitChar === "H" ? "♥" : suitChar === "D" ? "♦" : "♣";
30
+ const suitColor = suitChar === "H" || suitChar === "D" ? "text-red-600" : "text-gray-900";
31
+ return { rank: cardRank, suitSymbol, suitColor };
32
+ }
33
+
34
+ // Just rank without suit
35
+ return { rank, suitSymbol: "♠", suitColor: "text-gray-900" };
36
+ };
37
+
38
+ const { rank, suitSymbol, suitColor } = formatCardDisplay(wildJokerRank);
39
+
40
+ return (
41
+ <Dialog open={isOpen} onOpenChange={onClose}>
42
+ <DialogContent className="sm:max-w-md bg-gradient-to-b from-green-900 to-green-950 border-2 border-yellow-500">
43
+ <div className="flex flex-col items-center justify-center py-8 gap-6">
44
+ <h2 className="text-2xl font-bold text-yellow-400">Wild Joker Revealed!</h2>
45
+
46
+ {/* Card flip container */}
47
+ <div className="perspective-1000">
48
+ <div className={`flip-card ${isFlipping ? 'flipped' : ''}`}>
49
+ <div className="flip-card-inner">
50
+ {/* Card Back */}
51
+ <div className="flip-card-back">
52
+ <div className="w-32 h-48 bg-gradient-to-br from-red-900 to-red-950 border-2 border-red-700 rounded-lg flex items-center justify-center shadow-2xl">
53
+ <div className="text-4xl font-bold text-red-300">🃏</div>
54
+ </div>
55
+ </div>
56
+
57
+ {/* Card Front */}
58
+ <div className="flip-card-front">
59
+ <div className="w-32 h-48 bg-white rounded-lg border-2 border-gray-800 flex flex-col items-center justify-center gap-2 shadow-2xl">
60
+ <div className={`text-6xl font-bold ${suitColor}`}>
61
+ {rank}
62
+ </div>
63
+ <div className={`text-5xl ${suitColor}`}>
64
+ {suitSymbol}
65
+ </div>
66
+ </div>
67
+ </div>
68
+ </div>
69
+ </div>
70
+ </div>
71
+
72
+ <p className="text-center text-green-300 text-lg">
73
+ All <span className="font-bold text-yellow-400">{rank}</span> cards are now wild jokers!
74
+ </p>
75
+
76
+ <button
77
+ onClick={onClose}
78
+ className="px-6 py-2 bg-yellow-500 text-black font-semibold rounded-lg hover:bg-yellow-400 transition-colors"
79
+ >
80
+ Got it!
81
+ </button>
82
+ </div>
83
+ </DialogContent>
84
+
85
+ <style>{`
86
+ .perspective-1000 {
87
+ perspective: 1000px;
88
+ }
89
+
90
+ .flip-card {
91
+ width: 128px;
92
+ height: 192px;
93
+ position: relative;
94
+ transform-style: preserve-3d;
95
+ transition: transform 0.8s cubic-bezier(0.4, 0.0, 0.2, 1);
96
+ }
97
+
98
+ .flip-card.flipped {
99
+ transform: rotateY(180deg);
100
+ }
101
+
102
+ .flip-card-inner {
103
+ position: relative;
104
+ width: 100%;
105
+ height: 100%;
106
+ transform-style: preserve-3d;
107
+ }
108
+
109
+ .flip-card-front,
110
+ .flip-card-back {
111
+ position: absolute;
112
+ width: 100%;
113
+ height: 100%;
114
+ backface-visibility: hidden;
115
+ -webkit-backface-visibility: hidden;
116
+ }
117
+
118
+ .flip-card-front {
119
+ transform: rotateY(180deg);
120
+ }
121
+
122
+ .flip-card-back {
123
+ transform: rotateY(0deg);
124
+ }
125
+ `}</style>
126
+ </Dialog>
127
+ );
128
+ };
call chat rules button.png ADDED
cardCodeUtils.ts ADDED
@@ -0,0 +1,31 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Parse a card code like "7S" into a CardView object
3
+ *
4
+ * @param code - The card code string (e.g., "7S", "KH", "JOKER")
5
+ * @returns Object with rank, suit, joker flag, and original code
6
+ */
7
+ export const parseCardCode = (code: string): { rank: string; suit: string | null; joker: boolean; code: string } => {
8
+ if (!code) return { rank: '', suit: null, joker: false, code: '' };
9
+
10
+ // Try to parse as JSON first (in case it's already an object)
11
+ try {
12
+ const parsed = JSON.parse(code);
13
+ if (parsed.rank) return parsed;
14
+ } catch {}
15
+
16
+ // Handle joker cards
17
+ if (code === 'JOKER') {
18
+ return { rank: 'JOKER', suit: null, joker: true, code };
19
+ }
20
+
21
+ // Parse standard card codes (e.g., "7S" -> rank="7", suit="S")
22
+ const suit = code.slice(-1);
23
+ const rank = code.slice(0, -1) || code;
24
+
25
+ return {
26
+ rank,
27
+ suit: suit && ['S', 'H', 'D', 'C'].includes(suit) ? suit : null,
28
+ joker: false,
29
+ code
30
+ };
31
+ };
cardHelpers.ts ADDED
@@ -0,0 +1,17 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Format a card code (e.g., "7S") into display components
3
+ */
4
+ export const formatCardDisplay = (cardCode: string) => {
5
+ const suitChar = cardCode.slice(-1);
6
+ const suits = ['S', 'H', 'D', 'C'];
7
+
8
+ if (suits.includes(suitChar)) {
9
+ const rank = cardCode.slice(0, -1);
10
+ const suitSymbol = suitChar === "S" ? "♠" : suitChar === "H" ? "♥" : suitChar === "D" ? "♦" : "♣";
11
+ const suitColor = suitChar === "H" || suitChar === "D" ? "text-red-600" : "text-gray-900";
12
+ return { rank, suitSymbol, suitColor };
13
+ }
14
+
15
+ // Just rank without suit
16
+ return { rank: cardCode, suitSymbol: "♠", suitColor: "text-gray-900" };
17
+ };
chat.js ADDED
@@ -0,0 +1,186 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ from fastapi import APIRouter, HTTPException
3
+ from pydantic import BaseModel
4
+ import asyncpg
5
+ from app.auth import AuthorizedUser
6
+
7
+ router = APIRouter()
8
+
9
+ # Database connection helper
10
+ async def get_db_connection():
11
+ """Create and return a database connection."""
12
+ return await asyncpg.connect(os.environ.get("DATABASE_URL"))
13
+
14
+ # Pydantic models
15
+ class SendMessageRequest(BaseModel):
16
+ """Request to send a chat message."""
17
+ table_id: str
18
+ message: str
19
+ is_private: bool = False
20
+ recipient_id: str | None = None
21
+
22
+ class ChatMessage(BaseModel):
23
+ """Chat message response."""
24
+ id: int
25
+ table_id: str
26
+ user_id: str
27
+ sender_name: str
28
+ message: str
29
+ is_private: bool
30
+ recipient_id: str | None
31
+ created_at: str
32
+
33
+ class GetMessagesParams(BaseModel):
34
+ """Parameters for retrieving chat messages."""
35
+ table_id: str
36
+ limit: int = 100
37
+ before_id: int | None = None # For pagination
38
+
39
+ class GetMessagesResponse(BaseModel):
40
+ """Response containing chat messages."""
41
+ messages: list[ChatMessage]
42
+ has_more: bool
43
+
44
+ @router.post("/chat/send")
45
+ async def send_message(body: SendMessageRequest, user: AuthorizedUser) -> ChatMessage:
46
+ """
47
+ Send a chat message (public or private).
48
+
49
+ - Public messages are visible to all players at the table
50
+ - Private messages are only visible to sender and recipient
51
+ """
52
+ conn = await get_db_connection()
53
+ try:
54
+ # Verify user is part of the table
55
+ player = await conn.fetchrow(
56
+ """
57
+ SELECT display_name FROM rummy_table_players
58
+ WHERE table_id = $1 AND user_id = $2
59
+ """,
60
+ body.table_id,
61
+ user.sub
62
+ )
63
+
64
+ if not player:
65
+ raise HTTPException(status_code=403, detail="You are not part of this table")
66
+
67
+ # If private message, verify recipient exists at table
68
+ if body.is_private and body.recipient_id:
69
+ recipient = await conn.fetchrow(
70
+ """
71
+ SELECT user_id FROM rummy_table_players
72
+ WHERE table_id = $1 AND user_id = $2
73
+ """,
74
+ body.table_id,
75
+ body.recipient_id
76
+ )
77
+
78
+ if not recipient:
79
+ raise HTTPException(status_code=400, detail="Recipient is not part of this table")
80
+
81
+ # Insert message
82
+ row = await conn.fetchrow(
83
+ """
84
+ INSERT INTO chat_messages (table_id, user_id, message, is_private, recipient_id)
85
+ VALUES ($1, $2, $3, $4, $5)
86
+ RETURNING id, table_id, user_id, message, is_private, recipient_id, created_at
87
+ """,
88
+ body.table_id,
89
+ user.sub,
90
+ body.message,
91
+ body.is_private,
92
+ body.recipient_id
93
+ )
94
+
95
+ return ChatMessage(
96
+ id=row["id"],
97
+ table_id=row["table_id"],
98
+ user_id=row["user_id"],
99
+ sender_name=player["display_name"] or "Anonymous",
100
+ message=row["message"],
101
+ is_private=row["is_private"],
102
+ recipient_id=row["recipient_id"],
103
+ created_at=row["created_at"].isoformat()
104
+ )
105
+ finally:
106
+ await conn.close()
107
+
108
+ @router.get("/chat/messages")
109
+ async def get_messages(table_id: str, limit: int = 100, before_id: int | None = None, user: AuthorizedUser = None) -> GetMessagesResponse:
110
+ """
111
+ Retrieve chat messages for a table.
112
+
113
+ Returns public messages and private messages where user is sender or recipient.
114
+ Supports pagination with before_id parameter.
115
+ """
116
+ conn = await get_db_connection()
117
+ try:
118
+ # Verify user is part of the table
119
+ player = await conn.fetchrow(
120
+ """
121
+ SELECT user_id FROM rummy_table_players
122
+ WHERE table_id = $1 AND user_id = $2
123
+ """,
124
+ table_id,
125
+ user.sub
126
+ )
127
+
128
+ if not player:
129
+ raise HTTPException(status_code=403, detail="You are not part of this table")
130
+
131
+ # Build query for messages
132
+ # Get public messages OR private messages where user is sender or recipient
133
+ query = """
134
+ SELECT
135
+ cm.id, cm.table_id, cm.user_id, cm.message,
136
+ cm.is_private, cm.recipient_id, cm.created_at,
137
+ rtp.display_name as sender_name
138
+ FROM chat_messages cm
139
+ JOIN rummy_table_players rtp ON cm.table_id = rtp.table_id AND cm.user_id = rtp.user_id
140
+ WHERE cm.table_id = $1
141
+ AND (
142
+ cm.is_private = FALSE
143
+ OR cm.user_id = $2
144
+ OR cm.recipient_id = $2
145
+ )
146
+ """
147
+
148
+ params = [table_id, user.sub]
149
+
150
+ # Add pagination
151
+ if before_id:
152
+ query += " AND cm.id < $3"
153
+ params.append(before_id)
154
+
155
+ query += " ORDER BY cm.created_at DESC, cm.id DESC LIMIT $" + str(len(params) + 1)
156
+ params.append(limit + 1) # Fetch one extra to check if there are more
157
+
158
+ rows = await conn.fetch(query, *params)
159
+
160
+ # Check if there are more messages
161
+ has_more = len(rows) > limit
162
+ messages_data = rows[:limit] if has_more else rows
163
+
164
+ messages = [
165
+ ChatMessage(
166
+ id=row["id"],
167
+ table_id=row["table_id"],
168
+ user_id=row["user_id"],
169
+ sender_name=row["sender_name"] or "Anonymous",
170
+ message=row["message"],
171
+ is_private=row["is_private"],
172
+ recipient_id=row["recipient_id"],
173
+ created_at=row["created_at"].isoformat()
174
+ )
175
+ for row in messages_data
176
+ ]
177
+
178
+ # Reverse to get chronological order (oldest first)
179
+ messages.reverse()
180
+
181
+ return GetMessagesResponse(
182
+ messages=messages,
183
+ has_more=has_more
184
+ )
185
+ finally:
186
+ await conn.close()
cn.ts ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ import { clsx, type ClassValue } from "clsx";
2
+ import { twMerge } from "tailwind-merge";
3
+
4
+ export function cn(...inputs: ClassValue[]) {
5
+ return twMerge(clsx(inputs));
6
+ }
db.js ADDED
@@ -0,0 +1,48 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Simple asyncpg connection helper for the app
2
+ # Uses DATABASE_URL from environment. Provides a shared connection pool.
3
+
4
+ import os
5
+ import asyncpg
6
+ from typing import Optional
7
+
8
+ _pool: Optional[asyncpg.Pool] = None
9
+
10
+
11
+ async def get_pool() -> asyncpg.Pool:
12
+ """Get or create a global asyncpg pool.
13
+
14
+ The pool is cached in module state to avoid recreating it for each request.
15
+ """
16
+ global _pool
17
+ if _pool is None:
18
+ dsn = os.environ.get("DATABASE_URL")
19
+ if not dsn:
20
+ # In Riff, DATABASE_URL is provided as a secret in both dev and prod
21
+ raise RuntimeError("DATABASE_URL is not configured")
22
+ # Min pool size 1 to keep footprint small; adjust later if needed
23
+ _pool = await asyncpg.create_pool(dsn=dsn, min_size=1, max_size=10)
24
+ return _pool
25
+
26
+
27
+ async def fetchrow(query: str, *args):
28
+ pool = await get_pool()
29
+ async with pool.acquire() as conn:
30
+ return await conn.fetchrow(query, *args)
31
+
32
+
33
+ async def fetch(query: str, *args):
34
+ pool = await get_pool()
35
+ async with pool.acquire() as conn:
36
+ return await conn.fetch(query, *args)
37
+
38
+
39
+ async def execute(query: str, *args) -> str:
40
+ pool = await get_pool()
41
+ async with pool.acquire() as conn:
42
+ return await conn.execute(query, *args)
43
+
44
+
45
+ async def executemany(query: str, args_list):
46
+ pool = await get_pool()
47
+ async with pool.acquire() as conn:
48
+ return await conn.executemany(query, args_list)
declare discard buttons.png ADDED
default-theme.ts ADDED
@@ -0,0 +1 @@
 
 
1
+ export const DEFAULT_THEME = "dark";
full gaame table page.png ADDED

Git LFS Details

  • SHA256: 9f3c204d01b1324d4955ba2feaaf746dea52ddd26cd65a9eaa9a5ec2ca684d3e
  • Pointer size: 131 Bytes
  • Size of remote file: 176 kB
game.js ADDED
@@ -0,0 +1,1601 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+
2
+ # Game API - Optimized for <0.40s response times
3
+ # Last reload: 2025-11-10 19:35 IST
4
+
5
+ from fastapi import APIRouter, HTTPException
6
+ from pydantic import BaseModel
7
+ from typing import List, Optional
8
+ from app.auth import AuthorizedUser
9
+ from app.libs.db import fetchrow, fetch, execute
10
+ import uuid
11
+ import json
12
+ import random
13
+ import string
14
+ from app.libs.scoring import (
15
+ is_sequence,
16
+ is_pure_sequence,
17
+ is_set,
18
+ calculate_deadwood_points,
19
+ auto_organize_hand,
20
+ )
21
+ from app.libs.rummy_models import DeckConfig, deal_initial, StartRoundResponse
22
+ import time
23
+
24
+ router = APIRouter()
25
+
26
+
27
+ class CreateTableRequest(BaseModel):
28
+ max_players: int = 4
29
+ disqualify_score: int = 200
30
+ wild_joker_mode: str = "open_joker" # "no_joker", "close_joker", or "open_joker"
31
+ ace_value: int = 10 # 1 or 10
32
+
33
+
34
+ class CreateTableResponse(BaseModel):
35
+ table_id: str
36
+ code: str
37
+
38
+
39
+ @router.post("/tables")
40
+ async def create_table(body: CreateTableRequest, user: AuthorizedUser) -> CreateTableResponse:
41
+ table_id = str(uuid.uuid4())
42
+ # Generate short 6-character alphanumeric code
43
+ code = ''.join(random.choices(string.ascii_uppercase + string.digits, k=6))
44
+
45
+ # Single optimized query: create table, fetch profile, and add host as player
46
+ result = await fetchrow(
47
+ """
48
+ WITH new_table AS (
49
+ INSERT INTO public.rummy_tables (id, code, host_user_id, max_players, disqualify_score, wild_joker_mode, ace_value)
50
+ VALUES ($1, $2, $3, $4, $5, $6, $7)
51
+ RETURNING id, code
52
+ ),
53
+ profile_data AS (
54
+ SELECT
55
+ COALESCE(display_name, 'Player-' || SUBSTRING($3, LENGTH($3) - 5)) AS display_name,
56
+ avatar_url
57
+ FROM public.profiles
58
+ WHERE user_id = $3
59
+ UNION ALL
60
+ SELECT
61
+ 'Player-' || SUBSTRING($3, LENGTH($3) - 5) AS display_name,
62
+ NULL AS avatar_url
63
+ WHERE NOT EXISTS (SELECT 1 FROM public.profiles WHERE user_id = $3)
64
+ LIMIT 1
65
+ ),
66
+ new_player AS (
67
+ INSERT INTO public.rummy_table_players (table_id, user_id, seat, display_name, profile_image_url)
68
+ SELECT $1, $3, 1, profile_data.display_name, profile_data.avatar_url
69
+ FROM profile_data
70
+ RETURNING seat
71
+ )
72
+ SELECT new_table.id, new_table.code
73
+ FROM new_table
74
+ """,
75
+ table_id,
76
+ code,
77
+ user.sub,
78
+ body.max_players,
79
+ body.disqualify_score,
80
+ body.wild_joker_mode,
81
+ body.ace_value,
82
+ )
83
+
84
+ return CreateTableResponse(table_id=result["id"], code=result["code"])
85
+
86
+
87
+ class JoinTableRequest(BaseModel):
88
+ table_id: str
89
+
90
+
91
+ class JoinTableResponse(BaseModel):
92
+ table_id: str
93
+ seat: int
94
+
95
+
96
+ @router.post("/tables/join")
97
+ async def join_table(body: JoinTableRequest, user: AuthorizedUser) -> JoinTableResponse:
98
+ # Verify table exists and not full
99
+ tbl = await fetchrow(
100
+ "SELECT id, max_players, status FROM public.rummy_tables WHERE id = $1",
101
+ body.table_id,
102
+ )
103
+ if not tbl:
104
+ raise HTTPException(status_code=404, detail="Table not found")
105
+ if tbl["status"] != "waiting":
106
+ raise HTTPException(status_code=400, detail="Cannot join: round already started")
107
+
108
+ existing = await fetch(
109
+ "SELECT seat FROM public.rummy_table_players WHERE table_id = $1 ORDER BY seat",
110
+ body.table_id,
111
+ )
112
+ used_seats = {r["seat"] for r in existing}
113
+ next_seat = 1
114
+ while next_seat in used_seats:
115
+ next_seat += 1
116
+ if next_seat > tbl["max_players"]:
117
+ raise HTTPException(status_code=400, detail="Table is full")
118
+
119
+ # Fetch player display name from profiles table
120
+ profile = await fetchrow(
121
+ "SELECT display_name FROM public.profiles WHERE user_id = $1",
122
+ user.sub
123
+ )
124
+ display_name = profile["display_name"] if profile else f"Player-{user.sub[-6:]}"
125
+
126
+ await execute(
127
+ """
128
+ INSERT INTO public.rummy_table_players (table_id, user_id, seat, display_name)
129
+ VALUES ($1, $2, $3, $4)
130
+ ON CONFLICT (table_id, user_id) DO NOTHING
131
+ """,
132
+ body.table_id,
133
+ user.sub,
134
+ next_seat,
135
+ display_name,
136
+ )
137
+ return JoinTableResponse(table_id=body.table_id, seat=next_seat)
138
+
139
+
140
+ class JoinByCodeRequest(BaseModel):
141
+ code: str
142
+
143
+
144
+ @router.post("/tables/join-by-code")
145
+ async def join_table_by_code(body: JoinByCodeRequest, user: AuthorizedUser) -> JoinTableResponse:
146
+ # Verify table exists and get info
147
+ tbl = await fetchrow(
148
+ "SELECT id, max_players, status FROM public.rummy_tables WHERE code = $1",
149
+ body.code.upper()
150
+ )
151
+ if not tbl:
152
+ raise HTTPException(status_code=404, detail="Table code not found")
153
+ if tbl["status"] != "waiting":
154
+ raise HTTPException(status_code=400, detail="Cannot join: round already started")
155
+
156
+ # Get existing seats
157
+ existing = await fetch(
158
+ "SELECT seat FROM public.rummy_table_players WHERE table_id = $1 ORDER BY seat",
159
+ tbl["id"]
160
+ )
161
+ used_seats = {r["seat"] for r in existing}
162
+
163
+ # Find next available seat
164
+ next_seat = 1
165
+ while next_seat in used_seats:
166
+ next_seat += 1
167
+ if next_seat > tbl["max_players"]:
168
+ raise HTTPException(status_code=400, detail="Table is full")
169
+
170
+ # Fetch player display name from profiles table
171
+ profile = await fetchrow(
172
+ "SELECT display_name FROM public.profiles WHERE user_id = $1",
173
+ user.sub
174
+ )
175
+ display_name = profile["display_name"] if profile else f"Player-{user.sub[-6:]}"
176
+
177
+ # Add player
178
+ await execute(
179
+ """INSERT INTO public.rummy_table_players (table_id, user_id, seat, display_name)
180
+ VALUES ($1, $2, $3, $4)
181
+ ON CONFLICT (table_id, user_id) DO NOTHING""",
182
+ tbl["id"], user.sub, next_seat, display_name
183
+ )
184
+
185
+ return JoinTableResponse(table_id=tbl["id"], seat=next_seat)
186
+
187
+
188
+ class StartGameRequest(BaseModel):
189
+ table_id: str
190
+ seed: Optional[int] = None
191
+
192
+
193
+ @router.post("/start-game")
194
+ async def start_game(body: StartGameRequest, user: AuthorizedUser) -> StartRoundResponse:
195
+ # Confirm user in table and fetch host + status + game settings
196
+ tbl = await fetchrow(
197
+ """
198
+ SELECT t.id, t.status, t.host_user_id, t.wild_joker_mode, t.ace_value
199
+ FROM public.rummy_tables t
200
+ WHERE t.id = $1
201
+ """,
202
+ body.table_id,
203
+ )
204
+ if not tbl:
205
+ raise HTTPException(status_code=404, detail="Table not found")
206
+
207
+ member = await fetchrow(
208
+ "SELECT 1 FROM public.rummy_table_players WHERE table_id = $1 AND user_id = $2",
209
+ body.table_id,
210
+ user.sub,
211
+ )
212
+ if not member:
213
+ raise HTTPException(status_code=403, detail="Not part of the table")
214
+
215
+ if tbl["status"] != "waiting":
216
+ raise HTTPException(status_code=400, detail="Game already started")
217
+
218
+ if tbl["host_user_id"] != user.sub:
219
+ raise HTTPException(status_code=403, detail="Only host can start the game")
220
+
221
+ players = await fetch(
222
+ """
223
+ SELECT user_id
224
+ FROM public.rummy_table_players
225
+ WHERE table_id = $1 AND is_spectator = false
226
+ ORDER BY seat ASC
227
+ """,
228
+ body.table_id,
229
+ )
230
+ user_ids = [r["user_id"] for r in players]
231
+ if len(user_ids) < 2:
232
+ raise HTTPException(status_code=400, detail="Need at least 2 players to start")
233
+
234
+ cfg = DeckConfig(decks=2, include_printed_jokers=True)
235
+ deal = deal_initial(user_ids, cfg, body.seed)
236
+
237
+ round_id = str(uuid.uuid4())
238
+ number = 1
239
+
240
+ # Game mode logic:
241
+ # - no_joker: no wild joker at all
242
+ # - close_joker: wild joker exists but hidden initially
243
+ # - open_joker: wild joker revealed immediately
244
+ game_mode = tbl["wild_joker_mode"]
245
+ wild_joker_rank = None
246
+
247
+ if game_mode in ["close_joker", "open_joker"]:
248
+ # Randomly select wild joker rank (excluding JOKER itself)
249
+ all_ranks = ['2', '3', '4', '5', '6', '7', '8', '9', '10', 'J', 'Q', 'K', 'A']
250
+ wild_joker_rank = random.choice(all_ranks)
251
+
252
+ hands_serialized = {uid: [c.model_dump() for c in cards] for uid, cards in deal.hands.items()}
253
+ stock_serialized = [c.model_dump() for c in deal.stock]
254
+ discard_serialized = [c.model_dump() for c in deal.discard]
255
+
256
+ await execute(
257
+ """
258
+ INSERT INTO public.rummy_rounds (id, table_id, number, printed_joker, wild_joker_rank, stock, discard, hands, active_user_id, game_mode, ace_value)
259
+ VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
260
+ """,
261
+ round_id,
262
+ body.table_id,
263
+ number,
264
+ None,
265
+ wild_joker_rank,
266
+ json.dumps(stock_serialized),
267
+ json.dumps(discard_serialized),
268
+ json.dumps(hands_serialized),
269
+ user_ids[0],
270
+ game_mode,
271
+ tbl["ace_value"],
272
+ )
273
+
274
+ await execute(
275
+ "UPDATE public.rummy_tables SET status = 'playing', updated_at = now() WHERE id = $1",
276
+ body.table_id,
277
+ )
278
+
279
+ discard_top = None
280
+ if len(discard_serialized) > 0:
281
+ top = discard_serialized[-1]
282
+ if top.get("joker") and top.get("rank") == "JOKER":
283
+ discard_top = "JOKER"
284
+ else:
285
+ discard_top = f"{top.get('rank')}{top.get('suit') or ''}"
286
+
287
+ return StartRoundResponse(
288
+ round_id=round_id,
289
+ table_id=body.table_id,
290
+ number=number,
291
+ active_user_id=user_ids[0],
292
+ stock_count=len(stock_serialized),
293
+ discard_top=discard_top,
294
+ )
295
+
296
+
297
+ # -------- Table info (for lobby/table screen polling) --------
298
+ class PlayerInfo(BaseModel):
299
+ user_id: str
300
+ seat: int
301
+ display_name: Optional[str] = None
302
+ profile_image_url: Optional[str] = None
303
+
304
+
305
+ class TableInfoResponse(BaseModel):
306
+ table_id: str
307
+ code: str
308
+ status: str
309
+ host_user_id: str
310
+ max_players: int
311
+ disqualify_score: int
312
+ game_mode: str
313
+ ace_value: int
314
+ players: List[PlayerInfo]
315
+ current_round_number: Optional[int] = None
316
+ active_user_id: Optional[str] = None
317
+
318
+
319
+ @router.get("/tables/info")
320
+ async def get_table_info(table_id: str, user: AuthorizedUser) -> TableInfoResponse:
321
+ """Return basic table state with players and current round info.
322
+ Only accessible to the host or seated players.
323
+ """
324
+ # Single CTE query combining all data fetches
325
+ result = await fetchrow(
326
+ """
327
+ WITH table_data AS (
328
+ SELECT id, code, status, host_user_id, max_players, disqualify_score, wild_joker_mode, ace_value
329
+ FROM public.rummy_tables
330
+ WHERE id = $1
331
+ ),
332
+ membership_check AS (
333
+ SELECT EXISTS(
334
+ SELECT 1 FROM public.rummy_table_players
335
+ WHERE table_id = $1 AND user_id = $2
336
+ ) AS is_member
337
+ ),
338
+ players_data AS (
339
+ SELECT user_id, seat, display_name, profile_image_url
340
+ FROM public.rummy_table_players
341
+ WHERE table_id = $1 AND is_spectator = false
342
+ ORDER BY seat ASC
343
+ ),
344
+ last_round_data AS (
345
+ SELECT number, active_user_id
346
+ FROM public.rummy_rounds
347
+ WHERE table_id = $1
348
+ ORDER BY number DESC
349
+ LIMIT 1
350
+ )
351
+ SELECT
352
+ t.*,
353
+ m.is_member,
354
+ COALESCE(
355
+ json_agg(
356
+ json_build_object(
357
+ 'user_id', p.user_id,
358
+ 'seat', p.seat,
359
+ 'display_name', p.display_name,
360
+ 'profile_image_url', p.profile_image_url
361
+ ) ORDER BY p.seat
362
+ ) FILTER (WHERE p.user_id IS NOT NULL),
363
+ '[]'
364
+ ) AS players_json,
365
+ r.number AS round_number,
366
+ r.active_user_id
367
+ FROM table_data t
368
+ CROSS JOIN membership_check m
369
+ LEFT JOIN players_data p ON true
370
+ LEFT JOIN last_round_data r ON true
371
+ GROUP BY t.id, t.code, t.status, t.host_user_id, t.max_players, t.disqualify_score,
372
+ t.wild_joker_mode, t.ace_value, m.is_member, r.number, r.active_user_id
373
+ """,
374
+ table_id,
375
+ user.sub,
376
+ )
377
+
378
+ if not result or not result["id"]:
379
+ raise HTTPException(status_code=404, detail="Table not found")
380
+
381
+ # Check access (host or member)
382
+ if result["host_user_id"] != user.sub and not result["is_member"]:
383
+ raise HTTPException(status_code=403, detail="You don't have access to this table")
384
+
385
+ # Parse players JSON and build response
386
+ import json
387
+ players_data = json.loads(result["players_json"])
388
+
389
+ players = [
390
+ PlayerInfo(
391
+ user_id=p["user_id"],
392
+ seat=p["seat"],
393
+ display_name=p["display_name"],
394
+ profile_image_url=p.get("profile_image_url")
395
+ )
396
+ for p in players_data
397
+ ]
398
+
399
+ return TableInfoResponse(
400
+ table_id=result["id"],
401
+ code=result["code"],
402
+ status=result["status"],
403
+ host_user_id=result["host_user_id"],
404
+ max_players=result["max_players"],
405
+ disqualify_score=result["disqualify_score"],
406
+ game_mode=result["wild_joker_mode"],
407
+ ace_value=result["ace_value"],
408
+ players=players,
409
+ current_round_number=result["round_number"],
410
+ active_user_id=result["active_user_id"],
411
+ )
412
+
413
+
414
+ # -------- Round: My hand --------
415
+ class CardView(BaseModel):
416
+ rank: str
417
+ suit: Optional[str] = None
418
+ joker: bool = False
419
+ code: str
420
+
421
+
422
+ class RoundMeResponse(BaseModel):
423
+ table_id: str
424
+ round_number: int
425
+ hand: List[CardView]
426
+ stock_count: int
427
+ discard_top: Optional[str] = None
428
+ wild_joker_revealed: bool = False
429
+ wild_joker_rank: Optional[str] = None
430
+ finished_at: Optional[str] = None
431
+
432
+
433
+ @router.get("/round/me")
434
+ async def get_round_me(table_id: str, user: AuthorizedUser) -> RoundMeResponse:
435
+ """Get current round info for the authenticated user - OPTIMIZED"""
436
+ start = time.time()
437
+
438
+ # Verify membership
439
+ member = await fetchrow(
440
+ "SELECT 1 FROM rummy_table_players WHERE table_id = $1 AND user_id = $2",
441
+ table_id, user.sub
442
+ )
443
+ if not member:
444
+ raise HTTPException(status_code=403, detail="Not part of this table")
445
+
446
+ # Get table info
447
+ table = await fetchrow(
448
+ "SELECT wild_joker_mode, ace_value FROM rummy_tables WHERE id = $1",
449
+ table_id
450
+ )
451
+
452
+ # Get latest round
453
+ rnd = await fetchrow(
454
+ """SELECT id, number, printed_joker, wild_joker_rank, stock, discard, hands, active_user_id
455
+ FROM rummy_rounds
456
+ WHERE table_id = $1
457
+ ORDER BY number DESC
458
+ LIMIT 1""",
459
+ table_id
460
+ )
461
+
462
+ if not rnd:
463
+ elapsed = time.time() - start
464
+ return RoundMeResponse(
465
+ table_id=table_id,
466
+ round_number=0,
467
+ hand=[],
468
+ stock_count=0,
469
+ discard_top=None,
470
+ wild_joker_revealed=False,
471
+ wild_joker_rank=None,
472
+ finished_at=None
473
+ )
474
+
475
+ hands = json.loads(rnd["hands"]) if rnd["hands"] else {}
476
+ my_hand_data = hands.get(user.sub, [])
477
+
478
+ stock = json.loads(rnd["stock"]) if rnd["stock"] else []
479
+ discard = json.loads(rnd["discard"]) if rnd["discard"] else []
480
+ discard_top_str = None
481
+ if discard:
482
+ last = discard[-1]
483
+ if last.get("joker") and last.get("rank") == "JOKER":
484
+ discard_top_str = "JOKER"
485
+ else:
486
+ discard_top_str = f"{last.get('rank')}{last.get('suit') or ''}"
487
+
488
+ # Convert to CardView
489
+ def to_code(card: dict) -> str:
490
+ if card.get("joker") and card.get("rank") == "JOKER":
491
+ return "JOKER"
492
+ return f"{card.get('rank')}{card.get('suit') or ''}"
493
+
494
+ hand_view = [
495
+ CardView(rank=c.get("rank"), suit=c.get("suit"), joker=bool(c.get("joker")), code=to_code(c))
496
+ for c in my_hand_data
497
+ ]
498
+
499
+ elapsed = time.time() - start
500
+ return RoundMeResponse(
501
+ table_id=table_id,
502
+ round_number=rnd["number"],
503
+ hand=hand_view,
504
+ stock_count=len(stock),
505
+ discard_top=discard_top_str,
506
+ wild_joker_revealed=False, # Will fix this logic later
507
+ wild_joker_rank=rnd["wild_joker_rank"],
508
+ finished_at=None
509
+ )
510
+
511
+
512
+ # -------- Lock Sequence for Wild Joker Reveal --------
513
+ class CardData(BaseModel):
514
+ rank: str
515
+ suit: Optional[str] = None
516
+
517
+ class LockSequenceRequest(BaseModel):
518
+ table_id: str
519
+ meld: List[CardData] # Array of cards forming the sequence
520
+
521
+
522
+ class LockSequenceResponse(BaseModel):
523
+ success: bool
524
+ message: str
525
+ wild_joker_revealed: bool
526
+ wild_joker_rank: Optional[str] = None
527
+
528
+
529
+ @router.post("/lock-sequence")
530
+ async def lock_sequence(body: LockSequenceRequest, user: AuthorizedUser) -> LockSequenceResponse:
531
+ """Validate a sequence and reveal wild joker if it's the player's first pure sequence."""
532
+ try:
533
+ user_id = user.sub
534
+ table_id = body.table_id
535
+ # Convert Pydantic CardData objects to dicts for validation functions
536
+ meld = [card.model_dump() for card in body.meld]
537
+
538
+ # Get current round - USE number DESC for consistency with other endpoints
539
+ round_row = await fetchrow(
540
+ """
541
+ SELECT id, table_id, wild_joker_rank, players_with_first_sequence
542
+ FROM rummy_rounds
543
+ WHERE table_id = $1
544
+ ORDER BY number DESC
545
+ LIMIT 1
546
+ """,
547
+ table_id
548
+ )
549
+
550
+ if not round_row:
551
+ raise HTTPException(status_code=404, detail="No active round")
552
+
553
+ wild_joker_rank = round_row['wild_joker_rank']
554
+ # Parse players_with_first_sequence as JSON list
555
+ players_with_seq_raw = round_row['players_with_first_sequence']
556
+ if players_with_seq_raw is None:
557
+ players_with_seq = []
558
+ elif isinstance(players_with_seq_raw, str):
559
+ players_with_seq = json.loads(players_with_seq_raw)
560
+ elif isinstance(players_with_seq_raw, list):
561
+ players_with_seq = players_with_seq_raw
562
+ else:
563
+ players_with_seq = []
564
+
565
+ # Check if user already revealed wild joker
566
+ if user_id in players_with_seq:
567
+ return LockSequenceResponse(
568
+ success=False,
569
+ message="✅ You already revealed the wild joker!",
570
+ wild_joker_revealed=False,
571
+ wild_joker_rank=None
572
+ )
573
+
574
+ # Check if THIS player has already revealed their wild joker
575
+ has_wild_joker_revealed = user_id in players_with_seq
576
+
577
+ # First check if it's a valid sequence
578
+ if not is_sequence(meld, wild_joker_rank, has_wild_joker_revealed):
579
+ return LockSequenceResponse(
580
+ success=False,
581
+ message="❌ Invalid sequence - cards must be consecutive in same suit",
582
+ wild_joker_revealed=False,
583
+ wild_joker_rank=None
584
+ )
585
+
586
+ # Then check if it's a PURE sequence (no jokers)
587
+ if not is_pure_sequence(meld, wild_joker_rank, has_wild_joker_revealed):
588
+ return LockSequenceResponse(
589
+ success=False,
590
+ message="❌ Only pure sequences can reveal wild joker (no jokers allowed)",
591
+ wild_joker_revealed=False,
592
+ wild_joker_rank=None
593
+ )
594
+
595
+ # Add user to players_with_first_sequence
596
+ new_players = list(set(players_with_seq + [user_id]))
597
+ await execute(
598
+ "UPDATE rummy_rounds SET players_with_first_sequence = $1 WHERE id = $2",
599
+ json.dumps(new_players), round_row['id']
600
+ )
601
+
602
+ return LockSequenceResponse(
603
+ success=True,
604
+ message="✅ Pure sequence locked! Wild Joker revealed!",
605
+ wild_joker_revealed=True,
606
+ wild_joker_rank=wild_joker_rank
607
+ )
608
+ except Exception as e:
609
+ raise HTTPException(status_code=500, detail=f"Internal error: {str(e)}")
610
+
611
+
612
+ # -------- Core turn actions: draw stock/discard and discard a card --------
613
+ class DrawRequest(BaseModel):
614
+ table_id: str
615
+
616
+
617
+ class DiscardCard(BaseModel):
618
+ rank: str
619
+ suit: Optional[str] = None
620
+ joker: Optional[bool] = None
621
+
622
+
623
+ class DiscardRequest(BaseModel):
624
+ table_id: str
625
+ card: DiscardCard
626
+
627
+
628
+ class DiscardResponse(BaseModel):
629
+ table_id: str
630
+ round_number: int
631
+ hand: List[CardView]
632
+ stock_count: int
633
+ discard_top: Optional[str]
634
+ next_active_user_id: str
635
+
636
+
637
+ async def _get_latest_round(table_id: str):
638
+ return await fetchrow(
639
+ """
640
+ SELECT id, number, stock, discard, hands, active_user_id, finished_at, wild_joker_rank, ace_value, players_with_first_sequence
641
+ FROM public.rummy_rounds
642
+ WHERE table_id = $1
643
+ ORDER BY number DESC
644
+ LIMIT 1
645
+ """,
646
+ table_id,
647
+ )
648
+
649
+
650
+ async def _assert_member(table_id: str, user_id: str):
651
+ membership = await fetchrow(
652
+ "SELECT 1 FROM public.rummy_table_players WHERE table_id = $1 AND user_id = $2",
653
+ table_id,
654
+ user_id,
655
+ )
656
+ if not membership:
657
+ raise HTTPException(status_code=403, detail="Not part of this table")
658
+
659
+
660
+ def _serialize_card_code(card: dict) -> str:
661
+ if card.get("joker") and card.get("rank") == "JOKER":
662
+ return "JOKER"
663
+ return f"{card.get('rank')}{card.get('suit') or ''}"
664
+
665
+
666
+ def _hand_view(cards: List[dict]) -> List[CardView]:
667
+ return [
668
+ CardView(
669
+ rank=c.get("rank"),
670
+ suit=c.get("suit"),
671
+ joker=bool(c.get("joker")),
672
+ code=_serialize_card_code(c),
673
+ )
674
+ for c in cards
675
+ ]
676
+
677
+
678
+ @router.post("/draw/stock")
679
+ async def draw_stock(body: DrawRequest, user: AuthorizedUser) -> RoundMeResponse:
680
+ start_time = time.time()
681
+ # Single query: validate + fetch + update in one transaction
682
+ result = await fetchrow(
683
+ """
684
+ WITH table_check AS (
685
+ SELECT t.id, t.status,
686
+ EXISTS(SELECT 1 FROM public.rummy_table_players WHERE table_id = $1 AND user_id = $2) AS is_member
687
+ FROM public.rummy_tables t
688
+ WHERE t.id = $1
689
+ ),
690
+ round_data AS (
691
+ SELECT id, number, stock, hands, discard, active_user_id, finished_at
692
+ FROM public.rummy_rounds
693
+ WHERE table_id = $1
694
+ ORDER BY number DESC
695
+ LIMIT 1
696
+ )
697
+ 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
698
+ FROM table_check t
699
+ LEFT JOIN round_data r ON true
700
+ """,
701
+ body.table_id,
702
+ user.sub,
703
+ )
704
+
705
+ if not result or not result["id"]:
706
+ raise HTTPException(status_code=404, detail="Table not found")
707
+ if result["status"] != "playing":
708
+ raise HTTPException(status_code=400, detail="Game not in playing state")
709
+ if not result["is_member"]:
710
+ raise HTTPException(status_code=403, detail="Not part of the table")
711
+ if not result["round_id"]:
712
+ raise HTTPException(status_code=404, detail="No active round")
713
+ if result["active_user_id"] != user.sub:
714
+ raise HTTPException(status_code=403, detail="Not your turn")
715
+
716
+ # Parse JSON fields
717
+ hands = json.loads(result["hands"]) if isinstance(result["hands"], str) else result["hands"]
718
+ stock = json.loads(result["stock"]) if isinstance(result["stock"], str) else result["stock"]
719
+ discard = json.loads(result["discard"]) if isinstance(result["discard"], str) else result["discard"]
720
+
721
+ my = hands.get(user.sub)
722
+ if my is None:
723
+ raise HTTPException(status_code=404, detail="No hand for this player")
724
+ if len(my) != 13:
725
+ raise HTTPException(status_code=400, detail="You must discard before drawing again")
726
+ if not stock:
727
+ raise HTTPException(status_code=400, detail="Stock is empty")
728
+
729
+ drawn = stock.pop() # take top
730
+ my.append(drawn)
731
+
732
+ await execute(
733
+ """
734
+ UPDATE public.rummy_rounds
735
+ SET stock = $1::jsonb, hands = $2::jsonb, updated_at = now()
736
+ WHERE id = $3
737
+ """,
738
+ json.dumps(stock),
739
+ json.dumps(hands),
740
+ result["round_id"],
741
+ )
742
+
743
+ return RoundMeResponse(
744
+ table_id=body.table_id,
745
+ round_number=result["number"],
746
+ hand=_hand_view(my),
747
+ stock_count=len(stock),
748
+ discard_top=_serialize_card_code(discard[-1]) if discard else None,
749
+ finished_at=result["finished_at"].isoformat() if result["finished_at"] else None,
750
+ )
751
+
752
+
753
+ @router.post("/draw/discard")
754
+ async def draw_discard(body: DrawRequest, user: AuthorizedUser) -> RoundMeResponse:
755
+ start_time = time.time()
756
+ # Single query: validate + fetch + update in one transaction
757
+ result = await fetchrow(
758
+ """
759
+ WITH table_check AS (
760
+ SELECT t.id, t.status,
761
+ EXISTS(SELECT 1 FROM public.rummy_table_players WHERE table_id = $1 AND user_id = $2) AS is_member
762
+ FROM public.rummy_tables t
763
+ WHERE t.id = $1
764
+ ),
765
+ round_data AS (
766
+ SELECT id, number, stock, hands, discard, active_user_id, finished_at
767
+ FROM public.rummy_rounds
768
+ WHERE table_id = $1
769
+ ORDER BY number DESC
770
+ LIMIT 1
771
+ )
772
+ 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
773
+ FROM table_check t
774
+ LEFT JOIN round_data r ON true
775
+ """,
776
+ body.table_id,
777
+ user.sub,
778
+ )
779
+
780
+ if not result or not result["id"]:
781
+ raise HTTPException(status_code=404, detail="Table not found")
782
+ if result["status"] != "playing":
783
+ raise HTTPException(status_code=400, detail="Game not in playing state")
784
+ if not result["is_member"]:
785
+ raise HTTPException(status_code=403, detail="Not part of the table")
786
+ if not result["round_id"]:
787
+ raise HTTPException(status_code=404, detail="No active round")
788
+ if result["active_user_id"] != user.sub:
789
+ raise HTTPException(status_code=403, detail="Not your turn")
790
+
791
+ # Parse JSON fields
792
+ hands = json.loads(result["hands"]) if isinstance(result["hands"], str) else result["hands"]
793
+ stock = json.loads(result["stock"]) if isinstance(result["stock"], str) else result["stock"]
794
+ discard = json.loads(result["discard"]) if isinstance(result["discard"], str) else result["discard"]
795
+
796
+ my = hands.get(user.sub)
797
+ if my is None:
798
+ raise HTTPException(status_code=404, detail="No hand for this player")
799
+ if len(my) != 13:
800
+ raise HTTPException(status_code=400, detail="You must discard before drawing again")
801
+ if not discard:
802
+ raise HTTPException(status_code=400, detail="Discard pile is empty")
803
+
804
+ drawn = discard.pop()
805
+ my.append(drawn)
806
+
807
+ await execute(
808
+ """
809
+ UPDATE public.rummy_rounds
810
+ SET discard = $1::jsonb, hands = $2::jsonb, updated_at = now()
811
+ WHERE id = $3
812
+ """,
813
+ json.dumps(discard),
814
+ json.dumps(hands),
815
+ result["round_id"],
816
+ )
817
+
818
+ return RoundMeResponse(
819
+ table_id=body.table_id,
820
+ round_number=result["number"],
821
+ hand=_hand_view(my),
822
+ stock_count=len(stock),
823
+ discard_top=_serialize_card_code(discard[-1]) if discard else None,
824
+ finished_at=result["finished_at"].isoformat() if result["finished_at"] else None,
825
+ )
826
+
827
+
828
+ @router.post("/discard")
829
+ async def discard_card(body: DiscardRequest, user: AuthorizedUser) -> DiscardResponse:
830
+ start_time = time.time()
831
+ # Single query: validate + fetch seats + round data
832
+ result = await fetchrow(
833
+ """
834
+ WITH table_check AS (
835
+ SELECT t.id, t.status,
836
+ EXISTS(SELECT 1 FROM public.rummy_table_players WHERE table_id = $1 AND user_id = $2) AS is_member
837
+ FROM public.rummy_tables t
838
+ WHERE t.id = $1
839
+ ),
840
+ round_data AS (
841
+ SELECT id, number, stock, hands, discard, active_user_id
842
+ FROM public.rummy_rounds
843
+ WHERE table_id = $1
844
+ ORDER BY number DESC
845
+ LIMIT 1
846
+ ),
847
+ seat_order AS (
848
+ SELECT user_id, seat
849
+ FROM public.rummy_table_players
850
+ WHERE table_id = $1 AND is_spectator = false
851
+ ORDER BY seat ASC
852
+ )
853
+ SELECT
854
+ t.id, t.status, t.is_member,
855
+ r.id AS round_id, r.number, r.stock, r.hands, r.discard, r.active_user_id,
856
+ json_agg(s.user_id ORDER BY s.seat) AS user_order
857
+ FROM table_check t
858
+ LEFT JOIN round_data r ON true
859
+ LEFT JOIN seat_order s ON true
860
+ GROUP BY t.id, t.status, t.is_member, r.id, r.number, r.stock, r.hands, r.discard, r.active_user_id
861
+ """,
862
+ body.table_id,
863
+ user.sub,
864
+ )
865
+
866
+ if not result or not result["id"]:
867
+ raise HTTPException(status_code=404, detail="Table not found")
868
+ if result["status"] != "playing":
869
+ raise HTTPException(status_code=400, detail="Game not in playing state")
870
+ if not result["is_member"]:
871
+ raise HTTPException(status_code=403, detail="Not part of the table")
872
+ if not result["round_id"]:
873
+ raise HTTPException(status_code=404, detail="No active round")
874
+ if result["active_user_id"] != user.sub:
875
+ raise HTTPException(status_code=403, detail="Not your turn")
876
+
877
+ # Parse JSON fields
878
+ hands = json.loads(result["hands"]) if isinstance(result["hands"], str) else result["hands"]
879
+ stock = json.loads(result["stock"]) if isinstance(result["stock"], str) else result["stock"]
880
+ discard = json.loads(result["discard"]) if isinstance(result["discard"], str) else result["discard"]
881
+ order = json.loads(result["user_order"]) if isinstance(result["user_order"], str) else result["user_order"]
882
+
883
+ my = hands.get(user.sub)
884
+ if my is None:
885
+ raise HTTPException(status_code=404, detail="No hand for this player")
886
+ if len(my) != 14:
887
+ raise HTTPException(status_code=400, detail="You must draw first before discarding")
888
+
889
+ # Remove first matching card
890
+ idx_to_remove = None
891
+ for i, c in enumerate(my):
892
+ if (
893
+ c.get("rank") == body.card.rank
894
+ and (c.get("suit") or None) == (body.card.suit or None)
895
+ and bool(c.get("joker")) == bool(body.card.joker)
896
+ ):
897
+ idx_to_remove = i
898
+ break
899
+ if idx_to_remove is None:
900
+ raise HTTPException(status_code=400, detail="Card not found in hand")
901
+
902
+ removed = my.pop(idx_to_remove)
903
+ discard.append(removed)
904
+
905
+ # Find next active user
906
+ if user.sub not in order:
907
+ raise HTTPException(status_code=400, detail="Player has no seat")
908
+ cur_idx = order.index(user.sub)
909
+ next_user = order[(cur_idx + 1) % len(order)]
910
+
911
+ await execute(
912
+ """
913
+ UPDATE public.rummy_rounds
914
+ SET discard = $1::jsonb, hands = $2::jsonb, active_user_id = $3, updated_at = now()
915
+ WHERE id = $4
916
+ """,
917
+ json.dumps(discard),
918
+ json.dumps(hands),
919
+ next_user,
920
+ result["round_id"],
921
+ )
922
+
923
+ return DiscardResponse(
924
+ table_id=body.table_id,
925
+ round_number=result["number"],
926
+ hand=_hand_view(my),
927
+ stock_count=len(stock),
928
+ discard_top=_serialize_card_code(discard[-1]) if discard else None,
929
+ next_active_user_id=next_user,
930
+ )
931
+
932
+
933
+ # -------- Declaration and scoring --------
934
+ class DeclareRequest(BaseModel):
935
+ table_id: str
936
+ # For now, accept a simple client-declared payload; server will validate later
937
+ # We'll store player's grouped melds as-is and compute naive score 0 if valid later
938
+ groups: Optional[List[List[DiscardCard]]] = None
939
+
940
+
941
+ class DeclareResponse(BaseModel):
942
+ table_id: str
943
+ round_number: int
944
+ declared_by: str
945
+ status: str
946
+
947
+
948
+ class ScoreEntry(BaseModel):
949
+ user_id: str
950
+ points: int
951
+
952
+
953
+ class ScoreboardResponse(BaseModel):
954
+ table_id: str
955
+ round_number: int
956
+ scores: List[ScoreEntry]
957
+ winner_user_id: Optional[str] = None
958
+
959
+
960
+ @router.post("/declare")
961
+ async def declare(body: DeclareRequest, user: AuthorizedUser) -> DeclareResponse:
962
+ try:
963
+ # Declare endpoint - validates meld groups (13 cards) not full hand (can be 14 after draw)
964
+ # Only the active player can declare for now
965
+ tbl = await fetchrow(
966
+ "SELECT id, status FROM public.rummy_tables WHERE id = $1",
967
+ body.table_id,
968
+ )
969
+ if not tbl:
970
+ raise HTTPException(status_code=404, detail="Table not found")
971
+ if tbl["status"] != "playing":
972
+ raise HTTPException(status_code=400, detail="Game not in playing state")
973
+ await _assert_member(body.table_id, user.sub)
974
+
975
+ rnd = await _get_latest_round(body.table_id)
976
+ if not rnd:
977
+ raise HTTPException(status_code=404, detail="No active round")
978
+ if rnd["active_user_id"] != user.sub:
979
+ raise HTTPException(status_code=403, detail="Only active player may declare")
980
+
981
+ # Parse JSON fields from database
982
+ hands = json.loads(rnd["hands"]) if isinstance(rnd["hands"], str) else rnd["hands"]
983
+
984
+ # Get wild joker rank and ace value for validation and scoring
985
+ wild_joker_rank = rnd["wild_joker_rank"]
986
+ ace_value = rnd.get("ace_value", 10) # Default to 10 if not set
987
+
988
+ # Check if player has revealed wild joker
989
+ players_with_first_sequence = rnd.get("players_with_first_sequence") or []
990
+ if isinstance(players_with_first_sequence, str):
991
+ try:
992
+ players_with_first_sequence = json.loads(players_with_first_sequence)
993
+ except:
994
+ players_with_first_sequence = []
995
+ has_wild_joker_revealed = user.sub in players_with_first_sequence
996
+
997
+ # Get declarer's hand
998
+ declarer_hand = hands.get(user.sub)
999
+ if not declarer_hand:
1000
+ raise HTTPException(status_code=404, detail="No hand found for player")
1001
+
1002
+ # Check that player has exactly 14 cards (must have drawn before declaring)
1003
+ if len(declarer_hand) != 14:
1004
+ raise HTTPException(
1005
+ status_code=400,
1006
+ detail=f"Must have exactly 14 cards to declare. You have {len(declarer_hand)} cards. Please draw a card first."
1007
+ )
1008
+
1009
+ # Validate hand if groups are provided
1010
+ is_valid = False
1011
+ validation_reason = ""
1012
+ if body.groups:
1013
+ # Check that groups contain exactly 13 cards total
1014
+ total_cards_in_groups = sum(len(group) for group in body.groups)
1015
+ if total_cards_in_groups != 13:
1016
+ raise HTTPException(
1017
+ status_code=400,
1018
+ detail=f"Groups must contain exactly 13 cards. You provided {total_cards_in_groups} cards."
1019
+ )
1020
+
1021
+ # Extract the 14th card (leftover) from hand that's not in groups
1022
+ # Build count map of declared cards
1023
+ declared_counts = {}
1024
+ for group in body.groups:
1025
+ for card in group:
1026
+ card_dict = card.model_dump() if hasattr(card, 'model_dump') else card
1027
+ key = f"{card_dict['rank']}-{card_dict.get('suit', 'null')}"
1028
+ declared_counts[key] = declared_counts.get(key, 0) + 1
1029
+
1030
+ # Find the 14th card (not in declared melds)
1031
+ auto_discard_card = None
1032
+ temp_counts = declared_counts.copy()
1033
+ for card in declarer_hand:
1034
+ key = f"{card['rank']}-{card.get('suit', 'null')}"
1035
+ if key not in temp_counts or temp_counts[key] == 0:
1036
+ auto_discard_card = card
1037
+ break
1038
+ else:
1039
+ temp_counts[key] -= 1
1040
+
1041
+ if not auto_discard_card:
1042
+ raise HTTPException(status_code=500, detail="Could not identify 14th card")
1043
+
1044
+ # Remove card from declarer's hand
1045
+ updated_hand = [c for c in declarer_hand if c != auto_discard_card]
1046
+ hands[user.sub] = updated_hand
1047
+
1048
+ # Add to discard pile
1049
+ discard_pile = json.loads(rnd["discard"]) if isinstance(rnd["discard"], str) else (rnd["discard"] or [])
1050
+ discard_pile.append(auto_discard_card)
1051
+
1052
+ # Update game state with auto-discard
1053
+ await execute(
1054
+ "UPDATE public.rummy_rounds SET hands = $1::jsonb, discard = $2::jsonb WHERE id = $3",
1055
+ json.dumps(hands),
1056
+ json.dumps(discard_pile),
1057
+ rnd["id"]
1058
+ )
1059
+
1060
+ # Valid declaration: declarer gets 0 points, others get deadwood points
1061
+ scores: dict = {}
1062
+ organized_melds_all_players = {}
1063
+ for uid, cards in hands.items():
1064
+ if uid == user.sub:
1065
+ scores[uid] = 0
1066
+ # Store winner's declared melds - categorize them properly
1067
+ winner_pure_seqs = []
1068
+ winner_seqs = []
1069
+ winner_sets = []
1070
+ for group in body.groups:
1071
+ # Convert DiscardCard to dict for JSON serialization
1072
+ group_dicts = [card.model_dump() if hasattr(card, 'model_dump') else card for card in group]
1073
+ if is_pure_sequence(group_dicts, wild_joker_rank, has_wild_joker_revealed):
1074
+ winner_pure_seqs.append(group_dicts)
1075
+ elif is_sequence(group_dicts, wild_joker_rank, has_wild_joker_revealed):
1076
+ winner_seqs.append(group_dicts)
1077
+ elif is_set(group_dicts, wild_joker_rank, has_wild_joker_revealed):
1078
+ winner_sets.append(group_dicts)
1079
+
1080
+ organized_melds_all_players[uid] = {
1081
+ "pure_sequences": winner_pure_seqs,
1082
+ "sequences": winner_seqs,
1083
+ "sets": winner_sets,
1084
+ "deadwood": []
1085
+ }
1086
+ else:
1087
+ # Auto-organize opponent's hand to find best possible melds
1088
+ opponent_has_revealed = uid in players_with_first_sequence
1089
+ opponent_melds, opponent_leftover = auto_organize_hand(
1090
+ cards, wild_joker_rank, opponent_has_revealed
1091
+ )
1092
+ # Score only the ungrouped deadwood cards
1093
+ scores[uid] = calculate_deadwood_points(
1094
+ opponent_leftover, wild_joker_rank, opponent_has_revealed, ace_value
1095
+ )
1096
+ # Convert opponent melds to plain dicts and categorize them
1097
+ opponent_melds_dicts = [
1098
+ [card.dict() if hasattr(card, 'dict') else card for card in meld]
1099
+ for meld in opponent_melds
1100
+ ]
1101
+ opponent_leftover_dicts = [
1102
+ card.dict() if hasattr(card, 'dict') else card for card in opponent_leftover
1103
+ ]
1104
+
1105
+ # Categorize opponent melds
1106
+ opp_pure_seqs = []
1107
+ opp_seqs = []
1108
+ opp_sets = []
1109
+ for meld in opponent_melds_dicts:
1110
+ if is_pure_sequence(meld, wild_joker_rank, opponent_has_revealed):
1111
+ opp_pure_seqs.append(meld)
1112
+ elif is_sequence(meld, wild_joker_rank, opponent_has_revealed):
1113
+ opp_seqs.append(meld)
1114
+ elif is_set(meld, wild_joker_rank, opponent_has_revealed):
1115
+ opp_sets.append(meld)
1116
+
1117
+ # Store opponent's auto-organized melds
1118
+ organized_melds_all_players[uid] = {
1119
+ "pure_sequences": opp_pure_seqs,
1120
+ "sequences": opp_seqs,
1121
+ "sets": opp_sets,
1122
+ "deadwood": opponent_leftover_dicts
1123
+ }
1124
+ else:
1125
+ # Invalid declaration: declarer gets FULL hand deadwood points (80 cap), others get 0
1126
+ has_revealed = user.sub in players_with_first_sequence
1127
+ declarer_deadwood_pts = calculate_deadwood_points(declarer_hand, wild_joker_rank, has_revealed, ace_value)
1128
+ for uid, cards in hands.items():
1129
+ if uid == user.sub:
1130
+ scores[uid] = min(declarer_deadwood_pts, 80) # Cap at 80
1131
+ # Store declarer's ungrouped cards as all deadwood
1132
+ declarer_cards_dicts = [
1133
+ card.dict() if hasattr(card, 'dict') else card for card in declarer_hand
1134
+ ]
1135
+ organized_melds_all_players[uid] = {
1136
+ "pure_sequences": [],
1137
+ "sequences": [],
1138
+ "sets": [],
1139
+ "deadwood": declarer_cards_dicts
1140
+ }
1141
+ else:
1142
+ scores[uid] = 0
1143
+ # Opponents don't lose points when someone else's declaration fails
1144
+ organized_melds_all_players[uid] = {
1145
+ "pure_sequences": [],
1146
+ "sequences": [],
1147
+ "sets": [],
1148
+ "deadwood": []
1149
+ }
1150
+
1151
+ # Store the declaration with validation status
1152
+ declaration_data = {
1153
+ "groups": [[card.model_dump() if hasattr(card, 'model_dump') else card for card in group] for group in body.groups] if body.groups else [],
1154
+ "valid": is_valid,
1155
+ "reason": validation_reason,
1156
+ "revealed_hands": hands, # Already plain dicts from JSON parse
1157
+ "organized_melds": organized_melds_all_players
1158
+ }
1159
+
1160
+ await execute(
1161
+ """
1162
+ UPDATE public.rummy_rounds
1163
+ SET winner_user_id = $1, scores = $2::jsonb, declarations = jsonb_set(COALESCE(declarations, '{}'::jsonb), $3, $4::jsonb, true), finished_at = now(), updated_at = now()
1164
+ WHERE id = $5
1165
+ """,
1166
+ user.sub if is_valid else None, # Only set winner if valid
1167
+ json.dumps(scores), # Convert dict to JSON string for JSONB
1168
+ [user.sub],
1169
+ json.dumps(declaration_data), # Convert dict to JSON string for JSONB
1170
+ rnd["id"],
1171
+ )
1172
+
1173
+ # Return success response (valid or invalid declaration both complete the round)
1174
+ return DeclareResponse(
1175
+ table_id=body.table_id,
1176
+ round_number=rnd["number"],
1177
+ declared_by=user.sub,
1178
+ status="valid" if is_valid else "invalid"
1179
+ )
1180
+ except HTTPException:
1181
+ raise # Re-raise HTTP exceptions as-is
1182
+ except Exception as e:
1183
+ raise HTTPException(status_code=500, detail=f"Internal error: {str(e)}")
1184
+
1185
+
1186
+ class RevealedHandsResponse(BaseModel):
1187
+ table_id: str
1188
+ round_number: int
1189
+ winner_user_id: Optional[str] = None
1190
+ revealed_hands: dict[str, List[dict]] # user_id -> list of cards
1191
+ organized_melds: dict[str, dict] # user_id -> {pure_sequences: [...], impure_sequences: [...], sets: [...], ungrouped: [...]}
1192
+ scores: dict[str, int] # user_id -> points
1193
+ player_names: dict[str, str] # user_id -> display_name
1194
+ is_finished: bool
1195
+
1196
+
1197
+ @router.get("/round/revealed-hands")
1198
+ async def get_revealed_hands(table_id: str, user: AuthorizedUser) -> RevealedHandsResponse:
1199
+ """Get all players' revealed hands and organized melds after declaration."""
1200
+ try:
1201
+ # Fetch the current round
1202
+ rnd = await fetchrow(
1203
+ """
1204
+ SELECT id, number, finished_at, declarations, hands, scores, winner_user_id
1205
+ FROM public.rummy_rounds
1206
+ WHERE table_id=$1
1207
+ ORDER BY number DESC
1208
+ LIMIT 1
1209
+ """,
1210
+ table_id
1211
+ )
1212
+
1213
+ if not rnd:
1214
+ raise HTTPException(status_code=404, detail="No round found")
1215
+
1216
+ if not rnd["finished_at"]:
1217
+ raise HTTPException(status_code=400, detail="Round not finished")
1218
+
1219
+ # Get player information for names
1220
+ players_rows = await fetch(
1221
+ """
1222
+ SELECT user_id, display_name
1223
+ FROM public.rummy_table_players
1224
+ WHERE table_id=$1
1225
+ """,
1226
+ table_id
1227
+ )
1228
+ player_names = {p["user_id"]: p["display_name"] or "Player" for p in players_rows}
1229
+
1230
+ # Extract data from the round
1231
+ revealed_hands = rnd.get("hands", {})
1232
+ scores = rnd.get("scores", {})
1233
+ declarations = rnd.get("declarations", {})
1234
+
1235
+ # Extract organized_melds from declarations
1236
+ organized_melds = {}
1237
+ for uid, decl_data in declarations.items():
1238
+ if isinstance(decl_data, dict) and "organized_melds" in decl_data:
1239
+ organized_melds[uid] = decl_data["organized_melds"]
1240
+ else:
1241
+ organized_melds[uid] = {
1242
+ "pure_sequences": [],
1243
+ "sequences": [],
1244
+ "sets": [],
1245
+ "deadwood": []
1246
+ }
1247
+
1248
+ try:
1249
+ response = RevealedHandsResponse(
1250
+ table_id=table_id,
1251
+ round_number=rnd["number"],
1252
+ winner_user_id=rnd["winner_user_id"],
1253
+ revealed_hands=revealed_hands,
1254
+ organized_melds=organized_melds,
1255
+ scores=scores,
1256
+ player_names=player_names,
1257
+ is_finished=rnd["finished_at"] is not None
1258
+ )
1259
+ return response
1260
+ except Exception as e:
1261
+ raise HTTPException(status_code=500, detail=f"Failed to construct response: {str(e)}")
1262
+ except HTTPException:
1263
+ raise
1264
+ except Exception as e:
1265
+ raise HTTPException(status_code=500, detail=f"Endpoint failed: {str(e)}")
1266
+
1267
+
1268
+ @router.get("/round/scoreboard")
1269
+ async def round_scoreboard(table_id: str, user: AuthorizedUser) -> ScoreboardResponse:
1270
+ await _assert_member(table_id, user.sub)
1271
+ rnd = await fetchrow(
1272
+ """
1273
+ SELECT number, scores, winner_user_id, points_accumulated
1274
+ FROM public.rummy_rounds
1275
+ WHERE table_id = $1
1276
+ ORDER BY number DESC
1277
+ LIMIT 1
1278
+ """,
1279
+ table_id,
1280
+ )
1281
+ if not rnd:
1282
+ raise HTTPException(status_code=404, detail="No round found")
1283
+
1284
+ scores = rnd["scores"] or {}
1285
+
1286
+ # CRITICAL: Accumulate round scores to total_points ONLY ONCE
1287
+ # Check if points have already been accumulated for this round
1288
+ if not rnd.get("points_accumulated", False):
1289
+ for user_id, round_points in scores.items():
1290
+ await execute(
1291
+ """UPDATE public.rummy_table_players
1292
+ SET total_points = total_points + $1
1293
+ WHERE table_id = $2 AND user_id = $3""",
1294
+ int(round_points),
1295
+ table_id,
1296
+ user_id
1297
+ )
1298
+
1299
+ # Mark this round as accumulated
1300
+ await execute(
1301
+ """UPDATE public.rummy_rounds
1302
+ SET points_accumulated = TRUE
1303
+ WHERE table_id = $1 AND number = $2""",
1304
+ table_id,
1305
+ rnd["number"]
1306
+ )
1307
+
1308
+ entries = [ScoreEntry(user_id=uid, points=int(val)) for uid, val in scores.items()]
1309
+ return ScoreboardResponse(
1310
+ table_id=table_id,
1311
+ round_number=rnd["number"],
1312
+ scores=entries,
1313
+ winner_user_id=rnd["winner_user_id"],
1314
+ )
1315
+
1316
+
1317
+ class NextRoundRequest(BaseModel):
1318
+ table_id: str
1319
+
1320
+
1321
+ class NextRoundResponse(BaseModel):
1322
+ table_id: str
1323
+ number: int
1324
+ active_user_id: str
1325
+
1326
+
1327
+ @router.post("/round/next")
1328
+ async def start_next_round(body: NextRoundRequest, user: AuthorizedUser) -> NextRoundResponse:
1329
+ # Host only for next-round
1330
+ tbl = await fetchrow(
1331
+ "SELECT id, host_user_id, status, disqualify_score FROM public.rummy_tables WHERE id = $1",
1332
+ body.table_id,
1333
+ )
1334
+ if not tbl:
1335
+ raise HTTPException(status_code=404, detail="Table not found")
1336
+ await _assert_member(body.table_id, user.sub)
1337
+ if tbl["host_user_id"] != user.sub:
1338
+ raise HTTPException(status_code=403, detail="Only host can start next round")
1339
+
1340
+ # Check last round is finished
1341
+ last = await fetchrow(
1342
+ """
1343
+ SELECT id, number, finished_at
1344
+ FROM public.rummy_rounds
1345
+ WHERE table_id = $1
1346
+ ORDER BY number DESC
1347
+ LIMIT 1
1348
+ """,
1349
+ body.table_id,
1350
+ )
1351
+ if not last or not last["finished_at"]:
1352
+ raise HTTPException(status_code=400, detail="Last round not finished yet")
1353
+
1354
+ # Disqualify any players reaching threshold
1355
+ th = int(tbl["disqualify_score"])
1356
+ players = await fetch(
1357
+ "SELECT user_id, total_points FROM public.rummy_table_players WHERE table_id = $1 ORDER BY seat ASC",
1358
+ body.table_id,
1359
+ )
1360
+ active_user_ids = []
1361
+ for p in players:
1362
+ uid = p["user_id"]
1363
+ total = int(p["total_points"])
1364
+ if total >= th:
1365
+ await execute(
1366
+ "UPDATE public.rummy_table_players SET disqualified = true, eliminated_at = now() WHERE table_id = $1 AND user_id = $2",
1367
+ body.table_id,
1368
+ uid,
1369
+ )
1370
+ else:
1371
+ active_user_ids.append(uid)
1372
+
1373
+ if len(active_user_ids) < 2:
1374
+ # End table
1375
+ await execute("UPDATE public.rummy_tables SET status = 'finished', updated_at = now() WHERE id = $1", body.table_id)
1376
+ raise HTTPException(status_code=400, detail="Not enough players for next round; table finished")
1377
+
1378
+ # Create new round with fresh deal, rotate starting player (winner starts)
1379
+ cfg = DeckConfig(decks=2, include_printed_jokers=True)
1380
+ deal = deal_initial(active_user_ids, cfg, None)
1381
+
1382
+ new_round_id = str(uuid.uuid4())
1383
+ next_round_number = int(last["number"]) + 1
1384
+
1385
+ hands_serialized = {uid: [c.model_dump() for c in cards] for uid, cards in deal.hands.items()}
1386
+ stock_serialized = [c.model_dump() for c in deal.stock]
1387
+ discard_serialized = [c.model_dump() for c in deal.discard]
1388
+
1389
+ # Fetch table settings including game mode and ace value
1390
+ tbl = await fetchrow(
1391
+ "SELECT id, status, host_user_id, max_players, wild_joker_mode, ace_value FROM public.rummy_tables WHERE id = $1",
1392
+ body.table_id,
1393
+ )
1394
+
1395
+ wild_joker_mode = tbl["wild_joker_mode"]
1396
+ ace_value = tbl["ace_value"]
1397
+
1398
+ # Determine wild joker based on game mode
1399
+ if wild_joker_mode == "no_joker":
1400
+ wild_joker_rank = None # No wild joker in this mode
1401
+ else:
1402
+ # Pick a random wild joker rank (excluding printed joker)
1403
+ ranks = ['2', '3', '4', '5', '6', '7', '8', '9', '10', 'J', 'Q', 'K', 'A']
1404
+ wild_joker_rank = random.choice(ranks)
1405
+
1406
+ await execute(
1407
+ """
1408
+ INSERT INTO public.rummy_rounds (
1409
+ id, table_id, number, printed_joker, wild_joker_rank,
1410
+ stock, discard, hands, active_user_id, game_mode, ace_value
1411
+ )
1412
+ VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
1413
+ """,
1414
+ new_round_id,
1415
+ body.table_id,
1416
+ next_round_number,
1417
+ None,
1418
+ wild_joker_rank,
1419
+ json.dumps(stock_serialized),
1420
+ json.dumps(discard_serialized),
1421
+ json.dumps(hands_serialized),
1422
+ active_user_ids[0],
1423
+ wild_joker_mode,
1424
+ ace_value,
1425
+ )
1426
+
1427
+ await execute(
1428
+ "UPDATE public.rummy_tables SET status = 'playing', updated_at = now() WHERE id = $1",
1429
+ body.table_id,
1430
+ )
1431
+
1432
+ return NextRoundResponse(
1433
+ table_id=body.table_id,
1434
+ number=next_round_number,
1435
+ active_user_id=active_user_ids[0],
1436
+ )
1437
+
1438
+ @router.get("/round/history")
1439
+ async def get_round_history(table_id: str, user: AuthorizedUser):
1440
+ """Get all completed round history for the current table."""
1441
+ # Single CTE query combining all checks and data fetches with JOINs
1442
+ rows = await fetch(
1443
+ """
1444
+ WITH table_check AS (
1445
+ SELECT id FROM public.rummy_tables WHERE id = $1
1446
+ ),
1447
+ membership_check AS (
1448
+ SELECT EXISTS (
1449
+ SELECT 1 FROM public.rummy_table_players
1450
+ WHERE table_id = $1 AND user_id = $2
1451
+ ) AS is_member
1452
+ ),
1453
+ player_names AS (
1454
+ SELECT user_id, display_name
1455
+ FROM public.rummy_table_players
1456
+ WHERE table_id = $1
1457
+ )
1458
+ SELECT
1459
+ r.number AS round_number,
1460
+ r.winner_user_id,
1461
+ r.scores,
1462
+ COALESCE(
1463
+ json_object_agg(
1464
+ p.user_id,
1465
+ COALESCE(p.display_name, 'Player')
1466
+ ) FILTER (WHERE p.user_id IS NOT NULL),
1467
+ '{}'
1468
+ ) AS player_names_map,
1469
+ (SELECT is_member FROM membership_check) AS is_member,
1470
+ (SELECT id FROM table_check) AS table_exists
1471
+ FROM public.rummy_rounds r
1472
+ LEFT JOIN player_names p ON true
1473
+ WHERE r.table_id = $1 AND r.finished_at IS NOT NULL
1474
+ GROUP BY r.id, r.number, r.winner_user_id, r.scores
1475
+ ORDER BY r.number ASC
1476
+ """,
1477
+ table_id,
1478
+ user.sub
1479
+ )
1480
+
1481
+ if not rows or rows[0]["table_exists"] is None:
1482
+ raise HTTPException(status_code=404, detail="Table not found")
1483
+
1484
+ if not rows[0]["is_member"]:
1485
+ raise HTTPException(status_code=403, detail="You don't have access to this table")
1486
+
1487
+ # Build round history
1488
+ import json
1489
+ round_history = []
1490
+ for row in rows:
1491
+ player_names = json.loads(row["player_names_map"])
1492
+ scores_dict = row["scores"] or {}
1493
+ players_list = [
1494
+ {
1495
+ "user_id": user_id,
1496
+ "player_name": player_names.get(user_id, "Player"),
1497
+ "score": score
1498
+ }
1499
+ for user_id, score in scores_dict.items()
1500
+ ]
1501
+ # Sort by score ascending (winner has lowest score)
1502
+ players_list.sort(key=lambda p: p["score"])
1503
+
1504
+ round_history.append({
1505
+ "round_number": row["round_number"],
1506
+ "winner_user_id": row["winner_user_id"],
1507
+ "players": players_list
1508
+ })
1509
+
1510
+ return {"rounds": round_history}
1511
+
1512
+
1513
+ # ===== DROP ENDPOINT =====
1514
+
1515
+ class DropRequest(BaseModel):
1516
+ table_id: str
1517
+
1518
+ class DropResponse(BaseModel):
1519
+ success: bool
1520
+ penalty_points: int
1521
+
1522
+ @router.post("/game/drop")
1523
+ async def drop_game(body: DropRequest, user: AuthorizedUser) -> DropResponse:
1524
+ """Player drops before first draw (20pt penalty, 2+ players)."""
1525
+ result = await fetchrow(
1526
+ """WITH round_data AS (
1527
+ SELECT id, hands, active_user_id
1528
+ FROM public.rummy_rounds
1529
+ WHERE table_id = $1
1530
+ ORDER BY number DESC LIMIT 1
1531
+ ),
1532
+ player_count AS (
1533
+ SELECT COUNT(*) as cnt
1534
+ FROM public.rummy_table_players
1535
+ WHERE table_id = $1 AND is_spectator = false
1536
+ )
1537
+ SELECT r.id, r.hands, r.active_user_id, p.cnt as player_count
1538
+ FROM round_data r, player_count p""",
1539
+ body.table_id
1540
+ )
1541
+
1542
+ if not result:
1543
+ raise HTTPException(status_code=404, detail="No active round")
1544
+ if result["player_count"] < 2:
1545
+ raise HTTPException(status_code=400, detail="Need 2+ players to drop")
1546
+
1547
+ hands = json.loads(result["hands"]) if isinstance(result["hands"], str) else result["hands"]
1548
+ my_hand = hands.get(user.sub)
1549
+ if not my_hand or len(my_hand) != 13:
1550
+ raise HTTPException(status_code=400, detail="Can only drop before drawing first card")
1551
+
1552
+ await execute(
1553
+ """UPDATE public.rummy_table_players
1554
+ SET is_spectator = true, total_points = total_points + 20, eliminated_at = now()
1555
+ WHERE table_id = $1 AND user_id = $2""",
1556
+ body.table_id, user.sub
1557
+ )
1558
+
1559
+ return DropResponse(success=True, penalty_points=20)
1560
+
1561
+
1562
+ # ===== SPECTATE ENDPOINTS =====
1563
+
1564
+ class SpectateRequest(BaseModel):
1565
+ table_id: str
1566
+ player_id: str
1567
+
1568
+ class GrantSpectateRequest(BaseModel):
1569
+ table_id: str
1570
+ spectator_id: str
1571
+ granted: bool
1572
+
1573
+ @router.post("/game/request-spectate")
1574
+ async def request_spectate(body: SpectateRequest, user: AuthorizedUser):
1575
+ """Request permission to spectate a player."""
1576
+ spectator = await fetchrow(
1577
+ "SELECT is_spectator FROM public.rummy_table_players WHERE table_id = $1 AND user_id = $2",
1578
+ body.table_id, user.sub
1579
+ )
1580
+ if not spectator or not spectator["is_spectator"]:
1581
+ raise HTTPException(status_code=403, detail="Must be eliminated to spectate")
1582
+
1583
+ await execute(
1584
+ """INSERT INTO public.spectate_permissions (table_id, spectator_id, player_id, granted)
1585
+ VALUES ($1, $2, $3, false)
1586
+ ON CONFLICT DO NOTHING""",
1587
+ body.table_id, user.sub, body.player_id
1588
+ )
1589
+ return {"success": True}
1590
+
1591
+ @router.post("/game/grant-spectate")
1592
+ async def grant_spectate(body: GrantSpectateRequest, user: AuthorizedUser):
1593
+ """Player grants/denies spectate permission."""
1594
+ await execute(
1595
+ """UPDATE public.spectate_permissions
1596
+ SET granted = $1
1597
+ WHERE table_id = $2 AND spectator_id = $3 AND player_id = $4""",
1598
+ body.granted, body.table_id, body.spectator_id, user.sub
1599
+ )
1600
+ return {"success": True}
1601
+
head.html ADDED
@@ -0,0 +1,11 @@
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <meta property="og:title" content="Rummy Room" />
2
+ <meta property="og:description" content="Multiplayer 13‑card rummy with automatic meld validation, scoring, and round management." />
3
+ <meta property="og:type" content="website" />
4
+ <meta property="og:image" content="https://riff.new/static/user-apps/opengraph.png" />
5
+
6
+ <meta name="twitter:card" content="summary_large_image" />
7
+ <meta name="twitter:title" content="Rummy Room" />
8
+ <meta name="twitter:description" content="Multiplayer 13‑card rummy with automatic meld validation, scoring, and round management." />
9
+ <meta name="twitter:image" content="https://riff.new/static/user-apps/opengraph.png" />
10
+
11
+ <link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=inter:[email protected]&display=swap">
health.js ADDED
@@ -0,0 +1,13 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from fastapi import APIRouter
2
+ from pydantic import BaseModel
3
+
4
+ router = APIRouter()
5
+
6
+
7
+ class HealthResponse(BaseModel):
8
+ ok: bool
9
+
10
+
11
+ @router.get("/health-check")
12
+ def health_check() -> HealthResponse:
13
+ return HealthResponse(ok=True)
index.css ADDED
@@ -0,0 +1,101 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ @tailwind base;
2
+ @tailwind components;
3
+ @tailwind utilities;
4
+
5
+ @layer base {
6
+ :root {
7
+ --background: 0 0% 100%;
8
+ --foreground: 240 10% 3.9%;
9
+ --card: 0 0% 100%;
10
+ --card-foreground: 240 10% 3.9%;
11
+ --popover: 0 0% 100%;
12
+ --popover-foreground: 240 10% 3.9%;
13
+ --primary: 240 5.9% 10%;
14
+ --primary-foreground: 0 0% 98%;
15
+ --secondary: 240 4.8% 95.9%;
16
+ --secondary-foreground: 240 5.9% 10%;
17
+ --muted: 240 4.8% 95.9%;
18
+ --muted-foreground: 240 3.8% 46.1%;
19
+ --accent: 240 4.8% 95.9%;
20
+ --accent-foreground: 240 5.9% 10%;
21
+ --destructive: 0 84.2% 60.2%;
22
+ --destructive-foreground: 0 0% 98%;
23
+ --border: 240 5.9% 90%;
24
+ --input: 240 5.9% 90%;
25
+ --ring: 240 5.9% 10%;
26
+ --radius: 0.5rem;
27
+ --chart-1: 12 76% 61%;
28
+ --chart-2: 173 58% 39%;
29
+ --chart-3: 197 37% 24%;
30
+ --chart-4: 43 74% 66%;
31
+ --chart-5: 27 87% 67%;
32
+ }
33
+
34
+ .dark {
35
+ /* Rich dark gaming background - deep charcoal */
36
+ --background: 220 15% 12%;
37
+ --foreground: 0 0% 98%;
38
+
39
+ /* Card surfaces - slightly lighter for contrast */
40
+ --card: 220 13% 16%;
41
+ --card-foreground: 0 0% 98%;
42
+
43
+ /* Popover surfaces */
44
+ --popover: 220 13% 14%;
45
+ --popover-foreground: 0 0% 98%;
46
+
47
+ /* Primary - felt-green table surface inspiration */
48
+ --primary: 150 40% 35%;
49
+ --primary-foreground: 0 0% 98%;
50
+
51
+ /* Secondary - neutral darker tones */
52
+ --secondary: 220 12% 22%;
53
+ --secondary-foreground: 0 0% 98%;
54
+
55
+ /* Muted elements - subtle backgrounds */
56
+ --muted: 220 12% 20%;
57
+ --muted-foreground: 220 8% 65%;
58
+
59
+ /* Accent - warm accent for impure melds and highlights */
60
+ --accent: 25 75% 55%;
61
+ --accent-foreground: 220 15% 12%;
62
+
63
+ /* Destructive actions */
64
+ --destructive: 0 70% 50%;
65
+ --destructive-foreground: 0 0% 98%;
66
+
67
+ /* Borders - subtle separation */
68
+ --border: 220 12% 24%;
69
+ --input: 220 12% 24%;
70
+
71
+ /* Ring/focus - felt-green tint */
72
+ --ring: 150 40% 45%;
73
+
74
+ /* Chart colors - distinct for game stats */
75
+ --chart-1: 150 50% 45%;
76
+ --chart-2: 200 60% 50%;
77
+ --chart-3: 25 75% 55%;
78
+ --chart-4: 280 50% 60%;
79
+ --chart-5: 45 80% 60%;
80
+ }
81
+ }
82
+
83
+
84
+ @layer base {
85
+ * {
86
+ @apply border-border;
87
+ }
88
+ body {
89
+ @apply bg-background text-foreground;
90
+ font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarell', sans-serif;
91
+ font-feature-settings: "rlig" 1, "calt" 1, "tnum" 1;
92
+ font-weight: 400;
93
+ -webkit-font-smoothing: antialiased;
94
+ -moz-osx-font-smoothing: grayscale;
95
+ }
96
+
97
+ h1, h2, h3, h4, h5, h6 {
98
+ font-weight: 500;
99
+ }
100
+ }
101
+
ing game feautures.png ADDED
lobby.png ADDED
meld section.png ADDED
profiles.js ADDED
@@ -0,0 +1,43 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from fastapi import APIRouter, HTTPException
2
+ from pydantic import BaseModel
3
+ from app.auth import AuthorizedUser
4
+ from app.libs.db import execute
5
+ import os
6
+ import requests
7
+
8
+ router = APIRouter()
9
+
10
+ class UserProfile(BaseModel):
11
+ user_id: str
12
+ display_name: str | None
13
+ avatar_url: str | None
14
+
15
+ @router.get("/me")
16
+ async def get_my_profile(user: AuthorizedUser) -> UserProfile:
17
+ user_id = user.sub
18
+ # Stack Auth may not provide display_name or profile_image_url
19
+ display_name = getattr(user, 'display_name', None) or getattr(user, 'client_metadata', {}).get('display_name') or 'Player'
20
+ avatar_url = getattr(user, 'profile_image_url', None) or getattr(user, 'client_metadata', {}).get('avatar_url') or ''
21
+
22
+ print(f"🔄 Syncing profile for user {user_id}: {display_name} - {avatar_url}")
23
+
24
+ # Insert or update profile in database
25
+ await execute(
26
+ """
27
+ INSERT INTO profiles (user_id, display_name, avatar_url)
28
+ VALUES ($1, $2, $3)
29
+ ON CONFLICT (user_id) DO UPDATE
30
+ SET display_name = EXCLUDED.display_name,
31
+ avatar_url = EXCLUDED.avatar_url,
32
+ updated_at = NOW()
33
+ """,
34
+ user_id, display_name, avatar_url
35
+ )
36
+
37
+ print(f"✅ Profile synced successfully for {user_id}")
38
+
39
+ return UserProfile(
40
+ user_id=user_id,
41
+ display_name=display_name,
42
+ avatar_url=avatar_url
43
+ )
room created.png ADDED

Git LFS Details

  • SHA256: b7da03158c68bf54487dd33e16f2fd52da16addbbdf831570d8517c6df27c3f9
  • Pointer size: 131 Bytes
  • Size of remote file: 178 kB
rummy_engine.js ADDED
@@ -0,0 +1,147 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Core rummy game engine with validation and scoring logic"""
2
+ import asyncpg
3
+ import random
4
+ from typing import List, Dict, Tuple, Optional
5
+
6
+ # Card deck constants
7
+ SUITS = ['H', 'D', 'C', 'S'] # Hearts, Diamonds, Clubs, Spades
8
+ RANKS = ['A', '2', '3', '4', '5', '6', '7', '8', '9', '10', 'J', 'Q', 'K']
9
+
10
+ def create_deck(num_decks: int = 2) -> List[str]:
11
+ """Create shuffled deck with jokers"""
12
+ deck = []
13
+ for _ in range(num_decks):
14
+ # Regular cards
15
+ for suit in SUITS:
16
+ for rank in RANKS:
17
+ deck.append(f"{rank}{suit}")
18
+ # Printed jokers (2 per deck)
19
+ deck.append("JKR")
20
+ deck.append("JKR")
21
+
22
+ random.shuffle(deck)
23
+ return deck
24
+
25
+ def calculate_card_points(card: str) -> int:
26
+ """Calculate points for a single card"""
27
+ if card == "JKR":
28
+ return 0
29
+
30
+ rank = card[:-1] # Remove suit
31
+ if rank in ['J', 'Q', 'K', 'A']:
32
+ return 10
33
+ return int(rank)
34
+
35
+ def validate_sequence(cards: List[str]) -> Tuple[bool, bool]:
36
+ """Validate if cards form a sequence. Returns (is_valid, is_pure)"""
37
+ if len(cards) < 3:
38
+ return False, False
39
+
40
+ # Check for jokers
41
+ has_joker = any(c == "JKR" for c in cards)
42
+
43
+ # Extract suits and ranks
44
+ suits = [c[-1] for c in cards if c != "JKR"]
45
+ ranks = [c[:-1] for c in cards if c != "JKR"]
46
+
47
+ # All non-joker cards must be same suit
48
+ if len(set(suits)) > 1:
49
+ return False, False
50
+
51
+ # Check consecutive ranks
52
+ 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}
53
+ rank_values = sorted([rank_order[r] for r in ranks])
54
+
55
+ # With jokers, check if remaining cards can form sequence
56
+ if has_joker:
57
+ # Simple validation for now
58
+ return True, False
59
+
60
+ # Pure sequence check
61
+ for i in range(1, len(rank_values)):
62
+ if rank_values[i] != rank_values[i-1] + 1:
63
+ return False, False
64
+
65
+ return True, True
66
+
67
+ def validate_set(cards: List[str]) -> bool:
68
+ """Validate if cards form a set (same rank, different suits)"""
69
+ if len(cards) < 3:
70
+ return False
71
+
72
+ # Remove jokers for validation
73
+ non_jokers = [c for c in cards if c != "JKR"]
74
+ if len(non_jokers) < 2:
75
+ return False
76
+
77
+ # All non-joker cards must have same rank
78
+ ranks = [c[:-1] for c in non_jokers]
79
+ if len(set(ranks)) > 1:
80
+ return False
81
+
82
+ # All non-joker cards must have different suits
83
+ suits = [c[-1] for c in non_jokers]
84
+ if len(suits) != len(set(suits)):
85
+ return False
86
+
87
+ return True
88
+
89
+ async def deal_initial_hands(conn: asyncpg.Connection, table_id: str, round_num: int, player_ids: List[str]):
90
+ """Deal initial 13 cards to each player for a new round"""
91
+ num_players = len(player_ids)
92
+ deck = create_deck(num_decks=2 if num_players <= 4 else 3)
93
+
94
+ # Deal 13 cards to each player
95
+ for i, player_id in enumerate(player_ids):
96
+ hand = deck[i*13:(i+1)*13]
97
+ await conn.execute(
98
+ """
99
+ INSERT INTO player_rounds (table_id, round_number, user_id, hand, drawn_card, has_drawn, status)
100
+ VALUES ($1, $2, $3, $4, NULL, FALSE, 'playing')
101
+ ON CONFLICT (table_id, round_number, user_id)
102
+ DO UPDATE SET hand = $4, drawn_card = NULL, has_drawn = FALSE, status = 'playing'
103
+ """,
104
+ table_id, round_num, player_id, hand
105
+ )
106
+
107
+ # Remaining cards go to stock pile
108
+ stock_pile = deck[num_players*13:]
109
+
110
+ # Initialize round state
111
+ await conn.execute(
112
+ """
113
+ INSERT INTO round_state (table_id, round_number, stock_pile, discard_pile, current_turn_index)
114
+ VALUES ($1, $2, $3, '{}', 0)
115
+ ON CONFLICT (table_id, round_number)
116
+ DO UPDATE SET stock_pile = $3, discard_pile = '{}', current_turn_index = 0
117
+ """,
118
+ table_id, round_num, stock_pile
119
+ )
120
+
121
+ async def validate_declaration(conn: asyncpg.Connection, table_id: str, round_num: int, user_id: str) -> Tuple[bool, int, str]:
122
+ """Validate a player's declaration. Returns (is_valid, points, message)"""
123
+ # Get player's melds
124
+ melds = await conn.fetchval(
125
+ "SELECT locked_sequences FROM player_rounds WHERE table_id = $1 AND round_number = $2 AND user_id = $3",
126
+ table_id, round_num, user_id
127
+ )
128
+
129
+ if not melds:
130
+ return False, 0, "No melds declared"
131
+
132
+ # Must have at least 2 melds with one pure sequence
133
+ if len(melds) < 2:
134
+ return False, 0, "Need at least 2 melds (1 pure sequence + 1 other)"
135
+
136
+ has_pure_sequence = False
137
+ for meld in melds:
138
+ is_valid, is_pure = validate_sequence(meld)
139
+ if is_valid and is_pure:
140
+ has_pure_sequence = True
141
+ break
142
+
143
+ if not has_pure_sequence:
144
+ return False, 0, "Must have at least one pure sequence"
145
+
146
+ # Valid declaration = 0 points
147
+ return True, 0, "Valid declaration!"
rummy_models.js ADDED
@@ -0,0 +1,107 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Data models and helpers for Rummy engine
2
+ # These are pure-Python helpers used by FastAPI endpoints.
3
+ from __future__ import annotations
4
+ from pydantic import BaseModel, Field
5
+ from typing import List, Literal, Optional, Dict, Tuple
6
+ import random
7
+
8
+ Rank = Literal["A","2","3","4","5","6","7","8","9","10","J","Q","K", "JOKER"]
9
+ Suit = Literal["S","H","D","C"]
10
+
11
+ class Card(BaseModel):
12
+ rank: Rank
13
+ suit: Optional[Suit] = None # Jokers have no suit
14
+ joker: bool = False # True if joker (printed or wild)
15
+
16
+ def code(self) -> str:
17
+ if self.joker and self.rank == "JOKER":
18
+ return "JOKER"
19
+ return f"{self.rank}{self.suit or ''}"
20
+
21
+ class DeckConfig(BaseModel):
22
+ decks: int = 2 # standard: 2 decks for up to 6 players
23
+ include_printed_jokers: bool = True
24
+
25
+ RANKS: List[Rank] = ["A","2","3","4","5","6","7","8","9","10","J","Q","K"]
26
+ SUITS: List[Suit] = ["S","H","D","C"]
27
+
28
+ class ShuffledDeck(BaseModel):
29
+ cards: List[Card]
30
+
31
+ def draw(self) -> Card:
32
+ return self.cards.pop() # draw from top (end)
33
+
34
+
35
+ def build_deck(cfg: DeckConfig) -> List[Card]:
36
+ cards: List[Card] = []
37
+ for _ in range(cfg.decks):
38
+ for s in SUITS:
39
+ for r in RANKS:
40
+ cards.append(Card(rank=r, suit=s, joker=False))
41
+ if cfg.include_printed_jokers:
42
+ # Two printed jokers per deck typical
43
+ cards.append(Card(rank="JOKER", suit=None, joker=True))
44
+ cards.append(Card(rank="JOKER", suit=None, joker=True))
45
+ return cards
46
+
47
+
48
+ def fair_shuffle(cards: List[Card], seed: Optional[int] = None) -> ShuffledDeck:
49
+ rnd = random.Random(seed)
50
+ # Use Fisher-Yates via random.shuffle
51
+ cards_copy = list(cards)
52
+ rnd.shuffle(cards_copy)
53
+ return ShuffledDeck(cards=cards_copy)
54
+
55
+
56
+ class DealResult(BaseModel):
57
+ hands: Dict[str, List[Card]] # user_id -> 13 cards
58
+ stock: List[Card]
59
+ discard: List[Card]
60
+ printed_joker: Optional[Card]
61
+
62
+
63
+ def deal_initial(user_ids: List[str], cfg: DeckConfig, seed: Optional[int] = None) -> DealResult:
64
+ deck = fair_shuffle(build_deck(cfg), seed)
65
+ # Flip printed joker from stock top (non-player)
66
+ printed_joker: Optional[Card] = None
67
+
68
+ # Deal 13 to each player, round-robin
69
+ hands: Dict[str, List[Card]] = {u: [] for u in user_ids}
70
+ # Pre-draw a printed joker to reveal if present (optional rule)
71
+ # We'll reveal the first printed joker encountered when drawing discard initial card
72
+
73
+ # Draw initial discard card
74
+ discard: List[Card] = []
75
+
76
+ # Distribute 13 cards
77
+ for i in range(13):
78
+ for u in user_ids:
79
+ hands[u].append(deck.draw())
80
+
81
+ # Reveal top card to discard; if joker, keep discarding until a non-joker to start
82
+ while True:
83
+ if not deck.cards:
84
+ break
85
+ top = deck.draw()
86
+ if top.joker:
87
+ discard.append(top)
88
+ continue
89
+ discard.append(top)
90
+ break
91
+
92
+ return DealResult(hands=hands, stock=deck.cards, discard=discard, printed_joker=printed_joker)
93
+
94
+
95
+ class StartRoundRequest(BaseModel):
96
+ table_id: str
97
+ user_ids: List[str] = Field(min_items=2, max_items=6)
98
+ disqualify_score: int = 200
99
+ seed: Optional[int] = None
100
+
101
+ class StartRoundResponse(BaseModel):
102
+ round_id: str
103
+ table_id: str
104
+ number: int
105
+ active_user_id: str
106
+ stock_count: int
107
+ discard_top: Optional[str]
scoreboard.jpg ADDED
scoring.js ADDED
@@ -0,0 +1,471 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+
2
+
3
+
4
+
5
+ # Simple Rummy scoring utilities
6
+ # Points: Face cards (J,Q,K,A)=10, 2-10 face value, jokers=0. Cap per hand: 80.
7
+ from __future__ import annotations
8
+ from typing import List, Dict, Tuple, Optional, Union
9
+
10
+ # Card dict shape: {rank: str, suit: str | None, joker: bool}
11
+ # Can also be Pydantic models with rank, suit, joker attributes
12
+
13
+ RANK_POINTS = {
14
+ "A": 10, # Default, can be overridden
15
+ "K": 10,
16
+ "Q": 10,
17
+ "J": 10,
18
+ "10": 10,
19
+ "9": 9,
20
+ "8": 8,
21
+ "7": 7,
22
+ "6": 6,
23
+ "5": 5,
24
+ "4": 4,
25
+ "3": 3,
26
+ "2": 2,
27
+ }
28
+
29
+ RANK_ORDER = ["A", "2", "3", "4", "5", "6", "7", "8", "9", "10", "J", "Q", "K"]
30
+
31
+
32
+ def _get_card_attr(card: Union[dict, object], attr: str, default=None):
33
+ """Get attribute from card whether it's a dict or Pydantic model."""
34
+ if isinstance(card, dict):
35
+ return card.get(attr, default)
36
+ return getattr(card, attr, default)
37
+
38
+
39
+ def _is_joker_card(card: dict | tuple, wild_joker_rank: str | None, has_wild_joker_revealed: bool = True) -> bool:
40
+ """Check if a card is a joker (printed or wild).
41
+
42
+ Args:
43
+ card: Card as dict or tuple
44
+ wild_joker_rank: The rank that acts as wild joker
45
+ has_wild_joker_revealed: Whether wild joker is revealed to this player
46
+
47
+ Returns:
48
+ True if card is a joker (printed joker or matches wild joker rank IF revealed)
49
+ """
50
+ rank = _get_card_attr(card, "rank")
51
+
52
+ # Printed jokers are always jokers
53
+ if rank == "JOKER":
54
+ return True
55
+
56
+ # Wild joker cards only act as jokers if revealed
57
+ if has_wild_joker_revealed and wild_joker_rank and rank == wild_joker_rank:
58
+ return True
59
+
60
+ return False
61
+
62
+
63
+ def card_points(card: Union[dict, object], ace_value: int = 10) -> int:
64
+ """Calculate points for a single card.
65
+
66
+ Args:
67
+ card: Card as dict or Pydantic model
68
+ ace_value: Point value for Aces (1 or 10)
69
+ """
70
+ if _get_card_attr(card, "joker"):
71
+ return 0
72
+ rank = _get_card_attr(card, "rank")
73
+ if rank == "A":
74
+ return ace_value
75
+ return RANK_POINTS.get(rank, 0)
76
+
77
+
78
+ def naive_hand_points(hand: List[Union[dict, object]]) -> int:
79
+ # Naive pre-validation: full sum capped to 80
80
+ total = sum(card_points(c) for c in hand)
81
+ return min(total, 80)
82
+
83
+
84
+ def is_sequence(
85
+ cards: list[dict | tuple],
86
+ wild_joker_rank: str | None = None,
87
+ has_wild_joker_revealed: bool = True
88
+ ) -> bool:
89
+ """Check if cards form a valid sequence (consecutive ranks, same suit)."""
90
+ if len(cards) < 3:
91
+ return False
92
+
93
+ # All cards must have the same suit (excluding jokers)
94
+ suits = [_get_card_attr(c, "suit") for c in cards if not _is_joker_card(c, wild_joker_rank, has_wild_joker_revealed)]
95
+ if not suits or len(set(suits)) > 1:
96
+ return False
97
+
98
+ # Get non-joker cards
99
+ joker_count = sum(1 for c in cards if _is_joker_card(c, wild_joker_rank, has_wild_joker_revealed))
100
+ non_jokers = [c for c in cards if not _is_joker_card(c, wild_joker_rank, has_wild_joker_revealed)]
101
+
102
+ if len(non_jokers) < 2:
103
+ return False
104
+
105
+ # Get rank indices for non-joker cards
106
+ rank_indices = sorted([RANK_ORDER.index(_get_card_attr(c, "rank")) for c in non_jokers])
107
+
108
+ # Check for normal consecutive sequence
109
+ first_idx = rank_indices[0]
110
+ last_idx = rank_indices[-1]
111
+ required_span = last_idx - first_idx + 1
112
+
113
+ if required_span <= len(cards):
114
+ return True
115
+
116
+ # Check for wrap-around sequence (Ace can be high: Q-K-A or K-A-2)
117
+ # If we have an Ace (index 0) and high cards (J, Q, K at indices 10, 11, 12)
118
+ if 0 in rank_indices and any(idx >= 10 for idx in rank_indices):
119
+ # Try treating Ace as high (after King)
120
+ # Create alternate indices where Ace = 13
121
+ alt_indices = [idx if idx != 0 else 13 for idx in rank_indices]
122
+ alt_indices.sort()
123
+ alt_span = alt_indices[-1] - alt_indices[0] + 1
124
+
125
+ if alt_span <= len(cards):
126
+ return True
127
+
128
+ return False
129
+
130
+
131
+ def is_pure_sequence(
132
+ cards: list[dict | tuple],
133
+ wild_joker_rank: str | None = None,
134
+ has_wild_joker_revealed: bool = True
135
+ ) -> bool:
136
+ """Check if cards form a pure sequence (no jokers as substitutes).
137
+
138
+ Wild joker cards in their natural position (same suit, consecutive rank) are allowed.
139
+ Only reject if wild joker is used as a substitute.
140
+ """
141
+ if not is_sequence(cards, wild_joker_rank, has_wild_joker_revealed):
142
+ return False
143
+
144
+ # Check for printed jokers (always impure)
145
+ if any(_get_card_attr(c, "rank") == "JOKER" for c in cards):
146
+ return False
147
+
148
+ # If wild joker not revealed, all cards are treated as natural
149
+ if not has_wild_joker_revealed or not wild_joker_rank:
150
+ return True
151
+
152
+ # Check if any wild joker cards are used as substitutes (not in natural position)
153
+ suit = None
154
+ for c in cards:
155
+ c_suit = _get_card_attr(c, "suit")
156
+ if suit is None:
157
+ suit = c_suit
158
+ # All cards must have same suit for a sequence
159
+ if c_suit != suit:
160
+ return False
161
+
162
+ # Get rank indices for all cards
163
+ rank_indices = []
164
+ for c in cards:
165
+ rank = _get_card_attr(c, "rank")
166
+ if rank in RANK_ORDER:
167
+ rank_indices.append(RANK_ORDER.index(rank))
168
+
169
+ if len(rank_indices) != len(cards):
170
+ return False
171
+
172
+ rank_indices.sort()
173
+
174
+ # Check if this is a consecutive sequence
175
+ is_consecutive = all(
176
+ rank_indices[i+1] - rank_indices[i] == 1
177
+ for i in range(len(rank_indices) - 1)
178
+ )
179
+
180
+ # Check for wrap-around (Q-K-A or K-A-2)
181
+ is_wraparound = False
182
+ if 0 in rank_indices and any(idx >= 10 for idx in rank_indices):
183
+ alt_indices = [idx if idx != 0 else 13 for idx in rank_indices]
184
+ alt_indices.sort()
185
+ is_wraparound = all(
186
+ alt_indices[i+1] - alt_indices[i] == 1
187
+ for i in range(len(alt_indices) - 1)
188
+ )
189
+
190
+ # If cards form a natural consecutive sequence, all wild jokers are in natural positions
191
+ if is_consecutive or is_wraparound:
192
+ return True
193
+
194
+ # Otherwise, sequence has gaps - must be using wild jokers as substitutes
195
+ return False
196
+
197
+
198
+ def is_set(
199
+ cards: list[dict | tuple],
200
+ wild_joker_rank: str | None = None,
201
+ has_wild_joker_revealed: bool = True
202
+ ) -> bool:
203
+ """Check if cards form a valid set (3-4 cards of same rank, different suits)."""
204
+ if len(cards) < 3 or len(cards) > 4:
205
+ return False
206
+
207
+ # All non-joker cards must have the same rank
208
+ ranks = [_get_card_attr(c, "rank") for c in cards if not _is_joker_card(c, wild_joker_rank, has_wild_joker_revealed)]
209
+ if not ranks or len(set(ranks)) > 1:
210
+ return False
211
+
212
+ # All non-joker cards must have different suits
213
+ 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")]
214
+ if len(suits) != len(set(suits)):
215
+ return False
216
+
217
+ return True
218
+
219
+
220
+ def validate_hand(
221
+ melds: list[list[dict | tuple]],
222
+ leftover: list[dict | tuple],
223
+ wild_joker_rank: str | None = None,
224
+ has_wild_joker_revealed: bool = True
225
+ ) -> dict:
226
+ """Validate a complete 13-card hand declaration."""
227
+ # After drawing, player has 14 cards. They organize 13 into melds and discard the 14th.
228
+ # So we don't check hand length, only that melds contain exactly 13 cards.
229
+
230
+ if not melds:
231
+ return False, "No meld groups provided"
232
+
233
+ # Check total cards in groups equals 13
234
+ total_cards = sum(len(g) for g in melds)
235
+ if total_cards != 13:
236
+ return False, f"Meld groups must contain exactly 13 cards, found {total_cards}"
237
+
238
+ # Check for at least one pure sequence
239
+ has_pure_sequence = False
240
+ valid_sequences = 0
241
+ valid_sets = 0
242
+
243
+ for group in melds:
244
+ if len(group) < 3:
245
+ return False, f"Each meld must have at least 3 cards, found {len(group)}"
246
+
247
+ is_seq = is_sequence(group, wild_joker_rank, has_wild_joker_revealed)
248
+ is_pure_seq = is_pure_sequence(group, wild_joker_rank, has_wild_joker_revealed)
249
+ is_valid_set = is_set(group, wild_joker_rank, has_wild_joker_revealed)
250
+
251
+ if is_pure_seq:
252
+ has_pure_sequence = True
253
+ valid_sequences += 1
254
+ elif is_seq:
255
+ valid_sequences += 1
256
+ elif is_valid_set:
257
+ valid_sets += 1
258
+ else:
259
+ cards_str = ', '.join([f"{_get_card_attr(c, 'rank')}{_get_card_attr(c, 'suit') or ''}" for c in group])
260
+ return False, f"Invalid meld: [{cards_str}] is neither a valid sequence nor set"
261
+
262
+ if not has_pure_sequence:
263
+ return False, "Must have at least one pure sequence (no jokers)"
264
+
265
+ if len(melds) < 2:
266
+ return False, "Must have at least 2 melds"
267
+
268
+ return True, "Valid hand"
269
+
270
+
271
+ def calculate_deadwood_points(
272
+ cards: list[dict | tuple],
273
+ wild_joker_rank: str | None = None,
274
+ has_wild_joker_revealed: bool = True,
275
+ ace_value: int = 10
276
+ ) -> int:
277
+ """Calculate points for ungrouped/invalid cards.
278
+
279
+ Args:
280
+ cards: List of cards
281
+ wild_joker_rank: The rank that acts as wild joker
282
+ has_wild_joker_revealed: Whether wild joker is revealed
283
+ ace_value: Point value for Aces (1 or 10)
284
+ """
285
+ total = 0
286
+ for card in cards:
287
+ if _is_joker_card(card, wild_joker_rank, has_wild_joker_revealed):
288
+ total += 0 # Jokers are worth 0
289
+ else:
290
+ total += card_points(card, ace_value)
291
+ return min(total, 80)
292
+
293
+
294
+ def auto_organize_hand(
295
+ hand: list[dict | tuple],
296
+ wild_joker_rank: str | None = None,
297
+ has_wild_joker_revealed: bool = True
298
+ ) -> tuple[list[list[dict | tuple]], list[dict | tuple]]:
299
+ """
300
+ Automatically organize a hand into best possible melds and leftover cards.
301
+ Used for scoring opponents when someone declares.
302
+
303
+ Returns:
304
+ (melds, leftover_cards)
305
+ """
306
+ if not hand or len(hand) == 0:
307
+ return [], []
308
+
309
+ remaining = list(hand)
310
+ melds = []
311
+
312
+ # Helper to try forming sequences
313
+ def try_form_sequence(cards_pool: list) -> list | None:
314
+ """Try to find a valid sequence from cards pool."""
315
+ for i in range(len(cards_pool)):
316
+ for j in range(i + 1, len(cards_pool)):
317
+ for k in range(j + 1, len(cards_pool)):
318
+ group = [cards_pool[i], cards_pool[j], cards_pool[k]]
319
+ if is_sequence(group, wild_joker_rank, has_wild_joker_revealed):
320
+ # Try to extend to 4 cards
321
+ for m in range(len(cards_pool)):
322
+ if m not in [i, j, k]:
323
+ extended = group + [cards_pool[m]]
324
+ if is_sequence(extended, wild_joker_rank, has_wild_joker_revealed):
325
+ return extended
326
+ return group
327
+ return None
328
+
329
+ # Helper to try forming sets
330
+ def try_form_set(cards_pool: list) -> list | None:
331
+ """Try to find a valid set from cards pool."""
332
+ for i in range(len(cards_pool)):
333
+ for j in range(i + 1, len(cards_pool)):
334
+ for k in range(j + 1, len(cards_pool)):
335
+ group = [cards_pool[i], cards_pool[j], cards_pool[k]]
336
+ if is_set(group, wild_joker_rank, has_wild_joker_revealed):
337
+ # Try to extend to 4 cards
338
+ for m in range(len(cards_pool)):
339
+ if m not in [i, j, k]:
340
+ extended = group + [cards_pool[m]]
341
+ if is_set(extended, wild_joker_rank, has_wild_joker_revealed):
342
+ return extended
343
+ return group
344
+ return None
345
+
346
+ # First pass: try to form pure sequences (highest priority)
347
+ while True:
348
+ seq = try_form_sequence(remaining)
349
+ if seq and is_pure_sequence(seq, wild_joker_rank, has_wild_joker_revealed):
350
+ melds.append(seq)
351
+ for card in seq:
352
+ remaining.remove(card)
353
+ else:
354
+ break
355
+
356
+ # Second pass: form any sequences
357
+ while True:
358
+ seq = try_form_sequence(remaining)
359
+ if seq:
360
+ melds.append(seq)
361
+ for card in seq:
362
+ remaining.remove(card)
363
+ else:
364
+ break
365
+
366
+ # Third pass: form sets
367
+ while True:
368
+ set_group = try_form_set(remaining)
369
+ if set_group:
370
+ melds.append(set_group)
371
+ for card in set_group:
372
+ remaining.remove(card)
373
+ else:
374
+ break
375
+
376
+ return melds, remaining
377
+
378
+
379
+ def organize_hand_by_melds(hand: List[Union[dict, object]]) -> Dict[str, List[List[Union[dict, object]]]]:
380
+ """
381
+ Organize a hand into meld groups for display.
382
+ Returns cards grouped by meld type for easy verification.
383
+
384
+ Returns:
385
+ {
386
+ 'pure_sequences': [[card, card, card], ...],
387
+ 'impure_sequences': [[card, card, card], ...],
388
+ 'sets': [[card, card, card], ...],
389
+ 'ungrouped': [card, card, ...]
390
+ }
391
+ """
392
+ if not hand:
393
+ return {
394
+ 'pure_sequences': [],
395
+ 'impure_sequences': [],
396
+ 'sets': [],
397
+ 'ungrouped': []
398
+ }
399
+
400
+ remaining_cards = list(hand)
401
+ pure_seqs = []
402
+ impure_seqs = []
403
+ sets_list = []
404
+
405
+ # Helper to find best meld of specific type
406
+ def find_meld_of_type(cards: List, meld_type: str) -> Optional[List]:
407
+ if len(cards) < 3:
408
+ return None
409
+
410
+ # Try all combinations of 3 and 4 cards
411
+ for size in [4, 3]: # Try 4-card melds first
412
+ if len(cards) < size:
413
+ continue
414
+ for i in range(len(cards) - size + 1):
415
+ group = cards[i:i+size]
416
+ if meld_type == 'pure_seq' and is_pure_sequence(group):
417
+ return group
418
+ elif meld_type == 'impure_seq' and is_sequence(group) and not is_pure_sequence(group):
419
+ return group
420
+ elif meld_type == 'set' and is_set(group):
421
+ return group
422
+
423
+ # Try all combinations (not just consecutive)
424
+ from itertools import combinations
425
+ for size in [4, 3]:
426
+ if len(cards) < size:
427
+ continue
428
+ for combo in combinations(range(len(cards)), size):
429
+ group = [cards[idx] for idx in combo]
430
+ if meld_type == 'pure_seq' and is_pure_sequence(group):
431
+ return group
432
+ elif meld_type == 'impure_seq' and is_sequence(group) and not is_pure_sequence(group):
433
+ return group
434
+ elif meld_type == 'set' and is_set(group):
435
+ return group
436
+
437
+ return None
438
+
439
+ # 1. Find pure sequences first (highest priority)
440
+ while len(remaining_cards) >= 3:
441
+ meld = find_meld_of_type(remaining_cards, 'pure_seq')
442
+ if not meld:
443
+ break
444
+ pure_seqs.append(meld)
445
+ for card in meld:
446
+ remaining_cards.remove(card)
447
+
448
+ # 2. Find impure sequences
449
+ while len(remaining_cards) >= 3:
450
+ meld = find_meld_of_type(remaining_cards, 'impure_seq')
451
+ if not meld:
452
+ break
453
+ impure_seqs.append(meld)
454
+ for card in meld:
455
+ remaining_cards.remove(card)
456
+
457
+ # 3. Find sets
458
+ while len(remaining_cards) >= 3:
459
+ meld = find_meld_of_type(remaining_cards, 'set')
460
+ if not meld:
461
+ break
462
+ sets_list.append(meld)
463
+ for card in meld:
464
+ remaining_cards.remove(card)
465
+
466
+ return {
467
+ 'pure_sequences': pure_seqs,
468
+ 'impure_sequences': impure_seqs,
469
+ 'sets': sets_list,
470
+ 'ungrouped': remaining_cards
471
+ }
table creating.png ADDED

Git LFS Details

  • SHA256: c697d3440f82f497231cd58f337b692d6110f1966fb9abeeb39250ba4dfd9702
  • Pointer size: 131 Bytes
  • Size of remote file: 116 kB
table.png ADDED

Git LFS Details

  • SHA256: d061da4b9bc952165c77830a8710044474d20f28fdbcb66f3effdcb75a1ea948
  • Pointer size: 131 Bytes
  • Size of remote file: 361 kB
tailwind.config.js ADDED
@@ -0,0 +1,79 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /** @type {import('tailwindcss').Config} */
2
+ export default {
3
+ darkMode: ["class"],
4
+ content: ["./index.html", "./src/**/*.{js,ts,jsx,tsx}"],
5
+ theme: {
6
+ extend: {
7
+ borderRadius: {
8
+ lg: "var(--radius)",
9
+ md: "calc(var(--radius) - 2px)",
10
+ sm: "calc(var(--radius) - 4px)",
11
+ },
12
+ colors: {
13
+ background: "hsl(var(--background))",
14
+ foreground: "hsl(var(--foreground))",
15
+ card: {
16
+ DEFAULT: "hsl(var(--card))",
17
+ foreground: "hsl(var(--card-foreground))",
18
+ },
19
+ popover: {
20
+ DEFAULT: "hsl(var(--popover))",
21
+ foreground: "hsl(var(--popover-foreground))",
22
+ },
23
+ primary: {
24
+ DEFAULT: "hsl(var(--primary))",
25
+ foreground: "hsl(var(--primary-foreground))",
26
+ },
27
+ secondary: {
28
+ DEFAULT: "hsl(var(--secondary))",
29
+ foreground: "hsl(var(--secondary-foreground))",
30
+ },
31
+ muted: {
32
+ DEFAULT: "hsl(var(--muted))",
33
+ foreground: "hsl(var(--muted-foreground))",
34
+ },
35
+ accent: {
36
+ DEFAULT: "hsl(var(--accent))",
37
+ foreground: "hsl(var(--accent-foreground))",
38
+ },
39
+ destructive: {
40
+ DEFAULT: "hsl(var(--destructive))",
41
+ foreground: "hsl(var(--destructive-foreground))",
42
+ },
43
+ border: "hsl(var(--border))",
44
+ input: "hsl(var(--input))",
45
+ ring: "hsl(var(--ring))",
46
+ chart: {
47
+ 1: "hsl(var(--chart-1))",
48
+ 2: "hsl(var(--chart-2))",
49
+ 3: "hsl(var(--chart-3))",
50
+ 4: "hsl(var(--chart-4))",
51
+ 5: "hsl(var(--chart-5))",
52
+ },
53
+ },
54
+ keyframes: {
55
+ "accordion-down": {
56
+ from: {
57
+ height: "0",
58
+ },
59
+ to: {
60
+ height: "var(--radix-accordion-content-height)",
61
+ },
62
+ },
63
+ "accordion-up": {
64
+ from: {
65
+ height: "var(--radix-accordion-content-height)",
66
+ },
67
+ to: {
68
+ height: "0",
69
+ },
70
+ },
71
+ },
72
+ animation: {
73
+ "accordion-down": "accordion-down 0.2s ease-out",
74
+ "accordion-up": "accordion-up 0.2s ease-out",
75
+ },
76
+ },
77
+ },
78
+ plugins: [require("tailwindcss-animate"), require("@tailwindcss/typography")],
79
+ };
voice.js ADDED
@@ -0,0 +1,130 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Voice and video call system for table communication"""
2
+ from fastapi import APIRouter, HTTPException, Depends
3
+ from pydantic import BaseModel
4
+ import asyncpg
5
+ import os
6
+ from typing import List, Optional
7
+ from app.auth import AuthorizedUser
8
+
9
+ router = APIRouter()
10
+
11
+ class VoiceSettingsRequest(BaseModel):
12
+ table_id: str
13
+ audio_enabled: bool
14
+ video_enabled: bool
15
+
16
+ class MutePlayerRequest(BaseModel):
17
+ table_id: str
18
+ target_user_id: str
19
+ muted: bool
20
+
21
+ class TableVoiceSettingsRequest(BaseModel):
22
+ table_id: str
23
+ voice_enabled: bool
24
+
25
+ @router.post("/settings")
26
+ async def update_voice_settings(body: VoiceSettingsRequest, user: AuthorizedUser):
27
+ """Update user's voice/video settings for a table"""
28
+ conn = await asyncpg.connect(os.environ.get("DATABASE_URL"))
29
+
30
+ try:
31
+ await conn.execute(
32
+ """UPDATE table_players
33
+ SET audio_enabled = $1, video_enabled = $2
34
+ WHERE table_id = $3 AND user_id = $4""",
35
+ body.audio_enabled,
36
+ body.video_enabled,
37
+ body.table_id,
38
+ user.sub
39
+ )
40
+
41
+ return {"success": True}
42
+
43
+ finally:
44
+ await conn.close()
45
+
46
+ @router.post("/mute-player")
47
+ async def mute_player(body: MutePlayerRequest, user: AuthorizedUser):
48
+ """Host can mute/unmute other players"""
49
+ conn = await asyncpg.connect(os.environ.get("DATABASE_URL"))
50
+
51
+ try:
52
+ # Verify user is host
53
+ is_host = await conn.fetchval(
54
+ "SELECT host_id = $1 FROM tables WHERE id = $2",
55
+ user.sub,
56
+ body.table_id
57
+ )
58
+
59
+ if not is_host:
60
+ raise HTTPException(status_code=403, detail="Only host can mute players")
61
+
62
+ await conn.execute(
63
+ """UPDATE table_players
64
+ SET audio_enabled = $1
65
+ WHERE table_id = $2 AND user_id = $3""",
66
+ not body.muted,
67
+ body.table_id,
68
+ body.target_user_id
69
+ )
70
+
71
+ return {"success": True}
72
+
73
+ finally:
74
+ await conn.close()
75
+
76
+ @router.post("/table-settings")
77
+ async def update_table_voice_settings(body: TableVoiceSettingsRequest, user: AuthorizedUser):
78
+ """Host can enable/disable voice for entire table"""
79
+ conn = await asyncpg.connect(os.environ.get("DATABASE_URL"))
80
+
81
+ try:
82
+ # Verify user is host
83
+ is_host = await conn.fetchval(
84
+ "SELECT host_id = $1 FROM tables WHERE id = $2",
85
+ user.sub,
86
+ body.table_id
87
+ )
88
+
89
+ if not is_host:
90
+ raise HTTPException(status_code=403, detail="Only host can change table voice settings")
91
+
92
+ await conn.execute(
93
+ "UPDATE tables SET voice_enabled = $1 WHERE id = $2",
94
+ body.voice_enabled,
95
+ body.table_id
96
+ )
97
+
98
+ return {"success": True}
99
+
100
+ finally:
101
+ await conn.close()
102
+
103
+ @router.get("/participants/{table_id}")
104
+ async def get_voice_participants(table_id: str, user: AuthorizedUser):
105
+ """Get all voice participants and their settings"""
106
+ conn = await asyncpg.connect(os.environ.get("DATABASE_URL"))
107
+
108
+ try:
109
+ participants = await conn.fetch(
110
+ """SELECT tp.user_id, p.display_name, tp.is_muted, tp.is_speaking
111
+ FROM rummy_table_players tp
112
+ LEFT JOIN profiles p ON tp.user_id = p.user_id
113
+ WHERE tp.table_id = $1""",
114
+ table_id
115
+ )
116
+
117
+ return {
118
+ "participants": [
119
+ {
120
+ "user_id": p['user_id'],
121
+ "display_name": p['display_name'] or p['user_id'][:8],
122
+ "is_muted": p['is_muted'] or False,
123
+ "is_speaking": p['is_speaking'] or False
124
+ }
125
+ for p in participants
126
+ ]
127
+ }
128
+
129
+ finally:
130
+ await conn.close()
your hand section in game.png ADDED