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と照合してハンドシェイクを完了します。クライアントを返すか、既定で10秒のtimeoutMs経過後にNOT_EMBEDDED、HOST_DENIED、HANDSHAKE_REJECTED、INCOMPATIBLE_API、HANDSHAKE_TIMEOUTのいずれかで拒否されます。

コンテキスト

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は保存先を示します。ZenClubのサーバーに保存する場合は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

SDK 0.8およびホストAPI 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です。保存できるプレーンJSONは最大128 KiBです。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、ルールのバージョン、保存済みのリビジョン1つ、必須のリクエスト識別子だけです。本人情報、文章、画像、配置、その他の項目は含められません。サーバーは承認済みのルールに照らして、必要最小限のセーブデータ項目を検査します。段階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.

記録が停止したら、メモリ上の候補を消去してください。プラグイン独自のキューを持たず、サーバーにポーリングせず、保存や終了を遅らせないでください。再試行は回数を制限してホストが行います。結果が不確かな場合は、同じ報告内容とリクエスト識別子を保持してください。7日以内に内容が一致する受付記録があれば、新しい報告枠を使わずに元の結果が返ります。内容が異なると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個の数値指標とともにプレイを記録します。ひとときの画像は最大350,000文字のPNGデータURLで、プラットフォームが保存前に訪問者へ確認する場合があります。バッジ申請には証拠を含めます。判定はサーバーが行い、deviceモードでは結果は検証されません。

カウンター、イベント、共有、退出

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以降で利用できます。アクションは最大6個です。プラットフォームの共有アクション、標準インテント(sound、restart、settings、help、undo、redo)、またはタイトルが40文字以内で最大4つのSVGパスからなる24 × 24アイコンを使う独自アクションを指定できます。ヘッダーのないホスト向けにフレーム内の代替操作も用意してください。

ライフサイクルと切断

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はチャネルが完全に閉じたことを通知します。破棄ハンドラー内でdisconnect()を呼び出さないでください。ホストが1秒のタイムアウトを待つことになります。

上限

項目上限
1メッセージ512 KiB
保存データ128 KiB
節目の報告1キビバイト、所有者・ゲームごとに10秒間で新規報告2件、協定世界時の1日あたり所有者・ゲームごとに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ホストから節目の結果を確認できませんでした。ゲームのセーブデータは保持し、飾りが記録されたと伝えないでください。