@volter/play 0.5.211 → 0.5.212
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/NOTICE +5 -5
- package/README.md +125 -172
- package/contributions/game-panel.tsx +908 -0
- package/contributions/model-play.verbs.ts +44 -0
- package/contributions/play.page.tsx +211 -0
- package/contributions/std-scene.document.tsx +345 -0
- package/dist-node/serving.mjs +664 -0
- package/package.json +257 -12
- package/serving/index.ts +13 -0
- package/serving/std-dev.ts +514 -0
- package/serving/std-scene-runtime.js +119 -0
- package/serving/std-scene.ts +213 -0
- package/src/editor-blender.ts +33 -0
- package/src/editor-threejs.ts +19 -0
- package/src/model-play.ts +60 -287
- package/src/play-clock.ts +62 -0
- package/src/play-log.ts +77 -62
- package/src/std-controls.ts +128 -0
- package/src/std-export.ts +128 -0
- package/src/std-game-adapter.ts +20 -0
- package/src/std-game.ts +841 -0
- package/src/std-level-files.ts +33 -0
- package/src/std-level.ts +107 -0
- package/src/std-scene-level.ts +59 -0
- package/src/std-view-bar.tsx +78 -0
- package/contributions/model-play.command.ts +0 -37
- package/contributions/model-play.service.tsx +0 -152
- package/src/camera-transition.ts +0 -87
- package/src/play-materials.ts +0 -199
- package/src/play-script.ts +0 -841
|
@@ -0,0 +1,664 @@
|
|
|
1
|
+
// serving/std-dev.ts
|
|
2
|
+
import { spawn } from "node:child_process";
|
|
3
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
4
|
+
import { open, readdir, readFile } from "node:fs/promises";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
import { StringDecoder } from "node:string_decoder";
|
|
7
|
+
import { pathToFileURL } from "node:url";
|
|
8
|
+
import { stripVTControlCharacters } from "node:util";
|
|
9
|
+
|
|
10
|
+
// ../sdk/src/npm-process.ts
|
|
11
|
+
import { accessSync, constants, statSync } from "node:fs";
|
|
12
|
+
import { delimiter, dirname, resolve } from "node:path";
|
|
13
|
+
function resolveNpmProcess(cwd, path) {
|
|
14
|
+
if (process.platform !== "win32") {
|
|
15
|
+
for (const dir of path.split(delimiter)) {
|
|
16
|
+
const program = resolve(cwd, dir, "npm");
|
|
17
|
+
try {
|
|
18
|
+
accessSync(program, constants.X_OK);
|
|
19
|
+
if (statSync(program).isFile()) return { program, args: [] };
|
|
20
|
+
} catch {
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
return void 0;
|
|
24
|
+
}
|
|
25
|
+
const dirs = [dirname(process.execPath), ...path.split(delimiter).filter((dir) => {
|
|
26
|
+
try {
|
|
27
|
+
return statSync(resolve(cwd, dir, "npm.cmd")).isFile();
|
|
28
|
+
} catch {
|
|
29
|
+
return false;
|
|
30
|
+
}
|
|
31
|
+
})];
|
|
32
|
+
for (const dir of dirs) {
|
|
33
|
+
const cli = resolve(cwd, dir, "node_modules", "npm", "bin", "npm-cli.js");
|
|
34
|
+
try {
|
|
35
|
+
if (statSync(cli).isFile()) return { program: process.execPath, args: [cli] };
|
|
36
|
+
} catch {
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
return void 0;
|
|
40
|
+
}
|
|
41
|
+
function npmProcess(args, cwd) {
|
|
42
|
+
const npm = resolveNpmProcess(cwd, process.env["PATH"] ?? "");
|
|
43
|
+
if (!npm) throw new Error("npm is unavailable: install Node.js with npm, then retry.");
|
|
44
|
+
return { program: npm.program, args: [...npm.args, ...args] };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// ../sdk/src/process-tree.ts
|
|
48
|
+
import { execFileSync } from "node:child_process";
|
|
49
|
+
function signalProcessTree(pid, signal) {
|
|
50
|
+
if (process.platform === "win32") {
|
|
51
|
+
try {
|
|
52
|
+
execFileSync("taskkill", ["/PID", String(pid), "/T", "/F"], { windowsHide: true, stdio: "ignore" });
|
|
53
|
+
} catch (error) {
|
|
54
|
+
if (error.code === "ENOENT") {
|
|
55
|
+
try {
|
|
56
|
+
process.kill(pid, signal);
|
|
57
|
+
} catch {
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
try {
|
|
64
|
+
process.kill(-pid, signal);
|
|
65
|
+
} catch {
|
|
66
|
+
try {
|
|
67
|
+
process.kill(pid, signal);
|
|
68
|
+
} catch {
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// serving/std-dev.ts
|
|
74
|
+
var STD_ROUTE = "/__editor/play/std";
|
|
75
|
+
var START_MS = 12e4;
|
|
76
|
+
var LOOK_MS = 250;
|
|
77
|
+
var TRACE_READ_BYTES = 256 * 1024;
|
|
78
|
+
var TRACE_HEAD_BYTES = 4096;
|
|
79
|
+
var PAGE_ID = /^[0-9a-f]{32}(-rerun)?$/;
|
|
80
|
+
var CAPTURE_FILE = /^[0-9a-f]{32}(-rerun)?-capture-\d+\.png$/;
|
|
81
|
+
var stdDev = (root) => join(root, "node_modules", "@volter-ai", "std-dev");
|
|
82
|
+
var blenderHalfOf = (root) => join(stdDev(root), "blender", "blend-to-scene.py");
|
|
83
|
+
var isLevelGlb = (path) => (/^levels\/.+\.glb$/.test(path) || /^prefabs\/([^/]+)\/\1\.glb$/.test(path)) && !path.includes("\\") && path.split("/").every((part) => part !== "" && part !== "." && part !== "..");
|
|
84
|
+
function runStdLevel(root, glb) {
|
|
85
|
+
return new Promise((resolve2) => {
|
|
86
|
+
const run = spawn(process.execPath, [join(stdDev(root), "bin", "std.js"), "level", glb, "--description", glb.replace(/\.glb$/, ".level.json")], { cwd: root, windowsHide: true });
|
|
87
|
+
let output = "";
|
|
88
|
+
run.stdout.on("data", (chunk) => {
|
|
89
|
+
output += chunk.toString();
|
|
90
|
+
});
|
|
91
|
+
run.stderr.on("data", (chunk) => {
|
|
92
|
+
output += chunk.toString();
|
|
93
|
+
});
|
|
94
|
+
run.on("error", (error) => resolve2({ code: null, output: `${output}${error.message}` }));
|
|
95
|
+
run.on("close", (code) => resolve2({ code, output }));
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
var GOALS_MS = 1e4;
|
|
99
|
+
var BOT_ENTRIES = ["index.ts", "index.js", "index.mjs"];
|
|
100
|
+
var READ_GOALS = [
|
|
101
|
+
"const program = (await import(process.argv[1])).program;",
|
|
102
|
+
"if (typeof program?.goals !== 'object' || program.goals === null) throw new Error('autopilot/index exports no program with goals (autopilot({ goals }))');",
|
|
103
|
+
"console.log(JSON.stringify(Object.keys(program.goals)));"
|
|
104
|
+
].join("\n");
|
|
105
|
+
function botGoals(root) {
|
|
106
|
+
const entry = BOT_ENTRIES.map((name) => join(root, "autopilot", name)).find((file) => existsSync(file));
|
|
107
|
+
if (entry === void 0) return Promise.resolve({ status: 200, body: { goals: [] } });
|
|
108
|
+
return new Promise((resolve2) => {
|
|
109
|
+
const read = spawn(
|
|
110
|
+
process.execPath,
|
|
111
|
+
["--input-type=module", "--eval", READ_GOALS, pathToFileURL(entry).href],
|
|
112
|
+
{ cwd: root, windowsHide: true, timeout: GOALS_MS }
|
|
113
|
+
);
|
|
114
|
+
let output = "";
|
|
115
|
+
let said = "";
|
|
116
|
+
read.stdout.on("data", (chunk) => {
|
|
117
|
+
output += chunk.toString();
|
|
118
|
+
});
|
|
119
|
+
read.stderr.on("data", (chunk) => {
|
|
120
|
+
said += chunk.toString();
|
|
121
|
+
});
|
|
122
|
+
read.on("error", (error) => resolve2({ status: 500, body: { error: `The game's goals could not be read: ${error.message}` } }));
|
|
123
|
+
read.on("close", (code) => {
|
|
124
|
+
let goals = null;
|
|
125
|
+
try {
|
|
126
|
+
if (code === 0) goals = JSON.parse(output.trim().split("\n").at(-1) ?? "[]");
|
|
127
|
+
} catch {
|
|
128
|
+
}
|
|
129
|
+
resolve2(Array.isArray(goals) && goals.every((goal) => typeof goal === "string") ? { status: 200, body: { goals } } : { status: 500, body: { error: `The game's goals could not be read from ${entry} (exit ${String(code)}): ${said.trim() || output.trim()}` } });
|
|
130
|
+
});
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
async function levelAnswer(root, method, body) {
|
|
134
|
+
if (!stdGame(root).game || !existsSync(blenderHalfOf(root)))
|
|
135
|
+
return { status: 404, body: { error: "The project is not a std game with @volter-ai/std-dev installed: no std.json, or no blender/blend-to-scene.py in its std-dev." } };
|
|
136
|
+
if (method === "GET") return { status: 200, body: { blenderHalf: await readFile(blenderHalfOf(root), "utf8") } };
|
|
137
|
+
const glb = body?.glb;
|
|
138
|
+
if (typeof glb !== "string" || !isLevelGlb(glb))
|
|
139
|
+
return { status: 400, body: { error: `A level's glb is a path under levels/ ending .glb, a prefab's prefabs/<name>/<name>.glb; it was given ${JSON.stringify(glb)}.` } };
|
|
140
|
+
return { status: 200, body: await runStdLevel(root, glb) };
|
|
141
|
+
}
|
|
142
|
+
var ENGINE_PACKAGES = { roblox: "@volter-ai/std-roblox", unreal: "@volter-ai/std-unreal" };
|
|
143
|
+
var ENGINE_LABELS = { roblox: "Roblox", unreal: "Unreal" };
|
|
144
|
+
var isEngine = (value) => typeof value === "string" && Object.hasOwn(ENGINE_PACKAGES, value);
|
|
145
|
+
function stdGame(root) {
|
|
146
|
+
if (!existsSync(join(root, "std.json"))) return { game: false, goal: null, targets: [] };
|
|
147
|
+
const manifest = JSON.parse(readFileSync(join(root, "package.json"), "utf8"));
|
|
148
|
+
const named = (name) => manifest.dependencies?.[name] !== void 0 || manifest.devDependencies?.[name] !== void 0;
|
|
149
|
+
const targets = Object.keys(ENGINE_PACKAGES).filter((engine) => named(ENGINE_PACKAGES[engine]));
|
|
150
|
+
return { game: named("@volter-ai/std-dev"), goal: /--goal[ =](\S+)/.exec(manifest.scripts?.play ?? "")?.[1] ?? null, targets };
|
|
151
|
+
}
|
|
152
|
+
var EXPORT_MS = 30 * 6e4;
|
|
153
|
+
var EXPORTED = { web: "dist", roblox: "build/roblox", unreal: "build/unreal/StdGame" };
|
|
154
|
+
var EXPORT_LINE_MOST = 2e3;
|
|
155
|
+
var exporting = /* @__PURE__ */ new Map();
|
|
156
|
+
function endExports() {
|
|
157
|
+
for (const ended of exporting.values()) ended.abort();
|
|
158
|
+
exporting.clear();
|
|
159
|
+
}
|
|
160
|
+
function runStd(root, args, line, ended) {
|
|
161
|
+
return new Promise((resolve2) => {
|
|
162
|
+
const run = spawn(
|
|
163
|
+
process.execPath,
|
|
164
|
+
[join(stdDev(root), "bin", "std.js"), ...args],
|
|
165
|
+
{ cwd: root, windowsHide: true, detached: process.platform !== "win32" }
|
|
166
|
+
);
|
|
167
|
+
const lines = () => {
|
|
168
|
+
const decoder = new StringDecoder("utf8");
|
|
169
|
+
let held = "";
|
|
170
|
+
return (chunk) => {
|
|
171
|
+
held += chunk === null ? decoder.end() : decoder.write(chunk);
|
|
172
|
+
const parts = held.split(/\r\n|\r|\n/);
|
|
173
|
+
held = chunk === null ? "" : parts.pop() ?? "";
|
|
174
|
+
if (held.length > EXPORT_LINE_MOST) {
|
|
175
|
+
parts.push(held);
|
|
176
|
+
held = "";
|
|
177
|
+
}
|
|
178
|
+
for (const part of parts) if (part.trim() !== "") line(stripVTControlCharacters(part).slice(0, EXPORT_LINE_MOST));
|
|
179
|
+
};
|
|
180
|
+
};
|
|
181
|
+
const out = lines();
|
|
182
|
+
const err = lines();
|
|
183
|
+
run.stdout.on("data", out);
|
|
184
|
+
run.stdout.on("end", () => out(null));
|
|
185
|
+
run.stderr.on("data", err);
|
|
186
|
+
run.stderr.on("end", () => err(null));
|
|
187
|
+
const end = () => {
|
|
188
|
+
if (run.pid !== void 0 && run.exitCode === null) signalProcessTree(run.pid, "SIGTERM");
|
|
189
|
+
};
|
|
190
|
+
ended.addEventListener("abort", end, { once: true });
|
|
191
|
+
run.on("error", (error) => {
|
|
192
|
+
line(error.message);
|
|
193
|
+
resolve2(null);
|
|
194
|
+
});
|
|
195
|
+
run.on("close", (code) => {
|
|
196
|
+
ended.removeEventListener("abort", end);
|
|
197
|
+
resolve2(code);
|
|
198
|
+
});
|
|
199
|
+
});
|
|
200
|
+
}
|
|
201
|
+
async function exportGame(root, body, response) {
|
|
202
|
+
const asked = body;
|
|
203
|
+
const target = asked?.target;
|
|
204
|
+
if (target !== "web" && !isEngine(target) || asked?.add !== void 0 && typeof asked.add !== "boolean")
|
|
205
|
+
return answer(response, 400, { error: `An export is { target, add? }: web, roblox or unreal, and whether to add an engine as the game's target; it was given ${JSON.stringify(body)}.` });
|
|
206
|
+
const game = stdGame(root);
|
|
207
|
+
if (!game.game) return answer(response, 404, { error: "The project is not a std game: no std.json, or no @volter-ai/std-dev among its packages." });
|
|
208
|
+
const added = target !== "web" && !game.targets.includes(target);
|
|
209
|
+
if (added && asked?.add !== true)
|
|
210
|
+
return answer(response, 409, { error: `${ENGINE_LABELS[target]} isn't a target of this game: Export's "Add ${ENGINE_LABELS[target]} and export" adds it (std target add ${target}).` });
|
|
211
|
+
if (exporting.has(root)) return answer(response, 409, { error: "An export of this game is running: wait for it to end." });
|
|
212
|
+
const ended = new AbortController();
|
|
213
|
+
exporting.set(root, ended);
|
|
214
|
+
response.statusCode = 200;
|
|
215
|
+
response.setHeader("content-type", "application/x-ndjson");
|
|
216
|
+
const said = (message) => {
|
|
217
|
+
if (!response.writableEnded && !response.destroyed) response.write(`${JSON.stringify(message)}
|
|
218
|
+
`);
|
|
219
|
+
};
|
|
220
|
+
response.on("close", () => ended.abort());
|
|
221
|
+
const limit = setTimeout(() => {
|
|
222
|
+
said({ line: `Play ended the export: it ran ${EXPORT_MS / 6e4} minutes.` });
|
|
223
|
+
ended.abort();
|
|
224
|
+
}, EXPORT_MS);
|
|
225
|
+
const steps = target === "web" ? [["build"]] : [["target", "add", target], ["export", "--target", target]];
|
|
226
|
+
let ok = true;
|
|
227
|
+
try {
|
|
228
|
+
for (const step of steps) {
|
|
229
|
+
if (ended.signal.aborted) {
|
|
230
|
+
ok = false;
|
|
231
|
+
break;
|
|
232
|
+
}
|
|
233
|
+
said({ line: `std ${step.join(" ")}` });
|
|
234
|
+
if (await runStd(root, step, (line) => said({ line }), ended.signal) !== 0) {
|
|
235
|
+
ok = false;
|
|
236
|
+
break;
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
} finally {
|
|
240
|
+
clearTimeout(limit);
|
|
241
|
+
if (exporting.get(root) === ended) exporting.delete(root);
|
|
242
|
+
}
|
|
243
|
+
said({ end: { ok, added: added && stdGame(root).targets.includes(target), out: EXPORTED[target] } });
|
|
244
|
+
response.end();
|
|
245
|
+
}
|
|
246
|
+
var alive = (pid) => {
|
|
247
|
+
try {
|
|
248
|
+
process.kill(pid, 0);
|
|
249
|
+
return true;
|
|
250
|
+
} catch {
|
|
251
|
+
return false;
|
|
252
|
+
}
|
|
253
|
+
};
|
|
254
|
+
async function runningServer(root) {
|
|
255
|
+
const text = await readFile(join(root, "node_modules", ".vite", "std-dev.json"), "utf8").catch(() => null);
|
|
256
|
+
if (text === null) return null;
|
|
257
|
+
const record = JSON.parse(text);
|
|
258
|
+
return alive(record.pid) ? record : null;
|
|
259
|
+
}
|
|
260
|
+
var ERRORS_KEPT = 500;
|
|
261
|
+
var LINE_MOST = 4e3;
|
|
262
|
+
var owned = /* @__PURE__ */ new Map();
|
|
263
|
+
function heardError(server, chunk) {
|
|
264
|
+
const text = chunk === null ? server.decoder.end() : server.decoder.write(chunk);
|
|
265
|
+
const lines = stripVTControlCharacters(server.partial + text).split(/\r?\n/);
|
|
266
|
+
server.partial = chunk === null ? "" : (lines.pop() ?? "").slice(-LINE_MOST);
|
|
267
|
+
server.errors.push(...lines.filter((line, at) => chunk !== null || at < lines.length - 1 || line !== ""));
|
|
268
|
+
const over = server.errors.length - ERRORS_KEPT;
|
|
269
|
+
if (over > 0) {
|
|
270
|
+
server.errors.splice(0, over);
|
|
271
|
+
server.dropped += over;
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
function errorsFrom(root, server, from) {
|
|
275
|
+
const mine = owned.get(root);
|
|
276
|
+
const pid = mine?.child.pid ?? null;
|
|
277
|
+
if (!mine || pid === null) return { lines: [], next: 0, server: null };
|
|
278
|
+
const next = mine.dropped + mine.errors.length;
|
|
279
|
+
if (from === null) return { lines: [], next, server: pid };
|
|
280
|
+
const start = server === pid ? from : 0;
|
|
281
|
+
return { lines: mine.errors.slice(Math.max(0, start - mine.dropped)), next, server: pid };
|
|
282
|
+
}
|
|
283
|
+
function endOwned() {
|
|
284
|
+
for (const { child } of owned.values()) if (child.pid !== void 0 && child.exitCode === null) signalProcessTree(child.pid, "SIGTERM");
|
|
285
|
+
owned.clear();
|
|
286
|
+
}
|
|
287
|
+
async function startedServer(root) {
|
|
288
|
+
const running = await runningServer(root);
|
|
289
|
+
if (running) return running;
|
|
290
|
+
let mine = owned.get(root);
|
|
291
|
+
if (!mine || mine.child.exitCode !== null || mine.failed !== null) {
|
|
292
|
+
const npm = npmProcess(["run", "dev", "--", "--no-open"], root);
|
|
293
|
+
const child = spawn(npm.program, npm.args, { cwd: root, detached: process.platform !== "win32", windowsHide: true, stdio: ["ignore", "pipe", "pipe"] });
|
|
294
|
+
mine = { child, said: "", failed: null, errors: [], dropped: 0, partial: "", decoder: new StringDecoder("utf8") };
|
|
295
|
+
const kept = mine;
|
|
296
|
+
const hear = (chunk) => {
|
|
297
|
+
kept.said = (kept.said + chunk.toString("utf8")).slice(-4e3);
|
|
298
|
+
};
|
|
299
|
+
child.stdout?.on("data", hear);
|
|
300
|
+
child.stderr?.on("data", hear);
|
|
301
|
+
child.stderr?.on("data", (chunk) => heardError(kept, chunk));
|
|
302
|
+
child.stderr?.on("end", () => heardError(kept, null));
|
|
303
|
+
child.on("error", (error) => {
|
|
304
|
+
kept.failed = error.message;
|
|
305
|
+
});
|
|
306
|
+
owned.set(root, mine);
|
|
307
|
+
}
|
|
308
|
+
const bound = Date.now() + START_MS;
|
|
309
|
+
for (; ; ) {
|
|
310
|
+
const record = await runningServer(root);
|
|
311
|
+
if (record) return record;
|
|
312
|
+
if (mine.failed !== null) throw new Error(`The game's dev server (npm run dev) could not be started: ${mine.failed}`);
|
|
313
|
+
if (mine.child.exitCode !== null) throw new Error(`The game's dev server (npm run dev) ended with code ${mine.child.exitCode}:
|
|
314
|
+
${mine.said.trim()}`);
|
|
315
|
+
if (Date.now() > bound) throw new Error(`The game's dev server (npm run dev) wrote no record in ${START_MS / 1e3} s:
|
|
316
|
+
${mine.said.trim()}`);
|
|
317
|
+
await new Promise((done) => setTimeout(done, LOOK_MS));
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
async function newestRun(root) {
|
|
321
|
+
const runs = join(root, "build", "runs");
|
|
322
|
+
const newest = (await readdir(runs).catch(() => [])).sort().at(-1);
|
|
323
|
+
return newest === void 0 ? null : join(runs, newest);
|
|
324
|
+
}
|
|
325
|
+
async function pageSlots(root) {
|
|
326
|
+
const run = await newestRun(root);
|
|
327
|
+
if (run === null) return {};
|
|
328
|
+
const slots = {};
|
|
329
|
+
for (const name of await readdir(run)) {
|
|
330
|
+
const page = name.replace(/\.jsonl$/, "");
|
|
331
|
+
if (page === name || !PAGE_ID.test(page)) continue;
|
|
332
|
+
const handle = await open(join(run, name), "r");
|
|
333
|
+
try {
|
|
334
|
+
const buffer = Buffer.alloc(TRACE_HEAD_BYTES);
|
|
335
|
+
const { bytesRead } = await handle.read(buffer, 0, TRACE_HEAD_BYTES, 0);
|
|
336
|
+
const first = buffer.subarray(0, bytesRead).toString("utf8").split("\n")[0] ?? "";
|
|
337
|
+
if (!first.endsWith("}")) continue;
|
|
338
|
+
const head = JSON.parse(first);
|
|
339
|
+
if (head.k === "trace" && typeof head.slot === "string") slots[page] = head.slot;
|
|
340
|
+
} finally {
|
|
341
|
+
await handle.close();
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
return slots;
|
|
345
|
+
}
|
|
346
|
+
async function traceFrom(root, page, from) {
|
|
347
|
+
const run = await newestRun(root);
|
|
348
|
+
if (run === null || !existsSync(join(run, `${page}.jsonl`))) return { lines: [], next: from };
|
|
349
|
+
const handle = await open(join(run, `${page}.jsonl`), "r");
|
|
350
|
+
try {
|
|
351
|
+
const buffer = Buffer.alloc(TRACE_READ_BYTES);
|
|
352
|
+
const { bytesRead } = await handle.read(buffer, 0, TRACE_READ_BYTES, from);
|
|
353
|
+
const text = buffer.subarray(0, bytesRead).toString("utf8");
|
|
354
|
+
const end = text.lastIndexOf("\n") + 1;
|
|
355
|
+
return { lines: text.slice(0, end).split("\n").filter(Boolean), next: from + Buffer.byteLength(text.slice(0, end)) };
|
|
356
|
+
} finally {
|
|
357
|
+
await handle.close();
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
var PLAY_MS = 15 * 6e4;
|
|
361
|
+
async function playGoal(root, body, response) {
|
|
362
|
+
const asked = body;
|
|
363
|
+
if (typeof asked?.goal !== "string" || asked.goal === "" || asked.bots !== void 0 && !(Number.isInteger(asked.bots) && Number(asked.bots) >= 1))
|
|
364
|
+
return answer(response, 400, { error: `A play is { goal, bots? }: a goal of the game's bot, and a whole number of bots; it was given ${JSON.stringify(body)}.` });
|
|
365
|
+
const running = await runningServer(root);
|
|
366
|
+
if (!running) return answer(response, 409, { error: "The game's dev server is not running: press Play." });
|
|
367
|
+
response.statusCode = 200;
|
|
368
|
+
response.setHeader("content-type", "application/x-ndjson");
|
|
369
|
+
const said = (message) => {
|
|
370
|
+
response.write(`${JSON.stringify(message)}
|
|
371
|
+
`);
|
|
372
|
+
};
|
|
373
|
+
const socket = new WebSocket(`${running.url.replace(/^http/, "ws").replace(/\/$/, "")}/__std/play?token=${running.token}`);
|
|
374
|
+
let ended = false;
|
|
375
|
+
const end = (ok, why) => {
|
|
376
|
+
if (ended) return;
|
|
377
|
+
ended = true;
|
|
378
|
+
clearTimeout(limit);
|
|
379
|
+
if (why !== void 0) said({ line: why });
|
|
380
|
+
said({ end: { ok } });
|
|
381
|
+
response.end();
|
|
382
|
+
if (socket.readyState === WebSocket.OPEN || socket.readyState === WebSocket.CONNECTING) socket.close();
|
|
383
|
+
};
|
|
384
|
+
const limit = setTimeout(() => end(false, `Play ended std's play of ${asked.goal}: it ran ${PLAY_MS / 6e4} minutes.`), PLAY_MS);
|
|
385
|
+
socket.addEventListener("open", () => socket.send(JSON.stringify({ goal: asked.goal, bots: asked.bots ?? 1, device: "headless" })));
|
|
386
|
+
socket.addEventListener("message", (event) => {
|
|
387
|
+
const message = JSON.parse(String(event.data));
|
|
388
|
+
if (message.end !== void 0) end(message.end.ok === true);
|
|
389
|
+
else said(message);
|
|
390
|
+
});
|
|
391
|
+
socket.addEventListener("error", () => end(false, "std's play could not be reached on the game's dev server."));
|
|
392
|
+
socket.addEventListener("close", () => end(false, "std's play closed before it said how it ended."));
|
|
393
|
+
response.on("close", () => {
|
|
394
|
+
if (!ended) {
|
|
395
|
+
ended = true;
|
|
396
|
+
clearTimeout(limit);
|
|
397
|
+
socket.close();
|
|
398
|
+
}
|
|
399
|
+
});
|
|
400
|
+
}
|
|
401
|
+
var bodyOf = (request) => request.body;
|
|
402
|
+
function answer(response, status, value) {
|
|
403
|
+
response.statusCode = status;
|
|
404
|
+
response.setHeader("content-type", "application/json");
|
|
405
|
+
response.end(JSON.stringify(value));
|
|
406
|
+
}
|
|
407
|
+
async function askStd(root, path, init) {
|
|
408
|
+
const running = await runningServer(root);
|
|
409
|
+
if (!running) return { status: 409, body: { error: "The game's dev server is not running: press Play." } };
|
|
410
|
+
const response = await fetch(`${running.url}${path}?token=${running.token}`, init);
|
|
411
|
+
return { status: response.status, body: await response.json() };
|
|
412
|
+
}
|
|
413
|
+
function stdDevPlugin(services) {
|
|
414
|
+
return {
|
|
415
|
+
name: "volter-play-std-dev",
|
|
416
|
+
configureServer(server) {
|
|
417
|
+
server.httpServer?.once("close", endOwned);
|
|
418
|
+
process.once("exit", endOwned);
|
|
419
|
+
server.httpServer?.once("close", endExports);
|
|
420
|
+
process.once("exit", endExports);
|
|
421
|
+
server.middlewares.use(STD_ROUTE, (request, response, next) => {
|
|
422
|
+
const root = services.currentProjectRoot();
|
|
423
|
+
if (!root) return next();
|
|
424
|
+
if (!services.requireLocalOwner(request, response)) return;
|
|
425
|
+
const url = new URL(request.url ?? "/", "http://session");
|
|
426
|
+
const captured = url.pathname === "/capture" && request.method === "GET" ? url.searchParams.get("file") ?? "" : null;
|
|
427
|
+
if (captured !== null) {
|
|
428
|
+
void newestRun(root).then(async (run) => {
|
|
429
|
+
if (run === null || !CAPTURE_FILE.test(captured) || !existsSync(join(run, captured)))
|
|
430
|
+
return answer(response, 404, { error: `No captured frame ${JSON.stringify(captured)} in the dev server's newest run.` });
|
|
431
|
+
response.statusCode = 200;
|
|
432
|
+
response.setHeader("content-type", "image/png");
|
|
433
|
+
response.end(await readFile(join(run, captured)));
|
|
434
|
+
}).catch((error) => answer(response, 500, { error: error instanceof Error ? error.message : String(error) }));
|
|
435
|
+
return;
|
|
436
|
+
}
|
|
437
|
+
if (url.pathname === "/export" && request.method === "POST") {
|
|
438
|
+
void exportGame(root, bodyOf(request), response).catch((error) => {
|
|
439
|
+
if (!response.headersSent) answer(response, 500, { error: error instanceof Error ? error.message : String(error) });
|
|
440
|
+
else response.end();
|
|
441
|
+
});
|
|
442
|
+
return;
|
|
443
|
+
}
|
|
444
|
+
if (url.pathname === "/play" && request.method === "POST") {
|
|
445
|
+
void playGoal(root, bodyOf(request), response).catch((error) => {
|
|
446
|
+
if (!response.headersSent) answer(response, 500, { error: error instanceof Error ? error.message : String(error) });
|
|
447
|
+
else response.end();
|
|
448
|
+
});
|
|
449
|
+
return;
|
|
450
|
+
}
|
|
451
|
+
const control = /^\/devices\/([^/]+)\/control$/.exec(url.pathname)?.[1];
|
|
452
|
+
const asked = url.pathname === "/" && request.method === "GET" ? Promise.resolve({ status: 200, body: stdGame(root) }) : url.pathname === "/start" && request.method === "POST" ? startedServer(root).then(({ url: at }) => ({ status: 200, body: { url: at } })) : url.pathname === "/devices" && request.method === "GET" ? askStd(root, "/__std/devices") : control !== void 0 && request.method === "POST" ? askStd(
|
|
453
|
+
root,
|
|
454
|
+
`/__std/devices/${encodeURIComponent(decodeURIComponent(control))}/control`,
|
|
455
|
+
{ method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(bodyOf(request)) }
|
|
456
|
+
) : url.pathname === "/level" && (request.method === "GET" || request.method === "POST") ? levelAnswer(root, request.method, bodyOf(request)) : url.pathname === "/goals" && request.method === "GET" ? botGoals(root) : url.pathname === "/pages" && request.method === "GET" ? pageSlots(root).then((body) => ({ status: 200, body })) : url.pathname === "/trace" && request.method === "GET" && PAGE_ID.test(url.searchParams.get("page") ?? "") ? traceFrom(root, url.searchParams.get("page"), Number(url.searchParams.get("from") ?? 0)).then((body) => ({ status: 200, body })) : url.pathname === "/errors" && request.method === "GET" ? Promise.resolve({ status: 200, body: errorsFrom(
|
|
457
|
+
root,
|
|
458
|
+
url.searchParams.has("server") ? Number(url.searchParams.get("server")) : null,
|
|
459
|
+
url.searchParams.has("from") ? Number(url.searchParams.get("from")) || 0 : null
|
|
460
|
+
) }) : Promise.resolve({ status: 404, body: { error: `${STD_ROUTE}${url.pathname} is not a route of Play's` } });
|
|
461
|
+
asked.then(
|
|
462
|
+
({ status, body }) => answer(response, status, body),
|
|
463
|
+
(error) => answer(response, 500, { error: error instanceof Error ? error.message : String(error) })
|
|
464
|
+
);
|
|
465
|
+
});
|
|
466
|
+
}
|
|
467
|
+
};
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
// serving/std-scene.ts
|
|
471
|
+
import { existsSync as existsSync3, readFileSync as readFileSync2 } from "node:fs";
|
|
472
|
+
import { dirname as dirname3, join as join3, relative, sep } from "node:path";
|
|
473
|
+
import { fileURLToPath } from "node:url";
|
|
474
|
+
|
|
475
|
+
// ../live/dist/chunk-7WKVJGHZ.js
|
|
476
|
+
import { existsSync as existsSync2, realpathSync } from "node:fs";
|
|
477
|
+
import { dirname as dirname2, join as join2 } from "node:path";
|
|
478
|
+
function installedPackageDir(fromDir, name) {
|
|
479
|
+
for (let dir = fromDir; ; dir = dirname2(dir)) {
|
|
480
|
+
const candidate = join2(dir, "node_modules", ...name.split("/"));
|
|
481
|
+
if (existsSync2(join2(candidate, "package.json"))) return realpathSync(candidate);
|
|
482
|
+
if (dirname2(dir) === dir) return null;
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
// ../sdk/src/project-module-url.ts
|
|
487
|
+
function fsFileUrl(file) {
|
|
488
|
+
const path = file.replaceAll("\\", "/");
|
|
489
|
+
return path.startsWith("/") ? `/@fs${path}` : `/@fs/${path}`;
|
|
490
|
+
}
|
|
491
|
+
var liveModuleRevision = Date.now();
|
|
492
|
+
|
|
493
|
+
// serving/std-scene.ts
|
|
494
|
+
import { normalizePath } from "vite";
|
|
495
|
+
|
|
496
|
+
// src/std-level-files.ts
|
|
497
|
+
var pascal = (name) => name.replace(/(^|[^A-Za-z0-9]+)([A-Za-z0-9])/g, (_m, _s, c) => c.toUpperCase()).replace(/^[0-9]/, (d) => `L${d}`);
|
|
498
|
+
var camel = (name) => {
|
|
499
|
+
const p = pascal(name);
|
|
500
|
+
return p.charAt(0).toLowerCase() + p.slice(1);
|
|
501
|
+
};
|
|
502
|
+
function levelFiles(blend) {
|
|
503
|
+
const dir = blend.slice(0, blend.lastIndexOf("/"));
|
|
504
|
+
const base = blend.slice(dir.length + 1).replace(/\.blend$/, "");
|
|
505
|
+
return {
|
|
506
|
+
base,
|
|
507
|
+
dir,
|
|
508
|
+
component: `${dir}/${pascal(base)}Level.tsx`,
|
|
509
|
+
environment: `${dir}/${pascal(base)}Environment.tsx`,
|
|
510
|
+
canvas: `${dir}/${base}.canvas.ts`,
|
|
511
|
+
grading: `${dir}/${base}.grading.tsx`
|
|
512
|
+
};
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
// serving/std-scene.ts
|
|
516
|
+
var PREFIX = "/@volter-std-scene/";
|
|
517
|
+
var VIRTUAL = "\0volter-std-scene:";
|
|
518
|
+
var RUNTIME = "volter-std-scene-runtime";
|
|
519
|
+
var RUNTIME_ID = "\0volter-std-scene-runtime";
|
|
520
|
+
var RUNTIME_FILE = join3(dirname3(fileURLToPath(import.meta.url)), "..", "serving", "std-scene-runtime.js");
|
|
521
|
+
var LEVEL = /^levels\/.+\.blend$/;
|
|
522
|
+
var MARK = "volter-std-scene";
|
|
523
|
+
var RUNTIME_PACKAGES = ["@react-three/fiber", "@react-three/rapier"];
|
|
524
|
+
var THROUGH = /* @__PURE__ */ new Map([["postprocessing", "@volter-ai/std/level/client"]]);
|
|
525
|
+
var installedFolder = (root, name) => installedPackageDir(root, name.split("/").slice(0, name.startsWith("@") ? 2 : 1).join("/"));
|
|
526
|
+
var fileUrl = (file, version) => `${fsFileUrl(file)}?${MARK}=${version}`;
|
|
527
|
+
function sceneModule(root, blend, read, version) {
|
|
528
|
+
const files = levelFiles(blend);
|
|
529
|
+
const { base, dir } = files;
|
|
530
|
+
const level = `${pascal(base)}Level`, environment = `${pascal(base)}Environment`, canvas = `${camel(base)}Canvas`;
|
|
531
|
+
const exists = (at) => {
|
|
532
|
+
read.add(normalizePath(join3(root, at)));
|
|
533
|
+
return existsSync3(join3(root, at));
|
|
534
|
+
};
|
|
535
|
+
if (!exists(files.component) || !exists(files.canvas)) {
|
|
536
|
+
throw new Error(`${blend} has no exported level beside it (${files.component} and ${files.canvas}): save it, and std's level export writes them.`);
|
|
537
|
+
}
|
|
538
|
+
const hasEnvironment = exists(files.environment);
|
|
539
|
+
const source = exists(files.grading) ? readFileSync2(join3(root, files.grading), "utf8") : null;
|
|
540
|
+
const shaper = source === null ? null : /<ViewShaper min=\{(-?[\d.]+)\} max=\{(-?[\d.]+)\}(?: exposure=\{(-?[\d.]+)\})? \/>/.exec(source);
|
|
541
|
+
const lut = source === null ? null : /import viewLut from '\.\/(.+?)\?url';/.exec(source);
|
|
542
|
+
const grading = shaper !== null && lut !== null ? { lut: join3(root, dir, lut[1]), min: Number(shaper[1]), max: Number(shaper[2]), exposure: Number(shaper[3] ?? 0) } : null;
|
|
543
|
+
if (grading !== null) read.add(normalizePath(grading.lut));
|
|
544
|
+
const gradingExtra = source !== null && [...source.matchAll(/^\s+<([A-Z]\w*)\b/gm)].some((found) => found[1] !== "EffectComposer" && found[1] !== "ViewShaper" && found[1] !== "LUT");
|
|
545
|
+
return [
|
|
546
|
+
`import { mountScene as mountLevel, viewTransform as levelTransform } from ${JSON.stringify(RUNTIME)};`,
|
|
547
|
+
`import { ${level} } from ${JSON.stringify(`/${files.component}`)};`,
|
|
548
|
+
...hasEnvironment ? [`import { ${environment} } from ${JSON.stringify(`/${files.environment}`)};`] : [],
|
|
549
|
+
`import { ${canvas} } from ${JSON.stringify(`/${files.canvas}`)};`,
|
|
550
|
+
// (the LUT's address, which its loader fetches as it is: an import of a `.cube` is no asset to Vite, whose import of it
|
|
551
|
+
// is its text as a module)
|
|
552
|
+
...grading === null ? [] : [`const viewLut = ${JSON.stringify(fileUrl(grading.lut, version))};`],
|
|
553
|
+
`export const component = ${JSON.stringify(files.component)};`,
|
|
554
|
+
`export const canvas = ${canvas};`,
|
|
555
|
+
`export const gradingExtra = ${String(gradingExtra)};`,
|
|
556
|
+
`const level = { Level: ${level}, Environment: ${hasEnvironment ? environment : "null"}, canvas: ${canvas} };`,
|
|
557
|
+
"export const mountScene = (stage) => mountLevel(level, stage);",
|
|
558
|
+
grading === null ? "export const viewTransform = async () => null;" : `export const viewTransform = () => levelTransform({ lut: viewLut, min: ${grading.min}, max: ${grading.max}, exposure: ${grading.exposure} });`,
|
|
559
|
+
""
|
|
560
|
+
].join("\n");
|
|
561
|
+
}
|
|
562
|
+
function gameFile(root, file) {
|
|
563
|
+
if (file === null) return false;
|
|
564
|
+
const at = relative(root, file);
|
|
565
|
+
return !at.startsWith("..") && !at.split(sep).includes("node_modules");
|
|
566
|
+
}
|
|
567
|
+
function importClosure(graph, root, id) {
|
|
568
|
+
const found = /* @__PURE__ */ new Set();
|
|
569
|
+
const visit = (node) => {
|
|
570
|
+
for (const next of node.importedModules) {
|
|
571
|
+
if (found.has(next) || !gameFile(root, next.file)) continue;
|
|
572
|
+
found.add(next);
|
|
573
|
+
visit(next);
|
|
574
|
+
}
|
|
575
|
+
};
|
|
576
|
+
const start = graph.getModuleById(id);
|
|
577
|
+
if (start) visit(start);
|
|
578
|
+
return found;
|
|
579
|
+
}
|
|
580
|
+
function stdScenePlugin(services) {
|
|
581
|
+
const stdGame2 = () => {
|
|
582
|
+
const root = services.currentProjectRoot();
|
|
583
|
+
return root && existsSync3(join3(root, "std.json")) ? root : null;
|
|
584
|
+
};
|
|
585
|
+
const scenes = /* @__PURE__ */ new Map();
|
|
586
|
+
let version = Date.now();
|
|
587
|
+
return {
|
|
588
|
+
name: "volter-play-std-scene",
|
|
589
|
+
enforce: "pre",
|
|
590
|
+
// (a std game's Scene view and its levels' modules prebundled as the session starts: found on the view's first open,
|
|
591
|
+
// Vite would optimize them again then, and the editor's page, which does not reload for it, would hold two copies of
|
|
592
|
+
// React. The levels' files are crawled, and the runtime's own packages are included where the game has them)
|
|
593
|
+
config() {
|
|
594
|
+
const root = stdGame2();
|
|
595
|
+
if (root === null) return void 0;
|
|
596
|
+
const include = RUNTIME_PACKAGES.filter((name) => installedFolder(root, name) !== null);
|
|
597
|
+
return { optimizeDeps: { entries: ["levels/**/*.{ts,tsx}"], include } };
|
|
598
|
+
},
|
|
599
|
+
async resolveId(source, importer) {
|
|
600
|
+
const ours = importer?.startsWith(VIRTUAL) === true || importer === RUNTIME_ID;
|
|
601
|
+
if (source === RUNTIME && ours) return RUNTIME_ID;
|
|
602
|
+
const through = THROUGH.get(source);
|
|
603
|
+
if (through !== void 0 && ours) {
|
|
604
|
+
const root = stdGame2();
|
|
605
|
+
const owner = root === null ? null : installedFolder(root, through);
|
|
606
|
+
if (!owner) throw new Error(`${through} is not installed for the game's folder, so its ${source} is not at hand.`);
|
|
607
|
+
return this.resolve(source, join3(owner, "package.json"), { skipSelf: true });
|
|
608
|
+
}
|
|
609
|
+
if (!source.startsWith(PREFIX)) return null;
|
|
610
|
+
const [path = "", query = ""] = source.slice(PREFIX.length).split("?");
|
|
611
|
+
const blend = decodeURIComponent(path);
|
|
612
|
+
if (!LEVEL.test(blend)) throw new Error(`${blend} is not a std level's Blender file (levels/<name>.blend).`);
|
|
613
|
+
return `${VIRTUAL}${blend}?${query}`;
|
|
614
|
+
},
|
|
615
|
+
load(id) {
|
|
616
|
+
if (id === RUNTIME_ID) return readFileSync2(RUNTIME_FILE, "utf8");
|
|
617
|
+
if (id.startsWith(VIRTUAL)) {
|
|
618
|
+
const root2 = stdGame2();
|
|
619
|
+
if (!root2) throw new Error("The open project is not a std game (no std.json), so it has no level to draw.");
|
|
620
|
+
const blend = id.slice(VIRTUAL.length).replace(/\?.*$/, "");
|
|
621
|
+
const read = /* @__PURE__ */ new Set();
|
|
622
|
+
scenes.set(blend, { id, read });
|
|
623
|
+
return sceneModule(root2, blend, read, version);
|
|
624
|
+
}
|
|
625
|
+
const [file = id, query] = id.split("?");
|
|
626
|
+
const root = query === "url" ? stdGame2() : null;
|
|
627
|
+
if (!root || !gameFile(root, file)) return null;
|
|
628
|
+
return `export default ${JSON.stringify(fileUrl(file, version))};`;
|
|
629
|
+
},
|
|
630
|
+
// (a Scene view depends on its module's whole import closure among the game's files, the modules whose import failed
|
|
631
|
+
// to resolve within it and the files its source was written from. Any file of those written, made or removed bumps
|
|
632
|
+
// the content version, invalidates the closure, so each of its modules and URLs is served anew, and is announced on
|
|
633
|
+
// the session's change channel naming the levels it reached)
|
|
634
|
+
hotUpdate({ file, type, modules, server, timestamp }) {
|
|
635
|
+
if (this.environment.name !== "client") return;
|
|
636
|
+
const root = stdGame2();
|
|
637
|
+
if (!root) return;
|
|
638
|
+
const graph = this.environment.moduleGraph;
|
|
639
|
+
const failed = new Set(type === "create" ? modules : []);
|
|
640
|
+
const reached = [...scenes].map(([blend, scene]) => ({ blend, scene, closure: importClosure(graph, root, scene.id) })).filter(({ scene, closure }) => {
|
|
641
|
+
const start = graph.getModuleById(scene.id);
|
|
642
|
+
return scene.read.has(file) || start !== void 0 && failed.has(start) || [...closure].some((node) => node.file === file || failed.has(node));
|
|
643
|
+
});
|
|
644
|
+
if (reached.length === 0) return;
|
|
645
|
+
version += 1;
|
|
646
|
+
const seen = /* @__PURE__ */ new Set();
|
|
647
|
+
for (const { closure } of reached) for (const node of closure) graph.invalidateModule(node, seen, timestamp, true);
|
|
648
|
+
server.ws.send({
|
|
649
|
+
type: "custom",
|
|
650
|
+
event: "volter:restart-required",
|
|
651
|
+
data: { file, path: relative(root, file).replaceAll("\\", "/"), type, affected: reached.map(({ blend }) => blend) }
|
|
652
|
+
});
|
|
653
|
+
}
|
|
654
|
+
};
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
// serving/index.ts
|
|
658
|
+
var servingPlugins = (services) => [
|
|
659
|
+
stdDevPlugin(services),
|
|
660
|
+
stdScenePlugin(services)
|
|
661
|
+
];
|
|
662
|
+
export {
|
|
663
|
+
servingPlugins
|
|
664
|
+
};
|