Back to Outbreak: Soycoon City
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.

src/level-files.ts
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(); }
}