skillprint-js-sdk 1.1.0-beta.7 โ†’ 1.1.0-beta.8

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/README.md CHANGED
@@ -14,7 +14,7 @@ A professional JavaScript & TypeScript SDK for integrating Skillprint's real-tim
14
14
  - ๐Ÿ“ธ **Automated Screenshot Capture**: Asynchronous, throttled gameplay screenshot captures for Canvas and WebGL contexts without dropping game frame rates.
15
15
  - ๐Ÿ”„ **Real-Time Dynamic Parameters**: Receive, convert, clamp, and apply AI-driven parameter modifications seamlessly during active sessions.
16
16
  - ๐ŸŽฎ **Built-In Engine Adapters**: Ready-to-use adapter wrappers for Phaser.js, Three.js, PixiJS, and Generic Canvas.
17
- - ๐Ÿ“Š **Discrete Telemetry Events**: Optional `logEvent()` for precise, low-latency gameplay signals (a fixed vocabulary, or your own custom event names) alongside screenshot capture. See [Discrete Telemetry Events](#discrete-telemetry-events).
17
+ - ๐Ÿ“Š **Discrete Telemetry Events**: `logEvent()` for universal and game-specific gameplay events, sent with the screenshots and timestamped from session start. See [Discrete Telemetry Events](#discrete-telemetry-events).
18
18
  - ๐ŸŒ **WebGL & URL Extraction**: Automatic query parameter extraction for web deployments (`mood`, `playerId`).
19
19
  - ๐Ÿงช **100% Test Coverage Ready**: Fully tested with Vitest and jsdom.
20
20
 
@@ -191,35 +191,68 @@ manager.startGameSessionWithOverrides(
191
191
 
192
192
  ## Discrete Telemetry Events
193
193
 
194
- Screenshot capture is the SDK's primary telemetry mechanism and covers every game automatically. `logEvent()` is an **optional, additive** layer on top of it for games that want precise, low-latency event signals alongside the vision-based scoring โ€” it doesn't replace or interact with the screenshot loop.
194
+ Screenshots are the SDK's main telemetry and cover every game automatically. `logEvent()` adds discrete events: what the player did and when. Events and screenshots are both stamped with the **milliseconds since the session started**, so Skillprint can line each event up with the frames around it.
195
195
 
196
196
  ```typescript
197
197
  import { GameEvent } from 'skillprint-js-sdk';
198
198
 
199
- manager.logEvent(GameEvent.LEVEL_START);
199
+ manager.logEvent(GameEvent.LEVEL_START, { level: 3 });
200
+ manager.logEvent('ROTATE_CLOCKWISE');
200
201
  manager.logEvent(GameEvent.LEVEL_COMPLETE, { level: 3, score: 1200 });
201
202
  ```
202
203
 
203
- `logEvent` is fire-and-forget: a failed request is logged as a warning and does not throw, so it can never interrupt gameplay. Calling it with no active session is a no-op (also just a warning).
204
+ Events are queued and sent with the next screenshot upload, or on their own when there is no screenshot to send. `stopGameSession()` sends the rest with the closing upload, so they arrive before the session closes. `logEvent` never throws, and with no active session it only logs a warning.
204
205
 
205
- ### Two ways to use it
206
+ ### Universal and game-specific events
206
207
 
207
- **1. The fixed `GameEvent` vocabulary** โ€” recommended for most integrations. Events logged with one of these names are automatically read as a clear positive or negative signal, with nothing else to configure:
208
+ **Universal events** (`GameEvent`) mean the same thing in every game. Send the ones that apply to yours:
208
209
 
209
- | Event | Signal |
210
- |---|---|
211
- | `LEVEL_START`, `LEVEL_COMPLETE` | Positive |
212
- | `LEVEL_QUIT`, `LEVEL_FAILED`, `LEVEL_RESTART`, `HINT` | Negative |
213
- | `GENERIC_POSITIVE`, `GENERIC_NEGATIVE` | Use for anything else that's clearly good or bad for the player and doesn't fit the events above |
210
+ | Event | When | Scoring signal |
211
+ |---|---|---|
212
+ | `GAME_START`, `GAME_END` | The game begins or ends | |
213
+ | `GAME_PAUSE`, `GAME_RESUME` | Play is paused or resumed | |
214
+ | `LEVEL_START`, `LEVEL_COMPLETE` | A level begins or is completed | Positive |
215
+ | `LEVEL_QUIT`, `LEVEL_FAILED`, `LEVEL_RESTART` | A level is abandoned, lost or restarted | Negative |
216
+ | `MATCH`, `UNMATCH` | A match is made or undone, in games built on matching | |
217
+ | `HINT` | The player asks for a hint | Negative |
218
+ | `GENERIC_POSITIVE`, `GENERIC_NEGATIVE` | Anything else clearly good or bad for the player | Positive / Negative |
214
219
 
215
- **2. A custom event name** (any string) โ€” for a richer, game-specific vocabulary:
220
+ **Game-specific events** are any other name, for actions only your game has. For Hextris, for example:
216
221
 
217
222
  ```typescript
218
- manager.logEvent('CLOCKWISE_TAP', { comboCount: 4 });
219
- manager.logEvent('TWO_COLORS_MATCHED', { color: 'red' });
223
+ manager.logEvent('ROTATE_CLOCKWISE');
224
+ manager.logEvent('ROTATE_ANTICLOCKWISE');
220
225
  ```
221
226
 
222
- Custom event names don't need to be registered in advance โ€” send whatever your game already tracks. They're picked up by Skillprint's scoring as additional context alongside screenshots. If you want a custom event to map to a specific improvement in scoring accuracy for your game, talk to your Skillprint contact about setting up a custom scoring configuration.
227
+ They don't need registering; send what your game already tracks, in `UPPER_SNAKE_CASE`. `isUniversalEvent(name)` tells the two kinds apart.
228
+
229
+ Each event can carry data (`{ level: 3, score: 1200 }`). `event` and `timestamp` are set by the SDK and can't be overridden.
230
+
231
+ ### What is sent
232
+
233
+ Each screenshot upload can carry, beside the images:
234
+ - `offset_ms<n>`: when the n-th screenshot was captured, in ms from session start
235
+ - `events`: a JSON array of the events since the last upload (at most 1000), e.g. `[{"event": "ROTATE_CLOCKWISE", "timestamp": 1250}]`, where `timestamp` is ms from session start
236
+
237
+ ### Pages that upload their own frames
238
+
239
+ A page that captures its own screenshots and calls `SkillprintAPIClient` directly (see [Host mode](#skillprintapiclient)) keeps a `SessionTimeline` itself:
240
+
241
+ ```typescript
242
+ import { SessionTimeline, SkillprintAPIClient } from 'skillprint-js-sdk';
243
+
244
+ const timeline = new SessionTimeline();
245
+ await client.startSession(sessionId, 'focus', null, 'hextris');
246
+ timeline.start();
247
+
248
+ const offset = timeline.offsetMs(); // when a frame is captured
249
+ timeline.record('ROTATE_CLOCKWISE'); // when the player acts
250
+
251
+ await client.postScreenshots(sessionId, [frame], false, {
252
+ offsetsMs: [offset],
253
+ events: timeline.takeEvents()
254
+ });
255
+ ```
223
256
 
224
257
  ## API Reference
225
258
 
@@ -249,7 +282,7 @@ Custom event names don't need to be registered in advance โ€” send whatever your
249
282
  The HTTP client `SkillprintManager` uses. Use it directly when your page already captures its own frames, or when you need session scores.
250
283
  - `constructor(baseUrl: string, partnerApiKey?: string, logger?: SDKLogger)`
251
284
  - `startSession(sessionId, targetMood, customPlayerId?, gameName?, gameParameters?: ParameterInfo[], options?: { deviceContext? })`
252
- - `postScreenshots(sessionId, screenshots: Blob[], isLastChunk?, options?: { gameStates? })`: `gameStates[n]` (for example `{ score: 1200 }`) is sent with the n-th screenshot, and the latest `score` becomes the session's score. `inputCount` is how many player inputs the batch covers; a batch with 0 is left out of skill scores. `isLastChunk: true` scores the batch and closes the session.
285
+ - `postScreenshots(sessionId, screenshots: Blob[], isLastChunk?, options?: { gameStates?, inputCount?, offsetsMs?, events? })`: `offsetsMs[n]` is when the n-th screenshot was captured and `events` are the events since the last upload, both in ms from session start (see [Discrete Telemetry Events](#discrete-telemetry-events)). `gameStates[n]` (for example `{ score: 1200 }`) is sent with the n-th screenshot, and the latest `score` becomes the session's score. `inputCount` is how many player inputs the batch covers; a batch with 0 is left out of skill scores. `isLastChunk: true` scores the batch and closes the session.
253
286
  - `getSession(sessionId): Promise<SessionResult>`: the session's `state` (`OPEN`, then `CLOSED` once scored), `skillScores`, `moodScores`, `score`, `telemetry` (adjustments and logged events) and `parameterUpdates`
254
287
  - `pollParameterResults(sessionId)`: only the parameter updates
255
288
  - `getUserProfile()`: the player's skill profile; needs a player token
package/dist/index.cjs CHANGED
@@ -24,6 +24,7 @@ __export(index_exports, {
24
24
  GameEvent: () => GameEvent,
25
25
  GenericCanvasSkillprintAdapter: () => GenericCanvasSkillprintAdapter,
26
26
  LogLevel: () => LogLevel,
27
+ MAX_EVENTS_PER_UPLOAD: () => MAX_EVENTS_PER_UPLOAD,
27
28
  Mood: () => Mood,
28
29
  ParameterDefinition: () => ParameterDefinition,
29
30
  ParameterInfo: () => ParameterInfo,
@@ -33,6 +34,7 @@ __export(index_exports, {
33
34
  PixiSkillprintAdapter: () => PixiSkillprintAdapter,
34
35
  PollResultsResponse: () => PollResultsResponse,
35
36
  ScreenshotUtility: () => ScreenshotUtility,
37
+ SessionTimeline: () => SessionTimeline,
36
38
  SkillprintAPIClient: () => SkillprintAPIClient,
37
39
  SkillprintApiError: () => SkillprintApiError,
38
40
  SkillprintConfig: () => SkillprintConfig,
@@ -41,7 +43,8 @@ __export(index_exports, {
41
43
  StartSessionRequest: () => StartSessionRequest,
42
44
  TelemetryEventRequest: () => TelemetryEventRequest,
43
45
  ThreeSkillprintAdapter: () => ThreeSkillprintAdapter,
44
- WebGLUrlParameterExtractor: () => WebGLUrlParameterExtractor
46
+ WebGLUrlParameterExtractor: () => WebGLUrlParameterExtractor,
47
+ isUniversalEvent: () => isUniversalEvent
45
48
  });
46
49
  module.exports = __toCommonJS(index_exports);
47
50
 
@@ -76,16 +79,25 @@ var LogLevel = /* @__PURE__ */ ((LogLevel2) => {
76
79
  return LogLevel2;
77
80
  })(LogLevel || {});
78
81
  var GameEvent = /* @__PURE__ */ ((GameEvent4) => {
82
+ GameEvent4["GAME_START"] = "GAME_START";
83
+ GameEvent4["GAME_END"] = "GAME_END";
84
+ GameEvent4["GAME_PAUSE"] = "GAME_PAUSE";
85
+ GameEvent4["GAME_RESUME"] = "GAME_RESUME";
79
86
  GameEvent4["LEVEL_START"] = "LEVEL_START";
80
87
  GameEvent4["LEVEL_COMPLETE"] = "LEVEL_COMPLETE";
81
88
  GameEvent4["LEVEL_QUIT"] = "LEVEL_QUIT";
82
89
  GameEvent4["LEVEL_FAILED"] = "LEVEL_FAILED";
83
90
  GameEvent4["LEVEL_RESTART"] = "LEVEL_RESTART";
91
+ GameEvent4["MATCH"] = "MATCH";
92
+ GameEvent4["UNMATCH"] = "UNMATCH";
84
93
  GameEvent4["HINT"] = "HINT";
85
94
  GameEvent4["GENERIC_POSITIVE"] = "GENERIC_POSITIVE";
86
95
  GameEvent4["GENERIC_NEGATIVE"] = "GENERIC_NEGATIVE";
87
96
  return GameEvent4;
88
97
  })(GameEvent || {});
98
+ function isUniversalEvent(name) {
99
+ return Object.values(GameEvent).includes(name);
100
+ }
89
101
 
90
102
  // src/config.ts
91
103
  var SkillprintConfig = class {
@@ -473,8 +485,9 @@ var SkillprintAPIClient = class {
473
485
  const endpoint = this.UPLOAD_SCREENSHOTS_ENDPOINT.replace("{sessionId}", sessionId);
474
486
  const url = `${this.baseUrl}${endpoint}`;
475
487
  this.logger?.(`Posting ${screenshots.length} screenshots (isLastChunk: ${isLastChunk}): POST ${url}`, "info" /* INFO */);
476
- if (screenshots.length === 0 && !isLastChunk) {
477
- const errorMsg = "No screenshots provided, and 'is_last_chunk' is false. API likely requires files in this case.";
488
+ const events = options.events || [];
489
+ if (screenshots.length === 0 && !isLastChunk && events.length === 0) {
490
+ const errorMsg = "No screenshots or events provided, and 'is_last_chunk' is false. Nothing to upload.";
478
491
  this.logger?.(errorMsg, "warning" /* WARNING */);
479
492
  throw new Error(errorMsg);
480
493
  }
@@ -498,6 +511,14 @@ var SkillprintAPIClient = class {
498
511
  if (typeof options.inputCount === "number") {
499
512
  formData.append("input_count", String(options.inputCount));
500
513
  }
514
+ (options.offsetsMs || []).forEach((offset, i) => {
515
+ if (typeof offset === "number" && offset >= 0) {
516
+ formData.append(`offset_ms${i}`, String(Math.round(offset)));
517
+ }
518
+ });
519
+ if (events.length > 0) {
520
+ formData.append("events", JSON.stringify(events));
521
+ }
501
522
  try {
502
523
  const response = await fetch(url, {
503
524
  method: "POST",
@@ -819,6 +840,71 @@ var ScreenshotUtility = class {
819
840
  }
820
841
  };
821
842
 
843
+ // src/session-timeline.ts
844
+ var MAX_EVENTS_PER_UPLOAD = 1e3;
845
+ var defaultNow = () => typeof performance !== "undefined" && typeof performance.now === "function" ? performance.now() : Date.now();
846
+ var SessionTimeline = class {
847
+ /**
848
+ * @param now Clock in milliseconds; defaults to `performance.now()`
849
+ * @param maxQueued Events kept while waiting for an upload; later ones are dropped
850
+ */
851
+ constructor(now = defaultNow, maxQueued = 5e3) {
852
+ this.startedAt = null;
853
+ this.queue = [];
854
+ this.now = now;
855
+ this.maxQueued = maxQueued;
856
+ }
857
+ /** Starts the clock at 0 and empties the queue. */
858
+ start() {
859
+ this.startedAt = this.now();
860
+ this.queue = [];
861
+ }
862
+ /** Stops the clock and empties the queue. */
863
+ reset() {
864
+ this.startedAt = null;
865
+ this.queue = [];
866
+ }
867
+ get isStarted() {
868
+ return this.startedAt !== null;
869
+ }
870
+ /**
871
+ * Whole milliseconds from `start()` to `at` (default: now); 0 before
872
+ * the start, or for a time earlier than it.
873
+ */
874
+ offsetMs(at) {
875
+ if (this.startedAt === null) return 0;
876
+ const when = typeof at === "number" && Number.isFinite(at) ? at : this.now();
877
+ return Math.max(0, Math.round(when - this.startedAt));
878
+ }
879
+ /**
880
+ * Stamps an event with the current offset and queues it.
881
+ * Returns the queued event, or null when the queue is full.
882
+ *
883
+ * `event` is a `GameEvent` for the universal vocabulary, or the game's
884
+ * own name for a game-specific event (e.g. 'ROTATE_CLOCKWISE'). `data`
885
+ * is merged in; it can't override `event` or `timestamp`. `at` is when
886
+ * it happened, on this timeline's clock (default: now).
887
+ */
888
+ record(event, data = {}, at) {
889
+ if (this.queue.length >= this.maxQueued) return null;
890
+ const entry = { ...data, event, timestamp: this.offsetMs(at) };
891
+ this.queue.push(entry);
892
+ return entry;
893
+ }
894
+ get pendingEvents() {
895
+ return this.queue.length;
896
+ }
897
+ /** Removes and returns the oldest queued events, at most `max` of them. */
898
+ takeEvents(max = MAX_EVENTS_PER_UPLOAD) {
899
+ return this.queue.splice(0, max);
900
+ }
901
+ /** Puts events back at the front of the queue, e.g. after a failed upload. */
902
+ requeue(events) {
903
+ const room = Math.max(0, this.maxQueued - this.queue.length);
904
+ this.queue.unshift(...events.slice(0, room));
905
+ }
906
+ };
907
+
822
908
  // src/url-parameter-extractor.ts
823
909
  var WebGLUrlParameterExtractor = class {
824
910
  /**
@@ -941,6 +1027,8 @@ var _SkillprintManager = class _SkillprintManager {
941
1027
  this.apiClient = null;
942
1028
  this.screenshotUtility = null;
943
1029
  this.screenshotQueue = [];
1030
+ /** The session's clock and its queue of events waiting for an upload. */
1031
+ this.timeline = new SessionTimeline();
944
1032
  this.registeredParameters = /* @__PURE__ */ new Map();
945
1033
  // Timers
946
1034
  this.screenshotCaptureTimer = null;
@@ -960,6 +1048,7 @@ var _SkillprintManager = class _SkillprintManager {
960
1048
  this.apiClient = null;
961
1049
  this.screenshotUtility = null;
962
1050
  this.screenshotQueue = [];
1051
+ this.timeline = new SessionTimeline();
963
1052
  this.registeredParameters = /* @__PURE__ */ new Map();
964
1053
  this.initializeSDK();
965
1054
  _SkillprintManager.instance = this;
@@ -1061,6 +1150,7 @@ var _SkillprintManager = class _SkillprintManager {
1061
1150
  }
1062
1151
  this.currentSessionId = this.generateSessionId();
1063
1152
  this.isSessionActive = true;
1153
+ this.timeline.start();
1064
1154
  this.log(`Starting game session for ${this.config.targetEnvironment} environment.`, "info" /* INFO */);
1065
1155
  const parameterInfos = Array.from(this.registeredParameters.values()).map(
1066
1156
  (p) => new ParameterInfo(
@@ -1089,6 +1179,7 @@ var _SkillprintManager = class _SkillprintManager {
1089
1179
  this.log(`Failed to start Skillprint session: ${message}`, "error" /* ERROR */);
1090
1180
  this.isSessionActive = false;
1091
1181
  this.currentSessionId = null;
1182
+ this.timeline.reset();
1092
1183
  }
1093
1184
  }
1094
1185
  /**
@@ -1109,6 +1200,7 @@ var _SkillprintManager = class _SkillprintManager {
1109
1200
  }
1110
1201
  const sessionId = this.currentSessionId;
1111
1202
  const remaining = this.screenshotQueue;
1203
+ const events = this.timeline.takeEvents(Number.MAX_SAFE_INTEGER);
1112
1204
  const inFlightPost = this.inFlightPost;
1113
1205
  const apiClient = this.apiClient;
1114
1206
  this.log(`Stopping Skillprint session: ${sessionId}`);
@@ -1122,6 +1214,7 @@ var _SkillprintManager = class _SkillprintManager {
1122
1214
  this.screenshotQueue = [];
1123
1215
  this.currentSessionId = null;
1124
1216
  this.inFlightPost = null;
1217
+ this.timeline.reset();
1125
1218
  if (!sessionId || !apiClient) {
1126
1219
  this.log("Skillprint session stopped.");
1127
1220
  return;
@@ -1129,41 +1222,44 @@ var _SkillprintManager = class _SkillprintManager {
1129
1222
  if (inFlightPost) await inFlightPost;
1130
1223
  const batchSize = this.postBatchSize();
1131
1224
  try {
1225
+ while (events.length > MAX_EVENTS_PER_UPLOAD) {
1226
+ await apiClient.postScreenshots(sessionId, [], false, { events: events.splice(0, MAX_EVENTS_PER_UPLOAD) });
1227
+ }
1132
1228
  if (remaining.length === 0) {
1133
- await apiClient.postScreenshots(sessionId, [], true);
1229
+ await apiClient.postScreenshots(sessionId, [], true, { events });
1134
1230
  } else {
1135
1231
  for (let i = 0; i < remaining.length; i += batchSize) {
1136
1232
  const batch = remaining.slice(i, i + batchSize);
1137
1233
  const isLastChunk = i + batchSize >= remaining.length;
1138
- await apiClient.postScreenshots(sessionId, batch, isLastChunk);
1234
+ await apiClient.postScreenshots(sessionId, batch.map((s) => s.blob), isLastChunk, {
1235
+ offsetsMs: batch.map((s) => s.offsetMs),
1236
+ events: isLastChunk ? events : []
1237
+ });
1139
1238
  }
1140
1239
  }
1141
- this.log(`Skillprint session stopped. Flushed ${remaining.length} queued screenshots.`);
1240
+ this.log(`Skillprint session stopped. Flushed ${remaining.length} queued screenshots and ${events.length} events.`);
1142
1241
  } catch (error) {
1143
1242
  const message = error instanceof Error ? error.message : String(error);
1144
1243
  this.log(`Failed to flush screenshots on session stop: ${message}`, "error" /* ERROR */);
1145
1244
  }
1146
1245
  }
1147
1246
  /**
1148
- * Logs a discrete gameplay event for the active session, independent
1149
- * of screenshot capture. Fire-and-forget: a failed telemetry post is
1150
- * logged as a warning rather than thrown, so it can't interrupt
1151
- * gameplay the way an unhandled rejection might.
1247
+ * Logs a discrete gameplay event for the active session: a universal
1248
+ * `GameEvent`, or the game's own name for a game-specific event (e.g.
1249
+ * 'ROTATE_CLOCKWISE'). It is stamped with the milliseconds since the
1250
+ * session started and sent with the next screenshot upload (or on its own
1251
+ * when there is no screenshot to send), and the rest go with the closing
1252
+ * upload on stopGameSession().
1253
+ *
1254
+ * Never throws; with no active session it is ignored with a warning.
1152
1255
  */
1153
1256
  async logEvent(event, data = {}) {
1154
1257
  if (!this.isSessionActive || !this.currentSessionId || !this.apiClient) {
1155
1258
  this.log("logEvent called with no active session. Ignoring.", "warning" /* WARNING */);
1156
1259
  return;
1157
1260
  }
1158
- try {
1159
- await this.apiClient.logTelemetryEvent(
1160
- this.currentSessionId,
1161
- this.config.gameName,
1162
- new TelemetryEventRequest(event, data)
1163
- );
1164
- } catch (error) {
1165
- const message = error instanceof Error ? error.message : String(error);
1166
- this.log(`Failed to log event '${event}': ${message}`, "warning" /* WARNING */);
1261
+ if (!this.timeline.record(event, data)) {
1262
+ this.log(`Event queue full. Dropping '${event}'.`, "warning" /* WARNING */);
1167
1263
  }
1168
1264
  }
1169
1265
  startScreenshotCaptureLoop() {
@@ -1175,11 +1271,12 @@ var _SkillprintManager = class _SkillprintManager {
1175
1271
  return;
1176
1272
  }
1177
1273
  const sessionId = this.currentSessionId;
1274
+ const offsetMs = this.timeline.offsetMs();
1178
1275
  const screenshot = await this.screenshotUtility.captureScreenshot(canvas);
1179
1276
  if (!this.isSessionActive || this.currentSessionId !== sessionId) return;
1180
1277
  if (screenshot) {
1181
1278
  if (this.screenshotQueue.length < 50) {
1182
- this.screenshotQueue.push(screenshot);
1279
+ this.screenshotQueue.push({ blob: screenshot, offsetMs });
1183
1280
  this.log(`Screenshot captured. Queue size: ${this.screenshotQueue.length}`);
1184
1281
  } else {
1185
1282
  this.log("Screenshot queue full. Discarding new screenshot.", "warning" /* WARNING */);
@@ -1189,27 +1286,41 @@ var _SkillprintManager = class _SkillprintManager {
1189
1286
  }
1190
1287
  startScreenshotPostLoop() {
1191
1288
  this.screenshotPostTimer = setInterval(async () => {
1192
- if (!this.isSessionActive || this.screenshotQueue.length === 0 || !this.apiClient || !this.currentSessionId) return;
1193
- const batchToPost = this.screenshotQueue.splice(0, Math.min(this.screenshotQueue.length, this.postBatchSize()));
1194
- if (batchToPost.length > 0) {
1195
- this.log(`Posting ${batchToPost.length} screenshots...`);
1196
- const apiClient = this.apiClient;
1197
- const sessionId = this.currentSessionId;
1198
- const post = (async () => {
1199
- try {
1200
- const result = await apiClient.postScreenshots(sessionId, batchToPost, false);
1201
- this.log(`Successfully posted ${batchToPost.length} screenshots. Response: ${result.data}`);
1202
- } catch (error) {
1203
- const message = error instanceof Error ? error.message : String(error);
1204
- this.log(`Failed to post screenshots: ${message}`, "error" /* ERROR */);
1205
- }
1206
- })();
1207
- this.inFlightPost = post;
1208
- await post;
1209
- if (this.inFlightPost === post) this.inFlightPost = null;
1210
- }
1289
+ if (!this.isSessionActive || !this.apiClient || !this.currentSessionId) return;
1290
+ if (this.screenshotQueue.length === 0 && this.timeline.pendingEvents === 0) return;
1291
+ const batch = this.screenshotQueue.splice(0, Math.min(this.screenshotQueue.length, this.postBatchSize()));
1292
+ const events = this.timeline.takeEvents();
1293
+ const apiClient = this.apiClient;
1294
+ const sessionId = this.currentSessionId;
1295
+ this.log(`Posting ${batch.length} screenshots and ${events.length} events...`);
1296
+ const post = (async () => {
1297
+ try {
1298
+ const result = await apiClient.postScreenshots(sessionId, batch.map((s) => s.blob), false, {
1299
+ offsetsMs: batch.map((s) => s.offsetMs),
1300
+ events
1301
+ });
1302
+ this.log(`Successfully posted ${batch.length} screenshots and ${events.length} events. Response: ${result.data}`);
1303
+ } catch (error) {
1304
+ const message = error instanceof Error ? error.message : String(error);
1305
+ this.log(`Failed to post screenshots: ${message}`, "error" /* ERROR */);
1306
+ this.requeueEvents(events, error, sessionId);
1307
+ }
1308
+ })();
1309
+ this.inFlightPost = post;
1310
+ await post;
1311
+ if (this.inFlightPost === post) this.inFlightPost = null;
1211
1312
  }, this.config.screenshotPostIntervalSeconds * 1e3);
1212
1313
  }
1314
+ /**
1315
+ * Keeps events from an upload that may succeed if tried again (a network
1316
+ * error or a 5xx), so the next upload carries them. A 4xx would fail the
1317
+ * same way again, so those are dropped.
1318
+ */
1319
+ requeueEvents(events, error, sessionId) {
1320
+ if (events.length === 0 || !this.isSessionActive || this.currentSessionId !== sessionId) return;
1321
+ if (error instanceof SkillprintApiError && error.status < 500) return;
1322
+ this.timeline.requeue(events);
1323
+ }
1213
1324
  postBatchSize() {
1214
1325
  return Math.max(
1215
1326
  1,
@@ -1456,6 +1567,7 @@ var GenericCanvasSkillprintAdapter = class _GenericCanvasSkillprintAdapter {
1456
1567
  GameEvent,
1457
1568
  GenericCanvasSkillprintAdapter,
1458
1569
  LogLevel,
1570
+ MAX_EVENTS_PER_UPLOAD,
1459
1571
  Mood,
1460
1572
  ParameterDefinition,
1461
1573
  ParameterInfo,
@@ -1465,6 +1577,7 @@ var GenericCanvasSkillprintAdapter = class _GenericCanvasSkillprintAdapter {
1465
1577
  PixiSkillprintAdapter,
1466
1578
  PollResultsResponse,
1467
1579
  ScreenshotUtility,
1580
+ SessionTimeline,
1468
1581
  SkillprintAPIClient,
1469
1582
  SkillprintApiError,
1470
1583
  SkillprintConfig,
@@ -1473,6 +1586,7 @@ var GenericCanvasSkillprintAdapter = class _GenericCanvasSkillprintAdapter {
1473
1586
  StartSessionRequest,
1474
1587
  TelemetryEventRequest,
1475
1588
  ThreeSkillprintAdapter,
1476
- WebGLUrlParameterExtractor
1589
+ WebGLUrlParameterExtractor,
1590
+ isUniversalEvent
1477
1591
  });
1478
1592
  //# sourceMappingURL=index.cjs.map