From 61feaaf9e93433e7cecc6a7b00f6d01c31e7bb65 Mon Sep 17 00:00:00 2001 From: "gpt-engineer-app[bot]" <159125892+gpt-engineer-app[bot]@users.noreply.github.com> Date: Sun, 10 Aug 2025 05:03:17 +0000 Subject: [PATCH] Add Dogs and Bunny puzzle Recreate the Dogs and Bunny puzzle with functionality for random puzzle generation at varying difficulty levels (easy, medium, hard). Allow users to screenshot puzzles or solutions. Implement puzzle sharing via URL parameters. --- package-lock.json | 24 ++ package.json | 3 + src/App.tsx | 3 +- src/components/DogsBunnyPuzzle.tsx | 497 +++++++++++++++++++++++++++++ src/pages/DogsBunnyPuzzlePage.tsx | 17 + src/pages/theme-pages/Puzzles.tsx | 10 + 6 files changed, 553 insertions(+), 1 deletion(-) create mode 100644 src/components/DogsBunnyPuzzle.tsx create mode 100644 src/pages/DogsBunnyPuzzlePage.tsx 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" } ];