ravensight-playtest 0.5.0 → 0.5.2

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.
@@ -0,0 +1,118 @@
1
+ import { createHash, randomUUID } from 'node:crypto';
2
+ import { mkdir, open } from 'node:fs/promises';
3
+ import path from 'node:path';
4
+ import { Readable } from 'node:stream';
5
+ import { CliError } from '../errors.js';
6
+ import { ApiError } from '../api/errors.js';
7
+ import { writeJson } from '../fsutil.js';
8
+ import { openBuildArchive, inspectBuildArchive, buildLimits, limitsFromProfile } from './archivePreflight.js';
9
+
10
+ const fail = message => { throw new CliError(message); };
11
+ const base = game => `/api/v1/games/${encodeURIComponent(game)}/playtest/hosted`;
12
+ const body = answer => answer.body;
13
+ const money = cents => `$${(cents / 100).toFixed(2)}`;
14
+
15
+ export function singlePut(upload, { size, sha256, uploadExpiresAt }, now = Date.now()) {
16
+ const url = new URL(upload?.url), headers = upload?.headers;
17
+ if (upload.method !== 'PUT' || url.protocol !== 'https:' || url.username || url.password || url.hash
18
+ || !/^[a-z0-9.-]+\.s3\.[a-z0-9-]+\.amazonaws\.com$/.test(url.hostname)) fail('The server did not return a supported private S3 upload destination. Preserve the original upload journal.');
19
+ const expected = { 'content-type': 'application/zip', 'if-none-match': '*', 'x-amz-checksum-sha256': Buffer.from(sha256, 'hex').toString('base64'), 'x-amz-server-side-encryption': 'AES256' };
20
+ if (!headers || Object.keys(headers).sort().join(',') !== Object.keys(expected).sort().join(',')
21
+ || Object.entries(expected).some(([key, value]) => headers[key] !== value)) fail('The upload headers do not match the inspected archive. Preserve the original upload journal.');
22
+ const signed = url.searchParams.get('X-Amz-Date'), seconds = Number(url.searchParams.get('X-Amz-Expires'));
23
+ if (!/^\d{8}T\d{6}Z$/.test(signed || '') || !Number.isInteger(seconds) || seconds < 1 || seconds > 604800) fail('The upload URL has an invalid expiry.');
24
+ const at = Date.parse(`${signed.slice(0, 4)}-${signed.slice(4, 6)}-${signed.slice(6, 8)}T${signed.slice(9, 11)}:${signed.slice(11, 13)}:${signed.slice(13, 15)}Z`);
25
+ const until = Math.min(at + seconds * 1000, Date.parse(uploadExpiresAt));
26
+ if (!Number.isFinite(until) || until <= now || !Number.isSafeInteger(size) || size < 1) fail('The original upload URL has expired. Do not create a replacement reservation automatically.');
27
+ return { url: url.href, headers: { ...expected, 'content-length': String(size) }, expiresAt: until };
28
+ }
29
+
30
+ export async function putArchive(archive, transfer, { fetchImpl = globalThis.fetch, signal, onProgress } = {}) {
31
+ await archive.check();
32
+ const remaining = transfer.expiresAt - Date.now();
33
+ if (remaining <= 0) fail('The original upload URL has expired.');
34
+ const deadline = AbortSignal.timeout(Math.min(remaining, 2147483647));
35
+ const combined = signal ? AbortSignal.any([signal, deadline]) : deadline;
36
+ const stream = Readable.from(archive.chunks({ onProgress }), { objectMode: false, highWaterMark: 65536 });
37
+ try {
38
+ const response = await fetchImpl(transfer.url, { method: 'PUT', headers: transfer.headers, body: stream,
39
+ duplex: 'half', redirect: 'error', signal: combined });
40
+ if (!response.ok) fail(`The original archive upload returned HTTP ${response.status}. Preserve the journal; no replacement reservation was requested.`);
41
+ await archive.check();
42
+ } finally { stream.destroy(); }
43
+ }
44
+
45
+ async function journalOnce(filename, value) {
46
+ await mkdir(path.dirname(filename), { recursive: true, mode: 0o700 });
47
+ let handle;
48
+ try { handle = await open(filename, 'wx', 0o600); }
49
+ catch (error) {
50
+ if (error.code === 'EEXIST') fail(`An original build upload already has this journal: ${filename}. Reconcile it before starting another paid upload.`);
51
+ throw error;
52
+ }
53
+ try { await handle.writeFile(JSON.stringify(value, null, 2) + '\n'); await handle.sync(); }
54
+ finally { await handle.close(); }
55
+ }
56
+
57
+ // All metadata checks and a streamed checksum precede the confirmation and
58
+ // the only wallet-mutating POST. No model or gameplay API is called here.
59
+ export async function uploadHostedBuild({ filename, gameId, profileVersion, entrypoint, repoRoot, api,
60
+ apiUrl, journalPath, dryRun = false, confirm, onPrepared = () => {}, onProgress = () => {}, fetchImpl, signal } = {}) {
61
+ if (!gameId || !profileVersion || typeof confirm !== 'function') fail('Choose a game, ready profile and upload confirmation.');
62
+ onProgress({ phase: 'capabilities' });
63
+ const caps = body(await api.request('GET', `${base(gameId)}/capabilities`, { idempotent: true }));
64
+ const profiles = caps?.recorded?.profiles;
65
+ if (!caps?.recorded?.available || !Array.isArray(profiles)) fail('Recorded game uploads are not available for this game.');
66
+ const matching = profiles.filter(profile => profile.profileVersion === profileVersion);
67
+ if (matching.length !== 1) fail('Choose exactly one currently advertised ready profile.');
68
+ const profile = matching[0], limits = profile.capacity ? limitsFromProfile(profile) : buildLimits(caps.limits);
69
+ const offer = profile.reservationOffer;
70
+ if (!offer || offer.amountCents !== 49 || offer.currency !== 'USD' || offer.purpose !== 'validation_hold'
71
+ || offer.refund !== 'after_cleanup' || !/^[a-f0-9]{64}$/.test(offer.version || '')) fail('The server did not provide the supported validation-hold offer.');
72
+ if (caps.funding?.eligible === false) fail('The server reports insufficient prepaid funding. No reservation or top-up was requested.');
73
+ const archive = await openBuildArchive(filename, { signal });
74
+ try {
75
+ onProgress({ phase: 'preflight' });
76
+ const measurement = await inspectBuildArchive(archive, { limits, format: profile.format, entrypoint, requirements: profile.capacity?.requirements });
77
+ onProgress({ phase: 'hash', completedBytes: 0, totalBytes: archive.size });
78
+ const sha256 = await archive.sha256({ onProgress: (completedBytes, totalBytes) => onProgress({ phase: 'hash', completedBytes, totalBytes }) });
79
+ const prepared = { ...measurement, sha256, profileVersion, limits, capacity: profile.capacity || null,
80
+ validationHoldCents: offer.amountCents, funding: caps.funding || null, gameplayQuoteRequired: true };
81
+ await onPrepared(prepared);
82
+ if (dryRun) return { state: 'preflight_only', ...prepared, walletMutationRequested: false };
83
+ if (!await confirm(`Reserve ${money(offer.amountCents)} from prepaid funds for this build’s static validation? It is returned after safe cleanup and is separate from the gameplay quote.`)) {
84
+ return { state: 'declined', walletMutationRequested: false };
85
+ }
86
+ await archive.check();
87
+ const input = { sha256, bytes: archive.size, entrypoint, args: [], reservation: { amountCents: offer.amountCents, version: offer.version },
88
+ validation: { kind: 'recorded_graphical', format: profile.format, profileVersion, selfContained: true } };
89
+ const identity = createHash('sha256').update(JSON.stringify({ apiUrl, gameId, sha256, profileVersion, entrypoint })).digest('hex');
90
+ const journal = journalPath || path.join(repoRoot, '.ravensight', 'hosted-uploads', identity + '.json');
91
+ const state = { version: 1, apiUrl, gameId, profileVersion, archive: path.resolve(filename), measurement, sha256,
92
+ idempotencyKey: randomUUID(), input, phase: 'reservation_unknown', createdAt: new Date().toISOString() };
93
+ await journalOnce(journal, state); // durable intent BEFORE the paid POST
94
+ try {
95
+ onProgress({ phase: 'reservation' });
96
+ const answer = body(await api.request('POST', `${base(gameId)}/builds`, { body: input, headers: { 'Idempotency-Key': state.idempotencyKey }, idempotent: false }));
97
+ if (!/^pt_build_[a-f0-9-]{1,100}$/.test(answer?.build?.buildId || '')) fail('The upload reservation response needs reconciliation.');
98
+ state.buildId = answer.build.buildId; state.phase = 'reserved'; await writeJson(journal, state, { mode: 0o600 });
99
+ const transfer = singlePut(answer.upload, { size: archive.size, sha256, uploadExpiresAt: answer.uploadExpiresAt });
100
+ state.phase = 'upload_unknown'; await writeJson(journal, state, { mode: 0o600 });
101
+ onProgress({ phase: 'upload', completedBytes: 0, totalBytes: archive.size });
102
+ await putArchive(archive, transfer, { fetchImpl, signal,
103
+ onProgress: (completedBytes, totalBytes) => onProgress({ phase: 'upload', completedBytes, totalBytes }) });
104
+ state.phase = 'uploaded'; await writeJson(journal, state, { mode: 0o600 });
105
+ onProgress({ phase: 'completion' });
106
+ const completed = body(await api.request('POST', `${base(gameId)}/builds/${encodeURIComponent(state.buildId)}/complete`, { idempotent: true }));
107
+ state.phase = 'awaiting_validation'; state.serverBuild = completed; await writeJson(journal, state, { mode: 0o600 });
108
+ return { state: state.phase, buildId: state.buildId, journal, walletMutationRequested: true, validationHoldCents: offer.amountCents,
109
+ multipartSupported: false, uploadResumeSupported: false, gameplayStarted: false };
110
+ } catch (error) {
111
+ if (error instanceof ApiError && [401, 403].includes(error.status)) {
112
+ error.message += ` Original upload journal: ${journal}; no replacement reservation was requested.`;
113
+ throw error;
114
+ }
115
+ throw new CliError(`The original build upload needs review. Preserve ${journal}; its phase is ${state.phase}. No new reservation, multipart restart or gameplay request was issued.`, 1, { cause: error });
116
+ }
117
+ } finally { await archive.close(); }
118
+ }
@@ -0,0 +1,191 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { mkdir, open, access, rename } from 'node:fs/promises';
3
+ import path from 'node:path';
4
+ import { CliError } from '../errors.js';
5
+ import { ApiError } from '../api/errors.js';
6
+ import { stableStringify } from '../fsutil.js';
7
+
8
+ const MAX_QUOTE_BYTES = 1024 * 1024;
9
+ const CONSENT_KEYS = ['quoteId', 'planHash', 'profileVersion', 'controllers', 'includeVideo', 'includeTranscript',
10
+ 'totalPriceCents', 'dueNowCents', 'priceVersion', 'policyVersion'];
11
+ const PROVIDERS = ['anthropic', 'openai'];
12
+ const base = game => `/api/v1/games/${encodeURIComponent(game)}/playtest/hosted`;
13
+ const digest = value => createHash('sha256').update(stableStringify(value)).digest('hex');
14
+ const fail = message => { throw new CliError(message); };
15
+ const id = value => typeof value === 'string' && /^[A-Za-z0-9_.:-]{1,128}$/.test(value);
16
+ const hex = value => typeof value === 'string' && /^[a-f0-9]{64}$/.test(value);
17
+ const cents = value => Number.isSafeInteger(value) && value >= 0;
18
+ const money = value => `$${(value / 100).toFixed(2)}`;
19
+ const same = (a, b) => stableStringify(a) === stableStringify(b);
20
+
21
+ function origin(value) {
22
+ const url = new URL(value);
23
+ if (!['http:', 'https:'].includes(url.protocol) || url.username || url.password || url.search || url.hash) fail('Choose a supported API URL.');
24
+ return url.href.replace(/\/+$/, '');
25
+ }
26
+
27
+ async function exclusiveJson(filename, value) {
28
+ await mkdir(path.dirname(filename), { recursive: true, mode: 0o700 });
29
+ let handle;
30
+ try { handle = await open(filename, 'wx', 0o600); }
31
+ catch (error) {
32
+ if (error.code === 'EEXIST') fail(`An original hosted record already exists at ${filename}. Preserve it and reconcile it; no replacement request was sent.`);
33
+ throw error;
34
+ }
35
+ try { await handle.writeFile(JSON.stringify(value, null, 2) + '\n'); await handle.sync(); }
36
+ finally { await handle.close(); }
37
+ }
38
+
39
+ export async function readHostedQuote(filename) {
40
+ const handle = await open(filename, 'r');
41
+ try {
42
+ const stat = await handle.stat();
43
+ if (!stat.isFile() || stat.size < 1 || stat.size > MAX_QUOTE_BYTES) fail('Choose a saved hosted quote file no larger than 1 MiB.');
44
+ const buffer = Buffer.alloc(stat.size + 1);
45
+ let bytes = 0;
46
+ while (bytes < buffer.length) {
47
+ const row = await handle.read(buffer, bytes, buffer.length - bytes, bytes);
48
+ if (!row.bytesRead) break;
49
+ bytes += row.bytesRead;
50
+ }
51
+ if (bytes !== stat.size) fail('The saved quote changed while it was read. Nothing was charged.');
52
+ try { return JSON.parse(buffer.subarray(0, bytes).toString('utf8')); }
53
+ catch { fail('The saved hosted quote is not valid JSON. Nothing was charged.'); }
54
+ } finally { await handle.close(); }
55
+ }
56
+
57
+ function requestFor({ buildId, profileVersion, persona, standardProvider, includeVideo = false, includeTranscript = false }, profile) {
58
+ if (!id(buildId) || !id(profileVersion) || typeof persona !== 'string' || persona.length > 48
59
+ || !/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(persona)) fail('Choose a validated build, ready profile and one persona.');
60
+ if (standardProvider !== undefined && !PROVIDERS.includes(standardProvider)) fail('Choose anthropic or openai for Standard.');
61
+ if (typeof includeVideo !== 'boolean' || typeof includeTranscript !== 'boolean') fail('Choose explicit video and transcript options.');
62
+ return { kind: 'recorded_graphical', buildId, personas: [persona], controllers: ['standard', 'jev'],
63
+ driver: profile.driver, runLength: 'standard', profileVersion, includeVideo, includeTranscript,
64
+ ...(standardProvider === undefined ? {} : { standardProvider }) };
65
+ }
66
+
67
+ async function available(api, gameId, profileVersion, standardProvider) {
68
+ const answer = await api.request('GET', `${base(gameId)}/run-capabilities`, { idempotent: true });
69
+ const caps = answer.body, recorded = caps?.recorded;
70
+ if (!recorded?.available || recorded.intakeEnabled !== true || !Array.isArray(recorded.profiles)
71
+ || !Array.isArray(recorded.controllers) || !['standard', 'jev'].every(value => recorded.controllers.includes(value))) {
72
+ fail('This server does not currently advertise ready recorded Standard/Jev runs. Nothing was charged.');
73
+ }
74
+ const matches = recorded.profiles.filter(value => value.profileVersion === profileVersion);
75
+ if (matches.length !== 1 || !['playwright_web', 'godot_driver'].includes(matches[0].driver)) fail('Choose exactly one currently advertised ready hosted profile. Nothing was charged.');
76
+ if (standardProvider !== undefined && (!PROVIDERS.includes(standardProvider)
77
+ || !Array.isArray(recorded.standardProviders) || !recorded.standardProviders.includes(standardProvider))) {
78
+ fail(`This server does not advertise ready Standard ${standardProvider}. No provider fallback or charge was requested.`);
79
+ }
80
+ return { profile: matches[0], funding: caps.funding };
81
+ }
82
+
83
+ function sum(rows, value) {
84
+ const total = rows.reduce((n, row) => n + value(row), 0n);
85
+ if (total > BigInt(Number.MAX_SAFE_INTEGER)) fail('The server quote has an unsupported price. Nothing was charged.');
86
+ return Number(total);
87
+ }
88
+
89
+ export function hostedConsent(quote, request, now = Date.now()) {
90
+ if (!quote || quote.kind !== 'recorded_graphical' || !/^hq_[a-f0-9-]{36}$/.test(quote.quoteId || '') || !hex(quote.planHash)
91
+ || quote.buildId !== request.buildId || quote.profileVersion !== request.profileVersion || quote.driver !== request.driver
92
+ || !same(quote.controllers, ['standard', 'jev']) || quote.includeVideo !== request.includeVideo || quote.includeTranscript !== request.includeTranscript
93
+ || quote.currency !== 'USD' || !Number.isSafeInteger(quote.expiresAt) || quote.expiresAt <= now
94
+ || !['localPriceCents', 'computePriceCents', 'totalPriceCents', 'dueNowCents', 'alreadyReservedCents'].every(key => cents(quote[key]))
95
+ || quote.totalPriceCents < 1 || BigInt(quote.localPriceCents) + BigInt(quote.computePriceCents) !== BigInt(quote.totalPriceCents)
96
+ || BigInt(quote.dueNowCents) + BigInt(quote.alreadyReservedCents) !== BigInt(quote.totalPriceCents)
97
+ || !id(quote.priceVersion) || !id(quote.policyVersion) || !['refund', 'retain'].includes(quote.customerFailure)
98
+ || !['refund', 'retain'].includes(quote.cancelAfterStart)) fail('The server quote changed or expired. Nothing was charged; request a fresh quote.');
99
+ if (!Array.isArray(quote.localBreakdown) || !quote.localBreakdown.length || !quote.localBreakdown.every(row => id(row.unit) && cents(row.unitCents) && Number.isSafeInteger(row.count) && row.count > 0)
100
+ || sum(quote.localBreakdown, row => BigInt(row.count) * BigInt(row.unitCents)) !== quote.localPriceCents
101
+ || !Array.isArray(quote.computeBreakdown) || quote.computeBreakdown.length !== 2
102
+ || !quote.computeBreakdown.every((row, index) => row.controller === request.controllers[index] && row.profileVersion === request.profileVersion && cents(row.priceCents))
103
+ || sum(quote.computeBreakdown, row => BigInt(row.priceCents)) !== quote.computePriceCents) fail('The server did not quote every Standard/Jev price. Nothing was charged.');
104
+ const explicit = request.standardProvider !== undefined;
105
+ if (explicit ? quote.standardProvider !== request.standardProvider || !hex(quote.standardProviderPolicyHash)
106
+ : Object.hasOwn(quote, 'standardProvider') || Object.hasOwn(quote, 'standardProviderPolicyHash')) {
107
+ fail('The quoted Standard provider policy does not match the requested choice. Nothing was charged.');
108
+ }
109
+ return Object.fromEntries([...CONSENT_KEYS, ...(explicit ? ['standardProvider', 'standardProviderPolicyHash'] : [])].map(key => [key, quote[key]]));
110
+ }
111
+
112
+ export function validateHostedOffer(offer, apiUrl, now = Date.now()) {
113
+ if (offer?.schema !== 'ravensight-hosted-recorded-quote/v1' || !offer.request || !offer.quote || !id(offer.gameId) || offer.apiUrl !== origin(apiUrl)
114
+ || offer.integrity !== digest({ apiUrl: offer.apiUrl, gameId: offer.gameId, request: offer.request, quote: offer.quote, consent: offer.consent })) fail('The saved quote does not match this API or its original contents. Nothing was charged.');
115
+ const request = requestFor({ ...offer.request, persona: offer.request.personas?.[0] }, { driver: offer.request.driver });
116
+ if (!same(request, offer.request) || !same(hostedConsent(offer.quote, request, now), offer.consent)) fail('The saved quote request or consent changed. Nothing was charged.');
117
+ return offer;
118
+ }
119
+
120
+ export async function quoteHostedRun({ api, apiUrl, gameId, output, now = Date.now, ...options }) {
121
+ if (!id(gameId) || !output) fail('Choose a game and an output file for the original quote.');
122
+ try { await access(output); fail('That saved quote already exists. Choose a new output path; the original was preserved.'); }
123
+ catch (error) { if (error.code !== 'ENOENT') throw error; }
124
+ const { profile } = await available(api, gameId, options.profileVersion, options.standardProvider);
125
+ const request = requestFor(options, profile);
126
+ const response = await api.request('POST', `${base(gameId)}/quotes`, { body: request, idempotent: false });
127
+ if (response.status !== 200) fail('The server did not return a hosted quote. No admission was requested.');
128
+ const quote = response.body, consent = hostedConsent(quote, request, now());
129
+ const value = { apiUrl: origin(apiUrl), gameId, request, quote, consent };
130
+ const offer = { schema: 'ravensight-hosted-recorded-quote/v1', ...value, integrity: digest(value) };
131
+ await exclusiveJson(path.resolve(output), offer);
132
+ return { state: 'quoted', quoteFile: path.resolve(output), ...offer, walletMutationRequested: false };
133
+ }
134
+
135
+ export function hostedQuoteLines(offer) {
136
+ const { quote: q, request: r } = offer;
137
+ return [`Game ${offer.gameId}; build ${r.buildId}; persona ${r.personas[0]}; profile ${r.profileVersion}.`,
138
+ `Two separate playthroughs: Standard (${r.standardProvider || 'server default Anthropic'}) then Jev.`,
139
+ ...(r.standardProvider ? [`Standard provider policy: ${q.standardProviderPolicyHash}.`] : []),
140
+ ...q.localBreakdown.map(row => `${row.count} ${row.unit} at ${money(row.unitCents)} each: ${money(row.count * row.unitCents)}.`),
141
+ ...q.computeBreakdown.map(row => `${row.controller} hosted compute: ${money(row.priceCents)}.`),
142
+ `Total ${money(q.totalPriceCents)}; already reserved ${money(q.alreadyReservedCents)}; due now ${money(q.dueNowCents)} from prepaid funds. No top-up is performed.`,
143
+ `Video: ${r.includeVideo ? 'included' : 'off'}; transcript: ${r.includeTranscript ? 'included' : 'off'}.`,
144
+ `Customer failure: ${q.customerFailure}; cancellation after start: ${q.cancelAfterStart}. Price ${q.priceVersion}; refund policy ${q.policyVersion}.`,
145
+ `Quote expires ${new Date(q.expiresAt).toISOString()}.`];
146
+ }
147
+
148
+ // A saved durable intent is never retried, even with the same quote or a new
149
+ // capability response. The server also receives a stable key for this plan.
150
+ export async function runHostedQuote({ filename, api, apiUrl, repoRoot, confirm, onPrepared = () => {}, now = Date.now }) {
151
+ const offer = validateHostedOffer(await readHostedQuote(filename), apiUrl, now());
152
+ const identity = digest({ apiUrl: offer.apiUrl, gameId: offer.gameId, planHash: offer.quote.planHash });
153
+ const journal = path.join(repoRoot, '.ravensight', 'hosted-runs', `${identity}.json`);
154
+ try { await access(journal); fail(`This hosted quote already has an admission intent at ${journal}. Reconcile the original outcome; no POST was repeated.`); }
155
+ catch (error) { if (error.code !== 'ENOENT') throw error; }
156
+ const { profile, funding } = await available(api, offer.gameId, offer.request.profileVersion, offer.request.standardProvider);
157
+ if (profile.driver !== offer.request.driver || funding?.eligible === false || offer.quote.funding?.eligible === false) fail('The server no longer advertises this profile or sufficient prepaid funding. Nothing was charged.');
158
+ await onPrepared(offer);
159
+ if (typeof confirm !== 'function') fail('The full hosted quote must be confirmed before admission.');
160
+ if (!await confirm(`Pay ${money(offer.quote.dueNowCents)} now (${money(offer.quote.totalPriceCents)} total) for the quoted Standard then Jev runs and the evidence options shown?`)) return { state: 'declined', walletMutationRequested: false };
161
+ validateHostedOffer(offer, apiUrl, now());
162
+ const state = { schema: 'ravensight-hosted-admission-journal/v1', apiUrl: offer.apiUrl, gameId: offer.gameId,
163
+ quoteId: offer.quote.quoteId, planHash: offer.quote.planHash, consent: offer.consent,
164
+ idempotencyKey: `hosted-${offer.quote.planHash}`, phase: 'admission_unknown', createdAt: new Date(now()).toISOString() };
165
+ await exclusiveJson(journal, state); // fsync BEFORE the only wallet-mutating POST
166
+ try {
167
+ let responseCaptured = false;
168
+ const answer = await api.request('POST', `${base(offer.gameId)}/runs`, { body: offer.consent,
169
+ headers: { 'Idempotency-Key': state.idempotencyKey }, idempotent: false, raw: true,
170
+ onResponseRaw: async ({ status, contentType, bytes, bodyComplete }) => {
171
+ await exclusiveJson(journal + '.response.json', { status, contentType, bodyComplete,
172
+ bodyBase64: bytes.toString('base64'), bodyBytes: bytes.length,
173
+ bodySha256: createHash('sha256').update(bytes).digest('hex') });
174
+ responseCaptured = true;
175
+ } });
176
+ if (!responseCaptured) fail('The admission response was not captured. Preserve the original intent.');
177
+ if (answer.status !== 202 || answer.headers.get('content-type')?.split(';')[0].trim().toLowerCase() !== 'application/json'
178
+ || typeof answer.body !== 'string' || Buffer.byteLength(answer.body) > MAX_QUOTE_BYTES) fail('The admission acknowledgement needs reconciliation.');
179
+ const ack = JSON.parse(answer.body);
180
+ if (!same(Object.keys(ack).sort(), ['admissionId', 'dispatchAllowed', 'jobId', 'state', 'totalPriceCents'])
181
+ || ack.state !== 'charged_blocked' || ack.dispatchAllowed !== false || ack.totalPriceCents !== offer.quote.totalPriceCents
182
+ || !/^ha_[a-f0-9-]{36}$/.test(ack.admissionId || '') || !/^pt_job_[a-f0-9]{20}$/.test(ack.jobId || '')) fail('The admission acknowledgement needs reconciliation.');
183
+ state.phase = 'charged_blocked'; state.admission = ack;
184
+ await exclusiveJson(journal + '.acknowledged.json', state);
185
+ await rename(journal + '.acknowledged.json', journal);
186
+ return { state: 'charged_blocked', ...ack, journal, replayAuthorized: false, gameplayProven: false };
187
+ } catch (error) {
188
+ if (error instanceof ApiError && error.status === 401) throw error;
189
+ fail(`The original admission outcome needs reconciliation. Preserve ${journal}; no retry or replacement admission was requested.`);
190
+ }
191
+ }
@@ -18,7 +18,7 @@ export async function readBridgeConfig(file) {
18
18
  }
19
19
 
20
20
  export function createStdioDriver({ spawn: spawnImpl = spawn, timeoutMs = 30000 } = {}) {
21
- let child, buffer = '', nextId = 0, runDirectory, step = 0, shots = 0, lastHash;
21
+ let child, childState, buffer = '', nextId = 0, runDirectory, step = 0, shots = 0, lastHash;
22
22
  const pending = new Map();
23
23
  const fail = message => {
24
24
  for (const entry of pending.values()) { clearTimeout(entry.timer); entry.reject(new DriverError(message, { fatal: true })); }
@@ -52,6 +52,9 @@ export function createStdioDriver({ spawn: spawnImpl = spawn, timeoutMs = 30000
52
52
  const config = await readBridgeConfig(target);
53
53
  runDirectory = runDir;
54
54
  child = spawnImpl(config.command, config.args || [], { cwd: config.cwd || path.dirname(path.resolve(target)), shell: false, stdio: ['pipe', 'pipe', 'pipe'] });
55
+ const state = { process: child, closed: false, stopping: null };
56
+ state.close = new Promise(resolve => child.once('close', () => { state.closed = true; resolve(); }));
57
+ childState = state;
55
58
  child.stdout.setEncoding('utf8');
56
59
  child.stdout.on('data', chunk => {
57
60
  buffer += chunk;
@@ -92,20 +95,33 @@ export function createStdioDriver({ spawn: spawnImpl = spawn, timeoutMs = 30000
92
95
  async screenshot(reason) {
93
96
  const image = await request('screenshot');
94
97
  if (typeof image?.pngBase64 !== 'string' || !image.pngBase64.startsWith('iVBORw0KGgo')) throw new DriverError('This bridge did not return a PNG screenshot.');
95
- const relativePath = path.join('screenshots', screenshotName(++shots, reason));
98
+ const relativePath = path.posix.join('screenshots', screenshotName(++shots, reason));
96
99
  const file = path.join(runDirectory, relativePath);
97
100
  await fs.writeFile(file, Buffer.from(image.pngBase64, 'base64'));
98
101
  return { path: file, relativePath };
99
102
  },
100
103
  async consoleErrors() { return []; },
101
104
  async stop() {
102
- if (child && child.exitCode === null && !child.killed) {
103
- child.kill('SIGTERM');
104
- const process = child;
105
- const timer = setTimeout(() => { if (process.exitCode === null) process.kill('SIGKILL'); }, 1000);
106
- timer.unref();
107
- }
108
105
  fail('Game bridge stopped.');
106
+ const state = childState;
107
+ if (state && !state.stopping) {
108
+ state.stopping = (async () => {
109
+ if (state.closed) return;
110
+ const process = state.process;
111
+ let deadline;
112
+ const force = setTimeout(() => { if (!state.closed) process.kill('SIGKILL'); }, 1000);
113
+ try {
114
+ const timedOut = new Promise((resolve, reject) => {
115
+ deadline = setTimeout(() => reject(new DriverError('Game bridge did not close after stopping.', { fatal: true })), 5000);
116
+ });
117
+ if (process.exitCode === null && process.signalCode === null && !process.killed) process.kill('SIGTERM');
118
+ // Windows can retain the bridge's cwd until its pipes close, even
119
+ // after exit. Concurrent/repeated stops share the same close wait.
120
+ await Promise.race([state.close, timedOut]);
121
+ } finally { clearTimeout(force); clearTimeout(deadline); }
122
+ })();
123
+ }
124
+ if (state) await state.stopping;
109
125
  return { videoPath: null };
110
126
  }
111
127
  };
@@ -1706,7 +1706,9 @@ export function createWebDriver({ importer, headless = true, playtest = null, pl
1706
1706
  async screenshot(name) {
1707
1707
  const current = requirePage();
1708
1708
  shots += 1;
1709
- const relativePath = path.join('screenshots', screenshotName(shots, name));
1709
+ // Forward slashes on every platform: this is the evidence ref the server
1710
+ // matches against upload paths.
1711
+ const relativePath = path.posix.join('screenshots', screenshotName(shots, name));
1710
1712
  const absolute = path.join(runDirectory, relativePath);
1711
1713
  await mkdir(path.dirname(absolute), { recursive: true });
1712
1714
  await current.screenshot({ path: absolute, type: 'png', timeout: ACTION_TIMEOUT_MS });
@@ -61,7 +61,7 @@ export function createWebDriverAdapter({name='desktop_driver',fetch:fetchImpl=gl
61
61
  }catch(error){return {ok:false,changed:false,error:error instanceof DriverError?error.message:'Device input failed.'};}
62
62
  finally {if(session)await request('DELETE',route('/actions')).catch(()=>{});}
63
63
  },
64
- async screenshot(reason){const relativePath=path.join('screenshots',screenshotName(++shots,reason));const file=path.join(runDirectory,relativePath);await fs.writeFile(file,Buffer.from(await frame(),'base64'));return {path:file,relativePath,reason};},
64
+ async screenshot(reason){const relativePath=path.posix.join('screenshots',screenshotName(++shots,reason));const file=path.join(runDirectory,relativePath);await fs.writeFile(file,Buffer.from(await frame(),'base64'));return {path:file,relativePath,reason};},
65
65
  async consoleErrors(){return [];},
66
66
  async stop(){if(session){const current=route('');session=null;await request('DELETE',current);}return {videoPath:null};}
67
67
  };return driver;