Add Sikinian Parliament puzzle
Create a new playable puzzle based on the Sikinian Parliament problem. The puzzle features 10 individuals in a circular layout with randomly assigned enemy relationships (at most three per person). Users can color individuals blue or pink, and edges between individuals of the same color are highlighted in red if they have more than one same-colored enemy. The puzzle includes start, timer, high score, and share features, and is added to the Puzzles page.
This commit is contained in:
parent
146cc4cdcd
commit
2e75dfc30e
4 changed files with 380 additions and 0 deletions
|
|
@ -46,6 +46,7 @@ import Graphs from "./pages/theme-pages/discrete-math/Graphs";
|
|||
import NotFound from "./pages/NotFound";
|
||||
import GridTilingPuzzlePage from './pages/GridTilingPuzzlePage';
|
||||
import SunnyLinesPuzzlePage from './pages/SunnyLinesPuzzlePage';
|
||||
import SikiniaParliamentPuzzlePage from './pages/SikiniaParliamentPuzzlePage';
|
||||
|
||||
const queryClient = new QueryClient();
|
||||
|
||||
|
|
@ -98,6 +99,7 @@ const App = () => (
|
|||
<Route path="/games/nim" element={<NimGamePage />} />
|
||||
<Route path="/games/assisted-nim" element={<AssistedNimGamePage />} />
|
||||
<Route path="/games/gold-coin" element={<GoldCoinGamePage />} />
|
||||
<Route path="/puzzles/sikinia-parliament" element={<SikiniaParliamentPuzzlePage />} />
|
||||
<Route path="/puzzles/plate-swap" element={<PlateSwapPuzzlePage />} />
|
||||
<Route path="/puzzles/chessboard-repaint" element={<ChessboardRepaintPuzzlePage />} />
|
||||
<Route path="/discrete-math/foundations/zeckendorf" element={<ZeckendorfGamePage />} />
|
||||
|
|
|
|||
356
src/components/SikiniaParliamentPuzzle.tsx
Normal file
356
src/components/SikiniaParliamentPuzzle.tsx
Normal file
|
|
@ -0,0 +1,356 @@
|
|||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Alert, AlertDescription } from '@/components/ui/alert';
|
||||
import { Play, RotateCcw, Trophy, Clock, Info, ChevronDown, ChevronUp } from 'lucide-react';
|
||||
import SocialShare from '@/components/SocialShare';
|
||||
|
||||
interface Person {
|
||||
id: number;
|
||||
x: number;
|
||||
y: number;
|
||||
color: 'grey' | 'blue' | 'pink';
|
||||
}
|
||||
|
||||
interface Edge {
|
||||
from: number;
|
||||
to: number;
|
||||
isViolation: boolean;
|
||||
}
|
||||
|
||||
interface SikiniaParliamentPuzzleProps {
|
||||
showSocialShare?: boolean;
|
||||
}
|
||||
|
||||
const SikiniaParliamentPuzzle: React.FC<SikiniaParliamentPuzzleProps> = ({ showSocialShare = false }) => {
|
||||
const [people, setPeople] = useState<Person[]>([]);
|
||||
const [edges, setEdges] = useState<Edge[]>([]);
|
||||
const [gameStarted, setGameStarted] = useState(false);
|
||||
const [timer, setTimer] = useState(0);
|
||||
const [isRunning, setIsRunning] = useState(false);
|
||||
const [bestTime, setBestTime] = useState<number | null>(null);
|
||||
const [isComplete, setIsComplete] = useState(false);
|
||||
const [showExplanation, setShowExplanation] = useState(false);
|
||||
|
||||
// Initialize people in circular layout
|
||||
useEffect(() => {
|
||||
const centerX = 200;
|
||||
const centerY = 200;
|
||||
const radius = 150;
|
||||
const newPeople: Person[] = [];
|
||||
|
||||
for (let i = 0; i < 10; i++) {
|
||||
const angle = (i * 2 * Math.PI) / 10 - Math.PI / 2; // Start from top
|
||||
const x = centerX + radius * Math.cos(angle);
|
||||
const y = centerY + radius * Math.sin(angle);
|
||||
newPeople.push({
|
||||
id: i,
|
||||
x,
|
||||
y,
|
||||
color: 'grey'
|
||||
});
|
||||
}
|
||||
setPeople(newPeople);
|
||||
}, []);
|
||||
|
||||
// Timer effect
|
||||
useEffect(() => {
|
||||
let interval: NodeJS.Timeout;
|
||||
if (isRunning) {
|
||||
interval = setInterval(() => {
|
||||
setTimer(timer => timer + 1);
|
||||
}, 1000);
|
||||
}
|
||||
return () => clearInterval(interval);
|
||||
}, [isRunning]);
|
||||
|
||||
// Generate random graph with max degree 3
|
||||
const generateRandomGraph = useCallback(() => {
|
||||
const newEdges: Edge[] = [];
|
||||
const degrees = new Array(10).fill(0);
|
||||
|
||||
// Try to create edges while respecting max degree constraint
|
||||
for (let attempts = 0; attempts < 100 && newEdges.length < 15; attempts++) {
|
||||
const from = Math.floor(Math.random() * 10);
|
||||
const to = Math.floor(Math.random() * 10);
|
||||
|
||||
if (from !== to &&
|
||||
degrees[from] < 3 &&
|
||||
degrees[to] < 3 &&
|
||||
!newEdges.some(e => (e.from === from && e.to === to) || (e.from === to && e.to === from))) {
|
||||
newEdges.push({ from, to, isViolation: false });
|
||||
degrees[from]++;
|
||||
degrees[to]++;
|
||||
}
|
||||
}
|
||||
|
||||
setEdges(newEdges);
|
||||
}, []);
|
||||
|
||||
// Check for violations and update edge colors
|
||||
const updateViolations = useCallback(() => {
|
||||
setEdges(currentEdges => {
|
||||
return currentEdges.map(edge => {
|
||||
const person1 = people.find(p => p.id === edge.from);
|
||||
const person2 = people.find(p => p.id === edge.to);
|
||||
|
||||
if (!person1 || !person2 || person1.color === 'grey' || person2.color === 'grey') {
|
||||
return { ...edge, isViolation: false };
|
||||
}
|
||||
|
||||
if (person1.color === person2.color) {
|
||||
// Check if either person has more than 1 enemy of their color
|
||||
const person1Enemies = currentEdges.filter(e =>
|
||||
(e.from === person1.id || e.to === person1.id) &&
|
||||
e !== edge
|
||||
).map(e => e.from === person1.id ? e.to : e.from)
|
||||
.map(id => people.find(p => p.id === id))
|
||||
.filter(p => p && p.color === person1.color);
|
||||
|
||||
const person2Enemies = currentEdges.filter(e =>
|
||||
(e.from === person2.id || e.to === person2.id) &&
|
||||
e !== edge
|
||||
).map(e => e.from === person2.id ? e.to : e.from)
|
||||
.map(id => people.find(p => p.id === id))
|
||||
.filter(p => p && p.color === person2.color);
|
||||
|
||||
return { ...edge, isViolation: person1Enemies.length >= 1 || person2Enemies.length >= 1 };
|
||||
}
|
||||
|
||||
return { ...edge, isViolation: false };
|
||||
});
|
||||
});
|
||||
}, [people]);
|
||||
|
||||
// Check if puzzle is solved
|
||||
const checkCompletion = useCallback(() => {
|
||||
if (!gameStarted) return;
|
||||
|
||||
const allColored = people.every(p => p.color !== 'grey');
|
||||
const noViolations = edges.every(e => !e.isViolation);
|
||||
|
||||
if (allColored && noViolations && !isComplete) {
|
||||
setIsComplete(true);
|
||||
setIsRunning(false);
|
||||
|
||||
if (!bestTime || timer < bestTime) {
|
||||
setBestTime(timer);
|
||||
localStorage.setItem('sikinia-best-time', timer.toString());
|
||||
}
|
||||
}
|
||||
}, [people, edges, gameStarted, isComplete, timer, bestTime]);
|
||||
|
||||
useEffect(() => {
|
||||
updateViolations();
|
||||
checkCompletion();
|
||||
}, [people, updateViolations, checkCompletion]);
|
||||
|
||||
// Load best time from localStorage
|
||||
useEffect(() => {
|
||||
const saved = localStorage.getItem('sikinia-best-time');
|
||||
if (saved) {
|
||||
setBestTime(parseInt(saved));
|
||||
}
|
||||
}, []);
|
||||
|
||||
const startGame = () => {
|
||||
generateRandomGraph();
|
||||
setGameStarted(true);
|
||||
setIsRunning(true);
|
||||
setTimer(0);
|
||||
setIsComplete(false);
|
||||
setPeople(people.map(p => ({ ...p, color: 'grey' })));
|
||||
};
|
||||
|
||||
const resetGame = () => {
|
||||
setGameStarted(false);
|
||||
setIsRunning(false);
|
||||
setTimer(0);
|
||||
setIsComplete(false);
|
||||
setEdges([]);
|
||||
setPeople(people.map(p => ({ ...p, color: 'grey' })));
|
||||
};
|
||||
|
||||
const colorPerson = (id: number) => {
|
||||
if (!gameStarted) return;
|
||||
|
||||
setPeople(people.map(person => {
|
||||
if (person.id === id) {
|
||||
const nextColor = person.color === 'grey' ? 'blue' :
|
||||
person.color === 'blue' ? 'pink' : 'grey';
|
||||
return { ...person, color: nextColor };
|
||||
}
|
||||
return person;
|
||||
}));
|
||||
};
|
||||
|
||||
const formatTime = (seconds: number) => {
|
||||
const mins = Math.floor(seconds / 60);
|
||||
const secs = seconds % 60;
|
||||
return `${mins}:${secs.toString().padStart(2, '0')}`;
|
||||
};
|
||||
|
||||
const getPersonColor = (color: string) => {
|
||||
switch (color) {
|
||||
case 'blue': return '#3b82f6';
|
||||
case 'pink': return '#ec4899';
|
||||
default: return '#6b7280';
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background p-6 space-y-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<CardTitle className="flex items-center gap-2 text-2xl">
|
||||
Parliament of Sikinia Puzzle
|
||||
<Badge variant="secondary" className="text-xs">Graph Theory</Badge>
|
||||
</CardTitle>
|
||||
<p className="text-muted-foreground mt-2">
|
||||
Separate parliament members into two houses so each has at most one enemy in their house
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setShowExplanation(!showExplanation)}
|
||||
className="shrink-0"
|
||||
>
|
||||
<Info className="w-4 h-4 mr-2" />
|
||||
Rules
|
||||
{showExplanation ? <ChevronUp className="w-4 h-4 ml-2" /> : <ChevronDown className="w-4 h-4 ml-2" />}
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{showExplanation && (
|
||||
<Alert className="mb-6">
|
||||
<AlertDescription>
|
||||
<strong>Goal:</strong> Color all 10 parliament members blue or pink such that each member has at most one enemy of their own color in their house.<br/>
|
||||
<strong>How to play:</strong> Click grey icons to make them blue, blue to make them pink, pink to make them grey. Red edges indicate violations (someone has more than one enemy of their color).<br/>
|
||||
<strong>Mathematical insight:</strong> This demonstrates that any graph with maximum degree 3 is 2-colorable in a way that each vertex has at most one neighbor of its color.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<div className="flex flex-wrap gap-4 items-center justify-between mb-6">
|
||||
<div className="flex gap-4">
|
||||
{!gameStarted ? (
|
||||
<Button onClick={startGame} className="flex items-center gap-2">
|
||||
<Play className="w-4 h-4" />
|
||||
Start Game
|
||||
</Button>
|
||||
) : (
|
||||
<Button onClick={resetGame} variant="outline" className="flex items-center gap-2">
|
||||
<RotateCcw className="w-4 h-4" />
|
||||
Reset
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex gap-4 items-center">
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<Clock className="w-4 h-4" />
|
||||
<span className="font-mono">{formatTime(timer)}</span>
|
||||
</div>
|
||||
{bestTime && (
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<Trophy className="w-4 h-4" />
|
||||
<span className="font-mono">{formatTime(bestTime)}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isComplete && (
|
||||
<Alert className="mb-6 border-green-200 bg-green-50">
|
||||
<AlertDescription className="text-green-800">
|
||||
🎉 Congratulations! You've successfully separated the parliament in {formatTime(timer)}!
|
||||
{timer === bestTime && " New best time!"}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<div className="flex justify-center">
|
||||
<div className="relative">
|
||||
<svg width="400" height="400" viewBox="0 0 400 400">
|
||||
{/* Draw edges */}
|
||||
{edges.map((edge, index) => {
|
||||
const person1 = people.find(p => p.id === edge.from);
|
||||
const person2 = people.find(p => p.id === edge.to);
|
||||
if (!person1 || !person2) return null;
|
||||
|
||||
return (
|
||||
<line
|
||||
key={index}
|
||||
x1={person1.x}
|
||||
y1={person1.y}
|
||||
x2={person2.x}
|
||||
y2={person2.y}
|
||||
stroke={edge.isViolation ? "#ef4444" : "#d1d5db"}
|
||||
strokeWidth={edge.isViolation ? "3" : "2"}
|
||||
className={edge.isViolation ? "animate-pulse" : ""}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Draw people */}
|
||||
{people.map((person) => (
|
||||
<g key={person.id}>
|
||||
<circle
|
||||
cx={person.x}
|
||||
cy={person.y}
|
||||
r="20"
|
||||
fill={getPersonColor(person.color)}
|
||||
stroke={person.color === 'grey' ? "#374151" : "white"}
|
||||
strokeWidth="2"
|
||||
className="cursor-pointer hover:opacity-80 transition-opacity"
|
||||
onClick={() => colorPerson(person.id)}
|
||||
/>
|
||||
<text
|
||||
x={person.x}
|
||||
y={person.y + 5}
|
||||
textAnchor="middle"
|
||||
className="text-sm font-bold fill-white pointer-events-none"
|
||||
>
|
||||
{person.id + 1}
|
||||
</text>
|
||||
</g>
|
||||
))}
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-center gap-4 mt-6">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-4 h-4 rounded-full bg-gray-500"></div>
|
||||
<span className="text-sm">Unassigned</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-4 h-4 rounded-full bg-blue-500"></div>
|
||||
<span className="text-sm">House 1</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-4 h-4 rounded-full bg-pink-500"></div>
|
||||
<span className="text-sm">House 2</span>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Social Share */}
|
||||
{showSocialShare && (
|
||||
<SocialShare
|
||||
title="Parliament of Sikinia Puzzle"
|
||||
description="Separate parliament members into two houses so each has at most one enemy in their house. A graph theory puzzle about 2-coloring with constraints."
|
||||
url="/puzzles/sikinia-parliament"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SikiniaParliamentPuzzle;
|
||||
12
src/pages/SikiniaParliamentPuzzlePage.tsx
Normal file
12
src/pages/SikiniaParliamentPuzzlePage.tsx
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
import React from 'react';
|
||||
import SikiniaParliamentPuzzle from '@/components/SikiniaParliamentPuzzle';
|
||||
|
||||
const SikiniaParliamentPuzzlePage = () => {
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
<SikiniaParliamentPuzzle showSocialShare={true} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SikiniaParliamentPuzzlePage;
|
||||
|
|
@ -6,6 +6,16 @@ const Puzzles = () => {
|
|||
const navigate = useNavigate();
|
||||
|
||||
const puzzles = [
|
||||
{
|
||||
id: "sikinia-parliament",
|
||||
title: "Parliament of Sikinia Puzzle",
|
||||
description: "Separate parliament members into two houses so each has at most one enemy in their house. A graph theory puzzle!",
|
||||
path: "/puzzles/sikinia-parliament",
|
||||
tags: ["Graph Theory", "Logic", "Coloring"],
|
||||
difficulty: "Intermediate" as const,
|
||||
duration: "5-20 min",
|
||||
participants: "1 player"
|
||||
},
|
||||
{
|
||||
id: "plate-swap",
|
||||
title: "The Plate Swap Puzzle",
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue