Files
FolderGameChallenge/src/lib/folder-game/levels.ts
jimmy 1957e96363 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
2026-04-11 18:22:50 +12:00

224 lines
5.9 KiB
TypeScript

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 "";
}
}