import React, { useState, useEffect, useRef } from 'react'; import { Button } from '@/components/ui/button'; import { Slider } from '@/components/ui/slider'; import SocialShare from '@/components/SocialShare'; import { toast } from 'sonner'; interface BulgarianSolitaireProps { showSocialShare?: boolean; } interface Box { id: number; columnIndex: number | null; // null means it's in the container } const BulgarianSolitaire: React.FC = ({ showSocialShare }) => { const [n, setN] = useState(15); const [boxes, setBoxes] = useState([]); const [columns, setColumns] = useState([]); const [isPlaying, setIsPlaying] = useState(false); const [isPaused, setIsPaused] = useState(false); const [draggedBox, setDraggedBox] = useState(null); const [step, setStep] = useState(0); const [isComplete, setIsComplete] = useState(false); const [highlightedBoxes, setHighlightedBoxes] = useState>(new Set()); const [isAnimating, setIsAnimating] = useState(false); const [animationPhase, setAnimationPhase] = useState<'idle' | 'fade-to-red' | 'red-pause' | 'moving' | 'fade-from-red' | 'sorting'>('idle'); const [lastUsedColumn, setLastUsedColumn] = useState(null); const [selectedBoxForMove, setSelectedBoxForMove] = useState(null); const [showInstructions, setShowInstructions] = useState(false); const pauseRef = useRef(false); const totalBoxes = n; // Initialize boxes and columns when n changes useEffect(() => { const newBoxes: Box[] = []; for (let i = 0; i < totalBoxes; i++) { newBoxes.push({ id: i, columnIndex: null }); } setBoxes(newBoxes); setColumns(Array.from({ length: totalBoxes }, () => [])); setStep(0); setIsComplete(false); }, [n, totalBoxes]); const handleDragStart = (e: React.DragEvent, boxId: number) => { setDraggedBox(boxId); e.dataTransfer.effectAllowed = 'move'; }; const handleDragOver = (e: React.DragEvent) => { e.preventDefault(); e.dataTransfer.dropEffect = 'move'; }; const handleDrop = (e: React.DragEvent, columnIndex: number) => { e.preventDefault(); if (draggedBox === null) return; const box = boxes.find(b => b.id === draggedBox); if (!box || box.columnIndex === columnIndex) return; // Remove from previous location if (box.columnIndex !== null) { setColumns(prev => prev.map((col, idx) => idx === box.columnIndex ? col.filter(id => id !== draggedBox) : col )); } // Add to new column setColumns(prev => prev.map((col, idx) => idx === columnIndex ? [...col, draggedBox] : col )); // Update box location setBoxes(prev => prev.map(b => b.id === draggedBox ? { ...b, columnIndex } : b )); // Track last used column setLastUsedColumn(columnIndex); setDraggedBox(null); }; const handleDropToContainer = (e: React.DragEvent) => { e.preventDefault(); if (draggedBox === null) return; const box = boxes.find(b => b.id === draggedBox); if (!box || box.columnIndex === null) return; // Remove from column setColumns(prev => prev.map((col, idx) => idx === box.columnIndex ? col.filter(id => id !== draggedBox) : col )); // Return to container setBoxes(prev => prev.map(b => b.id === draggedBox ? { ...b, columnIndex: null } : b )); setDraggedBox(null); }; const handleBoxClick = (boxId: number) => { const box = boxes.find(b => b.id === boxId); if (!box || box.columnIndex !== null || isPlaying) return; // Select box for moving setSelectedBoxForMove(boxId); }; const handleColumnClick = (e: React.MouseEvent, columnIndex: number) => { e.stopPropagation(); if (selectedBoxForMove === null || isPlaying) return; // Move selected box to this column setColumns(prev => prev.map((col, idx) => idx === columnIndex ? [...col, selectedBoxForMove] : col )); // Update box location setBoxes(prev => prev.map(b => b.id === selectedBoxForMove ? { ...b, columnIndex } : b )); // Track last used column and clear selection setLastUsedColumn(columnIndex); setSelectedBoxForMove(null); }; const handleDoubleClick = (boxId: number) => { const box = boxes.find(b => b.id === boxId); if (!box) return; // If box is in container, move to last used column if (box.columnIndex === null) { if (lastUsedColumn !== null) { // Add to last used column setColumns(prev => prev.map((col, idx) => idx === lastUsedColumn ? [...col, boxId] : col )); // Update box location setBoxes(prev => prev.map(b => b.id === boxId ? { ...b, columnIndex: lastUsedColumn } : b )); } return; } // If box is in a column, remove from current column setColumns(prev => prev.map((col, idx) => idx === box.columnIndex ? col.filter(id => id !== boxId) : col )); // Move to last used column or container if no last used column if (lastUsedColumn !== null && lastUsedColumn !== box.columnIndex) { // Add to last used column setColumns(prev => prev.map((col, idx) => idx === lastUsedColumn ? [...col, boxId] : col )); // Update box location setBoxes(prev => prev.map(b => b.id === boxId ? { ...b, columnIndex: lastUsedColumn } : b )); } else { // Return to container if no last used column or same as current setBoxes(prev => prev.map(b => b.id === boxId ? { ...b, columnIndex: null } : b )); } }; const sleep = (ms: number) => new Promise(resolve => setTimeout(resolve, ms)); const waitForUnpause = async () => { while (pauseRef.current) { await sleep(100); } }; const handlePause = () => { pauseRef.current = true; setIsPaused(true); }; const handleResume = () => { pauseRef.current = false; setIsPaused(false); }; const isTriangularConfiguration = (cols: number[][]) => { const nonEmptyCols = cols.filter(col => col.length > 0).sort((a, b) => a.length - b.length); // Check if n is a triangular number and if columns form the staircase pattern // Find k such that k*(k+1)/2 = n const k = Math.floor((-1 + Math.sqrt(1 + 8 * n)) / 2); if (k * (k + 1) / 2 !== n) return false; // n is not a triangular number if (nonEmptyCols.length !== k) return false; for (let i = 0; i < k; i++) { if (nonEmptyCols[i].length !== i + 1) return false; } return true; }; const simulateBulgarianSolitaire = async () => { if (boxes.filter(b => b.columnIndex === null).length > 0) { toast.error("Please place all boxes in columns before playing!"); return; } setIsPlaying(true); setStep(0); let currentColumns = [...columns]; let stepCount = 0; while (!isTriangularConfiguration(currentColumns) && stepCount < 50) { stepCount++; setStep(stepCount); // Highlight top boxes that will be moved const topBoxes = new Set(); currentColumns.forEach(col => { if (col.length > 0) { topBoxes.add(col[col.length - 1]); } }); setHighlightedBoxes(topBoxes); // Phase 1: Fade to red setAnimationPhase('fade-to-red'); setIsAnimating(true); await sleep(500); // Fade to red animation await waitForUnpause(); // Phase 2: Red pause setAnimationPhase('red-pause'); await sleep(1000); // Pause for 1 second with red blocks await waitForUnpause(); // Phase 3: Move blocks setAnimationPhase('moving'); // Create new pile from non-empty columns const newPile: number[] = []; const updatedColumns = currentColumns.map(col => { if (col.length > 0) { const boxToMove = col[col.length - 1]; // Take from top newPile.push(boxToMove); return col.slice(0, -1); // Remove from column } return col; }); // Add the new pile const emptyColumnIndex = updatedColumns.findIndex(col => col.length === 0); if (emptyColumnIndex !== -1) { updatedColumns[emptyColumnIndex] = newPile; } else { // This shouldn't happen in a properly configured game console.error("No empty column found for new pile"); break; } currentColumns = updatedColumns; setColumns(currentColumns); // Update box positions setBoxes(prev => prev.map(box => { for (let i = 0; i < currentColumns.length; i++) { if (currentColumns[i].includes(box.id)) { return { ...box, columnIndex: i }; } } return box; })); await sleep(500); // Show move await waitForUnpause(); // Phase 4: Sort columns while boxes are still red setAnimationPhase('sorting'); // Sort columns by height (tallest to left) with smooth transition const columnData = currentColumns.map((col, index) => ({ col, originalIndex: index })) .filter(item => item.col.length > 0) .sort((a, b) => b.col.length - a.col.length); const sortedColumns = Array.from({ length: updatedColumns.length }, () => []); columnData.forEach((item, index) => { sortedColumns[index] = item.col; }); currentColumns = sortedColumns; setColumns(currentColumns); // Update box positions setBoxes(prev => prev.map(box => { for (let i = 0; i < currentColumns.length; i++) { if (currentColumns[i].includes(box.id)) { return { ...box, columnIndex: i }; } } return box; })); await sleep(800); // Wait for sorting animation to complete await waitForUnpause(); // Phase 5: Fade from red to normal setAnimationPhase('fade-from-red'); await sleep(500); // Fade from red animation await waitForUnpause(); // Clear highlighting and animation setHighlightedBoxes(new Set()); setIsAnimating(false); setAnimationPhase('idle'); await waitForUnpause(); // Check for pause again } setIsComplete(isTriangularConfiguration(currentColumns)); setIsPlaying(false); if (isTriangularConfiguration(currentColumns)) { toast.success(`Reached triangular configuration in ${stepCount} steps!`); } else { toast.error("Maximum steps reached without convergence"); } }; const reset = () => { pauseRef.current = false; setIsPaused(false); setIsPlaying(false); setDraggedBox(null); setLastUsedColumn(null); setSelectedBoxForMove(null); const newBoxes: Box[] = []; for (let i = 0; i < totalBoxes; i++) { newBoxes.push({ id: i, columnIndex: null }); } setBoxes(newBoxes); setColumns(Array.from({ length: totalBoxes }, () => [])); setStep(0); setIsComplete(false); setHighlightedBoxes(new Set()); setIsAnimating(false); setAnimationPhase('idle'); }; const randomStart = () => { // Reset first reset(); // Randomly distribute all boxes into columns const newBoxes: Box[] = []; const newColumns = Array.from({ length: totalBoxes }, () => []); for (let i = 0; i < totalBoxes; i++) { // Pick a random column index (ensure we use at least some columns) const columnIndex = Math.floor(Math.random() * Math.min(totalBoxes, 8)); // Limit to 8 columns for better visualization newBoxes.push({ id: i, columnIndex }); newColumns[columnIndex].push(i); } setBoxes(newBoxes); setColumns(newColumns); }; const containerBoxes = boxes.filter(box => box.columnIndex === null); const handleBackgroundClick = () => { if (selectedBoxForMove !== null) { setSelectedBoxForMove(null); } }; return (

Bulgarian Solitaire

Choose a number n, then drag the {totalBoxes} boxes into columns.
Note that one column can hold multiple boxes.
Click "Play" to simulate the Bulgarian Solitaire process:
here one box is taken from each non-empty column to form a new column.

Will this process always converge?

{/* Controls */}
setN(value[0])} min={2} max={45} step={1} className="w-32" disabled={isPlaying} />
{/* Container */}
Container
{containerBoxes.map((box) => (
handleDragStart(e, box.id)} onDoubleClick={() => handleDoubleClick(box.id)} onClick={() => handleBoxClick(box.id)} className={`w-8 h-8 bg-primary rounded cursor-pointer hover:bg-primary/80 transition-colors ${ selectedBoxForMove === box.id ? 'ring-2 ring-blue-500 ring-offset-2' : '' }`} >
))}
{/* Columns */}

Columns

{step > 0 && (

Step: {step}

)}
{columns.map((column, index) => (
handleDrop(e, index)} onClick={(e) => handleColumnClick(e, index)} className={`border rounded-lg min-h-[100px] w-12 p-1 bg-background/50 flex flex-col-reverse gap-1 transition-all duration-700 ease-in-out ${ selectedBoxForMove !== null ? 'border-blue-500 border-2 cursor-pointer hover:bg-blue-50' : 'border-muted-foreground' } ${animationPhase === 'sorting' ? 'transform-gpu' : ''}`} style={{ flexBasis: `calc(${100/15}% - 0.25rem)`, minWidth: '48px' }} >
{index + 1}
{column.map((boxId, boxIndex) => (
handleDragStart(e, boxId)} onDoubleClick={() => handleDoubleClick(boxId)} className={`w-8 h-8 rounded cursor-move transition-all duration-300 flex items-center justify-center text-xs font-medium mx-auto ${ highlightedBoxes.has(boxId) && animationPhase === 'fade-to-red' ? 'animate-fade-to-red shadow-lg text-white' : highlightedBoxes.has(boxId) && (animationPhase === 'red-pause' || animationPhase === 'moving' || animationPhase === 'sorting') ? 'bg-red-500 text-white shadow-lg' : highlightedBoxes.has(boxId) && animationPhase === 'fade-from-red' ? 'animate-fade-from-red shadow-lg' : 'bg-primary text-primary-foreground hover:bg-primary/80' }`} title="Drag to move or double-click to move to last-used column" >
))}
))}
{/* Play Button */}
{/* Status */} {isComplete && (

Triangular Configuration Reached! 🎉

The Bulgarian Solitaire process converged to the stable triangular configuration in {step} steps.

)} {/* Instructions */}
{showInstructions && (
  • Choose a value for n (2-45) using the slider
  • Drag the {totalBoxes} boxes from the container into any of the columns
  • Drag boxes between columns to rearrange them as needed
  • Drag boxes back to the container or double-click any box in a column to move it to the last-used column
  • Once all boxes are placed in columns, click "Play" to start the simulation
  • Watch as the algorithm takes one box from each non-empty column to form a new column
  • Spoiler: For triangular numbers, the process continues until reaching the staircase configuration: columns of sizes 1, 2, 3, ..., n.
)}
{/* Credits */}

Find out more about Bulgarian Solitaire on{" "} Wikipedia . I learned of this game from{" "} Akash Kumar , who very kindly walked me through the argument found in{" "} Peter Winkler's {" "}beautiful puzzle book,{" "} available here . The game was introduced by{" "} Martin Gardner .

{showSocialShare && ( )}
); }; export default BulgarianSolitaire;