clone

a browser grid puzzle game where you push words around to rewrite the rules of each level

Centralize data cloning across pack boundaries

Dami committed f014da5
commit
f014da595a471c42b0711719e5dc724730cd0b96
parent
78137b13dd115626eba1c03cdf5217da4b7e11fc
author
Dami <dami.etoile@gmail.com>
7 files changed, +53 -41
Msrc/catalog.js18++++++++----------
Asrc/data/clone.js19+++++++++++++++++++
Msrc/pack-library.js13+++++--------
Msrc/pack-service.js21+++++++++------------
Msrc/scenes/gift.js12++++++------
Msrc/scenes/workshop.js7++++---
Msrc/share.js4++--
M src/catalog.js +8 -10
@@ -1,14 +1,12 @@
11 import { levels as builtInLevels } from './levels.js';22 import { MOD_SCHEMA_VERSION, validateMod } from './mods.js';3+import { cloneData } from './data/clone.js';34 45 export const CORE_PACK_ID = 'core';56 const LOCAL_LEVEL_ID_RE = /^[a-z][a-z0-9._-]{0,63}$/;67 const ORIGINS = new Set(['local', 'url', 'bundled']);78 89 function isRecord(value) { return value !== null && typeof value === 'object' && !Array.isArray(value); }9-function clone(value) {10- return typeof structuredClone === 'function' ? structuredClone(value) : JSON.parse(JSON.stringify(value));11-}1210 function fail(errors) {1311 const list = Array.isArray(errors) ? errors : [errors];1412 const error = new Error(`Cannot register pack:\n- ${list.join('\n- ')}`);
@@ -24,7 +22,7 @@ function fail(errors) {
2422 export function createCorePack(sourceLevels = builtInLevels) {2523 return {2624 manifest: { id: CORE_PACK_ID, name: 'Miau Core', version: '1.0.0', engine: MOD_SCHEMA_VERSION },27- levels: clone(sourceLevels),25+ levels: cloneData(sourceLevels),2826 palette: {},2927 vocabulary: [],3028 };
@@ -51,7 +49,7 @@ function validatePack(pack) {
5149 5250 function makePackRecord(pack) {5351 validatePack(pack);54- const normalized = clone(pack);52+ const normalized = cloneData(pack);5553 const packId = normalized.manifest.id;5654 const entries = normalized.levels.map(level => {5755 const localId = level.id;
@@ -134,24 +132,24 @@ export function createCatalog(options = {}) {
134132 function getPacks() {135133 return order.map(id => {136134 const pack = packs.get(id);137- return { id: pack.id, origin: pack.origin, manifest: clone(pack.manifest), levelCount: pack.levels.length };135+ return { id: pack.id, origin: pack.origin, manifest: cloneData(pack.manifest), levelCount: pack.levels.length };138136 });139137 }140138 /** Return a validated deep clone of the original pack, or undefined. */141139 function getPack(id) {142140 const pack = packs.get(id);143- return pack ? clone(pack.source) : undefined;141+ return pack ? cloneData(pack.source) : undefined;144142 }145143 function getLevels() {146144 return order.flatMap(packId => packs.get(packId).levels.map(entry => ({147- ...clone(entry.level), id: entry.id, localId: entry.localId, packId: entry.packId,148- packName: entry.packName, origin: packs.get(entry.packId).origin, palette: clone(entry.palette),145+ ...cloneData(entry.level), id: entry.id, localId: entry.localId, packId: entry.packId,146+ packName: entry.packName, origin: packs.get(entry.packId).origin, palette: cloneData(entry.palette),149147 })));150148 }151149 function getLevel(identifier) {152150 const all = getLevels();153151 const found = typeof identifier === 'number' ? all[identifier] : all.find(level => level.id === identifier);154- return found ? clone(found) : undefined;152+ return found ? cloneData(found) : undefined;155153 }156154 function setActiveLevel(identifier) {157155 const level = getLevel(identifier);
A src/data/clone.js +19 -0
M src/pack-library.js +5 -8
@@ -1,14 +1,11 @@
11 import { CORE_PACK_ID, createCatalog } from './catalog.js';2+import { cloneData } from './data/clone.js';23 34 export const PACK_LIBRARY_SCHEMA = 1;45 export const DEFAULT_PACK_LIBRARY_KEY = 'miau.pack-library.v1';56 export const MAX_STORED_PACKS = 128;67 export const MAX_STORED_CHARACTERS = 2 * 1024 * 1024;78 8-const clone = value => typeof structuredClone === 'function'9- ? structuredClone(value)10- : JSON.parse(JSON.stringify(value));11-129 function discoverStorage(storage) {1310 if (storage !== undefined) return storage;1411 try { return globalThis.localStorage; } catch { return null; }
@@ -41,7 +38,7 @@ export function createPackLibrary(options = {}) {
4138 4239 function persist() {4340 const packs = snapshot(catalog);44- memorySnapshot = clone(packs);41+ memorySnapshot = cloneData(packs);4542 if (!storage || typeof storage.setItem !== 'function') {4643 storageError ||= 'localStorage is unavailable';4744 return false;
@@ -89,7 +86,7 @@ export function createPackLibrary(options = {}) {
8986 for (const [index, pack] of data.packs.entries()) {9087 try {9188 catalog.register(pack, { origin: 'local' });92- memorySnapshot.push(clone(pack));89+ memorySnapshot.push(cloneData(pack));9390 } catch (error) {9491 errors.push(`packs[${index}]: ${error instanceof Error ? error.message : String(error)}`);9592 }
@@ -107,7 +104,7 @@ export function createPackLibrary(options = {}) {
107104 let suffix = 2;108105 const forkId = () => `${original.slice(0, 64 - String(suffix).length - 1)}-${suffix}`;109106 while (catalog.getPack(forkId())) suffix++;110- candidate = clone(pack);107+ candidate = cloneData(pack);111108 candidate.manifest.id = forkId();112109 candidate.manifest.forkOf = original;113110 }
@@ -133,7 +130,7 @@ export function createPackLibrary(options = {}) {
133130 removePack,134131 getPacks: () => catalog.getPacks(),135132 getPack: id => catalog.getPack(id),136- get persistedPacks() { return clone(memorySnapshot); },133+ get persistedPacks() { return cloneData(memorySnapshot); },137134 get persistence() {138135 return { mode: storageError ? 'memory' : 'storage', error: storageError };139136 },
M src/pack-service.js +9 -12
@@ -2,10 +2,7 @@
22 import { loadMod } from './mods.js';33 import { createCatalog } from './catalog.js';44 import { decodeSharedPack, encodeSharedPack, exportCommonJS } from './share.js';5-6-function clone(value) {7- return typeof structuredClone === 'function' ? structuredClone(value) : JSON.parse(JSON.stringify(value));8-}5+import { cloneData } from './data/clone.js';96 107 function errorDetails(error) {118 return Array.isArray(error?.errors) ? error.errors.map(String)
@@ -71,8 +68,8 @@ function sourceFromView(view) {
7168 if (!view.id || !Array.isArray(view.levels)) return view;7269 return {7370 manifest: manifestFromView(view),74- vocabulary: clone(view.vocabulary || []),75- palette: clone(view.palette || {}),71+ vocabulary: cloneData(view.vocabulary || []),72+ palette: cloneData(view.palette || {}),7673 levels: view.levels.map(level => {7774 const { globalId, localId, packId, packName, origin, palette, ...authored } = level;7875 return authored;
@@ -81,7 +78,7 @@ function sourceFromView(view) {
8178 }8279 8380 function createPackView(catalog, source, origin, previews, conflict = null) {84- const data = clone(source);81+ const data = cloneData(source);8582 const view = {8683 id: data.manifest.id,8784 name: data.manifest.name,
@@ -90,17 +87,17 @@ function createPackView(catalog, source, origin, previews, conflict = null) {
9087 engine: data.manifest.engine,9188 forkOf: data.manifest.forkOf ?? null,9289 origin: origin || null,93- vocabulary: clone(data.vocabulary || []),90+ vocabulary: cloneData(data.vocabulary || []),9491 levels: data.levels.map(level => ({95- ...clone(level),92+ ...cloneData(level),9693 globalId: `${data.manifest.id}:${level.id}`,9794 localId: level.id,9895 packId: data.manifest.id,9996 packName: data.manifest.name,10097 origin: origin || null,101- palette: clone(data.palette || {}),98+ palette: cloneData(data.palette || {}),10299 })),103- palette: clone(data.palette || {}),100+ palette: cloneData(data.palette || {}),104101 conflict,105102 };106103 previews.set(view, data);
@@ -163,7 +160,7 @@ export function createPackService(options = {}) {
163160 let suffix = 2;164161 const forkId = () => `${original.slice(0, 64 - String(suffix).length - 1)}-${suffix}`;165162 while (catalog.getPack(forkId())) suffix++;166- candidate = clone(source);163+ candidate = cloneData(source);167164 candidate.manifest.id = forkId();168165 candidate.manifest.forkOf = original;169166 }
M src/scenes/gift.js +6 -6
@@ -1,5 +1,6 @@
11 import { drawBoard } from '../render/board.js';22 import { createGame } from '../engine.js';3+import { cloneData } from '../data/clone.js';34 45 const FONT = 'Recursive, ui-monospace, monospace';56 const COLORS = Object.freeze({
@@ -8,7 +9,6 @@ const COLORS = Object.freeze({
89 YOU: '#FF4B3E', WIN: '#2EE6A8', accent: '#FFB020', danger: '#FF4FA3',910 });1011 11-const clone = value => typeof structuredClone === 'function' ? structuredClone(value) : JSON.parse(JSON.stringify(value));1212 const manifestOf = pack => pack?.manifest || pack || {};1313 const levelList = pack => Array.isArray(pack?.levels) ? pack.levels : [];1414 const packName = pack => manifestOf(pack).name || pack?.name || 'Untitled pack';
@@ -35,8 +35,8 @@ function fitLabel(ctx, value, maxWidth, size = 16, weight = 800) {
3535 3636 /** Canvas-only Gift controller. It never installs a pack itself. */3737 export function createGiftScene({ pack = null, collision = false, reducedMotion = false, onAction } = {}) {38- let preview = pack ? clone(pack) : null;39- let collides = collision ? (typeof collision === 'object' ? clone(collision) : { id: '', name: 'this pack' }) : null;38+ let preview = pack ? cloneData(pack) : null;39+ let collides = collision ? (typeof collision === 'object' ? cloneData(collision) : { id: '', name: 'this pack' }) : null;4040 let mode = 'prompt';4141 let selected = 0;4242 let open = Boolean(pack);
@@ -44,13 +44,13 @@ export function createGiftScene({ pack = null, collision = false, reducedMotion
4444 const primaryChoices = () => ['keep', 'later'];4545 const collisionChoices = () => ['replace', 'copy', 'later'];4646 const choices = () => mode === 'collision' ? collisionChoices() : primaryChoices();47- function setPack(value, options = {}) { const collisionValue = options.collision ?? collision; preview = value ? clone(value) : null; collides = collisionValue ? (typeof collisionValue === 'object' ? clone(collisionValue) : { id: '', name: 'this pack' }) : null; mode = 'prompt'; selected = 0; open = Boolean(value); buttons = []; return api; }47+ function setPack(value, options = {}) { const collisionValue = options.collision ?? collision; preview = value ? cloneData(value) : null; collides = collisionValue ? (typeof collisionValue === 'object' ? cloneData(collisionValue) : { id: '', name: 'this pack' }) : null; mode = 'prompt'; selected = 0; open = Boolean(value); buttons = []; return api; }4848 function show(value = preview, options = {}) { return setPack(value, options); }4949 function hide() { open = false; buttons = []; return api; }5050 function choose(action = choices()[selected]) {5151 if (!open || !preview) return { ok: false, action: null };5252 if (action === 'keep' && collides) { mode = 'collision'; selected = 0; return { ok: true, action: 'collision' }; }53- const result = { ok: true, action, pack: clone(preview), install: action === 'keep' ? {} : action === 'replace' ? { replace: true } : action === 'copy' ? { keepBoth: true } : null };53+ const result = { ok: true, action, pack: cloneData(preview), install: action === 'keep' ? {} : action === 'replace' ? { replace: true } : action === 'copy' ? { keepBoth: true } : null };5454 hide(); onAction?.(result); return result;5555 }5656 function move(direction) { const values = choices(); if (direction === 'up' || direction === 'left') selected = (selected - 1 + values.length) % values.length; if (direction === 'down' || direction === 'right') selected = (selected + 1) % values.length; return values[selected]; }
@@ -128,7 +128,7 @@ export function createGiftScene({ pack = null, collision = false, reducedMotion
128128 hintMetrics.forEach(({ key, label, labelSize, keyWidth, width: itemWidth }) => { ctx.save(); ctx.fillStyle = COLORS.board; rounded(ctx, hintX, top + cardHeight - 35, keyWidth, 20, 3); ctx.fill(); ctx.strokeStyle = COLORS.edge; ctx.lineWidth = 1; rounded(ctx, hintX, top + cardHeight - 35, keyWidth, 20, 3); ctx.stroke(); text(ctx, key, hintX + keyWidth / 2, top + cardHeight - 25, { size: hintSize - 1, weight: 850, color: COLORS.fg, align: 'center' }); text(ctx, label, hintX + keyWidth + 6, top + cardHeight - 25, { size: labelSize, weight: 700, color: COLORS.muted }); hintX += itemWidth + hintGap; }); ctx.restore();129129 return { x: left, y: top, width: cardWidth, height: cardHeight, narrow, preview: { x: previewX, y: previewY, width: previewWidth, height: previewHeight }, actions: buttons.map(button => ({ ...button })), hints: { x: hintStart, width: hintTotal }, hintY: top + cardHeight - 25, selected: selectedAction, now };130130 }131- const api = { show, hide, setPack, choose, input, hit, draw, get open() { return open; }, get mode() { return mode; }, get selected() { return choices()[selected]; }, get pack() { return preview ? clone(preview) : null; }, get collision() { return collides; } };131+ const api = { show, hide, setPack, choose, input, hit, draw, get open() { return open; }, get mode() { return mode; }, get selected() { return choices()[selected]; }, get pack() { return preview ? cloneData(preview) : null; }, get collision() { return collides ? cloneData(collides) : null; } };132132 return Object.freeze(api);133133 }134134
M src/scenes/workshop.js +4 -3
@@ -1,3 +1,5 @@
1+import { cloneData } from '../data/clone.js';2+13 /**24 * Canvas Workshop scene.35 *
@@ -12,7 +14,6 @@ export const WORKSHOP_COLORS = Object.freeze({
1214 YOU: '#FF4B3E', WIN: '#2EE6A8', PUSH: '#FFB020', STOP: '#8B91FF',1315 });1416 15-const clone = value => typeof structuredClone === 'function' ? structuredClone(value) : JSON.parse(JSON.stringify(value));1617 const actionsFor = pack => ['play', 'share', 'export', ...(pack.origin === 'bundled' ? [] : ['remove'])];1718 const actionLabel = { play: 'PLAY', share: 'SHARE LINK', export: 'EXPORT .CJS', remove: 'REMOVE' };1819 const FONT = "'Recursive', ui-monospace, monospace";
@@ -43,7 +44,7 @@ function drawKeyHint(ctx, key, verb, right, y, colors) {
4344 function normalizePacks(service, packs) {4445 const source = packs || service?.listPacks?.() || [];4546 return source.map(pack => ({46- ...clone(pack),47+ ...cloneData(pack),4748 actions: actionsFor(pack),4849 levelCount: pack.levelCount ?? pack.levels?.length ?? 0,4950 }));
@@ -269,7 +270,7 @@ export function createWorkshopScene(options = {}) {
269270 id: 'workshop', eyebrow: 'WORKSHOP', title: "Packs of proofs you've added", prompt: 'IMPORT | PLAY | SHARE | EXPORT', help: 'ARROWS CHOOSE ENTER SELECT ESC BACK',270271 draw, view, refresh, move, handleInput, hit, activate, copyDetails, showErrors,271272 get selectedRow() { return rowIndex; }, get selectedAction() { return actionIndex; },272- get hitLayout() { return hitLayout ? clone(hitLayout) : null; },273+ get hitLayout() { return hitLayout ? cloneData(hitLayout) : null; },273274 });274275 }275276
M src/share.js +2 -2