chore: initial commit — puzzle-trainer

This commit is contained in:
Reverdin Agent 2026-05-23 01:05:21 +00:00
commit 57bf0092aa
148 changed files with 28619 additions and 0 deletions

76
Confetti.tsx Normal file
View file

@ -0,0 +1,76 @@
"use client";
import { useEffect, useState } from "react";
const COLORS = ["#f97316", "#10b981", "#3b82f6", "#f59e0b", "#ec4899", "#8b5cf6", "#14b8a6"];
interface Piece {
id: number;
color: string;
left: number;
delay: number;
duration: number;
size: number;
rotation: number;
}
function makePieces(n: number): Piece[] {
return Array.from({ length: n }, (_, i) => ({
id: i,
color: COLORS[i % COLORS.length],
left: Math.random() * 100,
delay: Math.random() * 1.2,
duration: 2.2 + Math.random() * 1.4,
size: 6 + Math.random() * 8,
rotation: Math.random() * 360,
}));
}
export default function Confetti() {
const [pieces] = useState(() => makePieces(48));
const [visible, setVisible] = useState(true);
useEffect(() => {
const t = setTimeout(() => setVisible(false), 4000);
return () => clearTimeout(t);
}, []);
if (!visible) return null;
return (
<div style={{
position: "fixed", inset: 0, pointerEvents: "none",
zIndex: 9999, overflow: "hidden",
}}>
<style>{`
@keyframes confetti-fall {
0% { transform: translateY(-20px) rotate(var(--rot)); opacity: 1; }
80% { opacity: 1; }
100% { transform: translateY(110vh) rotate(calc(var(--rot) + 720deg)); opacity: 0; }
}
@keyframes confetti-sway {
0%,100% { margin-left: 0; }
50% { margin-left: 30px; }
}
`}</style>
{pieces.map(p => (
<div
key={p.id}
style={{
position: "absolute",
top: 0,
left: `${p.left}%`,
width: p.size,
height: p.size * (Math.random() > 0.5 ? 1 : 0.4),
backgroundColor: p.color,
borderRadius: Math.random() > 0.5 ? "50%" : 2,
// @ts-expect-error css var
"--rot": `${p.rotation}deg`,
animation: `confetti-fall ${p.duration}s ease-in ${p.delay}s both, confetti-sway ${p.duration * 0.6}s ease-in-out ${p.delay}s infinite`,
willChange: "transform",
}}
/>
))}
</div>
);
}

91
DailyPageShell.tsx Normal file
View file

@ -0,0 +1,91 @@
"use client";
import { useEffect, useState } from "react";
import Link from "next/link";
import { loadStats } from "@/lib/stats";
import { GameId, GAME_META } from "@/lib/levels";
interface Props {
game: GameId;
date: string;
dateLabel: string;
children: React.ReactNode;
}
function fmt(s: number) {
return `${String(Math.floor(s / 60)).padStart(2, "0")}:${String(s % 60).padStart(2, "0")}`;
}
/**
* Wraps a daily puzzle page with:
* - Already-solved banner (if player already solved today)
* - Live date header
* - "Entraîne-toi" link to /game/levels
*/
export default function DailyPageShell({ game, date, dateLabel, children }: Props) {
const [solvedToday, setSolvedToday] = useState(false);
const [stats, setStats] = useState<ReturnType<typeof loadStats> | null>(null);
const { accent } = GAME_META[game];
useEffect(() => {
const s = loadStats(game);
setStats(s);
if (s.lastDate === date) {
setSolvedToday(true);
}
}, [game, date]);
return (
<div className="flex flex-col items-center gap-6">
{/* Header */}
<div className="text-center">
<h1 className="text-2xl font-bold text-gray-900 tracking-tight">{GAME_META[game].name}</h1>
<p className="text-sm text-gray-400 mt-1 capitalize">{dateLabel}</p>
</div>
{/* Already-solved banner */}
{solvedToday && stats && (
<div
className="w-full max-w-sm flex items-center gap-3 px-4 py-3 rounded-xl border"
style={{ background: `${accent}10`, borderColor: `${accent}30` }}
>
<div
className="w-8 h-8 rounded-full flex items-center justify-center shrink-0"
style={{ background: `${accent}20` }}
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke={accent} strokeWidth={2.5} strokeLinecap="round" strokeLinejoin="round">
<polyline points="20 6 9 17 4 12"/>
</svg>
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-semibold text-gray-800">Déjà résolu aujourd&apos;hui</p>
{stats.bestTime > 0 && (
<p className="text-xs text-gray-500 timer-mono">Meilleur temps : {fmt(stats.bestTime)}</p>
)}
</div>
<Link
href={`/${game}/levels`}
className="shrink-0 text-xs font-semibold px-3 py-1.5 rounded-full transition-colors"
style={{ background: `${accent}18`, color: accent }}
>
Niveaux
</Link>
</div>
)}
{/* Board */}
{children}
{/* Footer links */}
<div className="flex items-center gap-4 text-sm text-gray-400">
<Link href={`/archive?game=${game}`} className="hover:text-gray-700 transition-colors">
Archives
</Link>
<span className="text-gray-200">·</span>
<Link href={`/${game}/levels`} className="hover:text-gray-700 transition-colors">
Entraînement
</Link>
</div>
</div>
);
}

16
Dockerfile Normal file
View file

@ -0,0 +1,16 @@
FROM node:22-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
FROM node:22-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
ENV PORT=3000
COPY --from=builder /app/.next/standalone ./
COPY --from=builder /app/.next/static ./.next/static
COPY --from=builder /app/public ./public
EXPOSE 3000
CMD ["node", "server.js"]

50
ErrorBoundary.tsx Normal file
View file

@ -0,0 +1,50 @@
"use client";
import { Component, type ReactNode } from "react";
import Link from "next/link";
interface Props {
children: ReactNode;
fallback?: ReactNode;
}
interface State {
hasError: boolean;
error?: Error;
}
export default class ErrorBoundary extends Component<Props, State> {
constructor(props: Props) {
super(props);
this.state = { hasError: false };
}
static getDerivedStateFromError(error: Error): State {
return { hasError: true, error };
}
componentDidCatch(error: Error, info: { componentStack: string }) {
console.error("[ErrorBoundary]", error, info.componentStack);
}
render() {
if (this.state.hasError) {
if (this.props.fallback) return this.props.fallback;
return (
<div className="flex flex-col items-center gap-4 py-20 text-center">
<p className="text-gray-400 text-sm">Une erreur inattendue s&apos;est produite.</p>
<button
onClick={() => this.setState({ hasError: false })}
className="px-4 py-2 rounded-full border border-gray-300 text-sm text-gray-600 hover:bg-gray-50 transition-colors"
>
Réessayer
</button>
<Link href="/" className="text-xs text-gray-400 hover:text-gray-600 transition-colors">
Accueil
</Link>
</div>
);
}
return this.props.children;
}
}

196
LevelGrid.tsx Normal file
View file

@ -0,0 +1,196 @@
"use client";
import Link from "next/link";
import { GameId, TOTAL_LEVELS, levelMeta, GAME_META } from "@/lib/levels";
import { GameProgress } from "@/lib/progress";
interface Props {
game: GameId;
progress: GameProgress;
currentLevel?: number;
}
const DIFF_COLORS: Record<number, { bg: string; border: string; label: string; color: string }> = {
1: { bg: "#f0fdf4", border: "#86efac", label: "Facile", color: "#16a34a" },
2: { bg: "#fefce8", border: "#fde047", label: "Normal", color: "#ca8a04" },
3: { bg: "#fff7ed", border: "#fdba74", label: "Intermédiaire", color: "#ea580c" },
4: { bg: "#fef2f2", border: "#fca5a5", label: "Difficile", color: "#dc2626" },
5: { bg: "#faf5ff", border: "#d8b4fe", label: "Expert", color: "#9333ea" },
};
function fmt(s: number): string {
return `${String(Math.floor(s / 60)).padStart(2, "0")}:${String(s % 60).padStart(2, "0")}`;
}
function LevelCell({
game,
level,
done,
isCurrent,
accent,
diffInfo,
bestTime,
}: {
game: GameId;
level: number;
done: boolean;
isCurrent: boolean;
accent: string;
diffInfo: typeof DIFF_COLORS[1];
bestTime: number;
}) {
return (
<Link
href={`/${game}/level/${level}`}
title={`Niveau ${level}${diffInfo.label}${done ? ` · ${fmt(bestTime)}` : ""}`}
style={{
aspectRatio: "1",
borderRadius: 6,
display: "flex",
alignItems: "center",
justifyContent: "center",
fontSize: 11,
fontWeight: 600,
textDecoration: "none",
position: "relative",
...(done
? {
background: accent,
color: "#fff",
border: `1.5px solid ${accent}`,
}
: isCurrent
? {
background: diffInfo.bg,
color: accent,
border: `2px solid ${accent}`,
boxShadow: `0 0 0 3px ${accent}22`,
}
: {
background: diffInfo.bg,
color: "#9ca3af",
border: `1.5px solid ${diffInfo.border}`,
}),
}}
className={`hover:scale-105 hover:shadow-md transition-transform ${isCurrent ? "level-current" : ""}`}
>
{done ? (
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={3} strokeLinecap="round" strokeLinejoin="round">
<polyline points="20 6 9 17 4 12"/>
</svg>
) : (
level
)}
</Link>
);
}
export default function LevelGrid({ game, progress, currentLevel }: Props) {
const accent = GAME_META[game].accent;
const levels = Array.from({ length: TOTAL_LEVELS }, (_, i) => i + 1);
// Group levels by difficulty
const groups: { diff: number; levels: number[] }[] = [];
let currentDiff = -1;
for (const level of levels) {
const d = levelMeta(game, level).difficulty;
if (d !== currentDiff) {
groups.push({ diff: d, levels: [level] });
currentDiff = d;
} else {
groups[groups.length - 1].levels.push(level);
}
}
// Completion stats
const completedCount = Object.keys(progress).length;
return (
<div className="w-full max-w-lg">
{/* Difficulty legend */}
<div className="flex gap-3 flex-wrap mb-5">
{[1, 2, 3, 4, 5].map(d => {
const { bg, border, label, color } = DIFF_COLORS[d];
return (
<div key={d} className="flex items-center gap-1.5 text-xs" style={{ color }}>
<span style={{
width: 12,
height: 12,
borderRadius: 3,
display: "inline-block",
background: bg,
border: `1.5px solid ${border}`,
}} />
{label}
</div>
);
})}
</div>
{/* Difficulty groups */}
<div className="flex flex-col gap-5">
{groups.map(({ diff, levels: groupLevels }) => {
const diffInfo = DIFF_COLORS[diff];
const groupDone = groupLevels.filter(l => !!progress[l]).length;
return (
<div key={diff}>
{/* Group header */}
<div className="flex items-center gap-2 mb-2">
<span
className="text-xs font-semibold uppercase tracking-wide"
style={{ color: diffInfo.color }}
>
{diffInfo.label}
</span>
<span className="text-[10px] text-gray-300 font-medium tabular-nums">
{groupDone}/{groupLevels.length}
</span>
<div className="flex-1 h-px bg-gray-100" />
</div>
{/* Grid for this difficulty group */}
<div
style={{
display: "grid",
gridTemplateColumns: "repeat(10, 1fr)",
gap: 5,
}}
>
{groupLevels.map(level => {
const record = progress[level];
const done = !!record;
const isCurrent = level === currentLevel;
const meta = levelMeta(game, level);
return (
<LevelCell
key={level}
game={game}
level={level}
done={done}
isCurrent={isCurrent}
accent={accent}
diffInfo={DIFF_COLORS[meta.difficulty]}
bestTime={record?.bestTime ?? 0}
/>
);
})}
</div>
</div>
);
})}
</div>
{/* Footer stats */}
<p className="text-xs text-gray-400 mt-5 text-center tabular-nums">
{completedCount} / {TOTAL_LEVELS} niveaux complétés
{completedCount > 0 && (
<span className="ml-2 text-gray-300">
({Math.round((completedCount / TOTAL_LEVELS) * 100)}%)
</span>
)}
</p>
</div>
);
}

101
LevelsPageShell.tsx Normal file
View file

@ -0,0 +1,101 @@
"use client";
import { useEffect, useState } from "react";
import Link from "next/link";
import LevelGrid from "@/components/LevelGrid";
import { GameProgress, getGameProgress, nextLevel, allStats } from "@/lib/progress";
import { GameId, GAME_META, TOTAL_LEVELS } from "@/lib/levels";
interface Props {
game: GameId;
}
function fmt(s: number) {
return `${String(Math.floor(s / 60)).padStart(2, "0")}:${String(s % 60).padStart(2, "0")}`;
}
export default function LevelsPageShell({ game }: Props) {
const [progress, setProgress] = useState<GameProgress>({});
const [next, setNext] = useState(1);
const { name, accent } = GAME_META[game];
useEffect(() => {
const refresh = () => {
setProgress(getGameProgress(game));
setNext(nextLevel(game));
};
refresh();
window.addEventListener("focus", refresh);
document.addEventListener("visibilitychange", refresh);
return () => {
window.removeEventListener("focus", refresh);
document.removeEventListener("visibilitychange", refresh);
};
}, [game]);
const stats = allStats();
const gameStats = stats[game];
const completedCount = Object.keys(progress).length;
const pct = Math.round((completedCount / TOTAL_LEVELS) * 100);
return (
<div className="flex flex-col items-center gap-8 max-w-lg mx-auto">
{/* Header */}
<div className="w-full flex items-start justify-between">
<div>
<div className="flex items-center gap-2 mb-1">
<Link href={`/${game}`} className="text-sm text-gray-400 hover:text-gray-600 transition-colors flex items-center gap-1">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round"><polyline points="15 18 9 12 15 6"/></svg>
{name}
</Link>
</div>
<h1 className="text-2xl font-bold text-gray-900 tracking-tight">{name} Niveaux</h1>
<p className="text-sm text-gray-400 mt-1">100 puzzles · difficulté progressive</p>
</div>
{/* Completion ring */}
<div className="flex flex-col items-end gap-0.5 shrink-0 mt-1">
<span className="text-2xl font-black tabular-nums" style={{ color: accent }}>
{completedCount}
</span>
<span className="text-xs text-gray-400">/ {TOTAL_LEVELS}</span>
{pct > 0 && (
<span className="text-[10px] font-semibold text-gray-300">{pct}%</span>
)}
</div>
</div>
{/* Stats strip */}
{gameStats && gameStats.bestTime > 0 && (
<div className="w-full flex gap-3">
<div className="flex-1 flex flex-col items-center gap-0.5 px-3 py-2.5 bg-white rounded-xl border border-gray-100">
<span className="text-xs text-gray-400">Meilleur temps</span>
<span className="text-base font-bold text-gray-800 timer-mono">{fmt(gameStats.bestTime)}</span>
</div>
<div className="flex-1 flex flex-col items-center gap-0.5 px-3 py-2.5 bg-white rounded-xl border border-gray-100">
<span className="text-xs text-gray-400">Prochain</span>
<span className="text-base font-bold text-gray-800">Niv. {next}</span>
</div>
<div className="flex-1 flex flex-col items-center gap-0.5 px-3 py-2.5 bg-white rounded-xl border border-gray-100">
<span className="text-xs text-gray-400">Complétés</span>
<span className="text-base font-bold text-gray-800">{completedCount}</span>
</div>
</div>
)}
{/* CTA */}
<Link
href={`/${game}/level/${next}`}
className="w-full flex items-center justify-center gap-2 py-3 rounded-2xl text-white font-semibold text-base transition-opacity hover:opacity-90 shadow-sm"
style={{ background: accent }}
>
{completedCount === 0 ? "Commencer" : "Continuer"} Niveau {next}
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2.5} strokeLinecap="round"><polyline points="9 18 15 12 9 6"/></svg>
</Link>
{/* Grid */}
<LevelGrid game={game} progress={progress} currentLevel={next} />
</div>
);
}

25
NavLink.tsx Normal file
View file

@ -0,0 +1,25 @@
"use client";
import Link from "next/link";
import { usePathname } from "next/navigation";
export function NavLink({ href, label, symbol }: { href: string; label: string; symbol?: string }) {
const pathname = usePathname();
const active = pathname === href || (href !== "/" && pathname.startsWith(href));
return (
<Link
href={href}
className={`px-2.5 py-1.5 text-sm rounded-lg transition-colors flex items-center gap-1.5 whitespace-nowrap ${
active
? "bg-gray-900 text-white font-semibold"
: "text-gray-500 hover:text-gray-900 hover:bg-gray-100"
}`}
>
{symbol && (
<span className="text-[13px] leading-none" aria-hidden>
{symbol}
</span>
)}
<span>{label}</span>
</Link>
);
}

476
PatchesBoard.tsx Normal file
View file

@ -0,0 +1,476 @@
"use client";
import { useState, useEffect, useCallback, useRef, useMemo } from "react";
import { PatchesPuzzle, Region } from "@/lib/generators/patches";
import WinBanner from "./WinBanner";
interface Props {
puzzle: PatchesPuzzle;
date: string;
onSolve?: (elapsed: number) => void;
}
const MAX_CELL = 72;
const STORAGE_KEY = (d: string) => `patches-${d}`;
/** Tiny pixel-art preview of the polyomino shape. */
function ShapePreview({
relCells, previewRows, previewCols, color, cellPx,
}: {
relCells: [number, number][];
previewRows: number;
previewCols: number;
color: string;
cellPx: number;
}) {
const filled = new Set(relCells.map(([r, c]) => `${r},${c}`));
const gap = 1;
return (
<div style={{
display: "grid",
gridTemplateColumns: `repeat(${previewCols}, ${cellPx}px)`,
gridTemplateRows: `repeat(${previewRows}, ${cellPx}px)`,
gap,
pointerEvents: "none",
}}>
{Array.from({ length: previewRows }, (_, r) =>
Array.from({ length: previewCols }, (_, c) => (
<div
key={`${r}-${c}`}
style={{
width: cellPx,
height: cellPx,
backgroundColor: filled.has(`${r},${c}`) ? "rgba(255,255,255,0.9)" : "rgba(255,255,255,0.2)",
borderRadius: 1,
}}
/>
))
)}
</div>
);
}
function checkWin(userGrid: number[][], puzzle: PatchesPuzzle): boolean {
const { size, grid } = puzzle;
for (let r = 0; r < size; r++)
for (let c = 0; c < size; c++)
if (userGrid[r][c] !== grid[r][c]) return false;
return true;
}
export default function PatchesBoard({ puzzle, date, onSolve }: Props) {
const { size, regions, grid } = puzzle;
const [CELL, setCELL] = useState(() =>
typeof window === "undefined" ? MAX_CELL : Math.min(MAX_CELL, Math.floor((window.innerWidth - 32) / size))
);
const hintCellSet = useMemo(
() => new Set(regions.map(reg => `${reg.hintCell[0]},${reg.hintCell[1]}`)),
[regions]
);
const regionById = useMemo(
() => new Map<number, Region>(regions.map(reg => [reg.id, reg])),
[regions]
);
const hintCellToRegion = useMemo(() => {
const m = new Map<string, Region>();
for (const reg of regions) m.set(`${reg.hintCell[0]},${reg.hintCell[1]}`, reg);
return m;
}, [regions]);
const initGrid = useCallback((): number[][] => {
if (typeof window !== "undefined") {
const s = localStorage.getItem(STORAGE_KEY(date));
if (s) return JSON.parse(s);
}
const g: number[][] = Array.from({ length: size }, () => Array(size).fill(-1));
for (const reg of regions) g[reg.hintCell[0]][reg.hintCell[1]] = reg.id;
return g;
}, [date, regions, size]);
const [userGrid, setUserGrid] = useState<number[][]>(initGrid);
const [activeId, setActiveId] = useState<number | null>(null); // active region for painting
const [won, setWon] = useState(false);
const [elapsed, setElapsed] = useState(0);
const [t0] = useState(() => Date.now());
const history = useRef<number[][][]>([]);
// Painting state
const painting = useRef(false);
const paintMode = useRef<"add" | "erase">("add");
const boardRef = useRef<HTMLDivElement>(null);
const userGridSnap = useRef(userGrid);
useEffect(() => { userGridSnap.current = userGrid; }, [userGrid]);
useEffect(() => {
const update = () => setCELL(Math.min(MAX_CELL, Math.floor((window.innerWidth - 32) / size)));
window.addEventListener("resize", update);
return () => window.removeEventListener("resize", update);
}, [size]);
useEffect(() => {
if (won) return;
const id = setInterval(() => setElapsed(Math.floor((Date.now() - t0) / 1000)), 500);
return () => clearInterval(id);
}, [won, t0]);
useEffect(() => {
localStorage.setItem(STORAGE_KEY(date), JSON.stringify(userGrid));
if (!won && checkWin(userGrid, puzzle)) { setWon(true); onSolve?.(Math.floor((Date.now() - t0) / 1000)); }
}, [userGrid, date, puzzle, won, onSolve, t0]);
const fmt = (s: number) => `${String(Math.floor(s / 60)).padStart(2, "0")}:${String(s % 60).padStart(2, "0")}`;
function getCellFromPoint(clientX: number, clientY: number): [number, number] | null {
if (!boardRef.current) return null;
const rect = boardRef.current.getBoundingClientRect();
const r = Math.floor((clientY - rect.top) / CELL);
const c = Math.floor((clientX - rect.left) / CELL);
return (r >= 0 && r < size && c >= 0 && c < size) ? [r, c] : null;
}
/** Paint or erase a single cell for the active region. */
const paintCell = useCallback((r: number, c: number) => {
if (activeId === null || won) return;
const key = `${r},${c}`;
// Don't erase hint cells
if (hintCellSet.has(key)) return;
setUserGrid(prev => {
const next = prev.map(row => [...row]);
if (paintMode.current === "erase") {
if (next[r][c] === activeId) next[r][c] = -1;
} else {
// Only paint if empty or belongs to a different non-hint region (replace)
if (next[r][c] === -1 || (next[r][c] !== activeId && !hintCellSet.has(key))) {
// Remove from old region if any
next[r][c] = activeId;
} else if (next[r][c] === activeId) {
// Toggle off
next[r][c] = -1;
}
}
return next;
});
}, [activeId, won, hintCellSet]);
const stopPainting = useCallback(() => {
painting.current = false;
}, []);
useEffect(() => {
window.addEventListener("mouseup", stopPainting);
window.addEventListener("touchend", stopPainting);
return () => {
window.removeEventListener("mouseup", stopPainting);
window.removeEventListener("touchend", stopPainting);
};
}, [stopPainting]);
const undo = useCallback(() => {
if (won || history.current.length === 0) return;
setUserGrid(history.current.pop()!);
}, [won]);
useEffect(() => {
const handler = (e: KeyboardEvent) => {
if ((e.ctrlKey || e.metaKey) && e.key === "z") { e.preventDefault(); undo(); }
};
window.addEventListener("keydown", handler);
return () => window.removeEventListener("keydown", handler);
}, [undo]);
const handleCellMouseDown = useCallback((r: number, c: number, e: React.MouseEvent) => {
if (won) return;
e.preventDefault();
const key = `${r},${c}`;
const isHint = hintCellSet.has(key);
if (isHint) {
// Click hint cell → activate/deactivate region
const reg = hintCellToRegion.get(key);
if (reg) setActiveId(prev => prev === reg.id ? null : reg.id);
return;
}
// Non-hint cell: need active region
if (activeId === null) {
// Try to detect which region the cell belongs to and erase it
const cur = userGridSnap.current[r][c];
if (cur !== -1) {
history.current.push(userGridSnap.current.map(row => [...row]));
setUserGrid(prev => {
const next = prev.map(row => [...row]);
for (let rr = 0; rr < size; rr++)
for (let cc = 0; cc < size; cc++)
if (next[rr][cc] === cur && !hintCellSet.has(`${rr},${cc}`)) next[rr][cc] = -1;
return next;
});
}
return;
}
// Determine paint mode: if cell already belongs to active region → erase, else → add
const cur = userGridSnap.current[r][c];
paintMode.current = cur === activeId ? "erase" : "add";
painting.current = true;
history.current.push(userGridSnap.current.map(row => [...row]));
paintCell(r, c);
}, [won, hintCellSet, hintCellToRegion, activeId, size, paintCell]);
const handleMouseMove = useCallback((e: React.MouseEvent) => {
if (!painting.current || won || activeId === null) return;
const cell = getCellFromPoint(e.clientX, e.clientY);
if (cell) paintCell(cell[0], cell[1]);
}, [won, activeId, paintCell]);
const handleTouchStart = useCallback((e: React.TouchEvent) => {
if (won) return;
e.preventDefault();
const cell = getCellFromPoint(e.touches[0].clientX, e.touches[0].clientY);
if (!cell) return;
const [r, c] = cell;
const key = `${r},${c}`;
const isHint = hintCellSet.has(key);
if (isHint) {
const reg = hintCellToRegion.get(key);
if (reg) setActiveId(prev => prev === reg.id ? null : reg.id);
return;
}
if (activeId === null) {
const cur = userGridSnap.current[r][c];
if (cur !== -1) {
setUserGrid(prev => {
const next = prev.map(row => [...row]);
for (let rr = 0; rr < size; rr++)
for (let cc = 0; cc < size; cc++)
if (next[rr][cc] === cur && !hintCellSet.has(`${rr},${cc}`)) next[rr][cc] = -1;
return next;
});
}
return;
}
const cur = userGridSnap.current[r][c];
paintMode.current = cur === activeId ? "erase" : "add";
painting.current = true;
paintCell(r, c);
}, [won, hintCellSet, hintCellToRegion, activeId, size, paintCell]);
const handleTouchMove = useCallback((e: React.TouchEvent) => {
if (!painting.current || won || activeId === null) return;
e.preventDefault();
const cell = getCellFromPoint(e.touches[0].clientX, e.touches[0].clientY);
if (cell) paintCell(cell[0], cell[1]);
}, [won, activeId, paintCell]);
const handleHint = useCallback(() => {
if (won) return;
// Find first region not correctly placed, reveal one correct cell
for (const reg of regions) {
const correct = reg.cells.every(([r, c]) => userGridSnap.current[r][c] === reg.id);
if (!correct) {
// Activate this region and fill one missing correct cell
setActiveId(reg.id);
for (const [r, c] of reg.cells) {
if (userGridSnap.current[r][c] !== reg.id) {
setUserGrid(prev => {
const next = prev.map(row => [...row]);
next[r][c] = reg.id;
return next;
});
return;
}
}
}
}
}, [won, regions]);
const reset = () => {
history.current = [];
const g: number[][] = Array.from({ length: size }, () => Array(size).fill(-1));
for (const reg of regions) g[reg.hintCell[0]][reg.hintCell[1]] = reg.id;
setUserGrid(g);
setActiveId(null);
setWon(false);
};
const filled = userGrid.flat().filter(v => v !== -1).length;
// How many cells each region needs (for progress indicator inside hint cell)
const regionCorrectCount = useMemo(() => {
const m = new Map<number, number>();
for (const reg of regions) {
const correct = reg.cells.filter(([r, c]) => userGrid[r][c] === reg.id).length;
m.set(reg.id, correct);
}
return m;
}, [userGrid, regions]);
return (
<div className="flex flex-col items-center gap-4">
<div className="flex items-center gap-8 text-sm text-gray-500 font-medium">
<span>{filled} / {size * size}</span>
<span className="tabular-nums">{fmt(elapsed)}</span>
</div>
{won && <WinBanner game="patches" date={date} elapsed={elapsed} />}
{/* Board */}
<div
ref={boardRef}
style={{
display: "grid",
gridTemplateColumns: `repeat(${size}, ${CELL}px)`,
border: "2px solid #374151",
borderRadius: 6,
overflow: "hidden",
touchAction: "none",
userSelect: "none",
cursor: won ? "default" : "crosshair",
}}
onMouseMove={handleMouseMove}
onMouseUp={() => { painting.current = false; }}
onMouseLeave={() => { painting.current = false; }}
onTouchStart={handleTouchStart}
onTouchMove={handleTouchMove}
onTouchEnd={() => { painting.current = false; }}
>
{Array.from({ length: size }, (_, r) =>
Array.from({ length: size }, (_, c) => {
const key = `${r},${c}`;
const isHint = hintCellSet.has(key);
const assignedId = userGrid[r][c];
const region = assignedId !== -1 ? regionById.get(assignedId) : undefined;
const hintRegion = isHint ? hintCellToRegion.get(key) : undefined;
const isActive = hintRegion ? hintRegion.id === activeId : (region?.id === activeId && activeId !== null);
// Background
let bg: string;
if (region) {
bg = region.color;
} else if (isHint && hintRegion) {
bg = hintRegion.color;
} else {
bg = "#f3f4f6";
}
// Opacity: if cell belongs to active region and is not a hint → show at 80%
const opacity = !isHint && assignedId === activeId && activeId !== null ? 0.75 : 1;
// Seamless borders within same region
const sameAs = (dr: number, dc: number) => {
const nr = r + dr, nc = c + dc;
if (nr < 0 || nr >= size || nc < 0 || nc >= size) return false;
return userGrid[nr][nc] === assignedId && assignedId !== -1;
};
const seamless = `1px solid ${bg}`;
const bTop = sameAs(-1, 0) ? seamless : "1px solid #9ca3af";
const bBottom = sameAs( 1, 0) ? seamless : "1px solid #9ca3af";
const bLeft = sameAs( 0,-1) ? seamless : "1px solid #9ca3af";
const bRight = sameAs( 0, 1) ? seamless : "1px solid #9ca3af";
// Hint cell: active ring
const outline = isHint && activeId === hintRegion?.id
? "3px dashed rgba(255,255,255,0.9)"
: undefined;
// Hint cell preview size: fit shape in ~65% of cell
const maxDim = hintRegion ? Math.max(hintRegion.previewRows, hintRegion.previewCols) : 1;
const miniCellPx = Math.max(4, Math.floor((CELL * 0.6) / maxDim));
return (
<div
key={key}
onMouseDown={(e) => handleCellMouseDown(r, c, e)}
style={{
width: CELL,
height: CELL,
backgroundColor: bg,
opacity,
borderTop: bTop,
borderBottom: bBottom,
borderLeft: bLeft,
borderRight: bRight,
boxSizing: "border-box",
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
gap: 2,
transition: "background-color 0.06s, opacity 0.06s",
position: "relative",
outline,
outlineOffset: "-3px",
cursor: isHint ? "pointer" : "crosshair",
zIndex: isHint ? 1 : 0,
}}
>
{isHint && hintRegion && (
<>
<ShapePreview
relCells={hintRegion.relCells}
previewRows={hintRegion.previewRows}
previewCols={hintRegion.previewCols}
color={hintRegion.color}
cellPx={miniCellPx}
/>
<span style={{
fontSize: Math.max(9, CELL * 0.18),
fontWeight: 800,
color: "rgba(255,255,255,0.95)",
textShadow: "0 1px 3px rgba(0,0,0,0.4)",
lineHeight: 1,
pointerEvents: "none",
}}>
{regionCorrectCount.get(hintRegion.id) ?? 0}/{hintRegion.size}
</span>
</>
)}
</div>
);
})
)}
</div>
{activeId !== null && !won && (
<p className="text-sm font-medium" style={{ color: regionById.get(activeId)?.color ?? "#6b7280" }}>
Région active peignez les {regionById.get(activeId)?.size} cases
</p>
)}
<p className="text-xs text-gray-400 text-center max-w-[320px]">
Cliquez sur une icône pour activer sa région, puis peignez les cases. La forme en aperçu indique la disposition à reproduire.
</p>
<div className="flex gap-3">
{!won && (
<>
<button
onClick={undo}
disabled={history.current.length === 0}
className="px-4 py-2 rounded-xl border border-gray-200 text-gray-500 hover:bg-gray-50 text-sm transition-colors disabled:opacity-30"
title="Annuler (Ctrl+Z)"
>
Annuler
</button>
<button onClick={handleHint}
className="px-4 py-2 rounded-xl border border-amber-200 text-amber-600 hover:bg-amber-50 text-sm transition-colors">
Indice
</button>
</>
)}
<button
onClick={reset}
className="px-4 py-2 rounded-xl border border-gray-200 text-gray-500 hover:bg-gray-50 text-sm transition-colors"
>
Recommencer
</button>
</div>
</div>
);
}

555
QueensBoard.tsx Normal file
View file

@ -0,0 +1,555 @@
"use client";
import { useState, useEffect, useCallback, useRef } from "react";
import { QueensPuzzle } from "@/lib/generators/queens";
import WinBanner from "./WinBanner";
interface Props {
puzzle: QueensPuzzle;
date: string;
onSolve?: (elapsed: number) => void;
}
const REGION_COLORS = [
{ bg: "#c8b8e8", border: "#9870c8" },
{ bg: "#f5c898", border: "#d4986a" },
{ bg: "#a8c8f0", border: "#6098d8" },
{ bg: "#b8d8b0", border: "#78b870" },
{ bg: "#f5a898", border: "#d86858" },
{ bg: "#d8d8d8", border: "#a8a8a8" },
{ bg: "#d8e888", border: "#b0c840" },
{ bg: "#c8b8a8", border: "#a89878" },
{ bg: "#f0b8c8", border: "#d880a0" },
{ bg: "#a8e0d8", border: "#60b8b0" },
];
type CellState = "empty" | "queen" | "mark";
interface HintInfo {
explanation: string;
// cells highlighted in blue (the "why" context)
focusCells: Set<string>;
// cells highlighted in green (queen to place) or red (cells to eliminate)
actionCells: Set<string>;
action:
| { kind: "queen"; r: number; c: number }
| { kind: "marks"; cells: [number, number][] };
}
function CrownIcon({ size }: { size: number }) {
return (
<svg width={size} height={size} viewBox="0 0 24 24" fill="currentColor">
<path d="M3 18h18v2H3v-2zm1.5-12L8 10.5 12 4l4 6.5 3.5-4.5L22 16H2L5.5 6z" />
</svg>
);
}
function XIcon({ size }: { size: number }) {
return (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2.5} strokeLinecap="round">
<line x1="6" y1="6" x2="18" y2="18" />
<line x1="18" y1="6" x2="6" y2="18" />
</svg>
);
}
function checkWin(board: CellState[][], puzzle: QueensPuzzle): boolean {
const { size, regions } = puzzle;
const queens: [number, number][] = [];
for (let r = 0; r < size; r++)
for (let c = 0; c < size; c++)
if (board[r][c] === "queen") queens.push([r, c]);
if (queens.length !== size) return false;
const rows = new Set<number>(), cols = new Set<number>(), regs = new Set<number>();
for (const [r, c] of queens) {
if (rows.has(r) || cols.has(c) || regs.has(regions[r][c])) return false;
rows.add(r); cols.add(c); regs.add(regions[r][c]);
}
for (let i = 0; i < queens.length; i++)
for (let j = i + 1; j < queens.length; j++)
if (Math.abs(queens[i][0] - queens[j][0]) <= 1 && Math.abs(queens[i][1] - queens[j][1]) <= 1) return false;
return true;
}
function getErrors(board: CellState[][], puzzle: QueensPuzzle): Set<string> {
const { size, regions } = puzzle;
const errors = new Set<string>();
const queens: [number, number][] = [];
for (let r = 0; r < size; r++)
for (let c = 0; c < size; c++)
if (board[r][c] === "queen") queens.push([r, c]);
const byRow = new Map<number, number[]>(), byCol = new Map<number, number[]>(), byReg = new Map<number, number[]>();
for (const [r, c] of queens) {
const reg = regions[r][c];
byRow.set(r, [...(byRow.get(r) || []), c]);
byCol.set(c, [...(byCol.get(c) || []), r]);
byReg.set(reg, [...(byReg.get(reg) || []), r * size + c]);
}
for (const [r, cs] of byRow) if (cs.length > 1) cs.forEach(c => errors.add(`${r},${c}`));
for (const [c, rs] of byCol) if (rs.length > 1) rs.forEach(r => errors.add(`${r},${c}`));
for (const [, cells] of byReg) if (cells.length > 1) cells.forEach(idx => errors.add(`${Math.floor(idx / size)},${idx % size}`));
for (let i = 0; i < queens.length; i++)
for (let j = i + 1; j < queens.length; j++)
if (Math.abs(queens[i][0] - queens[j][0]) <= 1 && Math.abs(queens[i][1] - queens[j][1]) <= 1) {
errors.add(`${queens[i][0]},${queens[i][1]}`);
errors.add(`${queens[j][0]},${queens[j][1]}`);
}
return errors;
}
// Logical hint finder: detects which rule applies and explains the reasoning
function findLogicalHint(board: CellState[][], puzzle: QueensPuzzle): HintInfo | null {
const { size, regions } = puzzle;
const queens: [number, number][] = [];
const queenRows = new Set<number>();
const queenCols = new Set<number>();
const queenRegs = new Set<number>();
for (let r = 0; r < size; r++)
for (let c = 0; c < size; c++)
if (board[r][c] === "queen") {
queens.push([r, c]);
queenRows.add(r);
queenCols.add(c);
queenRegs.add(regions[r][c]);
}
// A cell is "possible" if no placed queen or user mark excludes it
function isPossible(r: number, c: number): boolean {
if (board[r][c] === "queen" || board[r][c] === "mark") return false;
if (queenRows.has(r) || queenCols.has(c) || queenRegs.has(regions[r][c])) return false;
for (const [qr, qc] of queens)
if (Math.abs(r - qr) <= 1 && Math.abs(c - qc) <= 1) return false;
return true;
}
const possSet = new Set<string>();
for (let r = 0; r < size; r++)
for (let c = 0; c < size; c++)
if (isPossible(r, c)) possSet.add(`${r},${c}`);
// Group all cells by region
const byRegion = new Map<number, [number, number][]>();
for (let r = 0; r < size; r++)
for (let c = 0; c < size; c++) {
const reg = regions[r][c];
if (!byRegion.has(reg)) byRegion.set(reg, []);
byRegion.get(reg)!.push([r, c]);
}
// Rule 1: A region has exactly one possible cell → forced queen
for (const [reg, cells] of byRegion) {
if (queenRegs.has(reg)) continue;
const poss = cells.filter(([r, c]) => possSet.has(`${r},${c}`));
if (poss.length === 1) {
const [r, c] = poss[0];
return {
explanation: "Cette zone colorée (en bleu) n'a plus qu'une seule case disponible (en vert). Toutes les autres ont été éliminées par les couronnes déjà posées. La couronne de cette zone doit obligatoirement s'y trouver.",
focusCells: new Set(cells.map(([r, c]) => `${r},${c}`)),
actionCells: new Set([`${r},${c}`]),
action: { kind: "queen", r, c },
};
}
}
// Rule 2: A row has exactly one possible cell → forced queen
for (let r = 0; r < size; r++) {
if (queenRows.has(r)) continue;
const poss: [number, number][] = [];
for (let c = 0; c < size; c++) if (possSet.has(`${r},${c}`)) poss.push([r, c]);
if (poss.length === 1) {
const [pr, pc] = poss[0];
return {
explanation: `La ligne ${r + 1} (en bleu) n'a plus qu'une seule case disponible (en vert). La couronne qui doit occuper cette ligne n'a pas d'autre choix.`,
focusCells: new Set(Array.from({ length: size }, (_, c) => `${r},${c}`)),
actionCells: new Set([`${pr},${pc}`]),
action: { kind: "queen", r: pr, c: pc },
};
}
}
// Rule 3: A column has exactly one possible cell → forced queen
for (let c = 0; c < size; c++) {
if (queenCols.has(c)) continue;
const poss: [number, number][] = [];
for (let r = 0; r < size; r++) if (possSet.has(`${r},${c}`)) poss.push([r, c]);
if (poss.length === 1) {
const [pr, pc] = poss[0];
return {
explanation: `La colonne ${c + 1} (en bleu) n'a plus qu'une seule case disponible (en vert). La couronne qui doit occuper cette colonne n'a pas d'autre choix.`,
focusCells: new Set(Array.from({ length: size }, (_, r) => `${r},${c}`)),
actionCells: new Set([`${pr},${pc}`]),
action: { kind: "queen", r: pr, c: pc },
};
}
}
// Rules 4+: Generalized naked subset — N regions confined to same N rows or cols
// N=1: locked region, N=2: naked pair, N=3: naked triple, N=4: naked quad
function* genCombinations<T>(arr: T[], n: number, start = 0): Generator<T[]> {
if (n === 0) { yield []; return; }
for (let i = start; i <= arr.length - n; i++)
for (const rest of genCombinations(arr, n - 1, i + 1))
yield [arr[i], ...rest];
}
const unsolved = [...byRegion.entries()].filter(([reg, cells]) =>
!queenRegs.has(reg) && cells.some(([r, c]) => possSet.has(`${r},${c}`))
);
for (let n = 1; n <= unsolved.length - 1; n++) {
for (const combo of genCombinations(unsolved, n)) {
const regSet = new Set(combo.map(([reg]) => reg));
const possCells = combo.flatMap(([, cells]) => cells.filter(([r, c]) => possSet.has(`${r},${c}`)));
if (possCells.length === 0) continue;
// Row subset
const rowSet = new Set(possCells.map(([r]) => r));
if (rowSet.size === n) {
const elimCells: [number, number][] = [];
for (const lr of rowSet)
for (let c = 0; c < size; c++)
if (possSet.has(`${lr},${c}`) && !regSet.has(regions[lr][c]))
elimCells.push([lr, c]);
if (elimCells.length > 0) {
const rowNames = [...rowSet].sort((a, b) => a - b).map(r => `ligne ${r + 1}`).join(" et ");
return {
explanation: n === 1
? `Toutes les cases disponibles de la zone bleue se trouvent sur la ${rowNames}. La couronne de cette zone occupera forcément cette ligne — aucune autre zone ne peut donc y placer de couronne. Les cases rouges sont éliminées.`
: `Ces ${n} zones (en bleu) ne peuvent se placer que sur les ${rowNames}. Ces lignes leur sont réservées — les autres zones ne peuvent plus y placer de couronne. Les cases rouges sont éliminées.`,
focusCells: new Set(possCells.map(([r, c]) => `${r},${c}`)),
actionCells: new Set(elimCells.map(([r, c]) => `${r},${c}`)),
action: { kind: "marks", cells: elimCells },
};
}
}
// Col subset
const colSet = new Set(possCells.map(([, c]) => c));
if (colSet.size === n) {
const elimCells: [number, number][] = [];
for (const lc of colSet)
for (let r = 0; r < size; r++)
if (possSet.has(`${r},${lc}`) && !regSet.has(regions[r][lc]))
elimCells.push([r, lc]);
if (elimCells.length > 0) {
const colNames = [...colSet].sort((a, b) => a - b).map(c => `colonne ${c + 1}`).join(" et ");
return {
explanation: n === 1
? `Toutes les cases disponibles de la zone bleue se trouvent dans la ${colNames}. La couronne de cette zone occupera forcément cette colonne — aucune autre zone ne peut donc y placer de couronne. Les cases rouges sont éliminées.`
: `Ces ${n} zones (en bleu) ne peuvent se placer que dans les ${colNames}. Ces colonnes leur sont réservées — les autres zones ne peuvent plus y placer de couronne. Les cases rouges sont éliminées.`,
focusCells: new Set(possCells.map(([r, c]) => `${r},${c}`)),
actionCells: new Set(elimCells.map(([r, c]) => `${r},${c}`)),
action: { kind: "marks", cells: elimCells },
};
}
}
}
}
return null;
}
const STORAGE_KEY = (date: string) => `queens-v2-${date}`;
const DIAGONAL_ERROR = "repeating-linear-gradient(-45deg, rgba(220,38,38,0.55) 0px, rgba(220,38,38,0.55) 3px, rgba(220,38,38,0.2) 3px, rgba(220,38,38,0.2) 6px)";
const MAX_CELL = 52;
export default function QueensBoard({ puzzle, date, onSolve }: Props) {
const { size, regions } = puzzle;
const [CELL, setCELL] = useState(() =>
typeof window === "undefined" ? MAX_CELL : Math.min(MAX_CELL, Math.floor((window.innerWidth - 32) / size))
);
const [board, setBoard] = useState<CellState[][]>(() => {
if (typeof window !== "undefined") {
const s = localStorage.getItem(STORAGE_KEY(date));
if (s) return JSON.parse(s);
}
return Array.from({ length: size }, () => Array(size).fill("empty"));
});
const [won, setWon] = useState(false);
const [elapsed, setElapsed] = useState(0);
const [t0] = useState(() => Date.now());
const [hintInfo, setHintInfo] = useState<HintInfo | null>(null);
const history = useRef<CellState[][][]>([]);
const drag = useRef<{
action: "mark" | "clear";
origin: string;
originApplied: boolean;
lastCell: string;
moved: boolean;
} | null>(null);
const boardSnap = useRef(board);
const gridRef = useRef<HTMLDivElement>(null);
useEffect(() => { boardSnap.current = board; }, [board]);
useEffect(() => {
const update = () => setCELL(Math.min(MAX_CELL, Math.floor((window.innerWidth - 32) / size)));
window.addEventListener("resize", update);
return () => window.removeEventListener("resize", update);
}, [size]);
useEffect(() => {
if (won) return;
const id = setInterval(() => setElapsed(Math.floor((Date.now() - t0) / 1000)), 500);
return () => clearInterval(id);
}, [won, t0]);
useEffect(() => {
localStorage.setItem(STORAGE_KEY(date), JSON.stringify(board));
if (!won && checkWin(board, puzzle)) { setWon(true); onSolve?.(Math.floor((Date.now() - t0) / 1000)); }
}, [board, date, puzzle, won, onSolve, t0]);
useEffect(() => {
const stop = () => { drag.current = null; };
window.addEventListener("pointerup", stop);
return () => window.removeEventListener("pointerup", stop);
}, []);
const errors = won ? new Set<string>() : getErrors(board, puzzle);
const queensPlaced = board.flat().filter(c => c === "queen").length;
const fmt = (s: number) => `${String(Math.floor(s / 60)).padStart(2, "0")}:${String(s % 60).padStart(2, "0")}`;
const applyDragToCell = useCallback((r: number, c: number) => {
if (!drag.current || won) return;
const { action } = drag.current;
setBoard(prev => {
const cur = prev[r][c];
if (action === "mark" && cur === "empty") {
const next = prev.map(row => [...row]); next[r][c] = "mark"; return next;
}
if (action === "clear" && cur === "mark") {
const next = prev.map(row => [...row]); next[r][c] = "empty"; return next;
}
return prev;
});
}, [won]);
const undo = useCallback(() => {
if (won || history.current.length === 0) return;
setBoard(history.current.pop()!);
setHintInfo(null);
}, [won]);
useEffect(() => {
const handler = (e: KeyboardEvent) => {
if ((e.ctrlKey || e.metaKey) && e.key === "z") { e.preventDefault(); undo(); }
};
window.addEventListener("keydown", handler);
return () => window.removeEventListener("keydown", handler);
}, [undo]);
const handlePointerDown = useCallback((r: number, c: number, e: React.PointerEvent) => {
if (won) return;
e.preventDefault();
history.current.push(boardSnap.current.map(row => [...row]));
setHintInfo(null);
const cur = boardSnap.current[r][c];
const action: "mark" | "clear" = cur === "mark" ? "clear" : "mark";
drag.current = { action, origin: `${r},${c}`, originApplied: false, lastCell: `${r},${c}`, moved: false };
}, [won]);
const handlePointerMove = useCallback((e: React.PointerEvent<HTMLDivElement>) => {
if (!drag.current || won) return;
if (!gridRef.current) return;
const rect = gridRef.current.getBoundingClientRect();
const c = Math.floor((e.clientX - rect.left) / CELL);
const r = Math.floor((e.clientY - rect.top) / CELL);
if (r < 0 || r >= size || c < 0 || c >= size) return;
const key = `${r},${c}`;
if (key === drag.current.lastCell) return;
// On first move to a different cell, also apply action to origin
if (!drag.current.originApplied) {
const [or, oc] = drag.current.origin.split(",").map(Number);
applyDragToCell(or, oc);
drag.current.originApplied = true;
}
drag.current.moved = true;
drag.current.lastCell = key;
applyDragToCell(r, c);
}, [won, applyDragToCell, size, CELL]);
const handlePointerUp = useCallback((r: number, c: number) => {
if (!drag.current || won) return;
if (!drag.current.moved) {
setBoard(prev => {
const next = prev.map(row => [...row]);
next[r][c] = next[r][c] === "empty" ? "mark" : next[r][c] === "mark" ? "queen" : "empty";
return next;
});
}
drag.current = null;
}, [won]);
const reset = () => { history.current = []; setBoard(Array.from({ length: size }, () => Array(size).fill("empty"))); setWon(false); setHintInfo(null); };
const handleHint = () => {
if (won) return;
if (hintInfo) { setHintInfo(null); return; } // toggle off
const hint = findLogicalHint(boardSnap.current, puzzle);
if (hint) {
setHintInfo(hint);
}
};
const applyHint = () => {
if (!hintInfo) return;
if (hintInfo.action.kind === "queen") {
const { r, c } = hintInfo.action;
setBoard(prev => {
const next = prev.map(row => [...row]);
next[r][c] = "queen";
return next;
});
} else {
const { cells } = hintInfo.action;
setBoard(prev => {
const next = prev.map(row => [...row]);
for (const [r, c] of cells) if (next[r][c] === "empty") next[r][c] = "mark";
return next;
});
}
setHintInfo(null);
};
return (
<div className="flex flex-col items-center gap-4 w-full">
<div className="flex items-center gap-8 text-sm text-gray-500 font-medium">
<span>{queensPlaced} / {size} reines</span>
<span className="tabular-nums">{fmt(elapsed)}</span>
</div>
{won && <WinBanner game="queens" date={date} elapsed={elapsed} />}
{/* Hint explanation panel */}
{hintInfo && (
<div className="w-full max-w-xs bg-blue-50 border border-blue-200 rounded-xl px-4 py-3 text-sm text-blue-900 shadow-sm">
<p className="mb-3 leading-relaxed">{hintInfo.explanation}</p>
<div className="flex gap-2">
{hintInfo.actionCells.size > 0 && (
<button
onClick={applyHint}
className="flex-1 py-1.5 rounded-lg bg-blue-600 text-white text-xs font-semibold hover:bg-blue-700 transition-colors"
>
Appliquer
</button>
)}
<button
onClick={() => setHintInfo(null)}
className={`flex-1 py-1.5 rounded-lg border border-blue-200 text-blue-600 text-xs font-semibold hover:bg-blue-100 transition-colors`}
>
Fermer
</button>
</div>
</div>
)}
{/* Board */}
<div
ref={gridRef}
onPointerMove={handlePointerMove}
style={{
display: "grid",
gridTemplateColumns: `repeat(${size}, ${CELL}px)`,
border: "2px solid #1a1a1a",
borderRadius: 4,
overflow: "hidden",
touchAction: "none",
}}
>
{Array.from({ length: size }, (_, r) =>
Array.from({ length: size }, (_, c) => {
const reg = regions[r][c];
const state = board[r][c];
const err = errors.has(`${r},${c}`);
const color = REGION_COLORS[reg % REGION_COLORS.length];
const key = `${r},${c}`;
const bTop = r > 0 && regions[r - 1][c] === reg ? "1px solid rgba(0,0,0,0.08)" : "2px solid #1a1a1a";
const bLeft = c > 0 && regions[r][c - 1] === reg ? "1px solid rgba(0,0,0,0.08)" : "2px solid #1a1a1a";
const bBottom = r < size - 1 && regions[r + 1][c] === reg ? "1px solid rgba(0,0,0,0.08)" : "2px solid #1a1a1a";
const bRight = c < size - 1 && regions[r][c + 1] === reg ? "1px solid rgba(0,0,0,0.08)" : "2px solid #1a1a1a";
// Hint highlighting overrides normal bg
let bg = color.bg;
const bgImage = err ? DIAGONAL_ERROR : undefined;
if (!err && hintInfo) {
if (hintInfo.action.kind === "queen" && hintInfo.actionCells.has(key)) {
bg = "#bbf7d0"; // green: forced queen
} else if (hintInfo.action.kind === "marks" && hintInfo.actionCells.has(key)) {
bg = "#fecaca"; // red: cells to eliminate
} else if (hintInfo.focusCells.has(key)) {
bg = "#bfdbfe"; // blue: context cells
}
}
return (
<div
key={key}
onPointerDown={(e) => handlePointerDown(r, c, e)}
onPointerUp={() => handlePointerUp(r, c)}
style={{
width: CELL, height: CELL,
backgroundColor: bg,
backgroundImage: bgImage,
borderTop: bTop, borderLeft: bLeft, borderBottom: bBottom, borderRight: bRight,
cursor: won ? "default" : "pointer",
display: "flex", alignItems: "center", justifyContent: "center",
userSelect: "none",
transition: "background-color 0.12s",
}}
>
{state === "queen" && (
<span style={{ color: err ? "#dc2626" : "#1a1a1a" }}>
<CrownIcon size={CELL * 0.48} />
</span>
)}
{state === "mark" && (
<span style={{ color: "#6b7280", opacity: 0.6 }}>
<XIcon size={CELL * 0.4} />
</span>
)}
</div>
);
})
)}
</div>
<div className="flex flex-col items-center gap-1 text-xs text-gray-400">
<span>1 clic = · 2 clics = couronne · glisser = en série</span>
</div>
<div className="flex gap-3">
{!won && (
<button
onClick={handleHint}
className={`px-4 py-2 rounded-xl border text-sm transition-colors ${hintInfo ? "border-blue-300 bg-blue-50 text-blue-700" : "border-amber-200 text-amber-600 hover:bg-amber-50"}`}
>
{hintInfo ? "Masquer" : "Indice"}
</button>
)}
<button
onClick={undo}
disabled={history.current.length === 0}
className="px-4 py-2 rounded-xl border border-gray-200 text-gray-500 hover:bg-gray-50 text-sm transition-colors disabled:opacity-30"
title="Annuler (Ctrl+Z)"
>
Annuler
</button>
<button onClick={reset} className="px-4 py-2 rounded-xl border border-gray-200 text-gray-500 hover:bg-gray-50 text-sm transition-colors">
Recommencer
</button>
</div>
<p className="text-xs text-gray-400 text-center max-w-xs">
Une couronne par ligne, par colonne et par zone colorée. Les couronnes ne peuvent pas se toucher.
</p>
</div>
);
}

19
StreakBadge.tsx Normal file
View file

@ -0,0 +1,19 @@
"use client";
import { useEffect, useState } from "react";
import { loadStats } from "@/lib/stats";
export default function StreakBadge({ game }: { game: string }) {
const [streak, setStreak] = useState(0);
useEffect(() => {
setStreak(loadStats(game).streak);
}, [game]);
if (streak === 0) return null;
return (
<span className="inline-flex items-center gap-1 text-xs font-semibold text-orange-500">
🔥{streak}
</span>
);
}

253
SudokuBoard.tsx Normal file
View file

@ -0,0 +1,253 @@
"use client";
import { useState, useEffect, useCallback, useRef } from "react";
import { SudokuPuzzle } from "@/lib/generators/sudoku";
import WinBanner from "./WinBanner";
interface Props {
puzzle: SudokuPuzzle;
date: string;
onSolve?: (elapsed: number) => void;
}
const MAX_CELL = 64;
const STORAGE_KEY = (d: string) => `sudoku-${d}`;
const DIAGONAL_ERROR = "repeating-linear-gradient(-45deg, rgba(220,38,38,0.55) 0px, rgba(220,38,38,0.55) 3px, rgba(220,38,38,0.2) 3px, rgba(220,38,38,0.2) 6px)";
function checkWin(grid: number[][], solution: number[][]): boolean {
for (let r = 0; r < 6; r++)
for (let c = 0; c < 6; c++)
if (grid[r][c] !== solution[r][c]) return false;
return true;
}
function getErrors(grid: number[][], given: number[][]): Set<string> {
const errors = new Set<string>();
for (let r = 0; r < 6; r++) {
for (let c = 0; c < 6; c++) {
const v = grid[r][c];
if (!v || given[r][c]) continue;
// Row
for (let cc = 0; cc < 6; cc++) if (cc !== c && grid[r][cc] === v) { errors.add(`${r},${c}`); errors.add(`${r},${cc}`); }
// Col
for (let rr = 0; rr < 6; rr++) if (rr !== r && grid[rr][c] === v) { errors.add(`${r},${c}`); errors.add(`${rr},${c}`); }
// Box
const br = Math.floor(r / 2) * 2, bc = Math.floor(c / 3) * 3;
for (let dr = 0; dr < 2; dr++)
for (let dc = 0; dc < 3; dc++) {
const rr = br + dr, cc = bc + dc;
if ((rr !== r || cc !== c) && grid[rr][cc] === v) { errors.add(`${r},${c}`); errors.add(`${rr},${cc}`); }
}
}
}
return errors;
}
export default function SudokuBoard({ puzzle, date, onSolve }: Props) {
const { given, solution } = puzzle;
const size = 6;
const [CELL, setCELL] = useState(() =>
typeof window === "undefined" ? MAX_CELL : Math.min(MAX_CELL, Math.floor((window.innerWidth - 32) / size))
);
const [grid, setGrid] = useState<number[][]>(() => {
if (typeof window !== "undefined") {
const s = localStorage.getItem(STORAGE_KEY(date));
if (s) return JSON.parse(s);
}
return given.map(r => [...r]);
});
const [selected, setSelected] = useState<[number, number] | null>(null);
const [won, setWon] = useState(false);
const [elapsed, setElapsed] = useState(0);
const [t0] = useState(() => Date.now());
const history = useRef<number[][][]>([]);
useEffect(() => {
const update = () => setCELL(Math.min(MAX_CELL, Math.floor((window.innerWidth - 32) / size)));
window.addEventListener("resize", update);
return () => window.removeEventListener("resize", update);
}, [size]);
useEffect(() => {
if (won) return;
const id = setInterval(() => setElapsed(Math.floor((Date.now() - t0) / 1000)), 500);
return () => clearInterval(id);
}, [won, t0]);
useEffect(() => {
localStorage.setItem(STORAGE_KEY(date), JSON.stringify(grid));
if (!won && checkWin(grid, solution)) { setWon(true); onSolve?.(Math.floor((Date.now() - t0) / 1000)); }
}, [grid, date, solution, won, onSolve]);
const errors = won ? new Set<string>() : getErrors(grid, given);
const filled = grid.flat().filter(Boolean).length;
const fmt = (s: number) => `${String(Math.floor(s / 60)).padStart(2, "0")}:${String(s % 60).padStart(2, "0")}`;
const input = useCallback((val: number) => {
if (!selected || won) return;
const [r, c] = selected;
if (given[r][c]) return;
setGrid(prev => {
history.current.push(prev.map(row => [...row]));
const next = prev.map(row => [...row]);
next[r][c] = next[r][c] === val ? 0 : val;
return next;
});
}, [selected, won, given]);
const undo = useCallback(() => {
if (won || history.current.length === 0) return;
const prev = history.current.pop()!;
setGrid(prev);
}, [won]);
useEffect(() => {
const handler = (e: KeyboardEvent) => {
const n = parseInt(e.key);
if (n >= 1 && n <= 6) input(n);
if (e.key === "Backspace" || e.key === "Delete" || e.key === "0") input(0);
if ((e.ctrlKey || e.metaKey) && e.key === "z") undo();
};
window.addEventListener("keydown", handler);
return () => window.removeEventListener("keydown", handler);
}, [input, undo]);
const reset = () => { history.current = []; setGrid(given.map(r => [...r])); setSelected(null); setWon(false); };
const handleHint = useCallback(() => {
if (won) return;
// Find first empty non-given cell
for (let r = 0; r < 6; r++)
for (let c = 0; c < 6; c++)
if (!given[r][c] && grid[r][c] === 0) {
history.current.push(grid.map(row => [...row]));
setGrid(prev => {
const next = prev.map(row => [...row]);
next[r][c] = solution[r][c];
return next;
});
setSelected([r, c]);
return;
}
}, [won, given, grid, solution]);
const [selR, selC] = selected ?? [-1, -1];
// Box borders: thick between 2x3 boxes
const boxBorderB = (r: number) => (r === 1 || r === 3) ? "3px solid #1a1a1a" : "1px solid #d1d5db";
const boxBorderR = (c: number) => (c === 2) ? "3px solid #1a1a1a" : "1px solid #d1d5db";
return (
<div className="flex flex-col items-center gap-4">
<div className="flex items-center gap-8 text-sm text-gray-500 font-medium">
<span>{filled} / 36</span>
<span className="tabular-nums">{fmt(elapsed)}</span>
</div>
{won && <WinBanner game="sudoku" date={date} elapsed={elapsed} />}
{/* Grid */}
<div style={{ border: "3px solid #1a1a1a", display: "inline-block", borderRadius: 4 }}>
{Array.from({ length: 6 }, (_, r) => (
<div key={r} style={{ display: "flex" }}>
{Array.from({ length: 6 }, (_, c) => {
const val = grid[r][c];
const isGiven = !!given[r][c];
const isSelected = r === selR && c === selC;
const isSameVal = selected && val && grid[selR]?.[selC] === val && !isSelected;
const isHighlighted = selected && !isSelected && (r === selR || c === selC || (Math.floor(r / 2) === Math.floor(selR / 2) && Math.floor(c / 3) === Math.floor(selC / 3)));
const err = errors.has(`${r},${c}`);
let bg = "#ffffff";
const bgImage: string | undefined = err ? DIAGONAL_ERROR : undefined;
if (isSelected) bg = "#ccfbf1";
else if (isSameVal) bg = "#99f6e4";
else if (isHighlighted) bg = "#f0fdfa";
const selectedBorder = isSelected ? "2px solid #0d9488" : undefined;
return (
<div
key={c}
onClick={() => !won && setSelected([r, c])}
style={{
width: CELL,
height: CELL,
backgroundColor: bg,
backgroundImage: bgImage,
borderBottom: selectedBorder ?? boxBorderB(r),
borderRight: selectedBorder ?? boxBorderR(c),
borderTop: isSelected ? "2px solid #10b981" : undefined,
borderLeft: isSelected ? "2px solid #10b981" : undefined,
display: "flex",
alignItems: "center",
justifyContent: "center",
cursor: isGiven || won ? "default" : "pointer",
fontSize: CELL * 0.42,
fontWeight: isGiven ? 700 : 500,
color: err ? "#dc2626" : isGiven ? "#1a1a1a" : "#1d4ed8",
transition: "background-color 0.1s",
userSelect: "none",
position: "relative",
zIndex: isSelected ? 1 : 0,
boxSizing: "border-box",
}}
>
{val || ""}
</div>
);
})}
</div>
))}
</div>
{/* Number pad */}
{!won && (
<div className="flex gap-2">
{[1, 2, 3, 4, 5, 6].map(n => (
<button
key={n}
onClick={() => input(n)}
className="w-10 h-10 rounded-lg border border-gray-300 text-gray-700 font-semibold text-base hover:bg-blue-50 hover:border-blue-300 transition-colors"
>
{n}
</button>
))}
<button
onClick={() => input(0)}
className="w-10 h-10 rounded-lg border border-gray-200 text-gray-400 text-xs hover:bg-gray-50 transition-colors"
title="Effacer"
>
</button>
<button
onClick={undo}
disabled={history.current.length === 0}
className="w-10 h-10 rounded-lg border border-gray-200 text-gray-400 text-base hover:bg-gray-50 transition-colors disabled:opacity-30"
title="Annuler (Ctrl+Z)"
>
</button>
</div>
)}
<p className="text-xs text-gray-400 text-center max-w-[300px]">
Chiffres 16 : un seul par ligne, colonne et bloc 2×3. Clic + clavier ou pavé numérique.
</p>
<div className="flex gap-3">
{!won && (
<button onClick={handleHint}
className="px-4 py-2 rounded-xl border border-amber-200 text-amber-600 hover:bg-amber-50 text-sm transition-colors">
Indice
</button>
)}
<button onClick={reset} className="px-4 py-2 rounded-xl border border-gray-200 text-gray-500 hover:bg-gray-50 text-sm transition-colors">
Recommencer
</button>
</div>
</div>
);
}

359
TangoBoard.tsx Normal file
View file

@ -0,0 +1,359 @@
"use client";
import { useState, useEffect, useCallback, useRef } from "react";
import { TangoPuzzle, Cell } from "@/lib/generators/tango";
import WinBanner from "./WinBanner";
interface Props {
puzzle: TangoPuzzle;
date: string;
onSolve?: (elapsed: number) => void;
}
const MAX_CELL = 68;
const CONSTRAINT_SIZE = 22; // badge diameter
function SunIcon({ size }: { size: number }) {
return (
<svg width={size} height={size} viewBox="0 0 40 40">
<circle cx="20" cy="20" r="14" fill="#f97316" />
</svg>
);
}
function MoonIcon({ size }: { size: number }) {
return (
<svg width={size} height={size} viewBox="0 0 40 40">
<defs>
<mask id="moon-mask">
<rect width="40" height="40" fill="white" />
<circle cx="27" cy="17" r="12" fill="black" />
</mask>
</defs>
<circle cx="20" cy="20" r="14" fill="#3b82f6" mask="url(#moon-mask)" />
</svg>
);
}
const DIAGONAL_ERROR = "repeating-linear-gradient(-45deg, rgba(220,38,38,0.55) 0px, rgba(220,38,38,0.55) 3px, rgba(220,38,38,0.2) 3px, rgba(220,38,38,0.2) 6px)";
function checkWin(grid: Cell[][], puzzle: TangoPuzzle): boolean {
const { size, hEdges, vEdges } = puzzle;
for (let r = 0; r < size; r++)
for (let c = 0; c < size; c++)
if (!grid[r][c]) return false;
for (let i = 0; i < size; i++) {
let rs = 0, rm = 0, cs = 0, cm = 0;
for (let j = 0; j < size; j++) {
if (grid[i][j] === "sun") rs++; else rm++;
if (grid[j][i] === "sun") cs++; else cm++;
}
if (rs !== size / 2 || rm !== size / 2 || cs !== size / 2 || cm !== size / 2) return false;
}
for (let i = 0; i < size; i++)
for (let j = 0; j <= size - 3; j++) {
if (grid[i][j] === grid[i][j + 1] && grid[i][j] === grid[i][j + 2]) return false;
if (grid[j][i] === grid[j + 1][i] && grid[j][i] === grid[j + 2][i]) return false;
}
for (let r = 0; r < size; r++)
for (let c = 0; c < size - 1; c++) {
const e = hEdges[r][c]; if (!e) continue;
const same = grid[r][c] === grid[r][c + 1];
if ((e === "=" && !same) || (e === "x" && same)) return false;
}
for (let r = 0; r < size - 1; r++)
for (let c = 0; c < size; c++) {
const e = vEdges[r][c]; if (!e) continue;
const same = grid[r][c] === grid[r + 1][c];
if ((e === "=" && !same) || (e === "x" && same)) return false;
}
return true;
}
function getErrors(grid: Cell[][], puzzle: TangoPuzzle): Set<string> {
const { size, hEdges, vEdges } = puzzle;
const err = new Set<string>();
for (let i = 0; i < size; i++) {
let rs = 0, rm = 0, cs = 0, cm = 0;
for (let j = 0; j < size; j++) {
if (grid[i][j] === "sun") rs++; if (grid[i][j] === "moon") rm++;
if (grid[j][i] === "sun") cs++; if (grid[j][i] === "moon") cm++;
}
if (rs > size / 2) for (let j = 0; j < size; j++) if (grid[i][j] === "sun") err.add(`${i},${j}`);
if (rm > size / 2) for (let j = 0; j < size; j++) if (grid[i][j] === "moon") err.add(`${i},${j}`);
if (cs > size / 2) for (let j = 0; j < size; j++) if (grid[j][i] === "sun") err.add(`${j},${i}`);
if (cm > size / 2) for (let j = 0; j < size; j++) if (grid[j][i] === "moon") err.add(`${j},${i}`);
}
for (let i = 0; i < size; i++)
for (let j = 0; j <= size - 3; j++) {
if (grid[i][j] && grid[i][j] === grid[i][j + 1] && grid[i][j] === grid[i][j + 2]) {
err.add(`${i},${j}`); err.add(`${i},${j + 1}`); err.add(`${i},${j + 2}`);
}
if (grid[j][i] && grid[j][i] === grid[j + 1][i] && grid[j][i] === grid[j + 2][i]) {
err.add(`${j},${i}`); err.add(`${j + 1},${i}`); err.add(`${j + 2},${i}`);
}
}
// Edge constraint violations
for (let r = 0; r < size; r++)
for (let c = 0; c < size - 1; c++) {
const e = hEdges[r][c];
if (!e || !grid[r][c] || !grid[r][c + 1]) continue;
const same = grid[r][c] === grid[r][c + 1];
if ((e === "=" && !same) || (e === "x" && same)) {
err.add(`${r},${c}`); err.add(`${r},${c + 1}`);
}
}
for (let r = 0; r < size - 1; r++)
for (let c = 0; c < size; c++) {
const e = vEdges[r][c];
if (!e || !grid[r][c] || !grid[r + 1][c]) continue;
const same = grid[r][c] === grid[r + 1][c];
if ((e === "=" && !same) || (e === "x" && same)) {
err.add(`${r},${c}`); err.add(`${r + 1},${c}`);
}
}
return err;
}
const STORAGE_KEY = (d: string) => `tango-${d}`;
export default function TangoBoard({ puzzle, date, onSolve }: Props) {
const { size, given, hEdges, vEdges } = puzzle;
const [CELL, setCELL] = useState(() =>
typeof window === "undefined" ? MAX_CELL : Math.min(MAX_CELL, Math.floor((window.innerWidth - 32) / size))
);
const [grid, setGrid] = useState<Cell[][]>(() => {
if (typeof window !== "undefined") {
const s = localStorage.getItem(STORAGE_KEY(date));
if (s) return JSON.parse(s);
}
return given.map(r => [...r]);
});
const history = useRef<Cell[][][]>([]);
const errorTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const [shownErrors, setShownErrors] = useState<Set<string>>(new Set());
const [won, setWon] = useState(false);
const [elapsed, setElapsed] = useState(0);
const [t0] = useState(() => Date.now());
useEffect(() => {
const update = () => setCELL(Math.min(MAX_CELL, Math.floor((window.innerWidth - 32) / size)));
window.addEventListener("resize", update);
return () => window.removeEventListener("resize", update);
}, [size]);
useEffect(() => {
if (won) return;
const id = setInterval(() => setElapsed(Math.floor((Date.now() - t0) / 1000)), 500);
return () => clearInterval(id);
}, [won, t0]);
useEffect(() => {
localStorage.setItem(STORAGE_KEY(date), JSON.stringify(grid));
if (!won && checkWin(grid, puzzle)) { setWon(true); onSolve?.(Math.floor((Date.now() - t0) / 1000)); }
}, [grid, date, puzzle, won, onSolve, t0]);
const filled = grid.flat().filter(Boolean).length;
const fmt = (s: number) => `${String(Math.floor(s / 60)).padStart(2, "0")}:${String(s % 60).padStart(2, "0")}`;
const undo = useCallback(() => {
if (won || history.current.length === 0) return;
setGrid(history.current.pop()!);
}, [won]);
useEffect(() => {
const handler = (e: KeyboardEvent) => {
if ((e.ctrlKey || e.metaKey) && e.key === "z") { e.preventDefault(); undo(); }
};
window.addEventListener("keydown", handler);
return () => window.removeEventListener("keydown", handler);
}, [undo]);
const cycleCell = useCallback((r: number, c: number) => {
if (won || given[r][c] !== null) return;
setGrid(prev => {
history.current.push(prev.map(row => [...row]));
const next = prev.map(row => [...row]);
next[r][c] = next[r][c] === null ? "sun" : next[r][c] === "sun" ? "moon" : null;
return next;
});
}, [won, given]);
const reset = () => { history.current = []; setGrid(given.map(r => [...r])); setWon(false); };
const handleHint = () => {
if (won) return;
for (let r = 0; r < size; r++)
for (let c = 0; c < size; c++)
if (!grid[r][c] && !given[r][c]) {
setGrid(prev => {
const next = prev.map(row => [...row]);
next[r][c] = puzzle.solution[r][c];
return next;
});
return;
}
};
const totalW = size * CELL;
const iconSize = Math.round(CELL * 0.55);
return (
<div className="flex flex-col items-center gap-4">
<div className="flex items-center gap-8 text-sm text-gray-500 font-medium">
<span>{filled} / {size * size}</span>
<span className="tabular-nums">{fmt(elapsed)}</span>
</div>
{won && <WinBanner game="tango" date={date} elapsed={elapsed} />}
{/* Board: positioned cells + constraint symbols on borders */}
<div style={{ position: "relative", width: totalW, height: totalW }}>
{/* Cells */}
<div style={{
display: "grid",
gridTemplateColumns: `repeat(${size}, ${CELL}px)`,
border: "1.5px solid #d1d5db",
borderRadius: 6,
overflow: "hidden",
}}>
{Array.from({ length: size }, (_, r) =>
Array.from({ length: size }, (_, c) => {
const val = grid[r][c];
const isGiven = given[r][c] !== null;
const err = shownErrors.has(`${r},${c}`);
let bg = "#ffffff";
if (isGiven) bg = "#e5e7eb"; // gray for given cells
const bRight = c < size - 1 ? "1px solid #d1d5db" : "none";
const bBottom = r < size - 1 ? "1px solid #d1d5db" : "none";
return (
<div
key={`${r}-${c}`}
onClick={() => cycleCell(r, c)}
style={{
width: CELL, height: CELL,
backgroundColor: bg,
backgroundImage: err ? DIAGONAL_ERROR : undefined,
borderRight: bRight,
borderBottom: bBottom,
cursor: (isGiven || won) ? "default" : "pointer",
display: "flex",
alignItems: "center",
justifyContent: "center",
userSelect: "none",
transition: "background-color 0.1s",
}}
>
<span style={{ pointerEvents: "none" }}>
{val === "sun" && <SunIcon size={iconSize} />}
{val === "moon" && <MoonIcon size={iconSize} />}
</span>
</div>
);
})
)}
</div>
{/* Horizontal constraint symbols: between (r,c) and (r,c+1) — on the vertical border */}
{Array.from({ length: size }, (_, r) =>
Array.from({ length: size - 1 }, (_, c) => {
const e = hEdges[r][c];
if (!e) return null;
return (
<div
key={`h-${r}-${c}`}
style={{
position: "absolute",
left: (c + 1) * CELL - CONSTRAINT_SIZE / 2,
top: r * CELL + (CELL - CONSTRAINT_SIZE) / 2,
width: CONSTRAINT_SIZE,
height: CONSTRAINT_SIZE,
borderRadius: "50%",
background: "#ffffff",
border: "1.5px solid #d1d5db",
display: "flex",
alignItems: "center",
justifyContent: "center",
fontSize: 11,
fontWeight: 700,
color: "#78716c",
zIndex: 10,
pointerEvents: "none",
}}
>
{e === "=" ? "=" : "×"}
</div>
);
})
)}
{/* Vertical constraint symbols: between (r,c) and (r+1,c) — on the horizontal border */}
{Array.from({ length: size - 1 }, (_, r) =>
Array.from({ length: size }, (_, c) => {
const e = vEdges[r][c];
if (!e) return null;
return (
<div
key={`v-${r}-${c}`}
style={{
position: "absolute",
left: c * CELL + (CELL - CONSTRAINT_SIZE) / 2,
top: (r + 1) * CELL - CONSTRAINT_SIZE / 2,
width: CONSTRAINT_SIZE,
height: CONSTRAINT_SIZE,
borderRadius: "50%",
background: "#ffffff",
border: "1.5px solid #d1d5db",
display: "flex",
alignItems: "center",
justifyContent: "center",
fontSize: 11,
fontWeight: 700,
color: "#78716c",
zIndex: 10,
pointerEvents: "none",
}}
>
{e === "=" ? "=" : "×"}
</div>
);
})
)}
</div>
<div className="flex gap-3">
{!won && (
<>
<button
onClick={undo}
disabled={history.current.length === 0}
className="px-5 py-2 rounded-full border border-gray-300 text-gray-500 hover:bg-gray-50 text-sm transition-colors disabled:opacity-30"
title="Annuler (Ctrl+Z)"
>
Annuler
</button>
<button onClick={reset}
className="px-5 py-2 rounded-full border border-gray-300 text-gray-500 hover:bg-gray-50 text-sm transition-colors">
Recommencer
</button>
<button onClick={handleHint}
className="px-5 py-2 rounded-full border border-gray-900 text-gray-900 hover:bg-gray-50 text-sm font-medium transition-colors">
Indice
</button>
</>
)}
</div>
<div className="flex flex-col items-center gap-1 text-xs text-gray-400">
<span>1 clic = · 2 clics = · 3 clics = effacer</span>
<span>Autant de soleils que de lunes par ligne et colonne. Pas plus de 2 identiques consécutifs.</span>
</div>
</div>
);
}

237
WinBanner.tsx Normal file
View file

@ -0,0 +1,237 @@
"use client";
import { useEffect, useState, useCallback } from "react";
import Link from "next/link";
import Confetti from "./Confetti";
import { loadStats, recordSolve, GameStats } from "@/lib/stats";
interface Props {
game: string;
date: string;
elapsed: number;
}
function fmt(s: number) {
return `${String(Math.floor(s / 60)).padStart(2, "0")}:${String(s % 60).padStart(2, "0")}`;
}
/** Add one calendar day to a YYYY-MM-DD string, using local-timezone arithmetic. */
function addOneDay(dateStr: string): string {
const [y, m, d] = dateStr.split("-").map(Number);
const next = new Date(y, m - 1, d + 1);
return `${next.getFullYear()}-${String(next.getMonth() + 1).padStart(2, "0")}-${String(next.getDate()).padStart(2, "0")}`;
}
/** Subtract one calendar day from a YYYY-MM-DD string. */
function subOneDay(dateStr: string): string {
const [y, m, d] = dateStr.split("-").map(Number);
const prev = new Date(y, m - 1, d - 1);
return `${prev.getFullYear()}-${String(prev.getMonth() + 1).padStart(2, "0")}-${String(prev.getDate()).padStart(2, "0")}`;
}
/** Find the first unsolved date after fromDate, skipping already-solved ones. */
function nextUnsolvedDate(game: string, fromDate: string): string {
const { solvedDates } = loadStats(game);
const solved = new Set(solvedDates ?? []);
let d = addOneDay(fromDate);
for (let i = 0; i < 365; i++) {
if (!solved.has(d)) return d;
d = addOneDay(d);
}
return d;
}
function ShareButton({ game, elapsed }: { game: string; elapsed: number }) {
const [copied, setCopied] = useState(false);
const handleShare = useCallback(async () => {
const text = `J'ai résolu le puzzle ${game} en ${fmt(elapsed)} sur Puzzle Trainer ! 🎉`;
const url = typeof window !== "undefined" ? window.location.href : "";
if (navigator.share) {
try {
await navigator.share({ text, url });
return;
} catch {
// user cancelled or not supported
}
}
// Fallback: copy to clipboard
try {
await navigator.clipboard.writeText(`${text}\n${url}`);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
} catch { /* ignore */ }
}, [game, elapsed]);
return (
<button
onClick={handleShare}
className="flex items-center gap-1.5 px-3 py-1.5 rounded-full text-xs font-semibold border border-green-300 text-green-700 hover:bg-green-100 transition-colors"
>
{copied ? (
<>
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2.5} strokeLinecap="round" strokeLinejoin="round">
<polyline points="20 6 9 17 4 12"/>
</svg>
Copié !
</>
) : (
<>
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round">
<circle cx="18" cy="5" r="3"/><circle cx="6" cy="12" r="3"/><circle cx="18" cy="19" r="3"/>
<line x1="8.59" y1="13.51" x2="15.42" y2="17.49"/><line x1="15.41" y1="6.51" x2="8.59" y2="10.49"/>
</svg>
Partager
</>
)}
</button>
);
}
function StatCard({ icon, label, value }: { icon: React.ReactNode; label: string; value: string }) {
return (
<div className="flex flex-col items-center gap-0.5 px-4 py-2.5 bg-white rounded-xl border border-green-100 min-w-[72px]">
<div className="text-green-600">{icon}</div>
<span className="text-[15px] font-bold text-gray-900 tabular-nums leading-none">{value}</span>
<span className="text-[10px] text-gray-400 uppercase tracking-wide">{label}</span>
</div>
);
}
export default function WinBanner({ game, date, elapsed }: Props) {
const [stats, setStats] = useState<GameStats | null>(null);
const [isPersonalRecord, setIsPersonalRecord] = useState(false);
const isDailyDate = /^\d{4}-\d{2}-\d{2}$/.test(date);
const levelMatch = date.match(/^level-\w+-(\d+)$/);
const currentLevel = levelMatch ? parseInt(levelMatch[1]) : null;
useEffect(() => {
if (!isDailyDate) return;
const prevStats = loadStats(game);
const s = recordSolve(game, date, elapsed);
setStats(s);
// Personal record: had a previous best, and new time is better
if (prevStats.total > 0 && prevStats.bestTime > 0 && elapsed < prevStats.bestTime) {
setIsPersonalRecord(true);
}
}, [game, date, elapsed, isDailyDate]);
const displayStats = stats ?? loadStats(game);
const nextHref = isDailyDate
? `/${game}/${nextUnsolvedDate(game, date)}`
: currentLevel !== null
? `/${game}/level/${currentLevel + 1}`
: null;
const nextLabel = isDailyDate ? "Grille suivante" : `Niveau ${(currentLevel ?? 0) + 1}`;
const prevHref = isDailyDate
? `/${game}/${subOneDay(date)}`
: currentLevel !== null && currentLevel > 1
? `/${game}/level/${currentLevel - 1}`
: null;
const prevLabel = isDailyDate ? "Hier" : currentLevel !== null ? `Niveau ${currentLevel - 1}` : "";
const levelsHref = `/${game}/levels`;
return (
<>
<Confetti />
{/* Main banner */}
<div className="win-banner w-full max-w-sm bg-gradient-to-b from-green-50 to-white border border-green-200 rounded-2xl px-6 py-5 flex flex-col items-center gap-3 shadow-[0_4px_16px_0_rgb(22_163_74/0.10)]">
{/* Trophy + title */}
<div className="flex items-center gap-2">
<svg width="22" height="22" viewBox="0 0 24 24" fill="#16a34a" className="shrink-0">
<path d="M19 3H5v2h14V3zM6 5v8a6 6 0 0012 0V5H6zm6 11a4 4 0 01-4-4V7h8v5a4 4 0 01-4 4zm-6 2h12v2H6v-2z"/>
</svg>
<span className="text-green-800 font-bold text-lg tracking-tight">Résolu !</span>
{isPersonalRecord && (
<span className="text-[10px] font-bold bg-amber-400 text-white px-1.5 py-0.5 rounded-full uppercase tracking-wide">
PR
</span>
)}
</div>
{/* Time */}
<div className="text-4xl font-black text-green-700 tabular-nums tracking-tight timer-mono">
{fmt(elapsed)}
</div>
{/* Stats row (daily only) */}
{isDailyDate && (
<div className="flex gap-2 mt-1">
<StatCard
icon={
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round">
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2z" fill="#dcfce7" stroke="#16a34a"/>
<polyline points="12 6 12 12 16 14" stroke="#16a34a"/>
</svg>
}
label="Meilleur"
value={displayStats.bestTime > 0 ? fmt(displayStats.bestTime) : "--:--"}
/>
<StatCard
icon={
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="#16a34a" strokeWidth={2} strokeLinecap="round">
<path d="M13 2L3 14h9l-1 8 10-12h-9l1-8z"/>
</svg>
}
label="Série"
value={`${displayStats.streak}j`}
/>
<StatCard
icon={
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="#16a34a" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round">
<polyline points="9 11 12 14 22 4"/><path d="M21 12v7a2 2 0 01-2 2H5a2 2 0 01-2-2V5a2 2 0 012-2h11"/>
</svg>
}
label="Total"
value={String(displayStats.total)}
/>
</div>
)}
{/* Share button */}
{isDailyDate && (
<ShareButton game={game} elapsed={elapsed} />
)}
</div>
{/* Navigation */}
<div className="flex items-center gap-3 flex-wrap justify-center">
{prevHref && (
<Link
href={prevHref}
className="flex items-center gap-1 px-4 py-2 rounded-full border border-gray-200 text-gray-500 text-sm hover:border-gray-300 hover:text-gray-700 transition-colors"
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round"><polyline points="15 18 9 12 15 6"/></svg>
{prevLabel}
</Link>
)}
{nextHref && (
<Link
href={nextHref}
className="flex items-center gap-1 px-5 py-2 rounded-full bg-gray-900 text-white text-sm font-semibold hover:bg-gray-700 transition-colors"
>
{nextLabel}
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round"><polyline points="9 18 15 12 9 6"/></svg>
</Link>
)}
{isDailyDate && (
<Link
href={levelsHref}
className="px-4 py-2 rounded-full border border-gray-200 text-gray-500 text-sm hover:border-gray-300 hover:text-gray-700 transition-colors"
>
Entraînement
</Link>
)}
</div>
</>
);
}

302
ZipBoard.tsx Normal file
View file

@ -0,0 +1,302 @@
"use client";
import { useState, useEffect, useCallback, useRef, useMemo } from "react";
import { ZipPuzzle, canMove } from "@/lib/generators/zip";
import WinBanner from "./WinBanner";
interface Props {
puzzle: ZipPuzzle;
date: string;
onSolve?: (elapsed: number) => void;
}
const WALL_W = 5;
const PATH_COLOR = "#f97316"; // orange — matches LinkedIn
const PATH_DONE = "#10b981"; // green on win
const PATH_BG = "#fde8d0"; // light salmon for path cells
const CELL_1_BG = "#fddcbb"; // slightly deeper salmon for waypoint 1 cell
const STORAGE_KEY = (d: string) => `zip-${d}`;
function cellKey(r: number, c: number) { return `${r},${c}`; }
const MAX_CELL = 52;
export default function ZipBoard({ puzzle, date, onSolve }: Props) {
const { size, path: solution, numberedCells, walls } = puzzle;
const totalCells = size * size;
const [CELL, setCELL] = useState(() =>
typeof window === "undefined" ? MAX_CELL : Math.min(MAX_CELL, Math.floor((window.innerWidth - 32) / size))
);
const STEP = CELL;
const [userPath, setUserPath] = useState<[number, number][]>(() => {
if (typeof window !== "undefined") {
const s = localStorage.getItem(STORAGE_KEY(date));
if (s) return JSON.parse(s);
}
return [];
});
const pathHistory = useRef<[number, number][][]>([]);
const [dragging, setDragging] = useState(false);
const [won, setWon] = useState(false);
const [elapsed, setElapsed] = useState(0);
const [t0] = useState(() => Date.now());
const boardRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const update = () => setCELL(Math.min(MAX_CELL, Math.floor((window.innerWidth - 32) / size)));
window.addEventListener("resize", update);
return () => window.removeEventListener("resize", update);
}, [size]);
useEffect(() => {
if (won) return;
const id = setInterval(() => setElapsed(Math.floor((Date.now() - t0) / 1000)), 500);
return () => clearInterval(id);
}, [won, t0]);
const checkWin = useCallback((p: [number, number][]) => {
if (p.length !== totalCells) return false;
const waypointCells = Object.entries(numberedCells).sort((a, b) => a[1] - b[1]).map(([k]) => k);
let wi = 0;
for (const [r, c] of p) {
if (wi < waypointCells.length && cellKey(r, c) === waypointCells[wi]) wi++;
}
return wi === waypointCells.length;
}, [totalCells, numberedCells]);
useEffect(() => {
localStorage.setItem(STORAGE_KEY(date), JSON.stringify(userPath));
if (!won && checkWin(userPath)) { setWon(true); onSolve?.(Math.floor((Date.now() - t0) / 1000)); }
}, [userPath, date, won, checkWin, onSolve]);
const fmt = (s: number) => `${String(Math.floor(s / 60)).padStart(2, "0")}:${String(s % 60).padStart(2, "0")}`;
const pathSet = new Set(userPath.map(([r, c]) => cellKey(r, c)));
function getCellFromPoint(clientX: number, clientY: number): [number, number] | null {
const board = boardRef.current;
if (!board) return null;
const rect = board.getBoundingClientRect();
const c = Math.floor((clientX - rect.left) / STEP);
const r = Math.floor((clientY - rect.top) / STEP);
if (r < 0 || r >= size || c < 0 || c >= size) return null;
return [r, c];
}
const undo = useCallback(() => {
if (won || pathHistory.current.length === 0) return;
setUserPath(pathHistory.current.pop()!);
}, [won]);
useEffect(() => {
const handler = (e: KeyboardEvent) => {
if ((e.ctrlKey || e.metaKey) && e.key === "z") { e.preventDefault(); undo(); }
};
window.addEventListener("keydown", handler);
return () => window.removeEventListener("keydown", handler);
}, [undo]);
const startPath = useCallback((r: number, c: number) => {
if (won) return;
setDragging(true);
const key = numberedCells[cellKey(r, c)];
if (key === 1) {
pathHistory.current.push([...userPath]);
setUserPath([[r, c]]);
return;
}
const last = userPath[userPath.length - 1];
if (last && last[0] === r && last[1] === c) return;
if (key) {
pathHistory.current.push([...userPath]);
setUserPath([[r, c]]);
}
}, [won, numberedCells, userPath]);
const extendPath = useCallback((r: number, c: number) => {
if (!dragging || won) return;
setUserPath(prev => {
if (!prev.length) return prev;
const last = prev[prev.length - 1];
const key = cellKey(r, c);
const dr = Math.abs(last[0] - r), dc = Math.abs(last[1] - c);
if (dr + dc !== 1) return prev;
if (!canMove(walls, last[0], last[1], r, c)) return prev;
if (prev.length >= 2 && prev[prev.length - 2][0] === r && prev[prev.length - 2][1] === c)
return prev.slice(0, -1);
if (pathSet.has(key)) return prev;
return [...prev, [r, c]];
});
}, [dragging, won, pathSet, walls]);
const handleMouseDown = (e: React.MouseEvent, r: number, c: number) => {
e.preventDefault(); startPath(r, c);
};
const handleMouseMove = (e: React.MouseEvent) => {
if (!dragging) return;
const cell = getCellFromPoint(e.clientX, e.clientY);
if (cell) extendPath(cell[0], cell[1]);
};
const handleTouchStart = (e: React.TouchEvent, r: number, c: number) => {
e.preventDefault(); startPath(r, c);
};
const handleTouchMove = (e: React.TouchEvent) => {
if (!dragging) return;
const t = e.touches[0];
const cell = getCellFromPoint(t.clientX, t.clientY);
if (cell) extendPath(cell[0], cell[1]);
};
// Thick orange polyline with rounded joins — matches LinkedIn visual
function renderPath() {
if (userPath.length < 1) return null;
const color = won ? PATH_DONE : PATH_COLOR;
const pts = userPath.map(([r, c]) => `${c * STEP + CELL / 2},${r * STEP + CELL / 2}`).join(" ");
return (
<svg
style={{ position: "absolute", inset: 0, pointerEvents: "none", zIndex: 5 }}
width={size * STEP} height={size * STEP}
>
<polyline
points={pts}
stroke={color}
strokeWidth={CELL * 0.46}
strokeLinecap="round"
strokeLinejoin="round"
fill="none"
opacity={0.85}
/>
</svg>
);
}
const handleHint = () => {
if (won) return;
let matchLen = 0;
for (let i = 0; i < Math.min(userPath.length, solution.length); i++) {
if (userPath[i][0] === solution[i][0] && userPath[i][1] === solution[i][1]) matchLen = i + 1;
else break;
}
if (matchLen < solution.length)
setUserPath(solution.slice(0, matchLen + 1) as [number, number][]);
};
const reset = () => { pathHistory.current = []; setUserPath([]); setWon(false); };
const boardSize = size * STEP;
const waypointR = Math.round(CELL * 0.35); // radius of black circle
return (
<div className="flex flex-col items-center gap-4">
<div className="flex items-center gap-8 text-sm text-gray-500 font-medium">
<span>{userPath.length} / {totalCells}</span>
<span className="tabular-nums">{fmt(elapsed)}</span>
</div>
{won && <WinBanner game="zip" date={date} elapsed={elapsed} />}
<div
ref={boardRef}
style={{
position: "relative", width: boardSize, height: boardSize,
userSelect: "none", touchAction: "none",
borderRadius: 8, overflow: "hidden",
border: "1.5px solid #d1d5db",
background: "#ffffff",
}}
onMouseMove={handleMouseMove}
onMouseUp={() => setDragging(false)}
onMouseLeave={() => setDragging(false)}
onTouchMove={handleTouchMove}
onTouchEnd={() => setDragging(false)}
>
{renderPath()}
{Array.from({ length: size }, (_, r) =>
Array.from({ length: size }, (_, c) => {
const key = cellKey(r, c);
const inPath = pathSet.has(key);
const num = numberedCells[key];
const w = walls[r][c];
const isStart = num === 1;
const bTop = (w & 8) ? `${WALL_W}px solid #1a1a1a` : "1px solid #e5e7eb";
const bBottom = (w & 2) ? `${WALL_W}px solid #1a1a1a` : "1px solid #e5e7eb";
const bLeft = (w & 4) ? `${WALL_W}px solid #1a1a1a` : "1px solid #e5e7eb";
const bRight = (w & 1) ? `${WALL_W}px solid #1a1a1a` : "1px solid #e5e7eb";
let bg = "#ffffff";
if (inPath) bg = won ? "#d1fae5" : PATH_BG;
else if (isStart) bg = CELL_1_BG;
return (
<div
key={key}
onMouseDown={e => handleMouseDown(e, r, c)}
onTouchStart={e => handleTouchStart(e, r, c)}
style={{
position: "absolute",
left: c * STEP, top: r * STEP,
width: CELL, height: CELL,
backgroundColor: bg,
borderTop: bTop, borderBottom: bBottom,
borderLeft: bLeft, borderRight: bRight,
boxSizing: "border-box",
display: "flex", alignItems: "center", justifyContent: "center",
cursor: "crosshair",
zIndex: num ? 10 : 2,
transition: "background-color 0.08s",
}}
>
{num && (
<div style={{
width: waypointR * 2,
height: waypointR * 2,
borderRadius: "50%",
background: "#1a1a1a",
display: "flex", alignItems: "center", justifyContent: "center",
color: "#ffffff",
fontSize: CELL * 0.28,
fontWeight: 700,
zIndex: 20,
position: "relative",
flexShrink: 0,
}}>{num}</div>
)}
</div>
);
})
)}
</div>
<div className="flex gap-3">
{!won && (
<>
<button
onClick={undo}
disabled={pathHistory.current.length === 0}
className="px-5 py-2 rounded-full border border-gray-300 text-gray-500 hover:bg-gray-50 text-sm transition-colors disabled:opacity-30"
title="Annuler (Ctrl+Z)"
>
Annuler
</button>
<button onClick={reset}
className="px-5 py-2 rounded-full border border-gray-300 text-gray-500 hover:bg-gray-50 text-sm transition-colors">
Recommencer
</button>
<button onClick={handleHint}
className="px-5 py-2 rounded-full border border-gray-900 text-gray-900 hover:bg-gray-50 text-sm font-medium transition-colors">
Indice
</button>
</>
)}
</div>
<p className="text-xs text-gray-400 text-center max-w-xs">
Glissez du <b>1</b> en passant par les chiffres dans l&apos;ordre, en couvrant toutes les cases.
</p>
</div>
);
}

25
app/apple-icon.tsx Normal file
View file

@ -0,0 +1,25 @@
import { ImageResponse } from "next/og";
export const size = { width: 180, height: 180 };
export const contentType = "image/png";
export default function AppleIcon() {
return new ImageResponse(
(
<div
style={{
width: "100%",
height: "100%",
display: "flex",
alignItems: "center",
justifyContent: "center",
background: "linear-gradient(145deg, #111827 0%, #1e3a5f 100%)",
borderRadius: "22%",
}}
>
<span style={{ fontSize: 108, lineHeight: 1 }}>🧩</span>
</div>
),
{ ...size }
);
}

241
app/archive/page.tsx Normal file
View file

@ -0,0 +1,241 @@
"use client";
import { useMemo, useState, useEffect, Suspense } from "react";
import Link from "next/link";
import { useSearchParams } from "next/navigation";
import { todayISO } from "@/lib/rng";
const GAMES = [
{
key: "queens",
label: "Queens",
symbol: "♛",
href: (d: string) => `/queens/${d}`,
storageKey: (d: string) => `queens-${d}`,
isWon: (s: string) => {
try { return (JSON.parse(s) as string[][]).flat().filter(c => c === "queen").length >= 6; } catch { return false; }
},
},
{
key: "tango",
label: "Tango",
symbol: "☀",
href: (d: string) => `/tango/${d}`,
storageKey: (d: string) => `tango-${d}`,
isWon: (s: string) => {
try { return (JSON.parse(s) as (string | null)[][]).flat().every(c => c !== null); } catch { return false; }
},
},
{
key: "zip",
label: "Zip",
symbol: "∞",
href: (d: string) => `/zip/${d}`,
storageKey: (d: string) => `zip-${d}`,
isWon: (s: string) => {
try { return (JSON.parse(s) as unknown[]).length === 25; } catch { return false; }
},
},
{
key: "sudoku",
label: "Sudoku",
symbol: "#",
href: (d: string) => `/sudoku/${d}`,
storageKey: (d: string) => `sudoku-${d}`,
isWon: (s: string) => {
try { return (JSON.parse(s) as number[][]).flat().every(n => n > 0); } catch { return false; }
},
},
{
key: "patches",
label: "Patches",
symbol: "▦",
href: (d: string) => `/patches/${d}`,
storageKey: (d: string) => `patches-${d}`,
isWon: (s: string) => {
try {
const p = JSON.parse(s);
// patches stores the placed pieces array
return Array.isArray(p) && p.length > 0;
} catch { return false; }
},
},
];
function getPastDates(n: number): string[] {
const dates: string[] = [];
const d = new Date();
for (let i = 0; i < n; i++) {
const y = d.getFullYear();
const m = String(d.getMonth() + 1).padStart(2, "0");
const day = String(d.getDate()).padStart(2, "0");
dates.push(`${y}-${m}-${day}`);
d.setDate(d.getDate() - 1);
}
return dates;
}
function fmtDate(iso: string, today: string): string {
if (iso === today) return "Aujourd'hui";
const [y, m, d] = iso.split("-").map(Number);
const date = new Date(y, m - 1, d);
const yesterday = new Date();
yesterday.setDate(yesterday.getDate() - 1);
if (iso === getPastDates(2)[1]) return "Hier";
return date.toLocaleDateString("fr-FR", { weekday: "short", day: "numeric", month: "short" });
}
function GameChip({ game, date, won }: { game: typeof GAMES[0]; date: string; won: boolean | undefined }) {
return (
<Link
href={game.href(date)}
className={`flex items-center gap-1.5 px-2.5 py-1 rounded-lg text-xs font-medium transition-colors border ${
won
? "bg-green-50 border-green-200 text-green-700 hover:bg-green-100"
: "bg-gray-50 border-gray-100 text-gray-500 hover:bg-gray-100 hover:text-gray-700"
}`}
>
<span className="text-[11px] opacity-60">{game.symbol}</span>
<span>{game.label}</span>
{won && (
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={3} strokeLinecap="round" strokeLinejoin="round" className="text-green-600">
<polyline points="20 6 9 17 4 12"/>
</svg>
)}
</Link>
);
}
function ArchiveContent() {
const params = useSearchParams();
const filter = params.get("game") ?? "all";
const [progress, setProgress] = useState<Record<string, boolean | undefined>>({});
const today = todayISO();
const dates = useMemo(() => getPastDates(90), []);
useEffect(() => {
const rec: Record<string, boolean | undefined> = {};
for (const game of GAMES) {
for (const date of dates) {
const s = localStorage.getItem(game.storageKey(date));
if (s) {
rec[`${game.key}-${date}`] = game.isWon(s);
}
}
}
setProgress(rec);
}, [dates]);
const visibleGames = filter === "all" ? GAMES : GAMES.filter(g => g.key === filter);
// Compute completion summary per visible game
const summary = useMemo(() => {
return visibleGames.map(game => {
const solved = dates.filter(d => progress[`${game.key}-${d}`] === true).length;
return { key: game.key, label: game.label, solved };
});
}, [visibleGames, dates, progress]);
return (
<div className="flex flex-col gap-6 max-w-2xl mx-auto">
{/* Header */}
<div className="text-center">
<h1 className="text-2xl font-bold text-gray-900 tracking-tight">Archives</h1>
<p className="text-gray-400 text-sm mt-1">90 derniers jours</p>
</div>
{/* Filter tabs */}
<div className="flex gap-1.5 flex-wrap justify-center">
{[{ k: "all", l: "Tous", sym: "" }, ...GAMES.map(g => ({ k: g.key, l: g.label, sym: g.symbol }))].map(({ k, l, sym }) => (
<Link
key={k}
href={k === "all" ? "/archive" : `/archive?game=${k}`}
className={`flex items-center gap-1 px-3 py-1.5 rounded-full text-sm font-medium transition-colors ${
filter === k
? "bg-gray-900 text-white"
: "bg-white text-gray-500 border border-gray-200 hover:bg-gray-50 hover:text-gray-700"
}`}
>
{sym && <span className="text-[12px] opacity-70">{sym}</span>}
{l}
</Link>
))}
</div>
{/* Completion summary chips */}
{Object.keys(progress).length > 0 && (
<div className="flex gap-2 flex-wrap justify-center">
{summary.map(({ key, label, solved }) => (
<div key={key} className="flex items-center gap-1.5 text-xs text-gray-500 bg-white border border-gray-100 rounded-full px-3 py-1">
<span className="font-semibold text-gray-700">{solved}</span>
<span>/ 90</span>
<span className="text-gray-400">{label}</span>
</div>
))}
</div>
)}
{/* Date rows */}
<div className="flex flex-col gap-1.5">
{dates.map(date => {
const isToday = date === today;
const dateLabel = fmtDate(date, today);
const solvedAll = visibleGames.every(g => progress[`${g.key}-${date}`] === true);
const solvedCount = visibleGames.filter(g => progress[`${g.key}-${date}`] === true).length;
return (
<div
key={date}
className={`flex items-center gap-3 px-4 py-2.5 bg-white rounded-xl border transition-colors ${
isToday ? "border-amber-200 shadow-sm" : "border-gray-100"
}`}
>
{/* Date label */}
<div className="w-24 shrink-0 flex flex-col">
<span className={`text-sm font-semibold leading-tight ${isToday ? "text-amber-700" : "text-gray-700"}`}>
{dateLabel}
</span>
{isToday && (
<span className="text-[10px] text-amber-500 font-medium">Aujourd&apos;hui</span>
)}
</div>
{/* Game chips */}
<div className="flex flex-wrap gap-1.5 flex-1 min-w-0">
{visibleGames.map(game => (
<GameChip
key={game.key}
game={game}
date={date}
won={progress[`${game.key}-${date}`]}
/>
))}
</div>
{/* Right: completion indicator */}
{solvedCount > 0 && (
<div className={`shrink-0 text-xs font-semibold tabular-nums ${solvedAll ? "text-green-600" : "text-gray-400"}`}>
{solvedCount}/{visibleGames.length}
</div>
)}
</div>
);
})}
</div>
</div>
);
}
export default function ArchivePage() {
return (
<Suspense fallback={
<div className="flex flex-col gap-3 max-w-2xl mx-auto py-8">
{Array.from({ length: 10 }).map((_, i) => (
<div key={i} className="skeleton h-12 rounded-xl" />
))}
</div>
}>
<ArchiveContent />
</Suspense>
);
}

View file

@ -0,0 +1,98 @@
"use client";
import Link from "next/link";
import { GAME_META, GAMES, GameId } from "@/lib/levels";
import { GAME_RULES } from "@/lib/rules";
function GameSection({ game }: { game: GameId }) {
const { name, accent, symbol } = GAME_META[game];
const rules = GAME_RULES[game];
return (
<div className="bg-white rounded-2xl border border-gray-100 p-5 flex flex-col gap-4">
<div className="flex items-center gap-3">
<span
className="w-10 h-10 rounded-xl flex items-center justify-center text-lg font-bold shrink-0"
style={{ background: `${accent}18`, color: accent }}
aria-hidden
>
{symbol}
</span>
<div>
<h2 className="text-base font-bold text-gray-900">{name}</h2>
<div className="flex items-center gap-2 mt-0.5">
<p className="text-xs text-gray-400">{rules.subtitle}</p>
<span className="text-[10px] text-gray-300">·</span>
<p className="text-xs text-gray-400">{rules.duration}</p>
</div>
</div>
</div>
<div className="flex flex-col gap-2">
{rules.howToPlay.map((rule, i) => (
<div key={i} className="flex items-start gap-3">
<span
className="w-5 h-5 rounded-full flex items-center justify-center text-[10px] font-bold shrink-0 mt-0.5"
style={{ background: `${accent}18`, color: accent }}
>
{i + 1}
</span>
<p className="text-sm text-gray-700 leading-snug">{rule}</p>
</div>
))}
</div>
{rules.tip && (
<div className="flex items-start gap-2.5 px-3 py-2.5 rounded-xl" style={{ background: `${accent}0d` }}>
<span className="text-sm shrink-0 mt-px">💡</span>
<p className="text-xs text-gray-600 leading-snug">{rules.tip}</p>
</div>
)}
<Link
href={`/${game}`}
className="flex items-center justify-center gap-2 py-2.5 rounded-xl text-white text-sm font-semibold transition-opacity hover:opacity-90"
style={{ background: accent }}
>
Jouer à {name}
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2.5} strokeLinecap="round"><polyline points="9 18 15 12 9 6"/></svg>
</Link>
</div>
);
}
export default function HowToPlayPage() {
return (
<div className="flex flex-col gap-6 max-w-sm mx-auto py-4 px-4">
{/* Header */}
<div className="flex items-center gap-3">
<Link href="/" className="text-gray-400 hover:text-gray-600 transition-colors">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round"><polyline points="15 18 9 12 15 6"/></svg>
</Link>
<div>
<h1 className="text-xl font-bold text-gray-900 tracking-tight">Comment jouer</h1>
<p className="text-xs text-gray-400 mt-0.5">Les règles de chaque puzzle</p>
</div>
</div>
{/* Intro */}
<div className="bg-gray-50 rounded-2xl p-4 text-sm text-gray-600 leading-relaxed">
<p>
Puzzle Trainer propose 5 puzzles logiques renouvelés chaque jour. Chaque puzzle peut aussi être pratiqué
en mode entraînement avec 100 niveaux de difficulté progressive.
</p>
</div>
{/* Per-game sections */}
<div className="flex flex-col gap-4">
{GAMES.map(game => (
<GameSection key={game} game={game} />
))}
</div>
<Link href="/" className="text-xs text-gray-400 hover:text-gray-600 transition-colors text-center pb-2">
Retour aux puzzles du jour
</Link>
</div>
);
}

BIN
app/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

212
app/globals.css Normal file
View file

@ -0,0 +1,212 @@
@import "tailwindcss";
/* ── Design tokens ──────────────────────────────────────────────────────────── */
:root {
--radius-sm: 6px;
--radius-md: 12px;
--radius-lg: 20px;
--radius-full: 9999px;
--shadow-card: 0 1px 3px 0 rgb(0 0 0 / 0.06), 0 1px 2px -1px rgb(0 0 0 / 0.04);
--shadow-hover: 0 4px 12px 0 rgb(0 0 0 / 0.08), 0 2px 4px -1px rgb(0 0 0 / 0.05);
--shadow-win: 0 8px 24px 0 rgb(34 197 94 / 0.18);
--transition-base: 150ms ease;
--transition-smooth: 250ms cubic-bezier(0.4, 0, 0.2, 1);
}
/* ── PWA / iOS native feel ──────────────────────────────────────────────────── */
html, body {
/* Full height accounting for iOS dynamic viewport */
height: 100%;
min-height: -webkit-fill-available;
}
body {
/* Prevent bounce scroll on iOS (native app feel) */
overscroll-behavior: none;
/* Prevent text selection on interactive elements */
-webkit-touch-callout: none;
}
/* Tap targets: no highlight flash on iOS */
* { -webkit-tap-highlight-color: transparent; }
/* Game grids: no text selection, instant touch response */
.game-grid, [data-game-grid] {
touch-action: manipulation;
user-select: none;
-webkit-user-select: none;
}
/* ── Reset & base ───────────────────────────────────────────────────────────── */
* { box-sizing: border-box; }
html {
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
scroll-behavior: smooth;
color-scheme: light;
}
body {
background: #f8fafc;
color: #09090b;
font-family: -apple-system, BlinkMacSystemFont, "Inter", "Segoe UI", "Helvetica Neue", Arial, sans-serif;
line-height: 1.5;
}
/* ── Focus visible ──────────────────────────────────────────────────────────── */
:focus-visible {
outline: 2px solid #3b82f6;
outline-offset: 2px;
border-radius: 4px;
}
/* ── Scrollbar ──────────────────────────────────────────────────────────────── */
::-webkit-scrollbar { width: 6px; height: 6px; }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb { background: #d1d5db; border-radius: 3px; }
::-webkit-scrollbar-thumb:hover { background: #9ca3af; }
/* ── Page fade-in ───────────────────────────────────────────────────────────── */
@keyframes fadeUp {
from { opacity: 0; transform: translateY(8px); }
to { opacity: 1; transform: translateY(0); }
}
main > div { animation: fadeUp 0.25s ease both; }
/* ── Win banner animation ───────────────────────────────────────────────────── */
@keyframes popIn {
0% { transform: scale(0.85); opacity: 0; }
60% { transform: scale(1.04); }
100% { transform: scale(1); opacity: 1; }
}
.win-banner {
animation: popIn 0.38s cubic-bezier(0.34, 1.56, 0.64, 1) both;
}
/* ── Cell flash (win feedback) ──────────────────────────────────────────────── */
@keyframes cellWin {
0% { background-color: inherit; }
40% { background-color: #bbf7d0; }
100% { background-color: inherit; }
}
.cell-win { animation: cellWin 0.6s ease both; }
/* ── Shake animation (error) ────────────────────────────────────────────────── */
@keyframes shake {
0%, 100% { transform: translateX(0); }
20% { transform: translateX(-4px); }
40% { transform: translateX(4px); }
60% { transform: translateX(-3px); }
80% { transform: translateX(3px); }
}
.shake { animation: shake 0.35s ease; }
/* ── Skeleton loading ───────────────────────────────────────────────────────── */
@keyframes shimmer {
0% { background-position: -200% 0; }
100% { background-position: 200% 0; }
}
.skeleton {
background: linear-gradient(90deg, #f1f5f9 25%, #e2e8f0 50%, #f1f5f9 75%);
background-size: 200% 100%;
animation: shimmer 1.4s ease infinite;
border-radius: var(--radius-md);
}
/* ── Progress bar ───────────────────────────────────────────────────────────── */
.progress-track {
height: 5px;
border-radius: var(--radius-full);
background: #e2e8f0;
overflow: hidden;
}
.progress-fill {
height: 100%;
border-radius: var(--radius-full);
transition: width 0.7s cubic-bezier(0.4, 0, 0.2, 1);
}
/* ── Timer display ──────────────────────────────────────────────────────────── */
.timer-mono {
font-variant-numeric: tabular-nums;
letter-spacing: 0.05em;
}
/* ── Pulse ring (current level) ─────────────────────────────────────────────── */
@keyframes pulseRing {
0%, 100% { box-shadow: 0 0 0 0px currentColor; }
50% { box-shadow: 0 0 0 3px currentColor; }
}
.level-current { animation: pulseRing 1.8s ease-in-out infinite; }
/* ── Solved card overlay ────────────────────────────────────────────────────── */
.card-solved {
position: relative;
}
.card-solved::after {
content: "✓";
position: absolute;
top: 6px;
right: 8px;
font-size: 11px;
font-weight: 700;
color: #16a34a;
opacity: 0.8;
}
/* ── Dark mode ──────────────────────────────────────────────────────────────── */
@media (prefers-color-scheme: dark) {
html { color-scheme: dark; }
body {
background: #0f1117;
color: #f1f5f9;
}
/* Cards & panels */
.bg-white { background: #1e2130 !important; }
.border-gray-100 { border-color: #2a2f42 !important; }
.border-gray-200 { border-color: #363b52 !important; }
.bg-gray-50 { background: #181c27 !important; }
.bg-gray-100 { background: #252a3a !important; }
/* Text */
.text-gray-900 { color: #f1f5f9 !important; }
.text-gray-800 { color: #e2e8f0 !important; }
.text-gray-700 { color: #cbd5e1 !important; }
.text-gray-600 { color: #94a3b8 !important; }
.text-gray-500 { color: #64748b !important; }
.text-gray-400 { color: #475569 !important; }
.text-gray-300 { color: #374151 !important; }
/* Header */
header { background: rgba(15, 17, 23, 0.92) !important; border-color: #1e2130 !important; }
/* Footer */
footer { background: #0f1117 !important; border-color: #1e2130 !important; }
/* Skeleton */
.skeleton {
background: linear-gradient(90deg, #1e2130 25%, #252a3a 50%, #1e2130 75%) !important;
}
/* Progress track */
.progress-track { background: #252a3a !important; }
/* Scrollbar */
::-webkit-scrollbar-thumb { background: #363b52; }
::-webkit-scrollbar-thumb:hover { background: #475569; }
/* Bottom nav */
nav.fixed.bottom-0 { background: rgba(15, 17, 23, 0.95) !important; border-color: #1e2130 !important; }
}

34
app/icon.tsx Normal file
View file

@ -0,0 +1,34 @@
import { ImageResponse } from "next/og";
export const size = { width: 512, height: 512 };
export const contentType = "image/png";
export default function Icon() {
return new ImageResponse(
(
<div
style={{
width: "100%",
height: "100%",
display: "flex",
alignItems: "center",
justifyContent: "center",
background: "linear-gradient(145deg, #111827 0%, #1e3a5f 100%)",
borderRadius: "20%",
}}
>
<div
style={{
display: "flex",
flexDirection: "column",
alignItems: "center",
gap: 6,
}}
>
<span style={{ fontSize: 240, lineHeight: 1 }}>🧩</span>
</div>
</div>
),
{ ...size }
);
}

89
app/layout.tsx Normal file
View file

@ -0,0 +1,89 @@
import type { Metadata, Viewport } from "next";
import "./globals.css";
import BottomNav from "@/components/BottomNav";
export const viewport: Viewport = {
width: "device-width",
initialScale: 1,
viewportFit: "cover",
userScalable: false,
themeColor: "#111827",
};
export const metadata: Metadata = {
title: { default: "Puzzle Trainer", template: "%s — Puzzle Trainer" },
description: "5 puzzles logiques chaque jour. Queens, Tango, Zip, Sudoku, Patches.",
applicationName: "Puzzle Trainer",
appleWebApp: {
capable: true,
title: "Puzzle Trainer",
statusBarStyle: "black-translucent",
},
openGraph: {
title: "Puzzle Trainer",
description: "5 puzzles logiques chaque jour.",
url: "https://puzzles.reverdin.eu",
siteName: "Puzzle Trainer",
locale: "fr_FR",
type: "website",
},
twitter: {
card: "summary_large_image",
title: "Puzzle Trainer",
description: "5 puzzles logiques chaque jour.",
},
metadataBase: new URL("https://puzzles.reverdin.eu"),
manifest: "/manifest.json",
formatDetection: { telephone: false },
};
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="fr" className="h-full">
<head>
{/* iOS standalone — critical meta tags */}
<meta name="mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
<meta name="apple-mobile-web-app-title" content="Puzzles" />
{/* iOS splash screens — key iPhone sizes */}
<link rel="apple-touch-startup-image"
href="/splash/splash-1290x2796.png"
media="(device-width: 430px) and (device-height: 932px) and (-webkit-device-pixel-ratio: 3)" />
<link rel="apple-touch-startup-image"
href="/splash/splash-1179x2556.png"
media="(device-width: 393px) and (device-height: 852px) and (-webkit-device-pixel-ratio: 3)" />
<link rel="apple-touch-startup-image"
href="/splash/splash-1170x2532.png"
media="(device-width: 390px) and (device-height: 844px) and (-webkit-device-pixel-ratio: 3)" />
<link rel="apple-touch-startup-image"
href="/splash/splash-1080x2340.png"
media="(device-width: 360px) and (device-height: 780px) and (-webkit-device-pixel-ratio: 3)" />
<link rel="apple-touch-startup-image"
href="/splash/splash-750x1334.png"
media="(device-width: 375px) and (device-height: 667px) and (-webkit-device-pixel-ratio: 2)" />
{/* Service worker registration */}
<script dangerouslySetInnerHTML={{ __html: `
if ('serviceWorker' in navigator) {
window.addEventListener('load', function() {
navigator.serviceWorker.register('/sw.js');
});
}
`}} />
</head>
<body className="h-full bg-[#f8fafc]">
{/* Main content — padded for top safe area and bottom nav */}
<main className="flex-1 overflow-y-auto" style={{ paddingTop: "env(safe-area-inset-top)" }}>
<div className="pt-4 pb-[calc(72px+env(safe-area-inset-bottom))]">
{children}
</div>
</main>
{/* Bottom tab bar */}
<BottomNav />
</body>
</html>
);
}

81
app/levels/page.tsx Normal file
View file

@ -0,0 +1,81 @@
"use client";
import Link from "next/link";
import { useEffect, useState } from "react";
import { GAME_META, GAMES, GameId, TOTAL_LEVELS } from "@/lib/levels";
function getLevelProgress(game: GameId): number {
if (typeof window === "undefined") return 0;
try {
const raw = localStorage.getItem(`levels-${game}`);
if (!raw) return 0;
const data = JSON.parse(raw);
return data.maxUnlocked ?? 0;
} catch { return 0; }
}
function GameCard({ game }: { game: GameId }) {
const meta = GAME_META[game];
const [progress, setProgress] = useState(0);
useEffect(() => {
setProgress(getLevelProgress(game));
}, [game]);
const pct = Math.round((progress / TOTAL_LEVELS) * 100);
return (
<Link
href={`/${game}/levels`}
className="block bg-white rounded-2xl p-4 shadow-sm border border-gray-100 active:scale-[0.98] transition-transform"
>
<div className="flex items-center gap-3 mb-3">
<span className="text-2xl" style={{ fontFamily: "monospace" }}>{meta.symbol}</span>
<div className="flex-1 min-w-0">
<p className="font-bold text-gray-900 text-base leading-tight">{meta.name}</p>
<p className="text-xs text-gray-400 truncate">{meta.subtitle}</p>
</div>
<div className="text-right">
<p className="text-sm font-bold" style={{ color: meta.accent }}>{progress}</p>
<p className="text-[10px] text-gray-400">/ {TOTAL_LEVELS}</p>
</div>
</div>
{/* Progress bar */}
<div className="h-1.5 bg-gray-100 rounded-full overflow-hidden">
<div
className="h-full rounded-full transition-all duration-500"
style={{ width: `${pct}%`, backgroundColor: meta.accent }}
/>
</div>
<div className="flex justify-between mt-1">
<p className="text-[10px] text-gray-400">{meta.duration}</p>
<p className="text-[10px] text-gray-400">{pct}% complété</p>
</div>
</Link>
);
}
export default function LevelsHubPage() {
return (
<div className="px-4 pb-6">
{/* Header */}
<div className="pt-4 pb-5">
<h1 className="text-2xl font-bold text-gray-900">Entraînement</h1>
<p className="text-sm text-gray-500 mt-0.5">100 niveaux par jeu, du facile à l'expert</p>
</div>
{/* Game cards */}
<div className="flex flex-col gap-3">
{GAMES.map(game => (
<GameCard key={game} game={game} />
))}
</div>
{/* Footer hint */}
<p className="text-center text-xs text-gray-400 mt-6">
Résous les niveaux du quotidien pour débloquer les suivants
</p>
</div>
);
}

View file

@ -0,0 +1,45 @@
import type { Metadata } from "next";
export const metadata: Metadata = {
title: "Mentions légales — Puzzle Trainer",
};
export default function MentionsLegalesPage() {
return (
<div className="max-w-2xl mx-auto prose prose-sm text-gray-700">
<h1 className="text-2xl font-bold text-gray-900 mb-6">Mentions légales</h1>
<h2 className="text-lg font-semibold text-gray-800 mt-6 mb-2">Éditeur</h2>
<p>
<strong>Reverdin Studio</strong><br />
Corso Vittorio Emanuele II 154, Rome, Italie<br />
Partita IVA : IT17458801002<br />
Directeur de publication : Marc Reverdin<br />
Contact : <a href="mailto:mr@reverdin.eu" className="text-blue-600 hover:underline">mr@reverdin.eu</a>
</p>
<h2 className="text-lg font-semibold text-gray-800 mt-6 mb-2">Hébergement</h2>
<p>
<strong>Hetzner Online GmbH</strong><br />
Industriestr. 25, 91710 Gunzenhausen, Allemagne<br />
<a href="https://www.hetzner.com" className="text-blue-600 hover:underline" target="_blank" rel="noopener noreferrer">www.hetzner.com</a>
</p>
<h2 className="text-lg font-semibold text-gray-800 mt-6 mb-2">Données personnelles</h2>
<p>
Puzzle Trainer ne collecte aucune donnée personnelle. Toutes les données de progression
(niveaux complétés, temps, séries) sont stockées exclusivement dans le navigateur de
l&apos;utilisateur via <code>localStorage</code> et ne sont jamais transmises à un serveur.
</p>
<p>
Aucun cookie tiers ni traceur analytique n&apos;est utilisé.
</p>
<h2 className="text-lg font-semibold text-gray-800 mt-6 mb-2">Propriété intellectuelle</h2>
<p>
Les puzzles générés par Puzzle Trainer sont créés algorithmiquement. Le code source
de l&apos;application est la propriété exclusive de Reverdin Studio.
</p>
</div>
);
}

69
app/opengraph-image.tsx Normal file
View file

@ -0,0 +1,69 @@
import { ImageResponse } from "next/og";
export const alt = "Puzzle Trainer";
export const size = { width: 1200, height: 630 };
export const contentType = "image/png";
export default function OGImage() {
return new ImageResponse(
(
<div
style={{
width: "100%",
height: "100%",
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
background: "linear-gradient(135deg, #f9fafb 0%, #f3f4f6 100%)",
fontFamily: "system-ui, -apple-system, sans-serif",
}}
>
{/* Game icons row */}
<div style={{ display: "flex", gap: 24, marginBottom: 40 }}>
{["♛", "☀", "∞", "#", "▦"].map((icon, i) => (
<div
key={i}
style={{
width: 72,
height: 72,
background: "#111827",
borderRadius: 16,
display: "flex",
alignItems: "center",
justifyContent: "center",
fontSize: 32,
color: "#f9fafb",
}}
>
{icon}
</div>
))}
</div>
{/* Title */}
<div style={{ fontSize: 72, fontWeight: 700, color: "#111827", letterSpacing: -2 }}>
Puzzle Trainer
</div>
{/* Tagline */}
<div style={{ fontSize: 28, color: "#6b7280", marginTop: 16 }}>
5 puzzles logiques · chaque jour
</div>
{/* URL */}
<div
style={{
position: "absolute",
bottom: 40,
fontSize: 20,
color: "#9ca3af",
}}
>
puzzles.reverdin.eu
</div>
</div>
),
{ ...size }
);
}

503
app/page.tsx Normal file
View file

@ -0,0 +1,503 @@
"use client";
import Link from "next/link";
import { useEffect, useState } from "react";
import { todayISO } from "@/lib/rng";
import { GAME_META, GAMES, GameId } from "@/lib/levels";
import { allStats, GameStats } from "@/lib/progress";
import { loadStats, updateRitualStreak, getRitualStreak, RitualStats } from "@/lib/stats";
import { PlayMode, getPlayMode, savePlayMode, getSessionOrder } from "@/lib/session";
// ── Other helpers ─────────────────────────────────────────────────────────────
function fmt(s: number): string {
return `${String(Math.floor(s / 60)).padStart(2, "0")}:${String(s % 60).padStart(2, "0")}`;
}
function isDailySolved(game: GameId, date: string): boolean {
if (typeof window === "undefined") return false;
try {
const stats = localStorage.getItem(`stats-${game}`);
if (!stats) return false;
return JSON.parse(stats)?.lastDate === date;
} catch { return false; }
}
function isNewUser(): boolean {
if (typeof window === "undefined") return true;
return GAMES.every(g => !localStorage.getItem(`stats-${g}`));
}
// ── Onboarding screen ─────────────────────────────────────────────────────────
function OnboardingScreen() {
return (
<div className="flex flex-col items-center justify-center min-h-[70vh] gap-8 text-center px-6">
<div className="flex flex-col items-center gap-4">
<div className="w-20 h-20 rounded-3xl bg-gray-900 flex items-center justify-center shadow-xl">
<span className="text-4xl" aria-hidden>🧩</span>
</div>
<div>
<h1 className="text-3xl font-black text-gray-900 tracking-tight">Puzzle Trainer</h1>
<p className="text-gray-500 mt-2 text-base">5 puzzles logiques, chaque jour.</p>
</div>
</div>
<div className="flex flex-col gap-3 text-sm text-gray-500 max-w-[260px]">
<div className="flex items-center gap-2.5 text-left">
<span className="w-6 h-6 rounded-full bg-gray-100 flex items-center justify-center text-base shrink-0">🔓</span>
<span>Pas de compte, pas de pub</span>
</div>
<div className="flex items-center gap-2.5 text-left">
<span className="w-6 h-6 rounded-full bg-gray-100 flex items-center justify-center text-base shrink-0">📱</span>
<span>Tout dans le navigateur</span>
</div>
<div className="flex items-center gap-2.5 text-left">
<span className="w-6 h-6 rounded-full bg-gray-100 flex items-center justify-center text-base shrink-0"></span>
<span>2 à 4 minutes par puzzle</span>
</div>
</div>
<Link
href="/tango"
className="flex items-center gap-2 px-8 py-4 rounded-2xl bg-gray-900 text-white font-bold text-base hover:bg-gray-700 transition-colors shadow-lg"
>
Commencer
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2.5} strokeLinecap="round"><polyline points="9 18 15 12 9 6"/></svg>
</Link>
<p className="text-xs text-gray-300 -mt-2">Commence avec Tango le plus facile à apprendre</p>
</div>
);
}
// ── Mode toggle ───────────────────────────────────────────────────────────────
function ModeToggle({ mode, onChange }: { mode: PlayMode; onChange: (m: PlayMode) => void }) {
return (
<div className="flex items-center gap-1 p-1 bg-gray-100 rounded-xl text-xs font-semibold">
<button
onClick={() => onChange("free")}
className={`px-3 py-1.5 rounded-lg transition-all ${
mode === "free"
? "bg-white text-gray-900 shadow-sm"
: "text-gray-400 hover:text-gray-600"
}`}
>
Libre
</button>
<button
onClick={() => onChange("session")}
className={`px-3 py-1.5 rounded-lg transition-all flex items-center gap-1 ${
mode === "session"
? "bg-white text-gray-900 shadow-sm"
: "text-gray-400 hover:text-gray-600"
}`}
>
Session
<span className="text-[10px] text-gray-400 font-normal">5 à la suite</span>
</button>
</div>
);
}
// ── Streak banner ─────────────────────────────────────────────────────────────
function StreakBanner({ ritual, today }: { ritual: RitualStats; today: string }) {
if (ritual.streak === 0) return null;
const isAliveToday = ritual.lastDate === today;
return (
<div className="w-full flex items-center gap-3 px-4 py-3 bg-amber-50 border border-amber-200 rounded-2xl">
<span className="text-xl" aria-hidden>🔥</span>
<div className="flex-1 min-w-0">
<p className="text-sm font-bold text-amber-800">
{ritual.streak} jour{ritual.streak > 1 ? "s" : ""} de suite
</p>
<p className="text-xs text-amber-600">
{isAliveToday
? "Tous les puzzles complétés aujourd'hui !"
: "Complète les puzzles du jour pour continuer ta série"}
</p>
</div>
</div>
);
}
// ── Free mode: simple game row ────────────────────────────────────────────────
function FreeRow({ game, solved, lastTime }: {
game: GameId;
solved: boolean;
lastTime: number;
}) {
const { name, accent, subtitle, duration, symbol } = GAME_META[game];
return (
<Link
href={`/${game}`}
className={`group flex items-center gap-4 px-4 py-3.5 bg-white rounded-2xl border transition-all ${
solved
? "border-green-200"
: "border-gray-100 hover:border-gray-200 hover:shadow-sm"
}`}
>
<span
className="w-10 h-10 rounded-xl flex items-center justify-center text-lg font-bold shrink-0"
style={{ background: solved ? "#dcfce7" : `${accent}18`, color: solved ? "#16a34a" : accent }}
aria-hidden
>
{solved ? "✓" : symbol}
</span>
<div className="flex-1 min-w-0">
<div className="flex items-baseline gap-2">
<span className={`text-sm font-semibold ${solved ? "text-green-800" : "text-gray-800"}`}>
{name}
</span>
{!solved && <span className="text-xs text-gray-400">{duration}</span>}
</div>
<p className="text-xs text-gray-400 truncate mt-0.5">
{solved && lastTime > 0 ? `Résolu en ${fmt(lastTime)}` : subtitle}
</p>
</div>
{solved ? (
<span className="shrink-0 text-xs text-green-600 font-semibold bg-green-50 px-2.5 py-1 rounded-full border border-green-200">
Rejouer
</span>
) : (
<span
className="shrink-0 text-xs font-semibold px-3 py-1.5 rounded-full text-white transition-opacity group-hover:opacity-90"
style={{ background: accent }}
>
Jouer
</span>
)}
</Link>
);
}
// ── Session mode: ordered list with progress ──────────────────────────────────
function SessionList({ order, solvedToday, lastTimes }: {
order: GameId[];
solvedToday: Record<GameId, boolean>;
lastTimes: Record<GameId, number>;
}) {
// Find the next game to play (first unsolved in order)
const nextIdx = order.findIndex(g => !solvedToday[g]);
const allDone = nextIdx === -1;
const solvedCount = order.filter(g => solvedToday[g]).length;
return (
<div className="flex flex-col gap-2">
{/* Progress bar */}
<div className="flex items-center gap-3 mb-1">
<div className="flex-1 h-1.5 bg-gray-100 rounded-full overflow-hidden">
<div
className="h-full rounded-full bg-gray-900 transition-all duration-500"
style={{ width: `${(solvedCount / order.length) * 100}%` }}
/>
</div>
<span className="text-xs text-gray-400 shrink-0">{solvedCount}/{order.length}</span>
</div>
{order.map((game, idx) => {
const solved = solvedToday[game];
const isCurrent = !allDone && idx === nextIdx;
const isPending = !solved && !isCurrent;
const { name, accent, subtitle, duration, symbol } = GAME_META[game];
return (
<Link
key={game}
href={`/${game}`}
className={`group flex items-center gap-4 px-4 py-3.5 rounded-2xl border transition-all ${
solved
? "bg-white border-green-200"
: isCurrent
? "bg-white border-2 shadow-sm"
: "bg-white border-gray-100 opacity-50"
}`}
style={isCurrent ? { borderColor: accent } : undefined}
>
{/* Step number / status */}
<div className="relative shrink-0">
<span
className="w-10 h-10 rounded-xl flex items-center justify-center text-lg font-bold"
style={{
background: solved ? "#dcfce7" : isCurrent ? `${accent}18` : "#f1f5f9",
color: solved ? "#16a34a" : isCurrent ? accent : "#9ca3af",
}}
aria-hidden
>
{solved ? "✓" : isCurrent ? symbol : <span className="text-sm font-bold">{idx + 1}</span>}
</span>
{/* Connector line below (not last) */}
{idx < order.length - 1 && (
<div
className="absolute left-1/2 -translate-x-1/2 w-0.5 rounded-full"
style={{
top: "100%",
height: "10px",
marginTop: "2px",
background: solved ? "#bbf7d0" : "#e5e7eb",
}}
/>
)}
</div>
<div className="flex-1 min-w-0">
<div className="flex items-baseline gap-2">
<span className={`text-sm font-semibold ${
solved ? "text-green-800" : isCurrent ? "text-gray-900" : "text-gray-500"
}`}>
{name}
</span>
{!solved && isCurrent && (
<span className="text-xs text-gray-400">{duration}</span>
)}
</div>
<p className="text-xs text-gray-400 truncate mt-0.5">
{solved && lastTimes[game] > 0
? `Résolu en ${fmt(lastTimes[game])}`
: isCurrent
? subtitle
: ""}
</p>
</div>
{/* CTA */}
{solved ? (
<span className="shrink-0 text-xs text-green-600 font-semibold bg-green-50 px-2.5 py-1 rounded-full border border-green-200">
</span>
) : isCurrent ? (
<span
className="shrink-0 text-sm font-bold px-4 py-2 rounded-full text-white flex items-center gap-1 transition-opacity group-hover:opacity-90"
style={{ background: accent }}
>
Jouer
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2.5} strokeLinecap="round"><polyline points="9 18 15 12 9 6"/></svg>
</span>
) : (
<span className="shrink-0 w-8 h-8 rounded-full bg-gray-100 flex items-center justify-center">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="#9ca3af" strokeWidth={2} strokeLinecap="round"><path d="M12 2a10 10 0 1 0 0 20 10 10 0 0 0 0-20z"/></svg>
</span>
)}
</Link>
);
})}
</div>
);
}
// ── All done banner ────────────────────────────────────────────────────────────
function AllDoneBanner({ nextPuzzleIn }: { nextPuzzleIn: string }) {
return (
<div className="w-full flex flex-col items-center gap-2 px-4 py-5 bg-green-50 border border-green-200 rounded-2xl text-center">
<span className="text-3xl" aria-hidden>🎉</span>
<p className="text-sm font-bold text-green-800">Bravo ! Tous les puzzles du jour complétés.</p>
<p className="text-xs text-green-600">Prochain puzzle dans {nextPuzzleIn}</p>
</div>
);
}
// ── Training row ──────────────────────────────────────────────────────────────
function TrainingRow({ game, stats }: { game: GameId; stats: GameStats | undefined }) {
const { name, accent } = GAME_META[game];
const pct = stats?.pct ?? 0;
const label = !stats || stats.completed === 0 ? "Commencer" : `Niv. ${stats.nextLevel}`;
return (
<Link
href={`/${game}/levels`}
className="group flex items-center gap-3 px-4 py-3 bg-white rounded-xl border border-gray-100 hover:border-gray-200 hover:shadow-sm transition-all"
>
<span className="text-xs font-semibold text-gray-600 w-14 shrink-0">{name}</span>
<div className="flex-1 min-w-0">
<div className="h-1.5 bg-gray-100 rounded-full overflow-hidden">
<div className="h-full rounded-full transition-all" style={{ width: `${pct}%`, background: accent }} />
</div>
</div>
<span className="text-[11px] text-gray-400 shrink-0 w-14 text-right">{stats?.completed ?? 0}/100</span>
<span
className="shrink-0 text-xs font-semibold px-2.5 py-1 rounded-full whitespace-nowrap"
style={{ background: `${accent}18`, color: accent }}
>
{label}
</span>
</Link>
);
}
// ── Main page ─────────────────────────────────────────────────────────────────
export default function Home() {
const today = todayISO();
const [loaded, setLoaded] = useState(false);
const [newUser, setNewUser] = useState(false);
const [mode, setMode] = useState<PlayMode>("free");
const [levelStats, setLevelStats] = useState<Record<GameId, GameStats> | null>(null);
const [solvedToday, setSolvedToday] = useState<Record<GameId, boolean>>({} as Record<GameId, boolean>);
const [lastTimes, setLastTimes] = useState<Record<GameId, number>>({} as Record<GameId, number>);
const [ritual, setRitual] = useState<RitualStats>({ streak: 0, lastDate: "" });
const [showTraining, setShowTraining] = useState(false);
const handleModeChange = (m: PlayMode) => {
setMode(m);
savePlayMode(m);
};
useEffect(() => {
const refresh = () => {
setNewUser(isNewUser());
setMode(getPlayMode());
setLevelStats(allStats());
const solved = {} as Record<GameId, boolean>;
const times = {} as Record<GameId, number>;
for (const g of GAMES) {
solved[g] = isDailySolved(g, today);
const s = loadStats(g);
times[g] = s.lastDate === today ? s.lastTime : 0;
}
setSolvedToday(solved);
setLastTimes(times);
const allSolved = GAMES.every(g => solved[g]);
const r = allSolved ? updateRitualStreak(today) : getRitualStreak();
setRitual(r);
setLoaded(true);
};
refresh();
window.addEventListener("focus", refresh);
document.addEventListener("visibilitychange", refresh);
return () => {
window.removeEventListener("focus", refresh);
document.removeEventListener("visibilitychange", refresh);
};
}, [today]);
if (!loaded) {
return (
<div className="flex flex-col gap-3 max-w-sm mx-auto py-4 px-4">
{[...Array(5)].map((_, i) => (
<div key={i} className="skeleton h-[68px] rounded-2xl" />
))}
</div>
);
}
if (newUser) return <OnboardingScreen />;
const dateLabel = new Date(today + "T00:00:00").toLocaleDateString("fr-FR", {
weekday: "long", day: "numeric", month: "long",
});
const sessionOrder = getSessionOrder(today);
const totalSolvedToday = GAMES.filter(g => solvedToday[g]).length;
const allSolvedToday = totalSolvedToday === GAMES.length;
const now = new Date();
const midnight = new Date(now);
midnight.setDate(midnight.getDate() + 1);
midnight.setHours(0, 0, 0, 0);
const hoursLeft = Math.ceil((midnight.getTime() - now.getTime()) / 3600000);
const nextPuzzleIn = `${hoursLeft}h`;
return (
<div className="flex flex-col gap-6 max-w-sm mx-auto py-4 px-4">
{/* Header */}
<div className="flex items-start justify-between">
<div>
<h1 className="text-2xl font-bold text-gray-900 tracking-tight">Puzzle Trainer</h1>
<p className="text-gray-400 mt-0.5 text-sm capitalize">{dateLabel}</p>
</div>
<Link
href="/stats"
className="flex items-center gap-1 text-xs text-gray-400 hover:text-gray-700 mt-1 transition-colors"
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round">
<line x1="18" y1="20" x2="18" y2="10"/><line x1="12" y1="20" x2="12" y2="4"/><line x1="6" y1="20" x2="6" y2="14"/>
</svg>
Stats
</Link>
</div>
{/* Streak */}
<StreakBanner ritual={ritual} today={today} />
{/* Daily puzzles */}
<section>
<div className="flex items-center justify-between mb-3">
<h2 className="text-[11px] font-semibold text-gray-400 uppercase tracking-widest">
Puzzles du jour
</h2>
<ModeToggle mode={mode} onChange={handleModeChange} />
</div>
{mode === "session" && (
<p className="text-xs text-gray-400 mb-3 -mt-1">
Joue les 5 puzzles à la suite l&apos;ordre change chaque jour.
</p>
)}
{allSolvedToday ? (
<AllDoneBanner nextPuzzleIn={nextPuzzleIn} />
) : mode === "session" ? (
<SessionList
order={sessionOrder}
solvedToday={solvedToday}
lastTimes={lastTimes}
/>
) : (
<div className="flex flex-col gap-2">
{GAMES.map(game => (
<FreeRow
key={game}
game={game}
solved={solvedToday[game]}
lastTime={lastTimes[game]}
/>
))}
</div>
)}
</section>
{/* Training */}
<section>
<button
onClick={() => setShowTraining(v => !v)}
className="w-full flex items-center justify-between text-[11px] font-semibold text-gray-400 uppercase tracking-widest mb-2 hover:text-gray-600 transition-colors"
>
<span>Entraînement</span>
<svg
width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round"
style={{ transform: showTraining ? "rotate(180deg)" : "rotate(0deg)", transition: "transform 0.2s" }}
>
<polyline points="6 9 12 15 18 9"/>
</svg>
</button>
{showTraining && (
<div className="flex flex-col gap-2">
{GAMES.map(game => (
<TrainingRow key={game} game={game} stats={levelStats?.[game]} />
))}
</div>
)}
</section>
{/* Footer */}
<div className="flex items-center gap-4 text-xs text-gray-300 pb-2">
<Link href="/archive" className="hover:text-gray-500 transition-colors">Archives</Link>
<span>·</span>
<Link href="/stats" className="hover:text-gray-500 transition-colors">Statistiques</Link>
<span>·</span>
<Link href="/comment-jouer" className="hover:text-gray-500 transition-colors">Comment jouer</Link>
</div>
</div>
);
}

View file

@ -0,0 +1,31 @@
"use client";
import { useState, useEffect } from "react";
import { useParams } from "next/navigation";
import { generatePatches, PatchesPuzzle } from "@/lib/generators/patches";
import PatchesBoard from "@/components/PatchesBoard";
import Link from "next/link";
export default function PatchesDatePage() {
const { date } = useParams<{ date: string }>();
const [puzzle, setPuzzle] = useState<PatchesPuzzle | null>(null);
useEffect(() => { setPuzzle(generatePatches(date)); }, [date]);
return (
<div className="flex flex-col items-center gap-8">
<div className="text-center">
<h1 className="text-2xl font-bold text-gray-900 tracking-tight">Patches</h1>
<p className="text-sm text-gray-400 mt-1">{date}</p>
</div>
{puzzle ? (
<PatchesBoard key={date} puzzle={puzzle} date={date} />
) : (
<div className="py-20 text-gray-300 text-sm">Chargement</div>
)}
<div className="flex gap-4 text-sm">
<Link href="/patches" className="text-gray-500 hover:text-gray-900 transition-colors"> Aujourd&apos;hui</Link>
<Link href="/archive?game=patches" className="text-gray-400 hover:text-gray-700 transition-colors">Archives</Link>
</div>
</div>
);
}

View file

@ -0,0 +1,98 @@
"use client";
import { useState, useEffect } from "react";
import { useParams } from "next/navigation";
import Link from "next/link";
import { generatePatches, PatchesPuzzle } from "@/lib/generators/patches";
import PatchesBoard from "@/components/PatchesBoard";
import { levelToDate, levelMeta, TOTAL_LEVELS, GAME_META } from "@/lib/levels";
import { getGameProgress, recordLevelSolve } from "@/lib/progress";
const DIFF_COLORS: Record<number, { bg: string; text: string }> = {
1: { bg: "#f0fdf4", text: "#16a34a" },
2: { bg: "#fefce8", text: "#ca8a04" },
3: { bg: "#fff7ed", text: "#ea580c" },
4: { bg: "#fef2f2", text: "#dc2626" },
5: { bg: "#faf5ff", text: "#9333ea" },
};
function fmt(s: number) {
return `${String(Math.floor(s / 60)).padStart(2, "0")}:${String(s % 60).padStart(2, "0")}`;
}
export default function PatchesLevelPage() {
const { n } = useParams<{ n: string }>();
const level = Math.max(1, Math.min(TOTAL_LEVELS, parseInt(n) || 1));
const { accent } = GAME_META["patches"];
const [puzzle, setPuzzle] = useState<PatchesPuzzle | null>(null);
const [completed, setCompleted] = useState(false);
const [bestTime, setBestTime] = useState(0);
useEffect(() => {
setPuzzle(generatePatches(levelToDate(level)));
const p = getGameProgress("patches");
const record = p[level];
setCompleted(!!record);
setBestTime(record?.bestTime ?? 0);
}, [level]);
const meta = levelMeta("patches", level);
const diffColors = DIFF_COLORS[meta.difficulty];
return (
<div className="flex flex-col items-center gap-6">
<div className="w-full flex items-center gap-1.5 text-xs text-gray-400">
<Link href="/patches" className="hover:text-gray-600 transition-colors">Patches</Link>
<span>/</span>
<Link href="/patches/levels" className="hover:text-gray-600 transition-colors">Niveaux</Link>
<span>/</span>
<span className="text-gray-600 font-medium">Niveau {level}</span>
</div>
<div className="text-center">
<div className="flex items-center justify-center gap-2 mb-2">
<h1 className="text-2xl font-bold text-gray-900 tracking-tight">Niveau {level}</h1>
{completed && (
<span className="flex items-center gap-1 text-xs font-semibold text-green-600 bg-green-50 px-2 py-0.5 rounded-full border border-green-200">
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={3} strokeLinecap="round" strokeLinejoin="round"><polyline points="20 6 9 17 4 12"/></svg>
Complété
</span>
)}
</div>
<div className="flex items-center justify-center gap-2">
<span className="text-xs font-semibold px-2.5 py-1 rounded-full" style={{ background: diffColors.bg, color: diffColors.text }}>
{meta.difficultyLabel}
</span>
{completed && bestTime > 0 && (
<span className="text-xs text-gray-400 timer-mono"> {fmt(bestTime)}</span>
)}
</div>
</div>
{puzzle ? (
<PatchesBoard key={`level-patches-${level}`} puzzle={puzzle} date={`level-patches-${level}`} onSolve={(elapsed) => recordLevelSolve("patches", level, elapsed)} />
) : (
<div className="py-20 text-gray-300 text-sm">Chargement</div>
)}
<div className="flex items-center gap-3">
{level > 1 && (
<Link href={`/patches/level/${level - 1}`} className="flex items-center gap-1 px-4 py-2 rounded-full border border-gray-200 text-gray-500 text-sm hover:border-gray-300 hover:text-gray-700 transition-colors">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round"><polyline points="15 18 9 12 15 6"/></svg>
Niveau {level - 1}
</Link>
)}
<Link href="/patches/levels" className="px-4 py-2 rounded-full border border-gray-200 text-gray-500 text-sm hover:border-gray-300 hover:text-gray-700 transition-colors">
Tous les niveaux
</Link>
{level < TOTAL_LEVELS && (
<Link href={`/patches/level/${level + 1}`} className="flex items-center gap-1 px-4 py-2 rounded-full text-white text-sm font-semibold transition-opacity hover:opacity-90" style={{ background: accent }}>
Niveau {level + 1}
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round"><polyline points="9 18 15 12 9 6"/></svg>
</Link>
)}
</div>
</div>
);
}

View file

@ -0,0 +1,7 @@
"use client";
import LevelsPageShell from "@/components/LevelsPageShell";
export default function PatchesLevelsPage() {
return <LevelsPageShell game="patches" />;
}

27
app/patches/page.tsx Normal file
View file

@ -0,0 +1,27 @@
"use client";
import { useState, useEffect } from "react";
import { generatePatches, PatchesPuzzle } from "@/lib/generators/patches";
import { todayISO } from "@/lib/rng";
import PatchesBoard from "@/components/PatchesBoard";
import DailyPageShell from "@/components/DailyPageShell";
export default function PatchesPage() {
const date = todayISO();
const [puzzle, setPuzzle] = useState<PatchesPuzzle | null>(null);
useEffect(() => { setPuzzle(generatePatches(date)); }, [date]);
const dateLabel = new Date(date + "T00:00:00").toLocaleDateString("fr-FR", {
weekday: "long", day: "numeric", month: "long",
});
return (
<DailyPageShell game="patches" date={date} dateLabel={dateLabel}>
{puzzle ? (
<PatchesBoard puzzle={puzzle} date={date} />
) : (
<div className="py-20 text-gray-300 text-sm">Chargement</div>
)}
</DailyPageShell>
);
}

View file

@ -0,0 +1,31 @@
"use client";
import { useState, useEffect } from "react";
import { useParams } from "next/navigation";
import { generateQueens, QueensPuzzle } from "@/lib/generators/queens";
import QueensBoard from "@/components/QueensBoard";
import Link from "next/link";
export default function QueensDatePage() {
const { date } = useParams<{ date: string }>();
const [puzzle, setPuzzle] = useState<QueensPuzzle | null>(null);
useEffect(() => { setPuzzle(generateQueens(date)); }, [date]);
return (
<div className="flex flex-col items-center gap-8">
<div className="text-center">
<h1 className="text-2xl font-bold text-gray-900 tracking-tight">Queens</h1>
<p className="text-sm text-gray-400 mt-1">{date}</p>
</div>
{puzzle ? (
<QueensBoard key={date} puzzle={puzzle} date={date} />
) : (
<div className="py-20 text-gray-300 text-sm">Chargement</div>
)}
<div className="flex gap-4 text-sm">
<Link href="/queens" className="text-gray-500 hover:text-gray-900 transition-colors"> Aujourd&apos;hui</Link>
<Link href="/archive?game=queens" className="text-gray-400 hover:text-gray-700 transition-colors">Archives</Link>
</div>
</div>
);
}

View file

@ -0,0 +1,125 @@
"use client";
import { useState, useEffect } from "react";
import { useParams } from "next/navigation";
import Link from "next/link";
import { generateQueens, QueensPuzzle } from "@/lib/generators/queens";
import QueensBoard from "@/components/QueensBoard";
import { levelToDate, queensSizeForLevel, levelMeta, TOTAL_LEVELS, GAME_META } from "@/lib/levels";
import { getGameProgress, recordLevelSolve } from "@/lib/progress";
const DIFF_COLORS: Record<number, { bg: string; text: string }> = {
1: { bg: "#f0fdf4", text: "#16a34a" },
2: { bg: "#fefce8", text: "#ca8a04" },
3: { bg: "#fff7ed", text: "#ea580c" },
4: { bg: "#fef2f2", text: "#dc2626" },
5: { bg: "#faf5ff", text: "#9333ea" },
};
function fmt(s: number) {
return `${String(Math.floor(s / 60)).padStart(2, "0")}:${String(s % 60).padStart(2, "0")}`;
}
export default function QueensLevelPage() {
const { n } = useParams<{ n: string }>();
const level = Math.max(1, Math.min(TOTAL_LEVELS, parseInt(n) || 1));
const { accent } = GAME_META["queens"];
const [puzzle, setPuzzle] = useState<QueensPuzzle | null>(null);
const [completed, setCompleted] = useState(false);
const [bestTime, setBestTime] = useState(0);
useEffect(() => {
const date = levelToDate(level);
const size = queensSizeForLevel(level);
setPuzzle(generateQueens(date, size));
const p = getGameProgress("queens");
const record = p[level];
setCompleted(!!record);
setBestTime(record?.bestTime ?? 0);
}, [level]);
const meta = levelMeta("queens", level);
const diffColors = DIFF_COLORS[meta.difficulty];
return (
<div className="flex flex-col items-center gap-6">
{/* Breadcrumb */}
<div className="w-full flex items-center gap-1.5 text-xs text-gray-400">
<Link href="/queens" className="hover:text-gray-600 transition-colors">Queens</Link>
<span>/</span>
<Link href="/queens/levels" className="hover:text-gray-600 transition-colors">Niveaux</Link>
<span>/</span>
<span className="text-gray-600 font-medium">Niveau {level}</span>
</div>
{/* Header */}
<div className="text-center">
<div className="flex items-center justify-center gap-2 mb-2">
<h1 className="text-2xl font-bold text-gray-900 tracking-tight">Niveau {level}</h1>
{completed && (
<span className="flex items-center gap-1 text-xs font-semibold text-green-600 bg-green-50 px-2 py-0.5 rounded-full border border-green-200">
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={3} strokeLinecap="round" strokeLinejoin="round">
<polyline points="20 6 9 17 4 12"/>
</svg>
Complété
</span>
)}
</div>
<div className="flex items-center justify-center gap-2">
<span
className="text-xs font-semibold px-2.5 py-1 rounded-full"
style={{ background: diffColors.bg, color: diffColors.text }}
>
{meta.difficultyLabel}
</span>
{completed && bestTime > 0 && (
<span className="text-xs text-gray-400 timer-mono"> {fmt(bestTime)}</span>
)}
</div>
</div>
{puzzle ? (
<QueensBoard
key={`level-queens-${level}`}
puzzle={puzzle}
date={`level-queens-${level}`}
onSolve={(elapsed) => recordLevelSolve("queens", level, elapsed)}
/>
) : (
<div className="py-20 text-gray-300 text-sm">Chargement</div>
)}
{/* Level navigation */}
<div className="flex items-center gap-3">
{level > 1 && (
<Link
href={`/queens/level/${level - 1}`}
className="flex items-center gap-1 px-4 py-2 rounded-full border border-gray-200 text-gray-500 text-sm hover:border-gray-300 hover:text-gray-700 transition-colors"
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round"><polyline points="15 18 9 12 15 6"/></svg>
Niveau {level - 1}
</Link>
)}
<Link
href="/queens/levels"
className="px-4 py-2 rounded-full border border-gray-200 text-gray-500 text-sm hover:border-gray-300 hover:text-gray-700 transition-colors"
>
Tous les niveaux
</Link>
{level < TOTAL_LEVELS && (
<Link
href={`/queens/level/${level + 1}`}
className="flex items-center gap-1 px-4 py-2 rounded-full text-white text-sm font-semibold transition-opacity hover:opacity-90"
style={{ background: accent }}
>
Niveau {level + 1}
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round"><polyline points="9 18 15 12 9 6"/></svg>
</Link>
)}
</div>
</div>
);
}

View file

@ -0,0 +1,7 @@
"use client";
import LevelsPageShell from "@/components/LevelsPageShell";
export default function QueensLevelsPage() {
return <LevelsPageShell game="queens" />;
}

27
app/queens/page.tsx Normal file
View file

@ -0,0 +1,27 @@
"use client";
import { useState, useEffect } from "react";
import { generateQueens, QueensPuzzle } from "@/lib/generators/queens";
import { todayISO } from "@/lib/rng";
import QueensBoard from "@/components/QueensBoard";
import DailyPageShell from "@/components/DailyPageShell";
export default function QueensPage() {
const date = todayISO();
const [puzzle, setPuzzle] = useState<QueensPuzzle | null>(null);
useEffect(() => { setPuzzle(generateQueens(date)); }, [date]);
const dateLabel = new Date(date + "T00:00:00").toLocaleDateString("fr-FR", {
weekday: "long", day: "numeric", month: "long",
});
return (
<DailyPageShell game="queens" date={date} dateLabel={dateLabel}>
{puzzle ? (
<QueensBoard puzzle={puzzle} date={date} />
) : (
<div className="py-20 text-gray-300 text-sm">Chargement</div>
)}
</DailyPageShell>
);
}

207
app/settings/page.tsx Normal file
View file

@ -0,0 +1,207 @@
"use client";
import { useEffect, useState } from "react";
import { getPlayMode, savePlayMode, PlayMode } from "@/lib/session";
import { GAMES } from "@/lib/levels";
import { loadStats, getRitualStreak } from "@/lib/stats";
function todayISO() {
const d = new Date();
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
}
function formatTime(secs: number) {
if (!secs) return "—";
return `${Math.floor(secs / 60)}:${String(secs % 60).padStart(2, "0")}`;
}
export default function SettingsPage() {
const [mode, setMode] = useState<PlayMode>("free");
const [storageOk, setStorageOk] = useState<boolean | null>(null);
const [isIOS, setIsIOS] = useState(false);
const [isStandalone, setIsStandalone] = useState(false);
const [exportDone, setExportDone] = useState(false);
useEffect(() => {
setMode(getPlayMode());
setIsIOS(/iPhone|iPad|iPod/.test(navigator.userAgent));
setIsStandalone(window.matchMedia("(display-mode: standalone)").matches);
// Check storage persistence
if ("storage" in navigator && "persisted" in navigator.storage) {
navigator.storage.persisted().then(p => setStorageOk(p));
}
}, []);
function handleModeChange(m: PlayMode) {
setMode(m);
savePlayMode(m);
}
async function requestPersist() {
if ("storage" in navigator && "persist" in navigator.storage) {
const granted = await navigator.storage.persist();
setStorageOk(granted);
}
}
function exportData() {
const data: Record<string, unknown> = { exportDate: todayISO() };
GAMES.forEach(g => {
const raw = localStorage.getItem(`stats-${g}`);
if (raw) data[`stats-${g}`] = JSON.parse(raw);
const lvl = localStorage.getItem(`levels-${g}`);
if (lvl) data[`levels-${g}`] = JSON.parse(lvl);
});
data["pt-mode"] = localStorage.getItem("pt-mode");
data["ritual"] = localStorage.getItem("ritual-streak");
const blob = new Blob([JSON.stringify(data, null, 2)], { type: "application/json" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `puzzle-trainer-${todayISO()}.json`;
a.click();
URL.revokeObjectURL(url);
setExportDone(true);
setTimeout(() => setExportDone(false), 2000);
}
const ritual = typeof window !== "undefined" ? getRitualStreak() : { streak: 0, lastDate: "" };
return (
<div className="px-4 pb-8">
{/* Header */}
<div className="pt-4 pb-5">
<h1 className="text-2xl font-bold text-gray-900">Réglages</h1>
</div>
{/* Stats summary */}
<section className="mb-5">
<h2 className="text-xs font-semibold uppercase tracking-wider text-gray-400 mb-2 px-1">Résumé</h2>
<div className="bg-white rounded-2xl border border-gray-100 shadow-sm divide-y divide-gray-50">
<div className="flex items-center justify-between px-4 py-3">
<span className="text-sm text-gray-700">Série quotidienne</span>
<span className="text-sm font-bold text-gray-900">
{ritual.streak > 0 ? `🔥 ${ritual.streak} jour${ritual.streak > 1 ? "s" : ""}` : "—"}
</span>
</div>
{GAMES.map(g => {
const stats = typeof window !== "undefined" ? loadStats(g) : null;
return (
<div key={g} className="flex items-center justify-between px-4 py-3">
<span className="text-sm text-gray-700 capitalize">{g}</span>
<span className="text-sm text-gray-500">
{stats?.total ?? 0} résolus · meilleur {formatTime(stats?.bestTime ?? 0)}
</span>
</div>
);
})}
</div>
</section>
{/* Mode de jeu */}
<section className="mb-5">
<h2 className="text-xs font-semibold uppercase tracking-wider text-gray-400 mb-2 px-1">Mode de jeu</h2>
<div className="bg-white rounded-2xl border border-gray-100 shadow-sm">
<button
onClick={() => handleModeChange("free")}
className="w-full flex items-center justify-between px-4 py-3.5 text-left"
>
<div>
<p className="text-sm font-medium text-gray-900">Libre</p>
<p className="text-xs text-gray-400">Joue les puzzles dans n'importe quel ordre</p>
</div>
<span className={`w-5 h-5 rounded-full border-2 flex-shrink-0 ${mode === "free" ? "border-gray-900 bg-gray-900" : "border-gray-300"}`} />
</button>
<div className="h-px bg-gray-50 mx-4" />
<button
onClick={() => handleModeChange("session")}
className="w-full flex items-center justify-between px-4 py-3.5 text-left"
>
<div>
<p className="text-sm font-medium text-gray-900">Session 5 à la suite</p>
<p className="text-xs text-gray-400">Enchaîne tous les jeux guidé, un après l'autre</p>
</div>
<span className={`w-5 h-5 rounded-full border-2 flex-shrink-0 ${mode === "session" ? "border-gray-900 bg-gray-900" : "border-gray-300"}`} />
</button>
</div>
</section>
{/* Données */}
<section className="mb-5">
<h2 className="text-xs font-semibold uppercase tracking-wider text-gray-400 mb-2 px-1">Données</h2>
<div className="bg-white rounded-2xl border border-gray-100 shadow-sm divide-y divide-gray-50">
<div className="flex items-center justify-between px-4 py-3.5">
<div>
<p className="text-sm font-medium text-gray-900">Stockage persistant</p>
<p className="text-xs text-gray-400">Évite la suppression des données par Safari</p>
</div>
{storageOk === true ? (
<span className="text-xs text-green-600 font-medium">Activé</span>
) : storageOk === false ? (
<button
onClick={requestPersist}
className="text-xs font-medium text-blue-600 active:opacity-70"
>
Activer
</button>
) : (
<span className="text-xs text-gray-400"></span>
)}
</div>
<button
onClick={exportData}
className="w-full flex items-center justify-between px-4 py-3.5 text-left active:opacity-70"
>
<div>
<p className="text-sm font-medium text-gray-900">Exporter mes données</p>
<p className="text-xs text-gray-400">Télécharge un fichier JSON de sauvegarde</p>
</div>
<span className="text-xs font-medium text-blue-600">
{exportDone ? "✓ Téléchargé" : "Télécharger"}
</span>
</button>
</div>
</section>
{/* iOS Install guide */}
{isIOS && !isStandalone && (
<section className="mb-5">
<h2 className="text-xs font-semibold uppercase tracking-wider text-gray-400 mb-2 px-1">Installer l'app</h2>
<div className="bg-white rounded-2xl border border-gray-100 shadow-sm p-4">
<p className="text-sm font-medium text-gray-900 mb-3">Ajouter à l'écran d'accueil</p>
<div className="flex flex-col gap-3">
{[
{ n: 1, text: "Appuie sur l'icône Partager (carré avec flèche vers le haut) en bas de Safari" },
{ n: 2, text: "Fais défiler et appuie sur « Sur l'écran d'accueil »" },
{ n: 3, text: "Appuie sur « Ajouter » en haut à droite" },
].map(step => (
<div key={step.n} className="flex gap-3 items-start">
<span className="w-5 h-5 rounded-full bg-gray-900 text-white text-[10px] font-bold flex items-center justify-center flex-shrink-0 mt-0.5">{step.n}</span>
<p className="text-sm text-gray-600 leading-snug">{step.text}</p>
</div>
))}
</div>
<p className="text-xs text-gray-400 mt-3">L'app sera disponible hors connexion une fois installée.</p>
</div>
</section>
)}
{/* À propos */}
<section>
<h2 className="text-xs font-semibold uppercase tracking-wider text-gray-400 mb-2 px-1">À propos</h2>
<div className="bg-white rounded-2xl border border-gray-100 shadow-sm divide-y divide-gray-50">
<div className="flex items-center justify-between px-4 py-3">
<span className="text-sm text-gray-700">Version</span>
<span className="text-sm text-gray-500">1.0</span>
</div>
<div className="flex items-center justify-between px-4 py-3">
<span className="text-sm text-gray-700">Données stockées</span>
<span className="text-sm text-gray-500">100% sur votre appareil</span>
</div>
</div>
</section>
</div>
);
}

27
app/sitemap.ts Normal file
View file

@ -0,0 +1,27 @@
import type { MetadataRoute } from "next";
const BASE = "https://puzzles.reverdin.eu";
const GAMES = ["queens", "tango", "zip", "sudoku", "patches"];
function todayISO(): string {
const now = new Date();
return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}-${String(now.getDate()).padStart(2, "0")}`;
}
export default function sitemap(): MetadataRoute.Sitemap {
const today = todayISO();
const staticRoutes: MetadataRoute.Sitemap = [
{ url: BASE, lastModified: today, changeFrequency: "daily", priority: 1 },
{ url: `${BASE}/archive`, lastModified: today, changeFrequency: "daily", priority: 0.5 },
{ url: `${BASE}/mentions-legales`, changeFrequency: "yearly", priority: 0.1 },
];
const gameRoutes: MetadataRoute.Sitemap = GAMES.flatMap((game) => [
{ url: `${BASE}/${game}`, lastModified: today, changeFrequency: "daily", priority: 0.9 },
{ url: `${BASE}/${game}/levels`, changeFrequency: "weekly", priority: 0.6 },
{ url: `${BASE}/${game}/${today}`, lastModified: today, changeFrequency: "daily", priority: 0.8 },
]);
return [...staticRoutes, ...gameRoutes];
}

215
app/stats/page.tsx Normal file
View file

@ -0,0 +1,215 @@
"use client";
import { useEffect, useState } from "react";
import Link from "next/link";
import { GAME_META, GAMES, GameId } from "@/lib/levels";
import { loadStats, GameStats } from "@/lib/stats";
import { allStats as allLevelStats, GameStats as LevelStats } from "@/lib/progress";
function fmt(s: number): string {
if (s === 0) return "--:--";
return `${String(Math.floor(s / 60)).padStart(2, "0")}:${String(s % 60).padStart(2, "0")}`;
}
function getDaysRange(days: number): string[] {
const result: string[] = [];
const today = new Date();
for (let i = days - 1; i >= 0; i--) {
const d = new Date(today);
d.setDate(d.getDate() - i);
result.push(`${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`);
}
return result;
}
// ── Heatmap ───────────────────────────────────────────────────────────────────
function Heatmap({ game, solvedDates, accent }: { game: GameId; solvedDates: string[]; accent: string }) {
const days = getDaysRange(91); // 13 weeks
const solvedSet = new Set(solvedDates);
// Group by weeks (7 days per column)
const weeks: string[][] = [];
for (let i = 0; i < days.length; i += 7) {
weeks.push(days.slice(i, i + 7));
}
return (
<div className="flex gap-1 overflow-x-auto pb-1">
{weeks.map((week, wi) => (
<div key={wi} className="flex flex-col gap-1">
{week.map(day => {
const solved = solvedSet.has(day);
return (
<div
key={day}
title={day}
className="w-3.5 h-3.5 rounded-sm"
style={{
background: solved ? accent : "#f1f5f9",
opacity: solved ? 1 : 1,
}}
/>
);
})}
</div>
))}
</div>
);
}
// ── Game stat card ────────────────────────────────────────────────────────────
function GameStatCard({
game,
dailyStats,
levelStats,
}: {
game: GameId;
dailyStats: GameStats;
levelStats: LevelStats | undefined;
}) {
const { name, accent, symbol, subtitle } = GAME_META[game];
return (
<div className="bg-white rounded-2xl border border-gray-100 p-4 flex flex-col gap-4">
{/* Header */}
<div className="flex items-center gap-3">
<span
className="w-9 h-9 rounded-xl flex items-center justify-center text-base font-bold shrink-0"
style={{ background: `${accent}18`, color: accent }}
aria-hidden
>
{symbol}
</span>
<div className="flex-1 min-w-0">
<h3 className="text-sm font-bold text-gray-900">{name}</h3>
<p className="text-xs text-gray-400 truncate">{subtitle}</p>
</div>
</div>
{/* Daily stats */}
<div className="grid grid-cols-3 gap-2">
<div className="flex flex-col items-center gap-0.5 px-2 py-2 bg-gray-50 rounded-xl">
<span className="text-[10px] text-gray-400 uppercase tracking-wide">Série</span>
<span className="text-base font-bold text-gray-800 tabular-nums">{dailyStats.streak}j</span>
</div>
<div className="flex flex-col items-center gap-0.5 px-2 py-2 bg-gray-50 rounded-xl">
<span className="text-[10px] text-gray-400 uppercase tracking-wide">Total</span>
<span className="text-base font-bold text-gray-800 tabular-nums">{dailyStats.total}</span>
</div>
<div className="flex flex-col items-center gap-0.5 px-2 py-2 bg-gray-50 rounded-xl">
<span className="text-[10px] text-gray-400 uppercase tracking-wide">Record</span>
<span className="text-base font-bold text-gray-800 tabular-nums timer-mono">{fmt(dailyStats.bestTime)}</span>
</div>
</div>
{/* Heatmap (last 13 weeks) */}
{dailyStats.solvedDates && dailyStats.solvedDates.length > 0 ? (
<div>
<p className="text-[10px] text-gray-400 mb-1.5">13 dernières semaines</p>
<Heatmap game={game} solvedDates={dailyStats.solvedDates} accent={accent} />
</div>
) : (
<p className="text-xs text-gray-300 text-center py-1">Joue ta première partie pour voir l&apos;historique</p>
)}
{/* Level progress */}
{levelStats && levelStats.completed > 0 && (
<div className="flex items-center gap-2 pt-1 border-t border-gray-50">
<div className="flex-1 h-1.5 bg-gray-100 rounded-full overflow-hidden">
<div className="h-full rounded-full" style={{ width: `${levelStats.pct}%`, background: accent }} />
</div>
<span className="text-[11px] text-gray-400 shrink-0">{levelStats.completed}/100 niv.</span>
<Link
href={`/${game}/levels`}
className="text-[11px] font-semibold shrink-0"
style={{ color: accent }}
>
Continuer
</Link>
</div>
)}
</div>
);
}
// ── Main page ─────────────────────────────────────────────────────────────────
export default function StatsPage() {
const [dailyStats, setDailyStats] = useState<Record<GameId, GameStats> | null>(null);
const [levelStatsAll, setLevelStatsAll] = useState<Record<GameId, LevelStats> | null>(null);
useEffect(() => {
const ds = {} as Record<GameId, GameStats>;
for (const g of GAMES) ds[g] = loadStats(g);
setDailyStats(ds);
setLevelStatsAll(allLevelStats());
}, []);
const totalDaily = dailyStats
? GAMES.reduce((sum, g) => sum + (dailyStats[g]?.total ?? 0), 0)
: 0;
const bestStreak = dailyStats
? Math.max(...GAMES.map(g => dailyStats[g]?.streak ?? 0))
: 0;
return (
<div className="flex flex-col gap-6 max-w-sm mx-auto py-4 px-4">
{/* Header */}
<div className="flex items-center gap-3">
<Link href="/" className="text-gray-400 hover:text-gray-600 transition-colors">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round"><polyline points="15 18 9 12 15 6"/></svg>
</Link>
<div>
<h1 className="text-xl font-bold text-gray-900 tracking-tight">Mes statistiques</h1>
<p className="text-xs text-gray-400 mt-0.5">Puzzles quotidiens &amp; entraînement</p>
</div>
</div>
{/* Summary strip */}
{dailyStats && (
<div className="grid grid-cols-2 gap-3">
<div className="bg-white rounded-2xl border border-gray-100 p-4 text-center">
<p className="text-3xl font-black text-gray-900 tabular-nums">{totalDaily}</p>
<p className="text-xs text-gray-400 mt-0.5">puzzles résolus</p>
</div>
<div className="bg-white rounded-2xl border border-gray-100 p-4 text-center">
<div className="flex items-center justify-center gap-1">
<span className="text-2xl" aria-hidden>🔥</span>
<p className="text-3xl font-black text-gray-900 tabular-nums">{bestStreak}</p>
</div>
<p className="text-xs text-gray-400 mt-0.5">meilleure série</p>
</div>
</div>
)}
{/* Per-game cards */}
{dailyStats ? (
<div className="flex flex-col gap-3">
{GAMES.map(game => (
<GameStatCard
key={game}
game={game}
dailyStats={dailyStats[game]}
levelStats={levelStatsAll?.[game]}
/>
))}
</div>
) : (
<div className="flex flex-col gap-3">
{[...Array(5)].map((_, i) => (
<div key={i} className="skeleton h-[200px] rounded-2xl" />
))}
</div>
)}
<Link
href="/"
className="text-xs text-gray-400 hover:text-gray-600 transition-colors text-center pb-2"
>
Retour aux puzzles du jour
</Link>
</div>
);
}

View file

@ -0,0 +1,31 @@
"use client";
import { useState, useEffect } from "react";
import { useParams } from "next/navigation";
import { generateSudoku, SudokuPuzzle } from "@/lib/generators/sudoku";
import SudokuBoard from "@/components/SudokuBoard";
import Link from "next/link";
export default function SudokuDatePage() {
const { date } = useParams<{ date: string }>();
const [puzzle, setPuzzle] = useState<SudokuPuzzle | null>(null);
useEffect(() => { setPuzzle(generateSudoku(date)); }, [date]);
return (
<div className="flex flex-col items-center gap-8">
<div className="text-center">
<h1 className="text-2xl font-bold text-gray-900 tracking-tight">Sudoku</h1>
<p className="text-sm text-gray-400 mt-1">{date}</p>
</div>
{puzzle ? (
<SudokuBoard key={date} puzzle={puzzle} date={date} />
) : (
<div className="py-20 text-gray-300 text-sm">Chargement</div>
)}
<div className="flex gap-4 text-sm">
<Link href="/sudoku" className="text-gray-500 hover:text-gray-900 transition-colors"> Aujourd&apos;hui</Link>
<Link href="/archive?game=sudoku" className="text-gray-400 hover:text-gray-700 transition-colors">Archives</Link>
</div>
</div>
);
}

View file

@ -0,0 +1,98 @@
"use client";
import { useState, useEffect } from "react";
import { useParams } from "next/navigation";
import Link from "next/link";
import { generateSudoku, SudokuPuzzle } from "@/lib/generators/sudoku";
import SudokuBoard from "@/components/SudokuBoard";
import { levelToDate, levelMeta, TOTAL_LEVELS, GAME_META } from "@/lib/levels";
import { getGameProgress, recordLevelSolve } from "@/lib/progress";
const DIFF_COLORS: Record<number, { bg: string; text: string }> = {
1: { bg: "#f0fdf4", text: "#16a34a" },
2: { bg: "#fefce8", text: "#ca8a04" },
3: { bg: "#fff7ed", text: "#ea580c" },
4: { bg: "#fef2f2", text: "#dc2626" },
5: { bg: "#faf5ff", text: "#9333ea" },
};
function fmt(s: number) {
return `${String(Math.floor(s / 60)).padStart(2, "0")}:${String(s % 60).padStart(2, "0")}`;
}
export default function SudokuLevelPage() {
const { n } = useParams<{ n: string }>();
const level = Math.max(1, Math.min(TOTAL_LEVELS, parseInt(n) || 1));
const { accent } = GAME_META["sudoku"];
const [puzzle, setPuzzle] = useState<SudokuPuzzle | null>(null);
const [completed, setCompleted] = useState(false);
const [bestTime, setBestTime] = useState(0);
useEffect(() => {
setPuzzle(generateSudoku(levelToDate(level)));
const p = getGameProgress("sudoku");
const record = p[level];
setCompleted(!!record);
setBestTime(record?.bestTime ?? 0);
}, [level]);
const meta = levelMeta("sudoku", level);
const diffColors = DIFF_COLORS[meta.difficulty];
return (
<div className="flex flex-col items-center gap-6">
<div className="w-full flex items-center gap-1.5 text-xs text-gray-400">
<Link href="/sudoku" className="hover:text-gray-600 transition-colors">Sudoku</Link>
<span>/</span>
<Link href="/sudoku/levels" className="hover:text-gray-600 transition-colors">Niveaux</Link>
<span>/</span>
<span className="text-gray-600 font-medium">Niveau {level}</span>
</div>
<div className="text-center">
<div className="flex items-center justify-center gap-2 mb-2">
<h1 className="text-2xl font-bold text-gray-900 tracking-tight">Niveau {level}</h1>
{completed && (
<span className="flex items-center gap-1 text-xs font-semibold text-green-600 bg-green-50 px-2 py-0.5 rounded-full border border-green-200">
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={3} strokeLinecap="round" strokeLinejoin="round"><polyline points="20 6 9 17 4 12"/></svg>
Complété
</span>
)}
</div>
<div className="flex items-center justify-center gap-2">
<span className="text-xs font-semibold px-2.5 py-1 rounded-full" style={{ background: diffColors.bg, color: diffColors.text }}>
{meta.difficultyLabel}
</span>
{completed && bestTime > 0 && (
<span className="text-xs text-gray-400 timer-mono"> {fmt(bestTime)}</span>
)}
</div>
</div>
{puzzle ? (
<SudokuBoard key={`level-sudoku-${level}`} puzzle={puzzle} date={`level-sudoku-${level}`} onSolve={(elapsed) => recordLevelSolve("sudoku", level, elapsed)} />
) : (
<div className="py-20 text-gray-300 text-sm">Chargement</div>
)}
<div className="flex items-center gap-3">
{level > 1 && (
<Link href={`/sudoku/level/${level - 1}`} className="flex items-center gap-1 px-4 py-2 rounded-full border border-gray-200 text-gray-500 text-sm hover:border-gray-300 hover:text-gray-700 transition-colors">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round"><polyline points="15 18 9 12 15 6"/></svg>
Niveau {level - 1}
</Link>
)}
<Link href="/sudoku/levels" className="px-4 py-2 rounded-full border border-gray-200 text-gray-500 text-sm hover:border-gray-300 hover:text-gray-700 transition-colors">
Tous les niveaux
</Link>
{level < TOTAL_LEVELS && (
<Link href={`/sudoku/level/${level + 1}`} className="flex items-center gap-1 px-4 py-2 rounded-full text-white text-sm font-semibold transition-opacity hover:opacity-90" style={{ background: accent }}>
Niveau {level + 1}
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round"><polyline points="9 18 15 12 9 6"/></svg>
</Link>
)}
</div>
</div>
);
}

View file

@ -0,0 +1,7 @@
"use client";
import LevelsPageShell from "@/components/LevelsPageShell";
export default function SudokuLevelsPage() {
return <LevelsPageShell game="sudoku" />;
}

27
app/sudoku/page.tsx Normal file
View file

@ -0,0 +1,27 @@
"use client";
import { useState, useEffect } from "react";
import { generateSudoku, SudokuPuzzle } from "@/lib/generators/sudoku";
import { todayISO } from "@/lib/rng";
import SudokuBoard from "@/components/SudokuBoard";
import DailyPageShell from "@/components/DailyPageShell";
export default function SudokuPage() {
const date = todayISO();
const [puzzle, setPuzzle] = useState<SudokuPuzzle | null>(null);
useEffect(() => { setPuzzle(generateSudoku(date)); }, [date]);
const dateLabel = new Date(date + "T00:00:00").toLocaleDateString("fr-FR", {
weekday: "long", day: "numeric", month: "long",
});
return (
<DailyPageShell game="sudoku" date={date} dateLabel={dateLabel}>
{puzzle ? (
<SudokuBoard puzzle={puzzle} date={date} />
) : (
<div className="py-20 text-gray-300 text-sm">Chargement</div>
)}
</DailyPageShell>
);
}

31
app/tango/[date]/page.tsx Normal file
View file

@ -0,0 +1,31 @@
"use client";
import { useState, useEffect } from "react";
import { useParams } from "next/navigation";
import { generateTango, TangoPuzzle } from "@/lib/generators/tango";
import TangoBoard from "@/components/TangoBoard";
import Link from "next/link";
export default function TangoDatePage() {
const { date } = useParams<{ date: string }>();
const [puzzle, setPuzzle] = useState<TangoPuzzle | null>(null);
useEffect(() => { setPuzzle(generateTango(date)); }, [date]);
return (
<div className="flex flex-col items-center gap-8">
<div className="text-center">
<h1 className="text-2xl font-bold text-gray-900 tracking-tight">Tango</h1>
<p className="text-sm text-gray-400 mt-1">{date}</p>
</div>
{puzzle ? (
<TangoBoard key={date} puzzle={puzzle} date={date} />
) : (
<div className="py-20 text-gray-300 text-sm">Chargement</div>
)}
<div className="flex gap-4 text-sm">
<Link href="/tango" className="text-gray-500 hover:text-gray-900 transition-colors"> Aujourd&apos;hui</Link>
<Link href="/archive?game=tango" className="text-gray-400 hover:text-gray-700 transition-colors">Archives</Link>
</div>
</div>
);
}

View file

@ -0,0 +1,98 @@
"use client";
import { useState, useEffect } from "react";
import { useParams } from "next/navigation";
import Link from "next/link";
import { generateTango, TangoPuzzle } from "@/lib/generators/tango";
import TangoBoard from "@/components/TangoBoard";
import { levelToDate, levelMeta, TOTAL_LEVELS, GAME_META } from "@/lib/levels";
import { getGameProgress, recordLevelSolve } from "@/lib/progress";
const DIFF_COLORS: Record<number, { bg: string; text: string }> = {
1: { bg: "#f0fdf4", text: "#16a34a" },
2: { bg: "#fefce8", text: "#ca8a04" },
3: { bg: "#fff7ed", text: "#ea580c" },
4: { bg: "#fef2f2", text: "#dc2626" },
5: { bg: "#faf5ff", text: "#9333ea" },
};
function fmt(s: number) {
return `${String(Math.floor(s / 60)).padStart(2, "0")}:${String(s % 60).padStart(2, "0")}`;
}
export default function TangoLevelPage() {
const { n } = useParams<{ n: string }>();
const level = Math.max(1, Math.min(TOTAL_LEVELS, parseInt(n) || 1));
const { accent } = GAME_META["tango"];
const [puzzle, setPuzzle] = useState<TangoPuzzle | null>(null);
const [completed, setCompleted] = useState(false);
const [bestTime, setBestTime] = useState(0);
useEffect(() => {
setPuzzle(generateTango(levelToDate(level)));
const p = getGameProgress("tango");
const record = p[level];
setCompleted(!!record);
setBestTime(record?.bestTime ?? 0);
}, [level]);
const meta = levelMeta("tango", level);
const diffColors = DIFF_COLORS[meta.difficulty];
return (
<div className="flex flex-col items-center gap-6">
<div className="w-full flex items-center gap-1.5 text-xs text-gray-400">
<Link href="/tango" className="hover:text-gray-600 transition-colors">Tango</Link>
<span>/</span>
<Link href="/tango/levels" className="hover:text-gray-600 transition-colors">Niveaux</Link>
<span>/</span>
<span className="text-gray-600 font-medium">Niveau {level}</span>
</div>
<div className="text-center">
<div className="flex items-center justify-center gap-2 mb-2">
<h1 className="text-2xl font-bold text-gray-900 tracking-tight">Niveau {level}</h1>
{completed && (
<span className="flex items-center gap-1 text-xs font-semibold text-green-600 bg-green-50 px-2 py-0.5 rounded-full border border-green-200">
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={3} strokeLinecap="round" strokeLinejoin="round"><polyline points="20 6 9 17 4 12"/></svg>
Complété
</span>
)}
</div>
<div className="flex items-center justify-center gap-2">
<span className="text-xs font-semibold px-2.5 py-1 rounded-full" style={{ background: diffColors.bg, color: diffColors.text }}>
{meta.difficultyLabel}
</span>
{completed && bestTime > 0 && (
<span className="text-xs text-gray-400 timer-mono"> {fmt(bestTime)}</span>
)}
</div>
</div>
{puzzle ? (
<TangoBoard key={`level-tango-${level}`} puzzle={puzzle} date={`level-tango-${level}`} onSolve={(elapsed) => recordLevelSolve("tango", level, elapsed)} />
) : (
<div className="py-20 text-gray-300 text-sm">Chargement</div>
)}
<div className="flex items-center gap-3">
{level > 1 && (
<Link href={`/tango/level/${level - 1}`} className="flex items-center gap-1 px-4 py-2 rounded-full border border-gray-200 text-gray-500 text-sm hover:border-gray-300 hover:text-gray-700 transition-colors">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round"><polyline points="15 18 9 12 15 6"/></svg>
Niveau {level - 1}
</Link>
)}
<Link href="/tango/levels" className="px-4 py-2 rounded-full border border-gray-200 text-gray-500 text-sm hover:border-gray-300 hover:text-gray-700 transition-colors">
Tous les niveaux
</Link>
{level < TOTAL_LEVELS && (
<Link href={`/tango/level/${level + 1}`} className="flex items-center gap-1 px-4 py-2 rounded-full text-white text-sm font-semibold transition-opacity hover:opacity-90" style={{ background: accent }}>
Niveau {level + 1}
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round"><polyline points="9 18 15 12 9 6"/></svg>
</Link>
)}
</div>
</div>
);
}

View file

@ -0,0 +1,7 @@
"use client";
import LevelsPageShell from "@/components/LevelsPageShell";
export default function TangoLevelsPage() {
return <LevelsPageShell game="tango" />;
}

27
app/tango/page.tsx Normal file
View file

@ -0,0 +1,27 @@
"use client";
import { useState, useEffect } from "react";
import { generateTango, TangoPuzzle } from "@/lib/generators/tango";
import { todayISO } from "@/lib/rng";
import TangoBoard from "@/components/TangoBoard";
import DailyPageShell from "@/components/DailyPageShell";
export default function TangoPage() {
const date = todayISO();
const [puzzle, setPuzzle] = useState<TangoPuzzle | null>(null);
useEffect(() => { setPuzzle(generateTango(date)); }, [date]);
const dateLabel = new Date(date + "T00:00:00").toLocaleDateString("fr-FR", {
weekday: "long", day: "numeric", month: "long",
});
return (
<DailyPageShell game="tango" date={date} dateLabel={dateLabel}>
{puzzle ? (
<TangoBoard puzzle={puzzle} date={date} />
) : (
<div className="py-20 text-gray-300 text-sm">Chargement</div>
)}
</DailyPageShell>
);
}

31
app/zip/[date]/page.tsx Normal file
View file

@ -0,0 +1,31 @@
"use client";
import { useState, useEffect } from "react";
import { useParams } from "next/navigation";
import { generateZip, zipSizeForDate, ZipPuzzle } from "@/lib/generators/zip";
import ZipBoard from "@/components/ZipBoard";
import Link from "next/link";
export default function ZipDatePage() {
const { date } = useParams<{ date: string }>();
const [puzzle, setPuzzle] = useState<ZipPuzzle | null>(null);
useEffect(() => { setPuzzle(generateZip(date, zipSizeForDate(date))); }, [date]);
return (
<div className="flex flex-col items-center gap-8">
<div className="text-center">
<h1 className="text-2xl font-bold text-gray-900 tracking-tight">Zip</h1>
<p className="text-sm text-gray-400 mt-1">{date}</p>
</div>
{puzzle ? (
<ZipBoard key={date} puzzle={puzzle} date={date} />
) : (
<div className="py-20 text-gray-300 text-sm">Chargement</div>
)}
<div className="flex gap-4 text-sm">
<Link href="/zip" className="text-gray-500 hover:text-gray-900 transition-colors"> Aujourd&apos;hui</Link>
<Link href="/archive?game=zip" className="text-gray-400 hover:text-gray-700 transition-colors">Archives</Link>
</div>
</div>
);
}

View file

@ -0,0 +1,98 @@
"use client";
import { useState, useEffect } from "react";
import { useParams } from "next/navigation";
import Link from "next/link";
import { generateZip, ZipPuzzle } from "@/lib/generators/zip";
import ZipBoard from "@/components/ZipBoard";
import { levelToDate, zipSizeForLevel, levelMeta, TOTAL_LEVELS, GAME_META } from "@/lib/levels";
import { getGameProgress, recordLevelSolve } from "@/lib/progress";
const DIFF_COLORS: Record<number, { bg: string; text: string }> = {
1: { bg: "#f0fdf4", text: "#16a34a" },
2: { bg: "#fefce8", text: "#ca8a04" },
3: { bg: "#fff7ed", text: "#ea580c" },
4: { bg: "#fef2f2", text: "#dc2626" },
5: { bg: "#faf5ff", text: "#9333ea" },
};
function fmt(s: number) {
return `${String(Math.floor(s / 60)).padStart(2, "0")}:${String(s % 60).padStart(2, "0")}`;
}
export default function ZipLevelPage() {
const { n } = useParams<{ n: string }>();
const level = Math.max(1, Math.min(TOTAL_LEVELS, parseInt(n) || 1));
const { accent } = GAME_META["zip"];
const [puzzle, setPuzzle] = useState<ZipPuzzle | null>(null);
const [completed, setCompleted] = useState(false);
const [bestTime, setBestTime] = useState(0);
useEffect(() => {
setPuzzle(generateZip(levelToDate(level), zipSizeForLevel(level)));
const p = getGameProgress("zip");
const record = p[level];
setCompleted(!!record);
setBestTime(record?.bestTime ?? 0);
}, [level]);
const meta = levelMeta("zip", level);
const diffColors = DIFF_COLORS[meta.difficulty];
return (
<div className="flex flex-col items-center gap-6">
<div className="w-full flex items-center gap-1.5 text-xs text-gray-400">
<Link href="/zip" className="hover:text-gray-600 transition-colors">Zip</Link>
<span>/</span>
<Link href="/zip/levels" className="hover:text-gray-600 transition-colors">Niveaux</Link>
<span>/</span>
<span className="text-gray-600 font-medium">Niveau {level}</span>
</div>
<div className="text-center">
<div className="flex items-center justify-center gap-2 mb-2">
<h1 className="text-2xl font-bold text-gray-900 tracking-tight">Niveau {level}</h1>
{completed && (
<span className="flex items-center gap-1 text-xs font-semibold text-green-600 bg-green-50 px-2 py-0.5 rounded-full border border-green-200">
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={3} strokeLinecap="round" strokeLinejoin="round"><polyline points="20 6 9 17 4 12"/></svg>
Complété
</span>
)}
</div>
<div className="flex items-center justify-center gap-2">
<span className="text-xs font-semibold px-2.5 py-1 rounded-full" style={{ background: diffColors.bg, color: diffColors.text }}>
{meta.difficultyLabel}
</span>
{completed && bestTime > 0 && (
<span className="text-xs text-gray-400 timer-mono"> {fmt(bestTime)}</span>
)}
</div>
</div>
{puzzle ? (
<ZipBoard key={`level-zip-${level}`} puzzle={puzzle} date={`level-zip-${level}`} onSolve={(elapsed) => recordLevelSolve("zip", level, elapsed)} />
) : (
<div className="py-20 text-gray-300 text-sm">Chargement</div>
)}
<div className="flex items-center gap-3">
{level > 1 && (
<Link href={`/zip/level/${level - 1}`} className="flex items-center gap-1 px-4 py-2 rounded-full border border-gray-200 text-gray-500 text-sm hover:border-gray-300 hover:text-gray-700 transition-colors">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round"><polyline points="15 18 9 12 15 6"/></svg>
Niveau {level - 1}
</Link>
)}
<Link href="/zip/levels" className="px-4 py-2 rounded-full border border-gray-200 text-gray-500 text-sm hover:border-gray-300 hover:text-gray-700 transition-colors">
Tous les niveaux
</Link>
{level < TOTAL_LEVELS && (
<Link href={`/zip/level/${level + 1}`} className="flex items-center gap-1 px-4 py-2 rounded-full text-white text-sm font-semibold transition-opacity hover:opacity-90" style={{ background: accent }}>
Niveau {level + 1}
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round"><polyline points="9 18 15 12 9 6"/></svg>
</Link>
)}
</div>
</div>
);
}

7
app/zip/levels/page.tsx Normal file
View file

@ -0,0 +1,7 @@
"use client";
import LevelsPageShell from "@/components/LevelsPageShell";
export default function ZipLevelsPage() {
return <LevelsPageShell game="zip" />;
}

27
app/zip/page.tsx Normal file
View file

@ -0,0 +1,27 @@
"use client";
import { useState, useEffect } from "react";
import { generateZip, zipSizeForDate, ZipPuzzle } from "@/lib/generators/zip";
import { todayISO } from "@/lib/rng";
import ZipBoard from "@/components/ZipBoard";
import DailyPageShell from "@/components/DailyPageShell";
export default function ZipPage() {
const date = todayISO();
const [puzzle, setPuzzle] = useState<ZipPuzzle | null>(null);
useEffect(() => { setPuzzle(generateZip(date, zipSizeForDate(date))); }, [date]);
const dateLabel = new Date(date + "T00:00:00").toLocaleDateString("fr-FR", {
weekday: "long", day: "numeric", month: "long",
});
return (
<DailyPageShell game="zip" date={date} dateLabel={dateLabel}>
{puzzle ? (
<ZipBoard puzzle={puzzle} date={date} />
) : (
<div className="py-20 text-gray-300 text-sm">Chargement</div>
)}
</DailyPageShell>
);
}

241
archive/page.tsx Normal file
View file

@ -0,0 +1,241 @@
"use client";
import { useMemo, useState, useEffect, Suspense } from "react";
import Link from "next/link";
import { useSearchParams } from "next/navigation";
import { todayISO } from "@/lib/rng";
const GAMES = [
{
key: "queens",
label: "Queens",
symbol: "♛",
href: (d: string) => `/queens/${d}`,
storageKey: (d: string) => `queens-${d}`,
isWon: (s: string) => {
try { return (JSON.parse(s) as string[][]).flat().filter(c => c === "queen").length >= 6; } catch { return false; }
},
},
{
key: "tango",
label: "Tango",
symbol: "☀",
href: (d: string) => `/tango/${d}`,
storageKey: (d: string) => `tango-${d}`,
isWon: (s: string) => {
try { return (JSON.parse(s) as (string | null)[][]).flat().every(c => c !== null); } catch { return false; }
},
},
{
key: "zip",
label: "Zip",
symbol: "∞",
href: (d: string) => `/zip/${d}`,
storageKey: (d: string) => `zip-${d}`,
isWon: (s: string) => {
try { return (JSON.parse(s) as unknown[]).length === 25; } catch { return false; }
},
},
{
key: "sudoku",
label: "Sudoku",
symbol: "#",
href: (d: string) => `/sudoku/${d}`,
storageKey: (d: string) => `sudoku-${d}`,
isWon: (s: string) => {
try { return (JSON.parse(s) as number[][]).flat().every(n => n > 0); } catch { return false; }
},
},
{
key: "patches",
label: "Patches",
symbol: "▦",
href: (d: string) => `/patches/${d}`,
storageKey: (d: string) => `patches-${d}`,
isWon: (s: string) => {
try {
const p = JSON.parse(s);
// patches stores the placed pieces array
return Array.isArray(p) && p.length > 0;
} catch { return false; }
},
},
];
function getPastDates(n: number): string[] {
const dates: string[] = [];
const d = new Date();
for (let i = 0; i < n; i++) {
const y = d.getFullYear();
const m = String(d.getMonth() + 1).padStart(2, "0");
const day = String(d.getDate()).padStart(2, "0");
dates.push(`${y}-${m}-${day}`);
d.setDate(d.getDate() - 1);
}
return dates;
}
function fmtDate(iso: string, today: string): string {
if (iso === today) return "Aujourd'hui";
const [y, m, d] = iso.split("-").map(Number);
const date = new Date(y, m - 1, d);
const yesterday = new Date();
yesterday.setDate(yesterday.getDate() - 1);
if (iso === getPastDates(2)[1]) return "Hier";
return date.toLocaleDateString("fr-FR", { weekday: "short", day: "numeric", month: "short" });
}
function GameChip({ game, date, won }: { game: typeof GAMES[0]; date: string; won: boolean | undefined }) {
return (
<Link
href={game.href(date)}
className={`flex items-center gap-1.5 px-2.5 py-1 rounded-lg text-xs font-medium transition-colors border ${
won
? "bg-green-50 border-green-200 text-green-700 hover:bg-green-100"
: "bg-gray-50 border-gray-100 text-gray-500 hover:bg-gray-100 hover:text-gray-700"
}`}
>
<span className="text-[11px] opacity-60">{game.symbol}</span>
<span>{game.label}</span>
{won && (
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={3} strokeLinecap="round" strokeLinejoin="round" className="text-green-600">
<polyline points="20 6 9 17 4 12"/>
</svg>
)}
</Link>
);
}
function ArchiveContent() {
const params = useSearchParams();
const filter = params.get("game") ?? "all";
const [progress, setProgress] = useState<Record<string, boolean | undefined>>({});
const today = todayISO();
const dates = useMemo(() => getPastDates(90), []);
useEffect(() => {
const rec: Record<string, boolean | undefined> = {};
for (const game of GAMES) {
for (const date of dates) {
const s = localStorage.getItem(game.storageKey(date));
if (s) {
rec[`${game.key}-${date}`] = game.isWon(s);
}
}
}
setProgress(rec);
}, [dates]);
const visibleGames = filter === "all" ? GAMES : GAMES.filter(g => g.key === filter);
// Compute completion summary per visible game
const summary = useMemo(() => {
return visibleGames.map(game => {
const solved = dates.filter(d => progress[`${game.key}-${d}`] === true).length;
return { key: game.key, label: game.label, solved };
});
}, [visibleGames, dates, progress]);
return (
<div className="flex flex-col gap-6 max-w-2xl mx-auto">
{/* Header */}
<div className="text-center">
<h1 className="text-2xl font-bold text-gray-900 tracking-tight">Archives</h1>
<p className="text-gray-400 text-sm mt-1">90 derniers jours</p>
</div>
{/* Filter tabs */}
<div className="flex gap-1.5 flex-wrap justify-center">
{[{ k: "all", l: "Tous", sym: "" }, ...GAMES.map(g => ({ k: g.key, l: g.label, sym: g.symbol }))].map(({ k, l, sym }) => (
<Link
key={k}
href={k === "all" ? "/archive" : `/archive?game=${k}`}
className={`flex items-center gap-1 px-3 py-1.5 rounded-full text-sm font-medium transition-colors ${
filter === k
? "bg-gray-900 text-white"
: "bg-white text-gray-500 border border-gray-200 hover:bg-gray-50 hover:text-gray-700"
}`}
>
{sym && <span className="text-[12px] opacity-70">{sym}</span>}
{l}
</Link>
))}
</div>
{/* Completion summary chips */}
{Object.keys(progress).length > 0 && (
<div className="flex gap-2 flex-wrap justify-center">
{summary.map(({ key, label, solved }) => (
<div key={key} className="flex items-center gap-1.5 text-xs text-gray-500 bg-white border border-gray-100 rounded-full px-3 py-1">
<span className="font-semibold text-gray-700">{solved}</span>
<span>/ 90</span>
<span className="text-gray-400">{label}</span>
</div>
))}
</div>
)}
{/* Date rows */}
<div className="flex flex-col gap-1.5">
{dates.map(date => {
const isToday = date === today;
const dateLabel = fmtDate(date, today);
const solvedAll = visibleGames.every(g => progress[`${g.key}-${date}`] === true);
const solvedCount = visibleGames.filter(g => progress[`${g.key}-${date}`] === true).length;
return (
<div
key={date}
className={`flex items-center gap-3 px-4 py-2.5 bg-white rounded-xl border transition-colors ${
isToday ? "border-amber-200 shadow-sm" : "border-gray-100"
}`}
>
{/* Date label */}
<div className="w-24 shrink-0 flex flex-col">
<span className={`text-sm font-semibold leading-tight ${isToday ? "text-amber-700" : "text-gray-700"}`}>
{dateLabel}
</span>
{isToday && (
<span className="text-[10px] text-amber-500 font-medium">Aujourd&apos;hui</span>
)}
</div>
{/* Game chips */}
<div className="flex flex-wrap gap-1.5 flex-1 min-w-0">
{visibleGames.map(game => (
<GameChip
key={game.key}
game={game}
date={date}
won={progress[`${game.key}-${date}`]}
/>
))}
</div>
{/* Right: completion indicator */}
{solvedCount > 0 && (
<div className={`shrink-0 text-xs font-semibold tabular-nums ${solvedAll ? "text-green-600" : "text-gray-400"}`}>
{solvedCount}/{visibleGames.length}
</div>
)}
</div>
);
})}
</div>
</div>
);
}
export default function ArchivePage() {
return (
<Suspense fallback={
<div className="flex flex-col gap-3 max-w-2xl mx-auto py-8">
{Array.from({ length: 10 }).map((_, i) => (
<div key={i} className="skeleton h-12 rounded-xl" />
))}
</div>
}>
<ArchiveContent />
</Suspense>
);
}

980
bot-output/2026-05-11.json Normal file
View file

@ -0,0 +1,980 @@
{
"date": "2026-05-11",
"puzzles": {
"queens": {
"game": "queens",
"date": "2026-05-11",
"size": 8,
"grid": [
" B B A [A] A A A C ",
" E [B] E E E A A C ",
" E E E E E D D [C]",
" E E E E G [D] D H ",
"[E] F E E G H H H ",
" E F [F] E G H H H ",
" G F G G [G] H H H ",
" G G G H H H [H] H "
],
"solution": [
[
0,
3
],
[
1,
1
],
[
2,
7
],
[
3,
5
],
[
4,
0
],
[
5,
2
],
[
6,
4
],
[
7,
6
]
],
"regionCount": 8
},
"tango": {
"game": "tango",
"date": "2026-05-11",
"size": 6,
"grid": [
" ◐ ◐ ☀ [☀][◐] ☀ ",
"[☀] ☀ ◐ [☀] ◐ ◐ ",
" ◐ [☀] ◐ [◐] ☀ ☀ ",
" ☀ ◐ ☀ [◐][◐] ☀ ",
"[◐] ☀ ◐ [☀] ☀ ◐ ",
" ☀ ◐ ☀ ◐ ☀ ◐ "
],
"solution": [
[
"moon",
"moon",
"sun",
"sun",
"moon",
"sun"
],
[
"sun",
"sun",
"moon",
"sun",
"moon",
"moon"
],
[
"moon",
"sun",
"moon",
"moon",
"sun",
"sun"
],
[
"sun",
"moon",
"sun",
"moon",
"moon",
"sun"
],
[
"moon",
"sun",
"moon",
"sun",
"sun",
"moon"
],
[
"sun",
"moon",
"sun",
"moon",
"sun",
"moon"
]
],
"given": [
[
null,
null,
null,
"sun",
"moon",
null
],
[
"sun",
null,
null,
"sun",
null,
null
],
[
null,
"sun",
null,
"moon",
null,
null
],
[
null,
null,
null,
"moon",
"moon",
null
],
[
"moon",
null,
null,
"sun",
null,
null
],
[
null,
null,
null,
null,
null,
null
]
],
"hEdges": [
[
null,
null,
null,
null,
null
],
[
null,
null,
null,
null,
null
],
[
null,
null,
null,
null,
null
],
[
null,
null,
null,
null,
null
],
[
null,
null,
null,
null,
null
],
[
null,
null,
null,
null,
null
]
],
"vEdges": [
[
null,
null,
null,
null,
null,
null
],
[
null,
"=",
null,
null,
null,
null
],
[
null,
null,
null,
null,
null,
null
],
[
null,
null,
null,
null,
null,
null
],
[
null,
null,
null,
null,
null,
"="
]
]
},
"zip": {
"game": "zip",
"date": "2026-05-11",
"size": 8,
"grid": [
" 45 46 47 2 3 6 7 [ 2]",
" 44 49 48 [ 1] 4 5 10 9 ",
"[ 7][ 8] 63 [10] 59 58 11 12 ",
" 42 51 62 61 60 [ 9] 14 13 ",
" 41 52 53 54 55 56 [ 3] 16 ",
" 40 39 38 37 [ 6] 35 34 17 ",
" 27 28 [ 5] 30 31 32 33 18 ",
" 26 25 24 23 [ 4] 21 20 19 "
],
"path": [
[
1,
3
],
[
0,
3
],
[
0,
4
],
[
1,
4
],
[
1,
5
],
[
0,
5
],
[
0,
6
],
[
0,
7
],
[
1,
7
],
[
1,
6
],
[
2,
6
],
[
2,
7
],
[
3,
7
],
[
3,
6
],
[
4,
6
],
[
4,
7
],
[
5,
7
],
[
6,
7
],
[
7,
7
],
[
7,
6
],
[
7,
5
],
[
7,
4
],
[
7,
3
],
[
7,
2
],
[
7,
1
],
[
7,
0
],
[
6,
0
],
[
6,
1
],
[
6,
2
],
[
6,
3
],
[
6,
4
],
[
6,
5
],
[
6,
6
],
[
5,
6
],
[
5,
5
],
[
5,
4
],
[
5,
3
],
[
5,
2
],
[
5,
1
],
[
5,
0
],
[
4,
0
],
[
3,
0
],
[
2,
0
],
[
1,
0
],
[
0,
0
],
[
0,
1
],
[
0,
2
],
[
1,
2
],
[
1,
1
],
[
2,
1
],
[
3,
1
],
[
4,
1
],
[
4,
2
],
[
4,
3
],
[
4,
4
],
[
4,
5
],
[
3,
5
],
[
2,
5
],
[
2,
4
],
[
3,
4
],
[
3,
3
],
[
3,
2
],
[
2,
2
],
[
2,
3
]
],
"numberedCells": {
"1,3": 1,
"0,7": 2,
"4,6": 3,
"7,4": 4,
"6,2": 5,
"5,4": 6,
"2,0": 7,
"2,1": 8,
"3,5": 9,
"2,3": 10
},
"walls": [
[
0,
0,
0,
0,
1,
4,
0,
0
],
[
0,
0,
1,
4,
0,
0,
0,
0
],
[
0,
0,
0,
3,
4,
0,
0,
0
],
[
0,
0,
0,
8,
1,
4,
0,
0
],
[
0,
0,
0,
0,
0,
0,
0,
0
],
[
2,
0,
0,
0,
0,
2,
0,
0
],
[
8,
0,
0,
0,
0,
8,
0,
0
],
[
0,
0,
0,
0,
0,
0,
0,
0
]
]
},
"sudoku": {
"game": "sudoku",
"date": "2026-05-11",
"size": 6,
"grid": [
" 1 5 [4] 3 2 [6]",
" 6 [3] 2 [5] 4 1 ",
"[5][4] 3 6 1 [2]",
"[2] 1 6 4 3 [5]",
" 3 [2] 5 [1] 6 [4]",
" 4 6 [1] 2 [5] 3 "
],
"given": [
[
0,
0,
4,
0,
0,
6
],
[
0,
3,
0,
5,
0,
0
],
[
5,
4,
0,
0,
0,
2
],
[
2,
0,
0,
0,
0,
5
],
[
0,
2,
0,
1,
0,
4
],
[
0,
0,
1,
0,
5,
0
]
],
"solution": [
[
1,
5,
4,
3,
2,
6
],
[
6,
3,
2,
5,
4,
1
],
[
5,
4,
3,
6,
1,
2
],
[
2,
1,
6,
4,
3,
5
],
[
3,
2,
5,
1,
6,
4
],
[
4,
6,
1,
2,
5,
3
]
]
},
"patches": {
"game": "patches",
"date": "2026-05-11",
"size": 6,
"grid": [
" B D E C C G ",
" B D E A A G ",
" B D E E A G ",
" B D E F A G ",
" B D E F F G ",
" B D F F F G "
],
"regions": [
{
"id": 0,
"size": 4,
"color": "#e8b040",
"cells": [
[
1,
3
],
[
1,
4
],
[
2,
4
],
[
3,
4
]
],
"hintCell": [
1,
3
]
},
{
"id": 1,
"size": 6,
"color": "#4a9fd4",
"cells": [
[
0,
0
],
[
1,
0
],
[
2,
0
],
[
3,
0
],
[
4,
0
],
[
5,
0
]
],
"hintCell": [
0,
0
]
},
{
"id": 2,
"size": 2,
"color": "#d06898",
"cells": [
[
0,
3
],
[
0,
4
]
],
"hintCell": [
0,
3
]
},
{
"id": 3,
"size": 6,
"color": "#e07830",
"cells": [
[
0,
1
],
[
1,
1
],
[
2,
1
],
[
3,
1
],
[
4,
1
],
[
5,
1
]
],
"hintCell": [
0,
1
]
},
{
"id": 4,
"size": 6,
"color": "#30b8b0",
"cells": [
[
0,
2
],
[
1,
2
],
[
2,
2
],
[
2,
3
],
[
3,
2
],
[
4,
2
]
],
"hintCell": [
0,
2
]
},
{
"id": 5,
"size": 6,
"color": "#e05050",
"cells": [
[
3,
3
],
[
4,
3
],
[
4,
4
],
[
5,
2
],
[
5,
3
],
[
5,
4
]
],
"hintCell": [
3,
3
]
},
{
"id": 6,
"size": 6,
"color": "#4db86e",
"cells": [
[
0,
5
],
[
1,
5
],
[
2,
5
],
[
3,
5
],
[
4,
5
],
[
5,
5
]
],
"hintCell": [
0,
5
]
}
],
"solution": {
"0": 0,
"1": 1,
"2": 2,
"3": 3,
"4": 4,
"5": 5,
"6": 6
}
}
},
"errors": []
}

980
bot-output/2026-05-12.json Normal file
View file

@ -0,0 +1,980 @@
{
"date": "2026-05-12",
"puzzles": {
"queens": {
"game": "queens",
"date": "2026-05-12",
"size": 8,
"grid": [
"[A] D D D D D B B ",
" A D D D D [B] B B ",
" A A D D B B B [C]",
" G G [D] D D B B C ",
" G G D D F F [E] C ",
" G G G [F] F H E E ",
" G [G] G G H H H E ",
" G G G G [H] H H E "
],
"solution": [
[
0,
0
],
[
1,
5
],
[
2,
7
],
[
3,
2
],
[
4,
6
],
[
5,
3
],
[
6,
1
],
[
7,
4
]
],
"regionCount": 8
},
"tango": {
"game": "tango",
"date": "2026-05-12",
"size": 6,
"grid": [
" ◐ [◐][☀] ◐ ☀ ☀ ",
" ☀ ☀ ◐ [◐] ☀ [◐]",
"[◐] ☀ ◐ [☀] ◐ ☀ ",
" ☀ ◐ ☀ [☀] ◐ ◐ ",
"[◐] ☀ [☀] ◐ ☀ ◐ ",
"[☀] ◐ ◐ ☀ ◐ ☀ "
],
"solution": [
[
"moon",
"moon",
"sun",
"moon",
"sun",
"sun"
],
[
"sun",
"sun",
"moon",
"moon",
"sun",
"moon"
],
[
"moon",
"sun",
"moon",
"sun",
"moon",
"sun"
],
[
"sun",
"moon",
"sun",
"sun",
"moon",
"moon"
],
[
"moon",
"sun",
"sun",
"moon",
"sun",
"moon"
],
[
"sun",
"moon",
"moon",
"sun",
"moon",
"sun"
]
],
"given": [
[
null,
"moon",
"sun",
null,
null,
null
],
[
null,
null,
null,
"moon",
null,
"moon"
],
[
"moon",
null,
null,
"sun",
null,
null
],
[
null,
null,
null,
"sun",
null,
null
],
[
"moon",
null,
"sun",
null,
null,
null
],
[
"sun",
null,
null,
null,
null,
null
]
],
"hEdges": [
[
null,
null,
null,
null,
null
],
[
null,
null,
null,
null,
null
],
[
null,
null,
null,
null,
null
],
[
null,
null,
"=",
null,
null
],
[
null,
null,
null,
null,
null
],
[
null,
null,
null,
null,
null
]
],
"vEdges": [
[
null,
null,
null,
null,
null,
null
],
[
null,
null,
null,
null,
null,
null
],
[
null,
null,
null,
null,
null,
null
],
[
null,
null,
null,
null,
null,
null
],
[
null,
null,
null,
null,
null,
null
]
]
},
"zip": {
"game": "zip",
"date": "2026-05-12",
"size": 8,
"grid": [
" 4 5 [ 2] 9 10 11 12 13 ",
" 3 6 7 18 17 16 [ 3] 14 ",
" 2 [ 1] 20 19 30 31 34 35 ",
" 23 [ 4] 21 28 [ 5] 32 33 [ 6]",
" 24 25 26 27 46 45 44 37 ",
"[ 9] 56 55 54 47 48 [ 7] 38 ",
" 58 61 62 53 52 49 42 39 ",
" 59 60 63 [10] 51 [ 8] 41 40 "
],
"path": [
[
2,
1
],
[
2,
0
],
[
1,
0
],
[
0,
0
],
[
0,
1
],
[
1,
1
],
[
1,
2
],
[
0,
2
],
[
0,
3
],
[
0,
4
],
[
0,
5
],
[
0,
6
],
[
0,
7
],
[
1,
7
],
[
1,
6
],
[
1,
5
],
[
1,
4
],
[
1,
3
],
[
2,
3
],
[
2,
2
],
[
3,
2
],
[
3,
1
],
[
3,
0
],
[
4,
0
],
[
4,
1
],
[
4,
2
],
[
4,
3
],
[
3,
3
],
[
3,
4
],
[
2,
4
],
[
2,
5
],
[
3,
5
],
[
3,
6
],
[
2,
6
],
[
2,
7
],
[
3,
7
],
[
4,
7
],
[
5,
7
],
[
6,
7
],
[
7,
7
],
[
7,
6
],
[
6,
6
],
[
5,
6
],
[
4,
6
],
[
4,
5
],
[
4,
4
],
[
5,
4
],
[
5,
5
],
[
6,
5
],
[
7,
5
],
[
7,
4
],
[
6,
4
],
[
6,
3
],
[
5,
3
],
[
5,
2
],
[
5,
1
],
[
5,
0
],
[
6,
0
],
[
7,
0
],
[
7,
1
],
[
6,
1
],
[
6,
2
],
[
7,
2
],
[
7,
3
]
],
"numberedCells": {
"2,1": 1,
"0,2": 2,
"1,6": 3,
"3,1": 4,
"3,4": 5,
"3,7": 6,
"5,6": 7,
"7,5": 8,
"5,0": 9,
"7,3": 10
},
"walls": [
[
0,
0,
0,
0,
0,
0,
0,
0
],
[
0,
0,
0,
0,
0,
2,
0,
2
],
[
0,
0,
0,
0,
0,
9,
4,
8
],
[
0,
0,
0,
0,
0,
0,
0,
0
],
[
0,
0,
0,
0,
0,
0,
0,
0
],
[
0,
0,
0,
0,
0,
0,
1,
4
],
[
0,
0,
0,
0,
0,
0,
0,
0
],
[
0,
1,
4,
0,
0,
0,
0,
0
]
]
},
"sudoku": {
"game": "sudoku",
"date": "2026-05-12",
"size": 6,
"grid": [
" 5 4 2 1 [6] 3 ",
"[1][6] 3 [5][2][4]",
"[3] 1 [5] 2 4 6 ",
"[4] 2 [6] 3 [1] 5 ",
" 6 5 1 4 3 [2]",
" 2 3 [4] 6 [5] 1 "
],
"given": [
[
0,
0,
0,
0,
6,
0
],
[
1,
6,
0,
5,
2,
4
],
[
3,
0,
5,
0,
0,
0
],
[
4,
0,
6,
0,
1,
0
],
[
0,
0,
0,
0,
0,
2
],
[
0,
0,
4,
0,
5,
0
]
],
"solution": [
[
5,
4,
2,
1,
6,
3
],
[
1,
6,
3,
5,
2,
4
],
[
3,
1,
5,
2,
4,
6
],
[
4,
2,
6,
3,
1,
5
],
[
6,
5,
1,
4,
3,
2
],
[
2,
3,
4,
6,
5,
1
]
]
},
"patches": {
"game": "patches",
"date": "2026-05-12",
"size": 6,
"grid": [
" D B B C C C ",
" D B B B C A ",
" D B G G C A ",
" D D G G A A ",
" E E G G F A ",
" E E E F F F "
],
"regions": [
{
"id": 0,
"size": 5,
"color": "#e05050",
"cells": [
[
1,
5
],
[
2,
5
],
[
3,
4
],
[
3,
5
],
[
4,
5
]
],
"hintCell": [
1,
5
]
},
{
"id": 1,
"size": 6,
"color": "#30b8b0",
"cells": [
[
0,
1
],
[
0,
2
],
[
1,
1
],
[
1,
2
],
[
1,
3
],
[
2,
1
]
],
"hintCell": [
0,
1
]
},
{
"id": 2,
"size": 5,
"color": "#e8b040",
"cells": [
[
0,
3
],
[
0,
4
],
[
0,
5
],
[
1,
4
],
[
2,
4
]
],
"hintCell": [
0,
3
]
},
{
"id": 3,
"size": 5,
"color": "#4db86e",
"cells": [
[
0,
0
],
[
1,
0
],
[
2,
0
],
[
3,
0
],
[
3,
1
]
],
"hintCell": [
0,
0
]
},
{
"id": 4,
"size": 5,
"color": "#9060c8",
"cells": [
[
4,
0
],
[
4,
1
],
[
5,
0
],
[
5,
1
],
[
5,
2
]
],
"hintCell": [
4,
0
]
},
{
"id": 5,
"size": 4,
"color": "#d06898",
"cells": [
[
4,
4
],
[
5,
3
],
[
5,
4
],
[
5,
5
]
],
"hintCell": [
4,
4
]
},
{
"id": 6,
"size": 6,
"color": "#e07830",
"cells": [
[
2,
2
],
[
2,
3
],
[
3,
2
],
[
3,
3
],
[
4,
2
],
[
4,
3
]
],
"hintCell": [
2,
2
]
}
],
"solution": {
"0": 0,
"1": 1,
"2": 2,
"3": 3,
"4": 4,
"5": 5,
"6": 6
}
}
},
"errors": []
}

980
bot-output/2026-05-17.json Normal file
View file

@ -0,0 +1,980 @@
{
"date": "2026-05-17",
"puzzles": {
"queens": {
"game": "queens",
"date": "2026-05-17",
"size": 8,
"grid": [
" D D [A] A A C C C ",
" D D D B [B] B B C ",
" D D D B B E [C] C ",
"[D] D D E E E E C ",
" D D F [E] E E E C ",
" D [F] F F E E E C ",
" F F F F H E H [G]",
" F F F F H [H] H G "
],
"solution": [
[
0,
2
],
[
1,
4
],
[
2,
6
],
[
3,
0
],
[
4,
3
],
[
5,
1
],
[
6,
7
],
[
7,
5
]
],
"regionCount": 8
},
"tango": {
"game": "tango",
"date": "2026-05-17",
"size": 6,
"grid": [
" ☀ ◐ ☀ ☀ [◐] ◐ ",
" ◐ ☀ ◐ ☀ ◐ ☀ ",
"[☀][◐][☀] ◐ ☀ ◐ ",
" ◐ [◐] ☀ ◐ [☀] ☀ ",
"[☀] ☀ ◐ ☀ ◐ ◐ ",
" ◐ [☀][◐] ◐ ☀ ☀ "
],
"solution": [
[
"sun",
"moon",
"sun",
"sun",
"moon",
"moon"
],
[
"moon",
"sun",
"moon",
"sun",
"moon",
"sun"
],
[
"sun",
"moon",
"sun",
"moon",
"sun",
"moon"
],
[
"moon",
"moon",
"sun",
"moon",
"sun",
"sun"
],
[
"sun",
"sun",
"moon",
"sun",
"moon",
"moon"
],
[
"moon",
"sun",
"moon",
"moon",
"sun",
"sun"
]
],
"given": [
[
null,
null,
null,
null,
"moon",
null
],
[
null,
null,
null,
null,
null,
null
],
[
"sun",
"moon",
"sun",
null,
null,
null
],
[
null,
"moon",
null,
null,
"sun",
null
],
[
"sun",
null,
null,
null,
null,
null
],
[
null,
"sun",
"moon",
null,
null,
null
]
],
"hEdges": [
[
"x",
null,
null,
null,
null
],
[
null,
null,
null,
null,
null
],
[
null,
null,
null,
null,
null
],
[
null,
null,
null,
null,
null
],
[
null,
null,
null,
null,
null
],
[
null,
null,
null,
null,
null
]
],
"vEdges": [
[
null,
null,
null,
null,
null,
null
],
[
null,
null,
null,
null,
null,
null
],
[
null,
null,
null,
null,
"=",
null
],
[
null,
null,
null,
null,
null,
null
],
[
null,
null,
null,
"x",
null,
null
]
]
},
"zip": {
"game": "zip",
"date": "2026-05-17",
"size": 8,
"grid": [
" 63 [10] 47 46 [ 1] 2 3 4 ",
" 62 61 48 45 [ 2] 7 6 5 ",
" 59 60 49 44 9 10 11 12 ",
" 58 [ 9][ 8][ 7] 16 [ 3] 14 13 ",
" 55 56 51 42 17 18 19 20 ",
" 54 53 52 41 40 39 38 21 ",
" 31 32 33 34 35 [ 6] 37 [ 4]",
" 30 [ 5] 28 27 26 25 24 23 "
],
"path": [
[
0,
4
],
[
0,
5
],
[
0,
6
],
[
0,
7
],
[
1,
7
],
[
1,
6
],
[
1,
5
],
[
1,
4
],
[
2,
4
],
[
2,
5
],
[
2,
6
],
[
2,
7
],
[
3,
7
],
[
3,
6
],
[
3,
5
],
[
3,
4
],
[
4,
4
],
[
4,
5
],
[
4,
6
],
[
4,
7
],
[
5,
7
],
[
6,
7
],
[
7,
7
],
[
7,
6
],
[
7,
5
],
[
7,
4
],
[
7,
3
],
[
7,
2
],
[
7,
1
],
[
7,
0
],
[
6,
0
],
[
6,
1
],
[
6,
2
],
[
6,
3
],
[
6,
4
],
[
6,
5
],
[
6,
6
],
[
5,
6
],
[
5,
5
],
[
5,
4
],
[
5,
3
],
[
4,
3
],
[
3,
3
],
[
2,
3
],
[
1,
3
],
[
0,
3
],
[
0,
2
],
[
1,
2
],
[
2,
2
],
[
3,
2
],
[
4,
2
],
[
5,
2
],
[
5,
1
],
[
5,
0
],
[
4,
0
],
[
4,
1
],
[
3,
1
],
[
3,
0
],
[
2,
0
],
[
2,
1
],
[
1,
1
],
[
1,
0
],
[
0,
0
],
[
0,
1
]
],
"numberedCells": {
"0,4": 1,
"1,4": 2,
"3,5": 3,
"6,7": 4,
"7,1": 5,
"6,5": 6,
"3,3": 7,
"3,2": 8,
"3,1": 9,
"0,1": 10
},
"walls": [
[
0,
3,
4,
0,
0,
0,
0,
0
],
[
0,
9,
4,
0,
0,
0,
2,
0
],
[
0,
0,
0,
1,
6,
0,
10,
0
],
[
0,
1,
4,
0,
8,
2,
8,
0
],
[
0,
0,
0,
0,
0,
8,
0,
0
],
[
0,
0,
0,
0,
0,
0,
1,
4
],
[
0,
0,
0,
0,
0,
0,
0,
0
],
[
0,
0,
0,
0,
0,
0,
0,
0
]
]
},
"sudoku": {
"game": "sudoku",
"date": "2026-05-17",
"size": 6,
"grid": [
" 6 [2] 4 [3] 1 5 ",
" 3 [5][1] 6 2 4 ",
" 2 [3] 6 5 4 [1]",
"[4] 1 [5] 2 3 [6]",
"[5] 4 2 [1] 6 [3]",
" 1 6 [3] 4 [5] 2 "
],
"given": [
[
0,
2,
0,
3,
0,
0
],
[
0,
5,
1,
0,
0,
0
],
[
0,
3,
0,
0,
0,
1
],
[
4,
0,
5,
0,
0,
6
],
[
5,
0,
0,
1,
0,
3
],
[
0,
0,
3,
0,
5,
0
]
],
"solution": [
[
6,
2,
4,
3,
1,
5
],
[
3,
5,
1,
6,
2,
4
],
[
2,
3,
6,
5,
4,
1
],
[
4,
1,
5,
2,
3,
6
],
[
5,
4,
2,
1,
6,
3
],
[
1,
6,
3,
4,
5,
2
]
]
},
"patches": {
"game": "patches",
"date": "2026-05-17",
"size": 6,
"grid": [
" E E G G A A ",
" E E G G A A ",
" E E G A A A ",
" F C C B B B ",
" F C D D B B ",
" F C C D D D "
],
"regions": [
{
"id": 0,
"size": 7,
"color": "#d06898",
"cells": [
[
0,
4
],
[
0,
5
],
[
1,
4
],
[
1,
5
],
[
2,
3
],
[
2,
4
],
[
2,
5
]
],
"hintCell": [
0,
4
]
},
{
"id": 1,
"size": 5,
"color": "#4a9fd4",
"cells": [
[
3,
3
],
[
3,
4
],
[
3,
5
],
[
4,
4
],
[
4,
5
]
],
"hintCell": [
3,
3
]
},
{
"id": 2,
"size": 5,
"color": "#4db86e",
"cells": [
[
3,
1
],
[
3,
2
],
[
4,
1
],
[
5,
1
],
[
5,
2
]
],
"hintCell": [
3,
1
]
},
{
"id": 3,
"size": 5,
"color": "#e8b040",
"cells": [
[
4,
2
],
[
4,
3
],
[
5,
3
],
[
5,
4
],
[
5,
5
]
],
"hintCell": [
4,
2
]
},
{
"id": 4,
"size": 6,
"color": "#9060c8",
"cells": [
[
0,
0
],
[
0,
1
],
[
1,
0
],
[
1,
1
],
[
2,
0
],
[
2,
1
]
],
"hintCell": [
0,
0
]
},
{
"id": 5,
"size": 3,
"color": "#e05050",
"cells": [
[
3,
0
],
[
4,
0
],
[
5,
0
]
],
"hintCell": [
3,
0
]
},
{
"id": 6,
"size": 5,
"color": "#30b8b0",
"cells": [
[
0,
2
],
[
0,
3
],
[
1,
2
],
[
1,
3
],
[
2,
2
]
],
"hintCell": [
0,
2
]
}
],
"solution": {
"0": 0,
"1": 1,
"2": 2,
"3": 3,
"4": 4,
"5": 5,
"6": 6
}
}
},
"errors": []
}

992
bot-output/2026-05-18.json Normal file
View file

@ -0,0 +1,992 @@
{
"date": "2026-05-18",
"puzzles": {
"queens": {
"game": "queens",
"date": "2026-05-18",
"size": 8,
"grid": [
" B B B B A A A [A]",
" C C C [B] D E E E ",
"[C] C C D D E E E ",
" C C [D] D D E E E ",
" F F F D H [E] G G ",
" F [F] F H H G G H ",
" F F F H H H [G] H ",
" F F H H [H] H H H "
],
"solution": [
[
0,
7
],
[
1,
3
],
[
2,
0
],
[
3,
2
],
[
4,
5
],
[
5,
1
],
[
6,
6
],
[
7,
4
]
],
"regionCount": 8
},
"tango": {
"game": "tango",
"date": "2026-05-18",
"size": 6,
"grid": [
"[◐][☀] ☀ ◐ ◐ ☀ ",
"[☀] ◐ ◐ ☀ [◐] ☀ ",
"[◐] ☀ [☀] ◐ ☀ ◐ ",
"[☀][◐] ◐ ☀ ☀ ◐ ",
" ☀ ◐ ☀ ◐ ◐ ☀ ",
"[◐][☀] ◐ [☀] ☀ ◐ "
],
"solution": [
[
"moon",
"sun",
"sun",
"moon",
"moon",
"sun"
],
[
"sun",
"moon",
"moon",
"sun",
"moon",
"sun"
],
[
"moon",
"sun",
"sun",
"moon",
"sun",
"moon"
],
[
"sun",
"moon",
"moon",
"sun",
"sun",
"moon"
],
[
"sun",
"moon",
"sun",
"moon",
"moon",
"sun"
],
[
"moon",
"sun",
"moon",
"sun",
"sun",
"moon"
]
],
"given": [
[
"moon",
"sun",
null,
null,
null,
null
],
[
"sun",
null,
null,
null,
"moon",
null
],
[
"moon",
null,
"sun",
null,
null,
null
],
[
"sun",
"moon",
null,
null,
null,
null
],
[
null,
null,
null,
null,
null,
null
],
[
"moon",
"sun",
null,
"sun",
null,
null
]
],
"hEdges": [
[
null,
null,
null,
null,
null
],
[
null,
"=",
null,
null,
null
],
[
null,
null,
null,
null,
null
],
[
null,
null,
null,
null,
null
],
[
null,
null,
null,
"=",
null
],
[
null,
null,
null,
null,
null
]
],
"vEdges": [
[
null,
null,
null,
null,
null,
"="
],
[
null,
null,
null,
null,
null,
null
],
[
null,
null,
null,
null,
null,
null
],
[
null,
null,
null,
null,
null,
null
],
[
null,
null,
null,
"x",
null,
null
]
]
},
"zip": {
"game": "zip",
"date": "2026-05-18",
"size": 8,
"grid": [
" 56 55 [ 8] 49 [ 6] 35 26 25 ",
"[ 9] 54 51 48 37 34 27 24 ",
" 58 53 52 47 38 33 28 23 ",
" 59 60 45 46 39 32 [ 5][ 4]",
"[10] 61 44 41 40 31 30 21 ",
" 63 62 [ 7] 42 17 18 19 20 ",
" 12 13 14 [ 3] 16 5 4 3 ",
" 11 10 9 [ 2] 7 6 [ 1] 2 "
],
"path": [
[
7,
6
],
[
7,
7
],
[
6,
7
],
[
6,
6
],
[
6,
5
],
[
7,
5
],
[
7,
4
],
[
7,
3
],
[
7,
2
],
[
7,
1
],
[
7,
0
],
[
6,
0
],
[
6,
1
],
[
6,
2
],
[
6,
3
],
[
6,
4
],
[
5,
4
],
[
5,
5
],
[
5,
6
],
[
5,
7
],
[
4,
7
],
[
3,
7
],
[
2,
7
],
[
1,
7
],
[
0,
7
],
[
0,
6
],
[
1,
6
],
[
2,
6
],
[
3,
6
],
[
4,
6
],
[
4,
5
],
[
3,
5
],
[
2,
5
],
[
1,
5
],
[
0,
5
],
[
0,
4
],
[
1,
4
],
[
2,
4
],
[
3,
4
],
[
4,
4
],
[
4,
3
],
[
5,
3
],
[
5,
2
],
[
4,
2
],
[
3,
2
],
[
3,
3
],
[
2,
3
],
[
1,
3
],
[
0,
3
],
[
0,
2
],
[
1,
2
],
[
2,
2
],
[
2,
1
],
[
1,
1
],
[
0,
1
],
[
0,
0
],
[
1,
0
],
[
2,
0
],
[
3,
0
],
[
3,
1
],
[
4,
1
],
[
5,
1
],
[
5,
0
],
[
4,
0
]
],
"numberedCells": {
"7,6": 1,
"7,3": 2,
"6,3": 3,
"3,7": 4,
"3,6": 5,
"0,4": 6,
"5,2": 7,
"0,2": 8,
"1,0": 9,
"4,0": 10
},
"walls": [
[
0,
1,
4,
0,
0,
0,
0,
0
],
[
0,
0,
0,
0,
0,
1,
4,
0
],
[
0,
0,
0,
0,
0,
0,
0,
0
],
[
2,
0,
0,
0,
0,
0,
0,
0
],
[
9,
4,
0,
0,
0,
2,
0,
0
],
[
0,
2,
0,
0,
0,
8,
0,
0
],
[
0,
8,
0,
0,
0,
0,
0,
0
],
[
0,
0,
0,
0,
0,
0,
0,
0
]
]
},
"sudoku": {
"game": "sudoku",
"date": "2026-05-18",
"size": 6,
"grid": [
"[4] 6 2 1 [5] 3 ",
"[5] 3 [1][2] 6 4 ",
" 3 2 5 [6][4] 1 ",
" 1 4 [6][5] 3 2 ",
" 6 1 3 4 [2] 5 ",
"[2] 5 [4][3][1] 6 "
],
"given": [
[
4,
0,
0,
0,
5,
0
],
[
5,
0,
1,
2,
0,
0
],
[
0,
0,
0,
6,
4,
0
],
[
0,
0,
6,
5,
0,
0
],
[
0,
0,
0,
0,
2,
0
],
[
2,
0,
4,
3,
1,
0
]
],
"solution": [
[
4,
6,
2,
1,
5,
3
],
[
5,
3,
1,
2,
6,
4
],
[
3,
2,
5,
6,
4,
1
],
[
1,
4,
6,
5,
3,
2
],
[
6,
1,
3,
4,
2,
5
],
[
2,
5,
4,
3,
1,
6
]
]
},
"patches": {
"game": "patches",
"date": "2026-05-18",
"size": 6,
"grid": [
" F D D D E E ",
" F F C C E G ",
" F F H H H G ",
" F H H H A A ",
" F B B A A A ",
" B B B B B A "
],
"regions": [
{
"id": 0,
"size": 6,
"color": "#d06898",
"cells": [
[
3,
4
],
[
3,
5
],
[
4,
3
],
[
4,
4
],
[
4,
5
],
[
5,
5
]
],
"hintCell": [
3,
4
]
},
{
"id": 1,
"size": 7,
"color": "#30b8b0",
"cells": [
[
4,
1
],
[
4,
2
],
[
5,
0
],
[
5,
1
],
[
5,
2
],
[
5,
3
],
[
5,
4
]
],
"hintCell": [
4,
1
]
},
{
"id": 2,
"size": 2,
"color": "#e05050",
"cells": [
[
1,
2
],
[
1,
3
]
],
"hintCell": [
1,
2
]
},
{
"id": 3,
"size": 3,
"color": "#e8b040",
"cells": [
[
0,
1
],
[
0,
2
],
[
0,
3
]
],
"hintCell": [
0,
1
]
},
{
"id": 4,
"size": 3,
"color": "#9060c8",
"cells": [
[
0,
4
],
[
0,
5
],
[
1,
4
]
],
"hintCell": [
0,
4
]
},
{
"id": 5,
"size": 7,
"color": "#4db86e",
"cells": [
[
0,
0
],
[
1,
0
],
[
1,
1
],
[
2,
0
],
[
2,
1
],
[
3,
0
],
[
4,
0
]
],
"hintCell": [
0,
0
]
},
{
"id": 6,
"size": 2,
"color": "#4a9fd4",
"cells": [
[
1,
5
],
[
2,
5
]
],
"hintCell": [
1,
5
]
},
{
"id": 7,
"size": 6,
"color": "#e07830",
"cells": [
[
2,
2
],
[
2,
3
],
[
2,
4
],
[
3,
1
],
[
3,
2
],
[
3,
3
]
],
"hintCell": [
2,
2
]
}
],
"solution": {
"0": 0,
"1": 1,
"2": 2,
"3": 3,
"4": 4,
"5": 5,
"6": 6,
"7": 7
}
}
},
"errors": []
}

992
bot-output/2026-05-19.json Normal file
View file

@ -0,0 +1,992 @@
{
"date": "2026-05-19",
"puzzles": {
"queens": {
"game": "queens",
"date": "2026-05-19",
"size": 8,
"grid": [
" D D B B B B B [A]",
" D [B] B B A A A A ",
" D D C [C] C A A A ",
"[D] D C C C A A E ",
" D D D C C E [E] E ",
" D D G G [F] E E H ",
" D D [G] G G H H H ",
" D D D D H [H] H H "
],
"solution": [
[
0,
7
],
[
1,
1
],
[
2,
3
],
[
3,
0
],
[
4,
6
],
[
5,
4
],
[
6,
2
],
[
7,
5
]
],
"regionCount": 8
},
"tango": {
"game": "tango",
"date": "2026-05-19",
"size": 6,
"grid": [
" ☀ ◐ ◐ [☀] ☀ [◐]",
" ◐ ☀ [◐] ◐ ☀ [☀]",
" ◐ ◐ ☀ ☀ ◐ ☀ ",
"[☀] ◐ [☀][☀][◐] ◐ ",
" ☀ ☀ ◐ [◐] ☀ ◐ ",
" ◐ [☀][☀] ◐ ◐ ☀ "
],
"solution": [
[
"sun",
"moon",
"moon",
"sun",
"sun",
"moon"
],
[
"moon",
"sun",
"moon",
"moon",
"sun",
"sun"
],
[
"moon",
"moon",
"sun",
"sun",
"moon",
"sun"
],
[
"sun",
"moon",
"sun",
"sun",
"moon",
"moon"
],
[
"sun",
"sun",
"moon",
"moon",
"sun",
"moon"
],
[
"moon",
"sun",
"sun",
"moon",
"moon",
"sun"
]
],
"given": [
[
null,
null,
null,
"sun",
null,
"moon"
],
[
null,
null,
"moon",
null,
null,
"sun"
],
[
null,
null,
null,
null,
null,
null
],
[
"sun",
null,
"sun",
"sun",
"moon",
null
],
[
null,
null,
null,
"moon",
null,
null
],
[
null,
"sun",
"sun",
null,
null,
null
]
],
"hEdges": [
[
null,
null,
null,
null,
null
],
[
null,
"x",
null,
null,
null
],
[
null,
null,
null,
null,
null
],
[
null,
null,
null,
null,
null
],
[
null,
null,
null,
null,
"x"
],
[
null,
null,
null,
null,
null
]
],
"vEdges": [
[
null,
null,
null,
null,
"=",
null
],
[
null,
null,
null,
null,
null,
null
],
[
null,
null,
null,
null,
null,
null
],
[
null,
null,
null,
null,
null,
null
],
[
null,
null,
null,
null,
null,
null
]
]
},
"zip": {
"game": "zip",
"date": "2026-05-19",
"size": 8,
"grid": [
" 17 16 [ 3] 14 13 12 5 4 ",
" 18 19 20 21 [ 4] 11 6 3 ",
" 27 26 25 24 23 10 7 2 ",
" 28 37 38 39 40 9 [ 2][ 1]",
"[ 5][ 6][ 7] 42 41 56 55 54 ",
" 30 35 44 63 [10][ 9] 58 53 ",
" 31 34 45 62 61 60 59 52 ",
" 32 33 46 47 48 49 [ 8] 51 "
],
"path": [
[
3,
7
],
[
2,
7
],
[
1,
7
],
[
0,
7
],
[
0,
6
],
[
1,
6
],
[
2,
6
],
[
3,
6
],
[
3,
5
],
[
2,
5
],
[
1,
5
],
[
0,
5
],
[
0,
4
],
[
0,
3
],
[
0,
2
],
[
0,
1
],
[
0,
0
],
[
1,
0
],
[
1,
1
],
[
1,
2
],
[
1,
3
],
[
1,
4
],
[
2,
4
],
[
2,
3
],
[
2,
2
],
[
2,
1
],
[
2,
0
],
[
3,
0
],
[
4,
0
],
[
5,
0
],
[
6,
0
],
[
7,
0
],
[
7,
1
],
[
6,
1
],
[
5,
1
],
[
4,
1
],
[
3,
1
],
[
3,
2
],
[
3,
3
],
[
3,
4
],
[
4,
4
],
[
4,
3
],
[
4,
2
],
[
5,
2
],
[
6,
2
],
[
7,
2
],
[
7,
3
],
[
7,
4
],
[
7,
5
],
[
7,
6
],
[
7,
7
],
[
6,
7
],
[
5,
7
],
[
4,
7
],
[
4,
6
],
[
4,
5
],
[
5,
5
],
[
5,
6
],
[
6,
6
],
[
6,
5
],
[
6,
4
],
[
6,
3
],
[
5,
3
],
[
5,
4
]
],
"numberedCells": {
"3,7": 1,
"3,6": 2,
"0,2": 3,
"1,4": 4,
"4,0": 5,
"4,1": 6,
"4,2": 7,
"7,6": 8,
"5,5": 9,
"5,4": 10
},
"walls": [
[
0,
0,
0,
0,
0,
1,
4,
0
],
[
0,
2,
0,
0,
0,
0,
0,
0
],
[
0,
8,
0,
0,
2,
1,
4,
0
],
[
0,
0,
0,
0,
9,
4,
0,
0
],
[
0,
0,
0,
0,
2,
0,
2,
0
],
[
0,
0,
0,
0,
11,
6,
8,
0
],
[
0,
1,
4,
0,
8,
8,
0,
0
],
[
0,
0,
0,
0,
0,
0,
0,
0
]
]
},
"sudoku": {
"game": "sudoku",
"date": "2026-05-19",
"size": 6,
"grid": [
" 4 [1][5][3] 6 2 ",
"[2] 6 3 [4] 5 1 ",
" 6 [2] 4 1 3 5 ",
"[5] 3 1 [6][2][4]",
" 3 4 2 [5][1] 6 ",
"[1] 5 6 2 [4] 3 "
],
"given": [
[
0,
1,
5,
3,
0,
0
],
[
2,
0,
0,
4,
0,
0
],
[
0,
2,
0,
0,
0,
0
],
[
5,
0,
0,
6,
2,
4
],
[
0,
0,
0,
5,
1,
0
],
[
1,
0,
0,
0,
4,
0
]
],
"solution": [
[
4,
1,
5,
3,
6,
2
],
[
2,
6,
3,
4,
5,
1
],
[
6,
2,
4,
1,
3,
5
],
[
5,
3,
1,
6,
2,
4
],
[
3,
4,
2,
5,
1,
6
],
[
1,
5,
6,
2,
4,
3
]
]
},
"patches": {
"game": "patches",
"date": "2026-05-19",
"size": 6,
"grid": [
" A F F F F C ",
" A A E E C C ",
" A B E E E C ",
" B B H H D D ",
" B B H H D D ",
" B B G G G G "
],
"regions": [
{
"id": 0,
"size": 4,
"color": "#e07830",
"cells": [
[
0,
0
],
[
1,
0
],
[
1,
1
],
[
2,
0
]
],
"hintCell": [
0,
0
]
},
{
"id": 1,
"size": 7,
"color": "#4db86e",
"cells": [
[
2,
1
],
[
3,
0
],
[
3,
1
],
[
4,
0
],
[
4,
1
],
[
5,
0
],
[
5,
1
]
],
"hintCell": [
2,
1
]
},
{
"id": 2,
"size": 4,
"color": "#4a9fd4",
"cells": [
[
0,
5
],
[
1,
4
],
[
1,
5
],
[
2,
5
]
],
"hintCell": [
0,
5
]
},
{
"id": 3,
"size": 4,
"color": "#30b8b0",
"cells": [
[
3,
4
],
[
3,
5
],
[
4,
4
],
[
4,
5
]
],
"hintCell": [
3,
4
]
},
{
"id": 4,
"size": 5,
"color": "#e8b040",
"cells": [
[
1,
2
],
[
1,
3
],
[
2,
2
],
[
2,
3
],
[
2,
4
]
],
"hintCell": [
1,
2
]
},
{
"id": 5,
"size": 4,
"color": "#d06898",
"cells": [
[
0,
1
],
[
0,
2
],
[
0,
3
],
[
0,
4
]
],
"hintCell": [
0,
1
]
},
{
"id": 6,
"size": 4,
"color": "#9060c8",
"cells": [
[
5,
2
],
[
5,
3
],
[
5,
4
],
[
5,
5
]
],
"hintCell": [
5,
2
]
},
{
"id": 7,
"size": 4,
"color": "#e05050",
"cells": [
[
3,
2
],
[
3,
3
],
[
4,
2
],
[
4,
3
]
],
"hintCell": [
3,
2
]
}
],
"solution": {
"0": 0,
"1": 1,
"2": 2,
"3": 3,
"4": 4,
"5": 5,
"6": 6,
"7": 7
}
}
},
"errors": []
}

980
bot-output/2026-05-20.json Normal file
View file

@ -0,0 +1,980 @@
{
"date": "2026-05-20",
"puzzles": {
"queens": {
"game": "queens",
"date": "2026-05-20",
"size": 8,
"grid": [
" B B C C [A] A A A ",
"[B] B B C C A A A ",
" B B B [C] C A A A ",
" B B C C C [D] D D ",
" B F F C C D D [E]",
" B [F] F F H G G G ",
" B F H F H G [G] G ",
" B F [H] H H H G G "
],
"solution": [
[
0,
4
],
[
1,
0
],
[
2,
3
],
[
3,
5
],
[
4,
7
],
[
5,
1
],
[
6,
6
],
[
7,
2
]
],
"regionCount": 8
},
"tango": {
"game": "tango",
"date": "2026-05-20",
"size": 6,
"grid": [
"[◐][◐] ☀ ☀ ◐ [☀]",
" ◐ [☀] ◐ ☀ ◐ ☀ ",
"[☀] ◐ ☀ ◐ ☀ ◐ ",
"[☀][◐] ☀ ☀ ◐ [◐]",
" ◐ ☀ [◐] ◐ ☀ ☀ ",
" ☀ [☀] ◐ [◐] ☀ ◐ "
],
"solution": [
[
"moon",
"moon",
"sun",
"sun",
"moon",
"sun"
],
[
"moon",
"sun",
"moon",
"sun",
"moon",
"sun"
],
[
"sun",
"moon",
"sun",
"moon",
"sun",
"moon"
],
[
"sun",
"moon",
"sun",
"sun",
"moon",
"moon"
],
[
"moon",
"sun",
"moon",
"moon",
"sun",
"sun"
],
[
"sun",
"sun",
"moon",
"moon",
"sun",
"moon"
]
],
"given": [
[
"moon",
"moon",
null,
null,
null,
"sun"
],
[
null,
"sun",
null,
null,
null,
null
],
[
"sun",
null,
null,
null,
null,
null
],
[
"sun",
"moon",
null,
null,
null,
"moon"
],
[
null,
null,
"moon",
null,
null,
null
],
[
null,
"sun",
null,
"moon",
null,
null
]
],
"hEdges": [
[
null,
null,
null,
null,
null
],
[
null,
null,
"x",
null,
null
],
[
null,
null,
null,
null,
null
],
[
null,
null,
null,
null,
null
],
[
null,
null,
null,
null,
"="
],
[
null,
null,
null,
null,
null
]
],
"vEdges": [
[
null,
null,
null,
null,
null,
null
],
[
null,
null,
null,
null,
null,
null
],
[
null,
null,
"=",
null,
null,
"="
],
[
null,
null,
null,
null,
null,
null
],
[
null,
null,
null,
null,
null,
null
]
]
},
"zip": {
"game": "zip",
"date": "2026-05-20",
"size": 8,
"grid": [
" 2 [ 1] 18 19 46 47 62 63 ",
" 3 16 17 20 45 48 61 [10]",
" 4 [ 3][ 4] 21 44 49 60 59 ",
" 5 14 23 24 [ 7][ 8][ 9] 58 ",
" 6 13 26 25 42 51 56 55 ",
" 7 12 27 40 41 52 53 54 ",
"[ 2] 11 28 39 38 37 [ 6] 35 ",
" 9 10 [ 5] 30 31 32 33 34 "
],
"path": [
[
0,
1
],
[
0,
0
],
[
1,
0
],
[
2,
0
],
[
3,
0
],
[
4,
0
],
[
5,
0
],
[
6,
0
],
[
7,
0
],
[
7,
1
],
[
6,
1
],
[
5,
1
],
[
4,
1
],
[
3,
1
],
[
2,
1
],
[
1,
1
],
[
1,
2
],
[
0,
2
],
[
0,
3
],
[
1,
3
],
[
2,
3
],
[
2,
2
],
[
3,
2
],
[
3,
3
],
[
4,
3
],
[
4,
2
],
[
5,
2
],
[
6,
2
],
[
7,
2
],
[
7,
3
],
[
7,
4
],
[
7,
5
],
[
7,
6
],
[
7,
7
],
[
6,
7
],
[
6,
6
],
[
6,
5
],
[
6,
4
],
[
6,
3
],
[
5,
3
],
[
5,
4
],
[
4,
4
],
[
3,
4
],
[
2,
4
],
[
1,
4
],
[
0,
4
],
[
0,
5
],
[
1,
5
],
[
2,
5
],
[
3,
5
],
[
4,
5
],
[
5,
5
],
[
5,
6
],
[
5,
7
],
[
4,
7
],
[
4,
6
],
[
3,
6
],
[
3,
7
],
[
2,
7
],
[
2,
6
],
[
1,
6
],
[
0,
6
],
[
0,
7
],
[
1,
7
]
],
"numberedCells": {
"0,1": 1,
"6,0": 2,
"2,1": 3,
"2,2": 4,
"7,2": 5,
"6,6": 6,
"3,4": 7,
"3,5": 8,
"3,6": 9,
"1,7": 10
},
"walls": [
[
0,
0,
0,
0,
0,
0,
0,
0
],
[
0,
0,
0,
0,
0,
0,
1,
6
],
[
0,
0,
0,
0,
1,
4,
0,
8
],
[
0,
0,
0,
0,
0,
0,
0,
0
],
[
0,
0,
0,
1,
4,
1,
4,
0
],
[
0,
0,
0,
0,
1,
6,
0,
0
],
[
0,
0,
1,
4,
0,
8,
2,
0
],
[
0,
0,
0,
0,
0,
0,
8,
0
]
]
},
"sudoku": {
"game": "sudoku",
"date": "2026-05-20",
"size": 6,
"grid": [
"[2][5] 1 6 3 [4]",
"[3][6][4] 1 5 2 ",
"[4] 2 5 [3] 6 1 ",
" 1 3 6 4 2 [5]",
" 5 [4] 3 2 [1][6]",
" 6 1 [2] 5 4 [3]"
],
"given": [
[
2,
5,
0,
0,
0,
4
],
[
3,
6,
4,
0,
0,
0
],
[
4,
0,
0,
3,
0,
0
],
[
0,
0,
0,
0,
0,
5
],
[
0,
4,
0,
0,
1,
6
],
[
0,
0,
2,
0,
0,
3
]
],
"solution": [
[
2,
5,
1,
6,
3,
4
],
[
3,
6,
4,
1,
5,
2
],
[
4,
2,
5,
3,
6,
1
],
[
1,
3,
6,
4,
2,
5
],
[
5,
4,
3,
2,
1,
6
],
[
6,
1,
2,
5,
4,
3
]
]
},
"patches": {
"game": "patches",
"date": "2026-05-20",
"size": 6,
"grid": [
" A B B B D D ",
" A A B B D F ",
" E E B D D F ",
" E E E D D F ",
" C C C G G F ",
" C G G G F F "
],
"regions": [
{
"id": 0,
"size": 3,
"color": "#d06898",
"cells": [
[
0,
0
],
[
1,
0
],
[
1,
1
]
],
"hintCell": [
0,
0
]
},
{
"id": 1,
"size": 6,
"color": "#e8b040",
"cells": [
[
0,
1
],
[
0,
2
],
[
0,
3
],
[
1,
2
],
[
1,
3
],
[
2,
2
]
],
"hintCell": [
0,
1
]
},
{
"id": 2,
"size": 4,
"color": "#e07830",
"cells": [
[
4,
0
],
[
4,
1
],
[
4,
2
],
[
5,
0
]
],
"hintCell": [
4,
0
]
},
{
"id": 3,
"size": 7,
"color": "#4a9fd4",
"cells": [
[
0,
4
],
[
0,
5
],
[
1,
4
],
[
2,
3
],
[
2,
4
],
[
3,
3
],
[
3,
4
]
],
"hintCell": [
0,
4
]
},
{
"id": 4,
"size": 5,
"color": "#4db86e",
"cells": [
[
2,
0
],
[
2,
1
],
[
3,
0
],
[
3,
1
],
[
3,
2
]
],
"hintCell": [
2,
0
]
},
{
"id": 5,
"size": 6,
"color": "#e05050",
"cells": [
[
1,
5
],
[
2,
5
],
[
3,
5
],
[
4,
5
],
[
5,
4
],
[
5,
5
]
],
"hintCell": [
1,
5
]
},
{
"id": 6,
"size": 5,
"color": "#30b8b0",
"cells": [
[
4,
3
],
[
4,
4
],
[
5,
1
],
[
5,
2
],
[
5,
3
]
],
"hintCell": [
4,
3
]
}
],
"solution": {
"0": 0,
"1": 1,
"2": 2,
"3": 3,
"4": 4,
"5": 5,
"6": 6
}
}
},
"errors": []
}

980
bot-output/2026-05-21.json Normal file
View file

@ -0,0 +1,980 @@
{
"date": "2026-05-21",
"puzzles": {
"queens": {
"game": "queens",
"date": "2026-05-21",
"size": 8,
"grid": [
" B B B B B [A] A A ",
"[B] D D C C C C A ",
" D D F F [C] C C A ",
" D [D] F C C C C E ",
" F D F G G C C [E]",
" F F [F] H G G G G ",
" F F H H H H [G] G ",
" F F H [H] H H G G "
],
"solution": [
[
0,
5
],
[
1,
0
],
[
2,
4
],
[
3,
1
],
[
4,
7
],
[
5,
2
],
[
6,
6
],
[
7,
3
]
],
"regionCount": 8
},
"tango": {
"game": "tango",
"date": "2026-05-21",
"size": 6,
"grid": [
"[◐] ☀ ☀ ◐ [☀] ◐ ",
" ☀ [☀] ◐ [◐] ☀ ◐ ",
" ◐ ◐ ☀ [☀] ◐ ☀ ",
" ◐ ◐ [☀] ◐ ☀ ☀ ",
"[☀][☀][◐] ☀ ◐ ◐ ",
" ☀ ◐ ◐ ☀ [◐] ☀ "
],
"solution": [
[
"moon",
"sun",
"sun",
"moon",
"sun",
"moon"
],
[
"sun",
"sun",
"moon",
"moon",
"sun",
"moon"
],
[
"moon",
"moon",
"sun",
"sun",
"moon",
"sun"
],
[
"moon",
"moon",
"sun",
"moon",
"sun",
"sun"
],
[
"sun",
"sun",
"moon",
"sun",
"moon",
"moon"
],
[
"sun",
"moon",
"moon",
"sun",
"moon",
"sun"
]
],
"given": [
[
"moon",
null,
null,
null,
"sun",
null
],
[
null,
"sun",
null,
"moon",
null,
null
],
[
null,
null,
null,
"sun",
null,
null
],
[
null,
null,
"sun",
null,
null,
null
],
[
"sun",
"sun",
"moon",
null,
null,
null
],
[
null,
null,
null,
null,
"moon",
null
]
],
"hEdges": [
[
"x",
null,
"x",
null,
null
],
[
null,
null,
null,
null,
null
],
[
null,
null,
null,
null,
null
],
[
null,
null,
null,
null,
null
],
[
null,
null,
null,
null,
null
],
[
null,
null,
null,
null,
null
]
],
"vEdges": [
[
"x",
null,
null,
null,
null,
null
],
[
null,
null,
null,
null,
null,
null
],
[
null,
null,
null,
null,
"x",
null
],
[
null,
null,
null,
null,
null,
null
],
[
"=",
null,
null,
"=",
null,
null
]
]
},
"zip": {
"game": "zip",
"date": "2026-05-21",
"size": 8,
"grid": [
" 17 16 [ 3] 14 13 12 11 10 ",
" 18 19 20 21 [ 4] 23 24 9 ",
" 63 [10] 47 46 35 34 25 [ 2]",
" 62 61 48 45 [ 6] 33 26 7 ",
" 59 60 49 44 37 32 27 6 ",
" 58 [ 9][ 8][ 7] 38 31 28 5 ",
" 55 56 51 42 39 30 [ 5] 4 ",
" 54 53 52 41 40 [ 1] 2 3 "
],
"path": [
[
7,
5
],
[
7,
6
],
[
7,
7
],
[
6,
7
],
[
5,
7
],
[
4,
7
],
[
3,
7
],
[
2,
7
],
[
1,
7
],
[
0,
7
],
[
0,
6
],
[
0,
5
],
[
0,
4
],
[
0,
3
],
[
0,
2
],
[
0,
1
],
[
0,
0
],
[
1,
0
],
[
1,
1
],
[
1,
2
],
[
1,
3
],
[
1,
4
],
[
1,
5
],
[
1,
6
],
[
2,
6
],
[
3,
6
],
[
4,
6
],
[
5,
6
],
[
6,
6
],
[
6,
5
],
[
5,
5
],
[
4,
5
],
[
3,
5
],
[
2,
5
],
[
2,
4
],
[
3,
4
],
[
4,
4
],
[
5,
4
],
[
6,
4
],
[
7,
4
],
[
7,
3
],
[
6,
3
],
[
5,
3
],
[
4,
3
],
[
3,
3
],
[
2,
3
],
[
2,
2
],
[
3,
2
],
[
4,
2
],
[
5,
2
],
[
6,
2
],
[
7,
2
],
[
7,
1
],
[
7,
0
],
[
6,
0
],
[
6,
1
],
[
5,
1
],
[
5,
0
],
[
4,
0
],
[
4,
1
],
[
3,
1
],
[
3,
0
],
[
2,
0
],
[
2,
1
]
],
"numberedCells": {
"7,5": 1,
"2,7": 2,
"0,2": 3,
"1,4": 4,
"6,6": 5,
"3,4": 6,
"5,3": 7,
"5,2": 8,
"5,1": 9,
"2,1": 10
},
"walls": [
[
0,
0,
2,
0,
0,
0,
0,
0
],
[
0,
0,
8,
0,
0,
2,
0,
0
],
[
0,
2,
0,
0,
0,
9,
4,
0
],
[
2,
8,
1,
4,
0,
0,
0,
0
],
[
8,
0,
1,
4,
0,
0,
0,
0
],
[
0,
0,
1,
4,
0,
0,
0,
0
],
[
0,
3,
4,
1,
4,
0,
0,
0
],
[
0,
8,
0,
0,
0,
0,
0,
0
]
]
},
"sudoku": {
"game": "sudoku",
"date": "2026-05-21",
"size": 6,
"grid": [
" 2 1 4 3 [6][5]",
"[6] 5 3 [1] 4 2 ",
"[4][6] 5 2 3 1 ",
"[1] 3 2 [4][5][6]",
"[5][4] 1 6 [2] 3 ",
" 3 2 6 5 1 [4]"
],
"given": [
[
0,
0,
0,
0,
6,
5
],
[
6,
0,
0,
1,
0,
0
],
[
4,
6,
0,
0,
0,
0
],
[
1,
0,
0,
4,
5,
6
],
[
5,
4,
0,
0,
2,
0
],
[
0,
0,
0,
0,
0,
4
]
],
"solution": [
[
2,
1,
4,
3,
6,
5
],
[
6,
5,
3,
1,
4,
2
],
[
4,
6,
5,
2,
3,
1
],
[
1,
3,
2,
4,
5,
6
],
[
5,
4,
1,
6,
2,
3
],
[
3,
2,
6,
5,
1,
4
]
]
},
"patches": {
"game": "patches",
"date": "2026-05-21",
"size": 6,
"grid": [
" D D D A C C ",
" D D A A C C ",
" G D A C C C ",
" G G A B B B ",
" G G A E E F ",
" G G G E E F "
],
"regions": [
{
"id": 0,
"size": 6,
"color": "#9060c8",
"cells": [
[
0,
3
],
[
1,
2
],
[
1,
3
],
[
2,
2
],
[
3,
2
],
[
4,
2
]
],
"hintCell": [
0,
3
]
},
{
"id": 1,
"size": 3,
"color": "#d06898",
"cells": [
[
3,
3
],
[
3,
4
],
[
3,
5
]
],
"hintCell": [
3,
3
]
},
{
"id": 2,
"size": 7,
"color": "#30b8b0",
"cells": [
[
0,
4
],
[
0,
5
],
[
1,
4
],
[
1,
5
],
[
2,
3
],
[
2,
4
],
[
2,
5
]
],
"hintCell": [
0,
4
]
},
{
"id": 3,
"size": 6,
"color": "#4a9fd4",
"cells": [
[
0,
0
],
[
0,
1
],
[
0,
2
],
[
1,
0
],
[
1,
1
],
[
2,
1
]
],
"hintCell": [
0,
0
]
},
{
"id": 4,
"size": 4,
"color": "#e8b040",
"cells": [
[
4,
3
],
[
4,
4
],
[
5,
3
],
[
5,
4
]
],
"hintCell": [
4,
3
]
},
{
"id": 5,
"size": 2,
"color": "#e07830",
"cells": [
[
4,
5
],
[
5,
5
]
],
"hintCell": [
4,
5
]
},
{
"id": 6,
"size": 8,
"color": "#e05050",
"cells": [
[
2,
0
],
[
3,
0
],
[
3,
1
],
[
4,
0
],
[
4,
1
],
[
5,
0
],
[
5,
1
],
[
5,
2
]
],
"hintCell": [
2,
0
]
}
],
"solution": {
"0": 0,
"1": 1,
"2": 2,
"3": 3,
"4": 4,
"5": 5,
"6": 6
}
}
},
"errors": []
}

980
bot-output/2026-05-22.json Normal file
View file

@ -0,0 +1,980 @@
{
"date": "2026-05-22",
"puzzles": {
"queens": {
"game": "queens",
"date": "2026-05-22",
"size": 8,
"grid": [
"[A] A A A A A A B ",
" A A A C A A [B] B ",
" A A C [C] C B B D ",
" A A C C C [D] D D ",
" A A F C D D D [E]",
" A [F] F G D D D E ",
" F F F G [G] D E E ",
" F F [H] G G G E E "
],
"solution": [
[
0,
0
],
[
1,
6
],
[
2,
3
],
[
3,
5
],
[
4,
7
],
[
5,
1
],
[
6,
4
],
[
7,
2
]
],
"regionCount": 8
},
"tango": {
"game": "tango",
"date": "2026-05-22",
"size": 6,
"grid": [
" ☀ [◐] ◐ ☀ ☀ [◐]",
" ◐ ◐ ☀ ◐ ☀ [☀]",
"[◐] ☀ ◐ [☀] ◐ ☀ ",
" ☀ [☀] ◐ [◐] ☀ ◐ ",
" ☀ ◐ ☀ [☀] ◐ [◐]",
" ◐ ☀ ☀ ◐ ◐ ☀ "
],
"solution": [
[
"sun",
"moon",
"moon",
"sun",
"sun",
"moon"
],
[
"moon",
"moon",
"sun",
"moon",
"sun",
"sun"
],
[
"moon",
"sun",
"moon",
"sun",
"moon",
"sun"
],
[
"sun",
"sun",
"moon",
"moon",
"sun",
"moon"
],
[
"sun",
"moon",
"sun",
"sun",
"moon",
"moon"
],
[
"moon",
"sun",
"sun",
"moon",
"moon",
"sun"
]
],
"given": [
[
null,
"moon",
null,
null,
null,
"moon"
],
[
null,
null,
null,
null,
null,
"sun"
],
[
"moon",
null,
null,
"sun",
null,
null
],
[
null,
"sun",
null,
"moon",
null,
null
],
[
null,
null,
null,
"sun",
null,
"moon"
],
[
null,
null,
null,
null,
null,
null
]
],
"hEdges": [
[
null,
null,
null,
null,
null
],
[
null,
null,
null,
null,
null
],
[
null,
null,
null,
null,
null
],
[
null,
null,
null,
null,
"x"
],
[
null,
"x",
null,
null,
null
],
[
null,
null,
null,
null,
null
]
],
"vEdges": [
[
null,
null,
null,
null,
null,
null
],
[
null,
null,
null,
null,
null,
null
],
[
null,
"=",
null,
null,
null,
null
],
[
null,
null,
null,
null,
null,
null
],
[
null,
null,
"=",
null,
null,
null
]
]
},
"zip": {
"game": "zip",
"date": "2026-05-22",
"size": 8,
"grid": [
" 63 62 3 4 [ 3] 16 35 [ 6]",
"[10] 61 2 5 14 17 34 37 ",
" 59 60 [ 1] 6 13 18 33 38 ",
" 58 [ 9][ 2] 7 12 19 32 39 ",
" 55 56 9 10 11 20 31 40 ",
" 54 53 24 23 [ 4] 21 30 41 ",
" 51 52 25 26 27 28 [ 5] 42 ",
"[ 8] 49 48 47 46 45 44 [ 7]"
],
"path": [
[
2,
2
],
[
1,
2
],
[
0,
2
],
[
0,
3
],
[
1,
3
],
[
2,
3
],
[
3,
3
],
[
3,
2
],
[
4,
2
],
[
4,
3
],
[
4,
4
],
[
3,
4
],
[
2,
4
],
[
1,
4
],
[
0,
4
],
[
0,
5
],
[
1,
5
],
[
2,
5
],
[
3,
5
],
[
4,
5
],
[
5,
5
],
[
5,
4
],
[
5,
3
],
[
5,
2
],
[
6,
2
],
[
6,
3
],
[
6,
4
],
[
6,
5
],
[
6,
6
],
[
5,
6
],
[
4,
6
],
[
3,
6
],
[
2,
6
],
[
1,
6
],
[
0,
6
],
[
0,
7
],
[
1,
7
],
[
2,
7
],
[
3,
7
],
[
4,
7
],
[
5,
7
],
[
6,
7
],
[
7,
7
],
[
7,
6
],
[
7,
5
],
[
7,
4
],
[
7,
3
],
[
7,
2
],
[
7,
1
],
[
7,
0
],
[
6,
0
],
[
6,
1
],
[
5,
1
],
[
5,
0
],
[
4,
0
],
[
4,
1
],
[
3,
1
],
[
3,
0
],
[
2,
0
],
[
2,
1
],
[
1,
1
],
[
0,
1
],
[
0,
0
],
[
1,
0
]
],
"numberedCells": {
"2,2": 1,
"3,2": 2,
"0,4": 3,
"5,4": 4,
"6,6": 5,
"0,7": 6,
"7,7": 7,
"7,0": 8,
"3,1": 9,
"1,0": 10
},
"walls": [
[
0,
1,
4,
0,
0,
1,
4,
0
],
[
3,
4,
0,
1,
4,
0,
0,
0
],
[
8,
0,
0,
0,
1,
5,
4,
0
],
[
0,
0,
0,
0,
0,
0,
0,
0
],
[
0,
0,
0,
0,
0,
0,
0,
0
],
[
0,
0,
0,
0,
2,
0,
0,
0
],
[
0,
3,
4,
0,
8,
0,
0,
0
],
[
0,
8,
0,
0,
0,
0,
0,
0
]
]
},
"sudoku": {
"game": "sudoku",
"date": "2026-05-22",
"size": 6,
"grid": [
" 3 4 6 [1][5][2]",
" 5 2 [1] 3 6 4 ",
"[4] 3 5 2 1 [6]",
" 1 [6] 2 4 3 5 ",
"[2][5] 3 6 [4] 1 ",
"[6] 1 [4] 5 [2][3]"
],
"given": [
[
0,
0,
0,
1,
5,
2
],
[
0,
0,
1,
0,
0,
0
],
[
4,
0,
0,
0,
0,
6
],
[
0,
6,
0,
0,
0,
0
],
[
2,
5,
0,
0,
4,
0
],
[
6,
0,
4,
0,
2,
3
]
],
"solution": [
[
3,
4,
6,
1,
5,
2
],
[
5,
2,
1,
3,
6,
4
],
[
4,
3,
5,
2,
1,
6
],
[
1,
6,
2,
4,
3,
5
],
[
2,
5,
3,
6,
4,
1
],
[
6,
1,
4,
5,
2,
3
]
]
},
"patches": {
"game": "patches",
"date": "2026-05-22",
"size": 6,
"grid": [
" F B B G G D ",
" F B B G D D ",
" F B G G D D ",
" F F A A E E ",
" C F A A E E ",
" C C C C E E "
],
"regions": [
{
"id": 0,
"size": 4,
"color": "#e05050",
"cells": [
[
3,
2
],
[
3,
3
],
[
4,
2
],
[
4,
3
]
],
"hintCell": [
3,
2
]
},
{
"id": 1,
"size": 5,
"color": "#e07830",
"cells": [
[
0,
1
],
[
0,
2
],
[
1,
1
],
[
1,
2
],
[
2,
1
]
],
"hintCell": [
0,
1
]
},
{
"id": 2,
"size": 5,
"color": "#4a9fd4",
"cells": [
[
4,
0
],
[
5,
0
],
[
5,
1
],
[
5,
2
],
[
5,
3
]
],
"hintCell": [
4,
0
]
},
{
"id": 3,
"size": 5,
"color": "#e8b040",
"cells": [
[
0,
5
],
[
1,
4
],
[
1,
5
],
[
2,
4
],
[
2,
5
]
],
"hintCell": [
0,
5
]
},
{
"id": 4,
"size": 6,
"color": "#30b8b0",
"cells": [
[
3,
4
],
[
3,
5
],
[
4,
4
],
[
4,
5
],
[
5,
4
],
[
5,
5
]
],
"hintCell": [
3,
4
]
},
{
"id": 5,
"size": 6,
"color": "#4db86e",
"cells": [
[
0,
0
],
[
1,
0
],
[
2,
0
],
[
3,
0
],
[
3,
1
],
[
4,
1
]
],
"hintCell": [
0,
0
]
},
{
"id": 6,
"size": 5,
"color": "#9060c8",
"cells": [
[
0,
3
],
[
0,
4
],
[
1,
3
],
[
2,
2
],
[
2,
3
]
],
"hintCell": [
0,
3
]
}
],
"solution": {
"0": 0,
"1": 1,
"2": 2,
"3": 3,
"4": 4,
"5": 5,
"6": 6
}
}
},
"errors": []
}

89
components/BottomNav.tsx Normal file
View file

@ -0,0 +1,89 @@
"use client";
import Link from "next/link";
import { usePathname } from "next/navigation";
const TABS = [
{
href: "/",
label: "Aujourd'hui",
icon: (active: boolean) => (
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={active ? 2.5 : 1.8} strokeLinecap="round" strokeLinejoin="round">
<rect x="3" y="4" width="18" height="18" rx="2"/>
<line x1="16" y1="2" x2="16" y2="6"/>
<line x1="8" y1="2" x2="8" y2="6"/>
<line x1="3" y1="10" x2="21" y2="10"/>
<path d="M8 14h.01M12 14h.01M16 14h.01M8 18h.01M12 18h.01" strokeWidth={2.5}/>
</svg>
),
},
{
href: "/levels",
label: "Entraînement",
icon: (active: boolean) => (
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={active ? 2.5 : 1.8} strokeLinecap="round" strokeLinejoin="round">
<path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z"/>
</svg>
),
},
{
href: "/stats",
label: "Stats",
icon: (active: boolean) => (
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={active ? 2.5 : 1.8} strokeLinecap="round" strokeLinejoin="round">
<line x1="18" y1="20" x2="18" y2="10"/>
<line x1="12" y1="20" x2="12" y2="4"/>
<line x1="6" y1="20" x2="6" y2="14"/>
</svg>
),
},
{
href: "/settings",
label: "Réglages",
icon: (active: boolean) => (
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={active ? 2.5 : 1.8} strokeLinecap="round" strokeLinejoin="round">
<circle cx="12" cy="12" r="3"/>
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/>
</svg>
),
},
];
export default function BottomNav() {
const pathname = usePathname();
function isActive(href: string) {
if (href === "/") return pathname === "/";
if (href === "/levels") return pathname.includes("/levels") || pathname.includes("/level/");
return pathname.startsWith(href);
}
return (
<nav
className="fixed bottom-0 left-0 right-0 z-40 bg-white/95 backdrop-blur-xl border-t border-gray-100"
style={{ paddingBottom: "env(safe-area-inset-bottom)" }}
>
<div className="flex items-stretch h-14">
{TABS.map(tab => {
const active = isActive(tab.href);
return (
<Link
key={tab.href}
href={tab.href}
className="flex-1 flex flex-col items-center justify-center gap-0.5 transition-colors"
style={{ color: active ? "#111827" : "#9ca3af" }}
>
{tab.icon(active)}
<span className={`text-[9px] font-semibold tracking-tight leading-none ${active ? "text-gray-900" : "text-gray-400"}`}>
{tab.label}
</span>
{active && (
<span className="absolute bottom-0 w-8 h-0.5 bg-gray-900 rounded-t-full" style={{ marginBottom: "calc(env(safe-area-inset-bottom) + 2px)" }} />
)}
</Link>
);
})}
</div>
</nav>
);
}

76
components/Confetti.tsx Normal file
View file

@ -0,0 +1,76 @@
"use client";
import { useEffect, useState } from "react";
const COLORS = ["#f97316", "#10b981", "#3b82f6", "#f59e0b", "#ec4899", "#8b5cf6", "#14b8a6"];
interface Piece {
id: number;
color: string;
left: number;
delay: number;
duration: number;
size: number;
rotation: number;
}
function makePieces(n: number): Piece[] {
return Array.from({ length: n }, (_, i) => ({
id: i,
color: COLORS[i % COLORS.length],
left: Math.random() * 100,
delay: Math.random() * 1.2,
duration: 2.2 + Math.random() * 1.4,
size: 6 + Math.random() * 8,
rotation: Math.random() * 360,
}));
}
export default function Confetti() {
const [pieces] = useState(() => makePieces(48));
const [visible, setVisible] = useState(true);
useEffect(() => {
const t = setTimeout(() => setVisible(false), 4000);
return () => clearTimeout(t);
}, []);
if (!visible) return null;
return (
<div style={{
position: "fixed", inset: 0, pointerEvents: "none",
zIndex: 9999, overflow: "hidden",
}}>
<style>{`
@keyframes confetti-fall {
0% { transform: translateY(-20px) rotate(var(--rot)); opacity: 1; }
80% { opacity: 1; }
100% { transform: translateY(110vh) rotate(calc(var(--rot) + 720deg)); opacity: 0; }
}
@keyframes confetti-sway {
0%,100% { margin-left: 0; }
50% { margin-left: 30px; }
}
`}</style>
{pieces.map(p => (
<div
key={p.id}
style={{
position: "absolute",
top: 0,
left: `${p.left}%`,
width: p.size,
height: p.size * (Math.random() > 0.5 ? 1 : 0.4),
backgroundColor: p.color,
borderRadius: Math.random() > 0.5 ? "50%" : 2,
// @ts-expect-error css var
"--rot": `${p.rotation}deg`,
animation: `confetti-fall ${p.duration}s ease-in ${p.delay}s both, confetti-sway ${p.duration * 0.6}s ease-in-out ${p.delay}s infinite`,
willChange: "transform",
}}
/>
))}
</div>
);
}

View file

@ -0,0 +1,132 @@
"use client";
import { useEffect, useState } from "react";
import Link from "next/link";
import { loadStats } from "@/lib/stats";
import { GameId, GAME_META } from "@/lib/levels";
import RuleOverlay, { useRuleOverlay } from "@/components/RuleOverlay";
interface Props {
game: GameId;
date: string;
dateLabel: string;
children: React.ReactNode;
}
function fmt(s: number) {
return `${String(Math.floor(s / 60)).padStart(2, "0")}:${String(s % 60).padStart(2, "0")}`;
}
/**
* Wraps a daily puzzle page with:
* - Rule overlay on first visit (auto) + "?" button to re-open
* - Already-solved banner (if player already solved today)
* - Live date header
* - Footer links to Archives / Training
*/
export default function DailyPageShell({ game, date, dateLabel, children }: Props) {
const [solvedToday, setSolvedToday] = useState(false);
const [stats, setStats] = useState<ReturnType<typeof loadStats> | null>(null);
const { accent, name } = GAME_META[game];
// Rule overlay — auto on first visit, manual via "?"
const { visible: ruleVisible, dismiss: ruleDismiss, open: ruleOpen } = useRuleOverlay(game);
const [forceRule, setForceRule] = useState(false);
useEffect(() => {
const s = loadStats(game);
setStats(s);
if (s.lastDate === date) {
setSolvedToday(true);
}
}, [game, date]);
const handleOpenRule = () => {
setForceRule(true);
ruleOpen();
};
const handleCloseRule = () => {
setForceRule(false);
ruleDismiss();
};
return (
<>
<RuleOverlay
game={game}
forceShow={forceRule || ruleVisible}
onClose={handleCloseRule}
/>
<div className="flex flex-col items-center gap-6">
{/* Header */}
<div className="text-center relative w-full">
<h1 className="text-2xl font-bold text-gray-900 tracking-tight">{name}</h1>
<p className="text-sm text-gray-400 mt-1 capitalize">{dateLabel}</p>
{/* "?" rule button */}
<button
onClick={handleOpenRule}
className="absolute right-0 top-0 w-8 h-8 rounded-full bg-gray-100 hover:bg-gray-200 transition-colors flex items-center justify-center text-gray-500 text-sm font-bold"
aria-label="Voir les règles"
title="Voir les règles"
>
?
</button>
</div>
{/* Already-solved banner */}
{solvedToday && stats && (
<div
className="w-full max-w-sm flex items-center gap-3 px-4 py-3 rounded-xl border"
style={{ background: `${accent}10`, borderColor: `${accent}30` }}
>
<div
className="w-8 h-8 rounded-full flex items-center justify-center shrink-0"
style={{ background: `${accent}20` }}
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke={accent} strokeWidth={2.5} strokeLinecap="round" strokeLinejoin="round">
<polyline points="20 6 9 17 4 12"/>
</svg>
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-semibold text-gray-800">Déjà résolu aujourd&apos;hui</p>
{stats.bestTime > 0 && (
<p className="text-xs text-gray-500 timer-mono">Meilleur temps : {fmt(stats.bestTime)}</p>
)}
</div>
<Link
href={`/${game}/levels`}
className="shrink-0 text-xs font-semibold px-3 py-1.5 rounded-full transition-colors"
style={{ background: `${accent}18`, color: accent }}
>
Niveaux
</Link>
</div>
)}
{/* Board */}
{children}
{/* Footer links */}
<div className="flex items-center gap-4 text-sm text-gray-400">
<Link href={`/archive?game=${game}`} className="hover:text-gray-700 transition-colors">
Archives
</Link>
<span className="text-gray-200">·</span>
<Link href={`/${game}/levels`} className="hover:text-gray-700 transition-colors">
Entraînement
</Link>
<span className="text-gray-200">·</span>
<button
onClick={handleOpenRule}
className="hover:text-gray-700 transition-colors"
>
Règles
</button>
</div>
</div>
</>
);
}

View file

@ -0,0 +1,50 @@
"use client";
import { Component, type ReactNode } from "react";
import Link from "next/link";
interface Props {
children: ReactNode;
fallback?: ReactNode;
}
interface State {
hasError: boolean;
error?: Error;
}
export default class ErrorBoundary extends Component<Props, State> {
constructor(props: Props) {
super(props);
this.state = { hasError: false };
}
static getDerivedStateFromError(error: Error): State {
return { hasError: true, error };
}
componentDidCatch(error: Error, info: { componentStack: string }) {
console.error("[ErrorBoundary]", error, info.componentStack);
}
render() {
if (this.state.hasError) {
if (this.props.fallback) return this.props.fallback;
return (
<div className="flex flex-col items-center gap-4 py-20 text-center">
<p className="text-gray-400 text-sm">Une erreur inattendue s&apos;est produite.</p>
<button
onClick={() => this.setState({ hasError: false })}
className="px-4 py-2 rounded-full border border-gray-300 text-sm text-gray-600 hover:bg-gray-50 transition-colors"
>
Réessayer
</button>
<Link href="/" className="text-xs text-gray-400 hover:text-gray-600 transition-colors">
Accueil
</Link>
</div>
);
}
return this.props.children;
}
}

196
components/LevelGrid.tsx Normal file
View file

@ -0,0 +1,196 @@
"use client";
import Link from "next/link";
import { GameId, TOTAL_LEVELS, levelMeta, GAME_META } from "@/lib/levels";
import { GameProgress } from "@/lib/progress";
interface Props {
game: GameId;
progress: GameProgress;
currentLevel?: number;
}
const DIFF_COLORS: Record<number, { bg: string; border: string; label: string; color: string }> = {
1: { bg: "#f0fdf4", border: "#86efac", label: "Facile", color: "#16a34a" },
2: { bg: "#fefce8", border: "#fde047", label: "Normal", color: "#ca8a04" },
3: { bg: "#fff7ed", border: "#fdba74", label: "Intermédiaire", color: "#ea580c" },
4: { bg: "#fef2f2", border: "#fca5a5", label: "Difficile", color: "#dc2626" },
5: { bg: "#faf5ff", border: "#d8b4fe", label: "Expert", color: "#9333ea" },
};
function fmt(s: number): string {
return `${String(Math.floor(s / 60)).padStart(2, "0")}:${String(s % 60).padStart(2, "0")}`;
}
function LevelCell({
game,
level,
done,
isCurrent,
accent,
diffInfo,
bestTime,
}: {
game: GameId;
level: number;
done: boolean;
isCurrent: boolean;
accent: string;
diffInfo: typeof DIFF_COLORS[1];
bestTime: number;
}) {
return (
<Link
href={`/${game}/level/${level}`}
title={`Niveau ${level}${diffInfo.label}${done ? ` · ${fmt(bestTime)}` : ""}`}
style={{
aspectRatio: "1",
borderRadius: 6,
display: "flex",
alignItems: "center",
justifyContent: "center",
fontSize: 11,
fontWeight: 600,
textDecoration: "none",
position: "relative",
...(done
? {
background: accent,
color: "#fff",
border: `1.5px solid ${accent}`,
}
: isCurrent
? {
background: diffInfo.bg,
color: accent,
border: `2px solid ${accent}`,
boxShadow: `0 0 0 3px ${accent}22`,
}
: {
background: diffInfo.bg,
color: "#9ca3af",
border: `1.5px solid ${diffInfo.border}`,
}),
}}
className={`hover:scale-105 hover:shadow-md transition-transform ${isCurrent ? "level-current" : ""}`}
>
{done ? (
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={3} strokeLinecap="round" strokeLinejoin="round">
<polyline points="20 6 9 17 4 12"/>
</svg>
) : (
level
)}
</Link>
);
}
export default function LevelGrid({ game, progress, currentLevel }: Props) {
const accent = GAME_META[game].accent;
const levels = Array.from({ length: TOTAL_LEVELS }, (_, i) => i + 1);
// Group levels by difficulty
const groups: { diff: number; levels: number[] }[] = [];
let currentDiff = -1;
for (const level of levels) {
const d = levelMeta(game, level).difficulty;
if (d !== currentDiff) {
groups.push({ diff: d, levels: [level] });
currentDiff = d;
} else {
groups[groups.length - 1].levels.push(level);
}
}
// Completion stats
const completedCount = Object.keys(progress).length;
return (
<div className="w-full max-w-lg">
{/* Difficulty legend */}
<div className="flex gap-3 flex-wrap mb-5">
{[1, 2, 3, 4, 5].map(d => {
const { bg, border, label, color } = DIFF_COLORS[d];
return (
<div key={d} className="flex items-center gap-1.5 text-xs" style={{ color }}>
<span style={{
width: 12,
height: 12,
borderRadius: 3,
display: "inline-block",
background: bg,
border: `1.5px solid ${border}`,
}} />
{label}
</div>
);
})}
</div>
{/* Difficulty groups */}
<div className="flex flex-col gap-5">
{groups.map(({ diff, levels: groupLevels }) => {
const diffInfo = DIFF_COLORS[diff];
const groupDone = groupLevels.filter(l => !!progress[l]).length;
return (
<div key={diff}>
{/* Group header */}
<div className="flex items-center gap-2 mb-2">
<span
className="text-xs font-semibold uppercase tracking-wide"
style={{ color: diffInfo.color }}
>
{diffInfo.label}
</span>
<span className="text-[10px] text-gray-300 font-medium tabular-nums">
{groupDone}/{groupLevels.length}
</span>
<div className="flex-1 h-px bg-gray-100" />
</div>
{/* Grid for this difficulty group */}
<div
style={{
display: "grid",
gridTemplateColumns: "repeat(10, 1fr)",
gap: 5,
}}
>
{groupLevels.map(level => {
const record = progress[level];
const done = !!record;
const isCurrent = level === currentLevel;
const meta = levelMeta(game, level);
return (
<LevelCell
key={level}
game={game}
level={level}
done={done}
isCurrent={isCurrent}
accent={accent}
diffInfo={DIFF_COLORS[meta.difficulty]}
bestTime={record?.bestTime ?? 0}
/>
);
})}
</div>
</div>
);
})}
</div>
{/* Footer stats */}
<p className="text-xs text-gray-400 mt-5 text-center tabular-nums">
{completedCount} / {TOTAL_LEVELS} niveaux complétés
{completedCount > 0 && (
<span className="ml-2 text-gray-300">
({Math.round((completedCount / TOTAL_LEVELS) * 100)}%)
</span>
)}
</p>
</div>
);
}

View file

@ -0,0 +1,101 @@
"use client";
import { useEffect, useState } from "react";
import Link from "next/link";
import LevelGrid from "@/components/LevelGrid";
import { GameProgress, getGameProgress, nextLevel, allStats } from "@/lib/progress";
import { GameId, GAME_META, TOTAL_LEVELS } from "@/lib/levels";
interface Props {
game: GameId;
}
function fmt(s: number) {
return `${String(Math.floor(s / 60)).padStart(2, "0")}:${String(s % 60).padStart(2, "0")}`;
}
export default function LevelsPageShell({ game }: Props) {
const [progress, setProgress] = useState<GameProgress>({});
const [next, setNext] = useState(1);
const { name, accent } = GAME_META[game];
useEffect(() => {
const refresh = () => {
setProgress(getGameProgress(game));
setNext(nextLevel(game));
};
refresh();
window.addEventListener("focus", refresh);
document.addEventListener("visibilitychange", refresh);
return () => {
window.removeEventListener("focus", refresh);
document.removeEventListener("visibilitychange", refresh);
};
}, [game]);
const stats = allStats();
const gameStats = stats[game];
const completedCount = Object.keys(progress).length;
const pct = Math.round((completedCount / TOTAL_LEVELS) * 100);
return (
<div className="flex flex-col items-center gap-8 max-w-lg mx-auto">
{/* Header */}
<div className="w-full flex items-start justify-between">
<div>
<div className="flex items-center gap-2 mb-1">
<Link href={`/${game}`} className="text-sm text-gray-400 hover:text-gray-600 transition-colors flex items-center gap-1">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round"><polyline points="15 18 9 12 15 6"/></svg>
{name}
</Link>
</div>
<h1 className="text-2xl font-bold text-gray-900 tracking-tight">{name} Niveaux</h1>
<p className="text-sm text-gray-400 mt-1">100 puzzles · difficulté progressive</p>
</div>
{/* Completion ring */}
<div className="flex flex-col items-end gap-0.5 shrink-0 mt-1">
<span className="text-2xl font-black tabular-nums" style={{ color: accent }}>
{completedCount}
</span>
<span className="text-xs text-gray-400">/ {TOTAL_LEVELS}</span>
{pct > 0 && (
<span className="text-[10px] font-semibold text-gray-300">{pct}%</span>
)}
</div>
</div>
{/* Stats strip */}
{gameStats && gameStats.bestTime > 0 && (
<div className="w-full flex gap-3">
<div className="flex-1 flex flex-col items-center gap-0.5 px-3 py-2.5 bg-white rounded-xl border border-gray-100">
<span className="text-xs text-gray-400">Meilleur temps</span>
<span className="text-base font-bold text-gray-800 timer-mono">{fmt(gameStats.bestTime)}</span>
</div>
<div className="flex-1 flex flex-col items-center gap-0.5 px-3 py-2.5 bg-white rounded-xl border border-gray-100">
<span className="text-xs text-gray-400">Prochain</span>
<span className="text-base font-bold text-gray-800">Niv. {next}</span>
</div>
<div className="flex-1 flex flex-col items-center gap-0.5 px-3 py-2.5 bg-white rounded-xl border border-gray-100">
<span className="text-xs text-gray-400">Complétés</span>
<span className="text-base font-bold text-gray-800">{completedCount}</span>
</div>
</div>
)}
{/* CTA */}
<Link
href={`/${game}/level/${next}`}
className="w-full flex items-center justify-center gap-2 py-3 rounded-2xl text-white font-semibold text-base transition-opacity hover:opacity-90 shadow-sm"
style={{ background: accent }}
>
{completedCount === 0 ? "Commencer" : "Continuer"} Niveau {next}
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2.5} strokeLinecap="round"><polyline points="9 18 15 12 9 6"/></svg>
</Link>
{/* Grid */}
<LevelGrid game={game} progress={progress} currentLevel={next} />
</div>
);
}

25
components/NavLink.tsx Normal file
View file

@ -0,0 +1,25 @@
"use client";
import Link from "next/link";
import { usePathname } from "next/navigation";
export function NavLink({ href, label, symbol }: { href: string; label: string; symbol?: string }) {
const pathname = usePathname();
const active = pathname === href || (href !== "/" && pathname.startsWith(href));
return (
<Link
href={href}
className={`px-2.5 py-1.5 text-sm rounded-lg transition-colors flex items-center gap-1.5 whitespace-nowrap ${
active
? "bg-gray-900 text-white font-semibold"
: "text-gray-500 hover:text-gray-900 hover:bg-gray-100"
}`}
>
{symbol && (
<span className="text-[13px] leading-none" aria-hidden>
{symbol}
</span>
)}
<span>{label}</span>
</Link>
);
}

357
components/PatchesBoard.tsx Normal file
View file

@ -0,0 +1,357 @@
"use client";
import { useState, useEffect, useMemo, useCallback } from "react";
import { PatchesPuzzle, Region } from "@/lib/generators/patches";
import { createRng, shuffle } from "@/lib/rng";
import WinBanner from "./WinBanner";
interface Props {
puzzle: PatchesPuzzle;
date: string;
onSolve?: (elapsed: number) => void;
}
const STORAGE_KEY = (d: string) => `patches-${d}`;
/** Canonical key for shape comparison: sorted "r,c" list. */
function shapeKey(cells: [number, number][]): string {
return cells
.map(([r, c]) => `${r},${c}`)
.sort()
.join("|");
}
/** Mini polyomino preview rendered as a small grid. */
function ShapePreview({
relCells, previewRows, previewCols, color, cellPx, faded,
}: {
relCells: [number, number][];
previewRows: number;
previewCols: number;
color: string;
cellPx: number;
faded?: boolean;
}) {
const filled = new Set(relCells.map(([r, c]) => `${r},${c}`));
return (
<div
style={{
display: "inline-grid",
gridTemplateColumns: `repeat(${previewCols}, ${cellPx}px)`,
gridTemplateRows: `repeat(${previewRows}, ${cellPx}px)`,
gap: 1.5,
opacity: faded ? 0.35 : 1,
}}
>
{Array.from({ length: previewRows }, (_, r) =>
Array.from({ length: previewCols }, (_, c) => (
<div
key={`${r}-${c}`}
style={{
width: cellPx,
height: cellPx,
backgroundColor: filled.has(`${r},${c}`) ? color : "transparent",
borderRadius: 2,
}}
/>
))
)}
</div>
);
}
export default function PatchesBoard({ puzzle, date, onSolve }: Props) {
const { size, regions, grid } = puzzle;
/* ── Responsive cell size ───────────────────────────────────────────────── */
const [CELL, setCELL] = useState(() =>
typeof window === "undefined" ? 56 : Math.min(58, Math.floor((window.innerWidth - 40) / size))
);
useEffect(() => {
const update = () =>
setCELL(Math.min(58, Math.floor((window.innerWidth - 40) / size)));
window.addEventListener("resize", update);
return () => window.removeEventListener("resize", update);
}, [size]);
/* ── Lookup maps ────────────────────────────────────────────────────────── */
const regionById = useMemo(() => new Map<number, Region>(regions.map(r => [r.id, r])), [regions]);
// Canonical shape key per region
const shapeKeys = useMemo(() => {
const m = new Map<number, string>();
for (const reg of regions) m.set(reg.id, shapeKey(reg.relCells));
return m;
}, [regions]);
// Piece tray order — shuffled deterministically per date
const trayPieces = useMemo(() => {
const rng = createRng(date.split("-").reduce((a, n) => a * 1000 + parseInt(n), 0) + 7777);
return shuffle([...regions], rng);
}, [regions, date]);
/* ── State ──────────────────────────────────────────────────────────────── */
// placement: Map<slotRegionId, pieceRegionId>
const [placement, setPlacement] = useState<Map<number, number>>(() => {
if (typeof window !== "undefined") {
try {
const s = localStorage.getItem(STORAGE_KEY(date));
if (s) return new Map(JSON.parse(s) as [number, number][]);
} catch { /* */ }
}
return new Map();
});
const [selectedId, setSelectedId] = useState<number | null>(null);
const [errorSlot, setErrorSlot] = useState<number | null>(null); // regionId to flash red
const [won, setWon] = useState(false);
const [elapsed, setElapsed] = useState(0);
const [t0] = useState(() => Date.now());
/* ── Timer ──────────────────────────────────────────────────────────────── */
useEffect(() => {
if (won) return;
const id = setInterval(() => setElapsed(Math.floor((Date.now() - t0) / 1000)), 500);
return () => clearInterval(id);
}, [won, t0]);
/* ── Persist & win detection ────────────────────────────────────────────── */
useEffect(() => {
localStorage.setItem(STORAGE_KEY(date), JSON.stringify([...placement]));
if (!won && placement.size === regions.length) {
setWon(true);
onSolve?.(Math.floor((Date.now() - t0) / 1000));
}
}, [placement, date, won, regions.length, onSolve, t0]);
/* ── Helpers ────────────────────────────────────────────────────────────── */
const fmt = (s: number) =>
`${String(Math.floor(s / 60)).padStart(2, "0")}:${String(s % 60).padStart(2, "0")}`;
const placedPieceIds = useMemo(() => new Set(placement.values()), [placement]);
/* ── Interaction ────────────────────────────────────────────────────────── */
const handleGridTap = useCallback((slotId: number) => {
if (won) return;
if (selectedId === null) {
// No piece selected — remove piece from this slot (return to tray)
if (placement.has(slotId)) {
setPlacement(prev => {
const m = new Map(prev);
m.delete(slotId);
return m;
});
}
return;
}
// Piece is selected
if (placement.get(slotId) === selectedId) {
// Tap the slot that already has this piece → deselect
setSelectedId(null);
return;
}
// Shape validation
const slotShape = shapeKeys.get(slotId)!;
const pieceShape = shapeKeys.get(selectedId)!;
if (slotShape !== pieceShape) {
// Wrong shape → error flash
setErrorSlot(slotId);
setTimeout(() => setErrorSlot(null), 500);
return;
}
// Valid — place piece (move from any previous slot, replace any existing piece in target)
setPlacement(prev => {
const m = new Map(prev);
// Remove selected piece from wherever it was
for (const [slot, piece] of m) {
if (piece === selectedId) { m.delete(slot); break; }
}
m.set(slotId, selectedId);
return m;
});
setSelectedId(null);
}, [won, selectedId, placement, shapeKeys]);
const handleTrayTap = useCallback((pieceId: number) => {
if (won) return;
setSelectedId(prev => prev === pieceId ? null : pieceId);
}, [won]);
const reset = () => {
setPlacement(new Map());
setSelectedId(null);
setWon(false);
};
const undoLast = () => {
if (placement.size === 0) return;
const entries = [...placement];
setPlacement(new Map(entries.slice(0, -1)));
};
/* ── Cell border style ──────────────────────────────────────────────────── */
function getCellStyle(r: number, c: number, slotId: number) {
const THICK = "2.5px solid #1f2937";
const THIN = "1px solid #e5e7eb";
const top = r === 0 || grid[r - 1][c] !== slotId ? THICK : THIN;
const bottom = r === size - 1 || grid[r + 1][c] !== slotId ? THICK : THIN;
const left = c === 0 || grid[r][c - 1] !== slotId ? THICK : THIN;
const right = c === size - 1 || grid[r][c + 1] !== slotId ? THICK : THIN;
return { borderTop: top, borderBottom: bottom, borderLeft: left, borderRight: right };
}
/* ── Render ─────────────────────────────────────────────────────────────── */
return (
<div className="flex flex-col items-center gap-5">
{/* Header row */}
<div className="flex items-center gap-8 text-sm text-gray-500 font-medium">
<span>{placement.size} / {regions.length} pièces</span>
<span className="tabular-nums">{fmt(elapsed)}</span>
</div>
{won && <WinBanner game="patches" date={date} elapsed={elapsed} />}
{/* Grid */}
<div
style={{
display: "grid",
gridTemplateColumns: `repeat(${size}, ${CELL}px)`,
gridTemplateRows: `repeat(${size}, ${CELL}px)`,
userSelect: "none",
}}
>
{Array.from({ length: size }, (_, r) =>
Array.from({ length: size }, (_, c) => {
const slotId = grid[r][c];
const placedPieceId = placement.get(slotId);
const placedRegion = placedPieceId !== undefined
? regionById.get(placedPieceId)
: undefined;
const isError = errorSlot === slotId;
// Highlight: when a piece is selected, subtly mark slots with matching shape
const selectedShape = selectedId !== null ? shapeKeys.get(selectedId) : null;
const isMatchingSlot = selectedShape !== null
&& shapeKeys.get(slotId) === selectedShape
&& !placement.has(slotId);
let bg = "#f9fafb";
if (placedRegion) bg = placedRegion.color;
else if (isError) bg = "#fee2e2";
else if (isMatchingSlot) bg = "#eff6ff";
const borders = getCellStyle(r, c, slotId);
return (
<div
key={`${r}-${c}`}
onClick={() => handleGridTap(slotId)}
style={{
width: CELL,
height: CELL,
boxSizing: "border-box",
backgroundColor: bg,
...borders,
cursor: won ? "default" : "pointer",
transition: "background-color 0.12s",
animation: isError ? "shake 0.35s ease" : undefined,
}}
/>
);
})
)}
</div>
{/* Selected piece label */}
{selectedId !== null && !won ? (
<p className="text-sm font-medium" style={{ color: regionById.get(selectedId)?.color }}>
Pièce sélectionnée touche la zone correspondante
</p>
) : !won ? (
<p className="text-xs text-gray-400">
Sélectionne une pièce, puis place-la dans la zone qui lui correspond
</p>
) : null}
{/* Piece tray */}
<div className="w-full overflow-x-auto pb-1">
<div
className="flex gap-3 px-2 py-1"
style={{ width: "max-content", margin: "0 auto" }}
>
{trayPieces.map(piece => {
const isPlaced = placedPieceIds.has(piece.id);
const isSelected = selectedId === piece.id;
const maxDim = Math.max(piece.previewRows, piece.previewCols);
const cellPx = Math.min(14, Math.floor(52 / maxDim));
return (
<button
key={piece.id}
onClick={() => !isPlaced && handleTrayTap(piece.id)}
aria-pressed={isSelected}
style={{
padding: "10px",
borderRadius: 14,
border: isSelected
? `2.5px solid ${piece.color}`
: "2px solid #e5e7eb",
background: isSelected
? `${piece.color}18`
: isPlaced ? "#f5f5f5" : "white",
opacity: isPlaced ? 0.3 : 1,
transform: isSelected ? "scale(1.10) translateY(-4px)" : "scale(1)",
transition: "transform 0.15s, box-shadow 0.15s, opacity 0.2s, border-color 0.15s",
boxShadow: isSelected ? `0 4px 14px ${piece.color}50` : "none",
cursor: isPlaced ? "default" : "pointer",
pointerEvents: isPlaced ? "none" : "auto",
flexShrink: 0,
minWidth: 48,
minHeight: 48,
display: "flex",
alignItems: "center",
justifyContent: "center",
}}
>
<ShapePreview
relCells={piece.relCells}
previewRows={piece.previewRows}
previewCols={piece.previewCols}
color={piece.color}
cellPx={cellPx}
faded={isPlaced}
/>
</button>
);
})}
</div>
</div>
{/* Controls */}
{!won && (
<div className="flex gap-3">
<button
onClick={undoLast}
disabled={placement.size === 0}
className="px-4 py-2 rounded-xl border border-gray-200 text-gray-500 hover:bg-gray-50 text-sm transition-colors disabled:opacity-30"
>
Annuler
</button>
<button
onClick={reset}
className="px-4 py-2 rounded-xl border border-gray-200 text-gray-500 hover:bg-gray-50 text-sm transition-colors"
>
Recommencer
</button>
</div>
)}
</div>
);
}

555
components/QueensBoard.tsx Normal file
View file

@ -0,0 +1,555 @@
"use client";
import { useState, useEffect, useCallback, useRef } from "react";
import { QueensPuzzle } from "@/lib/generators/queens";
import WinBanner from "./WinBanner";
interface Props {
puzzle: QueensPuzzle;
date: string;
onSolve?: (elapsed: number) => void;
}
const REGION_COLORS = [
{ bg: "#c8b8e8", border: "#9870c8" },
{ bg: "#f5c898", border: "#d4986a" },
{ bg: "#a8c8f0", border: "#6098d8" },
{ bg: "#b8d8b0", border: "#78b870" },
{ bg: "#f5a898", border: "#d86858" },
{ bg: "#d8d8d8", border: "#a8a8a8" },
{ bg: "#d8e888", border: "#b0c840" },
{ bg: "#c8b8a8", border: "#a89878" },
{ bg: "#f0b8c8", border: "#d880a0" },
{ bg: "#a8e0d8", border: "#60b8b0" },
];
type CellState = "empty" | "queen" | "mark";
interface HintInfo {
explanation: string;
// cells highlighted in blue (the "why" context)
focusCells: Set<string>;
// cells highlighted in green (queen to place) or red (cells to eliminate)
actionCells: Set<string>;
action:
| { kind: "queen"; r: number; c: number }
| { kind: "marks"; cells: [number, number][] };
}
function CrownIcon({ size }: { size: number }) {
return (
<svg width={size} height={size} viewBox="0 0 24 24" fill="currentColor">
<path d="M3 18h18v2H3v-2zm1.5-12L8 10.5 12 4l4 6.5 3.5-4.5L22 16H2L5.5 6z" />
</svg>
);
}
function XIcon({ size }: { size: number }) {
return (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2.5} strokeLinecap="round">
<line x1="6" y1="6" x2="18" y2="18" />
<line x1="18" y1="6" x2="6" y2="18" />
</svg>
);
}
function checkWin(board: CellState[][], puzzle: QueensPuzzle): boolean {
const { size, regions } = puzzle;
const queens: [number, number][] = [];
for (let r = 0; r < size; r++)
for (let c = 0; c < size; c++)
if (board[r][c] === "queen") queens.push([r, c]);
if (queens.length !== size) return false;
const rows = new Set<number>(), cols = new Set<number>(), regs = new Set<number>();
for (const [r, c] of queens) {
if (rows.has(r) || cols.has(c) || regs.has(regions[r][c])) return false;
rows.add(r); cols.add(c); regs.add(regions[r][c]);
}
for (let i = 0; i < queens.length; i++)
for (let j = i + 1; j < queens.length; j++)
if (Math.abs(queens[i][0] - queens[j][0]) <= 1 && Math.abs(queens[i][1] - queens[j][1]) <= 1) return false;
return true;
}
function getErrors(board: CellState[][], puzzle: QueensPuzzle): Set<string> {
const { size, regions } = puzzle;
const errors = new Set<string>();
const queens: [number, number][] = [];
for (let r = 0; r < size; r++)
for (let c = 0; c < size; c++)
if (board[r][c] === "queen") queens.push([r, c]);
const byRow = new Map<number, number[]>(), byCol = new Map<number, number[]>(), byReg = new Map<number, number[]>();
for (const [r, c] of queens) {
const reg = regions[r][c];
byRow.set(r, [...(byRow.get(r) || []), c]);
byCol.set(c, [...(byCol.get(c) || []), r]);
byReg.set(reg, [...(byReg.get(reg) || []), r * size + c]);
}
for (const [r, cs] of byRow) if (cs.length > 1) cs.forEach(c => errors.add(`${r},${c}`));
for (const [c, rs] of byCol) if (rs.length > 1) rs.forEach(r => errors.add(`${r},${c}`));
for (const [, cells] of byReg) if (cells.length > 1) cells.forEach(idx => errors.add(`${Math.floor(idx / size)},${idx % size}`));
for (let i = 0; i < queens.length; i++)
for (let j = i + 1; j < queens.length; j++)
if (Math.abs(queens[i][0] - queens[j][0]) <= 1 && Math.abs(queens[i][1] - queens[j][1]) <= 1) {
errors.add(`${queens[i][0]},${queens[i][1]}`);
errors.add(`${queens[j][0]},${queens[j][1]}`);
}
return errors;
}
// Logical hint finder: detects which rule applies and explains the reasoning
function findLogicalHint(board: CellState[][], puzzle: QueensPuzzle): HintInfo | null {
const { size, regions } = puzzle;
const queens: [number, number][] = [];
const queenRows = new Set<number>();
const queenCols = new Set<number>();
const queenRegs = new Set<number>();
for (let r = 0; r < size; r++)
for (let c = 0; c < size; c++)
if (board[r][c] === "queen") {
queens.push([r, c]);
queenRows.add(r);
queenCols.add(c);
queenRegs.add(regions[r][c]);
}
// A cell is "possible" if no placed queen or user mark excludes it
function isPossible(r: number, c: number): boolean {
if (board[r][c] === "queen" || board[r][c] === "mark") return false;
if (queenRows.has(r) || queenCols.has(c) || queenRegs.has(regions[r][c])) return false;
for (const [qr, qc] of queens)
if (Math.abs(r - qr) <= 1 && Math.abs(c - qc) <= 1) return false;
return true;
}
const possSet = new Set<string>();
for (let r = 0; r < size; r++)
for (let c = 0; c < size; c++)
if (isPossible(r, c)) possSet.add(`${r},${c}`);
// Group all cells by region
const byRegion = new Map<number, [number, number][]>();
for (let r = 0; r < size; r++)
for (let c = 0; c < size; c++) {
const reg = regions[r][c];
if (!byRegion.has(reg)) byRegion.set(reg, []);
byRegion.get(reg)!.push([r, c]);
}
// Rule 1: A region has exactly one possible cell → forced queen
for (const [reg, cells] of byRegion) {
if (queenRegs.has(reg)) continue;
const poss = cells.filter(([r, c]) => possSet.has(`${r},${c}`));
if (poss.length === 1) {
const [r, c] = poss[0];
return {
explanation: "Cette zone colorée (en bleu) n'a plus qu'une seule case disponible (en vert). Toutes les autres ont été éliminées par les couronnes déjà posées. La couronne de cette zone doit obligatoirement s'y trouver.",
focusCells: new Set(cells.map(([r, c]) => `${r},${c}`)),
actionCells: new Set([`${r},${c}`]),
action: { kind: "queen", r, c },
};
}
}
// Rule 2: A row has exactly one possible cell → forced queen
for (let r = 0; r < size; r++) {
if (queenRows.has(r)) continue;
const poss: [number, number][] = [];
for (let c = 0; c < size; c++) if (possSet.has(`${r},${c}`)) poss.push([r, c]);
if (poss.length === 1) {
const [pr, pc] = poss[0];
return {
explanation: `La ligne ${r + 1} (en bleu) n'a plus qu'une seule case disponible (en vert). La couronne qui doit occuper cette ligne n'a pas d'autre choix.`,
focusCells: new Set(Array.from({ length: size }, (_, c) => `${r},${c}`)),
actionCells: new Set([`${pr},${pc}`]),
action: { kind: "queen", r: pr, c: pc },
};
}
}
// Rule 3: A column has exactly one possible cell → forced queen
for (let c = 0; c < size; c++) {
if (queenCols.has(c)) continue;
const poss: [number, number][] = [];
for (let r = 0; r < size; r++) if (possSet.has(`${r},${c}`)) poss.push([r, c]);
if (poss.length === 1) {
const [pr, pc] = poss[0];
return {
explanation: `La colonne ${c + 1} (en bleu) n'a plus qu'une seule case disponible (en vert). La couronne qui doit occuper cette colonne n'a pas d'autre choix.`,
focusCells: new Set(Array.from({ length: size }, (_, r) => `${r},${c}`)),
actionCells: new Set([`${pr},${pc}`]),
action: { kind: "queen", r: pr, c: pc },
};
}
}
// Rules 4+: Generalized naked subset — N regions confined to same N rows or cols
// N=1: locked region, N=2: naked pair, N=3: naked triple, N=4: naked quad
function* genCombinations<T>(arr: T[], n: number, start = 0): Generator<T[]> {
if (n === 0) { yield []; return; }
for (let i = start; i <= arr.length - n; i++)
for (const rest of genCombinations(arr, n - 1, i + 1))
yield [arr[i], ...rest];
}
const unsolved = [...byRegion.entries()].filter(([reg, cells]) =>
!queenRegs.has(reg) && cells.some(([r, c]) => possSet.has(`${r},${c}`))
);
for (let n = 1; n <= unsolved.length - 1; n++) {
for (const combo of genCombinations(unsolved, n)) {
const regSet = new Set(combo.map(([reg]) => reg));
const possCells = combo.flatMap(([, cells]) => cells.filter(([r, c]) => possSet.has(`${r},${c}`)));
if (possCells.length === 0) continue;
// Row subset
const rowSet = new Set(possCells.map(([r]) => r));
if (rowSet.size === n) {
const elimCells: [number, number][] = [];
for (const lr of rowSet)
for (let c = 0; c < size; c++)
if (possSet.has(`${lr},${c}`) && !regSet.has(regions[lr][c]))
elimCells.push([lr, c]);
if (elimCells.length > 0) {
const rowNames = [...rowSet].sort((a, b) => a - b).map(r => `ligne ${r + 1}`).join(" et ");
return {
explanation: n === 1
? `Toutes les cases disponibles de la zone bleue se trouvent sur la ${rowNames}. La couronne de cette zone occupera forcément cette ligne — aucune autre zone ne peut donc y placer de couronne. Les cases rouges sont éliminées.`
: `Ces ${n} zones (en bleu) ne peuvent se placer que sur les ${rowNames}. Ces lignes leur sont réservées — les autres zones ne peuvent plus y placer de couronne. Les cases rouges sont éliminées.`,
focusCells: new Set(possCells.map(([r, c]) => `${r},${c}`)),
actionCells: new Set(elimCells.map(([r, c]) => `${r},${c}`)),
action: { kind: "marks", cells: elimCells },
};
}
}
// Col subset
const colSet = new Set(possCells.map(([, c]) => c));
if (colSet.size === n) {
const elimCells: [number, number][] = [];
for (const lc of colSet)
for (let r = 0; r < size; r++)
if (possSet.has(`${r},${lc}`) && !regSet.has(regions[r][lc]))
elimCells.push([r, lc]);
if (elimCells.length > 0) {
const colNames = [...colSet].sort((a, b) => a - b).map(c => `colonne ${c + 1}`).join(" et ");
return {
explanation: n === 1
? `Toutes les cases disponibles de la zone bleue se trouvent dans la ${colNames}. La couronne de cette zone occupera forcément cette colonne — aucune autre zone ne peut donc y placer de couronne. Les cases rouges sont éliminées.`
: `Ces ${n} zones (en bleu) ne peuvent se placer que dans les ${colNames}. Ces colonnes leur sont réservées — les autres zones ne peuvent plus y placer de couronne. Les cases rouges sont éliminées.`,
focusCells: new Set(possCells.map(([r, c]) => `${r},${c}`)),
actionCells: new Set(elimCells.map(([r, c]) => `${r},${c}`)),
action: { kind: "marks", cells: elimCells },
};
}
}
}
}
return null;
}
const STORAGE_KEY = (date: string) => `queens-v2-${date}`;
const DIAGONAL_ERROR = "repeating-linear-gradient(-45deg, rgba(220,38,38,0.55) 0px, rgba(220,38,38,0.55) 3px, rgba(220,38,38,0.2) 3px, rgba(220,38,38,0.2) 6px)";
const MAX_CELL = 52;
export default function QueensBoard({ puzzle, date, onSolve }: Props) {
const { size, regions } = puzzle;
const [CELL, setCELL] = useState(() =>
typeof window === "undefined" ? MAX_CELL : Math.min(MAX_CELL, Math.floor((window.innerWidth - 32) / size))
);
const [board, setBoard] = useState<CellState[][]>(() => {
if (typeof window !== "undefined") {
const s = localStorage.getItem(STORAGE_KEY(date));
if (s) return JSON.parse(s);
}
return Array.from({ length: size }, () => Array(size).fill("empty"));
});
const [won, setWon] = useState(false);
const [elapsed, setElapsed] = useState(0);
const [t0] = useState(() => Date.now());
const [hintInfo, setHintInfo] = useState<HintInfo | null>(null);
const history = useRef<CellState[][][]>([]);
const drag = useRef<{
action: "mark" | "clear";
origin: string;
originApplied: boolean;
lastCell: string;
moved: boolean;
} | null>(null);
const boardSnap = useRef(board);
const gridRef = useRef<HTMLDivElement>(null);
useEffect(() => { boardSnap.current = board; }, [board]);
useEffect(() => {
const update = () => setCELL(Math.min(MAX_CELL, Math.floor((window.innerWidth - 32) / size)));
window.addEventListener("resize", update);
return () => window.removeEventListener("resize", update);
}, [size]);
useEffect(() => {
if (won) return;
const id = setInterval(() => setElapsed(Math.floor((Date.now() - t0) / 1000)), 500);
return () => clearInterval(id);
}, [won, t0]);
useEffect(() => {
localStorage.setItem(STORAGE_KEY(date), JSON.stringify(board));
if (!won && checkWin(board, puzzle)) { setWon(true); onSolve?.(Math.floor((Date.now() - t0) / 1000)); }
}, [board, date, puzzle, won, onSolve, t0]);
useEffect(() => {
const stop = () => { drag.current = null; };
window.addEventListener("pointerup", stop);
return () => window.removeEventListener("pointerup", stop);
}, []);
const errors = won ? new Set<string>() : getErrors(board, puzzle);
const queensPlaced = board.flat().filter(c => c === "queen").length;
const fmt = (s: number) => `${String(Math.floor(s / 60)).padStart(2, "0")}:${String(s % 60).padStart(2, "0")}`;
const applyDragToCell = useCallback((r: number, c: number) => {
if (!drag.current || won) return;
const { action } = drag.current;
setBoard(prev => {
const cur = prev[r][c];
if (action === "mark" && cur === "empty") {
const next = prev.map(row => [...row]); next[r][c] = "mark"; return next;
}
if (action === "clear" && cur === "mark") {
const next = prev.map(row => [...row]); next[r][c] = "empty"; return next;
}
return prev;
});
}, [won]);
const undo = useCallback(() => {
if (won || history.current.length === 0) return;
setBoard(history.current.pop()!);
setHintInfo(null);
}, [won]);
useEffect(() => {
const handler = (e: KeyboardEvent) => {
if ((e.ctrlKey || e.metaKey) && e.key === "z") { e.preventDefault(); undo(); }
};
window.addEventListener("keydown", handler);
return () => window.removeEventListener("keydown", handler);
}, [undo]);
const handlePointerDown = useCallback((r: number, c: number, e: React.PointerEvent) => {
if (won) return;
e.preventDefault();
history.current.push(boardSnap.current.map(row => [...row]));
setHintInfo(null);
const cur = boardSnap.current[r][c];
const action: "mark" | "clear" = cur === "mark" ? "clear" : "mark";
drag.current = { action, origin: `${r},${c}`, originApplied: false, lastCell: `${r},${c}`, moved: false };
}, [won]);
const handlePointerMove = useCallback((e: React.PointerEvent<HTMLDivElement>) => {
if (!drag.current || won) return;
if (!gridRef.current) return;
const rect = gridRef.current.getBoundingClientRect();
const c = Math.floor((e.clientX - rect.left) / CELL);
const r = Math.floor((e.clientY - rect.top) / CELL);
if (r < 0 || r >= size || c < 0 || c >= size) return;
const key = `${r},${c}`;
if (key === drag.current.lastCell) return;
// On first move to a different cell, also apply action to origin
if (!drag.current.originApplied) {
const [or, oc] = drag.current.origin.split(",").map(Number);
applyDragToCell(or, oc);
drag.current.originApplied = true;
}
drag.current.moved = true;
drag.current.lastCell = key;
applyDragToCell(r, c);
}, [won, applyDragToCell, size, CELL]);
const handlePointerUp = useCallback((r: number, c: number) => {
if (!drag.current || won) return;
if (!drag.current.moved) {
setBoard(prev => {
const next = prev.map(row => [...row]);
next[r][c] = next[r][c] === "empty" ? "mark" : next[r][c] === "mark" ? "queen" : "empty";
return next;
});
}
drag.current = null;
}, [won]);
const reset = () => { history.current = []; setBoard(Array.from({ length: size }, () => Array(size).fill("empty"))); setWon(false); setHintInfo(null); };
const handleHint = () => {
if (won) return;
if (hintInfo) { setHintInfo(null); return; } // toggle off
const hint = findLogicalHint(boardSnap.current, puzzle);
if (hint) {
setHintInfo(hint);
}
};
const applyHint = () => {
if (!hintInfo) return;
if (hintInfo.action.kind === "queen") {
const { r, c } = hintInfo.action;
setBoard(prev => {
const next = prev.map(row => [...row]);
next[r][c] = "queen";
return next;
});
} else {
const { cells } = hintInfo.action;
setBoard(prev => {
const next = prev.map(row => [...row]);
for (const [r, c] of cells) if (next[r][c] === "empty") next[r][c] = "mark";
return next;
});
}
setHintInfo(null);
};
return (
<div className="flex flex-col items-center gap-4 w-full">
<div className="flex items-center gap-8 text-sm text-gray-500 font-medium">
<span>{queensPlaced} / {size} reines</span>
<span className="tabular-nums">{fmt(elapsed)}</span>
</div>
{won && <WinBanner game="queens" date={date} elapsed={elapsed} />}
{/* Hint explanation panel */}
{hintInfo && (
<div className="w-full max-w-xs bg-blue-50 border border-blue-200 rounded-xl px-4 py-3 text-sm text-blue-900 shadow-sm">
<p className="mb-3 leading-relaxed">{hintInfo.explanation}</p>
<div className="flex gap-2">
{hintInfo.actionCells.size > 0 && (
<button
onClick={applyHint}
className="flex-1 py-1.5 rounded-lg bg-blue-600 text-white text-xs font-semibold hover:bg-blue-700 transition-colors"
>
Appliquer
</button>
)}
<button
onClick={() => setHintInfo(null)}
className={`flex-1 py-1.5 rounded-lg border border-blue-200 text-blue-600 text-xs font-semibold hover:bg-blue-100 transition-colors`}
>
Fermer
</button>
</div>
</div>
)}
{/* Board */}
<div
ref={gridRef}
onPointerMove={handlePointerMove}
style={{
display: "grid",
gridTemplateColumns: `repeat(${size}, ${CELL}px)`,
border: "2px solid #1a1a1a",
borderRadius: 4,
overflow: "hidden",
touchAction: "none",
}}
>
{Array.from({ length: size }, (_, r) =>
Array.from({ length: size }, (_, c) => {
const reg = regions[r][c];
const state = board[r][c];
const err = errors.has(`${r},${c}`);
const color = REGION_COLORS[reg % REGION_COLORS.length];
const key = `${r},${c}`;
const bTop = r > 0 && regions[r - 1][c] === reg ? "1px solid rgba(0,0,0,0.08)" : "2px solid #1a1a1a";
const bLeft = c > 0 && regions[r][c - 1] === reg ? "1px solid rgba(0,0,0,0.08)" : "2px solid #1a1a1a";
const bBottom = r < size - 1 && regions[r + 1][c] === reg ? "1px solid rgba(0,0,0,0.08)" : "2px solid #1a1a1a";
const bRight = c < size - 1 && regions[r][c + 1] === reg ? "1px solid rgba(0,0,0,0.08)" : "2px solid #1a1a1a";
// Hint highlighting overrides normal bg
let bg = color.bg;
const bgImage = err ? DIAGONAL_ERROR : undefined;
if (!err && hintInfo) {
if (hintInfo.action.kind === "queen" && hintInfo.actionCells.has(key)) {
bg = "#bbf7d0"; // green: forced queen
} else if (hintInfo.action.kind === "marks" && hintInfo.actionCells.has(key)) {
bg = "#fecaca"; // red: cells to eliminate
} else if (hintInfo.focusCells.has(key)) {
bg = "#bfdbfe"; // blue: context cells
}
}
return (
<div
key={key}
onPointerDown={(e) => handlePointerDown(r, c, e)}
onPointerUp={() => handlePointerUp(r, c)}
style={{
width: CELL, height: CELL,
backgroundColor: bg,
backgroundImage: bgImage,
borderTop: bTop, borderLeft: bLeft, borderBottom: bBottom, borderRight: bRight,
cursor: won ? "default" : "pointer",
display: "flex", alignItems: "center", justifyContent: "center",
userSelect: "none",
transition: "background-color 0.12s",
}}
>
{state === "queen" && (
<span style={{ color: err ? "#dc2626" : "#1a1a1a" }}>
<CrownIcon size={CELL * 0.48} />
</span>
)}
{state === "mark" && (
<span style={{ color: "#6b7280", opacity: 0.6 }}>
<XIcon size={CELL * 0.4} />
</span>
)}
</div>
);
})
)}
</div>
<div className="flex flex-col items-center gap-1 text-xs text-gray-400">
<span>1 clic = · 2 clics = couronne · glisser = en série</span>
</div>
<div className="flex gap-3">
{!won && (
<button
onClick={handleHint}
className={`px-4 py-2 rounded-xl border text-sm transition-colors ${hintInfo ? "border-blue-300 bg-blue-50 text-blue-700" : "border-amber-200 text-amber-600 hover:bg-amber-50"}`}
>
{hintInfo ? "Masquer" : "Indice"}
</button>
)}
<button
onClick={undo}
disabled={history.current.length === 0}
className="px-4 py-2 rounded-xl border border-gray-200 text-gray-500 hover:bg-gray-50 text-sm transition-colors disabled:opacity-30"
title="Annuler (Ctrl+Z)"
>
Annuler
</button>
<button onClick={reset} className="px-4 py-2 rounded-xl border border-gray-200 text-gray-500 hover:bg-gray-50 text-sm transition-colors">
Recommencer
</button>
</div>
<p className="text-xs text-gray-400 text-center max-w-xs">
Une couronne par ligne, par colonne et par zone colorée. Les couronnes ne peuvent pas se toucher.
</p>
</div>
);
}

141
components/RuleOverlay.tsx Normal file
View file

@ -0,0 +1,141 @@
"use client";
import { useEffect, useState, useCallback } from "react";
import { GAME_META, GameId } from "@/lib/levels";
import { GAME_RULES } from "@/lib/rules";
interface Props {
game: GameId;
/** Force-show regardless of localStorage (e.g. when user clicks "?") */
forceShow?: boolean;
onClose?: () => void;
}
function seenKey(game: GameId) { return `rule-seen-${game}`; }
export function useRuleOverlay(game: GameId) {
const [visible, setVisible] = useState(false);
useEffect(() => {
if (typeof window === "undefined") return;
const seen = localStorage.getItem(seenKey(game));
if (!seen) setVisible(true);
}, [game]);
const dismiss = useCallback(() => {
if (typeof window !== "undefined") {
localStorage.setItem(seenKey(game), "1");
}
setVisible(false);
}, [game]);
const open = useCallback(() => setVisible(true), []);
return { visible, dismiss, open };
}
export default function RuleOverlay({ game, forceShow, onClose }: Props) {
const { visible, dismiss } = useRuleOverlay(game);
const isOpen = forceShow || visible;
const handleClose = useCallback(() => {
dismiss();
onClose?.();
}, [dismiss, onClose]);
// Close on Escape
useEffect(() => {
if (!isOpen) return;
const handler = (e: KeyboardEvent) => { if (e.key === "Escape") handleClose(); };
window.addEventListener("keydown", handler);
return () => window.removeEventListener("keydown", handler);
}, [isOpen, handleClose]);
if (!isOpen) return null;
const { name, accent, symbol } = GAME_META[game];
const rules = GAME_RULES[game];
if (!rules) return null;
return (
<div
className="fixed inset-0 z-50 flex items-end sm:items-center justify-center p-4"
style={{ background: "rgba(0,0,0,0.45)" }}
onClick={handleClose}
>
<div
className="w-full max-w-sm bg-white rounded-3xl p-6 flex flex-col gap-5 shadow-2xl"
style={{ animation: "slideUp 0.28s cubic-bezier(0.34,1.56,0.64,1) both" }}
onClick={e => e.stopPropagation()}
>
{/* Header */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<span
className="w-9 h-9 rounded-xl flex items-center justify-center text-lg font-bold"
style={{ background: `${accent}18`, color: accent }}
aria-hidden
>
{symbol}
</span>
<div>
<h2 className="text-base font-bold text-gray-900">{name}</h2>
<p className="text-xs text-gray-400">{rules.duration}</p>
</div>
</div>
<button
onClick={handleClose}
className="w-8 h-8 rounded-full bg-gray-100 hover:bg-gray-200 transition-colors flex items-center justify-center"
aria-label="Fermer"
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2.5} strokeLinecap="round">
<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>
</svg>
</button>
</div>
{/* Subtitle */}
<p className="text-sm text-gray-500 -mt-1">{rules.subtitle}</p>
{/* Rules */}
<div className="flex flex-col gap-2.5">
{rules.howToPlay.map((rule, i) => (
<div key={i} className="flex items-start gap-3">
<span
className="w-5 h-5 rounded-full flex items-center justify-center text-[10px] font-bold shrink-0 mt-0.5"
style={{ background: `${accent}18`, color: accent }}
>
{i + 1}
</span>
<p className="text-sm text-gray-700 leading-snug">{rule}</p>
</div>
))}
</div>
{/* Tip */}
{rules.tip && (
<div className="flex items-start gap-2.5 px-3 py-2.5 rounded-xl" style={{ background: `${accent}0d` }}>
<span style={{ color: accent }} className="text-sm shrink-0 mt-px">💡</span>
<p className="text-xs text-gray-600 leading-snug">{rules.tip}</p>
</div>
)}
{/* CTA */}
<button
onClick={handleClose}
className="w-full py-3 rounded-2xl text-white font-semibold text-sm transition-opacity hover:opacity-90"
style={{ background: accent }}
>
C&apos;est parti !
</button>
</div>
<style>{`
@keyframes slideUp {
from { opacity: 0; transform: translateY(24px) scale(0.97); }
to { opacity: 1; transform: translateY(0) scale(1); }
}
`}</style>
</div>
);
}

View file

@ -0,0 +1,19 @@
"use client";
import { useEffect, useState } from "react";
import { loadStats } from "@/lib/stats";
export default function StreakBadge({ game }: { game: string }) {
const [streak, setStreak] = useState(0);
useEffect(() => {
setStreak(loadStats(game).streak);
}, [game]);
if (streak === 0) return null;
return (
<span className="inline-flex items-center gap-1 text-xs font-semibold text-orange-500">
🔥{streak}
</span>
);
}

253
components/SudokuBoard.tsx Normal file
View file

@ -0,0 +1,253 @@
"use client";
import { useState, useEffect, useCallback, useRef } from "react";
import { SudokuPuzzle } from "@/lib/generators/sudoku";
import WinBanner from "./WinBanner";
interface Props {
puzzle: SudokuPuzzle;
date: string;
onSolve?: (elapsed: number) => void;
}
const MAX_CELL = 64;
const STORAGE_KEY = (d: string) => `sudoku-${d}`;
const DIAGONAL_ERROR = "repeating-linear-gradient(-45deg, rgba(220,38,38,0.55) 0px, rgba(220,38,38,0.55) 3px, rgba(220,38,38,0.2) 3px, rgba(220,38,38,0.2) 6px)";
function checkWin(grid: number[][], solution: number[][]): boolean {
for (let r = 0; r < 6; r++)
for (let c = 0; c < 6; c++)
if (grid[r][c] !== solution[r][c]) return false;
return true;
}
function getErrors(grid: number[][], given: number[][]): Set<string> {
const errors = new Set<string>();
for (let r = 0; r < 6; r++) {
for (let c = 0; c < 6; c++) {
const v = grid[r][c];
if (!v || given[r][c]) continue;
// Row
for (let cc = 0; cc < 6; cc++) if (cc !== c && grid[r][cc] === v) { errors.add(`${r},${c}`); errors.add(`${r},${cc}`); }
// Col
for (let rr = 0; rr < 6; rr++) if (rr !== r && grid[rr][c] === v) { errors.add(`${r},${c}`); errors.add(`${rr},${c}`); }
// Box
const br = Math.floor(r / 2) * 2, bc = Math.floor(c / 3) * 3;
for (let dr = 0; dr < 2; dr++)
for (let dc = 0; dc < 3; dc++) {
const rr = br + dr, cc = bc + dc;
if ((rr !== r || cc !== c) && grid[rr][cc] === v) { errors.add(`${r},${c}`); errors.add(`${rr},${cc}`); }
}
}
}
return errors;
}
export default function SudokuBoard({ puzzle, date, onSolve }: Props) {
const { given, solution } = puzzle;
const size = 6;
const [CELL, setCELL] = useState(() =>
typeof window === "undefined" ? MAX_CELL : Math.min(MAX_CELL, Math.floor((window.innerWidth - 32) / size))
);
const [grid, setGrid] = useState<number[][]>(() => {
if (typeof window !== "undefined") {
const s = localStorage.getItem(STORAGE_KEY(date));
if (s) return JSON.parse(s);
}
return given.map(r => [...r]);
});
const [selected, setSelected] = useState<[number, number] | null>(null);
const [won, setWon] = useState(false);
const [elapsed, setElapsed] = useState(0);
const [t0] = useState(() => Date.now());
const history = useRef<number[][][]>([]);
useEffect(() => {
const update = () => setCELL(Math.min(MAX_CELL, Math.floor((window.innerWidth - 32) / size)));
window.addEventListener("resize", update);
return () => window.removeEventListener("resize", update);
}, [size]);
useEffect(() => {
if (won) return;
const id = setInterval(() => setElapsed(Math.floor((Date.now() - t0) / 1000)), 500);
return () => clearInterval(id);
}, [won, t0]);
useEffect(() => {
localStorage.setItem(STORAGE_KEY(date), JSON.stringify(grid));
if (!won && checkWin(grid, solution)) { setWon(true); onSolve?.(Math.floor((Date.now() - t0) / 1000)); }
}, [grid, date, solution, won, onSolve]);
const errors = won ? new Set<string>() : getErrors(grid, given);
const filled = grid.flat().filter(Boolean).length;
const fmt = (s: number) => `${String(Math.floor(s / 60)).padStart(2, "0")}:${String(s % 60).padStart(2, "0")}`;
const input = useCallback((val: number) => {
if (!selected || won) return;
const [r, c] = selected;
if (given[r][c]) return;
setGrid(prev => {
history.current.push(prev.map(row => [...row]));
const next = prev.map(row => [...row]);
next[r][c] = next[r][c] === val ? 0 : val;
return next;
});
}, [selected, won, given]);
const undo = useCallback(() => {
if (won || history.current.length === 0) return;
const prev = history.current.pop()!;
setGrid(prev);
}, [won]);
useEffect(() => {
const handler = (e: KeyboardEvent) => {
const n = parseInt(e.key);
if (n >= 1 && n <= 6) input(n);
if (e.key === "Backspace" || e.key === "Delete" || e.key === "0") input(0);
if ((e.ctrlKey || e.metaKey) && e.key === "z") undo();
};
window.addEventListener("keydown", handler);
return () => window.removeEventListener("keydown", handler);
}, [input, undo]);
const reset = () => { history.current = []; setGrid(given.map(r => [...r])); setSelected(null); setWon(false); };
const handleHint = useCallback(() => {
if (won) return;
// Find first empty non-given cell
for (let r = 0; r < 6; r++)
for (let c = 0; c < 6; c++)
if (!given[r][c] && grid[r][c] === 0) {
history.current.push(grid.map(row => [...row]));
setGrid(prev => {
const next = prev.map(row => [...row]);
next[r][c] = solution[r][c];
return next;
});
setSelected([r, c]);
return;
}
}, [won, given, grid, solution]);
const [selR, selC] = selected ?? [-1, -1];
// Box borders: thick between 2x3 boxes
const boxBorderB = (r: number) => (r === 1 || r === 3) ? "3px solid #1a1a1a" : "1px solid #d1d5db";
const boxBorderR = (c: number) => (c === 2) ? "3px solid #1a1a1a" : "1px solid #d1d5db";
return (
<div className="flex flex-col items-center gap-4">
<div className="flex items-center gap-8 text-sm text-gray-500 font-medium">
<span>{filled} / 36</span>
<span className="tabular-nums">{fmt(elapsed)}</span>
</div>
{won && <WinBanner game="sudoku" date={date} elapsed={elapsed} />}
{/* Grid */}
<div style={{ border: "3px solid #1a1a1a", display: "inline-block", borderRadius: 4 }}>
{Array.from({ length: 6 }, (_, r) => (
<div key={r} style={{ display: "flex" }}>
{Array.from({ length: 6 }, (_, c) => {
const val = grid[r][c];
const isGiven = !!given[r][c];
const isSelected = r === selR && c === selC;
const isSameVal = selected && val && grid[selR]?.[selC] === val && !isSelected;
const isHighlighted = selected && !isSelected && (r === selR || c === selC || (Math.floor(r / 2) === Math.floor(selR / 2) && Math.floor(c / 3) === Math.floor(selC / 3)));
const err = errors.has(`${r},${c}`);
let bg = "#ffffff";
const bgImage: string | undefined = err ? DIAGONAL_ERROR : undefined;
if (isSelected) bg = "#ccfbf1";
else if (isSameVal) bg = "#99f6e4";
else if (isHighlighted) bg = "#f0fdfa";
const selectedBorder = isSelected ? "2px solid #0d9488" : undefined;
return (
<div
key={c}
onClick={() => !won && setSelected([r, c])}
style={{
width: CELL,
height: CELL,
backgroundColor: bg,
backgroundImage: bgImage,
borderBottom: selectedBorder ?? boxBorderB(r),
borderRight: selectedBorder ?? boxBorderR(c),
borderTop: isSelected ? "2px solid #10b981" : undefined,
borderLeft: isSelected ? "2px solid #10b981" : undefined,
display: "flex",
alignItems: "center",
justifyContent: "center",
cursor: isGiven || won ? "default" : "pointer",
fontSize: CELL * 0.42,
fontWeight: isGiven ? 700 : 500,
color: err ? "#dc2626" : isGiven ? "#1a1a1a" : "#1d4ed8",
transition: "background-color 0.1s",
userSelect: "none",
position: "relative",
zIndex: isSelected ? 1 : 0,
boxSizing: "border-box",
}}
>
{val || ""}
</div>
);
})}
</div>
))}
</div>
{/* Number pad */}
{!won && (
<div className="flex gap-2">
{[1, 2, 3, 4, 5, 6].map(n => (
<button
key={n}
onClick={() => input(n)}
className="w-10 h-10 rounded-lg border border-gray-300 text-gray-700 font-semibold text-base hover:bg-blue-50 hover:border-blue-300 transition-colors"
>
{n}
</button>
))}
<button
onClick={() => input(0)}
className="w-10 h-10 rounded-lg border border-gray-200 text-gray-400 text-xs hover:bg-gray-50 transition-colors"
title="Effacer"
>
</button>
<button
onClick={undo}
disabled={history.current.length === 0}
className="w-10 h-10 rounded-lg border border-gray-200 text-gray-400 text-base hover:bg-gray-50 transition-colors disabled:opacity-30"
title="Annuler (Ctrl+Z)"
>
</button>
</div>
)}
<p className="text-xs text-gray-400 text-center max-w-[300px]">
Chiffres 16 : un seul par ligne, colonne et bloc 2×3. Clic + clavier ou pavé numérique.
</p>
<div className="flex gap-3">
{!won && (
<button onClick={handleHint}
className="px-4 py-2 rounded-xl border border-amber-200 text-amber-600 hover:bg-amber-50 text-sm transition-colors">
Indice
</button>
)}
<button onClick={reset} className="px-4 py-2 rounded-xl border border-gray-200 text-gray-500 hover:bg-gray-50 text-sm transition-colors">
Recommencer
</button>
</div>
</div>
);
}

357
components/TangoBoard.tsx Normal file
View file

@ -0,0 +1,357 @@
"use client";
import { useState, useEffect, useCallback, useRef } from "react";
import { TangoPuzzle, Cell } from "@/lib/generators/tango";
import WinBanner from "./WinBanner";
interface Props {
puzzle: TangoPuzzle;
date: string;
onSolve?: (elapsed: number) => void;
}
const MAX_CELL = 68;
const CONSTRAINT_SIZE = 22; // badge diameter
function SunIcon({ size }: { size: number }) {
return (
<svg width={size} height={size} viewBox="0 0 40 40">
<circle cx="20" cy="20" r="14" fill="#f97316" />
</svg>
);
}
function MoonIcon({ size }: { size: number }) {
return (
<svg width={size} height={size} viewBox="0 0 40 40">
<defs>
<mask id="moon-mask">
<rect width="40" height="40" fill="white" />
<circle cx="27" cy="17" r="12" fill="black" />
</mask>
</defs>
<circle cx="20" cy="20" r="14" fill="#3b82f6" mask="url(#moon-mask)" />
</svg>
);
}
const DIAGONAL_ERROR = "repeating-linear-gradient(-45deg, rgba(220,38,38,0.55) 0px, rgba(220,38,38,0.55) 3px, rgba(220,38,38,0.2) 3px, rgba(220,38,38,0.2) 6px)";
function checkWin(grid: Cell[][], puzzle: TangoPuzzle): boolean {
const { size, hEdges, vEdges } = puzzle;
for (let r = 0; r < size; r++)
for (let c = 0; c < size; c++)
if (!grid[r][c]) return false;
for (let i = 0; i < size; i++) {
let rs = 0, rm = 0, cs = 0, cm = 0;
for (let j = 0; j < size; j++) {
if (grid[i][j] === "sun") rs++; else rm++;
if (grid[j][i] === "sun") cs++; else cm++;
}
if (rs !== size / 2 || rm !== size / 2 || cs !== size / 2 || cm !== size / 2) return false;
}
for (let i = 0; i < size; i++)
for (let j = 0; j <= size - 3; j++) {
if (grid[i][j] === grid[i][j + 1] && grid[i][j] === grid[i][j + 2]) return false;
if (grid[j][i] === grid[j + 1][i] && grid[j][i] === grid[j + 2][i]) return false;
}
for (let r = 0; r < size; r++)
for (let c = 0; c < size - 1; c++) {
const e = hEdges[r][c]; if (!e) continue;
const same = grid[r][c] === grid[r][c + 1];
if ((e === "=" && !same) || (e === "x" && same)) return false;
}
for (let r = 0; r < size - 1; r++)
for (let c = 0; c < size; c++) {
const e = vEdges[r][c]; if (!e) continue;
const same = grid[r][c] === grid[r + 1][c];
if ((e === "=" && !same) || (e === "x" && same)) return false;
}
return true;
}
function getErrors(grid: Cell[][], puzzle: TangoPuzzle): Set<string> {
const { size, hEdges, vEdges } = puzzle;
const err = new Set<string>();
for (let i = 0; i < size; i++) {
let rs = 0, rm = 0, cs = 0, cm = 0;
for (let j = 0; j < size; j++) {
if (grid[i][j] === "sun") rs++; if (grid[i][j] === "moon") rm++;
if (grid[j][i] === "sun") cs++; if (grid[j][i] === "moon") cm++;
}
if (rs > size / 2) for (let j = 0; j < size; j++) if (grid[i][j] === "sun") err.add(`${i},${j}`);
if (rm > size / 2) for (let j = 0; j < size; j++) if (grid[i][j] === "moon") err.add(`${i},${j}`);
if (cs > size / 2) for (let j = 0; j < size; j++) if (grid[j][i] === "sun") err.add(`${j},${i}`);
if (cm > size / 2) for (let j = 0; j < size; j++) if (grid[j][i] === "moon") err.add(`${j},${i}`);
}
for (let i = 0; i < size; i++)
for (let j = 0; j <= size - 3; j++) {
if (grid[i][j] && grid[i][j] === grid[i][j + 1] && grid[i][j] === grid[i][j + 2]) {
err.add(`${i},${j}`); err.add(`${i},${j + 1}`); err.add(`${i},${j + 2}`);
}
if (grid[j][i] && grid[j][i] === grid[j + 1][i] && grid[j][i] === grid[j + 2][i]) {
err.add(`${j},${i}`); err.add(`${j + 1},${i}`); err.add(`${j + 2},${i}`);
}
}
// Edge constraint violations
for (let r = 0; r < size; r++)
for (let c = 0; c < size - 1; c++) {
const e = hEdges[r][c];
if (!e || !grid[r][c] || !grid[r][c + 1]) continue;
const same = grid[r][c] === grid[r][c + 1];
if ((e === "=" && !same) || (e === "x" && same)) {
err.add(`${r},${c}`); err.add(`${r},${c + 1}`);
}
}
for (let r = 0; r < size - 1; r++)
for (let c = 0; c < size; c++) {
const e = vEdges[r][c];
if (!e || !grid[r][c] || !grid[r + 1][c]) continue;
const same = grid[r][c] === grid[r + 1][c];
if ((e === "=" && !same) || (e === "x" && same)) {
err.add(`${r},${c}`); err.add(`${r + 1},${c}`);
}
}
return err;
}
const STORAGE_KEY = (d: string) => `tango-${d}`;
export default function TangoBoard({ puzzle, date, onSolve }: Props) {
const { size, given, hEdges, vEdges } = puzzle;
const [CELL, setCELL] = useState(() =>
typeof window === "undefined" ? MAX_CELL : Math.min(MAX_CELL, Math.floor((window.innerWidth - 32) / size))
);
const [grid, setGrid] = useState<Cell[][]>(() => {
if (typeof window !== "undefined") {
const s = localStorage.getItem(STORAGE_KEY(date));
if (s) return JSON.parse(s);
}
return given.map(r => [...r]);
});
const history = useRef<Cell[][][]>([]);
const [won, setWon] = useState(false);
const [elapsed, setElapsed] = useState(0);
const [t0] = useState(() => Date.now());
useEffect(() => {
const update = () => setCELL(Math.min(MAX_CELL, Math.floor((window.innerWidth - 32) / size)));
window.addEventListener("resize", update);
return () => window.removeEventListener("resize", update);
}, [size]);
useEffect(() => {
if (won) return;
const id = setInterval(() => setElapsed(Math.floor((Date.now() - t0) / 1000)), 500);
return () => clearInterval(id);
}, [won, t0]);
useEffect(() => {
localStorage.setItem(STORAGE_KEY(date), JSON.stringify(grid));
if (!won && checkWin(grid, puzzle)) { setWon(true); onSolve?.(Math.floor((Date.now() - t0) / 1000)); }
}, [grid, date, puzzle, won, onSolve, t0]);
const errors = won ? new Set<string>() : getErrors(grid, puzzle);
const filled = grid.flat().filter(Boolean).length;
const fmt = (s: number) => `${String(Math.floor(s / 60)).padStart(2, "0")}:${String(s % 60).padStart(2, "0")}`;
const undo = useCallback(() => {
if (won || history.current.length === 0) return;
setGrid(history.current.pop()!);
}, [won]);
useEffect(() => {
const handler = (e: KeyboardEvent) => {
if ((e.ctrlKey || e.metaKey) && e.key === "z") { e.preventDefault(); undo(); }
};
window.addEventListener("keydown", handler);
return () => window.removeEventListener("keydown", handler);
}, [undo]);
const cycleCell = useCallback((r: number, c: number) => {
if (won || given[r][c] !== null) return;
setGrid(prev => {
history.current.push(prev.map(row => [...row]));
const next = prev.map(row => [...row]);
next[r][c] = next[r][c] === null ? "sun" : next[r][c] === "sun" ? "moon" : null;
return next;
});
}, [won, given]);
const reset = () => { history.current = []; setGrid(given.map(r => [...r])); setWon(false); };
const handleHint = () => {
if (won) return;
for (let r = 0; r < size; r++)
for (let c = 0; c < size; c++)
if (!grid[r][c] && !given[r][c]) {
setGrid(prev => {
const next = prev.map(row => [...row]);
next[r][c] = puzzle.solution[r][c];
return next;
});
return;
}
};
const totalW = size * CELL;
const iconSize = Math.round(CELL * 0.55);
return (
<div className="flex flex-col items-center gap-4">
<div className="flex items-center gap-8 text-sm text-gray-500 font-medium">
<span>{filled} / {size * size}</span>
<span className="tabular-nums">{fmt(elapsed)}</span>
</div>
{won && <WinBanner game="tango" date={date} elapsed={elapsed} />}
{/* Board: positioned cells + constraint symbols on borders */}
<div style={{ position: "relative", width: totalW, height: totalW }}>
{/* Cells */}
<div style={{
display: "grid",
gridTemplateColumns: `repeat(${size}, ${CELL}px)`,
border: "1.5px solid #d1d5db",
borderRadius: 6,
overflow: "hidden",
}}>
{Array.from({ length: size }, (_, r) =>
Array.from({ length: size }, (_, c) => {
const val = grid[r][c];
const isGiven = given[r][c] !== null;
const err = errors.has(`${r},${c}`);
let bg = "#ffffff";
if (isGiven) bg = "#e5e7eb"; // gray for given cells
const bRight = c < size - 1 ? "1px solid #d1d5db" : "none";
const bBottom = r < size - 1 ? "1px solid #d1d5db" : "none";
return (
<div
key={`${r}-${c}`}
onClick={() => cycleCell(r, c)}
style={{
width: CELL, height: CELL,
backgroundColor: bg,
backgroundImage: err ? DIAGONAL_ERROR : undefined,
borderRight: bRight,
borderBottom: bBottom,
cursor: (isGiven || won) ? "default" : "pointer",
display: "flex",
alignItems: "center",
justifyContent: "center",
userSelect: "none",
transition: "background-color 0.1s",
}}
>
<span style={{ pointerEvents: "none" }}>
{val === "sun" && <SunIcon size={iconSize} />}
{val === "moon" && <MoonIcon size={iconSize} />}
</span>
</div>
);
})
)}
</div>
{/* Horizontal constraint symbols: between (r,c) and (r,c+1) — on the vertical border */}
{Array.from({ length: size }, (_, r) =>
Array.from({ length: size - 1 }, (_, c) => {
const e = hEdges[r][c];
if (!e) return null;
return (
<div
key={`h-${r}-${c}`}
style={{
position: "absolute",
left: (c + 1) * CELL - CONSTRAINT_SIZE / 2,
top: r * CELL + (CELL - CONSTRAINT_SIZE) / 2,
width: CONSTRAINT_SIZE,
height: CONSTRAINT_SIZE,
borderRadius: "50%",
background: "#ffffff",
border: "1.5px solid #d1d5db",
display: "flex",
alignItems: "center",
justifyContent: "center",
fontSize: 11,
fontWeight: 700,
color: "#78716c",
zIndex: 10,
pointerEvents: "none",
}}
>
{e === "=" ? "=" : "×"}
</div>
);
})
)}
{/* Vertical constraint symbols: between (r,c) and (r+1,c) — on the horizontal border */}
{Array.from({ length: size - 1 }, (_, r) =>
Array.from({ length: size }, (_, c) => {
const e = vEdges[r][c];
if (!e) return null;
return (
<div
key={`v-${r}-${c}`}
style={{
position: "absolute",
left: c * CELL + (CELL - CONSTRAINT_SIZE) / 2,
top: (r + 1) * CELL - CONSTRAINT_SIZE / 2,
width: CONSTRAINT_SIZE,
height: CONSTRAINT_SIZE,
borderRadius: "50%",
background: "#ffffff",
border: "1.5px solid #d1d5db",
display: "flex",
alignItems: "center",
justifyContent: "center",
fontSize: 11,
fontWeight: 700,
color: "#78716c",
zIndex: 10,
pointerEvents: "none",
}}
>
{e === "=" ? "=" : "×"}
</div>
);
})
)}
</div>
<div className="flex gap-3">
{!won && (
<>
<button
onClick={undo}
disabled={history.current.length === 0}
className="px-5 py-2 rounded-full border border-gray-300 text-gray-500 hover:bg-gray-50 text-sm transition-colors disabled:opacity-30"
title="Annuler (Ctrl+Z)"
>
Annuler
</button>
<button onClick={reset}
className="px-5 py-2 rounded-full border border-gray-300 text-gray-500 hover:bg-gray-50 text-sm transition-colors">
Recommencer
</button>
<button onClick={handleHint}
className="px-5 py-2 rounded-full border border-gray-900 text-gray-900 hover:bg-gray-50 text-sm font-medium transition-colors">
Indice
</button>
</>
)}
</div>
<div className="flex flex-col items-center gap-1 text-xs text-gray-400">
<span>1 clic = · 2 clics = · 3 clics = effacer</span>
<span>Autant de soleils que de lunes par ligne et colonne. Pas plus de 2 identiques consécutifs.</span>
</div>
</div>
);
}

296
components/WinBanner.tsx Normal file
View file

@ -0,0 +1,296 @@
"use client";
import { useEffect, useState, useCallback } from "react";
import Link from "next/link";
import Confetti from "./Confetti";
import { loadStats, recordSolve, GameStats } from "@/lib/stats";
import { GAME_META, GameId } from "@/lib/levels";
import { todayISO } from "@/lib/rng";
import { getNextSessionGame } from "@/lib/session";
interface Props {
game: string;
date: string;
elapsed: number;
}
function fmt(s: number) {
return `${String(Math.floor(s / 60)).padStart(2, "0")}:${String(s % 60).padStart(2, "0")}`;
}
/** Add one calendar day to a YYYY-MM-DD string. */
function addOneDay(dateStr: string): string {
const [y, m, d] = dateStr.split("-").map(Number);
const next = new Date(y, m - 1, d + 1);
return `${next.getFullYear()}-${String(next.getMonth() + 1).padStart(2, "0")}-${String(next.getDate()).padStart(2, "0")}`;
}
/** Subtract one calendar day from a YYYY-MM-DD string. */
function subOneDay(dateStr: string): string {
const [y, m, d] = dateStr.split("-").map(Number);
const prev = new Date(y, m - 1, d - 1);
return `${prev.getFullYear()}-${String(prev.getMonth() + 1).padStart(2, "0")}-${String(prev.getDate()).padStart(2, "0")}`;
}
/** Haptic feedback (mobile) */
function haptic(pattern: number | number[] = 10) {
try { navigator.vibrate?.(pattern); } catch { /* ignore */ }
}
/** Build Wordle-style share text */
function buildShareText(game: string, date: string, elapsed: number, streak: number): string {
const gameMeta = (GAME_META as Record<string, { name: string; symbol: string }>)[game];
const name = gameMeta?.name ?? game;
const symbol = gameMeta?.symbol ?? "🧩";
const dateLabel = new Date(date + "T00:00:00").toLocaleDateString("fr-FR", {
day: "numeric", month: "long",
});
const streakLine = streak > 1 ? ` 🔥${streak}` : "";
return `Puzzle Trainer ${dateLabel}\n${name} ${symbol}${fmt(elapsed)}${streakLine}\nhttps://puzzles.reverdin.eu`;
}
function ShareButton({ game, date, elapsed, streak }: { game: string; date: string; elapsed: number; streak: number }) {
const [copied, setCopied] = useState(false);
const handleShare = useCallback(async () => {
haptic(10);
const text = buildShareText(game, date, elapsed, streak);
if (navigator.share) {
try {
await navigator.share({ text });
return;
} catch {
// user cancelled or not supported
}
}
try {
await navigator.clipboard.writeText(text);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
} catch { /* ignore */ }
}, [game, date, elapsed, streak]);
return (
<button
onClick={handleShare}
className="flex items-center gap-1.5 px-3 py-1.5 rounded-full text-xs font-semibold border border-green-300 text-green-700 hover:bg-green-100 transition-colors"
>
{copied ? (
<>
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2.5} strokeLinecap="round" strokeLinejoin="round">
<polyline points="20 6 9 17 4 12"/>
</svg>
Copié !
</>
) : (
<>
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round">
<circle cx="18" cy="5" r="3"/><circle cx="6" cy="12" r="3"/><circle cx="18" cy="19" r="3"/>
<line x1="8.59" y1="13.51" x2="15.42" y2="17.49"/><line x1="15.41" y1="6.51" x2="8.59" y2="10.49"/>
</svg>
Partager
</>
)}
</button>
);
}
function StatCard({ icon, label, value }: { icon: React.ReactNode; label: string; value: string }) {
return (
<div className="flex flex-col items-center gap-0.5 px-4 py-2.5 bg-white rounded-xl border border-green-100 min-w-[72px]">
<div className="text-green-600">{icon}</div>
<span className="text-[15px] font-bold text-gray-900 tabular-nums leading-none">{value}</span>
<span className="text-[10px] text-gray-400 uppercase tracking-wide">{label}</span>
</div>
);
}
// First-win congratulation messages
const FIRST_WIN_MESSAGES = [
"Excellent premier puzzle ! 🎉",
"Tu as le coup d'œil ! ✨",
"Impressionnant pour un début !",
"Premier de beaucoup d'autres ! 🌟",
];
export default function WinBanner({ game, date, elapsed }: Props) {
const [stats, setStats] = useState<GameStats | null>(null);
const [isPersonalRecord, setIsPersonalRecord] = useState(false);
const [isFirstWin, setIsFirstWin] = useState(false);
const [nextSessionGame, setNextSessionGame] = useState<GameId | null>(null);
const [firstWinMsg] = useState(() =>
FIRST_WIN_MESSAGES[Math.floor(Math.random() * FIRST_WIN_MESSAGES.length)]
);
const isDailyDate = /^\d{4}-\d{2}-\d{2}$/.test(date);
const levelMatch = date.match(/^level-\w+-(\d+)$/);
const currentLevel = levelMatch ? parseInt(levelMatch[1]) : null;
useEffect(() => {
if (!isDailyDate) return;
const prevStats = loadStats(game);
const isFirst = prevStats.total === 0;
setIsFirstWin(isFirst);
const s = recordSolve(game, date, elapsed);
setStats(s);
// Personal record: had a previous best, new time is better
if (!isFirst && prevStats.bestTime > 0 && elapsed < prevStats.bestTime) {
setIsPersonalRecord(true);
}
// Haptic celebration
haptic([30, 50, 30]);
// Session mode: find the next game
if (isDailyDate) {
setNextSessionGame(getNextSessionGame(game, date));
}
}, [game, date, elapsed, isDailyDate]);
const displayStats = stats ?? loadStats(game);
const nextHref = isDailyDate
? `/${game}/${addOneDay(date)}`
: currentLevel !== null
? `/${game}/level/${currentLevel + 1}`
: null;
const nextLabel = isDailyDate ? "Grille suivante" : `Niveau ${(currentLevel ?? 0) + 1}`;
const prevHref = isDailyDate
? `/${game}/${subOneDay(date)}`
: currentLevel !== null && currentLevel > 1
? `/${game}/level/${currentLevel - 1}`
: null;
const prevLabel = isDailyDate ? "Hier" : currentLevel !== null ? `Niveau ${currentLevel - 1}` : "";
const levelsHref = `/${game}/levels`;
const today = todayISO();
const isToday = date === today;
return (
<>
<Confetti />
{/* Main banner */}
<div className="win-banner w-full max-w-sm bg-gradient-to-b from-green-50 to-white border border-green-200 rounded-2xl px-6 py-5 flex flex-col items-center gap-3 shadow-[0_4px_16px_0_rgb(22_163_74/0.10)]">
{/* Trophy + title */}
<div className="flex items-center gap-2">
<svg width="22" height="22" viewBox="0 0 24 24" fill="#16a34a" className="shrink-0">
<path d="M19 3H5v2h14V3zM6 5v8a6 6 0 0012 0V5H6zm6 11a4 4 0 01-4-4V7h8v5a4 4 0 01-4 4zm-6 2h12v2H6v-2z"/>
</svg>
<span className="text-green-800 font-bold text-lg tracking-tight">Résolu !</span>
{isPersonalRecord && (
<span className="text-[10px] font-bold bg-amber-400 text-white px-1.5 py-0.5 rounded-full uppercase tracking-wide">
PR
</span>
)}
</div>
{/* First-win message */}
{isFirstWin && (
<p className="text-sm text-green-700 font-medium -mt-1">{firstWinMsg}</p>
)}
{/* Time */}
<div className="text-4xl font-black text-green-700 tabular-nums tracking-tight timer-mono">
{fmt(elapsed)}
</div>
{/* Stats row (daily only) */}
{isDailyDate && (
<div className="flex gap-2 mt-1">
<StatCard
icon={
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round">
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2z" fill="#dcfce7" stroke="#16a34a"/>
<polyline points="12 6 12 12 16 14" stroke="#16a34a"/>
</svg>
}
label="Meilleur"
value={displayStats.bestTime > 0 ? fmt(displayStats.bestTime) : "--:--"}
/>
<StatCard
icon={
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="#16a34a" strokeWidth={2} strokeLinecap="round">
<path d="M13 2L3 14h9l-1 8 10-12h-9l1-8z"/>
</svg>
}
label="Série"
value={`${displayStats.streak}j`}
/>
<StatCard
icon={
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="#16a34a" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round">
<polyline points="9 11 12 14 22 4"/><path d="M21 12v7a2 2 0 01-2 2H5a2 2 0 01-2-2V5a2 2 0 012-2h11"/>
</svg>
}
label="Total"
value={String(displayStats.total)}
/>
</div>
)}
{/* Share button (daily + today only) */}
{isDailyDate && isToday && (
<ShareButton game={game} date={date} elapsed={elapsed} streak={displayStats.streak} />
)}
</div>
{/* Navigation */}
<div className="flex items-center gap-3 flex-wrap justify-center">
{/* Session mode: next game button takes priority */}
{nextSessionGame ? (
<>
<Link
href={`/${nextSessionGame}`}
className="flex items-center gap-2 px-6 py-2.5 rounded-full text-white text-sm font-bold hover:opacity-90 transition-opacity"
style={{ background: GAME_META[nextSessionGame].accent }}
>
{GAME_META[nextSessionGame].symbol} {GAME_META[nextSessionGame].name}
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2.5} strokeLinecap="round"><polyline points="9 18 15 12 9 6"/></svg>
</Link>
<Link href="/" className="px-4 py-2 rounded-full border border-gray-200 text-gray-500 text-sm hover:border-gray-300 hover:text-gray-700 transition-colors">
Accueil
</Link>
</>
) : (
<>
{prevHref && (
<Link
href={prevHref}
className="flex items-center gap-1 px-4 py-2 rounded-full border border-gray-200 text-gray-500 text-sm hover:border-gray-300 hover:text-gray-700 transition-colors"
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round"><polyline points="15 18 9 12 15 6"/></svg>
{prevLabel}
</Link>
)}
{nextHref && (
<Link
href={nextHref}
className="flex items-center gap-1 px-5 py-2 rounded-full bg-gray-900 text-white text-sm font-semibold hover:bg-gray-700 transition-colors"
>
{nextLabel}
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round"><polyline points="9 18 15 12 9 6"/></svg>
</Link>
)}
{isDailyDate && (
<Link
href={levelsHref}
className="px-4 py-2 rounded-full border border-gray-200 text-gray-500 text-sm hover:border-gray-300 hover:text-gray-700 transition-colors"
>
Entraînement
</Link>
)}
</>
)}
</div>
</>
);
}

302
components/ZipBoard.tsx Normal file
View file

@ -0,0 +1,302 @@
"use client";
import { useState, useEffect, useCallback, useRef, useMemo } from "react";
import { ZipPuzzle, canMove } from "@/lib/generators/zip";
import WinBanner from "./WinBanner";
interface Props {
puzzle: ZipPuzzle;
date: string;
onSolve?: (elapsed: number) => void;
}
const WALL_W = 5;
const PATH_COLOR = "#f97316"; // orange — matches LinkedIn
const PATH_DONE = "#10b981"; // green on win
const PATH_BG = "#fde8d0"; // light salmon for path cells
const CELL_1_BG = "#fddcbb"; // slightly deeper salmon for waypoint 1 cell
const STORAGE_KEY = (d: string) => `zip-${d}`;
function cellKey(r: number, c: number) { return `${r},${c}`; }
const MAX_CELL = 52;
export default function ZipBoard({ puzzle, date, onSolve }: Props) {
const { size, path: solution, numberedCells, walls } = puzzle;
const totalCells = size * size;
const [CELL, setCELL] = useState(() =>
typeof window === "undefined" ? MAX_CELL : Math.min(MAX_CELL, Math.floor((window.innerWidth - 32) / size))
);
const STEP = CELL;
const [userPath, setUserPath] = useState<[number, number][]>(() => {
if (typeof window !== "undefined") {
const s = localStorage.getItem(STORAGE_KEY(date));
if (s) return JSON.parse(s);
}
return [];
});
const pathHistory = useRef<[number, number][][]>([]);
const [dragging, setDragging] = useState(false);
const [won, setWon] = useState(false);
const [elapsed, setElapsed] = useState(0);
const [t0] = useState(() => Date.now());
const boardRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const update = () => setCELL(Math.min(MAX_CELL, Math.floor((window.innerWidth - 32) / size)));
window.addEventListener("resize", update);
return () => window.removeEventListener("resize", update);
}, [size]);
useEffect(() => {
if (won) return;
const id = setInterval(() => setElapsed(Math.floor((Date.now() - t0) / 1000)), 500);
return () => clearInterval(id);
}, [won, t0]);
const checkWin = useCallback((p: [number, number][]) => {
if (p.length !== totalCells) return false;
const waypointCells = Object.entries(numberedCells).sort((a, b) => a[1] - b[1]).map(([k]) => k);
let wi = 0;
for (const [r, c] of p) {
if (wi < waypointCells.length && cellKey(r, c) === waypointCells[wi]) wi++;
}
return wi === waypointCells.length;
}, [totalCells, numberedCells]);
useEffect(() => {
localStorage.setItem(STORAGE_KEY(date), JSON.stringify(userPath));
if (!won && checkWin(userPath)) { setWon(true); onSolve?.(Math.floor((Date.now() - t0) / 1000)); }
}, [userPath, date, won, checkWin, onSolve]);
const fmt = (s: number) => `${String(Math.floor(s / 60)).padStart(2, "0")}:${String(s % 60).padStart(2, "0")}`;
const pathSet = new Set(userPath.map(([r, c]) => cellKey(r, c)));
function getCellFromPoint(clientX: number, clientY: number): [number, number] | null {
const board = boardRef.current;
if (!board) return null;
const rect = board.getBoundingClientRect();
const c = Math.floor((clientX - rect.left) / STEP);
const r = Math.floor((clientY - rect.top) / STEP);
if (r < 0 || r >= size || c < 0 || c >= size) return null;
return [r, c];
}
const undo = useCallback(() => {
if (won || pathHistory.current.length === 0) return;
setUserPath(pathHistory.current.pop()!);
}, [won]);
useEffect(() => {
const handler = (e: KeyboardEvent) => {
if ((e.ctrlKey || e.metaKey) && e.key === "z") { e.preventDefault(); undo(); }
};
window.addEventListener("keydown", handler);
return () => window.removeEventListener("keydown", handler);
}, [undo]);
const startPath = useCallback((r: number, c: number) => {
if (won) return;
setDragging(true);
const key = numberedCells[cellKey(r, c)];
if (key === 1) {
pathHistory.current.push([...userPath]);
setUserPath([[r, c]]);
return;
}
const last = userPath[userPath.length - 1];
if (last && last[0] === r && last[1] === c) return;
if (key) {
pathHistory.current.push([...userPath]);
setUserPath([[r, c]]);
}
}, [won, numberedCells, userPath]);
const extendPath = useCallback((r: number, c: number) => {
if (!dragging || won) return;
setUserPath(prev => {
if (!prev.length) return prev;
const last = prev[prev.length - 1];
const key = cellKey(r, c);
const dr = Math.abs(last[0] - r), dc = Math.abs(last[1] - c);
if (dr + dc !== 1) return prev;
if (!canMove(walls, last[0], last[1], r, c)) return prev;
if (prev.length >= 2 && prev[prev.length - 2][0] === r && prev[prev.length - 2][1] === c)
return prev.slice(0, -1);
if (pathSet.has(key)) return prev;
return [...prev, [r, c]];
});
}, [dragging, won, pathSet, walls]);
const handleMouseDown = (e: React.MouseEvent, r: number, c: number) => {
e.preventDefault(); startPath(r, c);
};
const handleMouseMove = (e: React.MouseEvent) => {
if (!dragging) return;
const cell = getCellFromPoint(e.clientX, e.clientY);
if (cell) extendPath(cell[0], cell[1]);
};
const handleTouchStart = (e: React.TouchEvent, r: number, c: number) => {
e.preventDefault(); startPath(r, c);
};
const handleTouchMove = (e: React.TouchEvent) => {
if (!dragging) return;
const t = e.touches[0];
const cell = getCellFromPoint(t.clientX, t.clientY);
if (cell) extendPath(cell[0], cell[1]);
};
// Thick orange polyline with rounded joins — matches LinkedIn visual
function renderPath() {
if (userPath.length < 1) return null;
const color = won ? PATH_DONE : PATH_COLOR;
const pts = userPath.map(([r, c]) => `${c * STEP + CELL / 2},${r * STEP + CELL / 2}`).join(" ");
return (
<svg
style={{ position: "absolute", inset: 0, pointerEvents: "none", zIndex: 5 }}
width={size * STEP} height={size * STEP}
>
<polyline
points={pts}
stroke={color}
strokeWidth={CELL * 0.46}
strokeLinecap="round"
strokeLinejoin="round"
fill="none"
opacity={0.85}
/>
</svg>
);
}
const handleHint = () => {
if (won) return;
let matchLen = 0;
for (let i = 0; i < Math.min(userPath.length, solution.length); i++) {
if (userPath[i][0] === solution[i][0] && userPath[i][1] === solution[i][1]) matchLen = i + 1;
else break;
}
if (matchLen < solution.length)
setUserPath(solution.slice(0, matchLen + 1) as [number, number][]);
};
const reset = () => { pathHistory.current = []; setUserPath([]); setWon(false); };
const boardSize = size * STEP;
const waypointR = Math.round(CELL * 0.35); // radius of black circle
return (
<div className="flex flex-col items-center gap-4">
<div className="flex items-center gap-8 text-sm text-gray-500 font-medium">
<span>{userPath.length} / {totalCells}</span>
<span className="tabular-nums">{fmt(elapsed)}</span>
</div>
{won && <WinBanner game="zip" date={date} elapsed={elapsed} />}
<div
ref={boardRef}
style={{
position: "relative", width: boardSize, height: boardSize,
userSelect: "none", touchAction: "none",
borderRadius: 8, overflow: "hidden",
border: "1.5px solid #d1d5db",
background: "#ffffff",
}}
onMouseMove={handleMouseMove}
onMouseUp={() => setDragging(false)}
onMouseLeave={() => setDragging(false)}
onTouchMove={handleTouchMove}
onTouchEnd={() => setDragging(false)}
>
{renderPath()}
{Array.from({ length: size }, (_, r) =>
Array.from({ length: size }, (_, c) => {
const key = cellKey(r, c);
const inPath = pathSet.has(key);
const num = numberedCells[key];
const w = walls[r][c];
const isStart = num === 1;
const bTop = (w & 8) ? `${WALL_W}px solid #1a1a1a` : "1px solid #e5e7eb";
const bBottom = (w & 2) ? `${WALL_W}px solid #1a1a1a` : "1px solid #e5e7eb";
const bLeft = (w & 4) ? `${WALL_W}px solid #1a1a1a` : "1px solid #e5e7eb";
const bRight = (w & 1) ? `${WALL_W}px solid #1a1a1a` : "1px solid #e5e7eb";
let bg = "#ffffff";
if (inPath) bg = won ? "#d1fae5" : PATH_BG;
else if (isStart) bg = CELL_1_BG;
return (
<div
key={key}
onMouseDown={e => handleMouseDown(e, r, c)}
onTouchStart={e => handleTouchStart(e, r, c)}
style={{
position: "absolute",
left: c * STEP, top: r * STEP,
width: CELL, height: CELL,
backgroundColor: bg,
borderTop: bTop, borderBottom: bBottom,
borderLeft: bLeft, borderRight: bRight,
boxSizing: "border-box",
display: "flex", alignItems: "center", justifyContent: "center",
cursor: "crosshair",
zIndex: num ? 10 : 2,
transition: "background-color 0.08s",
}}
>
{num && (
<div style={{
width: waypointR * 2,
height: waypointR * 2,
borderRadius: "50%",
background: "#1a1a1a",
display: "flex", alignItems: "center", justifyContent: "center",
color: "#ffffff",
fontSize: CELL * 0.28,
fontWeight: 700,
zIndex: 20,
position: "relative",
flexShrink: 0,
}}>{num}</div>
)}
</div>
);
})
)}
</div>
<div className="flex gap-3">
{!won && (
<>
<button
onClick={undo}
disabled={pathHistory.current.length === 0}
className="px-5 py-2 rounded-full border border-gray-300 text-gray-500 hover:bg-gray-50 text-sm transition-colors disabled:opacity-30"
title="Annuler (Ctrl+Z)"
>
Annuler
</button>
<button onClick={reset}
className="px-5 py-2 rounded-full border border-gray-300 text-gray-500 hover:bg-gray-50 text-sm transition-colors">
Recommencer
</button>
<button onClick={handleHint}
className="px-5 py-2 rounded-full border border-gray-900 text-gray-900 hover:bg-gray-50 text-sm font-medium transition-colors">
Indice
</button>
</>
)}
</div>
<p className="text-xs text-gray-400 text-center max-w-xs">
Glissez du <b>1</b> en passant par les chiffres dans l&apos;ordre, en couvrant toutes les cases.
</p>
</div>
);
}

214
daily-bot.ts Normal file
View file

@ -0,0 +1,214 @@
#!/usr/bin/env tsx
/**
* Puzzle Trainer Daily Bot
* Runs at 09:00 every day, generates all 5 puzzles for today's date,
* scrapes their grids, solves them, and logs results to /var/log/puzzle-bot.log
*/
import { generateQueens, QUEEN_COLORS } from "@/lib/generators/queens";
import { generateTango } from "@/lib/generators/tango";
import { generateZip } from "@/lib/generators/zip";
import { generateSudoku } from "@/lib/generators/sudoku";
import { generatePatches, PATCH_COLORS } from "@/lib/generators/patches";
import * as fs from "fs";
import * as path from "path";
const DATE = new Date().toISOString().slice(0, 10);
const LOG_FILE = "/var/log/puzzle-bot.log";
const OUTPUT_DIR = "/srv/stacks/puzzle-trainer/bot-output";
function log(msg: string) {
const line = `[${new Date().toISOString()}] ${msg}`;
console.log(line);
fs.appendFileSync(LOG_FILE, line + "\n");
}
function ensureDir(dir: string) {
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
}
// ── Queens ────────────────────────────────────────────────────────────────────
function scrapeQueens(date: string) {
const puzzle = generateQueens(date);
const { size, regions, solution } = puzzle;
// ASCII grid with region letters
const regionLetters = "ABCDEFGHIJKLMNOPQRSTUVWXYZ";
const grid: string[] = [];
for (let r = 0; r < size; r++) {
let row = "";
for (let c = 0; c < size; c++) {
const reg = regions[r][c];
const isQueen = solution.some(([sr, sc]) => sr === r && sc === c);
row += isQueen ? `[${regionLetters[reg]}]` : ` ${regionLetters[reg]} `;
}
grid.push(row);
}
return {
game: "queens",
date,
size,
grid,
solution,
regionCount: size,
};
}
// ── Tango ─────────────────────────────────────────────────────────────────────
function scrapeTango(date: string) {
const puzzle = generateTango(date);
const { size, given, hEdges, vEdges, solution } = puzzle;
const symbols = { sun: "☀", moon: "◐", null: "·" } as const;
const grid: string[] = [];
for (let r = 0; r < size; r++) {
const solRow = solution[r].map(v => symbols[v as keyof typeof symbols] ?? "?").join(" ");
const givenRow = given[r].map((v, c) => {
const s = symbols[v as keyof typeof symbols] ?? "·";
return v !== null ? `[${s}]` : ` ${symbols[solution[r][c] as keyof typeof symbols]} `;
}).join("");
grid.push(givenRow);
}
return {
game: "tango",
date,
size,
grid,
solution,
given,
hEdges,
vEdges,
};
}
// ── Zip ───────────────────────────────────────────────────────────────────────
function scrapeZip(date: string) {
const puzzle = generateZip(date);
const { size, path, numberedCells, walls } = puzzle;
// Build a grid showing numbers and path order
const grid: string[] = [];
const pathIdx = new Map<string, number>();
path.forEach(([r, c], i) => pathIdx.set(`${r},${c}`, i + 1));
for (let r = 0; r < size; r++) {
let row = "";
for (let c = 0; c < size; c++) {
const key = `${r},${c}`;
const num = (numberedCells as Record<string, number>)[key];
if (num !== undefined) row += `[${String(num).padStart(2)}]`;
else row += ` ${String(pathIdx.get(key) ?? "?").padStart(2)} `;
}
grid.push(row);
}
return {
game: "zip",
date,
size,
grid,
path,
numberedCells,
walls,
};
}
// ── Sudoku ────────────────────────────────────────────────────────────────────
function scrapeSudoku(date: string) {
const puzzle = generateSudoku(date);
const { size, given, solution } = puzzle;
const grid: string[] = [];
for (let r = 0; r < size; r++) {
let row = "";
for (let c = 0; c < size; c++) {
const g = given[r][c]; // 0 = empty, 1-6 = given
const s = solution[r][c];
row += g !== 0 ? `[${g}]` : ` ${s} `;
}
grid.push(row);
}
return { game: "sudoku", date, size, grid, given, solution };
}
// ── Patches ───────────────────────────────────────────────────────────────────
function scrapePatches(date: string) {
const puzzle = generatePatches(date);
const { size, regions, grid } = puzzle;
const letters = "ABCDEFGH";
const asciiGrid: string[] = [];
for (let r = 0; r < size; r++) {
let row = "";
for (let c = 0; c < size; c++) {
row += ` ${letters[grid[r][c]]} `;
}
asciiGrid.push(row);
}
// Solution: each region maps to itself
const solution = Object.fromEntries(regions.map(r => [r.id, r.id]));
return {
game: "patches",
date,
size,
grid: asciiGrid,
regions: regions.map(r => ({
id: r.id,
size: r.size,
color: r.color,
cells: r.cells,
hintCell: r.hintCell,
})),
solution,
};
}
// ── Main ──────────────────────────────────────────────────────────────────────
async function main() {
ensureDir(OUTPUT_DIR);
log(`=== Daily puzzle bot starting — ${DATE} ===`);
const results: Record<string, unknown> = {};
const errors: string[] = [];
const scrapers = [
{ name: "queens", fn: scrapeQueens },
{ name: "tango", fn: scrapeTango },
{ name: "zip", fn: scrapeZip },
{ name: "sudoku", fn: scrapeSudoku },
{ name: "patches", fn: scrapePatches },
];
for (const { name, fn } of scrapers) {
try {
const data = fn(DATE);
results[name] = data;
log(`${name} (${data.size}x${data.size}) — solved OK`);
// Print grid
data.grid.forEach((row: string) => log(` ${row}`));
} catch (e: unknown) {
const msg = e instanceof Error ? e.message : String(e);
errors.push(`${name}: ${msg}`);
log(`${name} ERROR: ${msg}`);
}
}
// Save JSON output for today
const outputFile = path.join(OUTPUT_DIR, `${DATE}.json`);
fs.writeFileSync(outputFile, JSON.stringify({ date: DATE, puzzles: results, errors }, null, 2));
log(`Saved → ${outputFile}`);
if (errors.length === 0) {
log(`=== All 5 puzzles OK — ${DATE} ===`);
} else {
log(`=== ${errors.length} error(s) — ${DATE} ===`);
process.exit(1);
}
}
main().catch(e => { log(`FATAL: ${e}`); process.exit(1); });

16
docker-compose.yml Normal file
View file

@ -0,0 +1,16 @@
services:
puzzle-trainer:
build: .
container_name: puzzle-trainer
restart: unless-stopped
expose:
- "3000"
mem_limit: 512m
networks:
app-net:
ipv4_address: 172.20.0.91
networks:
app-net:
external: true
name: srv_app-net

BIN
favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

1
file.svg Normal file
View file

@ -0,0 +1 @@
<svg fill="none" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M14.5 13.5V5.41a1 1 0 0 0-.3-.7L9.8.29A1 1 0 0 0 9.08 0H1.5v13.5A2.5 2.5 0 0 0 4 16h8a2.5 2.5 0 0 0 2.5-2.5m-1.5 0v-7H8v-5H3v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1M9.5 5V2.12L12.38 5zM5.13 5h-.62v1.25h2.12V5zm-.62 3h7.12v1.25H4.5zm.62 3h-.62v1.25h7.12V11z" clip-rule="evenodd" fill="#666" fill-rule="evenodd"/></svg>

After

Width:  |  Height:  |  Size: 391 B

192
generators/patches.ts Normal file
View file

@ -0,0 +1,192 @@
import { createRng, shuffle } from "../rng";
// Legacy export — board still imports it but we no longer use it for hints
export type ShapeHint = "free";
export interface Region {
id: number;
cells: [number, number][]; // all cells in grid
hintCell: [number, number]; // topmost-leftmost cell (always in solution)
relCells: [number, number][]; // shape normalised to (0,0) origin
previewRows: number;
previewCols: number;
size: number;
color: string;
}
export interface PatchesPuzzle {
size: number; // 6
regions: Region[];
grid: number[][]; // grid[r][c] = regionId
}
export const PATCH_COLORS = [
"#e8b040", // gold
"#4db86e", // green
"#4a9fd4", // blue
"#e05050", // red
"#e07830", // orange
"#30b8b0", // teal
"#9060c8", // purple
"#d06898", // pink
];
/** Place N seeds spread across the grid (no two adjacent). */
function placeSeeds(size: number, n: number, rng: () => number): [number, number][] {
const all: [number, number][] = [];
for (let r = 0; r < size; r++)
for (let c = 0; c < size; c++)
all.push([r, c]);
const pool = shuffle(all, rng);
const seeds: [number, number][] = [];
const banned = new Set<string>();
for (const [r, c] of pool) {
if (seeds.length >= n) break;
if (banned.has(`${r},${c}`)) continue;
seeds.push([r, c]);
// Ban cell + its orthogonal neighbours (avoid touching seeds)
for (const [dr, dc] of [[0,0],[-1,0],[1,0],[0,-1],[0,1]])
banned.add(`${r+dr},${c+dc}`);
}
return seeds;
}
/**
* Grow N polyomino regions from seeds, then flood-fill any remaining cells.
* Returns a size×size grid where grid[r][c] = regionId.
*/
function growPolyominos(size: number, seeds: [number, number][], rng: () => number): number[][] {
const grid: number[][] = Array.from({ length: size }, () => Array(size).fill(-1));
const sizes: number[] = Array(seeds.length).fill(0);
const n = seeds.length;
const target = Math.round((size * size) / n);
// Place seeds
for (let i = 0; i < n; i++) {
grid[seeds[i][0]][seeds[i][1]] = i;
sizes[i] = 1;
}
// BFS frontier per region
const ADJ: [number, number][] = [[-1,0],[1,0],[0,-1],[0,1]];
// Build initial frontiers
const frontiers: Set<string>[] = Array.from({ length: n }, (_, id) => {
const s = new Set<string>();
const [sr, sc] = seeds[id];
for (const [dr, dc] of ADJ) {
const nr = sr+dr, nc = sc+dc;
if (nr>=0 && nr<size && nc>=0 && nc<size && grid[nr][nc] === -1)
s.add(`${nr},${nc}`);
}
return s;
});
// Grow until each region hits its target (randomised round-robin)
let anyGrew = true;
while (anyGrew) {
anyGrew = false;
const order = shuffle(Array.from({ length: n }, (_, i) => i), rng);
for (const id of order) {
if (sizes[id] >= target) continue;
// Pick a random free frontier cell
const candidates = [...frontiers[id]].filter(k => {
const [r, c] = k.split(",").map(Number);
return grid[r][c] === -1;
});
if (!candidates.length) continue;
const k = candidates[Math.floor(rng() * candidates.length)];
const [nr, nc] = k.split(",").map(Number);
if (grid[nr][nc] !== -1) { frontiers[id].delete(k); continue; } // race condition
grid[nr][nc] = id;
sizes[id]++;
frontiers[id].delete(k);
anyGrew = true;
// Expand frontier
for (const [dr, dc] of ADJ) {
const nnr = nr+dr, nnc = nc+dc;
if (nnr>=0 && nnr<size && nnc>=0 && nnc<size && grid[nnr][nnc] === -1)
frontiers[id].add(`${nnr},${nnc}`);
}
}
}
// Flood-fill unassigned cells → smallest adjacent region
let changed = true;
while (changed) {
changed = false;
for (let r = 0; r < size; r++)
for (let c = 0; c < size; c++) {
if (grid[r][c] !== -1) continue;
let best = -1, bestSz = Infinity;
for (const [dr, dc] of ADJ) {
const nr = r+dr, nc = c+dc;
if (nr<0 || nr>=size || nc<0 || nc>=size) continue;
const id = grid[nr][nc];
if (id === -1) continue;
if (sizes[id] < bestSz) { bestSz = sizes[id]; best = id; }
}
if (best !== -1) { grid[r][c] = best; sizes[best]++; changed = true; }
}
}
return grid;
}
export function generatePatches(date: string): PatchesPuzzle {
const seed = date.split("-").reduce((a, n) => a * 1000 + parseInt(n), 0) + 333;
const rng = createRng(seed);
const size = 6;
// 7 or 8 regions → avg 4.55 cells each, with interesting shapes
const n = 7 + Math.floor(rng() * 2);
const seeds = placeSeeds(size, n, rng);
const grid = growPolyominos(size, seeds, rng);
const numRegions = Math.max(...grid.flat()) + 1;
// Collect cells per region
const regionCells = new Map<number, [number, number][]>();
for (let r = 0; r < size; r++)
for (let c = 0; c < size; c++) {
const id = grid[r][c];
if (!regionCells.has(id)) regionCells.set(id, []);
regionCells.get(id)!.push([r, c]);
}
const colors = shuffle([...PATCH_COLORS], rng);
const regions: Region[] = [];
for (let id = 0; id < numRegions; id++) {
const cells = regionCells.get(id) ?? [];
if (!cells.length) continue;
// Hint cell = topmost row, then leftmost col
const hintCell = cells.reduce((best, cur) =>
cur[0] < best[0] || (cur[0] === best[0] && cur[1] < best[1]) ? cur : best
);
// Normalised shape (origin at 0,0)
const minR = Math.min(...cells.map(([r]) => r));
const minC = Math.min(...cells.map(([, c]) => c));
const relCells: [number, number][] = cells.map(([r, c]) => [r - minR, c - minC]);
const previewRows = Math.max(...relCells.map(([r]) => r)) + 1;
const previewCols = Math.max(...relCells.map(([, c]) => c)) + 1;
regions.push({
id,
cells,
hintCell,
relCells,
previewRows,
previewCols,
size: cells.length,
color: colors[id % colors.length],
});
}
return { size, regions, grid };
}

769
generators/queens.ts Normal file
View file

@ -0,0 +1,769 @@
import { createRng, shuffle } from "../rng";
export interface QueensPuzzle {
size: number;
regions: number[][]; // regions[row][col] = regionId (0-indexed)
solution: [number, number][]; // [row, col] per queen
}
// ── solveQueens ────────────────────────────────────────────────────────────────
function solveQueens(size: number, rng: () => number): [number, number][] | null {
const cols: number[] = [];
const usedCols = new Set<number>();
const diag1 = new Set<number>();
const diag2 = new Set<number>();
const colOrder = shuffle(Array.from({ length: size }, (_, i) => i), rng);
function bt(row: number): boolean {
if (row === size) return true;
for (const col of colOrder) {
if (usedCols.has(col) || diag1.has(row - col) || diag2.has(row + col)) continue;
cols[row] = col;
usedCols.add(col); diag1.add(row - col); diag2.add(row + col);
if (bt(row + 1)) return true;
usedCols.delete(col); diag1.delete(row - col); diag2.delete(row + col);
}
return false;
}
if (!bt(0)) return null;
return cols.map((col, row) => [row, col]);
}
// ── buildRegions ───────────────────────────────────────────────────────────────
// Phase 1: organic BFS from each queen in sigma order, pure adjacency, capped at targetSize
// Phase 2: remaining cells → assigned to adjacent region with HIGHEST sigmaK
function buildRegions(size: number, queens: [number, number][], rng: () => number): number[][] {
const ADJ: [number, number][] = [[-1, 0], [1, 0], [0, -1], [0, 1]];
const regions: number[][] = Array.from({ length: size }, () => Array(size).fill(-1));
const regionSizes = new Int32Array(size);
const sigma = shuffle(Array.from({ length: size }, (_, i) => i), rng);
const sigmaK = new Int32Array(size);
for (let k = 0; k < size; k++) sigmaK[sigma[k]] = k;
const targetSize = size;
for (let k = 0; k < size; k++) {
const id = sigma[k];
const [qr, qc] = queens[id];
regions[qr][qc] = id;
regionSizes[id] = 1;
const queue: [number, number][] = [[qr, qc]];
let head = 0;
while (head < queue.length && regionSizes[id] < targetSize) {
const remaining = queue.length - head;
const pick = head + Math.floor(rng() * remaining);
[queue[head], queue[pick]] = [queue[pick], queue[head]];
const [r, c] = queue[head++];
for (const [dr, dc] of ADJ) {
const nr = r + dr, nc = c + dc;
if (nr < 0 || nr >= size || nc < 0 || nc >= size) continue;
if (regions[nr][nc] !== -1) continue;
regions[nr][nc] = id;
regionSizes[id]++;
queue.push([nr, nc]);
if (regionSizes[id] >= targetSize) break;
}
}
}
let changed = true;
while (changed) {
changed = false;
for (let r = 0; r < size; r++)
for (let c = 0; c < size; c++) {
if (regions[r][c] !== -1) continue;
let bestId = -1, bestK = -1;
for (const [dr, dc] of ADJ) {
const nr = r + dr, nc = c + dc;
if (nr < 0 || nr >= size || nc < 0 || nc >= size) continue;
const id = regions[nr][nc];
if (id === -1) continue;
const k = sigmaK[id];
if (k > bestK) { bestK = k; bestId = id; }
}
if (bestId !== -1) { regions[r][c] = bestId; regionSizes[bestId]++; changed = true; }
}
}
for (let r = 0; r < size; r++)
for (let c = 0; c < size; c++) {
if (regions[r][c] !== -1) continue;
let best = 0, bestDist = Infinity;
for (let id = 0; id < size; id++) {
const [qr, qc] = queens[id];
const d = Math.abs(qr - r) + Math.abs(qc - c);
if (d < bestDist) { bestDist = d; best = id; }
}
regions[r][c] = best;
}
return regions;
}
// ── getAllPlacements ───────────────────────────────────────────────────────────
// Enumerate valid Queens-game placements (one per row, one per col,
// no two consecutive-row queens king-adjacent: |col_r - col_{r+1}| > 1).
// For large N the count grows ~10× per step; use maxP + optional rng to get
// a random sample instead of the first-in-lex-order batch.
function getAllPlacements(size: number, maxP = Infinity, rng?: () => number): Int8Array[] {
const sols: Int8Array[] = [];
const uc = new Uint8Array(size);
const cols = new Array<number>(size);
// Per-row column order (shuffled when rng provided, for random sampling)
const colOrders: number[][] = Array.from({ length: size }, () => {
const order = Array.from({ length: size }, (_, i) => i);
if (rng) {
for (let i = order.length - 1; i > 0; i--) {
const j = (rng() * (i + 1)) | 0;
[order[i], order[j]] = [order[j], order[i]];
}
}
return order;
});
function bt(row: number): void {
if (sols.length >= maxP) return;
if (row === size) { sols.push(new Int8Array(cols)); return; }
for (const col of colOrders[row]) {
if (uc[col]) continue;
if (row > 0 && Math.abs(cols[row - 1] - col) <= 1) continue;
uc[col] = 1; cols[row] = col;
bt(row + 1);
uc[col] = 0;
if (sols.length >= maxP) return;
}
}
bt(0);
return sols;
}
// ── optimizeRegions ────────────────────────────────────────────────────────────
// Iterated Local Search: greedy hill-climbing with perturbation kicks.
//
// Theory: moving cell (r,c) from region A to B affects only placements where
// the queen in row r sits at column c.
// • vDestroyed: valid placements where region B already has another queen
// → moving queen-r to B creates a collision → placement becomes invalid.
// • aCreated: almost-valid placements (conflict=1) whose collision is in A
// AND region B is empty → moving queen-r from A to B resolves the collision.
// Accept swaps where vDestroyed > aCreated (net reduction in valid count).
// When stuck, apply random "kick" swaps, then resume greedy from the best state.
function optimizeRegions(
size: number,
regions: number[][],
queens: [number, number][],
placements: Int8Array[],
rng: () => number
): boolean {
const ADJ: [number, number][] = [[-1, 0], [1, 0], [0, -1], [0, 1]];
const P = placements.length;
// Queen cell lookup
const isQueen = new Uint8Array(size * size);
for (const [r, c] of queens) isQueen[r * size + c] = 1;
// Region cell lists — kept in sync after every swap
const regCells: [number, number][][] = Array.from({ length: size }, () => []);
for (let r = 0; r < size; r++)
for (let c = 0; c < size; c++)
regCells[regions[r][c]].push([r, c]);
// Reusable buffers
const conflictOf = new Uint8Array(P);
const visitBuf = new Uint8Array(size * size);
const queueBuf = new Int32Array(size * size);
function recomputeConflicts(): void {
for (let p = 0; p < P; p++) {
const pl = placements[p];
const regCount = new Uint8Array(size);
let conf = 0;
for (let r = 0; r < size; r++) {
const reg = regions[r][pl[r]];
if (++regCount[reg] === 2) { conf++; if (conf >= 2) break; }
}
conflictOf[p] = conf;
}
}
function countCurrentValid(): number {
let cnt = 0;
for (let p = 0; p < P; p++) if (conflictOf[p] === 0) cnt++;
return cnt;
}
function isConnectedWithout(regId: number, remR: number, remC: number): boolean {
const [qr, qc] = queens[regId];
const target = regCells[regId].length - 1;
if (target <= 1) return true;
visitBuf.fill(0);
const start = qr * size + qc;
visitBuf[start] = 1;
queueBuf[0] = start;
let head = 0, tail = 1, count = 1;
while (head < tail) {
const idx = queueBuf[head++];
const r = (idx / size) | 0, c = idx % size;
for (const [dr, dc] of ADJ) {
const nr = r + dr, nc = c + dc;
if (nr < 0 || nr >= size || nc < 0 || nc >= size) continue;
if (regions[nr][nc] !== regId || (nr === remR && nc === remC)) continue;
const key = nr * size + nc;
if (visitBuf[key]) continue;
visitBuf[key] = 1;
queueBuf[tail++] = key;
if (++count === target) return true;
}
}
return count === target;
}
function applySwap(r: number, c: number, newReg: number): void {
const oldReg = regions[r][c];
const idx = regCells[oldReg].findIndex(([rr, cc]) => rr === r && cc === c);
if (idx !== -1) regCells[oldReg].splice(idx, 1);
regCells[newReg].push([r, c]);
regions[r][c] = newReg;
}
// Run one pass of greedy hill-climbing.
// Returns number of improvements applied (0 = stuck).
function greedyPass(shuffledCells: [number, number][]): number {
recomputeConflicts();
const validIdx: number[] = [], almostIdx: number[] = [];
for (let p = 0; p < P; p++) {
if (conflictOf[p] === 0) validIdx.push(p);
else if (conflictOf[p] === 1) almostIdx.push(p);
}
if (validIdx.length <= 1) return 0; // done or impossible
const byCRValid: number[][] = Array.from({ length: size * size }, () => []);
const byCRAlmost: number[][] = Array.from({ length: size * size }, () => []);
for (const p of validIdx) {
const pl = placements[p];
for (let r = 0; r < size; r++) byCRValid[r * size + pl[r]].push(p);
}
for (const p of almostIdx) {
const pl = placements[p];
for (let r = 0; r < size; r++) byCRAlmost[r * size + pl[r]].push(p);
}
const pMask = new Int32Array(P);
for (const p of validIdx) {
const pl = placements[p];
let m = 0;
for (let r = 0; r < size; r++) m |= (1 << regions[r][pl[r]]);
pMask[p] = m;
}
for (const p of almostIdx) {
const pl = placements[p];
let m = 0;
for (let r = 0; r < size; r++) m |= (1 << regions[r][pl[r]]);
pMask[p] = m;
}
const almostColReg = new Int8Array(P).fill(-1);
for (const p of almostIdx) {
const pl = placements[p];
const regCount = new Uint8Array(size);
for (let r = 0; r < size; r++) {
const reg = regions[r][pl[r]];
if (++regCount[reg] === 2) { almostColReg[p] = reg; break; }
}
}
// Best-improvement: scan all cells, find globally best swap
let bestDelta = 0, bestR = -1, bestC = -1, bestNewReg = -1;
for (const [r, c] of shuffledCells) {
const oldReg = regions[r][c];
const adjRegs: number[] = [];
for (const [dr, dc] of ADJ) {
const nr = r + dr, nc = c + dc;
if (nr < 0 || nr >= size || nc < 0 || nc >= size) continue;
const ar = regions[nr][nc];
if (ar !== oldReg && !adjRegs.includes(ar)) adjRegs.push(ar);
}
if (adjRegs.length === 0 || !isConnectedWithout(oldReg, r, c)) continue;
const validHere = byCRValid[r * size + c];
const almostHere = byCRAlmost[r * size + c];
for (const newReg of adjRegs) {
let vDestroyed = 0;
for (const p of validHere) if ((pMask[p] >> newReg) & 1) vDestroyed++;
let aCreated = 0;
for (const p of almostHere)
if (almostColReg[p] === oldReg && !((pMask[p] >> newReg) & 1)) aCreated++;
const delta = vDestroyed - aCreated;
if (delta > bestDelta) { bestDelta = delta; bestR = r; bestC = c; bestNewReg = newReg; }
}
}
if (bestR === -1) return 0; // stuck
applySwap(bestR, bestC, bestNewReg);
return 1;
}
// Run greedy until stuck; return current valid count
function runGreedy(): number {
const cells: [number, number][] = [];
for (let r = 0; r < size; r++)
for (let c = 0; c < size; c++)
if (!isQueen[r * size + c]) cells.push([r, c]);
for (let round = 0; round < 300; round++) {
// Shuffle for variety
for (let i = cells.length - 1; i > 0; i--) {
const j = (rng() * (i + 1)) | 0;
[cells[i], cells[j]] = [cells[j], cells[i]];
}
recomputeConflicts();
const vc = countCurrentValid();
if (vc <= 1) return vc;
if (greedyPass(cells) === 0) break;
}
recomputeConflicts();
return countCurrentValid();
}
// Apply K random connectivity-preserving swaps (kick)
function kick(K: number): void {
const cells: [number, number][] = [];
for (let r = 0; r < size; r++)
for (let c = 0; c < size; c++)
if (!isQueen[r * size + c]) cells.push([r, c]);
// Shuffle
for (let i = cells.length - 1; i > 0; i--) {
const j = (rng() * (i + 1)) | 0;
[cells[i], cells[j]] = [cells[j], cells[i]];
}
let applied = 0;
for (const [r, c] of cells) {
if (applied >= K) break;
const oldReg = regions[r][c];
const adjRegs: number[] = [];
for (const [dr, dc] of ADJ) {
const nr = r + dr, nc = c + dc;
if (nr < 0 || nr >= size || nc < 0 || nc >= size) continue;
const ar = regions[nr][nc];
if (ar !== oldReg && !adjRegs.includes(ar)) adjRegs.push(ar);
}
if (adjRegs.length === 0 || !isConnectedWithout(oldReg, r, c)) continue;
const newReg = adjRegs[(rng() * adjRegs.length) | 0];
applySwap(r, c, newReg);
applied++;
}
}
// Snapshot helpers
function snapshot(): number[][] {
return regions.map(row => [...row]);
}
function restore(snap: number[][]): void {
for (let r = 0; r < size; r++)
for (let c = 0; c < size; c++)
regions[r][c] = snap[r][c];
regCells.forEach(rc => rc.length = 0);
for (let r = 0; r < size; r++)
for (let c = 0; c < size; c++)
regCells[regions[r][c]].push([r, c]);
}
// Scale kick budget with sample size: more kicks for smaller P (more reliable)
const MAX_KICKS = P <= 10000 ? 15 : P <= 100000 ? 40 : 20;
// ILS: run greedy, kick when stuck, restore best if worsened
let bestSnap = snapshot();
let bestValid = runGreedy();
if (bestValid <= 1) return bestValid === 1;
for (let k = 0; k < MAX_KICKS; k++) {
restore(bestSnap);
kick(2 + (k % 3)); // vary kick size 2-4
const vc = runGreedy();
if (vc === 1) return true;
if (vc < bestValid) {
bestValid = vc;
bestSnap = snapshot();
}
}
restore(bestSnap);
recomputeConflicts();
return countCurrentValid() === 1;
}
// ── optimizeRegionsLargeN ──────────────────────────────────────────────────────
// Solution-separation optimizer for large N (≥10) where enumerating all
// placements is too expensive.
//
// Strategy: repeatedly find a spurious solution S2 (via backtracking) and apply
// a targeted "separation swap" that creates a region conflict for S2 without
// breaking the intended solution S1. When no targeted swap exists, apply a
// random kick, then retry.
function optimizeRegionsLargeN(
size: number,
regions: number[][],
queens: [number, number][],
solution: [number, number][],
rng: () => number
): boolean {
const ADJ: [number, number][] = [[-1, 0], [1, 0], [0, -1], [0, 1]];
const isQueen = new Uint8Array(size * size);
for (const [r, c] of queens) isQueen[r * size + c] = 1;
const regCells: [number, number][][] = Array.from({ length: size }, () => []);
for (let r = 0; r < size; r++)
for (let c = 0; c < size; c++)
regCells[regions[r][c]].push([r, c]);
const visitBuf = new Uint8Array(size * size);
const queueBuf = new Int32Array(size * size);
function isConnectedWithout(regId: number, remR: number, remC: number): boolean {
const [qr, qc] = queens[regId];
const target = regCells[regId].length - 1;
if (target <= 1) return true;
visitBuf.fill(0);
const start = qr * size + qc;
visitBuf[start] = 1; queueBuf[0] = start;
let head = 0, tail = 1, count = 1;
while (head < tail) {
const idx = queueBuf[head++];
const r = (idx / size) | 0, c = idx % size;
for (const [dr, dc] of ADJ) {
const nr = r + dr, nc = c + dc;
if (nr < 0 || nr >= size || nc < 0 || nc >= size) continue;
if (regions[nr][nc] !== regId || (nr === remR && nc === remC)) continue;
const key = nr * size + nc;
if (visitBuf[key]) continue;
visitBuf[key] = 1; queueBuf[tail++] = key;
if (++count === target) return true;
}
}
return count === target;
}
function applySwap(r: number, c: number, newReg: number): void {
const oldReg = regions[r][c];
const idx = regCells[oldReg].findIndex(([rr, cc]) => rr === r && cc === c);
if (idx !== -1) regCells[oldReg].splice(idx, 1);
regCells[newReg].push([r, c]);
regions[r][c] = newReg;
}
// Find a spurious (non-solution) valid placement via backtracking.
// Returns the spurious placement's column array, or null if unique.
const solCols = solution.map(([, c]) => c);
function findSpurious(): number[] | null {
const uc = new Uint8Array(size), ur = new Uint8Array(size);
const pR = new Int32Array(size), pC = new Int32Array(size);
let found: number[] | null = null, depth = 0;
function bt(row: number): void {
if (found) return;
if (row === size) {
for (let r = 0; r < size; r++) if (pC[r] !== solCols[r]) { found = [...pC]; return; }
return; // this IS the game solution
}
for (let col = 0; col < size; col++) {
if (uc[col]) continue;
const reg = regions[row][col];
if (ur[reg]) continue;
let adj = false;
for (let i = 0; i < depth; i++)
if (Math.abs(row - pR[i]) <= 1 && Math.abs(col - pC[i]) <= 1) { adj = true; break; }
if (adj) continue;
uc[col] = 1; ur[reg] = 1; pR[depth] = row; pC[depth] = col; depth++;
bt(row + 1);
depth--; uc[col] = 0; ur[reg] = 0;
if (found) return;
}
}
bt(0);
return found;
}
// Try to separate spurious S2 from the game solution by swapping a cell
// at position (r, S2[r]) — where S2's queen sits but S1's doesn't — into
// an adjacent region already occupied by S2, creating a collision for S2.
function eliminate(s2cols: number[]): boolean {
const s2regSet = new Set(s2cols.map((c, r) => regions[r][c]));
// Shuffle row order for variety
const rows = Array.from({ length: size }, (_, i) => i);
for (let i = rows.length - 1; i > 0; i--) {
const j = (rng() * (i + 1)) | 0;
[rows[i], rows[j]] = [rows[j], rows[i]];
}
for (const r of rows) {
const c = s2cols[r];
if (solCols[r] === c) continue; // S1 also has queen here — skip
if (isQueen[r * size + c]) continue; // it's the game queen cell — never move
const oldReg = regions[r][c];
// Check connectivity before the loop (avoid redundant BFS per adjacent region)
if (!isConnectedWithout(oldReg, r, c)) continue;
for (const [dr, dc] of ADJ) {
const nr = r + dr, nc = c + dc;
if (nr < 0 || nr >= size || nc < 0 || nc >= size) continue;
const adjReg = regions[nr][nc];
if (adjReg === oldReg) continue;
// adjReg is occupied in S2 → moving (r,c) to adjReg creates a collision for S2
if (s2regSet.has(adjReg)) {
applySwap(r, c, adjReg);
return true;
}
}
}
return false;
}
// Random connectivity-preserving kick
function kick(K: number): void {
const cells: [number, number][] = [];
for (let r = 0; r < size; r++)
for (let c = 0; c < size; c++)
if (!isQueen[r * size + c]) cells.push([r, c]);
for (let i = cells.length - 1; i > 0; i--) {
const j = (rng() * (i + 1)) | 0;
[cells[i], cells[j]] = [cells[j], cells[i]];
}
let applied = 0;
for (const [r, c] of cells) {
if (applied >= K) break;
const oldReg = regions[r][c];
const adjRegs: number[] = [];
for (const [dr, dc] of ADJ) {
const nr = r + dr, nc = c + dc;
if (nr < 0 || nr >= size || nc < 0 || nc >= size) continue;
const ar = regions[nr][nc];
if (ar !== oldReg && !adjRegs.includes(ar)) adjRegs.push(ar);
}
if (adjRegs.length === 0 || !isConnectedWithout(oldReg, r, c)) continue;
applySwap(r, c, adjRegs[(rng() * adjRegs.length) | 0]);
applied++;
}
}
const MAX_ITERS = 300;
let kicksWithoutProgress = 0;
for (let iter = 0; iter < MAX_ITERS; iter++) {
const s2 = findSpurious();
if (!s2) return true; // unique!
if (!eliminate(s2)) {
kick(2 + (kicksWithoutProgress % 3));
kicksWithoutProgress++;
if (kicksWithoutProgress > 30) return false; // hopelessly stuck
} else {
kicksWithoutProgress = 0;
}
}
return false;
}
// ── genCombinations ───────────────────────────────────────────────────────────
function* genCombinations(arr: number[], n: number, start = 0): Generator<number[]> {
if (n === 0) { yield []; return; }
for (let i = start; i <= arr.length - n; i++)
for (const rest of genCombinations(arr, n - 1, i + 1))
yield [arr[i], ...rest];
}
// ── isLogicallySolvable ────────────────────────────────────────────────────────
// Returns true iff the puzzle can be solved by pure constraint propagation
// (no guessing). Same rules as the hint finder in QueensBoard.tsx.
function isLogicallySolvable(size: number, regions: number[][]): boolean {
const poss = new Uint8Array(size * size).fill(1);
const doneRows = new Uint8Array(size);
const doneRegs = new Uint8Array(size);
function placeQueen(r: number, c: number) {
const reg = regions[r][c];
doneRows[r] = 1;
doneRegs[reg] = 1;
for (let i = 0; i < size; i++) {
poss[r * size + i] = 0; // same row
poss[i * size + c] = 0; // same col
}
for (let rr = 0; rr < size; rr++)
for (let cc = 0; cc < size; cc++)
if (regions[rr][cc] === reg || (Math.abs(rr - r) <= 1 && Math.abs(cc - c) <= 1))
poss[rr * size + cc] = 0;
}
let placed = 0;
let progress = true;
while (placed < size && progress) {
progress = false;
// Rules 1-3: naked singles (region / row / col)
for (let id = 0; id < size; id++) {
if (doneRegs[id]) continue;
let fr = -1, fc = -1, cnt = 0;
for (let r = 0; r < size; r++)
for (let c = 0; c < size; c++)
if (regions[r][c] === id && poss[r * size + c]) { fr = r; fc = c; cnt++; }
if (cnt === 1) { placeQueen(fr, fc); placed++; progress = true; }
}
for (let r = 0; r < size; r++) {
if (doneRows[r]) continue;
let fc = -1, cnt = 0;
for (let c = 0; c < size; c++) if (poss[r * size + c]) { fc = c; cnt++; }
if (cnt === 1 && fc !== -1) { placeQueen(r, fc); placed++; progress = true; }
}
for (let c = 0; c < size; c++) {
let fr = -1, cnt = 0;
for (let r = 0; r < size; r++) if (!doneRows[r] && poss[r * size + c]) { fr = r; cnt++; }
if (cnt === 1 && fr !== -1) { placeQueen(fr, c); placed++; progress = true; }
}
// Rule 4: N-subset — N regions confined to N rows or N cols
if (!progress) {
const unreg: number[] = [];
for (let id = 0; id < size; id++) {
if (doneRegs[id]) continue;
let ok = false;
for (let r = 0; r < size && !ok; r++)
for (let c = 0; c < size && !ok; c++)
if (regions[r][c] === id && poss[r * size + c]) ok = true;
if (ok) unreg.push(id);
}
outer:
for (let n = 1; n < unreg.length; n++) {
for (const combo of genCombinations(unreg, n)) {
const comboSet = new Set(combo);
const rowSet = new Set<number>(), colSet = new Set<number>();
for (const id of combo)
for (let r = 0; r < size; r++)
for (let c = 0; c < size; c++)
if (regions[r][c] === id && poss[r * size + c]) { rowSet.add(r); colSet.add(c); }
if (rowSet.size === n) {
let changed = false;
for (const row of rowSet)
for (let c = 0; c < size; c++)
if (poss[row * size + c] && !comboSet.has(regions[row][c])) { poss[row * size + c] = 0; changed = true; }
if (changed) { progress = true; break outer; }
}
if (colSet.size === n) {
let changed = false;
for (const col of colSet)
for (let r = 0; r < size; r++)
if (poss[r * size + col] && !comboSet.has(regions[r][col])) { poss[r * size + col] = 0; changed = true; }
if (changed) { progress = true; break outer; }
}
}
}
}
}
return placed === size;
}
// ── isUnique ───────────────────────────────────────────────────────────────────
// Backtracking check — kept as fallback when optimizeRegions cannot find uniqueness.
function isUnique(size: number, regions: number[][]): boolean {
const usedCols = new Uint8Array(size);
const usedRegs = new Uint8Array(size);
const placedR = new Int32Array(size);
const placedC = new Int32Array(size);
let depth = 0;
let count = 0;
function bt(row: number): void {
if (count > 1) return;
if (row === size) { count++; return; }
for (let col = 0; col < size; col++) {
if (usedCols[col]) continue;
const reg = regions[row][col];
if (usedRegs[reg]) continue;
let adj = false;
for (let i = 0; i < depth; i++)
if (Math.abs(row - placedR[i]) <= 1 && Math.abs(col - placedC[i]) <= 1) { adj = true; break; }
if (adj) continue;
usedCols[col] = 1; usedRegs[reg] = 1;
placedR[depth] = row; placedC[depth] = col; depth++;
bt(row + 1);
depth--; usedCols[col] = 0; usedRegs[reg] = 0;
if (count > 1) return;
}
}
bt(0);
return count === 1;
}
// ── Public exports ─────────────────────────────────────────────────────────────
export function queensSizeForDate(date: string): number {
const start = new Date("2024-01-01").getTime();
const d = new Date(date).getTime();
const days = Math.max(0, Math.floor((d - start) / 86400000));
if (days < 100) return 6;
if (days < 400) return 7;
if (days < 900) return 8;
if (days < 1400) return 9;
return 10;
}
export function generateQueens(date: string, size?: number): QueensPuzzle {
const resolvedSize = size ?? queensSizeForDate(date);
const baseSeed = date.split("-").reduce((a, n) => a * 1000 + parseInt(n), 0);
// N ≤ 9: enumerate all placements once (fast).
// N ≥ 10: use solution-separation optimizer (no placements enumeration needed).
const largeN = resolvedSize >= 10;
const allPlacements = largeN ? null : getAllPlacements(resolvedSize);
// Tier 1: unique + logically solvable (no guessing needed)
// Tier 2: unique only (may require guessing — hint finder will always find something)
let fallbackUnique: QueensPuzzle | null = null;
const MAX_ATTEMPTS = largeN ? 30 : 20;
for (let attempt = 0; attempt < MAX_ATTEMPTS; attempt++) {
const rng1 = createRng(baseSeed + attempt * 1000003);
const solution = solveQueens(resolvedSize, rng1)!;
const regions = buildRegions(resolvedSize, solution, rng1);
// Deep-copy regions before optimization (mutates in-place)
const regCopy = regions.map(row => [...row]);
const rng2 = createRng(baseSeed + attempt * 999983 + 42);
const optimized = largeN
? optimizeRegionsLargeN(resolvedSize, regCopy, solution, solution, rng2)
: optimizeRegions(resolvedSize, regCopy, solution, allPlacements!, rng2);
if (optimized) {
// Best case: unique + logically solvable
if (isLogicallySolvable(resolvedSize, regCopy)) {
return { size: resolvedSize, regions: regCopy, solution };
}
// Keep as tier-2 fallback (unique but may need guessing)
if (!fallbackUnique) fallbackUnique = { size: resolvedSize, regions: regCopy, solution };
}
// Also save non-optimized unique puzzles as tier-2 fallback
if (!fallbackUnique && isUnique(resolvedSize, regions)) {
fallbackUnique = { size: resolvedSize, regions, solution };
}
}
if (fallbackUnique) return fallbackUnique;
// Absolute last resort (should never reach here in practice)
const rng = createRng(baseSeed);
const solution = solveQueens(resolvedSize, rng)!;
const regions = buildRegions(resolvedSize, solution, rng);
return { size: resolvedSize, regions, solution };
}
export const QUEEN_COLORS = [
"#e63946", "#f4a261", "#2a9d8f", "#457b9d",
"#6a4c93", "#264653", "#e9c46a", "#8ecae6",
];

93
generators/sudoku.ts Normal file
View file

@ -0,0 +1,93 @@
import { createRng, shuffle } from "../rng";
export interface SudokuPuzzle {
size: 6;
// 0 = empty, 1-6 = given
given: number[][];
solution: number[][];
}
function isValid(grid: number[][], r: number, c: number, val: number): boolean {
for (let i = 0; i < 6; i++) {
if (grid[r][i] === val || grid[i][c] === val) return false;
}
// 2x3 box
const br = Math.floor(r / 2) * 2, bc = Math.floor(c / 3) * 3;
for (let dr = 0; dr < 2; dr++)
for (let dc = 0; dc < 3; dc++)
if (grid[br + dr][bc + dc] === val) return false;
return true;
}
function solve(grid: number[][], rng: () => number): boolean {
for (let r = 0; r < 6; r++) {
for (let c = 0; c < 6; c++) {
if (grid[r][c] !== 0) continue;
const nums = shuffle([1, 2, 3, 4, 5, 6], rng);
for (const n of nums) {
if (!isValid(grid, r, c, n)) continue;
grid[r][c] = n;
if (solve(grid, rng)) return true;
grid[r][c] = 0;
}
return false;
}
}
return true;
}
function countSolutions(grid: number[][], limit = 2): number {
let count = 0;
function bt(): boolean {
for (let r = 0; r < 6; r++) {
for (let c = 0; c < 6; c++) {
if (grid[r][c] !== 0) continue;
for (let n = 1; n <= 6; n++) {
if (!isValid(grid, r, c, n)) continue;
grid[r][c] = n;
bt();
grid[r][c] = 0;
if (count >= limit) return true;
}
return false;
}
}
count++;
return count >= limit;
}
bt();
return count;
}
export function generateSudoku(date: string): SudokuPuzzle {
const seed = date.split("-").reduce((a, n) => a * 1000 + parseInt(n), 0) + 555;
const rng = createRng(seed);
const grid: number[][] = Array.from({ length: 6 }, () => Array(6).fill(0));
solve(grid, rng);
const solution = grid.map(r => [...r]);
// Remove cells while puzzle remains uniquely solvable
const positions = shuffle(
Array.from({ length: 36 }, (_, i) => [Math.floor(i / 6), i % 6] as [number, number]),
rng
);
const given = solution.map(r => [...r]);
let removed = 0;
for (const [r, c] of positions) {
if (removed >= 22) break; // keep ~14 givens in a 6x6
const val = given[r][c];
given[r][c] = 0;
// Quick check: still solvable
const test = given.map(row => [...row]);
if (countSolutions(test) !== 1) {
given[r][c] = val; // restore
} else {
removed++;
}
}
return { size: 6, given, solution };
}

184
generators/tango.ts Normal file
View file

@ -0,0 +1,184 @@
import { createRng, shuffle } from "../rng";
export type Cell = "sun" | "moon" | null;
export type EdgeConstraint = "=" | "x" | null;
export interface TangoPuzzle {
size: number; // always 6
given: Cell[][];
hEdges: EdgeConstraint[][];
vEdges: EdgeConstraint[][];
solution: Cell[][];
}
// Check if placing v at (r,c) is consistent with current partial grid
function consistent(
grid: Cell[][], hEdges: EdgeConstraint[][], vEdges: EdgeConstraint[][],
r: number, c: number, v: Cell, size: number
): boolean {
// Row balance
let rs = 0, rm = 0;
for (let j = 0; j < size; j++) {
const cell = j === c ? v : grid[r][j];
if (cell === "sun") rs++; else if (cell === "moon") rm++;
}
if (rs > size / 2 || rm > size / 2) return false;
// Col balance
let cs = 0, cm = 0;
for (let i = 0; i < size; i++) {
const cell = i === r ? v : grid[i][c];
if (cell === "sun") cs++; else if (cell === "moon") cm++;
}
if (cs > size / 2 || cm > size / 2) return false;
// No 3 consecutive in row
for (let j = Math.max(0, c - 2); j <= Math.min(size - 3, c); j++) {
const a = j === c ? v : grid[r][j];
const b = j + 1 === c ? v : grid[r][j + 1];
const d = j + 2 === c ? v : grid[r][j + 2];
if (a && a === b && b === d) return false;
}
// No 3 consecutive in col
for (let i = Math.max(0, r - 2); i <= Math.min(size - 3, r); i++) {
const a = i === r ? v : grid[i][c];
const b = i + 1 === r ? v : grid[i + 1][c];
const d = i + 2 === r ? v : grid[i + 2][c];
if (a && a === b && b === d) return false;
}
// Edge constraints (only check placed neighbours)
const chk = (e: EdgeConstraint, nb: Cell) => {
if (!e || !nb) return true;
if (e === "=" && nb !== v) return false;
if (e === "x" && nb === v) return false;
return true;
};
if (!chk(c > 0 ? hEdges[r][c - 1] : null, grid[r][c - 1])) return false;
if (!chk(c < size - 1 ? hEdges[r][c] : null, grid[r][c + 1])) return false;
if (!chk(r > 0 ? vEdges[r - 1][c] : null, grid[r - 1]?.[c] ?? null)) return false;
if (!chk(r < size - 1 ? vEdges[r][c] : null, grid[r + 1]?.[c] ?? null)) return false;
// Unique rows: if this row is now complete, check it doesn't duplicate an earlier complete row
const rowComplete = grid[r].every((cell, j) => (j === c ? v : cell) !== null);
if (rowComplete) {
const thisRow = grid[r].map((cell, j) => j === c ? v : cell);
for (let i = 0; i < r; i++) {
if (grid[i].every(cell => cell !== null) &&
grid[i].every((cell, j) => cell === thisRow[j])) return false;
}
}
// Unique cols: if this col is now complete, check it doesn't duplicate an earlier complete col
const colComplete = Array.from({ length: size }, (_, i) => i === r ? v : grid[i][c]).every(cell => cell !== null);
if (colComplete) {
const thisCol = Array.from({ length: size }, (_, i) => i === r ? v : grid[i][c]);
for (let j = 0; j < c; j++) {
const other = Array.from({ length: size }, (_, i) => grid[i][j]);
if (other.every(cell => cell !== null) &&
other.every((cell, i) => cell === thisCol[i])) return false;
}
}
return true;
}
// Count solutions (stops at `limit`)
function countSolutions(
given: Cell[][], hEdges: EdgeConstraint[][], vEdges: EdgeConstraint[][],
size: number, limit = 2
): number {
const grid = given.map(r => [...r]);
let count = 0;
function bt(pos: number): void {
if (count >= limit) return;
if (pos === size * size) { count++; return; }
const r = Math.floor(pos / size), c = pos % size;
if (grid[r][c] !== null) { bt(pos + 1); return; }
for (const v of ["sun", "moon"] as Cell[]) {
if (consistent(grid, hEdges, vEdges, r, c, v, size)) {
grid[r][c] = v;
bt(pos + 1);
grid[r][c] = null;
}
}
}
bt(0);
return count;
}
function generateSolution(size: number, rng: () => number): Cell[][] {
const grid: Cell[][] = Array.from({ length: size }, () => Array(size).fill(null));
const noHEdges: EdgeConstraint[][] = Array.from({ length: size }, () => Array(size - 1).fill(null));
const noVEdges: EdgeConstraint[][] = Array.from({ length: size - 1 }, () => Array(size).fill(null));
function bt(pos: number): boolean {
if (pos === size * size) return true;
const r = Math.floor(pos / size), c = pos % size;
const opts: Cell[] = rng() > 0.5 ? ["sun", "moon"] : ["moon", "sun"];
for (const v of opts) {
if (consistent(grid, noHEdges, noVEdges, r, c, v, size)) {
grid[r][c] = v;
if (bt(pos + 1)) return true;
grid[r][c] = null;
}
}
return false;
}
bt(0);
return grid;
}
export function generateTango(date: string): TangoPuzzle {
const seed = date.split("-").reduce((a, n) => a * 1000 + parseInt(n), 0) + 999;
const rng = createRng(seed);
const size = 6;
const solution = generateSolution(size, rng);
// Start with ALL edges as constraints
const hEdges: EdgeConstraint[][] = Array.from({ length: size }, () => Array(size - 1).fill(null));
const vEdges: EdgeConstraint[][] = Array.from({ length: size - 1 }, () => Array(size).fill(null));
for (let r = 0; r < size; r++)
for (let c = 0; c < size - 1; c++)
hEdges[r][c] = solution[r][c] === solution[r][c + 1] ? "=" : "x";
for (let r = 0; r < size - 1; r++)
for (let c = 0; c < size; c++)
vEdges[r][c] = solution[r][c] === solution[r + 1][c] ? "=" : "x";
// Build shuffled edge list
const edges: Array<{ type: "h" | "v"; r: number; c: number }> = [];
for (let r = 0; r < size; r++)
for (let c = 0; c < size - 1; c++) edges.push({ type: "h", r, c });
for (let r = 0; r < size - 1; r++)
for (let c = 0; c < size; c++) edges.push({ type: "v", r, c });
const given: Cell[][] = Array.from({ length: size }, () => Array(size).fill(null));
// Place given cells first (like LinkedIn: ~9-11 pre-placed sun/moon values)
const allCells: [number, number][] = [];
for (let r = 0; r < size; r++) for (let c = 0; c < size; c++) allCells.push([r, c]);
const cellOrder = shuffle(allCells, rng);
const targetGivens = 9 + Math.floor(rng() * 3); // 911 given cells
for (const [r, c] of cellOrder) {
if (given.flat().filter(Boolean).length >= targetGivens) break;
given[r][c] = solution[r][c];
if (countSolutions(given, hEdges, vEdges, size) > 1) given[r][c] = null;
}
// Remove edges greedily while maintaining unique solution
for (const e of shuffle(edges, rng)) {
if (e.type === "h") {
const old = hEdges[e.r][e.c];
hEdges[e.r][e.c] = null;
if (countSolutions(given, hEdges, vEdges, size) !== 1) hEdges[e.r][e.c] = old;
} else {
const old = vEdges[e.r][e.c];
vEdges[e.r][e.c] = null;
if (countSolutions(given, hEdges, vEdges, size) !== 1) vEdges[e.r][e.c] = old;
}
}
return { size, given, hEdges, vEdges, solution };
}

254
generators/zip.ts Normal file
View file

@ -0,0 +1,254 @@
import { createRng, shuffle } from "../rng";
export interface ZipPuzzle {
size: number;
path: [number, number][]; // solution path (all cells in order)
numberedCells: Record<string, number>; // "r,c" → waypoint number (1-based)
// walls[r][c] bitmask: bit0=right wall, bit1=bottom wall, bit2=left wall, bit3=top wall
walls: number[][];
}
export function canMove(walls: number[][], r1: number, c1: number, r2: number, c2: number): boolean {
const dr = r2 - r1, dc = c2 - c1;
if (dr === 0 && dc === 1) return !(walls[r1][c1] & 1); // right
if (dr === 1 && dc === 0) return !(walls[r1][c1] & 2); // down
if (dr === 0 && dc === -1) return !(walls[r1][c1] & 4); // left
if (dr === -1 && dc === 0) return !(walls[r1][c1] & 8); // up
return false;
}
function generateHamiltonianPath(size: number, rng: () => number): [number, number][] | null {
const total = size * size;
const visited = Array.from({ length: size }, () => Array(size).fill(false));
const path: [number, number][] = [];
const dirs: [number, number][] = [[-1, 0], [1, 0], [0, -1], [0, 1]];
const startR = Math.floor(rng() * size);
const startC = Math.floor(rng() * size);
visited[startR][startC] = true;
path.push([startR, startC]);
// Warnsdorff heuristic: prefer neighbors with fewer onward moves
function warnsdorffScore(r: number, c: number): number {
let n = 0;
for (const [dr, dc] of dirs) {
const nr = r + dr, nc = c + dc;
if (nr >= 0 && nr < size && nc >= 0 && nc < size && !visited[nr][nc]) n++;
}
return n;
}
function bt(): boolean {
if (path.length === total) return true;
const [r, c] = path[path.length - 1];
const nbrs = shuffle([...dirs], rng)
.map(([dr, dc]) => [r + dr, c + dc] as [number, number])
.filter(([nr, nc]) => nr >= 0 && nr < size && nc >= 0 && nc < size && !visited[nr][nc])
.sort((a, b) => warnsdorffScore(a[0], a[1]) - warnsdorffScore(b[0], b[1]));
for (const [nr, nc] of nbrs) {
visited[nr][nc] = true;
path.push([nr, nc]);
if (bt()) return true;
path.pop();
visited[nr][nc] = false;
}
return false;
}
return bt() ? path : null;
}
// Build walls on ALL non-path-adjacent edges
function buildAllWalls(size: number, path: [number, number][]): number[][] {
const openEdges = new Set<string>();
for (let i = 0; i < path.length - 1; i++) {
const [r1, c1] = path[i], [r2, c2] = path[i + 1];
const key = r1 === r2
? `h:${r1},${Math.min(c1, c2)}`
: `v:${Math.min(r1, r2)},${c1}`;
openEdges.add(key);
}
const walls: number[][] = Array.from({ length: size }, () => Array(size).fill(0));
for (let r = 0; r < size; r++) {
for (let c = 0; c < size; c++) {
if (c < size - 1 && !openEdges.has(`h:${r},${c}`)) {
walls[r][c] |= 1;
walls[r][c + 1] |= 4;
}
if (r < size - 1 && !openEdges.has(`v:${r},${c}`)) {
walls[r][c] |= 2;
walls[r + 1][c] |= 8;
}
}
}
return walls;
}
// Count Hamiltonian paths visiting waypoints in order — stops at limit
function countZipSolutions(
size: number,
walls: number[][],
numberedCells: Record<string, number>,
limit = 2
): number {
const waypointOrder = Object.entries(numberedCells)
.sort(([, a], [, b]) => a - b)
.map(([k]) => k);
if (!waypointOrder.length) return 0;
const [startR, startC] = waypointOrder[0].split(",").map(Number);
const total = size * size;
let count = 0;
const visited = Array.from({ length: size }, () => Array(size).fill(false));
visited[startR][startC] = true;
let visitedCount = 1;
const dirs: [number, number][] = [[-1, 0], [1, 0], [0, -1], [0, 1]];
// Island pruning: BFS from (r,c) through unvisited cells — if unreachable cells exist, prune
const bfsQueue = new Int32Array(total);
const bfsSeen = new Uint8Array(total);
function allUnvisitedReachable(r: number, c: number): boolean {
const remaining = total - visitedCount;
if (remaining === 0) return true;
bfsSeen.fill(0);
let head = 0, tail = 0, found = 0;
bfsQueue[tail++] = r * size + c;
bfsSeen[r * size + c] = 1;
while (head < tail) {
const idx = bfsQueue[head++];
const cr = (idx / size) | 0, cc = idx % size;
for (const [dr, dc] of dirs) {
const nr = cr + dr, nc = cc + dc;
if (nr < 0 || nr >= size || nc < 0 || nc >= size) continue;
if (visited[nr][nc]) continue;
if (!canMove(walls, cr, cc, nr, nc)) continue;
const nk = nr * size + nc;
if (bfsSeen[nk]) continue;
bfsSeen[nk] = 1;
bfsQueue[tail++] = nk;
found++;
if (found === remaining) return true;
}
}
return found === remaining;
}
function dfs(r: number, c: number, nextWpIdx: number): void {
if (count >= limit) return;
if (visitedCount === total) {
if (nextWpIdx === waypointOrder.length) count++;
return;
}
for (const [dr, dc] of dirs) {
const nr = r + dr, nc = c + dc;
if (nr < 0 || nr >= size || nc < 0 || nc >= size) continue;
if (visited[nr][nc]) continue;
if (!canMove(walls, r, c, nr, nc)) continue;
const nextKey = `${nr},${nc}`;
const wpNum = numberedCells[nextKey];
// If the next cell is a waypoint, it must be the expected one
if (wpNum !== undefined) {
if (nextWpIdx >= waypointOrder.length || waypointOrder[nextWpIdx] !== nextKey) continue;
}
visited[nr][nc] = true;
visitedCount++;
if (allUnvisitedReachable(nr, nc)) {
dfs(nr, nc, wpNum !== undefined ? nextWpIdx + 1 : nextWpIdx);
}
visited[nr][nc] = false;
visitedCount--;
}
}
dfs(startR, startC, 1); // already at waypoint[0], so next expected is index 1
return count;
}
// Remove walls while uniqueness holds → sparse, interesting puzzle
function buildMinimalWalls(
size: number,
path: [number, number][],
numberedCells: Record<string, number>,
rng: () => number
): number[][] {
const walls = buildAllWalls(size, path);
// Collect all removable walls (non-path-adjacent edges)
const candidates: Array<{ r1: number; c1: number; r2: number; c2: number }> = [];
for (let r = 0; r < size; r++) {
for (let c = 0; c < size; c++) {
if (c < size - 1 && walls[r][c] & 1) candidates.push({ r1: r, c1: c, r2: r, c2: c + 1 });
if (r < size - 1 && walls[r][c] & 2) candidates.push({ r1: r, c1: c, r2: r + 1, c2: c });
}
}
for (const { r1, c1, r2, c2 } of shuffle(candidates, rng)) {
const dc = c2 - c1;
// Remove wall
if (dc === 1) { walls[r1][c1] &= ~1; walls[r2][c2] &= ~4; }
else { walls[r1][c1] &= ~2; walls[r2][c2] &= ~8; }
if (countZipSolutions(size, walls, numberedCells) !== 1) {
// Restore wall — this wall is necessary
if (dc === 1) { walls[r1][c1] |= 1; walls[r2][c2] |= 4; }
else { walls[r1][c1] |= 2; walls[r2][c2] |= 8; }
}
}
return walls;
}
export function zipSizeForDate(date: string): number {
const start = new Date("2024-01-01").getTime();
const days = Math.max(0, Math.floor((new Date(date).getTime() - start) / 86400000));
if (days < 200) return 6;
if (days < 500) return 7;
return 8;
}
export function generateZip(date: string, size?: number): ZipPuzzle {
const resolvedSize = size ?? zipSizeForDate(date);
const seed = date.split("-").reduce((a, n) => a * 1000 + parseInt(n), 0) + 777;
const rng = createRng(seed);
let path: [number, number][] | null = null;
let attempts = 0;
while (!path && attempts < 30) {
path = generateHamiltonianPath(resolvedSize, rng);
attempts++;
}
if (!path) path = snakePath(resolvedSize);
// LinkedIn Zip always has exactly 10 waypoints
const total = path.length;
const numWaypoints = 10;
const step = Math.floor((total - 1) / (numWaypoints - 1));
const waypointIndices = Array.from({ length: numWaypoints }, (_, i) =>
i === numWaypoints - 1 ? total - 1 : i * step
);
const numberedCells: Record<string, number> = {};
waypointIndices.forEach((idx, i) => {
const [r, c] = path![idx];
numberedCells[`${r},${c}`] = i + 1;
});
const walls = buildMinimalWalls(resolvedSize, path, numberedCells, rng);
return { size: resolvedSize, path, numberedCells, walls };
}
function snakePath(size: number): [number, number][] {
const path: [number, number][] = [];
for (let r = 0; r < size; r++)
for (let c = r % 2 === 0 ? 0 : size - 1; r % 2 === 0 ? c < size : c >= 0; r % 2 === 0 ? c++ : c--)
path.push([r, c]);
return path;
}

141
globals.css Normal file
View file

@ -0,0 +1,141 @@
@import "tailwindcss";
/* ── Design tokens ──────────────────────────────────────────────────────────── */
:root {
--radius-sm: 6px;
--radius-md: 12px;
--radius-lg: 20px;
--radius-full: 9999px;
--shadow-card: 0 1px 3px 0 rgb(0 0 0 / 0.06), 0 1px 2px -1px rgb(0 0 0 / 0.04);
--shadow-hover: 0 4px 12px 0 rgb(0 0 0 / 0.08), 0 2px 4px -1px rgb(0 0 0 / 0.05);
--shadow-win: 0 8px 24px 0 rgb(34 197 94 / 0.18);
--transition-base: 150ms ease;
--transition-smooth: 250ms cubic-bezier(0.4, 0, 0.2, 1);
}
/* ── Reset & base ───────────────────────────────────────────────────────────── */
* { box-sizing: border-box; }
html {
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
scroll-behavior: smooth;
color-scheme: light;
}
body {
background: #f8fafc;
color: #09090b;
font-family: -apple-system, BlinkMacSystemFont, "Inter", "Segoe UI", "Helvetica Neue", Arial, sans-serif;
line-height: 1.5;
}
/* ── Focus visible ──────────────────────────────────────────────────────────── */
:focus-visible {
outline: 2px solid #3b82f6;
outline-offset: 2px;
border-radius: 4px;
}
/* ── Scrollbar ──────────────────────────────────────────────────────────────── */
::-webkit-scrollbar { width: 6px; height: 6px; }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb { background: #d1d5db; border-radius: 3px; }
::-webkit-scrollbar-thumb:hover { background: #9ca3af; }
/* ── Page fade-in ───────────────────────────────────────────────────────────── */
@keyframes fadeUp {
from { opacity: 0; transform: translateY(8px); }
to { opacity: 1; transform: translateY(0); }
}
main > div { animation: fadeUp 0.25s ease both; }
/* ── Win banner animation ───────────────────────────────────────────────────── */
@keyframes popIn {
0% { transform: scale(0.85); opacity: 0; }
60% { transform: scale(1.04); }
100% { transform: scale(1); opacity: 1; }
}
.win-banner {
animation: popIn 0.38s cubic-bezier(0.34, 1.56, 0.64, 1) both;
}
/* ── Cell flash (win feedback) ──────────────────────────────────────────────── */
@keyframes cellWin {
0% { background-color: inherit; }
40% { background-color: #bbf7d0; }
100% { background-color: inherit; }
}
.cell-win { animation: cellWin 0.6s ease both; }
/* ── Shake animation (error) ────────────────────────────────────────────────── */
@keyframes shake {
0%, 100% { transform: translateX(0); }
20% { transform: translateX(-4px); }
40% { transform: translateX(4px); }
60% { transform: translateX(-3px); }
80% { transform: translateX(3px); }
}
.shake { animation: shake 0.35s ease; }
/* ── Skeleton loading ───────────────────────────────────────────────────────── */
@keyframes shimmer {
0% { background-position: -200% 0; }
100% { background-position: 200% 0; }
}
.skeleton {
background: linear-gradient(90deg, #f1f5f9 25%, #e2e8f0 50%, #f1f5f9 75%);
background-size: 200% 100%;
animation: shimmer 1.4s ease infinite;
border-radius: var(--radius-md);
}
/* ── Progress bar ───────────────────────────────────────────────────────────── */
.progress-track {
height: 5px;
border-radius: var(--radius-full);
background: #e2e8f0;
overflow: hidden;
}
.progress-fill {
height: 100%;
border-radius: var(--radius-full);
transition: width 0.7s cubic-bezier(0.4, 0, 0.2, 1);
}
/* ── Timer display ──────────────────────────────────────────────────────────── */
.timer-mono {
font-variant-numeric: tabular-nums;
letter-spacing: 0.05em;
}
/* ── Pulse ring (current level) ─────────────────────────────────────────────── */
@keyframes pulseRing {
0%, 100% { box-shadow: 0 0 0 0px currentColor; }
50% { box-shadow: 0 0 0 3px currentColor; }
}
.level-current { animation: pulseRing 1.8s ease-in-out infinite; }
/* ── Solved card overlay ────────────────────────────────────────────────────── */
.card-solved {
position: relative;
}
.card-solved::after {
content: "✓";
position: absolute;
top: 6px;
right: 8px;
font-size: 11px;
font-weight: 700;
color: #16a34a;
opacity: 0.8;
}

1
globe.svg Normal file
View file

@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g clip-path="url(#a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M10.27 14.1a6.5 6.5 0 0 0 3.67-3.45q-1.24.21-2.7.34-.31 1.83-.97 3.1M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16m.48-1.52a7 7 0 0 1-.96 0H7.5a4 4 0 0 1-.84-1.32q-.38-.89-.63-2.08a40 40 0 0 0 3.92 0q-.25 1.2-.63 2.08a4 4 0 0 1-.84 1.31zm2.94-4.76q1.66-.15 2.95-.43a7 7 0 0 0 0-2.58q-1.3-.27-2.95-.43a18 18 0 0 1 0 3.44m-1.27-3.54a17 17 0 0 1 0 3.64 39 39 0 0 1-4.3 0 17 17 0 0 1 0-3.64 39 39 0 0 1 4.3 0m1.1-1.17q1.45.13 2.69.34a6.5 6.5 0 0 0-3.67-3.44q.65 1.26.98 3.1M8.48 1.5l.01.02q.41.37.84 1.31.38.89.63 2.08a40 40 0 0 0-3.92 0q.25-1.2.63-2.08a4 4 0 0 1 .85-1.32 7 7 0 0 1 .96 0m-2.75.4a6.5 6.5 0 0 0-3.67 3.44 29 29 0 0 1 2.7-.34q.31-1.83.97-3.1M4.58 6.28q-1.66.16-2.95.43a7 7 0 0 0 0 2.58q1.3.27 2.95.43a18 18 0 0 1 0-3.44m.17 4.71q-1.45-.12-2.69-.34a6.5 6.5 0 0 0 3.67 3.44q-.65-1.27-.98-3.1" fill="#666"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h16v16H0z"/></clipPath></defs></svg>

After

Width:  |  Height:  |  Size: 1 KiB

86
layout.tsx Normal file
View file

@ -0,0 +1,86 @@
import type { Metadata } from "next";
import "./globals.css";
import Link from "next/link";
import { NavLink } from "@/components/NavLink";
export const metadata: Metadata = {
title: { default: "Puzzle Trainer", template: "%s — Puzzle Trainer" },
description: "Entraîne-toi aux puzzles logiques chaque jour.",
openGraph: {
title: "Puzzle Trainer",
description: "Entraîne-toi aux puzzles logiques chaque jour.",
url: "https://puzzles.reverdin.eu",
siteName: "Puzzle Trainer",
locale: "fr_FR",
type: "website",
images: [{ url: "https://puzzles.reverdin.eu/opengraph-image", width: 1200, height: 630 }],
},
twitter: {
card: "summary_large_image",
title: "Puzzle Trainer",
description: "Entraîne-toi aux puzzles logiques chaque jour.",
images: ["https://puzzles.reverdin.eu/opengraph-image"],
},
metadataBase: new URL("https://puzzles.reverdin.eu"),
manifest: "/manifest.json",
themeColor: "#111827",
appleWebApp: { capable: true, statusBarStyle: "default", title: "Puzzles" },
};
const GAMES = [
{ href: "/queens", label: "Queens", symbol: "♛" },
{ href: "/tango", label: "Tango", symbol: "☀" },
{ href: "/zip", label: "Zip", symbol: "∞" },
{ href: "/sudoku", label: "Sudoku", symbol: "#" },
{ href: "/patches", label: "Patches", symbol: "▦" },
];
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="fr" className="h-full">
<head>
<link rel="apple-touch-icon" sizes="180x180" href="/favicon.ico" />
</head>
<body className="min-h-full flex flex-col bg-[#f8fafc]">
<header className="border-b border-gray-100 bg-white/90 backdrop-blur-sm sticky top-0 z-50 shadow-[0_1px_0_0_#f1f5f9]">
<div className="max-w-4xl mx-auto px-4 h-14 flex items-center gap-4">
<Link
href="/"
className="text-base font-bold text-gray-900 tracking-tight shrink-0 hover:text-gray-700 transition-colors"
>
Puzzle Trainer
</Link>
{/* Nav games */}
<nav className="flex items-center gap-0.5 overflow-x-auto scrollbar-none flex-1 min-w-0">
{GAMES.map(g => (
<NavLink key={g.href} href={g.href} label={g.label} symbol={g.symbol} />
))}
</nav>
<Link
href="/archive"
className="ml-auto text-sm text-gray-400 hover:text-gray-700 shrink-0 transition-colors"
title="Archives des puzzles"
>
Archives
</Link>
</div>
</header>
<main className="flex-1 py-8 px-4">
<div className="max-w-4xl mx-auto">{children}</div>
</main>
<footer className="border-t border-gray-100 bg-white py-5 text-center text-xs text-gray-400 space-y-1">
<div>
Puzzle Trainer · Puzzles générés quotidiennement ·{" "}
<Link href="/mentions-legales" className="hover:text-gray-600 transition-colors underline-offset-2 hover:underline">
Mentions légales
</Link>
</div>
</footer>
</body>
</html>
);
}

80
levels.ts Normal file
View file

@ -0,0 +1,80 @@
/**
* Level system 100 levels per game, deterministic seeds, graduated difficulty.
*
* Seed strategy: level N synthetic date "LEVEL-{game}-{N:03d}"
* Each generator uses date.split("-").reduce() to derive a numeric seed,
* so we pass a date-shaped string that yields a unique seed per (game, level).
*
* Level N date string: `${1900 + N}-01-01`
* This stays far from real daily dates (2024+) and is unique per level.
*/
export const GAMES = ["queens", "tango", "zip", "sudoku", "patches"] as const;
export type GameId = typeof GAMES[number];
export const TOTAL_LEVELS = 100;
/** Convert level number (1100) to the synthetic date fed to the generator. */
export function levelToDate(level: number): string {
const n = Math.max(1, Math.min(TOTAL_LEVELS, level));
// e.g. level 1 → "1901-01-01", level 42 → "1942-01-01", level 100 → "2000-01-01"
return `${1900 + n}-01-01`;
}
/** Queens: N scales 6→10 across 100 levels. */
export function queensSizeForLevel(level: number): number {
if (level <= 15) return 6;
if (level <= 35) return 7;
if (level <= 60) return 8;
if (level <= 80) return 9;
return 10;
}
/** Zip: grid size scales with level. */
export function zipSizeForLevel(level: number): number {
if (level <= 20) return 5;
if (level <= 50) return 6;
if (level <= 75) return 7;
return 8;
}
export interface LevelMeta {
level: number;
difficulty: 1 | 2 | 3 | 4 | 5; // 1=easy … 5=expert
difficultyLabel: string;
}
const DIFFICULTY_LABELS = ["", "Facile", "Normal", "Intermédiaire", "Difficile", "Expert"];
export function levelMeta(game: GameId, level: number): LevelMeta {
let difficulty: 1 | 2 | 3 | 4 | 5;
if (game === "queens") {
if (level <= 15) difficulty = 1;
else if (level <= 35) difficulty = 2;
else if (level <= 60) difficulty = 3;
else if (level <= 80) difficulty = 4;
else difficulty = 5;
} else if (game === "zip") {
if (level <= 20) difficulty = 1;
else if (level <= 50) difficulty = 2;
else if (level <= 75) difficulty = 3;
else if (level <= 90) difficulty = 4;
else difficulty = 5;
} else {
// Tango, Sudoku, Patches: fixed size, difficulty is nominal
if (level <= 20) difficulty = 1;
else if (level <= 45) difficulty = 2;
else if (level <= 65) difficulty = 3;
else if (level <= 85) difficulty = 4;
else difficulty = 5;
}
return { level, difficulty, difficultyLabel: DIFFICULTY_LABELS[difficulty] };
}
export const GAME_META: Record<GameId, { name: string; accent: string; desc: string }> = {
queens: { name: "Queens", accent: "#d97706", desc: "Une couronne par ligne, colonne et zone colorée." },
tango: { name: "Tango", accent: "#ea580c", desc: "Équilibrez soleils et lunes selon les contraintes." },
zip: { name: "Zip", accent: "#2563eb", desc: "Reliez les chiffres dans l'ordre en couvrant tout." },
sudoku: { name: "Sudoku", accent: "#16a34a", desc: "Chiffres 16 dans chaque ligne, colonne et bloc." },
patches: { name: "Patches", accent: "#7c3aed", desc: "Remplissez chaque zone selon sa forme et sa taille." },
};

192
lib/generators/patches.ts Normal file
View file

@ -0,0 +1,192 @@
import { createRng, shuffle } from "../rng";
// Legacy export — board still imports it but we no longer use it for hints
export type ShapeHint = "free";
export interface Region {
id: number;
cells: [number, number][]; // all cells in grid
hintCell: [number, number]; // topmost-leftmost cell (always in solution)
relCells: [number, number][]; // shape normalised to (0,0) origin
previewRows: number;
previewCols: number;
size: number;
color: string;
}
export interface PatchesPuzzle {
size: number; // 6
regions: Region[];
grid: number[][]; // grid[r][c] = regionId
}
export const PATCH_COLORS = [
"#e8b040", // gold
"#4db86e", // green
"#4a9fd4", // blue
"#e05050", // red
"#e07830", // orange
"#30b8b0", // teal
"#9060c8", // purple
"#d06898", // pink
];
/** Place N seeds spread across the grid (no two adjacent). */
function placeSeeds(size: number, n: number, rng: () => number): [number, number][] {
const all: [number, number][] = [];
for (let r = 0; r < size; r++)
for (let c = 0; c < size; c++)
all.push([r, c]);
const pool = shuffle(all, rng);
const seeds: [number, number][] = [];
const banned = new Set<string>();
for (const [r, c] of pool) {
if (seeds.length >= n) break;
if (banned.has(`${r},${c}`)) continue;
seeds.push([r, c]);
// Ban cell + its orthogonal neighbours (avoid touching seeds)
for (const [dr, dc] of [[0,0],[-1,0],[1,0],[0,-1],[0,1]])
banned.add(`${r+dr},${c+dc}`);
}
return seeds;
}
/**
* Grow N polyomino regions from seeds, then flood-fill any remaining cells.
* Returns a size×size grid where grid[r][c] = regionId.
*/
function growPolyominos(size: number, seeds: [number, number][], rng: () => number): number[][] {
const grid: number[][] = Array.from({ length: size }, () => Array(size).fill(-1));
const sizes: number[] = Array(seeds.length).fill(0);
const n = seeds.length;
const target = Math.round((size * size) / n);
// Place seeds
for (let i = 0; i < n; i++) {
grid[seeds[i][0]][seeds[i][1]] = i;
sizes[i] = 1;
}
// BFS frontier per region
const ADJ: [number, number][] = [[-1,0],[1,0],[0,-1],[0,1]];
// Build initial frontiers
const frontiers: Set<string>[] = Array.from({ length: n }, (_, id) => {
const s = new Set<string>();
const [sr, sc] = seeds[id];
for (const [dr, dc] of ADJ) {
const nr = sr+dr, nc = sc+dc;
if (nr>=0 && nr<size && nc>=0 && nc<size && grid[nr][nc] === -1)
s.add(`${nr},${nc}`);
}
return s;
});
// Grow until each region hits its target (randomised round-robin)
let anyGrew = true;
while (anyGrew) {
anyGrew = false;
const order = shuffle(Array.from({ length: n }, (_, i) => i), rng);
for (const id of order) {
if (sizes[id] >= target) continue;
// Pick a random free frontier cell
const candidates = [...frontiers[id]].filter(k => {
const [r, c] = k.split(",").map(Number);
return grid[r][c] === -1;
});
if (!candidates.length) continue;
const k = candidates[Math.floor(rng() * candidates.length)];
const [nr, nc] = k.split(",").map(Number);
if (grid[nr][nc] !== -1) { frontiers[id].delete(k); continue; } // race condition
grid[nr][nc] = id;
sizes[id]++;
frontiers[id].delete(k);
anyGrew = true;
// Expand frontier
for (const [dr, dc] of ADJ) {
const nnr = nr+dr, nnc = nc+dc;
if (nnr>=0 && nnr<size && nnc>=0 && nnc<size && grid[nnr][nnc] === -1)
frontiers[id].add(`${nnr},${nnc}`);
}
}
}
// Flood-fill unassigned cells → smallest adjacent region
let changed = true;
while (changed) {
changed = false;
for (let r = 0; r < size; r++)
for (let c = 0; c < size; c++) {
if (grid[r][c] !== -1) continue;
let best = -1, bestSz = Infinity;
for (const [dr, dc] of ADJ) {
const nr = r+dr, nc = c+dc;
if (nr<0 || nr>=size || nc<0 || nc>=size) continue;
const id = grid[nr][nc];
if (id === -1) continue;
if (sizes[id] < bestSz) { bestSz = sizes[id]; best = id; }
}
if (best !== -1) { grid[r][c] = best; sizes[best]++; changed = true; }
}
}
return grid;
}
export function generatePatches(date: string): PatchesPuzzle {
const seed = date.split("-").reduce((a, n) => a * 1000 + parseInt(n), 0) + 333;
const rng = createRng(seed);
const size = 6;
// 7 or 8 regions → avg 4.55 cells each, with interesting shapes
const n = 7 + Math.floor(rng() * 2);
const seeds = placeSeeds(size, n, rng);
const grid = growPolyominos(size, seeds, rng);
const numRegions = Math.max(...grid.flat()) + 1;
// Collect cells per region
const regionCells = new Map<number, [number, number][]>();
for (let r = 0; r < size; r++)
for (let c = 0; c < size; c++) {
const id = grid[r][c];
if (!regionCells.has(id)) regionCells.set(id, []);
regionCells.get(id)!.push([r, c]);
}
const colors = shuffle([...PATCH_COLORS], rng);
const regions: Region[] = [];
for (let id = 0; id < numRegions; id++) {
const cells = regionCells.get(id) ?? [];
if (!cells.length) continue;
// Hint cell = topmost row, then leftmost col
const hintCell = cells.reduce((best, cur) =>
cur[0] < best[0] || (cur[0] === best[0] && cur[1] < best[1]) ? cur : best
);
// Normalised shape (origin at 0,0)
const minR = Math.min(...cells.map(([r]) => r));
const minC = Math.min(...cells.map(([, c]) => c));
const relCells: [number, number][] = cells.map(([r, c]) => [r - minR, c - minC]);
const previewRows = Math.max(...relCells.map(([r]) => r)) + 1;
const previewCols = Math.max(...relCells.map(([, c]) => c)) + 1;
regions.push({
id,
cells,
hintCell,
relCells,
previewRows,
previewCols,
size: cells.length,
color: colors[id % colors.length],
});
}
return { size, regions, grid };
}

769
lib/generators/queens.ts Normal file
View file

@ -0,0 +1,769 @@
import { createRng, shuffle } from "../rng";
export interface QueensPuzzle {
size: number;
regions: number[][]; // regions[row][col] = regionId (0-indexed)
solution: [number, number][]; // [row, col] per queen
}
// ── solveQueens ────────────────────────────────────────────────────────────────
function solveQueens(size: number, rng: () => number): [number, number][] | null {
const cols: number[] = [];
const usedCols = new Set<number>();
const diag1 = new Set<number>();
const diag2 = new Set<number>();
const colOrder = shuffle(Array.from({ length: size }, (_, i) => i), rng);
function bt(row: number): boolean {
if (row === size) return true;
for (const col of colOrder) {
if (usedCols.has(col) || diag1.has(row - col) || diag2.has(row + col)) continue;
cols[row] = col;
usedCols.add(col); diag1.add(row - col); diag2.add(row + col);
if (bt(row + 1)) return true;
usedCols.delete(col); diag1.delete(row - col); diag2.delete(row + col);
}
return false;
}
if (!bt(0)) return null;
return cols.map((col, row) => [row, col]);
}
// ── buildRegions ───────────────────────────────────────────────────────────────
// Phase 1: organic BFS from each queen in sigma order, pure adjacency, capped at targetSize
// Phase 2: remaining cells → assigned to adjacent region with HIGHEST sigmaK
function buildRegions(size: number, queens: [number, number][], rng: () => number): number[][] {
const ADJ: [number, number][] = [[-1, 0], [1, 0], [0, -1], [0, 1]];
const regions: number[][] = Array.from({ length: size }, () => Array(size).fill(-1));
const regionSizes = new Int32Array(size);
const sigma = shuffle(Array.from({ length: size }, (_, i) => i), rng);
const sigmaK = new Int32Array(size);
for (let k = 0; k < size; k++) sigmaK[sigma[k]] = k;
const targetSize = size;
for (let k = 0; k < size; k++) {
const id = sigma[k];
const [qr, qc] = queens[id];
regions[qr][qc] = id;
regionSizes[id] = 1;
const queue: [number, number][] = [[qr, qc]];
let head = 0;
while (head < queue.length && regionSizes[id] < targetSize) {
const remaining = queue.length - head;
const pick = head + Math.floor(rng() * remaining);
[queue[head], queue[pick]] = [queue[pick], queue[head]];
const [r, c] = queue[head++];
for (const [dr, dc] of ADJ) {
const nr = r + dr, nc = c + dc;
if (nr < 0 || nr >= size || nc < 0 || nc >= size) continue;
if (regions[nr][nc] !== -1) continue;
regions[nr][nc] = id;
regionSizes[id]++;
queue.push([nr, nc]);
if (regionSizes[id] >= targetSize) break;
}
}
}
let changed = true;
while (changed) {
changed = false;
for (let r = 0; r < size; r++)
for (let c = 0; c < size; c++) {
if (regions[r][c] !== -1) continue;
let bestId = -1, bestK = -1;
for (const [dr, dc] of ADJ) {
const nr = r + dr, nc = c + dc;
if (nr < 0 || nr >= size || nc < 0 || nc >= size) continue;
const id = regions[nr][nc];
if (id === -1) continue;
const k = sigmaK[id];
if (k > bestK) { bestK = k; bestId = id; }
}
if (bestId !== -1) { regions[r][c] = bestId; regionSizes[bestId]++; changed = true; }
}
}
for (let r = 0; r < size; r++)
for (let c = 0; c < size; c++) {
if (regions[r][c] !== -1) continue;
let best = 0, bestDist = Infinity;
for (let id = 0; id < size; id++) {
const [qr, qc] = queens[id];
const d = Math.abs(qr - r) + Math.abs(qc - c);
if (d < bestDist) { bestDist = d; best = id; }
}
regions[r][c] = best;
}
return regions;
}
// ── getAllPlacements ───────────────────────────────────────────────────────────
// Enumerate valid Queens-game placements (one per row, one per col,
// no two consecutive-row queens king-adjacent: |col_r - col_{r+1}| > 1).
// For large N the count grows ~10× per step; use maxP + optional rng to get
// a random sample instead of the first-in-lex-order batch.
function getAllPlacements(size: number, maxP = Infinity, rng?: () => number): Int8Array[] {
const sols: Int8Array[] = [];
const uc = new Uint8Array(size);
const cols = new Array<number>(size);
// Per-row column order (shuffled when rng provided, for random sampling)
const colOrders: number[][] = Array.from({ length: size }, () => {
const order = Array.from({ length: size }, (_, i) => i);
if (rng) {
for (let i = order.length - 1; i > 0; i--) {
const j = (rng() * (i + 1)) | 0;
[order[i], order[j]] = [order[j], order[i]];
}
}
return order;
});
function bt(row: number): void {
if (sols.length >= maxP) return;
if (row === size) { sols.push(new Int8Array(cols)); return; }
for (const col of colOrders[row]) {
if (uc[col]) continue;
if (row > 0 && Math.abs(cols[row - 1] - col) <= 1) continue;
uc[col] = 1; cols[row] = col;
bt(row + 1);
uc[col] = 0;
if (sols.length >= maxP) return;
}
}
bt(0);
return sols;
}
// ── optimizeRegions ────────────────────────────────────────────────────────────
// Iterated Local Search: greedy hill-climbing with perturbation kicks.
//
// Theory: moving cell (r,c) from region A to B affects only placements where
// the queen in row r sits at column c.
// • vDestroyed: valid placements where region B already has another queen
// → moving queen-r to B creates a collision → placement becomes invalid.
// • aCreated: almost-valid placements (conflict=1) whose collision is in A
// AND region B is empty → moving queen-r from A to B resolves the collision.
// Accept swaps where vDestroyed > aCreated (net reduction in valid count).
// When stuck, apply random "kick" swaps, then resume greedy from the best state.
function optimizeRegions(
size: number,
regions: number[][],
queens: [number, number][],
placements: Int8Array[],
rng: () => number
): boolean {
const ADJ: [number, number][] = [[-1, 0], [1, 0], [0, -1], [0, 1]];
const P = placements.length;
// Queen cell lookup
const isQueen = new Uint8Array(size * size);
for (const [r, c] of queens) isQueen[r * size + c] = 1;
// Region cell lists — kept in sync after every swap
const regCells: [number, number][][] = Array.from({ length: size }, () => []);
for (let r = 0; r < size; r++)
for (let c = 0; c < size; c++)
regCells[regions[r][c]].push([r, c]);
// Reusable buffers
const conflictOf = new Uint8Array(P);
const visitBuf = new Uint8Array(size * size);
const queueBuf = new Int32Array(size * size);
function recomputeConflicts(): void {
for (let p = 0; p < P; p++) {
const pl = placements[p];
const regCount = new Uint8Array(size);
let conf = 0;
for (let r = 0; r < size; r++) {
const reg = regions[r][pl[r]];
if (++regCount[reg] === 2) { conf++; if (conf >= 2) break; }
}
conflictOf[p] = conf;
}
}
function countCurrentValid(): number {
let cnt = 0;
for (let p = 0; p < P; p++) if (conflictOf[p] === 0) cnt++;
return cnt;
}
function isConnectedWithout(regId: number, remR: number, remC: number): boolean {
const [qr, qc] = queens[regId];
const target = regCells[regId].length - 1;
if (target <= 1) return true;
visitBuf.fill(0);
const start = qr * size + qc;
visitBuf[start] = 1;
queueBuf[0] = start;
let head = 0, tail = 1, count = 1;
while (head < tail) {
const idx = queueBuf[head++];
const r = (idx / size) | 0, c = idx % size;
for (const [dr, dc] of ADJ) {
const nr = r + dr, nc = c + dc;
if (nr < 0 || nr >= size || nc < 0 || nc >= size) continue;
if (regions[nr][nc] !== regId || (nr === remR && nc === remC)) continue;
const key = nr * size + nc;
if (visitBuf[key]) continue;
visitBuf[key] = 1;
queueBuf[tail++] = key;
if (++count === target) return true;
}
}
return count === target;
}
function applySwap(r: number, c: number, newReg: number): void {
const oldReg = regions[r][c];
const idx = regCells[oldReg].findIndex(([rr, cc]) => rr === r && cc === c);
if (idx !== -1) regCells[oldReg].splice(idx, 1);
regCells[newReg].push([r, c]);
regions[r][c] = newReg;
}
// Run one pass of greedy hill-climbing.
// Returns number of improvements applied (0 = stuck).
function greedyPass(shuffledCells: [number, number][]): number {
recomputeConflicts();
const validIdx: number[] = [], almostIdx: number[] = [];
for (let p = 0; p < P; p++) {
if (conflictOf[p] === 0) validIdx.push(p);
else if (conflictOf[p] === 1) almostIdx.push(p);
}
if (validIdx.length <= 1) return 0; // done or impossible
const byCRValid: number[][] = Array.from({ length: size * size }, () => []);
const byCRAlmost: number[][] = Array.from({ length: size * size }, () => []);
for (const p of validIdx) {
const pl = placements[p];
for (let r = 0; r < size; r++) byCRValid[r * size + pl[r]].push(p);
}
for (const p of almostIdx) {
const pl = placements[p];
for (let r = 0; r < size; r++) byCRAlmost[r * size + pl[r]].push(p);
}
const pMask = new Int32Array(P);
for (const p of validIdx) {
const pl = placements[p];
let m = 0;
for (let r = 0; r < size; r++) m |= (1 << regions[r][pl[r]]);
pMask[p] = m;
}
for (const p of almostIdx) {
const pl = placements[p];
let m = 0;
for (let r = 0; r < size; r++) m |= (1 << regions[r][pl[r]]);
pMask[p] = m;
}
const almostColReg = new Int8Array(P).fill(-1);
for (const p of almostIdx) {
const pl = placements[p];
const regCount = new Uint8Array(size);
for (let r = 0; r < size; r++) {
const reg = regions[r][pl[r]];
if (++regCount[reg] === 2) { almostColReg[p] = reg; break; }
}
}
// Best-improvement: scan all cells, find globally best swap
let bestDelta = 0, bestR = -1, bestC = -1, bestNewReg = -1;
for (const [r, c] of shuffledCells) {
const oldReg = regions[r][c];
const adjRegs: number[] = [];
for (const [dr, dc] of ADJ) {
const nr = r + dr, nc = c + dc;
if (nr < 0 || nr >= size || nc < 0 || nc >= size) continue;
const ar = regions[nr][nc];
if (ar !== oldReg && !adjRegs.includes(ar)) adjRegs.push(ar);
}
if (adjRegs.length === 0 || !isConnectedWithout(oldReg, r, c)) continue;
const validHere = byCRValid[r * size + c];
const almostHere = byCRAlmost[r * size + c];
for (const newReg of adjRegs) {
let vDestroyed = 0;
for (const p of validHere) if ((pMask[p] >> newReg) & 1) vDestroyed++;
let aCreated = 0;
for (const p of almostHere)
if (almostColReg[p] === oldReg && !((pMask[p] >> newReg) & 1)) aCreated++;
const delta = vDestroyed - aCreated;
if (delta > bestDelta) { bestDelta = delta; bestR = r; bestC = c; bestNewReg = newReg; }
}
}
if (bestR === -1) return 0; // stuck
applySwap(bestR, bestC, bestNewReg);
return 1;
}
// Run greedy until stuck; return current valid count
function runGreedy(): number {
const cells: [number, number][] = [];
for (let r = 0; r < size; r++)
for (let c = 0; c < size; c++)
if (!isQueen[r * size + c]) cells.push([r, c]);
for (let round = 0; round < 300; round++) {
// Shuffle for variety
for (let i = cells.length - 1; i > 0; i--) {
const j = (rng() * (i + 1)) | 0;
[cells[i], cells[j]] = [cells[j], cells[i]];
}
recomputeConflicts();
const vc = countCurrentValid();
if (vc <= 1) return vc;
if (greedyPass(cells) === 0) break;
}
recomputeConflicts();
return countCurrentValid();
}
// Apply K random connectivity-preserving swaps (kick)
function kick(K: number): void {
const cells: [number, number][] = [];
for (let r = 0; r < size; r++)
for (let c = 0; c < size; c++)
if (!isQueen[r * size + c]) cells.push([r, c]);
// Shuffle
for (let i = cells.length - 1; i > 0; i--) {
const j = (rng() * (i + 1)) | 0;
[cells[i], cells[j]] = [cells[j], cells[i]];
}
let applied = 0;
for (const [r, c] of cells) {
if (applied >= K) break;
const oldReg = regions[r][c];
const adjRegs: number[] = [];
for (const [dr, dc] of ADJ) {
const nr = r + dr, nc = c + dc;
if (nr < 0 || nr >= size || nc < 0 || nc >= size) continue;
const ar = regions[nr][nc];
if (ar !== oldReg && !adjRegs.includes(ar)) adjRegs.push(ar);
}
if (adjRegs.length === 0 || !isConnectedWithout(oldReg, r, c)) continue;
const newReg = adjRegs[(rng() * adjRegs.length) | 0];
applySwap(r, c, newReg);
applied++;
}
}
// Snapshot helpers
function snapshot(): number[][] {
return regions.map(row => [...row]);
}
function restore(snap: number[][]): void {
for (let r = 0; r < size; r++)
for (let c = 0; c < size; c++)
regions[r][c] = snap[r][c];
regCells.forEach(rc => rc.length = 0);
for (let r = 0; r < size; r++)
for (let c = 0; c < size; c++)
regCells[regions[r][c]].push([r, c]);
}
// Scale kick budget with sample size: more kicks for smaller P (more reliable)
const MAX_KICKS = P <= 10000 ? 15 : P <= 100000 ? 40 : 20;
// ILS: run greedy, kick when stuck, restore best if worsened
let bestSnap = snapshot();
let bestValid = runGreedy();
if (bestValid <= 1) return bestValid === 1;
for (let k = 0; k < MAX_KICKS; k++) {
restore(bestSnap);
kick(2 + (k % 3)); // vary kick size 2-4
const vc = runGreedy();
if (vc === 1) return true;
if (vc < bestValid) {
bestValid = vc;
bestSnap = snapshot();
}
}
restore(bestSnap);
recomputeConflicts();
return countCurrentValid() === 1;
}
// ── optimizeRegionsLargeN ──────────────────────────────────────────────────────
// Solution-separation optimizer for large N (≥10) where enumerating all
// placements is too expensive.
//
// Strategy: repeatedly find a spurious solution S2 (via backtracking) and apply
// a targeted "separation swap" that creates a region conflict for S2 without
// breaking the intended solution S1. When no targeted swap exists, apply a
// random kick, then retry.
function optimizeRegionsLargeN(
size: number,
regions: number[][],
queens: [number, number][],
solution: [number, number][],
rng: () => number
): boolean {
const ADJ: [number, number][] = [[-1, 0], [1, 0], [0, -1], [0, 1]];
const isQueen = new Uint8Array(size * size);
for (const [r, c] of queens) isQueen[r * size + c] = 1;
const regCells: [number, number][][] = Array.from({ length: size }, () => []);
for (let r = 0; r < size; r++)
for (let c = 0; c < size; c++)
regCells[regions[r][c]].push([r, c]);
const visitBuf = new Uint8Array(size * size);
const queueBuf = new Int32Array(size * size);
function isConnectedWithout(regId: number, remR: number, remC: number): boolean {
const [qr, qc] = queens[regId];
const target = regCells[regId].length - 1;
if (target <= 1) return true;
visitBuf.fill(0);
const start = qr * size + qc;
visitBuf[start] = 1; queueBuf[0] = start;
let head = 0, tail = 1, count = 1;
while (head < tail) {
const idx = queueBuf[head++];
const r = (idx / size) | 0, c = idx % size;
for (const [dr, dc] of ADJ) {
const nr = r + dr, nc = c + dc;
if (nr < 0 || nr >= size || nc < 0 || nc >= size) continue;
if (regions[nr][nc] !== regId || (nr === remR && nc === remC)) continue;
const key = nr * size + nc;
if (visitBuf[key]) continue;
visitBuf[key] = 1; queueBuf[tail++] = key;
if (++count === target) return true;
}
}
return count === target;
}
function applySwap(r: number, c: number, newReg: number): void {
const oldReg = regions[r][c];
const idx = regCells[oldReg].findIndex(([rr, cc]) => rr === r && cc === c);
if (idx !== -1) regCells[oldReg].splice(idx, 1);
regCells[newReg].push([r, c]);
regions[r][c] = newReg;
}
// Find a spurious (non-solution) valid placement via backtracking.
// Returns the spurious placement's column array, or null if unique.
const solCols = solution.map(([, c]) => c);
function findSpurious(): number[] | null {
const uc = new Uint8Array(size), ur = new Uint8Array(size);
const pR = new Int32Array(size), pC = new Int32Array(size);
let found: number[] | null = null, depth = 0;
function bt(row: number): void {
if (found) return;
if (row === size) {
for (let r = 0; r < size; r++) if (pC[r] !== solCols[r]) { found = [...pC]; return; }
return; // this IS the game solution
}
for (let col = 0; col < size; col++) {
if (uc[col]) continue;
const reg = regions[row][col];
if (ur[reg]) continue;
let adj = false;
for (let i = 0; i < depth; i++)
if (Math.abs(row - pR[i]) <= 1 && Math.abs(col - pC[i]) <= 1) { adj = true; break; }
if (adj) continue;
uc[col] = 1; ur[reg] = 1; pR[depth] = row; pC[depth] = col; depth++;
bt(row + 1);
depth--; uc[col] = 0; ur[reg] = 0;
if (found) return;
}
}
bt(0);
return found;
}
// Try to separate spurious S2 from the game solution by swapping a cell
// at position (r, S2[r]) — where S2's queen sits but S1's doesn't — into
// an adjacent region already occupied by S2, creating a collision for S2.
function eliminate(s2cols: number[]): boolean {
const s2regSet = new Set(s2cols.map((c, r) => regions[r][c]));
// Shuffle row order for variety
const rows = Array.from({ length: size }, (_, i) => i);
for (let i = rows.length - 1; i > 0; i--) {
const j = (rng() * (i + 1)) | 0;
[rows[i], rows[j]] = [rows[j], rows[i]];
}
for (const r of rows) {
const c = s2cols[r];
if (solCols[r] === c) continue; // S1 also has queen here — skip
if (isQueen[r * size + c]) continue; // it's the game queen cell — never move
const oldReg = regions[r][c];
// Check connectivity before the loop (avoid redundant BFS per adjacent region)
if (!isConnectedWithout(oldReg, r, c)) continue;
for (const [dr, dc] of ADJ) {
const nr = r + dr, nc = c + dc;
if (nr < 0 || nr >= size || nc < 0 || nc >= size) continue;
const adjReg = regions[nr][nc];
if (adjReg === oldReg) continue;
// adjReg is occupied in S2 → moving (r,c) to adjReg creates a collision for S2
if (s2regSet.has(adjReg)) {
applySwap(r, c, adjReg);
return true;
}
}
}
return false;
}
// Random connectivity-preserving kick
function kick(K: number): void {
const cells: [number, number][] = [];
for (let r = 0; r < size; r++)
for (let c = 0; c < size; c++)
if (!isQueen[r * size + c]) cells.push([r, c]);
for (let i = cells.length - 1; i > 0; i--) {
const j = (rng() * (i + 1)) | 0;
[cells[i], cells[j]] = [cells[j], cells[i]];
}
let applied = 0;
for (const [r, c] of cells) {
if (applied >= K) break;
const oldReg = regions[r][c];
const adjRegs: number[] = [];
for (const [dr, dc] of ADJ) {
const nr = r + dr, nc = c + dc;
if (nr < 0 || nr >= size || nc < 0 || nc >= size) continue;
const ar = regions[nr][nc];
if (ar !== oldReg && !adjRegs.includes(ar)) adjRegs.push(ar);
}
if (adjRegs.length === 0 || !isConnectedWithout(oldReg, r, c)) continue;
applySwap(r, c, adjRegs[(rng() * adjRegs.length) | 0]);
applied++;
}
}
const MAX_ITERS = 300;
let kicksWithoutProgress = 0;
for (let iter = 0; iter < MAX_ITERS; iter++) {
const s2 = findSpurious();
if (!s2) return true; // unique!
if (!eliminate(s2)) {
kick(2 + (kicksWithoutProgress % 3));
kicksWithoutProgress++;
if (kicksWithoutProgress > 30) return false; // hopelessly stuck
} else {
kicksWithoutProgress = 0;
}
}
return false;
}
// ── genCombinations ───────────────────────────────────────────────────────────
function* genCombinations(arr: number[], n: number, start = 0): Generator<number[]> {
if (n === 0) { yield []; return; }
for (let i = start; i <= arr.length - n; i++)
for (const rest of genCombinations(arr, n - 1, i + 1))
yield [arr[i], ...rest];
}
// ── isLogicallySolvable ────────────────────────────────────────────────────────
// Returns true iff the puzzle can be solved by pure constraint propagation
// (no guessing). Same rules as the hint finder in QueensBoard.tsx.
function isLogicallySolvable(size: number, regions: number[][]): boolean {
const poss = new Uint8Array(size * size).fill(1);
const doneRows = new Uint8Array(size);
const doneRegs = new Uint8Array(size);
function placeQueen(r: number, c: number) {
const reg = regions[r][c];
doneRows[r] = 1;
doneRegs[reg] = 1;
for (let i = 0; i < size; i++) {
poss[r * size + i] = 0; // same row
poss[i * size + c] = 0; // same col
}
for (let rr = 0; rr < size; rr++)
for (let cc = 0; cc < size; cc++)
if (regions[rr][cc] === reg || (Math.abs(rr - r) <= 1 && Math.abs(cc - c) <= 1))
poss[rr * size + cc] = 0;
}
let placed = 0;
let progress = true;
while (placed < size && progress) {
progress = false;
// Rules 1-3: naked singles (region / row / col)
for (let id = 0; id < size; id++) {
if (doneRegs[id]) continue;
let fr = -1, fc = -1, cnt = 0;
for (let r = 0; r < size; r++)
for (let c = 0; c < size; c++)
if (regions[r][c] === id && poss[r * size + c]) { fr = r; fc = c; cnt++; }
if (cnt === 1) { placeQueen(fr, fc); placed++; progress = true; }
}
for (let r = 0; r < size; r++) {
if (doneRows[r]) continue;
let fc = -1, cnt = 0;
for (let c = 0; c < size; c++) if (poss[r * size + c]) { fc = c; cnt++; }
if (cnt === 1 && fc !== -1) { placeQueen(r, fc); placed++; progress = true; }
}
for (let c = 0; c < size; c++) {
let fr = -1, cnt = 0;
for (let r = 0; r < size; r++) if (!doneRows[r] && poss[r * size + c]) { fr = r; cnt++; }
if (cnt === 1 && fr !== -1) { placeQueen(fr, c); placed++; progress = true; }
}
// Rule 4: N-subset — N regions confined to N rows or N cols
if (!progress) {
const unreg: number[] = [];
for (let id = 0; id < size; id++) {
if (doneRegs[id]) continue;
let ok = false;
for (let r = 0; r < size && !ok; r++)
for (let c = 0; c < size && !ok; c++)
if (regions[r][c] === id && poss[r * size + c]) ok = true;
if (ok) unreg.push(id);
}
outer:
for (let n = 1; n < unreg.length; n++) {
for (const combo of genCombinations(unreg, n)) {
const comboSet = new Set(combo);
const rowSet = new Set<number>(), colSet = new Set<number>();
for (const id of combo)
for (let r = 0; r < size; r++)
for (let c = 0; c < size; c++)
if (regions[r][c] === id && poss[r * size + c]) { rowSet.add(r); colSet.add(c); }
if (rowSet.size === n) {
let changed = false;
for (const row of rowSet)
for (let c = 0; c < size; c++)
if (poss[row * size + c] && !comboSet.has(regions[row][c])) { poss[row * size + c] = 0; changed = true; }
if (changed) { progress = true; break outer; }
}
if (colSet.size === n) {
let changed = false;
for (const col of colSet)
for (let r = 0; r < size; r++)
if (poss[r * size + col] && !comboSet.has(regions[r][col])) { poss[r * size + col] = 0; changed = true; }
if (changed) { progress = true; break outer; }
}
}
}
}
}
return placed === size;
}
// ── isUnique ───────────────────────────────────────────────────────────────────
// Backtracking check — kept as fallback when optimizeRegions cannot find uniqueness.
function isUnique(size: number, regions: number[][]): boolean {
const usedCols = new Uint8Array(size);
const usedRegs = new Uint8Array(size);
const placedR = new Int32Array(size);
const placedC = new Int32Array(size);
let depth = 0;
let count = 0;
function bt(row: number): void {
if (count > 1) return;
if (row === size) { count++; return; }
for (let col = 0; col < size; col++) {
if (usedCols[col]) continue;
const reg = regions[row][col];
if (usedRegs[reg]) continue;
let adj = false;
for (let i = 0; i < depth; i++)
if (Math.abs(row - placedR[i]) <= 1 && Math.abs(col - placedC[i]) <= 1) { adj = true; break; }
if (adj) continue;
usedCols[col] = 1; usedRegs[reg] = 1;
placedR[depth] = row; placedC[depth] = col; depth++;
bt(row + 1);
depth--; usedCols[col] = 0; usedRegs[reg] = 0;
if (count > 1) return;
}
}
bt(0);
return count === 1;
}
// ── Public exports ─────────────────────────────────────────────────────────────
export function queensSizeForDate(date: string): number {
const start = new Date("2024-01-01").getTime();
const d = new Date(date).getTime();
const days = Math.max(0, Math.floor((d - start) / 86400000));
if (days < 100) return 6;
if (days < 400) return 7;
if (days < 900) return 8;
if (days < 1400) return 9;
return 10;
}
export function generateQueens(date: string, size?: number): QueensPuzzle {
const resolvedSize = size ?? queensSizeForDate(date);
const baseSeed = date.split("-").reduce((a, n) => a * 1000 + parseInt(n), 0);
// N ≤ 9: enumerate all placements once (fast).
// N ≥ 10: use solution-separation optimizer (no placements enumeration needed).
const largeN = resolvedSize >= 10;
const allPlacements = largeN ? null : getAllPlacements(resolvedSize);
// Tier 1: unique + logically solvable (no guessing needed)
// Tier 2: unique only (may require guessing — hint finder will always find something)
let fallbackUnique: QueensPuzzle | null = null;
const MAX_ATTEMPTS = largeN ? 30 : 20;
for (let attempt = 0; attempt < MAX_ATTEMPTS; attempt++) {
const rng1 = createRng(baseSeed + attempt * 1000003);
const solution = solveQueens(resolvedSize, rng1)!;
const regions = buildRegions(resolvedSize, solution, rng1);
// Deep-copy regions before optimization (mutates in-place)
const regCopy = regions.map(row => [...row]);
const rng2 = createRng(baseSeed + attempt * 999983 + 42);
const optimized = largeN
? optimizeRegionsLargeN(resolvedSize, regCopy, solution, solution, rng2)
: optimizeRegions(resolvedSize, regCopy, solution, allPlacements!, rng2);
if (optimized) {
// Best case: unique + logically solvable
if (isLogicallySolvable(resolvedSize, regCopy)) {
return { size: resolvedSize, regions: regCopy, solution };
}
// Keep as tier-2 fallback (unique but may need guessing)
if (!fallbackUnique) fallbackUnique = { size: resolvedSize, regions: regCopy, solution };
}
// Also save non-optimized unique puzzles as tier-2 fallback
if (!fallbackUnique && isUnique(resolvedSize, regions)) {
fallbackUnique = { size: resolvedSize, regions, solution };
}
}
if (fallbackUnique) return fallbackUnique;
// Absolute last resort (should never reach here in practice)
const rng = createRng(baseSeed);
const solution = solveQueens(resolvedSize, rng)!;
const regions = buildRegions(resolvedSize, solution, rng);
return { size: resolvedSize, regions, solution };
}
export const QUEEN_COLORS = [
"#e63946", "#f4a261", "#2a9d8f", "#457b9d",
"#6a4c93", "#264653", "#e9c46a", "#8ecae6",
];

93
lib/generators/sudoku.ts Normal file
View file

@ -0,0 +1,93 @@
import { createRng, shuffle } from "../rng";
export interface SudokuPuzzle {
size: 6;
// 0 = empty, 1-6 = given
given: number[][];
solution: number[][];
}
function isValid(grid: number[][], r: number, c: number, val: number): boolean {
for (let i = 0; i < 6; i++) {
if (grid[r][i] === val || grid[i][c] === val) return false;
}
// 2x3 box
const br = Math.floor(r / 2) * 2, bc = Math.floor(c / 3) * 3;
for (let dr = 0; dr < 2; dr++)
for (let dc = 0; dc < 3; dc++)
if (grid[br + dr][bc + dc] === val) return false;
return true;
}
function solve(grid: number[][], rng: () => number): boolean {
for (let r = 0; r < 6; r++) {
for (let c = 0; c < 6; c++) {
if (grid[r][c] !== 0) continue;
const nums = shuffle([1, 2, 3, 4, 5, 6], rng);
for (const n of nums) {
if (!isValid(grid, r, c, n)) continue;
grid[r][c] = n;
if (solve(grid, rng)) return true;
grid[r][c] = 0;
}
return false;
}
}
return true;
}
function countSolutions(grid: number[][], limit = 2): number {
let count = 0;
function bt(): boolean {
for (let r = 0; r < 6; r++) {
for (let c = 0; c < 6; c++) {
if (grid[r][c] !== 0) continue;
for (let n = 1; n <= 6; n++) {
if (!isValid(grid, r, c, n)) continue;
grid[r][c] = n;
bt();
grid[r][c] = 0;
if (count >= limit) return true;
}
return false;
}
}
count++;
return count >= limit;
}
bt();
return count;
}
export function generateSudoku(date: string): SudokuPuzzle {
const seed = date.split("-").reduce((a, n) => a * 1000 + parseInt(n), 0) + 555;
const rng = createRng(seed);
const grid: number[][] = Array.from({ length: 6 }, () => Array(6).fill(0));
solve(grid, rng);
const solution = grid.map(r => [...r]);
// Remove cells while puzzle remains uniquely solvable
const positions = shuffle(
Array.from({ length: 36 }, (_, i) => [Math.floor(i / 6), i % 6] as [number, number]),
rng
);
const given = solution.map(r => [...r]);
let removed = 0;
for (const [r, c] of positions) {
if (removed >= 22) break; // keep ~14 givens in a 6x6
const val = given[r][c];
given[r][c] = 0;
// Quick check: still solvable
const test = given.map(row => [...row]);
if (countSolutions(test) !== 1) {
given[r][c] = val; // restore
} else {
removed++;
}
}
return { size: 6, given, solution };
}

215
lib/generators/tango.ts Normal file
View file

@ -0,0 +1,215 @@
import { createRng, shuffle } from "../rng";
export type Cell = "sun" | "moon" | null;
export type EdgeConstraint = "=" | "x" | null;
export interface TangoPuzzle {
size: number; // always 6
given: Cell[][];
hEdges: EdgeConstraint[][];
vEdges: EdgeConstraint[][];
solution: Cell[][];
}
// Check if placing v at (r,c) is consistent with current partial grid
function consistent(
grid: Cell[][], hEdges: EdgeConstraint[][], vEdges: EdgeConstraint[][],
r: number, c: number, v: Cell, size: number
): boolean {
// Row balance
let rs = 0, rm = 0;
for (let j = 0; j < size; j++) {
const cell = j === c ? v : grid[r][j];
if (cell === "sun") rs++; else if (cell === "moon") rm++;
}
if (rs > size / 2 || rm > size / 2) return false;
// Col balance
let cs = 0, cm = 0;
for (let i = 0; i < size; i++) {
const cell = i === r ? v : grid[i][c];
if (cell === "sun") cs++; else if (cell === "moon") cm++;
}
if (cs > size / 2 || cm > size / 2) return false;
// No 3 consecutive in row
for (let j = Math.max(0, c - 2); j <= Math.min(size - 3, c); j++) {
const a = j === c ? v : grid[r][j];
const b = j + 1 === c ? v : grid[r][j + 1];
const d = j + 2 === c ? v : grid[r][j + 2];
if (a && a === b && b === d) return false;
}
// No 3 consecutive in col
for (let i = Math.max(0, r - 2); i <= Math.min(size - 3, r); i++) {
const a = i === r ? v : grid[i][c];
const b = i + 1 === r ? v : grid[i + 1][c];
const d = i + 2 === r ? v : grid[i + 2][c];
if (a && a === b && b === d) return false;
}
// Edge constraints (only check placed neighbours)
const chk = (e: EdgeConstraint, nb: Cell) => {
if (!e || !nb) return true;
if (e === "=" && nb !== v) return false;
if (e === "x" && nb === v) return false;
return true;
};
if (!chk(c > 0 ? hEdges[r][c - 1] : null, grid[r][c - 1])) return false;
if (!chk(c < size - 1 ? hEdges[r][c] : null, grid[r][c + 1])) return false;
if (!chk(r > 0 ? vEdges[r - 1][c] : null, grid[r - 1]?.[c] ?? null)) return false;
if (!chk(r < size - 1 ? vEdges[r][c] : null, grid[r + 1]?.[c] ?? null)) return false;
// Unique rows: if this row is now complete, check it doesn't duplicate an earlier complete row
const rowComplete = grid[r].every((cell, j) => (j === c ? v : cell) !== null);
if (rowComplete) {
const thisRow = grid[r].map((cell, j) => j === c ? v : cell);
for (let i = 0; i < r; i++) {
if (grid[i].every(cell => cell !== null) &&
grid[i].every((cell, j) => cell === thisRow[j])) return false;
}
}
// Unique cols: if this col is now complete, check it doesn't duplicate an earlier complete col
const colComplete = Array.from({ length: size }, (_, i) => i === r ? v : grid[i][c]).every(cell => cell !== null);
if (colComplete) {
const thisCol = Array.from({ length: size }, (_, i) => i === r ? v : grid[i][c]);
for (let j = 0; j < c; j++) {
const other = Array.from({ length: size }, (_, i) => grid[i][j]);
if (other.every(cell => cell !== null) &&
other.every((cell, i) => cell === thisCol[i])) return false;
}
}
return true;
}
// Count solutions (stops at `limit`)
function countSolutions(
given: Cell[][], hEdges: EdgeConstraint[][], vEdges: EdgeConstraint[][],
size: number, limit = 2
): number {
const grid = given.map(r => [...r]);
let count = 0;
function bt(pos: number): void {
if (count >= limit) return;
if (pos === size * size) { count++; return; }
const r = Math.floor(pos / size), c = pos % size;
if (grid[r][c] !== null) { bt(pos + 1); return; }
for (const v of ["sun", "moon"] as Cell[]) {
if (consistent(grid, hEdges, vEdges, r, c, v, size)) {
grid[r][c] = v;
bt(pos + 1);
grid[r][c] = null;
}
}
}
bt(0);
return count;
}
function generateSolution(size: number, rng: () => number): Cell[][] {
const grid: Cell[][] = Array.from({ length: size }, () => Array(size).fill(null));
const noHEdges: EdgeConstraint[][] = Array.from({ length: size }, () => Array(size - 1).fill(null));
const noVEdges: EdgeConstraint[][] = Array.from({ length: size - 1 }, () => Array(size).fill(null));
function bt(pos: number): boolean {
if (pos === size * size) return true;
const r = Math.floor(pos / size), c = pos % size;
const opts: Cell[] = rng() > 0.5 ? ["sun", "moon"] : ["moon", "sun"];
for (const v of opts) {
if (consistent(grid, noHEdges, noVEdges, r, c, v, size)) {
grid[r][c] = v;
if (bt(pos + 1)) return true;
grid[r][c] = null;
}
}
return false;
}
bt(0);
return grid;
}
/**
* Check that the puzzle can be solved purely by logical deduction (no guessing).
* At each step, at least one empty cell must be forced (only one consistent value).
* This ensures the puzzle never requires trial-and-error.
*/
function isHumanSolvable(
given: Cell[][], hEdges: EdgeConstraint[][], vEdges: EdgeConstraint[][], size: number
): boolean {
const grid = given.map(r => [...r]);
let progress = true;
while (progress) {
progress = false;
for (let r = 0; r < size; r++) {
for (let c = 0; c < size; c++) {
if (grid[r][c] !== null) continue;
const canSun = consistent(grid, hEdges, vEdges, r, c, "sun", size);
const canMoon = consistent(grid, hEdges, vEdges, r, c, "moon", size);
if (!canSun && !canMoon) return false; // contradiction
if (canSun !== canMoon) {
grid[r][c] = canSun ? "sun" : "moon"; // forced
progress = true;
}
}
}
}
return grid.every(row => row.every(c => c !== null));
}
export function generateTango(date: string): TangoPuzzle {
const seed = date.split("-").reduce((a, n) => a * 1000 + parseInt(n), 0) + 999;
const rng = createRng(seed);
const size = 6;
const solution = generateSolution(size, rng);
// Start with ALL edges as constraints
const hEdges: EdgeConstraint[][] = Array.from({ length: size }, () => Array(size - 1).fill(null));
const vEdges: EdgeConstraint[][] = Array.from({ length: size - 1 }, () => Array(size).fill(null));
for (let r = 0; r < size; r++)
for (let c = 0; c < size - 1; c++)
hEdges[r][c] = solution[r][c] === solution[r][c + 1] ? "=" : "x";
for (let r = 0; r < size - 1; r++)
for (let c = 0; c < size; c++)
vEdges[r][c] = solution[r][c] === solution[r + 1][c] ? "=" : "x";
// Build shuffled edge list
const edges: Array<{ type: "h" | "v"; r: number; c: number }> = [];
for (let r = 0; r < size; r++)
for (let c = 0; c < size - 1; c++) edges.push({ type: "h", r, c });
for (let r = 0; r < size - 1; r++)
for (let c = 0; c < size; c++) edges.push({ type: "v", r, c });
const given: Cell[][] = Array.from({ length: size }, () => Array(size).fill(null));
// Place given cells first (like LinkedIn: ~9-11 pre-placed sun/moon values)
const allCells: [number, number][] = [];
for (let r = 0; r < size; r++) for (let c = 0; c < size; c++) allCells.push([r, c]);
const cellOrder = shuffle(allCells, rng);
const targetGivens = 9 + Math.floor(rng() * 3); // 911 given cells
for (const [r, c] of cellOrder) {
if (given.flat().filter(Boolean).length >= targetGivens) break;
given[r][c] = solution[r][c];
if (countSolutions(given, hEdges, vEdges, size) > 1) given[r][c] = null;
}
// Remove edges greedily while maintaining unique solution
for (const e of shuffle(edges, rng)) {
if (e.type === "h") {
const old = hEdges[e.r][e.c];
hEdges[e.r][e.c] = null;
if (countSolutions(given, hEdges, vEdges, size) !== 1 ||
!isHumanSolvable(given, hEdges, vEdges, size)) hEdges[e.r][e.c] = old;
} else {
const old = vEdges[e.r][e.c];
vEdges[e.r][e.c] = null;
if (countSolutions(given, hEdges, vEdges, size) !== 1 ||
!isHumanSolvable(given, hEdges, vEdges, size)) vEdges[e.r][e.c] = old;
}
}
return { size, given, hEdges, vEdges, solution };
}

254
lib/generators/zip.ts Normal file
View file

@ -0,0 +1,254 @@
import { createRng, shuffle } from "../rng";
export interface ZipPuzzle {
size: number;
path: [number, number][]; // solution path (all cells in order)
numberedCells: Record<string, number>; // "r,c" → waypoint number (1-based)
// walls[r][c] bitmask: bit0=right wall, bit1=bottom wall, bit2=left wall, bit3=top wall
walls: number[][];
}
export function canMove(walls: number[][], r1: number, c1: number, r2: number, c2: number): boolean {
const dr = r2 - r1, dc = c2 - c1;
if (dr === 0 && dc === 1) return !(walls[r1][c1] & 1); // right
if (dr === 1 && dc === 0) return !(walls[r1][c1] & 2); // down
if (dr === 0 && dc === -1) return !(walls[r1][c1] & 4); // left
if (dr === -1 && dc === 0) return !(walls[r1][c1] & 8); // up
return false;
}
function generateHamiltonianPath(size: number, rng: () => number): [number, number][] | null {
const total = size * size;
const visited = Array.from({ length: size }, () => Array(size).fill(false));
const path: [number, number][] = [];
const dirs: [number, number][] = [[-1, 0], [1, 0], [0, -1], [0, 1]];
const startR = Math.floor(rng() * size);
const startC = Math.floor(rng() * size);
visited[startR][startC] = true;
path.push([startR, startC]);
// Warnsdorff heuristic: prefer neighbors with fewer onward moves
function warnsdorffScore(r: number, c: number): number {
let n = 0;
for (const [dr, dc] of dirs) {
const nr = r + dr, nc = c + dc;
if (nr >= 0 && nr < size && nc >= 0 && nc < size && !visited[nr][nc]) n++;
}
return n;
}
function bt(): boolean {
if (path.length === total) return true;
const [r, c] = path[path.length - 1];
const nbrs = shuffle([...dirs], rng)
.map(([dr, dc]) => [r + dr, c + dc] as [number, number])
.filter(([nr, nc]) => nr >= 0 && nr < size && nc >= 0 && nc < size && !visited[nr][nc])
.sort((a, b) => warnsdorffScore(a[0], a[1]) - warnsdorffScore(b[0], b[1]));
for (const [nr, nc] of nbrs) {
visited[nr][nc] = true;
path.push([nr, nc]);
if (bt()) return true;
path.pop();
visited[nr][nc] = false;
}
return false;
}
return bt() ? path : null;
}
// Build walls on ALL non-path-adjacent edges
function buildAllWalls(size: number, path: [number, number][]): number[][] {
const openEdges = new Set<string>();
for (let i = 0; i < path.length - 1; i++) {
const [r1, c1] = path[i], [r2, c2] = path[i + 1];
const key = r1 === r2
? `h:${r1},${Math.min(c1, c2)}`
: `v:${Math.min(r1, r2)},${c1}`;
openEdges.add(key);
}
const walls: number[][] = Array.from({ length: size }, () => Array(size).fill(0));
for (let r = 0; r < size; r++) {
for (let c = 0; c < size; c++) {
if (c < size - 1 && !openEdges.has(`h:${r},${c}`)) {
walls[r][c] |= 1;
walls[r][c + 1] |= 4;
}
if (r < size - 1 && !openEdges.has(`v:${r},${c}`)) {
walls[r][c] |= 2;
walls[r + 1][c] |= 8;
}
}
}
return walls;
}
// Count Hamiltonian paths visiting waypoints in order — stops at limit
function countZipSolutions(
size: number,
walls: number[][],
numberedCells: Record<string, number>,
limit = 2
): number {
const waypointOrder = Object.entries(numberedCells)
.sort(([, a], [, b]) => a - b)
.map(([k]) => k);
if (!waypointOrder.length) return 0;
const [startR, startC] = waypointOrder[0].split(",").map(Number);
const total = size * size;
let count = 0;
const visited = Array.from({ length: size }, () => Array(size).fill(false));
visited[startR][startC] = true;
let visitedCount = 1;
const dirs: [number, number][] = [[-1, 0], [1, 0], [0, -1], [0, 1]];
// Island pruning: BFS from (r,c) through unvisited cells — if unreachable cells exist, prune
const bfsQueue = new Int32Array(total);
const bfsSeen = new Uint8Array(total);
function allUnvisitedReachable(r: number, c: number): boolean {
const remaining = total - visitedCount;
if (remaining === 0) return true;
bfsSeen.fill(0);
let head = 0, tail = 0, found = 0;
bfsQueue[tail++] = r * size + c;
bfsSeen[r * size + c] = 1;
while (head < tail) {
const idx = bfsQueue[head++];
const cr = (idx / size) | 0, cc = idx % size;
for (const [dr, dc] of dirs) {
const nr = cr + dr, nc = cc + dc;
if (nr < 0 || nr >= size || nc < 0 || nc >= size) continue;
if (visited[nr][nc]) continue;
if (!canMove(walls, cr, cc, nr, nc)) continue;
const nk = nr * size + nc;
if (bfsSeen[nk]) continue;
bfsSeen[nk] = 1;
bfsQueue[tail++] = nk;
found++;
if (found === remaining) return true;
}
}
return found === remaining;
}
function dfs(r: number, c: number, nextWpIdx: number): void {
if (count >= limit) return;
if (visitedCount === total) {
if (nextWpIdx === waypointOrder.length) count++;
return;
}
for (const [dr, dc] of dirs) {
const nr = r + dr, nc = c + dc;
if (nr < 0 || nr >= size || nc < 0 || nc >= size) continue;
if (visited[nr][nc]) continue;
if (!canMove(walls, r, c, nr, nc)) continue;
const nextKey = `${nr},${nc}`;
const wpNum = numberedCells[nextKey];
// If the next cell is a waypoint, it must be the expected one
if (wpNum !== undefined) {
if (nextWpIdx >= waypointOrder.length || waypointOrder[nextWpIdx] !== nextKey) continue;
}
visited[nr][nc] = true;
visitedCount++;
if (allUnvisitedReachable(nr, nc)) {
dfs(nr, nc, wpNum !== undefined ? nextWpIdx + 1 : nextWpIdx);
}
visited[nr][nc] = false;
visitedCount--;
}
}
dfs(startR, startC, 1); // already at waypoint[0], so next expected is index 1
return count;
}
// Remove walls while uniqueness holds → sparse, interesting puzzle
function buildMinimalWalls(
size: number,
path: [number, number][],
numberedCells: Record<string, number>,
rng: () => number
): number[][] {
const walls = buildAllWalls(size, path);
// Collect all removable walls (non-path-adjacent edges)
const candidates: Array<{ r1: number; c1: number; r2: number; c2: number }> = [];
for (let r = 0; r < size; r++) {
for (let c = 0; c < size; c++) {
if (c < size - 1 && walls[r][c] & 1) candidates.push({ r1: r, c1: c, r2: r, c2: c + 1 });
if (r < size - 1 && walls[r][c] & 2) candidates.push({ r1: r, c1: c, r2: r + 1, c2: c });
}
}
for (const { r1, c1, r2, c2 } of shuffle(candidates, rng)) {
const dc = c2 - c1;
// Remove wall
if (dc === 1) { walls[r1][c1] &= ~1; walls[r2][c2] &= ~4; }
else { walls[r1][c1] &= ~2; walls[r2][c2] &= ~8; }
if (countZipSolutions(size, walls, numberedCells) !== 1) {
// Restore wall — this wall is necessary
if (dc === 1) { walls[r1][c1] |= 1; walls[r2][c2] |= 4; }
else { walls[r1][c1] |= 2; walls[r2][c2] |= 8; }
}
}
return walls;
}
export function zipSizeForDate(date: string): number {
const start = new Date("2024-01-01").getTime();
const days = Math.max(0, Math.floor((new Date(date).getTime() - start) / 86400000));
if (days < 200) return 6;
if (days < 500) return 7;
return 8;
}
export function generateZip(date: string, size?: number): ZipPuzzle {
const resolvedSize = size ?? zipSizeForDate(date);
const seed = date.split("-").reduce((a, n) => a * 1000 + parseInt(n), 0) + 777;
const rng = createRng(seed);
let path: [number, number][] | null = null;
let attempts = 0;
while (!path && attempts < 30) {
path = generateHamiltonianPath(resolvedSize, rng);
attempts++;
}
if (!path) path = snakePath(resolvedSize);
// LinkedIn Zip always has exactly 10 waypoints
const total = path.length;
const numWaypoints = 10;
const step = Math.floor((total - 1) / (numWaypoints - 1));
const waypointIndices = Array.from({ length: numWaypoints }, (_, i) =>
i === numWaypoints - 1 ? total - 1 : i * step
);
const numberedCells: Record<string, number> = {};
waypointIndices.forEach((idx, i) => {
const [r, c] = path![idx];
numberedCells[`${r},${c}`] = i + 1;
});
const walls = buildMinimalWalls(resolvedSize, path, numberedCells, rng);
return { size: resolvedSize, path, numberedCells, walls };
}
function snakePath(size: number): [number, number][] {
const path: [number, number][] = [];
for (let r = 0; r < size; r++)
for (let c = r % 2 === 0 ? 0 : size - 1; r % 2 === 0 ? c < size : c >= 0; r % 2 === 0 ? c++ : c--)
path.push([r, c]);
return path;
}

80
lib/levels.ts Normal file
View file

@ -0,0 +1,80 @@
/**
* Level system 100 levels per game, deterministic seeds, graduated difficulty.
*
* Seed strategy: level N synthetic date "LEVEL-{game}-{N:03d}"
* Each generator uses date.split("-").reduce() to derive a numeric seed,
* so we pass a date-shaped string that yields a unique seed per (game, level).
*
* Level N date string: `${1900 + N}-01-01`
* This stays far from real daily dates (2024+) and is unique per level.
*/
export const GAMES = ["queens", "tango", "zip", "sudoku", "patches"] as const;
export type GameId = typeof GAMES[number];
export const TOTAL_LEVELS = 100;
/** Convert level number (1100) to the synthetic date fed to the generator. */
export function levelToDate(level: number): string {
const n = Math.max(1, Math.min(TOTAL_LEVELS, level));
// e.g. level 1 → "1901-01-01", level 42 → "1942-01-01", level 100 → "2000-01-01"
return `${1900 + n}-01-01`;
}
/** Queens: N scales 6→10 across 100 levels. */
export function queensSizeForLevel(level: number): number {
if (level <= 15) return 6;
if (level <= 35) return 7;
if (level <= 60) return 8;
if (level <= 80) return 9;
return 10;
}
/** Zip: grid size scales with level. */
export function zipSizeForLevel(level: number): number {
if (level <= 20) return 5;
if (level <= 50) return 6;
if (level <= 75) return 7;
return 8;
}
export interface LevelMeta {
level: number;
difficulty: 1 | 2 | 3 | 4 | 5; // 1=easy … 5=expert
difficultyLabel: string;
}
const DIFFICULTY_LABELS = ["", "Facile", "Normal", "Intermédiaire", "Difficile", "Expert"];
export function levelMeta(game: GameId, level: number): LevelMeta {
let difficulty: 1 | 2 | 3 | 4 | 5;
if (game === "queens") {
if (level <= 15) difficulty = 1;
else if (level <= 35) difficulty = 2;
else if (level <= 60) difficulty = 3;
else if (level <= 80) difficulty = 4;
else difficulty = 5;
} else if (game === "zip") {
if (level <= 20) difficulty = 1;
else if (level <= 50) difficulty = 2;
else if (level <= 75) difficulty = 3;
else if (level <= 90) difficulty = 4;
else difficulty = 5;
} else {
// Tango, Sudoku, Patches: fixed size, difficulty is nominal
if (level <= 20) difficulty = 1;
else if (level <= 45) difficulty = 2;
else if (level <= 65) difficulty = 3;
else if (level <= 85) difficulty = 4;
else difficulty = 5;
}
return { level, difficulty, difficultyLabel: DIFFICULTY_LABELS[difficulty] };
}
export const GAME_META: Record<GameId, { name: string; accent: string; desc: string; subtitle: string; duration: string; symbol: string }> = {
queens: { name: "Queens", accent: "#d97706", desc: "Une couronne par ligne, colonne et zone colorée.", subtitle: "1 couronne par ligne, colonne et zone", duration: "≈ 3 min", symbol: "♛" },
tango: { name: "Tango", accent: "#ea580c", desc: "Équilibrez soleils et lunes selon les contraintes.", subtitle: "Équilibre soleils ☀ et lunes ◐ sur la grille", duration: "≈ 2 min", symbol: "☀" },
zip: { name: "Zip", accent: "#2563eb", desc: "Reliez les chiffres dans l'ordre en couvrant tout.", subtitle: "Relie les chiffres dans l'ordre en couvrant tout", duration: "≈ 2 min", symbol: "∞" },
sudoku: { name: "Sudoku", accent: "#16a34a", desc: "Chiffres 16 dans chaque ligne, colonne et bloc.", subtitle: "Chiffres 16 dans chaque ligne, colonne et bloc", duration: "≈ 4 min", symbol: "#" },
patches: { name: "Patches", accent: "#7c3aed", desc: "Remplissez chaque zone selon sa forme et sa taille.", subtitle: "Remplis chaque zone avec les bonnes pièces", duration: "≈ 3 min", symbol: "▦" },
};

98
lib/progress.ts Normal file
View file

@ -0,0 +1,98 @@
"use client";
/**
* Progress tracking localStorage-based profile.
* Structure ready for server-side sync in the future.
*/
import { GAMES, GameId, TOTAL_LEVELS } from "./levels";
const PROFILE_KEY = "pt-profile";
const PROGRESS_KEY = (game: GameId) => `pt-progress-${game}`;
export interface Profile {
id: string; // UUID
createdAt: string; // ISO date
name?: string;
}
export interface LevelRecord {
completedAt: string; // ISO datetime
bestTime: number; // seconds
attempts: number;
}
export type GameProgress = Record<number, LevelRecord>; // level → record
// ── Profile ───────────────────────────────────────────────────────────────────
function uuid(): string {
return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, (c) => {
const r = Math.random() * 16 | 0;
return (c === "x" ? r : (r & 0x3 | 0x8)).toString(16);
});
}
export function getProfile(): Profile {
if (typeof window === "undefined") return { id: "ssr", createdAt: new Date().toISOString() };
const raw = localStorage.getItem(PROFILE_KEY);
if (raw) try { return JSON.parse(raw) as Profile; } catch { /* corrupted, re-create */ }
const profile: Profile = { id: uuid(), createdAt: new Date().toISOString() };
localStorage.setItem(PROFILE_KEY, JSON.stringify(profile));
return profile;
}
// ── Progress ──────────────────────────────────────────────────────────────────
export function getGameProgress(game: GameId): GameProgress {
if (typeof window === "undefined") return {};
const raw = localStorage.getItem(PROGRESS_KEY(game));
if (!raw) return {};
try { return JSON.parse(raw); } catch { return {}; }
}
export function recordLevelSolve(game: GameId, level: number, elapsed: number): GameProgress {
const progress = getGameProgress(game);
const existing = progress[level];
progress[level] = {
completedAt: existing?.completedAt ?? new Date().toISOString(),
bestTime: existing ? Math.min(existing.bestTime, elapsed) : elapsed,
attempts: (existing?.attempts ?? 0) + 1,
};
localStorage.setItem(PROGRESS_KEY(game), JSON.stringify(progress));
return progress;
}
/** Returns the next uncompleted level (1-based), or null if all done. */
export function nextLevel(game: GameId): number {
const progress = getGameProgress(game);
for (let l = 1; l <= TOTAL_LEVELS; l++) {
if (!progress[l]) return l;
}
return TOTAL_LEVELS; // all done → stay on last
}
/** Summary stats for a game. */
export interface GameStats {
completed: number;
total: number;
pct: number; // 0100
bestTime: number; // fastest solve (seconds), 0 if none
nextLevel: number; // next uncompleted level
}
export function gameStats(game: GameId): GameStats {
const progress = getGameProgress(game);
const completed = Object.keys(progress).length;
const best = Object.values(progress).reduce((m, r) => Math.min(m, r.bestTime), Infinity);
return {
completed,
total: TOTAL_LEVELS,
pct: Math.round((completed / TOTAL_LEVELS) * 100),
bestTime: isFinite(best) ? best : 0,
nextLevel: nextLevel(game),
};
}
export function allStats(): Record<GameId, GameStats> {
return Object.fromEntries(GAMES.map(g => [g, gameStats(g)])) as Record<GameId, GameStats>;
}

29
lib/rng.ts Normal file
View file

@ -0,0 +1,29 @@
// Seeded pseudo-random number generator (mulberry32)
export function createRng(seed: number) {
let s = seed >>> 0;
return () => {
s += 0x6d2b79f5;
let t = Math.imul(s ^ (s >>> 15), 1 | s);
t ^= t + Math.imul(t ^ (t >>> 7), 61 | t);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
export function dateToSeed(date: string): number {
// date = YYYY-MM-DD
return date.split("-").reduce((acc, n) => acc * 1000 + parseInt(n), 0);
}
export function todayISO(): string {
const d = new Date();
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
}
export function shuffle<T>(arr: T[], rng: () => number): T[] {
const a = [...arr];
for (let i = a.length - 1; i > 0; i--) {
const j = Math.floor(rng() * (i + 1));
[a[i], a[j]] = [a[j], a[i]];
}
return a;
}

61
lib/rules.ts Normal file
View file

@ -0,0 +1,61 @@
export interface GameRules {
subtitle: string; // One-liner shown in home cards
duration: string; // Estimated time "≈ X min"
howToPlay: string[]; // 3-5 rules
tip?: string; // Optional pro tip
}
export const GAME_RULES: Record<string, GameRules> = {
queens: {
subtitle: "1 couronne par ligne, colonne et zone",
duration: "≈ 3 min",
howToPlay: [
"Place une seule couronne par ligne.",
"Place une seule couronne par colonne.",
"Place une seule couronne par zone colorée.",
"Les couronnes ne peuvent pas se toucher, même en diagonale.",
],
tip: "Commence par la zone la plus contrainte.",
},
tango: {
subtitle: "Équilibre soleils ☀ et lunes ◐ sur la grille",
duration: "≈ 2 min",
howToPlay: [
"Chaque ligne et colonne doit avoir autant de soleils que de lunes.",
"Pas plus de 2 symboles identiques consécutifs dans une ligne ou colonne.",
"Les contraintes = (même symbole) et × (symboles différents) doivent être respectées.",
],
tip: "Repère les contraintes × en premier — elles forcent des valeurs.",
},
zip: {
subtitle: "Relie les chiffres dans l'ordre en couvrant tout",
duration: "≈ 2 min",
howToPlay: [
"Trace un chemin continu de 1 jusqu'au dernier chiffre.",
"Le chemin doit passer par toutes les cellules de la grille.",
"Le chemin ne peut pas se croiser.",
],
tip: "Repère les coins et impasses pour contraindre le tracé.",
},
sudoku: {
subtitle: "Chiffres 16 dans chaque ligne, colonne et bloc",
duration: "≈ 4 min",
howToPlay: [
"Place les chiffres 1 à 6 dans chaque ligne.",
"Place les chiffres 1 à 6 dans chaque colonne.",
"Place les chiffres 1 à 6 dans chaque bloc 2×3.",
"Chaque chiffre n'apparaît qu'une seule fois par groupe.",
],
tip: "Commence par les lignes ou colonnes déjà les plus remplies.",
},
patches: {
subtitle: "Remplis chaque zone avec les bonnes pièces",
duration: "≈ 3 min",
howToPlay: [
"Chaque zone colorée doit être remplie entièrement.",
"Les pièces disponibles correspondent exactement aux zones.",
"Les pièces peuvent être tournées.",
],
tip: "Commence par les zones les plus petites et les plus contraintes.",
},
};

43
lib/session.ts Normal file
View file

@ -0,0 +1,43 @@
import { GAMES, GameId } from "@/lib/levels";
export type PlayMode = "free" | "session";
export function getPlayMode(): PlayMode {
if (typeof window === "undefined") return "free";
return (localStorage.getItem("pt-mode") as PlayMode) ?? "free";
}
export function savePlayMode(mode: PlayMode) {
localStorage.setItem("pt-mode", mode);
}
/**
* Session order for a given date: rotates the starting game each day.
* Day 0 Q T Z S P, Day 1 T Z S P Q, etc.
*/
export function getSessionOrder(date: string): GameId[] {
const [y, m, d] = date.split("-").map(Number);
const dayNum = Math.floor(Date.UTC(y, m - 1, d) / 86400000);
const start = dayNum % GAMES.length;
return [...GAMES.slice(start), ...GAMES.slice(0, start)];
}
/** Returns the next unsolved game in the session order, or null if all done / not in session mode. */
export function getNextSessionGame(currentGame: string, date: string): GameId | null {
if (typeof window === "undefined") return null;
if (getPlayMode() !== "session") return null;
const order = getSessionOrder(date);
const currentIdx = order.indexOf(currentGame as GameId);
if (currentIdx === -1) return null;
for (let i = currentIdx + 1; i < order.length; i++) {
const g = order[i];
try {
const raw = localStorage.getItem(`stats-${g}`);
const lastDate = raw ? JSON.parse(raw)?.lastDate : null;
if (lastDate !== date) return g; // not yet solved today
} catch { return g; }
}
return null; // all remaining games solved
}

87
lib/stats.ts Normal file
View file

@ -0,0 +1,87 @@
export interface GameStats {
streak: number;
lastDate: string; // ISO date YYYY-MM-DD of last solve
total: number;
bestTime: number; // seconds, 0 = never recorded
lastTime: number; // seconds for the most recent solve (0 if none)
solvedDates: string[]; // history of solved ISO dates (for heatmap)
}
const DEFAULT: GameStats = { streak: 0, lastDate: "", total: 0, bestTime: 0, lastTime: 0, solvedDates: [] };
function storageKey(game: string) { return `stats-${game}`; }
export function loadStats(game: string): GameStats {
if (typeof window === "undefined") return DEFAULT;
try {
const raw = localStorage.getItem(storageKey(game));
if (!raw) return DEFAULT;
return { ...DEFAULT, ...JSON.parse(raw) };
} catch { return DEFAULT; }
}
export function recordSolve(game: string, date: string, secs: number): GameStats {
const prev = loadStats(game);
// Compute yesterday ISO using local-date arithmetic (avoids UTC offset bug)
const [y, m, d] = date.split("-").map(Number);
const prev2 = new Date(y, m - 1, d - 1);
const yesterdayISO = `${prev2.getFullYear()}-${String(prev2.getMonth() + 1).padStart(2, "0")}-${String(prev2.getDate()).padStart(2, "0")}`;
let streak = prev.streak;
if (prev.lastDate === date) {
// Already solved today — don't double-count streak
} else if (prev.lastDate === yesterdayISO) {
streak += 1;
} else {
streak = 1;
}
// Build solved dates history (keep last 120 days, no duplicates)
const prevDates = prev.solvedDates ?? [];
const solvedDates = prevDates.includes(date) ? prevDates : [...prevDates, date].slice(-120);
const stats: GameStats = {
streak,
lastDate: date,
total: prev.lastDate === date ? prev.total : prev.total + 1,
bestTime: prev.bestTime === 0 || secs < prev.bestTime ? secs : prev.bestTime,
lastTime: secs,
solvedDates,
};
localStorage.setItem(storageKey(game), JSON.stringify(stats));
return stats;
}
// ── Ritual streak (global: all 5 games solved in a day) ───────────────────────
export interface RitualStats {
streak: number; // consecutive days with all 5 solved
lastDate: string; // last day all 5 were solved
}
const RITUAL_KEY = "stats-ritual";
export function getRitualStreak(): RitualStats {
if (typeof window === "undefined") return { streak: 0, lastDate: "" };
try {
const raw = localStorage.getItem(RITUAL_KEY);
return raw ? { ...{ streak: 0, lastDate: "" }, ...JSON.parse(raw) } : { streak: 0, lastDate: "" };
} catch { return { streak: 0, lastDate: "" }; }
}
/** Call this when all 5 games are confirmed solved today. Updates global ritual streak. */
export function updateRitualStreak(date: string): RitualStats {
if (typeof window === "undefined") return { streak: 0, lastDate: "" };
const prev = getRitualStreak();
if (prev.lastDate === date) return prev; // already counted today
const [y, m, d] = date.split("-").map(Number);
const yesterday = new Date(y, m - 1, d - 1);
const yesterdayISO = `${yesterday.getFullYear()}-${String(yesterday.getMonth() + 1).padStart(2, "0")}-${String(yesterday.getDate()).padStart(2, "0")}`;
const streak = prev.lastDate === yesterdayISO ? prev.streak + 1 : 1;
const updated: RitualStats = { streak, lastDate: date };
localStorage.setItem(RITUAL_KEY, JSON.stringify(updated));
return updated;
}

22
manifest.json Normal file
View file

@ -0,0 +1,22 @@
{
"name": "Puzzle Trainer",
"short_name": "Puzzles",
"description": "Entraîne-toi aux puzzles logiques chaque jour.",
"start_url": "/",
"display": "standalone",
"background_color": "#ffffff",
"theme_color": "#111827",
"lang": "fr",
"icons": [
{
"src": "/icon-192.png",
"sizes": "192x192",
"type": "image/png"
},
{
"src": "/icon-512.png",
"sizes": "512x512",
"type": "image/png"
}
]
}

45
mentions-legales/page.tsx Normal file
View file

@ -0,0 +1,45 @@
import type { Metadata } from "next";
export const metadata: Metadata = {
title: "Mentions légales — Puzzle Trainer",
};
export default function MentionsLegalesPage() {
return (
<div className="max-w-2xl mx-auto prose prose-sm text-gray-700">
<h1 className="text-2xl font-bold text-gray-900 mb-6">Mentions légales</h1>
<h2 className="text-lg font-semibold text-gray-800 mt-6 mb-2">Éditeur</h2>
<p>
<strong>Reverdin Studio</strong><br />
Corso Vittorio Emanuele II 154, Rome, Italie<br />
Partita IVA : IT17458801002<br />
Directeur de publication : Marc Reverdin<br />
Contact : <a href="mailto:mr@reverdin.eu" className="text-blue-600 hover:underline">mr@reverdin.eu</a>
</p>
<h2 className="text-lg font-semibold text-gray-800 mt-6 mb-2">Hébergement</h2>
<p>
<strong>Hetzner Online GmbH</strong><br />
Industriestr. 25, 91710 Gunzenhausen, Allemagne<br />
<a href="https://www.hetzner.com" className="text-blue-600 hover:underline" target="_blank" rel="noopener noreferrer">www.hetzner.com</a>
</p>
<h2 className="text-lg font-semibold text-gray-800 mt-6 mb-2">Données personnelles</h2>
<p>
Puzzle Trainer ne collecte aucune donnée personnelle. Toutes les données de progression
(niveaux complétés, temps, séries) sont stockées exclusivement dans le navigateur de
l&apos;utilisateur via <code>localStorage</code> et ne sont jamais transmises à un serveur.
</p>
<p>
Aucun cookie tiers ni traceur analytique n&apos;est utilisé.
</p>
<h2 className="text-lg font-semibold text-gray-800 mt-6 mb-2">Propriété intellectuelle</h2>
<p>
Les puzzles générés par Puzzle Trainer sont créés algorithmiquement. Le code source
de l&apos;application est la propriété exclusive de Reverdin Studio.
</p>
</div>
);
}

6
next-env.d.ts vendored Normal file
View file

@ -0,0 +1,6 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
import "./.next/types/routes.d.ts";
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.

32
next.config.ts Normal file
View file

@ -0,0 +1,32 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
output: "standalone",
async headers() {
return [
{
source: "/(.*)",
headers: [
{
key: "Content-Security-Policy",
value: [
"default-src 'self'",
"script-src 'self' 'unsafe-inline' 'unsafe-eval'",
"style-src 'self' 'unsafe-inline'",
"img-src 'self' data:",
"font-src 'self'",
"connect-src 'self'",
"frame-ancestors 'none'",
].join("; "),
},
{
key: "Permissions-Policy",
value: "camera=(), microphone=(), geolocation=()",
},
],
},
];
},
};
export default nextConfig;

Some files were not shown because too many files have changed in this diff Show more