SOURCE / PINNED RELEASE
Made of little things.
Outbreak: Soycoon City
- Release
- db7750b1d009…
- Author-recorded commit
- 3ee3f5fd27a6…
- License
- LICENSE
- Author’s source reference
- nostr://npub182jczunncwe0jn6frpqwq3e0qjws7yqqnc3auccqv9nte2dnd63scjm4rf/wss%3A%2F%2Fgit.napplet.soy%2F/n-b87c04571a9
Archive hash verified: dbebdc772b142a1c…. The source-to-build association is the author’s claim; it has not been independently rebuilt.
import { levelSnapshot, MAX_LEVEL_BYTES, sha256, verifiedBlob, type FileReference } from './level-format.js';
import { parseStage, signature, type Stage, type LevelAsset } from './world.js';
export type LevelFileIO = { read(ref: FileReference): Promise<Blob>; upload(blob: Blob, name: string): Promise<FileReference> };
export type BundledImage = { asset: LevelAsset; url: string };
export class LevelFiles {
images = new Map<string, { blob: Blob; url: string }>();
uploads = new Map<string, FileReference>();
objectUrls = new Set<string>();
constructor(private bundled: BundledImage[], private io: LevelFileIO) {}
private inline(uri: string): Blob {
const m = /^data:(image\/(?:png|jpeg|webp));base64,([A-Za-z0-9+/=]+)$/.exec(uri);
if (!m) throw new Error('Background bytes must be PNG, JPEG or WebP.');
return new Blob([Uint8Array.from(atob(m[2]), c => c.charCodeAt(0))], { type: m[1] });
}
private async decode(url: string): Promise<void> {
const image = new Image(); image.src = url; await image.decode();
if (image.naturalWidth > 4096 || image.naturalHeight > 4096 || Math.abs(image.naturalWidth / image.naturalHeight - 4 / 3) > .025) throw new Error('Background images must be 4:3 and at most 4096 pixels per side.');
}
private async image(asset: LevelAsset): Promise<{ blob: Blob; url: string }> {
const cached = this.images.get(asset.sha256); if (cached) { await verifiedBlob(cached.blob, asset); return cached; }
const bundled = this.bundled.find(b => b.asset.sha256 === asset.sha256);
let blob: Blob, url: string;
if (bundled) { blob = this.inline(bundled.url); url = bundled.url; }
else {
if (!asset.url) throw new Error(`Background ${asset.id} has no available linked file.`);
blob = await this.io.read({ ...asset, url: asset.url });
url = URL.createObjectURL(blob); this.objectUrls.add(url);
}
try {
await verifiedBlob(blob, asset);
// Decode before assigning art. No SVG/active documents or broken images.
await this.decode(url);
const result = { blob, url }; this.images.set(asset.sha256, result); return result;
} catch (error) { if (this.objectUrls.delete(url)) URL.revokeObjectURL(url); throw error; }
}
bind(stage: Stage, art: Map<string, { url: string; signature: string }>): void {
art.clear();
for (const r of stage.rooms) for (const c of r.cameras) {
const binding = c.background, asset = stage.assets?.find(a => a.id === binding?.asset); if (!asset || !binding) continue;
const url = this.images.get(asset.sha256)?.url ?? this.bundled.find(b => b.asset.sha256 === asset.sha256)?.url;
if (url) art.set(`${r.id}/${c.id}`, { url, signature: binding.signature });
}
}
async load(ref: FileReference): Promise<Stage> {
const blob = await verifiedBlob(await this.io.read(ref), ref);
const stage = parseStage(await blob.text());
if (stage.rooms.some(r => r.cameras.some(c => c.backdrop))) throw new Error('Shared level packages must link backgrounds rather than embed image bytes.');
await this.resolve(stage);
return stage;
}
async resolve(stage: Stage): Promise<void> {
const used = levelSnapshot(stage).assets ?? [];
if (used.reduce((n, a) => n + a.size, 0) > 30_000_000) throw new Error('This level links more than 30 MB of background images.');
for (const asset of used) await this.image(asset);
}
async prepare(stage: Stage, progress: (message: string) => void = () => {}, guard: () => Promise<void> = async () => {}): Promise<FileReference> {
const snapshot = levelSnapshot(stage);
for (const r of snapshot.rooms) for (const c of r.cameras) if (c.backdrop) {
const blob = this.inline(c.backdrop), hash = await sha256(blob), id = `custom-${hash.slice(0,24)}`;
await this.decode(c.backdrop);
let asset = snapshot.assets!.find(a => a.sha256 === hash);
if (!asset) { asset = { id, sha256: hash, size: blob.size, mime: blob.type as LevelAsset['mime'] }; snapshot.assets!.push(asset); }
this.images.set(hash, { blob, url: c.backdrop }); c.background = { asset: asset.id, signature: signature(r,c) }; delete c.backdrop; delete c.artSignature;
}
const used = new Set(snapshot.rooms.flatMap(r=>r.cameras.map(c=>c.background?.asset)));
snapshot.assets = snapshot.assets!.filter(a=>used.has(a.id));
if (snapshot.assets!.length > 96 || snapshot.assets!.reduce((n,a)=>n+a.size,0) > 30_000_000) throw new Error('A shared level supports 96 backgrounds and 30 MB of image bytes.');
// Deduplicate hashes, retain successful uploads for an explicit retry.
for (const [i, asset] of snapshot.assets!.entries()) {
await guard();
progress(`Background ${i+1}/${snapshot.assets!.length} · awaiting host upload approval`);
let ref = this.uploads.get(asset.sha256);
if (!ref && asset.url) { ref = { url: asset.url, sha256: asset.sha256, mime: asset.mime, size: asset.size }; await this.image(asset); }
if (!ref) {
const image = await this.image(asset);
ref = await this.io.upload(image.blob, `${asset.id}.${asset.mime==='image/jpeg'?'jpg':asset.mime.split('/')[1]}`);
await verifiedBlob(image.blob, ref); this.uploads.set(asset.sha256, ref);
}
asset.url = ref.url; delete asset.path;
}
const valid = parseStage(JSON.stringify(snapshot));
const blob = new Blob([JSON.stringify(valid)], { type:'application/json' });
if (blob.size > MAX_LEVEL_BYTES) throw new Error('Level JSON exceeds the 1 MB shared-package limit.');
const hash = await sha256(blob); let ref = this.uploads.get(hash);
if (!ref) { await guard(); progress('Level package · awaiting host upload approval'); ref = await this.io.upload(blob, `${snapshot.id ?? 'soyindent-level'}.json`); await verifiedBlob(blob,ref); this.uploads.set(hash,ref); }
return ref;
}
dispose(): void { for(const url of this.objectUrls) URL.revokeObjectURL(url); this.objectUrls.clear(); this.images.clear(); this.uploads.clear(); }
}
