- commit
- 7b85faf21316f2c90c06bde8d19eb1d0d2ae82dd
- parent
- 44c5b432e50ca7b91f2f13ec651305c915dca919
- author
- Dami <dami.etoile@gmail.com>
| A | src/ | 169 | +++++++++++++++++ |
| A | src/ | 296 | ++++++++++++++++++++++++++++++ |
| A | tests/ | 53 | +++++ |
| A | tests/ | 89 | +++++++++ |
@@ -0,0 +1,169 @@
1+/**2+ * Pure canvas renderer for the INDEX map.3+ *4+ * This consumes the data from scenes/map.js and a navigator's current cursor;5+ * it never mutates either object. Scene transitions and input remain outside6+ * the renderer.7+ */8+9+export const INDEX_COLORS = Object.freeze({10+ stage: '#0F0E0D', board: '#191816', edge: '#2E2B28', lip: '#000000',11+ ink: '#0F0E0D', fg: '#F2EEE6', muted: '#8E877C', dot: '#4A4540',12+ tile: '#F2EEE6', coral: '#FF4B3E', solved: '#2EE6A8', locked: '#8E877C',13+});14+15+function clamp(value, min, max) { return Math.max(min, Math.min(max, value)); }16+function roundRect(ctx, x, y, width, height, radius) {17+ ctx.beginPath();18+ if (typeof ctx.roundRect === 'function') ctx.roundRect(x, y, width, height, radius);19+ else ctx.rect(x, y, width, height);20+}21+function fitFont(ctx, text, maxWidth, size, weight = 800, family = 'ui-monospace, monospace') {22+ let current = size;23+ if (typeof ctx.measureText !== 'function') return `${weight} ${current}px ${family}`;24+ while (current > size * 0.55) {25+ ctx.font = `${weight} ${current}px ${family}`;26+ if (ctx.measureText(text).width <= maxWidth) break;27+ current *= 0.92;28+ }29+ return `${weight} ${Math.max(current, size * 0.55)}px ${family}`;30+}31+32+function currentNode(navigator) {33+ if (!navigator) return null;34+ if (navigator.current?.node) return navigator.current.node;35+ if (navigator.node) return navigator.node;36+ return null;37+}38+39+function currentSheet(layout, navigator, options) {40+ const node = currentNode(navigator);41+ const requested = options.sheetId || node?.sheetId;42+ return layout.sheets.find(sheet => sheet.id === requested) || layout.sheets[options.sheetIndex || 0] || layout.sheets[0] || null;43+}44+45+function nodeCenter(sheet, node, geometry) {46+ return {47+ x: geometry.x + (node.column + 0.5) * geometry.cell,48+ y: geometry.y + (node.row + 0.5) * geometry.cell,49+ };50+}51+52+function drawSlab(ctx, geometry, colors) {53+ const pad = geometry.cell * 0.2;54+ ctx.fillStyle = colors.lip;55+ ctx.fillRect(geometry.x - pad + 8, geometry.y - pad + 8, geometry.width + pad * 2, geometry.height + pad * 2);56+ roundRect(ctx, geometry.x - pad, geometry.y - pad, geometry.width + pad * 2, geometry.height + pad * 2, 4);57+ ctx.fillStyle = colors.board; ctx.fill();58+ ctx.strokeStyle = colors.edge; ctx.lineWidth = 1; ctx.stroke();59+ ctx.fillStyle = colors.dot;60+ for (let row = 0; row <= 3; row++) for (let column = 0; column <= 5; column++) {61+ ctx.beginPath(); ctx.arc(geometry.x + column * geometry.cell, geometry.y + row * geometry.cell, Math.max(1, geometry.cell * 0.018), 0, Math.PI * 2); ctx.fill();62+ }63+}64+65+function drawPath(ctx, sheet, geometry, colors) {66+ const byId = new Map(sheet.nodes.map(node => [node.id, node]));67+ ctx.save();68+ ctx.strokeStyle = colors.dot; ctx.lineWidth = Math.max(1, geometry.cell * 0.045); ctx.lineCap = 'round';69+ for (const node of sheet.nodes) {70+ const next = byId.get(node.nextId);71+ if (!next || next.sheetId !== sheet.id) continue;72+ const from = nodeCenter(sheet, node, geometry); const to = nodeCenter(sheet, next, geometry);73+ ctx.beginPath(); ctx.moveTo(from.x, from.y); ctx.lineTo(to.x, to.y); ctx.stroke();74+ }75+ ctx.restore();76+}77+78+function drawNode(ctx, node, center, geometry, current, colors, options) {79+ const tileWidth = geometry.cell * 0.68;80+ const tileHeight = geometry.cell * 0.54;81+ const left = center.x - tileWidth / 2;82+ const top = center.y - tileHeight / 2;83+ const locked = node.locked === true;84+ const solved = node.solved === true;85+ const active = current?.id === node.id;86+ const signal = solved ? colors.solved : colors.coral;87+ const alpha = locked ? 0.34 : 1;88+ const rect = { id: node.id, x: left, y: top, width: tileWidth, height: tileHeight, centerX: center.x, centerY: center.y, locked, solved, active };89+ ctx.save();90+ ctx.globalAlpha *= alpha;91+ // The cat replaces the tile; only its outline remains under the glyph.92+ if (active) {93+ roundRect(ctx, left - geometry.cell * 0.055, top - geometry.cell * 0.055, tileWidth + geometry.cell * 0.11, tileHeight + geometry.cell * 0.11, geometry.cell * 0.035);94+ ctx.fillStyle = colors.board; ctx.fill();95+ ctx.strokeStyle = signal; ctx.lineWidth = Math.max(2, geometry.cell * 0.045); ctx.stroke();96+ ctx.font = `${Math.max(18, geometry.cell * 0.42)}px "Apple Color Emoji", "Segoe UI Emoji", sans-serif`;97+ ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; ctx.fillStyle = colors.fg; ctx.fillText(options.cat || '🐱', center.x, center.y);98+ } else {99+ roundRect(ctx, left + geometry.cell * 0.04, top + geometry.cell * 0.04, tileWidth, tileHeight, geometry.cell * 0.025);100+ ctx.fillStyle = colors.lip; ctx.fill();101+ roundRect(ctx, left, top, tileWidth, tileHeight, geometry.cell * 0.025);102+ ctx.fillStyle = solved ? colors.solved : colors.tile; ctx.fill();103+ ctx.strokeStyle = solved ? colors.solved : colors.edge; ctx.lineWidth = Math.max(1, geometry.cell * 0.025); ctx.stroke();104+ ctx.font = fitFont(ctx, String(node.number).padStart(2, '0'), tileWidth * 0.75, geometry.cell * 0.22);105+ ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; ctx.fillStyle = colors.ink; ctx.fillText(String(node.number).padStart(2, '0'), center.x, center.y);106+ }107+ ctx.restore();108+109+ ctx.save(); ctx.globalAlpha *= alpha;110+ ctx.textAlign = 'center'; ctx.textBaseline = 'top';111+ if (locked) {112+ ctx.fillStyle = colors.locked; ctx.font = fitFont(ctx, 'LOCKED', geometry.cell * 0.8, geometry.cell * 0.105, 800);113+ ctx.fillText('LOCKED', center.x, top + tileHeight + geometry.cell * 0.09);114+ } else if (active || options.showTitles) {115+ const title = String(node.title || '').toUpperCase();116+ ctx.fillStyle = active ? signal : colors.muted; ctx.font = fitFont(ctx, title, geometry.cell * 0.94, geometry.cell * 0.105, active ? 800 : 600);117+ ctx.fillText(title, center.x, top + tileHeight + geometry.cell * 0.09);118+ }119+ ctx.restore();120+ return rect;121+}122+123+/**124+ * Draw one INDEX sheet and return geometry for hit testing and camera motion.125+ * `navigator.current.node` determines the sheet and the cat's replacement126+ * tile. Pass `sheetId` to draw a sheet before moving the cursor there.127+ */128+export function drawIndex(ctx, layout, navigator, options = {}) {129+ if (!ctx || !layout || !Array.isArray(layout.sheets)) throw new TypeError('drawIndex(ctx, layout, navigator): layout.sheets is required');130+ const colors = { ...INDEX_COLORS, ...(options.colors || {}) };131+ const sheet = currentSheet(layout, navigator, options);132+ if (!sheet) return { sheetId: null, nodes: [], width: 0, height: 0 };133+ const canvasWidth = options.availableWidth ?? ctx.canvas?.width ?? 640;134+ const canvasHeight = options.availableHeight ?? ctx.canvas?.height ?? 420;135+ const margin = options.margin ?? clamp(Math.min(canvasWidth, canvasHeight) * 0.08, 18, 52);136+ const hudHeight = options.hudHeight ?? clamp(canvasHeight * 0.18, 54, 96);137+ const footerHeight = options.footerHeight ?? clamp(canvasHeight * 0.14, 34, 64);138+ const availableWidth = Math.max(1, canvasWidth - margin * 2);139+ const availableHeight = Math.max(1, canvasHeight - hudHeight - footerHeight - margin * 2);140+ const cell = options.cell ?? clamp(Math.min(availableWidth / 5, availableHeight / 3), 28, 150);141+ const width = cell * 5;142+ const height = cell * 3;143+ const x = options.x ?? (canvasWidth - width) / 2;144+ const y = options.y ?? hudHeight + (availableHeight - height) / 2 + margin;145+ const geometry = { x, y, cell, width, height, sheetId: sheet.id, packId: sheet.packId, sheetIndex: sheet.globalSheetIndex, nodes: [] };146+ const current = currentNode(navigator);147+148+ ctx.save();149+ ctx.fillStyle = colors.stage; ctx.fillRect(0, 0, canvasWidth, canvasHeight);150+ drawSlab(ctx, geometry, colors);151+ drawPath(ctx, sheet, geometry, colors);152+ for (const node of sheet.nodes) geometry.nodes.push(drawNode(ctx, node, nodeCenter(sheet, node, geometry), geometry, current, colors, options));153+154+ ctx.textAlign = 'left'; ctx.textBaseline = 'top'; ctx.fillStyle = colors.fg;155+ ctx.font = fitFont(ctx, 'INDEX', canvasWidth * 0.24, Math.max(18, cell * 0.3)); ctx.fillText('INDEX', margin, margin * 0.55);156+ ctx.font = fitFont(ctx, String(sheet.packName || sheet.packId).toUpperCase(), canvasWidth * 0.45, Math.max(11, cell * 0.14), 700); ctx.fillStyle = colors.muted;157+ ctx.fillText(String(sheet.packName || sheet.packId).toUpperCase(), margin, margin * 0.55 + Math.max(22, cell * 0.32));158+ ctx.textAlign = 'right'; ctx.fillStyle = colors.muted; ctx.font = fitFont(ctx, `SHEET ${sheet.globalSheetIndex + 1} / ${layout.sheets.length}`, canvasWidth * 0.34, Math.max(11, cell * 0.14), 700);159+ ctx.fillText(`SHEET ${sheet.globalSheetIndex + 1} / ${layout.sheets.length}`, canvasWidth - margin, margin * 0.7);160+ if (current && current.sheetId === sheet.id) {161+ ctx.textAlign = 'center'; ctx.textBaseline = 'bottom'; ctx.fillStyle = current.locked ? colors.locked : colors.coral;162+ ctx.font = fitFont(ctx, String(current.title || '').toUpperCase(), width * 0.8, Math.max(12, cell * 0.15), 800);163+ ctx.fillText(String(current.title || '').toUpperCase(), x + width / 2, canvasHeight - margin * 0.55);164+ }165+ ctx.restore();166+ return geometry;167+}168+169+export default drawIndex;@@ -0,0 +1,296 @@
1+/**2+ * Data model for the walkable INDEX scene.3+ *4+ * The renderer owns the ink, tiles, and cat. This module only turns the5+ * catalog's stable pack/level records into serpentine sheets and provides the6+ * small amount of progress and cursor state the scene needs.7+ */8+9+export const INDEX_COLUMNS = 5;10+export const INDEX_ROWS = 3;11+export const INDEX_NODES_PER_SHEET = INDEX_COLUMNS * INDEX_ROWS;12+export const PROGRESS_PREFIX = 'miau.index.progress/';13+14+const clone = value => typeof structuredClone === 'function'15+ ? structuredClone(value)16+ : JSON.parse(JSON.stringify(value));17+18+function nonEmpty(value, label) {19+ if (typeof value !== 'string' || value.length === 0) throw new TypeError(`${label} must be a non-empty string`);20+ return value;21+}22+23+function progressKey(packId, levelId) {24+ return `${nonEmpty(packId, 'packId')}/${nonEmpty(levelId, 'levelId')}`;25+}26+27+/** The storage key used for one proof. It is intentionally human-readable. */28+export function getProgressKey(packId, levelId) {29+ return `${PROGRESS_PREFIX}${progressKey(packId, levelId)}`;30+}31+32+function storageWorks(storage) {33+ return storage && typeof storage.getItem === 'function'34+ && typeof storage.setItem === 'function' && typeof storage.removeItem === 'function';35+}36+37+/**38+ * A localStorage backed solved set. A memory fallback keeps private browsing39+ * and server side rendering usable without changing the scene API.40+ */41+export function createProgressStore(storage) {42+ let candidate = storage;43+ if (candidate === undefined) {44+ try { candidate = globalThis.localStorage; } catch { candidate = null; }45+ }46+ const backing = storageWorks(candidate) ? candidate : null;47+ const memory = new Set();48+ const read = key => {49+ if (memory.has(key)) return true;50+ if (!backing) return false;51+ try {52+ const solved = backing.getItem(key) === '1';53+ if (solved) memory.add(key);54+ return solved;55+ } catch { return false; }56+ };57+ const write = (key, solved) => {58+ if (solved) memory.add(key); else memory.delete(key);59+ if (!backing) return;60+ try {61+ if (solved) backing.setItem(key, '1');62+ else backing.removeItem(key);63+ } catch { /* A full or blocked store still has the memory fallback. */ }64+ };65+ return Object.freeze({66+ key: getProgressKey,67+ isSolved(packId, levelId) { return read(getProgressKey(packId, levelId)); },68+ get(packId, levelId) { return read(getProgressKey(packId, levelId)); },69+ setSolved(packId, levelId, solved = true) {70+ const key = getProgressKey(packId, levelId);71+ write(key, Boolean(solved));72+ return Boolean(solved);73+ },74+ markSolved(packId, levelId) { write(getProgressKey(packId, levelId), true); return true; },75+ clear(packId, levelId) { write(getProgressKey(packId, levelId), false); },76+ clearAll() {77+ for (const key of [...memory]) write(key, false);78+ if (!backing) return;79+ try {80+ const keys = [];81+ for (let index = 0; index < backing.length; index++) {82+ const key = backing.key(index);83+ if (key?.startsWith(PROGRESS_PREFIX)) keys.push(key);84+ }85+ keys.forEach(key => backing.removeItem(key));86+ } catch { /* Clearing memory remains safe when storage is unavailable. */ }87+ },88+ snapshot() { return [...memory].map(key => key.slice(PROGRESS_PREFIX.length)); },89+ });90+}91+92+function levelEntries(source) {93+ if (Array.isArray(source)) return source;94+ if (source && typeof source.getLevels === 'function') return source.getLevels();95+ if (source && Array.isArray(source.levels)) return source.levels;96+ return [];97+}98+99+function normalizeEntry(level, fallbackPackId = 'core', fallbackPackName = 'Miau Core') {100+ const id = typeof level.id === 'string' ? level.id : '';101+ const colon = id.indexOf(':');102+ const packId = nonEmpty(level.packId || (colon > 0 ? id.slice(0, colon) : fallbackPackId), 'packId');103+ const levelId = nonEmpty(level.localId || (colon > 0 ? id.slice(colon + 1) : id), 'levelId');104+ return {105+ id: id || `${packId}:${levelId}`,106+ packId,107+ levelId,108+ packName: level.packName || fallbackPackName || packId,109+ title: level.title || `Proof ${levelId}`,110+ hint: level.hint || '',111+ level,112+ };113+}114+115+function groupedEntries(source) {116+ // Catalogs expose a flat, ordered list. The nested form is useful to small117+ // scene tests and keeps the map API independent of catalog implementation.118+ const flat = levelEntries(source);119+ const groups = new Map();120+ for (const level of flat) {121+ const entry = normalizeEntry(level);122+ if (!groups.has(entry.packId)) groups.set(entry.packId, { id: entry.packId, name: entry.packName, entries: [] });123+ groups.get(entry.packId).entries.push(entry);124+ }125+ return [...groups.values()];126+}127+128+function nodeCoordinate(pathIndex) {129+ const row = Math.floor(pathIndex / INDEX_COLUMNS);130+ const offset = pathIndex % INDEX_COLUMNS;131+ return { row, column: row % 2 === 0 ? offset : INDEX_COLUMNS - 1 - offset };132+}133+134+function makeSheet(group, sheetEntries, sheetIndex, globalSheetIndex, allSheetIds) {135+ const sheetId = `${group.id}:sheet-${sheetIndex + 1}`;136+ const nodes = sheetEntries.map((entry, pathIndex) => {137+ const coordinate = nodeCoordinate(pathIndex);138+ return {139+ id: entry.id,140+ packId: entry.packId,141+ levelId: entry.levelId,142+ packName: entry.packName,143+ title: entry.title,144+ hint: entry.hint,145+ sheetId,146+ sheetIndex,147+ globalSheetIndex,148+ pathIndex,149+ number: pathIndex + 1,150+ row: coordinate.row,151+ column: coordinate.column,152+ x: coordinate.column,153+ y: coordinate.row,154+ level: clone(entry.level),155+ };156+ });157+ for (const node of nodes) {158+ node.previousId = nodes[node.pathIndex - 1]?.id || null;159+ node.nextId = nodes[node.pathIndex + 1]?.id || null;160+ node.neighbors = {161+ left: nodes.find(candidate => candidate.row === node.row && candidate.column === node.column - 1)?.id || null,162+ right: nodes.find(candidate => candidate.row === node.row && candidate.column === node.column + 1)?.id || null,163+ up: nodes.find(candidate => candidate.row === node.row - 1 && candidate.column === node.column)?.id || null,164+ down: nodes.find(candidate => candidate.row === node.row + 1 && candidate.column === node.column)?.id || null,165+ };166+ }167+ return {168+ id: sheetId,169+ packId: group.id,170+ packName: group.name,171+ sheetIndex,172+ globalSheetIndex,173+ columns: INDEX_COLUMNS,174+ rows: INDEX_ROWS,175+ nodes,176+ previousSheetId: allSheetIds[globalSheetIndex - 1] || null,177+ nextSheetId: allSheetIds[globalSheetIndex + 1] || null,178+ };179+}180+181+/**182+ * Lay out catalog levels in pack order. Each pack starts its own sheet range;183+ * a pack with more than fifteen levels continues onto another sheet.184+ */185+export function createIndexLayout(source) {186+ const groups = groupedEntries(source);187+ const sheetSpecs = [];188+ for (const group of groups) {189+ for (let offset = 0; offset < group.entries.length; offset += INDEX_NODES_PER_SHEET) {190+ sheetSpecs.push({ group, sheetEntries: group.entries.slice(offset, offset + INDEX_NODES_PER_SHEET), sheetIndex: Math.floor(offset / INDEX_NODES_PER_SHEET) });191+ }192+ }193+ const ids = sheetSpecs.map(({ group, sheetIndex }) => `${group.id}:sheet-${sheetIndex + 1}`);194+ const sheets = sheetSpecs.map((spec, globalSheetIndex) => makeSheet(spec.group, spec.sheetEntries, spec.sheetIndex, globalSheetIndex, ids));195+ // The path leaves one sheet at its edge and enters the next sheet. The196+ // renderer can use the sheet ids to start its camera slide at this link.197+ for (let index = 0; index < sheets.length - 1; index++) {198+ const last = sheets[index].nodes.at(-1);199+ const first = sheets[index + 1].nodes[0];200+ last.nextId = first.id;201+ first.previousId = last.id;202+ }203+ const nodes = sheets.flatMap(sheet => sheet.nodes);204+ return { columns: INDEX_COLUMNS, rows: INDEX_ROWS, sheets, nodes };205+}206+207+function solvedLookup(progress, node) {208+ if (!progress) return false;209+ if (typeof progress.isSolved === 'function') return progress.isSolved(node.packId, node.levelId);210+ if (typeof progress.get === 'function') return Boolean(progress.get(node.packId, node.levelId));211+ const key = `${node.packId}/${node.levelId}`;212+ return progress instanceof Set ? progress.has(key) : Boolean(progress[key]);213+}214+215+/** Add solved/locked state without mutating the layout or progress store. */216+export function decorateIndex(layout, progress) {217+ const priorByPack = new Map();218+ const sheets = layout.sheets.map(sheet => {219+ const nodes = sheet.nodes.map(node => ({ ...node, neighbors: { ...node.neighbors } }));220+ return {221+ ...sheet,222+ nodes: nodes.map(node => {223+ const solved = solvedLookup(progress, node);224+ const prior = priorByPack.get(node.packId);225+ const locked = !solved && Boolean(prior) && !prior.solved;226+ const result = { ...node, solved, locked, state: solved ? 'solved' : (locked ? 'locked' : 'available') };227+ priorByPack.set(node.packId, result);228+ return result;229+ }),230+ };231+ });232+ return { ...layout, sheets, nodes: sheets.flatMap(sheet => sheet.nodes) };233+}234+235+function findNode(layout, nodeId) {236+ return layout.nodes.find(node => node.id === nodeId) || null;237+}238+239+function candidateFor(node, direction, layout) {240+ if (direction === 'next' || direction === 'right') return findNode(layout, node.nextId);241+ if (direction === 'previous' || direction === 'left') return findNode(layout, node.previousId);242+ return findNode(layout, node.neighbors?.[direction]);243+}244+245+/**246+ * Cursor state for the map scene. `locked: 'indicate'` returns a locked node247+ * so the renderer can shake it; `locked: 'skip'` walks to the next available248+ * proof in that direction. Entering a locked node always returns a reason.249+ */250+export function createIndexNavigator(layout, options = {}) {251+ let currentId = options.startNodeId || layout.nodes[0]?.id || null;252+ let currentSheetId = options.startSheetId || findNode(layout, currentId)?.sheetId || layout.sheets[0]?.id || null;253+ const mode = options.locked || 'indicate';254+255+ function current() { return findNode(layout, currentId); }256+ function state() {257+ const node = current();258+ return { node, sheet: layout.sheets.find(sheet => sheet.id === currentSheetId) || null };259+ }260+ function move(direction, moveOptions = {}) {261+ const before = current();262+ if (!before) return { moved: false, locked: false, node: null, from: null };263+ const policy = moveOptions.locked || mode;264+ let candidate = candidateFor(before, direction, layout);265+ let skipped = 0;266+ while (candidate?.locked && policy === 'skip' && skipped < layout.nodes.length) {267+ skipped++;268+ candidate = candidateFor(candidate, direction, layout);269+ }270+ if (!candidate) return { moved: false, locked: false, node: before, from: before };271+ if (candidate.locked && policy !== 'skip') {272+ return { moved: false, locked: true, node: candidate, from: before, reason: 'locked' };273+ }274+ currentId = candidate.id;275+ currentSheetId = candidate.sheetId;276+ return { moved: true, locked: false, skipped, node: candidate, from: before };277+ }278+ function enter() {279+ const node = current();280+ if (!node) return { ok: false, reason: 'empty' };281+ if (node.locked) return { ok: false, reason: 'locked', node };282+ return { ok: true, packId: node.packId, levelId: node.levelId, level: clone(node.level), node };283+ }284+ return {285+ get current() { return state(); },286+ move,287+ enter,288+ setNode(nodeId) {289+ const node = findNode(layout, nodeId);290+ if (!node) return false;291+ currentId = node.id; currentSheetId = node.sheetId; return true;292+ },293+ };294+}295+296+export default createIndexLayout;@@ -0,0 +1,53 @@
1+import test from 'node:test';2+import assert from 'node:assert/strict';3+import { createIndexLayout, createIndexNavigator, decorateIndex } from '../src/scenes/map.js';4+import { drawIndex } from '../src/render/index.js';5+6+function context(width = 640, height = 420) {7+ const calls = [];8+ return {9+ canvas: { width, height }, calls,10+ save() { calls.push(['save']); }, restore() { calls.push(['restore']); },11+ beginPath() { calls.push(['beginPath']); }, closePath() { calls.push(['closePath']); },12+ moveTo(...args) { calls.push(['moveTo', ...args]); }, lineTo(...args) { calls.push(['lineTo', ...args]); },13+ rect(...args) { calls.push(['rect', ...args]); }, roundRect(...args) { calls.push(['roundRect', ...args]); },14+ fill() { calls.push(['fill']); }, stroke() { calls.push(['stroke']); }, fillRect(...args) { calls.push(['fillRect', ...args]); },15+ arc(...args) { calls.push(['arc', ...args]); }, fillText(...args) { calls.push(['fillText', ...args]); },16+ measureText(text) { return { width: String(text).length * 8 }; },17+ };18+}19+20+const level = (packId, localId, title = localId) => ({21+ id: `${packId}:${localId}`, localId, packId, packName: packId === 'core' ? 'Miau Core' : 'Word Lab', title,22+ width: 1, height: 1, objects: [],23+});24+25+test('drawIndex returns adaptive non-overlapping node geometry and renders the path', () => {26+ const layout = decorateIndex(createIndexLayout(Array.from({ length: 6 }, (_, i) => level('core', `p${i + 1}`))), null);27+ const navigator = createIndexNavigator(layout);28+ const ctx = context(480, 320);29+ const geometry = drawIndex(ctx, layout, navigator);30+ assert.equal(geometry.sheetId, 'core:sheet-1');31+ assert.equal(geometry.nodes.length, 6);32+ assert.ok(ctx.calls.some(call => call[0] === 'lineTo'));33+ assert.ok(ctx.calls.some(call => call[0] === 'fillText' && call[1] === '🐱'));34+ for (let i = 0; i < geometry.nodes.length; i++) for (let j = i + 1; j < geometry.nodes.length; j++) {35+ const a = geometry.nodes[i]; const b = geometry.nodes[j];36+ const overlap = a.x < b.x + b.width && a.x + a.width > b.x && a.y < b.y + b.height && a.y + a.height > b.y;37+ assert.equal(overlap, false, `${a.id} overlaps ${b.id}`);38+ }39+});40+41+test('drawIndex signals solved and locked proofs while the cat replaces the current tile', () => {42+ const layout = decorateIndex(createIndexLayout([level('core', 'one', 'Read'), level('core', 'two', 'Break'), level('core', 'three', 'Push')]), {43+ isSolved(packId, levelId) { return packId === 'core' && levelId === 'one'; },44+ });45+ const navigator = createIndexNavigator(layout, { startNodeId: 'core:two' });46+ const ctx = context(600, 400);47+ const geometry = drawIndex(ctx, layout, navigator, { showTitles: true });48+ assert.equal(geometry.nodes.find(node => node.id === 'core:two').active, true);49+ assert.equal(geometry.nodes.find(node => node.id === 'core:one').solved, true);50+ assert.equal(geometry.nodes.find(node => node.id === 'core:three').locked, true);51+ assert.ok(ctx.calls.some(call => call[0] === 'fillText' && call[1] === 'LOCKED'));52+ assert.ok(ctx.calls.some(call => call[0] === 'fillText' && call[1] === '🐱'));53+});@@ -0,0 +1,89 @@
1+import test from 'node:test';2+import assert from 'node:assert/strict';3+import {4+ INDEX_NODES_PER_SHEET,5+ createIndexLayout,6+ createIndexNavigator,7+ createProgressStore,8+ decorateIndex,9+ getProgressKey,10+} from '../src/scenes/map.js';11+12+const level = (packId, localId, title = localId) => ({13+ id: `${packId}:${localId}`,14+ localId,15+ packId,16+ packName: packId === 'core' ? 'Miau Core' : 'Word Lab',17+ title,18+ width: 1,19+ height: 1,20+ objects: [],21+});22+23+test('INDEX auto layout is serpentine and continues across sheets', () => {24+ const levels = Array.from({ length: 17 }, (_, index) => level('core', `proof-${index + 1}`));25+ levels.push(level('word-lab', 'first'));26+ const layout = createIndexLayout(levels);27+ assert.equal(layout.sheets.length, 3);28+ assert.equal(layout.sheets[0].nodes.length, INDEX_NODES_PER_SHEET);29+ assert.equal(layout.sheets[1].nodes.length, 2);30+ assert.equal(layout.sheets[2].packId, 'word-lab');31+ assert.deepEqual(layout.sheets[0].nodes.slice(0, 5).map(node => [node.column, node.row]), [[0, 0], [1, 0], [2, 0], [3, 0], [4, 0]]);32+ assert.deepEqual(layout.sheets[0].nodes.slice(5, 10).map(node => [node.column, node.row]), [[4, 1], [3, 1], [2, 1], [1, 1], [0, 1]]);33+ assert.equal(layout.sheets[0].nodes[14].nextId, 'core:proof-16');34+ assert.equal(layout.sheets[0].nextSheetId, 'core:sheet-2');35+ assert.equal(layout.sheets[1].previousSheetId, 'core:sheet-1');36+ assert.equal(layout.nodes[16].id, 'core:proof-17');37+ assert.equal(layout.nodes.at(-1).id, 'word-lab:first');38+});39+40+test('progress uses stable pack/level keys and locks the next proof', () => {41+ const storage = new Map();42+ const fakeStorage = {43+ get length() { return storage.size; },44+ key(index) { return [...storage.keys()][index] ?? null; },45+ getItem(key) { return storage.get(key) ?? null; },46+ setItem(key, value) { storage.set(key, String(value)); },47+ removeItem(key) { storage.delete(key); },48+ };49+ const progress = createProgressStore(fakeStorage);50+ assert.equal(getProgressKey('core', 'intro'), 'miau.index.progress/core/intro');51+ const layout = createIndexLayout([level('core', 'intro'), level('core', 'second'), level('word-lab', 'first')]);52+ let indexed = decorateIndex(layout, progress);53+ assert.deepEqual(indexed.nodes.map(node => [node.id, node.state]), [54+ ['core:intro', 'available'], ['core:second', 'locked'], ['word-lab:first', 'available'],55+ ]);56+ progress.markSolved('core', 'intro');57+ indexed = decorateIndex(layout, progress);58+ assert.equal(indexed.nodes[0].solved, true);59+ assert.equal(indexed.nodes[1].locked, false);60+ assert.equal(storage.get('miau.index.progress/core/intro'), '1');61+ progress.clear('core', 'intro');62+ assert.equal(progress.isSolved('core', 'intro'), false);63+});64+65+test('navigator indicates locked proofs, can skip them, and enters available proofs', () => {66+ const layout = decorateIndex(createIndexLayout([level('core', 'intro'), level('core', 'second'), level('core', 'third')]), {67+ isSolved(packId, levelId) { return packId === 'core' && levelId === 'intro'; },68+ });69+ const navigator = createIndexNavigator(layout, { locked: 'indicate' });70+ assert.equal(navigator.current.node.id, 'core:intro');71+ assert.equal(navigator.enter().ok, true);72+ let result = navigator.move('next');73+ assert.equal(result.moved, true);74+ assert.equal(result.node.id, 'core:second');75+ result = navigator.move('next');76+ assert.equal(result.locked, true);77+ assert.equal(result.node.id, 'core:third');78+ assert.equal(navigator.current.node.id, 'core:second');79+ assert.equal(navigator.enter().levelId, 'second');80+81+ const skipping = createIndexNavigator(layout, { startNodeId: 'core:intro', locked: 'skip' });82+ result = skipping.move('next');83+ assert.equal(result.moved, true);84+ assert.equal(result.node.id, 'core:second');85+ result = skipping.move('next');86+ assert.equal(result.moved, false);87+ assert.equal(result.node.id, 'core:second');88+ assert.equal(skipping.enter().levelId, 'second');89+});