@virjay/playscope-cocos 0.0.0-stage → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (92) hide show
  1. package/CHANGELOG.md +30 -0
  2. package/README.md +314 -2
  3. package/USAGE.md +372 -0
  4. package/VALIDATION.md +93 -0
  5. package/dist/adapters/levelplay/index.d.ts +15 -0
  6. package/dist/adapters/levelplay/index.js +14 -0
  7. package/dist/cocos/Storage.d.ts +16 -0
  8. package/dist/cocos/Storage.js +16 -0
  9. package/dist/cocos/XhrJsonTransport.d.ts +8 -0
  10. package/dist/cocos/XhrJsonTransport.js +65 -0
  11. package/dist/cocos/index.d.ts +48 -0
  12. package/dist/cocos/index.js +134 -0
  13. package/dist/core/ClientOwnership.d.ts +6 -0
  14. package/dist/core/ClientOwnership.js +39 -0
  15. package/dist/core/ClientScope.d.ts +5 -0
  16. package/dist/core/ClientScope.js +34 -0
  17. package/dist/core/ConfigurationSync.d.ts +18 -0
  18. package/dist/core/ConfigurationSync.js +66 -0
  19. package/dist/core/FactEncoding.d.ts +10 -0
  20. package/dist/core/FactEncoding.js +29 -0
  21. package/dist/core/HttpError.d.ts +10 -0
  22. package/dist/core/HttpError.js +17 -0
  23. package/dist/core/OutboxStore.d.ts +31 -0
  24. package/dist/core/OutboxStore.js +228 -0
  25. package/dist/core/PersistentState.d.ts +33 -0
  26. package/dist/core/PersistentState.js +92 -0
  27. package/dist/core/PlayScopeClient.d.ts +65 -0
  28. package/dist/core/PlayScopeClient.js +422 -0
  29. package/dist/core/QueueAdmission.d.ts +4 -0
  30. package/dist/core/QueueAdmission.js +29 -0
  31. package/dist/core/index.d.ts +7 -0
  32. package/dist/core/index.js +27 -0
  33. package/dist/core/revenue.d.ts +33 -0
  34. package/dist/core/revenue.js +23 -0
  35. package/dist/core/types.d.ts +155 -0
  36. package/dist/core/types.js +2 -0
  37. package/dist/core/uuid.d.ts +1 -0
  38. package/dist/core/uuid.js +9 -0
  39. package/dist/esm/adapters/levelplay/index.js +11 -0
  40. package/dist/esm/cocos/Storage.js +12 -0
  41. package/dist/esm/cocos/XhrJsonTransport.js +61 -0
  42. package/dist/esm/cocos/index.js +115 -0
  43. package/dist/esm/core/ClientOwnership.js +34 -0
  44. package/dist/esm/core/ClientScope.js +30 -0
  45. package/dist/esm/core/ConfigurationSync.js +62 -0
  46. package/dist/esm/core/FactEncoding.js +24 -0
  47. package/dist/esm/core/HttpError.js +12 -0
  48. package/dist/esm/core/OutboxStore.js +224 -0
  49. package/dist/esm/core/PersistentState.js +85 -0
  50. package/dist/esm/core/PlayScopeClient.js +415 -0
  51. package/dist/esm/core/QueueAdmission.js +25 -0
  52. package/dist/esm/core/index.js +7 -0
  53. package/dist/esm/core/revenue.js +20 -0
  54. package/dist/esm/core/types.js +1 -0
  55. package/dist/esm/core/uuid.js +6 -0
  56. package/dist/esm/package.json +1 -0
  57. package/dist/esm/protocols/game-v1/index.js +21 -0
  58. package/dist/protocols/game-v1/index.d.ts +8 -0
  59. package/dist/protocols/game-v1/index.js +25 -0
  60. package/doc/README.md +4 -0
  61. package/doc//344/275/277/347/224/250/346/214/207/345/215/227/README.md +585 -0
  62. package/doc//344/275/277/347/224/250/346/214/207/345/215/227//345/217/202/350/200/203/350/265/204/346/226/231/0.1.1-types/adapters/levelplay/index.d.ts +15 -0
  63. package/doc//344/275/277/347/224/250/346/214/207/345/215/227//345/217/202/350/200/203/350/265/204/346/226/231/0.1.1-types/cocos/Storage.d.ts +13 -0
  64. package/doc//344/275/277/347/224/250/346/214/207/345/215/227//345/217/202/350/200/203/350/265/204/346/226/231/0.1.1-types/cocos/XhrJsonTransport.d.ts +8 -0
  65. package/doc//344/275/277/347/224/250/346/214/207/345/215/227//345/217/202/350/200/203/350/265/204/346/226/231/0.1.1-types/cocos/index.d.ts +38 -0
  66. package/doc//344/275/277/347/224/250/346/214/207/345/215/227//345/217/202/350/200/203/350/265/204/346/226/231/0.1.1-types/core/PlayScopeClient.d.ts +46 -0
  67. package/doc//344/275/277/347/224/250/346/214/207/345/215/227//345/217/202/350/200/203/350/265/204/346/226/231/0.1.1-types/core/index.d.ts +3 -0
  68. package/doc//344/275/277/347/224/250/346/214/207/345/215/227//345/217/202/350/200/203/350/265/204/346/226/231/0.1.1-types/core/revenue.d.ts +25 -0
  69. package/doc//344/275/277/347/224/250/346/214/207/345/215/227//345/217/202/350/200/203/350/265/204/346/226/231/0.1.1-types/core/types.d.ts +81 -0
  70. package/doc//344/275/277/347/224/250/346/214/207/345/215/227//345/217/202/350/200/203/350/265/204/346/226/231/exports.json +89 -0
  71. package/doc//344/275/277/347/224/250/346/214/207/345/215/227//345/217/202/350/200/203/350/265/204/346/226/231/public-api.d.ts +281 -0
  72. package/doc//344/275/277/347/224/250/346/214/207/345/215/227//345/217/202/350/200/203/350/265/204/346/226/231//346/234/215/345/212/241/347/253/257/client-context.schema.json +103 -0
  73. package/doc//344/275/277/347/224/250/346/214/207/345/215/227//345/217/202/350/200/203/350/265/204/346/226/231//346/234/215/345/212/241/347/253/257/game-ad-properties.json +765 -0
  74. package/doc//344/275/277/347/224/250/346/214/207/345/215/227//345/217/202/350/200/203/350/265/204/346/226/231//346/234/215/345/212/241/347/253/257/game-builtin-properties.json +479 -0
  75. package/doc//344/275/277/347/224/250/346/214/207/345/215/227//345/217/202/350/200/203/350/265/204/346/226/231//346/234/215/345/212/241/347/253/257/game-event.json +278 -0
  76. package/doc//344/275/277/347/224/250/346/214/207/345/215/227//345/217/202/350/200/203/350/265/204/346/226/231//346/234/215/345/212/241/347/253/257/product-events.json +2878 -0
  77. package/doc//344/275/277/347/224/250/346/214/207/345/215/227//345/217/202/350/200/203/350/265/204/346/226/231//346/234/215/345/212/241/347/253/257/standard-contract.json +183 -0
  78. package/doc//344/275/277/347/224/250/346/214/207/345/215/227//345/217/202/350/200/203/350/265/204/346/226/231//346/234/215/345/212/241/347/253/257//344/272/247/345/223/201/344/272/213/344/273/266/347/264/242/345/274/225.md +87 -0
  79. package/doc//344/275/277/347/224/250/346/214/207/345/215/227//345/217/202/350/200/203/350/265/204/346/226/231//346/235/245/346/272/220/344/270/216/347/211/210/346/234/254.json +35 -0
  80. package/doc//344/275/277/347/224/250/346/214/207/345/215/227//347/244/272/344/276/213/CoreIntegration.ts +13 -0
  81. package/doc//344/275/277/347/224/250/346/214/207/345/215/227//347/244/272/344/276/213/LevelPlayTelemetry.ts +119 -0
  82. package/doc//344/275/277/347/224/250/346/214/207/345/215/227//347/244/272/344/276/213/PlayScopeExample.ts +46 -0
  83. package/doc//344/275/277/347/224/250/346/214/207/345/215/227//347/244/272/344/276/213/QuestGarden.ts +129 -0
  84. package/doc//344/275/277/347/224/250/346/214/207/345/215/227//347/244/272/344/276/213/README.md +23 -0
  85. package/doc//344/275/277/347/224/250/346/214/207/345/215/227//347/244/272/344/276/213/project-fixture.json +188 -0
  86. package/doc//344/275/277/347/224/250/346/214/207/345/215/227//347/244/272/344/276/213/tsconfig.json +20 -0
  87. package/examples/cocos-minimal/PlayScopeExample.ts +27 -0
  88. package/examples/quest-garden/QuestGarden.ts +129 -0
  89. package/examples/quest-garden/README.md +180 -0
  90. package/examples/quest-garden/project-fixture.json +188 -0
  91. package/examples/quest-garden/tsconfig.json +12 -0
  92. package/package.json +73 -4
package/USAGE.md ADDED
@@ -0,0 +1,372 @@
1
+ # PlayScope Cocos SDK 使用指南
2
+
3
+ 适用包:`@virjay/playscope-cocos`。更新于 2026-11-12。
4
+
5
+ 完整交接资料见 [doc/使用指南](doc/使用指南/README.md),包含全部公开接口、服务端字典、广告映射及验收。
6
+ 本文件保留为快速使用指南;交给 AI 完整接入时请提供整个目录及实际版本的 SDK 包。
7
+ 本文面向游戏接入开发者;SDK 维护者的打包与验收记录见 [README](README.md) 和 [VALIDATION](VALIDATION.md)。
8
+
9
+ ## 1. 先确认使用的版本
10
+
11
+ 本文适用于 `@virjay/playscope-cocos@0.2.0`。BlockDrop 仍使用旧 `@playscope/cocos` 的 `playscope-cocos-0.1.1.tgz`,本次发布不切换游戏依赖。
12
+ 标为“源码增强”的功能已包含在本次 0.2.0 包中;固定旧包不支持。旧包升级还需要替换导入路径并核实历史 scope。
13
+
14
+ | 能力 | 固定 0.1.1 包 | 0.2.0 包 |
15
+ | --- | --- | --- |
16
+ | Cocos 初始化、事件、身份、持久队列、ACK、重试 | 支持 | 支持 |
17
+ | ESM/CommonJS、LevelPlay 收入适配、宿主配置回调 | 支持 | 支持 |
18
+ | trackWithResult/flushWithResult/status 等结构化接口 | 不支持 | 支持 |
19
+ | 独立配置调度、队列保留策略、字节预算、自定义收入编码 | 不支持 | 支持 |
20
+ | 持久 scope 校验、legacyScope、重复 writer 检测 | 不支持 | 支持 |
21
+ | 分段存储、隔离记录及新增诊断字段 | 不支持 | 支持 |
22
+
23
+ 不要因为源码检查通过就对旧包调用新接口,也不要覆盖已经分发的 0.1.1 tarball。
24
+
25
+ ## 2. 准备后台项目与安装包
26
+
27
+ 接入前准备服务根地址、项目 ID、项目客户端 Key 和目标环境。baseUrl 使用服务根地址,
28
+ 例如 `https://analytics.example.com`,不自行追加 `/v1/projects/...`。
29
+ 游戏只使用项目客户端 Key,管理 Token 留在管理端。
30
+ 测试和正式分别创建独立项目;单独设置 environment 不会在服务端自动创建隔离项目。
31
+ Web 项目还需登记实际 Origin。
32
+
33
+ 先确定项目事件字典和配置 schema。SDK 只负责传输,不会自动注册游戏自定义事件,也不会
34
+ 自动把任意 run 或收入字段变成服务端认可的协议。使用 game 项目时,对局、模式、配置归因
35
+ 及结算字段必须符合该项目契约;自定义属性也必须匹配字典。
36
+ 独立示例的完整准备材料见 [Quest Garden fixture](examples/quest-garden/project-fixture.json)。
37
+
38
+ 在游戏目录安装并固定正式版本:
39
+
40
+ ```sh
41
+ npm install @virjay/playscope-cocos@0.2.0 --save-exact
42
+ ```
43
+
44
+ 提交 package.json 和 package-lock.json;使用本地 tarball 的项目也需提交对应包文件。以后升级时固定实际新版本,
45
+ 不使用相邻源码仓库的绝对路径或软链接作为分发依赖。包提供编译后的 JS 和类型声明。
46
+
47
+ ## 3. Cocos 最小接入
48
+
49
+ 创建一个组件挂到常驻节点。节点是否跨场景保留由游戏负责;同一存储键只保留一个活跃实例。
50
+ 以下示例默认关闭采集,需把游戏的真实授权结果接到 setCollectionAllowed。
51
+ 示例中的地址和凭据均为占位值。
52
+
53
+ ```ts
54
+ import { _decorator, Component, game, Game, sys } from 'cc';
55
+ import { init, type CocosPlayScope } from '@virjay/playscope-cocos';
56
+
57
+ const { ccclass } = _decorator;
58
+
59
+ @ccclass('GameAnalytics')
60
+ export class GameAnalytics extends Component {
61
+ private telemetry: CocosPlayScope | null = null;
62
+
63
+ onLoad(): void {
64
+ this.setCollectionAllowed(false); // 替换为宿主已取得的真实授权结果。
65
+ }
66
+
67
+ setCollectionAllowed(allowed: boolean): void {
68
+ const previous = this.telemetry;
69
+ this.telemetry = null;
70
+ previous?.dispose();
71
+ this.telemetry = init({
72
+ baseUrl: 'https://analytics.example.com',
73
+ projectId: 'YOUR_TEST_PROJECT_UUID',
74
+ projectKey: 'YOUR_PROJECT_CLIENT_KEY',
75
+ collectionEnabled: allowed,
76
+ client: {
77
+ appVersion: '1.0.0', buildNumber: '1',
78
+ platform: sys.isNative
79
+ ? (sys.platform === sys.Platform.ANDROID ? 'android' : 'ios')
80
+ : 'web',
81
+ locale: sys.language,
82
+ timezoneOffsetMinutes: -new Date().getTimezoneOffset(),
83
+ environment: 'test',
84
+ },
85
+ }, {
86
+ storage: sys.localStorage,
87
+ lifecycle: {
88
+ onHide: callback => {
89
+ game.on(Game.EVENT_HIDE, callback);
90
+ return () => game.off(Game.EVENT_HIDE, callback);
91
+ },
92
+ onShow: callback => {
93
+ game.on(Game.EVENT_SHOW, callback);
94
+ return () => game.off(Game.EVENT_SHOW, callback);
95
+ },
96
+ },
97
+ });
98
+ }
99
+
100
+ recordLaunch(): void {
101
+ // 由真实启动入口调用;不要在每次授权切换或每帧调用。
102
+ this.telemetry?.track('app_launch', { reason: 'launch' });
103
+ }
104
+
105
+ update(dt: number): void { this.telemetry?.update(dt); }
106
+
107
+ onDestroy(): void {
108
+ const previous = this.telemetry;
109
+ this.telemetry = null;
110
+ previous?.dispose();
111
+ }
112
+ }
113
+ ```
114
+
115
+ init 会恢复身份、登记一次 user_first_open 并尝试上传;业务事件由游戏在真实触发点调用。
116
+ 不要再手工补发首次打开。update 以秒计,每累计约 5 秒尝试 flush;前后台回调也只触发 flush,
117
+ 不自动开始新会话或结束游戏对局。原生广告弹层的会话判断继续由游戏负责。
118
+
119
+ collectionEnabled=false 时不创建身份、不访问存储、不发请求,也不注册生命周期监听。
120
+ 授权变化先 dispose 旧实例,再用新许可重建。已有持久身份和队列保留,关闭期间不会补采集事件;
121
+ 已到达服务器的请求不能撤回。dispose 不会清空队列,也不保证退出前已经上传完成。
122
+ 源码增强会尝试全部清理,再抛出第一个宿主清理错误;宿主应记录错误类别并处理初始化失败。
123
+
124
+ 若使用 new CocosPlayScope 而非 init,需要自己调用 start。不要对同一存储同时创建两者。
125
+
126
+ ## 4. 上报事件与对局上下文
127
+
128
+ 下列片段中的 telemetry 指前面的 CocosPlayScope 实例。只有启用采集后才会接收事件。
129
+
130
+ ```ts
131
+ import { telemetryUuid } from '@virjay/playscope-cocos';
132
+
133
+ // 事件名及属性需要先在目标项目字典中注册。
134
+ const accepted = telemetry.track('quest_harvested', {
135
+ quest_id: 'orchard-1', index: 1,
136
+ }, {
137
+ occurredAt: new Date().toISOString(),
138
+ deduplicationKey: 'harvest:orchard-1:operation-123',
139
+ });
140
+ ```
141
+
142
+ 用真实操作 ID 去重,不能为每次重复回调生成一个全新的键。去重键保留最近 2000 个,
143
+ 超出窗口后仍需宿主保证业务幂等。布尔 accepted=true 只表示接收进队列,不代表服务端入库。
144
+ 属性使用可序列化的 JSON 数据,不传循环引用、引擎对象或函数。
145
+
146
+ 对局事件通过第三个参数的 run 传入项目定义的完整上下文。runId 可以由 telemetryUuid 生成,
147
+ 同一局持续使用同一份归因快照;任务重启后不要凭 SDK 待传队列补造旧局结束。
148
+ 服务端 game 项目要求的配置归因与结算示例见 [QuestGarden.ts](examples/quest-garden/QuestGarden.ts)。
149
+ TrackContext.transform 只在新事件入队时执行,历史队列不会按新转换器重新编码。
150
+
151
+ newSession 只更新后续事件的 sessionId,不创建 session_start 等业务事件。
152
+ 宿主根据自身会话规则调用,不把普通前后台切换直接解释为新会话。
153
+
154
+ ### 源码增强:识别接收与落盘结果
155
+
156
+ ```ts
157
+ const result = telemetry.trackWithResult('quest_harvested', {
158
+ quest_id: 'orchard-1', index: 2,
159
+ });
160
+ if (result.status === 'accepted') {
161
+ console.info('telemetry_enqueued', result.eventId, result.durability);
162
+ }
163
+ ```
164
+
165
+ | status | 含义与处理 |
166
+ | --- | --- |
167
+ | accepted,durability=persisted | 已通过本地写入与读回检查,等待上传确认 |
168
+ | accepted,durability=memory | 仅在内存,存储恢复前暂停上传;杀进程可能丢失,修复存储后让 SDK 重试,避免重造同一事实 |
169
+ | duplicate | 去重键已存在,无需重新入队 |
170
+ | queue_full/queue_bytes_full | 容量不足,拒绝新事件,不驱逐旧事实;排查积压、故障或合理调整预算 |
171
+ | event_too_large | 最终事件加单批信封超过 250 KiB;缩小业务 payload,不把大存档塞进事件 |
172
+ | invalid_event | 输入不能编码为合法事实,修复调用数据 |
173
+ | collection_disabled/disposed | 当前实例不接收事件,检查授权或生命周期 |
174
+
175
+ ## 5. 上传、重试与诊断
176
+
177
+ 基础接口 `await telemetry.flush()` 尝试一轮交付。系统杀进程前的后台窗口不足以保证网络完成,
178
+ 因此依赖持久队列恢复。离线、丢 ACK 或部分确认时,SDK 重放原 eventId 和事实;
179
+ 服务端按 ID 幂等。明确永久拒收的事件不会无限重传,需修复字典或调用数据。
180
+
181
+ ### 源码增强:结构化交付结果
182
+
183
+ ```ts
184
+ async function inspectDelivery(): Promise<void> {
185
+ const delivery = await telemetry.flushWithResult();
186
+ console.info('telemetry_delivery', delivery.status, delivery.pending, delivery.durable);
187
+ const diagnostics = telemetry.getDiagnostics();
188
+ console.info('telemetry_queue', diagnostics.pending, diagnostics.lastError);
189
+ }
190
+ ```
191
+
192
+ | 结果或状态 | 如何处理 |
193
+ | --- | --- |
194
+ | completed | 本轮结束;结合 pending 看是否仍有积压,不代表所有配置已经应用 |
195
+ | backoff | 等待 retryAt,网络错误、限流等按 SDK 退避,不在每帧强制重试 |
196
+ | blocked | 修复字典、协议或权限;仍用原实例时调用 retryUploads 尝试恢复;更换连接参数须先释放再重建,并遵守 scope 限制 |
197
+ | storage_unavailable/durable=false | 保留原存储,修复读写故障;ACK 后本地检查点失败可能导致重启重放,服务端仍按原 ID 幂等 |
198
+ | failed | 查看 error 与 status,再判断网络、鉴权或存储问题 |
199
+ | collection_disabled/disposed | 无交付;检查实例与采集许可 |
200
+
201
+ accepted/rejected 是本轮明确确认的数量,quarantined 单列,不计作成功入库。
202
+ 每轮最多十批,不能把一次 flush 解释为无限清空或限时交付保证。
203
+ retryUploads 不绕过临时故障的 Retry-After;传入的 options 也不是运行时改配置接口。
204
+
205
+ status 区分 collection、delivery、pendingDurability 和 configuration。
206
+ getDiagnostics 还提供 pendingBytes、quarantinedBytes、maxQueueBytes、persistence、cleanupPending。
207
+ 只记录必要的错误类别与计数,不输出 Key、Token 或隔离事件私密原文。
208
+
209
+ ## 6. 接入远程配置
210
+
211
+ 配置 schema、校验、默认值及生效时机由宿主负责。基础版本通过 PlayScopeOptions 中的
212
+ restoreCache/resolveConfiguration 回调接入;没有适配器时 SDK 不自动拉配置。
213
+
214
+ - restoreCache(saved):校验历史缓存,返回可保存的规范化值;损坏的业务缓存应按游戏策略回退默认值。源码中抛错会导致 cache_restore_failed 并拒绝初始化。
215
+ - resolveConfiguration(context):返回 Promise<number>,数值为距下次刷新的秒数。使用 context.request 通过安装 Token 请求 `/config/resolve`,不要自行存取 Token。
216
+ - context.cache 是保存数据的副本;先校验配置及 assignment,再 context.saveCache。保存成功后应用新配置,避免未持久的归因被当作已生效。
217
+ - 新开局冻结一份规则与配置归因;活局不直接换成刚拉到的新参数。过期回调在宿主销毁后也应停止应用。
218
+
219
+ 完整的 full/not_modified、supportedSchemas、assignment、缓存校验和每局冻结实现见
220
+ [QuestGarden.ts](examples/quest-garden/QuestGarden.ts) 的 readCache、resolveConfig 和 beginQuest。
221
+ 不要只把响应做类型断言就视为校验通过。
222
+
223
+ 源码增强中,配置和事件独立调度,共享鉴权。await flush 不等待配置生效;需要观察配置结果时
224
+ 使用 `await telemetry.refreshConfiguration()`。该方法仍遵守 nextAttemptAt,并非强制绕过刷新间隔。
225
+ 成功间隔限制为 60 秒至 24 小时,失败从 60 秒退避至 1 小时,并尊重更长 Retry-After。
226
+ 自定义 transport 和配置回调应保证最终结束;默认 XHR 每请求超时 10 秒。
227
+
228
+ ## 7. 广告收入:接入已有曝光回调
229
+
230
+ SDK 不初始化、加载或展示广告,不决定发奖;只接收宿主已经关联好的曝光与收入事实。
231
+ 从现有 LevelPlay impression 唯一入口调用适配器,不另外覆盖监听。
232
+
233
+ ```ts
234
+ import { forwardLevelPlayRevenue } from '@virjay/playscope-cocos/adapters/levelplay';
235
+
236
+ const originalExposure = {
237
+ exposureId: 'UNIQUE_IMPRESSION_ID',
238
+ placement: 'banner' as const,
239
+ businessPlacement: 'cover_banner',
240
+ format: 'banner' as const,
241
+ isTest: true,
242
+ shownAt: new Date().toISOString(),
243
+ };
244
+ // originalExposure 和 originalRun 应在曝光发生时捕获,收入迟到也沿用它们。
245
+ forwardLevelPlayRevenue(telemetry, {
246
+ revenue: 0, precision: 'exact',
247
+ adNetwork: 'NETWORK_FROM_CALLBACK',
248
+ mediationAdUnitId: 'AD_UNIT_FROM_CALLBACK',
249
+ }, originalExposure, { run: originalRun });
250
+ ```
251
+
252
+ 示例值用于说明字段,真实值来自厂商回调。零收入合法,未知收入不补零;
253
+ 仅接收 USD 原值,负数、非有限数值或缺失关键字段会被拒绝,精度按原值保留。
254
+ 每次横幅刷新使用独立曝光 ID,不能沿用整个 banner 对象的 ID。
255
+ 收入按 provider + exposureId 持久去重,窗口为最近 2000 个;迟到收入保留原局和原广告配置。
256
+ 不要在多个层级对同一笔收入重复调用。
257
+
258
+ 默认 trackAdRevenue 使用 game-v1 的 ad_revenue 字段,与目标项目契约配套。
259
+ 源码增强可通过 CocosOptions.adRevenueEncoder 自定义事件名和字段;
260
+ 核心也可用 enqueueAdRevenue 显式选择编码器,标准编码器位于 protocols/game-v1 子路径。
261
+ 自定义事件必须注册字典,不能假定自动进入标准广告收入看板。
262
+ 完整自定义实现见 Quest Garden 的 encodeQuestIncome。
263
+
264
+ ## 8. 源码增强:存储、队列策略与旧数据迁移
265
+
266
+ ### 默认快照与容量
267
+
268
+ 默认最多 2000 条待传事件,待传与隔离事实合计默认 4 MiB UTF-8 JSON 字节;
269
+ 预算不包括身份、配置、索引及迁移临时副本,不能当作底层存储硬配额。
270
+
271
+ ```ts
272
+ import type { PlayScopeOptions } from '@virjay/playscope-cocos';
273
+
274
+ const queuePolicy: PlayScopeOptions['queuePolicy'] = {
275
+ maxEvents: 2000,
276
+ maxBytes: 4 * 1024 * 1024,
277
+ eventLimits: { quest_harvested: 1800 },
278
+ };
279
+ // 在 init 的选项中传入 queuePolicy。
280
+ ```
281
+
282
+ eventLimits 按输入事件名,在整个待传队列达到指定长度时拒绝新增,不是该类事件计数。
283
+ 上例为其他事件预留 200 条空间。较小的新策略不会删除恢复的旧队列,也不驱逐历史事实。
284
+
285
+ ### 分段模式
286
+
287
+ 新项目默认保持 snapshot。确认真实平台满足要求后,在选项中显式传 `persistence: 'segmented'`。
288
+ 分段存储使用 schemaVersion 2 根索引、不可变段与清理日志,减少大队列追加时的完整快照写入。
289
+ 要求严格删除、单键完整原子替换及读回检查;Cocos 存储需支持 removeItem。
290
+ 自定义 StoragePort 读取故障必须抛错,只有确定键不存在才返回 null,删除失败也必须抛错。
291
+
292
+ 旧无版本/v1 可迁移到 v2,身份、Token、缓存、去重和旧事实保留,迁移需额外空间。
293
+ 已迁移的 v2 后续会继续按分段读取,不能靠删掉选项恢复旧格式。
294
+ 回退旧包前需完整校验并还原所有引用段及隔离记录,SDK 未提供自动降级。
295
+ Node 文件或 Map 验证不能替代 Creator/JSB 真实存储与性能验收。
296
+
297
+ ### 来源绑定与历史键
298
+
299
+ 新项目默认键为 `playscope:<projectId>`。升级既有项目须沿用旧键,不能换键制造新安装。
300
+ 状态绑定 baseUrl、projectId、client.environment;environment 缺省为 production。
301
+ baseUrl 仅去除末尾斜线,不自动合并主机别名或路径。
302
+
303
+ 没有 scope 的旧状态需要显式声明已核验的历史来源,例如:
304
+
305
+ ```ts
306
+ import type { PlayScopeOptions } from '@virjay/playscope-cocos';
307
+
308
+ const migration: Pick<PlayScopeOptions, 'storageKey' | 'legacyScope'> = {
309
+ storageKey: 'mygame:playscope:ORIGINAL_PROJECT_UUID',
310
+ legacyScope: {
311
+ baseUrl: 'https://original-analytics.example.com',
312
+ projectId: 'ORIGINAL_PROJECT_UUID',
313
+ environment: 'production',
314
+ },
315
+ };
316
+ // 合并到 init 的连接选项;声明必须与实际目标匹配。
317
+ ```
318
+
319
+ 先核对旧版实际连接目标,再写声明;不能从旧 Token 或当前 options 自动证明历史来源。
320
+ 已绑定数据不能靠 legacyScope 改绑。切换到新的独立目标使用新命名空间,保留旧数据,
321
+ 恢复原目标后仍可继续交付。旧版本重写可能丢弃 scope 或隔离字段,降级不保证新保护。
322
+
323
+ ### 初始化与隔离错误
324
+
325
+ | 错误 | 处理 |
326
+ | --- | --- |
327
+ | storage_unavailable | 读取不可用;修复存储后重试创建,不清空原键 |
328
+ | state_corrupt/state_version_unsupported | 保留原数据,恢复完整状态或升级兼容版本 |
329
+ | scope_migration_required | 核实历史来源后提供 legacyScope |
330
+ | scope_mismatch | 恢复原连接参数或为独立目标使用新命名空间,不能强行改绑 |
331
+ | storage_capability_missing | 补齐分段要求的删除等能力 |
332
+ | cache_restore_failed | 修复宿主缓存恢复逻辑,避免异常覆盖状态 |
333
+ | PlayScopeOwnershipError | 释放旧实例;同运行时相同存储 scope/key 只有一个 writer |
334
+
335
+ PlayScopeInitializationError 提供 code 和 retryable,仅 storage_unavailable 标为可重试。
336
+ 不要捕获构造异常后清空状态或自动重置安装身份。
337
+ 自定义存储包装同一底层时,提供相同 ownershipScope 对象;单运行时检测不提供跨进程或标签页锁。
338
+
339
+ 历史超大事件和明确单事件 413 可进入隔离区,后续事件继续交付。
340
+ getQuarantinedEvents 返回保留的原事实、原因和时间;隔离不等于成功、不自动重传,
341
+ 也没有公开的处置/删除接口。ACK 检查点失败及清理待重试应通过诊断观察。
342
+
343
+ ## 9. 已有业务调度的项目
344
+
345
+ 已有常驻调度、配置适配或业务 facade 时,可直接使用根入口导出的 PlayScopeClient,
346
+ 注入 StoragePort、JsonTransport 和可选时钟,不再叠加 CocosPlayScope。
347
+ 自行调用 start、flush、dispose,并管理授权;核心没有 collectionEnabled 开关,
348
+ 因此关闭采集时不要构造核心实例。核心不会自动注册前后台监听或 update 定时调度。
349
+
350
+ BlockDrop 已走此方式:GameRoot 与 GameTelemetry 持有业务边界,游戏 PlayScopeClient
351
+ 保留 wire/配置归因;严格遥测存储与普通游戏存储分开。现有业务代码继续走游戏 facade,
352
+ 不要再增加第二个 SDK 实例或另调用 LevelPlay 收入转发。
353
+ BlockDrop 沿用 `blockdrop:playscope:<projectId>`,固定包仍为 0.1.1,当前未启用分段存储。
354
+
355
+ ## 10. 接入自查与验证入口
356
+
357
+ 接入后在独立测试项目确认:首次身份重启不变、业务字典能接收、离线恢复重放、重复收入不双报、
358
+ 配置保存后应用且活局归因固定、授权关闭无读写/请求、场景销毁后监听与实例释放。
359
+ 后台统计的测试/模拟收入与生产实际收入要按项目契约区分。
360
+ 正式升级还需验证实际旧安装数据、Creator 模块入口、原生平台存储和真实收入回调。
361
+
362
+ SDK 维护者可在 sdk 目录运行以下源码检查,不生成 dist,不更换游戏固定包:
363
+
364
+ ```sh
365
+ npm run check
366
+ npm run check:quest-example
367
+ npm run test:source
368
+ ```
369
+
370
+ 真实服务与 SIGKILL 恢复验证属于可选维护工具,命令、报告和边界见
371
+ [Quest Garden README](examples/quest-garden/README.md) 与 [VALIDATION](VALIDATION.md)。
372
+ 这些工具只连接本机临时服务,不替代设备验收。源码维护者提供完整工具,npm 包消费者使用对应包内类型、示例和指南。
package/VALIDATION.md ADDED
@@ -0,0 +1,93 @@
1
+ # SDK 验收(当前状态:2026-11-12)
2
+
3
+ ## 0.2.0 正式分发验收
4
+
5
+ 包名 `@virjay/playscope-cocos`,版本 `0.2.0`,公开发布目标为 npm `latest`。
6
+ 本次只构建和打包 SDK;BlockDrop 仍固定旧 `@playscope/cocos` 0.1.1 tarball,未升级游戏依赖。
7
+
8
+ | 检查 | 结果 |
9
+ | --- | --- |
10
+ | SDK `npm run check` | 通过 |
11
+ | 独立示例及使用指南示例类型检查 | 通过 |
12
+ | SDK 双模块构建与包测试 | 104 项通过 |
13
+ | `npm run test:source` | 115 项通过(含 3 项 XHR 子测试) |
14
+ | tarball 独立离线安装 | 通过,无运行时依赖 |
15
+ | CJS/ESM 与 root、core、LevelPlay、game-v1 入口 | 导出文件完整,导入与持久身份恢复通过 |
16
+ | 安装包内 Cocos/使用指南示例 | 使用真实 Creator 声明类型检查通过 |
17
+ | 独立安装包连接临时本地服务 | 两个隔离项目、3 条事件准确入库;离线恢复、丢 ACK 重放、事件原文及模拟收入验证通过 |
18
+ | 文档链接、JSON 与 SDK 参考来源哈希 | 通过 |
19
+
20
+ 本地服务验证复用已有 `bin/playscope` 0.1.20,只使用临时 loopback SQLite,未访问正式数据库。
21
+ 记录保存在本地忽略目录 `artifacts/local-verification.json`;tarball 与 registry 完整性在发布时另行核对。
22
+
23
+ 此前收尾已验证 BlockDrop 临时源码接入(66 文件、885 项通过)、固定旧包(883 项通过、2 项跳过)
24
+ 和应用/测试真实 Cocos 类型。这些是历史结果,不代表游戏已升级到本次 npm 包。
25
+
26
+ ## 已有真实服务与崩溃证据
27
+
28
+ 以下结果来自此前运行,本轮没有重复运行未改动的服务验证工具。
29
+ 服务为已有 `bin/playscope` 0.1.20,SHA-256:
30
+ `e8117fccf1b3c030e5cae20bc8cae86a400c79cd4e663143a79f02df1eb882ae`。
31
+ 每次使用全新 loopback SQLite 和临时项目,成功或失败后清理子进程及数据库,不读取 .env。
32
+
33
+ | 启用选项 | 已通过的范围 | 唯一事件/项目 |
34
+ | --- | --- | --- |
35
+ | 默认 | 自定义字典、配置 full/not_modified、提交后丢/部分 ACK、Token 隔离、服务重启 | 26/2 |
36
+ | `--process-restart` | 加三个独立游戏进程、两次 SIGKILL、原身份和五条事实恢复 | 39/3 |
37
+ | `--process-restart --storage-crash` | 再加新段写完根未提交、ACK 根提交后清理前两次 SIGKILL | 51/4 |
38
+ | `--migration-crash` | 默认加无版本/v1 合成快照多段迁移、四次 SIGKILL、旧事实及元数据保留 | 108/4 |
39
+
40
+ 这些是不同选项组合的独立运行结果,不能相加作为一次总验收。
41
+ 复现入口为 `npm run verify:quest-local -- /absolute/path/to/existing/playscope [选项]`;
42
+ 只接受已有本机二进制。报告分别位于 `artifacts/quest-{local,process,storage-crash,migration-crash}-verification.json`。
43
+ 详细过程与限制见 [Quest Garden README](examples/quest-garden/README.md)。
44
+ 工作进程管理和文件存储已有共享工具,收尾未增加新的验证模式。
45
+
46
+ ## 设备与升级边界
47
+
48
+ 本次未重新运行 Creator/JSB 导出、Android/真机、真实原生广告收入或服务端全套构建流水线。
49
+ 首次升级前必须核对 BlockDrop 无 scope 历史数据的实际服务器、项目和环境,再确认 legacyScope 声明。
50
+ 分段模式要求严格删除及单键完整替换;合成状态和 SIGKILL 不代表断电或真实设备旧版本升级。
51
+ v2 不支持直接降级;隔离记录处置、调用方取消/交付预算和跨进程 writer 锁未提供。
52
+ 已有 Node 基准不替代真机性能结论。以下保留旧包的独立历史构建证据。
53
+
54
+ ## 历史包验收
55
+
56
+ ### 0.1.1 构建兼容性复查
57
+
58
+ 0.1.0 的 CommonJS 转发导出在 Creator Android 导出时失败,不能仅凭类型检查和单测认定兼容。
59
+ 0.1.1 增加独立 ESM 产物、`module` 和 `import` / `require` 条件入口;消费端保留具名导入。
60
+
61
+ - SDK 13 项独立测试通过,包含双入口 API、子路径及同一持久身份 / 队列恢复。
62
+ - BlockDrop 64 个测试文件、879 项测试通过,应用和测试真实 Cocos 类型检查通过。
63
+ - 最小 Cocos 示例使用 BlockDrop 的 Creator 3.8.8 真实声明和已安装 tarball 类型检查通过。
64
+ - BlockDrop Cocos Android Debug 导出通过,无缺失导出告警;bundle 包含实际客户端与 XHR 实现。
65
+ - 当前 Cocos Android 工程 Java 编译和 24 项授权策略单测通过。
66
+
67
+ 这些结果不代表真实广告收入链路、Cocos Web 构建或服务端全套验收完成;首版的本地 API
68
+ 证据和环境限制保留如下。完整 APK 与 Release 构建结果以 BlockDrop 架构复查记录为准。
69
+
70
+ ### 0.1.0 首版历史记录
71
+
72
+ 历史本地包:@playscope/cocos 0.1.0,产物 artifacts/playscope-cocos-0.1.0.tgz。
73
+
74
+ 已通过:
75
+
76
+ - SDK TypeScript 检查和 12 项独立测试。
77
+ - 最终 tarball 独立 npm 安装,两个本地隔离项目共 3 条事件准确入库。
78
+ - 离线状态保存后恢复、丢 ACK 后重放、原 eventId/事件原文不变、项目隔离、测试零收入和重复回调。
79
+ - BlockDrop 首轮迁移全量 875 项测试,新增迁移 fixture 后相关 73 项测试。
80
+ - BlockDrop 应用/测试真实 Cocos 类型检查通过;最小 SDK 示例在恢复后的 SMB 读取阶段超时,未将其标为通过。
81
+ - BlockDrop 旧 storageKey、身份、首次时间、序号、配置缓存、旧 MAX 队列原文的迁移验证。
82
+ - 最终 tarball 与 BlockDrop package-lock.json 的 SHA-512 integrity 相符。
83
+
84
+ 边界:本地真实 API 验收复用仓库已有 bin/playscope,不证明服务端本轮重新构建通过。
85
+ LevelPlay 收入为显式模拟回调,不代表新的游戏原生收入验收。
86
+
87
+ 该轮未完成(后续完成项以上文为准):Cocos Web 构建、最小示例最终类型检查、全量测试最终重跑及服务端 make check/test/build/test-browser 全套。
88
+ 外置 SMB 卷中断后持续 ETIMEDOUT,Cocos 出现 SIGBUS,安装和编译读取阻塞,相关验证进程已停止。
89
+ PlayScope frontend/node_modules 在中断安装后不完整,需要存储稳定后 npm ci 恢复,锁文件未改。
90
+ make check/test/build/test-browser 均在前置 frontend-build 的 tsc: command not found 处失败,未执行后续 Go/浏览器步骤。
91
+ 该历史轮次的旧包未发布 registry,也未重新安装到手机。没有测试写入正式数据库。
92
+
93
+ 0.2.0 分发包包含 README、USAGE、CHANGELOG、VALIDATION、examples 与 doc 使用指南。
@@ -0,0 +1,15 @@
1
+ import type { AdRevenueFact, TrackContext } from '../../core';
2
+ /** Values forwarded by the host's existing LevelPlay impression listener. */
3
+ export interface LevelPlayImpression {
4
+ revenue?: number | null;
5
+ precision?: string | null;
6
+ adNetwork?: string | null;
7
+ mediationAdUnitId?: string | null;
8
+ country?: string | null;
9
+ }
10
+ export type LevelPlayExposure = Omit<AdRevenueFact, 'amount' | 'currency' | 'precision' | 'provider' | 'network' | 'adUnitId' | 'country' | 'countrySource'>;
11
+ export interface RevenueSink {
12
+ trackAdRevenue(fact: AdRevenueFact, context?: TrackContext): boolean;
13
+ }
14
+ /** Does not initialize ads or replace the game's native listener. Host supplies the original exposure context. */
15
+ export declare function forwardLevelPlayRevenue(sink: RevenueSink, impression: LevelPlayImpression, exposure: LevelPlayExposure, context?: TrackContext): boolean;
@@ -0,0 +1,14 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.forwardLevelPlayRevenue = forwardLevelPlayRevenue;
4
+ /** Does not initialize ads or replace the game's native listener. Host supplies the original exposure context. */
5
+ function forwardLevelPlayRevenue(sink, impression, exposure, context = {}) {
6
+ if (typeof impression.revenue !== 'number' || !Number.isFinite(impression.revenue) || impression.revenue < 0 ||
7
+ !impression.adNetwork || !impression.mediationAdUnitId || typeof impression.precision !== 'string')
8
+ return false;
9
+ const country = impression.country?.trim().toUpperCase();
10
+ return sink.trackAdRevenue({ ...exposure, provider: 'levelplay', amount: impression.revenue, currency: 'USD',
11
+ precision: impression.precision, network: impression.adNetwork, adUnitId: impression.mediationAdUnitId,
12
+ ...(country && /^[A-Z]{2}$/.test(country) ? { country, countrySource: 'levelplay_sdk' } : {}),
13
+ }, context);
14
+ }
@@ -0,0 +1,16 @@
1
+ import type { StoragePort } from '../core';
2
+ export interface LocalStorageLike {
3
+ getItem(key: string): string | null;
4
+ setItem(key: string, value: string): void;
5
+ removeItem?(key: string): void;
6
+ }
7
+ /** Inject sys.localStorage; failures reach the outbox and prevent unsafe uploads. */
8
+ export declare class CocosStorage implements StoragePort {
9
+ private readonly storage;
10
+ get ownershipScope(): object;
11
+ readonly removeString?: (key: string) => void;
12
+ constructor(storage: LocalStorageLike);
13
+ getString(key: string): string | null;
14
+ setString(key: string, value: string): void;
15
+ flush(): void;
16
+ }
@@ -0,0 +1,16 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.CocosStorage = void 0;
4
+ /** Inject sys.localStorage; failures reach the outbox and prevent unsafe uploads. */
5
+ class CocosStorage {
6
+ get ownershipScope() { return this.storage; }
7
+ constructor(storage) {
8
+ this.storage = storage;
9
+ if (storage.removeItem)
10
+ this.removeString = key => storage.removeItem(key);
11
+ }
12
+ getString(key) { return this.storage.getItem(key); }
13
+ setString(key, value) { this.storage.setItem(key, value); }
14
+ flush() { }
15
+ }
16
+ exports.CocosStorage = CocosStorage;
@@ -0,0 +1,8 @@
1
+ import type { HttpResponse, JsonTransport } from '../core';
2
+ /** XMLHttpRequest is supported by browsers and Cocos JSB Android/iOS. */
3
+ export declare class XhrJsonTransport implements JsonTransport {
4
+ private readonly pending;
5
+ private disposed;
6
+ post(url: string, headers: Record<string, string>, body: unknown): Promise<HttpResponse>;
7
+ dispose(): void;
8
+ }
@@ -0,0 +1,65 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.XhrJsonTransport = void 0;
4
+ /** XMLHttpRequest is supported by browsers and Cocos JSB Android/iOS. */
5
+ class XhrJsonTransport {
6
+ constructor() {
7
+ this.pending = new Map();
8
+ this.disposed = false;
9
+ }
10
+ post(url, headers, body) {
11
+ return new Promise((resolve, reject) => {
12
+ if (this.disposed) {
13
+ reject(new Error('disposed'));
14
+ return;
15
+ }
16
+ const xhr = new XMLHttpRequest();
17
+ const finish = () => { this.pending.delete(xhr); xhr.onload = xhr.onerror = xhr.ontimeout = xhr.onabort = null; };
18
+ const fail = (error) => { finish(); reject(error); };
19
+ this.pending.set(xhr, fail);
20
+ try {
21
+ xhr.open('POST', url, true);
22
+ xhr.timeout = 10000;
23
+ for (const [key, value] of Object.entries(headers))
24
+ xhr.setRequestHeader(key, value);
25
+ xhr.onload = () => {
26
+ try {
27
+ const rawRetry = xhr.getResponseHeader('Retry-After');
28
+ const retryAfterMs = rawRetry ? /^\d+$/.test(rawRetry) ? Number(rawRetry) * 1000 : Math.max(0, Date.parse(rawRetry) - Date.now()) : 0;
29
+ let parsed;
30
+ try {
31
+ parsed = JSON.parse(xhr.responseText);
32
+ }
33
+ catch {
34
+ parsed = null;
35
+ }
36
+ const status = xhr.status;
37
+ finish();
38
+ resolve({ status, body: parsed, retryAfterMs: Number.isFinite(retryAfterMs) ? retryAfterMs : 0 });
39
+ }
40
+ catch (error) {
41
+ fail(error);
42
+ }
43
+ };
44
+ xhr.onerror = xhr.ontimeout = xhr.onabort = () => fail(new Error('request_failed'));
45
+ xhr.send(JSON.stringify(body));
46
+ }
47
+ catch (error) {
48
+ fail(error);
49
+ }
50
+ });
51
+ }
52
+ dispose() {
53
+ this.disposed = true;
54
+ for (const [xhr, fail] of [...this.pending]) {
55
+ try {
56
+ xhr.abort();
57
+ }
58
+ catch { /* A broken abort must not strand other requests. */ }
59
+ finally {
60
+ fail(new Error('disposed'));
61
+ }
62
+ }
63
+ }
64
+ }
65
+ exports.XhrJsonTransport = XhrJsonTransport;
@@ -0,0 +1,48 @@
1
+ import type { PlayScopeOptions, TrackContext, AdRevenueFact, AdRevenueEncoder, JsonTransport, TrackResult, FlushResult, ConfigurationResult, ClientStatus } from '../core';
2
+ import type { LocalStorageLike } from './Storage';
3
+ export * from '../core';
4
+ export * from './Storage';
5
+ export * from './XhrJsonTransport';
6
+ export interface CocosLifecycle {
7
+ onHide(callback: () => void): () => void;
8
+ onShow(callback: () => void): () => void;
9
+ }
10
+ export interface CocosRuntime {
11
+ storage: LocalStorageLike;
12
+ lifecycle?: CocosLifecycle;
13
+ transport?: JsonTransport;
14
+ now?: () => number;
15
+ }
16
+ export interface CocosOptions extends PlayScopeOptions {
17
+ /** Host decides consent/collection policy before calling init. */
18
+ collectionEnabled: boolean;
19
+ /** Defaults to the existing game-v1 revenue protocol. Does not alter already queued facts. */
20
+ adRevenueEncoder?: AdRevenueEncoder;
21
+ }
22
+ /** Host owns event semantics; this wrapper only schedules flush and releases its subscriptions. */
23
+ export declare class CocosPlayScope {
24
+ private readonly client;
25
+ private readonly revenueEncoder;
26
+ private readonly off;
27
+ private elapsed;
28
+ private disposed;
29
+ constructor(options: CocosOptions, runtime: CocosRuntime);
30
+ start(): void;
31
+ get installId(): string | null;
32
+ getDiagnostics(): import("../core").Diagnostics;
33
+ getQuarantinedEvents(): import("../core").QuarantinedEvent[];
34
+ get status(): ClientStatus;
35
+ track(name: string, properties?: Record<string, unknown>, context?: TrackContext): boolean;
36
+ trackWithResult(name: string, properties?: Record<string, unknown>, context?: TrackContext): TrackResult;
37
+ trackAdRevenue(fact: AdRevenueFact, context?: TrackContext): boolean;
38
+ newSession(): void;
39
+ /** Call from a persistent Cocos component's update(dt), in seconds. */
40
+ update(dt: number): void;
41
+ flush(): Promise<void>;
42
+ flushWithResult(): Promise<FlushResult>;
43
+ retryUploads(): Promise<FlushResult>;
44
+ refreshConfiguration(): Promise<ConfigurationResult>;
45
+ private inactiveFlushResult;
46
+ dispose(): void;
47
+ }
48
+ export declare function init(options: CocosOptions, runtime: CocosRuntime): CocosPlayScope;