@jgengine/core 0.7.0 → 0.8.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 (159) hide show
  1. package/CHANGELOG.md +47 -0
  2. package/README.md +2 -0
  3. package/dist/ai/spawnDirector.d.ts +5 -0
  4. package/dist/ai/spawnDirector.js +13 -1
  5. package/dist/anim/easing.d.ts +14 -0
  6. package/dist/anim/easing.js +55 -0
  7. package/dist/behaviour/behaviour.d.ts +136 -0
  8. package/dist/behaviour/behaviour.js +388 -0
  9. package/dist/cards/cardPile.d.ts +1 -0
  10. package/dist/cards/cardPile.js +12 -3
  11. package/dist/combat/abilityKit.d.ts +5 -0
  12. package/dist/combat/abilityKit.js +10 -0
  13. package/dist/combat/effects.d.ts +1 -1
  14. package/dist/combat/effects.js +9 -6
  15. package/dist/combat/projectiles.d.ts +42 -5
  16. package/dist/combat/projectiles.js +98 -12
  17. package/dist/combat/telegraph.d.ts +5 -1
  18. package/dist/combat/telegraph.js +14 -0
  19. package/dist/commands/commandRegistry.d.ts +1 -1
  20. package/dist/commands/commandRegistry.js +2 -1
  21. package/dist/data/dataSource.d.ts +26 -0
  22. package/dist/data/dataSource.js +93 -0
  23. package/dist/data/devProxy.d.ts +11 -0
  24. package/dist/data/devProxy.js +38 -0
  25. package/dist/data/fetchJson.d.ts +20 -0
  26. package/dist/data/fetchJson.js +43 -0
  27. package/dist/data/jsonDataSource.d.ts +4 -0
  28. package/dist/data/jsonDataSource.js +6 -0
  29. package/dist/devtools/devtools.d.ts +157 -0
  30. package/dist/devtools/devtools.js +484 -0
  31. package/dist/devtools/transformTunables.d.ts +14 -0
  32. package/dist/devtools/transformTunables.js +38 -0
  33. package/dist/game/chat.d.ts +66 -0
  34. package/dist/game/chat.js +165 -0
  35. package/dist/game/defineGame.d.ts +8 -2
  36. package/dist/game/defineGame.js +2 -1
  37. package/dist/game/events.d.ts +25 -0
  38. package/dist/game/levelSequence.d.ts +44 -0
  39. package/dist/game/levelSequence.js +78 -0
  40. package/dist/game/playableGame.d.ts +237 -6
  41. package/dist/game/playableGame.js +6 -1
  42. package/dist/game/progression.d.ts +1 -0
  43. package/dist/game/progression.js +23 -2
  44. package/dist/game/race.d.ts +5 -0
  45. package/dist/game/race.js +20 -0
  46. package/dist/game/social.d.ts +42 -0
  47. package/dist/game/social.js +122 -0
  48. package/dist/game/spawnPoints.d.ts +19 -0
  49. package/dist/game/spawnPoints.js +23 -0
  50. package/dist/game/worldItem.d.ts +3 -0
  51. package/dist/game/worldItem.js +12 -6
  52. package/dist/input/actionBindings.d.ts +10 -0
  53. package/dist/input/actionBindings.js +14 -0
  54. package/dist/input/gestureSurface.d.ts +31 -0
  55. package/dist/input/gestureSurface.js +120 -0
  56. package/dist/input/pointer.d.ts +45 -0
  57. package/dist/input/pointer.js +48 -0
  58. package/dist/input/touchScheme.d.ts +83 -0
  59. package/dist/input/touchScheme.js +115 -0
  60. package/dist/item/affix.d.ts +1 -1
  61. package/dist/item/affix.js +1 -19
  62. package/dist/meta/changelog.d.ts +1 -1
  63. package/dist/meta/changelog.js +37 -1
  64. package/dist/movement/movementModel.d.ts +36 -3
  65. package/dist/movement/movementModel.js +81 -5
  66. package/dist/movement/voxelController.d.ts +61 -0
  67. package/dist/movement/voxelController.js +229 -0
  68. package/dist/multiplayer/chatContract.d.ts +42 -0
  69. package/dist/multiplayer/chatContract.js +57 -0
  70. package/dist/multiplayer/identity.d.ts +13 -0
  71. package/dist/multiplayer/identity.js +20 -0
  72. package/dist/multiplayer/poseSyncGate.d.ts +1 -0
  73. package/dist/multiplayer/poseSyncGate.js +13 -1
  74. package/dist/multiplayer/presenceModel.d.ts +3 -0
  75. package/dist/multiplayer/presenceModel.js +15 -1
  76. package/dist/multiplayer/voiceContract.d.ts +44 -0
  77. package/dist/multiplayer/voiceContract.js +108 -0
  78. package/dist/nav/navConstrain.d.ts +14 -0
  79. package/dist/nav/navConstrain.js +26 -0
  80. package/dist/nav/navFromEnvironment.d.ts +11 -0
  81. package/dist/nav/navFromEnvironment.js +16 -0
  82. package/dist/nav/navGrid.d.ts +10 -0
  83. package/dist/nav/navGrid.js +18 -1
  84. package/dist/nav/pathFollow.d.ts +4 -2
  85. package/dist/nav/pathFollow.js +5 -3
  86. package/dist/physics/ballisticSweep.d.ts +18 -0
  87. package/dist/physics/ballisticSweep.js +49 -0
  88. package/dist/physics/forceVolume.js +5 -5
  89. package/dist/physics/physicsWorld.d.ts +41 -2
  90. package/dist/physics/physicsWorld.js +232 -40
  91. package/dist/physics/spatialGrid.d.ts +1 -1
  92. package/dist/physics/spatialGrid.js +5 -0
  93. package/dist/physics/vehicleBody.d.ts +6 -0
  94. package/dist/physics/vehicleBody.js +16 -17
  95. package/dist/puzzle/cellGrid.d.ts +26 -0
  96. package/dist/puzzle/cellGrid.js +110 -0
  97. package/dist/puzzle/fallingPiece.d.ts +24 -0
  98. package/dist/puzzle/fallingPiece.js +51 -0
  99. package/dist/random/nameGen.d.ts +18 -0
  100. package/dist/random/nameGen.js +35 -0
  101. package/dist/random/rng.d.ts +2 -0
  102. package/dist/random/rng.js +23 -0
  103. package/dist/runtime/adapter.d.ts +35 -0
  104. package/dist/runtime/adapter.js +37 -1
  105. package/dist/runtime/cameraDirector.d.ts +10 -0
  106. package/dist/runtime/cameraDirector.js +25 -0
  107. package/dist/runtime/gameContext.d.ts +44 -2
  108. package/dist/runtime/gameContext.js +100 -4
  109. package/dist/runtime/hostPersistence.d.ts +21 -0
  110. package/dist/runtime/hostPersistence.js +50 -0
  111. package/dist/runtime/inputSnapshot.d.ts +7 -0
  112. package/dist/runtime/inputSnapshot.js +12 -0
  113. package/dist/runtime/motionIntents.d.ts +25 -0
  114. package/dist/runtime/motionIntents.js +25 -0
  115. package/dist/runtime/transport.d.ts +32 -0
  116. package/dist/scene/entityStore.d.ts +8 -0
  117. package/dist/scene/entityStore.js +25 -2
  118. package/dist/scene/movementSpeed.d.ts +35 -0
  119. package/dist/scene/movementSpeed.js +20 -0
  120. package/dist/scene/objectQuery.d.ts +18 -0
  121. package/dist/scene/objectQuery.js +108 -0
  122. package/dist/scene/objectStore.d.ts +11 -0
  123. package/dist/scene/objectStore.js +109 -4
  124. package/dist/scene/paintLayer.d.ts +15 -0
  125. package/dist/scene/paintLayer.js +53 -0
  126. package/dist/scene/spatial.d.ts +21 -0
  127. package/dist/scene/spatial.js +63 -1
  128. package/dist/sensor/concealment.d.ts +21 -0
  129. package/dist/sensor/concealment.js +63 -0
  130. package/dist/sensor/freezeMonitor.d.ts +17 -0
  131. package/dist/sensor/freezeMonitor.js +35 -0
  132. package/dist/session/roles.d.ts +6 -0
  133. package/dist/session/roles.js +32 -0
  134. package/dist/session/roundState.d.ts +41 -17
  135. package/dist/session/roundState.js +67 -38
  136. package/dist/tactics/fallingGrid.d.ts +62 -0
  137. package/dist/tactics/fallingGrid.js +158 -0
  138. package/dist/tactics/tacticalGrid.d.ts +11 -0
  139. package/dist/tactics/tacticalGrid.js +17 -0
  140. package/dist/time/simClock.d.ts +25 -1
  141. package/dist/time/simClock.js +28 -3
  142. package/dist/turn/intent.d.ts +14 -0
  143. package/dist/turn/intent.js +29 -0
  144. package/dist/turn/turnLoop.d.ts +2 -0
  145. package/dist/turn/turnLoop.js +8 -1
  146. package/dist/world/environmentSummary.d.ts +53 -0
  147. package/dist/world/environmentSummary.js +129 -0
  148. package/dist/world/features.d.ts +87 -6
  149. package/dist/world/features.js +52 -2
  150. package/dist/world/gridInstances.d.ts +13 -0
  151. package/dist/world/gridInstances.js +22 -0
  152. package/dist/world/placementController.d.ts +22 -1
  153. package/dist/world/placementController.js +56 -0
  154. package/dist/world/terrain.d.ts +23 -1
  155. package/dist/world/terrain.js +64 -1
  156. package/dist/world/voxelField.d.ts +54 -0
  157. package/dist/world/voxelField.js +241 -0
  158. package/llms.txt +4102 -0
  159. package/package.json +3 -2
@@ -1,6 +1,17 @@
1
1
  import { createObservableKeyedStore } from "../store/observableKeyedStore.js";
2
+ const CELL_SIZE = 1;
3
+ function cellIndexOf(value) {
4
+ return Math.floor(value / CELL_SIZE);
5
+ }
6
+ function cellKey(cx, cy, cz) {
7
+ return `${cx}:${cy}:${cz}`;
8
+ }
9
+ function cellKeyOf(position) {
10
+ return cellKey(cellIndexOf(position[0]), cellIndexOf(position[1]), cellIndexOf(position[2]));
11
+ }
2
12
  export function createObjectStore() {
3
13
  const store = createObservableKeyedStore();
14
+ const cellIndex = new Map();
4
15
  let nextCounter = 1;
5
16
  function generateId() {
6
17
  let id = `object-${nextCounter}`;
@@ -11,31 +22,58 @@ export function createObjectStore() {
11
22
  nextCounter += 1;
12
23
  return id;
13
24
  }
25
+ function indexAdd(instanceId, position) {
26
+ const key = cellKeyOf(position);
27
+ let bucket = cellIndex.get(key);
28
+ if (bucket === undefined) {
29
+ bucket = new Set();
30
+ cellIndex.set(key, bucket);
31
+ }
32
+ bucket.add(instanceId);
33
+ }
34
+ function indexRemove(instanceId, position) {
35
+ const key = cellKeyOf(position);
36
+ const bucket = cellIndex.get(key);
37
+ if (bucket === undefined)
38
+ return;
39
+ bucket.delete(instanceId);
40
+ if (bucket.size === 0)
41
+ cellIndex.delete(key);
42
+ }
14
43
  return {
15
44
  place(catalogId, x, y, z, options = {}) {
16
45
  if (options.instanceId !== undefined && store.has(options.instanceId)) {
17
46
  throw new Error(`Scene object id "${options.instanceId}" is already placed.`);
18
47
  }
19
48
  const instanceId = options.instanceId ?? generateId();
49
+ const position = [x, y, z];
20
50
  store.set(instanceId, {
21
51
  instanceId,
22
52
  catalogId,
23
- position: [x, y, z],
53
+ position,
24
54
  rotationY: options.rotation ?? 0,
25
55
  ...(options.parentSpace !== undefined ? { parentSpace: options.parentSpace } : {}),
56
+ ...(options.visual !== undefined ? { visual: options.visual } : {}),
26
57
  });
58
+ indexAdd(instanceId, position);
27
59
  return instanceId;
28
60
  },
29
61
  remove(instanceId) {
30
- const existed = store.has(instanceId);
62
+ const current = store.get(instanceId);
63
+ if (!current)
64
+ return false;
31
65
  store.delete(instanceId);
32
- return existed;
66
+ indexRemove(instanceId, current.position);
67
+ return true;
33
68
  },
34
69
  move(instanceId, x, y, z) {
35
70
  const current = store.get(instanceId);
36
71
  if (!current)
37
72
  return false;
38
- store.set(instanceId, { ...current, position: [x, y, z] });
73
+ const position = [x, y, z];
74
+ store.set(instanceId, { ...current, position });
75
+ indexRemove(instanceId, current.position);
76
+ indexAdd(instanceId, position);
39
77
  return true;
40
78
  },
41
79
  rotate(instanceId, rotationY) {
@@ -45,6 +83,20 @@ export function createObjectStore() {
45
83
  store.set(instanceId, { ...current, rotationY });
46
84
  return true;
47
85
  },
86
+ setVisual(instanceId, visual) {
87
+ const current = store.get(instanceId);
88
+ if (!current)
89
+ return false;
90
+ store.set(instanceId, {
91
+ instanceId: current.instanceId,
92
+ catalogId: current.catalogId,
93
+ position: current.position,
94
+ rotationY: current.rotationY,
95
+ ...(current.parentSpace !== undefined ? { parentSpace: current.parentSpace } : {}),
96
+ ...(visual !== undefined ? { visual } : {}),
97
+ });
98
+ return true;
99
+ },
48
100
  get(instanceId) {
49
101
  return store.get(instanceId) ?? null;
50
102
  },
@@ -54,10 +106,57 @@ export function createObjectStore() {
54
106
  return all;
55
107
  return all.filter((object) => object.parentSpace === filter.parentSpace);
56
108
  },
109
+ at(x, y, z, tolerance = 0) {
110
+ const minCx = cellIndexOf(x - tolerance);
111
+ const maxCx = cellIndexOf(x + tolerance);
112
+ const minCy = cellIndexOf(y - tolerance);
113
+ const maxCy = cellIndexOf(y + tolerance);
114
+ const minCz = cellIndexOf(z - tolerance);
115
+ const maxCz = cellIndexOf(z + tolerance);
116
+ const results = [];
117
+ for (let cx = minCx; cx <= maxCx; cx += 1) {
118
+ for (let cy = minCy; cy <= maxCy; cy += 1) {
119
+ for (let cz = minCz; cz <= maxCz; cz += 1) {
120
+ const bucket = cellIndex.get(cellKey(cx, cy, cz));
121
+ if (bucket === undefined)
122
+ continue;
123
+ for (const instanceId of bucket) {
124
+ const object = store.get(instanceId);
125
+ if (object === undefined)
126
+ continue;
127
+ const dx = object.position[0] - x;
128
+ const dy = object.position[1] - y;
129
+ const dz = object.position[2] - z;
130
+ if (Math.hypot(dx, dy, dz) <= tolerance)
131
+ results.push(object);
132
+ }
133
+ }
134
+ }
135
+ }
136
+ return results;
137
+ },
138
+ inBox(min, max) {
139
+ const hits = [];
140
+ for (const bucket of cellIndex.values()) {
141
+ for (const instanceId of bucket) {
142
+ const object = store.get(instanceId);
143
+ if (object === undefined)
144
+ continue;
145
+ const [x, y, z] = object.position;
146
+ if (x >= min[0] && x <= max[0] &&
147
+ y >= min[1] && y <= max[1] &&
148
+ z >= min[2] && z <= max[2]) {
149
+ hits.push(object);
150
+ }
151
+ }
152
+ }
153
+ return hits;
154
+ },
57
155
  clear() {
58
156
  for (const object of store.arraySnapshot()) {
59
157
  store.delete(object.instanceId);
60
158
  }
159
+ cellIndex.clear();
61
160
  },
62
161
  subscribe(listener) {
63
162
  return store.subscribe(listener);
@@ -67,3 +166,9 @@ export function createObjectStore() {
67
166
  },
68
167
  };
69
168
  }
169
+ export function objectVisualScale(visual) {
170
+ const scale = visual?.scale;
171
+ if (scale === undefined)
172
+ return [1, 1, 1];
173
+ return typeof scale === "number" ? [scale, scale, scale] : scale;
174
+ }
@@ -0,0 +1,15 @@
1
+ export interface PaintStroke {
2
+ u: number;
3
+ v: number;
4
+ radius: number;
5
+ color: string;
6
+ }
7
+ export interface PaintLayer {
8
+ paint(instanceId: string, stroke: PaintStroke): void;
9
+ strokes(instanceId: string): readonly PaintStroke[];
10
+ paintedIds(): readonly string[];
11
+ clear(instanceId?: string): void;
12
+ version(instanceId: string): number;
13
+ subscribe(listener: () => void): () => void;
14
+ }
15
+ export declare function createPaintLayer(): PaintLayer;
@@ -0,0 +1,53 @@
1
+ export function createPaintLayer() {
2
+ const strokesByInstance = new Map();
3
+ const versionByInstance = new Map();
4
+ const listeners = new Set();
5
+ function bumpVersion(instanceId) {
6
+ versionByInstance.set(instanceId, (versionByInstance.get(instanceId) ?? 0) + 1);
7
+ }
8
+ function notify() {
9
+ for (const listener of listeners)
10
+ listener();
11
+ }
12
+ return {
13
+ paint(instanceId, stroke) {
14
+ const list = strokesByInstance.get(instanceId);
15
+ if (list === undefined)
16
+ strokesByInstance.set(instanceId, [stroke]);
17
+ else
18
+ list.push(stroke);
19
+ bumpVersion(instanceId);
20
+ notify();
21
+ },
22
+ strokes(instanceId) {
23
+ return strokesByInstance.get(instanceId) ?? [];
24
+ },
25
+ paintedIds() {
26
+ return Array.from(strokesByInstance.keys());
27
+ },
28
+ clear(instanceId) {
29
+ if (instanceId === undefined) {
30
+ if (strokesByInstance.size === 0)
31
+ return;
32
+ for (const id of strokesByInstance.keys())
33
+ bumpVersion(id);
34
+ strokesByInstance.clear();
35
+ notify();
36
+ return;
37
+ }
38
+ if (!strokesByInstance.delete(instanceId))
39
+ return;
40
+ bumpVersion(instanceId);
41
+ notify();
42
+ },
43
+ version(instanceId) {
44
+ return versionByInstance.get(instanceId) ?? 0;
45
+ },
46
+ subscribe(listener) {
47
+ listeners.add(listener);
48
+ return () => {
49
+ listeners.delete(listener);
50
+ };
51
+ },
52
+ };
53
+ }
@@ -18,10 +18,22 @@ export interface MoveTowardOptions {
18
18
  stopDistance?: number;
19
19
  dt: number;
20
20
  }
21
+ export interface SpatialGridOptions {
22
+ /** Cell edge over the x/z plane; tune to ≈ the typical query radius so a query touches a handful of cells. */
23
+ cellSize: number;
24
+ }
21
25
  export interface SpatialApiOptions {
22
26
  resolvePosition: (instanceId: string) => EntityPosition | undefined;
23
27
  candidates: () => string[];
24
28
  occluder?: (from: EntityPosition, to: EntityPosition) => boolean;
29
+ /**
30
+ * Opt-in broadphase acceleration for `inRadius`/`queryArc` over large candidate sets. When set,
31
+ * a uniform x/z cell index is built lazily from `candidates()` + `resolvePosition()` on first use
32
+ * and reused across calls until `invalidate()` is called — so many queries against one tick's
33
+ * frozen positions pay for one rebuild, not one linear scan each. Results are exactly the linear
34
+ * scan's results (same filtering; see `SpatialApi.invalidate` for the staleness contract).
35
+ */
36
+ grid?: SpatialGridOptions;
25
37
  }
26
38
  export interface SpatialApi {
27
39
  distance(aInstanceId: string, bInstanceId: string): number | null;
@@ -29,6 +41,15 @@ export interface SpatialApi {
29
41
  hasLineOfSight(fromInstanceId: string, toInstanceId: string): boolean;
30
42
  queryArc(options: QueryArcOptions): string[];
31
43
  moveToward(instanceId: string, target: EntityPosition | string, options: MoveTowardOptions): EntityPosition | null;
44
+ /**
45
+ * Marks the `grid` broadphase index stale so the next `inRadius`/`queryArc` call rebuilds it from
46
+ * `candidates()` and `resolvePosition()`. Call this after moving, spawning, or despawning a
47
+ * candidate; a candidate absent from the index at query time is never skipped (it falls back to
48
+ * an exact check), but a candidate that *moved* since the last rebuild can be missed until you
49
+ * invalidate — so treat "invalidate after any position change" as the contract, not an
50
+ * optimization. A no-op when no `grid` config was supplied. Safe to call every tick.
51
+ */
52
+ invalidate(): void;
32
53
  }
33
54
  export declare function distanceBetween(a: EntityPosition, b: EntityPosition): number;
34
55
  export declare function createSpatialApi(options: SpatialApiOptions): SpatialApi;
@@ -14,11 +14,62 @@ function forwardXZ(aim) {
14
14
  }
15
15
  return [Math.sin(aim.yaw), Math.cos(aim.yaw)];
16
16
  }
17
+ function cellCoord(value, cellSize) {
18
+ return Math.floor(value / cellSize);
19
+ }
20
+ function gridCellKey(cx, cz) {
21
+ return `${cx}:${cz}`;
22
+ }
17
23
  export function createSpatialApi(options) {
18
- const { resolvePosition, candidates, occluder } = options;
24
+ const { resolvePosition, candidates, occluder, grid } = options;
25
+ const cellSize = grid?.cellSize;
26
+ let gridIndex = null;
27
+ let gridDirty = true;
19
28
  function resolveTarget(target) {
20
29
  return typeof target === "string" ? resolvePosition(target) : target;
21
30
  }
31
+ function ensureGrid() {
32
+ if (cellSize === undefined)
33
+ return null;
34
+ if (!gridDirty && gridIndex !== null)
35
+ return gridIndex;
36
+ const cells = new Map();
37
+ const indexed = new Set();
38
+ for (const instanceId of candidates()) {
39
+ const position = resolvePosition(instanceId);
40
+ if (position === undefined)
41
+ continue;
42
+ indexed.add(instanceId);
43
+ const key = gridCellKey(cellCoord(position[0], cellSize), cellCoord(position[2], cellSize));
44
+ let bucket = cells.get(key);
45
+ if (bucket === undefined) {
46
+ bucket = [];
47
+ cells.set(key, bucket);
48
+ }
49
+ bucket.push(instanceId);
50
+ }
51
+ gridIndex = { cells, indexed };
52
+ gridDirty = false;
53
+ return gridIndex;
54
+ }
55
+ function inRangeSet(index, centerX, centerZ, radius) {
56
+ const size = cellSize;
57
+ const cxMin = cellCoord(centerX - radius, size);
58
+ const cxMax = cellCoord(centerX + radius, size);
59
+ const czMin = cellCoord(centerZ - radius, size);
60
+ const czMax = cellCoord(centerZ + radius, size);
61
+ const near = new Set();
62
+ for (let cz = czMin; cz <= czMax; cz += 1) {
63
+ for (let cx = cxMin; cx <= cxMax; cx += 1) {
64
+ const bucket = index.cells.get(gridCellKey(cx, cz));
65
+ if (bucket === undefined)
66
+ continue;
67
+ for (const id of bucket)
68
+ near.add(id);
69
+ }
70
+ }
71
+ return near;
72
+ }
22
73
  return {
23
74
  distance(aInstanceId, bInstanceId) {
24
75
  const a = resolvePosition(aInstanceId);
@@ -32,11 +83,15 @@ export function createSpatialApi(options) {
32
83
  const centerPosition = resolveTarget(center);
33
84
  if (centerPosition === undefined)
34
85
  return [];
86
+ const index = ensureGrid();
87
+ const near = index === null ? null : inRangeSet(index, centerPosition[0], centerPosition[2], radius);
35
88
  return candidates().filter((instanceId) => {
36
89
  if (instanceId === centerId)
37
90
  return false;
38
91
  if (filter !== undefined && !filter(instanceId))
39
92
  return false;
93
+ if (near !== null && index.indexed.has(instanceId) && !near.has(instanceId))
94
+ return false;
40
95
  const position = resolvePosition(instanceId);
41
96
  return position !== undefined && distanceBetween(centerPosition, position) <= radius;
42
97
  });
@@ -58,9 +113,13 @@ export function createSpatialApi(options) {
58
113
  if (forward === null)
59
114
  return [];
60
115
  const minDot = Math.cos((halfAngleDeg * Math.PI) / 180);
116
+ const index = ensureGrid();
117
+ const near = index === null ? null : inRangeSet(index, origin[0], origin[2], radius);
61
118
  return candidates().filter((instanceId) => {
62
119
  if (instanceId === from)
63
120
  return false;
121
+ if (near !== null && index.indexed.has(instanceId) && !near.has(instanceId))
122
+ return false;
64
123
  const position = resolvePosition(instanceId);
65
124
  if (position === undefined)
66
125
  return false;
@@ -75,6 +134,9 @@ export function createSpatialApi(options) {
75
134
  return dot >= minDot;
76
135
  });
77
136
  },
137
+ invalidate() {
138
+ gridDirty = true;
139
+ },
78
140
  moveToward(instanceId, target, { speed, stopDistance = 0, dt }) {
79
141
  const current = resolvePosition(instanceId);
80
142
  const destination = resolveTarget(target);
@@ -0,0 +1,21 @@
1
+ export type ColorHex = string;
2
+ export declare function colorDistance(a: ColorHex, b: ColorHex): number;
3
+ export declare function concealmentScore(entityColors: readonly ColorHex[], backgroundColors: readonly ColorHex[]): number;
4
+ export interface ConcealmentTarget {
5
+ id: string;
6
+ entityColors: readonly ColorHex[];
7
+ backgroundColors: readonly ColorHex[];
8
+ }
9
+ export interface ConcealmentSample {
10
+ id: string;
11
+ score: number;
12
+ concealed: boolean;
13
+ dwellSeconds: number;
14
+ }
15
+ export interface ConcealmentSensor {
16
+ tick(targets: readonly ConcealmentTarget[], dt: number): ConcealmentSample[];
17
+ reset(id?: string): void;
18
+ }
19
+ export declare function createConcealmentSensor(config?: {
20
+ threshold?: number;
21
+ }): ConcealmentSensor;
@@ -0,0 +1,63 @@
1
+ function parseHex(hex) {
2
+ const match = /^#?([0-9a-fA-F]{6})$/.exec(hex.trim());
3
+ if (match === null)
4
+ return [0, 0, 0];
5
+ const value = parseInt(match[1], 16);
6
+ return [(value >> 16) & 0xff, (value >> 8) & 0xff, value & 0xff];
7
+ }
8
+ function redmean(a, b) {
9
+ const rmean = (a[0] + b[0]) / 2;
10
+ const dr = a[0] - b[0];
11
+ const dg = a[1] - b[1];
12
+ const db = a[2] - b[2];
13
+ return Math.sqrt((2 + rmean / 256) * dr * dr + 4 * dg * dg + (2 + (255 - rmean) / 256) * db * db);
14
+ }
15
+ const REDMEAN_MAX = redmean([0, 0, 0], [255, 255, 255]);
16
+ export function colorDistance(a, b) {
17
+ const distance = redmean(parseHex(a), parseHex(b));
18
+ return REDMEAN_MAX === 0 ? 0 : Math.min(1, distance / REDMEAN_MAX);
19
+ }
20
+ export function concealmentScore(entityColors, backgroundColors) {
21
+ if (entityColors.length === 0 || backgroundColors.length === 0)
22
+ return 0;
23
+ let total = 0;
24
+ for (const entityColor of entityColors) {
25
+ let nearest = Number.POSITIVE_INFINITY;
26
+ for (const backgroundColor of backgroundColors) {
27
+ const distance = colorDistance(entityColor, backgroundColor);
28
+ if (distance < nearest)
29
+ nearest = distance;
30
+ }
31
+ total += nearest;
32
+ }
33
+ return 1 - total / entityColors.length;
34
+ }
35
+ export function createConcealmentSensor(config) {
36
+ const threshold = config?.threshold ?? 0.7;
37
+ const dwell = new Map();
38
+ return {
39
+ tick(targets, dt) {
40
+ const seen = new Set();
41
+ const samples = [];
42
+ for (const target of targets) {
43
+ seen.add(target.id);
44
+ const score = concealmentScore(target.entityColors, target.backgroundColors);
45
+ const concealed = score >= threshold;
46
+ const nextDwell = concealed ? (dwell.get(target.id) ?? 0) + dt : 0;
47
+ dwell.set(target.id, nextDwell);
48
+ samples.push({ id: target.id, score, concealed, dwellSeconds: nextDwell });
49
+ }
50
+ for (const id of dwell.keys()) {
51
+ if (!seen.has(id))
52
+ dwell.delete(id);
53
+ }
54
+ return samples;
55
+ },
56
+ reset(id) {
57
+ if (id === undefined)
58
+ dwell.clear();
59
+ else
60
+ dwell.delete(id);
61
+ },
62
+ };
63
+ }
@@ -0,0 +1,17 @@
1
+ export interface FreezeSubject {
2
+ id: string;
3
+ groundSpeed: number;
4
+ }
5
+ export interface FreezeViolation {
6
+ id: string;
7
+ speed: number;
8
+ movedSeconds: number;
9
+ }
10
+ export interface FreezeMonitor {
11
+ tick(subjects: readonly FreezeSubject[], frozenIds: ReadonlySet<string>, dt: number): FreezeViolation[];
12
+ reset(id?: string): void;
13
+ }
14
+ export declare function createFreezeMonitor(config?: {
15
+ toleranceSpeed?: number;
16
+ graceSeconds?: number;
17
+ }): FreezeMonitor;
@@ -0,0 +1,35 @@
1
+ export function createFreezeMonitor(config) {
2
+ const toleranceSpeed = config?.toleranceSpeed ?? 0.05;
3
+ const graceSeconds = config?.graceSeconds ?? 0;
4
+ const moved = new Map();
5
+ return {
6
+ tick(subjects, frozenIds, dt) {
7
+ const seen = new Set();
8
+ const violations = [];
9
+ for (const subject of subjects) {
10
+ seen.add(subject.id);
11
+ const shouldHoldStill = frozenIds.has(subject.id) && subject.groundSpeed > toleranceSpeed;
12
+ if (!shouldHoldStill) {
13
+ moved.delete(subject.id);
14
+ continue;
15
+ }
16
+ const movedSeconds = (moved.get(subject.id) ?? 0) + dt;
17
+ moved.set(subject.id, movedSeconds);
18
+ if (movedSeconds > graceSeconds) {
19
+ violations.push({ id: subject.id, speed: subject.groundSpeed, movedSeconds });
20
+ }
21
+ }
22
+ for (const id of moved.keys()) {
23
+ if (!seen.has(id))
24
+ moved.delete(id);
25
+ }
26
+ return violations;
27
+ },
28
+ reset(id) {
29
+ if (id === undefined)
30
+ moved.clear();
31
+ else
32
+ moved.delete(id);
33
+ },
34
+ };
35
+ }
@@ -0,0 +1,6 @@
1
+ export interface RoleSpec {
2
+ id: string;
3
+ count?: number;
4
+ ratio?: number;
5
+ }
6
+ export declare function assignRoles(userIds: readonly string[], roles: readonly RoleSpec[], rng?: () => number): Record<string, string>;
@@ -0,0 +1,32 @@
1
+ import { shuffleWithRng } from "../cards/cardPile.js";
2
+ export function assignRoles(userIds, roles, rng = Math.random) {
3
+ if (roles.length === 0)
4
+ throw new Error("assignRoles needs at least one role");
5
+ const shuffled = shuffleWithRng(userIds, rng);
6
+ const assignment = {};
7
+ let index = 0;
8
+ const countRoles = roles.filter((role) => role.count !== undefined);
9
+ const ratioRoles = roles.filter((role) => role.count === undefined && role.ratio !== undefined);
10
+ const fillRole = roles.find((role) => role.count === undefined && role.ratio === undefined);
11
+ const fallbackRole = fillRole ?? roles[roles.length - 1];
12
+ for (const role of countRoles) {
13
+ const take = Math.max(0, Math.min(role.count, shuffled.length - index));
14
+ for (let i = 0; i < take; i += 1) {
15
+ assignment[shuffled[index]] = role.id;
16
+ index += 1;
17
+ }
18
+ }
19
+ for (const role of ratioRoles) {
20
+ const wanted = Math.round(role.ratio * shuffled.length);
21
+ const take = Math.max(0, Math.min(wanted, shuffled.length - index));
22
+ for (let i = 0; i < take; i += 1) {
23
+ assignment[shuffled[index]] = role.id;
24
+ index += 1;
25
+ }
26
+ }
27
+ while (index < shuffled.length) {
28
+ assignment[shuffled[index]] = fallbackRole.id;
29
+ index += 1;
30
+ }
31
+ return assignment;
32
+ }
@@ -1,4 +1,5 @@
1
- export type RoundPhase = "buy" | "live" | "end";
1
+ export type RoundPhase = string;
2
+ /** Default phase-duration shape for the built-in buy/live/end cycle; pass a wider `Record<string, number>` when using a custom `phaseOrder`. */
2
3
  export interface RoundPhaseDurations {
3
4
  buy: number;
4
5
  live: number;
@@ -9,9 +10,24 @@ export interface LossBonusRule {
9
10
  step: number;
10
11
  max: number;
11
12
  }
12
- export interface RoundConfig {
13
- phases: RoundPhaseDurations;
14
- teams: readonly string[];
13
+ /** A team entry with an optional role tag (e.g. "attacker", "defender") retrievable via `RoundState.roleOf`. */
14
+ export interface RoundTeam {
15
+ id: string;
16
+ role?: string;
17
+ }
18
+ export interface RoundConfig<TPhase extends string = RoundPhase> {
19
+ /** Duration in seconds for each phase name in `phaseOrder` (or the default `RoundPhaseDurations` shape). */
20
+ phases: Record<TPhase, number>;
21
+ teams: readonly string[] | readonly RoundTeam[];
22
+ /**
23
+ * Overrides the default `["buy", "live", "end"]` cycle. Phases other than the first and last are
24
+ * "conclude-eligible": `concludeRound`/`evaluate` may settle the round only while the current phase
25
+ * is neither the first nor the last entry, matching the original buy (no conclude) / live (conclude) /
26
+ * end (no conclude, settles the round) roles. The cycle wraps from the last entry back to the first.
27
+ */
28
+ phaseOrder?: readonly TPhase[];
29
+ /** Evaluated by `evaluate()`; returning a team id concludes the round with that team as winner. */
30
+ winCondition?: (state: RoundSnapshot<TPhase>) => string | null;
15
31
  maxRounds?: number;
16
32
  winReward?: number;
17
33
  lossBonus?: LossBonusRule;
@@ -23,33 +39,41 @@ export interface RoundEconomy {
23
39
  won: boolean;
24
40
  lossStreak: number;
25
41
  }
26
- export interface RoundEvent {
42
+ export interface RoundEvent<TPhase extends string = RoundPhase> {
27
43
  kind: RoundEventKind;
28
44
  round: number;
29
- phase?: RoundPhase;
30
- nextPhase?: RoundPhase;
45
+ phase?: TPhase;
46
+ nextPhase?: TPhase;
31
47
  winner?: string;
32
48
  economy?: RoundEconomy[];
33
49
  }
34
- export type PhaseEndHook = (endingPhase: RoundPhase, nextPhase: RoundPhase, round: number) => void;
35
- export interface RoundSnapshot {
50
+ export type PhaseEndHook<TPhase extends string = RoundPhase> = (endingPhase: TPhase, nextPhase: TPhase, round: number) => void;
51
+ export interface RoundSnapshot<TPhase extends string = RoundPhase> {
36
52
  round: number;
37
- phase: RoundPhase;
53
+ phase: TPhase;
38
54
  timeLeft: number;
39
55
  scores: Record<string, number>;
40
56
  lossStreaks: Record<string, number>;
57
+ roles: Record<string, string | undefined>;
41
58
  matchOver: boolean;
42
59
  }
43
- export interface RoundState {
44
- tick(dt: number): RoundEvent[];
45
- concludeRound(winner: string): RoundEvent[];
46
- onPhaseEnd(hook: PhaseEndHook): () => void;
47
- phase(): RoundPhase;
60
+ export interface RoundState<TPhase extends string = RoundPhase> {
61
+ tick(dt: number): RoundEvent<TPhase>[];
62
+ concludeRound(winner: string): RoundEvent<TPhase>[];
63
+ /** Calls `RoundConfig.winCondition` (a no-op when absent) and concludes the round if it returns a team id. */
64
+ evaluate(): RoundEvent<TPhase>[];
65
+ onPhaseEnd(hook: PhaseEndHook<TPhase>): () => void;
66
+ phase(): TPhase;
48
67
  round(): number;
49
68
  timeLeft(): number;
50
69
  score(team: string): number;
51
70
  economyFor(team: string): RoundEconomy;
52
- snapshot(): RoundSnapshot;
71
+ /** The role tag configured for `team`, if any. */
72
+ roleOf(team: string): string | undefined;
73
+ snapshot(): RoundSnapshot<TPhase>;
53
74
  }
54
75
  export declare function lossBonusFor(rule: LossBonusRule | undefined, streak: number): number;
55
- export declare function createRoundState(config: RoundConfig): RoundState;
76
+ export declare function createRoundState(config: RoundConfig<RoundPhase>): RoundState<RoundPhase>;
77
+ export declare function createRoundState<TPhase extends string>(config: RoundConfig<TPhase> & {
78
+ phaseOrder: readonly TPhase[];
79
+ }): RoundState<TPhase>;