@jgengine/core 0.8.0 → 0.9.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 (228) hide show
  1. package/CHANGELOG.md +16 -0
  2. package/dist/ai/flock.d.ts +36 -0
  3. package/dist/ai/flock.js +182 -0
  4. package/dist/ai/groupAssist.d.ts +19 -0
  5. package/dist/ai/groupAssist.js +59 -0
  6. package/dist/ai/laneSelect.d.ts +21 -0
  7. package/dist/ai/laneSelect.js +37 -0
  8. package/dist/ai/mobBrain.d.ts +68 -0
  9. package/dist/ai/mobBrain.js +115 -0
  10. package/dist/ai/spawnDirector.js +1 -1
  11. package/dist/ai/threat.d.ts +2 -0
  12. package/dist/ai/threat.js +34 -0
  13. package/dist/cartridge/runtime.d.ts +15 -0
  14. package/dist/cartridge/runtime.js +556 -0
  15. package/dist/cartridge/spec.d.ts +188 -0
  16. package/dist/cartridge/spec.js +19 -0
  17. package/dist/cartridge/validate.d.ts +2 -0
  18. package/dist/cartridge/validate.js +78 -0
  19. package/dist/combat/abilityKit.d.ts +17 -2
  20. package/dist/combat/abilityKit.js +62 -13
  21. package/dist/combat/castRunner.d.ts +40 -0
  22. package/dist/combat/castRunner.js +56 -0
  23. package/dist/combat/comboPoints.d.ts +14 -0
  24. package/dist/combat/comboPoints.js +52 -0
  25. package/dist/combat/defensiveWindow.d.ts +1 -1
  26. package/dist/combat/defensiveWindow.js +5 -3
  27. package/dist/combat/projectiles.d.ts +33 -12
  28. package/dist/combat/projectiles.js +201 -129
  29. package/dist/combat/resistance.d.ts +16 -4
  30. package/dist/combat/resistance.js +43 -7
  31. package/dist/combat/resourcePool.d.ts +20 -0
  32. package/dist/combat/resourcePool.js +55 -0
  33. package/dist/combat/shotOrigin.d.ts +40 -0
  34. package/dist/combat/shotOrigin.js +57 -0
  35. package/dist/combat/telegraph.d.ts +25 -0
  36. package/dist/combat/telegraph.js +47 -0
  37. package/dist/crafting/recipe.js +4 -1
  38. package/dist/devtools/devtools.d.ts +72 -24
  39. package/dist/devtools/devtools.js +449 -101
  40. package/dist/devtools/transformTunables.js +3 -3
  41. package/dist/devtools/tunableSchema.d.ts +140 -0
  42. package/dist/devtools/tunableSchema.js +476 -0
  43. package/dist/faction/factions.d.ts +33 -0
  44. package/dist/faction/factions.js +76 -0
  45. package/dist/faction/reputation.d.ts +32 -0
  46. package/dist/faction/reputation.js +82 -0
  47. package/dist/game/chat.d.ts +2 -0
  48. package/dist/game/chat.js +9 -1
  49. package/dist/game/chatFilter.d.ts +15 -0
  50. package/dist/game/chatFilter.js +55 -0
  51. package/dist/game/controlGate.d.ts +4 -0
  52. package/dist/game/controlGate.js +7 -0
  53. package/dist/game/defineGame.d.ts +2 -0
  54. package/dist/game/events.d.ts +10 -0
  55. package/dist/game/gamePhase.d.ts +20 -0
  56. package/dist/game/gamePhase.js +21 -0
  57. package/dist/game/lootTable.d.ts +10 -1
  58. package/dist/game/lootTable.js +27 -7
  59. package/dist/game/playableGame.d.ts +50 -1
  60. package/dist/game/playableGame.js +1 -1
  61. package/dist/game/quest.d.ts +7 -3
  62. package/dist/game/quest.js +17 -8
  63. package/dist/game/race.d.ts +39 -1
  64. package/dist/game/race.js +96 -11
  65. package/dist/game/recordBook.d.ts +29 -0
  66. package/dist/game/recordBook.js +86 -0
  67. package/dist/game/talents.d.ts +47 -0
  68. package/dist/game/talents.js +140 -0
  69. package/dist/index.d.ts +1 -0
  70. package/dist/index.js +1 -0
  71. package/dist/input/axisInput.d.ts +26 -3
  72. package/dist/input/axisInput.js +44 -4
  73. package/dist/input/bindingOverrides.d.ts +25 -0
  74. package/dist/input/bindingOverrides.js +95 -0
  75. package/dist/input/pointerAxis.d.ts +30 -0
  76. package/dist/input/pointerAxis.js +31 -0
  77. package/dist/input/touchScheme.d.ts +6 -0
  78. package/dist/input/touchScheme.js +45 -7
  79. package/dist/meta/changelog.d.ts +1 -1
  80. package/dist/meta/changelog.js +23 -1
  81. package/dist/movement/dash.d.ts +3 -0
  82. package/dist/movement/dash.js +19 -5
  83. package/dist/movement/glideModel.d.ts +44 -0
  84. package/dist/movement/glideModel.js +54 -0
  85. package/dist/movement/grappleSwing.d.ts +34 -0
  86. package/dist/movement/grappleSwing.js +56 -0
  87. package/dist/movement/leaderTrail.d.ts +29 -0
  88. package/dist/movement/leaderTrail.js +67 -0
  89. package/dist/movement/steering.d.ts +27 -0
  90. package/dist/movement/steering.js +40 -0
  91. package/dist/multiplayer/feedWriteGate.d.ts +10 -0
  92. package/dist/multiplayer/feedWriteGate.js +12 -0
  93. package/dist/multiplayer/presenceModel.d.ts +14 -2
  94. package/dist/multiplayer/presenceModel.js +15 -3
  95. package/dist/nav/corridors.d.ts +30 -0
  96. package/dist/nav/corridors.js +69 -0
  97. package/dist/nav/navGrid.js +30 -4
  98. package/dist/nav/railGraph.d.ts +66 -0
  99. package/dist/nav/railGraph.js +129 -0
  100. package/dist/nav/timetable.d.ts +41 -0
  101. package/dist/nav/timetable.js +94 -0
  102. package/dist/physics/buoyancy.d.ts +6 -0
  103. package/dist/physics/buoyancy.js +14 -0
  104. package/dist/physics/flowTube.d.ts +32 -0
  105. package/dist/physics/flowTube.js +64 -0
  106. package/dist/physics/forceVolume.d.ts +27 -0
  107. package/dist/physics/forceVolume.js +38 -0
  108. package/dist/physics/kinematicVehicle.d.ts +52 -0
  109. package/dist/physics/kinematicVehicle.js +81 -0
  110. package/dist/physics/physicsWorld.d.ts +13 -6
  111. package/dist/physics/physicsWorld.js +169 -40
  112. package/dist/physics/ragdoll.js +1 -1
  113. package/dist/physics/vehicleBody.js +2 -1
  114. package/dist/random/seedLink.d.ts +5 -0
  115. package/dist/random/seedLink.js +35 -0
  116. package/dist/runtime/cameraDirector.d.ts +6 -1
  117. package/dist/runtime/cameraDirector.js +6 -0
  118. package/dist/runtime/commandRunner.d.ts +2 -2
  119. package/dist/runtime/commandRunner.js +2 -2
  120. package/dist/runtime/gameContext.d.ts +21 -0
  121. package/dist/runtime/gameContext.js +184 -9
  122. package/dist/runtime/gameRuntime.d.ts +5 -1
  123. package/dist/runtime/gameRuntime.js +13 -12
  124. package/dist/runtime/hostPersistence.d.ts +9 -1
  125. package/dist/runtime/hostPersistence.js +23 -8
  126. package/dist/runtime/inputSnapshot.d.ts +5 -0
  127. package/dist/runtime/inputSnapshot.js +5 -0
  128. package/dist/runtime/motionIntents.d.ts +14 -6
  129. package/dist/runtime/motionIntents.js +20 -2
  130. package/dist/runtime/snapshot.d.ts +1 -0
  131. package/dist/runtime/snapshot.js +1 -1
  132. package/dist/runtime/visibility.d.ts +74 -0
  133. package/dist/runtime/visibility.js +127 -0
  134. package/dist/scene/colliders.d.ts +44 -0
  135. package/dist/scene/colliders.js +77 -0
  136. package/dist/scene/entityStore.d.ts +22 -5
  137. package/dist/scene/entityStore.js +59 -12
  138. package/dist/scene/objectQuery.d.ts +13 -2
  139. package/dist/scene/objectQuery.js +34 -2
  140. package/dist/scene/objectStore.d.ts +2 -0
  141. package/dist/scene/objectStore.js +31 -12
  142. package/dist/scene/sceneRaycast.d.ts +80 -0
  143. package/dist/scene/sceneRaycast.js +284 -0
  144. package/dist/scene/spatial.d.ts +11 -16
  145. package/dist/scene/spatial.js +99 -38
  146. package/dist/sensor/recordingBuffer.d.ts +6 -0
  147. package/dist/sensor/recordingBuffer.js +7 -0
  148. package/dist/sensor/replayLoop.d.ts +39 -0
  149. package/dist/sensor/replayLoop.js +64 -0
  150. package/dist/sensor/visionCone.d.ts +31 -0
  151. package/dist/sensor/visionCone.js +72 -0
  152. package/dist/session/extraction.d.ts +2 -2
  153. package/dist/session/extraction.js +13 -2
  154. package/dist/settings/settingsModel.d.ts +97 -0
  155. package/dist/settings/settingsModel.js +100 -0
  156. package/dist/stats/statModifiers.d.ts +5 -2
  157. package/dist/stats/statModifiers.js +32 -14
  158. package/dist/store/observableKeyedStore.js +8 -2
  159. package/dist/survival/decayMeter.d.ts +1 -1
  160. package/dist/survival/decayMeter.js +0 -1
  161. package/dist/time/beatClock.d.ts +18 -0
  162. package/dist/time/beatClock.js +24 -0
  163. package/dist/time/idleProgress.d.ts +29 -0
  164. package/dist/time/idleProgress.js +31 -0
  165. package/dist/time/serverTick.d.ts +7 -1
  166. package/dist/time/serverTick.js +27 -4
  167. package/dist/time/stateSchedule.d.ts +57 -0
  168. package/dist/time/stateSchedule.js +121 -0
  169. package/dist/turn/commit.d.ts +2 -2
  170. package/dist/turn/commit.js +2 -2
  171. package/dist/ui/hudLayout.d.ts +58 -0
  172. package/dist/ui/hudLayout.js +207 -0
  173. package/dist/ui/hudScale.d.ts +44 -0
  174. package/dist/ui/hudScale.js +55 -0
  175. package/dist/visibility/assetStreaming.d.ts +66 -0
  176. package/dist/visibility/assetStreaming.js +201 -0
  177. package/dist/visibility/bounds.d.ts +66 -0
  178. package/dist/visibility/bounds.js +113 -0
  179. package/dist/visibility/camera.d.ts +21 -0
  180. package/dist/visibility/camera.js +3 -0
  181. package/dist/visibility/config.d.ts +21 -0
  182. package/dist/visibility/config.js +1 -0
  183. package/dist/visibility/diagnostics.d.ts +26 -0
  184. package/dist/visibility/diagnostics.js +36 -0
  185. package/dist/visibility/distance.d.ts +11 -0
  186. package/dist/visibility/distance.js +30 -0
  187. package/dist/visibility/frustum.d.ts +47 -0
  188. package/dist/visibility/frustum.js +186 -0
  189. package/dist/visibility/occlusion.d.ts +36 -0
  190. package/dist/visibility/occlusion.js +45 -0
  191. package/dist/visibility/settings.d.ts +75 -0
  192. package/dist/visibility/settings.js +49 -0
  193. package/dist/visibility/simulationCulling.d.ts +34 -0
  194. package/dist/visibility/simulationCulling.js +36 -0
  195. package/dist/visibility/spatialIndex.d.ts +45 -0
  196. package/dist/visibility/spatialIndex.js +198 -0
  197. package/dist/visibility/visibilitySystem.d.ts +90 -0
  198. package/dist/visibility/visibilitySystem.js +257 -0
  199. package/dist/world/buildings.d.ts +6 -0
  200. package/dist/world/buildings.js +152 -0
  201. package/dist/world/carve.d.ts +7 -2
  202. package/dist/world/carve.js +17 -7
  203. package/dist/world/cellStates.d.ts +45 -0
  204. package/dist/world/cellStates.js +103 -0
  205. package/dist/world/environmentSummary.d.ts +10 -2
  206. package/dist/world/environmentSummary.js +11 -2
  207. package/dist/world/features.d.ts +56 -8
  208. package/dist/world/features.js +24 -3
  209. package/dist/world/gridInstances.d.ts +7 -8
  210. package/dist/world/gridInstances.js +145 -7
  211. package/dist/world/lod.d.ts +17 -0
  212. package/dist/world/lod.js +66 -0
  213. package/dist/world/mapLayers.d.ts +60 -0
  214. package/dist/world/mapLayers.js +44 -0
  215. package/dist/world/massing.d.ts +45 -0
  216. package/dist/world/massing.js +581 -0
  217. package/dist/world/minimap.d.ts +13 -1
  218. package/dist/world/minimap.js +21 -2
  219. package/dist/world/terrain.d.ts +42 -3
  220. package/dist/world/terrain.js +197 -16
  221. package/dist/world/water.d.ts +7 -0
  222. package/dist/world/water.js +19 -4
  223. package/dist/world/weather.d.ts +3 -18
  224. package/dist/world/weather.js +4 -17
  225. package/dist/world/windZones.d.ts +47 -0
  226. package/dist/world/windZones.js +59 -0
  227. package/llms.txt +2585 -1704
  228. package/package.json +12 -2
@@ -0,0 +1,29 @@
1
+ export interface IdleWindowConfig {
2
+ maxSeconds?: number;
3
+ efficiency?: number;
4
+ }
5
+ export interface IdleWindow {
6
+ elapsedSeconds: number;
7
+ effectiveSeconds: number;
8
+ capped: boolean;
9
+ }
10
+ export declare function idleWindow(lastSeenMs: number, nowMs: number, config?: IdleWindowConfig): IdleWindow;
11
+ export interface LinearCatchUpInput {
12
+ current: number;
13
+ ratePerSecond: number;
14
+ min?: number;
15
+ max?: number;
16
+ }
17
+ export declare function linearCatchUp(seconds: number, input: LinearCatchUpInput): number;
18
+ export interface ExponentialCatchUpInput {
19
+ current: number;
20
+ factorPerSecond: number;
21
+ min?: number;
22
+ max?: number;
23
+ }
24
+ export declare function exponentialCatchUp(seconds: number, input: ExponentialCatchUpInput): number;
25
+ export interface SteppedCatchUpResult {
26
+ steps: number;
27
+ remainderSeconds: number;
28
+ }
29
+ export declare function steppedCatchUp(seconds: number, stepSeconds: number, step: (index: number) => void, maxSteps?: number): SteppedCatchUpResult;
@@ -0,0 +1,31 @@
1
+ export function idleWindow(lastSeenMs, nowMs, config = {}) {
2
+ const elapsedSeconds = Math.max(0, (nowMs - lastSeenMs) / 1000);
3
+ const capped = config.maxSeconds !== undefined && elapsedSeconds > config.maxSeconds;
4
+ const boundedSeconds = Math.min(elapsedSeconds, config.maxSeconds ?? Number.POSITIVE_INFINITY);
5
+ const efficiency = Math.max(0, config.efficiency ?? 1);
6
+ return {
7
+ elapsedSeconds,
8
+ effectiveSeconds: boundedSeconds * efficiency,
9
+ capped,
10
+ };
11
+ }
12
+ export function linearCatchUp(seconds, input) {
13
+ const clampedSeconds = Math.max(0, seconds);
14
+ const value = input.current + input.ratePerSecond * clampedSeconds;
15
+ return Math.min(input.max ?? Number.POSITIVE_INFINITY, Math.max(input.min ?? Number.NEGATIVE_INFINITY, value));
16
+ }
17
+ export function exponentialCatchUp(seconds, input) {
18
+ const clampedSeconds = Math.max(0, seconds);
19
+ const factor = Math.max(0, input.factorPerSecond);
20
+ const value = input.current * factor ** clampedSeconds;
21
+ return Math.min(input.max ?? Number.POSITIVE_INFINITY, Math.max(input.min ?? Number.NEGATIVE_INFINITY, value));
22
+ }
23
+ export function steppedCatchUp(seconds, stepSeconds, step, maxSteps) {
24
+ if (stepSeconds <= 0 || seconds <= 0) {
25
+ return { steps: 0, remainderSeconds: Math.max(0, seconds) };
26
+ }
27
+ const steps = Math.min(Math.floor(seconds / stepSeconds), maxSteps ?? Number.POSITIVE_INFINITY);
28
+ for (let index = 0; index < steps; index++)
29
+ step(index);
30
+ return { steps, remainderSeconds: seconds - steps * stepSeconds };
31
+ }
@@ -9,8 +9,14 @@ export interface ServerTickPlan<TSystemId extends string = string> {
9
9
  due: TSystemId[];
10
10
  anchors: TickAnchors;
11
11
  }
12
+ export type PlanServerTickOptions = {
13
+ /** Cap catch-up runs per system per heartbeat to avoid spiral-of-death. Default 3. */
14
+ maxCatchUp?: number;
15
+ };
12
16
  /**
13
17
  * Decides which systems a heartbeat should run. A system with no anchor runs immediately.
18
+ * When wall time stalls past multiple intervals, the system id is repeated (bounded by
19
+ * maxCatchUp). Excess lag past the bound resyncs the anchor to `now`.
14
20
  * Returned anchors carry only systems present in the pipeline, so removed systems clean up.
15
21
  */
16
- export declare function planServerTick<TSystemId extends string>(systems: readonly TickSystemDefinition<TSystemId>[], anchors: TickAnchors, now: number): ServerTickPlan<TSystemId>;
22
+ export declare function planServerTick<TSystemId extends string>(systems: readonly TickSystemDefinition<TSystemId>[], anchors: TickAnchors, now: number, options?: PlanServerTickOptions): ServerTickPlan<TSystemId>;
@@ -1,18 +1,41 @@
1
+ const DEFAULT_MAX_CATCH_UP = 3;
1
2
  /**
2
3
  * Decides which systems a heartbeat should run. A system with no anchor runs immediately.
4
+ * When wall time stalls past multiple intervals, the system id is repeated (bounded by
5
+ * maxCatchUp). Excess lag past the bound resyncs the anchor to `now`.
3
6
  * Returned anchors carry only systems present in the pipeline, so removed systems clean up.
4
7
  */
5
- export function planServerTick(systems, anchors, now) {
8
+ export function planServerTick(systems, anchors, now, options) {
9
+ const maxCatchUp = options?.maxCatchUp ?? DEFAULT_MAX_CATCH_UP;
6
10
  const due = [];
7
11
  const nextAnchors = {};
8
12
  for (const system of systems) {
9
13
  const lastRunAt = anchors[system.id];
10
- if (lastRunAt !== undefined && now - lastRunAt < system.intervalMs) {
14
+ if (lastRunAt === undefined) {
15
+ due.push(system.id);
16
+ nextAnchors[system.id] = now;
17
+ continue;
18
+ }
19
+ if (system.intervalMs <= 0) {
20
+ due.push(system.id);
21
+ nextAnchors[system.id] = now;
22
+ continue;
23
+ }
24
+ const elapsed = now - lastRunAt;
25
+ if (elapsed < system.intervalMs) {
11
26
  nextAnchors[system.id] = lastRunAt;
12
27
  continue;
13
28
  }
14
- due.push(system.id);
15
- nextAnchors[system.id] = now;
29
+ const missed = Math.floor(elapsed / system.intervalMs);
30
+ if (missed > maxCatchUp) {
31
+ for (let i = 0; i < maxCatchUp; i += 1)
32
+ due.push(system.id);
33
+ nextAnchors[system.id] = now;
34
+ continue;
35
+ }
36
+ for (let i = 0; i < missed; i += 1)
37
+ due.push(system.id);
38
+ nextAnchors[system.id] = lastRunAt + missed * system.intervalMs;
16
39
  }
17
40
  return { due, anchors: nextAnchors };
18
41
  }
@@ -0,0 +1,57 @@
1
+ export interface SchedulePhase<T> {
2
+ state: T;
3
+ durationSeconds: number;
4
+ }
5
+ export interface StateScheduleConfig<T> {
6
+ phases: readonly SchedulePhase<T>[];
7
+ /** Repeat the phase list forever (default); `false` clamps to the last phase after one pass. */
8
+ loop?: boolean;
9
+ /** Shifts the whole timeline so `t = 0` lands `offsetSeconds` into the cycle — staggers identical schedules. */
10
+ offsetSeconds?: number;
11
+ }
12
+ export interface ScheduleSample<T> {
13
+ state: T;
14
+ /** Index into `phases`. */
15
+ index: number;
16
+ elapsedSeconds: number;
17
+ /** Seconds until this phase ends; `Infinity` on the clamped final phase of a non-looping schedule. */
18
+ remainingSeconds: number;
19
+ /** Elapsed fraction of the phase in `[0, 1]`; `1` on the clamped final phase. */
20
+ fraction: number;
21
+ }
22
+ export interface ScheduleWindow {
23
+ start: number;
24
+ end: number;
25
+ }
26
+ /**
27
+ * A deterministic timeline of states — position-in-schedule is a pure function of absolute time,
28
+ * so a forecast preview and the live state can never disagree. One primitive behind zoned weather
29
+ * shifts, hazard cycles, and timetabled machinery: `stateAt`/`sampleAt` for the live state,
30
+ * `nextTransitionAt` for countdowns, `windowsOf`/`nextWindow` for "when is it safe" forecasts.
31
+ */
32
+ export interface StateSchedule<T> {
33
+ /** Sum of all phase durations — one full cycle. */
34
+ readonly cycleSeconds: number;
35
+ stateAt(t: number): T;
36
+ sampleAt(t: number): ScheduleSample<T>;
37
+ /** Absolute time of the next phase boundary after `t`; `Infinity` once a non-looping schedule ends. */
38
+ nextTransitionAt(t: number): number;
39
+ /** All windows within `[fromT, fromT + horizonSeconds]` where the state matches. */
40
+ windowsOf(match: (state: T) => boolean, fromT: number, horizonSeconds: number): ScheduleWindow[];
41
+ /** The first matching window at or after `fromT` (looking ahead `horizonSeconds`, default one full cycle). */
42
+ nextWindow(match: (state: T) => boolean, fromT: number, horizonSeconds?: number): ScheduleWindow | null;
43
+ }
44
+ export declare function createStateSchedule<T>(config: StateScheduleConfig<T>): StateSchedule<T>;
45
+ export interface ClearWindowScan {
46
+ fromSeconds: number;
47
+ horizonSeconds: number;
48
+ /** Sampling resolution; smaller catches shorter gaps. */
49
+ stepSeconds: number;
50
+ /** Reject windows shorter than this; default `0`. */
51
+ minDurationSeconds?: number;
52
+ }
53
+ /**
54
+ * Forward-scan any predicate-of-time — a timetable mover's "is the crossing clear at `t`" — for the
55
+ * next open window. Sampling-based: pick `stepSeconds` at or below half the shortest gap that matters.
56
+ */
57
+ export declare function nextClearWindow(isClear: (t: number) => boolean, scan: ClearWindowScan): ScheduleWindow | null;
@@ -0,0 +1,121 @@
1
+ export function createStateSchedule(config) {
2
+ if (config.phases.length === 0)
3
+ throw new Error("state schedule needs at least one phase");
4
+ const phases = config.phases.map((phase) => {
5
+ if (!(phase.durationSeconds > 0)) {
6
+ throw new Error(`schedule phase duration must be positive, got ${phase.durationSeconds}`);
7
+ }
8
+ return phase;
9
+ });
10
+ const loop = config.loop ?? true;
11
+ const offset = config.offsetSeconds ?? 0;
12
+ const starts = [];
13
+ let cycleSeconds = 0;
14
+ for (const phase of phases) {
15
+ starts.push(cycleSeconds);
16
+ cycleSeconds += phase.durationSeconds;
17
+ }
18
+ function localTime(t) {
19
+ const shifted = t + offset;
20
+ if (loop)
21
+ return ((shifted % cycleSeconds) + cycleSeconds) % cycleSeconds;
22
+ return Math.max(0, shifted);
23
+ }
24
+ function sampleAt(t) {
25
+ const local = localTime(t);
26
+ if (!loop && local >= cycleSeconds) {
27
+ const index = phases.length - 1;
28
+ const phase = phases[index];
29
+ return {
30
+ state: phase.state,
31
+ index,
32
+ elapsedSeconds: local - starts[index],
33
+ remainingSeconds: Number.POSITIVE_INFINITY,
34
+ fraction: 1,
35
+ };
36
+ }
37
+ let index = phases.length - 1;
38
+ for (let i = 0; i < phases.length; i += 1) {
39
+ if (local < starts[i] + phases[i].durationSeconds) {
40
+ index = i;
41
+ break;
42
+ }
43
+ }
44
+ const phase = phases[index];
45
+ const elapsed = local - starts[index];
46
+ return {
47
+ state: phase.state,
48
+ index,
49
+ elapsedSeconds: elapsed,
50
+ remainingSeconds: phase.durationSeconds - elapsed,
51
+ fraction: Math.max(0, Math.min(1, elapsed / phase.durationSeconds)),
52
+ };
53
+ }
54
+ function nextTransitionAt(t) {
55
+ const sample = sampleAt(t);
56
+ if (!Number.isFinite(sample.remainingSeconds))
57
+ return Number.POSITIVE_INFINITY;
58
+ return t + sample.remainingSeconds;
59
+ }
60
+ function windowsOf(match, fromT, horizonSeconds) {
61
+ const windows = [];
62
+ const end = fromT + horizonSeconds;
63
+ let cursor = fromT;
64
+ let open = null;
65
+ let guard = phases.length * (Math.ceil(horizonSeconds / cycleSeconds) + 2) + 2;
66
+ while (cursor < end && guard > 0) {
67
+ guard -= 1;
68
+ const sample = sampleAt(cursor);
69
+ const matches = match(sample.state);
70
+ if (matches && open === null)
71
+ open = cursor;
72
+ const boundary = Number.isFinite(sample.remainingSeconds) ? cursor + sample.remainingSeconds : end;
73
+ const stop = Math.min(boundary, end);
74
+ if (!matches && open !== null) {
75
+ windows.push({ start: open, end: cursor });
76
+ open = null;
77
+ }
78
+ cursor = stop;
79
+ if (!Number.isFinite(sample.remainingSeconds))
80
+ break;
81
+ }
82
+ if (open !== null)
83
+ windows.push({ start: open, end: Math.min(cursor, end) });
84
+ return windows;
85
+ }
86
+ return {
87
+ cycleSeconds,
88
+ stateAt: (t) => sampleAt(t).state,
89
+ sampleAt,
90
+ nextTransitionAt,
91
+ windowsOf,
92
+ nextWindow(match, fromT, horizonSeconds = cycleSeconds) {
93
+ const windows = windowsOf(match, fromT, horizonSeconds);
94
+ return windows.length > 0 ? windows[0] : null;
95
+ },
96
+ };
97
+ }
98
+ /**
99
+ * Forward-scan any predicate-of-time — a timetable mover's "is the crossing clear at `t`" — for the
100
+ * next open window. Sampling-based: pick `stepSeconds` at or below half the shortest gap that matters.
101
+ */
102
+ export function nextClearWindow(isClear, scan) {
103
+ const minDuration = scan.minDurationSeconds ?? 0;
104
+ const end = scan.fromSeconds + scan.horizonSeconds;
105
+ let open = null;
106
+ for (let t = scan.fromSeconds; t <= end; t += scan.stepSeconds) {
107
+ if (isClear(t)) {
108
+ if (open === null)
109
+ open = t;
110
+ continue;
111
+ }
112
+ if (open !== null) {
113
+ if (t - open >= minDuration)
114
+ return { start: open, end: t };
115
+ open = null;
116
+ }
117
+ }
118
+ if (open !== null && end - open >= minDuration)
119
+ return { start: open, end };
120
+ return null;
121
+ }
@@ -1,4 +1,4 @@
1
- export type CommitMode = "immediate" | "simultaneous" | "rewind";
1
+ export type CommitMode = "immediate" | "simultaneous" | "resealable";
2
2
  export interface SubmittedAction<TAction> {
3
3
  participant: string;
4
4
  action: TAction;
@@ -21,7 +21,7 @@ export interface CommitController<TAction> {
21
21
  pending(): SubmittedAction<TAction>[];
22
22
  reveal(): SubmittedAction<TAction>[];
23
23
  commit(): SubmittedAction<TAction>[];
24
- rewind(): SubmittedAction<TAction>[];
24
+ discard(): SubmittedAction<TAction>[];
25
25
  clear(): void;
26
26
  }
27
27
  export declare function createCommitController<TAction>(config: CommitControllerConfig): CommitController<TAction>;
@@ -31,7 +31,7 @@ export function createCommitController(config) {
31
31
  if (mode === "immediate") {
32
32
  return { status: "committed", committed: [{ participant, action }] };
33
33
  }
34
- if (sealed.has(participant) && !(mode === "rewind")) {
34
+ if (sealed.has(participant) && mode !== "resealable") {
35
35
  return { status: "rejected", committed: [], reason: "already-submitted" };
36
36
  }
37
37
  sealed.set(participant, action);
@@ -62,7 +62,7 @@ export function createCommitController(config) {
62
62
  clear();
63
63
  return out;
64
64
  },
65
- rewind() {
65
+ discard() {
66
66
  const discarded = orderedSubmissions();
67
67
  clear();
68
68
  return discarded;
@@ -0,0 +1,58 @@
1
+ export type HudAnchor = "top-left" | "top" | "top-right" | "left" | "center" | "right" | "bottom-left" | "bottom" | "bottom-right";
2
+ export interface HudPlacement {
3
+ anchor: HudAnchor;
4
+ dx: number;
5
+ dy: number;
6
+ }
7
+ export interface HudRect {
8
+ x: number;
9
+ y: number;
10
+ width: number;
11
+ height: number;
12
+ }
13
+ export interface HudSize {
14
+ width: number;
15
+ height: number;
16
+ }
17
+ export interface HudPanelState {
18
+ id: string;
19
+ placement: HudPlacement;
20
+ z: number;
21
+ moved: boolean;
22
+ }
23
+ export interface HudLayoutState {
24
+ panels: Record<string, HudPanelState>;
25
+ locked: boolean;
26
+ editing: boolean;
27
+ }
28
+ export interface HudLayoutOptions {
29
+ snap?: number;
30
+ locked?: boolean;
31
+ }
32
+ export declare function isPanelDraggable(state: HudLayoutState, id: string): boolean;
33
+ export interface HudLayoutStore {
34
+ getState(): HudLayoutState;
35
+ subscribe(listener: (state: HudLayoutState) => void): () => void;
36
+ register(id: string, placement: HudPlacement): void;
37
+ move(id: string, rect: HudRect, viewport: HudSize): void;
38
+ bringToFront(id: string): void;
39
+ setLocked(locked: boolean): void;
40
+ setEditing(editing: boolean): void;
41
+ reset(id?: string): void;
42
+ serialize(): string;
43
+ hydrate(raw: string | null | undefined): void;
44
+ }
45
+ export declare const HUD_ANCHOR_FRACTIONS: Record<HudAnchor, {
46
+ fx: number;
47
+ fy: number;
48
+ }>;
49
+ export declare function isHudAnchor(value: unknown): value is HudAnchor;
50
+ export declare function anchoredPlacement(anchor: HudAnchor, inset: {
51
+ x: number;
52
+ y: number;
53
+ }): HudPlacement;
54
+ export declare function nearestAnchor(rect: HudRect, viewport: HudSize): HudAnchor;
55
+ export declare function clampRect(rect: HudRect, viewport: HudSize): HudRect;
56
+ export declare function placementFromRect(rect: HudRect, viewport: HudSize, snap?: number): HudPlacement;
57
+ export declare function rectFromPlacement(placement: HudPlacement, size: HudSize, viewport: HudSize): HudRect;
58
+ export declare function createHudLayout(options?: HudLayoutOptions): HudLayoutStore;
@@ -0,0 +1,207 @@
1
+ export function isPanelDraggable(state, id) {
2
+ return state.panels[id] !== undefined && (state.editing || !state.locked);
3
+ }
4
+ export const HUD_ANCHOR_FRACTIONS = {
5
+ "top-left": { fx: 0, fy: 0 },
6
+ top: { fx: 0.5, fy: 0 },
7
+ "top-right": { fx: 1, fy: 0 },
8
+ left: { fx: 0, fy: 0.5 },
9
+ center: { fx: 0.5, fy: 0.5 },
10
+ right: { fx: 1, fy: 0.5 },
11
+ "bottom-left": { fx: 0, fy: 1 },
12
+ bottom: { fx: 0.5, fy: 1 },
13
+ "bottom-right": { fx: 1, fy: 1 },
14
+ };
15
+ const HUD_ANCHORS = Object.keys(HUD_ANCHOR_FRACTIONS);
16
+ export function isHudAnchor(value) {
17
+ return typeof value === "string" && value in HUD_ANCHOR_FRACTIONS;
18
+ }
19
+ export function anchoredPlacement(anchor, inset) {
20
+ const { fx, fy } = HUD_ANCHOR_FRACTIONS[anchor];
21
+ return {
22
+ anchor,
23
+ dx: fx === 1 ? -inset.x : inset.x,
24
+ dy: fy === 1 ? -inset.y : inset.y,
25
+ };
26
+ }
27
+ export function nearestAnchor(rect, viewport) {
28
+ const cx = viewport.width > 0 ? (rect.x + rect.width / 2) / viewport.width : 0.5;
29
+ const cy = viewport.height > 0 ? (rect.y + rect.height / 2) / viewport.height : 0.5;
30
+ let best = "center";
31
+ let bestDist = Infinity;
32
+ for (const anchor of HUD_ANCHORS) {
33
+ const { fx, fy } = HUD_ANCHOR_FRACTIONS[anchor];
34
+ const dist = (cx - fx) * (cx - fx) + (cy - fy) * (cy - fy);
35
+ if (dist < bestDist) {
36
+ bestDist = dist;
37
+ best = anchor;
38
+ }
39
+ }
40
+ return best;
41
+ }
42
+ export function clampRect(rect, viewport) {
43
+ return {
44
+ x: Math.max(0, Math.min(rect.x, viewport.width - rect.width)),
45
+ y: Math.max(0, Math.min(rect.y, viewport.height - rect.height)),
46
+ width: rect.width,
47
+ height: rect.height,
48
+ };
49
+ }
50
+ export function placementFromRect(rect, viewport, snap = 1) {
51
+ const clamped = clampRect(rect, viewport);
52
+ const anchor = nearestAnchor(clamped, viewport);
53
+ const { fx, fy } = HUD_ANCHOR_FRACTIONS[anchor];
54
+ const step = snap > 0 ? snap : 1;
55
+ const dx = clamped.x + fx * clamped.width - fx * viewport.width;
56
+ const dy = clamped.y + fy * clamped.height - fy * viewport.height;
57
+ return {
58
+ anchor,
59
+ dx: Math.round(dx / step) * step,
60
+ dy: Math.round(dy / step) * step,
61
+ };
62
+ }
63
+ export function rectFromPlacement(placement, size, viewport) {
64
+ const { fx, fy } = HUD_ANCHOR_FRACTIONS[placement.anchor];
65
+ return {
66
+ x: fx * viewport.width + placement.dx - fx * size.width,
67
+ y: fy * viewport.height + placement.dy - fy * size.height,
68
+ width: size.width,
69
+ height: size.height,
70
+ };
71
+ }
72
+ function parseSerialized(raw) {
73
+ const out = new Map();
74
+ let data;
75
+ try {
76
+ data = JSON.parse(raw);
77
+ }
78
+ catch {
79
+ return out;
80
+ }
81
+ if (typeof data !== "object" || data === null)
82
+ return out;
83
+ const panels = data.panels;
84
+ if (typeof panels !== "object" || panels === null)
85
+ return out;
86
+ for (const [id, value] of Object.entries(panels)) {
87
+ if (typeof value !== "object" || value === null)
88
+ continue;
89
+ const { anchor, dx, dy } = value;
90
+ if (!isHudAnchor(anchor))
91
+ continue;
92
+ if (typeof dx !== "number" || !Number.isFinite(dx))
93
+ continue;
94
+ if (typeof dy !== "number" || !Number.isFinite(dy))
95
+ continue;
96
+ out.set(id, { anchor, dx, dy });
97
+ }
98
+ return out;
99
+ }
100
+ export function createHudLayout(options) {
101
+ const snap = options?.snap ?? 1;
102
+ const defaults = new Map();
103
+ const pending = new Map();
104
+ let topZ = 0;
105
+ let state = { panels: {}, locked: options?.locked ?? true, editing: false };
106
+ const listeners = new Set();
107
+ const emit = () => {
108
+ for (const listener of [...listeners])
109
+ listener(state);
110
+ };
111
+ const setPanel = (panel) => {
112
+ state = { ...state, panels: { ...state.panels, [panel.id]: panel } };
113
+ };
114
+ return {
115
+ getState: () => state,
116
+ subscribe(listener) {
117
+ listeners.add(listener);
118
+ return () => listeners.delete(listener);
119
+ },
120
+ register(id, placement) {
121
+ defaults.set(id, placement);
122
+ if (state.panels[id] !== undefined)
123
+ return;
124
+ const override = pending.get(id);
125
+ setPanel({
126
+ id,
127
+ placement: override ?? placement,
128
+ z: ++topZ,
129
+ moved: override !== undefined,
130
+ });
131
+ },
132
+ move(id, rect, viewport) {
133
+ const panel = state.panels[id];
134
+ if (panel === undefined)
135
+ return;
136
+ const placement = placementFromRect(rect, viewport, snap);
137
+ setPanel({ ...panel, placement, moved: true });
138
+ emit();
139
+ },
140
+ bringToFront(id) {
141
+ const panel = state.panels[id];
142
+ if (panel === undefined || panel.z === topZ)
143
+ return;
144
+ setPanel({ ...panel, z: ++topZ });
145
+ emit();
146
+ },
147
+ setLocked(locked) {
148
+ if (state.locked === locked)
149
+ return;
150
+ state = { ...state, locked };
151
+ emit();
152
+ },
153
+ setEditing(editing) {
154
+ if (state.editing === editing)
155
+ return;
156
+ state = { ...state, editing };
157
+ emit();
158
+ },
159
+ reset(id) {
160
+ const ids = id === undefined ? Object.keys(state.panels) : [id];
161
+ let changed = false;
162
+ for (const panelId of ids) {
163
+ pending.delete(panelId);
164
+ const panel = state.panels[panelId];
165
+ const fallback = defaults.get(panelId);
166
+ if (panel === undefined || fallback === undefined || !panel.moved)
167
+ continue;
168
+ setPanel({ ...panel, placement: fallback, moved: false });
169
+ changed = true;
170
+ }
171
+ if (changed)
172
+ emit();
173
+ },
174
+ serialize() {
175
+ const panels = {};
176
+ for (const panel of Object.values(state.panels)) {
177
+ if (panel.moved)
178
+ panels[panel.id] = panel.placement;
179
+ }
180
+ for (const [id, placement] of pending) {
181
+ if (state.panels[id] === undefined)
182
+ panels[id] = placement;
183
+ }
184
+ const data = { v: 1, panels };
185
+ return JSON.stringify(data);
186
+ },
187
+ hydrate(raw) {
188
+ if (raw === null || raw === undefined || raw === "")
189
+ return;
190
+ const parsed = parseSerialized(raw);
191
+ if (parsed.size === 0)
192
+ return;
193
+ let changed = false;
194
+ for (const [id, placement] of parsed) {
195
+ const panel = state.panels[id];
196
+ if (panel === undefined) {
197
+ pending.set(id, placement);
198
+ continue;
199
+ }
200
+ setPanel({ ...panel, placement, moved: true });
201
+ changed = true;
202
+ }
203
+ if (changed)
204
+ emit();
205
+ },
206
+ };
207
+ }
@@ -0,0 +1,44 @@
1
+ import type { HudRect, HudSize } from "./hudLayout.js";
2
+ /** Where a game is meant to be played. `"web"` alone keeps today's desktop-first HUD; adding `"mobile"` turns on design-resolution fit scaling on compact displays. */
3
+ export type HudPlatform = "web" | "mobile";
4
+ /**
5
+ * Design-resolution fit for the HUD surface. The game authors its UI against
6
+ * `designSize`; on smaller viewports the whole HUD scales down by the limiting
7
+ * axis ratio, clamped to `[minScale, maxScale]`, so panels keep their layout
8
+ * and simply shrink instead of overflowing.
9
+ */
10
+ export interface HudFitConfig {
11
+ designSize?: HudSize;
12
+ /** Floor for the computed scale — below this, shrinking further makes text unreadable. Default 0.4. */
13
+ minScale?: number;
14
+ /** Ceiling for the computed scale. Default 1 (never upscale past authored size). */
15
+ maxScale?: number;
16
+ }
17
+ /** Per-game HUD viewport declaration carried on `PlayableGame.hudFit`; `mobile` overrides the fit on compact displays so the owner can tune the phone layout separately. */
18
+ export interface HudViewportConfig extends HudFitConfig {
19
+ mobile?: HudFitConfig;
20
+ }
21
+ export declare const DEFAULT_HUD_DESIGN_SIZE: HudSize;
22
+ export declare const DEFAULT_HUD_MIN_SCALE = 0.4;
23
+ export declare const DEFAULT_HUD_MAX_SCALE = 1;
24
+ export declare function resolveHudFit(config: HudViewportConfig | undefined, mobile: boolean): Required<HudFitConfig>;
25
+ /**
26
+ * The one scaling rule for every display: the ratio of the live viewport to
27
+ * the authored design size along the limiting axis, clamped. 1 on a viewport
28
+ * at or above design size; smoothly below 1 down to `minScale` on phones.
29
+ */
30
+ export declare function hudScaleForViewport(fit: Required<HudFitConfig>, viewport: HudSize): number;
31
+ export interface HudOverflow {
32
+ id: string;
33
+ left: number;
34
+ top: number;
35
+ right: number;
36
+ bottom: number;
37
+ }
38
+ /** Non-null when any edge of `rect` falls outside `viewport` by more than `tolerance` px; each field is the overflow distance past that edge (0 when inside). */
39
+ export declare function rectOverflow(rect: HudRect, viewport: HudSize, tolerance?: number): Omit<HudOverflow, "id"> | null;
40
+ /** Every panel rect that escapes the viewport — the data behind the HUD overflow gate. */
41
+ export declare function overflowingPanels(panels: readonly {
42
+ id: string;
43
+ rect: HudRect;
44
+ }[], viewport: HudSize, tolerance?: number): HudOverflow[];