import { WORLD_MIN_Y, WORLD_MAX_Y } from './view-distance.js'; const positionKey = (pos) => pos.join(","); const integerPosition = (pos) => Array.isArray(pos) && pos.length === 3 && pos.every(Number.isSafeInteger); const samePosition = (a, b) => integerPosition(a) && integerPosition(b) && a.every((value, i) => value === b[i]); const sectionOf = (pos) => pos.map((value) => Math.floor(value / 16)); export function getViewBounds(message) { const center = message.view_center || message.spawn?.map((value) => Math.floor(value / 16) * 16); if (!integerPosition(center)) return null; if (integerPosition(message.view_min) && integerPosition(message.view_max)) return { min: [...message.view_min], max: [...message.view_max], ...(message.full_height === true && message.view_min[1] === WORLD_MIN_Y && message.view_max[1] === WORLD_MAX_Y ? {fullHeight:true} : {}) }; // Protocol 1 servers without chunk_stream_v1 use an unaligned vertical view. return { min: center.map((value, i) => value - (i === 1 ? 8 : 32)), max: center.map((value) => value + 31), }; } export function positionInView(pos, bounds) { return ( !bounds || pos.every((value, i) => value >= bounds.min[i] && value <= bounds.max[i]) ); } function recordMap(records) { const result = new Map(); for (const record of records) { if ( !integerPosition(record?.pos) || !Number.isSafeInteger(record.block) || record.block < 0 ) throw Error("Invalid block record"); if (record.block) result.set(positionKey(record.pos), record.block); } return result; } /** Update the existing map so unchanged blocks never trigger mesh rebuilds. */ export function mergeSnapshot(blocks, records) { const next = recordMap(records), changes = []; for (const [id] of blocks) { if (!next.has(id)) { blocks.delete(id); changes.push({ pos: id.split(",").map(Number), block: 0 }); } } for (const [id, block] of next) { if (blocks.get(id) !== block) { blocks.set(id, block); changes.push({ pos: id.split(",").map(Number), block }); } } return changes; } function sectionWindow(center) { const c = sectionOf(center), sections = new Set(); for (let x = c[0] - 2; x <= c[0] + 1; x++) for (let y = c[1] - 1; y <= c[1] + 1; y++) for (let z = c[2] - 2; z <= c[2] + 1; z++) sections.add(positionKey([x, y, z])); return sections; } function alignedCenter(center) { return integerPosition(center) && center.every((value) => value % 16 === 0); } function* entriesInSections(blocks, sections) { if (typeof blocks.keysInSection === "function") { for (const section of sections) for (const key of blocks.keysInSection(section)) yield [key, blocks.get(key), section]; } else { for (const [key, block] of blocks) { const section = positionKey(sectionOf(key.split(",").map(Number))); if (sections.has(section)) yield [key, block, section]; } } } /** * Validate the complete transition before mutating blocks or view metadata. * Consumers handling section unloads separately can omit their block-zero * records; the departing blocks are still removed from the map. */ export function mergeChunkUpdate( view, message, { includeUnloadedChanges = true } = {}, ) { const invalid = { status: "resync", changes: [] }; if (message.world !== view.world) return { status: "ignored", changes: [] }; if ( !Number.isSafeInteger(message.revision) || message.revision !== view.revision || !alignedCenter(view.viewCenter) || !samePosition(message.from_center, view.viewCenter) || !alignedCenter(message.view_center) || !samePosition( message.view_min, message.view_center.map((value, i) => value - (i === 1 ? 16 : 32)), ) || !samePosition( message.view_max, message.view_center.map((value) => value + 31), ) || !Array.isArray(message.unload) || !Array.isArray(message.sections) ) return invalid; const previous = sectionWindow(view.viewCenter), next = sectionWindow(message.view_center), departing = new Set([...previous].filter((id) => !next.has(id))), entering = new Set([...next].filter((id) => !previous.has(id))), unloaded = new Set(), received = new Set(), replacement = new Map(); for (const pos of message.unload) { if (!integerPosition(pos)) return invalid; const id = positionKey(pos); if (!departing.has(id) || unloaded.has(id)) return invalid; unloaded.add(id); } for (const section of message.sections) { if (!integerPosition(section?.section) || !Array.isArray(section.blocks)) return invalid; const id = positionKey(section.section), [sx, sy, sz] = section.section; if (!entering.has(id) || received.has(id)) return invalid; received.add(id); for (const record of section.blocks) { if ( !integerPosition(record?.pos) || Math.floor(record.pos[0] / 16) !== sx || Math.floor(record.pos[1] / 16) !== sy || Math.floor(record.pos[2] / 16) !== sz || !Number.isSafeInteger(record.block) || record.block < 0 ) return invalid; const key = positionKey(record.pos); if (replacement.has(key)) return invalid; replacement.set(key, { pos: record.pos, block: record.block, section: id, }); } } if (unloaded.size !== departing.size || received.size !== entering.size) return invalid; const changes = [], blocks = view.blocks, bulkUnload = typeof blocks.keysInSection === "function" && typeof blocks.deleteSection === "function"; if (bulkUnload) { for (const section of departing) { if (includeUnloadedChanges) for (const key of blocks.keysInSection(section)) if (blocks.get(key) !== 0) changes.push({ pos: key.split(",").map(Number), block: 0 }); blocks.deleteSection(section); } } for (const [id, block, section] of entriesInSections( blocks, bulkUnload ? entering : new Set([...departing, ...entering]), )) { const record = replacement.get(id), newBlock = record?.block || 0; if (newBlock !== block) { if (newBlock) blocks.set(id, newBlock); else blocks.delete(id); if (includeUnloadedChanges || !departing.has(section)) changes.push({ pos: record?.pos || id.split(",").map(Number), block: newBlock, }); } replacement.delete(id); } for (const [id, { pos, block, section }] of replacement) { if (!block) continue; if (typeof blocks.setInSection === "function") blocks.setInSection(id, block, section); else blocks.set(id, block); changes.push({ pos, block }); } view.viewCenter = [...message.view_center]; view.viewBounds = getViewBounds(message); return { status: "applied", changes }; }