miaoda-game-devkit 0.7.2 → 0.8.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.
@@ -0,0 +1,706 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { spawnSync } from 'node:child_process';
4
+ import { createHash, randomUUID } from 'node:crypto';
5
+ import {
6
+ cpSync,
7
+ existsSync,
8
+ lstatSync,
9
+ mkdirSync,
10
+ readFileSync,
11
+ readdirSync,
12
+ renameSync,
13
+ rmSync,
14
+ statSync,
15
+ writeFileSync,
16
+ } from 'node:fs';
17
+ import { basename, dirname, join, relative, resolve, sep } from 'node:path';
18
+ import { fileURLToPath } from 'node:url';
19
+ import {
20
+ areIndexedPackageSpecs,
21
+ cleanupIndexedMechanics,
22
+ resolveIndexedMechanics,
23
+ } from './resolve-game-mechanics-source-index.mjs';
24
+
25
+ const modulePath = fileURLToPath(import.meta.url);
26
+ const capabilityPath = join(dirname(modulePath), 'game-mechanics-capabilities.json');
27
+ const METADATA_ROOT = '.miaoda';
28
+ const SOURCE_ROOT = 'src/game-mechanics';
29
+ const STATE_FILENAME = 'mechanics-source.json';
30
+ const WORKSPACE_PATTERN = 'src/game-mechanics/*';
31
+ const STATE_SCHEMA_VERSION = 2;
32
+ const DEFAULT_SOURCE_INDEX_URL =
33
+ 'https://resource-static.bj.bcebos.com/miaoda-game/stable.json';
34
+
35
+ function fail(api, field, expected, repair) {
36
+ throw new Error(`miaoda mechanics source ${api}: ${field} must ${expected}. ${repair}`);
37
+ }
38
+
39
+ function readJson(path, label = path) {
40
+ let value;
41
+ try {
42
+ value = JSON.parse(readFileSync(path, 'utf8'));
43
+ } catch (error) {
44
+ throw new Error(
45
+ `miaoda mechanics source: cannot read ${label} as JSON: ${error instanceof Error ? error.message : String(error)}`,
46
+ );
47
+ }
48
+ return value;
49
+ }
50
+
51
+ function writeJson(path, value) {
52
+ writeFileSync(path, `${JSON.stringify(value, null, 2)}\n`);
53
+ }
54
+
55
+ function sourcePath(value) {
56
+ if (typeof value !== 'string' || !value.startsWith('./dist/')) return value;
57
+ if (value.endsWith('.d.ts')) return `./src/${value.slice('./dist/'.length, -'.d.ts'.length)}.ts`;
58
+ if (value.endsWith('.js')) return `./src/${value.slice('./dist/'.length, -'.js'.length)}.ts`;
59
+ return value.replace('./dist/', './src/');
60
+ }
61
+
62
+ function rewriteExportPaths(value) {
63
+ if (typeof value === 'string') return sourcePath(value);
64
+ if (Array.isArray(value)) return value.map(rewriteExportPaths);
65
+ if (value && typeof value === 'object') {
66
+ return Object.fromEntries(Object.entries(value).map(([key, child]) => [key, rewriteExportPaths(child)]));
67
+ }
68
+ return value;
69
+ }
70
+
71
+ function stringValues(value) {
72
+ if (typeof value === 'string') return [value];
73
+ if (Array.isArray(value)) return value.flatMap(stringValues);
74
+ if (value && typeof value === 'object') return Object.values(value).flatMap(stringValues);
75
+ return [];
76
+ }
77
+
78
+ export function createSourceManifest(manifest, resolvedPackages) {
79
+ const generated = structuredClone(manifest);
80
+ generated.private = true;
81
+ delete generated.devDependencies;
82
+ delete generated.publishConfig;
83
+ delete generated.files;
84
+ delete generated.scripts;
85
+ for (const field of ['main', 'module', 'types']) {
86
+ if (field in generated) generated[field] = sourcePath(generated[field]);
87
+ }
88
+ if (generated.exports) generated.exports = rewriteExportPaths(generated.exports);
89
+
90
+ for (const field of ['dependencies', 'optionalDependencies']) {
91
+ if (!generated[field]) continue;
92
+ for (const dependencyName of Object.keys(generated[field])) {
93
+ const dependency = resolvedPackages.get(dependencyName);
94
+ if (dependency) generated[field][dependencyName] = `workspace:${dependency.version}`;
95
+ }
96
+ }
97
+ return generated;
98
+ }
99
+
100
+ export function validateMechanicSelection(projectManifest, resolvedPackages, capabilityDocument) {
101
+ const config = projectManifest.miaodaGame;
102
+ if (config === undefined) return;
103
+ if (!config || typeof config !== 'object' || Array.isArray(config)) {
104
+ fail('add', 'package.json miaodaGame', 'be an object when defined', 'Remove it or declare schemaVersion, engine, uses, and blockedPackages.');
105
+ }
106
+ const engines = new Set(['neutral', 'react', 'phaser', 'cocos']);
107
+ if (config.schemaVersion !== 1 || !engines.has(config.engine)) {
108
+ fail('add', 'package.json miaodaGame', 'use schemaVersion 1 and a neutral, react, phaser, or cocos engine', 'Correct the project mechanic selection metadata.');
109
+ }
110
+ if (!config.uses || typeof config.uses !== 'object' || Array.isArray(config.uses)) {
111
+ fail('add', 'package.json miaodaGame.uses', 'be an object', 'Map selected package names to their intended owned capabilities.');
112
+ }
113
+ if (!Array.isArray(config.blockedPackages)) {
114
+ fail('add', 'package.json miaodaGame.blockedPackages', 'be an array', 'Use an empty array when no package is blocked.');
115
+ }
116
+ const capabilities = capabilityDocument?.packages ?? {};
117
+ const owners = new Map();
118
+ for (const [name, resolvedPackage] of resolvedPackages) {
119
+ if (config.blockedPackages.includes(name)) {
120
+ fail('add', name, 'not be listed in miaodaGame.blockedPackages', 'Remove the package request or unblock it explicitly.');
121
+ }
122
+ const capability = capabilities[name];
123
+ if (
124
+ capability &&
125
+ capability.engine !== 'neutral' &&
126
+ config.engine !== 'neutral' &&
127
+ capability.engine !== config.engine
128
+ ) {
129
+ fail('add', `${name} engine`, `match project engine ${config.engine}`, `Choose a ${config.engine} adapter or an engine-neutral core package.`);
130
+ }
131
+ const uses = config.uses[name];
132
+ if (uses === undefined) continue;
133
+ if (!capability) {
134
+ fail('add', `${name} capability metadata`, 'exist before declaring miaodaGame.uses', 'Update the Devkit capability snapshot or remove the declaration.');
135
+ }
136
+ if (!Array.isArray(uses) || uses.length === 0 || new Set(uses).size !== uses.length) {
137
+ fail('add', `miaodaGame.uses.${name}`, 'be a non-empty array without duplicates', 'Declare each intended owned capability once.');
138
+ }
139
+ for (const use of uses) {
140
+ if (!capability.owns.includes(use)) {
141
+ fail('add', `${name} use ${use}`, `be one of its owned capabilities: ${capability.owns.join(', ')}`, 'Select the correct package or capability name.');
142
+ }
143
+ const existing = owners.get(use);
144
+ if (existing && existing !== name) {
145
+ fail('add', `capability ${use}`, `have one owner, but both ${existing} and ${name} are selected`, 'Remove the duplicate ownership assignment.');
146
+ }
147
+ owners.set(use, name);
148
+ }
149
+ if (resolvedPackage.version.length === 0) fail('add', `${name} version`, 'be non-empty', 'Repack the package.');
150
+ }
151
+ }
152
+
153
+ function validateSourceEntrypoints(packageDirectory, manifest) {
154
+ const candidates = [manifest.main, manifest.module, manifest.types, ...stringValues(manifest.exports)];
155
+ for (const candidate of candidates.filter((value) => typeof value === 'string' && value.startsWith('./src/'))) {
156
+ if (!existsSync(join(packageDirectory, candidate))) {
157
+ fail(
158
+ 'add',
159
+ `${manifest.name} entry ${candidate}`,
160
+ 'exist after dist-to-src conversion',
161
+ 'Add the matching TypeScript source entry or publish an explicit source manifest.',
162
+ );
163
+ }
164
+ }
165
+ }
166
+
167
+ function listFiles(root, current = root) {
168
+ const entries = readdirSync(current, { withFileTypes: true }).sort((left, right) => left.name.localeCompare(right.name));
169
+ const files = [];
170
+ for (const entry of entries) {
171
+ if (entry.name === 'node_modules') continue;
172
+ const path = join(current, entry.name);
173
+ if (entry.isDirectory()) files.push(...listFiles(root, path));
174
+ else if (entry.isFile()) files.push(path);
175
+ else if (entry.isSymbolicLink()) {
176
+ fail(
177
+ 'add',
178
+ `${relative(root, path)} in ${root}`,
179
+ 'be a regular file or directory, not a symbolic link',
180
+ 'Repack the source package without symbolic links.',
181
+ );
182
+ }
183
+ }
184
+ return files;
185
+ }
186
+
187
+ function hashTree(root) {
188
+ const hash = createHash('sha256');
189
+ for (const path of listFiles(root)) {
190
+ hash.update(relative(root, path).split(sep).join('/'));
191
+ hash.update('\0');
192
+ hash.update(readFileSync(path));
193
+ hash.update('\0');
194
+ }
195
+ return hash.digest('hex');
196
+ }
197
+
198
+ function readState(metadataRoot) {
199
+ const statePath = join(metadataRoot, STATE_FILENAME);
200
+ if (!existsSync(statePath)) return { schemaVersion: STATE_SCHEMA_VERSION, roots: {}, packages: {} };
201
+ const state = readJson(statePath, STATE_FILENAME);
202
+ if (state.schemaVersion !== STATE_SCHEMA_VERSION || !state.roots || !state.packages) {
203
+ fail('add', STATE_FILENAME, `use schemaVersion ${STATE_SCHEMA_VERSION}`, `Preserve src/game-mechanics, then remove ${STATE_FILENAME} to adopt it again.`);
204
+ }
205
+ return state;
206
+ }
207
+
208
+ function inspectExistingPackages(sourceRoot, state) {
209
+ const statuses = {};
210
+ if (!existsSync(sourceRoot)) {
211
+ if (Object.keys(state.packages).length > 0) {
212
+ fail('add', sourceRoot, 'exist for the recorded state', `Restore it or preserve intentional changes and remove ${STATE_FILENAME} to reinstall.`);
213
+ }
214
+ return statuses;
215
+ }
216
+ if (lstatSync(sourceRoot).isSymbolicLink()) {
217
+ fail('add', sourceRoot, 'be a real project directory, not a symbolic link', 'Replace the link after preserving its contents.');
218
+ }
219
+ for (const [name, record] of Object.entries(state.packages)) {
220
+ const directory = join(sourceRoot, name);
221
+ if (!existsSync(directory)) {
222
+ statuses[name] = 'missing';
223
+ continue;
224
+ }
225
+ if (lstatSync(directory).isSymbolicLink()) {
226
+ fail('add', `${name} source`, 'be a real directory, not a symbolic link', 'Replace the link with the editable source directory.');
227
+ }
228
+ statuses[name] = hashTree(directory) === record.sha256 ? 'clean' : 'modified';
229
+ }
230
+ return statuses;
231
+ }
232
+
233
+ function ensureWorkspacePattern(projectRoot) {
234
+ const workspacePath = join(projectRoot, 'pnpm-workspace.yaml');
235
+ const quotedPattern = `'${WORKSPACE_PATTERN}'`;
236
+ if (!existsSync(workspacePath)) {
237
+ writeFileSync(workspacePath, `packages:\n - '.'\n - ${quotedPattern}\n`);
238
+ return { path: workspacePath, previous: undefined };
239
+ }
240
+ const previous = readFileSync(workspacePath, 'utf8');
241
+ const lines = previous.split('\n');
242
+ if (lines.some((line) => /^\s*-\s*['"]?src\/game-mechanics\/\*['"]?\s*(?:#.*)?$/.test(line))) {
243
+ return { path: workspacePath, previous };
244
+ }
245
+ const packagesIndex = lines.findIndex((line) => /^packages:\s*$/.test(line));
246
+ if (packagesIndex < 0) {
247
+ if (lines.some((line) => /^packages\s*:/.test(line))) {
248
+ fail(
249
+ 'add',
250
+ 'pnpm-workspace.yaml packages',
251
+ 'use a block list when the managed pattern is absent',
252
+ `Add " - '${WORKSPACE_PATTERN}'" to the existing packages list.`,
253
+ );
254
+ }
255
+ const separator = previous.endsWith('\n') ? '' : '\n';
256
+ writeFileSync(workspacePath, `${previous}${separator}packages:\n - ${quotedPattern}\n`);
257
+ return { path: workspacePath, previous };
258
+ }
259
+ let insertAt = packagesIndex + 1;
260
+ while (insertAt < lines.length && (lines[insertAt].trim() === '' || /^\s+/.test(lines[insertAt]))) insertAt += 1;
261
+ lines.splice(insertAt, 0, ` - ${quotedPattern}`);
262
+ writeFileSync(workspacePath, lines.join('\n'));
263
+ return { path: workspacePath, previous };
264
+ }
265
+
266
+ function restoreFile(path, previous) {
267
+ if (previous === undefined) rmSync(path, { force: true });
268
+ else writeFileSync(path, previous);
269
+ }
270
+
271
+ function updateProjectManifest(projectRoot, roots, resolvedPackages) {
272
+ const manifestPath = join(projectRoot, 'package.json');
273
+ const previous = readFileSync(manifestPath, 'utf8');
274
+ const manifest = readJson(manifestPath);
275
+ manifest.dependencies ??= {};
276
+ for (const name of Object.keys(roots).sort()) {
277
+ const resolvedPackage = resolvedPackages.get(name);
278
+ if (!resolvedPackage) {
279
+ fail('add', `direct package ${name}`, 'be present in the resolved Miaoda graph', 'Check the source package name and registry metadata.');
280
+ }
281
+ manifest.dependencies[name] = `workspace:${resolvedPackage.version}`;
282
+ }
283
+ writeJson(manifestPath, manifest);
284
+ return { path: manifestPath, previous };
285
+ }
286
+
287
+ function pnpmVersion(pnpmCommand, cwd) {
288
+ const result = spawnSync(pnpmCommand, ['--version'], { cwd, encoding: 'utf8' });
289
+ if (result.status !== 0) {
290
+ const detail = result.error?.message || result.stderr?.trim() || result.stdout?.trim();
291
+ fail('add', 'pnpm command', 'run successfully', detail || `Install pnpm 10 or 11 and ensure ${pnpmCommand} is on PATH.`);
292
+ }
293
+ const version = result.stdout.trim();
294
+ const major = Number.parseInt(version.split('.')[0], 10);
295
+ if (major !== 10 && major !== 11) {
296
+ fail('add', 'pnpm version', `be 10.x or 11.x, but found ${version}`, 'Use a supported project pnpm version.');
297
+ }
298
+ return version;
299
+ }
300
+
301
+ function projectDeclaredPnpmMajor(projectManifest) {
302
+ const match = /^pnpm@(\d+)(?:\.|$)/.exec(projectManifest.packageManager ?? '');
303
+ return match ? Number.parseInt(match[1], 10) : undefined;
304
+ }
305
+
306
+ function runPnpm(pnpmCommand, args, cwd) {
307
+ const result = spawnSync(pnpmCommand, args, {
308
+ cwd,
309
+ encoding: 'utf8',
310
+ env: { ...process.env, CI: 'true' },
311
+ maxBuffer: 16 * 1024 * 1024,
312
+ });
313
+ if (result.status !== 0) {
314
+ const output = result.error?.message || result.stderr || result.stdout || 'pnpm exited without diagnostic output';
315
+ const detail = output.trim().split('\n').slice(-12).join('\n');
316
+ throw new Error(`miaoda mechanics source: pnpm ${args.join(' ')} failed in ${cwd}:\n${detail}`);
317
+ }
318
+ return result;
319
+ }
320
+
321
+ function copyResolvedPackage(target, resolvedPackage, resolvedPackages) {
322
+ cpSync(resolvedPackage.directory, target, {
323
+ recursive: true,
324
+ filter: (source) => {
325
+ const sourceName = basename(source);
326
+ if (['node_modules', 'dist', 'coverage', '.nx', '__tests__'].includes(sourceName)) return false;
327
+ if (/\.(?:spec|test)\.[cm]?[jt]sx?$/.test(sourceName) || /^vitest\.config\.[cm]?[jt]s$/.test(sourceName)) {
328
+ return false;
329
+ }
330
+ const packageRelativePath = relative(resolvedPackage.directory, source);
331
+ return packageRelativePath.includes(sep) || !/^tsconfig(?:\..+)?\.json$/.test(packageRelativePath);
332
+ },
333
+ });
334
+ const sourceManifest = createSourceManifest(resolvedPackage.manifest, resolvedPackages);
335
+ writeJson(join(target, 'package.json'), sourceManifest);
336
+ validateSourceEntrypoints(target, sourceManifest);
337
+ return hashTree(target);
338
+ }
339
+
340
+ const legacyAgentsSource = '# Editable game mechanics\n\n- This directory contains editable TypeScript source, not generated build output.\n- Preserve package boundaries and import packages by their `miaoda-game-*` names.\n- Modify each package under `<package>/src/`; do not edit `node_modules`.\n- Run `pnpm exec miaoda mechanics status` before updating a locally modified package.\n';
341
+ const generatedReadmeMarker = '<!-- Generated by miaoda mechanics. -->';
342
+
343
+ function sourceReadme(resolvedPackages, capabilityDocument) {
344
+ const packages = [...resolvedPackages]
345
+ .sort(([left], [right]) => left.localeCompare(right))
346
+ .map(([name, resolvedPackage]) => {
347
+ const capability = capabilityDocument?.packages?.[name];
348
+ const description = resolvedPackage.manifest.description || 'Miaoda game mechanic package.';
349
+ const owns = capability?.owns?.length ? `\n - Provides: ${capability.owns.join(', ')}` : '';
350
+ return `- \`${name}@${resolvedPackage.version}\` — ${description}${owns}`;
351
+ })
352
+ .join('\n');
353
+ return `${generatedReadmeMarker}
354
+ # Game mechanics
355
+
356
+ Read this README before implementing gameplay.
357
+
358
+ Prefer the installed packages below whenever they cover the mechanic you need. Avoid implementing
359
+ overlapping algorithms, simulation rules, or reusable gameplay logic in the game project. Import
360
+ packages by their \`miaoda-game-*\` names; pnpm workspace links connect their internal dependencies.
361
+
362
+ The source under this directory is editable, but modifying package source is not recommended. First
363
+ use the package's public API, configuration, and composition options. Modify package source only when
364
+ the required game behavior cannot be implemented cleanly through those supported boundaries.
365
+
366
+ ## Available packages
367
+
368
+ ${packages || 'No game mechanic packages are installed.'}
369
+ `;
370
+ }
371
+
372
+ function ensureSourceGuidance(sourceRoot, resolvedPackages, capabilityDocument) {
373
+ const readmePath = join(sourceRoot, 'README.md');
374
+ const existingReadme = existsSync(readmePath) ? readFileSync(readmePath, 'utf8') : undefined;
375
+ if (
376
+ existingReadme === undefined ||
377
+ existingReadme.startsWith(generatedReadmeMarker) ||
378
+ existingReadme.startsWith('# Game mechanics source\n')
379
+ ) {
380
+ writeFileSync(readmePath, sourceReadme(resolvedPackages, capabilityDocument));
381
+ }
382
+ const agentsPath = join(sourceRoot, 'AGENTS.md');
383
+ if (existsSync(agentsPath) && readFileSync(agentsPath, 'utf8') === legacyAgentsSource) {
384
+ rmSync(agentsPath);
385
+ }
386
+ }
387
+
388
+ function materializePackages(
389
+ stagingSourceRoot,
390
+ sourceRoot,
391
+ resolvedPackages,
392
+ previousState,
393
+ statuses,
394
+ capabilityDocument,
395
+ ) {
396
+ mkdirSync(stagingSourceRoot, { recursive: true });
397
+ if (existsSync(sourceRoot)) {
398
+ cpSync(sourceRoot, stagingSourceRoot, {
399
+ recursive: true,
400
+ filter: (source) => basename(source) !== 'node_modules',
401
+ });
402
+ }
403
+ ensureSourceGuidance(stagingSourceRoot, resolvedPackages, capabilityDocument);
404
+ const records = {};
405
+ for (const [name, resolvedPackage] of [...resolvedPackages].sort(([left], [right]) => left.localeCompare(right))) {
406
+ const target = join(stagingSourceRoot, name);
407
+ const previous = previousState.packages[name];
408
+ if (previous && previous.version === resolvedPackage.version) {
409
+ if (statuses[name] === 'missing') {
410
+ fail('add', `${name} source`, 'still exist', `Restore ${join(sourceRoot, name)} or remove its state entry before reinstalling.`);
411
+ }
412
+ records[name] = previous;
413
+ continue;
414
+ }
415
+ if (previous && statuses[name] === 'modified') {
416
+ fail(
417
+ 'add',
418
+ `${name} ${previous.version} source`,
419
+ `be clean before updating to ${resolvedPackage.version}`,
420
+ `Preserve or commit ${join(sourceRoot, name)}, then explicitly restore it before retrying the update. Nothing was overwritten.`,
421
+ );
422
+ }
423
+ if (existsSync(target)) {
424
+ if (!previous) {
425
+ fail('add', `${name} destination`, 'not contain an unmanaged package', `Move ${join(sourceRoot, name)} elsewhere or adopt it explicitly.`);
426
+ }
427
+ rmSync(target, { recursive: true, force: true });
428
+ }
429
+ records[name] = { version: resolvedPackage.version, sha256: copyResolvedPackage(target, resolvedPackage, resolvedPackages) };
430
+ }
431
+ return records;
432
+ }
433
+
434
+ function switchManagedDirectory(sourceRoot, stagedSourceRoot, metadataRoot) {
435
+ const backupRoot = join(metadataRoot, `.game-mechanics-backup-${randomUUID()}`);
436
+ mkdirSync(dirname(sourceRoot), { recursive: true });
437
+ if (existsSync(sourceRoot)) renameSync(sourceRoot, backupRoot);
438
+ try {
439
+ renameSync(stagedSourceRoot, sourceRoot);
440
+ } catch (error) {
441
+ if (existsSync(backupRoot)) renameSync(backupRoot, sourceRoot);
442
+ throw error;
443
+ }
444
+ return { packagesRoot: sourceRoot, backupRoot: existsSync(backupRoot) ? backupRoot : undefined };
445
+ }
446
+
447
+ function restoreManagedDirectory({ packagesRoot, backupRoot }) {
448
+ rmSync(packagesRoot, { recursive: true, force: true });
449
+ if (backupRoot) renameSync(backupRoot, packagesRoot);
450
+ }
451
+
452
+ function parseArguments(argv) {
453
+ const command = argv[0];
454
+ const projectArgument = argv.find((argument) => argument.startsWith('--project='));
455
+ const pnpmArgument = argv.find((argument) => argument.startsWith('--pnpm='));
456
+ const sourceIndexArgument = argv.find((argument) => argument.startsWith('--source-index='));
457
+ const options = new Set(argv.filter((argument) => argument.startsWith('--')).map((argument) => argument.split('=')[0]));
458
+ const specs = argv.slice(1).filter((argument) => !argument.startsWith('--'));
459
+ return {
460
+ command,
461
+ projectRoot: resolve(projectArgument?.slice('--project='.length) || process.cwd()),
462
+ pnpmCommand: pnpmArgument?.slice('--pnpm='.length) || 'pnpm',
463
+ sourceIndexUrl:
464
+ sourceIndexArgument?.slice('--source-index='.length) ||
465
+ process.env.MIAODA_MECHANICS_INDEX_URL?.trim() ||
466
+ DEFAULT_SOURCE_INDEX_URL,
467
+ skipInstall: options.has('--skip-install'),
468
+ specs,
469
+ };
470
+ }
471
+
472
+ const ROOT_HELP = `Usage: miaoda mechanics <command> [options]
473
+
474
+ Manage editable Miaoda game-mechanic TypeScript source in src/game-mechanics.
475
+
476
+ Commands:
477
+ add <package...> Resolve packages from the source index and add editable source
478
+ status Show clean, modified, or missing source packages
479
+ help Show this help
480
+
481
+ Run "miaoda mechanics <command> --help" for command details.`;
482
+
483
+ const ADD_HELP = `Usage: miaoda mechanics add <miaoda-game-package[@version]...> [options]
484
+
485
+ Bare package names resolve to the source index's latest version and name@version
486
+ selects an exact indexed version. Transitive miaoda-game-* dependencies are downloaded
487
+ from the same index. Existing source is never overwritten when its recorded version is
488
+ unchanged. An update is refused if that package has local modifications. Source is
489
+ written to src/game-mechanics. npm registry and direct TGZ resolution are intentionally
490
+ not used for game-mechanic source.
491
+
492
+ Options:
493
+ --project=<path> Consumer project root (default: current directory)
494
+ --pnpm=<command> Project pnpm executable (default: pnpm)
495
+ --source-index=<url>
496
+ Override the default public stable.json URL:
497
+ https://resource-static.bj.bcebos.com/miaoda-game/stable.json
498
+ (also configurable with MIAODA_MECHANICS_INDEX_URL)
499
+ --skip-install Update source and manifests without the final pnpm install
500
+ -h, --help Show this help`;
501
+
502
+ const STATUS_HELP = `Usage: miaoda mechanics status [--project=<path>]
503
+
504
+ Compare editable source with its installation checksum. This reads files only.`;
505
+
506
+ function validateProject(projectRoot) {
507
+ if (!existsSync(projectRoot) || !statSync(projectRoot).isDirectory()) {
508
+ fail('add', 'project', `be an existing directory, but received ${projectRoot}`, 'Pass --project=<absolute-path>.');
509
+ }
510
+ if (!existsSync(join(projectRoot, 'package.json'))) {
511
+ fail('add', 'project package.json', 'exist', `Create ${join(projectRoot, 'package.json')} first.`);
512
+ }
513
+ }
514
+
515
+ function validateMetadataRoot(metadataRoot) {
516
+ if (existsSync(metadataRoot) && lstatSync(metadataRoot).isSymbolicLink()) {
517
+ fail('add', metadataRoot, 'be a real directory, not a symbolic link', `Replace ${METADATA_ROOT} with a project-local directory.`);
518
+ }
519
+ }
520
+
521
+ export function mechanicSourceStatus({ projectRoot }) {
522
+ projectRoot = resolve(projectRoot);
523
+ validateProject(projectRoot);
524
+ const metadataRoot = join(projectRoot, METADATA_ROOT);
525
+ validateMetadataRoot(metadataRoot);
526
+ const state = readState(metadataRoot);
527
+ const sourceRoot = join(projectRoot, SOURCE_ROOT);
528
+ const statuses = inspectExistingPackages(sourceRoot, state);
529
+ return Object.keys(state.packages).sort().map((name) => ({
530
+ name,
531
+ version: state.packages[name].version,
532
+ status: statuses[name],
533
+ path: join(sourceRoot, name),
534
+ }));
535
+ }
536
+
537
+ export async function addMechanicSources({
538
+ projectRoot,
539
+ pnpmCommand = 'pnpm',
540
+ sourceIndexUrl,
541
+ skipInstall = false,
542
+ specs,
543
+ }) {
544
+ projectRoot = resolve(projectRoot);
545
+ validateProject(projectRoot);
546
+ if (!Array.isArray(specs) || specs.length === 0) {
547
+ fail('add', 'package specs', 'contain at least one miaoda-game-* package', 'Pass a package name with an optional @version.');
548
+ }
549
+ if (!sourceIndexUrl) {
550
+ fail(
551
+ 'add',
552
+ 'source index',
553
+ 'be configured for package-name resolution',
554
+ 'Pass --source-index=https://.../stable.json or set MIAODA_MECHANICS_INDEX_URL.',
555
+ );
556
+ }
557
+ if (!areIndexedPackageSpecs(specs)) {
558
+ fail(
559
+ 'add',
560
+ 'package specs',
561
+ 'contain only miaoda-game-* names with optional @version',
562
+ 'Publish source artifacts to the configured storage and request them through stable.json.',
563
+ );
564
+ }
565
+ const projectManifest = readJson(join(projectRoot, 'package.json'));
566
+ const version = pnpmVersion(pnpmCommand, projectRoot);
567
+ const declaredMajor = projectDeclaredPnpmMajor(projectManifest);
568
+ if (declaredMajor && declaredMajor !== Number.parseInt(version.split('.')[0], 10)) {
569
+ fail(
570
+ 'add',
571
+ 'invoked pnpm major',
572
+ `match packageManager ${projectManifest.packageManager}, but found ${version}`,
573
+ 'Run the command through the project Corepack/pnpm configuration.',
574
+ );
575
+ }
576
+
577
+ const metadataRoot = join(projectRoot, METADATA_ROOT);
578
+ const sourceRoot = join(projectRoot, SOURCE_ROOT);
579
+ validateMetadataRoot(metadataRoot);
580
+ mkdirSync(metadataRoot, { recursive: true });
581
+ const statePath = join(metadataRoot, STATE_FILENAME);
582
+ const lockfilePath = join(projectRoot, 'pnpm-lock.yaml');
583
+ const nodeModulesPath = join(projectRoot, 'node_modules');
584
+ const previousStateText = existsSync(statePath) ? readFileSync(statePath, 'utf8') : undefined;
585
+ const previousLockfileText = existsSync(lockfilePath) ? readFileSync(lockfilePath, 'utf8') : undefined;
586
+ const nodeModulesExisted = existsSync(nodeModulesPath);
587
+ const previousState = readState(metadataRoot);
588
+ const statuses = inspectExistingPackages(sourceRoot, previousState);
589
+
590
+ const stagedPackagesRoot = join(metadataRoot, `.game-mechanics-staging-${randomUUID()}`);
591
+ let switched;
592
+ let workspaceChange;
593
+ let projectManifestChange;
594
+ let indexedResolution;
595
+ try {
596
+ const previousRoots = Object.fromEntries(
597
+ Object.keys(previousState.roots)
598
+ .map((name) => [name, previousState.packages[name]?.version])
599
+ .filter((entry) => typeof entry[1] === 'string' && entry[1].length > 0),
600
+ );
601
+ indexedResolution = await resolveIndexedMechanics({
602
+ indexUrl: sourceIndexUrl,
603
+ previousRoots,
604
+ specs,
605
+ });
606
+ const roots = indexedResolution.roots;
607
+ const directNames = Object.keys(roots).sort();
608
+ const resolvedPackages = indexedResolution.resolvedPackages;
609
+ const capabilityDocument = readJson(capabilityPath);
610
+ validateMechanicSelection(projectManifest, resolvedPackages, capabilityDocument);
611
+ const packageRecords = materializePackages(
612
+ stagedPackagesRoot,
613
+ sourceRoot,
614
+ resolvedPackages,
615
+ previousState,
616
+ statuses,
617
+ capabilityDocument,
618
+ );
619
+
620
+ switched = switchManagedDirectory(sourceRoot, stagedPackagesRoot, metadataRoot);
621
+ workspaceChange = ensureWorkspacePattern(projectRoot);
622
+ projectManifestChange = updateProjectManifest(projectRoot, roots, resolvedPackages);
623
+ writeJson(statePath, {
624
+ schemaVersion: STATE_SCHEMA_VERSION,
625
+ pnpmVersion: version,
626
+ roots,
627
+ packages: packageRecords,
628
+ sourceIndexUrl: indexedResolution.sourceIndexUrl,
629
+ });
630
+
631
+ if (!skipInstall) runPnpm(pnpmCommand, ['install', '--ignore-scripts', '--no-frozen-lockfile'], projectRoot);
632
+ if (switched.backupRoot) rmSync(switched.backupRoot, { recursive: true, force: true });
633
+ return {
634
+ directPackages: directNames,
635
+ packageNames: [...resolvedPackages.keys()].sort(),
636
+ packagesRoot: switched.packagesRoot,
637
+ pnpmVersion: version,
638
+ };
639
+ } catch (error) {
640
+ if (projectManifestChange) restoreFile(projectManifestChange.path, projectManifestChange.previous);
641
+ if (workspaceChange) restoreFile(workspaceChange.path, workspaceChange.previous);
642
+ if (switched) restoreManagedDirectory(switched);
643
+ restoreFile(statePath, previousStateText);
644
+ restoreFile(lockfilePath, previousLockfileText);
645
+ if (!nodeModulesExisted) rmSync(nodeModulesPath, { recursive: true, force: true });
646
+ throw error;
647
+ } finally {
648
+ cleanupIndexedMechanics(indexedResolution);
649
+ rmSync(stagedPackagesRoot, { recursive: true, force: true });
650
+ }
651
+ }
652
+
653
+ export async function main(argv = process.argv.slice(2)) {
654
+ const mechanicsArguments = argv[0] === 'mechanics' ? argv.slice(1) : argv;
655
+ const command = mechanicsArguments[0];
656
+ const wantsHelp = mechanicsArguments.includes('--help') || mechanicsArguments.includes('-h');
657
+ if (!command || command === 'help' || wantsHelp && !['add', 'status'].includes(command)) {
658
+ console.log(ROOT_HELP);
659
+ return;
660
+ }
661
+ if (command === 'add') {
662
+ if (wantsHelp) {
663
+ console.log(ADD_HELP);
664
+ return;
665
+ }
666
+ const options = parseArguments(mechanicsArguments);
667
+ const result = await addMechanicSources(options);
668
+ console.log(formatAddResult(result));
669
+ return result;
670
+ }
671
+ if (command === 'status') {
672
+ if (wantsHelp) {
673
+ console.log(STATUS_HELP);
674
+ return;
675
+ }
676
+ const options = parseArguments(mechanicsArguments);
677
+ const statuses = mechanicSourceStatus(options);
678
+ if (statuses.length === 0) console.log('No Miaoda game-mechanic source packages are installed.');
679
+ else for (const entry of statuses) console.log(`${entry.name}@${entry.version} ${entry.status} ${entry.path}`);
680
+ return statuses;
681
+ }
682
+ throw new Error(`Unknown miaoda mechanics command: ${command}\n\n${ROOT_HELP}`);
683
+ }
684
+
685
+ export function formatAddResult(result) {
686
+ const packagePaths = result.packageNames
687
+ .map((name) => ` - src/game-mechanics/${name}/`)
688
+ .join('\n');
689
+ return `✓ Added ${result.packageNames.length} editable game-mechanic source packages
690
+
691
+ Added source:
692
+ ${packagePaths}
693
+
694
+ Next:
695
+ Read src/game-mechanics/README.md before implementing gameplay.
696
+ Prefer these packages and avoid reimplementing overlapping algorithms or reusable gameplay logic.
697
+ Package source is editable, but modification is not recommended unless public APIs, configuration,
698
+ and composition cannot implement the required behavior.`;
699
+ }
700
+
701
+ if (process.argv[1] && resolve(process.argv[1]) === modulePath) {
702
+ main().catch((error) => {
703
+ console.error(error instanceof Error ? error.message : String(error));
704
+ process.exitCode = 1;
705
+ });
706
+ }