const FrankieSDK = (() => { const _handlers = {} const _llmResolvers = {} const _storageResolvers = {} // Dev mock mode: active when the game runs outside a Frankie host frame (opened // standalone) or when explicitly requested with ?frankie_dev=1. In mock mode the SDK // emulates the host locally instead of posting to a parent frame. let _mock = window.parent === window || /[?&]frankie_dev=1\b/.test(window.location.search) let _gotInit = false let _declarationCache // Promise resolving to the manifest storage declaration (or null) const MOCK_INIT = { userId: 0, username: 'DevPlayer', gameId: 'dev', theme: 'dark' } // _handle dispatches an inbound event to the registered lifecycle hook. function _handle(type, payload) { const hook = _handlers[type] if (typeof hook === 'function') hook(payload) } // _send routes an outbound message: to the real parent frame, or to the local mock host. function _send(message) { if (_mock) { _mockHandle(message) return } window.parent.postMessage(message, '*') } // _settleStorage resolves or rejects the pending storage Promise for a request id. function _settleStorage(id, result) { const pending = _storageResolvers[id] if (!pending) return delete _storageResolvers[id] if (result && result.ok) pending.resolve(result.value) else pending.reject(new Error(result?.error || 'storage error')) } // Boot: listen for messages from the Frankie parent frame (ignored in mock mode). window.addEventListener('message', (event) => { const { type, payload, id } = event.data || {} if (typeof type !== 'string' || !type.startsWith('FRANKIE_')) return const hook = type.slice(8).toLowerCase() // FRANKIE_INIT → 'init' if (hook === 'init') _gotInit = true // LLM + storage responses resolve Promises rather than dispatching hooks. if (hook === 'llm_response') { const resolve = _llmResolvers[id] if (typeof resolve === 'function') { resolve(payload?.content ?? '') delete _llmResolvers[id] } return } if (hook === 'storage_result') { _settleStorage(id, payload) return } _handle(hook, payload) }) // In mock mode, deliver a synthetic init once the game has registered its handlers // (games call sdk.on('init', …) synchronously on load, so a 0ms timer finds them). if (_mock) { setTimeout(() => _handle('init', MOCK_INIT), 0) } else { // Iframed but possibly not under a real Frankie host: if no init arrives shortly, // fall back to mock mode so the game still boots during local testing. setTimeout(() => { if (!_gotInit) { _mock = true _handle('init', MOCK_INIT) } }, 1500) } const api = { // on registers a lifecycle hook. Returns the SDK for chaining. on(event, handler) { _handlers[event] = handler return this }, // emit sends a typed event to Frankie (or logs it in mock mode). emit(event, payload = {}) { _send({ type: `FRANKIE_${event.toUpperCase()}`, payload }) }, // llm sends a chat-completion request and resolves with the model's reply. llm(messages) { return new Promise((resolve) => { const id = `llm_${Date.now()}_${Math.random().toString(36).slice(2)}` _llmResolvers[id] = resolve _send({ type: 'FRANKIE_LLM_REQUEST', id, payload: { messages } }) }) }, // storage is the only sanctioned way to persist game data. Both methods return a // Promise; get resolves with the stored object, set with the persisted object. storage: { get(store = 'default') { return _requestStorage('FRANKIE_STORAGE_GET', { store }) }, set(store, value) { return _requestStorage('FRANKIE_STORAGE_SET', { store, value }) } } } // _requestStorage issues a storage request and returns a Promise settled by the host // (real or mock) via a FRANKIE_STORAGE_RESULT reply. function _requestStorage(type, payload) { return new Promise((resolve, reject) => { const id = `st_${Date.now()}_${Math.random().toString(36).slice(2)}` _storageResolvers[id] = { resolve, reject } _send({ type, id, payload }) }) } // ========================================================================== // Mock host (dev mode) — a self-contained emulation of Frankie's bridge so a // game can be exercised standalone. It mirrors the storage validation rules. // ========================================================================== function _mockHandle(message) { const { type, id, payload } = message switch (type) { case 'FRANKIE_READY': case 'FRANKIE_XP': case 'FRANKIE_WIN': case 'FRANKIE_SCORE': case 'FRANKIE_CLOSE': console.log(`[FrankieSDK dev] ${type}`, payload || {}) break case 'FRANKIE_LLM_REQUEST': { const resolve = _llmResolvers[id] if (typeof resolve === 'function') { resolve('[FrankieSDK dev] LLM is stubbed in standalone mode.') delete _llmResolvers[id] } break } case 'FRANKIE_STORAGE_GET': _mockStorage('read', payload).then((result) => _settleStorage(id, result)) break case 'FRANKIE_STORAGE_SET': _mockStorage('write', payload).then((result) => _settleStorage(id, result)) break default: break } } // _mockDeclaration fetches and caches the game's own manifest storage block. function _mockDeclaration() { if (!_declarationCache) { _declarationCache = fetch('./frankie-game.json') .then((res) => (res.ok ? res.json() : {})) .then((manifest) => manifest?.storage || null) .catch(() => null) } return _declarationCache } async function _mockStorage(op, payload) { const storeName = payload?.store || 'default' try { const decl = await _mockDeclaration() const store = decl?.stores?.[storeName] if (!store) throw new Error(`store "${storeName}" is not declared by this game`) _mockAssertAccess(store, op) const key = `frankie_dev_gamedata_${storeName}` if (op === 'read') { return { ok: true, value: JSON.parse(localStorage.getItem(key) || '{}') } } _mockValidate(store.fields, payload?.value) localStorage.setItem(key, JSON.stringify(payload.value)) return { ok: true, value: payload.value } } catch (err) { return { ok: false, error: err.message } } } function _mockAssertAccess(store, op) { const a = store.access if (op === 'read' && (a === 'read' || a === 'readwrite')) return if (op === 'write' && (a === 'write' || a === 'readwrite')) return throw new Error(op === 'read' ? 'this store is write-only' : 'this store is read-only') } function _mockValidate(fields, value) { if (!value || typeof value !== 'object' || Array.isArray(value)) { throw new Error('value must be an object') } for (const [key, val] of Object.entries(value)) { if (!(key in fields)) throw new Error(`unknown field "${key}"`) _mockCheck(key, fields[key], val) } } function _mockCheck(name, spec, val) { if (typeof spec === 'string') { const ok = (spec === 'string' && typeof val === 'string') || (spec === 'number' && typeof val === 'number') || (spec === 'boolean' && typeof val === 'boolean') || (spec === 'object' && val && typeof val === 'object' && !Array.isArray(val)) || (spec === 'string[]' && Array.isArray(val) && val.every((v) => typeof v === 'string')) || (spec === 'number[]' && Array.isArray(val) && val.every((v) => typeof v === 'number')) if (!ok) throw new Error(`field "${name}" must be a ${spec}`) } else { if (!val || typeof val !== 'object' || Array.isArray(val)) { throw new Error(`field "${name}" must be an object`) } _mockValidate(spec, val) } } return api })() export default FrankieSDK