@khorsheed/dsh-game 0.0.0-stage → 0.1.1
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 +25 -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 +30690 -0
- package/lib/index.js +4297 -0
- package/lib/typert.host.d.ts +3 -0
- package/lib/typert.host.js +3876 -0
- package/lib/typert.remote-client.d.ts +104 -0
- package/lib/typert.remote-client.js +2138 -0
- package/lib/types/agent.d.ts +20 -0
- package/lib/types/agent.js +43 -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 +195 -0
- package/lib/types/client/GameTab.d.ts +14 -0
- package/lib/types/client/GameTab.js +261 -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 +163 -0
- package/lib/types/client/Preview3D.d.ts +52 -0
- package/lib/types/client/Preview3D.js +178 -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 +131 -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 +263 -0
- package/lib/types/client/locales.d.ts +379 -0
- package/lib/types/client/locales.js +733 -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 +110 -0
- package/lib/types/client/refs.d.ts +36 -0
- package/lib/types/client/refs.js +44 -0
- package/lib/types/client/reveal.d.ts +43 -0
- package/lib/types/client/reveal.js +73 -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 +138 -0
- package/lib/types/project.js +504 -0
- package/lib/types/remote.d.ts +184 -0
- package/lib/types/remote.js +263 -0
- package/lib/types/skills.d.ts +13 -0
- package/lib/types/skills.js +141 -0
- package/lib/types/store.d.ts +346 -0
- package/lib/types/store.js +1383 -0
- package/lib/types/tools.d.ts +12 -0
- package/lib/types/tools.js +309 -0
- package/lib/types/trash.d.ts +5 -0
- package/lib/types/trash.js +64 -0
- package/lib/types/types.d.ts +548 -0
- package/lib/types/types.js +85 -0
- package/lib/types/vfs.d.ts +94 -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
|
@@ -0,0 +1,1383 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The game service core (`ctx.gameStore`): the project registry, session
|
|
3
|
+
* binding, reading a project for the tools and the panel, the end-of-turn
|
|
4
|
+
* check flow, versions and the playtest log.
|
|
5
|
+
*
|
|
6
|
+
* Storage (proposal §3): a project is a plain directory in the session's
|
|
7
|
+
* workspace — `game.json`, `index.html`, `src/`, `assets/`, `elements/`,
|
|
8
|
+
* `design/`, `tests/` — and the plugin's own state lives inside it under
|
|
9
|
+
* `.game/` (versions, the last check, the playtest log), so the project moves
|
|
10
|
+
* as one folder. Only the registry (which projects exist, which session is
|
|
11
|
+
* bound to which) is deployment-level, at `$DSH_HOME/state/game/`.
|
|
12
|
+
*
|
|
13
|
+
* The agent writes project files with the ordinary fs tools; the plugin
|
|
14
|
+
* writes only the manifest and the first chapter at creation (through the
|
|
15
|
+
* mounted `ctx.fs` with the calling session's policy, so the fence applies)
|
|
16
|
+
* and its own `.game/` state. Version snapshots copy binary assets, which
|
|
17
|
+
* `ctx.fs` cannot write, so they go through node behind the same checks the
|
|
18
|
+
* canvas uses: the session's mode is not read-only and the target sits inside
|
|
19
|
+
* the project.
|
|
20
|
+
* @module @khorsheed/dsh-game
|
|
21
|
+
*/
|
|
22
|
+
import { createHash, randomBytes } from 'node:crypto';
|
|
23
|
+
import { appendFile, mkdir, readdir, readFile, rm, stat, writeFile } from 'node:fs/promises';
|
|
24
|
+
import { dirname, join, relative, resolve, sep } from 'node:path';
|
|
25
|
+
import { ChromeChecker } from "./checks.js";
|
|
26
|
+
import { kitOf, withKitRuntime } from "./kits/index.js";
|
|
27
|
+
import { systemTrash } from "./trash.js";
|
|
28
|
+
import { bundleOf, fingerprintOf, inside, readJson, readText, walk, writeAtomic } from "./fsutil.js";
|
|
29
|
+
import { artStyleLabel, chapterStatus, elementsChapter, gate, loreChapter, isBuildFile, parseManifest, patchElementText, listOf, pillarNotes, resolveArtStyle, serializeManifest, slugOf, validateElements, } from "./project.js";
|
|
30
|
+
import { CHAPTERS, LORE_MAX, MANIFEST_FILE, PANEL_CHANGES_MARK, STATE_DIR, } from "./types.js";
|
|
31
|
+
/** Versions kept under `.game/versions/`; older ones are pruned. */
|
|
32
|
+
const KEEP_VERSIONS = 10;
|
|
33
|
+
/**
|
|
34
|
+
* Resolve the registry root: explicit value, then `$DSH_HOME/state/game`,
|
|
35
|
+
* then `<cwd>/.dsh-game` (the canvas precedent).
|
|
36
|
+
*/
|
|
37
|
+
export function resolveGameStateRoot(configured) {
|
|
38
|
+
if (configured !== undefined && configured !== '')
|
|
39
|
+
return configured;
|
|
40
|
+
const home = process.env.DSH_HOME;
|
|
41
|
+
if (home !== undefined && home !== '')
|
|
42
|
+
return join(home, 'state', 'game');
|
|
43
|
+
return join(process.cwd(), '.dsh-game');
|
|
44
|
+
}
|
|
45
|
+
/** Format a number the way the dock shows it (no float noise). */
|
|
46
|
+
function num(value) {
|
|
47
|
+
return String(Math.round(value * 1000) / 1000);
|
|
48
|
+
}
|
|
49
|
+
/** A change this recent means someone is still writing (a CLI member gives no other sign). */
|
|
50
|
+
const BUSY_QUIET_MS = 20_000;
|
|
51
|
+
/** Build-file paths under a folder with their modification times (no contents read). */
|
|
52
|
+
async function statWalk(root, keep) {
|
|
53
|
+
const out = [];
|
|
54
|
+
const visit = async (dir) => {
|
|
55
|
+
const entries = await readdir(dir, { withFileTypes: true }).catch(() => []);
|
|
56
|
+
for (const entry of entries) {
|
|
57
|
+
if (out.length >= 600 || entry.name.startsWith('.') || entry.name === 'node_modules')
|
|
58
|
+
continue;
|
|
59
|
+
const full = join(dir, entry.name);
|
|
60
|
+
const rel = relative(root, full).split(sep).join('/');
|
|
61
|
+
if (entry.isDirectory()) {
|
|
62
|
+
await visit(full);
|
|
63
|
+
continue;
|
|
64
|
+
}
|
|
65
|
+
if (!entry.isFile() || !keep(rel))
|
|
66
|
+
continue;
|
|
67
|
+
const info = await stat(full).catch(() => undefined);
|
|
68
|
+
if (info !== undefined)
|
|
69
|
+
out.push({ path: rel, mtimeMs: info.mtimeMs });
|
|
70
|
+
}
|
|
71
|
+
};
|
|
72
|
+
await visit(root);
|
|
73
|
+
return out;
|
|
74
|
+
}
|
|
75
|
+
/** The game service core. */
|
|
76
|
+
export class GameService {
|
|
77
|
+
ctx;
|
|
78
|
+
static inject = ['fs'];
|
|
79
|
+
stateRoot;
|
|
80
|
+
sandboxPolicy;
|
|
81
|
+
checker;
|
|
82
|
+
/** Serializes registry read-modify-write cycles. */
|
|
83
|
+
registryLock = Promise.resolve();
|
|
84
|
+
listeners = new Set();
|
|
85
|
+
/**
|
|
86
|
+
* @param ctx - host context (the mounted `ctx.fs`; `sandboxPolicy` probed).
|
|
87
|
+
* @param config - plugin config.
|
|
88
|
+
* @param deps - seams the specs replace.
|
|
89
|
+
*/
|
|
90
|
+
constructor(ctx, config = {}, deps = {}) {
|
|
91
|
+
this.ctx = ctx;
|
|
92
|
+
this.stateRoot = resolveGameStateRoot(config.stateRoot);
|
|
93
|
+
this.sandboxPolicy = ctx.fs?.sandboxMode === undefined ? undefined : ctx.get('sandboxPolicy');
|
|
94
|
+
this.checker = deps.checker ?? new ChromeChecker(config.chromePath);
|
|
95
|
+
if (deps.writer !== undefined)
|
|
96
|
+
this.writeFenced = deps.writer;
|
|
97
|
+
this.trash = deps.trash ?? systemTrash();
|
|
98
|
+
}
|
|
99
|
+
/** Where a deleted project folder goes (the system trash; specs swap a fake). */
|
|
100
|
+
trash;
|
|
101
|
+
/** Release the checker's browser. */
|
|
102
|
+
async dispose() {
|
|
103
|
+
await this.checker.dispose();
|
|
104
|
+
}
|
|
105
|
+
/** Subscribe to project changes (the Remote face forwards nothing yet; specs and hooks use it). */
|
|
106
|
+
subscribe(listener) {
|
|
107
|
+
this.listeners.add(listener);
|
|
108
|
+
return () => { this.listeners.delete(listener); };
|
|
109
|
+
}
|
|
110
|
+
notify(projectId) {
|
|
111
|
+
for (const listener of this.listeners)
|
|
112
|
+
listener(projectId);
|
|
113
|
+
}
|
|
114
|
+
/** Default fenced write: the mounted fs, stamped with the caller's policy. */
|
|
115
|
+
writeFenced = async (path, content, session) => {
|
|
116
|
+
const policy = this.sandboxPolicy?.resolve({ session });
|
|
117
|
+
const target = await this.ctx.fs.resolve(path);
|
|
118
|
+
await this.ctx.fs.writeText(target, content, { kind: 'createIfAbsent' }, undefined, policy);
|
|
119
|
+
};
|
|
120
|
+
/** Whether the session may have the plugin write into its workspace. */
|
|
121
|
+
writable(session) {
|
|
122
|
+
if (session === undefined)
|
|
123
|
+
return true;
|
|
124
|
+
return this.sandboxPolicy?.resolve({ session })?.mode !== 'read-only';
|
|
125
|
+
}
|
|
126
|
+
// ---------------------------------------------------------------- registry
|
|
127
|
+
registryPath() {
|
|
128
|
+
return join(this.stateRoot, 'registry.json');
|
|
129
|
+
}
|
|
130
|
+
async readRegistry() {
|
|
131
|
+
const raw = await readJson(this.registryPath());
|
|
132
|
+
return {
|
|
133
|
+
projects: Array.isArray(raw?.projects) ? raw.projects.filter(p => typeof p?.id === 'string' && typeof p.root === 'string') : [],
|
|
134
|
+
bindings: raw?.bindings !== null && typeof raw?.bindings === 'object' ? { ...raw.bindings } : {},
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
/** Read-modify-write the registry under the lock. */
|
|
138
|
+
async updateRegistry(change) {
|
|
139
|
+
const run = this.registryLock.then(async () => {
|
|
140
|
+
const registry = await this.readRegistry();
|
|
141
|
+
const result = change(registry);
|
|
142
|
+
await writeAtomic(this.registryPath(), `${JSON.stringify(registry, null, 2)}\n`);
|
|
143
|
+
return result;
|
|
144
|
+
});
|
|
145
|
+
this.registryLock = run.catch(() => { });
|
|
146
|
+
return run;
|
|
147
|
+
}
|
|
148
|
+
/** Every registered project, most recently updated first. */
|
|
149
|
+
async listProjects() {
|
|
150
|
+
const { projects } = await this.readRegistry();
|
|
151
|
+
return [...projects].sort((a, b) => b.updatedAt.localeCompare(a.updatedAt));
|
|
152
|
+
}
|
|
153
|
+
/** Element files parsed per id, for turn diffs. */
|
|
154
|
+
async elementIndex(root) {
|
|
155
|
+
const out = new Map();
|
|
156
|
+
for (const f of await walk(join(root, 'elements'), p => p.endsWith('.json'))) {
|
|
157
|
+
let raw;
|
|
158
|
+
try {
|
|
159
|
+
raw = JSON.parse(f.data.toString('utf8'));
|
|
160
|
+
}
|
|
161
|
+
catch {
|
|
162
|
+
continue;
|
|
163
|
+
}
|
|
164
|
+
for (const item of Array.isArray(raw) ? raw : [raw]) {
|
|
165
|
+
if (item === null || typeof item !== 'object')
|
|
166
|
+
continue;
|
|
167
|
+
const r = item;
|
|
168
|
+
if (typeof r.id !== 'string')
|
|
169
|
+
continue;
|
|
170
|
+
const fields = {};
|
|
171
|
+
for (const [k, v] of Object.entries(r))
|
|
172
|
+
if (k !== 'look' && k !== 'id')
|
|
173
|
+
fields[k] = JSON.stringify(v);
|
|
174
|
+
out.set(r.id, { name: typeof r.name === 'string' ? r.name : r.id, json: JSON.stringify(r), look: JSON.stringify(r.look ?? null), fields });
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
return out;
|
|
178
|
+
}
|
|
179
|
+
/** Snapshot the bound project when a turn starts (undefined when unbound). */
|
|
180
|
+
async turnBaseline(sessionId) {
|
|
181
|
+
const row = await this.boundProject(sessionId);
|
|
182
|
+
if (row === undefined)
|
|
183
|
+
return undefined;
|
|
184
|
+
const files = new Map((await walk(row.root, () => true)).map(f => [f.path, createHash('sha1').update(f.data).digest('hex')]));
|
|
185
|
+
const manifest = await this.manifestOf(row.root);
|
|
186
|
+
return {
|
|
187
|
+
at: Date.now(), projectId: row.id, files, elements: await this.elementIndex(row.root),
|
|
188
|
+
params: Object.fromEntries((manifest?.params ?? []).map(p => [p.key, p.value])),
|
|
189
|
+
current: (await readJson(join(row.root, STATE_DIR, 'current.json')))?.version,
|
|
190
|
+
candidate: (await readJson(join(row.root, STATE_DIR, 'candidate.json')))?.version,
|
|
191
|
+
};
|
|
192
|
+
}
|
|
193
|
+
turnsPath(sessionId) {
|
|
194
|
+
return join(this.stateRoot, 'turns', `${sessionId.replace(/[^\w.-]/g, '_')}.json`);
|
|
195
|
+
}
|
|
196
|
+
/**
|
|
197
|
+
* Compare the project with its turn-start snapshot and store what the turn
|
|
198
|
+
* changed. Nothing is stored for a turn that changed nothing.
|
|
199
|
+
*/
|
|
200
|
+
async recordTurn(sessionId, turn, base) {
|
|
201
|
+
const row = await this.row(base.projectId);
|
|
202
|
+
if (row === undefined)
|
|
203
|
+
return undefined;
|
|
204
|
+
const now = new Map((await walk(row.root, () => true)).map(f => [f.path, createHash('sha1').update(f.data).digest('hex')]));
|
|
205
|
+
const changed = [...now.keys()].filter(p => base.files.get(p) !== now.get(p));
|
|
206
|
+
const removed = [...base.files.keys()].filter(p => !now.has(p));
|
|
207
|
+
const elementsNow = await this.elementIndex(row.root);
|
|
208
|
+
const elements = [];
|
|
209
|
+
for (const [id, e] of elementsNow) {
|
|
210
|
+
const before = base.elements.get(id);
|
|
211
|
+
if (before === undefined) {
|
|
212
|
+
elements.push({ id, name: e.name, what: 'new' });
|
|
213
|
+
continue;
|
|
214
|
+
}
|
|
215
|
+
if (before.json === e.json)
|
|
216
|
+
continue;
|
|
217
|
+
const fields = [...new Set([...Object.keys(e.fields), ...Object.keys(before.fields)])].filter(k => e.fields[k] !== before.fields[k]);
|
|
218
|
+
if (before.look !== e.look) {
|
|
219
|
+
// 3D looks ride along (bounded) so the card can show both side by side; pixel looks have their own preview.
|
|
220
|
+
const is3d = /"kind":"(parts3d|gltf)"/.test(e.look);
|
|
221
|
+
const looks = is3d && e.look.length < 8000 && before.look.length < 8000 ? { looks: { before: before.look === 'null' ? null : before.look, after: e.look } } : {};
|
|
222
|
+
elements.push({ id, name: e.name, what: 'look', ...(fields.length > 0 ? { fields } : {}), ...looks });
|
|
223
|
+
}
|
|
224
|
+
else
|
|
225
|
+
elements.push({ id, name: e.name, what: 'fields', fields });
|
|
226
|
+
}
|
|
227
|
+
for (const [id, e] of base.elements)
|
|
228
|
+
if (!elementsNow.has(id))
|
|
229
|
+
elements.push({ id, name: e.name, what: 'removed' });
|
|
230
|
+
const manifest = await this.manifestOf(row.root);
|
|
231
|
+
const params = (manifest?.params ?? []).filter(p => base.params[p.key] !== p.value)
|
|
232
|
+
.map(p => ({ key: p.key, label: p.label, to: p.value, ...(base.params[p.key] === undefined ? {} : { from: base.params[p.key] }) }));
|
|
233
|
+
const chapters = CHAPTERS.filter(c => c.generated !== true && [...changed, ...removed].includes(c.file)).map(c => ({ id: c.id, title: c.title }));
|
|
234
|
+
const code = [...changed, ...removed].filter(p => isBuildFile(p) && !p.startsWith('elements/') && p !== MANIFEST_FILE);
|
|
235
|
+
const tests = changed.filter(p => p.startsWith('tests/'));
|
|
236
|
+
const current = (await readJson(join(row.root, STATE_DIR, 'current.json')))?.version;
|
|
237
|
+
const candidate = (await readJson(join(row.root, STATE_DIR, 'candidate.json')))?.version;
|
|
238
|
+
const version = candidate !== undefined && candidate !== base.candidate ? { kind: 'candidate', version: candidate }
|
|
239
|
+
: current !== undefined && current !== base.current ? { kind: 'deployed', version: current } : null;
|
|
240
|
+
const last = await readJson(join(row.root, STATE_DIR, 'checks.json'));
|
|
241
|
+
const check = last !== undefined && new Date(last.at).getTime() >= base.at
|
|
242
|
+
? { ok: last.ok, fixes: last.attempt, gaveUp: last.gaveUp === true, passed: last.passed, failures: last.failures.length }
|
|
243
|
+
: null;
|
|
244
|
+
if (elements.length + params.length + chapters.length + code.length + tests.length === 0 && version === null && check === null)
|
|
245
|
+
return undefined;
|
|
246
|
+
const report = {
|
|
247
|
+
turn, projectId: row.id, title: manifest?.title ?? row.title, at: new Date().toISOString(),
|
|
248
|
+
version, check, elements, chapters, params, code: code.slice(0, 20), tests,
|
|
249
|
+
};
|
|
250
|
+
const path = this.turnsPath(sessionId);
|
|
251
|
+
const all = (await readJson(path)) ?? {};
|
|
252
|
+
all[String(turn)] = report;
|
|
253
|
+
const keys = Object.keys(all).map(Number).sort((a, b) => b - a);
|
|
254
|
+
for (const k of keys.slice(200))
|
|
255
|
+
delete all[String(k)];
|
|
256
|
+
await writeAtomic(path, `${JSON.stringify(all)}\n`);
|
|
257
|
+
this.notify(row.id);
|
|
258
|
+
return report;
|
|
259
|
+
}
|
|
260
|
+
/** A stored turn report. */
|
|
261
|
+
async turnReport(sessionId, turn) {
|
|
262
|
+
const all = await readJson(this.turnsPath(sessionId));
|
|
263
|
+
return all?.[String(turn)] ?? null;
|
|
264
|
+
}
|
|
265
|
+
/** Every project as a switcher card (manifest summary, live version, last screenshot), most recent first. */
|
|
266
|
+
async projectCards() {
|
|
267
|
+
const cards = [];
|
|
268
|
+
for (const row of await this.listProjects()) {
|
|
269
|
+
const manifest = await this.manifestOf(row.root);
|
|
270
|
+
const current = await readJson(join(row.root, STATE_DIR, 'current.json'));
|
|
271
|
+
const elementFiles = await walk(join(row.root, 'elements'), p => p.endsWith('.json'));
|
|
272
|
+
const { elements } = validateElements(Object.fromEntries(elementFiles.map(f => [`elements/${f.path}`, f.data.toString('utf8')])), [], kitOf(manifest?.runtime));
|
|
273
|
+
const thumb = await readFile(join(row.root, STATE_DIR, 'last-check.webp')).catch(() => undefined);
|
|
274
|
+
cards.push({
|
|
275
|
+
id: row.id, title: manifest?.title ?? row.title, root: row.root, updatedAt: row.updatedAt,
|
|
276
|
+
pitch: manifest?.pitch ?? '', artStyle: manifest?.artStyle ?? '', version: current?.version ?? null, elements: elements.length,
|
|
277
|
+
...(thumb === undefined || thumb.length > 400_000 ? {} : { thumb: thumb.toString('base64') }),
|
|
278
|
+
});
|
|
279
|
+
}
|
|
280
|
+
return cards;
|
|
281
|
+
}
|
|
282
|
+
/** The project a session is bound to, if any. */
|
|
283
|
+
async boundProject(sessionId) {
|
|
284
|
+
const registry = await this.readRegistry();
|
|
285
|
+
const id = registry.bindings[sessionId];
|
|
286
|
+
return id === undefined ? undefined : registry.projects.find(p => p.id === id);
|
|
287
|
+
}
|
|
288
|
+
/** Bind a session to a project (or unbind with null). */
|
|
289
|
+
async bind(sessionId, projectId) {
|
|
290
|
+
return this.updateRegistry(registry => {
|
|
291
|
+
if (projectId === null) {
|
|
292
|
+
delete registry.bindings[sessionId];
|
|
293
|
+
return { ok: true };
|
|
294
|
+
}
|
|
295
|
+
if (!registry.projects.some(p => p.id === projectId))
|
|
296
|
+
return { ok: false, error: 'not-found' };
|
|
297
|
+
registry.bindings[sessionId] = projectId;
|
|
298
|
+
return { ok: true };
|
|
299
|
+
});
|
|
300
|
+
}
|
|
301
|
+
async touch(projectId) {
|
|
302
|
+
await this.updateRegistry(registry => {
|
|
303
|
+
const row = registry.projects.find(p => p.id === projectId);
|
|
304
|
+
if (row !== undefined)
|
|
305
|
+
Object.assign(row, { updatedAt: new Date().toISOString() });
|
|
306
|
+
}).catch(() => { });
|
|
307
|
+
this.notify(projectId);
|
|
308
|
+
}
|
|
309
|
+
/**
|
|
310
|
+
* Register an existing folder that holds a `game.json` (a project copied
|
|
311
|
+
* in, or one whose registry row was lost).
|
|
312
|
+
*/
|
|
313
|
+
async adopt(root) {
|
|
314
|
+
const manifestText = await readText(join(root, MANIFEST_FILE));
|
|
315
|
+
if (manifestText === undefined)
|
|
316
|
+
return { ok: false, error: 'not-found' };
|
|
317
|
+
const parsed = parseManifest(manifestText);
|
|
318
|
+
if (!parsed.ok)
|
|
319
|
+
return { ok: false, error: 'invalid' };
|
|
320
|
+
return this.updateRegistry(registry => {
|
|
321
|
+
const existing = registry.projects.find(p => p.root === root || p.id === parsed.manifest.id);
|
|
322
|
+
if (existing !== undefined)
|
|
323
|
+
return { ok: true, project: existing };
|
|
324
|
+
const now = new Date().toISOString();
|
|
325
|
+
const row = { id: parsed.manifest.id, title: parsed.manifest.title, root, createdAt: parsed.manifest.createdAt || now, updatedAt: now };
|
|
326
|
+
registry.projects.push(row);
|
|
327
|
+
return { ok: true, project: row };
|
|
328
|
+
});
|
|
329
|
+
}
|
|
330
|
+
/**
|
|
331
|
+
* Delete a project from the panel. `trash` takes it out of the plugin
|
|
332
|
+
* (registry row and every session binding) AND moves its folder to the
|
|
333
|
+
* system trash — complete, yet recoverable; `forget` only takes it out of
|
|
334
|
+
* the plugin and leaves the folder (re-adopt with `game_open path`).
|
|
335
|
+
*
|
|
336
|
+
* Guards, checked before anything changes: the folder must still hold the
|
|
337
|
+
* project's own `game.json` (never trash an arbitrary directory a registry
|
|
338
|
+
* row happens to point at), must not be the calling session's workspace
|
|
339
|
+
* itself, and a read-only session may do neither. A failed move changes
|
|
340
|
+
* nothing, so the panel can offer `forget` instead.
|
|
341
|
+
*/
|
|
342
|
+
async deleteProject(projectId, mode, session) {
|
|
343
|
+
const row = await this.row(projectId);
|
|
344
|
+
if (row === undefined)
|
|
345
|
+
return { ok: false, error: 'not-found' };
|
|
346
|
+
if (!this.writable(session))
|
|
347
|
+
return { ok: false, error: 'denied' };
|
|
348
|
+
let trashedTo;
|
|
349
|
+
if (mode === 'trash') {
|
|
350
|
+
const manifest = await this.manifestOf(row.root);
|
|
351
|
+
const cwd = session?.header?.cwd;
|
|
352
|
+
if (manifest === undefined || manifest.id !== projectId)
|
|
353
|
+
return { ok: false, error: 'not-a-project' };
|
|
354
|
+
if (cwd !== undefined && resolve(cwd) === resolve(row.root))
|
|
355
|
+
return { ok: false, error: 'is-workspace' };
|
|
356
|
+
try {
|
|
357
|
+
trashedTo = await this.trash(row.root);
|
|
358
|
+
}
|
|
359
|
+
catch (error) {
|
|
360
|
+
return { ok: false, error: 'trash-failed', reason: error.message.split('\n')[0] ?? '' };
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
await this.updateRegistry(registry => {
|
|
364
|
+
registry.projects = registry.projects.filter(p => p.id !== projectId);
|
|
365
|
+
for (const [sid, bound] of Object.entries(registry.bindings))
|
|
366
|
+
if (bound === projectId)
|
|
367
|
+
delete registry.bindings[sid];
|
|
368
|
+
});
|
|
369
|
+
this.notify(projectId);
|
|
370
|
+
return { ok: true, ...(trashedTo === undefined ? {} : { trashedTo }) };
|
|
371
|
+
}
|
|
372
|
+
// ---------------------------------------------------------------- create
|
|
373
|
+
/**
|
|
374
|
+
* Create a project in the session's workspace behind the light gate, and
|
|
375
|
+
* bind the session to it.
|
|
376
|
+
* @param request - the gate's inputs.
|
|
377
|
+
* @param session - the calling session (its workspace and fence).
|
|
378
|
+
* @returns the new row, the gate's missing items, or a failure.
|
|
379
|
+
*/
|
|
380
|
+
async create(request, session) {
|
|
381
|
+
const verdict = gate(request);
|
|
382
|
+
if (!verdict.ok)
|
|
383
|
+
return { ok: false, missing: verdict.missing };
|
|
384
|
+
const cwd = session.header?.cwd;
|
|
385
|
+
if (cwd === undefined || cwd === '')
|
|
386
|
+
return { ok: false, error: '这个会话没有工作区,项目没处放。请用户先给会话选一个工作区。' };
|
|
387
|
+
if (!this.writable(session))
|
|
388
|
+
return { ok: false, error: '这个会话是只读模式,不能新建项目。' };
|
|
389
|
+
const registry = await this.readRegistry();
|
|
390
|
+
const base = slugOf(verdict.manifest.title);
|
|
391
|
+
let folder = base;
|
|
392
|
+
for (let n = 2; registry.projects.some(p => p.root === join(cwd, folder)) || await stat(join(cwd, folder)).then(() => true, () => false); n++) {
|
|
393
|
+
folder = `${base}-${n}`;
|
|
394
|
+
}
|
|
395
|
+
const root = join(cwd, folder);
|
|
396
|
+
const id = `g_${randomBytes(4).toString('hex')}`;
|
|
397
|
+
const now = new Date().toISOString();
|
|
398
|
+
const manifest = { ...verdict.manifest, id, createdAt: now };
|
|
399
|
+
try {
|
|
400
|
+
await this.writeFenced(join(root, MANIFEST_FILE), serializeManifest(manifest), session);
|
|
401
|
+
}
|
|
402
|
+
catch (error) {
|
|
403
|
+
return { ok: false, error: `写不进工作区:${error.message.split('\n')[0]}` };
|
|
404
|
+
}
|
|
405
|
+
const row = { id, title: manifest.title, root, createdAt: now, updatedAt: now };
|
|
406
|
+
await this.updateRegistry(r => {
|
|
407
|
+
r.projects.push(row);
|
|
408
|
+
r.bindings[session.id] = id;
|
|
409
|
+
});
|
|
410
|
+
this.notify(id);
|
|
411
|
+
return { ok: true, project: row };
|
|
412
|
+
}
|
|
413
|
+
// ---------------------------------------------------------------- read
|
|
414
|
+
async row(projectId) {
|
|
415
|
+
return (await this.readRegistry()).projects.find(p => p.id === projectId);
|
|
416
|
+
}
|
|
417
|
+
/**
|
|
418
|
+
* Everything the panel and `game_read` show about one project.
|
|
419
|
+
* @param projectId - registry id.
|
|
420
|
+
* @returns the view, or undefined for an unknown id.
|
|
421
|
+
*/
|
|
422
|
+
async view(projectId) {
|
|
423
|
+
const row = await this.row(projectId);
|
|
424
|
+
if (row === undefined)
|
|
425
|
+
return undefined;
|
|
426
|
+
const manifestText = await readText(join(row.root, MANIFEST_FILE));
|
|
427
|
+
const parsed = manifestText === undefined ? undefined : parseManifest(manifestText);
|
|
428
|
+
const manifest = parsed?.ok === true ? parsed.manifest : null;
|
|
429
|
+
const elementFiles = await walk(join(row.root, 'elements'), p => p.endsWith('.json'));
|
|
430
|
+
const { elements, failures } = validateElements(Object.fromEntries(elementFiles.map(f => [`elements/${f.path}`, f.data.toString('utf8')])), manifest?.elementTypes ?? [], kitOf(manifest?.runtime));
|
|
431
|
+
const playtest = await this.playtestText(row.root);
|
|
432
|
+
const chapters = [];
|
|
433
|
+
for (const spec of CHAPTERS) {
|
|
434
|
+
const text = spec.id === 'elements'
|
|
435
|
+
? elementsChapter(elements, manifest?.elementTypes ?? [])
|
|
436
|
+
: spec.id === 'lore' ? loreChapter(elements, manifest?.elementTypes ?? [])
|
|
437
|
+
: spec.id === 'playtest' ? playtest
|
|
438
|
+
: spec.id === 'pillars' ? pillarNotes((await readText(join(row.root, spec.file))) ?? '', manifest)
|
|
439
|
+
: (await readText(join(row.root, spec.file))) ?? '';
|
|
440
|
+
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);
|
|
441
|
+
const mtime = await stat(source).then(info => info.mtimeMs, () => 0);
|
|
442
|
+
chapters.push({
|
|
443
|
+
id: spec.id, title: spec.title, file: spec.file, text, generated: spec.generated === true, mtime,
|
|
444
|
+
status: spec.generated === true ? (text === '' ? 'todo' : 'draft') : chapterStatus(text),
|
|
445
|
+
});
|
|
446
|
+
}
|
|
447
|
+
const state = join(row.root, STATE_DIR);
|
|
448
|
+
const user = await this.userLayer(row.root);
|
|
449
|
+
const params = manifest?.params ?? [];
|
|
450
|
+
const paramOverrides = Object.fromEntries(Object.entries(user.params).filter(([key, value]) => {
|
|
451
|
+
const param = params.find(p => p.key === key);
|
|
452
|
+
return param !== undefined && param.value !== value;
|
|
453
|
+
}));
|
|
454
|
+
return {
|
|
455
|
+
id: row.id,
|
|
456
|
+
title: manifest?.title ?? row.title,
|
|
457
|
+
root: row.root,
|
|
458
|
+
manifest,
|
|
459
|
+
...(manifestText === undefined ? { manifestError: `${MANIFEST_FILE} 不见了` } : parsed?.ok === false ? { manifestError: parsed.error } : {}),
|
|
460
|
+
chapters,
|
|
461
|
+
// Each element carries its file's last change, so the panel can list the newest first.
|
|
462
|
+
elements: await Promise.all(elements.map(async (e) => {
|
|
463
|
+
const info = await stat(join(row.root, e.file)).catch(() => undefined);
|
|
464
|
+
return info === undefined ? e : { ...e, updatedAt: info.mtimeMs };
|
|
465
|
+
})),
|
|
466
|
+
elementErrors: failures,
|
|
467
|
+
current: (await readJson(join(state, 'current.json'))) ?? null,
|
|
468
|
+
candidate: (await readJson(join(state, 'candidate.json'))) ?? null,
|
|
469
|
+
lastCheck: (await readJson(join(state, 'checks.json'))) ?? null,
|
|
470
|
+
hasEntry: await stat(join(row.root, 'index.html')).then(() => true, () => false),
|
|
471
|
+
paramOverrides,
|
|
472
|
+
pending: user.pending,
|
|
473
|
+
uncommitted: user.edits,
|
|
474
|
+
proposals: await this.proposals(row.root),
|
|
475
|
+
versions: await this.versionRows(row.root),
|
|
476
|
+
log: await this.logEntries(row.root),
|
|
477
|
+
...(user.restyleTo === undefined ? {} : { restyleTo: user.restyleTo }),
|
|
478
|
+
candidateDiff: await this.candidateDiff(row.root),
|
|
479
|
+
};
|
|
480
|
+
}
|
|
481
|
+
/** Elements the waiting candidate changed or added against the live version (null without both). */
|
|
482
|
+
async candidateDiff(root) {
|
|
483
|
+
const current = await readJson(join(root, STATE_DIR, 'current.json'));
|
|
484
|
+
const candidate = await readJson(join(root, STATE_DIR, 'candidate.json'));
|
|
485
|
+
if (current === undefined || candidate === undefined)
|
|
486
|
+
return null;
|
|
487
|
+
const before = await this.elementIndex(join(root, STATE_DIR, 'versions', String(current.version)));
|
|
488
|
+
const after = await this.elementIndex(join(root, STATE_DIR, 'versions', String(candidate.version)));
|
|
489
|
+
const changed = [], added = [];
|
|
490
|
+
for (const [id, e] of after) {
|
|
491
|
+
const was = before.get(id);
|
|
492
|
+
if (was === undefined)
|
|
493
|
+
added.push(id);
|
|
494
|
+
else if (was.json !== e.json)
|
|
495
|
+
changed.push(id);
|
|
496
|
+
}
|
|
497
|
+
return { changed, added };
|
|
498
|
+
}
|
|
499
|
+
/** The tab's state for one session. */
|
|
500
|
+
/**
|
|
501
|
+
* The bound project's newest version (the waiting candidate, else the live
|
|
502
|
+
* one), read from two small files: the page polls it to announce a version
|
|
503
|
+
* the moment it lands, whoever's turn cut it. Version 0 means none yet.
|
|
504
|
+
*/
|
|
505
|
+
async liveVersion(sessionId) {
|
|
506
|
+
const row = await this.boundProject(sessionId);
|
|
507
|
+
if (row === undefined)
|
|
508
|
+
return null;
|
|
509
|
+
const state = join(row.root, STATE_DIR);
|
|
510
|
+
const candidate = await readJson(join(state, 'candidate.json'));
|
|
511
|
+
const record = candidate ?? (await readJson(join(state, 'current.json')));
|
|
512
|
+
const show = this.shows.get(row.id);
|
|
513
|
+
const extra = show === undefined ? {} : { show };
|
|
514
|
+
if (record === undefined)
|
|
515
|
+
return { projectId: row.id, version: 0, kind: 'none', at: null, ...extra };
|
|
516
|
+
return { projectId: row.id, version: record.version, kind: candidate !== undefined ? 'candidate' : 'deployed', at: record.at, ...(record.by === undefined ? {} : { by: record.by }), ...extra };
|
|
517
|
+
}
|
|
518
|
+
/** The agent's latest 「请用户看这里」 per project (in memory: a request is for the moment, not history). */
|
|
519
|
+
shows = new Map();
|
|
520
|
+
showSeq = 0;
|
|
521
|
+
/**
|
|
522
|
+
* After a passing `game_publish`: leave the pages showing this project a
|
|
523
|
+
* request to take their game tab to the result. `show` is the agent's choice
|
|
524
|
+
* (play, versions, elements, elements:<id or name>, design:<chapter>); left
|
|
525
|
+
* out — or not found — the version decides: the one element it changed, the
|
|
526
|
+
* grid when several, else the play area (a candidate always opens play,
|
|
527
|
+
* where it waits for the user's OK). Pages pick it up on their poll.
|
|
528
|
+
* @returns a line for the tool's answer.
|
|
529
|
+
*/
|
|
530
|
+
async showAfterPublish(sessionId, version, candidate, show, note) {
|
|
531
|
+
const row = await this.boundProject(sessionId);
|
|
532
|
+
const project = row === undefined ? undefined : await this.view(row.id);
|
|
533
|
+
if (row === undefined || project === undefined)
|
|
534
|
+
return '';
|
|
535
|
+
let target = null;
|
|
536
|
+
let refused = '';
|
|
537
|
+
const asked = (show ?? '').trim();
|
|
538
|
+
if (asked !== '') {
|
|
539
|
+
const [view, ...rest] = asked.split(':');
|
|
540
|
+
const name = rest.join(':').trim();
|
|
541
|
+
if (view === 'play' || view === 'versions' || (view === 'elements' && name === ''))
|
|
542
|
+
target = { view };
|
|
543
|
+
else if (view === 'elements') {
|
|
544
|
+
const e = project.elements.find(x => x.id === name || x.name === name);
|
|
545
|
+
if (e !== undefined)
|
|
546
|
+
target = { view, elementId: e.id };
|
|
547
|
+
}
|
|
548
|
+
else if (view === 'design') {
|
|
549
|
+
const c = project.chapters.find(x => x.id === name || x.title === name);
|
|
550
|
+
if (c !== undefined)
|
|
551
|
+
target = { view, chapterId: c.id };
|
|
552
|
+
}
|
|
553
|
+
if (target === null)
|
|
554
|
+
refused = `(show「${asked}」找不到,改用默认位置)`;
|
|
555
|
+
}
|
|
556
|
+
let changedNames = [];
|
|
557
|
+
if (target === null) {
|
|
558
|
+
const decided = candidate ? { target: { view: 'play' }, names: [] } : await this.changedBy(row.root, version);
|
|
559
|
+
target = decided.target;
|
|
560
|
+
changedNames = decided.names;
|
|
561
|
+
}
|
|
562
|
+
const words = (note ?? '').trim();
|
|
563
|
+
const text = words !== '' ? words
|
|
564
|
+
: candidate ? `v${version} 等你确认上线`
|
|
565
|
+
: changedNames.length > 0 ? `v${version}:改了${changedNames.slice(0, 3).join('、')}${changedNames.length > 3 ? ` 等 ${changedNames.length} 个` : ''}`
|
|
566
|
+
: `v${version} 已上线`;
|
|
567
|
+
this.shows.set(row.id, { seq: ++this.showSeq, target, note: text.slice(0, 120), at: new Date().toISOString() });
|
|
568
|
+
this.notify(row.id);
|
|
569
|
+
const where = target.view === 'play' ? '试玩' : target.view === 'versions' ? '版本' : target.view === 'design' ? `策划·${target.chapterId}` : target.elementId === undefined ? '元素列表' : `元素「${target.elementId}」`;
|
|
570
|
+
return `右栏会打开:${where}${refused}。`;
|
|
571
|
+
}
|
|
572
|
+
/** Where a version's change is: the one element it changed, the grid for several, else play (code, assets, a first version). */
|
|
573
|
+
async changedBy(root, version) {
|
|
574
|
+
const earlier = (await this.readVersions(root)).map(v => v.version).filter(v => v < version);
|
|
575
|
+
const play = { target: { view: 'play' }, names: [] };
|
|
576
|
+
if (earlier.length === 0)
|
|
577
|
+
return play;
|
|
578
|
+
const prev = Math.max(...earlier);
|
|
579
|
+
const dir = (v) => join(root, STATE_DIR, 'versions', String(v));
|
|
580
|
+
if (!await stat(dir(prev)).then(() => true, () => false))
|
|
581
|
+
return play;
|
|
582
|
+
const [before, after] = await Promise.all([this.elementIndex(dir(prev)), this.elementIndex(dir(version))]);
|
|
583
|
+
const changed = [...after].filter(([id, e]) => before.get(id)?.json !== e.json);
|
|
584
|
+
const others = async (v) => new Map((await walk(dir(v), p => !p.startsWith('elements/'))).map(f => [f.path, f.data.toString('base64')]));
|
|
585
|
+
const [a, b] = await Promise.all([others(prev), others(version)]);
|
|
586
|
+
const otherChanged = a.size !== b.size || [...b].some(([p, d]) => a.get(p) !== d);
|
|
587
|
+
const names = changed.map(([, e]) => e.name);
|
|
588
|
+
if (otherChanged || changed.length === 0)
|
|
589
|
+
return { target: { view: 'play' }, names };
|
|
590
|
+
if (changed.length === 1)
|
|
591
|
+
return { target: { view: 'elements', elementId: changed[0][0] }, names };
|
|
592
|
+
return { target: { view: 'elements' }, names };
|
|
593
|
+
}
|
|
594
|
+
async sessionState(sessionId) {
|
|
595
|
+
const bound = await this.boundProject(sessionId);
|
|
596
|
+
return {
|
|
597
|
+
bound: bound === undefined ? null : (await this.view(bound.id)) ?? null,
|
|
598
|
+
projects: await this.listProjects(),
|
|
599
|
+
};
|
|
600
|
+
}
|
|
601
|
+
/**
|
|
602
|
+
* One build file as it is in the working folder now (a model the user just
|
|
603
|
+
* exported, say), for a panel preview ahead of any version. Build files only,
|
|
604
|
+
* inside the project, 8 MB at most.
|
|
605
|
+
*/
|
|
606
|
+
async workingFile(projectId, path) {
|
|
607
|
+
const row = await this.row(projectId);
|
|
608
|
+
if (row === undefined)
|
|
609
|
+
return { ok: false, error: 'not-found' };
|
|
610
|
+
const rel = path.replace(/^\.\//, '');
|
|
611
|
+
const full = resolve(row.root, rel);
|
|
612
|
+
if (!isBuildFile(rel) || !inside(row.root, full))
|
|
613
|
+
return { ok: false, error: 'invalid' };
|
|
614
|
+
const info = await stat(full).catch(() => undefined);
|
|
615
|
+
if (info === undefined || !info.isFile() || info.size > 8 * 1024 * 1024)
|
|
616
|
+
return { ok: false, error: 'not-found' };
|
|
617
|
+
const data = await readFile(full);
|
|
618
|
+
return { ok: true, file: bundleOf([{ path: rel, data }])[rel] };
|
|
619
|
+
}
|
|
620
|
+
/** The files of a deployed (or candidate) version, for the play area. */
|
|
621
|
+
async bundle(projectId, which = 'current') {
|
|
622
|
+
const row = await this.row(projectId);
|
|
623
|
+
if (row === undefined)
|
|
624
|
+
return { ok: false, error: 'not-found' };
|
|
625
|
+
const record = await readJson(join(row.root, STATE_DIR, `${which}.json`));
|
|
626
|
+
if (record === undefined)
|
|
627
|
+
return { ok: false, error: 'no-version' };
|
|
628
|
+
const files = await walk(join(row.root, STATE_DIR, 'versions', String(record.version)), () => true);
|
|
629
|
+
if (files.length === 0)
|
|
630
|
+
return { ok: false, error: 'no-version' };
|
|
631
|
+
return { ok: true, bundle: { projectId, version: record.version, files: withKitRuntime(bundleOf(files), this.snapshotKit(files)) } };
|
|
632
|
+
}
|
|
633
|
+
/** One specific version's files. */
|
|
634
|
+
async versionBundle(projectId, version) {
|
|
635
|
+
const row = await this.row(projectId);
|
|
636
|
+
if (row === undefined)
|
|
637
|
+
return { ok: false, error: 'not-found' };
|
|
638
|
+
const files = await walk(join(row.root, STATE_DIR, 'versions', String(Math.floor(version))), () => true);
|
|
639
|
+
if (files.length === 0)
|
|
640
|
+
return { ok: false, error: 'no-version' };
|
|
641
|
+
return { ok: true, bundle: { projectId, version, files: withKitRuntime(bundleOf(files), this.snapshotKit(files)) } };
|
|
642
|
+
}
|
|
643
|
+
/** The kit a snapshot was built for: its own `game.json`'s runtime. */
|
|
644
|
+
snapshotKit(files) {
|
|
645
|
+
const manifest = files.find(f => f.path === MANIFEST_FILE);
|
|
646
|
+
const parsed = manifest === undefined ? undefined : parseManifest(manifest.data.toString('utf8'));
|
|
647
|
+
return kitOf(parsed?.ok === true ? parsed.manifest.runtime : undefined);
|
|
648
|
+
}
|
|
649
|
+
/** The kit a project runs on now (its working `game.json`). */
|
|
650
|
+
async kitFor(root) {
|
|
651
|
+
return kitOf((await this.manifestOf(root))?.runtime);
|
|
652
|
+
}
|
|
653
|
+
// ---------------------------------------------------------------- checks
|
|
654
|
+
/** The build and test files as they are on disk now, plus their fingerprint. */
|
|
655
|
+
async snapshot(root) {
|
|
656
|
+
const build = await walk(root, isBuildFile);
|
|
657
|
+
const tests = await walk(join(root, 'tests'), p => p.endsWith('.test.js'));
|
|
658
|
+
return { build, tests, fingerprint: fingerprintOf([...build, ...tests.map(t => ({ path: `tests/${t.path}`, data: t.data }))]) };
|
|
659
|
+
}
|
|
660
|
+
/**
|
|
661
|
+
* The end-of-turn evaluation for a bound session: skip when nothing
|
|
662
|
+
* playable changed since the last check, else run the checks, persist the
|
|
663
|
+
* record, and on a pass deploy (auto-confirm) or leave a candidate.
|
|
664
|
+
* @param session - the session whose turn is closing.
|
|
665
|
+
* @param attempt - which automatic fix round this is (0 = the turn's own work).
|
|
666
|
+
* @param cap - the fix-round cap (a failure at the cap is recorded as given up).
|
|
667
|
+
* @returns what the hook should do.
|
|
668
|
+
*/
|
|
669
|
+
async checkTurn(session, attempt, cap, by = 'turn') {
|
|
670
|
+
const row = await this.boundProject(session.id);
|
|
671
|
+
if (row === undefined)
|
|
672
|
+
return { kind: 'skip' };
|
|
673
|
+
return this.checkRow(row, session, attempt, cap, by);
|
|
674
|
+
}
|
|
675
|
+
/**
|
|
676
|
+
* 提交为新版本: the user commits the panel edits sitting in the working
|
|
677
|
+
* files. Runs the same checks as the end of a turn (no fix cap — nothing
|
|
678
|
+
* is steered); a pass cuts the version, whose note lists the edits.
|
|
679
|
+
*/
|
|
680
|
+
async commit(projectId, session) {
|
|
681
|
+
const row = await this.row(projectId);
|
|
682
|
+
if (row === undefined)
|
|
683
|
+
return { ok: false, error: 'not-found' };
|
|
684
|
+
if (!this.writable(session))
|
|
685
|
+
return { ok: false, error: 'denied' };
|
|
686
|
+
const outcome = await this.checkRow(row, session, 0, Number.POSITIVE_INFINITY, 'user');
|
|
687
|
+
this.notify(projectId);
|
|
688
|
+
if (outcome.kind === 'fail')
|
|
689
|
+
return { ok: true, kind: 'failed', failures: outcome.record.failures.slice(0, 8).map(f => ({ where: f.where, message: f.message.slice(0, 300) })) };
|
|
690
|
+
if (outcome.kind === 'pass') {
|
|
691
|
+
if (outcome.deployed !== null)
|
|
692
|
+
return { ok: true, kind: 'deployed', version: outcome.deployed.version };
|
|
693
|
+
if (outcome.candidate !== null)
|
|
694
|
+
return { ok: true, kind: 'candidate', version: outcome.candidate.version };
|
|
695
|
+
return { ok: false, error: 'denied' };
|
|
696
|
+
}
|
|
697
|
+
if (!await stat(join(row.root, 'index.html')).then(() => true, () => false))
|
|
698
|
+
return { ok: true, kind: 'no-entry' };
|
|
699
|
+
// Same build as the last check: a passed one already carries the edits (edited back, say); a given-up one still fails.
|
|
700
|
+
const last = await readJson(join(row.root, STATE_DIR, 'checks.json'));
|
|
701
|
+
if (last !== undefined && !last.ok)
|
|
702
|
+
return { ok: true, kind: 'failed', failures: last.failures.slice(0, 8).map(f => ({ where: f.where, message: f.message.slice(0, 300) })) };
|
|
703
|
+
const user = await this.userLayer(row.root);
|
|
704
|
+
user.edits = [];
|
|
705
|
+
await this.saveUserLayer(row.root, user);
|
|
706
|
+
return { ok: true, kind: 'unchanged' };
|
|
707
|
+
}
|
|
708
|
+
async checkRow(row, session, attempt, cap, by) {
|
|
709
|
+
const root = row.root;
|
|
710
|
+
if (!await stat(join(root, 'index.html')).then(() => true, () => false))
|
|
711
|
+
return { kind: 'skip' };
|
|
712
|
+
const { build, tests, fingerprint } = await this.snapshot(root);
|
|
713
|
+
const last = await readJson(join(root, STATE_DIR, 'checks.json'));
|
|
714
|
+
if (last?.fingerprint === fingerprint && (last.ok || last.gaveUp === true))
|
|
715
|
+
return { kind: 'skip' };
|
|
716
|
+
const failures = [];
|
|
717
|
+
const manifestText = await readText(join(root, MANIFEST_FILE));
|
|
718
|
+
const parsed = manifestText === undefined ? undefined : parseManifest(manifestText);
|
|
719
|
+
if (parsed === undefined)
|
|
720
|
+
failures.push({ where: 'data', message: `${MANIFEST_FILE} 不见了` });
|
|
721
|
+
else if (!parsed.ok)
|
|
722
|
+
failures.push({ where: 'data', message: parsed.error });
|
|
723
|
+
const kit = kitOf(parsed?.ok === true ? parsed.manifest.runtime : undefined);
|
|
724
|
+
const elementFiles = build.filter(f => f.path.startsWith('elements/') && f.path.endsWith('.json'));
|
|
725
|
+
const validated = validateElements(Object.fromEntries(elementFiles.map(f => [f.path, f.data.toString('utf8')])), parsed?.ok === true ? parsed.manifest.elementTypes : [], kit);
|
|
726
|
+
failures.push(...validated.failures);
|
|
727
|
+
// A model look must point at a file the build carries.
|
|
728
|
+
const shipped = new Set(build.map(f => f.path));
|
|
729
|
+
for (const e of validated.elements) {
|
|
730
|
+
if (e.look3d?.kind === 'gltf' && !shipped.has(e.look3d.src)) {
|
|
731
|
+
failures.push({ where: e.file, message: `「${e.name}」的外观引用了 ${e.look3d.src},项目里没有这个文件(放进 assets/ 再引用)` });
|
|
732
|
+
}
|
|
733
|
+
}
|
|
734
|
+
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 });
|
|
735
|
+
failures.push(...browser.failures);
|
|
736
|
+
const ok = failures.length === 0;
|
|
737
|
+
const record = {
|
|
738
|
+
fingerprint, at: new Date().toISOString(), ok, failures, passed: browser.passed, attempt,
|
|
739
|
+
...(browser.skipped === undefined ? {} : { skipped: browser.skipped }),
|
|
740
|
+
...(!ok && attempt >= cap ? { gaveUp: true } : {}),
|
|
741
|
+
};
|
|
742
|
+
const writable = this.writable(session);
|
|
743
|
+
if (writable) {
|
|
744
|
+
await writeAtomic(join(root, STATE_DIR, 'checks.json'), `${JSON.stringify(record, null, 2)}\n`);
|
|
745
|
+
if (browser.screenshot !== undefined)
|
|
746
|
+
await writeAtomic(join(root, STATE_DIR, 'last-check.webp'), Buffer.from(browser.screenshot, 'base64'));
|
|
747
|
+
}
|
|
748
|
+
if (!ok) {
|
|
749
|
+
if (record.gaveUp === true)
|
|
750
|
+
await this.log(root, writable, `检查仍未通过,已停止自动修复(${failures.length} 项):${failures.slice(0, 2).map(f => f.where).join('、')}`);
|
|
751
|
+
await this.touch(row.id);
|
|
752
|
+
return { kind: 'fail', record };
|
|
753
|
+
}
|
|
754
|
+
if (!writable)
|
|
755
|
+
return { kind: 'pass', record, deployed: null, candidate: null };
|
|
756
|
+
const version = await this.nextVersion(root);
|
|
757
|
+
await this.copyVersion(root, build, version);
|
|
758
|
+
// Each version keeps its own poster: the play area shows the version it plays, never a later or failed build.
|
|
759
|
+
if (browser.screenshot !== undefined)
|
|
760
|
+
await writeAtomic(join(root, STATE_DIR, 'shots', `${version}.webp`), Buffer.from(browser.screenshot, 'base64'));
|
|
761
|
+
const user = await this.userLayer(root);
|
|
762
|
+
const restyle = user.restyleTo;
|
|
763
|
+
// Every version carries whatever panel edits sit in the working files, whoever cut it.
|
|
764
|
+
const edits = user.edits.map(e => e.text);
|
|
765
|
+
const versionRecord = {
|
|
766
|
+
version, at: record.at, fingerprint, by,
|
|
767
|
+
note: restyle !== undefined ? `换成「${artStyleLabel(restyle)}」风格`
|
|
768
|
+
: edits.length > 0 ? `面板修改:${edits.join('、')}`
|
|
769
|
+
: browser.passed > 0 ? `通过检查(${browser.passed} 个用例)` : '通过检查',
|
|
770
|
+
};
|
|
771
|
+
await this.appendVersion(root, { ...versionRecord, passed: browser.passed });
|
|
772
|
+
// A restyle always lands as a candidate, whatever auto-confirm says.
|
|
773
|
+
const autoConfirm = (parsed?.ok === true ? parsed.manifest.autoConfirm : true) && restyle === undefined;
|
|
774
|
+
if (restyle !== undefined || edits.length > 0) {
|
|
775
|
+
delete user.restyleTo;
|
|
776
|
+
user.edits = [];
|
|
777
|
+
await this.saveUserLayer(root, user);
|
|
778
|
+
}
|
|
779
|
+
if (autoConfirm) {
|
|
780
|
+
await writeAtomic(join(root, STATE_DIR, 'current.json'), `${JSON.stringify(versionRecord, null, 2)}\n`);
|
|
781
|
+
await rm(join(root, STATE_DIR, 'candidate.json'), { force: true });
|
|
782
|
+
await this.log(root, true, `v${version} 通过检查${browser.passed > 0 ? `(${browser.passed} 个用例)` : ''},已进试玩区`);
|
|
783
|
+
}
|
|
784
|
+
else {
|
|
785
|
+
await writeAtomic(join(root, STATE_DIR, 'candidate.json'), `${JSON.stringify(versionRecord, null, 2)}\n`);
|
|
786
|
+
await this.log(root, true, `v${version} 通过检查,等用户确认${restyle === undefined ? '' : `(换风格:${artStyleLabel(restyle)})`}`);
|
|
787
|
+
}
|
|
788
|
+
await this.prune(root);
|
|
789
|
+
await this.touch(row.id);
|
|
790
|
+
return { kind: 'pass', record, deployed: autoConfirm ? versionRecord : null, candidate: autoConfirm ? null : versionRecord };
|
|
791
|
+
}
|
|
792
|
+
async nextVersion(root) {
|
|
793
|
+
const names = await readdir(join(root, STATE_DIR, 'versions')).catch(() => []);
|
|
794
|
+
const numbers = names.map(Number).filter(n => Number.isInteger(n) && n > 0);
|
|
795
|
+
return (numbers.length === 0 ? 0 : Math.max(...numbers)) + 1;
|
|
796
|
+
}
|
|
797
|
+
async copyVersion(root, build, version) {
|
|
798
|
+
const dir = join(root, STATE_DIR, 'versions', String(version));
|
|
799
|
+
for (const f of build) {
|
|
800
|
+
const target = resolve(dir, f.path);
|
|
801
|
+
if (!inside(dir, target))
|
|
802
|
+
continue;
|
|
803
|
+
await mkdir(dirname(target), { recursive: true });
|
|
804
|
+
await writeFile(target, f.data);
|
|
805
|
+
}
|
|
806
|
+
}
|
|
807
|
+
/** Keep the newest versions plus whatever current/candidate point at. */
|
|
808
|
+
async prune(root) {
|
|
809
|
+
const versionsDir = join(root, STATE_DIR, 'versions');
|
|
810
|
+
const names = (await readdir(versionsDir).catch(() => [])).map(Number).filter(n => Number.isInteger(n) && n > 0).sort((a, b) => b - a);
|
|
811
|
+
const pinned = new Set();
|
|
812
|
+
for (const which of ['current', 'candidate']) {
|
|
813
|
+
const r = await readJson(join(root, STATE_DIR, `${which}.json`));
|
|
814
|
+
if (r !== undefined)
|
|
815
|
+
pinned.add(r.version);
|
|
816
|
+
}
|
|
817
|
+
for (const n of names.slice(KEEP_VERSIONS)) {
|
|
818
|
+
if (pinned.has(n))
|
|
819
|
+
continue;
|
|
820
|
+
await rm(join(versionsDir, String(n)), { recursive: true, force: true });
|
|
821
|
+
await rm(join(root, STATE_DIR, 'shots', `${n}.webp`), { force: true });
|
|
822
|
+
}
|
|
823
|
+
}
|
|
824
|
+
/** Promote the candidate to current (the user's 确认 when auto-confirm is off). */
|
|
825
|
+
async confirm(projectId, session) {
|
|
826
|
+
const row = await this.row(projectId);
|
|
827
|
+
if (row === undefined)
|
|
828
|
+
return { ok: false, error: 'not-found' };
|
|
829
|
+
if (!this.writable(session))
|
|
830
|
+
return { ok: false, error: 'denied' };
|
|
831
|
+
const candidate = await readJson(join(row.root, STATE_DIR, 'candidate.json'));
|
|
832
|
+
if (candidate === undefined)
|
|
833
|
+
return { ok: false, error: 'no-version' };
|
|
834
|
+
await writeAtomic(join(row.root, STATE_DIR, 'current.json'), `${JSON.stringify(candidate, null, 2)}\n`);
|
|
835
|
+
await rm(join(row.root, STATE_DIR, 'candidate.json'), { force: true });
|
|
836
|
+
await this.log(row.root, true, `用户确认 v${candidate.version},已进试玩区`);
|
|
837
|
+
await this.touch(projectId);
|
|
838
|
+
return { ok: true, version: candidate.version };
|
|
839
|
+
}
|
|
840
|
+
/**
|
|
841
|
+
* Turn auto-confirm on or off in `game.json` (the panel's switch). The
|
|
842
|
+
* manifest is the project's own file, so this goes through node behind the
|
|
843
|
+
* same writable check.
|
|
844
|
+
*/
|
|
845
|
+
async setAutoConfirm(projectId, on, session) {
|
|
846
|
+
const row = await this.row(projectId);
|
|
847
|
+
if (row === undefined)
|
|
848
|
+
return { ok: false, error: 'not-found' };
|
|
849
|
+
if (!this.writable(session))
|
|
850
|
+
return { ok: false, error: 'denied' };
|
|
851
|
+
const text = await readText(join(row.root, MANIFEST_FILE));
|
|
852
|
+
const parsed = text === undefined ? undefined : parseManifest(text);
|
|
853
|
+
if (parsed === undefined || !parsed.ok)
|
|
854
|
+
return { ok: false, error: 'invalid' };
|
|
855
|
+
await writeAtomic(join(row.root, MANIFEST_FILE), serializeManifest({ ...parsed.manifest, autoConfirm: on }));
|
|
856
|
+
await this.touch(projectId);
|
|
857
|
+
return { ok: true };
|
|
858
|
+
}
|
|
859
|
+
/** Throw the candidate away (the user's 退回); the model hears about it on the next send. */
|
|
860
|
+
async reject(projectId, reason, session) {
|
|
861
|
+
const row = await this.row(projectId);
|
|
862
|
+
if (row === undefined)
|
|
863
|
+
return { ok: false, error: 'not-found' };
|
|
864
|
+
if (!this.writable(session))
|
|
865
|
+
return { ok: false, error: 'denied' };
|
|
866
|
+
const candidate = await readJson(join(row.root, STATE_DIR, 'candidate.json'));
|
|
867
|
+
if (candidate === undefined)
|
|
868
|
+
return { ok: false, error: 'no-version' };
|
|
869
|
+
await rm(join(row.root, STATE_DIR, 'candidate.json'), { force: true });
|
|
870
|
+
await this.updateVersions(row.root, list => list.map(v => (v.version === candidate.version ? { ...v, rejected: true } : v)));
|
|
871
|
+
const why = reason.trim().slice(0, 200);
|
|
872
|
+
await this.log(row.root, true, `用户退回 v${candidate.version}${why === '' ? '' : `:${why}`}`);
|
|
873
|
+
await this.addPending(row.root, { key: `reject:${candidate.version}`, text: `退回了候选 v${candidate.version}${why === '' ? '' : `(${why})`},试玩区仍是上一版` });
|
|
874
|
+
await this.touch(projectId);
|
|
875
|
+
return { ok: true };
|
|
876
|
+
}
|
|
877
|
+
/** Put an older version back into the play area. */
|
|
878
|
+
async rollback(projectId, version, session) {
|
|
879
|
+
const row = await this.row(projectId);
|
|
880
|
+
if (row === undefined)
|
|
881
|
+
return { ok: false, error: 'not-found' };
|
|
882
|
+
if (!this.writable(session))
|
|
883
|
+
return { ok: false, error: 'denied' };
|
|
884
|
+
const entry = (await this.readVersions(row.root)).find(v => v.version === version);
|
|
885
|
+
const exists = await stat(join(row.root, STATE_DIR, 'versions', String(version))).then(() => true, () => false);
|
|
886
|
+
if (entry === undefined || !exists)
|
|
887
|
+
return { ok: false, error: 'no-version' };
|
|
888
|
+
const { passed: _passed, rejected: _rejected, ...record } = entry;
|
|
889
|
+
await writeAtomic(join(row.root, STATE_DIR, 'current.json'), `${JSON.stringify(record, null, 2)}\n`);
|
|
890
|
+
await this.log(row.root, true, `试玩区切回 v${version}`);
|
|
891
|
+
await this.addPending(row.root, { key: 'rollback', text: `把试玩区切回了 v${version}(工作目录里的代码没动)` });
|
|
892
|
+
await this.touch(projectId);
|
|
893
|
+
return { ok: true };
|
|
894
|
+
}
|
|
895
|
+
/** Files that differ between two versions' snapshots. */
|
|
896
|
+
async diff(projectId, from, to) {
|
|
897
|
+
const row = await this.row(projectId);
|
|
898
|
+
if (row === undefined)
|
|
899
|
+
return { ok: false, error: 'not-found' };
|
|
900
|
+
const hashes = async (version) => {
|
|
901
|
+
const dir = join(row.root, STATE_DIR, 'versions', String(version));
|
|
902
|
+
if (!await stat(dir).then(() => true, () => false))
|
|
903
|
+
return undefined;
|
|
904
|
+
return new Map((await walk(dir, () => true)).map(f => [f.path, createHash('sha1').update(f.data).digest('hex')]));
|
|
905
|
+
};
|
|
906
|
+
const a = await hashes(from), b = await hashes(to);
|
|
907
|
+
if (a === undefined || b === undefined)
|
|
908
|
+
return { ok: false, error: 'no-version' };
|
|
909
|
+
return {
|
|
910
|
+
ok: true,
|
|
911
|
+
diff: {
|
|
912
|
+
added: [...b.keys()].filter(k => !a.has(k)),
|
|
913
|
+
removed: [...a.keys()].filter(k => !b.has(k)),
|
|
914
|
+
changed: [...b.keys()].filter(k => a.has(k) && a.get(k) !== b.get(k)),
|
|
915
|
+
},
|
|
916
|
+
};
|
|
917
|
+
}
|
|
918
|
+
// ---------------------------------------------------------------- user layer
|
|
919
|
+
async userLayer(root) {
|
|
920
|
+
const raw = await readJson(join(root, STATE_DIR, 'user.json'));
|
|
921
|
+
return {
|
|
922
|
+
params: raw?.params !== null && typeof raw?.params === 'object' ? { ...raw.params } : {},
|
|
923
|
+
// `param:` entries came from the retired slider auto-pending (a slider is a trial now).
|
|
924
|
+
pending: Array.isArray(raw?.pending) ? raw.pending.filter(p => typeof p?.key === 'string' && typeof p.text === 'string' && !p.key.startsWith('param:')) : [],
|
|
925
|
+
edits: Array.isArray(raw?.edits) ? raw.edits.filter(p => typeof p?.key === 'string' && typeof p.text === 'string') : [],
|
|
926
|
+
...(typeof raw?.restyleTo === 'string' ? { restyleTo: raw.restyleTo } : {}),
|
|
927
|
+
};
|
|
928
|
+
}
|
|
929
|
+
async saveUserLayer(root, user) {
|
|
930
|
+
await writeAtomic(join(root, STATE_DIR, 'user.json'), `${JSON.stringify(user, null, 2)}\n`);
|
|
931
|
+
}
|
|
932
|
+
/** Add (or replace by key) one pending change; `text: ''` removes it. */
|
|
933
|
+
async addPending(root, change) {
|
|
934
|
+
const user = await this.userLayer(root);
|
|
935
|
+
user.pending = user.pending.filter(p => p.key !== change.key);
|
|
936
|
+
if (change.text !== '')
|
|
937
|
+
user.pending.push(change);
|
|
938
|
+
await this.saveUserLayer(root, user);
|
|
939
|
+
}
|
|
940
|
+
/** Sessions whose agent turn is running now (the turn hooks keep it). */
|
|
941
|
+
activeTurns = new Set();
|
|
942
|
+
/** The turn hooks report a bound session's turn starting and ending. */
|
|
943
|
+
setTurnActive(sessionId, active) {
|
|
944
|
+
if (active)
|
|
945
|
+
this.activeTurns.add(sessionId);
|
|
946
|
+
else
|
|
947
|
+
this.activeTurns.delete(sessionId);
|
|
948
|
+
}
|
|
949
|
+
/**
|
|
950
|
+
* What the working files hold that the play area does not: build files
|
|
951
|
+
* added, changed or removed against the waiting candidate (else the live
|
|
952
|
+
* version), whoever wrote them — the panel, the agent, a CLI member, the
|
|
953
|
+
* user's own editor. Modification times pick the suspects; only those are
|
|
954
|
+
* compared byte for byte, so an unchanged project costs a stat walk.
|
|
955
|
+
*/
|
|
956
|
+
async changes(projectId) {
|
|
957
|
+
const row = await this.row(projectId);
|
|
958
|
+
if (row === undefined)
|
|
959
|
+
return { ok: false, error: 'not-found' };
|
|
960
|
+
const root = row.root;
|
|
961
|
+
const state = join(root, STATE_DIR);
|
|
962
|
+
const base = (await readJson(join(state, 'candidate.json'))) ?? (await readJson(join(state, 'current.json')));
|
|
963
|
+
const hasEntry = await stat(join(root, 'index.html')).then(() => true, () => false);
|
|
964
|
+
const working = await statWalk(root, isBuildFile);
|
|
965
|
+
const files = [];
|
|
966
|
+
let lastMs = 0;
|
|
967
|
+
if (base === undefined) {
|
|
968
|
+
for (const f of working) {
|
|
969
|
+
files.push({ path: f.path, kind: 'added' });
|
|
970
|
+
lastMs = Math.max(lastMs, f.mtimeMs);
|
|
971
|
+
}
|
|
972
|
+
}
|
|
973
|
+
else {
|
|
974
|
+
const snapshot = join(state, 'versions', String(base.version));
|
|
975
|
+
// A file edited while that version's check ran is older than its record: look a minute back.
|
|
976
|
+
const since = Date.parse(base.at) - 60_000;
|
|
977
|
+
for (const f of working) {
|
|
978
|
+
if (f.mtimeMs < since)
|
|
979
|
+
continue;
|
|
980
|
+
const before = await readFile(join(snapshot, f.path)).catch(() => undefined);
|
|
981
|
+
if (before === undefined)
|
|
982
|
+
files.push({ path: f.path, kind: 'added' });
|
|
983
|
+
else if (!before.equals(await readFile(join(root, f.path)).catch(() => Buffer.alloc(0))))
|
|
984
|
+
files.push({ path: f.path, kind: 'changed' });
|
|
985
|
+
else
|
|
986
|
+
continue;
|
|
987
|
+
lastMs = Math.max(lastMs, f.mtimeMs);
|
|
988
|
+
}
|
|
989
|
+
const present = new Set(working.map(f => f.path));
|
|
990
|
+
for (const f of await statWalk(snapshot, () => true))
|
|
991
|
+
if (!present.has(f.path))
|
|
992
|
+
files.push({ path: f.path, kind: 'removed' });
|
|
993
|
+
}
|
|
994
|
+
const user = await this.userLayer(root);
|
|
995
|
+
const registry = await this.readRegistry();
|
|
996
|
+
const turn = Object.entries(registry.bindings).some(([sid, pid]) => pid === projectId && this.activeTurns.has(sid));
|
|
997
|
+
const lastAt = lastMs > 0 ? new Date(lastMs).toISOString() : null;
|
|
998
|
+
const busy = turn ? 'turn' : lastMs > 0 && Date.now() - lastMs < BUSY_QUIET_MS ? 'recent' : null;
|
|
999
|
+
// The last check ran after the newest change and failed: that failure is about these very files.
|
|
1000
|
+
const last = await readJson(join(state, 'checks.json'));
|
|
1001
|
+
const failed = files.length > 0 && last !== undefined && !last.ok && Date.parse(last.at) >= lastMs
|
|
1002
|
+
? last.failures.slice(0, 8).map(f => ({ where: f.where, message: f.message.slice(0, 300) }))
|
|
1003
|
+
: null;
|
|
1004
|
+
return {
|
|
1005
|
+
ok: true, against: base?.version ?? null, hasEntry, count: files.length, files: files.slice(0, 30), lastAt, busy,
|
|
1006
|
+
edits: user.edits, failed,
|
|
1007
|
+
};
|
|
1008
|
+
}
|
|
1009
|
+
/** Add (or replace by key) one uncommitted panel edit. */
|
|
1010
|
+
async addEdit(root, change) {
|
|
1011
|
+
const user = await this.userLayer(root);
|
|
1012
|
+
user.edits = [...user.edits.filter(p => p.key !== change.key), change];
|
|
1013
|
+
await this.saveUserLayer(root, user);
|
|
1014
|
+
}
|
|
1015
|
+
/** The agent has read the pending list (`game_read`): it is no longer news. */
|
|
1016
|
+
async clearPending(projectId) {
|
|
1017
|
+
const row = await this.row(projectId);
|
|
1018
|
+
if (row === undefined)
|
|
1019
|
+
return;
|
|
1020
|
+
const user = await this.userLayer(row.root);
|
|
1021
|
+
if (user.pending.length === 0)
|
|
1022
|
+
return;
|
|
1023
|
+
user.pending = [];
|
|
1024
|
+
await this.saveUserLayer(row.root, user);
|
|
1025
|
+
}
|
|
1026
|
+
/** Drop one pending change by key (the dock's ×). */
|
|
1027
|
+
async dropPending(projectId, key, session) {
|
|
1028
|
+
const row = await this.row(projectId);
|
|
1029
|
+
if (row === undefined)
|
|
1030
|
+
return { ok: false, error: 'not-found' };
|
|
1031
|
+
if (!this.writable(session))
|
|
1032
|
+
return { ok: false, error: 'denied' };
|
|
1033
|
+
await this.addPending(row.root, { key, text: '' });
|
|
1034
|
+
this.notify(projectId);
|
|
1035
|
+
return { ok: true };
|
|
1036
|
+
}
|
|
1037
|
+
/**
|
|
1038
|
+
* The user sent a message carrying the panel's change list (it starts
|
|
1039
|
+
* with {@link PANEL_CHANGES_MARK}): drop exactly the changes it lists.
|
|
1040
|
+
* Changes made after the list was written stay pending.
|
|
1041
|
+
* @param sessionId - the sending session.
|
|
1042
|
+
* @param text - the user message's text.
|
|
1043
|
+
* @returns how many changes were cleared.
|
|
1044
|
+
*/
|
|
1045
|
+
async clearSent(sessionId, text) {
|
|
1046
|
+
if (!text.includes(PANEL_CHANGES_MARK))
|
|
1047
|
+
return 0;
|
|
1048
|
+
const row = await this.boundProject(sessionId);
|
|
1049
|
+
if (row === undefined)
|
|
1050
|
+
return 0;
|
|
1051
|
+
const user = await this.userLayer(row.root);
|
|
1052
|
+
const kept = user.pending.filter(p => !text.includes(p.text));
|
|
1053
|
+
const cleared = user.pending.length - kept.length;
|
|
1054
|
+
if (cleared === 0)
|
|
1055
|
+
return 0;
|
|
1056
|
+
user.pending = kept;
|
|
1057
|
+
await this.saveUserLayer(row.root, user);
|
|
1058
|
+
this.notify(row.id);
|
|
1059
|
+
return cleared;
|
|
1060
|
+
}
|
|
1061
|
+
/** The manifest of a project, or undefined. */
|
|
1062
|
+
async manifestOf(root) {
|
|
1063
|
+
const text = await readText(join(root, MANIFEST_FILE));
|
|
1064
|
+
const parsed = text === undefined ? undefined : parseManifest(text);
|
|
1065
|
+
return parsed?.ok === true ? parsed.manifest : undefined;
|
|
1066
|
+
}
|
|
1067
|
+
/** Set one slider value (user layer; the play frame gets it live from the panel). */
|
|
1068
|
+
async setParam(projectId, key, value, session) {
|
|
1069
|
+
const row = await this.row(projectId);
|
|
1070
|
+
if (row === undefined)
|
|
1071
|
+
return { ok: false, error: 'not-found' };
|
|
1072
|
+
if (!this.writable(session))
|
|
1073
|
+
return { ok: false, error: 'denied' };
|
|
1074
|
+
const manifest = await this.manifestOf(row.root);
|
|
1075
|
+
const param = manifest?.params.find(p => p.key === key);
|
|
1076
|
+
if (param === undefined || !Number.isFinite(value))
|
|
1077
|
+
return { ok: false, error: 'invalid' };
|
|
1078
|
+
const clamped = Math.min(param.max, Math.max(param.min, value));
|
|
1079
|
+
const user = await this.userLayer(row.root);
|
|
1080
|
+
if (clamped === param.value)
|
|
1081
|
+
delete user.params[key];
|
|
1082
|
+
else
|
|
1083
|
+
user.params[key] = clamped;
|
|
1084
|
+
// A slider is a trial (2026-10-10): it changes the running game only. The
|
|
1085
|
+
// agent hears about it when the user says so (告诉 agent) or finalizes.
|
|
1086
|
+
await this.saveUserLayer(row.root, user);
|
|
1087
|
+
this.notify(projectId);
|
|
1088
|
+
return { ok: true };
|
|
1089
|
+
}
|
|
1090
|
+
/** 定稿: write the slider values into game.json as the new defaults. */
|
|
1091
|
+
async finalizeParams(projectId, session) {
|
|
1092
|
+
const row = await this.row(projectId);
|
|
1093
|
+
if (row === undefined)
|
|
1094
|
+
return { ok: false, error: 'not-found' };
|
|
1095
|
+
if (!this.writable(session))
|
|
1096
|
+
return { ok: false, error: 'denied' };
|
|
1097
|
+
const manifest = await this.manifestOf(row.root);
|
|
1098
|
+
if (manifest === undefined)
|
|
1099
|
+
return { ok: false, error: 'invalid' };
|
|
1100
|
+
const user = await this.userLayer(row.root);
|
|
1101
|
+
const changed = manifest.params.filter(p => user.params[p.key] !== undefined && user.params[p.key] !== p.value);
|
|
1102
|
+
if (changed.length === 0)
|
|
1103
|
+
return { ok: true };
|
|
1104
|
+
const params = manifest.params.map(p => (user.params[p.key] === undefined ? p : { ...p, value: user.params[p.key] }));
|
|
1105
|
+
await writeAtomic(join(row.root, MANIFEST_FILE), serializeManifest({ ...manifest, params }));
|
|
1106
|
+
const summary = changed.map(p => `${p.label} ${num(p.value)}→${num(user.params[p.key])}`).join('、');
|
|
1107
|
+
user.params = {};
|
|
1108
|
+
user.pending = user.pending.filter(p => !p.key.startsWith('param:'));
|
|
1109
|
+
user.pending.push({ key: 'params-final', text: `定稿参数:${summary}(已写进 game.json)` });
|
|
1110
|
+
user.edits = [...user.edits.filter(e => e.key !== 'params'), { key: 'params', text: `参数定稿 ${summary}` }];
|
|
1111
|
+
await this.saveUserLayer(row.root, user);
|
|
1112
|
+
await this.log(row.root, true, `定稿参数:${summary}`);
|
|
1113
|
+
await this.touch(projectId);
|
|
1114
|
+
return { ok: true };
|
|
1115
|
+
}
|
|
1116
|
+
/** 还原: drop every slider override. */
|
|
1117
|
+
async resetParams(projectId, session) {
|
|
1118
|
+
const row = await this.row(projectId);
|
|
1119
|
+
if (row === undefined)
|
|
1120
|
+
return { ok: false, error: 'not-found' };
|
|
1121
|
+
if (!this.writable(session))
|
|
1122
|
+
return { ok: false, error: 'denied' };
|
|
1123
|
+
const user = await this.userLayer(row.root);
|
|
1124
|
+
user.params = {};
|
|
1125
|
+
user.pending = user.pending.filter(p => !p.key.startsWith('param:'));
|
|
1126
|
+
await this.saveUserLayer(row.root, user);
|
|
1127
|
+
this.notify(projectId);
|
|
1128
|
+
return { ok: true };
|
|
1129
|
+
}
|
|
1130
|
+
/**
|
|
1131
|
+
* Edit one element in the working files — its look, or scalar fields
|
|
1132
|
+
* (`lore`, the 设定 text, included). The play area keeps its version until
|
|
1133
|
+
* the user commits (`commit`); the edit is listed as uncommitted, and as
|
|
1134
|
+
* pending for the agent.
|
|
1135
|
+
*/
|
|
1136
|
+
async editElement(projectId, elementId, patch, session, note) {
|
|
1137
|
+
const row = await this.row(projectId);
|
|
1138
|
+
if (row === undefined)
|
|
1139
|
+
return { ok: false, error: 'not-found' };
|
|
1140
|
+
if (!this.writable(session))
|
|
1141
|
+
return { ok: false, error: 'denied' };
|
|
1142
|
+
const files = await walk(join(row.root, 'elements'), p => p.endsWith('.json'));
|
|
1143
|
+
const changes = {};
|
|
1144
|
+
if (patch.look !== undefined) {
|
|
1145
|
+
const look = (await this.kitFor(row.root)).normalizeLook(patch.look);
|
|
1146
|
+
if (look === null)
|
|
1147
|
+
return { ok: false, error: 'invalid' };
|
|
1148
|
+
changes.look = look;
|
|
1149
|
+
}
|
|
1150
|
+
for (const [key, value] of Object.entries(patch.fields ?? {})) {
|
|
1151
|
+
if (key === 'id' || key === 'look' || key.length > 40)
|
|
1152
|
+
continue;
|
|
1153
|
+
changes[key] = value === null ? undefined : key === 'lore' && typeof value === 'string' ? value.slice(0, LORE_MAX) : value;
|
|
1154
|
+
}
|
|
1155
|
+
for (const f of files) {
|
|
1156
|
+
const next = patchElementText(f.data.toString('utf8'), elementId, changes);
|
|
1157
|
+
if (next === undefined)
|
|
1158
|
+
continue;
|
|
1159
|
+
await writeAtomic(join(row.root, 'elements', f.path), next);
|
|
1160
|
+
const parsed = JSON.parse(next);
|
|
1161
|
+
const element = (Array.isArray(parsed) ? parsed : [parsed]).find(e => e.id === elementId);
|
|
1162
|
+
const name = typeof element.name === 'string' ? element.name : elementId;
|
|
1163
|
+
if (changes.look !== undefined) {
|
|
1164
|
+
await this.addPending(row.root, { key: `look:${elementId}`, text: note ?? `改了「${name}」的外观(elements 文件里的 look 已更新)` });
|
|
1165
|
+
await this.addEdit(row.root, { key: `look:${elementId}`, text: `${name}的外观` });
|
|
1166
|
+
}
|
|
1167
|
+
if ('lore' in changes) {
|
|
1168
|
+
await this.addPending(row.root, { key: `lore:${elementId}`, text: `改了「${name}」的设定(elements 文件里的 lore),按新设定调整它的行为和文案` });
|
|
1169
|
+
await this.addEdit(row.root, { key: `lore:${elementId}`, text: `${name}的设定` });
|
|
1170
|
+
}
|
|
1171
|
+
const fieldKeys = Object.keys(changes).filter(k => k !== 'look' && k !== 'lore');
|
|
1172
|
+
if (fieldKeys.length > 0) {
|
|
1173
|
+
await this.addPending(row.root, {
|
|
1174
|
+
key: `fields:${elementId}`,
|
|
1175
|
+
text: `改了「${name}」的字段:${fieldKeys.map(k => `${k}=${changes[k] === undefined ? '(删除)' : String(changes[k])}`).join('、')}`,
|
|
1176
|
+
});
|
|
1177
|
+
await this.addEdit(row.root, { key: `fields:${elementId}`, text: `${name}的${fieldKeys.join('、')}` });
|
|
1178
|
+
}
|
|
1179
|
+
await this.log(row.root, true, note ?? `用户编辑了元素「${name}」`);
|
|
1180
|
+
await this.touch(projectId);
|
|
1181
|
+
return { ok: true, element };
|
|
1182
|
+
}
|
|
1183
|
+
return { ok: false, error: 'not-found' };
|
|
1184
|
+
}
|
|
1185
|
+
/** Write one design chapter (the user's direct edit). */
|
|
1186
|
+
async writeChapter(projectId, chapterId, text, session) {
|
|
1187
|
+
const row = await this.row(projectId);
|
|
1188
|
+
if (row === undefined)
|
|
1189
|
+
return { ok: false, error: 'not-found' };
|
|
1190
|
+
if (!this.writable(session))
|
|
1191
|
+
return { ok: false, error: 'denied' };
|
|
1192
|
+
const spec = CHAPTERS.find(c => c.id === chapterId);
|
|
1193
|
+
if (spec === undefined || spec.generated === true)
|
|
1194
|
+
return { ok: false, error: 'invalid' };
|
|
1195
|
+
await writeAtomic(join(row.root, spec.file), text.endsWith('\n') ? text : `${text}\n`);
|
|
1196
|
+
await this.addPending(row.root, { key: `design:${chapterId}`, text: `直接改了策划《${spec.title}》(${spec.file}),以文件为准` });
|
|
1197
|
+
await this.touch(projectId);
|
|
1198
|
+
return { ok: true };
|
|
1199
|
+
}
|
|
1200
|
+
/** Edit the one-pager in game.json (pitch, element types, pillars) — the user's direct edit. */
|
|
1201
|
+
async setOnePager(projectId, patch, session) {
|
|
1202
|
+
const row = await this.row(projectId);
|
|
1203
|
+
if (row === undefined)
|
|
1204
|
+
return { ok: false, error: 'not-found' };
|
|
1205
|
+
if (!this.writable(session))
|
|
1206
|
+
return { ok: false, error: 'denied' };
|
|
1207
|
+
const manifest = await this.manifestOf(row.root);
|
|
1208
|
+
if (manifest === undefined)
|
|
1209
|
+
return { ok: false, error: 'invalid' };
|
|
1210
|
+
const pitch = patch.pitch === undefined ? manifest.pitch : patch.pitch.trim().slice(0, 400);
|
|
1211
|
+
const elementTypes = patch.elementTypes === undefined ? manifest.elementTypes : listOf(patch.elementTypes).slice(0, 12);
|
|
1212
|
+
const pillars = patch.pillars === undefined ? manifest.pillars : listOf(patch.pillars.split(/\n/)).slice(0, 5);
|
|
1213
|
+
if (pitch === '' || elementTypes.length === 0)
|
|
1214
|
+
return { ok: false, error: 'invalid' };
|
|
1215
|
+
const parts = [];
|
|
1216
|
+
if (pitch !== manifest.pitch)
|
|
1217
|
+
parts.push(`玩法「${pitch}」`);
|
|
1218
|
+
if (elementTypes.join('、') !== manifest.elementTypes.join('、'))
|
|
1219
|
+
parts.push(`元素类型 ${elementTypes.join('、')}`);
|
|
1220
|
+
if (pillars.join(';') !== manifest.pillars.join(';'))
|
|
1221
|
+
parts.push(`支柱 ${pillars.map((p, i) => `${i + 1}.${p}`).join(' ')}`);
|
|
1222
|
+
if (parts.length === 0)
|
|
1223
|
+
return { ok: true };
|
|
1224
|
+
await writeAtomic(join(row.root, MANIFEST_FILE), serializeManifest({ ...manifest, pitch, elementTypes, pillars }));
|
|
1225
|
+
await this.addPending(row.root, { key: 'onepager', text: `改了一页纸(game.json):${parts.join(';')}` });
|
|
1226
|
+
await this.log(row.root, true, `用户改了一页纸:${parts.join(';')}`);
|
|
1227
|
+
await this.touch(projectId);
|
|
1228
|
+
return { ok: true };
|
|
1229
|
+
}
|
|
1230
|
+
/** Ask for a restyle: the next passing version becomes a candidate. The panel puts the request into the composer. */
|
|
1231
|
+
async requestRestyle(projectId, styleId, session) {
|
|
1232
|
+
const row = await this.row(projectId);
|
|
1233
|
+
if (row === undefined)
|
|
1234
|
+
return { ok: false, error: 'not-found' };
|
|
1235
|
+
if (!this.writable(session))
|
|
1236
|
+
return { ok: false, error: 'denied' };
|
|
1237
|
+
const style = resolveArtStyle(styleId);
|
|
1238
|
+
if (style === undefined)
|
|
1239
|
+
return { ok: false, error: 'invalid' };
|
|
1240
|
+
const user = await this.userLayer(row.root);
|
|
1241
|
+
user.restyleTo = style;
|
|
1242
|
+
await this.saveUserLayer(row.root, user);
|
|
1243
|
+
this.notify(projectId);
|
|
1244
|
+
return { ok: true };
|
|
1245
|
+
}
|
|
1246
|
+
// ---------------------------------------------------------------- appearance proposals
|
|
1247
|
+
async proposals(root) {
|
|
1248
|
+
const list = await readJson(join(root, STATE_DIR, 'proposals.json'));
|
|
1249
|
+
return Array.isArray(list) ? list.slice(-12).reverse() : [];
|
|
1250
|
+
}
|
|
1251
|
+
/**
|
|
1252
|
+
* Store up to 3 appearance variants for an element (the `game_propose`
|
|
1253
|
+
* tool); the user picks one in the panel.
|
|
1254
|
+
*/
|
|
1255
|
+
async proposeLooks(sessionId, elementId, request, variants) {
|
|
1256
|
+
const row = await this.boundProject(sessionId);
|
|
1257
|
+
if (row === undefined)
|
|
1258
|
+
return { ok: false, error: '本会话还没有游戏项目' };
|
|
1259
|
+
const view = await this.view(row.id);
|
|
1260
|
+
const element = view?.elements.find(e => e.id === elementId);
|
|
1261
|
+
if (element === undefined)
|
|
1262
|
+
return { ok: false, error: `没有 id 为「${elementId}」的元素` };
|
|
1263
|
+
const clean = [];
|
|
1264
|
+
const bad = [];
|
|
1265
|
+
const kit = await this.kitFor(row.root);
|
|
1266
|
+
variants.slice(0, 3).forEach((v, i) => {
|
|
1267
|
+
const look = kit.normalizeLook(v.look);
|
|
1268
|
+
if (look === null)
|
|
1269
|
+
bad.push(i + 1);
|
|
1270
|
+
else
|
|
1271
|
+
clean.push({ label: (v.label ?? '').trim().slice(0, 20) || 'ABC'[i], note: (v.note ?? '').trim().slice(0, 120), look });
|
|
1272
|
+
});
|
|
1273
|
+
if (clean.length === 0)
|
|
1274
|
+
return { ok: false, error: `方案都不可用(第 ${bad.join('、')} 个):${kit.lookHint}` };
|
|
1275
|
+
const all = await readJson(join(row.root, STATE_DIR, 'proposals.json'));
|
|
1276
|
+
const list = (Array.isArray(all) ? all : []).filter(p => !(p.elementId === elementId && p.picked === undefined));
|
|
1277
|
+
list.push({ elementId, at: new Date().toISOString(), request: request.trim().slice(0, 200), variants: clean });
|
|
1278
|
+
await writeAtomic(join(row.root, STATE_DIR, 'proposals.json'), `${JSON.stringify(list.slice(-20), null, 2)}\n`);
|
|
1279
|
+
await this.touch(row.id);
|
|
1280
|
+
return { ok: true, count: clean.length, name: element.name };
|
|
1281
|
+
}
|
|
1282
|
+
/** The user picks a variant: it replaces the element's look at once; the others stay in the history. */
|
|
1283
|
+
async pickProposal(projectId, elementId, at, index, session) {
|
|
1284
|
+
const row = await this.row(projectId);
|
|
1285
|
+
if (row === undefined)
|
|
1286
|
+
return { ok: false, error: 'not-found' };
|
|
1287
|
+
const all = await readJson(join(row.root, STATE_DIR, 'proposals.json'));
|
|
1288
|
+
const list = Array.isArray(all) ? all : [];
|
|
1289
|
+
const proposal = list.find(p => p.elementId === elementId && p.at === at);
|
|
1290
|
+
const variant = proposal?.variants[index];
|
|
1291
|
+
if (proposal === undefined || variant === undefined)
|
|
1292
|
+
return { ok: false, error: 'not-found' };
|
|
1293
|
+
const edited = await this.editElement(projectId, elementId, { look: variant.look }, session, `外观方案:用户选了「${variant.label}」${variant.note === '' ? '' : `(${variant.note})`},已写进元素文件`);
|
|
1294
|
+
if (!edited.ok)
|
|
1295
|
+
return edited;
|
|
1296
|
+
await writeAtomic(join(row.root, STATE_DIR, 'proposals.json'), `${JSON.stringify(list.map(p => (p === proposal ? { ...p, picked: index } : p)), null, 2)}\n`);
|
|
1297
|
+
this.notify(projectId);
|
|
1298
|
+
return { ok: true };
|
|
1299
|
+
}
|
|
1300
|
+
// ---------------------------------------------------------------- versions
|
|
1301
|
+
async readVersions(root) {
|
|
1302
|
+
const list = await readJson(join(root, STATE_DIR, 'versions.json'));
|
|
1303
|
+
return Array.isArray(list) ? list.filter(v => typeof v?.version === 'number') : [];
|
|
1304
|
+
}
|
|
1305
|
+
async updateVersions(root, change) {
|
|
1306
|
+
await writeAtomic(join(root, STATE_DIR, 'versions.json'), `${JSON.stringify(change(await this.readVersions(root)), null, 2)}\n`);
|
|
1307
|
+
}
|
|
1308
|
+
async appendVersion(root, entry) {
|
|
1309
|
+
await this.updateVersions(root, list => [...list.filter(v => v.version !== entry.version), entry].slice(-50));
|
|
1310
|
+
}
|
|
1311
|
+
/** The 版本 view's rows, newest first: only versions whose snapshot still exists. */
|
|
1312
|
+
async versionRows(root) {
|
|
1313
|
+
const current = await readJson(join(root, STATE_DIR, 'current.json'));
|
|
1314
|
+
const candidate = await readJson(join(root, STATE_DIR, 'candidate.json'));
|
|
1315
|
+
const kept = new Set((await readdir(join(root, STATE_DIR, 'versions')).catch(() => [])).map(Number));
|
|
1316
|
+
return (await this.readVersions(root)).filter(v => kept.has(v.version)).reverse().map(v => ({
|
|
1317
|
+
version: v.version, at: v.at, fingerprint: v.fingerprint,
|
|
1318
|
+
...(v.by === undefined ? {} : { by: v.by }),
|
|
1319
|
+
...(v.note === undefined ? {} : { note: v.note }),
|
|
1320
|
+
...(v.passed === undefined ? {} : { passed: v.passed }),
|
|
1321
|
+
status: v.version === current?.version ? 'current' : v.version === candidate?.version ? 'candidate' : v.rejected === true ? 'rejected' : 'past',
|
|
1322
|
+
}));
|
|
1323
|
+
}
|
|
1324
|
+
/** The last check's screenshot (webp, base64), if any. */
|
|
1325
|
+
async checkShot(projectId, version) {
|
|
1326
|
+
const row = await this.row(projectId);
|
|
1327
|
+
if (row === undefined)
|
|
1328
|
+
return { ok: false, error: 'not-found' };
|
|
1329
|
+
// A version's own poster; without a version, the last check's (projects made before per-version posters).
|
|
1330
|
+
const own = version === undefined ? undefined : await readFile(join(row.root, STATE_DIR, 'shots', `${Math.floor(version)}.webp`)).catch(() => undefined);
|
|
1331
|
+
const data = own ?? (version === undefined ? await readFile(join(row.root, STATE_DIR, 'last-check.webp')).catch(() => undefined) : undefined);
|
|
1332
|
+
return data === undefined ? { ok: false, error: 'no-version' } : { ok: true, webp: data.toString('base64') };
|
|
1333
|
+
}
|
|
1334
|
+
// ---------------------------------------------------------------- playtest log
|
|
1335
|
+
/** Append one line to the project's playtest log (`.game/playtest-log.jsonl`). */
|
|
1336
|
+
async log(root, writable, text) {
|
|
1337
|
+
if (!writable)
|
|
1338
|
+
return;
|
|
1339
|
+
const path = join(root, STATE_DIR, 'playtest-log.jsonl');
|
|
1340
|
+
await mkdir(dirname(path), { recursive: true });
|
|
1341
|
+
await appendFile(path, `${JSON.stringify({ at: new Date().toISOString(), text })}\n`).catch(() => { });
|
|
1342
|
+
}
|
|
1343
|
+
/** Record what the panel saw during one play session (errors, length). */
|
|
1344
|
+
async logPlay(projectId, entry, session) {
|
|
1345
|
+
const row = await this.row(projectId);
|
|
1346
|
+
if (row === undefined)
|
|
1347
|
+
return { ok: false, error: 'not-found' };
|
|
1348
|
+
const seconds = Math.max(0, Math.round(entry.seconds));
|
|
1349
|
+
const errors = entry.errors.slice(0, 3).map(e => String(e).slice(0, 160));
|
|
1350
|
+
const text = errors.length === 0
|
|
1351
|
+
? `试玩 v${entry.version} ${seconds} 秒,无报错`
|
|
1352
|
+
: `试玩 v${entry.version} ${seconds} 秒,报错 ${entry.errors.length} 次:${errors.join(';')}`;
|
|
1353
|
+
await this.log(row.root, this.writable(session), text);
|
|
1354
|
+
this.notify(projectId);
|
|
1355
|
+
return { ok: true };
|
|
1356
|
+
}
|
|
1357
|
+
/** The playtest log, newest first (last 60). */
|
|
1358
|
+
async logEntries(root) {
|
|
1359
|
+
const text = await readText(join(root, STATE_DIR, 'playtest-log.jsonl'));
|
|
1360
|
+
if (text === undefined)
|
|
1361
|
+
return [];
|
|
1362
|
+
return text.split('\n').filter(Boolean).map(line => {
|
|
1363
|
+
try {
|
|
1364
|
+
return JSON.parse(line);
|
|
1365
|
+
}
|
|
1366
|
+
catch {
|
|
1367
|
+
return undefined;
|
|
1368
|
+
}
|
|
1369
|
+
}).filter((r) => r !== undefined && typeof r.text === 'string').slice(-60).reverse();
|
|
1370
|
+
}
|
|
1371
|
+
/** The generated 试玩记录 chapter, newest first. */
|
|
1372
|
+
async playtestText(root) {
|
|
1373
|
+
const rows = (await this.logEntries(root)).slice(0, 40);
|
|
1374
|
+
if (rows.length === 0)
|
|
1375
|
+
return '';
|
|
1376
|
+
const local = (iso) => {
|
|
1377
|
+
const d = new Date(iso);
|
|
1378
|
+
const pad = (n) => String(n).padStart(2, '0');
|
|
1379
|
+
return Number.isNaN(d.getTime()) ? iso : `${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`;
|
|
1380
|
+
};
|
|
1381
|
+
return ['# 试玩记录', '', ...rows.map(r => `- ${local(r.at)} ${r.text}`), ''].join('\n');
|
|
1382
|
+
}
|
|
1383
|
+
}
|