@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.
- package/CHANGELOG.md +30 -0
- package/README.md +314 -2
- package/USAGE.md +372 -0
- package/VALIDATION.md +93 -0
- package/dist/adapters/levelplay/index.d.ts +15 -0
- package/dist/adapters/levelplay/index.js +14 -0
- package/dist/cocos/Storage.d.ts +16 -0
- package/dist/cocos/Storage.js +16 -0
- package/dist/cocos/XhrJsonTransport.d.ts +8 -0
- package/dist/cocos/XhrJsonTransport.js +65 -0
- package/dist/cocos/index.d.ts +48 -0
- package/dist/cocos/index.js +134 -0
- package/dist/core/ClientOwnership.d.ts +6 -0
- package/dist/core/ClientOwnership.js +39 -0
- package/dist/core/ClientScope.d.ts +5 -0
- package/dist/core/ClientScope.js +34 -0
- package/dist/core/ConfigurationSync.d.ts +18 -0
- package/dist/core/ConfigurationSync.js +66 -0
- package/dist/core/FactEncoding.d.ts +10 -0
- package/dist/core/FactEncoding.js +29 -0
- package/dist/core/HttpError.d.ts +10 -0
- package/dist/core/HttpError.js +17 -0
- package/dist/core/OutboxStore.d.ts +31 -0
- package/dist/core/OutboxStore.js +228 -0
- package/dist/core/PersistentState.d.ts +33 -0
- package/dist/core/PersistentState.js +92 -0
- package/dist/core/PlayScopeClient.d.ts +65 -0
- package/dist/core/PlayScopeClient.js +422 -0
- package/dist/core/QueueAdmission.d.ts +4 -0
- package/dist/core/QueueAdmission.js +29 -0
- package/dist/core/index.d.ts +7 -0
- package/dist/core/index.js +27 -0
- package/dist/core/revenue.d.ts +33 -0
- package/dist/core/revenue.js +23 -0
- package/dist/core/types.d.ts +155 -0
- package/dist/core/types.js +2 -0
- package/dist/core/uuid.d.ts +1 -0
- package/dist/core/uuid.js +9 -0
- package/dist/esm/adapters/levelplay/index.js +11 -0
- package/dist/esm/cocos/Storage.js +12 -0
- package/dist/esm/cocos/XhrJsonTransport.js +61 -0
- package/dist/esm/cocos/index.js +115 -0
- package/dist/esm/core/ClientOwnership.js +34 -0
- package/dist/esm/core/ClientScope.js +30 -0
- package/dist/esm/core/ConfigurationSync.js +62 -0
- package/dist/esm/core/FactEncoding.js +24 -0
- package/dist/esm/core/HttpError.js +12 -0
- package/dist/esm/core/OutboxStore.js +224 -0
- package/dist/esm/core/PersistentState.js +85 -0
- package/dist/esm/core/PlayScopeClient.js +415 -0
- package/dist/esm/core/QueueAdmission.js +25 -0
- package/dist/esm/core/index.js +7 -0
- package/dist/esm/core/revenue.js +20 -0
- package/dist/esm/core/types.js +1 -0
- package/dist/esm/core/uuid.js +6 -0
- package/dist/esm/package.json +1 -0
- package/dist/esm/protocols/game-v1/index.js +21 -0
- package/dist/protocols/game-v1/index.d.ts +8 -0
- package/dist/protocols/game-v1/index.js +25 -0
- package/doc/README.md +4 -0
- package/doc//344/275/277/347/224/250/346/214/207/345/215/227/README.md +585 -0
- 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
- 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
- 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
- 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
- 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
- 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
- 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
- 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
- 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
- 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
- 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
- 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
- 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
- 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
- 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
- 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
- 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
- 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
- package/doc//344/275/277/347/224/250/346/214/207/345/215/227//347/244/272/344/276/213/CoreIntegration.ts +13 -0
- package/doc//344/275/277/347/224/250/346/214/207/345/215/227//347/244/272/344/276/213/LevelPlayTelemetry.ts +119 -0
- package/doc//344/275/277/347/224/250/346/214/207/345/215/227//347/244/272/344/276/213/PlayScopeExample.ts +46 -0
- package/doc//344/275/277/347/224/250/346/214/207/345/215/227//347/244/272/344/276/213/QuestGarden.ts +129 -0
- package/doc//344/275/277/347/224/250/346/214/207/345/215/227//347/244/272/344/276/213/README.md +23 -0
- 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
- package/doc//344/275/277/347/224/250/346/214/207/345/215/227//347/244/272/344/276/213/tsconfig.json +20 -0
- package/examples/cocos-minimal/PlayScopeExample.ts +27 -0
- package/examples/quest-garden/QuestGarden.ts +129 -0
- package/examples/quest-garden/README.md +180 -0
- package/examples/quest-garden/project-fixture.json +188 -0
- package/examples/quest-garden/tsconfig.json +12 -0
- package/package.json +73 -4
|
@@ -0,0 +1,415 @@
|
|
|
1
|
+
import { ConfigurationSync } from './ConfigurationSync.js';
|
|
2
|
+
import { PlayScopeHttpError, StorageUnavailableError } from './HttpError.js';
|
|
3
|
+
export { PlayScopeHttpError } from './HttpError.js';
|
|
4
|
+
import { claimStorage } from './ClientOwnership.js';
|
|
5
|
+
import { isEventFact, PlayScopeInitializationError } from './PersistentState.js';
|
|
6
|
+
import { telemetryUuid } from './uuid.js';
|
|
7
|
+
import { resolveQueuePolicy, resolveByteLimit } from './QueueAdmission.js';
|
|
8
|
+
import { OutboxStore } from './OutboxStore.js';
|
|
9
|
+
import { FactEncoding, utf8Bytes } from './FactEncoding.js';
|
|
10
|
+
import { bindClientScope, resolveClientScope } from './ClientScope.js';
|
|
11
|
+
export { utf8Bytes } from './FactEncoding.js';
|
|
12
|
+
export { telemetryUuid } from './uuid.js';
|
|
13
|
+
const MAX_BATCH_BYTES = 250 * 1024;
|
|
14
|
+
/** Durable API v1 outbox, independent of game schema and rendering engine. */
|
|
15
|
+
export class PlayScopeClient {
|
|
16
|
+
constructor(options, storage, transport, now = Date.now) {
|
|
17
|
+
this.options = options;
|
|
18
|
+
this.storage = storage;
|
|
19
|
+
this.transport = transport;
|
|
20
|
+
this.now = now;
|
|
21
|
+
this.encoding = new FactEncoding();
|
|
22
|
+
this.sessionId = telemetryUuid();
|
|
23
|
+
this.flushTask = null;
|
|
24
|
+
this.tokenTask = null;
|
|
25
|
+
this.disposed = false;
|
|
26
|
+
this.blocked = false;
|
|
27
|
+
this.retryAt = 0;
|
|
28
|
+
this.failures = 0;
|
|
29
|
+
this.batchLimit = 100;
|
|
30
|
+
this.lastError_ = null;
|
|
31
|
+
this.needsPersistence = false;
|
|
32
|
+
this.identityPublished = false;
|
|
33
|
+
const scope = resolveClientScope(options);
|
|
34
|
+
this.api = `${scope.baseUrl}/v1/projects/${encodeURIComponent(scope.projectId)}`;
|
|
35
|
+
this.clientContext = JSON.parse(JSON.stringify(options.client));
|
|
36
|
+
this.bootstrapContext = JSON.parse(JSON.stringify(options.bootstrapClient ?? options.client));
|
|
37
|
+
this.key = options.storageKey ?? `playscope:${options.projectId}`;
|
|
38
|
+
this.admissionLimit = resolveQueuePolicy(options.queuePolicy);
|
|
39
|
+
this.maxQueueBytes = resolveByteLimit(options.queuePolicy);
|
|
40
|
+
this.outbox = new OutboxStore(storage, this.key, options.persistence, this.encoding);
|
|
41
|
+
this.configuration = new ConfigurationSync(options.resolveConfiguration, async () => {
|
|
42
|
+
if (this.disposed)
|
|
43
|
+
throw new Error('disposed');
|
|
44
|
+
if (this.needsPersistence && !this.persist())
|
|
45
|
+
throw new StorageUnavailableError();
|
|
46
|
+
await this.ensureToken();
|
|
47
|
+
if (this.disposed)
|
|
48
|
+
throw new Error('disposed');
|
|
49
|
+
return { cache: this.cache,
|
|
50
|
+
request: (path, body) => this.authorized(path, body), saveCache: value => {
|
|
51
|
+
if (!this.disposed && !this.saveCache(value))
|
|
52
|
+
throw new StorageUnavailableError();
|
|
53
|
+
} };
|
|
54
|
+
}, now);
|
|
55
|
+
this.releaseOwnership = claimStorage(storage, this.key);
|
|
56
|
+
try {
|
|
57
|
+
this.state = this.outbox.load(now);
|
|
58
|
+
bindClientScope(this.state, scope, this.outbox.hasStoredState, options.legacyScope);
|
|
59
|
+
if (options.restoreCache) {
|
|
60
|
+
try {
|
|
61
|
+
this.state.cache = options.restoreCache(this.state.cache);
|
|
62
|
+
}
|
|
63
|
+
catch {
|
|
64
|
+
throw new PlayScopeInitializationError('cache_restore_failed');
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
this.persist();
|
|
68
|
+
}
|
|
69
|
+
catch (error) {
|
|
70
|
+
this.releaseOwnership();
|
|
71
|
+
throw error;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
get installId() { return this.state.installId; }
|
|
75
|
+
get cache() { return JSON.parse(JSON.stringify(this.state.cache)); }
|
|
76
|
+
saveCache(value) {
|
|
77
|
+
if (this.disposed)
|
|
78
|
+
return false;
|
|
79
|
+
this.state.cache = JSON.parse(JSON.stringify(value));
|
|
80
|
+
return this.persist();
|
|
81
|
+
}
|
|
82
|
+
getQuarantinedEvents() { return JSON.parse(JSON.stringify(this.state.quarantined)); }
|
|
83
|
+
get diagnostics() {
|
|
84
|
+
return { pending: this.state.queue.length, rejected: this.state.rejected.length, droppedEvents: this.state.droppedEvents,
|
|
85
|
+
lastError: this.lastError_, blocked: this.blocked, quarantined: this.state.quarantined.length,
|
|
86
|
+
...this.queueBytes(), maxQueueBytes: this.maxQueueBytes,
|
|
87
|
+
persistence: this.outbox.mode, cleanupPending: this.outbox.cleanupPending };
|
|
88
|
+
}
|
|
89
|
+
queueBytes() {
|
|
90
|
+
return { pendingBytes: this.state.queue.reduce((sum, event) => sum + this.encoding.get(event).bytes, 0),
|
|
91
|
+
quarantinedBytes: this.state.quarantined.reduce((sum, event) => sum + this.encoding.get(event).bytes, 0) };
|
|
92
|
+
}
|
|
93
|
+
get status() {
|
|
94
|
+
return { collection: this.disposed ? 'disposed' : 'enabled',
|
|
95
|
+
delivery: this.disposed ? 'disposed' : this.needsPersistence ? 'storage_unavailable' : this.flushTask ? 'uploading' :
|
|
96
|
+
this.blocked ? 'blocked' : this.now() < this.retryAt ? 'backoff' : 'idle',
|
|
97
|
+
pendingDurability: this.needsPersistence ? 'memory' : 'persisted', retryAt: this.retryAt,
|
|
98
|
+
configuration: this.configuration.status };
|
|
99
|
+
}
|
|
100
|
+
/** Configuration has its own failure budget and never waits for event delivery. */
|
|
101
|
+
refreshConfiguration() {
|
|
102
|
+
return this.configuration.refresh();
|
|
103
|
+
}
|
|
104
|
+
/** Explicit recovery after the host has repaired credentials, endpoint or permissions. */
|
|
105
|
+
retryUploads() {
|
|
106
|
+
if (!this.disposed && !this.flushTask && this.blocked) {
|
|
107
|
+
this.blocked = false;
|
|
108
|
+
this.retryAt = 0;
|
|
109
|
+
this.failures = 0;
|
|
110
|
+
}
|
|
111
|
+
return this.flushWithResult();
|
|
112
|
+
}
|
|
113
|
+
/** Call after collection is allowed. Existing first-open facts are never recreated. */
|
|
114
|
+
start(name = 'user_first_open', context = {}) {
|
|
115
|
+
if (this.disposed || this.state.firstOpenTracked)
|
|
116
|
+
return;
|
|
117
|
+
this.state.firstOpenTracked = true;
|
|
118
|
+
if (!this.track(name, {}, { ...context, occurredAt: this.state.firstOpenedAt })) {
|
|
119
|
+
this.state.firstOpenTracked = false;
|
|
120
|
+
this.persist();
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
newSession() { if (!this.disposed)
|
|
124
|
+
this.sessionId = telemetryUuid(); }
|
|
125
|
+
track(name, properties, context = {}) {
|
|
126
|
+
return this.trackWithResult(name, properties, context).status === 'accepted';
|
|
127
|
+
}
|
|
128
|
+
trackWithResult(name, properties, context = {}) {
|
|
129
|
+
if (this.disposed)
|
|
130
|
+
return { status: 'disposed' };
|
|
131
|
+
if (context.deduplicationKey && this.state.deduplicationKeys?.includes(context.deduplicationKey))
|
|
132
|
+
return { status: 'duplicate' };
|
|
133
|
+
if (this.state.queue.length >= this.admissionLimit(name)) {
|
|
134
|
+
this.state.droppedEvents++;
|
|
135
|
+
this.lastError_ = 'queue_full';
|
|
136
|
+
this.persist();
|
|
137
|
+
return { status: 'queue_full' };
|
|
138
|
+
}
|
|
139
|
+
let event;
|
|
140
|
+
try {
|
|
141
|
+
event = JSON.parse(JSON.stringify({ eventVersion: 1, eventId: telemetryUuid(), name,
|
|
142
|
+
occurredAt: context.occurredAt ?? new Date(this.now()).toISOString(), sessionId: this.sessionId,
|
|
143
|
+
sequence: ++this.state.sequence, client: this.clientContext, samplingProbability: 1,
|
|
144
|
+
run: context.run ?? null, properties }));
|
|
145
|
+
if (context.transform)
|
|
146
|
+
event = JSON.parse(JSON.stringify(context.transform(event)));
|
|
147
|
+
}
|
|
148
|
+
catch {
|
|
149
|
+
this.lastError_ = 'invalid_event';
|
|
150
|
+
return { status: 'invalid_event' };
|
|
151
|
+
}
|
|
152
|
+
// A JavaScript host can return an invalid object despite the TypeScript signature.
|
|
153
|
+
// Do not persist a fact that our own recovery path would reject on restart.
|
|
154
|
+
if (!isEventFact(event)) {
|
|
155
|
+
this.lastError_ = 'invalid_event';
|
|
156
|
+
return { status: 'invalid_event' };
|
|
157
|
+
}
|
|
158
|
+
// Validate the final wire fact, including any host transform and batch envelope.
|
|
159
|
+
if (!this.fitsSingleBatch(event)) {
|
|
160
|
+
this.state.droppedEvents++;
|
|
161
|
+
this.lastError_ = 'event_too_large';
|
|
162
|
+
this.persist();
|
|
163
|
+
return { status: 'event_too_large' };
|
|
164
|
+
}
|
|
165
|
+
const bytes = this.queueBytes();
|
|
166
|
+
if (bytes.pendingBytes + bytes.quarantinedBytes + this.encoding.get(event).bytes > this.maxQueueBytes) {
|
|
167
|
+
this.state.droppedEvents++;
|
|
168
|
+
this.lastError_ = 'queue_bytes_full';
|
|
169
|
+
this.persist();
|
|
170
|
+
return { status: 'queue_bytes_full' };
|
|
171
|
+
}
|
|
172
|
+
this.state.queue.push(event);
|
|
173
|
+
if (context.deduplicationKey)
|
|
174
|
+
this.state.deduplicationKeys =
|
|
175
|
+
[...(this.state.deduplicationKeys ?? []), context.deduplicationKey].slice(-2000);
|
|
176
|
+
const durable = this.persist();
|
|
177
|
+
if (this.state.queue.length >= 20)
|
|
178
|
+
void this.flush();
|
|
179
|
+
return { status: 'accepted', eventId: event.eventId, durability: durable ? 'persisted' : 'memory' };
|
|
180
|
+
}
|
|
181
|
+
persist() {
|
|
182
|
+
try {
|
|
183
|
+
this.outbox.save(this.state);
|
|
184
|
+
this.needsPersistence = false;
|
|
185
|
+
}
|
|
186
|
+
catch {
|
|
187
|
+
this.needsPersistence = true;
|
|
188
|
+
this.lastError_ = 'storage_unavailable';
|
|
189
|
+
return false;
|
|
190
|
+
}
|
|
191
|
+
if (!this.identityPublished && this.options.onIdentityReady) {
|
|
192
|
+
try {
|
|
193
|
+
this.identityPublished = this.options.onIdentityReady(this.state.installId) !== false;
|
|
194
|
+
}
|
|
195
|
+
catch { /* Optional report linkage retries without blocking durable telemetry. */ }
|
|
196
|
+
}
|
|
197
|
+
return true;
|
|
198
|
+
}
|
|
199
|
+
/** Compatibility wrapper: independently schedules config; resolves when this delivery pass finishes. */
|
|
200
|
+
async flush() {
|
|
201
|
+
await this.flushWithResult();
|
|
202
|
+
}
|
|
203
|
+
flushWithResult() {
|
|
204
|
+
if (this.disposed)
|
|
205
|
+
return Promise.resolve(this.flushResult('disposed'));
|
|
206
|
+
if (this.flushTask)
|
|
207
|
+
return this.flushTask;
|
|
208
|
+
if (this.needsPersistence && !this.persist())
|
|
209
|
+
return Promise.resolve(this.flushResult('storage_unavailable'));
|
|
210
|
+
// Config remains eligible even when event uploads are blocked or in backoff.
|
|
211
|
+
void this.refreshConfiguration();
|
|
212
|
+
if (this.blocked)
|
|
213
|
+
return Promise.resolve(this.flushResult('blocked'));
|
|
214
|
+
if (this.now() < this.retryAt)
|
|
215
|
+
return Promise.resolve(this.flushResult('backoff'));
|
|
216
|
+
const task = Promise.resolve().then(() => this.deliver());
|
|
217
|
+
this.flushTask = task;
|
|
218
|
+
void task.then(() => { if (this.flushTask === task)
|
|
219
|
+
this.flushTask = null; });
|
|
220
|
+
return task;
|
|
221
|
+
}
|
|
222
|
+
flushResult(status, accepted = 0, rejected = 0, quarantined = 0) {
|
|
223
|
+
return { status, accepted, rejected, quarantined, pending: this.state.queue.length,
|
|
224
|
+
durable: !this.needsPersistence, retryAt: this.retryAt, error: this.lastError_ };
|
|
225
|
+
}
|
|
226
|
+
async deliver() {
|
|
227
|
+
let accepted = 0, rejected = 0;
|
|
228
|
+
const initialQuarantined = this.state.quarantined.length;
|
|
229
|
+
const result = (status) => this.flushResult(status, accepted, rejected, this.state.quarantined.length - initialQuarantined);
|
|
230
|
+
let attemptedBatch = null;
|
|
231
|
+
try {
|
|
232
|
+
if (this.disposed)
|
|
233
|
+
return result('disposed');
|
|
234
|
+
await this.ensureToken();
|
|
235
|
+
if (this.disposed)
|
|
236
|
+
return result('disposed');
|
|
237
|
+
// Drain a bounded amount; large offline backlogs continue on the next tick.
|
|
238
|
+
for (let i = 0; i < 10 && this.state.queue.length && !this.disposed; i++) {
|
|
239
|
+
const batch = this.makeBatch();
|
|
240
|
+
// makeBatch may quarantine legacy facts; persist that transition before sending.
|
|
241
|
+
if (!this.persist())
|
|
242
|
+
return result('storage_unavailable');
|
|
243
|
+
if (!batch)
|
|
244
|
+
break;
|
|
245
|
+
attemptedBatch = batch;
|
|
246
|
+
const response = await this.authorized('/analytics/events', batch);
|
|
247
|
+
if (this.disposed)
|
|
248
|
+
return result('disposed');
|
|
249
|
+
if (response?.apiVersion !== 1 || !Array.isArray(response.acceptedEventIds) || !Array.isArray(response.rejectedEvents) ||
|
|
250
|
+
response.acceptedEventIds.some(id => typeof id !== 'string') ||
|
|
251
|
+
response.rejectedEvents.some(r => !r || typeof r.eventId !== 'string' || typeof r.code !== 'string' || typeof r.permanent !== 'boolean'))
|
|
252
|
+
throw new Error('invalid_ack');
|
|
253
|
+
const sent = new Set(batch.events.map(e => e.eventId));
|
|
254
|
+
const removed = new Set(response.acceptedEventIds.filter(id => sent.has(id)));
|
|
255
|
+
accepted += removed.size;
|
|
256
|
+
for (const rejection of response.rejectedEvents) {
|
|
257
|
+
if (!sent.has(rejection.eventId) || rejection.permanent !== true)
|
|
258
|
+
continue;
|
|
259
|
+
if (removed.has(rejection.eventId))
|
|
260
|
+
continue;
|
|
261
|
+
removed.add(rejection.eventId);
|
|
262
|
+
rejected++;
|
|
263
|
+
const event = batch.events.find(e => e.eventId === rejection.eventId);
|
|
264
|
+
this.state.rejected.push({ eventId: event.eventId, name: event.name, code: rejection.code });
|
|
265
|
+
}
|
|
266
|
+
this.state.rejected = this.state.rejected.slice(-50);
|
|
267
|
+
this.state.queue = this.state.queue.filter(e => !removed.has(e.eventId));
|
|
268
|
+
if (!this.persist())
|
|
269
|
+
return result('storage_unavailable');
|
|
270
|
+
if (removed.size === 0 || removed.size < batch.events.length)
|
|
271
|
+
throw new Error('incomplete_ack');
|
|
272
|
+
}
|
|
273
|
+
this.failures = 0;
|
|
274
|
+
this.retryAt = 0;
|
|
275
|
+
this.lastError_ = null;
|
|
276
|
+
return result('completed');
|
|
277
|
+
}
|
|
278
|
+
catch (error) {
|
|
279
|
+
if (this.disposed)
|
|
280
|
+
return result('disposed');
|
|
281
|
+
const http = error instanceof PlayScopeHttpError ? error : null;
|
|
282
|
+
if (http?.status === 413 && http.path === '/analytics/events' && attemptedBatch) {
|
|
283
|
+
if (attemptedBatch.events.length > 1)
|
|
284
|
+
this.batchLimit = Math.max(1, Math.floor(attemptedBatch.events.length / 2));
|
|
285
|
+
else {
|
|
286
|
+
// A server may enforce a smaller limit than our local admission budget.
|
|
287
|
+
// Retain the exact fact separately and let later facts progress on retry.
|
|
288
|
+
this.quarantine(attemptedBatch.events[0], 'http_413');
|
|
289
|
+
this.persist();
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
else if (http && [400, 401, 403, 404, 413].includes(http.status))
|
|
293
|
+
this.blocked = true;
|
|
294
|
+
this.lastError_ = this.needsPersistence ? 'storage_unavailable' : http ? `http_${http.status}` :
|
|
295
|
+
error instanceof Error && ['incomplete_ack', 'invalid_ack'].includes(error.message) ? error.message : 'network_or_protocol_error';
|
|
296
|
+
this.retryAt = this.now() + Math.max(http?.retryAfterMs ?? 0, Math.min(60000, 1000 * 2 ** Math.min(++this.failures, 6)) * (0.75 + Math.random() * 0.5));
|
|
297
|
+
return result(this.needsPersistence ? 'storage_unavailable' : this.blocked ? 'blocked' : 'failed');
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
batchEnvelope(events) {
|
|
301
|
+
return { apiVersion: 1, installId: this.state.installId, batchId: telemetryUuid(),
|
|
302
|
+
sentAt: new Date(this.now()).toISOString(), events };
|
|
303
|
+
}
|
|
304
|
+
fitsSingleBatch(event) {
|
|
305
|
+
return utf8Bytes(JSON.stringify(this.batchEnvelope([]))) + this.encoding.get(event).bytes <= MAX_BATCH_BYTES;
|
|
306
|
+
}
|
|
307
|
+
quarantine(event, reason) {
|
|
308
|
+
const index = this.state.queue.indexOf(event);
|
|
309
|
+
if (index < 0)
|
|
310
|
+
return;
|
|
311
|
+
this.state.quarantined.push({ event, reason, quarantinedAt: new Date(this.now()).toISOString() });
|
|
312
|
+
this.state.queue.splice(index, 1);
|
|
313
|
+
}
|
|
314
|
+
makeBatch() {
|
|
315
|
+
const batch = this.batchEnvelope([]);
|
|
316
|
+
const envelopeBytes = utf8Bytes(JSON.stringify(batch));
|
|
317
|
+
let bytes = envelopeBytes;
|
|
318
|
+
// Inspect a snapshot because isolation removes facts from the pending queue.
|
|
319
|
+
for (const event of [...this.state.queue]) {
|
|
320
|
+
const factBytes = this.encoding.get(event).bytes;
|
|
321
|
+
if (envelopeBytes + factBytes > MAX_BATCH_BYTES) {
|
|
322
|
+
this.quarantine(event, 'event_too_large');
|
|
323
|
+
continue;
|
|
324
|
+
}
|
|
325
|
+
const nextBytes = bytes + factBytes + (batch.events.length ? 1 : 0);
|
|
326
|
+
if (nextBytes > MAX_BATCH_BYTES)
|
|
327
|
+
break;
|
|
328
|
+
batch.events.push(event);
|
|
329
|
+
bytes = nextBytes;
|
|
330
|
+
if (batch.events.length >= this.batchLimit)
|
|
331
|
+
break;
|
|
332
|
+
}
|
|
333
|
+
return batch.events.length ? batch : null;
|
|
334
|
+
}
|
|
335
|
+
ensureToken() {
|
|
336
|
+
if (this.disposed)
|
|
337
|
+
return Promise.reject(new Error('disposed'));
|
|
338
|
+
if (this.tokenTask)
|
|
339
|
+
return this.tokenTask;
|
|
340
|
+
if (this.state.token && Date.parse(this.state.token.expiresAt) > this.now() + 60000)
|
|
341
|
+
return Promise.resolve(this.state.token);
|
|
342
|
+
const task = Promise.resolve().then(() => this.bootstrap());
|
|
343
|
+
this.tokenTask = task;
|
|
344
|
+
void task.then(() => { if (this.tokenTask === task)
|
|
345
|
+
this.tokenTask = null; }, () => { if (this.tokenTask === task)
|
|
346
|
+
this.tokenTask = null; });
|
|
347
|
+
return task;
|
|
348
|
+
}
|
|
349
|
+
async bootstrap() {
|
|
350
|
+
if (this.disposed)
|
|
351
|
+
throw new Error('disposed');
|
|
352
|
+
const result = await this.post('/client/bootstrap', { 'X-Project-Key': this.options.projectKey }, {
|
|
353
|
+
apiVersion: 1, installId: this.state.installId, firstOpenedAt: this.state.firstOpenedAt, client: this.bootstrapContext,
|
|
354
|
+
});
|
|
355
|
+
if (this.disposed)
|
|
356
|
+
throw new Error('disposed');
|
|
357
|
+
if (result?.apiVersion !== 1 || typeof result.accessToken !== 'string' || !result.accessToken ||
|
|
358
|
+
!(Date.parse(result.expiresAt) > this.now()))
|
|
359
|
+
throw new Error('invalid_token');
|
|
360
|
+
this.state.token = { accessToken: result.accessToken, expiresAt: result.expiresAt };
|
|
361
|
+
if (!this.persist())
|
|
362
|
+
throw new StorageUnavailableError();
|
|
363
|
+
return this.state.token;
|
|
364
|
+
}
|
|
365
|
+
async authorized(path, body) {
|
|
366
|
+
if (this.disposed)
|
|
367
|
+
throw new Error('disposed');
|
|
368
|
+
// Keep the acquired token snapshot: another request can invalidate shared state while we resume.
|
|
369
|
+
const { accessToken: token } = await this.ensureToken();
|
|
370
|
+
if (this.disposed)
|
|
371
|
+
throw new Error('disposed');
|
|
372
|
+
if (this.needsPersistence && !this.persist())
|
|
373
|
+
throw new StorageUnavailableError();
|
|
374
|
+
try {
|
|
375
|
+
return await this.post(path, { Authorization: `Bearer ${token}` }, body);
|
|
376
|
+
}
|
|
377
|
+
catch (error) {
|
|
378
|
+
if (!(error instanceof PlayScopeHttpError) || error.status !== 401 || this.disposed)
|
|
379
|
+
throw error;
|
|
380
|
+
// Another request may already have refreshed this rejected token.
|
|
381
|
+
if (this.state.token?.accessToken === token)
|
|
382
|
+
this.state.token = null;
|
|
383
|
+
const refreshed = await this.ensureToken();
|
|
384
|
+
if (this.disposed)
|
|
385
|
+
throw new Error('disposed');
|
|
386
|
+
if (this.needsPersistence && !this.persist())
|
|
387
|
+
throw new StorageUnavailableError();
|
|
388
|
+
return this.post(path, { Authorization: `Bearer ${refreshed.accessToken}` }, body);
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
async post(path, headers, body) {
|
|
392
|
+
if (this.disposed)
|
|
393
|
+
throw new Error('disposed');
|
|
394
|
+
// A host-provided transport must never receive mutable references into cached immutable facts.
|
|
395
|
+
const response = await this.transport.post(this.api + path, { 'Content-Type': 'application/json', ...headers }, JSON.parse(JSON.stringify(body)));
|
|
396
|
+
if (this.disposed)
|
|
397
|
+
throw new Error('disposed');
|
|
398
|
+
if (response.status !== 200)
|
|
399
|
+
throw new PlayScopeHttpError(response.status, response.retryAfterMs, path);
|
|
400
|
+
return response.body;
|
|
401
|
+
}
|
|
402
|
+
dispose() {
|
|
403
|
+
if (this.disposed)
|
|
404
|
+
return;
|
|
405
|
+
this.disposed = true;
|
|
406
|
+
this.configuration.dispose();
|
|
407
|
+
try {
|
|
408
|
+
this.persist();
|
|
409
|
+
this.transport.dispose?.();
|
|
410
|
+
}
|
|
411
|
+
finally {
|
|
412
|
+
this.releaseOwnership();
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/** Admission only: restored/unacknowledged facts are never evicted to meet a new limit. */
|
|
2
|
+
export function resolveQueuePolicy(policy = {}) {
|
|
3
|
+
resolveByteLimit(policy);
|
|
4
|
+
const maxEvents = policy.maxEvents === undefined ? 2000 : policy.maxEvents;
|
|
5
|
+
if (!Number.isSafeInteger(maxEvents) || maxEvents < 1)
|
|
6
|
+
throw new RangeError('queuePolicy.maxEvents must be a positive safe integer');
|
|
7
|
+
const limits = new Map();
|
|
8
|
+
if (policy.eventLimits !== undefined) {
|
|
9
|
+
if (!policy.eventLimits || typeof policy.eventLimits !== 'object' || Array.isArray(policy.eventLimits))
|
|
10
|
+
throw new TypeError('queuePolicy.eventLimits must be an event-name map');
|
|
11
|
+
for (const [name, limit] of Object.entries(policy.eventLimits)) {
|
|
12
|
+
if (!name || !Number.isSafeInteger(limit) || limit < 0 || limit > maxEvents)
|
|
13
|
+
throw new RangeError('queuePolicy event limits must be safe integers between zero and maxEvents');
|
|
14
|
+
limits.set(name, limit);
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
// Own a snapshot. Later mutations of the host's options cannot change admission.
|
|
18
|
+
return eventName => limits.get(eventName) ?? maxEvents;
|
|
19
|
+
}
|
|
20
|
+
export function resolveByteLimit(policy = {}) {
|
|
21
|
+
const value = policy.maxBytes === undefined ? 4 * 1024 * 1024 : policy.maxBytes;
|
|
22
|
+
if (!Number.isSafeInteger(value) || value < 1)
|
|
23
|
+
throw new RangeError('queuePolicy.maxBytes must be a positive safe integer');
|
|
24
|
+
return value;
|
|
25
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
export * from './types.js';
|
|
2
|
+
export * from './PlayScopeClient.js';
|
|
3
|
+
export * from './revenue.js';
|
|
4
|
+
// Keep the existing /core helper available while its protocol implementation lives outside the core.
|
|
5
|
+
export { trackAdRevenue } from '../protocols/game-v1/index.js';
|
|
6
|
+
export { PlayScopeInitializationError } from './PersistentState.js';
|
|
7
|
+
export { PlayScopeOwnershipError } from './ClientOwnership.js';
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/** Validate the income fact; the explicit encoder owns event names and wire properties. */
|
|
2
|
+
export function enqueueAdRevenue(client, fact, context, encode) {
|
|
3
|
+
if (!fact || ![fact.exposureId, fact.network, fact.adUnitId, fact.provider, fact.placement].every(value => typeof value === 'string' && value.trim().length > 0) ||
|
|
4
|
+
!Number.isFinite(fact.amount) || fact.amount < 0 || fact.currency !== 'USD' ||
|
|
5
|
+
typeof fact.precision !== 'string' || typeof fact.isTest !== 'boolean')
|
|
6
|
+
return false;
|
|
7
|
+
// All fact fields are scalar. The encoder cannot mutate the source exposure or its dedup identity.
|
|
8
|
+
const snapshot = Object.freeze({ ...fact });
|
|
9
|
+
let event;
|
|
10
|
+
try {
|
|
11
|
+
event = encode(snapshot);
|
|
12
|
+
}
|
|
13
|
+
catch {
|
|
14
|
+
return false;
|
|
15
|
+
}
|
|
16
|
+
if (!event || typeof event.name !== 'string' || !event.name || !event.properties ||
|
|
17
|
+
typeof event.properties !== 'object' || Array.isArray(event.properties))
|
|
18
|
+
return false;
|
|
19
|
+
return client.track(event.name, event.properties, { ...context, deduplicationKey: `ad_revenue:${snapshot.provider}:${snapshot.exposureId}` });
|
|
20
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"type":"module"}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { enqueueAdRevenue } from '../../core/revenue.js';
|
|
2
|
+
/** API v1 field encoding. Kept byte-compatible with the original SDK's revenue properties. */
|
|
3
|
+
export function encodeGameV1AdRevenue(fact) {
|
|
4
|
+
// This protocol's fixed rate of one is only valid for the original USD fact.
|
|
5
|
+
if (!fact || fact.currency !== 'USD' || !Number.isFinite(fact.amount) || fact.amount < 0)
|
|
6
|
+
return null;
|
|
7
|
+
return { name: 'ad_revenue', properties: {
|
|
8
|
+
placement: fact.placement, business_placement: fact.businessPlacement ?? fact.placement,
|
|
9
|
+
ad_request_id: fact.exposureId, ad_format: fact.format, provider: fact.provider,
|
|
10
|
+
ad_unit_id: fact.adUnitId, network: fact.network, revenue: fact.amount, currency: fact.currency,
|
|
11
|
+
exchange_rate_usd: 1, revenue_precision: fact.precision, is_test: fact.isTest,
|
|
12
|
+
is_mock: fact.isMock === true, is_demo: fact.isDemo === true,
|
|
13
|
+
...(fact.country ? { country: fact.country, ...(fact.countrySource ? { country_source: fact.countrySource } : {}) } : {}),
|
|
14
|
+
...(fact.shownAt ? { ad_shown_at: fact.shownAt } : {}),
|
|
15
|
+
...(fact.adConfigRevision ? { ad_config_revision: fact.adConfigRevision } : {}),
|
|
16
|
+
} };
|
|
17
|
+
}
|
|
18
|
+
/** Compatibility helper for existing callers; new integrations can select an encoder explicitly. */
|
|
19
|
+
export function trackAdRevenue(client, fact, context = {}) {
|
|
20
|
+
return enqueueAdRevenue(client, fact, context, encodeGameV1AdRevenue);
|
|
21
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { AdRevenueFact, EncodedRevenueEvent, EventSink } from '../../core/revenue';
|
|
2
|
+
import type { TrackContext } from '../../core/types';
|
|
3
|
+
/** Existing product protocol vocabulary; optional for games which want this narrower type. */
|
|
4
|
+
export type GameV1AdPlacement = 'revive_video' | 'gameover_interstitial' | 'win_interstitial' | 'pause_interstitial' | 'hint_video' | 'win_double' | 'banner';
|
|
5
|
+
/** API v1 field encoding. Kept byte-compatible with the original SDK's revenue properties. */
|
|
6
|
+
export declare function encodeGameV1AdRevenue(fact: Readonly<AdRevenueFact>): EncodedRevenueEvent | null;
|
|
7
|
+
/** Compatibility helper for existing callers; new integrations can select an encoder explicitly. */
|
|
8
|
+
export declare function trackAdRevenue(client: EventSink, fact: AdRevenueFact, context?: TrackContext): boolean;
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.encodeGameV1AdRevenue = encodeGameV1AdRevenue;
|
|
4
|
+
exports.trackAdRevenue = trackAdRevenue;
|
|
5
|
+
const revenue_1 = require("../../core/revenue");
|
|
6
|
+
/** API v1 field encoding. Kept byte-compatible with the original SDK's revenue properties. */
|
|
7
|
+
function encodeGameV1AdRevenue(fact) {
|
|
8
|
+
// This protocol's fixed rate of one is only valid for the original USD fact.
|
|
9
|
+
if (!fact || fact.currency !== 'USD' || !Number.isFinite(fact.amount) || fact.amount < 0)
|
|
10
|
+
return null;
|
|
11
|
+
return { name: 'ad_revenue', properties: {
|
|
12
|
+
placement: fact.placement, business_placement: fact.businessPlacement ?? fact.placement,
|
|
13
|
+
ad_request_id: fact.exposureId, ad_format: fact.format, provider: fact.provider,
|
|
14
|
+
ad_unit_id: fact.adUnitId, network: fact.network, revenue: fact.amount, currency: fact.currency,
|
|
15
|
+
exchange_rate_usd: 1, revenue_precision: fact.precision, is_test: fact.isTest,
|
|
16
|
+
is_mock: fact.isMock === true, is_demo: fact.isDemo === true,
|
|
17
|
+
...(fact.country ? { country: fact.country, ...(fact.countrySource ? { country_source: fact.countrySource } : {}) } : {}),
|
|
18
|
+
...(fact.shownAt ? { ad_shown_at: fact.shownAt } : {}),
|
|
19
|
+
...(fact.adConfigRevision ? { ad_config_revision: fact.adConfigRevision } : {}),
|
|
20
|
+
} };
|
|
21
|
+
}
|
|
22
|
+
/** Compatibility helper for existing callers; new integrations can select an encoder explicitly. */
|
|
23
|
+
function trackAdRevenue(client, fact, context = {}) {
|
|
24
|
+
return (0, revenue_1.enqueueAdRevenue)(client, fact, context, encodeGameV1AdRevenue);
|
|
25
|
+
}
|
package/doc/README.md
ADDED