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:
parent
16ccffa5f4
commit
954110c6a2
4 changed files with 208 additions and 0 deletions
188
src/components/KnightsAndKnavesI.tsx
Normal file
188
src/components/KnightsAndKnavesI.tsx
Normal 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;
|
||||||
|
|
@ -37,6 +37,10 @@ All colors MUST be HSL.
|
||||||
--input: 220 13% 91%;
|
--input: 220 13% 91%;
|
||||||
--ring: 220 13% 18%;
|
--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 */
|
/* Custom design tokens for the boxy aesthetic */
|
||||||
--surface: 0 0% 100%;
|
--surface: 0 0% 100%;
|
||||||
--surface-variant: 220 13% 96%;
|
--surface-variant: 220 13% 96%;
|
||||||
|
|
@ -99,6 +103,10 @@ All colors MUST be HSL.
|
||||||
--input: 220 13% 15%;
|
--input: 220 13% 15%;
|
||||||
--ring: 220 13% 91%;
|
--ring: 220 13% 91%;
|
||||||
|
|
||||||
|
/* Success in dark */
|
||||||
|
--success: 142 71% 45%;
|
||||||
|
--success-foreground: 0 0% 98%;
|
||||||
|
|
||||||
/* Dark mode custom tokens */
|
/* Dark mode custom tokens */
|
||||||
--surface: 220 13% 11%;
|
--surface: 220 13% 11%;
|
||||||
--surface-variant: 220 13% 15%;
|
--surface-variant: 220 13% 15%;
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ import TernaryNumberGame from "@/components/TernaryNumberGame";
|
||||||
import BalancedTernaryGame from "@/components/BalancedTernaryGame";
|
import BalancedTernaryGame from "@/components/BalancedTernaryGame";
|
||||||
import ZeckendorfGame from "@/components/ZeckendorfGame";
|
import ZeckendorfGame from "@/components/ZeckendorfGame";
|
||||||
import ZeckendorfSearchTrick from "@/components/ZeckendorfSearchTrick";
|
import ZeckendorfSearchTrick from "@/components/ZeckendorfSearchTrick";
|
||||||
|
import KnightsAndKnavesI from "@/components/KnightsAndKnavesI";
|
||||||
interface Interactive {
|
interface Interactive {
|
||||||
id: string;
|
id: string;
|
||||||
title: string;
|
title: string;
|
||||||
|
|
@ -44,6 +45,13 @@ const interactives: Interactive[] = [{
|
||||||
tags: ["fibonacci", "numbers", "representation", "zeckendorf", "combinatorics"],
|
tags: ["fibonacci", "numbers", "representation", "zeckendorf", "combinatorics"],
|
||||||
component: ZeckendorfGame,
|
component: ZeckendorfGame,
|
||||||
greenScreenPath: "/discrete-math/foundations/zeckendorf"
|
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 Foundations = () => {
|
||||||
const [searchTerm, setSearchTerm] = useState("");
|
const [searchTerm, setSearchTerm] = useState("");
|
||||||
|
|
|
||||||
|
|
@ -36,6 +36,10 @@ export default {
|
||||||
DEFAULT: 'hsl(var(--destructive))',
|
DEFAULT: 'hsl(var(--destructive))',
|
||||||
foreground: 'hsl(var(--destructive-foreground))'
|
foreground: 'hsl(var(--destructive-foreground))'
|
||||||
},
|
},
|
||||||
|
success: {
|
||||||
|
DEFAULT: 'hsl(var(--success))',
|
||||||
|
foreground: 'hsl(var(--success-foreground))'
|
||||||
|
},
|
||||||
muted: {
|
muted: {
|
||||||
DEFAULT: 'hsl(var(--muted))',
|
DEFAULT: 'hsl(var(--muted))',
|
||||||
foreground: 'hsl(var(--muted-foreground))'
|
foreground: 'hsl(var(--muted-foreground))'
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue