From ef4472b4b8c29bfb23fb90003b536886894c3499 Mon Sep 17 00:00:00 2001 From: NoisemakerJon <139656120+Noisemaker111@users.noreply.github.com> Date: Tue, 1 Sep 2026 22:48:42 -0400 Subject: [PATCH 1/2] feat-add-spectator-world-roles --- .claude/skills/jgengine-multiplayer/api.md | 12 ++++----- .claude/skills/jgengine/api.md | 3 ++- CHANGELOG.md | 1 + packages/core/src/runtime/gameContext.ts | 2 +- packages/core/src/runtime/hostedGameRunner.ts | 4 ++- packages/core/src/runtime/transport.ts | 5 +++- .../core/src/runtime/worldSnapshot.test.ts | 11 ++++++++ packages/core/src/runtime/worldSnapshot.ts | 8 ++++++ packages/ws/src/createWsBackend.ts | 14 +++++++--- packages/ws/src/host.ts | 5 +++- packages/ws/src/hostRouter.test.ts | 21 +++++++++++++++ packages/ws/src/hostRouter.ts | 27 ++++++++++++++----- packages/ws/src/protocol.ts | 9 ++++--- packages/ws/src/worldHost.ts | 27 ++++++++++++------- 14 files changed, 117 insertions(+), 32 deletions(-) diff --git a/.claude/skills/jgengine-multiplayer/api.md b/.claude/skills/jgengine-multiplayer/api.md index a813aaf48..f81fcea8e 100644 --- a/.claude/skills/jgengine-multiplayer/api.md +++ b/.claude/skills/jgengine-multiplayer/api.md @@ -292,7 +292,7 @@ - `DEFAULT_HEARTBEAT_INTERVAL_MS` (const): const DEFAULT_HEARTBEAT_INTERVAL_MS: 30000 — Default ping/pong interval; a socket that misses one round-trip is terminated. - `DEFAULT_MAX_CONNECTIONS` (const): const DEFAULT_MAX_CONNECTIONS: 10000 — Default max concurrent sockets this server accepts before rejecting new ones. - `DEFAULT_MAX_PAYLOAD_BYTES` (const): const DEFAULT_MAX_PAYLOAD_BYTES: 1048576 — Default per-message payload cap (bytes) — `ws` closes the socket with 1009 past this. -- `GameHost` (type): type GameHost = { joinServer: (args: { userId: string; gameId: string; serverId?: string; attributes?: SessionAttributes; code?: string; }) => Promise; browseServers: (args: { gameId: string; filter?: MatchFilter; limit?: number; }) => Promise; joinByCode: (args: … — A transport-agnostic authoritative game server host that manages sessions, ticking, and persistence. +- `GameHost` (type): type GameHost = { joinServer: (args: { userId: string; gameId: string; serverId?: string; attributes?: SessionAttributes; code?: string; role?: SnapshotViewer["role"]; }) => Promise; browseServers: (args: { gameId: string; filter?: MatchFilter; limit?: number; }) => Promise number; createServerId?: () => string; allowedFeedActions?: readonly string[]; } — Configuration for {@link createGameHost}, including persistence, tick rate, and game runtimes. - `GameSocketIoServer` (type): type GameSocketIoServer = { rewind: (args: { serverId: string; atMs: number }) => RewoundPosition[]; close: () => void; } — ⚠ undocumented - `GameSocketIoServerOptions` (type): type GameSocketIoServerOptions = HostRouterOptions & { io: SocketIoLikeServer } — ⚠ undocumented @@ -348,7 +348,7 @@ ## @jgengine/node/host -- `GameHost` (type): type GameHost = { joinServer: (args: { userId: string; gameId: string; serverId?: string; attributes?: SessionAttributes; code?: string; }) => Promise; browseServers: (args: { gameId: string; filter?: MatchFilter; limit?: number; }) => Promise; joinByCode: (args: … — A transport-agnostic authoritative game server host that manages sessions, ticking, and persistence. +- `GameHost` (type): type GameHost = { joinServer: (args: { userId: string; gameId: string; serverId?: string; attributes?: SessionAttributes; code?: string; role?: SnapshotViewer["role"]; }) => Promise; browseServers: (args: { gameId: string; filter?: MatchFilter; limit?: number; }) => Promise number; createServerId?: () => string; allowedFeedActions?: readonly string[]; } — Configuration for {@link createGameHost}, including persistence, tick rate, and game runtimes. - `HostChangeEvent` (type): type HostChangeEvent = { type: "server"; serverId: string; } | { type: "player"; serverId: string; userId: string; } | { type: "feed"; serverId: string; action: string; } — A change notification emitted by a `GameHost` for a server, player, or feed mutation. - `createGameHost` (function): function createGameHost(options: GameHostOptions): GameHost — Creates a `GameHost` that runs game servers over the given persistence and runtimes. @@ -445,7 +445,7 @@ - `DEFAULT_COMMAND_LIMITS` (const): const DEFAULT_COMMAND_LIMITS: CommandLimits — Recommended per-op limits a host can opt into via `limits: DEFAULT_COMMAND_LIMITS`. Rate limiting is off unless `limits` is set. - `DEFAULT_GRACE_MS` (const): const DEFAULT_GRACE_MS: 15000 — Default reconnect grace period for hosted socket sessions. - `DEFAULT_POSE_RULES` (const): const DEFAULT_POSE_RULES: PoseSyncRules — ⚠ undocumented -- `GameHost` (type): type GameHost = { joinServer: (args: { userId: string; gameId: string; serverId?: string; attributes?: SessionAttributes; code?: string; }) => Promise; browseServers: (args: { gameId: string; filter?: MatchFilter; limit?: number; }) => Promise; joinByCode: (args: … — A transport-agnostic authoritative game server host that manages sessions, ticking, and persistence. +- `GameHost` (type): type GameHost = { joinServer: (args: { userId: string; gameId: string; serverId?: string; attributes?: SessionAttributes; code?: string; role?: SnapshotViewer["role"]; }) => Promise; browseServers: (args: { gameId: string; filter?: MatchFilter; limit?: number; }) => Promise number; createServerId?: () => string; allowedFeedActions?: readonly string[]; } — Configuration for {@link createGameHost}, including persistence, tick rate, and game runtimes. - `HostChangeEvent` (type): type HostChangeEvent = | { type: "server"; serverId: string } | { type: "player"; serverId: string; userId: string } | { type: "feed"; serverId: string; action: string } — A change notification emitted by a `GameHost` for a server, player, or feed mutation. - `HostCommandOp` (type): type HostCommandOp = "pose" | "runCommand" | "join" | "browse" | "voice" — A host-side op the command middleware pipeline can gate: pose sync, `runCommand`, join/joinByCode, browse, or voice join/leave/publish. @@ -496,7 +496,7 @@ - `WsChannel` (type): type WsChannel = "server" | "player" | "feed" | "presence" | "chat" | "voice" — ⚠ undocumented - `WsChatMessage` (type): type WsChatMessage = { id: string; channelId: string; fromUserId: string; body: string; at: number; } — ⚠ undocumented - `WsChatSync` (type): type WsChatSync = { subscribe: ( serverId: string, channelId: string, onChange: (messages: WsChatMessage[]) => void, ) => () => void; send: (serverId: string, channelId: string, body: string) => Promise; } — ⚠ undocumented -- `WsClientMessage` (type): type WsClientMessage = | { v: 1; t: "hello"; id: number; userId: string; token?: string } | { v: 1; t: "join"; id: number; gameId: string; serverId?: string; attributes?: SessionAttributes; code?: string; } | { v: 1; t: "joinByCode"; id: number; gameId: string; code: string } | { v: 1; t: "browse"; … — ⚠ undocumented +- `WsClientMessage` (type): type WsClientMessage = | { v: 1; t: "hello"; id: number; userId: string; token?: string } | { v: 1; t: "join"; id: number; gameId: string; serverId?: string; attributes?: SessionAttributes; code?: string; role?: "player" | "spectator"; } | { v: 1; t: "joinByCode"; id: number; gameId: string; code: s… — ⚠ undocumented - `WsDecodeFailure` (type): type WsDecodeFailure = { reason: string; id?: number; } — ⚠ undocumented - `WsJoinByCodeResult` (type): type WsJoinByCodeResult = JoinServerResult | null — ⚠ undocumented - `WsJoinResult` (type): type WsJoinResult = JoinServerResult — ⚠ undocumented @@ -561,7 +561,7 @@ ## @jgengine/ws/host -- `GameHost` (type): type GameHost = { joinServer: (args: { userId: string; gameId: string; serverId?: string; attributes?: SessionAttributes; code?: string; }) => Promise; browseServers: (args: { gameId: string; filter?: MatchFilter; limit?: number; }) => Promise; joinByCode: (args: … — A transport-agnostic authoritative game server host that manages sessions, ticking, and persistence. +- `GameHost` (type): type GameHost = { joinServer: (args: { userId: string; gameId: string; serverId?: string; attributes?: SessionAttributes; code?: string; role?: SnapshotViewer["role"]; }) => Promise; browseServers: (args: { gameId: string; filter?: MatchFilter; limit?: number; }) => Promise number; createServerId?: () => string; allowedFeedActions?: readonly string[]; } — Configuration for {@link createGameHost}, including persistence, tick rate, and game runtimes. - `HostChangeEvent` (type): type HostChangeEvent = | { type: "server"; serverId: string } | { type: "player"; serverId: string; userId: string } | { type: "feed"; serverId: string; action: string } — A change notification emitted by a `GameHost` for a server, player, or feed mutation. - `OP_LEDGER_LIMIT` (const): const OP_LEDGER_LIMIT: 64 — Max recently-applied `runCommand` op IDs retained per (serverId, userId), oldest evicted first. @@ -624,7 +624,7 @@ - `WsBrowseResult` (type): type WsBrowseResult = SessionListing[] — ⚠ undocumented - `WsChannel` (type): type WsChannel = "server" | "player" | "feed" | "presence" | "chat" | "voice" — ⚠ undocumented - `WsChatMessage` (type): type WsChatMessage = { id: string; channelId: string; fromUserId: string; body: string; at: number; } — ⚠ undocumented -- `WsClientMessage` (type): type WsClientMessage = | { v: 1; t: "hello"; id: number; userId: string; token?: string } | { v: 1; t: "join"; id: number; gameId: string; serverId?: string; attributes?: SessionAttributes; code?: string; } | { v: 1; t: "joinByCode"; id: number; gameId: string; code: string } | { v: 1; t: "browse"; … — ⚠ undocumented +- `WsClientMessage` (type): type WsClientMessage = | { v: 1; t: "hello"; id: number; userId: string; token?: string } | { v: 1; t: "join"; id: number; gameId: string; serverId?: string; attributes?: SessionAttributes; code?: string; role?: "player" | "spectator"; } | { v: 1; t: "joinByCode"; id: number; gameId: string; code: s… — ⚠ undocumented - `WsDecodeFailure` (type): type WsDecodeFailure = { reason: string; id?: number; } — ⚠ undocumented - `WsJoinByCodeResult` (type): type WsJoinByCodeResult = JoinServerResult | null — ⚠ undocumented - `WsJoinResult` (type): type WsJoinResult = JoinServerResult — ⚠ undocumented diff --git a/.claude/skills/jgengine/api.md b/.claude/skills/jgengine/api.md index 032c7a098..9294ed930 100644 --- a/.claude/skills/jgengine/api.md +++ b/.claude/skills/jgengine/api.md @@ -524,9 +524,10 @@ - `GameRuntimeFeeds` (type): type GameRuntimeFeeds = { subscribeServer: ( serverId: string, onChange: (view: GameRuntimeServerView | null) => void, ) => FeedUnsubscribe; subscribePlayer: ( args: { serverId: string }, onChange: (view: GameRuntimePlayerView | null) => void, ) => FeedUnsubscribe; subscribeFeed: ( args: { serverId:… — ⚠ undocumented - `GameRuntimePlayerView` (type): type GameRuntimePlayerView = { userId: string; gameId: string; playerState: unknown; updatedAt: number; } — ⚠ undocumented - `GameRuntimeServerView` (type): type GameRuntimeServerView = { serverId: string; gameId: string; revision: number; memberUserIds: string[]; serverState: unknown | WorldSyncFrame; updatedAt: number; } — ⚠ undocumented -- `GameRuntimeTransport` (type): type GameRuntimeTransport = { joinServer: (args: { gameId: string; serverId?: string }) => Promise; leaveServer: (args: { serverId: string }) => Promise; runCommand: (args: RunCommandArgs) => Promise; } — ⚠ undocumented +- `GameRuntimeTransport` (type): type GameRuntimeTransport = { joinServer: (args: { gameId: string; serverId?: string; role?: MultiplayerRole }) => Promise; leaveServer: (args: { serverId: string }) => Promise; runCommand: (args: RunCommandArgs) => Promise; } — ⚠ undocumented - `JoinServerResult` (type): type JoinServerResult = { serverId: string; isNew: boolean; resumeTicket?: ResumeTicket; } — ⚠ undocumented - `LiveGameBackend` (type): type LiveGameBackend = GameBackend & { presenceSync: PresenceSync; pushFeedEntry: (args: { serverId: string; action: string; entry: unknown }) => Promise; chatSyncFor… — ⚠ undocumented +- `MultiplayerRole` (type): type MultiplayerRole = "player" | "spectator" — Connection role used for authoritative world access; spectators are read-only. - `MultiplayerSession` (type): type MultiplayerSession = { gameId: string; userId: string; backend: LiveGameBackend; feedActions: string[]; } — ⚠ undocumented - `PresencePoseRow` (type): type PresencePoseRow = { userId: string; /** Set when the host tracks presence per session, so one user can hold two rows. */ sessionId?: string; /** Actor class, e.g. `"player"` / `"agent"` — a host may clamp each differently. */ kind?: string; /** Display name carried on the row, so a nameplate ne… — ⚠ undocumented - `PresenceSync` (type): type PresenceSync = { subscribe: (serverId: string, onChange: (rows: PresencePoseRow[]) => void) => FeedUnsubscribe; syncPose: (serverId: string, pose: PlayerPose) => void; } — ⚠ undocumented diff --git a/CHANGELOG.md b/CHANGELOG.md index 95221a6ee..75e0a9284 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -44,6 +44,7 @@ between (`--json` for structured output). - `defineGame({ physics: { backend } })` now adopts a capsule character controller for shell/headless movement and registers the backend simulation after movement, including floor collision and step-up behavior. - Added the `physics-probe` dev scene for Rapier crate, ragdoll, and vehicle motion. - Assets now expose import-spec validation and magic-byte classification for supported source files. +- Hosted WS sessions now support spectator joins, viewer roles, throttled low-priority snapshot modules, and default command rate limits. ### Migrate diff --git a/packages/core/src/runtime/gameContext.ts b/packages/core/src/runtime/gameContext.ts index 2bbefb17a..480c013b8 100644 --- a/packages/core/src/runtime/gameContext.ts +++ b/packages/core/src/runtime/gameContext.ts @@ -395,7 +395,7 @@ export function createGameContext ]; const replication = options.replication; - const projectsViewers = policyProjectsViewers(replication); + const projectsViewers = policyProjectsViewers(replication) || snapshotModules.some((module) => module.priority !== undefined); const aoiRadius = replication?.aoiRadius; const typedProject = ( diff --git a/packages/core/src/runtime/hostedGameRunner.ts b/packages/core/src/runtime/hostedGameRunner.ts index 970abe492..6909211c9 100644 --- a/packages/core/src/runtime/hostedGameRunner.ts +++ b/packages/core/src/runtime/hostedGameRunner.ts @@ -82,6 +82,7 @@ export function createHostedGameRunner(); const movementTuning = resolvePlayerMovementTuning({ world: definition.world, physics: definition.physics }); const inputSeq = new Map(); + let hostTick = 0; loop.onInit?.(ctx); syncLifecyclePhase(ctx, definition.lifecycle); @@ -126,6 +127,7 @@ export function createHostedGameRunner { ctx.sim.runStages("beforeMovement", stepDt); for (const userId of members.keys()) { @@ -142,7 +144,7 @@ export function createHostedGameRunner replicator.diff(sinceRevision), revision: () => replicator.revision(), - snapshot: (viewer) => ctx.snapshot(viewer), + snapshot: (viewer) => ctx.snapshot(viewer === undefined ? undefined : { ...viewer, tick: hostTick }), projectsViewers: () => ctx.replicatesPerViewer(), members: () => Array.from(members.keys()), context: () => ctx, diff --git a/packages/core/src/runtime/transport.ts b/packages/core/src/runtime/transport.ts index 71a34c346..9b1d88039 100644 --- a/packages/core/src/runtime/transport.ts +++ b/packages/core/src/runtime/transport.ts @@ -22,6 +22,9 @@ export type JoinServerResult = { resumeTicket?: ResumeTicket; }; +/** Connection role used for authoritative world access; spectators are read-only. */ +export type MultiplayerRole = "player" | "spectator"; + export type RunCommandArgs = { serverId: string; command: string; @@ -54,7 +57,7 @@ export type GameRuntimeFeedView = { }; export type GameRuntimeTransport = { - joinServer: (args: { gameId: string; serverId?: string }) => Promise; + joinServer: (args: { gameId: string; serverId?: string; role?: MultiplayerRole }) => Promise; leaveServer: (args: { serverId: string }) => Promise; runCommand: (args: RunCommandArgs) => Promise; }; diff --git a/packages/core/src/runtime/worldSnapshot.test.ts b/packages/core/src/runtime/worldSnapshot.test.ts index 12d1bd8df..024c40664 100644 --- a/packages/core/src/runtime/worldSnapshot.test.ts +++ b/packages/core/src/runtime/worldSnapshot.test.ts @@ -55,4 +55,15 @@ describe("world snapshot seam", () => { applyWorldSnapshot([module], { n: 42 }); expect(box.value).toBe(42); }); + + test("priority throttles a module on intervening viewer ticks", () => { + const module: SnapshotModule = { + key: "slow", + snapshot: () => 7, + hydrate: () => {}, + priority: () => 3, + }; + expect(composeWorldSnapshot([module], { userId: "spectator", role: "spectator", tick: 1 })).toEqual({}); + expect(composeWorldSnapshot([module], { userId: "spectator", role: "spectator", tick: 3 })).toEqual({ slow: 7 }); + }); }); diff --git a/packages/core/src/runtime/worldSnapshot.ts b/packages/core/src/runtime/worldSnapshot.ts index 2915245f1..9f332fb1a 100644 --- a/packages/core/src/runtime/worldSnapshot.ts +++ b/packages/core/src/runtime/worldSnapshot.ts @@ -1,6 +1,10 @@ /** Who a host→client snapshot is being projected for — the identity a {@link SnapshotModule.project} filters against. */ export interface SnapshotViewer { readonly userId: string; + /** A read-only connection role; spectators receive snapshots but cannot mutate the world. */ + readonly role?: "player" | "spectator"; + /** Host tick used by {@link SnapshotModule.priority} to throttle low-priority modules. */ + readonly tick?: number; } /** @@ -38,6 +42,8 @@ export interface SnapshotModule { project?(data: T, viewer: SnapshotViewer, world: WorldSnapshot): T; /** Monotone change counter; unchanged between host commits means this module didn't mutate and need not re-serialize. */ version?(): number; + /** Return the host tick interval for this module; values greater than one omit it on intervening ticks. */ + priority?(data: T, viewer: SnapshotViewer): number; } /** Full world baseline keyed by {@link SnapshotModule.key} — one entry per opted-in subsystem. */ @@ -59,6 +65,8 @@ export function composeWorldSnapshot( const snapshot: WorldSnapshot = {}; for (const module of modules) { const value = raw[module.key]; + const interval = viewer?.tick === undefined ? 1 : Math.max(1, Math.floor(module.priority?.(value, viewer) ?? 1)); + if (viewer?.tick !== undefined && interval > 1 && viewer.tick % interval !== 0) continue; snapshot[module.key] = module.project !== undefined ? module.project(value, viewer, raw) : value; } diff --git a/packages/ws/src/createWsBackend.ts b/packages/ws/src/createWsBackend.ts index e41363dbd..e81581dac 100644 --- a/packages/ws/src/createWsBackend.ts +++ b/packages/ws/src/createWsBackend.ts @@ -69,8 +69,8 @@ export type WsVoiceSync = { export type WsBackend = GameBackend & { pushFeedEntry: (args: { serverId: string; action: string; entry: unknown }) => Promise; browse: (args: { gameId: string; filter?: MatchFilter; limit?: number }) => Promise; - joinByCode: (args: { gameId: string; code: string }) => Promise; - createSession: (args: { gameId: string; attributes?: SessionAttributes }) => Promise; + joinByCode: (args: { gameId: string; code: string; role?: "player" | "spectator" }) => Promise; + createSession: (args: { gameId: string; attributes?: SessionAttributes; role?: "player" | "spectator" }) => Promise; presenceSync: WsPresenceSync; chatSync: WsChatSync; chatSyncFor: (serverId: string) => ChatSync; @@ -150,6 +150,7 @@ export function createWsBackend(options: WsBackendOptions): WsBackend { const subscriptions = new Map(); const resumeTickets = new Map(); const joinedGames = new Map(); + const joinedRoles = new Map(); const clearRequestTimer = (request: PendingRequest) => { if (request.timer !== null) { @@ -213,7 +214,7 @@ export function createWsBackend(options: WsBackendOptions): WsBackend { const rejoinServers = () => { for (const [serverId, gameId] of joinedGames) { - rawSend({ v: 1, t: "join", id: nextId++, gameId, serverId }); + rawSend({ v: 1, t: "join", id: nextId++, gameId, serverId, role: joinedRoles.get(serverId) }); } }; const helloToken = () => resumeTickets.values().next().value?.token ?? options.token; @@ -394,9 +395,11 @@ export function createWsBackend(options: WsBackendOptions): WsBackend { id, gameId: args.gameId, serverId: args.serverId, + role: args.role, })); const joined = result as JoinServerResult; joinedGames.set(joined.serverId, args.gameId); + joinedRoles.set(joined.serverId, args.role ?? "player"); if (joined.resumeTicket !== undefined) resumeTickets.set(joined.serverId, joined.resumeTicket); return joined; }, @@ -404,6 +407,7 @@ export function createWsBackend(options: WsBackendOptions): WsBackend { poseGates.delete(args.serverId); await request((id) => ({ v: 1, t: "leave", id, serverId: args.serverId })); joinedGames.delete(args.serverId); + joinedRoles.delete(args.serverId); resumeTickets.delete(args.serverId); }, async runCommand(args) { @@ -583,10 +587,12 @@ export function createWsBackend(options: WsBackendOptions): WsBackend { id, gameId: args.gameId, code: args.code, + role: args.role, })); const joined = result as JoinServerResult | null; if (joined !== null) { joinedGames.set(joined.serverId, args.gameId); + joinedRoles.set(joined.serverId, args.role ?? "player"); if (joined.resumeTicket !== undefined) resumeTickets.set(joined.serverId, joined.resumeTicket); } return joined; @@ -598,9 +604,11 @@ export function createWsBackend(options: WsBackendOptions): WsBackend { id, gameId: args.gameId, attributes: args.attributes, + role: args.role, })); const joined = result as JoinServerResult; joinedGames.set(joined.serverId, args.gameId); + joinedRoles.set(joined.serverId, args.role ?? "player"); if (joined.resumeTicket !== undefined) resumeTickets.set(joined.serverId, joined.resumeTicket); return joined; }, diff --git a/packages/ws/src/host.ts b/packages/ws/src/host.ts index d8b038ef2..3f79556fd 100644 --- a/packages/ws/src/host.ts +++ b/packages/ws/src/host.ts @@ -45,6 +45,7 @@ import type { TransportRunCommandResult, WorldSyncFrame, } from "@jgengine/core/runtime/transport"; +import type { SnapshotViewer } from "@jgengine/core/runtime/worldSnapshot"; /** A change notification emitted by a `GameHost` for a server, player, or feed mutation. */ export type HostChangeEvent = @@ -71,6 +72,7 @@ export type GameHost = { serverId?: string; attributes?: SessionAttributes; code?: string; + role?: SnapshotViewer["role"]; }) => Promise; browseServers: (args: { gameId: string; @@ -90,11 +92,12 @@ export type GameHost = { input: unknown; }) => Promise; isMember: (args: { userId: string; serverId: string }) => Promise; - getServerView: (args: { userId: string; serverId: string }) => Promise; + getServerView: (args: { userId: string; serverId: string; role?: SnapshotViewer["role"] }) => Promise; pullWorld?: (args: { userId: string; serverId: string; sinceRevision: number | null; + role?: SnapshotViewer["role"]; }) => Promise; getPlayerView: (args: { userId: string; serverId: string }) => Promise; getFeed: (args: { userId: string; serverId: string; action: string }) => Promise; diff --git a/packages/ws/src/hostRouter.test.ts b/packages/ws/src/hostRouter.test.ts index 2ad169c73..4d5afe929 100644 --- a/packages/ws/src/hostRouter.test.ts +++ b/packages/ws/src/hostRouter.test.ts @@ -95,6 +95,27 @@ test("loopback: second client joins the first client's server", async () => { } }); +test("loopback: spectators can join and receive server updates but cannot run commands or move", async () => { + const stack = startStack(); + try { + const alice = stack.connect("alice"); + const joined = await alice.transport.joinServer({ gameId: "test-game" }); + const spectator = stack.connect("spectator"); + await spectator.transport.joinServer({ gameId: "test-game", serverId: joined.serverId, role: "spectator" }); + expect(await spectator.transport.runCommand({ serverId: joined.serverId, command: "engine.ping", input: null })) + .toEqual({ ok: false, reason: "Spectators cannot run commands" }); + const serverFeed = spectator.feeds?.subscribeServer; + expect(serverFeed).toBeDefined(); + const updates: unknown[] = []; + const unsubscribe = serverFeed!(joined.serverId, (view) => updates.push(view)); + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(updates.length).toBeGreaterThan(0); + unsubscribe(); + } finally { + await stack.shutdown(); + } +}); + test("loopback: reconnect within the grace window keeps the player entity", async () => { const stack = startStack({ graceMs: 50 }); try { diff --git a/packages/ws/src/hostRouter.ts b/packages/ws/src/hostRouter.ts index 0ca0dc5ac..299f2ef57 100644 --- a/packages/ws/src/hostRouter.ts +++ b/packages/ws/src/hostRouter.ts @@ -13,6 +13,7 @@ import { type ChatRateLimit, } from "@jgengine/core/game/chat"; import type { ResumeTicket } from "@jgengine/core/runtime/transport"; +import type { SnapshotViewer } from "@jgengine/core/runtime/worldSnapshot"; import { createCommandMiddleware, @@ -21,6 +22,7 @@ import { type CommandLimits, type HostCommandOp, } from "./commandMiddleware"; +import { DEFAULT_COMMAND_LIMITS } from "./commandMiddleware"; import type { GameHost, HostChangeEvent } from "./host"; import type { TransportPipeFactory } from "./pipe"; import { @@ -53,7 +55,7 @@ export type HostRouterOptions = { chatRateLimit?: ChatRateLimit; chatHistoryLimit?: number; chatMaxBodyLength?: number; - /** Per-op rate limits for pose/runCommand/join/browse/voice. Omit (default) for no rate limiting; pass {@link DEFAULT_COMMAND_LIMITS} or a custom {@link CommandLimits} to opt in. */ + /** Per-op rate limits for pose/runCommand/join/browse/voice. Defaults to {@link DEFAULT_COMMAND_LIMITS}. */ limits?: CommandLimits; /** Per-command authorization hook; defaults to allow-all. */ authorize?: CommandAuthorize; @@ -103,6 +105,7 @@ type Connection = { queue: Promise; queuedMessages: number; worldRevisions: Map; + roles: Map; }; type PresenceEntry = PresencePoseState & { appearance?: WsAppearance }; @@ -242,7 +245,7 @@ export function createHostRouter(options: HostRouterOptions): HostRouter { })); const commandMiddleware = createCommandMiddleware({ - limits: options.limits, + limits: options.limits ?? DEFAULT_COMMAND_LIMITS, authorize: options.authorize, validate: options.validate, }); @@ -385,17 +388,17 @@ export function createHostRouter(options: HostRouterOptions): HostRouter { if (channel === "server") { if (host.pullWorld !== undefined) { const sinceRevision = connection.worldRevisions.get(serverId) ?? null; - const world = await host.pullWorld({ userId: connection.userId, serverId, sinceRevision }); + const world = await host.pullWorld({ userId: connection.userId, serverId, sinceRevision, role: connection.roles.get(serverId) }); if (world !== null) { connection.worldRevisions.set(serverId, world.revision); - const data = await host.getServerView({ userId: connection.userId, serverId }); + const data = await host.getServerView({ userId: connection.userId, serverId, role: connection.roles.get(serverId) }); if (data !== null) { send(connection, { v: 1, t: "update", channel, serverId, data: { ...data, serverState: world } }); } return; } } - const data = await host.getServerView({ userId: connection.userId, serverId }); + const data = await host.getServerView({ userId: connection.userId, serverId, role: connection.roles.get(serverId) }); send(connection, { v: 1, t: "update", channel, serverId, data }); } else if (channel === "player") { const data = await host.getPlayerView({ userId: connection.userId, serverId }); @@ -541,6 +544,7 @@ export function createHostRouter(options: HostRouterOptions): HostRouter { } if (message.t === "pose") { + if (connection.roles.get(message.serverId) === "spectator") return; await handlePose(connection, message.serverId, message.pose); return; } @@ -561,8 +565,10 @@ export function createHostRouter(options: HostRouterOptions): HostRouter { serverId: message.serverId, attributes: message.attributes, code: message.code, + role: message.role, }); connection.joinedServers.add(result.serverId); + connection.roles.set(result.serverId, message.role ?? "player"); reply(connection, message.id, { ...result, resumeTicket: ticketFor(userId, result.serverId), @@ -576,7 +582,10 @@ export function createHostRouter(options: HostRouterOptions): HostRouter { gameId: message.gameId, code: message.code, }); - if (result !== null) connection.joinedServers.add(result.serverId); + if (result !== null) { + connection.joinedServers.add(result.serverId); + connection.roles.set(result.serverId, message.role ?? "player"); + } reply( connection, message.id, @@ -600,12 +609,17 @@ export function createHostRouter(options: HostRouterOptions): HostRouter { await host.leaveServer({ userId, serverId: message.serverId }); clearPendingLeave(userId, message.serverId); connection.joinedServers.delete(message.serverId); + connection.roles.delete(message.serverId); dropPresenceForServer(userId, message.serverId); dropVoiceForServer(userId, message.serverId); reply(connection, message.id, null); return; } case "runCommand": { + if (connection.roles.get(message.serverId) === "spectator") { + replyError(connection, message.id, "Spectators cannot run commands"); + return; + } if ( !(await gate(connection, message.id, "runCommand", { serverId: message.serverId, @@ -727,6 +741,7 @@ export function createHostRouter(options: HostRouterOptions): HostRouter { queue: Promise.resolve(), queuedMessages: 0, worldRevisions: new Map(), + roles: new Map(), }; connections.add(connection); return { diff --git a/packages/ws/src/protocol.ts b/packages/ws/src/protocol.ts index d695c8397..5bde37eff 100644 --- a/packages/ws/src/protocol.ts +++ b/packages/ws/src/protocol.ts @@ -54,8 +54,9 @@ export type WsClientMessage = serverId?: string; attributes?: SessionAttributes; code?: string; + role?: "player" | "spectator"; } - | { v: 1; t: "joinByCode"; id: number; gameId: string; code: string } + | { v: 1; t: "joinByCode"; id: number; gameId: string; code: string; role?: "player" | "spectator" } | { v: 1; t: "browse"; id: number; gameId: string; filter?: MatchFilter; limit?: number } | { v: 1; t: "leave"; id: number; serverId: string } | { v: 1; t: "runCommand"; id: number; serverId: string; command: string; input: unknown } @@ -211,13 +212,15 @@ export function decodeWsClientMessage(raw: unknown): WsClientMessage | null { return typeof message.id === "number" && typeof message.gameId === "string" && (message.serverId === undefined || typeof message.serverId === "string") && - (message.code === undefined || typeof message.code === "string") + (message.code === undefined || typeof message.code === "string") && + (message.role === undefined || message.role === "player" || message.role === "spectator") ? (message as WsClientMessage) : null; case "joinByCode": return typeof message.id === "number" && typeof message.gameId === "string" && - typeof message.code === "string" + typeof message.code === "string" && + (message.role === undefined || message.role === "player" || message.role === "spectator") ? (message as WsClientMessage) : null; case "browse": diff --git a/packages/ws/src/worldHost.ts b/packages/ws/src/worldHost.ts index 6c26f22e9..d601650fd 100644 --- a/packages/ws/src/worldHost.ts +++ b/packages/ws/src/worldHost.ts @@ -6,6 +6,7 @@ import type { TransportRunCommandResult, WorldSyncFrame, } from "@jgengine/core/runtime/transport"; +import type { SnapshotViewer } from "@jgengine/core/runtime/worldSnapshot"; import type { GameHost, HostChangeEvent } from "./host"; /** Config for {@link createWorldGameHost}: how to resolve a hosted world's authoritative session per server. */ @@ -32,6 +33,7 @@ export interface WorldGameHost extends GameHost { */ export function createWorldGameHost(options: WorldGameHostOptions): WorldGameHost { const live = new Map(); + const roles = new Map>(); const listeners = new Set<(event: HostChangeEvent) => void>(); const now = options.now ?? (() => Date.now()); @@ -66,13 +68,19 @@ export function createWorldGameHost(options: WorldGameHostOptions): WorldGameHos } return { - async joinServer({ userId, gameId, serverId }): Promise { + async joinServer({ userId, gameId, serverId, role }): Promise { const id = serverId ?? gameId; const pending = ensure(gameId, id); const entry = pending instanceof Promise ? await pending : pending; if (entry === null) throw new Error(`no hosted world for game "${gameId}"`); const isNew = !entry.session.members().includes(userId); - entry.session.join(userId, isNew); + let serverRoles = roles.get(id); + if (serverRoles === undefined) { + serverRoles = new Map(); + roles.set(id, serverRoles); + } + serverRoles.set(userId, role ?? "player"); + if (role !== "spectator") entry.session.join(userId, isNew); emit({ type: "server", serverId: id }); emit({ type: "player", serverId: id, userId }); return { serverId: id, isNew }; @@ -80,7 +88,8 @@ export function createWorldGameHost(options: WorldGameHostOptions): WorldGameHos async leaveServer({ userId, serverId }): Promise { const entry = live.get(serverId); if (entry === undefined) return; - entry.session.leave(userId); + if (entry.session.members().includes(userId)) entry.session.leave(userId); + roles.get(serverId)?.delete(userId); emit({ type: "server", serverId }); }, async runCommand({ userId, serverId, command, input }): Promise { @@ -98,9 +107,9 @@ export function createWorldGameHost(options: WorldGameHostOptions): WorldGameHos return { ok: true }; }, async isMember({ userId, serverId }): Promise { - return live.get(serverId)?.session.members().includes(userId) ?? false; + return live.get(serverId)?.session.members().includes(userId) || roles.get(serverId)?.has(userId) === true; }, - async getServerView({ userId, serverId }): Promise { + async getServerView({ userId, serverId, role }): Promise { const entry = live.get(serverId); if (entry === undefined) return null; return { @@ -108,18 +117,18 @@ export function createWorldGameHost(options: WorldGameHostOptions): WorldGameHos gameId: entry.gameId, revision: entry.session.revision(), memberUserIds: [...entry.session.members()], - serverState: entry.session.snapshotFor({ userId }), + serverState: entry.session.snapshotFor({ userId, role: role ?? roles.get(serverId)?.get(userId) ?? "player" }), updatedAt: now(), }; }, - async pullWorld({ userId, serverId, sinceRevision }): Promise { + async pullWorld({ userId, serverId, sinceRevision, role }): Promise { const entry = live.get(serverId); - if (entry === undefined || !entry.session.members().includes(userId)) return null; + if (entry === undefined || (!entry.session.members().includes(userId) && roles.get(serverId)?.get(userId) !== "spectator")) return null; if (entry.session.projectsViewers()) { return { kind: "baseline", revision: entry.session.revision(), - snapshot: entry.session.snapshotFor({ userId }), + snapshot: entry.session.snapshotFor({ userId, role: role ?? roles.get(serverId)?.get(userId) ?? "player" }), }; } const sync = entry.session.pull(sinceRevision); From b6897bc330340ead18a6269da099853f293b2594 Mon Sep 17 00:00:00 2001 From: NoisemakerJon <139656120+Noisemaker111@users.noreply.github.com> Date: Wed, 2 Sep 2026 02:42:55 -0400 Subject: [PATCH 2/2] gen-refresh-multiplayer-api --- .claude/skills/jgengine-multiplayer/api.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.claude/skills/jgengine-multiplayer/api.md b/.claude/skills/jgengine-multiplayer/api.md index f81fcea8e..95cbf1f58 100644 --- a/.claude/skills/jgengine-multiplayer/api.md +++ b/.claude/skills/jgengine-multiplayer/api.md @@ -490,7 +490,7 @@ - `WorldGameHost` (interface): interface WorldGameHost extends GameHost — A {@link GameHost} whose worlds run on `HostedWorldSession`s; `tick` advances them and re-broadcasts on change. - `WorldGameHostOptions` (interface): interface WorldGameHostOptions — Config for {@link createWorldGameHost}: how to resolve a hosted world's authoritative session per server. - `WsAppearance` (type): type WsAppearance = Record — Client-set cosmetic/state tags carried alongside a pose (skin, mount, emote, ...). Primitive values only. -- `WsBackend` (type): type WsBackend = GameBackend & { pushFeedEntry: (args: { serverId: string; action: string; entry: unknown }) => Promise; browse: (args: { gameId: string; filter?: MatchFilter; limit?: number }) => Promise; joinByCode: (args: { gameId: string; code: string }) => Promise Promise; browse: (args: { gameId: string; filter?: MatchFilter; limit?: number }) => Promise; joinByCode: (args: { gameId: string; code: string; role?: "player" | "… — ⚠ undocumented - `WsBackendOptions` (type): type WsBackendOptions = { url?: string; pipe?: TransportPipeFactory; userId: string; token?: string; webSocketFactory?: (url: string) => WebSocket; reconnectDelayMs?: number; maxReconnectDelayMs?: number; rpcTimeoutMs?: number; poseTuning?: PoseSyncTuning; now?: () => number; setTimeoutFn?: typeof s… — ⚠ undocumented - `WsBrowseResult` (type): type WsBrowseResult = SessionListing[] — ⚠ undocumented - `WsChannel` (type): type WsChannel = "server" | "player" | "feed" | "presence" | "chat" | "voice" — ⚠ undocumented @@ -552,7 +552,7 @@ ## @jgengine/ws/createWsBackend -- `WsBackend` (type): type WsBackend = GameBackend & { pushFeedEntry: (args: { serverId: string; action: string; entry: unknown }) => Promise; browse: (args: { gameId: string; filter?: MatchFilter; limit?: number }) => Promise; joinByCode: (args: { gameId: string; code: string }) => Promise Promise; browse: (args: { gameId: string; filter?: MatchFilter; limit?: number }) => Promise; joinByCode: (args: { gameId: string; code: string; role?: "player" | "… — ⚠ undocumented - `WsBackendOptions` (type): type WsBackendOptions = { url?: string; pipe?: TransportPipeFactory; userId: string; token?: string; webSocketFactory?: (url: string) => WebSocket; reconnectDelayMs?: number; maxReconnectDelayMs?: number; rpcTimeoutMs?: number; poseTuning?: PoseSyncTuning; now?: () => number; setTimeoutFn?: typeof s… — ⚠ undocumented - `WsChatSync` (type): type WsChatSync = { subscribe: ( serverId: string, channelId: string, onChange: (messages: WsChatMessage[]) => void, ) => () => void; send: (serverId: string, channelId: string, body: string) => Promise; } — ⚠ undocumented - `WsPresenceSync` (type): type WsPresenceSync = { subscribe: (serverId: string, onChange: (rows: WsPresenceRow[]) => void) => () => void; /** `pose.appearance`, when provided, is forwarded to the host as-is and surfaces on every subscriber's presence row for that user. */ syncPose: (serverId: string, pose: WsPose) => void; } — ⚠ undocumented