franke-arcade-sdk.js aktualisiert

This commit is contained in:
2026-07-03 01:40:02 +02:00
parent 213c078b1d
commit 14cc7bda49
+181 -16
View File
@@ -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