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

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 each screenshot upload carries the ones logged up to its last screenshot. Later events wait for the upload with the screenshots after them, and `stopGameSession()` sends the rest with the closing upload. The SDK decides which events go with which upload; the server keeps each upload's events with its screenshots and does no time arithmetic. If no screenshots come for a long while, events only go on their own once more than one upload's worth (1000) has piled up. `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 logged up to the upload's last screenshot (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.eventsForUpload(offset) // null for the closing upload
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 (options.events !== void 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,106 @@ 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.recordedAny = false;
855
+ this.now = now;
856
+ this.maxQueued = maxQueued;
857
+ }
858
+ /** Starts the clock at 0 and empties the queue. */
859
+ start() {
860
+ this.startedAt = this.now();
861
+ this.queue = [];
862
+ this.recordedAny = false;
863
+ }
864
+ /** Stops the clock and empties the queue. */
865
+ reset() {
866
+ this.startedAt = null;
867
+ this.queue = [];
868
+ this.recordedAny = false;
869
+ }
870
+ get isStarted() {
871
+ return this.startedAt !== null;
872
+ }
873
+ /**
874
+ * Whole milliseconds from `start()` to `at` (default: now); 0 before
875
+ * the start, or for a time earlier than it.
876
+ */
877
+ offsetMs(at) {
878
+ if (this.startedAt === null) return 0;
879
+ const when = typeof at === "number" && Number.isFinite(at) ? at : this.now();
880
+ return Math.max(0, Math.round(when - this.startedAt));
881
+ }
882
+ /**
883
+ * Stamps an event with the current offset and queues it.
884
+ * Returns the queued event, or null when the queue is full.
885
+ *
886
+ * `event` is a `GameEvent` for the universal vocabulary, or the game's
887
+ * own name for a game-specific event (e.g. 'ROTATE_CLOCKWISE'). `data`
888
+ * is merged in; it can't override `event` or `timestamp`. `at` is when
889
+ * it happened, on this timeline's clock (default: now).
890
+ */
891
+ record(event, data = {}, at) {
892
+ if (this.queue.length >= this.maxQueued) return null;
893
+ const entry = { ...data, event, timestamp: this.offsetMs(at) };
894
+ this.queue.push(entry);
895
+ this.recordedAny = true;
896
+ return entry;
897
+ }
898
+ get pendingEvents() {
899
+ return this.queue.length;
900
+ }
901
+ /** True once this session has recorded any event. */
902
+ get hasRecorded() {
903
+ return this.recordedAny;
904
+ }
905
+ /** Removes and returns the earliest queued events, at most `max`, in session-time order. */
906
+ takeEvents(max = MAX_EVENTS_PER_UPLOAD) {
907
+ this.sortQueue();
908
+ return this.queue.splice(0, max);
909
+ }
910
+ /**
911
+ * Removes and returns the queued events logged up to `offsetMs`
912
+ * (inclusive), at most `max`, in session-time order. Later ones stay
913
+ * queued for the upload that carries the screenshots after them.
914
+ */
915
+ takeEventsThrough(offsetMs, max = MAX_EVENTS_PER_UPLOAD) {
916
+ this.sortQueue();
917
+ let n = 0;
918
+ while (n < this.queue.length && n < max && this.queue[n].timestamp <= offsetMs) n++;
919
+ return this.queue.splice(0, n);
920
+ }
921
+ /**
922
+ * The `events` for an upload whose last screenshot was taken at
923
+ * `lastFrameOffsetMs`: the events logged up to then, or every queued
924
+ * event for the closing upload (pass null). An empty list once the
925
+ * session has recorded any event, so the server can tell a quiet stretch
926
+ * from a game that doesn't log events; undefined before that.
927
+ */
928
+ eventsForUpload(lastFrameOffsetMs) {
929
+ if (!this.recordedAny) return void 0;
930
+ return lastFrameOffsetMs === null ? this.takeEvents() : this.takeEventsThrough(lastFrameOffsetMs);
931
+ }
932
+ // Events stamped with `at` can arrive out of order; uploads send them in order.
933
+ sortQueue() {
934
+ this.queue.sort((a, b) => a.timestamp - b.timestamp);
935
+ }
936
+ /** Puts events back at the front of the queue, e.g. after a failed upload. */
937
+ requeue(events) {
938
+ const room = Math.max(0, this.maxQueued - this.queue.length);
939
+ this.queue.unshift(...events.slice(0, room));
940
+ }
941
+ };
942
+
822
943
  // src/url-parameter-extractor.ts
823
944
  var WebGLUrlParameterExtractor = class {
824
945
  /**
@@ -941,6 +1062,8 @@ var _SkillprintManager = class _SkillprintManager {
941
1062
  this.apiClient = null;
942
1063
  this.screenshotUtility = null;
943
1064
  this.screenshotQueue = [];
1065
+ /** The session's clock and its queue of events waiting for an upload. */
1066
+ this.timeline = new SessionTimeline();
944
1067
  this.registeredParameters = /* @__PURE__ */ new Map();
945
1068
  // Timers
946
1069
  this.screenshotCaptureTimer = null;
@@ -960,6 +1083,7 @@ var _SkillprintManager = class _SkillprintManager {
960
1083
  this.apiClient = null;
961
1084
  this.screenshotUtility = null;
962
1085
  this.screenshotQueue = [];
1086
+ this.timeline = new SessionTimeline();
963
1087
  this.registeredParameters = /* @__PURE__ */ new Map();
964
1088
  this.initializeSDK();
965
1089
  _SkillprintManager.instance = this;
@@ -1061,6 +1185,7 @@ var _SkillprintManager = class _SkillprintManager {
1061
1185
  }
1062
1186
  this.currentSessionId = this.generateSessionId();
1063
1187
  this.isSessionActive = true;
1188
+ this.timeline.start();
1064
1189
  this.log(`Starting game session for ${this.config.targetEnvironment} environment.`, "info" /* INFO */);
1065
1190
  const parameterInfos = Array.from(this.registeredParameters.values()).map(
1066
1191
  (p) => new ParameterInfo(
@@ -1089,6 +1214,7 @@ var _SkillprintManager = class _SkillprintManager {
1089
1214
  this.log(`Failed to start Skillprint session: ${message}`, "error" /* ERROR */);
1090
1215
  this.isSessionActive = false;
1091
1216
  this.currentSessionId = null;
1217
+ this.timeline.reset();
1092
1218
  }
1093
1219
  }
1094
1220
  /**
@@ -1109,6 +1235,8 @@ var _SkillprintManager = class _SkillprintManager {
1109
1235
  }
1110
1236
  const sessionId = this.currentSessionId;
1111
1237
  const remaining = this.screenshotQueue;
1238
+ const recorded = this.timeline.hasRecorded;
1239
+ const events = this.timeline.takeEvents(Number.MAX_SAFE_INTEGER);
1112
1240
  const inFlightPost = this.inFlightPost;
1113
1241
  const apiClient = this.apiClient;
1114
1242
  this.log(`Stopping Skillprint session: ${sessionId}`);
@@ -1122,6 +1250,7 @@ var _SkillprintManager = class _SkillprintManager {
1122
1250
  this.screenshotQueue = [];
1123
1251
  this.currentSessionId = null;
1124
1252
  this.inFlightPost = null;
1253
+ this.timeline.reset();
1125
1254
  if (!sessionId || !apiClient) {
1126
1255
  this.log("Skillprint session stopped.");
1127
1256
  return;
@@ -1129,41 +1258,56 @@ var _SkillprintManager = class _SkillprintManager {
1129
1258
  if (inFlightPost) await inFlightPost;
1130
1259
  const batchSize = this.postBatchSize();
1131
1260
  try {
1261
+ const eventsUpTo = (lastFrameOffsetMs) => {
1262
+ if (!recorded) return void 0;
1263
+ const n = lastFrameOffsetMs === null ? events.length : events.findIndex((e) => e.timestamp > lastFrameOffsetMs);
1264
+ return events.splice(0, Math.min(n === -1 ? events.length : n, MAX_EVENTS_PER_UPLOAD));
1265
+ };
1132
1266
  if (remaining.length === 0) {
1133
- await apiClient.postScreenshots(sessionId, [], true);
1267
+ while (events.length > MAX_EVENTS_PER_UPLOAD) {
1268
+ await apiClient.postScreenshots(sessionId, [], false, { events: events.splice(0, MAX_EVENTS_PER_UPLOAD) });
1269
+ }
1270
+ await apiClient.postScreenshots(sessionId, [], true, { events: eventsUpTo(null) });
1134
1271
  } else {
1135
1272
  for (let i = 0; i < remaining.length; i += batchSize) {
1136
1273
  const batch = remaining.slice(i, i + batchSize);
1137
1274
  const isLastChunk = i + batchSize >= remaining.length;
1138
- await apiClient.postScreenshots(sessionId, batch, isLastChunk);
1275
+ if (isLastChunk) {
1276
+ while (events.length > MAX_EVENTS_PER_UPLOAD) {
1277
+ await apiClient.postScreenshots(sessionId, [], false, { events: events.splice(0, MAX_EVENTS_PER_UPLOAD) });
1278
+ }
1279
+ }
1280
+ const lastOffset = Math.max(...batch.map((s) => s.offsetMs));
1281
+ await apiClient.postScreenshots(sessionId, batch.map((s) => s.blob), isLastChunk, {
1282
+ offsetsMs: batch.map((s) => s.offsetMs),
1283
+ events: eventsUpTo(isLastChunk ? null : lastOffset)
1284
+ });
1139
1285
  }
1140
1286
  }
1141
- this.log(`Skillprint session stopped. Flushed ${remaining.length} queued screenshots.`);
1287
+ const flushed = remaining.length;
1288
+ this.log(`Skillprint session stopped. Flushed ${flushed} queued screenshots.`);
1142
1289
  } catch (error) {
1143
1290
  const message = error instanceof Error ? error.message : String(error);
1144
1291
  this.log(`Failed to flush screenshots on session stop: ${message}`, "error" /* ERROR */);
1145
1292
  }
1146
1293
  }
1147
1294
  /**
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.
1295
+ * Logs a discrete gameplay event for the active session: a universal
1296
+ * `GameEvent`, or the game's own name for a game-specific event (e.g.
1297
+ * 'ROTATE_CLOCKWISE'). It is stamped with the milliseconds since the
1298
+ * session started and sent with the next screenshot upload (or on its own
1299
+ * when there is no screenshot to send), and the rest go with the closing
1300
+ * upload on stopGameSession().
1301
+ *
1302
+ * Never throws; with no active session it is ignored with a warning.
1152
1303
  */
1153
1304
  async logEvent(event, data = {}) {
1154
1305
  if (!this.isSessionActive || !this.currentSessionId || !this.apiClient) {
1155
1306
  this.log("logEvent called with no active session. Ignoring.", "warning" /* WARNING */);
1156
1307
  return;
1157
1308
  }
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 */);
1309
+ if (!this.timeline.record(event, data)) {
1310
+ this.log(`Event queue full. Dropping '${event}'.`, "warning" /* WARNING */);
1167
1311
  }
1168
1312
  }
1169
1313
  startScreenshotCaptureLoop() {
@@ -1175,11 +1319,12 @@ var _SkillprintManager = class _SkillprintManager {
1175
1319
  return;
1176
1320
  }
1177
1321
  const sessionId = this.currentSessionId;
1322
+ const offsetMs = this.timeline.offsetMs();
1178
1323
  const screenshot = await this.screenshotUtility.captureScreenshot(canvas);
1179
1324
  if (!this.isSessionActive || this.currentSessionId !== sessionId) return;
1180
1325
  if (screenshot) {
1181
1326
  if (this.screenshotQueue.length < 50) {
1182
- this.screenshotQueue.push(screenshot);
1327
+ this.screenshotQueue.push({ blob: screenshot, offsetMs });
1183
1328
  this.log(`Screenshot captured. Queue size: ${this.screenshotQueue.length}`);
1184
1329
  } else {
1185
1330
  this.log("Screenshot queue full. Discarding new screenshot.", "warning" /* WARNING */);
@@ -1189,27 +1334,41 @@ var _SkillprintManager = class _SkillprintManager {
1189
1334
  }
1190
1335
  startScreenshotPostLoop() {
1191
1336
  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
- }
1337
+ if (!this.isSessionActive || !this.apiClient || !this.currentSessionId) return;
1338
+ if (this.screenshotQueue.length === 0 && this.timeline.pendingEvents <= MAX_EVENTS_PER_UPLOAD) return;
1339
+ const batch = this.screenshotQueue.splice(0, Math.min(this.screenshotQueue.length, this.postBatchSize()));
1340
+ const events = batch.length > 0 ? this.timeline.eventsForUpload(Math.max(...batch.map((s) => s.offsetMs))) : this.timeline.takeEvents();
1341
+ const apiClient = this.apiClient;
1342
+ const sessionId = this.currentSessionId;
1343
+ this.log(`Posting ${batch.length} screenshots and ${events?.length ?? 0} events...`);
1344
+ const post = (async () => {
1345
+ try {
1346
+ const result = await apiClient.postScreenshots(sessionId, batch.map((s) => s.blob), false, {
1347
+ offsetsMs: batch.map((s) => s.offsetMs),
1348
+ events
1349
+ });
1350
+ this.log(`Successfully posted ${batch.length} screenshots and ${events?.length ?? 0} events. Response: ${result.data}`);
1351
+ } catch (error) {
1352
+ const message = error instanceof Error ? error.message : String(error);
1353
+ this.log(`Failed to post screenshots: ${message}`, "error" /* ERROR */);
1354
+ this.requeueEvents(events ?? [], error, sessionId);
1355
+ }
1356
+ })();
1357
+ this.inFlightPost = post;
1358
+ await post;
1359
+ if (this.inFlightPost === post) this.inFlightPost = null;
1211
1360
  }, this.config.screenshotPostIntervalSeconds * 1e3);
1212
1361
  }
1362
+ /**
1363
+ * Keeps events from an upload that may succeed if tried again (a network
1364
+ * error or a 5xx), so the next upload carries them. A 4xx would fail the
1365
+ * same way again, so those are dropped.
1366
+ */
1367
+ requeueEvents(events, error, sessionId) {
1368
+ if (events.length === 0 || !this.isSessionActive || this.currentSessionId !== sessionId) return;
1369
+ if (error instanceof SkillprintApiError && error.status < 500) return;
1370
+ this.timeline.requeue(events);
1371
+ }
1213
1372
  postBatchSize() {
1214
1373
  return Math.max(
1215
1374
  1,
@@ -1456,6 +1615,7 @@ var GenericCanvasSkillprintAdapter = class _GenericCanvasSkillprintAdapter {
1456
1615
  GameEvent,
1457
1616
  GenericCanvasSkillprintAdapter,
1458
1617
  LogLevel,
1618
+ MAX_EVENTS_PER_UPLOAD,
1459
1619
  Mood,
1460
1620
  ParameterDefinition,
1461
1621
  ParameterInfo,
@@ -1465,6 +1625,7 @@ var GenericCanvasSkillprintAdapter = class _GenericCanvasSkillprintAdapter {
1465
1625
  PixiSkillprintAdapter,
1466
1626
  PollResultsResponse,
1467
1627
  ScreenshotUtility,
1628
+ SessionTimeline,
1468
1629
  SkillprintAPIClient,
1469
1630
  SkillprintApiError,
1470
1631
  SkillprintConfig,
@@ -1473,6 +1634,7 @@ var GenericCanvasSkillprintAdapter = class _GenericCanvasSkillprintAdapter {
1473
1634
  StartSessionRequest,
1474
1635
  TelemetryEventRequest,
1475
1636
  ThreeSkillprintAdapter,
1476
- WebGLUrlParameterExtractor
1637
+ WebGLUrlParameterExtractor,
1638
+ isUniversalEvent
1477
1639
  });
1478
1640
  //# sourceMappingURL=index.cjs.map