Save encoding (maybe compression, remains to be seen for bigger saves)

This commit is contained in:
Tobias Berger 2021-09-30 21:17:01 +02:00
parent 61b4f4b06d
commit 921e74fb88
4 changed files with 466 additions and 7 deletions

2
src/global.d.ts vendored
View file

@ -1,9 +1,11 @@
/// <reference types="svelte" /> /// <reference types="svelte" />
import type { LZString } from "./shark/LZString";
import type { SharkGame } from "./shark/SharkGame"; import type { SharkGame } from "./shark/SharkGame";
declare global { declare global {
interface Window { interface Window {
SharkGame: SharkGame; SharkGame: SharkGame;
LZString: LZString;
} }
} }

435
src/shark/LZString.ts Normal file
View file

@ -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<string, number> {
const dict: Record<string, number> = {};
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<string, number> = {};
const contextDictionaryToCreate: Record<string, boolean> = {};
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;

View file

@ -1,8 +1,8 @@
import type { Message, MessageType } from "./Message"; import type { Message, MessageType } from "./Message";
import type { Settings } from "./Settings";
import type { SharkGame } from "./SharkGame"; import type { SharkGame } from "./SharkGame";
import { StaticClass } from "./StaticClass"; import { StaticClass } from "./StaticClass";
import type { TabHandler } from "./TabHandler"; import type { TabHandler } from "./TabHandler";
import { LZString } from "./LZString";
const __EMPTY_OBJECT = {}; const __EMPTY_OBJECT = {};
type Version0Save = typeof __EMPTY_OBJECT; type Version0Save = typeof __EMPTY_OBJECT;
@ -13,7 +13,7 @@ type Version1Save = Version0Save & {
type: MessageType; type: MessageType;
}[]; }[];
selectedTab: keyof typeof TabHandler["AllTabs"]; selectedTab: keyof typeof TabHandler["AllTabs"];
settings: ReturnType<typeof Settings["getCurrent"]>; settings: Record<`${string};${string}`, unknown>;
}; };
type CurrentVersionSave = Version1Save; type CurrentVersionSave = Version1Save;
@ -40,10 +40,21 @@ export class SaveHandler extends StaticClass {
); );
const selectedTabName = allTabsEntries[selectedTabIndex][0]; 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 = { const save: CurrentVersionSave = {
version: 1, version: 1,
selectedTab: selectedTabName, selectedTab: selectedTabName,
settings: game.Settings.getCurrent(), settings: saveSettings,
messages: messages.map((message) => { messages: messages.map((message) => {
return { return {
message: message.message, 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); localStorage.setItem(SaveHandler.saveName, encodedSave);
} }
static async load(game: typeof SharkGame): Promise<unknown> { static async load(game: typeof SharkGame): Promise<unknown> {
const localSave = localStorage.getItem(SaveHandler.saveName); const localSave = LZString.decompressFromBase64(
localStorage.getItem(SaveHandler.saveName)
);
const loadedSave = JSON.parse(localSave ?? "{}"); const loadedSave = JSON.parse(localSave ?? "{}");
const saveVersion = loadedSave.version ?? 0; const saveVersion = loadedSave.version ?? 0;

View file

@ -162,12 +162,12 @@ export class Settings extends StaticClass {
static getSettings(): SettingsRecord { static getSettings(): SettingsRecord {
return Object.seal(Object.assign({}, Settings.#settings)); return Object.seal(Object.assign({}, Settings.#settings));
} }
static getCurrent(): Record<`${string};${string}`, Setting> { static getSaveable(): Record<`${string};${string}`, Setting> {
const current = this.getSettings(); const current = this.getSettings();
const result: Record<`${string};${string}`, Setting> = {}; const result: Record<`${string};${string}`, Setting> = {};
for (const [categoryName, category] of Object.entries(current)) { for (const [categoryName, category] of Object.entries(current)) {
for (const [settingName, setting] of Object.entries(category)) { for (const [settingName, setting] of Object.entries(category)) {
result[`${categoryName};${settingName}`] = setting.current; result[`${categoryName};${settingName}`] = setting;
} }
} }
return result; return result;