@jgengine/ws 0.6.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.
package/dist/peer.js ADDED
@@ -0,0 +1,237 @@
1
+ import { createGameHost, memoryPersistence } from "./host.js";
2
+ import { createHostRouter, loopbackPipe } from "./hostRouter.js";
3
+ import { createWsBackend } from "./createWsBackend.js";
4
+ const DEFAULT_ICE_GATHER_TIMEOUT_MS = 2_000;
5
+ function isRecord(value) {
6
+ return typeof value === "object" && value !== null;
7
+ }
8
+ export function encodePeerSignal(payload) {
9
+ const base64 = btoa(JSON.stringify(payload));
10
+ return base64.replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
11
+ }
12
+ export function decodePeerSignal(code) {
13
+ try {
14
+ const base64 = code.replace(/-/g, "+").replace(/_/g, "/");
15
+ const padded = base64 + "=".repeat((4 - (base64.length % 4)) % 4);
16
+ const parsed = JSON.parse(atob(padded));
17
+ if (!isRecord(parsed))
18
+ return null;
19
+ if (parsed.type !== "offer" && parsed.type !== "answer")
20
+ return null;
21
+ if (typeof parsed.sdp !== "string")
22
+ return null;
23
+ return { type: parsed.type, sdp: parsed.sdp };
24
+ }
25
+ catch {
26
+ return null;
27
+ }
28
+ }
29
+ function waitForIceGathering(pc, timeoutMs) {
30
+ if (pc.iceGatheringState === "complete")
31
+ return Promise.resolve();
32
+ return new Promise((resolve) => {
33
+ let settled = false;
34
+ const finish = () => {
35
+ if (settled)
36
+ return;
37
+ settled = true;
38
+ pc.onicegatheringstatechange = null;
39
+ clearTimeout(timer);
40
+ resolve();
41
+ };
42
+ pc.onicegatheringstatechange = () => {
43
+ if (pc.iceGatheringState === "complete")
44
+ finish();
45
+ };
46
+ const timer = setTimeout(finish, timeoutMs);
47
+ });
48
+ }
49
+ function localDescriptionOrThrow(pc) {
50
+ const description = pc.localDescription;
51
+ if (description === null)
52
+ throw new Error("No local description");
53
+ return description;
54
+ }
55
+ function bindDataChannelPipe(channel, handlers) {
56
+ if (channel.readyState === "open") {
57
+ queueMicrotask(() => handlers.onOpen());
58
+ }
59
+ else {
60
+ channel.onopen = () => handlers.onOpen();
61
+ }
62
+ channel.onmessage = (event) => {
63
+ handlers.onMessage(typeof event.data === "string" ? event.data : String(event.data));
64
+ };
65
+ channel.onclose = () => handlers.onClose();
66
+ }
67
+ function guestPipeFactory(channel) {
68
+ let bound = false;
69
+ return (handlers) => {
70
+ if (bound) {
71
+ queueMicrotask(() => handlers.onClose());
72
+ return { send: () => undefined, close: () => undefined };
73
+ }
74
+ bound = true;
75
+ bindDataChannelPipe(channel, handlers);
76
+ return {
77
+ send: (data) => {
78
+ if (channel.readyState !== "open")
79
+ return;
80
+ channel.send(data);
81
+ },
82
+ close: () => channel.close(),
83
+ };
84
+ };
85
+ }
86
+ export function createPeerHost(options) {
87
+ const ownsHost = options.host === undefined;
88
+ const host = options.host ??
89
+ createGameHost({
90
+ runtimes: options.runtimes,
91
+ persistence: options.persistence ?? memoryPersistence(),
92
+ tickMs: options.tickMs,
93
+ });
94
+ if (ownsHost)
95
+ host.start();
96
+ const router = createHostRouter({ ...options.router, host });
97
+ const backend = createWsBackend({
98
+ userId: options.userId,
99
+ token: options.token,
100
+ pipe: loopbackPipe(router),
101
+ });
102
+ const configuration = options.rtc?.configuration ?? {};
103
+ const iceGatherTimeoutMs = options.rtc?.iceGatherTimeoutMs ?? DEFAULT_ICE_GATHER_TIMEOUT_MS;
104
+ const peerConnections = new Set();
105
+ const accept = async (offerCode) => {
106
+ const payload = decodePeerSignal(offerCode);
107
+ if (payload === null)
108
+ throw new Error("Invalid signal code");
109
+ const pc = new RTCPeerConnection(configuration);
110
+ peerConnections.add(pc);
111
+ pc.ondatachannel = (event) => {
112
+ const channel = event.channel;
113
+ const connection = router.connect({
114
+ send: (data) => {
115
+ if (channel.readyState === "open")
116
+ channel.send(data);
117
+ },
118
+ close: () => channel.close(),
119
+ });
120
+ channel.onmessage = (messageEvent) => {
121
+ connection.handleRaw(typeof messageEvent.data === "string" ? messageEvent.data : String(messageEvent.data));
122
+ };
123
+ channel.onclose = () => connection.close();
124
+ };
125
+ await pc.setRemoteDescription({ type: payload.type, sdp: payload.sdp });
126
+ const answer = await pc.createAnswer();
127
+ await pc.setLocalDescription(answer);
128
+ await waitForIceGathering(pc, iceGatherTimeoutMs);
129
+ const description = localDescriptionOrThrow(pc);
130
+ return encodePeerSignal({ type: "answer", sdp: description.sdp });
131
+ };
132
+ return {
133
+ backend,
134
+ host,
135
+ router,
136
+ accept,
137
+ close: () => {
138
+ for (const pc of peerConnections)
139
+ pc.close();
140
+ peerConnections.clear();
141
+ backend.close();
142
+ router.close();
143
+ if (ownsHost)
144
+ void host.stop();
145
+ },
146
+ };
147
+ }
148
+ export function createPeerGuest(options) {
149
+ const configuration = options.rtc?.configuration ?? {};
150
+ const iceGatherTimeoutMs = options.rtc?.iceGatherTimeoutMs ?? DEFAULT_ICE_GATHER_TIMEOUT_MS;
151
+ const pc = new RTCPeerConnection(configuration);
152
+ const channel = pc.createDataChannel("jg");
153
+ const backend = createWsBackend({
154
+ userId: options.userId,
155
+ token: options.token,
156
+ pipe: guestPipeFactory(channel),
157
+ });
158
+ return {
159
+ backend,
160
+ offer: async () => {
161
+ const offerDescription = await pc.createOffer();
162
+ await pc.setLocalDescription(offerDescription);
163
+ await waitForIceGathering(pc, iceGatherTimeoutMs);
164
+ const description = localDescriptionOrThrow(pc);
165
+ return encodePeerSignal({ type: "offer", sdp: description.sdp });
166
+ },
167
+ connect: async (answerCode) => {
168
+ const payload = decodePeerSignal(answerCode);
169
+ if (payload === null)
170
+ throw new Error("Invalid signal code");
171
+ await pc.setRemoteDescription({ type: payload.type, sdp: payload.sdp });
172
+ },
173
+ close: () => {
174
+ backend.close();
175
+ pc.close();
176
+ },
177
+ };
178
+ }
179
+ function isSignalEnvelope(value) {
180
+ return (isRecord(value) &&
181
+ (value.t === "offer" || value.t === "answer") &&
182
+ typeof value.id === "string" &&
183
+ typeof value.code === "string");
184
+ }
185
+ export function broadcastChannelSignaling(room) {
186
+ const channel = new BroadcastChannel(room);
187
+ const pendingAnswers = new Map();
188
+ let currentAnswer = null;
189
+ channel.onmessage = (event) => {
190
+ const envelope = event.data;
191
+ if (!isSignalEnvelope(envelope))
192
+ return;
193
+ if (envelope.t === "offer") {
194
+ const answer = currentAnswer;
195
+ if (answer === null)
196
+ return;
197
+ void answer(envelope.code).then((answerCode) => {
198
+ channel.postMessage({ t: "answer", id: envelope.id, code: answerCode });
199
+ });
200
+ return;
201
+ }
202
+ const resolve = pendingAnswers.get(envelope.id);
203
+ if (resolve === undefined)
204
+ return;
205
+ pendingAnswers.delete(envelope.id);
206
+ resolve(envelope.code);
207
+ };
208
+ return {
209
+ publishOffer: (offerCode) => {
210
+ const id = crypto.randomUUID();
211
+ return new Promise((resolve) => {
212
+ pendingAnswers.set(id, resolve);
213
+ channel.postMessage({ t: "offer", id, code: offerCode });
214
+ });
215
+ },
216
+ onOffer: (answer) => {
217
+ currentAnswer = answer;
218
+ return () => {
219
+ if (currentAnswer === answer)
220
+ currentAnswer = null;
221
+ };
222
+ },
223
+ close: () => {
224
+ pendingAnswers.clear();
225
+ channel.close();
226
+ },
227
+ };
228
+ }
229
+ export function announcePeerHost(host, signaling) {
230
+ return signaling.onOffer(host.accept);
231
+ }
232
+ export async function joinPeerSession(guest, signaling) {
233
+ const offerCode = await guest.offer();
234
+ const answerCode = await signaling.publishOffer(offerCode);
235
+ await guest.connect(answerCode);
236
+ return guest.backend;
237
+ }
package/dist/pipe.d.ts ADDED
@@ -0,0 +1,11 @@
1
+ export type TransportPipe = {
2
+ send: (data: string) => void;
3
+ close: () => void;
4
+ };
5
+ export type TransportPipeHandlers = {
6
+ onOpen: () => void;
7
+ onMessage: (data: string) => void;
8
+ onClose: () => void;
9
+ };
10
+ export type TransportPipeFactory = (handlers: TransportPipeHandlers) => TransportPipe;
11
+ export declare function webSocketPipe(url: string, webSocketFactory?: (url: string) => WebSocket): TransportPipeFactory;
package/dist/pipe.js ADDED
@@ -0,0 +1,16 @@
1
+ export function webSocketPipe(url, webSocketFactory = (target) => new WebSocket(target)) {
2
+ return (handlers) => {
3
+ const socket = webSocketFactory(url);
4
+ socket.onopen = () => handlers.onOpen();
5
+ socket.onmessage = (event) => handlers.onMessage(typeof event.data === "string" ? event.data : String(event.data));
6
+ socket.onclose = () => handlers.onClose();
7
+ return {
8
+ send: (data) => {
9
+ if (socket.readyState !== 1)
10
+ return;
11
+ socket.send(data);
12
+ },
13
+ close: () => socket.close(),
14
+ };
15
+ };
16
+ }
@@ -1,23 +1,27 @@
1
- import type { GameRuntimePlayerView, GameRuntimeServerView, JoinServerResult, TransportRunCommandResult } from "@jgengine/core/runtime/transport";
1
+ import type { GameRuntimePlayerView, GameRuntimeServerView, JoinServerResult, PresencePoseRow, TransportRunCommandResult } from "@jgengine/core/runtime/transport";
2
+ import type { PlayerPose } from "@jgengine/core/multiplayer/poseSyncGate";
3
+ import type { SessionAttributes } from "@jgengine/core/runtime/hostPersistence";
4
+ import type { MatchFilter, SessionListing } from "@jgengine/core/multiplayer/matchmaking";
2
5
  export declare const WS_PROTOCOL_VERSION = 1;
3
- export type WsChannel = "server" | "player" | "feed" | "presence";
4
- export type WsPose = {
5
- x: number;
6
- y: number;
7
- z: number;
8
- rotationY: number;
9
- rotationPitch: number;
6
+ export type WsChannel = "server" | "player" | "feed" | "presence" | "chat" | "voice";
7
+ /** Client-set cosmetic/state tags carried alongside a pose (skin, mount, emote, ...). Primitive values only. */
8
+ export type WsAppearance = Record<string, string | number | boolean>;
9
+ export type WsPose = PlayerPose & {
10
+ appearance?: WsAppearance;
10
11
  };
11
- export type WsPresenceRow = {
12
+ export type WsPresenceRow = PresencePoseRow & {
13
+ appearance?: WsAppearance;
14
+ };
15
+ export type WsChatMessage = {
16
+ id: string;
17
+ channelId: string;
18
+ fromUserId: string;
19
+ body: string;
20
+ at: number;
21
+ };
22
+ export type WsVoiceParticipant = {
12
23
  userId: string;
13
- position: {
14
- x: number;
15
- y: number;
16
- z: number;
17
- };
18
- rotationY: number;
19
- rotationPitch: number;
20
- lastSeenAt: number;
24
+ streamId?: string;
21
25
  };
22
26
  export type WsClientMessage = {
23
27
  v: 1;
@@ -31,6 +35,20 @@ export type WsClientMessage = {
31
35
  id: number;
32
36
  gameId: string;
33
37
  serverId?: string;
38
+ attributes?: SessionAttributes;
39
+ } | {
40
+ v: 1;
41
+ t: "joinByCode";
42
+ id: number;
43
+ gameId: string;
44
+ code: string;
45
+ } | {
46
+ v: 1;
47
+ t: "browse";
48
+ id: number;
49
+ gameId: string;
50
+ filter?: MatchFilter;
51
+ limit?: number;
34
52
  } | {
35
53
  v: 1;
36
54
  t: "leave";
@@ -69,6 +87,33 @@ export type WsClientMessage = {
69
87
  t: "pose";
70
88
  serverId: string;
71
89
  pose: WsPose;
90
+ } | {
91
+ v: 1;
92
+ t: "chatSend";
93
+ id: number;
94
+ serverId: string;
95
+ channelId: string;
96
+ body: string;
97
+ } | {
98
+ v: 1;
99
+ t: "voiceJoin";
100
+ id: number;
101
+ serverId: string;
102
+ channelId: string;
103
+ streamId?: string;
104
+ } | {
105
+ v: 1;
106
+ t: "voiceLeave";
107
+ id: number;
108
+ serverId: string;
109
+ channelId: string;
110
+ } | {
111
+ v: 1;
112
+ t: "voicePublish";
113
+ id: number;
114
+ serverId: string;
115
+ channelId: string;
116
+ streamId: string;
72
117
  };
73
118
  export type WsUpdateMessage = {
74
119
  v: 1;
@@ -95,6 +140,20 @@ export type WsUpdateMessage = {
95
140
  channel: "presence";
96
141
  serverId: string;
97
142
  data: WsPresenceRow[];
143
+ } | {
144
+ v: 1;
145
+ t: "update";
146
+ channel: "chat";
147
+ serverId: string;
148
+ action: string;
149
+ data: WsChatMessage[];
150
+ } | {
151
+ v: 1;
152
+ t: "update";
153
+ channel: "voice";
154
+ serverId: string;
155
+ action: string;
156
+ data: WsVoiceParticipant[];
98
157
  };
99
158
  export type WsServerMessage = {
100
159
  v: 1;
@@ -111,6 +170,8 @@ export type WsServerMessage = {
111
170
  } | WsUpdateMessage;
112
171
  export type WsJoinResult = JoinServerResult;
113
172
  export type WsRunCommandResult = TransportRunCommandResult;
173
+ export type WsBrowseResult = SessionListing[];
174
+ export type WsJoinByCodeResult = JoinServerResult | null;
114
175
  export declare function encodeWsMessage(message: WsClientMessage | WsServerMessage): string;
115
176
  export declare function decodeWsClientMessage(raw: unknown): WsClientMessage | null;
116
177
  export declare function decodeWsServerMessage(raw: unknown): WsServerMessage | null;
package/dist/protocol.js CHANGED
@@ -24,7 +24,16 @@ function parseVersioned(raw) {
24
24
  return parsed;
25
25
  }
26
26
  function isWsChannel(value) {
27
- return value === "server" || value === "player" || value === "feed" || value === "presence";
27
+ return (value === "server" ||
28
+ value === "player" ||
29
+ value === "feed" ||
30
+ value === "presence" ||
31
+ value === "chat" ||
32
+ value === "voice");
33
+ }
34
+ function isAppearance(value) {
35
+ return (isRecord(value) &&
36
+ Object.values(value).every((v) => typeof v === "string" || typeof v === "number" || typeof v === "boolean"));
28
37
  }
29
38
  function isPose(value) {
30
39
  return (isRecord(value) &&
@@ -32,7 +41,8 @@ function isPose(value) {
32
41
  typeof value.y === "number" &&
33
42
  typeof value.z === "number" &&
34
43
  typeof value.rotationY === "number" &&
35
- typeof value.rotationPitch === "number");
44
+ typeof value.rotationPitch === "number" &&
45
+ (value.appearance === undefined || isAppearance(value.appearance)));
36
46
  }
37
47
  export function decodeWsClientMessage(raw) {
38
48
  const message = parseVersioned(raw);
@@ -51,6 +61,16 @@ export function decodeWsClientMessage(raw) {
51
61
  (message.serverId === undefined || typeof message.serverId === "string")
52
62
  ? message
53
63
  : null;
64
+ case "joinByCode":
65
+ return typeof message.id === "number" &&
66
+ typeof message.gameId === "string" &&
67
+ typeof message.code === "string"
68
+ ? message
69
+ : null;
70
+ case "browse":
71
+ return typeof message.id === "number" && typeof message.gameId === "string"
72
+ ? message
73
+ : null;
54
74
  case "leave":
55
75
  return typeof message.id === "number" && typeof message.serverId === "string"
56
76
  ? message
@@ -79,6 +99,33 @@ export function decodeWsClientMessage(raw) {
79
99
  return typeof message.serverId === "string" && isPose(message.pose)
80
100
  ? message
81
101
  : null;
102
+ case "chatSend":
103
+ return typeof message.id === "number" &&
104
+ typeof message.serverId === "string" &&
105
+ typeof message.channelId === "string" &&
106
+ typeof message.body === "string"
107
+ ? message
108
+ : null;
109
+ case "voiceJoin":
110
+ return typeof message.id === "number" &&
111
+ typeof message.serverId === "string" &&
112
+ typeof message.channelId === "string" &&
113
+ (message.streamId === undefined || typeof message.streamId === "string")
114
+ ? message
115
+ : null;
116
+ case "voiceLeave":
117
+ return typeof message.id === "number" &&
118
+ typeof message.serverId === "string" &&
119
+ typeof message.channelId === "string"
120
+ ? message
121
+ : null;
122
+ case "voicePublish":
123
+ return typeof message.id === "number" &&
124
+ typeof message.serverId === "string" &&
125
+ typeof message.channelId === "string" &&
126
+ typeof message.streamId === "string"
127
+ ? message
128
+ : null;
82
129
  default:
83
130
  return null;
84
131
  }
@@ -0,0 +1,12 @@
1
+ import type { HostPersistence, ServerListing } from "@jgengine/core/runtime/hostPersistence";
2
+ export type ReadsPersistence = Pick<HostPersistence, "listServers" | "loadProfile" | "getLeaderboardTop" | "getLeaderboardProfile">;
3
+ export type ReadsHandler = (request: Request) => Promise<Response>;
4
+ export type ReadsHandlerOptions = {
5
+ persistence: ReadsPersistence | (() => Promise<ReadsPersistence>);
6
+ basePath?: string;
7
+ listOpenServers?: (args: {
8
+ gameId: string;
9
+ limit?: number;
10
+ }) => Promise<ServerListing[]>;
11
+ };
12
+ export declare function createReadsHandler(options: ReadsHandlerOptions): ReadsHandler;
@@ -0,0 +1,65 @@
1
+ import { toOpenServerListings, toServerListing } from "@jgengine/core/runtime/hostPersistence";
2
+ function isScope(value) {
3
+ return value === "global" || value === "server" || value === "profile";
4
+ }
5
+ function json(body, status = 200) {
6
+ return Response.json(body, { status });
7
+ }
8
+ export function createReadsHandler(options) {
9
+ const basePath = (options.basePath ?? "/api").replace(/\/$/, "");
10
+ let persistence = null;
11
+ const getPersistence = () => {
12
+ persistence ??=
13
+ typeof options.persistence === "function" ? options.persistence() : Promise.resolve(options.persistence);
14
+ return persistence;
15
+ };
16
+ return async (request) => {
17
+ const url = new URL(request.url);
18
+ if (url.pathname !== basePath && !url.pathname.startsWith(`${basePath}/`)) {
19
+ return json({ error: "not found" }, 404);
20
+ }
21
+ if (request.method !== "GET") {
22
+ return json({ error: "method not allowed" }, 405);
23
+ }
24
+ const segments = url.pathname.slice(basePath.length).split("/").filter(Boolean).map(decodeURIComponent);
25
+ const gameId = url.searchParams.get("gameId") ?? "";
26
+ const limitParam = url.searchParams.get("limit");
27
+ const limit = limitParam === null ? undefined : Number(limitParam);
28
+ const [head, tail] = segments;
29
+ if (head === "servers" && tail === undefined) {
30
+ if (options.listOpenServers !== undefined) {
31
+ return json(await options.listOpenServers({ gameId, limit }));
32
+ }
33
+ const records = await (await getPersistence()).listServers(gameId);
34
+ return json(toOpenServerListings(records.map(toServerListing), limit));
35
+ }
36
+ if (head === "leaderboard" && tail !== undefined && segments.length === 2) {
37
+ const scope = url.searchParams.get("scope") ?? "global";
38
+ if (!isScope(scope)) {
39
+ return json({ error: "scope must be global | server | profile" }, 400);
40
+ }
41
+ return json(await (await getPersistence()).getLeaderboardTop({
42
+ gameId,
43
+ stat: tail,
44
+ scope,
45
+ serverId: url.searchParams.get("serverId") ?? undefined,
46
+ limit,
47
+ }));
48
+ }
49
+ if (head === "leaderboard-profile" && tail !== undefined && segments.length === 2) {
50
+ return json(await (await getPersistence()).getLeaderboardProfile({ gameId, userId: tail }));
51
+ }
52
+ if (head === "profile" && tail !== undefined && segments.length === 2) {
53
+ const profile = await (await getPersistence()).loadProfile({ userId: tail, gameId });
54
+ return json(profile === null
55
+ ? null
56
+ : {
57
+ userId: profile.userId,
58
+ gameId: profile.gameId,
59
+ playerState: profile.playerState,
60
+ updatedAt: profile.updatedAt,
61
+ });
62
+ }
63
+ return json({ error: "not found" }, 404);
64
+ };
65
+ }
@@ -0,0 +1,14 @@
1
+ import { type WsBackend, type WsBackendOptions } from "./createWsBackend.js";
2
+ import type { TransportPipeFactory } from "./pipe.js";
3
+ export type SocketIoLikeSocket = {
4
+ connected: boolean;
5
+ on: (event: string, listener: (payload: string) => void) => unknown;
6
+ off: (event: string, listener: (payload: string) => void) => unknown;
7
+ send: (data: string) => unknown;
8
+ disconnect: () => unknown;
9
+ };
10
+ export declare function socketIoPipe(socket: SocketIoLikeSocket): TransportPipeFactory;
11
+ export type SocketIoBackendOptions = Omit<WsBackendOptions, "url" | "pipe" | "webSocketFactory"> & {
12
+ socket: SocketIoLikeSocket;
13
+ };
14
+ export declare function createSocketIoBackend(options: SocketIoBackendOptions): WsBackend;
@@ -0,0 +1,49 @@
1
+ import { createWsBackend } from "./createWsBackend.js";
2
+ export function socketIoPipe(socket) {
3
+ return (handlers) => {
4
+ let closed = false;
5
+ const onConnect = () => {
6
+ if (closed)
7
+ return;
8
+ handlers.onOpen();
9
+ };
10
+ const onMessage = (payload) => {
11
+ if (closed)
12
+ return;
13
+ handlers.onMessage(typeof payload === "string" ? payload : String(payload));
14
+ };
15
+ const onDisconnect = () => {
16
+ if (closed)
17
+ return;
18
+ handlers.onClose();
19
+ };
20
+ socket.on("connect", onConnect);
21
+ socket.on("message", onMessage);
22
+ socket.on("disconnect", onDisconnect);
23
+ if (socket.connected) {
24
+ queueMicrotask(() => {
25
+ if (!closed)
26
+ handlers.onOpen();
27
+ });
28
+ }
29
+ return {
30
+ send: (data) => {
31
+ if (!socket.connected)
32
+ return;
33
+ socket.send(data);
34
+ },
35
+ close: () => {
36
+ if (closed)
37
+ return;
38
+ closed = true;
39
+ socket.off("connect", onConnect);
40
+ socket.off("message", onMessage);
41
+ socket.off("disconnect", onDisconnect);
42
+ },
43
+ };
44
+ };
45
+ }
46
+ export function createSocketIoBackend(options) {
47
+ const { socket, ...rest } = options;
48
+ return createWsBackend({ ...rest, pipe: socketIoPipe(socket) });
49
+ }
@@ -0,0 +1,35 @@
1
+ import { type AudioFalloffConfig } from "@jgengine/core/audio/audioFalloff";
2
+ export type VoiceChannelId = string;
3
+ export type VoiceMemberId = string;
4
+ export interface VoiceChannelDef {
5
+ id: VoiceChannelId;
6
+ /** Positional channels (proximity voice) attenuate by distance from the listener; non-positional channels (walkie/crew) play at flat gain regardless of distance. */
7
+ positional: boolean;
8
+ /** Only read when `positional` is true. */
9
+ falloff?: AudioFalloffConfig;
10
+ /** Flat gain multiplier applied on top of any falloff. Default 1. */
11
+ gain?: number;
12
+ }
13
+ export interface VoicePosition {
14
+ x: number;
15
+ y: number;
16
+ z: number;
17
+ }
18
+ export interface VoiceRoute {
19
+ fromUserId: VoiceMemberId;
20
+ channelId: VoiceChannelId;
21
+ gain: number;
22
+ }
23
+ export interface VoiceChannelRouter {
24
+ registerChannel(def: VoiceChannelDef): void;
25
+ join(userId: VoiceMemberId, channelId: VoiceChannelId): void;
26
+ leave(userId: VoiceMemberId, channelId: VoiceChannelId): void;
27
+ leaveAll(userId: VoiceMemberId): void;
28
+ updatePosition(userId: VoiceMemberId, position: VoicePosition): void;
29
+ setMuted(userId: VoiceMemberId, muted: boolean): void;
30
+ channelsOf(userId: VoiceMemberId): VoiceChannelId[];
31
+ /** Every `{ fromUserId, channelId, gain }` the given listener should mix in right now — one route per shared channel, so a speaker heard on both a proximity and a walkie channel at once yields two independent-gain routes. */
32
+ resolveRoutes(listenerUserId: VoiceMemberId): VoiceRoute[];
33
+ }
34
+ export declare function computeVoiceGain(def: VoiceChannelDef, distance: number | null): number;
35
+ export declare function createVoiceChannelRouter(channels?: readonly VoiceChannelDef[]): VoiceChannelRouter;