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 오류를 반환해요. 기본 10초인 timeoutMs가 지나면 시간 초과가 발생해요.

컨텍스트

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예요. 일반 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단계, 규칙 버전, 저장 차수 하나, 필수 요청 식별자만 담겨요. 신원, 글, 그림, 배치 위치 또는 다른 속성은 담을 수 없어요. 서버는 승인된 규칙에 따라 저장 데이터에서 필요한 최소 항목만 확인해요. 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이며, 보관 전에 플랫폼이 방문자에게 확인할 수 있어요. 배지 요청에는 근거가 포함돼요. 서버가 결과를 결정하며 기기 모드에서는 결과를 확인하지 않아요.

집계, 이벤트, 공유와 나가기

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자 제목과 최대 네 개의 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초 시간 초과까지 기다리게 돼요.

제한

항목제한값
메시지 하나512 KiB
저장 데이터128 KiB
이정표 보고1,024바이트; 소유자와 게임별로 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호스트가 이정표 결과를 확인하지 않았어요. 게임 저장 데이터는 유지하고, 장식이 기록됐다고 안내하지 마세요.