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