mirror of
https://github.com/tobspr/shapez.io.git
synced 2025-12-11 09:11:50 +00:00
Merge pull request #69 from tobspr-games/dengr1065/new-savegame-storage
Implement gzipped MessagePack savegame storage
This commit is contained in:
commit
2b890466b0
10
electron/package-lock.json
generated
10
electron/package-lock.json
generated
@ -9,6 +9,7 @@
|
|||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@msgpack/msgpack": "^3.1.1",
|
||||||
"semver": "^7.7.1",
|
"semver": "^7.7.1",
|
||||||
"zod": "^3.24.2"
|
"zod": "^3.24.2"
|
||||||
},
|
},
|
||||||
@ -50,6 +51,15 @@
|
|||||||
"semver": "bin/semver.js"
|
"semver": "bin/semver.js"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@msgpack/msgpack": {
|
||||||
|
"version": "3.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@msgpack/msgpack/-/msgpack-3.1.1.tgz",
|
||||||
|
"integrity": "sha512-DnBpqkMOUGayNVKyTLlkM6ILmU/m/+VUxGkuQlPQVAcvreLz5jn1OlQnWd8uHKL/ZSiljpM12rjRhr51VtvJUQ==",
|
||||||
|
"license": "ISC",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 18"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@sindresorhus/is": {
|
"node_modules/@sindresorhus/is": {
|
||||||
"version": "4.6.0",
|
"version": "4.6.0",
|
||||||
"resolved": "https://registry.npmjs.org/@sindresorhus/is/-/is-4.6.0.tgz",
|
"resolved": "https://registry.npmjs.org/@sindresorhus/is/-/is-4.6.0.tgz",
|
||||||
|
|||||||
@ -9,6 +9,7 @@
|
|||||||
"start": "tsc && electron ."
|
"start": "tsc && electron ."
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@msgpack/msgpack": "^3.1.1",
|
||||||
"semver": "^7.7.1",
|
"semver": "^7.7.1",
|
||||||
"zod": "^3.24.2"
|
"zod": "^3.24.2"
|
||||||
},
|
},
|
||||||
|
|||||||
@ -1,68 +1,129 @@
|
|||||||
|
import { BrowserWindow, dialog, FileFilter } from "electron";
|
||||||
import fs from "fs/promises";
|
import fs from "fs/promises";
|
||||||
import path from "path";
|
import path from "path";
|
||||||
import { userData } from "./config.js";
|
import { userData } from "./config.js";
|
||||||
|
import { StorageInterface } from "./storage/interface.js";
|
||||||
|
|
||||||
interface GenericFsJob {
|
interface GenericFsJob {
|
||||||
filename: string;
|
id: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
type ListFsJob = GenericFsJob & { type: "list" };
|
export type InitializeFsJob = GenericFsJob & { type: "initialize" };
|
||||||
type ReadFsJob = GenericFsJob & { type: "read" };
|
type ListFsJob = GenericFsJob & { type: "list"; filename: string };
|
||||||
type WriteFsJob = GenericFsJob & { type: "write"; contents: string };
|
type ReadFsJob = GenericFsJob & { type: "read"; filename: string };
|
||||||
type DeleteFsJob = GenericFsJob & { type: "delete" };
|
type WriteFsJob<T> = GenericFsJob & { type: "write"; filename: string; contents: T };
|
||||||
|
type DeleteFsJob = GenericFsJob & { type: "delete"; filename: string };
|
||||||
|
|
||||||
export type FsJob = ListFsJob | ReadFsJob | WriteFsJob | DeleteFsJob;
|
type OpenExternalFsJob = GenericFsJob & { type: "open-external"; extension: string };
|
||||||
type FsJobResult = string | string[] | void;
|
type SaveExternalFsJob<T> = GenericFsJob & { type: "save-external"; filename: string; contents: T };
|
||||||
|
|
||||||
export class FsJobHandler {
|
export type FsJob<T> =
|
||||||
|
| InitializeFsJob
|
||||||
|
| ListFsJob
|
||||||
|
| ReadFsJob
|
||||||
|
| WriteFsJob<T>
|
||||||
|
| DeleteFsJob
|
||||||
|
| OpenExternalFsJob
|
||||||
|
| SaveExternalFsJob<T>;
|
||||||
|
type FsJobResult<T> = T | string[] | void;
|
||||||
|
|
||||||
|
export class FsJobHandler<T> {
|
||||||
readonly rootDir: string;
|
readonly rootDir: string;
|
||||||
|
private readonly storage: StorageInterface<T>;
|
||||||
|
private initialized = false;
|
||||||
|
|
||||||
constructor(subDir: string) {
|
constructor(subDir: string, storage: StorageInterface<T>) {
|
||||||
this.rootDir = path.join(userData, subDir);
|
this.rootDir = path.join(userData, subDir);
|
||||||
|
this.storage = storage;
|
||||||
}
|
}
|
||||||
|
|
||||||
handleJob(job: FsJob): Promise<FsJobResult> {
|
async initialize(): Promise<void> {
|
||||||
|
if (this.initialized) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create the directory so that users know where to put files
|
||||||
|
await fs.mkdir(this.rootDir, { recursive: true });
|
||||||
|
this.initialized = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
handleJob(job: FsJob<T>): Promise<FsJobResult<T>> {
|
||||||
|
switch (job.type) {
|
||||||
|
case "initialize":
|
||||||
|
return this.initialize();
|
||||||
|
case "open-external":
|
||||||
|
return this.openExternal(job.extension);
|
||||||
|
case "save-external":
|
||||||
|
return this.saveExternal(job.filename, job.contents);
|
||||||
|
}
|
||||||
|
|
||||||
const filename = this.safeFileName(job.filename);
|
const filename = this.safeFileName(job.filename);
|
||||||
|
|
||||||
switch (job.type) {
|
switch (job.type) {
|
||||||
case "list":
|
case "list":
|
||||||
return this.list(filename);
|
return this.list(filename);
|
||||||
case "read":
|
case "read":
|
||||||
return this.read(filename);
|
return this.storage.read(filename);
|
||||||
case "write":
|
case "write":
|
||||||
return this.write(filename, job.contents);
|
return this.write(filename, job.contents);
|
||||||
case "delete":
|
case "delete":
|
||||||
return this.delete(filename);
|
return this.storage.delete(filename);
|
||||||
}
|
}
|
||||||
|
|
||||||
// @ts-expect-error this method can actually receive garbage
|
// @ts-expect-error this method can actually receive garbage
|
||||||
throw new Error(`Unknown FS job type: ${job.type}`);
|
throw new Error(`Unknown FS job type: ${job.type}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async openExternal(extension: string): Promise<T | undefined> {
|
||||||
|
const filters = this.getFileDialogFilters(extension === "*" ? undefined : extension);
|
||||||
|
const window = BrowserWindow.getAllWindows()[0]!;
|
||||||
|
|
||||||
|
const result = await dialog.showOpenDialog(window, { filters, properties: ["openFile"] });
|
||||||
|
if (result.canceled) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
return await this.storage.read(result.filePaths[0]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async saveExternal(filename: string, contents: T): Promise<void> {
|
||||||
|
// Try to guess extension
|
||||||
|
const ext = filename.indexOf(".") < 1 ? filename.split(".").at(-1)! : undefined;
|
||||||
|
const filters = this.getFileDialogFilters(ext);
|
||||||
|
const window = BrowserWindow.getAllWindows()[0]!;
|
||||||
|
|
||||||
|
const result = await dialog.showSaveDialog(window, { defaultPath: filename, filters });
|
||||||
|
if (result.canceled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
return await this.storage.write(result.filePath, contents);
|
||||||
|
}
|
||||||
|
|
||||||
|
private getFileDialogFilters(extension?: string): FileFilter[] {
|
||||||
|
const filters: FileFilter[] = [{ name: "All files", extensions: ["*"] }];
|
||||||
|
|
||||||
|
if (extension !== undefined) {
|
||||||
|
filters.unshift({
|
||||||
|
name: `${extension.toUpperCase()} files`,
|
||||||
|
extensions: [extension],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return filters;
|
||||||
|
}
|
||||||
|
|
||||||
private list(subdir: string): Promise<string[]> {
|
private list(subdir: string): Promise<string[]> {
|
||||||
// Bare-bones implementation
|
// Bare-bones implementation
|
||||||
return fs.readdir(subdir);
|
return fs.readdir(subdir);
|
||||||
}
|
}
|
||||||
|
|
||||||
private read(file: string): Promise<string> {
|
private async write(file: string, contents: T): Promise<void> {
|
||||||
return fs.readFile(file, "utf-8");
|
|
||||||
}
|
|
||||||
|
|
||||||
private async write(file: string, contents: string): Promise<string> {
|
|
||||||
// The target directory might not exist, ensure it does
|
// The target directory might not exist, ensure it does
|
||||||
const parentDir = path.dirname(file);
|
const parentDir = path.dirname(file);
|
||||||
await fs.mkdir(parentDir, { recursive: true });
|
await fs.mkdir(parentDir, { recursive: true });
|
||||||
|
|
||||||
// Backups not implemented yet.
|
await this.storage.write(file, contents);
|
||||||
await fs.writeFile(file, contents, {
|
|
||||||
encoding: "utf-8",
|
|
||||||
flush: true,
|
|
||||||
});
|
|
||||||
return contents;
|
|
||||||
}
|
|
||||||
|
|
||||||
private delete(file: string): Promise<void> {
|
|
||||||
return fs.unlink(file);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private safeFileName(name: string) {
|
private safeFileName(name: string) {
|
||||||
|
|||||||
@ -1,7 +1,6 @@
|
|||||||
import { BrowserWindow, app, shell } from "electron";
|
import { BrowserWindow, app, shell } from "electron";
|
||||||
import path from "path";
|
import path from "path";
|
||||||
import { defaultWindowTitle, pageUrl, switches } from "./config.js";
|
import { defaultWindowTitle, pageUrl, switches } from "./config.js";
|
||||||
import { FsJobHandler } from "./fsjob.js";
|
|
||||||
import { IpcHandler } from "./ipc.js";
|
import { IpcHandler } from "./ipc.js";
|
||||||
import { ModLoader } from "./mods/loader.js";
|
import { ModLoader } from "./mods/loader.js";
|
||||||
import { ModProtocolHandler } from "./mods/protocol_handler.js";
|
import { ModProtocolHandler } from "./mods/protocol_handler.js";
|
||||||
@ -20,15 +19,9 @@ if (!app.requestSingleInstanceLock()) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO: Implement a redirector/advanced storage system
|
|
||||||
// Let mods have own data directories with easy access and
|
|
||||||
// split savegames/configs - only implement backups and gzip
|
|
||||||
// files if requested. Perhaps, use streaming to make large
|
|
||||||
// transfers less "blocking"
|
|
||||||
const fsJob = new FsJobHandler("saves");
|
|
||||||
const modLoader = new ModLoader();
|
const modLoader = new ModLoader();
|
||||||
const modProtocol = new ModProtocolHandler(modLoader);
|
const modProtocol = new ModProtocolHandler(modLoader);
|
||||||
const ipc = new IpcHandler(fsJob, modLoader);
|
const ipc = new IpcHandler(modLoader);
|
||||||
|
|
||||||
function createWindow() {
|
function createWindow() {
|
||||||
// The protocol can only be handled after "ready" event
|
// The protocol can only be handled after "ready" event
|
||||||
|
|||||||
@ -1,13 +1,13 @@
|
|||||||
import { BrowserWindow, IpcMainInvokeEvent, ipcMain } from "electron";
|
import { BrowserWindow, IpcMainInvokeEvent, ipcMain } from "electron";
|
||||||
import { FsJob, FsJobHandler } from "./fsjob.js";
|
import { FsJob, FsJobHandler } from "./fsjob.js";
|
||||||
import { ModLoader } from "./mods/loader.js";
|
import { ModLoader } from "./mods/loader.js";
|
||||||
|
import { SavesStorage } from "./storage/saves.js";
|
||||||
|
|
||||||
export class IpcHandler {
|
export class IpcHandler {
|
||||||
private readonly fsJob: FsJobHandler;
|
private readonly savesHandler = new FsJobHandler("saves", new SavesStorage());
|
||||||
private readonly modLoader: ModLoader;
|
private readonly modLoader: ModLoader;
|
||||||
|
|
||||||
constructor(fsJob: FsJobHandler, modLoader: ModLoader) {
|
constructor(modLoader: ModLoader) {
|
||||||
this.fsJob = fsJob;
|
|
||||||
this.modLoader = modLoader;
|
this.modLoader = modLoader;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -20,8 +20,12 @@ export class IpcHandler {
|
|||||||
// ipcMain.handle("open-mods-folder", ...)
|
// ipcMain.handle("open-mods-folder", ...)
|
||||||
}
|
}
|
||||||
|
|
||||||
private handleFsJob(_event: IpcMainInvokeEvent, job: FsJob) {
|
private handleFsJob(_event: IpcMainInvokeEvent, job: FsJob<unknown>) {
|
||||||
return this.fsJob.handleJob(job);
|
if (job.id !== "saves") {
|
||||||
|
throw new Error("Storages other than saves/ are not implemented yet");
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.savesHandler.handleJob(job);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async getMods() {
|
private async getMods() {
|
||||||
|
|||||||
5
electron/src/storage/interface.ts
Normal file
5
electron/src/storage/interface.ts
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
export interface StorageInterface<T> {
|
||||||
|
read(file: string): Promise<T>;
|
||||||
|
write(file: string, contents: T): Promise<void>;
|
||||||
|
delete(file: string): Promise<void>;
|
||||||
|
}
|
||||||
16
electron/src/storage/raw.ts
Normal file
16
electron/src/storage/raw.ts
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
import fs from "node:fs/promises";
|
||||||
|
import { StorageInterface } from "./interface.js";
|
||||||
|
|
||||||
|
export class RawStorage implements StorageInterface<string> {
|
||||||
|
read(file: string): Promise<string> {
|
||||||
|
return fs.readFile(file, "utf-8");
|
||||||
|
}
|
||||||
|
|
||||||
|
write(file: string, contents: string): Promise<void> {
|
||||||
|
return fs.writeFile(file, contents, "utf-8");
|
||||||
|
}
|
||||||
|
|
||||||
|
delete(file: string): Promise<void> {
|
||||||
|
return fs.unlink(file);
|
||||||
|
}
|
||||||
|
}
|
||||||
54
electron/src/storage/saves.ts
Normal file
54
electron/src/storage/saves.ts
Normal file
@ -0,0 +1,54 @@
|
|||||||
|
import { decodeAsync, encode } from "@msgpack/msgpack";
|
||||||
|
import fs from "node:fs";
|
||||||
|
import { pipeline } from "node:stream/promises";
|
||||||
|
import { createGunzip, createGzip } from "node:zlib";
|
||||||
|
import { StorageInterface } from "./interface.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This storage implementation is used for savegame files and other
|
||||||
|
* ReadWriteProxy objects. It uses gzipped MessagePack as the file format.
|
||||||
|
*/
|
||||||
|
export class SavesStorage implements StorageInterface<unknown> {
|
||||||
|
async read(file: string): Promise<unknown> {
|
||||||
|
const stream = fs.createReadStream(file);
|
||||||
|
const gunzip = createGunzip();
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Any filesystem errors will be uncovered here. This code ensures we return the most
|
||||||
|
// relevant rejection, or resolve with the decoded data
|
||||||
|
const [readResult, decodeResult] = await Promise.allSettled([
|
||||||
|
pipeline(stream, gunzip),
|
||||||
|
decodeAsync(gunzip),
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (decodeResult.status === "fulfilled") {
|
||||||
|
return decodeResult.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return the most relevant error
|
||||||
|
throw readResult.status === "rejected" ? readResult.reason : decodeResult.reason;
|
||||||
|
} finally {
|
||||||
|
stream.close();
|
||||||
|
gunzip.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async write(file: string, contents: unknown): Promise<void> {
|
||||||
|
const stream = fs.createWriteStream(file);
|
||||||
|
const gzip = createGzip();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const encoded = encode(contents);
|
||||||
|
const blob = new Blob([encoded]);
|
||||||
|
|
||||||
|
return await pipeline(blob.stream(), gzip, stream);
|
||||||
|
} finally {
|
||||||
|
gzip.close();
|
||||||
|
stream.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
delete(file: string): Promise<void> {
|
||||||
|
return fs.promises.unlink(file);
|
||||||
|
}
|
||||||
|
}
|
||||||
13
package-lock.json
generated
13
package-lock.json
generated
@ -12,7 +12,6 @@
|
|||||||
"ajv": "^6.10.2",
|
"ajv": "^6.10.2",
|
||||||
"circular-json": "^0.5.9",
|
"circular-json": "^0.5.9",
|
||||||
"clipboard-copy": "^3.1.0",
|
"clipboard-copy": "^3.1.0",
|
||||||
"crc": "^3.8.0",
|
|
||||||
"debounce-promise": "^3.1.2",
|
"debounce-promise": "^3.1.2",
|
||||||
"howler": "^2.1.2",
|
"howler": "^2.1.2",
|
||||||
"lz-string": "^1.4.4"
|
"lz-string": "^1.4.4"
|
||||||
@ -2434,6 +2433,7 @@
|
|||||||
"version": "1.5.1",
|
"version": "1.5.1",
|
||||||
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
|
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
|
||||||
"integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==",
|
"integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==",
|
||||||
|
"dev": true,
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
"type": "github",
|
"type": "github",
|
||||||
@ -3253,6 +3253,7 @@
|
|||||||
"version": "5.7.1",
|
"version": "5.7.1",
|
||||||
"resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz",
|
"resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz",
|
||||||
"integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==",
|
"integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==",
|
||||||
|
"dev": true,
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
"type": "github",
|
"type": "github",
|
||||||
@ -4363,15 +4364,6 @@
|
|||||||
"node": ">=4"
|
"node": ">=4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/crc": {
|
|
||||||
"version": "3.8.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/crc/-/crc-3.8.0.tgz",
|
|
||||||
"integrity": "sha512-iX3mfgcTMIq3ZKLIsVFAbv7+Mc10kxabAGQb8HvjA1o3T1PIYprbakQ65d3I+2HGHt6nSKkM9PYjgoJO2KcFBQ==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"buffer": "^5.1.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/cross-spawn": {
|
"node_modules/cross-spawn": {
|
||||||
"version": "6.0.5",
|
"version": "6.0.5",
|
||||||
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-6.0.5.tgz",
|
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-6.0.5.tgz",
|
||||||
@ -9937,6 +9929,7 @@
|
|||||||
"version": "1.2.1",
|
"version": "1.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz",
|
||||||
"integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==",
|
"integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==",
|
||||||
|
"dev": true,
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
"type": "github",
|
"type": "github",
|
||||||
|
|||||||
@ -24,7 +24,6 @@
|
|||||||
"ajv": "^6.10.2",
|
"ajv": "^6.10.2",
|
||||||
"circular-json": "^0.5.9",
|
"circular-json": "^0.5.9",
|
||||||
"clipboard-copy": "^3.1.0",
|
"clipboard-copy": "^3.1.0",
|
||||||
"crc": "^3.8.0",
|
|
||||||
"debounce-promise": "^3.1.2",
|
"debounce-promise": "^3.1.2",
|
||||||
"howler": "^2.1.2",
|
"howler": "^2.1.2",
|
||||||
"lz-string": "^1.4.4"
|
"lz-string": "^1.4.4"
|
||||||
|
|||||||
@ -13,7 +13,7 @@ import { MOD_SIGNALS } from "./mods/mod_signals";
|
|||||||
import { MODS } from "./mods/modloader";
|
import { MODS } from "./mods/modloader";
|
||||||
import { ClientAPI } from "./platform/api";
|
import { ClientAPI } from "./platform/api";
|
||||||
import { Sound } from "./platform/sound";
|
import { Sound } from "./platform/sound";
|
||||||
import { Storage } from "./platform/storage";
|
import { Storage, STORAGE_SAVES } from "./platform/storage";
|
||||||
import { PlatformWrapperImplElectron } from "./platform/wrapper";
|
import { PlatformWrapperImplElectron } from "./platform/wrapper";
|
||||||
import { ApplicationSettings } from "./profile/application_settings";
|
import { ApplicationSettings } from "./profile/application_settings";
|
||||||
import { SavegameManager } from "./savegame/savegame_manager";
|
import { SavegameManager } from "./savegame/savegame_manager";
|
||||||
@ -54,21 +54,23 @@ export class Application {
|
|||||||
|
|
||||||
this.unloaded = false;
|
this.unloaded = false;
|
||||||
|
|
||||||
|
// Platform stuff
|
||||||
|
this.storage = new Storage(this, STORAGE_SAVES);
|
||||||
|
await this.storage.initialize();
|
||||||
|
|
||||||
|
this.platformWrapper = new PlatformWrapperImplElectron(this);
|
||||||
|
|
||||||
// Global stuff
|
// Global stuff
|
||||||
this.settings = new ApplicationSettings(this);
|
this.settings = new ApplicationSettings(this, this.storage);
|
||||||
this.ticker = new AnimationFrame();
|
this.ticker = new AnimationFrame();
|
||||||
this.stateMgr = new StateManager(this);
|
this.stateMgr = new StateManager(this);
|
||||||
this.savegameMgr = new SavegameManager(this);
|
// NOTE: SavegameManager uses the passed storage, but savegames always
|
||||||
|
// use Application#storage
|
||||||
|
this.savegameMgr = new SavegameManager(this, this.storage);
|
||||||
this.inputMgr = new InputDistributor(this);
|
this.inputMgr = new InputDistributor(this);
|
||||||
this.backgroundResourceLoader = new BackgroundResourcesLoader(this);
|
this.backgroundResourceLoader = new BackgroundResourcesLoader(this);
|
||||||
this.clientApi = new ClientAPI(this);
|
this.clientApi = new ClientAPI(this);
|
||||||
|
|
||||||
// Platform dependent stuff
|
|
||||||
|
|
||||||
this.storage = new Storage(this);
|
|
||||||
|
|
||||||
this.platformWrapper = new PlatformWrapperImplElectron(this);
|
|
||||||
|
|
||||||
this.sound = new Sound(this);
|
this.sound = new Sound(this);
|
||||||
|
|
||||||
// Track if the window is focused (only relevant for browser)
|
// Track if the window is focused (only relevant for browser)
|
||||||
|
|||||||
@ -1,101 +0,0 @@
|
|||||||
// @ts-expect-error FIXME: missing typings
|
|
||||||
import CompressionWorker from "../webworkers/compression.worker";
|
|
||||||
|
|
||||||
import { createLogger } from "./logging";
|
|
||||||
import { round2Digits } from "./utils";
|
|
||||||
|
|
||||||
const logger = createLogger("async_compression");
|
|
||||||
|
|
||||||
export const compressionPrefix = String.fromCodePoint(1);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @typedef {{
|
|
||||||
* errorHandler: function(any) : void,
|
|
||||||
* resolver: function(any) : void,
|
|
||||||
* startTime: number
|
|
||||||
* }} JobEntry
|
|
||||||
*/
|
|
||||||
|
|
||||||
class AsynCompression {
|
|
||||||
constructor() {
|
|
||||||
this.worker = new CompressionWorker();
|
|
||||||
|
|
||||||
this.currentJobId = 1000;
|
|
||||||
|
|
||||||
/** @type {Object.<number, JobEntry>} */
|
|
||||||
this.currentJobs = {};
|
|
||||||
|
|
||||||
this.worker.addEventListener("message", event => {
|
|
||||||
const { jobId, result } = event.data;
|
|
||||||
const jobData = this.currentJobs[jobId];
|
|
||||||
if (!jobData) {
|
|
||||||
logger.error("Failed to resolve job result, job id", jobId, "is not known");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const duration = performance.now() - jobData.startTime;
|
|
||||||
logger.log(
|
|
||||||
"Got job",
|
|
||||||
jobId,
|
|
||||||
"response within",
|
|
||||||
round2Digits(duration),
|
|
||||||
"ms: ",
|
|
||||||
result.length,
|
|
||||||
"bytes"
|
|
||||||
);
|
|
||||||
const resolver = jobData.resolver;
|
|
||||||
delete this.currentJobs[jobId];
|
|
||||||
resolver(result);
|
|
||||||
});
|
|
||||||
|
|
||||||
this.worker.addEventListener("error", err => {
|
|
||||||
logger.error("Got error from webworker:", err, "aborting all jobs");
|
|
||||||
const failureCalls = [];
|
|
||||||
for (const jobId in this.currentJobs) {
|
|
||||||
failureCalls.push(this.currentJobs[jobId].errorHandler);
|
|
||||||
}
|
|
||||||
this.currentJobs = {};
|
|
||||||
for (let i = 0; i < failureCalls.length; ++i) {
|
|
||||||
failureCalls[i](err);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Compresses any object
|
|
||||||
* @param {any} obj
|
|
||||||
*/
|
|
||||||
compressObjectAsync(obj) {
|
|
||||||
logger.log("Compressing object async (optimized)");
|
|
||||||
return this.internalQueueJob("compressObject", {
|
|
||||||
obj,
|
|
||||||
compressionPrefix,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Queues a new job
|
|
||||||
* @param {string} job
|
|
||||||
* @param {any} data
|
|
||||||
* @returns {Promise<any>}
|
|
||||||
*/
|
|
||||||
internalQueueJob(job, data) {
|
|
||||||
const jobId = ++this.currentJobId;
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
const errorHandler = err => {
|
|
||||||
logger.error("Failed to compress job", jobId, ":", err);
|
|
||||||
reject(err);
|
|
||||||
};
|
|
||||||
this.currentJobs[jobId] = {
|
|
||||||
errorHandler,
|
|
||||||
resolver: resolve,
|
|
||||||
startTime: performance.now(),
|
|
||||||
};
|
|
||||||
|
|
||||||
logger.log("Posting job", job, "/", jobId);
|
|
||||||
this.worker.postMessage({ jobId, job, data });
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export const asyncCompressor = new AsynCompression();
|
|
||||||
@ -110,12 +110,6 @@ export const globalConfig = {
|
|||||||
|
|
||||||
rendering: {},
|
rendering: {},
|
||||||
debug,
|
debug,
|
||||||
|
|
||||||
// Secret vars
|
|
||||||
info: {
|
|
||||||
// Binary file salt
|
|
||||||
file: "Ec'])@^+*9zMevK3uMV4432x9%iK'=",
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const IS_MOBILE = /iPhone|iPad|iPod|Android/i.test(navigator.userAgent);
|
export const IS_MOBILE = /iPhone|iPad|iPod|Android/i.test(navigator.userAgent);
|
||||||
|
|||||||
@ -1,27 +1,21 @@
|
|||||||
/* typehints:start */
|
/* typehints:start */
|
||||||
import { Application } from "../application";
|
import { Storage } from "@/platform/storage";
|
||||||
/* typehints:end */
|
/* typehints:end */
|
||||||
|
|
||||||
import { FsError } from "@/platform/fs_error";
|
import { FsError } from "@/platform/fs_error";
|
||||||
import { compressObject, decompressObject } from "../savegame/savegame_compressor";
|
import { IS_DEBUG } from "./config";
|
||||||
import { asyncCompressor, compressionPrefix } from "./async_compression";
|
|
||||||
import { IS_DEBUG, globalConfig } from "./config";
|
|
||||||
import { ExplainedResult } from "./explained_result";
|
import { ExplainedResult } from "./explained_result";
|
||||||
import { createLogger } from "./logging";
|
import { createLogger } from "./logging";
|
||||||
import { compressX64, decompressX64 } from "./lzstring";
|
|
||||||
import { computeCrc } from "./sensitive_utils.encrypt";
|
|
||||||
|
|
||||||
import debounce from "debounce-promise";
|
import debounce from "debounce-promise";
|
||||||
|
|
||||||
const logger = createLogger("read_write_proxy");
|
const logger = createLogger("read_write_proxy");
|
||||||
|
|
||||||
const salt = globalConfig.info.file;
|
|
||||||
|
|
||||||
// Helper which only writes / reads if verify() works. Also performs migration
|
// Helper which only writes / reads if verify() works. Also performs migration
|
||||||
export class ReadWriteProxy {
|
export class ReadWriteProxy {
|
||||||
constructor(app, filename) {
|
constructor(storage, filename) {
|
||||||
/** @type {Application} */
|
/** @type {Storage} */
|
||||||
this.app = app;
|
this.storage = storage;
|
||||||
|
|
||||||
this.filename = filename;
|
this.filename = filename;
|
||||||
|
|
||||||
@ -85,9 +79,8 @@ export class ReadWriteProxy {
|
|||||||
* @param {object} obj
|
* @param {object} obj
|
||||||
*/
|
*/
|
||||||
static serializeObject(obj) {
|
static serializeObject(obj) {
|
||||||
const jsonString = JSON.stringify(compressObject(obj));
|
// TODO: Remove redundant method
|
||||||
const checksum = computeCrc(jsonString + salt);
|
return obj;
|
||||||
return compressionPrefix + compressX64(checksum + jsonString);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -95,30 +88,8 @@ export class ReadWriteProxy {
|
|||||||
* @param {object} text
|
* @param {object} text
|
||||||
*/
|
*/
|
||||||
static deserializeObject(text) {
|
static deserializeObject(text) {
|
||||||
const decompressed = decompressX64(text.substr(compressionPrefix.length));
|
// TODO: Remove redundant method
|
||||||
if (!decompressed) {
|
return text;
|
||||||
// LZ string decompression failure
|
|
||||||
throw new Error("bad-content / decompression-failed");
|
|
||||||
}
|
|
||||||
if (decompressed.length < 40) {
|
|
||||||
// String too short
|
|
||||||
throw new Error("bad-content / payload-too-small");
|
|
||||||
}
|
|
||||||
|
|
||||||
// Compare stored checksum with actual checksum
|
|
||||||
const checksum = decompressed.substring(0, 40);
|
|
||||||
const jsonString = decompressed.substr(40);
|
|
||||||
|
|
||||||
const desiredChecksum = computeCrc(jsonString + salt);
|
|
||||||
|
|
||||||
if (desiredChecksum !== checksum) {
|
|
||||||
// Checksum mismatch
|
|
||||||
throw new Error("bad-content / checksum-mismatch");
|
|
||||||
}
|
|
||||||
|
|
||||||
const parsed = JSON.parse(jsonString);
|
|
||||||
const decoded = decompressObject(parsed);
|
|
||||||
return decoded;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -142,11 +113,8 @@ export class ReadWriteProxy {
|
|||||||
* @returns {Promise<void>}
|
* @returns {Promise<void>}
|
||||||
*/
|
*/
|
||||||
doWriteAsync() {
|
doWriteAsync() {
|
||||||
return asyncCompressor
|
return this.storage
|
||||||
.compressObjectAsync(this.currentData)
|
.writeFileAsync(this.filename, this.currentData)
|
||||||
.then(compressed => {
|
|
||||||
return this.app.storage.writeFileAsync(this.filename, compressed);
|
|
||||||
})
|
|
||||||
.then(() => {
|
.then(() => {
|
||||||
logger.log("📄 Wrote", this.filename);
|
logger.log("📄 Wrote", this.filename);
|
||||||
})
|
})
|
||||||
@ -160,7 +128,7 @@ export class ReadWriteProxy {
|
|||||||
readAsync() {
|
readAsync() {
|
||||||
// Start read request
|
// Start read request
|
||||||
return (
|
return (
|
||||||
this.app.storage
|
this.storage
|
||||||
.readFileAsync(this.filename)
|
.readFileAsync(this.filename)
|
||||||
|
|
||||||
// Check for errors during read
|
// Check for errors during read
|
||||||
@ -169,73 +137,12 @@ export class ReadWriteProxy {
|
|||||||
logger.log("File not found, using default data");
|
logger.log("File not found, using default data");
|
||||||
|
|
||||||
// File not found or unreadable, assume default file
|
// File not found or unreadable, assume default file
|
||||||
return Promise.resolve(null);
|
return Promise.resolve(this.getDefaultData());
|
||||||
}
|
}
|
||||||
|
|
||||||
return Promise.reject("file-error: " + err);
|
return Promise.reject("file-error: " + err);
|
||||||
})
|
})
|
||||||
|
|
||||||
// Decrypt data (if its encrypted)
|
|
||||||
// @ts-ignore
|
|
||||||
.then(rawData => {
|
|
||||||
if (rawData == null) {
|
|
||||||
// So, the file has not been found, use default data
|
|
||||||
return JSON.stringify(compressObject(this.getDefaultData()));
|
|
||||||
}
|
|
||||||
|
|
||||||
if (rawData.startsWith(compressionPrefix)) {
|
|
||||||
const decompressed = decompressX64(rawData.substr(compressionPrefix.length));
|
|
||||||
if (!decompressed) {
|
|
||||||
// LZ string decompression failure
|
|
||||||
return Promise.reject("bad-content / decompression-failed");
|
|
||||||
}
|
|
||||||
if (decompressed.length < 40) {
|
|
||||||
// String too short
|
|
||||||
return Promise.reject("bad-content / payload-too-small");
|
|
||||||
}
|
|
||||||
|
|
||||||
// Compare stored checksum with actual checksum
|
|
||||||
const checksum = decompressed.substring(0, 40);
|
|
||||||
const jsonString = decompressed.slice(40);
|
|
||||||
|
|
||||||
const desiredChecksum = computeCrc(jsonString + salt);
|
|
||||||
|
|
||||||
if (desiredChecksum !== checksum) {
|
|
||||||
// Checksum mismatch
|
|
||||||
return Promise.reject(
|
|
||||||
"bad-content / checksum-mismatch: " + desiredChecksum + " vs " + checksum
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return jsonString;
|
|
||||||
} else {
|
|
||||||
if (!G_IS_DEV) {
|
|
||||||
return Promise.reject("bad-content / missing-compression");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return rawData;
|
|
||||||
})
|
|
||||||
|
|
||||||
// Parse JSON, this could throw but that's fine
|
|
||||||
.then(res => {
|
|
||||||
try {
|
|
||||||
return JSON.parse(res);
|
|
||||||
} catch (ex) {
|
|
||||||
logger.error(
|
|
||||||
"Failed to parse file content of",
|
|
||||||
this.filename,
|
|
||||||
":",
|
|
||||||
ex,
|
|
||||||
"(content was:",
|
|
||||||
res,
|
|
||||||
")"
|
|
||||||
);
|
|
||||||
throw new Error("invalid-serialized-data");
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
// Decompress
|
|
||||||
.then(compressed => decompressObject(compressed))
|
|
||||||
|
|
||||||
// Verify basic structure
|
// Verify basic structure
|
||||||
.then(contents => {
|
.then(contents => {
|
||||||
const result = this.internalVerifyBasicStructure(contents);
|
const result = this.internalVerifyBasicStructure(contents);
|
||||||
@ -302,7 +209,7 @@ export class ReadWriteProxy {
|
|||||||
* @returns {Promise<void>}
|
* @returns {Promise<void>}
|
||||||
*/
|
*/
|
||||||
deleteAsync() {
|
deleteAsync() {
|
||||||
return this.app.storage.deleteFileAsync(this.filename);
|
return this.storage.deleteFileAsync(this.filename);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Internal
|
// Internal
|
||||||
|
|||||||
@ -1,12 +0,0 @@
|
|||||||
import crc32 from "crc/crc32";
|
|
||||||
|
|
||||||
// Distinguish legacy crc prefixes
|
|
||||||
export const CRC_PREFIX = "crc32".padEnd(32, "-");
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Computes the crc for a given string
|
|
||||||
* @param {string} str
|
|
||||||
*/
|
|
||||||
export function computeCrc(str) {
|
|
||||||
return CRC_PREFIX + crc32(str).toString(16).padStart(8, "0");
|
|
||||||
}
|
|
||||||
@ -599,38 +599,6 @@ export function fillInLinkIntoTranslation(translation, link) {
|
|||||||
.replace("</link>", "</a>");
|
.replace("</link>", "</a>");
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Generates a file download
|
|
||||||
* @param {string} filename
|
|
||||||
* @param {string} text
|
|
||||||
*/
|
|
||||||
export function generateFileDownload(filename, text) {
|
|
||||||
const element = document.createElement("a");
|
|
||||||
element.setAttribute("href", "data:text/plain;charset=utf-8," + encodeURIComponent(text));
|
|
||||||
element.setAttribute("download", filename);
|
|
||||||
|
|
||||||
element.style.display = "none";
|
|
||||||
document.body.appendChild(element);
|
|
||||||
|
|
||||||
element.click();
|
|
||||||
document.body.removeChild(element);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Starts a file chooser
|
|
||||||
* @param {string} acceptedType
|
|
||||||
*/
|
|
||||||
export function startFileChoose(acceptedType = ".bin") {
|
|
||||||
const input = document.createElement("input");
|
|
||||||
input.type = "file";
|
|
||||||
input.accept = acceptedType;
|
|
||||||
|
|
||||||
return new Promise(resolve => {
|
|
||||||
input.onchange = _ => resolve(input.files[0]);
|
|
||||||
input.click();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const MAX_ROMAN_NUMBER = 49;
|
const MAX_ROMAN_NUMBER = 49;
|
||||||
const romanLiteralsCache = ["0"];
|
const romanLiteralsCache = ["0"];
|
||||||
|
|
||||||
|
|||||||
@ -1,53 +1,68 @@
|
|||||||
import { Application } from "@/application";
|
import { Application } from "@/application";
|
||||||
import { FsError } from "./fs_error";
|
import { FsError } from "./fs_error";
|
||||||
|
|
||||||
|
export const STORAGE_SAVES = "saves";
|
||||||
|
export const STORAGE_MOD_PREFIX = "mod/";
|
||||||
|
|
||||||
export class Storage {
|
export class Storage {
|
||||||
readonly app: Application;
|
readonly app: Application;
|
||||||
|
readonly id: string;
|
||||||
|
|
||||||
constructor(app: Application) {
|
constructor(app: Application, id: string) {
|
||||||
this.app = app;
|
this.app = app;
|
||||||
|
this.id = id;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Initializes the storage
|
* Initializes the storage
|
||||||
*/
|
*/
|
||||||
initialize(): Promise<void> {
|
initialize(): Promise<void> {
|
||||||
return Promise.resolve();
|
return this.invokeFsJob({ type: "initialize" });
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Writes a string to a file asynchronously
|
* Writes a string to a file asynchronously
|
||||||
*/
|
*/
|
||||||
writeFileAsync(filename: string, contents: string): Promise<void> {
|
writeFileAsync(filename: string, contents: unknown): Promise<void> {
|
||||||
return ipcRenderer
|
return this.invokeFsJob({ type: "write", filename, contents });
|
||||||
.invoke("fs-job", {
|
|
||||||
type: "write",
|
|
||||||
filename,
|
|
||||||
contents,
|
|
||||||
})
|
|
||||||
.catch(e => this.wrapError(e));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Reads a string asynchronously
|
* Reads a string asynchronously
|
||||||
*/
|
*/
|
||||||
readFileAsync(filename: string): Promise<string> {
|
readFileAsync(filename: string): Promise<unknown> {
|
||||||
return ipcRenderer
|
return this.invokeFsJob({ type: "read", filename });
|
||||||
.invoke("fs-job", {
|
|
||||||
type: "read",
|
|
||||||
filename,
|
|
||||||
})
|
|
||||||
.catch(e => this.wrapError(e));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Tries to delete a file
|
* Tries to delete a file
|
||||||
*/
|
*/
|
||||||
deleteFileAsync(filename: string): Promise<void> {
|
deleteFileAsync(filename: string): Promise<void> {
|
||||||
|
return this.invokeFsJob({ type: "delete", filename });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Displays the "Open File" dialog to let user pick a file. Returns the
|
||||||
|
* decompressed file contents, or undefined if the operation was canceled
|
||||||
|
*/
|
||||||
|
requestOpenFile(extension: string): Promise<unknown> {
|
||||||
|
return this.invokeFsJob({ type: "open-external", extension });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Displays the "Save File" dialog to let user pick a file. If the user
|
||||||
|
* picks a file, the passed contents will be compressed and written to
|
||||||
|
* that file.
|
||||||
|
*/
|
||||||
|
requestSaveFile(filename: string, contents: unknown): Promise<unknown> {
|
||||||
|
return this.invokeFsJob({ type: "save-external", filename, contents });
|
||||||
|
}
|
||||||
|
|
||||||
|
private invokeFsJob(data: object) {
|
||||||
return ipcRenderer
|
return ipcRenderer
|
||||||
.invoke("fs-job", {
|
.invoke("fs-job", {
|
||||||
type: "delete",
|
id: this.id,
|
||||||
filename,
|
...data,
|
||||||
})
|
})
|
||||||
.catch(e => this.wrapError(e));
|
.catch(e => this.wrapError(e));
|
||||||
}
|
}
|
||||||
|
|||||||
@ -2,13 +2,13 @@
|
|||||||
import { Application } from "../application";
|
import { Application } from "../application";
|
||||||
/* typehints:end */
|
/* typehints:end */
|
||||||
|
|
||||||
import { ReadWriteProxy } from "../core/read_write_proxy";
|
|
||||||
import { BoolSetting, EnumSetting, RangeSetting, BaseSetting } from "./setting_types";
|
|
||||||
import { createLogger } from "../core/logging";
|
|
||||||
import { ExplainedResult } from "../core/explained_result";
|
import { ExplainedResult } from "../core/explained_result";
|
||||||
|
import { createLogger } from "../core/logging";
|
||||||
|
import { ReadWriteProxy } from "../core/read_write_proxy";
|
||||||
import { THEMES, applyGameTheme } from "../game/theme";
|
import { THEMES, applyGameTheme } from "../game/theme";
|
||||||
import { T } from "../translations";
|
|
||||||
import { LANGUAGES } from "../languages";
|
import { LANGUAGES } from "../languages";
|
||||||
|
import { T } from "../translations";
|
||||||
|
import { BaseSetting, BoolSetting, EnumSetting, RangeSetting } from "./setting_types";
|
||||||
|
|
||||||
const logger = createLogger("application_settings");
|
const logger = createLogger("application_settings");
|
||||||
|
|
||||||
@ -330,8 +330,11 @@ class SettingsStorage {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export class ApplicationSettings extends ReadWriteProxy {
|
export class ApplicationSettings extends ReadWriteProxy {
|
||||||
constructor(app) {
|
constructor(app, storage) {
|
||||||
super(app, "app_settings.bin");
|
super(storage, "app_settings.bin");
|
||||||
|
|
||||||
|
/** @type {Application} */
|
||||||
|
this.app = app;
|
||||||
|
|
||||||
this.settingHandles = initializeSettings();
|
this.settingHandles = initializeSettings();
|
||||||
}
|
}
|
||||||
|
|||||||
@ -6,16 +6,6 @@ import { MODS } from "../mods/modloader";
|
|||||||
import { BaseSavegameInterface } from "./savegame_interface";
|
import { BaseSavegameInterface } from "./savegame_interface";
|
||||||
import { getSavegameInterface, savegameInterfaces } from "./savegame_interface_registry";
|
import { getSavegameInterface, savegameInterfaces } from "./savegame_interface_registry";
|
||||||
import { SavegameSerializer } from "./savegame_serializer";
|
import { SavegameSerializer } from "./savegame_serializer";
|
||||||
import { SavegameInterface_V1001 } from "./schemas/1001";
|
|
||||||
import { SavegameInterface_V1002 } from "./schemas/1002";
|
|
||||||
import { SavegameInterface_V1003 } from "./schemas/1003";
|
|
||||||
import { SavegameInterface_V1004 } from "./schemas/1004";
|
|
||||||
import { SavegameInterface_V1005 } from "./schemas/1005";
|
|
||||||
import { SavegameInterface_V1006 } from "./schemas/1006";
|
|
||||||
import { SavegameInterface_V1007 } from "./schemas/1007";
|
|
||||||
import { SavegameInterface_V1008 } from "./schemas/1008";
|
|
||||||
import { SavegameInterface_V1009 } from "./schemas/1009";
|
|
||||||
import { SavegameInterface_V1010 } from "./schemas/1010";
|
|
||||||
|
|
||||||
const logger = createLogger("savegame");
|
const logger = createLogger("savegame");
|
||||||
|
|
||||||
@ -36,7 +26,11 @@ export class Savegame extends ReadWriteProxy {
|
|||||||
* @param {SavegameMetadata} param0.metaDataRef Handle to the meta data
|
* @param {SavegameMetadata} param0.metaDataRef Handle to the meta data
|
||||||
*/
|
*/
|
||||||
constructor(app, { internalId, metaDataRef }) {
|
constructor(app, { internalId, metaDataRef }) {
|
||||||
super(app, "savegame-" + internalId + ".bin");
|
super(app.storage, "savegame-" + internalId + ".bin");
|
||||||
|
|
||||||
|
/** @type {Application} */
|
||||||
|
this.app = app;
|
||||||
|
|
||||||
this.internalId = internalId;
|
this.internalId = internalId;
|
||||||
this.metaDataRef = metaDataRef;
|
this.metaDataRef = metaDataRef;
|
||||||
|
|
||||||
@ -108,58 +102,8 @@ export class Savegame extends ReadWriteProxy {
|
|||||||
* @param {SavegameData} data
|
* @param {SavegameData} data
|
||||||
*/
|
*/
|
||||||
migrate(data) {
|
migrate(data) {
|
||||||
if (data.version < 1000) {
|
if (data.version !== this.getCurrentVersion()) {
|
||||||
return ExplainedResult.bad("Can not migrate savegame, too old");
|
return ExplainedResult.bad("Savegame upgrade is not supported");
|
||||||
}
|
|
||||||
|
|
||||||
if (data.version === 1000) {
|
|
||||||
SavegameInterface_V1001.migrate1000to1001(data);
|
|
||||||
data.version = 1001;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (data.version === 1001) {
|
|
||||||
SavegameInterface_V1002.migrate1001to1002(data);
|
|
||||||
data.version = 1002;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (data.version === 1002) {
|
|
||||||
SavegameInterface_V1003.migrate1002to1003(data);
|
|
||||||
data.version = 1003;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (data.version === 1003) {
|
|
||||||
SavegameInterface_V1004.migrate1003to1004(data);
|
|
||||||
data.version = 1004;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (data.version === 1004) {
|
|
||||||
SavegameInterface_V1005.migrate1004to1005(data);
|
|
||||||
data.version = 1005;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (data.version === 1005) {
|
|
||||||
SavegameInterface_V1006.migrate1005to1006(data);
|
|
||||||
data.version = 1006;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (data.version === 1006) {
|
|
||||||
SavegameInterface_V1007.migrate1006to1007(data);
|
|
||||||
data.version = 1007;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (data.version === 1007) {
|
|
||||||
SavegameInterface_V1008.migrate1007to1008(data);
|
|
||||||
data.version = 1008;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (data.version === 1008) {
|
|
||||||
SavegameInterface_V1009.migrate1008to1009(data);
|
|
||||||
data.version = 1009;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (data.version === 1009) {
|
|
||||||
SavegameInterface_V1010.migrate1009to1010(data);
|
|
||||||
data.version = 1010;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return ExplainedResult.good();
|
return ExplainedResult.good();
|
||||||
|
|||||||
@ -1,168 +0,0 @@
|
|||||||
const charmap =
|
|
||||||
"!#%&'()*+,-./:;<=>?@[]^_`{|}~¥¦§¨©ª«¬®¯°±²³´µ¶·¸¹º»¼½¾¿ÀÁÂÃÄÅÆÇÈÉÊËÌÍÎÏÐÑÒÓÔÕÖרÙÚÛÜÝÞßàáâãäåæçèéêëìíîïðñòóôõö÷øùúûüýþÿABCDEFGHIJKLMNOPQRSTUVWXYZ";
|
|
||||||
|
|
||||||
let compressionCache = {};
|
|
||||||
let decompressionCache = {};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Compresses an integer into a tight string representation
|
|
||||||
* @param {number} i
|
|
||||||
* @returns {string}
|
|
||||||
*/
|
|
||||||
function compressInt(i) {
|
|
||||||
// Zero value breaks
|
|
||||||
i += 1;
|
|
||||||
|
|
||||||
// save `i` as the cache key
|
|
||||||
// to avoid it being modified by the
|
|
||||||
// rest of the function.
|
|
||||||
const cache_key = i;
|
|
||||||
|
|
||||||
if (compressionCache[cache_key]) {
|
|
||||||
return compressionCache[cache_key];
|
|
||||||
}
|
|
||||||
let result = "";
|
|
||||||
do {
|
|
||||||
result += charmap[i % charmap.length];
|
|
||||||
i = Math.floor(i / charmap.length);
|
|
||||||
} while (i > 0);
|
|
||||||
return (compressionCache[cache_key] = result);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Decompresses an integer from its tight string representation
|
|
||||||
* @param {string} s
|
|
||||||
* @returns {number}
|
|
||||||
*/
|
|
||||||
function decompressInt(s) {
|
|
||||||
if (decompressionCache[s]) {
|
|
||||||
return decompressionCache[s];
|
|
||||||
}
|
|
||||||
s = "" + s;
|
|
||||||
let result = 0;
|
|
||||||
for (let i = s.length - 1; i >= 0; --i) {
|
|
||||||
result = result * charmap.length + charmap.indexOf(s.charAt(i));
|
|
||||||
}
|
|
||||||
// Fixes zero value break fix from above
|
|
||||||
result -= 1;
|
|
||||||
return (decompressionCache[s] = result);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Sanity
|
|
||||||
if (G_IS_DEV) {
|
|
||||||
for (let i = 0; i < 10000; ++i) {
|
|
||||||
if (decompressInt(compressInt(i)) !== i) {
|
|
||||||
throw new Error(
|
|
||||||
"Bad compression for: " +
|
|
||||||
i +
|
|
||||||
" compressed: " +
|
|
||||||
compressInt(i) +
|
|
||||||
" decompressed: " +
|
|
||||||
decompressInt(compressInt(i))
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param {any} obj
|
|
||||||
* @param {Map} keys
|
|
||||||
* @param {Map} values
|
|
||||||
* @returns {any[]|object|number|string}
|
|
||||||
*/
|
|
||||||
function compressObjectInternal(obj, keys, values) {
|
|
||||||
if (Array.isArray(obj)) {
|
|
||||||
let result = [];
|
|
||||||
for (let i = 0; i < obj.length; ++i) {
|
|
||||||
result.push(compressObjectInternal(obj[i], keys, values));
|
|
||||||
}
|
|
||||||
return result;
|
|
||||||
} else if (typeof obj === "object" && obj !== null) {
|
|
||||||
let result = {};
|
|
||||||
for (const key in obj) {
|
|
||||||
let index = keys.get(key);
|
|
||||||
if (index === undefined) {
|
|
||||||
index = keys.size;
|
|
||||||
keys.set(key, index);
|
|
||||||
}
|
|
||||||
const value = obj[key];
|
|
||||||
result[compressInt(index)] = compressObjectInternal(value, keys, values);
|
|
||||||
}
|
|
||||||
return result;
|
|
||||||
} else if (typeof obj === "string") {
|
|
||||||
let index = values.get(obj);
|
|
||||||
if (index === undefined) {
|
|
||||||
index = values.size;
|
|
||||||
values.set(obj, index);
|
|
||||||
}
|
|
||||||
return compressInt(index);
|
|
||||||
}
|
|
||||||
return obj;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param {Map} hashMap
|
|
||||||
* @returns {Array}
|
|
||||||
*/
|
|
||||||
function indexMapToArray(hashMap) {
|
|
||||||
const result = new Array(hashMap.size);
|
|
||||||
hashMap.forEach((index, key) => {
|
|
||||||
result[index] = key;
|
|
||||||
});
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param {object} obj
|
|
||||||
*/
|
|
||||||
export function compressObject(obj) {
|
|
||||||
const keys = new Map();
|
|
||||||
const values = new Map();
|
|
||||||
const data = compressObjectInternal(obj, keys, values);
|
|
||||||
return {
|
|
||||||
keys: indexMapToArray(keys),
|
|
||||||
values: indexMapToArray(values),
|
|
||||||
data,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param {object} obj
|
|
||||||
* @param {string[]} keys
|
|
||||||
* @param {any[]} values
|
|
||||||
* @returns {object}
|
|
||||||
*/
|
|
||||||
function decompressObjectInternal(obj, keys = [], values = []) {
|
|
||||||
if (Array.isArray(obj)) {
|
|
||||||
let result = [];
|
|
||||||
for (let i = 0; i < obj.length; ++i) {
|
|
||||||
result.push(decompressObjectInternal(obj[i], keys, values));
|
|
||||||
}
|
|
||||||
return result;
|
|
||||||
} else if (typeof obj === "object" && obj !== null) {
|
|
||||||
let result = {};
|
|
||||||
for (const key in obj) {
|
|
||||||
const realIndex = decompressInt(key);
|
|
||||||
const value = obj[key];
|
|
||||||
result[keys[realIndex]] = decompressObjectInternal(value, keys, values);
|
|
||||||
}
|
|
||||||
return result;
|
|
||||||
} else if (typeof obj === "string") {
|
|
||||||
const realIndex = decompressInt(obj);
|
|
||||||
return values[realIndex];
|
|
||||||
}
|
|
||||||
return obj;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param {object} obj
|
|
||||||
*/
|
|
||||||
export function decompressObject(obj) {
|
|
||||||
if (obj.keys && obj.values && obj.data) {
|
|
||||||
const keys = obj.keys;
|
|
||||||
const values = obj.values;
|
|
||||||
const result = decompressObjectInternal(obj.data, keys, values);
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
return obj;
|
|
||||||
}
|
|
||||||
@ -1,3 +1,7 @@
|
|||||||
|
/* typehints:start */
|
||||||
|
import { Application } from "@/application";
|
||||||
|
/* typehints:end */
|
||||||
|
|
||||||
import { globalConfig } from "../core/config";
|
import { globalConfig } from "../core/config";
|
||||||
import { ExplainedResult } from "../core/explained_result";
|
import { ExplainedResult } from "../core/explained_result";
|
||||||
import { createLogger } from "../core/logging";
|
import { createLogger } from "../core/logging";
|
||||||
@ -17,8 +21,11 @@ export const enumLocalSavegameStatus = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export class SavegameManager extends ReadWriteProxy {
|
export class SavegameManager extends ReadWriteProxy {
|
||||||
constructor(app) {
|
constructor(app, storage) {
|
||||||
super(app, "savegames.bin");
|
super(storage, "savegames.bin");
|
||||||
|
|
||||||
|
/** @type {Application} */
|
||||||
|
this.app = app;
|
||||||
|
|
||||||
this.currentData = this.getDefaultData();
|
this.currentData = this.getDefaultData();
|
||||||
}
|
}
|
||||||
|
|||||||
@ -2,16 +2,13 @@ import { globalConfig, THIRDPARTY_URLS } from "../core/config";
|
|||||||
import { GameState } from "../core/game_state";
|
import { GameState } from "../core/game_state";
|
||||||
import { DialogWithForm } from "../core/modal_dialog_elements";
|
import { DialogWithForm } from "../core/modal_dialog_elements";
|
||||||
import { FormElementInput } from "../core/modal_dialog_forms";
|
import { FormElementInput } from "../core/modal_dialog_forms";
|
||||||
import { ReadWriteProxy } from "../core/read_write_proxy";
|
|
||||||
import {
|
import {
|
||||||
formatSecondsToTimeAgo,
|
formatSecondsToTimeAgo,
|
||||||
generateFileDownload,
|
|
||||||
getLogoSprite,
|
getLogoSprite,
|
||||||
makeButton,
|
makeButton,
|
||||||
makeDiv,
|
makeDiv,
|
||||||
makeDivElement,
|
makeDivElement,
|
||||||
removeAllChildren,
|
removeAllChildren,
|
||||||
startFileChoose,
|
|
||||||
waitNextFrame,
|
waitNextFrame,
|
||||||
} from "../core/utils";
|
} from "../core/utils";
|
||||||
import { HUDModalDialogs } from "../game/hud/parts/modal_dialogs";
|
import { HUDModalDialogs } from "../game/hud/parts/modal_dialogs";
|
||||||
@ -137,58 +134,34 @@ export class MainMenuState extends GameState {
|
|||||||
/**
|
/**
|
||||||
* Asks the user to import a savegame
|
* Asks the user to import a savegame
|
||||||
*/
|
*/
|
||||||
requestImportSavegame() {
|
async requestImportSavegame() {
|
||||||
// Create a 'fake' file-input to accept savegames
|
const closeLoader = this.dialogs.showLoadingDialog();
|
||||||
startFileChoose(".bin").then(file => {
|
await waitNextFrame();
|
||||||
if (file) {
|
|
||||||
const closeLoader = this.dialogs.showLoadingDialog();
|
|
||||||
waitNextFrame().then(() => {
|
|
||||||
const reader = new FileReader();
|
|
||||||
reader.addEventListener("load", event => {
|
|
||||||
const contents = event.target.result;
|
|
||||||
let realContent;
|
|
||||||
|
|
||||||
try {
|
const data = await this.app.storage.requestOpenFile("bin");
|
||||||
realContent = ReadWriteProxy.deserializeObject(contents);
|
if (data === undefined) {
|
||||||
} catch (err) {
|
// User canceled the request
|
||||||
closeLoader();
|
closeLoader();
|
||||||
this.dialogs.showWarning(
|
return;
|
||||||
T.dialogs.importSavegameError.title,
|
}
|
||||||
T.dialogs.importSavegameError.text + "<br><br>" + err
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.app.savegameMgr.importSavegame(realContent).then(
|
try {
|
||||||
() => {
|
this.app.savegameMgr.importSavegame(data);
|
||||||
closeLoader();
|
closeLoader();
|
||||||
this.dialogs.showWarning(
|
this.dialogs.showWarning(
|
||||||
T.dialogs.importSavegameSuccess.title,
|
T.dialogs.importSavegameSuccess.title,
|
||||||
T.dialogs.importSavegameSuccess.text
|
T.dialogs.importSavegameSuccess.text
|
||||||
);
|
);
|
||||||
|
|
||||||
this.renderMainMenu();
|
this.renderMainMenu();
|
||||||
this.renderSavegames();
|
this.renderSavegames();
|
||||||
},
|
} catch (err) {
|
||||||
err => {
|
closeLoader();
|
||||||
closeLoader();
|
this.dialogs.showWarning(
|
||||||
this.dialogs.showWarning(
|
T.dialogs.importSavegameError.title,
|
||||||
T.dialogs.importSavegameError.title,
|
T.dialogs.importSavegameError.text + ":<br><br>" + err
|
||||||
T.dialogs.importSavegameError.text + ":<br><br>" + err
|
);
|
||||||
);
|
}
|
||||||
}
|
|
||||||
);
|
|
||||||
});
|
|
||||||
reader.addEventListener("error", error => {
|
|
||||||
this.dialogs.showWarning(
|
|
||||||
T.dialogs.importSavegameError.title,
|
|
||||||
T.dialogs.importSavegameError.text + ":<br><br>" + error
|
|
||||||
);
|
|
||||||
});
|
|
||||||
reader.readAsText(file, "utf-8");
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
onBackButton() {
|
onBackButton() {
|
||||||
@ -602,9 +575,8 @@ export class MainMenuState extends GameState {
|
|||||||
downloadGame(game) {
|
downloadGame(game) {
|
||||||
const savegame = this.app.savegameMgr.getSavegameById(game.internalId);
|
const savegame = this.app.savegameMgr.getSavegameById(game.internalId);
|
||||||
savegame.readAsync().then(() => {
|
savegame.readAsync().then(() => {
|
||||||
const data = ReadWriteProxy.serializeObject(savegame.currentData);
|
|
||||||
const filename = (game.name || "unnamed") + ".bin";
|
const filename = (game.name || "unnamed") + ".bin";
|
||||||
generateFileDownload(filename, data);
|
savegame.storage.requestSaveFile(filename, savegame.currentData);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,31 +0,0 @@
|
|||||||
import { globalConfig } from "../core/config";
|
|
||||||
import { compressX64 } from "../core/lzstring";
|
|
||||||
import { computeCrc } from "../core/sensitive_utils.encrypt";
|
|
||||||
import { compressObject } from "../savegame/savegame_compressor";
|
|
||||||
|
|
||||||
self.addEventListener("message", event => {
|
|
||||||
// @ts-ignore
|
|
||||||
const { jobId, job, data } = event.data;
|
|
||||||
const result = performJob(job, data);
|
|
||||||
|
|
||||||
// @ts-ignore
|
|
||||||
self.postMessage({ jobId, result });
|
|
||||||
});
|
|
||||||
|
|
||||||
function performJob(job, data) {
|
|
||||||
switch (job) {
|
|
||||||
case "compressX64": {
|
|
||||||
return compressX64(data);
|
|
||||||
}
|
|
||||||
|
|
||||||
case "compressObject": {
|
|
||||||
const optimized = compressObject(data.obj);
|
|
||||||
const stringified = JSON.stringify(optimized);
|
|
||||||
|
|
||||||
const checksum = computeCrc(stringified + globalConfig.info.file);
|
|
||||||
return data.compressionPrefix + compressX64(checksum + stringified);
|
|
||||||
}
|
|
||||||
default:
|
|
||||||
throw new Error("Webworker: Unknown job: " + job);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Loading…
Reference in New Issue
Block a user