clone

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

Resolve active proofs by stable catalog identity

Dami committed 50e4d68
commit
50e4d687990c223d77e5dab9c73834a9d8c861a2
parent
ac9625feb2ce38403366f5479e0486833e56723a
author
Dami <dami.etoile@gmail.com>
3 files changed, +113 -31
Msrc/catalog.js81+++++++++++++++++++++++-------
Msrc/main.js42+++++++++++----
Mtests/catalog.test.js21++++++++
M src/catalog.js +61 -20
@@ -97,13 +97,32 @@ export function createCatalog(options = {}) {
9797 const coreLevels = Array.isArray(options) ? options : options.coreLevels;9898 const packs = new Map();9999 const order = [];100+ const levelsById = new Map();101+ let orderedLevelIds = [];102+ let generation = 0;103+ let packSummaries = null;104+ let packSummaryGeneration = -1;100105 let activeId = null;101106 const core = makePackRecord(createCorePack(coreLevels || builtInLevels));102107 core.origin = 'bundled';103108 packs.set(core.id, core);104109 order.push(core.id);110+ for (const entry of core.levels) levelsById.set(entry.id, entry);111+ orderedLevelIds = core.levels.map(entry => entry.id);105112 activeId = core.levels[0]?.id || null;106113 114+ function invalidate() {115+ generation += 1;116+ }117+118+ function rebuildLevelOrder() {119+ orderedLevelIds = order.flatMap(packId => packs.get(packId).levels.map(entry => entry.id));120+ for (const [id, entry] of levelsById) if (!packs.has(entry.packId)) levelsById.delete(id);121+ for (const packId of order) for (const entry of packs.get(packId).levels) levelsById.set(entry.id, entry);122+ }123+124+ function firstAvailableLevelId() { return orderedLevelIds[0] || null; }125+107126 function register(pack, options = {}) {108127 const origin = options.origin || 'local';109128 const replace = options.replace === true;
@@ -114,15 +133,21 @@ export function createCatalog(options = {}) {
114133 if (previous) {115134 if (record.id === CORE_PACK_ID) throw fail('core pack is bundled and cannot be replaced');116135 if (!replace) throw fail(`pack id collision: ${record.id}`);136+ const replacedActive = levelsById.get(activeId)?.packId === record.id;137+ for (const [id, entry] of levelsById) if (entry.packId === record.id) levelsById.delete(id);117138 packs.set(record.id, record);118- if (activeId?.startsWith(`${record.id}:`) && !record.levels.some(level => level.id === activeId)) {139+ rebuildLevelOrder();140+ invalidate();141+ if (replacedActive && !levelsById.has(activeId)) {119142 activeId = record.levels[0]?.id || null;120143 }121- if (!activeId) activeId = getLevels().find(level => level.id)?.id || null;144+ if (!activeId) activeId = firstAvailableLevelId();122145 return record.id;123146 }124147 packs.set(record.id, record);125148 order.push(record.id);149+ rebuildLevelOrder();150+ invalidate();126151 if (!activeId) activeId = record.levels[0]?.id || null;127152 return record.id;128153 }
@@ -135,25 +160,31 @@ export function createCatalog(options = {}) {
135160 const record = packs.get(id);136161 if (!record) return false;137162 if (record.origin === 'bundled' || id === CORE_PACK_ID) throw fail(`bundled pack cannot be removed: ${id}`);138- const wasActive = activeId?.startsWith(`${id}:`);163+ const wasActive = levelsById.get(activeId)?.packId === id;139164 packs.delete(id);140165 order.splice(order.indexOf(id), 1);141- if (wasActive) activeId = getLevels().find(level => level.id)?.id || null;166+ rebuildLevelOrder();167+ invalidate();168+ if (wasActive) activeId = firstAvailableLevelId();142169 return true;143170 }144171 145172 function getPacks() {146- return order.map(id => {147- const pack = packs.get(id);148- return {149- id: pack.id,150- origin: pack.origin,151- manifest: { ...cloneData(pack.manifest), world: cloneData(pack.world) },152- world: cloneData(pack.world),153- worldWarnings: cloneData(pack.worldWarnings),154- levelCount: pack.levels.length,155- };156- });173+ if (!packSummaries || packSummaryGeneration !== generation) {174+ packSummaries = order.map(id => {175+ const pack = packs.get(id);176+ return {177+ id: pack.id,178+ origin: pack.origin,179+ manifest: { ...cloneData(pack.manifest), world: cloneData(pack.world) },180+ world: cloneData(pack.world),181+ worldWarnings: cloneData(pack.worldWarnings),182+ levelCount: pack.levels.length,183+ };184+ });185+ packSummaryGeneration = generation;186+ }187+ return cloneData(packSummaries);157188 }158189 /** Return a validated deep clone of the original pack, or undefined. */159190 function getPack(id) {
@@ -167,9 +198,19 @@ export function createCatalog(options = {}) {
167198 })));168199 }169200 function getLevel(identifier) {170- const all = getLevels();171- const found = typeof identifier === 'number' ? all[identifier] : all.find(level => level.id === identifier);172- return found ? cloneData(found) : undefined;201+ const id = typeof identifier === 'number' ? orderedLevelIds[identifier] : identifier;202+ const entry = levelsById.get(id);203+ if (!entry) return undefined;204+ const pack = packs.get(entry.packId);205+ return {206+ ...cloneData(entry.level),207+ id: entry.id,208+ localId: entry.localId,209+ packId: entry.packId,210+ packName: entry.packName,211+ origin: pack.origin,212+ palette: cloneData(entry.palette),213+ };173214 }174215 function setActiveLevel(identifier) {175216 const level = getLevel(identifier);
@@ -177,7 +218,7 @@ export function createCatalog(options = {}) {
177218 activeId = level.id;178219 return true;179220 }180- function getActiveLevel() { return getLevel(activeId); }221+ function getActiveLevel() { return activeId ? getLevel(activeId) : undefined; }181222 182223 return Object.freeze({183224 register,
@@ -190,7 +231,7 @@ export function createCatalog(options = {}) {
190231 setActiveLevel,191232 getActiveLevel,192233 get activeLevelId() { return activeId; },193- get size() { return getLevels().length; },234+ get size() { return orderedLevelIds.length; },194235 });195236 }196237
M src/main.js +31 -11
@@ -47,6 +47,7 @@ if (!new URLSearchParams(location.search).has('dev')) document.body.classList.ad
4747 // clocks; only presentation effects and their intentional scene waits use this.4848 const presentationClock = createPresentationClock({ slowmo: parseSlowmo(location.search) });4949 let levelNo = 0;50+let activeLevelId = null;5051 let game;5152 let lastState;5253 const catalog = createCatalog();
@@ -67,7 +68,7 @@ const giftController = createGiftScene({
6768 },6869 onAction: result => { if (result.action === 'keep' || result.action === 'replace' || result.action === 'copy') { const installed=packService.installPack(result.pack,{origin:giftOrigin,...(result.install||{})}); if(installed.ok){ showModStatus(`Pack installed / ${installed.pack.name}`); const first = currentLevels().findIndex(level => level.packId === installed.pack.id); if (first >= 0) loadLevel(first); refreshIndex(currentLevels()[first]?.id); workshopController.refresh(); stage.show('index'); } else {workshopController.showErrors(installed.errors);stage.show('workshop');} } }6970 });70-const workshopController = createWorkshopScene({ service: packService, onBack: () => stage.closeWorkshop(), onImport: () => document.querySelector('#mod-input').click(), onPlay: packId => { const first=currentLevels().find(level=>level.packId===packId); refreshIndex(first?.id); stage.show('index', { origin: 'workshop', worldId: packId }); }, onRemove: () => { refreshIndex(); if (!catalog.getLevel(catalog.activeLevelId)) loadLevel(0); }, onShare: (_id,url) => { navigator.clipboard?.writeText(url).then(() => { stage.announce('LINK IS COPIED'); stage.showCopyConfirmation?.(); }).catch(() => showModStatus('Share URL ready / copy it manually',url)); }, onExport: (id,blob) => { const href=URL.createObjectURL(blob); const a=document.createElement('a');a.href=href;a.download=`${id}.cjs`;a.click();setTimeout(()=>URL.revokeObjectURL(href),0); }, copyToClipboard: text => navigator.clipboard?.writeText(text) });71+const workshopController = createWorkshopScene({ service: packService, onBack: () => stage.closeWorkshop(), onImport: () => document.querySelector('#mod-input').click(), onPlay: packId => { const first=currentLevels().find(level=>level.packId===packId); refreshIndex(first?.id); stage.show('index', { origin: 'workshop', worldId: packId }); }, onRemove: handleWorkshopRemove, onShare: (_id,url) => { navigator.clipboard?.writeText(url).then(() => { stage.announce('LINK IS COPIED'); stage.showCopyConfirmation?.(); }).catch(() => showModStatus('Share URL ready / copy it manually',url)); }, onExport: (id,blob) => { const href=URL.createObjectURL(blob); const a=document.createElement('a');a.href=href;a.download=`${id}.cjs`;a.click();setTimeout(()=>URL.revokeObjectURL(href),0); }, copyToClipboard: text => navigator.clipboard?.writeText(text) });7172 stage.setControllers({ title: titleController, gift: giftController, workshop: workshopController });7273 let soundOn = !audio.isMuted();7374 soundButton.textContent = soundOn ? '◖' : '○';
@@ -82,11 +83,26 @@ const escapeHTML = value => String(value ?? '').replace(/[&<>"']/g, character =>
8283 const errorText = errors => (errors || []).map(error => error?.message || error?.detail || String(error)).join('\n');8384 8485 function currentLevels() { return catalog.getLevels(); }86+function activeLevel() { return activeLevelId ? catalog.getLevel(activeLevelId) : catalog.getActiveLevel(); }87+function reconcileActiveLevel() {88+ const resolved = catalog.getActiveLevel();89+ activeLevelId = resolved?.id || null;90+ const index = resolved ? currentLevels().findIndex(level => level.id === resolved.id) : -1;91+ if (index >= 0) levelNo = index;92+ return resolved;93+}94+function handleWorkshopRemove() {95+ refreshIndex();96+ if (activeLevelId && catalog.getLevel(activeLevelId)) return;97+ const fallback = reconcileActiveLevel();98+ if (fallback) loadLevel(fallback.id);99+}85100 function displayState() {86101 const list = currentLevels();102+ const activeIndex = activeLevelId ? list.findIndex(level => level.id === activeLevelId) : levelNo;87103 return {88104 ...game.getState(),89- proofNumber: levelNo + 1,105+ proofNumber: (activeIndex >= 0 ? activeIndex : levelNo) + 1,90106 proofTotal: list.length,91107 completedCount: list.filter(level => progress.isSolved(level.packId, level.localId)).length,92108 };
@@ -106,18 +122,22 @@ function refreshIndex(focusId) {
106122 });107123 stage.setIndex(indexLayout, indexNavigator);108124 }109-function activePalette() { return currentLevels()[levelNo]?.palette || {}; }110-function activePackId() { return currentLevels()[levelNo]?.packId || 'core'; }125+function activePalette() { return activeLevel()?.palette || {}; }126+function activePackId() { return activeLevel()?.packId || 'core'; }111127 function showModStatus(message, detail = '') { modStatus.textContent = message; modStatus.title = detail || message; }112128 function registerBundledPacks() { for (const { filename, source } of BUNDLED_WORLD_SOURCES) { try { loadAndRegister(source, filename); } catch (error) { showModStatus(`Could not load ${filename}`, error.message); } } }113129 function loadAndRegister(source, filename) { const mod = loadMod(source, { filename }); return catalog.register(mod, { origin: 'bundled' }); }114130 function loadLevel(index) {115131 const list = currentLevels();116- if (typeof index === 'string') index = Math.max(0, list.findIndex(level => level.id === index));117- if (!list[index]) { showModStatus('Level unavailable'); return false; }132+ const selected = catalog.getLevel(index);133+ if (!selected) { showModStatus('Level unavailable'); return false; }134+ const selectedIndex = list.findIndex(level => level.id === selected.id);135+ if (selectedIndex < 0) { showModStatus('Level unavailable'); return false; }118136 presentationClock.clearTimeout(winTimer);119137 feedback = '';120- levelNo = index; catalog.setActiveLevel(list[levelNo].id); game = createGame(list[levelNo]); lastState = game.getState();138+ catalog.setActiveLevel(selected.id);139+ activeLevelId = catalog.activeLevelId;140+ levelNo = selectedIndex; game = createGame(selected); lastState = game.getState();121141 levelTitle.textContent = lastState.title; levelIndex.textContent = `${String(levelNo + 1).padStart(2,'0')} / ${String(list.length).padStart(2,'0')}`;122142 hintCopy.textContent = lastState.hint || 'Find the sentence that changes the proof.';123143 hintPrepared = prepare(hintCopy.textContent, '12px Space Grotesk');
@@ -210,7 +230,7 @@ let winTimer = 0;
210230 function scheduleInitialWin() {211231 presentationClock.clearTimeout(winTimer);212232 if (!game?.getState().won) return;213- const level = currentLevels()[levelNo];233+ const level = activeLevel();214234 const expectedGame = game;215235 const expectedLevelId = level?.id;216236 if (level?.packId && level?.localId) {
@@ -218,7 +238,7 @@ function scheduleInitialWin() {
218238 refreshIndex(level.id);219239 }220240 const promote = () => {221- if (game !== expectedGame || currentLevels()[levelNo]?.id !== expectedLevelId || !game?.getState().won) return;241+ if (game !== expectedGame || catalog.getActiveLevel()?.id !== expectedLevelId || !game?.getState().won) return;222242 if (stage.scene === 'proof') {223243 stage.show('win', { steps: game.getState().moves, nodeId: expectedLevelId, instant: true });224244 return;
@@ -245,7 +265,7 @@ const turns = createTurnActions({
245265 playTurnSounds,246266 announceTurn,247267 onWin: now => {248- const level = currentLevels()[levelNo];268+ const level = activeLevel();249269 if (level?.packId && level?.localId) {250270 progress.markSolved(level.packId, level.localId);251271 refreshIndex(level.id);
@@ -254,7 +274,7 @@ const turns = createTurnActions({
254274 presentationClock.clearTimeout(winTimer);255275 const beatStart = juice.frame(now).win?.beatStart ?? now;256276 winTimer = presentationClock.setTimeout(() => {257- if (game?.getState().won) stage.show('win', { steps: game.getState().moves, nodeId: level?.id });277+ if (game?.getState().won && catalog.getActiveLevel()?.id === level?.id) stage.show('win', { steps: game.getState().moves, nodeId: level?.id });258278 }, Math.max(0, beatStart - now));259279 },260280 onRestart: () => {
M tests/catalog.test.js +21 -0
@@ -111,6 +111,16 @@ test('replaces an imported pack atomically, preserving order and repairing activ
111111 assert.equal(catalog.getLevel('alpha:two'), undefined);112112 });113113 114+test('replacement preserves the active stable ID when its authored level remains', () => {115+ const catalog = createCatalog({ coreLevels: [level('Core', 'intro')] });116+ catalog.register(pack('alpha', 'Original', [level('Original', 'one'), level('Other', 'two')]));117+ catalog.setActiveLevel('alpha:two');118+ catalog.register(pack('alpha', 'Replacement', [level('Updated', 'two'), level('New', 'three')]), { replace: true });119+ assert.equal(catalog.activeLevelId, 'alpha:two');120+ assert.equal(catalog.getActiveLevel().title, 'Updated');121+ assert.equal(catalog.getLevel(2).id, 'alpha:three');122+});123+114124 test('protects the bundled core pack from replacement', () => {115125 const catalog = createCatalog({ coreLevels: [level('Core', 'intro')] });116126 assert.throws(() => catalog.register(pack('core', 'Fake Core', [level('Fake', 'fake')]), { replace: true }), /bundled and cannot be replaced/);
@@ -168,3 +178,14 @@ test('returns cloned levels so callers cannot mutate registry state', () => {
168178 assert.equal(catalog.getLevel('core:intro').title, 'Core');169179 assert.equal(catalog.getLevel('core:intro').objects[0].text, 'MIAU');170180 });181+182+test('pack summaries are invalidated and returned as caller-owned clones', () => {183+ const catalog = createCatalog({ coreLevels: [level('Core', 'intro')] });184+ const first = catalog.getPacks();185+ first[0].manifest.name = 'Changed';186+ catalog.register(pack('alpha', 'Alpha', [level('A', 'a')]));187+ assert.equal(catalog.getPacks()[0].manifest.name, 'Miau Core');188+ catalog.register(pack('alpha', 'Replaced', [level('B', 'b'), level('C', 'c')]), { replace: true });189+ assert.equal(catalog.getPacks().find(pack => pack.id === 'alpha').manifest.name, 'Replaced');190+ assert.equal(catalog.getPacks().find(pack => pack.id === 'alpha').levelCount, 2);191+});