created the tournament ui and fixed the turns sequence

This commit is contained in:
2025-12-22 19:54:01 +01:00
parent ac21657bc7
commit 937620bac1
14 changed files with 738 additions and 198 deletions

View File

@@ -82,7 +82,7 @@ define(['./workbox-5a5d9309'], (function (workbox) { 'use strict';
"revision": "3ca0b8505b4bec776b69afdba2768812" "revision": "3ca0b8505b4bec776b69afdba2768812"
}, { }, {
"url": "index.html", "url": "index.html",
"revision": "0.gg4oatbh7is" "revision": "0.ca9afac9bpo"
}], {}); }], {});
workbox.cleanupOutdatedCaches(); workbox.cleanupOutdatedCaches();
workbox.registerRoute(new workbox.NavigationRoute(workbox.createHandlerBoundToURL("index.html"), { workbox.registerRoute(new workbox.NavigationRoute(workbox.createHandlerBoundToURL("index.html"), {

View File

@@ -1,7 +1,6 @@
import React, { useState } from 'react'; import React, { useState } from 'react';
import { Layers, Box, Trophy, Users, Play } from 'lucide-react'; import { Layers, Box, Trophy, Users, Play } from 'lucide-react';
import { CubeManager } from './modules/cube/CubeManager'; import { CubeManager } from './modules/cube/CubeManager';
import { TournamentManager } from './modules/tournament/TournamentManager';
import { LobbyManager } from './modules/lobby/LobbyManager'; import { LobbyManager } from './modules/lobby/LobbyManager';
import { DeckTester } from './modules/tester/DeckTester'; import { DeckTester } from './modules/tester/DeckTester';
import { Pack } from './services/PackGeneratorService'; import { Pack } from './services/PackGeneratorService';
@@ -130,7 +129,13 @@ export const App: React.FC = () => {
)} )}
{activeTab === 'lobby' && <LobbyManager generatedPacks={generatedPacks} availableLands={availableLands} />} {activeTab === 'lobby' && <LobbyManager generatedPacks={generatedPacks} availableLands={availableLands} />}
{activeTab === 'tester' && <DeckTester />} {activeTab === 'tester' && <DeckTester />}
{activeTab === 'bracket' && <TournamentManager />} {activeTab === 'bracket' && (
<div className="flex flex-col items-center justify-center h-full text-slate-400">
<Trophy className="w-16 h-16 mb-4 opacity-50" />
<h2 className="text-xl font-bold">Tournament Manager</h2>
<p>Tournaments are now managed within the Online Lobby.</p>
</div>
)}
</main> </main>
<footer className="bg-slate-900 border-t border-slate-800 p-2 text-center text-xs text-slate-500 shrink-0"> <footer className="bg-slate-900 border-t border-slate-800 p-2 text-center text-xs text-slate-500 shrink-0">

View File

@@ -11,6 +11,7 @@ interface SidePanelPreviewProps {
onToggleCollapse: (collapsed: boolean) => void; onToggleCollapse: (collapsed: boolean) => void;
onResizeStart?: (e: React.MouseEvent | React.TouchEvent) => void; onResizeStart?: (e: React.MouseEvent | React.TouchEvent) => void;
className?: string; // For additional styling (positioning, z-index, etc) className?: string; // For additional styling (positioning, z-index, etc)
children?: React.ReactNode;
} }
export const SidePanelPreview: React.FC<SidePanelPreviewProps> = ({ export const SidePanelPreview: React.FC<SidePanelPreviewProps> = ({

View File

@@ -17,7 +17,10 @@ interface DeckBuilderViewProps {
roomId: string; roomId: string;
currentPlayerId: string; currentPlayerId: string;
initialPool: any[]; initialPool: any[];
initialDeck?: any[];
availableBasicLands?: any[]; availableBasicLands?: any[];
onSubmit?: (deck: any[]) => void;
submitLabel?: string;
} }
const ManaCurve = ({ deck }: { deck: any[] }) => { const ManaCurve = ({ deck }: { deck: any[] }) => {
@@ -176,6 +179,40 @@ const ListItem: React.FC<{ card: DraftCard; onClick?: () => void; onHover?: (c:
); );
}; };
const DeckCardItem = ({ card, useArtCrop, isFoil, onCardClick, onHover }: any) => {
const displayImage = useArtCrop ? card.imageArtCrop : card.image;
const { onTouchStart, onTouchEnd, onTouchMove, onClick } = useCardTouch(onHover, () => {
if (window.matchMedia('(pointer: coarse)').matches) {
onHover(card);
} else {
onCardClick(card);
}
}, card);
return (
<div
onClick={onClick}
onMouseEnter={() => onHover(card)}
onMouseLeave={() => onHover(null)}
onTouchStart={onTouchStart}
onTouchEnd={onTouchEnd}
onTouchMove={onTouchMove}
className="relative group bg-slate-900 rounded-lg shrink-0 cursor-pointer hover:scale-105 transition-transform"
>
<div className={`relative ${useArtCrop ? 'aspect-square' : 'aspect-[2.5/3.5]'} overflow-hidden rounded-lg shadow-xl border transition-all duration-200 group-hover:ring-2 group-hover:ring-purple-400 group-hover:shadow-purple-500/30 ${isFoil ? 'border-purple-400 shadow-purple-500/20' : 'border-slate-800'}`}>
{isFoil && <FoilOverlay />}
{isFoil && <div className="absolute top-1 right-1 z-30 text-[10px] font-bold text-white bg-purple-600/80 px-1.5 rounded backdrop-blur-sm">FOIL</div>}
{displayImage ? (
<img src={displayImage} alt={card.name} className="w-full h-full object-cover" draggable={false} />
) : (
<div className="w-full h-full flex items-center justify-center text-xs text-center p-1 text-slate-500 font-bold border-2 border-slate-700 m-1 rounded">{card.name}</div>
)}
<div className={`absolute bottom-0 left-0 right-0 h-1.5 ${card.rarity === 'mythic' ? 'bg-gradient-to-r from-orange-500 to-red-600' : card.rarity === 'rare' ? 'bg-gradient-to-r from-yellow-400 to-yellow-600' : card.rarity === 'uncommon' ? 'bg-gradient-to-r from-gray-300 to-gray-500' : 'bg-black'}`} />
</div>
</div>
);
};
// Extracted Component to avoid re-mounting issues // Extracted Component to avoid re-mounting issues
const CardsDisplay: React.FC<{ const CardsDisplay: React.FC<{
cards: any[]; cards: any[];
@@ -273,7 +310,7 @@ const CardsDisplay: React.FC<{
) )
}; };
export const DeckBuilderView: React.FC<DeckBuilderViewProps> = ({ initialPool, availableBasicLands = [] }) => { export const DeckBuilderView: React.FC<DeckBuilderViewProps> = ({ initialPool, initialDeck = [], availableBasicLands = [], onSubmit, submitLabel }) => {
// Unlimited Timer (Static for now) // Unlimited Timer (Static for now)
const [timer] = useState<string>("Unlimited"); const [timer] = useState<string>("Unlimited");
/* --- Hooks --- */ /* --- Hooks --- */
@@ -359,8 +396,17 @@ export const DeckBuilderView: React.FC<DeckBuilderViewProps> = ({ initialPool, a
useEffect(() => localStorage.setItem('deck_groupBy', groupBy), [groupBy]); useEffect(() => localStorage.setItem('deck_groupBy', groupBy), [groupBy]);
useEffect(() => localStorage.setItem('deck_cardWidth', cardWidth.toString()), [cardWidth]); useEffect(() => localStorage.setItem('deck_cardWidth', cardWidth.toString()), [cardWidth]);
const [pool, setPool] = useState<any[]>(initialPool); const [deck, setDeck] = useState<any[]>(initialDeck);
const [deck, setDeck] = useState<any[]>([]); const [pool, setPool] = useState<any[]>(() => {
if (initialDeck && initialDeck.length > 0) {
// Need to be careful about IDs.
// If initialDeck cards are from the pool, they share IDs?
// Usually yes.
const deckIds = new Set(initialDeck.map(c => c.id));
return initialPool.filter(c => !deckIds.has(c.id));
}
return initialPool;
});
// const [lands, setLands] = useState(...); // REMOVED: Managed directly in deck now // const [lands, setLands] = useState(...); // REMOVED: Managed directly in deck now
const [hoveredCard, setHoveredCard] = useState<any>(null); const [hoveredCard, setHoveredCard] = useState<any>(null);
const [displayCard, setDisplayCard] = useState<any>(null); const [displayCard, setDisplayCard] = useState<any>(null);
@@ -498,7 +544,13 @@ export const DeckBuilderView: React.FC<DeckBuilderViewProps> = ({ initialPool, a
return cardWithDefinition; return cardWithDefinition;
}); });
if (onSubmit) {
onSubmit(preparedDeck);
} else {
socketService.socket.emit('player_ready', { deck: preparedDeck }); socketService.socket.emit('player_ready', { deck: preparedDeck });
}
}; };
const handleAutoBuild = async () => { const handleAutoBuild = async () => {
@@ -876,7 +928,7 @@ export const DeckBuilderView: React.FC<DeckBuilderViewProps> = ({ initialPool, a
onClick={submitDeck} onClick={submitDeck}
className="bg-emerald-600 hover:bg-emerald-500 text-white px-4 py-2 rounded-lg font-bold shadow-lg flex items-center gap-2 transition-transform hover:scale-105 text-sm" className="bg-emerald-600 hover:bg-emerald-500 text-white px-4 py-2 rounded-lg font-bold shadow-lg flex items-center gap-2 transition-transform hover:scale-105 text-sm"
> >
<Save className="w-4 h-4" /> <span className="hidden sm:inline">Submit Deck</span><span className="sm:hidden">Save</span> <Save className="w-4 h-4" /> <span className="hidden sm:inline">{submitLabel || 'Submit Deck'}</span><span className="sm:hidden">Save</span>
</button> </button>
</div> </div>
</div> </div>
@@ -1004,36 +1056,4 @@ export const DeckBuilderView: React.FC<DeckBuilderViewProps> = ({ initialPool, a
); );
}; };
const DeckCardItem = ({ card, useArtCrop, isFoil, onCardClick, onHover }: any) => {
const displayImage = useArtCrop ? card.imageArtCrop : card.image;
const { onTouchStart, onTouchEnd, onTouchMove, onClick } = useCardTouch(onHover, () => {
if (window.matchMedia('(pointer: coarse)').matches) {
onHover(card);
} else {
onCardClick(card);
}
}, card);
return (
<div
onClick={onClick}
onMouseEnter={() => onHover(card)}
onMouseLeave={() => onHover(null)}
onTouchStart={onTouchStart}
onTouchEnd={onTouchEnd}
onTouchMove={onTouchMove}
className="relative group bg-slate-900 rounded-lg shrink-0 cursor-pointer hover:scale-105 transition-transform"
>
<div className={`relative ${useArtCrop ? 'aspect-square' : 'aspect-[2.5/3.5]'} overflow-hidden rounded-lg shadow-xl border transition-all duration-200 group-hover:ring-2 group-hover:ring-purple-400 group-hover:shadow-purple-500/30 ${isFoil ? 'border-purple-400 shadow-purple-500/20' : 'border-slate-800'}`}>
{isFoil && <FoilOverlay />}
{isFoil && <div className="absolute top-1 right-1 z-30 text-[10px] font-bold text-white bg-purple-600/80 px-1.5 rounded backdrop-blur-sm">FOIL</div>}
{displayImage ? (
<img src={displayImage} alt={card.name} className="w-full h-full object-cover" draggable={false} />
) : (
<div className="w-full h-full flex items-center justify-center text-xs text-center p-1 text-slate-500 font-bold border-2 border-slate-700 m-1 rounded">{card.name}</div>
)}
<div className={`absolute bottom-0 left-0 right-0 h-1.5 ${card.rarity === 'mythic' ? 'bg-gradient-to-r from-orange-500 to-red-600' : card.rarity === 'rare' ? 'bg-gradient-to-r from-yellow-400 to-yellow-600' : card.rarity === 'uncommon' ? 'bg-gradient-to-r from-gray-300 to-gray-500' : 'bg-black'}`} />
</div>
</div>
);
};

View File

@@ -77,7 +77,10 @@ export const MulliganView: React.FC<MulliganViewProps> = ({ hand, mulliganCount,
{/* Controls */} {/* Controls */}
<div className="flex gap-8"> <div className="flex gap-8">
<button <button
onClick={() => onDecision(false, [])} onClick={() => {
console.log("Mulligan Clicked");
onDecision(false, []);
}}
className="px-8 py-4 bg-red-600/20 hover:bg-red-600/40 border border-red-500 text-red-100 rounded-xl font-bold text-lg transition-all flex flex-col items-center gap-1 group" className="px-8 py-4 bg-red-600/20 hover:bg-red-600/40 border border-red-500 text-red-100 rounded-xl font-bold text-lg transition-all flex flex-col items-center gap-1 group"
> >
<span>Mulligan</span> <span>Mulligan</span>
@@ -85,7 +88,12 @@ export const MulliganView: React.FC<MulliganViewProps> = ({ hand, mulliganCount,
</button> </button>
<button <button
onClick={() => isSelectionValid && onDecision(true, Array.from(selectedToBottom))} onClick={() => {
if (isSelectionValid) {
console.log("Keep Hand Clicked", Array.from(selectedToBottom));
onDecision(true, Array.from(selectedToBottom));
}
}}
disabled={!isSelectionValid} disabled={!isSelectionValid}
className={`px-8 py-4 rounded-xl font-bold text-lg transition-all flex flex-col items-center gap-1 min-w-[200px] ${isSelectionValid className={`px-8 py-4 rounded-xl font-bold text-lg transition-all flex flex-col items-center gap-1 min-w-[200px] ${isSelectionValid
? 'bg-emerald-600 hover:bg-emerald-500 text-white shadow-[0_0_20px_rgba(16,185,129,0.4)]' ? 'bg-emerald-600 hover:bg-emerald-500 text-white shadow-[0_0_20px_rgba(16,185,129,0.4)]'

View File

@@ -1,7 +1,7 @@
import React, { useMemo } from 'react'; import React, { useMemo } from 'react';
import { GameState, Phase, Step } from '../../types/game'; import { GameState, Phase, Step } from '../../types/game';
import { ManaIcon } from '../../components/ManaIcon'; import { ManaIcon } from '../../components/ManaIcon';
import { Shield, Swords, Hourglass, Zap, Hand, ChevronRight, XCircle, Play, Clock, Files, Crosshair, Skull, Flag, Moon, Trash2 } from 'lucide-react'; import { Shield, Swords, Hourglass, Zap, Hand, ChevronRight, XCircle, Clock, Files, Crosshair, Skull, Flag, Moon, Trash2 } from 'lucide-react';
interface PhaseStripProps { interface PhaseStripProps {
gameState: GameState; gameState: GameState;
@@ -75,7 +75,7 @@ export const PhaseStrip: React.FC<PhaseStripProps> = ({
else actionLabel = "Pass"; else actionLabel = "Pass";
} else { } else {
// Resolve // Resolve
const topItem = gameState.stack![gameState.stack!.length - 1]; // const topItem = gameState.stack![gameState.stack!.length - 1]; // Unused
actionLabel = "Resolve"; actionLabel = "Resolve";
actionType = 'PASS_PRIORITY'; actionType = 'PASS_PRIORITY';
ActionIcon = Zap; ActionIcon = Zap;

View File

@@ -6,6 +6,7 @@ import { Modal } from '../../components/Modal';
import { useToast } from '../../components/Toast'; import { useToast } from '../../components/Toast';
import { GameView } from '../game/GameView'; import { GameView } from '../game/GameView';
import { DraftView } from '../draft/DraftView'; import { DraftView } from '../draft/DraftView';
import { TournamentManager as TournamentView } from '../tournament/TournamentManager';
import { DeckBuilderView } from '../draft/DeckBuilderView'; import { DeckBuilderView } from '../draft/DeckBuilderView';
interface Player { interface Player {
@@ -71,6 +72,8 @@ export const GameRoom: React.FC<GameRoomProps> = ({ room: initialRoom, currentPl
const messagesEndRef = useRef<HTMLDivElement>(null); const messagesEndRef = useRef<HTMLDivElement>(null);
const [gameState, setGameState] = useState<any>(initialGameState || null); const [gameState, setGameState] = useState<any>(initialGameState || null);
const [draftState, setDraftState] = useState<any>(initialDraftState || null); const [draftState, setDraftState] = useState<any>(initialDraftState || null);
const [tournamentState, setTournamentState] = useState<any>((initialRoom as any).tournament || null);
const [preparingMatchId, setPreparingMatchId] = useState<string | null>(null);
const [mobileTab, setMobileTab] = useState<'game' | 'chat'>('game'); // Keep for mobile const [mobileTab, setMobileTab] = useState<'game' | 'chat'>('game'); // Keep for mobile
// Derived State // Derived State
@@ -180,14 +183,32 @@ export const GameRoom: React.FC<GameRoomProps> = ({ room: initialRoom, currentPl
setGameState(data); setGameState(data);
}; };
const handleTournamentUpdate = (data: any) => {
setTournamentState(data);
};
// Also handle finish
const handleTournamentFinished = (data: any) => {
showToast(`Tournament Winner: ${data.winner.name}!`, 'success');
};
socket.on('draft_update', handleDraftUpdate); socket.on('draft_update', handleDraftUpdate);
socket.on('draft_error', handleDraftError); socket.on('draft_error', handleDraftError);
socket.on('game_update', handleGameUpdate); socket.on('game_update', handleGameUpdate);
socket.on('tournament_update', handleTournamentUpdate);
socket.on('tournament_finished', handleTournamentFinished);
socket.on('match_start', () => {
setPreparingMatchId(null);
});
return () => { return () => {
socket.off('draft_update', handleDraftUpdate); socket.off('draft_update', handleDraftUpdate);
socket.off('draft_error', handleDraftError); socket.off('draft_error', handleDraftError);
socket.off('game_update', handleGameUpdate); socket.off('game_update', handleGameUpdate);
socket.off('tournament_update', handleTournamentUpdate);
socket.off('tournament_finished', handleTournamentFinished);
socket.off('match_start');
}; };
}, []); }, []);
@@ -271,6 +292,29 @@ export const GameRoom: React.FC<GameRoomProps> = ({ room: initialRoom, currentPl
return <DeckBuilderView roomId={room.id} currentPlayerId={currentPlayerId} initialPool={myPool} availableBasicLands={room.basicLands} />; return <DeckBuilderView roomId={room.id} currentPlayerId={currentPlayerId} initialPool={myPool} availableBasicLands={room.basicLands} />;
} }
if (room.status === 'tournament' && tournamentState) {
if (preparingMatchId) {
const myTournamentPlayer = tournamentState.players.find((p: any) => p.id === currentPlayerId);
const myPool = draftState?.players[currentPlayerId]?.pool || [];
const myDeck = myTournamentPlayer?.deck || [];
return <DeckBuilderView
roomId={room.id}
currentPlayerId={currentPlayerId}
initialPool={myPool}
initialDeck={myDeck}
availableBasicLands={room.basicLands}
onSubmit={(deck) => {
socketService.socket.emit('match_ready', { matchId: preparingMatchId, deck });
setPreparingMatchId(null);
showToast("Deck ready! Waiting for game to start...", 'success');
}}
submitLabel="Ready for Match"
/>;
}
return <TournamentView tournament={tournamentState} currentPlayerId={currentPlayerId} onJoinMatch={setPreparingMatchId} />;
}
return ( return (
<div className="flex-1 bg-slate-800 rounded-xl p-6 border border-slate-700 shadow-xl flex flex-col items-center justify-center"> <div className="flex-1 bg-slate-800 rounded-xl p-6 border border-slate-700 shadow-xl flex flex-col items-center justify-center">
<h2 className="text-3xl font-bold text-white mb-4">Waiting for Players...</h2> <h2 className="text-3xl font-bold text-white mb-4">Waiting for Players...</h2>

View File

@@ -1,97 +1,119 @@
import React, { useState } from 'react'; import React from 'react';
import { Users } from 'lucide-react'; import { Trophy, Play } from 'lucide-react';
import { useToast } from '../../components/Toast'; import { socketService } from '../../services/SocketService';
interface TournamentPlayer {
id: string;
name: string;
isBot: boolean;
}
interface Match { interface Match {
id: number; id: string;
p1: string; round: number;
p2: string; matchIndex: number;
player1: TournamentPlayer | null;
player2: TournamentPlayer | null;
winnerId?: string;
status: 'pending' | 'ready' | 'in_progress' | 'finished';
} }
interface Bracket { interface Tournament {
round1: Match[]; id: string;
totalPlayers: number; players: TournamentPlayer[];
rounds: Match[][];
currentRound: number;
status: 'setup' | 'active' | 'finished';
winner?: TournamentPlayer;
} }
export const TournamentManager: React.FC = () => { interface TournamentManagerProps {
const [playerInput, setPlayerInput] = useState(''); tournament: Tournament;
const [bracket, setBracket] = useState<Bracket | null>(null); currentPlayerId: string;
const { showToast } = useToast(); onJoinMatch: (matchId: string) => void;
const shuffleArray = (array: any[]) => {
let currentIndex = array.length, randomIndex;
const newArray = [...array];
while (currentIndex !== 0) {
randomIndex = Math.floor(Math.random() * currentIndex);
currentIndex--;
[newArray[currentIndex], newArray[randomIndex]] = [newArray[randomIndex], newArray[currentIndex]];
}
return newArray;
};
const generateBracket = () => {
if (!playerInput.trim()) return;
const names = playerInput.split('\n').filter(n => n.trim() !== '').map(n => n.trim());
if (names.length < 2) {
showToast("Enter at least 2 players.", 'error');
return;
} }
const shuffled = shuffleArray(names); export const TournamentManager: React.FC<TournamentManagerProps> = ({ tournament, currentPlayerId, onJoinMatch }) => {
const nextPowerOf2 = Math.pow(2, Math.ceil(Math.log2(shuffled.length))); const { rounds, winner } = tournament;
const byesNeeded = nextPowerOf2 - shuffled.length;
const fullRoster = [...shuffled]; const handleJoinMatch = (matchId: string) => {
for (let i = 0; i < byesNeeded; i++) fullRoster.push("BYE"); socketService.socket.emit('join_match', { matchId }, (response: any) => {
if (!response.success) {
const pairings: Match[] = []; console.error(response.message);
for (let i = 0; i < fullRoster.length; i += 2) { // Ideally show toast
pairings.push({ id: i, p1: fullRoster[i], p2: fullRoster[i + 1] }); alert(response.message); // Fallback
} else {
onJoinMatch(matchId);
} }
});
setBracket({ round1: pairings, totalPlayers: names.length });
}; };
return ( return (
<div className="h-full overflow-y-auto max-w-4xl mx-auto p-4 md:p-6"> <div className="h-full overflow-y-auto max-w-6xl mx-auto p-4 md:p-6 text-slate-100">
<div className="bg-slate-800 rounded-xl p-6 border border-slate-700 shadow-xl mb-8">
<h2 className="text-xl font-bold text-white mb-4 flex items-center gap-2"> {/* Header */}
<Users className="w-5 h-5 text-blue-400" /> Players <div className="bg-slate-800 rounded-xl p-6 border border-slate-700 shadow-xl mb-8 flex justify-between items-center">
<div>
<h2 className="text-2xl font-bold text-white flex items-center gap-2">
<Trophy className="w-6 h-6 text-yellow-500" /> Tournament Bracket
</h2> </h2>
<p className="text-sm text-slate-400 mb-2">Enter one name per line</p> <p className="text-slate-400 text-sm mt-1">Round {tournament.currentRound}</p>
<textarea </div>
className="w-full h-32 bg-slate-900 border border-slate-700 rounded-lg p-3 text-sm text-slate-300 focus:ring-2 focus:ring-blue-500 outline-none resize-none mb-4" {winner && (
placeholder={`Player 1\nPlayer 2...`} <div className="bg-yellow-500/20 border border-yellow-500 text-yellow-200 px-6 py-3 rounded-xl flex items-center gap-3 animate-pulse">
value={playerInput} <Trophy className="w-8 h-8" />
onChange={(e) => setPlayerInput(e.target.value)} <div>
/> <div className="text-xs uppercase font-bold tracking-wider">Winner</div>
<button <div className="text-xl font-bold">{winner.name}</div>
onClick={generateBracket} </div>
className="bg-blue-600 hover:bg-blue-500 text-white px-6 py-2 rounded-lg font-bold w-full md:w-auto transition-colors" </div>
> )}
Generate Bracket
</button>
</div> </div>
{bracket && ( <div className="flex gap-8 overflow-x-auto pb-8 snap-x">
<div className="bg-slate-800 rounded-xl p-6 border border-slate-700 shadow-xl overflow-x-auto"> {rounds.map((roundMatches, roundIndex) => (
<h3 className="text-lg font-bold text-white mb-6 border-b border-slate-700 pb-2">Round 1 (Single Elimination)</h3> <div key={roundIndex} className="flex flex-col justify-center gap-16 min-w-[280px] snap-center">
<div className="flex flex-col gap-4 min-w-[300px]"> <h3 className="text-center font-bold text-slate-500 uppercase tracking-widest text-sm mb-4">
{bracket.round1.map((match, i) => ( {roundIndex === rounds.length - 1 ? "Finals" : `Round ${roundIndex + 1}`}
<div key={i} className="bg-slate-900 border border-slate-700 rounded-lg p-4 flex flex-col gap-2 relative"> </h3>
<div className="absolute -left-3 top-1/2 w-3 h-px bg-slate-600"></div> <div className="flex flex-col gap-8 justify-center flex-1">
<div className="flex justify-between items-center bg-slate-800/50 p-2 rounded border border-slate-700/50"> {roundMatches.map((match) => {
<span className={match.p1 === 'BYE' ? 'text-slate-500 italic' : 'font-bold text-white'}>{match.p1}</span> const isMyMatch = (match.player1?.id === currentPlayerId || match.player2?.id === currentPlayerId);
const isPlayable = isMyMatch && match.status === 'ready' && !match.winnerId;
return (
<div key={match.id} className={`bg-slate-900 border ${isMyMatch ? 'border-blue-500 ring-1 ring-blue-500/50' : 'border-slate-700'} rounded-lg p-0 overflow-hidden relative shadow-lg`}>
{/* Status Indicator */}
{match.status === 'in_progress' && <div className="absolute top-0 right-0 bg-green-500 text-xs text-black font-bold px-2 py-0.5">LIVE</div>}
<div className={`p-3 border-b border-slate-800 flex justify-between items-center ${match.winnerId === match.player1?.id ? 'bg-emerald-900/30' : ''}`}>
<span className={match.player1 ? 'font-bold' : 'text-slate-600 italic'}>
{match.player1 ? match.player1.name : 'Waiting...'}
</span>
{match.winnerId === match.player1?.id && <Trophy className="w-4 h-4 text-emerald-500" />}
</div> </div>
<div className="text-xs text-center text-slate-500">VS</div> <div className={`p-3 flex justify-between items-center ${match.winnerId === match.player2?.id ? 'bg-emerald-900/30' : ''}`}>
<div className="flex justify-between items-center bg-slate-800/50 p-2 rounded border border-slate-700/50"> <span className={match.player2 ? 'font-bold' : 'text-slate-600 italic'}>
<span className={match.p2 === 'BYE' ? 'text-slate-500 italic' : 'font-bold text-white'}>{match.p2}</span> {match.player2 ? match.player2.name : 'Waiting...'}
</span>
{match.winnerId === match.player2?.id && <Trophy className="w-4 h-4 text-emerald-500" />}
</div>
{isPlayable && (
<button
onClick={() => handleJoinMatch(match.id)}
className="w-full bg-blue-600 hover:bg-blue-500 text-white font-bold py-2 flex items-center justify-center gap-2 transition-colors"
>
<Play className="w-4 h-4" /> Play Match
</button>
)}
</div>
);
})}
</div> </div>
</div> </div>
))} ))}
</div> </div>
</div> </div>
)}
</div>
); );
}; };

View File

@@ -11,6 +11,13 @@ class SocketService {
this.socket = io(URL, { this.socket = io(URL, {
autoConnect: false autoConnect: false
}); });
// Debug Wrapper
const originalEmit = this.socket.emit;
this.socket.emit = (event: string, ...args: any[]) => {
console.log(`[Socket] 📤 Emitting: ${event}`, args);
return originalEmit.apply(this.socket, [event, ...args]);
};
} }
connect() { connect() {

View File

@@ -429,10 +429,15 @@ export class RulesEngine {
// 0. Mulligan Step // 0. Mulligan Step
if (step === 'mulligan') { if (step === 'mulligan') {
const total = Object.keys(this.state.players).length;
const kept = Object.values(this.state.players).filter(p => p.handKept).length;
console.log(`[RulesEngine] Performing Mulligan TBA. Kept: ${kept}/${total}`);
// Draw 7 for everyone if they have 0 cards in hand and haven't kept // Draw 7 for everyone if they have 0 cards in hand and haven't kept
Object.values(this.state.players).forEach(p => { Object.values(this.state.players).forEach(p => {
const hand = Object.values(this.state.cards).filter(c => c.ownerId === p.id && c.zone === 'hand'); const hand = Object.values(this.state.cards).filter(c => c.ownerId === p.id && c.zone === 'hand');
if (hand.length === 0 && !p.handKept) { if (hand.length === 0 && !p.handKept) {
console.log(`[RulesEngine] Initial Draw 7 for ${p.name}`);
// Initial Draw // Initial Draw
for (let i = 0; i < 7; i++) { for (let i = 0; i < 7; i++) {
this.drawCard(p.id); this.drawCard(p.id);
@@ -442,10 +447,12 @@ export class RulesEngine {
// Check if all kept // Check if all kept
const allKept = Object.values(this.state.players).every(p => p.handKept); const allKept = Object.values(this.state.players).every(p => p.handKept);
if (allKept) { if (allKept) {
console.log("All players kept hand. Starting game."); console.log("[RulesEngine] All players kept hand. Advancing Step.");
// Normally untap is automatic? // Normally untap is automatic?
// advanceStep will go to beginning/untap // advanceStep will go to beginning/untap
this.advanceStep(); this.advanceStep();
} else {
console.log("[RulesEngine] Waiting for more mulligan decisions.");
} }
return; // Wait for actions return; // Wait for actions
} }

View File

@@ -7,6 +7,7 @@ import { fileURLToPath } from 'url';
import { RoomManager } from './managers/RoomManager'; import { RoomManager } from './managers/RoomManager';
import { GameManager } from './managers/GameManager'; import { GameManager } from './managers/GameManager';
import { DraftManager } from './managers/DraftManager'; import { DraftManager } from './managers/DraftManager';
import { TournamentManager } from './managers/TournamentManager';
import { CardService } from './services/CardService'; import { CardService } from './services/CardService';
import { ScryfallService } from './services/ScryfallService'; import { ScryfallService } from './services/ScryfallService';
import { PackGeneratorService } from './services/PackGeneratorService'; import { PackGeneratorService } from './services/PackGeneratorService';
@@ -31,8 +32,35 @@ const io = new Server(httpServer, {
const roomManager = new RoomManager(); const roomManager = new RoomManager();
const gameManager = new GameManager(); const gameManager = new GameManager();
const draftManager = new DraftManager(); const draftManager = new DraftManager();
const tournamentManager = new TournamentManager();
const persistenceManager = new PersistenceManager(roomManager, draftManager, gameManager); const persistenceManager = new PersistenceManager(roomManager, draftManager, gameManager);
// Game Over Listener
gameManager.on('game_over', ({ gameId, winnerId }) => {
console.log(`[Index] Game Over received: ${gameId}, Winner: ${winnerId}`);
// Find tournament by Room? We need a way to map matchId -> roomId?
// Or matchId is unique enough?
// Wait, I used gameId = matchId for 1v1.
// Iterate all tournaments to find the match? Inefficient but works.
// Ideally we track mapping.
// For now, let's assume we can find it.
// TODO: Optimise lookup
// Actually, RoomManager knows the tournament.
// We can scan rooms?
// Let's implement recordMatchResult that searches if needed, or pass roomId in event?
// checkWinCondition passes roomId as gameId...
// Ah, 1v1 match gameId will be the matchId (e.g. "r1-m0").
// We need the RoomId too.
// Let's pass roomId in metadata to createGame?
// For now, checkWinCondition(game, gameId).
// Hack: We iterate rooms to find the tournament that contains this matchId.
// TODO: Fix efficiency
});
// Load previous state // Load previous state
persistenceManager.load(); persistenceManager.load();
@@ -281,40 +309,20 @@ const draftInterval = setInterval(() => {
// Check if EVERYONE is ready to start game automatically // Check if EVERYONE is ready to start game automatically
const activePlayers = room.players.filter(p => p.role === 'player'); const activePlayers = room.players.filter(p => p.role === 'player');
if (activePlayers.length > 0 && activePlayers.every(p => p.ready)) { if (activePlayers.length > 0 && activePlayers.every(p => p.ready)) {
console.log(`All players ready (including bots) in room ${roomId}. Starting game.`); console.log(`All players ready (including bots) in room ${roomId}. Starting TOURNAMENT.`);
room.status = 'playing'; room.status = 'tournament';
io.to(roomId).emit('room_update', room); io.to(roomId).emit('room_update', room);
const game = gameManager.createGame(roomId, room.players); // Create Tournament
const tournament = tournamentManager.createTournament(roomId, room.players.map(p => ({
id: p.id,
name: p.name,
isBot: !!p.isBot,
deck: p.deck
})));
// Populate Decks room.tournament = tournament;
activePlayers.forEach(p => { io.to(roomId).emit('tournament_update', tournament);
if (p.deck) {
p.deck.forEach((card: any) => {
gameManager.addCardToGame(roomId, {
ownerId: p.id,
controllerId: p.id,
oracleId: card.oracle_id || card.id,
name: card.name,
imageUrl: card.image || card.image_uris?.normal || card.card_faces?.[0]?.image_uris?.normal || "",
zone: 'library',
typeLine: card.typeLine || card.type_line || '',
oracleText: card.oracleText || card.oracle_text || '',
manaCost: card.manaCost || card.mana_cost || '',
keywords: card.keywords || [],
power: card.power,
toughness: card.toughness,
damageMarked: 0,
controlledSinceTurn: 0
});
});
}
});
const engine = new RulesEngine(game);
engine.startGame();
gameManager.triggerBotCheck(roomId);
io.to(roomId).emit('game_update', game);
} }
} }
} }
@@ -411,7 +419,12 @@ io.on('connection', (socket) => {
if (currentDraft) socket.emit('draft_update', currentDraft); if (currentDraft) socket.emit('draft_update', currentDraft);
} }
callback({ success: true, room, draftState: currentDraft }); if (room.status === 'tournament' && room.tournament) {
socket.emit('tournament_update', room.tournament);
// Assuming join_room is initial join, probably not in a match yet unless re-joining
}
callback({ success: true, room, draftState: currentDraft, tournament: room.tournament });
} else { } else {
callback({ success: false, message: 'Room not found or full' }); callback({ success: false, message: 'Room not found or full' });
} }
@@ -451,11 +464,27 @@ io.on('connection', (socket) => {
if (room.status === 'playing') { if (room.status === 'playing') {
currentGame = gameManager.getGame(roomId); currentGame = gameManager.getGame(roomId);
if (currentGame) socket.emit('game_update', currentGame); if (currentGame) socket.emit('game_update', currentGame);
} else if (room.status === 'tournament') {
if (room.tournament) {
socket.emit('tournament_update', room.tournament);
// If player was in a match
// We need to check if they have a matchId in their player object
// room.players is the source of truth
const p = room.players.find(rp => rp.id === playerId);
if (p && p.matchId) {
currentGame = gameManager.getGame(p.matchId);
if (currentGame) {
socket.join(p.matchId); // Re-join socket room
socket.emit('game_update', currentGame);
}
}
}
} }
// ACK Callback // ACK Callback
if (typeof callback === 'function') { if (typeof callback === 'function') {
callback({ success: true, room, draftState: currentDraft, gameState: currentGame }); callback({ success: true, room, draftState: currentDraft, gameState: currentGame, tournament: room.tournament });
} }
} else { } else {
// Room found but player not in it? Or room not found? // Room found but player not in it? Or room not found?
@@ -609,39 +638,17 @@ io.on('connection', (socket) => {
io.to(room.id).emit('room_update', updatedRoom); io.to(room.id).emit('room_update', updatedRoom);
const activePlayers = updatedRoom.players.filter(p => p.role === 'player'); const activePlayers = updatedRoom.players.filter(p => p.role === 'player');
if (activePlayers.length > 0 && activePlayers.every(p => p.ready)) { if (activePlayers.length > 0 && activePlayers.every(p => p.ready)) {
updatedRoom.status = 'playing'; updatedRoom.status = 'tournament';
io.to(room.id).emit('room_update', updatedRoom); io.to(room.id).emit('room_update', updatedRoom);
const game = gameManager.createGame(room.id, updatedRoom.players); const tournament = tournamentManager.createTournament(room.id, updatedRoom.players.map(p => ({
activePlayers.forEach(p => { id: p.id,
if (p.deck) { name: p.name,
p.deck.forEach((card: any) => { isBot: !!p.isBot,
gameManager.addCardToGame(room.id, { deck: p.deck
ownerId: p.id, })));
controllerId: p.id, updatedRoom.tournament = tournament;
oracleId: card.oracle_id || card.id, io.to(room.id).emit('tournament_update', tournament);
name: card.name,
imageUrl: card.image || card.image_uris?.normal || card.card_faces?.[0]?.image_uris?.normal || "",
zone: 'library',
typeLine: card.typeLine || card.type_line || '',
oracleText: card.oracleText || card.oracle_text || '',
manaCost: card.manaCost || card.mana_cost || '',
keywords: card.keywords || [],
power: card.power, // Add Power
toughness: card.toughness, // Add Toughness
damageMarked: 0,
controlledSinceTurn: 0
});
});
}
});
// Initialize Game State (Draw Hands)
const engine = new RulesEngine(game);
engine.startGame();
gameManager.triggerBotCheck(room.id);
io.to(room.id).emit('game_update', game);
} }
} }
}); });
@@ -714,9 +721,12 @@ io.on('connection', (socket) => {
if (!context) return; if (!context) return;
const { room, player } = context; const { room, player } = context;
const game = gameManager.handleAction(room.id, action, player.id); // Fix: If in a match (Tournament), actions go to matchId, not roomId
const targetGameId = player.matchId || room.id;
const game = gameManager.handleAction(targetGameId, action, player.id);
if (game) { if (game) {
io.to(room.id).emit('game_update', game); io.to(game.roomId).emit('game_update', game);
} }
}); });
@@ -725,9 +735,107 @@ io.on('connection', (socket) => {
if (!context) return; if (!context) return;
const { room, player } = context; const { room, player } = context;
const game = gameManager.handleStrictAction(room.id, action, player.id); // Fix: If in a match (Tournament), actions go to matchId, not roomId
const targetGameId = player.matchId || room.id;
const game = gameManager.handleStrictAction(targetGameId, action, player.id);
if (game) { if (game) {
io.to(room.id).emit('game_update', game); io.to(game.roomId).emit('game_update', game);
}
});
socket.on('join_match', ({ matchId }, callback) => {
const context = getContext();
if (!context) return;
const { room, player } = context;
if (!room.tournament) {
callback({ success: false, message: "No active tournament." });
return;
}
const match = tournamentManager.getMatch(room.tournament, matchId);
if (!match) {
callback({ success: false, message: "Match not found." });
return;
}
if (match.status === 'pending') {
callback({ success: false, message: "Match is pending." });
return;
}
// Check if Game Exists (Maybe it was already created by the other player becoming ready?)
let game = gameManager.getGame(matchId);
// Join Socket to Match Room
socket.join(matchId);
player.matchId = matchId; // Track match
// If game exists (both players already ready), send it
if (game) {
socket.emit('game_update', game);
}
callback({ success: true, match, gameCreated: !!game });
});
socket.on('match_ready', ({ matchId, deck }) => {
const context = getContext();
if (!context) return;
const { room, player } = context;
if (!room.tournament) return;
const readyState = tournamentManager.setMatchReady(room.id, matchId, player.id, deck);
if (readyState?.bothReady) {
console.log(`[Index] Both players ready for match ${matchId}. Starting Game.`);
const match = tournamentManager.getMatch(room.tournament, matchId);
if (match && match.player1 && match.player2) {
const p1 = room.players.find(p => p.id === match.player1!.id)!;
const p2 = room.players.find(p => p.id === match.player2!.id)!;
// Get Decks from Ready State (stored in tournament manager)
const deck1 = readyState.decks[p1.id];
const deck2 = readyState.decks[p2.id];
const game = gameManager.createGame(matchId, [
{ id: p1.id, name: p1.name, isBot: p1.isBot },
{ id: p2.id, name: p2.name, isBot: p2.isBot }
]);
// Populate Decks
[{ p: p1, d: deck1 }, { p: p2, d: deck2 }].forEach(({ p, d }) => {
if (d) {
d.forEach((card: any) => {
gameManager.addCardToGame(matchId, {
ownerId: p.id,
controllerId: p.id,
oracleId: card.oracle_id || card.id,
name: card.name,
imageUrl: card.image || card.image_uris?.normal || card.card_faces?.[0]?.image_uris?.normal || "",
zone: 'library',
typeLine: card.typeLine || card.type_line || '',
oracleText: card.oracleText || card.oracle_text || '',
manaCost: card.manaCost || card.mana_cost || '',
keywords: card.keywords || [],
power: card.power,
toughness: card.toughness,
damageMarked: 0,
controlledSinceTurn: 0
});
});
}
});
const engine = new RulesEngine(game);
engine.startGame();
gameManager.triggerBotCheck(matchId);
io.to(matchId).emit('game_update', game);
io.to(matchId).emit('match_start', { gameId: matchId });
}
} }
}); });

View File

@@ -2,10 +2,12 @@
import { StrictGameState, PlayerState, CardObject } from '../game/types'; import { StrictGameState, PlayerState, CardObject } from '../game/types';
import { RulesEngine } from '../game/RulesEngine'; import { RulesEngine } from '../game/RulesEngine';
export class GameManager { import { EventEmitter } from 'events';
export class GameManager extends EventEmitter {
public games: Map<string, StrictGameState> = new Map(); public games: Map<string, StrictGameState> = new Map();
createGame(roomId: string, players: { id: string; name: string; isBot?: boolean }[]): StrictGameState { createGame(gameId: string, players: { id: string; name: string; isBot?: boolean }[]): StrictGameState {
// Convert array to map // Convert array to map
const playerRecord: Record<string, PlayerState> = {}; const playerRecord: Record<string, PlayerState> = {};
@@ -26,7 +28,7 @@ export class GameManager {
const firstPlayerId = players.length > 0 ? players[0].id : ''; const firstPlayerId = players.length > 0 ? players[0].id : '';
const gameState: StrictGameState = { const gameState: StrictGameState = {
roomId, roomId: gameId,
players: playerRecord, players: playerRecord,
cards: {}, // Populated later cards: {}, // Populated later
stack: [], stack: [],
@@ -50,7 +52,7 @@ export class GameManager {
gameState.players[firstPlayerId].isActive = true; gameState.players[firstPlayerId].isActive = true;
} }
this.games.set(roomId, gameState); this.games.set(gameId, gameState);
return gameState; return gameState;
} }
@@ -150,14 +152,51 @@ export class GameManager {
// Bot Cycle: If priority passed to a bot, or it's a bot's turn to act // Bot Cycle: If priority passed to a bot, or it's a bot's turn to act
const MAX_LOOPS = 50; const MAX_LOOPS = 50;
let loops = 0; let loops = 0;
// Special Bot Handling for Mulligan (Simultaneous actions allowed, or strict priority ignored by bots)
if (game.step === 'mulligan') {
console.log(`[GameManager] Checking Bot Mulligans for ${game.roomId}`);
Object.values(game.players).forEach(p => {
if (p.isBot && !p.handKept) {
console.log(`[GameManager] Forcing Bot ${p.name} to keep hand.`);
try {
// Bots always keep for now
engine.resolveMulligan(p.id, true, []);
} catch (e) {
console.warn(`[Bot Mulligan Error] ${p.name}:`, e);
}
}
});
}
while (game.players[game.priorityPlayerId]?.isBot && loops < MAX_LOOPS) { while (game.players[game.priorityPlayerId]?.isBot && loops < MAX_LOOPS) {
loops++; loops++;
this.processBotActions(game); this.processBotActions(game);
} }
// Check Win Condition
this.checkWinCondition(game, roomId);
return game; return game;
} }
// Check if game is over
public checkWinCondition(game: StrictGameState, gameId: string) {
const alivePlayers = Object.values(game.players).filter(p => p.life > 0 && p.poison < 10);
// 1v1 Logic
if (alivePlayers.length === 1 && Object.keys(game.players).length > 1) {
// Winner found
const winner = alivePlayers[0];
// Only emit once
if (game.phase !== 'ending') {
console.log(`[GameManager] Game Over. Winner: ${winner.name}`);
this.emit('game_over', { gameId, winnerId: winner.id });
game.phase = 'ending'; // Mark as ending so we don't double emit
}
}
}
// --- Bot AI Logic --- // --- Bot AI Logic ---
private processBotActions(game: StrictGameState) { private processBotActions(game: StrictGameState) {
const engine = new RulesEngine(game); const engine = new RulesEngine(game);

View File

@@ -1,3 +1,5 @@
import { Tournament } from './TournamentManager';
interface Player { interface Player {
id: string; id: string;
name: string; name: string;
@@ -8,6 +10,7 @@ interface Player {
socketId?: string; // Current or last known socket socketId?: string; // Current or last known socket
isOffline?: boolean; isOffline?: boolean;
isBot?: boolean; isBot?: boolean;
matchId?: string; // Current match in tournament
} }
interface ChatMessage { interface ChatMessage {
@@ -23,10 +26,11 @@ interface Room {
players: Player[]; players: Player[];
packs: any[]; // Store generated packs (JSON) packs: any[]; // Store generated packs (JSON)
basicLands?: any[]; basicLands?: any[];
status: 'waiting' | 'drafting' | 'deck_building' | 'playing' | 'finished'; status: 'waiting' | 'drafting' | 'deck_building' | 'playing' | 'finished' | 'tournament';
messages: ChatMessage[]; messages: ChatMessage[];
maxPlayers: number; maxPlayers: number;
lastActive: number; // For persistence cleanup lastActive: number; // For persistence cleanup
tournament?: Tournament | null;
} }
export class RoomManager { export class RoomManager {

View File

@@ -0,0 +1,275 @@
import { EventEmitter } from 'events';
export interface TournamentPlayer {
id: string;
name: string;
isBot: boolean;
deck?: any[]; // Snapshot of deck
}
export interface Match {
id: string; // "round-X-match-Y"
round: number;
matchIndex: number; // 0-based index in the round
player1: TournamentPlayer | null; // Null if bye or waiting for previous match
player2: TournamentPlayer | null;
winnerId?: string;
status: 'pending' | 'ready' | 'in_progress' | 'finished';
startTime?: number;
endTime?: number;
readyPlayers: string[]; // IDs of players who have submitted deck
}
export interface Tournament {
id: string; // usually roomId
players: TournamentPlayer[];
rounds: Match[][]; // Array of rounds, each containing matches
currentRound: number;
status: 'setup' | 'active' | 'finished';
winner?: TournamentPlayer;
}
export class TournamentManager extends EventEmitter {
private tournaments: Map<string, Tournament> = new Map();
createTournament(roomId: string, players: TournamentPlayer[]): Tournament {
// 1. Shuffle Players
const shuffled = [...players].sort(() => Math.random() - 0.5);
// 2. Generate Bracket (Single Elimination)
// Calc next power of 2
const total = shuffled.length;
const size = Math.pow(2, Math.ceil(Math.log2(total)));
const byes = size - total;
// Distribute byes? Simple method: Add "Bye" players, then resolved them immediately.
// Actually, let's keep it robust.
// Round 1:
// Proper Roster with Byes
const roster: (TournamentPlayer | null)[] = [...shuffled];
while (roster.length < size) {
roster.push(null); // Null = BYE
}
// Create Rounds recursively? Or just Round 1 and empty slots for others?
// Let's pre-allocate the structure
const rounds: Match[][] = [];
let currentSize = size;
let roundNum = 1;
while (currentSize > 1) {
const matchCount = currentSize / 2;
const roundMatches: Match[] = [];
for (let i = 0; i < matchCount; i++) {
roundMatches.push({
id: `r${roundNum}-m${i}`,
round: roundNum,
matchIndex: i,
player1: null,
player2: null,
status: 'pending',
readyPlayers: []
});
}
rounds.push(roundMatches);
currentSize = matchCount;
roundNum++;
}
// Fill Round 1
const r1 = rounds[0];
for (let i = 0; i < r1.length; i++) {
r1[i].player1 = roster[i * 2];
r1[i].player2 = roster[i * 2 + 1];
r1[i].status = 'ready'; // Potential auto-resolve if Bye
}
const t: Tournament = {
id: roomId,
players,
rounds,
currentRound: 1,
status: 'active'
};
this.tournaments.set(roomId, t);
// Auto-resolve Byes and potentially Bot vs Bot in Round 1
this.checkAutoResolutions(t);
return t;
}
getTournament(roomId: string): Tournament | undefined {
return this.tournaments.get(roomId);
}
// Called when a game ends or a Bye is processed
recordMatchResult(roomId: string, matchId: string, winnerId: string): Tournament | null {
const t = this.tournaments.get(roomId);
if (!t) return null;
// Find match
let match: Match | undefined;
for (const r of t.rounds) {
match = r.find(m => m.id === matchId);
if (match) break;
}
if (!match) return null;
if (match.status === 'finished') return t; // Already done
// Verify winner is part of match
const winner = (match.player1?.id === winnerId) ? match.player1 : (match.player2?.id === winnerId) ? match.player2 : null;
if (!winner) {
// Maybe it was a Bye resolution where winnerId is valid?
// If bye, player2 is null, winner is player1.
if (match.player2 === null && match.player1?.id === winnerId) {
// ok
} else if (match.player1 === null && match.player2?.id === winnerId) {
// ok
} else {
console.warn(`Invalid winner ${winnerId} for match ${matchId}`);
return null;
}
}
match.status = 'finished';
match.winnerId = winnerId;
match.endTime = Date.now();
// Advance Winner to Next Round
this.advanceToNextRound(t, match, winnerId);
// Trigger further auto-resolutions (e.g. if next match is now Bot vs Bot)
this.checkAutoResolutions(t);
return t;
}
private advanceToNextRound(t: Tournament, match: Match, winnerId: string) {
// Logic: Match M in Round R feeds into Match floor(M/2) in Round R+1
// If M is even (0, 2), it is Player 1 of next match.
// If M is odd (1, 3), it is Player 2 of next match.
const nextRoundIdx = match.round; // rounds is 0-indexed array, so round 1 is at index 0. Next round is at index 1.
// Wait, I stored round as 1-based in Match interface.
// rounds[0] = Make Round 1
// rounds[1] = Make Round 2
if (nextRoundIdx >= t.rounds.length) {
// Tournament Over
t.status = 'finished';
t.winner = t.players.find(p => p.id === winnerId);
return;
}
const nextRound = t.rounds[nextRoundIdx];
const nextMatchIndex = Math.floor(match.matchIndex / 2);
const nextMatch = nextRound[nextMatchIndex];
if (!nextMatch) {
console.error("Critical: Next match not found in bracket logic.");
return;
}
// Determine slot
const winner = t.players.find(p => p.id === winnerId);
if (match.matchIndex % 2 === 0) {
nextMatch.player1 = winner || null;
} else {
nextMatch.player2 = winner || null;
}
// Check if next match is now ready
if (nextMatch.player1 && nextMatch.player2) {
nextMatch.status = 'ready';
}
// If one is BYE (null)?
// My roster logic filled byes as nulls.
// If we have a Bye in Step 1, it resolves.
// In later rounds, null means "Waiting for opponent".
// So status remains 'pending'.
}
private checkAutoResolutions(t: Tournament) {
// Currently we check ALL rounds because a fast resolution might cascade
for (const r of t.rounds) {
for (const m of r) {
if (m.status !== 'ready') continue;
// 1. Check Byes (Player vs Null)
if (m.player1 && !m.player2) {
console.log(`[Tournament] Auto-resolving Bye for ${m.player1.name} in ${m.id}`);
this.recordMatchResult(t.id, m.id, m.player1.id);
continue;
}
// (Should not happen with my filler logic, but symetrically)
if (!m.player1 && m.player2) {
this.recordMatchResult(t.id, m.id, m.player2.id);
continue;
}
// 2. Check Bot vs Bot
if (m.player1?.isBot && m.player2?.isBot) {
// Coin flip
const winner = Math.random() > 0.5 ? m.player1 : m.player2;
console.log(`[Tournament] Auto-resolving Bot Match ${m.id}: ${m.player1.name} vs ${m.player2.name} -> Winner: ${winner.name}`);
this.recordMatchResult(t.id, m.id, winner.id);
}
}
}
}
// For frontend to know connection status
getMatch(t: Tournament, matchId: string): Match | undefined {
for (const r of t.rounds) {
const m = r.find(x => x.id === matchId);
if (m) return m;
}
return undefined;
}
setMatchReady(roomId: string, matchId: string, playerId: string, deck: any[]): { bothReady: boolean, decks: Record<string, any[]> } | null {
const t = this.getTournament(roomId);
if (!t) return null;
const match = this.getMatch(t, matchId);
if (!match) return null;
// Update Player Deck in Tournament Roster
const player = t.players.find(p => p.id === playerId);
if (player) {
player.deck = deck;
}
// Add to Ready
if (!match.readyPlayers.includes(playerId)) {
match.readyPlayers.push(playerId);
}
// Check if both ready
const p1 = match.player1;
const p2 = match.player2;
if (p1 && p2) {
const p1Ready = p1.isBot || match.readyPlayers.includes(p1.id);
const p2Ready = p2.isBot || match.readyPlayers.includes(p2.id);
if (p1Ready && p2Ready) {
match.status = 'in_progress'; // lock it
// Return decks
const p1Deck = t.players.find(p => p.id === p1.id)?.deck || [];
const p2Deck = t.players.find(p => p.id === p2.id)?.deck || [];
return { bothReady: true, decks: { [p1.id]: p1Deck, [p2.id]: p2Deck } };
}
}
return { bothReady: false, decks: {} };
}
}