@jgengine/core 0.24.0 → 0.26.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 (98) hide show
  1. package/CHANGELOG.md +92 -0
  2. package/dist/ai/rvo.d.ts +106 -0
  3. package/dist/ai/rvo.js +581 -0
  4. package/dist/ai/utilityBrain.d.ts +173 -0
  5. package/dist/ai/utilityBrain.js +277 -0
  6. package/dist/combat/cover.d.ts +169 -0
  7. package/dist/combat/cover.js +264 -0
  8. package/dist/combat/damageRuntime.d.ts +150 -0
  9. package/dist/combat/damageRuntime.js +208 -0
  10. package/dist/combat/damageTable.d.ts +116 -0
  11. package/dist/combat/damageTable.js +165 -0
  12. package/dist/combat/hitZones.d.ts +70 -0
  13. package/dist/combat/hitZones.js +77 -0
  14. package/dist/combat/magazine.d.ts +8 -0
  15. package/dist/combat/magazine.js +20 -2
  16. package/dist/combat/squadCombat.d.ts +167 -0
  17. package/dist/combat/squadCombat.js +557 -0
  18. package/dist/combat/squadTactics.d.ts +73 -0
  19. package/dist/combat/squadTactics.js +49 -0
  20. package/dist/combat/statusRuntime.d.ts +142 -0
  21. package/dist/combat/statusRuntime.js +236 -0
  22. package/dist/combat/weaponDefinition.d.ts +106 -0
  23. package/dist/combat/weaponDefinition.js +108 -0
  24. package/dist/combat/weaponSystem.d.ts +189 -0
  25. package/dist/combat/weaponSystem.js +469 -0
  26. package/dist/combat.d.ts +9 -0
  27. package/dist/combat.js +9 -0
  28. package/dist/devtools/devtools.d.ts +4 -2
  29. package/dist/devtools/devtools.js +2 -1
  30. package/dist/devtools/discover.d.ts +2 -1
  31. package/dist/devtools/discover.js +38 -0
  32. package/dist/devtools/frame.d.ts +0 -2
  33. package/dist/devtools/frame.js +1 -2
  34. package/dist/devtools/logBus.d.ts +58 -0
  35. package/dist/devtools/logBus.js +79 -0
  36. package/dist/devtools/logs.d.ts +6 -3
  37. package/dist/devtools/logs.js +17 -13
  38. package/dist/devtools/report.d.ts +120 -0
  39. package/dist/devtools/report.js +49 -0
  40. package/dist/devtools/reportEndpoint.d.ts +17 -0
  41. package/dist/devtools/reportEndpoint.js +7 -0
  42. package/dist/devtools/types.d.ts +48 -11
  43. package/dist/editor/document.js +210 -0
  44. package/dist/editor/environment.d.ts +48 -1
  45. package/dist/editor/environment.js +161 -0
  46. package/dist/editor/index.d.ts +6 -2
  47. package/dist/editor/index.js +1 -1
  48. package/dist/editor/types.d.ts +54 -0
  49. package/dist/game/notices.d.ts +4 -0
  50. package/dist/game/notices.js +4 -3
  51. package/dist/game/playableGame.d.ts +9 -1
  52. package/dist/gameplay.d.ts +4 -0
  53. package/dist/gameplay.js +4 -0
  54. package/dist/meta/changelog.d.ts +1 -1
  55. package/dist/meta/changelog.js +78 -1
  56. package/dist/nav/navCrowd.d.ts +138 -0
  57. package/dist/nav/navCrowd.js +412 -0
  58. package/dist/nav/navRuntime.d.ts +108 -0
  59. package/dist/nav/navRuntime.js +256 -0
  60. package/dist/render/colorPipeline.d.ts +53 -0
  61. package/dist/render/colorPipeline.js +79 -0
  62. package/dist/render/lightProbes.d.ts +97 -0
  63. package/dist/render/lightProbes.js +221 -0
  64. package/dist/render/lookPreset.js +4 -4
  65. package/dist/render/physicalSky.d.ts +39 -0
  66. package/dist/render/physicalSky.js +89 -0
  67. package/dist/render/postProcessing.d.ts +40 -8
  68. package/dist/render/postProcessing.js +1 -1
  69. package/dist/render/skyOwnedLighting.d.ts +31 -0
  70. package/dist/render/skyOwnedLighting.js +46 -0
  71. package/dist/sensor/perception.d.ts +66 -5
  72. package/dist/sensor/perception.js +167 -42
  73. package/dist/sim/nearAgents.d.ts +68 -0
  74. package/dist/sim/nearAgents.js +152 -0
  75. package/dist/sim/needs.d.ts +60 -0
  76. package/dist/sim/needs.js +108 -0
  77. package/dist/sim/offScreen.d.ts +181 -0
  78. package/dist/sim/offScreen.js +450 -0
  79. package/dist/sim/routine.d.ts +79 -0
  80. package/dist/sim/routine.js +137 -0
  81. package/dist/visibility/visibilitySystem.js +21 -0
  82. package/dist/world/features.d.ts +8 -0
  83. package/dist/world.d.ts +5 -0
  84. package/dist/world.js +5 -0
  85. package/package.json +1 -1
  86. package/skills/jgengine/api.md +59 -6
  87. package/skills/jgengine/capabilities.md +4 -0
  88. package/skills/jgengine-combat/SKILL.md +17 -0
  89. package/skills/jgengine-combat/api.md +246 -0
  90. package/skills/jgengine-combat/capabilities.md +112 -0
  91. package/skills/jgengine-combat/reference.md +32 -0
  92. package/skills/jgengine-gameplay/SKILL.md +8 -0
  93. package/skills/jgengine-gameplay/api.md +71 -1
  94. package/skills/jgengine-gameplay/capabilities.md +16 -0
  95. package/skills/jgengine-world/SKILL.md +3 -1
  96. package/skills/jgengine-world/api.md +107 -3
  97. package/skills/jgengine-world/capabilities.md +22 -2
  98. package/skills/jgengine-world/recipes/npc-senses.md +8 -2
@@ -0,0 +1,450 @@
1
+ import { devtools } from "../devtools/devtools.js";
2
+ import { decayMeters, initDecayMeters } from "../survival/decayMeter.js";
3
+ const DEFAULT_SWITCH_BUDGET = 32;
4
+ function distanceBetween(a, b) {
5
+ const dx = a[0] - b[0];
6
+ const dy = a[1] - b[1];
7
+ const dz = a[2] - b[2];
8
+ return Math.sqrt(dx * dx + dy * dy + dz * dz);
9
+ }
10
+ function lerp(a, b, t) {
11
+ return [a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t, a[2] + (b[2] - a[2]) * t];
12
+ }
13
+ /**
14
+ * Create the world that decides which agents deserve a simulation. Residents are cheap state until
15
+ * they come near, and cheap again when they leave; the cheap state is the schedule, the needs and
16
+ * the last place, so a returning resident never pops in at the origin.
17
+ *
18
+ * @capability offscreen-world schedules agents near the player as real state and far ones as cheap state
19
+ */
20
+ export function createOffScreenWorld(options) {
21
+ const places = options.places;
22
+ const defs = options.needs.map((definition) => ({ ...definition }));
23
+ const near = options.near;
24
+ const residents = [];
25
+ const residentOf = new Map();
26
+ let nearRadius = Math.max(0, options.policy.nearRadius);
27
+ let farRadius = Math.max(nearRadius, options.policy.farRadius ?? nearRadius);
28
+ let costModel = options.policy.costModel;
29
+ let nearBudget = options.policy.nearBudget ?? null;
30
+ let switchBudget = Math.max(1, Math.floor(options.policy.switchBudget ?? DEFAULT_SWITCH_BUDGET));
31
+ let promotions = 0;
32
+ let demotions = 0;
33
+ let farRefreshes = 0;
34
+ function placeAt(place, id) {
35
+ const anchor = places[place];
36
+ if (anchor === undefined)
37
+ throw new Error(`resident "${id}" follows a routine through unknown place "${place}"`);
38
+ return anchor;
39
+ }
40
+ function goalFor(resident) {
41
+ if (resident.routine === null)
42
+ return resident.home;
43
+ const phase = resident.routine.phaseAt(resident.updatedAt);
44
+ const target = phase.nextPlace ?? phase.place;
45
+ return placeAt(target, resident.id);
46
+ }
47
+ function positionFor(resident, phase) {
48
+ if (phase === null || resident.routine === null)
49
+ return resident.home;
50
+ const from = placeAt(phase.place, resident.id);
51
+ if (!phase.travelling || phase.nextPlace === null)
52
+ return from;
53
+ return lerp(from, placeAt(phase.nextPlace, resident.id), phase.progress);
54
+ }
55
+ /** Advance a far resident's cheap state through every routine boundary up to `now`. */
56
+ function refreshCheap(resident, now) {
57
+ if (now <= resident.updatedAt)
58
+ return;
59
+ farRefreshes += 1;
60
+ if (resident.routine === null) {
61
+ resident.needs = decayMeters(resident.needs, defs, now - resident.updatedAt);
62
+ resident.updatedAt = now;
63
+ resident.staleAt = Infinity;
64
+ return;
65
+ }
66
+ while (resident.updatedAt < now) {
67
+ const boundary = Math.min(now, resident.staleAt);
68
+ resident.needs = decayMeters(resident.needs, defs, boundary - resident.updatedAt);
69
+ resident.updatedAt = boundary;
70
+ if (boundary >= now)
71
+ break;
72
+ const phase = resident.routine.phaseAt(boundary);
73
+ const arriving = !phase.travelling && Math.abs(phase.since - boundary) < 1e-6;
74
+ resident.stop = phase.stop;
75
+ resident.travelling = phase.travelling;
76
+ resident.place = phase.place;
77
+ resident.activity = phase.activity;
78
+ resident.position = positionFor(resident, phase);
79
+ if (arriving) {
80
+ const stop = resident.routine.stops[phase.stop];
81
+ if (stop?.refill != null) {
82
+ resident.needs = { ...resident.needs };
83
+ for (const needId of Object.keys(stop.refill)) {
84
+ const def = defs.find((candidate) => candidate.id === needId);
85
+ if (def === undefined)
86
+ continue;
87
+ const min = def.min ?? 0;
88
+ resident.needs[needId] = Math.min(def.max, Math.max(min, (resident.needs[needId] ?? min) + stop.refill[needId]));
89
+ }
90
+ }
91
+ }
92
+ resident.staleAt = resident.routine.nextChangeAfter(resident.updatedAt);
93
+ }
94
+ }
95
+ /** Keep a near resident's routine phase current and walk it to the next place when the clock moves it. */
96
+ function refreshNear(resident, now) {
97
+ if (resident.routine === null || now < resident.staleAt)
98
+ return;
99
+ const phase = resident.routine.phaseAt(now);
100
+ const moved = phase.place !== resident.place || phase.nextPlace !== resident.nextPlace;
101
+ resident.stop = phase.stop;
102
+ resident.travelling = phase.travelling;
103
+ resident.place = phase.place;
104
+ resident.activity = phase.activity;
105
+ resident.nextPlace = phase.nextPlace;
106
+ resident.updatedAt = now;
107
+ resident.staleAt = resident.routine.nextChangeAfter(now);
108
+ if (moved)
109
+ near.setGoal(resident.id, placeAt(phase.nextPlace ?? phase.place, resident.id));
110
+ }
111
+ function promote(resident, now) {
112
+ resolveFar(resident, now);
113
+ const phase = resident.routine === null ? null : resident.routine.phaseAt(now);
114
+ if (phase !== null) {
115
+ resident.stop = phase.stop;
116
+ resident.travelling = phase.travelling;
117
+ resident.place = phase.place;
118
+ resident.activity = phase.activity;
119
+ resident.nextPlace = phase.nextPlace;
120
+ }
121
+ resident.mode = "near";
122
+ promotions += 1;
123
+ near.spawn({
124
+ id: resident.id,
125
+ position: resident.position,
126
+ place: resident.place,
127
+ activity: resident.activity,
128
+ needs: { ...resident.needs },
129
+ goal: goalFor(resident),
130
+ stop: resident.stop,
131
+ travelling: resident.travelling,
132
+ });
133
+ }
134
+ function demote(resident, now) {
135
+ const handoff = near.despawn(resident.id);
136
+ resident.mode = "far";
137
+ demotions += 1;
138
+ if (handoff !== null) {
139
+ resident.position = handoff.position;
140
+ resident.needs = { ...handoff.needs };
141
+ }
142
+ resident.updatedAt = now;
143
+ resident.staleAt = resident.routine === null ? Infinity : resident.routine.nextChangeAfter(now);
144
+ }
145
+ function resolveFar(resident, now) {
146
+ refreshCheap(resident, now);
147
+ }
148
+ function probeLine() {
149
+ let nearCount = 0;
150
+ for (const resident of residents)
151
+ if (resident.mode === "near")
152
+ nearCount += 1;
153
+ const decisions = near.decisions?.() ?? 0;
154
+ return `${nearCount} near ${residents.length - nearCount} far ${promotions} promotions ${demotions} demotions ${farRefreshes} far refreshes ${decisions} decisions`;
155
+ }
156
+ const unregister = options.devtoolsName === undefined ? null : devtools.probes.register(options.devtoolsName, () => probeLine());
157
+ function toResident(spec) {
158
+ if (spec.routine !== null && spec.routine !== undefined) {
159
+ for (const stop of spec.routine.stops)
160
+ placeAt(stop.place, spec.id);
161
+ }
162
+ const needs = { ...initDecayMeters(defs) };
163
+ if (spec.needs !== undefined) {
164
+ for (const def of defs) {
165
+ const saved = spec.needs[def.id];
166
+ if (saved === undefined || !Number.isFinite(saved))
167
+ continue;
168
+ const min = def.min ?? 0;
169
+ needs[def.id] = Math.min(def.max, Math.max(min, saved));
170
+ }
171
+ }
172
+ const resident = {
173
+ id: spec.id,
174
+ home: spec.home,
175
+ routine: spec.routine ?? null,
176
+ mode: "far",
177
+ position: spec.home,
178
+ place: null,
179
+ activity: null,
180
+ nextPlace: null,
181
+ stop: 0,
182
+ travelling: false,
183
+ pinned: spec.pinned === true,
184
+ updatedAt: 0,
185
+ staleAt: 0,
186
+ needs,
187
+ distance: Infinity,
188
+ };
189
+ if (resident.routine !== null) {
190
+ const phase = resident.routine.phaseAt(0);
191
+ resident.stop = phase.stop;
192
+ resident.travelling = phase.travelling;
193
+ resident.place = phase.place;
194
+ resident.activity = phase.activity;
195
+ resident.nextPlace = phase.nextPlace;
196
+ resident.position = positionFor(resident, phase);
197
+ resident.staleAt = resident.routine.nextChangeAfter(0);
198
+ }
199
+ return resident;
200
+ }
201
+ for (const spec of options.residents ?? []) {
202
+ if (residentOf.has(spec.id))
203
+ throw new Error(`duplicate off-screen resident "${spec.id}"`);
204
+ const resident = toResident(spec);
205
+ residentOf.set(spec.id, residents.length);
206
+ residents.push(resident);
207
+ }
208
+ function indexOf(id) {
209
+ return residentOf.get(id) ?? -1;
210
+ }
211
+ function livePosition(resident) {
212
+ if (resident.mode === "near") {
213
+ const live = near.position(resident.id);
214
+ if (live !== null)
215
+ return live;
216
+ }
217
+ return resident.position;
218
+ }
219
+ return {
220
+ add(spec) {
221
+ if (residentOf.has(spec.id))
222
+ return false;
223
+ const resident = toResident(spec);
224
+ residentOf.set(spec.id, residents.length);
225
+ residents.push(resident);
226
+ return true;
227
+ },
228
+ remove(id) {
229
+ const at = indexOf(id);
230
+ if (at < 0)
231
+ return false;
232
+ if (residents[at].mode === "near")
233
+ near.despawn(id);
234
+ const last = residents.pop();
235
+ residentOf.delete(id);
236
+ if (at < residents.length) {
237
+ residents[at] = last;
238
+ residentOf.set(last.id, at);
239
+ }
240
+ return true;
241
+ },
242
+ has: (id) => residentOf.has(id),
243
+ count: () => residents.length,
244
+ ids: () => residents.map((resident) => resident.id),
245
+ agent(id) {
246
+ const at = indexOf(id);
247
+ if (at < 0)
248
+ return null;
249
+ const resident = residents[at];
250
+ const live = resident.mode === "near" ? near.needs?.(resident.id) ?? null : null;
251
+ return {
252
+ id: resident.id,
253
+ mode: resident.mode,
254
+ position: livePosition(resident),
255
+ place: resident.place,
256
+ activity: resident.activity,
257
+ stop: resident.stop,
258
+ travelling: resident.travelling,
259
+ needs: { ...(live ?? resident.needs) },
260
+ pinned: resident.pinned,
261
+ distance: resident.distance,
262
+ };
263
+ },
264
+ pin(id, pinned = true) {
265
+ const at = indexOf(id);
266
+ if (at < 0)
267
+ return false;
268
+ residents[at].pinned = pinned;
269
+ return true;
270
+ },
271
+ needsAt(id, now) {
272
+ const at = indexOf(id);
273
+ if (at < 0)
274
+ return null;
275
+ const resident = residents[at];
276
+ if (resident.mode === "far")
277
+ resolveFar(resident, now);
278
+ else {
279
+ const live = near.needs?.(id) ?? null;
280
+ if (live !== null)
281
+ return { ...live };
282
+ }
283
+ return { ...resident.needs };
284
+ },
285
+ tick(dt, now, viewer) {
286
+ let switches = switchBudget;
287
+ for (const resident of residents) {
288
+ if (resident.mode === "far" && now >= resident.staleAt)
289
+ refreshCheap(resident, now);
290
+ resident.distance = distanceBetween(livePosition(resident), viewer);
291
+ const wantsNear = resident.pinned || resident.distance <= (resident.mode === "near" ? farRadius : nearRadius);
292
+ if (wantsNear === (resident.mode === "near"))
293
+ continue;
294
+ if (switches <= 0)
295
+ continue;
296
+ switches -= 1;
297
+ if (wantsNear)
298
+ promote(resident, now);
299
+ else
300
+ demote(resident, now);
301
+ }
302
+ if (nearBudget !== null && costModel !== undefined) {
303
+ let total = 0;
304
+ for (const resident of residents)
305
+ if (resident.mode === "near")
306
+ total += costModel(resident.id, resident.distance);
307
+ while (total > nearBudget && switches > 0) {
308
+ let worst = null;
309
+ let worstCost = -Infinity;
310
+ for (const resident of residents) {
311
+ if (resident.mode !== "near" || resident.pinned)
312
+ continue;
313
+ const cost = costModel(resident.id, resident.distance);
314
+ if (cost > worstCost) {
315
+ worst = resident;
316
+ worstCost = cost;
317
+ }
318
+ }
319
+ if (worst === null)
320
+ break;
321
+ demote(worst, now);
322
+ total -= worstCost;
323
+ switches -= 1;
324
+ }
325
+ }
326
+ near.tick(dt, now);
327
+ for (const resident of residents) {
328
+ if (resident.mode !== "near")
329
+ continue;
330
+ const live = near.position(resident.id);
331
+ if (live !== null)
332
+ resident.position = live;
333
+ refreshNear(resident, now);
334
+ }
335
+ },
336
+ stats() {
337
+ let nearCount = 0;
338
+ let farCount = 0;
339
+ for (const resident of residents) {
340
+ if (resident.mode === "near")
341
+ nearCount += 1;
342
+ else
343
+ farCount += 1;
344
+ }
345
+ return {
346
+ agents: residents.length,
347
+ near: nearCount,
348
+ far: farCount,
349
+ promotions,
350
+ demotions,
351
+ farRefreshes,
352
+ decisions: near.decisions?.() ?? 0,
353
+ };
354
+ },
355
+ retune(patch) {
356
+ if (patch.nearRadius !== undefined)
357
+ nearRadius = Math.max(0, patch.nearRadius);
358
+ if (patch.farRadius !== undefined)
359
+ farRadius = Math.max(nearRadius, patch.farRadius);
360
+ farRadius = Math.max(nearRadius, farRadius);
361
+ if (patch.costModel !== undefined)
362
+ costModel = patch.costModel;
363
+ if (patch.nearBudget !== undefined)
364
+ nearBudget = patch.nearBudget;
365
+ if (patch.switchBudget !== undefined)
366
+ switchBudget = Math.max(1, Math.floor(patch.switchBudget));
367
+ },
368
+ snapshot() {
369
+ const copy = residents.map((resident) => {
370
+ const live = resident.mode === "near" ? near.needs?.(resident.id) ?? null : null;
371
+ const livePos = resident.mode === "near" ? near.position(resident.id) : null;
372
+ return {
373
+ id: resident.id,
374
+ home: [resident.home[0], resident.home[1], resident.home[2]],
375
+ mode: resident.mode,
376
+ position: livePos === null ? resident.position : livePos,
377
+ place: resident.place,
378
+ activity: resident.activity,
379
+ stop: resident.stop,
380
+ travelling: resident.travelling,
381
+ pinned: resident.pinned,
382
+ updatedAt: resident.updatedAt,
383
+ staleAt: resident.staleAt,
384
+ needs: { ...(live ?? resident.needs) },
385
+ };
386
+ });
387
+ return {
388
+ policy: { nearRadius, farRadius, nearBudget, switchBudget },
389
+ residents: copy,
390
+ };
391
+ },
392
+ restore(next) {
393
+ const authored = new Map();
394
+ for (const resident of residents)
395
+ authored.set(resident.id, { home: resident.home, routine: resident.routine });
396
+ for (const resident of residents) {
397
+ if (resident.mode === "near")
398
+ near.despawn(resident.id);
399
+ }
400
+ residents.length = 0;
401
+ residentOf.clear();
402
+ nearRadius = Math.max(0, next.policy.nearRadius);
403
+ farRadius = Math.max(nearRadius, next.policy.farRadius);
404
+ nearBudget = next.policy.nearBudget;
405
+ switchBudget = Math.max(1, Math.floor(next.policy.switchBudget));
406
+ for (const saved of next.residents) {
407
+ const source = authored.get(saved.id);
408
+ const resident = {
409
+ id: saved.id,
410
+ home: source?.home ?? saved.home,
411
+ routine: source?.routine ?? null,
412
+ mode: saved.mode,
413
+ position: saved.position,
414
+ place: saved.place,
415
+ activity: saved.activity,
416
+ nextPlace: null,
417
+ stop: saved.stop,
418
+ travelling: saved.travelling,
419
+ pinned: saved.pinned,
420
+ updatedAt: saved.updatedAt,
421
+ staleAt: saved.staleAt,
422
+ needs: { ...saved.needs },
423
+ distance: Infinity,
424
+ };
425
+ if (resident.routine !== null) {
426
+ const phase = resident.routine.phaseAt(resident.updatedAt);
427
+ resident.nextPlace = phase.nextPlace;
428
+ if (resident.mode === "near") {
429
+ near.spawn({
430
+ id: resident.id,
431
+ position: resident.position,
432
+ place: resident.place,
433
+ activity: resident.activity,
434
+ needs: { ...resident.needs },
435
+ goal: placeAt(phase.nextPlace ?? phase.place, resident.id),
436
+ stop: resident.stop,
437
+ travelling: resident.travelling,
438
+ });
439
+ }
440
+ }
441
+ residentOf.set(resident.id, residents.length);
442
+ residents.push(resident);
443
+ }
444
+ },
445
+ dispose() {
446
+ unregister?.();
447
+ near.dispose?.();
448
+ },
449
+ };
450
+ }
@@ -0,0 +1,79 @@
1
+ /** One authored stop in a day: where the agent belongs, and when it wants to get there. */
2
+ export interface RoutineStopSpec {
3
+ /** Opaque place id the agent occupies; the caller owns what a place means and where it stands. */
4
+ readonly place: string;
5
+ /** Authored arrival time in game-seconds into the cycle (0 .. `cycleSeconds`). */
6
+ readonly arriveAt: number;
7
+ /** Opaque activity label the stop is for ("work", "meal", "sleep"); never interpreted here. */
8
+ readonly activity?: string;
9
+ /** Seconds spent walking here from the previous stop; overrides `RoutineConfig.travelSeconds`. */
10
+ readonly travelSeconds?: number;
11
+ /** Need refills applied on arrival — a meal restores hunger while the agent stands there. */
12
+ readonly refill?: Readonly<Record<string, number>>;
13
+ }
14
+ /** Everything needed to resolve a routine: its authored stops, its cycle, and how far apart places are. */
15
+ export interface RoutineConfig {
16
+ readonly stops: readonly RoutineStopSpec[];
17
+ /** Cycle length in game-seconds; default one game day (`SECONDS_PER_GAME_DAY`). */
18
+ readonly cycleSeconds?: number;
19
+ /**
20
+ * Injected travel time between two places, used when a stop does not author `travelSeconds`.
21
+ * Default `0`, so a routine of places that are all the same spot needs no travel at all.
22
+ */
23
+ readonly travelSeconds?: (from: string, to: string) => number;
24
+ }
25
+ /** One stop after travel is accounted for: the authored time and the time the agent really gets there. */
26
+ export interface RoutineStop {
27
+ readonly index: number;
28
+ readonly place: string;
29
+ readonly activity: string | null;
30
+ /** The authored arrival time, in cycle seconds. */
31
+ readonly plannedArriveAt: number;
32
+ /** Real arrival — later than planned when the previous stop's walk ran into this one. */
33
+ readonly arriveAt: number;
34
+ /** When the agent must leave to reach the next stop on time; never before `arriveAt`. */
35
+ readonly departAt: number;
36
+ readonly refill: Readonly<Record<string, number>> | null;
37
+ }
38
+ /** Where a routine puts its agent at one instant. */
39
+ export interface RoutinePhase {
40
+ /** Index of the stop held here, or the stop being walked away from. */
41
+ readonly stop: number;
42
+ /** Place the agent is at, or the place it is walking from. */
43
+ readonly place: string;
44
+ /** Place it is walking to; `null` while holding. */
45
+ readonly nextPlace: string | null;
46
+ readonly activity: string | null;
47
+ readonly travelling: boolean;
48
+ /** 0 at departure to 1 on arrival; `0` while holding. */
49
+ readonly progress: number;
50
+ /** Absolute game-second this phase began. */
51
+ readonly since: number;
52
+ /** Absolute game-second the next phase begins. */
53
+ readonly until: number;
54
+ }
55
+ /**
56
+ * An authored day resolved against time: arrival times after walking, departures early enough to
57
+ * make the next stop, and a phase for any instant. Pure — there is no state to tick, so a far agent
58
+ * and a loaded save read the same day from the same clock.
59
+ */
60
+ export interface RoutineTimeline {
61
+ /** Seconds in one cycle. */
62
+ readonly cycleSeconds: number;
63
+ /** Stops in order, with real arrival and departure times. */
64
+ readonly stops: readonly RoutineStop[];
65
+ /** Where the routine puts the agent at absolute game-second `time`; wraps every cycle. */
66
+ phaseAt(time: number): RoutinePhase;
67
+ /** Absolute game-second of the next arrival or departure strictly after `time`. */
68
+ nextChangeAfter(time: number): number;
69
+ }
70
+ /**
71
+ * Resolve an authored routine into a timeline driven by the world clock.
72
+ *
73
+ * Travel is what makes arrival times real: each stop departs early enough to walk there on time,
74
+ * and when the previous stop ran long the arrival slips instead of the agent teleporting. A day
75
+ * whose walks cannot fit its cycle throws rather than silently putting the agent in two places.
76
+ *
77
+ * @capability routine-timeline authored daily routines (home, work, meal) resolved against the world clock
78
+ */
79
+ export declare function resolveRoutine(config: RoutineConfig): RoutineTimeline;
@@ -0,0 +1,137 @@
1
+ import { SECONDS_PER_GAME_DAY } from "../time/gameClock.js";
2
+ function wrap(value, cycle) {
3
+ return ((value % cycle) + cycle) % cycle;
4
+ }
5
+ /**
6
+ * Resolve an authored routine into a timeline driven by the world clock.
7
+ *
8
+ * Travel is what makes arrival times real: each stop departs early enough to walk there on time,
9
+ * and when the previous stop ran long the arrival slips instead of the agent teleporting. A day
10
+ * whose walks cannot fit its cycle throws rather than silently putting the agent in two places.
11
+ *
12
+ * @capability routine-timeline authored daily routines (home, work, meal) resolved against the world clock
13
+ */
14
+ export function resolveRoutine(config) {
15
+ if (config.stops.length === 0)
16
+ throw new Error("routine needs at least one stop");
17
+ const cycleSeconds = config.cycleSeconds !== undefined && config.cycleSeconds > 0 ? config.cycleSeconds : SECONDS_PER_GAME_DAY;
18
+ const specs = config.stops;
19
+ for (let i = 0; i < specs.length; i += 1) {
20
+ const stop = specs[i];
21
+ if (!Number.isFinite(stop.arriveAt) || stop.arriveAt < 0 || stop.arriveAt >= cycleSeconds) {
22
+ throw new Error(`routine stop ${i} ("${stop.place}") needs arriveAt in [0, ${cycleSeconds}), got ${stop.arriveAt}`);
23
+ }
24
+ if (i > 0 && stop.arriveAt <= specs[i - 1].arriveAt) {
25
+ throw new Error(`routine stops must be authored in ascending order; "${stop.place}" is not after "${specs[i - 1].place}"`);
26
+ }
27
+ }
28
+ const travelFrom = (from) => {
29
+ const index = from % specs.length;
30
+ const stop = specs[index];
31
+ if (stop.travelSeconds !== undefined)
32
+ return Math.max(0, stop.travelSeconds);
33
+ if (config.travelSeconds === undefined || specs.length < 2)
34
+ return 0;
35
+ const previous = specs[(index - 1 + specs.length) % specs.length];
36
+ return Math.max(0, config.travelSeconds(previous.place, stop.place));
37
+ };
38
+ const arrive = [];
39
+ for (let i = 0; i < specs.length; i += 1) {
40
+ const planned = specs[i].arriveAt;
41
+ arrive.push(i === 0 ? planned : Math.max(planned, arrive[i - 1] + travelFrom(i)));
42
+ }
43
+ const depart = [];
44
+ for (let i = 0; i < specs.length; i += 1) {
45
+ const nextArrive = i + 1 < specs.length ? arrive[i + 1] : arrive[0] + cycleSeconds;
46
+ const leave = nextArrive - travelFrom(i + 1 < specs.length ? i + 1 : 0);
47
+ if (leave < arrive[i] - 1e-9) {
48
+ let needed = 0;
49
+ for (let index = 0; index < specs.length; index += 1)
50
+ needed += travelFrom(index);
51
+ throw new Error(`routine does not fit its ${cycleSeconds} s cycle: it needs ${needed} s of walking and the stops leave too little room`);
52
+ }
53
+ depart.push(Math.max(arrive[i], leave));
54
+ }
55
+ const stops = specs.map((spec, index) => ({
56
+ index,
57
+ place: spec.place,
58
+ activity: spec.activity ?? null,
59
+ plannedArriveAt: spec.arriveAt,
60
+ arriveAt: arrive[index],
61
+ departAt: depart[index],
62
+ refill: spec.refill === undefined ? null : { ...spec.refill },
63
+ }));
64
+ function phaseAt(time) {
65
+ const cycle = Math.floor(time / cycleSeconds) * cycleSeconds;
66
+ const local = wrap(time, cycleSeconds);
67
+ if (stops.length === 1) {
68
+ const only = stops[0];
69
+ return {
70
+ stop: 0,
71
+ place: only.place,
72
+ nextPlace: null,
73
+ activity: only.activity,
74
+ travelling: false,
75
+ progress: 0,
76
+ since: cycle + only.arriveAt,
77
+ until: cycle + only.arriveAt + cycleSeconds,
78
+ };
79
+ }
80
+ for (const stop of stops) {
81
+ if (local >= stop.arriveAt && local < stop.departAt) {
82
+ return {
83
+ stop: stop.index,
84
+ place: stop.place,
85
+ nextPlace: null,
86
+ activity: stop.activity,
87
+ travelling: false,
88
+ progress: 0,
89
+ since: cycle + stop.arriveAt,
90
+ until: cycle + stop.departAt,
91
+ };
92
+ }
93
+ }
94
+ let from = stops.length - 1;
95
+ for (let i = 0; i < stops.length; i += 1) {
96
+ if (stops[i].departAt <= local)
97
+ from = i;
98
+ }
99
+ const next = (from + 1) % stops.length;
100
+ const departLocal = stops[from].departAt;
101
+ const arriveLocal = stops[next].arriveAt + (next === 0 ? cycleSeconds : 0);
102
+ const span = arriveLocal - departLocal;
103
+ const start = departLocal > local ? departLocal - cycleSeconds : departLocal;
104
+ const raw = span <= 0 ? 1 : (local - start) / span;
105
+ const progress = Number.isFinite(raw) ? Math.min(1, Math.max(0, raw)) : 1;
106
+ return {
107
+ stop: from,
108
+ place: stops[from].place,
109
+ nextPlace: stops[next].place,
110
+ activity: null,
111
+ travelling: true,
112
+ progress,
113
+ since: cycle + start,
114
+ until: cycle + start + span,
115
+ };
116
+ }
117
+ return {
118
+ cycleSeconds,
119
+ stops,
120
+ phaseAt,
121
+ nextChangeAfter(time) {
122
+ const cycle = Math.floor(time / cycleSeconds) * cycleSeconds;
123
+ const local = wrap(time, cycleSeconds);
124
+ let best = Infinity;
125
+ for (const stop of stops) {
126
+ for (const boundary of [stop.arriveAt, stop.departAt]) {
127
+ let candidate = boundary;
128
+ if (candidate <= local)
129
+ candidate += cycleSeconds;
130
+ if (candidate < best)
131
+ best = candidate;
132
+ }
133
+ }
134
+ return cycle + best;
135
+ },
136
+ };
137
+ }