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%;
|
||||
--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%;
|
||||
|
|
|
|||
|
|
@ -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("");
|
||||
|
|
|
|||
|
|
@ -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))'
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue