clone

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

Index world navigation by stable nodes and preserve cursors

Dami committed 7740bcb
commit
7740bcbb1cf861bfe4aa87d6b76c5f6fed3eba75
parent
bded761f404d04948e61ebe6e43a28da29a86118
author
Dami <dami.etoile@gmail.com>
2 files changed, +127 -23
Msrc/scenes/map.js91++++++++++++++++++++++--------
Mtests/map.test.js59+++++++++++++++++++
M src/scenes/map.js +68 -23
@@ -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;
M tests/map.test.js +59 -0
@@ -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')]; },