Skip to content

Commit

Permalink
Adding save/load methods and handling of game_load/game_save callback…
Browse files Browse the repository at this point in the history
…s. Adding tests and extended mocks.
  • Loading branch information
Neloreck committed Jul 9, 2023
1 parent fc26de8 commit bf9eb63
Show file tree
Hide file tree
Showing 27 changed files with 412 additions and 45 deletions.
9 changes: 9 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.<br/>
Detailed description: [link](doc/BUILDING_CUSTOM_GAME_PACKAGE.md)

---

## 🧰 Docs
Expand All @@ -104,7 +109,11 @@ Optimizations, quality and logics updates are welcome.

## 🏗️ Assets

Additional assets repository can be cloned manually or with shortcut command: <br/>
`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)

6 changes: 3 additions & 3 deletions doc/BUILDING_CUSTOM_GAME_PACKAGE.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ and bundled together with custom engine.

Comparing to normal gamedata builds the only needed thing is full assets list. <br/>
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). <br/>
and one of locales packs, for example [eng](https://gitlab.com/xray-forge/stalker-xrf-resources-locale-eng). <br/>

After cloning suggested repositories or providing custom assets, you should list them in 'config.json' if paths are different from already suggested.

Expand All @@ -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
Expand Down
1 change: 1 addition & 0 deletions doc/TODO.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion src/engine/core/database/save_markers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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());
}

Expand Down
90 changes: 82 additions & 8 deletions src/engine/core/managers/base/SaveManager.test.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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 = () => {
Expand All @@ -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<TAbstractCoreManagerConstructor> = [
WeatherManager,
ReleaseBodyManager,
Expand All @@ -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<TAbstractCoreManagerConstructor> = [SimulationBoardManager];
Expand All @@ -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);
});
});
73 changes: 66 additions & 7 deletions src/engine/core/managers/base/SaveManager.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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);
Expand All @@ -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);
Expand All @@ -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<IDynamicSaveData> = 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);
}
}
2 changes: 1 addition & 1 deletion src/engine/core/managers/events/EventsManager.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
8 changes: 8 additions & 0 deletions src/engine/core/managers/events/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,14 @@ export enum EGameEvent {
* Game started.
*/
GAME_STARTED,
/**
* Game state save.
*/
GAME_SAVE,
/**
* Game state load.
*/
GAME_LOAD,
}

/**
Expand Down
10 changes: 3 additions & 7 deletions src/engine/core/objects/binders/creature/ActorBinder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -177,16 +175,14 @@ export class ActorBinder extends object_binder {
}

public override load(reader: Reader): void {
logger.info("Load");

this.isFirstUpdatePerformed = false;

openLoadMarker(reader, ActorBinder.__name);

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();
Expand Down
Loading

0 comments on commit bf9eb63

Please sign in to comment.