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 { parseStage, safeAssetUrl, signature, type Stage } from './world.js';
export const LEVEL_SCHEMA = 'soyindent.level';
export const MAX_LEVEL_BYTES = 1_000_000;
export type FileReference = { url: string; sha256: string; mime: string; size: number };
export type LevelRecordData = { package: FileReference; description: string; rooms: number; zombies: number; remix?: { author: string; id: string; scope: string; revision: string } };
export function fileReference(value: unknown, mime?: string, max = MAX_LEVEL_BYTES): FileReference {
const r = value as FileReference;
if (!r || !safeAssetUrl(r.url) || !/^[a-f0-9]{64}$/.test(r.sha256) || typeof r.mime !== 'string' || (mime && r.mime !== mime) || !Number.isInteger(r.size) || r.size < 1 || r.size > max) throw new Error('Invalid file link, MIME, size or SHA-256.');
return { url: r.url, sha256: r.sha256, mime: r.mime, size: r.size };
}
export function validateLevelRecord(value: unknown): LevelRecordData {
const r = value as LevelRecordData;
if (!r || typeof r.description !== 'string' || r.description.length > 2000 || !Number.isInteger(r.rooms) || r.rooms < 1 || r.rooms > 16 || !Number.isInteger(r.zombies) || r.zombies < 0 || r.zombies > 320) throw new Error('Invalid level listing.');
const result: LevelRecordData = { package: fileReference(r.package, 'application/json'), description: r.description, rooms: r.rooms, zombies: r.zombies };
if (r.remix) {
if (!/^[a-f0-9]{64}$/.test(r.remix.author) || !/^[a-f0-9]{64}$/.test(r.remix.scope) || !/^[a-f0-9]{64}$/.test(r.remix.revision) || !/^[a-zA-Z0-9_-]{1,64}$/.test(r.remix.id)) throw new Error('Invalid remix attribution.');
result.remix = { ...r.remix };
}
return result;
}
export function levelSnapshot(value: Stage): Stage {
// Clone through validation; publishing never mutates the editor's live map.
const s = parseStage(JSON.stringify(value));
delete (s as Stage & { $schema?: string }).$schema;
const used = new Set<string>();
for (const room of s.rooms) for (const camera of room.cameras) {
if (camera.background && camera.background.signature === signature(room, camera)) used.add(camera.background.asset);
else delete camera.background;
if (camera.backdrop && camera.artSignature !== signature(room, camera)) { delete camera.backdrop; delete camera.artSignature; }
}
s.assets = (s.assets ?? []).filter(a => used.has(a.id));
return s;
}
export async function sha256(blob: Blob): Promise<string> {
const bytes = await blob.arrayBuffer();
return Array.from(new Uint8Array(await crypto.subtle.digest('SHA-256', bytes)), b => b.toString(16).padStart(2, '0')).join('');
}
export async function verifiedBlob(blob: Blob, ref: Omit<FileReference, 'url'>): Promise<Blob> {
if (blob.size !== ref.size || blob.type.split(';')[0] !== ref.mime || await sha256(blob) !== ref.sha256) throw new Error('Linked file failed its size, MIME or SHA-256 check.');
return blob;
}
