ravensight-playtest 0.5.2 → 0.7.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/README.md +319 -14
- package/addons/ravensight_driver/driver.gd +406 -8
- package/addons/ravensight_simulate/simulate.gd +360 -0
- package/docs/godot-driver.md +70 -8
- package/package.json +4 -3
- package/src/api/README.md +4 -1
- package/src/api/client.js +120 -3
- package/src/api/errors.js +17 -1
- package/src/api/http.js +43 -17
- package/src/assets/formats.js +267 -0
- package/src/assets/index.js +260 -0
- package/src/assets/integration.js +429 -0
- package/src/assets/inventory.js +337 -0
- package/src/assets/measure.js +392 -0
- package/src/cli.js +233 -0
- package/src/commands/assets.js +62 -0
- package/src/commands/check.js +4 -1
- package/src/commands/create.js +734 -0
- package/src/commands/fakeCore.js +16 -1
- package/src/commands/feature.js +845 -0
- package/src/commands/fix.js +647 -70
- package/src/commands/init.js +19 -3
- package/src/commands/lists.js +206 -0
- package/src/commands/profile.js +25 -6
- package/src/commands/punch.js +116 -0
- package/src/commands/replay.js +204 -0
- package/src/commands/resume.js +7 -0
- package/src/commands/run.js +200 -24
- package/src/commands/tape.js +74 -0
- package/src/commands/template.js +70 -0
- package/src/commands/validate.js +414 -0
- package/src/commands/validateHosted.js +292 -0
- package/src/create/scaffold.js +192 -0
- package/src/run/acceptance.js +212 -0
- package/src/run/args.js +18 -1
- package/src/run/checks.js +705 -0
- package/src/run/dimension.js +95 -0
- package/src/run/drivers/godot-observation.js +59 -11
- package/src/run/drivers/godot-project.js +201 -31
- package/src/run/drivers/godot.js +532 -45
- package/src/run/drivers/web.js +18 -0
- package/src/run/evidence.js +170 -0
- package/src/run/featureLoop.js +288 -0
- package/src/run/fixLoop.js +82 -12
- package/src/run/git.js +110 -20
- package/src/run/json.js +65 -9
- package/src/run/labsOutcome.js +136 -0
- package/src/run/liveWork.js +103 -0
- package/src/run/model.js +105 -5
- package/src/run/openPr.js +107 -0
- package/src/run/paths.js +4 -1
- package/src/run/pendingRegister.js +81 -0
- package/src/run/personaLoop.js +111 -4
- package/src/run/profile.js +68 -7
- package/src/run/replay.js +236 -0
- package/src/run/repoTools.js +227 -27
- package/src/run/report.js +7 -1
- package/src/run/resume.js +19 -1
- package/src/run/secretScan.js +35 -0
- package/src/run/settle.js +41 -0
- package/src/run/tools.js +33 -4
- package/src/run/trace.js +124 -0
- package/src/simulation/godotPack.js +112 -0
- package/src/ui/index.js +61 -9
- package/src/upload/allowlist.js +6 -2
- package/src/upload/index.js +5 -4
- package/templates/godot-2d/README.md +36 -0
- package/templates/godot-2d/addons/ravensight/LICENSE +21 -0
- package/templates/godot-2d/addons/ravensight/Ravensight.gd +1823 -0
- package/templates/godot-2d/export_presets.cfg +27 -0
- package/templates/godot-2d/gitignore +10 -0
- package/templates/godot-2d/project.godot +58 -0
- package/templates/godot-2d/scenes/main.tscn +67 -0
- package/templates/godot-2d/scripts/autoload/game_config.gd +83 -0
- package/templates/godot-2d/scripts/autoload/game_state.gd +44 -0
- package/templates/godot-2d/scripts/autoload/telemetry.gd +37 -0
- package/templates/godot-2d/scripts/main.gd +32 -0
- package/templates/godot-2d/scripts/player.gd +10 -0
- package/templates/godot-2d/scripts/state_hook.gd +16 -0
- package/templates/godot-2d/template.json +14 -0
- package/templates/godot-2d/tools/inject_release_key.sh +19 -0
- package/templates/godot-2d/tools/selftest.gd +70 -0
- package/templates/godot-3d/README.md +49 -0
- package/templates/godot-3d/addons/ravensight/LICENSE +21 -0
- package/templates/godot-3d/addons/ravensight/Ravensight.gd +1823 -0
- package/templates/godot-3d/export_presets.cfg +27 -0
- package/templates/godot-3d/gitignore +10 -0
- package/templates/godot-3d/project.godot +56 -0
- package/templates/godot-3d/scenes/main.tscn +154 -0
- package/templates/godot-3d/scripts/autoload/game_config.gd +83 -0
- package/templates/godot-3d/scripts/autoload/game_state.gd +44 -0
- package/templates/godot-3d/scripts/autoload/telemetry.gd +37 -0
- package/templates/godot-3d/scripts/beacon.gd +20 -0
- package/templates/godot-3d/scripts/main.gd +49 -0
- package/templates/godot-3d/scripts/player.gd +19 -0
- package/templates/godot-3d/scripts/state_hook.gd +25 -0
- package/templates/godot-3d/template.json +16 -0
- package/templates/godot-3d/tools/inject_release_key.sh +19 -0
- package/templates/godot-3d/tools/selftest.gd +77 -0
package/src/run/trace.js
ADDED
|
@@ -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
|
-
|
|
87
|
-
write(
|
|
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
|
-
|
|
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;
|
package/src/upload/allowlist.js
CHANGED
|
@@ -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
|
|
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,
|
package/src/upload/index.js
CHANGED
|
@@ -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.
|