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]); });