From f2d9753ae971761a0f6ca7230fc18a7dd280a5e8 Mon Sep 17 00:00:00 2001 From: wuhuizuo Date: Tue, 5 May 2026 14:43:12 +0800 Subject: [PATCH 1/3] Add sticker studio slice --- sticker-studio/README.md | 32 ++ sticker-studio/index.html | 13 + sticker-studio/package.json | 11 + sticker-studio/src/app.js | 848 ++++++++++++++++++++++++++++ sticker-studio/src/daily.js | 121 ++++ sticker-studio/src/data.js | 640 +++++++++++++++++++++ sticker-studio/src/engine.js | 705 +++++++++++++++++++++++ sticker-studio/styles.css | 671 ++++++++++++++++++++++ sticker-studio/tests/engine.test.js | 172 ++++++ 9 files changed, 3213 insertions(+) create mode 100644 sticker-studio/README.md create mode 100644 sticker-studio/index.html create mode 100644 sticker-studio/package.json create mode 100644 sticker-studio/src/app.js create mode 100644 sticker-studio/src/daily.js create mode 100644 sticker-studio/src/data.js create mode 100644 sticker-studio/src/engine.js create mode 100644 sticker-studio/styles.css create mode 100644 sticker-studio/tests/engine.test.js diff --git a/sticker-studio/README.md b/sticker-studio/README.md new file mode 100644 index 0000000..1b95ab0 --- /dev/null +++ b/sticker-studio/README.md @@ -0,0 +1,32 @@ +# Sticker Studio + +Standalone first-playable vertical slice for the Sticker Studio scrapbook puzzle. + +## Run + +Open `index.html` in a browser, or serve the directory with any static file server. + +## Test + +```bash +npm test +``` + +## Verify + +```bash +npm run verify +``` + +## What Is Included + +- Data-driven ordered sticker-sheet gameplay with tray sheets, active partial sheets, binder clips, undo, hint, restart, fail, win, and daily-page entry +- `12` handcrafted campaign pages in `2` packs of `6`, plus `1` deterministic daily generator keyed by UTC `YYYY-MM-DD` +- Solver-backed authoring validation that checks clip counts, target references, repeat-family usage, early long-chain gating, and at least one completion path +- Portrait-first scrapbook layout with visible silhouettes from the start, clip rail, sheet tray, local reject feedback, and minimal album reward wrapper + +## Implementation Notes + +- The current validator uses state-space search across `place` and `park` actions. That is sufficient for this slice because clip assignment is deterministic and there are no drag paths, hidden layers, or alternate slot behaviors. If production later adds manual clip-slot selection, drag-only peeling, or visual layers that affect legality, the authoring tool will need a deeper solver and better diagnostics. +- The design spec has one internal tension: levels `8` and `9` ask for `5` sheets with `9` silhouettes, while the same spec also says authored sheet lengths should stay between `2` and `4`. This slice resolves that by allowing a small number of one-sticker opener sheets in late pages and some daily seeds. If the team wants a strict two-sticker minimum, those pages should move to `10` silhouettes or back down to `4` sheets. +- Readability risk rises on dense portrait pages once repeated families arrive. The slice keeps every empty silhouette outlined, adds variant labels for repeated families, and keeps filled stickers slightly flatter so open targets stay legible. QA should still focus on level `10` onward and smaller phone widths to catch any pages where decorative layering or filled stickers hide open spots. diff --git a/sticker-studio/index.html b/sticker-studio/index.html new file mode 100644 index 0000000..495e3d8 --- /dev/null +++ b/sticker-studio/index.html @@ -0,0 +1,13 @@ + + + + + + Sticker Studio + + + +
+ + + diff --git a/sticker-studio/package.json b/sticker-studio/package.json new file mode 100644 index 0000000..18baca6 --- /dev/null +++ b/sticker-studio/package.json @@ -0,0 +1,11 @@ +{ + "name": "sticker-studio", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "test": "node --test", + "check:app": "node --check src/app.js", + "verify": "npm test && npm run check:app" + } +} diff --git a/sticker-studio/src/app.js b/sticker-studio/src/app.js new file mode 100644 index 0000000..fa94d80 --- /dev/null +++ b/sticker-studio/src/app.js @@ -0,0 +1,848 @@ +import { DAILY_UNLOCK_LEVEL, HANDCRAFTED_LEVELS, STORAGE_KEY, getPackForLevel } from "./data.js"; +import { dailySeedFromDate, generateDailyLevel } from "./daily.js"; +import { + applyAction, + createInitialState, + createRuntime, + evaluateParkAction, + evaluateSheet, + findHint, + getCurrentSticker, + getEmptyClipCount, + getSheetLocation, + getTargetStatus, + restartLevel, + undoAction, + validateLevel +} from "./engine.js"; + +const root = document.querySelector("#app"); + +const appState = { + mode: "campaign", + levelIndex: 0, + dailySeed: dailySeedFromDate(), + progress: loadProgress(), + level: null, + runtime: null, + puzzle: null, + validation: null, + modal: "start", + selectedSheetId: null +}; + +function createEmptyProgress() { + return { + clearedLevels: [], + dailySeeds: [], + rewards: [] + }; +} + +function loadProgress() { + try { + const parsed = JSON.parse(localStorage.getItem(STORAGE_KEY) ?? "{}"); + return { + clearedLevels: Array.isArray(parsed.clearedLevels) ? parsed.clearedLevels : [], + dailySeeds: Array.isArray(parsed.dailySeeds) ? parsed.dailySeeds : [], + rewards: Array.isArray(parsed.rewards) ? parsed.rewards : [] + }; + } catch { + return createEmptyProgress(); + } +} + +function saveProgress() { + localStorage.setItem(STORAGE_KEY, JSON.stringify(appState.progress)); +} + +function clearedCampaignCount() { + let count = 0; + for (const level of HANDCRAFTED_LEVELS) { + if (!appState.progress.clearedLevels.includes(level.id)) { + break; + } + count += 1; + } + return count; +} + +function isDailyUnlocked() { + return clearedCampaignCount() >= DAILY_UNLOCK_LEVEL; +} + +function getCurrentPack() { + return getPackForLevel(appState.level); +} + +function isLevelCleared(level) { + if (level.kind === "daily") { + return appState.progress.dailySeeds.includes(level.seed); + } + return appState.progress.clearedLevels.includes(level.id); +} + +function rewardKeyFor(level) { + if (level.kind === "daily") { + return `daily:${level.seed}`; + } + return level.id; +} + +function recordWin(level) { + if (level.kind === "daily") { + if (!appState.progress.dailySeeds.includes(level.seed)) { + appState.progress.dailySeeds.push(level.seed); + } + } else if (!appState.progress.clearedLevels.includes(level.id)) { + appState.progress.clearedLevels.push(level.id); + } + + const rewardKey = rewardKeyFor(level); + if (!appState.progress.rewards.includes(rewardKey)) { + appState.progress.rewards.push(rewardKey); + } + + saveProgress(); +} + +function loadLevel({ + mode = appState.mode, + levelIndex = appState.levelIndex, + dailySeed = appState.dailySeed, + showStart = true +} = {}) { + if (mode === "daily" && !isDailyUnlocked()) { + mode = "campaign"; + } + + appState.mode = mode; + appState.levelIndex = Math.min(levelIndex, HANDCRAFTED_LEVELS.length - 1); + appState.dailySeed = dailySeed; + appState.level = + mode === "daily" ? generateDailyLevel(dailySeed) : HANDCRAFTED_LEVELS[appState.levelIndex]; + appState.runtime = createRuntime(appState.level); + appState.puzzle = createInitialState(appState.level); + appState.validation = validateLevel(appState.level); + appState.modal = showStart ? "start" : null; + appState.selectedSheetId = null; + document.title = + appState.level.kind === "daily" + ? `Sticker Studio • Daily ${appState.level.seed}` + : `Sticker Studio • Level ${appState.level.number}`; + render(); +} + +function getPackLevels(packId) { + return HANDCRAFTED_LEVELS.filter((level) => level.packId === packId); +} + +function getPackProgress(packId) { + const levels = getPackLevels(packId); + const cleared = levels.filter((level) => appState.progress.clearedLevels.includes(level.id)).length; + return { + cleared, + total: levels.length, + percent: levels.length ? Math.round((cleared / levels.length) * 100) : 0 + }; +} + +function setLocalMessage(message, hintAction = null) { + appState.puzzle = { + ...appState.puzzle, + message, + hintAction + }; +} + +function syncSelectionAfterState() { + if (appState.puzzle.activeSheetId) { + appState.selectedSheetId = appState.puzzle.activeSheetId; + return; + } + + if (appState.selectedSheetId && !getCurrentSticker(appState.level, appState.puzzle, appState.selectedSheetId, appState.runtime)) { + appState.selectedSheetId = null; + } +} + +function applyAndRender(nextPuzzle) { + const justCompleted = !appState.puzzle.completed && nextPuzzle.completed; + const justFailed = !appState.puzzle.failed && nextPuzzle.failed; + + appState.puzzle = nextPuzzle; + syncSelectionAfterState(); + + if (justCompleted) { + recordWin(appState.level); + appState.modal = "win"; + } else if (justFailed) { + appState.modal = "fail"; + appState.selectedSheetId = appState.puzzle.activeSheetId ?? appState.selectedSheetId; + } + + render(); +} + +function handleSheetPick(sheetId) { + if (appState.puzzle.activeSheetId && appState.puzzle.activeSheetId !== sheetId) { + const evaluation = evaluateSheet(appState.level, appState.puzzle, sheetId, appState.runtime); + setLocalMessage(evaluation.reason); + render(); + return; + } + + const sticker = getCurrentSticker(appState.level, appState.puzzle, sheetId, appState.runtime); + if (!sticker) { + setLocalMessage("That sheet is already exhausted."); + render(); + return; + } + + const evaluation = evaluateSheet(appState.level, appState.puzzle, sheetId, appState.runtime); + appState.selectedSheetId = sheetId; + setLocalMessage(evaluation.ok ? `${sticker.target.title} is ready to place.` : evaluation.reason); + render(); +} + +function handleTargetPick(targetId) { + if (!appState.selectedSheetId) { + setLocalMessage("Pick a sheet first."); + render(); + return; + } + + applyAndRender( + applyAction( + appState.level, + appState.puzzle, + { + type: "place", + sheetId: appState.selectedSheetId, + targetId + }, + appState.runtime + ) + ); +} + +function handlePark() { + applyAndRender( + applyAction( + appState.level, + appState.puzzle, + { + type: "park", + sheetId: appState.puzzle.activeSheetId + }, + appState.runtime + ) + ); +} + +function handleHint() { + const hint = findHint(appState.level, appState.puzzle, appState.runtime); + if (hint.action?.sheetId) { + appState.selectedSheetId = hint.action.sheetId; + } + setLocalMessage(hint.message, hint.action); + render(); +} + +function handleUndo() { + appState.modal = null; + appState.puzzle = undoAction(appState.puzzle); + syncSelectionAfterState(); + render(); +} + +function handleRestart() { + appState.modal = null; + appState.selectedSheetId = null; + appState.puzzle = restartLevel(appState.level); + render(); +} + +function openNextLevel() { + if (appState.mode === "campaign" && appState.levelIndex < HANDCRAFTED_LEVELS.length - 1) { + loadLevel({ mode: "campaign", levelIndex: appState.levelIndex + 1, showStart: true }); + return; + } + + if (isDailyUnlocked()) { + loadLevel({ mode: "daily", dailySeed: appState.dailySeed, showStart: true }); + return; + } + + loadLevel({ mode: "campaign", levelIndex: appState.levelIndex, showStart: true }); +} + +function isLevelUnlocked(levelIndex) { + return levelIndex <= clearedCampaignCount(); +} + +function openLevelChip(levelIndex) { + if (!isLevelUnlocked(levelIndex)) { + setLocalMessage("That page unlocks by clearing the pages before it."); + render(); + return; + } + loadLevel({ mode: "campaign", levelIndex, showStart: true }); +} + +function formatLevelLabel(level) { + if (level.kind === "daily") { + return `Daily • ${level.seed}`; + } + return `Level ${String(level.number).padStart(2, "0")}`; +} + +function renderPackMeter() { + const pack = getCurrentPack(); + if (appState.level.kind === "daily") { + return ` +
+ +
+

First clear earns one foil badge for ${appState.level.seed}.

+ `; + } + + const progress = getPackProgress(pack.id); + return ` +
+ +
+

${progress.cleared}/${progress.total} pages restored in ${pack.title}.

+ `; +} + +function renderLevelRail() { + return HANDCRAFTED_LEVELS.map((level, index) => { + const locked = !isLevelUnlocked(index); + const active = appState.mode === "campaign" && appState.levelIndex === index; + const cleared = appState.progress.clearedLevels.includes(level.id); + const classes = [ + "rail-chip", + active ? "is-active" : "", + locked ? "is-locked" : "", + cleared ? "is-cleared" : "" + ] + .filter(Boolean) + .join(" "); + + return ` + + `; + }).join(""); +} + +function renderDailyCard() { + const unlocked = isDailyUnlocked(); + return ` +
+
+

Daily Page

+

${unlocked ? "Foil Badge Page" : `Unlocks After Level ${DAILY_UNLOCK_LEVEL}`}

+

${ + unlocked + ? "Seeded by UTC date, validated by the same solver, and clearable once per day." + : "Clear the Kitchen Keepsakes finale to reveal the daily retention stub." + }

+
+ + +
+ `; +} + +function sheetGhosts(sheetId) { + const sheet = appState.runtime.sheetMap.get(sheetId); + const index = appState.puzzle.sheetIndices[sheetId] ?? 0; + return sheet.stickers + .slice(index + 1, index + 3) + .map((sticker) => appState.runtime.targetMap.get(sticker.targetId)?.short ?? sticker.targetId); +} + +function sheetStatus(sheetId) { + const sheet = appState.runtime.sheetMap.get(sheetId); + const sticker = getCurrentSticker(appState.level, appState.puzzle, sheetId, appState.runtime); + if (!sticker) { + return { label: "Done", detail: "Exhausted" }; + } + + if (appState.puzzle.activeSheetId === sheetId) { + const status = getTargetStatus(appState.level, appState.puzzle, sticker.targetId, appState.runtime); + if (status.open) { + return { label: "Active", detail: "Keep placing or park it." }; + } + if (getEmptyClipCount(appState.puzzle)) { + return { label: "Active", detail: "Park it before you switch." }; + } + return { label: "Jammed", detail: "No free clip for the active strip." }; + } + + const evaluation = evaluateSheet(appState.level, appState.puzzle, sheetId, appState.runtime); + if (evaluation.ok) { + return { label: "Ready", detail: sticker.target.title }; + } + return { label: getSheetLocation(appState.puzzle, sheetId) === "clip" ? "Clipped" : "Waiting", detail: evaluation.reason }; +} + +function renderSheetCard(sheetId, options = {}) { + const sheet = appState.runtime.sheetMap.get(sheetId); + const sticker = getCurrentSticker(appState.level, appState.puzzle, sheetId, appState.runtime); + if (!sheet || !sticker) { + return ""; + } + + const status = sheetStatus(sheetId); + const ghosts = sheetGhosts(sheetId); + const selected = appState.selectedSheetId === sheetId; + const active = appState.puzzle.activeSheetId === sheetId; + const hinted = appState.puzzle.hintAction?.sheetId === sheetId; + const location = options.location ?? getSheetLocation(appState.puzzle, sheetId); + const classes = [ + "sheet-card", + selected ? "is-selected" : "", + active ? "is-active" : "", + hinted ? "is-hinted" : "", + location === "clip" ? "is-clip" : "", + options.compact ? "is-compact" : "" + ] + .filter(Boolean) + .join(" "); + + return ` + + `; +} + +function renderActiveSheet() { + const sheetId = appState.puzzle.activeSheetId; + if (!sheetId) { + return ` +
+

Active Strip

+

Place a sticker from the tray or a clip to bring a strip into hand.

+
+ `; + } + + const parkEnabled = evaluateParkAction( + appState.level, + appState.puzzle, + { type: "park", sheetId }, + appState.runtime + ).ok; + + return ` +
+
+

Active Strip

+

${appState.runtime.sheetMap.get(sheetId)?.name ?? sheetId}

+

You can keep placing from this strip or park it to switch.

+
+ ${renderSheetCard(sheetId, { compact: true, location: "active" })} + +
+ `; +} + +function renderClipRail() { + return appState.puzzle.clips + .map((sheetId, index) => { + if (!sheetId) { + return ` +
+ Clip ${index + 1} + Open +
+ `; + } + + return ` +
+ Clip ${index + 1} + ${renderSheetCard(sheetId, { compact: true, location: "clip" })} +
+ `; + }) + .join(""); +} + +function renderTray() { + const sheets = appState.runtime.sheets + .filter((sheet) => getCurrentSticker(appState.level, appState.puzzle, sheet.id, appState.runtime)) + .filter((sheet) => getSheetLocation(appState.puzzle, sheet.id) === "tray"); + + if (!sheets.length) { + return `
No untouched sheets remain in the tray.
`; + } + + return sheets.map((sheet) => renderSheetCard(sheet.id)).join(""); +} + +function repeatedFamilyCounts() { + const counts = new Map(); + for (const target of appState.runtime.targets) { + counts.set(target.family, (counts.get(target.family) ?? 0) + 1); + } + return counts; +} + +function renderTargetButton(target, familyCounts) { + const status = getTargetStatus(appState.level, appState.puzzle, target.id, appState.runtime); + const selectedSticker = appState.selectedSheetId + ? getCurrentSticker(appState.level, appState.puzzle, appState.selectedSheetId, appState.runtime) + : null; + const selectedMatch = selectedSticker?.targetId === target.id; + const hinted = + appState.puzzle.hintAction?.type === "place" && + appState.puzzle.hintAction.targetId === target.id && + appState.puzzle.hintAction.sheetId === appState.selectedSheetId; + const classes = [ + "target-node", + status.filled ? "is-filled" : "", + status.open ? "is-open" : "is-locked", + selectedMatch ? "is-match" : "", + hinted ? "is-hinted" : "", + selectedSticker && !selectedMatch && !status.filled ? "is-dimmed" : "" + ] + .filter(Boolean) + .join(" "); + + const variantBadge = + familyCounts.get(target.family) > 1 + ? `${target.variant.toUpperCase()}` + : ""; + + return ` + + `; +} + +function renderPage() { + const familyCounts = repeatedFamilyCounts(); + return ` +
+
+ ${appState.runtime.targets.map((target) => renderTargetButton(target, familyCounts)).join("")} +
+ `; +} + +function renderMechanicTags() { + return appState.level.mechanics.map((mechanic) => `${mechanic}`).join(""); +} + +function renderCallouts() { + return appState.level.callouts + .map( + (callout, index) => ` +
  • + ${index + 1} + ${callout.text} +
  • + ` + ) + .join(""); +} + +function renderRewardRail() { + if (!appState.progress.rewards.length) { + return `
    Clear a page to add the first collage reward.
    `; + } + + return appState.progress.rewards + .slice(-6) + .map((rewardId) => `${rewardId.startsWith("daily:") ? rewardId.slice(6) : rewardId.replace("ftue-", "L")}`) + .join(""); +} + +function renderToolbar() { + return ` +
    + + + +
    + `; +} + +function renderTraySection() { + return ` +
    +
    +
    +

    Sheet Tray

    +

    Untouched strips stay down here until you start peeling them.

    +
    +

    ${getEmptyClipCount(appState.puzzle)} open clip slot${getEmptyClipCount(appState.puzzle) === 1 ? "" : "s"}.

    +
    +
    ${renderTray()}
    +
    + `; +} + +function renderModal() { + if (!appState.modal) { + return ""; + } + + if (appState.modal === "start") { + return ` + + `; + } + + if (appState.modal === "fail") { + return ` + + `; + } + + return ` + + `; +} + +function render() { + const pack = getCurrentPack(); + const progressPips = + appState.level.kind === "daily" + ? `${clearedCampaignCount()} campaign clears` + : `${getPackProgress(pack.id).cleared} / ${getPackProgress(pack.id).total} pages`; + + root.innerHTML = ` +
    +
    +
    +

    Sticker Studio

    +

    Ordered sticker sheets, limited binder clips, one calm scrapbook page at a time.

    +

    ${appState.level.beat}

    + ${renderPackMeter()} +
    +
    +
    +

    Current Page

    +

    ${appState.level.title}

    +

    ${formatLevelLabel(appState.level)} • ${progressPips}

    +
    +
    +

    Album Rewards

    +
    ${renderRewardRail()}
    +
    +
    +
    + +
    +
    +
    +

    Mainline Pages

    +

    ${pack.title}

    +
    +

    Daily unlocks after level ${DAILY_UNLOCK_LEVEL}.

    +
    +
    ${renderLevelRail()}
    + ${renderDailyCard()} +
    + +
    +
    +
    +
    +

    ${formatLevelLabel(appState.level)}

    +

    ${appState.level.page.title}

    +
    +
    + ${appState.puzzle.placedTargets.length}/${appState.level.page.targets.length} placed + ${appState.level.clipCount} clips +
    +
    +
    ${renderPage()}
    + ${renderActiveSheet()} +
    ${renderClipRail()}
    + ${renderToolbar()} + ${renderTraySection()} +
    + + +
    + + ${renderModal()} +
    + `; +} + +root.addEventListener("click", (event) => { + const target = event.target.closest("[data-action]"); + if (!target) { + return; + } + + const { action } = target.dataset; + if (action === "pick-sheet") { + handleSheetPick(target.dataset.id); + return; + } + if (action === "pick-target") { + handleTargetPick(target.dataset.id); + return; + } + if (action === "park-active") { + handlePark(); + return; + } + if (action === "hint") { + appState.modal = null; + handleHint(); + return; + } + if (action === "undo") { + handleUndo(); + return; + } + if (action === "restart") { + handleRestart(); + return; + } + if (action === "close-modal") { + appState.modal = null; + render(); + return; + } + if (action === "next-level") { + openNextLevel(); + return; + } + if (action === "open-level") { + openLevelChip(Number(target.dataset.index)); + return; + } + if (action === "open-daily") { + if (!isDailyUnlocked()) { + setLocalMessage(`Clear level ${DAILY_UNLOCK_LEVEL} to unlock the daily page.`); + render(); + return; + } + loadLevel({ mode: "daily", dailySeed: appState.dailySeed, showStart: true }); + } +}); + +root.addEventListener("change", (event) => { + const target = event.target.closest("[data-action='daily-seed']"); + if (!target) { + return; + } + appState.dailySeed = target.value || dailySeedFromDate(); +}); + +loadLevel(); diff --git a/sticker-studio/src/daily.js b/sticker-studio/src/daily.js new file mode 100644 index 0000000..6432123 --- /dev/null +++ b/sticker-studio/src/daily.js @@ -0,0 +1,121 @@ +import { DAILY_THEME, buildLevelFromPlan } from "./data.js"; + +const DAILY_LAYOUTS = [ + ["hero", "topLeft", "topRight", "midLeft", "center", "midRight", "lowLeft", "lowCenter", "footer"], + ["topLeft", "hero", "topRight", "midLeft", "accentLeft", "center", "midRight", "lowCenter", "lowRight"], + ["hero", "topLeft", "topRight", "midLeft", "center", "accentRight", "midRight", "lowLeft", "lowCenter", "lowRight"] +]; + +const DAILY_MOTIFS = [ + { title: "Foil wing A", short: "WNGA", paletteId: "plum", family: "foil-wing", variant: "a" }, + { title: "Foil wing B", short: "WNGB", paletteId: "plum", family: "foil-wing", variant: "b" }, + { title: "Leaf tab A", short: "LEA1", paletteId: "sage", family: "leaf-tab", variant: "a" }, + { title: "Leaf tab B", short: "LEA2", paletteId: "sage", family: "leaf-tab", variant: "b" }, + { title: "Petal gloss", short: "PETL", paletteId: "rose", family: "petal", variant: "a" }, + { title: "Petal frame", short: "FRME", paletteId: "rose", family: "petal", variant: "b" }, + { title: "Seed mark", short: "SEED", paletteId: "gold", family: "seed-mark", variant: "a" }, + { title: "Seed pin", short: "PIN", paletteId: "gold", family: "seed-mark", variant: "b" }, + { title: "Sprig wrap", short: "WRAP", paletteId: "mint", family: "sprig", variant: "a" }, + { title: "Sprig knot", short: "KNOT", paletteId: "mint", family: "sprig", variant: "b" } +]; + +const DAILY_SHEETS = [ + { id: "A", name: "Foil strip", paletteId: "plum" }, + { id: "B", name: "Leaf strip", paletteId: "sage" }, + { id: "C", name: "Petal strip", paletteId: "rose" }, + { id: "D", name: "Seed strip", paletteId: "gold" }, + { id: "E", name: "Sprig strip", paletteId: "mint" } +]; + +const DAILY_TEMPLATES = [ + { + title: "Daily Foil Spread", + caption: "A seeded foil page with two clip slots and one date-stamped badge.", + beat: "Daily pages reuse the same sheet and clip rules with a deterministic UTC date seed.", + sheets: DAILY_SHEETS.slice(0, 4), + solution: ["A", "B", "A", "C", "B", "D", "C", "D", "A"] + }, + { + title: "Daily Foil Spread", + caption: "A seeded foil page with two clip slots and one date-stamped badge.", + beat: "Daily pages reuse the same sheet and clip rules with a deterministic UTC date seed.", + sheets: DAILY_SHEETS, + solution: ["A", "B", "C", "C", "A", "D", "D", "B", "E"] + }, + { + title: "Daily Foil Spread", + caption: "A seeded foil page with two clip slots and one date-stamped badge.", + beat: "Daily pages reuse the same sheet and clip rules with a deterministic UTC date seed.", + sheets: DAILY_SHEETS, + solution: ["A", "B", "B", "A", "C", "A", "D", "E", "C", "A"] + } +]; + +function hashSeed(seed) { + let hash = 1779033703 ^ seed.length; + for (let index = 0; index < seed.length; index += 1) { + hash = Math.imul(hash ^ seed.charCodeAt(index), 3432918353); + hash = (hash << 13) | (hash >>> 19); + } + return () => { + hash = Math.imul(hash ^ (hash >>> 16), 2246822507); + hash = Math.imul(hash ^ (hash >>> 13), 3266489909); + return (hash ^= hash >>> 16) >>> 0; + }; +} + +function mulberry32(seed) { + return () => { + let value = (seed += 0x6d2b79f5); + value = Math.imul(value ^ (value >>> 15), value | 1); + value ^= value + Math.imul(value ^ (value >>> 7), value | 61); + return ((value ^ (value >>> 14)) >>> 0) / 4294967296; + }; +} + +function shuffle(values, random) { + const copy = [...values]; + for (let index = copy.length - 1; index > 0; index -= 1) { + const swapIndex = Math.floor(random() * (index + 1)); + [copy[index], copy[swapIndex]] = [copy[swapIndex], copy[index]]; + } + return copy; +} + +export function dailySeedFromDate(date = new Date()) { + const year = date.getUTCFullYear(); + const month = String(date.getUTCMonth() + 1).padStart(2, "0"); + const day = String(date.getUTCDate()).padStart(2, "0"); + return `${year}-${month}-${day}`; +} + +export function generateDailyLevel(seed = dailySeedFromDate()) { + const random = mulberry32(hashSeed(seed)()); + const templateIndex = Math.floor(random() * DAILY_TEMPLATES.length); + const template = DAILY_TEMPLATES[templateIndex]; + const layout = DAILY_LAYOUTS[template.solution.length === 10 ? 2 : templateIndex % 2]; + const motifs = shuffle(DAILY_MOTIFS, random).slice(0, template.solution.length); + + return buildLevelFromPlan({ + id: `daily-${seed}`, + kind: "daily", + seed, + packId: DAILY_THEME.id, + title: `${template.title} • ${seed}`, + caption: template.caption, + beat: template.beat, + clipCount: 2, + mechanics: ["Seeded daily page", "Two clip slots", "Date-stamped foil badge"], + callouts: [ + { focus: "daily", text: "Daily pages are seeded by UTC date for stable QA and first-clear rewards." } + ], + reward: { + title: DAILY_THEME.rewardTitle, + detail: `First clear on ${seed} adds a foil badge to the album strip.` + }, + layout: layout.slice(0, template.solution.length), + motifs, + sheets: template.sheets, + solution: template.solution + }); +} diff --git a/sticker-studio/src/data.js b/sticker-studio/src/data.js new file mode 100644 index 0000000..4a5c9d4 --- /dev/null +++ b/sticker-studio/src/data.js @@ -0,0 +1,640 @@ +export const STORAGE_KEY = "sticker-studio-progress-v1"; +export const DAILY_UNLOCK_LEVEL = 6; + +export const PACKS = Object.freeze([ + { + id: "kitchen-keepsakes", + title: "Kitchen Keepsakes", + accent: "#b96d56", + gradient: ["#f5ebde", "#efd9c9", "#d9b59f"], + rewardTitle: "Apricot album tab" + }, + { + id: "garden-notes", + title: "Garden Notes", + accent: "#688a67", + gradient: ["#eef4e8", "#d8e8d4", "#bfd3bf"], + rewardTitle: "Moss album tab" + } +]); + +export const DAILY_THEME = Object.freeze({ + id: "daily-foil", + title: "Daily Foil Page", + accent: "#7b6d9d", + gradient: ["#f3edf8", "#e0d7ef", "#c8bfe1"], + rewardTitle: "Date-stamped foil badge" +}); + +const PACK_BY_ID = Object.fromEntries(PACKS.map((pack) => [pack.id, pack])); + +const VISUALS = Object.freeze({ + coral: { + target: "#f8ddd3", + trim: "#bb6a55", + sheet: "#ffd6ca", + accent: "#8f4e3c" + }, + gold: { + target: "#f6e3b9", + trim: "#b78828", + sheet: "#f9e6bb", + accent: "#83611b" + }, + sage: { + target: "#dcead7", + trim: "#6a9467", + sheet: "#d7ead3", + accent: "#4e704d" + }, + sky: { + target: "#dfe9f6", + trim: "#5d7da6", + sheet: "#dce8fb", + accent: "#425e85" + }, + plum: { + target: "#e8ddf2", + trim: "#7e67a8", + sheet: "#e9ddfb", + accent: "#59457f" + }, + rose: { + target: "#f1dbe4", + trim: "#b56980", + sheet: "#f6dce5", + accent: "#874a61" + }, + mint: { + target: "#d9efe7", + trim: "#4d8a7d", + sheet: "#d6f2ea", + accent: "#34675c" + }, + ink: { + target: "#ece8eb", + trim: "#7c7278", + sheet: "#f1ecef", + accent: "#585256" + } +}); + +const TARGET_SLOTS = Object.freeze({ + hero: { x: 34, y: 11, w: 34, h: 18, shape: "ticket", rotation: -4 }, + topLeft: { x: 8, y: 12, w: 20, h: 16, shape: "seal", rotation: -8 }, + topRight: { x: 72, y: 10, w: 18, h: 16, shape: "seal", rotation: 8 }, + midLeft: { x: 8, y: 34, w: 24, h: 18, shape: "tag", rotation: -6 }, + center: { x: 37, y: 34, w: 26, h: 17, shape: "strip", rotation: 2 }, + midRight: { x: 68, y: 34, w: 20, h: 18, shape: "frame", rotation: 5 }, + accentLeft: { x: 24, y: 24, w: 12, h: 10, shape: "mini", rotation: -8 }, + accentRight: { x: 78, y: 25, w: 10, h: 8, shape: "mini", rotation: 8 }, + lowLeft: { x: 12, y: 60, w: 22, h: 16, shape: "leaf", rotation: -10 }, + lowCenter: { x: 39, y: 59, w: 28, h: 17, shape: "ticket", rotation: -2 }, + lowRight: { x: 70, y: 60, w: 18, h: 16, shape: "seal", rotation: 9 }, + footer: { x: 27, y: 78, w: 46, h: 14, shape: "strip", rotation: 1 } +}); + +const clone = (value) => JSON.parse(JSON.stringify(value)); + +const motif = (title, short, paletteId, family = title, variant = "a") => ({ + title, + short, + paletteId, + family, + variant +}); + +const reward = (title, detail) => ({ title, detail }); + +export function makeTarget(slotId, config) { + const slot = TARGET_SLOTS[slotId]; + const visual = VISUALS[config.paletteId]; + if (!slot) { + throw new Error(`Unknown target slot ${slotId}.`); + } + if (!visual) { + throw new Error(`Unknown target palette ${config.paletteId}.`); + } + + return { + id: config.id, + title: config.title, + short: config.short, + family: config.family ?? config.title, + variant: config.variant ?? "a", + paletteId: config.paletteId, + tone: visual.target, + trim: visual.trim, + shape: config.shape ?? slot.shape, + x: slot.x, + y: slot.y, + w: slot.w, + h: slot.h, + rotation: config.rotation ?? slot.rotation, + prereqs: [...(config.prereqs ?? [])] + }; +} + +export function makeSheet(id, name, paletteId, targetIds) { + const visual = VISUALS[paletteId]; + if (!visual) { + throw new Error(`Unknown sheet palette ${paletteId}.`); + } + + return { + id, + name, + paletteId, + color: visual.sheet, + accent: visual.accent, + stickers: targetIds.map((targetId, index) => ({ + id: `${id}-${index + 1}`, + targetId + })) + }; +} + +export function buildLevelFromPlan({ + id, + kind = "ftue", + seed = null, + number = null, + packId, + title, + caption, + beat, + clipCount, + mechanics, + callouts, + reward: rewardData, + layout, + motifs, + sheets, + solution +}) { + const pack = kind === "daily" ? DAILY_THEME : PACK_BY_ID[packId]; + if (!pack) { + throw new Error(`Unknown pack ${packId}.`); + } + + if (layout.length !== solution.length || motifs.length !== solution.length) { + throw new Error(`Level ${id} has mismatched layout, motif, or solution lengths.`); + } + + const sheetMap = new Map(sheets.map((sheet) => [sheet.id, sheet])); + const usageBySheet = new Map(sheets.map((sheet) => [sheet.id, []])); + const targetIds = solution.map((_, index) => `t${index + 1}`); + const lastIndexBySheet = new Map(); + + const targets = solution.map((sheetId, index) => { + if (!sheetMap.has(sheetId)) { + throw new Error(`Level ${id} references unknown sheet ${sheetId} in its solution.`); + } + + const previousIndex = lastIndexBySheet.get(sheetId) ?? -1; + const prereqs = targetIds.slice(previousIndex + 1, index); + lastIndexBySheet.set(sheetId, index); + usageBySheet.get(sheetId).push(targetIds[index]); + + return makeTarget(layout[index], { + id: targetIds[index], + ...motifs[index], + prereqs + }); + }); + + const builtSheets = sheets.map((sheet) => + makeSheet(sheet.id, sheet.name, sheet.paletteId, usageBySheet.get(sheet.id)) + ); + + return { + id, + kind, + seed, + number, + packId, + title, + beat, + clipCount, + mechanics: [...mechanics], + callouts: clone(callouts), + reward: clone(rewardData), + page: { + title, + caption, + accent: pack.accent, + gradient: [...pack.gradient], + targets + }, + sheets: builtSheets + }; +} + +const kitchenPlans = [ + { + id: "ftue-01", + title: "First Peel", + caption: "Clear each short strip in order to finish the first scrapbook card.", + beat: "Follow one sheet through both stickers before you touch the next strip.", + clipCount: 0, + mechanics: ["Two-tap placement", "Ordered sheet chains", "No clips yet"], + callouts: [ + { focus: "tray", text: "Untouched sheets wait in the tray and cost nothing yet." }, + { focus: "page", text: "All silhouettes are visible from the start on every page." } + ], + reward: reward("Warm-up tile", "Adds the first collage tile to Kitchen Keepsakes."), + layout: ["hero", "center", "topLeft", "midLeft", "lowCenter", "lowRight"], + motifs: [ + motif("Teacup base", "CUP", "coral"), + motif("Cup steam", "STEM", "rose"), + motif("Lemon round", "LEMN", "gold"), + motif("Lemon sparkle", "ZEST", "gold"), + motif("Jam jar", "JAR", "sage"), + motif("Jar lid", "LID", "sage") + ], + sheets: [ + { id: "A", name: "Teacup strip", paletteId: "coral" }, + { id: "B", name: "Lemon strip", paletteId: "gold" }, + { id: "C", name: "Jam strip", paletteId: "sage" } + ], + solution: ["A", "A", "B", "B", "C", "C"] + }, + { + id: "ftue-02", + title: "First Clip", + caption: "A long strip jams unless you park it and come back after the right pieces land.", + beat: "After the first placement, the strip must wait in the only binder clip.", + clipCount: 1, + mechanics: ["First binder clip", "Return to a parked strip", "Three-sticker chain"], + callouts: [ + { focus: "clips", text: "Half-used sheets wait here. Clips are limited." }, + { focus: "hud", text: "If a revealed sticker cannot fit and no clip is free, the page jams." } + ], + reward: reward("Citrus tab", "Unlocks a lemon tab on the album edge."), + layout: ["hero", "topLeft", "midLeft", "center", "midRight", "lowCenter", "lowRight"], + motifs: [ + motif("Recipe card", "CARD", "ink"), + motif("Ribbon knot", "KNOT", "coral"), + motif("Salt label", "SALT", "sky"), + motif("Pepper label", "PEPR", "sky"), + motif("Cup saucer", "SAUC", "coral"), + motif("Berry spoon", "SPOON", "plum"), + motif("Berry shine", "SHNE", "plum") + ], + sheets: [ + { id: "A", name: "Recipe strip", paletteId: "coral" }, + { id: "B", name: "Seasoning strip", paletteId: "sky" }, + { id: "C", name: "Berry strip", paletteId: "plum" } + ], + solution: ["A", "B", "B", "A", "C", "C", "C"] + }, + { + id: "ftue-03", + title: "Return Pass", + caption: "You will park one strip, clear two others, then come back for the final sticker.", + beat: "Retrieving a parked sheet is part of the main loop, not a special case.", + clipCount: 1, + mechanics: ["Clip retrieval", "One-sticker closer", "Deadlock-free recall"], + callouts: [ + { focus: "clips", text: "Clipped sheets still show the next sticker and the remaining count." }, + { focus: "page", text: "A matching silhouette pulses only when the exposed sticker can fit now." } + ], + reward: reward("Pantry stamp", "Adds a pantry stamp tile to the album strip."), + layout: ["hero", "topRight", "midLeft", "center", "midRight", "lowLeft", "footer"], + motifs: [ + motif("Jar note", "NOTE", "sage"), + motif("Note pin", "PIN", "rose"), + motif("Tea tin", "TIN", "sky"), + motif("Tin label", "LBL", "sky"), + motif("Toast tag", "TOST", "gold"), + motif("Toast crumb", "CRMB", "gold"), + motif("Counter tab", "TAB", "ink") + ], + sheets: [ + { id: "A", name: "Jar strip", paletteId: "sage" }, + { id: "B", name: "Tin strip", paletteId: "sky" }, + { id: "C", name: "Toast strip", paletteId: "gold" }, + { id: "D", name: "Counter strip", paletteId: "ink" } + ], + solution: ["A", "B", "B", "C", "C", "A", "D"] + }, + { + id: "ftue-04", + title: "Read Before You Peel", + caption: "Two strips can open the page, but one order keeps the clip free for the right return.", + beat: "Blocked taps only nudge locally, so the player can probe without punishment.", + clipCount: 1, + mechanics: ["Multiple legal openers", "Non-punishing reject feedback", "One-clip planning"], + callouts: [ + { focus: "tray", text: "A blocked strip tells you it does not fit yet, but it spends nothing." }, + { focus: "page", text: "Repeated taps on the wrong silhouette only give local feedback." } + ], + reward: reward("Ribbon stamp", "Adds a ribbon-stamped collage tile."), + layout: ["topLeft", "hero", "topRight", "midLeft", "center", "midRight", "lowLeft", "lowCenter"], + motifs: [ + motif("Envelope front", "MAIL", "ink"), + motif("Envelope seal", "SEAL", "rose"), + motif("Teabag tag", "BAG", "gold"), + motif("Teabag string", "STR", "gold"), + motif("Butter dish", "DISH", "coral"), + motif("Butter curl", "CURL", "coral"), + motif("Apron patch", "PATCH", "sage"), + motif("Apron stitch", "STCH", "sage") + ], + sheets: [ + { id: "A", name: "Envelope strip", paletteId: "rose" }, + { id: "B", name: "Teabag strip", paletteId: "gold" }, + { id: "C", name: "Butter strip", paletteId: "coral" }, + { id: "D", name: "Apron strip", paletteId: "sage" } + ], + solution: ["A", "B", "B", "C", "C", "D", "D", "A"] + }, + { + id: "ftue-05", + title: "First Jam", + caption: "This page can deadlock if both clips hold the wrong partial strips.", + beat: "The first real jam branch teaches why clip order matters before the capstone page.", + clipCount: 2, + mechanics: ["Deadlock branch", "Two clip slots", "Three delayed returns"], + callouts: [ + { focus: "hud", text: "If no exposed sticker matches any open silhouette, the page is jammed." }, + { focus: "clips", text: "You can park an active strip even when its next sticker fits, if you need to switch." } + ], + reward: reward("Mixer badge", "Adds a brass mixer badge to the album strip."), + layout: ["topLeft", "hero", "topRight", "midLeft", "center", "midRight", "lowLeft", "lowRight"], + motifs: [ + motif("Mix bowl", "BOWL", "sky"), + motif("Whisk loop", "WHSK", "sky"), + motif("Spoon rest", "REST", "coral"), + motif("Spoon shine", "SHNE", "coral"), + motif("Herb bunch", "HERB", "sage"), + motif("Twine knot", "TWNE", "sage"), + motif("Pie note", "PIE", "gold"), + motif("Pie star", "STAR", "gold") + ], + sheets: [ + { id: "A", name: "Bowl strip", paletteId: "sky" }, + { id: "B", name: "Spoon strip", paletteId: "coral" }, + { id: "C", name: "Herb strip", paletteId: "sage" }, + { id: "D", name: "Pie strip", paletteId: "gold" } + ], + solution: ["A", "B", "A", "C", "B", "D", "C", "D"] + }, + { + id: "ftue-06", + title: "Pack Finale", + caption: "The last kitchen page asks for two clean parks before the long return chain resolves.", + beat: "Clear the finale to unlock the daily foil page.", + clipCount: 2, + mechanics: ["Pack finale", "Daily unlock", "Two-step return chain"], + callouts: [ + { focus: "daily", text: "Clear this page to unlock the seeded daily foil page." }, + { focus: "clips", text: "Only partial sheets consume clip space. Untouched strips stay in the tray." } + ], + reward: reward("Apricot album tab", "Unlocks the Kitchen Keepsakes album tab color and the daily card."), + layout: ["hero", "topLeft", "topRight", "midLeft", "center", "midRight", "lowLeft", "footer"], + motifs: [ + motif("Recipe frame", "FRME", "ink"), + motif("Frame tack", "TACK", "rose"), + motif("Rolling pin", "ROLL", "coral"), + motif("Rolling grain", "WOOD", "coral"), + motif("Spice clip", "SPCE", "gold"), + motif("Spice star", "ANIS", "gold"), + motif("Napkin fold", "FOLD", "sage"), + motif("Napkin trim", "TRIM", "sage") + ], + sheets: [ + { id: "A", name: "Frame strip", paletteId: "rose" }, + { id: "B", name: "Rolling strip", paletteId: "coral" }, + { id: "C", name: "Spice strip", paletteId: "gold" }, + { id: "D", name: "Napkin strip", paletteId: "sage" } + ], + solution: ["A", "B", "A", "D", "B", "C", "C", "D"] + } +]; + +const gardenPlans = [ + { + id: "ftue-07", + title: "Repeated Family", + caption: "Two leaf stickers share a family, so the notch and label variant matter now.", + beat: "Repeated families begin here, but both variants stay visible and readable.", + clipCount: 2, + mechanics: ["Repeated family variants", "Two clips", "Variant readability"], + callouts: [ + { focus: "page", text: "Repeated families use a notch variant and a short label so color is never the only cue." }, + { focus: "tray", text: "The tray still keeps untouched sheets free, even with repeated sticker families." } + ], + reward: reward("Leaf tile", "Adds the first Garden Notes tile to the album strip."), + layout: ["hero", "topLeft", "topRight", "midLeft", "center", "midRight", "lowLeft", "lowCenter"], + motifs: [ + motif("Seed packet", "SEED", "gold"), + motif("Leaf marker A", "LEF1", "sage", "leaf-marker", "a"), + motif("Leaf marker B", "LEF2", "sage", "leaf-marker", "b"), + motif("Tulip base", "TULP", "rose"), + motif("Tulip shine", "GLSS", "rose"), + motif("Garden twine", "TWNE", "mint"), + motif("Moth stamp", "MOTH", "plum"), + motif("Moth spark", "SPRK", "plum") + ], + sheets: [ + { id: "A", name: "Seed strip", paletteId: "gold" }, + { id: "B", name: "Leaf strip", paletteId: "sage" }, + { id: "C", name: "Tulip strip", paletteId: "rose" }, + { id: "D", name: "Moth strip", paletteId: "plum" } + ], + solution: ["A", "B", "A", "C", "C", "D", "D", "B"] + }, + { + id: "ftue-08", + title: "Long Chain", + caption: "The first four-sticker strip stretches across the page and keeps coming back.", + beat: "A long chain is introduced here, even though one opener strip stays only one sticker long in the slice data.", + clipCount: 2, + mechanics: ["First four-sticker strip", "Long returns", "Readable long-chain pacing"], + callouts: [ + { focus: "clips", text: "A long strip still shows the exposed sticker plus a remaining-count pip while clipped." }, + { focus: "hud", text: "Use Undo if the long strip parks in the wrong order." } + ], + reward: reward("Pressed bloom tile", "Adds a pressed bloom collage tile to Garden Notes."), + layout: ["hero", "topLeft", "topRight", "midLeft", "center", "midRight", "lowLeft", "lowCenter", "lowRight"], + motifs: [ + motif("Butterfly wing", "WING", "plum"), + motif("Wing spot", "SPOT", "plum"), + motif("Seed note", "NOTE", "gold"), + motif("Seed tie", "TIE", "gold"), + motif("Leaf wash", "WASH", "sage"), + motif("Leaf line", "LINE", "sage"), + motif("Tulip tag", "TAG", "rose", "petal", "a"), + motif("Petal gloss", "GLOS", "rose", "petal", "b"), + motif("Postmark sprig", "SPRG", "mint") + ], + sheets: [ + { id: "A", name: "Wing strip", paletteId: "plum" }, + { id: "B", name: "Seed strip", paletteId: "gold" }, + { id: "C", name: "Leaf strip", paletteId: "sage" }, + { id: "D", name: "Tulip strip", paletteId: "rose" }, + { id: "E", name: "Sprig strip", paletteId: "mint" } + ], + solution: ["A", "B", "B", "A", "C", "A", "D", "A", "E"] + }, + { + id: "ftue-09", + title: "Double Parking", + caption: "Both clips must hold the correct strips before the center of the page unlocks.", + beat: "This page makes the player use both clips correctly at once.", + clipCount: 2, + mechanics: ["Double parking", "Two active returns", "Shared family read"], + callouts: [ + { focus: "clips", text: "This is the first page that expects both clips to be occupied correctly." }, + { focus: "page", text: "Deadlocks stay deterministic: the page only jams when nothing exposed can fit." } + ], + reward: reward("Sprout badge", "Adds a sprout badge tile to the album strip."), + layout: ["topLeft", "hero", "topRight", "midLeft", "center", "midRight", "lowLeft", "lowCenter", "footer"], + motifs: [ + motif("Butterfly tab", "BUTA", "plum", "butterfly", "a"), + motif("Seed tab", "SEDA", "gold", "seed-tag", "a"), + motif("Leaf marker A", "LEF1", "sage", "leaf-marker", "a"), + motif("Leaf marker B", "LEF2", "sage", "leaf-marker", "b"), + motif("Butterfly shine", "BUTB", "plum", "butterfly", "b"), + motif("Stem wrap", "STEM", "mint"), + motif("Stem knot", "KNOT", "mint"), + motif("Tulip note", "NOTE", "rose"), + motif("Garden pin", "PIN", "ink") + ], + sheets: [ + { id: "A", name: "Butterfly strip", paletteId: "plum" }, + { id: "B", name: "Seed strip", paletteId: "gold" }, + { id: "C", name: "Leaf strip", paletteId: "sage" }, + { id: "D", name: "Stem strip", paletteId: "mint" }, + { id: "E", name: "Garden pin strip", paletteId: "ink" } + ], + solution: ["A", "B", "C", "C", "A", "D", "D", "B", "E"] + }, + { + id: "ftue-10", + title: "Dense Spread", + caption: "The page gets busier, but every silhouette keeps its own outline and family cue.", + beat: "Readability, not a new rule, is the main pressure point here.", + clipCount: 3, + mechanics: ["Dense portrait spread", "Three clips", "Five live strip families"], + callouts: [ + { focus: "page", text: "Even dense pages keep all empty silhouettes outlined and readable on a phone." }, + { focus: "hud", text: "Hint only reveals one exact sheet and target, never a full sequence." } + ], + reward: reward("Glasshouse tile", "Adds a glasshouse collage tile to the album strip."), + layout: ["hero", "topLeft", "topRight", "midLeft", "accentLeft", "center", "midRight", "lowLeft", "lowCenter", "lowRight"], + motifs: [ + motif("Seed frame", "FRME", "gold"), + motif("Leaf marker A", "LEF1", "sage", "leaf-marker", "a"), + motif("Moth stamp", "MOTH", "plum"), + motif("Seed seal", "SEAL", "gold"), + motif("Leaf marker B", "LEF2", "sage", "leaf-marker", "b"), + motif("Tulip petal", "PETL", "rose"), + motif("Moth trail", "TRIL", "plum"), + motif("Sprig clip", "CLIP", "mint"), + motif("Tulip shine", "SHNE", "rose"), + motif("Sprig knot", "KNOT", "mint") + ], + sheets: [ + { id: "A", name: "Seed strip", paletteId: "gold" }, + { id: "B", name: "Leaf strip", paletteId: "sage" }, + { id: "C", name: "Moth strip", paletteId: "plum" }, + { id: "D", name: "Tulip strip", paletteId: "rose" }, + { id: "E", name: "Sprig strip", paletteId: "mint" } + ], + solution: ["A", "B", "C", "A", "D", "B", "E", "C", "D", "E"] + }, + { + id: "ftue-11", + title: "Two Openers", + caption: "Several clean starts are visible, but the return order still decides whether the page opens or jams.", + beat: "This is the first late-game page with multiple reasonable opening reads.", + clipCount: 3, + mechanics: ["Multiple good openings", "Three-way clip pressure", "Late return chain"], + callouts: [ + { focus: "tray", text: "More than one opener is valid here. The puzzle lives in the follow-up parking order." }, + { focus: "clips", text: "Clips are strategic storage, not punishment. Use them to hold later chains." } + ], + reward: reward("Pressed fern tile", "Adds a pressed fern tile to the album strip."), + layout: ["topLeft", "hero", "topRight", "midLeft", "center", "accentRight", "midRight", "lowLeft", "lowCenter", "footer"], + motifs: [ + motif("Butterfly wing A", "WNGA", "plum", "butterfly", "a"), + motif("Leaf tag A", "LEA1", "sage", "leaf-tag", "a"), + motif("Tulip bud", "BUD", "rose"), + motif("Leaf tag B", "LEA2", "sage", "leaf-tag", "b"), + motif("Seed packet", "PACK", "gold"), + motif("Butterfly wing B", "WNGB", "plum", "butterfly", "b"), + motif("Clip shine", "SHNE", "ink"), + motif("Tulip gloss", "GLOS", "rose"), + motif("Seed pin", "PIN", "gold"), + motif("Clip tab", "TAB", "ink") + ], + sheets: [ + { id: "A", name: "Butterfly strip", paletteId: "plum" }, + { id: "B", name: "Leaf strip", paletteId: "sage" }, + { id: "C", name: "Tulip strip", paletteId: "rose" }, + { id: "D", name: "Seed strip", paletteId: "gold" }, + { id: "E", name: "Clip strip", paletteId: "ink" } + ], + solution: ["A", "B", "C", "B", "D", "A", "E", "C", "D", "E"] + }, + { + id: "ftue-12", + title: "Capstone Collage", + caption: "The last page combines repeated families, a long return chain, and full clip management without new rules.", + beat: "Everything from the slice comes together on one readable portrait page.", + clipCount: 3, + mechanics: ["Capstone collage", "Repeated families", "Long return chain", "Full clip management"], + callouts: [ + { focus: "daily", text: "The daily foil page stays available after this clear." }, + { focus: "page", text: "If dense decoration ever hides open silhouettes, the page art needs simplification before more content." } + ], + reward: reward("Moss album tab", "Unlocks the Garden Notes album tab color."), + layout: ["hero", "topLeft", "topRight", "midLeft", "accentLeft", "center", "midRight", "lowLeft", "lowCenter", "lowRight"], + motifs: [ + motif("Butterfly band", "BAND", "plum", "butterfly", "a"), + motif("Leaf marker A", "LEF1", "sage", "leaf-marker", "a"), + motif("Leaf marker B", "LEF2", "sage", "leaf-marker", "b"), + motif("Tulip frame", "FRME", "rose"), + motif("Frame gloss", "GLOS", "rose"), + motif("Seed stamp", "SEED", "gold"), + motif("Sprig twine", "TWNE", "mint"), + motif("Twine knot", "KNOT", "mint"), + motif("Butterfly pin", "PIN", "plum", "butterfly", "b"), + motif("Foil tag", "FOIL", "ink") + ], + sheets: [ + { id: "A", name: "Butterfly strip", paletteId: "plum" }, + { id: "B", name: "Leaf strip", paletteId: "sage" }, + { id: "C", name: "Tulip strip", paletteId: "rose" }, + { id: "D", name: "Seed strip", paletteId: "gold" }, + { id: "E", name: "Foil strip", paletteId: "ink" } + ], + solution: ["A", "B", "C", "A", "B", "D", "E", "C", "D", "A"] + } +]; + +export const HANDCRAFTED_LEVELS = Object.freeze([ + ...kitchenPlans.map((plan, index) => + buildLevelFromPlan({ + ...plan, + number: index + 1, + packId: "kitchen-keepsakes" + }) + ), + ...gardenPlans.map((plan, index) => + buildLevelFromPlan({ + ...plan, + number: kitchenPlans.length + index + 1, + packId: "garden-notes" + }) + ) +]); + +export function getPackForLevel(level) { + if (!level || level.kind === "daily") { + return DAILY_THEME; + } + return PACK_BY_ID[level.packId]; +} diff --git a/sticker-studio/src/engine.js b/sticker-studio/src/engine.js new file mode 100644 index 0000000..c42d660 --- /dev/null +++ b/sticker-studio/src/engine.js @@ -0,0 +1,705 @@ +const clone = (value) => JSON.parse(JSON.stringify(value)); + +export function createRuntime(level) { + const targets = level.page.targets.map((target) => clone(target)); + const sheets = level.sheets.map((sheet) => clone(sheet)); + + return { + level, + targets, + targetMap: new Map(targets.map((target) => [target.id, target])), + sheets, + sheetMap: new Map(sheets.map((sheet) => [sheet.id, sheet])), + totalStickers: targets.length + }; +} + +export function createInitialState(level) { + return { + sheetIndices: Object.fromEntries(level.sheets.map((sheet) => [sheet.id, 0])), + clips: Array(level.clipCount).fill(null), + activeSheetId: null, + placedTargets: [], + failed: false, + completed: false, + message: level.beat, + failReason: null, + hintAction: null, + history: [] + }; +} + +function snapshotState(state) { + return { + sheetIndices: clone(state.sheetIndices), + clips: [...state.clips], + activeSheetId: state.activeSheetId, + placedTargets: [...state.placedTargets], + failed: state.failed, + completed: state.completed, + message: state.message, + failReason: state.failReason, + hintAction: state.hintAction ? clone(state.hintAction) : null + }; +} + +function targetLabel(runtime, targetId) { + return runtime.targetMap.get(targetId)?.title ?? targetId; +} + +function remainingLabel(sheet, index) { + const remaining = Math.max(0, sheet.stickers.length - index); + return remaining === 1 ? "1 sticker left" : `${remaining} stickers left`; +} + +export function getSheetLocation(state, sheetId) { + if (state.activeSheetId === sheetId) { + return "active"; + } + + const clipIndex = state.clips.findIndex((entry) => entry === sheetId); + if (clipIndex >= 0) { + return "clip"; + } + + return "tray"; +} + +export function getEmptyClipCount(state) { + return state.clips.filter((entry) => entry === null).length; +} + +export function getCurrentSticker(level, state, sheetId, runtime = createRuntime(level)) { + const sheet = runtime.sheetMap.get(sheetId); + if (!sheet) { + return null; + } + + const index = state.sheetIndices[sheetId] ?? 0; + if (index >= sheet.stickers.length) { + return null; + } + + const sticker = sheet.stickers[index]; + const target = runtime.targetMap.get(sticker.targetId); + + return { + ...clone(sticker), + sheetId, + index, + remaining: sheet.stickers.length - index, + target: target ? clone(target) : null + }; +} + +export function isTargetFilled(state, targetId) { + return state.placedTargets.includes(targetId); +} + +export function getTargetStatus(level, state, targetId, runtime = createRuntime(level)) { + const target = runtime.targetMap.get(targetId); + if (!target) { + return { + target: null, + filled: false, + open: false, + missingPrereqs: [], + reason: `Unknown target ${targetId}.` + }; + } + + if (isTargetFilled(state, targetId)) { + return { + target, + filled: true, + open: false, + missingPrereqs: [], + reason: `${target.title} is already filled.` + }; + } + + const missingPrereqs = target.prereqs.filter((prereqId) => !isTargetFilled(state, prereqId)); + if (!missingPrereqs.length) { + return { + target, + filled: false, + open: true, + missingPrereqs: [], + reason: `${target.title} is open.` + }; + } + + const missingLabels = missingPrereqs.map((prereqId) => targetLabel(runtime, prereqId)); + return { + target, + filled: false, + open: false, + missingPrereqs, + reason: + missingLabels.length > 2 + ? `${target.title} still waits for earlier collage layers.` + : `${target.title} still waits for ${missingLabels.join(" and ")}.` + }; +} + +function liftSheetFromClip(state, sheetId) { + const clips = [...state.clips]; + const clipIndex = clips.findIndex((entry) => entry === sheetId); + if (clipIndex >= 0) { + clips[clipIndex] = null; + } + return clips; +} + +function assignSheetToClip(state, sheetId) { + const clips = [...state.clips]; + const openIndex = clips.findIndex((entry) => entry === null); + if (openIndex < 0) { + return null; + } + clips[openIndex] = sheetId; + return clips; +} + +function canExposeCurrentSticker(level, state, sheetId, runtime) { + const currentSticker = getCurrentSticker(level, state, sheetId, runtime); + if (!currentSticker?.target) { + return { ok: false, reason: "That sheet is already exhausted." }; + } + + const status = getTargetStatus(level, state, currentSticker.targetId, runtime); + if (!status.open) { + return { ok: false, reason: status.reason, sticker: currentSticker }; + } + + return { + ok: true, + reason: `${currentSticker.target.title} can place now.`, + sticker: currentSticker, + targetStatus: status + }; +} + +export function evaluateSheet(level, state, sheetId, runtime = createRuntime(level)) { + if (state.failed || state.completed) { + return { ok: false, reason: "The page is no longer active." }; + } + + const sheet = runtime.sheetMap.get(sheetId); + if (!sheet) { + return { ok: false, reason: `Unknown sheet ${sheetId}.` }; + } + + if (state.activeSheetId && state.activeSheetId !== sheetId) { + const activeSheet = runtime.sheetMap.get(state.activeSheetId); + return { + ok: false, + reason: `Park or finish ${activeSheet?.name ?? state.activeSheetId} first.` + }; + } + + return canExposeCurrentSticker(level, state, sheetId, runtime); +} + +export function evaluatePlaceAction(level, state, action, runtime = createRuntime(level)) { + if (action.type !== "place") { + return { ok: false, reason: "Place evaluation expects a place action." }; + } + + const sheetCheck = evaluateSheet(level, state, action.sheetId, runtime); + if (!sheetCheck.ok) { + return sheetCheck; + } + + const currentSticker = sheetCheck.sticker; + if (action.targetId !== currentSticker.targetId) { + return { + ok: false, + reason: `${runtime.sheetMap.get(action.sheetId)?.name ?? action.sheetId} exposes ${currentSticker.target.title}, not ${targetLabel( + runtime, + action.targetId + )}.` + }; + } + + return { + ok: true, + reason: `${currentSticker.target.title} can place now.`, + sticker: currentSticker, + target: currentSticker.target + }; +} + +export function evaluateParkAction(level, state, action, runtime = createRuntime(level)) { + if (action.type !== "park") { + return { ok: false, reason: "Park evaluation expects a park action." }; + } + + if (state.failed || state.completed) { + return { ok: false, reason: "The page is no longer active." }; + } + + if (!state.activeSheetId) { + return { ok: false, reason: "No partial sheet is active right now." }; + } + + if (action.sheetId && action.sheetId !== state.activeSheetId) { + return { ok: false, reason: "Only the active partial sheet can be parked." }; + } + + if (!getCurrentSticker(level, state, state.activeSheetId, runtime)) { + return { ok: false, reason: "That sheet is already exhausted." }; + } + + if (!getEmptyClipCount(state)) { + return { ok: false, reason: "All binder clips are full." }; + } + + const sheet = runtime.sheetMap.get(state.activeSheetId); + const sticker = getCurrentSticker(level, state, state.activeSheetId, runtime); + return { + ok: true, + reason: `Park ${sheet?.name ?? state.activeSheetId} with ${remainingLabel(sheet, sticker.index)}.` + }; +} + +function scorePlaceAction(level, state, action, runtime) { + const sticker = getCurrentSticker(level, state, action.sheetId, runtime); + if (!sticker) { + return -1; + } + + const nextIndices = { ...state.sheetIndices, [action.sheetId]: sticker.index + 1 }; + const provisionalState = { + ...state, + sheetIndices: nextIndices, + placedTargets: [...state.placedTargets, sticker.targetId] + }; + const nextSticker = getCurrentSticker(level, provisionalState, action.sheetId, runtime); + const unlocks = runtime.targets.filter((target) => { + if (provisionalState.placedTargets.includes(target.id)) { + return false; + } + return target.prereqs.every((prereqId) => provisionalState.placedTargets.includes(prereqId)); + }).length; + + return ( + (nextSticker ? 6 : 12) + + unlocks * 3 + + (state.activeSheetId === action.sheetId ? 2 : 0) + + (getSheetLocation(state, action.sheetId) === "clip" ? 3 : 0) + ); +} + +export function collectLegalActions(level, state, runtime = createRuntime(level)) { + if (state.failed || state.completed) { + return []; + } + + const actions = []; + if (state.activeSheetId) { + const currentSticker = getCurrentSticker(level, state, state.activeSheetId, runtime); + if (currentSticker) { + const placeAction = { + type: "place", + sheetId: state.activeSheetId, + targetId: currentSticker.targetId + }; + if (evaluatePlaceAction(level, state, placeAction, runtime).ok) { + actions.push({ + ...placeAction, + score: scorePlaceAction(level, state, placeAction, runtime) + }); + } + } + + const parkAction = { type: "park", sheetId: state.activeSheetId }; + if (evaluateParkAction(level, state, parkAction, runtime).ok) { + actions.push({ ...parkAction, score: 4 }); + } + } else { + for (const sheet of runtime.sheets) { + const currentSticker = getCurrentSticker(level, state, sheet.id, runtime); + if (!currentSticker) { + continue; + } + const placeAction = { + type: "place", + sheetId: sheet.id, + targetId: currentSticker.targetId + }; + if (evaluatePlaceAction(level, state, placeAction, runtime).ok) { + actions.push({ + ...placeAction, + score: scorePlaceAction(level, state, placeAction, runtime) + }); + } + } + } + + return actions.sort((left, right) => right.score - left.score || left.sheetId.localeCompare(right.sheetId)); +} + +function finalizeOutcome(level, state, runtime) { + if (state.placedTargets.length === runtime.totalStickers) { + return { + ...state, + completed: true, + failed: false, + failReason: null, + activeSheetId: null, + hintAction: null, + message: "The scrapbook page is complete." + }; + } + + if (!collectLegalActions(level, state, runtime).length) { + return { + ...state, + failed: true, + completed: false, + failReason: "deadlock", + hintAction: null, + message: "No open spot matches any exposed sticker." + }; + } + + return state; +} + +export function applyAction(level, state, action, runtime = createRuntime(level)) { + if (action.type === "park") { + const evaluation = evaluateParkAction(level, state, action, runtime); + if (!evaluation.ok) { + return { + ...state, + message: evaluation.reason, + hintAction: null + }; + } + + const clips = assignSheetToClip(state, state.activeSheetId); + const sheet = runtime.sheetMap.get(state.activeSheetId); + return finalizeOutcome( + level, + { + ...state, + clips, + activeSheetId: null, + history: [...state.history, snapshotState(state)], + hintAction: null, + message: `${sheet?.name ?? state.activeSheetId} parks in a binder clip.` + }, + runtime + ); + } + + const evaluation = evaluatePlaceAction(level, state, action, runtime); + if (!evaluation.ok) { + return { + ...state, + message: evaluation.reason, + hintAction: null + }; + } + + const currentSticker = evaluation.sticker; + const sheet = runtime.sheetMap.get(action.sheetId); + const nextSheetIndices = { + ...state.sheetIndices, + [action.sheetId]: currentSticker.index + 1 + }; + const nextState = { + ...state, + sheetIndices: nextSheetIndices, + clips: liftSheetFromClip(state, action.sheetId), + placedTargets: [...state.placedTargets, currentSticker.targetId], + activeSheetId: action.sheetId, + history: [...state.history, snapshotState(state)], + hintAction: null, + failReason: null, + message: `${currentSticker.target.title} lands on the page.` + }; + + const revealedSticker = getCurrentSticker(level, nextState, action.sheetId, runtime); + if (!revealedSticker) { + return finalizeOutcome( + level, + { + ...nextState, + activeSheetId: null, + message: `${sheet?.name ?? action.sheetId} is exhausted and slides away.` + }, + runtime + ); + } + + const revealedStatus = getTargetStatus(level, nextState, revealedSticker.targetId, runtime); + if (!revealedStatus.open && !getEmptyClipCount(nextState)) { + return { + ...nextState, + failed: true, + completed: false, + failReason: "overflow", + hintAction: null, + message: "Page Jammed. The active sheet needs a clip, but every binder clip is full." + }; + } + + if (revealedStatus.open) { + return finalizeOutcome( + level, + { + ...nextState, + message: `${sheet?.name ?? action.sheetId} reveals ${revealedSticker.target.title}. Keep going or park it.` + }, + runtime + ); + } + + return finalizeOutcome( + level, + { + ...nextState, + message: `${sheet?.name ?? action.sheetId} now waits for an open clip.` + }, + runtime + ); +} + +export function undoAction(state) { + if (!state.history.length) { + return { + ...state, + message: "Nothing to undo yet.", + hintAction: null + }; + } + + const snapshot = state.history[state.history.length - 1]; + return { + ...clone(snapshot), + history: state.history.slice(0, -1), + hintAction: null, + message: "Last step undone." + }; +} + +export function restartLevel(level) { + return createInitialState(level); +} + +export function findHint(level, state, runtime = createRuntime(level)) { + const action = collectLegalActions(level, state, runtime)[0]; + if (!action) { + return { + action: null, + message: "No legal move is available from this state." + }; + } + + if (action.type === "park") { + const sheet = runtime.sheetMap.get(action.sheetId); + return { + action, + message: `Hint: park ${sheet?.name ?? action.sheetId} so another strip can open the page.` + }; + } + + const sticker = getCurrentSticker(level, state, action.sheetId, runtime); + return { + action, + message: `Hint: place ${sticker?.target?.title ?? action.targetId} from ${runtime.sheetMap.get(action.sheetId)?.name ?? action.sheetId}.` + }; +} + +function sheetCountSummary(level) { + return level.sheets.map((sheet) => sheet.stickers.length); +} + +function overlapArea(left, right) { + const width = Math.max( + 0, + Math.min(left.x + left.w, right.x + right.w) - Math.max(left.x, right.x) + ); + const height = Math.max( + 0, + Math.min(left.y + left.h, right.y + right.h) - Math.max(left.y, right.y) + ); + return width * height; +} + +export function stateKey(level, state) { + const sheetIds = level.sheets.map((sheet) => sheet.id); + const indices = sheetIds.map((sheetId) => state.sheetIndices[sheetId]).join(","); + return `${indices}|${state.clips.join(",")}|${state.activeSheetId ?? "-"}|${state.placedTargets.join(",")}`; +} + +export function solveLevel(level, runtime = createRuntime(level)) { + const start = createInitialState(level); + const queue = [{ state: start, sequence: [] }]; + const visited = new Set([stateKey(level, start)]); + + while (queue.length) { + const current = queue.shift(); + if (current.state.completed) { + return { ok: true, sequence: current.sequence }; + } + + if (current.state.failed) { + continue; + } + + for (const action of collectLegalActions(level, current.state, runtime)) { + const next = applyAction(level, current.state, action, runtime); + if (next.failed && next.failReason === "overflow") { + continue; + } + const key = stateKey(level, next); + if (visited.has(key)) { + continue; + } + visited.add(key); + queue.push({ + state: next, + sequence: [...current.sequence, action] + }); + } + } + + return { + ok: false, + sequence: [], + reason: "Validator could not find a complete sequence." + }; +} + +export function validateLevel(level) { + const runtime = createRuntime(level); + const issues = []; + + if (level.clipCount < 0 || level.clipCount > 3) { + issues.push("Clip count must stay between 0 and 3."); + } + + if (runtime.targets.length < 6 || runtime.targets.length > 10) { + issues.push("Each authored page should contain 6 to 10 silhouettes."); + } + + if (runtime.sheets.length < 3 || runtime.sheets.length > 5) { + issues.push("Each authored page should contain 3 to 5 sheets."); + } + + const targetIds = new Set(); + for (const target of runtime.targets) { + if (targetIds.has(target.id)) { + issues.push(`Duplicate target id ${target.id}.`); + } + targetIds.add(target.id); + + if (target.x < 0 || target.y < 0 || target.x + target.w > 100 || target.y + target.h > 100) { + issues.push(`Target ${target.id} falls outside the page bounds.`); + } + + for (const prereqId of target.prereqs) { + if (prereqId === target.id) { + issues.push(`Target ${target.id} cannot depend on itself.`); + } + } + } + + for (let leftIndex = 0; leftIndex < runtime.targets.length; leftIndex += 1) { + for (let rightIndex = leftIndex + 1; rightIndex < runtime.targets.length; rightIndex += 1) { + const left = runtime.targets[leftIndex]; + const right = runtime.targets[rightIndex]; + const overlap = overlapArea(left, right); + if (!overlap) { + continue; + } + + const leftRatio = overlap / (left.w * left.h); + const rightRatio = overlap / (right.w * right.h); + if (leftRatio > 0.15 || rightRatio > 0.15) { + issues.push( + `Targets ${left.id} and ${right.id} overlap too much (${Math.round(leftRatio * 100)}% / ${Math.round(rightRatio * 100)}%).` + ); + } + } + } + + const sheetIds = new Set(); + const targetUsage = new Map(runtime.targets.map((target) => [target.id, 0])); + const lengths = sheetCountSummary(level); + + for (const sheet of runtime.sheets) { + if (sheetIds.has(sheet.id)) { + issues.push(`Duplicate sheet id ${sheet.id}.`); + } + sheetIds.add(sheet.id); + + if (!sheet.stickers.length) { + issues.push(`Sheet ${sheet.id} is empty.`); + } + + if (level.kind === "ftue" && level.number < 8 && sheet.stickers.length > 3) { + issues.push(`Level ${level.number} introduces a 4-sticker sheet too early.`); + } + + for (const sticker of sheet.stickers) { + if (!runtime.targetMap.has(sticker.targetId)) { + issues.push(`Sheet ${sheet.id} references unknown target ${sticker.targetId}.`); + continue; + } + targetUsage.set(sticker.targetId, (targetUsage.get(sticker.targetId) ?? 0) + 1); + } + } + + for (const target of runtime.targets) { + for (const prereqId of target.prereqs) { + if (!runtime.targetMap.has(prereqId)) { + issues.push(`Target ${target.id} references unknown prerequisite ${prereqId}.`); + } + } + } + + for (const [targetId, usage] of targetUsage.entries()) { + if (usage !== 1) { + issues.push(`Target ${targetId} should be referenced exactly once, found ${usage}.`); + } + } + + if (level.number >= 7) { + const familyCounts = new Map(); + for (const target of runtime.targets) { + familyCounts.set(target.family, (familyCounts.get(target.family) ?? 0) + 1); + } + const hasRepeat = [...familyCounts.values()].some((count) => count > 1); + if (!hasRepeat) { + issues.push(`Level ${level.number} should introduce or use a repeated sticker family.`); + } + } + + if (level.kind === "daily") { + if (level.clipCount !== 2) { + issues.push("Daily pages should use exactly 2 clip slots."); + } + if (runtime.targets.length < 9 || runtime.targets.length > 10) { + issues.push("Daily pages should contain 9 to 10 silhouettes."); + } + } + + if (lengths.some((length) => length < 1 || length > 4)) { + issues.push("Sheet lengths must stay between 1 and 4 in the slice authoring data."); + } + + const solution = solveLevel(level, runtime); + if (!solution.ok) { + issues.push(solution.reason); + } + + return { + ok: issues.length === 0, + issues, + solution + }; +} diff --git a/sticker-studio/styles.css b/sticker-studio/styles.css new file mode 100644 index 0000000..4cdc0ba --- /dev/null +++ b/sticker-studio/styles.css @@ -0,0 +1,671 @@ +:root { + --paper: #f6efe4; + --paper-strong: #efe1cd; + --ink: #2d2924; + --ink-soft: #665d53; + --accent: #bc6f56; + --accent-dark: #8c4f3e; + --leaf: #6c926a; + --shadow: 0 24px 60px rgba(96, 72, 46, 0.16); + --line: rgba(92, 71, 46, 0.16); + --card: rgba(255, 250, 245, 0.82); +} + +* { + box-sizing: border-box; +} + +html, +body { + margin: 0; + min-height: 100%; + color: var(--ink); + background: + radial-gradient(circle at top left, rgba(241, 205, 171, 0.5), transparent 28%), + radial-gradient(circle at right 22%, rgba(198, 220, 192, 0.38), transparent 26%), + linear-gradient(180deg, #fbf6ef 0%, #f1e6d7 100%); + font-family: "Trebuchet MS", "Gill Sans", sans-serif; +} + +body { + padding: 18px; +} + +button, +input { + font: inherit; +} + +button { + cursor: pointer; +} + +.shell { + width: min(1160px, 100%); + margin: 0 auto; + display: grid; + gap: 18px; + animation: lift-in 420ms ease; +} + +.hero-card, +.rail-card, +.board-card, +.info-card, +.mini-card, +.daily-panel, +.active-well, +.tray-panel, +.sheet-card, +.message-card, +.modal-card { + background: var(--card); + border: 1px solid rgba(128, 102, 73, 0.16); + box-shadow: var(--shadow); + backdrop-filter: blur(12px); +} + +.hero-card, +.rail-card, +.board-card, +.info-card { + border-radius: 28px; +} + +.hero-card { + display: grid; + gap: 18px; + grid-template-columns: minmax(0, 1.5fr) minmax(260px, 0.8fr); + padding: 24px 26px; +} + +.hero-side, +.info-card, +.mini-card, +.active-well, +.daily-panel { + display: grid; + gap: 14px; +} + +.mini-card { + border-radius: 22px; + padding: 18px; +} + +.rail-card { + padding: 18px 20px; +} + +.board-card { + padding: 18px; + display: grid; + gap: 16px; +} + +.info-card { + padding: 18px; +} + +.play-layout { + display: grid; + gap: 18px; + grid-template-columns: minmax(0, 1.25fr) minmax(300px, 0.75fr); + align-items: start; +} + +.eyebrow { + margin: 0 0 8px; + font-size: 0.72rem; + text-transform: uppercase; + letter-spacing: 0.16em; + color: var(--accent-dark); +} + +h1, +h2, +h3 { + margin: 0; + font-family: Baskerville, Georgia, serif; + line-height: 1.05; +} + +h1 { + font-size: clamp(2rem, 4vw, 3.35rem); + max-width: 12ch; +} + +h2 { + font-size: clamp(1.55rem, 2vw, 2.2rem); +} + +h3 { + font-size: 1.15rem; +} + +p { + margin: 0; + color: var(--ink-soft); + line-height: 1.55; +} + +.meta-copy { + font-size: 0.94rem; +} + +.meter { + width: 100%; + height: 12px; + border-radius: 999px; + background: rgba(114, 87, 60, 0.12); + overflow: hidden; + margin-top: 12px; +} + +.meter span { + display: block; + height: 100%; + border-radius: inherit; + background: linear-gradient(90deg, #c96d58 0%, #e0b36c 52%, #7fa078 100%); +} + +.rail-card__top, +.tray-panel__top, +.hud-row { + display: flex; + justify-content: space-between; + gap: 16px; + align-items: start; +} + +.rail-grid { + display: grid; + gap: 10px; + grid-template-columns: repeat(12, minmax(0, 1fr)); + margin-top: 16px; +} + +.daily-panel { + border-radius: 24px; + padding: 18px; + margin-top: 16px; + grid-template-columns: minmax(0, 1fr) auto auto; + align-items: end; +} + +.daily-panel.is-locked { + opacity: 0.74; +} + +.daily-seed { + display: grid; + gap: 6px; + font-size: 0.92rem; + color: var(--ink-soft); +} + +.daily-seed input { + padding: 10px 12px; + border-radius: 14px; + border: 1px solid rgba(111, 85, 58, 0.18); + background: rgba(255, 252, 248, 0.88); +} + +.rail-chip, +.ghost-button, +.primary-button { + border: 0; + border-radius: 999px; + transition: transform 160ms ease, box-shadow 160ms ease, background-color 160ms ease; +} + +.rail-chip { + min-height: 44px; + background: rgba(255, 251, 247, 0.84); + border: 1px solid rgba(98, 74, 48, 0.14); + color: var(--ink); + font-weight: 700; +} + +.rail-chip.is-active, +.rail-chip:hover, +.ghost-button:hover, +.primary-button:hover, +.sheet-card:hover, +.target-node:hover { + transform: translateY(-1px); +} + +.rail-chip.is-cleared { + box-shadow: inset 0 0 0 1px rgba(107, 150, 110, 0.36); +} + +.rail-chip.is-locked { + opacity: 0.45; +} + +.ghost-button, +.primary-button { + padding: 12px 18px; + font-weight: 700; +} + +.ghost-button { + background: rgba(255, 251, 247, 0.86); + border: 1px solid rgba(98, 74, 48, 0.14); + color: var(--ink); +} + +.primary-button { + color: white; + background: linear-gradient(135deg, #c97059, #af5847); + box-shadow: 0 14px 28px rgba(175, 88, 71, 0.24); +} + +.page-frame { + position: relative; + min-height: min(54vh, 620px); + border-radius: 30px; + overflow: hidden; + border: 1px solid rgba(94, 74, 51, 0.16); + background: linear-gradient(180deg, rgba(255, 251, 247, 0.96), rgba(240, 229, 214, 0.94)); +} + +.page-art { + position: absolute; + inset: 0; + background: + radial-gradient(circle at 18% 18%, rgba(255, 255, 255, 0.6), transparent 18%), + radial-gradient(circle at 78% 22%, rgba(255, 255, 255, 0.5), transparent 20%), + linear-gradient(145deg, var(--tone-a), var(--tone-b) 54%, var(--tone-c)); +} + +.page-art__wash { + position: absolute; + inset: 8% 12%; + border-radius: 32px; + background: + linear-gradient(135deg, rgba(255, 255, 255, 0.54), rgba(255, 255, 255, 0.08)), + rgba(255, 249, 242, 0.28); + border: 1px solid rgba(255, 255, 255, 0.24); + transform: rotate(-2deg); +} + +.target-node { + position: absolute; + padding: 0; + background: none; + border: 0; + min-width: 56px; + min-height: 56px; + display: block; + text-align: left; +} + +.target-node__outline { + position: absolute; + inset: 0; + border-radius: 18px; + border: 2px dashed rgba(87, 69, 48, 0.42); + background: rgba(255, 250, 246, 0.42); +} + +.target-node.is-open .target-node__outline { + border-style: solid; + box-shadow: inset 0 0 0 2px rgba(255, 255, 255, 0.28); +} + +.target-node.is-filled .target-node__outline { + background: var(--tone); + border-color: var(--trim); + transform: scale(0.98); +} + +.target-node.is-match .target-node__outline, +.target-node.is-hinted .target-node__outline { + animation: target-pulse 960ms ease-in-out infinite; +} + +.target-node.is-dimmed { + opacity: 0.72; +} + +.target-node__label, +.target-node__variant, +.target-node__name { + position: absolute; + z-index: 1; +} + +.target-node__label { + top: 10px; + left: 12px; + font-weight: 800; + letter-spacing: 0.06em; + color: var(--ink); +} + +.target-node__variant { + top: 10px; + right: 10px; + display: inline-flex; + min-width: 22px; + min-height: 22px; + border-radius: 999px; + align-items: center; + justify-content: center; + font-size: 0.72rem; + font-weight: 800; + background: rgba(255, 255, 255, 0.8); + color: var(--accent-dark); +} + +.target-node__name { + left: 12px; + right: 12px; + bottom: 10px; + font-size: 0.82rem; + color: var(--ink-soft); +} + +.target-node.is-filled .target-node__name, +.target-node.is-filled .target-node__label { + color: var(--ink); +} + +.active-well { + border-radius: 24px; + padding: 16px; +} + +.active-well.is-empty { + place-items: start; +} + +.clip-rail { + display: grid; + gap: 12px; + grid-template-columns: repeat(3, minmax(0, 1fr)); +} + +.clip-slot { + min-height: 112px; + border-radius: 22px; + padding: 12px; + border: 1px dashed rgba(106, 81, 54, 0.24); + background: rgba(255, 252, 247, 0.54); + display: grid; + gap: 8px; +} + +.clip-slot.is-empty { + place-items: center; + color: rgba(99, 78, 56, 0.6); +} + +.toolbar { + display: flex; + gap: 10px; + flex-wrap: wrap; +} + +.sheet-card { + width: 100%; + border-radius: 22px; + border: 1px solid rgba(100, 76, 51, 0.16); + padding: 14px; + text-align: left; + display: grid; + gap: 10px; + position: relative; + overflow: hidden; + background: + linear-gradient(180deg, rgba(255, 255, 255, 0.34), rgba(255, 255, 255, 0)), + linear-gradient(135deg, color-mix(in srgb, var(--sheet) 88%, white), color-mix(in srgb, var(--sheet) 68%, white)); +} + +.sheet-card.is-compact { + min-height: 0; +} + +.sheet-card.is-selected, +.sheet-card.is-active { + box-shadow: inset 0 0 0 2px rgba(85, 73, 53, 0.22); +} + +.sheet-card.is-hinted { + box-shadow: inset 0 0 0 2px rgba(201, 157, 60, 0.34), 0 0 0 1px rgba(201, 157, 60, 0.22); +} + +.sheet-card__stripe { + position: absolute; + inset: 0 auto 0 0; + width: 12px; + background: var(--sheet-accent); +} + +.sheet-card__top, +.sheet-card__bottom, +.hud-meta { + display: flex; + justify-content: space-between; + gap: 10px; + align-items: baseline; +} + +.sheet-card__status, +.sheet-card__count, +.sheet-card__ghosts, +.sheet-card__detail, +.hud-meta { + color: var(--ink-soft); + font-size: 0.86rem; +} + +.sheet-card__preview { + display: flex; + align-items: center; + gap: 12px; +} + +.sheet-badge { + min-width: 68px; + min-height: 68px; + display: grid; + place-items: center; + border-radius: 20px; + background: rgba(255, 251, 247, 0.76); + border: 2px solid rgba(97, 74, 51, 0.18); + font-weight: 800; + letter-spacing: 0.06em; + position: relative; +} + +.sheet-badge small { + position: absolute; + right: 8px; + bottom: 6px; + font-size: 0.68rem; + color: var(--accent-dark); +} + +.sheet-card__target { + color: var(--ink); + font-weight: 700; +} + +.tag-row, +.reward-rail { + display: flex; + flex-wrap: wrap; + gap: 8px; +} + +.tag, +.reward-chip { + display: inline-flex; + padding: 8px 12px; + border-radius: 999px; + background: rgba(255, 252, 247, 0.76); + border: 1px solid rgba(97, 74, 51, 0.14); + font-size: 0.86rem; +} + +.callout-list { + list-style: none; + padding: 0; + margin: 0; + display: grid; + gap: 10px; +} + +.callout-row { + display: grid; + grid-template-columns: auto 1fr; + gap: 10px; + align-items: start; +} + +.callout-row__index { + min-width: 26px; + min-height: 26px; + border-radius: 999px; + display: inline-flex; + align-items: center; + justify-content: center; + background: rgba(196, 111, 86, 0.14); + color: var(--accent-dark); + font-weight: 800; +} + +.info-block { + display: grid; + gap: 10px; +} + +.message-card { + border-radius: 20px; + padding: 16px; +} + +.tray-panel { + border-radius: 24px; + padding: 16px; + display: grid; + gap: 12px; + background: rgba(255, 249, 242, 0.72); +} + +.tray-grid { + display: flex; + gap: 12px; + overflow-x: auto; + padding-bottom: 4px; + scroll-snap-type: x proximity; +} + +.tray-grid .sheet-card { + flex: 0 0 clamp(220px, 34vw, 280px); + scroll-snap-align: start; +} + +.tray-empty, +.reward-empty { + padding: 14px 0; + color: var(--ink-soft); +} + +.modal-backdrop { + position: fixed; + inset: 0; + background: rgba(42, 31, 22, 0.32); + display: grid; + place-items: center; + padding: 20px; +} + +.modal-card { + width: min(520px, 100%); + border-radius: 28px; + padding: 24px; + display: grid; + gap: 16px; +} + +.modal-actions { + display: flex; + gap: 10px; + flex-wrap: wrap; +} + +button:disabled, +input:disabled { + cursor: not-allowed; + opacity: 0.56; +} + +@keyframes lift-in { + from { + opacity: 0; + transform: translateY(12px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +@keyframes target-pulse { + 0%, + 100% { + transform: scale(1); + box-shadow: 0 0 0 rgba(198, 151, 49, 0); + } + 50% { + transform: scale(1.02); + box-shadow: 0 0 0 8px rgba(198, 151, 49, 0.12); + } +} + +@media (max-width: 920px) { + body { + padding: 12px; + } + + .hero-card, + .play-layout, + .daily-panel { + grid-template-columns: 1fr; + } + + .rail-grid { + grid-template-columns: repeat(6, minmax(0, 1fr)); + } + + .page-frame { + min-height: 54vh; + } +} + +@media (max-width: 560px) { + .hero-card, + .rail-card, + .board-card, + .info-card { + border-radius: 22px; + } + + .hud-row, + .tray-panel__top, + .rail-card__top { + flex-direction: column; + } + + .clip-rail { + grid-template-columns: repeat(3, minmax(92px, 1fr)); + } + + .rail-grid { + grid-template-columns: repeat(4, minmax(0, 1fr)); + } +} diff --git a/sticker-studio/tests/engine.test.js b/sticker-studio/tests/engine.test.js new file mode 100644 index 0000000..17b906d --- /dev/null +++ b/sticker-studio/tests/engine.test.js @@ -0,0 +1,172 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +import { HANDCRAFTED_LEVELS, buildLevelFromPlan } from "../src/data.js"; +import { + applyAction, + collectLegalActions, + createInitialState, + createRuntime, + evaluateParkAction, + findHint, + solveLevel, + undoAction, + validateLevel +} from "../src/engine.js"; +import { dailySeedFromDate, generateDailyLevel } from "../src/daily.js"; + +test("handcrafted levels follow the expected clip and target ramp", () => { + const expected = [ + [0, 6, 3], + [1, 7, 3], + [1, 7, 4], + [1, 8, 4], + [2, 8, 4], + [2, 8, 4], + [2, 8, 4], + [2, 9, 5], + [2, 9, 5], + [3, 10, 5], + [3, 10, 5], + [3, 10, 5] + ]; + + HANDCRAFTED_LEVELS.forEach((level, index) => { + assert.deepEqual( + [level.clipCount, level.page.targets.length, level.sheets.length], + expected[index], + `Unexpected authored counts for level ${level.number}` + ); + }); +}); + +test("every handcrafted level validates and solves", () => { + for (const level of HANDCRAFTED_LEVELS) { + const validation = validateLevel(level); + assert.equal(validation.ok, true, `${level.id} failed: ${validation.issues.join(" | ")}`); + assert.equal(validation.solution.ok, true, `${level.id} did not solve cleanly.`); + } +}); + +test("authored pages respect the silhouette overlap cap", () => { + for (const level of HANDCRAFTED_LEVELS) { + const overlapIssues = validateLevel(level).issues.filter((issue) => issue.includes("overlap too much")); + assert.deepEqual(overlapIssues, [], `${level.id} exceeds the overlap cap: ${overlapIssues.join(" | ")}`); + } +}); + +test("park becomes the only legal action when an active sheet reveals a blocked sticker", () => { + const level = HANDCRAFTED_LEVELS[1]; + const runtime = createRuntime(level); + const start = createInitialState(level); + const firstMove = applyAction( + level, + start, + { + type: "place", + sheetId: "A", + targetId: "t1" + }, + runtime + ); + + const legal = collectLegalActions(level, firstMove, runtime); + assert.equal(firstMove.activeSheetId, "A"); + assert.equal(legal.length, 1); + assert.equal(legal[0].type, "park"); + assert.match(firstMove.message, /waits for an open clip/i); +}); + +test("parking a sheet enables the next authored line", () => { + const level = HANDCRAFTED_LEVELS[1]; + const runtime = createRuntime(level); + const start = createInitialState(level); + const afterPlace = applyAction(level, start, { type: "place", sheetId: "A", targetId: "t1" }, runtime); + const afterPark = applyAction(level, afterPlace, { type: "park", sheetId: "A" }, runtime); + + assert.equal(afterPark.activeSheetId, null); + assert.deepEqual(afterPark.clips, ["A"]); + assert.ok(collectLegalActions(level, afterPark, runtime).some((action) => action.sheetId === "B")); +}); + +test("undo rewinds successful place and park steps", () => { + const level = HANDCRAFTED_LEVELS[4]; + const runtime = createRuntime(level); + const start = createInitialState(level); + const first = applyAction(level, start, { type: "place", sheetId: "A", targetId: "t1" }, runtime); + const parked = applyAction(level, first, { type: "park", sheetId: "A" }, runtime); + const undonePark = undoAction(parked); + const undonePlace = undoAction(undonePark); + + assert.equal(undonePark.activeSheetId, "A"); + assert.equal(undonePlace.activeSheetId, null); + assert.deepEqual(undonePlace.placedTargets, []); +}); + +test("overflow fail triggers when a required park has no free clip", () => { + const level = buildLevelFromPlan({ + id: "overflow-check", + number: 99, + packId: "kitchen-keepsakes", + title: "Overflow Check", + caption: "Fixture", + beat: "Fixture", + clipCount: 0, + mechanics: [], + callouts: [], + reward: { title: "Fixture", detail: "Fixture" }, + layout: ["hero", "center", "lowCenter"], + motifs: [ + { title: "Base", short: "BASE", paletteId: "coral" }, + { title: "Blocked", short: "BLCK", paletteId: "gold" }, + { title: "Opener", short: "OPEN", paletteId: "sage" } + ], + sheets: [ + { id: "A", name: "Fixture A", paletteId: "coral" }, + { id: "B", name: "Fixture B", paletteId: "sage" } + ], + solution: ["A", "B", "A"] + }); + const runtime = createRuntime(level); + const start = createInitialState(level); + const next = applyAction(level, start, { type: "place", sheetId: "A", targetId: "t1" }, runtime); + + assert.equal(next.failed, true); + assert.equal(next.failReason, "overflow"); + assert.match(next.message, /binder clip is full|binder clip is full|binder clips are full|needs a clip/i); +}); + +test("hint returns a legal next action", () => { + const level = HANDCRAFTED_LEVELS[9]; + const runtime = createRuntime(level); + const state = createInitialState(level); + const hint = findHint(level, state, runtime); + + assert.ok(hint.action); + assert.match(hint.message, /Hint:/); +}); + +test("daily seed uses UTC formatting and generation is deterministic", () => { + const seed = dailySeedFromDate(new Date("2026-05-04T16:20:00+08:00")); + assert.equal(seed, "2026-05-04"); + + const first = generateDailyLevel("2026-05-04"); + const second = generateDailyLevel("2026-05-04"); + assert.deepEqual(first, second); + + const validation = validateLevel(first); + assert.equal(validation.ok, true, validation.issues.join(" | ")); + assert.equal(solveLevel(first).ok, true); + assert.equal(first.clipCount, 2); + assert.ok(first.page.targets.length >= 9 && first.page.targets.length <= 10); +}); + +test("active sheet can always be parked while a clip remains open", () => { + const level = HANDCRAFTED_LEVELS[4]; + const runtime = createRuntime(level); + const start = createInitialState(level); + const first = applyAction(level, start, { type: "place", sheetId: "A", targetId: "t1" }, runtime); + const parkEvaluation = evaluateParkAction(level, first, { type: "park", sheetId: "A" }, runtime); + + assert.equal(parkEvaluation.ok, true); +}); From a8acf4ebd7444b9e3bccf0dcd4c6df4115bb66bc Mon Sep 17 00:00:00 2001 From: wuhuizuo Date: Tue, 5 May 2026 17:21:10 +0800 Subject: [PATCH 2/3] Add Sticker Studio deploy workflow --- .github/workflows/sticker-studio-preview.yml | 170 +++++++++++++++++++ sticker-studio/README.md | 12 ++ 2 files changed, 182 insertions(+) create mode 100644 .github/workflows/sticker-studio-preview.yml diff --git a/.github/workflows/sticker-studio-preview.yml b/.github/workflows/sticker-studio-preview.yml new file mode 100644 index 0000000..8305750 --- /dev/null +++ b/.github/workflows/sticker-studio-preview.yml @@ -0,0 +1,170 @@ +name: Sticker Studio Preview Deploy + +on: + pull_request: + types: + - opened + - reopened + - synchronize + - closed + paths: + - "sticker-studio/**" + - ".github/workflows/sticker-studio-preview.yml" + push: + branches: + - main + paths: + - "sticker-studio/**" + - ".github/workflows/sticker-studio-preview.yml" + +permissions: + contents: write + pull-requests: write + +concurrency: + group: sticker-studio-preview-${{ github.event.pull_request.number || github.ref_name }} + cancel-in-progress: true + +jobs: + deploy-preview: + if: github.event_name == 'pull_request' && github.event.action != 'closed' && github.event.pull_request.head.repo.full_name == github.repository + runs-on: ubuntu-latest + env: + PREVIEW_DIR: previews/sticker-studio/pr-${{ github.event.pull_request.number }} + PREVIEW_URL: https://${{ github.repository_owner }}.github.io/${{ github.event.repository.name }}/previews/sticker-studio/pr-${{ github.event.pull_request.number }}/ + steps: + - name: Check out repository + uses: actions/checkout@v4 + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: 22 + + - name: Verify Sticker Studio + working-directory: sticker-studio + run: npm run verify + + - name: Deploy preview to gh-pages + uses: peaceiris/actions-gh-pages@v4 + with: + github_token: ${{ secrets.GITHUB_TOKEN }} + publish_branch: gh-pages + publish_dir: ./sticker-studio + destination_dir: ${{ env.PREVIEW_DIR }} + keep_files: true + enable_jekyll: false + + - name: Comment preview URL on pull request + uses: actions/github-script@v7 + env: + PREVIEW_DIR: ${{ env.PREVIEW_DIR }} + PREVIEW_URL: ${{ env.PREVIEW_URL }} + with: + script: | + const marker = ""; + const body = `${marker} + Sticker Studio preview deployed. + + URL: ${process.env.PREVIEW_URL} + Commit: ${context.sha.slice(0, 7)} + Path: \`${process.env.PREVIEW_DIR}/\` + + If this is the first deployment, enable GitHub Pages once in repository settings and point it at the \`gh-pages\` branch.`; + + const { data: comments } = await github.rest.issues.listComments({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number + }); + + const existing = comments.find( + (comment) => comment.user.type === "Bot" && comment.body.includes(marker) + ); + + if (existing) { + await github.rest.issues.updateComment({ + owner: context.repo.owner, + repo: context.repo.repo, + comment_id: existing.id, + body + }); + } else { + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number, + body + }); + } + + cleanup-preview: + if: github.event_name == 'pull_request' && github.event.action == 'closed' && github.event.pull_request.head.repo.full_name == github.repository + runs-on: ubuntu-latest + env: + PREVIEW_DIR: previews/sticker-studio/pr-${{ github.event.pull_request.number }} + steps: + - name: Check out repository + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Remove preview directory from gh-pages + run: | + if ! git ls-remote --exit-code --heads origin gh-pages >/dev/null 2>&1; then + echo "gh-pages branch does not exist yet." + exit 0 + fi + + git fetch origin gh-pages:gh-pages + git switch gh-pages + + if [ ! -d "${PREVIEW_DIR}" ]; then + echo "Preview directory already removed." + exit 0 + fi + + rm -rf "${PREVIEW_DIR}" + + if [ -z "$(git status --short)" ]; then + echo "No cleanup changes to commit." + exit 0 + fi + + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git add -A + git commit -m "Remove Sticker Studio preview for PR #${{ github.event.pull_request.number }}" + git push origin gh-pages + + deploy-stable: + if: github.event_name == 'push' && github.ref == 'refs/heads/main' + runs-on: ubuntu-latest + env: + STABLE_DIR: sticker-studio + STABLE_URL: https://${{ github.repository_owner }}.github.io/${{ github.event.repository.name }}/sticker-studio/ + steps: + - name: Check out repository + uses: actions/checkout@v4 + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: 22 + + - name: Verify Sticker Studio + working-directory: sticker-studio + run: npm run verify + + - name: Deploy stable build to gh-pages + uses: peaceiris/actions-gh-pages@v4 + with: + github_token: ${{ secrets.GITHUB_TOKEN }} + publish_branch: gh-pages + publish_dir: ./sticker-studio + destination_dir: ${{ env.STABLE_DIR }} + keep_files: true + enable_jekyll: false + + - name: Print stable URL + run: echo "Stable Sticker Studio build deployed to ${STABLE_URL}" diff --git a/sticker-studio/README.md b/sticker-studio/README.md index 1b95ab0..eeafd81 100644 --- a/sticker-studio/README.md +++ b/sticker-studio/README.md @@ -18,6 +18,18 @@ npm test npm run verify ``` +## Deployment + +- Pull requests that change `sticker-studio/**` or `.github/workflows/sticker-studio-preview.yml` publish a preview build to `https://.github.io//previews/sticker-studio/pr-/`. +- Pushes to `main` that change the same paths publish the stable build to `https://.github.io//sticker-studio/`. +- Both deploy jobs verify the slice first with `npm run verify`. + +## Rollback And First Checks + +- Preview cleanup is automatic when the pull request closes; the workflow removes `previews/sticker-studio/pr-/` from `gh-pages`. +- Stable rollback is a normal git revert on `main`; the next stable deploy republishes the reverted `sticker-studio/` contents to `gh-pages`. +- First post-deploy QA should cover a narrow-phone viewport pass, daily unlock after level `6`, persistence across reloads, and the UTC date rollover for the daily page. + ## What Is Included - Data-driven ordered sticker-sheet gameplay with tray sheets, active partial sheets, binder clips, undo, hint, restart, fail, win, and daily-page entry From 03b7e6d64698ffab6c47658325ddc6d2ee6f29ae Mon Sep 17 00:00:00 2001 From: wuhuizuo Date: Tue, 5 May 2026 17:23:24 +0800 Subject: [PATCH 3/3] Fix preview comment SHA --- .github/workflows/sticker-studio-preview.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/sticker-studio-preview.yml b/.github/workflows/sticker-studio-preview.yml index 8305750..15edeef 100644 --- a/.github/workflows/sticker-studio-preview.yml +++ b/.github/workflows/sticker-studio-preview.yml @@ -63,11 +63,12 @@ jobs: with: script: | const marker = ""; + const headSha = context.payload.pull_request.head.sha.slice(0, 7); const body = `${marker} Sticker Studio preview deployed. URL: ${process.env.PREVIEW_URL} - Commit: ${context.sha.slice(0, 7)} + Commit: ${headSha} Path: \`${process.env.PREVIEW_DIR}/\` If this is the first deployment, enable GitHub Pages once in repository settings and point it at the \`gh-pages\` branch.`;