"use strict"; (function (global) { const STORAGE_PREFIX = "tarinai_save_slot_v1_"; const EXPORT_PREFIX = "TARINAI_SAVE_V1:"; const EXPORT_PREFIX_COMPRESSED = "TARINAI-SAVE-V11:"; // Alpha note: save/import compatibility is not guaranteed yet. // Hash imports intentionally accept only the current V11 format during alpha. // V11 stores compact semantic early-game saves when possible, falls back to the V10 binary packet for complex states, and encodes bytes with printable ASCII excluding \, *, ' and _. const SLOT_COUNT = 9; const Snapshot = global.TarinaiSnapshot; if (!Snapshot) throw new Error("TarinaiSnapshot is not available for save_system.js"); const createSnapshot = (...args) => Snapshot.createSnapshot(...args); const restoreSnapshot = (...args) => Snapshot.restoreSnapshot(...args); const htmlEscape = global.TarinaiUIHelpers?.htmlEscape || ((v) => String(v ?? "").replace(/[&<>"]/g, c => ({ "&": "&", "<": "<", ">": ">", '"': """ }[c]))); const BASE94_ALPHABET = (() => { const excluded = new Set(["\\", "*", "'", "_"]); let out = ""; for (let i = 33; i <= 126; i++) { const ch = String.fromCharCode(i); if (!excluded.has(ch)) out += ch; } return out; })(); const BASE94_RADIX = BASE94_ALPHABET.length; const BASE94_DECODE = (() => { const map = Object.create(null); for (let i = 0; i < BASE94_ALPHABET.length; i++) map[BASE94_ALPHABET[i]] = i; return map; })(); function bytesToBase94(bytes) { if (!bytes || !bytes.length) return "!"; let zeros = 0; while (zeros < bytes.length && bytes[zeros] === 0) zeros++; const digits = [0]; for (let i = zeros; i < bytes.length; i++) { let carry = bytes[i]; for (let j = 0; j < digits.length; j++) { const x = digits[j] * 256 + carry; digits[j] = x % BASE94_RADIX; carry = Math.floor(x / BASE94_RADIX); } while (carry > 0) { digits.push(carry % BASE94_RADIX); carry = Math.floor(carry / BASE94_RADIX); } } let out = BASE94_ALPHABET[0].repeat(zeros); for (let i = digits.length - 1; i >= 0; i--) out += BASE94_ALPHABET[digits[i]]; return out || BASE94_ALPHABET[0]; } function base94ToBytes(text) { const str = String(text || "").replace(/\s/g, ""); if (!str) return new Uint8Array(); let zeros = 0; while (zeros < str.length && str[zeros] === BASE94_ALPHABET[0]) zeros++; const bytes = [0]; for (let i = zeros; i < str.length; i++) { const digit = BASE94_DECODE[str[i]]; if (digit === undefined) throw new Error("invalid base94 character"); let carry = digit; for (let j = 0; j < bytes.length; j++) { const x = bytes[j] * BASE94_RADIX + carry; bytes[j] = x & 255; carry = x >> 8; } while (carry > 0) { bytes.push(carry & 255); carry >>= 8; } } const out = new Uint8Array(zeros + bytes.length); for (let i = 0; i < zeros; i++) out[i] = 0; for (let i = 0; i < bytes.length; i++) out[out.length - 1 - i] = bytes[i]; return out; } function bytesToBase64(bytes) { let binary = ""; const chunk = 0x8000; for (let i = 0; i < bytes.length; i += chunk) binary += String.fromCharCode(...bytes.subarray(i, i + chunk)); return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/g, ""); } function stripExportPrefix(text, prefixes) { let value = String(text || "").trim(); const hashless = value.replace(/^#+/, ""); if (prefixes.some(prefix => hashless.startsWith(prefix))) value = hashless; for (const prefix of prefixes) { if (value.startsWith(prefix)) return value.slice(prefix.length); } return value; } function base64ToBytes(text) { let b64 = stripExportPrefix(text, [EXPORT_PREFIX]).replace(/-/g, "+").replace(/_/g, "/"); while (b64.length % 4) b64 += "="; const binary = atob(b64); const bytes = new Uint8Array(binary.length); for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i); return bytes; } async function gzipBytes(bytes) { if (typeof CompressionStream !== "function") return null; const stream = new Blob([bytes]).stream().pipeThrough(new CompressionStream("gzip")); return new Uint8Array(await new Response(stream).arrayBuffer()); } async function gunzipBytes(bytes) { if (typeof DecompressionStream !== "function") throw new Error("compressed import is not supported in this browser"); const stream = new Blob([bytes]).stream().pipeThrough(new DecompressionStream("gzip")); return new Uint8Array(await new Response(stream).arrayBuffer()); } function encodeSnapshotLegacy(snapshot) { const json = JSON.stringify(snapshot); const bytes = new TextEncoder().encode(json); return EXPORT_PREFIX + bytesToBase64(bytes); } async function encodeSnapshot(snapshot) { if (snapshot?.__tarinaiCompactBytes && snapshot.bytes) { const raw = snapshot.bytes instanceof Uint8Array ? snapshot.bytes : new Uint8Array(snapshot.bytes); const compressed = await gzipBytes(raw); if (compressed && compressed.length < raw.length) return EXPORT_PREFIX_COMPRESSED + "z" + bytesToBase94(compressed); return EXPORT_PREFIX_COMPRESSED + "r" + bytesToBase94(raw); } const json = JSON.stringify(snapshot); const bytes = new TextEncoder().encode(json); const compressed = await gzipBytes(bytes); if (compressed && compressed.length < bytes.length) return EXPORT_PREFIX_COMPRESSED + "z" + bytesToBase94(compressed); return EXPORT_PREFIX_COMPRESSED + "r" + bytesToBase94(bytes); } async function decodeSnapshot(text) { const raw = String(text || "").trim(); const normalized = raw.replace(/^#+/, ""); if (!normalized) throw new Error("empty import text"); if (!normalized.startsWith(EXPORT_PREFIX_COMPRESSED)) throw new Error("unsupported save hash version"); const body = stripExportPrefix(normalized, [EXPORT_PREFIX_COMPRESSED]); const mode = body[0] || "r"; const payload = body.slice(1); const bytes = base94ToBytes(payload); const packet = mode === "z" ? await gunzipBytes(bytes) : bytes; if (!Snapshot.decodeV11SnapshotBytes) throw new Error("V11 decoder is not available"); return Snapshot.decodeV11SnapshotBytes(packet); } function slotKey(slot) { return `${STORAGE_PREFIX}${slot}`; } function makeThumbnail() { const source = global.canvas || document.getElementById("gameCanvas"); if (!source || !source.width || !source.height) return ""; try { const tw = 168; const th = 96; const c = document.createElement("canvas"); c.width = tw; c.height = th; const ctx = c.getContext("2d", { alpha: false }); if (!ctx) return ""; ctx.fillStyle = "#f6efe3"; ctx.fillRect(0, 0, tw, th); const scale = Math.min(tw / source.width, th / source.height); const w = Math.max(1, Math.round(source.width * scale)); const h = Math.max(1, Math.round(source.height * scale)); const x = Math.floor((tw - w) / 2); const y = Math.floor((th - h) / 2); ctx.imageSmoothingEnabled = true; ctx.drawImage(source, x, y, w, h); return c.toDataURL("image/jpeg", 0.58); } catch (_) { return ""; } } function makeSaveSnapshot() { const snapshot = createSnapshot(global.world); snapshot.thumbnail = makeThumbnail(); return snapshot; } function saveSlot(slot) { const snapshot = makeSaveSnapshot(); localStorage.setItem(slotKey(slot), JSON.stringify(snapshot)); return snapshot; } function loadSlot(slot) { const raw = localStorage.getItem(slotKey(slot)); if (!raw) throw new Error("empty slot"); return restoreSnapshot(JSON.parse(raw), global.world); } function deleteSlot(slot) { localStorage.removeItem(slotKey(slot)); } function readSlot(slot) { const raw = localStorage.getItem(slotKey(slot)); if (!raw) return null; try { return JSON.parse(raw); } catch (_) { return null; } } function formatSlotSummary(snapshot) { if (!snapshot) return "空き"; const d = snapshot.summary || {}; const date = snapshot.createdAt ? new Date(snapshot.createdAt).toLocaleString("ja-JP", { month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit" }) : "日時不明"; return `${date}\n${d.fieldType || "庭"} / ${d.day || 1}日目 ${d.time || ""}\n${d.population || 0}匹 / 道具${d.items || 0}`; } function ensureDialog() { let dialog = document.getElementById("saveDialog"); if (dialog) return dialog; dialog = document.createElement("div"); dialog.id = "saveDialog"; dialog.className = "field-dialog hidden"; dialog.setAttribute("role", "dialog"); dialog.setAttribute("aria-modal", "true"); dialog.innerHTML = `
9つのスロットに現在のコロニーを保存できます。ハッシュテキストは圧縮して短く書き出します。