diff --git a/package-lock.json b/package-lock.json index 1dc22ac..7e686ba 100644 --- a/package-lock.json +++ b/package-lock.json @@ -43,11 +43,8 @@ "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", @@ -4665,12 +4662,6 @@ "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", @@ -4899,12 +4890,6 @@ "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", @@ -5676,15 +5661,6 @@ "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 2faefe1..50578b1 100644 --- a/package.json +++ b/package.json @@ -46,11 +46,8 @@ "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 9573c2b..fadaae4 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,7 +103,6 @@ const App = () => ( } /> } /> } /> - } /> } /> } /> } /> diff --git a/src/components/DogsBunnyPuzzle.tsx b/src/components/DogsBunnyPuzzle.tsx deleted file mode 100644 index df47ea3..0000000 --- a/src/components/DogsBunnyPuzzle.tsx +++ /dev/null @@ -1,497 +0,0 @@ -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 deleted file mode 100644 index 2332ea2..0000000 --- a/src/pages/DogsBunnyPuzzlePage.tsx +++ /dev/null @@ -1,17 +0,0 @@ -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 a8b7919..3cf3896 100644 --- a/src/pages/theme-pages/Puzzles.tsx +++ b/src/pages/theme-pages/Puzzles.tsx @@ -55,16 +55,6 @@ 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" } ];