- commit
- 50e4d687990c223d77e5dab9c73834a9d8c861a2
- parent
- ac9625feb2ce38403366f5479e0486833e56723a
- author
- Dami <dami.etoile@gmail.com>
| M | src/ | 81 | +++++++++++++++++++++++------- |
| M | src/ | 42 | +++++++++++---- |
| M | tests/ | 21 | ++++++++ |
@@ -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 @@ -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: () => {@@ -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+});