diff --git a/.gitignore b/.gitignore
index c24a835..9bdaac0 100644
--- a/.gitignore
+++ b/.gitignore
@@ -7,6 +7,10 @@
# testing
/coverage
+/playwright-report/
+/test-results/
+/blob-report/
+/playwright/.cache/
# database
/prisma/db.sqlite
diff --git a/e2e/app.spec.ts b/e2e/app.spec.ts
new file mode 100644
index 0000000..09dc9a4
--- /dev/null
+++ b/e2e/app.spec.ts
@@ -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" });
+ });
+});
diff --git a/package-lock.json b/package-lock.json
index e523a93..d0aee30 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -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",
diff --git a/package.json b/package.json
index 7500adb..6a826b6 100644
--- a/package.json
+++ b/package.json
@@ -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": {
diff --git a/playwright.config.ts b/playwright.config.ts
new file mode 100644
index 0000000..e4d2d6e
--- /dev/null
+++ b/playwright.config.ts
@@ -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!!",
+ },
+ },
+});
diff --git a/src/app/api/health/route.ts b/src/app/api/health/route.ts
new file mode 100644
index 0000000..eb68084
--- /dev/null
+++ b/src/app/api/health/route.ts
@@ -0,0 +1,3 @@
+export function GET() {
+ return Response.json({ status: "ok" as const });
+}
diff --git a/src/app/home-hub.tsx b/src/app/home-hub.tsx
new file mode 100644
index 0000000..ebfc9f9
--- /dev/null
+++ b/src/app/home-hub.tsx
@@ -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 (
+
+
+
+
+ Folder Game Challenge
+
+
+ Repair a messy desktop: folders, sorting, downloads, and safe saves.
+ Progress is stored in your browser.
+
+
+
+
+ {ready && canContinue && !done ? "Continue" : "Play"}
+
+ {
+ clearSave();
+ const start = freshSave(0);
+ writeSave(start);
+ router.push("/play");
+ }}
+ >
+ New game
+
+
+ {ready && done ? (
+
+ You already finished — open Play to see the ending, or New game to
+ reset.
+
+ ) : null}
+
+ );
+}
diff --git a/src/app/layout.tsx b/src/app/layout.tsx
index 42eaca0..1dac4c6 100644
--- a/src/app/layout.tsx
+++ b/src/app/layout.tsx
@@ -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 (
-
+
{children}
diff --git a/src/app/page.tsx b/src/app/page.tsx
index 68cf024..5475599 100644
--- a/src/app/page.tsx
+++ b/src/app/page.tsx
@@ -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 (
-
-
-
-
- {paragraphs.map((text, i) => (
-
- {text}
-
- ))}
-
-
+
);
}
diff --git a/src/app/play/file-browser.tsx b/src/app/play/file-browser.tsx
new file mode 100644
index 0000000..6bb2d57
--- /dev/null
+++ b/src/app/play/file-browser.tsx
@@ -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(null);
+ const [dragOverFolderId, setDragOverFolderId] = useState(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 (
+
+ {/* Title bar */}
+
+
+
+
+
+
+
+ Files
+
+
+ Drag files onto folders
+
+
+
+
+ {/* Sidebar — Places */}
+
+
+
+
+ Places
+
+
+
+ {placesFolders.map((f) => {
+ const active = fs.viewParentId === f.id;
+ return (
+ 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 ? (
+
+ ) : (
+
+ )}
+ {f.name}
+
+ );
+ })}
+ 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"
+ }`}
+ >
+
+ Desktop
+
+
+
+
+ {/* Main column */}
+
+ {/* Toolbar */}
+
+
+ ← Back
+
+
+ {breadcrumbs.map((node, i) => (
+
+ {i > 0 ? (
+
+ ) : null}
+ 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"}
+
+
+ ))}
+
+
+
+ {/* List header */}
+
+ Name
+ Kind
+
+
+ {/* Rows */}
+
+ {sortedChildren.length === 0 ? (
+
+ This folder is empty.
+
+ ) : (
+
+ )}
+
+
+ {/* Destinations strip */}
+
+
+ {selectedFileId ? (
+ <>
+ Tap a destination for “
+
+ {getNode(fs.nodes, selectedFileId)?.name}
+
+ ”, or drag it there:
+ >
+ ) : (
+ <>Drop a file onto a folder row above, or onto a destination:>
+ )}
+
+
+ {foldersForMove.map((f) => {
+ const isParent =
+ !!selectedFileId &&
+ f.id === getNode(fs.nodes, selectedFileId)?.parentId;
+ return (
+
+ {
+ 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"
+ >
+
+ {f.name || "This PC"}
+
+
+ );
+ })}
+
+
+
+ {subComplete ? (
+
+
+ {save.subIndex < lastSub ? "Next step" : "Next level"}
+
+
+ ) : null}
+
+
+
+ );
+}
+
+/** 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;
+}
diff --git a/src/app/play/page.tsx b/src/app/play/page.tsx
new file mode 100644
index 0000000..8382b4a
--- /dev/null
+++ b/src/app/play/page.tsx
@@ -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 ;
+}
diff --git a/src/app/play/play-client.tsx b/src/app/play/play-client.tsx
new file mode 100644
index 0000000..5021faa
--- /dev/null
+++ b/src/app/play/play-client.tsx
@@ -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(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 (
+
+ Loading…
+
+ );
+ }
+
+ if (isFinished(save)) {
+ return (
+
+
+
Desktop repaired
+
+ You practiced folders, sorting, downloads, and picking a safe save
+ spot. Try the same ideas on your real computer.
+
+
+ {
+ clearSave();
+ setSave({
+ version: 1,
+ levelIndex: 0,
+ subIndex: 0,
+ fs: buildInitialFs(0),
+ quizChoiceId: null,
+ });
+ }}
+ >
+ Play again
+
+
+ Home
+
+
+
+ );
+ }
+
+ if (save.levelIndex === 3) {
+ return (
+ {
+ 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 (
+
+
+
+ ← Home
+
+
+
+
+
+
+ Messy desktop · Level {save.levelIndex + 1} / {LAST_PLAYABLE + 1}
+ {stepLabel ? ` · ${stepLabel}` : ""}
+
+
+ {levelTitle(save.levelIndex)}
+
+
+ {subBlurb(save.levelIndex, save.subIndex)}
+
+
+ {levelBlurb(save.levelIndex)}
+
+
+
+
+
+ );
+}
+
+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 (
+
+
+ ← Home
+
+
Safe saving
+
{blurb}
+
+ Save location
+ {choices.map((c) => (
+
+
+ {
+ setWrong(false);
+ onPick(c.id);
+ }}
+ className="h-4 w-4"
+ />
+
+ {c.label}
+
+
+
+ {c.hint}
+
+
+ ))}
+
+
{
+ if (!save.quizChoiceId) return;
+ if (isQuizComplete(save.quizChoiceId)) {
+ onWin();
+ } else {
+ setWrong(true);
+ }
+ }}
+ >
+ Submit answer
+
+ {wrong ? (
+
+ Not quite — homework stays easiest to find in a dedicated folder like
+ Documents.
+
+ ) : null}
+
+ );
+}
diff --git a/src/lib/folder-game/levels.ts b/src/lib/folder-game/levels.ts
new file mode 100644
index 0000000..ba78155
--- /dev/null
+++ b/src/lib/folder-game/levels.ts
@@ -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[] {
+ 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 "";
+ }
+}
diff --git a/src/lib/folder-game/state.ts b/src/lib/folder-game/state.ts
new file mode 100644
index 0000000..827861b
--- /dev/null
+++ b/src/lib/folder-game/state.ts
@@ -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",
+ },
+ ];
+}
diff --git a/src/lib/folder-game/storage.ts b/src/lib/folder-game/storage.ts
new file mode 100644
index 0000000..9fdf9ee
--- /dev/null
+++ b/src/lib/folder-game/storage.ts
@@ -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;
+}
diff --git a/src/lib/folder-game/types.ts b/src/lib/folder-game/types.ts
new file mode 100644
index 0000000..c37496b
--- /dev/null
+++ b/src/lib/folder-game/types.ts
@@ -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;
+};
diff --git a/src/styles/globals.css b/src/styles/globals.css
index afc0e1e..f755467 100644
--- a/src/styles/globals.css
+++ b/src/styles/globals.css
@@ -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;
}
diff --git a/tests/api/health-route.test.ts b/tests/api/health-route.test.ts
new file mode 100644
index 0000000..732edf5
--- /dev/null
+++ b/tests/api/health-route.test.ts
@@ -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" });
+ });
+});
diff --git a/tests/api/post-hello.test.ts b/tests/api/post-hello.test.ts
new file mode 100644
index 0000000..b26cb5b
--- /dev/null
+++ b/tests/api/post-hello.test.ts
@@ -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");
+ });
+});
diff --git a/tests/unit/levels.test.ts b/tests/unit/levels.test.ts
new file mode 100644
index 0000000..6ede8f3
--- /dev/null
+++ b/tests/unit/levels.test.ts
@@ -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);
+ });
+});
diff --git a/vitest.config.ts b/vitest.config.ts
new file mode 100644
index 0000000..5075897
--- /dev/null
+++ b/vitest.config.ts
@@ -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",
+ },
+ },
+});