SDK 参考

以下内容均来自软件包的主入口。宿主方法调用均会返回异步结果。每次宿主调用都会先在你的框架内检查能力和输入;若检查失败,在发送任何内容前就会返回 FORBIDDEN 或 INVALID_INPUT。本地读取方法和订阅会同步完成。

连接

import {connect, applyTheme, SDKError} from '@zenclub/sdk'

connect(options: {
  pluginId: string
  version: string                  // the manifest version
  allowedHostOrigins: string[]     // exact origins, such as 'https://zenclub.app'
  hostApi?: {major: number, minMinor: number}   // default {major: 1, minMinor: 0}
  timeoutMs?: number               // default 10000
}): Promise<ZenClubClient>

从 URL 片段读取宿主源站和实例 ID,根据 allowedHostOrigins 检查源站并完成握手。成功时返回客户端;失败时会以 NOT_EMBEDDED、HOST_DENIED、HANDSHAKE_REJECTED、INCOMPATIBLE_API 或 HANDSHAKE_TIMEOUT 拒绝(超时由 timeoutMs 决定,默认 10 秒)。

上下文

sdk.context: {
  pluginId: string, instanceId: string, sessionId: string
  protocol: 'zenclub.rpc/1'
  hostApi: {major: 1, minor: 6}      // undeclared legacy manifests receive 1.4 or 1.5
  permissions: string[]            // the granted capabilities
  theme: ThemeSnapshot
  sound?: {enabled: boolean}       // only for 1.5 manifests, once a preference was chosen
  progression?: {enabled: boolean, importExisting: boolean} // approved city source only
  storageMode: 'postgres' | 'device' | 'memory' | 'development'
  user?: {displayName: string} | null
  locale?: string                  // a BCP 47 tag, such as 'ja-JP' or 'zh-TW'
  timeZone?: string                // such as 'Asia/Tokyo'
  entitlements?: {tier: 'free' | 'basic' | 'pro'}
  campaign?: {source?: string, medium?: string, campaign?: string} | null
}
sdk.supports(capability: string): boolean

sdk.context 描述当前会话。storageMode 表示存档位置:存于觉城服务器时为 postgres,加密后留在此浏览器时为 device;测试宿主使用 memory 或 development。旧版宿主可能缺少可选字段,读取时请提供备用值。

主题

sdk.theme.current(): ThemeSnapshot
sdk.theme.subscribe(listener: (theme: ThemeSnapshot) => void): () => void
applyTheme(theme: ThemeSnapshot, root?: HTMLElement): void

ThemeSnapshot: {
  themeId: string
  mode: 'light' | 'dark'
  modePreference?: 'light' | 'dark' | 'system'
  reducedMotion: boolean
  tokens: {background, surface, ink, muted, accent, accentInk, border, soft,
           radius, font, step1 … step12, onStepLow, onStepHigh}
}

subscribe 会立即调用你,并在每次变化时再次调用。applyTheme 会将 24 个令牌写为 --zc-* CSS 变量,并在根元素上设置 data-mode、data-motion 和 color-scheme。请遵循 reducedMotion。

声音偏好

// Require hostApi: {major: 1, minMinor: 5} in your manifest and connect().
sdk.sound.current(): boolean | undefined
sdk.sound.subscribe(listener: (enabled: boolean) => void): () => void

自开发套件 0.8 和宿主接口 1.5 起提供。只有要求版本为 1.5 或更高的插件才会收到可选的 context.sound 字段和 sound.changed 事件。两者只接受 {enabled: boolean}。访客尚未作出选择时,上下文中不会包含该字段。宿主未提供选择时,current() 会返回 undefined,请使用你自己的默认值。已知偏好后,subscribe 会立即收到一个布尔值,并在偏好变化时再次收到;它会返回取消订阅函数。

这是一项默认偏好,不是强制静音。启用时,先打开游戏内的声音开关,并且只在玩家于游戏内操作后开始播放音频。关闭时,请保持安静,直到访客在游戏中开启声音。请保留自己的声音控制,并在平台偏好变化时同步更新。无论偏好如何,游戏暂停或隐藏时都必须静音。

let soundOn = sdk.sound.current() ?? false
let paused = false, hidden = false
function applySound() {
  setSoundSwitch(soundOn)          // your own control, including a header action if used
  if (!soundOn || paused || hidden) stopAudio()
}
applySound()
const unsubscribe = sdk.sound.subscribe(enabled => {
  soundOn = enabled
  applySound()                     // a host event is not an audio-unlocking gesture
})
sdk.onLifecycle(command => {
  if (command.type === 'pause') paused = true
  if (command.type === 'resume') paused = false
  if (command.type === 'visibility') hidden = !command.visible
  if (command.type === 'dispose') { paused = true; unsubscribe(); stopAudio(); return }
  applySound()
})
gameSurface.addEventListener('click', () => {
  if (soundOn && !paused && !hidden) startAudioFromGesture()
})
soundButton.addEventListener('click', () => {
  soundOn = !soundOn
  applySound()
  if (soundOn && !paused && !hidden) startAudioFromGesture()
})
sdk.onDisconnect(() => { paused = true; unsubscribe(); stopAudio() })

存储

sdk.storage.read(slot = 'primary'): Promise<SaveEnvelope | null>
sdk.storage.write(slot: string, data: Json, options: {
  expectedRevision: number         // 0 for a new slot
  schemaVersion: string            // your save format, such as 'garden-v2'
  requestId?: string               // the same id for every retry of one change
}): Promise<SaveEnvelope>
sdk.storage.createAutosave(save: () => Promise<unknown>, options?: {
  intervalMs?: number              // 10000 to 300000, default 10000
  onError?: (error: SDKError) => void
}): {schedule(): void, flush(): Promise<void>, dispose(): void}
sdk.storage.flush(): Promise<void>

SaveEnvelope: {slot: string, schemaVersion: string, revision: number, data: Json, updatedAt: string}

存储槽名称类似 ID,默认值为 primary。每份存档最多容纳 128 KiB 的纯 JSON。只有 expectedRevision 匹配时写入才会成功;否则会以 CONFLICT 失败,且不会覆盖任何内容。新建存储槽时使用 0。

私人城市里程碑

sdk.progression.current(): {enabled: boolean, importExisting: boolean}
sdk.progression.subscribe(listener: (preference: {
  enabled: boolean, importExisting: boolean
}) => void): () => void
sdk.milestones.report(value: {
  key: 'homestead' | 'pond' | 'clouds' | 'bamboo' | 'tea' | 'stream' | 'bell' | 'lotus'
  tier: 1 | 2
  ruleVersion: string              // exactly '<key>.1' in this release
  evidence: {saveRevision: number} // confirmed primary save revision
  requestId: string                // one id per immutable report, 1-80 ID characters
}): Promise<{status: 'recorded' | 'already-recorded', tier: 1 | 2}>

自开发套件 0.9 和宿主接口 1.6 起提供。获批的来源会收到可选的 context.progression 和 progression.changed,内容恰为 {enabled: boolean, importExisting: boolean}。不支持时,本地读取方法会将两项都返回为假。订阅会立即收到一份副本,之后在有效变更时再次收到,并返回取消订阅函数。带入选择仅适用于当前选中的游戏。只有使用 postgres 或等效的 development 存储时,才提供此能力和上下文。

请先等待主存档成功保存,再报告其修订号。报告只包含固定键、等级 1 或 2、规则版本、一份已保存存档的修订号,以及必需的请求标识;不能包含身份、文本、图片、位置或其他属性。服务器会将自身的最小存档投影与获批规则核对。等级 2 包含等级 1。结果为 recorded 或 already-recorded,并附当前等级。确认结果表示装饰已可用;是否摆放由玩家决定。

// Call with the envelope returned by a successful primary storage.write.
// This rule counts completed cups, never play time or sound settings.
async function reportTea(confirmedSave) {
  const serverStored = ['postgres', 'development'].includes(sdk.context.storageMode)
  if (!sdk.supports('milestones.report') || !serverStored || !sdk.progression.current().enabled) return
  const cups = confirmedSave.data.cups
  const tier = cups >= 3 ? 2 : cups >= 1 ? 1 : 0
  if (!tier) return
  return sdk.milestones.report({
    key: 'tea', tier, ruleVersion: 'tea.1',
    evidence: {saveRevision: confirmedSave.revision},
    requestId: crypto.randomUUID(),
  })
}
const unsubscribe = sdk.progression.subscribe(({enabled}) => {
  if (!enabled) clearInMemoryCityCandidates()
})
sdk.onLifecycle(command => { if (command.type === 'dispose') unsubscribe() })
sdk.onDisconnect(unsubscribe)
// A result confirms the decoration was recorded; placement is the player's choice.
// Keep no plugin-owned outbox and never block game saves or leaving.

停止记录后,请清除内存中的候选项。插件不得保留自有队列、轮询服务器,也不得延迟保存或离开。重试次数由宿主限制。结果不确定时,保留完全相同的报告和请求标识。七天内,匹配的回执会返回原始结果,且不占用新报告额度;内容不同则会以 IDEMPOTENCY_CONFLICT 失败。遇到 STALE_EVIDENCE 后,请读取较新的存档并重新评估,再使用新的标识。权限缺失、记录关闭、规则未知或会话过期时,应停止当前报告。

这些装饰只用于选择私人外观。已确认的快照由客户端报告,明确带入的历史记录会标记为已带入。两者都不能证明真实的手动游玩或呼吸情况,也不能用于公开排名、交易或付款。插件不会得知所有者身份、城市布局、宿主代际或其他游戏的进度。停止或清除城市记录不会影响原有游戏存档。

加载与会话

sdk.loading.start(): Promise<{ok: true}>
sdk.loading.ready(): Promise<{ok: true}>
sdk.loading.fail(message: string): Promise<{ok: true}>     // up to 240 characters
sdk.session.report(status: {
  state: 'running' | 'paused' | 'ended'
  mode: 'watch' | 'manual' | 'listen'
  runId?: string
}): Promise<{ok: true}>

访客可以开始时报告 ready。会话状态变化时都要报告;运行期间至少每 12 秒报告一次。平台根据这些报告计时。在 ready 前报告 running 会以 NOT_READY 失败。

运行记录、片刻与徽章

sdk.runs.finish(run: {
  id: string, reason: 'finished' | 'restarted', control: 'auto' | 'manual' | 'mixed'
  metrics: Record<string, number>, endedAt: string
}): Promise<{recorded: true}>
sdk.moments.create(moment: {title: string, image: string, metadata?: Json})
  : Promise<{id: string, stored: true}>
sdk.achievements.request(id: string, evidence: Json)
  : Promise<{status: 'recorded', verified: boolean, awarded?: string[]}>

runs.finish 会先保存待处理进度,再记录一局,最多包含 20 项数值指标。片刻图片是 PNG 数据 URL,最长 350,000 个字符;平台可能会先征求访客同意再保存。徽章申请会附带你的证明。结果由服务器决定,设备模式下不会核验。

计数、事件、分享与离开

sdk.counters.add(key: string, delta: number, options?: {reason?: string, requestId?: string})
  : Promise<{key: string, value: number, applied: boolean}>
sdk.events.report(name: string, props?: Record<string, string | number | boolean | null>)
  : Promise<{recorded: boolean}>
sdk.shares.request(request: {kind: 'experience'} | {kind: 'badge', badgeCode: string})
  : Promise<{presented: boolean}>
sdk.navigation.exit(reason?: string): Promise<{ok: true}>

只有运营者定义了计数并将你列为写入方时,共享计数才会变化;请保留 requestId,确保重试只生效一次。事件名称须为小写,最多 64 个字符,最多带 20 个扁平属性。navigation.exit 会保存待处理进度并带访客前往你的详情页。

沉浸式显示

if (sdk.supports('display.immersive')) {
  const {immersive, applied} = await sdk.display.immersive(true)
}

从宿主 API 1.3 起提供。只有你已就绪且访客未关闭沉浸模式时,平台才会隐藏周围的框架。applied 会告知是否发生变化。退出沉浸模式始终可用。

页头操作

if (sdk.supports('header.setActions')) {
  sdk.header.onAction(action => {
    if (action.id === 'sound') toggleSound()
    if (action.id === 'water') waterGarden()
  })
  await sdk.header.setActions([
    {kind: 'standard', id: 'sound', intent: 'sound', pressed: soundOn},
    {kind: 'custom', id: 'water', title: 'Water the garden',
     icon: {paths: ['M12 3 C8 9 6 12 6 15 C6 18 9 21 12 21 C15 21 18 18 18 15 C18 12 16 9 12 3 Z']}},
  ])
}
sdk.header.clear()

从宿主 API 1.4 起提供。最多六项操作:平台分享操作、标准意图(sound、restart、settings、help、undo、redo),或自定义操作。自定义标题最多 40 个字符,图标为 24 × 24,最多由四条 SVG 路径绘制。对于没有页头的宿主,请保留框架内的备用操作。

生命周期与断连

sdk.onLifecycle(handler: (command: {
  type: 'pause' | 'resume' | 'visibility' | 'dispose'
  visible?: boolean
}) => void | Promise<void>): () => void
sdk.onDisconnect(handler: (error: SDKError) => void): () => void
sdk.disconnect(): void

onLifecycle 会收到 pause、resume、visibility 和 dispose。处理程序运行后,SDK 会在暂停、隐藏和销毁时刷新所有自动存档。onDisconnect 会告知通道已永久关闭。不要在 dispose 处理程序中调用 disconnect(),否则宿主会等待一秒直至超时。

限制

项目上限
单条消息512 KiB
存档数据128 KiB
里程碑报告1 千字节;每位所有者在每款游戏内每 10 秒最多提交 2 条新报告,每款游戏每日(协调世界时)最多 40 条,每位所有者每日最多 160 条。
JSON 结构普通对象和数组、有限数值;最多 22 层深;每个对象最多 1,000 个键,每个数组最多 20,000 项
正在处理的请求32
请求速率可突发发送 40 个,之后每秒 20 个
服务器速率片刻每分钟 15 次,徽章每分钟 30 次,计数和事件每分钟 120 次
超时时间请求 8 秒、握手 10 秒、就绪检查 15 秒、保活 12 秒、销毁 1 秒

错误代码

每次拒绝都会返回带稳定 code 的 SDKError。错误消息用于日志,不面向访客。

代码含义与处理方式
NOT_EMBEDDED · HOST_DENIED · HANDSHAKE_REJECTED · INCOMPATIBLE_API · HANDSHAKE_TIMEOUT页面在框架外打开、宿主未获允许,或宿主不支持你要求的宿主 API。请显示平和的提示。
FORBIDDEN · INVALID_INPUT · NO_METHOD · TOO_LARGE · NOT_READY此版本未获授该能力,或输入违反规则。请修正调用,不要重试。
CONFLICT · IDEMPOTENCY_CONFLICT · ID_REUSE你的存档已在其他位置更改,或不同数据重复使用了 requestId。请重新读取存档并让访客决定。切勿静默覆盖。
TIMEOUT · INTERNAL · SAVE_UNCONFIRMED结果未知,写入可能已经发生。请使用相同的 requestId 重试。
BUSY · RATE_LIMIT请求过多或发送过快。请稍等片刻再试。
GUEST_CAP · STORAGE_LIMIT访客已达到访客额度或存储上限。请将状态保存在内存中,并温和地告知对方。
DISCONNECTED · DISPOSED · UNAUTHORIZED会话已结束。请停止处理并停止存档。
UNAVAILABLE · STORAGE_UNAVAILABLE此宿主当前不提供该功能。请改用自己的实现。
PROGRESSION_DISABLED · SAVE_NOT_READY · STALE_EVIDENCE城市记录已关闭,请清除候选项。所引用的存档尚未到达,请等待保存确认。存在更新的快照,请读取并重新评估,再使用新的报告标识。
INVALID_RESPONSE宿主未确认里程碑结果。请保留游戏存档,不要声称装饰已记录。