tarinai/js/save_codec.js
2026-06-25 14:51:58 +09:00

139 lines
4.2 KiB
JavaScript

"use strict";
(function (global) {
const Snapshot = global.TarinaiSnapshot;
if (!Snapshot) throw new Error("TarinaiSnapshot is not available for save_codec.js");
const EXPORT_PREFIX = "TN3!";
const SAVE_TEXT_ALPHABET = (() => {
let out = "";
for (let i = 33; i <= 126; i++) {
const ch = String.fromCharCode(i);
if (ch === "*" || ch === "'" || ch === "_") continue;
out += ch;
}
return out;
})();
const SAVE_TEXT_REVERSE = (() => {
const map = new Map();
for (let i = 0; i < SAVE_TEXT_ALPHABET.length; i++) map.set(SAVE_TEXT_ALPHABET[i], i);
return map;
})();
if (SAVE_TEXT_ALPHABET.length !== 91) throw new Error("save alphabet must contain 91 printable ASCII chars");
function base91Encode(bytes) {
let b = 0;
let n = 0;
let out = "";
for (const byte of bytes || []) {
b |= (byte & 255) << n;
n += 8;
if (n > 13) {
let v = b & 8191;
if (v > 88) {
b >>= 13;
n -= 13;
} else {
v = b & 16383;
b >>= 14;
n -= 14;
}
out += SAVE_TEXT_ALPHABET[v % 91] + SAVE_TEXT_ALPHABET[Math.floor(v / 91)];
}
}
if (n) {
out += SAVE_TEXT_ALPHABET[b % 91];
if (n > 7 || b > 90) out += SAVE_TEXT_ALPHABET[Math.floor(b / 91)];
}
return out;
}
function base91Decode(text) {
const clean = String(text || "").trim();
let v = -1;
let b = 0;
let n = 0;
const out = [];
for (const ch of clean) {
const c = SAVE_TEXT_REVERSE.get(ch);
if (c == null) continue;
if (v < 0) {
v = c;
} else {
v += c * 91;
b |= v << n;
n += (v & 8191) > 88 ? 13 : 14;
do {
out.push(b & 255);
b >>= 8;
n -= 8;
} while (n > 7);
v = -1;
}
}
if (v >= 0) out.push((b | (v << n)) & 255);
return new Uint8Array(out);
}
async function compressBytes(bytes) {
if (typeof CompressionStream === "function") {
const stream = new Blob([bytes]).stream().pipeThrough(new CompressionStream("deflate-raw"));
return new Uint8Array(await new Response(stream).arrayBuffer());
}
return bytes;
}
async function decompressBytes(bytes, codec = "R") {
if (codec === "D") {
if (typeof DecompressionStream !== "function") throw new Error("deflate decoder is not available in this browser");
const stream = new Blob([bytes]).stream().pipeThrough(new DecompressionStream("deflate-raw"));
return new Uint8Array(await new Response(stream).arrayBuffer());
}
return bytes;
}
async function encodeSnapshot(snapshot) {
if (!snapshot || snapshot.v !== Snapshot.version) throw new Error("snapshot version mismatch");
const json = JSON.stringify(snapshot);
const bytes = new TextEncoder().encode(json);
let codec = "R";
let packed = bytes;
try {
const compressed = await compressBytes(bytes);
if (compressed.length < bytes.length) {
codec = "D";
packed = compressed;
}
} catch (error) {
console.warn("save compression fallback", error);
}
return `${EXPORT_PREFIX}${codec}${base91Encode(packed)}`;
}
async function decodeSnapshot(text) {
const raw = String(text || "").trim();
if (!raw) throw new Error("empty import text");
if (raw.startsWith("{") || raw.startsWith("[")) {
const data = JSON.parse(raw);
if (data?.v !== Snapshot.version) throw new Error("unsupported save version");
return data;
}
if (!raw.startsWith(EXPORT_PREFIX)) throw new Error("unsupported save text");
const codec = raw.charAt(EXPORT_PREFIX.length) || "R";
const payload = raw.slice(EXPORT_PREFIX.length + 1);
const packed = base91Decode(payload);
const bytes = await decompressBytes(packed, codec);
const json = new TextDecoder().decode(bytes);
const data = JSON.parse(json);
if (data?.v !== Snapshot.version) throw new Error("unsupported save version");
return data;
}
global.TarinaiSaveCodec = {
EXPORT_PREFIX,
SAVE_TEXT_ALPHABET,
base91Encode,
base91Decode,
encodeSnapshot,
decodeSnapshot,
};
})(typeof window !== "undefined" ? window : globalThis);