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 限额:

字段值语义
walletTransactions200本地钱包流水保留条数
gameRecords50每游戏本地记录保留条数
analyticsEvents500本地埋点队列上限(保留最新)
recentGames10最近游戏列表长度
maxSessionMs21600000(6h)单次会话时长封顶
minAmount / maxRewardAmount1 / 10000单笔金额合法区间(整数)
saveThrottleMs2000远端存档直推最小间隔
sessionEvents1000单次进程埋点入队上限
progressMaxBytes262144(256KB)存档 JSON 序列化长度上限
eventPropertiesMaxBytes8192(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 优先,其次 URL lang 参数,最后按 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