mirror of
https://github.com/tobspr/shapez.io.git
synced 2026-03-02 03:39:21 +00:00
Initial commit
This commit is contained in:
237
src/js/savegame/savegame.js
Normal file
237
src/js/savegame/savegame.js
Normal file
@@ -0,0 +1,237 @@
|
||||
/* typehints:start */
|
||||
import { Application } from "../application";
|
||||
import { GameRoot } from "../game/root";
|
||||
/* typehints:end */
|
||||
|
||||
import { ReadWriteProxy } from "../core/read_write_proxy";
|
||||
import { ExplainedResult } from "../core/explained_result";
|
||||
import { SavegameSerializer } from "./savegame_serializer";
|
||||
import { BaseSavegameInterface } from "./savegame_interface";
|
||||
import { createLogger } from "../core/logging";
|
||||
import { globalConfig } from "../core/config";
|
||||
import { SavegameInterface_V1000 } from "./schemas/1000";
|
||||
import { getSavegameInterface } from "./savegame_interface_registry";
|
||||
|
||||
const logger = createLogger("savegame");
|
||||
|
||||
export class Savegame extends ReadWriteProxy {
|
||||
/**
|
||||
*
|
||||
* @param {Application} app
|
||||
* @param {object} param0
|
||||
* @param {string} param0.internalId
|
||||
* @param {import("./savegame_manager").SavegameMetadata} param0.metaDataRef Handle to the meta data
|
||||
*/
|
||||
constructor(app, { internalId, metaDataRef }) {
|
||||
super(app, "savegame-" + internalId + ".bin");
|
||||
this.internalId = internalId;
|
||||
this.metaDataRef = metaDataRef;
|
||||
|
||||
/** @type {SavegameData} */
|
||||
this.currentData = this.getDefaultData();
|
||||
}
|
||||
|
||||
//////// RW Proxy Impl //////////
|
||||
|
||||
/**
|
||||
* @returns {number}
|
||||
*/
|
||||
static getCurrentVersion() {
|
||||
return 1015;
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {typeof BaseSavegameInterface}
|
||||
*/
|
||||
static getReaderClass() {
|
||||
return SavegameInterface_V1000;
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {number}
|
||||
*/
|
||||
getCurrentVersion() {
|
||||
return /** @type {typeof Savegame} */ (this.constructor).getCurrentVersion();
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the savegames default data
|
||||
* @returns {SavegameData}
|
||||
*/
|
||||
getDefaultData() {
|
||||
return {
|
||||
version: this.getCurrentVersion(),
|
||||
dump: null,
|
||||
stats: {
|
||||
buildingsPlaced: 0,
|
||||
},
|
||||
lastUpdate: Date.now(),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Migrates the savegames data
|
||||
* @param {SavegameData} data
|
||||
*/
|
||||
migrate(data) {
|
||||
// if (data.version === 1014) {
|
||||
// if (data.dump) {
|
||||
// const reader = new SavegameInterface_V1015(fakeLogger, data);
|
||||
// reader.migrateFrom1014();
|
||||
// }
|
||||
// data.version = 1015;
|
||||
// }
|
||||
return ExplainedResult.good();
|
||||
}
|
||||
|
||||
/**
|
||||
* Verifies the savegames data
|
||||
* @param {SavegameData} data
|
||||
*/
|
||||
verify(data) {
|
||||
if (!data.dump) {
|
||||
// Well, guess that works
|
||||
return ExplainedResult.good();
|
||||
}
|
||||
|
||||
if (!this.getDumpReaderForExternalData(data).validate()) {
|
||||
return ExplainedResult.bad("dump-reader-failed-validation");
|
||||
}
|
||||
return ExplainedResult.good();
|
||||
}
|
||||
|
||||
//////// Subclasses interface ////////
|
||||
|
||||
/**
|
||||
* Returns if this game can be saved on disc
|
||||
* @returns {boolean}
|
||||
*/
|
||||
isSaveable() {
|
||||
return true;
|
||||
}
|
||||
/**
|
||||
* Returns the statistics of the savegame
|
||||
* @returns {SavegameStats}
|
||||
*/
|
||||
getStatistics() {
|
||||
return this.currentData.stats;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the *real* last update of the savegame, not the one of the metadata
|
||||
* which could also be the servers one
|
||||
*/
|
||||
getRealLastUpdate() {
|
||||
return this.currentData.lastUpdate;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns if this game has a serialized game dump
|
||||
*/
|
||||
hasGameDump() {
|
||||
return !!this.currentData.dump;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the current game dump
|
||||
* @returns {SerializedGame}
|
||||
*/
|
||||
getCurrentDump() {
|
||||
return this.currentData.dump;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a reader to access the data
|
||||
* @returns {BaseSavegameInterface}
|
||||
*/
|
||||
getDumpReader() {
|
||||
if (!this.currentData.dump) {
|
||||
logger.warn("Getting reader on null-savegame dump");
|
||||
}
|
||||
|
||||
const cls = /** @type {typeof Savegame} */ (this.constructor).getReaderClass();
|
||||
return new cls(this.currentData);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a reader to access external data
|
||||
* @returns {BaseSavegameInterface}
|
||||
*/
|
||||
getDumpReaderForExternalData(data) {
|
||||
assert(data.version, "External data contains no version");
|
||||
return getSavegameInterface(data);
|
||||
}
|
||||
|
||||
///////// Public Interface ///////////
|
||||
|
||||
/**
|
||||
* Updates the last update field so we can send the savegame to the server,
|
||||
* WITHOUT Saving!
|
||||
*/
|
||||
setLastUpdate(time) {
|
||||
this.currentData.lastUpdate = time;
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {GameRoot} root
|
||||
*/
|
||||
updateData(root) {
|
||||
// Construct a new serializer
|
||||
const serializer = new SavegameSerializer();
|
||||
|
||||
// let timer = performanceNow();
|
||||
const dump = serializer.generateDumpFromGameRoot(root);
|
||||
if (!dump) {
|
||||
return false;
|
||||
}
|
||||
// let duration = performanceNow() - timer;
|
||||
// console.log("TOOK", duration, "ms to generate dump:", dump);
|
||||
|
||||
const shadowData = Object.assign({}, this.currentData);
|
||||
shadowData.dump = dump;
|
||||
shadowData.lastUpdate = new Date().getTime();
|
||||
shadowData.version = this.getCurrentVersion();
|
||||
|
||||
const reader = this.getDumpReaderForExternalData(shadowData);
|
||||
|
||||
// Validate (not in prod though)
|
||||
if (!G_IS_RELEASE) {
|
||||
const validationResult = reader.validate();
|
||||
if (!validationResult) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Save data
|
||||
this.currentData = shadowData;
|
||||
}
|
||||
|
||||
/**
|
||||
* Writes the savegame as well as its metadata
|
||||
*/
|
||||
writeSavegameAndMetadata() {
|
||||
return this.writeAsync().then(() => this.saveMetadata());
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the savegames metadata
|
||||
*/
|
||||
saveMetadata() {
|
||||
const reader = this.getDumpReader();
|
||||
this.metaDataRef.lastUpdate = new Date().getTime();
|
||||
this.metaDataRef.version = this.getCurrentVersion();
|
||||
return this.app.savegameMgr.writeAsync();
|
||||
}
|
||||
|
||||
/**
|
||||
* @see ReadWriteProxy.writeAsync
|
||||
* @returns {Promise<any>}
|
||||
*/
|
||||
writeAsync() {
|
||||
if (G_IS_DEV && globalConfig.debug.disableSavegameWrite) {
|
||||
return Promise.resolve();
|
||||
}
|
||||
return super.writeAsync();
|
||||
}
|
||||
}
|
||||
108
src/js/savegame/savegame_interface.js
Normal file
108
src/js/savegame/savegame_interface.js
Normal file
@@ -0,0 +1,108 @@
|
||||
import { createLogger } from "../core/logging";
|
||||
|
||||
const Ajv = require("ajv");
|
||||
const ajv = new Ajv({
|
||||
allErrors: false,
|
||||
uniqueItems: false,
|
||||
unicode: false,
|
||||
nullable: false,
|
||||
});
|
||||
|
||||
const validators = {};
|
||||
|
||||
const logger = createLogger("savegame_interface");
|
||||
|
||||
export class BaseSavegameInterface {
|
||||
/**
|
||||
* Returns the interfaces version
|
||||
*/
|
||||
getVersion() {
|
||||
throw new Error("Implement get version");
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the uncached json schema
|
||||
* @returns {object}
|
||||
*/
|
||||
getSchemaUncached() {
|
||||
throw new Error("Implement get schema");
|
||||
return {};
|
||||
}
|
||||
|
||||
getValidator() {
|
||||
const version = this.getVersion();
|
||||
if (validators[version]) {
|
||||
return validators[version];
|
||||
}
|
||||
logger.log("Compiling schema for savegame version", version);
|
||||
const schema = this.getSchemaUncached();
|
||||
try {
|
||||
validators[version] = ajv.compile(schema);
|
||||
} catch (ex) {
|
||||
logger.error("SCHEMA FOR", this.getVersion(), "IS INVALID!");
|
||||
logger.error(ex);
|
||||
throw new Error("Invalid schema for version " + version);
|
||||
}
|
||||
return validators[version];
|
||||
}
|
||||
|
||||
/**
|
||||
* Constructs an new interface for the given savegame
|
||||
* @param {any} data
|
||||
*/
|
||||
constructor(data) {
|
||||
this.data = data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates the data
|
||||
* @returns {boolean}
|
||||
*/
|
||||
validate() {
|
||||
const validator = this.getValidator();
|
||||
|
||||
if (!validator(this.data)) {
|
||||
logger.error(
|
||||
"Savegame failed validation! ErrorText:",
|
||||
ajv.errorsText(validator.errors),
|
||||
"RawErrors:",
|
||||
validator.errors
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
///// INTERFACE (Override when the schema changes) /////
|
||||
|
||||
/**
|
||||
* Returns the time of last update
|
||||
* @returns {number}
|
||||
*/
|
||||
readLastUpdate() {
|
||||
return this.data.lastUpdate;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the ingame time in seconds
|
||||
* @returns {number}
|
||||
*/
|
||||
readIngameTimeSeconds() {
|
||||
return this.data.dump.time.timeSeconds;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
|
||||
//////// ANTICHEAT ///////
|
||||
|
||||
/**
|
||||
* Detects cheats in the savegmae - returns false if the game looks cheated
|
||||
*/
|
||||
performAnticheatCheck() {
|
||||
// TODO
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
35
src/js/savegame/savegame_interface_registry.js
Normal file
35
src/js/savegame/savegame_interface_registry.js
Normal file
@@ -0,0 +1,35 @@
|
||||
import { BaseSavegameInterface } from "./savegame_interface";
|
||||
import { SavegameInterface_V1000 } from "./schemas/1000";
|
||||
import { createLogger } from "../core/logging";
|
||||
|
||||
/** @type {Object.<number, typeof BaseSavegameInterface>} */
|
||||
const interfaces = {
|
||||
1000: SavegameInterface_V1000,
|
||||
};
|
||||
|
||||
const logger = createLogger("savegame_interface_registry");
|
||||
|
||||
/**
|
||||
* Returns if the given savegame has any supported interface
|
||||
* @param {any} savegame
|
||||
* @returns {BaseSavegameInterface|null}
|
||||
*/
|
||||
export function getSavegameInterface(savegame) {
|
||||
if (!savegame || !savegame.version) {
|
||||
logger.warn("Savegame does not contain a valid version (undefined)");
|
||||
return null;
|
||||
}
|
||||
const version = savegame.version;
|
||||
if (!Number.isInteger(version)) {
|
||||
logger.warn("Savegame does not contain a valid version (non-integer):", version);
|
||||
return null;
|
||||
}
|
||||
|
||||
const interfaceClass = interfaces[version];
|
||||
if (!interfaceClass) {
|
||||
logger.warn("Version", version, "has no implemented interface!");
|
||||
return null;
|
||||
}
|
||||
|
||||
return new interfaceClass(savegame);
|
||||
}
|
||||
211
src/js/savegame/savegame_manager.js
Normal file
211
src/js/savegame/savegame_manager.js
Normal file
@@ -0,0 +1,211 @@
|
||||
import { ExplainedResult } from "../core/explained_result";
|
||||
import { createLogger } from "../core/logging";
|
||||
import { ReadWriteProxy } from "../core/read_write_proxy";
|
||||
import { globalConfig } from "../core/config";
|
||||
import { Savegame } from "./savegame";
|
||||
import { Math_floor } from "../core/builtins";
|
||||
|
||||
const logger = createLogger("savegame_manager");
|
||||
|
||||
const Rusha = require("rusha");
|
||||
|
||||
/** @enum {string} */
|
||||
export const enumLocalSavegameStatus = {
|
||||
offline: "offline",
|
||||
synced: "synced",
|
||||
};
|
||||
|
||||
/**
|
||||
* @typedef {{
|
||||
* lastUpdate: number,
|
||||
* version: number,
|
||||
* internalId: string
|
||||
* }} SavegameMetadata
|
||||
*
|
||||
* @typedef {{
|
||||
* version: number,
|
||||
* savegames: Array<SavegameMetadata>
|
||||
* }} SavegamesData
|
||||
*/
|
||||
|
||||
export class SavegameManager extends ReadWriteProxy {
|
||||
constructor(app) {
|
||||
super(app, "savegames.bin");
|
||||
|
||||
/** @type {SavegamesData} */
|
||||
this.currentData = this.getDefaultData();
|
||||
}
|
||||
|
||||
// RW Proxy Impl
|
||||
/**
|
||||
* @returns {SavegamesData}
|
||||
*/
|
||||
getDefaultData() {
|
||||
return {
|
||||
version: this.getCurrentVersion(),
|
||||
savegames: [],
|
||||
};
|
||||
}
|
||||
|
||||
getCurrentVersion() {
|
||||
return 1000;
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {SavegamesData}
|
||||
*/
|
||||
getCurrentData() {
|
||||
return super.getCurrentData();
|
||||
}
|
||||
|
||||
verify(data) {
|
||||
// TODO / FIXME!!!!
|
||||
return ExplainedResult.good();
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {SavegamesData} data
|
||||
*/
|
||||
migrate(data) {
|
||||
return ExplainedResult.good();
|
||||
}
|
||||
|
||||
// End rw proxy
|
||||
|
||||
/**
|
||||
* @returns {Array<SavegameMetadata>}
|
||||
*/
|
||||
getSavegamesMetaData() {
|
||||
return this.currentData.savegames;
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {string} internalId
|
||||
* @returns {Savegame}
|
||||
*/
|
||||
getSavegameById(internalId) {
|
||||
const metadata = this.getGameMetaDataByInternalId(internalId);
|
||||
if (!metadata) {
|
||||
return null;
|
||||
}
|
||||
return new Savegame(this.app, { internalId, metaDataRef: metadata });
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes a savegame
|
||||
* @param {SavegameMetadata} game
|
||||
*/
|
||||
deleteSavegame(game) {
|
||||
const handle = new Savegame(this.app, {
|
||||
internalId: game.internalId,
|
||||
metaDataRef: game,
|
||||
});
|
||||
|
||||
return handle.deleteAsync().then(() => {
|
||||
for (let i = 0; i < this.currentData.savegames.length; ++i) {
|
||||
const potentialGame = this.currentData.savegames[i];
|
||||
if (potentialGame.internalId === handle.internalId) {
|
||||
this.currentData.savegames.splice(i, 1);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return this.writeAsync();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a given games metadata by id
|
||||
* @param {string} id
|
||||
* @returns {SavegameMetadata}
|
||||
*/
|
||||
getGameMetaDataByInternalId(id) {
|
||||
for (let i = 0; i < this.currentData.savegames.length; ++i) {
|
||||
const data = this.currentData.savegames[i];
|
||||
if (data.internalId === id) {
|
||||
return data;
|
||||
}
|
||||
}
|
||||
logger.error("Savegame internal id not found:", id);
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new savegame
|
||||
* @returns {Savegame}
|
||||
*/
|
||||
createNewSavegame() {
|
||||
const id = this.generateInternalId();
|
||||
|
||||
const metaData = /** @type {SavegameMetadata} */ ({
|
||||
lastUpdate: Date.now(),
|
||||
version: Savegame.getCurrentVersion(),
|
||||
internalId: id,
|
||||
});
|
||||
|
||||
this.currentData.savegames.push(metaData);
|
||||
this.sortSavegames();
|
||||
|
||||
return new Savegame(this.app, {
|
||||
internalId: id,
|
||||
metaDataRef: metaData,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Sorts all savegames by their creation time descending
|
||||
* @returns {Promise<any>}
|
||||
*/
|
||||
sortSavegames() {
|
||||
this.currentData.savegames.sort((a, b) => b.lastUpdate - a.lastUpdate);
|
||||
let promiseChain = Promise.resolve();
|
||||
while (this.currentData.savegames.length > 100) {
|
||||
const toRemove = this.currentData.savegames.pop();
|
||||
|
||||
// Try to remove the savegame since its no longer available
|
||||
const game = new Savegame(this.app, {
|
||||
internalId: toRemove.internalId,
|
||||
metaDataRef: toRemove,
|
||||
});
|
||||
promiseChain = promiseChain
|
||||
.then(() => game.deleteAsync())
|
||||
.then(
|
||||
() => {},
|
||||
err => {
|
||||
logger.error(this, "Failed to remove old savegame:", toRemove, ":", err);
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
return promiseChain;
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper method to generate a new internal savegame id
|
||||
*/
|
||||
generateInternalId() {
|
||||
const timestamp = ("" + Math_floor(Date.now() / 1000.0 - 1565641619)).padStart(10, "0");
|
||||
return (
|
||||
timestamp +
|
||||
"." +
|
||||
Rusha.createHash()
|
||||
.update(Date.now() + "/" + Math.random())
|
||||
.digest("hex")
|
||||
);
|
||||
}
|
||||
|
||||
// End
|
||||
|
||||
initialize() {
|
||||
// First read, then directly write to ensure we have the latest data
|
||||
// @ts-ignore
|
||||
return this.readAsync().then(() => {
|
||||
if (G_IS_DEV && globalConfig.debug.disableSavegameWrite) {
|
||||
return Promise.resolve();
|
||||
}
|
||||
return this.writeAsync();
|
||||
});
|
||||
}
|
||||
}
|
||||
216
src/js/savegame/savegame_serializer.js
Normal file
216
src/js/savegame/savegame_serializer.js
Normal file
@@ -0,0 +1,216 @@
|
||||
/* typehints:start */
|
||||
import { Component } from "../game/component";
|
||||
import { GameRoot } from "../game/root";
|
||||
/* typehints:end */
|
||||
|
||||
import { JSON_stringify } from "../core/builtins";
|
||||
import { ExplainedResult } from "../core/explained_result";
|
||||
import { createLogger } from "../core/logging";
|
||||
// import { BuildingComponent } from "../components/impl/building";
|
||||
import { gComponentRegistry } from "../core/global_registries";
|
||||
import { SerializerInternal } from "./serializer_internal";
|
||||
|
||||
const logger = createLogger("savegame_serializer");
|
||||
|
||||
/**
|
||||
* Allows to serialize a savegame
|
||||
*/
|
||||
export class SavegameSerializer {
|
||||
constructor() {
|
||||
this.internal = new SerializerInternal();
|
||||
}
|
||||
|
||||
/**
|
||||
* Serializes the game root into a dump
|
||||
* @param {GameRoot} root
|
||||
* @param {boolean=} sanityChecks Whether to check for validity
|
||||
* @returns {SerializedGame}
|
||||
*/
|
||||
generateDumpFromGameRoot(root, sanityChecks = true) {
|
||||
// Finalize particles before saving (Like granting destroy indicator rewards)
|
||||
// root.particleMgr.finalizeBeforeSave();
|
||||
// root.uiParticleMgr.finalizeBeforeSave();
|
||||
|
||||
// Now store generic savegame payload
|
||||
const data = /** @type {SerializedGame} */ ({
|
||||
camera: root.camera.serialize(),
|
||||
time: root.time.serialize(),
|
||||
entityMgr: root.entityMgr.serialize(),
|
||||
entities: {},
|
||||
});
|
||||
|
||||
// Serialize all types of entities
|
||||
const serializeEntities = component =>
|
||||
this.internal.serializeEntityArray(root.entityMgr.getAllWithComponent(component));
|
||||
const serializeEntitiesFixed = component =>
|
||||
this.internal.serializeEntityArrayFixedType(root.entityMgr.getAllWithComponent(component));
|
||||
|
||||
// data.entities.resources = serializeEntitiesFixed(RawMaterialComponent);
|
||||
// data.entities.buildings = serializeEntities(BuildingComponent);
|
||||
|
||||
if (!G_IS_RELEASE) {
|
||||
if (sanityChecks) {
|
||||
// Sanity check
|
||||
const sanity = this.verifyLogicalErrors(data);
|
||||
if (!sanity.result) {
|
||||
logger.error("Created invalid savegame:", sanity.reason, "savegame:", data);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Verifies if there are logical errors in the savegame
|
||||
* @param {SerializedGame} savegame
|
||||
* @returns {ExplainedResult}
|
||||
*/
|
||||
verifyLogicalErrors(savegame) {
|
||||
if (!savegame.entities) {
|
||||
return ExplainedResult.bad("Savegame has no entities");
|
||||
}
|
||||
|
||||
const seenUids = [];
|
||||
|
||||
// Check for duplicate UIDS
|
||||
for (const entityListId in savegame.entities) {
|
||||
for (let i = 0; i < savegame.entities[entityListId].length; ++i) {
|
||||
const list = savegame.entities[entityListId][i];
|
||||
for (let k = 0; k < list.length; ++k) {
|
||||
const entity = list[k];
|
||||
const uid = entity.uid;
|
||||
if (!Number.isInteger(uid)) {
|
||||
return ExplainedResult.bad("Entity has invalid uid: " + uid);
|
||||
}
|
||||
if (seenUids.indexOf(uid) >= 0) {
|
||||
return ExplainedResult.bad("Duplicate uid " + uid);
|
||||
}
|
||||
seenUids.push(uid);
|
||||
|
||||
// Verify components
|
||||
if (!entity.components) {
|
||||
return ExplainedResult.bad(
|
||||
"Entity is missing key 'components': " + JSON_stringify(entity)
|
||||
);
|
||||
}
|
||||
const components = entity.components;
|
||||
for (const componentId in components) {
|
||||
// Verify component data
|
||||
const componentData = components[componentId];
|
||||
const componentClass = gComponentRegistry.findById(componentId);
|
||||
|
||||
// Check component id is known
|
||||
if (!componentClass) {
|
||||
return ExplainedResult.bad("Unknown component id: " + componentId);
|
||||
}
|
||||
|
||||
// Check component data is ok
|
||||
const componentVerifyError = /** @type {typeof Component} */ (componentClass).verify(
|
||||
componentData
|
||||
);
|
||||
if (componentVerifyError) {
|
||||
return ExplainedResult.bad(
|
||||
"Component " + componentId + " has invalid data: " + componentVerifyError
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return ExplainedResult.good();
|
||||
}
|
||||
|
||||
/**
|
||||
* Tries to load the savegame from a given dump
|
||||
* @param {SerializedGame} savegame
|
||||
* @param {GameRoot} root
|
||||
* @returns {ExplainedResult}
|
||||
*/
|
||||
deserialize(savegame, root) {
|
||||
// Sanity
|
||||
const verifyResult = this.verifyLogicalErrors(savegame);
|
||||
if (!verifyResult.result) {
|
||||
return ExplainedResult.bad(verifyResult.reason);
|
||||
}
|
||||
|
||||
let errorReason = null;
|
||||
|
||||
// entities
|
||||
errorReason = errorReason || root.entityMgr.deserialize(savegame.entityMgr);
|
||||
|
||||
// resources
|
||||
errorReason =
|
||||
errorReason ||
|
||||
this.internal.deserializeEntityArrayFixedType(
|
||||
root,
|
||||
savegame.entities.resources,
|
||||
this.internal.deserializeResource
|
||||
);
|
||||
|
||||
// buildings
|
||||
errorReason =
|
||||
errorReason ||
|
||||
this.internal.deserializeEntityArray(
|
||||
root,
|
||||
savegame.entities.buildings,
|
||||
this.internal.deserializeBuilding
|
||||
);
|
||||
|
||||
// other stuff
|
||||
errorReason = errorReason || root.time.deserialize(savegame.time);
|
||||
errorReason = errorReason || root.camera.deserialize(savegame.camera);
|
||||
|
||||
// Check for errors
|
||||
if (errorReason) {
|
||||
return ExplainedResult.bad(errorReason);
|
||||
}
|
||||
|
||||
return ExplainedResult.good();
|
||||
}
|
||||
|
||||
/////////// MIGRATION HELPERS ///////////
|
||||
|
||||
/**
|
||||
* Performs a function on each component (useful to add / remove / alter properties for migration)
|
||||
* @param {SerializedGame} savegame
|
||||
* @param {typeof Component} componentHandle
|
||||
* @param {function} modifier
|
||||
*/
|
||||
migration_migrateComponent(savegame, componentHandle, modifier) {
|
||||
const targetId = componentHandle.getId();
|
||||
for (const entityListId in savegame.entities) {
|
||||
for (let i = 0; i < savegame.entities[entityListId].length; ++i) {
|
||||
const list = savegame.entities[entityListId][i];
|
||||
for (let k = 0; k < list.length; ++k) {
|
||||
const entity = list[k];
|
||||
const components = entity.components;
|
||||
if (components[targetId]) {
|
||||
modifier(components[targetId]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Performs an operation on each object which is a PooledObject (usually Projectiles). Useful to
|
||||
* perform migrations
|
||||
* @param {Array<any>} pools
|
||||
* @param {string} targetClassKey
|
||||
* @param {function} modifier
|
||||
*/
|
||||
migration_migrateGenericObjectPool(pools, targetClassKey, modifier) {
|
||||
for (let i = 0; i < pools.length; ++i) {
|
||||
const pool = pools[i];
|
||||
if (pool.key === targetClassKey) {
|
||||
const entries = pool.data.entries;
|
||||
for (const uid in entries) {
|
||||
modifier(entries[uid]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
35
src/js/savegame/savegame_typedefs.js
Normal file
35
src/js/savegame/savegame_typedefs.js
Normal file
@@ -0,0 +1,35 @@
|
||||
/**
|
||||
* @typedef {{
|
||||
* buildingsPlaced: number
|
||||
* }} SavegameStats
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {{
|
||||
* x: number,
|
||||
* y: number,
|
||||
* uid: number,
|
||||
* key: string
|
||||
* }} SerializedMapResource
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {{
|
||||
* camera: any,
|
||||
* time: any,
|
||||
* entityMgr: any,
|
||||
* entities: {
|
||||
* resources: Array<SerializedMapResource>,
|
||||
* buildings: Array<any>
|
||||
* }
|
||||
* }} SerializedGame
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {{
|
||||
* version: number,
|
||||
* dump: SerializedGame,
|
||||
* stats: SavegameStats,
|
||||
* lastUpdate: number
|
||||
* }} SavegameData
|
||||
*/
|
||||
13
src/js/savegame/schemas/1000.js
Normal file
13
src/js/savegame/schemas/1000.js
Normal file
@@ -0,0 +1,13 @@
|
||||
import { BaseSavegameInterface } from "../savegame_interface.js";
|
||||
|
||||
const schema = require("./1000.json");
|
||||
|
||||
export class SavegameInterface_V1000 extends BaseSavegameInterface {
|
||||
getVersion() {
|
||||
return 1000;
|
||||
}
|
||||
|
||||
getSchemaUncached() {
|
||||
return schema;
|
||||
}
|
||||
}
|
||||
5
src/js/savegame/schemas/1000.json
Normal file
5
src/js/savegame/schemas/1000.json
Normal file
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"type": "object",
|
||||
"required": [],
|
||||
"additionalProperties": true
|
||||
}
|
||||
330
src/js/savegame/serialization.js
Normal file
330
src/js/savegame/serialization.js
Normal file
@@ -0,0 +1,330 @@
|
||||
import { JSON_stringify } from "../core/builtins";
|
||||
import {
|
||||
BaseDataType,
|
||||
TypeArray,
|
||||
TypeBoolean,
|
||||
TypeClass,
|
||||
TypeClassId,
|
||||
TypeEntity,
|
||||
TypeEntityWeakref,
|
||||
TypeEnum,
|
||||
TypeFixedClass,
|
||||
TypeInteger,
|
||||
TypeKeyValueMap,
|
||||
TypeMetaClass,
|
||||
TypeNullable,
|
||||
TypeNumber,
|
||||
TypePair,
|
||||
TypePositiveInteger,
|
||||
TypePositiveNumber,
|
||||
TypeString,
|
||||
TypeVector,
|
||||
TypeClassFromMetaclass,
|
||||
TypeClassData,
|
||||
} from "./serialization_data_types";
|
||||
import { createLogger } from "../core/logging";
|
||||
|
||||
const logger = createLogger("serialization");
|
||||
|
||||
// Schema declarations
|
||||
export const types = {
|
||||
int: new TypeInteger(),
|
||||
uint: new TypePositiveInteger(),
|
||||
float: new TypeNumber(),
|
||||
ufloat: new TypePositiveNumber(),
|
||||
string: new TypeString(),
|
||||
entity: new TypeEntity(),
|
||||
weakEntityRef: new TypeEntityWeakref(),
|
||||
vector: new TypeVector(),
|
||||
tileVector: new TypeVector(),
|
||||
bool: new TypeBoolean(),
|
||||
|
||||
/**
|
||||
* @param {BaseDataType} wrapped
|
||||
*/
|
||||
nullable(wrapped) {
|
||||
return new TypeNullable(wrapped);
|
||||
},
|
||||
|
||||
/**
|
||||
* @param {FactoryTemplate<*>|SingletonFactoryTemplate<*>} registry
|
||||
*/
|
||||
classId(registry) {
|
||||
return new TypeClassId(registry);
|
||||
},
|
||||
/**
|
||||
* @param {BaseDataType} valueType
|
||||
* @param {boolean=} includeEmptyValues
|
||||
*/
|
||||
keyValueMap(valueType, includeEmptyValues = true) {
|
||||
return new TypeKeyValueMap(valueType, includeEmptyValues);
|
||||
},
|
||||
|
||||
/**
|
||||
* @param {Array<string>} values
|
||||
*/
|
||||
enum(values) {
|
||||
return new TypeEnum(values);
|
||||
},
|
||||
|
||||
/**
|
||||
* @param {FactoryTemplate<*>} registry
|
||||
*/
|
||||
obj(registry) {
|
||||
return new TypeClass(registry);
|
||||
},
|
||||
|
||||
/**
|
||||
* @param {FactoryTemplate<*>} registry
|
||||
*/
|
||||
objData(registry) {
|
||||
return new TypeClassData(registry);
|
||||
},
|
||||
|
||||
/**
|
||||
* @param {typeof BasicSerializableObject} cls
|
||||
*/
|
||||
knownType(cls) {
|
||||
return new TypeFixedClass(cls);
|
||||
},
|
||||
|
||||
/**
|
||||
* @param {BaseDataType} innerType
|
||||
*/
|
||||
array(innerType) {
|
||||
return new TypeArray(innerType);
|
||||
},
|
||||
|
||||
/**
|
||||
* @param {SingletonFactoryTemplate<*>} innerType
|
||||
*/
|
||||
classRef(registry) {
|
||||
return new TypeMetaClass(registry);
|
||||
},
|
||||
|
||||
/**
|
||||
* @param {BaseDataType} a
|
||||
* @param {BaseDataType} b
|
||||
*/
|
||||
pair(a, b) {
|
||||
return new TypePair(a, b);
|
||||
},
|
||||
|
||||
/**
|
||||
* @param {typeof BasicSerializableObject} classHandle
|
||||
* @param {SingletonFactoryTemplate<*>} registry
|
||||
*/
|
||||
classWithMetaclass(classHandle, registry) {
|
||||
return new TypeClassFromMetaclass(classHandle, registry);
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* A full schema declaration
|
||||
* @typedef {Object.<string, BaseDataType>} Schema
|
||||
*/
|
||||
|
||||
const globalSchemaCache = {};
|
||||
|
||||
/* dev:start */
|
||||
const classnamesCache = {};
|
||||
/* dev:end*/
|
||||
|
||||
export class BasicSerializableObject {
|
||||
/* dev:start */
|
||||
/**
|
||||
* Fixes typeof DerivedComponent is not assignable to typeof Component, compiled out
|
||||
* in non-dev builds
|
||||
*/
|
||||
constructor(...args) {}
|
||||
|
||||
/* dev:end */
|
||||
|
||||
static getId() {
|
||||
abstract;
|
||||
}
|
||||
|
||||
/**
|
||||
* Should return the serialization schema
|
||||
* @returns {Schema}
|
||||
*/
|
||||
static getSchema() {
|
||||
return {};
|
||||
}
|
||||
|
||||
// Implementation
|
||||
/** @returns {Schema} */
|
||||
static getCachedSchema() {
|
||||
const id = this.getId();
|
||||
|
||||
/* dev:start */
|
||||
assert(
|
||||
classnamesCache[id] === this || classnamesCache[id] === undefined,
|
||||
"Class name taken twice: " + id + " (from " + this.name + ")"
|
||||
);
|
||||
classnamesCache[id] = this;
|
||||
/* dev:end */
|
||||
|
||||
const entry = globalSchemaCache[id];
|
||||
if (entry) {
|
||||
return entry;
|
||||
}
|
||||
|
||||
const schema = this.getSchema();
|
||||
globalSchemaCache[id] = schema;
|
||||
return schema;
|
||||
}
|
||||
|
||||
/** @returns {object} */
|
||||
serialize() {
|
||||
return serializeSchema(
|
||||
this,
|
||||
/** @type {typeof BasicSerializableObject} */ (this.constructor).getCachedSchema()
|
||||
);
|
||||
}
|
||||
|
||||
/** @returns {string|void} */
|
||||
deserialize(data) {
|
||||
return deserializeSchema(
|
||||
this,
|
||||
/** @type {typeof BasicSerializableObject} */ (this.constructor).getCachedSchema(),
|
||||
data
|
||||
);
|
||||
}
|
||||
|
||||
/** @returns {string|void} */
|
||||
static verify(data) {
|
||||
return verifySchema(this.getCachedSchema(), data);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Serializes an object using the given schema, mergin with the given properties
|
||||
* @param {object} obj The object to serialize
|
||||
* @param {Schema} schema The schema to use
|
||||
* @param {object=} mergeWith Any additional properties to merge with the schema, useful for super calls
|
||||
* @returns {object} Serialized data object
|
||||
*/
|
||||
export function serializeSchema(obj, schema, mergeWith = {}) {
|
||||
for (const key in schema) {
|
||||
if (!obj.hasOwnProperty(key)) {
|
||||
logger.error("Invalid schema, property", key, "does not exist on", obj, "(schema=", schema, ")");
|
||||
assert(
|
||||
obj.hasOwnProperty(key),
|
||||
"serialization: invalid schema, property does not exist on object: " + key
|
||||
);
|
||||
}
|
||||
if (!schema[key]) {
|
||||
assert(false, "Invalid schema: " + JSON_stringify(schema) + " / " + key);
|
||||
}
|
||||
|
||||
if (G_IS_DEV) {
|
||||
try {
|
||||
mergeWith[key] = schema[key].serialize(obj[key]);
|
||||
} catch (ex) {
|
||||
logger.error(
|
||||
"Serialization of",
|
||||
obj,
|
||||
"failed on key '" + key + "' ->",
|
||||
ex,
|
||||
"(schema was",
|
||||
schema,
|
||||
")"
|
||||
);
|
||||
throw ex;
|
||||
}
|
||||
} else {
|
||||
mergeWith[key] = schema[key].serialize(obj[key]);
|
||||
}
|
||||
}
|
||||
return mergeWith;
|
||||
}
|
||||
|
||||
/**
|
||||
* Deserializes data into an object
|
||||
* @param {object} obj The object to store the deserialized data into
|
||||
* @param {Schema} schema The schema to use
|
||||
* @param {object} data The serialized data
|
||||
* @param {string|void|null=} baseclassErrorResult Convenience, if this is a string error code, do nothing and return it
|
||||
* @returns {string|void} String error code or nothing on success
|
||||
*/
|
||||
export function deserializeSchema(obj, schema, data, baseclassErrorResult = null) {
|
||||
if (baseclassErrorResult) {
|
||||
return baseclassErrorResult;
|
||||
}
|
||||
|
||||
if (!data) {
|
||||
logger.error("Got 'NULL' data for", obj, "and schema", schema, "!");
|
||||
return "Got null data";
|
||||
}
|
||||
|
||||
for (const key in schema) {
|
||||
if (!data.hasOwnProperty(key)) {
|
||||
logger.error("Data", data, "does not contain", key, "(schema:", schema, ")");
|
||||
return "Missing key in schema: " + key + " of class " + obj.constructor.name;
|
||||
}
|
||||
if (!schema[key].allowNull() && (data[key] === null || data[key] === undefined)) {
|
||||
logger.error("Data", data, "has null value for", key, "(schema:", schema, ")");
|
||||
return "Non-nullable entry is null: " + key + " of class " + obj.constructor.name;
|
||||
}
|
||||
|
||||
const errorStatus = schema[key].deserializeWithVerify(data[key], obj, key, obj.root);
|
||||
if (errorStatus) {
|
||||
error(
|
||||
"serialization",
|
||||
"Deserialization failed with error '" + errorStatus + "' on object",
|
||||
obj,
|
||||
"and key",
|
||||
key,
|
||||
"(root? =",
|
||||
obj.root ? "y" : "n",
|
||||
")"
|
||||
);
|
||||
return errorStatus;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Verifies stored data using the given schema
|
||||
* @param {Schema} schema The schema to use
|
||||
* @param {object} data The data to verify
|
||||
* @returns {string|void} String error code or nothing on success
|
||||
*/
|
||||
export function verifySchema(schema, data) {
|
||||
for (const key in schema) {
|
||||
if (!data.hasOwnProperty(key)) {
|
||||
error("verify", "Data", data, "does not contain", key, "(schema:", schema, ")");
|
||||
return "verify: missing key required by schema in stored data: " + key;
|
||||
}
|
||||
if (!schema[key].allowNull() && (data[key] === null || data[key] === undefined)) {
|
||||
error("verify", "Data", data, "has null value for", key, "(schema:", schema, ")");
|
||||
return "verify: non-nullable entry is null: " + key;
|
||||
}
|
||||
|
||||
const errorStatus = schema[key].verifySerializedValue(data[key]);
|
||||
if (errorStatus) {
|
||||
error("verify", errorStatus);
|
||||
return "verify: " + errorStatus;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extends a schema by adding the properties from the new schema to the existing base schema
|
||||
* @param {Schema} base
|
||||
* @param {Schema} newOne
|
||||
* @returns {Schema}
|
||||
*/
|
||||
export function extendSchema(base, newOne) {
|
||||
/** @type {Schema} */
|
||||
const result = Object.assign({}, base);
|
||||
for (const key in newOne) {
|
||||
if (result.hasOwnProperty(key)) {
|
||||
logger.error("Extend schema got duplicate key:", key);
|
||||
continue;
|
||||
}
|
||||
result[key] = newOne[key];
|
||||
}
|
||||
return result;
|
||||
}
|
||||
1204
src/js/savegame/serialization_data_types.js
Normal file
1204
src/js/savegame/serialization_data_types.js
Normal file
File diff suppressed because it is too large
Load Diff
180
src/js/savegame/serializer_internal.js
Normal file
180
src/js/savegame/serializer_internal.js
Normal file
@@ -0,0 +1,180 @@
|
||||
/* typehints:start */
|
||||
import { GameRoot } from "../game/root";
|
||||
/* typehints:end */
|
||||
|
||||
import { Vector } from "../core/vector";
|
||||
import { createLogger } from "../core/logging";
|
||||
import { gMetaBuildingRegistry } from "../core/global_registries";
|
||||
import { Entity } from "../game/entity";
|
||||
|
||||
const logger = createLogger("serializer_internal");
|
||||
|
||||
// Internal serializer methods
|
||||
export class SerializerInternal {
|
||||
constructor() {}
|
||||
|
||||
/**
|
||||
* Serializes an array of entities
|
||||
* @param {Array<Entity>} array
|
||||
*/
|
||||
serializeEntityArray(array) {
|
||||
const serialized = [];
|
||||
for (let i = 0; i < array.length; ++i) {
|
||||
const entity = array[i];
|
||||
if (!entity.queuedForDestroy && !entity.destroyed) {
|
||||
serialized.push({
|
||||
$: entity.getMetaclass().getId(),
|
||||
data: entity.serialize(),
|
||||
});
|
||||
}
|
||||
}
|
||||
return serialized;
|
||||
}
|
||||
|
||||
/**
|
||||
* Serializes an array of entities where we know the type of
|
||||
* @param {Array<Entity>} array
|
||||
*/
|
||||
serializeEntityArrayFixedType(array) {
|
||||
const serialized = [];
|
||||
for (let i = 0; i < array.length; ++i) {
|
||||
const entity = array[i];
|
||||
if (!entity.queuedForDestroy && !entity.destroyed) {
|
||||
serialized.push(entity.serialize());
|
||||
}
|
||||
}
|
||||
return serialized;
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {GameRoot} root
|
||||
* @param {Array<any>} array
|
||||
* @param {function(GameRoot, { $: string, data: object }):string|void} deserializerMethod
|
||||
* @returns {string|void}
|
||||
*/
|
||||
deserializeEntityArray(root, array, deserializerMethod) {
|
||||
for (let i = 0; i < array.length; ++i) {
|
||||
const errorState = deserializerMethod.call(this, root, array[i]);
|
||||
if (errorState) {
|
||||
return errorState;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {GameRoot} root
|
||||
* @param {Array<any>} array
|
||||
* @param {function(GameRoot, object):string|void} deserializerMethod
|
||||
* @returns {string|void}
|
||||
*/
|
||||
deserializeEntityArrayFixedType(root, array, deserializerMethod) {
|
||||
for (let i = 0; i < array.length; ++i) {
|
||||
const errorState = deserializerMethod.call(this, root, array[i]);
|
||||
if (errorState) {
|
||||
return errorState;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Deserializes a building
|
||||
* @param {GameRoot} root
|
||||
* @param {{ $: string, data: any }} payload
|
||||
*/
|
||||
deserializeBuilding(root, payload) {
|
||||
const data = payload.data;
|
||||
const id = payload.$;
|
||||
if (!gMetaBuildingRegistry.hasId(id)) {
|
||||
return "Metaclass not found for building: '" + id + "'";
|
||||
}
|
||||
const meta = gMetaBuildingRegistry.findById(id);
|
||||
if (!meta) {
|
||||
return "Metaclass not found for building: '" + id + "'";
|
||||
}
|
||||
|
||||
const tile = new Vector(data.x, data.y).toTileSpace();
|
||||
const instance = root.logic.internalPlaceBuildingLocalClientOnly({
|
||||
tile: tile,
|
||||
metaBuilding: meta,
|
||||
uid: data.uid,
|
||||
});
|
||||
|
||||
// Apply component specific properties
|
||||
const errorStatus = this.deserializeComponents(instance, data.components);
|
||||
if (errorStatus) {
|
||||
return errorStatus;
|
||||
}
|
||||
|
||||
// Apply enhancements
|
||||
instance.updateEnhancements();
|
||||
}
|
||||
|
||||
/**
|
||||
* Deserializes a blueprint
|
||||
* @param {GameRoot} root
|
||||
* @param {any} data
|
||||
* @returns {string|void}
|
||||
*/
|
||||
deserializeBlueprint(root, data) {
|
||||
const id = data.meta;
|
||||
const metaClass = gMetaBuildingRegistry.findById(id);
|
||||
if (!metaClass) {
|
||||
return "Metaclass not found for blueprint: '" + id + "'";
|
||||
}
|
||||
|
||||
const tile = new Vector(data.x, data.y).toTileSpace();
|
||||
const instance = root.logic.internalPlaceBlueprintLocalClientOnly({
|
||||
tile: tile,
|
||||
metaBuilding: metaClass,
|
||||
uid: data.uid,
|
||||
});
|
||||
return this.deserializeComponents(instance, data.components);
|
||||
}
|
||||
|
||||
/////// COMPONENTS ////
|
||||
|
||||
/**
|
||||
* Deserializes components of an entity
|
||||
* @param {Entity} entity
|
||||
* @param {Object.<string, any>} data
|
||||
* @returns {string|void}
|
||||
*/
|
||||
deserializeComponents(entity, data) {
|
||||
for (const componentId in data) {
|
||||
const componentHandle = entity.components[componentId];
|
||||
if (!componentHandle) {
|
||||
logger.warn(
|
||||
"Loading outdated savegame, where entity had component",
|
||||
componentId,
|
||||
"but now no longer has"
|
||||
);
|
||||
continue;
|
||||
}
|
||||
const componentData = data[componentId];
|
||||
const errorStatus = componentHandle.deserialize(componentData);
|
||||
if (errorStatus) {
|
||||
return errorStatus;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Deserializes a resource
|
||||
* @param {GameRoot} root
|
||||
* @param {object} data
|
||||
* @returns {string|void}
|
||||
*/
|
||||
deserializeResource(root, data) {
|
||||
const id = data.key;
|
||||
const instance = new MapResource(root, this.neutralFaction, id);
|
||||
root.logic.internalPlaceMapEntityLocalClientOnly(
|
||||
new Vector(data.x, data.y).toTileSpace(),
|
||||
instance,
|
||||
data.uid
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user