ravensight-playtest 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +380 -0
- package/addons/ravensight_driver/driver.gd +836 -0
- package/addons/ravensight_driver/export_plugin.gd +51 -0
- package/addons/ravensight_driver/plugin.cfg +7 -0
- package/addons/ravensight_driver/plugin.gd +36 -0
- package/bin/ravensight-playtest.js +31 -0
- package/package.json +45 -0
- package/src/api/README.md +500 -0
- package/src/api/client.js +340 -0
- package/src/api/errors.js +115 -0
- package/src/api/http.js +194 -0
- package/src/api/index.js +107 -0
- package/src/auth/deviceCode.js +79 -0
- package/src/auth/keychain.js +159 -0
- package/src/auth/session.js +128 -0
- package/src/cli.js +335 -0
- package/src/commands/brief.js +303 -0
- package/src/commands/check.js +318 -0
- package/src/commands/fakeCore.js +379 -0
- package/src/commands/init.js +120 -0
- package/src/commands/login.js +90 -0
- package/src/commands/logout.js +70 -0
- package/src/commands/open.js +125 -0
- package/src/commands/profile.js +262 -0
- package/src/commands/resume.js +156 -0
- package/src/commands/run.js +1015 -0
- package/src/commands/upload.js +137 -0
- package/src/config.js +100 -0
- package/src/dashboard.js +97 -0
- package/src/detect.js +77 -0
- package/src/errors.js +44 -0
- package/src/fsutil.js +77 -0
- package/src/godot.js +85 -0
- package/src/packs/index.js +191 -0
- package/src/paths.js +129 -0
- package/src/run/aggregate.js +658 -0
- package/src/run/args.js +111 -0
- package/src/run/context.js +181 -0
- package/src/run/deps.js +184 -0
- package/src/run/drivers/driver.js +183 -0
- package/src/run/drivers/godot-observation.js +138 -0
- package/src/run/drivers/godot-project.js +475 -0
- package/src/run/drivers/godot-rpc.js +225 -0
- package/src/run/drivers/godot.js +587 -0
- package/src/run/drivers/index.js +52 -0
- package/src/run/drivers/web.js +385 -0
- package/src/run/exit.js +21 -0
- package/src/run/heartbeat.js +131 -0
- package/src/run/index.js +31 -0
- package/src/run/json.js +56 -0
- package/src/run/model.js +384 -0
- package/src/run/paths.js +88 -0
- package/src/run/personaLoop.js +871 -0
- package/src/run/profile.js +214 -0
- package/src/run/regenerate.js +149 -0
- package/src/run/repoTools.js +286 -0
- package/src/run/report.js +222 -0
- package/src/run/resume.js +272 -0
- package/src/run/secretScan.js +171 -0
- package/src/run/state.js +198 -0
- package/src/run/synthetic.js +206 -0
- package/src/run/tools.js +344 -0
- package/src/run/transcript.js +93 -0
- package/src/run/usage.js +115 -0
- package/src/state/index.js +105 -0
- package/src/states.js +104 -0
- package/src/ui/index.js +195 -0
- package/src/upload/allowlist.js +116 -0
- package/src/upload/index.js +467 -0
- package/src/upload/queue.js +114 -0
- package/src/version.js +63 -0
package/src/run/state.js
ADDED
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import { mkdir, readFile, rename, writeFile } from 'node:fs/promises';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { jobDir, JOB_FILES } from './paths.js';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* `state.json`, the resumable job journal (spec 17 section 1.8). Written
|
|
8
|
+
* atomically after every step, so a Ctrl-C between two writes leaves the
|
|
9
|
+
* last complete journal on disk rather than half of the next one.
|
|
10
|
+
*
|
|
11
|
+
* What is NOT in here, deliberately:
|
|
12
|
+
*
|
|
13
|
+
* - **No playtest tokens.** They are JWTs, and a JWT written into a file
|
|
14
|
+
* under the developer's repo is both a secret on disk and something this
|
|
15
|
+
* CLI's own secret scan would refuse. `resume` gets tokens back by
|
|
16
|
+
* replaying `POST /jobs` with `idempotency_key`: the server answers a
|
|
17
|
+
* known `{gameId, idempotencyKey}` with the stored job and *freshly
|
|
18
|
+
* minted* tokens, which is exactly why replay mints instead of returning
|
|
19
|
+
* what it stored.
|
|
20
|
+
* - **No money.** `price_cents` is recorded for display, but what was
|
|
21
|
+
* charged and what gets refunded is the server's ledger. Resume never
|
|
22
|
+
* re-charges, because it replays the same idempotency key.
|
|
23
|
+
*
|
|
24
|
+
* The shape is shared with cli-core's `resume` command, which reads this
|
|
25
|
+
* file. Bump `SCHEMA_VERSION` if a field changes meaning; `load` refuses a
|
|
26
|
+
* journal from a newer CLI rather than guessing.
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Matches cli-core's `STATE_VERSION`, and the field is spelled the same
|
|
31
|
+
* (`state_version`), because cli-core's `resume` and `listStates` read this
|
|
32
|
+
* very file. Two version fields on one document would be two answers to one
|
|
33
|
+
* question.
|
|
34
|
+
*/
|
|
35
|
+
export const SCHEMA_VERSION = 1;
|
|
36
|
+
|
|
37
|
+
/** Run states this journal tracks, a subset of the server's PlaytestRun states. */
|
|
38
|
+
export const RUN_DONE_STATES = Object.freeze(['succeeded', 'failed', 'canceled', 'budget_exceeded', 'skipped']);
|
|
39
|
+
|
|
40
|
+
export class StateVersionError extends Error {
|
|
41
|
+
constructor(found) {
|
|
42
|
+
super(`This job was written by a newer ravensight-playtest (state schema ${found}, this build understands ${SCHEMA_VERSION}). Upgrade the CLI and resume again.`);
|
|
43
|
+
this.name = 'StateVersionError';
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* @param {string} repoRoot
|
|
49
|
+
* @param {string} jobId
|
|
50
|
+
*/
|
|
51
|
+
export function stateFile(repoRoot, jobId) {
|
|
52
|
+
return path.join(jobDir(repoRoot, jobId), JOB_FILES.state);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* A fresh journal for a job that has just been registered.
|
|
57
|
+
*
|
|
58
|
+
* @param {Object} init
|
|
59
|
+
* @returns {Object}
|
|
60
|
+
*/
|
|
61
|
+
export function newState(init) {
|
|
62
|
+
const now = new Date().toISOString();
|
|
63
|
+
return {
|
|
64
|
+
state_version: SCHEMA_VERSION,
|
|
65
|
+
job_id: init.jobId,
|
|
66
|
+
game_id: init.gameId,
|
|
67
|
+
idempotency_key: init.idempotencyKey,
|
|
68
|
+
cli_version: init.cliVersion || null,
|
|
69
|
+
pack_version: init.packVersion || null,
|
|
70
|
+
brief_version: init.briefVersion ?? null,
|
|
71
|
+
modules: init.modules || [],
|
|
72
|
+
driver: init.driver,
|
|
73
|
+
target: init.target,
|
|
74
|
+
options: init.options || {},
|
|
75
|
+
price_cents: init.priceCents ?? null,
|
|
76
|
+
created_at: now,
|
|
77
|
+
updated_at: now,
|
|
78
|
+
finished: false,
|
|
79
|
+
aggregate: { state: 'pending' },
|
|
80
|
+
runs: {}
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* One run's journal entry. `state` mirrors the server's run state so
|
|
86
|
+
* `resume` can tell a finished run from one that has to start over.
|
|
87
|
+
*
|
|
88
|
+
* @param {Object} init
|
|
89
|
+
* @returns {Object}
|
|
90
|
+
*/
|
|
91
|
+
export function newRunEntry(init) {
|
|
92
|
+
return {
|
|
93
|
+
run_id: init.runId,
|
|
94
|
+
module: init.module,
|
|
95
|
+
persona: init.persona || null,
|
|
96
|
+
state: init.state || 'queued',
|
|
97
|
+
actions_taken: 0,
|
|
98
|
+
checkpoint_step: 0,
|
|
99
|
+
quit_reason: null,
|
|
100
|
+
report_written: false,
|
|
101
|
+
completed: false,
|
|
102
|
+
uploaded: false,
|
|
103
|
+
budget_warned: false,
|
|
104
|
+
error: null
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Serialises writes per journal file. Personas run in parallel, so two runs
|
|
110
|
+
* of one job finish a step at the same moment and both save; without this
|
|
111
|
+
* they interleave, and with a shared temp file name they race each other's
|
|
112
|
+
* rename outright (which is a real ENOENT, not a theoretical one).
|
|
113
|
+
*
|
|
114
|
+
* @type {Map<string, Promise<any>>}
|
|
115
|
+
*/
|
|
116
|
+
const writeQueues = new Map();
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Atomic write: a uniquely named temp file in the same directory, then a
|
|
120
|
+
* rename. Rename inside one directory is atomic on every platform this CLI
|
|
121
|
+
* supports, so a reader never sees a partial journal.
|
|
122
|
+
*
|
|
123
|
+
* @param {string} repoRoot
|
|
124
|
+
* @param {Object} state
|
|
125
|
+
*/
|
|
126
|
+
export async function save(repoRoot, state) {
|
|
127
|
+
const target = stateFile(repoRoot, state.job_id);
|
|
128
|
+
const previous = writeQueues.get(target) || Promise.resolve();
|
|
129
|
+
const next = previous.then(() => writeOnce(target, state), () => writeOnce(target, state));
|
|
130
|
+
writeQueues.set(target, next);
|
|
131
|
+
try {
|
|
132
|
+
return await next;
|
|
133
|
+
} finally {
|
|
134
|
+
if (writeQueues.get(target) === next) writeQueues.delete(target);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
async function writeOnce(target, state) {
|
|
139
|
+
await mkdir(path.dirname(target), { recursive: true });
|
|
140
|
+
state.updated_at = new Date().toISOString();
|
|
141
|
+
const body = `${JSON.stringify(state, null, 2)}\n`;
|
|
142
|
+
const tmp = `${target}.${process.pid}.${randomUUID()}.tmp`;
|
|
143
|
+
await writeFile(tmp, body, 'utf8');
|
|
144
|
+
await rename(tmp, target);
|
|
145
|
+
return state;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* @param {string} repoRoot
|
|
150
|
+
* @param {string} jobId
|
|
151
|
+
* @returns {Promise<Object|null>} null when there is no journal
|
|
152
|
+
*/
|
|
153
|
+
export async function load(repoRoot, jobId) {
|
|
154
|
+
let text;
|
|
155
|
+
try {
|
|
156
|
+
text = await readFile(stateFile(repoRoot, jobId), 'utf8');
|
|
157
|
+
} catch (error) {
|
|
158
|
+
if (error.code === 'ENOENT') return null;
|
|
159
|
+
throw error;
|
|
160
|
+
}
|
|
161
|
+
const state = JSON.parse(text);
|
|
162
|
+
const version = Number(state.state_version ?? state.schema_version ?? SCHEMA_VERSION);
|
|
163
|
+
if (version > SCHEMA_VERSION) throw new StateVersionError(version);
|
|
164
|
+
return state;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Record a change to one run and flush the journal. Every caller goes
|
|
169
|
+
* through here rather than mutating and saving separately, so the journal
|
|
170
|
+
* on disk is never behind the run it describes.
|
|
171
|
+
*
|
|
172
|
+
* @param {string} repoRoot
|
|
173
|
+
* @param {Object} state
|
|
174
|
+
* @param {string} runId
|
|
175
|
+
* @param {Object} patch
|
|
176
|
+
*/
|
|
177
|
+
export async function patchRun(repoRoot, state, runId, patch) {
|
|
178
|
+
const entry = state.runs[runId];
|
|
179
|
+
if (!entry) throw new Error(`state.json has no run ${runId}`);
|
|
180
|
+
Object.assign(entry, patch);
|
|
181
|
+
await save(repoRoot, state);
|
|
182
|
+
return entry;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Whether this run is finished as far as the journal knows. A resume keeps
|
|
187
|
+
* these and restarts everything else from the beginning: live game state
|
|
188
|
+
* cannot be restored, so a half-played persona run is worth less than a
|
|
189
|
+
* clean one (spec 17 section 1.8).
|
|
190
|
+
*
|
|
191
|
+
* @param {Object} entry
|
|
192
|
+
* @returns {boolean}
|
|
193
|
+
*/
|
|
194
|
+
export function isRunDone(entry) {
|
|
195
|
+
return Boolean(entry) && (entry.completed === true || RUN_DONE_STATES.includes(entry.state));
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
export default { SCHEMA_VERSION, newState, newRunEntry, save, load, patchRun, isRunDone, stateFile };
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Synthetic telemetry for a playtest run (spec 08), so the dashboard can
|
|
3
|
+
* show AI personas beside real players.
|
|
4
|
+
*
|
|
5
|
+
* This is the runner side emitter, and it is a fallback: a game that has the
|
|
6
|
+
* Ravensight SDK emits its own events from the same session, tagged the same
|
|
7
|
+
* way, because the driver passes `RAVENSIGHT_PLAYTEST_TOKEN`,
|
|
8
|
+
* `RAVENSIGHT_PLAYTEST_RUN_ID`, `RAVENSIGHT_PLAYTEST_JOB_ID` and
|
|
9
|
+
* `RAVENSIGHT_PLAYTEST_PERSONA` into the game's environment and
|
|
10
|
+
* `Ravensight.gd` reads them at boot. This module covers the builds that do
|
|
11
|
+
* not have the SDK at all, which is most web builds on the first run.
|
|
12
|
+
*
|
|
13
|
+
* Two things changed from the PoC's `synthetic_events.ts`:
|
|
14
|
+
*
|
|
15
|
+
* 1. **The playtest token, not just an ingest key.** `POST /api/v1/session`
|
|
16
|
+
* takes the ingest key for game identity (`authenticateIngestKey`, the
|
|
17
|
+
* game is never read from the body) and the run's scoped playtest JWT in
|
|
18
|
+
* `X-Ravensight-Playtest`. The token is what marks the session synthetic:
|
|
19
|
+
* the server forces `deviceId` to `pt-<runId>` and `platform` to
|
|
20
|
+
* `ravensight-playtest` whatever the body says, so two runs of one job
|
|
21
|
+
* can never merge into one "player" and a playtest can never take over a
|
|
22
|
+
* real device id that already has history.
|
|
23
|
+
* 2. **Synthetic events cost the customer nothing.** `reserveQuota` returns
|
|
24
|
+
* immediately for a synthetic session, so no `Usage` or `AccountUsage`
|
|
25
|
+
* document is written. They also ride a separate rate limit bucket
|
|
26
|
+
* (3000/min) so a running pack cannot 429 the game's real players.
|
|
27
|
+
*
|
|
28
|
+
* Failure here never fails a run. The report is the product; telemetry is a
|
|
29
|
+
* nicety, and a dropped batch is not worth throwing away a playthrough the
|
|
30
|
+
* developer already paid for.
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
/** Documented maximum events per `/track/batch` call. */
|
|
34
|
+
export const MAX_BATCH = 50;
|
|
35
|
+
|
|
36
|
+
/** Documented maximum bytes of `data` per event. */
|
|
37
|
+
export const MAX_DATA_BYTES = 8000;
|
|
38
|
+
|
|
39
|
+
/** How long to wait out a 429 before requeuing the batch. */
|
|
40
|
+
export const BACKOFF_MS = 5000;
|
|
41
|
+
|
|
42
|
+
export class SyntheticSession {
|
|
43
|
+
/**
|
|
44
|
+
* @param {Object} args
|
|
45
|
+
* @param {string} args.apiUrl
|
|
46
|
+
* @param {string} args.ingestKey the game's gt_live_ key
|
|
47
|
+
* @param {string} args.playtestToken the run's scoped token
|
|
48
|
+
* @param {{jobId: string, runId: string, persona: string, gameVersion: string}} args.context
|
|
49
|
+
* @param {Function} [args.fetchImpl] test seam
|
|
50
|
+
* @param {Function} [args.sleep] test seam
|
|
51
|
+
*/
|
|
52
|
+
constructor({ apiUrl, ingestKey, playtestToken, context, fetchImpl = fetch, sleep }) {
|
|
53
|
+
this.apiUrl = String(apiUrl).replace(/\/+$/, '');
|
|
54
|
+
this.ingestKey = ingestKey;
|
|
55
|
+
this.playtestToken = playtestToken;
|
|
56
|
+
this.context = context;
|
|
57
|
+
this.fetchImpl = fetchImpl;
|
|
58
|
+
this.sleep = sleep || (ms => new Promise(resolve => setTimeout(resolve, ms)));
|
|
59
|
+
this.sessionToken = null;
|
|
60
|
+
this.queue = [];
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* The tags every synthetic event carries. The server merges the same set
|
|
65
|
+
* server side from the verified token, so these are belt and braces rather
|
|
66
|
+
* than the source of truth.
|
|
67
|
+
*/
|
|
68
|
+
tags() {
|
|
69
|
+
return {
|
|
70
|
+
synthetic: true,
|
|
71
|
+
persona: this.context.persona,
|
|
72
|
+
pt_job: this.context.jobId,
|
|
73
|
+
pt_run: this.context.runId
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* @returns {Promise<boolean>} false when tracking is switched off for this
|
|
79
|
+
* game, in which case nothing is sent
|
|
80
|
+
*/
|
|
81
|
+
async open() {
|
|
82
|
+
const settings = await this.fetchImpl(`${this.apiUrl}/api/v1/settings`, {
|
|
83
|
+
headers: { 'X-API-Key': this.ingestKey }
|
|
84
|
+
});
|
|
85
|
+
if (settings.ok) {
|
|
86
|
+
const body = await settings.json();
|
|
87
|
+
// The kill switch. A game whose owner switched tracking off does not
|
|
88
|
+
// get synthetic events pushed into it either.
|
|
89
|
+
if (body && body.trackingEnabled === false) return false;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const response = await this.fetchImpl(`${this.apiUrl}/api/v1/session`, {
|
|
93
|
+
method: 'POST',
|
|
94
|
+
headers: {
|
|
95
|
+
'X-API-Key': this.ingestKey,
|
|
96
|
+
'X-Ravensight-Playtest': `Bearer ${this.playtestToken}`,
|
|
97
|
+
'Content-Type': 'application/json'
|
|
98
|
+
},
|
|
99
|
+
body: JSON.stringify({ gameVersion: this.context.gameVersion })
|
|
100
|
+
});
|
|
101
|
+
if (response.status !== 201) {
|
|
102
|
+
throw new Error(`synthetic session open failed: ${response.status}`);
|
|
103
|
+
}
|
|
104
|
+
const body = await response.json();
|
|
105
|
+
this.sessionToken = body.token;
|
|
106
|
+
return true;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* @param {string} event
|
|
111
|
+
* @param {Object} [data]
|
|
112
|
+
*/
|
|
113
|
+
track(event, data = {}) {
|
|
114
|
+
const merged = { ...data, ...this.tags() };
|
|
115
|
+
if (Buffer.byteLength(JSON.stringify(merged), 'utf8') > MAX_DATA_BYTES) {
|
|
116
|
+
throw new Error(`synthetic event ${event} data exceeds ${MAX_DATA_BYTES} bytes`);
|
|
117
|
+
}
|
|
118
|
+
this.queue.push({
|
|
119
|
+
event: String(event).slice(0, 200),
|
|
120
|
+
data: merged,
|
|
121
|
+
timestamp: Math.floor(Date.now() / 1000)
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* @returns {Promise<number>} events the server accepted
|
|
127
|
+
*/
|
|
128
|
+
async flush() {
|
|
129
|
+
if (!this.sessionToken) throw new Error('synthetic session is not open');
|
|
130
|
+
let accepted = 0;
|
|
131
|
+
let retries = 0;
|
|
132
|
+
while (this.queue.length > 0) {
|
|
133
|
+
const batch = this.queue.splice(0, MAX_BATCH);
|
|
134
|
+
const response = await this.fetchImpl(`${this.apiUrl}/api/v1/track/batch`, {
|
|
135
|
+
method: 'POST',
|
|
136
|
+
headers: { 'X-Session-Token': this.sessionToken, 'Content-Type': 'application/json' },
|
|
137
|
+
body: JSON.stringify({ events: batch })
|
|
138
|
+
});
|
|
139
|
+
if (response.status === 429) {
|
|
140
|
+
this.queue.unshift(...batch);
|
|
141
|
+
retries += 1;
|
|
142
|
+
// Three tries and then give up rather than looping forever: the
|
|
143
|
+
// playtest bucket is 3000 events a minute, so hitting it repeatedly
|
|
144
|
+
// means something else is wrong.
|
|
145
|
+
if (retries > 3) return accepted;
|
|
146
|
+
await this.sleep(BACKOFF_MS);
|
|
147
|
+
continue;
|
|
148
|
+
}
|
|
149
|
+
if (response.status !== 202) throw new Error(`track/batch failed: ${response.status}`);
|
|
150
|
+
const body = await response.json();
|
|
151
|
+
accepted += Number(body && body.accepted) || 0;
|
|
152
|
+
}
|
|
153
|
+
return accepted;
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Emit the three events a run produces, best effort.
|
|
159
|
+
*
|
|
160
|
+
* @param {Object} args
|
|
161
|
+
* @param {string} args.apiUrl
|
|
162
|
+
* @param {string|null} args.ingestKey
|
|
163
|
+
* @param {string|null} args.playtestToken
|
|
164
|
+
* @param {Object} args.context
|
|
165
|
+
* @param {Object} args.report the run's report.json
|
|
166
|
+
* @param {string} args.driver
|
|
167
|
+
* @param {Function} [args.fetchImpl]
|
|
168
|
+
* @param {(message: string) => void} [args.log]
|
|
169
|
+
* @returns {Promise<{sent: boolean, accepted: number, reason?: string}>}
|
|
170
|
+
*/
|
|
171
|
+
export async function emitRunEvents({ apiUrl, ingestKey, playtestToken, context, report, driver, fetchImpl, log }) {
|
|
172
|
+
if (!ingestKey) {
|
|
173
|
+
return { sent: false, accepted: 0, reason: 'no ingest key configured, so no synthetic telemetry was sent' };
|
|
174
|
+
}
|
|
175
|
+
if (!playtestToken) {
|
|
176
|
+
return { sent: false, accepted: 0, reason: 'no playtest token for this run, so no synthetic telemetry was sent' };
|
|
177
|
+
}
|
|
178
|
+
const session = new SyntheticSession({ apiUrl, ingestKey, playtestToken, context, fetchImpl });
|
|
179
|
+
try {
|
|
180
|
+
const opened = await session.open();
|
|
181
|
+
if (!opened) return { sent: false, accepted: 0, reason: 'tracking is switched off for this game' };
|
|
182
|
+
|
|
183
|
+
session.track('playtest_run_started', { driver });
|
|
184
|
+
for (const finding of Array.isArray(report && report.findings) ? report.findings : []) {
|
|
185
|
+
session.track('playtest_finding', {
|
|
186
|
+
finding_id: finding.id,
|
|
187
|
+
severity: finding.severity,
|
|
188
|
+
category: finding.category,
|
|
189
|
+
title: String(finding.title || '').slice(0, 120)
|
|
190
|
+
});
|
|
191
|
+
}
|
|
192
|
+
session.track('playtest_run_ended', {
|
|
193
|
+
quit_reason: report && report.quit_reason,
|
|
194
|
+
actions: report && report.actions_taken,
|
|
195
|
+
usd: report && report.usage && report.usage.total_usd
|
|
196
|
+
});
|
|
197
|
+
const accepted = await session.flush();
|
|
198
|
+
return { sent: true, accepted };
|
|
199
|
+
} catch (error) {
|
|
200
|
+
const reason = String(error && error.message ? error.message : error);
|
|
201
|
+
if (log) log(`synthetic telemetry skipped: ${reason}`);
|
|
202
|
+
return { sent: false, accepted: 0, reason };
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
export default { SyntheticSession, emitRunEvents };
|