@jgengine/core 0.26.0 → 0.28.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 (143) hide show
  1. package/CHANGELOG.md +174 -0
  2. package/dist/ai/policeResponse.d.ts +253 -0
  3. package/dist/ai/policeResponse.js +421 -0
  4. package/dist/anim/actionLayer.d.ts +47 -0
  5. package/dist/anim/actionLayer.js +63 -0
  6. package/dist/anim/ikSolver.d.ts +14 -0
  7. package/dist/anim/ikSolver.js +37 -0
  8. package/dist/anim/locomotionGraph.d.ts +58 -3
  9. package/dist/anim/locomotionGraph.js +142 -15
  10. package/dist/anim/ragdollRig.d.ts +53 -0
  11. package/dist/anim/ragdollRig.js +132 -0
  12. package/dist/anim/skeletonStandard.d.ts +140 -0
  13. package/dist/anim/skeletonStandard.js +326 -0
  14. package/dist/combat/damageRuntime.d.ts +14 -1
  15. package/dist/combat/damageRuntime.js +62 -4
  16. package/dist/combat/death.js +7 -3
  17. package/dist/combat/deathReason.d.ts +7 -1
  18. package/dist/combat/effects.d.ts +25 -0
  19. package/dist/combat/effects.js +16 -0
  20. package/dist/combat/statusRuntime.d.ts +2 -0
  21. package/dist/combat/statusRuntime.js +1 -0
  22. package/dist/economy/loanBook.d.ts +182 -0
  23. package/dist/economy/loanBook.js +303 -0
  24. package/dist/economy/market.d.ts +161 -0
  25. package/dist/economy/market.js +248 -0
  26. package/dist/economy/transactionLedger.d.ts +162 -0
  27. package/dist/economy/transactionLedger.js +301 -0
  28. package/dist/editor/creatorStorage.js +3 -1
  29. package/dist/editor/simulation.d.ts +5 -2
  30. package/dist/editor/simulation.js +29 -19
  31. package/dist/game/cameraConfig.d.ts +14 -0
  32. package/dist/game/clipRoles.d.ts +5 -4
  33. package/dist/game/clipRoles.js +34 -4
  34. package/dist/game/events.d.ts +27 -0
  35. package/dist/game/gameSave.d.ts +142 -0
  36. package/dist/game/gameSave.js +174 -0
  37. package/dist/game/playableGame.d.ts +74 -6
  38. package/dist/game/saveSlotIndex.d.ts +54 -0
  39. package/dist/game/saveSlotIndex.js +71 -0
  40. package/dist/game/saveSlots.d.ts +2 -0
  41. package/dist/game/saveSlots.js +1 -1
  42. package/dist/game/saveStore.d.ts +8 -0
  43. package/dist/game/saveStore.js +16 -0
  44. package/dist/gameplay.d.ts +17 -2
  45. package/dist/gameplay.js +15 -0
  46. package/dist/inventory/carryLoad.d.ts +48 -0
  47. package/dist/inventory/carryLoad.js +44 -0
  48. package/dist/inventory/equipment.d.ts +95 -0
  49. package/dist/inventory/equipment.js +169 -0
  50. package/dist/inventory/inventoryModel.d.ts +15 -6
  51. package/dist/inventory/inventoryModel.js +54 -5
  52. package/dist/inventory/shapedGrid.d.ts +26 -0
  53. package/dist/inventory/shapedGrid.js +47 -0
  54. package/dist/loot/lootGen.d.ts +186 -0
  55. package/dist/loot/lootGen.js +437 -0
  56. package/dist/loot/lootWeapon.d.ts +35 -0
  57. package/dist/loot/lootWeapon.js +70 -0
  58. package/dist/meta/changelog.d.ts +1 -1
  59. package/dist/meta/changelog.js +156 -1
  60. package/dist/mission/compositions.d.ts +85 -0
  61. package/dist/mission/compositions.js +130 -0
  62. package/dist/mission/mission.d.ts +435 -0
  63. package/dist/mission/mission.js +770 -0
  64. package/dist/mission/triggers.d.ts +61 -0
  65. package/dist/mission/triggers.js +108 -0
  66. package/dist/nav/laneGraph.d.ts +178 -0
  67. package/dist/nav/laneGraph.js +671 -0
  68. package/dist/nav/traffic.d.ts +222 -0
  69. package/dist/nav/traffic.js +918 -0
  70. package/dist/physics/kinematicVehicle.d.ts +36 -0
  71. package/dist/physics/kinematicVehicle.js +15 -1
  72. package/dist/physics/vehicleDamage.d.ts +136 -0
  73. package/dist/physics/vehicleDamage.js +214 -0
  74. package/dist/physics/vehicleParts.d.ts +62 -0
  75. package/dist/physics/vehicleParts.js +65 -0
  76. package/dist/progression/gearScore.d.ts +56 -0
  77. package/dist/progression/gearScore.js +59 -0
  78. package/dist/progression/skillTree.d.ts +129 -0
  79. package/dist/progression/skillTree.js +265 -0
  80. package/dist/progression/xpCurve.d.ts +60 -0
  81. package/dist/progression/xpCurve.js +141 -0
  82. package/dist/runtime/context/combat.js +11 -0
  83. package/dist/scene/builtinSceneKinds.js +2 -0
  84. package/dist/settings/accessibilitySettings.d.ts +81 -0
  85. package/dist/settings/accessibilitySettings.js +152 -0
  86. package/dist/settings/graphicsProfile.d.ts +3 -0
  87. package/dist/settings/graphicsProfile.js +4 -0
  88. package/dist/settings/settingsModel.d.ts +1 -1
  89. package/dist/settings/settingsModel.js +1 -0
  90. package/dist/sim/nearAgents.d.ts +8 -1
  91. package/dist/sim/nearAgents.js +34 -17
  92. package/dist/sim/offScreen.d.ts +2 -1
  93. package/dist/sim/offScreen.js +14 -3
  94. package/dist/sim/population.d.ts +147 -0
  95. package/dist/sim/population.js +182 -0
  96. package/dist/sim/queue.d.ts +90 -0
  97. package/dist/sim/queue.js +171 -0
  98. package/dist/sim/rides.d.ts +287 -0
  99. package/dist/sim/rides.js +471 -0
  100. package/dist/sim/routine.js +19 -11
  101. package/dist/ui/loadingTips.d.ts +41 -0
  102. package/dist/ui/loadingTips.js +64 -0
  103. package/dist/ui/menuNav.d.ts +79 -0
  104. package/dist/ui/menuNav.js +147 -0
  105. package/dist/ui/menuTransition.d.ts +50 -0
  106. package/dist/ui/menuTransition.js +67 -0
  107. package/dist/ui.d.ts +2 -0
  108. package/dist/ui.js +2 -0
  109. package/dist/vfx/effectLibrary.d.ts +98 -0
  110. package/dist/vfx/effectLibrary.js +181 -0
  111. package/dist/vfx/particleBudget.d.ts +40 -0
  112. package/dist/vfx/particleBudget.js +39 -0
  113. package/dist/vfx/particleDirector.d.ts +16 -0
  114. package/dist/vfx/particleDirector.js +18 -0
  115. package/dist/world/authoredSimulation.js +7 -1
  116. package/dist/world/captureCamera.d.ts +30 -0
  117. package/dist/world/captureCamera.js +19 -0
  118. package/dist/world/decalKind.d.ts +48 -0
  119. package/dist/world/decalKind.js +86 -0
  120. package/dist/world/features.d.ts +18 -5
  121. package/dist/world/features.js +1 -0
  122. package/dist/world/grassKind.d.ts +5 -0
  123. package/dist/world/grassKind.js +3 -0
  124. package/dist/world/terraform.d.ts +8 -0
  125. package/dist/world/terrain.d.ts +1 -0
  126. package/dist/world/terrain.js +2 -0
  127. package/dist/world.d.ts +7 -2
  128. package/dist/world.js +5 -0
  129. package/package.json +1 -1
  130. package/skills/jgengine/api.md +2 -2
  131. package/skills/jgengine-combat/SKILL.md +9 -1
  132. package/skills/jgengine-combat/api.md +5 -4
  133. package/skills/jgengine-combat/reference.md +4 -0
  134. package/skills/jgengine-gameplay/SKILL.md +86 -0
  135. package/skills/jgengine-gameplay/api.md +485 -16
  136. package/skills/jgengine-gameplay/capabilities.md +131 -4
  137. package/skills/jgengine-gameplay/reference-systems.md +118 -0
  138. package/skills/jgengine-world/SKILL.md +8 -0
  139. package/skills/jgengine-world/api.md +243 -11
  140. package/skills/jgengine-world/capabilities.md +61 -1
  141. package/skills/jgengine-world/recipes/character-animation.md +25 -2
  142. package/skills/jgengine-world/recipes/vehicle-feel.md +5 -0
  143. package/skills/jgengine-world/reference.md +1 -1
@@ -0,0 +1,471 @@
1
+ import { createUtilityBrain, sampleResponseCurve, } from "../ai/utilityBrain.js";
2
+ import { devtools } from "../devtools/devtools.js";
3
+ import { clamp } from "../math/scalar.js";
4
+ import { resolveRng } from "../random/resolveRng.js";
5
+ import { createWaitQueue } from "./queue.js";
6
+ /** A ride's breakdown curve a park starts from when it has none and retunes only part of one. */
7
+ export const DEFAULT_RIDE_BREAKDOWN = {
8
+ baseChance: 0,
9
+ wearChance: 0,
10
+ repairSeconds: 60,
11
+ repairCost: 0,
12
+ serviceCost: 0,
13
+ };
14
+ function cloneBreakdown(breakdown) {
15
+ return breakdown === undefined ? undefined : { ...breakdown };
16
+ }
17
+ function cloneDefinition(def) {
18
+ return {
19
+ id: def.id,
20
+ ratings: { ...def.ratings },
21
+ capacity: def.capacity,
22
+ loadSeconds: def.loadSeconds,
23
+ rideSeconds: def.rideSeconds,
24
+ unloadSeconds: def.unloadSeconds,
25
+ breakdown: cloneBreakdown(def.breakdown),
26
+ };
27
+ }
28
+ function validateDefinition(def) {
29
+ if (typeof def.id !== "string" || def.id === "")
30
+ throw new Error("ride needs a non-empty id");
31
+ if (!Number.isFinite(def.capacity) || def.capacity < 1) {
32
+ throw new Error(`ride "${def.id}" needs a capacity of at least 1, got ${def.capacity}`);
33
+ }
34
+ for (const field of ["loadSeconds", "rideSeconds", "unloadSeconds"]) {
35
+ if (!Number.isFinite(def[field]) || def[field] < 0) {
36
+ throw new Error(`ride "${def.id}" needs a non-negative ${field}, got ${def[field]}`);
37
+ }
38
+ }
39
+ for (const field of ["excitement", "intensity", "nausea"]) {
40
+ if (!Number.isFinite(def.ratings[field])) {
41
+ throw new Error(`ride "${def.id}" needs a finite ${field} rating, got ${def.ratings[field]}`);
42
+ }
43
+ }
44
+ }
45
+ /**
46
+ * Create the rides a park runs. Each ride cycles on its own data, boards from its own line up to its
47
+ * own capacity, wears against its own breakdown curve and reports its repairs through the sink, so
48
+ * the caller keeps the wallet, the bodies and the visuals.
49
+ *
50
+ * @capability ride-sim rides with capacity, cycles, queues, ratings, breakdowns and maintenance
51
+ */
52
+ export function createRideSystem(config) {
53
+ const rides = [];
54
+ const stateOf = new Map();
55
+ const defs = [];
56
+ const ids = [];
57
+ const seedServiceSeconds = config.queueSeedServiceSeconds;
58
+ for (const definition of config.rides) {
59
+ validateDefinition(definition);
60
+ if (stateOf.has(definition.id))
61
+ throw new Error(`duplicate ride id "${definition.id}"`);
62
+ const def = cloneDefinition(definition);
63
+ const state = {
64
+ def,
65
+ queue: createWaitQueue({ seedServiceSeconds }),
66
+ phase: "idle",
67
+ timer: 0,
68
+ riders: [],
69
+ cycles: 0,
70
+ wear: 0,
71
+ breakdowns: 0,
72
+ repairSpend: 0,
73
+ serviceSpend: 0,
74
+ boarded: 0,
75
+ };
76
+ rides.push(state);
77
+ defs.push(def);
78
+ ids.push(def.id);
79
+ stateOf.set(def.id, state);
80
+ }
81
+ let elapsed = 0;
82
+ let revision = 0;
83
+ let ticks = 0;
84
+ let slowestTickMs = 0;
85
+ let rng = config.rng;
86
+ function rollRng() {
87
+ if (rng === undefined)
88
+ rng = resolveRng(undefined, "createRideSystem");
89
+ return rng();
90
+ }
91
+ function probeLine() {
92
+ let broken = 0;
93
+ let queued = 0;
94
+ let boarded = 0;
95
+ for (const state of rides) {
96
+ if (state.phase === "broken")
97
+ broken += 1;
98
+ queued += state.queue.size();
99
+ boarded += state.boarded;
100
+ }
101
+ return `${rides.length} rides ${broken} broken ${queued} queued ${boarded} carried ${ticks} ticks ${slowestTickMs.toFixed(2)} ms slowest`;
102
+ }
103
+ const unregister = config.devtoolsName === undefined ? null : devtools.probes.register(config.devtoolsName, () => probeLine());
104
+ function board(state, sink) {
105
+ const capacity = Math.max(1, Math.floor(state.def.capacity));
106
+ let boarded = 0;
107
+ while (boarded < capacity) {
108
+ const guestId = state.queue.dequeue();
109
+ if (guestId === null)
110
+ break;
111
+ state.riders.push(guestId);
112
+ boarded += 1;
113
+ state.boarded += 1;
114
+ sink?.onBoard?.(state.def.id, guestId, elapsed);
115
+ }
116
+ if (boarded > 0)
117
+ state.queue.recordBoard(elapsed, boarded);
118
+ state.phase = "loading";
119
+ state.timer = Math.max(0, state.def.loadSeconds);
120
+ }
121
+ function finishCycle(state, sink) {
122
+ const id = state.def.id;
123
+ for (const guestId of state.riders)
124
+ sink?.onAlight?.(id, guestId, elapsed);
125
+ state.riders.length = 0;
126
+ state.cycles += 1;
127
+ state.wear += 1;
128
+ const breakdown = state.def.breakdown;
129
+ if (breakdown !== undefined) {
130
+ const chance = clamp(breakdown.baseChance + breakdown.wearChance * state.wear, 0, 1);
131
+ if (chance > 0 && rollRng() < chance) {
132
+ state.phase = "broken";
133
+ state.timer = Math.max(0, breakdown.repairSeconds);
134
+ state.breakdowns += 1;
135
+ sink?.onBreak?.(id, elapsed);
136
+ return;
137
+ }
138
+ }
139
+ state.phase = "idle";
140
+ state.timer = 0;
141
+ }
142
+ function tickRide(state, dt, sink) {
143
+ if (state.phase === "idle") {
144
+ if (state.queue.size() > 0)
145
+ board(state, sink);
146
+ return;
147
+ }
148
+ state.timer -= dt;
149
+ if (state.timer > 0)
150
+ return;
151
+ switch (state.phase) {
152
+ case "broken": {
153
+ const cost = state.def.breakdown?.repairCost ?? 0;
154
+ state.repairSpend += cost;
155
+ state.phase = "idle";
156
+ state.timer = 0;
157
+ sink?.onRepaired?.(state.def.id, cost, elapsed);
158
+ return;
159
+ }
160
+ case "loading":
161
+ state.phase = "riding";
162
+ state.timer = Math.max(0, state.def.rideSeconds);
163
+ return;
164
+ case "riding":
165
+ state.phase = "unloading";
166
+ state.timer = Math.max(0, state.def.unloadSeconds);
167
+ return;
168
+ case "unloading":
169
+ finishCycle(state, sink);
170
+ return;
171
+ }
172
+ }
173
+ function viewOf(state) {
174
+ return {
175
+ id: state.def.id,
176
+ ratings: state.def.ratings,
177
+ capacity: state.def.capacity,
178
+ phase: state.phase,
179
+ phaseSeconds: state.timer,
180
+ riders: state.riders.length,
181
+ queue: { size: state.queue.size(), wait: state.queue.wait() },
182
+ cycles: state.cycles,
183
+ wear: state.wear,
184
+ breakdowns: state.breakdowns,
185
+ spend: state.repairSpend + state.serviceSpend,
186
+ };
187
+ }
188
+ return {
189
+ now: () => elapsed,
190
+ revision: () => revision,
191
+ rideIds: () => ids,
192
+ definitions: () => defs,
193
+ ride(id) {
194
+ const state = stateOf.get(id);
195
+ return state === undefined ? null : viewOf(state);
196
+ },
197
+ rides: () => rides.map(viewOf),
198
+ queue(id) {
199
+ const state = stateOf.get(id);
200
+ return state === undefined ? null : state.queue;
201
+ },
202
+ join(id, guestId) {
203
+ const state = stateOf.get(id);
204
+ if (state === undefined)
205
+ return false;
206
+ return state.queue.enqueue(guestId, elapsed);
207
+ },
208
+ leave(id, guestId) {
209
+ const state = stateOf.get(id);
210
+ if (state === undefined)
211
+ return false;
212
+ return state.queue.remove(guestId);
213
+ },
214
+ waitSeconds(id) {
215
+ const state = stateOf.get(id);
216
+ return state === undefined ? 0 : state.queue.wait().seconds;
217
+ },
218
+ tick(dt, sink) {
219
+ if (!(dt > 0) || rides.length === 0)
220
+ return;
221
+ const started = performance.now();
222
+ elapsed += dt;
223
+ for (const state of rides)
224
+ tickRide(state, dt, sink);
225
+ ticks += 1;
226
+ slowestTickMs = Math.max(slowestTickMs, performance.now() - started);
227
+ },
228
+ service(id) {
229
+ const state = stateOf.get(id);
230
+ if (state === undefined)
231
+ return null;
232
+ const wear = state.wear;
233
+ const breakdown = state.def.breakdown;
234
+ if (breakdown === undefined || wear <= 0)
235
+ return { id, cost: 0, wear };
236
+ state.wear = 0;
237
+ state.serviceSpend += breakdown.serviceCost;
238
+ return { id, cost: breakdown.serviceCost, wear };
239
+ },
240
+ retune(id, patch) {
241
+ const state = stateOf.get(id);
242
+ if (state === undefined)
243
+ return false;
244
+ const def = state.def;
245
+ const index = rides.indexOf(state);
246
+ const breakdown = patch.breakdown === undefined
247
+ ? def.breakdown
248
+ : patch.breakdown === null
249
+ ? undefined
250
+ : { ...(def.breakdown ?? DEFAULT_RIDE_BREAKDOWN), ...patch.breakdown };
251
+ const next = {
252
+ id: def.id,
253
+ ratings: patch.ratings === undefined ? def.ratings : { ...def.ratings, ...patch.ratings },
254
+ capacity: patch.capacity === undefined ? def.capacity : Math.max(1, Math.floor(patch.capacity)),
255
+ loadSeconds: patch.loadSeconds === undefined ? def.loadSeconds : Math.max(0, patch.loadSeconds),
256
+ rideSeconds: patch.rideSeconds === undefined ? def.rideSeconds : Math.max(0, patch.rideSeconds),
257
+ unloadSeconds: patch.unloadSeconds === undefined ? def.unloadSeconds : Math.max(0, patch.unloadSeconds),
258
+ breakdown,
259
+ };
260
+ validateDefinition(next);
261
+ state.def = next;
262
+ defs[index] = next;
263
+ revision += 1;
264
+ return true;
265
+ },
266
+ stats() {
267
+ let broken = 0;
268
+ let riders = 0;
269
+ let queued = 0;
270
+ let boarded = 0;
271
+ let cycles = 0;
272
+ let breakdowns = 0;
273
+ let spend = 0;
274
+ for (const state of rides) {
275
+ if (state.phase === "broken")
276
+ broken += 1;
277
+ riders += state.riders.length;
278
+ queued += state.queue.size();
279
+ boarded += state.boarded;
280
+ cycles += state.cycles;
281
+ breakdowns += state.breakdowns;
282
+ spend += state.repairSpend + state.serviceSpend;
283
+ }
284
+ return { rides: rides.length, broken, riders, queued, boarded, cycles, breakdowns, spend, ticks, slowestTickMs };
285
+ },
286
+ snapshot() {
287
+ return {
288
+ elapsed,
289
+ rides: rides.map((state) => ({
290
+ definition: cloneDefinition(state.def),
291
+ phase: state.phase,
292
+ timer: state.timer,
293
+ riders: [...state.riders],
294
+ cycles: state.cycles,
295
+ wear: state.wear,
296
+ breakdowns: state.breakdowns,
297
+ repairSpend: state.repairSpend,
298
+ serviceSpend: state.serviceSpend,
299
+ boarded: state.boarded,
300
+ queue: state.queue.snapshot(),
301
+ })),
302
+ };
303
+ },
304
+ restore(next) {
305
+ rides.length = 0;
306
+ stateOf.clear();
307
+ defs.length = 0;
308
+ ids.length = 0;
309
+ for (const saved of next.rides) {
310
+ const def = cloneDefinition(saved.definition);
311
+ const state = {
312
+ def,
313
+ queue: createWaitQueue({ seedServiceSeconds }),
314
+ phase: saved.phase,
315
+ timer: saved.timer,
316
+ riders: [...saved.riders],
317
+ cycles: saved.cycles,
318
+ wear: saved.wear,
319
+ breakdowns: saved.breakdowns,
320
+ repairSpend: saved.repairSpend,
321
+ serviceSpend: saved.serviceSpend,
322
+ boarded: saved.boarded,
323
+ };
324
+ state.queue.restore(saved.queue);
325
+ rides.push(state);
326
+ defs.push(def);
327
+ ids.push(def.id);
328
+ stateOf.set(def.id, state);
329
+ }
330
+ elapsed = Number.isFinite(next.elapsed) ? next.elapsed : 0;
331
+ revision += 1;
332
+ },
333
+ dispose() {
334
+ unregister?.();
335
+ },
336
+ };
337
+ }
338
+ /** A 0–10 rating scale, the usual shape of a ride catalog. Override it when a park rates differently. */
339
+ export const DEFAULT_RIDE_RATING_RANGE = { excitementMax: 10, intensityMax: 10, nauseaMax: 10 };
340
+ /** How empty a bored guest's fun need has to be before a ride starts to pull, by default. */
341
+ export const DEFAULT_RIDE_FUN_CURVE = { kind: "linear", from: 0.15, to: 0.9 };
342
+ /**
343
+ * Appeal that rises with the rating: `0` at zero, `1` at `max`. Response curves sample a `0..1`
344
+ * satisfaction, so a rating is normalized against the park's own scale first.
345
+ */
346
+ function risesTo(rating, max) {
347
+ if (!(max > 0))
348
+ return 1;
349
+ return sampleResponseCurve({ kind: "linear", from: 1, to: 0 }, clamp(rating / max, 0, 1));
350
+ }
351
+ /** `1` while the rating stays inside the guest's comfort, falling to `0` at the park's maximum. */
352
+ function comfortFit(rating, comfort, max) {
353
+ if (!(max > comfort))
354
+ return 1;
355
+ return sampleResponseCurve({ kind: "linear", from: clamp(comfort / max, 0, 1), to: 1 }, clamp(rating / max, 0, 1));
356
+ }
357
+ /**
358
+ * One ride's standing appeal to one guest, before queue cost. Excitement draws, force and nausea
359
+ * push back — what a guest's own card or thought bubble can show.
360
+ *
361
+ * @capability ride-appeal how much one guest likes one ride, from its ratings and their taste
362
+ */
363
+ export function rideAppeal(ratings, taste, range = DEFAULT_RIDE_RATING_RANGE) {
364
+ return (risesTo(ratings.excitement, range.excitementMax) *
365
+ comfortFit(ratings.intensity, taste.intensityComfort, range.intensityMax) *
366
+ comfortFit(ratings.nausea, taste.nauseaComfort, range.nauseaMax));
367
+ }
368
+ /**
369
+ * Compile a guest's taste and the park's ride data into utility options, so the guest's pick comes
370
+ * from `createUtilityBrain` like every other decision it makes. Ride choice is one more set of
371
+ * options a guest can score, not a separate scoring function.
372
+ *
373
+ * @capability ride-choice guests pick rides by taste and queue tolerance through the utility brain
374
+ */
375
+ export function rideChoiceOptions(rides, taste, policy = {}) {
376
+ const range = policy.range ?? DEFAULT_RIDE_RATING_RANGE;
377
+ const terms = policy.funNeed === undefined
378
+ ? undefined
379
+ : [{ need: policy.funNeed, curve: policy.funCurve ?? DEFAULT_RIDE_FUN_CURVE, weight: policy.funWeight ?? 1 }];
380
+ return rides.map((ride) => ({ id: ride.id, base: rideAppeal(ride.ratings, taste, range), terms }));
381
+ }
382
+ /**
383
+ * Cost each ride by the wait its own line reports, so a guest weighs a ride by seconds in line
384
+ * rather than by a fixed order. `out` is reused for the array; the candidate records are not.
385
+ *
386
+ * @capability ride-choice guests pick rides by taste and queue tolerance through the utility brain
387
+ */
388
+ export function rideChoiceCandidates(source, out = []) {
389
+ out.length = 0;
390
+ for (const id of source.rideIds())
391
+ out.push({ option: id, cost: source.waitSeconds(id) });
392
+ return out;
393
+ }
394
+ /**
395
+ * Score points per second of queue wait: a guest's own tolerance is the denominator. A guest who
396
+ * merges rides into a brain that also scores food and exits uses this as its `costWeight`.
397
+ *
398
+ * @capability ride-choice guests pick rides by taste and queue tolerance through the utility brain
399
+ */
400
+ export function rideQueueCostWeight(taste) {
401
+ return 1 / Math.max(taste.queueTolerance, 1e-3);
402
+ }
403
+ function validateTaste(taste) {
404
+ for (const field of ["intensityComfort", "nauseaComfort", "queueTolerance"]) {
405
+ if (!Number.isFinite(taste[field]) || taste[field] < 0) {
406
+ throw new Error(`ride taste needs a non-negative ${field}, got ${taste[field]}`);
407
+ }
408
+ }
409
+ }
410
+ /**
411
+ * Create one guest's ride choice over a park. Options are rebuilt when the park is retuned, so a
412
+ * coaster whose track changes its ratings is re-scored without rebuilding the guest.
413
+ *
414
+ * @capability ride-choice guests pick rides by taste and queue tolerance through the utility brain
415
+ */
416
+ export function createRideChooser(config) {
417
+ const system = config.system;
418
+ let taste = { ...config.taste };
419
+ validateTaste(taste);
420
+ const candidates = [];
421
+ let brain = null;
422
+ let builtRevision = -1;
423
+ function build() {
424
+ const held = brain?.chosen() ?? null;
425
+ brain?.dispose();
426
+ const options = rideChoiceOptions(system.definitions(), taste, config.policy);
427
+ const next = createUtilityBrain({
428
+ needs: config.needs,
429
+ options,
430
+ costWeight: rideQueueCostWeight(taste),
431
+ switchMargin: config.switchMargin,
432
+ minHoldSeconds: config.minHoldSeconds,
433
+ rng: config.rng,
434
+ devtoolsName: config.devtoolsName,
435
+ });
436
+ brain = next;
437
+ builtRevision = system.revision();
438
+ if (held !== null && options.some((option) => option.id === held))
439
+ next.decide([{ option: held, cost: 0 }]);
440
+ return next;
441
+ }
442
+ function active() {
443
+ if (brain === null || system.revision() !== builtRevision)
444
+ return build();
445
+ return brain;
446
+ }
447
+ return {
448
+ choose() {
449
+ const pick = active().decide(rideChoiceCandidates(system, candidates));
450
+ return pick.option;
451
+ },
452
+ scoreOf: (rideId) => active().scoreOf(rideId),
453
+ advance(dt) {
454
+ active().advance(dt);
455
+ },
456
+ setTaste(next) {
457
+ validateTaste(next);
458
+ taste = { ...next };
459
+ build();
460
+ },
461
+ snapshot: () => ({ taste: { ...taste }, brain: (brain ?? build()).snapshot() }),
462
+ restore(next) {
463
+ taste = { ...next.taste };
464
+ build().restore(next.brain);
465
+ },
466
+ dispose() {
467
+ brain?.dispose();
468
+ brain = null;
469
+ },
470
+ };
471
+ }
@@ -1,6 +1,14 @@
1
1
  import { SECONDS_PER_GAME_DAY } from "../time/gameClock.js";
2
- function wrap(value, cycle) {
3
- return ((value % cycle) + cycle) % cycle;
2
+ /**
3
+ * Seconds into the cycle, plus how far float rounding can have moved them.
4
+ *
5
+ * `time` is a sum of whole cycles and a stop time, so it arrives with the low bits of the stop time
6
+ * already rounded away; comparing a boundary against it needs a tolerance or a boundary that *is*
7
+ * now reads as a change still to come, which would repeat forever.
8
+ */
9
+ function localAt(time, cycleSeconds) {
10
+ const cycle = Math.floor(time / cycleSeconds) * cycleSeconds;
11
+ return { cycle, local: time - cycle, tolerance: Math.max(1e-9, Math.abs(time) * Number.EPSILON * 8) };
4
12
  }
5
13
  /**
6
14
  * Resolve an authored routine into a timeline driven by the world clock.
@@ -62,8 +70,7 @@ export function resolveRoutine(config) {
62
70
  refill: spec.refill === undefined ? null : { ...spec.refill },
63
71
  }));
64
72
  function phaseAt(time) {
65
- const cycle = Math.floor(time / cycleSeconds) * cycleSeconds;
66
- const local = wrap(time, cycleSeconds);
73
+ const { cycle, local, tolerance } = localAt(time, cycleSeconds);
67
74
  if (stops.length === 1) {
68
75
  const only = stops[0];
69
76
  return {
@@ -78,7 +85,7 @@ export function resolveRoutine(config) {
78
85
  };
79
86
  }
80
87
  for (const stop of stops) {
81
- if (local >= stop.arriveAt && local < stop.departAt) {
88
+ if (local >= stop.arriveAt - tolerance && local < stop.departAt - tolerance) {
82
89
  return {
83
90
  stop: stop.index,
84
91
  place: stop.place,
@@ -93,7 +100,7 @@ export function resolveRoutine(config) {
93
100
  }
94
101
  let from = stops.length - 1;
95
102
  for (let i = 0; i < stops.length; i += 1) {
96
- if (stops[i].departAt <= local)
103
+ if (stops[i].departAt <= local + tolerance)
97
104
  from = i;
98
105
  }
99
106
  const next = (from + 1) % stops.length;
@@ -119,19 +126,20 @@ export function resolveRoutine(config) {
119
126
  stops,
120
127
  phaseAt,
121
128
  nextChangeAfter(time) {
122
- const cycle = Math.floor(time / cycleSeconds) * cycleSeconds;
123
- const local = wrap(time, cycleSeconds);
129
+ const { cycle, tolerance } = localAt(time, cycleSeconds);
124
130
  let best = Infinity;
125
131
  for (const stop of stops) {
126
132
  for (const boundary of [stop.arriveAt, stop.departAt]) {
127
- let candidate = boundary;
128
- if (candidate <= local)
133
+ // Absolute time, so a boundary that rounding has put a hair behind `time` cannot answer
134
+ // with `time` itself and stall every caller that steps by the answer.
135
+ let candidate = cycle + boundary;
136
+ if (candidate <= time + tolerance)
129
137
  candidate += cycleSeconds;
130
138
  if (candidate < best)
131
139
  best = candidate;
132
140
  }
133
141
  }
134
- return cycle + best;
142
+ return best;
135
143
  },
136
144
  };
137
145
  }
@@ -0,0 +1,41 @@
1
+ /**
2
+ * The tip rotation behind a loading screen: a deterministic shuffle of the game's tips, shown one at a
3
+ * time, cycling without repeating until the list is exhausted. Deterministic for a given seed, so a
4
+ * capture or a test sees the same first tip every run. Presentation-free — the loading block owns the
5
+ * look and the timer.
6
+ */
7
+ /** How a tip rotation is wired. */
8
+ export interface TipRotationConfig {
9
+ /** The tips to rotate, in authored order. Empty means no tip is shown. */
10
+ tips: readonly string[];
11
+ /** Seed for the shuffle. Default `"tips"`, so the order is stable across runs. */
12
+ seed?: string | number;
13
+ }
14
+ /** Serializable rotation state. */
15
+ export interface TipRotationSnapshot {
16
+ /** Shuffled indices into the authored tip list. */
17
+ order: number[];
18
+ /** Position in {@link order} of the tip being shown. */
19
+ cursor: number;
20
+ }
21
+ /** A deterministic, cycle-through tip list. */
22
+ export interface TipRotation {
23
+ /** The tip being shown, or `null` when the game authored none. */
24
+ current(): string | null;
25
+ /** Advance to the next tip (wrapping) and return it. */
26
+ next(): string | null;
27
+ /** Index into {@link TipRotationConfig.tips} of the current tip, or `-1`. */
28
+ index(): number;
29
+ /** Restart at the first tip of the same shuffled order. */
30
+ reset(): void;
31
+ subscribe(listener: () => void): () => void;
32
+ snapshot(): TipRotationSnapshot;
33
+ restore(snapshot: TipRotationSnapshot): void;
34
+ }
35
+ /**
36
+ * Create a {@link TipRotation} over the game's tips. Order is a seeded shuffle; `next()` walks it and
37
+ * wraps, so every tip is seen once per cycle instead of a random repeat.
38
+ *
39
+ * @capability loading-tips deterministic loading-screen tip rotation with a seeded shuffle and snapshot/restore
40
+ */
41
+ export declare function createTipRotation(config: TipRotationConfig): TipRotation;
@@ -0,0 +1,64 @@
1
+ import { hashString } from "../random/rng.js";
2
+ function shuffled(count, seed) {
3
+ const order = [...Array(count).keys()];
4
+ let state = hashString(String(seed)) >>> 0;
5
+ for (let i = order.length - 1; i > 0; i -= 1) {
6
+ state = (Math.imul(state, 1664525) + 1013904223) >>> 0;
7
+ const j = state % (i + 1);
8
+ const swap = order[i];
9
+ order[i] = order[j];
10
+ order[j] = swap;
11
+ }
12
+ return order;
13
+ }
14
+ /**
15
+ * Create a {@link TipRotation} over the game's tips. Order is a seeded shuffle; `next()` walks it and
16
+ * wraps, so every tip is seen once per cycle instead of a random repeat.
17
+ *
18
+ * @capability loading-tips deterministic loading-screen tip rotation with a seeded shuffle and snapshot/restore
19
+ */
20
+ export function createTipRotation(config) {
21
+ const tips = [...config.tips];
22
+ let snapshot = { order: shuffled(tips.length, config.seed ?? "tips"), cursor: 0 };
23
+ const listeners = new Set();
24
+ function commit(next) {
25
+ snapshot = next;
26
+ for (const listener of listeners)
27
+ listener();
28
+ }
29
+ function current() {
30
+ if (tips.length === 0)
31
+ return null;
32
+ return tips[snapshot.order[snapshot.cursor] ?? 0] ?? null;
33
+ }
34
+ return {
35
+ current,
36
+ next() {
37
+ if (tips.length === 0)
38
+ return null;
39
+ commit({ order: snapshot.order, cursor: (snapshot.cursor + 1) % tips.length });
40
+ return current();
41
+ },
42
+ index() {
43
+ if (tips.length === 0)
44
+ return -1;
45
+ return snapshot.order[snapshot.cursor] ?? -1;
46
+ },
47
+ reset() {
48
+ commit({ order: snapshot.order, cursor: 0 });
49
+ },
50
+ subscribe(listener) {
51
+ listeners.add(listener);
52
+ return () => listeners.delete(listener);
53
+ },
54
+ snapshot: () => ({ order: [...snapshot.order], cursor: snapshot.cursor }),
55
+ restore(next) {
56
+ const order = next.order.filter((index) => Number.isInteger(index) && index >= 0 && index < tips.length);
57
+ const valid = order.length === tips.length && new Set(order).size === tips.length;
58
+ commit({
59
+ order: valid ? [...order] : shuffled(tips.length, config.seed ?? "tips"),
60
+ cursor: valid ? Math.max(0, Math.min(next.cursor, tips.length - 1)) : 0,
61
+ });
62
+ },
63
+ };
64
+ }