franke-arcade-sdk.js aktualisiert
This commit is contained in:
+180
-15
@@ -1,21 +1,51 @@
|
|||||||
const FrankieSDK = (() => {
|
const FrankieSDK = (() => {
|
||||||
const _handlers = {}
|
const _handlers = {}
|
||||||
const _llmResolvers = {}
|
const _llmResolvers = {}
|
||||||
|
const _storageResolvers = {}
|
||||||
|
|
||||||
// _handle dispatches an inbound postMessage event to the registered hook.
|
// 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) {
|
function _handle(type, payload) {
|
||||||
const hook = _handlers[type]
|
const hook = _handlers[type]
|
||||||
if (typeof hook === 'function') hook(payload)
|
if (typeof hook === 'function') hook(payload)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Boot: listen for messages from the Frankie parent frame.
|
// _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) => {
|
window.addEventListener('message', (event) => {
|
||||||
const { type, payload, id } = event.data || {}
|
const { type, payload, id } = event.data || {}
|
||||||
if (typeof type !== 'string' || !type.startsWith('FRANKIE_')) return
|
if (typeof type !== 'string' || !type.startsWith('FRANKIE_')) return
|
||||||
|
|
||||||
const hook = type.slice(8).toLowerCase() // FRANKIE_INIT → 'init'
|
const hook = type.slice(8).toLowerCase() // FRANKIE_INIT → 'init'
|
||||||
|
|
||||||
// LLM responses are resolved as promises rather than dispatched as hooks.
|
if (hook === 'init') _gotInit = true
|
||||||
|
|
||||||
|
// LLM + storage responses resolve Promises rather than dispatching hooks.
|
||||||
if (hook === 'llm_response') {
|
if (hook === 'llm_response') {
|
||||||
const resolve = _llmResolvers[id]
|
const resolve = _llmResolvers[id]
|
||||||
if (typeof resolve === 'function') {
|
if (typeof resolve === 'function') {
|
||||||
@@ -24,38 +54,173 @@ const FrankieSDK = (() => {
|
|||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if (hook === 'storage_result') {
|
||||||
|
_settleStorage(id, payload)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
_handle(hook, payload)
|
_handle(hook, payload)
|
||||||
})
|
})
|
||||||
|
|
||||||
return {
|
// 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 registers a lifecycle hook. Returns the SDK for chaining.
|
||||||
on(event, handler) {
|
on(event, handler) {
|
||||||
_handlers[event] = handler
|
_handlers[event] = handler
|
||||||
return this
|
return this
|
||||||
},
|
},
|
||||||
|
|
||||||
// emit sends a typed event to the Frankie parent frame.
|
// emit sends a typed event to Frankie (or logs it in mock mode).
|
||||||
emit(event, payload = {}) {
|
emit(event, payload = {}) {
|
||||||
window.parent.postMessage(
|
_send({ type: `FRANKIE_${event.toUpperCase()}`, payload })
|
||||||
{ type: `FRANKIE_${event.toUpperCase()}`, payload },
|
|
||||||
'*'
|
|
||||||
)
|
|
||||||
},
|
},
|
||||||
|
|
||||||
// llm sends a chat-completion request to Frankie's LLM and returns a Promise
|
// llm sends a chat-completion request and resolves with the model's reply.
|
||||||
// that resolves with the model's reply as a string.
|
|
||||||
llm(messages) {
|
llm(messages) {
|
||||||
return new Promise((resolve) => {
|
return new Promise((resolve) => {
|
||||||
const id = `llm_${Date.now()}_${Math.random().toString(36).slice(2)}`
|
const id = `llm_${Date.now()}_${Math.random().toString(36).slice(2)}`
|
||||||
_llmResolvers[id] = resolve
|
_llmResolvers[id] = resolve
|
||||||
window.parent.postMessage(
|
_send({ type: 'FRANKIE_LLM_REQUEST', id, payload: { messages } })
|
||||||
{ 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
|
export default FrankieSDK
|
||||||
Reference in New Issue
Block a user