diff --git a/.github/workflows/mosaic-press-preview.yml b/.github/workflows/mosaic-press-preview.yml new file mode 100644 index 0000000..218eeeb --- /dev/null +++ b/.github/workflows/mosaic-press-preview.yml @@ -0,0 +1,170 @@ +name: Mosaic Press Preview Deploy + +on: + pull_request: + types: + - opened + - reopened + - synchronize + - closed + paths: + - "mosaic-press/**" + - ".github/workflows/mosaic-press-preview.yml" + push: + branches: + - main + paths: + - "mosaic-press/**" + - ".github/workflows/mosaic-press-preview.yml" + +permissions: + contents: write + pull-requests: write + +concurrency: + group: mosaic-press-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/pr-${{ github.event.pull_request.number }} + PREVIEW_URL: https://${{ github.repository_owner }}.github.io/${{ github.event.repository.name }}/previews/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 Mosaic Press + working-directory: mosaic-press + 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: ./mosaic-press + destination_dir: ${{ env.PREVIEW_DIR }} + keep_files: true + enable_jekyll: false + + - name: Comment preview URL on pull request + uses: actions/github-script@v7 + env: + PREVIEW_DIR: ${{ env.PREVIEW_DIR }} + PREVIEW_URL: ${{ env.PREVIEW_URL }} + with: + script: | + const marker = ""; + const body = `${marker} + Mosaic Press preview deployed. + + URL: ${process.env.PREVIEW_URL} + Commit: ${context.sha.slice(0, 7)} + Path: \`${process.env.PREVIEW_DIR}/\` + + If this is the first deployment, enable GitHub Pages once in repository settings and point it at the \`gh-pages\` branch.`; + + const { data: comments } = await github.rest.issues.listComments({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number + }); + + const existing = comments.find( + (comment) => comment.user.type === "Bot" && comment.body.includes(marker) + ); + + if (existing) { + await github.rest.issues.updateComment({ + owner: context.repo.owner, + repo: context.repo.repo, + comment_id: existing.id, + body + }); + } else { + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number, + body + }); + } + + cleanup-preview: + if: github.event_name == 'pull_request' && github.event.action == 'closed' && github.event.pull_request.head.repo.full_name == github.repository + runs-on: ubuntu-latest + env: + PREVIEW_DIR: previews/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 Mosaic Press 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: mosaic-press + STABLE_URL: https://${{ github.repository_owner }}.github.io/${{ github.event.repository.name }}/mosaic-press/ + 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 Mosaic Press + working-directory: mosaic-press + 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: ./mosaic-press + destination_dir: ${{ env.STABLE_DIR }} + keep_files: true + enable_jekyll: false + + - name: Print stable URL + run: echo "Stable Mosaic Press build deployed to ${STABLE_URL}" diff --git a/mosaic-press/README.md b/mosaic-press/README.md new file mode 100644 index 0000000..235fe46 --- /dev/null +++ b/mosaic-press/README.md @@ -0,0 +1,46 @@ +# Mosaic Press + +Standalone first-playable vertical slice for the Mosaic Press release-order mosaic puzzle. + +## Run + +Open `index.html` in a browser, or serve the directory with any static file server. + +## Test + +```bash +npm test +``` + +## Verify + +```bash +npm run verify +``` + +## What Is Included + +- Data-driven Mosaic Press rules for press stacks, clamps, wax tabs, paper spacers, row entry lanes, gutter overflow, no-move fail, undo, hint, restart, win, and daily commission entry +- `12` handcrafted campaign cards in `2` packs of `6`, plus `1` deterministic daily generator keyed by UTC `YYYY-MM-DD` +- Portrait-first static HTML, CSS, and JS slice with gallery wrapper, card preview ribbon, press bench, tray rows, fail modal, and reward flow +- Validator-backed authoring that checks level shape limits, right-entry gating, repeated-mechanic claims, opening productive fasteners, and at least one completion path + +## Implementation Notes + +- Readability limits: the slice keeps rows to `2` to `4` cells, stacks to `2` to `5`, and active hit targets to the front strip only. Dense late levels still need phone QA, especially level `11`, where visual crowding is deliberate. +- Hit-target handling: clamps, wax tabs, and spacers render as oversized buttons anchored to authored slot positions on the front strip only. Covered fasteners still render when the design expects the player to notice them, but they reject taps with local copy instead of spending a penalty. +- Solver and validator needs: the current validator uses BFS over fastener removals because strip motion and destinations are deterministic. If production later adds manual row choice, dragging, irregular shard clusters, or buffered gutter parking, the authoring pipeline will need a deeper state-space solver and better diagnostics. + +## QA Focus + +- Verify `Gutter Overflow` reads as fair on level `5`, level `10`, and the daily templates. +- Verify `No Move` only triggers when no exposed fastener can lead to an immediate successful placement. +- Check narrow-phone readability for fastener overlap, row entry direction, and target-card preview cells. +- Confirm daily commission unlocks after level `6` and uses UTC date rollover rather than local device midnight. + +## Preview Deployment + +- GitHub Actions workflow: `.github/workflows/mosaic-press-preview.yml` +- Pull requests deploy previews to `https://.github.io//previews/pr-/` +- `main` deploys the stable build to `https://.github.io//mosaic-press/` +- The first deployment still requires enabling GitHub Pages for the `gh-pages` branch in repository settings diff --git a/mosaic-press/index.html b/mosaic-press/index.html new file mode 100644 index 0000000..da168d8 --- /dev/null +++ b/mosaic-press/index.html @@ -0,0 +1,13 @@ + + + + + + Mosaic Press + + + +
+ + + diff --git a/mosaic-press/package.json b/mosaic-press/package.json new file mode 100644 index 0000000..658372a --- /dev/null +++ b/mosaic-press/package.json @@ -0,0 +1,11 @@ +{ + "name": "mosaic-press", + "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/mosaic-press/src/app.js b/mosaic-press/src/app.js new file mode 100644 index 0000000..c542b88 --- /dev/null +++ b/mosaic-press/src/app.js @@ -0,0 +1,626 @@ +import { DAILY_THEME, DAILY_UNLOCK_LEVEL, HANDCRAFTED_LEVELS, PACKS, STORAGE_KEY } from "./data.js"; +import { dailySeedFromDate, generateDailyLevel } from "./daily.js"; +import { + applyAction, + collectVisibleFasteners, + createInitialState, + createRuntime, + findHint, + getFillRatio, + getFrontStrip, + restartLevel, + undoAction +} from "./engine.js"; + +const root = document.querySelector("#app"); + +const appState = { + screen: "gallery", + mode: "ftue", + levelIndex: 0, + dailySeed: dailySeedFromDate(), + progress: loadProgress(), + level: null, + runtime: null, + puzzle: null, + modal: null +}; + +function loadProgress() { + try { + const parsed = JSON.parse(localStorage.getItem(STORAGE_KEY) ?? "{}"); + return { + clearedLevelIds: Array.isArray(parsed.clearedLevelIds) ? parsed.clearedLevelIds : [], + dailySeeds: Array.isArray(parsed.dailySeeds) ? parsed.dailySeeds : [], + rewards: Array.isArray(parsed.rewards) ? parsed.rewards : [] + }; + } catch { + return { + clearedLevelIds: [], + dailySeeds: [], + rewards: [] + }; + } +} + +function saveProgress() { + localStorage.setItem(STORAGE_KEY, JSON.stringify(appState.progress)); +} + +function isLevelCleared(level) { + return appState.progress.clearedLevelIds.includes(level.id); +} + +function isDailyCleared(seed = appState.dailySeed) { + return appState.progress.dailySeeds.includes(seed); +} + +function isLevelUnlocked(index) { + if (index === 0) { + return true; + } + return appState.progress.clearedLevelIds.includes(HANDCRAFTED_LEVELS[index - 1].id); +} + +function isDailyUnlocked() { + return appState.progress.clearedLevelIds.includes(HANDCRAFTED_LEVELS[DAILY_UNLOCK_LEVEL - 1].id); +} + +function recordWin(level) { + if (level.kind === "daily") { + if (!appState.progress.dailySeeds.includes(level.seed)) { + appState.progress.dailySeeds.push(level.seed); + } + } else if (!appState.progress.clearedLevelIds.includes(level.id)) { + appState.progress.clearedLevelIds.push(level.id); + } + + if (level.reward?.title && !appState.progress.rewards.includes(level.reward.title)) { + appState.progress.rewards.push(level.reward.title); + } + + saveProgress(); +} + +function currentLevel() { + return appState.level; +} + +function currentPack() { + if (!appState.level || appState.level.kind === "daily") { + return DAILY_THEME; + } + return PACKS.find((pack) => pack.id === appState.level.packId) ?? PACKS[0]; +} + +function levelLabel(level = currentLevel()) { + if (!level) { + return ""; + } + return level.kind === "daily" ? `Daily • ${level.seed}` : `Level ${level.number}`; +} + +function loadLevel({ mode = "ftue", levelIndex = 0, showStart = true, dailySeed = appState.dailySeed } = {}) { + appState.mode = mode; + appState.levelIndex = levelIndex; + appState.dailySeed = dailySeed; + appState.level = mode === "daily" ? generateDailyLevel(dailySeed) : HANDCRAFTED_LEVELS[levelIndex]; + appState.runtime = createRuntime(appState.level); + appState.puzzle = createInitialState(appState.level); + appState.screen = "level"; + appState.modal = showStart ? "start" : null; + document.title = `Mosaic Press • ${levelLabel(appState.level)}`; + render(); +} + +function backToGallery() { + appState.screen = "gallery"; + appState.modal = null; + document.title = "Mosaic Press"; + render(); +} + +function openNextLevel() { + if (appState.level.kind === "daily") { + backToGallery(); + return; + } + + if (appState.levelIndex < HANDCRAFTED_LEVELS.length - 1) { + loadLevel({ levelIndex: appState.levelIndex + 1, showStart: true }); + return; + } + + backToGallery(); +} + +function applyHint() { + const hint = findHint(appState.level, appState.puzzle, appState.runtime); + appState.puzzle = { + ...appState.puzzle, + hintAction: hint.action, + message: hint.message + }; + render(); +} + +function performUndo() { + appState.puzzle = undoAction(appState.puzzle); + if (!appState.puzzle.failed) { + appState.modal = null; + } + render(); +} + +function performRestart(showStart = false) { + appState.puzzle = restartLevel(appState.level); + appState.modal = showStart ? "start" : null; + render(); +} + +function handleFastener(id) { + appState.puzzle = applyAction( + appState.level, + appState.puzzle, + { type: "fastener", id }, + appState.runtime + ); + + if (appState.puzzle.completed) { + recordWin(appState.level); + appState.modal = "win"; + } else if (appState.puzzle.failed) { + appState.modal = "fail"; + } + + render(); +} + +function getPackLevels(packId) { + return HANDCRAFTED_LEVELS.filter((level) => level.packId === packId); +} + +function getPackProgress(packId) { + const levels = getPackLevels(packId); + const cleared = levels.filter((level) => isLevelCleared(level)).length; + return { + cleared, + total: levels.length + }; +} + +function formatFastenerLabel(type) { + return { + clamp: "Clamp", + wax: "Wax", + spacer: "Spacer" + }[type]; +} + +function fastenerGlyph(type) { + return { + clamp: "C", + wax: "W", + spacer: "S" + }[type]; +} + +function rowEntryGlyph(entry) { + return entry === "left" ? ">>" : "<<"; +} + +function visibleFastenerMap() { + return new Map( + collectVisibleFasteners(appState.level, appState.puzzle, appState.runtime).map((entry) => [ + entry.fastener.id, + entry + ]) + ); +} + +function cellMap() { + return new Map(appState.level.tray.cells.map((cell) => [cell.id, cell])); +} + +function renderGallery() { + return ` + + `; +} + +function renderPackSection(pack) { + const progress = getPackProgress(pack.id); + const levels = getPackLevels(pack.id); + + return ` + + `; +} + +function renderLevel() { + const level = currentLevel(); + const pack = currentPack(); + const fasteners = visibleFastenerMap(); + const cellsById = cellMap(); + const progress = Math.round(getFillRatio(level, appState.puzzle, appState.runtime) * 100); + + return ` +
+
+
+ +
+

${pack.title}

+

${levelLabel(level)}

+
+
+
+ ${progress}% filled + + + +
+
+
+
+

${level.title}

+

${level.card.caption}

+
+
+ ${renderPreviewRows(level, cellsById)} +
+
+
+
+ ${level.stacks.map((stack) => renderStack(stack, fasteners)).join("")} +
+ +
+
+ ${level.tray.rows.map((row) => renderRow(row, cellsById)).join("")} +
+ ${renderModal()} +
+ `; +} + +function renderPreviewRows(level, cellsById) { + return level.tray.rows + .map( + (row) => ` +
+ ${rowEntryGlyph(row.entry)} + ${row.cellIds + .map((cellId) => { + const cell = cellsById.get(cellId); + const filled = appState.puzzle.filledCells.includes(cellId); + return ` +
+ ${cell.short} +
+ `; + }) + .join("")} +
+ ` + ) + .join(""); +} + +function renderStack(stack, fasteners) { + const unresolved = stack.stripIds + .map((stripId) => appState.runtime.stripMap.get(stripId)) + .filter((strip) => !appState.puzzle.resolvedStrips.includes(strip.id)); + const frontStrip = getFrontStrip(appState.runtime, appState.puzzle, stack.id); + + return ` +
+
+ Stack ${stack.laneIndex + 1} + ${stack.title} +
+
+ ${unresolved + .map((strip, index) => renderStrip(strip, index, strip.id === frontStrip?.id, fasteners)) + .reverse() + .join("")} + ${!unresolved.length ? '
Cleared
' : ""} +
+
+ `; +} + +function renderStrip(strip, index, isFront, fasteners) { + const hintId = appState.puzzle.hintAction?.id; + const stackDepth = Math.max(0, 18 - index * 6); + + return ` +
+
+ ${strip.name} + ${appState.runtime.rowMap.get(strip.rowId)?.title ?? strip.rowId} +
+
+ ${strip.shards + .map( + (shard) => ` +
+ ${shard.short} +
+ ` + ) + .join("")} +
+ ${ + isFront + ? strip.blockerIds + .map((fastenerId) => { + const entry = fasteners.get(fastenerId); + if (!entry) { + return ""; + } + return ` + + `; + }) + .join("") + : "" + } +
+ `; +} + +function renderRow(row, cellsById) { + const overflowPath = appState.puzzle.lastEvent?.type === "overflow" ? appState.puzzle.lastEvent.pathCellIds ?? [] : []; + return ` +
+
+ ${row.title} + ${row.entry === "left" ? "Left entry" : "Right entry"} +
+
+ ${rowEntryGlyph(row.entry)} + ${row.cellIds + .map((cellId) => { + const cell = cellsById.get(cellId); + const filled = appState.puzzle.filledCells.includes(cellId); + return ` +
+ ${cell.short} +
+ `; + }) + .join("")} +
+
+ `; +} + +function renderContextCallout() { + const failTrigger = appState.puzzle.failed ? `fail:${appState.puzzle.failReason}` : null; + const winTrigger = appState.puzzle.completed ? "win" : null; + const callout = currentLevel().callouts.find((entry) => entry.trigger === failTrigger || entry.trigger === winTrigger); + if (!callout) { + return ""; + } + + return `

${callout.text}

`; +} + +function renderModal() { + if (!appState.modal) { + return ""; + } + + if (appState.modal === "start") { + return ` + + `; + } + + if (appState.modal === "fail") { + return ` + + `; + } + + return ` + + `; +} + +function render() { + root.innerHTML = appState.screen === "gallery" ? renderGallery() : renderLevel(); +} + +root.addEventListener("click", (event) => { + const target = event.target.closest("[data-action]"); + if (!target) { + return; + } + + const action = target.dataset.action; + if (action === "open-level") { + loadLevel({ levelIndex: Number(target.dataset.levelIndex), showStart: true }); + return; + } + + if (action === "open-daily") { + if (!isDailyUnlocked()) { + return; + } + loadLevel({ mode: "daily", dailySeed: appState.dailySeed, showStart: true }); + return; + } + + if (action === "back") { + backToGallery(); + return; + } + + if (action === "start-level") { + appState.modal = null; + render(); + return; + } + + if (action === "fastener") { + handleFastener(target.dataset.fastenerId); + return; + } + + if (action === "undo") { + performUndo(); + return; + } + + if (action === "hint") { + appState.modal = appState.modal === "start" ? appState.modal : null; + applyHint(); + return; + } + + if (action === "restart") { + performRestart(false); + return; + } + + if (action === "replay") { + performRestart(true); + return; + } + + if (action === "next-level") { + openNextLevel(); + } +}); + +render(); diff --git a/mosaic-press/src/daily.js b/mosaic-press/src/daily.js new file mode 100644 index 0000000..f411910 --- /dev/null +++ b/mosaic-press/src/daily.js @@ -0,0 +1,195 @@ +import { DAILY_TEMPLATE_RULES, DAILY_THEME, buildLevelFromPlan } from "./data.js"; + +function hashSeed(seed) { + let hash = 1779033703 ^ seed.length; + for (let index = 0; index < seed.length; index += 1) { + hash = Math.imul(hash ^ seed.charCodeAt(index), 3432918353); + hash = (hash << 13) | (hash >>> 19); + } + return () => { + hash = Math.imul(hash ^ (hash >>> 16), 2246822507); + hash = Math.imul(hash ^ (hash >>> 13), 3266489909); + return (hash ^= hash >>> 16) >>> 0; + }; +} + +function mulberry32(seed) { + return () => { + let value = (seed += 0x6d2b79f5); + value = Math.imul(value ^ (value >>> 15), value | 1); + value ^= value + Math.imul(value ^ (value >>> 7), value | 61); + return ((value ^ (value >>> 14)) >>> 0) / 4294967296; + }; +} + +function shuffle(values, random) { + const copy = [...values]; + for (let index = copy.length - 1; index > 0; index -= 1) { + const swapIndex = Math.floor(random() * (index + 1)); + [copy[index], copy[swapIndex]] = [copy[swapIndex], copy[index]]; + } + return copy; +} + +function fastener(id, type, slot, config = {}) { + return { + id, + type, + slot, + blockedBy: [...(config.blockedBy ?? [])], + blockedReason: config.blockedReason ?? null + }; +} + +const DAILY_TEMPLATES = [ + { + title: "Daily Garden Commission", + caption: "A seeded studio order with only left-entry rows and already-unlocked fastener rules.", + beat: "Daily commissions stay deterministic by UTC date so QA can replay the same board.", + rows: [ + { id: "r1", title: "Petal row", entry: "left", segments: ["A", "B"] }, + { id: "r2", title: "Leaf row", entry: "left", segments: ["C", "D"] }, + { id: "r3", title: "Cup row", entry: "left", segments: ["E"] }, + { id: "r4", title: "Seal row", entry: "left", segments: ["F"] } + ], + stacks: [ + { id: "bench-a", title: "Bench A", stripIds: ["A"] }, + { id: "bench-b", title: "Bench B", stripIds: ["B", "E"] }, + { id: "bench-c", title: "Bench C", stripIds: ["C"] }, + { id: "bench-d", title: "Bench D", stripIds: ["D", "F"] } + ], + strips: [ + { id: "A", name: "Petal ribbon", labels: ["Ribbon", "Bloom"], family: "daily-bloom", fasteners: [fastener("a-clamp", "clamp", "left")] }, + { id: "B", name: "Petal cap", labels: ["Cap"], family: "daily-bloom", fasteners: [fastener("b-clamp", "clamp", "right")] }, + { id: "C", name: "Leaf ladder", labels: ["Ladder", "Curl"], family: "daily-leaf", fasteners: [fastener("c-wax", "wax", "top")] }, + { id: "D", name: "Leaf nib", labels: ["Nib"], family: "daily-leaf", fasteners: [fastener("d-clamp", "clamp", "right")] }, + { id: "E", name: "Cup basin", labels: ["Basin", "Lip"], family: "daily-cup", fasteners: [fastener("e-spacer", "spacer", "mouth")] }, + { + id: "F", + name: "Ledger wax", + labels: ["Wax", "Date"], + family: "daily-ledger", + fasteners: [ + fastener("f-clamp", "clamp", "left"), + fastener("f-wax", "wax", "top", { + blockedBy: ["f-clamp"], + blockedReason: "The wax ring is still tucked under the clamp ear." + }) + ] + } + ] + }, + { + title: "Daily Garden Commission", + caption: "A seeded studio order with only left-entry rows and already-unlocked fastener rules.", + beat: "Daily commissions stay deterministic by UTC date so QA can replay the same board.", + rows: [ + { id: "r1", title: "Bloom row", entry: "left", segments: ["A", "B"] }, + { id: "r2", title: "Vine row", entry: "left", segments: ["C", "D"] }, + { id: "r3", title: "Cup row", entry: "left", segments: ["E", "F"] }, + { id: "r4", title: "Tag row", entry: "left", segments: ["G"] } + ], + stacks: [ + { id: "bench-a", title: "Bench A", stripIds: ["A"] }, + { id: "bench-b", title: "Bench B", stripIds: ["B", "E"] }, + { id: "bench-c", title: "Bench C", stripIds: ["C"] }, + { id: "bench-d", title: "Bench D", stripIds: ["D", "F"] }, + { id: "bench-e", title: "Bench E", stripIds: ["G"] } + ], + strips: [ + { id: "A", name: "Bloom panel", labels: ["Panel", "Glow"], family: "daily-bloom", fasteners: [fastener("a-clamp", "clamp", "left")] }, + { id: "B", name: "Bloom cap", labels: ["Cap"], family: "daily-bloom", fasteners: [fastener("b-clamp", "clamp", "right")] }, + { id: "C", name: "Vine stretch", labels: ["Stretch", "Curl"], family: "daily-vine", fasteners: [fastener("c-wax", "wax", "top")] }, + { id: "D", name: "Vine notch", labels: ["Notch"], family: "daily-vine", fasteners: [fastener("d-clamp", "clamp", "right")] }, + { id: "E", name: "Cup body", labels: ["Body", "Rim"], family: "daily-cup", fasteners: [fastener("e-spacer", "spacer", "mouth")] }, + { id: "F", name: "Cup cap", labels: ["Cap"], family: "daily-cup", fasteners: [fastener("f-clamp", "clamp", "left")] }, + { id: "G", name: "Seal ledger", labels: ["Seal", "Date", "Wax"], family: "daily-ledger", fasteners: [fastener("g-clamp", "clamp", "left")] } + ] + }, + { + title: "Daily Garden Commission", + caption: "A seeded studio order with only left-entry rows and already-unlocked fastener rules.", + beat: "Daily commissions stay deterministic by UTC date so QA can replay the same board.", + rows: [ + { id: "r1", title: "Petal row", entry: "left", segments: ["A", "B"] }, + { id: "r2", title: "Leaf row", entry: "left", segments: ["C", "D"] }, + { id: "r3", title: "Cup row", entry: "left", segments: ["E"] }, + { id: "r4", title: "Ledger row", entry: "left", segments: ["F", "G"] } + ], + stacks: [ + { id: "bench-a", title: "Bench A", stripIds: ["A"] }, + { id: "bench-b", title: "Bench B", stripIds: ["B", "E"] }, + { id: "bench-c", title: "Bench C", stripIds: ["C"] }, + { id: "bench-d", title: "Bench D", stripIds: ["D", "F"] }, + { id: "bench-e", title: "Bench E", stripIds: ["G"] } + ], + strips: [ + { id: "A", name: "Petal arc", labels: ["Arc", "Light"], family: "daily-bloom", fasteners: [fastener("a-clamp", "clamp", "left")] }, + { id: "B", name: "Petal cap", labels: ["Cap"], family: "daily-bloom", fasteners: [fastener("b-clamp", "clamp", "right")] }, + { id: "C", name: "Leaf braid", labels: ["Braid", "Curl"], family: "daily-leaf", fasteners: [fastener("c-wax", "wax", "top")] }, + { id: "D", name: "Leaf cap", labels: ["Cap"], family: "daily-leaf", fasteners: [fastener("d-clamp", "clamp", "right")] }, + { + id: "E", + name: "Cup ledger", + labels: ["Cup", "Lip", "Saucer"], + family: "daily-cup", + fasteners: [ + fastener("e-clamp", "clamp", "left"), + fastener("e-spacer", "spacer", "mouth", { + blockedBy: ["e-clamp"], + blockedReason: "The spacer tail is still tucked behind the lifted clamp." + }) + ] + }, + { id: "F", name: "Ledger curve", labels: ["Curve"], family: "daily-ledger", fasteners: [fastener("f-clamp", "clamp", "left")] }, + { id: "G", name: "Wax ledger", labels: ["Wax", "Seal"], family: "daily-ledger", fasteners: [fastener("g-clamp", "clamp", "left")] } + ] + } +]; + +export function dailySeedFromDate(date = new Date()) { + const year = date.getUTCFullYear(); + const month = String(date.getUTCMonth() + 1).padStart(2, "0"); + const day = String(date.getUTCDate()).padStart(2, "0"); + return `${year}-${month}-${day}`; +} + +export function generateDailyLevel(seed = dailySeedFromDate()) { + const random = mulberry32(hashSeed(seed)()); + const template = DAILY_TEMPLATES[Math.floor(random() * DAILY_TEMPLATES.length)]; + const paletteOrder = shuffle(DAILY_TEMPLATE_RULES.allowedPalettes, random); + + const strips = template.strips.map((strip, index) => ({ + id: strip.id, + name: strip.name, + stackId: template.stacks.find((stack) => stack.stripIds.includes(strip.id))?.id ?? template.stacks[0].id, + family: strip.family, + paletteId: paletteOrder[index % paletteOrder.length], + labels: [...strip.labels], + fasteners: strip.fasteners.map((entry) => ({ ...entry })) + })); + + return buildLevelFromPlan({ + id: `daily-${seed}`, + kind: "daily", + seed, + packId: DAILY_THEME.id, + title: `${template.title} • ${seed}`, + caption: template.caption, + beat: template.beat, + mechanics: ["Seeded UTC daily", "Unlocked pack-one fasteners", "First-clear wax ledger stamp"], + callouts: [ + { + trigger: "start", + text: `Daily seed ${seed}. Boards refresh on UTC midnight and stay stable for QA.` + } + ], + reward: { + title: DAILY_THEME.rewardTitle, + detail: `First clear on ${seed} adds a dated wax ledger stamp to the studio book.` + }, + rows: template.rows, + stacks: template.stacks, + strips + }); +} diff --git a/mosaic-press/src/data.js b/mosaic-press/src/data.js new file mode 100644 index 0000000..79fc57d --- /dev/null +++ b/mosaic-press/src/data.js @@ -0,0 +1,1344 @@ +export const STORAGE_KEY = "mosaic-press-progress-v1"; +export const DAILY_UNLOCK_LEVEL = 6; + +export const PACKS = Object.freeze([ + { + id: "garden-tiles", + title: "Garden Tiles", + accent: "#bf6d4d", + gradient: ["#f4eadb", "#e8d4bd", "#c89c73"], + rewardTitle: "Pressed cedar frame" + }, + { + id: "harbor-keepsakes", + title: "Harbor Keepsakes", + accent: "#567c94", + gradient: ["#edf2ef", "#dce7e8", "#9fb8c4"], + rewardTitle: "Salt-glazed frame" + } +]); + +export const DAILY_THEME = Object.freeze({ + id: "daily-commission", + title: "Daily Commission", + accent: "#8d5a4d", + gradient: ["#f2e6db", "#e5d2c7", "#c8a792"], + rewardTitle: "Dated wax ledger stamp" +}); + +export const DAILY_TEMPLATE_RULES = Object.freeze({ + allowedPalettes: ["terracotta", "leaf", "cream", "sky"], + allowedEntries: ["left"], + rowCountRange: [3, 4], + stackCountRange: [4, 5], + cellCountRange: [10, 12], + fastenerCountRange: [6, 8], + seedFormat: "UTC YYYY-MM-DD" +}); + +export const PALETTES = Object.freeze({ + terracotta: { + id: "terracotta", + name: "Terracotta", + shard: "#d58a67", + trim: "#884d39", + grout: "#6a463a", + card: "#f2ddd0", + glaze: "#f9efe8" + }, + leaf: { + id: "leaf", + name: "Leaf Green", + shard: "#8eb17d", + trim: "#4f6a43", + grout: "#465540", + card: "#e3ecdc", + glaze: "#f0f5eb" + }, + cream: { + id: "cream", + name: "Cream", + shard: "#e8d8b8", + trim: "#8e7650", + grout: "#6d5b44", + card: "#f5efdf", + glaze: "#fbf8ef" + }, + sky: { + id: "sky", + name: "Sky Blue", + shard: "#8fb6cf", + trim: "#4d6a87", + grout: "#44586f", + card: "#dfeaf1", + glaze: "#eef5f9" + }, + indigo: { + id: "indigo", + name: "Indigo", + shard: "#6b739e", + trim: "#39415f", + grout: "#343d52", + card: "#dfe2ef", + glaze: "#f0f2fb" + }, + coral: { + id: "coral", + name: "Coral", + shard: "#d9937f", + trim: "#8f4f43", + grout: "#6b453f", + card: "#f3e1d8", + glaze: "#fbf0eb" + }, + sand: { + id: "sand", + name: "Sand", + shard: "#d9bf8f", + trim: "#8a6d46", + grout: "#6d573f", + card: "#f2e8d4", + glaze: "#faf4ea" + }, + seafoam: { + id: "seafoam", + name: "Seafoam", + shard: "#8fc7b8", + trim: "#46766f", + grout: "#3d5e59", + card: "#dff0ea", + glaze: "#eff8f5" + } +}); + +const PACK_BY_ID = Object.fromEntries(PACKS.map((pack) => [pack.id, pack])); +const clone = (value) => JSON.parse(JSON.stringify(value)); + +const ROW_Y = { + 1: [50], + 2: [27, 69], + 3: [20, 48, 76], + 4: [16, 37, 58, 79] +}; + +const CELL_SIZE = { + 2: 21, + 3: 17, + 4: 14 +}; + +const CELL_STEP = { + 2: 24, + 3: 18, + 4: 15 +}; + +function shortCode(label) { + const cleaned = label.replace(/[^a-z0-9]/gi, "").toUpperCase(); + return cleaned.slice(0, 4) || "TILE"; +} + +function shardLabel(stripName, label, family, variant) { + return { + title: `${stripName} ${label}`, + short: shortCode(label), + family, + variant + }; +} + +function strip(config) { + return { + id: config.id, + name: config.name, + paletteId: config.paletteId, + family: config.family ?? config.name.toLowerCase().replace(/\s+/g, "-"), + stackId: config.stackId, + labels: [...config.labels], + fasteners: config.fasteners.map((fastener) => ({ ...fastener })) + }; +} + +function clamp(id, slot = "left", config = {}) { + return { + id, + type: "clamp", + slot, + blockedBy: [...(config.blockedBy ?? [])], + blockedReason: config.blockedReason ?? null + }; +} + +function wax(id, slot = "top", config = {}) { + return { + id, + type: "wax", + slot, + blockedBy: [...(config.blockedBy ?? [])], + blockedReason: config.blockedReason ?? null + }; +} + +function spacer(id, slot = "mouth", config = {}) { + return { + id, + type: "spacer", + slot, + blockedBy: [...(config.blockedBy ?? [])], + blockedReason: config.blockedReason ?? null + }; +} + +function reward(title, detail) { + return { title, detail }; +} + +function callout(trigger, text, focus = null) { + return { trigger, text, focus }; +} + +function buildRowCells(rowCount, rowIndex, rowLength) { + const y = ROW_Y[rowCount][rowIndex]; + const size = CELL_SIZE[rowLength]; + const step = CELL_STEP[rowLength]; + const cells = []; + + for (let index = 0; index < rowLength; index += 1) { + const centerX = 50 + (index - (rowLength - 1) / 2) * step; + cells.push({ + x: Number((centerX - size / 2).toFixed(2)), + y: Number((y - size / 2).toFixed(2)), + w: size, + h: size + }); + } + + return cells; +} + +export function buildLevelFromPlan({ + id, + kind = "ftue", + seed = null, + number = null, + packId, + title, + caption, + beat, + mechanics, + callouts, + reward: rewardData, + rows, + stacks, + strips +}) { + const pack = kind === "daily" ? DAILY_THEME : PACK_BY_ID[packId]; + if (!pack) { + throw new Error(`Unknown pack ${packId}.`); + } + + const stackMap = new Map(stacks.map((stack) => [stack.id, stack])); + const stripMap = new Map(strips.map((entry) => [entry.id, entry])); + const rowCount = rows.length; + const segmentCells = new Map(); + + const builtRows = rows.map((row, rowIndex) => { + const builtSegments = row.segments.map((segmentId) => { + const rowStrip = stripMap.get(segmentId); + if (!rowStrip) { + throw new Error(`Row ${row.id} references unknown strip ${segmentId}.`); + } + return rowStrip; + }); + + const length = builtSegments.reduce((total, rowStrip) => total + rowStrip.labels.length, 0); + const rowCells = buildRowCells(rowCount, rowIndex, length).map((rect, cellIndex) => ({ + id: `${row.id}-c${cellIndex + 1}`, + rowId: row.id, + columnIndex: cellIndex, + entry: row.entry, + ...rect + })); + + let pointer = row.entry === "left" ? rowCells.length : 0; + for (const rowStrip of builtSegments) { + const segmentLength = rowStrip.labels.length; + let targetCellIds; + + if (row.entry === "left") { + targetCellIds = rowCells + .slice(pointer - segmentLength, pointer) + .map((cell) => cell.id); + pointer -= segmentLength; + } else { + targetCellIds = rowCells + .slice(pointer, pointer + segmentLength) + .map((cell) => cell.id); + pointer += segmentLength; + } + + segmentCells.set(rowStrip.id, targetCellIds); + } + + return { + id: row.id, + title: row.title, + entry: row.entry, + mouthHint: row.entry === "left" ? "Enter from the left lane." : "Enter from the right lane.", + cellIds: rowCells.map((cell) => cell.id), + mouthX: + row.entry === "left" + ? Number((rowCells[0].x - 4).toFixed(2)) + : Number((rowCells[rowCells.length - 1].x + rowCells[rowCells.length - 1].w + 4).toFixed(2)), + mouthY: Number((rowCells[0].y + rowCells[0].h / 2).toFixed(2)) + }; + }); + + const builtCells = []; + const builtStrips = strips.map((entry) => { + if (!stackMap.has(entry.stackId)) { + throw new Error(`Strip ${entry.id} references unknown stack ${entry.stackId}.`); + } + + const targetCellIds = segmentCells.get(entry.id); + if (!targetCellIds) { + throw new Error(`Strip ${entry.id} is not assigned to any tray row.`); + } + + const stackOrder = stackMap.get(entry.stackId).stripIds; + const rowId = builtRows.find((row) => row.cellIds.some((cellId) => targetCellIds.includes(cellId)))?.id; + + const shardItems = entry.labels.map((label, index) => { + const shard = shardLabel(entry.name, label, entry.family, String.fromCharCode(97 + index)); + const palette = PALETTES[entry.paletteId]; + const cellId = targetCellIds[index]; + const targetCell = builtRows + .flatMap((row) => row.cellIds) + .find((candidateId) => candidateId === cellId); + + builtCells.push({ + id: cellId, + rowId, + paletteId: entry.paletteId, + title: shard.title, + short: shard.short, + family: shard.family, + variant: shard.variant, + tone: palette.card, + trim: palette.trim, + glaze: palette.glaze, + targetStripId: entry.id + }); + + return { + id: `${entry.id}-shard-${index + 1}`, + cellId: targetCell, + paletteId: entry.paletteId, + title: shard.title, + short: shard.short, + family: shard.family, + variant: shard.variant, + tone: palette.shard, + trim: palette.trim, + glaze: palette.glaze + }; + }); + + return { + id: entry.id, + name: entry.name, + rowId, + stackId: entry.stackId, + orderInStack: stackOrder.indexOf(entry.id), + paletteId: entry.paletteId, + family: entry.family, + entrySide: builtRows.find((row) => row.id === rowId)?.entry ?? "left", + targetCellIds, + shardCount: shardItems.length, + shards: shardItems, + blockerIds: entry.fasteners.map((fastener) => fastener.id) + }; + }); + + const cellRectsById = new Map( + rows.flatMap((row, rowIndex) => + buildRowCells(rows.length, rowIndex, row.segments.reduce((total, segmentId) => total + stripMap.get(segmentId).labels.length, 0)).map( + (rect, index) => [`${row.id}-c${index + 1}`, rect] + ) + ) + ); + + const builtFasteners = builtStrips.flatMap((rowStrip) => { + const source = stripMap.get(rowStrip.id); + return source.fasteners.map((entry) => ({ + id: entry.id, + stripId: rowStrip.id, + rowId: rowStrip.rowId, + stackId: rowStrip.stackId, + type: entry.type, + slot: entry.slot, + blockedBy: [...entry.blockedBy], + blockedReason: entry.blockedReason, + hitbox: + entry.type === "spacer" + ? { w: 64, h: 48 } + : entry.type === "wax" + ? { w: 56, h: 56 } + : { w: 60, h: 60 } + })); + }); + + const builtStacks = stacks.map((stack, index) => ({ + id: stack.id, + title: stack.title, + laneIndex: index, + stripIds: [...stack.stripIds] + })); + + return { + id, + kind, + seed, + number, + packId, + title, + beat, + mechanics: [...mechanics], + callouts: clone(callouts), + reward: clone(rewardData), + card: { + title, + caption, + accent: pack.accent, + gradient: [...pack.gradient] + }, + tray: { + rows: builtRows.map((row) => ({ + ...row, + cells: row.cellIds.map((cellId) => ({ + id: cellId, + ...cellRectsById.get(cellId) + })) + })), + cells: builtCells + }, + stacks: builtStacks, + strips: builtStrips, + fasteners: builtFasteners + }; +} + +const gardenPlans = [ + { + id: "ftue-01", + title: "First Clamp", + caption: "Two simple rows teach that a clamp tap releases one strip and auto-sets it into the card.", + beat: "Tap the exposed clamps. Each strip glides into its row on its own.", + mechanics: ["Clamp release", "Auto-settle strips", "Frontmost stack read"], + callouts: [ + callout("start", "Only the front strip in each stack can move.", { type: "stack", id: "bench-a" }), + callout("start", "Clamp first. The strip settles by itself after a legal release.", { + type: "fastener", + id: "a-clamp" + }) + ], + reward: reward("Tulip start card", "Adds the first garden card to the wall rail."), + rows: [ + { id: "r1", title: "Tulip row", entry: "left", segments: ["A", "B"] }, + { id: "r2", title: "Teacup row", entry: "left", segments: ["C", "D"] } + ], + stacks: [ + { id: "bench-a", title: "North bench", stripIds: ["A", "B"] }, + { id: "bench-b", title: "South bench", stripIds: ["C", "D"] } + ], + strips: [ + strip({ + id: "A", + name: "Tulip bloom", + paletteId: "terracotta", + stackId: "bench-a", + labels: ["Bloom"], + fasteners: [clamp("a-clamp", "left")] + }), + strip({ + id: "B", + name: "Tulip leaf", + paletteId: "leaf", + stackId: "bench-a", + labels: ["Leaf"], + fasteners: [clamp("b-clamp", "right")] + }), + strip({ + id: "C", + name: "Cup rim", + paletteId: "cream", + stackId: "bench-b", + labels: ["Rim"], + fasteners: [clamp("c-clamp", "left")] + }), + strip({ + id: "D", + name: "Sky glaze", + paletteId: "sky", + stackId: "bench-b", + labels: ["Sky"], + fasteners: [clamp("d-clamp", "right")] + }) + ] + }, + { + id: "ftue-02", + title: "First Wax", + caption: "A wax seal peels like a second kind of fastener, but the strip still settles automatically.", + beat: "Wax seals work like clamps once the pull ring is visible.", + mechanics: ["Wax tab release", "Mixed fastener types"], + callouts: [ + callout("start", "Wax tabs only peel when the ring sits on top of the front strip.", { + type: "fastener", + id: "b-wax" + }) + ], + reward: reward("Lemon branch card", "Pins a citrus tile beside the tulip card."), + rows: [ + { id: "r1", title: "Lemon row", entry: "left", segments: ["A", "B"] }, + { id: "r2", title: "Saucer row", entry: "left", segments: ["C", "D"] } + ], + stacks: [ + { id: "bench-a", title: "Left press", stripIds: ["A", "B"] }, + { id: "bench-b", title: "Right press", stripIds: ["C", "D"] } + ], + strips: [ + strip({ + id: "A", + name: "Lemon arc", + paletteId: "cream", + stackId: "bench-a", + labels: ["Arc", "Pith"], + fasteners: [clamp("a-clamp", "left")] + }), + strip({ + id: "B", + name: "Leaf tip", + paletteId: "leaf", + stackId: "bench-a", + labels: ["Tip"], + fasteners: [wax("b-wax", "top")] + }), + strip({ + id: "C", + name: "Saucer ring", + paletteId: "sky", + stackId: "bench-b", + labels: ["Ring"], + fasteners: [clamp("c-clamp", "left")] + }), + strip({ + id: "D", + name: "Cup stem", + paletteId: "terracotta", + stackId: "bench-b", + labels: ["Stem"], + fasteners: [clamp("d-clamp", "right")] + }) + ] + }, + { + id: "ftue-03", + title: "First Spacer", + caption: "Paper spacers can block a row mouth even after the strip itself is almost ready.", + beat: "A row mouth spacer can still stop a strip after its own clamp is gone.", + mechanics: ["Paper spacer", "Two-step strip release"], + callouts: [ + callout("start", "Paper tails sit at the row mouth. Pull them last when they are exposed.", { + type: "fastener", + id: "e-spacer" + }) + ], + reward: reward("Sparrow perch card", "Adds the first bird tile to Garden Tiles."), + rows: [ + { id: "r1", title: "Petal row", entry: "left", segments: ["A", "B"] }, + { id: "r2", title: "Vine row", entry: "left", segments: ["C", "D"] }, + { id: "r3", title: "Bird row", entry: "left", segments: ["E"] } + ], + stacks: [ + { id: "bench-a", title: "West rack", stripIds: ["A", "B"] }, + { id: "bench-b", title: "Center rack", stripIds: ["C", "D"] }, + { id: "bench-c", title: "East rack", stripIds: ["E"] } + ], + strips: [ + strip({ + id: "A", + name: "Petal fan", + paletteId: "terracotta", + stackId: "bench-a", + labels: ["Fan"], + fasteners: [clamp("a-clamp", "left")] + }), + strip({ + id: "B", + name: "Petal edge", + paletteId: "coral", + stackId: "bench-a", + labels: ["Edge"], + fasteners: [clamp("b-clamp", "right")] + }), + strip({ + id: "C", + name: "Vine curl", + paletteId: "leaf", + stackId: "bench-b", + labels: ["Curl"], + fasteners: [wax("c-wax", "top")] + }), + strip({ + id: "D", + name: "Vine knot", + paletteId: "leaf", + stackId: "bench-b", + labels: ["Knot"], + fasteners: [clamp("d-clamp", "right")] + }), + strip({ + id: "E", + name: "Sparrow wing", + paletteId: "sky", + stackId: "bench-c", + labels: ["Wing", "Feather"], + fasteners: [ + clamp("e-clamp", "left"), + spacer("e-spacer", "mouth", { + blockedBy: ["e-clamp"], + blockedReason: "The spacer tail is still pinned behind the clamp." + }) + ] + }) + ] + }, + { + id: "ftue-04", + title: "Read The Row", + caption: "Longer rows now ask players to read the deeper cells before they touch the near cap.", + beat: "Follow the depth ticks. Deeper cells in a row must clear before the near cap can lock in.", + mechanics: ["Depth read", "Three-row tray"], + callouts: [ + callout("start", "Depth ticks mark how far a strip must travel from the row mouth.", { type: "row", id: "r1" }) + ], + reward: reward("Window vine card", "Unlocks a brighter paper backing for Garden Tiles."), + rows: [ + { id: "r1", title: "Window row", entry: "left", segments: ["A", "B"] }, + { id: "r2", title: "Teacup row", entry: "left", segments: ["C", "D"] }, + { id: "r3", title: "Vine row", entry: "left", segments: ["E", "F"] } + ], + stacks: [ + { id: "bench-a", title: "Upper press", stripIds: ["A", "B"] }, + { id: "bench-b", title: "Middle press", stripIds: ["C", "D"] }, + { id: "bench-c", title: "Lower press", stripIds: ["E", "F"] } + ], + strips: [ + strip({ + id: "A", + name: "Window crest", + paletteId: "sky", + stackId: "bench-a", + labels: ["Crest", "Pane"], + fasteners: [clamp("a-clamp", "left")] + }), + strip({ + id: "B", + name: "Window sill", + paletteId: "cream", + stackId: "bench-a", + labels: ["Sill"], + fasteners: [clamp("b-clamp", "right")] + }), + strip({ + id: "C", + name: "Cup bowl", + paletteId: "terracotta", + stackId: "bench-b", + labels: ["Bowl"], + fasteners: [wax("c-wax", "top")] + }), + strip({ + id: "D", + name: "Cup saucer", + paletteId: "cream", + stackId: "bench-b", + labels: ["Saucer"], + fasteners: [clamp("d-clamp", "right")] + }), + strip({ + id: "E", + name: "Vine sweep", + paletteId: "leaf", + stackId: "bench-c", + labels: ["Sweep"], + fasteners: [spacer("e-spacer", "mouth")] + }), + strip({ + id: "F", + name: "Leaf tail", + paletteId: "leaf", + stackId: "bench-c", + labels: ["Tail"], + fasteners: [clamp("f-clamp", "right")] + }) + ] + }, + { + id: "ftue-05", + title: "First Overflow", + caption: "A near cap can slide in first, but it may seal the deeper cells and jam the row later.", + beat: "A wrong release can seal a row. Undo is here when gutter pressure wins.", + mechanics: ["Gutter overflow", "Undo surfacing"], + callouts: [ + callout("start", "The short cream cap looks tempting, but the deeper blue cells still need room first.", { + type: "row", + id: "r1" + }), + callout("fail:overflow", "That near cap sealed the row mouth for the deeper strip. Undo rewinds the last release.") + ], + reward: reward("Lattice warning card", "Adds a teaching plaque to the studio wall."), + rows: [ + { id: "r1", title: "Sky row", entry: "left", segments: ["A", "B"] }, + { id: "r2", title: "Leaf row", entry: "left", segments: ["C", "D"] }, + { id: "r3", title: "Tulip row", entry: "left", segments: ["E"] } + ], + stacks: [ + { id: "bench-a", title: "Left press", stripIds: ["A", "C"] }, + { id: "bench-b", title: "Right press", stripIds: ["B", "D"] }, + { id: "bench-c", title: "Rear press", stripIds: ["E"] } + ], + strips: [ + strip({ + id: "A", + name: "Sky ribbon", + paletteId: "sky", + stackId: "bench-a", + labels: ["Ribbon", "Cloud"], + fasteners: [clamp("a-clamp", "left")] + }), + strip({ + id: "B", + name: "Cream cap", + paletteId: "cream", + stackId: "bench-b", + labels: ["Cap"], + fasteners: [clamp("b-clamp", "right")] + }), + strip({ + id: "C", + name: "Leaf fold", + paletteId: "leaf", + stackId: "bench-a", + labels: ["Fold"], + fasteners: [wax("c-wax", "top")] + }), + strip({ + id: "D", + name: "Stem dot", + paletteId: "terracotta", + stackId: "bench-b", + labels: ["Dot"], + fasteners: [clamp("d-clamp", "right")] + }), + strip({ + id: "E", + name: "Tulip sweep", + paletteId: "coral", + stackId: "bench-c", + labels: ["Sweep", "Petal", "Glow"], + fasteners: [ + clamp("e-clamp", "left"), + wax("e-wax", "top", { + blockedBy: ["e-clamp"], + blockedReason: "The wax ring stays tucked until the clamp lifts." + }) + ] + }) + ] + }, + { + id: "ftue-06", + title: "Studio Finish", + caption: "The first pack finale mixes clamps, wax tabs, and spacers across a full 3x3 tray.", + beat: "All three fastener types now share the same bench. Release order matters more than fast tapping.", + mechanics: ["Clamp, wax, spacer mix", "Pack clear", "Daily unlock"], + callouts: [ + callout("start", "Finish this studio card to unlock the daily commission bench."), + callout("win", "Daily commission unlocked. It refreshes by UTC date for stable QA.") + ], + reward: reward("Pressed cedar frame", "Unlocks the first gallery frame and the daily commission card."), + rows: [ + { id: "r1", title: "Bloom row", entry: "left", segments: ["A", "B"] }, + { id: "r2", title: "Window row", entry: "left", segments: ["C", "D"] }, + { id: "r3", title: "Studio row", entry: "left", segments: ["E", "F"] } + ], + stacks: [ + { id: "bench-a", title: "North bench", stripIds: ["A", "B"] }, + { id: "bench-b", title: "East bench", stripIds: ["C", "D"] }, + { id: "bench-c", title: "South bench", stripIds: ["E"] }, + { id: "bench-d", title: "Side tray", stripIds: ["F"] } + ], + strips: [ + strip({ + id: "A", + name: "Bloom arc", + paletteId: "coral", + stackId: "bench-a", + labels: ["Arc", "Light"], + fasteners: [clamp("a-clamp", "left")] + }), + strip({ + id: "B", + name: "Bloom cap", + paletteId: "cream", + stackId: "bench-a", + labels: ["Cap"], + fasteners: [wax("b-wax", "top")] + }), + strip({ + id: "C", + name: "Window bay", + paletteId: "sky", + stackId: "bench-b", + labels: ["Bay"], + fasteners: [clamp("c-clamp", "left")] + }), + strip({ + id: "D", + name: "Window trim", + paletteId: "cream", + stackId: "bench-b", + labels: ["Trim", "Seal"], + fasteners: [spacer("d-spacer", "mouth")] + }), + strip({ + id: "E", + name: "Studio note", + paletteId: "terracotta", + stackId: "bench-c", + labels: ["Note"], + fasteners: [clamp("e-clamp", "left")] + }), + strip({ + id: "F", + name: "Leaf bracket", + paletteId: "leaf", + stackId: "bench-d", + labels: ["Bracket", "Vine"], + fasteners: [wax("f-wax", "top")] + }) + ] + } +]; + +const harborPlans = [ + { + id: "ftue-07", + title: "Repeated Motif", + caption: "Two sky-blue rows now share the same palette family, so the shard art matters more than hue alone.", + beat: "Repeated colors ask you to read crack lines and labels, not hue alone.", + mechanics: ["Repeated palette family", "Three full rows"], + callouts: [ + callout("start", "Two rows share the same sky glaze. Use the artwork fragments, not color alone.") + ], + reward: reward("Shell study card", "Starts the Harbor Keepsakes gallery with repeated blue motifs."), + rows: [ + { id: "r1", title: "Harbor sky", entry: "left", segments: ["A", "B"] }, + { id: "r2", title: "Harbor water", entry: "left", segments: ["C", "D"] }, + { id: "r3", title: "Shell edge", entry: "left", segments: ["E", "F"] } + ], + stacks: [ + { id: "bench-a", title: "Top rail", stripIds: ["A"] }, + { id: "bench-b", title: "Mid rail", stripIds: ["B", "D"] }, + { id: "bench-c", title: "Lower rail", stripIds: ["C"] }, + { id: "bench-d", title: "Side rail", stripIds: ["E", "F"] } + ], + strips: [ + strip({ + id: "A", + name: "Harbor sky", + paletteId: "sky", + family: "harbor-blue", + stackId: "bench-a", + labels: ["Sky", "Wing"], + fasteners: [clamp("a-clamp", "left")] + }), + strip({ + id: "B", + name: "Cloud cap", + paletteId: "sky", + family: "harbor-blue", + stackId: "bench-b", + labels: ["Cap"], + fasteners: [clamp("b-clamp", "right")] + }), + strip({ + id: "C", + name: "Sea braid", + paletteId: "sky", + family: "water-blue", + stackId: "bench-c", + labels: ["Braid", "Wake"], + fasteners: [wax("c-wax", "top")] + }), + strip({ + id: "D", + name: "Foam lip", + paletteId: "seafoam", + family: "water-blue", + stackId: "bench-b", + labels: ["Foam"], + fasteners: [clamp("d-clamp", "right")] + }), + strip({ + id: "E", + name: "Shell rib", + paletteId: "sand", + stackId: "bench-d", + labels: ["Rib"], + fasteners: [spacer("e-spacer", "mouth")] + }), + strip({ + id: "F", + name: "Coral spark", + paletteId: "coral", + stackId: "bench-d", + labels: ["Spark", "Shell"], + fasteners: [clamp("f-clamp", "right")] + }) + ] + }, + { + id: "ftue-08", + title: "Hidden Wax", + caption: "A wax tab can look obvious, but a clamp ear may still cover the pull ring until the clamp lifts.", + beat: "Not every visible wax seal is ready. Coverage still matters.", + mechanics: ["Wax hidden under clamp", "Late pack density"], + callouts: [ + callout("start", "That wax ring is still tucked under the clamp ear. Lift the clamp first.", { + type: "fastener", + id: "f-wax" + }) + ], + reward: reward("Sail study card", "Adds the first late-pack readability stress board."), + rows: [ + { id: "r1", title: "Sail row", entry: "left", segments: ["A", "B"] }, + { id: "r2", title: "Harbor row", entry: "left", segments: ["C", "D"] }, + { id: "r3", title: "Beacon row", entry: "left", segments: ["E", "F"] } + ], + stacks: [ + { id: "bench-a", title: "Far left", stripIds: ["A", "B"] }, + { id: "bench-b", title: "Left center", stripIds: ["C"] }, + { id: "bench-c", title: "Right center", stripIds: ["D", "E"] }, + { id: "bench-d", title: "Far right", stripIds: ["F"] } + ], + strips: [ + strip({ + id: "A", + name: "Sail body", + paletteId: "cream", + family: "sail-repeat", + stackId: "bench-a", + labels: ["Body", "Cut"], + fasteners: [clamp("a-clamp", "left")] + }), + strip({ + id: "B", + name: "Sail trim", + paletteId: "indigo", + family: "sail-repeat", + stackId: "bench-a", + labels: ["Trim"], + fasteners: [clamp("b-clamp", "right")] + }), + strip({ + id: "C", + name: "Harbor line", + paletteId: "sky", + stackId: "bench-b", + labels: ["Line", "Wake"], + fasteners: [wax("c-wax", "top")] + }), + strip({ + id: "D", + name: "Beacon glow", + paletteId: "coral", + stackId: "bench-c", + labels: ["Glow"], + fasteners: [spacer("d-spacer", "mouth")] + }), + strip({ + id: "E", + name: "Beacon cap", + paletteId: "sand", + stackId: "bench-c", + labels: ["Cap"], + fasteners: [clamp("e-clamp", "right")] + }), + strip({ + id: "F", + name: "Shell shadow", + paletteId: "seafoam", + stackId: "bench-d", + labels: ["Shell", "Shadow", "Edge"], + fasteners: [ + clamp("f-clamp", "left"), + wax("f-wax", "top", { + blockedBy: ["f-clamp"], + blockedReason: "The wax ring is still tucked beneath the clamp ear." + }) + ] + }) + ] + }, + { + id: "ftue-09", + title: "First Right Entry", + caption: "Some rows now enter from the right gutter, so their deeper cells live on the left side instead.", + beat: "Right-entry rows reverse the depth read. Start from the row mouth, not from the left edge.", + mechanics: ["Right-entry rows", "Mixed row directions"], + callouts: [ + callout("start", "These indigo ticks face right-to-left. Deeper cells sit farther from the right mouth.", { + type: "row", + id: "r1" + }) + ], + reward: reward("Fish tile card", "Unlocks right-entry commissions for the validator."), + rows: [ + { id: "r1", title: "Fish row", entry: "right", segments: ["A", "B"] }, + { id: "r2", title: "Wave row", entry: "left", segments: ["C", "D"] }, + { id: "r3", title: "Stamp row", entry: "right", segments: ["E"] }, + { id: "r4", title: "Seal row", entry: "left", segments: ["F"] } + ], + stacks: [ + { id: "bench-a", title: "Top press", stripIds: ["A"] }, + { id: "bench-b", title: "Upper side", stripIds: ["B", "D"] }, + { id: "bench-c", title: "Lower side", stripIds: ["C", "E"] }, + { id: "bench-d", title: "Bottom press", stripIds: ["F"] } + ], + strips: [ + strip({ + id: "A", + name: "Fish body", + paletteId: "indigo", + stackId: "bench-a", + labels: ["Body", "Fin"], + fasteners: [clamp("a-clamp", "left")] + }), + strip({ + id: "B", + name: "Fish eye", + paletteId: "cream", + stackId: "bench-b", + labels: ["Eye"], + fasteners: [clamp("b-clamp", "right")] + }), + strip({ + id: "C", + name: "Wave crest", + paletteId: "sky", + stackId: "bench-c", + labels: ["Crest", "Wake"], + fasteners: [wax("c-wax", "top")] + }), + strip({ + id: "D", + name: "Foam nib", + paletteId: "seafoam", + stackId: "bench-b", + labels: ["Nib"], + fasteners: [clamp("d-clamp", "right")] + }), + strip({ + id: "E", + name: "Stamp curl", + paletteId: "coral", + stackId: "bench-c", + labels: ["Curl", "Seal"], + fasteners: [spacer("e-spacer", "mouth")] + }), + strip({ + id: "F", + name: "Sand mark", + paletteId: "sand", + stackId: "bench-d", + labels: ["Mark", "Grain"], + fasteners: [clamp("f-clamp", "left")] + }) + ] + }, + { + id: "ftue-10", + title: "Deep Row Pair", + caption: "Two different rows now hold deep strips that can both be sealed by the wrong short cap.", + beat: "Watch both deep rows. A fast near cap can ruin either lane.", + mechanics: ["Adjacent deep rows", "Five-stack bench"], + callouts: [ + callout("start", "Two rows want their deeper bands first. The short caps can wait.", { type: "row", id: "r1" }) + ], + reward: reward("Lighthouse panel", "Adds the tallest harbor card to the gallery wall."), + rows: [ + { id: "r1", title: "Lighthouse row", entry: "left", segments: ["A", "B"] }, + { id: "r2", title: "Wave row", entry: "right", segments: ["C", "D"] }, + { id: "r3", title: "Postcard row", entry: "left", segments: ["E"] }, + { id: "r4", title: "Ledger row", entry: "left", segments: ["F", "G"] } + ], + stacks: [ + { id: "bench-a", title: "Far left", stripIds: ["A"] }, + { id: "bench-b", title: "Left", stripIds: ["B", "F"] }, + { id: "bench-c", title: "Center", stripIds: ["C"] }, + { id: "bench-d", title: "Right", stripIds: ["D", "G"] }, + { id: "bench-e", title: "Far right", stripIds: ["E"] } + ], + strips: [ + strip({ + id: "A", + name: "Lighthouse shaft", + paletteId: "cream", + stackId: "bench-a", + labels: ["Shaft", "Lamp"], + fasteners: [clamp("a-clamp", "left")] + }), + strip({ + id: "B", + name: "Brick cap", + paletteId: "coral", + stackId: "bench-b", + labels: ["Cap"], + fasteners: [clamp("b-clamp", "right")] + }), + strip({ + id: "C", + name: "Wave body", + paletteId: "sky", + stackId: "bench-c", + labels: ["Body", "Flow"], + fasteners: [wax("c-wax", "top")] + }), + strip({ + id: "D", + name: "Foam cap", + paletteId: "seafoam", + stackId: "bench-d", + labels: ["Cap"], + fasteners: [clamp("d-clamp", "right")] + }), + strip({ + id: "E", + name: "Post stamp", + paletteId: "sand", + stackId: "bench-e", + labels: ["Post", "Date"], + fasteners: [spacer("e-spacer", "mouth")] + }), + strip({ + id: "F", + name: "Ledger edge", + paletteId: "indigo", + stackId: "bench-b", + labels: ["Edge"], + fasteners: [clamp("f-clamp", "left")] + }), + strip({ + id: "G", + name: "Ledger wax", + paletteId: "coral", + stackId: "bench-d", + labels: ["Wax", "Seal"], + fasteners: [clamp("g-clamp", "left")] + }) + ] + }, + { + id: "ftue-11", + title: "Crowded Bench", + caption: "Five stacks crowd the bench without overlapping the live hit targets beyond the readability cap.", + beat: "Dense boards are still about clean taps. The bench is crowded, but only exposed fasteners count.", + mechanics: ["Crowded hit targets", "Late pack density"], + callouts: [ + callout("start", "If two fasteners sit close together, look for the light outline on the exposed one.") + ], + reward: reward("Postmark cluster", "Adds a crowded bench plaque for QA focus passes."), + rows: [ + { id: "r1", title: "Sun row", entry: "left", segments: ["A", "B"] }, + { id: "r2", title: "Harbor row", entry: "right", segments: ["C", "D"] }, + { id: "r3", title: "Shell row", entry: "left", segments: ["E"] }, + { id: "r4", title: "Stamp row", entry: "left", segments: ["F", "G"] } + ], + stacks: [ + { id: "bench-a", title: "A rail", stripIds: ["A"] }, + { id: "bench-b", title: "B rail", stripIds: ["B", "E"] }, + { id: "bench-c", title: "C rail", stripIds: ["C"] }, + { id: "bench-d", title: "D rail", stripIds: ["D", "F"] }, + { id: "bench-e", title: "E rail", stripIds: ["G"] } + ], + strips: [ + strip({ + id: "A", + name: "Sun core", + paletteId: "sand", + stackId: "bench-a", + labels: ["Core", "Ray"], + fasteners: [clamp("a-clamp", "left")] + }), + strip({ + id: "B", + name: "Sun cap", + paletteId: "coral", + stackId: "bench-b", + labels: ["Cap"], + fasteners: [clamp("b-clamp", "right")] + }), + strip({ + id: "C", + name: "Harbor body", + paletteId: "sky", + stackId: "bench-c", + labels: ["Body", "Dock"], + fasteners: [wax("c-wax", "top")] + }), + strip({ + id: "D", + name: "Harbor lip", + paletteId: "indigo", + stackId: "bench-d", + labels: ["Lip"], + fasteners: [clamp("d-clamp", "right")] + }), + strip({ + id: "E", + name: "Shell fan", + paletteId: "cream", + stackId: "bench-b", + labels: ["Fan", "Glint"], + fasteners: [spacer("e-spacer", "mouth")] + }), + strip({ + id: "F", + name: "Postmark edge", + paletteId: "coral", + stackId: "bench-d", + labels: ["Edge"], + fasteners: [clamp("f-clamp", "left")] + }), + strip({ + id: "G", + name: "Ledger stamp", + paletteId: "seafoam", + stackId: "bench-e", + labels: ["Stamp", "Date", "Seal"], + fasteners: [clamp("g-clamp", "left")] + }) + ] + }, + { + id: "ftue-12", + title: "Commission Capstone", + caption: "The capstone mixes repeated colors, both row entries, delayed spacer reveals, and one hidden wax ring.", + beat: "This last card uses the whole slice grammar. Read the entries, the repeats, and the covered fasteners.", + mechanics: ["Capstone mix", "Delayed spacer reveal", "8-step finish"], + callouts: [ + callout("start", "This commission mixes both entry sides and a delayed spacer reveal."), + callout("fail:no_move", "No productive releases remain. Undo or restart to reopen the commission.") + ], + reward: reward("Salt-glazed frame", "Unlocks the second gallery frame for the completed harbor set."), + rows: [ + { id: "r1", title: "Sun row", entry: "left", segments: ["A", "B"] }, + { id: "r2", title: "Harbor row", entry: "right", segments: ["C", "D"] }, + { id: "r3", title: "Beacon row", entry: "left", segments: ["E"] }, + { id: "r4", title: "Ledger row", entry: "right", segments: ["G"] } + ], + stacks: [ + { id: "bench-a", title: "North left", stripIds: ["A"] }, + { id: "bench-b", title: "North right", stripIds: ["B", "E"] }, + { id: "bench-c", title: "Center", stripIds: ["C"] }, + { id: "bench-d", title: "South left", stripIds: ["D"] }, + { id: "bench-e", title: "South right", stripIds: ["G"] } + ], + strips: [ + strip({ + id: "A", + name: "Sun body", + paletteId: "sand", + family: "sun-repeat", + stackId: "bench-a", + labels: ["Body", "Ray"], + fasteners: [clamp("a-clamp", "left")] + }), + strip({ + id: "B", + name: "Sun cap", + paletteId: "sand", + family: "sun-repeat", + stackId: "bench-b", + labels: ["Cap"], + fasteners: [clamp("b-clamp", "right")] + }), + strip({ + id: "C", + name: "Harbor body", + paletteId: "sky", + family: "harbor-repeat", + stackId: "bench-c", + labels: ["Body", "Wake"], + fasteners: [wax("c-wax", "top")] + }), + strip({ + id: "D", + name: "Harbor lip", + paletteId: "sky", + family: "harbor-repeat", + stackId: "bench-d", + labels: ["Lip"], + fasteners: [clamp("d-clamp", "right")] + }), + strip({ + id: "E", + name: "Beacon stripe", + paletteId: "coral", + stackId: "bench-b", + labels: ["Stripe", "Lamp", "Glow"], + fasteners: [ + clamp("e-clamp", "left"), + spacer("e-spacer", "mouth", { + blockedBy: ["e-clamp"], + blockedReason: "The spacer tail is still tucked behind the lifted clamp." + }) + ] + }), + strip({ + id: "G", + name: "Wax ledger", + paletteId: "seafoam", + stackId: "bench-e", + labels: ["Wax", "Date", "Seal"], + fasteners: [ + clamp("g-clamp", "left"), + wax("g-wax", "top", { + blockedBy: ["g-clamp"], + blockedReason: "The wax ring is still tucked under the clamp ear." + }) + ] + }) + ] + } +]; + +export const HANDCRAFTED_LEVELS = [...gardenPlans, ...harborPlans].map((plan, index) => + buildLevelFromPlan({ + id: plan.id, + number: index + 1, + packId: index < 6 ? "garden-tiles" : "harbor-keepsakes", + title: plan.title, + caption: plan.caption, + beat: plan.beat, + mechanics: plan.mechanics, + callouts: plan.callouts, + reward: plan.reward, + rows: plan.rows, + stacks: plan.stacks, + strips: plan.strips + }) +); diff --git a/mosaic-press/src/engine.js b/mosaic-press/src/engine.js new file mode 100644 index 0000000..bc472a1 --- /dev/null +++ b/mosaic-press/src/engine.js @@ -0,0 +1,791 @@ +const clone = (value) => JSON.parse(JSON.stringify(value)); + +function snapshotState(state) { + return { + removedFasteners: [...state.removedFasteners], + resolvedStrips: [...state.resolvedStrips], + filledCells: [...state.filledCells], + failed: state.failed, + completed: state.completed, + failReason: state.failReason, + message: state.message, + hintAction: state.hintAction ? { ...state.hintAction } : null, + lastEvent: state.lastEvent ? clone(state.lastEvent) : null + }; +} + +export function createRuntime(level) { + const rows = level.tray.rows.map((row) => clone(row)); + const cells = level.tray.cells.map((cell) => clone(cell)); + const strips = level.strips.map((strip) => clone(strip)); + const fasteners = level.fasteners.map((fastener) => clone(fastener)); + const stacks = level.stacks.map((stack) => clone(stack)); + + return { + level, + rows, + rowMap: new Map(rows.map((row) => [row.id, row])), + cells, + cellMap: new Map(cells.map((cell) => [cell.id, cell])), + strips, + stripMap: new Map(strips.map((strip) => [strip.id, strip])), + fasteners, + fastenerMap: new Map(fasteners.map((fastener) => [fastener.id, fastener])), + stacks, + stackMap: new Map(stacks.map((stack) => [stack.id, stack])), + fastenersByStrip: new Map( + strips.map((strip) => [ + strip.id, + fasteners.filter((fastener) => fastener.stripId === strip.id) + ]) + ), + totalCells: cells.length + }; +} + +export function createInitialState(level) { + return { + removedFasteners: [], + resolvedStrips: [], + filledCells: [], + failed: false, + completed: false, + failReason: null, + message: level.beat, + hintAction: null, + lastEvent: { type: "start" }, + history: [] + }; +} + +export function isFastenerRemoved(state, fastenerId) { + return state.removedFasteners.includes(fastenerId); +} + +export function isStripResolved(state, stripId) { + return state.resolvedStrips.includes(stripId); +} + +export function getFillRatio(level, state, runtime = createRuntime(level)) { + if (!runtime.totalCells) { + return 0; + } + return state.filledCells.length / runtime.totalCells; +} + +export function getFrontStrip(runtime, state, stackId) { + const stack = runtime.stackMap.get(stackId); + if (!stack) { + return null; + } + + const frontId = stack.stripIds.find((stripId) => !isStripResolved(state, stripId)); + return frontId ? runtime.stripMap.get(frontId) ?? null : null; +} + +export function getFrontStripIds(level, state, runtime = createRuntime(level)) { + return runtime.stacks + .map((stack) => getFrontStrip(runtime, state, stack.id)?.id ?? null) + .filter(Boolean); +} + +function defaultBlockedReason(fastener, strip) { + if (fastener.type === "wax") { + return `${strip.name} still tucks the wax ring under another layer.`; + } + if (fastener.type === "spacer") { + return `${strip.name} still hides the spacer tail at the row mouth.`; + } + return `${strip.name} is still covered by another strip edge.`; +} + +function defaultHiddenReason(fastener, strip) { + if (fastener.type === "spacer") { + return `${strip.name} has not reached the row mouth yet.`; + } + if (fastener.type === "wax") { + return `${strip.name} is still tucked behind the front strip.`; + } + return `${strip.name} is still behind another strip.`; +} + +function blockersForStrip(strip, state) { + return strip.blockerIds.filter((fastenerId) => !isFastenerRemoved(state, fastenerId)); +} + +function remainingBlockerLabel(runtime, blockerId) { + const fastener = runtime.fastenerMap.get(blockerId); + if (!fastener) { + return "another fastener"; + } + + if (fastener.type === "wax") { + return "wax tab"; + } + if (fastener.type === "spacer") { + return "paper spacer"; + } + return "clamp"; +} + +function getFastenerRenderState(level, state, fastenerId, runtime = createRuntime(level)) { + const fastener = runtime.fastenerMap.get(fastenerId); + if (!fastener) { + return { + fastener: null, + strip: null, + visible: false, + removable: false, + reason: `Unknown fastener ${fastenerId}.` + }; + } + + const strip = runtime.stripMap.get(fastener.stripId); + if (!strip) { + return { + fastener, + strip: null, + visible: false, + removable: false, + reason: `Fastener ${fastenerId} has no strip.` + }; + } + + if (isStripResolved(state, strip.id) || isFastenerRemoved(state, fastenerId)) { + return { + fastener, + strip, + visible: false, + removable: false, + reason: `${strip.name} has already cleared.` + }; + } + + const frontStrip = getFrontStrip(runtime, state, strip.stackId); + if (!frontStrip || frontStrip.id !== strip.id) { + return { + fastener, + strip, + visible: false, + removable: false, + reason: defaultHiddenReason(fastener, strip) + }; + } + + const blockingFastener = fastener.blockedBy.find((candidateId) => !isFastenerRemoved(state, candidateId)); + if (blockingFastener) { + return { + fastener, + strip, + visible: true, + removable: false, + reason: fastener.blockedReason ?? defaultBlockedReason(fastener, strip) + }; + } + + return { + fastener, + strip, + visible: true, + removable: true, + reason: `${strip.name} is exposed.` + }; +} + +export function collectVisibleFasteners(level, state, runtime = createRuntime(level)) { + return runtime.fasteners + .map((fastener) => getFastenerRenderState(level, state, fastener.id, runtime)) + .filter((entry) => entry.visible); +} + +function pathForStrip(strip, runtime) { + const row = runtime.rowMap.get(strip.rowId); + if (!row) { + return { + ok: false, + reason: `Unknown row ${strip.rowId}.`, + row: null, + pathCellIds: [] + }; + } + + const indices = strip.targetCellIds.map((cellId) => row.cellIds.indexOf(cellId)); + if (indices.some((index) => index < 0)) { + return { + ok: false, + reason: `${strip.name} references a cell outside ${row.title}.`, + row, + pathCellIds: [] + }; + } + + const pathCellIds = + row.entry === "left" + ? row.cellIds.slice(0, Math.max(...indices) + 1) + : row.cellIds.slice(Math.min(...indices)); + + return { + ok: true, + row, + pathCellIds + }; +} + +function evaluatePlacement(strip, state, runtime) { + const path = pathForStrip(strip, runtime); + if (!path.ok) { + return path; + } + + const occupied = new Set(state.filledCells); + const occupiedTarget = strip.targetCellIds.find((cellId) => occupied.has(cellId)); + if (occupiedTarget) { + return { + ok: false, + row: path.row, + pathCellIds: path.pathCellIds, + blockedCellId: occupiedTarget, + reason: "target-occupied" + }; + } + + const blockingCellId = path.pathCellIds.find( + (cellId) => occupied.has(cellId) && !strip.targetCellIds.includes(cellId) + ); + if (blockingCellId) { + return { + ok: false, + row: path.row, + pathCellIds: path.pathCellIds, + blockedCellId: blockingCellId, + reason: "gutter-blocked" + }; + } + + return { + ok: true, + row: path.row, + pathCellIds: path.pathCellIds + }; +} + +function simulateFastener(level, state, fastenerId, runtime = createRuntime(level)) { + const renderState = getFastenerRenderState(level, state, fastenerId, runtime); + if (!renderState.fastener || !renderState.strip) { + return { + ok: false, + visible: false, + removable: false, + reason: renderState.reason + }; + } + + if (!renderState.visible || !renderState.removable) { + return { + ok: false, + visible: renderState.visible, + removable: renderState.removable, + reason: renderState.reason, + fastener: renderState.fastener, + strip: renderState.strip + }; + } + + const provisional = { + ...state, + removedFasteners: [...state.removedFasteners, fastenerId] + }; + const frontStrip = getFrontStrip(runtime, provisional, renderState.strip.stackId); + if (!frontStrip) { + return { + ok: true, + visible: true, + removable: true, + fastener: renderState.fastener, + strip: renderState.strip, + outcome: { + type: "remove-only", + remainingBlockers: [] + } + }; + } + + const remainingBlockers = blockersForStrip(frontStrip, provisional); + if (remainingBlockers.length) { + return { + ok: true, + visible: true, + removable: true, + fastener: renderState.fastener, + strip: renderState.strip, + outcome: { + type: "remove-only", + remainingBlockers + } + }; + } + + const placement = evaluatePlacement(frontStrip, provisional, runtime); + if (!placement.ok) { + return { + ok: true, + visible: true, + removable: true, + fastener: renderState.fastener, + strip: renderState.strip, + outcome: { + type: "overflow", + stripId: frontStrip.id, + rowId: frontStrip.rowId, + rowTitle: placement.row?.title ?? frontStrip.rowId, + blockedCellId: placement.blockedCellId, + pathCellIds: placement.pathCellIds + } + }; + } + + return { + ok: true, + visible: true, + removable: true, + fastener: renderState.fastener, + strip: renderState.strip, + outcome: { + type: "place", + stripId: frontStrip.id, + rowId: frontStrip.rowId, + pathCellIds: placement.pathCellIds + } + }; +} + +export function evaluateFastener(level, state, fastenerId, runtime = createRuntime(level)) { + return simulateFastener(level, state, fastenerId, runtime); +} + +function scoreAction(result, runtime) { + if (!result.outcome) { + return -1; + } + + if (result.outcome.type === "place") { + const strip = runtime.stripMap.get(result.outcome.stripId); + return 100 + (strip?.shardCount ?? 0) * 5; + } + + if (result.outcome.type === "remove-only") { + return 20 - result.outcome.remainingBlockers.length; + } + + return 1; +} + +export function collectLegalActions(level, state, runtime = createRuntime(level)) { + if (state.failed || state.completed) { + return []; + } + + return collectVisibleFasteners(level, state, runtime) + .map((entry) => evaluateFastener(level, state, entry.fastener.id, runtime)) + .filter((result) => result.ok && result.removable) + .map((result) => ({ + type: "fastener", + id: result.fastener.id, + stripId: result.strip.id, + outcome: result.outcome, + score: scoreAction(result, runtime) + })) + .sort( + (left, right) => + right.score - left.score || + left.stripId.localeCompare(right.stripId) || + left.id.localeCompare(right.id) + ); +} + +export function collectProductiveActions(level, state, runtime = createRuntime(level)) { + return collectLegalActions(level, state, runtime).filter((action) => action.outcome.type === "place"); +} + +function finalizeProgress(level, state, runtime) { + if (state.filledCells.length === runtime.totalCells) { + return { + ...state, + completed: true, + failed: false, + failReason: null, + hintAction: null, + lastEvent: { type: "win" }, + message: "The commission is complete." + }; + } + + if (!collectProductiveActions(level, state, runtime).length) { + return { + ...state, + completed: false, + failed: true, + failReason: "no_move", + hintAction: null, + lastEvent: { type: "no_move" }, + message: "No productive releases remain." + }; + } + + return state; +} + +function placeStrip(state, strip, fastenerId) { + return { + ...state, + removedFasteners: [...state.removedFasteners, fastenerId], + resolvedStrips: [...state.resolvedStrips, strip.id], + filledCells: [...state.filledCells, ...strip.targetCellIds] + }; +} + +function removeOnly(state, fastenerId) { + return { + ...state, + removedFasteners: [...state.removedFasteners, fastenerId] + }; +} + +function overflowMessage(strip, rowTitle) { + return `Gutter Overflow. ${strip.name} needed the deeper cells in ${rowTitle} first.`; +} + +function removeOnlyMessage(strip, remainingBlockerId, runtime) { + return `${strip.name} still waits on the ${remainingBlockerLabel(runtime, remainingBlockerId)}.`; +} + +export function applyAction( + level, + state, + action, + runtime = createRuntime(level), + options = {} +) { + const recordHistory = options.recordHistory !== false; + + if (action.type !== "fastener") { + return { + ...state, + hintAction: null, + message: "Only fastener taps are supported in this slice." + }; + } + + const evaluation = evaluateFastener(level, state, action.id, runtime); + if (!evaluation.ok || !evaluation.removable || !evaluation.outcome) { + return { + ...state, + hintAction: null, + message: evaluation.reason ?? "That fastener cannot move right now." + }; + } + + const history = recordHistory ? [...state.history, snapshotState(state)] : state.history; + const baseState = { + ...state, + history, + hintAction: null, + failReason: null + }; + + if (evaluation.outcome.type === "overflow") { + const strip = runtime.stripMap.get(evaluation.outcome.stripId); + const next = removeOnly(baseState, action.id); + return { + ...next, + failed: true, + completed: false, + failReason: "overflow", + lastEvent: { + type: "overflow", + stripId: evaluation.outcome.stripId, + rowId: evaluation.outcome.rowId, + pathCellIds: evaluation.outcome.pathCellIds, + blockedCellId: evaluation.outcome.blockedCellId + }, + message: overflowMessage(strip, evaluation.outcome.rowTitle) + }; + } + + if (evaluation.outcome.type === "remove-only") { + const strip = evaluation.strip; + const next = removeOnly(baseState, action.id); + return finalizeProgress( + level, + { + ...next, + failed: false, + completed: false, + lastEvent: { + type: "remove-only", + stripId: strip.id, + fastenerId: action.id + }, + message: removeOnlyMessage(strip, evaluation.outcome.remainingBlockers[0], runtime) + }, + runtime + ); + } + + const placedStrip = runtime.stripMap.get(evaluation.outcome.stripId); + const next = placeStrip(baseState, placedStrip, action.id); + return finalizeProgress( + level, + { + ...next, + failed: false, + completed: false, + lastEvent: { + type: "place", + stripId: placedStrip.id, + fastenerId: action.id, + rowId: placedStrip.rowId, + targetCellIds: placedStrip.targetCellIds + }, + message: `${placedStrip.name} settles into ${runtime.rowMap.get(placedStrip.rowId)?.title ?? placedStrip.rowId}.` + }, + runtime + ); +} + +export function undoAction(state) { + if (!state.history.length) { + return { + ...state, + hintAction: null, + message: "Nothing to undo yet." + }; + } + + const snapshot = state.history[state.history.length - 1]; + return { + ...clone(snapshot), + history: state.history.slice(0, -1), + hintAction: null, + message: "Last release undone." + }; +} + +export function restartLevel(level) { + return createInitialState(level); +} + +export function stateKey(level, state) { + const removed = [...state.removedFasteners].sort().join(","); + const resolved = [...state.resolvedStrips].sort().join(","); + return `${removed}|${resolved}`; +} + +export function solveLevel(level, runtime = createRuntime(level), startState = createInitialState(level)) { + const queue = [{ state: startState, sequence: [] }]; + const visited = new Set([stateKey(level, startState)]); + + while (queue.length) { + const current = queue.shift(); + if (current.state.completed) { + return { + ok: true, + sequence: current.sequence + }; + } + + if (current.state.failed) { + continue; + } + + for (const action of collectLegalActions(level, current.state, runtime)) { + const next = applyAction(level, current.state, action, runtime, { recordHistory: false }); + if (next.failed) { + continue; + } + + const key = stateKey(level, next); + if (visited.has(key)) { + continue; + } + visited.add(key); + queue.push({ + state: next, + sequence: [...current.sequence, { type: action.type, id: action.id }] + }); + } + } + + return { + ok: false, + sequence: [], + reason: "Validator could not find a completion path." + }; +} + +export function findHint(level, state, runtime = createRuntime(level)) { + if (state.failed || state.completed) { + return { + action: null, + message: "No hint is available right now." + }; + } + + const solution = solveLevel(level, runtime, { + ...state, + history: [] + }); + + if (!solution.ok || !solution.sequence.length) { + return { + action: null, + message: "No legal hint is available from this state." + }; + } + + const action = solution.sequence[0]; + const evaluation = evaluateFastener(level, state, action.id, runtime); + const strip = evaluation.strip; + const row = strip ? runtime.rowMap.get(strip.rowId) : null; + + return { + action, + message: `Hint: lift ${strip?.name ?? action.id} toward ${row?.title ?? "its row"}.` + }; +} + +export function validateLevel(level) { + const runtime = createRuntime(level); + const issues = []; + + if (runtime.rows.length < 2 || runtime.rows.length > 4) { + issues.push("Each board should use 2 to 4 tray rows."); + } + + if (runtime.stacks.length < 2 || runtime.stacks.length > 5) { + issues.push("Each board should use 2 to 5 press stacks."); + } + + if (runtime.totalCells < 4 || runtime.totalCells > 12) { + issues.push("Each board should contain 4 to 12 tray cells."); + } + + const fastenerCount = runtime.fasteners.length; + if (fastenerCount < 1 || fastenerCount > 8) { + issues.push("Fastener count should stay between 1 and 8 for this slice."); + } + + const rowIds = new Set(); + for (const row of runtime.rows) { + if (rowIds.has(row.id)) { + issues.push(`Duplicate row id ${row.id}.`); + } + rowIds.add(row.id); + + if (row.entry !== "left" && row.entry !== "right") { + issues.push(`Row ${row.id} must enter from the left or right.`); + } + + if (row.cellIds.length < 2 || row.cellIds.length > 4) { + issues.push(`Row ${row.id} should contain 2 to 4 cells in this prototype.`); + } + } + + if (level.kind === "ftue" && level.number < 9 && runtime.rows.some((row) => row.entry === "right")) { + issues.push(`Level ${level.number} introduces right-entry rows too early.`); + } + + if (level.kind === "daily" && runtime.rows.some((row) => row.entry !== "left")) { + issues.push("Daily boards should only use already-unlocked left-entry rows."); + } + + const stripIds = new Set(); + for (const strip of runtime.strips) { + if (stripIds.has(strip.id)) { + issues.push(`Duplicate strip id ${strip.id}.`); + } + stripIds.add(strip.id); + + if (strip.shardCount < 1 || strip.shardCount > 3) { + issues.push(`Strip ${strip.id} must contain 1 to 3 shards.`); + } + + if (strip.blockerIds.length < 1 || strip.blockerIds.length > 2) { + issues.push(`Strip ${strip.id} must carry 1 or 2 fasteners.`); + } + + if (!runtime.stackMap.has(strip.stackId)) { + issues.push(`Strip ${strip.id} references unknown stack ${strip.stackId}.`); + } + + if (!runtime.rowMap.has(strip.rowId)) { + issues.push(`Strip ${strip.id} references unknown row ${strip.rowId}.`); + } + } + + const fastenerIds = new Set(); + for (const fastener of runtime.fasteners) { + if (fastenerIds.has(fastener.id)) { + issues.push(`Duplicate fastener id ${fastener.id}.`); + } + fastenerIds.add(fastener.id); + + if (!runtime.stripMap.has(fastener.stripId)) { + issues.push(`Fastener ${fastener.id} references unknown strip ${fastener.stripId}.`); + } + + for (const blockedId of fastener.blockedBy) { + if (!runtime.fastenerMap.has(blockedId)) { + issues.push(`Fastener ${fastener.id} is blocked by unknown fastener ${blockedId}.`); + } + } + } + + for (const stack of runtime.stacks) { + const stripSet = new Set(); + for (const stripId of stack.stripIds) { + if (!runtime.stripMap.has(stripId)) { + issues.push(`Stack ${stack.id} references unknown strip ${stripId}.`); + } + if (stripSet.has(stripId)) { + issues.push(`Stack ${stack.id} repeats strip ${stripId}.`); + } + stripSet.add(stripId); + } + } + + const initialState = createInitialState(level); + if (!collectProductiveActions(level, initialState, runtime).length) { + issues.push("The board starts with no productive fastener."); + } + + if (level.mechanics.some((mechanic) => /repeated/i.test(mechanic))) { + const familyCounts = new Map(); + for (const strip of runtime.strips) { + familyCounts.set(strip.family, (familyCounts.get(strip.family) ?? 0) + 1); + } + if (![...familyCounts.values()].some((count) => count > 1)) { + issues.push(`${level.id} advertises repeated motifs but does not author any repeated family.`); + } + } + + if (level.kind === "daily") { + if (runtime.stacks.length < 4 || runtime.stacks.length > 5) { + issues.push("Daily boards should use 4 to 5 stacks."); + } + if (runtime.totalCells < 10 || runtime.totalCells > 12) { + issues.push("Daily boards should use 10 to 12 cells."); + } + } + + const solution = solveLevel(level, runtime); + if (!solution.ok) { + issues.push(solution.reason); + } + + return { + ok: issues.length === 0, + issues, + solution + }; +} diff --git a/mosaic-press/styles.css b/mosaic-press/styles.css new file mode 100644 index 0000000..a0ff7dc --- /dev/null +++ b/mosaic-press/styles.css @@ -0,0 +1,646 @@ +:root { + --bg: #efe4d4; + --ink: #2f2723; + --muted: #6a5a52; + --panel: rgba(255, 249, 242, 0.86); + --panel-strong: #fbf5ef; + --line: rgba(70, 49, 34, 0.14); + --shadow: 0 18px 45px rgba(76, 48, 30, 0.16); + --wood: linear-gradient(180deg, #d8b28b 0%, #bc8f63 100%); + --wood-dark: linear-gradient(180deg, #a87149 0%, #80502e 100%); + --glow: rgba(250, 236, 215, 0.72); + font-family: "Trebuchet MS", "Avenir Next", sans-serif; + color: var(--ink); + background: + radial-gradient(circle at top, rgba(255, 255, 255, 0.55), transparent 34%), + linear-gradient(180deg, #f6eee5 0%, #eadbc8 50%, #e0ccb7 100%); +} + +* { + box-sizing: border-box; +} + +body { + margin: 0; + min-height: 100vh; + color: var(--ink); + background: + radial-gradient(circle at 15% 20%, rgba(255, 255, 255, 0.85), transparent 18%), + radial-gradient(circle at 80% 0%, rgba(255, 241, 226, 0.8), transparent 22%), + linear-gradient(180deg, #f4ece3 0%, #ebdbc9 52%, #ddc6b0 100%); +} + +button { + font: inherit; +} + +#app { + min-height: 100vh; + padding: 18px; +} + +.eyebrow { + margin: 0 0 4px; + font-size: 0.72rem; + letter-spacing: 0.12em; + text-transform: uppercase; + color: var(--muted); +} + +h1, +h2, +h3, +strong { + font-family: Georgia, "Times New Roman", serif; +} + +.button, +.hud__button, +.hud__back { + border: 0; + border-radius: 999px; + background: var(--ink); + color: #fdf7f0; + cursor: pointer; + transition: transform 120ms ease, opacity 120ms ease, background 120ms ease; +} + +.button:hover, +.hud__button:hover, +.hud__back:hover, +.fastener:hover { + transform: translateY(-1px); +} + +.button:disabled { + opacity: 0.45; + cursor: not-allowed; +} + +.button { + padding: 10px 16px; + min-height: 42px; +} + +.button--ghost, +.hud__button, +.hud__back { + background: rgba(47, 39, 35, 0.08); + color: var(--ink); + border: 1px solid rgba(47, 39, 35, 0.12); +} + +.gallery-pill { + display: inline-flex; + align-items: center; + gap: 6px; + min-height: 28px; + padding: 6px 12px; + border-radius: 999px; + background: rgba(255, 255, 255, 0.6); + color: var(--muted); + border: 1px solid rgba(47, 39, 35, 0.1); + font-size: 0.78rem; +} + +.gallery-screen, +.play-screen { + width: min(100%, 1120px); + margin: 0 auto; +} + +.hero, +.gallery-card, +.gallery-pack, +.card-ribbon, +.press-field, +.tray, +.modal-card, +.hud { + border: 1px solid var(--line); + box-shadow: var(--shadow); +} + +.hero { + display: grid; + gap: 18px; + padding: 22px; + border-radius: 28px; + background: + radial-gradient(circle at top right, rgba(255, 255, 255, 0.95), transparent 32%), + linear-gradient(135deg, rgba(255, 248, 240, 0.92), rgba(245, 231, 214, 0.88)); +} + +.hero h1 { + margin: 0; + font-size: clamp(2.4rem, 7vw, 4.4rem); +} + +.hero__text { + margin: 0; + max-width: 45ch; + line-height: 1.5; + color: var(--muted); +} + +.hero__status { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 12px; +} + +.hero__stat, +.gallery-card, +.gallery-pack, +.level-tile, +.beat-panel, +.card-ribbon, +.hud, +.tray-row, +.stack, +.modal-card { + background: var(--panel); + backdrop-filter: blur(10px); +} + +.hero__stat { + padding: 14px; + border-radius: 18px; + background: rgba(255, 255, 255, 0.58); +} + +.hero__stat span { + display: block; + font-size: 0.75rem; + color: var(--muted); +} + +.hero__stat strong { + font-size: 1.4rem; +} + +.gallery-card, +.gallery-pack { + margin-top: 18px; + padding: 20px; + border-radius: 24px; +} + +.gallery-card { + display: flex; + justify-content: space-between; + gap: 16px; + align-items: center; +} + +.gallery-card h2, +.gallery-pack h2, +.card-ribbon h2, +.modal-card h2, +.hud h1 { + margin: 0; +} + +.gallery-card p, +.gallery-pack p, +.level-tile p, +.beat-panel p, +.modal-card p { + margin: 0; + line-height: 1.45; + color: var(--muted); +} + +.gallery-card__actions { + display: grid; + justify-items: end; + gap: 10px; +} + +.gallery-pack__header { + display: flex; + justify-content: space-between; + gap: 16px; + align-items: center; + margin-bottom: 18px; +} + +.level-grid { + display: grid; + gap: 12px; + grid-template-columns: repeat(2, minmax(0, 1fr)); +} + +.level-tile { + display: grid; + gap: 10px; + padding: 16px; + border-radius: 20px; + border: 1px solid rgba(47, 39, 35, 0.1); +} + +.level-tile--locked { + opacity: 0.55; +} + +.level-tile__head { + display: grid; + gap: 3px; +} + +.level-tile__head span { + font-size: 0.76rem; + color: var(--muted); +} + +.play-screen { + display: grid; + gap: 16px; +} + +.hud { + display: grid; + gap: 14px; + padding: 16px; + border-radius: 24px; +} + +.hud__left, +.hud__meta { + display: flex; + gap: 10px; + align-items: center; + flex-wrap: wrap; +} + +.hud__back, +.hud__button { + min-height: 38px; + padding: 8px 14px; +} + +.card-ribbon { + display: grid; + gap: 16px; + padding: 18px; + border-radius: 24px; + background: + linear-gradient(135deg, rgba(255, 255, 255, 0.9), rgba(250, 239, 226, 0.85)); +} + +.card-ribbon__copy { + display: grid; + gap: 6px; +} + +.card-ribbon__preview { + display: grid; + gap: 10px; + padding: 14px; + border-radius: 18px; + background: rgba(92, 64, 43, 0.08); +} + +.preview-row, +.tray-row__cells { + display: flex; + align-items: center; + gap: 8px; +} + +.preview-row__entry, +.tray-row__entry { + min-width: 28px; + font-size: 0.72rem; + font-weight: 700; + color: var(--muted); +} + +.preview-cell, +.tray-cell, +.strip-shard { + position: relative; + display: grid; + place-items: center; + border-radius: 10px; + font-size: 0.66rem; + font-weight: 700; + letter-spacing: 0.04em; +} + +.preview-cell { + width: 36px; + height: 36px; + border: 2px solid color-mix(in srgb, var(--trim) 72%, white); + background: + linear-gradient(145deg, rgba(255, 255, 255, 0.6), transparent), + var(--tone); + color: color-mix(in srgb, var(--trim) 85%, black); + opacity: 0.45; +} + +.preview-cell.is-filled { + opacity: 1; +} + +.press-field { + display: grid; + gap: 16px; +} + +.press-field__board { + position: relative; + display: grid; + gap: 12px; + grid-template-columns: repeat(auto-fit, minmax(132px, 1fr)); + padding: 18px; + border-radius: 28px; + background: + linear-gradient(135deg, rgba(255, 251, 245, 0.38), rgba(255, 255, 255, 0.1)), + var(--wood); +} + +.press-field__board::before { + content: ""; + position: absolute; + inset: 12px; + border-radius: 22px; + border: 1px solid rgba(255, 248, 238, 0.25); + pointer-events: none; +} + +.stack { + position: relative; + display: grid; + gap: 10px; + padding: 14px; + border-radius: 22px; + border: 1px solid rgba(68, 43, 24, 0.16); + background: + linear-gradient(180deg, rgba(255, 255, 255, 0.52), rgba(255, 255, 255, 0.2)), + var(--wood-dark); + color: #fff9f2; +} + +.stack--cleared { + opacity: 0.72; +} + +.stack__header span { + display: block; + font-size: 0.72rem; + letter-spacing: 0.08em; + text-transform: uppercase; + color: rgba(255, 250, 244, 0.72); +} + +.stack__body { + position: relative; + min-height: 156px; +} + +.stack__empty { + display: grid; + place-items: center; + height: 100%; + min-height: 126px; + border-radius: 18px; + color: rgba(255, 248, 240, 0.68); + border: 1px dashed rgba(255, 248, 240, 0.3); +} + +.strip-card { + position: absolute; + inset-inline: 0; + min-height: 122px; + padding: 14px; + border-radius: 20px; + border: 1px solid rgba(55, 39, 25, 0.16); + background: + radial-gradient(circle at top left, rgba(255, 255, 255, 0.9), transparent 32%), + linear-gradient(180deg, #f7eee6 0%, #ecddd2 100%); + color: var(--ink); + transform: translateY(calc(var(--lift) * 1px)); +} + +.strip-card.is-hidden { + filter: saturate(0.78); + opacity: 0.74; +} + +.strip-card__meta { + display: grid; + gap: 2px; + margin-bottom: 10px; +} + +.strip-card__meta span { + font-size: 0.74rem; + color: var(--muted); +} + +.strip-card__shards { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 8px; +} + +.strip-shard { + min-height: 44px; + padding: 6px; + border: 2px solid color-mix(in srgb, var(--trim) 70%, white); + background: + linear-gradient(135deg, rgba(255, 255, 255, 0.6), transparent), + var(--tone); + color: color-mix(in srgb, var(--trim) 82%, black); +} + +.fastener { + position: absolute; + display: grid; + place-items: center; + width: 56px; + height: 56px; + border-radius: 50%; + border: 2px solid rgba(61, 46, 35, 0.18); + background: rgba(255, 248, 240, 0.96); + color: var(--ink); + cursor: pointer; +} + +.fastener--clamp { + background: #c59a67; + color: #fff7ec; +} + +.fastener--wax { + background: #b65f57; + color: #fff2ef; +} + +.fastener--spacer { + width: 64px; + height: 44px; + border-radius: 16px; + background: #efe3cb; + color: #745b44; +} + +.fastener.is-locked { + opacity: 0.68; +} + +.fastener.is-hint { + box-shadow: 0 0 0 6px rgba(255, 236, 168, 0.42); +} + +.fastener--left { + left: 10px; + top: 48px; +} + +.fastener--right { + right: 10px; + top: 48px; +} + +.fastener--top { + left: calc(50% - 28px); + top: 8px; +} + +.fastener--mouth { + right: 8px; + bottom: 10px; +} + +.beat-panel { + display: grid; + gap: 12px; + padding: 18px; + border-radius: 24px; +} + +.beat-panel__chip { + padding: 10px 12px; + border-radius: 18px; + background: rgba(47, 39, 35, 0.08); + color: var(--muted); + font-size: 0.82rem; +} + +.context-callout { + padding: 12px 14px; + border-radius: 18px; + background: rgba(223, 193, 160, 0.28); +} + +.tray { + display: grid; + gap: 12px; + padding: 18px; + border-radius: 28px; + background: + radial-gradient(circle at top, rgba(255, 255, 255, 0.75), transparent 28%), + linear-gradient(180deg, #ded2c4 0%, #b8a596 100%); +} + +.tray-row { + display: grid; + gap: 10px; + padding: 14px; + border-radius: 20px; + border: 1px solid rgba(47, 39, 35, 0.12); +} + +.tray-row__header { + display: flex; + justify-content: space-between; + gap: 10px; + align-items: center; +} + +.tray-row__header span { + font-size: 0.78rem; + color: var(--muted); +} + +.tray-cell { + width: 48px; + height: 48px; + border: 3px solid color-mix(in srgb, var(--trim) 68%, white); + background: rgba(255, 255, 255, 0.18); + color: color-mix(in srgb, var(--trim) 82%, black); +} + +.tray-cell.is-open { + border-style: dashed; + background: rgba(255, 255, 255, 0.12); + color: rgba(64, 51, 41, 0.58); +} + +.tray-cell.is-filled { + background: + linear-gradient(135deg, rgba(255, 255, 255, 0.62), transparent), + var(--tone); + box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.3); +} + +.tray-cell.is-overflow { + box-shadow: 0 0 0 4px rgba(188, 91, 73, 0.2); +} + +.modal-shell { + position: fixed; + inset: 0; + display: grid; + place-items: center; + padding: 18px; + background: rgba(39, 28, 19, 0.42); + z-index: 5; +} + +.modal-card { + width: min(100%, 420px); + display: grid; + gap: 14px; + padding: 24px; + border-radius: 26px; +} + +.modal-card__callouts { + display: grid; + gap: 10px; +} + +.modal-card__callouts p { + padding: 10px 12px; + border-radius: 16px; + background: rgba(47, 39, 35, 0.06); +} + +.modal-card__actions { + display: flex; + flex-wrap: wrap; + gap: 10px; +} + +@media (min-width: 900px) { + .hero { + grid-template-columns: 1.5fr 1fr; + align-items: end; + } + + .card-ribbon { + grid-template-columns: 1.1fr 1fr; + align-items: center; + } + + .press-field { + grid-template-columns: 1.7fr 0.9fr; + } + + .level-grid { + grid-template-columns: repeat(3, minmax(0, 1fr)); + } +} diff --git a/mosaic-press/tests/engine.test.js b/mosaic-press/tests/engine.test.js new file mode 100644 index 0000000..5096a90 --- /dev/null +++ b/mosaic-press/tests/engine.test.js @@ -0,0 +1,150 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +import { HANDCRAFTED_LEVELS, DAILY_THEME, buildLevelFromPlan } from "../src/data.js"; +import { + applyAction, + collectProductiveActions, + createInitialState, + createRuntime, + findHint, + validateLevel +} from "../src/engine.js"; +import { dailySeedFromDate, generateDailyLevel } from "../src/daily.js"; + +test("handcrafted levels follow the expected stack, row, and cell ramp", () => { + const expected = [ + [2, 2, 4], + [2, 2, 5], + [3, 3, 6], + [3, 3, 7], + [3, 3, 8], + [4, 3, 9], + [4, 3, 9], + [4, 3, 10], + [4, 4, 10], + [5, 4, 11], + [5, 4, 12], + [5, 4, 12] + ]; + + HANDCRAFTED_LEVELS.forEach((level, index) => { + assert.deepEqual( + [level.stacks.length, level.tray.rows.length, level.tray.cells.length], + expected[index], + `Unexpected authored counts for level ${level.number}` + ); + }); +}); + +test("every handcrafted level validates and solves", () => { + for (const level of HANDCRAFTED_LEVELS) { + const validation = validateLevel(level); + assert.equal(validation.ok, true, `${level.id} failed: ${validation.issues.join(" | ")}`); + assert.equal(validation.solution.ok, true, `${level.id} did not solve cleanly.`); + } +}); + +test("blocked wax tabs reject taps until the clamp lifts", () => { + const level = HANDCRAFTED_LEVELS[7]; + const runtime = createRuntime(level); + const start = createInitialState(level); + const next = applyAction(level, start, { type: "fastener", id: "f-wax" }, runtime); + + assert.deepEqual(next.removedFasteners, []); + assert.match(next.message, /wax ring is still tucked/i); +}); + +test("overflow fail triggers when a near cap seals the deeper cells", () => { + const level = HANDCRAFTED_LEVELS[4]; + const runtime = createRuntime(level); + const start = createInitialState(level); + const afterNearCap = applyAction(level, start, { type: "fastener", id: "b-clamp" }, runtime); + const overflow = applyAction(level, afterNearCap, { type: "fastener", id: "a-clamp" }, runtime); + + assert.equal(afterNearCap.failed, false); + assert.equal(overflow.failed, true); + assert.equal(overflow.failReason, "overflow"); + assert.match(overflow.message, /Gutter Overflow/i); +}); + +test("no move fail triggers when only nonproductive fasteners remain", () => { + const level = buildLevelFromPlan({ + id: "no-move-check", + number: 99, + packId: "garden-tiles", + title: "No Move Check", + caption: "Fixture", + beat: "Fixture", + mechanics: [], + callouts: [], + reward: { title: "Fixture", detail: "Fixture" }, + rows: [{ id: "r1", title: "Fixture row", entry: "left", segments: ["A", "B"] }], + stacks: [ + { id: "bench-a", title: "Bench A", stripIds: ["A"] }, + { id: "bench-b", title: "Bench B", stripIds: ["B"] } + ], + strips: [ + { + id: "A", + name: "Deep strip", + paletteId: "sky", + stackId: "bench-a", + labels: ["Deep", "Band"], + fasteners: [ + { id: "a-clamp", type: "clamp", slot: "left", blockedBy: [] }, + { + id: "a-spacer", + type: "spacer", + slot: "mouth", + blockedBy: ["a-clamp"], + blockedReason: "The spacer tail is still tucked behind the lifted clamp." + } + ] + }, + { + id: "B", + name: "Near cap", + paletteId: "cream", + stackId: "bench-b", + labels: ["Cap"], + fasteners: [{ id: "b-clamp", type: "clamp", slot: "right", blockedBy: [] }] + } + ] + }); + const runtime = createRuntime(level); + const start = createInitialState(level); + const productive = collectProductiveActions(level, start, runtime); + const next = applyAction(level, start, { type: "fastener", id: "b-clamp" }, runtime); + + assert.equal(productive.length, 1); + assert.equal(next.failed, true); + assert.equal(next.failReason, "no_move"); + assert.match(next.message, /No productive releases remain/i); +}); + +test("hint returns the next solvable fastener", () => { + const level = HANDCRAFTED_LEVELS[9]; + const runtime = createRuntime(level); + const hint = findHint(level, createInitialState(level), runtime); + + assert.ok(hint.action); + assert.match(hint.message, /Hint:/); +}); + +test("daily seed uses UTC formatting and generation is deterministic", () => { + const seed = dailySeedFromDate(new Date("2026-05-04T16:20:00+08:00")); + assert.equal(seed, "2026-05-04"); + + const first = generateDailyLevel("2026-05-04"); + const second = generateDailyLevel("2026-05-04"); + assert.deepEqual(first, second); + assert.equal(first.packId, DAILY_THEME.id); + + const validation = validateLevel(first); + assert.equal(validation.ok, true, validation.issues.join(" | ")); + assert.equal(validation.solution.ok, true); + assert.ok(first.stacks.length >= 4 && first.stacks.length <= 5); + assert.ok(first.tray.cells.length >= 10 && first.tray.cells.length <= 12); + assert.ok(validation.solution.sequence.length >= 6 && validation.solution.sequence.length <= 8); +});