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
224 lines
5.9 KiB
TypeScript
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 "";
|
|
}
|
|
}
|