/** * Classification of how a `Context` field behaves on `Context.resetRuntimeState()`. * * Every field on Context that holds mutable collection state (Array/Map/Set) MUST carry a * classification via the {@link resettable} decorator — this is enforced by a test * (tests/context/test context reset classification.ts), so adding a new runtime-state field * without deciding its reset behavior fails CI instead of silently leaking. */ export type ContextResetBehavior = /** Collections are emptied in place: arrays → length 0, Map/Set → clear(), * plain container objects (e.g. the coroutines map) → each array entry emptied */ | "empty" /** Field is set to null */ | "null" /** Field is set to undefined */ | "undefined" /** Deliberately NOT reset — document why at the field declaration */ | "keep" /** Reset by explicit code (e.g. Context.resetSubsystems) — the decorator only records * that the decision was made consciously */ | "custom"; const $contextReset = Symbol("needle:context-reset-behavior"); /** * @internal Classifies how a `Context` field behaves on `Context.resetRuntimeState()`. * Fields marked `"empty"`/`"null"`/`"undefined"` are reset automatically by * {@link applyContextReset}; `"keep"` and `"custom"` only record the decision. */ export function resettable(behavior: ContextResetBehavior = "empty"): PropertyDecorator { return function (target: object, propertyKey: string | symbol) { let map = (target as Record)[$contextReset] as Map | undefined; if (!map || !Object.prototype.hasOwnProperty.call(target, $contextReset)) { // create an own map per class, copying inherited entries so subclasses don't shadow them away map = new Map(map); Object.defineProperty(target, $contextReset, { value: map, enumerable: false, writable: false, configurable: true }); } map.set(propertyKey, behavior); }; } /** @internal Returns the reset classifications declared via {@link resettable} for an instance's class. */ export function getContextResetBehaviors(instance: object): ReadonlyMap | undefined { return (instance as Record)[$contextReset] as Map | undefined; } /** @internal Applies all automatic reset behaviors ("empty"/"null"/"undefined") declared via {@link resettable}. */ export function applyContextReset(instance: object): void { const behaviors = getContextResetBehaviors(instance); if (!behaviors) return; const obj = instance as Record; for (const [key, behavior] of behaviors) { switch (behavior) { case "keep": case "custom": break; case "null": obj[key] = null; break; case "undefined": obj[key] = undefined; break; case "empty": { const value = obj[key]; if (Array.isArray(value)) value.length = 0; else if (value instanceof Map || value instanceof Set) value.clear(); else if (value && typeof value === "object") { // plain container object, e.g. coroutines: { [FrameEvent]: CoroutineData[] } const container = value as Record; for (const k of Object.keys(container)) { const entry = container[k]; if (Array.isArray(entry)) entry.length = 0; else delete container[k]; } } else if (value !== undefined && value !== null) { console.warn(`Context.resetRuntimeState: field "${String(key)}" is marked @resettable("empty") but does not hold a collection`, value); } break; } } } }