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:
@@ -1,6 +1,14 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
|
||||
const SAVE_KEY = "folder-game-challenge:v1";
|
||||
|
||||
test.describe("browser", () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.addInitScript((key) => {
|
||||
localStorage.removeItem(key);
|
||||
}, SAVE_KEY);
|
||||
});
|
||||
|
||||
test("home page shows title", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
await expect(
|
||||
@@ -14,6 +22,83 @@ test.describe("browser", () => {
|
||||
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", () => {
|
||||
|
||||
@@ -7,14 +7,12 @@ import {
|
||||
IconFile,
|
||||
IconLayoutSidebarLeftExpand,
|
||||
} from "@tabler/icons-react";
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
type DragEvent,
|
||||
} from "react";
|
||||
import { useCallback, useMemo, useState, type DragEvent } from "react";
|
||||
|
||||
import {
|
||||
highlightTargets,
|
||||
isSubComplete,
|
||||
} from "~/lib/folder-game/levels";
|
||||
import {
|
||||
DESKTOP_ID,
|
||||
ROOT_ID,
|
||||
@@ -22,19 +20,21 @@ import {
|
||||
getChildren,
|
||||
getNode,
|
||||
goUp,
|
||||
moveFile,
|
||||
} from "~/lib/folder-game/state";
|
||||
import type { FileNode, SaveV1 } from "~/lib/folder-game/types";
|
||||
|
||||
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 = {
|
||||
save: SaveV1;
|
||||
persist: (next: SaveV1) => void;
|
||||
tryMoveToFolder: (fileId: string, folderId: string) => void;
|
||||
subComplete: boolean;
|
||||
lastSub: number;
|
||||
advanceSubOrLevel: () => void;
|
||||
levelComplete: boolean;
|
||||
onNextLevel: () => void;
|
||||
};
|
||||
|
||||
function crumbTargetContains(
|
||||
@@ -49,18 +49,12 @@ export function FileBrowserView({
|
||||
save,
|
||||
persist,
|
||||
tryMoveToFolder,
|
||||
subComplete,
|
||||
lastSub,
|
||||
advanceSubOrLevel,
|
||||
levelComplete,
|
||||
onNextLevel,
|
||||
}: 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;
|
||||
@@ -79,11 +73,6 @@ export function FileBrowserView({
|
||||
.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);
|
||||
@@ -122,6 +111,45 @@ export function FileBrowserView({
|
||||
|
||||
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 (
|
||||
<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 */}
|
||||
@@ -156,11 +184,16 @@ export function FileBrowserView({
|
||||
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 ${
|
||||
{...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
|
||||
? "bg-violet-600/25 text-violet-100"
|
||||
: "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 ? (
|
||||
<IconFolderOpen size={22} className="text-amber-400/90" />
|
||||
@@ -174,11 +207,16 @@ export function FileBrowserView({
|
||||
<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 ${
|
||||
{...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
|
||||
? "bg-violet-600/25 text-violet-100"
|
||||
: "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} />
|
||||
<span>Desktop</span>
|
||||
@@ -194,7 +232,9 @@ export function FileBrowserView({
|
||||
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"
|
||||
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
|
||||
</button>
|
||||
@@ -213,11 +253,16 @@ export function FileBrowserView({
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => openFolder(node.id)}
|
||||
{...bindFolderDrop(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"
|
||||
}`}
|
||||
} ${
|
||||
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"}
|
||||
</button>
|
||||
@@ -244,11 +289,11 @@ export function FileBrowserView({
|
||||
n.kind === "folder" ? (
|
||||
<li key={n.id}>
|
||||
<div
|
||||
className={`transition-colors ${
|
||||
className={`rounded-lg transition-colors ${
|
||||
dragOverFolderId === n.id
|
||||
? "bg-violet-600/20 ring-1 ring-inset ring-violet-400/60"
|
||||
: ""
|
||||
}`}
|
||||
} ${hintFolder(n.id) ? HINT_RING : ""}`}
|
||||
{...bindFolderDrop(n.id)}
|
||||
>
|
||||
<button
|
||||
@@ -282,16 +327,7 @@ export function FileBrowserView({
|
||||
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"
|
||||
}`}
|
||||
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 : ""}`}
|
||||
>
|
||||
<span className="flex min-w-0 items-center gap-4">
|
||||
<IconFile
|
||||
@@ -314,68 +350,14 @@ export function FileBrowserView({
|
||||
)}
|
||||
</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 ? (
|
||||
{levelComplete ? (
|
||||
<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}
|
||||
onClick={onNextLevel}
|
||||
>
|
||||
{save.subIndex < lastSub ? "Next step" : "Next level"}
|
||||
Next level
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
@@ -7,7 +7,9 @@ import { useCallback, useEffect, useState } from "react";
|
||||
import {
|
||||
LAST_PLAYABLE,
|
||||
QUIZ_CORRECT_ID,
|
||||
buildFsAtSubStart,
|
||||
buildInitialFs,
|
||||
isFsLevelComplete,
|
||||
isQuizComplete,
|
||||
isSubComplete,
|
||||
levelBlurb,
|
||||
@@ -39,6 +41,24 @@ export function PlayClient() {
|
||||
if (save) writeSave(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) => {
|
||||
setSave(next);
|
||||
}, []);
|
||||
@@ -68,15 +88,10 @@ export function PlayClient() {
|
||||
});
|
||||
}, []);
|
||||
|
||||
/** After finishing the current sub-challenge (or whole level if last sub). */
|
||||
const advanceSubOrLevel = useCallback(() => {
|
||||
/** After all sub-challenges in the level are done (single continue control). */
|
||||
const advanceToNextLevel = 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 {
|
||||
@@ -149,49 +164,67 @@ export function PlayClient() {
|
||||
}}
|
||||
onWin={advanceMainLevel}
|
||||
blurb={levelBlurb(3)}
|
||||
onDebugRetryQuiz={
|
||||
process.env.NODE_ENV === "development"
|
||||
? () => persist({ ...save, quizChoiceId: null })
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const fs = save.fs;
|
||||
const subs = subCount(save.levelIndex);
|
||||
const subComplete = isSubComplete(save.levelIndex, save.subIndex, fs);
|
||||
const lastSub = subs - 1;
|
||||
const levelComplete = isFsLevelComplete(save.levelIndex, fs);
|
||||
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">
|
||||
<div className="mx-auto max-w-7xl space-y-4 px-4 pb-8 pt-4 sm:px-6 sm:pt-5">
|
||||
<header className="space-y-2">
|
||||
<div className="flex flex-wrap items-center gap-x-4 gap-y-2">
|
||||
<Link
|
||||
href="/"
|
||||
className="font-sans text-sm text-violet-300 hover:text-violet-200"
|
||||
className="shrink-0 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">
|
||||
<p className="font-heading text-sm tracking-wide text-violet-300/95 sm:text-base">
|
||||
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">
|
||||
{process.env.NODE_ENV === "development" ? (
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Retry this step (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={() =>
|
||||
persist({
|
||||
...save,
|
||||
fs: buildFsAtSubStart(save.levelIndex, save.subIndex),
|
||||
})
|
||||
}
|
||||
>
|
||||
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)}
|
||||
</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)}
|
||||
</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)}
|
||||
</p>
|
||||
</header>
|
||||
@@ -200,9 +233,8 @@ export function PlayClient() {
|
||||
save={save}
|
||||
persist={persist}
|
||||
tryMoveToFolder={tryMoveToFolder}
|
||||
subComplete={subComplete}
|
||||
lastSub={lastSub}
|
||||
advanceSubOrLevel={advanceSubOrLevel}
|
||||
levelComplete={levelComplete}
|
||||
onNextLevel={advanceToNextLevel}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
@@ -213,11 +245,13 @@ function QuizLevel({
|
||||
onPick,
|
||||
onWin,
|
||||
blurb,
|
||||
onDebugRetryQuiz,
|
||||
}: {
|
||||
save: SaveV1;
|
||||
onPick: (id: string) => void;
|
||||
onWin: () => void;
|
||||
blurb: string;
|
||||
onDebugRetryQuiz?: () => void;
|
||||
}) {
|
||||
const [wrong, setWrong] = useState(false);
|
||||
|
||||
@@ -247,7 +281,22 @@ function QuizLevel({
|
||||
>
|
||||
← Home
|
||||
</Link>
|
||||
<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>
|
||||
<fieldset className="space-y-3">
|
||||
<legend className="sr-only">Save location</legend>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
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. */
|
||||
export const LAST_PLAYABLE = 3;
|
||||
@@ -75,8 +75,15 @@ export function buildInitialFs(levelIndex: number): FsSlice {
|
||||
return initialFsSlice(level0Nodes());
|
||||
case 1:
|
||||
return initialFsSlice(level1Nodes());
|
||||
case 2:
|
||||
return initialFsSlice(level2Nodes());
|
||||
case 2: {
|
||||
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:
|
||||
return initialFsSlice(
|
||||
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 {
|
||||
return {
|
||||
version: 1,
|
||||
@@ -163,6 +242,35 @@ export function isFsLevelComplete(levelIndex: number, fs: FsSlice): boolean {
|
||||
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 function isQuizComplete(choiceId: string | null) {
|
||||
@@ -190,9 +298,9 @@ export function levelBlurb(levelIndex: number): string {
|
||||
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.";
|
||||
return "Drag each file onto the right folder in the list or sidebar.";
|
||||
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:
|
||||
return "Where should you save homework so you can find it later?";
|
||||
default:
|
||||
@@ -209,12 +317,14 @@ export function subBlurb(levelIndex: number, subIndex: number): string {
|
||||
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 cat.png onto the Pictures folder.";
|
||||
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.";
|
||||
if (subIndex === 0)
|
||||
return "Drag beach.jpg onto Photos (drop on Photos in Places or the path bar).";
|
||||
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:
|
||||
return levelBlurb(3);
|
||||
default:
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
buildFsAtSubStart,
|
||||
buildInitialFs,
|
||||
isFsLevelComplete,
|
||||
isQuizComplete,
|
||||
isSubComplete,
|
||||
QUIZ_CORRECT_ID,
|
||||
} from "~/lib/folder-game/levels";
|
||||
import { DESKTOP_ID } from "~/lib/folder-game/state";
|
||||
|
||||
describe("isFsLevelComplete", () => {
|
||||
it("level 0 completes when Games and Documents were opened", () => {
|
||||
@@ -50,3 +52,33 @@ describe("isQuizComplete", () => {
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user