> For the complete documentation index, see [llms.txt](https://docs.decentraland.org/llms.txt). Markdown versions of documentation pages are available by appending `.md` to page URLs; this page is available as [Markdown](https://docs.decentraland.org/creator/content-creator-zh/chang-jing-sdk7/wang-luo/authoritative-servers.md).

# 多人服务器

使用无头多人服务器构建多人 Decentraland 场景。

**📔 注意**: 多人服务器之前被称为 **权威服务器**。只是名称变了，功能没变。要安装的 SDK 分支仍然名为 `auth-server`，见 [设置](#setup).

## 概览

Decentraland 在玩家的机器上本地运行场景。默认情况下，玩家可以彼此看到并直接交互，但每个人都独立地与环境交互。默认情况下，环境中的更改不会在玩家之间共享。

让所有玩家都看到场景具有相同内容和相同状态，对玩家以更有意义的方式进行交互至关重要。否则，如果一名玩家打开了一扇门并走进房子，其他玩家仍会看到那扇门是关闭的，而第一个玩家在其他玩家眼中就像是直接穿过了关闭的门。

该 **多人服务器** 是一个无头服务器进程，用于运行你的场景代码、验证状态更改，并将结果广播给所有已连接的玩家。它采用权威服务器架构：不是信任每个客户端上报自己的操作，而是由服务器作为唯一真实来源。这使它成为同步多人场景的推荐方案。

只要游戏机制中公平性很重要，多人服务器就是理想选择，因为你可以实现在服务器端运行的复杂反作弊校验。你还可以把私钥和其他敏感信息存储在服务器上，避免将它们直接暴露给用户。

拥有多人服务器也能解决一个实际问题：在点对点架构中，两个玩家同时控制像悬浮平台这样的物体，可能会产生冲突结果。每个客户端都会把平台设到不同的高度，而没有任何一方有权决定哪个是正确的。多人服务器把所有更改集中到一个地方处理，因此所有客户端都会收敛到同一个状态。

它还为你提供了一个地方来 **在不同会话之间持久保存数据**：排行榜、玩家进度、已解锁成就，或者环境变化，比如门被打开或物品被放置。玩家回来时，世界会反映之前发生过的事情。

Decentraland 会为你托管并部署服务器。通过正常流程发布你的场景时，也会无缝发布服务器，无需额外步骤，也不需要支付任何托管费用。

## 设置

### 1. 安装 auth-server 版 SDK

原生多人服务器 API（`isServer`, `registerMessages`, `存储`, `EnvVar`等）都可在单独的 SDK 分支中使用。运行以下命令，将其安装到你的项目中，而不是标准 SDK 分支：

```bash
npm install @dcl/sdk@auth-server
npm install @dcl/js-runtime@auth-server
```

### 2. 配置 scene.json

多人服务器由标志位 `"authoritativeMultiplayer": true` 在 `scene.json`在根级别启用。你不需要手动添加它：使用 SDK 的 auth-server 分支时，在你首次构建或预览场景时它会自动添加。只要确保不要把它删掉，因为没有这个标志位服务器永远不会运行，而且 `isServer()` 总是返回 *false*.

另外，你也可以选择将以下内容添加到你的 `scene.json` 根级别：

```json
{
  "logsPermissions": ["0xYourWalletAddress"]
}
```

添加 `logsPermissions` 以列出可以查看 `console.log()` 服务器日志的钱包地址。列出的用户随后可以通过运行以下命令在生产环境中查看服务器日志：

`npx sdk-commands sdk-server-logs`

### 3. 运行预览

使用标准的预览命令即可，无需额外步骤。使用 SDK 的 auth-server 分支时，预览会自动在后台启动一个本地版多人服务器。

服务器的本地会话不会连接到生产环境中的实例，因此你可以自由测试，而不会影响已经在你已发布场景中的玩家。

## 服务器 / 客户端分支

你项目中的场景代码在 `src` 文件夹中同时在服务器和客户端运行。使用 `isServer()` 函数来分离执行路径：

```typescript
import { isServer } from "@dcl/sdk/network"
import { initServer } from "./server/server"
import { initClient } from "./client/setup"
import { setupUi } from "./client/ui"

function main() {
  if (isServer()) {
    // 仅服务器端：游戏逻辑、验证、状态管理
    initServer()
    return
  } else {
    // 仅客户端：UI、输入处理、消息发送
    initClient()
    setupUi()
  }
}
```

服务器以无渲染的无头方式运行你的场景。它通过以下方式对所有玩家位置、穿戴物品和其他数据拥有已验证的访问权限： `PlayerIdentityData` 并且是游戏状态的唯一权威。

## 同步组件与验证

### 将实体同步给所有客户端

使用 `syncEntity` 以广播该实体指定组件中的任何更改：

```typescript
import { isServer, syncEntity } from "@dcl/sdk/network"

if (isServer()) {
  syncEntity(
    entity,
    [Transform.componentId, GameState.componentId],
    /* enumId */ 1
  )
}
```

语法与以下功能所使用的语法完全相同： [无服务器多人游戏](/creator/content-creator-zh/chang-jing-sdk7/wang-luo/serverless-multiplayer.md) 功能，因此把场景从这种架构升级到多人服务器非常简单。当场景使用多人服务器时，状态更新不再在所有玩家之间直接发送，而是全部通过服务器进行路由和验证。

{% hint style="warning" %}
**📔 注意**：使用多人服务器时，理想模式是只让服务器调用 `syncEntity`。这样你就不用担心 entity-id 一致性。相反，实体由服务器实例化并共享，所有客户端都会同步到该实例。始终用 `isServer()`来保护它。这与 [无服务器多人游戏](/creator/content-creator-zh/chang-jing-sdk7/wang-luo/serverless-multiplayer.md)不同，在那里每个客户端都会调用 `syncEntity` ，由各自独立执行。
{% endhint %}

### 验证更改

使用 `validateBeforeChange()` 来限制实体某个特定组件中的任何状态更新。它允许你运行自定义验证函数，并且只有在验证测试通过时更改才会成功。

如果验证返回值 *真*，则更改会被接受并传播给所有玩家。如果验证返回值 *false*，则更改会被拒绝。被拒绝的更改不会传递给其他玩家，并且会对尝试进行更改的玩家回滚。

#### 验证值

最简单的情况是验证新的 *值* 正在写入的值是否在某些参数范围内。例如，只接受对 `Transform` 的更改，前提是新的 Y 坐标大于 0：

```typescript
import { engine, Transform } from "@dcl/sdk/ecs"
import { Vector3 } from "@dcl/sdk/math"
import { isServer } from "@dcl/sdk/network"

const entity = engine.addEntity()
Transform.create(entity, { position: Vector3.create(10, 2, 10) })

if (isServer()) {
  Transform.validateBeforeChange(entity, (value) => {
    // 拒绝任何会把实体放到 Y = 0 或更低的位置的更新
    return value.newValue.position.y > 0
  })
}
```

因为 `validateBeforeChange()` 只在服务器上才有意义，因此始终用 `isServer()`来保护它。在客户端调用该函数不会产生任何有用作用。

你可以利用这一点阻止违反游戏逻辑的更改，作为反作弊机制。

#### 验证与玩家的距离

你可以将 `validateBeforeChange()` 结合服务器已验证的玩家位置，在允许玩家与物体交互之前检查他们是否足够靠近该物体。例如，当玩家试图通过更改其 `Transform`来拾取一个物体时，如果物体距离玩家超过 5 米，你可以拒绝该更改：

```typescript
import { engine, Transform, PlayerIdentityData } from "@dcl/sdk/ecs"
import { Vector3 } from "@dcl/sdk/math"
import { isServer } from "@dcl/sdk/network"

const pickableEntity = engine.addEntity()
Transform.create(pickableEntity, { position: Vector3.create(8, 1, 8) })

if (isServer()) {
  Transform.validateBeforeChange(pickableEntity, (value) => {
    // 找到发送此更改的玩家
    for (const [playerEntity, identity] of engine.getEntitiesWith(
      PlayerIdentityData
    )) {
      if (identity.address.toLowerCase() !== value.senderAddress.toLowerCase())
        continue

      const playerTransform = Transform.getOrNull(playerEntity)
      if (!playerTransform) return false

      // 获取对象在更改前的当前位置
      const objectTransform = Transform.getOrNull(pickableEntity)
      if (!objectTransform) return false

      const distance = Vector3.distance(
        playerTransform.position,
        objectTransform.position
      )

      // 只有当玩家在 5 米以内时才允许更改
      return distance <= 5
    }

    // 在已连接玩家中未找到发送者 — 拒绝
    return false
  })
}
```

这种模式作为反作弊机制很有用：它阻止玩家跨越场景去拿取他们本不该能够交互的物体。

#### 只允许管理员进行更改

你也可以基于 *谁* 发送了更改。每个传入值都包含一个 `senderAddress` 字段，其中包含发送者的钱包地址。利用它只允许某些玩家进行更改。例如，只允许场景管理员修改某个 `VideoPlayer` 组件：

```typescript
import { engine, VideoPlayer } from "@dcl/sdk/ecs"
import { isServer } from "@dcl/sdk/network"
import { isPreview } from "@dcl/asset-packs/dist/admin-toolkit-ui/fetch-utils"
import { getSceneAdmins } from "@dcl/asset-packs/dist/admin-toolkit-ui/ModerationControl/api"

// 从场景管理员列表刷新得到的管理员钱包地址缓存
let adminAddresses = new Set<string>()

async function updateAdminAddresses() {
  if (isPreview()) return
  try {
    const [error, response] = await getSceneAdmins()
    if (error) {
      console.error("[SERVER] Error fetching admin list:", error)
      adminAddresses = new Set()
      return
    }
    adminAddresses = new Set(
      (response ?? []).map((admin) => admin.admin.toLowerCase())
    )
    console.log(
      "[SERVER] 已更新管理员地址缓存：",
      Array.from(adminAddresses)
    )
  } catch (error) {
    console.error("[SERVER] Error updating admin addresses:", error)
    adminAddresses = new Set()
  }
}

export async function main() {
  const videoEntity = engine.addEntity()
  VideoPlayer.create(videoEntity, { src: "videos/intro.mp4" })

  if (isServer()) {
    // 在接入验证之前先填充缓存
    await updateAdminAddresses()

    VideoPlayer.validateBeforeChange(videoEntity, (value) => {
      // 在预览运行时始终允许更改，这样本地测试更方便
      if (isPreview()) return true

      const senderAddress = value.senderAddress.toLowerCase()
      if (!adminAddresses.has(senderAddress)) {
        console.log(
          "[SERVER] 已阻止来自以下地址的未授权 VideoPlayer 更改：",
          senderAddress
        )
        return false
      }
      return true
    })
  }
}
```

参见 [场景管理员](/creator/content-creator-zh/chang-jing-bian-ji-qi/shi-shi-yun-wei/scene-admin.md) 以了解玩家如何在场景中成为管理员的更多背景。

#### 只允许服务器进行更改

最严格的情况是只接受来自服务器本身的写入，拒绝任何来自客户端的更改。这是适用于应完全由服务器权威控制的状态的首选模式：分数、游戏阶段、生成的实体等。

每个传入值都包含一个 `senderAddress` 字段。当发送者是服务器时，该字段与常量 `AUTH_SERVER_PEER_ID`，它导出自 `@dcl/sdk/network/message-bus-sync`.

下面的示例定义了一个小型 `protectServerEntity()` 辅助函数，它会将此检查应用于给定实体的一个或多个组件。这是保护多个组件（例如 `Transform` 和 `GltfContainer`）的便捷方式，只需一次调用：

```typescript
import { engine, Entity, Transform, GltfContainer } from "@dcl/sdk/ecs"
import { Vector3 } from "@dcl/sdk/math"
import { isServer } from "@dcl/sdk/network"
import { AUTH_SERVER_PEER_ID } from "@dcl/sdk/network/message-bus-sync"

type ComponentWithValidation = {
  validateBeforeChange: (
    entity: Entity,
    cb: (value: { senderAddress: string }) => boolean
  ) => void
}

function protectServerEntity(
  entity: Entity,
  components: ComponentWithValidation[]
) {
  for (const component of components) {
    component.validateBeforeChange(entity, (value) => {
      return value.senderAddress === AUTH_SERVER_PEER_ID
    })
  }
}

if (isServer()) {
  // 在创建由服务器管理的实体之后：
  const entity = engine.addEntity()
  Transform.create(entity, { position: Vector3.create(10, 5, 10) })
  GltfContainer.create(entity, { src: "assets/model.glb" })
  protectServerEntity(entity, [Transform, GltfContainer])
}
```

{% hint style="warning" %}
**📔 注意**：始终调用 `protectServerEntity()` 在一个 `isServer()` 代码块中。它包装了 `validateBeforeChange()`，它只在服务器上才有意义——在客户端调用会导致错误。
{% endhint %}

#### 自定义组件

你也可以将 `validateBeforeChange()` 应用于场景定义的自定义组件。

```typescript
import { engine, Schemas } from "@dcl/sdk/ecs"
import { isServer } from "@dcl/sdk/network"
import { AUTH_SERVER_PEER_ID } from "@dcl/sdk/network/message-bus-sync"

export const GameState = engine.defineComponent("game:State", {
  phase: Schemas.String,
  score: Schemas.Int,
  timeRemaining: Schemas.Int,
})

// 只有服务器可以修改此组件
if (isServer()) {
  GameState.validateBeforeChange((value) => {
    return value.senderAddress === AUTH_SERVER_PEER_ID
  })
}
```

## 消息

同步组件非常适合所有玩家都应持续看到的状态：例如位置、分数或游戏阶段。但并非所有内容都适合这种模型。有时，玩家只需要告诉服务器“我点击了这个按钮”或“我想加入游戏”，而服务器需要回复一次性响应，比如“回合开始了”或“这是你的统计数据”。这些属于事件，而不是持续状态。

这就是消息的用途。使用 `registerMessages()` 在客户端和服务器之间进行带类型、经 schema 验证的通信。消息属于一次发送即忘：客户端把消息发给服务器，服务器处理后可选择回复一条。它们本身不会直接创建任何持久状态。

### 定义消息

在服务器和客户端都会导入的共享文件中定义所有消息。这样双方始终对有哪些消息以及它们携带什么数据达成一致。每个消息都是一个 `Schemas.Map` ，用于描述其载荷：

```typescript
import { Schemas } from "@dcl/sdk/ecs"
import { registerMessages } from "@dcl/sdk/network"

export const Messages = {
  // 客户端 → 服务器
  playerReady: Schemas.Map({ displayName: Schemas.String }),
  playerAction: Schemas.Map({ action: Schemas.String, targetId: Schemas.Int }),

  // 服务器 → 客户端
  gameStarted: Schemas.Map({ roundNumber: Schemas.Int }),
  gameEnded: Schemas.Map({ winnerId: Schemas.String }),
}

export const room = registerMessages(Messages)
```

{% hint style="warning" %}
**📔 注意**：调用 `registerMessages()` 一次，在共享模块的顶层调用，就像上面的示例一样。这样它会在模块首次加载时于服务器和客户端两边都运行。不要有条件地调用它，也不要放在函数内部，双方必须始终注册相同的消息。
{% endhint %}

### 发送消息

客户端只能向服务器发送消息，不存在客户端到客户端的直接消息传递。服务器可以向所有客户端广播，也可以通过地址针对特定玩家。

```typescript
import { room } from "./shared/messages"

// 客户端 → 服务器（广播，服务器接收）
room.send("playerReady", { displayName: "Alice" })

// 服务器 → 所有客户端
room.send("gameStarted", { roundNumber: 1 })

// 服务器 → 某个特定客户端（按钱包地址）
room.send("gameEnded", { winnerId: "Alice" }, { to: [playerAddress] })
```

{% hint style="warning" %}
**📔 注意**：消息的最大大小约为 13 KB。更大的消息会被传输层静默丢弃，不会产生任何错误。请保持载荷较小；如果需要共享更大的数据，请把它拆分成多条消息，或者使用同步组件。
{% endhint %}

### 接收消息

```typescript
import { room } from "./shared/messages"

// 客户端接收来自服务器的消息
room.onMessage("gameStarted", (data) => {
  console.log(`Round ${data.roundNumber} started!`)
})

// 服务器接收来自客户端的消息
room.onMessage("playerReady", (data, context) => {
  if (!context) return
  const senderAddress = context.from // 已验证的钱包地址
  console.log(`[Server] ${data.displayName} is ready (${senderAddress})`)
})
```

在服务器上，每条收到的消息都包含一个 `context` 对象，其中包含发送者已验证的钱包地址。利用它来知道是哪位玩家发送了消息（绝不要依赖载荷中自报的身份）。

### 在发送前等待状态同步

客户端应在发送第一条消息之前等待场景状态同步完成，以避免加入时的竞争条件：

```typescript
import { engine } from "@dcl/sdk/ecs"
import { isStateSyncronized } from "@dcl/sdk/network"
import { room } from "./shared/messages"

engine.addSystem(() => {
  if (!isStateSyncronized()) return

  // 现在可以安全发送消息了
  room.send("playerReady", { displayName: "Alice" })
})
```

{% hint style="info" %}
**💡 提示**: `isStateSyncronized()` 只告诉你客户端的状态已同步，并不能保证服务器已经完成初始化。若要更稳健地检查就绪状态，可以让服务器广播一条“ready”或心跳消息，并让客户端在发送游戏消息前等待它。
{% endhint %}

### 可用的 Schema 类型

所有消息载荷和自定义组件都使用 `Schemas` 进行二进制序列化。以下是可用类型的快速参考：

```typescript
import { Schemas } from "@dcl/sdk/ecs"

// 基本类型
Schemas.String // "hello"
Schemas.Int // 42
Schemas.Float // 3.14
Schemas.Boolean // true / false
Schemas.Int64 // Date.now()

// 向量类型
Schemas.Vector3 // { x: 1, y: 2, z: 3 }
Schemas.Quaternion // { x, y, z, w }

// 复杂类型
Schemas.Array(Schemas.String) // ["a", "b", "c"]
Schemas.Entity // 实体引用
Schemas.Optional(Schemas.String) // "hello" 或 undefined
Schemas.Optional(Schemas.Int) // 42 或 undefined

// 嵌套对象
Schemas.Map({
  name: Schemas.String,
  health: Schemas.Int,
  position: Schemas.Vector3,
  playerId: Schemas.Optional(Schemas.String),
})
```

{% hint style="warning" %}
**📔 注意**：消息 *必须* 使用 `Schemas.Map(...)`进行定义。你不能发送普通的 JavaScript 对象，它们会在二进制序列化时失败。
{% endhint %}

## 服务器读取玩家位置

服务器可以读取 **已验证的** 玩家位置；客户端无法伪造这些数据。这是基于位置的反作弊基础：

```typescript
import { engine, PlayerIdentityData, Transform } from "@dcl/sdk/ecs"

engine.addSystem(() => {
  for (const [entity, identity] of engine.getEntitiesWith(PlayerIdentityData)) {
    const transform = Transform.getOrNull(entity)
    if (!transform) continue

    const address = identity.address
    const position = transform.position
    // 这个位置是服务器已验证的——绝不要信任客户端上报的位置
  }
})
```

{% hint style="warning" %}
**📔 注意**：始终使用 `PlayerIdentityData` + `Transform` 服务器端获取玩家位置。绝不要信任客户端自身报告的数值。
{% endhint %}

## 数据存储

在服务器重启后持久保存数据。Storage 是 **仅限服务器**，因此始终用 `isServer()`来保护调用。服务器既可以写入也可以读取这些数据。

```typescript
import { Storage } from "@dcl/sdk/server"
```

数据可以存储在两个层级：

* **世界**：用于与所有玩家都相关的数据，例如排行榜或持久化的环境更改。
* **玩家**：用于玩家特定数据，例如保存该玩家的进度或偏好。

{% hint style="info" %}
**💡 提示**：Storage 只接受字符串。使用 `JSON.stringify()` / `JSON.parse()` 处理对象，使用 `String()` / `parseInt()` 处理数字。

在本地开发期间，存储会写入到 `node_modules/@dcl/sdk-commands/.runtime-data/server-storage.json`.
{% endhint %}

{% hint style="warning" %}
**📔 注意**: `Storage.set()`, `Storage.player.set()`，以及 `删除` 变体会解析为一个 **布尔值**。它们绝不会抛出异常——在失败时（网络错误，或者并发请求过多，见下文）它们会记录错误并解析为 `false`，表示该值已 **不是** 持久化。务必检查结果：被丢弃的 `false` 就是一次静默丢失的保存。
{% endhint %}

### 在检查点保存，不要在每次变更时都保存

Storage 是用于必须在服务器重启和重新部署后仍然保留的数据的持久存储。它不是实时数据存储。把你的运行中游戏状态保存在服务器内存里。那样更快，也更符合服务器的正确模式。只有在真正需要的时候、在有意义的检查点上，才写入 Storage。

{% hint style="warning" %}
**⚠️ 警告**：服务器运行时最多允许 **40 个进行中的主机调用** 同时存在，并且由 *所有内容* 场景请求运行时执行的操作：每次存储请求， `signedFetch`，以及其他运行时 API 都会计入同一个限制。超出的调用 **不会排队**。它们会立即以 `并发主机调用过多` 错误被拒绝。SDK 会捕获该拒绝并将 `Storage.set` promise 解析为 `false` 而不是抛出异常。如果你的代码丢弃了那个布尔值，失败的保存就会变得不可见，而你持久化的数据最终会过时或丢失。请检查每一次写入的结果（参见上面的注释）。
{% endhint %}

适合持久化的时机：

* 游戏结束，或者一局结束时。
* 玩家离开时。
* 定期去抖保存（例如每 30 秒一次），而不是每帧一次。

只持久化必须在重启或重新部署后仍然存在的数据。其他一切都可以保存在内存中。

下面的示例将分数保存在内存中，只在检查点写入，而不是每得一分都写入：

```typescript
import { Storage } from "@dcl/sdk/server"

// 工作状态保存在内存中，每次事件都会更新
const scores: Record<string, number> = {}

function onPointScored(address: string) {
  scores[address] = (scores[address] ?? 0) + 1
  // 这里不调用 Storage —— 只更新内存
}

// 只在检查点持久化，例如玩家离开时
async function onPlayerLeave(address: string) {
  const saved = await Storage.player.set(address, "score", String(scores[address] ?? 0))
  if (!saved) {
    // 该写入未能持久化 —— 先把值留在内存中，稍后再重试
    console.error(`Failed to save score for ${address}`)
  }
}
```

对于有大量并发玩家的场景，更稳健的方法是跟踪哪些键有未保存的更改（一个“脏集合”），并在下一次刷新时重试失败的写入：

```typescript
import { engine } from "@dcl/sdk/ecs"
import { Storage } from "@dcl/sdk/server"

// 工作状态在内存中，每次事件都会更新
const scores: Record<string, number> = {}
const dirty = new Set<string>()

function onPointScored(address: string) {
  scores[address] = (scores[address] ?? 0) + 1
  dirty.add(address) // 标记为稍后刷新，这里不调用 Storage
}

// 定期刷新脏键（例如每约 30 秒）以及在检查点刷新
async function flush() {
  for (const address of dirty) {
    const ok = await Storage.player.set(address, "score", String(scores[address]))
    if (ok) {
      dirty.delete(address) // 保存成功
    }
    // 如果 ok 为 false，该键会保持脏状态，并在下一次刷新时重试
  }
}

// 使用 dt 累加器按计时器运行刷新
let flushTimer = 0
engine.addSystem((dt) => {
  flushTimer += dt
  if (flushTimer > 30) {
    flushTimer = 0
    flush()
  }
})
```

### 世界存储 — 所有玩家共享

```typescript
import { Storage } from "@dcl/sdk/server"

// 写入 — 如果值未持久化，set() 会解析为 false
const ok = await Storage.set(
  "leaderboard",
  JSON.stringify([
    { name: "Alice", score: 100 },
    { name: "Bob", score: 85 },
  ])
)
if (!ok) {
  console.error("Failed to save leaderboard — retry later")
}

// 读取
const raw = await Storage.get<string>("leaderboard")
const leaderboard = raw ? JSON.parse(raw) : []

// 删除
await Storage.delete("leaderboard")
```

你也可以通过命令行管理场景存储，使用 `npx sdk-commands storage scene`:

```bash
# 设置值
npx sdk-commands storage scene set high_score --value 100

# 获取值
npx sdk-commands storage scene get high_score

# 删除值
npx sdk-commands storage scene delete high_score

# 删除所有场景存储数据
npx sdk-commands storage scene clear --confirm
```

### 玩家存储 — 按钱包地址区分

```typescript
import { Storage } from "@dcl/sdk/server"

// 写入 — 如果值未持久化，set() 会解析为 false
const ok = await Storage.player.set(
  playerAddress,
  "progress",
  JSON.stringify({
    level: 5,
    coins: 250,
  })
)
if (!ok) {
  console.error(`Failed to save progress for ${playerAddress} — retry later`)
}

// 读取
const saved = await Storage.player.get<string>(playerAddress, "progress")
const progress = saved ? JSON.parse(saved) : { level: 1, coins: 0 }

// 删除
await Storage.player.delete(playerAddress, "progress")
```

你也可以通过命令行管理玩家存储，使用 `npx sdk-commands storage player`:

```bash
# 为特定玩家设置值
npx sdk-commands storage player set level --value 10 --address 0x1234...

# 获取特定玩家的值
npx sdk-commands storage player get level --address 0x1234...

# 删除特定玩家的值
npx sdk-commands storage player delete level --address 0x1234...

# 删除特定玩家的所有数据
npx sdk-commands storage player clear --address 0x1234... --confirm

# 删除所有玩家数据（所有玩家）
npx sdk-commands storage player clear --confirm
```

### 访问已存储的数据

你可以通过存储界面查看和编辑服务器上实时存储的数据，输入此链接：

[decentraland.org/storage](https://decentraland.org/storage)

你也可以通过 Creator Hub 访问此页面。打开 **管理** 选项卡，点击你已发布内容的地点旁边的三个点，然后选择 **查看存储**.

在那里你可以看到所有可发布场景的世界和地块列表。

打开你的场景，然后打开 **场景** 或 **玩家** 选项卡中。

在 **场景** 选项卡，你会看到所有已存储变量的列表。你可以在这里通过点击铅笔或垃圾桶图标来编辑或移除这些变量中的任意一个。

![Activate stream](https://2460066822-files.gitbook.io/~/files/v0/b/gitbook-x-prod.appspot.com/o/spaces%2FoPnXBby9S6MrsW83Y9qZ%2Fuploads%2Fgit-blob-cc204e102da9c2e63419b91f818f22a579e55ebf%2Fscene-data.png?alt=media)

在 **玩家** 选项卡，你会看到所有在你的服务器上存有任何数据的玩家列表。你可以按地址或名称搜索他们，然后查看其关联的全部数据。你也可以通过点击铅笔或垃圾桶图标来编辑或移除这些数据。

### 更改数据结构

生产环境中的存储数据 **在你发布场景新版本时不会被清除**。这对于排行榜、玩家进度以及玩家期望在场景小更新之后仍然存在的持久环境变化非常有用。

另一方面，存储中的数据是由你代码的旧版本写入的。如果你的新代码期望不同的结构，那么解析或读取这些旧数据时可能会以微妙的方式失败。你重命名过的字段会缺失。过去是字符串、现在变成对象的字段，在你尝试访问其属性时会抛出异常。一个几个月都没登录过的玩家，可能会加载一个早于你代码已不再知道如何处理的结构的数据。

{% hint style="warning" %}
**📔 注意**：模式变更不会只影响部署后的第一次读取。存储数据会一直存在，直到被覆盖或删除，因此旧格式的值可能在任何时候出现，通常来自你早已忘记的回归玩家。
{% endhint %}

#### 最佳实践

* *始终采用防御性解析*。即使是你写入的，也要把任何从存储中取出的内容视为不可信输入。将 `JSON.parse()` 包裹在一个 `try/catch`中，在读取字段之前检查它们是否存在，并在它们不存在时准备一个合理的默认值：

  ```typescript
  import { Storage } from "@dcl/sdk/server"

  const raw = await Storage.player.get<string>(playerAddress, "progress")
  let progress = { level: 1, coins: 0 }
  if (raw) {
    try {
      const parsed = JSON.parse(raw)
      progress = {
        level: typeof parsed.level === "number" ? parsed.level : 1,
        coins: typeof parsed.coins === "number" ? parsed.coins : 0,
      }
    } catch {
      // 旧数据或损坏数据 —— 回退到默认值
    }
  }
  ```
* *添加字段，不要重命名或删除字段*。最安全的模式更改是增量式的：引入一个带默认值的新字段，并保持现有字段不变。旧数据只是不会包含新字段，而你的防御性解析已经能处理这种情况。重命名字段会让每一条旧记录都失效。
* *为你存储的对象添加版本*。从第一天起就包含一个 `version` 字段。在读取数据时，根据版本分支处理，并在使用之前把旧结构迁移到当前结构。这样其余代码就能始终使用单一的当前结构：

  ```typescript
  type ProgressV2 = { version: 2; level: number; coins: number; xp: number }

  function migrate(raw: any): ProgressV2 {
    const version = raw?.version ?? 1
    if (version === 1) {
      // v1 没有 xp 字段 —— 给它设置默认值
      return {
        version: 2,
        level: raw.level ?? 1,
        coins: raw.coins ?? 0,
        xp: 0,
      }
    }
    return raw as ProgressV2
  }
  ```
* *将迁移后的值写回*。一旦你在内存中升级了某条记录，就把它写回去，这样下一次读取时它已经是新格式。随着时间推移，这会逐步清空旧结构记录，而无需一次性迁移脚本。
* *对于破坏性更改，请使用新键*。如果新结构确实不兼容，而且迁移不值得，那么就写入一个新的存储键（例如 `progress_v2`），并忽略旧键。旧键会安然留在存储中，而你也避免了任何必须解释它的读取路径。你可以稍后通过 [存储界面](https://decentraland.org/storage) 或 `npx sdk-commands storage` 命令来清理旧键。
* *使用真实生产数据进行测试*。在部署结构性更改之前，从存储界面中拉取几条真实记录，并用你的新解析代码对它们运行测试。真正会出问题的边角案例，通常都是你根本不知道存在的记录。
* *保留应急出口*。请记住，你可以通过存储界面或通过 `npx sdk-commands storage`编辑或删除单条记录。如果某个玩家在模式更改后卡在了坏状态里，你可以直接修复他们的记录，而无需重新部署。

## 环境变量

在不把值硬编码进代码的情况下配置你的场景。环境变量适合存放敏感数据，也适合存放功能开关或参数，这些内容无需重新发布场景即可轻松更改。

环境变量是 **仅限服务器**。使用 `isServer()`进行保护。服务器可以读取环境变量，但不能修改它们的值。

`EnvVar.get()` 返回一个 `Promise<string>` ，在变量未设置时会解析为空字符串，因此始终要为缺失值提供回退：

```typescript
import { EnvVar } from "@dcl/sdk/server"
import { isServer } from "@dcl/sdk/network"

export async function main() {
  if (!isServer()) return

  const maxPlayers = parseInt((await EnvVar.get("MAX_PLAYERS")) || "4")
  const gameDuration = parseInt((await EnvVar.get("GAME_DURATION")) || "300")
  const debugMode = ((await EnvVar.get("DEBUG")) || "false") === "true"
}
```

### 敏感数据

环境变量对于存放私钥、奖励兑换码以及其他敏感数据尤其有用，因为把这些内容暴露在公开场景的编译代码里会很危险。

你可以把私钥存储在服务器的存储中，并且只让服务器通过 `isServer()`来读取它们。这样敏感数据就永远不会经过玩家的机器。

### 本地开发

要在本地运行项目时使用环境变量，请在项目根目录创建一个 `.env` 文件：

```
MAX_PLAYERS=8
GAME_DURATION=300
DEBUG=true
```

重要：将 `.env` 添加到你的 `.gitignore`，这样这些可能敏感的值就永远不会被上传到公共内容服务器。

### 更改环境变量

更改环境变量值的最简单方式是通过存储界面。

你可以通过输入此链接来访问由场景存储保存的数据：

[decentraland.org/storage](https://decentraland.org/storage)

你也可以通过 Creator Hub 访问此页面。打开 **管理** 选项卡，点击你已发布内容的地点旁边的三个点，然后选择 **查看存储**.

在那里你可以看到所有可发布场景的世界和地块列表。

打开你的场景，然后打开 **环境** 选项卡。你应该会看到项目中的所有环境变量。

![Activate stream](https://2460066822-files.gitbook.io/~/files/v0/b/gitbook-x-prod.appspot.com/o/spaces%2FoPnXBby9S6MrsW83Y9qZ%2Fuploads%2Fgit-blob-3661fea25e77812e3b8eb0c4ec56a5ee808f9ff1%2Fenvironment-variables.png?alt=media)

请注意，你无法读取这些环境变量中的任何值（这是为了保护敏感数据），但你可以删除或覆盖其中任何一个。只需点击铅笔或垃圾桶图标即可。

你也可以通过命令行管理环境变量，使用 `npx sdk-commands storage env`:

```bash
# 设置变量
npx sdk-commands storage env set MAX_PLAYERS --value 8

# 删除变量
npx sdk-commands storage env delete OLD_VAR

# 删除所有环境变量
npx sdk-commands storage env clear --confirm
```

你也可以通过 `--target` 标志来指定特定环境：

```bash
# 部署到预发布环境
npx sdk-commands storage env set MY_KEY --value my_value --target https://storage.decentraland.zone

# 部署到本地开发服务器
npx sdk-commands storage env set MY_KEY --value my_value --target http://localhost:8000
```

已部署的环境变量优先于 `.env` 取值列表。

## 推荐的项目结构

将服务器、客户端和共享代码分离，可以让代码库随着增长仍保持可读：

```
src/
├── index.ts              # 入口点 — isServer() 分支
├── client/
│   ├── setup.ts          # 输入处理器、消息发送器
│   └── ui.tsx            # React ECS UI（读取同步状态）
├── server/
│   ├── server.ts         # 游戏循环、消息处理器、状态变更
│   └── gameState.ts      # 服务器状态的辅助函数
└── shared/
    ├── schemas.ts        # 组件定义 + validateBeforeChange
    └── messages.ts       # registerMessages() — 由双方导入
```

{% hint style="info" %}
**💡 提示**：将所有 `registerMessages()` 调用和自定义组件定义放在 `shared/`中。服务器和客户端都从那里导入，确保它们始终就消息模式达成一致。
{% endhint %}

## 性能最佳实践

每次组件更改都会发送 *整个* 组件数据到网络上。这与 Colyseus 的做法不同，后者只发送差异。在设计自定义组件时请记住这一点。最优方案可能是根据变更频率，把数据存放在不同组件中。

### ❌ 避免单体组件

```typescript
import { engine, Schemas } from "@dcl/sdk/ecs"

// 不佳 — 修改分数时也会发送位置数组
const GameState = engine.defineComponent("GameState", {
  playerAScore: Schemas.Int,
  playerBScore: Schemas.Int,
  timer: Schemas.Int,
  playerPositions: Schemas.Array(Schemas.Vector3), // 大负载
})
```

### ✅ 优先使用原子化组件

```typescript
import { engine, Schemas } from "@dcl/sdk/ecs"

// 好 — 每次更新都很小且彼此独立
const PlayerScore = engine.defineComponent("PlayerScore", {
  playerA: Schemas.Int,
  playerB: Schemas.Int,
})

const GameTimer = engine.defineComponent("GameTimer", {
  secondsLeft: Schemas.Int,
})
```

*经验法则*：把一起变化且频率相近的字段分组。将变化快的数据（计时器、位置）与变化慢的数据（分数、配置）分开。

### 对高频消息进行限流

避免每帧都发送消息。尽可能批量处理或限流：

```typescript
import { engine } from "@dcl/sdk/ecs"
import { room } from "./shared/messages"

let lastSend = 0
engine.addSystem((dt) => {
  lastSend += dt
  if (lastSend > 0.1) {
    // 每 100 毫秒
    room.send("position", transform.position)
    lastSend = 0
  }
})
```

例如，如果服务器控制一个倒计时器，就没有必要每秒向所有玩家发送更新。最好让每个客户端自己计算时间流逝，然后服务器每约 30 秒广播一次当前状态，以确保一致性。

## 服务器资源限制

多人服务器会在一个沙箱化隔离环境中运行每个场景，并施加严格的资源上限。触及这些限制可能会静默丢失数据，或者让场景中的所有人服务器终止，因此请让你的场景设计远低于这些上限。

### 内存

该隔离环境有一个 **256 MB** 内存上限。如果超出，隔离环境会被释放，服务器会对所有已连接玩家关闭。请保持工作状态精简，并在玩家离开时清理每个玩家的数据。

### CPU

每个执行轮次都有一个墙钟时间预算：

* **同步执行**：每轮 10 秒。超出这个时间的无限循环会让隔离环境被终止。
* **异步轮次完成**：60 秒。如果一个 `Promise` 链或 `await` 解析所需时间超过这个限制，隔离环境就会被终止。

使用一个 `dt` 中的累加器把重工作分散到多个 tick 中， `engine.addSystem()`。绝不要在服务器上运行无限制的同步循环。

### 入站消息速率

每个已连接对等端大约最多可发送 **每 1,000 ms 300 条消息**。超出的数据帧会被丢弃（不会排队）。绝不要从客户端在每帧都发送消息。参见 [性能最佳实践](#performance-best-practices) 了解限流模式。

### 消息大小

* 入站包大小上限为 **128 KB** 每个包。过大的包会被直接丢弃。
* 场景到通信的消息大小上限约为 **30 KB**。在实际传输层中，请将同步消息控制在远低于 **13 KB** （见 [消息](#messages) 部分进一步说明）。

### 外部获取

并发 `signedFetch` 调用上限为 **32** 个进行中。额外的 fetch 会排队，直到有一个槽位空出。每次 fetch 尝试都有 **15 秒** 超时。Fetch 响应大小上限为 **10 MB**。WebSocket 连接数量限制为 **32** 个并发 socket，单条消息最大大小为 **1 MB** 每条消息。

### 进行中的主机调用

中描述的 40 次调用上限 [数据存储](#data-storage) 适用于整个 isolate 的所有宿主调用，包括 Storage、 `signedFetch`以及其他运行时 API。超出调用会立即被拒绝，不会排队。

## 常见陷阱

### 忘记对仅服务器状态进行校验

如果没有 `validateBeforeChange`，客户端可以向任何组件写入：

```typescript
import { engine, Schemas } from "@dcl/sdk/ecs"
import { isServer } from "@dcl/sdk/network"
import { AUTH_SERVER_PEER_ID } from "@dcl/sdk/network/message-bus-sync"

// ❌ 糟糕——客户端可以作弊
const Score = engine.defineComponent("Score", { value: Schemas.Int })

// ✅ 很好——仅服务器
if (isServer()) {
  Score.validateBeforeChange((v) => v.senderAddress === AUTH_SERVER_PEER_ID)
}
```

### 信任客户端提供的值

永远不要让客户端为生命值、得分或位置等重要数据决定其自身的值：

```typescript
import { room } from "./shared/messages"

// ❌ 糟糕
room.onMessage("setHealth", (data) => {
  player.health = data.health // 客户端控制这个值！
})

// ✅ 很好——由服务器计算结果
room.onMessage("takeDamage", (data) => {
  const damage = calculateDamage(data.source)
  player.health = Math.max(0, player.health - damage)
})
```

### 在状态同步之前发送消息

客户端必须等到状态同步完成后才能交互：

```typescript
import { engine } from "@dcl/sdk/ecs"
import { isStateSyncronized } from "@dcl/sdk/network"

engine.addSystem(() => {
  if (!isStateSyncronized()) return
  // 可以安全发送消息
})
```

### 等待服务器启动

服务器仅在场景中至少有一名玩家时才处于活动状态。最后一名玩家离开后，服务器会继续运行大约两分钟，然后关闭。下一次访问会冷启动一个新实例，这大约需要 **生产环境下 15 秒**。本地预览会立即启动服务器，因此冷启动问题几乎总是在发布后才会显现。

你的场景代码应准备好等待服务器上线。服务器启动完成之前发送的消息会被静默丢失。等待期间向玩家显示“服务器正在启动”消息，并为初始服务器请求添加重试逻辑。

{% hint style="info" %}
**提示：** 一种可靠检测服务器就绪的方法是心跳：让服务器每 2 秒向一个同步组件字段写入 `Date.now()` ，并让客户端跟踪它上次看到该值变化的时间。如果 6 秒内没有变化到来，就把服务器视为离线。这比 `isStateSyncronized()`更稳妥，因为它只能确认传输已连接，而不能确认服务器正在运行。
{% endhint %}

## 完整示例

一个最小的多人计数器：点击按钮，服务器会递增一个同步分数。服务器会将计数器持久化到 `存储` ，这样该值在服务器重启后仍然保留。请记住，当场景中没有玩家时服务器会关闭，因此如果没有存储，只要场景里没人，计数就会每次重置为零。

```typescript
import { engine, Schemas } from "@dcl/sdk/ecs"
import { registerMessages, isServer, syncEntity } from "@dcl/sdk/network"
import { AUTH_SERVER_PEER_ID } from "@dcl/sdk/network/message-bus-sync"
import { pointerEventsSystem } from "@dcl/sdk/ecs"
import { Storage } from "@dcl/sdk/server"

// 1. 定义消息（共享）
const Messages = {
  increment: Schemas.Map({}),
  stateUpdate: Schemas.Map({
    count: Schemas.Int,
    lastPlayer: Schemas.String,
  }),
}

// 2. 定义一个仅服务器组件（共享）
const Counter = engine.defineComponent("Counter", {
  value: Schemas.Int,
  lastPlayer: Schemas.String,
})

// 3. 创建房间
const room = registerMessages(Messages)

export async function main() {
  if (isServer()) {
    // === 服务器 ===

    // 只有服务器可以修改此组件
    Counter.validateBeforeChange(
      (v) => v.senderAddress === AUTH_SERVER_PEER_ID
    )

    // 如果服务器重启，则从存储中恢复计数器
    const savedCount = await Storage.get<string>("counter")
    const savedPlayer = await Storage.get<string>("lastPlayer")
    const initialCount = savedCount ? parseInt(savedCount) : 0
    const initialPlayer = savedPlayer ?? "none"

    const counterEntity = engine.addEntity()
    syncEntity(counterEntity, [Counter.componentId], 1)
    Counter.create(counterEntity, {
      value: initialCount,
      lastPlayer: initialPlayer,
    })

    room.onMessage("increment", async (_data, context) => {
      if (!context) return
      const counter = Counter.getMutable(counterEntity)
      counter.value += 1
      counter.lastPlayer = context.from

      // 将其持久化到存储，以便该值在服务器重启后仍然保留
      await Storage.set("counter", String(counter.value))
      await Storage.set("lastPlayer", counter.lastPlayer)

      room.send("stateUpdate", {
        count: counter.value,
        lastPlayer: context.from,
      })
    })
  } else {
    // === 客户端 ===
    const button = engine.addEntity()
    // ... 添加 Transform、MeshRenderer 等

    pointerEventsSystem.onPointerDown(button, () => {
      room.send("increment", {})
    })

    room.onMessage("stateUpdate", (data) => {
      console.log(`Count: ${data.count} (last click by ${data.lastPlayer})`)
    })
  }
}
```

## 本地测试

标准预览会处理一切。当使用 SDK 的 auth-server 分支时，本地服务器会在客户端预览旁边的后台自动启动。

要在本地测试多人交互，请在两个独立窗口中打开预览，每个窗口都会被视为一个独立玩家。使用不同地址连接每个窗口。两个客户端都会连接到同一个本地服务器实例。

使用 Creator Hub，再次点击“预览”按钮，它会打开第二个 Decentraland explorer 窗口。你必须在两个窗口中使用不同的地址连接。即使场景重新加载，两个会话也会保持打开。

作为替代方案，你可以在浏览器 URL 中输入以下内容，以打开第二个 Decentraland explorer 窗口：

> `decentraland://realm=http://127.0.0.1:8000&local-scene=true&debug=true&multi-instance=true`

### 调试提示

* *为日志添加前缀* 中调用你的 UI 渲染方法，使用 `[SERVER]` 或 `[CLIENT]` ，这样你就能在终端中区分它们：

  ```typescript
  import { isServer } from "@dcl/sdk/network"

  if (isServer()) {
    console.log("[SERVER] Starting...")
  } else {
    console.log("[CLIENT] Starting...")
  }
  ```
* *验证组件同步* 在客户端通过记录实体数量：

  ```typescript
  import { engine } from "@dcl/sdk/ecs"

  engine.addSystem(() => {
    const entities = Array.from(engine.getEntitiesWith(MyComponent))
    console.log("[CLIENT] Synced entities:", entities.length)
  })
  ```

## 在生产环境中调试

要查看 `console.log()` 已发布服务器的输出，你的钱包地址必须列在 `logsPermissions` 数组里 `scene.json`:

```json
{
  "logsPermissions": ["0xYourWalletAddress"]
}
```

中。否则，即使场景所有者也看不到生产环境中的服务器日志。

在项目文件夹中运行以下命令，即可从命令行实时流式查看服务器日志

```bash
npx sdk-commands sdk-server-logs
```

你还可以通过以下方式手动指定日志的世界名称：

```bash
npx sdk-commands sdk-server-logs --world WORLD_NAME.dcl.eth
```

在查看多场景世界中的某个场景或 Genesis City 中的地块日志时，还要传入一个 `位置` 用于坐标的

```bash
npx sdk-commands sdk-server-logs --world WORLD_NAME.dcl.eth --position=x,y
```

系统会提示你使用 `logsPermissions` 中列出的某个钱包签名一条消息 `console.log()` 进行身份验证。连接后，你将实时看到服务器端

### 输出，这对于无需重新部署即可诊断问题非常有用。

你可以通过输入此链接来访问由场景存储保存的数据：

[decentraland.org/storage](https://decentraland.org/storage)

你也可以通过 Creator Hub 访问此页面。打开 **管理** 选项卡，点击你已发布内容的地点旁边的三个点，然后选择 **查看存储**.

在那里你可以看到所有可发布场景的世界和地块列表。

查看存储数据

打开世界或玩家数据，查看为每个对象存储的信息。例如，如果某个玩家在游玩你的场景时遇到问题，你可以通过地址查找这个玩家，并查看为其存储了哪些数据，以了解他们的情况。也许他们遇到了一个边缘情况，导致数据自相矛盾。你甚至可以在此页面上清除或编辑该玩家的数据，把他们恢复到稳定状态。

## 版本控制

场景的每个已发布版本都会获得自己独特的哈希 ID，并且每个哈希都对应自己的服务器实例。这意味着客户端代码和服务器代码始终同步更新，不会出现旧逻辑的客户端与新逻辑的服务器通信（反之亦然）的窗口期。

当你发布更新时：

* *场景中已存在的玩家* 在他们离开并回来之前，会继续看到旧版本的场景。他们的客户端会保持连接到与旧哈希匹配的服务器实例。
* *新到达的玩家* 在更新后会加载新场景版本并连接到新的服务器实例。

这保证了由于 schema 更改或组件重命名导致的客户端和服务器状态永远不会失去同步。更新绝不会破坏已经在你场景中的玩家会话。

代价是，在部署后的短时间窗口内，玩家可能会分散到两个不同的服务器实例上。已经在场景中的玩家和刚到达的玩家可能彼此看不到，也无法通过场景交互，即使他们在同一个场景中，直到较早的玩家离开并重新加入。

{% hint style="info" %}
**💡 提示**：通过 [存储](#data-storage) 服务存储的数据（如排行榜、玩家进度或持久化环境更改）会在 *不是* 不同版本之间被清空。存储在地点级别持久保存，并在指向同一场景的所有服务器实例之间共享，因此新版本会从上一个版本停止的地方继续。
{% endhint %}

## 从 Colyseus 迁移

如果你已有基于 Colyseus 构建的场景，下表会将常见的 Colyseus 模式映射到它们在 SDK7 中的等价写法：

| Colyseus                      | SDK7 多人服务器                          |
| ----------------------------- | ----------------------------------- |
| `room.send(type, data)`       | `room.send(type, data)` — 相同 API    |
| `room.onMessage(type, cb)`    | `room.onMessage(type, cb)` — 相同 API |
| `room.state.players` （schema） | `syncEntity` + 自定义组件                |
| JSON 序列化                      | 二进制序列化（自动通过 `Schemas`)              |
| 独立的服务器应用                      | 同一代码库—— `isServer()` 分支             |
| 自定义服务器托管                      | 内置：预览会自动运行服务器                       |

需要牢记的关键差异：

* *序列化*：Colyseus 发送 JSON diff；SDK 在每次更改时发送整个组件。保持组件尽可能小（见 [性能最佳实践](#performance-best-practices)).
* *状态模型*：Colyseus 使用带自动 diff 的可变状态树。SDK 使用通过 `syncEntity` 同步并受 `validateBeforeChange`.
* *托管*：无需单独部署服务器。多人服务器会与场景一起自动部署。


---

# Agent Instructions
This documentation is published with GitBook. GitBook is the documentation platform designed so that both humans and AI agents can read, navigate, and reason over technical content effectively. Learn more at gitbook.com.

## Querying This Documentation
If you need additional information that is not directly available in this page, you can query the documentation dynamically by asking a question.

Perform an HTTP GET request on the current page URL with the `ask` query parameter, and the optional `goal` query parameter:

```
GET https://docs.decentraland.org/creator/content-creator-zh/chang-jing-sdk7/wang-luo/authoritative-servers.md?ask=<question>&goal=<endgoal>
```

`ask` is the immediate question: it should be specific, self-contained, and written in natural language.
`goal` is optional and describes the broader end goal you are ultimately trying to accomplish on behalf of the user. GitBook uses it to tailor the answer towards what is most useful for that goal.

The response will contain a direct answer to the question and relevant excerpts and sources from the documentation.

Use this mechanism when the answer is not explicitly present in the current page, you need clarification or additional context, or you want to retrieve related documentation sections.
