import type { NeedSet } from "../sim/needs.js"; /** * How one need's satisfaction maps to an option's appeal: `1` at `from`, `0` at `to`, clamped * outside. Read satisfaction `1` as a full meter; pair with {@link UtilityTerm.invert} when a full * meter is what makes the option attractive. */ export type ResponseCurve = { readonly kind: "linear"; readonly from: number; readonly to: number; } /** Same shape as `linear`, raised to `exponent` (default `2`), so appeal stays flat until it matters. */ | { readonly kind: "quadratic"; readonly from: number; readonly to: number; readonly exponent?: number; } /** Smooth S-curve through `0.5` at `midpoint`; `steepness` (default `10`) is how sharp the knee is. */ | { readonly kind: "logistic"; readonly midpoint: number; readonly steepness?: number; } /** `exp(-rate·x)` normalized to reach exactly 1 at empty and 0 at full; sharper than `linear` near empty. */ | { readonly kind: "exponential"; readonly rate: number; } /** Step: `value` (default `1`) while satisfaction is at or below `at`, else `0`. */ | { readonly kind: "threshold"; readonly at: number; readonly value?: number; }; /** Sample a response curve at a `0..1` satisfaction. Deterministic, allocation-free. */ export declare function sampleResponseCurve(curve: ResponseCurve, satisfaction: number): number; /** One need's contribution to an option's appeal. */ export interface UtilityTerm { /** Declared need id on the brain's need set; resolved once at construction. */ readonly need: string; readonly curve: ResponseCurve; /** Multiplier on the sampled appeal (negative pushes the option away). Default `1`. */ readonly weight?: number; /** Sample `1 - satisfaction` — an option that wants a *full* meter rather than an empty one. */ readonly invert?: boolean; } /** A thing an agent can decide to do, scored against its needs. */ export interface UtilityOption { readonly id: string; /** Appeal regardless of needs — the option's standing preference. Default `0`. */ readonly base?: number; readonly terms?: readonly UtilityTerm[]; } /** One reachable instance of an option this tick; `cost` is the caller's own effort number. */ export interface UtilityCandidate { /** Option id. Unknown ids are skipped, so content can be removed at runtime. */ readonly option: string; /** * Caller-computed effort — travel distance, queue seconds, price, risk. Scored as * `-costWeight · cost`, so the game keeps ownership of what a meter means. Default `0`. */ readonly cost?: number; } /** A need level that overrides the sticky pick the moment it is crossed. */ export interface UtilityInterrupt { readonly need: string; /** Fraction that fires the interrupt. */ readonly at: number; /** Fire while the need is `below` (default, a starving agent) or `above` (panic, overheat). */ readonly when?: "below" | "above"; /** Force this option when it is among the candidates; omit to just re-decide without stickiness. */ readonly option?: string; } /** Why the brain's pick changed on the tick it changed. */ export type UtilitySwitchReason = "initial" | "score" | "interrupt" | "unavailable"; /** The outcome of one {@link UtilityBrain.decide}. Valid until the next `decide` call. */ export interface UtilityDecision { readonly option: string | null; /** Costed score of the chosen candidate. */ readonly score: number; /** The pick changed on this tick. */ readonly changed: boolean; readonly reason: UtilitySwitchReason | null; /** An interrupt fired this tick, whether or not it changed the pick. */ readonly interrupted: boolean; } /** In-play dials that stay reachable through {@link UtilityBrain.retune}. */ export interface UtilityTunables { /** Score margin a rival must beat the held pick by. Default `0.05`; `0` switches on any lead. */ readonly switchMargin?: number; /** Seconds a pick is held before a non-interrupt rival may replace it. Default `0`. */ readonly minHoldSeconds?: number; /** Score points per candidate cost unit. Default `1`. */ readonly costWeight?: number; /** Symmetric score noise amplitude from the injected `rng`; default `0` (ties keep the held pick). */ readonly jitter?: number; } /** Everything a utility brain needs: the shared need set, its scored options, and selection policy. */ export interface UtilityBrainConfig extends UtilityTunables { readonly needs: NeedSet; readonly options: readonly UtilityOption[]; readonly interrupts?: readonly UtilityInterrupt[]; /** `[0,1)` source for `jitter`; pass `ctx.rng`. Only read when `jitter > 0`. */ readonly rng?: () => number; /** Register a devtools probe under this name so the perf overlay shows the live pick. */ readonly devtoolsName?: string; } /** One option's need-driven score, ignoring candidate cost. */ export interface UtilityScore { readonly option: string; readonly score: number; } /** JSON-safe view of a brain for the devtools overlay and bug reports. */ export interface UtilityDebugSnapshot { readonly chosen: string | null; readonly score: number; readonly reason: UtilitySwitchReason | null; readonly switchMargin: number; readonly costWeight: number; readonly holdSeconds: number; readonly needs: readonly { id: string; value: number; fraction: number; }[]; readonly options: readonly UtilityScore[]; } /** Serializable brain state: the held pick plus any in-play retuning. Needs save separately. */ export interface UtilityBrainSnapshot { option: string | null; score: number; holdSeconds: number; switchMargin: number; minHoldSeconds: number; costWeight: number; jitter: number; } /** * Utility-scored action selection over decaying needs: score every option's appeal through response * curves, pick the best reachable candidate minus its cost, and hold that pick until a rival clears * the hysteresis margin — unless a declared interrupt fires. * * The tick cost is bounded by declared content: `advance` is O(needs), `decide` is * O(options × terms + candidates + interrupts), with no allocation on either path. */ export interface UtilityBrain { /** Decay the injected needs by `dt` seconds and age the hold timer. */ advance(dt: number): void; /** * Pick among `candidates` for this tick. The returned object is reused, so read it before the * next call. `cost` defaults weight candidates down linearly (travel, queues, price). */ decide(candidates: readonly UtilityCandidate[]): UtilityDecision; chosen(): string | null; /** Need-driven score of one option right now, cost excluded. Throws on an unknown id. */ scoreOf(optionId: string): number; /** Every option's need-driven score in declared order. Allocates — debug and tests. */ scores(): readonly UtilityScore[]; retune(patch: UtilityTunables): void; debugSnapshot(): UtilityDebugSnapshot; snapshot(): UtilityBrainSnapshot; restore(next: UtilityBrainSnapshot): void; /** Drop the devtools probe registered by `devtoolsName`. Safe to call twice. */ dispose(): void; } /** * Create a utility brain over an existing {@link NeedSet}. The need set is injected, not owned, so * the game shows the same hunger the brain scores and persists one copy. * * @capability utility-brain pick actions by decaying-need appeal with response curves, hysteresis and interrupts */ export declare function createUtilityBrain(config: UtilityBrainConfig): UtilityBrain;