chore: initial commit — puzzle-trainer
This commit is contained in:
commit
57bf0092aa
148 changed files with 28619 additions and 0 deletions
76
Confetti.tsx
Normal file
76
Confetti.tsx
Normal 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
91
DailyPageShell.tsx
Normal 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'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
16
Dockerfile
Normal 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
50
ErrorBoundary.tsx
Normal 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'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
196
LevelGrid.tsx
Normal 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
101
LevelsPageShell.tsx
Normal 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
25
NavLink.tsx
Normal 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
476
PatchesBoard.tsx
Normal 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
555
QueensBoard.tsx
Normal 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
19
StreakBadge.tsx
Normal 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
253
SudokuBoard.tsx
Normal 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 1–6 : 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
359
TangoBoard.tsx
Normal 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
237
WinBanner.tsx
Normal 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
302
ZipBoard.tsx
Normal 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'ordre, en couvrant toutes les cases.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
25
app/apple-icon.tsx
Normal file
25
app/apple-icon.tsx
Normal 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
241
app/archive/page.tsx
Normal 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'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>
|
||||
);
|
||||
}
|
||||
98
app/comment-jouer/page.tsx
Normal file
98
app/comment-jouer/page.tsx
Normal 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
BIN
app/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 25 KiB |
212
app/globals.css
Normal file
212
app/globals.css
Normal 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
34
app/icon.tsx
Normal 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
89
app/layout.tsx
Normal 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
81
app/levels/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
45
app/mentions-legales/page.tsx
Normal file
45
app/mentions-legales/page.tsx
Normal 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'utilisateur via <code>localStorage</code> et ne sont jamais transmises à un serveur.
|
||||
</p>
|
||||
<p>
|
||||
Aucun cookie tiers ni traceur analytique n'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'application est la propriété exclusive de Reverdin Studio.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
69
app/opengraph-image.tsx
Normal file
69
app/opengraph-image.tsx
Normal 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
503
app/page.tsx
Normal 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'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>
|
||||
);
|
||||
}
|
||||
31
app/patches/[date]/page.tsx
Normal file
31
app/patches/[date]/page.tsx
Normal 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'hui</Link>
|
||||
<Link href="/archive?game=patches" className="text-gray-400 hover:text-gray-700 transition-colors">Archives</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
98
app/patches/level/[n]/page.tsx
Normal file
98
app/patches/level/[n]/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
7
app/patches/levels/page.tsx
Normal file
7
app/patches/levels/page.tsx
Normal 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
27
app/patches/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
31
app/queens/[date]/page.tsx
Normal file
31
app/queens/[date]/page.tsx
Normal 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'hui</Link>
|
||||
<Link href="/archive?game=queens" className="text-gray-400 hover:text-gray-700 transition-colors">Archives</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
125
app/queens/level/[n]/page.tsx
Normal file
125
app/queens/level/[n]/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
7
app/queens/levels/page.tsx
Normal file
7
app/queens/levels/page.tsx
Normal 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
27
app/queens/page.tsx
Normal 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
207
app/settings/page.tsx
Normal 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
27
app/sitemap.ts
Normal 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
215
app/stats/page.tsx
Normal 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'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 & 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>
|
||||
);
|
||||
}
|
||||
31
app/sudoku/[date]/page.tsx
Normal file
31
app/sudoku/[date]/page.tsx
Normal 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'hui</Link>
|
||||
<Link href="/archive?game=sudoku" className="text-gray-400 hover:text-gray-700 transition-colors">Archives</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
98
app/sudoku/level/[n]/page.tsx
Normal file
98
app/sudoku/level/[n]/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
7
app/sudoku/levels/page.tsx
Normal file
7
app/sudoku/levels/page.tsx
Normal 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
27
app/sudoku/page.tsx
Normal 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
31
app/tango/[date]/page.tsx
Normal 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'hui</Link>
|
||||
<Link href="/archive?game=tango" className="text-gray-400 hover:text-gray-700 transition-colors">Archives</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
98
app/tango/level/[n]/page.tsx
Normal file
98
app/tango/level/[n]/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
7
app/tango/levels/page.tsx
Normal file
7
app/tango/levels/page.tsx
Normal 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
27
app/tango/page.tsx
Normal 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
31
app/zip/[date]/page.tsx
Normal 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'hui</Link>
|
||||
<Link href="/archive?game=zip" className="text-gray-400 hover:text-gray-700 transition-colors">Archives</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
98
app/zip/level/[n]/page.tsx
Normal file
98
app/zip/level/[n]/page.tsx
Normal 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
7
app/zip/levels/page.tsx
Normal 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
27
app/zip/page.tsx
Normal 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
241
archive/page.tsx
Normal 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'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
980
bot-output/2026-05-11.json
Normal 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
980
bot-output/2026-05-12.json
Normal 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
980
bot-output/2026-05-17.json
Normal 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
992
bot-output/2026-05-18.json
Normal 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
992
bot-output/2026-05-19.json
Normal 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
980
bot-output/2026-05-20.json
Normal 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
980
bot-output/2026-05-21.json
Normal 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
980
bot-output/2026-05-22.json
Normal 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
89
components/BottomNav.tsx
Normal 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
76
components/Confetti.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
132
components/DailyPageShell.tsx
Normal file
132
components/DailyPageShell.tsx
Normal 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'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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
50
components/ErrorBoundary.tsx
Normal file
50
components/ErrorBoundary.tsx
Normal 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'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
196
components/LevelGrid.tsx
Normal 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
components/LevelsPageShell.tsx
Normal file
101
components/LevelsPageShell.tsx
Normal 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
25
components/NavLink.tsx
Normal 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
357
components/PatchesBoard.tsx
Normal 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
555
components/QueensBoard.tsx
Normal 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
141
components/RuleOverlay.tsx
Normal 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'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>
|
||||
);
|
||||
}
|
||||
19
components/StreakBadge.tsx
Normal file
19
components/StreakBadge.tsx
Normal 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
253
components/SudokuBoard.tsx
Normal 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 1–6 : 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
357
components/TangoBoard.tsx
Normal 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
296
components/WinBanner.tsx
Normal 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
302
components/ZipBoard.tsx
Normal 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'ordre, en couvrant toutes les cases.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
214
daily-bot.ts
Normal file
214
daily-bot.ts
Normal 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
16
docker-compose.yml
Normal 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
BIN
favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 25 KiB |
1
file.svg
Normal file
1
file.svg
Normal 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
192
generators/patches.ts
Normal 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.5–5 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
769
generators/queens.ts
Normal 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
93
generators/sudoku.ts
Normal 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
184
generators/tango.ts
Normal 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); // 9–11 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
254
generators/zip.ts
Normal 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
141
globals.css
Normal 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
1
globe.svg
Normal 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
86
layout.tsx
Normal 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
80
levels.ts
Normal 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 (1–100) 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 1–6 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
192
lib/generators/patches.ts
Normal 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.5–5 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
769
lib/generators/queens.ts
Normal 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
93
lib/generators/sudoku.ts
Normal 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
215
lib/generators/tango.ts
Normal 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); // 9–11 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
254
lib/generators/zip.ts
Normal 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
80
lib/levels.ts
Normal 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 (1–100) 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 1–6 dans chaque ligne, colonne et bloc.", subtitle: "Chiffres 1–6 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
98
lib/progress.ts
Normal 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; // 0–100
|
||||
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
29
lib/rng.ts
Normal 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
61
lib/rules.ts
Normal 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 1–6 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
43
lib/session.ts
Normal 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
87
lib/stats.ts
Normal 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
22
manifest.json
Normal 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
45
mentions-legales/page.tsx
Normal 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'utilisateur via <code>localStorage</code> et ne sont jamais transmises à un serveur.
|
||||
</p>
|
||||
<p>
|
||||
Aucun cookie tiers ni traceur analytique n'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'application est la propriété exclusive de Reverdin Studio.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
6
next-env.d.ts
vendored
Normal file
6
next-env.d.ts
vendored
Normal 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
32
next.config.ts
Normal 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
Loading…
Add table
Reference in a new issue