@khorsheed/dsh-game 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +17 -0
- package/README.en.md +53 -0
- package/README.i18n.yaml +6 -0
- package/README.md +52 -2
- package/cordis.patch.yml +23 -0
- package/lib/client.js +30348 -0
- package/lib/index.js +4154 -0
- package/lib/typert.host.d.ts +3 -0
- package/lib/typert.host.js +3769 -0
- package/lib/typert.remote-client.d.ts +101 -0
- package/lib/typert.remote-client.js +2082 -0
- package/lib/types/agent.d.ts +20 -0
- package/lib/types/agent.js +42 -0
- package/lib/types/checks.d.ts +44 -0
- package/lib/types/checks.js +267 -0
- package/lib/types/client/DesignView.d.ts +22 -0
- package/lib/types/client/DesignView.js +134 -0
- package/lib/types/client/ElementsView.d.ts +21 -0
- package/lib/types/client/ElementsView.js +187 -0
- package/lib/types/client/GameTab.d.ts +14 -0
- package/lib/types/client/GameTab.js +256 -0
- package/lib/types/client/PlayFrame.d.ts +54 -0
- package/lib/types/client/PlayFrame.js +129 -0
- package/lib/types/client/PlayView.d.ts +19 -0
- package/lib/types/client/PlayView.js +161 -0
- package/lib/types/client/Preview3D.d.ts +50 -0
- package/lib/types/client/Preview3D.js +176 -0
- package/lib/types/client/ProjectSwitcher.d.ts +20 -0
- package/lib/types/client/ProjectSwitcher.js +75 -0
- package/lib/types/client/Recipe3DEditor.d.ts +19 -0
- package/lib/types/client/Recipe3DEditor.js +44 -0
- package/lib/types/client/StyleGallery.d.ts +18 -0
- package/lib/types/client/StyleGallery.js +27 -0
- package/lib/types/client/TurnCard.d.ts +62 -0
- package/lib/types/client/TurnCard.js +100 -0
- package/lib/types/client/VersionsView.d.ts +17 -0
- package/lib/types/client/VersionsView.js +68 -0
- package/lib/types/client/chat-node.d.ts +42 -0
- package/lib/types/client/chat-node.js +77 -0
- package/lib/types/client/contract.d.ts +125 -0
- package/lib/types/client/contract.js +6 -0
- package/lib/types/client/definition.d.ts +22 -0
- package/lib/types/client/definition.js +28 -0
- package/lib/types/client/index.d.ts +21 -0
- package/lib/types/client/index.js +229 -0
- package/lib/types/client/locales.d.ts +376 -0
- package/lib/types/client/locales.js +727 -0
- package/lib/types/client/looks.d.ts +36 -0
- package/lib/types/client/looks.js +105 -0
- package/lib/types/client/markdown.d.ts +11 -0
- package/lib/types/client/markdown.js +52 -0
- package/lib/types/client/preset-visibility.d.ts +94 -0
- package/lib/types/client/preset-visibility.js +115 -0
- package/lib/types/client/preview-entry.d.ts +24 -0
- package/lib/types/client/preview-entry.js +107 -0
- package/lib/types/client/refs.d.ts +36 -0
- package/lib/types/client/refs.js +44 -0
- package/lib/types/client/style-samples.d.ts +5 -0
- package/lib/types/client/style-samples.js +196 -0
- package/lib/types/fsutil.d.ts +26 -0
- package/lib/types/fsutil.js +104 -0
- package/lib/types/hooks.d.ts +83 -0
- package/lib/types/hooks.js +108 -0
- package/lib/types/index.d.ts +30 -0
- package/lib/types/index.js +22 -0
- package/lib/types/kits/canvas2d.d.ts +6 -0
- package/lib/types/kits/canvas2d.js +60 -0
- package/lib/types/kits/ids.d.ts +11 -0
- package/lib/types/kits/ids.js +6 -0
- package/lib/types/kits/index.d.ts +26 -0
- package/lib/types/kits/index.js +28 -0
- package/lib/types/kits/three3d.d.ts +12 -0
- package/lib/types/kits/three3d.js +188 -0
- package/lib/types/kits/types.d.ts +52 -0
- package/lib/types/kits/types.js +6 -0
- package/lib/types/look3d.d.ts +20 -0
- package/lib/types/look3d.js +175 -0
- package/lib/types/panel-session.d.ts +21 -0
- package/lib/types/panel-session.js +16 -0
- package/lib/types/project.d.ts +132 -0
- package/lib/types/project.js +494 -0
- package/lib/types/remote.d.ts +182 -0
- package/lib/types/remote.js +256 -0
- package/lib/types/skills.d.ts +13 -0
- package/lib/types/skills.js +140 -0
- package/lib/types/store.d.ts +325 -0
- package/lib/types/store.js +1285 -0
- package/lib/types/tools.d.ts +12 -0
- package/lib/types/tools.js +301 -0
- package/lib/types/trash.d.ts +5 -0
- package/lib/types/trash.js +64 -0
- package/lib/types/types.d.ts +522 -0
- package/lib/types/types.js +85 -0
- package/lib/types/vfs.d.ts +92 -0
- package/lib/types/vfs.js +217 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +132 -3
- package/runtime/dsh3d.js +1279 -0
package/lib/index.js
ADDED
|
@@ -0,0 +1,4154 @@
|
|
|
1
|
+
import { createRequire } from "node:module";
|
|
2
|
+
import { createUserMessage } from "@deepseek-ai/dsh-llm";
|
|
3
|
+
import { Remote, TypertRemoteService } from "@deepseek-ai/dsh-typert-protocol";
|
|
4
|
+
import { createHash, randomBytes } from "node:crypto";
|
|
5
|
+
import { appendFile, mkdir, mkdtemp, readFile, readdir, rename, rm, stat, writeFile } from "node:fs/promises";
|
|
6
|
+
import { basename, dirname, isAbsolute, join, normalize, posix, relative, resolve, sep } from "node:path";
|
|
7
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
8
|
+
import { homedir, tmpdir } from "node:os";
|
|
9
|
+
import { fileURLToPath } from "node:url";
|
|
10
|
+
//#region lib/types/types.js
|
|
11
|
+
/**
|
|
12
|
+
* Wire and storage shapes shared by the host half, the Remote face and the
|
|
13
|
+
* browser half. Pure types and constants — no node imports, so the client
|
|
14
|
+
* type program includes this file as-is.
|
|
15
|
+
* @module @khorsheed/dsh-game/types
|
|
16
|
+
*/
|
|
17
|
+
const MANIFEST_FILE = "game.json";
|
|
18
|
+
/** The plugin's own directory inside a project (snapshots, check results, logs). */
|
|
19
|
+
const STATE_DIR = ".game";
|
|
20
|
+
/**
|
|
21
|
+
* The example styles the user picks from (decision D10). Without image
|
|
22
|
+
* generation the game draws everything in code, so each style is one a
|
|
23
|
+
* canvas/HTML game can render faithfully; `custom:<text>` covers the rest.
|
|
24
|
+
*/
|
|
25
|
+
const ART_STYLES = [
|
|
26
|
+
{
|
|
27
|
+
id: "pixel",
|
|
28
|
+
label: "像素",
|
|
29
|
+
look: "16×16 / 32×32 像素块,有限调色板,整数倍放大不糊",
|
|
30
|
+
palette: [
|
|
31
|
+
"#1a1c2c",
|
|
32
|
+
"#5d275d",
|
|
33
|
+
"#b13e53",
|
|
34
|
+
"#ef7d57",
|
|
35
|
+
"#ffcd75",
|
|
36
|
+
"#a7f070",
|
|
37
|
+
"#38b764",
|
|
38
|
+
"#257179",
|
|
39
|
+
"#29366f",
|
|
40
|
+
"#3b5dc9",
|
|
41
|
+
"#41a6f6",
|
|
42
|
+
"#73eff7",
|
|
43
|
+
"#f4f4f4",
|
|
44
|
+
"#94b0c2",
|
|
45
|
+
"#566c86",
|
|
46
|
+
"#333c57"
|
|
47
|
+
]
|
|
48
|
+
},
|
|
49
|
+
{
|
|
50
|
+
id: "flat",
|
|
51
|
+
label: "扁平矢量",
|
|
52
|
+
look: "纯色几何形,圆角,少量阴影,配色明快",
|
|
53
|
+
palette: [
|
|
54
|
+
"#264653",
|
|
55
|
+
"#2a9d8f",
|
|
56
|
+
"#8ab17d",
|
|
57
|
+
"#e9c46a",
|
|
58
|
+
"#f4a261",
|
|
59
|
+
"#e76f51",
|
|
60
|
+
"#fefae0",
|
|
61
|
+
"#3d405b"
|
|
62
|
+
]
|
|
63
|
+
},
|
|
64
|
+
{
|
|
65
|
+
id: "line",
|
|
66
|
+
label: "手绘线稿",
|
|
67
|
+
look: "黑色描线 + 淡彩填充,线条略抖",
|
|
68
|
+
palette: [
|
|
69
|
+
"#222222",
|
|
70
|
+
"#f7f3e8",
|
|
71
|
+
"#f2c6c2",
|
|
72
|
+
"#c9e4de",
|
|
73
|
+
"#c6def1",
|
|
74
|
+
"#faedcb",
|
|
75
|
+
"#dbcdf0",
|
|
76
|
+
"#8a8a8a"
|
|
77
|
+
]
|
|
78
|
+
},
|
|
79
|
+
{
|
|
80
|
+
id: "neon",
|
|
81
|
+
label: "霓虹几何",
|
|
82
|
+
look: "深色底,发光的线框与粒子",
|
|
83
|
+
palette: [
|
|
84
|
+
"#0d0221",
|
|
85
|
+
"#261447",
|
|
86
|
+
"#ff3864",
|
|
87
|
+
"#2de2e6",
|
|
88
|
+
"#f6019d",
|
|
89
|
+
"#fdf500",
|
|
90
|
+
"#7b2cbf",
|
|
91
|
+
"#ffffff"
|
|
92
|
+
]
|
|
93
|
+
},
|
|
94
|
+
{
|
|
95
|
+
id: "paper",
|
|
96
|
+
label: "剪纸拼贴",
|
|
97
|
+
look: "纸张纹理色块,轻微投影,层叠感",
|
|
98
|
+
palette: [
|
|
99
|
+
"#f2e8cf",
|
|
100
|
+
"#e0c9a6",
|
|
101
|
+
"#bc4749",
|
|
102
|
+
"#6a994e",
|
|
103
|
+
"#386641",
|
|
104
|
+
"#a7c957",
|
|
105
|
+
"#3d348b",
|
|
106
|
+
"#5c4033"
|
|
107
|
+
]
|
|
108
|
+
},
|
|
109
|
+
{
|
|
110
|
+
id: "mono",
|
|
111
|
+
label: "极简单色",
|
|
112
|
+
look: "一种主色加黑白,靠形状和留白区分",
|
|
113
|
+
palette: [
|
|
114
|
+
"#111111",
|
|
115
|
+
"#f5f5f5",
|
|
116
|
+
"#e63946",
|
|
117
|
+
"#9a9a9a",
|
|
118
|
+
"#5a5a5a",
|
|
119
|
+
"#d0d0d0"
|
|
120
|
+
]
|
|
121
|
+
}
|
|
122
|
+
];
|
|
123
|
+
/**
|
|
124
|
+
* The 3D example styles (the 3d-three kit, proposal 2026-10-21-game-3d G8):
|
|
125
|
+
* toon shading with an ink outline over a paper ground. The ids are what
|
|
126
|
+
* `dsh3d.createWorld({ style })` takes.
|
|
127
|
+
*/
|
|
128
|
+
const ART_STYLES_3D = [{
|
|
129
|
+
id: "ink",
|
|
130
|
+
label: "水墨",
|
|
131
|
+
look: "低多边形 + 墨线描边 + 宣纸底与远雾,留白多",
|
|
132
|
+
palette: [
|
|
133
|
+
"#2a2622",
|
|
134
|
+
"#efe7d6",
|
|
135
|
+
"#e9dfcc",
|
|
136
|
+
"#b8442f",
|
|
137
|
+
"#8f897b",
|
|
138
|
+
"#4c5747",
|
|
139
|
+
"#a7bab4",
|
|
140
|
+
"#46698a"
|
|
141
|
+
]
|
|
142
|
+
}, {
|
|
143
|
+
id: "qinglv",
|
|
144
|
+
label: "青绿山水",
|
|
145
|
+
look: "石青石绿的山、赭色描边、暖纸底,色块鲜明",
|
|
146
|
+
palette: [
|
|
147
|
+
"#4f3b28",
|
|
148
|
+
"#f1e8cf",
|
|
149
|
+
"#3c8a78",
|
|
150
|
+
"#3a6f93",
|
|
151
|
+
"#d9d9b0",
|
|
152
|
+
"#2c6a57",
|
|
153
|
+
"#77a9bb",
|
|
154
|
+
"#b8442f"
|
|
155
|
+
]
|
|
156
|
+
}];
|
|
157
|
+
/** Body archetypes a `parts3d` look builds on. */
|
|
158
|
+
const BODY_BASES = [
|
|
159
|
+
"quadruped",
|
|
160
|
+
"bird",
|
|
161
|
+
"fish",
|
|
162
|
+
"serpent",
|
|
163
|
+
"humanoid"
|
|
164
|
+
];
|
|
165
|
+
/** Built-in parts a `parts3d` look can attach; a project can register more with `dsh3d.definePart`. */
|
|
166
|
+
const PART_TYPES = [
|
|
167
|
+
"ear",
|
|
168
|
+
"horn",
|
|
169
|
+
"tail",
|
|
170
|
+
"wing",
|
|
171
|
+
"fin",
|
|
172
|
+
"eye",
|
|
173
|
+
"face"
|
|
174
|
+
];
|
|
175
|
+
/** Whether a look is the 2D pixel-row kind. */
|
|
176
|
+
function isPixelLook(look) {
|
|
177
|
+
return !("kind" in look);
|
|
178
|
+
}
|
|
179
|
+
/** The characters a look row may use, in palette-index order. */
|
|
180
|
+
const LOOK_CHARS = "0123456789abcdefghijklmnopqrstuvwxyz";
|
|
181
|
+
/** The chaptered design doc (decision D12): fixed chapters, pillars pinned first. */
|
|
182
|
+
const CHAPTERS = [
|
|
183
|
+
{
|
|
184
|
+
id: "pillars",
|
|
185
|
+
title: "支柱说明",
|
|
186
|
+
file: "design/pillars.md"
|
|
187
|
+
},
|
|
188
|
+
{
|
|
189
|
+
id: "core-loop",
|
|
190
|
+
title: "核心循环",
|
|
191
|
+
file: "design/core-loop.md"
|
|
192
|
+
},
|
|
193
|
+
{
|
|
194
|
+
id: "mechanics",
|
|
195
|
+
title: "机制与规则",
|
|
196
|
+
file: "design/mechanics.md"
|
|
197
|
+
},
|
|
198
|
+
{
|
|
199
|
+
id: "lore",
|
|
200
|
+
title: "元素设定",
|
|
201
|
+
file: "design/lore.md",
|
|
202
|
+
generated: true
|
|
203
|
+
},
|
|
204
|
+
{
|
|
205
|
+
id: "elements",
|
|
206
|
+
title: "元素与数值",
|
|
207
|
+
file: "design/elements.md",
|
|
208
|
+
generated: true
|
|
209
|
+
},
|
|
210
|
+
{
|
|
211
|
+
id: "levels",
|
|
212
|
+
title: "关卡与节奏",
|
|
213
|
+
file: "design/levels.md"
|
|
214
|
+
},
|
|
215
|
+
{
|
|
216
|
+
id: "art-audio",
|
|
217
|
+
title: "美术与音效",
|
|
218
|
+
file: "design/art-audio.md"
|
|
219
|
+
},
|
|
220
|
+
{
|
|
221
|
+
id: "playtest",
|
|
222
|
+
title: "试玩记录",
|
|
223
|
+
file: "design/playtest.md",
|
|
224
|
+
generated: true
|
|
225
|
+
}
|
|
226
|
+
];
|
|
227
|
+
/** Longest 设定 the panel saves. */
|
|
228
|
+
const LORE_MAX = 2e3;
|
|
229
|
+
/**
|
|
230
|
+
* The opening of the message the panel writes into the composer for its
|
|
231
|
+
* changes (「写进对话」). The pre-step hook recognizes a sent message by it and
|
|
232
|
+
* clears exactly the changes the message lists.
|
|
233
|
+
*/
|
|
234
|
+
const PANEL_CHANGES_MARK = "【面板改动】";
|
|
235
|
+
//#endregion
|
|
236
|
+
//#region lib/types/hooks.js
|
|
237
|
+
/** Build a dsh-game user message. */
|
|
238
|
+
function gameMessage(text, section, op) {
|
|
239
|
+
return createUserMessage({
|
|
240
|
+
content: [{
|
|
241
|
+
type: "text",
|
|
242
|
+
text
|
|
243
|
+
}],
|
|
244
|
+
source: {
|
|
245
|
+
kind: "dsh-game",
|
|
246
|
+
plugin: "@khorsheed/dsh-game",
|
|
247
|
+
form: "snapshot",
|
|
248
|
+
sections: [{
|
|
249
|
+
name: section,
|
|
250
|
+
text
|
|
251
|
+
}],
|
|
252
|
+
...op
|
|
253
|
+
}
|
|
254
|
+
});
|
|
255
|
+
}
|
|
256
|
+
/** The steer text for one failed check (model-facing). */
|
|
257
|
+
function failureText(record, attempt) {
|
|
258
|
+
const lines = [`[游戏检查未通过 ${attempt}/2] 这一轮改完的版本没过检查,试玩区还停在上一个版本。`, ...record.failures.slice(0, 8).map((f) => `- ${f.where}:${f.message}`)];
|
|
259
|
+
if (record.failures.length > 8) lines.push(`- ……另有 ${record.failures.length - 8} 项`);
|
|
260
|
+
if (record.skipped !== void 0) lines.push(`(${record.skipped})`);
|
|
261
|
+
lines.push(attempt < 2 ? "请修好再结束这一轮:先读报错对应的代码,改完不用告诉我,结束时会自动重新检查。若是测试本身写错了,改测试并说明理由。" : "这是最后一次自动修复机会;修不好就如实告诉用户卡在哪里。");
|
|
262
|
+
return lines.join("\n");
|
|
263
|
+
}
|
|
264
|
+
/**
|
|
265
|
+
* Register the hooks on the plugin's scope.
|
|
266
|
+
* @param ctx - the core plugin's context.
|
|
267
|
+
* @param store - the service core.
|
|
268
|
+
*/
|
|
269
|
+
function registerTurnHooks(ctx, store) {
|
|
270
|
+
/** Steers already sent, per `<session>:<turn>`. */
|
|
271
|
+
const steered = /* @__PURE__ */ new Map();
|
|
272
|
+
ctx.on("agent/turn-stopping", async ({ agent, turn, signal }) => {
|
|
273
|
+
if (signal.aborted) return;
|
|
274
|
+
const key = `${agent.session.id}:${turn}`;
|
|
275
|
+
const attempt = steered.get(key) ?? 0;
|
|
276
|
+
let outcome;
|
|
277
|
+
try {
|
|
278
|
+
outcome = await store.checkTurn(agent.session, attempt, 2);
|
|
279
|
+
} catch (error) {
|
|
280
|
+
ctx.logger.warn("game: end-of-turn check failed to run", error);
|
|
281
|
+
return;
|
|
282
|
+
}
|
|
283
|
+
if (outcome.kind !== "fail" || signal.aborted) return;
|
|
284
|
+
if (attempt >= 2) return;
|
|
285
|
+
steered.set(key, attempt + 1);
|
|
286
|
+
const text = failureText(outcome.record, attempt + 1);
|
|
287
|
+
agent.steer(gameMessage(text, "checks", {
|
|
288
|
+
op: "check-failed",
|
|
289
|
+
attempt: attempt + 1,
|
|
290
|
+
cap: 2,
|
|
291
|
+
failures: outcome.record.failures.slice(0, 8).map((f) => ({
|
|
292
|
+
where: f.where,
|
|
293
|
+
message: f.message.slice(0, 300)
|
|
294
|
+
}))
|
|
295
|
+
}));
|
|
296
|
+
});
|
|
297
|
+
ctx.on("agent/pre-step", async ({ agent, signal }, next) => {
|
|
298
|
+
const decision = await next();
|
|
299
|
+
if (decision.kind === "reject" || signal.aborted || decision.messages.length === 0) return decision;
|
|
300
|
+
const text = decision.messages.map((m) => m.content.map((block) => block.type === "text" ? block.text : "").join("\n")).join("\n");
|
|
301
|
+
if (text.includes("【面板改动】")) await store.clearSent(agent.session.id, text).catch((error) => {
|
|
302
|
+
ctx.logger.warn("game: could not clear sent panel changes", error);
|
|
303
|
+
});
|
|
304
|
+
return decision;
|
|
305
|
+
}, { prepend: true });
|
|
306
|
+
const baselines = /* @__PURE__ */ new Map();
|
|
307
|
+
ctx.on("session/event", (session, raw) => {
|
|
308
|
+
const event = raw;
|
|
309
|
+
const turn = event?.data?.turn ?? event?.turn;
|
|
310
|
+
if (event?.type === "turn/start") store.setTurnActive(session.id, true);
|
|
311
|
+
if (event?.type === "turn/end") store.setTurnActive(session.id, false);
|
|
312
|
+
if (event?.type === "turn/start" && turn !== void 0) {
|
|
313
|
+
baselines.set(`${session.id}:${turn}`, store.turnBaseline(session.id).catch(() => void 0));
|
|
314
|
+
return;
|
|
315
|
+
}
|
|
316
|
+
if (event?.type !== "turn/end") return;
|
|
317
|
+
if (turn !== void 0) {
|
|
318
|
+
steered.delete(`${session.id}:${turn}`);
|
|
319
|
+
const key = `${session.id}:${turn}`;
|
|
320
|
+
const base = baselines.get(key);
|
|
321
|
+
baselines.delete(key);
|
|
322
|
+
if (base !== void 0) base.then((b) => b === void 0 ? void 0 : store.recordTurn(session.id, turn, b)).catch((error) => {
|
|
323
|
+
ctx.logger.warn("game: could not record the turn report", error);
|
|
324
|
+
});
|
|
325
|
+
} else for (const key of steered.keys()) if (key.startsWith(`${session.id}:`)) steered.delete(key);
|
|
326
|
+
});
|
|
327
|
+
}
|
|
328
|
+
//#endregion
|
|
329
|
+
//#region lib/types/panel-session.js
|
|
330
|
+
/**
|
|
331
|
+
* Resolve the session a panel call names.
|
|
332
|
+
* @param ctx - host context carrying the session store and the typert lookups.
|
|
333
|
+
* @param sessionId - the session the panel shows.
|
|
334
|
+
* @returns the session.
|
|
335
|
+
* @throws the host lookup's refusal, or when no session has that id.
|
|
336
|
+
*/
|
|
337
|
+
async function panelSession(ctx, sessionId) {
|
|
338
|
+
const attached = ctx.get("sessions")?.get(sessionId);
|
|
339
|
+
if (attached !== void 0) return attached;
|
|
340
|
+
const resolved = await ctx.get("typert")?.lookups.get("session")?.resolve(sessionId);
|
|
341
|
+
if (resolved === void 0) throw new Error(`session "${sessionId}" not found`);
|
|
342
|
+
return resolved;
|
|
343
|
+
}
|
|
344
|
+
//#endregion
|
|
345
|
+
//#region lib/types/remote.js
|
|
346
|
+
var __runInitializers = function(thisArg, initializers, value) {
|
|
347
|
+
var useValue = arguments.length > 2;
|
|
348
|
+
for (var i = 0; i < initializers.length; i++) value = useValue ? initializers[i].call(thisArg, value) : initializers[i].call(thisArg);
|
|
349
|
+
return useValue ? value : void 0;
|
|
350
|
+
};
|
|
351
|
+
var __esDecorate = function(ctor, descriptorIn, decorators, contextIn, initializers, extraInitializers) {
|
|
352
|
+
function accept(f) {
|
|
353
|
+
if (f !== void 0 && typeof f !== "function") throw new TypeError("Function expected");
|
|
354
|
+
return f;
|
|
355
|
+
}
|
|
356
|
+
var kind = contextIn.kind, key = kind === "getter" ? "get" : kind === "setter" ? "set" : "value";
|
|
357
|
+
var target = !descriptorIn && ctor ? contextIn["static"] ? ctor : ctor.prototype : null;
|
|
358
|
+
var descriptor = descriptorIn || (target ? Object.getOwnPropertyDescriptor(target, contextIn.name) : {});
|
|
359
|
+
var _, done = false;
|
|
360
|
+
for (var i = decorators.length - 1; i >= 0; i--) {
|
|
361
|
+
var context = {};
|
|
362
|
+
for (var p in contextIn) context[p] = p === "access" ? {} : contextIn[p];
|
|
363
|
+
for (var p in contextIn.access) context.access[p] = contextIn.access[p];
|
|
364
|
+
context.addInitializer = function(f) {
|
|
365
|
+
if (done) throw new TypeError("Cannot add initializers after decoration has completed");
|
|
366
|
+
extraInitializers.push(accept(f || null));
|
|
367
|
+
};
|
|
368
|
+
var result = (0, decorators[i])(kind === "accessor" ? {
|
|
369
|
+
get: descriptor.get,
|
|
370
|
+
set: descriptor.set
|
|
371
|
+
} : descriptor[key], context);
|
|
372
|
+
if (kind === "accessor") {
|
|
373
|
+
if (result === void 0) continue;
|
|
374
|
+
if (result === null || typeof result !== "object") throw new TypeError("Object expected");
|
|
375
|
+
if (_ = accept(result.get)) descriptor.get = _;
|
|
376
|
+
if (_ = accept(result.set)) descriptor.set = _;
|
|
377
|
+
if (_ = accept(result.init)) initializers.unshift(_);
|
|
378
|
+
} else if (_ = accept(result)) {
|
|
379
|
+
if (kind === "field") initializers.unshift(_);
|
|
380
|
+
else descriptor[key] = _;
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
if (target) Object.defineProperty(target, contextIn.name, descriptor);
|
|
384
|
+
done = true;
|
|
385
|
+
};
|
|
386
|
+
/** The `game` wire namespace. */
|
|
387
|
+
let GameRemoteService = (() => {
|
|
388
|
+
let _classSuper = TypertRemoteService;
|
|
389
|
+
let _instanceExtraInitializers = [];
|
|
390
|
+
let _state_decorators;
|
|
391
|
+
let _turnReport_decorators;
|
|
392
|
+
let _projectCards_decorators;
|
|
393
|
+
let _deleteProject_decorators;
|
|
394
|
+
let _bind_decorators;
|
|
395
|
+
let _workingFile_decorators;
|
|
396
|
+
let _bundle_decorators;
|
|
397
|
+
let _confirm_decorators;
|
|
398
|
+
let _changes_decorators;
|
|
399
|
+
let _commit_decorators;
|
|
400
|
+
let _setAutoConfirm_decorators;
|
|
401
|
+
let _logPlay_decorators;
|
|
402
|
+
let _dropPending_decorators;
|
|
403
|
+
let _setParam_decorators;
|
|
404
|
+
let _finalizeParams_decorators;
|
|
405
|
+
let _resetParams_decorators;
|
|
406
|
+
let _editElement_decorators;
|
|
407
|
+
let _pickProposal_decorators;
|
|
408
|
+
let _writeChapter_decorators;
|
|
409
|
+
let _setOnePager_decorators;
|
|
410
|
+
let _requestRestyle_decorators;
|
|
411
|
+
let _reject_decorators;
|
|
412
|
+
let _rollback_decorators;
|
|
413
|
+
let _diff_decorators;
|
|
414
|
+
let _checkShot_decorators;
|
|
415
|
+
let _versionBundle_decorators;
|
|
416
|
+
return class GameRemoteService extends _classSuper {
|
|
417
|
+
static {
|
|
418
|
+
const _metadata = typeof Symbol === "function" && Symbol.metadata ? Object.create(_classSuper[Symbol.metadata] ?? null) : void 0;
|
|
419
|
+
_state_decorators = [Remote("state")];
|
|
420
|
+
_turnReport_decorators = [Remote("turnReport")];
|
|
421
|
+
_projectCards_decorators = [Remote("projectCards")];
|
|
422
|
+
_deleteProject_decorators = [Remote("deleteProject")];
|
|
423
|
+
_bind_decorators = [Remote("bind")];
|
|
424
|
+
_workingFile_decorators = [Remote("workingFile")];
|
|
425
|
+
_bundle_decorators = [Remote("bundle")];
|
|
426
|
+
_confirm_decorators = [Remote("confirm")];
|
|
427
|
+
_changes_decorators = [Remote("changes")];
|
|
428
|
+
_commit_decorators = [Remote("commit")];
|
|
429
|
+
_setAutoConfirm_decorators = [Remote("setAutoConfirm")];
|
|
430
|
+
_logPlay_decorators = [Remote("logPlay")];
|
|
431
|
+
_dropPending_decorators = [Remote("dropPending")];
|
|
432
|
+
_setParam_decorators = [Remote("setParam")];
|
|
433
|
+
_finalizeParams_decorators = [Remote("finalizeParams")];
|
|
434
|
+
_resetParams_decorators = [Remote("resetParams")];
|
|
435
|
+
_editElement_decorators = [Remote("editElement")];
|
|
436
|
+
_pickProposal_decorators = [Remote("pickProposal")];
|
|
437
|
+
_writeChapter_decorators = [Remote("writeChapter")];
|
|
438
|
+
_setOnePager_decorators = [Remote("setOnePager")];
|
|
439
|
+
_requestRestyle_decorators = [Remote("requestRestyle")];
|
|
440
|
+
_reject_decorators = [Remote("reject")];
|
|
441
|
+
_rollback_decorators = [Remote("rollback")];
|
|
442
|
+
_diff_decorators = [Remote("diff")];
|
|
443
|
+
_checkShot_decorators = [Remote("checkShot")];
|
|
444
|
+
_versionBundle_decorators = [Remote("versionBundle")];
|
|
445
|
+
__esDecorate(this, null, _state_decorators, {
|
|
446
|
+
kind: "method",
|
|
447
|
+
name: "state",
|
|
448
|
+
static: false,
|
|
449
|
+
private: false,
|
|
450
|
+
access: {
|
|
451
|
+
has: (obj) => "state" in obj,
|
|
452
|
+
get: (obj) => obj.state
|
|
453
|
+
},
|
|
454
|
+
metadata: _metadata
|
|
455
|
+
}, null, _instanceExtraInitializers);
|
|
456
|
+
__esDecorate(this, null, _turnReport_decorators, {
|
|
457
|
+
kind: "method",
|
|
458
|
+
name: "turnReport",
|
|
459
|
+
static: false,
|
|
460
|
+
private: false,
|
|
461
|
+
access: {
|
|
462
|
+
has: (obj) => "turnReport" in obj,
|
|
463
|
+
get: (obj) => obj.turnReport
|
|
464
|
+
},
|
|
465
|
+
metadata: _metadata
|
|
466
|
+
}, null, _instanceExtraInitializers);
|
|
467
|
+
__esDecorate(this, null, _projectCards_decorators, {
|
|
468
|
+
kind: "method",
|
|
469
|
+
name: "projectCards",
|
|
470
|
+
static: false,
|
|
471
|
+
private: false,
|
|
472
|
+
access: {
|
|
473
|
+
has: (obj) => "projectCards" in obj,
|
|
474
|
+
get: (obj) => obj.projectCards
|
|
475
|
+
},
|
|
476
|
+
metadata: _metadata
|
|
477
|
+
}, null, _instanceExtraInitializers);
|
|
478
|
+
__esDecorate(this, null, _deleteProject_decorators, {
|
|
479
|
+
kind: "method",
|
|
480
|
+
name: "deleteProject",
|
|
481
|
+
static: false,
|
|
482
|
+
private: false,
|
|
483
|
+
access: {
|
|
484
|
+
has: (obj) => "deleteProject" in obj,
|
|
485
|
+
get: (obj) => obj.deleteProject
|
|
486
|
+
},
|
|
487
|
+
metadata: _metadata
|
|
488
|
+
}, null, _instanceExtraInitializers);
|
|
489
|
+
__esDecorate(this, null, _bind_decorators, {
|
|
490
|
+
kind: "method",
|
|
491
|
+
name: "bind",
|
|
492
|
+
static: false,
|
|
493
|
+
private: false,
|
|
494
|
+
access: {
|
|
495
|
+
has: (obj) => "bind" in obj,
|
|
496
|
+
get: (obj) => obj.bind
|
|
497
|
+
},
|
|
498
|
+
metadata: _metadata
|
|
499
|
+
}, null, _instanceExtraInitializers);
|
|
500
|
+
__esDecorate(this, null, _workingFile_decorators, {
|
|
501
|
+
kind: "method",
|
|
502
|
+
name: "workingFile",
|
|
503
|
+
static: false,
|
|
504
|
+
private: false,
|
|
505
|
+
access: {
|
|
506
|
+
has: (obj) => "workingFile" in obj,
|
|
507
|
+
get: (obj) => obj.workingFile
|
|
508
|
+
},
|
|
509
|
+
metadata: _metadata
|
|
510
|
+
}, null, _instanceExtraInitializers);
|
|
511
|
+
__esDecorate(this, null, _bundle_decorators, {
|
|
512
|
+
kind: "method",
|
|
513
|
+
name: "bundle",
|
|
514
|
+
static: false,
|
|
515
|
+
private: false,
|
|
516
|
+
access: {
|
|
517
|
+
has: (obj) => "bundle" in obj,
|
|
518
|
+
get: (obj) => obj.bundle
|
|
519
|
+
},
|
|
520
|
+
metadata: _metadata
|
|
521
|
+
}, null, _instanceExtraInitializers);
|
|
522
|
+
__esDecorate(this, null, _confirm_decorators, {
|
|
523
|
+
kind: "method",
|
|
524
|
+
name: "confirm",
|
|
525
|
+
static: false,
|
|
526
|
+
private: false,
|
|
527
|
+
access: {
|
|
528
|
+
has: (obj) => "confirm" in obj,
|
|
529
|
+
get: (obj) => obj.confirm
|
|
530
|
+
},
|
|
531
|
+
metadata: _metadata
|
|
532
|
+
}, null, _instanceExtraInitializers);
|
|
533
|
+
__esDecorate(this, null, _changes_decorators, {
|
|
534
|
+
kind: "method",
|
|
535
|
+
name: "changes",
|
|
536
|
+
static: false,
|
|
537
|
+
private: false,
|
|
538
|
+
access: {
|
|
539
|
+
has: (obj) => "changes" in obj,
|
|
540
|
+
get: (obj) => obj.changes
|
|
541
|
+
},
|
|
542
|
+
metadata: _metadata
|
|
543
|
+
}, null, _instanceExtraInitializers);
|
|
544
|
+
__esDecorate(this, null, _commit_decorators, {
|
|
545
|
+
kind: "method",
|
|
546
|
+
name: "commit",
|
|
547
|
+
static: false,
|
|
548
|
+
private: false,
|
|
549
|
+
access: {
|
|
550
|
+
has: (obj) => "commit" in obj,
|
|
551
|
+
get: (obj) => obj.commit
|
|
552
|
+
},
|
|
553
|
+
metadata: _metadata
|
|
554
|
+
}, null, _instanceExtraInitializers);
|
|
555
|
+
__esDecorate(this, null, _setAutoConfirm_decorators, {
|
|
556
|
+
kind: "method",
|
|
557
|
+
name: "setAutoConfirm",
|
|
558
|
+
static: false,
|
|
559
|
+
private: false,
|
|
560
|
+
access: {
|
|
561
|
+
has: (obj) => "setAutoConfirm" in obj,
|
|
562
|
+
get: (obj) => obj.setAutoConfirm
|
|
563
|
+
},
|
|
564
|
+
metadata: _metadata
|
|
565
|
+
}, null, _instanceExtraInitializers);
|
|
566
|
+
__esDecorate(this, null, _logPlay_decorators, {
|
|
567
|
+
kind: "method",
|
|
568
|
+
name: "logPlay",
|
|
569
|
+
static: false,
|
|
570
|
+
private: false,
|
|
571
|
+
access: {
|
|
572
|
+
has: (obj) => "logPlay" in obj,
|
|
573
|
+
get: (obj) => obj.logPlay
|
|
574
|
+
},
|
|
575
|
+
metadata: _metadata
|
|
576
|
+
}, null, _instanceExtraInitializers);
|
|
577
|
+
__esDecorate(this, null, _dropPending_decorators, {
|
|
578
|
+
kind: "method",
|
|
579
|
+
name: "dropPending",
|
|
580
|
+
static: false,
|
|
581
|
+
private: false,
|
|
582
|
+
access: {
|
|
583
|
+
has: (obj) => "dropPending" in obj,
|
|
584
|
+
get: (obj) => obj.dropPending
|
|
585
|
+
},
|
|
586
|
+
metadata: _metadata
|
|
587
|
+
}, null, _instanceExtraInitializers);
|
|
588
|
+
__esDecorate(this, null, _setParam_decorators, {
|
|
589
|
+
kind: "method",
|
|
590
|
+
name: "setParam",
|
|
591
|
+
static: false,
|
|
592
|
+
private: false,
|
|
593
|
+
access: {
|
|
594
|
+
has: (obj) => "setParam" in obj,
|
|
595
|
+
get: (obj) => obj.setParam
|
|
596
|
+
},
|
|
597
|
+
metadata: _metadata
|
|
598
|
+
}, null, _instanceExtraInitializers);
|
|
599
|
+
__esDecorate(this, null, _finalizeParams_decorators, {
|
|
600
|
+
kind: "method",
|
|
601
|
+
name: "finalizeParams",
|
|
602
|
+
static: false,
|
|
603
|
+
private: false,
|
|
604
|
+
access: {
|
|
605
|
+
has: (obj) => "finalizeParams" in obj,
|
|
606
|
+
get: (obj) => obj.finalizeParams
|
|
607
|
+
},
|
|
608
|
+
metadata: _metadata
|
|
609
|
+
}, null, _instanceExtraInitializers);
|
|
610
|
+
__esDecorate(this, null, _resetParams_decorators, {
|
|
611
|
+
kind: "method",
|
|
612
|
+
name: "resetParams",
|
|
613
|
+
static: false,
|
|
614
|
+
private: false,
|
|
615
|
+
access: {
|
|
616
|
+
has: (obj) => "resetParams" in obj,
|
|
617
|
+
get: (obj) => obj.resetParams
|
|
618
|
+
},
|
|
619
|
+
metadata: _metadata
|
|
620
|
+
}, null, _instanceExtraInitializers);
|
|
621
|
+
__esDecorate(this, null, _editElement_decorators, {
|
|
622
|
+
kind: "method",
|
|
623
|
+
name: "editElement",
|
|
624
|
+
static: false,
|
|
625
|
+
private: false,
|
|
626
|
+
access: {
|
|
627
|
+
has: (obj) => "editElement" in obj,
|
|
628
|
+
get: (obj) => obj.editElement
|
|
629
|
+
},
|
|
630
|
+
metadata: _metadata
|
|
631
|
+
}, null, _instanceExtraInitializers);
|
|
632
|
+
__esDecorate(this, null, _pickProposal_decorators, {
|
|
633
|
+
kind: "method",
|
|
634
|
+
name: "pickProposal",
|
|
635
|
+
static: false,
|
|
636
|
+
private: false,
|
|
637
|
+
access: {
|
|
638
|
+
has: (obj) => "pickProposal" in obj,
|
|
639
|
+
get: (obj) => obj.pickProposal
|
|
640
|
+
},
|
|
641
|
+
metadata: _metadata
|
|
642
|
+
}, null, _instanceExtraInitializers);
|
|
643
|
+
__esDecorate(this, null, _writeChapter_decorators, {
|
|
644
|
+
kind: "method",
|
|
645
|
+
name: "writeChapter",
|
|
646
|
+
static: false,
|
|
647
|
+
private: false,
|
|
648
|
+
access: {
|
|
649
|
+
has: (obj) => "writeChapter" in obj,
|
|
650
|
+
get: (obj) => obj.writeChapter
|
|
651
|
+
},
|
|
652
|
+
metadata: _metadata
|
|
653
|
+
}, null, _instanceExtraInitializers);
|
|
654
|
+
__esDecorate(this, null, _setOnePager_decorators, {
|
|
655
|
+
kind: "method",
|
|
656
|
+
name: "setOnePager",
|
|
657
|
+
static: false,
|
|
658
|
+
private: false,
|
|
659
|
+
access: {
|
|
660
|
+
has: (obj) => "setOnePager" in obj,
|
|
661
|
+
get: (obj) => obj.setOnePager
|
|
662
|
+
},
|
|
663
|
+
metadata: _metadata
|
|
664
|
+
}, null, _instanceExtraInitializers);
|
|
665
|
+
__esDecorate(this, null, _requestRestyle_decorators, {
|
|
666
|
+
kind: "method",
|
|
667
|
+
name: "requestRestyle",
|
|
668
|
+
static: false,
|
|
669
|
+
private: false,
|
|
670
|
+
access: {
|
|
671
|
+
has: (obj) => "requestRestyle" in obj,
|
|
672
|
+
get: (obj) => obj.requestRestyle
|
|
673
|
+
},
|
|
674
|
+
metadata: _metadata
|
|
675
|
+
}, null, _instanceExtraInitializers);
|
|
676
|
+
__esDecorate(this, null, _reject_decorators, {
|
|
677
|
+
kind: "method",
|
|
678
|
+
name: "reject",
|
|
679
|
+
static: false,
|
|
680
|
+
private: false,
|
|
681
|
+
access: {
|
|
682
|
+
has: (obj) => "reject" in obj,
|
|
683
|
+
get: (obj) => obj.reject
|
|
684
|
+
},
|
|
685
|
+
metadata: _metadata
|
|
686
|
+
}, null, _instanceExtraInitializers);
|
|
687
|
+
__esDecorate(this, null, _rollback_decorators, {
|
|
688
|
+
kind: "method",
|
|
689
|
+
name: "rollback",
|
|
690
|
+
static: false,
|
|
691
|
+
private: false,
|
|
692
|
+
access: {
|
|
693
|
+
has: (obj) => "rollback" in obj,
|
|
694
|
+
get: (obj) => obj.rollback
|
|
695
|
+
},
|
|
696
|
+
metadata: _metadata
|
|
697
|
+
}, null, _instanceExtraInitializers);
|
|
698
|
+
__esDecorate(this, null, _diff_decorators, {
|
|
699
|
+
kind: "method",
|
|
700
|
+
name: "diff",
|
|
701
|
+
static: false,
|
|
702
|
+
private: false,
|
|
703
|
+
access: {
|
|
704
|
+
has: (obj) => "diff" in obj,
|
|
705
|
+
get: (obj) => obj.diff
|
|
706
|
+
},
|
|
707
|
+
metadata: _metadata
|
|
708
|
+
}, null, _instanceExtraInitializers);
|
|
709
|
+
__esDecorate(this, null, _checkShot_decorators, {
|
|
710
|
+
kind: "method",
|
|
711
|
+
name: "checkShot",
|
|
712
|
+
static: false,
|
|
713
|
+
private: false,
|
|
714
|
+
access: {
|
|
715
|
+
has: (obj) => "checkShot" in obj,
|
|
716
|
+
get: (obj) => obj.checkShot
|
|
717
|
+
},
|
|
718
|
+
metadata: _metadata
|
|
719
|
+
}, null, _instanceExtraInitializers);
|
|
720
|
+
__esDecorate(this, null, _versionBundle_decorators, {
|
|
721
|
+
kind: "method",
|
|
722
|
+
name: "versionBundle",
|
|
723
|
+
static: false,
|
|
724
|
+
private: false,
|
|
725
|
+
access: {
|
|
726
|
+
has: (obj) => "versionBundle" in obj,
|
|
727
|
+
get: (obj) => obj.versionBundle
|
|
728
|
+
},
|
|
729
|
+
metadata: _metadata
|
|
730
|
+
}, null, _instanceExtraInitializers);
|
|
731
|
+
if (_metadata) Object.defineProperty(this, Symbol.metadata, {
|
|
732
|
+
enumerable: true,
|
|
733
|
+
configurable: true,
|
|
734
|
+
writable: true,
|
|
735
|
+
value: _metadata
|
|
736
|
+
});
|
|
737
|
+
}
|
|
738
|
+
static inject = ["gameStore"];
|
|
739
|
+
/**
|
|
740
|
+
* @param ctx - host context carrying the service core.
|
|
741
|
+
* @param _config - reserved.
|
|
742
|
+
*/
|
|
743
|
+
constructor(ctx, _config = {}) {
|
|
744
|
+
super(ctx, "gameRemote", { namespace: "game" });
|
|
745
|
+
__runInitializers(this, _instanceExtraInitializers);
|
|
746
|
+
}
|
|
747
|
+
get store() {
|
|
748
|
+
return this.ctx.gameStore;
|
|
749
|
+
}
|
|
750
|
+
/** The session's bound project (full view) and the project list. */
|
|
751
|
+
state(sessionId) {
|
|
752
|
+
return this.store.sessionState(sessionId);
|
|
753
|
+
}
|
|
754
|
+
/** What one of the session's turns changed (null when nothing, or not recorded). */
|
|
755
|
+
turnReport(sessionId, request) {
|
|
756
|
+
return this.store.turnReport(sessionId, request.turn);
|
|
757
|
+
}
|
|
758
|
+
/** Every project as a switcher card. */
|
|
759
|
+
projectCards() {
|
|
760
|
+
return this.store.projectCards();
|
|
761
|
+
}
|
|
762
|
+
/** Delete a project: `trash` moves its folder to the system trash, `forget` only removes it from the plugin. */
|
|
763
|
+
async deleteProject(sessionId, request) {
|
|
764
|
+
return this.store.deleteProject(request.projectId, request.mode === "forget" ? "forget" : "trash", await panelSession(this.ctx, sessionId));
|
|
765
|
+
}
|
|
766
|
+
/** Bind the session to a project, or unbind with `projectId: null`. */
|
|
767
|
+
bind(sessionId, request) {
|
|
768
|
+
return this.store.bind(sessionId, request.projectId);
|
|
769
|
+
}
|
|
770
|
+
/** One working build file, for a panel preview ahead of any version. */
|
|
771
|
+
workingFile(request) {
|
|
772
|
+
return this.store.workingFile(request.projectId, request.path);
|
|
773
|
+
}
|
|
774
|
+
/** A version's files for the play area. */
|
|
775
|
+
bundle(request) {
|
|
776
|
+
return this.store.bundle(request.projectId, request.which ?? "current");
|
|
777
|
+
}
|
|
778
|
+
/** Promote the candidate into the play area. */
|
|
779
|
+
async confirm(sessionId, request) {
|
|
780
|
+
return this.store.confirm(request.projectId, await panelSession(this.ctx, sessionId));
|
|
781
|
+
}
|
|
782
|
+
/** What the working files hold that the play area does not (the commit bar polls it). */
|
|
783
|
+
changes(request) {
|
|
784
|
+
return this.store.changes(request.projectId);
|
|
785
|
+
}
|
|
786
|
+
/** 检查并发版: check the working files and cut a version (by the user) carrying every change. */
|
|
787
|
+
async commit(sessionId, request) {
|
|
788
|
+
return this.store.commit(request.projectId, await panelSession(this.ctx, sessionId));
|
|
789
|
+
}
|
|
790
|
+
/** Switch the project's auto-confirm. */
|
|
791
|
+
async setAutoConfirm(sessionId, request) {
|
|
792
|
+
return this.store.setAutoConfirm(request.projectId, request.on, await panelSession(this.ctx, sessionId));
|
|
793
|
+
}
|
|
794
|
+
/** Record one play session into the playtest log. */
|
|
795
|
+
async logPlay(sessionId, request) {
|
|
796
|
+
return this.store.logPlay(request.projectId, request, await panelSession(this.ctx, sessionId));
|
|
797
|
+
}
|
|
798
|
+
/** Drop one pending change (the dock's ×). */
|
|
799
|
+
async dropPending(sessionId, request) {
|
|
800
|
+
return this.store.dropPending(request.projectId, request.key, await panelSession(this.ctx, sessionId));
|
|
801
|
+
}
|
|
802
|
+
/** Set one slider value (user layer). */
|
|
803
|
+
async setParam(sessionId, request) {
|
|
804
|
+
return this.store.setParam(request.projectId, request.key, request.value, await panelSession(this.ctx, sessionId));
|
|
805
|
+
}
|
|
806
|
+
/** 定稿: write slider values into game.json. */
|
|
807
|
+
async finalizeParams(sessionId, request) {
|
|
808
|
+
return this.store.finalizeParams(request.projectId, await panelSession(this.ctx, sessionId));
|
|
809
|
+
}
|
|
810
|
+
/** 还原: drop the slider overrides. */
|
|
811
|
+
async resetParams(sessionId, request) {
|
|
812
|
+
return this.store.resetParams(request.projectId, await panelSession(this.ctx, sessionId));
|
|
813
|
+
}
|
|
814
|
+
/** Edit one element's look (JSON text) and/or scalar fields (JSON text of an object). */
|
|
815
|
+
async editElement(sessionId, request) {
|
|
816
|
+
let look;
|
|
817
|
+
let fields;
|
|
818
|
+
try {
|
|
819
|
+
if (request.look !== void 0) look = JSON.parse(request.look);
|
|
820
|
+
if (request.fields !== void 0) fields = JSON.parse(request.fields);
|
|
821
|
+
} catch {
|
|
822
|
+
return {
|
|
823
|
+
ok: false,
|
|
824
|
+
error: "invalid"
|
|
825
|
+
};
|
|
826
|
+
}
|
|
827
|
+
const result = await this.store.editElement(request.projectId, request.elementId, {
|
|
828
|
+
...look === void 0 ? {} : { look },
|
|
829
|
+
...fields === void 0 ? {} : { fields }
|
|
830
|
+
}, await panelSession(this.ctx, sessionId));
|
|
831
|
+
return result.ok ? {
|
|
832
|
+
ok: true,
|
|
833
|
+
elementJson: JSON.stringify(result.element)
|
|
834
|
+
} : result;
|
|
835
|
+
}
|
|
836
|
+
/** Pick one appearance variant. */
|
|
837
|
+
async pickProposal(sessionId, request) {
|
|
838
|
+
return this.store.pickProposal(request.projectId, request.elementId, request.at, request.index, await panelSession(this.ctx, sessionId));
|
|
839
|
+
}
|
|
840
|
+
/** Write a design chapter. */
|
|
841
|
+
async writeChapter(sessionId, request) {
|
|
842
|
+
return this.store.writeChapter(request.projectId, request.chapterId, request.text, await panelSession(this.ctx, sessionId));
|
|
843
|
+
}
|
|
844
|
+
/** Edit the one-pager (game.json pitch / element types / pillars). */
|
|
845
|
+
async setOnePager(sessionId, request) {
|
|
846
|
+
return this.store.setOnePager(request.projectId, request, await panelSession(this.ctx, sessionId));
|
|
847
|
+
}
|
|
848
|
+
/** Mark a pending restyle (the next passing version becomes a candidate). */
|
|
849
|
+
async requestRestyle(sessionId, request) {
|
|
850
|
+
return this.store.requestRestyle(request.projectId, request.styleId, await panelSession(this.ctx, sessionId));
|
|
851
|
+
}
|
|
852
|
+
/** Throw the candidate away. */
|
|
853
|
+
async reject(sessionId, request) {
|
|
854
|
+
return this.store.reject(request.projectId, request.reason, await panelSession(this.ctx, sessionId));
|
|
855
|
+
}
|
|
856
|
+
/** Put an older version into the play area. */
|
|
857
|
+
async rollback(sessionId, request) {
|
|
858
|
+
return this.store.rollback(request.projectId, request.version, await panelSession(this.ctx, sessionId));
|
|
859
|
+
}
|
|
860
|
+
/** Files that differ between two versions. */
|
|
861
|
+
diff(request) {
|
|
862
|
+
return this.store.diff(request.projectId, request.from, request.to);
|
|
863
|
+
}
|
|
864
|
+
/** The last check's screenshot. */
|
|
865
|
+
checkShot(request) {
|
|
866
|
+
return this.store.checkShot(request.projectId, request.version);
|
|
867
|
+
}
|
|
868
|
+
/** A specific version's files (rollback preview, export). */
|
|
869
|
+
versionBundle(request) {
|
|
870
|
+
return this.store.versionBundle(request.projectId, request.version);
|
|
871
|
+
}
|
|
872
|
+
};
|
|
873
|
+
})();
|
|
874
|
+
//#endregion
|
|
875
|
+
//#region lib/types/vfs.js
|
|
876
|
+
/**
|
|
877
|
+
* The in-sandbox loader (probe P0-1, proposal §5): a tiny HTML shell that
|
|
878
|
+
* receives a project's files, mints blob URLs inside its OWN opaque origin,
|
|
879
|
+
* rewrites relative module specifiers and CSS `url()`, shims `fetch` and
|
|
880
|
+
* element `src`, then writes the project's `index.html` over itself.
|
|
881
|
+
*
|
|
882
|
+
* Shared by both halves: the panel puts the shell in a
|
|
883
|
+
* `sandbox="allow-scripts"` iframe and posts the files in; the host's checks
|
|
884
|
+
* load the same shell in headless Chrome with the files pre-set on
|
|
885
|
+
* `window.__VFS_FILES__`. Everything that runs in the frame is kept as
|
|
886
|
+
* STRINGS here — never `fn.toString()` — so a bundler's renaming can never
|
|
887
|
+
* break it.
|
|
888
|
+
*
|
|
889
|
+
* The bridge script is installed twice, in the shell and again in the written
|
|
890
|
+
* document, because `document.open()` drops every window listener. It reports
|
|
891
|
+
* errors to the parent, and stands in for `localStorage` (an opaque origin
|
|
892
|
+
* has none): saves start from what the parent sends with the files and each
|
|
893
|
+
* write is posted back for the parent to keep.
|
|
894
|
+
* @module @khorsheed/dsh-game/vfs
|
|
895
|
+
*/
|
|
896
|
+
/** The frame's CSP: nothing leaves the frame; scripts and assets come from blobs only. */
|
|
897
|
+
const SANDBOX_CSP = "default-src 'none'; script-src 'unsafe-inline' blob:; style-src 'unsafe-inline' blob:; img-src blob: data:; media-src blob: data:; font-src blob: data:; connect-src blob: data:";
|
|
898
|
+
/** The bridge (runs in the frame). */
|
|
899
|
+
const BRIDGE = `(function(){
|
|
900
|
+
var errors = window.__gamedevErrors || (window.__gamedevErrors = []);
|
|
901
|
+
function post(m){ try { if (parent !== window) parent.postMessage(m, '*') } catch (e) {} }
|
|
902
|
+
function report(kind, msg){ var t = String(msg); if (errors.length < 50) errors.push({ kind: kind, msg: t }); post({ gamedev: 'error', kind: kind, msg: t }) }
|
|
903
|
+
addEventListener('error', function(e){
|
|
904
|
+
if (e.target && e.target !== window && e.target.tagName) { report('resource', (e.target.tagName || '') + ' ' + (e.target.src || e.target.href || '').slice(0, 60)); return }
|
|
905
|
+
report('error', (e.message || '') + (e.lineno ? ' (line ' + e.lineno + ')' : ''))
|
|
906
|
+
}, true);
|
|
907
|
+
addEventListener('unhandledrejection', function(e){ report('rejection', e.reason && e.reason.message || e.reason) });
|
|
908
|
+
if (!console.__gd) { var ce = console.error; console.error = function(){ report('console', [].join.call(arguments, ' ')); return ce.apply(console, arguments) }; console.__gd = 1 }
|
|
909
|
+
var saves = window.__gdSaves || (window.__gdSaves = {});
|
|
910
|
+
var nativeOk = false;
|
|
911
|
+
try { nativeOk = !!window.localStorage && (window.localStorage.getItem('__gd'), true) } catch (e) {}
|
|
912
|
+
if (!nativeOk) {
|
|
913
|
+
var store = {
|
|
914
|
+
getItem: function(k){ k = String(k); return Object.prototype.hasOwnProperty.call(saves, k) ? saves[k] : null },
|
|
915
|
+
setItem: function(k, v){ saves[String(k)] = String(v); post({ gamedev: 'save', saves: saves }) },
|
|
916
|
+
removeItem: function(k){ delete saves[String(k)]; post({ gamedev: 'save', saves: saves }) },
|
|
917
|
+
clear: function(){ for (var k in saves) delete saves[k]; post({ gamedev: 'save', saves: saves }) },
|
|
918
|
+
key: function(i){ return Object.keys(saves)[i] || null },
|
|
919
|
+
get length(){ return Object.keys(saves).length }
|
|
920
|
+
};
|
|
921
|
+
try { Object.defineProperty(window, 'localStorage', { configurable: true, get: function(){ return store } }) } catch (e) {}
|
|
922
|
+
}
|
|
923
|
+
window.dshParams = window.dshParams || {};
|
|
924
|
+
window.dshElements = window.dshElements || {};
|
|
925
|
+
addEventListener('message', function(e){
|
|
926
|
+
var d = e.data || {}, g = window.dshGame;
|
|
927
|
+
if (d.gamedev === 'inspect') post({ gamedev: 'inspect', ready: !!(g && g.ready), state: g && g.state ? g.state() : null, errors: errors });
|
|
928
|
+
if (d.gamedev === 'params' && d.values) {
|
|
929
|
+
for (var k in d.values) window.dshParams[k] = Number(d.values[k]);
|
|
930
|
+
dispatchEvent(new CustomEvent('dshparams', { detail: window.dshParams }));
|
|
931
|
+
}
|
|
932
|
+
if (d.gamedev === 'element' && d.element && d.element.id) {
|
|
933
|
+
window.dshElements[d.element.id] = d.element;
|
|
934
|
+
dispatchEvent(new CustomEvent('dshelements', { detail: d.element }));
|
|
935
|
+
}
|
|
936
|
+
if (d.gamedev === 'pick') {
|
|
937
|
+
var cv = document.querySelector('canvas'), id = null, ref = null, cx = 0, cy = 0;
|
|
938
|
+
if (cv) {
|
|
939
|
+
var rect = cv.getBoundingClientRect(), px = d.x * innerWidth, py = d.y * innerHeight;
|
|
940
|
+
cx = (px - rect.left) * cv.width / rect.width; cy = (py - rect.top) * cv.height / rect.height;
|
|
941
|
+
if (g && typeof g.pick === 'function' && cx >= 0 && cy >= 0 && cx <= cv.width && cy <= cv.height) {
|
|
942
|
+
try { var got = g.pick(cx, cy); if (got && typeof got === 'object') { ref = got; id = got.id || null } else id = got || null } catch (err) { report('error', 'pick: ' + err.message) }
|
|
943
|
+
}
|
|
944
|
+
}
|
|
945
|
+
// A string id (2D games) or a structured reference (dsh3d: id, inst, name, state, point, region).
|
|
946
|
+
post({ gamedev: 'picked', id: id, ref: ref, hover: !!d.hover, supported: !!(g && typeof g.pick === 'function'), x: d.x, y: d.y });
|
|
947
|
+
}
|
|
948
|
+
// The panel's pick mode freezes the game; a candidate's changes get ringed. Both optional on the game side.
|
|
949
|
+
// The panel sends them as soon as the files are posted, often before the game's modules ran: the latest
|
|
950
|
+
// of each is kept and applied once window.dshGame appears (polled briefly; installs share one record).
|
|
951
|
+
if (d.gamedev === 'pause' || d.gamedev === 'highlight') {
|
|
952
|
+
var pend = window.__gdPend || (window.__gdPend = {});
|
|
953
|
+
pend[d.gamedev] = d;
|
|
954
|
+
var apply = function () {
|
|
955
|
+
var game = window.dshGame;
|
|
956
|
+
if (!game) return false;
|
|
957
|
+
var p = window.__gdPend || {};
|
|
958
|
+
if (p.pause && typeof game.pause === 'function') { try { game.pause(!!p.pause.on) } catch (err) { report('error', 'pause: ' + err.message) } }
|
|
959
|
+
if (p.highlight && typeof game.highlight === 'function') { try { game.highlight(Array.isArray(p.highlight.list) ? p.highlight.list : []) } catch (err) { report('error', 'highlight: ' + err.message) } }
|
|
960
|
+
window.__gdPend = {};
|
|
961
|
+
return true;
|
|
962
|
+
};
|
|
963
|
+
if (!apply() && !window.__gdPendTimer) {
|
|
964
|
+
var tries = 0;
|
|
965
|
+
window.__gdPendTimer = setInterval(function () { if (apply() || ++tries > 150) { clearInterval(window.__gdPendTimer); window.__gdPendTimer = 0 } }, 100);
|
|
966
|
+
}
|
|
967
|
+
}
|
|
968
|
+
});
|
|
969
|
+
})();`;
|
|
970
|
+
/** The loader (runs in the frame): defines `window.__boot(files)`. */
|
|
971
|
+
const LOADER = `window.__boot = function (files) {
|
|
972
|
+
var urls = {};
|
|
973
|
+
function norm(base, rel) {
|
|
974
|
+
if (/^[a-z][a-z0-9+.-]*:/i.test(rel) || rel.indexOf('//') === 0) return null;
|
|
975
|
+
var clean = rel.split(/[?#]/)[0], out = [];
|
|
976
|
+
var parts = ((clean.charAt(0) === '/' ? '' : base) + clean).split('/');
|
|
977
|
+
for (var i = 0; i < parts.length; i++) { var s = parts[i]; if (!s || s === '.') continue; if (s === '..') out.pop(); else out.push(s) }
|
|
978
|
+
return out.join('/');
|
|
979
|
+
}
|
|
980
|
+
function dir(p) { return p.indexOf('/') >= 0 ? p.slice(0, p.lastIndexOf('/') + 1) : '' }
|
|
981
|
+
function bytes(p) {
|
|
982
|
+
var f = files[p];
|
|
983
|
+
if (f.text != null) return new TextEncoder().encode(f.text);
|
|
984
|
+
var bin = atob(f.b64), u = new Uint8Array(bin.length);
|
|
985
|
+
for (var i = 0; i < bin.length; i++) u[i] = bin.charCodeAt(i);
|
|
986
|
+
return u;
|
|
987
|
+
}
|
|
988
|
+
function text(p) { return files[p].text != null ? files[p].text : new TextDecoder().decode(bytes(p)) }
|
|
989
|
+
function mint(body, type) { return URL.createObjectURL(new Blob([body], { type: type })) }
|
|
990
|
+
function rewriteCss(css, base) {
|
|
991
|
+
return css.replace(/url\\(\\s*(['"]?)([^'")]+)\\1\\s*\\)/g, function (m, q, u) { var r = norm(base, u); return r && files[r] ? 'url("' + urlFor(r) + '")' : m });
|
|
992
|
+
}
|
|
993
|
+
var busy = {};
|
|
994
|
+
// An exact bare name, else the longest prefix key ending in '/' (import-map style).
|
|
995
|
+
function bareOf(spec) {
|
|
996
|
+
if (bare[spec]) return bare[spec];
|
|
997
|
+
var best = '';
|
|
998
|
+
for (var k in bare) if (k.charAt(k.length - 1) === '/' && spec.indexOf(k) === 0 && k.length > best.length) best = k;
|
|
999
|
+
return best ? bare[best] + spec.slice(best.length) : undefined;
|
|
1000
|
+
}
|
|
1001
|
+
function modUrl(p) {
|
|
1002
|
+
if (urls[p]) return urls[p];
|
|
1003
|
+
if (busy[p]) throw new Error('module cycle at ' + p);
|
|
1004
|
+
busy[p] = 1;
|
|
1005
|
+
var src = text(p).replace(/(\\bimport\\s*\\(\\s*|\\bfrom\\s*|\\bimport\\s+)(['"])([^'"]+)\\2/g, function (m, pre, q, spec) {
|
|
1006
|
+
var r = /^\\.{0,2}\\//.test(spec) ? norm(dir(p), spec) : bareOf(spec);
|
|
1007
|
+
return r && files[r] ? pre + q + modUrl(r) + q : m;
|
|
1008
|
+
});
|
|
1009
|
+
delete busy[p];
|
|
1010
|
+
return (urls[p] = mint(src, 'text/javascript'));
|
|
1011
|
+
}
|
|
1012
|
+
function urlFor(p) {
|
|
1013
|
+
if (urls[p]) return urls[p];
|
|
1014
|
+
if (/\\.m?js$/.test(p)) return modUrl(p);
|
|
1015
|
+
if (/\\.css$/.test(p)) return (urls[p] = mint(rewriteCss(text(p), dir(p)), 'text/css'));
|
|
1016
|
+
return (urls[p] = mint(bytes(p), files[p].type));
|
|
1017
|
+
}
|
|
1018
|
+
function rel(v) { var r = typeof v === 'string' && norm('', v); return r && files[r] ? urlFor(r) : null }
|
|
1019
|
+
// A project path's blob URL (null when the project has no such file): three's loading
|
|
1020
|
+
// manager maps model and texture paths through it, since a relative Request has no usable base here.
|
|
1021
|
+
window.__dshFileUrl = rel;
|
|
1022
|
+
var nativeFetch = window.fetch.bind(window);
|
|
1023
|
+
window.fetch = function (input, init) {
|
|
1024
|
+
var u = typeof input === 'string' ? input : input && input.url;
|
|
1025
|
+
var r = typeof u === 'string' && norm('', u);
|
|
1026
|
+
if (r && files[r]) return Promise.resolve(new Response(bytes(r), { status: 200, headers: { 'content-type': files[r].type } }));
|
|
1027
|
+
return nativeFetch(input, init);
|
|
1028
|
+
};
|
|
1029
|
+
[[HTMLImageElement.prototype, 'src'], [HTMLMediaElement.prototype, 'src'], [HTMLSourceElement.prototype, 'src']].forEach(function (pair) {
|
|
1030
|
+
var d = Object.getOwnPropertyDescriptor(pair[0], pair[1]);
|
|
1031
|
+
Object.defineProperty(pair[0], pair[1], { configurable: true, enumerable: d.enumerable, get: function () { return d.get.call(this) }, set: function (v) { d.set.call(this, rel(v) || v) } });
|
|
1032
|
+
});
|
|
1033
|
+
// The kit's bare module map (e.g. 'three' → '_dsh/three.module.js').
|
|
1034
|
+
var bare = {};
|
|
1035
|
+
try { if (files['_dsh/imports.json']) bare = JSON.parse(text('_dsh/imports.json')) || {} } catch (e) {}
|
|
1036
|
+
if (!files['index.html']) { document.body.textContent = '这个版本没有 index.html'; return }
|
|
1037
|
+
try {
|
|
1038
|
+
var manifest = files['game.json'] ? JSON.parse(text('game.json')) : {};
|
|
1039
|
+
window.dshManifest = manifest;
|
|
1040
|
+
(manifest.params || []).forEach(function (p) { if (p && p.key && !(p.key in window.dshParams)) window.dshParams[p.key] = Number(p.value) });
|
|
1041
|
+
} catch (e) {}
|
|
1042
|
+
Object.keys(files).forEach(function (p) {
|
|
1043
|
+
if (p.indexOf('elements/') !== 0 || !/\.json$/.test(p)) return;
|
|
1044
|
+
try { var raw = JSON.parse(text(p)); (Array.isArray(raw) ? raw : [raw]).forEach(function (el) { if (el && el.id) window.dshElements[el.id] = el }) } catch (e) {}
|
|
1045
|
+
});
|
|
1046
|
+
var doc = new DOMParser().parseFromString(text('index.html'), 'text/html');
|
|
1047
|
+
doc.querySelectorAll('[src]').forEach(function (el) { var u = rel(el.getAttribute('src')); if (u) el.setAttribute('src', u) });
|
|
1048
|
+
doc.querySelectorAll('link[href]').forEach(function (el) { var u = rel(el.getAttribute('href')); if (u) el.setAttribute('href', u) });
|
|
1049
|
+
doc.querySelectorAll('style').forEach(function (st) { st.textContent = rewriteCss(st.textContent, '') });
|
|
1050
|
+
var meta = doc.createElement('meta'); meta.httpEquiv = 'Content-Security-Policy'; meta.content = window.__GD_CSP;
|
|
1051
|
+
var bridge = doc.createElement('script'); bridge.textContent = window.__GD_BRIDGE;
|
|
1052
|
+
doc.head.prepend(meta, bridge);
|
|
1053
|
+
// The kit runtime runs right after the bridge, before any of the game's own scripts.
|
|
1054
|
+
if (files['_dsh/runtime.js']) { var kit = doc.createElement('script'); kit.src = urlFor('_dsh/runtime.js'); bridge.after(kit) }
|
|
1055
|
+
document.open(); document.write('<!doctype html>' + doc.documentElement.outerHTML); document.close();
|
|
1056
|
+
};`;
|
|
1057
|
+
/** Starts the boot: pre-set files (headless) or the first `files` message (panel). */
|
|
1058
|
+
const START = `if (window.__VFS_FILES__) window.__boot(window.__VFS_FILES__);
|
|
1059
|
+
else { var once = false; addEventListener('message', function (e) {
|
|
1060
|
+
var d = e.data;
|
|
1061
|
+
if (once || !d || d.gamedev !== 'files') return;
|
|
1062
|
+
once = true;
|
|
1063
|
+
if (d.saves && typeof d.saves === 'object') { var s = window.__gdSaves || (window.__gdSaves = {}); for (var k in d.saves) s[k] = String(d.saves[k]) }
|
|
1064
|
+
if (d.params && typeof d.params === 'object') { window.dshParams = window.dshParams || {}; for (var pk in d.params) window.dshParams[pk] = Number(d.params[pk]) }
|
|
1065
|
+
window.__boot(d.files);
|
|
1066
|
+
}); }`;
|
|
1067
|
+
/** Escape a string for embedding inside an inline `<script>` (a literal `<\/script>` would end it). */
|
|
1068
|
+
function scriptSafe(json) {
|
|
1069
|
+
return json.replace(/</g, "\\u003c");
|
|
1070
|
+
}
|
|
1071
|
+
/**
|
|
1072
|
+
* The shell document. `extraHead` lets the headless runner add a marker;
|
|
1073
|
+
* the panel passes nothing.
|
|
1074
|
+
* @param extraHead - raw HTML placed before the bridge.
|
|
1075
|
+
* @returns the complete HTML string.
|
|
1076
|
+
*/
|
|
1077
|
+
function shellHtml(extraHead = "") {
|
|
1078
|
+
const globals = `window.__GD_CSP=${scriptSafe(JSON.stringify(SANDBOX_CSP))};window.__GD_BRIDGE=${scriptSafe(JSON.stringify(BRIDGE))};`;
|
|
1079
|
+
return `<!doctype html><meta charset="utf-8"><meta http-equiv="Content-Security-Policy" content="${SANDBOX_CSP}"><style>html,body{margin:0;height:100%;background:#111;color:#ccc;font:13px system-ui}</style>${extraHead}<script>${BRIDGE}<\/script><script>${globals}${LOADER}<\/script><script>${START}<\/script>`;
|
|
1080
|
+
}
|
|
1081
|
+
//#endregion
|
|
1082
|
+
//#region lib/types/checks.js
|
|
1083
|
+
/**
|
|
1084
|
+
* The end-of-turn checks (proposal §6): data validation in plain node, then
|
|
1085
|
+
* a smoke run and the project's own test scripts in headless Chrome, all on
|
|
1086
|
+
* the same sandbox loader the panel uses.
|
|
1087
|
+
*
|
|
1088
|
+
* The browser is the machine's installed Chrome (or `chromePath`), driven by
|
|
1089
|
+
* `puppeteer-core` loaded on first use — a host without Chrome still runs
|
|
1090
|
+
* the data checks and reports the browser part as skipped instead of
|
|
1091
|
+
* failing. One browser stays up between turns and shuts down after a minute
|
|
1092
|
+
* idle.
|
|
1093
|
+
*
|
|
1094
|
+
* Test scripts are `tests/*.test.js` the agent writes beside the code. Each
|
|
1095
|
+
* file runs on a fresh load of the build, as the body of an async function
|
|
1096
|
+
* receiving `test`, `assert`, `game` (= `window.dshGame`), `step`, `press`
|
|
1097
|
+
* and `drag` (calls the game's optional `look(dx, dy)`); `test(name, fn)`
|
|
1098
|
+
* registers a case and every case runs in order.
|
|
1099
|
+
* @module @khorsheed/dsh-game
|
|
1100
|
+
*/
|
|
1101
|
+
var __rewriteRelativeImportExtension = function(path, preserveJsx) {
|
|
1102
|
+
if (typeof path === "string" && /^\.\.?\//.test(path)) return path.replace(/\.(tsx)$|((?:\.d)?)((?:\.[^./]+?)?)\.([cm]?)ts$/i, function(m, tsx, d, ext, cm) {
|
|
1103
|
+
return tsx ? preserveJsx ? ".jsx" : ".js" : d && (!ext || !cm) ? m : d + ext + "." + cm.toLowerCase() + "js";
|
|
1104
|
+
});
|
|
1105
|
+
return path;
|
|
1106
|
+
};
|
|
1107
|
+
/** Common Chrome install locations, tried when no path is configured. */
|
|
1108
|
+
const CHROME_CANDIDATES = [
|
|
1109
|
+
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
|
|
1110
|
+
"/Applications/Chromium.app/Contents/MacOS/Chromium",
|
|
1111
|
+
"/usr/bin/google-chrome",
|
|
1112
|
+
"/usr/bin/google-chrome-stable",
|
|
1113
|
+
"/usr/bin/chromium",
|
|
1114
|
+
"/usr/bin/chromium-browser",
|
|
1115
|
+
"C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe"
|
|
1116
|
+
];
|
|
1117
|
+
/**
|
|
1118
|
+
* The Chrome executable to drive: the configured path, `CHROME_PATH`, or the
|
|
1119
|
+
* first common install location that exists.
|
|
1120
|
+
* @param configured - the plugin config's `chromePath`.
|
|
1121
|
+
* @returns the path, or undefined when none is found.
|
|
1122
|
+
*/
|
|
1123
|
+
function findChrome(configured) {
|
|
1124
|
+
for (const candidate of [
|
|
1125
|
+
configured,
|
|
1126
|
+
process.env.CHROME_PATH,
|
|
1127
|
+
...CHROME_CANDIDATES
|
|
1128
|
+
]) if (candidate !== void 0 && candidate !== "" && existsSync(candidate)) return candidate;
|
|
1129
|
+
}
|
|
1130
|
+
/** How long the game gets to set `dshGame.ready`. */
|
|
1131
|
+
const READY_TIMEOUT_MS = 4e3;
|
|
1132
|
+
/** …and how long while it says it is still loading assets (`dshGame.loading`). */
|
|
1133
|
+
const LOADING_TIMEOUT_MS = 2e4;
|
|
1134
|
+
/** How long one test file may run. */
|
|
1135
|
+
const TEST_TIMEOUT_MS = 8e3;
|
|
1136
|
+
/** The viewport checks render at. */
|
|
1137
|
+
const VIEWPORT = {
|
|
1138
|
+
width: 640,
|
|
1139
|
+
height: 400
|
|
1140
|
+
};
|
|
1141
|
+
/** The in-page wait for `window.dshGame.ready`; resolves true/false. */
|
|
1142
|
+
const WAIT_READY = `(async () => {
|
|
1143
|
+
const start = Date.now();
|
|
1144
|
+
for (;;) {
|
|
1145
|
+
const g = window.dshGame;
|
|
1146
|
+
if (g && g.ready) {
|
|
1147
|
+
// Models still loading (dsh3d's glTF looks): smoke, tests and the screenshot wait for them.
|
|
1148
|
+
if (window.__dshPending) await Promise.race([window.__dshPending(), new Promise(r => setTimeout(r, ${LOADING_TIMEOUT_MS}))]);
|
|
1149
|
+
return true;
|
|
1150
|
+
}
|
|
1151
|
+
const limit = g && g.loading ? ${LOADING_TIMEOUT_MS} : ${READY_TIMEOUT_MS};
|
|
1152
|
+
if (Date.now() - start >= limit) return false;
|
|
1153
|
+
await new Promise(r => setTimeout(r, 40));
|
|
1154
|
+
}
|
|
1155
|
+
})()`;
|
|
1156
|
+
/**
|
|
1157
|
+
* The in-page smoke run: 2 s of game time, then the kit's blank-screen probe.
|
|
1158
|
+
* @param blankProbe - the kit's expression (true blank, false drawn, null no canvas).
|
|
1159
|
+
*/
|
|
1160
|
+
function smokeExpression(blankProbe) {
|
|
1161
|
+
return `(async () => {
|
|
1162
|
+
const g = window.dshGame;
|
|
1163
|
+
const out = { stepped: false, blank: null };
|
|
1164
|
+
if (g && typeof g.step === 'function') { for (let i = 0; i < 120; i++) g.step(1 / 60); out.stepped = true; }
|
|
1165
|
+
await new Promise(r => requestAnimationFrame(() => requestAnimationFrame(r)));
|
|
1166
|
+
out.blank = ${blankProbe};
|
|
1167
|
+
return out;
|
|
1168
|
+
})()`;
|
|
1169
|
+
}
|
|
1170
|
+
/**
|
|
1171
|
+
* The in-page test harness for one file. The body is spliced in as the
|
|
1172
|
+
* function body — CSP is bypassed for the check page, never for the panel.
|
|
1173
|
+
* @param body - the test file's text.
|
|
1174
|
+
* @returns the expression to evaluate.
|
|
1175
|
+
*/
|
|
1176
|
+
function testExpression(body) {
|
|
1177
|
+
return `(async () => {
|
|
1178
|
+
const cases = [];
|
|
1179
|
+
const test = (name, fn) => { cases.push({ name: String(name), fn }); };
|
|
1180
|
+
const fail = (msg) => { throw new Error(msg); };
|
|
1181
|
+
const assert = (cond, msg) => { if (!cond) fail(msg || 'assert 失败'); };
|
|
1182
|
+
assert.equal = (a, b, msg) => { if (a !== b) fail((msg ? msg + ':' : '') + '期望 ' + JSON.stringify(b) + ',实际 ' + JSON.stringify(a)); };
|
|
1183
|
+
assert.near = (a, b, eps, msg) => { if (!(Math.abs(a - b) <= (eps ?? 1e-6))) fail((msg ? msg + ':' : '') + '期望约 ' + b + ',实际 ' + a); };
|
|
1184
|
+
const game = window.dshGame;
|
|
1185
|
+
const step = (n = 1, dt = 1 / 60) => { for (let i = 0; i < n; i++) game.step(dt); };
|
|
1186
|
+
const press = (key, frames = 1) => { game.input(key, true); step(frames); game.input(key, false); };
|
|
1187
|
+
const drag = (dx, dy, frames = 1) => { if (typeof game.look !== 'function') fail('window.dshGame.look 不是函数,没法模拟鼠标拖动'); game.look(dx, dy); step(frames); };
|
|
1188
|
+
try {
|
|
1189
|
+
await (async (test, assert, game, step, press, drag) => {\n${body}\n})(test, assert, game, step, press, drag);
|
|
1190
|
+
} catch (e) { return { load: String(e && e.message || e), results: [] }; }
|
|
1191
|
+
const results = [];
|
|
1192
|
+
for (const c of cases) {
|
|
1193
|
+
try { await c.fn(); results.push({ name: c.name, ok: true }); }
|
|
1194
|
+
catch (e) { results.push({ name: c.name, ok: false, msg: String(e && e.message || e).slice(0, 300) }); }
|
|
1195
|
+
}
|
|
1196
|
+
return { results };
|
|
1197
|
+
})()`;
|
|
1198
|
+
}
|
|
1199
|
+
/** The errors the bridge collected, worded for the model. */
|
|
1200
|
+
const READ_ERRORS = "(window.__gamedevErrors || []).slice(0, 5)";
|
|
1201
|
+
/** Race a promise against a timeout. */
|
|
1202
|
+
async function within(ms, work, what) {
|
|
1203
|
+
let timer;
|
|
1204
|
+
try {
|
|
1205
|
+
return await Promise.race([work, new Promise((_, reject) => {
|
|
1206
|
+
timer = setTimeout(() => reject(/* @__PURE__ */ new Error(`${what}超时(${ms / 1e3} 秒)`)), ms);
|
|
1207
|
+
})]);
|
|
1208
|
+
} finally {
|
|
1209
|
+
if (timer !== void 0) clearTimeout(timer);
|
|
1210
|
+
}
|
|
1211
|
+
}
|
|
1212
|
+
/** The headless-Chrome checker. */
|
|
1213
|
+
var ChromeChecker = class {
|
|
1214
|
+
chromePath;
|
|
1215
|
+
browser;
|
|
1216
|
+
idle;
|
|
1217
|
+
profile;
|
|
1218
|
+
/** @param chromePath - configured executable (optional). */
|
|
1219
|
+
constructor(chromePath) {
|
|
1220
|
+
this.chromePath = chromePath;
|
|
1221
|
+
}
|
|
1222
|
+
async launch(executablePath) {
|
|
1223
|
+
const { launch } = await import(__rewriteRelativeImportExtension("puppeteer-core"));
|
|
1224
|
+
this.profile = await mkdtemp(join(tmpdir(), "dsh-game-chrome-"));
|
|
1225
|
+
const browser = await launch({
|
|
1226
|
+
executablePath,
|
|
1227
|
+
headless: true,
|
|
1228
|
+
pipe: true,
|
|
1229
|
+
userDataDir: this.profile,
|
|
1230
|
+
args: [
|
|
1231
|
+
"--no-first-run",
|
|
1232
|
+
"--no-default-browser-check",
|
|
1233
|
+
"--disable-background-networking",
|
|
1234
|
+
"--mute-audio"
|
|
1235
|
+
],
|
|
1236
|
+
timeout: 3e4
|
|
1237
|
+
});
|
|
1238
|
+
browser.on("disconnected", () => {
|
|
1239
|
+
this.browser = void 0;
|
|
1240
|
+
});
|
|
1241
|
+
return browser;
|
|
1242
|
+
}
|
|
1243
|
+
async page(executablePath) {
|
|
1244
|
+
if (this.idle !== void 0) clearTimeout(this.idle);
|
|
1245
|
+
this.browser ??= this.launch(executablePath).catch((error) => {
|
|
1246
|
+
this.browser = void 0;
|
|
1247
|
+
throw error;
|
|
1248
|
+
});
|
|
1249
|
+
return (await this.browser).newPage();
|
|
1250
|
+
}
|
|
1251
|
+
armIdle() {
|
|
1252
|
+
if (this.idle !== void 0) clearTimeout(this.idle);
|
|
1253
|
+
this.idle = setTimeout(() => {
|
|
1254
|
+
this.dispose();
|
|
1255
|
+
}, 6e4);
|
|
1256
|
+
this.idle.unref?.();
|
|
1257
|
+
}
|
|
1258
|
+
/** Load the build into a fresh page with CSP bypassed and wait for ready. */
|
|
1259
|
+
async load(executablePath, files) {
|
|
1260
|
+
const page = await this.page(executablePath);
|
|
1261
|
+
await page.setBypassCSP(true);
|
|
1262
|
+
await page.setViewport(VIEWPORT);
|
|
1263
|
+
await page.evaluateOnNewDocument(`window.__VFS_FILES__ = ${JSON.stringify(files)};`);
|
|
1264
|
+
const shell = shellHtml("<script>window.__GAMEDEV_TEST__=true<\/script>");
|
|
1265
|
+
await page.goto(`data:text/html;base64,${Buffer.from(shell).toString("base64")}`, {
|
|
1266
|
+
waitUntil: "load",
|
|
1267
|
+
timeout: 1e4
|
|
1268
|
+
});
|
|
1269
|
+
return {
|
|
1270
|
+
page,
|
|
1271
|
+
ready: await page.evaluate(WAIT_READY)
|
|
1272
|
+
};
|
|
1273
|
+
}
|
|
1274
|
+
async run(files, tests, options) {
|
|
1275
|
+
const executablePath = findChrome(this.chromePath);
|
|
1276
|
+
if (executablePath === void 0) return {
|
|
1277
|
+
failures: [],
|
|
1278
|
+
passed: 0,
|
|
1279
|
+
skipped: "没找到 Chrome,只跑了数据校验"
|
|
1280
|
+
};
|
|
1281
|
+
const failures = [];
|
|
1282
|
+
let passed = 0;
|
|
1283
|
+
let screenshot;
|
|
1284
|
+
try {
|
|
1285
|
+
const { page, ready } = await this.load(executablePath, files);
|
|
1286
|
+
try {
|
|
1287
|
+
if (!ready) failures.push({
|
|
1288
|
+
where: "smoke",
|
|
1289
|
+
message: `游戏没在 ${READY_TIMEOUT_MS / 1e3} 秒内就绪(加载资源时设 dshGame.loading = true 可放宽到 ${LOADING_TIMEOUT_MS / 1e3} 秒):window.dshGame.ready 一直不是 true`
|
|
1290
|
+
});
|
|
1291
|
+
else {
|
|
1292
|
+
const smoke = await within(5e3, page.evaluate(smokeExpression(options.blankProbe)), "冒烟运行");
|
|
1293
|
+
if (!smoke.stepped) failures.push({
|
|
1294
|
+
where: "smoke",
|
|
1295
|
+
message: "window.dshGame.step 不是函数,测试没法推进游戏时间"
|
|
1296
|
+
});
|
|
1297
|
+
if (smoke.blank === true) failures.push({
|
|
1298
|
+
where: "smoke",
|
|
1299
|
+
message: "跑了 2 秒后画布仍是同一种颜色(什么都没画出来)"
|
|
1300
|
+
});
|
|
1301
|
+
}
|
|
1302
|
+
const errors = await page.evaluate(READ_ERRORS);
|
|
1303
|
+
for (const e of errors) failures.push({
|
|
1304
|
+
where: "smoke",
|
|
1305
|
+
message: `运行报错(${e.kind}):${e.msg.slice(0, 300)}`
|
|
1306
|
+
});
|
|
1307
|
+
screenshot = await page.screenshot({
|
|
1308
|
+
type: "webp",
|
|
1309
|
+
quality: 70,
|
|
1310
|
+
encoding: "base64",
|
|
1311
|
+
captureBeyondViewport: false
|
|
1312
|
+
});
|
|
1313
|
+
} finally {
|
|
1314
|
+
await page.close().catch(() => {});
|
|
1315
|
+
}
|
|
1316
|
+
if (failures.length === 0) for (const [file, body] of Object.entries(tests).sort(([a], [b]) => a.localeCompare(b))) {
|
|
1317
|
+
const { page, ready } = await this.load(executablePath, files);
|
|
1318
|
+
try {
|
|
1319
|
+
if (!ready) {
|
|
1320
|
+
failures.push({
|
|
1321
|
+
where: file,
|
|
1322
|
+
message: "这次加载游戏没有就绪,测试没跑"
|
|
1323
|
+
});
|
|
1324
|
+
continue;
|
|
1325
|
+
}
|
|
1326
|
+
const outcome = await within(TEST_TIMEOUT_MS, page.evaluate(testExpression(body)), "测试");
|
|
1327
|
+
if (outcome.load !== void 0) failures.push({
|
|
1328
|
+
where: file,
|
|
1329
|
+
message: `测试文件本身出错:${outcome.load}`
|
|
1330
|
+
});
|
|
1331
|
+
if (outcome.load === void 0 && outcome.results.length === 0) failures.push({
|
|
1332
|
+
where: file,
|
|
1333
|
+
message: "没有用 test(名称, 函数) 登记任何用例"
|
|
1334
|
+
});
|
|
1335
|
+
for (const r of outcome.results) if (r.ok) passed++;
|
|
1336
|
+
else failures.push({
|
|
1337
|
+
where: `${file} › ${r.name}`,
|
|
1338
|
+
message: r.msg ?? "失败"
|
|
1339
|
+
});
|
|
1340
|
+
} catch (error) {
|
|
1341
|
+
failures.push({
|
|
1342
|
+
where: file,
|
|
1343
|
+
message: error.message.split("\n")[0] ?? "出错"
|
|
1344
|
+
});
|
|
1345
|
+
} finally {
|
|
1346
|
+
await page.close().catch(() => {});
|
|
1347
|
+
}
|
|
1348
|
+
}
|
|
1349
|
+
} catch (error) {
|
|
1350
|
+
return {
|
|
1351
|
+
failures,
|
|
1352
|
+
passed,
|
|
1353
|
+
skipped: `浏览器检查没跑起来:${error.message.split("\n")[0]?.slice(0, 200)}`
|
|
1354
|
+
};
|
|
1355
|
+
} finally {
|
|
1356
|
+
this.armIdle();
|
|
1357
|
+
}
|
|
1358
|
+
return {
|
|
1359
|
+
failures,
|
|
1360
|
+
passed,
|
|
1361
|
+
...screenshot === void 0 ? {} : { screenshot }
|
|
1362
|
+
};
|
|
1363
|
+
}
|
|
1364
|
+
async dispose() {
|
|
1365
|
+
if (this.idle !== void 0) clearTimeout(this.idle);
|
|
1366
|
+
const pending = this.browser;
|
|
1367
|
+
this.browser = void 0;
|
|
1368
|
+
if (pending !== void 0) await pending.then((b) => b.close()).catch(() => {});
|
|
1369
|
+
if (this.profile !== void 0) await rm(this.profile, {
|
|
1370
|
+
recursive: true,
|
|
1371
|
+
force: true
|
|
1372
|
+
}).catch(() => {});
|
|
1373
|
+
this.profile = void 0;
|
|
1374
|
+
}
|
|
1375
|
+
};
|
|
1376
|
+
//#endregion
|
|
1377
|
+
//#region lib/types/kits/ids.js
|
|
1378
|
+
/** Every kit id this build ships. */
|
|
1379
|
+
const KIT_IDS = ["2d-canvas", "3d-three"];
|
|
1380
|
+
/** Whether a value names a shipped kit. */
|
|
1381
|
+
function isKitId(value) {
|
|
1382
|
+
return typeof value === "string" && KIT_IDS.includes(value);
|
|
1383
|
+
}
|
|
1384
|
+
//#endregion
|
|
1385
|
+
//#region lib/types/project.js
|
|
1386
|
+
/**
|
|
1387
|
+
* Pure project logic: the creation gate, manifest parsing, chapter status,
|
|
1388
|
+
* element validation and which files make up a playable build. No I/O —
|
|
1389
|
+
* the store feeds it file contents, the specs feed it strings.
|
|
1390
|
+
* @module @khorsheed/dsh-game
|
|
1391
|
+
*/
|
|
1392
|
+
/** Split a list given either as an array or as one 、/,/newline-separated string. */
|
|
1393
|
+
function listOf(value) {
|
|
1394
|
+
const raw = typeof value === "string" ? value.split(/[、,,;;\n]/) : Array.isArray(value) ? value : [];
|
|
1395
|
+
const out = [];
|
|
1396
|
+
for (const item of raw) {
|
|
1397
|
+
const clean = typeof item === "string" ? item.trim() : "";
|
|
1398
|
+
if (clean !== "" && !out.includes(clean)) out.push(clean);
|
|
1399
|
+
}
|
|
1400
|
+
return out;
|
|
1401
|
+
}
|
|
1402
|
+
/**
|
|
1403
|
+
* Resolve the art style the user picked: an example id, its Chinese label,
|
|
1404
|
+
* or `custom:<description>`. Anything else is not a choice the user made.
|
|
1405
|
+
* @param value - what the model passed.
|
|
1406
|
+
* @returns the stored spelling, or undefined when it names no style.
|
|
1407
|
+
*/
|
|
1408
|
+
function resolveArtStyle(value, styles = ART_STYLES) {
|
|
1409
|
+
const wanted = (value ?? "").trim();
|
|
1410
|
+
if (wanted === "") return void 0;
|
|
1411
|
+
const known = styles.find((style) => style.id === wanted || style.label === wanted);
|
|
1412
|
+
if (known !== void 0) return known.id;
|
|
1413
|
+
if (/^custom[::]\s*\S/.test(wanted)) return `custom:${wanted.replace(/^custom[::]\s*/, "")}`;
|
|
1414
|
+
}
|
|
1415
|
+
/** The style's human label (`custom:` keeps the user's own words). */
|
|
1416
|
+
function artStyleLabel(id) {
|
|
1417
|
+
if (id.startsWith("custom:")) return id.slice(7);
|
|
1418
|
+
return [...ART_STYLES, ...ART_STYLES_3D].find((style) => style.id === id)?.label ?? id;
|
|
1419
|
+
}
|
|
1420
|
+
/** The kit a create request asks for: `3d` / `3d-three` → 3D, anything else 2D. */
|
|
1421
|
+
function requestedKit(runtime) {
|
|
1422
|
+
return /^3d/i.test((runtime ?? "").trim()) ? "3d-three" : "2d-canvas";
|
|
1423
|
+
}
|
|
1424
|
+
/** The example styles a kit offers. */
|
|
1425
|
+
function stylesFor(kit) {
|
|
1426
|
+
return kit === "3d-three" ? ART_STYLES_3D : ART_STYLES;
|
|
1427
|
+
}
|
|
1428
|
+
/**
|
|
1429
|
+
* The light gate: a project needs a title, a one-sentence pitch, at least one
|
|
1430
|
+
* element type and a chosen art style. The model gets back exactly what is
|
|
1431
|
+
* missing, so it asks the user instead of inventing it.
|
|
1432
|
+
* @param request - the create arguments.
|
|
1433
|
+
* @returns the manifest body, or the missing items.
|
|
1434
|
+
*/
|
|
1435
|
+
function gate(request) {
|
|
1436
|
+
const missing = [];
|
|
1437
|
+
const title = (request.title ?? "").trim().slice(0, 60);
|
|
1438
|
+
const pitch = (request.pitch ?? "").trim().slice(0, 400);
|
|
1439
|
+
const elementTypes = listOf(request.elementTypes).slice(0, 12);
|
|
1440
|
+
const kit = requestedKit(request.runtime);
|
|
1441
|
+
const styles = stylesFor(kit);
|
|
1442
|
+
const artStyle = resolveArtStyle(request.artStyle, styles);
|
|
1443
|
+
if (title === "") missing.push("title(游戏名)");
|
|
1444
|
+
if (pitch === "") missing.push("pitch(一句话:玩家做什么、为什么好玩)");
|
|
1445
|
+
if (elementTypes.length === 0) missing.push("elementTypes(游戏里有哪几类元素,如 角色、敌人、道具)");
|
|
1446
|
+
if (artStyle === void 0) missing.push(`artStyle(用户从示例里选的美术风格:${styles.map((s) => `${s.id}=${s.label}`).join("、")},或 custom:描述)`);
|
|
1447
|
+
if (missing.length > 0) return {
|
|
1448
|
+
ok: false,
|
|
1449
|
+
missing
|
|
1450
|
+
};
|
|
1451
|
+
return {
|
|
1452
|
+
ok: true,
|
|
1453
|
+
manifest: {
|
|
1454
|
+
schema: 1,
|
|
1455
|
+
title,
|
|
1456
|
+
pitch,
|
|
1457
|
+
elementTypes,
|
|
1458
|
+
artStyle,
|
|
1459
|
+
pillars: listOf(request.pillars).slice(0, 5),
|
|
1460
|
+
autoConfirm: true,
|
|
1461
|
+
params: [],
|
|
1462
|
+
palette: stylePalette(artStyle),
|
|
1463
|
+
...kit === "2d-canvas" ? {} : { runtime: {
|
|
1464
|
+
kit,
|
|
1465
|
+
version: 1
|
|
1466
|
+
} }
|
|
1467
|
+
}
|
|
1468
|
+
};
|
|
1469
|
+
}
|
|
1470
|
+
/** A style's palette; custom styles start from the pixel palette (the agent may set its own in game.json). */
|
|
1471
|
+
function stylePalette(styleId) {
|
|
1472
|
+
return [...([...ART_STYLES, ...ART_STYLES_3D].find((style) => style.id === styleId) ?? ART_STYLES[0]).palette];
|
|
1473
|
+
}
|
|
1474
|
+
const HEX$1 = /^#[0-9a-f]{6}$/i;
|
|
1475
|
+
/** Parse `game.json` `params[]`, dropping malformed rows and clamping each value into its range. */
|
|
1476
|
+
function parseParams(raw) {
|
|
1477
|
+
if (!Array.isArray(raw)) return [];
|
|
1478
|
+
const out = [];
|
|
1479
|
+
for (const row of raw) {
|
|
1480
|
+
if (row === null || typeof row !== "object") continue;
|
|
1481
|
+
const r = row;
|
|
1482
|
+
const key = typeof r.key === "string" ? r.key.trim() : "";
|
|
1483
|
+
const min = Number(r.min), max = Number(r.max);
|
|
1484
|
+
if (key === "" || !Number.isFinite(min) || !Number.isFinite(max) || max <= min || out.some((p) => p.key === key)) continue;
|
|
1485
|
+
const step = Number(r.step) > 0 ? Number(r.step) : (max - min) / 100;
|
|
1486
|
+
const value = Number.isFinite(Number(r.value)) ? Math.min(max, Math.max(min, Number(r.value))) : min;
|
|
1487
|
+
out.push({
|
|
1488
|
+
key,
|
|
1489
|
+
label: typeof r.label === "string" && r.label.trim() !== "" ? r.label.trim() : key,
|
|
1490
|
+
min,
|
|
1491
|
+
max,
|
|
1492
|
+
step,
|
|
1493
|
+
value,
|
|
1494
|
+
...typeof r.unit === "string" && r.unit !== "" ? { unit: r.unit } : {}
|
|
1495
|
+
});
|
|
1496
|
+
}
|
|
1497
|
+
return out.slice(0, 24);
|
|
1498
|
+
}
|
|
1499
|
+
/** What a usable pixel look needs, worded for the model. */
|
|
1500
|
+
const PIXEL_LOOK_HINT = "look 不可用:需要 palette(#rrggbb 数组)和 states(状态名 → 像素行数组)";
|
|
1501
|
+
/**
|
|
1502
|
+
* Normalize an element's `look`: a palette of `#rrggbb` colors and pixel
|
|
1503
|
+
* rows per state, every row padded/cut to the widest row, characters outside
|
|
1504
|
+
* the palette turned transparent. Anything unusable is null.
|
|
1505
|
+
*/
|
|
1506
|
+
function normalizeLook(raw) {
|
|
1507
|
+
if (raw === null || typeof raw !== "object" || Array.isArray(raw)) return null;
|
|
1508
|
+
const r = raw;
|
|
1509
|
+
const palette = Array.isArray(r.palette) ? r.palette.filter((c) => typeof c === "string" && HEX$1.test(c)).slice(0, 36) : [];
|
|
1510
|
+
if (palette.length === 0 || r.states === null || typeof r.states !== "object") return null;
|
|
1511
|
+
const states = {};
|
|
1512
|
+
for (const [name, rowsRaw] of Object.entries(r.states).slice(0, 8)) {
|
|
1513
|
+
if (!Array.isArray(rowsRaw)) continue;
|
|
1514
|
+
const rows = rowsRaw.filter((row) => typeof row === "string").slice(0, 64);
|
|
1515
|
+
if (rows.length === 0) continue;
|
|
1516
|
+
const width = Math.min(64, Math.max(...rows.map((row) => row.length)));
|
|
1517
|
+
states[name] = rows.map((row) => [...row.padEnd(width, ".").slice(0, width)].map((ch) => {
|
|
1518
|
+
const index = LOOK_CHARS.indexOf(ch.toLowerCase());
|
|
1519
|
+
return index >= 0 && index < palette.length ? ch.toLowerCase() : ".";
|
|
1520
|
+
}).join(""));
|
|
1521
|
+
}
|
|
1522
|
+
return Object.keys(states).length === 0 ? null : {
|
|
1523
|
+
palette,
|
|
1524
|
+
states
|
|
1525
|
+
};
|
|
1526
|
+
}
|
|
1527
|
+
/**
|
|
1528
|
+
* Parse `game.json`. Unknown keys are dropped; a missing optional key takes
|
|
1529
|
+
* its default, so a hand-edited manifest still loads.
|
|
1530
|
+
* @param text - the file's contents.
|
|
1531
|
+
* @returns the manifest, or a Chinese reason.
|
|
1532
|
+
*/
|
|
1533
|
+
function parseManifest(text) {
|
|
1534
|
+
let raw;
|
|
1535
|
+
try {
|
|
1536
|
+
raw = JSON.parse(text);
|
|
1537
|
+
} catch (error) {
|
|
1538
|
+
return {
|
|
1539
|
+
ok: false,
|
|
1540
|
+
error: `${MANIFEST_FILE} 不是合法 JSON:${error.message}`
|
|
1541
|
+
};
|
|
1542
|
+
}
|
|
1543
|
+
if (raw === null || typeof raw !== "object" || Array.isArray(raw)) return {
|
|
1544
|
+
ok: false,
|
|
1545
|
+
error: `${MANIFEST_FILE} 应是一个对象`
|
|
1546
|
+
};
|
|
1547
|
+
const r = raw;
|
|
1548
|
+
const str = (v) => typeof v === "string" ? v : "";
|
|
1549
|
+
const id = str(r.id);
|
|
1550
|
+
if (id === "") return {
|
|
1551
|
+
ok: false,
|
|
1552
|
+
error: `${MANIFEST_FILE} 缺少 id`
|
|
1553
|
+
};
|
|
1554
|
+
return {
|
|
1555
|
+
ok: true,
|
|
1556
|
+
manifest: {
|
|
1557
|
+
schema: 1,
|
|
1558
|
+
id,
|
|
1559
|
+
title: str(r.title) || id,
|
|
1560
|
+
pitch: str(r.pitch),
|
|
1561
|
+
elementTypes: listOf(Array.isArray(r.elementTypes) ? r.elementTypes.filter((x) => typeof x === "string") : str(r.elementTypes)),
|
|
1562
|
+
artStyle: str(r.artStyle),
|
|
1563
|
+
pillars: listOf(Array.isArray(r.pillars) ? r.pillars.filter((x) => typeof x === "string") : str(r.pillars)),
|
|
1564
|
+
autoConfirm: r.autoConfirm !== false,
|
|
1565
|
+
params: parseParams(r.params),
|
|
1566
|
+
palette: Array.isArray(r.palette) && r.palette.some((c) => typeof c === "string" && HEX$1.test(c)) ? r.palette.filter((c) => typeof c === "string" && HEX$1.test(c)) : stylePalette(str(r.artStyle)),
|
|
1567
|
+
createdAt: str(r.createdAt),
|
|
1568
|
+
...runtimeOf(r.runtime) ?? {},
|
|
1569
|
+
...style3dOf(r.style3d) ?? {}
|
|
1570
|
+
}
|
|
1571
|
+
};
|
|
1572
|
+
}
|
|
1573
|
+
/** The manifest's `style3d`, keeping valid colors and clamped numbers; undefined when nothing usable. */
|
|
1574
|
+
function style3dOf(raw) {
|
|
1575
|
+
if (raw === null || typeof raw !== "object" || Array.isArray(raw)) return void 0;
|
|
1576
|
+
const r = raw;
|
|
1577
|
+
const out = {};
|
|
1578
|
+
if (typeof r.preset === "string" && /^[a-z][\w-]{0,31}$/.test(r.preset)) out.preset = r.preset;
|
|
1579
|
+
Object.assign(out, sceneStyleOf(r));
|
|
1580
|
+
if (typeof r.outline === "string" && HEX$1.test(r.outline)) out.outline = r.outline.toLowerCase();
|
|
1581
|
+
const width = clampNum(r.outlineWidth, 0, .2), shades = clampNum(r.shades, 2, 5);
|
|
1582
|
+
if (width !== void 0) out.outlineWidth = width;
|
|
1583
|
+
if (shades !== void 0) out.shades = Math.round(shades);
|
|
1584
|
+
if (typeof r.shadows === "boolean") out.shadows = r.shadows;
|
|
1585
|
+
const rim = clampNum(r.rim, 0, 2);
|
|
1586
|
+
if (rim !== void 0) out.rim = rim;
|
|
1587
|
+
if (typeof r.rimColor === "string" && HEX$1.test(r.rimColor)) out.rimColor = r.rimColor.toLowerCase();
|
|
1588
|
+
if (r.scenes !== null && typeof r.scenes === "object" && !Array.isArray(r.scenes)) {
|
|
1589
|
+
const scenes = {};
|
|
1590
|
+
for (const [name, raw] of Object.entries(r.scenes).slice(0, 32)) {
|
|
1591
|
+
if (name.trim() === "" || name.length > 40 || raw === null || typeof raw !== "object" || Array.isArray(raw)) continue;
|
|
1592
|
+
scenes[name] = sceneStyleOf(raw);
|
|
1593
|
+
}
|
|
1594
|
+
if (Object.keys(scenes).length > 0) out.scenes = scenes;
|
|
1595
|
+
}
|
|
1596
|
+
return Object.keys(out).length === 0 ? void 0 : { style3d: out };
|
|
1597
|
+
}
|
|
1598
|
+
const clampNum = (v, min, max) => typeof v === "number" && Number.isFinite(v) ? Math.min(max, Math.max(min, v)) : void 0;
|
|
1599
|
+
/** The keys a scene may override (and the game-wide style shares): colors, fog, light. */
|
|
1600
|
+
function sceneStyleOf(r) {
|
|
1601
|
+
const out = {};
|
|
1602
|
+
for (const key of [
|
|
1603
|
+
"bg",
|
|
1604
|
+
"ground",
|
|
1605
|
+
"ground2",
|
|
1606
|
+
"water"
|
|
1607
|
+
]) {
|
|
1608
|
+
const v = r[key];
|
|
1609
|
+
if (typeof v === "string" && HEX$1.test(v)) out[key] = v.toLowerCase();
|
|
1610
|
+
}
|
|
1611
|
+
const light = clampNum(r.light, 0, 4);
|
|
1612
|
+
if (light !== void 0) out.light = light;
|
|
1613
|
+
if (Array.isArray(r.fog) && r.fog.length === 2) {
|
|
1614
|
+
const a = clampNum(r.fog[0], 0, 500), b = clampNum(r.fog[1], 0, 1e3);
|
|
1615
|
+
if (a !== void 0 && b !== void 0 && b > a) out.fog = [a, b];
|
|
1616
|
+
}
|
|
1617
|
+
return out;
|
|
1618
|
+
}
|
|
1619
|
+
/** The manifest's `runtime` when it names a shipped kit with a positive integer version. */
|
|
1620
|
+
function runtimeOf(raw) {
|
|
1621
|
+
if (raw === null || typeof raw !== "object" || Array.isArray(raw)) return void 0;
|
|
1622
|
+
const r = raw;
|
|
1623
|
+
if (!isKitId(r.kit)) return void 0;
|
|
1624
|
+
const version = typeof r.version === "number" && Number.isInteger(r.version) && r.version > 0 ? r.version : 1;
|
|
1625
|
+
return { runtime: {
|
|
1626
|
+
kit: r.kit,
|
|
1627
|
+
version
|
|
1628
|
+
} };
|
|
1629
|
+
}
|
|
1630
|
+
/** Serialize a manifest the way the plugin writes it. */
|
|
1631
|
+
function serializeManifest(manifest) {
|
|
1632
|
+
return `${JSON.stringify(manifest, null, 2)}\n`;
|
|
1633
|
+
}
|
|
1634
|
+
/**
|
|
1635
|
+
* A chapter's status from its text: empty is `todo`, a `状态:定稿` (or
|
|
1636
|
+
* `status: done`) line is `done`, anything else is a `draft`.
|
|
1637
|
+
*/
|
|
1638
|
+
function chapterStatus(text) {
|
|
1639
|
+
if (text === void 0 || text.trim() === "") return "todo";
|
|
1640
|
+
if (/^\s*(状态[::]\s*定稿|status:\s*done)\s*$/im.test(text)) return "done";
|
|
1641
|
+
return "draft";
|
|
1642
|
+
}
|
|
1643
|
+
/**
|
|
1644
|
+
* The one-pager (pitch, element types, style, pillars) lives in `game.json`
|
|
1645
|
+
* and the panel pins it; `design/pillars.md` holds only the notes about the
|
|
1646
|
+
* pillars (why each matters, how to test it). Projects created before
|
|
1647
|
+
* 2026-10-10 got a pillars.md that restated the one-pager: this strips those
|
|
1648
|
+
* restated lines so the notes view never shows the card twice.
|
|
1649
|
+
* @param text - the chapter's text.
|
|
1650
|
+
* @param manifest - the one-pager it may restate.
|
|
1651
|
+
* @returns the notes that are not a restatement (trimmed; '' when none).
|
|
1652
|
+
*/
|
|
1653
|
+
function pillarNotes(text, manifest) {
|
|
1654
|
+
if (manifest === null) return text.trim();
|
|
1655
|
+
const pillars = new Set(manifest.pillars.map((p) => p.trim()));
|
|
1656
|
+
return text.split("\n").filter((line) => {
|
|
1657
|
+
const l = line.trim();
|
|
1658
|
+
if (/^#\s/.test(l) && /设计支柱|支柱说明/.test(l)) return false;
|
|
1659
|
+
if (l.startsWith(">") && l.replace(/^>\s*/, "") === manifest.pitch.trim()) return false;
|
|
1660
|
+
if (/^(\d+\.|[-*])\s/.test(l) && pillars.has(l.replace(/^(\d+\.|[-*])\s+/, ""))) return false;
|
|
1661
|
+
if (l === `美术风格:${artStyleLabel(manifest.artStyle)}` || l === `元素类型:${manifest.elementTypes.join("、")}`) return false;
|
|
1662
|
+
if (l.startsWith("(还没定支柱")) return false;
|
|
1663
|
+
return true;
|
|
1664
|
+
}).join("\n").replace(/\n{3,}/g, "\n\n").trim();
|
|
1665
|
+
}
|
|
1666
|
+
/** Element-file scalar values worth tabulating. */
|
|
1667
|
+
function scalarStats(r) {
|
|
1668
|
+
const out = {};
|
|
1669
|
+
for (const [key, value] of Object.entries(r)) {
|
|
1670
|
+
if (key === "id" || key === "type" || key === "name" || key === "look" || key === "lore") continue;
|
|
1671
|
+
if (typeof value === "number" || typeof value === "boolean" || typeof value === "string" && value.length <= 40) out[key] = value;
|
|
1672
|
+
}
|
|
1673
|
+
return out;
|
|
1674
|
+
}
|
|
1675
|
+
/**
|
|
1676
|
+
* Validate the element files. A file holds one element object or an array of
|
|
1677
|
+
* them; each needs `id`, `name` and a `type` the manifest declares, and ids
|
|
1678
|
+
* are unique across files.
|
|
1679
|
+
* @param files - project-relative path → contents, only `elements/*.json`.
|
|
1680
|
+
* @param elementTypes - the manifest's declared types (empty = accept any).
|
|
1681
|
+
* @param looks - how the project's kit normalizes a `look` (default: 2D pixel rows).
|
|
1682
|
+
* @returns the elements that parsed, and one failure per problem.
|
|
1683
|
+
*/
|
|
1684
|
+
function validateElements(files, elementTypes, looks = {
|
|
1685
|
+
normalizeLook,
|
|
1686
|
+
lookHint: PIXEL_LOOK_HINT
|
|
1687
|
+
}) {
|
|
1688
|
+
const elements = [];
|
|
1689
|
+
const failures = [];
|
|
1690
|
+
const seen = /* @__PURE__ */ new Map();
|
|
1691
|
+
for (const [file, text] of Object.entries(files).sort(([a], [b]) => a.localeCompare(b))) {
|
|
1692
|
+
let raw;
|
|
1693
|
+
try {
|
|
1694
|
+
raw = JSON.parse(text);
|
|
1695
|
+
} catch (error) {
|
|
1696
|
+
failures.push({
|
|
1697
|
+
where: file,
|
|
1698
|
+
message: `不是合法 JSON:${error.message}`
|
|
1699
|
+
});
|
|
1700
|
+
continue;
|
|
1701
|
+
}
|
|
1702
|
+
(Array.isArray(raw) ? raw : [raw]).forEach((item, index) => {
|
|
1703
|
+
const at = Array.isArray(raw) ? `${file}[${index}]` : file;
|
|
1704
|
+
if (item === null || typeof item !== "object" || Array.isArray(item)) {
|
|
1705
|
+
failures.push({
|
|
1706
|
+
where: at,
|
|
1707
|
+
message: "元素应是对象"
|
|
1708
|
+
});
|
|
1709
|
+
return;
|
|
1710
|
+
}
|
|
1711
|
+
const r = item;
|
|
1712
|
+
const id = typeof r.id === "string" ? r.id.trim() : "";
|
|
1713
|
+
const name = typeof r.name === "string" ? r.name.trim() : "";
|
|
1714
|
+
const type = typeof r.type === "string" ? r.type.trim() : "";
|
|
1715
|
+
if (id === "") failures.push({
|
|
1716
|
+
where: at,
|
|
1717
|
+
message: "缺少 id"
|
|
1718
|
+
});
|
|
1719
|
+
if (name === "") failures.push({
|
|
1720
|
+
where: at,
|
|
1721
|
+
message: "缺少 name"
|
|
1722
|
+
});
|
|
1723
|
+
if (type === "") failures.push({
|
|
1724
|
+
where: at,
|
|
1725
|
+
message: "缺少 type"
|
|
1726
|
+
});
|
|
1727
|
+
else if (elementTypes.length > 0 && !elementTypes.includes(type)) failures.push({
|
|
1728
|
+
where: at,
|
|
1729
|
+
message: `type「${type}」不在 game.json 的 elementTypes(${elementTypes.join("、")})里`
|
|
1730
|
+
});
|
|
1731
|
+
if (id !== "") {
|
|
1732
|
+
const earlier = seen.get(id);
|
|
1733
|
+
if (earlier !== void 0) failures.push({
|
|
1734
|
+
where: at,
|
|
1735
|
+
message: `id「${id}」和 ${earlier} 重复`
|
|
1736
|
+
});
|
|
1737
|
+
else seen.set(id, at);
|
|
1738
|
+
}
|
|
1739
|
+
if (r.look !== void 0 && looks.normalizeLook(r.look) === null) failures.push({
|
|
1740
|
+
where: at,
|
|
1741
|
+
message: looks.lookHint
|
|
1742
|
+
});
|
|
1743
|
+
if (id !== "" && name !== "" && type !== "") {
|
|
1744
|
+
const look = looks.normalizeLook(r.look);
|
|
1745
|
+
elements.push({
|
|
1746
|
+
file,
|
|
1747
|
+
index: Array.isArray(raw) ? index : -1,
|
|
1748
|
+
id,
|
|
1749
|
+
type,
|
|
1750
|
+
name,
|
|
1751
|
+
stats: scalarStats(r),
|
|
1752
|
+
look: look !== null && isPixelLook(look) ? look : null,
|
|
1753
|
+
look3d: look !== null && !isPixelLook(look) ? look : null,
|
|
1754
|
+
lore: typeof r.lore === "string" ? r.lore.trim() : ""
|
|
1755
|
+
});
|
|
1756
|
+
}
|
|
1757
|
+
});
|
|
1758
|
+
}
|
|
1759
|
+
return {
|
|
1760
|
+
elements,
|
|
1761
|
+
failures
|
|
1762
|
+
};
|
|
1763
|
+
}
|
|
1764
|
+
/** The generated 元素与数值 chapter: one table per element type. */
|
|
1765
|
+
function elementsChapter(elements, elementTypes) {
|
|
1766
|
+
if (elements.length === 0) return "";
|
|
1767
|
+
const types = [...elementTypes, ...elements.map((e) => e.type).filter((t) => !elementTypes.includes(t))];
|
|
1768
|
+
const lines = [
|
|
1769
|
+
"# 元素与数值",
|
|
1770
|
+
"",
|
|
1771
|
+
"(由 elements/ 里的文件自动生成,改数值请改元素文件。)",
|
|
1772
|
+
""
|
|
1773
|
+
];
|
|
1774
|
+
for (const type of types) {
|
|
1775
|
+
const rows = elements.filter((e) => e.type === type);
|
|
1776
|
+
if (rows.length === 0) continue;
|
|
1777
|
+
const keys = [...new Set(rows.flatMap((e) => Object.keys(e.stats)))].slice(0, 8);
|
|
1778
|
+
lines.push(`## ${type}`, "", `| 名称 | id | ${keys.join(" | ")} |`.replace(/ \| \|$/, " |"), `|${" --- |".repeat(2 + keys.length)}`);
|
|
1779
|
+
for (const e of rows) lines.push(`| ${e.name} | ${e.id} | ${keys.map((k) => String(e.stats[k] ?? "")).join(" | ")} |`.replace(/ \| \|$/, " |"));
|
|
1780
|
+
lines.push("");
|
|
1781
|
+
}
|
|
1782
|
+
return lines.join("\n");
|
|
1783
|
+
}
|
|
1784
|
+
/** The generated 元素设定 chapter: every element's 设定 by type (elements without one are left out). */
|
|
1785
|
+
function loreChapter(elements, elementTypes) {
|
|
1786
|
+
const written = elements.filter((e) => e.lore !== "");
|
|
1787
|
+
if (written.length === 0) return "";
|
|
1788
|
+
const types = [...elementTypes, ...written.map((e) => e.type).filter((t) => !elementTypes.includes(t))];
|
|
1789
|
+
const lines = [
|
|
1790
|
+
"# 元素设定",
|
|
1791
|
+
"",
|
|
1792
|
+
"(由元素文件里的设定自动汇总,在「元素」里打开一个元素就能改。)",
|
|
1793
|
+
""
|
|
1794
|
+
];
|
|
1795
|
+
for (const type of types) {
|
|
1796
|
+
const rows = written.filter((e) => e.type === type);
|
|
1797
|
+
if (rows.length === 0) continue;
|
|
1798
|
+
lines.push(`## ${type}`, "");
|
|
1799
|
+
for (const e of rows) lines.push(`### ${e.name}`, "", e.lore, "");
|
|
1800
|
+
}
|
|
1801
|
+
return lines.join("\n");
|
|
1802
|
+
}
|
|
1803
|
+
/** Directories never part of a playable build. */
|
|
1804
|
+
const NON_BUILD_DIRS = /* @__PURE__ */ new Set([
|
|
1805
|
+
STATE_DIR,
|
|
1806
|
+
"design",
|
|
1807
|
+
"tests",
|
|
1808
|
+
"node_modules",
|
|
1809
|
+
".git"
|
|
1810
|
+
]);
|
|
1811
|
+
/**
|
|
1812
|
+
* Whether a project-relative path belongs in a playable build: everything
|
|
1813
|
+
* except the plugin's state, the design doc, the tests and dot-files.
|
|
1814
|
+
*/
|
|
1815
|
+
function isBuildFile(path) {
|
|
1816
|
+
const segments = path.split("/");
|
|
1817
|
+
if (segments.some((s) => s.startsWith(".") && s !== ".")) return false;
|
|
1818
|
+
return !NON_BUILD_DIRS.has(segments[0] ?? "");
|
|
1819
|
+
}
|
|
1820
|
+
/** File-extension → MIME type for the sandbox loader. */
|
|
1821
|
+
const TYPES = {
|
|
1822
|
+
html: "text/html",
|
|
1823
|
+
htm: "text/html",
|
|
1824
|
+
js: "text/javascript",
|
|
1825
|
+
mjs: "text/javascript",
|
|
1826
|
+
css: "text/css",
|
|
1827
|
+
json: "application/json",
|
|
1828
|
+
txt: "text/plain",
|
|
1829
|
+
md: "text/markdown",
|
|
1830
|
+
svg: "image/svg+xml",
|
|
1831
|
+
png: "image/png",
|
|
1832
|
+
jpg: "image/jpeg",
|
|
1833
|
+
jpeg: "image/jpeg",
|
|
1834
|
+
gif: "image/gif",
|
|
1835
|
+
webp: "image/webp",
|
|
1836
|
+
wav: "audio/wav",
|
|
1837
|
+
ogg: "audio/ogg",
|
|
1838
|
+
mp3: "audio/mpeg",
|
|
1839
|
+
m4a: "audio/mp4",
|
|
1840
|
+
woff: "font/woff",
|
|
1841
|
+
woff2: "font/woff2",
|
|
1842
|
+
ttf: "font/ttf",
|
|
1843
|
+
otf: "font/otf",
|
|
1844
|
+
glb: "model/gltf-binary",
|
|
1845
|
+
gltf: "model/gltf+json"
|
|
1846
|
+
};
|
|
1847
|
+
/** Extensions shipped as text (everything else is base64). */
|
|
1848
|
+
const TEXT_EXT = /* @__PURE__ */ new Set([
|
|
1849
|
+
"html",
|
|
1850
|
+
"htm",
|
|
1851
|
+
"js",
|
|
1852
|
+
"mjs",
|
|
1853
|
+
"css",
|
|
1854
|
+
"json",
|
|
1855
|
+
"txt",
|
|
1856
|
+
"md",
|
|
1857
|
+
"svg"
|
|
1858
|
+
]);
|
|
1859
|
+
/** The extension of a path, lower-cased, without the dot. */
|
|
1860
|
+
function extOf(path) {
|
|
1861
|
+
const name = path.slice(path.lastIndexOf("/") + 1);
|
|
1862
|
+
return name.includes(".") ? name.slice(name.lastIndexOf(".") + 1).toLowerCase() : "";
|
|
1863
|
+
}
|
|
1864
|
+
/** A file's MIME type by extension. */
|
|
1865
|
+
function mimeOf(path) {
|
|
1866
|
+
return TYPES[extOf(path)] ?? "application/octet-stream";
|
|
1867
|
+
}
|
|
1868
|
+
/** Whether a file ships to the sandbox as text. */
|
|
1869
|
+
function isTextFile(path) {
|
|
1870
|
+
return TEXT_EXT.has(extOf(path));
|
|
1871
|
+
}
|
|
1872
|
+
/**
|
|
1873
|
+
* A directory-safe project folder name from a title: ASCII letters and
|
|
1874
|
+
* digits kept, other runs collapsed to `-`; a title with none falls back to
|
|
1875
|
+
* `game`. The store appends `-2`, `-3` … on collision.
|
|
1876
|
+
*/
|
|
1877
|
+
function slugOf(title) {
|
|
1878
|
+
const slug = title.normalize("NFKD").toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 40);
|
|
1879
|
+
return slug === "" ? "game" : slug;
|
|
1880
|
+
}
|
|
1881
|
+
/**
|
|
1882
|
+
* Apply a patch to one element inside its file's text (an object, or one
|
|
1883
|
+
* entry of an array), keeping every other key. `undefined` values delete.
|
|
1884
|
+
* @returns the new file text, or undefined when the element is not there.
|
|
1885
|
+
*/
|
|
1886
|
+
function patchElementText(text, elementId, patch) {
|
|
1887
|
+
let raw;
|
|
1888
|
+
try {
|
|
1889
|
+
raw = JSON.parse(text);
|
|
1890
|
+
} catch {
|
|
1891
|
+
return;
|
|
1892
|
+
}
|
|
1893
|
+
const target = (Array.isArray(raw) ? raw : [raw]).find((item) => item !== null && typeof item === "object" && item.id === elementId);
|
|
1894
|
+
if (target === void 0) return void 0;
|
|
1895
|
+
for (const [key, value] of Object.entries(patch)) {
|
|
1896
|
+
if (key === "id") continue;
|
|
1897
|
+
if (value === void 0) delete target[key];
|
|
1898
|
+
else target[key] = value;
|
|
1899
|
+
}
|
|
1900
|
+
return `${JSON.stringify(raw, null, 2)}\n`;
|
|
1901
|
+
}
|
|
1902
|
+
//#endregion
|
|
1903
|
+
//#region lib/types/kits/types.js
|
|
1904
|
+
/** Bundle path prefix the kit's runtime files ride under (never written into a project). */
|
|
1905
|
+
const KIT_DIR = "_dsh";
|
|
1906
|
+
/** The runtime script the loader injects before the game's own scripts. */
|
|
1907
|
+
const KIT_RUNTIME_FILE = `${KIT_DIR}/runtime.js`;
|
|
1908
|
+
/** Bare module specifier → bundle path, read by the loader (e.g. `three` → `_dsh/three.module.js`). */
|
|
1909
|
+
const KIT_IMPORTS_FILE = `${KIT_DIR}/imports.json`;
|
|
1910
|
+
//#endregion
|
|
1911
|
+
//#region lib/types/kits/canvas2d.js
|
|
1912
|
+
/**
|
|
1913
|
+
* The `2d-canvas` kit: native canvas plus an HTML/CSS layer, pixel-row looks
|
|
1914
|
+
* drawn with `dshDraw`, the eight built-in 2D art styles. This is the
|
|
1915
|
+
* workbench's original runtime, moved behind the kit seam unchanged.
|
|
1916
|
+
* @module @khorsheed/dsh-game
|
|
1917
|
+
*/
|
|
1918
|
+
/** The frame runtime: pixel-look drawing helpers over `window.dshElements`. Kept as a string (never `fn.toString()`). */
|
|
1919
|
+
const RUNTIME = `(function(){
|
|
1920
|
+
var CH = '0123456789abcdefghijklmnopqrstuvwxyz';
|
|
1921
|
+
window.dshDraw = function(g, look, state, x, y, scale){
|
|
1922
|
+
if (typeof look === 'string') { var el = window.dshElements[look]; look = el && el.look }
|
|
1923
|
+
if (!look || !look.states || !look.palette) return false;
|
|
1924
|
+
var names = Object.keys(look.states), rows = look.states[state] || look.states[names[0]];
|
|
1925
|
+
if (!rows) return false;
|
|
1926
|
+
scale = scale || 1;
|
|
1927
|
+
for (var r = 0; r < rows.length; r++) for (var c = 0; c < rows[r].length; c++) {
|
|
1928
|
+
var col = look.palette[CH.indexOf(rows[r].charAt(c))];
|
|
1929
|
+
if (!col) continue;
|
|
1930
|
+
g.fillStyle = col; g.fillRect(Math.round(x + c * scale), Math.round(y + r * scale), Math.ceil(scale), Math.ceil(scale));
|
|
1931
|
+
}
|
|
1932
|
+
return true;
|
|
1933
|
+
};
|
|
1934
|
+
window.dshLookSize = function(look){
|
|
1935
|
+
if (typeof look === 'string') { var el = window.dshElements[look]; look = el && el.look }
|
|
1936
|
+
var rows = look && look.states && look.states[Object.keys(look.states)[0]];
|
|
1937
|
+
return rows ? { w: rows[0].length, h: rows.length } : { w: 0, h: 0 };
|
|
1938
|
+
};
|
|
1939
|
+
})();`;
|
|
1940
|
+
/** Blank when the first canvas, scaled to 32×32 through a 2D context, holds one color. */
|
|
1941
|
+
const BLANK_PROBE = `(() => {
|
|
1942
|
+
const c = document.querySelector('canvas');
|
|
1943
|
+
if (!c || c.width <= 0 || c.height <= 0) return null;
|
|
1944
|
+
const probe = document.createElement('canvas'); probe.width = 32; probe.height = 32;
|
|
1945
|
+
const p = probe.getContext('2d'); p.drawImage(c, 0, 0, 32, 32);
|
|
1946
|
+
const d = p.getImageData(0, 0, 32, 32).data; const distinct = new Set();
|
|
1947
|
+
for (let i = 0; i < d.length; i += 4) { distinct.add(d[i] + ',' + d[i + 1] + ',' + d[i + 2]); if (distinct.size > 1) break; }
|
|
1948
|
+
return distinct.size <= 1;
|
|
1949
|
+
})()`;
|
|
1950
|
+
/** The 2D canvas kit. */
|
|
1951
|
+
const CANVAS_2D = {
|
|
1952
|
+
id: "2d-canvas",
|
|
1953
|
+
version: 1,
|
|
1954
|
+
label: "2D 画布",
|
|
1955
|
+
runtimeFiles: () => ({ [KIT_RUNTIME_FILE]: {
|
|
1956
|
+
type: "text/javascript",
|
|
1957
|
+
text: RUNTIME
|
|
1958
|
+
} }),
|
|
1959
|
+
normalizeLook,
|
|
1960
|
+
lookHint: PIXEL_LOOK_HINT,
|
|
1961
|
+
blankProbe: BLANK_PROBE,
|
|
1962
|
+
styles: ART_STYLES,
|
|
1963
|
+
skillSection: `## 运行环境
|
|
1964
|
+
- 只有 2D:原生 canvas 加 HTML/CSS 做界面。不能联网、不能引外部 CDN(沙箱 CSP 全拦),所有代码和资源都放项目里。
|
|
1965
|
+
- 存档用 \`localStorage\`(面板替你保存在用户浏览器里)。
|
|
1966
|
+
- 没有文生图:画面全部用代码画(canvas 绘制、像素数组、SVG)。严格按 game.json 的 artStyle 画:
|
|
1967
|
+
${ART_STYLES.map((s) => `- \`${s.id}\` ${s.label}:${s.look}`).join("\n")}
|
|
1968
|
+
- 外观 look 的格式:\`{ "palette": ["#1a1c2c", "#ffcd75", …], "states": { "idle": ["..00..", ".0110.", …], "move": [...] } }\`。每行一个字符串,\`.\` 透明,\`0-9a-z\` 是 palette 下标;同一状态各行等宽,常用 16×16。颜色优先取 game.json 的 \`palette\`(来自美术风格)。画的时候用 \`dshDraw(ctx2d, 元素id或look, 状态名, x, y, 放大倍数)\`,尺寸用 \`dshLookSize(元素id)\`。
|
|
1969
|
+
`
|
|
1970
|
+
};
|
|
1971
|
+
//#endregion
|
|
1972
|
+
//#region lib/types/look3d.js
|
|
1973
|
+
/**
|
|
1974
|
+
* Validation for 3D looks (`parts3d` recipes and `gltf` files). Pure data,
|
|
1975
|
+
* no three.js: the store, the tools and the browser half all use it, and the
|
|
1976
|
+
* 3d-three kit's `dsh3d.build` trusts what passes here.
|
|
1977
|
+
*
|
|
1978
|
+
* Normalizing is forgiving the way the pixel-look normalizer is: unknown
|
|
1979
|
+
* keys drop, numbers clamp into range, bad colors drop; only a look with no
|
|
1980
|
+
* usable body (an unknown base, or a gltf path that leaves the project) is
|
|
1981
|
+
* rejected outright.
|
|
1982
|
+
* @module @khorsheed/dsh-game
|
|
1983
|
+
*/
|
|
1984
|
+
const HEX = /^#[0-9a-f]{6}$/i;
|
|
1985
|
+
const MOTIONS = [
|
|
1986
|
+
"tailSway",
|
|
1987
|
+
"breath",
|
|
1988
|
+
"flap",
|
|
1989
|
+
"swim",
|
|
1990
|
+
"bob"
|
|
1991
|
+
];
|
|
1992
|
+
/** Proportions that are counts (六足, 多首, a serpent's segments), with their integer ranges. */
|
|
1993
|
+
const COUNTS = {
|
|
1994
|
+
legs: [0, 8],
|
|
1995
|
+
heads: [1, 9],
|
|
1996
|
+
segments: [3, 40]
|
|
1997
|
+
};
|
|
1998
|
+
/** What a usable 3D look needs, worded for the model. */
|
|
1999
|
+
const LOOK3D_HINT = `look 不可用:3D 外观要么是 { kind: "parts3d", base: ${BODY_BASES.map((b) => `"${b}"`).join(" / ")} 或项目里 defineBase 注册的名字, proportions, parts, palette, motion },要么是 { kind: "gltf", src: "assets/….glb" }(路径在项目里)`;
|
|
2000
|
+
function clamp(value, min, max) {
|
|
2001
|
+
return typeof value === "number" && Number.isFinite(value) ? Math.min(max, Math.max(min, value)) : void 0;
|
|
2002
|
+
}
|
|
2003
|
+
function record(raw) {
|
|
2004
|
+
return raw !== null && typeof raw === "object" && !Array.isArray(raw) ? raw : void 0;
|
|
2005
|
+
}
|
|
2006
|
+
/** A name a project may register with `defineBase` / `definePart`: lowercase identifier. */
|
|
2007
|
+
const NAME = /^[a-z][\w-]{0,31}$/;
|
|
2008
|
+
function part(raw) {
|
|
2009
|
+
const r = record(raw);
|
|
2010
|
+
if (r === void 0 || typeof r.type !== "string" || !NAME.test(r.type)) return void 0;
|
|
2011
|
+
const out = { type: r.type };
|
|
2012
|
+
const count = clamp(r.count, 1, 12);
|
|
2013
|
+
if (count !== void 0) out.count = Math.round(count);
|
|
2014
|
+
for (const [key, min, max] of [
|
|
2015
|
+
[
|
|
2016
|
+
"size",
|
|
2017
|
+
.01,
|
|
2018
|
+
3
|
|
2019
|
+
],
|
|
2020
|
+
[
|
|
2021
|
+
"length",
|
|
2022
|
+
.01,
|
|
2023
|
+
5
|
|
2024
|
+
],
|
|
2025
|
+
[
|
|
2026
|
+
"fan",
|
|
2027
|
+
0,
|
|
2028
|
+
360
|
|
2029
|
+
],
|
|
2030
|
+
[
|
|
2031
|
+
"thickness",
|
|
2032
|
+
.005,
|
|
2033
|
+
1
|
|
2034
|
+
]
|
|
2035
|
+
]) {
|
|
2036
|
+
const v = clamp(r[key], min, max);
|
|
2037
|
+
if (v !== void 0) out[key] = v;
|
|
2038
|
+
}
|
|
2039
|
+
if (typeof r.color === "string" && r.color.length <= 24) out.color = r.color;
|
|
2040
|
+
if (typeof r.style === "string" && r.style.length <= 24) out.style = r.style;
|
|
2041
|
+
if (!PART_TYPES.includes(r.type)) {
|
|
2042
|
+
const extra = {};
|
|
2043
|
+
for (const [k, v] of Object.entries(r).slice(0, 16)) {
|
|
2044
|
+
if (k in out || k === "type" || !NAME.test(k)) continue;
|
|
2045
|
+
if (typeof v === "number" && Number.isFinite(v)) extra[k] = Math.min(1e3, Math.max(-1e3, v));
|
|
2046
|
+
else if (typeof v === "string" && v.length <= 40) extra[k] = v;
|
|
2047
|
+
}
|
|
2048
|
+
if (Object.keys(extra).length > 0) out.extra = extra;
|
|
2049
|
+
}
|
|
2050
|
+
return out;
|
|
2051
|
+
}
|
|
2052
|
+
function parts3d(r) {
|
|
2053
|
+
if (typeof r.base !== "string" || !NAME.test(r.base)) return null;
|
|
2054
|
+
const proportions = {};
|
|
2055
|
+
for (const [k, v] of Object.entries(record(r.proportions) ?? {}).slice(0, 16)) {
|
|
2056
|
+
const count = COUNTS[k];
|
|
2057
|
+
const n = count === void 0 ? clamp(v, .01, 5) : clamp(v, count[0], count[1]);
|
|
2058
|
+
if (n !== void 0 && k.length <= 24) proportions[k] = count === void 0 ? n : Math.round(n);
|
|
2059
|
+
}
|
|
2060
|
+
const palette = {};
|
|
2061
|
+
for (const [k, v] of Object.entries(record(r.palette) ?? {}).slice(0, 12)) if (typeof v === "string" && HEX.test(v) && k.length <= 24) palette[k] = v.toLowerCase();
|
|
2062
|
+
const motion = {};
|
|
2063
|
+
for (const name of MOTIONS) {
|
|
2064
|
+
const m = record(record(r.motion)?.[name]);
|
|
2065
|
+
if (m === void 0) continue;
|
|
2066
|
+
const amplitude = clamp(m.amplitude, 0, 90), period = clamp(m.period, .1, 20);
|
|
2067
|
+
motion[name] = {
|
|
2068
|
+
...amplitude === void 0 ? {} : { amplitude },
|
|
2069
|
+
...period === void 0 ? {} : { period }
|
|
2070
|
+
};
|
|
2071
|
+
}
|
|
2072
|
+
const refine = {};
|
|
2073
|
+
for (const [k, v] of Object.entries(record(r.refine) ?? {}).slice(0, 8)) {
|
|
2074
|
+
const n = clamp(v, 0, 1e5);
|
|
2075
|
+
if (n !== void 0) refine[k] = n;
|
|
2076
|
+
}
|
|
2077
|
+
const scale = clamp(r.scale, .05, 50);
|
|
2078
|
+
return {
|
|
2079
|
+
kind: "parts3d",
|
|
2080
|
+
base: r.base,
|
|
2081
|
+
proportions,
|
|
2082
|
+
parts: (Array.isArray(r.parts) ? r.parts : []).slice(0, 16).map(part).filter((p) => p !== void 0),
|
|
2083
|
+
palette,
|
|
2084
|
+
motion,
|
|
2085
|
+
...scale === void 0 ? {} : { scale },
|
|
2086
|
+
...previewOf(r),
|
|
2087
|
+
...Object.keys(refine).length === 0 ? {} : { refine }
|
|
2088
|
+
};
|
|
2089
|
+
}
|
|
2090
|
+
/** A look's `preview` angle (degrees), when it has a usable one. */
|
|
2091
|
+
function previewOf(r) {
|
|
2092
|
+
const p = record(r.preview);
|
|
2093
|
+
if (p === void 0) return {};
|
|
2094
|
+
const yaw = clamp(p.yaw, -360, 360), pitch = clamp(p.pitch, -10, 80);
|
|
2095
|
+
const out = {
|
|
2096
|
+
...yaw === void 0 ? {} : { yaw },
|
|
2097
|
+
...pitch === void 0 ? {} : { pitch }
|
|
2098
|
+
};
|
|
2099
|
+
return Object.keys(out).length === 0 ? {} : { preview: out };
|
|
2100
|
+
}
|
|
2101
|
+
/** Names the model chose (materials, bones, shape keys): any text, bounded. */
|
|
2102
|
+
const PART_NAME = /^[^\u0000-\u001f]{1,60}$/;
|
|
2103
|
+
/** A glTF look's named tweaks: tint, bones, morphs (each kept only when usable, at most 64 names). */
|
|
2104
|
+
function tweaksOf(r) {
|
|
2105
|
+
const entries = (raw) => Object.entries(record(raw) ?? {}).filter(([k]) => PART_NAME.test(k)).slice(0, 64);
|
|
2106
|
+
const tint = Object.fromEntries(entries(r.tint).filter(([, v]) => typeof v === "string" && HEX.test(v)).map(([k, v]) => [k, v.toLowerCase()]));
|
|
2107
|
+
const morphs = Object.fromEntries(entries(r.morphs).map(([k, v]) => [k, clamp(v, -1, 2)]).filter(([, v]) => v !== void 0));
|
|
2108
|
+
const bones = {};
|
|
2109
|
+
for (const [k, v] of entries(r.bones)) {
|
|
2110
|
+
const b = record(v);
|
|
2111
|
+
if (b === void 0) continue;
|
|
2112
|
+
const triple = (raw, min, max) => {
|
|
2113
|
+
if (!Array.isArray(raw) || raw.length !== 3) return void 0;
|
|
2114
|
+
const t = raw.map((x) => clamp(x, min, max));
|
|
2115
|
+
return t.every((x) => x !== void 0) ? t : void 0;
|
|
2116
|
+
};
|
|
2117
|
+
const scale = typeof b.scale === "number" ? clamp(b.scale, .1, 5) : triple(b.scale, .1, 5);
|
|
2118
|
+
const rotate = triple(b.rotate, -180, 180);
|
|
2119
|
+
if (scale !== void 0 || rotate !== void 0) bones[k] = {
|
|
2120
|
+
...scale === void 0 ? {} : { scale },
|
|
2121
|
+
...rotate === void 0 ? {} : { rotate }
|
|
2122
|
+
};
|
|
2123
|
+
}
|
|
2124
|
+
return {
|
|
2125
|
+
...Object.keys(tint).length === 0 ? {} : { tint },
|
|
2126
|
+
...Object.keys(bones).length === 0 ? {} : { bones },
|
|
2127
|
+
...Object.keys(morphs).length === 0 ? {} : { morphs }
|
|
2128
|
+
};
|
|
2129
|
+
}
|
|
2130
|
+
function gltf(r) {
|
|
2131
|
+
const src = typeof r.src === "string" ? r.src.trim().replace(/^\.\//, "") : "";
|
|
2132
|
+
if (!/\.(glb|gltf)$/i.test(src) || src.startsWith("/") || src.split("/").includes("..")) return null;
|
|
2133
|
+
const scale = clamp(r.scale, .05, 50);
|
|
2134
|
+
const recipe = record(r.recipe);
|
|
2135
|
+
const baked = recipe === void 0 ? null : parts3d(recipe);
|
|
2136
|
+
const material = r.material === "original" || r.material === "toon" ? r.material : void 0;
|
|
2137
|
+
return {
|
|
2138
|
+
kind: "gltf",
|
|
2139
|
+
src,
|
|
2140
|
+
...scale === void 0 ? {} : { scale },
|
|
2141
|
+
...material === void 0 ? {} : { material },
|
|
2142
|
+
...typeof r.outline === "boolean" ? { outline: r.outline } : {},
|
|
2143
|
+
...typeof r.animation === "string" && r.animation.trim() !== "" ? { animation: r.animation.trim().slice(0, 80) } : {},
|
|
2144
|
+
...previewOf(r),
|
|
2145
|
+
...tweaksOf(r),
|
|
2146
|
+
...baked === null ? {} : { recipe: baked }
|
|
2147
|
+
};
|
|
2148
|
+
}
|
|
2149
|
+
/**
|
|
2150
|
+
* Normalize a 3D look; null when it has no usable body.
|
|
2151
|
+
* @param raw - the element's `look` as written.
|
|
2152
|
+
*/
|
|
2153
|
+
function normalizeLook3d(raw) {
|
|
2154
|
+
const r = record(raw);
|
|
2155
|
+
if (r === void 0) return null;
|
|
2156
|
+
if (r.kind === "parts3d") return parts3d(r);
|
|
2157
|
+
if (r.kind === "gltf") return gltf(r);
|
|
2158
|
+
return null;
|
|
2159
|
+
}
|
|
2160
|
+
//#endregion
|
|
2161
|
+
//#region lib/types/kits/three3d.js
|
|
2162
|
+
/**
|
|
2163
|
+
* The `3d-three` kit (proposal 2026-10-21-game-3d §4): three.js plus the
|
|
2164
|
+
* package's own thin runtime `dsh3d` (parts-recipe models with an open
|
|
2165
|
+
* body/part set, style as data with two starting presets, a 45° camera, and
|
|
2166
|
+
* the workbench hooks pick / pause / highlight).
|
|
2167
|
+
*
|
|
2168
|
+
* Both modules ride every bundle under `_dsh/` and resolve through the
|
|
2169
|
+
* loader's bare-import map, so a game writes `import * as THREE from 'three'`
|
|
2170
|
+
* and `import { createWorld, build } from 'dsh3d'`. They are read from disk
|
|
2171
|
+
* once, on first use: three.js from the pinned `three` dependency, dsh3d from
|
|
2172
|
+
* this package's `runtime/` folder. A missing file degrades to a runtime that
|
|
2173
|
+
* reports why, so the smoke check names the problem instead of crashing.
|
|
2174
|
+
*
|
|
2175
|
+
* three's own add-ons (`three/addons/…`: GLTFLoader, EffectComposer, …) map
|
|
2176
|
+
* to `_dsh/addons/` too, but only the ones a project's code imports ride its
|
|
2177
|
+
* bundle, with whatever they import in turn: the whole add-on tree is far too
|
|
2178
|
+
* big to carry on every load.
|
|
2179
|
+
* @module @khorsheed/dsh-game
|
|
2180
|
+
*/
|
|
2181
|
+
const THREE_FILE = "_dsh/three.module.js";
|
|
2182
|
+
/** The 2D probe after one render: under the checks dsh3d's \`step\` advances logic only, so the canvas is drawn on demand here. */
|
|
2183
|
+
const BLANK_PROBE_3D = `(window.dsh3dWorld && window.dsh3dWorld.render(), ${BLANK_PROBE})`;
|
|
2184
|
+
const DSH3D_FILE = "_dsh/dsh3d.js";
|
|
2185
|
+
/** `runtime/dsh3d.js` beside the package root: one level up from `lib/`, two from `src/kits/`. */
|
|
2186
|
+
function dsh3dPath() {
|
|
2187
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
2188
|
+
return [join(here, "..", "runtime", "dsh3d.js"), join(here, "..", "..", "runtime", "dsh3d.js")].find((p) => existsSync(p));
|
|
2189
|
+
}
|
|
2190
|
+
/** The pinned three.js module build (the package's exports only expose the unminified one). */
|
|
2191
|
+
function threePath() {
|
|
2192
|
+
try {
|
|
2193
|
+
const entry = createRequire(import.meta.url).resolve("three");
|
|
2194
|
+
const min = join(dirname(entry), "three.module.min.js");
|
|
2195
|
+
return existsSync(min) ? min : void 0;
|
|
2196
|
+
} catch {
|
|
2197
|
+
return;
|
|
2198
|
+
}
|
|
2199
|
+
}
|
|
2200
|
+
let cached;
|
|
2201
|
+
function load() {
|
|
2202
|
+
if (cached !== void 0) return cached;
|
|
2203
|
+
const three = threePath(), dsh3d = dsh3dPath();
|
|
2204
|
+
if (three === void 0 || dsh3d === void 0) {
|
|
2205
|
+
const missing = [three === void 0 ? "three.js" : "", dsh3d === void 0 ? "dsh3d" : ""].filter(Boolean).join("、");
|
|
2206
|
+
return { [KIT_RUNTIME_FILE]: {
|
|
2207
|
+
type: "text/javascript",
|
|
2208
|
+
text: `console.error(${JSON.stringify(`3D 套件缺文件:${missing}(插件安装不完整)`)})`
|
|
2209
|
+
} };
|
|
2210
|
+
}
|
|
2211
|
+
cached = {
|
|
2212
|
+
[THREE_FILE]: {
|
|
2213
|
+
type: "text/javascript",
|
|
2214
|
+
text: readFileSync(three, "utf8")
|
|
2215
|
+
},
|
|
2216
|
+
[DSH3D_FILE]: {
|
|
2217
|
+
type: "text/javascript",
|
|
2218
|
+
text: readFileSync(dsh3d, "utf8")
|
|
2219
|
+
},
|
|
2220
|
+
...addonFiles({ "dsh3d.js": {
|
|
2221
|
+
type: "text/javascript",
|
|
2222
|
+
text: "import 'three/addons/loaders/GLTFLoader.js'"
|
|
2223
|
+
} }),
|
|
2224
|
+
[KIT_IMPORTS_FILE]: {
|
|
2225
|
+
type: "application/json",
|
|
2226
|
+
text: JSON.stringify({
|
|
2227
|
+
three: THREE_FILE,
|
|
2228
|
+
dsh3d: DSH3D_FILE,
|
|
2229
|
+
"three/addons/": ADDONS_DIR
|
|
2230
|
+
})
|
|
2231
|
+
}
|
|
2232
|
+
};
|
|
2233
|
+
return cached;
|
|
2234
|
+
}
|
|
2235
|
+
const ADDONS_DIR = "_dsh/addons/";
|
|
2236
|
+
/** An add-on set larger than this is cut off (the loader then reports the missing module). */
|
|
2237
|
+
const ADDONS_MAX_BYTES = 6291456;
|
|
2238
|
+
/** three's `examples/jsm` folder (what `three/addons/` names). */
|
|
2239
|
+
function addonsRoot() {
|
|
2240
|
+
try {
|
|
2241
|
+
const dir = join(dirname(createRequire(import.meta.url).resolve("three")), "..", "examples", "jsm");
|
|
2242
|
+
return existsSync(dir) ? dir : void 0;
|
|
2243
|
+
} catch {
|
|
2244
|
+
return;
|
|
2245
|
+
}
|
|
2246
|
+
}
|
|
2247
|
+
const SPECIFIER = /(?:\bimport\s*\(\s*|\bfrom\s*|\bimport\s+)(['"])([^'"]+)\1/g;
|
|
2248
|
+
/**
|
|
2249
|
+
* The add-on files a project's scripts import (`three/addons/x`), plus the
|
|
2250
|
+
* add-on files those import relatively, as bundle entries under `_dsh/addons/`.
|
|
2251
|
+
* @param project - the project's bundle files.
|
|
2252
|
+
* @returns the entries to add to the bundle.
|
|
2253
|
+
*/
|
|
2254
|
+
function addonFiles(project) {
|
|
2255
|
+
const root = addonsRoot();
|
|
2256
|
+
if (root === void 0) return {};
|
|
2257
|
+
const queue = [];
|
|
2258
|
+
for (const [path, file] of Object.entries(project)) {
|
|
2259
|
+
if (!/\.m?js$/.test(path) || !("text" in file)) continue;
|
|
2260
|
+
for (const m of file.text.matchAll(SPECIFIER)) if (m[2].startsWith("three/addons/")) queue.push(m[2].slice(13));
|
|
2261
|
+
}
|
|
2262
|
+
const out = {};
|
|
2263
|
+
let total = 0;
|
|
2264
|
+
while (queue.length > 0) {
|
|
2265
|
+
const rel = posix.normalize(queue.shift());
|
|
2266
|
+
if (rel.startsWith("..") || rel.startsWith("/") || out[ADDONS_DIR + rel] !== void 0) continue;
|
|
2267
|
+
const full = normalize(join(root, rel));
|
|
2268
|
+
if (!full.startsWith(root) || !existsSync(full)) continue;
|
|
2269
|
+
const text = readFileSync(full, "utf8");
|
|
2270
|
+
if (total + text.length > ADDONS_MAX_BYTES) break;
|
|
2271
|
+
total += text.length;
|
|
2272
|
+
out[ADDONS_DIR + rel] = {
|
|
2273
|
+
type: "text/javascript",
|
|
2274
|
+
text
|
|
2275
|
+
};
|
|
2276
|
+
for (const m of text.matchAll(SPECIFIER)) {
|
|
2277
|
+
const spec = m[2];
|
|
2278
|
+
if (spec.startsWith("./") || spec.startsWith("../")) queue.push(posix.join(posix.dirname(rel), spec));
|
|
2279
|
+
}
|
|
2280
|
+
}
|
|
2281
|
+
return out;
|
|
2282
|
+
}
|
|
2283
|
+
//#endregion
|
|
2284
|
+
//#region lib/types/kits/index.js
|
|
2285
|
+
/** Every shipped kit, by id. */
|
|
2286
|
+
const KITS = {
|
|
2287
|
+
"2d-canvas": CANVAS_2D,
|
|
2288
|
+
"3d-three": {
|
|
2289
|
+
id: "3d-three",
|
|
2290
|
+
version: 1,
|
|
2291
|
+
label: "3D(three.js)",
|
|
2292
|
+
runtimeFiles: (project) => project === void 0 ? load() : {
|
|
2293
|
+
...load(),
|
|
2294
|
+
...addonFiles(project)
|
|
2295
|
+
},
|
|
2296
|
+
normalizeLook: normalizeLook3d,
|
|
2297
|
+
lookHint: LOOK3D_HINT,
|
|
2298
|
+
blankProbe: BLANK_PROBE_3D,
|
|
2299
|
+
styles: ART_STYLES_3D,
|
|
2300
|
+
skillSection: `## 运行环境 · 3D 套件(game.json 的 runtime.kit 是 3d-three)
|
|
2301
|
+
- three.js 0.170 已内置,再加插件的薄运行时 dsh3d。代码里直接写 \`import * as THREE from 'three'\` 和 \`import { createWorld, build, defineBase, definePart, ink, toon, flat } from 'dsh3d'\`(不要写路径、不要引 CDN)。
|
|
2302
|
+
- 题材、世界、角色全由用户的描述决定;插件只给机制和几个起点,别往用户没提的题材上靠。
|
|
2303
|
+
- **风格是数据**:写在 game.json 的 \`style3d\`,按用户描述的画风生成,createWorld 自动读取:
|
|
2304
|
+
\`{ "preset": "ink", "bg": "#efe7d6", "ground": "#e5dbc5", "ground2": "#d6c9ac", "water": "#a7bab4", "outline": "#2a2622", "outlineWidth": 0.032, "shades": 3, "fog": [26, 62], "light": 1.1 }\`
|
|
2305
|
+
preset 是起点(下面两个),其余键覆盖它;outlineWidth 为 0 就没有描边,shades 是卡通明暗层数(2 硬朗 … 5 柔和)。用户说「霓虹赛博」「糖果色」「黑白剪影」之类,就据此调颜色、描边和层数;artStyle 写 custom:用户的描述。现成起点:
|
|
2306
|
+
${ART_STYLES_3D.map((s) => `- \`${s.id}\` ${s.label}:${s.look}`).join("\n")}
|
|
2307
|
+
自己加的物体用 \`ink(geometry, toon('#颜色'))\`(卡通材质 + 描边),地面起伏、石头、树这类场景用 \`flat('#颜色')\`,这样换 style3d 时全场跟着变。别用写实贴图和 PBR 材质。
|
|
2308
|
+
- 世界:\`const world = createWorld({ region: (x, z) => '区域名' })\`,然后
|
|
2309
|
+
- \`world.terrain({ size, height: (x, z) => 高度 })\` 地形;\`world.place(obj, x, z)\` 放到地面上
|
|
2310
|
+
- **能站上去的模型**(楼梯、平台、桥、屋顶):\`world.add(obj, { walkable: true })\` 或 \`world.walkable(obj)\`。角色移动时用 \`world.groundAt(x, z, 脚底高度 + 台阶高)\` 取落脚高度:只认这个高度以下最高的面,所以能一级级爬楼梯,不会被吸到头顶的屋顶上。要挡墙就再问一次不设上限的 \`groundAt(x, z)\`,比脚底高出一个台阶以上就别走过去。它只管「站在哪」,不管撞墙、滑坡。
|
|
2311
|
+
- \`world.add(obj, { id: 元素id, inst: 实例id, name, state: () => '当前在干什么' })\` 登记实体(右栏「指一下」靠它认出东西)
|
|
2312
|
+
- \`world.scatter(元素id, 数量或位置列表, { seed, spread, center, scale: [0.85, 1.15] })\` 把一种元素批量撒在地面上:外观只生成一次、批量绘制,每棵按地形高度落地、随机朝向和大小(同一个 seed 每次摆得一样);点到任意一棵,「指一下」认出是哪个元素、第几棵。树林、石滩、草地都用它,别自己循环 clone。加 \`sway: { amplitude: 0.06, period: 2.4 }\` 让草木随风摆(越高处摆得越大,每棵错开;影子不跟着摆);除此之外撒出来的是静物,没有逐棵动效。
|
|
2313
|
+
- **多个场景**(地图上的几座山、室内外):\`world.enterScene('场景名', w => { w.terrain(…); w.scatter(…); w.add(…); w.onUpdate(…) })\`。切到下一个场景时,上一个场景里登记的地形、实体、批量物件、onUpdate 全部移除并释放;在任何场景之外登记的(玩家、镜头跟随)保留。各场景的底色、地面颜色、水色、雾、光照写在 game.json 的 \`style3d.scenes\`:\`{ "北坡": { "bg": "#cfd6dc", "ground": "#d8dcd6", "fog": [12, 40], "light": 0.8 } }\`(名字由游戏自己定;明暗层数和描边全局共用,不能按场景换)。别在同一个场景里堆所有区域,也别手动去删 scene 里的东西。
|
|
2314
|
+
- \`world.follow(玩家)\` 镜头跟随(俯视斜 45° 正交镜头);\`world.onUpdate((dt, t) => {...})\` 写游戏逻辑
|
|
2315
|
+
- \`world.step(dt)\` 推进一帧(dshGame.step 就调它),\`world.start()\` 开始实时播放
|
|
2316
|
+
- \`world.pick(x, y)\`、\`world.pause(on)\`、\`world.highlight(list)\` 是给面板用的,dshGame 里转发即可
|
|
2317
|
+
- 外观 look(3D):部件配方,\`build(元素id或look)\` 拼出模型并自带程序动效。例子(一只长尾猫):
|
|
2318
|
+
\`{ "kind": "parts3d", "base": "quadruped", "proportions": { "bodyLength": 0.9, "bodyHeight": 0.4, "legLength": 0.3, "headSize": 0.34, "snout": 0.1 }, "parts": [{ "type": "ear", "count": 2 }, { "type": "tail", "count": 1, "length": 0.9, "thickness": 0.06 }], "palette": { "body": "#f2a65a", "accent": "#7a4a2a", "eye": "#1d1d1f" }, "motion": { "tailSway": { "amplitude": 20, "period": 1.4 }, "breath": { "amplitude": 0.02, "period": 2.4 } } }\`
|
|
2319
|
+
- 内置身体 base:${BODY_BASES.join(" / ")}(四足、禽、鱼、蛇形、人形)。比例:bodyLength、bodyHeight、legLength、headSize、snout、legs(足数)、heads(头数);禽另有 beak;蛇形是 length、thickness、segments;人形是 height。
|
|
2320
|
+
- 内置部件 parts:ear、horn、tail(count/length/fan/thickness)、wing(count/size)、fin、eye(count)、face(style: "human")。color 写 palette 的键或 #rrggbb。
|
|
2321
|
+
- palette 的键:body、accent、eye,另可 belly、horn、wing、beak、face、skin。motion:tailSway、breath、flap、swim、bob(amplitude 角度,breath/bob 是比例;period 秒)。
|
|
2322
|
+
- **内置的不够就自己注册**(机器人、史莱姆、车、建筑……)。注册代码一律放 \`src/looks.js\`(入口先 \`import './looks.js'\` 再 build)——右栏的外观预览只加载这个文件,写在别处面板就拼不出来:
|
|
2323
|
+
\`defineBase('robot', (look, kit) => { const g = new kit.THREE.Group(), body = new kit.THREE.Group(); g.add(body); body.add(kit.ink(new kit.THREE.BoxGeometry(0.8, 0.6, 0.5), kit.toon(kit.color('body', '#888888')))); return { g, body } })\`
|
|
2324
|
+
\`definePart('antenna', ({ spec, anchors, kit }) => { const a = kit.ink(new kit.THREE.CylinderGeometry(0.02, 0.02, spec.length ?? 0.4), kit.toon(kit.color(spec.color ?? 'accent', '#ff0000'))); a.position.y = 0.5; anchors.body.add(a); return { update: t => { a.rotation.z = 0.2 * Math.sin(t * 3) } } })\`
|
|
2325
|
+
之后配方里就能写 \`"base": "robot"\`、\`{ "type": "antenna", "length": 0.5 }\`(自定义部件的其他参数在 spec.extra 里)。注册过的名字用户在面板里同样能调。
|
|
2326
|
+
- **场景物件也走配方**:树、石头、草丛、远山、建筑这类有形状的元素,同样在 \`src/looks.js\` 里 defineBase 注册造型(用 \`kit.flat(kit.color('body', '#…'))\`,尺寸读 \`look.proportions\`),元素文件写 \`look\`,成片的用 \`world.scatter(元素id, …)\` 撒开、单个的 \`build(元素id)\`(数量、分布范围这类仍是元素字段)。别在 main.js 里直接手写几何体——那样右栏看不到它的样子,用户也没法调。只有地面高度场(world.terrain)和水面这种铺满整个场景的不算元素。
|
|
2327
|
+
- 面数预算:单个元素 1 万三角面以内,同屏一个场景 15 万以内(\`world.stats()\` 可查,批量物件按份数算)。这是建议不是硬限制,超了先看帧率。
|
|
2328
|
+
- 贴图与素材:图片放 \`assets/\`,\`new THREE.TextureLoader().load('assets/xx.png')\` 就能用(贴到 toon/flat 材质的 map 上);纸纹、墨晕这类也可以用 canvas 现画,再 \`new THREE.CanvasTexture(canvas)\`,不用另存文件。
|
|
2329
|
+
- **模型文件做外观(glTF)**:Blender 做的角色、建筑导出成 .glb 放进 \`assets/\`,元素文件写 \`"look": { "kind": "gltf", "src": "assets/yumin.glb", "scale": 1, "material": "toon" }\`。代码里照样 \`build('yumin')\`:它立刻返回,模型加载完自己出现(要等就 \`await model.userData.loaded\` 或 \`await loaded()\`)。\`material\` 取 \`toon\`(默认:套当前风格的卡通明暗和描边,保留模型的颜色、顶点色和贴图)或 \`original\`(模型自己的材质,默认不描边);\`outline: true/false\` 单独开关描边。右栏能预览、切换材质、调大小。导出建议:Y 轴朝上、单位米、原点在脚底、**脸朝 +X**(插件的约定,内置的异兽也是;在 Blender 里就是让角色面向 +X 轴,glTF 默认的 +Z 朝向要转过来)、贴图打包进 .glb、单个模型 1 万面以内。右栏预览按这个约定拍正面偏 3/4;某个元素想换默认角度,写 \`"preview": { "yaw": 35, "pitch": 18 }\`(度数:yaw 0 正面、90 侧面、180 背面,pitch 往下看的角度),配方外观也能写。检查会等模型加载完再截图、跑测试;外观引用的文件不在项目里,检查会拦下。用现成的 CC0 素材时,在 \`assets/LICENSES.md\` 记下来源和授权。
|
|
2330
|
+
- 骨骼动画:Blender 里每个动作做成一个 Action(名字就是动画名,如 idle、walk),导出时勾选动画。模型加载后自动循环播放 \`look.animation\` 指定的那个,没写就挑名字带 idle / 待机 / stand 的,再没有就第一个。代码里 \`model.userData.play('walk')\` 切换(默认 0.2 秒过渡;\`{ loop: false }\` 只播一遍),\`model.userData.animations\` 是全部动画名(加载完才有)。动画跟着 world 的时钟走:模型要 \`world.add\` 登记(或者自己每帧调 \`model.userData.update(world.time)\`),测试里 \`step\` 照样推进动画。描边跟着骨骼动;「指一下」能点中摆着姿势的角色。右栏转盘下面列出全部动画,点一下就播。
|
|
2331
|
+
- 按名字微调(游戏和右栏一致,都由 build 生效):\`"tint": { "冠": "#c9a23a" }\` 按材质名换基础色(材质用了顶点色时是相乘,会偏暗);\`"bones": { "head": { "scale": 1.2, "rotate": [0, 10, 0] } }\` 按骨头名缩放(一个数或 [x,y,z],子骨头跟着变)、加旋转(度),叠在动画之上,播动作也不会被冲掉;\`"morphs": { "smile": 0.6 }\` 按形态键名设权重,描边跟着变形。所以 Blender 里材质、骨头、形态键要起有意义的名字(羽_青、冠、head、wing.L、smile……)。右栏「细调外观」读出模型里的这些名字,列成色块和滑块(带 DEF-/MCH-/ORG- 前缀的绑定辅助骨默认不列,可以搜)。代码里加载后改了的东西右栏看不到,能写进外观的就写进外观。
|
|
2332
|
+
- three 自带的扩展直接引:\`import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js'\`,后期处理 \`three/addons/postprocessing/EffectComposer.js\` 等都行(只打包你引用到的文件)。
|
|
2333
|
+
- 镜头:默认是 45° 俯视的正交镜头。第三人称跟随直接用现成的:\`const rig = world.followCamera({ target: player, distance: 7, height: 1.4, mouse: true })\`(\`mouse: true\` 点画面锁定鼠标、移动鼠标转视角,Esc 退出),\`rig.yaw\` / \`rig.pitch\` / \`rig.distance\` 随时可改,dshGame 上写 \`look: (dx, dy) => rig.look(dx, dy)\`,测试里用 \`drag\` 模拟;角色朝镜头前方走时用 \`rig.yaw\` 换算方向。要别的镜头就自己建 \`new THREE.PerspectiveCamera(…)\`,\`world.setCamera(cam)\`,然后每帧(onUpdate 里)自己摆位置、朝向;「指一下」、高亮圈都跟着这台镜头走,\`world.setCamera(null)\` 换回默认。鼠标转视角可以在点击里 \`canvas.requestPointerLock()\`(Esc 退出),并在 dshGame 上提供 \`look(dx, dy)\`,测试里用 \`drag(dx, dy)\` 模拟拖动。
|
|
2334
|
+
- 光与投影:\`world.lights.hemi\`(天光)、\`world.lights.sun\`(太阳)可以直接调;style3d 里写 \`"shadows": true\` 开太阳投影(之后 world.add / scatter 进来的东西都投影、受影);\`"rim": 0.6\`(0–2)加轮廓光,角色边缘亮一圈、从地面上「浮」出来,\`"rimColor"\` 换颜色。
|
|
2335
|
+
- 后期处理(泛光、调色):\`world.onRender(({ renderer, scene, camera }) => composer.render())\` 接管绘制,返回的函数交还。泛光(狐火、灯笼)这样接:\`import { EffectComposer } from 'three/addons/postprocessing/EffectComposer.js'\`、\`RenderPass\`、\`UnrealBloomPass\`、\`OutputPass\`(同目录),建 composer 依次 addPass(new RenderPass(world.scene, world.camera))、addPass(new UnrealBloomPass(new THREE.Vector2(w, h), 强度 0.6, 半径 0.4, 阈值 0.85))、addPass(new OutputPass()),然后 onRender 里 \`composer.render()\`;发光的东西用 \`toon(颜色, { own: true, emissive: 颜色 })\`。换镜头后记得把 RenderPass 的 camera 一起换。
|
|
2336
|
+
- 材质:toon / flat 按颜色共用一份。要让某一个东西单独发光、变色、淡出:\`toon('#ff8a3d', { own: true, emissive: '#ff8a3d' })\` 拿一份自己的。
|
|
2337
|
+
- 加载资源时(glb、大贴图)先挂 \`window.dshGame = { ready: false, loading: true, … }\`,加载完再设 \`ready = true\`:检查会等最多 20 秒(不写 loading 只等 4 秒)。
|
|
2338
|
+
- 检查里(\`window.__GAMEDEV_TEST__\`)\`step\` 推进逻辑和镜头但不画画面,测试可以放心多跑帧;要读像素时先 \`world.render()\`。
|
|
2339
|
+
- dshGame 约定照旧,3D 推荐这样接:
|
|
2340
|
+
\`\`\`js
|
|
2341
|
+
window.dshGame = {
|
|
2342
|
+
ready: true,
|
|
2343
|
+
step: dt => world.step(dt),
|
|
2344
|
+
input(key, down) { /* 记到按键状态里,onUpdate 里读 */ },
|
|
2345
|
+
state: () => ({ player: { x: player.position.x, z: player.position.z } }),
|
|
2346
|
+
pick: (x, y) => world.pick(x, y),
|
|
2347
|
+
pause: on => world.pause(on),
|
|
2348
|
+
highlight: list => world.highlight(list),
|
|
2349
|
+
}
|
|
2350
|
+
world.start()
|
|
2351
|
+
\`\`\`
|
|
2352
|
+
`
|
|
2353
|
+
}
|
|
2354
|
+
};
|
|
2355
|
+
/** The kit a project without a `runtime` field runs on. */
|
|
2356
|
+
const DEFAULT_KIT = CANVAS_2D;
|
|
2357
|
+
/**
|
|
2358
|
+
* The kit a project runs on.
|
|
2359
|
+
* @param ref - the manifest's `runtime` (absent on projects made before kits).
|
|
2360
|
+
* @returns the kit; unknown ids fall back to the default.
|
|
2361
|
+
*/
|
|
2362
|
+
function kitOf(ref) {
|
|
2363
|
+
return ref !== void 0 && isKitId(ref.kit) ? KITS[ref.kit] : DEFAULT_KIT;
|
|
2364
|
+
}
|
|
2365
|
+
/**
|
|
2366
|
+
* A bundle with the kit's runtime files added. The project's own files win
|
|
2367
|
+
* on a path clash (a project should never write under `_dsh/`, but if it
|
|
2368
|
+
* does, its file is what it meant to ship).
|
|
2369
|
+
*/
|
|
2370
|
+
function withKitRuntime(files, kit) {
|
|
2371
|
+
return {
|
|
2372
|
+
...kit.runtimeFiles(files),
|
|
2373
|
+
...files
|
|
2374
|
+
};
|
|
2375
|
+
}
|
|
2376
|
+
//#endregion
|
|
2377
|
+
//#region lib/types/trash.js
|
|
2378
|
+
/**
|
|
2379
|
+
* Moving a deleted project folder to the system trash, so a delete from the
|
|
2380
|
+
* panel is complete (gone from the workspace and the plugin) yet recoverable.
|
|
2381
|
+
*
|
|
2382
|
+
* - macOS: a rename into `~/.Trash`, de-duplicated with a timestamp suffix.
|
|
2383
|
+
* - Linux: the freedesktop trash (`$XDG_DATA_HOME/Trash`, default
|
|
2384
|
+
* `~/.local/share/Trash`): the folder goes to `files/`, a `.trashinfo`
|
|
2385
|
+
* record to `info/` so the file manager can restore it to where it was.
|
|
2386
|
+
* - Elsewhere, or across volumes (a rename cannot cross devices), the move is
|
|
2387
|
+
* refused and nothing is touched: the panel then offers "remove from the
|
|
2388
|
+
* list only". A project folder is never deleted outright.
|
|
2389
|
+
* @module @khorsheed/dsh-game
|
|
2390
|
+
*/
|
|
2391
|
+
/** A free name inside `dir` for `name`, suffixed with the time when taken. */
|
|
2392
|
+
async function freeName(dir, name) {
|
|
2393
|
+
const taken = async (candidate) => stat(join(dir, candidate)).then(() => true, () => false);
|
|
2394
|
+
if (!await taken(name)) return name;
|
|
2395
|
+
const stamp = (/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-");
|
|
2396
|
+
for (let n = 0;; n++) {
|
|
2397
|
+
const candidate = `${name} ${stamp}${n === 0 ? "" : `-${n}`}`;
|
|
2398
|
+
if (!await taken(candidate)) return candidate;
|
|
2399
|
+
}
|
|
2400
|
+
}
|
|
2401
|
+
/** Rename, turning a cross-device failure into the refusal the panel explains. */
|
|
2402
|
+
async function move(from, to) {
|
|
2403
|
+
try {
|
|
2404
|
+
await rename(from, to);
|
|
2405
|
+
} catch (error) {
|
|
2406
|
+
if (error.code === "EXDEV") throw new Error("项目和废纸篓不在同一块磁盘上,没法移过去");
|
|
2407
|
+
throw error;
|
|
2408
|
+
}
|
|
2409
|
+
}
|
|
2410
|
+
/** The system trash mover for this platform (`home` is a test seam). */
|
|
2411
|
+
function systemTrash(home = homedir(), platform = process.platform) {
|
|
2412
|
+
if (platform === "darwin") return async (folder) => {
|
|
2413
|
+
const dir = join(home, ".Trash");
|
|
2414
|
+
await mkdir(dir, { recursive: true });
|
|
2415
|
+
const target = join(dir, await freeName(dir, basename(folder)));
|
|
2416
|
+
await move(folder, target);
|
|
2417
|
+
return target;
|
|
2418
|
+
};
|
|
2419
|
+
if (platform === "linux") return async (folder) => {
|
|
2420
|
+
const root = join(process.env.XDG_DATA_HOME || join(home, ".local", "share"), "Trash");
|
|
2421
|
+
await mkdir(join(root, "files"), { recursive: true });
|
|
2422
|
+
await mkdir(join(root, "info"), { recursive: true });
|
|
2423
|
+
const name = await freeName(join(root, "files"), basename(folder));
|
|
2424
|
+
const date = (/* @__PURE__ */ new Date()).toISOString().slice(0, 19);
|
|
2425
|
+
await writeFile(join(root, "info", `${name}.trashinfo`), `[Trash Info]\nPath=${encodeURI(folder)}\nDeletionDate=${date}\n`);
|
|
2426
|
+
await move(folder, join(root, "files", name));
|
|
2427
|
+
return join(root, "files", name);
|
|
2428
|
+
};
|
|
2429
|
+
return async () => {
|
|
2430
|
+
throw new Error("这个系统上插件不会动项目文件夹");
|
|
2431
|
+
};
|
|
2432
|
+
}
|
|
2433
|
+
//#endregion
|
|
2434
|
+
//#region lib/types/fsutil.js
|
|
2435
|
+
/**
|
|
2436
|
+
* File helpers the game service builds on: atomic writes, tolerant reads, the
|
|
2437
|
+
* bounded project walk, and the sandbox bundle form of walked files.
|
|
2438
|
+
* @module @khorsheed/dsh-game
|
|
2439
|
+
*/
|
|
2440
|
+
/** Walk limits for one project read: enough for a small game, bounded for a stray huge folder. */
|
|
2441
|
+
const MAX_FILES = 600;
|
|
2442
|
+
const MAX_FILE_BYTES = 8388608;
|
|
2443
|
+
const MAX_TOTAL_BYTES = 50331648;
|
|
2444
|
+
/** Whether `child` is `parent` or inside it. */
|
|
2445
|
+
function inside(parent, child) {
|
|
2446
|
+
const rel = relative(parent, child);
|
|
2447
|
+
return rel === "" || !rel.startsWith("..") && !isAbsolute(rel);
|
|
2448
|
+
}
|
|
2449
|
+
/** Write a file atomically (tmp + rename), creating its directory. */
|
|
2450
|
+
async function writeAtomic(path, data) {
|
|
2451
|
+
await mkdir(dirname(path), { recursive: true });
|
|
2452
|
+
const tmp = `${path}.${randomBytes(4).toString("hex")}.tmp`;
|
|
2453
|
+
await writeFile(tmp, data);
|
|
2454
|
+
await rename(tmp, path);
|
|
2455
|
+
}
|
|
2456
|
+
/** Read a text file, undefined when absent or unreadable. */
|
|
2457
|
+
async function readText(path) {
|
|
2458
|
+
try {
|
|
2459
|
+
return await readFile(path, "utf8");
|
|
2460
|
+
} catch {
|
|
2461
|
+
return;
|
|
2462
|
+
}
|
|
2463
|
+
}
|
|
2464
|
+
/** Read JSON, undefined when absent or broken. */
|
|
2465
|
+
async function readJson(path) {
|
|
2466
|
+
const text = await readText(path);
|
|
2467
|
+
if (text === void 0) return void 0;
|
|
2468
|
+
try {
|
|
2469
|
+
return JSON.parse(text);
|
|
2470
|
+
} catch {
|
|
2471
|
+
return;
|
|
2472
|
+
}
|
|
2473
|
+
}
|
|
2474
|
+
/**
|
|
2475
|
+
* Walk a directory (dot-entries skipped), bounded by the walk limits.
|
|
2476
|
+
* @param root - directory to walk.
|
|
2477
|
+
* @param keep - project-relative path filter.
|
|
2478
|
+
* @returns the files, sorted by path.
|
|
2479
|
+
*/
|
|
2480
|
+
async function walk(root, keep) {
|
|
2481
|
+
const out = [];
|
|
2482
|
+
let total = 0;
|
|
2483
|
+
const visit = async (dir) => {
|
|
2484
|
+
let entries;
|
|
2485
|
+
try {
|
|
2486
|
+
entries = await readdir(dir, { withFileTypes: true });
|
|
2487
|
+
} catch {
|
|
2488
|
+
return;
|
|
2489
|
+
}
|
|
2490
|
+
for (const entry of entries.sort((a, b) => a.name.localeCompare(b.name))) {
|
|
2491
|
+
if (out.length >= MAX_FILES || entry.name.startsWith(".") || entry.name === "node_modules") continue;
|
|
2492
|
+
const full = join(dir, entry.name);
|
|
2493
|
+
const rel = relative(root, full).split(sep).join("/");
|
|
2494
|
+
if (entry.isDirectory()) {
|
|
2495
|
+
await visit(full);
|
|
2496
|
+
continue;
|
|
2497
|
+
}
|
|
2498
|
+
if (!entry.isFile() || !keep(rel)) continue;
|
|
2499
|
+
const info = await stat(full).catch(() => void 0);
|
|
2500
|
+
if (info === void 0 || info.size > MAX_FILE_BYTES || total + info.size > MAX_TOTAL_BYTES) continue;
|
|
2501
|
+
total += info.size;
|
|
2502
|
+
out.push({
|
|
2503
|
+
path: rel,
|
|
2504
|
+
data: await readFile(full)
|
|
2505
|
+
});
|
|
2506
|
+
}
|
|
2507
|
+
};
|
|
2508
|
+
await visit(root);
|
|
2509
|
+
return out;
|
|
2510
|
+
}
|
|
2511
|
+
/** The sandbox bundle form of walked files. */
|
|
2512
|
+
function bundleOf(files) {
|
|
2513
|
+
const out = {};
|
|
2514
|
+
for (const f of files) out[f.path] = isTextFile(f.path) ? {
|
|
2515
|
+
type: mimeOf(f.path),
|
|
2516
|
+
text: f.data.toString("utf8")
|
|
2517
|
+
} : {
|
|
2518
|
+
type: mimeOf(f.path),
|
|
2519
|
+
b64: f.data.toString("base64")
|
|
2520
|
+
};
|
|
2521
|
+
return out;
|
|
2522
|
+
}
|
|
2523
|
+
/** A content fingerprint over (path, bytes) pairs. */
|
|
2524
|
+
function fingerprintOf(files) {
|
|
2525
|
+
const hash = createHash("sha1");
|
|
2526
|
+
for (const f of files) {
|
|
2527
|
+
hash.update(f.path);
|
|
2528
|
+
hash.update("\0");
|
|
2529
|
+
hash.update(createHash("sha1").update(f.data).digest());
|
|
2530
|
+
}
|
|
2531
|
+
return hash.digest("hex").slice(0, 16);
|
|
2532
|
+
}
|
|
2533
|
+
//#endregion
|
|
2534
|
+
//#region lib/types/store.js
|
|
2535
|
+
/**
|
|
2536
|
+
* The game service core (`ctx.gameStore`): the project registry, session
|
|
2537
|
+
* binding, reading a project for the tools and the panel, the end-of-turn
|
|
2538
|
+
* check flow, versions and the playtest log.
|
|
2539
|
+
*
|
|
2540
|
+
* Storage (proposal §3): a project is a plain directory in the session's
|
|
2541
|
+
* workspace — `game.json`, `index.html`, `src/`, `assets/`, `elements/`,
|
|
2542
|
+
* `design/`, `tests/` — and the plugin's own state lives inside it under
|
|
2543
|
+
* `.game/` (versions, the last check, the playtest log), so the project moves
|
|
2544
|
+
* as one folder. Only the registry (which projects exist, which session is
|
|
2545
|
+
* bound to which) is deployment-level, at `$DSH_HOME/state/game/`.
|
|
2546
|
+
*
|
|
2547
|
+
* The agent writes project files with the ordinary fs tools; the plugin
|
|
2548
|
+
* writes only the manifest and the first chapter at creation (through the
|
|
2549
|
+
* mounted `ctx.fs` with the calling session's policy, so the fence applies)
|
|
2550
|
+
* and its own `.game/` state. Version snapshots copy binary assets, which
|
|
2551
|
+
* `ctx.fs` cannot write, so they go through node behind the same checks the
|
|
2552
|
+
* canvas uses: the session's mode is not read-only and the target sits inside
|
|
2553
|
+
* the project.
|
|
2554
|
+
* @module @khorsheed/dsh-game
|
|
2555
|
+
*/
|
|
2556
|
+
/** Versions kept under `.game/versions/`; older ones are pruned. */
|
|
2557
|
+
const KEEP_VERSIONS = 10;
|
|
2558
|
+
/**
|
|
2559
|
+
* Resolve the registry root: explicit value, then `$DSH_HOME/state/game`,
|
|
2560
|
+
* then `<cwd>/.dsh-game` (the canvas precedent).
|
|
2561
|
+
*/
|
|
2562
|
+
function resolveGameStateRoot(configured) {
|
|
2563
|
+
if (configured !== void 0 && configured !== "") return configured;
|
|
2564
|
+
const home = process.env.DSH_HOME;
|
|
2565
|
+
if (home !== void 0 && home !== "") return join(home, "state", "game");
|
|
2566
|
+
return join(process.cwd(), ".dsh-game");
|
|
2567
|
+
}
|
|
2568
|
+
/** Format a number the way the dock shows it (no float noise). */
|
|
2569
|
+
function num(value) {
|
|
2570
|
+
return String(Math.round(value * 1e3) / 1e3);
|
|
2571
|
+
}
|
|
2572
|
+
/** A change this recent means someone is still writing (a CLI member gives no other sign). */
|
|
2573
|
+
const BUSY_QUIET_MS = 2e4;
|
|
2574
|
+
/** Build-file paths under a folder with their modification times (no contents read). */
|
|
2575
|
+
async function statWalk(root, keep) {
|
|
2576
|
+
const out = [];
|
|
2577
|
+
const visit = async (dir) => {
|
|
2578
|
+
const entries = await readdir(dir, { withFileTypes: true }).catch(() => []);
|
|
2579
|
+
for (const entry of entries) {
|
|
2580
|
+
if (out.length >= 600 || entry.name.startsWith(".") || entry.name === "node_modules") continue;
|
|
2581
|
+
const full = join(dir, entry.name);
|
|
2582
|
+
const rel = relative(root, full).split(sep).join("/");
|
|
2583
|
+
if (entry.isDirectory()) {
|
|
2584
|
+
await visit(full);
|
|
2585
|
+
continue;
|
|
2586
|
+
}
|
|
2587
|
+
if (!entry.isFile() || !keep(rel)) continue;
|
|
2588
|
+
const info = await stat(full).catch(() => void 0);
|
|
2589
|
+
if (info !== void 0) out.push({
|
|
2590
|
+
path: rel,
|
|
2591
|
+
mtimeMs: info.mtimeMs
|
|
2592
|
+
});
|
|
2593
|
+
}
|
|
2594
|
+
};
|
|
2595
|
+
await visit(root);
|
|
2596
|
+
return out;
|
|
2597
|
+
}
|
|
2598
|
+
/** The game service core. */
|
|
2599
|
+
var GameService = class {
|
|
2600
|
+
ctx;
|
|
2601
|
+
static inject = ["fs"];
|
|
2602
|
+
stateRoot;
|
|
2603
|
+
sandboxPolicy;
|
|
2604
|
+
checker;
|
|
2605
|
+
/** Serializes registry read-modify-write cycles. */
|
|
2606
|
+
registryLock = Promise.resolve();
|
|
2607
|
+
listeners = /* @__PURE__ */ new Set();
|
|
2608
|
+
/**
|
|
2609
|
+
* @param ctx - host context (the mounted `ctx.fs`; `sandboxPolicy` probed).
|
|
2610
|
+
* @param config - plugin config.
|
|
2611
|
+
* @param deps - seams the specs replace.
|
|
2612
|
+
*/
|
|
2613
|
+
constructor(ctx, config = {}, deps = {}) {
|
|
2614
|
+
this.ctx = ctx;
|
|
2615
|
+
this.stateRoot = resolveGameStateRoot(config.stateRoot);
|
|
2616
|
+
this.sandboxPolicy = ctx.fs?.sandboxMode === void 0 ? void 0 : ctx.get("sandboxPolicy");
|
|
2617
|
+
this.checker = deps.checker ?? new ChromeChecker(config.chromePath);
|
|
2618
|
+
if (deps.writer !== void 0) this.writeFenced = deps.writer;
|
|
2619
|
+
this.trash = deps.trash ?? systemTrash();
|
|
2620
|
+
}
|
|
2621
|
+
/** Where a deleted project folder goes (the system trash; specs swap a fake). */
|
|
2622
|
+
trash;
|
|
2623
|
+
/** Release the checker's browser. */
|
|
2624
|
+
async dispose() {
|
|
2625
|
+
await this.checker.dispose();
|
|
2626
|
+
}
|
|
2627
|
+
/** Subscribe to project changes (the Remote face forwards nothing yet; specs and hooks use it). */
|
|
2628
|
+
subscribe(listener) {
|
|
2629
|
+
this.listeners.add(listener);
|
|
2630
|
+
return () => {
|
|
2631
|
+
this.listeners.delete(listener);
|
|
2632
|
+
};
|
|
2633
|
+
}
|
|
2634
|
+
notify(projectId) {
|
|
2635
|
+
for (const listener of this.listeners) listener(projectId);
|
|
2636
|
+
}
|
|
2637
|
+
/** Default fenced write: the mounted fs, stamped with the caller's policy. */
|
|
2638
|
+
writeFenced = async (path, content, session) => {
|
|
2639
|
+
const policy = this.sandboxPolicy?.resolve({ session });
|
|
2640
|
+
const target = await this.ctx.fs.resolve(path);
|
|
2641
|
+
await this.ctx.fs.writeText(target, content, { kind: "createIfAbsent" }, void 0, policy);
|
|
2642
|
+
};
|
|
2643
|
+
/** Whether the session may have the plugin write into its workspace. */
|
|
2644
|
+
writable(session) {
|
|
2645
|
+
if (session === void 0) return true;
|
|
2646
|
+
return this.sandboxPolicy?.resolve({ session })?.mode !== "read-only";
|
|
2647
|
+
}
|
|
2648
|
+
registryPath() {
|
|
2649
|
+
return join(this.stateRoot, "registry.json");
|
|
2650
|
+
}
|
|
2651
|
+
async readRegistry() {
|
|
2652
|
+
const raw = await readJson(this.registryPath());
|
|
2653
|
+
return {
|
|
2654
|
+
projects: Array.isArray(raw?.projects) ? raw.projects.filter((p) => typeof p?.id === "string" && typeof p.root === "string") : [],
|
|
2655
|
+
bindings: raw?.bindings !== null && typeof raw?.bindings === "object" ? { ...raw.bindings } : {}
|
|
2656
|
+
};
|
|
2657
|
+
}
|
|
2658
|
+
/** Read-modify-write the registry under the lock. */
|
|
2659
|
+
async updateRegistry(change) {
|
|
2660
|
+
const run = this.registryLock.then(async () => {
|
|
2661
|
+
const registry = await this.readRegistry();
|
|
2662
|
+
const result = change(registry);
|
|
2663
|
+
await writeAtomic(this.registryPath(), `${JSON.stringify(registry, null, 2)}\n`);
|
|
2664
|
+
return result;
|
|
2665
|
+
});
|
|
2666
|
+
this.registryLock = run.catch(() => {});
|
|
2667
|
+
return run;
|
|
2668
|
+
}
|
|
2669
|
+
/** Every registered project, most recently updated first. */
|
|
2670
|
+
async listProjects() {
|
|
2671
|
+
const { projects } = await this.readRegistry();
|
|
2672
|
+
return [...projects].sort((a, b) => b.updatedAt.localeCompare(a.updatedAt));
|
|
2673
|
+
}
|
|
2674
|
+
/** Element files parsed per id, for turn diffs. */
|
|
2675
|
+
async elementIndex(root) {
|
|
2676
|
+
const out = /* @__PURE__ */ new Map();
|
|
2677
|
+
for (const f of await walk(join(root, "elements"), (p) => p.endsWith(".json"))) {
|
|
2678
|
+
let raw;
|
|
2679
|
+
try {
|
|
2680
|
+
raw = JSON.parse(f.data.toString("utf8"));
|
|
2681
|
+
} catch {
|
|
2682
|
+
continue;
|
|
2683
|
+
}
|
|
2684
|
+
for (const item of Array.isArray(raw) ? raw : [raw]) {
|
|
2685
|
+
if (item === null || typeof item !== "object") continue;
|
|
2686
|
+
const r = item;
|
|
2687
|
+
if (typeof r.id !== "string") continue;
|
|
2688
|
+
const fields = {};
|
|
2689
|
+
for (const [k, v] of Object.entries(r)) if (k !== "look" && k !== "id") fields[k] = JSON.stringify(v);
|
|
2690
|
+
out.set(r.id, {
|
|
2691
|
+
name: typeof r.name === "string" ? r.name : r.id,
|
|
2692
|
+
json: JSON.stringify(r),
|
|
2693
|
+
look: JSON.stringify(r.look ?? null),
|
|
2694
|
+
fields
|
|
2695
|
+
});
|
|
2696
|
+
}
|
|
2697
|
+
}
|
|
2698
|
+
return out;
|
|
2699
|
+
}
|
|
2700
|
+
/** Snapshot the bound project when a turn starts (undefined when unbound). */
|
|
2701
|
+
async turnBaseline(sessionId) {
|
|
2702
|
+
const row = await this.boundProject(sessionId);
|
|
2703
|
+
if (row === void 0) return void 0;
|
|
2704
|
+
const files = new Map((await walk(row.root, () => true)).map((f) => [f.path, createHash("sha1").update(f.data).digest("hex")]));
|
|
2705
|
+
const manifest = await this.manifestOf(row.root);
|
|
2706
|
+
return {
|
|
2707
|
+
at: Date.now(),
|
|
2708
|
+
projectId: row.id,
|
|
2709
|
+
files,
|
|
2710
|
+
elements: await this.elementIndex(row.root),
|
|
2711
|
+
params: Object.fromEntries((manifest?.params ?? []).map((p) => [p.key, p.value])),
|
|
2712
|
+
current: (await readJson(join(row.root, STATE_DIR, "current.json")))?.version,
|
|
2713
|
+
candidate: (await readJson(join(row.root, STATE_DIR, "candidate.json")))?.version
|
|
2714
|
+
};
|
|
2715
|
+
}
|
|
2716
|
+
turnsPath(sessionId) {
|
|
2717
|
+
return join(this.stateRoot, "turns", `${sessionId.replace(/[^\w.-]/g, "_")}.json`);
|
|
2718
|
+
}
|
|
2719
|
+
/**
|
|
2720
|
+
* Compare the project with its turn-start snapshot and store what the turn
|
|
2721
|
+
* changed. Nothing is stored for a turn that changed nothing.
|
|
2722
|
+
*/
|
|
2723
|
+
async recordTurn(sessionId, turn, base) {
|
|
2724
|
+
const row = await this.row(base.projectId);
|
|
2725
|
+
if (row === void 0) return void 0;
|
|
2726
|
+
const now = new Map((await walk(row.root, () => true)).map((f) => [f.path, createHash("sha1").update(f.data).digest("hex")]));
|
|
2727
|
+
const changed = [...now.keys()].filter((p) => base.files.get(p) !== now.get(p));
|
|
2728
|
+
const removed = [...base.files.keys()].filter((p) => !now.has(p));
|
|
2729
|
+
const elementsNow = await this.elementIndex(row.root);
|
|
2730
|
+
const elements = [];
|
|
2731
|
+
for (const [id, e] of elementsNow) {
|
|
2732
|
+
const before = base.elements.get(id);
|
|
2733
|
+
if (before === void 0) {
|
|
2734
|
+
elements.push({
|
|
2735
|
+
id,
|
|
2736
|
+
name: e.name,
|
|
2737
|
+
what: "new"
|
|
2738
|
+
});
|
|
2739
|
+
continue;
|
|
2740
|
+
}
|
|
2741
|
+
if (before.json === e.json) continue;
|
|
2742
|
+
const fields = [.../* @__PURE__ */ new Set([...Object.keys(e.fields), ...Object.keys(before.fields)])].filter((k) => e.fields[k] !== before.fields[k]);
|
|
2743
|
+
if (before.look !== e.look) {
|
|
2744
|
+
const looks = /"kind":"(parts3d|gltf)"/.test(e.look) && e.look.length < 8e3 && before.look.length < 8e3 ? { looks: {
|
|
2745
|
+
before: before.look === "null" ? null : before.look,
|
|
2746
|
+
after: e.look
|
|
2747
|
+
} } : {};
|
|
2748
|
+
elements.push({
|
|
2749
|
+
id,
|
|
2750
|
+
name: e.name,
|
|
2751
|
+
what: "look",
|
|
2752
|
+
...fields.length > 0 ? { fields } : {},
|
|
2753
|
+
...looks
|
|
2754
|
+
});
|
|
2755
|
+
} else elements.push({
|
|
2756
|
+
id,
|
|
2757
|
+
name: e.name,
|
|
2758
|
+
what: "fields",
|
|
2759
|
+
fields
|
|
2760
|
+
});
|
|
2761
|
+
}
|
|
2762
|
+
for (const [id, e] of base.elements) if (!elementsNow.has(id)) elements.push({
|
|
2763
|
+
id,
|
|
2764
|
+
name: e.name,
|
|
2765
|
+
what: "removed"
|
|
2766
|
+
});
|
|
2767
|
+
const manifest = await this.manifestOf(row.root);
|
|
2768
|
+
const params = (manifest?.params ?? []).filter((p) => base.params[p.key] !== p.value).map((p) => ({
|
|
2769
|
+
key: p.key,
|
|
2770
|
+
label: p.label,
|
|
2771
|
+
to: p.value,
|
|
2772
|
+
...base.params[p.key] === void 0 ? {} : { from: base.params[p.key] }
|
|
2773
|
+
}));
|
|
2774
|
+
const chapters = CHAPTERS.filter((c) => c.generated !== true && [...changed, ...removed].includes(c.file)).map((c) => ({
|
|
2775
|
+
id: c.id,
|
|
2776
|
+
title: c.title
|
|
2777
|
+
}));
|
|
2778
|
+
const code = [...changed, ...removed].filter((p) => isBuildFile(p) && !p.startsWith("elements/") && p !== "game.json");
|
|
2779
|
+
const tests = changed.filter((p) => p.startsWith("tests/"));
|
|
2780
|
+
const current = (await readJson(join(row.root, STATE_DIR, "current.json")))?.version;
|
|
2781
|
+
const candidate = (await readJson(join(row.root, STATE_DIR, "candidate.json")))?.version;
|
|
2782
|
+
const version = candidate !== void 0 && candidate !== base.candidate ? {
|
|
2783
|
+
kind: "candidate",
|
|
2784
|
+
version: candidate
|
|
2785
|
+
} : current !== void 0 && current !== base.current ? {
|
|
2786
|
+
kind: "deployed",
|
|
2787
|
+
version: current
|
|
2788
|
+
} : null;
|
|
2789
|
+
const last = await readJson(join(row.root, STATE_DIR, "checks.json"));
|
|
2790
|
+
const check = last !== void 0 && new Date(last.at).getTime() >= base.at ? {
|
|
2791
|
+
ok: last.ok,
|
|
2792
|
+
fixes: last.attempt,
|
|
2793
|
+
gaveUp: last.gaveUp === true,
|
|
2794
|
+
passed: last.passed,
|
|
2795
|
+
failures: last.failures.length
|
|
2796
|
+
} : null;
|
|
2797
|
+
if (elements.length + params.length + chapters.length + code.length + tests.length === 0 && version === null && check === null) return void 0;
|
|
2798
|
+
const report = {
|
|
2799
|
+
turn,
|
|
2800
|
+
projectId: row.id,
|
|
2801
|
+
title: manifest?.title ?? row.title,
|
|
2802
|
+
at: (/* @__PURE__ */ new Date()).toISOString(),
|
|
2803
|
+
version,
|
|
2804
|
+
check,
|
|
2805
|
+
elements,
|
|
2806
|
+
chapters,
|
|
2807
|
+
params,
|
|
2808
|
+
code: code.slice(0, 20),
|
|
2809
|
+
tests
|
|
2810
|
+
};
|
|
2811
|
+
const path = this.turnsPath(sessionId);
|
|
2812
|
+
const all = await readJson(path) ?? {};
|
|
2813
|
+
all[String(turn)] = report;
|
|
2814
|
+
const keys = Object.keys(all).map(Number).sort((a, b) => b - a);
|
|
2815
|
+
for (const k of keys.slice(200)) delete all[String(k)];
|
|
2816
|
+
await writeAtomic(path, `${JSON.stringify(all)}\n`);
|
|
2817
|
+
this.notify(row.id);
|
|
2818
|
+
return report;
|
|
2819
|
+
}
|
|
2820
|
+
/** A stored turn report. */
|
|
2821
|
+
async turnReport(sessionId, turn) {
|
|
2822
|
+
return (await readJson(this.turnsPath(sessionId)))?.[String(turn)] ?? null;
|
|
2823
|
+
}
|
|
2824
|
+
/** Every project as a switcher card (manifest summary, live version, last screenshot), most recent first. */
|
|
2825
|
+
async projectCards() {
|
|
2826
|
+
const cards = [];
|
|
2827
|
+
for (const row of await this.listProjects()) {
|
|
2828
|
+
const manifest = await this.manifestOf(row.root);
|
|
2829
|
+
const current = await readJson(join(row.root, STATE_DIR, "current.json"));
|
|
2830
|
+
const elementFiles = await walk(join(row.root, "elements"), (p) => p.endsWith(".json"));
|
|
2831
|
+
const { elements } = validateElements(Object.fromEntries(elementFiles.map((f) => [`elements/${f.path}`, f.data.toString("utf8")])), [], kitOf(manifest?.runtime));
|
|
2832
|
+
const thumb = await readFile(join(row.root, STATE_DIR, "last-check.webp")).catch(() => void 0);
|
|
2833
|
+
cards.push({
|
|
2834
|
+
id: row.id,
|
|
2835
|
+
title: manifest?.title ?? row.title,
|
|
2836
|
+
root: row.root,
|
|
2837
|
+
updatedAt: row.updatedAt,
|
|
2838
|
+
pitch: manifest?.pitch ?? "",
|
|
2839
|
+
artStyle: manifest?.artStyle ?? "",
|
|
2840
|
+
version: current?.version ?? null,
|
|
2841
|
+
elements: elements.length,
|
|
2842
|
+
...thumb === void 0 || thumb.length > 4e5 ? {} : { thumb: thumb.toString("base64") }
|
|
2843
|
+
});
|
|
2844
|
+
}
|
|
2845
|
+
return cards;
|
|
2846
|
+
}
|
|
2847
|
+
/** The project a session is bound to, if any. */
|
|
2848
|
+
async boundProject(sessionId) {
|
|
2849
|
+
const registry = await this.readRegistry();
|
|
2850
|
+
const id = registry.bindings[sessionId];
|
|
2851
|
+
return id === void 0 ? void 0 : registry.projects.find((p) => p.id === id);
|
|
2852
|
+
}
|
|
2853
|
+
/** Bind a session to a project (or unbind with null). */
|
|
2854
|
+
async bind(sessionId, projectId) {
|
|
2855
|
+
return this.updateRegistry((registry) => {
|
|
2856
|
+
if (projectId === null) {
|
|
2857
|
+
delete registry.bindings[sessionId];
|
|
2858
|
+
return { ok: true };
|
|
2859
|
+
}
|
|
2860
|
+
if (!registry.projects.some((p) => p.id === projectId)) return {
|
|
2861
|
+
ok: false,
|
|
2862
|
+
error: "not-found"
|
|
2863
|
+
};
|
|
2864
|
+
registry.bindings[sessionId] = projectId;
|
|
2865
|
+
return { ok: true };
|
|
2866
|
+
});
|
|
2867
|
+
}
|
|
2868
|
+
async touch(projectId) {
|
|
2869
|
+
await this.updateRegistry((registry) => {
|
|
2870
|
+
const row = registry.projects.find((p) => p.id === projectId);
|
|
2871
|
+
if (row !== void 0) Object.assign(row, { updatedAt: (/* @__PURE__ */ new Date()).toISOString() });
|
|
2872
|
+
}).catch(() => {});
|
|
2873
|
+
this.notify(projectId);
|
|
2874
|
+
}
|
|
2875
|
+
/**
|
|
2876
|
+
* Register an existing folder that holds a `game.json` (a project copied
|
|
2877
|
+
* in, or one whose registry row was lost).
|
|
2878
|
+
*/
|
|
2879
|
+
async adopt(root) {
|
|
2880
|
+
const manifestText = await readText(join(root, MANIFEST_FILE));
|
|
2881
|
+
if (manifestText === void 0) return {
|
|
2882
|
+
ok: false,
|
|
2883
|
+
error: "not-found"
|
|
2884
|
+
};
|
|
2885
|
+
const parsed = parseManifest(manifestText);
|
|
2886
|
+
if (!parsed.ok) return {
|
|
2887
|
+
ok: false,
|
|
2888
|
+
error: "invalid"
|
|
2889
|
+
};
|
|
2890
|
+
return this.updateRegistry((registry) => {
|
|
2891
|
+
const existing = registry.projects.find((p) => p.root === root || p.id === parsed.manifest.id);
|
|
2892
|
+
if (existing !== void 0) return {
|
|
2893
|
+
ok: true,
|
|
2894
|
+
project: existing
|
|
2895
|
+
};
|
|
2896
|
+
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
2897
|
+
const row = {
|
|
2898
|
+
id: parsed.manifest.id,
|
|
2899
|
+
title: parsed.manifest.title,
|
|
2900
|
+
root,
|
|
2901
|
+
createdAt: parsed.manifest.createdAt || now,
|
|
2902
|
+
updatedAt: now
|
|
2903
|
+
};
|
|
2904
|
+
registry.projects.push(row);
|
|
2905
|
+
return {
|
|
2906
|
+
ok: true,
|
|
2907
|
+
project: row
|
|
2908
|
+
};
|
|
2909
|
+
});
|
|
2910
|
+
}
|
|
2911
|
+
/**
|
|
2912
|
+
* Delete a project from the panel. `trash` takes it out of the plugin
|
|
2913
|
+
* (registry row and every session binding) AND moves its folder to the
|
|
2914
|
+
* system trash — complete, yet recoverable; `forget` only takes it out of
|
|
2915
|
+
* the plugin and leaves the folder (re-adopt with `game_open path`).
|
|
2916
|
+
*
|
|
2917
|
+
* Guards, checked before anything changes: the folder must still hold the
|
|
2918
|
+
* project's own `game.json` (never trash an arbitrary directory a registry
|
|
2919
|
+
* row happens to point at), must not be the calling session's workspace
|
|
2920
|
+
* itself, and a read-only session may do neither. A failed move changes
|
|
2921
|
+
* nothing, so the panel can offer `forget` instead.
|
|
2922
|
+
*/
|
|
2923
|
+
async deleteProject(projectId, mode, session) {
|
|
2924
|
+
const row = await this.row(projectId);
|
|
2925
|
+
if (row === void 0) return {
|
|
2926
|
+
ok: false,
|
|
2927
|
+
error: "not-found"
|
|
2928
|
+
};
|
|
2929
|
+
if (!this.writable(session)) return {
|
|
2930
|
+
ok: false,
|
|
2931
|
+
error: "denied"
|
|
2932
|
+
};
|
|
2933
|
+
let trashedTo;
|
|
2934
|
+
if (mode === "trash") {
|
|
2935
|
+
const manifest = await this.manifestOf(row.root);
|
|
2936
|
+
const cwd = session?.header?.cwd;
|
|
2937
|
+
if (manifest === void 0 || manifest.id !== projectId) return {
|
|
2938
|
+
ok: false,
|
|
2939
|
+
error: "not-a-project"
|
|
2940
|
+
};
|
|
2941
|
+
if (cwd !== void 0 && resolve(cwd) === resolve(row.root)) return {
|
|
2942
|
+
ok: false,
|
|
2943
|
+
error: "is-workspace"
|
|
2944
|
+
};
|
|
2945
|
+
try {
|
|
2946
|
+
trashedTo = await this.trash(row.root);
|
|
2947
|
+
} catch (error) {
|
|
2948
|
+
return {
|
|
2949
|
+
ok: false,
|
|
2950
|
+
error: "trash-failed",
|
|
2951
|
+
reason: error.message.split("\n")[0] ?? ""
|
|
2952
|
+
};
|
|
2953
|
+
}
|
|
2954
|
+
}
|
|
2955
|
+
await this.updateRegistry((registry) => {
|
|
2956
|
+
registry.projects = registry.projects.filter((p) => p.id !== projectId);
|
|
2957
|
+
for (const [sid, bound] of Object.entries(registry.bindings)) if (bound === projectId) delete registry.bindings[sid];
|
|
2958
|
+
});
|
|
2959
|
+
this.notify(projectId);
|
|
2960
|
+
return {
|
|
2961
|
+
ok: true,
|
|
2962
|
+
...trashedTo === void 0 ? {} : { trashedTo }
|
|
2963
|
+
};
|
|
2964
|
+
}
|
|
2965
|
+
/**
|
|
2966
|
+
* Create a project in the session's workspace behind the light gate, and
|
|
2967
|
+
* bind the session to it.
|
|
2968
|
+
* @param request - the gate's inputs.
|
|
2969
|
+
* @param session - the calling session (its workspace and fence).
|
|
2970
|
+
* @returns the new row, the gate's missing items, or a failure.
|
|
2971
|
+
*/
|
|
2972
|
+
async create(request, session) {
|
|
2973
|
+
const verdict = gate(request);
|
|
2974
|
+
if (!verdict.ok) return {
|
|
2975
|
+
ok: false,
|
|
2976
|
+
missing: verdict.missing
|
|
2977
|
+
};
|
|
2978
|
+
const cwd = session.header?.cwd;
|
|
2979
|
+
if (cwd === void 0 || cwd === "") return {
|
|
2980
|
+
ok: false,
|
|
2981
|
+
error: "这个会话没有工作区,项目没处放。请用户先给会话选一个工作区。"
|
|
2982
|
+
};
|
|
2983
|
+
if (!this.writable(session)) return {
|
|
2984
|
+
ok: false,
|
|
2985
|
+
error: "这个会话是只读模式,不能新建项目。"
|
|
2986
|
+
};
|
|
2987
|
+
const registry = await this.readRegistry();
|
|
2988
|
+
const base = slugOf(verdict.manifest.title);
|
|
2989
|
+
let folder = base;
|
|
2990
|
+
for (let n = 2; registry.projects.some((p) => p.root === join(cwd, folder)) || await stat(join(cwd, folder)).then(() => true, () => false); n++) folder = `${base}-${n}`;
|
|
2991
|
+
const root = join(cwd, folder);
|
|
2992
|
+
const id = `g_${randomBytes(4).toString("hex")}`;
|
|
2993
|
+
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
2994
|
+
const manifest = {
|
|
2995
|
+
...verdict.manifest,
|
|
2996
|
+
id,
|
|
2997
|
+
createdAt: now
|
|
2998
|
+
};
|
|
2999
|
+
try {
|
|
3000
|
+
await this.writeFenced(join(root, MANIFEST_FILE), serializeManifest(manifest), session);
|
|
3001
|
+
} catch (error) {
|
|
3002
|
+
return {
|
|
3003
|
+
ok: false,
|
|
3004
|
+
error: `写不进工作区:${error.message.split("\n")[0]}`
|
|
3005
|
+
};
|
|
3006
|
+
}
|
|
3007
|
+
const row = {
|
|
3008
|
+
id,
|
|
3009
|
+
title: manifest.title,
|
|
3010
|
+
root,
|
|
3011
|
+
createdAt: now,
|
|
3012
|
+
updatedAt: now
|
|
3013
|
+
};
|
|
3014
|
+
await this.updateRegistry((r) => {
|
|
3015
|
+
r.projects.push(row);
|
|
3016
|
+
r.bindings[session.id] = id;
|
|
3017
|
+
});
|
|
3018
|
+
this.notify(id);
|
|
3019
|
+
return {
|
|
3020
|
+
ok: true,
|
|
3021
|
+
project: row
|
|
3022
|
+
};
|
|
3023
|
+
}
|
|
3024
|
+
async row(projectId) {
|
|
3025
|
+
return (await this.readRegistry()).projects.find((p) => p.id === projectId);
|
|
3026
|
+
}
|
|
3027
|
+
/**
|
|
3028
|
+
* Everything the panel and `game_read` show about one project.
|
|
3029
|
+
* @param projectId - registry id.
|
|
3030
|
+
* @returns the view, or undefined for an unknown id.
|
|
3031
|
+
*/
|
|
3032
|
+
async view(projectId) {
|
|
3033
|
+
const row = await this.row(projectId);
|
|
3034
|
+
if (row === void 0) return void 0;
|
|
3035
|
+
const manifestText = await readText(join(row.root, MANIFEST_FILE));
|
|
3036
|
+
const parsed = manifestText === void 0 ? void 0 : parseManifest(manifestText);
|
|
3037
|
+
const manifest = parsed?.ok === true ? parsed.manifest : null;
|
|
3038
|
+
const elementFiles = await walk(join(row.root, "elements"), (p) => p.endsWith(".json"));
|
|
3039
|
+
const { elements, failures } = validateElements(Object.fromEntries(elementFiles.map((f) => [`elements/${f.path}`, f.data.toString("utf8")])), manifest?.elementTypes ?? [], kitOf(manifest?.runtime));
|
|
3040
|
+
const playtest = await this.playtestText(row.root);
|
|
3041
|
+
const chapters = [];
|
|
3042
|
+
for (const spec of CHAPTERS) {
|
|
3043
|
+
const text = spec.id === "elements" ? elementsChapter(elements, manifest?.elementTypes ?? []) : spec.id === "lore" ? loreChapter(elements, manifest?.elementTypes ?? []) : spec.id === "playtest" ? playtest : spec.id === "pillars" ? pillarNotes(await readText(join(row.root, spec.file)) ?? "", manifest) : await readText(join(row.root, spec.file)) ?? "";
|
|
3044
|
+
const source = spec.id === "playtest" ? join(row.root, STATE_DIR, "playtest-log.jsonl") : spec.id === "elements" || spec.id === "lore" ? join(row.root, "elements") : join(row.root, spec.file);
|
|
3045
|
+
const mtime = await stat(source).then((info) => info.mtimeMs, () => 0);
|
|
3046
|
+
chapters.push({
|
|
3047
|
+
id: spec.id,
|
|
3048
|
+
title: spec.title,
|
|
3049
|
+
file: spec.file,
|
|
3050
|
+
text,
|
|
3051
|
+
generated: spec.generated === true,
|
|
3052
|
+
mtime,
|
|
3053
|
+
status: spec.generated === true ? text === "" ? "todo" : "draft" : chapterStatus(text)
|
|
3054
|
+
});
|
|
3055
|
+
}
|
|
3056
|
+
const state = join(row.root, STATE_DIR);
|
|
3057
|
+
const user = await this.userLayer(row.root);
|
|
3058
|
+
const params = manifest?.params ?? [];
|
|
3059
|
+
const paramOverrides = Object.fromEntries(Object.entries(user.params).filter(([key, value]) => {
|
|
3060
|
+
const param = params.find((p) => p.key === key);
|
|
3061
|
+
return param !== void 0 && param.value !== value;
|
|
3062
|
+
}));
|
|
3063
|
+
return {
|
|
3064
|
+
id: row.id,
|
|
3065
|
+
title: manifest?.title ?? row.title,
|
|
3066
|
+
root: row.root,
|
|
3067
|
+
manifest,
|
|
3068
|
+
...manifestText === void 0 ? { manifestError: `${MANIFEST_FILE} 不见了` } : parsed?.ok === false ? { manifestError: parsed.error } : {},
|
|
3069
|
+
chapters,
|
|
3070
|
+
elements,
|
|
3071
|
+
elementErrors: failures,
|
|
3072
|
+
current: await readJson(join(state, "current.json")) ?? null,
|
|
3073
|
+
candidate: await readJson(join(state, "candidate.json")) ?? null,
|
|
3074
|
+
lastCheck: await readJson(join(state, "checks.json")) ?? null,
|
|
3075
|
+
hasEntry: await stat(join(row.root, "index.html")).then(() => true, () => false),
|
|
3076
|
+
paramOverrides,
|
|
3077
|
+
pending: user.pending,
|
|
3078
|
+
uncommitted: user.edits,
|
|
3079
|
+
proposals: await this.proposals(row.root),
|
|
3080
|
+
versions: await this.versionRows(row.root),
|
|
3081
|
+
log: await this.logEntries(row.root),
|
|
3082
|
+
...user.restyleTo === void 0 ? {} : { restyleTo: user.restyleTo },
|
|
3083
|
+
candidateDiff: await this.candidateDiff(row.root)
|
|
3084
|
+
};
|
|
3085
|
+
}
|
|
3086
|
+
/** Elements the waiting candidate changed or added against the live version (null without both). */
|
|
3087
|
+
async candidateDiff(root) {
|
|
3088
|
+
const current = await readJson(join(root, STATE_DIR, "current.json"));
|
|
3089
|
+
const candidate = await readJson(join(root, STATE_DIR, "candidate.json"));
|
|
3090
|
+
if (current === void 0 || candidate === void 0) return null;
|
|
3091
|
+
const before = await this.elementIndex(join(root, STATE_DIR, "versions", String(current.version)));
|
|
3092
|
+
const after = await this.elementIndex(join(root, STATE_DIR, "versions", String(candidate.version)));
|
|
3093
|
+
const changed = [], added = [];
|
|
3094
|
+
for (const [id, e] of after) {
|
|
3095
|
+
const was = before.get(id);
|
|
3096
|
+
if (was === void 0) added.push(id);
|
|
3097
|
+
else if (was.json !== e.json) changed.push(id);
|
|
3098
|
+
}
|
|
3099
|
+
return {
|
|
3100
|
+
changed,
|
|
3101
|
+
added
|
|
3102
|
+
};
|
|
3103
|
+
}
|
|
3104
|
+
/** The tab's state for one session. */
|
|
3105
|
+
async sessionState(sessionId) {
|
|
3106
|
+
const bound = await this.boundProject(sessionId);
|
|
3107
|
+
return {
|
|
3108
|
+
bound: bound === void 0 ? null : await this.view(bound.id) ?? null,
|
|
3109
|
+
projects: await this.listProjects()
|
|
3110
|
+
};
|
|
3111
|
+
}
|
|
3112
|
+
/**
|
|
3113
|
+
* One build file as it is in the working folder now (a model the user just
|
|
3114
|
+
* exported, say), for a panel preview ahead of any version. Build files only,
|
|
3115
|
+
* inside the project, 8 MB at most.
|
|
3116
|
+
*/
|
|
3117
|
+
async workingFile(projectId, path) {
|
|
3118
|
+
const row = await this.row(projectId);
|
|
3119
|
+
if (row === void 0) return {
|
|
3120
|
+
ok: false,
|
|
3121
|
+
error: "not-found"
|
|
3122
|
+
};
|
|
3123
|
+
const rel = path.replace(/^\.\//, "");
|
|
3124
|
+
const full = resolve(row.root, rel);
|
|
3125
|
+
if (!isBuildFile(rel) || !inside(row.root, full)) return {
|
|
3126
|
+
ok: false,
|
|
3127
|
+
error: "invalid"
|
|
3128
|
+
};
|
|
3129
|
+
const info = await stat(full).catch(() => void 0);
|
|
3130
|
+
if (info === void 0 || !info.isFile() || info.size > 8388608) return {
|
|
3131
|
+
ok: false,
|
|
3132
|
+
error: "not-found"
|
|
3133
|
+
};
|
|
3134
|
+
return {
|
|
3135
|
+
ok: true,
|
|
3136
|
+
file: bundleOf([{
|
|
3137
|
+
path: rel,
|
|
3138
|
+
data: await readFile(full)
|
|
3139
|
+
}])[rel]
|
|
3140
|
+
};
|
|
3141
|
+
}
|
|
3142
|
+
/** The files of a deployed (or candidate) version, for the play area. */
|
|
3143
|
+
async bundle(projectId, which = "current") {
|
|
3144
|
+
const row = await this.row(projectId);
|
|
3145
|
+
if (row === void 0) return {
|
|
3146
|
+
ok: false,
|
|
3147
|
+
error: "not-found"
|
|
3148
|
+
};
|
|
3149
|
+
const record = await readJson(join(row.root, STATE_DIR, `${which}.json`));
|
|
3150
|
+
if (record === void 0) return {
|
|
3151
|
+
ok: false,
|
|
3152
|
+
error: "no-version"
|
|
3153
|
+
};
|
|
3154
|
+
const files = await walk(join(row.root, STATE_DIR, "versions", String(record.version)), () => true);
|
|
3155
|
+
if (files.length === 0) return {
|
|
3156
|
+
ok: false,
|
|
3157
|
+
error: "no-version"
|
|
3158
|
+
};
|
|
3159
|
+
return {
|
|
3160
|
+
ok: true,
|
|
3161
|
+
bundle: {
|
|
3162
|
+
projectId,
|
|
3163
|
+
version: record.version,
|
|
3164
|
+
files: withKitRuntime(bundleOf(files), this.snapshotKit(files))
|
|
3165
|
+
}
|
|
3166
|
+
};
|
|
3167
|
+
}
|
|
3168
|
+
/** One specific version's files. */
|
|
3169
|
+
async versionBundle(projectId, version) {
|
|
3170
|
+
const row = await this.row(projectId);
|
|
3171
|
+
if (row === void 0) return {
|
|
3172
|
+
ok: false,
|
|
3173
|
+
error: "not-found"
|
|
3174
|
+
};
|
|
3175
|
+
const files = await walk(join(row.root, STATE_DIR, "versions", String(Math.floor(version))), () => true);
|
|
3176
|
+
if (files.length === 0) return {
|
|
3177
|
+
ok: false,
|
|
3178
|
+
error: "no-version"
|
|
3179
|
+
};
|
|
3180
|
+
return {
|
|
3181
|
+
ok: true,
|
|
3182
|
+
bundle: {
|
|
3183
|
+
projectId,
|
|
3184
|
+
version,
|
|
3185
|
+
files: withKitRuntime(bundleOf(files), this.snapshotKit(files))
|
|
3186
|
+
}
|
|
3187
|
+
};
|
|
3188
|
+
}
|
|
3189
|
+
/** The kit a snapshot was built for: its own `game.json`'s runtime. */
|
|
3190
|
+
snapshotKit(files) {
|
|
3191
|
+
const manifest = files.find((f) => f.path === MANIFEST_FILE);
|
|
3192
|
+
const parsed = manifest === void 0 ? void 0 : parseManifest(manifest.data.toString("utf8"));
|
|
3193
|
+
return kitOf(parsed?.ok === true ? parsed.manifest.runtime : void 0);
|
|
3194
|
+
}
|
|
3195
|
+
/** The kit a project runs on now (its working `game.json`). */
|
|
3196
|
+
async kitFor(root) {
|
|
3197
|
+
return kitOf((await this.manifestOf(root))?.runtime);
|
|
3198
|
+
}
|
|
3199
|
+
/** The build and test files as they are on disk now, plus their fingerprint. */
|
|
3200
|
+
async snapshot(root) {
|
|
3201
|
+
const build = await walk(root, isBuildFile);
|
|
3202
|
+
const tests = await walk(join(root, "tests"), (p) => p.endsWith(".test.js"));
|
|
3203
|
+
return {
|
|
3204
|
+
build,
|
|
3205
|
+
tests,
|
|
3206
|
+
fingerprint: fingerprintOf([...build, ...tests.map((t) => ({
|
|
3207
|
+
path: `tests/${t.path}`,
|
|
3208
|
+
data: t.data
|
|
3209
|
+
}))])
|
|
3210
|
+
};
|
|
3211
|
+
}
|
|
3212
|
+
/**
|
|
3213
|
+
* The end-of-turn evaluation for a bound session: skip when nothing
|
|
3214
|
+
* playable changed since the last check, else run the checks, persist the
|
|
3215
|
+
* record, and on a pass deploy (auto-confirm) or leave a candidate.
|
|
3216
|
+
* @param session - the session whose turn is closing.
|
|
3217
|
+
* @param attempt - which automatic fix round this is (0 = the turn's own work).
|
|
3218
|
+
* @param cap - the fix-round cap (a failure at the cap is recorded as given up).
|
|
3219
|
+
* @returns what the hook should do.
|
|
3220
|
+
*/
|
|
3221
|
+
async checkTurn(session, attempt, cap, by = "turn") {
|
|
3222
|
+
const row = await this.boundProject(session.id);
|
|
3223
|
+
if (row === void 0) return { kind: "skip" };
|
|
3224
|
+
return this.checkRow(row, session, attempt, cap, by);
|
|
3225
|
+
}
|
|
3226
|
+
/**
|
|
3227
|
+
* 提交为新版本: the user commits the panel edits sitting in the working
|
|
3228
|
+
* files. Runs the same checks as the end of a turn (no fix cap — nothing
|
|
3229
|
+
* is steered); a pass cuts the version, whose note lists the edits.
|
|
3230
|
+
*/
|
|
3231
|
+
async commit(projectId, session) {
|
|
3232
|
+
const row = await this.row(projectId);
|
|
3233
|
+
if (row === void 0) return {
|
|
3234
|
+
ok: false,
|
|
3235
|
+
error: "not-found"
|
|
3236
|
+
};
|
|
3237
|
+
if (!this.writable(session)) return {
|
|
3238
|
+
ok: false,
|
|
3239
|
+
error: "denied"
|
|
3240
|
+
};
|
|
3241
|
+
const outcome = await this.checkRow(row, session, 0, Number.POSITIVE_INFINITY, "user");
|
|
3242
|
+
this.notify(projectId);
|
|
3243
|
+
if (outcome.kind === "fail") return {
|
|
3244
|
+
ok: true,
|
|
3245
|
+
kind: "failed",
|
|
3246
|
+
failures: outcome.record.failures.slice(0, 8).map((f) => ({
|
|
3247
|
+
where: f.where,
|
|
3248
|
+
message: f.message.slice(0, 300)
|
|
3249
|
+
}))
|
|
3250
|
+
};
|
|
3251
|
+
if (outcome.kind === "pass") {
|
|
3252
|
+
if (outcome.deployed !== null) return {
|
|
3253
|
+
ok: true,
|
|
3254
|
+
kind: "deployed",
|
|
3255
|
+
version: outcome.deployed.version
|
|
3256
|
+
};
|
|
3257
|
+
if (outcome.candidate !== null) return {
|
|
3258
|
+
ok: true,
|
|
3259
|
+
kind: "candidate",
|
|
3260
|
+
version: outcome.candidate.version
|
|
3261
|
+
};
|
|
3262
|
+
return {
|
|
3263
|
+
ok: false,
|
|
3264
|
+
error: "denied"
|
|
3265
|
+
};
|
|
3266
|
+
}
|
|
3267
|
+
if (!await stat(join(row.root, "index.html")).then(() => true, () => false)) return {
|
|
3268
|
+
ok: true,
|
|
3269
|
+
kind: "no-entry"
|
|
3270
|
+
};
|
|
3271
|
+
const last = await readJson(join(row.root, STATE_DIR, "checks.json"));
|
|
3272
|
+
if (last !== void 0 && !last.ok) return {
|
|
3273
|
+
ok: true,
|
|
3274
|
+
kind: "failed",
|
|
3275
|
+
failures: last.failures.slice(0, 8).map((f) => ({
|
|
3276
|
+
where: f.where,
|
|
3277
|
+
message: f.message.slice(0, 300)
|
|
3278
|
+
}))
|
|
3279
|
+
};
|
|
3280
|
+
const user = await this.userLayer(row.root);
|
|
3281
|
+
user.edits = [];
|
|
3282
|
+
await this.saveUserLayer(row.root, user);
|
|
3283
|
+
return {
|
|
3284
|
+
ok: true,
|
|
3285
|
+
kind: "unchanged"
|
|
3286
|
+
};
|
|
3287
|
+
}
|
|
3288
|
+
async checkRow(row, session, attempt, cap, by) {
|
|
3289
|
+
const root = row.root;
|
|
3290
|
+
if (!await stat(join(root, "index.html")).then(() => true, () => false)) return { kind: "skip" };
|
|
3291
|
+
const { build, tests, fingerprint } = await this.snapshot(root);
|
|
3292
|
+
const last = await readJson(join(root, STATE_DIR, "checks.json"));
|
|
3293
|
+
if (last?.fingerprint === fingerprint && (last.ok || last.gaveUp === true)) return { kind: "skip" };
|
|
3294
|
+
const failures = [];
|
|
3295
|
+
const manifestText = await readText(join(root, MANIFEST_FILE));
|
|
3296
|
+
const parsed = manifestText === void 0 ? void 0 : parseManifest(manifestText);
|
|
3297
|
+
if (parsed === void 0) failures.push({
|
|
3298
|
+
where: "data",
|
|
3299
|
+
message: `${MANIFEST_FILE} 不见了`
|
|
3300
|
+
});
|
|
3301
|
+
else if (!parsed.ok) failures.push({
|
|
3302
|
+
where: "data",
|
|
3303
|
+
message: parsed.error
|
|
3304
|
+
});
|
|
3305
|
+
const kit = kitOf(parsed?.ok === true ? parsed.manifest.runtime : void 0);
|
|
3306
|
+
const elementFiles = build.filter((f) => f.path.startsWith("elements/") && f.path.endsWith(".json"));
|
|
3307
|
+
const validated = validateElements(Object.fromEntries(elementFiles.map((f) => [f.path, f.data.toString("utf8")])), parsed?.ok === true ? parsed.manifest.elementTypes : [], kit);
|
|
3308
|
+
failures.push(...validated.failures);
|
|
3309
|
+
const shipped = new Set(build.map((f) => f.path));
|
|
3310
|
+
for (const e of validated.elements) if (e.look3d?.kind === "gltf" && !shipped.has(e.look3d.src)) failures.push({
|
|
3311
|
+
where: e.file,
|
|
3312
|
+
message: `「${e.name}」的外观引用了 ${e.look3d.src},项目里没有这个文件(放进 assets/ 再引用)`
|
|
3313
|
+
});
|
|
3314
|
+
const browser = await this.checker.run(withKitRuntime(bundleOf(build), kit), Object.fromEntries(tests.map((t) => [`tests/${t.path}`, t.data.toString("utf8")])), { blankProbe: kit.blankProbe });
|
|
3315
|
+
failures.push(...browser.failures);
|
|
3316
|
+
const ok = failures.length === 0;
|
|
3317
|
+
const record = {
|
|
3318
|
+
fingerprint,
|
|
3319
|
+
at: (/* @__PURE__ */ new Date()).toISOString(),
|
|
3320
|
+
ok,
|
|
3321
|
+
failures,
|
|
3322
|
+
passed: browser.passed,
|
|
3323
|
+
attempt,
|
|
3324
|
+
...browser.skipped === void 0 ? {} : { skipped: browser.skipped },
|
|
3325
|
+
...!ok && attempt >= cap ? { gaveUp: true } : {}
|
|
3326
|
+
};
|
|
3327
|
+
const writable = this.writable(session);
|
|
3328
|
+
if (writable) {
|
|
3329
|
+
await writeAtomic(join(root, STATE_DIR, "checks.json"), `${JSON.stringify(record, null, 2)}\n`);
|
|
3330
|
+
if (browser.screenshot !== void 0) await writeAtomic(join(root, STATE_DIR, "last-check.webp"), Buffer.from(browser.screenshot, "base64"));
|
|
3331
|
+
}
|
|
3332
|
+
if (!ok) {
|
|
3333
|
+
if (record.gaveUp === true) await this.log(root, writable, `检查仍未通过,已停止自动修复(${failures.length} 项):${failures.slice(0, 2).map((f) => f.where).join("、")}`);
|
|
3334
|
+
await this.touch(row.id);
|
|
3335
|
+
return {
|
|
3336
|
+
kind: "fail",
|
|
3337
|
+
record
|
|
3338
|
+
};
|
|
3339
|
+
}
|
|
3340
|
+
if (!writable) return {
|
|
3341
|
+
kind: "pass",
|
|
3342
|
+
record,
|
|
3343
|
+
deployed: null,
|
|
3344
|
+
candidate: null
|
|
3345
|
+
};
|
|
3346
|
+
const version = await this.nextVersion(root);
|
|
3347
|
+
await this.copyVersion(root, build, version);
|
|
3348
|
+
if (browser.screenshot !== void 0) await writeAtomic(join(root, STATE_DIR, "shots", `${version}.webp`), Buffer.from(browser.screenshot, "base64"));
|
|
3349
|
+
const user = await this.userLayer(root);
|
|
3350
|
+
const restyle = user.restyleTo;
|
|
3351
|
+
const edits = user.edits.map((e) => e.text);
|
|
3352
|
+
const versionRecord = {
|
|
3353
|
+
version,
|
|
3354
|
+
at: record.at,
|
|
3355
|
+
fingerprint,
|
|
3356
|
+
by,
|
|
3357
|
+
note: restyle !== void 0 ? `换成「${artStyleLabel(restyle)}」风格` : edits.length > 0 ? `面板修改:${edits.join("、")}` : browser.passed > 0 ? `通过检查(${browser.passed} 个用例)` : "通过检查"
|
|
3358
|
+
};
|
|
3359
|
+
await this.appendVersion(root, {
|
|
3360
|
+
...versionRecord,
|
|
3361
|
+
passed: browser.passed
|
|
3362
|
+
});
|
|
3363
|
+
const autoConfirm = (parsed?.ok === true ? parsed.manifest.autoConfirm : true) && restyle === void 0;
|
|
3364
|
+
if (restyle !== void 0 || edits.length > 0) {
|
|
3365
|
+
delete user.restyleTo;
|
|
3366
|
+
user.edits = [];
|
|
3367
|
+
await this.saveUserLayer(root, user);
|
|
3368
|
+
}
|
|
3369
|
+
if (autoConfirm) {
|
|
3370
|
+
await writeAtomic(join(root, STATE_DIR, "current.json"), `${JSON.stringify(versionRecord, null, 2)}\n`);
|
|
3371
|
+
await rm(join(root, STATE_DIR, "candidate.json"), { force: true });
|
|
3372
|
+
await this.log(root, true, `v${version} 通过检查${browser.passed > 0 ? `(${browser.passed} 个用例)` : ""},已进试玩区`);
|
|
3373
|
+
} else {
|
|
3374
|
+
await writeAtomic(join(root, STATE_DIR, "candidate.json"), `${JSON.stringify(versionRecord, null, 2)}\n`);
|
|
3375
|
+
await this.log(root, true, `v${version} 通过检查,等用户确认${restyle === void 0 ? "" : `(换风格:${artStyleLabel(restyle)})`}`);
|
|
3376
|
+
}
|
|
3377
|
+
await this.prune(root);
|
|
3378
|
+
await this.touch(row.id);
|
|
3379
|
+
return {
|
|
3380
|
+
kind: "pass",
|
|
3381
|
+
record,
|
|
3382
|
+
deployed: autoConfirm ? versionRecord : null,
|
|
3383
|
+
candidate: autoConfirm ? null : versionRecord
|
|
3384
|
+
};
|
|
3385
|
+
}
|
|
3386
|
+
async nextVersion(root) {
|
|
3387
|
+
const numbers = (await readdir(join(root, STATE_DIR, "versions")).catch(() => [])).map(Number).filter((n) => Number.isInteger(n) && n > 0);
|
|
3388
|
+
return (numbers.length === 0 ? 0 : Math.max(...numbers)) + 1;
|
|
3389
|
+
}
|
|
3390
|
+
async copyVersion(root, build, version) {
|
|
3391
|
+
const dir = join(root, STATE_DIR, "versions", String(version));
|
|
3392
|
+
for (const f of build) {
|
|
3393
|
+
const target = resolve(dir, f.path);
|
|
3394
|
+
if (!inside(dir, target)) continue;
|
|
3395
|
+
await mkdir(dirname(target), { recursive: true });
|
|
3396
|
+
await writeFile(target, f.data);
|
|
3397
|
+
}
|
|
3398
|
+
}
|
|
3399
|
+
/** Keep the newest versions plus whatever current/candidate point at. */
|
|
3400
|
+
async prune(root) {
|
|
3401
|
+
const versionsDir = join(root, STATE_DIR, "versions");
|
|
3402
|
+
const names = (await readdir(versionsDir).catch(() => [])).map(Number).filter((n) => Number.isInteger(n) && n > 0).sort((a, b) => b - a);
|
|
3403
|
+
const pinned = /* @__PURE__ */ new Set();
|
|
3404
|
+
for (const which of ["current", "candidate"]) {
|
|
3405
|
+
const r = await readJson(join(root, STATE_DIR, `${which}.json`));
|
|
3406
|
+
if (r !== void 0) pinned.add(r.version);
|
|
3407
|
+
}
|
|
3408
|
+
for (const n of names.slice(KEEP_VERSIONS)) {
|
|
3409
|
+
if (pinned.has(n)) continue;
|
|
3410
|
+
await rm(join(versionsDir, String(n)), {
|
|
3411
|
+
recursive: true,
|
|
3412
|
+
force: true
|
|
3413
|
+
});
|
|
3414
|
+
await rm(join(root, STATE_DIR, "shots", `${n}.webp`), { force: true });
|
|
3415
|
+
}
|
|
3416
|
+
}
|
|
3417
|
+
/** Promote the candidate to current (the user's 确认 when auto-confirm is off). */
|
|
3418
|
+
async confirm(projectId, session) {
|
|
3419
|
+
const row = await this.row(projectId);
|
|
3420
|
+
if (row === void 0) return {
|
|
3421
|
+
ok: false,
|
|
3422
|
+
error: "not-found"
|
|
3423
|
+
};
|
|
3424
|
+
if (!this.writable(session)) return {
|
|
3425
|
+
ok: false,
|
|
3426
|
+
error: "denied"
|
|
3427
|
+
};
|
|
3428
|
+
const candidate = await readJson(join(row.root, STATE_DIR, "candidate.json"));
|
|
3429
|
+
if (candidate === void 0) return {
|
|
3430
|
+
ok: false,
|
|
3431
|
+
error: "no-version"
|
|
3432
|
+
};
|
|
3433
|
+
await writeAtomic(join(row.root, STATE_DIR, "current.json"), `${JSON.stringify(candidate, null, 2)}\n`);
|
|
3434
|
+
await rm(join(row.root, STATE_DIR, "candidate.json"), { force: true });
|
|
3435
|
+
await this.log(row.root, true, `用户确认 v${candidate.version},已进试玩区`);
|
|
3436
|
+
await this.touch(projectId);
|
|
3437
|
+
return {
|
|
3438
|
+
ok: true,
|
|
3439
|
+
version: candidate.version
|
|
3440
|
+
};
|
|
3441
|
+
}
|
|
3442
|
+
/**
|
|
3443
|
+
* Turn auto-confirm on or off in `game.json` (the panel's switch). The
|
|
3444
|
+
* manifest is the project's own file, so this goes through node behind the
|
|
3445
|
+
* same writable check.
|
|
3446
|
+
*/
|
|
3447
|
+
async setAutoConfirm(projectId, on, session) {
|
|
3448
|
+
const row = await this.row(projectId);
|
|
3449
|
+
if (row === void 0) return {
|
|
3450
|
+
ok: false,
|
|
3451
|
+
error: "not-found"
|
|
3452
|
+
};
|
|
3453
|
+
if (!this.writable(session)) return {
|
|
3454
|
+
ok: false,
|
|
3455
|
+
error: "denied"
|
|
3456
|
+
};
|
|
3457
|
+
const text = await readText(join(row.root, MANIFEST_FILE));
|
|
3458
|
+
const parsed = text === void 0 ? void 0 : parseManifest(text);
|
|
3459
|
+
if (parsed === void 0 || !parsed.ok) return {
|
|
3460
|
+
ok: false,
|
|
3461
|
+
error: "invalid"
|
|
3462
|
+
};
|
|
3463
|
+
await writeAtomic(join(row.root, MANIFEST_FILE), serializeManifest({
|
|
3464
|
+
...parsed.manifest,
|
|
3465
|
+
autoConfirm: on
|
|
3466
|
+
}));
|
|
3467
|
+
await this.touch(projectId);
|
|
3468
|
+
return { ok: true };
|
|
3469
|
+
}
|
|
3470
|
+
/** Throw the candidate away (the user's 退回); the model hears about it on the next send. */
|
|
3471
|
+
async reject(projectId, reason, session) {
|
|
3472
|
+
const row = await this.row(projectId);
|
|
3473
|
+
if (row === void 0) return {
|
|
3474
|
+
ok: false,
|
|
3475
|
+
error: "not-found"
|
|
3476
|
+
};
|
|
3477
|
+
if (!this.writable(session)) return {
|
|
3478
|
+
ok: false,
|
|
3479
|
+
error: "denied"
|
|
3480
|
+
};
|
|
3481
|
+
const candidate = await readJson(join(row.root, STATE_DIR, "candidate.json"));
|
|
3482
|
+
if (candidate === void 0) return {
|
|
3483
|
+
ok: false,
|
|
3484
|
+
error: "no-version"
|
|
3485
|
+
};
|
|
3486
|
+
await rm(join(row.root, STATE_DIR, "candidate.json"), { force: true });
|
|
3487
|
+
await this.updateVersions(row.root, (list) => list.map((v) => v.version === candidate.version ? {
|
|
3488
|
+
...v,
|
|
3489
|
+
rejected: true
|
|
3490
|
+
} : v));
|
|
3491
|
+
const why = reason.trim().slice(0, 200);
|
|
3492
|
+
await this.log(row.root, true, `用户退回 v${candidate.version}${why === "" ? "" : `:${why}`}`);
|
|
3493
|
+
await this.addPending(row.root, {
|
|
3494
|
+
key: `reject:${candidate.version}`,
|
|
3495
|
+
text: `退回了候选 v${candidate.version}${why === "" ? "" : `(${why})`},试玩区仍是上一版`
|
|
3496
|
+
});
|
|
3497
|
+
await this.touch(projectId);
|
|
3498
|
+
return { ok: true };
|
|
3499
|
+
}
|
|
3500
|
+
/** Put an older version back into the play area. */
|
|
3501
|
+
async rollback(projectId, version, session) {
|
|
3502
|
+
const row = await this.row(projectId);
|
|
3503
|
+
if (row === void 0) return {
|
|
3504
|
+
ok: false,
|
|
3505
|
+
error: "not-found"
|
|
3506
|
+
};
|
|
3507
|
+
if (!this.writable(session)) return {
|
|
3508
|
+
ok: false,
|
|
3509
|
+
error: "denied"
|
|
3510
|
+
};
|
|
3511
|
+
const entry = (await this.readVersions(row.root)).find((v) => v.version === version);
|
|
3512
|
+
const exists = await stat(join(row.root, STATE_DIR, "versions", String(version))).then(() => true, () => false);
|
|
3513
|
+
if (entry === void 0 || !exists) return {
|
|
3514
|
+
ok: false,
|
|
3515
|
+
error: "no-version"
|
|
3516
|
+
};
|
|
3517
|
+
const { passed: _passed, rejected: _rejected, ...record } = entry;
|
|
3518
|
+
await writeAtomic(join(row.root, STATE_DIR, "current.json"), `${JSON.stringify(record, null, 2)}\n`);
|
|
3519
|
+
await this.log(row.root, true, `试玩区切回 v${version}`);
|
|
3520
|
+
await this.addPending(row.root, {
|
|
3521
|
+
key: "rollback",
|
|
3522
|
+
text: `把试玩区切回了 v${version}(工作目录里的代码没动)`
|
|
3523
|
+
});
|
|
3524
|
+
await this.touch(projectId);
|
|
3525
|
+
return { ok: true };
|
|
3526
|
+
}
|
|
3527
|
+
/** Files that differ between two versions' snapshots. */
|
|
3528
|
+
async diff(projectId, from, to) {
|
|
3529
|
+
const row = await this.row(projectId);
|
|
3530
|
+
if (row === void 0) return {
|
|
3531
|
+
ok: false,
|
|
3532
|
+
error: "not-found"
|
|
3533
|
+
};
|
|
3534
|
+
const hashes = async (version) => {
|
|
3535
|
+
const dir = join(row.root, STATE_DIR, "versions", String(version));
|
|
3536
|
+
if (!await stat(dir).then(() => true, () => false)) return void 0;
|
|
3537
|
+
return new Map((await walk(dir, () => true)).map((f) => [f.path, createHash("sha1").update(f.data).digest("hex")]));
|
|
3538
|
+
};
|
|
3539
|
+
const a = await hashes(from), b = await hashes(to);
|
|
3540
|
+
if (a === void 0 || b === void 0) return {
|
|
3541
|
+
ok: false,
|
|
3542
|
+
error: "no-version"
|
|
3543
|
+
};
|
|
3544
|
+
return {
|
|
3545
|
+
ok: true,
|
|
3546
|
+
diff: {
|
|
3547
|
+
added: [...b.keys()].filter((k) => !a.has(k)),
|
|
3548
|
+
removed: [...a.keys()].filter((k) => !b.has(k)),
|
|
3549
|
+
changed: [...b.keys()].filter((k) => a.has(k) && a.get(k) !== b.get(k))
|
|
3550
|
+
}
|
|
3551
|
+
};
|
|
3552
|
+
}
|
|
3553
|
+
async userLayer(root) {
|
|
3554
|
+
const raw = await readJson(join(root, STATE_DIR, "user.json"));
|
|
3555
|
+
return {
|
|
3556
|
+
params: raw?.params !== null && typeof raw?.params === "object" ? { ...raw.params } : {},
|
|
3557
|
+
pending: Array.isArray(raw?.pending) ? raw.pending.filter((p) => typeof p?.key === "string" && typeof p.text === "string" && !p.key.startsWith("param:")) : [],
|
|
3558
|
+
edits: Array.isArray(raw?.edits) ? raw.edits.filter((p) => typeof p?.key === "string" && typeof p.text === "string") : [],
|
|
3559
|
+
...typeof raw?.restyleTo === "string" ? { restyleTo: raw.restyleTo } : {}
|
|
3560
|
+
};
|
|
3561
|
+
}
|
|
3562
|
+
async saveUserLayer(root, user) {
|
|
3563
|
+
await writeAtomic(join(root, STATE_DIR, "user.json"), `${JSON.stringify(user, null, 2)}\n`);
|
|
3564
|
+
}
|
|
3565
|
+
/** Add (or replace by key) one pending change; `text: ''` removes it. */
|
|
3566
|
+
async addPending(root, change) {
|
|
3567
|
+
const user = await this.userLayer(root);
|
|
3568
|
+
user.pending = user.pending.filter((p) => p.key !== change.key);
|
|
3569
|
+
if (change.text !== "") user.pending.push(change);
|
|
3570
|
+
await this.saveUserLayer(root, user);
|
|
3571
|
+
}
|
|
3572
|
+
/** Sessions whose agent turn is running now (the turn hooks keep it). */
|
|
3573
|
+
activeTurns = /* @__PURE__ */ new Set();
|
|
3574
|
+
/** The turn hooks report a bound session's turn starting and ending. */
|
|
3575
|
+
setTurnActive(sessionId, active) {
|
|
3576
|
+
if (active) this.activeTurns.add(sessionId);
|
|
3577
|
+
else this.activeTurns.delete(sessionId);
|
|
3578
|
+
}
|
|
3579
|
+
/**
|
|
3580
|
+
* What the working files hold that the play area does not: build files
|
|
3581
|
+
* added, changed or removed against the waiting candidate (else the live
|
|
3582
|
+
* version), whoever wrote them — the panel, the agent, a CLI member, the
|
|
3583
|
+
* user's own editor. Modification times pick the suspects; only those are
|
|
3584
|
+
* compared byte for byte, so an unchanged project costs a stat walk.
|
|
3585
|
+
*/
|
|
3586
|
+
async changes(projectId) {
|
|
3587
|
+
const row = await this.row(projectId);
|
|
3588
|
+
if (row === void 0) return {
|
|
3589
|
+
ok: false,
|
|
3590
|
+
error: "not-found"
|
|
3591
|
+
};
|
|
3592
|
+
const root = row.root;
|
|
3593
|
+
const state = join(root, STATE_DIR);
|
|
3594
|
+
const base = await readJson(join(state, "candidate.json")) ?? await readJson(join(state, "current.json"));
|
|
3595
|
+
const hasEntry = await stat(join(root, "index.html")).then(() => true, () => false);
|
|
3596
|
+
const working = await statWalk(root, isBuildFile);
|
|
3597
|
+
const files = [];
|
|
3598
|
+
let lastMs = 0;
|
|
3599
|
+
if (base === void 0) for (const f of working) {
|
|
3600
|
+
files.push({
|
|
3601
|
+
path: f.path,
|
|
3602
|
+
kind: "added"
|
|
3603
|
+
});
|
|
3604
|
+
lastMs = Math.max(lastMs, f.mtimeMs);
|
|
3605
|
+
}
|
|
3606
|
+
else {
|
|
3607
|
+
const snapshot = join(state, "versions", String(base.version));
|
|
3608
|
+
const since = Date.parse(base.at) - 6e4;
|
|
3609
|
+
for (const f of working) {
|
|
3610
|
+
if (f.mtimeMs < since) continue;
|
|
3611
|
+
const before = await readFile(join(snapshot, f.path)).catch(() => void 0);
|
|
3612
|
+
if (before === void 0) files.push({
|
|
3613
|
+
path: f.path,
|
|
3614
|
+
kind: "added"
|
|
3615
|
+
});
|
|
3616
|
+
else if (!before.equals(await readFile(join(root, f.path)).catch(() => Buffer.alloc(0)))) files.push({
|
|
3617
|
+
path: f.path,
|
|
3618
|
+
kind: "changed"
|
|
3619
|
+
});
|
|
3620
|
+
else continue;
|
|
3621
|
+
lastMs = Math.max(lastMs, f.mtimeMs);
|
|
3622
|
+
}
|
|
3623
|
+
const present = new Set(working.map((f) => f.path));
|
|
3624
|
+
for (const f of await statWalk(snapshot, () => true)) if (!present.has(f.path)) files.push({
|
|
3625
|
+
path: f.path,
|
|
3626
|
+
kind: "removed"
|
|
3627
|
+
});
|
|
3628
|
+
}
|
|
3629
|
+
const user = await this.userLayer(root);
|
|
3630
|
+
const registry = await this.readRegistry();
|
|
3631
|
+
const turn = Object.entries(registry.bindings).some(([sid, pid]) => pid === projectId && this.activeTurns.has(sid));
|
|
3632
|
+
const lastAt = lastMs > 0 ? new Date(lastMs).toISOString() : null;
|
|
3633
|
+
const busy = turn ? "turn" : lastMs > 0 && Date.now() - lastMs < BUSY_QUIET_MS ? "recent" : null;
|
|
3634
|
+
const last = await readJson(join(state, "checks.json"));
|
|
3635
|
+
const failed = files.length > 0 && last !== void 0 && !last.ok && Date.parse(last.at) >= lastMs ? last.failures.slice(0, 8).map((f) => ({
|
|
3636
|
+
where: f.where,
|
|
3637
|
+
message: f.message.slice(0, 300)
|
|
3638
|
+
})) : null;
|
|
3639
|
+
return {
|
|
3640
|
+
ok: true,
|
|
3641
|
+
against: base?.version ?? null,
|
|
3642
|
+
hasEntry,
|
|
3643
|
+
count: files.length,
|
|
3644
|
+
files: files.slice(0, 30),
|
|
3645
|
+
lastAt,
|
|
3646
|
+
busy,
|
|
3647
|
+
edits: user.edits,
|
|
3648
|
+
failed
|
|
3649
|
+
};
|
|
3650
|
+
}
|
|
3651
|
+
/** Add (or replace by key) one uncommitted panel edit. */
|
|
3652
|
+
async addEdit(root, change) {
|
|
3653
|
+
const user = await this.userLayer(root);
|
|
3654
|
+
user.edits = [...user.edits.filter((p) => p.key !== change.key), change];
|
|
3655
|
+
await this.saveUserLayer(root, user);
|
|
3656
|
+
}
|
|
3657
|
+
/** The agent has read the pending list (`game_read`): it is no longer news. */
|
|
3658
|
+
async clearPending(projectId) {
|
|
3659
|
+
const row = await this.row(projectId);
|
|
3660
|
+
if (row === void 0) return;
|
|
3661
|
+
const user = await this.userLayer(row.root);
|
|
3662
|
+
if (user.pending.length === 0) return;
|
|
3663
|
+
user.pending = [];
|
|
3664
|
+
await this.saveUserLayer(row.root, user);
|
|
3665
|
+
}
|
|
3666
|
+
/** Drop one pending change by key (the dock's ×). */
|
|
3667
|
+
async dropPending(projectId, key, session) {
|
|
3668
|
+
const row = await this.row(projectId);
|
|
3669
|
+
if (row === void 0) return {
|
|
3670
|
+
ok: false,
|
|
3671
|
+
error: "not-found"
|
|
3672
|
+
};
|
|
3673
|
+
if (!this.writable(session)) return {
|
|
3674
|
+
ok: false,
|
|
3675
|
+
error: "denied"
|
|
3676
|
+
};
|
|
3677
|
+
await this.addPending(row.root, {
|
|
3678
|
+
key,
|
|
3679
|
+
text: ""
|
|
3680
|
+
});
|
|
3681
|
+
this.notify(projectId);
|
|
3682
|
+
return { ok: true };
|
|
3683
|
+
}
|
|
3684
|
+
/**
|
|
3685
|
+
* The user sent a message carrying the panel's change list (it starts
|
|
3686
|
+
* with {@link PANEL_CHANGES_MARK}): drop exactly the changes it lists.
|
|
3687
|
+
* Changes made after the list was written stay pending.
|
|
3688
|
+
* @param sessionId - the sending session.
|
|
3689
|
+
* @param text - the user message's text.
|
|
3690
|
+
* @returns how many changes were cleared.
|
|
3691
|
+
*/
|
|
3692
|
+
async clearSent(sessionId, text) {
|
|
3693
|
+
if (!text.includes("【面板改动】")) return 0;
|
|
3694
|
+
const row = await this.boundProject(sessionId);
|
|
3695
|
+
if (row === void 0) return 0;
|
|
3696
|
+
const user = await this.userLayer(row.root);
|
|
3697
|
+
const kept = user.pending.filter((p) => !text.includes(p.text));
|
|
3698
|
+
const cleared = user.pending.length - kept.length;
|
|
3699
|
+
if (cleared === 0) return 0;
|
|
3700
|
+
user.pending = kept;
|
|
3701
|
+
await this.saveUserLayer(row.root, user);
|
|
3702
|
+
this.notify(row.id);
|
|
3703
|
+
return cleared;
|
|
3704
|
+
}
|
|
3705
|
+
/** The manifest of a project, or undefined. */
|
|
3706
|
+
async manifestOf(root) {
|
|
3707
|
+
const text = await readText(join(root, MANIFEST_FILE));
|
|
3708
|
+
const parsed = text === void 0 ? void 0 : parseManifest(text);
|
|
3709
|
+
return parsed?.ok === true ? parsed.manifest : void 0;
|
|
3710
|
+
}
|
|
3711
|
+
/** Set one slider value (user layer; the play frame gets it live from the panel). */
|
|
3712
|
+
async setParam(projectId, key, value, session) {
|
|
3713
|
+
const row = await this.row(projectId);
|
|
3714
|
+
if (row === void 0) return {
|
|
3715
|
+
ok: false,
|
|
3716
|
+
error: "not-found"
|
|
3717
|
+
};
|
|
3718
|
+
if (!this.writable(session)) return {
|
|
3719
|
+
ok: false,
|
|
3720
|
+
error: "denied"
|
|
3721
|
+
};
|
|
3722
|
+
const param = (await this.manifestOf(row.root))?.params.find((p) => p.key === key);
|
|
3723
|
+
if (param === void 0 || !Number.isFinite(value)) return {
|
|
3724
|
+
ok: false,
|
|
3725
|
+
error: "invalid"
|
|
3726
|
+
};
|
|
3727
|
+
const clamped = Math.min(param.max, Math.max(param.min, value));
|
|
3728
|
+
const user = await this.userLayer(row.root);
|
|
3729
|
+
if (clamped === param.value) delete user.params[key];
|
|
3730
|
+
else user.params[key] = clamped;
|
|
3731
|
+
await this.saveUserLayer(row.root, user);
|
|
3732
|
+
this.notify(projectId);
|
|
3733
|
+
return { ok: true };
|
|
3734
|
+
}
|
|
3735
|
+
/** 定稿: write the slider values into game.json as the new defaults. */
|
|
3736
|
+
async finalizeParams(projectId, session) {
|
|
3737
|
+
const row = await this.row(projectId);
|
|
3738
|
+
if (row === void 0) return {
|
|
3739
|
+
ok: false,
|
|
3740
|
+
error: "not-found"
|
|
3741
|
+
};
|
|
3742
|
+
if (!this.writable(session)) return {
|
|
3743
|
+
ok: false,
|
|
3744
|
+
error: "denied"
|
|
3745
|
+
};
|
|
3746
|
+
const manifest = await this.manifestOf(row.root);
|
|
3747
|
+
if (manifest === void 0) return {
|
|
3748
|
+
ok: false,
|
|
3749
|
+
error: "invalid"
|
|
3750
|
+
};
|
|
3751
|
+
const user = await this.userLayer(row.root);
|
|
3752
|
+
const changed = manifest.params.filter((p) => user.params[p.key] !== void 0 && user.params[p.key] !== p.value);
|
|
3753
|
+
if (changed.length === 0) return { ok: true };
|
|
3754
|
+
const params = manifest.params.map((p) => user.params[p.key] === void 0 ? p : {
|
|
3755
|
+
...p,
|
|
3756
|
+
value: user.params[p.key]
|
|
3757
|
+
});
|
|
3758
|
+
await writeAtomic(join(row.root, MANIFEST_FILE), serializeManifest({
|
|
3759
|
+
...manifest,
|
|
3760
|
+
params
|
|
3761
|
+
}));
|
|
3762
|
+
const summary = changed.map((p) => `${p.label} ${num(p.value)}→${num(user.params[p.key])}`).join("、");
|
|
3763
|
+
user.params = {};
|
|
3764
|
+
user.pending = user.pending.filter((p) => !p.key.startsWith("param:"));
|
|
3765
|
+
user.pending.push({
|
|
3766
|
+
key: "params-final",
|
|
3767
|
+
text: `定稿参数:${summary}(已写进 game.json)`
|
|
3768
|
+
});
|
|
3769
|
+
user.edits = [...user.edits.filter((e) => e.key !== "params"), {
|
|
3770
|
+
key: "params",
|
|
3771
|
+
text: `参数定稿 ${summary}`
|
|
3772
|
+
}];
|
|
3773
|
+
await this.saveUserLayer(row.root, user);
|
|
3774
|
+
await this.log(row.root, true, `定稿参数:${summary}`);
|
|
3775
|
+
await this.touch(projectId);
|
|
3776
|
+
return { ok: true };
|
|
3777
|
+
}
|
|
3778
|
+
/** 还原: drop every slider override. */
|
|
3779
|
+
async resetParams(projectId, session) {
|
|
3780
|
+
const row = await this.row(projectId);
|
|
3781
|
+
if (row === void 0) return {
|
|
3782
|
+
ok: false,
|
|
3783
|
+
error: "not-found"
|
|
3784
|
+
};
|
|
3785
|
+
if (!this.writable(session)) return {
|
|
3786
|
+
ok: false,
|
|
3787
|
+
error: "denied"
|
|
3788
|
+
};
|
|
3789
|
+
const user = await this.userLayer(row.root);
|
|
3790
|
+
user.params = {};
|
|
3791
|
+
user.pending = user.pending.filter((p) => !p.key.startsWith("param:"));
|
|
3792
|
+
await this.saveUserLayer(row.root, user);
|
|
3793
|
+
this.notify(projectId);
|
|
3794
|
+
return { ok: true };
|
|
3795
|
+
}
|
|
3796
|
+
/**
|
|
3797
|
+
* Edit one element in the working files — its look, or scalar fields
|
|
3798
|
+
* (`lore`, the 设定 text, included). The play area keeps its version until
|
|
3799
|
+
* the user commits (`commit`); the edit is listed as uncommitted, and as
|
|
3800
|
+
* pending for the agent.
|
|
3801
|
+
*/
|
|
3802
|
+
async editElement(projectId, elementId, patch, session, note) {
|
|
3803
|
+
const row = await this.row(projectId);
|
|
3804
|
+
if (row === void 0) return {
|
|
3805
|
+
ok: false,
|
|
3806
|
+
error: "not-found"
|
|
3807
|
+
};
|
|
3808
|
+
if (!this.writable(session)) return {
|
|
3809
|
+
ok: false,
|
|
3810
|
+
error: "denied"
|
|
3811
|
+
};
|
|
3812
|
+
const files = await walk(join(row.root, "elements"), (p) => p.endsWith(".json"));
|
|
3813
|
+
const changes = {};
|
|
3814
|
+
if (patch.look !== void 0) {
|
|
3815
|
+
const look = (await this.kitFor(row.root)).normalizeLook(patch.look);
|
|
3816
|
+
if (look === null) return {
|
|
3817
|
+
ok: false,
|
|
3818
|
+
error: "invalid"
|
|
3819
|
+
};
|
|
3820
|
+
changes.look = look;
|
|
3821
|
+
}
|
|
3822
|
+
for (const [key, value] of Object.entries(patch.fields ?? {})) {
|
|
3823
|
+
if (key === "id" || key === "look" || key.length > 40) continue;
|
|
3824
|
+
changes[key] = value === null ? void 0 : key === "lore" && typeof value === "string" ? value.slice(0, LORE_MAX) : value;
|
|
3825
|
+
}
|
|
3826
|
+
for (const f of files) {
|
|
3827
|
+
const next = patchElementText(f.data.toString("utf8"), elementId, changes);
|
|
3828
|
+
if (next === void 0) continue;
|
|
3829
|
+
await writeAtomic(join(row.root, "elements", f.path), next);
|
|
3830
|
+
const parsed = JSON.parse(next);
|
|
3831
|
+
const element = (Array.isArray(parsed) ? parsed : [parsed]).find((e) => e.id === elementId);
|
|
3832
|
+
const name = typeof element.name === "string" ? element.name : elementId;
|
|
3833
|
+
if (changes.look !== void 0) {
|
|
3834
|
+
await this.addPending(row.root, {
|
|
3835
|
+
key: `look:${elementId}`,
|
|
3836
|
+
text: note ?? `改了「${name}」的外观(elements 文件里的 look 已更新)`
|
|
3837
|
+
});
|
|
3838
|
+
await this.addEdit(row.root, {
|
|
3839
|
+
key: `look:${elementId}`,
|
|
3840
|
+
text: `${name}的外观`
|
|
3841
|
+
});
|
|
3842
|
+
}
|
|
3843
|
+
if ("lore" in changes) {
|
|
3844
|
+
await this.addPending(row.root, {
|
|
3845
|
+
key: `lore:${elementId}`,
|
|
3846
|
+
text: `改了「${name}」的设定(elements 文件里的 lore),按新设定调整它的行为和文案`
|
|
3847
|
+
});
|
|
3848
|
+
await this.addEdit(row.root, {
|
|
3849
|
+
key: `lore:${elementId}`,
|
|
3850
|
+
text: `${name}的设定`
|
|
3851
|
+
});
|
|
3852
|
+
}
|
|
3853
|
+
const fieldKeys = Object.keys(changes).filter((k) => k !== "look" && k !== "lore");
|
|
3854
|
+
if (fieldKeys.length > 0) {
|
|
3855
|
+
await this.addPending(row.root, {
|
|
3856
|
+
key: `fields:${elementId}`,
|
|
3857
|
+
text: `改了「${name}」的字段:${fieldKeys.map((k) => `${k}=${changes[k] === void 0 ? "(删除)" : String(changes[k])}`).join("、")}`
|
|
3858
|
+
});
|
|
3859
|
+
await this.addEdit(row.root, {
|
|
3860
|
+
key: `fields:${elementId}`,
|
|
3861
|
+
text: `${name}的${fieldKeys.join("、")}`
|
|
3862
|
+
});
|
|
3863
|
+
}
|
|
3864
|
+
await this.log(row.root, true, note ?? `用户编辑了元素「${name}」`);
|
|
3865
|
+
await this.touch(projectId);
|
|
3866
|
+
return {
|
|
3867
|
+
ok: true,
|
|
3868
|
+
element
|
|
3869
|
+
};
|
|
3870
|
+
}
|
|
3871
|
+
return {
|
|
3872
|
+
ok: false,
|
|
3873
|
+
error: "not-found"
|
|
3874
|
+
};
|
|
3875
|
+
}
|
|
3876
|
+
/** Write one design chapter (the user's direct edit). */
|
|
3877
|
+
async writeChapter(projectId, chapterId, text, session) {
|
|
3878
|
+
const row = await this.row(projectId);
|
|
3879
|
+
if (row === void 0) return {
|
|
3880
|
+
ok: false,
|
|
3881
|
+
error: "not-found"
|
|
3882
|
+
};
|
|
3883
|
+
if (!this.writable(session)) return {
|
|
3884
|
+
ok: false,
|
|
3885
|
+
error: "denied"
|
|
3886
|
+
};
|
|
3887
|
+
const spec = CHAPTERS.find((c) => c.id === chapterId);
|
|
3888
|
+
if (spec === void 0 || spec.generated === true) return {
|
|
3889
|
+
ok: false,
|
|
3890
|
+
error: "invalid"
|
|
3891
|
+
};
|
|
3892
|
+
await writeAtomic(join(row.root, spec.file), text.endsWith("\n") ? text : `${text}\n`);
|
|
3893
|
+
await this.addPending(row.root, {
|
|
3894
|
+
key: `design:${chapterId}`,
|
|
3895
|
+
text: `直接改了策划《${spec.title}》(${spec.file}),以文件为准`
|
|
3896
|
+
});
|
|
3897
|
+
await this.touch(projectId);
|
|
3898
|
+
return { ok: true };
|
|
3899
|
+
}
|
|
3900
|
+
/** Edit the one-pager in game.json (pitch, element types, pillars) — the user's direct edit. */
|
|
3901
|
+
async setOnePager(projectId, patch, session) {
|
|
3902
|
+
const row = await this.row(projectId);
|
|
3903
|
+
if (row === void 0) return {
|
|
3904
|
+
ok: false,
|
|
3905
|
+
error: "not-found"
|
|
3906
|
+
};
|
|
3907
|
+
if (!this.writable(session)) return {
|
|
3908
|
+
ok: false,
|
|
3909
|
+
error: "denied"
|
|
3910
|
+
};
|
|
3911
|
+
const manifest = await this.manifestOf(row.root);
|
|
3912
|
+
if (manifest === void 0) return {
|
|
3913
|
+
ok: false,
|
|
3914
|
+
error: "invalid"
|
|
3915
|
+
};
|
|
3916
|
+
const pitch = patch.pitch === void 0 ? manifest.pitch : patch.pitch.trim().slice(0, 400);
|
|
3917
|
+
const elementTypes = patch.elementTypes === void 0 ? manifest.elementTypes : listOf(patch.elementTypes).slice(0, 12);
|
|
3918
|
+
const pillars = patch.pillars === void 0 ? manifest.pillars : listOf(patch.pillars.split(/\n/)).slice(0, 5);
|
|
3919
|
+
if (pitch === "" || elementTypes.length === 0) return {
|
|
3920
|
+
ok: false,
|
|
3921
|
+
error: "invalid"
|
|
3922
|
+
};
|
|
3923
|
+
const parts = [];
|
|
3924
|
+
if (pitch !== manifest.pitch) parts.push(`玩法「${pitch}」`);
|
|
3925
|
+
if (elementTypes.join("、") !== manifest.elementTypes.join("、")) parts.push(`元素类型 ${elementTypes.join("、")}`);
|
|
3926
|
+
if (pillars.join(";") !== manifest.pillars.join(";")) parts.push(`支柱 ${pillars.map((p, i) => `${i + 1}.${p}`).join(" ")}`);
|
|
3927
|
+
if (parts.length === 0) return { ok: true };
|
|
3928
|
+
await writeAtomic(join(row.root, MANIFEST_FILE), serializeManifest({
|
|
3929
|
+
...manifest,
|
|
3930
|
+
pitch,
|
|
3931
|
+
elementTypes,
|
|
3932
|
+
pillars
|
|
3933
|
+
}));
|
|
3934
|
+
await this.addPending(row.root, {
|
|
3935
|
+
key: "onepager",
|
|
3936
|
+
text: `改了一页纸(game.json):${parts.join(";")}`
|
|
3937
|
+
});
|
|
3938
|
+
await this.log(row.root, true, `用户改了一页纸:${parts.join(";")}`);
|
|
3939
|
+
await this.touch(projectId);
|
|
3940
|
+
return { ok: true };
|
|
3941
|
+
}
|
|
3942
|
+
/** Ask for a restyle: the next passing version becomes a candidate. The panel puts the request into the composer. */
|
|
3943
|
+
async requestRestyle(projectId, styleId, session) {
|
|
3944
|
+
const row = await this.row(projectId);
|
|
3945
|
+
if (row === void 0) return {
|
|
3946
|
+
ok: false,
|
|
3947
|
+
error: "not-found"
|
|
3948
|
+
};
|
|
3949
|
+
if (!this.writable(session)) return {
|
|
3950
|
+
ok: false,
|
|
3951
|
+
error: "denied"
|
|
3952
|
+
};
|
|
3953
|
+
const style = resolveArtStyle(styleId);
|
|
3954
|
+
if (style === void 0) return {
|
|
3955
|
+
ok: false,
|
|
3956
|
+
error: "invalid"
|
|
3957
|
+
};
|
|
3958
|
+
const user = await this.userLayer(row.root);
|
|
3959
|
+
user.restyleTo = style;
|
|
3960
|
+
await this.saveUserLayer(row.root, user);
|
|
3961
|
+
this.notify(projectId);
|
|
3962
|
+
return { ok: true };
|
|
3963
|
+
}
|
|
3964
|
+
async proposals(root) {
|
|
3965
|
+
const list = await readJson(join(root, STATE_DIR, "proposals.json"));
|
|
3966
|
+
return Array.isArray(list) ? list.slice(-12).reverse() : [];
|
|
3967
|
+
}
|
|
3968
|
+
/**
|
|
3969
|
+
* Store up to 3 appearance variants for an element (the `game_propose`
|
|
3970
|
+
* tool); the user picks one in the panel.
|
|
3971
|
+
*/
|
|
3972
|
+
async proposeLooks(sessionId, elementId, request, variants) {
|
|
3973
|
+
const row = await this.boundProject(sessionId);
|
|
3974
|
+
if (row === void 0) return {
|
|
3975
|
+
ok: false,
|
|
3976
|
+
error: "本会话还没有游戏项目"
|
|
3977
|
+
};
|
|
3978
|
+
const element = (await this.view(row.id))?.elements.find((e) => e.id === elementId);
|
|
3979
|
+
if (element === void 0) return {
|
|
3980
|
+
ok: false,
|
|
3981
|
+
error: `没有 id 为「${elementId}」的元素`
|
|
3982
|
+
};
|
|
3983
|
+
const clean = [];
|
|
3984
|
+
const bad = [];
|
|
3985
|
+
const kit = await this.kitFor(row.root);
|
|
3986
|
+
variants.slice(0, 3).forEach((v, i) => {
|
|
3987
|
+
const look = kit.normalizeLook(v.look);
|
|
3988
|
+
if (look === null) bad.push(i + 1);
|
|
3989
|
+
else clean.push({
|
|
3990
|
+
label: (v.label ?? "").trim().slice(0, 20) || "ABC"[i],
|
|
3991
|
+
note: (v.note ?? "").trim().slice(0, 120),
|
|
3992
|
+
look
|
|
3993
|
+
});
|
|
3994
|
+
});
|
|
3995
|
+
if (clean.length === 0) return {
|
|
3996
|
+
ok: false,
|
|
3997
|
+
error: `方案都不可用(第 ${bad.join("、")} 个):${kit.lookHint}`
|
|
3998
|
+
};
|
|
3999
|
+
const all = await readJson(join(row.root, STATE_DIR, "proposals.json"));
|
|
4000
|
+
const list = (Array.isArray(all) ? all : []).filter((p) => !(p.elementId === elementId && p.picked === void 0));
|
|
4001
|
+
list.push({
|
|
4002
|
+
elementId,
|
|
4003
|
+
at: (/* @__PURE__ */ new Date()).toISOString(),
|
|
4004
|
+
request: request.trim().slice(0, 200),
|
|
4005
|
+
variants: clean
|
|
4006
|
+
});
|
|
4007
|
+
await writeAtomic(join(row.root, STATE_DIR, "proposals.json"), `${JSON.stringify(list.slice(-20), null, 2)}\n`);
|
|
4008
|
+
await this.touch(row.id);
|
|
4009
|
+
return {
|
|
4010
|
+
ok: true,
|
|
4011
|
+
count: clean.length,
|
|
4012
|
+
name: element.name
|
|
4013
|
+
};
|
|
4014
|
+
}
|
|
4015
|
+
/** The user picks a variant: it replaces the element's look at once; the others stay in the history. */
|
|
4016
|
+
async pickProposal(projectId, elementId, at, index, session) {
|
|
4017
|
+
const row = await this.row(projectId);
|
|
4018
|
+
if (row === void 0) return {
|
|
4019
|
+
ok: false,
|
|
4020
|
+
error: "not-found"
|
|
4021
|
+
};
|
|
4022
|
+
const all = await readJson(join(row.root, STATE_DIR, "proposals.json"));
|
|
4023
|
+
const list = Array.isArray(all) ? all : [];
|
|
4024
|
+
const proposal = list.find((p) => p.elementId === elementId && p.at === at);
|
|
4025
|
+
const variant = proposal?.variants[index];
|
|
4026
|
+
if (proposal === void 0 || variant === void 0) return {
|
|
4027
|
+
ok: false,
|
|
4028
|
+
error: "not-found"
|
|
4029
|
+
};
|
|
4030
|
+
const edited = await this.editElement(projectId, elementId, { look: variant.look }, session, `外观方案:用户选了「${variant.label}」${variant.note === "" ? "" : `(${variant.note})`},已写进元素文件`);
|
|
4031
|
+
if (!edited.ok) return edited;
|
|
4032
|
+
await writeAtomic(join(row.root, STATE_DIR, "proposals.json"), `${JSON.stringify(list.map((p) => p === proposal ? {
|
|
4033
|
+
...p,
|
|
4034
|
+
picked: index
|
|
4035
|
+
} : p), null, 2)}\n`);
|
|
4036
|
+
this.notify(projectId);
|
|
4037
|
+
return { ok: true };
|
|
4038
|
+
}
|
|
4039
|
+
async readVersions(root) {
|
|
4040
|
+
const list = await readJson(join(root, STATE_DIR, "versions.json"));
|
|
4041
|
+
return Array.isArray(list) ? list.filter((v) => typeof v?.version === "number") : [];
|
|
4042
|
+
}
|
|
4043
|
+
async updateVersions(root, change) {
|
|
4044
|
+
await writeAtomic(join(root, STATE_DIR, "versions.json"), `${JSON.stringify(change(await this.readVersions(root)), null, 2)}\n`);
|
|
4045
|
+
}
|
|
4046
|
+
async appendVersion(root, entry) {
|
|
4047
|
+
await this.updateVersions(root, (list) => [...list.filter((v) => v.version !== entry.version), entry].slice(-50));
|
|
4048
|
+
}
|
|
4049
|
+
/** The 版本 view's rows, newest first: only versions whose snapshot still exists. */
|
|
4050
|
+
async versionRows(root) {
|
|
4051
|
+
const current = await readJson(join(root, STATE_DIR, "current.json"));
|
|
4052
|
+
const candidate = await readJson(join(root, STATE_DIR, "candidate.json"));
|
|
4053
|
+
const kept = new Set((await readdir(join(root, STATE_DIR, "versions")).catch(() => [])).map(Number));
|
|
4054
|
+
return (await this.readVersions(root)).filter((v) => kept.has(v.version)).reverse().map((v) => ({
|
|
4055
|
+
version: v.version,
|
|
4056
|
+
at: v.at,
|
|
4057
|
+
fingerprint: v.fingerprint,
|
|
4058
|
+
...v.by === void 0 ? {} : { by: v.by },
|
|
4059
|
+
...v.note === void 0 ? {} : { note: v.note },
|
|
4060
|
+
...v.passed === void 0 ? {} : { passed: v.passed },
|
|
4061
|
+
status: v.version === current?.version ? "current" : v.version === candidate?.version ? "candidate" : v.rejected === true ? "rejected" : "past"
|
|
4062
|
+
}));
|
|
4063
|
+
}
|
|
4064
|
+
/** The last check's screenshot (webp, base64), if any. */
|
|
4065
|
+
async checkShot(projectId, version) {
|
|
4066
|
+
const row = await this.row(projectId);
|
|
4067
|
+
if (row === void 0) return {
|
|
4068
|
+
ok: false,
|
|
4069
|
+
error: "not-found"
|
|
4070
|
+
};
|
|
4071
|
+
const data = (version === void 0 ? void 0 : await readFile(join(row.root, ".game", "shots", `${Math.floor(version)}.webp`)).catch(() => void 0)) ?? (version === void 0 ? await readFile(join(row.root, ".game", "last-check.webp")).catch(() => void 0) : void 0);
|
|
4072
|
+
return data === void 0 ? {
|
|
4073
|
+
ok: false,
|
|
4074
|
+
error: "no-version"
|
|
4075
|
+
} : {
|
|
4076
|
+
ok: true,
|
|
4077
|
+
webp: data.toString("base64")
|
|
4078
|
+
};
|
|
4079
|
+
}
|
|
4080
|
+
/** Append one line to the project's playtest log (`.game/playtest-log.jsonl`). */
|
|
4081
|
+
async log(root, writable, text) {
|
|
4082
|
+
if (!writable) return;
|
|
4083
|
+
const path = join(root, STATE_DIR, "playtest-log.jsonl");
|
|
4084
|
+
await mkdir(dirname(path), { recursive: true });
|
|
4085
|
+
await appendFile(path, `${JSON.stringify({
|
|
4086
|
+
at: (/* @__PURE__ */ new Date()).toISOString(),
|
|
4087
|
+
text
|
|
4088
|
+
})}\n`).catch(() => {});
|
|
4089
|
+
}
|
|
4090
|
+
/** Record what the panel saw during one play session (errors, length). */
|
|
4091
|
+
async logPlay(projectId, entry, session) {
|
|
4092
|
+
const row = await this.row(projectId);
|
|
4093
|
+
if (row === void 0) return {
|
|
4094
|
+
ok: false,
|
|
4095
|
+
error: "not-found"
|
|
4096
|
+
};
|
|
4097
|
+
const seconds = Math.max(0, Math.round(entry.seconds));
|
|
4098
|
+
const errors = entry.errors.slice(0, 3).map((e) => String(e).slice(0, 160));
|
|
4099
|
+
const text = errors.length === 0 ? `试玩 v${entry.version} ${seconds} 秒,无报错` : `试玩 v${entry.version} ${seconds} 秒,报错 ${entry.errors.length} 次:${errors.join(";")}`;
|
|
4100
|
+
await this.log(row.root, this.writable(session), text);
|
|
4101
|
+
this.notify(projectId);
|
|
4102
|
+
return { ok: true };
|
|
4103
|
+
}
|
|
4104
|
+
/** The playtest log, newest first (last 60). */
|
|
4105
|
+
async logEntries(root) {
|
|
4106
|
+
const text = await readText(join(root, STATE_DIR, "playtest-log.jsonl"));
|
|
4107
|
+
if (text === void 0) return [];
|
|
4108
|
+
return text.split("\n").filter(Boolean).map((line) => {
|
|
4109
|
+
try {
|
|
4110
|
+
return JSON.parse(line);
|
|
4111
|
+
} catch {
|
|
4112
|
+
return;
|
|
4113
|
+
}
|
|
4114
|
+
}).filter((r) => r !== void 0 && typeof r.text === "string").slice(-60).reverse();
|
|
4115
|
+
}
|
|
4116
|
+
/** The generated 试玩记录 chapter, newest first. */
|
|
4117
|
+
async playtestText(root) {
|
|
4118
|
+
const rows = (await this.logEntries(root)).slice(0, 40);
|
|
4119
|
+
if (rows.length === 0) return "";
|
|
4120
|
+
const local = (iso) => {
|
|
4121
|
+
const d = new Date(iso);
|
|
4122
|
+
const pad = (n) => String(n).padStart(2, "0");
|
|
4123
|
+
return Number.isNaN(d.getTime()) ? iso : `${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`;
|
|
4124
|
+
};
|
|
4125
|
+
return [
|
|
4126
|
+
"# 试玩记录",
|
|
4127
|
+
"",
|
|
4128
|
+
...rows.map((r) => `- ${local(r.at)} ${r.text}`),
|
|
4129
|
+
""
|
|
4130
|
+
].join("\n");
|
|
4131
|
+
}
|
|
4132
|
+
};
|
|
4133
|
+
//#endregion
|
|
4134
|
+
//#region lib/types/index.js
|
|
4135
|
+
/** Cordis plugin name used by loader diagnostics. */
|
|
4136
|
+
const name = "game";
|
|
4137
|
+
/** The service core writes through the mounted filesystem. */
|
|
4138
|
+
const inject = ["fs"];
|
|
4139
|
+
/**
|
|
4140
|
+
* Plugin body.
|
|
4141
|
+
* @param ctx - owning Cordis Context.
|
|
4142
|
+
* @param config - plugin config.
|
|
4143
|
+
*/
|
|
4144
|
+
function apply(ctx, config = {}) {
|
|
4145
|
+
const store = new GameService(ctx, config);
|
|
4146
|
+
ctx.provide("gameStore", store);
|
|
4147
|
+
ctx.effect(() => () => {
|
|
4148
|
+
store.dispose();
|
|
4149
|
+
}, "game: checker browser");
|
|
4150
|
+
ctx.plugin(GameRemoteService, {});
|
|
4151
|
+
registerTurnHooks(ctx, store);
|
|
4152
|
+
}
|
|
4153
|
+
//#endregion
|
|
4154
|
+
export { ART_STYLES, ART_STYLES_3D, BODY_BASES, CHAPTERS, GameRemoteService, GameService, LOOK_CHARS, LORE_MAX, MANIFEST_FILE, PANEL_CHANGES_MARK, PART_TYPES, STATE_DIR, apply, inject, isPixelLook, name, resolveGameStateRoot };
|