@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,76 @@
1
+ export function createFactionGraph(config) {
2
+ const symmetric = config.symmetric ?? true;
3
+ const unaligned = config.unaligned ?? "neutral";
4
+ const defs = new Map();
5
+ for (const def of config.factions)
6
+ defs.set(def.id, def);
7
+ function relationBetween(observer, other) {
8
+ if (observer == null || other == null)
9
+ return unaligned;
10
+ const from = defs.get(observer);
11
+ const to = defs.get(other);
12
+ if (from === undefined || to === undefined)
13
+ return unaligned;
14
+ if (observer === other)
15
+ return from.towardSelf ?? "friendly";
16
+ const direct = from.relations?.[other];
17
+ if (direct !== undefined)
18
+ return direct;
19
+ if (symmetric) {
20
+ const mirrored = to.relations?.[observer];
21
+ if (mirrored !== undefined)
22
+ return mirrored;
23
+ }
24
+ return from.towardOthers ?? "neutral";
25
+ }
26
+ return {
27
+ relationBetween,
28
+ isHostile: (observer, other) => relationBetween(observer, other) === "hostile",
29
+ isFriendly: (observer, other) => relationBetween(observer, other) === "friendly",
30
+ isNeutral: (observer, other) => relationBetween(observer, other) === "neutral",
31
+ has: (factionId) => defs.has(factionId),
32
+ ids: () => [...defs.keys()],
33
+ };
34
+ }
35
+ export function createFactionRoster(graph) {
36
+ const membership = new Map();
37
+ function factionOf(entityId) {
38
+ return membership.get(entityId) ?? null;
39
+ }
40
+ function relationBetweenEntities(observer, other) {
41
+ return graph.relationBetween(factionOf(observer), factionOf(other));
42
+ }
43
+ return {
44
+ assign(entityId, factionId) {
45
+ if (factionId === null)
46
+ membership.delete(entityId);
47
+ else
48
+ membership.set(entityId, factionId);
49
+ },
50
+ factionOf,
51
+ relationBetweenEntities,
52
+ isHostile: (observer, other) => relationBetweenEntities(observer, other) === "hostile",
53
+ isFriendly: (observer, other) => relationBetweenEntities(observer, other) === "friendly",
54
+ members(factionId) {
55
+ const out = [];
56
+ for (const [entityId, id] of membership)
57
+ if (id === factionId)
58
+ out.push(entityId);
59
+ return out;
60
+ },
61
+ hostilesOf(observer, candidates) {
62
+ const out = [];
63
+ for (const id of candidates) {
64
+ if (id !== observer && relationBetweenEntities(observer, id) === "hostile")
65
+ out.push(id);
66
+ }
67
+ return out;
68
+ },
69
+ remove(entityId) {
70
+ membership.delete(entityId);
71
+ },
72
+ clear() {
73
+ membership.clear();
74
+ },
75
+ };
76
+ }
@@ -0,0 +1,32 @@
1
+ import type { FactionRelation } from "./factions.js";
2
+ export interface ReputationTier {
3
+ id: string;
4
+ min: number;
5
+ relation: FactionRelation;
6
+ }
7
+ export declare const DEFAULT_REPUTATION_TIERS: readonly ReputationTier[];
8
+ export declare function tierForStanding(tiers: readonly ReputationTier[], standing: number): ReputationTier;
9
+ export interface ReputationLedgerConfig {
10
+ tiers?: readonly ReputationTier[];
11
+ initial?: Readonly<Record<string, number>>;
12
+ min?: number;
13
+ max?: number;
14
+ }
15
+ export interface ReputationLedger {
16
+ standing(actorId: string, factionId: string): number;
17
+ hasStanding(actorId: string, factionId: string): boolean;
18
+ gain(actorId: string, factionId: string, amount: number): number;
19
+ set(actorId: string, factionId: string, standing: number): number;
20
+ tier(actorId: string, factionId: string): ReputationTier;
21
+ relation(actorId: string, factionId: string): FactionRelation;
22
+ standings(actorId: string): Record<string, number>;
23
+ reset(actorId: string, factionId?: string): void;
24
+ }
25
+ export declare function createReputationLedger(config?: ReputationLedgerConfig): ReputationLedger;
26
+ export interface EffectiveRelationInput {
27
+ base: FactionRelation;
28
+ ledger: ReputationLedger;
29
+ actorId: string;
30
+ factionId: string;
31
+ }
32
+ export declare function effectiveRelation(input: EffectiveRelationInput): FactionRelation;
@@ -0,0 +1,82 @@
1
+ export const DEFAULT_REPUTATION_TIERS = [
2
+ { id: "hated", min: -42000, relation: "hostile" },
3
+ { id: "hostile", min: -6000, relation: "hostile" },
4
+ { id: "unfriendly", min: -3000, relation: "neutral" },
5
+ { id: "neutral", min: 0, relation: "neutral" },
6
+ { id: "friendly", min: 3000, relation: "friendly" },
7
+ { id: "honored", min: 9000, relation: "friendly" },
8
+ { id: "revered", min: 21000, relation: "friendly" },
9
+ { id: "exalted", min: 42000, relation: "friendly" },
10
+ ];
11
+ export function tierForStanding(tiers, standing) {
12
+ let match = tiers[0];
13
+ for (const tier of tiers) {
14
+ if (standing >= tier.min && tier.min >= match.min)
15
+ match = tier;
16
+ }
17
+ return match;
18
+ }
19
+ export function createReputationLedger(config = {}) {
20
+ const tiers = [...(config.tiers ?? DEFAULT_REPUTATION_TIERS)].sort((a, b) => a.min - b.min);
21
+ const initial = config.initial ?? {};
22
+ const min = config.min ?? Number.NEGATIVE_INFINITY;
23
+ const max = config.max ?? Number.POSITIVE_INFINITY;
24
+ const records = new Map();
25
+ function clamp(value) {
26
+ return Math.min(max, Math.max(min, value));
27
+ }
28
+ function standing(actorId, factionId) {
29
+ const stored = records.get(actorId)?.get(factionId);
30
+ if (stored !== undefined)
31
+ return stored;
32
+ return initial[factionId] ?? 0;
33
+ }
34
+ function store(actorId, factionId, value) {
35
+ const clamped = clamp(value);
36
+ let row = records.get(actorId);
37
+ if (row === undefined) {
38
+ row = new Map();
39
+ records.set(actorId, row);
40
+ }
41
+ row.set(factionId, clamped);
42
+ return clamped;
43
+ }
44
+ return {
45
+ standing,
46
+ hasStanding(actorId, factionId) {
47
+ return records.get(actorId)?.has(factionId) === true || initial[factionId] !== undefined;
48
+ },
49
+ gain(actorId, factionId, amount) {
50
+ return store(actorId, factionId, standing(actorId, factionId) + amount);
51
+ },
52
+ set(actorId, factionId, value) {
53
+ return store(actorId, factionId, value);
54
+ },
55
+ tier(actorId, factionId) {
56
+ return tierForStanding(tiers, standing(actorId, factionId));
57
+ },
58
+ relation(actorId, factionId) {
59
+ return tierForStanding(tiers, standing(actorId, factionId)).relation;
60
+ },
61
+ standings(actorId) {
62
+ const out = { ...initial };
63
+ const row = records.get(actorId);
64
+ if (row !== undefined)
65
+ for (const [factionId, value] of row)
66
+ out[factionId] = value;
67
+ return out;
68
+ },
69
+ reset(actorId, factionId) {
70
+ if (factionId === undefined)
71
+ records.delete(actorId);
72
+ else
73
+ records.get(actorId)?.delete(factionId);
74
+ },
75
+ };
76
+ }
77
+ export function effectiveRelation(input) {
78
+ if (input.ledger.hasStanding(input.actorId, input.factionId)) {
79
+ return input.ledger.relation(input.actorId, input.factionId);
80
+ }
81
+ return input.base;
82
+ }
@@ -1,4 +1,5 @@
1
1
  import type { GameEvents } from "./events.js";
2
+ import type { ChatFilter } from "./chatFilter.js";
2
3
  import type { EmotesDeps } from "./social.js";
3
4
  export type ChatChannelKind = "global" | "party" | "proximity";
4
5
  export interface ChatRateLimit {
@@ -52,6 +53,7 @@ export interface ChatDeps {
52
53
  blockedBy?: (userId: string) => readonly string[];
53
54
  maxBodyLength?: number;
54
55
  defaultRateLimit?: ChatRateLimit;
56
+ filter?: ChatFilter;
55
57
  }
56
58
  export declare const DEFAULT_CHAT_RATE_LIMIT: ChatRateLimit;
57
59
  export declare const DEFAULT_CHAT_HISTORY_LIMIT = 100;
package/dist/game/chat.js CHANGED
@@ -34,6 +34,7 @@ export function createChat(deps) {
34
34
  const maxBodyLength = deps.maxBodyLength ?? DEFAULT_CHAT_BODY_LENGTH;
35
35
  const defaultRateLimit = deps.defaultRateLimit ?? DEFAULT_CHAT_RATE_LIMIT;
36
36
  const blockedBy = deps.blockedBy ?? (() => []);
37
+ const filter = deps.filter;
37
38
  const channelDefs = new Map();
38
39
  for (const def of BUILTIN_CHANNELS)
39
40
  channelDefs.set(def.id, { ...def });
@@ -97,6 +98,13 @@ export function createChat(deps) {
97
98
  return { reason: "empty message" };
98
99
  if (trimmed.length > maxBodyLength)
99
100
  return { reason: "message too long" };
101
+ let filteredBody = trimmed;
102
+ if (filter !== undefined) {
103
+ const filtered = filter.apply(trimmed);
104
+ if (!filtered.ok)
105
+ return { reason: "filtered" };
106
+ filteredBody = filtered.body;
107
+ }
100
108
  const resolved = resolveRecipients(fromUserId, channelId, def);
101
109
  if ("reason" in resolved)
102
110
  return resolved;
@@ -110,7 +118,7 @@ export function createChat(deps) {
110
118
  id: `msg_${counter}`,
111
119
  channelId,
112
120
  fromUserId,
113
- body: trimmed,
121
+ body: filteredBody,
114
122
  at,
115
123
  };
116
124
  append(channelId, message, def?.historyLimit ?? DEFAULT_CHAT_HISTORY_LIMIT);
@@ -0,0 +1,15 @@
1
+ export interface ChatFilterConfig {
2
+ blockedWords: readonly string[];
3
+ mode?: "mask" | "reject";
4
+ mask?: string;
5
+ }
6
+ export interface ChatFilterResult {
7
+ ok: boolean;
8
+ body: string;
9
+ matched: readonly string[];
10
+ }
11
+ export interface ChatFilter {
12
+ apply(body: string): ChatFilterResult;
13
+ }
14
+ export declare function normalizeChatText(text: string): string;
15
+ export declare function createChatFilter(config: ChatFilterConfig): ChatFilter;
@@ -0,0 +1,55 @@
1
+ const LEET_SUBSTITUTIONS = {
2
+ "0": "o",
3
+ "1": "i",
4
+ "3": "e",
5
+ "4": "a",
6
+ "5": "s",
7
+ "7": "t",
8
+ "@": "a",
9
+ $: "s",
10
+ "!": "i",
11
+ };
12
+ const TOKEN_PATTERN = /[\p{L}\p{N}@$!]+/gu;
13
+ export function normalizeChatText(text) {
14
+ let normalized = "";
15
+ for (const char of text.toLowerCase()) {
16
+ normalized += LEET_SUBSTITUTIONS[char] ?? char;
17
+ }
18
+ return normalized;
19
+ }
20
+ export function createChatFilter(config) {
21
+ const mode = config.mode ?? "mask";
22
+ const mask = config.mask ?? "*";
23
+ const blockedByNormalized = new Map();
24
+ for (const word of config.blockedWords) {
25
+ blockedByNormalized.set(normalizeChatText(word), word);
26
+ }
27
+ return {
28
+ apply(body) {
29
+ if (blockedByNormalized.size === 0)
30
+ return { ok: true, body, matched: [] };
31
+ const hit = new Set();
32
+ let masked = "";
33
+ let cursor = 0;
34
+ for (const token of body.matchAll(TOKEN_PATTERN)) {
35
+ const word = token[0];
36
+ const start = token.index;
37
+ const blockedWord = blockedByNormalized.get(normalizeChatText(word));
38
+ if (blockedWord === undefined)
39
+ continue;
40
+ hit.add(blockedWord);
41
+ if (mode === "reject")
42
+ continue;
43
+ masked += body.slice(cursor, start) + mask.repeat(word.length);
44
+ cursor = start + word.length;
45
+ }
46
+ if (hit.size === 0)
47
+ return { ok: true, body, matched: [] };
48
+ const matched = [...new Set(config.blockedWords)].filter((word) => hit.has(word));
49
+ if (mode === "reject")
50
+ return { ok: false, body, matched };
51
+ masked += body.slice(cursor);
52
+ return { ok: true, body: masked, matched };
53
+ },
54
+ };
55
+ }
@@ -0,0 +1,4 @@
1
+ import type { GameContext } from "../runtime/gameContext.js";
2
+ export declare const PLAY_CONTROLS_STORE_KEY = "jg.playControls";
3
+ export declare function setPlayControlsActive(ctx: GameContext, active: boolean): void;
4
+ export declare function playControlsActive(ctx: GameContext): boolean;
@@ -0,0 +1,7 @@
1
+ export const PLAY_CONTROLS_STORE_KEY = "jg.playControls";
2
+ export function setPlayControlsActive(ctx, active) {
3
+ ctx.game.store.set(PLAY_CONTROLS_STORE_KEY, active);
4
+ }
5
+ export function playControlsActive(ctx) {
6
+ return ctx.game.store.get(PLAY_CONTROLS_STORE_KEY) !== false;
7
+ }
@@ -10,6 +10,8 @@ import type { WorldFeature } from "../world/features.js";
10
10
  export interface PhysicsConfig {
11
11
  gravity?: number;
12
12
  jumpVelocity?: number;
13
+ /** Opt placed scene objects into projectile raycasts as blocking cover AABBs. Default false — enabling changes where shots land. */
14
+ projectileObstacles?: boolean;
13
15
  }
14
16
  export interface InventoryDeclaration {
15
17
  slots: number;
@@ -109,6 +109,10 @@ export interface CombatTelegraphEvent {
109
109
  windupMs: number;
110
110
  kind: string;
111
111
  }
112
+ export interface CombatTelegraphCancelledEvent {
113
+ /** The `combat.telegraph` event id whose decal should disappear early. */
114
+ id: number;
115
+ }
112
116
  export interface CombatHitReactionEvent {
113
117
  instanceId?: string;
114
118
  position: [number, number, number];
@@ -156,10 +160,15 @@ export interface FormChangedEvent {
156
160
  instanceId: string;
157
161
  formId: string | null;
158
162
  }
163
+ export interface AudioPlayEvent {
164
+ sound: string;
165
+ at?: readonly [number, number, number];
166
+ }
159
167
  export interface GameEventMap {
160
168
  "entity.died": EntityDiedEvent;
161
169
  "entity.floatText": EntityFloatTextEvent;
162
170
  "combat.telegraph": CombatTelegraphEvent;
171
+ "combat.telegraphCancelled": CombatTelegraphCancelledEvent;
163
172
  "combat.hitReaction": CombatHitReactionEvent;
164
173
  "loot.granted": LootGrantedEvent;
165
174
  "inventory.added": InventoryAddedEvent;
@@ -180,6 +189,7 @@ export interface GameEventMap {
180
189
  "emote.played": EmotePlayedEvent;
181
190
  "possession.swapped": PossessionSwappedEvent;
182
191
  "form.changed": FormChangedEvent;
192
+ "audio.play": AudioPlayEvent;
183
193
  }
184
194
  export type GameEventHandler<TPayload> = (payload: TPayload) => void;
185
195
  export interface GameEvents<TMap extends GameEventMap = GameEventMap> {
@@ -0,0 +1,20 @@
1
+ import type { GameContext } from "../runtime/gameContext.js";
2
+ /**
3
+ * Canonical run phase every game moves through. `menu` (title/main menu),
4
+ * `playing` (live), `paused` (mid-run pause), `ended` (win/lose/results).
5
+ * Touch controls are shown only while `playing`; menus and results never paint
6
+ * the touch dock over themselves.
7
+ */
8
+ export type GamePhase = "menu" | "playing" | "paused" | "ended";
9
+ export declare const GAME_PHASE_STORE_KEY = "jg.phase";
10
+ /**
11
+ * Set the current phase. Publishes it to `ctx.game.store` (React reads it via
12
+ * `useGamePhase`) and gates the shell's on-screen touch controls in one call —
13
+ * `playing` shows them, every other phase hides them. This is the whole
14
+ * "main menu shouldn't show touch controls" wiring: call it once per phase
15
+ * transition and the dock follows.
16
+ */
17
+ export declare function setGamePhase(ctx: GameContext, phase: GamePhase): void;
18
+ /** Current phase; defaults to `playing` when unset so always-live games need no wiring. */
19
+ export declare function gamePhase(ctx: GameContext): GamePhase;
20
+ export declare function isPlaying(ctx: GameContext): boolean;
@@ -0,0 +1,21 @@
1
+ import { setPlayControlsActive } from "./controlGate.js";
2
+ export const GAME_PHASE_STORE_KEY = "jg.phase";
3
+ /**
4
+ * Set the current phase. Publishes it to `ctx.game.store` (React reads it via
5
+ * `useGamePhase`) and gates the shell's on-screen touch controls in one call —
6
+ * `playing` shows them, every other phase hides them. This is the whole
7
+ * "main menu shouldn't show touch controls" wiring: call it once per phase
8
+ * transition and the dock follows.
9
+ */
10
+ export function setGamePhase(ctx, phase) {
11
+ ctx.game.store.set(GAME_PHASE_STORE_KEY, phase);
12
+ setPlayControlsActive(ctx, phase === "playing");
13
+ }
14
+ /** Current phase; defaults to `playing` when unset so always-live games need no wiring. */
15
+ export function gamePhase(ctx) {
16
+ const value = ctx.game.store.get(GAME_PHASE_STORE_KEY);
17
+ return value === "menu" || value === "paused" || value === "ended" ? value : "playing";
18
+ }
19
+ export function isPlaying(ctx) {
20
+ return gamePhase(ctx) === "playing";
21
+ }
@@ -2,11 +2,20 @@ export interface LootEntry {
2
2
  item?: string;
3
3
  currency?: string;
4
4
  count: number | [number, number];
5
- weight: number;
5
+ /** Relative pick weight — required in `"weighted"` mode, forbidden in `"independent"` mode. */
6
+ weight?: number;
7
+ /** Per-entry drop probability in `(0, 1]` — required in `"independent"` mode, forbidden in `"weighted"` mode. */
8
+ chance?: number;
6
9
  }
7
10
  export interface LootTableDef {
8
11
  id: string;
9
12
  rolls?: number;
13
+ /**
14
+ * `"weighted"` (default): each roll picks exactly one entry by relative `weight`.
15
+ * `"independent"`: each roll gives every entry its own `chance` — classic drop lists
16
+ * where a kill can yield several items (or nothing) without filler entries.
17
+ */
18
+ mode?: "weighted" | "independent";
10
19
  entries: LootEntry[];
11
20
  }
12
21
  export interface Drop {
@@ -1,11 +1,23 @@
1
- function assertValidEntry(entry) {
1
+ function assertValidEntry(entry, mode) {
2
2
  const hasItem = entry.item !== undefined;
3
3
  const hasCurrency = entry.currency !== undefined;
4
4
  if (hasItem === hasCurrency) {
5
5
  throw new Error("loot entry must have exactly one of item or currency");
6
6
  }
7
- if (!(entry.weight > 0)) {
8
- throw new Error(`loot entry weight must be positive, got ${entry.weight}`);
7
+ if (mode === "weighted") {
8
+ if (entry.chance !== undefined) {
9
+ throw new Error("loot entry chance is only valid in independent mode");
10
+ }
11
+ if (entry.weight === undefined || !(entry.weight > 0)) {
12
+ throw new Error(`loot entry weight must be positive, got ${entry.weight}`);
13
+ }
14
+ return;
15
+ }
16
+ if (entry.weight !== undefined) {
17
+ throw new Error("loot entry weight is only valid in weighted mode");
18
+ }
19
+ if (entry.chance === undefined || !(entry.chance > 0) || entry.chance > 1) {
20
+ throw new Error(`loot entry chance must be in (0, 1], got ${entry.chance}`);
9
21
  }
10
22
  }
11
23
  function assertValidDef(def) {
@@ -13,7 +25,7 @@ function assertValidDef(def) {
13
25
  throw new Error(`loot table "${def.id}" must have at least one entry`);
14
26
  }
15
27
  for (const entry of def.entries)
16
- assertValidEntry(entry);
28
+ assertValidEntry(entry, def.mode ?? "weighted");
17
29
  }
18
30
  function resolveCount(count, rng) {
19
31
  if (typeof count === "number")
@@ -22,10 +34,10 @@ function resolveCount(count, rng) {
22
34
  return min + Math.floor(rng() * (max - min + 1));
23
35
  }
24
36
  function pickEntry(entries, rng) {
25
- const total = entries.reduce((sum, entry) => sum + entry.weight, 0);
37
+ const total = entries.reduce((sum, entry) => sum + (entry.weight ?? 0), 0);
26
38
  let roll = rng() * total;
27
39
  for (const entry of entries) {
28
- roll -= entry.weight;
40
+ roll -= entry.weight ?? 0;
29
41
  if (roll < 0)
30
42
  return entry;
31
43
  }
@@ -56,7 +68,15 @@ export function createLootRegistry() {
56
68
  const rolls = table.rolls ?? 1;
57
69
  const drops = [];
58
70
  for (let i = 0; i < rolls; i++) {
59
- drops.push(rollEntry(pickEntry(table.entries, rng), rng));
71
+ if (table.mode === "independent") {
72
+ for (const entry of table.entries) {
73
+ if (rng() < (entry.chance ?? 0))
74
+ drops.push(rollEntry(entry, rng));
75
+ }
76
+ }
77
+ else {
78
+ drops.push(rollEntry(pickEntry(table.entries, rng), rng));
79
+ }
60
80
  }
61
81
  return drops;
62
82
  },
@@ -1,9 +1,12 @@
1
1
  import type { AudioBusDef, SoundDef } from "../audio/audioFalloff.js";
2
2
  import type { TouchControlsConfig } from "../input/touchScheme.js";
3
+ import type { GameSettingsConfig } from "../settings/settingsModel.js";
4
+ import type { HudPlatform, HudViewportConfig } from "../ui/hudScale.js";
3
5
  import type { PositionedPrompt } from "../interaction/proximityPrompt.js";
4
6
  import type { CatalogEntityRole, GameContext, GameContextContent } from "../runtime/gameContext.js";
5
7
  import type { ModelDims } from "../scene/assetCatalog.js";
6
8
  import type { SkyEnvironmentConfig } from "../world/features.js";
9
+ import type { VisibilityConfig } from "../visibility/config.js";
7
10
  import type { GameDefinition, GameLoop } from "./defineGame.js";
8
11
  import type { LootFilterRule } from "./lootFilter.js";
9
12
  import type { RarityStyle } from "./worldItem.js";
@@ -75,6 +78,8 @@ export interface FirstPersonCameraConfig {
75
78
  * - `none` — no camera rig is mounted; use for HUD-only presentations or a game that manages its own camera.
76
79
  */
77
80
  export type CameraRigKind = "orbit" | "first" | "topDown" | "rts" | "shoulder" | "lockOn" | "chase" | "observer" | "turntable" | "sideScroll" | "inspection" | "none";
81
+ /** Canvas camera projection. "orthographic" renders a flat 2D-style view (side-scrollers, falling-block puzzles) — pair with `rig: "sideScroll"`; default "perspective". */
82
+ export type CameraProjection = "perspective" | "orthographic";
78
83
  /** Fixed lateral 2.5D follow (side-on platformer cam): the camera sits perpendicular to the travel axis, tracks the followed entity, and never reads player look input. */
79
84
  export interface SideScrollCameraConfig {
80
85
  /** World axis the action travels along; the camera watches from the perpendicular side. Default "x". */
@@ -202,6 +207,17 @@ export interface ChaseCameraConfig {
202
207
  };
203
208
  /** Procedural shake amplitude per unit of speed (adds to the trauma channel). Default 0.0. */
204
209
  shakePerSpeed?: number;
210
+ /** Velocity-lead / predictive follow (#286.9): the rig aims `time` seconds ahead of the target along its velocity, clamped to `max` world units (default 4). */
211
+ lead?: {
212
+ time: number;
213
+ max?: number;
214
+ };
215
+ /** Roll into turns (#286.10): radians of camera roll per rad/s of the target's yaw rate, clamped to `max` (default 0.35), exponentially smoothed by `damping` (default 8). */
216
+ bank?: {
217
+ perYawRate: number;
218
+ max?: number;
219
+ damping?: number;
220
+ };
205
221
  /** Which view to mount. Default "chase". */
206
222
  view?: ChaseView;
207
223
  /** Local offset for cockpit/hood/rear seats (relative to the vehicle, +z forward). */
@@ -354,16 +370,35 @@ export declare const CAMERA_FRUSTUM_DEFAULTS: {
354
370
  readonly fov: 55;
355
371
  readonly near: 0.1;
356
372
  readonly far: 300;
373
+ readonly zoom: 50;
357
374
  };
375
+ /** Player-facing FOV preference applied across every perspective camera rig. Orthographic projections ignore it. */
376
+ export interface PlayerFovConfig {
377
+ /** Inclusive lower bound for the slider and persisted values. Default 40. */
378
+ min?: number;
379
+ /** Inclusive upper bound for the slider and persisted values. Default 120. */
380
+ max?: number;
381
+ /** Initial FOV when nothing is persisted. Defaults to `frustum.fov` or 55. */
382
+ default?: number;
383
+ /** Persist the preference in localStorage across reloads. Default true. */
384
+ persist?: boolean;
385
+ /** Show the shell FOV slider. Default true for perspective cameras; forced off for orthographic. */
386
+ control?: boolean;
387
+ }
358
388
  export interface GameCameraConfig {
359
389
  /** Selects the rig. Overrides `perspective`; leave unset to fall back to `perspective`. */
360
390
  rig?: CameraRigKind;
361
- /** Render frustum overrides applied to the canvas camera. `far` defaults to 300 — raise it for worlds whose content spans more than a few hundred units, or distant scenery silently clips. */
391
+ /** Canvas camera projection. "orthographic" renders a flat 2D-style view (side-scrollers, falling-block puzzles) — pair with `rig: "sideScroll"`; default perspective. */
392
+ projection?: CameraProjection;
393
+ /** Render frustum overrides applied to the canvas camera. `far` defaults to 300 — raise it for worlds whose content spans more than a few hundred units, or distant scenery silently clips. `zoom` is the orthographic zoom in canvas pixels per world unit, read only when `projection` is "orthographic"; default 50. */
362
394
  frustum?: {
363
395
  fov?: number;
364
396
  near?: number;
365
397
  far?: number;
398
+ zoom?: number;
366
399
  };
400
+ /** Universal player FOV preference (slider + persistence) for perspective rigs. Ignored when `projection` is `"orthographic"`. */
401
+ playerFov?: PlayerFovConfig;
367
402
  /** "third" mounts the orbit camera (default); "first" mounts pointer-lock mouse-look. Shorthand for `rig: "orbit" | "first"`. */
368
403
  perspective?: "third" | "first";
369
404
  /** First-person tuning; only read when the rig resolves to "first". */
@@ -542,6 +577,8 @@ export interface PlayerMovementConfig {
542
577
  collideObjects?: boolean;
543
578
  /** Intercepts each frame's resolved position before the pose commits (and before onTick): return a replacement [x,y,z] to constrain or redirect the step, or nothing to accept it. */
544
579
  beforeCommit?: (frame: MovementCommitFrame) => readonly [number, number, number] | undefined | void;
580
+ /** Gates the built-in sprint (`runSpeedMultiplier`) behind live game state — a stamina stat, an encumbrance check (#282.3). Called each frame while sprint is held; `false` walks. */
581
+ canSprint?: (ctx: GameContext) => boolean;
545
582
  }
546
583
  /** One frame's movement resolution handed to `PlayerMovementConfig.beforeCommit`. */
547
584
  export interface MovementCommitFrame {
@@ -549,6 +586,8 @@ export interface MovementCommitFrame {
549
586
  current: readonly [number, number, number];
550
587
  next: readonly [number, number, number];
551
588
  dt: number;
589
+ /** The live game context (#282.2) — movement rules read stores/stats directly instead of module-level bridge objects. */
590
+ ctx: GameContext;
552
591
  }
553
592
  export interface PlayableGame<TUi = unknown, TWorldOverlay = unknown, TRenderEntity = never, TRenderObject = never> {
554
593
  game: GameDefinition;
@@ -585,6 +624,12 @@ export interface PlayableGame<TUi = unknown, TWorldOverlay = unknown, TRenderEnt
585
624
  pointer?: PointerConfig;
586
625
  /** Touch controls on coarse-pointer devices. Unset derives a scheme from `input` (virtual joystick for movement actions, on-screen buttons for the rest); a config refines it with gestures and curated buttons; `false` opts out. */
587
626
  touch?: TouchControlsConfig | false;
627
+ /** Preferred phone orientation. When a coarse-pointer device is held the other way the shell shows a dismissible rotate hint; purely advisory, never blocks play. */
628
+ orientation?: "landscape" | "portrait";
629
+ /** Where the game is meant to be played. Default `["web", "mobile"]` — design-resolution HUD fit is on for every game: `HudCanvas` auto-scales from `hudFit.designSize` down to the live viewport, so the desktop layout shrinks to fit a phone instead of overflowing it. Declare `["web"]` to opt a desktop-only game out (compact displays fall back to the legacy fixed 0.85 zoom). */
630
+ platforms?: readonly HudPlatform[];
631
+ /** HUD design resolution + scale clamps (default 1600×900, scale 0.4–1). `mobile` overrides tune the phone fit separately — the same resolution system drives desktop UI-scale and phone shrink. */
632
+ hudFit?: HudViewportConfig;
588
633
  /** Opt in to world-space health bars floating over non-local entities that carry the stat. `roles` restricts bars to entities whose catalog entry declares one of the given roles. */
589
634
  worldHealthBars?: boolean | {
590
635
  statId?: string;
@@ -611,5 +656,9 @@ export interface PlayableGame<TUi = unknown, TWorldOverlay = unknown, TRenderEnt
611
656
  backdrop?: BackdropConfig;
612
657
  /** F2 debug overlay (frame/sim timing, logs, backend latency, keybinds, live tunables). On for every game by default; `false` disables the toggle. */
613
658
  devtools?: boolean;
659
+ /** Player settings menu. Auto-mounted for every game (Sound / Graphics / Gameplay / Controls); unset uses the defaults, `false` opts out. Add game-specific rows via `extra`, switch overlay/full-page via `mode`, or swap the gear for compact on-screen buttons via `surface: "quick"`. */
660
+ settings?: GameSettingsConfig | false;
661
+ /** Automatic camera frustum + distance culling and asset streaming. On by default with conservative margins; unset uses engine defaults, `{ enabled: false }` opts out. Override bounds/distance/pins per entity kind or object catalog id. */
662
+ visibility?: VisibilityConfig;
614
663
  }
615
664
  export declare function worldHealthBarAllowsRole(roles: readonly CatalogEntityRole[] | undefined, role: CatalogEntityRole | undefined): boolean;
@@ -1,4 +1,4 @@
1
- export const CAMERA_FRUSTUM_DEFAULTS = { fov: 55, near: 0.1, far: 300 };
1
+ export const CAMERA_FRUSTUM_DEFAULTS = { fov: 55, near: 0.1, far: 300, zoom: 50 };
2
2
  export function worldHealthBarAllowsRole(roles, role) {
3
3
  if (roles === undefined)
4
4
  return true;
@@ -65,7 +65,7 @@ export interface QuestJournalDeps {
65
65
  partyMembersNear?(userId: string, radius: number): string[];
66
66
  }
67
67
  export interface QuestJournal {
68
- register(catalog: QuestDef[] | Record<string, QuestDef>): void;
68
+ register(catalog: readonly QuestDef[] | Record<string, QuestDef>): void;
69
69
  has(questId: string): boolean;
70
70
  canAccept(userId: string, questId: string): {
71
71
  reason: string;
@@ -126,7 +126,11 @@ export interface QuestEvaluator {
126
126
  export declare function applyQuestRewards(rewards: QuestRewards, appliers: {
127
127
  grantXp?(amount: number): void;
128
128
  grantEconomy?(currencyId: string, amount: number): void;
129
- grantItem?(inventoryId: string, itemId: string, count: number): void;
129
+ grantItem?(inventoryId: string, itemId: string, count: number): {
130
+ reason: string;
131
+ } | null | void;
130
132
  grantUnlock?(unlockId: string): void;
131
- }): void;
133
+ }): {
134
+ reason: string;
135
+ } | null;
132
136
  export declare function createQuestEvaluator(defs: QuestDef[] | Record<string, QuestDef>): QuestEvaluator;