@krtuworks/sdk 0.0.0-stage → 1.0.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 (56) hide show
  1. package/AGENTS.md +48 -0
  2. package/CHANGELOG.md +22 -0
  3. package/LICENSE +201 -0
  4. package/NOTICE +6 -0
  5. package/README.md +24 -2
  6. package/dist/api.d.ts +34 -0
  7. package/dist/api.js +61 -0
  8. package/dist/avatarReports.d.ts +15 -0
  9. package/dist/avatarReports.js +18 -0
  10. package/dist/bugs.d.ts +114 -0
  11. package/dist/bugs.js +276 -0
  12. package/dist/client.d.ts +390 -0
  13. package/dist/client.js +666 -0
  14. package/dist/clock.d.ts +19 -0
  15. package/dist/clock.js +32 -0
  16. package/dist/config.d.ts +85 -0
  17. package/dist/config.js +68 -0
  18. package/dist/deviceKey.d.ts +32 -0
  19. package/dist/deviceKey.js +26 -0
  20. package/dist/entitlements.d.ts +48 -0
  21. package/dist/entitlements.js +113 -0
  22. package/dist/errors.d.ts +21 -0
  23. package/dist/errors.js +32 -0
  24. package/dist/events.d.ts +93 -0
  25. package/dist/events.js +304 -0
  26. package/dist/families.d.ts +94 -0
  27. package/dist/families.js +1 -0
  28. package/dist/files.d.ts +108 -0
  29. package/dist/files.js +215 -0
  30. package/dist/identity.d.ts +129 -0
  31. package/dist/identity.js +437 -0
  32. package/dist/ids.d.ts +13 -0
  33. package/dist/ids.js +61 -0
  34. package/dist/index.d.ts +16 -0
  35. package/dist/index.js +7 -0
  36. package/dist/jwt.d.ts +2 -0
  37. package/dist/jwt.js +8 -0
  38. package/dist/locks.d.ts +6 -0
  39. package/dist/locks.js +20 -0
  40. package/dist/presence.d.ts +45 -0
  41. package/dist/presence.js +97 -0
  42. package/dist/progress.d.ts +108 -0
  43. package/dist/progress.js +347 -0
  44. package/dist/progression.d.ts +39 -0
  45. package/dist/progression.js +81 -0
  46. package/dist/remoteConfig.d.ts +48 -0
  47. package/dist/remoteConfig.js +70 -0
  48. package/dist/sessions.d.ts +45 -0
  49. package/dist/sessions.js +157 -0
  50. package/dist/store.d.ts +18 -0
  51. package/dist/store.js +57 -0
  52. package/dist/transport.d.ts +30 -0
  53. package/dist/transport.js +73 -0
  54. package/dist/validator.d.ts +43 -0
  55. package/dist/validator.js +252 -0
  56. package/package.json +40 -4
package/dist/events.js ADDED
@@ -0,0 +1,304 @@
1
+ import { KrtuworksError, isKrtuworksError } from './errors.js';
2
+ import { uuidV7 } from './ids.js';
3
+ import { withLock } from './locks.js';
4
+ export const ANON = 'anon';
5
+ const MAX_EVENTS = 50_000;
6
+ const MAX_BYTES = 20 * 1024 * 1024;
7
+ const MAX_AGE_MS = 30 * 86_400_000;
8
+ const BATCH_EVENTS = 500;
9
+ /** The server reads at most 256 KB as sent; batches are built well under it and halved when gzip still overshoots. */
10
+ const SENT_LIMIT = 250 * 1024;
11
+ const RAW_LIMIT_GZIP = 1_500_000;
12
+ const encoder = new TextEncoder();
13
+ const sizeOf = (e) => encoder.encode(JSON.stringify(e)).length;
14
+ async function gzip(text) {
15
+ if (typeof CompressionStream === 'undefined')
16
+ return null;
17
+ const stream = new Blob([text]).stream().pipeThrough(new CompressionStream('gzip'));
18
+ return new Uint8Array(await new Response(stream).arrayBuffer());
19
+ }
20
+ /**
21
+ * Telemetry events: every event is made with its id and
22
+ * bound to the player it is about (`anon` before any account; that queue goes to the first player).
23
+ * Queued events are written to the store before they are sent; a batch is frozen before its first send
24
+ * and sent again unchanged after a failure; each event's answer is read on its own: accepted, duplicate
25
+ * and rejected leave the queue, retry goes back with its id into a new batch.
26
+ */
27
+ export class EventQueue {
28
+ ctx;
29
+ buffer = [];
30
+ persistTimer = null;
31
+ notBefore = 0;
32
+ backoffMs = 0;
33
+ constructor(ctx) {
34
+ this.ctx = ctx;
35
+ }
36
+ key(owner) {
37
+ return `krtu:${this.ctx.opts.clientId}:events:${owner}`;
38
+ }
39
+ lock(owner) {
40
+ return `krtu:${this.ctx.opts.clientId}:events:${owner}`;
41
+ }
42
+ async load(owner) {
43
+ return (await this.ctx.store.get(this.key(owner))) ?? { pending: [], batch: null, dropped: 0, reported: 0 };
44
+ }
45
+ async save(owner, state) {
46
+ if (!state.pending.length && !state.batch && state.dropped === state.reported)
47
+ await this.ctx.store.delete(this.key(owner));
48
+ else
49
+ await this.ctx.store.set(this.key(owner), state);
50
+ }
51
+ /** Makes an event now, for whoever plays now. Kept in memory for a moment, then written to the store. */
52
+ add(event) {
53
+ const made = {
54
+ id: uuidV7(),
55
+ createdAt: new Date().toISOString(),
56
+ launchId: this.ctx.clock.launchId,
57
+ monotonicMs: this.ctx.clock.monotonicMs(),
58
+ ...(this.ctx.clock.serverOffsetMs !== null ? { serverOffsetMs: Math.round(this.ctx.clock.serverOffsetMs) } : {}),
59
+ ...event,
60
+ };
61
+ this.buffer.push({ owner: this.ctx.subject() ?? ANON, event: made });
62
+ this.persistTimer ??= setTimeout(() => {
63
+ this.persistTimer = null;
64
+ void this.persist().catch(() => undefined);
65
+ }, 1000);
66
+ return made;
67
+ }
68
+ /** Writes the events made since the last write; drops the oldest when the queue is full or too old. */
69
+ async persist() {
70
+ if (this.persistTimer) {
71
+ clearTimeout(this.persistTimer);
72
+ this.persistTimer = null;
73
+ }
74
+ const taken = this.buffer;
75
+ this.buffer = [];
76
+ const byOwner = new Map();
77
+ for (const { owner, event } of taken)
78
+ byOwner.set(owner, [...(byOwner.get(owner) ?? []), event]);
79
+ for (const [owner, events] of byOwner) {
80
+ await withLock(this.lock(owner), async () => {
81
+ const state = await this.load(owner);
82
+ state.pending.push(...events);
83
+ this.trim(state);
84
+ await this.save(owner, state);
85
+ });
86
+ }
87
+ }
88
+ trim(state) {
89
+ const oldest = Date.now() - MAX_AGE_MS;
90
+ const before = state.pending.length;
91
+ state.pending = state.pending.filter((e) => Date.parse(e.createdAt) >= oldest);
92
+ let bytes = state.pending.reduce((n, e) => n + sizeOf(e), 0) + (state.batch?.events.reduce((n, e) => n + sizeOf(e), 0) ?? 0);
93
+ let count = state.pending.length + (state.batch?.events.length ?? 0);
94
+ let cut = 0;
95
+ while (state.pending.length > cut && (count > MAX_EVENTS || bytes > MAX_BYTES)) {
96
+ bytes -= sizeOf(state.pending[cut]);
97
+ count -= 1;
98
+ cut += 1;
99
+ }
100
+ state.pending = state.pending.slice(cut);
101
+ state.dropped += before - state.pending.length;
102
+ }
103
+ /** Events waiting for `owner` (written and not yet written). */
104
+ async size(owner) {
105
+ const state = await this.load(owner);
106
+ return state.pending.length + (state.batch?.events.length ?? 0) + this.buffer.filter((b) => b.owner === owner).length;
107
+ }
108
+ /**
109
+ * Moves `from`'s queue to `to`: the events made before any account go to the first player; a merged
110
+ * account's go to the one it went into (the server counts each event once by its id, and a batch it
111
+ * took under the old account answers the same under the new one).
112
+ */
113
+ async move(from, to) {
114
+ await this.persist();
115
+ const [first, second] = from < to ? [from, to] : [to, from];
116
+ await withLock(this.lock(first), () => withLock(this.lock(second), async () => {
117
+ const source = await this.load(from);
118
+ if (!source.pending.length && !source.batch && source.dropped === source.reported)
119
+ return;
120
+ const target = await this.load(to);
121
+ if (source.batch) {
122
+ if (target.batch)
123
+ target.pending.unshift(...source.batch.events);
124
+ else
125
+ target.batch = source.batch;
126
+ }
127
+ target.pending.push(...source.pending);
128
+ target.dropped += source.dropped - source.reported;
129
+ this.trim(target);
130
+ await this.save(to, target);
131
+ await this.ctx.store.delete(this.key(from));
132
+ }));
133
+ }
134
+ freeze(state, limit) {
135
+ if (!state.pending.length)
136
+ return null;
137
+ const gzipOk = typeof CompressionStream !== 'undefined';
138
+ const raw = gzipOk ? RAW_LIMIT_GZIP : SENT_LIMIT;
139
+ const events = [];
140
+ let bytes = 0;
141
+ for (const e of state.pending) {
142
+ const size = sizeOf(e) + 1;
143
+ if (events.length >= limit || (events.length > 0 && bytes + size > raw))
144
+ break;
145
+ events.push(e);
146
+ bytes += size;
147
+ }
148
+ state.pending = state.pending.slice(events.length);
149
+ return { batchId: uuidV7(), platform: this.ctx.opts.platform, build: this.ctx.opts.build, events };
150
+ }
151
+ /** The dropped-events count, said once as an SDK error event (if the app keeps errors). */
152
+ droppedNotice(state, errorsOn) {
153
+ const unsaid = state.dropped - state.reported;
154
+ if (unsaid <= 0)
155
+ return null;
156
+ state.reported = state.dropped;
157
+ if (!errorsOn)
158
+ return null;
159
+ return {
160
+ id: uuidV7(),
161
+ type: 'error',
162
+ createdAt: new Date().toISOString(),
163
+ launchId: this.ctx.clock.launchId,
164
+ monotonicMs: this.ctx.clock.monotonicMs(),
165
+ data: { kind: 'other', message: `${unsaid} events were dropped on this device: the queue was full or too old`, fingerprint: 'sdk.events_dropped', handled: true },
166
+ };
167
+ }
168
+ /**
169
+ * Sends the signed-in player's queue, batch by batch, until it is empty or a send fails (the frozen
170
+ * batch then waits, unchanged, for the next flush).
171
+ */
172
+ async flush(options = { errorsOn: true }) {
173
+ await this.persist();
174
+ const owner = this.ctx.subject();
175
+ const result = { sent: 0, rejected: [], stoppedBy: null };
176
+ if (!owner)
177
+ return { ...result, stoppedBy: 'signed_out' };
178
+ if (Date.now() < this.notBefore)
179
+ return { ...result, stoppedBy: 'waiting' };
180
+ let limit = BATCH_EVENTS;
181
+ for (let round = 0; round < (options.maxBatches ?? 20); round += 1) {
182
+ const batch = await withLock(this.lock(owner), async () => {
183
+ const state = await this.load(owner);
184
+ if (!state.batch) {
185
+ const notice = this.droppedNotice(state, options.errorsOn);
186
+ if (notice)
187
+ state.pending.push(notice);
188
+ state.batch = this.freeze(state, limit);
189
+ }
190
+ await this.save(owner, state);
191
+ return state.batch;
192
+ });
193
+ if (!batch)
194
+ return result;
195
+ const outcome = await this.sendBatch(owner, batch, result);
196
+ if (outcome === 'split') {
197
+ limit = Math.max(1, Math.floor(batch.events.length / 2));
198
+ continue;
199
+ }
200
+ if (outcome !== 'done') {
201
+ result.stoppedBy = outcome;
202
+ return result;
203
+ }
204
+ limit = BATCH_EVENTS;
205
+ }
206
+ return result;
207
+ }
208
+ async sendBatch(owner, batch, result) {
209
+ const text = JSON.stringify({
210
+ batchId: batch.batchId,
211
+ platform: batch.platform,
212
+ build: batch.build,
213
+ sentAt: new Date().toISOString(),
214
+ launchId: this.ctx.clock.launchId,
215
+ monotonicMs: this.ctx.clock.monotonicMs(),
216
+ events: batch.events,
217
+ });
218
+ const zipped = await gzip(text);
219
+ const body = zipped ?? encoder.encode(text);
220
+ if (body.length > 256 * 1024 && batch.events.length > 1) {
221
+ await this.unfreeze(owner, batch);
222
+ return 'split';
223
+ }
224
+ let answer;
225
+ try {
226
+ answer = await this.ctx.api.player('/v1/events', {
227
+ method: 'POST',
228
+ body: body,
229
+ headers: { 'content-type': 'application/json', ...(zipped ? { 'content-encoding': 'gzip' } : {}) },
230
+ });
231
+ }
232
+ catch (err) {
233
+ return this.failed(owner, batch, err);
234
+ }
235
+ this.backoffMs = 0;
236
+ const json = answer.json ?? {};
237
+ const retryIds = new Set((json.retry ?? []).map((r) => r.id));
238
+ const rejected = json.rejected ?? [];
239
+ await withLock(this.lock(owner), async () => {
240
+ const state = await this.load(owner);
241
+ if (state.batch?.batchId !== batch.batchId)
242
+ return;
243
+ state.pending.unshift(...batch.events.filter((e) => retryIds.has(e.id)));
244
+ state.batch = null;
245
+ await this.save(owner, state);
246
+ });
247
+ result.sent += batch.events.length - retryIds.size - rejected.length;
248
+ result.rejected.push(...rejected.map((r) => ({ id: r.id, code: r.code })));
249
+ if (retryIds.size) {
250
+ // `retry` is for now (the app's events of the day, a build cap): wait before the next batch.
251
+ this.notBefore = Date.now() + 60_000;
252
+ return 'retry';
253
+ }
254
+ return 'done';
255
+ }
256
+ /** A batch never taken by the server goes back to the front of the queue (only before any answer said otherwise). */
257
+ async unfreeze(owner, batch) {
258
+ await withLock(this.lock(owner), async () => {
259
+ const state = await this.load(owner);
260
+ if (state.batch?.batchId !== batch.batchId)
261
+ return;
262
+ state.pending.unshift(...batch.events);
263
+ state.batch = null;
264
+ await this.save(owner, state);
265
+ });
266
+ }
267
+ async failed(owner, batch, err) {
268
+ if (!isKrtuworksError(err))
269
+ throw err;
270
+ if (err.code === 'payload_too_large' && batch.events.length > 1) {
271
+ await this.unfreeze(owner, batch);
272
+ return 'split';
273
+ }
274
+ if (err.code === 'batch_id_reused') {
275
+ // The id was spent on other events (a store restored from another device): same events, new id.
276
+ await this.unfreeze(owner, batch);
277
+ return 'done';
278
+ }
279
+ if (err.status === 400 || err.status === 413) {
280
+ // The batch itself can never be taken: drop it rather than block every later event.
281
+ await withLock(this.lock(owner), async () => {
282
+ const state = await this.load(owner);
283
+ if (state.batch?.batchId !== batch.batchId)
284
+ return;
285
+ state.dropped += batch.events.length;
286
+ state.batch = null;
287
+ await this.save(owner, state);
288
+ });
289
+ return err.code;
290
+ }
291
+ this.backoff(err);
292
+ return err.code;
293
+ }
294
+ backoff(err) {
295
+ this.backoffMs = Math.min(300_000, Math.max(5_000, this.backoffMs * 2));
296
+ const wait = err.retryAfter !== null ? err.retryAfter * 1000 : this.backoffMs;
297
+ this.notBefore = Date.now() + wait;
298
+ }
299
+ /** Lets the next flush try at once (back online, or the player changed). */
300
+ resetBackoff() {
301
+ this.notBefore = 0;
302
+ this.backoffMs = 0;
303
+ }
304
+ }
@@ -0,0 +1,94 @@
1
+ /** Where in the game something happened (death, damage, economy, items, performance, errors). */
2
+ export interface Place {
3
+ world?: string;
4
+ level?: string;
5
+ }
6
+ /**
7
+ * The ready-made event families and their fields, as the server checks them. An app adds
8
+ * its own fields to a family through an extension schema (`ext`) and makes its own event types.
9
+ */
10
+ export interface FamilyData {
11
+ screen_view: {
12
+ screen: string;
13
+ previous?: string;
14
+ };
15
+ button: {
16
+ name: string;
17
+ screen?: string;
18
+ };
19
+ tutorial: {
20
+ tutorial: string;
21
+ step: number;
22
+ action: 'start' | 'complete' | 'skip';
23
+ };
24
+ progression: {
25
+ action: 'start' | 'complete' | 'fail' | 'quit' | 'checkpoint' | 'restart';
26
+ level: string;
27
+ world?: string;
28
+ part?: string;
29
+ checkpoint?: string;
30
+ attempt?: number;
31
+ seconds?: number;
32
+ activeSeconds?: number;
33
+ deaths?: number;
34
+ score?: number;
35
+ };
36
+ death: Place & {
37
+ cause: string;
38
+ enemy?: string;
39
+ };
40
+ damage: Place & {
41
+ amount: number;
42
+ source?: string;
43
+ };
44
+ economy: Place & {
45
+ direction: 'earn' | 'spend';
46
+ currency: string;
47
+ amount: number;
48
+ source?: string;
49
+ sink?: string;
50
+ item?: string;
51
+ };
52
+ balance: {
53
+ currency: string;
54
+ amount: number;
55
+ };
56
+ item: Place & {
57
+ action: 'pickup' | 'use' | 'equip' | 'unequip' | 'drop' | 'craft' | 'buy' | 'sell';
58
+ item: string;
59
+ count?: number;
60
+ };
61
+ interaction: Place & {
62
+ kind: 'dialog' | 'puzzle' | 'secret' | 'hint' | 'npc' | 'other';
63
+ name: string;
64
+ choice?: string;
65
+ outcome?: string;
66
+ };
67
+ performance: Place & {
68
+ fps?: number;
69
+ fpsLow1?: number;
70
+ frameMs?: number;
71
+ loadMs?: number;
72
+ loadKind?: 'startup' | 'level' | 'other';
73
+ memoryMb?: number;
74
+ batteryPct?: number;
75
+ thermal?: 'nominal' | 'fair' | 'serious' | 'critical';
76
+ latencyMs?: number;
77
+ };
78
+ settings: {
79
+ graphicsPreset?: string;
80
+ resolution?: string;
81
+ language?: string;
82
+ subtitles?: boolean;
83
+ subtitleLanguage?: string;
84
+ difficulty?: string;
85
+ inputDevice?: 'keyboard_mouse' | 'gamepad' | 'touch' | 'other';
86
+ };
87
+ error: Place & {
88
+ kind: 'exception' | 'crash' | 'assert' | 'other';
89
+ message?: string;
90
+ fingerprint?: string;
91
+ handled?: boolean;
92
+ };
93
+ }
94
+ export type Family = keyof FamilyData;
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,108 @@
1
+ import type { Api } from './api.js';
2
+ import type { ResolvedOptions } from './config.js';
3
+ import type { KrtuStore } from './store.js';
4
+ export interface FileVersion {
5
+ id: string;
6
+ slot: string;
7
+ device: string | null;
8
+ generation: number;
9
+ sizeBytes: number;
10
+ sha256: string;
11
+ platform: string | null;
12
+ build: string | null;
13
+ savedAt: string;
14
+ fromMerge: boolean;
15
+ }
16
+ export interface SlotView {
17
+ key: string;
18
+ kind: 'SAVE' | 'PROFILE' | 'SETTINGS' | 'DEVICE_SETTINGS';
19
+ name: string;
20
+ retired: boolean;
21
+ file: FileVersion | null;
22
+ /** After a merge: the other account's version, waiting for the player's choice. */
23
+ conflict: FileVersion | null;
24
+ devices: FileVersion[];
25
+ }
26
+ export interface FileRead {
27
+ bytes: Uint8Array;
28
+ /** A save of this slot started on this device while the read ran: the read is older than what the player has here. */
29
+ newerLocalSave: boolean;
30
+ /** The version read (null when it is a similar device's file: a starting point, not this device's). */
31
+ version: {
32
+ id: string;
33
+ generation: number;
34
+ sha256: string;
35
+ savedAt: string;
36
+ } | null;
37
+ similarDevice: boolean;
38
+ }
39
+ export interface SaveOptions {
40
+ /** The version this one replaces (its id, the ETag of a read), or null for an empty slot. SETTINGS-like slots need neither. */
41
+ base?: string | null;
42
+ /** DEVICE_SETTINGS: this device's id and description (`os`, `gpu`, `ramMb`, `screenWidth`, `screenHeight`). */
43
+ device?: string;
44
+ deviceInfo?: Record<string, string | number>;
45
+ }
46
+ /**
47
+ * The player's files in the app's slots: a save is
48
+ * written to the store first, sent with its upload id as the Idempotency-Key and asked about until it
49
+ * is final; a conflict comes back with the slot's current version for the game's choice screen.
50
+ */
51
+ export declare class Files {
52
+ private readonly opts;
53
+ private readonly api;
54
+ private readonly store;
55
+ private readonly subject;
56
+ private readonly generation;
57
+ /** Told after every save the server activated (the client's link reminder counts the first one). */
58
+ private readonly onSaved;
59
+ /** Per slot (and device), bumped by every save started here: a read compares it before and after. */
60
+ private readonly revisions;
61
+ constructor(opts: ResolvedOptions, api: Api, store: KrtuStore, subject: () => string | null, generation: () => number,
62
+ /** Told after every save the server activated (the client's link reminder counts the first one). */
63
+ onSaved?: () => void);
64
+ private revisionKey;
65
+ private path;
66
+ private uploadKey;
67
+ list(): Promise<{
68
+ slots: SlotView[];
69
+ limits: Record<string, number>;
70
+ }>;
71
+ /** A slot's current version; null when `ifNoneMatch` (a version id held) is still current. */
72
+ read(slot: string, options?: {
73
+ device?: string;
74
+ deviceInfo?: Record<string, string | number>;
75
+ ifNoneMatch?: string;
76
+ }): Promise<FileRead | null>;
77
+ /**
78
+ * Saves a new version of `slot`. Resolves with the new version once the server activated it; rejects
79
+ * with `file_conflict` (`details.current`: the version now in the slot) when another one came first.
80
+ */
81
+ save(slot: string, data: Uint8Array, options?: SaveOptions): Promise<FileVersion>;
82
+ private send;
83
+ /** Reads an upload's state (200 again or 202) until it is final. */
84
+ private settle;
85
+ private done;
86
+ /** Saves left unfinished by an earlier launch (an answer lost, a 202): finishes the signed-in player's. */
87
+ resume(): Promise<Array<{
88
+ uploadId: string;
89
+ slot: string;
90
+ outcome: 'saved' | 'refused' | 'waiting';
91
+ code?: string;
92
+ }>>;
93
+ /** Saves of another player waiting on this device (a merged account's): the game asks the player what to keep. */
94
+ waitingFor(owner: string): Promise<Array<{
95
+ uploadId: string;
96
+ slot: string;
97
+ savedAt: number;
98
+ }>>;
99
+ /**
100
+ * A save another player left waiting (a merged account's) that the player chose to keep:
101
+ * saved now as this player's, over `base` (the slot's version the player saw), with a new upload id.
102
+ */
103
+ adoptWaiting(uploadId: string, base: string | null): Promise<FileVersion>;
104
+ /** A waiting save the player chose not to keep. */
105
+ discardWaiting(uploadId: string): Promise<void>;
106
+ /** After a merge: keep `keep` (the slot's version or the merged one) over `current` (the one the player saw). */
107
+ resolve(slot: string, keep: string, current: string): Promise<FileVersion>;
108
+ }