clone

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

model world-owned index navigation

Dami committed a939279
commit
a9392796260aac77a1e871ec1a77bbd6d76e9faf
parent
6d62db861631f8a63c1d05e26d94c58a88d625c3
author
Dami <dami.etoile@gmail.com>
2 files changed, +210 -47
Msrc/scenes/map.js200++++++++++++++++++++++++------
Mtests/map.test.js57+++++++-
M src/scenes/map.js +161 -39
@@ -39,30 +39,57 @@ function normalizeEntry(level, fallbackPackId = 'core', fallbackPackName = 'Miau
3939 };4040 }4141 42-const BUNDLED_PACK_IDS = new Set(['core', 'garden-basics', 'word-lab']);43-function isBundledEntry(level) {44- return level.origin === undefined45- ? BUNDLED_PACK_IDS.has(level.packId)46- : level.origin === 'bundled';47-}48-49-function groupedEntries(source) {50- // Catalogs expose a flat, ordered list. Bundled proofs share one campaign51- // sheet; imported packs each get their own sheet range.52- const flat = levelEntries(source);53- const groups = new Map();42+function packRecords(source, flat) {43+ if (source && typeof source.getPacks === 'function') {44+ return source.getPacks().map((pack, registrationIndex) => ({45+ ...pack,46+ registrationIndex,47+ entries: flat.filter(level => level.packId === pack.id).map(level => normalizeEntry(level, pack.id, pack.manifest?.name || pack.name)),48+ }));49+ }50+ const records = new Map();5451 for (const level of flat) {5552 const entry = normalizeEntry(level);56- // Keep historical core sheet IDs stable while grouping all bundled packs.57- const groupId = isBundledEntry(level) ? 'core' : entry.packId;58- if (!groups.has(groupId)) groups.set(groupId, {59- id: groupId,60- name: groupId === 'core' && isBundledEntry(level) ? 'Proofs' : entry.packName,53+ if (!records.has(entry.packId)) records.set(entry.packId, {54+ id: entry.packId,55+ origin: level.origin || 'local',56+ registrationIndex: records.size,57+ manifest: { name: entry.packName },6158 entries: [],6259 });63- groups.get(groupId).entries.push(entry);60+ records.get(entry.packId).entries.push(entry);6461 }65- return [...groups.values()];62+ return [...records.values()];63+}64+65+function worldMetadata(record, entries, registrationIndex) {66+ const manifest = record.manifest || {};67+ const world = manifest.world || record.world || {};68+ const first = entries[0];69+ return {70+ id: record.id,71+ number: registrationIndex + 1,72+ worldId: record.id,73+ packId: record.id,74+ name: manifest.name || record.name || first?.packName || record.id,75+ author: manifest.author || record.author || null,76+ glyph: world.glyph || null,77+ tagline: world.tagline || null,78+ accent: world.accent || null,79+ origin: record.origin || 'local',80+ registrationIndex,81+ sheets: [],82+ levelCount: entries.length,83+ completedCount: 0,84+ };85+}86+87+function groupedEntries(source) {88+ const flat = levelEntries(source);89+ return packRecords(source, flat).map((record, registrationIndex) => {90+ const entries = record.entries || [];91+ return { ...worldMetadata(record, entries, registrationIndex), entries };92+ });6693 }6794 6895 function nodeCoordinate(pathIndex) {
@@ -71,13 +98,14 @@ function nodeCoordinate(pathIndex) {
7198 return { row, column: row % 2 === 0 ? offset : INDEX_COLUMNS - 1 - offset };7299 }73100 74-function makeSheet(group, sheetEntries, sheetIndex, globalSheetIndex, allSheetIds) {101+function makeSheet(group, sheetEntries, sheetIndex, globalSheetIndex) {75102 const sheetId = `${group.id}:sheet-${sheetIndex + 1}`;76103 const nodes = sheetEntries.map((entry, pathIndex) => {77104 const coordinate = nodeCoordinate(pathIndex);78105 return {79106 id: entry.id,80107 packId: entry.packId,108+ worldId: group.id,81109 levelId: entry.levelId,82110 packName: entry.packName,83111 title: entry.title,
@@ -107,21 +135,24 @@ function makeSheet(group, sheetEntries, sheetIndex, globalSheetIndex, allSheetId
107135 return {108136 id: sheetId,109137 packId: group.id,138+ worldId: group.id,110139 packName: group.name,111140 sheetIndex,112141 globalSheetIndex,113142 columns: INDEX_COLUMNS,114143 rows: INDEX_ROWS,115144 nodes,116- previousSheetId: allSheetIds[globalSheetIndex - 1] || null,117- nextSheetId: allSheetIds[globalSheetIndex + 1] || null,145+ previousSheetId: null,146+ nextSheetId: null,147+ previousWorldId: null,148+ nextWorldId: null,118149 };119150 }120151 121152 /**122- * Lay out catalog levels in pack order. Bundled proofs share one sheet range;123- * imported packs start their own range, and any range over fifteen proofs124- * continues onto another sheet.153+ * Lay out catalog levels in registration order. Every pack owns a world and154+ * its own sheet range; any range over fifteen proofs continues within that155+ * same world.125156 */126157 export function createIndexLayout(source) {127158 const groups = groupedEntries(source);
@@ -131,18 +162,50 @@ export function createIndexLayout(source) {
131162 sheetSpecs.push({ group, sheetEntries: group.entries.slice(offset, offset + INDEX_NODES_PER_SHEET), sheetIndex: Math.floor(offset / INDEX_NODES_PER_SHEET) });132163 }133164 }134- const ids = sheetSpecs.map(({ group, sheetIndex }) => `${group.id}:sheet-${sheetIndex + 1}`);135- const sheets = sheetSpecs.map((spec, globalSheetIndex) => makeSheet(spec.group, spec.sheetEntries, spec.sheetIndex, globalSheetIndex, ids));136- // The path leaves one sheet at its edge and enters the next sheet. The137- // renderer can use the sheet ids to start its camera slide at this link.138- for (let index = 0; index < sheets.length - 1; index++) {139- const last = sheets[index].nodes.at(-1);140- const first = sheets[index + 1].nodes[0];141- last.nextId = first.id;142- first.previousId = last.id;165+ const sheets = sheetSpecs.map((spec, globalSheetIndex) => makeSheet(spec.group, spec.sheetEntries, spec.sheetIndex, globalSheetIndex));166+ for (const group of groups) {167+ const own = sheets.filter(sheet => sheet.worldId === group.id);168+ group.sheets = own.map(sheet => sheet.id);169+ for (let index = 0; index < own.length; index++) {170+ const sheet = own[index];171+ sheet.previousSheetId = own[index - 1]?.id || null;172+ sheet.nextSheetId = own[index + 1]?.id || null;173+ const previousSheet = own[index - 1];174+ const nextSheet = own[index + 1];175+ if (previousSheet?.nodes.length && sheet.nodes.length) {176+ sheet.nodes[0].previousId = previousSheet.nodes.at(-1).id;177+ previousSheet.nodes.at(-1).nextId = sheet.nodes[0].id;178+ }179+ if (nextSheet?.nodes.length && sheet.nodes.length) {180+ sheet.nodes.at(-1).nextId = nextSheet.nodes[0].id;181+ nextSheet.nodes[0].previousId = sheet.nodes.at(-1).id;182+ }183+ }184+ }185+ const worlds = groups.map((group, index) => ({186+ ...group,187+ number: index + 1,188+ previousWorldId: groups[index - 1]?.id || null,189+ nextWorldId: groups[index + 1]?.id || null,190+ sheets: [...group.sheets],191+ }));192+ for (const sheet of sheets) {193+ const index = worlds.findIndex(world => world.id === sheet.worldId);194+ sheet.previousWorldId = worlds[index - 1]?.id || null;195+ sheet.nextWorldId = worlds[index + 1]?.id || null;143196 }144197 const nodes = sheets.flatMap(sheet => sheet.nodes);145- return { columns: INDEX_COLUMNS, rows: INDEX_ROWS, sheets, nodes };198+ const byWorld = new Map(worlds.map(world => [world.id, world]));199+ return {200+ columns: INDEX_COLUMNS,201+ rows: INDEX_ROWS,202+ worlds,203+ sheets,204+ nodes,205+ getWorld(worldId) { return byWorld.get(worldId) || null; },206+ getSheet(sheetId) { return sheets.find(sheet => sheet.id === sheetId) || null; },207+ getNode(nodeId) { return findNode(this, nodeId); },208+ };146209 }147210 148211 function solvedLookup(progress, node) {
@@ -156,12 +219,14 @@ function solvedLookup(progress, node) {
156219 /** Add solved/locked state without mutating the layout or progress store. */157220 export function decorateIndex(layout, progress) {158221 const priorByPack = new Map();222+ const completedByWorld = new Map();159223 const sheets = layout.sheets.map(sheet => {160224 const nodes = sheet.nodes.map(node => ({ ...node, neighbors: { ...node.neighbors } }));161225 return {162226 ...sheet,163227 nodes: nodes.map(node => {164228 const solved = solvedLookup(progress, node);229+ if (solved) completedByWorld.set(node.worldId, (completedByWorld.get(node.worldId) || 0) + 1);165230 const prior = priorByPack.get(node.packId);166231 const locked = !solved && Boolean(prior) && !prior.solved;167232 const result = { ...node, solved, locked, state: solved ? 'solved' : (locked ? 'locked' : 'available') };
@@ -170,7 +235,11 @@ export function decorateIndex(layout, progress) {
170235 }),171236 };172237 });173- return { ...layout, sheets, nodes: sheets.flatMap(sheet => sheet.nodes) };238+ const worlds = layout.worlds.map(world => ({239+ ...world,240+ completedCount: completedByWorld.get(world.id) || 0,241+ }));242+ return { ...layout, worlds, sheets, nodes: sheets.flatMap(sheet => sheet.nodes) };174243 }175244 176245 function findNode(layout, nodeId) {
@@ -181,8 +250,21 @@ function candidateFor(node, direction, layout) {
181250 // Arrow and gamepad directions are spatial. `next` and `previous` remain182251 // explicit path traversal for continue/campaign callers, including sheet183252 // boundaries where no visible neighbor exists.184- if (direction === 'next') return findNode(layout, node.nextId);185- if (direction === 'previous') return findNode(layout, node.previousId);253+ if (direction === 'next') {254+ const local = findNode(layout, node.nextId);255+ if (local) return local;256+ const world = layout.worlds.find(item => item.id === node.worldId);257+ const nextWorld = world && layout.worlds[world.number];258+ return nextWorld ? layout.nodes.find(item => item.worldId === nextWorld.id) || null : null;259+ }260+ if (direction === 'previous') {261+ const local = findNode(layout, node.previousId);262+ if (local) return local;263+ const world = layout.worlds.find(item => item.id === node.worldId);264+ const previousWorld = world && layout.worlds[world.number - 2];265+ const candidates = previousWorld ? layout.nodes.filter(item => item.worldId === previousWorld.id) : [];266+ return candidates.at(-1) || null;267+ }186268 const deltas = { left: [-1, 0], right: [1, 0], up: [0, -1], down: [0, 1] };187269 const delta = deltas[direction];188270 if (!delta) return null;
@@ -205,6 +287,19 @@ function candidateFor(node, direction, layout) {
205287 if (candidate.column - node.column === delta[0]206288 && candidate.row - node.row === delta[1]) return candidate;207289 }290+ const world = layout.worlds.find(item => item.id === node.worldId);291+ const worldIndex = world ? world.number - 1 : -1;292+ const first = world && layout.nodes.find(item => item.worldId === world.id);293+ const worldNodes = world ? layout.nodes.filter(item => item.worldId === world.id) : [];294+ const last = worldNodes.at(-1);295+ if (direction === 'left' && node.id === first?.id && worldIndex > 0) {296+ const previousNodes = layout.nodes.filter(item => item.worldId === layout.worlds[worldIndex - 1].id);297+ return previousNodes.at(-1) || null;298+ }299+ const forward = node.row % 2 === 0 ? 'right' : 'left';300+ if (direction === forward && node.id === last?.id && worldIndex + 1 < layout.worlds.length) {301+ return layout.nodes.find(item => item.worldId === layout.worlds[worldIndex + 1].id) || null;302+ }208303 return null;209304 }210305
@@ -217,11 +312,37 @@ export function createIndexNavigator(layout, options = {}) {
217312 let currentId = options.startNodeId || layout.nodes[0]?.id || null;218313 let currentSheetId = options.startSheetId || findNode(layout, currentId)?.sheetId || layout.sheets[0]?.id || null;219314 const mode = options.locked || 'indicate';315+ const cursorByWorld = new Map();220316 221317 function current() { return findNode(layout, currentId); }222318 function state() {223319 const node = current();224- return { node, sheet: layout.sheets.find(sheet => sheet.id === currentSheetId) || null };320+ const world = node ? layout.worlds.find(item => item.id === node.worldId) || null : null;321+ return {322+ node,323+ nodeId: node?.id || null,324+ sheet: layout.sheets.find(sheet => sheet.id === currentSheetId) || null,325+ sheetId: currentSheetId,326+ world,327+ worldId: world?.id || null,328+ worldIndex: world ? world.number - 1 : -1,329+ };330+ }331+ function moveWorld(delta) {332+ const before = current();333+ if (!before || !Number.isInteger(delta) || delta === 0) return { moved: false, boundary: true, node: before, from: before };334+ const worldIndex = layout.worlds.findIndex(world => world.id === before.worldId);335+ const targetIndex = worldIndex + Math.sign(delta);336+ const target = layout.worlds[targetIndex];337+ if (!target) return { moved: false, boundary: true, node: before, from: before, reason: 'boundary' };338+ cursorByWorld.set(before.worldId, before.id);339+ const remembered = findNode(layout, cursorByWorld.get(target.id));340+ const targetNodes = layout.nodes.filter(node => node.worldId === target.id);341+ const destination = remembered || targetNodes.find(node => !node.locked) || targetNodes[0] || null;342+ if (!destination) return { moved: false, boundary: true, node: before, from: before, reason: 'empty-world' };343+ currentId = destination.id;344+ currentSheetId = destination.sheetId;345+ return { moved: true, boundary: false, world: target, node: destination, from: before };225346 }226347 function move(direction, moveOptions = {}) {227348 const before = current();
@@ -250,6 +371,7 @@ export function createIndexNavigator(layout, options = {}) {
250371 return {251372 get current() { return state(); },252373 move,374+ moveWorld,253375 enter,254376 setNode(nodeId) {255377 const node = findNode(layout, nodeId);
M tests/map.test.js +49 -8
@@ -38,7 +38,7 @@ test('INDEX auto layout is serpentine and continues across sheets', () => {
3838 assert.equal(layout.nodes.at(-1).id, 'word-lab:first');3939 });4040 41-test('bundled core packs share one sheet while imported packs start new sheets', () => {41+test('every pack owns its sheet range in registration order', () => {4242 const levels = [4343 ...Array.from({ length: 3 }, (_, index) => level('core', `core-${index + 1}`)),4444 ...Array.from({ length: 3 }, (_, index) => level('garden-basics', `garden-${index + 1}`, undefined, 'bundled')),
@@ -46,15 +46,20 @@ test('bundled core packs share one sheet while imported packs start new sheets',
4646 level('imported-pack', 'intro'),4747 ];4848 const layout = createIndexLayout(levels);49- assert.equal(layout.sheets.length, 2);50- assert.equal(layout.sheets[0].nodes.length, 9);49+ assert.equal(layout.sheets.length, 4);50+ assert.equal(layout.sheets[0].nodes.length, 3);5151 assert.equal(layout.sheets[0].id, 'core:sheet-1');52- assert.deepEqual(layout.sheets[0].nodes.map(node => node.packId), [53- 'core', 'core', 'core', 'garden-basics', 'garden-basics', 'garden-basics', 'word-lab', 'word-lab', 'word-lab',52+ assert.deepEqual(layout.sheets.map(sheet => [sheet.id, sheet.nodes.length]), [53+ ['core:sheet-1', 3], ['garden-basics:sheet-1', 3], ['word-lab:sheet-1', 3], ['imported-pack:sheet-1', 1],5454 ]);55- assert.equal(layout.sheets[1].packId, 'imported-pack');56- assert.equal(layout.sheets[0].nextSheetId, 'imported-pack:sheet-1');57- assert.equal(layout.sheets[1].previousSheetId, 'core:sheet-1');55+ assert.deepEqual(layout.worlds.map(world => [world.number, world.id, world.sheets]), [56+ [1, 'core', ['core:sheet-1']],57+ [2, 'garden-basics', ['garden-basics:sheet-1']],58+ [3, 'word-lab', ['word-lab:sheet-1']],59+ [4, 'imported-pack', ['imported-pack:sheet-1']],60+ ]);61+ assert.equal(layout.sheets[0].nextSheetId, null);62+ assert.equal(layout.sheets[0].nextWorldId, 'garden-basics');5863 });5964 6065 test('bundled proofs continue after fifteen without merging imported packs', () => {
@@ -169,3 +174,39 @@ test('arrow directions page across sheet and imported-pack boundaries', () => {
169174 assert.equal(result.moved, true);170175 assert.equal(result.node.id, 'core:proof-16');171176 });177+178+test('world navigator switches worlds without wraparound and remembers cursors', () => {179+ const layout = decorateIndex(createIndexLayout([180+ level('core', 'one'), level('core', 'two'),181+ level('garden-basics', 'one'), level('garden-basics', 'two'),182+ level('word-lab', 'one'),183+ ]), { isSolved: () => true });184+ const navigator = createIndexNavigator(layout, { startNodeId: 'core:one' });185+ assert.equal(navigator.current.worldId, 'core');186+ assert.equal(navigator.moveWorld(-1).moved, false);187+ assert.equal(navigator.moveWorld(1).node.id, 'garden-basics:one');188+ navigator.move('next');189+ assert.equal(navigator.current.node.id, 'garden-basics:two');190+ assert.equal(navigator.moveWorld(1).node.id, 'word-lab:one');191+ assert.equal(navigator.moveWorld(1).moved, false);192+ assert.equal(navigator.moveWorld(-1).node.id, 'garden-basics:two');193+ assert.equal(navigator.moveWorld(-1).node.id, 'core:one');194+});195+196+test('catalog pack metadata becomes world metadata and completion is scoped', () => {197+ const catalogLike = {198+ getLevels() { return [level('core', 'one'), level('tidewater', 'one')]; },199+ getPacks() {200+ return [201+ { id: 'core', origin: 'bundled', manifest: { name: 'Miau Core', author: 'team', world: { glyph: '🐈', tagline: 'Core proofs', accent: 'you' } } },202+ { id: 'tidewater', origin: 'bundled', manifest: { name: 'Tidewater', world: { glyph: '🌊', accent: 'sink' } } },203+ ];204+ },205+ };206+ const layout = createIndexLayout(catalogLike);207+ assert.deepEqual(layout.worlds.map(world => [world.id, world.number, world.glyph, world.tagline]), [208+ ['core', 1, '🐈', 'Core proofs'], ['tidewater', 2, '🌊', null],209+ ]);210+ const decorated = decorateIndex(layout, { isSolved(packId) { return packId === 'tidewater'; } });211+ assert.deepEqual(decorated.worlds.map(world => world.completedCount), [0, 1]);212+});