import { migrateSave, type SaveDocument, type SaveParse } from "@deadhead/schema"; /** * Save export/import (decision 0.5.92): one text code holding the whole save, * gzip-compressed and base64url-encoded behind a short prefix, so it survives * being pasted into a chat, a note or a file. */ export const SAVE_CODE_PREFIX = "DEADHEAD1."; async function pipe( bytes: Uint8Array, stream: CompressionStream | DecompressionStream, ): Promise { const piped = new Response(bytes as BodyInit).body!.pipeThrough(stream); return new Uint8Array(await new Response(piped).arrayBuffer()); } function toBase64Url(bytes: Uint8Array): string { let binary = ""; for (let i = 0; i < bytes.length; i += 0x8000) { binary += String.fromCharCode(...bytes.subarray(i, i + 0x8000)); } return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, ""); } function fromBase64Url(text: string): Uint8Array { const binary = atob(text.replace(/-/g, "+").replace(/_/g, "/")); return Uint8Array.from(binary, (c) => c.charCodeAt(0)); } /** The save as an export code. */ export async function encodeSave(save: SaveDocument): Promise { const json = new TextEncoder().encode(JSON.stringify(save)); return SAVE_CODE_PREFIX + toBase64Url(await pipe(json, new CompressionStream("gzip"))); } /** * Reads an export code: decodes, migrates an older `saveVersion` and validates. * Never throws; anything that isn't a good save gives a readable error. */ export async function decodeSave( code: string, migrate: (raw: unknown) => SaveParse = migrateSave, ): Promise { // Pasting often adds spaces or line breaks. const text = code.replace(/\s+/g, ""); if (!text.startsWith(SAVE_CODE_PREFIX)) { return { ok: false, error: "That isn't a Deadhead save code." }; } let raw: unknown; try { const bytes = await pipe( fromBase64Url(text.slice(SAVE_CODE_PREFIX.length)), new DecompressionStream("gzip"), ); raw = JSON.parse(new TextDecoder().decode(bytes)); } catch { return { ok: false, error: "The save code is incomplete or damaged." }; } return migrate(raw); }