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.
This commit is contained in:
gpt-engineer-app[bot] 2025-08-08 06:49:18 +00:00
parent 16ccffa5f4
commit 954110c6a2
4 changed files with 208 additions and 0 deletions

View file

@ -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<StatementId[]>([]);
// 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<HTMLDivElement>, id: StatementId) {
e.dataTransfer.setData("text/plain", id);
e.dataTransfer.effectAllowed = "move";
}
function onArenaDrop(e: React.DragEvent<HTMLDivElement>) {
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<HTMLDivElement>) {
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 (
<section className="space-y-6">
<header className="space-y-2">
<h1 className="text-3xl font-bold">Knights and Knaves I</h1>
<p className="text-muted-foreground max-w-3xl">
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.
</p>
</header>
<div className="grid lg:grid-cols-3 gap-6">
{/* Available statements */}
<Card className="lg:col-span-1">
<CardHeader>
<CardTitle className="text-xl">Statements</CardTitle>
</CardHeader>
<CardContent className="flex flex-wrap gap-3">
{available.map(s => (
<div
key={s.id}
draggable
onDragStart={(e) => 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}
</div>
))}
</CardContent>
</Card>
{/* Arena */}
<div className="lg:col-span-2">
<div
className={arenaClasses}
onDragOver={onArenaDragOver}
onDrop={onArenaDrop}
aria-label="Arena drop zone"
role="region"
>
<div className="w-full max-w-2xl">
<div className="flex items-center justify-between mb-4">
<p className={cn("text-sm font-medium", isConsistent ? "text-success" : "text-destructive")}
>{isConsistent ? "Consistent" : "Incompatible set"}</p>
<button
onClick={reset}
className="text-muted-foreground hover:text-foreground text-sm"
>Reset</button>
</div>
{inArena.length === 0 ? (
<p className="text-muted-foreground text-sm">
Drag any statement here. Tokens turn green in the arena. If two statements clash,
the arena turns red.
</p>
) : (
<div className="flex flex-wrap gap-3">
{inArena.map(id => {
const s = STATEMENTS.find(x => x.id === id)!;
return (
<div
key={id}
className="group inline-flex items-center gap-2 rounded-md border border-success bg-success/15 px-3 py-2 text-sm text-success shadow-card"
title="Click to remove"
onClick={() => removeFromArena(id)}
role="button"
tabIndex={0}
onKeyDown={(e) => (e.key === 'Enter' || e.key === ' ') && removeFromArena(id)}
>
{s.label}
<span className="text-success/70 group-hover:text-success/100">×</span>
</div>
);
})}
</div>
)}
</div>
</div>
</div>
</div>
</section>
);
};
export default KnightsAndKnavesI;

View file

@ -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%;

View file

@ -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 As 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("");

View file

@ -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))'