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.
Files changed (72) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +380 -0
  3. package/addons/ravensight_driver/driver.gd +836 -0
  4. package/addons/ravensight_driver/export_plugin.gd +51 -0
  5. package/addons/ravensight_driver/plugin.cfg +7 -0
  6. package/addons/ravensight_driver/plugin.gd +36 -0
  7. package/bin/ravensight-playtest.js +31 -0
  8. package/package.json +45 -0
  9. package/src/api/README.md +500 -0
  10. package/src/api/client.js +340 -0
  11. package/src/api/errors.js +115 -0
  12. package/src/api/http.js +194 -0
  13. package/src/api/index.js +107 -0
  14. package/src/auth/deviceCode.js +79 -0
  15. package/src/auth/keychain.js +159 -0
  16. package/src/auth/session.js +128 -0
  17. package/src/cli.js +335 -0
  18. package/src/commands/brief.js +303 -0
  19. package/src/commands/check.js +318 -0
  20. package/src/commands/fakeCore.js +379 -0
  21. package/src/commands/init.js +120 -0
  22. package/src/commands/login.js +90 -0
  23. package/src/commands/logout.js +70 -0
  24. package/src/commands/open.js +125 -0
  25. package/src/commands/profile.js +262 -0
  26. package/src/commands/resume.js +156 -0
  27. package/src/commands/run.js +1015 -0
  28. package/src/commands/upload.js +137 -0
  29. package/src/config.js +100 -0
  30. package/src/dashboard.js +97 -0
  31. package/src/detect.js +77 -0
  32. package/src/errors.js +44 -0
  33. package/src/fsutil.js +77 -0
  34. package/src/godot.js +85 -0
  35. package/src/packs/index.js +191 -0
  36. package/src/paths.js +129 -0
  37. package/src/run/aggregate.js +658 -0
  38. package/src/run/args.js +111 -0
  39. package/src/run/context.js +181 -0
  40. package/src/run/deps.js +184 -0
  41. package/src/run/drivers/driver.js +183 -0
  42. package/src/run/drivers/godot-observation.js +138 -0
  43. package/src/run/drivers/godot-project.js +475 -0
  44. package/src/run/drivers/godot-rpc.js +225 -0
  45. package/src/run/drivers/godot.js +587 -0
  46. package/src/run/drivers/index.js +52 -0
  47. package/src/run/drivers/web.js +385 -0
  48. package/src/run/exit.js +21 -0
  49. package/src/run/heartbeat.js +131 -0
  50. package/src/run/index.js +31 -0
  51. package/src/run/json.js +56 -0
  52. package/src/run/model.js +384 -0
  53. package/src/run/paths.js +88 -0
  54. package/src/run/personaLoop.js +871 -0
  55. package/src/run/profile.js +214 -0
  56. package/src/run/regenerate.js +149 -0
  57. package/src/run/repoTools.js +286 -0
  58. package/src/run/report.js +222 -0
  59. package/src/run/resume.js +272 -0
  60. package/src/run/secretScan.js +171 -0
  61. package/src/run/state.js +198 -0
  62. package/src/run/synthetic.js +206 -0
  63. package/src/run/tools.js +344 -0
  64. package/src/run/transcript.js +93 -0
  65. package/src/run/usage.js +115 -0
  66. package/src/state/index.js +105 -0
  67. package/src/states.js +104 -0
  68. package/src/ui/index.js +195 -0
  69. package/src/upload/allowlist.js +116 -0
  70. package/src/upload/index.js +467 -0
  71. package/src/upload/queue.js +114 -0
  72. package/src/version.js +63 -0
@@ -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 };