@jgengine/core 0.13.0 → 0.15.0-next.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 (223) hide show
  1. package/CHANGELOG.md +529 -1
  2. package/dist/ai/difficulty.d.ts +154 -0
  3. package/dist/ai/difficulty.js +194 -0
  4. package/dist/ai/driver.d.ts +107 -0
  5. package/dist/ai/driver.js +175 -0
  6. package/dist/ai/flock.js +13 -23
  7. package/dist/ai/laneSelect.js +2 -1
  8. package/dist/ai/mobBrain.js +2 -1
  9. package/dist/ai/waveRunner.d.ts +81 -0
  10. package/dist/ai/waveRunner.js +104 -0
  11. package/dist/authoring.d.ts +6 -4
  12. package/dist/authoring.js +6 -4
  13. package/dist/combat/castRunner.d.ts +13 -1
  14. package/dist/combat/castRunner.js +5 -1
  15. package/dist/combat/comboMeter.d.ts +140 -0
  16. package/dist/combat/comboMeter.js +215 -0
  17. package/dist/combat/comboPoints.d.ts +11 -0
  18. package/dist/combat/comboPoints.js +11 -2
  19. package/dist/combat/damageResolution.d.ts +1 -1
  20. package/dist/combat/defensiveWindow.d.ts +5 -0
  21. package/dist/combat/effects.d.ts +24 -0
  22. package/dist/combat/effects.js +6 -1
  23. package/dist/combat/projectiles.d.ts +6 -0
  24. package/dist/combat/projectiles.js +2 -2
  25. package/dist/combat/regenShield.d.ts +40 -2
  26. package/dist/combat/regenShield.js +66 -18
  27. package/dist/combat/resourcePool.d.ts +19 -0
  28. package/dist/combat/resourcePool.js +20 -2
  29. package/dist/combat/shotOrigin.d.ts +5 -0
  30. package/dist/combat/statusApplication.d.ts +1 -1
  31. package/dist/combat/statusApplication.js +2 -1
  32. package/dist/combat/statusEffectView.d.ts +56 -0
  33. package/dist/combat/statusEffectView.js +52 -0
  34. package/dist/combat.d.ts +4 -2
  35. package/dist/combat.js +2 -0
  36. package/dist/devtools/fallbackSeams.d.ts +3 -2
  37. package/dist/devtools/fallbackSeams.js +1 -1
  38. package/dist/devtools/textureErrors.d.ts +40 -0
  39. package/dist/devtools/textureErrors.js +68 -0
  40. package/dist/economy/shopStock.d.ts +110 -0
  41. package/dist/economy/shopStock.js +157 -0
  42. package/dist/economy/wallet.d.ts +6 -0
  43. package/dist/economy/wallet.js +1 -0
  44. package/dist/game/achievements.d.ts +80 -0
  45. package/dist/game/achievements.js +124 -0
  46. package/dist/game/cameraConfig.d.ts +7 -0
  47. package/dist/game/codex.d.ts +60 -0
  48. package/dist/game/codex.js +89 -0
  49. package/dist/game/credits.d.ts +36 -0
  50. package/dist/game/credits.js +0 -0
  51. package/dist/game/defineGame.d.ts +28 -3
  52. package/dist/game/defineGame.js +5 -0
  53. package/dist/game/dialogueGraph.d.ts +116 -0
  54. package/dist/game/dialogueGraph.js +103 -0
  55. package/dist/game/eventTicker.d.ts +95 -0
  56. package/dist/game/eventTicker.js +91 -0
  57. package/dist/game/events.d.ts +24 -5
  58. package/dist/game/events.js +18 -0
  59. package/dist/game/gamePhase.d.ts +1 -1
  60. package/dist/game/gamePhase.js +1 -1
  61. package/dist/game/leaderboardRank.d.ts +71 -0
  62. package/dist/game/leaderboardRank.js +76 -0
  63. package/dist/game/lootTable.d.ts +10 -1
  64. package/dist/game/lootTable.js +4 -2
  65. package/dist/game/notifications.d.ts +57 -0
  66. package/dist/game/notifications.js +107 -0
  67. package/dist/game/playableGame.d.ts +39 -2
  68. package/dist/game/quest.d.ts +26 -0
  69. package/dist/game/quest.js +31 -0
  70. package/dist/game/runDraft.js +2 -1
  71. package/dist/game/saveSlots.d.ts +102 -0
  72. package/dist/game/saveSlots.js +136 -0
  73. package/dist/game/talentTreeView.d.ts +106 -0
  74. package/dist/game/talentTreeView.js +114 -0
  75. package/dist/game/worldItem.js +2 -1
  76. package/dist/gameplay.d.ts +15 -4
  77. package/dist/gameplay.js +13 -2
  78. package/dist/i18n/i18n.d.ts +51 -0
  79. package/dist/i18n/i18n.js +82 -0
  80. package/dist/input/axisInput.d.ts +1 -1
  81. package/dist/input/axisInput.js +18 -2
  82. package/dist/input/rebindSession.d.ts +141 -0
  83. package/dist/input/rebindSession.js +235 -0
  84. package/dist/input/touchControlsMode.d.ts +16 -0
  85. package/dist/input/touchControlsMode.js +23 -0
  86. package/dist/input/touchScheme.d.ts +30 -1
  87. package/dist/input/touchScheme.js +31 -1
  88. package/dist/interaction/promptRegistry.d.ts +78 -0
  89. package/dist/interaction/promptRegistry.js +130 -0
  90. package/dist/inventory/storageTier.js +3 -2
  91. package/dist/meta/changelog.d.ts +1 -1
  92. package/dist/meta/changelog.js +21 -1
  93. package/dist/movement/movementModel.d.ts +26 -3
  94. package/dist/movement/movementModel.js +121 -15
  95. package/dist/movement/playerMovement.js +161 -25
  96. package/dist/physics/drivableVehicle.js +3 -1
  97. package/dist/physics/kinematicVehicle.d.ts +56 -1
  98. package/dist/physics/kinematicVehicle.js +99 -10
  99. package/dist/physics/vehicleObstacles.js +19 -0
  100. package/dist/random/resolveRng.d.ts +14 -0
  101. package/dist/random/resolveRng.js +31 -0
  102. package/dist/render/postProcessing.d.ts +12 -1
  103. package/dist/runtime/cameraDirector.d.ts +2 -2
  104. package/dist/runtime/context/combat.js +8 -0
  105. package/dist/runtime/context/scene.d.ts +2 -0
  106. package/dist/runtime/context/scene.js +48 -1
  107. package/dist/runtime/gameContext.js +8 -2
  108. package/dist/runtime/gameContextTypes.d.ts +39 -0
  109. package/dist/runtime/inputSnapshot.d.ts +10 -1
  110. package/dist/runtime/inputSnapshot.js +13 -1
  111. package/dist/runtime/runtimeSave.d.ts +3 -3
  112. package/dist/runtime/runtimeSave.js +11 -2
  113. package/dist/scene/assetDiagnostics.js +2 -2
  114. package/dist/scene/autoTarget.js +2 -1
  115. package/dist/scene/builtinSceneKinds.d.ts +6 -1
  116. package/dist/scene/builtinSceneKinds.js +10 -3
  117. package/dist/scene/captureCheck.js +3 -2
  118. package/dist/scene/colliders.d.ts +20 -0
  119. package/dist/scene/colliders.js +78 -0
  120. package/dist/scene/collisionMesh.d.ts +8 -0
  121. package/dist/scene/collisionMesh.js +48 -0
  122. package/dist/scene/entityStore.d.ts +4 -1
  123. package/dist/scene/entityStore.js +3 -0
  124. package/dist/scene/objectStore.d.ts +10 -0
  125. package/dist/scene/objectStore.js +29 -6
  126. package/dist/scene/sceneKinds.d.ts +37 -0
  127. package/dist/scene/sceneKinds.js +19 -0
  128. package/dist/scene/sequenceDirector.d.ts +110 -0
  129. package/dist/scene/sequenceDirector.js +146 -0
  130. package/dist/session/roles.js +3 -2
  131. package/dist/settings/settingsModel.d.ts +1 -0
  132. package/dist/settings/settingsModel.js +1 -0
  133. package/dist/stats/rollCheck.js +4 -2
  134. package/dist/time/dayNightCycle.d.ts +114 -0
  135. package/dist/time/dayNightCycle.js +208 -0
  136. package/dist/time/timerSet.d.ts +132 -0
  137. package/dist/time/timerSet.js +243 -0
  138. package/dist/ui/accessibility.d.ts +51 -0
  139. package/dist/ui/accessibility.js +83 -0
  140. package/dist/ui/coachMarks.d.ts +99 -0
  141. package/dist/ui/coachMarks.js +126 -0
  142. package/dist/ui/floatingText.d.ts +114 -0
  143. package/dist/ui/floatingText.js +203 -0
  144. package/dist/ui/hudScale.d.ts +16 -1
  145. package/dist/ui/hudScale.js +22 -1
  146. package/dist/ui/modalStack.d.ts +143 -0
  147. package/dist/ui/modalStack.js +160 -0
  148. package/dist/ui/objectiveBanner.d.ts +123 -0
  149. package/dist/ui/objectiveBanner.js +162 -0
  150. package/dist/ui/photoMode.d.ts +32 -0
  151. package/dist/ui/photoMode.js +51 -0
  152. package/dist/ui/radialMenu.d.ts +47 -0
  153. package/dist/ui/radialMenu.js +74 -0
  154. package/dist/ui/screenMarkers.d.ts +128 -0
  155. package/dist/ui/screenMarkers.js +118 -0
  156. package/dist/ui.d.ts +8 -0
  157. package/dist/ui.js +8 -0
  158. package/dist/vfx/cameraShake.d.ts +97 -0
  159. package/dist/vfx/cameraShake.js +136 -0
  160. package/dist/vfx/damageDirection.d.ts +102 -0
  161. package/dist/vfx/damageDirection.js +188 -0
  162. package/dist/vfx/particles.d.ts +103 -0
  163. package/dist/vfx/particles.js +256 -0
  164. package/dist/vfx/screenEffects.d.ts +149 -0
  165. package/dist/vfx/screenEffects.js +191 -0
  166. package/dist/world/authoredObjects.d.ts +18 -0
  167. package/dist/world/authoredObjects.js +21 -0
  168. package/dist/world/authoredSpawn.js +19 -0
  169. package/dist/world/buildingLots.d.ts +54 -11
  170. package/dist/world/buildingLots.js +116 -30
  171. package/dist/world/cityBlocks.d.ts +45 -0
  172. package/dist/world/cityBlocks.js +159 -0
  173. package/dist/world/cityContent.d.ts +46 -2
  174. package/dist/world/cityContent.js +160 -2
  175. package/dist/world/cityGenerator.d.ts +261 -23
  176. package/dist/world/cityGenerator.js +643 -33
  177. package/dist/world/cityGeometry.d.ts +20 -0
  178. package/dist/world/cityGeometry.js +91 -0
  179. package/dist/world/cityKind.js +9 -46
  180. package/dist/world/fastTravel.d.ts +64 -0
  181. package/dist/world/fastTravel.js +108 -0
  182. package/dist/world/features.d.ts +16 -0
  183. package/dist/world/features.js +2 -0
  184. package/dist/world/grassKind.js +4 -1
  185. package/dist/world/grounded.d.ts +11 -0
  186. package/dist/world/grounded.js +12 -0
  187. package/dist/world/mapAnnotations.d.ts +75 -0
  188. package/dist/world/mapAnnotations.js +139 -0
  189. package/dist/world/markers.js +1 -0
  190. package/dist/world/resourceNode.d.ts +1 -1
  191. package/dist/world/resourceNode.js +2 -1
  192. package/dist/world/roads.d.ts +294 -5
  193. package/dist/world/roads.js +1262 -21
  194. package/dist/world/scatterCoverage.d.ts +98 -0
  195. package/dist/world/scatterCoverage.js +101 -0
  196. package/dist/world/scatterRegion.js +6 -0
  197. package/dist/world/sharedAnnotations.d.ts +84 -0
  198. package/dist/world/sharedAnnotations.js +76 -0
  199. package/dist/world/spawnOverride.d.ts +37 -0
  200. package/dist/world/spawnOverride.js +50 -0
  201. package/dist/world/streetGenerator.d.ts +60 -11
  202. package/dist/world/streetGenerator.js +1786 -129
  203. package/dist/world/terrain.d.ts +1 -0
  204. package/dist/world/terrain.js +2 -0
  205. package/dist/world/waypoints.d.ts +63 -0
  206. package/dist/world/waypoints.js +137 -0
  207. package/dist/world.d.ts +18 -2
  208. package/dist/world.js +18 -2
  209. package/package.json +2 -2
  210. package/skills/ce-handoff/SKILL.md +149 -0
  211. package/skills/game-design/SKILL.md +2 -0
  212. package/skills/jgengine/SKILL.md +7 -3
  213. package/skills/jgengine/api.md +17 -7
  214. package/skills/jgengine/capabilities.md +12 -0
  215. package/skills/jgengine/recipes/minimal-game.md +7 -4
  216. package/skills/jgengine-combat/api.md +40 -4
  217. package/skills/jgengine-combat/capabilities.md +12 -0
  218. package/skills/jgengine-combat/reference.md +2 -0
  219. package/skills/jgengine-gameplay/api.md +212 -11
  220. package/skills/jgengine-gameplay/capabilities.md +66 -1
  221. package/skills/jgengine-world/api.md +408 -12
  222. package/skills/jgengine-world/capabilities.md +127 -2
  223. package/skills/jgengine-world/reference.md +15 -1
@@ -0,0 +1,110 @@
1
+ /** One scheduled beat of a cutscene: a typed cue that fires when the playhead reaches `atMs`. */
2
+ export interface SequenceCue<Payload = unknown> {
3
+ /** Playhead time (ms from the start of the timeline) at which this cue fires. Must be `>= 0`. */
4
+ atMs: number;
5
+ /**
6
+ * Free-string tag naming what this cue is — `"camera"`, `"dialogue"`, `"fade"`, anything.
7
+ * The director never interprets it; the game switches on `kind` to act on the {@link SequenceCue.payload}.
8
+ */
9
+ kind: string;
10
+ /** Arbitrary serializable data the game reads when the cue fires. Opaque to the director. */
11
+ payload?: Payload;
12
+ /** Optional stable id, surfaced on emission and useful for logging/debugging. */
13
+ id?: string;
14
+ }
15
+ /** A cue plus the resolved firing context passed to {@link SequenceDirector.onCue} listeners. */
16
+ export interface EmittedCue<Payload = unknown> {
17
+ /** The cue that fired. */
18
+ cue: SequenceCue<Payload>;
19
+ /** Its index in the ordered cue list. */
20
+ index: number;
21
+ /** Playhead (ms) at the moment it was emitted — `>= cue.atMs`, possibly well past on a large jump. */
22
+ atPlayheadMs: number;
23
+ }
24
+ /** A read-only view of the director's playback state, returned by {@link SequenceDirector.state}. */
25
+ export interface SequenceState {
26
+ /** Current playhead position in ms from the start of the timeline. */
27
+ playheadMs: number;
28
+ /** Total timeline length — the largest cue `atMs` (0 when empty). */
29
+ durationMs: number;
30
+ /** Whether the clock is currently advancing the playhead. */
31
+ playing: boolean;
32
+ /** True once the playhead has reached the end (every cue has fired). */
33
+ done: boolean;
34
+ /** Fraction of the timeline elapsed, in `[0, 1]`; `1` when `durationMs` is 0. */
35
+ progress: number;
36
+ /** How many cues have fired so far (a prefix of the ordered list). */
37
+ firedCount: number;
38
+ /** Total number of cues. */
39
+ total: number;
40
+ }
41
+ /** Serializable snapshot — enough to resume a cutscene exactly where a save left it. */
42
+ export interface SequenceSnapshot {
43
+ /** Playhead position in ms. */
44
+ playheadMs: number;
45
+ /** Index of the next cue to fire; cues before it are considered already fired. */
46
+ nextIndex: number;
47
+ /** Whether playback was running when captured. */
48
+ playing: boolean;
49
+ }
50
+ /** Options for {@link createSequenceDirector}. */
51
+ export interface SequenceDirectorOptions<Payload = unknown> {
52
+ /** The timeline: cues in any order (sorted by `atMs` internally, stably). */
53
+ cues: readonly SequenceCue<Payload>[];
54
+ /** Injected clock (ms) that drives playback. Default `Date.now`; inject a fake for deterministic tests. */
55
+ now?: () => number;
56
+ /** Start playing immediately (default `false` — call {@link SequenceDirector.play}). */
57
+ autoplay?: boolean;
58
+ }
59
+ /** A cue listener, called once per cue as it fires. Returns nothing. */
60
+ export type CueListener<Payload = unknown> = (emitted: EmittedCue<Payload>) => void;
61
+ /**
62
+ * A data-driven cutscene: an ordered timeline of typed cues that fire on a single
63
+ * injected clock. See {@link createSequenceDirector}.
64
+ */
65
+ export interface SequenceDirector<Payload = unknown> {
66
+ /** The cues, sorted by `atMs`. */
67
+ readonly cues: readonly SequenceCue<Payload>[];
68
+ /** Start (or resume) playback from the current playhead. No-op if already playing or done. */
69
+ play(): void;
70
+ /** Freeze the playhead, capturing elapsed clock time up to now. No-op if not playing. */
71
+ pause(): void;
72
+ /**
73
+ * Jump the playhead to `ms` (clamped to `[0, durationMs]`). Moving forward fires every
74
+ * un-fired cue up to `ms` in order (once each, even across a large jump); moving backward
75
+ * re-arms cues now in the future without emitting them.
76
+ */
77
+ seek(ms: number): void;
78
+ /** Fast-forward to the end: emit every remaining cue in order, then stop and mark done. */
79
+ skip(): void;
80
+ /** Halt and rewind: pause, reset the playhead to 0, and re-arm every cue. */
81
+ stop(): void;
82
+ /**
83
+ * Advance the playhead by the elapsed injected-clock time since the last tick/resume and
84
+ * emit any cues now due. A host calls this once per frame while playing; a no-op when paused.
85
+ */
86
+ tick(): void;
87
+ /** Current playback state (playhead, duration, playing/done, progress). */
88
+ state(): SequenceState;
89
+ /** Register a cue listener; returns an unsubscribe fn. */
90
+ onCue(listener: CueListener<Payload>): () => void;
91
+ /** Observe state changes (play/pause/seek/skip/stop/tick/restore); returns an unsubscribe fn. */
92
+ subscribe(listener: () => void): () => void;
93
+ /** Serializable state for a save. */
94
+ snapshot(): SequenceSnapshot;
95
+ /** Restore from a {@link SequenceSnapshot}; does not re-emit already-fired cues. */
96
+ restore(snapshot: SequenceSnapshot): void;
97
+ }
98
+ /**
99
+ * A serializable cutscene / sequence director: an ordered timeline of typed cues
100
+ * (`{ atMs, kind, payload }`) advanced by one injected clock, firing each cue once
101
+ * and in order as its time passes — even across a large seek — with play/pause/
102
+ * seek/skip/stop controls. The director only *schedules and emits* cues; it never
103
+ * interprets what a `kind` means, so the same primitive drives camera moves,
104
+ * dialogue lines, fades, or any game event. `snapshot`/`restore` round-trip the
105
+ * playhead and which cues have fired. Deterministic (no wall clock beyond the
106
+ * injected `now`) and allocation-free on the tick path.
107
+ *
108
+ * @capability sequence-director data-driven cutscene timeline — fire typed camera/dialogue/event cues on one injected clock with play/pause/seek/skip and snapshot/restore
109
+ */
110
+ export declare function createSequenceDirector<Payload = unknown>(options: SequenceDirectorOptions<Payload>): SequenceDirector<Payload>;
@@ -0,0 +1,146 @@
1
+ /**
2
+ * A serializable cutscene / sequence director: an ordered timeline of typed cues
3
+ * (`{ atMs, kind, payload }`) advanced by one injected clock, firing each cue once
4
+ * and in order as its time passes — even across a large seek — with play/pause/
5
+ * seek/skip/stop controls. The director only *schedules and emits* cues; it never
6
+ * interprets what a `kind` means, so the same primitive drives camera moves,
7
+ * dialogue lines, fades, or any game event. `snapshot`/`restore` round-trip the
8
+ * playhead and which cues have fired. Deterministic (no wall clock beyond the
9
+ * injected `now`) and allocation-free on the tick path.
10
+ *
11
+ * @capability sequence-director data-driven cutscene timeline — fire typed camera/dialogue/event cues on one injected clock with play/pause/seek/skip and snapshot/restore
12
+ */
13
+ export function createSequenceDirector(options) {
14
+ const cues = [...options.cues].sort((a, b) => a.atMs - b.atMs);
15
+ for (const cue of cues) {
16
+ if (!(cue.atMs >= 0) || !Number.isFinite(cue.atMs)) {
17
+ throw new Error(`sequence cue "${cue.kind}" needs a finite atMs >= 0, got ${cue.atMs}`);
18
+ }
19
+ }
20
+ const durationMs = cues.length > 0 ? cues[cues.length - 1].atMs : 0;
21
+ const now = options.now ?? Date.now;
22
+ const cueListeners = new Set();
23
+ const stateListeners = new Set();
24
+ let playheadMs = 0;
25
+ let nextIndex = 0;
26
+ let playing = false;
27
+ let lastNow = now();
28
+ function notify() {
29
+ for (const listener of stateListeners)
30
+ listener();
31
+ }
32
+ /** Emit every un-fired cue with `atMs <= playheadMs`, in order. Allocation-free. */
33
+ function fireDue() {
34
+ let fired = false;
35
+ while (nextIndex < cues.length && cues[nextIndex].atMs <= playheadMs) {
36
+ const cue = cues[nextIndex];
37
+ const index = nextIndex;
38
+ nextIndex += 1;
39
+ fired = true;
40
+ for (const listener of cueListeners)
41
+ listener({ cue, index, atPlayheadMs: playheadMs });
42
+ }
43
+ return fired;
44
+ }
45
+ /** Fold elapsed injected-clock time into the playhead and fire due cues. */
46
+ function advanceToNow() {
47
+ const t = now();
48
+ const delta = t - lastNow;
49
+ lastNow = t;
50
+ if (delta <= 0)
51
+ return false;
52
+ playheadMs = Math.min(durationMs, playheadMs + delta);
53
+ const fired = fireDue();
54
+ const stopped = playheadMs >= durationMs && playing;
55
+ if (playheadMs >= durationMs)
56
+ playing = false;
57
+ return fired || stopped;
58
+ }
59
+ function isDone() {
60
+ return nextIndex >= cues.length && playheadMs >= durationMs;
61
+ }
62
+ return {
63
+ cues,
64
+ play() {
65
+ if (playing || isDone())
66
+ return;
67
+ playing = true;
68
+ lastNow = now();
69
+ notify();
70
+ },
71
+ pause() {
72
+ if (!playing)
73
+ return;
74
+ advanceToNow();
75
+ playing = false;
76
+ notify();
77
+ },
78
+ seek(ms) {
79
+ const target = Math.max(0, Math.min(durationMs, ms));
80
+ if (target < playheadMs) {
81
+ // Backward scrub: re-arm cues now in the future, emit nothing.
82
+ playheadMs = target;
83
+ let i = 0;
84
+ while (i < cues.length && cues[i].atMs <= target)
85
+ i += 1;
86
+ nextIndex = i;
87
+ }
88
+ else {
89
+ playheadMs = target;
90
+ fireDue();
91
+ }
92
+ lastNow = now();
93
+ if (playheadMs >= durationMs && nextIndex >= cues.length)
94
+ playing = false;
95
+ notify();
96
+ },
97
+ skip() {
98
+ playheadMs = durationMs;
99
+ fireDue();
100
+ playing = false;
101
+ notify();
102
+ },
103
+ stop() {
104
+ playheadMs = 0;
105
+ nextIndex = 0;
106
+ playing = false;
107
+ lastNow = now();
108
+ notify();
109
+ },
110
+ tick() {
111
+ if (!playing)
112
+ return;
113
+ if (advanceToNow())
114
+ notify();
115
+ },
116
+ state() {
117
+ return {
118
+ playheadMs,
119
+ durationMs,
120
+ playing,
121
+ done: isDone(),
122
+ progress: durationMs > 0 ? Math.max(0, Math.min(1, playheadMs / durationMs)) : 1,
123
+ firedCount: nextIndex,
124
+ total: cues.length,
125
+ };
126
+ },
127
+ onCue(listener) {
128
+ cueListeners.add(listener);
129
+ return () => cueListeners.delete(listener);
130
+ },
131
+ subscribe(listener) {
132
+ stateListeners.add(listener);
133
+ return () => stateListeners.delete(listener);
134
+ },
135
+ snapshot() {
136
+ return { playheadMs, nextIndex, playing };
137
+ },
138
+ restore(snapshot) {
139
+ playheadMs = Math.max(0, Math.min(durationMs, snapshot.playheadMs));
140
+ nextIndex = Math.max(0, Math.min(cues.length, snapshot.nextIndex));
141
+ playing = snapshot.playing;
142
+ lastNow = now();
143
+ notify();
144
+ },
145
+ };
146
+ }
@@ -1,14 +1,15 @@
1
1
  import { shuffleWithRng } from "../cards/cardPile.js";
2
+ import { resolveRng } from "../random/resolveRng.js";
2
3
  /**
3
4
  * Assign hidden or team roles across players by ratio — social-deduction and team-shuffle setups.
4
5
  *
5
6
  * @capability role-assign assign hidden or team roles to players by ratio
6
7
  * @internal
7
8
  */
8
- export function assignRoles(userIds, roles, rng = Math.random) {
9
+ export function assignRoles(userIds, roles, rng) {
9
10
  if (roles.length === 0)
10
11
  throw new Error("assignRoles needs at least one role");
11
- const shuffled = shuffleWithRng(userIds, rng);
12
+ const shuffled = shuffleWithRng(userIds, resolveRng(rng, "assignRoles"));
12
13
  const assignment = {};
13
14
  let index = 0;
14
15
  const countRoles = roles.filter((role) => role.count !== undefined);
@@ -68,6 +68,7 @@ export declare const SETTING_IDS: {
68
68
  readonly graphicsShadows: "graphics.shadows";
69
69
  readonly graphicsUiScale: "graphics.uiScale";
70
70
  readonly touchStyle: "controls.touchStyle";
71
+ readonly touchJoystick: "controls.touchJoystick";
71
72
  };
72
73
  export type GraphicsQuality = "low" | "medium" | "high";
73
74
  export declare const GRAPHICS_QUALITY_OPTIONS: readonly SettingOption[];
@@ -12,6 +12,7 @@ export const SETTING_IDS = {
12
12
  graphicsShadows: "graphics.shadows",
13
13
  graphicsUiScale: "graphics.uiScale",
14
14
  touchStyle: "controls.touchStyle",
15
+ touchJoystick: "controls.touchJoystick",
15
16
  };
16
17
  export const GRAPHICS_QUALITY_OPTIONS = [
17
18
  { value: "low", label: "Low" },
@@ -1,12 +1,14 @@
1
+ import { resolveRng } from "../random/resolveRng.js";
1
2
  /**
2
3
  * Resolve a tabletop-style pass/fail roll against a target number with modifiers and crit/fumble bands.
3
4
  *
4
5
  * @capability dice-check resolve a pass/fail roll against a target number with modifiers and crits
5
6
  */
6
- export function rollCheck(input, rng = Math.random) {
7
+ export function rollCheck(input, rng) {
8
+ const random = resolveRng(rng, "rollCheck");
7
9
  const sides = input.diceSides ?? 20;
8
10
  const advantage = input.advantage ?? "normal";
9
- const rollDie = () => Math.floor(rng() * sides) + 1;
11
+ const rollDie = () => Math.floor(random() * sides) + 1;
10
12
  const rolls = advantage === "normal" ? [rollDie()] : [rollDie(), rollDie()];
11
13
  const roll = advantage === "advantage"
12
14
  ? Math.max(...rolls)
@@ -0,0 +1,114 @@
1
+ /**
2
+ * Turnkey day-night cycle: one serializable brain that advances a normalized day
3
+ * fraction on an injected clock and blends per-keyframe phase labels and tint/light
4
+ * colors. A game wires this one model and drives an existing sky/daylight seam from
5
+ * `sample()` instead of hand-rolling a clock plus a color lerp.
6
+ *
7
+ * The model is genre-agnostic: `phase` labels and colors are free-form values the
8
+ * game styles. The model never branches on what a phase string "means" — it only
9
+ * interpolates between the keyframes you give it.
10
+ */
11
+ /** One day-night keyframe: at a normalized day position, the phase label and target colors the game styles. */
12
+ export interface DayNightKeyframe {
13
+ /** Normalized day position in [0,1) where this keyframe sits; the cycle wraps past 1 back to 0. */
14
+ at: number;
15
+ /** Free-form phase label (e.g. `"dawn"`, `"day"`, `"dusk"`, `"night"`, or anything). The model never interprets it. */
16
+ phase: string;
17
+ /** Primary tint/grade color at this keyframe as `"#rrggbb"`. */
18
+ color: string;
19
+ /** Optional secondary color (e.g. sky zenith or key-light color). Falls back to `color` when sampled. */
20
+ lightColor?: string;
21
+ /** Optional light level in [0,1] a presenter maps to intensity. Falls back to `1` when sampled. */
22
+ intensity?: number;
23
+ }
24
+ /** The interpolated day-night look at a moment: day fraction, active phase, and blended colors. */
25
+ export interface DayNightSample {
26
+ /** Current normalized day fraction in [0,1). */
27
+ dayFraction: number;
28
+ /** Phase label of the keyframe segment currently active (the free string the game styles). */
29
+ phase: string;
30
+ /** Blended primary tint/grade color as `"#rrggbb"`. */
31
+ color: string;
32
+ /** Blended secondary color as `"#rrggbb"` (equals `color` when no keyframe set `lightColor`). */
33
+ lightColor: string;
34
+ /** Blended light level in [0,1]. */
35
+ intensity: number;
36
+ }
37
+ /** Serializable day-night position — accumulated clock offset, pause state, and speed — for save/load. */
38
+ export interface DayNightSnapshot {
39
+ /** Accumulated game-time in ms since the cycle's zero, before wrapping into a day. */
40
+ elapsedMs: number;
41
+ /** Whether the clock is paused. */
42
+ paused: boolean;
43
+ /** Clock rate multiplier in effect. */
44
+ speed: number;
45
+ }
46
+ /** Options for {@link createDayNightCycle}. */
47
+ export interface DayNightCycleOptions {
48
+ /** At least one keyframe; sorted and wrapped into [0,1) internally. Colors and phases are free-form. */
49
+ keyframes: readonly DayNightKeyframe[];
50
+ /** Real milliseconds for one full day at speed 1. Default `120000` (two minutes). */
51
+ dayLengthMs?: number;
52
+ /** Normalized day fraction to start at, in [0,1). Default `0`. */
53
+ start?: number;
54
+ /** Clock rate multiplier (`2` runs twice as fast). Default `1`. */
55
+ speed?: number;
56
+ /** Start with the clock paused. Default `false`. */
57
+ startPaused?: boolean;
58
+ /** Injected clock in ms so advancement stays deterministic under test. Default `Date.now`. */
59
+ now?: () => number;
60
+ }
61
+ /** A running, observable, serializable day-night cycle. A presenter renders `sample()`. */
62
+ export interface DayNightCycle {
63
+ /** The configured keyframes, sorted by `at` and wrapped into [0,1). */
64
+ readonly keyframes: readonly DayNightKeyframe[];
65
+ /** Real milliseconds for one full day at speed 1. */
66
+ readonly dayLengthMs: number;
67
+ /** Current normalized day fraction in [0,1), advanced from the injected clock. */
68
+ dayFraction(): number;
69
+ /** The free-form phase label active at the current day fraction. */
70
+ phase(): string;
71
+ /** The interpolated look at the current day fraction. */
72
+ sample(): DayNightSample;
73
+ /** The interpolated look at an explicit day fraction, without touching the clock. */
74
+ sampleAt(fraction: number): DayNightSample;
75
+ /**
76
+ * Calendar adapter (`{ dayFraction }`) so the cycle drops straight into any
77
+ * `{ calendar(): { dayFraction: number } }` sky/daylight seam that already
78
+ * consumes a day fraction — no glue in the game.
79
+ */
80
+ calendar(): {
81
+ dayFraction: number;
82
+ };
83
+ /** Jump the clock to a normalized day fraction (wrapped into [0,1)). */
84
+ setDayFraction(fraction: number): void;
85
+ /** Whether the clock is paused. */
86
+ isPaused(): boolean;
87
+ /** Pause advancement. */
88
+ pause(): void;
89
+ /** Resume advancement. */
90
+ play(): void;
91
+ /** Toggle pause/resume. */
92
+ toggle(): void;
93
+ /** Current clock rate multiplier. */
94
+ speed(): number;
95
+ /** Set the clock rate multiplier; `0` or negative pauses. */
96
+ setSpeed(multiplier: number): void;
97
+ /** Observe control changes (pause/play/speed/setDayFraction/restore). Returns an unsubscribe fn. Continuous advance is read via `sample()`, not events. */
98
+ subscribe(listener: () => void): () => void;
99
+ /** Serializable position for a save. */
100
+ snapshot(): DayNightSnapshot;
101
+ /** Restore position from a {@link DayNightSnapshot}. */
102
+ restore(snapshot: DayNightSnapshot): void;
103
+ }
104
+ /**
105
+ * Creates a turnkey day-night cycle: a serializable model that advances a normalized
106
+ * day fraction on an injected clock and blends per-keyframe phase labels and tint/light
107
+ * colors. Wire this one model, then drive an existing sky/daylight seam from `sample()`
108
+ * (or drop it straight into a `{ calendar(): { dayFraction } }` seam via `calendar()`)
109
+ * to get a moving day-night cycle with color grading — no hand-rolled clock or lerp.
110
+ * `phase` labels and colors are free-form; the model never interprets their meaning.
111
+ *
112
+ * @capability day-night-cycle one wired model gives a moving day-night cycle — running clock plus keyframed phase labels and tint/light colors a game drives an existing sky/daylight seam from
113
+ */
114
+ export declare function createDayNightCycle(options: DayNightCycleOptions): DayNightCycle;
@@ -0,0 +1,208 @@
1
+ /**
2
+ * Turnkey day-night cycle: one serializable brain that advances a normalized day
3
+ * fraction on an injected clock and blends per-keyframe phase labels and tint/light
4
+ * colors. A game wires this one model and drives an existing sky/daylight seam from
5
+ * `sample()` instead of hand-rolling a clock plus a color lerp.
6
+ *
7
+ * The model is genre-agnostic: `phase` labels and colors are free-form values the
8
+ * game styles. The model never branches on what a phase string "means" — it only
9
+ * interpolates between the keyframes you give it.
10
+ */
11
+ /** Clamp a number into [0,1]. */
12
+ function clamp01(value) {
13
+ return value < 0 ? 0 : value > 1 ? 1 : value;
14
+ }
15
+ /** Wrap a number into [0,1), so `-0.1` → `0.9` and `1.2` → `0.2`. */
16
+ function wrap01(value) {
17
+ if (!Number.isFinite(value))
18
+ return 0;
19
+ return ((value % 1) + 1) % 1;
20
+ }
21
+ /** Linear interpolation between `a` and `b` by `t`. */
22
+ function lerp(a, b, t) {
23
+ return a + (b - a) * t;
24
+ }
25
+ /** Parse `"#rrggbb"` (or `"rrggbb"`) into an `[r,g,b]` byte triple. */
26
+ function hexToRgb(hex) {
27
+ const clean = hex.startsWith("#") ? hex.slice(1) : hex;
28
+ const value = Number.parseInt(clean, 16);
29
+ return [(value >> 16) & 255, (value >> 8) & 255, value & 255];
30
+ }
31
+ /** Clamp and round a channel into a 0-255 byte. */
32
+ function clampByte(value) {
33
+ return Math.max(0, Math.min(255, Math.round(value)));
34
+ }
35
+ /** Encode an `[r,g,b]` triple back into `"#rrggbb"`. */
36
+ function rgbToHex(rgb) {
37
+ return `#${rgb.map((channel) => clampByte(channel).toString(16).padStart(2, "0")).join("")}`;
38
+ }
39
+ /** Interpolate two `"#rrggbb"` colors in RGB space by `t`. */
40
+ function lerpHex(a, b, t) {
41
+ const [ar, ag, ab] = hexToRgb(a);
42
+ const [br, bg, bb] = hexToRgb(b);
43
+ return rgbToHex([lerp(ar, br, t), lerp(ag, bg, t), lerp(ab, bb, t)]);
44
+ }
45
+ /** Sanitize a positive finite number, falling back to `fallback`. */
46
+ function positiveOr(value, fallback) {
47
+ return typeof value === "number" && Number.isFinite(value) && value > 0 ? value : fallback;
48
+ }
49
+ /** Sort keyframes by wrapped `at` and fill in color/intensity fallbacks; never mutates the input. */
50
+ function normalizeKeyframes(keyframes) {
51
+ if (keyframes.length === 0) {
52
+ throw new Error("createDayNightCycle requires at least one keyframe");
53
+ }
54
+ return keyframes
55
+ .map((keyframe) => ({
56
+ at: wrap01(keyframe.at),
57
+ phase: keyframe.phase,
58
+ color: keyframe.color,
59
+ lightColor: keyframe.lightColor ?? keyframe.color,
60
+ intensity: keyframe.intensity ?? 1,
61
+ }))
62
+ .sort((a, b) => a.at - b.at);
63
+ }
64
+ /** Blend the normalized keyframe ring at a wrapped day fraction. */
65
+ function sampleKeyframes(sorted, fraction) {
66
+ const dayFraction = wrap01(fraction);
67
+ if (sorted.length === 1) {
68
+ const only = sorted[0];
69
+ return { dayFraction, phase: only.phase, color: only.color, lightColor: only.lightColor, intensity: only.intensity };
70
+ }
71
+ // Find the last keyframe at or before the fraction; the following one (wrapping past
72
+ // 1.0 back to the first) is the segment we interpolate across.
73
+ let fromIndex = sorted.length - 1;
74
+ for (let index = 0; index < sorted.length; index += 1) {
75
+ if (sorted[index].at <= dayFraction)
76
+ fromIndex = index;
77
+ else
78
+ break;
79
+ }
80
+ const from = sorted[fromIndex];
81
+ const to = sorted[(fromIndex + 1) % sorted.length];
82
+ const span = (to.at - from.at + 1) % 1;
83
+ const localRaw = (dayFraction - from.at + 1) % 1;
84
+ const localT = span <= 0 ? 0 : clamp01(localRaw / span);
85
+ return {
86
+ dayFraction,
87
+ phase: from.phase,
88
+ color: lerpHex(from.color, to.color, localT),
89
+ lightColor: lerpHex(from.lightColor, to.lightColor, localT),
90
+ intensity: lerp(from.intensity, to.intensity, localT),
91
+ };
92
+ }
93
+ /**
94
+ * Creates a turnkey day-night cycle: a serializable model that advances a normalized
95
+ * day fraction on an injected clock and blends per-keyframe phase labels and tint/light
96
+ * colors. Wire this one model, then drive an existing sky/daylight seam from `sample()`
97
+ * (or drop it straight into a `{ calendar(): { dayFraction } }` seam via `calendar()`)
98
+ * to get a moving day-night cycle with color grading — no hand-rolled clock or lerp.
99
+ * `phase` labels and colors are free-form; the model never interprets their meaning.
100
+ *
101
+ * @capability day-night-cycle one wired model gives a moving day-night cycle — running clock plus keyframed phase labels and tint/light colors a game drives an existing sky/daylight seam from
102
+ */
103
+ export function createDayNightCycle(options) {
104
+ const sorted = normalizeKeyframes(options.keyframes);
105
+ const keyframes = sorted.map((keyframe) => ({ ...keyframe }));
106
+ const dayLengthMs = positiveOr(options.dayLengthMs, 120_000);
107
+ const now = options.now ?? Date.now;
108
+ const listeners = new Set();
109
+ let elapsedMs = wrap01(options.start ?? 0) * dayLengthMs;
110
+ let paused = options.startPaused ?? false;
111
+ let speed = positiveOr(options.speed, 1);
112
+ let anchor = now();
113
+ function notify() {
114
+ for (const listener of listeners)
115
+ listener();
116
+ }
117
+ /** Accrue elapsed game-time from the injected clock since the last sync, then re-anchor. */
118
+ function sync() {
119
+ const t = now();
120
+ if (!paused)
121
+ elapsedMs += Math.max(0, t - anchor) * speed;
122
+ anchor = t;
123
+ }
124
+ function dayFraction() {
125
+ sync();
126
+ return wrap01(elapsedMs / dayLengthMs);
127
+ }
128
+ return {
129
+ keyframes,
130
+ dayLengthMs,
131
+ dayFraction,
132
+ phase() {
133
+ return sampleKeyframes(sorted, dayFraction()).phase;
134
+ },
135
+ sample() {
136
+ return sampleKeyframes(sorted, dayFraction());
137
+ },
138
+ sampleAt(fraction) {
139
+ return sampleKeyframes(sorted, fraction);
140
+ },
141
+ calendar() {
142
+ return { dayFraction: dayFraction() };
143
+ },
144
+ setDayFraction(fraction) {
145
+ sync();
146
+ elapsedMs = wrap01(fraction) * dayLengthMs;
147
+ notify();
148
+ },
149
+ isPaused() {
150
+ return paused;
151
+ },
152
+ pause() {
153
+ if (paused)
154
+ return;
155
+ sync();
156
+ paused = true;
157
+ notify();
158
+ },
159
+ play() {
160
+ if (!paused)
161
+ return;
162
+ sync();
163
+ paused = false;
164
+ notify();
165
+ },
166
+ toggle() {
167
+ if (paused)
168
+ this.play();
169
+ else
170
+ this.pause();
171
+ },
172
+ speed() {
173
+ return speed;
174
+ },
175
+ setSpeed(multiplier) {
176
+ sync();
177
+ if (!Number.isFinite(multiplier) || multiplier <= 0) {
178
+ if (!paused) {
179
+ paused = true;
180
+ notify();
181
+ }
182
+ return;
183
+ }
184
+ const changed = paused || multiplier !== speed;
185
+ speed = multiplier;
186
+ paused = false;
187
+ if (changed)
188
+ notify();
189
+ },
190
+ subscribe(listener) {
191
+ listeners.add(listener);
192
+ return () => {
193
+ listeners.delete(listener);
194
+ };
195
+ },
196
+ snapshot() {
197
+ sync();
198
+ return { elapsedMs, paused, speed };
199
+ },
200
+ restore(snapshot) {
201
+ elapsedMs = Number.isFinite(snapshot.elapsedMs) ? snapshot.elapsedMs : 0;
202
+ paused = snapshot.paused;
203
+ speed = positiveOr(snapshot.speed, 1);
204
+ anchor = now();
205
+ notify();
206
+ },
207
+ };
208
+ }