diff --git a/.github/workflows/loom-rescue-preview.yml b/.github/workflows/loom-rescue-preview.yml new file mode 100644 index 0000000..8cf0c94 --- /dev/null +++ b/.github/workflows/loom-rescue-preview.yml @@ -0,0 +1,170 @@ +name: Loom Rescue Preview Deploy + +on: + pull_request: + types: + - opened + - reopened + - synchronize + - closed + paths: + - "loom-rescue/**" + - ".github/workflows/loom-rescue-preview.yml" + push: + branches: + - main + paths: + - "loom-rescue/**" + - ".github/workflows/loom-rescue-preview.yml" + +permissions: + contents: write + pull-requests: write + +concurrency: + group: loom-rescue-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 Loom Rescue + working-directory: loom-rescue + 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: ./loom-rescue + 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} + Loom Rescue 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 Loom Rescue 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: loom-rescue + STABLE_URL: https://${{ github.repository_owner }}.github.io/${{ github.event.repository.name }}/loom-rescue/ + 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 Loom Rescue + working-directory: loom-rescue + 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: ./loom-rescue + destination_dir: ${{ env.STABLE_DIR }} + keep_files: true + enable_jekyll: false + + - name: Print stable URL + run: echo "Stable Loom Rescue build deployed to ${STABLE_URL}" diff --git a/loom-rescue/README.md b/loom-rescue/README.md new file mode 100644 index 0000000..d488a7e --- /dev/null +++ b/loom-rescue/README.md @@ -0,0 +1,41 @@ +# Loom Rescue + +Standalone first-playable vertical slice for the Loom Rescue puzzle brief. + +## Run + +Open `index.html` in a browser, or serve the directory with any static file server. + +## Test + +```bash +npm test +``` + +## Verify + +```bash +npm run verify +``` + +## What Is Included + +- Data-driven rules for bundles, lock pins, static one-way guides, and two-layer crossings on a `6x8` portrait board +- Full FTUE path with `15` handcrafted levels plus a seeded daily template +- In-level flows for start, play, hint, undo, restart, fail, win, and postcard reveal +- Light reward wrapper with postcard progress and milestone stamps at levels `5`, `10`, `15`, and the daily +- Deadlock fail handling when no legal pulls or exposed pins remain +- Undo that only rewinds the last successful state change and does not refund knot loss from invalid pulls + +## Implementation Notes + +- The current validator proves solvability because the slice uses monotonic unlocks only. If the design later adds moving guides, true reverse-pull guide failures, partial pulls, or dynamic crossings, the authoring pipeline will need a deeper search-based solver. +- Readability risk rises sharply once multiple undeclared route corridors sit close together. The current prototype offsets thread rendering to keep the dense boards legible, but a production version should add stronger art treatment for top/bottom crossings and pin silhouettes. +- QA should focus on invalid pull reasons, deadlock fail messaging, two-knot daily failure tuning, daily unlock gating after level 15, and whether bundle labels remain readable on smaller mobile screens. + +## Preview Deployment + +- GitHub Actions workflow: `.github/workflows/loom-rescue-preview.yml` +- Pull requests deploy to `https://.github.io//previews/pr-/` +- `main` deploys the stable path to `https://.github.io//loom-rescue/` +- The first deployment still requires enabling GitHub Pages for the `gh-pages` branch in repository settings diff --git a/loom-rescue/index.html b/loom-rescue/index.html new file mode 100644 index 0000000..04dbb60 --- /dev/null +++ b/loom-rescue/index.html @@ -0,0 +1,13 @@ + + + + + + Loom Rescue + + + +
+ + + diff --git a/loom-rescue/package.json b/loom-rescue/package.json new file mode 100644 index 0000000..200924a --- /dev/null +++ b/loom-rescue/package.json @@ -0,0 +1,11 @@ +{ + "name": "loom-rescue", + "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/loom-rescue/src/app.js b/loom-rescue/src/app.js new file mode 100644 index 0000000..782ccd0 --- /dev/null +++ b/loom-rescue/src/app.js @@ -0,0 +1,792 @@ +import { dailySeedFromDate, generateDailyLevel } from "./daily.js"; +import { HANDCRAFTED_LEVELS, MILESTONE_LEVELS, POSTCARD_SETS } from "./data.js"; +import { + applyAction, + collectLegalActions, + createInitialState, + createRuntime, + evaluateBundle, + evaluatePin, + findHint, + getRevealRatio, + isBundleRemoved, + isPinRemoved, + restartLevel, + undoAction, + validateLevel +} from "./engine.js"; + +const STORAGE_KEY = "loom-rescue-progress-v1"; +const root = document.querySelector("#app"); +const CELL_SIZE = 100; +const BUNDLE_OFFSET_SCALE = 18; + +const appState = { + mode: "ftue", + levelIndex: 0, + dailySeed: dailySeedFromDate(), + progress: loadProgress(), + level: null, + runtime: null, + puzzle: null, + validation: null, + modal: "start" +}; + +function loadProgress() { + try { + const parsed = JSON.parse(localStorage.getItem(STORAGE_KEY) ?? "{}"); + return { + clearedFtue: Array.isArray(parsed.clearedFtue) ? parsed.clearedFtue : [], + dailySeeds: Array.isArray(parsed.dailySeeds) ? parsed.dailySeeds : [], + stamps: Array.isArray(parsed.stamps) ? parsed.stamps : [] + }; + } catch { + return { + clearedFtue: [], + dailySeeds: [], + stamps: [] + }; + } +} + +function saveProgress() { + localStorage.setItem(STORAGE_KEY, JSON.stringify(appState.progress)); +} + +function isDailyUnlocked() { + return appState.progress.clearedFtue.includes("ftue-15"); +} + +function getPackForLevel(level = appState.level) { + if (!level || level.kind === "daily") { + return null; + } + return POSTCARD_SETS.find((pack) => pack.id === level.packId) ?? null; +} + +function getNextPack(level = appState.level) { + const pack = getPackForLevel(level); + if (!pack) { + return null; + } + const index = POSTCARD_SETS.findIndex((entry) => entry.id === pack.id); + return POSTCARD_SETS[index + 1] ?? null; +} + +function getCompletedPackCount() { + return [...MILESTONE_LEVELS].filter((levelNumber) => + appState.progress.stamps.includes(`level:${levelNumber}`) + ).length; +} + +function getPackProgressPercent(level = appState.level, puzzle = appState.puzzle) { + if (!level) { + return 0; + } + + if (level.kind === "daily") { + return appState.progress.dailySeeds.includes(level.seed) ? 100 : 0; + } + + const packStart = Math.floor((level.number - 1) / 5) * 5 + 1; + const packEnd = packStart + 4; + const packLevels = HANDCRAFTED_LEVELS.filter( + (entry) => entry.number >= packStart && entry.number <= packEnd + ); + const clearedCount = packLevels.filter((entry) => + appState.progress.clearedFtue.includes(entry.id) + ).length; + + if (isLevelCleared(level)) { + return Math.min(100, clearedCount * 20); + } + + const currentFill = getRevealRatio(level, puzzle) * 20; + const clearedBeforeCurrent = packLevels.filter( + (entry) => entry.id !== level.id && appState.progress.clearedFtue.includes(entry.id) + ).length; + return Math.min(100, clearedBeforeCurrent * 20 + currentFill); +} + +function stampKeyFor(level) { + if (level.kind === "daily") { + return `daily:${level.seed}`; + } + if (MILESTONE_LEVELS.has(level.number)) { + return `level:${level.number}`; + } + return null; +} + +function isLevelCleared(level) { + if (level.kind === "daily") { + return appState.progress.dailySeeds.includes(level.seed); + } + return appState.progress.clearedFtue.includes(level.id); +} + +function recordWin(level) { + if (level.kind === "daily") { + if (!appState.progress.dailySeeds.includes(level.seed)) { + appState.progress.dailySeeds.push(level.seed); + } + } else if (!appState.progress.clearedFtue.includes(level.id)) { + appState.progress.clearedFtue.push(level.id); + } + + const stampKey = stampKeyFor(level); + if (stampKey && !appState.progress.stamps.includes(stampKey)) { + appState.progress.stamps.push(stampKey); + } + + saveProgress(); +} + +function loadLevel({ + mode = appState.mode, + levelIndex = appState.levelIndex, + dailySeed = appState.dailySeed, + showStart = true +} = {}) { + const dailyMode = mode === "daily" && isDailyUnlocked(); + + appState.mode = dailyMode ? "daily" : "ftue"; + appState.levelIndex = dailyMode ? levelIndex : Math.min(levelIndex, HANDCRAFTED_LEVELS.length - 1); + appState.dailySeed = dailySeed; + appState.level = + dailyMode ? generateDailyLevel(dailySeed) : HANDCRAFTED_LEVELS[appState.levelIndex]; + appState.runtime = createRuntime(appState.level); + appState.puzzle = createInitialState(appState.level); + appState.validation = validateLevel(appState.level); + appState.modal = showStart ? "start" : null; + document.title = + appState.level.kind === "daily" + ? `Loom Rescue • Daily ${appState.level.seed}` + : `Loom Rescue • Level ${appState.level.number}`; + render(); +} + +function currentStampEarned() { + const stampKey = stampKeyFor(appState.level); + return stampKey ? appState.progress.stamps.includes(stampKey) : false; +} + +function handleBundle(bundleId) { + appState.puzzle = applyAction( + appState.level, + appState.puzzle, + { type: "bundle", id: bundleId }, + appState.runtime + ); + + if (appState.puzzle.completed) { + recordWin(appState.level); + appState.modal = "win"; + } else if (appState.puzzle.failed) { + appState.modal = "fail"; + } + + render(); +} + +function handlePin(pinId) { + appState.puzzle = applyAction( + appState.level, + appState.puzzle, + { type: "pin", id: pinId }, + appState.runtime + ); + if (appState.puzzle.failed) { + appState.modal = "fail"; + } + render(); +} + +function openNextLevel() { + if (appState.mode === "ftue" && appState.levelIndex < HANDCRAFTED_LEVELS.length - 1) { + loadLevel({ levelIndex: appState.levelIndex + 1, showStart: true }); + return; + } + loadLevel({ mode: "daily", dailySeed: appState.dailySeed, showStart: true }); +} + +function arrowFor(direction) { + return { + up: "↑", + right: "→", + down: "↓", + left: "←" + }[direction]; +} + +function statusForBundle(bundle) { + const removed = isBundleRemoved(appState.puzzle, bundle.id); + if (removed) { + return { label: "Cleared", className: "is-cleared" }; + } + + const evaluation = evaluateBundle(appState.level, appState.puzzle, bundle.id, appState.runtime); + if (evaluation.ok) { + return { label: "Loose", className: "is-ready" }; + } + + return { label: "Blocked", className: "is-blocked", reason: evaluation.reason }; +} + +function pointToScreen(point, bundle) { + return { + x: point.x * CELL_SIZE + CELL_SIZE / 2 + (bundle.offset?.x ?? 0) * BUNDLE_OFFSET_SCALE, + y: point.y * CELL_SIZE + CELL_SIZE / 2 + (bundle.offset?.y ?? 0) * BUNDLE_OFFSET_SCALE + }; +} + +function pathFromBundle(bundle) { + return bundle.points + .map((point, index) => { + const screen = pointToScreen(point, bundle); + return `${index === 0 ? "M" : "L"} ${screen.x} ${screen.y}`; + }) + .join(" "); +} + +function renderBoardSvg() { + const hintAction = appState.puzzle.hintAction; + + const guides = appState.runtime.guides + .map((guide) => + guide.cells + .map((cell) => { + const x = cell.x * CELL_SIZE + 14; + const y = cell.y * CELL_SIZE + 14; + return ` + + + ${arrowFor(guide.direction)} + + `; + }) + .join("") + ) + .join(""); + + const crossings = appState.runtime.crossings + .map((crossing) => { + const over = appState.runtime.bundleMap.get(crossing.over); + const under = appState.runtime.bundleMap.get(crossing.under); + const centerX = crossing.cell.x * CELL_SIZE + CELL_SIZE / 2; + const centerY = crossing.cell.y * CELL_SIZE + CELL_SIZE / 2; + const inactive = + isBundleRemoved(appState.puzzle, crossing.over) || + isBundleRemoved(appState.puzzle, crossing.under); + + return ` + + + + + ${over.id} + + `; + }) + .join(""); + + const pins = appState.runtime.pins + .filter( + (pin) => + !isPinRemoved(appState.puzzle, pin.id) && + pin.blocks.some((bundleId) => !isBundleRemoved(appState.puzzle, bundleId)) + ) + .map((pin) => { + const evaluation = evaluatePin(appState.level, appState.puzzle, pin.id, appState.runtime); + const x = pin.cell.x * CELL_SIZE + CELL_SIZE / 2; + const y = pin.cell.y * CELL_SIZE + CELL_SIZE / 2; + return ` + + + PIN + + `; + }) + .join(""); + + const bundles = appState.runtime.bundles + .map((bundle) => { + const removed = isBundleRemoved(appState.puzzle, bundle.id); + const evaluation = evaluateBundle(appState.level, appState.puzzle, bundle.id, appState.runtime); + const isHint = hintAction?.type === "bundle" && hintAction.id === bundle.id; + const path = pathFromBundle(bundle); + const start = pointToScreen(bundle.points[0], bundle); + const end = pointToScreen(bundle.points[bundle.points.length - 1], bundle); + const classNames = [ + "thread-bundle", + removed ? "is-removed" : "", + evaluation.ok ? "is-ready" : "is-blocked", + isHint ? "is-hinted" : "" + ] + .filter(Boolean) + .join(" "); + + return ` + + + + + + ${arrowFor( + bundle.pullDirection + )} + ${bundle.id} + + `; + }) + .join(""); + + return ` + + + + + + + + + ${guides} + ${bundles} + ${crossings} + ${pins} + + `; +} + +function renderBundleTray() { + return appState.runtime.bundles + .map((bundle) => { + const status = statusForBundle(bundle); + const hint = appState.puzzle.hintAction?.type === "bundle" && appState.puzzle.hintAction.id === bundle.id; + return ` + + `; + }) + .join(""); +} + +function renderRemainingList() { + return appState.runtime.bundles + .map((bundle) => { + const status = statusForBundle(bundle); + return ` +
  • + ${bundle.id} ${bundle.name} + ${status.label} +
  • + `; + }) + .join(""); +} + +function renderSolutionSequence() { + const steps = appState.validation.solution.sequence + .map((action) => { + if (action.type === "pin") { + return `Lift ${appState.runtime.pinMap.get(action.id)?.name ?? action.id}`; + } + return `Pull ${appState.runtime.bundleMap.get(action.id)?.name ?? action.id}`; + }) + .join(" → "); + + return steps || "Validator did not need a sequence."; +} + +function renderKnotMeter() { + return Array.from({ length: appState.level.failLimit }, (_, index) => { + const filled = index < appState.puzzle.knots; + return ``; + }).join(""); +} + +function renderModal() { + if (!appState.modal) { + return ""; + } + + const pack = getPackForLevel(); + const nextPack = getNextPack(); + const packProgress = Math.round(getPackProgressPercent()); + + if (appState.modal === "start") { + return ` + + `; + } + + if (appState.modal === "fail") { + return ` + + `; + } + + if (appState.modal === "postcard") { + return ` + + `; + } + + const stampKey = stampKeyFor(appState.level); + const stampLine = stampKey + ? `

    ${currentStampEarned() ? (appState.level.kind === "daily" ? "Foil stamp added to album." : "Stamp added to album.") : "Stamp ready."}

    ` + : ""; + + return ` + + `; +} + +function render() { + const revealPercent = Math.round(getRevealRatio(appState.level, appState.puzzle) * 100); + const completedPacks = getCompletedPackCount(); + const legalActions = collectLegalActions(appState.level, appState.puzzle, appState.runtime); + const currentPack = getPackForLevel(); + const packProgress = Math.round(getPackProgressPercent()); + const dailyUnlocked = isDailyUnlocked(); + + root.innerHTML = ` +
    +
    +
    +

    Loom Rescue

    +

    Restore quiet postcard scenes by releasing thread bundles in the right order.

    +

    + Whole bundles only. Static guides. Crossing order and pin timing carry the puzzle. +

    +
    +
    +

    Album Progress

    +

    ${completedPacks}/3 postcard sets

    +

    ${appState.progress.clearedFtue.length}/15 level repairs complete

    +
    + +
    +
    + L5 + L10 + L15 + DAY +
    +
    +
    + +
    +
    + ${HANDCRAFTED_LEVELS.map( + (level, index) => ` + + ` + ).join("")} +
    +
    + + +
    +
    + +
    +
    +
    +
    + +
    +

    ${appState.level.kind === "daily" ? "Daily Challenge" : `Level ${appState.level.number}`}

    +

    ${appState.level.title}

    +

    ${appState.level.postcard.caption}

    +
    +
    +
    + ${appState.level.kind === "daily" ? "Foil Stamp" : currentPack?.title ?? "Postcard Pack"} +
    + +
    + Knots +
    ${renderKnotMeter()}
    + Reveal ${revealPercent}% +
    +
    + +
    +
    + ${renderBoardSvg()} +
    + +
    + + + +
    + +
    + ${renderBundleTray()} +
    +
    + + +
    + ${renderModal()} +
    + `; +} + +root.addEventListener("click", (event) => { + const target = event.target.closest("[data-action]"); + if (!target) { + return; + } + + const { action } = target.dataset; + if (action === "select-ftue") { + loadLevel({ mode: "ftue", levelIndex: Number(target.dataset.index), showStart: true }); + return; + } + + if (action === "select-daily") { + if (isDailyUnlocked()) { + loadLevel({ mode: "daily", dailySeed: appState.dailySeed, showStart: true }); + } + return; + } + + if (action === "start-level") { + appState.modal = null; + render(); + return; + } + + if (action === "bundle") { + if (!appState.modal) { + handleBundle(target.dataset.id); + } + return; + } + + if (action === "pin") { + if (!appState.modal) { + handlePin(target.dataset.id); + } + return; + } + + if (action === "undo") { + appState.puzzle = undoAction(appState.puzzle); + appState.modal = null; + render(); + return; + } + + if (action === "hint") { + const hint = findHint(appState.level, appState.puzzle, appState.runtime); + appState.puzzle = { + ...appState.puzzle, + hintAction: hint.action, + message: hint.message + }; + render(); + return; + } + + if (action === "restart-level") { + appState.puzzle = restartLevel(appState.level); + appState.modal = "start"; + render(); + return; + } + + if (action === "retry-level") { + appState.puzzle = restartLevel(appState.level); + appState.modal = null; + render(); + return; + } + + if (action === "fail-hint") { + appState.puzzle = restartLevel(appState.level); + const hint = findHint(appState.level, appState.puzzle, appState.runtime); + appState.puzzle = { + ...appState.puzzle, + hintAction: hint.action, + message: hint.message + }; + appState.modal = null; + render(); + return; + } + + if (action === "quit-level") { + appState.puzzle = restartLevel(appState.level); + appState.modal = "start"; + render(); + return; + } + + if (action === "view-postcard") { + appState.modal = "postcard"; + render(); + return; + } + + if (action === "close-postcard") { + appState.modal = "win"; + render(); + return; + } + + if (action === "next-level") { + openNextLevel(); + } +}); + +root.addEventListener("change", (event) => { + const target = event.target; + if (!(target instanceof HTMLInputElement)) { + return; + } + + if (target.matches("[data-seed-input]") && target.value) { + appState.dailySeed = target.value; + if (appState.mode === "daily" && isDailyUnlocked()) { + loadLevel({ mode: "daily", dailySeed: target.value, showStart: true }); + } else { + render(); + } + } +}); + +loadLevel(); diff --git a/loom-rescue/src/daily.js b/loom-rescue/src/daily.js new file mode 100644 index 0000000..8992c7e --- /dev/null +++ b/loom-rescue/src/daily.js @@ -0,0 +1,135 @@ +import { + BOARD, + BUNDLE_LIBRARY, + CROSSING_LIBRARY, + GUIDE_LIBRARY, + PIN_SLOTS, + pickBundles +} from "./data.js"; + +const clone = (value) => JSON.parse(JSON.stringify(value)); + +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 t = (seed += 0x6d2b79f5); + t = Math.imul(t ^ (t >>> 15), t | 1); + t ^= t + Math.imul(t ^ (t >>> 7), t | 61); + return ((t ^ (t >>> 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; +} + +const DAILY_POSTCARDS = [ + { + title: "Daily Harbor", + caption: "Two knots only. Read the top layer before you pull.", + gradient: ["#efe2d0", "#7f91b5", "#47647b"], + accent: "#647da8" + }, + { + title: "Daily Garden", + caption: "Static guides stay fixed. Use them to scan the clean exit.", + gradient: ["#f5e6d4", "#a7b765", "#68895a"], + accent: "#8fa95a" + }, + { + title: "Daily Lantern", + caption: "The daily template reorders pins and crossings from the same data model.", + gradient: ["#f2e2cf", "#c1735c", "#7b62ac"], + accent: "#ac634b" + } +]; + +export function dailySeedFromDate(date = new Date()) { + const year = date.getFullYear(); + const month = String(date.getMonth() + 1).padStart(2, "0"); + const day = String(date.getDate()).padStart(2, "0"); + return `${year}-${month}-${day}`; +} + +export function generateDailyLevel(seed = dailySeedFromDate()) { + const random = mulberry32(hashSeed(seed)()); + const bundleIds = shuffle(Object.keys(BUNDLE_LIBRARY), random); + const bundles = pickBundles(bundleIds); + const order = shuffle(bundleIds, random); + const orderIndex = Object.fromEntries(order.map((bundleId, index) => [bundleId, index])); + const crossingCount = random() > 0.5 ? 4 : 3; + + const crossings = shuffle(Object.values(CROSSING_LIBRARY), random) + .filter((crossing) => crossing.pair.every((bundleId) => bundleIds.includes(bundleId))) + .slice(0, crossingCount) + .map((crossing, index) => { + const [first, second] = crossing.pair; + const over = orderIndex[first] < orderIndex[second] ? first : second; + const under = over === first ? second : first; + return { + id: `daily-cross-${index + 1}`, + slot: crossing.slot, + label: crossing.label, + cell: clone(crossing.cell), + over, + under + }; + }); + + const pinTargets = shuffle(order.slice(2), random).slice(0, 2); + const pins = pinTargets.map((bundleId, index) => { + const earlier = order.slice(0, orderIndex[bundleId]); + const requiredCount = Math.min(earlier.length, 1 + Math.floor(random() * Math.min(2, earlier.length))); + const requiresClear = shuffle(earlier, random).slice(0, requiredCount); + return { + id: `daily-pin-${index + 1}`, + name: `${BUNDLE_LIBRARY[bundleId].name} clasp`, + cell: clone(PIN_SLOTS[bundleId]), + blocks: [bundleId], + requiresClear + }; + }); + + const guides = shuffle(bundleIds, random) + .slice(0, 2) + .map((bundleId, index) => ({ + ...clone(GUIDE_LIBRARY[bundleId]), + id: `daily-guide-${index + 1}` + })); + + const postcard = clone(DAILY_POSTCARDS[Math.floor(random() * DAILY_POSTCARDS.length)]); + + return { + id: `daily-${seed}`, + kind: "daily", + seed, + title: `Daily Tangle • ${seed}`, + number: null, + beat: "Daily boards keep the same rules, but the foil stamp only gives you two knots and a seeded 8-step weave.", + mechanics: ["2-knot fail meter", "2 pins", "2 guide chains", "Seeded template"], + board: BOARD, + failLimit: 2, + postcard, + bundles, + pins, + guides, + crossings + }; +} diff --git a/loom-rescue/src/data.js b/loom-rescue/src/data.js new file mode 100644 index 0000000..f4449af --- /dev/null +++ b/loom-rescue/src/data.js @@ -0,0 +1,529 @@ +export const BOARD = Object.freeze({ cols: 6, rows: 8 }); +export const STANDARD_FAIL_LIMIT = 3; +export const MILESTONE_LEVELS = new Set([5, 10, 15]); +export const POSTCARD_SETS = Object.freeze([ + { + id: "coastal-morning", + title: "Coastal Morning", + accent: "#cf6f4c", + gradient: ["#f3dbc0", "#df8d67", "#9f533f"], + nextTitle: "Flower Shop Window" + }, + { + id: "flower-shop-window", + title: "Flower Shop Window", + accent: "#8b7a4c", + gradient: ["#f7e7c9", "#c7ab71", "#7b9d7a"], + nextTitle: "Tea House Garden" + }, + { + id: "tea-house-garden", + title: "Tea House Garden", + accent: "#6a7ea3", + gradient: ["#efe0cf", "#7f91b5", "#47647b"], + nextTitle: "Daily Foil Stamp" + } +]); + +const pt = (x, y) => ({ x, y }); +const clone = (value) => JSON.parse(JSON.stringify(value)); +const POSTCARD_SET_MAP = Object.fromEntries(POSTCARD_SETS.map((set) => [set.id, set])); + +export const BUNDLE_LIBRARY = Object.freeze({ + A: { + id: "A", + name: "Dawn Ribbon", + color: "#dd6f63", + accent: "#8f3d38", + offset: { x: -0.18, y: -0.12 }, + points: [pt(1, 0), pt(1, 2), pt(4, 2), pt(4, 4)] + }, + B: { + id: "B", + name: "Harbor Cord", + color: "#2e7a78", + accent: "#1e4f4d", + offset: { x: 0.18, y: -0.08 }, + points: [pt(5, 1), pt(3, 1), pt(3, 4), pt(1, 4), pt(1, 7)] + }, + C: { + id: "C", + name: "Garden Weft", + color: "#d8a145", + accent: "#8c6428", + offset: { x: 0.12, y: 0.16 }, + points: [pt(5, 7), pt(5, 5), pt(2, 5), pt(2, 1)] + }, + D: { + id: "D", + name: "Lantern Strand", + color: "#5b66cf", + accent: "#363d85", + offset: { x: -0.14, y: 0.12 }, + points: [pt(0, 6), pt(2, 6), pt(2, 3), pt(5, 3)] + }, + E: { + id: "E", + name: "Postmark Loop", + color: "#7b9b72", + accent: "#4d6549", + offset: { x: -0.08, y: -0.18 }, + points: [pt(3, 0), pt(3, 1), pt(0, 1)] + }, + F: { + id: "F", + name: "Shore Twine", + color: "#b86a96", + accent: "#73415d", + offset: { x: 0.16, y: 0.14 }, + points: [pt(5, 4), pt(4, 4), pt(4, 7)] + } +}); + +export const GUIDE_LIBRARY = Object.freeze({ + A: { + id: "guide-a", + name: "North Sleeve", + direction: "up", + bundleIds: ["A"], + cells: [pt(1, 1)] + }, + B: { + id: "guide-b", + name: "Harbor Gate", + direction: "right", + bundleIds: ["B"], + cells: [pt(4, 1), pt(3, 1)] + }, + C: { + id: "guide-c", + name: "Garden Drop", + direction: "down", + bundleIds: ["C"], + cells: [pt(5, 6)] + }, + D: { + id: "guide-d", + name: "Lantern Rail", + direction: "left", + bundleIds: ["D"], + cells: [pt(1, 6)] + }, + E: { + id: "guide-e", + name: "Postmark Notch", + direction: "up", + bundleIds: ["E"], + cells: [pt(3, 0)] + }, + F: { + id: "guide-f", + name: "Shore Slide", + direction: "right", + bundleIds: ["F"], + cells: [pt(5, 4)] + } +}); + +export const PIN_SLOTS = Object.freeze({ + A: pt(4, 3), + B: pt(1, 6), + C: pt(5, 6), + D: pt(2, 4), + E: pt(2, 1), + F: pt(4, 6) +}); + +export const CROSSING_LIBRARY = Object.freeze({ + AB: { slot: "AB", cell: pt(3, 2), pair: ["A", "B"], label: "braid shelf" }, + AC: { slot: "AC", cell: pt(2, 2), pair: ["A", "C"], label: "flower latch" }, + AD: { slot: "AD", cell: pt(4, 3), pair: ["A", "D"], label: "loom bridge" }, + AE: { slot: "AE", cell: pt(1, 1), pair: ["A", "E"], label: "dawn notch" }, + AF: { slot: "AF", cell: pt(4, 4), pair: ["A", "F"], label: "shore seam" }, + BD: { slot: "BD", cell: pt(3, 3), pair: ["B", "D"], label: "harbor bridge" }, + BE: { slot: "BE", cell: pt(3, 1), pair: ["B", "E"], label: "post tuck" }, + CD: { slot: "CD", cell: pt(2, 5), pair: ["C", "D"], label: "garden arch" }, + CE: { slot: "CE", cell: pt(2, 1), pair: ["C", "E"], label: "latch knot" }, + CF: { slot: "CF", cell: pt(4, 5), pair: ["C", "F"], label: "shore arch" } +}); + +const postcard = (title, caption, gradient, accent) => ({ + title, + caption, + gradient, + accent +}); + +const postcardForSet = (setId, caption) => { + const set = POSTCARD_SET_MAP[setId]; + return postcard(set.title, caption, set.gradient, set.accent); +}; + +const createLevel = ({ + id, + number, + packId, + title, + beat, + mechanics, + postcard: postcardData, + bundles, + pins = [], + guides = [], + crossings = [] +}) => ({ + id, + kind: "ftue", + number, + packId, + title, + beat, + mechanics, + board: BOARD, + failLimit: STANDARD_FAIL_LIMIT, + postcard: postcardData, + bundles, + pins, + guides, + crossings +}); + +export const pickBundles = (bundleIds) => bundleIds.map((id) => clone(BUNDLE_LIBRARY[id])); + +export const pickGuides = (bundleIds) => + bundleIds.map((bundleId, index) => ({ + ...clone(GUIDE_LIBRARY[bundleId]), + id: `guide-${bundleId.toLowerCase()}-${index + 1}` + })); + +export const makeCrossing = (slotId, over, under) => { + const slot = CROSSING_LIBRARY[slotId]; + return { + id: `cross-${slotId.toLowerCase()}-${over.toLowerCase()}-${under.toLowerCase()}`, + slot: slot.slot, + cell: clone(slot.cell), + label: slot.label, + over, + under + }; +}; + +export const makePin = (slotId, config) => ({ + id: config.id, + name: config.name, + cell: clone(PIN_SLOTS[slotId]), + blocks: [...config.blocks], + requiresClear: [...config.requiresClear] +}); + +export const HANDCRAFTED_LEVELS = [ + createLevel({ + id: "ftue-01", + number: 1, + packId: "coastal-morning", + title: "First Pull", + beat: "Learn whole-bundle extraction with two loose exits and no blockers.", + mechanics: ["Whole-bundle extraction"], + postcard: postcardForSet( + "coastal-morning", + "Restore the first postcard by clearing two free threads from the outer ring." + ), + bundles: pickBundles(["E", "F"]) + }), + createLevel({ + id: "ftue-02", + number: 2, + packId: "coastal-morning", + title: "Over First", + beat: "A top bundle must leave before the lower crossing can move.", + mechanics: ["Two-layer crossing"], + postcard: postcardForSet( + "coastal-morning", + "A single crossing teaches that the visible top strand resolves before the lower strand." + ), + bundles: pickBundles(["A", "B", "E"]), + crossings: [makeCrossing("AB", "B", "A")] + }), + createLevel({ + id: "ftue-03", + number: 3, + packId: "coastal-morning", + title: "Read the Edge", + beat: "Mixed exit sides teach players to trace the assigned border, not the nearest edge.", + mechanics: ["Assigned exit vectors"], + postcard: postcardForSet( + "coastal-morning", + "Three bundles leave from different borders, so the clean read starts at the edge." + ), + bundles: pickBundles(["B", "D", "E"]) + }), + createLevel({ + id: "ftue-04", + number: 4, + packId: "coastal-morning", + title: "First Pin", + beat: "An exposed pin must lift before the deeper bundle can leave.", + mechanics: ["Lock pins"], + postcard: postcardForSet( + "coastal-morning", + "Tap the exposed pin first, then finish the gentle pull sequence." + ), + bundles: pickBundles(["B", "C", "E"]), + pins: [ + makePin("C", { + id: "pin-c-first", + name: "First clasp", + blocks: ["C"], + requiresClear: ["E"] + }) + ] + }), + createLevel({ + id: "ftue-05", + number: 5, + packId: "coastal-morning", + title: "Pin Then Pull Chain", + beat: "A pin and crossing interleave into the first postcard-set finish.", + mechanics: ["Pin and crossing chain"], + postcard: postcardForSet( + "coastal-morning", + "Clear the top layer, lift the clasp it was hiding, and stitch the first postcard set closed." + ), + bundles: pickBundles(["A", "B", "C", "E"]), + pins: [ + makePin("C", { + id: "pin-c-chain", + name: "Chain clasp", + blocks: ["C"], + requiresClear: ["A"] + }) + ], + crossings: [makeCrossing("AB", "B", "A")] + }), + createLevel({ + id: "ftue-06", + number: 6, + packId: "flower-shop-window", + title: "Arrow Direction", + beat: "Static guide arrows enter the slice as readable exit signage.", + mechanics: ["Static one-way guides"], + postcard: postcardForSet( + "flower-shop-window", + "The first guide board stays light: read the fixed arrow cue before committing a pull." + ), + bundles: pickBundles(["B", "C", "E", "F"]), + guides: pickGuides(["B"]) + }), + createLevel({ + id: "ftue-07", + number: 7, + packId: "flower-shop-window", + title: "Crossing + Guide", + beat: "A lower-layer path can still need guide read before it becomes actionable.", + mechanics: ["Guide on a lower-layer path"], + postcard: postcardForSet( + "flower-shop-window", + "A crossing and guide share the same read, keeping the blocker cluster local and legible." + ), + bundles: pickBundles(["A", "B", "C", "E"]), + guides: pickGuides(["A"]), + crossings: [makeCrossing("AB", "B", "A")] + }), + createLevel({ + id: "ftue-08", + number: 8, + packId: "flower-shop-window", + title: "Covered Pin", + beat: "Two pins stage a clean sequence without letting the board turn noisy.", + mechanics: ["Two pins on layered routes"], + postcard: postcardForSet( + "flower-shop-window", + "The visible path is simple, but the second clasp only matters after the first branch clears." + ), + bundles: pickBundles(["A", "B", "C", "D", "E"]), + pins: [ + makePin("C", { + id: "pin-c-covered", + name: "Covered clasp", + blocks: ["C"], + requiresClear: ["A"] + }), + makePin("D", { + id: "pin-d-covered", + name: "Lower rail pin", + blocks: ["D"], + requiresClear: ["B"] + }) + ] + }), + createLevel({ + id: "ftue-09", + number: 9, + packId: "flower-shop-window", + title: "False Easy Exit", + beat: "Several bundles look equally loose, but tracing the full path matters more than color.", + mechanics: ["Mixed exit-side scan"], + postcard: postcardForSet( + "flower-shop-window", + "This board mirrors multiple edge reads so the player must scan before tugging." + ), + bundles: pickBundles(["B", "C", "D", "E", "F"]) + }), + createLevel({ + id: "ftue-10", + number: 10, + packId: "flower-shop-window", + title: "Mid-arc Mix", + beat: "Pins, guide signage, and a single crossing combine into the second postcard finish.", + mechanics: ["Two pins plus one guide cluster"], + postcard: postcardForSet( + "flower-shop-window", + "This is the mid-arc check: solve a local blocker cluster, then stamp the second postcard set." + ), + bundles: pickBundles(["A", "B", "C", "E", "F"]), + pins: [ + makePin("A", { + id: "pin-a-midarc", + name: "Window pin", + blocks: ["A"], + requiresClear: ["E"] + }), + makePin("C", { + id: "pin-c-midarc", + name: "Stem clasp", + blocks: ["C"], + requiresClear: ["A"] + }) + ], + guides: pickGuides(["B"]), + crossings: [makeCrossing("AB", "B", "A")] + }), + createLevel({ + id: "ftue-11", + number: 11, + packId: "tea-house-garden", + title: "Double Crossing", + beat: "Two separated crossings ask the player to scan the nearest unresolved layer first.", + mechanics: ["Separated crossing reads"], + postcard: postcardForSet( + "tea-house-garden", + "The first garden board splits depth into two quiet branches instead of one dense knot." + ), + bundles: pickBundles(["A", "B", "C", "D", "F"]), + crossings: [makeCrossing("AB", "B", "A"), makeCrossing("CF", "C", "F")] + }), + createLevel({ + id: "ftue-12", + number: 12, + packId: "tea-house-garden", + title: "Pressure Test", + beat: "The first full 6-bundle board makes the 3-knot budget feel relevant without becoming unreadable.", + mechanics: ["6-bundle pressure test"], + postcard: postcardForSet( + "tea-house-garden", + "This board expands to the full slice footprint while keeping at least one clean edge opener." + ), + bundles: pickBundles(["A", "B", "C", "D", "E", "F"]), + pins: [ + makePin("A", { + id: "pin-a-pressure", + name: "Pressure clasp", + blocks: ["A"], + requiresClear: ["E"] + }) + ], + crossings: [ + makeCrossing("AB", "B", "A"), + makeCrossing("CD", "C", "D"), + makeCrossing("CF", "C", "F") + ] + }), + createLevel({ + id: "ftue-13", + number: 13, + packId: "tea-house-garden", + title: "Soft Braid", + beat: "Three crossings create density, but two opening routes stay visible at the edge.", + mechanics: ["Multiple valid openings"], + postcard: postcardForSet( + "tea-house-garden", + "A softer braid lets two different starts succeed, proving the slice is not single-path only." + ), + bundles: pickBundles(["A", "B", "C", "D", "E", "F"]), + crossings: [makeCrossing("AB", "B", "A"), makeCrossing("BE", "B", "E"), makeCrossing("CF", "C", "F")] + }), + createLevel({ + id: "ftue-14", + number: 14, + packId: "tea-house-garden", + title: "Color Trap", + beat: "Close visual neighbors make the depth halos and exit arrows do the real readability work.", + mechanics: ["Close-palette readability test"], + postcard: postcardForSet( + "tea-house-garden", + "Readability gets stress-tested here: similar tones share the board but not the same blockers." + ), + bundles: pickBundles(["A", "B", "C", "D", "E", "F"]), + pins: [ + makePin("A", { + id: "pin-a-color", + name: "Trap pin", + blocks: ["A"], + requiresClear: ["E"] + }), + makePin("F", { + id: "pin-f-color", + name: "Shade clasp", + blocks: ["F"], + requiresClear: ["D"] + }) + ], + guides: pickGuides(["B", "C"]), + crossings: [ + makeCrossing("BE", "B", "E"), + makeCrossing("AB", "B", "A"), + makeCrossing("CD", "C", "D"), + makeCrossing("CF", "C", "F") + ] + }), + createLevel({ + id: "ftue-15", + number: 15, + packId: "tea-house-garden", + title: "Slice Finale", + beat: "The finale hits the slice cap with 6 bundles, 3 pins, 2 guide chains, and 4 crossings.", + mechanics: ["Final FTUE exam"], + postcard: postcardForSet( + "tea-house-garden", + "Every slice rule lands together here, closing the third postcard set and unlocking the daily foil stamp." + ), + bundles: pickBundles(["A", "B", "C", "D", "E", "F"]), + pins: [ + makePin("A", { + id: "pin-a-festival", + name: "Festival pin", + blocks: ["A"], + requiresClear: ["E"] + }), + makePin("C", { + id: "pin-c-festival", + name: "Petal pin", + blocks: ["C"], + requiresClear: ["A"] + }), + makePin("D", { + id: "pin-d-festival", + name: "Lantern pin", + blocks: ["D"], + requiresClear: ["F"] + }) + ], + guides: pickGuides(["A", "B"]), + crossings: [ + makeCrossing("BE", "B", "E"), + makeCrossing("AB", "B", "A"), + makeCrossing("AC", "A", "C"), + makeCrossing("CD", "C", "D") + ] + }) +]; diff --git a/loom-rescue/src/engine.js b/loom-rescue/src/engine.js new file mode 100644 index 0000000..4e1a4c5 --- /dev/null +++ b/loom-rescue/src/engine.js @@ -0,0 +1,601 @@ +import { BOARD } from "./data.js"; + +const EDGE_TO_DIRECTION = { + top: "up", + right: "right", + bottom: "down", + left: "left" +}; + +const clone = (value) => JSON.parse(JSON.stringify(value)); + +export const cellKey = (cell) => `${cell.x},${cell.y}`; + +export const cellLabel = (cell) => + `${String.fromCharCode(65 + cell.x)}${cell.y + 1}`; + +export function inferEdge(cell, board = BOARD) { + if (cell.y === 0) { + return "top"; + } + if (cell.y === board.rows - 1) { + return "bottom"; + } + if (cell.x === 0) { + return "left"; + } + if (cell.x === board.cols - 1) { + return "right"; + } + return null; +} + +function inferBundleEdge(bundle, board = BOARD) { + const start = bundle.points[0]; + const next = bundle.points[1]; + + if (next) { + if (start.y === 0 && next.y > start.y) { + return "top"; + } + if (start.y === board.rows - 1 && next.y < start.y) { + return "bottom"; + } + if (start.x === 0 && next.x > start.x) { + return "left"; + } + if (start.x === board.cols - 1 && next.x < start.x) { + return "right"; + } + } + + return inferEdge(start, board); +} + +export function inferPullDirection(bundle, board = BOARD) { + const edge = inferBundleEdge(bundle, board); + if (!edge) { + throw new Error(`Bundle ${bundle.id} does not start on the board edge.`); + } + return EDGE_TO_DIRECTION[edge]; +} + +export function expandPolyline(points) { + if (!points.length) { + return []; + } + + const cells = []; + for (let index = 0; index < points.length - 1; index += 1) { + const from = points[index]; + const to = points[index + 1]; + const dx = Math.sign(to.x - from.x); + const dy = Math.sign(to.y - from.y); + + if (dx !== 0 && dy !== 0) { + throw new Error( + `Diagonal segment from ${cellLabel(from)} to ${cellLabel(to)} is not supported.` + ); + } + + const length = Math.max(Math.abs(to.x - from.x), Math.abs(to.y - from.y)); + for (let step = 0; step <= length; step += 1) { + const cell = { x: from.x + dx * step, y: from.y + dy * step }; + if (!cells.length || cellKey(cells[cells.length - 1]) !== cellKey(cell)) { + cells.push(cell); + } + } + } + + if (points.length === 1) { + return [clone(points[0])]; + } + + return cells; +} + +export function createRuntime(level) { + const board = level.board ?? BOARD; + const bundles = level.bundles.map((bundle) => ({ + ...clone(bundle), + cells: expandPolyline(bundle.points), + pullDirection: inferPullDirection(bundle, board), + exitEdge: inferBundleEdge(bundle, board) + })); + const bundleMap = new Map(bundles.map((bundle) => [bundle.id, bundle])); + const pathCellSets = new Map( + bundles.map((bundle) => [bundle.id, new Set(bundle.cells.map(cellKey))]) + ); + + const pins = (level.pins ?? []).map(clone); + const pinMap = new Map(pins.map((pin) => [pin.id, pin])); + + const guides = (level.guides ?? []).map(clone); + const guidesByBundle = new Map(); + for (const guide of guides) { + for (const bundleId of guide.bundleIds) { + const existing = guidesByBundle.get(bundleId) ?? []; + existing.push(guide); + guidesByBundle.set(bundleId, existing); + } + } + + const crossings = (level.crossings ?? []).map(clone); + const crossingsByUnder = new Map(); + const crossingsByOver = new Map(); + for (const crossing of crossings) { + const underList = crossingsByUnder.get(crossing.under) ?? []; + underList.push(crossing); + crossingsByUnder.set(crossing.under, underList); + + const overList = crossingsByOver.get(crossing.over) ?? []; + overList.push(crossing); + crossingsByOver.set(crossing.over, overList); + } + + return { + board, + bundles, + bundleMap, + pathCellSets, + pins, + pinMap, + guides, + guidesByBundle, + crossings, + crossingsByUnder, + crossingsByOver + }; +} + +export function createInitialState(level) { + return { + removedBundles: [], + removedPins: [], + knots: 0, + failed: false, + completed: false, + message: + level.beat ?? "Read the exits first, then pull one loose bundle at a time.", + hintAction: null, + history: [] + }; +} + +function snapshotState(state) { + return { + removedBundles: [...state.removedBundles], + removedPins: [...state.removedPins], + knots: state.knots, + failed: state.failed, + completed: state.completed, + message: state.message, + hintAction: state.hintAction ? { ...state.hintAction } : null + }; +} + +export function isBundleRemoved(state, bundleId) { + return state.removedBundles.includes(bundleId); +} + +export function isPinRemoved(state, pinId) { + return state.removedPins.includes(pinId); +} + +export function getRevealRatio(level, state) { + if (!level.bundles.length) { + return 0; + } + return state.removedBundles.length / level.bundles.length; +} + +export function evaluateBundle(level, state, bundleId, runtime = createRuntime(level)) { + const bundle = runtime.bundleMap.get(bundleId); + if (!bundle) { + return { ok: false, reason: `Unknown bundle ${bundleId}.` }; + } + + if (state.failed || state.completed) { + return { ok: false, reason: "The level is no longer active." }; + } + + if (isBundleRemoved(state, bundleId)) { + return { ok: false, reason: `${bundle.name} has already been cleared.` }; + } + + const activePin = runtime.pins.find( + (pin) => pin.blocks.includes(bundleId) && !isPinRemoved(state, pin.id) + ); + if (activePin) { + return { + ok: false, + reason: `${bundle.name} is still pinned at ${cellLabel(activePin.cell)}.` + }; + } + + const badGuide = (runtime.guidesByBundle.get(bundleId) ?? []).find( + (guide) => guide.direction !== bundle.pullDirection + ); + if (badGuide) { + return { + ok: false, + reason: `${bundle.name} fights the ${badGuide.name} guide.` + }; + } + + const blockingCrossing = (runtime.crossingsByUnder.get(bundleId) ?? []).find( + (crossing) => !isBundleRemoved(state, crossing.over) + ); + if (blockingCrossing) { + const blocker = runtime.bundleMap.get(blockingCrossing.over); + return { + ok: false, + reason: `${bundle.name} snags under ${blocker.name} at ${cellLabel(blockingCrossing.cell)}.` + }; + } + + return { + ok: true, + reason: `${bundle.name} can leave toward the ${bundle.pullDirection}.` + }; +} + +export function evaluatePin(level, state, pinId, runtime = createRuntime(level)) { + const pin = runtime.pinMap.get(pinId); + if (!pin) { + return { ok: false, reason: `Unknown pin ${pinId}.` }; + } + + if (state.failed || state.completed) { + return { ok: false, reason: "The level is no longer active." }; + } + + if (isPinRemoved(state, pinId)) { + return { ok: false, reason: `${pin.name} has already been lifted.` }; + } + + const blockedPresent = pin.blocks.some((bundleId) => !isBundleRemoved(state, bundleId)); + if (!blockedPresent) { + return { ok: false, reason: `${pin.name} is already irrelevant.` }; + } + + const waitingOn = pin.requiresClear.filter((bundleId) => !isBundleRemoved(state, bundleId)); + if (waitingOn.length) { + const bundleNames = waitingOn + .map((bundleId) => runtime.bundleMap.get(bundleId)?.name ?? bundleId) + .join(", "); + return { + ok: false, + reason: `${pin.name} still catches on ${bundleNames}.` + }; + } + + return { + ok: true, + reason: `${pin.name} can lift now.` + }; +} + +function scoreAction(action, state, runtime) { + if (action.type === "pin") { + const pin = runtime.pinMap.get(action.id); + const frees = pin.blocks.filter((bundleId) => !isBundleRemoved(state, bundleId)).length; + return 20 + frees * 5; + } + + const uncovers = (runtime.crossingsByOver.get(action.id) ?? []).filter( + (crossing) => !isBundleRemoved(state, crossing.under) + ).length; + return 10 + uncovers * 3; +} + +export function collectLegalActions(level, state, runtime = createRuntime(level)) { + if (state.failed || state.completed) { + return []; + } + + const actions = []; + + for (const pin of runtime.pins) { + const evaluation = evaluatePin(level, state, pin.id, runtime); + if (evaluation.ok) { + actions.push({ type: "pin", id: pin.id, score: scoreAction({ type: "pin", id: pin.id }, state, runtime) }); + } + } + + for (const bundle of runtime.bundles) { + const evaluation = evaluateBundle(level, state, bundle.id, runtime); + if (evaluation.ok) { + actions.push({ + type: "bundle", + id: bundle.id, + score: scoreAction({ type: "bundle", id: bundle.id }, state, runtime) + }); + } + } + + return actions.sort((left, right) => right.score - left.score || left.id.localeCompare(right.id)); +} + +function finalizeProgress(level, nextState) { + if (nextState.removedBundles.length === level.bundles.length) { + nextState.completed = true; + nextState.failed = false; + } + return nextState; +} + +function finalizeOutcome(level, state, runtime) { + const nextState = finalizeProgress(level, state); + if (nextState.completed) { + return nextState; + } + + if (!collectLegalActions(level, nextState, runtime).length) { + return { + ...nextState, + failed: true, + completed: false, + hintAction: null, + message: "No legal pulls or exposed pins remain. The weave deadlocks." + }; + } + + return nextState; +} + +export function applyAction(level, state, action, runtime = createRuntime(level)) { + if (action.type === "pin") { + const evaluation = evaluatePin(level, state, action.id, runtime); + if (!evaluation.ok) { + return { + ...state, + message: evaluation.reason, + hintAction: null + }; + } + + const pin = runtime.pinMap.get(action.id); + const nextState = { + ...state, + removedPins: [...state.removedPins, action.id], + history: [...state.history, snapshotState(state)], + message: `${pin.name} lifts away.`, + hintAction: null + }; + return finalizeOutcome(level, nextState, runtime); + } + + const evaluation = evaluateBundle(level, state, action.id, runtime); + const bundle = runtime.bundleMap.get(action.id); + + if (!evaluation.ok) { + const knots = state.knots + 1; + const failed = knots >= level.failLimit; + return { + ...state, + knots, + failed, + completed: false, + hintAction: null, + message: failed + ? `${evaluation.reason} Knot ${knots}/${level.failLimit}. The weave locks shut.` + : `${evaluation.reason} Knot ${knots}/${level.failLimit}.` + }; + } + + const nextState = finalizeOutcome(level, { + ...state, + removedBundles: [...state.removedBundles, action.id], + history: [...state.history, snapshotState(state)], + hintAction: null, + message: `${bundle.name} slides free toward the ${bundle.pullDirection}.` + }, runtime); + + if (nextState.completed) { + nextState.message = `${bundle.name} completes the postcard reveal.`; + } + + return nextState; +} + +export function undoAction(state) { + if (!state.history.length) { + return { + ...state, + message: "Nothing to undo yet.", + hintAction: null + }; + } + + const snapshot = state.history[state.history.length - 1]; + return { + ...clone(snapshot), + history: state.history.slice(0, -1), + message: "Last action undone.", + hintAction: null + }; +} + +export function restartLevel(level) { + return createInitialState(level); +} + +export function findHint(level, state, runtime = createRuntime(level)) { + const candidate = collectLegalActions(level, state, runtime)[0]; + if (!candidate) { + return { + action: null, + message: "No legal move is available from this state." + }; + } + + if (candidate.type === "pin") { + const pin = runtime.pinMap.get(candidate.id); + const blocks = pin.blocks + .map((bundleId) => runtime.bundleMap.get(bundleId)?.name ?? bundleId) + .join(", "); + return { + action: candidate, + message: `Hint: lift ${pin.name} to free ${blocks}.` + }; + } + + const bundle = runtime.bundleMap.get(candidate.id); + return { + action: candidate, + message: `Hint: pull ${bundle.name} toward the ${bundle.pullDirection}.` + }; +} + +export function validateLevel(level) { + const issues = []; + const runtime = createRuntime(level); + + if (runtime.bundles.length > 6) { + issues.push("Board exceeds the 6-bundle slice cap."); + } + if (runtime.pins.length > 3) { + issues.push("Board exceeds the 3-pin slice cap."); + } + if (runtime.guides.length > 3) { + issues.push("Board exceeds the 3-guide-cluster slice cap."); + } + if (runtime.crossings.length > 4) { + issues.push("Board exceeds the 4-crossing slice cap."); + } + + if (level.kind === "daily") { + if (level.failLimit !== 2) { + issues.push("Daily levels must use a 2-knot fail meter."); + } + if (runtime.pins.length !== 2) { + issues.push("Daily levels should author exactly 2 pins."); + } + if (runtime.guides.length !== 2) { + issues.push("Daily levels should author exactly 2 guide chains."); + } + if (runtime.crossings.length < 3 || runtime.crossings.length > 4) { + issues.push("Daily levels should author 3 to 4 crossings."); + } + } else if (level.failLimit !== 3) { + issues.push("Standard FTUE levels must use a 3-knot fail meter."); + } + + const bundleIds = new Set(); + for (const bundle of runtime.bundles) { + if (bundleIds.has(bundle.id)) { + issues.push(`Duplicate bundle id ${bundle.id}.`); + } + bundleIds.add(bundle.id); + + if (!inferBundleEdge(bundle, runtime.board)) { + issues.push(`Bundle ${bundle.id} does not start on an edge cell.`); + } + } + + const pinIds = new Set(); + for (const pin of runtime.pins) { + if (pinIds.has(pin.id)) { + issues.push(`Duplicate pin id ${pin.id}.`); + } + pinIds.add(pin.id); + + for (const bundleId of [...pin.blocks, ...pin.requiresClear]) { + if (!runtime.bundleMap.has(bundleId)) { + issues.push(`Pin ${pin.id} references unknown bundle ${bundleId}.`); + } + } + + const blockedCells = pin.blocks.every((bundleId) => + runtime.pathCellSets.get(bundleId)?.has(cellKey(pin.cell)) + ); + if (!blockedCells) { + issues.push(`Pin ${pin.id} is not placed on all blocked bundle paths.`); + } + } + + for (const guide of runtime.guides) { + for (const bundleId of guide.bundleIds) { + const bundle = runtime.bundleMap.get(bundleId); + if (!bundle) { + issues.push(`Guide ${guide.id} references unknown bundle ${bundleId}.`); + continue; + } + if (guide.direction !== bundle.pullDirection) { + issues.push( + `Guide ${guide.id} points ${guide.direction} but bundle ${bundleId} pulls ${bundle.pullDirection}.` + ); + } + const pathSet = runtime.pathCellSets.get(bundleId); + const allCellsOnPath = guide.cells.every((cell) => pathSet?.has(cellKey(cell))); + if (!allCellsOnPath) { + issues.push(`Guide ${guide.id} is not aligned with bundle ${bundleId}.`); + } + } + } + + for (const crossing of runtime.crossings) { + if (!runtime.bundleMap.has(crossing.over) || !runtime.bundleMap.has(crossing.under)) { + issues.push(`Crossing ${crossing.id} references an unknown bundle.`); + continue; + } + + const overPath = runtime.pathCellSets.get(crossing.over); + const underPath = runtime.pathCellSets.get(crossing.under); + if (!overPath?.has(cellKey(crossing.cell)) || !underPath?.has(cellKey(crossing.cell))) { + issues.push(`Crossing ${crossing.id} is not placed on both bundle paths.`); + } + } + + const solution = solveLevel(level, runtime); + if (!solution.ok) { + issues.push(solution.reason); + } + if (level.kind === "daily" && solution.ok && (solution.sequence.length < 7 || solution.sequence.length > 9)) { + issues.push("Daily levels should resolve in 7 to 9 actions."); + } + + return { + ok: issues.length === 0, + issues, + solution + }; +} + +export function solveLevel(level, runtime = createRuntime(level)) { + let state = createInitialState(level); + const sequence = []; + + for (let step = 0; step < 40; step += 1) { + if (state.completed) { + return { ok: true, sequence }; + } + if (state.failed) { + return { + ok: false, + sequence, + reason: state.message + }; + } + + const nextAction = collectLegalActions(level, state, runtime)[0]; + if (!nextAction) { + const remaining = runtime.bundles.filter((bundle) => !isBundleRemoved(state, bundle.id)).length; + return { + ok: false, + sequence, + reason: `Validator stalled with ${remaining} bundle(s) still on the board.` + }; + } + + sequence.push(nextAction); + state = applyAction(level, state, nextAction, runtime); + } + + return { + ok: false, + sequence, + reason: "Validator exceeded the expected step budget." + }; +} diff --git a/loom-rescue/styles.css b/loom-rescue/styles.css new file mode 100644 index 0000000..c30acc7 --- /dev/null +++ b/loom-rescue/styles.css @@ -0,0 +1,782 @@ +:root { + --paper: #f6efe3; + --paper-deep: #e4d5be; + --ink: #2f2a24; + --ink-soft: #665d50; + --brand: #b55f4e; + --brand-dark: #7b3b31; + --card: rgba(255, 250, 241, 0.78); + --line: rgba(92, 73, 52, 0.12); + --shadow: 0 26px 60px rgba(92, 67, 41, 0.14); + --thread-shadow: rgba(57, 40, 27, 0.22); +} + +* { + box-sizing: border-box; +} + +html, +body { + margin: 0; + min-height: 100%; + background: + radial-gradient(circle at top left, rgba(233, 193, 151, 0.45), transparent 34%), + radial-gradient(circle at bottom right, rgba(156, 180, 151, 0.32), transparent 28%), + linear-gradient(180deg, #f9f3ea 0%, #efe4d3 100%); + color: var(--ink); + font-family: "Trebuchet MS", "Gill Sans", sans-serif; +} + +body { + padding: 24px; +} + +button, +input { + font: inherit; +} + +button { + cursor: pointer; +} + +.shell { + width: min(1180px, 100%); + margin: 0 auto; + display: grid; + gap: 18px; + animation: rise-in 520ms ease; +} + +.hero-card, +.board-card, +.info-card, +.album-card, +.level-rail { + background: var(--card); + border: 1px solid rgba(132, 108, 77, 0.14); + border-radius: 28px; + box-shadow: var(--shadow); + backdrop-filter: blur(12px); +} + +.hero-card { + display: grid; + grid-template-columns: 1.5fr 0.85fr; + gap: 18px; + padding: 26px 28px; +} + +.eyebrow, +.board-card__kicker, +.album-card__label, +.info-card__label, +.modal-kicker { + margin: 0 0 8px; + text-transform: uppercase; + letter-spacing: 0.16em; + font-size: 0.72rem; + color: var(--brand-dark); +} + +h1, +h2, +h3 { + margin: 0; + font-family: Georgia, "Times New Roman", serif; + line-height: 1.04; +} + +h1 { + font-size: clamp(2rem, 4vw, 3.35rem); + max-width: 12ch; +} + +h2 { + font-size: clamp(1.6rem, 2vw, 2.2rem); +} + +h3 { + font-size: 1.1rem; +} + +p { + color: var(--ink-soft); + line-height: 1.55; +} + +.hero-copy { + max-width: 58ch; + margin-bottom: 0; +} + +.album-card { + padding: 22px; + background: + linear-gradient(135deg, rgba(255, 246, 233, 0.96), rgba(236, 222, 196, 0.92)), + linear-gradient(180deg, rgba(255, 255, 255, 0.22), transparent); +} + +.album-card__value { + margin: 0 0 12px; + font-family: Georgia, "Times New Roman", serif; + font-size: 2rem; + color: var(--ink); +} + +.album-card__sub, +.modal-support, +.info-foot { + margin: 8px 0 12px; + color: var(--ink-soft); + font-size: 0.92rem; +} + +.meter { + width: 100%; + height: 12px; + background: rgba(112, 89, 63, 0.12); + border-radius: 999px; + overflow: hidden; +} + +.meter span { + display: block; + height: 100%; + border-radius: inherit; + background: linear-gradient(90deg, #d7745b 0%, #e5b66a 54%, #7b9c73 100%); +} + +.stamp-grid { + margin-top: 16px; + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 10px; +} + +.stamp { + min-height: 48px; + display: grid; + place-items: center; + border-radius: 16px; + border: 1px dashed rgba(110, 77, 55, 0.24); + color: rgba(88, 68, 48, 0.44); + font-weight: 700; + letter-spacing: 0.08em; +} + +.stamp.is-earned { + background: rgba(217, 115, 83, 0.16); + border-style: solid; + border-color: rgba(178, 90, 66, 0.36); + color: var(--brand-dark); +} + +.level-rail { + display: flex; + justify-content: space-between; + gap: 16px; + padding: 16px 18px; + align-items: center; +} + +.level-rail__list { + display: flex; + gap: 10px; + flex-wrap: wrap; +} + +.daily-rail { + display: flex; + gap: 12px; + align-items: end; +} + +.daily-rail label { + display: grid; + gap: 6px; + color: var(--ink-soft); + font-size: 0.92rem; +} + +.daily-rail input { + padding: 10px 12px; + border-radius: 14px; + border: 1px solid rgba(108, 85, 62, 0.18); + background: rgba(255, 252, 246, 0.86); +} + +.rail-chip, +.ghost-button, +.primary-button { + border: 0; + border-radius: 999px; + transition: transform 160ms ease, background-color 160ms ease, box-shadow 160ms ease; +} + +.rail-chip { + min-width: 46px; + padding: 10px 16px; + background: rgba(255, 250, 242, 0.72); + color: var(--ink); + border: 1px solid rgba(103, 79, 58, 0.14); +} + +.rail-chip.is-active, +.rail-chip:hover { + transform: translateY(-1px); + background: rgba(215, 115, 88, 0.14); +} + +.rail-chip:disabled, +.daily-rail input:disabled { + cursor: not-allowed; + opacity: 0.58; +} + +.rail-chip.is-cleared { + box-shadow: inset 0 0 0 1px rgba(115, 148, 108, 0.34); +} + +.rail-chip.is-locked { + background: rgba(243, 236, 225, 0.84); +} + +.play-layout { + display: grid; + grid-template-columns: minmax(0, 1.25fr) minmax(280px, 0.75fr); + gap: 18px; +} + +.board-card { + padding: 18px; + display: grid; + gap: 16px; +} + +.board-card__top { + display: flex; + justify-content: space-between; + gap: 16px; +} + +.board-card__heading { + display: grid; + gap: 12px; +} + +.board-back { + width: fit-content; +} + +.hud-block { + min-width: 170px; + display: grid; + gap: 8px; + align-content: start; + justify-items: end; +} + +.hud-label { + color: var(--ink-soft); + font-size: 0.84rem; + letter-spacing: 0.06em; + text-transform: uppercase; +} + +.hud-meter { + min-width: 170px; +} + +.board-frame { + position: relative; + aspect-ratio: 3 / 4; + border-radius: 30px; + overflow: hidden; + border: 1px solid rgba(90, 72, 49, 0.16); + background: linear-gradient(180deg, rgba(255, 251, 246, 0.96), rgba(238, 227, 207, 0.94)); +} + +.postcard-art { + position: absolute; + inset: 0; + background: + radial-gradient(circle at 20% 20%, rgba(255, 255, 255, 0.75), transparent 22%), + radial-gradient(circle at 78% 24%, rgba(255, 255, 255, 0.55), transparent 18%), + linear-gradient(135deg, var(--tone-a), var(--tone-b) 58%, var(--tone-c)); + opacity: calc(0.14 + (var(--reveal) * 0.0086)); + transform: scale(calc(1.02 - (var(--reveal) * 0.0006))); + transition: opacity 220ms ease, transform 220ms ease; +} + +.postcard-art::after { + content: ""; + position: absolute; + inset: 10% 14% auto auto; + width: 28%; + height: 38%; + border-radius: 24px; + background: rgba(255, 249, 239, 0.28); + box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.36); + transform: rotate(8deg); +} + +.board-svg { + position: absolute; + inset: 0; + width: 100%; + height: 100%; +} + +.board-grid { + fill: rgba(255, 250, 243, 0.42); +} + +.board-grid-lines path { + stroke: rgba(115, 87, 58, 0.12); + stroke-width: 1.4; +} + +.guide-cluster rect { + fill: rgba(255, 250, 241, 0.8); + stroke: rgba(110, 87, 63, 0.22); +} + +.guide-cluster text { + fill: var(--brand-dark); + font-family: Georgia, "Times New Roman", serif; + font-size: 18px; +} + +.thread-shadow, +.thread-main, +.thread-hit { + fill: none; + stroke-linecap: round; + stroke-linejoin: round; +} + +.thread-shadow { + stroke: var(--thread-shadow); + stroke-width: 30; +} + +.thread-main { + stroke-width: 18; +} + +.thread-hit { + stroke-width: 34; + stroke: transparent; +} + +.thread-bundle.is-ready { + cursor: pointer; +} + +.thread-bundle.is-blocked .thread-main { + opacity: 0.72; +} + +.thread-bundle.is-removed .thread-shadow, +.thread-bundle.is-removed .thread-main, +.thread-bundle.is-removed .bundle-tag, +.thread-bundle.is-removed .exit-chip, +.thread-bundle.is-removed .exit-arrow { + opacity: 0.18; +} + +.thread-bundle.is-removed .thread-main { + stroke-dasharray: 16 18; +} + +.thread-bundle.is-hinted .thread-main { + filter: drop-shadow(0 0 18px rgba(213, 165, 77, 0.78)); +} + +.exit-chip { + fill: rgba(255, 251, 244, 0.92); + stroke: rgba(97, 74, 52, 0.3); + stroke-width: 2; +} + +.exit-chip.is-hinted, +.exit-arrow.is-hinted { + animation: pulse-hint 900ms ease-in-out infinite; +} + +.exit-arrow, +.bundle-tag, +.pin-node text, +.crossing-badge text { + fill: var(--ink); + font-weight: 700; + letter-spacing: 0.04em; +} + +.bundle-tag { + font-size: 22px; + font-family: Georgia, "Times New Roman", serif; +} + +.pin-node { + cursor: pointer; +} + +.pin-node circle { + fill: rgba(255, 247, 236, 0.92); + stroke-width: 3; +} + +.pin-node.is-ready circle { + stroke: rgba(113, 140, 100, 0.82); +} + +.pin-node.is-blocked circle { + stroke: rgba(186, 111, 92, 0.62); +} + +.pin-node text { + font-size: 11px; +} + +.crossing-badge rect:first-child { + fill: rgba(255, 248, 239, 0.94); + stroke: rgba(104, 83, 59, 0.16); +} + +.crossing-badge text { + font-size: 10px; +} + +.crossing-badge.is-inactive { + opacity: 0.36; +} + +.toolbar { + display: flex; + gap: 10px; + flex-wrap: wrap; +} + +.ghost-button, +.primary-button { + padding: 12px 18px; + font-weight: 700; +} + +.ghost-button { + background: rgba(255, 250, 242, 0.8); + color: var(--ink); + border: 1px solid rgba(99, 75, 53, 0.14); +} + +.primary-button { + background: linear-gradient(135deg, #cc725b, #b15448); + color: white; + box-shadow: 0 12px 26px rgba(177, 84, 72, 0.28); +} + +.ghost-button:hover, +.primary-button:hover, +.bundle-pill:hover { + transform: translateY(-1px); +} + +.bundle-tray { + display: grid; + gap: 10px; +} + +.bundle-pill { + width: 100%; + padding: 12px 14px; + text-align: left; + border-radius: 20px; + border: 1px solid rgba(106, 81, 57, 0.14); + background: rgba(255, 252, 247, 0.78); + display: grid; + gap: 4px; +} + +.bundle-pill__head { + font-weight: 700; + color: var(--ink); +} + +.bundle-pill__meta { + font-size: 0.9rem; + color: var(--ink-soft); +} + +.bundle-pill.is-ready { + box-shadow: inset 0 0 0 1px rgba(110, 150, 118, 0.22); +} + +.bundle-pill.is-blocked { + background: rgba(246, 237, 229, 0.8); +} + +.bundle-pill.is-cleared { + opacity: 0.62; +} + +.bundle-pill.is-hinted { + border-color: rgba(213, 165, 77, 0.56); + box-shadow: 0 0 0 3px rgba(228, 193, 108, 0.18); +} + +.info-column { + display: grid; + gap: 16px; +} + +.info-card { + padding: 18px 20px; +} + +.message-card { + background: + linear-gradient(150deg, rgba(255, 248, 239, 0.96), rgba(236, 223, 199, 0.92)), + linear-gradient(180deg, rgba(255, 255, 255, 0.16), transparent); +} + +.message-text, +.validator-copy, +.validator-sequence, +.message-foot { + margin: 0; +} + +.message-foot { + margin-top: 10px; + color: var(--brand-dark); + font-size: 0.88rem; +} + +.status-list, +.note-list, +.modal-list { + margin: 0; + padding-left: 18px; + display: grid; + gap: 8px; +} + +.status-row { + display: flex; + justify-content: space-between; + gap: 12px; + color: var(--ink-soft); +} + +.status-row__name { + color: var(--ink); +} + +.status-row.is-ready .status-row__value { + color: #557b53; +} + +.status-row.is-blocked .status-row__value { + color: #a16052; +} + +.status-row.is-cleared .status-row__value { + color: #7b9b72; +} + +.knot-meter { + display: flex; + gap: 8px; +} + +.knot { + width: 20px; + height: 20px; + border-radius: 50%; + background: rgba(108, 84, 59, 0.12); + border: 1px solid rgba(100, 72, 45, 0.12); +} + +.knot.is-filled { + background: radial-gradient(circle at 32% 30%, #f1d29b 0%, #d9795b 72%, #90453c 100%); + border-color: rgba(154, 77, 64, 0.42); +} + +.modal-backdrop { + position: fixed; + inset: 0; + background: rgba(47, 34, 22, 0.36); + display: grid; + place-items: center; + padding: 24px; + z-index: 10; +} + +.modal-card { + width: min(460px, 100%); + padding: 28px; + border-radius: 28px; + background: + linear-gradient(150deg, rgba(255, 250, 242, 0.98), rgba(239, 228, 207, 0.96)), + linear-gradient(180deg, rgba(255, 255, 255, 0.18), transparent); + box-shadow: 0 36px 80px rgba(51, 35, 18, 0.24); + animation: rise-in 280ms ease; +} + +.modal-actions { + display: flex; + gap: 10px; + flex-wrap: wrap; + margin-top: 18px; +} + +.postcard-card { + max-width: 560px; +} + +.modal-postcard { + margin-top: 18px; + aspect-ratio: 4 / 3; + border-radius: 24px; + background: + radial-gradient(circle at 20% 20%, rgba(255, 255, 255, 0.7), transparent 28%), + linear-gradient(145deg, var(--tone-a), var(--tone-b) 55%, var(--tone-c)); + position: relative; + overflow: hidden; +} + +.modal-postcard span { + position: absolute; + right: 18px; + bottom: 16px; + padding: 8px 12px; + border-radius: 999px; + background: rgba(255, 248, 238, 0.72); + color: var(--ink); + font-weight: 700; + letter-spacing: 0.08em; +} + +.modal-postcard--full { + aspect-ratio: 5 / 4; +} + +.stamp-line { + margin-top: 14px; + color: var(--brand-dark); + font-weight: 700; +} + +.next-pack-preview { + margin-top: 16px; + padding: 16px 18px; + border-radius: 18px; + background: rgba(255, 248, 238, 0.72); + border: 1px solid rgba(109, 83, 58, 0.12); + display: grid; + gap: 4px; +} + +.next-pack-preview__label { + margin: 0; + text-transform: uppercase; + letter-spacing: 0.12em; + font-size: 0.72rem; + color: var(--brand-dark); +} + +.next-pack-preview strong, +.next-pack-preview span { + color: var(--ink); +} + +@keyframes rise-in { + from { + opacity: 0; + transform: translateY(12px); + } + + to { + opacity: 1; + transform: translateY(0); + } +} + +@keyframes pulse-hint { + 0%, + 100% { + transform: scale(1); + opacity: 1; + } + + 50% { + transform: scale(1.08); + opacity: 0.72; + } +} + +@media (max-width: 980px) { + body { + padding: 14px; + } + + .hero-card, + .play-layout, + .level-rail { + grid-template-columns: 1fr; + } + + .level-rail, + .daily-rail { + align-items: stretch; + } + + .daily-rail { + flex-direction: column; + } + + .board-card__top { + flex-direction: column; + } + + .hud-block { + justify-items: start; + } +} + +@media (max-width: 640px) { + body { + padding: 10px; + } + + .hero-card, + .board-card, + .info-card, + .album-card, + .level-rail, + .modal-card { + border-radius: 22px; + } + + .hero-card, + .board-card, + .level-rail { + padding: 16px; + } + + .toolbar, + .modal-actions { + flex-direction: column; + } + + .ghost-button, + .primary-button { + width: 100%; + } + + .stamp-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } +} diff --git a/loom-rescue/tests/engine.test.js b/loom-rescue/tests/engine.test.js new file mode 100644 index 0000000..c836740 --- /dev/null +++ b/loom-rescue/tests/engine.test.js @@ -0,0 +1,147 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +import { HANDCRAFTED_LEVELS, makeCrossing, makePin, pickBundles } from "../src/data.js"; +import { + applyAction, + createInitialState, + createRuntime, + findHint, + undoAction, + validateLevel +} from "../src/engine.js"; +import { generateDailyLevel } from "../src/daily.js"; + +test("ftue level authoring matches the gameplay spec progression counts", () => { + const expected = [ + [2, 0, 0, 0], + [3, 0, 0, 1], + [3, 0, 0, 0], + [3, 1, 0, 0], + [4, 1, 0, 1], + [4, 0, 1, 0], + [4, 0, 1, 1], + [5, 2, 0, 0], + [5, 0, 0, 0], + [5, 2, 1, 1], + [5, 0, 0, 2], + [6, 1, 0, 3], + [6, 0, 0, 3], + [6, 2, 2, 4], + [6, 3, 2, 4] + ]; + + HANDCRAFTED_LEVELS.forEach((level, index) => { + assert.deepEqual( + [level.bundles.length, level.pins.length, level.guides.length, level.crossings.length], + expected[index], + `Unexpected authoring 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 pulls add a knot and explain the blocker locally", () => { + const level = HANDCRAFTED_LEVELS[1]; + const runtime = createRuntime(level); + const start = createInitialState(level); + const next = applyAction(level, start, { type: "bundle", id: "A" }, runtime); + + assert.equal(next.knots, 1); + assert.match(next.message, /snags under Harbor Cord/); + assert.equal(next.failed, false); +}); + +test("undo does not refund knots from invalid pulls", () => { + const level = HANDCRAFTED_LEVELS[1]; + const runtime = createRuntime(level); + const start = createInitialState(level); + const knotted = applyAction(level, start, { type: "bundle", id: "A" }, runtime); + const undone = undoAction(knotted); + + assert.equal(knotted.knots, 1); + assert.equal(undone.knots, 1); + assert.equal(undone.message, "Nothing to undo yet."); +}); + +test("undo restores removed bundles and knot count", () => { + const level = HANDCRAFTED_LEVELS[2]; + const runtime = createRuntime(level); + const start = createInitialState(level); + const first = applyAction(level, start, { type: "bundle", id: "B" }, runtime); + const second = applyAction(level, first, { type: "bundle", id: "D" }, runtime); + const undone = undoAction(second); + + assert.deepEqual(undone.removedBundles, ["B"]); + assert.equal(undone.knots, 0); + assert.equal(undone.completed, false); +}); + +test("deadlock states fail immediately after a successful action", () => { + const level = { + id: "deadlock-check", + kind: "ftue", + number: 99, + packId: "coastal-morning", + title: "Deadlock Check", + beat: "Custom test fixture", + mechanics: [], + board: { cols: 6, rows: 8 }, + failLimit: 3, + postcard: { + title: "Fixture", + caption: "Fixture", + gradient: ["#fff", "#eee", "#ddd"], + accent: "#000" + }, + bundles: pickBundles(["A", "B", "E"]), + pins: [ + makePin("B", { + id: "pin-b-deadlock", + name: "Deadlock pin", + blocks: ["B"], + requiresClear: ["A"] + }) + ], + guides: [], + crossings: [makeCrossing("AB", "B", "A")] + }; + const runtime = createRuntime(level); + const start = createInitialState(level); + const next = applyAction(level, start, { type: "bundle", id: "E" }, runtime); + + assert.equal(next.failed, true); + assert.match(next.message, /deadlocks/); +}); + +test("hint surfaces a legal next action", () => { + const level = HANDCRAFTED_LEVELS[4]; + const runtime = createRuntime(level); + const state = createInitialState(level); + const hint = findHint(level, state, runtime); + + assert.ok(hint.action); + assert.match(hint.message, /Hint:/); +}); + +test("daily generation is deterministic and validates", () => { + const first = generateDailyLevel("2026-05-03"); + const second = generateDailyLevel("2026-05-03"); + + assert.deepEqual(first, second); + + const validation = validateLevel(first); + assert.equal(validation.ok, true, validation.issues.join(" | ")); + assert.equal(validation.solution.ok, true); + assert.equal(first.pins.length, 2); + assert.equal(first.guides.length, 2); + assert.ok(first.crossings.length >= 3 && first.crossings.length <= 4); + assert.ok(validation.solution.sequence.length >= 7 && validation.solution.sequence.length <= 9); +});