ravensight-playtest 0.5.2 → 0.6.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 (96) hide show
  1. package/README.md +249 -14
  2. package/addons/ravensight_driver/driver.gd +406 -8
  3. package/addons/ravensight_simulate/simulate.gd +360 -0
  4. package/docs/godot-driver.md +70 -8
  5. package/package.json +4 -3
  6. package/src/api/README.md +4 -1
  7. package/src/api/client.js +92 -3
  8. package/src/api/errors.js +17 -1
  9. package/src/api/http.js +43 -17
  10. package/src/assets/formats.js +267 -0
  11. package/src/assets/index.js +260 -0
  12. package/src/assets/integration.js +429 -0
  13. package/src/assets/inventory.js +337 -0
  14. package/src/assets/measure.js +392 -0
  15. package/src/cli.js +165 -0
  16. package/src/commands/assets.js +62 -0
  17. package/src/commands/check.js +4 -1
  18. package/src/commands/create.js +734 -0
  19. package/src/commands/fakeCore.js +16 -1
  20. package/src/commands/feature.js +845 -0
  21. package/src/commands/fix.js +578 -68
  22. package/src/commands/init.js +19 -3
  23. package/src/commands/profile.js +25 -6
  24. package/src/commands/replay.js +204 -0
  25. package/src/commands/resume.js +7 -0
  26. package/src/commands/run.js +200 -24
  27. package/src/commands/template.js +70 -0
  28. package/src/commands/validate.js +414 -0
  29. package/src/commands/validateHosted.js +292 -0
  30. package/src/create/scaffold.js +192 -0
  31. package/src/run/acceptance.js +212 -0
  32. package/src/run/args.js +18 -1
  33. package/src/run/checks.js +705 -0
  34. package/src/run/dimension.js +95 -0
  35. package/src/run/drivers/godot-observation.js +59 -11
  36. package/src/run/drivers/godot-project.js +201 -31
  37. package/src/run/drivers/godot.js +532 -45
  38. package/src/run/drivers/web.js +18 -0
  39. package/src/run/evidence.js +170 -0
  40. package/src/run/featureLoop.js +288 -0
  41. package/src/run/fixLoop.js +82 -12
  42. package/src/run/git.js +110 -20
  43. package/src/run/json.js +65 -9
  44. package/src/run/labsOutcome.js +136 -0
  45. package/src/run/liveWork.js +103 -0
  46. package/src/run/model.js +105 -5
  47. package/src/run/openPr.js +107 -0
  48. package/src/run/paths.js +4 -1
  49. package/src/run/pendingRegister.js +81 -0
  50. package/src/run/personaLoop.js +111 -4
  51. package/src/run/profile.js +68 -7
  52. package/src/run/replay.js +236 -0
  53. package/src/run/repoTools.js +227 -27
  54. package/src/run/report.js +7 -1
  55. package/src/run/resume.js +19 -1
  56. package/src/run/secretScan.js +35 -0
  57. package/src/run/settle.js +41 -0
  58. package/src/run/tools.js +33 -4
  59. package/src/run/trace.js +124 -0
  60. package/src/simulation/godotPack.js +112 -0
  61. package/src/ui/index.js +61 -9
  62. package/src/upload/allowlist.js +6 -2
  63. package/src/upload/index.js +5 -4
  64. package/templates/godot-2d/README.md +36 -0
  65. package/templates/godot-2d/addons/ravensight/LICENSE +21 -0
  66. package/templates/godot-2d/addons/ravensight/Ravensight.gd +1823 -0
  67. package/templates/godot-2d/export_presets.cfg +27 -0
  68. package/templates/godot-2d/gitignore +10 -0
  69. package/templates/godot-2d/project.godot +58 -0
  70. package/templates/godot-2d/scenes/main.tscn +67 -0
  71. package/templates/godot-2d/scripts/autoload/game_config.gd +83 -0
  72. package/templates/godot-2d/scripts/autoload/game_state.gd +44 -0
  73. package/templates/godot-2d/scripts/autoload/telemetry.gd +37 -0
  74. package/templates/godot-2d/scripts/main.gd +32 -0
  75. package/templates/godot-2d/scripts/player.gd +10 -0
  76. package/templates/godot-2d/scripts/state_hook.gd +16 -0
  77. package/templates/godot-2d/template.json +14 -0
  78. package/templates/godot-2d/tools/inject_release_key.sh +19 -0
  79. package/templates/godot-2d/tools/selftest.gd +70 -0
  80. package/templates/godot-3d/README.md +49 -0
  81. package/templates/godot-3d/addons/ravensight/LICENSE +21 -0
  82. package/templates/godot-3d/addons/ravensight/Ravensight.gd +1823 -0
  83. package/templates/godot-3d/export_presets.cfg +27 -0
  84. package/templates/godot-3d/gitignore +10 -0
  85. package/templates/godot-3d/project.godot +56 -0
  86. package/templates/godot-3d/scenes/main.tscn +154 -0
  87. package/templates/godot-3d/scripts/autoload/game_config.gd +83 -0
  88. package/templates/godot-3d/scripts/autoload/game_state.gd +44 -0
  89. package/templates/godot-3d/scripts/autoload/telemetry.gd +37 -0
  90. package/templates/godot-3d/scripts/beacon.gd +20 -0
  91. package/templates/godot-3d/scripts/main.gd +49 -0
  92. package/templates/godot-3d/scripts/player.gd +19 -0
  93. package/templates/godot-3d/scripts/state_hook.gd +25 -0
  94. package/templates/godot-3d/template.json +16 -0
  95. package/templates/godot-3d/tools/inject_release_key.sh +19 -0
  96. package/templates/godot-3d/tools/selftest.gd +77 -0
@@ -0,0 +1,124 @@
1
+ import { createHash } from 'node:crypto';
2
+ import fs from 'node:fs/promises';
3
+ import path from 'node:path';
4
+
5
+ /**
6
+ * `trace.jsonl`: what a lockstep persona run actually did to the game, tick by
7
+ * tick, so it can be played again with no model and compared.
8
+ *
9
+ * {"type":"header","version":1,"engine":"godot","seed":123,"ticks_per_second":60,...}
10
+ * {"type":"boot","state":{...}}
11
+ * {"type":"step","i":0,"frame":0,"action":"key","args":{...},"events":[...],"ticks":30,"state":{...}}
12
+ * {"type":"end","tick":310,"reason":"completed"}
13
+ *
14
+ * `frame` is the simulation tick the step began on. It counts ticks the game
15
+ * was allowed to run and nothing else, so the seconds a persona spent thinking
16
+ * are not in it. `events` are what the driver was told verbatim, and `state` is
17
+ * the digest it answered with (see `_state_digest` in the addon).
18
+ *
19
+ * Inputs and digests only: never a screenshot, never the persona's reasoning,
20
+ * never the game's source. A step that typed one of the local brief's secrets
21
+ * records `redacted` instead of its keys, and a replay stops there rather than
22
+ * guessing at them.
23
+ */
24
+
25
+ export const TRACE_FILE = 'trace.jsonl';
26
+ export const TRACE_VERSION = 1;
27
+ /** The server scans an uploaded text artifact for secrets up to this size. */
28
+ export const MAX_TRACE_BYTES = 2 * 1024 * 1024;
29
+ export const MAX_TRACE_STEPS = 5000;
30
+
31
+ /**
32
+ * A stable 31 bit seed from any text, so a run id or a pair index always names
33
+ * the same seed and a seed fits every integer the engine takes.
34
+ * @param {string} text
35
+ * @returns {number}
36
+ */
37
+ export function seedFrom(text) {
38
+ return parseInt(createHash('sha256').update(String(text)).digest('hex').slice(0, 8), 16) & 0x7fffffff;
39
+ }
40
+
41
+ /**
42
+ * Append only, bounded, and never able to fail a run: a trace that cannot be
43
+ * written costs the replay of this run and nothing else.
44
+ *
45
+ * @param {string} file
46
+ * @param {{maxBytes?: number, maxSteps?: number}} [limits]
47
+ */
48
+ export function createTraceWriter(file, { maxBytes = MAX_TRACE_BYTES, maxSteps = MAX_TRACE_STEPS } = {}) {
49
+ let bytes = 0;
50
+ let steps = 0;
51
+ let closed = false;
52
+ let truncated = false;
53
+ let chain = Promise.resolve();
54
+ const append = (line) => {
55
+ const text = `${JSON.stringify(line)}\n`;
56
+ bytes += Buffer.byteLength(text);
57
+ chain = chain.then(() => fs.appendFile(file, text, { mode: 0o600 })).catch(() => {});
58
+ return chain;
59
+ };
60
+ return {
61
+ file,
62
+ get truncated() { return truncated; },
63
+ get steps() { return steps; },
64
+ async start(header, boot) {
65
+ await fs.mkdir(path.dirname(file), { recursive: true });
66
+ await fs.rm(file, { force: true });
67
+ await append({ type: 'header', version: TRACE_VERSION, ...header });
68
+ await append({ type: 'boot', state: boot });
69
+ },
70
+ async step(entry) {
71
+ if (closed || truncated) return;
72
+ const candidate = Buffer.byteLength(JSON.stringify(entry)) + 64;
73
+ if (steps >= maxSteps || bytes + candidate > maxBytes) {
74
+ truncated = true;
75
+ await append({ type: 'truncated', at_step: steps });
76
+ return;
77
+ }
78
+ await append({ type: 'step', i: steps, ...entry });
79
+ steps += 1;
80
+ },
81
+ async end(fields) {
82
+ if (closed) return;
83
+ closed = true;
84
+ await append({ type: 'end', ...fields });
85
+ await chain;
86
+ }
87
+ };
88
+ }
89
+
90
+ /**
91
+ * Read and check a trace. Refuses anything that cannot be replayed honestly,
92
+ * with the reason, rather than replaying part of a file nobody recognises.
93
+ *
94
+ * @param {string} text
95
+ * @returns {{header: Object, boot: Object, steps: Array<Object>, end: Object|null, truncated: boolean}}
96
+ */
97
+ export function parseTrace(text) {
98
+ if (Buffer.byteLength(text) > MAX_TRACE_BYTES + 1024) throw new Error('This trace is larger than a replay can read.');
99
+ const out = { header: null, boot: null, steps: [], end: null, truncated: false };
100
+ for (const line of String(text).split('\n')) {
101
+ if (!line.trim()) continue;
102
+ let entry;
103
+ try { entry = JSON.parse(line); } catch { throw new Error('This trace has a line that is not JSON.'); }
104
+ if (entry.type === 'header') out.header = entry;
105
+ else if (entry.type === 'boot') out.boot = entry.state;
106
+ else if (entry.type === 'step') out.steps.push(entry);
107
+ else if (entry.type === 'end') out.end = entry;
108
+ else if (entry.type === 'truncated') out.truncated = true;
109
+ }
110
+ if (!out.header || out.header.version !== TRACE_VERSION) throw new Error('This is not a trace this CLI can replay.');
111
+ if (out.header.engine !== 'godot') throw new Error('Only a Godot trace can be replayed.');
112
+ if (!Number.isInteger(out.header.seed) || !Number.isInteger(out.header.ticks_per_second)) throw new Error('This trace has no seed or tick rate.');
113
+ for (const step of out.steps) {
114
+ if (!Number.isInteger(step.ticks) || step.ticks < 1 || !Array.isArray(step.events) && !step.redacted) throw new Error(`Trace step ${step.i} is not valid.`);
115
+ }
116
+ return out;
117
+ }
118
+
119
+ /** @param {string} directory a run directory @returns {Promise<string|null>} */
120
+ export async function findTraceIn(directory) {
121
+ const file = path.join(directory, TRACE_FILE);
122
+ const stat = await fs.stat(file).catch(() => null);
123
+ return stat?.isFile() ? file : null;
124
+ }
@@ -0,0 +1,112 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { spawn } from 'node:child_process';
3
+ import fs from 'node:fs/promises';
4
+ import path from 'node:path';
5
+ import { crc32, deflateRawSync } from 'node:zlib';
6
+ import { allowlistedEnv } from '../run/drivers/godot.js';
7
+
8
+ /**
9
+ * Turning one commit of a Godot project into what a hosted comparison plays: a game
10
+ * pack, with the simulate addon in it, in a ZIP of one file.
11
+ *
12
+ * All of this runs on the developer's machine against a throwaway copy of the commit.
13
+ * The pack holds exported scripts and resources, and no source tree leaves this
14
+ * machine. Nothing is written to the developer's checkout.
15
+ */
16
+
17
+ /** The engine in the hosted guest image. A pack has to come from this version. */
18
+ export const HOSTED_GODOT = '4.5';
19
+ export const PACK_NAME = 'game.pck';
20
+ const PRESET = 'ravensight_simulate';
21
+ const AUTOLOAD_LINE = 'RavensightSimulate="*res://addons/ravensight_simulate/simulate.gd"';
22
+ // Never in a pack, whatever a project's own export settings say.
23
+ const NEVER_EXPORT = 'ravensight_key*.cfg, *.env, .env*, *.pem, *.key, addons/ravensight_driver/*';
24
+
25
+ export function packagedSimulateAddon() {
26
+ return path.resolve(import.meta.dirname, '../../addons/ravensight_simulate');
27
+ }
28
+
29
+ /** project.godot with the addon first among autoloads and stdout flushed per line. */
30
+ export function patchProjectSettings(text) {
31
+ let out = String(text).replace(/\r\n/g, '\n');
32
+ if (out.includes(AUTOLOAD_LINE)) return out;
33
+ // First, so its _ready takes the seed before another autoload can draw a number.
34
+ out = /^\[autoload\]$/m.test(out)
35
+ ? out.replace(/^\[autoload\]\n+/m, `[autoload]\n\n${AUTOLOAD_LINE}\n`)
36
+ : `${out.trimEnd()}\n\n[autoload]\n\n${AUTOLOAD_LINE}\n`;
37
+ const flush = 'run/flush_stdout_on_print=true';
38
+ out = out.replace(/^run\/flush_stdout_on_print(\.[a-z]+)?=.*\n/gm, '');
39
+ out = /^\[application\]$/m.test(out)
40
+ ? out.replace(/^\[application\]\n+/m, `[application]\n\n${flush}\n`)
41
+ : `[application]\n\n${flush}\n\n${out}`;
42
+ return out;
43
+ }
44
+
45
+ export function exportPreset() {
46
+ return [
47
+ '[preset.0]', '', `name="${PRESET}"`, 'platform="Linux"', 'runnable=false', 'dedicated_server=false', 'custom_features=""',
48
+ 'export_filter="all_resources"', 'include_filter=""', `exclude_filter="${NEVER_EXPORT}"`, `export_path="${PACK_NAME}"`, 'script_export_mode=2', '',
49
+ '[preset.0.options]', '', 'binary_format/embed_pck=false', 'binary_format/architecture="x86_64"', ''
50
+ ].join('\n');
51
+ }
52
+
53
+ /** Writes into the throwaway copy only. A symlink in it is never written through. */
54
+ export async function injectSimulate(projectDir, addonDir = packagedSimulateAddon()) {
55
+ const settings = path.join(projectDir, 'project.godot');
56
+ const overrideable = async file => { const stat = await fs.lstat(file).catch(() => null); if (stat?.isSymbolicLink()) await fs.unlink(file); };
57
+ const text = await fs.readFile(settings, 'utf8');
58
+ await overrideable(settings);
59
+ await fs.writeFile(settings, patchProjectSettings(text));
60
+ const presets = path.join(projectDir, 'export_presets.cfg');
61
+ await overrideable(presets);
62
+ await fs.writeFile(presets, exportPreset());
63
+ await overrideable(path.join(projectDir, 'addons'));
64
+ await fs.mkdir(path.join(projectDir, 'addons'), { recursive: true });
65
+ await fs.cp(addonDir, path.join(projectDir, 'addons/ravensight_simulate'), { recursive: true, force: true });
66
+ }
67
+
68
+ /** A ZIP of one deflated file, which is all the worker's archive check needs. */
69
+ export function zipOneFile(name, body) {
70
+ const data = deflateRawSync(body, { level: 9 });
71
+ const fileName = Buffer.from(name);
72
+ const crc = crc32(body);
73
+ const local = Buffer.alloc(30);
74
+ local.writeUInt32LE(0x04034b50, 0); local.writeUInt16LE(20, 4); local.writeUInt16LE(8, 8); local.writeUInt32LE(crc, 14);
75
+ local.writeUInt32LE(data.length, 18); local.writeUInt32LE(body.length, 22); local.writeUInt16LE(fileName.length, 26);
76
+ const central = Buffer.alloc(46);
77
+ central.writeUInt32LE(0x02014b50, 0); central.writeUInt16LE(0x0314, 4); central.writeUInt16LE(20, 6); central.writeUInt16LE(8, 10); central.writeUInt32LE(crc, 16);
78
+ central.writeUInt32LE(data.length, 20); central.writeUInt32LE(body.length, 24); central.writeUInt16LE(fileName.length, 28); central.writeUInt32LE(0o100644 << 16 >>> 0, 38);
79
+ const end = Buffer.alloc(22);
80
+ const directoryAt = local.length + fileName.length + data.length;
81
+ end.writeUInt32LE(0x06054b50, 0); end.writeUInt16LE(1, 8); end.writeUInt16LE(1, 10); end.writeUInt32LE(central.length + fileName.length, 12); end.writeUInt32LE(directoryAt, 16);
82
+ return Buffer.concat([local, fileName, data, central, fileName, end]);
83
+ }
84
+
85
+ function runGodot({ godotPath, args, env, spawnFn = spawn, timeoutMs = 300000 }) {
86
+ return new Promise((resolve, reject) => {
87
+ const child = spawnFn(godotPath, args, { env, stdio: ['ignore', 'ignore', 'pipe'] });
88
+ let stderr = '';
89
+ const timer = setTimeout(() => { child.kill('SIGKILL'); reject(new Error('Godot did not finish exporting in time.')); }, timeoutMs);
90
+ child.stderr?.on('data', chunk => { stderr = (stderr + chunk).slice(-2000); });
91
+ child.on('error', error => { clearTimeout(timer); reject(error); });
92
+ child.on('exit', code => { clearTimeout(timer); code === 0 ? resolve() : reject(new Error(`Godot could not export this commit: ${stderr.trim().slice(-400) || `exit ${code}`}`)); });
93
+ });
94
+ }
95
+
96
+ /**
97
+ * @param {{projectDir: string, godotPath: string, env?: Object, addonDir?: string, spawnFn?: Function}} options
98
+ * @returns {Promise<{zip: Buffer, sha256: string, bytes: number}>}
99
+ */
100
+ export async function buildPack({ projectDir, godotPath, env: callerEnv = process.env, addonDir, spawnFn }) {
101
+ await injectSimulate(projectDir, addonDir);
102
+ const base = ['--headless', '--path', projectDir];
103
+ // A commit's editor plugins run in both of these, so they get no token and no key.
104
+ const env = allowlistedEnv(callerEnv);
105
+ await runGodot({ godotPath, args: [...base, '--import'], env, spawnFn });
106
+ const out = path.join(projectDir, PACK_NAME);
107
+ await runGodot({ godotPath, args: [...base, '--export-pack', PRESET, out], env, spawnFn });
108
+ const pack = await fs.readFile(out).catch(() => null);
109
+ if (!pack || pack.subarray(0, 4).toString('latin1') !== 'GDPC') throw new Error('Godot finished, but it did not write a game pack.');
110
+ const zip = zipOneFile(PACK_NAME, pack);
111
+ return { zip, sha256: createHash('sha256').update(zip).digest('hex'), bytes: zip.length };
112
+ }
package/src/ui/index.js CHANGED
@@ -30,9 +30,23 @@ export function setStreams({ out, err } = {}) {
30
30
  errStream = err || process.stderr;
31
31
  }
32
32
 
33
+ /**
34
+ * Text fit for a terminal. A lot of what this CLI prints was written by a model, a
35
+ * game or a file in the repository, and a terminal obeys what it is sent: clear the
36
+ * screen, set the title, write the clipboard, overwrite the line above. Newlines and
37
+ * tabs stay, and so does this file's own bold.
38
+ *
39
+ * @param {*} text
40
+ * @returns {string}
41
+ */
42
+ export function printable(text) {
43
+ /* eslint-disable-next-line no-control-regex */
44
+ return String(text).replace(/\u001b(?!\[(?:1|22)m)|[\u0000-\u0008\u000b-\u001a\u001c-\u001f\u007f-\u009f]/g, '');
45
+ }
46
+
33
47
  function write(text) {
34
48
  if (isQuiet()) return;
35
- stream.write(`${text}\n`);
49
+ stream.write(`${printable(text)}\n`);
36
50
  }
37
51
 
38
52
  export function info(text) {
@@ -49,7 +63,7 @@ export function warn(text) {
49
63
 
50
64
  /** Errors go to stderr and are printed even in quiet mode: a silent failure is a bug. */
51
65
  export function error(text) {
52
- errStream.write(`${SYMBOLS.fail} ${text}\n`);
66
+ errStream.write(`${SYMBOLS.fail} ${printable(text)}\n`);
53
67
  }
54
68
 
55
69
  export function blank() {
@@ -64,13 +78,44 @@ export function json(value) {
64
78
  /** Stable JSON, for anything that gets hashed or compared between runs. */
65
79
  export const stable = stableStringify;
66
80
 
81
+ /**
82
+ * Words of `text` in lines of at most `width` characters; a word longer than
83
+ * the width is broken inside itself.
84
+ * @param {string} text
85
+ * @param {number} width
86
+ * @returns {string[]}
87
+ */
88
+ export function wrapText(text, width) {
89
+ const lines = [];
90
+ let current = '';
91
+ for (const word of String(text).split(/\s+/).filter(Boolean)) {
92
+ let rest = word;
93
+ while (rest.length > width) {
94
+ if (current) { lines.push(current); current = ''; }
95
+ lines.push(rest.slice(0, width));
96
+ rest = rest.slice(width);
97
+ }
98
+ if (!current) current = rest;
99
+ else if (current.length + 1 + rest.length <= width) current += ` ${rest}`;
100
+ else { lines.push(current); current = rest; }
101
+ }
102
+ if (current || lines.length === 0) lines.push(current);
103
+ return lines;
104
+ }
105
+
106
+ /** The width tables wrap to when the output is not a terminal. */
107
+ export const DEFAULT_TABLE_WIDTH = 100;
108
+
67
109
  /**
68
110
  * An aligned table with no borders, which is what fits in a terminal that may
69
- * be 80 columns wide.
111
+ * be 80 columns wide. The last column is never padded, and wraps under itself
112
+ * when a row would run past the terminal: check's login row once listed 17
113
+ * games inline and padded every other row to the same 900 columns.
70
114
  * @param {Array<Object>} rows
71
115
  * @param {Array<{key: string, label: string, align?: 'left'|'right'}>} columns
116
+ * @param {{width?: number}} [options]
72
117
  */
73
- export function table(rows, columns) {
118
+ export function table(rows, columns, options = {}) {
74
119
  if (isQuiet()) return;
75
120
  const cells = rows.map(row => columns.map(column => {
76
121
  const value = row[column.key];
@@ -82,11 +127,18 @@ export function table(rows, columns) {
82
127
  0
83
128
  ));
84
129
  const pad = (text, width, align) => (align === 'right' ? text.padStart(width) : text.padEnd(width));
130
+ const last = columns.length - 1;
131
+ const total = Number(options.width) || (stream.columns > 0 ? stream.columns : DEFAULT_TABLE_WIDTH);
132
+ const indent = widths.slice(0, last).reduce((sum, width) => sum + width + 2, 0);
133
+ const room = Math.max(20, total - indent);
85
134
 
86
- write(columns.map((column, i) => pad(column.label, widths[i], column.align)).join(' '));
87
- write(widths.map(width => '-'.repeat(width)).join(' '));
135
+ const line = (texts) => texts.map((text, i) => (i === last ? text : pad(text, widths[i], columns[i].align))).join(' ').trimEnd();
136
+ write(line(columns.map(column => column.label)));
137
+ write(line(widths.map((width, i) => '-'.repeat(i === last ? Math.min(width, room) : width))));
88
138
  for (const row of cells) {
89
- write(row.map((text, i) => pad(text, widths[i], columns[i].align)).join(' '));
139
+ const wrapped = row[last].length > room ? wrapText(row[last], room) : [row[last]];
140
+ write(line([...row.slice(0, last), wrapped[0]]));
141
+ for (const more of wrapped.slice(1)) write(`${' '.repeat(indent)}${more}`);
90
142
  }
91
143
  }
92
144
 
@@ -150,7 +202,7 @@ export async function confirm(question, options = {}) {
150
202
 
151
203
  const rl = createInterface({ input, output: options.output || stream });
152
204
  try {
153
- const answer = await rl.question(`${question} [y/N] `);
205
+ const answer = await rl.question(`${printable(question)} [y/N] `);
154
206
  return /^y(es)?$/i.test(answer.trim());
155
207
  } finally {
156
208
  rl.close();
@@ -175,7 +227,7 @@ export async function choose(question, labels, options = {}) {
175
227
  labels.forEach((label, index) => output.write(` ${index + 1}. ${label}\n`));
176
228
  const rl = createInterface({ input, output });
177
229
  try {
178
- const answer = (await rl.question(`${question} `)).trim();
230
+ const answer = (await rl.question(`${printable(question)} `)).trim();
179
231
  if (!/^\d+$/.test(answer)) return null;
180
232
  const index = Number(answer) - 1;
181
233
  return index >= 0 && index < labels.length ? index : null;
@@ -32,6 +32,9 @@ export const ALLOWED_PATHS = [
32
32
  { pattern: /^job-review\.json$/, contentType: 'application/json' },
33
33
  { pattern: /^expectations-brief\.json$/, contentType: 'application/json' },
34
34
  { pattern: /^next-action-plan\.json$/, contentType: 'application/json' },
35
+ { pattern: /^validation-report\.json$/, contentType: 'application/json' },
36
+ { pattern: /^validation-report\.md$/, contentType: 'text/markdown' },
37
+ { pattern: /^trace\.jsonl$/, contentType: 'application/x-ndjson', optIn: 'trace' },
35
38
  { pattern: /^session\.webm$/, contentType: 'video/webm', optIn: 'video' },
36
39
  { pattern: /^session\.mp4$/, contentType: 'video/mp4', optIn: 'video' },
37
40
  { pattern: /^transcript\.jsonl$/, contentType: 'application/x-ndjson', optIn: 'transcript' }
@@ -39,14 +42,15 @@ export const ALLOWED_PATHS = [
39
42
 
40
43
  export const TRANSCRIPT_PATH = 'transcript.jsonl';
41
44
  export const VIDEO_PATHS = Object.freeze(['session.webm', 'session.mp4']);
45
+ export const TRACE_PATH = 'trace.jsonl';
42
46
 
43
47
  /**
44
- * The three paths whose objects carry the `retention=90d` S3 tag, from W8's
48
+ * The paths whose objects carry the `retention=90d` S3 tag, from W8's
45
49
  * `RETENTION_TAGGED_PATHS`. The CLI never computes the tag value itself: the
46
50
  * presign response hands back the exact string that was signed. This list is
47
51
  * only used to explain, in `--dry-upload` output, which files expire.
48
52
  */
49
- export const RETENTION_TAGGED_PATHS = Object.freeze([...VIDEO_PATHS, TRANSCRIPT_PATH]);
53
+ export const RETENTION_TAGGED_PATHS = Object.freeze([...VIDEO_PATHS, TRANSCRIPT_PATH, TRACE_PATH]);
50
54
 
51
55
  /**
52
56
  * The only three artifacts that belong to a job rather than to one of its runs,
@@ -233,8 +233,8 @@ export async function putFile(options) {
233
233
  * honest reading of the one it has is "include the opt in artifacts", and that
234
234
  * is what is sent.
235
235
  */
236
- function presignWith({ api, gameId, jobId, runId, includeVideo, includeTranscript }) {
237
- const includeOptIn = Boolean(includeVideo) || Boolean(includeTranscript);
236
+ function presignWith({ api, gameId, jobId, runId, includeVideo, includeTranscript, includeTrace }) {
237
+ const includeOptIn = Boolean(includeVideo) || Boolean(includeTranscript) || Boolean(includeTrace);
238
238
  return files => (runId
239
239
  ? api.runs.uploads(gameId, jobId, runId, { files, includeVideo: includeOptIn })
240
240
  : api.jobs.uploads(gameId, jobId, { files }));
@@ -274,7 +274,7 @@ function presignWith({ api, gameId, jobId, runId, includeVideo, includeTranscrip
274
274
  export async function uploadRunDir(runDir, options) {
275
275
  const {
276
276
  api, gameId, jobId, runId = null,
277
- includeVideo = false, includeTranscript = false,
277
+ includeVideo = false, includeTranscript = false, includeTrace = false,
278
278
  dryRun = false, force = false, only = null, allowedPaths = null, exclude = null, excludeReason = 'excluded',
279
279
  onFile, repoRoot, fetch: fetchImpl, sleep
280
280
  } = options;
@@ -283,6 +283,7 @@ export async function uploadRunDir(runDir, options) {
283
283
  const plan = await planRunDir(runDir, {
284
284
  video: includeVideo,
285
285
  transcript: includeTranscript,
286
+ trace: includeTrace,
286
287
  allowedPaths
287
288
  });
288
289
  let files = plan.files;
@@ -328,7 +329,7 @@ export async function uploadRunDir(runDir, options) {
328
329
  return { uploaded: [], skipped, bytes: 0, dryRun: false };
329
330
  }
330
331
 
331
- const presign = presignWith({ api, gameId, jobId, runId, includeVideo, includeTranscript });
332
+ const presign = presignWith({ api, gameId, jobId, runId, includeVideo, includeTranscript, includeTrace });
332
333
  const request = files.map(({ path, size, sha256, content_type: contentType }) => ({
333
334
  path, size, sha256, content_type: contentType
334
335
  }));
@@ -0,0 +1,36 @@
1
+ # __GAME_NAME__
2
+
3
+ A placeholder prototype made with `ravensight-playtest create` (Experimental). The
4
+ design is in `DESIGN.md`. Every visual is a placeholder primitive, and your own art
5
+ and audio replace them.
6
+
7
+ ## Run it
8
+
9
+ Open this folder in Godot 4, or run `godot --path .`. Move with WASD or the arrow
10
+ keys, use Space or E for the game's action, and Esc for settings.
11
+
12
+ `godot --headless --path . --script tools/selftest.gd` checks the parts a game
13
+ depends on.
14
+
15
+ ## Analytics
16
+
17
+ The Ravensight SDK (`addons/ravensight/Ravensight.gd`, MIT, unmodified) is an autoload.
18
+ Report events with `Telemetry.track(name, data, kind)`, where `kind` is `level_start`,
19
+ `level_complete`, `death` or `none`. It always sets `rs_kind`, so an event's name is
20
+ never used to guess what it means.
21
+
22
+ - **The key.** `ravensight_key.cfg` is this game's development key. It is ignored by
23
+ git. `scripts/autoload/game_config.gd` says where else a key can come from.
24
+ - **The opt-out.** Esc opens settings, and "Share anonymous analytics" is the player's
25
+ own choice. The SDK saves it and honours it on every later run.
26
+ - **A build anyone can download.** Use a public key, the one that starts `gt_pub_`, so
27
+ it is written only into the build:
28
+
29
+ RAVENSIGHT_RELEASE_KEY=gt_pub_... sh tools/inject_release_key.sh godot --headless --export-release Web build/web/index.html
30
+
31
+ ## How a playtest reads the game
32
+
33
+ `scripts/state_hook.gd` is in the `ravensight_state` group, and its
34
+ `_ravensight_state()` answer is what a persona sees and a replay compares. Call
35
+ `GameState.complete_core_loop()` once for each pass of the core loop: it is what the
36
+ playable check reads.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Reality Software Entertainment
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.