- commit
- 7740bcbb1cf861bfe4aa87d6b76c5f6fed3eba75
- parent
- bded761f404d04948e61ebe6e43a28da29a86118
- author
- Dami <dami.etoile@gmail.com>
| M | src/ | 91 | ++++++++++++++++++++++-------- |
| M | tests/ | 59 | +++++++++++++++++++ |
@@ -41,10 +41,20 @@ function normalizeEntry(level, fallbackPackId = 'core', fallbackPackName = 'Miau
4141 4242 function packRecords(source, flat) {4343 if (source && typeof source.getPacks === 'function') {44+ const byPack = new Map();45+ for (const level of flat) {46+ const entry = normalizeEntry(level);47+ const entries = byPack.get(entry.packId) || [];48+ entries.push(entry);49+ byPack.set(entry.packId, entries);50+ }4451 return source.getPacks().map((pack, registrationIndex) => ({4552 ...pack,4653 registrationIndex,47- entries: flat.filter(level => level.packId === pack.id).map(level => normalizeEntry(level, pack.id, pack.manifest?.name || pack.name)),54+ entries: (byPack.get(pack.id) || []).map(entry => ({55+ ...entry,56+ packName: entry.packName || pack.manifest?.name || pack.name || pack.id,57+ })),4858 }));4959 }5060 const records = new Map();@@ -122,14 +132,16 @@ function makeSheet(group, sheetEntries, sheetIndex, globalSheetIndex) {
122132 level: cloneData(entry.level),123133 };124134 });135+ const byCell = new Map(nodes.map(node => [`${node.row},${node.column}`, node]));125136 for (const node of nodes) {137+ const at = (row, column) => byCell.get(`${row},${column}`)?.id || null;126138 node.previousId = nodes[node.pathIndex - 1]?.id || null;127139 node.nextId = nodes[node.pathIndex + 1]?.id || null;128140 node.neighbors = {129- left: nodes.find(candidate => candidate.row === node.row && candidate.column === node.column - 1)?.id || null,130- right: nodes.find(candidate => candidate.row === node.row && candidate.column === node.column + 1)?.id || null,131- up: nodes.find(candidate => candidate.row === node.row - 1 && candidate.column === node.column)?.id || null,132- down: nodes.find(candidate => candidate.row === node.row + 1 && candidate.column === node.column)?.id || null,141+ left: at(node.row, node.column - 1),142+ right: at(node.row, node.column + 1),143+ up: at(node.row - 1, node.column),144+ down: at(node.row + 1, node.column),133145 };134146 }135147 return {@@ -163,8 +175,14 @@ export function createIndexLayout(source) {
163175 }164176 }165177 const sheets = sheetSpecs.map((spec, globalSheetIndex) => makeSheet(spec.group, spec.sheetEntries, spec.sheetIndex, globalSheetIndex));178+ const sheetsByWorld = new Map();179+ for (const sheet of sheets) {180+ const own = sheetsByWorld.get(sheet.worldId) || [];181+ own.push(sheet);182+ sheetsByWorld.set(sheet.worldId, own);183+ }166184 for (const group of groups) {167- const own = sheets.filter(sheet => sheet.worldId === group.id);185+ const own = sheetsByWorld.get(group.id) || [];168186 group.sheets = own.map(sheet => sheet.id);169187 for (let index = 0; index < own.length; index++) {170188 const sheet = own[index];@@ -189,8 +207,9 @@ export function createIndexLayout(source) {
189207 nextWorldId: groups[index + 1]?.id || null,190208 sheets: [...group.sheets],191209 }));210+ const worldIndex = new Map(worlds.map((world, index) => [world.id, index]));192211 for (const sheet of sheets) {193- const index = worlds.findIndex(world => world.id === sheet.worldId);212+ const index = worldIndex.get(sheet.worldId);194213 sheet.previousWorldId = worlds[index - 1]?.id || null;195214 sheet.nextWorldId = worlds[index + 1]?.id || null;196215 }@@ -208,11 +227,18 @@ function withLookupHelpers(layout) {
208227 const byWorld = new Map(layout.worlds.map(world => [world.id, world]));209228 const bySheet = new Map(layout.sheets.map(sheet => [sheet.id, sheet]));210229 const byNode = new Map(layout.nodes.map(node => [node.id, node]));230+ const byWorldNodes = new Map();231+ for (const node of layout.nodes) {232+ const nodes = byWorldNodes.get(node.worldId) || [];233+ nodes.push(node);234+ byWorldNodes.set(node.worldId, nodes);235+ }211236 return {212237 ...layout,213238 getWorld(worldId) { return byWorld.get(worldId) || null; },214239 getSheet(sheetId) { return bySheet.get(sheetId) || null; },215240 getNode(nodeId) { return byNode.get(nodeId) || null; },241+ getWorldNodes(worldId) { return byWorldNodes.get(worldId) || []; },216242 };217243 }218244 @@ -251,7 +277,7 @@ export function decorateIndex(layout, progress) {
251277 }252278 253279 function findNode(layout, nodeId) {254- return layout.nodes.find(node => node.id === nodeId) || null;280+ return layout.getNode?.(nodeId) || layout.nodes.find(node => node.id === nodeId) || null;255281 }256282 257283 function candidateFor(node, direction, layout) {@@ -261,16 +287,16 @@ function candidateFor(node, direction, layout) {
261287 if (direction === 'next') {262288 const local = findNode(layout, node.nextId);263289 if (local) return local;264- const world = layout.worlds.find(item => item.id === node.worldId);290+ const world = layout.getWorld?.(node.worldId) || layout.worlds.find(item => item.id === node.worldId);265291 const nextWorld = world && layout.worlds[world.number];266- return nextWorld ? layout.nodes.find(item => item.worldId === nextWorld.id) || null : null;292+ return nextWorld ? layout.getWorldNodes?.(nextWorld.id)?.[0] || layout.nodes.find(item => item.worldId === nextWorld.id) || null : null;267293 }268294 if (direction === 'previous') {269295 const local = findNode(layout, node.previousId);270296 if (local) return local;271- const world = layout.worlds.find(item => item.id === node.worldId);297+ const world = layout.getWorld?.(node.worldId) || layout.worlds.find(item => item.id === node.worldId);272298 const previousWorld = world && layout.worlds[world.number - 2];273- const candidates = previousWorld ? layout.nodes.filter(item => item.worldId === previousWorld.id) : [];299+ const candidates = previousWorld ? layout.getWorldNodes?.(previousWorld.id) || layout.nodes.filter(item => item.worldId === previousWorld.id) : [];274300 return candidates.at(-1) || null;275301 }276302 const deltas = { left: [-1, 0], right: [1, 0], up: [0, -1], down: [0, 1] };@@ -287,7 +313,8 @@ function candidateFor(node, direction, layout) {
287313 // node can page back with left, and the final node can page forward in288314 // the direction its serpentine row is travelling.289315 const isFirst = node.pathIndex === 0 && adjacentId === node.previousId;290- const isLast = adjacentId === node.nextId && node.nextId && !layout.nodes.some(other => other.sheetId === node.sheetId && other.pathIndex > node.pathIndex);316+ const sheet = layout.getSheet?.(node.sheetId) || layout.sheets.find(item => item.id === node.sheetId);317+ const isLast = adjacentId === node.nextId && node.nextId && sheet?.nodes.at(-1)?.id === node.id;291318 const forward = node.row % 2 === 0 ? 'right' : 'left';292319 if ((isFirst && direction === 'left') || (isLast && direction === forward)) return candidate;293320 continue;@@ -295,18 +322,18 @@ function candidateFor(node, direction, layout) {
295322 if (candidate.column - node.column === delta[0]296323 && candidate.row - node.row === delta[1]) return candidate;297324 }298- const world = layout.worlds.find(item => item.id === node.worldId);325+ const world = layout.getWorld?.(node.worldId) || layout.worlds.find(item => item.id === node.worldId);299326 const worldIndex = world ? world.number - 1 : -1;300- const first = world && layout.nodes.find(item => item.worldId === world.id);301- const worldNodes = world ? layout.nodes.filter(item => item.worldId === world.id) : [];327+ const worldNodes = world ? layout.getWorldNodes?.(world.id) || layout.nodes.filter(item => item.worldId === world.id) : [];328+ const first = worldNodes[0];302329 const last = worldNodes.at(-1);303330 if (direction === 'left' && node.id === first?.id && worldIndex > 0) {304- const previousNodes = layout.nodes.filter(item => item.worldId === layout.worlds[worldIndex - 1].id);331+ const previousNodes = layout.getWorldNodes?.(layout.worlds[worldIndex - 1].id) || layout.nodes.filter(item => item.worldId === layout.worlds[worldIndex - 1].id);305332 return previousNodes.at(-1) || null;306333 }307334 const forward = node.row % 2 === 0 ? 'right' : 'left';308335 if (direction === forward && node.id === last?.id && worldIndex + 1 < layout.worlds.length) {309- return layout.nodes.find(item => item.worldId === layout.worlds[worldIndex + 1].id) || null;336+ return layout.getWorldNodes?.(layout.worlds[worldIndex + 1].id)?.[0] || layout.nodes.find(item => item.worldId === layout.worlds[worldIndex + 1].id) || null;310337 }311338 return null;312339 }@@ -317,7 +344,7 @@ function candidateFor(node, direction, layout) {
317344 * proof in that direction. Entering a locked node always returns a reason.318345 */319346 export function createIndexNavigator(layout, options = {}) {320- let currentId = options.startNodeId || layout.nodes[0]?.id || null;347+ let currentId = findNode(layout, options.startNodeId)?.id || layout.nodes[0]?.id || null;321348 let currentSheetId = options.startSheetId || findNode(layout, currentId)?.sheetId || layout.sheets[0]?.id || null;322349 const mode = options.locked || 'indicate';323350 const cursorByWorld = new Map();@@ -325,11 +352,11 @@ export function createIndexNavigator(layout, options = {}) {
325352 function current() { return findNode(layout, currentId); }326353 function state() {327354 const node = current();328- const world = node ? layout.worlds.find(item => item.id === node.worldId) || null : null;355+ const world = node ? layout.getWorld?.(node.worldId) || layout.worlds.find(item => item.id === node.worldId) || null : null;329356 return {330357 node,331358 nodeId: node?.id || null,332- sheet: layout.sheets.find(sheet => sheet.id === currentSheetId) || null,359+ sheet: layout.getSheet?.(currentSheetId) || layout.sheets.find(sheet => sheet.id === currentSheetId) || null,333360 sheetId: currentSheetId,334361 world,335362 worldId: world?.id || null,@@ -339,13 +366,14 @@ export function createIndexNavigator(layout, options = {}) {
339366 function moveWorld(delta) {340367 const before = current();341368 if (!before || !Number.isInteger(delta) || delta === 0) return { moved: false, boundary: true, node: before, from: before };342- const worldIndex = layout.worlds.findIndex(world => world.id === before.worldId);369+ const worldNumber = layout.getWorld?.(before.worldId)?.number;370+ const worldIndex = Number.isInteger(worldNumber) ? worldNumber - 1 : -1;343371 const targetIndex = worldIndex + Math.sign(delta);344372 const target = layout.worlds[targetIndex];345373 if (!target) return { moved: false, boundary: true, node: before, from: before, reason: 'boundary' };346374 cursorByWorld.set(before.worldId, before.id);347375 const remembered = findNode(layout, cursorByWorld.get(target.id));348- const targetNodes = layout.nodes.filter(node => node.worldId === target.id);376+ const targetNodes = layout.getWorldNodes?.(target.id) || layout.nodes.filter(node => node.worldId === target.id);349377 const destination = remembered || targetNodes.find(node => !node.locked) || targetNodes[0] || null;350378 if (!destination) return { moved: false, boundary: true, node: before, from: before, reason: 'empty-world' };351379 currentId = destination.id;@@ -376,11 +404,28 @@ export function createIndexNavigator(layout, options = {}) {
376404 if (node.locked) return { ok: false, reason: 'locked', node };377405 return { ok: true, packId: node.packId, levelId: node.levelId, level: cloneData(node.level), node };378406 }407+ function restoreCursorSnapshot(snapshot) {408+ const entries = snapshot instanceof Map409+ ? [...snapshot.entries()]410+ : snapshot && typeof snapshot === 'object' ? Object.entries(snapshot) : [];411+ cursorByWorld.clear();412+ for (const [worldId, nodeId] of entries) {413+ const node = findNode(layout, nodeId);414+ if (node?.worldId === worldId) cursorByWorld.set(worldId, node.id);415+ }416+ return cursorSnapshot();417+ }418+ function cursorSnapshot() {419+ return Object.fromEntries(cursorByWorld);420+ }421+ if (options.cursorByWorld) restoreCursorSnapshot(options.cursorByWorld);379422 return {380423 get current() { return state(); },381424 move,382425 moveWorld,383426 enter,427+ cursorSnapshot,428+ restoreCursorSnapshot,384429 setNode(nodeId) {385430 const node = findNode(layout, nodeId);386431 if (!node) return false;@@ -73,6 +73,28 @@ test('bundled proofs continue after fifteen without merging imported packs', ()
7373 ]);7474 });7575 76+test('indexed layout keeps neighbor and world lookups stable across large sheets', () => {77+ const levels = [78+ ...Array.from({ length: 47 }, (_, index) => level('core', `core-${index + 1}`)),79+ ...Array.from({ length: 31 }, (_, index) => level('tidewater', `tide-${index + 1}`)),80+ ];81+ const layout = createIndexLayout(levels);82+ assert.equal(layout.sheets.length, 7);83+ assert.equal(layout.getWorldNodes('core').length, 47);84+ assert.equal(layout.getWorldNodes('tidewater').length, 31);85+ for (const sheet of layout.sheets) {86+ for (const node of sheet.nodes) {87+ const adjacent = (row, column) => sheet.nodes.find(candidate => candidate.row === row && candidate.column === column)?.id || null;88+ assert.deepEqual(node.neighbors, {89+ left: adjacent(node.row, node.column - 1),90+ right: adjacent(node.row, node.column + 1),91+ up: adjacent(node.row - 1, node.column),92+ down: adjacent(node.row + 1, node.column),93+ });94+ }95+ }96+});97+7698 test('progress uses stable pack/level keys and locks the next proof', () => {7799 const storage = new Map();78100 const fakeStorage = {@@ -193,6 +215,43 @@ test('world navigator switches worlds without wraparound and remembers cursors',
193215 assert.equal(navigator.moveWorld(-1).node.id, 'core:one');194216 });195217 218+test('world cursor snapshots survive index reconstruction and ignore stale IDs', () => {219+ const layout = decorateIndex(createIndexLayout([220+ level('core', 'one'), level('core', 'two'),221+ level('garden-basics', 'one'), level('garden-basics', 'two'),222+ ]), { isSolved: () => true });223+ const navigator = createIndexNavigator(layout, { startNodeId: 'core:one' });224+ navigator.moveWorld(1);225+ navigator.move('next');226+ navigator.moveWorld(-1);227+ const snapshot = navigator.cursorSnapshot();228+ assert.deepEqual(snapshot, { core: 'core:one', 'garden-basics': 'garden-basics:two' });229+230+ const rebuilt = decorateIndex(createIndexLayout([231+ level('core', 'one'), level('core', 'two'),232+ level('garden-basics', 'one'), level('garden-basics', 'two'),233+ ]), { isSolved: () => true });234+ const restored = createIndexNavigator(rebuilt, {235+ startNodeId: 'core:one',236+ cursorByWorld: { ...snapshot, removed: 'removed:proof' },237+ });238+ assert.deepEqual(restored.cursorSnapshot(), snapshot);239+ assert.equal(restored.moveWorld(1).node.id, 'garden-basics:two');240+ assert.deepEqual(restored.restoreCursorSnapshot({ removed: 'gone', 'garden-basics': 'garden-basics:one' }), {241+ 'garden-basics': 'garden-basics:one',242+ });243+});244+245+test('removing the active imported world repairs the INDEX cursor', () => {246+ const layout = decorateIndex(createIndexLayout([level('core', 'one')]), null);247+ const navigator = createIndexNavigator(layout, {248+ startNodeId: 'removed:proof',249+ cursorByWorld: { removed: 'removed:proof' },250+ });251+ assert.equal(navigator.current.node.id, 'core:one');252+ assert.deepEqual(navigator.cursorSnapshot(), {});253+});254+196255 test('catalog pack metadata becomes world metadata and completion is scoped', () => {197256 const catalogLike = {198257 getLevels() { return [level('core', 'one'), level('tidewater', 'one')]; },