Referencia del SDK

Todo lo que sigue procede de la entrada principal del paquete. Las llamadas a métodos del host devuelven promesas. Cada llamada al host comprueba primero los permisos y los datos de entrada dentro de tu marco, y falla con FORBIDDEN o INVALID_INPUT antes de enviar nada. Los getters y las suscripciones locales son síncronos.

Conexión

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>

Lee el origen del host y el identificador de instancia del fragmento de la URL, comprueba el origen con allowedHostOrigins y completa el saludo. Devuelve un cliente o se rechaza con NOT_EMBEDDED, HOST_DENIED, HANDSHAKE_REJECTED, INCOMPATIBLE_API o HANDSHAKE_TIMEOUT (tras timeoutMs, 10 segundos de forma predeterminada).

Contexto

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 describe esta sesión. storageMode indica dónde se guardan los datos: postgres si se conservan en los servidores de ZenClub y device si permanecen cifrados en este navegador; los hosts de prueba usan memory o development. En hosts antiguos pueden faltar campos opcionales, así que léelos con un valor alternativo.

Tema

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 te avisa de inmediato y de nuevo con cada cambio. applyTheme escribe los 24 tokens como variables CSS --zc-* y establece data-mode, data-motion y color-scheme en el elemento raíz. Respeta reducedMotion.

Preferencia de sonido

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

Desde SDK 0.8 y API del host 1.5. Solo los complementos que requieren 1.5 o posterior reciben los eventos opcionales context.sound y sound.changed. Ambos solo aceptan {enabled: boolean}. El campo del contexto no aparece si no se eligió ninguna opción. current() devuelve undefined cuando el host no ha proporcionado una opción: conserva tu valor predeterminado. subscribe recibe un booleano de inmediato cuando se conoce la opción y después cada vez que cambia; devuelve una función para cancelar la suscripción.

Es un valor predeterminado, no un silencio forzado. Si está activado, empieza con el control de sonido del juego encendido y reproduce audio solo después de una interacción dentro del juego. Si está desactivado, mantén el silencio hasta que la persona active el sonido en el juego. Conserva el control de sonido propio del juego y actualízalo cuando cambie la preferencia de la plataforma. Al pausar u ocultar el juego, el audio siempre debe quedar en silencio, sea cual sea la preferencia.

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() })

Almacenamiento

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}

Las ranuras se nombran como identificadores y primary es la predeterminada. Un guardado admite hasta 128 KiB de JSON sin formato. La escritura solo funciona si coincide expectedRevision; de lo contrario, falla con CONFLICT y no sobrescribe nada. Usa 0 para una ranura que aún no existe.

Hitos privados de la ciudad

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}>

Desde SDK 0.9 y API del host 1.6. Un complemento aprobado recibe de forma opcional context.progression y progression.changed con exactamente {enabled: boolean, importExisting: boolean}. Sin compatibilidad, el getter local devuelve ambos valores como false. Las suscripciones reciben una copia de inmediato y, después, con cada cambio válido; devuelven una función para cancelar la suscripción. La opción de importar corresponde solo al juego seleccionado. La capacidad y el contexto solo están disponibles con almacenamiento postgres o development equivalente.

Espera a que se confirme un guardado principal antes de informar de su revisión. Un informe contiene solo la clave fija, el nivel 1 o 2, la versión de la regla, una revisión guardada y un identificador de solicitud obligatorio; no puede incluir identidad, texto, ilustraciones, posiciones ni otras propiedades. El servidor comprueba su propia proyección mínima del guardado según la regla aprobada. El nivel 2 incluye el nivel 1. Los resultados son recorded o already-recorded, con el nivel actual. Un resultado confirmado significa que la decoración está disponible; el jugador decide si la coloca.

// 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.

Cuando se detenga el registro, borra los candidatos en memoria. No mantengas una cola propia del complemento, no consultes periódicamente ningún servidor y nunca retrases los guardados ni la salida. El host se encarga de los reintentos limitados. Si el resultado no está claro, conserva el informe exacto y el identificador de solicitud. Durante siete días, un comprobante coincidente devuelve el resultado original sin consumir el cupo de informes nuevos; si el contenido es distinto, se produce IDEMPOTENCY_CONFLICT. Después de STALE_EVIDENCE, lee los guardados más recientes y vuelve a evaluarlos con un identificador nuevo. Si falta el permiso, el registro está desactivado, la regla no se reconoce o la sesión ha caducado, detén el informe actual.

Estas decoraciones son decisiones privadas sobre el aspecto. Las instantáneas confirmadas se comunican desde el cliente, y el historial importado se marca explícitamente como importado. Ninguna de las dos cosas demuestra que se haya jugado manualmente o respirado, y ninguna puede usarse para clasificaciones públicas, intercambios o pagos. Los complementos nunca conocen la identidad de quien posee la ciudad, su distribución, la generación del host ni el progreso de otros juegos. Detener o borrar los registros de la ciudad deja intacta la partida original.

Carga y sesiones

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}>

Informa de ready cuando quien visita pueda empezar. Comunica el estado de la sesión cada vez que cambie y, mientras esté en marcha, al menos cada 12 segundos; así mide el tiempo la plataforma. Informar de running antes de ready falla con NOT_READY.

Partidas, momentos e insignias

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 guarda primero el progreso pendiente y luego registra una partida con hasta 20 métricas numéricas. La imagen de un momento es una URL de datos PNG de hasta 350000 caracteres; la plataforma puede pedir permiso antes de guardarla. La solicitud de insignia incluye tus pruebas. El servidor decide y, en modo de dispositivo, el resultado nunca se verifica.

Recuentos, eventos, compartir y salir

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}>

Un recuento solo cambia si la persona operadora lo ha definido y te ha incluido como colaborador; conserva un requestId para aplicar cada reintento una sola vez. Los nombres de evento van en minúsculas, admiten hasta 64 caracteres y hasta 20 propiedades simples. navigation.exit guarda el progreso pendiente y lleva a quien visita a la página de detalles.

Vista inmersiva

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

Disponible desde la API del host 1.3. La plataforma oculta su marco solo cuando estás listo y quien visita no ha desactivado el modo inmersivo. applied indica si hubo algún cambio. Siempre se puede salir del modo inmersivo.

Acciones en la cabecera

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()

Disponible desde la API del host 1.4. Hasta seis acciones: compartir de la plataforma, acciones estándar (sound, restart, settings, help, undo, redo) o acciones propias con un título de hasta 40 caracteres y un icono de 24 × 24 dibujado con un máximo de cuatro trazados SVG. Incluye una alternativa dentro del marco para hosts sin cabecera.

Ciclo de vida y desconexiones

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 recibe pause, resume, visibility y dispose. Tras tu controlador, el SDK vacía todos los guardados automáticos al pausar, ocultar y cerrar. onDisconnect avisa de que el canal se ha cerrado definitivamente. No llames a disconnect() dentro de un controlador de cierre: el host esperaría entonces hasta agotar su límite de un segundo.

Límites

ElementoLímite
Un mensaje512 KiB
Datos guardados128 KiB
Informes de hitos1 KiB; 2 informes nuevos por propietario y juego cada 10 segundos, 40 por propietario y juego al día según UTC y 160 por propietario al día según UTC
Estructura JSONObjetos y matrices simples, números finitos, hasta 22 niveles de profundidad, 1000 claves por objeto y 20000 elementos por matriz
Solicitudes en curso32
Frecuencia de solicitudesRáfagas de 40 y después 20 por segundo
Frecuencias del servidorMomentos: 15 por minuto; insignias: 30 por minuto; recuentos y eventos: 120 por minuto
Tiempos límiteSolicitudes: 8 s; saludo: 10 s; comprobación de disponibilidad: 15 s; actividad: 12 s; cierre: 1 s

Códigos de error

Cada rechazo es un SDKError con un code estable. Los mensajes son para los registros, no para quien visita.

CódigoSignificado y acción
NOT_EMBEDDED · HOST_DENIED · HANDSHAKE_REJECTED · INCOMPATIBLE_API · HANDSHAKE_TIMEOUTSe abrió fuera de un marco, desde un host no autorizado o desde uno que no ofrece la API requerida. Muestra un aviso tranquilo.
FORBIDDEN · INVALID_INPUT · NO_METHOD · TOO_LARGE · NOT_READYLa capacidad no está concedida para esta versión o los datos de entrada incumplen una regla. Corrige la llamada; no la repitas.
CONFLICT · IDEMPOTENCY_CONFLICT · ID_REUSEEl guardado cambió en otro lugar o se reutilizó un requestId con datos distintos. Vuelve a leerlo y deja que quien visita decida. Nunca lo sobrescribas en silencio.
TIMEOUT · INTERNAL · SAVE_UNCONFIRMEDSe desconoce el resultado. Puede que la escritura ya se haya realizado. Reintenta con el mismo requestId.
BUSY · RATE_LIMITHay demasiadas solicitudes a la vez o demasiado rápido. Espera y vuelve a intentarlo.
GUEST_CAP · STORAGE_LIMITSe alcanzó el límite de uso para invitados o de almacenamiento. Conserva el estado en memoria e informa con tacto.
DISCONNECTED · DISPOSED · UNAUTHORIZEDLa sesión ha terminado. Detén el trabajo y deja de guardar.
UNAVAILABLE · STORAGE_UNAVAILABLEEste host no ofrece esa función ahora. Resuélvelo por tu cuenta como alternativa.
PROGRESSION_DISABLED · SAVE_NOT_READY · STALE_EVIDENCEEl registro está desactivado: borra los candidatos. La partida guardada indicada aún no ha llegado: espera a que se confirme. Hay una instantánea más reciente: léela, vuelve a evaluarla y usa un identificador de informe nuevo.
INVALID_RESPONSEEl host no confirmó el resultado del hito. Conserva el guardado del juego y no afirmes que la decoración se registró.