@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
@@ -0,0 +1,229 @@
1
+ /**
2
+ * Voxel character controller (pure model).
3
+ *
4
+ * The base first-person controller in `movementModel.ts` assumes flat ground at
5
+ * y=0 — it integrates gravity into a `jumpOffset` and never asks the world how
6
+ * tall the ground is. That is fine for a plaza demo but wrong for any game where
7
+ * the player walks on placed blocks and digs down into the world: they float on
8
+ * an invisible floor, clip through walls, and can never descend.
9
+ *
10
+ * This module resolves the player as an axis-aligned box against a set of solid
11
+ * unit cells (a `SolidQuery`), so the same held-keys → intent pipeline drives a
12
+ * body that stands on blocks, falls into holes, is stopped by walls, and steps
13
+ * up single-block ledges. Keeping it renderer-free means the shell can drive it
14
+ * from live scene objects while the descent/landing/step-up behaviour stays unit
15
+ * testable without three.js.
16
+ *
17
+ * Cell convention matches the shell's object rendering and the voxel raycast:
18
+ * cell (x,y,z) is the solid box X∈[x-0.5,x+0.5], Y∈[y,y+1], Z∈[z-0.5,z+0.5]. A
19
+ * block at cell y therefore has its top face at world y+1, so a player standing
20
+ * on the surface layer (cell y=-1) rests with their feet at y=0.
21
+ */
22
+ import { MOVEMENT_TUNING } from "./movementModel.js";
23
+ export const DEFAULT_VOXEL_DIMS = {
24
+ halfWidth: 0.3,
25
+ height: 1.8,
26
+ stepHeight: 0.6,
27
+ };
28
+ export function createVoxelPlayerBody(x, y, z) {
29
+ return { x, y, z, velocityX: 0, velocityY: 0, velocityZ: 0, grounded: true, jumpHeld: false };
30
+ }
31
+ const SUPPORT_EPSILON = 0.02;
32
+ const PENETRATION_EPSILON = 1e-4;
33
+ function cellX(worldX) {
34
+ return Math.floor(worldX + 0.5);
35
+ }
36
+ function cellZ(worldZ) {
37
+ return Math.floor(worldZ + 0.5);
38
+ }
39
+ /** True if the player box centred at (x, y, z) overlaps any solid cell. */
40
+ function overlapsSolid(x, y, z, isSolid, dims) {
41
+ const minCellX = cellX(x - dims.halfWidth);
42
+ const maxCellX = cellX(x + dims.halfWidth);
43
+ const minCellZ = cellZ(z - dims.halfWidth);
44
+ const maxCellZ = cellZ(z + dims.halfWidth);
45
+ const minCellY = Math.floor(y + PENETRATION_EPSILON);
46
+ const maxCellY = Math.floor(y + dims.height - PENETRATION_EPSILON);
47
+ for (let cx = minCellX; cx <= maxCellX; cx += 1) {
48
+ for (let cz = minCellZ; cz <= maxCellZ; cz += 1) {
49
+ for (let cy = minCellY; cy <= maxCellY; cy += 1) {
50
+ if (isSolid(cx, cy, cz))
51
+ return true;
52
+ }
53
+ }
54
+ }
55
+ return false;
56
+ }
57
+ /** True if a solid cell sits directly beneath the feet, or terrain supports them (i.e. the player is supported). */
58
+ function hasSupport(x, z, y, isSolid, dims, groundHeight) {
59
+ const cy = Math.floor(y - SUPPORT_EPSILON);
60
+ const minCellX = cellX(x - dims.halfWidth);
61
+ const maxCellX = cellX(x + dims.halfWidth);
62
+ const minCellZ = cellZ(z - dims.halfWidth);
63
+ const maxCellZ = cellZ(z + dims.halfWidth);
64
+ for (let cx = minCellX; cx <= maxCellX; cx += 1) {
65
+ for (let cz = minCellZ; cz <= maxCellZ; cz += 1) {
66
+ if (isSolid(cx, cy, cz))
67
+ return true;
68
+ }
69
+ }
70
+ if (groundHeight !== undefined && y <= groundHeight(x, z) + SUPPORT_EPSILON)
71
+ return true;
72
+ return false;
73
+ }
74
+ /**
75
+ * Highest solid-cell top under the footprint at (x, z) within the vertical
76
+ * window [fromY, toY]. Returns null when nothing supports the feet there — used
77
+ * both to snap a fall onto a block and to raise a step-up.
78
+ */
79
+ function supportTopWithin(x, z, fromY, toY, isSolid, dims) {
80
+ const minCellX = cellX(x - dims.halfWidth);
81
+ const maxCellX = cellX(x + dims.halfWidth);
82
+ const minCellZ = cellZ(z - dims.halfWidth);
83
+ const maxCellZ = cellZ(z + dims.halfWidth);
84
+ const lowCellY = Math.floor(fromY - PENETRATION_EPSILON);
85
+ const highCellY = Math.floor(toY - PENETRATION_EPSILON);
86
+ let best = null;
87
+ for (let cx = minCellX; cx <= maxCellX; cx += 1) {
88
+ for (let cz = minCellZ; cz <= maxCellZ; cz += 1) {
89
+ for (let cy = lowCellY; cy <= highCellY; cy += 1) {
90
+ if (!isSolid(cx, cy, cz))
91
+ continue;
92
+ const top = cy + 1;
93
+ if (best === null || top > best)
94
+ best = top;
95
+ }
96
+ }
97
+ }
98
+ return best;
99
+ }
100
+ function targetHorizontalVelocity(intent, forwardX, forwardZ, baseSpeed) {
101
+ if (!intent.moving)
102
+ return { x: 0, z: 0 };
103
+ let fx = forwardX;
104
+ let fz = forwardZ;
105
+ const lengthSq = fx * fx + fz * fz;
106
+ if (lengthSq < 1e-6) {
107
+ fx = 0;
108
+ fz = -1;
109
+ }
110
+ else {
111
+ const inv = 1 / Math.sqrt(lengthSq);
112
+ fx *= inv;
113
+ fz *= inv;
114
+ }
115
+ const rightX = -fz;
116
+ const rightZ = fx;
117
+ let vx = fx * intent.forward + rightX * intent.right;
118
+ let vz = fz * intent.forward + rightZ * intent.right;
119
+ const magSq = vx * vx + vz * vz;
120
+ if (magSq < 1e-6)
121
+ return { x: 0, z: 0 };
122
+ const speedMultiplier = intent.crouching
123
+ ? MOVEMENT_TUNING.crouchSpeedMultiplier
124
+ : intent.running
125
+ ? MOVEMENT_TUNING.runSpeedMultiplier
126
+ : 1;
127
+ const targetSpeed = baseSpeed * MOVEMENT_TUNING.walkSpeedMultiplier * speedMultiplier;
128
+ const scale = targetSpeed / Math.sqrt(magSq);
129
+ vx *= scale;
130
+ vz *= scale;
131
+ return { x: vx, z: vz };
132
+ }
133
+ /**
134
+ * Move the body along one horizontal axis, resolving collisions. Returns the
135
+ * component of the step that survived (0 when a wall stopped the move). When the
136
+ * move is blocked but the obstacle is a short ledge, the body steps up onto it.
137
+ */
138
+ function moveAxis(body, axis, delta, isSolid, dims) {
139
+ if (delta === 0)
140
+ return 0;
141
+ const nextX = axis === "x" ? body.x + delta : body.x;
142
+ const nextZ = axis === "z" ? body.z + delta : body.z;
143
+ if (!overlapsSolid(nextX, body.y, nextZ, isSolid, dims)) {
144
+ body.x = nextX;
145
+ body.z = nextZ;
146
+ return delta;
147
+ }
148
+ if (body.grounded) {
149
+ const raised = supportTopWithin(nextX, nextZ, body.y, body.y + dims.stepHeight, isSolid, dims);
150
+ if (raised !== null &&
151
+ raised - body.y <= dims.stepHeight + PENETRATION_EPSILON &&
152
+ raised >= body.y &&
153
+ !overlapsSolid(nextX, raised, nextZ, isSolid, dims)) {
154
+ body.x = nextX;
155
+ body.z = nextZ;
156
+ body.y = raised;
157
+ return delta;
158
+ }
159
+ }
160
+ return 0;
161
+ }
162
+ /**
163
+ * Advance one frame of voxel-collided player kinematics. Mutates `body`
164
+ * (position, velocity, grounded) in place.
165
+ *
166
+ * `forwardX`/`forwardZ` are the camera heading projected onto the ground plane
167
+ * (need not be normalized). Pulling this out of the render loop is what lets us
168
+ * assert "dig the block underfoot and the player drops a level" in a unit test.
169
+ *
170
+ * `groundHeight`, when supplied, is an additional solid floor sampled at the
171
+ * body's (x, z): the player never sinks below it and standing on it counts as
172
+ * supported (resets jump state like landing on a solid cell). Where terrain and
173
+ * a solid cell's top disagree, the higher of the two wins.
174
+ */
175
+ export function advanceVoxelPlayer(body, intent, forwardX, forwardZ, baseSpeed, rawDeltaSeconds, isSolid, dims = DEFAULT_VOXEL_DIMS, tuning, groundHeight) {
176
+ const dt = Math.min(rawDeltaSeconds, MOVEMENT_TUNING.maxFrameSeconds);
177
+ const gravityAcceleration = tuning?.gravityAcceleration ?? MOVEMENT_TUNING.gravityAcceleration;
178
+ const jumpVelocity = tuning?.jumpVelocity ?? MOVEMENT_TUNING.jumpVelocity;
179
+ const target = targetHorizontalVelocity(intent, forwardX, forwardZ, baseSpeed);
180
+ const acceleration = body.grounded
181
+ ? MOVEMENT_TUNING.groundAcceleration
182
+ : MOVEMENT_TUNING.airAcceleration;
183
+ const accelerationBlend = 1 - Math.exp(-acceleration * dt);
184
+ body.velocityX += (target.x - body.velocityX) * accelerationBlend;
185
+ body.velocityZ += (target.z - body.velocityZ) * accelerationBlend;
186
+ if (!intent.moving && body.grounded) {
187
+ const friction = Math.exp(-MOVEMENT_TUNING.groundFriction * dt);
188
+ body.velocityX *= friction;
189
+ body.velocityZ *= friction;
190
+ }
191
+ if (moveAxis(body, "x", body.velocityX * dt, isSolid, dims) === 0)
192
+ body.velocityX = 0;
193
+ if (moveAxis(body, "z", body.velocityZ * dt, isSolid, dims) === 0)
194
+ body.velocityZ = 0;
195
+ const supported = body.grounded && hasSupport(body.x, body.z, body.y, isSolid, dims, groundHeight);
196
+ body.grounded = supported;
197
+ const jumpPressed = intent.jumping;
198
+ if (jumpPressed && !body.jumpHeld && body.grounded && !intent.crouching) {
199
+ body.velocityY = jumpVelocity;
200
+ body.grounded = false;
201
+ }
202
+ body.jumpHeld = jumpPressed;
203
+ if (!body.grounded) {
204
+ body.velocityY -= gravityAcceleration * dt;
205
+ const nextY = body.y + body.velocityY * dt;
206
+ if (body.velocityY <= 0) {
207
+ const terrainFloor = groundHeight?.(body.x, body.z);
208
+ const solidBlocked = overlapsSolid(body.x, nextY, body.z, isSolid, dims);
209
+ const terrainBlocked = terrainFloor !== undefined && nextY <= terrainFloor;
210
+ if (solidBlocked || terrainBlocked) {
211
+ const solidTop = solidBlocked
212
+ ? (supportTopWithin(body.x, body.z, nextY, body.y + dims.height, isSolid, dims) ?? Math.floor(body.y))
213
+ : undefined;
214
+ body.y = Math.max(solidTop ?? -Infinity, terrainFloor ?? -Infinity);
215
+ body.velocityY = 0;
216
+ body.grounded = true;
217
+ }
218
+ else {
219
+ body.y = nextY;
220
+ }
221
+ }
222
+ else if (overlapsSolid(body.x, nextY, body.z, isSolid, dims)) {
223
+ body.velocityY = 0;
224
+ }
225
+ else {
226
+ body.y = nextY;
227
+ }
228
+ }
229
+ }
@@ -0,0 +1,42 @@
1
+ import type { ChatMessage } from "../game/chat.js";
2
+ export interface ChatSendArgs {
3
+ channelId: string;
4
+ body: string;
5
+ }
6
+ export interface ChatSendOutcome {
7
+ ok: boolean;
8
+ reason?: string;
9
+ }
10
+ export interface ChatActions {
11
+ sendMessage(args: ChatSendArgs): Promise<ChatSendOutcome>;
12
+ }
13
+ /**
14
+ * Backend seam for remote text chat, mirroring PresenceTransport: the use*
15
+ * members are called as React hooks by consumers, so a mounted transport must
16
+ * never change identity — remount the subtree to switch backends. useMessages
17
+ * returns undefined while the subscription is loading and the channel's recent
18
+ * history once live.
19
+ */
20
+ export interface ChatTransport {
21
+ useMessages(channelId: string | "skip"): readonly ChatMessage[] | undefined;
22
+ useActions(): ChatActions;
23
+ }
24
+ /**
25
+ * Callback seam for backends that cannot host React hooks (e.g. the ws
26
+ * client): subscribe delivers the channel's recent history on every change;
27
+ * send resolves with the host's verdict. @jgengine/react's chatTransportFromSync
28
+ * lifts a ChatSync into a ChatTransport.
29
+ */
30
+ export interface ChatSync {
31
+ subscribe(channelId: string, onChange: (messages: readonly ChatMessage[]) => void): () => void;
32
+ send(channelId: string, body: string): Promise<ChatSendOutcome>;
33
+ }
34
+ export declare function createLocalChatTransport(options?: {
35
+ userId?: string;
36
+ historyLimit?: number;
37
+ now?: () => number;
38
+ }): {
39
+ transport: ChatTransport;
40
+ sync: ChatSync;
41
+ actions: ChatActions;
42
+ };
@@ -0,0 +1,57 @@
1
+ export function createLocalChatTransport(options) {
2
+ const userId = options?.userId ?? "local";
3
+ const historyLimit = options?.historyLimit ?? 100;
4
+ const now = options?.now ?? Date.now;
5
+ const messagesByChannel = new Map();
6
+ const listeners = new Map();
7
+ let counter = 0;
8
+ function snapshot(channelId) {
9
+ return (messagesByChannel.get(channelId) ?? []).slice();
10
+ }
11
+ const sync = {
12
+ subscribe(channelId, onChange) {
13
+ let set = listeners.get(channelId);
14
+ if (set === undefined) {
15
+ set = new Set();
16
+ listeners.set(channelId, set);
17
+ }
18
+ set.add(onChange);
19
+ onChange(snapshot(channelId));
20
+ return () => {
21
+ set.delete(onChange);
22
+ if (set.size === 0)
23
+ listeners.delete(channelId);
24
+ };
25
+ },
26
+ send(channelId, body) {
27
+ const trimmed = body.trim();
28
+ if (trimmed.length === 0)
29
+ return Promise.resolve({ ok: false, reason: "empty message" });
30
+ counter += 1;
31
+ const message = {
32
+ id: `local_${counter}`,
33
+ channelId,
34
+ fromUserId: userId,
35
+ body: trimmed,
36
+ at: now(),
37
+ };
38
+ const ring = messagesByChannel.get(channelId) ?? [];
39
+ ring.push(message);
40
+ if (ring.length > historyLimit)
41
+ ring.splice(0, ring.length - historyLimit);
42
+ messagesByChannel.set(channelId, ring);
43
+ const current = snapshot(channelId);
44
+ for (const listener of listeners.get(channelId) ?? [])
45
+ listener(current);
46
+ return Promise.resolve({ ok: true });
47
+ },
48
+ };
49
+ const actions = {
50
+ sendMessage: (args) => sync.send(args.channelId, args.body),
51
+ };
52
+ const transport = {
53
+ useMessages: (channelId) => (channelId === "skip" ? undefined : snapshot(channelId)),
54
+ useActions: () => actions,
55
+ };
56
+ return { transport, sync, actions };
57
+ }
@@ -0,0 +1,13 @@
1
+ export interface AuthSession {
2
+ userId: string;
3
+ displayName?: string;
4
+ avatarUrl?: string;
5
+ email?: string;
6
+ isNew?: boolean;
7
+ }
8
+ export interface PlayerIdentity {
9
+ userId: string;
10
+ isNew: boolean;
11
+ }
12
+ export declare function sessionPlayer(session: AuthSession): PlayerIdentity;
13
+ export declare function resolveGuestSession(seed?: string): AuthSession;
@@ -0,0 +1,20 @@
1
+ export function sessionPlayer(session) {
2
+ return { userId: session.userId, isNew: session.isNew ?? false };
3
+ }
4
+ const DEFAULT_GUEST_SEED = "local";
5
+ export function resolveGuestSession(seed) {
6
+ const normalized = seed === undefined || seed.trim().length === 0 ? DEFAULT_GUEST_SEED : seed.trim();
7
+ const hash = fnv1a(normalized);
8
+ return {
9
+ userId: `guest_${hash}`,
10
+ displayName: `Guest ${hash.slice(0, 4).toUpperCase()}`,
11
+ };
12
+ }
13
+ function fnv1a(input) {
14
+ let hash = 0x811c9dc5;
15
+ for (let index = 0; index < input.length; index += 1) {
16
+ hash ^= input.charCodeAt(index);
17
+ hash = Math.imul(hash, 0x01000193) >>> 0;
18
+ }
19
+ return hash.toString(36);
20
+ }
@@ -4,6 +4,7 @@ export interface PlayerPose {
4
4
  z: number;
5
5
  rotationY: number;
6
6
  rotationPitch: number;
7
+ appearance?: Record<string, string>;
7
8
  }
8
9
  export interface PoseSyncTuning {
9
10
  minIntervalMs: number;
@@ -1,9 +1,21 @@
1
+ function appearanceChanged(a, b) {
2
+ if (a === b)
3
+ return false;
4
+ if (a === undefined || b === undefined)
5
+ return true;
6
+ const aKeys = Object.keys(a);
7
+ const bKeys = Object.keys(b);
8
+ if (aKeys.length !== bKeys.length)
9
+ return true;
10
+ return aKeys.some((key) => a[key] !== b[key]);
11
+ }
1
12
  function poseChanged(a, b, tuning) {
2
13
  return (Math.abs(a.x - b.x) > tuning.positionEpsilon
3
14
  || Math.abs(a.y - b.y) > tuning.verticalEpsilon
4
15
  || Math.abs(a.z - b.z) > tuning.positionEpsilon
5
16
  || Math.abs(a.rotationY - b.rotationY) > tuning.rotationEpsilon
6
- || Math.abs(a.rotationPitch - b.rotationPitch) > tuning.rotationEpsilon);
17
+ || Math.abs(a.rotationPitch - b.rotationPitch) > tuning.rotationEpsilon
18
+ || appearanceChanged(a.appearance, b.appearance));
7
19
  }
8
20
  export function createPoseSyncGate(tuning) {
9
21
  let lastSentPose = null;
@@ -7,6 +7,7 @@ export interface PresencePoseState {
7
7
  rotationY: number;
8
8
  rotationPitch?: number;
9
9
  lastSeenAtMs?: number;
10
+ appearance?: Record<string, string>;
10
11
  }
11
12
  export interface IncomingPose {
12
13
  position: {
@@ -16,6 +17,7 @@ export interface IncomingPose {
16
17
  };
17
18
  rotationY?: number;
18
19
  rotationPitch?: number;
20
+ appearance?: Record<string, string>;
19
21
  }
20
22
  export interface PoseSyncRules {
21
23
  /** Speed cap (units/sec) for client-authoritative movement. */
@@ -36,6 +38,7 @@ export interface PoseSyncDecision {
36
38
  };
37
39
  rotationY: number;
38
40
  rotationPitch: number;
41
+ appearance?: Record<string, string>;
39
42
  /** True when the pose differs and a pose write is needed. */
40
43
  changed: boolean;
41
44
  /** True when only the keep-alive stamp should be written. */
@@ -1,3 +1,14 @@
1
+ function appearanceEqual(a, b) {
2
+ if (a === b)
3
+ return true;
4
+ if (a === undefined || b === undefined)
5
+ return false;
6
+ const aKeys = Object.keys(a);
7
+ const bKeys = Object.keys(b);
8
+ if (aKeys.length !== bKeys.length)
9
+ return false;
10
+ return aKeys.every((key) => a[key] === b[key]);
11
+ }
1
12
  export function decidePoseSync(current, incoming, rules, nowMs) {
2
13
  const elapsedSec = Math.max(rules.minElapsedSec, Math.min(rules.maxElapsedSec, (nowMs - (current.lastSeenAtMs ?? nowMs)) / 1000));
3
14
  const maxDist = rules.maxSpeed * elapsedSec;
@@ -17,14 +28,17 @@ export function decidePoseSync(current, incoming, rules, nowMs) {
17
28
  : Math.max(0, Math.min(rules.maxVerticalOffset, incoming.position.y));
18
29
  const nextRotationY = incoming.rotationY ?? current.rotationY;
19
30
  const nextRotationPitch = incoming.rotationPitch ?? current.rotationPitch ?? 0;
31
+ const nextAppearance = incoming.appearance ?? current.appearance;
32
+ const appearanceChanged = incoming.appearance !== undefined && !appearanceEqual(incoming.appearance, current.appearance);
20
33
  const moved = targetX !== current.position.x || targetZ !== current.position.z;
21
34
  const jumped = nextY !== current.position.y;
22
35
  const rotated = nextRotationY !== current.rotationY || nextRotationPitch !== (current.rotationPitch ?? 0);
23
- const changed = moved || jumped || rotated;
36
+ const changed = moved || jumped || rotated || appearanceChanged;
24
37
  return {
25
38
  position: { x: targetX, y: nextY, z: targetZ },
26
39
  rotationY: nextRotationY,
27
40
  rotationPitch: nextRotationPitch,
41
+ appearance: nextAppearance,
28
42
  changed,
29
43
  refreshKeepAlive: !changed && shouldRefreshKeepAlive(current.lastSeenAtMs, nowMs, rules),
30
44
  };
@@ -0,0 +1,44 @@
1
+ export interface VoiceParticipant {
2
+ userId: string;
3
+ streamId?: string;
4
+ }
5
+ export interface VoiceRoute {
6
+ fromUserId: string;
7
+ channelId: string;
8
+ gain: number;
9
+ }
10
+ /**
11
+ * Signaling seam for voice: who is in a channel and which media stream
12
+ * descriptor they published. The media plane (WebRTC, SFU, or anything else
13
+ * that moves audio bytes) stays behind this seam, host-supplied — the engine
14
+ * never touches it. subscribers delivers the channel roster on every change,
15
+ * starting with the current roster.
16
+ */
17
+ export interface VoiceTransport {
18
+ join(channelId: string, streamId?: string): Promise<void>;
19
+ leave(channelId: string): Promise<void>;
20
+ publish(channelId: string, streamId: string): Promise<void>;
21
+ subscribers(channelId: string, onChange: (participants: readonly VoiceParticipant[]) => void): () => void;
22
+ }
23
+ export declare function createLocalVoiceTransport(options?: {
24
+ userId?: string;
25
+ }): {
26
+ transport: VoiceTransport;
27
+ participants(channelId: string): readonly VoiceParticipant[];
28
+ };
29
+ export type PushToTalkMode = "hold" | "toggle" | "openMic";
30
+ export type PushToTalkStatus = "idle" | "keyed" | "open";
31
+ export interface PushToTalk {
32
+ mode(): PushToTalkMode;
33
+ setMode(mode: PushToTalkMode): void;
34
+ keyDown(): void;
35
+ keyUp(): void;
36
+ muted(): boolean;
37
+ setMuted(muted: boolean): void;
38
+ status(): PushToTalkStatus;
39
+ transmitting(): boolean;
40
+ }
41
+ export declare function createPushToTalk(config?: {
42
+ mode?: PushToTalkMode;
43
+ onChange?: (transmitting: boolean) => void;
44
+ }): PushToTalk;
@@ -0,0 +1,108 @@
1
+ export function createLocalVoiceTransport(options) {
2
+ const userId = options?.userId ?? "local";
3
+ const rosters = new Map();
4
+ const listeners = new Map();
5
+ function roster(channelId) {
6
+ let entries = rosters.get(channelId);
7
+ if (entries === undefined) {
8
+ entries = new Map();
9
+ rosters.set(channelId, entries);
10
+ }
11
+ return entries;
12
+ }
13
+ function snapshot(channelId) {
14
+ return [...roster(channelId).values()].map((participant) => ({ ...participant }));
15
+ }
16
+ function notify(channelId) {
17
+ const current = snapshot(channelId);
18
+ for (const listener of listeners.get(channelId) ?? [])
19
+ listener(current);
20
+ }
21
+ const transport = {
22
+ join(channelId, streamId) {
23
+ const participant = { userId };
24
+ if (streamId !== undefined)
25
+ participant.streamId = streamId;
26
+ roster(channelId).set(userId, participant);
27
+ notify(channelId);
28
+ return Promise.resolve();
29
+ },
30
+ leave(channelId) {
31
+ if (roster(channelId).delete(userId))
32
+ notify(channelId);
33
+ return Promise.resolve();
34
+ },
35
+ publish(channelId, streamId) {
36
+ const participant = roster(channelId).get(userId);
37
+ if (participant !== undefined) {
38
+ participant.streamId = streamId;
39
+ notify(channelId);
40
+ }
41
+ return Promise.resolve();
42
+ },
43
+ subscribers(channelId, onChange) {
44
+ let set = listeners.get(channelId);
45
+ if (set === undefined) {
46
+ set = new Set();
47
+ listeners.set(channelId, set);
48
+ }
49
+ set.add(onChange);
50
+ onChange(snapshot(channelId));
51
+ return () => {
52
+ set.delete(onChange);
53
+ if (set.size === 0)
54
+ listeners.delete(channelId);
55
+ };
56
+ },
57
+ };
58
+ return { transport, participants: snapshot };
59
+ }
60
+ export function createPushToTalk(config) {
61
+ let mode = config?.mode ?? "hold";
62
+ let keyed = false;
63
+ let muted = false;
64
+ let lastTransmitting = false;
65
+ function transmitting() {
66
+ if (muted)
67
+ return false;
68
+ return mode === "openMic" ? true : keyed;
69
+ }
70
+ function emit() {
71
+ const current = transmitting();
72
+ if (current === lastTransmitting)
73
+ return;
74
+ lastTransmitting = current;
75
+ config?.onChange?.(current);
76
+ }
77
+ return {
78
+ mode: () => mode,
79
+ setMode(next) {
80
+ mode = next;
81
+ keyed = false;
82
+ emit();
83
+ },
84
+ keyDown() {
85
+ if (mode === "hold")
86
+ keyed = true;
87
+ else if (mode === "toggle")
88
+ keyed = !keyed;
89
+ emit();
90
+ },
91
+ keyUp() {
92
+ if (mode === "hold")
93
+ keyed = false;
94
+ emit();
95
+ },
96
+ muted: () => muted,
97
+ setMuted(next) {
98
+ muted = next;
99
+ emit();
100
+ },
101
+ status() {
102
+ if (mode === "openMic")
103
+ return "open";
104
+ return keyed ? "keyed" : "idle";
105
+ },
106
+ transmitting,
107
+ };
108
+ }
@@ -0,0 +1,14 @@
1
+ import type { EntityPosition } from "../scene/entityStore.js";
2
+ import type { NavGrid, NavPoint } from "./navGrid.js";
3
+ export interface NavConstrainProposed {
4
+ position: EntityPosition;
5
+ rotationY: number;
6
+ grounded: boolean;
7
+ }
8
+ export interface NavConstrainEntity {
9
+ position: EntityPosition;
10
+ }
11
+ export interface NavConstrainOptions {
12
+ y?: (point: NavPoint) => number;
13
+ }
14
+ export declare function constrainToNavGrid(grid: NavGrid, options?: NavConstrainOptions): (proposed: NavConstrainProposed, entity: NavConstrainEntity) => NavConstrainProposed | null;
@@ -0,0 +1,26 @@
1
+ function withYRemap(proposed, options) {
2
+ if (options?.y === undefined)
3
+ return proposed;
4
+ const y = options.y([proposed.position[0], proposed.position[2]]);
5
+ return { ...proposed, position: [proposed.position[0], y, proposed.position[2]] };
6
+ }
7
+ function walkable(grid, point) {
8
+ const cell = grid.cellAt(point);
9
+ return grid.isWalkable(cell.col, cell.row);
10
+ }
11
+ export function constrainToNavGrid(grid, options) {
12
+ return (proposed, entity) => {
13
+ const target = [proposed.position[0], proposed.position[2]];
14
+ if (walkable(grid, target))
15
+ return withYRemap(proposed, options);
16
+ const slideX = [entity.position[0], proposed.position[2]];
17
+ if (walkable(grid, slideX)) {
18
+ return withYRemap({ ...proposed, position: [entity.position[0], proposed.position[1], proposed.position[2]] }, options);
19
+ }
20
+ const slideZ = [proposed.position[0], entity.position[2]];
21
+ if (walkable(grid, slideZ)) {
22
+ return withYRemap({ ...proposed, position: [proposed.position[0], proposed.position[1], entity.position[2]] }, options);
23
+ }
24
+ return null;
25
+ };
26
+ }
@@ -0,0 +1,11 @@
1
+ import type { EnvironmentWorldFeature } from "../world/features.js";
2
+ import type { Aabb } from "../world/geometry.js";
3
+ export interface NavObstacleGrid {
4
+ blockAabb(aabb: Aabb): void;
5
+ }
6
+ /**
7
+ * Expands every structure descriptor on an environment world feature into its
8
+ * generated buildings and blocks their footprints on `grid`. Returns the
9
+ * number of buildings blocked.
10
+ */
11
+ export declare function populateNavGridFromEnvironment(grid: NavObstacleGrid, world: EnvironmentWorldFeature): number;
@@ -0,0 +1,16 @@
1
+ import { resolveStructureBuildings } from "../world/environmentSummary.js";
2
+ /**
3
+ * Expands every structure descriptor on an environment world feature into its
4
+ * generated buildings and blocks their footprints on `grid`. Returns the
5
+ * number of buildings blocked.
6
+ */
7
+ export function populateNavGridFromEnvironment(grid, world) {
8
+ let blocked = 0;
9
+ for (const descriptor of world.structures ?? []) {
10
+ for (const building of resolveStructureBuildings(descriptor)) {
11
+ grid.blockAabb(building.bounds);
12
+ blocked += 1;
13
+ }
14
+ }
15
+ return blocked;
16
+ }