diff --git a/README.md b/README.md
index 42534ab4c..e37d340ca 100644
--- a/README.md
+++ b/README.md
@@ -95,6 +95,11 @@ Optimizations, quality and logics updates are welcome.
- [parse](cli/parse/README.md) - parsing utils to collect documentation or JSON summaries
- `help` - print list of commands and information about them
+## 💿 Build
+
+Script engine can be packaged and built into custom game package.
+Detailed description: [link](doc/BUILDING_CUSTOM_GAME_PACKAGE.md)
+
---
## 🧰 Docs
@@ -104,7 +109,11 @@ Optimizations, quality and logics updates are welcome.
## 🏗️ Assets
+Additional assets repository can be cloned manually or with shortcut command:
+`npm run cli clone *name*` (`extended`, `locale-eng`, `locale-ukr`, `locale-rus`)
+
- Extended assets: [https://gitlab.com/xray-forge/stalker-xrf-resources-extended](https://gitlab.com/xray-forge/stalker-xrf-resources-extended)
- EN locale assets: [https://gitlab.com/xray-forge/stalker-xrf-resources-locale-eng](https://gitlab.com/xray-forge/stalker-xrf-resources-locale-eng)
- UA locale assets: [https://gitlab.com/xray-forge/stalker-xrf-resources-locale-ukr](https://gitlab.com/xray-forge/stalker-xrf-resources-locale-ukr)
- RU locale assets: [https://gitlab.com/xray-forge/stalker-xrf-resources-locale-rus](https://gitlab.com/xray-forge/stalker-xrf-resources-locale-rus)
+
diff --git a/doc/BUILDING_CUSTOM_GAME_PACKAGE.md b/doc/BUILDING_CUSTOM_GAME_PACKAGE.md
index b9fa375b0..9a7a0816f 100644
--- a/doc/BUILDING_CUSTOM_GAME_PACKAGE.md
+++ b/doc/BUILDING_CUSTOM_GAME_PACKAGE.md
@@ -10,7 +10,7 @@ and bundled together with custom engine.
Comparing to normal gamedata builds the only needed thing is full assets list.
To build package you will need [extended](https://gitlab.com/xray-forge/stalker-xrf-resources-extended) assets
-and one of locales packs, for example [en](https://gitlab.com/xray-forge/stalker-xrf-resources-locale-en).
+and one of locales packs, for example [eng](https://gitlab.com/xray-forge/stalker-xrf-resources-locale-eng).
After cloning suggested repositories or providing custom assets, you should list them in 'config.json' if paths are different from already suggested.
@@ -19,10 +19,10 @@ After cloning suggested repositories or providing custom assets, you should list
If assets are downloaded and configured correctly, the only needed thing is:
```
-npm run cli pack game -- --clean --build --optimize
+npm run cli pack game -- --clean --optimize
# or
-npm run cli pack game -- -c -b -o
+npm run cli pack game -- -c -o
# or
npm run pack:game
diff --git a/doc/TODO.md b/doc/TODO.md
index ad314d575..53eda9ba9 100644
--- a/doc/TODO.md
+++ b/doc/TODO.md
@@ -13,6 +13,7 @@
## 🧰 Requests to open x-ray
+- Add callback notifying about game save to get filename
- With lua bindings generation include all call overrides when output TXT
- Export actor menu and actor menu item classes for overriding with lua
- Fix numerous calls to disk with menu, implement caching for character menu and fix lags when opening inventory
diff --git a/src/engine/core/database/save_markers.ts b/src/engine/core/database/save_markers.ts
index ebb6c58d5..8281fc715 100644
--- a/src/engine/core/database/save_markers.ts
+++ b/src/engine/core/database/save_markers.ts
@@ -14,7 +14,7 @@ const logger: LuaLogger = new LuaLogger($filename);
export function openSaveMarker(packet: NetPacket, markerName: TName): void {
const packetSize: TCount = packet.w_tell();
- assert(packetSize < 20_480, "You are saving too much in '%s' - '%s'.", markerName, packetSize);
+ assert(packetSize < 16_384, "You are saving too much in '%s' - '%s'.", markerName, packetSize);
registry.saveMarkers.set(markerName, packet.w_tell());
}
diff --git a/src/engine/core/managers/base/SaveManager.test.ts b/src/engine/core/managers/base/SaveManager.test.ts
index 4fe5e3b22..28b7d3c9f 100644
--- a/src/engine/core/managers/base/SaveManager.test.ts
+++ b/src/engine/core/managers/base/SaveManager.test.ts
@@ -1,6 +1,7 @@
import { beforeEach, describe, expect, it, jest } from "@jest/globals";
import { disposeManagers, initializeManager, registerActor, registry } from "@/engine/core/database";
+import { EGameEvent, EventsManager } from "@/engine/core/managers";
import { TAbstractCoreManagerConstructor } from "@/engine/core/managers/base/AbstractCoreManager";
import { SaveManager } from "@/engine/core/managers/base/SaveManager";
import { AchievementsManager } from "@/engine/core/managers/interaction/achievements";
@@ -16,8 +17,9 @@ import { SurgeManager } from "@/engine/core/managers/world/SurgeManager";
import { TreasureManager } from "@/engine/core/managers/world/TreasureManager";
import { WeatherManager } from "@/engine/core/managers/world/WeatherManager";
import { AnyObject } from "@/engine/lib/types";
-import { mockClientGameObject } from "@/fixtures/xray";
-import { mockNetPacket } from "@/fixtures/xray/mocks/save";
+import { MockIoFile } from "@/fixtures/lua";
+import { resetFunctionMock } from "@/fixtures/utils";
+import { mockClientGameObject, mockNetPacket } from "@/fixtures/xray";
describe("SaveManager class", () => {
const mockLifecycleMethods = () => {
@@ -38,9 +40,10 @@ describe("SaveManager class", () => {
beforeEach(() => {
disposeManagers();
+ resetFunctionMock(io.open);
});
- it("Should save and load data from managers in a strict order", () => {
+ it("should save and load data from managers in a strict order", () => {
const expectedOrder: Array = [
WeatherManager,
ReleaseBodyManager,
@@ -62,18 +65,18 @@ describe("SaveManager class", () => {
expect(saveOrder).toEqual([]);
expect(loadOrder).toEqual([]);
- SaveManager.getInstance().save(mockNetPacket());
+ SaveManager.getInstance().clientSave(mockNetPacket());
expect(saveOrder).toEqual(expectedOrder);
expect(loadOrder).toEqual([]);
- SaveManager.getInstance().load(mockNetPacket());
+ SaveManager.getInstance().clientLoad(mockNetPacket());
expect(saveOrder).toEqual(expectedOrder);
expect(loadOrder).toEqual(expectedOrder);
});
- it("Should read and write data from managers in a strict order", () => {
+ it("should read and write data from managers in a strict order", () => {
registerActor(mockClientGameObject());
const expectedOrder: Array = [SimulationBoardManager];
@@ -85,14 +88,85 @@ describe("SaveManager class", () => {
expect(saveOrder).toEqual([]);
expect(loadOrder).toEqual([]);
- SaveManager.getInstance().writeState(mockNetPacket());
+ SaveManager.getInstance().serverSave(mockNetPacket());
expect(saveOrder).toEqual(expectedOrder);
expect(loadOrder).toEqual([]);
- SaveManager.getInstance().readState(mockNetPacket());
+ SaveManager.getInstance().serverLoad(mockNetPacket());
expect(saveOrder).toEqual(expectedOrder);
expect(loadOrder).toEqual(expectedOrder);
});
+
+ it("should have implementation base for save callbacks", () => {
+ const saveManager: SaveManager = SaveManager.getInstance();
+
+ expect(saveManager.onBeforeGameSave).toBeDefined();
+ expect(saveManager.onGameSave).toBeDefined();
+ expect(saveManager.onBeforeGameLoad).toBeDefined();
+ expect(saveManager.onGameLoad).toBeDefined();
+ });
+
+ it("should properly create dynamic saves", () => {
+ const saveManager: SaveManager = SaveManager.getInstance();
+ const file: MockIoFile = new MockIoFile("test", "wb");
+
+ const onSave = jest.fn((data: AnyObject) => {
+ data.example = 123;
+ });
+
+ EventsManager.getInstance().registerCallback(EGameEvent.GAME_SAVE, onSave);
+
+ jest.spyOn(io, "open").mockImplementationOnce(() => $multi(file.asMock()));
+
+ saveManager.onBeforeGameSave("test.scop");
+
+ expect(onSave).toHaveBeenCalledTimes(1);
+ expect(io.open).toHaveBeenCalledWith("$game_saves$test.scopx", "wb");
+ expect(file.write).toHaveBeenCalledWith(JSON.stringify({ generic: { example: 123 }, store: {} }));
+ expect(file.close).toHaveBeenCalledTimes(1);
+ });
+
+ it("should properly load dynamic saves", () => {
+ const saveManager: SaveManager = SaveManager.getInstance();
+ const file: MockIoFile = new MockIoFile("test", "wb");
+
+ file.content = JSON.stringify({ generic: { example: 123 }, store: {} });
+
+ const onLoad = jest.fn((data: AnyObject) => {
+ expect(data).toEqual({ example: 123 });
+ });
+
+ EventsManager.getInstance().registerCallback(EGameEvent.GAME_LOAD, onLoad);
+
+ jest.spyOn(io, "open").mockImplementation(() => $multi(file.asMock()));
+
+ const contentBefore: AnyObject = saveManager.dynamicData;
+
+ saveManager.onBeforeGameLoad("F:\\\\parent\\\\test.scop");
+
+ expect(marshal.decode).toHaveBeenCalledWith(file.content);
+ expect(onLoad).toHaveBeenCalledTimes(1);
+ expect(io.open).toHaveBeenCalledWith("F:\\\\parent\\\\test.scopx", "rb");
+ expect(file.read).toHaveBeenCalledTimes(1);
+ expect(file.close).toHaveBeenCalledTimes(1);
+ expect(contentBefore).not.toBe(saveManager.dynamicData);
+
+ // In case of empty file data should stay same.
+ const contentAfter: AnyObject = saveManager.dynamicData;
+
+ file.content = "";
+ saveManager.onBeforeGameLoad("F:\\\\parent\\\\test.scop");
+ expect(contentAfter).toBe(saveManager.dynamicData);
+
+ file.content = null;
+ saveManager.onBeforeGameLoad("F:\\\\parent\\\\test.scop");
+ expect(contentAfter).toBe(saveManager.dynamicData);
+
+ file.content = "{}";
+ file.isOpen = false;
+ saveManager.onBeforeGameLoad("F:\\\\parent\\\\test.scop");
+ expect(contentAfter).toBe(saveManager.dynamicData);
+ });
});
diff --git a/src/engine/core/managers/base/SaveManager.ts b/src/engine/core/managers/base/SaveManager.ts
index 1dad527e9..d78dbf908 100644
--- a/src/engine/core/managers/base/SaveManager.ts
+++ b/src/engine/core/managers/base/SaveManager.ts
@@ -1,3 +1,4 @@
+import { EGameEvent, EventsManager } from "@/engine/core/managers";
import { AbstractCoreManager } from "@/engine/core/managers/base/AbstractCoreManager";
import { AchievementsManager } from "@/engine/core/managers/interaction/achievements";
import { SimulationBoardManager } from "@/engine/core/managers/interaction/SimulationBoardManager";
@@ -11,20 +12,28 @@ import { ReleaseBodyManager } from "@/engine/core/managers/world/ReleaseBodyMana
import { SurgeManager } from "@/engine/core/managers/world/SurgeManager";
import { TreasureManager } from "@/engine/core/managers/world/TreasureManager";
import { WeatherManager } from "@/engine/core/managers/world/WeatherManager";
+import { loadDynamicGameSave, saveDynamicGameSave } from "@/engine/core/utils/game";
import { LuaLogger } from "@/engine/core/utils/logging";
-import { NetPacket, NetProcessor } from "@/engine/lib/types";
+import { AnyObject, NetPacket, NetProcessor, Optional, TName } from "@/engine/lib/types";
const logger: LuaLogger = new LuaLogger($filename);
+export interface IDynamicSaveData {
+ generic: AnyObject;
+ store: AnyObject;
+}
+
/**
* Manage game saves for other managers / parts.
*/
export class SaveManager extends AbstractCoreManager {
+ public dynamicData: IDynamicSaveData = { generic: {}, store: {} };
+
/**
* Save core managers data.
*/
- public override save(packet: NetPacket): void {
- logger.info("Saving");
+ public clientSave(packet: NetPacket): void {
+ logger.info("Saving client");
WeatherManager.getInstance().save(packet);
ReleaseBodyManager.getInstance().save(packet);
@@ -42,8 +51,8 @@ export class SaveManager extends AbstractCoreManager {
/**
* Load core managers data.
*/
- public override load(reader: NetProcessor): void {
- logger.info("Loading");
+ public clientLoad(reader: NetProcessor): void {
+ logger.info("Loading client");
WeatherManager.getInstance().load(reader);
ReleaseBodyManager.getInstance().load(reader);
@@ -61,14 +70,64 @@ export class SaveManager extends AbstractCoreManager {
/**
* Write state for core managers.
*/
- public writeState(packet: NetPacket): void {
+ public serverSave(packet: NetPacket): void {
+ logger.info("Saving server");
+
SimulationBoardManager.getInstance().save(packet);
}
/**
* Read state for core managers.
*/
- public readState(reader: NetProcessor): void {
+ public serverLoad(reader: NetProcessor): void {
+ logger.info("Loading server");
+
SimulationBoardManager.getInstance().load(reader);
}
+
+ /**
+ * When game save creation starting.
+ *
+ * @param saveName - name of save file, just base name with extension like `example.scop`
+ */
+ public onBeforeGameSave(saveName: TName): void {
+ logger.info("Before game save:", saveName);
+
+ EventsManager.getInstance().emitEvent(EGameEvent.GAME_SAVE, this.dynamicData.generic);
+
+ saveDynamicGameSave(saveName, this.dynamicData);
+ }
+
+ /**
+ * When game saved successfully.
+ *
+ * @param saveName - name of save file, just base name with extension like `example.scop`
+ */
+ public onGameSave(saveName: TName): void {
+ logger.info("On game save:", saveName);
+ }
+
+ /**
+ * When game save loading starts.
+ *
+ * @param saveName - name of save file, full path with disk/system folders structure
+ */
+ public onBeforeGameLoad(saveName: TName): void {
+ logger.info("Before game load:", saveName);
+
+ const data: Optional = loadDynamicGameSave(saveName);
+
+ this.dynamicData = data ? data : this.dynamicData;
+
+ EventsManager.getInstance().emitEvent(EGameEvent.GAME_LOAD, this.dynamicData.generic);
+ }
+
+ /**
+ * When game save loaded successfully.
+ *
+ * @param saveName - name of save file, full path with disk/system folders structure
+ */
+ public onGameLoad(saveName: TName): void {
+ logger.info("On game load:", saveName);
+ }
}
diff --git a/src/engine/core/managers/events/EventsManager.test.ts b/src/engine/core/managers/events/EventsManager.test.ts
index dc81d4aa9..ccd293144 100644
--- a/src/engine/core/managers/events/EventsManager.test.ts
+++ b/src/engine/core/managers/events/EventsManager.test.ts
@@ -13,7 +13,7 @@ describe("EventsManager class", () => {
it("should correctly initialize", () => {
const manager: EventsManager = getManagerInstance(EventsManager);
- expect(MockLuaTable.getMockSize(manager.callbacks)).toBe(29);
+ expect(MockLuaTable.getMockSize(manager.callbacks)).toBe(31);
Object.keys(manager.callbacks).forEach((it) => {
expect(MockLuaTable.getMockSize(manager.callbacks[it as unknown as EGameEvent])).toBe(0);
diff --git a/src/engine/core/managers/events/types.ts b/src/engine/core/managers/events/types.ts
index d716dd411..ad63479ff 100644
--- a/src/engine/core/managers/events/types.ts
+++ b/src/engine/core/managers/events/types.ts
@@ -120,6 +120,14 @@ export enum EGameEvent {
* Game started.
*/
GAME_STARTED,
+ /**
+ * Game state save.
+ */
+ GAME_SAVE,
+ /**
+ * Game state load.
+ */
+ GAME_LOAD,
}
/**
diff --git a/src/engine/core/objects/binders/creature/ActorBinder.ts b/src/engine/core/objects/binders/creature/ActorBinder.ts
index 0bf60dd78..7eed2465d 100644
--- a/src/engine/core/objects/binders/creature/ActorBinder.ts
+++ b/src/engine/core/objects/binders/creature/ActorBinder.ts
@@ -146,16 +146,14 @@ export class ActorBinder extends object_binder {
}
public override save(packet: NetPacket): void {
- logger.info("Save");
-
openSaveMarker(packet, ActorBinder.__name);
super.save(packet);
savePortableStore(this.object, packet);
- SaveManager.getInstance().save(packet);
+ SaveManager.getInstance().clientSave(packet);
- // todo: Move out deimos logic.
+ // todo: Move out deimos logic. Probably store in pstore?
let isDeimosExisting: boolean = false;
for (const [id, zone] of registry.zones) {
@@ -177,8 +175,6 @@ export class ActorBinder extends object_binder {
}
public override load(reader: Reader): void {
- logger.info("Load");
-
this.isFirstUpdatePerformed = false;
openLoadMarker(reader, ActorBinder.__name);
@@ -186,7 +182,7 @@ export class ActorBinder extends object_binder {
super.load(reader);
loadPortableStore(this.object, reader);
- SaveManager.getInstance().load(reader);
+ SaveManager.getInstance().clientLoad(reader);
// todo: Move out deimos logic.
const hasDeimos: boolean = reader.r_bool();
diff --git a/src/engine/core/objects/server/creature/Actor.ts b/src/engine/core/objects/server/creature/Actor.ts
index bea066c30..0cce4a7d0 100644
--- a/src/engine/core/objects/server/creature/Actor.ts
+++ b/src/engine/core/objects/server/creature/Actor.ts
@@ -32,6 +32,7 @@ import {
Optional,
ServerCreatureObject,
TNumberId,
+ TSize,
Vector,
} from "@/engine/lib/types";
@@ -70,15 +71,15 @@ export class Actor extends cse_alife_creature_actor implements ISimulationTarget
super.STATE_Write(packet);
openSaveMarker(packet, Actor.__name);
- SaveManager.getInstance().writeState(packet);
+ SaveManager.getInstance().serverSave(packet);
closeSaveMarker(packet, Actor.__name);
}
- public override STATE_Read(packet: NetPacket, size: number): void {
+ public override STATE_Read(packet: NetPacket, size: TSize): void {
super.STATE_Read(packet, size);
openLoadMarker(packet, Actor.__name);
- SaveManager.getInstance().readState(packet);
+ SaveManager.getInstance().serverLoad(packet);
closeLoadMarker(packet, Actor.__name);
}
diff --git a/src/engine/core/utils/game/game_save.test.ts b/src/engine/core/utils/game/game_save.test.ts
index ff54325ed..3655e9c96 100644
--- a/src/engine/core/utils/game/game_save.test.ts
+++ b/src/engine/core/utils/game/game_save.test.ts
@@ -1,4 +1,4 @@
-import { beforeEach, describe, expect, it } from "@jest/globals";
+import { beforeEach, describe, expect, it, jest } from "@jest/globals";
import {
createAutoSave,
@@ -6,7 +6,10 @@ import {
deleteGameSave,
getFileDataForGameSave,
isGameSaveFileExist,
+ loadDynamicGameSave,
+ saveDynamicGameSave,
} from "@/engine/core/utils/game/game_save";
+import { MockIoFile } from "@/fixtures/lua";
import { resetFunctionMock } from "@/fixtures/utils";
import { gameConsole, MockFileSystem, MockFileSystemList, mocksConfig } from "@/fixtures/xray";
@@ -14,6 +17,7 @@ describe("'game_save' utils", () => {
beforeEach(() => {
resetFunctionMock(gameConsole.execute);
resetFunctionMock(gameConsole.get_float);
+ resetFunctionMock(io.open);
});
it("'getFileDataForGameSave' should correctly get save data", () => {
@@ -48,7 +52,8 @@ describe("'game_save' utils", () => {
MockFileSystem.getInstance().file_list_open_ex.mockImplementation(() => new MockFileSystemList(["a"]));
deleteGameSave("another");
expect(fileSystem.file_delete).toHaveBeenNthCalledWith(1, "$game_saves$", "another.scop");
- expect(fileSystem.file_delete).toHaveBeenNthCalledWith(2, "$game_saves$", "another.dds");
+ expect(fileSystem.file_delete).toHaveBeenNthCalledWith(2, "$game_saves$", "another.scopx");
+ expect(fileSystem.file_delete).toHaveBeenNthCalledWith(3, "$game_saves$", "another.dds");
});
it("'createSave' should correctly generate commands", () => {
@@ -92,4 +97,51 @@ describe("'game_save' utils", () => {
expect(() => createSave(null)).toThrow();
});
+
+ it("'saveDynamicGameSave' should correctly create dynamic file saves", () => {
+ const file: MockIoFile = new MockIoFile("test", "wb");
+
+ jest.spyOn(io, "open").mockImplementation(() => $multi(file.asMock()));
+
+ saveDynamicGameSave("example.scop", { a: 1, b: 2, c: 3 });
+
+ expect(lfs.mkdir).toHaveBeenCalledTimes(1);
+ expect(io.open).toHaveBeenCalledWith("$game_saves$example.scopx", "wb");
+ expect(file.write).toHaveBeenCalledWith(JSON.stringify({ a: 1, b: 2, c: 3 }));
+ expect(file.close).toHaveBeenCalledTimes(1);
+
+ expect(file.content).toBe(JSON.stringify({ a: 1, b: 2, c: 3 }));
+
+ file.isOpen = false;
+ saveDynamicGameSave("example.scop", { a: 1000 });
+
+ expect(file.write).toHaveBeenCalledTimes(1);
+ expect(file.content).toBe(JSON.stringify({ a: 1, b: 2, c: 3 }));
+ expect(file.close).toHaveBeenCalledTimes(1);
+ });
+
+ it("'loadDynamicGameSave' should correctly load dynamic file saves", () => {
+ const file: MockIoFile = new MockIoFile("test", "wb");
+
+ file.content = JSON.stringify({ a: 1, b: 33 });
+
+ jest.spyOn(io, "open").mockImplementation(() => $multi(file.asMock()));
+
+ expect(loadDynamicGameSave("F:\\\\parent\\\\example.scop")).toEqual({ a: 1, b: 33 });
+
+ expect(marshal.decode).toHaveBeenCalledWith(file.content);
+ expect(io.open).toHaveBeenCalledWith("F:\\\\parent\\\\example.scopx", "rb");
+ expect(file.read).toHaveBeenCalledTimes(1);
+ expect(file.close).toHaveBeenCalledTimes(1);
+
+ file.content = "";
+ expect(loadDynamicGameSave("F:\\\\parent\\\\example.scop")).toBeNull();
+
+ file.content = null;
+ expect(loadDynamicGameSave("F:\\\\parent\\\\example.scop")).toBeNull();
+
+ file.content = "{}";
+ file.isOpen = false;
+ expect(loadDynamicGameSave("F:\\\\parent\\\\example.scop")).toBeNull();
+ });
});
diff --git a/src/engine/core/utils/game/game_save.ts b/src/engine/core/utils/game/game_save.ts
index 5c0a2642c..2a54c6f65 100644
--- a/src/engine/core/utils/game/game_save.ts
+++ b/src/engine/core/utils/game/game_save.ts
@@ -8,7 +8,7 @@ import { gameConfig } from "@/engine/lib/configs/GameConfig";
import { captions } from "@/engine/lib/constants/captions";
import { consoleCommands } from "@/engine/lib/constants/console_commands";
import { roots } from "@/engine/lib/constants/roots";
-import { FSFileListEX, Optional, SavedGameWrapper, TCount, TLabel, TName } from "@/engine/lib/types";
+import { AnyObject, FSFileListEX, Optional, SavedGameWrapper, TCount, TLabel, TName, TPath } from "@/engine/lib/types";
const logger: LuaLogger = new LuaLogger($filename);
@@ -34,15 +34,76 @@ export function isGameSaveFileExist(filename: TName): boolean {
* @param filename - target name to delete from saves folder
*/
export function deleteGameSave(filename: TName): void {
- const saveFileName: TName = filename + gameConfig.GAME_SAVE_EXTENSION;
- const ddsFile: TName = filename + gameConfig.GAME_SAVE_PREVIEW_EXTENSION;
+ const saveBaseFile: TName = filename + gameConfig.GAME_SAVE_EXTENSION;
+ const saveDynamicFile: TName = filename + gameConfig.GAME_SAVE_DYNAMIC_EXTENSION;
+ const savePreviewFile: TName = filename + gameConfig.GAME_SAVE_PREVIEW_EXTENSION;
const fs: FS = getFS();
- fs.file_delete(roots.gameSaves, saveFileName);
+ logger.info("Delete game save:", filename);
- if (isGameSaveFileExist(ddsFile)) {
- fs.file_delete(roots.gameSaves, ddsFile);
+ fs.file_delete(roots.gameSaves, saveBaseFile);
+
+ // Delete dynamic base.
+ if (isGameSaveFileExist(saveDynamicFile)) {
+ fs.file_delete(roots.gameSaves, saveDynamicFile);
+ }
+
+ // Delete preview.
+ if (isGameSaveFileExist(savePreviewFile)) {
+ fs.file_delete(roots.gameSaves, savePreviewFile);
+ }
+}
+
+/**
+ * Create dynamic game save based on stringified binary data.
+ *
+ * @param filename - target save filename base to create or overwrite it
+ * @param data - data to save
+ */
+export function saveDynamicGameSave(filename: TName, data: AnyObject): void {
+ const savesFolder: TPath = getFS().update_path(roots.gameSaves, "");
+ const saveFile: TPath =
+ savesFolder + string.lower(string.sub(filename, 0, -6)) + gameConfig.GAME_SAVE_DYNAMIC_EXTENSION;
+
+ // Make sure saves directory exists.
+ lfs.mkdir(savesFolder);
+
+ const [existingSave] = io.open(saveFile, "wb");
+
+ if (!existingSave || io.type(existingSave) !== "file") {
+ return logger.error("Cannot write to save path:", saveFile);
+ }
+
+ existingSave.write(marshal.encode(data));
+ existingSave.close();
+}
+
+/**
+ * Read dynamic game save with stringified binary data.
+ *
+ * @param filename - target save filename full path
+ * @returns stringified binary data or null
+ */
+export function loadDynamicGameSave(filename: TName): Optional {
+ const saveFile: TPath = string.sub(filename, 0, -6) + gameConfig.GAME_SAVE_DYNAMIC_EXTENSION;
+
+ const [existingSave] = io.open(saveFile, "rb");
+
+ if (!existingSave || io.type(existingSave) !== "file") {
+ return null;
+ }
+
+ const data: Optional = existingSave.read("*all" as unknown as "*a") as Optional;
+
+ existingSave.close();
+
+ if (data && data !== "") {
+ return marshal.decode(data);
+ } else {
+ logger.warn("Was not able to read dynamic game save:", filename);
+
+ return null;
}
}
diff --git a/src/engine/lib/configs/GameConfig.ts b/src/engine/lib/configs/GameConfig.ts
index 743b6d169..529410b9c 100644
--- a/src/engine/lib/configs/GameConfig.ts
+++ b/src/engine/lib/configs/GameConfig.ts
@@ -46,6 +46,10 @@ export const gameConfig = {
* Game save file extension by default.
*/
GAME_SAVE_EXTENSION: ".scop",
+ /**
+ * Game save file extension for dynamic data.
+ */
+ GAME_SAVE_DYNAMIC_EXTENSION: ".scopx",
/**
* Game save preview file extension by default.
*/
diff --git a/src/engine/scripts/declarations/callbacks/game.ts b/src/engine/scripts/declarations/callbacks/game.ts
index 848664ccb..86e3cb6e9 100644
--- a/src/engine/scripts/declarations/callbacks/game.ts
+++ b/src/engine/scripts/declarations/callbacks/game.ts
@@ -1,12 +1,13 @@
/**
* Outro conditions for game ending based on alife information.
*/
+import { SaveManager } from "@/engine/core/managers/base/SaveManager";
import { TradeManager } from "@/engine/core/managers/interaction/TradeManager";
import { smartCoversList } from "@/engine/core/objects/server/smart_cover/smart_covers_list";
import { GameOutroManager } from "@/engine/core/ui/game/GameOutroManager";
import { extern } from "@/engine/core/utils/binding";
import { LuaLogger } from "@/engine/core/utils/logging";
-import { TNumberId } from "@/engine/lib/types";
+import { TName, TNumberId } from "@/engine/lib/types";
const logger: LuaLogger = new LuaLogger($filename);
@@ -35,3 +36,23 @@ extern("trade_manager", {
get_sell_discount: (objectId: TNumberId) => TradeManager.getInstance().getSellDiscountForObject(objectId),
get_buy_discount: (objectId: TNumberId) => TradeManager.getInstance().getBuyDiscountForObject(objectId),
});
+
+/**
+ * Called from game engine just before creating game save.
+ */
+extern("on_before_game_save", (saveName: TName) => SaveManager.getInstance().onBeforeGameSave(saveName));
+
+/**
+ * Called from game engine when game save is created.
+ */
+extern("on_game_save", (saveName: TName) => SaveManager.getInstance().onGameSave(saveName));
+
+/**
+ * Called from game engine just before loading game save.
+ */
+extern("on_before_game_load", (saveName: TName) => SaveManager.getInstance().onBeforeGameLoad(saveName));
+
+/**
+ * Called from game engine after loading game save.
+ */
+extern("on_game_load", (saveName: TName) => SaveManager.getInstance().onGameLoad(saveName));
diff --git a/src/fixtures/engine/mockEngineGlobals.ts b/src/fixtures/engine/mockEngineGlobals.ts
index e28f22887..3d020f407 100644
--- a/src/fixtures/engine/mockEngineGlobals.ts
+++ b/src/fixtures/engine/mockEngineGlobals.ts
@@ -1,7 +1,9 @@
import { jest } from "@jest/globals";
+import { mockLfs } from "@/fixtures/engine/mocks/lfs.mock";
import { MockLuaLogger } from "@/fixtures/engine/mocks/LuaLogger.mock";
-import { mockTableUtils } from "@/fixtures/engine/mocks/table.mocks";
+import { mockMarshal } from "@/fixtures/engine/mocks/marshal.mock";
+import { mockTableUtils } from "@/fixtures/engine/mocks/table.mock";
/**
* todo;
@@ -12,4 +14,10 @@ export function mockEngineGlobals(): void {
}));
jest.mock("@/engine/core/utils/table", () => mockTableUtils);
+
+ // @ts-ignore
+ global.marshal = mockMarshal;
+
+ // @ts-ignore
+ global.lfs = mockLfs;
}
diff --git a/src/fixtures/engine/mocks/index.ts b/src/fixtures/engine/mocks/index.ts
index 46e9c208d..74a0ac2ba 100644
--- a/src/fixtures/engine/mocks/index.ts
+++ b/src/fixtures/engine/mocks/index.ts
@@ -1,4 +1,3 @@
-export * from "@/fixtures/engine/mocks/registry.mocks";
-export * from "@/fixtures/engine/mocks/scheme.mocks";
-export * from "@/fixtures/engine/mocks/scheme.mocks";
-export * from "@/fixtures/engine/mocks/squads.mocks";
+export * from "@/fixtures/engine/mocks/registry.mock";
+export * from "@/fixtures/engine/mocks/scheme.mock";
+export * from "@/fixtures/engine/mocks/squads.mock";
diff --git a/src/fixtures/engine/mocks/lfs.mock.ts b/src/fixtures/engine/mocks/lfs.mock.ts
new file mode 100644
index 000000000..bf08f2bfc
--- /dev/null
+++ b/src/fixtures/engine/mocks/lfs.mock.ts
@@ -0,0 +1,5 @@
+import { jest } from "@jest/globals";
+
+export const mockLfs = {
+ mkdir: jest.fn(),
+};
diff --git a/src/fixtures/engine/mocks/marshal.mock.ts b/src/fixtures/engine/mocks/marshal.mock.ts
new file mode 100644
index 000000000..b55c8835e
--- /dev/null
+++ b/src/fixtures/engine/mocks/marshal.mock.ts
@@ -0,0 +1,6 @@
+import { jest } from "@jest/globals";
+
+export const mockMarshal = {
+ encode: jest.fn((data) => JSON.stringify(data)),
+ decode: jest.fn((data: string) => JSON.parse(data)),
+};
diff --git a/src/fixtures/engine/mocks/registry.mocks.ts b/src/fixtures/engine/mocks/registry.mock.ts
similarity index 100%
rename from src/fixtures/engine/mocks/registry.mocks.ts
rename to src/fixtures/engine/mocks/registry.mock.ts
diff --git a/src/fixtures/engine/mocks/scheme.mocks.ts b/src/fixtures/engine/mocks/scheme.mock.ts
similarity index 100%
rename from src/fixtures/engine/mocks/scheme.mocks.ts
rename to src/fixtures/engine/mocks/scheme.mock.ts
diff --git a/src/fixtures/engine/mocks/squads.mocks.ts b/src/fixtures/engine/mocks/squads.mock.ts
similarity index 100%
rename from src/fixtures/engine/mocks/squads.mocks.ts
rename to src/fixtures/engine/mocks/squads.mock.ts
diff --git a/src/fixtures/engine/mocks/table.mocks.ts b/src/fixtures/engine/mocks/table.mock.ts
similarity index 100%
rename from src/fixtures/engine/mocks/table.mocks.ts
rename to src/fixtures/engine/mocks/table.mock.ts
diff --git a/src/fixtures/lua/mocks/index.ts b/src/fixtures/lua/mocks/index.ts
index 55ea3c0ba..293cb83e9 100644
--- a/src/fixtures/lua/mocks/index.ts
+++ b/src/fixtures/lua/mocks/index.ts
@@ -1 +1,2 @@
export * from "@/fixtures/lua/mocks/lua_utils";
+export * from "@/fixtures/lua/mocks/lua_io.mock";
diff --git a/src/fixtures/lua/mocks/lua_globals.mocks.ts b/src/fixtures/lua/mocks/lua_globals.mocks.ts
index c384188fd..e0f915a86 100644
--- a/src/fixtures/lua/mocks/lua_globals.mocks.ts
+++ b/src/fixtures/lua/mocks/lua_globals.mocks.ts
@@ -2,6 +2,7 @@ import { ILuaState, lauxlib, lua, lualib, to_jsstring, to_luastring } from "feng
import { AnyArgs } from "@/engine/lib/types";
import { mockDebug } from "@/fixtures/lua/mocks/lua_debug.mock";
+import { mockIo } from "@/fixtures/lua/mocks/lua_io.mock";
import { mockMath } from "@/fixtures/lua/mocks/lua_math.mocks";
import { mockPairs } from "@/fixtures/lua/mocks/lua_pairs.mock";
import { mockString } from "@/fixtures/lua/mocks/lua_string.mock";
@@ -27,6 +28,8 @@ export function mockLuaGlobals(): void {
global.math = mockMath;
// @ts-ignore
global.debug = mockDebug;
+ // @ts-ignore
+ global.io = mockIo;
// @ts-ignore
global.$range = (start: number, end: number) => {
diff --git a/src/fixtures/lua/mocks/lua_io.mock.ts b/src/fixtures/lua/mocks/lua_io.mock.ts
new file mode 100644
index 000000000..0c716d1cc
--- /dev/null
+++ b/src/fixtures/lua/mocks/lua_io.mock.ts
@@ -0,0 +1,54 @@
+import { jest } from "@jest/globals";
+
+import { Optional } from "@/engine/lib/types";
+
+/**
+ * Mock generic io file.
+ */
+export class MockIoFile {
+ public static mock(path: string, mode: string, isOpen: boolean = true): LuaFile {
+ return new MockIoFile(path, mode, isOpen) as unknown as LuaFile;
+ }
+
+ public path: string;
+ public mode: string;
+ public isOpen: boolean;
+ public content: Optional = "";
+
+ public constructor(path: string, mode: string, isOpen: boolean = true) {
+ this.path = path;
+ this.mode = mode;
+ this.isOpen = isOpen;
+ }
+
+ public write = jest.fn((data: string): void => {
+ if (!this.isOpen) {
+ throw new Error("Cannot write in closed file.");
+ }
+
+ this.content = data;
+ });
+
+ public close = jest.fn((): void => {
+ this.isOpen = false;
+ });
+
+ public read = jest.fn((): Optional => {
+ return this.content;
+ });
+
+ public asMock = jest.fn((): LuaFile => {
+ return this as unknown as LuaFile;
+ });
+}
+
+export const mockIo = {
+ open: jest.fn((path: string, mode: string) => [new MockIoFile(path, mode)]),
+ type: jest.fn((target) => {
+ if (target instanceof MockIoFile) {
+ return target.isOpen ? "file" : "closed file";
+ } else {
+ return null;
+ }
+ }),
+};
diff --git a/src/fixtures/lua/mocks/lua_string.mock.ts b/src/fixtures/lua/mocks/lua_string.mock.ts
index 98008017b..cc108da8a 100644
--- a/src/fixtures/lua/mocks/lua_string.mock.ts
+++ b/src/fixtures/lua/mocks/lua_string.mock.ts
@@ -174,4 +174,5 @@ export const mockString = {
return Number.parseInt(to_jsstring(lauxlib.luaL_tolstring(L, -1)));
},
+ lower: (target: string) => target.toLowerCase(),
};
diff --git a/src/fixtures/xray/mocks/fs/FileSystem.mock.ts b/src/fixtures/xray/mocks/fs/FileSystem.mock.ts
index 17218a9c5..8e7011d9e 100644
--- a/src/fixtures/xray/mocks/fs/FileSystem.mock.ts
+++ b/src/fixtures/xray/mocks/fs/FileSystem.mock.ts
@@ -1,6 +1,8 @@
+import * as path from "path";
+
import { jest } from "@jest/globals";
-import { AnyObject, Optional } from "@/engine/lib/types";
+import { AnyObject, Optional, TPath } from "@/engine/lib/types";
import { MockFileSystemList } from "@/fixtures/xray/mocks/fs/FileSystemList.mock";
import { FS_MOCKS } from "@/fixtures/xray/mocks/fs/fs.mock";
@@ -47,6 +49,8 @@ export class MockFileSystem {
public file_delete = jest.fn(() => {});
+ public update_path = jest.fn((base: TPath, part: TPath) => path.join(base, part));
+
public exist = jest.fn((root: string, path: string) => {
return Boolean(this.mocks[root] && this.mocks[root][path]);
});