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