@jgengine/core 0.17.0 → 0.18.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/CHANGELOG.md CHANGED
@@ -26,14 +26,30 @@ At publish, rename this heading to the new version and mirror the entries into
26
26
  `packages/core/src/meta/changelog.ts` (the typed `CHANGELOG` export).
27
27
  -->
28
28
 
29
+ ## 0.18.0
30
+
29
31
  ### Migrate
30
32
 
33
+ - **Bump lockstep SDK packages to `^0.18.0`:** `@jgengine/{core,react,ws,node,sql,convex,shell,editor,assets}`. CLI `jgengine` is `0.15.0`; `@jgengine/github` is `0.5.0`.
34
+ - **Register the presence reaper and redeploy your schema.** `jgPoses` gains `sessionId` / `kind` / `label` / `revokedAt` (all optional) and a `by_updated` index, and `createPresenceFunctions` now also returns `reapIdlePresence`. Re-export it (`export const { list, sync, leave, reapIdlePresence } = createPresenceFunctions(...)`) and add `crons.interval("jg presence reap", { seconds: 60 }, internal.presence.reapIdlePresence, {})`. Without it nothing deletes the row of a player who closed the tab — `list` filtered stale rows at read time but the table grew forever. `jgengineCronSpecs()` carries the new entry, and every spec now names the `module` (`"runtime"` / `"presence"`) it is exported from.
35
+ - **`presence.sync` returns `{ pose, lastSeenAt, displaced }` instead of `null`,** and `presence.leave` marks the row revoked instead of deleting it (the reaper collects it once idle). A caller that ignored the return value is unaffected.
36
+
31
37
  ### Changed
32
38
 
39
+ - **`presence.sync` accepts a call with no `pose` at all,** which holds the stored position and moves only the liveness stamp. `createConvexPresenceSync` sends one itself when the pose gate has suppressed writes for 10s, so a standing player keeps their row alive without a pose write — and without the reaper collecting a live session.
40
+ - **`PresencePoseRow` carries optional `sessionId` / `kind` / `label`,** so a nameplate reads off the presence row instead of joining against users per frame.
41
+
33
42
  ### Added
34
43
 
44
+ - **`createPresenceFunctions` gains the policy hooks the pose lane was missing** — `resolveSpawn(ctx, { serverId, userId, kind })` for where a session with no row starts (async and ctx-bearing, so a spawn can read the world, replacing a hardcoded origin), `poseRules` as a function of the actor `kind` so an agent need not obey a human's speed cap, `idleCutoffMs` / `maxReapPerRun` for the reaper, and optional `sessionId` on `sync` / `leave`: a second tab is a second row, syncing revokes the others, and the displaced session learns it on its next call through `displaced: true`.
45
+
46
+ - **A command declares what it touches, so `runCommand` hydrates that and nothing else.** `CommandDef` gains an optional `scope(input, actorUserId)` returning `{ players?, chunkKeys? }`, evaluated before hydration; the registered `runCommand` mutation also accepts an explicit `scope` argument, which wins over the declared one. A game that never sets either keeps the whole-world default. `ServerLoopHooks.joinScope(userId, isNew)` does the same for `onNewPlayer`.
47
+ - **`GameRuntime.commandScope` / `GameRuntime.joinScope`** expose those declarations to any host, alongside the pure helpers `resolveCommandScope` / `scopeWithActor` / `commandScopeEscape` (`@jgengine/core/runtime/commandRunner`).
48
+
35
49
  ### Fixed
36
50
 
51
+ - **`runCommand` no longer reads the whole world on every command.** `LoadSnapshotScope` landed in 0.17 but only `helpers.loadSnapshot` accepted one, so the supported way to run a command — the registered `runCommand` mutation, a one-line delegation to `helpers.runCommand` — hydrated every member profile and every chunk however narrow the command was, and a game could only get bounded hydration by reimplementing the mutation. `runCommand`, `joinServer`, and `leaveServer` now hydrate a scope; a command whose `apply` dirties a player or chunk its scope never named is refused rather than persisted, since writing an unhydrated chunk overwrites the stored one with a blank. `flushSave` / `flushServerIfDue` / `tickActiveServers` / `flushDirtyServers` still hydrate everything, which is correct for what they write.
52
+
37
53
  ## 0.17.0
38
54
 
39
55
  ### Migrate
@@ -1,5 +1,5 @@
1
1
  /** Installed `@jgengine/core` semver — compare against {@link CHANGELOG} keys when migrating. */
2
- export declare const VERSION = "0.17.0";
2
+ export declare const VERSION = "0.18.0";
3
3
  /** One release's migrate steps plus added/changed/removed notes (typed mirror of CHANGELOG.md). */
4
4
  export interface ChangelogEntry {
5
5
  migrate: readonly string[];
@@ -1,7 +1,25 @@
1
1
  /** Installed `@jgengine/core` semver — compare against {@link CHANGELOG} keys when migrating. */
2
- export const VERSION = "0.17.0";
2
+ export const VERSION = "0.18.0";
3
3
  /** Per-version engine changelog keyed by semver string (e.g. `"0.10.0"`). */
4
4
  export const CHANGELOG = {
5
+ "0.18.0": {
6
+ migrate: [
7
+ "Bump lockstep SDK packages to `^0.18.0`: `@jgengine/{core,react,ws,node,sql,convex,shell,editor,assets}`. CLI `jgengine` is `0.15.0`; `@jgengine/github` is `0.5.0`.",
8
+ "Register the presence reaper and redeploy your schema. `jgPoses` gains `sessionId` / `kind` / `label` / `revokedAt` (all optional) and a `by_updated` index, and `createPresenceFunctions` now also returns `reapIdlePresence`. Re-export it (`export const { list, sync, leave, reapIdlePresence } = createPresenceFunctions(...)`) and add `crons.interval(\"jg presence reap\", { seconds: 60 }, internal.presence.reapIdlePresence, {})`. Without it nothing deletes the row of a player who closed the tab — `list` filtered stale rows at read time but the table grew forever. `jgengineCronSpecs()` carries the new entry, and every spec now names the `module` (`\"runtime\"` / `\"presence\"`) it is exported from.",
9
+ "`presence.sync` returns `{ pose, lastSeenAt, displaced }` instead of `null`, and `presence.leave` marks the row revoked instead of deleting it (the reaper collects it once idle). A caller that ignored the return value is unaffected.",
10
+ ],
11
+ added: [
12
+ "`createPresenceFunctions` gains the policy hooks the pose lane was missing — `resolveSpawn(ctx, { serverId, userId, kind })` for where a session with no row starts (async and ctx-bearing, so a spawn can read the world, replacing a hardcoded origin), `poseRules` as a function of the actor `kind` so an agent need not obey a human's speed cap, `idleCutoffMs` / `maxReapPerRun` for the reaper, and optional `sessionId` on `sync` / `leave`: a second tab is a second row, syncing revokes the others, and the displaced session learns it on its next call through `displaced: true`.",
13
+ "A command declares what it touches, so `runCommand` hydrates that and nothing else. `CommandDef` gains an optional `scope(input, actorUserId)` returning `{ players?, chunkKeys? }`, evaluated before hydration; the registered `runCommand` mutation also accepts an explicit `scope` argument, which wins over the declared one. A game that never sets either keeps the whole-world default. `ServerLoopHooks.joinScope(userId, isNew)` does the same for `onNewPlayer`.",
14
+ "`GameRuntime.commandScope` / `GameRuntime.joinScope` expose those declarations to any host, alongside the pure helpers `resolveCommandScope` / `scopeWithActor` / `commandScopeEscape` (`@jgengine/core/runtime/commandRunner`).",
15
+ ],
16
+ changed: [
17
+ "`presence.sync` accepts a call with no `pose` at all, which holds the stored position and moves only the liveness stamp. `createConvexPresenceSync` sends one itself when the pose gate has suppressed writes for 10s, so a standing player keeps their row alive without a pose write — and without the reaper collecting a live session.",
18
+ "`PresencePoseRow` carries optional `sessionId` / `kind` / `label`, so a nameplate reads off the presence row instead of joining against users per frame.",
19
+ "`runCommand` no longer reads the whole world on every command. `LoadSnapshotScope` landed in 0.17 but only `helpers.loadSnapshot` accepted one, so the supported way to run a command — the registered `runCommand` mutation, a one-line delegation to `helpers.runCommand` — hydrated every member profile and every chunk however narrow the command was, and a game could only get bounded hydration by reimplementing the mutation. `runCommand`, `joinServer`, and `leaveServer` now hydrate a scope; a command whose `apply` dirties a player or chunk its scope never named is refused rather than persisted, since writing an unhydrated chunk overwrites the stored one with a blank. `flushSave` / `flushServerIfDue` / `tickActiveServers` / `flushDirtyServers` still hydrate everything, which is correct for what they write.",
20
+ ],
21
+ removed: [],
22
+ },
5
23
  "0.17.0": {
6
24
  migrate: [
7
25
  "Bump lockstep SDK packages to `^0.17.0`: `@jgengine/{core,react,ws,node,sql,convex,shell,editor,assets}`. CLI `jgengine` is `0.14.0`; `@jgengine/github` is `0.4.0`.",
@@ -1,10 +1,46 @@
1
1
  export type CommandValidationError = {
2
2
  reason: string;
3
3
  };
4
+ /**
5
+ * Which slice of a server has to be hydrated before some work runs against it. Both fields default to
6
+ * "everything", which costs a read per member plus one per chunk — the cost that makes a large shared
7
+ * world unaffordable per mutation. Narrow them when the caller knows what it will touch.
8
+ */
9
+ export type CommandScope = {
10
+ /** Member ids to hydrate; omit for the whole roster. */
11
+ players?: readonly string[];
12
+ /** Chunk keys to hydrate; omit for every chunk of the server, `[]` for none. */
13
+ chunkKeys?: readonly string[];
14
+ };
4
15
  export type CommandDef<TInput = unknown> = {
16
+ /**
17
+ * What this command reads and writes, derived from its own input. A host that hydrates through a
18
+ * scope loads only this slice instead of the whole world, and refuses the command if `apply` then
19
+ * dirties a player or chunk the scope did not name — writing an unhydrated chunk would overwrite
20
+ * the stored one with a blank. Omit it to keep the whole-world default.
21
+ */
22
+ scope?: (input: TInput, actorUserId: string) => CommandScope;
5
23
  validate: (snapshot: import("./snapshot.js").GameRuntimeSnapshot, input: TInput, actorUserId: string) => CommandValidationError | null;
6
24
  apply: (snapshot: import("./snapshot.js").GameRuntimeSnapshot, input: TInput, actorUserId: string) => import("./snapshot.js").GameRuntimeSnapshot;
7
25
  };
26
+ /**
27
+ * The scope a command declares for this input, or `undefined` when it declares none (hydrate everything).
28
+ * Evaluate it before hydration: the actor and the command's own input are both known by then.
29
+ * @internal
30
+ */
31
+ export declare function resolveCommandScope<TInput>(commands: Record<string, CommandDef<TInput>>, commandName: string, input: TInput, actorUserId: string): CommandScope | undefined;
32
+ /**
33
+ * Widen a scope so the actor is always hydrated. A command's own player row is dirtied by
34
+ * {@link runCommand} whatever the command did, and an unhydrated player is dropped from the persist.
35
+ * @internal
36
+ */
37
+ export declare function scopeWithActor(scope: CommandScope | undefined, actorUserId: string): CommandScope | undefined;
38
+ /**
39
+ * Why a command's writes escaped the scope it was hydrated under, or `null` when they did not.
40
+ * A write outside the scope is a silent overwrite of unread state, so the host refuses it.
41
+ * @internal
42
+ */
43
+ export declare function commandScopeEscape(scope: CommandScope | undefined, dirty: import("./snapshot.js").GameRuntimeSnapshot["dirty"], commandName: string): string | null;
8
44
  export type RunCommandResult = {
9
45
  ok: true;
10
46
  snapshot: import("./snapshot.js").GameRuntimeSnapshot;
@@ -1,3 +1,39 @@
1
+ /**
2
+ * The scope a command declares for this input, or `undefined` when it declares none (hydrate everything).
3
+ * Evaluate it before hydration: the actor and the command's own input are both known by then.
4
+ * @internal
5
+ */
6
+ export function resolveCommandScope(commands, commandName, input, actorUserId) {
7
+ return commands[commandName]?.scope?.(input, actorUserId);
8
+ }
9
+ /**
10
+ * Widen a scope so the actor is always hydrated. A command's own player row is dirtied by
11
+ * {@link runCommand} whatever the command did, and an unhydrated player is dropped from the persist.
12
+ * @internal
13
+ */
14
+ export function scopeWithActor(scope, actorUserId) {
15
+ if (!scope || scope.players === undefined || scope.players.includes(actorUserId))
16
+ return scope;
17
+ return { ...scope, players: [...scope.players, actorUserId] };
18
+ }
19
+ /**
20
+ * Why a command's writes escaped the scope it was hydrated under, or `null` when they did not.
21
+ * A write outside the scope is a silent overwrite of unread state, so the host refuses it.
22
+ * @internal
23
+ */
24
+ export function commandScopeEscape(scope, dirty, commandName) {
25
+ if (!scope)
26
+ return null;
27
+ const strayPlayers = scope.players === undefined ? [] : dirty.players.filter((id) => !scope.players?.includes(id));
28
+ const strayChunks = scope.chunkKeys === undefined ? [] : dirty.chunks.filter((key) => !scope.chunkKeys?.includes(key));
29
+ if (strayPlayers.length === 0 && strayChunks.length === 0)
30
+ return null;
31
+ const parts = [
32
+ strayPlayers.length > 0 ? `players ${strayPlayers.join(", ")}` : null,
33
+ strayChunks.length > 0 ? `chunks ${strayChunks.join(", ")}` : null,
34
+ ].filter((part) => part !== null);
35
+ return `Command "${commandName}" wrote outside its declared scope: ${parts.join("; ")}`;
36
+ }
1
37
  /** @internal */
2
38
  export function runCommand(snapshot, commands, commandName, input, actorUserId) {
3
39
  const command = commands[commandName];
@@ -1,11 +1,17 @@
1
1
  import type { SaveConfig } from "./save.js";
2
- import type { CommandDef } from "./commandRunner.js";
2
+ import type { CommandDef, CommandScope } from "./commandRunner.js";
3
3
  import { runCommand } from "./commandRunner.js";
4
4
  import { type GameRuntimeSnapshot, type RuntimeChunkRow, type RuntimePlayerRow, type RuntimeProfileRow, type RuntimeServerRow } from "./snapshot.js";
5
5
  export type ServerLoopHooks = {
6
6
  onInit?: (ctx: RuntimeInitContext) => void;
7
7
  onNewPlayer?: (ctx: RuntimeLoopContext) => void;
8
8
  onTick?: (ctx: RuntimeWorldContext, dtSeconds: number) => void;
9
+ /**
10
+ * What `onNewPlayer` touches, so a join hydrates that instead of the whole world. Only consulted
11
+ * when `onNewPlayer` exists — without the hook a join provably needs the joining member and nothing
12
+ * else. Omit it and a join keeps hydrating everything.
13
+ */
14
+ joinScope?: (userId: string, isNew: boolean) => CommandScope;
9
15
  };
10
16
  export type RuntimeInitContext = {
11
17
  snapshot: GameRuntimeSnapshot;
@@ -45,6 +51,13 @@ export type GameRuntime = {
45
51
  hasTick: boolean;
46
52
  hydrate: (input: HydrateInput) => GameRuntimeSnapshot;
47
53
  runCommand: (snapshot: GameRuntimeSnapshot, actorUserId: string, commandName: string, input: unknown) => ReturnType<typeof runCommand>;
54
+ /**
55
+ * What a command declares it will touch, evaluated before hydration so a host can load that slice
56
+ * and nothing else. `undefined` means the command declared no scope — hydrate everything.
57
+ */
58
+ commandScope: (commandName: string, input: unknown, actorUserId: string) => CommandScope | undefined;
59
+ /** What a join has to hydrate. `undefined` means the whole world, as when `onNewPlayer` is unscoped. */
60
+ joinScope: (userId: string, isNew: boolean) => CommandScope | undefined;
48
61
  tick: (snapshot: GameRuntimeSnapshot, dtSeconds: number) => GameRuntimeSnapshot;
49
62
  joinPlayer: (snapshot: GameRuntimeSnapshot, userId: string, isNew: boolean) => GameRuntimeSnapshot;
50
63
  toProfileRow: (snapshot: GameRuntimeSnapshot, userId: string) => RuntimeProfileRow | null;
@@ -1,4 +1,4 @@
1
- import { runCommand } from "./commandRunner.js";
1
+ import { resolveCommandScope, runCommand } from "./commandRunner.js";
2
2
  import { createEmptyPlayerRow, createRuntimeSnapshot, } from "./snapshot.js";
3
3
  /** @internal */
4
4
  export function createGameRuntime(definition) {
@@ -33,6 +33,14 @@ export function createGameRuntime(definition) {
33
33
  runCommand(snapshot, actorUserId, commandName, input) {
34
34
  return runCommand(snapshot, definition.commands, commandName, input, actorUserId);
35
35
  },
36
+ commandScope(commandName, input, actorUserId) {
37
+ return resolveCommandScope(definition.commands, commandName, input, actorUserId);
38
+ },
39
+ joinScope(userId, isNew) {
40
+ if (!loop?.onNewPlayer)
41
+ return { players: [userId], chunkKeys: [] };
42
+ return loop.joinScope?.(userId, isNew);
43
+ },
36
44
  tick(snapshot, dtSeconds) {
37
45
  if (!loop?.onTick)
38
46
  return snapshot;
@@ -62,6 +62,12 @@ export type GameBackend<TPresenceRow = unknown, TPresenceLocation = unknown, TGa
62
62
  };
63
63
  export type PresencePoseRow = {
64
64
  userId: string;
65
+ /** Set when the host tracks presence per session, so one user can hold two rows. */
66
+ sessionId?: string;
67
+ /** Actor class, e.g. `"player"` / `"agent"` — a host may clamp each differently. */
68
+ kind?: string;
69
+ /** Display name carried on the row, so a nameplate needs no second lookup. */
70
+ label?: string;
65
71
  position: {
66
72
  x: number;
67
73
  y: number;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jgengine/core",
3
- "version": "0.17.0",
3
+ "version": "0.18.0",
4
4
  "description": "jgengine core — pure-TypeScript game framework SDK (npm @jgengine/core). Entity stores, commands, combat, inventory, multiplayer seams. Zero dependencies. Not an ECS. Agent skills ship in the tarball. https://jgengine.com",
5
5
  "keywords": [
6
6
  "jgengine",
@@ -6,7 +6,7 @@
6
6
 
7
7
  - `CHANGELOG` (const): const CHANGELOG: Record<string, ChangelogEntry> — Per-version engine changelog keyed by semver string (e.g. `"0.10.0"`).
8
8
  - `ChangelogEntry` (interface): interface ChangelogEntry — One release's migrate steps plus added/changed/removed notes (typed mirror of CHANGELOG.md).
9
- - `VERSION` (const): const VERSION: "0.17.0" — Installed `@jgengine/core` semver — compare against {@link CHANGELOG} keys when migrating.
9
+ - `VERSION` (const): const VERSION: "0.18.0" — Installed `@jgengine/core` semver — compare against {@link CHANGELOG} keys when migrating.
10
10
 
11
11
  ## @jgengine/core/authoring
12
12
 
@@ -228,7 +228,7 @@
228
228
 
229
229
  - `CHANGELOG` (const): const CHANGELOG: Record<string, ChangelogEntry> — Per-version engine changelog keyed by semver string (e.g. `"0.10.0"`).
230
230
  - `ChangelogEntry` (interface): interface ChangelogEntry — One release's migrate steps plus added/changed/removed notes (typed mirror of CHANGELOG.md).
231
- - `VERSION` (const): const VERSION: "0.17.0" — Installed `@jgengine/core` semver — compare against {@link CHANGELOG} keys when migrating.
231
+ - `VERSION` (const): const VERSION: "0.18.0" — Installed `@jgengine/core` semver — compare against {@link CHANGELOG} keys when migrating.
232
232
 
233
233
  ## @jgengine/core/runtime/adapter
234
234
 
@@ -260,7 +260,8 @@
260
260
 
261
261
  ## @jgengine/core/runtime/commandRunner
262
262
 
263
- - `CommandDef` (type): type CommandDef<TInput = unknown> = { validate: ( snapshot: GameRuntimeSnapshot, input: TInput, actorUserId: string, ) => CommandValidationError | null; apply: ( snapshot: GameRuntimeSnapshot, input: TInput, actorUserId: string, ) => GameRuntimeSnapshot; } — ⚠ undocumented
263
+ - `CommandDef` (type): type CommandDef<TInput = unknown> = { /** * What this command reads and writes, derived from its own input. A host that hydrates through a * scope loads only this slice instead of the whole world, and refuses the command if `apply` then * dirties a player or chunk the scope did not name — writing an… — ⚠ undocumented
264
+ - `CommandScope` (type): type CommandScope = { /** Member ids to hydrate; omit for the whole roster. */ players?: readonly string[]; /** Chunk keys to hydrate; omit for every chunk of the server, `[]` for none. */ chunkKeys?: readonly string[]; } — Which slice of a server has to be hydrated before some work runs against it. Both fields default to "everything", which costs a read per member plus one per chunk — the cost that makes a large shared world unaffordable per mutation. Narrow them when the caller knows what it will touch.
264
265
  - `CommandValidationError` (type): type CommandValidationError = { reason: string } — ⚠ undocumented
265
266
  - `RunCommandResult` (type): type RunCommandResult = | { ok: true; snapshot: GameRuntimeSnapshot } | { ok: false; reason: string } — ⚠ undocumented
266
267
 
@@ -340,7 +341,7 @@
340
341
  - `RuntimeInitContext` (type): type RuntimeInitContext = { snapshot: GameRuntimeSnapshot; setSnapshot: (snapshot: GameRuntimeSnapshot) => void; } — ⚠ undocumented
341
342
  - `RuntimeLoopContext` (type): type RuntimeLoopContext = RuntimeInitContext & { player: { userId: string; isNew: boolean; }; } — ⚠ undocumented
342
343
  - `RuntimeWorldContext` (type): type RuntimeWorldContext = RuntimeInitContext & { playerIds: readonly string[]; } — ⚠ undocumented
343
- - `ServerLoopHooks` (type): type ServerLoopHooks = { onInit?: (ctx: RuntimeInitContext) => void; onNewPlayer?: (ctx: RuntimeLoopContext) => void; onTick?: (ctx: RuntimeWorldContext, dtSeconds: number) => void; } — ⚠ undocumented
344
+ - `ServerLoopHooks` (type): type ServerLoopHooks = { onInit?: (ctx: RuntimeInitContext) => void; onNewPlayer?: (ctx: RuntimeLoopContext) => void; onTick?: (ctx: RuntimeWorldContext, dtSeconds: number) => void; /** * What `onNewPlayer` touches, so a join hydrates that instead of the whole world. Only consulted * when `onNewPlay… — ⚠ undocumented
344
345
 
345
346
  ## @jgengine/core/runtime/headlessRunner
346
347
 
@@ -477,7 +478,7 @@
477
478
  - `JoinServerResult` (type): type JoinServerResult = { serverId: string; isNew: boolean; } — ⚠ undocumented
478
479
  - `LiveGameBackend` (type): type LiveGameBackend<TPresenceRow = unknown, TPresenceLocation = unknown, TGameId extends string = string> = GameBackend<TPresenceRow, TPresenceLocation, TGameId> & { presenceSync: PresenceSync; pushFeedEntry: (args: { serverId: string; action: string; entry: unknown }) => Promise<void>; chatSyncFor… — ⚠ undocumented
479
480
  - `MultiplayerSession` (type): type MultiplayerSession = { gameId: string; userId: string; backend: LiveGameBackend; feedActions: string[]; } — ⚠ undocumented
480
- - `PresencePoseRow` (type): type PresencePoseRow = { userId: string; position: { x: number; y: number; z: number }; rotationY: number; rotationPitch: number; lastSeenAt: number; } — ⚠ undocumented
481
+ - `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
481
482
  - `PresenceSync` (type): type PresenceSync = { subscribe: (serverId: string, onChange: (rows: PresencePoseRow[]) => void) => FeedUnsubscribe; syncPose: (serverId: string, pose: PlayerPose) => void; } — ⚠ undocumented
482
483
  - `RunCommandArgs` (type): type RunCommandArgs = { serverId: string; command: string; input: unknown; } — ⚠ undocumented
483
484
  - `TransportRunCommandResult` (type): type TransportRunCommandResult = | { ok: true } | { ok: false; reason: string } — ⚠ undocumented