From 954110c6a20284cbb32a97c5dbd0ca3e744e63be Mon Sep 17 00:00:00 2001 From: "gpt-engineer-app[bot]" <159125892+gpt-engineer-app[bot]@users.noreply.github.com> Date: Fri, 8 Aug 2025 06:49:18 +0000 Subject: [PATCH] Add Knights and Knaves puzzle Adds the "Knights and Knaves - I" interactive to the Foundations section. The interactive features a central arena and five external boxes for statements. Dragging statements into the arena turns them green, and incompatible statements turn the arena red. --- src/components/KnightsAndKnavesI.tsx | 188 ++++++++++++++++++ src/index.css | 8 + .../theme-pages/discrete-math/Foundations.tsx | 8 + tailwind.config.ts | 4 + 4 files changed, 208 insertions(+) create mode 100644 src/components/KnightsAndKnavesI.tsx diff --git a/src/components/KnightsAndKnavesI.tsx b/src/components/KnightsAndKnavesI.tsx new file mode 100644 index 0000000..41882cd --- /dev/null +++ b/src/components/KnightsAndKnavesI.tsx @@ -0,0 +1,188 @@ +import { useEffect, useMemo, useState } from "react"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { cn } from "@/lib/utils"; + +// Statements +const STATEMENTS = [ + { id: "S", label: "At least one of us is a knave." }, + { id: "AK", label: "A is a knight" }, + { id: "AN", label: "A is a knave" }, + { id: "BK", label: "B is a knight" }, + { id: "BN", label: "B is a knave" }, +] as const; + +type StatementId = typeof STATEMENTS[number]["id"]; + +const KnightsAndKnavesI = () => { + const [inArena, setInArena] = useState([]); + + // SEO basics + useEffect(() => { + const title = "Knights and Knaves – I (Logic Puzzle)"; + document.title = title; + const desc = "Drag statements into the arena to test consistency in a classic Knights and Knaves puzzle."; + let meta = document.querySelector('meta[name="description"]'); + if (!meta) { + meta = document.createElement("meta"); + meta.setAttribute("name", "description"); + document.head.appendChild(meta); + } + meta.setAttribute("content", desc); + + // canonical + let link = document.querySelector('link[rel="canonical"]'); + if (!link) { + link = document.createElement("link"); + link.setAttribute("rel", "canonical"); + document.head.appendChild(link); + } + link.setAttribute("href", window.location.href); + }, []); + + const available = useMemo( + () => STATEMENTS.filter(s => !inArena.includes(s.id)), + [inArena] + ); + + function onDragStart(e: React.DragEvent, id: StatementId) { + e.dataTransfer.setData("text/plain", id); + e.dataTransfer.effectAllowed = "move"; + } + + function onArenaDrop(e: React.DragEvent) { + e.preventDefault(); + const id = e.dataTransfer.getData("text/plain") as StatementId; + if (id && !inArena.includes(id)) setInArena(prev => [...prev, id]); + } + + function onArenaDragOver(e: React.DragEvent) { + e.preventDefault(); + e.dataTransfer.dropEffect = "move"; + } + + function removeFromArena(id: StatementId) { + setInArena(prev => prev.filter(x => x !== id)); + } + + function reset() { + setInArena([]); + } + + // Consistency checker via brute force over assignments + const isConsistent = useMemo(() => { + // Try all assignments for A, B + const ids = new Set(inArena); + + for (const aKnight of [true, false]) { + for (const bKnight of [true, false]) { + // role constraints from dragged tokens + if (ids.has("AK") && !aKnight) continue; + if (ids.has("AN") && aKnight) continue; + if (ids.has("BK") && !bKnight) continue; + if (ids.has("BN") && bKnight) continue; + + // A's utterance if included: S = "At least one is a knave" + if (ids.has("S")) { + const S = (!aKnight) || (!bKnight); + // Knights tell truth, knaves lie + if (aKnight !== S) continue; + } + + // If we got here, this assignment fits all dragged statements + return true; + } + } + return inArena.length <= 1; // single or empty always treated as consistent for UX + }, [inArena]); + + const arenaClasses = cn( + "relative rounded-lg border transition-colors min-h-[320px] grid place-items-center p-6", + isConsistent ? "bg-surface border-outline" : "bg-destructive/10 border-destructive" + ); + + return ( +
+
+

Knights and Knaves – I

+

+ Knights always tell the truth, knaves always lie. A says: “At least one of us is a knave.” + Drag statements into the arena to test whether they can all be true together. +

+
+ +
+ {/* Available statements */} + + + Statements + + + {available.map(s => ( +
onDragStart(e, s.id)} + className="cursor-move select-none rounded-md border border-outline bg-surface-variant px-3 py-2 text-sm text-foreground shadow-card" + aria-label={`Drag: ${s.label}`} + role="button" + > + {s.label} +
+ ))} +
+
+ + {/* Arena */} +
+
+
+
+

{isConsistent ? "Consistent" : "Incompatible set"}

+ +
+ + {inArena.length === 0 ? ( +

+ Drag any statement here. Tokens turn green in the arena. If two statements clash, + the arena turns red. +

+ ) : ( +
+ {inArena.map(id => { + const s = STATEMENTS.find(x => x.id === id)!; + return ( +
removeFromArena(id)} + role="button" + tabIndex={0} + onKeyDown={(e) => (e.key === 'Enter' || e.key === ' ') && removeFromArena(id)} + > + {s.label} + × +
+ ); + })} +
+ )} +
+
+
+
+
+ ); +}; + +export default KnightsAndKnavesI; diff --git a/src/index.css b/src/index.css index 161fadf..056d54d 100644 --- a/src/index.css +++ b/src/index.css @@ -37,6 +37,10 @@ All colors MUST be HSL. --input: 220 13% 91%; --ring: 220 13% 18%; + /* Success semantics for positive feedback */ + --success: 142 71% 45%; + --success-foreground: 0 0% 98%; + /* Custom design tokens for the boxy aesthetic */ --surface: 0 0% 100%; --surface-variant: 220 13% 96%; @@ -99,6 +103,10 @@ All colors MUST be HSL. --input: 220 13% 15%; --ring: 220 13% 91%; + /* Success in dark */ + --success: 142 71% 45%; + --success-foreground: 0 0% 98%; + /* Dark mode custom tokens */ --surface: 220 13% 11%; --surface-variant: 220 13% 15%; diff --git a/src/pages/theme-pages/discrete-math/Foundations.tsx b/src/pages/theme-pages/discrete-math/Foundations.tsx index 6129156..1cdf8c0 100644 --- a/src/pages/theme-pages/discrete-math/Foundations.tsx +++ b/src/pages/theme-pages/discrete-math/Foundations.tsx @@ -8,6 +8,7 @@ import TernaryNumberGame from "@/components/TernaryNumberGame"; import BalancedTernaryGame from "@/components/BalancedTernaryGame"; import ZeckendorfGame from "@/components/ZeckendorfGame"; import ZeckendorfSearchTrick from "@/components/ZeckendorfSearchTrick"; +import KnightsAndKnavesI from "@/components/KnightsAndKnavesI"; interface Interactive { id: string; title: string; @@ -44,6 +45,13 @@ const interactives: Interactive[] = [{ tags: ["fibonacci", "numbers", "representation", "zeckendorf", "combinatorics"], component: ZeckendorfGame, greenScreenPath: "/discrete-math/foundations/zeckendorf" +}, { + id: "knights-and-knaves-i", + title: "Knights and Knaves - I", + description: "Drag statements into the arena to test consistency of A’s claim and roles in a classic Knights & Knaves puzzle.", + tags: ["logic", "puzzle", "knights", "knaves", "truth-tellers"], + component: KnightsAndKnavesI, + greenScreenPath: "/themes/discrete-math/foundations" }]; const Foundations = () => { const [searchTerm, setSearchTerm] = useState(""); diff --git a/tailwind.config.ts b/tailwind.config.ts index 9d2ab45..e2277b4 100644 --- a/tailwind.config.ts +++ b/tailwind.config.ts @@ -36,6 +36,10 @@ export default { DEFAULT: 'hsl(var(--destructive))', foreground: 'hsl(var(--destructive-foreground))' }, + success: { + DEFAULT: 'hsl(var(--success))', + foreground: 'hsl(var(--success-foreground))' + }, muted: { DEFAULT: 'hsl(var(--muted))', foreground: 'hsl(var(--muted-foreground))'