diff --git a/.github/workflows/bento-board-preview.yml b/.github/workflows/bento-board-preview.yml new file mode 100644 index 0000000..6d72f32 --- /dev/null +++ b/.github/workflows/bento-board-preview.yml @@ -0,0 +1,171 @@ +name: Bento Board Preview Deploy + +on: + pull_request: + types: + - opened + - reopened + - synchronize + - closed + paths: + - "bento-board/**" + - ".github/workflows/bento-board-preview.yml" + push: + branches: + - main + paths: + - "bento-board/**" + - ".github/workflows/bento-board-preview.yml" + +permissions: + contents: write + pull-requests: write + +concurrency: + group: bento-board-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/bento-board/pr-${{ github.event.pull_request.number }} + PREVIEW_URL: https://${{ github.repository_owner }}.github.io/${{ github.event.repository.name }}/previews/bento-board/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 Bento Board + working-directory: bento-board + 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: ./bento-board + 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 headSha = context.payload.pull_request.head.sha.slice(0, 7); + const body = `${marker} + Bento Board preview deployed. + + URL: ${process.env.PREVIEW_URL} + 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.`; + + 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/bento-board/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 Bento Board 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: bento-board + STABLE_URL: https://${{ github.repository_owner }}.github.io/${{ github.event.repository.name }}/bento-board/ + 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 Bento Board + working-directory: bento-board + 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: ./bento-board + destination_dir: ${{ env.STABLE_DIR }} + keep_files: true + enable_jekyll: false + + - name: Print stable URL + run: echo "Stable Bento Board build deployed to ${STABLE_URL}" diff --git a/bento-board/README.md b/bento-board/README.md new file mode 100644 index 0000000..2718513 --- /dev/null +++ b/bento-board/README.md @@ -0,0 +1,45 @@ +# Bento Board + +Standalone first-playable vertical slice for the Bento Board puzzle brief. + +## 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 +``` + +## Deployment + +- Pull requests that change `bento-board/**` or `.github/workflows/bento-board-preview.yml` publish a preview build to `https://.github.io//previews/bento-board/pr-/`. +- Pushes to `main` that change the same paths publish the stable build to `https://.github.io//bento-board/`. +- 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/bento-board/pr-/` from `gh-pages`. +- Stable rollback is a normal git revert on `main`; the next stable deploy republishes the reverted `bento-board/` contents to `gh-pages`. +- First post-deploy QA should cover a narrow-phone viewport pass, daily unlock after level `6`, UTC date rollover for the daily special, and jam / overflow recovery through `Undo`, `Restart`, and `Hint`. + +## Included + +- Data-driven menu packs, handcrafted levels, tutorial callouts, rewards, and daily special generation +- Fastener-first input only: picks, bands, and dividers trigger auto-slide resolution +- Undo, hint, restart, fail, win, and a simple journal wrapper +- Portrait mobile-first UI that keeps the lunchbox, recipe ribbon, and stack field readable on a phone viewport + +## Implementation Notes + +- Readability gets fragile once multiple fasteners crowd the same lane, so the authoring validator keeps the silhouette count and early exposed moves conservative. +- Fastener hit targets are separated by type and offset in the UI, but any future denser layouts should keep the 56px / 60px / 64px target sizes intact. +- The solver is intentionally lightweight and monotonic; if later content adds branching destinations, moving blockers, or non-monotonic stack rules, the validator will need a deeper search model. +- Overflow is supported by the engine, but the handcrafted campaign stays mostly exact-count so the slice remains readable and solvable without hidden routing. diff --git a/bento-board/index.html b/bento-board/index.html new file mode 100644 index 0000000..9742f56 --- /dev/null +++ b/bento-board/index.html @@ -0,0 +1,13 @@ + + + + + + Bento Board + + + +
+ + + diff --git a/bento-board/package.json b/bento-board/package.json new file mode 100644 index 0000000..66eaf83 --- /dev/null +++ b/bento-board/package.json @@ -0,0 +1,11 @@ +{ + "name": "bento-board", + "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/bento-board/src/app.js b/bento-board/src/app.js new file mode 100644 index 0000000..28b0d64 --- /dev/null +++ b/bento-board/src/app.js @@ -0,0 +1,824 @@ +import { dailySeedFromDate, generateDailyLevel, getUnlockedDailyPool } from "./daily.js"; +import { + HANDCRAFTED_LEVELS, + MENU_PACKS, + REWARD_DEFINITIONS, + getPackById, + getRewardById +} from "./data.js"; +import { + applyFastener, + buildBoardView, + createInitialState, + createRuntime, + findHint, + getCompletionRatio, + restartLevel, + undoAction +} from "./engine.js"; + +const STORAGE_KEY = "bento-board-progress-v1"; +const root = document.querySelector("#app"); + +const appState = { + view: "home", + mode: "campaign", + levelIndex: 0, + dailySeed: dailySeedFromDate(), + progress: loadProgress(), + level: null, + runtime: null, + puzzle: null, + boardView: null, + modal: null +}; + +function loadProgress() { + try { + const parsed = JSON.parse(localStorage.getItem(STORAGE_KEY) ?? "{}"); + return { + clearedLevels: Array.isArray(parsed.clearedLevels) ? parsed.clearedLevels : [], + clearedDailySeeds: Array.isArray(parsed.clearedDailySeeds) ? parsed.clearedDailySeeds : [], + unlockedRewards: Array.isArray(parsed.unlockedRewards) ? parsed.unlockedRewards : [] + }; + } catch { + return { + clearedLevels: [], + clearedDailySeeds: [], + unlockedRewards: [] + }; + } +} + +function saveProgress() { + localStorage.setItem(STORAGE_KEY, JSON.stringify(appState.progress)); +} + +function escapeHtml(value) { + return String(value) + .replaceAll("&", "&") + .replaceAll("<", "<") + .replaceAll(">", ">") + .replaceAll('"', """) + .replaceAll("'", "'"); +} + +function currentReward() { + if (!appState.level) { + return null; + } + return getRewardById(appState.level.rewardId ?? "reward-daily"); +} + +function highestClearedLevelNumber() { + const clearedNumbers = HANDCRAFTED_LEVELS.filter((level) => + appState.progress.clearedLevels.includes(level.id) + ).map((level) => level.number); + + return clearedNumbers.length ? Math.max(...clearedNumbers) : 0; +} + +function isLevelCleared(level) { + return appState.progress.clearedLevels.includes(level.id); +} + +function isDailyCleared(seed = appState.dailySeed) { + return appState.progress.clearedDailySeeds.includes(seed); +} + +function isLevelUnlocked(index) { + if (index <= 0) { + return true; + } + return appState.progress.clearedLevels.includes(HANDCRAFTED_LEVELS[index - 1].id); +} + +function isDailyUnlocked() { + return highestClearedLevelNumber() >= 6; +} + +function unlockedRewardCount() { + return appState.progress.unlockedRewards.length; +} + +function packProgress(packId) { + const packLevels = HANDCRAFTED_LEVELS.filter((level) => level.packId === packId); + const cleared = packLevels.filter((level) => isLevelCleared(level)).length; + return { + cleared, + total: packLevels.length, + percent: Math.round((cleared / packLevels.length) * 100) + }; +} + +function refreshBoard() { + if (!appState.level || !appState.puzzle || !appState.runtime) { + appState.boardView = null; + return; + } + appState.boardView = buildBoardView(appState.level, appState.puzzle, appState.runtime); +} + +function updateTitle() { + if (appState.view === "home") { + document.title = "Bento Board"; + return; + } + const suffix = + appState.level.kind === "daily" + ? `Daily ${appState.level.seed}` + : `${getPackById(appState.level.packId).title} • Level ${appState.level.number}`; + document.title = `Bento Board • ${suffix}`; +} + +function openHome() { + appState.view = "home"; + appState.modal = null; + refreshBoard(); + updateTitle(); + render(); +} + +function recordWin(level) { + if (level.kind === "daily") { + if (!appState.progress.clearedDailySeeds.includes(level.seed)) { + appState.progress.clearedDailySeeds.push(level.seed); + } + } else if (!appState.progress.clearedLevels.includes(level.id)) { + appState.progress.clearedLevels.push(level.id); + } + + if (level.rewardId && !appState.progress.unlockedRewards.includes(level.rewardId)) { + appState.progress.unlockedRewards.push(level.rewardId); + } + saveProgress(); +} + +function getUnlockedPool() { + return getUnlockedDailyPool(highestClearedLevelNumber()); +} + +function loadLevelByIndex(levelIndex, { showStart = true } = {}) { + const boundedIndex = Math.min(Math.max(levelIndex, 0), HANDCRAFTED_LEVELS.length - 1); + const level = HANDCRAFTED_LEVELS[boundedIndex]; + appState.view = "play"; + appState.mode = "campaign"; + appState.levelIndex = boundedIndex; + appState.level = level; + appState.runtime = createRuntime(level); + appState.puzzle = createInitialState(level); + appState.modal = showStart ? "start" : null; + refreshBoard(); + updateTitle(); + render(); +} + +function loadDaily({ seed = appState.dailySeed, showStart = true } = {}) { + appState.view = "play"; + appState.mode = "daily"; + appState.dailySeed = seed; + appState.level = generateDailyLevel(seed, { unlockedIngredientIds: getUnlockedPool() }); + appState.runtime = createRuntime(appState.level); + appState.puzzle = createInitialState(appState.level); + appState.modal = showStart ? "start" : null; + refreshBoard(); + updateTitle(); + render(); +} + +function applyPuzzle(nextPuzzle) { + appState.puzzle = nextPuzzle; + + if (nextPuzzle.completed) { + recordWin(appState.level); + appState.modal = "win"; + } else if (nextPuzzle.failed) { + appState.modal = "fail"; + } + + refreshBoard(); + render(); +} + +function handleFastener(fastenerId) { + if (!appState.level || !appState.puzzle || appState.modal === "start") { + return; + } + const nextPuzzle = applyFastener(appState.level, appState.puzzle, fastenerId, appState.runtime); + applyPuzzle(nextPuzzle); +} + +function handleUndo() { + if (!appState.puzzle) { + return; + } + appState.modal = null; + appState.puzzle = undoAction(appState.puzzle); + refreshBoard(); + render(); +} + +function handleRestart() { + if (!appState.level) { + return; + } + appState.modal = null; + appState.puzzle = restartLevel(appState.level); + refreshBoard(); + render(); +} + +function handleHint() { + if (!appState.level || !appState.puzzle || appState.puzzle.failed || appState.puzzle.completed) { + return; + } + + const hint = findHint(appState.level, appState.puzzle, appState.runtime); + appState.puzzle = { + ...appState.puzzle, + hintAction: hint.action, + feedback: null, + message: hint.message + }; + refreshBoard(); + render(); +} + +function openNextOrder() { + if (!appState.level) { + return; + } + + if (appState.level.kind === "daily") { + const nextDate = new Date(`${appState.level.seed}T00:00:00Z`); + nextDate.setUTCDate(nextDate.getUTCDate() + 1); + loadDaily({ seed: dailySeedFromDate(nextDate), showStart: true }); + return; + } + + if (appState.levelIndex < HANDCRAFTED_LEVELS.length - 1) { + loadLevelByIndex(appState.levelIndex + 1, { showStart: true }); + return; + } + + if (isDailyUnlocked()) { + loadDaily({ seed: dailySeedFromDate(), showStart: true }); + return; + } + + openHome(); +} + +function currentPack() { + if (!appState.level || appState.level.kind === "daily") { + return null; + } + return getPackById(appState.level.packId); +} + +function levelChipText(level) { + return level.kind === "daily" ? `Daily • ${level.seed}` : `Level ${level.number}`; +} + +function packLevelProgress(level) { + if (level.kind === "daily") { + return []; + } + const pack = getPackById(level.packId); + return pack.levelNumbers.map((number) => { + const target = HANDCRAFTED_LEVELS.find((entry) => entry.number === number); + return { + number, + current: target.id === level.id, + cleared: isLevelCleared(target) + }; + }); +} + +function activeTutorialStep() { + if (!appState.level || !appState.puzzle) { + return null; + } + + for (const step of appState.level.tutorialSteps ?? []) { + if (step.trigger === "start" && appState.puzzle.history.length === 0) { + return step; + } + if (step.trigger.startsWith("after:")) { + const fastenerId = step.trigger.slice("after:".length); + if (appState.puzzle.lastResolution?.fastenerId === fastenerId) { + return step; + } + } + } + + return null; +} + +function ingredientPips(compartment) { + return compartment.acceptedSlots + .map((ingredientTypeId, index) => { + const filled = Boolean(compartment.filledSlots[index]); + return ``; + }) + .join(""); +} + +function feedbackBubble(fastenerId) { + if (!appState.puzzle?.feedback) { + return ""; + } + + if (appState.puzzle.feedback.fastenerId !== fastenerId || appState.puzzle.feedback.outcome === "valid") { + return ""; + } + + return `
${escapeHtml(appState.puzzle.feedback.message)}
`; +} + +function renderFastener(fastener) { + const classes = [ + "fastener", + `fastener--${fastener.type}`, + `is-${fastener.status}`, + fastener.highlighted ? "is-highlighted" : "" + ] + .filter(Boolean) + .join(" "); + + return ` + + `; +} + +function renderLayer(layer) { + return ` +
+
${escapeHtml(layer.ingredient.shortLabel)}
+
+ ${escapeHtml(layer.ingredient.family)} + ${escapeHtml(layer.artVariant)} +
+
${layer.remainingBlockers.length} blocker${layer.remainingBlockers.length === 1 ? "" : "s"}
+
+ `; +} + +function renderStack(stack) { + return ` +
+
+ ${escapeHtml(stack.title)} + ${stack.compressedCount > 0 ? `+${stack.compressedCount} tucked` : `${stack.visibleLayers.length} visible`} +
+
+ ${stack.frontDividerId ? `
Divider Ready
` : ""} + ${stack.visibleLayers.map(renderLayer).join("")} + ${stack.fasteners.map(renderFastener).join("")} + ${stack.compressedCount > 0 ? `
+${stack.compressedCount}
` : ""} +
+
+ `; +} + +function renderCompartment(compartment) { + return ` +
+
+ ${escapeHtml(compartment.label)} + ${compartment.remainingCount} left +
+
+ ${compartment.acceptedSlots + .map((ingredientTypeId, index) => { + const filled = compartment.filledSlots[index]; + return ` +
+ ${escapeHtml(ingredientTypeId.slice(0, 2).toUpperCase())} +
+ `; + }) + .join("")} +
+
+ `; +} + +function renderRecipeRibbon() { + return ` +
+ ${appState.boardView.compartments + .map( + (compartment) => ` +
+
+ ${escapeHtml(compartment.label)} + ${compartment.remainingCount}/${compartment.acceptedSlots.length} +
+
${ingredientPips(compartment)}
+
+ ` + ) + .join("")} +
+ `; +} + +function renderHud() { + const pack = currentPack(); + const progressPips = pack + ? packLevelProgress(appState.level) + .map( + (entry) => ` + + ${entry.number} + + ` + ) + .join("") + : `DAILY`; + + const progressPercent = Math.round(getCompletionRatio(appState.level, appState.puzzle) * 100); + + return ` +
+
+ +
+ ${escapeHtml(pack?.title ?? "Daily Special")} +

${escapeHtml(appState.level.title)}

+
+
+
+
+ ${escapeHtml(levelChipText(appState.level))} + ${progressPercent}% packed +
+
${progressPips}
+
+
+ + + +
+
+ `; +} + +function renderTutorial() { + const tutorial = activeTutorialStep(); + const copy = tutorial?.copy ?? appState.level.beat; + return ` +
+ ${tutorial ? "Tutorial" : "Beat"} +

${escapeHtml(copy)}

+
+ `; +} + +function renderPlayModal() { + if (!appState.modal || !appState.level) { + return ""; + } + + const reward = currentReward(); + if (appState.modal === "start") { + return ` + + `; + } + + if (appState.modal === "fail") { + const title = appState.puzzle.failKind === "overflow" ? "Lunch Overflow" : "Lunch Jammed"; + return ` + + `; + } + + return ` + + `; +} + +function renderPlayView() { + const themeAccent = + appState.level.kind === "daily" + ? appState.level.theme.accent + : currentPack()?.accent ?? "#cc714b"; + + return ` +
+ ${renderHud()} + ${renderRecipeRibbon()} +
+ Kitchen Note +

${escapeHtml(appState.puzzle.message)}

+
+ ${renderTutorial()} +
+ ${appState.boardView.stacks.map(renderStack).join("")} +
+
+
+
+ Lunchbox +

Compartment Tray

+
+ ${appState.boardView.readyFasteners.length} exposed fastener${appState.boardView.readyFasteners.length === 1 ? "" : "s"} +
+
+ ${appState.boardView.compartments.map(renderCompartment).join("")} +
+
+ ${renderPlayModal()} +
+ `; +} + +function renderPackCard(pack) { + const progress = packProgress(pack.id); + const levelButtons = pack.levelNumbers + .map((number) => { + const level = HANDCRAFTED_LEVELS.find((entry) => entry.number === number); + const index = HANDCRAFTED_LEVELS.findIndex((entry) => entry.id === level.id); + const unlocked = isLevelUnlocked(index); + return ` + + `; + }) + .join(""); + + return ` + + `; +} + +function renderDailyCard() { + const unlocked = isDailyUnlocked(); + const pool = getUnlockedPool(); + return ` + + `; +} + +function renderJournal() { + const unlocked = REWARD_DEFINITIONS.filter((reward) => + appState.progress.unlockedRewards.includes(reward.id) + ); + + return ` +
+
+
+ Lunch Journal +

${unlockedRewardCount()} rewards collected

+
+ +
+
+ ${unlocked.length + ? unlocked + .slice(-6) + .map( + (reward) => ` +
+ ${escapeHtml(reward.title)} + ${escapeHtml(reward.copy)} +
+ ` + ) + .join("") + : '
First stamp waits on level 1.Clear an order to start the journal.
'} +
+
+ `; +} + +function latestUnlockedLevelIndex() { + for (let index = HANDCRAFTED_LEVELS.length - 1; index >= 0; index -= 1) { + if (isLevelUnlocked(index)) { + return index; + } + } + return 0; +} + +function renderHomeView() { + return ` +
+
+
+ Mobile First Prototype +

Bento Board

+

+ Remove lunch picks, cloth bands, and paper dividers so layered ingredients slide into the right lunchbox compartments. +

+
+
+
${appState.progress.clearedLevels.length}main clears
+
${appState.progress.clearedDailySeeds.length}daily seals
+
${unlockedRewardCount()}journal rewards
+
+
+ + ${renderJournal()} +
+ `; +} + +function render() { + if (appState.view === "play" && appState.level && appState.puzzle) { + root.innerHTML = renderPlayView(); + } else { + root.innerHTML = renderHomeView(); + } +} + +function continueLatest() { + if (isDailyUnlocked() && !appState.progress.clearedLevels.length) { + loadLevelByIndex(0, { showStart: true }); + return; + } + loadLevelByIndex(latestUnlockedLevelIndex(), { showStart: true }); +} + +root.addEventListener("click", (event) => { + const button = event.target.closest("[data-action]"); + if (!button) { + return; + } + + const { action } = button.dataset; + + if (action === "fastener") { + handleFastener(button.dataset.fastenerId); + return; + } + + if (action === "open-level") { + loadLevelByIndex(Number(button.dataset.levelIndex), { showStart: true }); + return; + } + + if (action === "open-daily") { + if (isDailyUnlocked()) { + loadDaily({ seed: dailySeedFromDate(), showStart: true }); + } + return; + } + + if (action === "home") { + openHome(); + return; + } + + if (action === "close-modal") { + appState.modal = null; + render(); + return; + } + + if (action === "undo") { + handleUndo(); + return; + } + + if (action === "hint") { + appState.modal = null; + handleHint(); + return; + } + + if (action === "restart") { + handleRestart(); + return; + } + + if (action === "next-order") { + openNextOrder(); + return; + } + + if (action === "replay") { + if (appState.level.kind === "daily") { + loadDaily({ seed: appState.level.seed, showStart: false }); + } else { + loadLevelByIndex(appState.levelIndex, { showStart: false }); + } + return; + } + + if (action === "continue") { + continueLatest(); + } +}); + +openHome(); diff --git a/bento-board/src/daily.js b/bento-board/src/daily.js new file mode 100644 index 0000000..8b01679 --- /dev/null +++ b/bento-board/src/daily.js @@ -0,0 +1,138 @@ +import { DAILY_TEMPLATE_DEFINITIONS, INGREDIENT_TYPES, MENU_PACKS } from "./data.js"; +import { validateLevel } from "./engine.js"; + +const clone = (value) => JSON.parse(JSON.stringify(value)); + +const DAILY_CARD_THEMES = [ + { + id: "sunset-lacquer", + accent: "#c96f4e", + gradient: ["#f3e0c8", "#d18c64", "#925240"] + }, + { + id: "market-leaf", + accent: "#678564", + gradient: ["#efe4d0", "#95aa6d", "#5f7859"] + }, + { + id: "foil-seal", + accent: "#9f6f53", + gradient: ["#f2e6d3", "#d9b56f", "#8d6a54"] + } +]; + +function hashSeed(seed) { + let hash = 2166136261; + for (let index = 0; index < seed.length; index += 1) { + hash ^= seed.charCodeAt(index); + hash = Math.imul(hash, 16777619); + } + return hash >>> 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; +} + +function pickTheme(random) { + return clone(DAILY_CARD_THEMES[Math.floor(random() * DAILY_CARD_THEMES.length)]); +} + +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 getUnlockedDailyPool(highestClearedLevelNumber = 0) { + if (highestClearedLevelNumber >= 12) { + return [...MENU_PACKS[1].ingredientPool]; + } + return [...MENU_PACKS[0].ingredientPool]; +} + +function labelForIngredient(ingredientId) { + return INGREDIENT_TYPES[ingredientId]?.family ?? ingredientId; +} + +function instantiateTemplate(template, ingredientIds, seed) { + const tokenMap = Object.fromEntries(template.tokenOrder.map((token, index) => [token, ingredientIds[index]])); + const mappedCompartments = template.compartments.map((compartment) => { + const ingredientId = tokenMap[compartment.acceptedSlots[0]]; + return { + ...clone(compartment), + label: `${labelForIngredient(ingredientId)} Bay`, + acceptedSlots: compartment.acceptedSlots.map((token) => tokenMap[token]) + }; + }); + + const mappedLayers = template.layers.map((layer) => ({ + ...clone(layer), + ingredientTypeId: tokenMap[layer.ingredientTypeId] + })); + + return { + id: `daily-${seed}-${template.id}`, + kind: "daily", + seed, + templateId: template.id, + title: `Daily Special • ${seed}`, + beat: "Daily specials keep the same fastener-first rules, but the foil seal only appears once per UTC date.", + mechanics: ["Daily special", "Seeded template"], + rewardId: "reward-daily", + journalTitle: "Foil Seal", + journalCaption: "A daily lunch order built from a deterministic UTC seed.", + compartments: mappedCompartments, + stacks: clone(template.stacks), + layers: mappedLayers, + fasteners: clone(template.fasteners), + tutorialSteps: [], + theme: pickTheme(mulberry32(hashSeed(seed + template.id))) + }; +} + +export function generateDailyLevel( + seed = dailySeedFromDate(), + { unlockedIngredientIds = getUnlockedDailyPool(6) } = {} +) { + const baseSeed = hashSeed(seed); + const random = mulberry32(baseSeed); + const templateOrder = shuffle(DAILY_TEMPLATE_DEFINITIONS, random); + const pool = unlockedIngredientIds.filter((ingredientId) => Boolean(INGREDIENT_TYPES[ingredientId])); + + if (pool.length < 4) { + throw new Error("Daily generation requires at least four unlocked ingredients."); + } + + for (let attempt = 0; attempt < templateOrder.length * 6; attempt += 1) { + const template = templateOrder[attempt % templateOrder.length]; + const attemptRandom = mulberry32(baseSeed + attempt * 97); + const ingredientIds = shuffle(pool, attemptRandom).slice(0, template.tokenOrder.length); + const level = instantiateTemplate(template, ingredientIds, seed); + const validation = validateLevel(level); + + if (validation.ok) { + return { + ...level, + solveTarget: template.solveTarget + }; + } + } + + throw new Error(`Unable to generate a valid daily special for ${seed}.`); +} diff --git a/bento-board/src/data.js b/bento-board/src/data.js new file mode 100644 index 0000000..68e6d27 --- /dev/null +++ b/bento-board/src/data.js @@ -0,0 +1,1071 @@ +const clone = (value) => JSON.parse(JSON.stringify(value)); + +const slots = (ingredientTypeId, count) => + Array.from({ length: count }, () => ingredientTypeId); + +const layer = (id, ingredientTypeId, targetCompartmentId, blockerIds = [], artVariant = "strip") => ({ + kind: "layer", + id, + ingredientTypeId, + targetCompartmentId, + blockerIds, + artVariant +}); + +const dividerItem = (id) => ({ + kind: "divider", + id +}); + +const stack = (id, laneIndex, title, accent, items, exitPosition = "down") => ({ + id, + laneIndex, + title, + accent, + exitPosition, + items +}); + +const pick = (id, stackId, layerId, label, overlappingFastenerIds = []) => ({ + id, + type: "pick", + stackId, + label, + anchorLayerId: layerId, + controlledLayerIds: [layerId], + dependencyIds: [], + overlappingFastenerIds, + visibilityRule: "front-layer-head", + hitTarget: { width: 56, height: 56 } +}); + +const band = (id, stackId, controlledLayerIds, label, dependencyIds = []) => ({ + id, + type: "band", + stackId, + label, + controlledLayerIds, + dependencyIds, + overlappingFastenerIds: [], + visibilityRule: "front-segment-tabs", + hitTarget: { width: 60, height: 60 } +}); + +const divider = (id, stackId, label) => ({ + id, + type: "divider", + stackId, + label, + controlledLayerIds: [], + dependencyIds: [], + overlappingFastenerIds: [], + visibilityRule: "mouth-tab", + hitTarget: { width: 64, height: 48 } +}); + +const compartment = (id, label, ingredientTypeId, count, artSkin) => ({ + id, + label, + acceptedSlots: slots(ingredientTypeId, count), + artSkin +}); + +const tutorialStep = (id, trigger, highlightedIds, copy) => ({ + id, + trigger, + highlightedIds, + copy +}); + +function createLevel({ + id, + number, + packId, + title, + beat, + mechanics, + rewardId, + journalTitle, + journalCaption, + compartments, + stacks, + fasteners, + tutorialSteps = [] +}) { + const layers = []; + const normalizedStacks = stacks.map((stackDefinition) => ({ + ...stackDefinition, + items: stackDefinition.items.map((item) => { + if (item.kind === "layer") { + layers.push({ + ...item, + stackId: stackDefinition.id + }); + return { kind: "layer", id: item.id }; + } + return item; + }) + })); + + return { + id, + kind: "campaign", + number, + packId, + title, + beat, + mechanics, + rewardId, + journalTitle, + journalCaption, + compartments, + stacks: normalizedStacks, + layers, + fasteners, + tutorialSteps + }; +} + +function createDailyTemplate({ + id, + title, + subtitle, + tokenOrder, + compartments, + stacks, + fasteners, + solveTarget +}) { + const layers = []; + const normalizedStacks = stacks.map((stackDefinition) => ({ + ...stackDefinition, + items: stackDefinition.items.map((item) => { + if (item.kind === "layer") { + layers.push({ + ...item, + stackId: stackDefinition.id + }); + return { kind: "layer", id: item.id }; + } + return item; + }) + })); + + return { + id, + title, + subtitle, + tokenOrder, + compartments, + stacks: normalizedStacks, + layers, + fasteners, + solveTarget + }; +} + +export const MENU_PACKS = Object.freeze([ + { + id: "picnic-basics", + title: "Picnic Basics", + accent: "#cc714b", + clothTitle: "Dawn Gingham", + ingredientPool: ["rice", "tamago", "tomato", "broccoli"], + levelNumbers: [1, 2, 3, 4, 5, 6] + }, + { + id: "market-lunch", + title: "Market Lunch", + accent: "#58755f", + clothTitle: "Moss Check", + ingredientPool: ["rice", "tamago", "tomato", "broccoli", "salmon", "cucumber", "shrimp", "tofu"], + levelNumbers: [7, 8, 9, 10, 11, 12] + } +]); + +export const INGREDIENT_TYPES = Object.freeze({ + rice: { + id: "rice", + family: "Rice Strip", + shortLabel: "RI", + tint: "#f4ead9", + accent: "#bb8d5c", + outline: "grain" + }, + tamago: { + id: "tamago", + family: "Tamago", + shortLabel: "EG", + tint: "#f5cc67", + accent: "#ae7a1f", + outline: "sun" + }, + tomato: { + id: "tomato", + family: "Tomato", + shortLabel: "TO", + tint: "#e66e57", + accent: "#9c4232", + outline: "seed" + }, + broccoli: { + id: "broccoli", + family: "Broccoli", + shortLabel: "BR", + tint: "#77a55f", + accent: "#46693a", + outline: "floret" + }, + salmon: { + id: "salmon", + family: "Salmon", + shortLabel: "SA", + tint: "#f18a6d", + accent: "#9b4d3f", + outline: "wave" + }, + cucumber: { + id: "cucumber", + family: "Cucumber", + shortLabel: "CU", + tint: "#92c08f", + accent: "#507850", + outline: "slice" + }, + shrimp: { + id: "shrimp", + family: "Shrimp", + shortLabel: "SH", + tint: "#f0b49a", + accent: "#9d6652", + outline: "curl" + }, + tofu: { + id: "tofu", + family: "Tofu", + shortLabel: "TF", + tint: "#efe4ca", + accent: "#9d835e", + outline: "press" + } +}); + +export const REWARD_DEFINITIONS = Object.freeze([ + { + id: "reward-01", + title: "Clover Stamp", + copy: "A soft green stamp lands in the lunch journal." + }, + { + id: "reward-02", + title: "Band Note", + copy: "The recipe card keeps a note about clearing loops before the slide." + }, + { + id: "reward-03", + title: "Paper Seal", + copy: "A divider sticker marks the first paper-gate lunch." + }, + { + id: "reward-04", + title: "Slot Study", + copy: "The journal records the first exact-count lunch order." + }, + { + id: "reward-05", + title: "Overflow Note", + copy: "The team notes that capacity readability matters before any denser authoring." + }, + { + id: "reward-06", + title: "Dawn Gingham Cloth", + copy: "Level 6 unlocks the first cloth pattern for the order strip." + }, + { + id: "reward-07", + title: "Market Stamp", + copy: "A new market-page stamp tracks repeated family planning." + }, + { + id: "reward-08", + title: "Loop Ledger", + copy: "Dense band dependencies get their own margin note in the journal." + }, + { + id: "reward-09", + title: "Hidden Gate Note", + copy: "A folded page marks the first delayed divider reveal." + }, + { + id: "reward-10", + title: "Chain Ribbon", + copy: "The journal highlights a multi-slide lunch chain." + }, + { + id: "reward-11", + title: "Counter Sketch", + copy: "A packed countertop sketch tracks the readability stress test." + }, + { + id: "reward-12", + title: "Moss Check Cloth", + copy: "The capstone unlocks the second cloth pattern." + }, + { + id: "reward-daily", + title: "Foil Chopstick Seal", + copy: "The daily special awards one dated foil seal on first clear." + } +]); + +export const HANDCRAFTED_LEVELS = [ + createLevel({ + id: "picnic-01", + number: 1, + packId: "picnic-basics", + title: "First Pick", + beat: "Tap the exposed bead picks. The touched stack resolves automatically when the blocker comes off.", + mechanics: ["Pick introduction"], + rewardId: "reward-01", + journalTitle: "Egg & Rice Pair", + journalCaption: "Two simple lanes teach that players only remove fasteners, never drag ingredients.", + compartments: [ + compartment("rice-box", "Rice Bay", "rice", 2, "amber"), + compartment("egg-box", "Tamago Cup", "tamago", 2, "coral") + ], + stacks: [ + stack("l1-stack-a", 0, "Sunrise Stack", "#d78559", [ + layer("l1-a1", "tamago", "egg-box", ["l1-pick-a1"]), + layer("l1-a2", "rice", "rice-box", ["l1-pick-a2"]) + ]), + stack("l1-stack-b", 1, "Picnic Stack", "#a85e3f", [ + layer("l1-b1", "rice", "rice-box", ["l1-pick-b1"]), + layer("l1-b2", "tamago", "egg-box", ["l1-pick-b2"]) + ]) + ], + fasteners: [ + pick("l1-pick-a1", "l1-stack-a", "l1-a1", "Sunrise pick"), + pick("l1-pick-a2", "l1-stack-a", "l1-a2", "Rice pick"), + pick("l1-pick-b1", "l1-stack-b", "l1-b1", "Bay pick"), + pick("l1-pick-b2", "l1-stack-b", "l1-b2", "Egg pick") + ], + tutorialSteps: [ + tutorialStep( + "l1-start", + "start", + ["l1-pick-a1", "l1-pick-b1"], + "Tap a bead pick. Only the touched stack resolves, and the freed ingredient slides on its own." + ) + ] + }), + createLevel({ + id: "picnic-02", + number: 2, + packId: "picnic-basics", + title: "First Band", + beat: "Bands wait until every pick inside their loop has been cleared.", + mechanics: ["Band introduction"], + rewardId: "reward-02", + journalTitle: "Looped Sandwich", + journalCaption: "The first elastic band teaches that visible tabs can still be mechanically blocked.", + compartments: [ + compartment("rice-box", "Rice Bay", "rice", 3, "amber"), + compartment("egg-box", "Tamago Cup", "tamago", 2, "coral") + ], + stacks: [ + stack("l2-stack-a", 0, "Band Stack", "#d9894e", [ + layer("l2-a1", "tamago", "egg-box", ["l2-pick-a1", "l2-band-a"]), + layer("l2-a2", "rice", "rice-box", ["l2-band-a"]), + layer("l2-a3", "rice", "rice-box", ["l2-pick-a3"]) + ]), + stack("l2-stack-b", 1, "Lunch Strip", "#b36542", [ + layer("l2-b1", "tamago", "egg-box", ["l2-pick-b1"]), + layer("l2-b2", "rice", "rice-box", ["l2-pick-b2"]) + ]) + ], + fasteners: [ + pick("l2-pick-a1", "l2-stack-a", "l2-a1", "Loop pick"), + band("l2-band-a", "l2-stack-a", ["l2-a1", "l2-a2"], "Dawn band", ["l2-pick-a1"]), + pick("l2-pick-a3", "l2-stack-a", "l2-a3", "Back rice pick"), + pick("l2-pick-b1", "l2-stack-b", "l2-b1", "Tamago pick"), + pick("l2-pick-b2", "l2-stack-b", "l2-b2", "Rice pick") + ], + tutorialSteps: [ + tutorialStep( + "l2-start", + "start", + ["l2-pick-a1", "l2-band-a"], + "Bands can be visible before they are removable. Clear the picks inside the loop first." + ), + tutorialStep( + "l2-after-pick", + "after:l2-pick-a1", + ["l2-band-a"], + "Now the band tabs are legal. Removing the band can release a short slide chain." + ) + ] + }), + createLevel({ + id: "picnic-03", + number: 3, + packId: "picnic-basics", + title: "First Divider", + beat: "Paper dividers are stack-mouth gates. Freed layers still wait if the gate is closed.", + mechanics: ["Divider introduction"], + rewardId: "reward-03", + journalTitle: "Folded Bento", + journalCaption: "A paper tab at the stack mouth blocks everything behind it until it is exposed and removed.", + compartments: [ + compartment("rice-box", "Rice Bay", "rice", 2, "amber"), + compartment("egg-box", "Tamago Cup", "tamago", 2, "coral"), + compartment("tomato-box", "Tomato Pocket", "tomato", 2, "berry") + ], + stacks: [ + stack("l3-stack-a", 0, "Paper Gate", "#cf8251", [ + layer("l3-a1", "tamago", "egg-box", ["l3-pick-a1"]), + dividerItem("l3-divider-a"), + layer("l3-a2", "rice", "rice-box") + ]), + stack("l3-stack-b", 1, "Field Stack", "#b76a43", [ + layer("l3-b1", "tomato", "tomato-box", ["l3-pick-b1"]), + layer("l3-b2", "rice", "rice-box", ["l3-pick-b2"]) + ]), + stack("l3-stack-c", 2, "Garden Stack", "#9e5a3b", [ + layer("l3-c1", "tamago", "egg-box", ["l3-pick-c1"]), + layer("l3-c2", "tomato", "tomato-box", ["l3-pick-c2"]) + ]) + ], + fasteners: [ + pick("l3-pick-a1", "l3-stack-a", "l3-a1", "Gate pick"), + divider("l3-divider-a", "l3-stack-a", "Washi divider"), + pick("l3-pick-b1", "l3-stack-b", "l3-b1", "Tomato pick"), + pick("l3-pick-b2", "l3-stack-b", "l3-b2", "Rice pick"), + pick("l3-pick-c1", "l3-stack-c", "l3-c1", "Egg pick"), + pick("l3-pick-c2", "l3-stack-c", "l3-c2", "Tomato bead") + ], + tutorialSteps: [ + tutorialStep( + "l3-start", + "start", + ["l3-divider-a"], + "Divider tabs matter only when they reach the front of the stack mouth." + ), + tutorialStep( + "l3-after-pick", + "after:l3-pick-a1", + ["l3-divider-a"], + "The tamago slides first. The paper tab becomes the new front item, so it can lift next." + ) + ] + }), + createLevel({ + id: "picnic-04", + number: 4, + packId: "picnic-basics", + title: "Read the Slots", + beat: "Compartment pips are exact counts. The lunchbox and recipe card mirror the same remaining needs.", + mechanics: ["Exact slot counts"], + rewardId: "reward-04", + journalTitle: "Slot Counting", + journalCaption: "The first repeated-count lunch shows why each compartment uses explicit pips, not hidden volume.", + compartments: [ + compartment("rice-box", "Rice Bay", "rice", 3, "amber"), + compartment("tomato-box", "Tomato Pocket", "tomato", 2, "berry"), + compartment("broccoli-box", "Broccoli Nook", "broccoli", 2, "moss") + ], + stacks: [ + stack("l4-stack-a", 0, "Market Red", "#d57c52", [ + layer("l4-a1", "tomato", "tomato-box", ["l4-pick-a1"]), + layer("l4-a2", "rice", "rice-box", ["l4-pick-a2"]) + ]), + stack("l4-stack-b", 1, "Garden Green", "#8ba765", [ + layer("l4-b1", "broccoli", "broccoli-box", ["l4-pick-b1"]), + layer("l4-b2", "rice", "rice-box", ["l4-pick-b2"]) + ]), + stack("l4-stack-c", 2, "Counter Mix", "#bf6940", [ + layer("l4-c1", "rice", "rice-box", ["l4-pick-c1"]), + layer("l4-c2", "broccoli", "broccoli-box", ["l4-pick-c2"]), + layer("l4-c3", "tomato", "tomato-box", ["l4-pick-c3"]) + ]) + ], + fasteners: [ + pick("l4-pick-a1", "l4-stack-a", "l4-a1", "Tomato bead"), + pick("l4-pick-a2", "l4-stack-a", "l4-a2", "Rice bead"), + pick("l4-pick-b1", "l4-stack-b", "l4-b1", "Broccoli pick"), + pick("l4-pick-b2", "l4-stack-b", "l4-b2", "Rice pick"), + pick("l4-pick-c1", "l4-stack-c", "l4-c1", "Front rice pick"), + pick("l4-pick-c2", "l4-stack-c", "l4-c2", "Green pick"), + pick("l4-pick-c3", "l4-stack-c", "l4-c3", "Back tomato pick") + ], + tutorialSteps: [ + tutorialStep( + "l4-start", + "start", + ["rice-box"], + "The dashed slot pips are exact needs. If three rice pips remain, the lunch still needs three rice strips." + ) + ] + }), + createLevel({ + id: "picnic-05", + number: 5, + packId: "picnic-basics", + title: "Busy Sidecar", + beat: "Bands, picks, and a divider now share the countertop. Read the mouth of each stack before you commit.", + mechanics: ["Two bands", "First dense read"], + rewardId: "reward-05", + journalTitle: "Sidecar Warning", + journalCaption: "Readability gets stricter once multiple fastener types share the same small stack field.", + compartments: [ + compartment("rice-box", "Rice Bay", "rice", 2, "amber"), + compartment("egg-box", "Tamago Cup", "tamago", 2, "coral"), + compartment("tomato-box", "Tomato Pocket", "tomato", 2, "berry"), + compartment("broccoli-box", "Broccoli Nook", "broccoli", 1, "moss") + ], + stacks: [ + stack("l5-stack-a", 0, "Loop Left", "#d07c4f", [ + layer("l5-a1", "tamago", "egg-box", ["l5-pick-a1", "l5-band-a"]), + layer("l5-a2", "rice", "rice-box", ["l5-band-a"]) + ]), + stack("l5-stack-b", 1, "Paper Middle", "#b0613f", [ + layer("l5-b1", "tomato", "tomato-box", ["l5-pick-b1"]), + dividerItem("l5-divider-b"), + layer("l5-b2", "broccoli", "broccoli-box") + ]), + stack("l5-stack-c", 2, "Loop Right", "#a45a3d", [ + layer("l5-c1", "rice", "rice-box", ["l5-pick-c1", "l5-band-c"]), + layer("l5-c2", "tamago", "egg-box", ["l5-band-c"]), + layer("l5-c3", "tomato", "tomato-box", ["l5-pick-c3"]) + ]) + ], + fasteners: [ + pick("l5-pick-a1", "l5-stack-a", "l5-a1", "Left loop pick"), + band("l5-band-a", "l5-stack-a", ["l5-a1", "l5-a2"], "Left cloth band", ["l5-pick-a1"]), + pick("l5-pick-b1", "l5-stack-b", "l5-b1", "Tomato bead"), + divider("l5-divider-b", "l5-stack-b", "Lunch paper"), + pick("l5-pick-c1", "l5-stack-c", "l5-c1", "Right loop pick"), + band("l5-band-c", "l5-stack-c", ["l5-c1", "l5-c2"], "Right cloth band", ["l5-pick-c1"]), + pick("l5-pick-c3", "l5-stack-c", "l5-c3", "Back tomato pick") + ], + tutorialSteps: [ + tutorialStep( + "l5-start", + "start", + ["l5-band-a", "l5-divider-b", "l5-band-c"], + "This is the first crowded read. Focus on the front-most legal fastener in each lane, not every peeking part at once." + ) + ] + }), + createLevel({ + id: "picnic-06", + number: 6, + packId: "picnic-basics", + title: "Lunch Set Finale", + beat: "The full picnic ruleset comes together here. Clear the right fasteners and the lunchbox starts to fill itself.", + mechanics: ["Pack finale", "Daily unlock"], + rewardId: "reward-06", + journalTitle: "Picnic Cloth", + journalCaption: "Clearing the first menu pack unlocks the Dawn Gingham cloth and opens the daily special card.", + compartments: [ + compartment("rice-box", "Rice Bay", "rice", 3, "amber"), + compartment("egg-box", "Tamago Cup", "tamago", 2, "coral"), + compartment("tomato-box", "Tomato Pocket", "tomato", 2, "berry"), + compartment("broccoli-box", "Broccoli Nook", "broccoli", 1, "moss") + ], + stacks: [ + stack("l6-stack-a", 0, "Loop Front", "#cf7c4d", [ + layer("l6-a1", "rice", "rice-box", ["l6-pick-a1", "l6-band-a"]), + layer("l6-a2", "tamago", "egg-box", ["l6-band-a"]) + ]), + stack("l6-stack-b", 1, "Paper Lane", "#b96c44", [ + layer("l6-b1", "tomato", "tomato-box", ["l6-pick-b1"]), + dividerItem("l6-divider-b"), + layer("l6-b2", "rice", "rice-box") + ]), + stack("l6-stack-c", 2, "Green Lane", "#91a866", [ + layer("l6-c1", "broccoli", "broccoli-box", ["l6-pick-c1"]), + layer("l6-c2", "tomato", "tomato-box", ["l6-pick-c2"]) + ]), + stack("l6-stack-d", 3, "Egg Tail", "#9b5b3f", [ + layer("l6-d1", "tamago", "egg-box", ["l6-pick-d1"]), + layer("l6-d2", "rice", "rice-box", ["l6-pick-d2"]) + ]) + ], + fasteners: [ + pick("l6-pick-a1", "l6-stack-a", "l6-a1", "Front rice pick"), + band("l6-band-a", "l6-stack-a", ["l6-a1", "l6-a2"], "Picnic band", ["l6-pick-a1"]), + pick("l6-pick-b1", "l6-stack-b", "l6-b1", "Tomato pick"), + divider("l6-divider-b", "l6-stack-b", "Washi gate"), + pick("l6-pick-c1", "l6-stack-c", "l6-c1", "Broccoli pick"), + pick("l6-pick-c2", "l6-stack-c", "l6-c2", "Tomato bead"), + pick("l6-pick-d1", "l6-stack-d", "l6-d1", "Egg pick"), + pick("l6-pick-d2", "l6-stack-d", "l6-d2", "Rice tail pick") + ], + tutorialSteps: [ + tutorialStep( + "l6-start", + "start", + ["l6-band-a", "l6-divider-b"], + "The daily special unlocks after this order. Use the recipe ribbon and lunchbox together to scan the whole layout." + ) + ] + }), + createLevel({ + id: "market-07", + number: 7, + packId: "market-lunch", + title: "Repeated Family", + beat: "The same ingredient family can now appear in multiple stacks, so plan counts before you clear a lane.", + mechanics: ["Repeated family"], + rewardId: "reward-07", + journalTitle: "Market Repeat", + journalCaption: "The first market lunch repeats cucumber across different stacks without adding new rules.", + compartments: [ + compartment("salmon-box", "Salmon Bar", "salmon", 2, "ember"), + compartment("cucumber-box", "Cucumber Cup", "cucumber", 3, "jade"), + compartment("tofu-box", "Tofu Bed", "tofu", 3, "linen") + ], + stacks: [ + stack("l7-stack-a", 0, "Cool Left", "#6e8f70", [ + layer("l7-a1", "cucumber", "cucumber-box", ["l7-pick-a1"]), + layer("l7-a2", "tofu", "tofu-box", ["l7-pick-a2"]) + ]), + stack("l7-stack-b", 1, "Fish Lane", "#cb7b5d", [ + layer("l7-b1", "salmon", "salmon-box", ["l7-pick-b1"]), + layer("l7-b2", "cucumber", "cucumber-box", ["l7-pick-b2"]) + ]), + stack("l7-stack-c", 2, "Paper Cut", "#a68156", [ + layer("l7-c1", "tofu", "tofu-box", ["l7-pick-c1"]), + dividerItem("l7-divider-c"), + layer("l7-c2", "cucumber", "cucumber-box") + ]), + stack("l7-stack-d", 3, "Soft Tail", "#8f6b56", [ + layer("l7-d1", "salmon", "salmon-box", ["l7-pick-d1"]), + layer("l7-d2", "tofu", "tofu-box", ["l7-pick-d2"]) + ]) + ], + fasteners: [ + pick("l7-pick-a1", "l7-stack-a", "l7-a1", "Cucumber pick"), + pick("l7-pick-a2", "l7-stack-a", "l7-a2", "Tofu bead"), + pick("l7-pick-b1", "l7-stack-b", "l7-b1", "Salmon pin"), + pick("l7-pick-b2", "l7-stack-b", "l7-b2", "Cucumber pin"), + pick("l7-pick-c1", "l7-stack-c", "l7-c1", "Tofu front pick"), + divider("l7-divider-c", "l7-stack-c", "Market divider"), + pick("l7-pick-d1", "l7-stack-d", "l7-d1", "Salmon bead"), + pick("l7-pick-d2", "l7-stack-d", "l7-d2", "Tofu tail") + ], + tutorialSteps: [ + tutorialStep( + "l7-start", + "start", + ["cucumber-box"], + "Repeated cucumber strips now arrive from different stacks. The recipe ribbon still shows the exact remaining count." + ) + ] + }), + createLevel({ + id: "market-08", + number: 8, + packId: "market-lunch", + title: "Band Under Pick", + beat: "Visible band tabs can still wait behind one front-layer pick.", + mechanics: ["Band dependency"], + rewardId: "reward-08", + journalTitle: "Loop Ledger", + journalCaption: "A single band now depends on two picks before the stack can release its chain.", + compartments: [ + compartment("rice-box", "Rice Bay", "rice", 3, "amber"), + compartment("salmon-box", "Salmon Bar", "salmon", 3, "ember"), + compartment("cucumber-box", "Cucumber Cup", "cucumber", 3, "jade") + ], + stacks: [ + stack("l8-stack-a", 0, "Layered Loop", "#ca795d", [ + layer("l8-a1", "salmon", "salmon-box", ["l8-pick-a1", "l8-band-a"]), + layer("l8-a2", "rice", "rice-box", ["l8-band-a"]), + layer("l8-a3", "cucumber", "cucumber-box") + ]), + stack("l8-stack-b", 1, "Cool Pair", "#6d8e70", [ + layer("l8-b1", "cucumber", "cucumber-box", ["l8-pick-b1"]), + layer("l8-b2", "rice", "rice-box", ["l8-pick-b2"]) + ]), + stack("l8-stack-c", 2, "Paper Fish", "#a9845a", [ + layer("l8-c1", "salmon", "salmon-box", ["l8-pick-c1"]), + dividerItem("l8-divider-c"), + layer("l8-c2", "cucumber", "cucumber-box") + ]), + stack("l8-stack-d", 3, "Rice Tail", "#8f6d56", [ + layer("l8-d1", "rice", "rice-box", ["l8-pick-d1"]), + layer("l8-d2", "salmon", "salmon-box", ["l8-pick-d2"]) + ]) + ], + fasteners: [ + pick("l8-pick-a1", "l8-stack-a", "l8-a1", "Top salmon pick"), + band("l8-band-a", "l8-stack-a", ["l8-a1", "l8-a2"], "Market band", ["l8-pick-a1"]), + pick("l8-pick-b1", "l8-stack-b", "l8-b1", "Cucumber pick"), + pick("l8-pick-b2", "l8-stack-b", "l8-b2", "Rice pick"), + pick("l8-pick-c1", "l8-stack-c", "l8-c1", "Salmon paper pick"), + divider("l8-divider-c", "l8-stack-c", "Paper gate"), + pick("l8-pick-d1", "l8-stack-d", "l8-d1", "Rice pin"), + pick("l8-pick-d2", "l8-stack-d", "l8-d2", "Salmon tail") + ], + tutorialSteps: [ + tutorialStep( + "l8-start", + "start", + ["l8-band-a"], + "This band is visible from the start, but it still waits behind the front pick that pierces the looped layers." + ) + ] + }), + createLevel({ + id: "market-09", + number: 9, + packId: "market-lunch", + title: "Back Divider", + beat: "A hidden divider can sit behind an early slide chain. Watch what a successful removal exposes next.", + mechanics: ["Delayed divider reveal"], + rewardId: "reward-09", + journalTitle: "Hidden Gate", + journalCaption: "One front removal now exposes a divider only after two ingredients have already settled.", + compartments: [ + compartment("salmon-box", "Salmon Bar", "salmon", 2, "ember"), + compartment("cucumber-box", "Cucumber Cup", "cucumber", 2, "jade"), + compartment("shrimp-box", "Shrimp Tray", "shrimp", 2, "peach"), + compartment("tofu-box", "Tofu Bed", "tofu", 3, "linen") + ], + stacks: [ + stack("l9-stack-a", 0, "Hidden Gate", "#b5794b", [ + layer("l9-a1", "shrimp", "shrimp-box", ["l9-pick-a1"]), + layer("l9-a2", "tofu", "tofu-box"), + dividerItem("l9-divider-a"), + layer("l9-a3", "cucumber", "cucumber-box") + ]), + stack("l9-stack-b", 1, "Fish Pair", "#cd7c60", [ + layer("l9-b1", "salmon", "salmon-box", ["l9-pick-b1"]), + layer("l9-b2", "shrimp", "shrimp-box", ["l9-pick-b2"]) + ]), + stack("l9-stack-c", 2, "Quiet Green", "#6e8f70", [ + layer("l9-c1", "tofu", "tofu-box", ["l9-pick-c1"]), + layer("l9-c2", "cucumber", "cucumber-box", ["l9-pick-c2"]) + ]), + stack("l9-stack-d", 3, "Soft Tail", "#9b775f", [ + layer("l9-d1", "salmon", "salmon-box", ["l9-pick-d1"]), + layer("l9-d2", "tofu", "tofu-box", ["l9-pick-d2"]) + ]) + ], + fasteners: [ + pick("l9-pick-a1", "l9-stack-a", "l9-a1", "Shrimp front pick"), + divider("l9-divider-a", "l9-stack-a", "Back washi gate"), + pick("l9-pick-b1", "l9-stack-b", "l9-b1", "Salmon bead"), + pick("l9-pick-b2", "l9-stack-b", "l9-b2", "Shrimp bead"), + pick("l9-pick-c1", "l9-stack-c", "l9-c1", "Tofu pin"), + pick("l9-pick-c2", "l9-stack-c", "l9-c2", "Cucumber pin"), + pick("l9-pick-d1", "l9-stack-d", "l9-d1", "Salmon tail"), + pick("l9-pick-d2", "l9-stack-d", "l9-d2", "Tofu tail") + ], + tutorialSteps: [ + tutorialStep( + "l9-after-front", + "after:l9-pick-a1", + ["l9-divider-a"], + "That first shrimp pick released a short chain. The divider only matters once the chain exposes its paper tab." + ) + ] + }), + createLevel({ + id: "market-10", + number: 10, + packId: "market-lunch", + title: "Double Chain", + beat: "One clean removal can settle several ingredients in sequence before the next blocker appears.", + mechanics: ["Longer chain"], + rewardId: "reward-10", + journalTitle: "Chain Ribbon", + journalCaption: "The recipe journal marks the first intentional multi-slide chain as a difficulty milestone.", + compartments: [ + compartment("salmon-box", "Salmon Bar", "salmon", 3, "ember"), + compartment("cucumber-box", "Cucumber Cup", "cucumber", 3, "jade"), + compartment("shrimp-box", "Shrimp Tray", "shrimp", 2, "peach"), + compartment("tofu-box", "Tofu Bed", "tofu", 2, "linen") + ], + stacks: [ + stack("l10-stack-a", 0, "Chain Loop", "#ca7c5a", [ + layer("l10-a1", "salmon", "salmon-box", ["l10-pick-a1", "l10-band-a"]), + layer("l10-a2", "cucumber", "cucumber-box", ["l10-band-a"]), + layer("l10-a3", "tofu", "tofu-box") + ]), + stack("l10-stack-b", 1, "Sea Pair", "#d08a63", [ + layer("l10-b1", "shrimp", "shrimp-box", ["l10-pick-b1"]), + layer("l10-b2", "cucumber", "cucumber-box", ["l10-pick-b2"]) + ]), + stack("l10-stack-c", 2, "Paper Fish", "#ae8558", [ + layer("l10-c1", "tofu", "tofu-box", ["l10-pick-c1"]), + dividerItem("l10-divider-c"), + layer("l10-c2", "salmon", "salmon-box") + ]), + stack("l10-stack-d", 3, "Green Pair", "#6b8d70", [ + layer("l10-d1", "cucumber", "cucumber-box", ["l10-pick-d1"]), + layer("l10-d2", "shrimp", "shrimp-box", ["l10-pick-d2"]) + ]), + stack("l10-stack-e", 4, "Last Fish", "#9e775f", [ + layer("l10-e1", "salmon", "salmon-box", ["l10-pick-e1"]) + ]) + ], + fasteners: [ + pick("l10-pick-a1", "l10-stack-a", "l10-a1", "Chain pick"), + band("l10-band-a", "l10-stack-a", ["l10-a1", "l10-a2"], "Sea band", ["l10-pick-a1"]), + pick("l10-pick-b1", "l10-stack-b", "l10-b1", "Shrimp bead"), + pick("l10-pick-b2", "l10-stack-b", "l10-b2", "Cucumber bead"), + pick("l10-pick-c1", "l10-stack-c", "l10-c1", "Tofu paper pick"), + divider("l10-divider-c", "l10-stack-c", "Paper divider"), + pick("l10-pick-d1", "l10-stack-d", "l10-d1", "Green pin"), + pick("l10-pick-d2", "l10-stack-d", "l10-d2", "Shrimp pin"), + pick("l10-pick-e1", "l10-stack-e", "l10-e1", "Final salmon pick") + ], + tutorialSteps: [ + tutorialStep( + "l10-after-band", + "after:l10-band-a", + ["l10-a1", "l10-a2", "l10-a3"], + "A band release can chain through several free layers in the same stack until the next blocker or empty stack end." + ) + ] + }), + createLevel({ + id: "market-11", + number: 11, + packId: "market-lunch", + title: "Crowded Counter", + beat: "The board gets denser here, but hit targets still stay distinct and the rule set stays unchanged.", + mechanics: ["Dense field"], + rewardId: "reward-11", + journalTitle: "Counter Sketch", + journalCaption: "This layout is tuned as a readability stress test, not a new rule layer.", + compartments: [ + compartment("salmon-box", "Salmon Bar", "salmon", 3, "ember"), + compartment("tofu-box", "Tofu Bed", "tofu", 3, "linen"), + compartment("shrimp-box", "Shrimp Tray", "shrimp", 2, "peach"), + compartment("rice-box", "Rice Bay", "rice", 2, "amber") + ], + stacks: [ + stack("l11-stack-a", 0, "Looped Fish", "#c97858", [ + layer("l11-a1", "salmon", "salmon-box", ["l11-pick-a1", "l11-band-a"]), + layer("l11-a2", "tofu", "tofu-box", ["l11-band-a"]) + ]), + stack("l11-stack-b", 1, "Short Pair", "#cf8b64", [ + layer("l11-b1", "shrimp", "shrimp-box", ["l11-pick-b1"]), + layer("l11-b2", "rice", "rice-box", ["l11-pick-b2"]) + ]), + stack("l11-stack-c", 2, "Paper Fish", "#ab8156", [ + layer("l11-c1", "tofu", "tofu-box", ["l11-pick-c1"]), + dividerItem("l11-divider-c"), + layer("l11-c2", "salmon", "salmon-box") + ]), + stack("l11-stack-d", 3, "Rice Pair", "#9d6f58", [ + layer("l11-d1", "rice", "rice-box", ["l11-pick-d1"]), + layer("l11-d2", "shrimp", "shrimp-box", ["l11-pick-d2"]) + ]), + stack("l11-stack-e", 4, "Tail Pair", "#7b946d", [ + layer("l11-e1", "tofu", "tofu-box", ["l11-pick-e1"]), + layer("l11-e2", "salmon", "salmon-box", ["l11-pick-e2"]) + ]) + ], + fasteners: [ + pick("l11-pick-a1", "l11-stack-a", "l11-a1", "Loop fish pick"), + band("l11-band-a", "l11-stack-a", ["l11-a1", "l11-a2"], "Crowd band", ["l11-pick-a1"]), + pick("l11-pick-b1", "l11-stack-b", "l11-b1", "Shrimp pick"), + pick("l11-pick-b2", "l11-stack-b", "l11-b2", "Rice pick"), + pick("l11-pick-c1", "l11-stack-c", "l11-c1", "Tofu paper pick"), + divider("l11-divider-c", "l11-stack-c", "Counter divider"), + pick("l11-pick-d1", "l11-stack-d", "l11-d1", "Rice bead"), + pick("l11-pick-d2", "l11-stack-d", "l11-d2", "Shrimp bead"), + pick("l11-pick-e1", "l11-stack-e", "l11-e1", "Tofu tail"), + pick("l11-pick-e2", "l11-stack-e", "l11-e2", "Salmon tail") + ], + tutorialSteps: [ + tutorialStep( + "l11-start", + "start", + ["l11-pick-a1", "l11-band-a", "l11-divider-c"], + "Even in the densest field, fasteners keep distinct silhouettes and separate tap targets." + ) + ] + }), + createLevel({ + id: "market-12", + number: 12, + packId: "market-lunch", + title: "Chef Special", + beat: "The capstone combines duplicate families, delayed dividers, and multi-step chains without adding new mechanics.", + mechanics: ["Capstone"], + rewardId: "reward-12", + journalTitle: "Chef Cloth", + journalCaption: "The market capstone unlocks the Moss Check cloth and completes the first playable lunch journal set.", + compartments: [ + compartment("salmon-box", "Salmon Bar", "salmon", 3, "ember"), + compartment("tofu-box", "Tofu Bed", "tofu", 3, "linen"), + compartment("rice-box", "Rice Bay", "rice", 3, "amber"), + compartment("shrimp-box", "Shrimp Tray", "shrimp", 2, "peach") + ], + stacks: [ + stack("l12-stack-a", 0, "Chef Loop", "#c67b5c", [ + layer("l12-a1", "salmon", "salmon-box", ["l12-pick-a1", "l12-band-a"]), + layer("l12-a2", "tofu", "tofu-box", ["l12-band-a"]), + layer("l12-a3", "rice", "rice-box") + ]), + stack("l12-stack-b", 1, "Paper Fish", "#d18b63", [ + layer("l12-b1", "shrimp", "shrimp-box", ["l12-pick-b1"]), + dividerItem("l12-divider-b"), + layer("l12-b2", "salmon", "salmon-box") + ]), + stack("l12-stack-c", 2, "Center Pair", "#aa8357", [ + layer("l12-c1", "rice", "rice-box", ["l12-pick-c1"]), + layer("l12-c2", "tofu", "tofu-box", ["l12-pick-c2"]) + ]), + stack("l12-stack-d", 3, "Second Gate", "#8e705a", [ + layer("l12-d1", "salmon", "salmon-box", ["l12-pick-d1"]), + dividerItem("l12-divider-d"), + layer("l12-d2", "tofu", "tofu-box") + ]), + stack("l12-stack-e", 4, "Rice Tail", "#78916a", [ + layer("l12-e1", "rice", "rice-box", ["l12-pick-e1"]), + layer("l12-e2", "shrimp", "shrimp-box", ["l12-pick-e2"]) + ]) + ], + fasteners: [ + pick("l12-pick-a1", "l12-stack-a", "l12-a1", "Chef salmon pick"), + band("l12-band-a", "l12-stack-a", ["l12-a1", "l12-a2"], "Chef band", ["l12-pick-a1"]), + pick("l12-pick-b1", "l12-stack-b", "l12-b1", "Shrimp paper pick"), + divider("l12-divider-b", "l12-stack-b", "North divider"), + pick("l12-pick-c1", "l12-stack-c", "l12-c1", "Rice pin"), + pick("l12-pick-c2", "l12-stack-c", "l12-c2", "Tofu pin"), + pick("l12-pick-d1", "l12-stack-d", "l12-d1", "Salmon gate pick"), + divider("l12-divider-d", "l12-stack-d", "South divider"), + pick("l12-pick-e1", "l12-stack-e", "l12-e1", "Rice tail pick"), + pick("l12-pick-e2", "l12-stack-e", "l12-e2", "Shrimp tail pick") + ], + tutorialSteps: [ + tutorialStep( + "l12-start", + "start", + ["l12-band-a", "l12-divider-b", "l12-divider-d"], + "The capstone does not add new rules. It asks you to chain the familiar ones cleanly across a fuller board." + ) + ] + }) +]; + +export const DAILY_TEMPLATE_DEFINITIONS = Object.freeze([ + createDailyTemplate({ + id: "daily-template-01", + title: "Daily Special", + subtitle: "Balanced lunch with one divider and one loop chain.", + tokenOrder: ["A", "B", "C", "D"], + compartments: [ + { id: "daily-a", label: "Top Left", acceptedSlots: ["A", "A", "A"], artSkin: "daily-a" }, + { id: "daily-b", label: "Top Right", acceptedSlots: ["B", "B"], artSkin: "daily-b" }, + { id: "daily-c", label: "Bottom Left", acceptedSlots: ["C", "C"], artSkin: "daily-c" }, + { id: "daily-d", label: "Bottom Right", acceptedSlots: ["D", "D"], artSkin: "daily-d" } + ], + stacks: [ + stack("dt1-stack-a", 0, "Daily Loop", "#c87a5b", [ + layer("dt1-a1", "A", "daily-a", ["dt1-pick-a1", "dt1-band-a"]), + layer("dt1-a2", "B", "daily-b", ["dt1-band-a"]) + ]), + stack("dt1-stack-b", 1, "Daily Paper", "#a88258", [ + layer("dt1-b1", "C", "daily-c", ["dt1-pick-b1"]), + dividerItem("dt1-divider-b"), + layer("dt1-b2", "A", "daily-a") + ]), + stack("dt1-stack-c", 2, "Daily Pair", "#789069", [ + layer("dt1-c1", "D", "daily-d", ["dt1-pick-c1"]), + layer("dt1-c2", "B", "daily-b", ["dt1-pick-c2"]) + ]), + stack("dt1-stack-d", 3, "Daily Tail", "#90705a", [ + layer("dt1-d1", "A", "daily-a", ["dt1-pick-d1"]), + layer("dt1-d2", "D", "daily-d", ["dt1-pick-d2"]), + layer("dt1-d3", "C", "daily-c", ["dt1-pick-d3"]) + ]) + ], + fasteners: [ + pick("dt1-pick-a1", "dt1-stack-a", "dt1-a1", "Daily top pick"), + band("dt1-band-a", "dt1-stack-a", ["dt1-a1", "dt1-a2"], "Daily loop", ["dt1-pick-a1"]), + pick("dt1-pick-b1", "dt1-stack-b", "dt1-b1", "Daily paper pick"), + divider("dt1-divider-b", "dt1-stack-b", "Daily paper tab"), + pick("dt1-pick-c1", "dt1-stack-c", "dt1-c1", "Daily pair pick"), + pick("dt1-pick-c2", "dt1-stack-c", "dt1-c2", "Daily pair bead"), + pick("dt1-pick-d1", "dt1-stack-d", "dt1-d1", "Daily tail pick"), + pick("dt1-pick-d2", "dt1-stack-d", "dt1-d2", "Daily tail bead"), + pick("dt1-pick-d3", "dt1-stack-d", "dt1-d3", "Daily back bead") + ], + solveTarget: { min: 7, max: 8 } + }), + createDailyTemplate({ + id: "daily-template-02", + title: "Daily Special", + subtitle: "Five stacks with one long chain and two paper reveals.", + tokenOrder: ["A", "B", "C", "D"], + compartments: [ + { id: "daily-a", label: "North", acceptedSlots: ["A", "A", "A"], artSkin: "daily-a" }, + { id: "daily-b", label: "West", acceptedSlots: ["B", "B", "B"], artSkin: "daily-b" }, + { id: "daily-c", label: "East", acceptedSlots: ["C", "C"], artSkin: "daily-c" }, + { id: "daily-d", label: "South", acceptedSlots: ["D", "D"], artSkin: "daily-d" } + ], + stacks: [ + stack("dt2-stack-a", 0, "Loop Stack", "#c77b5d", [ + layer("dt2-a1", "A", "daily-a", ["dt2-pick-a1", "dt2-band-a"]), + layer("dt2-a2", "B", "daily-b", ["dt2-pick-a2", "dt2-band-a"]), + layer("dt2-a3", "C", "daily-c") + ]), + stack("dt2-stack-b", 1, "Paper North", "#a98158", [ + layer("dt2-b1", "D", "daily-d", ["dt2-pick-b1"]), + dividerItem("dt2-divider-b"), + layer("dt2-b2", "A", "daily-a") + ]), + stack("dt2-stack-c", 2, "Short Pair", "#6f8c70", [ + layer("dt2-c1", "B", "daily-b", ["dt2-pick-c1"]), + layer("dt2-c2", "C", "daily-c", ["dt2-pick-c2"]) + ]), + stack("dt2-stack-d", 3, "Paper South", "#936f59", [ + layer("dt2-d1", "A", "daily-a", ["dt2-pick-d1"]), + dividerItem("dt2-divider-d"), + layer("dt2-d2", "B", "daily-b") + ]), + stack("dt2-stack-e", 4, "Tail Pair", "#c18a60", [ + layer("dt2-e1", "D", "daily-d", ["dt2-pick-e1"]), + layer("dt2-e2", "B", "daily-b", ["dt2-pick-e2"]) + ]) + ], + fasteners: [ + pick("dt2-pick-a1", "dt2-stack-a", "dt2-a1", "Daily chain pick"), + pick("dt2-pick-a2", "dt2-stack-a", "dt2-a2", "Daily chain bead"), + band("dt2-band-a", "dt2-stack-a", ["dt2-a1", "dt2-a2"], "Daily chain band", ["dt2-pick-a1", "dt2-pick-a2"]), + pick("dt2-pick-b1", "dt2-stack-b", "dt2-b1", "North paper pick"), + divider("dt2-divider-b", "dt2-stack-b", "North paper tab"), + pick("dt2-pick-c1", "dt2-stack-c", "dt2-c1", "Short pair pick"), + pick("dt2-pick-c2", "dt2-stack-c", "dt2-c2", "Short pair bead"), + pick("dt2-pick-d1", "dt2-stack-d", "dt2-d1", "South paper pick"), + divider("dt2-divider-d", "dt2-stack-d", "South paper tab"), + pick("dt2-pick-e1", "dt2-stack-e", "dt2-e1", "Tail daily pick"), + pick("dt2-pick-e2", "dt2-stack-e", "dt2-e2", "Tail daily bead") + ], + solveTarget: { min: 8, max: 9 } + }) +]); + +export function getPackById(packId) { + return MENU_PACKS.find((pack) => pack.id === packId) ?? null; +} + +export function getRewardById(rewardId) { + return REWARD_DEFINITIONS.find((reward) => reward.id === rewardId) ?? null; +} + +export function getLevelById(levelId) { + return HANDCRAFTED_LEVELS.find((level) => level.id === levelId) ?? null; +} + +export function cloneDailyTemplate(templateId) { + const template = DAILY_TEMPLATE_DEFINITIONS.find((entry) => entry.id === templateId); + return template ? clone(template) : null; +} diff --git a/bento-board/src/engine.js b/bento-board/src/engine.js new file mode 100644 index 0000000..a294f38 --- /dev/null +++ b/bento-board/src/engine.js @@ -0,0 +1,829 @@ +import { INGREDIENT_TYPES, getRewardById } from "./data.js"; + +const clone = (value) => JSON.parse(JSON.stringify(value)); + +const FASTENER_PRIORITY = { + band: 3, + divider: 2, + pick: 1 +}; + +const arraysEqual = (left, right) => + left.length === right.length && left.every((value, index) => value === right[index]); + +function createFilledSlots(level) { + return Object.fromEntries( + level.compartments.map((compartment) => [ + compartment.id, + Array.from({ length: compartment.acceptedSlots.length }, () => null) + ]) + ); +} + +function snapshotState(state) { + return { + removedFastenerIds: [...state.removedFastenerIds], + settledLayerIds: [...state.settledLayerIds], + filledSlots: clone(state.filledSlots), + failed: state.failed, + failKind: state.failKind, + completed: state.completed, + message: state.message + }; +} + +export function createRuntime(level) { + const stackMap = new Map(level.stacks.map((stack) => [stack.id, stack])); + const layerMap = new Map(level.layers.map((layer) => [layer.id, layer])); + const fastenerMap = new Map(level.fasteners.map((fastener) => [fastener.id, fastener])); + const compartmentMap = new Map(level.compartments.map((compartment) => [compartment.id, compartment])); + const fastenersByStack = new Map(); + const fastenersByLayer = new Map(); + + for (const fastener of level.fasteners) { + const stackFasteners = fastenersByStack.get(fastener.stackId) ?? []; + stackFasteners.push(fastener); + fastenersByStack.set(fastener.stackId, stackFasteners); + + for (const layerId of fastener.controlledLayerIds) { + const layerFasteners = fastenersByLayer.get(layerId) ?? []; + layerFasteners.push(fastener.id); + fastenersByLayer.set(layerId, layerFasteners); + } + } + + return { + stackMap, + layerMap, + fastenerMap, + compartmentMap, + fastenersByStack, + fastenersByLayer + }; +} + +export function createInitialState(level) { + return { + removedFastenerIds: [], + settledLayerIds: [], + filledSlots: createFilledSlots(level), + failed: false, + failKind: null, + completed: false, + message: level.beat, + hintAction: null, + history: [], + lastResolution: null, + feedback: null, + feedbackSeq: 0 + }; +} + +export function isFastenerRemoved(state, fastenerId) { + return state.removedFastenerIds.includes(fastenerId); +} + +export function isLayerSettled(state, layerId) { + return state.settledLayerIds.includes(layerId); +} + +function getRemainingItems(stack, state) { + return stack.items.filter((item) => { + if (item.kind === "divider") { + return !isFastenerRemoved(state, item.id); + } + return !isLayerSettled(state, item.id); + }); +} + +export function getRemainingItemsForStack(level, state, stackId, runtime = createRuntime(level)) { + const stack = runtime.stackMap.get(stackId); + return stack ? getRemainingItems(stack, state) : []; +} + +function getFrontItem(stack, state) { + return getRemainingItems(stack, state)[0] ?? null; +} + +function getFrontSegmentLayerIds(stack, state) { + const result = []; + for (const item of getRemainingItems(stack, state)) { + if (item.kind === "divider") { + break; + } + result.push(item.id); + } + return result; +} + +function findLayerPositionInStack(stack, layerId) { + return stack.items.findIndex((item) => item.kind === "layer" && item.id === layerId); +} + +function getVisibleLayerViews(stack, state, runtime, limit = 3) { + const visible = []; + for (const item of getRemainingItems(stack, state)) { + if (item.kind === "layer") { + visible.push(runtime.layerMap.get(item.id)); + } + if (visible.length === limit) { + break; + } + } + return visible; +} + +function nextOpenSlotIndex(compartment, filledSlots, ingredientTypeId) { + for (let index = 0; index < compartment.acceptedSlots.length; index += 1) { + if (!filledSlots[index] && compartment.acceptedSlots[index] === ingredientTypeId) { + return index; + } + } + return -1; +} + +function stateSignature(state) { + const removed = [...state.removedFastenerIds].sort().join(","); + const settled = [...state.settledLayerIds].sort().join(","); + return `${removed}|${settled}`; +} + +function feedback(state, fastenerId, outcome, message) { + return { + ...state, + hintAction: null, + lastResolution: null, + feedbackSeq: state.feedbackSeq + 1, + feedback: { + fastenerId, + outcome, + message, + seq: state.feedbackSeq + 1 + }, + message + }; +} + +function frontLayerReason() { + return "Pick head is still covered by the front layer."; +} + +function bandCoverReason() { + return "Band tabs are not fully exposed yet."; +} + +function dividerCoverReason() { + return "Divider tab is not exposed at the stack mouth yet."; +} + +function pickInsideBandReason() { + return "A band still crosses that pick head."; +} + +function bandDependencyReason() { + return "A pick still pins the banded layers."; +} + +export function evaluateFastener(level, state, fastenerId, runtime = createRuntime(level)) { + const fastener = runtime.fastenerMap.get(fastenerId); + if (!fastener) { + return { ok: false, status: "missing", reason: `Unknown fastener ${fastenerId}.` }; + } + + if (state.failed || state.completed) { + return { ok: false, status: "inactive", reason: "The lunch order is no longer active." }; + } + + if (isFastenerRemoved(state, fastener.id)) { + return { ok: false, status: "spent", reason: `${fastener.label} has already been removed.` }; + } + + const stack = runtime.stackMap.get(fastener.stackId); + if (!stack) { + return { ok: false, status: "missing", reason: `Unknown stack ${fastener.stackId}.` }; + } + + if (fastener.type === "divider") { + const frontItem = getFrontItem(stack, state); + if (!frontItem || frontItem.kind !== "divider" || frontItem.id !== fastener.id) { + return { ok: false, status: "covered", reason: dividerCoverReason() }; + } + return { ok: true, status: "ready", reason: `${fastener.label} can lift now.` }; + } + + if (fastener.type === "pick") { + const layer = runtime.layerMap.get(fastener.anchorLayerId); + if (!layer || isLayerSettled(state, layer.id)) { + return { ok: false, status: "spent", reason: `${fastener.label} no longer matters.` }; + } + + const frontItem = getFrontItem(stack, state); + if (!frontItem || frontItem.kind !== "layer" || frontItem.id !== layer.id) { + return { ok: false, status: "covered", reason: frontLayerReason() }; + } + + const blockingBand = fastener.overlappingFastenerIds.find((id) => !isFastenerRemoved(state, id)); + if (blockingBand) { + return { ok: false, status: "blocked", reason: pickInsideBandReason() }; + } + + return { ok: true, status: "ready", reason: `${fastener.label} can lift now.` }; + } + + const controlledLayerIds = fastener.controlledLayerIds.filter((layerId) => !isLayerSettled(state, layerId)); + const frontSegment = getFrontSegmentLayerIds(stack, state); + const visiblePrefix = frontSegment.slice(0, controlledLayerIds.length); + + if (!controlledLayerIds.length) { + return { ok: false, status: "spent", reason: `${fastener.label} no longer matters.` }; + } + + if (!arraysEqual(controlledLayerIds, visiblePrefix)) { + return { ok: false, status: "covered", reason: bandCoverReason() }; + } + + const blockingPick = fastener.dependencyIds.find((id) => !isFastenerRemoved(state, id)); + if (blockingPick) { + return { ok: false, status: "blocked", reason: bandDependencyReason() }; + } + + return { ok: true, status: "ready", reason: `${fastener.label} can snap away.` }; +} + +function simulateChain(level, state, stackId, runtime) { + const stack = runtime.stackMap.get(stackId); + const nextState = { + ...state, + filledSlots: clone(state.filledSlots), + settledLayerIds: [...state.settledLayerIds] + }; + const chainLayerIds = []; + const chainCompartmentIds = []; + + while (true) { + const frontItem = getFrontItem(stack, nextState); + if (!frontItem || frontItem.kind === "divider") { + break; + } + + const layer = runtime.layerMap.get(frontItem.id); + const activeBlocker = layer.blockerIds.find((blockerId) => !isFastenerRemoved(nextState, blockerId)); + if (activeBlocker) { + break; + } + + const compartment = runtime.compartmentMap.get(layer.targetCompartmentId); + const slotIndex = nextOpenSlotIndex(compartment, nextState.filledSlots[compartment.id], layer.ingredientTypeId); + if (slotIndex === -1) { + return { + ok: false, + failKind: "overflow", + state: nextState, + chainLayerIds, + chainCompartmentIds, + layerId: layer.id, + compartmentId: compartment.id + }; + } + + nextState.filledSlots[compartment.id][slotIndex] = layer.id; + nextState.settledLayerIds.push(layer.id); + chainLayerIds.push(layer.id); + chainCompartmentIds.push(compartment.id); + } + + return { + ok: true, + state: nextState, + chainLayerIds, + chainCompartmentIds + }; +} + +function countRemainingLayers(level, state) { + return level.layers.filter((layer) => !isLayerSettled(state, layer.id)).length; +} + +function actionScore(level, state, fastener, runtime) { + let score = FASTENER_PRIORITY[fastener.type] * 10; + if (fastener.type === "band") { + score += fastener.controlledLayerIds.length * 4; + } + if (fastener.type === "divider") { + const stack = runtime.stackMap.get(fastener.stackId); + score += getRemainingItems(stack, state).length; + } + if (fastener.type === "pick") { + const stack = runtime.stackMap.get(fastener.stackId); + const position = findLayerPositionInStack(stack, fastener.anchorLayerId); + score += Math.max(0, 5 - position); + } + return score; +} + +export function collectReadyFasteners(level, state, runtime = createRuntime(level)) { + if (state.failed || state.completed) { + return []; + } + + return level.fasteners + .map((fastener) => { + const evaluation = evaluateFastener(level, state, fastener.id, runtime); + return { + id: fastener.id, + type: fastener.type, + label: fastener.label, + evaluation, + score: actionScore(level, state, fastener, runtime) + }; + }) + .filter((entry) => entry.evaluation.ok) + .sort((left, right) => right.score - left.score || left.id.localeCompare(right.id)); +} + +function finishState(level, state, runtime, fastenerId, chainLayerIds, chainCompartmentIds) { + const nextState = { + ...state, + hintAction: null, + lastResolution: { + fastenerId, + chainLayerIds, + chainCompartmentIds + }, + feedbackSeq: state.feedbackSeq + 1, + feedback: { + fastenerId, + outcome: "valid", + message: state.message, + seq: state.feedbackSeq + 1 + } + }; + + if (nextState.settledLayerIds.length === level.layers.length) { + return { + ...nextState, + completed: true, + failed: false, + failKind: null, + message: "Lunch packed. Every recipe slot is filled." + }; + } + + if (!collectReadyFasteners(level, nextState, runtime).length && countRemainingLayers(level, nextState) > 0) { + return { + ...nextState, + failed: true, + completed: false, + failKind: "jammed", + message: "No exposed fasteners remain. The lunch jams shut." + }; + } + + return nextState; +} + +export function applyFastener(level, state, fastenerId, runtime = createRuntime(level)) { + const evaluation = evaluateFastener(level, state, fastenerId, runtime); + const fastener = runtime.fastenerMap.get(fastenerId); + + if (!fastener) { + return feedback(state, fastenerId, "invalid", `Unknown fastener ${fastenerId}.`); + } + + if (!evaluation.ok) { + return feedback(state, fastenerId, "invalid", evaluation.reason); + } + + const stagedState = { + ...state, + removedFastenerIds: [...state.removedFastenerIds, fastenerId], + history: [...state.history, snapshotState(state)], + message: `${fastener.label} comes away.`, + filledSlots: clone(state.filledSlots), + settledLayerIds: [...state.settledLayerIds] + }; + + const resolved = simulateChain(level, stagedState, fastener.stackId, runtime); + if (!resolved.ok) { + const layer = runtime.layerMap.get(resolved.layerId); + const ingredient = INGREDIENT_TYPES[layer.ingredientTypeId]; + const compartment = runtime.compartmentMap.get(resolved.compartmentId); + return { + ...stagedState, + filledSlots: resolved.state.filledSlots, + settledLayerIds: resolved.state.settledLayerIds, + failed: true, + completed: false, + failKind: "overflow", + hintAction: null, + feedbackSeq: state.feedbackSeq + 1, + feedback: { + fastenerId, + outcome: "fail", + message: `${ingredient.family} has no room in ${compartment.label}.`, + seq: state.feedbackSeq + 1 + }, + lastResolution: { + fastenerId, + chainLayerIds: resolved.chainLayerIds, + chainCompartmentIds: resolved.chainCompartmentIds + }, + message: `${ingredient.family} has no room in ${compartment.label}.` + }; + } + + const chainCount = resolved.chainLayerIds.length; + const message = + chainCount > 1 + ? `${fastener.label} frees a ${chainCount}-ingredient slide chain.` + : chainCount === 1 + ? `${fastener.label} frees one clean slide.` + : `${fastener.label} clears the blocker, but this stack still holds.`; + + return finishState( + level, + { + ...stagedState, + filledSlots: resolved.state.filledSlots, + settledLayerIds: resolved.state.settledLayerIds, + message + }, + runtime, + fastenerId, + resolved.chainLayerIds, + resolved.chainCompartmentIds + ); +} + +export function undoAction(state) { + if (!state.history.length) { + return { + ...state, + hintAction: null, + lastResolution: null, + feedbackSeq: state.feedbackSeq + 1, + feedback: { + fastenerId: null, + outcome: "invalid", + message: "Nothing to undo yet.", + seq: state.feedbackSeq + 1 + }, + message: "Nothing to undo yet." + }; + } + + const snapshot = state.history[state.history.length - 1]; + return { + ...clone(snapshot), + history: state.history.slice(0, -1), + hintAction: null, + lastResolution: null, + feedbackSeq: state.feedbackSeq + 1, + feedback: { + fastenerId: null, + outcome: "valid", + message: "Last successful removal undone.", + seq: state.feedbackSeq + 1 + }, + message: "Last successful removal undone." + }; +} + +export function restartLevel(level) { + return createInitialState(level); +} + +export function solveLevel(level, runtime = createRuntime(level), fromState = createInitialState(level)) { + const seen = new Set(); + + function search(state) { + if (state.completed) { + return []; + } + if (state.failed) { + return null; + } + + const signature = stateSignature(state); + if (seen.has(signature)) { + return null; + } + seen.add(signature); + + const actions = collectReadyFasteners(level, state, runtime); + for (const action of actions) { + const next = applyFastener(level, state, action.id, runtime); + const suffix = search(next); + if (suffix) { + return [action, ...suffix]; + } + } + + return null; + } + + const sequence = search(fromState); + if (!sequence) { + return { + ok: false, + sequence: [], + reason: fromState.failed ? fromState.message : "No solution path was found." + }; + } + + return { + ok: true, + sequence + }; +} + +export function findHint(level, state, runtime = createRuntime(level)) { + if (state.failed || state.completed) { + return { + action: null, + message: "This order is not currently playable." + }; + } + + const solution = solveLevel(level, runtime, state); + if (!solution.ok || !solution.sequence.length) { + const fallback = collectReadyFasteners(level, state, runtime)[0] ?? null; + if (!fallback) { + return { + action: null, + message: "No legal fastener is exposed from this state." + }; + } + + return { + action: { id: fallback.id, type: fallback.type }, + message: `Hint: remove ${fallback.label}.` + }; + } + + const [action] = solution.sequence; + const fastener = runtime.fastenerMap.get(action.id); + const preview = applyFastener(level, state, action.id, runtime); + const chainCount = preview.lastResolution?.chainLayerIds.length ?? 0; + + return { + action: { id: action.id, type: action.type }, + message: + chainCount > 1 + ? `Hint: remove ${fastener.label} to release a ${chainCount}-ingredient chain.` + : `Hint: remove ${fastener.label}.` + }; +} + +export function getCompletionRatio(level, state) { + if (!level.layers.length) { + return 0; + } + return state.settledLayerIds.length / level.layers.length; +} + +export function getCompartmentStates(level, state, runtime = createRuntime(level)) { + return level.compartments.map((compartment) => { + const filledSlots = state.filledSlots[compartment.id]; + const remaining = filledSlots.filter((slot) => !slot).length; + return { + ...compartment, + filledSlots, + filledCount: filledSlots.filter(Boolean).length, + remainingCount: remaining, + highlighted: state.lastResolution?.chainCompartmentIds.includes(compartment.id) ?? false + }; + }); +} + +function buildFastenerView(level, state, stack, runtime, visibleLayerIds) { + const activeFasteners = (runtime.fastenersByStack.get(stack.id) ?? []) + .filter((fastener) => !isFastenerRemoved(state, fastener.id)) + .map((fastener) => { + const evaluation = evaluateFastener(level, state, fastener.id, runtime); + let anchorLayerId = fastener.anchorLayerId ?? fastener.controlledLayerIds[0] ?? null; + let visible = true; + let anchorDepth = 0; + + if (fastener.type === "divider") { + const frontItem = getFrontItem(stack, state); + visible = frontItem?.kind === "divider" && frontItem.id === fastener.id; + anchorDepth = -0.35; + } else if (anchorLayerId) { + anchorDepth = visibleLayerIds.indexOf(anchorLayerId); + visible = anchorDepth !== -1; + } + + return { + ...fastener, + status: evaluation.status, + reason: evaluation.reason, + visible, + anchorDepth, + highlighted: + state.hintAction?.id === fastener.id || + state.lastResolution?.fastenerId === fastener.id + }; + }) + .filter((fastener) => fastener.visible) + .sort((left, right) => left.anchorDepth - right.anchorDepth || left.id.localeCompare(right.id)); + + return activeFasteners; +} + +export function buildBoardView(level, state, runtime = createRuntime(level)) { + const compartments = getCompartmentStates(level, state, runtime); + const stacks = level.stacks.map((stack) => { + const visibleLayers = getVisibleLayerViews(stack, state, runtime, 3); + const visibleLayerIds = visibleLayers.map((layer) => layer.id); + const remainingItems = getRemainingItems(stack, state); + const layerCount = remainingItems.filter((item) => item.kind === "layer").length; + const frontItem = remainingItems[0] ?? null; + + return { + ...stack, + visibleLayers: visibleLayers.map((layerDefinition, index) => ({ + ...layerDefinition, + ingredient: INGREDIENT_TYPES[layerDefinition.ingredientTypeId], + depth: index, + isFront: index === 0 && frontItem?.kind === "layer" && frontItem.id === layerDefinition.id, + highlighted: state.lastResolution?.chainLayerIds.includes(layerDefinition.id) ?? false, + remainingBlockers: layerDefinition.blockerIds.filter( + (blockerId) => !isFastenerRemoved(state, blockerId) + ) + })), + compressedCount: Math.max(0, layerCount - visibleLayers.length), + frontDividerId: frontItem?.kind === "divider" ? frontItem.id : null, + fasteners: buildFastenerView(level, state, stack, runtime, visibleLayerIds) + }; + }); + + return { + compartments, + stacks, + readyFasteners: collectReadyFasteners(level, state, runtime) + }; +} + +export function validateLevel(level) { + const runtime = createRuntime(level); + const issues = []; + + if (level.kind === "campaign") { + if (level.stacks.length < 2 || level.stacks.length > 5) { + issues.push("Campaign levels must author 2 to 5 stacks."); + } + if (level.compartments.length < 2 || level.compartments.length > 4) { + issues.push("Campaign levels must author 2 to 4 compartments."); + } + if (level.layers.length < 4 || level.layers.length > 11) { + issues.push("Campaign levels must author 4 to 11 ingredients."); + } + } else { + if (level.stacks.length < 4 || level.stacks.length > 5) { + issues.push("Daily levels should author 4 to 5 stacks."); + } + if (level.compartments.length < 3 || level.compartments.length > 4) { + issues.push("Daily levels should author 3 to 4 compartments."); + } + if (level.layers.length < 9 || level.layers.length > 10) { + issues.push("Daily levels should author 9 to 10 ingredients."); + } + } + + if (!getRewardById(level.rewardId ?? "reward-daily") && level.kind === "campaign") { + issues.push(`Missing reward definition for ${level.rewardId}.`); + } + + const stackIds = new Set(); + const layerIds = new Set(); + const fastenerIds = new Set(); + const compartmentIds = new Set(); + + for (const stack of level.stacks) { + if (stackIds.has(stack.id)) { + issues.push(`Duplicate stack id ${stack.id}.`); + } + stackIds.add(stack.id); + } + + for (const layer of level.layers) { + if (layerIds.has(layer.id)) { + issues.push(`Duplicate layer id ${layer.id}.`); + } + layerIds.add(layer.id); + if (!runtime.compartmentMap.has(layer.targetCompartmentId)) { + issues.push(`Layer ${layer.id} targets unknown compartment ${layer.targetCompartmentId}.`); + } + if (!INGREDIENT_TYPES[layer.ingredientTypeId] && !["A", "B", "C", "D"].includes(layer.ingredientTypeId)) { + issues.push(`Layer ${layer.id} uses unknown ingredient ${layer.ingredientTypeId}.`); + } + } + + for (const compartment of level.compartments) { + if (compartmentIds.has(compartment.id)) { + issues.push(`Duplicate compartment id ${compartment.id}.`); + } + compartmentIds.add(compartment.id); + } + + for (const fastener of level.fasteners) { + if (fastenerIds.has(fastener.id)) { + issues.push(`Duplicate fastener id ${fastener.id}.`); + } + fastenerIds.add(fastener.id); + if (!runtime.stackMap.has(fastener.stackId)) { + issues.push(`Fastener ${fastener.id} uses unknown stack ${fastener.stackId}.`); + } + + for (const layerId of fastener.controlledLayerIds) { + if (!runtime.layerMap.has(layerId)) { + issues.push(`Fastener ${fastener.id} references unknown layer ${layerId}.`); + } + } + + for (const dependencyId of fastener.dependencyIds) { + if (!runtime.fastenerMap.has(dependencyId)) { + issues.push(`Fastener ${fastener.id} depends on unknown fastener ${dependencyId}.`); + } + } + + if (fastener.type === "pick" && !runtime.layerMap.has(fastener.anchorLayerId)) { + issues.push(`Pick ${fastener.id} references unknown anchor layer ${fastener.anchorLayerId}.`); + } + + if (fastener.type === "band") { + if (fastener.controlledLayerIds.length < 1 || fastener.controlledLayerIds.length > 3) { + issues.push(`Band ${fastener.id} must wrap 1 to 3 layers.`); + } + + const stack = runtime.stackMap.get(fastener.stackId); + const positions = fastener.controlledLayerIds.map((layerId) => findLayerPositionInStack(stack, layerId)); + const contiguous = positions.every( + (position, index) => index === 0 || position === positions[index - 1] + 1 + ); + if (!contiguous) { + issues.push(`Band ${fastener.id} must wrap consecutive layers in its stack.`); + } + } + + if (fastener.type === "divider") { + const stack = runtime.stackMap.get(fastener.stackId); + const dividerExists = stack.items.some((item) => item.kind === "divider" && item.id === fastener.id); + if (!dividerExists) { + issues.push(`Divider ${fastener.id} does not match a divider item in ${fastener.stackId}.`); + } + } + } + + for (const layer of level.layers) { + for (const blockerId of layer.blockerIds) { + if (!runtime.fastenerMap.has(blockerId)) { + issues.push(`Layer ${layer.id} references unknown blocker ${blockerId}.`); + } + } + } + + for (const compartment of level.compartments) { + const expected = compartment.acceptedSlots.length; + const authored = level.layers.filter((layer) => layer.targetCompartmentId === compartment.id).length; + if (expected !== authored) { + issues.push( + `Compartment ${compartment.id} expects ${expected} ingredient(s) but ${authored} layer(s) target it.` + ); + } + } + + const openingState = createInitialState(level); + if (!collectReadyFasteners(level, openingState, runtime).length) { + issues.push("Level starts with no exposed legal fasteners."); + } + + for (const step of level.tutorialSteps ?? []) { + for (const id of step.highlightedIds) { + if ( + !runtime.fastenerMap.has(id) && + !runtime.layerMap.has(id) && + !runtime.compartmentMap.has(id) + ) { + issues.push(`Tutorial step ${step.id} highlights unknown id ${id}.`); + } + } + } + + const families = new Set(level.layers.map((layer) => layer.ingredientTypeId)); + if (families.size > 5 && level.kind === "campaign") { + issues.push("Levels should limit visible ingredient families to 5."); + } + + const solution = solveLevel(level, runtime); + if (!solution.ok) { + issues.push(solution.reason); + } + + return { + ok: issues.length === 0, + issues, + solution + }; +} diff --git a/bento-board/styles.css b/bento-board/styles.css new file mode 100644 index 0000000..bc82d7b --- /dev/null +++ b/bento-board/styles.css @@ -0,0 +1,745 @@ +:root { + --ink: #2a1d18; + --ink-soft: #5e4a40; + --cream: #f5ecdf; + --shell: #fbf6ef; + --wood-1: #efe3d1; + --wood-2: #d7b898; + --wood-3: #b78262; + --lacquer: #7d3e30; + --shadow: 0 18px 38px rgba(66, 39, 28, 0.16); + --line: rgba(73, 45, 32, 0.14); +} + +* { + box-sizing: border-box; +} + +html, +body { + margin: 0; + min-height: 100%; +} + +body { + font-family: "Avenir Next Condensed", "Trebuchet MS", sans-serif; + color: var(--ink); + background: + radial-gradient(circle at top, rgba(255, 255, 255, 0.45), transparent 34%), + linear-gradient(180deg, #f6ead8 0%, #ecd7bb 42%, #d0a382 100%); + background-attachment: fixed; +} + +body::before { + content: ""; + position: fixed; + inset: 0; + pointer-events: none; + background: + repeating-linear-gradient( + 115deg, + rgba(115, 76, 51, 0.035) 0, + rgba(115, 76, 51, 0.035) 12px, + transparent 12px, + transparent 28px + ); + opacity: 0.6; +} + +button { + font: inherit; + cursor: pointer; +} + +#app { + position: relative; + z-index: 1; +} + +.home-shell, +.play-shell { + width: min(100%, 480px); + margin: 0 auto; + padding: 18px 16px 32px; +} + +.home-shell > *, +.play-shell > * { + animation: lift-in 0.42s ease both; +} + +.eyebrow { + display: inline-block; + font-size: 0.74rem; + letter-spacing: 0.14em; + text-transform: uppercase; + color: rgba(61, 40, 29, 0.72); +} + +.hero-card, +.menu-card, +.journal-panel, +.play-hud, +.recipe-ribbon, +.message-strip, +.callout-card, +.stack-lane, +.lunchbox-tray, +.modal-card { + border: 1px solid var(--line); + border-radius: 24px; + box-shadow: var(--shadow); +} + +.hero-card, +.menu-card, +.journal-panel, +.play-hud, +.message-strip, +.callout-card, +.lunchbox-tray, +.modal-card { + background: rgba(250, 245, 236, 0.9); + backdrop-filter: blur(8px); +} + +.hero-card { + display: grid; + gap: 18px; + padding: 22px; + background: + radial-gradient(circle at top right, rgba(204, 113, 75, 0.22), transparent 36%), + linear-gradient(160deg, rgba(255, 255, 255, 0.8), rgba(241, 230, 214, 0.95)); +} + +.hero-card h1 { + margin: 6px 0 8px; + font-family: "Iowan Old Style", "Palatino Linotype", serif; + font-size: 2.4rem; + line-height: 0.95; +} + +.hero-card p, +.menu-card p, +.journal-card span, +.message-strip p, +.callout-card p, +.modal-card p { + margin: 0; + color: var(--ink-soft); + line-height: 1.35; +} + +.hero-card__stats { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 10px; +} + +.hero-card__stats div, +.menu-card__meter { + padding: 12px 14px; + border-radius: 18px; + background: rgba(255, 255, 255, 0.6); + border: 1px solid rgba(112, 76, 55, 0.12); +} + +.hero-card__stats strong, +.menu-card__meter strong, +.journal-panel__head h2, +.play-hud h1, +.modal-card h2, +.lunchbox-tray__head h2 { + display: block; + font-family: "Iowan Old Style", "Palatino Linotype", serif; +} + +.hero-card__stats strong, +.menu-card__meter strong { + font-size: 1.3rem; +} + +.hero-card__stats span, +.menu-card__meter span, +.journal-card strong, +.compartment__head span:last-child, +.stack-lane__head span:last-child { + color: var(--ink-soft); +} + +.menu-grid { + display: grid; + gap: 16px; + margin-top: 16px; +} + +.menu-card { + position: relative; + overflow: hidden; + padding: 18px; + background: + radial-gradient(circle at top right, color-mix(in srgb, var(--card-accent, #cc714b) 22%, white), transparent 34%), + linear-gradient(160deg, rgba(255, 255, 255, 0.88), rgba(242, 232, 218, 0.95)); +} + +.menu-card--daily { + --card-accent: #7e6e52; +} + +.menu-card__head, +.journal-panel__head, +.play-hud__meta, +.play-hud__status, +.lunchbox-tray__head, +.compartment__head, +.stack-lane__head { + display: flex; + align-items: start; + justify-content: space-between; + gap: 12px; +} + +.level-pill-grid { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 10px; + margin-top: 14px; +} + +.level-pill, +.daily-button, +.journal-panel__head button, +.play-hud__buttons button, +.back-button, +.modal-card__actions button { + border: 0; + border-radius: 16px; + background: linear-gradient(180deg, #7f4d3c, #61382d); + color: white; + padding: 12px 14px; + box-shadow: 0 10px 18px rgba(72, 39, 29, 0.18); +} + +.level-pill { + display: flex; + flex-direction: column; + align-items: start; + gap: 4px; + padding: 12px; + background: rgba(255, 255, 255, 0.7); + color: var(--ink); + border: 1px solid rgba(96, 66, 52, 0.12); + box-shadow: none; +} + +.level-pill small { + color: var(--ink-soft); +} + +.level-pill.is-cleared { + background: linear-gradient(180deg, rgba(205, 238, 203, 0.92), rgba(173, 212, 170, 0.9)); +} + +.level-pill.is-locked, +.daily-button:disabled { + opacity: 0.45; + cursor: not-allowed; +} + +.journal-panel { + margin-top: 16px; + padding: 18px; +} + +.journal-strip { + display: grid; + grid-auto-flow: column; + grid-auto-columns: minmax(180px, 1fr); + gap: 12px; + overflow-x: auto; + padding-bottom: 2px; +} + +.journal-card { + min-height: 112px; + padding: 16px; + border-radius: 18px; + border: 1px solid rgba(96, 66, 52, 0.12); + background: + linear-gradient(150deg, rgba(255, 255, 255, 0.92), rgba(243, 233, 216, 0.95)); +} + +.journal-card.is-empty { + opacity: 0.7; +} + +.play-shell { + display: grid; + gap: 14px; +} + +.play-hud { + padding: 18px; + background: + radial-gradient(circle at top right, color-mix(in srgb, var(--theme-accent, #cc714b) 25%, white), transparent 34%), + linear-gradient(160deg, rgba(255, 255, 255, 0.88), rgba(242, 232, 218, 0.95)); +} + +.play-hud h1 { + margin: 3px 0 0; + font-size: 1.7rem; + line-height: 1; +} + +.play-hud__meta, +.play-hud__status, +.play-hud__buttons { + margin-top: 12px; +} + +.play-hud__buttons { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 10px; +} + +.back-button { + padding-inline: 16px; +} + +.chip-row, +.hud-pips { + display: flex; + flex-wrap: wrap; + gap: 8px; +} + +.mode-chip, +.hud-pip { + display: inline-flex; + align-items: center; + justify-content: center; + min-height: 32px; + padding: 0 12px; + border-radius: 999px; + border: 1px solid rgba(97, 56, 45, 0.12); + background: rgba(255, 255, 255, 0.72); + color: var(--ink-soft); +} + +.hud-pip.is-current { + background: color-mix(in srgb, var(--theme-accent, #cc714b) 16%, white); + color: var(--ink); +} + +.hud-pip.is-cleared { + background: rgba(193, 229, 190, 0.88); + color: #355434; +} + +.recipe-ribbon { + display: grid; + grid-auto-flow: column; + grid-auto-columns: minmax(150px, 1fr); + gap: 12px; + overflow-x: auto; + padding: 14px; + background: + linear-gradient(135deg, rgba(255, 255, 255, 0.75), rgba(242, 232, 218, 0.86)); +} + +.recipe-card, +.compartment { + border-radius: 18px; + border: 1px solid rgba(96, 66, 52, 0.12); + background: rgba(255, 255, 255, 0.72); +} + +.recipe-card { + min-height: 96px; + padding: 12px; +} + +.recipe-card header { + display: flex; + justify-content: space-between; + gap: 8px; + margin-bottom: 10px; +} + +.recipe-card__pips, +.compartment__slots { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(46px, 1fr)); + gap: 8px; +} + +.slot-pip, +.compartment__slot { + display: inline-flex; + align-items: center; + justify-content: center; + min-height: 44px; + border-radius: 14px; + border: 1px dashed rgba(97, 56, 45, 0.22); + background: rgba(251, 247, 241, 0.86); + color: rgba(73, 45, 32, 0.64); +} + +.slot-pip.is-filled, +.compartment__slot.is-filled { + border-style: solid; + background: linear-gradient(180deg, rgba(240, 232, 220, 0.95), rgba(216, 198, 174, 0.95)); + color: var(--ink); +} + +.message-strip, +.callout-card { + padding: 14px 16px; +} + +.stack-field { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(144px, 1fr)); + gap: 12px; +} + +.stack-lane { + padding: 14px; + background: + radial-gradient(circle at top right, color-mix(in srgb, var(--stack-accent) 18%, white), transparent 34%), + linear-gradient(180deg, rgba(244, 234, 220, 0.95), rgba(230, 211, 191, 0.96)); +} + +.stack-card { + position: relative; + min-height: 274px; + margin-top: 12px; + border-radius: 20px; + background: + linear-gradient(180deg, rgba(120, 75, 49, 0.08), rgba(107, 69, 45, 0.16)), + linear-gradient(180deg, rgba(255, 255, 255, 0.82), rgba(241, 230, 212, 0.95)); + border: 1px solid rgba(91, 58, 40, 0.12); + overflow: visible; +} + +.stack-card::after { + content: ""; + position: absolute; + inset: 12px; + border-radius: 16px; + border: 1px dashed rgba(91, 58, 40, 0.12); + pointer-events: none; +} + +.ingredient-layer { + position: absolute; + left: 12px; + right: 12px; + top: calc(22px + (var(--depth) * 54px)); + display: flex; + align-items: center; + gap: 10px; + min-height: 64px; + padding: 12px; + border-radius: 18px; + border: 1px solid color-mix(in srgb, var(--accent), white 60%); + background: + linear-gradient(160deg, color-mix(in srgb, var(--tint), white 40%), color-mix(in srgb, var(--tint), #d7c0a8 15%)); + box-shadow: 0 10px 18px rgba(86, 54, 39, 0.12); + transform: translateY(calc(var(--depth) * 2px)); +} + +.ingredient-layer.is-front { + box-shadow: 0 14px 24px rgba(86, 54, 39, 0.18); +} + +.ingredient-layer__mark { + display: inline-flex; + align-items: center; + justify-content: center; + width: 40px; + height: 40px; + border-radius: 14px; + background: rgba(255, 255, 255, 0.75); + border: 1px solid rgba(91, 58, 40, 0.12); + font-weight: 700; +} + +.ingredient-layer__body { + display: grid; + gap: 4px; + flex: 1; +} + +.ingredient-layer__body span, +.ingredient-layer__blockers { + color: rgba(63, 42, 31, 0.76); + font-size: 0.82rem; +} + +.ingredient-layer__blockers { + min-width: 54px; + text-align: right; +} + +.divider-tab, +.stack-card__count { + position: absolute; + z-index: 5; + left: 50%; + transform: translateX(-50%); + padding: 8px 12px; + border-radius: 999px; + background: rgba(255, 251, 244, 0.92); + border: 1px solid rgba(91, 58, 40, 0.16); + color: var(--ink-soft); +} + +.divider-tab { + top: -12px; +} + +.stack-card__count { + bottom: 12px; +} + +.fastener { + position: absolute; + z-index: 6; + border: 0; + background: transparent; + padding: 0; +} + +.fastener__shape { + display: block; + box-shadow: 0 10px 18px rgba(76, 48, 35, 0.18); +} + +.fastener__label { + position: absolute; + opacity: 0; + pointer-events: none; +} + +.fastener--pick { + left: 50%; + top: calc(16px + (var(--anchor-depth) * 54px)); + transform: translateX(-50%); + width: 56px; + height: 56px; +} + +.fastener--pick .fastener__shape { + width: 56px; + height: 56px; + border-radius: 50%; + background: + radial-gradient(circle at 38% 36%, #fff6dc 0, #fff6dc 18%, #c28448 19%, #8b5633 74%); +} + +.fastener--band { + left: 10px; + right: 10px; + top: calc(24px + (var(--anchor-depth) * 54px)); + height: 64px; +} + +.fastener--band .fastener__shape { + position: relative; + width: 100%; + height: 64px; + border-radius: 18px; + border: 3px solid #6f5146; + background: transparent; +} + +.fastener--band .fastener__shape::before, +.fastener--band .fastener__shape::after { + content: ""; + position: absolute; + top: 14px; + width: 18px; + height: 34px; + border-radius: 10px; + background: linear-gradient(180deg, #f5d7aa, #b98654); +} + +.fastener--band .fastener__shape::before { + left: -8px; +} + +.fastener--band .fastener__shape::after { + right: -8px; +} + +.fastener--divider { + left: 50%; + top: 0; + transform: translateX(-50%); + width: 64px; + height: 48px; +} + +.fastener--divider .fastener__shape { + width: 64px; + height: 48px; + border-radius: 0 0 18px 18px; + background: linear-gradient(180deg, #fff4e0, #e3c59f); +} + +.fastener.is-covered .fastener__shape { + filter: saturate(0.7); + opacity: 0.74; +} + +.fastener.is-blocked .fastener__shape { + outline: 3px solid rgba(161, 112, 87, 0.55); +} + +.fastener.is-ready .fastener__shape { + outline: 3px solid rgba(112, 164, 110, 0.44); +} + +.fastener-bubble { + position: absolute; + left: 50%; + bottom: calc(100% + 8px); + transform: translateX(-50%); + width: max-content; + max-width: 150px; + padding: 8px 10px; + border-radius: 14px; + background: rgba(60, 40, 31, 0.92); + color: white; + font-size: 0.74rem; + line-height: 1.25; + box-shadow: 0 10px 18px rgba(33, 20, 14, 0.2); +} + +.lunchbox-tray { + padding: 16px; +} + +.lunchbox-tray__grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 12px; + margin-top: 14px; +} + +.compartment { + padding: 12px; +} + +.modal-scrim { + position: fixed; + inset: 0; + display: grid; + place-items: center; + padding: 20px; + background: rgba(47, 31, 23, 0.48); + backdrop-filter: blur(8px); +} + +.modal-card { + width: min(100%, 420px); + padding: 22px; +} + +.modal-card h2 { + margin: 8px 0 10px; + font-size: 2rem; + line-height: 0.95; +} + +.modal-card__reward { + display: grid; + gap: 4px; + margin-top: 16px; + padding: 14px; + border-radius: 18px; + background: rgba(255, 255, 255, 0.72); + border: 1px solid rgba(91, 58, 40, 0.12); +} + +.modal-card__actions { + display: grid; + gap: 10px; + margin-top: 18px; +} + +.is-highlighted { + animation: pulse-ring 0.9s ease-in-out infinite alternate; +} + +.ingredient-layer.is-highlighted, +.recipe-card.is-highlighted, +.compartment.is-highlighted { + animation: tray-pulse 0.55s ease; +} + +@keyframes lift-in { + from { + opacity: 0; + transform: translateY(10px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +@keyframes pulse-ring { + from { + transform: translateX(-50%) scale(1); + } + to { + transform: translateX(-50%) scale(1.04); + } +} + +@keyframes tray-pulse { + 0% { + transform: scale(0.98); + box-shadow: 0 0 0 rgba(207, 113, 75, 0); + } + 60% { + transform: scale(1.02); + box-shadow: 0 0 0 10px rgba(207, 113, 75, 0.08); + } + 100% { + transform: scale(1); + box-shadow: 0 0 0 rgba(207, 113, 75, 0); + } +} + +@media (min-width: 700px) { + .home-shell, + .play-shell { + width: min(100%, 940px); + padding-inline: 22px; + } + + .menu-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .hero-card { + grid-template-columns: 2fr 1fr; + align-items: end; + } + + .stack-field { + grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); + } + + .lunchbox-tray__grid { + grid-template-columns: repeat(4, minmax(0, 1fr)); + } + + .modal-card__actions { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } +} diff --git a/bento-board/tests/engine.test.js b/bento-board/tests/engine.test.js new file mode 100644 index 0000000..734b0b5 --- /dev/null +++ b/bento-board/tests/engine.test.js @@ -0,0 +1,315 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { generateDailyLevel, getUnlockedDailyPool } from "../src/daily.js"; +import { HANDCRAFTED_LEVELS } from "../src/data.js"; +import { + applyFastener, + createInitialState, + createRuntime, + evaluateFastener, + findHint, + solveLevel, + undoAction, + validateLevel +} from "../src/engine.js"; + +test("every handcrafted level validates and has a solution", () => { + 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 produce a solution.`); + } +}); + +test("bands stay blocked until all dependency picks clear", () => { + const level = HANDCRAFTED_LEVELS.find((entry) => entry.id === "picnic-02"); + const runtime = createRuntime(level); + const start = createInitialState(level); + + const initialBand = evaluateFastener(level, start, "l2-band-a", runtime); + assert.equal(initialBand.ok, false); + assert.equal(initialBand.status, "blocked"); + + const afterPick = applyFastener(level, start, "l2-pick-a1", runtime); + const readyBand = evaluateFastener(level, afterPick, "l2-band-a", runtime); + assert.equal(readyBand.ok, true); + assert.equal(readyBand.status, "ready"); +}); + +test("dividers stay covered until they reach the stack mouth", () => { + const level = HANDCRAFTED_LEVELS.find((entry) => entry.id === "picnic-03"); + const runtime = createRuntime(level); + const start = createInitialState(level); + + const initialDivider = evaluateFastener(level, start, "l3-divider-a", runtime); + assert.equal(initialDivider.ok, false); + assert.equal(initialDivider.status, "covered"); + + const afterPick = applyFastener(level, start, "l3-pick-a1", runtime); + const readyDivider = evaluateFastener(level, afterPick, "l3-divider-a", runtime); + assert.equal(readyDivider.ok, true); +}); + +test("undo restores the previous successful state including chain results", () => { + const level = HANDCRAFTED_LEVELS.find((entry) => entry.id === "picnic-02"); + const runtime = createRuntime(level); + const start = createInitialState(level); + const afterPick = applyFastener(level, start, "l2-pick-a1", runtime); + const afterBand = applyFastener(level, afterPick, "l2-band-a", runtime); + const undone = undoAction(afterBand); + + assert.deepEqual(undone.removedFastenerIds, ["l2-pick-a1"]); + assert.deepEqual(undone.settledLayerIds, []); + assert.equal(undone.message, "Last successful removal undone."); +}); + +test("overflow fail triggers when a freed ingredient has no open slot", () => { + const level = { + id: "overflow-fixture", + kind: "campaign", + number: 99, + packId: "picnic-basics", + title: "Overflow Fixture", + beat: "Fixture", + mechanics: [], + rewardId: "reward-01", + journalTitle: "Fixture", + journalCaption: "Fixture", + compartments: [ + { + id: "egg-box", + label: "Tamago Cup", + acceptedSlots: ["tamago"], + artSkin: "coral" + } + ], + stacks: [ + { + id: "of-stack-a", + laneIndex: 0, + title: "Overflow A", + accent: "#c77755", + exitPosition: "down", + items: [{ kind: "layer", id: "of-a1" }] + }, + { + id: "of-stack-b", + laneIndex: 1, + title: "Overflow B", + accent: "#9c6a4f", + exitPosition: "down", + items: [{ kind: "layer", id: "of-b1" }] + } + ], + layers: [ + { + id: "of-a1", + kind: "layer", + ingredientTypeId: "tamago", + targetCompartmentId: "egg-box", + blockerIds: ["of-pick-a1"], + artVariant: "strip", + stackId: "of-stack-a" + }, + { + id: "of-b1", + kind: "layer", + ingredientTypeId: "tamago", + targetCompartmentId: "egg-box", + blockerIds: ["of-pick-b1"], + artVariant: "strip", + stackId: "of-stack-b" + } + ], + fasteners: [ + { + id: "of-pick-a1", + type: "pick", + stackId: "of-stack-a", + label: "Overflow pick A", + anchorLayerId: "of-a1", + controlledLayerIds: ["of-a1"], + dependencyIds: [], + overlappingFastenerIds: [], + visibilityRule: "front-layer-head", + hitTarget: { width: 56, height: 56 } + }, + { + id: "of-pick-b1", + type: "pick", + stackId: "of-stack-b", + label: "Overflow pick B", + anchorLayerId: "of-b1", + controlledLayerIds: ["of-b1"], + dependencyIds: [], + overlappingFastenerIds: [], + visibilityRule: "front-layer-head", + hitTarget: { width: 56, height: 56 } + } + ], + tutorialSteps: [] + }; + + const runtime = createRuntime(level); + const start = createInitialState(level); + const first = applyFastener(level, start, "of-pick-a1", runtime); + const second = applyFastener(level, first, "of-pick-b1", runtime); + + assert.equal(second.failed, true); + assert.equal(second.failKind, "overflow"); + assert.match(second.message, /has no room/); +}); + +test("jam fail triggers when ingredients remain but no fastener is exposed", () => { + const level = { + id: "jam-fixture", + kind: "campaign", + number: 100, + packId: "picnic-basics", + title: "Jam Fixture", + beat: "Fixture", + mechanics: [], + rewardId: "reward-01", + journalTitle: "Fixture", + journalCaption: "Fixture", + compartments: [ + { + id: "rice-box", + label: "Rice Bay", + acceptedSlots: ["rice"], + artSkin: "amber" + }, + { + id: "egg-box", + label: "Tamago Cup", + acceptedSlots: ["tamago", "tamago"], + artSkin: "coral" + } + ], + stacks: [ + { + id: "jam-stack-a", + laneIndex: 0, + title: "Safe Stack", + accent: "#c77755", + exitPosition: "down", + items: [{ kind: "layer", id: "jam-a1" }] + }, + { + id: "jam-stack-b", + laneIndex: 1, + title: "Jammed Stack", + accent: "#9c6a4f", + exitPosition: "down", + items: [ + { kind: "layer", id: "jam-b1" }, + { kind: "layer", id: "jam-b2" } + ] + } + ], + layers: [ + { + id: "jam-a1", + kind: "layer", + ingredientTypeId: "rice", + targetCompartmentId: "rice-box", + blockerIds: ["jam-pick-a1"], + artVariant: "strip", + stackId: "jam-stack-a" + }, + { + id: "jam-b1", + kind: "layer", + ingredientTypeId: "tamago", + targetCompartmentId: "egg-box", + blockerIds: ["jam-band-b"], + artVariant: "strip", + stackId: "jam-stack-b" + }, + { + id: "jam-b2", + kind: "layer", + ingredientTypeId: "tamago", + targetCompartmentId: "egg-box", + blockerIds: ["jam-pick-b2", "jam-band-b"], + artVariant: "strip", + stackId: "jam-stack-b" + } + ], + fasteners: [ + { + id: "jam-pick-a1", + type: "pick", + stackId: "jam-stack-a", + label: "Safe pick", + anchorLayerId: "jam-a1", + controlledLayerIds: ["jam-a1"], + dependencyIds: [], + overlappingFastenerIds: [], + visibilityRule: "front-layer-head", + hitTarget: { width: 56, height: 56 } + }, + { + id: "jam-pick-b2", + type: "pick", + stackId: "jam-stack-b", + label: "Hidden pick", + anchorLayerId: "jam-b2", + controlledLayerIds: ["jam-b2"], + dependencyIds: [], + overlappingFastenerIds: [], + visibilityRule: "front-layer-head", + hitTarget: { width: 56, height: 56 } + }, + { + id: "jam-band-b", + type: "band", + stackId: "jam-stack-b", + label: "Jammed band", + anchorLayerId: null, + controlledLayerIds: ["jam-b1", "jam-b2"], + dependencyIds: ["jam-pick-b2"], + overlappingFastenerIds: [], + visibilityRule: "front-segment-tabs", + hitTarget: { width: 60, height: 60 } + } + ], + tutorialSteps: [] + }; + + const runtime = createRuntime(level); + const start = createInitialState(level); + const afterSafePick = applyFastener(level, start, "jam-pick-a1", runtime); + + assert.equal(afterSafePick.failed, true); + assert.equal(afterSafePick.failKind, "jammed"); + assert.match(afterSafePick.message, /No exposed fasteners remain/); +}); + +test("hints use a solvable next move", () => { + const level = HANDCRAFTED_LEVELS.find((entry) => entry.id === "market-10"); + const runtime = createRuntime(level); + const start = createInitialState(level); + const hint = findHint(level, start, runtime); + const solution = solveLevel(level, runtime, start); + + assert.ok(hint.action); + assert.equal(hint.action.id, solution.sequence[0].id); + assert.match(hint.message, /Hint:/); +}); + +test("daily generation is deterministic and uses the unlocked pool", () => { + const pool = getUnlockedDailyPool(12); + const first = generateDailyLevel("2026-05-05", { unlockedIngredientIds: pool }); + const second = generateDailyLevel("2026-05-05", { unlockedIngredientIds: pool }); + + assert.deepEqual(first, second); + const validation = validateLevel(first); + assert.equal(validation.ok, true, validation.issues.join(" | ")); + assert.equal(validation.solution.ok, true); + assert.ok(first.layers.length >= 9 && first.layers.length <= 10); + assert.ok(first.stacks.length >= 4 && first.stacks.length <= 5); + assert.ok(first.compartments.length >= 3 && first.compartments.length <= 4); + assert.ok(first.layers.every((layer) => pool.includes(layer.ingredientTypeId))); +});