Implementation
This document is currently available in Chinese only.
本文档面向平台内部与深度接入方,内容可能随实现演进。
本文按模块说明 SDK 1.2.0 的实现:常量、存储键、模块行为与远端协议。架构分层与设计目标见「架构设计」,接入方法见「接入指南」。
分层总览
| 模块 | 职责 |
|---|---|
| core | 无业务副作用的底座:常量、错误、id、元信息、水印、URL 语言 |
| storage | 存储驱动、信封编解码、键构造 |
| host | 宿主适配:android / web / browser 与探测 |
| modules | 本地模式模块实现(同时作为远端模式的离线底座) |
| remote | 远端模式模块实现(HTTP、身份、outbox、迁移等) |
| ui | 自绘 UI(邮箱绑定弹窗,Shadow DOM),本地与远端共用 |
| types | 对外类型契约(无运行时实现) |
| sdk | 模式解析 + 模块装配 + 工厂 |
| index | 包入口、公开导出、单例 |
建议阅读顺序:config → types → storage → modules → remote → 装配。
core
config:只声明常量。SDK_VERSION = "1.2.0";STORAGE_PREFIX = "instfun:v1";CURRENT_SCHEMA_VERSION = 1;默认激励视频奖励为 100 coins。
LIMITS 限额:
| 字段 | 值 | 语义 |
|---|---|---|
walletTransactions | 200 | 本地钱包流水保留条数 |
gameRecords | 50 | 每游戏本地记录保留条数 |
analyticsEvents | 500 | 本地埋点队列上限(保留最新) |
recentGames | 10 | 最近游戏列表长度 |
maxSessionMs | 21600000(6h) | 单次会话时长封顶 |
minAmount / maxRewardAmount | 1 / 10000 | 单笔金额合法区间(整数) |
saveThrottleMs | 2000 | 远端存档直推最小间隔 |
sessionEvents | 1000 | 单次进程埋点入队上限 |
progressMaxBytes | 262144(256KB) | 存档 JSON 序列化长度上限 |
eventPropertiesMaxBytes | 8192(8KB) | 单条事件 properties 序列化长度上限 |
errors:InstfunError extends Error,字段 code 与可选 details(不保证可序列化)。约定调用方用 error.code 分支,不解析 message;远端错误统一映射到同一枚举(见「错误码」)。
ids:randomHex(bytes) 优先 crypto.getRandomValues,否则回退 Math.random(不承担安全强度);createId(prefix) 生成 {prefix}_{时间戳}{随机} 形式的本地去重 id;createAccountCode() 生成 8 位 [0-9A-Za-z] 账号码,使用拒绝采样避免取模偏置。
meta:维护 PlatformMeta { games, recentGameIds };touchGame 为纯函数,recentGameIds 去重置顶并截断到 10,openCount + 1,firstSeenAt 一经写入不变。
watermark:读取 <meta name="instfun-wm">,trim 后长度 1~64 才有效,否则返回 null;结果进程内缓存(首次调用后不重读 DOM)。
url-locale:URL_LOCALE_CODES 为官网 8 语言表 ["en","es","de","fr","ru","ja","ko","zh"];readUrlLocale() 优先读 location.search 的 lang 参数,回退 location.hash 内的 lang,大小写归一比较,不在表内或缺失返回 undefined。用于弹窗语言协商与游戏自有 i18n 回退。
storage
driver:存储接缝,所有方法返回 Promise(为 IndexedDB 等异步后端预留):
interface StorageDriver {
readonly persistent: boolean;
get(key: string): Promise<string | null>;
set(key: string, value: string): Promise<void>;
remove(key: string): Promise<void>;
keys(): Promise<string[]>;
}MemoryStorageDriver 用 Map 实现,persistent = false;LocalStorageDriver 注入 Storage,persistent = true。探测时写入并立即删除探针键 instfun:v1:probe,任何异常(隐私模式、禁用存储、配额)都回落内存驱动。
docs:信封 StoredDoc<T> = { schemaVersion, updatedAt, value }。读取时键不存在、JSON 解析失败或 schemaVersion 不匹配一律返回 null,不抛错;写入总是以当前 schemaVersion 覆盖,updatedAt 仅作诊断。升级 CURRENT_SCHEMA_VERSION 等于让所有旧信封数据失效,必须配套迁移策略。
keys:全部键以 STORAGE_PREFIX 开头,除游戏 KV 前缀外都是「每次调用生成新字符串」的函数:
| 构造器 | 结果 |
|---|---|
identity() | instfun:v1:identity |
meta() | instfun:v1:meta |
walletBalance() / walletTx() | instfun:v1:wallet:balance / wallet:transactions |
analytics() | instfun:v1:analytics:queue |
progress(gameId) | instfun:v1:game:{gameId}:progress |
records(gameId) / stats(gameId) | instfun:v1:game:{gameId}:records / stats |
session(gameId) / boards(gameId) | instfun:v1:game:{gameId}:session / boards |
gameKvPrefix(gameId) / gameKv(gameId, key) | instfun:v1:game:{gameId}:kv: 前缀与完整键 |
远端专用键不在 keys 模块,而由 identity、outbox、migrate 各自以 instfun:v1:v2:* 拼出。
host
宿主接缝:
interface HostAdapter {
readonly kind: "android" | "web" | "browser";
supports(capability: "ads" | "login" | "payments" | "tracking"): Promise<boolean>;
showAd(request: {
adType: "rewarded" | "interstitial";
placement: string;
gameId: string;
}): Promise<{ completed: boolean; simulated: boolean; receiptId?: string }>;
}- 探测顺序:
window.InstfunAndroid?.postMessage→window.InstfunWebHost?.invoke→ 浏览器兜底。 - Android 宿主通过
window.InstfunAndroid.postMessage同步 JSON 桥调用,success === false时抛普通Error(不映射为InstfunError);原生广告恒为真实广告(simulated: false)。 - Web 宿主通过
window.InstfunWebHost.invoke异步 Promise 桥调用;桥未提供某能力时supports保守返回false。 - 浏览器宿主仅
supports("ads")为true;showAd等待约 600ms 后返回模拟结果:激励广告视为完成、插屏视为未完成,simulated: true。
supports("ads")的语义是「能力可用(可能是模拟)」,不代表真实广告,游戏不应据此跳过showRewarded。
modules(本地实现)
account:
- 身份记录存
instfun:v1:identity(含userId、nickname、avatarUrl、bio、createdAt、lastSeenAt、accountCode);进程内缓存命中直接返回,未命中时生成或读取记录、刷新lastSeenAt并落盘;缺失时生成guest_前缀 userId 与随机昵称;旧记录缺accountCode时补齐并落盘。 getCurrentUser()组装UserProfile:isGuest: true、email: null、accountBound: false,recentGameIds取自平台元信息。updateProfile(patch)只接受nickname/avatarUrl/bio。hasEmail()恒为false;openEmailBindDialog()在无 DOM 时直接返回unavailable(offline),有 DOM 时展示离线提示弹窗,关闭后返回同样的结果,不触发onCancel、不抛错。
wallet:
- 金额必须为
[1, 10000]内的整数,否则抛INVALID_AMOUNT; - 余额快照与流水分别存两个键;流水写入后只保留最新 200 条;
- 写操作经内部串行队列执行,保证并发调用不丢账;只读方法不参与串行化;
- 入账/扣款顺序:确保 userId → 幂等键命中则直接返回原流水 → 扣款前检查余额(不足抛
INSUFFICIENT_FUNDS)→ 按「账本 → 快照」顺序落盘;流水含balanceAfter、type、reason、gameId; transactions({ limit, currency })先过滤币种、再取尾部limit条、最后反转为倒序。
game:
- 职责是会话、记录、统计、存档与榜单提交的编排;
start(meta?):发现遗留会话时按quit补记一条并埋点game_session_expired;更新平台元信息;写入新会话并埋点game_start;finish(input):读会话(缺失则就地生成)、追加记录、读取统计、按需写存档(失败被吞掉,属于可选副作用)、按需提交本机榜(失败同样吞掉并使leaderboard = null)、埋点game_finish,返回{ record, stats, leaderboard };- 记录追加后只保留最新 50 条;统计更新
playCount、bestScore、totalDurationMs、lastPlayedAt; record(input)直接落一条记录,不依赖会话。
progress:
save(data, { merge }):merge === true且新旧均为普通对象时做浅合并(顶层键覆盖,嵌套整体替换);合并结果序列化超过 256KB 抛PAYLOAD_TOO_LARGE;revision在旧值基础上加 1,写入{ gameId, data, revision, gameVersion, sdkVersion, updatedAt };load()直接读信封,损坏或版本不匹配返回null;- 远端的乐观并发以
revision为基准(推送时baseRevision = revision - 1)。
leaderboard:
- 存储结构为
Record<boardId, BoardRecord>,记录含best、higherIsBetter、updatedAt、userId、nickname; - 分数必须为非负整数,否则抛
INVALID_SCORE; submitScore的boardId缺省"global"、higherIsBetter缺省true;方向变化时视为可比性失效并直接覆盖,否则只在更优时更新;get返回scope: "local"、localOnly: true、window: "all"、rank恒为 1 的单条或空榜。
analytics:
- 队列上限 500 条,单次进程上限 1000 条(均可注入覆盖);
track()永不 reject:身份与时间获取失败时用空值兜底;properties 未带wm时自动附加水印;校验事件名长度 1~64 与 properties 序列化长度不超过 8KB;超限丢弃;入队时保留最新 500 条;- 本地
flush()只返回{ sent: 0, pending: queue.length };clear()写入空数组。
ads:
showRewarded:调用宿主播放 → 埋点ad_show;未完成则埋点ad_dismiss并返回{ completed: false, rewardGranted: false };完成后用钱包入账(缺省 100 coins、reason为ad:{placement}、type: "AD_REWARD"、幂等键为ad:{placement}:{receipt}),埋点ad_reward并返回奖励与流水号;showInterstitial:placement缺省"GAME_OVER",返回shown = response.completed(浏览器宿主恒为false)。
game-storage:按游戏 KV 前缀实现 get/set/remove/keys,读写走信封;persistent 透传存储驱动。
remote(远端实现)
transport:TransportRequest { method: "GET" | "POST" | "PUT" | "PATCH"; path: string; body? }。默认 fetch 实现去掉 baseUrl 末尾斜杠;有 body 才设置 JSON content-type;每次请求实时读取 token 并附 Authorization: Bearer;响应解析失败按空载荷处理;!response.ok || payload.ok !== true 时抛携带 HTTP 状态与业务码的 HttpError,成功返回 payload.data。
http-client:错误归一入口。InstfunError 原样透传;HttpError 的业务码命中映射表则采用,SERVER_ERROR 时用状态码兜底(401 → UNAUTHORIZED、404 → NOT_FOUND、409 → CONFLICT、413 → PAYLOAD_TOO_LARGE、429 → RATE_LIMITED),未命中归 SERVER_ERROR;其它异常(断网、fetch 抛错)归 NETWORK。任意 2xx(含 202)都按成功解析信封。
identity:远端身份核心。deviceSeed、token、recoveryCode 为裸字符串,会话信息走信封。bootstrap() 优先命中内存资料,其次校验已存 token(401 时清除并重新游客登录),无 token 时用 deviceSeed 换取同一账号;hasEmail() 以最新缓存为准;requestEmailCode / verifyEmail 遇首次 401 会清 token、重新游客登录并重试原调用一次;verifyEmail 成功后强制刷新内存与落盘缓存。toProfile() 将服务端用户转换为 UserProfile(邮箱已脱敏,isGuest: true)。
account:纯代理远端身份,不做缓存或字段转换。
wallet:get → GET /wallet;getBalance 取对应币种字段;transactions → GET /wallet/transactions;reward / spend → POST /wallet/reward / /wallet/spend(body 合并 gameId)。不做客户端金额校验与缓存,幂等键随请求透传,服务端权威。
cache:远端存档缓存直接复用本地存档键,使远端与本地共享同一份缓存位置。
outbox:
- 键
instfun:v1:v2:outbox,整表读写;操作为{ id, kind: "progress" | "record" | "leaderboard", gameId, payload, createdAt },上限 200 条; progress为覆盖式入队(先删除同游戏旧操作),record/leaderboard追加;flushOutbox按入队顺序逐条重放:成功后移除并计数;遇到第一个可重试失败(NETWORK/SERVER_ERROR/RATE_LIMITED,未知异常按可重试处理)即停止并保留该操作及其后所有操作;不可重试错误移除操作并埋点outbox_op_dropped;存档二次冲突保留在队列中计入 pending 但不阻塞后续操作。
game:
pushProgressPayload以PUT /game/progress推送;响应冲突时拉取服务端存档、以服务端为底做顶层浅合并、以服务端 revision 重试一次,二次冲突返回失败并转入 outbox;pushProgress决策顺序:先本地落盘;距上次直推小于节流窗口则覆盖式入 outbox 并安排一次尾随 flush,返回pendingSync: true;否则直推,成功后清理同游戏过期操作、写缓存并返回pendingSync: false;失败仅在NETWORK/RATE_LIMITED时入 outbox,其它错误上抛;finish组合本地结算(榜单改由远端统一处理,避免双重提交)、记录上传、存档推送与榜单提交;榜单提交失败时可重试错误入 outbox,其它错误埋点leaderboard_submit_failed并置空结果;loadProgress成功即写缓存;仅NETWORK回退本地缓存并置pendingSync: true;getStats/getRecords同样仅在NETWORK时回退本地。
leaderboard:get 请求 GET /leaderboard/{boardId}?window=all&limit=...,仅 NETWORK 且有本地 fallback 时回退;submitScore 请求 POST /leaderboard/{boardId}/submit,分数校验在服务端。
analytics:track / getQueue / clear 转发本地;flush() 按 100 条分批上传,全部成功才清空并返回 { sent, pending: 0 },中途失败回写未发送尾部并返回 { sent, pending },不抛错。
ads:浏览器宿主直接复用本地模拟实现;其余宿主走「POST /ads/reward-token 领一次性 token → 宿主播放 → POST /ads/reward 核销发奖」,未看完只埋点 ad_dismiss。
migrate:远端首次启动时,若本地有存档且服务端没有,则上传一次(baseRevision: 0)并埋点,同时写入迁移标记避免重复;网络异常时标记不写,下次启动重试。
ui(邮箱绑定弹窗)
- 自绘弹窗运行在 Shadow DOM 中,宿主元素使用内联
all: initial,桌面与移动自适应,打开时锁定页面滚动; - 状态机:邮箱输入 → 发送验证码(成功后禁用邮箱输入、显示验证码行、按服务端
resendAfterSeconds启动倒计时,缺省 60s)→ 6 位验证码 → 校验;成功后返回bound并清理 DOM; - 服务端返回
alreadyBound: true时跳过输码,刷新资料后直接返回绑定成功; - 错误内联展示并映射错误码:邮箱格式、验证码错误、邮箱被占用、频率限制、服务不可用、网络异常、登录失效;验证失败不关闭弹窗,可修改后重试;
- 关闭路径(取消按钮、右上角关闭、ESC、点击遮罩)统一返回
cancelled并清理计时器与事件监听;已绑定用户先看到脱敏邮箱与「更换邮箱」入口; - 文案中英双语:显式
locale优先,其次 URLlang参数,最后按navigator.language前缀协商; - 离线弹窗(
showUnavailableEmailBindDialog)关闭后返回unavailable(offline)。
装配与包入口
assertGameId:gameId需匹配^[a-z0-9][a-z0-9_-]{0,63}$,否则抛INVALID_CONFIG;resolveRemoteBaseUrl:按上文运行模式的优先级解析;buildCommon:统一创建存储驱动、宿主、时间源、游戏版本与本地 account / analytics / storage / leaderboard,以及ensureUserId;createInstfunSdk:纯本地同步工厂,无网络请求;sync()只做本地埋点 flush;buildRemoteSdk:创建传输层与 HTTP 客户端 → 远端身份 → 远端各模块 → 包装本地 game 的同步模块 → 远端广告模块,并返回 bootstrap 流程;index:导出全部类型、InstfunError、InstfunErrorCode、SDK_VERSION、URL 语言工具、纯本地工厂与远端工厂;单例Instfun提供version/init()/getInstance(),重复init会覆盖实例。
types
纯类型文件,按分组定义公开契约:
| 分组 | 关键类型 |
|---|---|
| 宿主与基础 | HostKind、HostCapability、Currency |
| 配置 | InstfunConfig、InstfunMode |
| 账号 | UserProfile、AccountModule、EmailBindDialogOptions、EmailBindResult |
| 钱包 | WalletTransactionType、WalletTransaction、WalletRewardInput、WalletSpendInput、WalletBalance、WalletModule |
| 游戏 | GameProgress、GameSession、GameResult、GameRecord、GameStats、GameInfo、GameFinishInput、GameFinishResult、GameModule、SyncResult |
| 榜单 | LeaderboardEntry、LeaderboardSnapshot、LeaderboardModule |
| 埋点 | AnalyticsEvent、AnalyticsModule |
| 广告 | ShowRewardedOptions、RewardedAdResult、ShowInterstitialOptions、InterstitialAdResult、AdsModule |
| 存储 | GameStorageModule |
| 聚合 | InstfunSdk |
同时声明全局 window.INSTFUN_CONFIG?: { apiBaseUrl?: string }。
已知边界
| 项 | 现状 | 影响 / 后续方向 |
|---|---|---|
| outbox 与尾随补偿无串行化 | 显式 sync() 与尾随 flush 可能并发,finish 直推与 flush 可能交错 | 记录与榜单靠服务端幂等、存档靠冲突合并收敛;后续加串行化 |
| 埋点 flush 队列快照 | 失败时整体回写未发送尾部;flush 期间新事件可能被覆盖 | 低频场景影响小;可改为按事件 id 差集回写 |
| 体积口径 | 本地按 JSON 字符串长度(UTF-16 码元),非 UTF-8 字节 | 多字节字符实际字节更大;后续统一为字节口径 |
| 定时器生命周期 | 存档节流的尾随定时器没有释放接口 | 长驻页面无碍;页面卸载时需注意 |
| 周榜 | 快照类型含 weekly,但 SDK 固定请求 all,get() 未暴露窗口参数 | 服务端已支持;后续在榜单模块增加参数 |
| 水印缓存 | 进程级缓存、不失效 | 单页应用中水印不会变 |
| 裸键不受 schema 升级影响 | deviceSeed、token、recoveryCode、迁移标记为裸字符串 | 升级 CURRENT_SCHEMA_VERSION 不会清理它们,如需重置要显式删除 |
本地 sync() | 不产生网络请求,仅返回埋点积压数 | 与远端返回结构一致,游戏侧无需分支 |
| 浏览器宿主广告 | 激励模拟完成、插屏模拟未展示,supports("ads") 为 true | 用结果中的 simulated 区分,不要用 supports 判定真实广告 |
| 邮箱弹窗依赖 DOM | 无 document / window 时返回 unavailable(offline) | 仅在浏览器环境调用;SSR / Worker 中无 UI |