Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 7 additions & 3 deletions src/engine/core/binders/creature/ActorBinder.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -87,8 +87,9 @@ describe("ActorBinder", () => {
expect(eventsManager.emitEvent).toHaveBeenCalledTimes(2);
expect(eventsManager.emitEvent).toHaveBeenCalledWith(EGameEvent.ACTOR_GO_OFFLINE, binder);

expect(actor.set_callback).toHaveBeenCalledTimes(10);
expect(actor.set_callback).toHaveBeenCalledTimes(11);
expect(actor.set_callback).toHaveBeenCalledWith(callback.inventory_info, null);
// todo: Add here check for info removal.
expect(actor.set_callback).toHaveBeenCalledWith(callback.article_info, null);
expect(actor.set_callback).toHaveBeenCalledWith(callback.on_item_take, null);
expect(actor.set_callback).toHaveBeenCalledWith(callback.on_item_drop, null);
Expand Down Expand Up @@ -134,8 +135,9 @@ describe("ActorBinder", () => {
expect(state).not.toBeNull();
expect(state.portableStore).not.toBeNull();

expect(actor.set_callback).toHaveBeenCalledTimes(8);
expect(actor.set_callback).toHaveBeenCalledTimes(9);
expect(actor.set_callback).toHaveBeenCalledWith(callback.inventory_info, expect.any(Function));
// todo: Add here check for info removal.
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));
Expand Down Expand Up @@ -278,7 +280,9 @@ describe("ActorBinder", () => {
binder.reinit();

MockGameObject.callCallback(actor, callback.inventory_info, actor, "test-info");
expect(eventsManager.emitEvent).toHaveBeenCalledWith(EGameEvent.ACTOR_INFO_UPDATE, actor, "test-info");
expect(eventsManager.emitEvent).toHaveBeenCalledWith(EGameEvent.ACTOR_INFO_ADDED, actor, "test-info");

// todo: Test info removal callback here.

MockGameObject.callCallback(actor, callback.take_item_from_box, box, item);
expect(eventsManager.emitEvent).toHaveBeenCalledWith(EGameEvent.ACTOR_TAKE_BOX_ITEM, box, item);
Expand Down
20 changes: 18 additions & 2 deletions src/engine/core/binders/creature/ActorBinder.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { callback, level, LuabindClass, object_binder, time_global } from "xray16";
import { GameObject, GameTask, NetPacket, NetReader, ServerActorObject, TTaskState } from "xray16/alias";
import { ACTOR_ID, Nillable, TCount, TDuration, TName, TSection, TTimestamp } from "xray16/lib";
import { ACTOR_ID, AnyObject, Nillable, TCount, TDuration, TName, TSection, TTimestamp } from "xray16/lib";
import { $filename } from "xray16/macros";

import {
Expand All @@ -26,6 +26,7 @@ import { alifeConfig } from "@/engine/core/managers/simulation/AlifeConfig";
import { ISchemeDeimosState } from "@/engine/core/schemes/restrictor/sr_deimos";
import { SchemeDeimos } from "@/engine/core/schemes/restrictor/sr_deimos/SchemeDeimos";
import { setStableAlifeObjectsUpdate, setUnlimitedAlifeObjectsUpdate } from "@/engine/core/utils/alife";
import { updateInfoPortionCache } from "@/engine/core/utils/info_portion";
import { LuaLogger } from "@/engine/core/utils/logging";

const logger: LuaLogger = new LuaLogger($filename);
Expand Down Expand Up @@ -225,9 +226,19 @@ export class ActorBinder extends object_binder {
const object: GameObject = this.object;
const eventsManager: EventsManager = this.eventsManager;

// Covers engine-side grants (dialog XML, tasks, info chains) invisible to lua utils.
object.set_callback(callback.inventory_info, (object: GameObject, info: string) => {
eventsManager.emitEvent(EGameEvent.ACTOR_INFO_UPDATE, object, info);
updateInfoPortionCache(info, true);
eventsManager.emitEvent(EGameEvent.ACTOR_INFO_ADDED, object, info);
});
// Todo: add `inventory_info_removed` to xray16 package typings.
object.set_callback(
(callback as unknown as AnyObject).inventory_info_removed as typeof callback.inventory_info,
(object: GameObject, info: string) => {
updateInfoPortionCache(info, false);
eventsManager.emitEvent(EGameEvent.ACTOR_INFO_REMOVED, object, info);
}
);
object.set_callback(callback.take_item_from_box, (box: GameObject, item: GameObject) => {
eventsManager.emitEvent(EGameEvent.ACTOR_TAKE_BOX_ITEM, box, item);
});
Expand Down Expand Up @@ -263,6 +274,11 @@ export class ActorBinder extends object_binder {
const object: GameObject = this.object;

object.set_callback(callback.inventory_info, null);
// todo: Simplify type casting after OpenXray update.
object.set_callback(
(callback as unknown as AnyObject).inventory_info_removed as typeof callback.inventory_info,
null
);
object.set_callback(callback.article_info, null);
object.set_callback(callback.on_item_take, null);
object.set_callback(callback.on_item_drop, null);
Expand Down
6 changes: 6 additions & 0 deletions src/engine/core/database/actor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { type IRegistryObjectState } from "@/engine/core/database/database_types
import { registerObject, unregisterObject } from "@/engine/core/database/objects";
import { registry } from "@/engine/core/database/registry";
import { type Actor } from "@/engine/core/objects/creature/Actor";
import { invalidateInfoPortionsCache } from "@/engine/core/utils/info_portion";

/**
* Register new actor entry in db.
Expand All @@ -15,6 +16,9 @@ import { type Actor } from "@/engine/core/objects/creature/Actor";
export function registerActor(object: GameObject): IRegistryObjectState {
registry.actor = object;

// New actor instance means unknown info portions state.
invalidateInfoPortionsCache();

return registerObject(object);
}

Expand All @@ -26,6 +30,8 @@ export function registerActor(object: GameObject): IRegistryObjectState {
export function unregisterActor(): void {
unregisterObject(registry.actor);
registry.actor = null as unknown as GameObject;

invalidateInfoPortionsCache();
}

/**
Expand Down
9 changes: 6 additions & 3 deletions src/engine/core/database/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,15 +62,18 @@ export const registry = {
*/
schemes: new LuaTable<EScheme, TAbstractSchemeConstructor>(),
/**
* Session-constant derived facts only: entries are computed once, never invalidated and always
* safe to read. Transient or coherence-managed caches (per-tick snapshots, scratch buffers)
* belong next to their mutation points in domain modules instead.
* Session-constant derived facts and engine-mirrored state kept coherent via engine callbacks.
* Nested containers may be replaced wholesale on invalidation, so always access them through ref.
*/
cache: {
/**
* Memoized condlist for parsing simplification, where key is string data and value is parsed descriptor.
*/
conditionLists: new LuaMap<string, TConditionList>(),
/**
* Mirror of actor info portions state.
*/
infoPortions: new LuaMap<TName, boolean>(),
/**
* Memoized game graph vertex -> level id lookups, constant for the loaded game graph.
*/
Expand Down
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", () => {
it("should correctly initialize", () => {
const manager: EventsManager = getManager(EventsManager);

expect(table.size(manager.callbacks)).toBe(128);
expect(table.size(manager.callbacks)).toBe(129);

Object.keys(manager.callbacks).forEach((it) => {
expect(table.size(manager.callbacks[it as unknown as EGameEvent])).toBe(0);
Expand Down
8 changes: 6 additions & 2 deletions src/engine/core/managers/events/events_types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,9 +63,13 @@ export enum EGameEvent {
*/
ACTOR_DEATH,
/**
* Actor info-portions update.
* Actor info-portion added.
*/
ACTOR_INFO_UPDATE,
ACTOR_INFO_ADDED,
/**
* Actor info-portion removed.
*/
ACTOR_INFO_REMOVED,
/**
* Actor take item from box.
*/
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { SoundManager } from "@/engine/core/managers/sounds";
import { SmartCover } from "@/engine/core/objects/smart_cover";
import { ISchemeSmartCoverState } from "@/engine/core/schemes/stalker/smartcover";
import { ActionSmartCoverUse } from "@/engine/core/schemes/stalker/smartcover/actions/ActionSmartCoverUse";
import { giveInfoPortion, invalidateInfoPortionsCache } from "@/engine/core/utils/info_portion";
import { parseConditionsList } from "@/engine/core/utils/ini";
import { EScheme } from "@/engine/lib/types";
import { mockRegisteredActor, mockSchemeState, resetRegistry } from "@/fixtures/engine";
Expand Down Expand Up @@ -271,7 +272,7 @@ describe("ActionSmartCoverUse", () => {
expect(action.firePosition).toEqual(MockVector.mock(1, 1, 1));
expect(action.object.set_smart_cover_target).toHaveBeenCalledWith(action.firePosition);

actorGameObject.give_info_portion("a");
giveInfoPortion("a");

expect(() => action.updateSmartCoverTarget()).toThrow("There is no patrol path 'not-existing'.");
});
Expand Down
55 changes: 54 additions & 1 deletion src/engine/core/utils/info_portion.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
hasFewInfoPortions,
hasInfoPortion,
hasInfoPortions,
invalidateInfoPortionsCache,
} from "@/engine/core/utils/info_portion";
import { infoPortions } from "@/engine/lib/constants/info_portions";
import { mockRegisteredActor, resetRegistry } from "@/fixtures/engine";
Expand Down Expand Up @@ -46,7 +47,9 @@ describe("disableInfoPortion util", () => {
expect(hasInfoPortion(infoPortions.info_up_ac_mp5)).toBe(false);

expect(registry.actor.disable_info_portion).toHaveBeenCalledTimes(1);
expect(registry.actor.has_info).toHaveBeenCalledTimes(3);

// Gives / disables update the mirror granularly - no engine reads were needed at all.
expect(registry.actor.has_info).toHaveBeenCalledTimes(0);
});
});

Expand Down Expand Up @@ -143,3 +146,53 @@ describe("hasFewInfoPortions util", () => {
).toBe(false);
});
});

describe("info portions mirror", () => {
beforeEach(() => {
resetRegistry();
});

it("should memoize checks and re-read from engine after invalidation", () => {
const { actorGameObject } = mockRegisteredActor();

expect(hasInfoPortion(infoPortions.info_up_ac_mp5)).toBe(false);

// Raw mock grant is invisible while memoized (engine grants update the mirror via actor binder callback).
actorGameObject.give_info_portion(infoPortions.info_up_ac_mp5);

expect(hasInfoPortion(infoPortions.info_up_ac_mp5)).toBe(false);
expect(registry.actor.has_info).toHaveBeenCalledTimes(1);

invalidateInfoPortionsCache();

expect(hasInfoPortion(infoPortions.info_up_ac_mp5)).toBe(true);
});

it("should update exact entries on lua-side mutations without dropping the mirror", () => {
mockRegisteredActor();

expect(hasInfoPortion(infoPortions.pri_a15_lights_off)).toBe(false);

giveInfoPortion(infoPortions.info_up_ac_mp5);

// Mutated entry is visible without engine reads, unrelated memoized entry survives.
expect(hasInfoPortion(infoPortions.info_up_ac_mp5)).toBe(true);
expect(hasInfoPortion(infoPortions.pri_a15_lights_off)).toBe(false);
expect(registry.actor.has_info).toHaveBeenCalledTimes(1);
});

it("should invalidate when the actor is re-registered", () => {
const { actorGameObject: firstActor } = mockRegisteredActor();

firstActor.give_info_portion(infoPortions.info_up_ac_mp5);
invalidateInfoPortionsCache();

expect(hasInfoPortion(infoPortions.info_up_ac_mp5)).toBe(true);

// New actor instance - registerActor replaces the mirror, state re-read from the fresh object.
resetRegistry();
mockRegisteredActor();

expect(hasInfoPortion(infoPortions.info_up_ac_mp5)).toBe(false);
});
});
51 changes: 46 additions & 5 deletions src/engine/core/utils/info_portion.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,33 @@
import { TCount, TName } from "xray16/lib";
import { $filename, $isNil } from "xray16/macros";
import { Nillable, TCount, TName } from "xray16/lib";
import { $filename, $isNil, $isNotNil } from "xray16/macros";

import { registry } from "@/engine/core/database/registry";
import { ELuaLoggerMode, LuaLogger } from "@/engine/core/utils/logging";
import { TInfoPortion } from "@/engine/lib/constants/info_portions/info_portions";

const logger: LuaLogger = new LuaLogger($filename, { file: "info_portions", mode: ELuaLoggerMode.DUAL });

/**
* Replace the actor info portions mirror when the whole state becomes unknown.
* Called on actor lifecycle changes (register / unregister) - granular mutations are applied
* with `updateInfoPortionCache` instead, engine callbacks make every mutation observable.
*/
export function invalidateInfoPortionsCache(): void {
registry.cache.infoPortions = new LuaMap();
}

/**
* Update state of a single info portion in the mirror cache.
* Called from lua-side give / disable utils and from actor binder engine callbacks
* (`inventory_info` grants, `inventory_info_removed` disables).
*
* @param name - Info portion name to update.
* @param isActive - Whether the info portion is known to be active.
*/
export function updateInfoPortionCache(name: TName, isActive: boolean): void {
registry.cache.infoPortions.set(name, isActive);
}

/**
* Give info portion to actor.
*
Expand All @@ -16,6 +37,7 @@ export function giveInfoPortion(name: TName): void {
logger.info("Give info portion: %s", name);

registry.actor.give_info_portion(name);
updateInfoPortionCache(name, true);
}

/**
Expand All @@ -26,19 +48,38 @@ export function giveInfoPortion(name: TName): void {
export function disableInfoPortion(name: TName): void {
logger.info("Disable info portion: %s", name);

if (registry.actor?.has_info(name)) {
if (hasInfoPortion(name)) {
registry.actor.disable_info_portion(name);
updateInfoPortionCache(name, false);
}
}

/**
* Whether actor has info portion set.
* Fallbacks to false if actor is not registered.
*
* Checks hit the mirror cache first - condlists re-check the same handful of infos many times
* per frame and each miss crosses into an engine O(n) vector scan, so steady-state checks are
* plain table reads.
*
* @returns Whether actor has info portion set already.
*/
export function hasInfoPortion(name: TName): name is TInfoPortion {
return $isNil(registry.actor) ? false : registry.actor.has_info(name);
if ($isNil(registry.actor)) {
return false;
}

const existing: Nillable<boolean> = registry.cache.infoPortions.get(name);

if ($isNotNil(existing)) {
return existing;
}

const isActive: boolean = registry.actor.has_info(name);

registry.cache.infoPortions.set(name, isActive);

return isActive;
}

/**
Expand Down Expand Up @@ -72,7 +113,7 @@ export function hasFewInfoPortions(names: Array<TName>, count: TCount): boolean
let activeInfos: TCount = 0;

for (let it = 0; it < names.length; it++) {
if (registry.actor.has_info(names[it])) {
if (hasInfoPortion(names[it])) {
activeInfos += 1;

if (activeInfos >= count) {
Expand Down
8 changes: 8 additions & 0 deletions src/fixtures/engine/mocks/registry.mock.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,14 @@ export function mockRegisteredActor(
*/
export function resetRegistry(): void {
registry.actor = null as unknown as GameObject;
registry.cache = {
conditionLists: new LuaMap(),
gameVertexLevelIds: new LuaMap(),
graphDistances: new LuaMap(),
graphDistancesCount: 0,
infoPortions: new LuaMap(),
levelNames: new LuaMap(),
};
registry.actorCombat = new LuaTable();
registry.artefacts.parentZones = new LuaTable();
registry.artefacts.points = new LuaTable();
Expand Down