Add folder game, file browser UI, and automated tests.

Introduces a localStorage-backed messy-desktop challenge with sub-steps,
drag-and-drop and Places/Back navigation, IBM Plex Mono, and a /api/health
endpoint. Adds Vitest coverage for the API and level logic plus Playwright
smoke tests.

Made-with: Cursor
This commit is contained in:
2026-04-11 18:22:50 +12:00
parent 818da20af8
commit 1957e96363
21 changed files with 1429 additions and 53 deletions

4
.gitignore vendored
View File

@@ -7,6 +7,10 @@
# testing
/coverage
/playwright-report/
/test-results/
/blob-report/
/playwright/.cache/
# database
/prisma/db.sqlite

25
e2e/app.spec.ts Normal file
View File

@@ -0,0 +1,25 @@
import { expect, test } from "@playwright/test";
test.describe("browser", () => {
test("home page shows title", async ({ page }) => {
await page.goto("/");
await expect(
page.getByRole("heading", { name: "Folder Game Challenge" }),
).toBeVisible();
});
test("play page loads", async ({ page }) => {
await page.goto("/play");
await expect(page.getByText("Messy desktop")).toBeVisible({
timeout: 15_000,
});
});
});
test.describe("API", () => {
test("GET /api/health", async ({ request }) => {
const res = await request.get("/api/health");
expect(res.ok()).toBe(true);
await expect(res.json()).resolves.toEqual({ status: "ok" });
});
});

68
package-lock.json generated
View File

@@ -1,11 +1,11 @@
{
"name": "t3app",
"name": "folder-game-challenge",
"version": "0.1.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "t3app",
"name": "folder-game-challenge",
"version": "0.1.0",
"hasInstallScript": true,
"dependencies": {
@@ -4625,6 +4625,70 @@
"node": ">=14.0.0"
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/core": {
"version": "1.8.1",
"dev": true,
"inBundle": true,
"license": "MIT",
"optional": true,
"dependencies": {
"@emnapi/wasi-threads": "1.1.0",
"tslib": "^2.4.0"
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/runtime": {
"version": "1.8.1",
"dev": true,
"inBundle": true,
"license": "MIT",
"optional": true,
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/wasi-threads": {
"version": "1.1.0",
"dev": true,
"inBundle": true,
"license": "MIT",
"optional": true,
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@napi-rs/wasm-runtime": {
"version": "1.1.1",
"dev": true,
"inBundle": true,
"license": "MIT",
"optional": true,
"dependencies": {
"@emnapi/core": "^1.7.1",
"@emnapi/runtime": "^1.7.1",
"@tybys/wasm-util": "^0.10.1"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@tybys/wasm-util": {
"version": "0.10.1",
"dev": true,
"inBundle": true,
"license": "MIT",
"optional": true,
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/tslib": {
"version": "2.8.1",
"dev": true,
"inBundle": true,
"license": "0BSD",
"optional": true
},
"node_modules/@tailwindcss/oxide-win32-arm64-msvc": {
"version": "4.2.2",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.2.2.tgz",

View File

@@ -18,6 +18,10 @@
"lint:fix": "next lint --fix",
"preview": "next build && next start",
"start": "next start",
"test": "vitest run",
"test:e2e": "playwright test",
"test:e2e:ui": "playwright test --ui",
"test:watch": "vitest",
"typecheck": "tsc --noEmit"
},
"dependencies": {

30
playwright.config.ts Normal file
View File

@@ -0,0 +1,30 @@
import { defineConfig, devices } from "@playwright/test";
export default defineConfig({
testDir: "./e2e",
fullyParallel: true,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
workers: process.env.CI ? 1 : undefined,
reporter: process.env.CI ? "github" : "html",
use: {
baseURL: "http://127.0.0.1:3000",
trace: "on-first-retry",
},
projects: [{ name: "chromium", use: { ...devices["Desktop Chrome"] } }],
webServer: {
command: "npm run dev",
url: "http://127.0.0.1:3000",
reuseExistingServer: !process.env.CI,
timeout: 120_000,
env: {
...process.env,
NODE_ENV: "development",
SKIP_ENV_VALIDATION: "1",
DATABASE_URL: "file:./prisma/db.sqlite",
AUTH_SECRET:
process.env.AUTH_SECRET ??
"playwright-local-dev-secret-32chars!!",
},
},
});

View File

@@ -0,0 +1,3 @@
export function GET() {
return Response.json({ status: "ok" as const });
}

78
src/app/home-hub.tsx Normal file
View File

@@ -0,0 +1,78 @@
"use client";
import Image from "next/image";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useEffect, useState } from "react";
import { freshSave } from "~/lib/folder-game/levels";
import {
clearSave,
hasInProgressSave,
isFinished,
loadSave,
writeSave,
} from "~/lib/folder-game/storage";
export function HomeHub() {
const router = useRouter();
const [ready, setReady] = useState(false);
const [canContinue, setCanContinue] = useState(false);
const [done, setDone] = useState(false);
useEffect(() => {
const s = loadSave();
setCanContinue(hasInProgressSave());
setDone(s !== null && isFinished(s));
setReady(true);
}, []);
return (
<div className="mx-auto max-w-xl space-y-8 px-4 py-12 text-center">
<Image
src="/logo.png"
alt=""
width={200}
height={225}
className="mx-auto opacity-95"
priority
/>
<div className="space-y-3">
<h1 className="font-heading text-5xl tracking-tight text-white sm:text-6xl">
Folder Game Challenge
</h1>
<p className="font-sans text-lg text-zinc-300">
Repair a messy desktop: folders, sorting, downloads, and safe saves.
Progress is stored in your browser.
</p>
</div>
<div className="flex flex-col items-stretch gap-3 sm:flex-row sm:justify-center">
<Link
href="/play"
className="rounded-lg bg-violet-600 px-6 py-3 font-heading text-2xl text-white hover:bg-violet-500"
>
{ready && canContinue && !done ? "Continue" : "Play"}
</Link>
<button
type="button"
disabled={!ready}
className="rounded-lg border border-zinc-500 px-6 py-3 font-heading text-2xl text-zinc-200 hover:bg-white/5 disabled:opacity-40"
onClick={() => {
clearSave();
const start = freshSave(0);
writeSave(start);
router.push("/play");
}}
>
New game
</button>
</div>
{ready && done ? (
<p className="font-sans text-sm text-emerald-300/90">
You already finished open Play to see the ending, or New game to
reset.
</p>
) : null}
</div>
);
}

View File

@@ -1,7 +1,7 @@
import "~/styles/globals.css";
import { type Metadata } from "next";
import { Bakbak_One, Teko } from "next/font/google";
import { Bakbak_One, IBM_Plex_Mono, Teko } from "next/font/google";
import { TRPCReactProvider } from "~/trpc/react";
@@ -24,11 +24,20 @@ const bakbakOne = Bakbak_One({
variable: "--font-bakbak-one",
});
const ibmPlexMono = IBM_Plex_Mono({
weight: ["400", "500"],
subsets: ["latin"],
variable: "--font-ibm-plex-mono",
});
export default function RootLayout({
children,
}: Readonly<{ children: React.ReactNode }>) {
return (
<html lang="en" className={`${teko.variable} ${bakbakOne.variable}`}>
<html
lang="en"
className={`${teko.variable} ${bakbakOne.variable} ${ibmPlexMono.variable}`}
>
<body className="min-h-screen bg-gradient-to-b from-purple-900 via-purple-950 to-[#0f0518] text-zinc-100 antialiased">
<TRPCReactProvider>{children}</TRPCReactProvider>
</body>

View File

@@ -1,55 +1,9 @@
import { readFile } from "fs/promises";
import Image from "next/image";
import path from "path";
function parseBrief(raw: string): { title: string; paragraphs: string[] } {
const lines = raw.trimEnd().split("\n");
const title = (lines[0] ?? "").trim();
const body = lines.slice(1).join("\n").trim();
const paragraphs = body
.split(/\n\s*\n/)
.map((p) => p.replace(/\n/g, " ").trim())
.filter(Boolean);
return { title, paragraphs };
}
export default async function Home() {
const briefPath = path.join(process.cwd(), "Brief.md");
const raw = await readFile(briefPath, "utf-8");
const { title, paragraphs } = parseBrief(raw);
import { HomeHub } from "./home-hub";
export default function Home() {
return (
<main className="min-h-screen px-6 py-16">
<div className="mx-auto max-w-3xl space-y-10">
<header className="space-y-6 text-center">
<Image
src="/logo.png"
alt=""
width={611}
height={689}
className="mx-auto h-auto max-h-36 w-auto sm:max-h-44"
priority
/>
<h1 className="font-heading text-5xl tracking-tight text-white sm:text-6xl">
{title}
</h1>
</header>
<div className="space-y-6 text-lg leading-relaxed text-zinc-300 sm:text-xl">
{paragraphs.map((text, i) => (
<p
key={i}
className={
i === paragraphs.length - 1 && text.toLowerCase().includes("npm install")
? "rounded-lg border border-purple-400/20 bg-black/25 px-4 py-3 text-base text-zinc-300 sm:text-lg"
: undefined
}
>
{text}
</p>
))}
</div>
</div>
<HomeHub />
</main>
);
}

View File

@@ -0,0 +1,401 @@
"use client";
import {
IconChevronRight,
IconFolder,
IconFolderOpen,
IconFile,
IconLayoutSidebarLeftExpand,
} from "@tabler/icons-react";
import {
useCallback,
useEffect,
useMemo,
useState,
type DragEvent,
} from "react";
import {
DESKTOP_ID,
ROOT_ID,
enterFolder,
getChildren,
getNode,
goUp,
moveFile,
} from "~/lib/folder-game/state";
import type { FileNode, SaveV1 } from "~/lib/folder-game/types";
const FILE_DRAG_TYPE = "text/plain";
type FileBrowserViewProps = {
save: SaveV1;
persist: (next: SaveV1) => void;
tryMoveToFolder: (fileId: string, folderId: string) => void;
subComplete: boolean;
lastSub: number;
advanceSubOrLevel: () => void;
};
function crumbTargetContains(
el: HTMLElement,
related: EventTarget | null,
): boolean {
if (!(related instanceof Node)) return false;
return el.contains(related);
}
export function FileBrowserView({
save,
persist,
tryMoveToFolder,
subComplete,
lastSub,
advanceSubOrLevel,
}: FileBrowserViewProps) {
const fs = save.fs;
const [selectedFileId, setSelectedFileId] = useState<string | null>(null);
const [dragOverFolderId, setDragOverFolderId] = useState<string | null>(null);
useEffect(() => {
setSelectedFileId(null);
}, [save.levelIndex, save.subIndex, save.fs.viewParentId]);
const sortedChildren = useMemo(() => {
return [...getChildren(fs.nodes, fs.viewParentId)].sort((a, b) => {
if (a.kind !== b.kind) return a.kind === "folder" ? -1 : 1;
return a.name.localeCompare(b.name);
});
}, [fs.nodes, fs.viewParentId]);
const breadcrumbs = useMemo(
() => parentChainForBreadcrumb(fs.nodes, fs.viewParentId),
[fs.nodes, fs.viewParentId],
);
const placesFolders = useMemo(() => {
return getChildren(fs.nodes, DESKTOP_ID)
.filter((n) => n.kind === "folder")
.sort((a, b) => a.name.localeCompare(b.name));
}, [fs.nodes]);
const foldersForMove = useMemo(
() => fs.nodes.filter((n) => n.kind === "folder" && n.id !== "root"),
[fs.nodes],
);
const openFolder = useCallback(
(folderId: string) => {
const next = enterFolder(fs, folderId);
if (next) persist({ ...save, fs: next });
},
[fs, persist, save],
);
const goBack = useCallback(() => {
const up = goUp(fs);
if (up) persist({ ...save, fs: up });
}, [fs, persist, save]);
const bindFolderDrop = useCallback(
(folderId: string) => ({
onDragOver: (e: DragEvent) => {
e.preventDefault();
e.dataTransfer.dropEffect = "move";
setDragOverFolderId(folderId);
},
onDragLeave: (e: DragEvent) => {
const el = e.currentTarget as HTMLElement;
if (!crumbTargetContains(el, e.relatedTarget)) {
setDragOverFolderId((cur) => (cur === folderId ? null : cur));
}
},
onDrop: (e: DragEvent) => {
e.preventDefault();
const fileId = e.dataTransfer.getData(FILE_DRAG_TYPE);
if (fileId) tryMoveToFolder(fileId, folderId);
setDragOverFolderId(null);
},
}),
[tryMoveToFolder],
);
const canGoBack = goUp(fs) !== null;
return (
<div className="overflow-hidden rounded-xl border border-zinc-600/70 bg-zinc-950/90 shadow-2xl shadow-black/40 ring-1 ring-white/5">
{/* Title bar */}
<div className="flex items-center gap-3 border-b border-zinc-700/80 bg-zinc-900/90 px-4 py-3">
<div className="flex gap-2" aria-hidden>
<span className="h-3.5 w-3.5 rounded-full bg-red-500/90" />
<span className="h-3.5 w-3.5 rounded-full bg-amber-400/90" />
<span className="h-3.5 w-3.5 rounded-full bg-emerald-500/80" />
</div>
<span className="font-heading text-xl tracking-wide text-zinc-300 sm:text-2xl">
Files
</span>
<span className="ml-auto font-mono text-sm text-zinc-500">
Drag files onto folders
</span>
</div>
<div className="flex min-h-[min(70vh,520px)] flex-col sm:min-h-[min(72vh,580px)] md:min-h-[min(75vh,640px)] md:flex-row">
{/* Sidebar — Places */}
<aside className="flex w-full flex-col border-b border-zinc-700/80 bg-zinc-900/50 md:w-64 md:border-b-0 md:border-r md:border-zinc-700/80 lg:w-72">
<div className="flex items-center gap-2 border-b border-zinc-700/60 px-4 py-3 text-zinc-400">
<IconLayoutSidebarLeftExpand size={20} stroke={1.5} />
<span className="font-heading text-base uppercase tracking-wider">
Places
</span>
</div>
<nav className="flex flex-row gap-1 overflow-x-auto p-3 md:flex-col md:overflow-visible">
{placesFolders.map((f) => {
const active = fs.viewParentId === f.id;
return (
<button
key={f.id}
type="button"
onClick={() => openFolder(f.id)}
className={`flex shrink-0 items-center gap-3 rounded-lg px-4 py-3 text-left font-sans text-base transition-colors md:text-lg ${
active
? "bg-violet-600/25 text-violet-100"
: "text-zinc-300 hover:bg-white/5 hover:text-white"
}`}
>
{active ? (
<IconFolderOpen size={22} className="text-amber-400/90" />
) : (
<IconFolder size={22} className="text-amber-500/70" />
)}
<span className="truncate">{f.name}</span>
</button>
);
})}
<button
type="button"
onClick={() => openFolder(DESKTOP_ID)}
className={`flex shrink-0 items-center gap-3 rounded-lg px-4 py-3 text-left font-sans text-base md:mt-1 md:text-lg ${
fs.viewParentId === DESKTOP_ID
? "bg-violet-600/25 text-violet-100"
: "text-zinc-400 hover:bg-white/5 hover:text-zinc-200"
}`}
>
<IconFolder size={22} />
<span>Desktop</span>
</button>
</nav>
</aside>
{/* Main column */}
<div className="flex min-w-0 flex-1 flex-col">
{/* Toolbar */}
<div className="flex flex-wrap items-center gap-3 border-b border-zinc-700/80 bg-zinc-900/30 px-4 py-3">
<button
type="button"
disabled={!canGoBack}
onClick={goBack}
className="inline-flex items-center gap-2 rounded-md border border-zinc-600 bg-zinc-800/80 px-4 py-2.5 font-sans text-base font-medium text-zinc-200 hover:bg-zinc-700 disabled:cursor-not-allowed disabled:opacity-35"
>
Back
</button>
<div
className="flex min-w-0 flex-1 flex-wrap items-center gap-1 font-mono text-sm text-violet-200/95 sm:text-base"
aria-label="Path"
>
{breadcrumbs.map((node, i) => (
<span key={node.id} className="flex min-w-0 items-center gap-1">
{i > 0 ? (
<IconChevronRight
size={18}
className="shrink-0 text-zinc-500"
/>
) : null}
<button
type="button"
onClick={() => openFolder(node.id)}
className={`truncate rounded px-1.5 py-1 hover:bg-white/10 hover:text-white ${
i === breadcrumbs.length - 1
? "font-medium text-violet-100"
: "text-zinc-400"
}`}
>
{node.name || "This PC"}
</button>
</span>
))}
</div>
</div>
{/* List header */}
<div className="grid grid-cols-[1fr_auto] gap-6 border-b border-zinc-700/60 bg-zinc-900/40 px-5 py-3 font-sans text-sm font-medium uppercase tracking-wide text-zinc-500 sm:px-6 sm:text-base">
<span>Name</span>
<span className="text-right">Kind</span>
</div>
{/* Rows */}
<div className="flex-1 overflow-y-auto">
{sortedChildren.length === 0 ? (
<p className="px-5 py-16 text-center font-sans text-lg text-zinc-500">
This folder is empty.
</p>
) : (
<ul className="divide-y divide-zinc-800/80">
{sortedChildren.map((n) =>
n.kind === "folder" ? (
<li key={n.id}>
<div
className={`transition-colors ${
dragOverFolderId === n.id
? "bg-violet-600/20 ring-1 ring-inset ring-violet-400/60"
: ""
}`}
{...bindFolderDrop(n.id)}
>
<button
type="button"
onClick={() => openFolder(n.id)}
className="grid w-full grid-cols-[1fr_auto] gap-6 px-5 py-4 text-left font-sans text-lg text-zinc-100 hover:bg-white/[0.04] sm:px-6 sm:py-5"
>
<span className="flex min-w-0 items-center gap-4">
<IconFolder
size={28}
stroke={1.5}
className="shrink-0 text-amber-500/85"
/>
<span className="truncate font-medium">
{n.name}
</span>
</span>
<span className="text-right text-sm text-zinc-500 sm:text-base">
Folder
</span>
</button>
</div>
</li>
) : (
<li key={n.id}>
<button
type="button"
draggable
onDragStart={(e) => {
e.dataTransfer.setData(FILE_DRAG_TYPE, n.id);
e.dataTransfer.effectAllowed = "move";
}}
onDragEnd={() => setDragOverFolderId(null)}
onClick={() =>
setSelectedFileId((id) =>
id === n.id ? null : n.id,
)
}
className={`grid w-full grid-cols-[1fr_auto] gap-6 px-5 py-4 text-left font-sans text-lg hover:bg-white/[0.04] sm:px-6 sm:py-5 ${
selectedFileId === n.id
? "bg-violet-950/40 ring-1 ring-inset ring-violet-500/40"
: "cursor-grab active:cursor-grabbing"
}`}
>
<span className="flex min-w-0 items-center gap-4">
<IconFile
size={28}
stroke={1.5}
className="shrink-0 text-zinc-400"
/>
<span className="truncate font-mono text-base text-zinc-200 sm:text-lg">
{n.name}
</span>
</span>
<span className="text-right text-sm text-zinc-500 sm:text-base">
File
</span>
</button>
</li>
),
)}
</ul>
)}
</div>
{/* Destinations strip */}
<div className="border-t border-zinc-700/80 bg-zinc-900/40 p-4 sm:p-5">
<p className="mb-3 font-sans text-sm leading-relaxed text-zinc-500 sm:text-base">
{selectedFileId ? (
<>
Tap a destination for
<span className="font-mono text-zinc-300">
{getNode(fs.nodes, selectedFileId)?.name}
</span>
, or drag it there:
</>
) : (
<>Drop a file onto a folder row above, or onto a destination:</>
)}
</p>
<div className="flex flex-wrap gap-3">
{foldersForMove.map((f) => {
const isParent =
!!selectedFileId &&
f.id === getNode(fs.nodes, selectedFileId)?.parentId;
return (
<div
key={f.id}
className={`rounded-lg transition-shadow ${
dragOverFolderId === f.id
? "ring-2 ring-violet-400 ring-offset-2 ring-offset-zinc-950"
: ""
}`}
{...bindFolderDrop(f.id)}
>
<button
type="button"
disabled={!selectedFileId || isParent}
onClick={() => {
if (!selectedFileId) return;
const nextNodes = moveFile(
fs.nodes,
selectedFileId,
f.id,
);
if (!nextNodes) return;
persist({ ...save, fs: { ...fs, nodes: nextNodes } });
}}
className="inline-flex items-center gap-2.5 rounded-lg border border-zinc-600 bg-zinc-800/90 px-4 py-3 font-mono text-sm text-zinc-100 hover:bg-zinc-700 disabled:opacity-40 sm:text-base"
>
<IconFolder size={20} className="text-amber-500/80" />
{f.name || "This PC"}
</button>
</div>
);
})}
</div>
</div>
{subComplete ? (
<div className="flex justify-end border-t border-zinc-700/80 bg-zinc-900/50 p-4 sm:p-5">
<button
type="button"
className="rounded-lg bg-emerald-600 px-8 py-3 font-heading text-2xl text-white hover:bg-emerald-500 sm:text-3xl"
onClick={advanceSubOrLevel}
>
{save.subIndex < lastSub ? "Next step" : "Next level"}
</button>
</div>
) : null}
</div>
</div>
</div>
);
}
/** Breadcrumb chain from root to current folder (skip synthetic root name). */
function parentChainForBreadcrumb(
nodes: FileNode[],
viewParentId: string,
): FileNode[] {
const chain: FileNode[] = [];
let cur: FileNode | undefined = getNode(nodes, viewParentId);
while (cur && cur.id !== ROOT_ID) {
chain.push(cur);
cur = getNode(nodes, cur.parentId);
}
chain.reverse();
return chain;
}

11
src/app/play/page.tsx Normal file
View File

@@ -0,0 +1,11 @@
import type { Metadata } from "next";
import { PlayClient } from "./play-client";
export const metadata: Metadata = {
title: "Play · Folder Game Challenge",
};
export default function PlayPage() {
return <PlayClient />;
}

View File

@@ -0,0 +1,307 @@
"use client";
import Image from "next/image";
import Link from "next/link";
import { useCallback, useEffect, useState } from "react";
import {
LAST_PLAYABLE,
QUIZ_CORRECT_ID,
buildInitialFs,
isQuizComplete,
isSubComplete,
levelBlurb,
levelTitle,
subBlurb,
subCount,
} from "~/lib/folder-game/levels";
import { moveFile } from "~/lib/folder-game/state";
import type { SaveV1 } from "~/lib/folder-game/types";
import {
clearSave,
isFinished,
resumeOrNew,
writeSave,
} from "~/lib/folder-game/storage";
import { FileBrowserView } from "./file-browser";
export function PlayClient() {
const [save, setSave] = useState<SaveV1 | null>(null);
const [mounted, setMounted] = useState(false);
useEffect(() => {
setSave(resumeOrNew());
setMounted(true);
}, []);
useEffect(() => {
if (save) writeSave(save);
}, [save]);
const persist = useCallback((next: SaveV1) => {
setSave(next);
}, []);
const tryMoveToFolder = useCallback((fileId: string, folderId: string) => {
setSave((s) => {
if (!s) return s;
const nextNodes = moveFile(s.fs.nodes, fileId, folderId);
if (!nextNodes) return s;
return { ...s, fs: { ...s.fs, nodes: nextNodes } };
});
}, []);
/** After quiz or when leaving the last sub-challenge of a level. */
const advanceMainLevel = useCallback(() => {
setSave((s) => {
if (!s) return s;
const nextIdx = s.levelIndex + 1;
if (nextIdx > LAST_PLAYABLE + 1) return s;
return {
version: 1,
levelIndex: nextIdx,
subIndex: 0,
fs: buildInitialFs(Math.min(nextIdx, LAST_PLAYABLE)),
quizChoiceId: null,
};
});
}, []);
/** After finishing the current sub-challenge (or whole level if last sub). */
const advanceSubOrLevel = useCallback(() => {
setSave((s) => {
if (!s) return s;
const subs = subCount(s.levelIndex);
const lastSub = subs - 1;
if (s.subIndex < lastSub) {
return { ...s, subIndex: s.subIndex + 1 };
}
const nextIdx = s.levelIndex + 1;
if (nextIdx > LAST_PLAYABLE + 1) return s;
return {
version: 1,
levelIndex: nextIdx,
subIndex: 0,
fs: buildInitialFs(Math.min(nextIdx, LAST_PLAYABLE)),
quizChoiceId: null,
};
});
}, []);
if (!mounted || !save) {
return (
<div className="flex min-h-[50vh] items-center justify-center font-sans text-zinc-400">
Loading
</div>
);
}
if (isFinished(save)) {
return (
<div className="mx-auto max-w-lg space-y-8 px-4 py-12 text-center">
<Image
src="/logo.png"
alt=""
width={200}
height={225}
className="mx-auto opacity-90"
/>
<h1 className="font-heading text-4xl text-white">Desktop repaired</h1>
<p className="font-sans text-lg text-zinc-300">
You practiced folders, sorting, downloads, and picking a safe save
spot. Try the same ideas on your real computer.
</p>
<div className="flex flex-wrap justify-center gap-3">
<button
type="button"
className="rounded-lg bg-violet-600 px-5 py-2.5 font-heading text-xl text-white hover:bg-violet-500"
onClick={() => {
clearSave();
setSave({
version: 1,
levelIndex: 0,
subIndex: 0,
fs: buildInitialFs(0),
quizChoiceId: null,
});
}}
>
Play again
</button>
<Link
href="/"
className="rounded-lg border border-zinc-600 px-5 py-2.5 font-heading text-xl text-zinc-200 hover:bg-white/5"
>
Home
</Link>
</div>
</div>
);
}
if (save.levelIndex === 3) {
return (
<QuizLevel
save={save}
onPick={(id) => {
persist({ ...save, quizChoiceId: id });
}}
onWin={advanceMainLevel}
blurb={levelBlurb(3)}
/>
);
}
const fs = save.fs;
const subs = subCount(save.levelIndex);
const subComplete = isSubComplete(save.levelIndex, save.subIndex, fs);
const lastSub = subs - 1;
const stepLabel =
subs > 1
? `Step ${save.subIndex + 1} of ${subs}`
: null;
return (
<div className="mx-auto max-w-7xl space-y-8 px-4 py-8 sm:px-6">
<div className="flex flex-wrap items-center justify-between gap-4">
<Link
href="/"
className="font-sans text-sm text-violet-300 hover:text-violet-200"
>
Home
</Link>
<Image
src="/logo.png"
alt=""
width={80}
height={90}
className="opacity-90"
/>
</div>
<header className="space-y-4">
<p className="font-heading text-base tracking-wide text-violet-300 sm:text-lg">
Messy desktop · Level {save.levelIndex + 1} / {LAST_PLAYABLE + 1}
{stepLabel ? ` · ${stepLabel}` : ""}
</p>
<h1 className="font-heading text-5xl tracking-tight text-white sm:text-6xl">
{levelTitle(save.levelIndex)}
</h1>
<p className="max-w-4xl font-sans text-xl leading-relaxed text-zinc-100 sm:text-2xl sm:leading-relaxed">
{subBlurb(save.levelIndex, save.subIndex)}
</p>
<p className="max-w-4xl font-sans text-base leading-relaxed text-zinc-400 sm:text-lg">
{levelBlurb(save.levelIndex)}
</p>
</header>
<FileBrowserView
save={save}
persist={persist}
tryMoveToFolder={tryMoveToFolder}
subComplete={subComplete}
lastSub={lastSub}
advanceSubOrLevel={advanceSubOrLevel}
/>
</div>
);
}
function QuizLevel({
save,
onPick,
onWin,
blurb,
}: {
save: SaveV1;
onPick: (id: string) => void;
onWin: () => void;
blurb: string;
}) {
const [wrong, setWrong] = useState(false);
const choices = [
{
id: QUIZ_CORRECT_ID,
label: "Documents",
hint: "A stable place for things you care about.",
},
{
id: "opt-dl",
label: "Downloads",
hint: "Easy to lose track when everything lands here.",
},
{
id: "opt-desk",
label: "Desktop",
hint: "Clutters fast; not ideal for homework.",
},
];
return (
<div className="mx-auto max-w-lg space-y-6 px-4 py-10">
<Link
href="/"
className="inline-block font-sans text-sm text-violet-300 hover:text-violet-200"
>
Home
</Link>
<h1 className="font-heading text-4xl text-white">Safe saving</h1>
<p className="font-sans text-lg text-zinc-300">{blurb}</p>
<fieldset className="space-y-3">
<legend className="sr-only">Save location</legend>
{choices.map((c) => (
<label
key={c.id}
className={`flex cursor-pointer flex-col rounded-lg border p-4 ${
save.quizChoiceId === c.id
? "border-violet-400 bg-violet-950/40"
: "border-zinc-600 bg-black/25 hover:border-zinc-500"
}`}
>
<div className="flex items-center gap-3">
<input
type="radio"
name="save-loc"
checked={save.quizChoiceId === c.id}
onChange={() => {
setWrong(false);
onPick(c.id);
}}
className="h-4 w-4"
/>
<span className="font-heading text-2xl text-white">
{c.label}
</span>
</div>
<span className="mt-1 pl-7 font-sans text-sm text-zinc-400">
{c.hint}
</span>
</label>
))}
</fieldset>
<button
type="button"
disabled={!save.quizChoiceId}
className="w-full rounded-lg bg-violet-600 py-3 font-heading text-2xl text-white hover:bg-violet-500 disabled:opacity-40"
onClick={() => {
if (!save.quizChoiceId) return;
if (isQuizComplete(save.quizChoiceId)) {
onWin();
} else {
setWrong(true);
}
}}
>
Submit answer
</button>
{wrong ? (
<p className="font-sans text-amber-300" role="alert">
Not quite homework stays easiest to find in a dedicated folder like
Documents.
</p>
) : null}
</div>
);
}

View File

@@ -0,0 +1,223 @@
import type { FileNode, FsSlice, SaveV1 } from "./types";
import { DESKTOP_ID, baseNodes, initialFsSlice } from "./state";
/** Playable levels are 0..3; 4 means finished. */
export const LAST_PLAYABLE = 3;
function withDesktop(children: Omit<FileNode, "parentId">[]): FileNode[] {
const nodes = baseNodes();
for (const c of children) {
nodes.push({ ...c, parentId: DESKTOP_ID });
}
return nodes;
}
function level0Nodes(): FileNode[] {
return withDesktop([
{ id: "fld-docs", name: "Documents", kind: "folder" },
{ id: "fld-games", name: "Games", kind: "folder" },
{ id: "file-readme", name: "READ_ME.txt", kind: "file" },
]);
}
function level1Nodes(): FileNode[] {
return withDesktop([
{ id: "fld-pics", name: "Pictures", kind: "folder" },
{ id: "fld-school", name: "School", kind: "folder" },
{ id: "file-cat", name: "cat.png", kind: "file" },
{ id: "file-hw", name: "homework.txt", kind: "file" },
]);
}
function level2Nodes(): FileNode[] {
const nodes = baseNodes();
nodes.push(
{ id: "fld-photos", name: "Photos", parentId: DESKTOP_ID, kind: "folder" },
{ id: "fld-work", name: "Work", parentId: DESKTOP_ID, kind: "folder" },
{
id: "fld-installers",
name: "Installers",
kind: "folder",
parentId: DESKTOP_ID,
},
{
id: "fld-downloads",
name: "Downloads",
kind: "folder",
parentId: DESKTOP_ID,
},
{
id: "f-beach",
name: "beach.jpg",
kind: "file",
parentId: "fld-downloads",
},
{
id: "f-budget",
name: "budget.xlsx",
kind: "file",
parentId: "fld-downloads",
},
{
id: "f-game",
name: "game-installer.msi",
kind: "file",
parentId: "fld-downloads",
},
);
return nodes;
}
export function buildInitialFs(levelIndex: number): FsSlice {
switch (levelIndex) {
case 0:
return initialFsSlice(level0Nodes());
case 1:
return initialFsSlice(level1Nodes());
case 2:
return initialFsSlice(level2Nodes());
case 3:
return initialFsSlice(
withDesktop([{ id: "fld-docs", name: "Documents", kind: "folder" }]),
);
default:
return initialFsSlice(level0Nodes());
}
}
export function freshSave(levelIndex: number): SaveV1 {
return {
version: 1,
levelIndex,
subIndex: 0,
fs: buildInitialFs(Math.min(levelIndex, LAST_PLAYABLE)),
quizChoiceId: null,
};
}
function parentOf(fs: FsSlice, fileId: string) {
return fs.nodes.find((n) => n.id === fileId)?.parentId;
}
/** How many sub-challenges this main level has (quiz = 1). */
export function subCount(levelIndex: number): number {
switch (levelIndex) {
case 0:
return 2;
case 1:
return 2;
case 2:
return 3;
case 3:
return 1;
default:
return 1;
}
}
/**
* Align subIndex with filesystem progress (fixes older saves without `subIndex`).
*/
export function migrateSubIndex(
levelIndex: number,
fs: FsSlice,
_storedSub: number | undefined,
): number {
const n = subCount(levelIndex);
for (let i = 0; i < n; i++) {
if (!isSubComplete(levelIndex, i, fs)) return i;
}
return Math.max(0, n - 1);
}
export function isSubComplete(
levelIndex: number,
subIndex: number,
fs: FsSlice,
): boolean {
switch (levelIndex) {
case 0:
if (subIndex === 0) return fs.visitedFolderIds.includes("fld-games");
if (subIndex === 1) return fs.visitedFolderIds.includes("fld-docs");
return false;
case 1:
if (subIndex === 0) return parentOf(fs, "file-cat") === "fld-pics";
if (subIndex === 1) return parentOf(fs, "file-hw") === "fld-school";
return false;
case 2:
if (subIndex === 0) return parentOf(fs, "f-beach") === "fld-photos";
if (subIndex === 1) return parentOf(fs, "f-budget") === "fld-work";
if (subIndex === 2) return parentOf(fs, "f-game") === "fld-installers";
return false;
default:
return false;
}
}
export function isFsLevelComplete(levelIndex: number, fs: FsSlice): boolean {
const n = subCount(levelIndex);
for (let i = 0; i < n; i++) {
if (!isSubComplete(levelIndex, i, fs)) return false;
}
return true;
}
export const QUIZ_CORRECT_ID = "opt-docs";
export function isQuizComplete(choiceId: string | null) {
return choiceId === QUIZ_CORRECT_ID;
}
export function levelTitle(levelIndex: number): string {
switch (levelIndex) {
case 0:
return "Find Documents";
case 1:
return "Put files away";
case 2:
return "Rescue Downloads";
case 3:
return "Safe saving";
default:
return "Complete";
}
}
/** Short context for the whole level. */
export function levelBlurb(levelIndex: number): string {
switch (levelIndex) {
case 0:
return "Folders are like drawers—open them with a click. Use Back to return to the folder above.";
case 1:
return "Drag a file onto a folder to move it—or tap a file, then tap a folder below.";
case 2:
return "Drag one file at a time onto the right folder (or use tap + folder buttons).";
case 3:
return "Where should you save homework so you can find it later?";
default:
return "";
}
}
/** Current sub-challenge instruction. */
export function subBlurb(levelIndex: number, subIndex: number): string {
switch (levelIndex) {
case 0:
if (subIndex === 0)
return "First: click the Games folder to open it. Then use Back to return to the Desktop.";
return "Next: open the Documents folder the same way.";
case 1:
if (subIndex === 0)
return "Drag cat.png onto the Pictures folder (or select it, then pick Pictures).";
return "Drag homework.txt onto the School folder.";
case 2:
if (subIndex === 0) return "Drag beach.jpg onto Photos.";
if (subIndex === 1) return "Drag budget.xlsx onto Work.";
return "Drag game-installer.msi onto Installers.";
case 3:
return levelBlurb(3);
default:
return "";
}
}

View File

@@ -0,0 +1,76 @@
import type { FileNode, FsSlice } from "./types";
export const ROOT_ID = "root";
export const DESKTOP_ID = "desktop";
export function getChildren(nodes: FileNode[], parentId: string) {
return nodes.filter((n) => n.parentId === parentId);
}
export function getNode(nodes: FileNode[], id: string) {
return nodes.find((n) => n.id === id);
}
export function parentChain(nodes: FileNode[], id: string): FileNode[] {
const out: FileNode[] = [];
let cur: FileNode | undefined = getNode(nodes, id);
while (cur && cur.id !== ROOT_ID) {
out.push(cur);
cur = getNode(nodes, cur.parentId);
}
return out.reverse();
}
export function pathLabel(nodes: FileNode[], viewParentId: string): string {
const chain = parentChain(nodes, viewParentId);
return chain.map((n) => n.name).join(" / ");
}
export function moveFile(
nodes: FileNode[],
fileId: string,
newParentId: string,
): FileNode[] | null {
const file = getNode(nodes, fileId);
const target = getNode(nodes, newParentId);
if (!file || file.kind !== "file") return null;
if (!target || target.kind !== "folder") return null;
return nodes.map((n) =>
n.id === fileId ? { ...n, parentId: newParentId } : n,
);
}
export function enterFolder(fs: FsSlice, folderId: string): FsSlice | null {
const folder = getNode(fs.nodes, folderId);
if (!folder || folder.kind !== "folder") return null;
const visited = fs.visitedFolderIds.includes(folderId)
? fs.visitedFolderIds
: [...fs.visitedFolderIds, folderId];
return { ...fs, viewParentId: folderId, visitedFolderIds: visited };
}
export function goUp(fs: FsSlice): FsSlice | null {
const cur = getNode(fs.nodes, fs.viewParentId);
if (!cur || cur.parentId === ROOT_ID) return null;
return { ...fs, viewParentId: cur.parentId };
}
export function initialFsSlice(nodes: FileNode[]): FsSlice {
return {
nodes,
viewParentId: DESKTOP_ID,
visitedFolderIds: [],
};
}
export function baseNodes(): FileNode[] {
return [
{ id: ROOT_ID, name: "", parentId: ROOT_ID, kind: "folder" },
{
id: DESKTOP_ID,
name: "Desktop",
parentId: ROOT_ID,
kind: "folder",
},
];
}

View File

@@ -0,0 +1,57 @@
import type { SaveV1 } from "./types";
import {
LAST_PLAYABLE,
freshSave,
migrateSubIndex,
subCount,
} from "./levels";
export const STORAGE_KEY = "folder-game-challenge:v1";
export function parseSave(raw: string | null): SaveV1 | null {
if (!raw) return null;
try {
const v = JSON.parse(raw) as SaveV1;
if (v?.version !== 1) return null;
if (typeof v.levelIndex !== "number") return null;
if (v.levelIndex < 0 || v.levelIndex > LAST_PLAYABLE + 1) return null;
if (!v.fs?.nodes || typeof v.fs.viewParentId !== "string") return null;
if (!Array.isArray(v.fs.visitedFolderIds)) return null;
const subIndex = migrateSubIndex(
v.levelIndex,
v.fs,
typeof v.subIndex === "number" ? v.subIndex : undefined,
);
const maxSub = Math.max(0, subCount(v.levelIndex) - 1);
return { ...v, subIndex: Math.min(subIndex, maxSub) };
} catch {
return null;
}
}
export function loadSave(): SaveV1 | null {
if (typeof window === "undefined") return null;
return parseSave(localStorage.getItem(STORAGE_KEY));
}
export function writeSave(save: SaveV1) {
localStorage.setItem(STORAGE_KEY, JSON.stringify(save));
}
export function clearSave() {
localStorage.removeItem(STORAGE_KEY);
}
export function resumeOrNew(): SaveV1 {
return loadSave() ?? freshSave(0);
}
export function hasInProgressSave(): boolean {
const s = loadSave();
return s !== null && s.levelIndex <= LAST_PLAYABLE;
}
export function isFinished(save: SaveV1) {
return save.levelIndex > LAST_PLAYABLE;
}

View File

@@ -0,0 +1,23 @@
export type NodeKind = "file" | "folder";
export type FileNode = {
id: string;
name: string;
parentId: string;
kind: NodeKind;
};
export type FsSlice = {
nodes: FileNode[];
viewParentId: string;
visitedFolderIds: string[];
};
export type SaveV1 = {
version: 1;
levelIndex: number;
/** Step within the current level (filesystem levels use multiple). */
subIndex: number;
fs: FsSlice;
quizChoiceId: string | null;
};

View File

@@ -4,4 +4,5 @@
--font-sans: var(--font-bakbak-one), ui-sans-serif, system-ui, sans-serif,
"Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";
--font-heading: var(--font-teko), ui-sans-serif, sans-serif;
--font-mono: var(--font-ibm-plex-mono), ui-monospace, monospace;
}

View File

@@ -0,0 +1,11 @@
import { describe, expect, it } from "vitest";
import { GET } from "~/app/api/health/route";
describe("GET /api/health", () => {
it("returns 200 and status ok", async () => {
const res = await GET();
expect(res.status).toBe(200);
await expect(res.json()).resolves.toEqual({ status: "ok" });
});
});

View File

@@ -0,0 +1,21 @@
import { describe, expect, it, vi } from "vitest";
vi.mock("~/server/auth", () => ({
auth: vi.fn(async () => null),
}));
vi.mock("~/server/db", () => ({
db: {},
}));
import { createCaller } from "~/server/api/root";
import { createTRPCContext } from "~/server/api/trpc";
describe("tRPC post.hello", () => {
it("returns a greeting for the given text", async () => {
const ctx = await createTRPCContext({ headers: new Headers() });
const caller = createCaller(ctx);
const out = await caller.post.hello({ text: "Tester" });
expect(out.greeting).toBe("Hello Tester");
});
});

52
tests/unit/levels.test.ts Normal file
View File

@@ -0,0 +1,52 @@
import { describe, expect, it } from "vitest";
import {
buildInitialFs,
isFsLevelComplete,
isQuizComplete,
isSubComplete,
QUIZ_CORRECT_ID,
} from "~/lib/folder-game/levels";
describe("isFsLevelComplete", () => {
it("level 0 completes when Games and Documents were opened", () => {
const fs = buildInitialFs(0);
expect(isFsLevelComplete(0, fs)).toBe(false);
expect(
isFsLevelComplete(0, {
...fs,
visitedFolderIds: ["fld-games", "fld-docs"],
}),
).toBe(true);
});
it("level 1 completes when files are sorted", () => {
const fs = buildInitialFs(1);
const nodes = fs.nodes.map((n) => {
if (n.id === "file-cat") return { ...n, parentId: "fld-pics" };
if (n.id === "file-hw") return { ...n, parentId: "fld-school" };
return n;
});
expect(isFsLevelComplete(1, { ...fs, nodes })).toBe(true);
});
});
describe("isSubComplete", () => {
it("level 0 sub 0 requires Games", () => {
const fs = buildInitialFs(0);
expect(isSubComplete(0, 0, fs)).toBe(false);
expect(
isSubComplete(0, 0, {
...fs,
visitedFolderIds: ["fld-games"],
}),
).toBe(true);
});
});
describe("isQuizComplete", () => {
it("accepts Documents choice", () => {
expect(isQuizComplete(QUIZ_CORRECT_ID)).toBe(true);
expect(isQuizComplete("opt-dl")).toBe(false);
});
});

22
vitest.config.ts Normal file
View File

@@ -0,0 +1,22 @@
import path from "node:path";
import { fileURLToPath } from "node:url";
import { defineConfig } from "vitest/config";
const root = path.dirname(fileURLToPath(import.meta.url));
export default defineConfig({
resolve: {
alias: {
"~": path.join(root, "src"),
},
},
test: {
environment: "node",
include: ["tests/**/*.test.ts"],
exclude: ["e2e/**", "node_modules/**"],
env: {
NODE_ENV: "test",
SKIP_ENV_VALIDATION: "1",
},
},
});