diff --git a/cli/bin b/cli/bin index e9ffbbc8f..337b2f3f6 160000 --- a/cli/bin +++ b/cli/bin @@ -1 +1 @@ -Subproject commit e9ffbbc8f76bd7a81d8e2e5d5e90c4f51e10a71d +Subproject commit 337b2f3f60453c3d6edd657dc201c197506647dd diff --git a/package-lock.json b/package-lock.json index e1292c3cd..74fd9f6f1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,7 +8,7 @@ "name": "stalker-xrf-engine", "version": "1.0.0", "dependencies": { - "xray16": "1.1.2" + "xray16": "1.2.2" }, "bin": { "xrf": "cli/run.ts" @@ -17,7 +17,7 @@ "@jest/globals": "^29.7.0", "@types/ini": "^4.1.1", "@types/jsdom": "^21.1.7", - "@types/node": "^22.0.2", + "@types/node": "^22.10.2", "@typescript-eslint/eslint-plugin": "^6.21.0", "@typescript-eslint/parser": "^6.21.0", "@typescript-to-lua/language-extensions": "^1.19.0", @@ -45,8 +45,8 @@ "ts-jest": "^29.2.3", "ts-node": "^10.9.2", "tsconfig-paths": "^4.2.0", - "typescript": "^5.6.2", - "typescript-to-lua": "^1.27.1" + "typescript": "^5.7.2", + "typescript-to-lua": "^1.28.1" } }, "node_modules/@aashutoshrathi/word-wrap": { @@ -1499,12 +1499,13 @@ "dev": true }, "node_modules/@types/node": { - "version": "22.0.2", - "resolved": "https://registry.npmjs.org/@types/node/-/node-22.0.2.tgz", - "integrity": "sha512-yPL6DyFwY5PiMVEwymNeqUTKsDczQBJ/5T7W/46RwLU/VH+AA8aT5TZkvBviLKLbbm0hlfftEkGrNzfRk/fofQ==", + "version": "22.10.2", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.10.2.tgz", + "integrity": "sha512-Xxr6BBRCAOQixvonOye19wnzyDiUtTeqldOOmj3CkeblonbccA12PFwlufvRdrpjXxqnmUaeiU5EOA+7s5diUQ==", "dev": true, + "license": "MIT", "dependencies": { - "undici-types": "~6.11.1" + "undici-types": "~6.20.0" } }, "node_modules/@types/semver": { @@ -2630,10 +2631,11 @@ "dev": true }, "node_modules/cross-spawn": { - "version": "7.0.3", - "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz", - "integrity": "sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==", + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", "dev": true, + "license": "MIT", "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", @@ -8049,9 +8051,9 @@ } }, "node_modules/typescript": { - "version": "5.6.2", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.6.2.tgz", - "integrity": "sha512-NW8ByodCSNCwZeghjN3o+JX5OFH0Ojg6sadjEKY4huZ52TqbJTJnDo5+Tw98lSy63NZvi4n+ez5m2u5d4PkZyw==", + "version": "5.7.2", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.7.2.tgz", + "integrity": "sha512-i5t66RHxDvVN40HfDd1PsEThGNnlMCMT3jMUuoh9/0TaqWevNontacunWyN02LA9/fIbEWlcHZcgTKb9QoaLfg==", "license": "Apache-2.0", "bin": { "tsc": "bin/tsc", @@ -8062,9 +8064,9 @@ } }, "node_modules/typescript-to-lua": { - "version": "1.27.1", - "resolved": "https://registry.npmjs.org/typescript-to-lua/-/typescript-to-lua-1.27.1.tgz", - "integrity": "sha512-9MTMIyeFkl5i/eOUtVkqzW6w2SA4Sy3K+p9cJX06Y9IP7b2kKnuZH7Vkva9rCR96eVPsBqfPysLBT+mdS5Y21Q==", + "version": "1.28.1", + "resolved": "https://registry.npmjs.org/typescript-to-lua/-/typescript-to-lua-1.28.1.tgz", + "integrity": "sha512-4Ah8bco9X7BNrDcVUHEDDWbZL2whoUg8CCLnUJWsoopmV43aDZuEqOlhtsWAjsH+3Wq5SugYcmyFRrZ+mWyCYw==", "license": "MIT", "dependencies": { "@typescript-to-lua/language-extensions": "1.19.0", @@ -8080,7 +8082,7 @@ "node": ">=16.10.0" }, "peerDependencies": { - "typescript": "5.6.2" + "typescript": "5.7.2" } }, "node_modules/typescript-to-lua/node_modules/source-map": { @@ -8107,10 +8109,11 @@ } }, "node_modules/undici-types": { - "version": "6.11.1", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.11.1.tgz", - "integrity": "sha512-mIDEX2ek50x0OlRgxryxsenE5XaQD4on5U2inY7RApK3SOJpofyw7uW2AyfMKkhAxXIceo2DeWGVGwyvng1GNQ==", - "dev": true + "version": "6.20.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.20.0.tgz", + "integrity": "sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg==", + "dev": true, + "license": "MIT" }, "node_modules/universalify": { "version": "0.2.0", @@ -8451,11 +8454,11 @@ "dev": true }, "node_modules/xray16": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/xray16/-/xray16-1.1.2.tgz", - "integrity": "sha512-kYkiAUtEbyg19RrlmHqkRKyuf1GuL/iLicbonRraHii9eYsW4Fr6+MlRzEOnnm82LaTOxZ6mZ0nFjk1b46HiUw==", + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/xray16/-/xray16-1.2.2.tgz", + "integrity": "sha512-3uVTD9yqRzRzVUg4nXfTz2Ksww7TuvpXHqv2j9Xh8HUjauzspXpqxqKZ9EE0oVkBPGnuVJDXueIOXJZXIaU2vg==", "peerDependencies": { - "typescript-to-lua": "^1.16.3" + "typescript-to-lua": "^1.28.1" } }, "node_modules/xtend": { diff --git a/package.json b/package.json index 7629822a4..2ef688872 100644 --- a/package.json +++ b/package.json @@ -21,13 +21,13 @@ "help": "ts-node -P ./cli/tsconfig.json cli/run.ts -h" }, "dependencies": { - "xray16": "1.1.2" + "xray16": "1.2.2" }, "devDependencies": { "@jest/globals": "^29.7.0", "@types/ini": "^4.1.1", "@types/jsdom": "^21.1.7", - "@types/node": "^22.0.2", + "@types/node": "^22.10.2", "@typescript-eslint/eslint-plugin": "^6.21.0", "@typescript-eslint/parser": "^6.21.0", "@typescript-to-lua/language-extensions": "^1.19.0", @@ -55,8 +55,8 @@ "ts-jest": "^29.2.3", "ts-node": "^10.9.2", "tsconfig-paths": "^4.2.0", - "typescript": "^5.6.2", - "typescript-to-lua": "^1.27.1" + "typescript": "^5.7.2", + "typescript-to-lua": "^1.28.1" }, "bin": { "xrf": "./cli/run.ts" diff --git a/src/engine/core/ai/combat/combat_visibility_calculation.test.ts b/src/engine/core/ai/combat/combat_visibility_calculation.test.ts new file mode 100644 index 000000000..c484175b6 --- /dev/null +++ b/src/engine/core/ai/combat/combat_visibility_calculation.test.ts @@ -0,0 +1,38 @@ +import { beforeEach, describe, expect, it } from "@jest/globals"; + +import { calculateObjectVisibility } from "@/engine/core/ai/combat/combat_visibility_calculation"; +import { weatherConfig } from "@/engine/core/managers/weather/WeatherConfig"; +import { MockGameObject } from "@/fixtures/xray"; + +describe("calculateObjectVisibility", () => { + beforeEach(() => { + weatherConfig.IS_UNDERGROUND_WEATHER = false; + }); + + it("should correctly calculate outside values with negative distance / luminosity", () => { + expect(calculateObjectVisibility(MockGameObject.mock(), MockGameObject.mock(), 100, 10, -1, 5, 7, -1, 5, 10)).toBe( + -1799.9964000000002 + ); + expect( + calculateObjectVisibility(MockGameObject.mock(), MockGameObject.mock(), 100, 10, 0.00001, 5, 7, 0.00001, 5, 10) + ).toBe(-1799.9964000000002); + }); + + it("should correctly calculate outside and inside", () => { + weatherConfig.IS_UNDERGROUND_WEATHER = false; + expect( + calculateObjectVisibility(MockGameObject.mock(), MockGameObject.mock(), 100, 10, 1, 5, 7, 150, 100, 10) + ).toBe(120); + expect( + calculateObjectVisibility(MockGameObject.mock(), MockGameObject.mock(), 25.5, 5.5, 10.5, 8.5, 4.5, 2.5, 1.5, 0.5) + ).toBe(764.3045454545455); + + weatherConfig.IS_UNDERGROUND_WEATHER = true; + expect( + calculateObjectVisibility(MockGameObject.mock(), MockGameObject.mock(), 100, 10, 1, 5, 7, 150, 100, 10) + ).toBe(42); + expect( + calculateObjectVisibility(MockGameObject.mock(), MockGameObject.mock(), 25.5, 5.5, 10.5, 8.5, 4.5, 2.5, 1.5, 0.5) + ).toBe(267.50659090909096); + }); +}); diff --git a/src/engine/core/ai/combat/combat_visibility_calculation.ts b/src/engine/core/ai/combat/combat_visibility_calculation.ts new file mode 100644 index 000000000..1d57726a2 --- /dev/null +++ b/src/engine/core/ai/combat/combat_visibility_calculation.ts @@ -0,0 +1,46 @@ +import { weatherConfig } from "@/engine/core/managers/weather/WeatherConfig"; +import { GameObject, Optional, TDistance, TDuration, TRate } from "@/engine/lib/types"; + +/** + * If value >= visiblity_threshold then object is considered visible. + * `visibility_threshold` is configured in LTX files for each monster / stalker separately. + * + * @param object - target object checking visibility + * @param target - target checking visibility for from perspective of object + * @param timeDelta - ? + * @param timeQuantity - ? + * @param luminosity - level of brightness outside + * @param velocityFactor - ? + * @param velocity - ? + * @param distance - ? + * @param objectDistance - ? + * @param alwaysVisibleDistance -? + * @returns visibility rate + */ +export function calculateObjectVisibility( + object: Optional, + target: Optional, + timeDelta: TDuration, + timeQuantity: TDuration, + luminosity: TRate, + velocityFactor: TRate, + velocity: TRate, + distance: TDistance, + objectDistance: TDistance, + alwaysVisibleDistance: TDistance +): TRate { + luminosity = luminosity <= 0 ? 0.00001 : luminosity; + distance = distance <= 0 ? 0.00001 : distance; + + if (weatherConfig.IS_UNDERGROUND_WEATHER) { + luminosity *= 0.35; + } + + // Unaltered formula from engine: + // time_delta / time_quant * luminocity * (1 + velocity_factor*velocity) * (distance - object_distance) / + // (distance - always_visible_distance) + + return ( + ((timeDelta / timeQuantity) * luminosity * (1 + velocityFactor * velocity) * (distance - objectDistance)) / distance + ); +} diff --git a/src/engine/core/ai/combat/combat_weapon_select.test.ts b/src/engine/core/ai/combat/combat_weapon_select.test.ts new file mode 100644 index 000000000..651cedaf7 --- /dev/null +++ b/src/engine/core/ai/combat/combat_weapon_select.test.ts @@ -0,0 +1,139 @@ +import { beforeEach, describe, expect, it } from "@jest/globals"; +import { clsid } from "xray16"; + +import { selectBestStalkerWeapon } from "@/engine/core/ai/combat/combat_weapon_select"; +import { getManager, registerSimulator } from "@/engine/core/database"; +import { EGameEvent, EventsManager } from "@/engine/core/managers/events"; +import { AnyObject, GameObject } from "@/engine/lib/types"; +import { resetRegistry } from "@/fixtures/engine"; +import { MockAlifeObject, MockGameObject } from "@/fixtures/xray"; + +describe("selectBestStalkerWeapon util", () => { + beforeEach(() => { + resetRegistry(); + registerSimulator(); + }); + + it("should fallback to null if no handlers found", () => { + expect(selectBestStalkerWeapon(MockGameObject.mock(), MockGameObject.mock())).toBeNull(); + expect(selectBestStalkerWeapon(MockGameObject.mock(), null)).toBeNull(); + }); + + it("should handle exceptional cases from callback handlers without throwing", () => { + const eventsManager: EventsManager = getManager(EventsManager); + + eventsManager.registerCallback( + EGameEvent.STALKER_WEAPON_SELECT, + (_: GameObject, __: GameObject, data: AnyObject) => { + data.weaponId = true; + } + ); + + expect(selectBestStalkerWeapon(MockGameObject.mock(), MockGameObject.mock())).toBeNull(); + + eventsManager.registerCallback( + EGameEvent.STALKER_WEAPON_SELECT, + (_: GameObject, __: GameObject, data: AnyObject) => { + data.weaponId = "test-string"; + } + ); + + expect(selectBestStalkerWeapon(MockGameObject.mock(), MockGameObject.mock())).toBeNull(); + + eventsManager.registerCallback( + EGameEvent.STALKER_WEAPON_SELECT, + (_: GameObject, __: GameObject, data: AnyObject) => { + data.weaponId = {}; + } + ); + + expect(selectBestStalkerWeapon(MockGameObject.mock(), MockGameObject.mock())).toBeNull(); + }); + + it("should use weapon from latest event handler", () => { + const object: GameObject = MockGameObject.mock(); + const weapon: GameObject = MockGameObject.mock(); + + const firstBestWeapon: GameObject = MockGameObject.mock(); + const secondBestWeapon: GameObject = MockGameObject.mock(); + + MockAlifeObject.mock({ + id: firstBestWeapon.id(), + parentId: object.id(), + clsid: clsid.wpn_svd, + }); + + MockAlifeObject.mock({ + id: secondBestWeapon.id(), + parentId: object.id(), + clsid: clsid.wpn_ak74, + }); + + const eventsManager: EventsManager = getManager(EventsManager); + + eventsManager.registerCallback( + EGameEvent.STALKER_WEAPON_SELECT, + (_: GameObject, __: GameObject, data: AnyObject) => { + expect(data).toEqual({ weaponId: null }); + data.weaponId = firstBestWeapon.id(); + } + ); + + eventsManager.registerCallback( + EGameEvent.STALKER_WEAPON_SELECT, + (_: GameObject, __: GameObject, data: AnyObject) => { + expect(data).toEqual({ weaponId: firstBestWeapon.id() }); + data.weaponId = secondBestWeapon.id(); + } + ); + + expect(selectBestStalkerWeapon(object, weapon)).toBe(secondBestWeapon); + }); + + it("should require weapon ownership to use it", () => { + const object: GameObject = MockGameObject.mock(); + const weapon: GameObject = MockGameObject.mock(); + + const bestWeapon: GameObject = MockGameObject.mock(); + + MockAlifeObject.mock({ + id: bestWeapon.id(), + clsid: clsid.wpn_svd, + }); + + const eventsManager: EventsManager = getManager(EventsManager); + + eventsManager.registerCallback( + EGameEvent.STALKER_WEAPON_SELECT, + (_: GameObject, __: GameObject, data: AnyObject) => { + data.weaponId = bestWeapon.id(); + } + ); + + expect(selectBestStalkerWeapon(object, weapon)).toBeNull(); + }); + + it("should require weapon cls id to use it", () => { + const object: GameObject = MockGameObject.mock(); + const weapon: GameObject = MockGameObject.mock(); + + const bestWeapon: GameObject = MockGameObject.mock(); + + MockAlifeObject.mock({ + id: bestWeapon.id(), + parentId: object.id(), + clsid: clsid.obj_food, + }); + + const eventsManager: EventsManager = getManager(EventsManager); + + eventsManager.registerCallback( + EGameEvent.STALKER_WEAPON_SELECT, + (_: GameObject, __: GameObject, data: AnyObject) => { + data.weaponId = bestWeapon.id(); + } + ); + + expect(selectBestStalkerWeapon(object, weapon)).toBeNull(); + }); +}); diff --git a/src/engine/core/ai/combat/combat_weapon_select.ts b/src/engine/core/ai/combat/combat_weapon_select.ts new file mode 100644 index 000000000..3af212c5f --- /dev/null +++ b/src/engine/core/ai/combat/combat_weapon_select.ts @@ -0,0 +1,41 @@ +import { level } from "xray16"; + +import { registry } from "@/engine/core/database"; +import { EGameEvent, EventsManager } from "@/engine/core/managers/events"; +import { isWeapon } from "@/engine/core/utils/class_ids"; +import { GameObject, Optional, ServerObject, TNumberId, TStringId } from "@/engine/lib/types"; + +/** + * Try to pick the best weapon to kill enemy based on situation. + * Returning null value will delegate choosing of weapon to game engine. + * + * This method leaves place for weapon custom logics implementation using events / extensions. + * + * @param object - stalker object to pick the best weapon for + * @param weapon - currently selected best weapon to kill enemy + * @returns best weapon to use for enemy kill or null + */ +export function selectBestStalkerWeapon(object: GameObject, weapon: Optional): Optional { + const data = { weaponId: null as Optional }; + + EventsManager.emitEvent(EGameEvent.STALKER_WEAPON_SELECT, object, weapon, data); + + if (data.weaponId && typeof data.weaponId === "number") { + const nextWeaponId: TNumberId = tonumber(data.weaponId) as TNumberId; + const nextWeaponServerObject: Optional = registry.simulator.object(nextWeaponId); + + if ( + nextWeaponServerObject && + isWeapon(nextWeaponServerObject) && + nextWeaponServerObject.parent_id === object.id() + ) { + const gunObject: Optional = level.object_by_id(nextWeaponId); + + if (gunObject) { + return gunObject; + } + } + } + + return null; +} diff --git a/src/engine/core/ai/combat/index.ts b/src/engine/core/ai/combat/index.ts new file mode 100644 index 000000000..4ea76cf8b --- /dev/null +++ b/src/engine/core/ai/combat/index.ts @@ -0,0 +1,2 @@ +export * from "@/engine/core/ai/combat/combat_visibility_calculation"; +export * from "@/engine/core/ai/combat/combat_weapon_select"; diff --git a/src/engine/core/binders/creature/ActorBinder.test.ts b/src/engine/core/binders/creature/ActorBinder.test.ts index 7da487473..3d0a690ab 100644 --- a/src/engine/core/binders/creature/ActorBinder.test.ts +++ b/src/engine/core/binders/creature/ActorBinder.test.ts @@ -119,14 +119,15 @@ describe("ActorBinder", () => { expect(state).not.toBeNull(); expect(state.portableStore).not.toBeNull(); - expect(actor.set_callback).toHaveBeenCalledTimes(7); + expect(actor.set_callback).toHaveBeenCalledTimes(8); expect(actor.set_callback).toHaveBeenCalledWith(callback.inventory_info, expect.any(Function)); + expect(actor.set_callback).toHaveBeenCalledWith(callback.take_item_from_box, expect.any(Function)); expect(actor.set_callback).toHaveBeenCalledWith(callback.on_item_take, expect.any(Function)); expect(actor.set_callback).toHaveBeenCalledWith(callback.on_item_drop, expect.any(Function)); expect(actor.set_callback).toHaveBeenCalledWith(callback.trade_sell_buy_item, expect.any(Function)); expect(actor.set_callback).toHaveBeenCalledWith(callback.task_state, expect.any(Function)); - expect(actor.set_callback).toHaveBeenCalledWith(callback.take_item_from_box, expect.any(Function)); expect(actor.set_callback).toHaveBeenCalledWith(callback.use_object, expect.any(Function)); + expect(actor.set_callback).toHaveBeenCalledWith(callback.hud_animation_end, expect.any(Function)); expect(eventsManager.emitEvent).toHaveBeenCalledWith(EGameEvent.ACTOR_REINIT, binder); }); diff --git a/src/engine/core/binders/creature/ActorBinder.ts b/src/engine/core/binders/creature/ActorBinder.ts index 436544e3b..6c4d9c554 100644 --- a/src/engine/core/binders/creature/ActorBinder.ts +++ b/src/engine/core/binders/creature/ActorBinder.ts @@ -34,6 +34,8 @@ import { ServerActorObject, TCount, TDuration, + TName, + TSection, TTaskState, TTimestamp, } from "@/engine/lib/types"; @@ -233,10 +235,14 @@ export class ActorBinder extends object_binder { object.set_callback(callback.use_object, (object: GameObject) => { eventsManager.emitEvent(EGameEvent.ACTOR_USE_ITEM, object); }); + object.set_callback(callback.hud_animation_end, (object: GameObject, hudSection: TSection, motion: TName) => { + logger.info("Hud animation ended: %s - %s - %s", object.name(), hudSection, motion); + }); // todo: article_info info callback. // todo: level_border_enter info callback. // todo: level_border_exit info callback. + // todo: before death callback. } /** diff --git a/src/engine/core/managers/actor/ActorInputManager.test.ts b/src/engine/core/managers/actor/ActorInputManager.test.ts index fd803582d..0c3ab3923 100644 --- a/src/engine/core/managers/actor/ActorInputManager.test.ts +++ b/src/engine/core/managers/actor/ActorInputManager.test.ts @@ -173,7 +173,7 @@ describe("ActorInputManager", () => { expect(registry.effectsVolume).toBe(0.8); }); - it("should correctly first update event", () => { + it("should correctly handle first update event", () => { const manager: ActorInputManager = getManager(ActorInputManager); actorConfig.ACTIVE_ITEM_SLOT = EActiveItemSlot.PRIMARY; @@ -187,7 +187,7 @@ describe("ActorInputManager", () => { expect(registry.actor.activate_slot).toHaveBeenNthCalledWith(2, EActiveItemSlot.KNIFE); }); - it("should correctly network spawn event", () => { + it("should correctly handle network spawn event", () => { const manager: ActorInputManager = getManager(ActorInputManager); actorConfig.DISABLED_INPUT_AT = MockCTime.mock(2012, 12, 1, 12, 30, 5, 500); @@ -198,4 +198,10 @@ describe("ActorInputManager", () => { manager.onActorGoOnline(); expect(level.enable_input).toHaveBeenCalledTimes(1); }); + + it("should correctly handle keyboard input event", () => { + const manager: ActorInputManager = getManager(ActorInputManager); + + expect(manager.onKeyPress(1, 2)).toBe(false); + }); }); diff --git a/src/engine/core/managers/actor/ActorInputManager.ts b/src/engine/core/managers/actor/ActorInputManager.ts index 26109171a..7872c8cba 100644 --- a/src/engine/core/managers/actor/ActorInputManager.ts +++ b/src/engine/core/managers/actor/ActorInputManager.ts @@ -34,6 +34,7 @@ import { Optional, TDuration, TIndex, + TNumberId, TRate, } from "@/engine/lib/types"; @@ -400,4 +401,25 @@ export class ActorInputManager extends AbstractManager { public onSurgeSurviveEnd(): void { this.enableGameUi(); } + + /** + * Handle actor keyboard input pressing. + * + * @param key - key code + * @param bind - key binding code + * @returns whether action was handled by script and engine should stop further execution of callbacks + */ + public onKeyPress(key: TNumberId, bind: TNumberId): boolean { + /** + * Place to implement quick save / quick load logics with incremental naming / rotating files. + * For reference, check anomaly/CoC etc. + * + * -> scripts/level_input.script + * on_key_press + * action_quick_save + * action_quick_load + */ + + return false; + } } diff --git a/src/engine/core/managers/actor/ActorInventoryMenuManager.test.ts b/src/engine/core/managers/actor/ActorInventoryMenuManager.test.ts index 7acd21529..4d715ab3d 100644 --- a/src/engine/core/managers/actor/ActorInventoryMenuManager.test.ts +++ b/src/engine/core/managers/actor/ActorInventoryMenuManager.test.ts @@ -90,6 +90,18 @@ describe("ActorInventoryMenuManager", () => { expect(manager.onWindowClosed).toHaveBeenCalledWith(EActorMenuMode.TALK_DIALOG); }); + it("should correctly handle item focus receive event", () => { + expect(() => { + getManager(ActorInventoryMenuManager).onItemFocusReceived(MockGameObject.mock()); + }).not.toThrow(); + }); + + it("should correctly handle item focus lost event", () => { + expect(() => { + getManager(ActorInventoryMenuManager).onItemFocusLost(MockGameObject.mock()); + }).not.toThrow(); + }); + it("should correctly handle drop item event", () => { expect(() => { getManager(ActorInventoryMenuManager).onItemDropped( diff --git a/src/engine/core/managers/actor/ActorInventoryMenuManager.ts b/src/engine/core/managers/actor/ActorInventoryMenuManager.ts index 7386a39ab..81866a48a 100644 --- a/src/engine/core/managers/actor/ActorInventoryMenuManager.ts +++ b/src/engine/core/managers/actor/ActorInventoryMenuManager.ts @@ -84,6 +84,20 @@ export class ActorInventoryMenuManager extends AbstractManager { actorConfig.ACTOR_MENU_MODE = EActorMenuMode.UNDEFINED; } + /** + * @param item - item game object receiving UI focus + */ + public onItemFocusReceived(item: GameObject): void { + logger.info("Actor item focus received: %s", item?.name()); + } + + /** + * @param item - item game object losing UI focus + */ + public onItemFocusLost(item: GameObject): void { + logger.info("Actor item focus lost: %s", item?.name()); + } + /** * @param from - game object owner of previous list * @param to - game object owner of next list diff --git a/src/engine/core/managers/death/ReleaseBodyManager.ts b/src/engine/core/managers/death/ReleaseBodyManager.ts index 3beecf39b..50576c861 100644 --- a/src/engine/core/managers/death/ReleaseBodyManager.ts +++ b/src/engine/core/managers/death/ReleaseBodyManager.ts @@ -23,6 +23,8 @@ const logger: LuaLogger = new LuaLogger($filename); /** * Manage persisting dead bodies. * Release the most further of them from time to time to keep up with limits. + * + * todo: EGameEvent.BEFORE_LEVEL_CHANGE -> call releaseCorpses? */ export class ReleaseBodyManager extends AbstractManager { public override initialize(): void { diff --git a/src/engine/core/managers/events/EventsManager.test.ts b/src/engine/core/managers/events/EventsManager.test.ts index e448f13dc..0f72a389d 100644 --- a/src/engine/core/managers/events/EventsManager.test.ts +++ b/src/engine/core/managers/events/EventsManager.test.ts @@ -14,7 +14,7 @@ describe("EventsManager", () => { it("should correctly initialize", () => { const manager: EventsManager = getManager(EventsManager); - expect(MockLuaTable.getMockSize(manager.callbacks)).toBe(120); + expect(MockLuaTable.getMockSize(manager.callbacks)).toBe(125); 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/events_types.ts b/src/engine/core/managers/events/events_types.ts index 0f65dbdfe..477e1332b 100644 --- a/src/engine/core/managers/events/events_types.ts +++ b/src/engine/core/managers/events/events_types.ts @@ -126,6 +126,10 @@ export enum EGameEvent { * Server side death event. */ STALKER_DEATH_ALIFE, + /** + * Stalker weapon selection is needed. + */ + STALKER_WEAPON_SELECT, /** * On monster register. */ @@ -424,6 +428,10 @@ export enum EGameEvent { * Unregistered shotgun weapon server object. */ ITEM_WEAPON_SHOTGUN_UNREGISTERED, + /** + * Unregistered generic server ALifeDynamicObject. + */ + SERVER_OBJECT_UNREGISTERED, /** * Surge ended. */ @@ -484,10 +492,22 @@ export enum EGameEvent { * Game state save. */ GAME_SAVE, + /** + * Game state saved. + */ + GAME_SAVED, /** * Game state load. */ GAME_LOAD, + /** + * Game state loaded. + */ + GAME_LOADED, + /** + * Event called before level change. + */ + BEFORE_LEVEL_CHANGE, } /** diff --git a/src/engine/core/managers/save/SaveManager.test.ts b/src/engine/core/managers/save/SaveManager.test.ts index fb8ec0e19..bf25d7660 100644 --- a/src/engine/core/managers/save/SaveManager.test.ts +++ b/src/engine/core/managers/save/SaveManager.test.ts @@ -23,7 +23,7 @@ import { TaskManager } from "@/engine/core/managers/tasks"; import { TreasureManager } from "@/engine/core/managers/treasures"; import { WeatherManager } from "@/engine/core/managers/weather/WeatherManager"; import { IExtensionsDescriptor } from "@/engine/core/utils/extensions"; -import { AnyObject } from "@/engine/lib/types"; +import { AnyObject, TName } from "@/engine/lib/types"; import { mockExtension, resetRegistry } from "@/fixtures/engine"; import { resetFunctionMock } from "@/fixtures/jest"; import { MockIoFile } from "@/fixtures/lua"; @@ -140,8 +140,8 @@ describe("SaveManager", () => { expect(saveManager.onBeforeGameSave).toBeDefined(); expect(saveManager.onGameSave).toBeDefined(); - expect(saveManager.onBeforeGameLoad).toBeDefined(); expect(saveManager.onGameLoad).toBeDefined(); + expect(saveManager.onAfterGameLoad).toBeDefined(); }); it("should properly create dynamic saves", () => { @@ -157,7 +157,8 @@ describe("SaveManager", () => { registry.extensions.set(firstExtension.name, firstExtension); registry.extensions.set(secondExtension.name, secondExtension); - const onSave = jest.fn((data: AnyObject) => { + const onSave = jest.fn((saveName: TName, data: AnyObject) => { + expect(saveName).toBe("test.scop"); data.example = 123; }); @@ -184,6 +185,19 @@ describe("SaveManager", () => { expect(file.close).toHaveBeenCalledTimes(1); }); + it("should properly handle after game saved event", () => { + const saveManager: SaveManager = getManager(SaveManager); + + const onSaved = jest.fn(); + + getManager(EventsManager).registerCallback(EGameEvent.GAME_SAVED, onSaved); + + saveManager.onGameSave("test.scop"); + + expect(onSaved).toHaveBeenCalledTimes(1); + expect(onSaved).toHaveBeenCalledWith("test.scop"); + }); + it("should properly load dynamic saves", () => { const saveManager: SaveManager = getManager(SaveManager); const file: MockIoFile = new MockIoFile("test", "wb"); @@ -204,7 +218,8 @@ describe("SaveManager", () => { objects: {}, }); - const onLoad = jest.fn((data: AnyObject) => { + const onLoad = jest.fn((saveName: TName, data: AnyObject) => { + expect(saveName).toBe("F:\\\\parent\\\\test.scop"); expect(data).toEqual({ example: 123 }); }); @@ -214,7 +229,7 @@ describe("SaveManager", () => { const contentBefore: AnyObject = registry.dynamicData; - saveManager.onBeforeGameLoad("F:\\\\parent\\\\test.scop"); + saveManager.onGameLoad("F:\\\\parent\\\\test.scop"); expect(marshal.decode).toHaveBeenCalledWith(file.content); expect(onLoad).toHaveBeenCalledTimes(1); @@ -241,16 +256,29 @@ describe("SaveManager", () => { const contentAfter: AnyObject = registry.dynamicData; file.content = ""; - saveManager.onBeforeGameLoad("F:\\\\parent\\\\test.scop"); + saveManager.onGameLoad("F:\\\\parent\\\\test.scop"); expect(contentAfter).toBe(registry.dynamicData); file.content = null; - saveManager.onBeforeGameLoad("F:\\\\parent\\\\test.scop"); + saveManager.onGameLoad("F:\\\\parent\\\\test.scop"); expect(contentAfter).toBe(registry.dynamicData); file.content = "{}"; file.isOpen = false; - saveManager.onBeforeGameLoad("F:\\\\parent\\\\test.scop"); + saveManager.onGameLoad("F:\\\\parent\\\\test.scop"); expect(contentAfter).toBe(registry.dynamicData); }); + + it("should properly handle callback after game loaded", () => { + const saveManager: SaveManager = getManager(SaveManager); + + const onLoad = jest.fn(); + + getManager(EventsManager).registerCallback(EGameEvent.GAME_LOADED, onLoad); + + saveManager.onAfterGameLoad("F:\\\\parent\\\\test.scop"); + + expect(onLoad).toHaveBeenCalledTimes(1); + expect(onLoad).toHaveBeenCalledWith("F:\\\\parent\\\\test.scop"); + }); }); diff --git a/src/engine/core/managers/save/SaveManager.ts b/src/engine/core/managers/save/SaveManager.ts index a422be45b..99fa29f28 100644 --- a/src/engine/core/managers/save/SaveManager.ts +++ b/src/engine/core/managers/save/SaveManager.ts @@ -97,7 +97,7 @@ export class SaveManager extends AbstractManager { public onBeforeGameSave(saveName: TName): void { logger.info("Before game save: %s", saveName); - EventsManager.emitEvent(EGameEvent.GAME_SAVE, registry.dynamicData.event); + EventsManager.emitEvent(EGameEvent.GAME_SAVE, saveName, registry.dynamicData.event); for (const [, extension] of registry.extensions) { saveExtension(extension); @@ -113,15 +113,17 @@ export class SaveManager extends AbstractManager { */ public onGameSave(saveName: TName): void { logger.info("On game save: %s", saveName); + + EventsManager.emitEvent(EGameEvent.GAME_SAVED, saveName); } /** - * When game save loading starts. + * When game 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: %s", saveName); + public onGameLoad(saveName: TName): void { + logger.info("On game load: %s", saveName); registry.dynamicData = loadDynamicGameSave(saveName) ?? registry.dynamicData; @@ -129,16 +131,18 @@ export class SaveManager extends AbstractManager { loadExtension(extension); } - EventsManager.emitEvent(EGameEvent.GAME_LOAD, registry.dynamicData.event); + EventsManager.emitEvent(EGameEvent.GAME_LOAD, saveName, registry.dynamicData.event); } /** - * When game save loaded successfully. + * When game 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: %s", saveName); + public onAfterGameLoad(saveName: TName): void { + logger.info("On after game load: %s", saveName); + + EventsManager.emitEvent(EGameEvent.GAME_LOADED, saveName); } /** diff --git a/src/engine/core/managers/simulation/SimulationConfig.ts b/src/engine/core/managers/simulation/SimulationConfig.ts index 2eef97e03..c8cf99819 100644 --- a/src/engine/core/managers/simulation/SimulationConfig.ts +++ b/src/engine/core/managers/simulation/SimulationConfig.ts @@ -12,7 +12,7 @@ export const SIMULATION_OBJECTS_PROPERTIES_LTX: IniFile = new ini_file( ); /** - * todo: Remove / use strings / solve it + * todo: Remove / use strings / simplify and make more scalable */ export const GROUP_ID_BY_LEVEL_NAME: LuaTable = $fromObject({ [levels.zaton]: 1, diff --git a/src/engine/core/managers/trade/TradeManager.test.ts b/src/engine/core/managers/trade/TradeManager.test.ts index 09c8d666a..a9ae2edb2 100644 --- a/src/engine/core/managers/trade/TradeManager.test.ts +++ b/src/engine/core/managers/trade/TradeManager.test.ts @@ -106,7 +106,7 @@ describe("TradeManager class implementation", () => { expect(tradeManager.getBuyDiscountForObject(object.id())).toBe(0.3); }); - it("TradeManager should correctly save and load data when not initialized", () => { + it("should correctly save and load data when not initialized", () => { const tradeManager: TradeManager = getManager(TradeManager); const object: GameObject = MockGameObject.mock(); const processor: MockNetProcessor = new MockNetProcessor(); @@ -127,7 +127,11 @@ describe("TradeManager class implementation", () => { expect(registry.trade.get(object.id())).toBeNull(); }); - it("TradeManager should correctly save and load data when not updated", () => { + it("should correctly check if item can be traded (now always true)", () => { + expect(getManager(TradeManager).isItemAvailableForTrade(MockGameObject.mock(), MockGameObject.mock())).toBe(true); + }); + + it("should correctly save and load data when not updated", () => { const tradeManager: TradeManager = getManager(TradeManager); const object: GameObject = MockGameObject.mock(); const ini: IniFile = loadIniFile("managers\\trade\\trade_generic.ltx"); @@ -171,7 +175,7 @@ describe("TradeManager class implementation", () => { }); }); - it("TradeManager should correctly save and load data when updated", () => { + it("should correctly save and load data when updated", () => { const tradeManager: TradeManager = getManager(TradeManager); const object: GameObject = MockGameObject.mock(); const ini: IniFile = loadIniFile("managers\\trade\\trade_generic.ltx"); diff --git a/src/engine/core/managers/trade/TradeManager.ts b/src/engine/core/managers/trade/TradeManager.ts index ae8d086c7..b9d14f494 100644 --- a/src/engine/core/managers/trade/TradeManager.ts +++ b/src/engine/core/managers/trade/TradeManager.ts @@ -192,6 +192,15 @@ export class TradeManager extends AbstractManager { } } + /** + * @param owner - item owner game object + * @param item - target item to check + * @returns whether item can be traded (script logics overriding) + */ + public isItemAvailableForTrade(owner: GameObject, item: GameObject): boolean { + return true; + } + /** * Save object state in net processor. * diff --git a/src/engine/core/managers/weather/WeatherConfig.ts b/src/engine/core/managers/weather/WeatherConfig.ts index fa72d26b0..d1a371485 100644 --- a/src/engine/core/managers/weather/WeatherConfig.ts +++ b/src/engine/core/managers/weather/WeatherConfig.ts @@ -23,4 +23,6 @@ export const weatherConfig = { // Defines how far fog should be based on time / weather section. FOG_DISTANCES: readFogDistances(DYNAMIC_WEATHER_GRAPHS_LTX), MONTH_DAYS: $fromArray([31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]), + // Whether current weather is considered underground. + IS_UNDERGROUND_WEATHER: false, }; diff --git a/src/engine/core/managers/weather/WeatherManager.test.ts b/src/engine/core/managers/weather/WeatherManager.test.ts index 0649407f2..46e93165e 100644 --- a/src/engine/core/managers/weather/WeatherManager.test.ts +++ b/src/engine/core/managers/weather/WeatherManager.test.ts @@ -1,9 +1,10 @@ -import { beforeEach, describe, expect, it } from "@jest/globals"; +import { beforeEach, describe, expect, it, jest } from "@jest/globals"; import { level } from "xray16"; import { disposeManager, getManager } from "@/engine/core/database"; import { EGameEvent, EventsManager } from "@/engine/core/managers/events"; import { EWeatherPeriodType, IWeatherState } from "@/engine/core/managers/weather/weather_types"; +import { weatherConfig } from "@/engine/core/managers/weather/WeatherConfig"; import { WeatherManager } from "@/engine/core/managers/weather/WeatherManager"; import { NIL } from "@/engine/lib/constants/words"; import { resetRegistry } from "@/fixtures/engine"; @@ -14,6 +15,8 @@ import { EPacketDataType, MockNetProcessor } from "@/fixtures/xray/mocks/save"; describe("WeatherManager", () => { beforeEach(() => { resetRegistry(); + + weatherConfig.IS_UNDERGROUND_WEATHER = false; }); it("should correctly initialize and destroy", () => { @@ -36,11 +39,20 @@ describe("WeatherManager", () => { eventsManager.emitEvent(EGameEvent.ACTOR_GO_ONLINE); - expect(level.name()).toBe("zaton"); + jest.spyOn(level, "name").mockImplementation(() => "zaton"); + expect(level.name()).toBe("zaton"); + expect(weatherConfig.IS_UNDERGROUND_WEATHER).toBe(false); expect(manager.weatherPeriod).toBe("good"); expect(manager.weatherSection).toBe("atmosfear_clear_foggy"); expect(String(getFunctionMock(level.set_weather).mock.calls[0][0]).startsWith("af3_slight_")).toBeTruthy(); + + jest.spyOn(level, "name").mockImplementation(() => "jupiter_underground"); + + expect(level.name()).toBe("jupiter_underground"); + expect(weatherConfig.IS_UNDERGROUND_WEATHER).toBe(false); + + jest.spyOn(level, "name").mockImplementation(() => "zaton"); }); it("should correctly set state", () => { diff --git a/src/engine/core/managers/weather/WeatherManager.ts b/src/engine/core/managers/weather/WeatherManager.ts index 5ebfe63d6..7f14fc4e7 100644 --- a/src/engine/core/managers/weather/WeatherManager.ts +++ b/src/engine/core/managers/weather/WeatherManager.ts @@ -39,6 +39,7 @@ import { readIniString, TConditionList, } from "@/engine/core/utils/ini"; +import { isUndergroundLevel } from "@/engine/core/utils/level"; import { LuaLogger } from "@/engine/core/utils/logging"; import { NIL } from "@/engine/lib/constants/words"; import { @@ -353,11 +354,13 @@ export class WeatherManager extends AbstractManager { * Detect current level and environment, reset and re-init states. */ protected onActorNetworkSpawn(): void { - logger.info("Initialize weather on network spawn"); - const levelName: TName = level.name(); const levelWeather: TName = readIniString(GAME_LTX, levelName, "weathers", false, null, ATMOSFEAR_WEATHER); + logger.info("Initialize weather on network spawn: %s, %s", levelName, levelWeather); + + weatherConfig.IS_UNDERGROUND_WEATHER = isUndergroundLevel(levelName); + this.weatherSection = levelWeather; this.weatherConditionList = parseConditionsList(levelWeather); diff --git a/src/engine/core/objects/creature/Actor.ts b/src/engine/core/objects/creature/Actor.ts index 7ff4ac252..823f36b59 100644 --- a/src/engine/core/objects/creature/Actor.ts +++ b/src/engine/core/objects/creature/Actor.ts @@ -96,7 +96,7 @@ export class Actor extends cse_alife_creature_actor implements ISimulationTarget } /** - * Get generic task. + * @returns generic simulation task based on current game graph IDs */ public getSimulationTask(): ALifeSmartTerrainTask { return new CALifeSmartTerrainTask(this.m_game_vertex_id, this.m_level_vertex_id); diff --git a/src/engine/core/utils/extensions/extensions_save.ts b/src/engine/core/utils/extensions/extensions_save.ts index 2c272ce27..5106fb739 100644 --- a/src/engine/core/utils/extensions/extensions_save.ts +++ b/src/engine/core/utils/extensions/extensions_save.ts @@ -6,7 +6,10 @@ import { AnyObject } from "@/engine/lib/types"; const logger: LuaLogger = new LuaLogger($filename); /** - * @param extension - description of extension to save data for + * Handle save event for provided extension. + * Save data in registry dynamic storage for future serialization and storing with lua `marshal` lib. + * + * @param extension - descriptor of extension to save data for */ export function saveExtension(extension: IExtensionsDescriptor): void { if (extension.module.save) { @@ -21,7 +24,10 @@ export function saveExtension(extension: IExtensionsDescriptor): void { } /** - * todo + * Handle load event for provided extension. + * Load data into registry dynamic storage and deserialize it with lua `marshal` lib. + * + * @param extension - descriptor of extension to load data for */ export function loadExtension(extension: IExtensionsDescriptor): void { if (extension.module.load) { diff --git a/src/engine/core/utils/level.ts b/src/engine/core/utils/level.ts index 5b568e8e8..2498b7652 100644 --- a/src/engine/core/utils/level.ts +++ b/src/engine/core/utils/level.ts @@ -1,14 +1,14 @@ import { surgeConfig } from "@/engine/core/managers/surge/SurgeConfig"; -import { TLevel } from "@/engine/lib/constants/levels"; +import { TName } from "@/engine/lib/types"; /** * Check whether level is underground. * - * todo: Define flag in ltx files for all levels. + * todo: Define flag in ltx files for all levels with boolean value, avoid calculations and enums. * * @param levelName - level name to check * @returns whether level is fully indoor. */ -export function isUndergroundLevel(levelName: TLevel): boolean { +export function isUndergroundLevel(levelName: TName): boolean { return surgeConfig.UNDERGROUND_LEVELS.get(levelName) === true; } diff --git a/src/engine/lib/constants/console_commands.ts b/src/engine/lib/constants/console_commands.ts index 575e522d6..ee42d3545 100644 --- a/src/engine/lib/constants/console_commands.ts +++ b/src/engine/lib/constants/console_commands.ts @@ -92,6 +92,7 @@ * CMD3(CCC_Mask, "lua_debug", &g_LuaDebug, 1); * CMD3(CCC_Mask, "g_god", &psActorFlags, AF_GODMODE); * CMD3(CCC_Mask, "g_unlimitedammo", &psActorFlags, AF_UNLIMITEDAMMO); + * CMD3(CCC_Mask, "g_use_tracers", &psActorFlags, AF_USE_TRACERS); * CMD4(CCC_TimeFactorSingle, "time_factor_single", &g_fTimeFactor, 0.f, 1000.0f); * CMD4(CCC_Vector3, "psp_cam_offset", &CCameraLook2::m_cam_offset, Fvector().set(-1000, -1000, -1000), * CMD4(CCC_Float, "ai_smart_factor", &g_smart_cover_factor, 0.f, 1000000.f); diff --git a/src/engine/lib/constants/sections.ts b/src/engine/lib/constants/sections.ts index 33eaaa0af..59d7f5223 100644 --- a/src/engine/lib/constants/sections.ts +++ b/src/engine/lib/constants/sections.ts @@ -6,7 +6,7 @@ import { TName } from "@/engine/lib/types"; export const SMART_TERRAIN_SECTION: TName = "smart_terrain"; /** - * Section representing stalkers that are used only for cutscenes / scenarios (todo: confirm). + * Section representing stalkers that are used only for cutscenes / scenarios. */ export const ACTOR_VISUAL_STALKER: TName = "actor_visual_stalker"; diff --git a/src/engine/lib/types/scheme.ts b/src/engine/lib/types/scheme.ts index 475383632..3a20a42fc 100644 --- a/src/engine/lib/types/scheme.ts +++ b/src/engine/lib/types/scheme.ts @@ -146,8 +146,6 @@ export enum ESchemeEvent { */ export interface ISchemeEventHandler { /** - * todo: Swap params order. - * * Handle schema activation event. * * @param object - game object activation happen for diff --git a/src/engine/scripts/declarations/callbacks/game.test.ts b/src/engine/scripts/declarations/callbacks/game.test.ts index 9c14b7b0d..b51795e05 100644 --- a/src/engine/scripts/declarations/callbacks/game.test.ts +++ b/src/engine/scripts/declarations/callbacks/game.test.ts @@ -1,20 +1,18 @@ import { beforeAll, beforeEach, describe, expect, it, jest } from "@jest/globals"; +import { calculateObjectVisibility, selectBestStalkerWeapon } from "@/engine/core/ai/combat"; import { smartCoversList } from "@/engine/core/animation/smart_covers"; import { getManager } from "@/engine/core/database"; +import { ActorInputManager } from "@/engine/core/managers/actor"; +import { EGameEvent, EventsManager } from "@/engine/core/managers/events"; import { gameOutroConfig, GameOutroManager } from "@/engine/core/managers/outro"; import { SaveManager } from "@/engine/core/managers/save"; import { TradeManager } from "@/engine/core/managers/trade"; -import { AnyArgs, AnyObject, TName } from "@/engine/lib/types"; -import { callBinding, checkBinding, checkNestedBinding, resetRegistry } from "@/fixtures/engine"; +import { AnyObject, GameObject, NetPacket } from "@/engine/lib/types"; +import { callBinding, callNestedBinding, checkBinding, checkNestedBinding, resetRegistry } from "@/fixtures/engine"; +import { MockGameObject, MockNetProcessor } from "@/fixtures/xray"; -function callTradeBinding(name: TName, args: AnyArgs = []): unknown { - return callBinding(name, args, (_G as AnyObject)["trade_manager"]); -} - -function callOutroBinding(name: TName, args: AnyArgs = []): void { - return callBinding(name, args, (_G as AnyObject)["outro"]); -} +jest.mock("@/engine/core/ai/combat"); describe("game external callbacks", () => { beforeAll(() => { @@ -27,16 +25,69 @@ describe("game external callbacks", () => { it("should correctly inject external methods for game", () => { checkBinding("main"); + + checkBinding("CSE_ALifeDynamicObject_on_unregister"); + checkBinding("CALifeUpdateManager__on_before_change_level"); + checkBinding("smart_covers"); checkNestedBinding("smart_covers", "descriptions"); + checkBinding("outro"); + checkNestedBinding("outro", "conditions"); + checkNestedBinding("outro", "start_bk_sound"); + checkNestedBinding("outro", "stop_bk_sound"); + checkNestedBinding("outro", "update_bk_sound_fade_start"); + checkNestedBinding("outro", "update_bk_sound_fade_stop"); + checkBinding("trade_manager"); + checkNestedBinding("trade_manager", "get_sell_discount"); + checkNestedBinding("trade_manager", "get_buy_discount"); + + checkBinding("alife_storage_manager"); + checkNestedBinding("alife_storage_manager", "CALifeStorageManager_load"); + checkNestedBinding("alife_storage_manager", "CALifeStorageManager_after_load"); + checkNestedBinding("alife_storage_manager", "CALifeStorageManager_before_save"); + checkNestedBinding("alife_storage_manager", "CALifeStorageManager_save"); + + checkBinding("level_input"); + checkNestedBinding("level_input", "on_key_press"); + + checkBinding("visual_memory_manager"); + checkNestedBinding("visual_memory_manager", "get_visible_value"); + + checkBinding("ai_stalker"); + checkNestedBinding("ai_stalker", "update_best_weapon"); }); it("main to be defined for custom scripts", () => { expect(() => callBinding("main")).not.toThrow(); }); + it("CSE_ALifeDynamicObject_on_unregister to be defined and emit events", () => { + const manager: EventsManager = getManager(EventsManager); + + const onUnregister = jest.fn(); + + manager.registerCallback(EGameEvent.SERVER_OBJECT_UNREGISTERED, onUnregister); + + expect(() => callBinding("CSE_ALifeDynamicObject_on_unregister", [5_000])).not.toThrow(); + expect(onUnregister).toHaveBeenCalledTimes(1); + expect(onUnregister).toHaveBeenCalledWith(5_000); + }); + + it("CALifeUpdateManager__on_before_change_level to be defined and emit events", () => { + const manager: EventsManager = getManager(EventsManager); + const packet: NetPacket = MockNetProcessor.mockNetPacket(); + + const onBeforeLevelChange = jest.fn(); + + manager.registerCallback(EGameEvent.BEFORE_LEVEL_CHANGE, onBeforeLevelChange); + + expect(() => callBinding("CALifeUpdateManager__on_before_change_level", [packet])).not.toThrow(); + expect(onBeforeLevelChange).toHaveBeenCalledTimes(1); + expect(onBeforeLevelChange).toHaveBeenCalledWith(packet); + }); + it("smart_covers should be defined", () => { expect((_G as AnyObject).smart_covers.descriptions).toBe(smartCoversList); }); @@ -51,17 +102,17 @@ describe("game external callbacks", () => { expect((_G as AnyObject)["outro"]["conditions"]).toBe(gameOutroConfig.OUTRO_CONDITIONS); - callOutroBinding("start_bk_sound"); + callNestedBinding("outro", "start_bk_sound"); expect(outroManager.startBlackScreenAndSound).toHaveBeenCalled(); - callOutroBinding("stop_bk_sound"); + callNestedBinding("outro", "stop_bk_sound"); expect(outroManager.stopBlackScreenAndSound).toHaveBeenCalled(); - callOutroBinding("update_bk_sound_fade_start", [25]); + callNestedBinding("outro", "update_bk_sound_fade_start", [25]); expect(outroManager.updateBlackScreenAndSoundFadeStart).toHaveBeenCalledWith(25); expect(outroManager.stopBlackScreenAndSound).toHaveBeenCalled(); - callOutroBinding("update_bk_sound_fade_stop", [35]); + callNestedBinding("outro", "update_bk_sound_fade_stop", [35]); expect(outroManager.updateBlackScreenAndSoundFadeStop).toHaveBeenCalledWith(35); }); @@ -71,46 +122,62 @@ describe("game external callbacks", () => { jest.spyOn(tradeManager, "getSellDiscountForObject").mockImplementation(() => 10); jest.spyOn(tradeManager, "getBuyDiscountForObject").mockImplementation(() => 20); - expect(callTradeBinding("get_sell_discount", [30])).toBe(10); + expect(callNestedBinding("trade_manager", "get_sell_discount", [30])).toBe(10); expect(tradeManager.getSellDiscountForObject).toHaveBeenCalledWith(30); - expect(callTradeBinding("get_buy_discount", [40])).toBe(20); + expect(callNestedBinding("trade_manager", "get_buy_discount", [40])).toBe(20); expect(tradeManager.getBuyDiscountForObject).toHaveBeenCalledWith(40); }); - it("on_before_game_save should be handled", () => { + it("alife storage save methods should be defined", () => { const saveManager: SaveManager = getManager(SaveManager); jest.spyOn(saveManager, "onBeforeGameSave"); + jest.spyOn(saveManager, "onGameSave"); + jest.spyOn(saveManager, "onGameLoad"); + jest.spyOn(saveManager, "onAfterGameLoad"); + + callNestedBinding("alife_storage_manager", "CALifeStorageManager_before_save", ["name1"]); + expect(saveManager.onBeforeGameSave).toHaveBeenCalledWith("name1"); + + callNestedBinding("alife_storage_manager", "CALifeStorageManager_save", ["name2"]); + expect(saveManager.onGameSave).toHaveBeenCalledWith("name2"); - callBinding("on_before_game_save", ["name"]); - expect(saveManager.onBeforeGameSave).toHaveBeenCalledWith("name"); + callNestedBinding("alife_storage_manager", "CALifeStorageManager_load", ["name3"]); + expect(saveManager.onGameLoad).toHaveBeenCalledWith("name3"); + + callNestedBinding("alife_storage_manager", "CALifeStorageManager_after_load", ["name4"]); + expect(saveManager.onAfterGameLoad).toHaveBeenCalledWith("name4"); }); - it("on_game_save should be handled", () => { - const saveManager: SaveManager = getManager(SaveManager); + it("level_input callbacks should be defined", () => { + const manager: ActorInputManager = getManager(ActorInputManager); - jest.spyOn(saveManager, "onGameSave"); + jest.spyOn(manager, "onKeyPress").mockImplementation(jest.fn(() => false)); + + callNestedBinding("level_input", "on_key_press", [1, 2]); - callBinding("on_game_save", ["name"]); - expect(saveManager.onGameSave).toHaveBeenCalledWith("name"); + expect(manager.onKeyPress).toHaveBeenCalledTimes(1); + expect(manager.onKeyPress).toHaveBeenCalledWith(1, 2); }); - it("on_before_game_load should be handled", () => { - const saveManager: SaveManager = getManager(SaveManager); + it("visual_memory_manager callbacks should be defined", () => { + const object: GameObject = MockGameObject.mock(); + const target: GameObject = MockGameObject.mock(); - jest.spyOn(saveManager, "onBeforeGameLoad"); + callNestedBinding("visual_memory_manager", "get_visible_value", [object, target, 1000, 50, 25, 5, 1, 200, 20, 2]); - callBinding("on_before_game_load", ["name"]); - expect(saveManager.onBeforeGameLoad).toHaveBeenCalledWith("name"); + expect(calculateObjectVisibility).toHaveBeenCalledTimes(1); + expect(calculateObjectVisibility).toHaveBeenCalledWith(object, target, 1000, 50, 25, 5, 1, 200, 20, 2); }); - it("on_game_load should be handled", () => { - const saveManager: SaveManager = getManager(SaveManager); + it("ai_stalker callbacks should be defined", () => { + const object: GameObject = MockGameObject.mock(); + const weapon: GameObject = MockGameObject.mock(); - jest.spyOn(saveManager, "onGameLoad"); + callNestedBinding("ai_stalker", "update_best_weapon", [object, weapon]); - callBinding("on_game_load", ["name"]); - expect(saveManager.onGameLoad).toHaveBeenCalledWith("name"); + expect(selectBestStalkerWeapon).toHaveBeenCalledTimes(1); + expect(selectBestStalkerWeapon).toHaveBeenCalledWith(object, weapon); }); }); diff --git a/src/engine/scripts/declarations/callbacks/game.ts b/src/engine/scripts/declarations/callbacks/game.ts index 8834aa105..924ea972b 100644 --- a/src/engine/scripts/declarations/callbacks/game.ts +++ b/src/engine/scripts/declarations/callbacks/game.ts @@ -1,12 +1,15 @@ +import { calculateObjectVisibility, selectBestStalkerWeapon } from "@/engine/core/ai/combat"; import { smartCoversList } from "@/engine/core/animation/smart_covers"; import { getManager } from "@/engine/core/database"; +import { ActorInputManager } from "@/engine/core/managers/actor"; +import { EGameEvent, EventsManager } from "@/engine/core/managers/events"; import { GameOutroManager } from "@/engine/core/managers/outro"; import { gameOutroConfig } from "@/engine/core/managers/outro/GameOutroConfig"; import { SaveManager } from "@/engine/core/managers/save"; import { TradeManager } from "@/engine/core/managers/trade"; import { extern } from "@/engine/core/utils/binding"; import { LuaLogger } from "@/engine/core/utils/logging"; -import { TName, TNumberId } from "@/engine/lib/types"; +import { NetPacket, TName, TNumberId } from "@/engine/lib/types"; const logger: LuaLogger = new LuaLogger($filename); @@ -18,6 +21,24 @@ logger.info("Resolve and bind game externals"); */ extern("main", () => {}); +/** + * Declare method for all dynamic objects unregister event. + * Good place to remove ids from persistent tables and clean up all object data. + */ +extern("CSE_ALifeDynamicObject_on_unregister", (id: TNumberId) => { + EventsManager.emitEvent(EGameEvent.SERVER_OBJECT_UNREGISTERED, id); +}); + +/** + * Handle event before level change. + * As an idea - clear corpses or other cleanup logics parts. + */ +extern("CALifeUpdateManager__on_before_change_level", (packet: NetPacket) => { + logger.info("On before level change callback"); + + EventsManager.emitEvent(EGameEvent.BEFORE_LEVEL_CHANGE, packet); +}); + /** * Declare list of available smart covers for game engine. * Xray uses it internally. @@ -48,21 +69,48 @@ extern("trade_manager", { }); /** - * Called from game engine just before creating game save. + * AlifeStorage callbacks module. + * Includes methods working with game saves to provide alternatives for storage packets. + * Alternative variants are: + * - Flexible, not hardcoded, can contain extensive data + * - Not limited by game save file upper limits */ -extern("on_before_game_save", (saveName: TName) => getManager(SaveManager).onBeforeGameSave(saveName)); +extern("alife_storage_manager", { + /** + * Called from game engine on loading game save. + */ + CALifeStorageManager_load: (saveName: TName) => getManager(SaveManager).onGameLoad(saveName), + /** + * Called from game engine after successful game load. + */ + CALifeStorageManager_after_load: (saveName: TName) => getManager(SaveManager).onAfterGameLoad(saveName), + /** + * Called from game engine just before creating game save. + */ + CALifeStorageManager_before_save: (saveName: TName) => getManager(SaveManager).onBeforeGameSave(saveName), + /** + * Called from game engine when game save is created. + */ + CALifeStorageManager_save: (saveName: TName) => getManager(SaveManager).onGameSave(saveName), +}); /** - * Called from game engine when game save is created. + * Callbacks related to game input from player. */ -extern("on_game_save", (saveName: TName) => getManager(SaveManager).onGameSave(saveName)); +extern("level_input", { + on_key_press: (key: TNumberId, bind: TNumberId) => getManager(ActorInputManager).onKeyPress(key, bind), +}); /** - * Called from game engine just before loading game save. + * Callbacks related to objects visibility and memory calculation for AI logics execution. */ -extern("on_before_game_load", (saveName: TName) => getManager(SaveManager).onBeforeGameLoad(saveName)); +extern("visual_memory_manager", { + get_visible_value: calculateObjectVisibility, +}); /** - * Called from game engine after loading game save. + * Callbacks related to objects AI logics calculation and execution. */ -extern("on_game_load", (saveName: TName) => getManager(SaveManager).onGameLoad(saveName)); +extern("ai_stalker", { + update_best_weapon: selectBestStalkerWeapon, +}); diff --git a/src/engine/scripts/declarations/callbacks/interface.test.ts b/src/engine/scripts/declarations/callbacks/interface.test.ts index 659fea42e..0a5dbb135 100644 --- a/src/engine/scripts/declarations/callbacks/interface.test.ts +++ b/src/engine/scripts/declarations/callbacks/interface.test.ts @@ -4,6 +4,7 @@ import { getManager } from "@/engine/core/database"; import { ActorInventoryMenuManager } from "@/engine/core/managers/actor"; import { LoadScreenManager } from "@/engine/core/managers/interface/LoadScreenManager"; import { PdaManager } from "@/engine/core/managers/pda"; +import { TradeManager } from "@/engine/core/managers/trade"; import { canRepairItem, getRepairItemAskReplicLabel, @@ -176,8 +177,16 @@ describe("interface external callbacks", () => { it("actor_menu_inventory callbacks", () => { const actorInventoryMenuManager: ActorInventoryMenuManager = getManager(ActorInventoryMenuManager); + const tradeManager: TradeManager = getManager(TradeManager); jest.spyOn(actorInventoryMenuManager, "onItemDropped").mockImplementation(jest.fn()); + jest.spyOn(actorInventoryMenuManager, "onItemFocusReceived").mockImplementation(jest.fn()); + jest.spyOn(actorInventoryMenuManager, "onItemFocusLost").mockImplementation(jest.fn()); + + jest.spyOn(tradeManager, "isItemAvailableForTrade").mockImplementation(jest.fn(() => false)); + + const owner: GameObject = MockGameObject.mock(); + const item: GameObject = MockGameObject.mock(); const from: GameObject = MockGameObject.mock(); const to: GameObject = MockGameObject.mock(); @@ -186,6 +195,16 @@ describe("interface external callbacks", () => { callBinding("CUIActorMenu_OnItemDropped", [from, to, oldList, newList], (_G as AnyObject)["actor_menu_inventory"]); expect(actorInventoryMenuManager.onItemDropped).toHaveBeenCalledWith(from, to, oldList, newList); + + callBinding("CUIActorMenu_OnItemFocusLost", [item], (_G as AnyObject)["actor_menu_inventory"]); + expect(actorInventoryMenuManager.onItemFocusLost).toHaveBeenCalledWith(item); + + callBinding("CUIActorMenu_OnItemFocusReceive", [item], (_G as AnyObject)["actor_menu_inventory"]); + expect(actorInventoryMenuManager.onItemFocusReceived).toHaveBeenCalledWith(item); + + callBinding("CInventory_ItemAvailableToTrade", [owner, item], (_G as AnyObject)["actor_menu_inventory"]); + expect(tradeManager.isItemAvailableForTrade).toHaveBeenCalledWith(owner, item); + expect(tradeManager.isItemAvailableForTrade).toHaveReturnedWith(false); }); it("pda callbacks", () => { diff --git a/src/engine/scripts/declarations/callbacks/interface.ts b/src/engine/scripts/declarations/callbacks/interface.ts index 0e971638a..b6794a515 100644 --- a/src/engine/scripts/declarations/callbacks/interface.ts +++ b/src/engine/scripts/declarations/callbacks/interface.ts @@ -2,6 +2,7 @@ import { getManager } from "@/engine/core/database"; import { ActorInventoryMenuManager } from "@/engine/core/managers/actor/ActorInventoryMenuManager"; import { LoadScreenManager } from "@/engine/core/managers/interface/LoadScreenManager"; import { PdaManager } from "@/engine/core/managers/pda/PdaManager"; +import { TradeManager } from "@/engine/core/managers/trade"; import { UpgradesManager } from "@/engine/core/managers/upgrades/UpgradesManager"; import { getRepairItemAskReplicLabel, @@ -29,6 +30,7 @@ import { TLabel, TName, TNotCastedBoolean, + TNumberId, TRate, TSection, } from "@/engine/lib/types"; @@ -46,7 +48,7 @@ extern("loadscreen", { }); /** - * Handle item upgrade callbacks from game engine. + * Item upgrade callbacks from game engine. */ extern("inventory_upgrades", { get_upgrade_cost: (section: TSection): TLabel => getUpgradeCostLabel(section), @@ -71,7 +73,7 @@ extern("inventory_upgrades", { }); /** - * Handle actor menu modes switching (pda, map, inventory). + * Actor menu modes switching (pda, map, inventory) callbacks declaration. */ extern("actor_menu", { actor_menu_mode: (mode: EActorMenuMode): void => { @@ -80,7 +82,7 @@ extern("actor_menu", { }); /** - * Handle actor menu callbacks. + * Actor menu callbacks declaration. */ extern("actor_menu_inventory", { /** @@ -102,14 +104,32 @@ extern("actor_menu_inventory", { return true; }, + /** + * @param item - item game object receiving focus + */ + CUIActorMenu_OnItemFocusReceive: (item: GameObject) => + getManager(ActorInventoryMenuManager).onItemFocusReceived(item), + /** + * @param item - item game object losing focus + */ + CUIActorMenu_OnItemFocusLost: (item: GameObject) => getManager(ActorInventoryMenuManager).onItemFocusLost(item), + /** + * Script utils for logics extending to override availability of some items in NPC trading. + * + * @param owner - item owning game object + * @param item - item game object for check + * @returns whether item is available for trading + */ + CInventory_ItemAvailableToTrade: (owner: GameObject, item: GameObject): boolean => + getManager(TradeManager).isItemAvailableForTrade(owner, item), }); /** * PDA callbacks. */ extern("pda", { - set_active_subdialog: (...args: AnyArgs): void => { - logger.info("Set active subdialog"); + set_active_subdialog: (section: TSection): void => { + logger.info("Set active sub-dialog: %s", section); }, get_max_resource: (): TCount => { return 10; @@ -120,16 +140,16 @@ extern("pda", { get_max_member_count: (): TCount => { return 10; }, - actor_menu_mode: (...args: AnyArgs): void => { - logger.info("Pda actor menu mode changed"); + actor_menu_mode: (mode: TNumberId): void => { + logger.info("PDA actor menu mode changed: %s", mode); }, // todo: m_UIPropertiesBox, m_cur_location property_box_clicked: (...args: AnyArgs): void => { - logger.info("Pda box property clicked"); + logger.info("PDA box property clicked"); }, // todo: m_UIPropertiesBox, m_cur_location->ObjectID(), (LPCSTR)m_cur_location->GetLevelName().c_str(), m_cur_location property_box_add_properties: (...args: AnyArgs): void => { - logger.info("Pda box property added"); + logger.info("PDA box property added"); }, fill_fraction_state: (state: AnyObject): void => { getManager(PdaManager).fillFactionState(state); @@ -149,7 +169,7 @@ extern("pda", { }); /** - * Params in weapon menu in inventory. + * Params calculation for weapon menu in inventory. */ extern("ui_wpn_params", { GetRPM: (section: TSection, upgradeSections: string): number => readWeaponRPM(section, upgradeSections), diff --git a/src/fixtures/engine/utils/check_binding.ts b/src/fixtures/engine/utils/check_binding.ts index 335945297..48a450a0d 100644 --- a/src/fixtures/engine/utils/check_binding.ts +++ b/src/fixtures/engine/utils/check_binding.ts @@ -26,6 +26,21 @@ export function callBinding(name: TName, args: AnyArgs = [], container: AnyOb return (container[name] as AnyCallable)(...args) as T; } +/** + * Call nested binding function. + * + * @param base - base name of global binding object + * @param name - name of nested binding + * @param args - variadic list of arguments + * @param container - container object + * @returns generic value from binding function + */ +export function callNestedBinding(base: TName, name: TName, args: AnyArgs = [], container: AnyObject = _G): T { + checkNestedBinding(base, name, container); + + return (container[base][name] as AnyCallable)(...args) as T; +} + /** * Expect binding to be defined in nested global container. *