@jgengine/core 0.28.0 → 0.30.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. package/CHANGELOG.md +31 -0
  2. package/dist/agents/agent.d.ts +134 -0
  3. package/dist/agents/agent.js +224 -0
  4. package/dist/agents/mood.d.ts +74 -0
  5. package/dist/agents/mood.js +97 -0
  6. package/dist/agents/relationships.d.ts +92 -0
  7. package/dist/agents/relationships.js +132 -0
  8. package/dist/agents/schedule.d.ts +58 -0
  9. package/dist/agents/schedule.js +72 -0
  10. package/dist/ai/director/boss.d.ts +63 -0
  11. package/dist/ai/director/boss.js +87 -0
  12. package/dist/ai/director/encounter.d.ts +146 -0
  13. package/dist/ai/director/encounter.js +195 -0
  14. package/dist/ai/director/pressure.d.ts +58 -0
  15. package/dist/ai/director/pressure.js +70 -0
  16. package/dist/ai/director/roles.d.ts +61 -0
  17. package/dist/ai/director/roles.js +118 -0
  18. package/dist/ai/director/sound.d.ts +79 -0
  19. package/dist/ai/director/sound.js +113 -0
  20. package/dist/game/playableGame.d.ts +2 -2
  21. package/dist/meta/changelog.d.ts +1 -1
  22. package/dist/meta/changelog.js +30 -1
  23. package/dist/mission/mission.d.ts +1 -1
  24. package/dist/mission/mission.js +4 -1
  25. package/dist/progression/careerBank.d.ts +63 -0
  26. package/dist/progression/careerBank.js +83 -0
  27. package/dist/progression/gates.d.ts +66 -0
  28. package/dist/progression/gates.js +88 -0
  29. package/dist/progression/standing.d.ts +77 -0
  30. package/dist/progression/standing.js +151 -0
  31. package/dist/progression/upgrades.d.ts +70 -0
  32. package/dist/progression/upgrades.js +128 -0
  33. package/dist/ui/accessibility.d.ts +8 -0
  34. package/dist/ui/accessibility.js +14 -0
  35. package/package.json +1 -1
  36. package/skills/jgengine/api.md +2 -2
  37. package/skills/jgengine-gameplay/SKILL.md +2 -0
  38. package/skills/jgengine-gameplay/api.md +89 -0
  39. package/skills/jgengine-gameplay/capabilities.md +34 -0
  40. package/skills/jgengine-gameplay/recipes/agent-lives.md +76 -0
  41. package/skills/jgengine-world/api.md +52 -0
  42. package/skills/jgengine-world/capabilities.md +32 -0
package/CHANGELOG.md CHANGED
@@ -26,6 +26,37 @@ At publish, rename this heading to the new version and mirror the entries into
26
26
  `packages/core/src/meta/changelog.ts` (the typed `CHANGELOG` export).
27
27
  -->
28
28
 
29
+ ## 0.30.0
30
+
31
+ ### Migrate
32
+
33
+ - **Bump lockstep SDK packages to `^0.30.0`:** `@jgengine/{core,rapier,react,ws,node,sql,convex,shell,editor,assets,navbake}`. CLI `jgengine` is `0.27.0`; `@jgengine/github` is `0.17.0`.
34
+
35
+ ### Added
36
+
37
+ - `ColorblindFrame` (`@jgengine/react/accessibility`) wraps the 3D canvas and applies the player's colour-vision mode to that box alone, so HUD, menu and caption text keep their own contrast. `colorblindFrameFilter` (`@jgengine/core/ui/accessibility`) resolves a mode to the CSS filter, and `useColorblindMode` reads the mode from the settings store the settings rows write; the 3D shell does both for the mounted game.
38
+ - `SettingsSection` (`@jgengine/react/settingsBlocks`) is one `useMenuNav` menu: a d-pad or left stick moves between rows and left/right steps the focused value — slider by its step, toggle flipped, select to the next option, confirm arms a keybind — through the same `onAdjust` path the arrow keys use. `onAdjust` (`@jgengine/react/menuNav`) is the option that makes left/right adjust instead of move.
39
+ - `createStanding` (`@jgengine/core/progression/standing`) holds per-faction or per-district standing over authored tracks and moves it from named outcomes, so an outcome that resolves to nothing throws at the call instead of silently moving nothing.
40
+ - `createGateSet` (`@jgengine/core/progression/gates`) evaluates authored milestones against standing and money, and refuses to load when a gate's unlock id resolves to nothing.
41
+ - `createCareerBank` (`@jgengine/core/progression/careerBank`) keeps persistent career money apart from a run's haul, so a failed run loses the haul and keeps the bank.
42
+ - `createUpgradeTree` (`@jgengine/core/progression/upgrades`) prices each rank from the shared curve primitive and gates purchases on prerequisites and standing.
43
+
44
+ ### Changed
45
+
46
+ - `AccessibilityProvider` (`@jgengine/react/accessibility`) sets its data attributes and text-scale var without filtering its own subtree. A colour matrix over the HUD and menu root recoloured the text the rest of the UI assumes, so wrap the game canvas in `ColorblindFrame` instead; a custom root can still offset it with its own `ColorblindFilters` and `filter: url(#jg-cb-<mode>)`.
47
+ - World health bars and nameplates derive their range from the render camera and the target height when a game sets no `maxDistance`: an overhead camera reaches the units it looks down at, and a bar is kept while the target it annotates still covers 18 px, instead of hiding every target past a fixed 60 units.
48
+ - A configured `worldHealthBars.maxDistance` or `nameplates.maxDistance` that hides every target in view now warns once with the nearest measured distance and the prop to raise, instead of rendering nothing.
49
+
50
+ ## 0.29.0
51
+
52
+ ### Migrate
53
+
54
+ - **Bump lockstep SDK packages to `^0.29.0`:** `@jgengine/{core,rapier,react,ws,node,sql,convex,shell,editor,assets,navbake}`. CLI `jgengine` is `0.26.0`; `@jgengine/github` is `0.16.0`.
55
+
56
+ ### Fixed
57
+
58
+ - `MissionView.remaining` (`@jgengine/core/gameplay`) now counts down from a `survive` objective's own `duration`, not only from a `timeout`. A defend or hold objective reports the seconds it still has to run, which is the one number its HUD needs; a completed run still reports null.
59
+
29
60
  ## 0.28.0
30
61
 
31
62
  ### Migrate
@@ -0,0 +1,134 @@
1
+ import { type UtilityBrainSnapshot, type UtilityInterrupt, type UtilityOption, type UtilitySwitchReason } from "../ai/utilityBrain.js";
2
+ import { type NeedDefinition, type NeedSet, type NeedSnapshot } from "../sim/needs.js";
3
+ import { type Mood, type MoodReader, type MoodSnapshot, type MoodTier, type MoodWeight } from "./mood.js";
4
+ import { type AgentSchedule, type AgentScheduleConfig, type AgentSchedulePhase } from "./schedule.js";
5
+ /** Where an agent goes when the thing it wanted is gone: a named option and a named place. */
6
+ export interface AgentFallback {
7
+ readonly option: string;
8
+ readonly target: string;
9
+ }
10
+ /** One reachable thing the agent could do this tick, as the game sees its own world. */
11
+ export interface AgentTarget {
12
+ /** Option id declared in the agent's `options`; unknown ids are skipped. */
13
+ readonly option: string;
14
+ /** Opaque place or object id the option would be done at. */
15
+ readonly target: string;
16
+ /** Effort the caller wants priced in: travel, queue seconds, price. */
17
+ readonly cost?: number;
18
+ /** What to do when this target stops existing, instead of re-deciding from scratch. */
19
+ readonly fallback?: AgentFallback;
20
+ }
21
+ /** What the agent is doing. */
22
+ export interface AgentAction {
23
+ readonly option: string | null;
24
+ readonly target: string | null;
25
+ readonly fallback: AgentFallback | null;
26
+ /** True while the action is the declared fallback rather than the agent's own pick. */
27
+ readonly usedFallback: boolean;
28
+ /** Game-second the action began. */
29
+ readonly since: number;
30
+ readonly score: number;
31
+ }
32
+ /** Why the action last changed. */
33
+ export type AgentActionReason = UtilitySwitchReason | "fallback" | "schedule" | "cleared";
34
+ /** One call per tick: the clock, and the targets the game's world currently offers. */
35
+ export interface AgentUpdate {
36
+ readonly dt: number;
37
+ /** Absolute game-second from the world clock; the schedule reads it. */
38
+ readonly now?: number;
39
+ readonly targets?: readonly AgentTarget[];
40
+ }
41
+ /** What one update produced: the action, the feeling, and where the day plan puts the agent. */
42
+ export interface AgentFrame {
43
+ readonly action: AgentAction;
44
+ readonly mood: Mood;
45
+ readonly phase: AgentSchedulePhase | null;
46
+ /** Place the work beat posts the agent to now; `null` with no schedule or while travelling. */
47
+ readonly post: string | null;
48
+ readonly changed: boolean;
49
+ readonly reason: AgentActionReason | null;
50
+ }
51
+ /** Everything an agent is made of. Policy is data; nothing about a game is compiled in. */
52
+ export interface AgentConfig {
53
+ readonly id: string;
54
+ /** Decaying needs, in the survival meter shape (`sim/needs`). Order breaks ties. */
55
+ readonly needs: readonly NeedDefinition[];
56
+ /** Scored actions, in the utility option shape (`ai/utilityBrain`). */
57
+ readonly options: readonly UtilityOption[];
58
+ readonly interrupts?: readonly UtilityInterrupt[];
59
+ /** The authored day plan, or omitted for an agent with no schedule. */
60
+ readonly schedule?: AgentScheduleConfig;
61
+ readonly mood?: {
62
+ readonly weights?: readonly MoodWeight[];
63
+ readonly tiers?: readonly MoodTier[];
64
+ };
65
+ readonly switchMargin?: number;
66
+ readonly minHoldSeconds?: number;
67
+ readonly costWeight?: number;
68
+ readonly jitter?: number;
69
+ /** `[0,1)` source for `jitter`; pass `ctx.rng` so runs replay. */
70
+ readonly rng?: () => number;
71
+ readonly devtoolsName?: string;
72
+ }
73
+ /** Serializable whole agent: needs, feeling, held action, day-plan cursor and the far flag. */
74
+ export interface AgentSnapshot {
75
+ needs: NeedSnapshot;
76
+ mood: MoodSnapshot;
77
+ brain: UtilityBrainSnapshot;
78
+ action: AgentAction;
79
+ /** True while the agent runs on its schedule alone, with no utility pass per tick. */
80
+ far: boolean;
81
+ /**
82
+ * Identity of the schedule stop the needs were last refilled at (`stop@arrival`). Saved so a
83
+ * loaded agent does not eat the same meal twice.
84
+ */
85
+ scheduleMark: string | null;
86
+ }
87
+ /**
88
+ * Needs, mood, schedule and a chosen action, in one object a game ticks.
89
+ *
90
+ * `update` is the loop: needs decay (`sim/needs`), the day plan says where the agent belongs
91
+ * (`sim/routine` through an agent schedule), the utility brain picks among the targets the game
92
+ * offers (`ai/utilityBrain`), and the mood reads how satisfied those needs are. A target that
93
+ * disappears falls back to the declared fallback rather than freezing the agent.
94
+ */
95
+ export interface Agent {
96
+ readonly id: string;
97
+ /** The agent's own need set - the game refills it after a meal, a ride or a night's sleep. */
98
+ readonly needs: NeedSet;
99
+ /** Current mood: weighted need satisfaction plus any source the game raised. */
100
+ mood(): Mood;
101
+ /** The game-raised mood sources (a queue outside, litter underfoot). */
102
+ readonly moodSources: MoodReader;
103
+ /** The resigned day plan, or `null` for an agent that only reacts to targets. */
104
+ readonly schedule: AgentSchedule | null;
105
+ /** The held action. */
106
+ action(): AgentAction;
107
+ /** Where the schedule puts the agent now; `null` without a schedule. */
108
+ phaseAt(now: number): AgentSchedulePhase | null;
109
+ /** Run one tick: decay, day plan, decision. Returns the same frame object each call. */
110
+ update(next: AgentUpdate): AgentFrame;
111
+ /**
112
+ * Put the agent off camera: it keeps decaying and follows its schedule, but takes no utility
113
+ * pass - the cheap state a far body should be. A far agent with no schedule holds its action.
114
+ */
115
+ setFar(far: boolean): void;
116
+ isFar(): boolean;
117
+ retune(patch: {
118
+ readonly switchMargin?: number;
119
+ readonly minHoldSeconds?: number;
120
+ readonly costWeight?: number;
121
+ readonly jitter?: number;
122
+ }): void;
123
+ snapshot(): AgentSnapshot;
124
+ restore(next: AgentSnapshot): void;
125
+ /** Drop the devtools probe registered by `devtoolsName`. Safe to call twice. */
126
+ dispose(): void;
127
+ }
128
+ /**
129
+ * Create one agent. The same factory serves a park guest, a household being, a patrolling janitor
130
+ * and a soldier: `options` is what it can choose between, `schedule` is where its day puts it.
131
+ *
132
+ * @capability agents a needs, thought, action and mood loop an agent runs on the world clock
133
+ */
134
+ export declare function createAgent(config: AgentConfig): Agent;
@@ -0,0 +1,224 @@
1
+ import { createUtilityBrain, } from "../ai/utilityBrain.js";
2
+ import { createNeedSet } from "../sim/needs.js";
3
+ import { createMoodReader, } from "./mood.js";
4
+ import { createAgentSchedule } from "./schedule.js";
5
+ const IDLE_ACTION = {
6
+ option: null,
7
+ target: null,
8
+ fallback: null,
9
+ usedFallback: false,
10
+ since: 0,
11
+ score: 0,
12
+ };
13
+ function findTarget(targets, option, target) {
14
+ for (const candidate of targets) {
15
+ if (candidate.option === option && candidate.target === target)
16
+ return candidate;
17
+ }
18
+ return null;
19
+ }
20
+ function firstOfOption(targets, option) {
21
+ if (option === null)
22
+ return null;
23
+ for (const candidate of targets)
24
+ if (candidate.option === option)
25
+ return candidate;
26
+ return null;
27
+ }
28
+ /**
29
+ * Create one agent. The same factory serves a park guest, a household being, a patrolling janitor
30
+ * and a soldier: `options` is what it can choose between, `schedule` is where its day puts it.
31
+ *
32
+ * @capability agents a needs, thought, action and mood loop an agent runs on the world clock
33
+ */
34
+ export function createAgent(config) {
35
+ const needs = createNeedSet(config.needs);
36
+ const brain = createUtilityBrain({
37
+ needs,
38
+ options: config.options,
39
+ ...(config.interrupts === undefined ? {} : { interrupts: config.interrupts }),
40
+ ...(config.switchMargin === undefined ? {} : { switchMargin: config.switchMargin }),
41
+ ...(config.minHoldSeconds === undefined ? {} : { minHoldSeconds: config.minHoldSeconds }),
42
+ ...(config.costWeight === undefined ? {} : { costWeight: config.costWeight }),
43
+ ...(config.jitter === undefined ? {} : { jitter: config.jitter }),
44
+ ...(config.rng === undefined ? {} : { rng: config.rng }),
45
+ ...(config.devtoolsName === undefined ? {} : { devtoolsName: config.devtoolsName }),
46
+ });
47
+ const moodReader = createMoodReader({
48
+ needs,
49
+ ...(config.mood?.weights === undefined ? {} : { weights: config.mood.weights }),
50
+ ...(config.mood?.tiers === undefined ? {} : { tiers: config.mood.tiers }),
51
+ });
52
+ const schedule = config.schedule === undefined ? null : createAgentSchedule(config.schedule);
53
+ let action = { ...IDLE_ACTION };
54
+ let far = false;
55
+ let scheduleMark = null;
56
+ let reason = null;
57
+ let changed = false;
58
+ const candidates = [];
59
+ let frame = {
60
+ action,
61
+ mood: moodReader.read(),
62
+ phase: null,
63
+ post: null,
64
+ changed: false,
65
+ reason: null,
66
+ };
67
+ function applySchedule(now) {
68
+ if (schedule === null)
69
+ return null;
70
+ const phase = schedule.phaseAt(now);
71
+ const mark = `${phase.stop}@${phase.since}`;
72
+ if (mark !== scheduleMark) {
73
+ scheduleMark = mark;
74
+ if (phase.refill !== null && !phase.travelling) {
75
+ for (const [id, amount] of Object.entries(phase.refill))
76
+ needs.refill(id, amount);
77
+ }
78
+ }
79
+ return phase;
80
+ }
81
+ function commit(option, target, score, since) {
82
+ if (option === null) {
83
+ action = { ...action, option: null, target: null, since };
84
+ return;
85
+ }
86
+ if (brain.chosen() !== option)
87
+ brain.decide([{ option, cost: 0 }]);
88
+ action = { ...action, option, target, since, score };
89
+ }
90
+ function think(targets, now) {
91
+ candidates.length = 0;
92
+ for (const target of targets) {
93
+ candidates.push(target.cost === undefined ? { option: target.option } : { option: target.option, cost: target.cost });
94
+ }
95
+ const decision = brain.decide(candidates);
96
+ const pickedOption = decision.option;
97
+ const pickedTarget = firstOfOption(targets, pickedOption);
98
+ const held = findTarget(targets, action.option, action.target);
99
+ const adopt = (target, nextReason, usedFallback) => {
100
+ const nextScore = brain.scoreOf(target.option);
101
+ const changedIdentity = target.option !== action.option || target.target !== action.target;
102
+ commit(target.option, target.target, nextScore, now);
103
+ action = {
104
+ ...action,
105
+ fallback: usedFallback ? null : target.fallback ?? null,
106
+ usedFallback,
107
+ };
108
+ changed = changedIdentity;
109
+ reason = changed ? nextReason : reason;
110
+ };
111
+ if (action.option !== null && held === null) {
112
+ const fallback = action.fallback;
113
+ const fallbackTarget = fallback === null ? null : findTarget(targets, fallback.option, fallback.target);
114
+ if (fallbackTarget !== null) {
115
+ adopt(fallbackTarget, "fallback", true);
116
+ return;
117
+ }
118
+ if (pickedTarget === null) {
119
+ action = { ...IDLE_ACTION, since: now };
120
+ changed = true;
121
+ reason = "cleared";
122
+ return;
123
+ }
124
+ adopt(pickedTarget, decision.reason ?? "unavailable", false);
125
+ return;
126
+ }
127
+ if (pickedTarget === null) {
128
+ if (action.option === null) {
129
+ changed = false;
130
+ reason = null;
131
+ return;
132
+ }
133
+ return;
134
+ }
135
+ if (decision.changed && pickedOption !== action.option) {
136
+ adopt(pickedTarget, decision.reason ?? "score", false);
137
+ return;
138
+ }
139
+ if (action.option === null) {
140
+ adopt(pickedTarget, decision.reason ?? "initial", false);
141
+ return;
142
+ }
143
+ changed = false;
144
+ }
145
+ return {
146
+ id: config.id,
147
+ needs,
148
+ mood: () => moodReader.read(),
149
+ moodSources: moodReader,
150
+ schedule,
151
+ action: () => action,
152
+ phaseAt: (now) => (schedule === null ? null : schedule.phaseAt(now)),
153
+ update(next) {
154
+ if (next.dt > 0)
155
+ brain.advance(next.dt);
156
+ const now = next.now ?? 0;
157
+ const phase = applySchedule(now);
158
+ changed = false;
159
+ reason = null;
160
+ if (far) {
161
+ if (phase !== null) {
162
+ const option = phase.travelling ? "travel" : phase.activity;
163
+ const target = phase.travelling ? phase.nextPlace : phase.post;
164
+ if (option !== action.option || target !== action.target) {
165
+ action = {
166
+ option,
167
+ target,
168
+ fallback: null,
169
+ usedFallback: false,
170
+ since: now,
171
+ score: 0,
172
+ };
173
+ changed = true;
174
+ reason = "schedule";
175
+ }
176
+ }
177
+ }
178
+ else {
179
+ think(next.targets ?? [], now);
180
+ }
181
+ frame = {
182
+ action,
183
+ mood: moodReader.read(),
184
+ phase,
185
+ post: phase?.post ?? null,
186
+ changed,
187
+ reason,
188
+ };
189
+ return frame;
190
+ },
191
+ setFar(next) {
192
+ if (far === next)
193
+ return;
194
+ far = next;
195
+ changed = true;
196
+ reason = "schedule";
197
+ },
198
+ isFar: () => far,
199
+ retune(patch) {
200
+ brain.retune(patch);
201
+ },
202
+ snapshot: () => ({
203
+ needs: needs.snapshot(),
204
+ mood: moodReader.snapshot(),
205
+ brain: brain.snapshot(),
206
+ action: { ...action },
207
+ far,
208
+ scheduleMark,
209
+ }),
210
+ restore(next) {
211
+ needs.restore(next.needs);
212
+ moodReader.restore(next.mood);
213
+ brain.restore(next.brain);
214
+ action = { ...next.action };
215
+ far = next.far;
216
+ scheduleMark = next.scheduleMark;
217
+ changed = false;
218
+ reason = null;
219
+ },
220
+ dispose() {
221
+ brain.dispose();
222
+ },
223
+ };
224
+ }
@@ -0,0 +1,74 @@
1
+ import type { NeedSet } from "../sim/needs.js";
2
+ /** One need's say in the mood score; weights are relative, so they need no fixed total. */
3
+ export interface MoodWeight {
4
+ /** Declared need id on the agent's need set; unknown ids are ignored. */
5
+ readonly need: string;
6
+ readonly weight: number;
7
+ }
8
+ /** One labelled band of the 0-100 mood score. */
9
+ export interface MoodTier {
10
+ readonly id: string;
11
+ readonly label: string;
12
+ /** Lowest score that reads as this tier. */
13
+ readonly at: number;
14
+ }
15
+ /** What the agent feels right now, on the caller's own tier labels. */
16
+ export interface Mood {
17
+ /** 0 (nothing satisfied) . 100 (everything satisfied), plus any live source. */
18
+ readonly score: number;
19
+ readonly tier: string;
20
+ readonly label: string;
21
+ }
22
+ /**
23
+ * Five bands a park guest, a household being or a soldier can share. A game overrides them when its
24
+ * own tone needs different words; the numeric bands are what HUD counters and tests read.
25
+ */
26
+ export declare const DEFAULT_MOOD_TIERS: readonly MoodTier[];
27
+ /** Serializable mood state: the game-raised biases that sit on top of need satisfaction. */
28
+ export interface MoodSnapshot {
29
+ sources: Record<string, number>;
30
+ }
31
+ /**
32
+ * The band a score reads as, from tiers sorted by `at` descending. Unknown or unsorted tiers fall
33
+ * back to the lowest declared band rather than throwing, because a HUD reads this every frame.
34
+ *
35
+ * @capability mood a mood derived from how satisfied an agent's needs are
36
+ */
37
+ export declare function moodTierOf(score: number, tiers?: readonly MoodTier[]): MoodTier;
38
+ /**
39
+ * A mood read from a need set: each declared need contributes its `0..1` satisfaction times its
40
+ * weight, normalized by the total weight, so an agent with four needs and one with six read on the
41
+ * same 0-100 scale. Pure and allocation-light - the per-frame read a HUD and a thought bubble share.
42
+ *
43
+ * @capability mood a mood derived from how satisfied an agent's needs are
44
+ */
45
+ export declare function readMood(needs: Pick<NeedSet, "ids" | "fraction">, weights: readonly MoodWeight[], tiers?: readonly MoodTier[], sources?: Readonly<Record<string, number>>): Mood;
46
+ /** A mood reader that keeps the game-raised biases, so a queue or a litter patch moves mood too. */
47
+ export interface MoodReader {
48
+ /** Current mood: need satisfaction plus every live source. */
49
+ read(): Mood;
50
+ /** Raise (or lower, with a negative) one named bias; calling again replaces that source. */
51
+ setSource(id: string, value: number): void;
52
+ /** Drop one bias - the queue emptied, the litter was cleaned. */
53
+ clearSource(id: string): void;
54
+ /** Every live bias, for a debug line or a panel. */
55
+ sources(): Readonly<Record<string, number>>;
56
+ /** Replace the weights and tiers in play, keeping the biases. */
57
+ retune(patch: {
58
+ readonly weights?: readonly MoodWeight[];
59
+ readonly tiers?: readonly MoodTier[];
60
+ }): void;
61
+ snapshot(): MoodSnapshot;
62
+ restore(next: MoodSnapshot): void;
63
+ }
64
+ /**
65
+ * Create the mood an agent carries. Needs are the agent's own (shared with its utility brain), and
66
+ * every bias is named data the game can remove again - there is no decaying hidden penalty.
67
+ *
68
+ * @capability mood a mood derived from how satisfied an agent's needs are
69
+ */
70
+ export declare function createMoodReader(config: {
71
+ readonly needs: Pick<NeedSet, "ids" | "fraction">;
72
+ readonly weights?: readonly MoodWeight[];
73
+ readonly tiers?: readonly MoodTier[];
74
+ }): MoodReader;
@@ -0,0 +1,97 @@
1
+ import { clamp } from "../math/scalar.js";
2
+ /**
3
+ * Five bands a park guest, a household being or a soldier can share. A game overrides them when its
4
+ * own tone needs different words; the numeric bands are what HUD counters and tests read.
5
+ */
6
+ export const DEFAULT_MOOD_TIERS = [
7
+ { at: 82, id: "radiant", label: "Radiant" },
8
+ { at: 64, id: "content", label: "Content" },
9
+ { at: 45, id: "fine", label: "Fine" },
10
+ { at: 26, id: "glum", label: "Glum" },
11
+ { at: 0, id: "low", label: "Low" },
12
+ ];
13
+ /**
14
+ * The band a score reads as, from tiers sorted by `at` descending. Unknown or unsorted tiers fall
15
+ * back to the lowest declared band rather than throwing, because a HUD reads this every frame.
16
+ *
17
+ * @capability mood a mood derived from how satisfied an agent's needs are
18
+ */
19
+ export function moodTierOf(score, tiers = DEFAULT_MOOD_TIERS) {
20
+ let best = null;
21
+ for (const tier of tiers)
22
+ if (score >= tier.at && (best === null || tier.at > best.at))
23
+ best = tier;
24
+ return best ?? tiers[tiers.length - 1] ?? { at: 0, id: "low", label: "Low" };
25
+ }
26
+ /**
27
+ * A mood read from a need set: each declared need contributes its `0..1` satisfaction times its
28
+ * weight, normalized by the total weight, so an agent with four needs and one with six read on the
29
+ * same 0-100 scale. Pure and allocation-light - the per-frame read a HUD and a thought bubble share.
30
+ *
31
+ * @capability mood a mood derived from how satisfied an agent's needs are
32
+ */
33
+ export function readMood(needs, weights, tiers = DEFAULT_MOOD_TIERS, sources) {
34
+ let total = 0;
35
+ let weighted = 0;
36
+ const declared = needs.ids();
37
+ for (const entry of weights) {
38
+ if (!(entry.weight > 0) || declared.indexOf(entry.need) < 0)
39
+ continue;
40
+ const fraction = needs.fraction(entry.need);
41
+ weighted += fraction * entry.weight;
42
+ total += entry.weight;
43
+ }
44
+ const base = total > 0 ? weighted / total : 0;
45
+ let bias = 0;
46
+ if (sources !== undefined)
47
+ for (const value of Object.values(sources))
48
+ bias += value;
49
+ const score = clamp(base * 100 + bias, 0, 100);
50
+ const tier = moodTierOf(score, tiers);
51
+ return { score, tier: tier.id, label: tier.label };
52
+ }
53
+ /**
54
+ * Create the mood an agent carries. Needs are the agent's own (shared with its utility brain), and
55
+ * every bias is named data the game can remove again - there is no decaying hidden penalty.
56
+ *
57
+ * @capability mood a mood derived from how satisfied an agent's needs are
58
+ */
59
+ export function createMoodReader(config) {
60
+ let weights = config.weights ??
61
+ config.needs.ids().map((need) => ({ need, weight: 1 }));
62
+ let tiers = config.tiers ?? DEFAULT_MOOD_TIERS;
63
+ let sources = {};
64
+ return {
65
+ read: () => readMood(config.needs, weights, tiers, sources),
66
+ setSource(id, value) {
67
+ if (!Number.isFinite(value) || value === 0) {
68
+ const { [id]: _dropped, ...rest } = sources;
69
+ sources = rest;
70
+ return;
71
+ }
72
+ sources = { ...sources, [id]: value };
73
+ },
74
+ clearSource(id) {
75
+ if (!(id in sources))
76
+ return;
77
+ const { [id]: _dropped, ...rest } = sources;
78
+ sources = rest;
79
+ },
80
+ sources: () => ({ ...sources }),
81
+ retune(patch) {
82
+ if (patch.weights !== undefined)
83
+ weights = patch.weights;
84
+ if (patch.tiers !== undefined)
85
+ tiers = patch.tiers;
86
+ },
87
+ snapshot: () => ({ sources: { ...sources } }),
88
+ restore(next) {
89
+ const restored = {};
90
+ for (const [id, value] of Object.entries(next.sources)) {
91
+ if (Number.isFinite(value) && value !== 0)
92
+ restored[id] = value;
93
+ }
94
+ sources = restored;
95
+ },
96
+ };
97
+ }
@@ -0,0 +1,92 @@
1
+ /** One labelled bond level on the opinion scale. */
2
+ export interface RelationshipMilestone {
3
+ readonly id: string;
4
+ readonly label: string;
5
+ /** Opinion at which the pair reaches this level. */
6
+ readonly at: number;
7
+ /** Familiarity the pair also needs; a bond wants time shared, not one good afternoon. */
8
+ readonly requiresFamiliarity?: number;
9
+ }
10
+ /** The pair levels every game starts from; a game overrides the words and the cut points. */
11
+ export declare const DEFAULT_RELATIONSHIP_MILESTONES: readonly RelationshipMilestone[];
12
+ /** How one pair stands: how much time they have shared, and what they think of each other. */
13
+ export interface Relationship {
14
+ /** 0 (strangers) . 1 (they know each other well). */
15
+ readonly familiarity: number;
16
+ /** The game's opinion scale, default -100 (rivals) . 100 (inseparable). */
17
+ readonly opinion: number;
18
+ }
19
+ /** Tunables a game owns, every one reachable through {@link RelationshipBook.retune}. */
20
+ export interface RelationshipPolicy {
21
+ readonly opinionMin?: number;
22
+ readonly opinionMax?: number;
23
+ readonly familiarityMax?: number;
24
+ readonly milestones?: readonly RelationshipMilestone[];
25
+ }
26
+ /** Opinion and familiarity one interaction adds; a game passes its own for a hug, a row or a shift. */
27
+ export interface SocialContact {
28
+ readonly familiarity?: number;
29
+ readonly opinion?: number;
30
+ }
31
+ /** What one social action did to a pair. */
32
+ export interface SocialOutcome {
33
+ readonly a: string;
34
+ readonly b: string;
35
+ readonly before: Relationship;
36
+ readonly after: Relationship;
37
+ /** Milestones newly crossed upward, lowest first - what a notice feed or a journal line reads. */
38
+ readonly crossed: readonly RelationshipMilestone[];
39
+ }
40
+ /** Serializable relationship state: every known pair plus the dials in play. */
41
+ export interface RelationshipBookSnapshot {
42
+ pairs: Record<string, Relationship>;
43
+ policy: RelationshipPolicy;
44
+ }
45
+ /** What a plain social action adds: a shared moment nudges both dials, never maxes either. */
46
+ export declare const DEFAULT_SOCIAL_CONTACT: Required<SocialContact>;
47
+ /**
48
+ * Everyone's view of everyone else: familiarity and opinion per pair, changed by social actions,
49
+ * with the pair levels that make a friendship a preference and a bond a refusal to split.
50
+ *
51
+ * One book serves a whole world (a park, a household, a squad). Pairs are undirected, so
52
+ * `opinion(a, b) === opinion(b, a)` and one interaction is enough for both sides.
53
+ */
54
+ export interface RelationshipBook {
55
+ /** How a pair stands; strangers read as `familiarity 0, opinion 0`. */
56
+ get(a: string, b: string): Relationship;
57
+ familiarity(a: string, b: string): number;
58
+ opinion(a: string, b: string): number;
59
+ /**
60
+ * Record one interaction between two agents. Defaults add a small amount of familiarity and
61
+ * opinion; a game passes a larger or negative contact for a row or a rescue. An agent's own id is
62
+ * ignored, so a wide query that includes the actor cannot self-bond.
63
+ */
64
+ socialise(a: string, b: string, contact?: SocialContact): SocialOutcome;
65
+ /** The highest milestone a pair has reached, or `null` for strangers. */
66
+ milestone(a: string, b: string): RelationshipMilestone | null;
67
+ /** Id of the highest milestone reached, or `null`. */
68
+ label(a: string, b: string): string | null;
69
+ friends(a: string, b: string): boolean;
70
+ /** A bonded pair. */
71
+ bonded(a: string, b: string): boolean;
72
+ /** The strongest bonded partner of `a`, or `null`. A tie keeps the first pair recorded. */
73
+ bondedWith(a: string): string | null;
74
+ /** Friends or closer - the pair prefers each other's company when both are free. */
75
+ prefersCompanyOf(a: string, b: string): boolean;
76
+ /** WHY: the rule that keeps a couple from being posted to opposite shifts is an opinion threshold, not a flag. */
77
+ refusesToSplit(a: string, b: string): boolean;
78
+ /** Every agent this one has shared anything with, sorted by id. Allocates - UI and tests. */
79
+ partnersOf(a: string): readonly string[];
80
+ policy(): Required<RelationshipPolicy>;
81
+ retune(patch: RelationshipPolicy): void;
82
+ snapshot(): RelationshipBookSnapshot;
83
+ restore(next: RelationshipBookSnapshot): void;
84
+ }
85
+ /**
86
+ * Create the shared book of pair state. Keys are undirected and delimiter-safe through
87
+ * `createPairKeyCodec`, and milestone crossings come from `crossThresholds`, so a notice feed and a
88
+ * journal both read the same event.
89
+ *
90
+ * @capability relationships familiarity and opinion between agents, with a bond that changes behaviour
91
+ */
92
+ export declare function createRelationshipBook(policy?: RelationshipPolicy): RelationshipBook;