deadhead-space-freight-tycoon/packages/engine/src/save-code.ts

62 lines
2.1 KiB
TypeScript

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<Uint8Array> {
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<string> {
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<SaveParse> {
// 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);
}