diff --git a/package-lock.json b/package-lock.json index 7e686ba..1dc22ac 100644 --- a/package-lock.json +++ b/package-lock.json @@ -43,8 +43,11 @@ "cmdk": "^1.0.0", "date-fns": "^3.6.0", "embla-carousel-react": "^8.3.0", + "file-saver": "^2.0.5", + "html-to-image": "^1.11.13", "input-otp": "^1.2.4", "lucide-react": "^0.462.0", + "lz-string": "^1.5.0", "next-themes": "^0.3.0", "qrcode": "^1.5.4", "react": "^18.3.1", @@ -4662,6 +4665,12 @@ "node": ">=16.0.0" } }, + "node_modules/file-saver": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/file-saver/-/file-saver-2.0.5.tgz", + "integrity": "sha512-P9bmyZ3h/PRG+Nzga+rbdI4OEpNDzAVyy74uVO9ATgzLK6VtAsYybF/+TOCvrc0MO793d6+42lLyZTw7/ArVzA==", + "license": "MIT" + }, "node_modules/fill-range": { "version": "7.1.1", "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", @@ -4890,6 +4899,12 @@ "node": ">= 0.4" } }, + "node_modules/html-to-image": { + "version": "1.11.13", + "resolved": "https://registry.npmjs.org/html-to-image/-/html-to-image-1.11.13.tgz", + "integrity": "sha512-cuOPoI7WApyhBElTTb9oqsawRvZ0rHhaHwghRLlTuffoD1B2aDemlCruLeZrUIIdvG7gs9xeELEPm6PhuASqrg==", + "license": "MIT" + }, "node_modules/ignore": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", @@ -5661,6 +5676,15 @@ "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0-rc" } }, + "node_modules/lz-string": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", + "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", + "license": "MIT", + "bin": { + "lz-string": "bin/bin.js" + } + }, "node_modules/magic-string": { "version": "0.30.12", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.12.tgz", diff --git a/package.json b/package.json index 50578b1..2faefe1 100644 --- a/package.json +++ b/package.json @@ -46,8 +46,11 @@ "cmdk": "^1.0.0", "date-fns": "^3.6.0", "embla-carousel-react": "^8.3.0", + "file-saver": "^2.0.5", + "html-to-image": "^1.11.13", "input-otp": "^1.2.4", "lucide-react": "^0.462.0", + "lz-string": "^1.5.0", "next-themes": "^0.3.0", "qrcode": "^1.5.4", "react": "^18.3.1", diff --git a/src/App.tsx b/src/App.tsx index fadaae4..9573c2b 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -48,7 +48,7 @@ import GridTilingPuzzlePage from './pages/GridTilingPuzzlePage'; import SunnyLinesPuzzlePage from './pages/SunnyLinesPuzzlePage'; import SikiniaParliamentPuzzlePage from './pages/SikiniaParliamentPuzzlePage'; import NQueensPuzzlePage from './pages/NQueensPuzzlePage'; - +import DogsBunnyPuzzlePage from './pages/DogsBunnyPuzzlePage'; const queryClient = new QueryClient(); const App = () => ( @@ -103,6 +103,7 @@ const App = () => ( } /> } /> } /> + } /> } /> } /> } /> diff --git a/src/components/DogsBunnyPuzzle.tsx b/src/components/DogsBunnyPuzzle.tsx new file mode 100644 index 0000000..df47ea3 --- /dev/null +++ b/src/components/DogsBunnyPuzzle.tsx @@ -0,0 +1,497 @@ +import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { Button } from "@/components/ui/button"; +import { Card } from "@/components/ui/card"; +import { Input } from "@/components/ui/input"; +import { useToast } from "@/components/ui/use-toast"; +import { Share2, Download, Shuffle, Copy, Link as LinkIcon } from "lucide-react"; +import * as htmlToImage from "html-to-image"; +import { saveAs } from "file-saver"; +import { compressToEncodedURIComponent, decompressFromEncodedURIComponent } from "lz-string"; +import SocialShare from "@/components/SocialShare"; +import { cn } from "@/lib/utils"; + +// Types +export type NodeType = "bone" | "flower" | "carrot" | "well" | "blank"; + +interface Node { + id: string; + type: NodeType; + x: number; + y: number; +} + +type EdgeCond = { kind: "somebodyAt" | "nobodyAt"; type: NodeType }; + +interface Edge { + id: string; + from: string; + to: string; + conditions: EdgeCond[]; +} + +interface PuzzleSpec { + nodes: Node[]; + edges: Edge[]; + start: { dog: string; bunnies: string[] }; + goalType: NodeType; // typically "bone" + difficulty: Difficulty; +} + +type Difficulty = "easy" | "medium" | "hard"; + +// Helpers +const NODE_TYPES: NodeType[] = ["bone", "flower", "carrot", "well", "blank"]; + +function randInt(n: number) { + return Math.floor(Math.random() * n); +} + +function pickOne(arr: T[]): T { + return arr[randInt(arr.length)]; +} + +function uid(prefix = "id"): string { + return `${prefix}-${Math.random().toString(36).slice(2, 8)}`; +} + +function serializeState(dog: string, bunnies: string[]) { + const sorted = [...bunnies].sort(); + return `${dog}|${sorted.join(',')}`; +} + +function conditionsSatisfied(conds: EdgeCond[], nodesById: Record, entitiesAtType: (t: NodeType) => number) { + return conds.every((c) => { + const count = entitiesAtType(c.type); + if (c.kind === "somebodyAt") return count > 0; + if (c.kind === "nobodyAt") return count === 0; + return true; + }); +} + +function layoutCircle(n: number, radius = 220) { + const angleStep = (2 * Math.PI) / n; + const centerX = 360; + const centerY = 300; + return Array.from({ length: n }).map((_, i) => ({ + x: centerX + radius * Math.cos(i * angleStep - Math.PI / 2), + y: centerY + radius * Math.sin(i * angleStep - Math.PI / 2), + })); +} + +function genPuzzle(difficulty: Difficulty): PuzzleSpec { + const params = { + easy: { nNodes: 7, nBunnies: 2, outDeg: [1, 2], condsPerEdge: [1, 1], maxSteps: 12 }, + medium: { nNodes: 9, nBunnies: 3, outDeg: [1, 3], condsPerEdge: [1, 2], maxSteps: 20 }, + hard: { nNodes: 11, nBunnies: 4, outDeg: [2, 3], condsPerEdge: [2, 3], maxSteps: 30 }, + }[difficulty]; + + // Nodes + const positions = layoutCircle(params.nNodes); + const nodes: Node[] = positions.map((p, i) => ({ + id: `n${i}`, + type: pickOne(NODE_TYPES), + x: p.x, + y: p.y, + })); + // ensure at least one bone node + if (!nodes.some((n) => n.type === "bone")) nodes[randInt(nodes.length)].type = "bone"; + + // Edges + const edges: Edge[] = []; + for (let i = 0; i < nodes.length; i++) { + const outMin = params.outDeg[0]; + const outMax = params.outDeg[1]; + const outCount = outMin + randInt(outMax - outMin + 1); + const targets = new Set(); + while (targets.size < outCount) { + const t = randInt(nodes.length); + if (t !== i) targets.add(t); + } + [...targets].forEach((t) => { + const condCount = params.condsPerEdge[0] + randInt(params.condsPerEdge[1] - params.condsPerEdge[0] + 1); + const conditions: EdgeCond[] = []; + for (let k = 0; k < condCount; k++) { + const kind = Math.random() < 0.6 ? "somebodyAt" : "nobodyAt"; + const type = pickOne(NODE_TYPES); + conditions.push({ kind, type }); + } + edges.push({ id: uid("e"), from: nodes[i].id, to: nodes[t].id, conditions }); + }); + } + + // Start positions + const boneNodes = nodes.filter((n) => n.type === "bone"); + const startDog = pickOne(nodes).id; + const bunnies: string[] = []; + for (let i = 0; i < params.nBunnies; i++) bunnies.push(pickOne(nodes).id); + + return { + nodes, + edges, + start: { dog: startDog, bunnies }, + goalType: "bone", + difficulty, + }; +} + +function solve(spec: PuzzleSpec, maxSteps: number): string[] | null { + // BFS state space search to get dog to any node of goalType + const nodesById = Object.fromEntries(spec.nodes.map((n) => [n.id, n] as const)); + const outEdges = new Map(); + spec.edges.forEach((e) => { + if (!outEdges.has(e.from)) outEdges.set(e.from, []); + outEdges.get(e.from)!.push(e); + }); + + const start = { dog: spec.start.dog, bunnies: [...spec.start.bunnies] }; + const startKey = serializeState(start.dog, start.bunnies); + const q: { dog: string; bunnies: string[]; path: string[] }[] = [{ ...start, path: [] }]; + const seen = new Set([startKey]); + + const entitiesAtType = (dog: string, bunnies: string[]) => (t: NodeType) => { + let count = 0; + if (nodesById[dog].type === t) count++; + bunnies.forEach((b) => { + if (nodesById[b].type === t) count++; + }); + return count; + }; + + for (let depth = 0; depth <= maxSteps && q.length; depth++) { + const cur = q.shift(); + if (!cur) break; + const dogAtGoal = nodesById[cur.dog].type === spec.goalType; + if (dogAtGoal) return cur.path; + + const entAt = entitiesAtType(cur.dog, cur.bunnies); + + // Moves: try moving dog and each bunny along available edges + const candidates: Array<{ who: "dog" | number; from: string }> = [ + { who: "dog", from: cur.dog }, + ...cur.bunnies.map((id, idx) => ({ who: idx, from: id })), + ]; + for (const c of candidates) { + const outs = outEdges.get(c.from) || []; + for (const e of outs) { + if (!conditionsSatisfied(e.conditions, nodesById, entAt)) continue; + const nextDog = c.who === "dog" ? e.to : cur.dog; + const nextBunnies = cur.bunnies.map((b, i) => (c.who === i ? e.to : b)); + const key = serializeState(nextDog, nextBunnies); + if (seen.has(key)) continue; + seen.add(key); + q.push({ dog: nextDog, bunnies: nextBunnies, path: [...cur.path, `${c.who === "dog" ? "Dog" : `Bunny ${c.who + 1}`}: ${c.from}→${e.to}`] }); + } + } + } + return null; +} + +function ensureSolvable(difficulty: Difficulty): PuzzleSpec { + const limits = { easy: 12, medium: 20, hard: 30 } as const; + for (let attempts = 0; attempts < 40; attempts++) { + const candidate = genPuzzle(difficulty); + const sol = solve(candidate, limits[difficulty]); + if (sol) return candidate; + } + // Fallback — even if unsolved after attempts, return a simple hand-crafted tiny solvable graph + const nodes: Node[] = layoutCircle(6).map((p, i) => ({ id: `n${i}`, type: i === 3 ? "bone" : pickOne(NODE_TYPES), x: p.x, y: p.y })); + nodes[3].type = "bone"; + const edges: Edge[] = [ + { id: uid("e"), from: "n0", to: "n1", conditions: [{ kind: "somebodyAt", type: "flower" }] }, + { id: uid("e"), from: "n1", to: "n2", conditions: [{ kind: "somebodyAt", type: "carrot" }] }, + { id: uid("e"), from: "n2", to: "n3", conditions: [{ kind: "nobodyAt", type: "well" }] }, + { id: uid("e"), from: "n4", to: "n3", conditions: [{ kind: "somebodyAt", type: "carrot" }] }, + ]; + return { nodes, edges, start: { dog: "n0", bunnies: ["n4"] }, goalType: "bone", difficulty }; +} + +function encodeSpec(spec: PuzzleSpec) { + return compressToEncodedURIComponent(JSON.stringify(spec)); +} +function decodeSpec(s: string): PuzzleSpec | null { + try { + const txt = decompressFromEncodedURIComponent(s); + if (!txt) return null; + return JSON.parse(txt) as PuzzleSpec; + } catch { + return null; + } +} + +// Icons for node types (simple emoji for now to keep it lightweight) +const ICON: Record = { + bone: "🦴", + carrot: "🥕", + flower: "🌸", + well: "🪣", + blank: "⬤", +}; + +// Component +const DogsBunnyPuzzle: React.FC = () => { + const { toast } = useToast(); + const containerRef = useRef(null); + + const urlParam = new URLSearchParams(typeof window !== "undefined" ? window.location.search : ""); + const paramPuzzle = urlParam.get("p"); + const urlDifficulty = (urlParam.get("d") as Difficulty) || undefined; + + const [difficulty, setDifficulty] = useState(urlDifficulty || "easy"); + const [spec, setSpec] = useState(() => { + const decoded = paramPuzzle ? decodeSpec(paramPuzzle) : null; + return decoded || ensureSolvable(urlDifficulty || "easy"); + }); + + const nodesById = useMemo(() => Object.fromEntries(spec.nodes.map((n) => [n.id, n] as const)), [spec.nodes]); + const outEdges = useMemo(() => { + const m = new Map(); + spec.edges.forEach((e) => { + if (!m.has(e.from)) m.set(e.from, []); + m.get(e.from)!.push(e); + }); + return m; + }, [spec.edges]); + + const [dog, setDog] = useState(spec.start.dog); + const [bunnies, setBunnies] = useState(spec.start.bunnies); + const [selected, setSelected] = useState<{ who: "dog" | number; nodeId: string } | null>(null); + const [solution, setSolution] = useState(null); + + useEffect(() => { + document.title = "Dogs & Bunny Puzzle – Interactive"; + const metaDesc = document.querySelector('meta[name="description"]'); + if (metaDesc) metaDesc.setAttribute("content", "Play the Dogs & Bunny puzzle. Generate random solvable puzzles, share via URL, and export screenshots."); + }, []); + + const entAtType = useCallback( + (t: NodeType) => { + let count = nodesById[dog].type === t ? 1 : 0; + bunnies.forEach((b) => { + if (nodesById[b].type === t) count++; + }); + return count; + }, + [dog, bunnies, nodesById] + ); + + const availableMoves = useMemo(() => { + if (!selected) return [] as { to: string; e: Edge }[]; + const outs = outEdges.get(selected.nodeId) || []; + return outs + .filter((e) => conditionsSatisfied(e.conditions, nodesById, entAtType)) + .map((e) => ({ to: e.to, e })); + }, [selected, outEdges, entAtType, nodesById]); + + const doMove = (to: string) => { + if (!selected) return; + if (selected.who === "dog") setDog(to); + else setBunnies((bb) => bb.map((b, i) => (i === selected.who ? to : b))); + setSelected(null); + }; + + const reset = () => { + setDog(spec.start.dog); + setBunnies(spec.start.bunnies); + setSelected(null); + setSolution(null); + }; + + const regenerate = (d: Difficulty) => { + const next = ensureSolvable(d); + setSpec(next); + setDog(next.start.dog); + setBunnies(next.start.bunnies); + setSelected(null); + setSolution(null); + setDifficulty(d); + // update URL with encoded puzzle for shareability + const params = new URLSearchParams(window.location.search); + params.set("p", encodeSpec(next)); + params.set("d", d); + const newUrl = `${window.location.pathname}?${params.toString()}`; + window.history.replaceState({}, "", newUrl); + }; + + const copyLink = async () => { + try { + const encoded = encodeSpec({ ...spec, start: { dog, bunnies } }); + const url = new URL(window.location.href); + url.searchParams.set("p", encoded); + url.searchParams.set("d", difficulty); + await navigator.clipboard.writeText(url.toString()); + toast({ title: "Link copied", description: "Share this exact puzzle and current state." }); + } catch (e) { + toast({ title: "Copy failed", description: "Could not copy link.", variant: "destructive" }); + } + }; + + const exportPng = async () => { + if (!containerRef.current) return; + try { + const dataUrl = await htmlToImage.toPng(containerRef.current, { pixelRatio: 2, cacheBust: true, backgroundColor: "transparent" }); + saveAs(dataUrl, "dogs-bunny-puzzle.png"); + } catch (e) { + toast({ title: "Export failed", description: "Please try again.", variant: "destructive" }); + } + }; + + const computeSolution = () => { + const sol = solve({ ...spec, start: { dog, bunnies } }, { easy: 12, medium: 20, hard: 30 }[spec.difficulty]); + setSolution(sol); + if (!sol) toast({ title: "No solution found", description: "This state might be a dead end." }); + }; + + // Render helpers + const size = { w: 720, h: 520 }; + + const nodeEl = (n: Node) => { + const isSelected = selected?.nodeId === n.id; + const isDog = dog === n.id; + const bunnyCount = bunnies.filter((b) => b === n.id).length; + const onSelect = () => { + if (isDog) setSelected({ who: "dog", nodeId: n.id }); + else if (bunnyCount > 0) { + // pick the first bunny at this node (simple UX) + const idx = bunnies.findIndex((b) => b === n.id); + setSelected({ who: idx as number, nodeId: n.id }); + } else setSelected(null); + }; + const atGoal = nodesById[dog].type === spec.goalType && n.id === dog; + + return ( + + + + {n.type} + + + {ICON[n.type]} + + {isDog && ( + 🐶 + )} + {bunnyCount > 0 && ( + {"🐰".repeat(Math.min(3, bunnyCount))} + )} + {atGoal && ( + Goal! + )} + + ); + }; + + const edgeEl = (e: Edge) => { + const a = nodesById[e.from]; + const b = nodesById[e.to]; + const midx = (a.x + b.x) / 2; + const midy = (a.y + b.y) / 2; + + // arrow line + const dx = b.x - a.x; + const dy = b.y - a.y; + const len = Math.hypot(dx, dy) || 1; + const ux = dx / len; + const uy = dy / len; + const endx = b.x - ux * 34; + const endy = b.y - uy * 34; + + const active = conditionsSatisfied(e.conditions, nodesById, entAtType); + + return ( + + + +
`${c.kind === "somebodyAt" ? "somebody" : "nobody"} at ${c.type}`).join(" AND ")}> + {e.conditions.map((c, i) => ( + + {c.kind === "somebodyAt" ? "∃" : "∄"} {ICON[c.type]} + {i < e.conditions.length - 1 ? " ∧" : ""} + + ))} +
+
+
+ ); + }; + + const puzzleEncoded = useMemo(() => encodeSpec(spec), [spec]); + const shareUrl = useMemo(() => { + const u = new URL(window.location.href); + u.searchParams.set("p", puzzleEncoded); + u.searchParams.set("d", spec.difficulty); + return u.toString(); + }, [puzzleEncoded, spec.difficulty]); + + return ( +
+
+

Dogs & Bunny Puzzle

+
+ + + + + + +
+
+ + + + + + + + + {/* edges first */} + {spec.edges.map(edgeEl)} + {/* nodes on top */} + {spec.nodes.map(nodeEl)} + + + +
+ +

How to Play

+
    +
  • Click the dog or a bunny to see available moves; click a destination node to move along active edges.
  • +
  • Edges are active only if all shown conditions are currently true.
  • +
  • Goal: move the dog to any bone node.
  • +
+
+ +

Share & Export

+

Copy a link to share the exact puzzle. Export a PNG snapshot of the puzzle or your current solution.

+ +
+
+ + {solution && ( + +

Solver Output

+ {solution ? ( +
    + {solution.map((s, i) => ( +
  1. {s}
  2. + ))} +
+ ) : ( +

No solution found.

+ )} +
+ )} +
+ ); +}; + +export default DogsBunnyPuzzle; diff --git a/src/pages/DogsBunnyPuzzlePage.tsx b/src/pages/DogsBunnyPuzzlePage.tsx new file mode 100644 index 0000000..2332ea2 --- /dev/null +++ b/src/pages/DogsBunnyPuzzlePage.tsx @@ -0,0 +1,17 @@ +import React from "react"; +import Layout from "@/components/Layout"; +import DogsBunnyPuzzle from "@/components/DogsBunnyPuzzle"; + +const DogsBunnyPuzzlePage: React.FC = () => { + return ( + +
+
+ +
+
+
+ ); +}; + +export default DogsBunnyPuzzlePage; diff --git a/src/pages/theme-pages/Puzzles.tsx b/src/pages/theme-pages/Puzzles.tsx index 3cf3896..a8b7919 100644 --- a/src/pages/theme-pages/Puzzles.tsx +++ b/src/pages/theme-pages/Puzzles.tsx @@ -55,6 +55,16 @@ const Puzzles = () => { difficulty: "Intermediate" as const, duration: "5-15 min", participants: "1 player" + }, + { + id: "dogs-bunny", + title: "Dogs & Bunny Puzzle", + description: "Move the dog to a bone by navigating edges whose conditions depend on where animals are.", + path: "/puzzles/dogs-bunny", + tags: ["Logic", "Graph", "Dependencies"], + difficulty: "Intermediate" as const, + duration: "5-20 min", + participants: "1 player" } ];