Files
Genarrative/apps/ai-game-creator-shell/scripts/check-config.mjs
T
kdletters d9151219ab
Project CI / AI game creator shell Rust shard 1/4 (push) Successful in 4m49s
Project CI / AI game creator shell Rust smoke (push) Successful in 1m45s
Project CI / AI game creator shell Rust shard 2/4 (push) Successful in 5m15s
Project CI / AI game creator shell Rust shard 3/4 (push) Successful in 5m34s
Project CI / AI game creator shell Rust shard 4/4 (push) Successful in 4m5s
Project CI / AI game creator shell Rust crates (push) Successful in 3m34s
Project CI / Frontend tests (push) Successful in 4m45s
Project CI / Repository checks (push) Successful in 3m20s
Project CI / Backend tests (push) Successful in 7m11s
Project CI / Native shell tests (push) Successful in 7m48s
Project CI / AI game creator shell web tests (push) Successful in 3m25s
调整陶泥儿安装包资源与显示名称
安装包内置 Codex 顶层目录改为 coding-agent

Windows 注册表和快捷方式显示名改为陶泥儿

同步运行时路径、配置校验与项目文档
2026-09-15 15:10:45 +08:00

1913 lines
58 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import assert from 'node:assert/strict';
import { execFileSync } from 'node:child_process';
import { EventEmitter } from 'node:events';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import ts from 'typescript';
import {
appIdentifier,
defaultRealSwarmTestTask,
} from './agent-swarm-test-chat.mjs';
import {
askHidden,
assertSafeGameCreatorConfigDestination,
buildGameCreatorWizardConfig,
readGameCreatorWizardConfigState,
writeGameCreatorConfigAtomically,
writeGameCreatorWizardConfig,
} from './game-creator-config-wizard.mjs';
const packageConfig = JSON.parse(
fs.readFileSync(new URL('../package.json', import.meta.url), 'utf8'),
);
const tauriConfig = JSON.parse(
fs.readFileSync(
new URL('../src-tauri/tauri.conf.json', import.meta.url),
'utf8',
),
);
const windowsTauriConfig = JSON.parse(
fs.readFileSync(
new URL('../src-tauri/tauri.windows.conf.json', import.meta.url),
'utf8',
),
);
const cargoManifestSource = fs.readFileSync(
new URL('../src-tauri/Cargo.toml', import.meta.url),
'utf8',
);
const cargoPackageVersion = cargoManifestSource
.split(/\r?\n(?=\[)/u)
.find((section) => section.startsWith('[package]'))
?.match(/^version\s*=\s*"([^"]+)"\s*$/mu)?.[1];
const eventCapabilityPath = new URL(
'../src-tauri/capabilities/events.json',
import.meta.url,
);
const defaultAppConfig = JSON.parse(
fs.readFileSync(
new URL('../game-creator.config.json', import.meta.url),
'utf8',
),
);
const rootPackageConfig = JSON.parse(
fs.readFileSync(new URL('../../../package.json', import.meta.url), 'utf8'),
);
const swarmTestChatSource = fs.readFileSync(
new URL('../scripts/agent-swarm-test-chat.mjs', import.meta.url),
'utf8',
);
const viteConfigSource = fs.readFileSync(
new URL('../vite.config.ts', import.meta.url),
'utf8',
);
const devPortSource = fs.readFileSync(
new URL('../scripts/dev-port.mjs', import.meta.url),
'utf8',
);
const startTauriDevSource = fs.readFileSync(
new URL('../scripts/start-tauri-dev.mjs', import.meta.url),
'utf8',
);
const appSource = [
readSourceTree(new URL('../src/', import.meta.url), '.ts'),
readSourceTree(new URL('../src/', import.meta.url), '.tsx'),
].join('\n');
const appInvokeSources = readSourceFiles(
new URL('../src/', import.meta.url),
new Set(['.ts', '.tsx']),
);
const appEntrypointSource = fs.readFileSync(
new URL('../src/main.tsx', import.meta.url),
'utf8',
);
const tauriHandlerSource = fs.readFileSync(
new URL('../src-tauri/src/main.rs', import.meta.url),
'utf8',
);
const tauriRustSource = readSourceTree(
new URL('../src-tauri/src/', import.meta.url),
'.rs',
);
const sharedContractSource = fs.readFileSync(
new URL(
'../../../packages/shared/src/contracts/gameCreationApp.ts',
import.meta.url,
),
'utf8',
);
const rustSharedContractSource = fs.readFileSync(
new URL(
'../../../server-rs/crates/shared-contracts/src/game_creation_app.rs',
import.meta.url,
),
'utf8',
);
const allowedUncalledTauriCommands = [
'append_direct_project_conversation_message',
// TODO: Remove the retired binding command after the legacy runtime path is removed.
'bind_components',
'chat_with_game_creator_agent',
'check_ui_editor_font_glyph_coverage',
'create_ui_design_resource',
// 图片类生成的同步变体:GUI 已改为 `start_local_project_asset_generation` + 项目内任务账本
// (提交即返回、后台生成)。这条命令**没有生产调用方**,只有 Rust 集成测试
// `src/tests/project.rs`)与 `commands.rs` 单测在调;待后续批次删除,或改为转调
// `start_local_project_asset_generation`。
'generate_local_project_asset',
'open_game_creator_launcher_window',
'open_game_creator_workspace_window',
'read_direct_project_conversation',
'reset_design_agent_session',
'stop_local_game_preview_if_matches',
'start_game_creator_external_mcp',
'stop_game_creator_external_mcp',
'list_agc_plugins',
'list_agc_extensions',
'refresh_agc_plugins',
'start_agc_plugin',
'stop_agc_plugin',
'reload_agc_plugin',
'call_agc_plugin',
'read_agc_plugin_panel',
'set_agc_plugin_project_path',
'set_agc_plugin_enabled',
];
const sourceExtensions = new Set([
'.json',
'.md',
'.mjs',
'.rs',
'.toml',
'.ts',
'.tsx',
]);
function collectFiles(path) {
const stat = fs.statSync(path);
if (stat.isDirectory()) {
return fs.readdirSync(path, { withFileTypes: true }).flatMap((entry) => {
if (entry.name === 'node_modules' || entry.name === 'target') {
return [];
}
return collectFiles(
new URL(`${entry.name}${entry.isDirectory() ? '/' : ''}`, path),
);
});
}
if (sourceExtensions.has(pathnameExtension(path.pathname))) {
return [path];
}
return [];
}
function readSourceTree(path, extension) {
const stat = fs.statSync(path);
if (stat.isDirectory()) {
return fs
.readdirSync(path, { withFileTypes: true })
.sort((left, right) => left.name.localeCompare(right.name))
.map((entry) =>
readSourceTree(
new URL(`${entry.name}${entry.isDirectory() ? '/' : ''}`, path),
extension,
),
)
.join('\n');
}
if (pathnameExtension(path.pathname) !== extension) {
return '';
}
return fs.readFileSync(path, 'utf8');
}
function readSourceFiles(path, extensions) {
const stat = fs.statSync(path);
if (stat.isDirectory()) {
return fs
.readdirSync(path, { withFileTypes: true })
.sort((left, right) => left.name.localeCompare(right.name))
.flatMap((entry) =>
readSourceFiles(
new URL(`${entry.name}${entry.isDirectory() ? '/' : ''}`, path),
extensions,
),
);
}
if (!extensions.has(pathnameExtension(path.pathname))) return [];
return [{ fileName: path.pathname, source: fs.readFileSync(path, 'utf8') }];
}
function pathnameExtension(pathname) {
const index = pathname.lastIndexOf('.');
return index === -1 ? '' : pathname.slice(index);
}
function assertNoOpenAiApiKeys(paths) {
const secretPattern = /sk-[A-Za-z0-9_-]{20,}/;
for (const path of paths.flatMap((entry) => collectFiles(entry))) {
const source = fs.readFileSync(path, 'utf8');
if (secretPattern.test(source)) {
throw new Error(
`AI game creator shell source must not contain API keys: ${path.pathname}`,
);
}
}
}
function assertNoEnvironmentConfigFallbacks(paths) {
const allowedDevCheck = 'import.meta.env.DEV';
for (const path of paths.flatMap((entry) => collectFiles(entry))) {
const source = fs
.readFileSync(path, 'utf8')
.replaceAll(allowedDevCheck, '')
.replaceAll('game-creator.config.local.json', '');
if (/\bprocess\.env\b|\bdotenv\b/.test(source)) {
throw new Error(
`AI game creator shell must use runtime config, not environment config: ${path.pathname}`,
);
}
}
}
function assertNoNativeBrowserConfirm(paths) {
for (const path of paths.flatMap((entry) => collectFiles(entry))) {
const source = fs.readFileSync(path, 'utf8');
if (/\bwindow\.confirm\b/.test(source)) {
throw new Error(
`AI game creator shell confirmations must use in-app UI: ${path.pathname}`,
);
}
}
}
function assertNoBlockingNativeFilePicker(source) {
if (/\.blocking_pick_(?:file|files|folder|folders)\s*\(/.test(source)) {
throw new Error(
'AI game creator shell native file pickers must not block the Tauri event loop',
);
}
}
function extractConstArrayBlock(source, name) {
const start = source.indexOf(`const ${name}`);
if (start === -1) {
throw new Error(`Missing contract array: ${name}`);
}
const end = source.indexOf('];', start);
if (end === -1) {
throw new Error(`Missing contract array end: ${name}`);
}
return source.slice(start, end + 2);
}
function parseTsCommands(source) {
const block = extractConstArrayBlock(source, 'GAME_CREATION_APP_COMMANDS');
return Array.from(
block.matchAll(/\{\s*id:\s*'([^']+)',\s*permission:\s*'([^']+)'\s*\}/g),
([, id, permission]) => ({ id, permission }),
);
}
function parseRustCommands(source) {
const block = extractConstArrayBlock(source, 'GAME_CREATION_APP_COMMANDS');
const permissionNames = {
Auto: 'auto',
Confirm: 'confirm',
Deny: 'deny',
};
return Array.from(
block.matchAll(
/command\(\s*"([^"]+)",\s*GameCreationAppPermission::(Auto|Confirm|Deny)\s*\)/g,
),
([, id, permission]) => ({ id, permission: permissionNames[permission] }),
);
}
function parseTsCapabilities(source) {
const block = extractConstArrayBlock(
source,
'GAME_CREATION_AGENT_CAPABILITIES',
);
return Array.from(
block.matchAll(
/\{\s*id:\s*'([^']+)',\s*area:\s*'([^']+)',\s*title:\s*'([^']+)',?\s*\}/g,
),
([, id, area, title]) => ({ id, area, title }),
);
}
function parseRustCapabilities(source) {
const block = extractConstArrayBlock(
source,
'GAME_CREATION_AGENT_CAPABILITIES',
);
return Array.from(
block.matchAll(
/capability\(\s*"([^"]+)",\s*"([^"]+)",\s*"([^"]+)",?\s*\)/g,
),
([, id, area, title]) => ({ id, area, title }),
);
}
function assertContractRecordsMatch(label, leftRecords, rightRecords) {
const normalize = (records) =>
records
.map((record) => JSON.stringify(record))
.sort((left, right) => left.localeCompare(right));
const left = normalize(leftRecords);
const right = normalize(rightRecords);
if (left.length === 0 || right.length === 0) {
throw new Error(`${label} parser returned no records`);
}
if (JSON.stringify(left) !== JSON.stringify(right)) {
throw new Error(
`${label} drifted between TypeScript and Rust contracts\nTS=${left.join(
'\n',
)}\nRust=${right.join('\n')}`,
);
}
}
const APP_INVOKE_FILE_MAX_COUNT = 4 * 1024;
const APP_INVOKE_TOTAL_SOURCE_MAX_LENGTH = 16 * 1024 * 1024;
const APP_INVOKE_SOURCE_MAX_LENGTH = 2 * 1024 * 1024;
const APP_INVOKE_COMMAND_MAX_LENGTH = 128;
const APP_INVOKE_CALL_MAX_COUNT = 4 * 1024;
const APP_INVOKE_BARE_CALL_NAMES = new Set([
'invoke',
'directInvoke',
'invokeInput',
'invokeAuthenticatedInput',
'invokeDiagnostic',
]);
function parseAppInvokeCommandNames(source, fileName = 'fixture.tsx') {
const sourceByteLength = Buffer.byteLength(source, 'utf8');
if (sourceByteLength > APP_INVOKE_SOURCE_MAX_LENGTH) {
throw new Error(
`AI game creator shell App invoke source exceeds ${APP_INVOKE_SOURCE_MAX_LENGTH} bytes: ${fileName}`,
);
}
const sourceFile = ts.createSourceFile(
fileName,
source,
ts.ScriptTarget.Latest,
true,
fileName.endsWith('.tsx') ? ts.ScriptKind.TSX : ts.ScriptKind.TS,
);
const parseDiagnostic = sourceFile.parseDiagnostics[0];
if (parseDiagnostic !== undefined) {
throw new Error(
`AI game creator shell App invoke source cannot be parsed: ${fileName} (TS${parseDiagnostic.code})`,
);
}
const commands = [];
const visit = (node) => {
if (ts.isCallExpression(node)) {
const expression = node.expression;
const isBareCall =
ts.isIdentifier(expression) &&
APP_INVOKE_BARE_CALL_NAMES.has(expression.text);
const isObjectInvoke =
ts.isPropertyAccessExpression(expression) &&
expression.name.text === 'invoke';
const commandArgument =
ts.isIdentifier(expression) && expression.text === 'invokeDiagnostic'
? node.arguments[1]
: node.arguments[0];
if (
(isBareCall || isObjectInvoke) &&
commandArgument !== undefined &&
ts.isStringLiteral(commandArgument) &&
commandArgument.text.length <= APP_INVOKE_COMMAND_MAX_LENGTH &&
/^[a-z0-9_]+$/u.test(commandArgument.text)
) {
commands.push(commandArgument.text);
if (commands.length > APP_INVOKE_CALL_MAX_COUNT) {
throw new Error(
`AI game creator shell App invoke calls exceed ${APP_INVOKE_CALL_MAX_COUNT}: ${fileName}`,
);
}
}
}
ts.forEachChild(node, visit);
};
visit(sourceFile);
return commands;
}
function parseAppInvokeSourceFiles(files) {
if (files.length > APP_INVOKE_FILE_MAX_COUNT) {
throw new Error(
`AI game creator shell App invoke files exceed ${APP_INVOKE_FILE_MAX_COUNT}`,
);
}
const totalLength = files.reduce(
(length, file) => length + Buffer.byteLength(file.source, 'utf8'),
0,
);
if (totalLength > APP_INVOKE_TOTAL_SOURCE_MAX_LENGTH) {
throw new Error(
`AI game creator shell App invoke sources exceed ${APP_INVOKE_TOTAL_SOURCE_MAX_LENGTH} bytes`,
);
}
const commands = files.flatMap(({ fileName, source }) =>
parseAppInvokeCommandNames(source, fileName),
);
if (commands.length > APP_INVOKE_CALL_MAX_COUNT) {
throw new Error(
`AI game creator shell App invoke calls exceed ${APP_INVOKE_CALL_MAX_COUNT}`,
);
}
return commands;
}
function parseTauriHandlerCommandNames(source) {
const match = source.match(/tauri::generate_handler!\[([\s\S]*?)\]/);
if (!match) {
throw new Error('AI game creator shell Tauri handler list is missing');
}
return Array.from(
match[1].matchAll(/\b([a-z][a-z0-9_]+)\b/g),
([, command]) => command,
);
}
function parseRustFunctionNames(source) {
return Array.from(
source.matchAll(/\b(?:async\s+)?fn\s+([a-z][a-z0-9_]*)\s*\(/g),
([, name]) => name,
);
}
function assertCommandNamesSubset(label, leftNames, rightNames) {
const right = new Set(rightNames);
const missing = Array.from(new Set(leftNames))
.filter((name) => !right.has(name))
.sort((left, rightName) => left.localeCompare(rightName));
if (missing.length > 0) {
throw new Error(`${label} missing commands: ${missing.join(', ')}`);
}
}
function assertCommandNamesDisjoint(label, leftNames, rightNames) {
const right = new Set(rightNames);
const overlapping = Array.from(new Set(leftNames))
.filter((name) => right.has(name))
.sort((left, rightName) => left.localeCompare(rightName));
if (overlapping.length > 0) {
throw new Error(`${label} overlapping commands: ${overlapping.join(', ')}`);
}
}
function runAppInvokeParserRegressionChecks() {
assert.deepEqual(
parseAppInvokeCommandNames(`
invoke('direct_command', {});
directInvoke<Result>('generic_direct_command', {});
invokeInput < Result > ('input_wrapper_command', {});
invokeAuthenticatedInput<Result>(
'authenticated_input_wrapper_command',
{},
);
input.invoke<Result>('object_field_command', {});
`),
[
'direct_command',
'generic_direct_command',
'input_wrapper_command',
'authenticated_input_wrapper_command',
'object_field_command',
],
);
assert.deepEqual(
parseAppInvokeCommandNames(`
// invoke('line_comment_decoy')
/* invokeInput('block_comment_decoy') */
const quoted = "directInvoke('string_decoy')";
const template = \`input.invoke('template_decoy')\`;
const expression = /invokeAuthenticatedInput\\('regex_decoy'\\)/u;
invokeCommand('unrelated_name');
myinvoke('unrelated_suffix');
invoke(dynamicCommand, {});
invoke('UPPERCASE_COMMAND', {});
`),
[],
);
assert.throws(
() => parseAppInvokeCommandNames("invoke<Result>('malformed_generic', {"),
/source cannot be parsed/u,
);
assert.deepEqual(
parseAppInvokeCommandNames(
`invoke('${'a'.repeat(APP_INVOKE_COMMAND_MAX_LENGTH + 1)}', {})`,
),
[],
);
assert.throws(
() =>
parseAppInvokeCommandNames(' '.repeat(APP_INVOKE_SOURCE_MAX_LENGTH + 1)),
/source exceeds/u,
);
const wrapperInvocations = parseAppInvokeCommandNames(
"invokeInput<Result>('wrapper_reachability_command', {})",
);
assert.doesNotThrow(() =>
assertCommandNamesSubset(
'App invoke parser reachability fixture',
['wrapper_reachability_command'],
wrapperInvocations,
),
);
assert.throws(
() =>
assertCommandNamesDisjoint(
'App invoke parser false allowlist fixture',
wrapperInvocations,
['wrapper_reachability_command'],
),
/overlapping commands: wrapper_reachability_command/u,
);
assert.throws(
() =>
assertCommandNamesSubset(
'App invoke parser removed wrapper fixture',
['wrapper_reachability_command'],
parseAppInvokeCommandNames('const wrapperWasRemoved = true;'),
),
/missing commands: wrapper_reachability_command/u,
);
}
function gitCheckResult({ code = 0, signal = null, stdout = '', stderr = '' }) {
return { code, signal, stdout, stderr };
}
class HiddenInputFixture extends EventEmitter {
constructor({ failRawRestore = false } = {}) {
super();
this.isTTY = true;
this.isRaw = false;
this.paused = true;
this.failRawRestore = failRawRestore;
this.rawModeChanges = [];
}
isPaused() {
return this.paused;
}
setRawMode(enabled) {
this.rawModeChanges.push(enabled);
this.isRaw = enabled;
if (!enabled && this.failRawRestore) {
throw new Error('fixture raw restore failure');
}
return this;
}
resume() {
this.paused = false;
return this;
}
pause() {
this.paused = true;
return this;
}
}
function hiddenOutputFixture() {
const writes = [];
return {
writes,
write(value) {
writes.push(value);
return true;
},
};
}
function assertHiddenInputRestored(input) {
assert.equal(input.listenerCount('data'), 0);
assert.equal(input.listenerCount('end'), 0);
assert.equal(input.listenerCount('error'), 0);
assert.equal(input.paused, true);
}
async function runHiddenInputRegressionChecks() {
const endedInput = new HiddenInputFixture();
const endedPromise = askHidden('fixture', {
input: endedInput,
output: hiddenOutputFixture(),
});
endedInput.emit('end');
await assert.rejects(endedPromise, /隐藏输入在完成前已结束/u);
assert.deepEqual(endedInput.rawModeChanges, [true, false]);
assertHiddenInputRestored(endedInput);
const erroredInput = new HiddenInputFixture();
const erroredPromise = askHidden('fixture', {
input: erroredInput,
output: hiddenOutputFixture(),
});
erroredInput.emit('error', new Error('fixture stdin error'));
await assert.rejects(erroredPromise, /fixture stdin error/u);
assert.deepEqual(erroredInput.rawModeChanges, [true, false]);
assertHiddenInputRestored(erroredInput);
const restoreFailedInput = new HiddenInputFixture({ failRawRestore: true });
const restoreFailedPromise = askHidden('fixture', {
input: restoreFailedInput,
output: hiddenOutputFixture(),
});
restoreFailedInput.emit('data', Buffer.from('secret\n'));
await assert.rejects(restoreFailedPromise, /恢复终端 raw mode 失败/u);
assert.deepEqual(restoreFailedInput.rawModeChanges, [true, false]);
assertHiddenInputRestored(restoreFailedInput);
const signaledInput = new HiddenInputFixture();
const signalSource = new EventEmitter();
const forwardedSignals = [];
const signaledPromise = askHidden('fixture', {
input: signaledInput,
output: hiddenOutputFixture(),
signalSource,
terminateForSignal: (signal) => forwardedSignals.push(signal),
platform: 'win32',
});
signalSource.emit('SIGBREAK');
await assert.rejects(signaledPromise, /SIGBREAK/u);
assert.deepEqual(signaledInput.rawModeChanges, [true, false]);
assertHiddenInputRestored(signaledInput);
assert.deepEqual(forwardedSignals, ['SIGBREAK']);
for (const signal of ['SIGINT', 'SIGTERM', 'SIGHUP', 'SIGBREAK']) {
assert.equal(signalSource.listenerCount(signal), 0);
}
}
async function runConfigWizardRegressionChecks() {
const testRoot = fs.mkdtempSync(
path.join(os.tmpdir(), 'genarrative-agc-config-check-'),
);
try {
const canonicalTestRoot = fs.realpathSync.native(testRoot);
const realConfigAncestor = path.join(testRoot, 'real-config-ancestor');
const linkedConfigAncestor = path.join(testRoot, 'linked-config-ancestor');
fs.mkdirSync(realConfigAncestor);
fs.symlinkSync(
realConfigAncestor,
linkedConfigAncestor,
process.platform === 'win32' ? 'junction' : 'dir',
);
const missingLinkedConfigDir = path.join(
linkedConfigAncestor,
'missing-appdata',
);
assert.equal(fs.existsSync(missingLinkedConfigDir), false);
assert.equal(
await assertSafeGameCreatorConfigDestination(missingLinkedConfigDir),
path.join(fs.realpathSync.native(realConfigAncestor), 'missing-appdata'),
);
const gitRoot = path.join(testRoot, 'tracked-repository');
const trackedConfigDir = path.join(gitRoot, 'runtime-config');
fs.mkdirSync(trackedConfigDir, { recursive: true });
fs.writeFileSync(
path.join(trackedConfigDir, 'game-creator.config.json'),
'{}\n',
{ mode: 0o600 },
);
execFileSync('git', ['init', '--quiet', gitRoot], { stdio: 'ignore' });
execFileSync(
'git',
['-C', gitRoot, 'add', 'runtime-config/game-creator.config.json'],
{ stdio: 'ignore' },
);
await assert.rejects(
assertSafeGameCreatorConfigDestination(trackedConfigDir),
/Git 已跟踪/u,
);
const untrackedConfigDir = path.join(gitRoot, 'untracked-runtime-config');
await assert.rejects(
assertSafeGameCreatorConfigDestination(untrackedConfigDir),
/Git 仓库之外/u,
);
const outsideConfigDir = path.join(testRoot, 'outside-appdata');
assert.equal(
await assertSafeGameCreatorConfigDestination(outsideConfigDir),
path.join(canonicalTestRoot, 'outside-appdata'),
);
await assert.rejects(
assertSafeGameCreatorConfigDestination(outsideConfigDir, {
requireDedicatedLeaf: true,
}),
new RegExp(appIdentifier.replaceAll('.', '\\.')),
);
const dedicatedConfigDir = path.join(testRoot, appIdentifier);
assert.equal(
await assertSafeGameCreatorConfigDestination(dedicatedConfigDir, {
requireDedicatedLeaf: true,
}),
path.join(canonicalTestRoot, appIdentifier),
);
const injectedNonGitConfigDir = path.join(testRoot, 'injected-non-git');
assert.equal(
await assertSafeGameCreatorConfigDestination(injectedNonGitConfigDir, {
runGit: async () =>
gitCheckResult({
code: 128,
stderr:
'fatal: not a git repository (or any of the parent directories): .git\n',
}),
}),
path.join(canonicalTestRoot, 'injected-non-git'),
);
await assert.rejects(
assertSafeGameCreatorConfigDestination(
path.join(testRoot, 'missing-git-command'),
{
runGit: async () => {
const error = new Error('spawn git ENOENT');
error.code = 'ENOENT';
throw error;
},
},
),
/Git 命令不可用或启动失败/u,
);
await assert.rejects(
assertSafeGameCreatorConfigDestination(
path.join(testRoot, 'abnormal-rev-parse'),
{
runGit: async () =>
gitCheckResult({
code: 129,
stderr: 'fixture rev-parse failure',
}),
},
),
/Git 检查异常.*fixture rev-parse failure/u,
);
const abnormalTrackedGitRoot = path.join(
testRoot,
'abnormal-tracked-repository',
);
const abnormalTrackedConfigDir = path.join(
abnormalTrackedGitRoot,
'runtime-config',
);
fs.mkdirSync(abnormalTrackedConfigDir, { recursive: true });
let gitCheckCount = 0;
await assert.rejects(
assertSafeGameCreatorConfigDestination(abnormalTrackedConfigDir, {
runGit: async () => {
gitCheckCount += 1;
if (gitCheckCount === 1) {
return gitCheckResult({ stdout: `${abnormalTrackedGitRoot}\n` });
}
return gitCheckResult({
code: 2,
stderr: 'fixture ls-files failure',
});
},
}),
/配置文件跟踪状态.*fixture ls-files failure/u,
);
assert.equal(gitCheckCount, 2);
const overlayConfigDir = path.join(testRoot, 'overlay-appdata');
fs.mkdirSync(overlayConfigDir, { recursive: true, mode: 0o700 });
const primaryConfigPath = path.join(
overlayConfigDir,
'game-creator.config.json',
);
const localConfigPath = path.join(
overlayConfigDir,
'game-creator.config.local.json',
);
const primaryConfig = {
llm: {
apiKey: 'fixture-primary-key',
baseUrl: 'https://primary.example.test/v1',
model: 'primary-model',
apiKind: 'openai_chat',
stream: false,
requestTimeoutMs: 12345,
},
editorApi: {
apiKey: 'fixture-editor-key',
baseUrl: 'http://127.0.0.1:8082',
},
};
const localConfig = {
llm: {
apiKey: 'fixture-old-overlay-key',
model: 'old-overlay-model',
stream: true,
},
agentLlm: {
planner: { model: 'planner-overlay-model' },
},
};
fs.writeFileSync(
primaryConfigPath,
`${JSON.stringify(primaryConfig, null, 2)}\n`,
{ mode: 0o644 },
);
fs.writeFileSync(
localConfigPath,
`${JSON.stringify(localConfig, null, 2)}\n`,
{ mode: 0o644 },
);
const overlayState =
await readGameCreatorWizardConfigState(overlayConfigDir);
if (process.platform !== 'win32') {
assert.equal(fs.statSync(primaryConfigPath).mode & 0o777, 0o600);
assert.equal(fs.statSync(localConfigPath).mode & 0o777, 0o600);
}
assert.equal(overlayState.configPath, primaryConfigPath);
assert.equal(overlayState.localConfigPath, localConfigPath);
assert.equal(overlayState.effectiveConfig.llm.model, 'old-overlay-model');
assert.equal(overlayState.effectiveConfig.llm.stream, true);
assert.equal(overlayState.effectiveConfig.llm.requestTimeoutMs, 12345);
assert.deepEqual(
overlayState.writeConfig.editorApi,
primaryConfig.editorApi,
);
const updatedPrimary = buildGameCreatorWizardConfig(
overlayState.writeConfig,
{
apiKey: 'fixture-new-key',
baseUrl: 'https://new.example.test/v1/',
model: 'new-model',
apiKind: 'openai_responses',
},
);
assert.equal(updatedPrimary.agentMode, 'provider');
await writeGameCreatorWizardConfig(overlayState, updatedPrimary);
const reloadedState =
await readGameCreatorWizardConfigState(overlayConfigDir);
assert.equal(reloadedState.effectiveConfig.llm.apiKey, 'fixture-new-key');
assert.equal(reloadedState.effectiveConfig.agentMode, 'provider');
assert.equal(reloadedState.effectiveConfig.llm.model, 'new-model');
assert.equal(
reloadedState.effectiveConfig.llm.baseUrl,
'https://new.example.test/v1',
);
assert.equal(reloadedState.effectiveConfig.llm.requestTimeoutMs, 12345);
assert.equal(reloadedState.effectiveConfig.llm.stream, true);
assert.equal(
JSON.parse(fs.readFileSync(primaryConfigPath, 'utf8')).llm.apiKey,
'fixture-new-key',
);
const sanitizedLocalConfig = JSON.parse(
fs.readFileSync(localConfigPath, 'utf8'),
);
assert.equal(sanitizedLocalConfig.llm, undefined);
assert.deepEqual(sanitizedLocalConfig.agentLlm, localConfig.agentLlm);
if (process.platform !== 'win32') {
assert.equal(fs.statSync(localConfigPath).mode & 0o777, 0o600);
}
const unchangedLocalConfigDir = path.join(
testRoot,
'unchanged-local-appdata',
);
fs.mkdirSync(unchangedLocalConfigDir, { recursive: true, mode: 0o700 });
const unchangedPrimaryConfigPath = path.join(
unchangedLocalConfigDir,
'game-creator.config.json',
);
const unchangedLocalConfigPath = path.join(
unchangedLocalConfigDir,
'game-creator.config.local.json',
);
const unchangedLocalSource =
'{\n "editorApi": { "baseUrl": "http://127.0.0.1:8082" }\n}\n';
fs.writeFileSync(unchangedPrimaryConfigPath, '{}\n', { mode: 0o600 });
fs.writeFileSync(unchangedLocalConfigPath, unchangedLocalSource, {
mode: 0o644,
});
const unchangedLocalState = await readGameCreatorWizardConfigState(
unchangedLocalConfigDir,
);
await writeGameCreatorWizardConfig(
unchangedLocalState,
buildGameCreatorWizardConfig(unchangedLocalState.writeConfig, {
apiKey: 'fixture-private-local-key',
baseUrl: 'https://private.example.test/v1',
model: 'private-model',
apiKind: 'openai_responses',
}),
);
assert.equal(
fs.readFileSync(unchangedLocalConfigPath, 'utf8'),
unchangedLocalSource,
);
if (process.platform !== 'win32') {
assert.equal(fs.statSync(unchangedLocalConfigPath).mode & 0o777, 0o600);
}
const invalidLocalConfigDir = path.join(testRoot, 'invalid-local-appdata');
fs.mkdirSync(invalidLocalConfigDir, { recursive: true, mode: 0o700 });
const invalidPrimaryConfigPath = path.join(
invalidLocalConfigDir,
'game-creator.config.json',
);
const invalidLocalConfigPath = path.join(
invalidLocalConfigDir,
'game-creator.config.local.json',
);
fs.writeFileSync(invalidPrimaryConfigPath, '{}\n', { mode: 0o600 });
fs.writeFileSync(invalidLocalConfigPath, '{ invalid json\n', {
mode: 0o644,
});
await assert.rejects(
writeGameCreatorWizardConfig(
{
configPath: invalidPrimaryConfigPath,
localConfigPath: invalidLocalConfigPath,
},
{ llm: { apiKey: 'fixture-invalid-local-key' } },
),
/读取客户端配置失败/u,
);
if (process.platform !== 'win32') {
assert.equal(fs.statSync(invalidLocalConfigPath).mode & 0o777, 0o600);
}
const windowsLocalConfigDir = path.join(testRoot, 'windows-local-appdata');
fs.mkdirSync(windowsLocalConfigDir, { recursive: true });
const windowsLocalPrimaryPath = path.join(
windowsLocalConfigDir,
'game-creator.config.json',
);
const windowsLocalConfigPath = path.join(
windowsLocalConfigDir,
'game-creator.config.local.json',
);
fs.writeFileSync(windowsLocalPrimaryPath, '{}\n');
fs.writeFileSync(windowsLocalConfigPath, unchangedLocalSource);
const windowsReadAclPaths = [];
const windowsLocalState = await readGameCreatorWizardConfigState(
windowsLocalConfigDir,
{
platform: 'win32',
secureWindowsPath: async (targetPath, { isDirectory }) => {
windowsReadAclPaths.push({ targetPath, isDirectory });
},
},
);
assert.deepEqual(windowsReadAclPaths, [
{ targetPath: windowsLocalConfigDir, isDirectory: true },
{ targetPath: windowsLocalPrimaryPath, isDirectory: false },
{ targetPath: windowsLocalConfigPath, isDirectory: false },
]);
const windowsLocalAclPaths = [];
await writeGameCreatorWizardConfig(
windowsLocalState,
buildGameCreatorWizardConfig(windowsLocalState.writeConfig, {
apiKey: 'fixture-windows-local-key',
baseUrl: 'https://windows.example.test/v1',
model: 'windows-model',
apiKind: 'openai_responses',
}),
{
platform: 'win32',
secureWindowsPath: async (targetPath, { isDirectory }) => {
if (!isDirectory) windowsLocalAclPaths.push(targetPath);
},
},
);
assert.equal(
fs.readFileSync(windowsLocalConfigPath, 'utf8'),
unchangedLocalSource,
);
assert.equal(windowsLocalAclPaths.includes(windowsLocalConfigPath), true);
const guiSavedConfig = JSON.parse(
fs.readFileSync(primaryConfigPath, 'utf8'),
);
guiSavedConfig.llm = {
...guiSavedConfig.llm,
apiKey: 'fixture-gui-key',
model: 'gui-model',
};
await writeGameCreatorConfigAtomically(primaryConfigPath, guiSavedConfig);
const afterGuiSave =
await readGameCreatorWizardConfigState(overlayConfigDir);
assert.equal(afterGuiSave.effectiveConfig.llm.apiKey, 'fixture-gui-key');
assert.equal(afterGuiSave.effectiveConfig.llm.model, 'gui-model');
const windowsConfigDir = path.join(testRoot, 'windows-appdata');
const windowsConfigPath = path.join(
windowsConfigDir,
'game-creator.config.json',
);
const aclEvents = [];
await writeGameCreatorConfigAtomically(
windowsConfigPath,
{ llm: { apiKey: 'fixture-windows-key' } },
{
platform: 'win32',
secureWindowsPath: async (targetPath, { isDirectory }) => {
const metadata = fs.lstatSync(targetPath);
const temporaryFile =
!isDirectory && path.basename(targetPath).startsWith('.');
if (temporaryFile) {
assert.equal(metadata.size, 0);
assert.equal(fs.readFileSync(targetPath, 'utf8'), '');
}
aclEvents.push({ isDirectory, temporaryFile });
},
},
);
assert.deepEqual(aclEvents, [
{ isDirectory: true, temporaryFile: false },
{ isDirectory: false, temporaryFile: true },
{ isDirectory: false, temporaryFile: false },
]);
assert.equal(
JSON.parse(fs.readFileSync(windowsConfigPath, 'utf8')).llm.apiKey,
'fixture-windows-key',
);
const failedWindowsConfigDir = path.join(
testRoot,
'failed-windows-appdata',
);
const failedWindowsConfigPath = path.join(
failedWindowsConfigDir,
'game-creator.config.json',
);
await assert.rejects(
writeGameCreatorConfigAtomically(
failedWindowsConfigPath,
{ llm: { apiKey: 'fixture-must-not-reach-disk' } },
{
platform: 'win32',
secureWindowsPath: async (_targetPath, { isDirectory }) => {
if (!isDirectory) throw new Error('fixture DACL failure');
},
},
),
/fixture DACL failure/u,
);
assert.equal(fs.existsSync(failedWindowsConfigPath), false);
assert.deepEqual(fs.readdirSync(failedWindowsConfigDir), []);
} finally {
fs.rmSync(testRoot, { recursive: true, force: true });
}
}
assertNoOpenAiApiKeys([
new URL('../src/', import.meta.url),
new URL('../scripts/', import.meta.url),
new URL('../game-creator.config.json', import.meta.url),
new URL('../src-tauri/src/', import.meta.url),
new URL('../package.json', import.meta.url),
new URL('../vite.config.ts', import.meta.url),
new URL('../src-tauri/Cargo.toml', import.meta.url),
new URL('../src-tauri/tauri.conf.json', import.meta.url),
new URL('../src-tauri/tauri.windows.conf.json', import.meta.url),
new URL(
'../../../packages/shared/src/contracts/gameCreationApp.ts',
import.meta.url,
),
new URL(
'../../../packages/shared/src/contracts/gameCreationApp.test.ts',
import.meta.url,
),
new URL(
'../../../server-rs/crates/platform-agent/src/game_creation.rs',
import.meta.url,
),
new URL(
'../../../server-rs/crates/shared-contracts/src/game_creation_app.rs',
import.meta.url,
),
new URL(
'../../../docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md',
import.meta.url,
),
]);
assertNoEnvironmentConfigFallbacks([
new URL('../src/', import.meta.url),
new URL('../scripts/run-cli-with-config.mjs', import.meta.url),
new URL('../scripts/smoke-agent-run-local-provider.mjs', import.meta.url),
new URL('../scripts/start-dev-server.mjs', import.meta.url),
new URL('../src-tauri/src/', import.meta.url),
new URL('../package.json', import.meta.url),
new URL('../vite.config.ts', import.meta.url),
new URL('../src-tauri/Cargo.toml', import.meta.url),
new URL('../src-tauri/tauri.conf.json', import.meta.url),
new URL('../src-tauri/tauri.windows.conf.json', import.meta.url),
]);
assertNoNativeBrowserConfirm([new URL('../src/', import.meta.url)]);
assertNoBlockingNativeFilePicker(tauriRustSource);
runAppInvokeParserRegressionChecks();
const appInvokeCommandNames = parseAppInvokeSourceFiles(appInvokeSources);
assertContractRecordsMatch(
'AI game creator shell command contract',
parseTsCommands(sharedContractSource),
parseRustCommands(rustSharedContractSource),
);
assertContractRecordsMatch(
'AI game creator shell capability contract',
parseTsCapabilities(sharedContractSource),
parseRustCapabilities(rustSharedContractSource),
);
assertCommandNamesSubset(
'AI game creator shell Tauri handler',
appInvokeCommandNames,
parseTauriHandlerCommandNames(tauriHandlerSource),
);
assertCommandNamesSubset(
'AI game creator shell Tauri command implementation',
parseTauriHandlerCommandNames(tauriHandlerSource),
parseRustFunctionNames(tauriRustSource),
);
assertCommandNamesSubset(
'AI game creator shell App invoke or explicit native-only allowlist',
parseTauriHandlerCommandNames(tauriHandlerSource),
[...appInvokeCommandNames, ...allowedUncalledTauriCommands],
);
assertCommandNamesSubset(
'AI game creator shell explicit native-only allowlist',
allowedUncalledTauriCommands,
parseTauriHandlerCommandNames(tauriHandlerSource),
);
assertCommandNamesDisjoint(
'AI game creator shell App invoke and explicit native-only allowlist',
appInvokeCommandNames,
allowedUncalledTauriCommands,
);
const tauriHandlerCommandNames =
parseTauriHandlerCommandNames(tauriHandlerSource);
if (!tauriHandlerCommandNames.includes('create_automatic_local_game_project')) {
throw new Error(
'AI game creator shell home creation must expose automatic project creation',
);
}
if (
tauriHandlerCommandNames.includes('chat_with_game_creator_home_direct_codex')
) {
throw new Error(
'AI game creator shell home surface must not expose a projectless Codex conversation',
);
}
if (packageConfig.name !== '@genarrative/ai-game-creator-shell') {
throw new Error('AI game creator shell package name drifted');
}
if (
packageConfig.scripts?.['llm-status'] !==
'node scripts/run-cli-with-config.mjs --llm-status'
) {
throw new Error(
'AI game creator shell llm-status must use client config before checking LLM config',
);
}
if (
packageConfig.scripts?.['agent-run'] !==
'node scripts/run-cli-with-config.mjs --agent-run'
) {
throw new Error(
'AI game creator shell agent-run must use client config before running the provider path',
);
}
if (
packageConfig.scripts?.['agent-task'] !==
'node scripts/run-cli-with-config.mjs --agent-task'
) {
throw new Error(
'AI game creator shell agent-task must use client config before starting the single Agent runtime',
);
}
if (
packageConfig.scripts?.swarm !==
'node scripts/run-cli-with-config.mjs --swarm-chat'
) {
throw new Error(
'AI game creator shell swarm must use client config before starting the interactive Agent runtime',
);
}
if (
packageConfig.scripts?.config !==
'node scripts/game-creator-config-wizard.mjs'
) {
throw new Error(
'AI game creator shell config must use the AppData configuration wizard',
);
}
if (
packageConfig.scripts?.['test:chat'] !==
`node scripts/agent-swarm-test-chat.mjs --task ${JSON.stringify(defaultRealSwarmTestTask)} --no-open`
) {
throw new Error(
'AI game creator shell test:chat must use the one-click Swarm test entry',
);
}
if (
packageConfig.scripts?.['test:chat:manual'] !==
'node scripts/agent-swarm-test-chat.mjs'
) {
throw new Error(
'AI game creator shell test:chat:manual must keep the interactive Swarm test entry',
);
}
if (
rootPackageConfig.scripts?.['agc:test'] !==
'npm --prefix apps/ai-game-creator-shell run agent-runtime:supervisor-autonomous-playable-lane-defense-deterministic-e2e --'
) {
throw new Error('agc:test must delegate to the deterministic playable E2E');
}
if (
rootPackageConfig.scripts?.['agc:config'] !==
'npm --prefix apps/ai-game-creator-shell run config --'
) {
throw new Error(
'agc:config must delegate to the AppData configuration wizard',
);
}
if (
rootPackageConfig.scripts?.['agc:test:chat'] !==
'npm --prefix apps/ai-game-creator-shell run test:chat --'
) {
throw new Error(
'agc:test:chat must delegate to the one-click Swarm test entry',
);
}
if (
rootPackageConfig.scripts?.['agc:test:chat:manual'] !==
'npm --prefix apps/ai-game-creator-shell run test:chat:manual --'
) {
throw new Error(
'agc:test:chat:manual must delegate to the interactive Swarm test entry',
);
}
for (const requiredSource of [
"export const appIdentifier = 'world.genarrative.ai-game-creator'",
"'--swarm-chat'",
"'--autonomous-game-build'",
"'--preview-serve'",
'cleanupSwarmTestProject(project)',
]) {
if (!swarmTestChatSource.includes(requiredSource)) {
throw new Error(`Swarm test entry contract drifted: ${requiredSource}`);
}
}
if (tauriConfig.productName !== '陶泥儿') {
throw new Error('AI game creator shell productName drifted');
}
if (tauriConfig.identifier !== 'world.genarrative.ai-game-creator') {
throw new Error('AI game creator shell identifier drifted');
}
const expectedBundledDesignAgentResources = {
'design-agent': 'design-agent',
};
const expectedBundledWindowsResources = {
'resources/codex/win-x64/bin/codex.exe': 'coding-agent/win-x64/bin/codex.exe',
'resources/codex/win-x64/bin/codex-code-mode-host.exe':
'coding-agent/win-x64/bin/codex-code-mode-host.exe',
'resources/codex/win-x64/codex-path/rg.exe':
'coding-agent/win-x64/codex-path/rg.exe',
'resources/codex/win-x64/codex-resources/codex-command-runner.exe':
'coding-agent/win-x64/codex-resources/codex-command-runner.exe',
'resources/codex/win-x64/codex-resources/codex-windows-sandbox-setup.exe':
'coding-agent/win-x64/codex-resources/codex-windows-sandbox-setup.exe',
'resources/codex/win-x64/codex-package.json':
'coding-agent/win-x64/codex-package.json',
'resources/codex/win-x64/NOTICE.md': 'coding-agent/win-x64/NOTICE.md',
'resources/codex/win-x64/manifest.json': 'coding-agent/win-x64/manifest.json',
'resources/plugins': 'plugins',
};
assert.deepEqual(
tauriConfig.bundle?.resources,
expectedBundledDesignAgentResources,
'AI game creator shell base Tauri config must bundle the design-agent resource pack',
);
for (const key of Object.keys(tauriConfig.bundle?.resources ?? {})) {
if (String(key).includes('codex')) {
throw new Error(
'AI game creator shell base Tauri config must not require Windows-only Codex resources',
);
}
}
assert.deepEqual(
windowsTauriConfig.bundle?.resources,
expectedBundledWindowsResources,
'AI game creator shell Windows Tauri config must bundle the complete pinned Codex resource set and Cocos bridge payload directory',
);
if (
Object.prototype.hasOwnProperty.call(
windowsTauriConfig.bundle?.resources ?? {},
'design-agent',
)
) {
throw new Error(
'design-agent resource pack must not be mixed into the Windows Codex sidecar bundle',
);
}
if (windowsTauriConfig.bundle?.useLocalToolsDir !== true) {
throw new Error(
'AI game creator shell Windows Tauri config must cache bundling tools in the project target directory',
);
}
if (tauriConfig.app?.withGlobalTauri !== true) {
throw new Error(
'AI game creator shell must expose window.__TAURI__ for local commands',
);
}
if (!fs.existsSync(eventCapabilityPath)) {
throw new Error(
'AI game creator shell must declare a Tauri event-listener capability',
);
}
const eventCapability = JSON.parse(
fs.readFileSync(eventCapabilityPath, 'utf8'),
);
const eventCapabilityWindows = new Set(eventCapability.windows ?? []);
const eventCapabilityPermissions = new Set(eventCapability.permissions ?? []);
for (const windowLabel of [
'client',
'developer',
'main',
'launcher',
'supervisor-chat',
]) {
if (!eventCapabilityWindows.has(windowLabel)) {
throw new Error(
`AI game creator shell event capability missing window: ${windowLabel}`,
);
}
}
for (const permission of [
'core:event:allow-listen',
'core:event:allow-unlisten',
]) {
if (!eventCapabilityPermissions.has(permission)) {
throw new Error(
`AI game creator shell event capability missing permission: ${permission}`,
);
}
}
if (
eventCapabilityPermissions.has('core:event:allow-emit') ||
eventCapabilityPermissions.has('core:event:allow-emit-to') ||
eventCapabilityPermissions.has('core:event:default')
) {
throw new Error(
'AI game creator shell frontend event capability must stay listen-only',
);
}
if (
!Array.isArray(tauriConfig.app?.windows) ||
tauriConfig.app.windows.length !== 1 ||
tauriConfig.app.windows[0]?.label !== 'client' ||
tauriConfig.app.windows[0]?.title !== '陶泥儿' ||
tauriConfig.app.windows[0]?.url !== 'index.html'
) {
throw new Error(
'AI game creator shell must start with only the client window',
);
}
if (defaultAppConfig.llm?.apiKey !== '') {
throw new Error('AI game creator shell default llm.apiKey must stay empty');
}
if (defaultAppConfig.agentMode !== 'codex_app_server') {
throw new Error(
'AI game creator shell default agentMode must be codex_app_server',
);
}
const allowedLlmReasoningEfforts = new Set([
'default',
'low',
'medium',
'high',
'max',
]);
if (defaultAppConfig.llm?.reasoningEffort !== 'max') {
throw new Error(
'AI game creator shell default llm.reasoningEffort must stay max',
);
}
for (const [agentId, agentConfig] of Object.entries(
defaultAppConfig.agentLlm ?? {},
)) {
if (
agentConfig?.reasoningEffort !== undefined &&
!allowedLlmReasoningEfforts.has(agentConfig.reasoningEffort)
) {
throw new Error(
`AI game creator shell agentLlm.${agentId}.reasoningEffort is invalid`,
);
}
}
if (defaultAppConfig.editorApi !== undefined) {
throw new Error(
'AI game creator shell ordinary default config must not contain editorApi',
);
}
if (
defaultAppConfig.llm?.requestTimeoutMs < 1000 ||
defaultAppConfig.llm?.maxRetries < 0 ||
defaultAppConfig.llm?.retryBackoffMs < 1
) {
throw new Error('AI game creator shell default LLM timing config is invalid');
}
const windows = tauriConfig.app?.windows ?? [];
if (
windows.length !== 1 ||
windows[0]?.label !== 'client' ||
windows[0]?.url !== 'index.html'
) {
throw new Error(
'AI game creator shell release config must expose only the client window',
);
}
const clientWindow = windows[0];
if (
clientWindow.width !== 1280 ||
clientWindow.height !== 800 ||
clientWindow.minWidth !== 1280 ||
clientWindow.minHeight !== 720
) {
throw new Error(
'AI game creator shell client window must default to 1280x800 and stay at least 1280x720',
);
}
if (tauriConfig.build?.devUrl !== 'http://127.0.0.1:3080/') {
throw new Error(
'AI game creator shell Tauri config must retain the non-launcher fallback devUrl',
);
}
if (!viteConfigSource.includes("host: '127.0.0.1'")) {
throw new Error(
'AI game creator shell Vite dev server must bind to localhost',
);
}
if (
!viteConfigSource.includes('port: 3080') ||
!viteConfigSource.includes('port: server.config.server.port')
) {
throw new Error(
'AI game creator shell Vite config must retain its fallback and report the actual CLI-selected port',
);
}
for (const snippet of [
'mapDevPortsToPortRange',
'agcVitePort',
'resolveAgcDevEndpoint',
'GENARRATIVE_AGC_VITE_PORT',
]) {
if (!devPortSource.includes(snippet)) {
throw new Error(
`AI game creator shell dev port resolver drifted: ${snippet}`,
);
}
}
for (const snippet of [
'resolveAgcDevEndpoint',
'withAgcDevEndpointEnv',
"'--config'",
'configOverride',
]) {
if (!startTauriDevSource.includes(snippet)) {
throw new Error(
`AI game creator shell Tauri dev port injection drifted: ${snippet}`,
);
}
}
if (!viteConfigSource.includes('strictPort: true')) {
throw new Error(
'AI game creator shell Vite dev server must not drift away from Tauri devUrl',
);
}
if (!viteConfigSource.includes('allow: [repoRoot]')) {
throw new Error(
'AI game creator shell Vite dev server must allow shared repository sources',
);
}
if (
!(
tauriConfig.build?.beforeDevCommand?.includes(
'run ai-game-creator-shell:dev-server',
) || tauriConfig.build?.beforeDevCommand?.includes('run agc:serve')
)
) {
throw new Error(
'AI game creator shell beforeDevCommand must start the selected Vite dev server',
);
}
if (packageConfig.scripts?.dev !== 'node scripts/start-tauri-dev.mjs') {
throw new Error(
'AI game creator shell dev must run through the managed Tauri dev launcher',
);
}
if (
!tauriHandlerSource.includes('.build(tauri_context)') ||
!tauriHandlerSource.includes('handle_game_creator_gui_run_event(&event)')
) {
throw new Error(
'AI game creator Tauri runtime must build from the prepared Context and preserve the generic GUI exit hook',
);
}
if (
!tauriConfig.build?.beforeBuildCommand?.includes('--config vite.config.ts')
) {
throw new Error(
'AI game creator shell beforeBuildCommand must resolve vite config from app root',
);
}
const releaseVersions = [
packageConfig.version,
tauriConfig.version,
cargoPackageVersion,
];
if (
releaseVersions.some((version) => !/^\d+\.\d+\.\d+$/u.test(version ?? '')) ||
new Set(releaseVersions).size !== 1
) {
throw new Error(
'AI game creator release versions must be valid three-part semver and synchronized',
);
}
const devServerSource = fs.readFileSync(
new URL('../scripts/start-dev-server.mjs', import.meta.url),
'utf8',
);
const runCliWithConfigSource = fs.readFileSync(
new URL('../scripts/run-cli-with-config.mjs', import.meta.url),
'utf8',
);
if (!runCliWithConfigSource.includes('resolveGameCreatorAppConfigDir')) {
throw new Error(
'AI game creator shell CLI wrapper must resolve the GUI AppData directory',
);
}
if (
!runCliWithConfigSource.includes(
"'--config-dir', resolveGameCreatorAppConfigDir()",
)
) {
throw new Error(
'AI game creator shell CLI wrapper must pass the GUI AppData directory to native commands',
);
}
for (const snippet of [
'resolveAgcDevEndpoint',
'withAgcDevEndpointEnv',
"response.body.includes('<title>陶泥儿</title>')",
'function isPortListening()',
'cannot be safely reused',
'non-HTTP or unrecognized server',
"'--config'",
"'vite.config.ts'",
"'--port'",
]) {
if (!devServerSource.includes(snippet)) {
throw new Error(
`AI game creator shell dev server wrapper drifted: ${snippet}`,
);
}
}
const configWizardSource = fs.readFileSync(
new URL('./game-creator-config-wizard.mjs', import.meta.url),
'utf8',
);
for (const snippet of [
'assertSafeGameCreatorConfigDestination',
'readGameCreatorWizardConfigState',
'$security.SetAccessRuleProtection($true, $false)',
'$targetItem = Get-Item -LiteralPath $target -Force',
'$targetItem.SetAccessControl($security)',
'$verified = $targetItem.GetAccessControl()',
'$rules.Count -ne 1',
'[System.Security.AccessControl.FileSystemRights]::FullControl',
"'-NoProfile'",
"'-Command'",
'windowsPrivateAclScript',
'await secureWindowsPath(temporaryPath, { isDirectory: false })',
'await temporaryFile.writeFile',
]) {
if (!configWizardSource.includes(snippet)) {
throw new Error(
`AI game creator config wizard guardrail drifted: ${snippet}`,
);
}
}
if (!/runChildCapture\(\s*['"]powershell\.exe['"]/u.test(configWizardSource)) {
throw new Error(
'AI game creator config wizard guardrail drifted: runChildCapture(powershell.exe)',
);
}
if (/\bGet-Acl\b/u.test(configWizardSource)) {
throw new Error(
'AI game creator config wizard must not rely on Get-Acl module auto-loading',
);
}
await runConfigWizardRegressionChecks();
await runHiddenInputRegressionChecks();
for (const snippet of [
"new URL('..', import.meta.url)",
"'--manifest-path'",
"'src-tauri/Cargo.toml'",
]) {
if (!runCliWithConfigSource.includes(snippet)) {
throw new Error(
`AI game creator shell config CLI wrapper drifted: ${snippet}`,
);
}
}
for (const snippet of [
'const GAME_CREATOR_CONFIG_FILE_NAME: &str = "game-creator.config.json"',
'const GAME_CREATOR_LOCAL_CONFIG_FILE_NAME: &str = "game-creator.config.local.json"',
'const DEFAULT_GAME_CREATOR_APP_CONFIG_JSON: &str = include_str!("../../game-creator.config.json")',
'fn configure_game_creator_runtime_config_dir(',
'game_creator_runtime_config_dir()',
'.unwrap_or_else(|| app.path().app_config_dir())?',
'fn load_game_creator_app_config()',
'fn read_game_creator_app_config()',
'fn write_game_creator_app_config(',
'fn writable_game_creator_config_path()',
'fn normalize_game_creator_app_config(',
'fn merge_game_creator_config_file(',
'.join("apps")',
'.join("ai-game-creator-shell")',
'read_game_creator_app_config,',
'write_game_creator_app_config,',
'resolve_game_creator_llm_config_for_agent(app_config, "planner")',
'resolve_game_creator_llm_config_for_agent(app_config, "generator")',
'build_game_creator_llm_client_from_llm_config(&planner_llm, "agentLlm.planner")?',
'build_game_creator_llm_client_from_llm_config(&generator_llm, "agentLlm.generator")?',
'agentLlm.{agent_id}',
'let app_config = match load_game_creator_app_config()',
'fn append_local_permission_log_at(',
'"command.auto"',
'GameCreationAppPermission::Auto',
'GAME_CREATOR_BUNDLED_CODEX_CLI_RELATIVE_PATH',
'validate_game_creator_bundled_codex_cli',
'内置 Codex CLI 完整性校验失败',
]) {
if (!tauriRustSource.includes(snippet)) {
throw new Error(
`AI game creator shell developer window guardrail drifted: ${snippet}`,
);
}
}
const runtimeConfigSetupStart = tauriHandlerSource.indexOf(
'configure_game_creator_runtime_config_dir(app.handle()).inspect_err(|error| {',
);
const runtimeConfigSetupEnd = tauriHandlerSource.indexOf(
'})?;',
runtimeConfigSetupStart,
);
const runtimeConfigSetupSource = tauriHandlerSource.slice(
runtimeConfigSetupStart,
runtimeConfigSetupEnd,
);
if (
runtimeConfigSetupStart === -1 ||
runtimeConfigSetupEnd === -1 ||
!runtimeConfigSetupSource.includes('sanitize_diagnostic_message(') ||
!runtimeConfigSetupSource.includes('setup_log.fail(') ||
!runtimeConfigSetupSource.includes(
'startup.appdata.configure.failed details={details}',
) ||
!tauriHandlerSource.includes('impl StartupLogSlot {') ||
!tauriHandlerSource.includes('append_bounded_diagnostic_line(&path, line)') ||
!tauriHandlerSource.includes(
'self.append(line);\n show_startup_error_dialog(self.path().as_deref());',
) ||
!tauriHandlerSource.includes('early_startup_log_path(')
) {
throw new Error(
'AI game creator setup must configure the runtime AppData directory and log sanitized setup failures',
);
}
for (const snippet of [
'import.meta.env.DEV',
'supervisorChatMode',
'supervisorChatOnly',
'open_project_supervisor_chat_window',
'index.html?supervisor-chat&projectPath=',
]) {
if (!`${appEntrypointSource}\n${tauriRustSource}`.includes(snippet)) {
throw new Error(
`AI game creator shell developer window guardrail drifted: ${snippet}`,
);
}
}
if (tauriHandlerSource.includes('open_developer_window(app.handle())?')) {
throw new Error(
'AI game creator normal startup must not automatically open the developer window',
);
}
const smokeAgentRunSource = fs.readFileSync(
new URL('./smoke-agent-run-local-provider.mjs', import.meta.url),
'utf8',
);
for (const snippet of [
'agentLlm',
'planner-smoke-model',
'generator-smoke-model',
'global-smoke-model-unused',
]) {
if (!smokeAgentRunSource.includes(snippet)) {
throw new Error(
`AI game creator local-provider smoke lost per-agent LLM coverage: ${snippet}`,
);
}
}
for (const snippet of [
"'game.run_local'",
"'read_game_creator_app_config'",
"'write_game_creator_app_config'",
'aria-label="运行时配置"',
'陶泥儿智能创作(固定)',
'官方账号服务(固定)',
'runtime_config.save',
"'/run:运行自检,启动本地 HTTP 预览并载入客户端运行视图'",
"'activate_local_game_preview'",
'已切换到客户端运行视图',
'async function executeRunLocal',
'function needsInitializedChatProject',
'function resolvePendingCommandProjectPath',
'resolveChatProjectPath(localProject) ?? draftProjectPath',
'`permission.cancel ${command.id} missing-project`',
"'/remember [short|long|blackboard] 内容:追加短期、长期或黑板记忆'",
"'/memory-set [short|long|blackboard] 内容:覆盖保存对应记忆'",
'function parseRememberInput',
"'/trace 或 /loop:查看最近一次 Agent loop trace'",
'async function executeAgentTraceChat',
"relativePath: '.agent/logs/command.log'",
"'permission.pending'",
"'permission.confirm'",
"'permission.cancel'",
"'command.auto'",
"'agent.run_status'",
'function summarizeAgentRunTrace',
'工具调用:${agentRunTrace.toolCallCount}/${agentRunTrace.maxToolCalls}',
'agentRunTrace.error ?',
'className="trace-error"',
'agentRunTrace.taskGraph.repairRoutes.map',
"in: ${step.inputPaths.join(', ') || 'none'}",
"out: ${step.outputPaths.join(', ') || 'none'}",
]) {
if (!appSource.includes(snippet)) {
throw new Error(
`AI game creator shell trace panel guardrail drifted: ${snippet}`,
);
}
}
for (const script of [
'ai-game-creator-shell:dev',
'ai-game-creator-shell:dev-server',
'ai-game-creator-shell:build',
'ai-game-creator-shell:agent-task',
'agc:swarm',
'ai-game-creator-shell:typecheck',
'ai-game-creator-shell:agent-run:smoke',
'ai-game-creator-shell:check',
]) {
if (!rootPackageConfig.scripts?.[script]) {
throw new Error(`root package missing ${script}`);
}
}
if (
rootPackageConfig.scripts?.['agc:swarm'] !==
'npm --prefix apps/ai-game-creator-shell run swarm --'
) {
throw new Error('root agc:swarm script must forward CLI args');
}
if (
rootPackageConfig.scripts?.['ai-game-creator-shell:build'] !==
'npm --prefix apps/ai-game-creator-shell run build --'
) {
throw new Error(
'root ai-game-creator-shell:build script must forward build args',
);
}
const agentRunSmokeSource = fs.readFileSync(
new URL('../scripts/smoke-agent-run-local-provider.mjs', import.meta.url),
'utf8',
);
for (const snippet of [
"const smokeAssetMarker = 'SMOKE_LOCAL_ASSET:chef'",
"const smokeAudioAssetPath = 'assets/uploads/smoke-bounce.mp3'",
"mediaType: 'audio/mpeg'",
'document.body.dataset.smokeFrame = String(frameCount)',
"ctx.fillStyle = '#ff00ff'",
'ctx.fillRect(4, 4, 8, 24)',
'const sample = ctx.getImageData(4, 4, 24, 24).data',
'chef.complete && chef.naturalWidth > 0',
'document.body.dataset.smokeCanvasPixels = String(litPixels)',
'document.body.dataset.smokeCanvasColors = String(colors.size)',
'readBrowserDom(previewUrl)',
"extractDomNumber(previewDom, 'smoke-canvas-pixels')",
'--dump-dom',
'readHttpHead(new URL(smokeAssetPath, previewUrl).toString())',
'readHttpHead(new URL(smokeAudioAssetPath, previewUrl).toString())',
'function writeStreamingChatCompletion',
'requestJson?.stream === true',
`requestBodies.every((body) => body.includes('"stream":true'))`,
"const localConfigPath = path.join(appRoot, 'game-creator.config.local.json')",
"apiKind: 'openai_chat'",
'stream: true',
'await restoreOptionalFile(localConfigPath, previousLocalConfig)',
"method: 'HEAD'",
'previewAssetHead.contentLength === String(smokeAssetBytes.length)',
"previewAudioHead.contentType === 'audio/mpeg'",
"previewAssetHead.body === ''",
'trace missing professional group ${group}',
]) {
if (!agentRunSmokeSource.includes(snippet)) {
throw new Error(
`AI game creator shell agent-run smoke drifted: ${snippet}`,
);
}
}