feat(play): debug retry step, folder hints, and e2e coverage

- Add buildFsAtSubStart so dev "Retry step" resets the current sub while keeping earlier subs complete; quiz screen clears the selected answer.

- Stop adding hinted files' parent folders to click hints (avoids wrongly highlighting Downloads when already inside it).

- Extend Playwright: localStorage reset, full playthrough through quiz, and a retry-step regression test. Add Vitest for buildFsAtSubStart.

Made-with: Cursor
This commit is contained in:
2026-04-11 19:26:39 +12:00
parent 1957e96363
commit 104d6a1d46
5 changed files with 404 additions and 146 deletions

View File

@@ -1,6 +1,14 @@
import { expect, test } from "@playwright/test"; import { expect, test } from "@playwright/test";
const SAVE_KEY = "folder-game-challenge:v1";
test.describe("browser", () => { test.describe("browser", () => {
test.beforeEach(async ({ page }) => {
await page.addInitScript((key) => {
localStorage.removeItem(key);
}, SAVE_KEY);
});
test("home page shows title", async ({ page }) => { test("home page shows title", async ({ page }) => {
await page.goto("/"); await page.goto("/");
await expect( await expect(
@@ -14,6 +22,83 @@ test.describe("browser", () => {
timeout: 15_000, timeout: 15_000,
}); });
}); });
test("debug retry step resets current sub without losing prior progress", async ({
page,
}) => {
await page.goto("/play");
await expect(page.getByText("Messy desktop")).toBeVisible({
timeout: 15_000,
});
await page.getByRole("button", { name: "Games" }).first().click();
await page.getByRole("button", { name: "Back" }).click();
await page.getByRole("button", { name: "Documents" }).first().click();
await page.getByRole("button", { name: "Next level" }).click();
await expect(page.getByText(/Drag cat\.png/)).toBeVisible();
await page.getByRole("button", { name: /cat\.png/ }).dragTo(
page.locator("aside").getByRole("button", { name: "Pictures" }),
);
await expect(page.getByText(/Drag homework\.txt/)).toBeVisible();
await page.getByRole("button", { name: "Retry this step (debug)" }).click();
await page.locator("aside").getByRole("button", { name: "Pictures" }).click();
await expect(page.getByRole("button", { name: /cat\.png/ })).toBeVisible();
await expect(page.getByText(/Drag homework\.txt/)).toBeVisible();
});
test("full playthrough through quiz win", async ({ page }) => {
test.setTimeout(120_000);
await page.goto("/play");
await expect(page.getByText("Messy desktop")).toBeVisible({
timeout: 15_000,
});
// Level 0 — Find Documents
await expect(page.getByText(/click the Games folder/)).toBeVisible();
await page.getByRole("button", { name: "Games" }).first().click();
await page.getByRole("button", { name: "Back" }).click();
await page.getByRole("button", { name: "Documents" }).first().click();
await page.getByRole("button", { name: "Next level" }).click();
// Level 1 — Put files away
await expect(page.getByText(/Drag cat\.png/)).toBeVisible();
await page.getByRole("button", { name: /cat\.png/ }).dragTo(
page.locator("aside").getByRole("button", { name: "Pictures" }),
);
await expect(page.getByText(/Drag homework\.txt/)).toBeVisible();
await page.getByRole("button", { name: /homework\.txt/ }).dragTo(
page.locator("aside").getByRole("button", { name: "School" }),
);
await page.getByRole("button", { name: "Next level" }).click();
// Level 2 — Rescue Downloads (starts in Downloads)
await expect(page.getByText(/Drag beach\.jpg/)).toBeVisible();
await expect(page.getByRole("button", { name: /beach\.jpg/ })).toBeVisible();
await page.getByRole("button", { name: /beach\.jpg/ }).dragTo(
page.locator("aside").getByRole("button", { name: "Photos" }),
);
await expect(page.getByText(/Drag budget\.xlsx/)).toBeVisible();
await page.getByRole("button", { name: /budget\.xlsx/ }).dragTo(
page.locator("aside").getByRole("button", { name: "Work" }),
);
await expect(page.getByText(/Drag game-installer/)).toBeVisible();
await page.getByRole("button", { name: /game-installer\.msi/ }).dragTo(
page.locator("aside").getByRole("button", { name: "Installers" }),
);
await page.getByRole("button", { name: "Next level" }).click();
// Level 3 — Quiz
await expect(page.getByRole("heading", { name: "Safe saving" })).toBeVisible();
await page.getByRole("radio", { name: /Documents/i }).check();
await page.getByRole("button", { name: "Submit answer" }).click();
await expect(
page.getByRole("heading", { name: "Desktop repaired" }),
).toBeVisible({ timeout: 15_000 });
});
}); });
test.describe("API", () => { test.describe("API", () => {

View File

@@ -7,14 +7,12 @@ import {
IconFile, IconFile,
IconLayoutSidebarLeftExpand, IconLayoutSidebarLeftExpand,
} from "@tabler/icons-react"; } from "@tabler/icons-react";
import { import { useCallback, useMemo, useState, type DragEvent } from "react";
useCallback,
useEffect,
useMemo,
useState,
type DragEvent,
} from "react";
import {
highlightTargets,
isSubComplete,
} from "~/lib/folder-game/levels";
import { import {
DESKTOP_ID, DESKTOP_ID,
ROOT_ID, ROOT_ID,
@@ -22,19 +20,21 @@ import {
getChildren, getChildren,
getNode, getNode,
goUp, goUp,
moveFile,
} from "~/lib/folder-game/state"; } from "~/lib/folder-game/state";
import type { FileNode, SaveV1 } from "~/lib/folder-game/types"; import type { FileNode, SaveV1 } from "~/lib/folder-game/types";
const FILE_DRAG_TYPE = "text/plain"; const FILE_DRAG_TYPE = "text/plain";
/** Pulsing ring for the current sub-task targets. */
const HINT_RING =
"ring-2 ring-amber-400 ring-offset-2 ring-offset-zinc-950 shadow-[0_0_22px_rgba(251,191,36,0.35)] motion-safe:animate-pulse";
type FileBrowserViewProps = { type FileBrowserViewProps = {
save: SaveV1; save: SaveV1;
persist: (next: SaveV1) => void; persist: (next: SaveV1) => void;
tryMoveToFolder: (fileId: string, folderId: string) => void; tryMoveToFolder: (fileId: string, folderId: string) => void;
subComplete: boolean; levelComplete: boolean;
lastSub: number; onNextLevel: () => void;
advanceSubOrLevel: () => void;
}; };
function crumbTargetContains( function crumbTargetContains(
@@ -49,18 +49,12 @@ export function FileBrowserView({
save, save,
persist, persist,
tryMoveToFolder, tryMoveToFolder,
subComplete, levelComplete,
lastSub, onNextLevel,
advanceSubOrLevel,
}: FileBrowserViewProps) { }: FileBrowserViewProps) {
const fs = save.fs; const fs = save.fs;
const [selectedFileId, setSelectedFileId] = useState<string | null>(null);
const [dragOverFolderId, setDragOverFolderId] = useState<string | null>(null); const [dragOverFolderId, setDragOverFolderId] = useState<string | null>(null);
useEffect(() => {
setSelectedFileId(null);
}, [save.levelIndex, save.subIndex, save.fs.viewParentId]);
const sortedChildren = useMemo(() => { const sortedChildren = useMemo(() => {
return [...getChildren(fs.nodes, fs.viewParentId)].sort((a, b) => { return [...getChildren(fs.nodes, fs.viewParentId)].sort((a, b) => {
if (a.kind !== b.kind) return a.kind === "folder" ? -1 : 1; if (a.kind !== b.kind) return a.kind === "folder" ? -1 : 1;
@@ -79,11 +73,6 @@ export function FileBrowserView({
.sort((a, b) => a.name.localeCompare(b.name)); .sort((a, b) => a.name.localeCompare(b.name));
}, [fs.nodes]); }, [fs.nodes]);
const foldersForMove = useMemo(
() => fs.nodes.filter((n) => n.kind === "folder" && n.id !== "root"),
[fs.nodes],
);
const openFolder = useCallback( const openFolder = useCallback(
(folderId: string) => { (folderId: string) => {
const next = enterFolder(fs, folderId); const next = enterFolder(fs, folderId);
@@ -122,6 +111,45 @@ export function FileBrowserView({
const canGoBack = goUp(fs) !== null; const canGoBack = goUp(fs) !== null;
const clickHints = useMemo(() => {
if (save.levelIndex > 2 || levelComplete) {
return {
active: false,
folders: new Set<string>(),
files: new Set<string>(),
back: false,
};
}
const subDone = isSubComplete(save.levelIndex, save.subIndex, fs);
const active = !subDone;
if (!active) {
return {
active: false,
folders: new Set<string>(),
files: new Set<string>(),
back: false,
};
}
const t = highlightTargets(save.levelIndex, save.subIndex);
const folders = new Set<string>(t.folderIds);
const back =
save.levelIndex === 0 &&
save.subIndex === 1 &&
fs.viewParentId !== DESKTOP_ID &&
goUp(fs) !== null;
return {
active: true,
folders,
files: new Set(t.fileIds),
back,
};
}, [save.levelIndex, save.subIndex, fs, levelComplete]);
const hintFolder = (id: string) =>
clickHints.active && clickHints.folders.has(id);
const hintFile = (id: string) =>
clickHints.active && clickHints.files.has(id);
return ( 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"> <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 */} {/* Title bar */}
@@ -156,11 +184,16 @@ export function FileBrowserView({
key={f.id} key={f.id}
type="button" type="button"
onClick={() => openFolder(f.id)} 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 ${ {...bindFolderDrop(f.id)}
className={`flex w-full shrink-0 items-center gap-3 rounded-lg px-4 py-3 text-left font-sans text-base transition-colors md:text-lg ${
active active
? "bg-violet-600/25 text-violet-100" ? "bg-violet-600/25 text-violet-100"
: "text-zinc-300 hover:bg-white/5 hover:text-white" : "text-zinc-300 hover:bg-white/5 hover:text-white"
}`} } ${
dragOverFolderId === f.id
? "ring-2 ring-violet-400 ring-offset-2 ring-offset-zinc-950"
: ""
} ${hintFolder(f.id) ? HINT_RING : ""}`}
> >
{active ? ( {active ? (
<IconFolderOpen size={22} className="text-amber-400/90" /> <IconFolderOpen size={22} className="text-amber-400/90" />
@@ -174,11 +207,16 @@ export function FileBrowserView({
<button <button
type="button" type="button"
onClick={() => openFolder(DESKTOP_ID)} 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 ${ {...bindFolderDrop(DESKTOP_ID)}
className={`flex w-full 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 fs.viewParentId === DESKTOP_ID
? "bg-violet-600/25 text-violet-100" ? "bg-violet-600/25 text-violet-100"
: "text-zinc-400 hover:bg-white/5 hover:text-zinc-200" : "text-zinc-400 hover:bg-white/5 hover:text-zinc-200"
}`} } ${
dragOverFolderId === DESKTOP_ID
? "ring-2 ring-violet-400 ring-offset-2 ring-offset-zinc-950"
: ""
} ${hintFolder(DESKTOP_ID) ? HINT_RING : ""}`}
> >
<IconFolder size={22} /> <IconFolder size={22} />
<span>Desktop</span> <span>Desktop</span>
@@ -194,7 +232,9 @@ export function FileBrowserView({
type="button" type="button"
disabled={!canGoBack} disabled={!canGoBack}
onClick={goBack} 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" 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 ${
clickHints.back ? `rounded-md ${HINT_RING}` : ""
}`}
> >
Back Back
</button> </button>
@@ -213,11 +253,16 @@ export function FileBrowserView({
<button <button
type="button" type="button"
onClick={() => openFolder(node.id)} onClick={() => openFolder(node.id)}
{...bindFolderDrop(node.id)}
className={`truncate rounded px-1.5 py-1 hover:bg-white/10 hover:text-white ${ className={`truncate rounded px-1.5 py-1 hover:bg-white/10 hover:text-white ${
i === breadcrumbs.length - 1 i === breadcrumbs.length - 1
? "font-medium text-violet-100" ? "font-medium text-violet-100"
: "text-zinc-400" : "text-zinc-400"
}`} } ${
dragOverFolderId === node.id
? "ring-2 ring-violet-400 ring-offset-2 ring-offset-zinc-900"
: ""
} ${hintFolder(node.id) ? `rounded-md ${HINT_RING}` : ""}`}
> >
{node.name || "This PC"} {node.name || "This PC"}
</button> </button>
@@ -244,11 +289,11 @@ export function FileBrowserView({
n.kind === "folder" ? ( n.kind === "folder" ? (
<li key={n.id}> <li key={n.id}>
<div <div
className={`transition-colors ${ className={`rounded-lg transition-colors ${
dragOverFolderId === n.id dragOverFolderId === n.id
? "bg-violet-600/20 ring-1 ring-inset ring-violet-400/60" ? "bg-violet-600/20 ring-1 ring-inset ring-violet-400/60"
: "" : ""
}`} } ${hintFolder(n.id) ? HINT_RING : ""}`}
{...bindFolderDrop(n.id)} {...bindFolderDrop(n.id)}
> >
<button <button
@@ -282,16 +327,7 @@ export function FileBrowserView({
e.dataTransfer.effectAllowed = "move"; e.dataTransfer.effectAllowed = "move";
}} }}
onDragEnd={() => setDragOverFolderId(null)} onDragEnd={() => setDragOverFolderId(null)}
onClick={() => className={`grid w-full grid-cols-[1fr_auto] gap-6 rounded-lg px-5 py-4 text-left font-sans text-lg hover:bg-white/[0.04] sm:px-6 sm:py-5 cursor-grab active:cursor-grabbing ${hintFile(n.id) ? HINT_RING : ""}`}
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"> <span className="flex min-w-0 items-center gap-4">
<IconFile <IconFile
@@ -314,68 +350,14 @@ export function FileBrowserView({
)} )}
</div> </div>
{/* Destinations strip */} {levelComplete ? (
<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"> <div className="flex justify-end border-t border-zinc-700/80 bg-zinc-900/50 p-4 sm:p-5">
<button <button
type="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" 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} onClick={onNextLevel}
> >
{save.subIndex < lastSub ? "Next step" : "Next level"} Next level
</button> </button>
</div> </div>
) : null} ) : null}

View File

@@ -7,7 +7,9 @@ import { useCallback, useEffect, useState } from "react";
import { import {
LAST_PLAYABLE, LAST_PLAYABLE,
QUIZ_CORRECT_ID, QUIZ_CORRECT_ID,
buildFsAtSubStart,
buildInitialFs, buildInitialFs,
isFsLevelComplete,
isQuizComplete, isQuizComplete,
isSubComplete, isSubComplete,
levelBlurb, levelBlurb,
@@ -39,6 +41,24 @@ export function PlayClient() {
if (save) writeSave(save); if (save) writeSave(save);
}, [save]); }, [save]);
/** Advance sub-step automatically when the current sub is done (no mid-level button). */
useEffect(() => {
if (!mounted || !save) return;
if (save.levelIndex > 2) return;
setSave((s) => {
if (!s || s.levelIndex > 2) return s;
const subs = subCount(s.levelIndex);
const last = subs - 1;
let subIndex = s.subIndex;
while (subIndex < last && isSubComplete(s.levelIndex, subIndex, s.fs)) {
subIndex += 1;
}
if (subIndex === s.subIndex) return s;
return { ...s, subIndex };
});
}, [mounted, save?.fs, save?.levelIndex, save?.subIndex]);
const persist = useCallback((next: SaveV1) => { const persist = useCallback((next: SaveV1) => {
setSave(next); setSave(next);
}, []); }, []);
@@ -68,15 +88,10 @@ export function PlayClient() {
}); });
}, []); }, []);
/** After finishing the current sub-challenge (or whole level if last sub). */ /** After all sub-challenges in the level are done (single continue control). */
const advanceSubOrLevel = useCallback(() => { const advanceToNextLevel = useCallback(() => {
setSave((s) => { setSave((s) => {
if (!s) return 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; const nextIdx = s.levelIndex + 1;
if (nextIdx > LAST_PLAYABLE + 1) return s; if (nextIdx > LAST_PLAYABLE + 1) return s;
return { return {
@@ -149,49 +164,67 @@ export function PlayClient() {
}} }}
onWin={advanceMainLevel} onWin={advanceMainLevel}
blurb={levelBlurb(3)} blurb={levelBlurb(3)}
onDebugRetryQuiz={
process.env.NODE_ENV === "development"
? () => persist({ ...save, quizChoiceId: null })
: undefined
}
/> />
); );
} }
const fs = save.fs; const fs = save.fs;
const subs = subCount(save.levelIndex); const subs = subCount(save.levelIndex);
const subComplete = isSubComplete(save.levelIndex, save.subIndex, fs); const levelComplete = isFsLevelComplete(save.levelIndex, fs);
const lastSub = subs - 1;
const stepLabel = const stepLabel =
subs > 1 subs > 1
? `Step ${save.subIndex + 1} of ${subs}` ? `Step ${save.subIndex + 1} of ${subs}`
: null; : null;
return ( return (
<div className="mx-auto max-w-7xl space-y-8 px-4 py-8 sm:px-6"> <div className="mx-auto max-w-7xl space-y-4 px-4 pb-8 pt-4 sm:px-6 sm:pt-5">
<div className="flex flex-wrap items-center justify-between gap-4"> <header className="space-y-2">
<Link <div className="flex flex-wrap items-center gap-x-4 gap-y-2">
href="/" <Link
className="font-sans text-sm text-violet-300 hover:text-violet-200" href="/"
> className="shrink-0 font-sans text-sm text-violet-300 hover:text-violet-200"
Home >
</Link> Home
<Image </Link>
src="/logo.png" <p className="font-heading text-sm tracking-wide text-violet-300/95 sm:text-base">
alt="" Messy desktop · Level {save.levelIndex + 1} / {LAST_PLAYABLE + 1}
width={80} {stepLabel ? ` · ${stepLabel}` : ""}
height={90} </p>
className="opacity-90" {process.env.NODE_ENV === "development" ? (
/> <button
</div> type="button"
aria-label="Retry this step (debug)"
<header className="space-y-4"> className="rounded border border-amber-700/80 bg-amber-950/50 px-2 py-1 font-sans text-xs text-amber-200 hover:bg-amber-900/40"
<p className="font-heading text-base tracking-wide text-violet-300 sm:text-lg"> onClick={() =>
Messy desktop · Level {save.levelIndex + 1} / {LAST_PLAYABLE + 1} persist({
{stepLabel ? ` · ${stepLabel}` : ""} ...save,
</p> fs: buildFsAtSubStart(save.levelIndex, save.subIndex),
<h1 className="font-heading text-5xl tracking-tight text-white sm:text-6xl"> })
}
>
Retry step
</button>
) : null}
<Image
src="/logo.png"
alt=""
width={48}
height={54}
className="ml-auto h-10 w-auto opacity-90 sm:h-11"
/>
</div>
<h1 className="font-heading text-3xl tracking-tight text-white sm:text-4xl">
{levelTitle(save.levelIndex)} {levelTitle(save.levelIndex)}
</h1> </h1>
<p className="max-w-4xl font-sans text-xl leading-relaxed text-zinc-100 sm:text-2xl sm:leading-relaxed"> <p className="max-w-4xl font-sans text-lg leading-snug text-zinc-100 sm:text-xl sm:leading-snug">
{subBlurb(save.levelIndex, save.subIndex)} {subBlurb(save.levelIndex, save.subIndex)}
</p> </p>
<p className="max-w-4xl font-sans text-base leading-relaxed text-zinc-400 sm:text-lg"> <p className="max-w-4xl font-sans text-sm leading-snug text-zinc-400 sm:text-base">
{levelBlurb(save.levelIndex)} {levelBlurb(save.levelIndex)}
</p> </p>
</header> </header>
@@ -200,9 +233,8 @@ export function PlayClient() {
save={save} save={save}
persist={persist} persist={persist}
tryMoveToFolder={tryMoveToFolder} tryMoveToFolder={tryMoveToFolder}
subComplete={subComplete} levelComplete={levelComplete}
lastSub={lastSub} onNextLevel={advanceToNextLevel}
advanceSubOrLevel={advanceSubOrLevel}
/> />
</div> </div>
); );
@@ -213,11 +245,13 @@ function QuizLevel({
onPick, onPick,
onWin, onWin,
blurb, blurb,
onDebugRetryQuiz,
}: { }: {
save: SaveV1; save: SaveV1;
onPick: (id: string) => void; onPick: (id: string) => void;
onWin: () => void; onWin: () => void;
blurb: string; blurb: string;
onDebugRetryQuiz?: () => void;
}) { }) {
const [wrong, setWrong] = useState(false); const [wrong, setWrong] = useState(false);
@@ -247,7 +281,22 @@ function QuizLevel({
> >
Home Home
</Link> </Link>
<h1 className="font-heading text-4xl text-white">Safe saving</h1> <div className="flex flex-wrap items-end justify-between gap-3">
<h1 className="font-heading text-4xl text-white">Safe saving</h1>
{onDebugRetryQuiz ? (
<button
type="button"
aria-label="Clear quiz choice (debug)"
className="rounded border border-amber-700/80 bg-amber-950/50 px-2 py-1 font-sans text-xs text-amber-200 hover:bg-amber-900/40"
onClick={() => {
setWrong(false);
onDebugRetryQuiz();
}}
>
Retry step
</button>
) : null}
</div>
<p className="font-sans text-lg text-zinc-300">{blurb}</p> <p className="font-sans text-lg text-zinc-300">{blurb}</p>
<fieldset className="space-y-3"> <fieldset className="space-y-3">
<legend className="sr-only">Save location</legend> <legend className="sr-only">Save location</legend>

View File

@@ -1,6 +1,6 @@
import type { FileNode, FsSlice, SaveV1 } from "./types"; import type { FileNode, FsSlice, SaveV1 } from "./types";
import { DESKTOP_ID, baseNodes, initialFsSlice } from "./state"; import { DESKTOP_ID, baseNodes, initialFsSlice, moveFile } from "./state";
/** Playable levels are 0..3; 4 means finished. */ /** Playable levels are 0..3; 4 means finished. */
export const LAST_PLAYABLE = 3; export const LAST_PLAYABLE = 3;
@@ -75,8 +75,15 @@ export function buildInitialFs(levelIndex: number): FsSlice {
return initialFsSlice(level0Nodes()); return initialFsSlice(level0Nodes());
case 1: case 1:
return initialFsSlice(level1Nodes()); return initialFsSlice(level1Nodes());
case 2: case 2: {
return initialFsSlice(level2Nodes()); const nodes = level2Nodes();
/** Start inside Downloads so beach.jpg and the other files are visible (not only on Desktop). */
return {
nodes,
viewParentId: "fld-downloads",
visitedFolderIds: ["fld-downloads"],
};
}
case 3: case 3:
return initialFsSlice( return initialFsSlice(
withDesktop([{ id: "fld-docs", name: "Documents", kind: "folder" }]), withDesktop([{ id: "fld-docs", name: "Documents", kind: "folder" }]),
@@ -86,6 +93,78 @@ export function buildInitialFs(levelIndex: number): FsSlice {
} }
} }
function withVisit(fs: FsSlice, folderId: string): FsSlice {
if (fs.visitedFolderIds.includes(folderId)) return fs;
return { ...fs, visitedFolderIds: [...fs.visitedFolderIds, folderId] };
}
/** Apply the filesystem effects of having finished sub-step `completedSub` (0-based). */
function applyCompletedSub(
levelIndex: number,
completedSub: number,
fs: FsSlice,
): FsSlice {
switch (levelIndex) {
case 0:
if (completedSub === 0) return withVisit(fs, "fld-games");
if (completedSub === 1) return withVisit(fs, "fld-docs");
return fs;
case 1: {
if (completedSub === 0) {
const nodes = moveFile(fs.nodes, "file-cat", "fld-pics");
return nodes ? { ...fs, nodes } : fs;
}
if (completedSub === 1) {
const nodes = moveFile(fs.nodes, "file-hw", "fld-school");
return nodes ? { ...fs, nodes } : fs;
}
return fs;
}
case 2: {
if (completedSub === 0) {
const nodes = moveFile(fs.nodes, "f-beach", "fld-photos");
if (!nodes) return fs;
return {
...fs,
nodes,
viewParentId: "fld-downloads",
visitedFolderIds: fs.visitedFolderIds.includes("fld-downloads")
? fs.visitedFolderIds
: [...fs.visitedFolderIds, "fld-downloads"],
};
}
if (completedSub === 1) {
const nodes = moveFile(fs.nodes, "f-budget", "fld-work");
if (!nodes) return fs;
return {
...fs,
nodes,
viewParentId: "fld-downloads",
visitedFolderIds: fs.visitedFolderIds.includes("fld-downloads")
? fs.visitedFolderIds
: [...fs.visitedFolderIds, "fld-downloads"],
};
}
return fs;
}
default:
return fs;
}
}
/**
* Filesystem at the start of sub-step `subIndex` (all earlier subs applied).
* Used for debug “retry this step” without restarting the whole level.
*/
export function buildFsAtSubStart(levelIndex: number, subIndex: number): FsSlice {
if (subIndex <= 0) return buildInitialFs(levelIndex);
let fs = buildInitialFs(levelIndex);
for (let s = 0; s < subIndex; s++) {
fs = applyCompletedSub(levelIndex, s, fs);
}
return fs;
}
export function freshSave(levelIndex: number): SaveV1 { export function freshSave(levelIndex: number): SaveV1 {
return { return {
version: 1, version: 1,
@@ -163,6 +242,35 @@ export function isFsLevelComplete(levelIndex: number, fs: FsSlice): boolean {
return true; return true;
} }
/** Folders / files to visually emphasize for the current sub-step. */
export type HighlightTargets = {
folderIds: readonly string[];
fileIds: readonly string[];
};
export function highlightTargets(
levelIndex: number,
subIndex: number,
): HighlightTargets {
switch (levelIndex) {
case 0:
if (subIndex === 0) return { folderIds: ["fld-games"], fileIds: [] };
return { folderIds: ["fld-docs"], fileIds: [] };
case 1:
if (subIndex === 0)
return { folderIds: ["fld-pics"], fileIds: ["file-cat"] };
return { folderIds: ["fld-school"], fileIds: ["file-hw"] };
case 2:
if (subIndex === 0)
return { folderIds: ["fld-photos"], fileIds: ["f-beach"] };
if (subIndex === 1)
return { folderIds: ["fld-work"], fileIds: ["f-budget"] };
return { folderIds: ["fld-installers"], fileIds: ["f-game"] };
default:
return { folderIds: [], fileIds: [] };
}
}
export const QUIZ_CORRECT_ID = "opt-docs"; export const QUIZ_CORRECT_ID = "opt-docs";
export function isQuizComplete(choiceId: string | null) { export function isQuizComplete(choiceId: string | null) {
@@ -190,9 +298,9 @@ export function levelBlurb(levelIndex: number): string {
case 0: case 0:
return "Folders are like drawers—open them with a click. Use Back to return to the folder above."; return "Folders are like drawers—open them with a click. Use Back to return to the folder above.";
case 1: case 1:
return "Drag a file onto a folder to move it—or tap a file, then tap a folder below."; return "Drag each file onto the right folder in the list or sidebar.";
case 2: case 2:
return "Drag one file at a time onto the right folder (or use tap + folder buttons)."; return "You start inside Downloads. Drag each file onto its folder using Places or by opening Desktop.";
case 3: case 3:
return "Where should you save homework so you can find it later?"; return "Where should you save homework so you can find it later?";
default: default:
@@ -209,12 +317,14 @@ export function subBlurb(levelIndex: number, subIndex: number): string {
return "Next: open the Documents folder the same way."; return "Next: open the Documents folder the same way.";
case 1: case 1:
if (subIndex === 0) if (subIndex === 0)
return "Drag cat.png onto the Pictures folder (or select it, then pick Pictures)."; return "Drag cat.png onto the Pictures folder.";
return "Drag homework.txt onto the School folder."; return "Drag homework.txt onto the School folder.";
case 2: case 2:
if (subIndex === 0) return "Drag beach.jpg onto Photos."; if (subIndex === 0)
if (subIndex === 1) return "Drag budget.xlsx onto Work."; return "Drag beach.jpg onto Photos (drop on Photos in Places or the path bar).";
return "Drag game-installer.msi onto Installers."; if (subIndex === 1)
return "Drag budget.xlsx onto Work (Places or path bar).";
return "Drag game-installer.msi onto Installers (Places or path bar).";
case 3: case 3:
return levelBlurb(3); return levelBlurb(3);
default: default:

View File

@@ -1,12 +1,14 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { import {
buildFsAtSubStart,
buildInitialFs, buildInitialFs,
isFsLevelComplete, isFsLevelComplete,
isQuizComplete, isQuizComplete,
isSubComplete, isSubComplete,
QUIZ_CORRECT_ID, QUIZ_CORRECT_ID,
} from "~/lib/folder-game/levels"; } from "~/lib/folder-game/levels";
import { DESKTOP_ID } from "~/lib/folder-game/state";
describe("isFsLevelComplete", () => { describe("isFsLevelComplete", () => {
it("level 0 completes when Games and Documents were opened", () => { it("level 0 completes when Games and Documents were opened", () => {
@@ -50,3 +52,33 @@ describe("isQuizComplete", () => {
expect(isQuizComplete("opt-dl")).toBe(false); expect(isQuizComplete("opt-dl")).toBe(false);
}); });
}); });
describe("buildFsAtSubStart", () => {
it("matches full initial state for sub 0", () => {
expect(buildFsAtSubStart(1, 0)).toEqual(buildInitialFs(1));
});
it("level 1 sub 1 start keeps cat sorted and homework on desktop", () => {
const fs = buildFsAtSubStart(1, 1);
expect(isSubComplete(1, 0, fs)).toBe(true);
expect(isSubComplete(1, 1, fs)).toBe(false);
const nodes = fs.nodes;
const cat = nodes.find((n) => n.id === "file-cat");
const hw = nodes.find((n) => n.id === "file-hw");
expect(cat?.parentId).toBe("fld-pics");
expect(hw?.parentId).toBe(DESKTOP_ID);
});
it("level 2 sub 2 start has beach and budget sorted, installer in downloads", () => {
const fs = buildFsAtSubStart(2, 2);
const beach = fs.nodes.find((n) => n.id === "f-beach");
const budget = fs.nodes.find((n) => n.id === "f-budget");
const game = fs.nodes.find((n) => n.id === "f-game");
expect(beach?.parentId).toBe("fld-photos");
expect(budget?.parentId).toBe("fld-work");
expect(game?.parentId).toBe("fld-downloads");
expect(isSubComplete(2, 0, fs)).toBe(true);
expect(isSubComplete(2, 1, fs)).toBe(true);
expect(isSubComplete(2, 2, fs)).toBe(false);
});
});