Spaces:
Running
Running
Upload 49 files
#527
by
AchyuthKolli
- opened
This view is limited to 50 files because it contains too many changes.
See the raw diff here.
- .gitattributes +5 -0
- AppProvider.jsx +21 -0
- CasinoTable3D.jsx +128 -0
- ChatPanel.jsx +150 -0
- ChatSidebar.jsx +256 -0
- CreateTable.jsx +349 -0
- GameHistory.jsx +132 -0
- GameRules.jsx +101 -0
- HandStrip.jsx +144 -0
- HistoryTable.jsx +121 -0
- Home.jsx +287 -0
- Home.png +3 -0
- PlayerProfile.jsx +60 -0
- PlayingCard.jsx +67 -0
- PointsTable.jsx +115 -0
- ProfileCard.jsx +73 -0
- ScoreboardModal.jsx +248 -0
- SpectateControls.jsx +122 -0
- Table.jsx +2081 -0
- TableDiagram.jsx +90 -0
- VoiceControls.jsx +56 -0
- VoicePanel.jsx +276 -0
- WildJokerRevealModal.jsx +128 -0
- call chat rules button.png +0 -0
- cardCodeUtils.ts +31 -0
- cardHelpers.ts +17 -0
- chat.js +186 -0
- cn.ts +6 -0
- db.js +48 -0
- declare discard buttons.png +0 -0
- default-theme.ts +1 -0
- full gaame table page.png +3 -0
- game.js +1601 -0
- head.html +11 -0
- health.js +13 -0
- index.css +101 -0
- ing game feautures.png +0 -0
- lobby.png +0 -0
- meld section.png +0 -0
- profiles.js +43 -0
- room created.png +3 -0
- rummy_engine.js +147 -0
- rummy_models.js +107 -0
- scoreboard.jpg +0 -0
- scoring.js +471 -0
- table creating.png +3 -0
- table.png +3 -0
- tailwind.config.js +79 -0
- voice.js +130 -0
- your hand section in game.png +0 -0
.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
|
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
|
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
|
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
|
table.png
ADDED
|
Git LFS Details
|
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
|