From 921e74fb882b86aa8a54fc3a954d669596b669ce Mon Sep 17 00:00:00 2001 From: Tobias Berger Date: Thu, 30 Sep 2021 21:17:01 +0200 Subject: [PATCH] Save encoding (maybe compression, remains to be seen for bigger saves) --- src/global.d.ts | 2 + src/shark/LZString.ts | 435 +++++++++++++++++++++++++++++++++++++++ src/shark/SaveHandler.ts | 32 ++- src/shark/Settings.ts | 4 +- 4 files changed, 466 insertions(+), 7 deletions(-) create mode 100644 src/shark/LZString.ts diff --git a/src/global.d.ts b/src/global.d.ts index 74ad4c4..698febf 100644 --- a/src/global.d.ts +++ b/src/global.d.ts @@ -1,9 +1,11 @@ /// +import type { LZString } from "./shark/LZString"; import type { SharkGame } from "./shark/SharkGame"; declare global { interface Window { SharkGame: SharkGame; + LZString: LZString; } } diff --git a/src/shark/LZString.ts b/src/shark/LZString.ts new file mode 100644 index 0000000..ba57e7d --- /dev/null +++ b/src/shark/LZString.ts @@ -0,0 +1,435 @@ +import { StaticClass } from "./StaticClass"; + +export class LZString extends StaticClass { + static readonly #KeyStrBase64 = + "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/="; + + static readonly #KeyStrBase64Dict = LZString.#createBaseDict( + LZString.#KeyStrBase64 + ); + + static #createBaseDict(alphabet: string): Record { + const dict: Record = {}; + for (let i = 0; i < alphabet.length; i++) { + dict[alphabet[i]] = i; + } + return dict; + } + + static compressToBase64(input: string): string { + const result = LZString.#compress(input, 6, (a) => + LZString.#KeyStrBase64.charAt(a) + ); + return result + "=".repeat((4 - (result.length % 4)) % 4); + } + static decompressFromBase64(input: string | null): string | null { + if (input === null || input === "") return null; + return LZString.#decompress( + input.length, + 32, + (index) => LZString.#KeyStrBase64Dict[input.charAt(index)] + ); + } + + static compress(uncompressed: string): string { + return LZString.#compress(uncompressed); + } + static #compress(uncompressed: string): string; + static #compress( + uncompressed: string, + bitsPerChar: number, + getCharFromInt: (index: number) => string + ): string; + static #compress( + uncompressed: string, + bitsPerChar = 16, + getCharFromNumber = (code: number) => String.fromCharCode(code) + ): string { + const contextDictionary: Record = {}; + const contextDictionaryToCreate: Record = {}; + + let contextDictSize = 3; + let contextW = ""; + let contextEnlargeIn = 2; // Compensate for the first entry which should not count + let contextNumBits = 2; + let contextData = ""; + let contextDataVal = 0; + let contextDataPosition = 0; + + let value: number; + + for (const contextC of uncompressed) { + if (!Object.keys(contextDictionary).includes(contextC)) { + contextDictionary[contextC] = contextDictSize++; + contextDictionaryToCreate[contextC] = true; + } + + const contextWC = contextW + contextC; + if (Object.keys(contextDictionary).includes(contextWC)) { + contextW = contextWC; + } else { + if (Object.keys(contextDictionaryToCreate).includes(contextW)) { + if (contextW.charCodeAt(0) < 256) { + for (let i = 0; i < contextNumBits; i++) { + contextDataVal <<= 1; + if (contextDataPosition === bitsPerChar - 1) { + contextDataPosition = 0; + contextData += getCharFromNumber(contextDataVal); + contextDataVal = 0; + } else { + contextDataPosition++; + } + } + value = contextW.charCodeAt(0); + for (let i = 0; i < 8; i++) { + contextDataVal = (contextDataVal << 1) | (value & 1); + if (contextDataPosition === bitsPerChar - 1) { + contextDataPosition = 0; + contextData += getCharFromNumber(contextDataVal); + contextDataVal = 0; + } else { + contextDataPosition++; + } + value >>= 1; + } + } else { + value = 1; + for (let i = 0; i < contextNumBits; i++) { + contextDataVal = (contextDataVal << 1) | value; + if (contextDataPosition === bitsPerChar - 1) { + contextDataPosition = 0; + contextData += getCharFromNumber(contextDataVal); + contextDataVal = 0; + } else { + contextDataPosition++; + } + value = 0; + } + value = contextW.charCodeAt(0); + + for (let i = 0; i < 16; i++) { + contextDataVal = (contextDataVal << 1) | (value & 1); + if (contextDataPosition === bitsPerChar - 1) { + contextDataPosition = 0; + contextData += getCharFromNumber(contextDataVal); + contextDataVal = 0; + } else { + contextDataPosition++; + } + value >>= 1; + } + } + contextEnlargeIn--; + if (contextEnlargeIn === 0) { + contextEnlargeIn = 2 ** contextNumBits; + contextNumBits++; + } + delete contextDictionaryToCreate[contextW]; + } else { + value = contextDictionary[contextW]; + for (let i = 0; i < contextNumBits; i++) { + contextDataVal = (contextDataVal << 1) | (value & 1); + if (contextDataPosition === bitsPerChar - 1) { + contextDataPosition = 0; + contextData += getCharFromNumber(contextDataVal); + contextDataVal = 0; + } else { + contextDataPosition++; + } + value >>= 1; + } + } + contextEnlargeIn--; + if (contextEnlargeIn === 0) { + contextEnlargeIn = 2 ** contextNumBits; + contextNumBits++; + } + contextDictionary[contextWC] = contextDictSize++; + contextW = contextC; + } + } + + if (contextW !== "") { + if (Object.keys(contextDictionaryToCreate).includes(contextW)) { + if (contextW.charCodeAt(0) < 256) { + for (let i = 0; i < contextNumBits; i++) { + contextDataVal = contextDataVal << 1; + if (contextDataPosition === bitsPerChar - 1) { + contextDataPosition = 0; + contextData += getCharFromNumber(contextDataVal); + contextDataVal = 0; + } else { + contextDataPosition++; + } + } + value = contextW.charCodeAt(0); + for (let i = 0; i < 8; i++) { + contextDataVal = (contextDataVal << 1) | (value & 1); + if (contextDataPosition === bitsPerChar - 1) { + contextDataPosition = 0; + contextData += getCharFromNumber(contextDataVal); + contextDataVal = 0; + } else { + contextDataPosition++; + } + value >>= 1; + } + } else { + value = 1; + for (let i = 0; i < contextNumBits; i++) { + contextDataVal = (contextDataVal << 1) | value; + if (contextDataPosition === bitsPerChar - 1) { + contextDataPosition = 0; + contextData += getCharFromNumber(contextDataVal); + contextDataVal = 0; + } else { + contextDataPosition++; + } + value = 0; + } + + value = contextW.charCodeAt(0); + for (let i = 0; i < 16; i++) { + contextDataVal = (contextDataVal << 1) | (value & 1); + if (contextDataPosition === bitsPerChar - 1) { + contextDataPosition = 0; + contextData += getCharFromNumber(contextDataVal); + contextDataVal = 0; + } else { + contextDataPosition++; + } + value >>= 1; + } + } + contextEnlargeIn--; + if (contextEnlargeIn === 0) { + contextEnlargeIn = 2 ** contextNumBits; + contextNumBits++; + } + delete contextDictionaryToCreate[contextW]; + } else { + value = contextDictionary[contextW]; + for (let i = 0; i < contextNumBits; i++) { + contextDataVal = (contextDataVal << 1) | (value & 1); + if (contextDataPosition === bitsPerChar - 1) { + contextDataPosition = 0; + contextData += getCharFromNumber(contextDataVal); + contextDataVal = 0; + } else { + contextDataPosition++; + } + value >>= 1; + } + } + contextEnlargeIn--; + if (contextEnlargeIn === 0) { + contextNumBits++; + } + } + value = 2; + for (let i = 0; i < contextNumBits; i++) { + contextDataVal = (contextDataVal << 1) | (value & 1); + if (contextDataPosition === bitsPerChar - 1) { + contextDataPosition = 0; + contextData += getCharFromNumber(contextDataVal); + contextDataVal = 0; + } else { + contextDataPosition++; + } + value >>= 1; + } + + // eslint-disable-next-line no-constant-condition + while (true) { + contextDataVal <<= 1; + if (contextDataPosition === bitsPerChar - 1) { + contextData += getCharFromNumber(contextDataVal); + break; + } else { + contextDataPosition++; + } + } + + return contextData; + } + + static decompress(compressed: string | null): string | null { + if (compressed === null || compressed === "") return null; + return LZString.#decompress(compressed.length, 32768, (index) => + compressed.charCodeAt(index) + ); + } + static #decompress( + length: number, + resetValue: number, + getNextValue: (index: number) => number + ): string | null { + const dictionary: (number | string)[] = []; + + let enlargeIn = 4; + let dictSize = 4; + let numBits = 3; + let entry = ""; + const result = []; + + const data = { + val: getNextValue(0), + position: resetValue, + index: 1, + }; + + for (let i = 0; i < 3; i++) { + dictionary[i] = i; + } + + let bits = 0; + let maxPower = 2 ** 2; + let power = 1; + + let resb: number; + while (power !== maxPower) { + resb = data.val & data.position; + data.position >>= 1; + if (data.position === 0) { + data.position = resetValue; + data.val = getNextValue(data.index++); + } + bits |= resb > 0 ? power : 0; + power <<= 1; + } + + let c = ""; + switch (bits) { + case 0: + bits = 0; + maxPower = 2 ** 8; + power = 1; + while (power !== maxPower) { + resb = data.val & data.position; + data.position >>= 1; + if (data.position === 0) { + data.position = resetValue; + data.val = getNextValue(data.index++); + } + bits |= (resb > 0 ? 1 : 0) * power; + power <<= 1; + } + c = String.fromCharCode(bits); + break; + case 1: + bits = 0; + maxPower = 2 ** 16; + power = 1; + while (power != maxPower) { + resb = data.val & data.position; + data.position >>= 1; + if (data.position == 0) { + data.position = resetValue; + data.val = getNextValue(data.index++); + } + bits |= (resb > 0 ? 1 : 0) * power; + power <<= 1; + } + c = String.fromCharCode(bits); + break; + case 2: + return ""; + } + dictionary[3] = c; + let w = c; + result.push(c); + // eslint-disable-next-line no-constant-condition + while (true) { + if (data.index > length) { + return ""; + } + + bits = 0; + maxPower = 2 ** numBits; + power = 1; + + while (power !== maxPower) { + resb = data.val & data.position; + data.position >>= 1; + if (data.position === 0) { + data.position = resetValue; + data.val = getNextValue(data.index++); + } + bits |= resb > 0 ? power : 0; + power <<= 1; + } + + let c2; + switch ((c2 = bits)) { + case 0: + bits = 0; + maxPower = 2 ** 8; + power = 1; + while (power !== maxPower) { + resb = data.val & data.position; + data.position >>= 1; + if (data.position === 0) { + data.position = resetValue; + data.val = getNextValue(data.index++); + } + bits |= (resb > 0 ? 1 : 0) * power; + power <<= 1; + } + + dictionary[dictSize++] = String.fromCharCode(bits); + c2 = dictSize - 1; + enlargeIn--; + break; + case 1: + bits = 0; + maxPower = 2 ** 16; + power = 1; + while (power !== maxPower) { + resb = data.val & data.position; + data.position >>= 1; + if (data.position === 0) { + data.position = resetValue; + data.val = getNextValue(data.index++); + } + bits |= (resb > 0 ? 1 : 0) * power; + power <<= 1; + } + dictionary[dictSize++] = String.fromCharCode(bits); + c2 = dictSize - 1; + enlargeIn--; + break; + case 2: + return result.join(""); + } + + if (enlargeIn == 0) { + enlargeIn = Math.pow(2, numBits); + numBits++; + } + + if (dictionary[c2]) { + entry = dictionary[c2] as string; + } else { + if (c2 === dictSize) { + entry = w + w.charAt(0); + } else { + return null; + } + } + result.push(entry); + + // Add w+entry[0] to the dictionary. + dictionary[dictSize++] = w + entry.charAt(0); + enlargeIn--; + + w = entry; + + if (enlargeIn == 0) { + enlargeIn = Math.pow(2, numBits); + numBits++; + } + } + } +} + +window.LZString = LZString; diff --git a/src/shark/SaveHandler.ts b/src/shark/SaveHandler.ts index afb146d..86d9fbb 100644 --- a/src/shark/SaveHandler.ts +++ b/src/shark/SaveHandler.ts @@ -1,8 +1,8 @@ import type { Message, MessageType } from "./Message"; -import type { Settings } from "./Settings"; import type { SharkGame } from "./SharkGame"; import { StaticClass } from "./StaticClass"; import type { TabHandler } from "./TabHandler"; +import { LZString } from "./LZString"; const __EMPTY_OBJECT = {}; type Version0Save = typeof __EMPTY_OBJECT; @@ -13,7 +13,7 @@ type Version1Save = Version0Save & { type: MessageType; }[]; selectedTab: keyof typeof TabHandler["AllTabs"]; - settings: ReturnType; + settings: Record<`${string};${string}`, unknown>; }; type CurrentVersionSave = Version1Save; @@ -40,10 +40,21 @@ export class SaveHandler extends StaticClass { ); const selectedTabName = allTabsEntries[selectedTabIndex][0]; + const currentSettings = game.Settings.getSaveable(); + const saveSettings: CurrentVersionSave["settings"] = {}; + for (const [settingId, setting] of Object.entries(currentSettings) as [ + keyof typeof currentSettings, + typeof currentSettings[keyof typeof currentSettings] + ][]) { + if (setting.current !== setting.default) { + saveSettings[settingId] = setting.current; + } + } + const save: CurrentVersionSave = { version: 1, selectedTab: selectedTabName, - settings: game.Settings.getCurrent(), + settings: saveSettings, messages: messages.map((message) => { return { message: message.message, @@ -51,13 +62,24 @@ export class SaveHandler extends StaticClass { }; }), }; - const encodedSave = JSON.stringify(save); + const stringifiedSave = JSON.stringify(save); + const encodedSave = LZString.compressToBase64(stringifiedSave); + console.debug( + `${new Date( + Date.now() + ).toISOString()} - saving ${stringifiedSave} - encoded to ${ + Math.round((encodedSave.length / stringifiedSave.length) * 100 * 100) / + 100 + }% size` + ); localStorage.setItem(SaveHandler.saveName, encodedSave); } static async load(game: typeof SharkGame): Promise { - const localSave = localStorage.getItem(SaveHandler.saveName); + const localSave = LZString.decompressFromBase64( + localStorage.getItem(SaveHandler.saveName) + ); const loadedSave = JSON.parse(localSave ?? "{}"); const saveVersion = loadedSave.version ?? 0; diff --git a/src/shark/Settings.ts b/src/shark/Settings.ts index ac8b8a2..cad8433 100644 --- a/src/shark/Settings.ts +++ b/src/shark/Settings.ts @@ -162,12 +162,12 @@ export class Settings extends StaticClass { static getSettings(): SettingsRecord { return Object.seal(Object.assign({}, Settings.#settings)); } - static getCurrent(): Record<`${string};${string}`, Setting> { + static getSaveable(): Record<`${string};${string}`, Setting> { const current = this.getSettings(); const result: Record<`${string};${string}`, Setting> = {}; for (const [categoryName, category] of Object.entries(current)) { for (const [settingName, setting] of Object.entries(category)) { - result[`${categoryName};${settingName}`] = setting.current; + result[`${categoryName};${settingName}`] = setting; } } return result;