779 lines
29 KiB
JavaScript
779 lines
29 KiB
JavaScript
(function () {
|
|
'use strict';
|
|
|
|
const root = window.PixelIslandModules ||= {};
|
|
const SNAPSHOT_FORMAT = 'pixel-island-phase2-snapshot-v1';
|
|
const ASSET_BUNDLE_FORMAT = 'pixel-island-phase2-asset-bundle-v2';
|
|
const ASSET_BUNDLE_FORMAT_V1 = 'pixel-island-phase2-asset-bundle-v1';
|
|
const DB_NAME = 'pixel-island-phase2-cache';
|
|
const DB_VERSION = 6;
|
|
const ASSET_STORE = 'assets';
|
|
const SNAPSHOT_STORE = 'snapshots';
|
|
const PIXEL_BLOB_STORE = 'pixelBlobs';
|
|
const OUTBOX_STORE = 'outbox';
|
|
const COLOR_CODES = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ';
|
|
const PACK6_CHARS = `.${COLOR_CODES}`;
|
|
|
|
function isAssetBundle(value) {
|
|
return Boolean(value && (value.format === ASSET_BUNDLE_FORMAT || value.format === ASSET_BUNDLE_FORMAT_V1) && Array.isArray(value.assets));
|
|
}
|
|
|
|
function bytesToBase64(bytes) {
|
|
if (!bytes || !bytes.length) return '';
|
|
if (typeof btoa === 'function') {
|
|
let binary = '';
|
|
const chunk = 8192;
|
|
for (let i = 0; i < bytes.length; i += chunk) {
|
|
binary += String.fromCharCode(...bytes.slice(i, i + chunk));
|
|
}
|
|
return btoa(binary);
|
|
}
|
|
if (typeof Buffer !== 'undefined') return Buffer.from(bytes).toString('base64');
|
|
return '';
|
|
}
|
|
|
|
function base64ToBytes(value) {
|
|
const text = String(value || '');
|
|
if (!text) return [];
|
|
if (typeof atob === 'function') {
|
|
const binary = atob(text);
|
|
const out = new Array(binary.length);
|
|
for (let i = 0; i < binary.length; i++) out[i] = binary.charCodeAt(i) & 255;
|
|
return out;
|
|
}
|
|
if (typeof Buffer !== 'undefined') return Array.from(Buffer.from(text, 'base64'));
|
|
return [];
|
|
}
|
|
|
|
function pack6Index(ch) {
|
|
const index = PACK6_CHARS.indexOf(ch || '.');
|
|
return index >= 0 && index < 64 ? index : 0;
|
|
}
|
|
|
|
function unpack6Index(value) {
|
|
return PACK6_CHARS[value] || '.';
|
|
}
|
|
|
|
function canPack6(text) {
|
|
for (const ch of String(text || '')) if (PACK6_CHARS.indexOf(ch) < 0) return false;
|
|
return true;
|
|
}
|
|
|
|
function pack6Text(input) {
|
|
const text = String(input || '');
|
|
if (!text) return '';
|
|
let buffer = 0;
|
|
let bitCount = 0;
|
|
const bytes = [];
|
|
for (const ch of text) {
|
|
buffer = (buffer << 6) | pack6Index(ch);
|
|
bitCount += 6;
|
|
while (bitCount >= 8) {
|
|
bitCount -= 8;
|
|
bytes.push((buffer >> bitCount) & 255);
|
|
buffer &= (1 << bitCount) - 1;
|
|
}
|
|
}
|
|
if (bitCount > 0) bytes.push((buffer << (8 - bitCount)) & 255);
|
|
return bytesToBase64(bytes);
|
|
}
|
|
|
|
function unpack6Text(input, length) {
|
|
const target = Math.max(0, Number(length) || 0);
|
|
if (!target) return '';
|
|
const bytes = base64ToBytes(input);
|
|
let buffer = 0;
|
|
let bitCount = 0;
|
|
let out = '';
|
|
for (const byte of bytes) {
|
|
buffer = (buffer << 8) | (byte & 255);
|
|
bitCount += 8;
|
|
while (bitCount >= 6 && out.length < target) {
|
|
bitCount -= 6;
|
|
out += unpack6Index((buffer >> bitCount) & 63);
|
|
buffer &= (1 << bitCount) - 1;
|
|
}
|
|
if (out.length >= target) break;
|
|
}
|
|
return (out + '.'.repeat(target)).slice(0, target);
|
|
}
|
|
|
|
function packBitMask(mask) {
|
|
const bits = Array.from(mask || [], Boolean);
|
|
if (!bits.length) return '';
|
|
const bytes = [];
|
|
let current = 0;
|
|
for (let i = 0; i < bits.length; i++) {
|
|
current = (current << 1) | (bits[i] ? 1 : 0);
|
|
if (i % 8 === 7) {
|
|
bytes.push(current & 255);
|
|
current = 0;
|
|
}
|
|
}
|
|
const rest = bits.length % 8;
|
|
if (rest) bytes.push((current << (8 - rest)) & 255);
|
|
return bytesToBase64(bytes);
|
|
}
|
|
|
|
function unpackBitMask(input, length) {
|
|
const target = Math.max(0, Number(length) || 0);
|
|
const bytes = base64ToBytes(input);
|
|
const out = [];
|
|
for (const byte of bytes) {
|
|
for (let bit = 7; bit >= 0 && out.length < target; bit--) out.push(Boolean((byte >> bit) & 1));
|
|
if (out.length >= target) break;
|
|
}
|
|
while (out.length < target) out.push(false);
|
|
return out;
|
|
}
|
|
|
|
function rleEncode(input) {
|
|
const text = String(input || '');
|
|
if (!text) return '';
|
|
let out = '';
|
|
let last = text[0];
|
|
let count = 1;
|
|
for (let i = 1; i < text.length; i++) {
|
|
const ch = text[i];
|
|
if (ch === last) count++;
|
|
else {
|
|
out += `${count}:${last}`;
|
|
last = ch;
|
|
count = 1;
|
|
}
|
|
}
|
|
out += `${count}:${last}`;
|
|
return out;
|
|
}
|
|
|
|
function rleDecode(input) {
|
|
const text = String(input || '');
|
|
if (!text) return '';
|
|
let out = '';
|
|
let i = 0;
|
|
while (i < text.length) {
|
|
let digits = '';
|
|
while (i < text.length && text[i] >= '0' && text[i] <= '9') digits += text[i++];
|
|
if (text[i] !== ':') break;
|
|
i++;
|
|
const ch = text[i++] || '';
|
|
const count = Math.max(0, Number(digits) || 0);
|
|
out += ch.repeat(count);
|
|
}
|
|
return out;
|
|
}
|
|
|
|
function assetWidth(asset) {
|
|
const size = Math.max(1, Number(asset?.size) || 16);
|
|
return Math.max(1, Number(asset?.width ?? asset?.w ?? size) || size);
|
|
}
|
|
|
|
function assetHeight(asset) {
|
|
const size = Math.max(1, Number(asset?.size) || 16);
|
|
return Math.max(1, Number(asset?.height ?? asset?.ht ?? size) || size);
|
|
}
|
|
|
|
function normalizeEncodedPlane(value, width, emptyChar = '.', height = width) {
|
|
const w = Math.max(1, Number(width) || 1);
|
|
const h = Math.max(1, Number(height) || w);
|
|
const total = w * h;
|
|
const source = typeof value === 'string' ? value : Array.isArray(value) ? value.map((v) => v || emptyChar).join('') : '';
|
|
return (source + emptyChar.repeat(total)).slice(0, total);
|
|
}
|
|
|
|
function chooseSmallest(candidates) {
|
|
let best = candidates[0];
|
|
let bestSize = JSON.stringify(best).length;
|
|
for (const candidate of candidates.slice(1)) {
|
|
const size = JSON.stringify(candidate).length;
|
|
if (size < bestSize) { best = candidate; bestSize = size; }
|
|
}
|
|
return best;
|
|
}
|
|
|
|
function cropPlane(encoded, width, emptyChar = '.', options = {}, height = width) {
|
|
const w0 = Math.max(1, Number(width) || 1);
|
|
const h0 = Math.max(1, Number(height) || w0);
|
|
const text = normalizeEncodedPlane(encoded, w0, emptyChar, h0);
|
|
let minX = w0;
|
|
let minY = h0;
|
|
let maxX = -1;
|
|
let maxY = -1;
|
|
for (let y = 0; y < h0; y++) {
|
|
for (let x = 0; x < w0; x++) {
|
|
if (text[y * w0 + x] !== emptyChar) {
|
|
minX = Math.min(minX, x);
|
|
minY = Math.min(minY, y);
|
|
maxX = Math.max(maxX, x);
|
|
maxY = Math.max(maxY, y);
|
|
}
|
|
}
|
|
}
|
|
if (maxX < 0) return { b: null, e: 'raw', v: '' };
|
|
const w = maxX - minX + 1;
|
|
const h = maxY - minY + 1;
|
|
let cropped = '';
|
|
for (let y = minY; y <= maxY; y++) cropped += text.slice(y * w0 + minX, y * w0 + minX + w);
|
|
const candidates = [
|
|
{ b: [minX, minY, w, h], e: 'raw', v: cropped },
|
|
{ b: [minX, minY, w, h], e: 'rle', v: rleEncode(cropped) }
|
|
];
|
|
if (options.bitPack && canPack6(cropped)) candidates.push({ b: [minX, minY, w, h], e: 'bp6', v: pack6Text(cropped), n: cropped.length });
|
|
return chooseSmallest(candidates);
|
|
}
|
|
|
|
function expandPlane(packed, width, emptyChar = '.', baseEncoded = null, height = width) {
|
|
const w0 = Math.max(1, Number(width) || 1);
|
|
const h0 = Math.max(1, Number(height) || w0);
|
|
const total = w0 * h0;
|
|
const out = Array(total).fill(emptyChar);
|
|
if (baseEncoded) {
|
|
const base = normalizeEncodedPlane(baseEncoded, w0, emptyChar, h0);
|
|
for (let i = 0; i < Math.min(out.length, base.length); i++) out[i] = base[i] || emptyChar;
|
|
}
|
|
if (!packed || !packed.b) return out.join('');
|
|
const [x0, y0, w, h] = packed.b.map((v) => Math.max(0, Number(v) || 0));
|
|
let value = '';
|
|
if (packed.e === 'rle') value = rleDecode(packed.v);
|
|
else if (packed.e === 'bp6') value = unpack6Text(packed.v, packed.n || (w * h));
|
|
else value = String(packed.v || '');
|
|
for (let y = 0; y < h; y++) {
|
|
for (let x = 0; x < w; x++) {
|
|
const src = y * w + x;
|
|
const dx = x0 + x;
|
|
const dy = y0 + y;
|
|
if (dx >= 0 && dy >= 0 && dx < w0 && dy < h0 && src < value.length) out[dy * w0 + dx] = value[src] || emptyChar;
|
|
}
|
|
}
|
|
return out.join('');
|
|
}
|
|
|
|
function planeForAsset(asset) {
|
|
const w = assetWidth(asset);
|
|
const h = assetHeight(asset);
|
|
return normalizeEncodedPlane(asset?.faces?.right || asset?.pixels || '', w, '.', h);
|
|
}
|
|
|
|
function selectPixelPlanePack(asset, assetMap) {
|
|
const w = assetWidth(asset);
|
|
const h = assetHeight(asset);
|
|
const right = planeForAsset(asset);
|
|
return cropPlane(right, w, '.', { bitPack: true }, h);
|
|
}
|
|
|
|
function packAsset(asset, assetMap = null) {
|
|
const width = assetWidth(asset);
|
|
const height = assetHeight(asset);
|
|
const size = Math.max(width, height, Math.max(1, Number(asset.size) || 16));
|
|
const category = asset.category === 'dynamic' ? 'dynamic' : 'static';
|
|
const depth = asset.meta?.depthPixels ? normalizeEncodedPlane(asset.meta.depthPixels, width, '.', height) : '';
|
|
const lights = Array.isArray(asset.meta?.lightPixels)
|
|
? asset.meta.lightPixels.map((p) => [Number(p.x) || 0, Number(p.y) || 0, p.c || asset.meta?.lightColor || '']).filter((p) => p[2])
|
|
: [];
|
|
const particles = Array.isArray(asset.meta?.particlePixels)
|
|
? asset.meta.particlePixels.map((p) => [Number(p.x) || 0, Number(p.y) || 0, p.c || '', p.dir || 'up']).filter((p) => p[2])
|
|
: [];
|
|
const meta = {};
|
|
if (depth && /[1\-]/.test(depth)) meta.d = cropPlane(depth, width, '.', {}, height);
|
|
if (lights.length) meta.l = lights;
|
|
if (particles.length) meta.pt = particles;
|
|
if (asset.meta?.lightColor) meta.lc = asset.meta.lightColor;
|
|
if (asset.meta?.door) meta.dr = [Number(asset.meta.door.x) || 0, Number(asset.meta.door.y) || 0];
|
|
if (asset.meta?.particleConfig?.enabled && !particles.length) {
|
|
const pc = asset.meta.particleConfig;
|
|
meta.pc = [pc.c || '', pc.dir || 'up', null];
|
|
}
|
|
|
|
return {
|
|
id: asset.id,
|
|
h: asset.contentHash || asset.hash || null,
|
|
bi: asset.blobId || asset.bi || null,
|
|
n: asset.name || 'Untitled',
|
|
c: category,
|
|
t: asset.subtype || (category === 'dynamic' ? 'human' : 'other'),
|
|
s: size,
|
|
w: width !== size ? width : undefined,
|
|
ht: height !== size ? height : undefined,
|
|
p: selectPixelPlanePack(asset, assetMap),
|
|
f: category === 'dynamic' ? { l: 'mirror' } : null,
|
|
pa: asset.parentAssetId || null,
|
|
oa: asset.originalAssetId || null,
|
|
ca: asset.createdAt || Date.now(),
|
|
ua: asset.updatedAt || asset.createdAt || Date.now(),
|
|
au: asset.author || 'Local Artist',
|
|
ow: asset.ownerAccountId || null,
|
|
v: Number(asset.version) || 1,
|
|
m: Object.keys(meta).length ? meta : null
|
|
};
|
|
}
|
|
|
|
|
|
function packAssetMetadata(asset, assetMap = null) {
|
|
const packed = packAsset(asset, assetMap);
|
|
delete packed.p;
|
|
return packed;
|
|
}
|
|
|
|
function packPixelBlob(input) {
|
|
if (!input) return null;
|
|
const width = assetWidth(input);
|
|
const height = assetHeight(input);
|
|
const payload = normalizeEncodedPlane(input.payload || input.faces?.right || input.pixels || '', width, '.', height);
|
|
const id = input.blobId || input.bi || ((input.codec || input.payload) ? input.id : null);
|
|
if (!id) return null;
|
|
return {
|
|
id,
|
|
co: input.codec || 'palette-index-v1',
|
|
w: width,
|
|
ht: height,
|
|
p: cropPlane(payload, width, '.', { bitPack: true }, height),
|
|
ca: input.createdAt || Date.now(),
|
|
ua: input.updatedAt || input.createdAt || Date.now()
|
|
};
|
|
}
|
|
|
|
function unpackPixelBlob(packed) {
|
|
if (!packed || !packed.id) return null;
|
|
const width = Math.max(1, Number(packed.w || packed.width) || 16);
|
|
const height = Math.max(1, Number(packed.ht || packed.height || width) || width);
|
|
return {
|
|
id: packed.id,
|
|
codec: packed.co || packed.codec || 'palette-index-v1',
|
|
width,
|
|
height,
|
|
payload: expandPlane(packed.p, width, '.', null, height),
|
|
createdAt: packed.ca || null,
|
|
updatedAt: packed.ua || packed.ca || null
|
|
};
|
|
}
|
|
|
|
function unpackAsset(packed, assetById = null, pixelBlobById = null) {
|
|
if (!packed || !packed.id) return null;
|
|
const size = Math.max(1, Number(packed.s || packed.size) || 16);
|
|
const width = Math.max(1, Number(packed.w || packed.width || size) || size);
|
|
const height = Math.max(1, Number(packed.ht || packed.height || size) || size);
|
|
const category = packed.c === 'dynamic' || packed.category === 'dynamic' ? 'dynamic' : 'static';
|
|
const blobId = packed.bi || packed.blobId || null;
|
|
const pixelBlob = blobId && pixelBlobById
|
|
? (typeof pixelBlobById.get === 'function' ? pixelBlobById.get(blobId) : pixelBlobById[blobId])
|
|
: null;
|
|
const blobPayload = typeof pixelBlob?.payload === 'string' ? pixelBlob.payload : null;
|
|
const pixels = blobPayload ? normalizeEncodedPlane(blobPayload, width, '.', height) : expandPlane(packed.p, width, '.', null, height);
|
|
if (pixels == null) return null;
|
|
const metaPacked = packed.m || {};
|
|
const lightPixels = Array.isArray(metaPacked.l)
|
|
? metaPacked.l.map((p) => ({ x: Number(p[0]) || 0, y: Number(p[1]) || 0, c: p[2] || metaPacked.lc || 'a' }))
|
|
: [];
|
|
const particlePixels = Array.isArray(metaPacked.pt)
|
|
? metaPacked.pt.map((p) => ({ x: Number(p[0]) || 0, y: Number(p[1]) || 0, c: p[2] || 'a', dir: p[3] || 'up' }))
|
|
: [];
|
|
const particleConfig = Array.isArray(metaPacked.pc)
|
|
? { enabled: true, c: metaPacked.pc[0] || 'a', dir: metaPacked.pc[1] || 'up', range: Array.isArray(metaPacked.pc[2]) ? { x: metaPacked.pc[2][0] || 0, y: metaPacked.pc[2][1] || 0, w: metaPacked.pc[2][2] || 0, h: metaPacked.pc[2][3] || 0 } : null }
|
|
: null;
|
|
const meta = {
|
|
hasLight: lightPixels.length > 0,
|
|
lightPixels,
|
|
lightColor: lightPixels.length > 0 ? (metaPacked.lc || lightPixels[0]?.c || null) : null,
|
|
hasParticles: Boolean(particleConfig) || particlePixels.length > 0,
|
|
particlePixels,
|
|
particleConfig,
|
|
depthPixels: metaPacked.d ? expandPlane(metaPacked.d, width, '.', null, height) : null,
|
|
door: Array.isArray(metaPacked.dr) ? { x: Number(metaPacked.dr[0]) || 0, y: Number(metaPacked.dr[1]) || 0 } : null
|
|
};
|
|
return {
|
|
id: packed.id,
|
|
name: packed.n || 'Untitled',
|
|
category,
|
|
subtype: packed.t || (category === 'dynamic' ? 'human' : 'other'),
|
|
size,
|
|
width,
|
|
height,
|
|
blobId,
|
|
pixels,
|
|
faces: category === 'dynamic' ? { right: pixels, left: 'mirror' } : null,
|
|
parentAssetId: packed.pa || null,
|
|
originalAssetId: packed.oa || null,
|
|
createdAt: packed.ca || Date.now(),
|
|
updatedAt: packed.ua || packed.ca || Date.now(),
|
|
author: packed.au || 'Local Artist',
|
|
ownerAccountId: packed.ow || packed.ownerAccountId || '',
|
|
version: Number(packed.v || packed.version) || 1,
|
|
meta,
|
|
contentHash: packed.h || null
|
|
};
|
|
}
|
|
|
|
function unpackAssets(rows, pixelBlobById = null) {
|
|
return (Array.isArray(rows) ? rows : []).map((row) => unpackAsset(row, null, pixelBlobById)).filter(Boolean);
|
|
}
|
|
|
|
function packPlacement(item) {
|
|
const meta = {};
|
|
if (item.publishedAt) meta.pu = item.publishedAt;
|
|
if (item.status && item.status !== 'active') meta.st = item.status;
|
|
if (item.ownerAccountId) meta.ow = item.ownerAccountId;
|
|
return [item.id, item.assetId, Number(item.x) || 0, Number(item.y) || 0, item.placedAt || Date.now(), Number(item.version) || 1, Object.keys(meta).length ? meta : null];
|
|
}
|
|
|
|
function unpackPlacement(row) {
|
|
if (!Array.isArray(row)) return row;
|
|
const meta = row[6] || {};
|
|
return { id: row[0], assetId: row[1], x: row[2], y: row[3], placedAt: row[4], version: row[5] || 1, publishedAt: meta.pu || row[4], status: meta.st || 'active', ownerAccountId: meta.ow || '' };
|
|
}
|
|
|
|
function packDynamic(item) {
|
|
const meta = {};
|
|
if (item.publishedAt) meta.pu = item.publishedAt;
|
|
if (item.status && item.status !== 'active') meta.st = item.status;
|
|
if (item.ownerAccountId) meta.ow = item.ownerAccountId;
|
|
return [item.id, item.assetId, Number(item.homeX) || 0, Number(item.homeY) || 0, item.createdAt || Date.now(), Number(item.version) || 1, Object.keys(meta).length ? meta : null];
|
|
}
|
|
|
|
function unpackDynamic(row) {
|
|
if (!Array.isArray(row)) return row;
|
|
const meta = row[6] || {};
|
|
return { id: row[0], assetId: row[1], homeX: row[2], homeY: row[3], createdAt: row[4], version: row[5] || 1, publishedAt: meta.pu || row[4], status: meta.st || 'active', ownerAccountId: meta.ow || '' };
|
|
}
|
|
|
|
function assetMapFor(assets) {
|
|
return new Map((assets || []).map((asset) => [asset.id, asset]));
|
|
}
|
|
|
|
function assetManifest(state) {
|
|
return (state.assets || []).map((asset) => ({ id: asset.id, h: asset.contentHash || null, bi: asset.blobId || null, s: asset.size, w: asset.width || null, ht: asset.height || null, c: asset.category, t: asset.subtype, pa: asset.parentAssetId || null, oa: asset.originalAssetId || null }));
|
|
}
|
|
|
|
function makeSnapshot(state, worldId = 'local-main') {
|
|
return {
|
|
schema: 4,
|
|
format: SNAPSHOT_FORMAT,
|
|
worldId,
|
|
createdAt: Date.now(),
|
|
manifest: assetManifest(state),
|
|
placed: (state.placed || []).map(packPlacement),
|
|
dynamicSummons: (state.dynamicSummons || []).map(packDynamic),
|
|
hiddenObjects: state.hiddenObjects || {}
|
|
};
|
|
}
|
|
|
|
function makeAssetBundle(state, assetIds) {
|
|
const wanted = new Set(assetIds || []);
|
|
const assets = (state.assets || []).filter((asset) => !wanted.size || wanted.has(asset.id));
|
|
const map = assetMapFor(assets);
|
|
return { schema: 4, format: ASSET_BUNDLE_FORMAT, createdAt: Date.now(), assets: assets.map((asset) => packAsset(asset, map)) };
|
|
}
|
|
|
|
function unpackAssetBundle(bundle) {
|
|
if (!isAssetBundle(bundle)) return [];
|
|
return unpackAssets(bundle.assets);
|
|
}
|
|
|
|
function findMissingAssetIds(snapshot, knownAssetIds) {
|
|
const known = new Set(knownAssetIds || []);
|
|
return (snapshot?.manifest || []).map((asset) => asset.id).filter((id) => id && !known.has(id));
|
|
}
|
|
|
|
function makeEvent(type, payload = {}) {
|
|
return { id: `ev_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`, type, at: Date.now(), ...payload };
|
|
}
|
|
|
|
function createAssetUpsertEvent(asset) {
|
|
return makeEvent('asset.upsert', { asset: packAsset(asset) });
|
|
}
|
|
|
|
function createAssetDeleteEvent(assetId) {
|
|
return makeEvent('asset.delete', { assetId });
|
|
}
|
|
|
|
function createObjectUpsertEvent(kind, object) {
|
|
return makeEvent('object.upsert', { kind, object: kind === 'dynamic' ? packDynamic(object) : packPlacement(object) });
|
|
}
|
|
|
|
function createObjectDeleteEvent(kind, objectId) {
|
|
return makeEvent('object.delete', { kind, objectId });
|
|
}
|
|
|
|
function deleteAssetOnly(state, assetId) {
|
|
if (!state || !assetId) return state;
|
|
const removedObjectIds = new Set();
|
|
for (const item of state.placed || []) if (item.assetId === assetId) removedObjectIds.add(item.id);
|
|
for (const item of state.dynamicSummons || []) if (item.assetId === assetId) removedObjectIds.add(item.id);
|
|
state.assets = (state.assets || []).filter((asset) => asset.id !== assetId);
|
|
state.placed = (state.placed || []).filter((item) => item.assetId !== assetId);
|
|
state.dynamicSummons = (state.dynamicSummons || []).filter((item) => item.assetId !== assetId);
|
|
delete state.assetVotes?.[assetId];
|
|
delete state.hiddenAssets?.[assetId];
|
|
for (const id of removedObjectIds) delete state.objectVotes?.[id], delete state.hiddenObjects?.[id];
|
|
state.moderationReports = (state.moderationReports || []).filter((report) => !removedObjectIds.has(report.objectId));
|
|
return state;
|
|
}
|
|
|
|
function applyEvent(state, event) {
|
|
if (!state || !event) return state;
|
|
if (event.type === 'asset.upsert' && event.asset) {
|
|
const byId = assetMapFor(state.assets || []);
|
|
const asset = unpackAsset(event.asset, byId);
|
|
if (!asset) return state;
|
|
const index = (state.assets || []).findIndex((a) => a.id === asset.id);
|
|
if (index >= 0) state.assets[index] = asset;
|
|
else (state.assets ||= []).unshift(asset);
|
|
}
|
|
if (event.type === 'asset.delete' && event.assetId) {
|
|
deleteAssetOnly(state, event.assetId);
|
|
}
|
|
if (event.type === 'object.upsert') {
|
|
if (event.kind === 'dynamic') {
|
|
const object = unpackDynamic(event.object);
|
|
const list = state.dynamicSummons ||= [];
|
|
const index = list.findIndex((item) => item.id === object.id);
|
|
if (index >= 0) list[index] = object;
|
|
else list.push(object);
|
|
} else {
|
|
const object = unpackPlacement(event.object);
|
|
const list = state.placed ||= [];
|
|
const index = list.findIndex((item) => item.id === object.id);
|
|
if (index >= 0) list[index] = object;
|
|
else list.push(object);
|
|
}
|
|
}
|
|
if (event.type === 'object.delete') {
|
|
const key = event.kind === 'dynamic' ? 'dynamicSummons' : 'placed';
|
|
state[key] = (state[key] || []).filter((item) => item.id !== event.objectId);
|
|
}
|
|
return state;
|
|
}
|
|
|
|
function openDb() {
|
|
if (!('indexedDB' in window)) return Promise.reject(new Error('IndexedDB is not available.'));
|
|
return new Promise((resolve, reject) => {
|
|
const request = indexedDB.open(DB_NAME, DB_VERSION);
|
|
request.onupgradeneeded = () => {
|
|
const db = request.result;
|
|
const tx = request.transaction;
|
|
if (!db.objectStoreNames.contains(ASSET_STORE)) db.createObjectStore(ASSET_STORE, { keyPath: 'id' });
|
|
if (!db.objectStoreNames.contains(SNAPSHOT_STORE)) db.createObjectStore(SNAPSHOT_STORE, { keyPath: 'worldId' });
|
|
if (!db.objectStoreNames.contains(PIXEL_BLOB_STORE)) db.createObjectStore(PIXEL_BLOB_STORE, { keyPath: 'id' });
|
|
if (!db.objectStoreNames.contains(OUTBOX_STORE)) db.createObjectStore(OUTBOX_STORE, { keyPath: 'id' });
|
|
if (request.oldVersion && request.oldVersion < DB_VERSION) {
|
|
if (db.objectStoreNames.contains(ASSET_STORE)) tx.objectStore(ASSET_STORE).clear();
|
|
if (db.objectStoreNames.contains(SNAPSHOT_STORE)) tx.objectStore(SNAPSHOT_STORE).clear();
|
|
if (db.objectStoreNames.contains(PIXEL_BLOB_STORE)) tx.objectStore(PIXEL_BLOB_STORE).clear();
|
|
if (db.objectStoreNames.contains(OUTBOX_STORE)) tx.objectStore(OUTBOX_STORE).clear();
|
|
}
|
|
};
|
|
request.onsuccess = () => resolve(request.result);
|
|
request.onerror = () => reject(request.error);
|
|
});
|
|
}
|
|
|
|
async function cacheAssets(assets) {
|
|
if (!Array.isArray(assets) || !assets.length) return;
|
|
const db = await openDb();
|
|
await new Promise((resolve, reject) => {
|
|
const tx = db.transaction(ASSET_STORE, 'readwrite');
|
|
const assetStore = tx.objectStore(ASSET_STORE);
|
|
const map = assetMapFor(assets);
|
|
for (const asset of assets) assetStore.put(packAssetMetadata(asset, map));
|
|
tx.oncomplete = () => resolve();
|
|
tx.onerror = () => reject(tx.error);
|
|
});
|
|
db.close();
|
|
}
|
|
|
|
async function cachePixelBlobs(pixelBlobs) {
|
|
if (!Array.isArray(pixelBlobs) || !pixelBlobs.length) return;
|
|
const db = await openDb();
|
|
await new Promise((resolve, reject) => {
|
|
const tx = db.transaction(PIXEL_BLOB_STORE, 'readwrite');
|
|
const store = tx.objectStore(PIXEL_BLOB_STORE);
|
|
for (const pixelBlob of pixelBlobs) {
|
|
const packed = packPixelBlob(pixelBlob);
|
|
if (packed) store.put(packed);
|
|
}
|
|
tx.oncomplete = () => resolve();
|
|
tx.onerror = () => reject(tx.error);
|
|
});
|
|
db.close();
|
|
}
|
|
|
|
async function readCachedPixelBlobs(blobIds) {
|
|
const ids = Array.from(blobIds || []).filter(Boolean);
|
|
if (!ids.length) return [];
|
|
const db = await openDb();
|
|
const rows = await Promise.all(ids.map((id) => new Promise((resolve) => {
|
|
const request = db.transaction(PIXEL_BLOB_STORE, 'readonly').objectStore(PIXEL_BLOB_STORE).get(id);
|
|
request.onsuccess = () => resolve(request.result || null);
|
|
request.onerror = () => resolve(null);
|
|
})));
|
|
db.close();
|
|
return rows.map(unpackPixelBlob).filter(Boolean);
|
|
}
|
|
|
|
async function readCachedAssets(assetIds) {
|
|
const ids = Array.from(assetIds || []);
|
|
if (!ids.length) return [];
|
|
const db = await openDb();
|
|
const assetRows = await Promise.all(ids.map((id) => new Promise((resolve) => {
|
|
const request = db.transaction(ASSET_STORE, 'readonly').objectStore(ASSET_STORE).get(id);
|
|
request.onsuccess = () => resolve(request.result || null);
|
|
request.onerror = () => resolve(null);
|
|
})));
|
|
const blobIds = assetRows.map((row) => row?.bi || row?.blobId).filter(Boolean);
|
|
const blobRows = await Promise.all(blobIds.map((id) => new Promise((resolve) => {
|
|
const request = db.transaction(PIXEL_BLOB_STORE, 'readonly').objectStore(PIXEL_BLOB_STORE).get(id);
|
|
request.onsuccess = () => resolve(request.result || null);
|
|
request.onerror = () => resolve(null);
|
|
})));
|
|
db.close();
|
|
const blobById = new Map(blobRows.map(unpackPixelBlob).filter(Boolean).map((blob) => [blob.id, blob]));
|
|
return unpackAssets(assetRows.filter(Boolean), blobById);
|
|
}
|
|
|
|
async function cacheSnapshot(snapshot) {
|
|
if (!snapshot?.worldId) return;
|
|
const db = await openDb();
|
|
await new Promise((resolve, reject) => {
|
|
const tx = db.transaction(SNAPSHOT_STORE, 'readwrite');
|
|
tx.objectStore(SNAPSHOT_STORE).put(snapshot);
|
|
tx.oncomplete = () => resolve();
|
|
tx.onerror = () => reject(tx.error);
|
|
});
|
|
db.close();
|
|
}
|
|
|
|
|
|
async function readCachedSnapshot(worldId = 'local-main') {
|
|
const db = await openDb();
|
|
const row = await new Promise((resolve) => {
|
|
const request = db.transaction(SNAPSHOT_STORE, 'readonly').objectStore(SNAPSHOT_STORE).get(worldId);
|
|
request.onsuccess = () => resolve(request.result || null);
|
|
request.onerror = () => resolve(null);
|
|
});
|
|
db.close();
|
|
if (!row) return null;
|
|
return {
|
|
...row,
|
|
placed: Array.isArray(row.placed) ? row.placed.map(unpackPlacement) : [],
|
|
dynamicSummons: Array.isArray(row.dynamicSummons) ? row.dynamicSummons.map(unpackDynamic) : [],
|
|
hiddenObjects: row.hiddenObjects || {}
|
|
};
|
|
}
|
|
|
|
async function deleteCachedAssets(assetIds, blobIds = []) {
|
|
const assets = Array.from(assetIds || []).filter(Boolean);
|
|
const blobs = Array.from(blobIds || []).filter(Boolean);
|
|
if (!assets.length && !blobs.length) return;
|
|
const db = await openDb();
|
|
await new Promise((resolve, reject) => {
|
|
const tx = db.transaction([ASSET_STORE, PIXEL_BLOB_STORE], 'readwrite');
|
|
const assetStore = tx.objectStore(ASSET_STORE);
|
|
const blobStore = tx.objectStore(PIXEL_BLOB_STORE);
|
|
for (const id of assets) assetStore.delete(id);
|
|
for (const id of blobs) blobStore.delete(id);
|
|
tx.oncomplete = () => resolve();
|
|
tx.onerror = () => reject(tx.error);
|
|
});
|
|
db.close();
|
|
}
|
|
|
|
async function cacheOutboxCommand(command) {
|
|
if (!command?.id) return;
|
|
const db = await openDb();
|
|
await new Promise((resolve, reject) => {
|
|
const tx = db.transaction(OUTBOX_STORE, 'readwrite');
|
|
tx.objectStore(OUTBOX_STORE).put({ ...command, queuedAt: command.queuedAt || Date.now() });
|
|
tx.oncomplete = () => resolve();
|
|
tx.onerror = () => reject(tx.error);
|
|
});
|
|
db.close();
|
|
}
|
|
|
|
async function readOutboxCommands(limit = 300) {
|
|
const db = await openDb();
|
|
const rows = await new Promise((resolve) => {
|
|
const request = db.transaction(OUTBOX_STORE, 'readonly').objectStore(OUTBOX_STORE).getAll();
|
|
request.onsuccess = () => resolve(Array.isArray(request.result) ? request.result : []);
|
|
request.onerror = () => resolve([]);
|
|
});
|
|
db.close();
|
|
return rows
|
|
.sort((a, b) => Number(a.createdAt || a.queuedAt || 0) - Number(b.createdAt || b.queuedAt || 0))
|
|
.slice(-Math.max(1, Number(limit) || 300));
|
|
}
|
|
|
|
async function deleteOutboxCommands(commandIds) {
|
|
const ids = Array.from(commandIds || []).filter(Boolean);
|
|
if (!ids.length) return;
|
|
const db = await openDb();
|
|
await new Promise((resolve, reject) => {
|
|
const tx = db.transaction(OUTBOX_STORE, 'readwrite');
|
|
const store = tx.objectStore(OUTBOX_STORE);
|
|
for (const id of ids) store.delete(id);
|
|
tx.oncomplete = () => resolve();
|
|
tx.onerror = () => reject(tx.error);
|
|
});
|
|
db.close();
|
|
}
|
|
|
|
async function clearCache() {
|
|
const db = await openDb();
|
|
await new Promise((resolve, reject) => {
|
|
const tx = db.transaction([ASSET_STORE, PIXEL_BLOB_STORE, SNAPSHOT_STORE, OUTBOX_STORE], 'readwrite');
|
|
tx.objectStore(ASSET_STORE).clear();
|
|
tx.objectStore(PIXEL_BLOB_STORE).clear();
|
|
tx.objectStore(SNAPSHOT_STORE).clear();
|
|
tx.objectStore(OUTBOX_STORE).clear();
|
|
tx.oncomplete = () => resolve();
|
|
tx.onerror = () => reject(tx.error);
|
|
});
|
|
db.close();
|
|
}
|
|
|
|
root.Phase2Sync = {
|
|
SNAPSHOT_FORMAT,
|
|
ASSET_BUNDLE_FORMAT,
|
|
ASSET_BUNDLE_FORMAT_V1,
|
|
isAssetBundle,
|
|
rleEncode,
|
|
rleDecode,
|
|
pack6Text,
|
|
unpack6Text,
|
|
packBitMask,
|
|
unpackBitMask,
|
|
cropPlane,
|
|
expandPlane,
|
|
packAsset,
|
|
packAssetMetadata,
|
|
packPixelBlob,
|
|
unpackPixelBlob,
|
|
unpackAsset,
|
|
unpackAssets,
|
|
packPlacement,
|
|
unpackPlacement,
|
|
packDynamic,
|
|
unpackDynamic,
|
|
assetManifest,
|
|
makeSnapshot,
|
|
makeAssetBundle,
|
|
unpackAssetBundle,
|
|
findMissingAssetIds,
|
|
makeEvent,
|
|
createAssetUpsertEvent,
|
|
createAssetDeleteEvent,
|
|
createObjectUpsertEvent,
|
|
createObjectDeleteEvent,
|
|
deleteAssetOnly,
|
|
applyEvent,
|
|
cacheAssets,
|
|
cachePixelBlobs,
|
|
readCachedPixelBlobs,
|
|
readCachedAssets,
|
|
cacheSnapshot,
|
|
readCachedSnapshot,
|
|
deleteCachedAssets,
|
|
cacheOutboxCommand,
|
|
readOutboxCommands,
|
|
deleteOutboxCommands,
|
|
clearCache
|
|
};
|
|
})();
|