@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
@@ -0,0 +1,188 @@
1
+ {
2
+ "project": {
3
+ "name": "Quest Garden source validation",
4
+ "profile": "game",
5
+ "gameSettings": {
6
+ "hasLevels": true,
7
+ "scoreOutcomes": [
8
+ "completed"
9
+ ]
10
+ },
11
+ "configSchema": {
12
+ "type": "object",
13
+ "properties": {
14
+ "schemaVersion": {
15
+ "const": 1
16
+ },
17
+ "revision": {
18
+ "type": "string",
19
+ "minLength": 1,
20
+ "maxLength": 256
21
+ },
22
+ "harvestTarget": {
23
+ "type": "integer",
24
+ "minimum": 1,
25
+ "maximum": 20
26
+ },
27
+ "season": {
28
+ "type": "string",
29
+ "minLength": 1,
30
+ "maxLength": 256
31
+ }
32
+ },
33
+ "required": [
34
+ "schemaVersion",
35
+ "revision",
36
+ "harvestTarget",
37
+ "season"
38
+ ],
39
+ "additionalProperties": false
40
+ }
41
+ },
42
+ "eventSchemas": [
43
+ {
44
+ "name": "quest_started",
45
+ "version": 1,
46
+ "countsAsActivity": true,
47
+ "propertiesSchema": {
48
+ "type": "object",
49
+ "properties": {
50
+ "quest_id": {
51
+ "type": "string",
52
+ "minLength": 1,
53
+ "maxLength": 256
54
+ },
55
+ "season": {
56
+ "type": "string",
57
+ "minLength": 1,
58
+ "maxLength": 256
59
+ }
60
+ },
61
+ "required": [
62
+ "quest_id",
63
+ "season"
64
+ ],
65
+ "additionalProperties": false
66
+ }
67
+ },
68
+ {
69
+ "name": "quest_harvested",
70
+ "version": 1,
71
+ "countsAsActivity": true,
72
+ "propertiesSchema": {
73
+ "type": "object",
74
+ "properties": {
75
+ "quest_id": {
76
+ "type": "string",
77
+ "minLength": 1,
78
+ "maxLength": 256
79
+ },
80
+ "index": {
81
+ "type": "integer",
82
+ "minimum": 1,
83
+ "maximum": 20
84
+ }
85
+ },
86
+ "required": [
87
+ "quest_id",
88
+ "index"
89
+ ],
90
+ "additionalProperties": false
91
+ }
92
+ },
93
+ {
94
+ "name": "quest_completed",
95
+ "version": 1,
96
+ "countsAsActivity": true,
97
+ "propertiesSchema": {
98
+ "type": "object",
99
+ "properties": {
100
+ "quest_id": {
101
+ "type": "string",
102
+ "minLength": 1,
103
+ "maxLength": 256
104
+ },
105
+ "harvests": {
106
+ "type": "integer",
107
+ "minimum": 1,
108
+ "maximum": 20
109
+ }
110
+ },
111
+ "required": [
112
+ "quest_id",
113
+ "harvests"
114
+ ],
115
+ "additionalProperties": false
116
+ }
117
+ },
118
+ {
119
+ "name": "quest_ad_income",
120
+ "version": 1,
121
+ "countsAsActivity": false,
122
+ "propertiesSchema": {
123
+ "type": "object",
124
+ "properties": {
125
+ "placement": {
126
+ "type": "string",
127
+ "minLength": 1,
128
+ "maxLength": 256
129
+ },
130
+ "exposure_id": {
131
+ "type": "string",
132
+ "minLength": 1,
133
+ "maxLength": 256
134
+ },
135
+ "usd": {
136
+ "type": "number",
137
+ "minimum": 0
138
+ },
139
+ "provider": {
140
+ "type": "string",
141
+ "minLength": 1,
142
+ "maxLength": 256
143
+ },
144
+ "network": {
145
+ "type": "string",
146
+ "minLength": 1,
147
+ "maxLength": 256
148
+ },
149
+ "ad_unit": {
150
+ "type": "string",
151
+ "minLength": 1,
152
+ "maxLength": 256
153
+ },
154
+ "precision": {
155
+ "type": "string"
156
+ }
157
+ },
158
+ "required": [
159
+ "placement",
160
+ "exposure_id",
161
+ "usd",
162
+ "provider",
163
+ "network",
164
+ "ad_unit",
165
+ "precision"
166
+ ],
167
+ "additionalProperties": false
168
+ }
169
+ }
170
+ ],
171
+ "configuration": {
172
+ "config": {
173
+ "schemaVersion": 1,
174
+ "revision": "garden-remote-1",
175
+ "harvestTarget": 2,
176
+ "season": "summer"
177
+ }
178
+ },
179
+ "rollout": {
180
+ "variants": [
181
+ {
182
+ "id": "default",
183
+ "revision": "garden-remote-1",
184
+ "weight": 100
185
+ }
186
+ ]
187
+ }
188
+ }
@@ -0,0 +1,20 @@
1
+ {
2
+ "extends": "../../../tsconfig.json",
3
+ "compilerOptions": {
4
+ "noEmit": true,
5
+ "baseUrl": ".",
6
+ "paths": {
7
+ "@virjay/playscope-cocos": [
8
+ "../../../cocos/index.ts"
9
+ ],
10
+ "@virjay/playscope-cocos/adapters/levelplay": [
11
+ "../../../adapters/levelplay/index.ts"
12
+ ]
13
+ }
14
+ },
15
+ "include": [
16
+ "QuestGarden.ts",
17
+ "LevelPlayTelemetry.ts",
18
+ "CoreIntegration.ts"
19
+ ]
20
+ }
@@ -0,0 +1,27 @@
1
+ import { _decorator, Component, sys, game, Game } from 'cc';
2
+ import { init, type CocosPlayScope } from '@virjay/playscope-cocos';
3
+ const { ccclass } = _decorator;
4
+ /** One persistent owner. Replace placeholders with a dedicated test project's client settings. */
5
+ @ccclass('PlayScopeExample')
6
+ export class PlayScopeExample extends Component {
7
+ private telemetry: CocosPlayScope | null = null;
8
+ onLoad(): void {
9
+ this.telemetry = init({
10
+ baseUrl: 'https://analytics.example.com', projectId: 'YOUR_TEST_PROJECT_UUID', projectKey: 'YOUR_PROJECT_CLIENT_KEY',
11
+ collectionEnabled: true, // Replace with the host's collection decision.
12
+ client: { appVersion: '1.0.0', buildNumber: '1',
13
+ platform: sys.isNative ? sys.platform === sys.Platform.ANDROID ? 'android' : 'ios' : 'web',
14
+ locale: sys.language, timezoneOffsetMinutes: -new Date().getTimezoneOffset() },
15
+ }, {
16
+ storage: sys.localStorage,
17
+ lifecycle: {
18
+ onHide: callback => { game.on(Game.EVENT_HIDE, callback); return () => game.off(Game.EVENT_HIDE, callback); },
19
+ onShow: callback => { game.on(Game.EVENT_SHOW, callback); return () => game.off(Game.EVENT_SHOW, callback); },
20
+ },
21
+ });
22
+ this.telemetry.track('app_launch', { reason: 'launch' });
23
+ this.telemetry.track('session_start', {});
24
+ }
25
+ update(dt: number): void { this.telemetry?.update(dt); }
26
+ onDestroy(): void { this.telemetry?.dispose(); this.telemetry = null; }
27
+ }
@@ -0,0 +1,129 @@
1
+ import { CocosPlayScope, telemetryUuid } from '@virjay/playscope-cocos';
2
+ import type { CocosRuntime, ClientContext, AdRevenueFact, ConfigurationContext, FlushResult } from '@virjay/playscope-cocos';
3
+ import { forwardLevelPlayRevenue } from '@virjay/playscope-cocos/adapters/levelplay';
4
+ import type { LevelPlayExposure, LevelPlayImpression } from '@virjay/playscope-cocos/adapters/levelplay';
5
+
6
+ export interface QuestConnection {
7
+ baseUrl: string;
8
+ projectId: string;
9
+ projectKey: string;
10
+ environment: 'production' | 'test';
11
+ }
12
+ interface Rules { schemaVersion: 1; revision: string; harvestTarget: number; season: string }
13
+ interface Assignment { assignmentId: string; experimentId: string | null; variantId: string | null }
14
+ interface Cache { config: Rules; assignment: Assignment }
15
+ interface Attribution { configRevision: string; source: 'bundled' | 'cache' | 'remote';
16
+ assignmentId: string | null; experimentId: string | null; variantId: string | null }
17
+ export interface QuestRun { runId: string; mode: 'garden-quest'; level: string; config: Attribution }
18
+ export interface QuestExposure extends LevelPlayExposure { run: QuestRun | null }
19
+ const BUNDLED: Rules = { schemaVersion: 1, revision: 'garden-bundled-1', harvestTarget: 3, season: 'spring' };
20
+ function readCache(value: unknown): Cache | null {
21
+ const saved = value as Cache | null, rules = saved?.config, assignment = saved?.assignment;
22
+ if (!rules || rules.schemaVersion !== 1 || typeof rules.revision !== 'string' || !rules.revision ||
23
+ !Number.isSafeInteger(rules.harvestTarget) || rules.harvestTarget < 1 || rules.harvestTarget > 20 ||
24
+ typeof rules.season !== 'string' || !rules.season || !assignment || typeof assignment.assignmentId !== 'string' || !assignment.assignmentId ||
25
+ !(assignment.experimentId === null && assignment.variantId === null ||
26
+ typeof assignment.experimentId === 'string' && !!assignment.experimentId && typeof assignment.variantId === 'string' && !!assignment.variantId)) return null;
27
+ return { config: { schemaVersion: 1, revision: rules.revision, harvestTarget: rules.harvestTarget, season: rules.season },
28
+ assignment: { assignmentId: assignment.assignmentId, experimentId: assignment.experimentId, variantId: assignment.variantId } };
29
+ }
30
+ /** Example protocol owned by this game. Custom events must be registered in its own game project. */
31
+ export function encodeQuestIncome(fact: Readonly<AdRevenueFact>) {
32
+ return { name: 'quest_ad_income', properties: { placement: fact.placement, exposure_id: fact.exposureId,
33
+ usd: fact.amount, provider: fact.provider, network: fact.network, ad_unit: fact.adUnitId, precision: fact.precision } };
34
+ }
35
+
36
+ /** Small independent host: gameplay and config application stay here; SDK only collects and delivers facts. */
37
+ export class QuestGarden {
38
+ private readonly sdk: CocosPlayScope;
39
+ private readonly client: ClientContext;
40
+ private rules: Rules = { ...BUNDLED };
41
+ private attribution: Attribution = { configRevision: BUNDLED.revision, source: 'bundled',
42
+ assignmentId: null, experimentId: null, variantId: null };
43
+ private active: { run: QuestRun; rules: Rules; harvests: number; activeMs: number } | null = null;
44
+ private started = false;
45
+ private disposed = false;
46
+ private completed = 0;
47
+ constructor(connection: QuestConnection, runtime: CocosRuntime, collectionEnabled: boolean,
48
+ client: Omit<ClientContext, 'environment'>) {
49
+ const { environment, ...endpoint } = connection;
50
+ this.client = JSON.parse(JSON.stringify({ ...client, environment }));
51
+ this.sdk = new CocosPlayScope({ ...endpoint, client: this.client, collectionEnabled,
52
+ // Separate project IDs are also required for real test/production projects.
53
+ storageKey: `quest-garden:${environment}:${connection.projectId}`, persistence: 'segmented',
54
+ queuePolicy: { maxEvents: 64, maxBytes: 512 * 1024, eventLimits: { quest_harvested: 48 } },
55
+ adRevenueEncoder: encodeQuestIncome,
56
+ restoreCache: saved => {
57
+ const cache = readCache(saved);
58
+ if (cache) this.apply(cache, 'cache');
59
+ return cache;
60
+ },
61
+ resolveConfiguration: context => this.resolveConfig(context),
62
+ }, runtime);
63
+ }
64
+ start(): void {
65
+ if (this.disposed || this.started) return;
66
+ this.started = true; this.sdk.start(); this.sdk.track('app_launch', { reason: 'launch' });
67
+ }
68
+ beginQuest(questId: string): string | null {
69
+ if (this.disposed || this.active || !questId) return null;
70
+ const run: QuestRun = { runId: telemetryUuid(), mode: 'garden-quest', level: questId, config: { ...this.attribution } };
71
+ this.active = { run, rules: { ...this.rules }, harvests: 0, activeMs: 0 };
72
+ this.sdk.track('game_start', {}, { run });
73
+ this.sdk.track('quest_started', { quest_id: questId, season: this.rules.season }, { run });
74
+ return run.runId;
75
+ }
76
+ harvest(): boolean {
77
+ if (this.disposed || !this.active) return false;
78
+ const quest = this.active;
79
+ quest.harvests++;
80
+ this.sdk.track('quest_harvested', { quest_id: quest.run.level, index: quest.harvests }, { run: quest.run });
81
+ if (quest.harvests >= quest.rules.harvestTarget) {
82
+ this.completed++;
83
+ this.sdk.track('quest_completed', { quest_id: quest.run.level, harvests: quest.harvests }, { run: quest.run });
84
+ this.sdk.track('run_ended', { outcome: 'completed', summary: { score: quest.harvests, durationMs: quest.activeMs,
85
+ metrics: { harvests: quest.harvests } } }, { run: quest.run });
86
+ this.active = null;
87
+ }
88
+ return true;
89
+ }
90
+ captureExposure(exposureId: string, placement: string, format: 'banner' | 'rewarded', isTest: boolean): QuestExposure {
91
+ return { exposureId, placement, businessPlacement: placement, format, isTest,
92
+ run: this.active ? JSON.parse(JSON.stringify(this.active.run)) : null };
93
+ }
94
+ recordIncome(impression: LevelPlayImpression, exposure: QuestExposure): boolean {
95
+ if (this.disposed) return false;
96
+ const { run, ...fact } = exposure;
97
+ return forwardLevelPlayRevenue(this.sdk, impression, fact, { run });
98
+ }
99
+ update(dt: number, playing = true): void {
100
+ if (this.disposed) return;
101
+ if (playing && this.active && Number.isFinite(dt) && dt > 0 && dt <= 1) this.active.activeMs += dt * 1000;
102
+ this.sdk.update(dt);
103
+ }
104
+ get progress() { return { completed: this.completed, questId: this.active?.run.level ?? null,
105
+ harvests: this.active?.harvests ?? 0, target: this.active?.rules.harvestTarget ?? this.rules.harvestTarget }; }
106
+ get installId() { return this.sdk.installId; }
107
+ get status() { return this.sdk.status; }
108
+ get diagnostics() { return this.sdk.getDiagnostics(); }
109
+ flush(): Promise<FlushResult> { return this.sdk.flushWithResult(); }
110
+ refreshConfiguration() { return this.sdk.refreshConfiguration(); }
111
+ dispose(): void { if (!this.disposed) { this.disposed = true; this.sdk.dispose(); } }
112
+ private apply(cache: Cache, source: 'cache' | 'remote'): void {
113
+ this.rules = cache.config;
114
+ this.attribution = { configRevision: cache.config.revision, source, ...cache.assignment };
115
+ }
116
+ private async resolveConfig(context: ConfigurationContext): Promise<number> {
117
+ const previous = readCache(context.cache);
118
+ const response = await context.request('/config/resolve', { apiVersion: 1, installId: this.installId,
119
+ client: this.client, supportedSchemas: [1], current: previous ? { revision: previous.config.revision, assignmentId: previous.assignment.assignmentId } : null }) as {
120
+ apiVersion: number; delivery: string; config: unknown; configRevision: string; assignment: unknown; refreshAfterSeconds: number };
121
+ if (this.disposed) return 3600;
122
+ const cache = readCache({ config: response?.delivery === 'full' ? response.config :
123
+ response?.delivery === 'not_modified' ? previous?.config : null, assignment: response?.assignment });
124
+ if (response?.apiVersion !== 1 || !cache || cache.config.revision !== response.configRevision) throw new Error('invalid_quest_config');
125
+ context.saveCache(cache); this.apply(cache, 'remote');
126
+ this.sdk.track('config_received', { revision: cache.config.revision });
127
+ return response.refreshAfterSeconds;
128
+ }
129
+ }
@@ -0,0 +1,180 @@
1
+ # Quest Garden 独立接入示例(0.2.0)
2
+
3
+ 这是第二款游戏的 TypeScript 宿主示例,用独立任务、事件字典、配置 schema 和广告位验证
4
+ SDK 复用。它只从 `@virjay/playscope-cocos` 和 `@virjay/playscope-cocos/adapters/levelplay` 公共入口导入,
5
+ 不引用 BlockDrop,也不访问 SDK 内部状态。这些接口包含在 @virjay/playscope-cocos 0.2.0 中;旧 @playscope/cocos 0.1.1 包不支持。
6
+ 示例没有 Creator 场景、画面或游戏存档,不能视为已交付的第二款游戏。
7
+
8
+ ## 接入与所有权
9
+
10
+ ```ts
11
+ import { QuestGarden } from './QuestGarden';
12
+
13
+ // runtime 由宿主提供 CocosRuntime:storage、transport、生命周期和时钟。
14
+ const game = new QuestGarden({
15
+ baseUrl: 'https://analytics.example.com',
16
+ projectId: '单独创建的测试项目 UUID',
17
+ projectKey: '该项目客户端 Key',
18
+ environment: 'test',
19
+ }, runtime, collectionAllowed, {
20
+ appVersion: '1.0.0', buildNumber: '1', platform: 'android',
21
+ locale: 'zh-CN', timezoneOffsetMinutes: -new Date().getTimezoneOffset(),
22
+ });
23
+ game.start();
24
+ game.beginQuest('orchard-1');
25
+ game.update(0.5, true); // 宿主每帧调用;暂停时 playing=false
26
+ game.harvest();
27
+ // 场景/常驻宿主释放时:
28
+ game.dispose();
29
+ ```
30
+
31
+ 宿主持有采集授权、玩法、对局、活跃计时、配置校验和原生曝光;SDK 持有身份、鉴权、
32
+ 持久 Outbox、ACK 和重试。每次任务冻结其规则和归因;后台 flush 不产生任务失败或结束。
33
+ 玩法进度只在内存中,重启恢复 SDK 待传事实不代表恢复活跃任务。
34
+
35
+ 示例显式启用分段存储,需要支持严格 removeItem 的 localStorage。总预算为 64 条 / 512 KiB,
36
+ quest_harvested 在队列长度 48 时拒绝新增,为其他事件预留 16 条;不驱逐旧事实。
37
+ storageKey 为 `quest-garden:<environment>:<projectId>`;测试与正式还应各用独立项目 ID。
38
+ 相同键切换服务器会触发 scope_mismatch,不能通过清空状态修复。
39
+
40
+ collectionAllowed=false 时 SDK 不访问存储、网络或注册监听,任务仍可完成。授权变化时先
41
+ dispose,再用新的许可重建宿主。已落盘身份与队列保留,重新授权后可继续交付;关闭采集
42
+ 期间的玩法不会补报。dispose 后旧回调不能再上传,但已到达服务器的请求无法撤回。
43
+
44
+ ## 独立项目契约
45
+
46
+ [project-fixture.json](project-fixture.json) 是离线、可审阅的配置材料,不会创建外部项目。
47
+ 需要真实服务验收时,在隔离测试环境按当前管理 API 分别完成:
48
+
49
+ 1. 按 project 部分创建 game 项目,设置 hasLevels、scoreOutcomes 和独立 configSchema;
50
+ 保存返回的项目 ID 和客户端 Key,管理 Token 不能放入游戏。
51
+ 2. 为该项目注册 eventSchemas 中四个 version 1 自定义事件及其 propertiesSchema、活动标记。
52
+ 3. 发布 configuration.config,再按 rollout 的版本权重建立发布规则;Web 宿主另登记 Origin。
53
+ 4. 将返回凭据传给宿主,使用独立测试项目验收,正式项目单独配置。
54
+
55
+ 内置事件为 user_first_open、app_launch、game_start、run_ended 和 config_received;
56
+ 自定义事件为 quest_started、quest_harvested、quest_completed 和 quest_ad_income。
57
+ 任务结算使用 outcome=completed,summary 中记录 score、durationMs 和 harvests。
58
+ 配置只包含 schemaVersion=1、revision、harvestTarget 和 season,宿主发送 supportedSchemas=[1],
59
+ 验证完整配置与 assignment、先保存后应用,当前任务继续使用开局快照。
60
+
61
+ 收入转发先 captureExposure,再将原曝光传给 recordIncome;每次横幅刷新使用新 exposureId,
62
+ isTest 显式传入,迟到收入保留原任务。示例业务位为 potion_cart_banner 等宿主字符串。
63
+ encodeQuestIncome 将通用收入编码为自定义 quest_ad_income(usd、exposure_id 等字段),
64
+ 它是项目事件数据,**不会自动映射标准广告收入看板**。合法零收入、逆序回调及重启去重均有验证。
65
+ 真实 LevelPlay 展示、曝光关联和发奖仍需游戏的原生桥接实现。
66
+
67
+ ## 只验证源码,不生成 dist
68
+
69
+ 在 sdk 目录使用已有依赖:
70
+
71
+ ```sh
72
+ npm run check
73
+ npm run check:quest-example
74
+ npm run test:source
75
+ ```
76
+
77
+ 示例 tsconfig 和测试专用加载器把公共包路径映射到当前源码,不更换游戏固定依赖。
78
+ 9 项示例测试覆盖任务完成、离线重启、丢 ACK、部分 ACK、测试/正式隔离、采集关闭、
79
+ 生命周期释放、独立收入编码和配置归因。离线重启比较持久段中的原事实与最终上传原文。
80
+ 其中两项使用 127.0.0.1 随机端口 HTTP 服务,丢 ACK 测试在记录事实后断开 socket。
81
+
82
+ 该服务是测试协议实现,使用内存 Map 幂等记录和字典校验;它不是 Go PlayScope 服务,
83
+ 不证明真实数据库持久提交。这里也没有 Creator 构建、打包入口、原生广告或真机验收。
84
+
85
+ ## 可选真实服务验证(复用已有程序)
86
+
87
+ 在 sdk 目录执行,参数必须是已经存在的 PlayScope 可执行文件;命令不构建或安装依赖:
88
+
89
+ ```sh
90
+ npm run verify:quest-local -- /absolute/path/to/existing/playscope
91
+ ```
92
+
93
+ 脚本只在 sdk/artifacts 下新建临时目录,启动绑定 127.0.0.1 随机端口的真实 Go 服务及全新
94
+ SQLite 数据库。它不接受外部服务 URL、不加载 .env;子进程环境显式指定测试数据库、
95
+ 临时管理员和关闭更新。真实管理 API 创建两个独立项目、注册字典并发布配置,随后源码
96
+ 示例完成离线文件恢复、配置 full / not_modified、任务与收入上报、丢 ACK/部分 ACK 重放、
97
+ 项目 Token 隔离及错误字典拒收。数据库在同一文件上经过两次服务重启,事件原文再次核对。
98
+
99
+ 丢 ACK 是真实提交响应返回后由 transport 抛错;部分 ACK 是 transport 隐藏已接收 ID,
100
+ 真实服务实际已接收全批。这样验证客户端在确认不完整时如何重放,不代表服务端有随机
101
+ 丢包或只提交了一部分。localStorage 测试端同步写临时文件并 fsync / rename,宿主重新构造
102
+ 时重新从文件读取;没有启动独立的游戏操作系统进程,也没有检验断电存储保证。
103
+
104
+ 正常完成或校验抛错时停止临时服务并删除数据库/客户端状态,只保留无凭据的
105
+ `artifacts/quest-local-verification.json`。报告包含服务版本和二进制 SHA-256,不能将旧程序
106
+ 通过理解为当前 Go 工作区重新编译通过。此工具与普通 test:source 分开,需要本机可执行程序。
107
+
108
+ 2026-11-11 使用已有 0.1.20 程序验证通过:两个项目共 26 条不可变事件、8 份项目自定义 schema、
109
+ 1 次丢 ACK、1 次部分 ACK、2 条错误数据永久拒收;重启后数量和原文保持一致。
110
+ 仍未进行 Creator、包入口、真实广告 SDK 或设备验收。
111
+
112
+ ## 可选独立进程崩溃恢复
113
+
114
+ 同一入口增加标记,在原验证之外创建第三个临时项目,并启动三个独立 Node 游戏宿主进程:
115
+
116
+ ```sh
117
+ npm run verify:quest-local -- /absolute/path/to/existing/playscope --process-restart
118
+ ```
119
+
120
+ 第一个进程离线写入五条事实后等待,父进程确认文件已落盘,再用 SIGKILL 终止它。
121
+ 第二个进程恢复原身份和队列,真实服务提交后,transport 暂不把 ACK 交给 SDK;父进程
122
+ 查询真实入库、确认本地五条事实仍待传,再次 SIGKILL。两次均不执行 SDK dispose。
123
+ 服务另重启一次后,第三个进程恢复同一文件和 Token,按原 eventId/原文重放并完成一项新任务。
124
+ 父进程比较三个不同 PID、完整事件、安装身份、首次时间和首次标记;旧任务不会被补造结束。
125
+
126
+ 工作进程通过本机 IPC 接收临时连接参数,命令行和报告不包含凭据;每个进程单独加载 SDK
127
+ 源码。父进程为工作进程设置 30 秒上限,校验失败也会终止它们,之后清理临时状态和数据库。
128
+ 报告单独保存为 `artifacts/quest-process-verification.json`,不覆盖不带标记的默认报告。
129
+
130
+ 2026-11-11:新增项目 13 条事件,初始五条事实逐字重放,首次事件仅一次,最终待传为零,
131
+ 新任务成功完成且没有旧任务结束事件。包含原验证在内合计三个项目、39 条事件。
132
+ 该验证覆盖操作系统进程被杀,不包含设备断电、写入中途磁盘故障、Creator 或原生广告验收。
133
+
134
+ ## 可选分段写入中断验证
135
+
136
+ 增加 --storage-crash 可单独检查分段持久化的提交边界,也可与进程重启验证一起运行:
137
+
138
+ ```sh
139
+ npm run verify:quest-local -- /absolute/path/to/existing/playscope --process-restart --storage-crash
140
+ ```
141
+
142
+ 新增项目先离线保存五条事实。写入下一条时,工作进程在新数据段完成写入、根索引尚未
143
+ 更新的位置同步暂停;父进程核对旧根仍引用原五条事实,再 SIGKILL。另一个进程恢复后
144
+ 保留原队列、清理未提交段。被中断的 track 尚未返回,其候选事件不算已接收事实。
145
+
146
+ 随后真实服务接收原五条事件,工作进程在 ACK 后的空队列索引已写入、旧段尚未删除时
147
+ 再次同步暂停并被 SIGKILL。恢复进程先清理旧段和日志,空队列 flush 不重传原事实,
148
+ 再通过一个新任务的七条事件验证可以继续写入和上传。原身份/首次标记、其他存储键均保留。
149
+
150
+ 检查点通过同步写入的临时文件向父进程公开;工作进程此时阻塞在 setItem 内,不依赖
151
+ 事件循环处理 IPC。进程管理复用同一测试工具,30 秒超时并在失败路径清理。
152
+ 启用此选项时报告写入 `artifacts/quest-storage-crash-verification.json`,不覆盖前两种报告。
153
+
154
+ 2026-11-12 合并验证通过:本场景五个游戏进程、两次 SIGKILL,新增项目 12 条事件;
155
+ 连同原验证及 --process-restart 共四个项目、51 条事件。测试端仍保证单键完整替换,
156
+ 该结果只证明跨多个键的 SDK 提交流程恢复,不证明半个文件写入、断电或真实 JSB 存储行为。
157
+
158
+ ## 可选旧快照迁移中断验证
159
+
160
+ ```sh
161
+ npm run verify:quest-local -- /absolute/path/to/existing/playscope --migration-crash
162
+ ```
163
+
164
+ 此标记在默认验证外创建两个临时项目,分别放入无版本和 v1 合成快照:各有 40 条待传
165
+ 历史事件,v1 另有一条大隔离记录。它不是实际旧版本游戏进程或生产快照导入。
166
+ 迁移宿主通过 SDK 源码公开类验证存储,保留原键,显式声明已知的 legacyScope;
167
+ SDK 不从 Token 自动推断旧来源。示例玩法和生产固定依赖保持不变。
168
+
169
+ 每种格式分别在第一段写完、旧根仍完整时 SIGKILL,再在完整 v2 根写完、日志尚未清理时
170
+ SIGKILL。第三个进程恢复并检查旧事实、缓存、Token、序号、去重、首次标记及隔离记录,
171
+ 拒绝一个已存在去重键,再上传原 40 条和一条新事实。真实服务正常重启后再次逐条核对。
172
+ 两条历史 provider=max 的合法零收入按原字段入库,不启用 MAX 广告 SDK 或改写收入编码。
173
+
174
+ 2026-11-12:六个独立迁移进程、四次 SIGKILL、两次额外服务重启通过;新增两个项目
175
+ 共 82 条唯一事件,连同默认验证为四个项目/108 条事件。原事件 ID 和完整原文、
176
+ Token、缓存及元数据保留,没有额外首次事件或孤立段,大隔离记录继续保留且不上传。
177
+
178
+ 结果写入 `artifacts/quest-migration-crash-verification.json`,不覆盖其他模式报告。
179
+ 标记可与前两种合用;报告数量以实际启用项为准。测试仍要求单键原子替换,不覆盖断电、
180
+ 半份文件、真实 JSB/Creator 或生产升级验收。