Add AscDesc grid puzzle

Implement interactive Ascending-Descending Grid Puzzle (Puzzles theme)
- New AscDescGridPuzzle component with grid, A/D labels, number palette, validation, and hints
- New AscDescGridPuzzlePage and route integration
- Hook into App routing to include new puzzle in puzzles list

X-Lovable-Edit-ID: edt-5f6c4886-712d-47ac-ae85-3364cbbeb22e
This commit is contained in:
gpt-engineer-app[bot] 2026-01-13 01:40:36 +00:00
commit c2d2cfa990
4 changed files with 463 additions and 0 deletions

View file

@ -64,6 +64,7 @@ import FerrersRogersRamanujanPage from './pages/FerrersRogersRamanujanPage';
import DominoRetilingPuzzlePage from './pages/DominoRetilingPuzzlePage'; import DominoRetilingPuzzlePage from './pages/DominoRetilingPuzzlePage';
import RentDivisionPuzzlePage from './pages/RentDivisionPuzzlePage'; import RentDivisionPuzzlePage from './pages/RentDivisionPuzzlePage';
import BagchalGamePage from './pages/BagchalGamePage'; import BagchalGamePage from './pages/BagchalGamePage';
import AscDescGridPuzzlePage from './pages/AscDescGridPuzzlePage';
const queryClient = new QueryClient(); const queryClient = new QueryClient();
@ -138,6 +139,7 @@ const App = () => (
<Route path="/puzzles/chessboard-repaint" element={<ChessboardRepaintPuzzlePage />} /> <Route path="/puzzles/chessboard-repaint" element={<ChessboardRepaintPuzzlePage />} />
<Route path="/puzzles/n-queens" element={<NQueensPuzzlePage />} /> <Route path="/puzzles/n-queens" element={<NQueensPuzzlePage />} />
<Route path="/puzzles/domino-retiling" element={<DominoRetilingPuzzlePage />} /> <Route path="/puzzles/domino-retiling" element={<DominoRetilingPuzzlePage />} />
<Route path="/puzzles/asc-desc-grid" element={<AscDescGridPuzzlePage />} />
<Route path="/discrete-math/foundations/zeckendorf" element={<ZeckendorfGamePage />} /> <Route path="/discrete-math/foundations/zeckendorf" element={<ZeckendorfGamePage />} />
<Route path="/discrete-math/foundations/zeckendorf-search" element={<ZeckendorfSearchTrickPage />} /> <Route path="/discrete-math/foundations/zeckendorf-search" element={<ZeckendorfSearchTrickPage />} />
<Route path="/discrete-math/foundations/rules-of-inference" element={<RulesOfInferencePlaygroundPage />} /> <Route path="/discrete-math/foundations/rules-of-inference" element={<RulesOfInferencePlaygroundPage />} />

View file

@ -0,0 +1,437 @@
import { useState, useCallback } from "react";
import { Button } from "@/components/ui/button";
import { Slider } from "@/components/ui/slider";
import { Badge } from "@/components/ui/badge";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { RotateCcw, Check, Shuffle, Lightbulb } from "lucide-react";
import SocialShare from "./SocialShare";
type Label = 'A' | 'D';
interface AscDescGridPuzzleProps {
showSocialShare?: boolean;
}
const AscDescGridPuzzle = ({ showSocialShare = true }: AscDescGridPuzzleProps) => {
const [gridSize, setGridSize] = useState(3);
const [rowLabels, setRowLabels] = useState<Label[]>(['A', 'D', 'A']);
const [colLabels, setColLabels] = useState<Label[]>(['A', 'D', 'A']);
const [grid, setGrid] = useState<(number | null)[][]>(
Array(3).fill(null).map(() => Array(3).fill(null))
);
const [selectedNumber, setSelectedNumber] = useState<number | null>(null);
const [validationResult, setValidationResult] = useState<{
isValid: boolean;
message: string;
errors: { row?: number; col?: number; type: string }[];
} | null>(null);
const [showHint, setShowHint] = useState(false);
// Get all numbers that have been placed
const getPlacedNumbers = useCallback((): Set<number> => {
const placed = new Set<number>();
for (let r = 0; r < gridSize; r++) {
for (let c = 0; c < gridSize; c++) {
if (grid[r]?.[c] !== null && grid[r]?.[c] !== undefined) {
placed.add(grid[r][c] as number);
}
}
}
return placed;
}, [grid, gridSize]);
// Reset the puzzle
const resetPuzzle = useCallback(() => {
setGrid(Array(gridSize).fill(null).map(() => Array(gridSize).fill(null)));
setSelectedNumber(null);
setValidationResult(null);
setShowHint(false);
}, [gridSize]);
// Change grid size
const handleGridSizeChange = (value: number[]) => {
const newSize = value[0];
setGridSize(newSize);
setRowLabels(Array(newSize).fill('A').map((_, i) => (i % 2 === 0 ? 'A' : 'D') as Label));
setColLabels(Array(newSize).fill('A').map((_, i) => (i % 2 === 0 ? 'A' : 'D') as Label));
setGrid(Array(newSize).fill(null).map(() => Array(newSize).fill(null)));
setSelectedNumber(null);
setValidationResult(null);
setShowHint(false);
};
// Toggle row label
const toggleRowLabel = (index: number) => {
setRowLabels(prev => {
const newLabels = [...prev];
newLabels[index] = newLabels[index] === 'A' ? 'D' : 'A';
return newLabels;
});
setValidationResult(null);
};
// Toggle column label
const toggleColLabel = (index: number) => {
setColLabels(prev => {
const newLabels = [...prev];
newLabels[index] = newLabels[index] === 'A' ? 'D' : 'A';
return newLabels;
});
setValidationResult(null);
};
// Randomize labels
const randomizeLabels = () => {
setRowLabels(Array(gridSize).fill(null).map(() => (Math.random() > 0.5 ? 'A' : 'D') as Label));
setColLabels(Array(gridSize).fill(null).map(() => (Math.random() > 0.5 ? 'A' : 'D') as Label));
resetPuzzle();
};
// Handle cell click
const handleCellClick = (row: number, col: number) => {
if (selectedNumber === null) {
// If clicking on a cell with a number, remove it
if (grid[row][col] !== null) {
setGrid(prev => {
const newGrid = prev.map(r => [...r]);
newGrid[row][col] = null;
return newGrid;
});
setValidationResult(null);
}
return;
}
// Place the selected number
setGrid(prev => {
const newGrid = prev.map(r => [...r]);
// Remove the number from any previous position
for (let r = 0; r < gridSize; r++) {
for (let c = 0; c < gridSize; c++) {
if (newGrid[r][c] === selectedNumber) {
newGrid[r][c] = null;
}
}
}
newGrid[row][col] = selectedNumber;
return newGrid;
});
setSelectedNumber(null);
setValidationResult(null);
};
// Check if puzzle is complete and valid
const validatePuzzle = () => {
const errors: { row?: number; col?: number; type: string }[] = [];
const totalNumbers = gridSize * gridSize;
const placed = getPlacedNumbers();
// Check if all numbers are placed
if (placed.size !== totalNumbers) {
setValidationResult({
isValid: false,
message: `Place all numbers from 1 to ${totalNumbers} first.`,
errors: []
});
return;
}
// Check rows
for (let r = 0; r < gridSize; r++) {
const row = grid[r];
const label = rowLabels[r];
for (let c = 0; c < gridSize - 1; c++) {
const current = row[c] as number;
const next = row[c + 1] as number;
if (label === 'A' && current >= next) {
errors.push({ row: r, type: 'row' });
break;
}
if (label === 'D' && current <= next) {
errors.push({ row: r, type: 'row' });
break;
}
}
}
// Check columns
for (let c = 0; c < gridSize; c++) {
const label = colLabels[c];
for (let r = 0; r < gridSize - 1; r++) {
const current = grid[r][c] as number;
const next = grid[r + 1][c] as number;
if (label === 'A' && current >= next) {
errors.push({ col: c, type: 'col' });
break;
}
if (label === 'D' && current <= next) {
errors.push({ col: c, type: 'col' });
break;
}
}
}
if (errors.length === 0) {
setValidationResult({
isValid: true,
message: "🎉 Congratulations! The puzzle is solved correctly!",
errors: []
});
} else {
setValidationResult({
isValid: false,
message: `Found ${errors.length} constraint violation(s). Check highlighted rows/columns.`,
errors
});
}
};
// Check for circular constraint (unsolvable pattern)
const checkSolvability = (): { solvable: boolean; reason?: string } => {
// Check for the circular constraint pattern described in the paper
// If rows i < j have labels D,A (or A,D) and columns p < q have labels A,D (or D,A),
// then we have a circular constraint making it unsolvable
for (let i = 0; i < gridSize; i++) {
for (let j = i + 1; j < gridSize; j++) {
// Check if rows i and j have opposite labels
if ((rowLabels[i] === 'D' && rowLabels[j] === 'A') ||
(rowLabels[i] === 'A' && rowLabels[j] === 'D')) {
for (let p = 0; p < gridSize; p++) {
for (let q = p + 1; q < gridSize; q++) {
// Check if columns p and q have opposite labels in the "wrong" way
const rowPattern = rowLabels[i] === 'D' ? 'DA' : 'AD';
const colPattern = colLabels[p] === 'A' ? 'AD' : 'DA';
// The circular constraint occurs when:
// rows i,j are D,A and columns p,q are A,D (or vice versa pattern)
if ((rowLabels[i] === 'D' && rowLabels[j] === 'A' &&
colLabels[p] === 'A' && colLabels[q] === 'D') ||
(rowLabels[i] === 'A' && rowLabels[j] === 'D' &&
colLabels[p] === 'D' && colLabels[q] === 'A')) {
return {
solvable: false,
reason: `Circular constraint detected: rows ${i+1},${j+1} (${rowLabels[i]},${rowLabels[j]}) and columns ${p+1},${q+1} (${colLabels[p]},${colLabels[q]}) create an impossible configuration.`
};
}
}
}
}
}
}
return { solvable: true };
};
const solvabilityCheck = checkSolvability();
const placedNumbers = getPlacedNumbers();
const totalNumbers = gridSize * gridSize;
const hasRowError = (row: number) =>
validationResult?.errors.some(e => e.row === row && e.type === 'row');
const hasColError = (col: number) =>
validationResult?.errors.some(e => e.col === col && e.type === 'col');
return (
<div className="w-full max-w-4xl mx-auto p-4 space-y-6">
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
Ascending-Descending Grid Puzzle
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<p className="text-muted-foreground">
Fill the grid with numbers 1 to n² such that rows labeled <Badge variant="secondary">A</Badge> are
in <strong>ascending</strong> order (leftright) and rows labeled <Badge variant="outline">D</Badge> are
in <strong>descending</strong> order. The same applies to columns (topbottom).
</p>
<p className="text-muted-foreground text-sm">
Click on row/column labels to toggle between A and D. Click a number, then click a cell to place it.
</p>
</CardContent>
</Card>
{/* Controls */}
<Card>
<CardContent className="pt-6 space-y-4">
<div className="flex flex-wrap gap-4 items-center">
<div className="flex-1 min-w-[200px]">
<label className="text-sm font-medium mb-2 block">
Grid Size: {gridSize} × {gridSize}
</label>
<Slider
value={[gridSize]}
onValueChange={handleGridSizeChange}
min={2}
max={5}
step={1}
className="w-full"
/>
</div>
<div className="flex gap-2">
<Button onClick={randomizeLabels} variant="outline" size="sm">
<Shuffle className="w-4 h-4 mr-1" /> Randomize
</Button>
<Button onClick={resetPuzzle} variant="outline" size="sm">
<RotateCcw className="w-4 h-4 mr-1" /> Reset
</Button>
<Button onClick={() => setShowHint(!showHint)} variant="outline" size="sm">
<Lightbulb className="w-4 h-4 mr-1" /> {showHint ? 'Hide' : 'Show'} Hint
</Button>
</div>
</div>
{showHint && (
<div className="p-3 bg-muted rounded-lg text-sm">
<strong>Hint:</strong> Some configurations are unsolvable! A circular constraint occurs when
rows i {"<"} j have labels D,A (in that order) while columns p {"<"} q have labels A,D (in that order),
or vice versa. Try to avoid these patterns.
</div>
)}
{!solvabilityCheck.solvable && (
<div className="p-3 bg-destructive/10 border border-destructive/20 rounded-lg text-sm text-destructive">
{solvabilityCheck.reason}
</div>
)}
</CardContent>
</Card>
{/* Number Palette */}
<Card>
<CardContent className="pt-6">
<div className="mb-2 text-sm font-medium">
Select a number to place ({placedNumbers.size}/{totalNumbers} placed):
</div>
<div className="flex flex-wrap gap-2">
{Array.from({ length: totalNumbers }, (_, i) => i + 1).map(num => {
const isPlaced = placedNumbers.has(num);
const isSelected = selectedNumber === num;
return (
<Button
key={num}
variant={isSelected ? "default" : isPlaced ? "ghost" : "outline"}
size="sm"
className={`w-10 h-10 ${isPlaced && !isSelected ? 'opacity-40' : ''}`}
onClick={() => setSelectedNumber(isSelected ? null : num)}
>
{num}
</Button>
);
})}
</div>
</CardContent>
</Card>
{/* Grid */}
<div className="flex justify-center">
<div className="inline-block">
{/* Column labels */}
<div className="flex">
<div className="w-10 h-10" /> {/* Empty corner */}
{colLabels.map((label, c) => (
<button
key={`col-${c}`}
onClick={() => toggleColLabel(c)}
className={`w-12 h-10 flex items-center justify-center font-bold text-lg cursor-pointer transition-colors rounded-t
${hasColError(c) ? 'bg-destructive/20 text-destructive' : 'hover:bg-muted'}
${label === 'A' ? 'text-green-600 dark:text-green-400' : 'text-blue-600 dark:text-blue-400'}`}
>
{label}
</button>
))}
</div>
{/* Grid rows */}
{Array.from({ length: gridSize }, (_, r) => (
<div key={`row-${r}`} className="flex">
{/* Row label */}
<button
onClick={() => toggleRowLabel(r)}
className={`w-10 h-12 flex items-center justify-center font-bold text-lg cursor-pointer transition-colors rounded-l
${hasRowError(r) ? 'bg-destructive/20 text-destructive' : 'hover:bg-muted'}
${rowLabels[r] === 'A' ? 'text-green-600 dark:text-green-400' : 'text-blue-600 dark:text-blue-400'}`}
>
{rowLabels[r]}
</button>
{/* Grid cells */}
{Array.from({ length: gridSize }, (_, c) => {
const value = grid[r]?.[c];
const hasError = hasRowError(r) || hasColError(c);
return (
<button
key={`cell-${r}-${c}`}
onClick={() => handleCellClick(r, c)}
className={`w-12 h-12 border border-border flex items-center justify-center text-lg font-semibold
transition-all cursor-pointer
${value !== null ? 'bg-primary/10' : 'bg-background hover:bg-muted'}
${hasError && validationResult ? 'bg-destructive/10' : ''}
${selectedNumber !== null ? 'hover:bg-primary/20' : ''}
`}
>
{value !== null ? value : ''}
</button>
);
})}
</div>
))}
</div>
</div>
{/* Validate Button */}
<div className="flex justify-center">
<Button onClick={validatePuzzle} size="lg" disabled={placedNumbers.size !== totalNumbers}>
<Check className="w-5 h-5 mr-2" /> Check Solution
</Button>
</div>
{/* Validation Result */}
{validationResult && (
<Card className={validationResult.isValid ? 'border-green-500' : 'border-destructive'}>
<CardContent className="pt-6">
<p className={`text-center font-medium ${validationResult.isValid ? 'text-green-600 dark:text-green-400' : 'text-destructive'}`}>
{validationResult.message}
</p>
</CardContent>
</Card>
)}
{/* Instructions */}
<Card>
<CardHeader>
<CardTitle className="text-lg">How to Play</CardTitle>
</CardHeader>
<CardContent className="space-y-2 text-sm text-muted-foreground">
<ol className="list-decimal list-inside space-y-1">
<li>The grid has row labels on the left and column labels on top</li>
<li><Badge variant="secondary" className="mx-1">A</Badge> means ascending order (numbers increase leftright or topbottom)</li>
<li><Badge variant="outline" className="mx-1">D</Badge> means descending order (numbers decrease leftright or topbottom)</li>
<li>Click on labels to toggle between A and D</li>
<li>Click a number from the palette, then click a cell to place it</li>
<li>Click a placed number in the grid to remove it</li>
<li>Fill all cells with numbers 1 to n² satisfying all constraints</li>
<li>Some label configurations are unsolvable the puzzle will warn you!</li>
</ol>
</CardContent>
</Card>
{showSocialShare && (
<SocialShare
title="Ascending-Descending Grid Puzzle"
description="Fill the grid so each row and column follows its ascending or descending constraint!"
/>
)}
</div>
);
};
export default AscDescGridPuzzle;

View file

@ -0,0 +1,14 @@
import Layout from "@/components/Layout";
import AscDescGridPuzzle from "@/components/AscDescGridPuzzle";
const AscDescGridPuzzlePage = () => {
return (
<Layout>
<div className="py-8">
<AscDescGridPuzzle />
</div>
</Layout>
);
};
export default AscDescGridPuzzlePage;

View file

@ -95,6 +95,16 @@ const Puzzles = () => {
difficulty: "Advanced" as const, difficulty: "Advanced" as const,
duration: "10-30 min", duration: "10-30 min",
participants: "1 player" participants: "1 player"
},
{
id: "asc-desc-grid",
title: "Ascending-Descending Grid Puzzle",
description: "Fill an n×n grid with numbers 1 to n² such that each row and column follows its ascending or descending constraint.",
path: "/puzzles/asc-desc-grid",
tags: ["Logic", "Constraints", "Numbers", "Grid"],
difficulty: "Intermediate" as const,
duration: "5-15 min",
participants: "1 player"
} }
]; ];