Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 2043f8c966 | |||
| 65012e89b6 | |||
| 64b9ccfb9d | |||
| fcc3c39dad | |||
| f7a0adc616 | |||
| 57cd81d551 | |||
| 78088e422a | |||
| 65551cd827 |
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@genarrative/ai-game-creator-shell",
|
||||
"private": true,
|
||||
"version": "0.1.12",
|
||||
"version": "0.1.19",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "node scripts/start-tauri-dev.mjs",
|
||||
@@ -9,6 +9,7 @@
|
||||
"dev-stack": "node scripts/start-dev-stack.mjs",
|
||||
"build": "node scripts/build-release.mjs",
|
||||
"release:upload": "node scripts/release-upload.mjs",
|
||||
"bump-version": "node scripts/bump-version.mjs",
|
||||
"skill-pack:check": "node scripts/check-skill-pack.mjs",
|
||||
"skill-pack:sync": "node scripts/check-skill-pack.mjs --write",
|
||||
"skill-pack:test": "node --test scripts/check-skill-pack.test.mjs",
|
||||
@@ -53,7 +54,6 @@
|
||||
"focus-trap-react": "^12.0.3",
|
||||
"lexical": "^0.47.0",
|
||||
"lucide-react": "^0.546.0",
|
||||
"phaser": "^4.2.1",
|
||||
"react": "^19.0.0",
|
||||
"react-arborist": "^3.16.0",
|
||||
"react-colorful": "^5.8.0",
|
||||
|
||||
@@ -5,6 +5,7 @@ import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const appRoot = fileURLToPath(new URL('..', import.meta.url));
|
||||
const repoRoot = path.resolve(appRoot, '../..');
|
||||
const defaultReleaseTarget = 'x86_64-pc-windows-msvc';
|
||||
const releaseTarget =
|
||||
process.env.AGC_BUILD_TARGET?.trim() || defaultReleaseTarget;
|
||||
@@ -49,16 +50,23 @@ function parseVersion(value, label) {
|
||||
return value;
|
||||
}
|
||||
|
||||
export function nextPatchVersion(localVersion, remoteVersion) {
|
||||
const local = parseVersion(localVersion, '本地版本');
|
||||
const remote =
|
||||
remoteVersion == null ? null : parseVersion(remoteVersion, 'OSS版本');
|
||||
const base = remote && compareVersions(remote, local) > 0 ? remote : local;
|
||||
const [major, minor, patch] = base.split('.').map(Number);
|
||||
if (patch === Number.MAX_SAFE_INTEGER) {
|
||||
throw new Error(`版本号 patch 已达到上限:${base}`);
|
||||
export function bumpVersion(current, target = 'patch') {
|
||||
const [major, minor, patch] =
|
||||
parseVersion(current, '当前版本').split('.').map(Number);
|
||||
if (target === 'major') return `${major + 1}.0.0`;
|
||||
if (target === 'minor') return `${major}.${minor + 1}.0`;
|
||||
if (target === 'patch' || target == null) {
|
||||
if (patch === Number.MAX_SAFE_INTEGER) {
|
||||
throw new Error(`版本号 patch 已达到上限:${current}`);
|
||||
}
|
||||
return `${major}.${minor}.${patch + 1}`;
|
||||
}
|
||||
return `${major}.${minor}.${patch + 1}`;
|
||||
if (typeof target === 'string' && /^\d+\.\d+\.\d+$/u.test(target)) {
|
||||
return parseVersion(target, '指定版本');
|
||||
}
|
||||
throw new Error(
|
||||
`无法识别的版本目标:${String(target)}(支持 patch / minor / major / 明确的三段版本号)`,
|
||||
);
|
||||
}
|
||||
|
||||
async function readRemoteVersion() {
|
||||
@@ -88,14 +96,52 @@ function replaceVersionLine(source, version, pattern, label) {
|
||||
return source.replace(pattern, `$1${version}$3`);
|
||||
}
|
||||
|
||||
export async function prepareReleaseVersion() {
|
||||
const localVersion = parseVersion(readPackageJson().version, '本地版本');
|
||||
const remoteVersion = await readRemoteVersion();
|
||||
const requestedVersion = process.env.AGC_RELEASE_VERSION?.trim();
|
||||
const nextVersion = requestedVersion
|
||||
? parseVersion(requestedVersion, '指定版本')
|
||||
: nextPatchVersion(localVersion, remoteVersion);
|
||||
const versionFileSources = [
|
||||
{
|
||||
file: packageJsonPath,
|
||||
pattern: /("version"\s*:\s*")([^"]+)(")/u,
|
||||
label: 'package.json',
|
||||
},
|
||||
{
|
||||
file: rootPackageLockPath,
|
||||
pattern: /("apps\/ai-game-creator-shell"\s*:\s*\{\s*\n\s*"name"\s*:\s*"@genarrative\/ai-game-creator-shell"\s*,\s*\n\s*"version"\s*:\s*")([^"]+)(")/u,
|
||||
label: 'package-lock.json',
|
||||
},
|
||||
{
|
||||
file: tauriConfigPath,
|
||||
pattern: /("version"\s*:\s*")([^"]+)(")/u,
|
||||
label: 'tauri.conf.json',
|
||||
},
|
||||
{
|
||||
file: cargoManifestPath,
|
||||
pattern: /(^\[package\][\s\S]*?^version\s*=\s*")([^"]+)(")/mu,
|
||||
label: 'Cargo.toml',
|
||||
},
|
||||
{
|
||||
file: cargoLockPath,
|
||||
pattern: /(^name\s*=\s*"genarrative-ai-game-creator-shell"\s*\nversion\s*=\s*")([^"]+)(")/mu,
|
||||
label: 'Cargo.lock',
|
||||
},
|
||||
];
|
||||
|
||||
export function readLocalVersion() {
|
||||
return parseVersion(readPackageJson().version, '本地版本');
|
||||
}
|
||||
|
||||
export function validateVersionConsistency(expectedVersion) {
|
||||
for (const source of versionFileSources) {
|
||||
const match = fs.readFileSync(source.file, 'utf8').match(source.pattern);
|
||||
const actual = match ? match[2].trim() : '<未找到>';
|
||||
if (actual !== expectedVersion) {
|
||||
throw new Error(
|
||||
`AGC 版本号不一致:${source.label} 应为 ${expectedVersion},实际 ${actual}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function writeVersionFiles(version) {
|
||||
const nextVersion = parseVersion(version, '目标版本');
|
||||
const packageSource = fs.readFileSync(packageJsonPath, 'utf8');
|
||||
fs.writeFileSync(
|
||||
packageJsonPath,
|
||||
@@ -151,14 +197,47 @@ export async function prepareReleaseVersion() {
|
||||
),
|
||||
);
|
||||
|
||||
console.log(
|
||||
requestedVersion
|
||||
? `[ai-game-creator-shell] 使用指定版本 ${nextVersion}(本地 ${localVersion} / OSS ${remoteVersion ?? '不存在'})`
|
||||
: `[ai-game-creator-shell] 版本 ${localVersion} / OSS ${remoteVersion ?? '不存在'} -> ${nextVersion}`,
|
||||
);
|
||||
return nextVersion;
|
||||
}
|
||||
|
||||
export function assertVersionCommitted() {
|
||||
const repoPaths = versionFileSources.map((source) =>
|
||||
path.relative(repoRoot, source.file).replaceAll('\\', '/'),
|
||||
);
|
||||
const changed =
|
||||
spawnSync(
|
||||
'git',
|
||||
['diff', '--quiet', 'HEAD', '--', ...repoPaths],
|
||||
{ cwd: repoRoot },
|
||||
).status === 1;
|
||||
if (changed) {
|
||||
throw new Error(
|
||||
'版本文件相对 HEAD 存在未提交改动,请先提交后再发布:运行 npm --prefix apps/ai-game-creator-shell run bump-version -- --commit',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export function prepareReleaseVersion() {
|
||||
const localVersion = readLocalVersion();
|
||||
validateVersionConsistency(localVersion);
|
||||
console.log(
|
||||
`[ai-game-creator-shell] 使用仓库版本 ${localVersion}(不再自动递增或自由指定版本)`,
|
||||
);
|
||||
return localVersion;
|
||||
}
|
||||
|
||||
export async function assertVersionNotBelowOss(version) {
|
||||
const remoteVersion = await readRemoteVersion();
|
||||
if (remoteVersion && compareVersions(version, remoteVersion) < 0) {
|
||||
throw new Error(
|
||||
`仓库版本 ${version} 低于线上 OSS 版本 ${remoteVersion}。请先运行 npm --prefix apps/ai-game-creator-shell run bump-version 提升版本后再发布。`,
|
||||
);
|
||||
}
|
||||
console.log(
|
||||
`[ai-game-creator-shell] 线上 OSS 版本 ${remoteVersion ?? '不存在'},发布版本 ${version}`,
|
||||
);
|
||||
}
|
||||
|
||||
export function runTauriBuild(args = []) {
|
||||
const noBundle = args.includes('--no-bundle');
|
||||
const hasTarget = args.includes('--target');
|
||||
|
||||
@@ -1,16 +1,19 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { test } from 'node:test';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
import {
|
||||
bumpVersion,
|
||||
compareVersions,
|
||||
createUpdateManifest,
|
||||
nextPatchVersion,
|
||||
selectReleaseArtifact,
|
||||
} from './build-release.mjs';
|
||||
|
||||
test('selects an explicit release artifact when configured', () => {
|
||||
const artifactPath = new URL('../package.json', import.meta.url).pathname;
|
||||
const artifactPath = fileURLToPath(
|
||||
new URL('../package.json', import.meta.url),
|
||||
);
|
||||
const previous = process.env.AGC_UPDATE_ARTIFACT;
|
||||
process.env.AGC_UPDATE_ARTIFACT = artifactPath;
|
||||
try {
|
||||
@@ -30,7 +33,7 @@ test('does not select unsupported files', () => {
|
||||
|
||||
test('manifest contains version, download URL and integrity fields', () => {
|
||||
const manifest = createUpdateManifest(
|
||||
new URL('../package.json', import.meta.url).pathname,
|
||||
fileURLToPath(new URL('../package.json', import.meta.url)),
|
||||
);
|
||||
assert.match(manifest.version, /^\d+\.\d+\.\d+$/u);
|
||||
assert.match(
|
||||
@@ -46,7 +49,7 @@ test('manifest preserves multiline release notes', () => {
|
||||
process.env.AGC_UPDATE_RELEASE_NOTES = '第一行\n第二行\r\n第三行';
|
||||
try {
|
||||
const manifest = createUpdateManifest(
|
||||
new URL('../package.json', import.meta.url).pathname,
|
||||
fileURLToPath(new URL('../package.json', import.meta.url)),
|
||||
);
|
||||
assert.equal(manifest.releaseNotes, '第一行\n第二行\r\n第三行');
|
||||
} finally {
|
||||
@@ -55,11 +58,14 @@ test('manifest preserves multiline release notes', () => {
|
||||
}
|
||||
});
|
||||
|
||||
test('next release version follows the higher local or OSS version', () => {
|
||||
test('bump version follows the requested target', () => {
|
||||
assert.equal(compareVersions('0.1.15', '0.1.12'), 1);
|
||||
assert.equal(nextPatchVersion('0.1.12', '0.1.15'), '0.1.16');
|
||||
assert.equal(nextPatchVersion('0.1.18', '0.1.15'), '0.1.19');
|
||||
assert.equal(nextPatchVersion('0.1.12', null), '0.1.13');
|
||||
assert.equal(compareVersions('0.1.12', '0.1.12'), 0);
|
||||
assert.equal(bumpVersion('0.1.19', 'patch'), '0.1.20');
|
||||
assert.equal(bumpVersion('0.1.19'), '0.1.20');
|
||||
assert.equal(bumpVersion('0.1.19', 'minor'), '0.2.0');
|
||||
assert.equal(bumpVersion('0.1.19', 'major'), '1.0.0');
|
||||
assert.equal(bumpVersion('0.1.12', '0.1.25'), '0.1.25');
|
||||
});
|
||||
|
||||
test('release upload forces overwrite for versioned artifact and latest pointer', () => {
|
||||
@@ -72,3 +78,12 @@ test('release upload forces overwrite for versioned artifact and latest pointer'
|
||||
2,
|
||||
);
|
||||
});
|
||||
|
||||
test('release upload verifies the version is committed and not below OSS', () => {
|
||||
const source = readFileSync(
|
||||
new URL('./release-upload.mjs', import.meta.url),
|
||||
'utf8',
|
||||
);
|
||||
assert.match(source, /assertVersionCommitted\(\)/u);
|
||||
assert.match(source, /assertVersionNotBelowOss\(/u);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const appRoot = fileURLToPath(new URL('..', import.meta.url));
|
||||
const repoRoot = path.resolve(appRoot, '../..');
|
||||
|
||||
const {
|
||||
bumpVersion,
|
||||
readLocalVersion,
|
||||
writeVersionFiles,
|
||||
} = await import('./build-release.mjs');
|
||||
|
||||
const versionFiles = [
|
||||
'apps/ai-game-creator-shell/package.json',
|
||||
'package-lock.json',
|
||||
'apps/ai-game-creator-shell/src-tauri/tauri.conf.json',
|
||||
'apps/ai-game-creator-shell/src-tauri/Cargo.toml',
|
||||
'apps/ai-game-creator-shell/src-tauri/Cargo.lock',
|
||||
];
|
||||
|
||||
function parseArgs(argv) {
|
||||
const args = argv.slice(2);
|
||||
const explicitIndex = args.indexOf('--version');
|
||||
const explicit = explicitIndex >= 0 ? args[explicitIndex + 1] : null;
|
||||
const target =
|
||||
explicit ??
|
||||
args.find((arg) => arg === 'patch' || arg === 'minor' || arg === 'major') ??
|
||||
'patch';
|
||||
return { target, commit: args.includes('--commit') };
|
||||
}
|
||||
|
||||
function runGit(args) {
|
||||
const result = spawnSync('git', args, { cwd: repoRoot, stdio: 'inherit' });
|
||||
if (result.error) throw result.error;
|
||||
return result.status ?? 1;
|
||||
}
|
||||
|
||||
function hasUncommittedVersionChanges() {
|
||||
return (
|
||||
spawnSync('git', ['diff', '--quiet', '--', ...versionFiles], {
|
||||
cwd: repoRoot,
|
||||
}).status === 1
|
||||
);
|
||||
}
|
||||
|
||||
function otherStagedFiles() {
|
||||
const result = spawnSync(
|
||||
'git',
|
||||
['diff', '--cached', '--name-only'],
|
||||
{ cwd: repoRoot, encoding: 'utf8' },
|
||||
);
|
||||
if (result.status !== 0) {
|
||||
throw new Error('无法读取已暂存文件列表');
|
||||
}
|
||||
const versionSet = new Set(versionFiles);
|
||||
return result.stdout
|
||||
.split('\n')
|
||||
.map((file) => file.trim())
|
||||
.filter(Boolean)
|
||||
.filter((file) => !versionSet.has(file));
|
||||
}
|
||||
|
||||
const { target, commit } = parseArgs(process.argv);
|
||||
|
||||
// 提交前先确认没有其他已暂存改动,避免把无关改动一并提交;若存在则在改动任何文件前中止。
|
||||
if (commit) {
|
||||
const others = otherStagedFiles();
|
||||
if (others.length > 0) {
|
||||
console.error(
|
||||
`[bump-version] 检测到其他已暂存改动,为避免误提交,请先单独处理后再运行 --commit:\n ${others.join('\n ')}`,
|
||||
);
|
||||
console.error(
|
||||
'提示:用 git restore --staged <file> 取消暂存,或先提交这些改动。',
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
const current = readLocalVersion();
|
||||
|
||||
// 若版本文件已带着一次未提交的提升(例如先默认跑过一次),再 --commit 时不再重复递增,直接提交现有改动。
|
||||
const next =
|
||||
commit && hasUncommittedVersionChanges()
|
||||
? current
|
||||
: bumpVersion(current, target);
|
||||
|
||||
writeVersionFiles(next);
|
||||
|
||||
if (!commit) {
|
||||
console.log(
|
||||
`[bump-version] 版本 ${current} -> ${next}(已写入版本文件,未创建提交;如需提交加 --commit)`,
|
||||
);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const addStatus = runGit(['add', ...versionFiles]);
|
||||
if (addStatus !== 0) process.exit(addStatus);
|
||||
|
||||
const hasDiff =
|
||||
spawnSync('git', ['diff', '--cached', '--quiet'], {
|
||||
cwd: repoRoot,
|
||||
}).status === 1;
|
||||
|
||||
if (!hasDiff) {
|
||||
console.log(`[bump-version] 版本未变化(已是 ${current}),未创建提交`);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const commitStatus = runGit([
|
||||
'commit',
|
||||
'-m',
|
||||
`提升 AGC 版本至 ${next}`,
|
||||
'-m',
|
||||
`- AGC 客户端版本提升至 ${next}`,
|
||||
'-m',
|
||||
'- 同步更新 package.json、根 package-lock.json、tauri.conf.json、Cargo.toml、Cargo.lock 中的 AGC 包条目',
|
||||
]);
|
||||
if (commitStatus !== 0) process.exit(commitStatus);
|
||||
|
||||
console.log(`[bump-version] 已提交版本 ${next}(本地提交,未推送)`);
|
||||
@@ -107,16 +107,12 @@ const rustSharedContractSource = fs.readFileSync(
|
||||
'utf8',
|
||||
);
|
||||
const allowedUncalledTauriCommands = [
|
||||
'append_direct_project_conversation_message',
|
||||
'chat_with_game_creator_agent',
|
||||
'check_ui_editor_font_glyph_coverage',
|
||||
'create_ui_design_resource',
|
||||
'open_game_creator_launcher_window',
|
||||
'open_game_creator_workspace_window',
|
||||
'read_direct_project_conversation',
|
||||
'stop_local_game_preview_if_matches',
|
||||
'start_game_creator_external_mcp',
|
||||
'stop_game_creator_external_mcp',
|
||||
];
|
||||
const sourceExtensions = new Set([
|
||||
'.json',
|
||||
|
||||
@@ -9,8 +9,13 @@ if (!/^[a-z0-9][a-z0-9.-]{1,62}$/u.test(bucket) || /[\r\n\0]/u.test(endpoint)) {
|
||||
}
|
||||
process.env.AGC_UPDATE_OSS_BASE_URL ||= `https://${bucket}.${endpoint}/agc`;
|
||||
|
||||
const { generateUpdateManifest, prepareReleaseVersion, runTauriBuild } =
|
||||
await import('./build-release.mjs');
|
||||
const {
|
||||
assertVersionCommitted,
|
||||
assertVersionNotBelowOss,
|
||||
generateUpdateManifest,
|
||||
prepareReleaseVersion,
|
||||
runTauriBuild,
|
||||
} = await import('./build-release.mjs');
|
||||
|
||||
function runOssutil(args) {
|
||||
const binary = process.env.OSSUTIL_BIN?.trim() || 'ossutil';
|
||||
@@ -36,7 +41,9 @@ function runOssutil(args) {
|
||||
if (result.status !== 0) process.exit(result.status ?? 1);
|
||||
}
|
||||
|
||||
await prepareReleaseVersion();
|
||||
const releaseVersion = await prepareReleaseVersion();
|
||||
assertVersionCommitted();
|
||||
await assertVersionNotBelowOss(releaseVersion);
|
||||
runTauriBuild([]);
|
||||
const { artifact, manifestPath, manifest } = generateUpdateManifest();
|
||||
const artifactKey = `agc/${manifest.version}/${path.basename(artifact)}`;
|
||||
|
||||
@@ -127,39 +127,6 @@ function readBackendTargets({ requireAgcBackend = false } = {}) {
|
||||
});
|
||||
}
|
||||
|
||||
function readBackendServiceFailure(
|
||||
state,
|
||||
{
|
||||
expectedDatabase = backendDatabase,
|
||||
expectedSpacetimeDataDir = backendSpacetimeDataDir,
|
||||
} = {},
|
||||
) {
|
||||
const targets = resolveBackendTargetsFromState(state, {
|
||||
requireAgcBackend: true,
|
||||
expectedDatabase,
|
||||
expectedSpacetimeDataDir,
|
||||
});
|
||||
if (!targets.hasMatchingBackend) {
|
||||
return null;
|
||||
}
|
||||
|
||||
for (const serviceName of ['spacetime', 'api-server', 'bgfilter-worker']) {
|
||||
const service = state?.services?.[serviceName];
|
||||
if (service?.status !== 'failed') {
|
||||
continue;
|
||||
}
|
||||
|
||||
return {
|
||||
serviceName,
|
||||
failure: service.signal
|
||||
? `signal=${service.signal}`
|
||||
: `code=${service.exitCode ?? 1}`,
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
async function isBackendReady({
|
||||
state = readJson(devStackStatePath),
|
||||
isReady = isHttpReady,
|
||||
@@ -538,29 +505,11 @@ async function terminateChildTree(
|
||||
return { stopped, forced: true };
|
||||
}
|
||||
|
||||
async function waitForBackendReady(
|
||||
backendChild,
|
||||
timeoutMs = 600_000,
|
||||
{
|
||||
checkBackendReady = isBackendReady,
|
||||
readState = () => readJson(devStackStatePath),
|
||||
resolveTargets = readBackendTargets,
|
||||
} = {},
|
||||
) {
|
||||
const initialStateUpdatedAt = readState()?.updatedAt ?? '';
|
||||
async function waitForBackendReady(backendChild, timeoutMs = 600_000) {
|
||||
const startedAt = Date.now();
|
||||
while (Date.now() - startedAt < timeoutMs) {
|
||||
if (await checkBackendReady()) {
|
||||
return resolveTargets();
|
||||
}
|
||||
const state = readState();
|
||||
if ((state?.updatedAt ?? '') !== initialStateUpdatedAt) {
|
||||
const serviceFailure = readBackendServiceFailure(state);
|
||||
if (serviceFailure) {
|
||||
throw new Error(
|
||||
`配套后端启动失败: ${serviceFailure.serviceName} ${serviceFailure.failure}`,
|
||||
);
|
||||
}
|
||||
if (await isBackendReady()) {
|
||||
return readBackendTargets();
|
||||
}
|
||||
const failure = readChildFailure(backendChild);
|
||||
if (failure) {
|
||||
@@ -737,7 +686,6 @@ export {
|
||||
isDirectModuleExecution,
|
||||
isProcessGroupAlive,
|
||||
preflightExistingVite,
|
||||
readBackendServiceFailure,
|
||||
readChildFailure,
|
||||
readExistingViteServer,
|
||||
readLinuxProcessGroupAlive,
|
||||
|
||||
+1
-1
@@ -1703,7 +1703,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "genarrative-ai-game-creator-shell"
|
||||
version = "0.1.12"
|
||||
version = "0.1.19"
|
||||
dependencies = [
|
||||
"agent-runtime-core",
|
||||
"axum",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "genarrative-ai-game-creator-shell"
|
||||
version = "0.1.12"
|
||||
version = "0.1.19"
|
||||
edition = "2021"
|
||||
publish = false
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
+9
-10
@@ -1,22 +1,21 @@
|
||||
---
|
||||
name: agc-web-game-development
|
||||
description: Build or modify a playable npm-managed Phaser 4 web game in the current AGC project. Use for gameplay creation, bug fixes, UI or layout changes, responsive behavior, asset integration, controls, scoring, reset flows, and other HTML, CSS, JavaScript, DOM, Canvas, or WebGL work.
|
||||
description: Build or modify a playable web game in the current AGC project. Use for gameplay creation, bug fixes, UI or layout changes, responsive behavior, asset integration, controls, scoring, reset flows, and other HTML, CSS, JavaScript, DOM, Canvas, or WebGL work.
|
||||
---
|
||||
|
||||
# AGC Web Game Development
|
||||
|
||||
Implement the user's actual game request in the current project as an npm-managed Phaser 4.2.1 game. Use Phaser scenes for gameplay and DOM only for deliberately external UI.
|
||||
Implement the user's actual game request in the current project. Choose DOM, Canvas, WebGL, or a combination based on the game rather than a fixed code template.
|
||||
|
||||
## Workflow
|
||||
|
||||
1. Read the existing package and source files before editing. New projects place `package.json`, `index.html`, `style.css`, and `game.js` under `game/`; existing root packages retain their layout. Run npm in that package directory (for example `npm --prefix game ci` and `npm --prefix game run build`).
|
||||
2. Keep `package.json` and `package-lock.json` authoritative. Import Phaser with `import Phaser from 'phaser'`; do not copy a bundle, add an import map, or use a CDN. Other npm dependencies are allowed when the game needs them.
|
||||
3. Build with the project's npm script before previewing. The playable entry is the package directory's `dist/index.html`; never report an unbuilt bare-module page as playable. Import assets or configure public assets so all runtime media is included in dist; preview and exports cannot read outside it.
|
||||
4. Build a complete playable loop: visible objective, responsive input, meaningful state changes, success or failure feedback, and a reliable restart path where the game needs one.
|
||||
5. Fit the active game scene to desktop and mobile viewports without accidental page scrollbars. Reserve deliberate safe space for HUD elements instead of covering interactive content.
|
||||
6. Reuse registered Taonier art when available through `agc_tools`. Load media defensively and keep gameplay usable when an optional derivative is absent; never relabel a local placeholder as platform art.
|
||||
7. Let Phaser own the render loop and input dispatch. Avoid duplicate scenes, stale event listeners, and state that survives restart unintentionally.
|
||||
8. After a meaningful game change, use the browser playtest Skill and fix issues shown by real evidence before reporting completion.
|
||||
1. Read the existing `index.html`, `style.css`, and `game.js` before modifying an existing game.
|
||||
2. Keep the entry self-contained and runnable from the AGC loopback preview. Avoid CDN-only dependencies and network-required runtime assets.
|
||||
3. Build a complete playable loop: visible objective, responsive input, meaningful state changes, success or failure feedback, and a reliable restart path where the game needs one.
|
||||
4. Fit the active game scene to desktop and mobile viewports without accidental page scrollbars. Reserve deliberate safe space for HUD elements instead of covering interactive content.
|
||||
5. Reuse registered Taonier art when available through `agc_tools`. Load media defensively and keep gameplay usable when an optional derivative is absent; never relabel a local placeholder as platform art.
|
||||
6. Avoid undefined animation callbacks, duplicate loops, stale event listeners, and state that survives restart unintentionally.
|
||||
7. After a meaningful game change, use the browser playtest Skill and fix issues shown by real evidence before reporting completion.
|
||||
|
||||
When implementing a new game loop or a broad gameplay revision, read `references/game-quality-checklist.md`.
|
||||
|
||||
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
interface:
|
||||
display_name: "Web 游戏实现"
|
||||
short_description: "在当前项目内设计、实现并验证 npm 管理的 Phaser 4 游戏"
|
||||
short_description: "在当前项目内设计、实现并验证可玩的 HTML、CSS 与 JavaScript 游戏"
|
||||
default_prompt: "Use $agc-web-game-development to build or modify the current playable web game."
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"schemaVersion": "agc-skill-pack.v1",
|
||||
"version": "2026-08-26.12",
|
||||
"version": "2026-08-26.10",
|
||||
"skills": [
|
||||
{
|
||||
"name": "agc-project-structure",
|
||||
@@ -57,7 +57,7 @@
|
||||
"agents/openai.yaml",
|
||||
"references/game-quality-checklist.md"
|
||||
],
|
||||
"sha256": "0649c72dd53e05ad7c87b28def1397c2badf61b0c308091196c40f7c48a8b36a"
|
||||
"sha256": "d7748d9ebf4324add0541daf16a2bbec09c4862b85af55bfb369c7f3b99aedff"
|
||||
},
|
||||
{
|
||||
"name": "agc-browser-playtest",
|
||||
|
||||
@@ -14,8 +14,6 @@ mod codex_cli;
|
||||
mod codex_provider_proxy;
|
||||
mod direct_codex_attachments;
|
||||
mod direct_codex_audit;
|
||||
mod direct_project_history;
|
||||
mod direct_project_turn_history;
|
||||
mod direct_runtime;
|
||||
mod direct_tool_bridge;
|
||||
mod direct_tools_mcp;
|
||||
@@ -40,8 +38,6 @@ pub(crate) use codex_cli::{
|
||||
pub(crate) use codex_provider_proxy::*;
|
||||
pub(crate) use direct_codex_attachments::*;
|
||||
pub(crate) use direct_codex_audit::*;
|
||||
pub(crate) use direct_project_history::*;
|
||||
pub(crate) use direct_project_turn_history::*;
|
||||
pub(crate) use direct_runtime::*;
|
||||
pub(crate) use direct_tool_bridge::*;
|
||||
pub(crate) use direct_tools_mcp::*;
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,467 +0,0 @@
|
||||
use crate::config::prepare_game_creator_private_path_for_read;
|
||||
use crate::project::{
|
||||
append_jsonl_line_unlocked, enforce_project_permission_policy, project_append_lock_for,
|
||||
};
|
||||
use crate::{LocalConversationMessageRecord, LocalConversationResult};
|
||||
use serde_json::Value;
|
||||
use std::fs::File;
|
||||
use std::io::{BufRead, BufReader, Read, Seek, SeekFrom};
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
const DIRECT_PROJECT_HISTORY_RECORD_TYPE: &str = "response_item";
|
||||
const DIRECT_PROJECT_INTERNAL_CONTEXT_KINDS: &[&str] = &[
|
||||
"host_skills.instructions",
|
||||
"permissions.instructions",
|
||||
"environments.environment_context",
|
||||
];
|
||||
|
||||
const DIRECT_PROJECT_CONTEXTUAL_USER_TEXT_MARKERS: &[(&str, &str)] = &[
|
||||
("# AGENTS.md instructions", "</INSTRUCTIONS>"),
|
||||
("<environment_context>", "</environment_context>"),
|
||||
("<skill>", "</skill>"),
|
||||
("<user_shell_command>", "</user_shell_command>"),
|
||||
("<turn_aborted>", "</turn_aborted>"),
|
||||
("<subagent_notification>", "</subagent_notification>"),
|
||||
("<recommended_plugins>", "</recommended_plugins>"),
|
||||
("<goal_context>", "</goal_context>"),
|
||||
];
|
||||
|
||||
fn is_known_contextual_user_text(text: &str) -> bool {
|
||||
let text = text.trim();
|
||||
if DIRECT_PROJECT_CONTEXTUAL_USER_TEXT_MARKERS
|
||||
.iter()
|
||||
.any(|(start, end)| text.starts_with(start) && text.ends_with(end))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
if text.starts_with("<codex_internal_context") && text.ends_with("</codex_internal_context>") {
|
||||
return true;
|
||||
}
|
||||
text.starts_with("<external_")
|
||||
&& text
|
||||
.split_once('>')
|
||||
.and_then(|(start, _)| start.strip_prefix("<external_"))
|
||||
.is_some_and(|key| text.ends_with(&format!("</external_{key}>")))
|
||||
}
|
||||
|
||||
pub(crate) fn is_direct_project_internal_context_item(item: &Value) -> bool {
|
||||
if matches!(
|
||||
item.get("role").and_then(Value::as_str),
|
||||
Some("developer" | "system")
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
if item
|
||||
.pointer("/internal_chat_message_metadata_passthrough/content_item_kinds")
|
||||
.and_then(Value::as_array)
|
||||
.is_some_and(|kinds| {
|
||||
kinds
|
||||
.iter()
|
||||
.filter_map(Value::as_str)
|
||||
.any(|kind| DIRECT_PROJECT_INTERNAL_CONTEXT_KINDS.contains(&kind))
|
||||
})
|
||||
{
|
||||
return true;
|
||||
}
|
||||
item.get("content")
|
||||
.and_then(Value::as_array)
|
||||
.is_some_and(|parts| {
|
||||
parts.iter().any(|part| {
|
||||
let Some(text) = part.get("text").and_then(Value::as_str) else {
|
||||
return false;
|
||||
};
|
||||
is_known_contextual_user_text(text)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
fn history_path(root: &Path) -> PathBuf {
|
||||
root.join(".agent/conversations/project.jsonl")
|
||||
}
|
||||
|
||||
fn record(item: &Value) -> Result<String, String> {
|
||||
serde_json::to_string(&serde_json::json!({
|
||||
"type": DIRECT_PROJECT_HISTORY_RECORD_TYPE,
|
||||
"payload": item,
|
||||
}))
|
||||
.map_err(|error| format!("序列化 DirectProject 历史失败:{error}"))
|
||||
}
|
||||
|
||||
const DIRECT_PROJECT_HISTORY_REVERSE_SCAN_CHUNK_BYTES: usize = 16 * 1024;
|
||||
|
||||
fn find_direct_project_history_item_by_id_at(
|
||||
path: &Path,
|
||||
item_id: &str,
|
||||
) -> Result<Option<Value>, String> {
|
||||
let mut file = File::open(path)
|
||||
.map_err(|error| format!("打开 DirectProject 历史失败:{}: {error}", path.display()))?;
|
||||
let mut position = file
|
||||
.metadata()
|
||||
.map_err(|error| {
|
||||
format!(
|
||||
"读取 DirectProject 历史元数据失败:{}: {error}",
|
||||
path.display()
|
||||
)
|
||||
})?
|
||||
.len();
|
||||
let mut pending = Vec::new();
|
||||
let mut chunk = vec![0u8; DIRECT_PROJECT_HISTORY_REVERSE_SCAN_CHUNK_BYTES];
|
||||
|
||||
loop {
|
||||
if position == 0 {
|
||||
break;
|
||||
}
|
||||
let read_len = usize::try_from(position)
|
||||
.unwrap_or(usize::MAX)
|
||||
.min(chunk.len());
|
||||
position -= read_len as u64;
|
||||
file.seek(SeekFrom::Start(position))
|
||||
.map_err(|error| format!("定位 DirectProject 历史失败:{}: {error}", path.display()))?;
|
||||
file.read_exact(&mut chunk[..read_len])
|
||||
.map_err(|error| format!("读取 DirectProject 历史失败:{}: {error}", path.display()))?;
|
||||
|
||||
let mut combined = Vec::with_capacity(read_len + pending.len());
|
||||
combined.extend_from_slice(&chunk[..read_len]);
|
||||
combined.extend_from_slice(&pending);
|
||||
let mut line_end = combined.len();
|
||||
while let Some(newline) = combined[..line_end].iter().rposition(|byte| *byte == b'\n') {
|
||||
let line = &combined[newline + 1..line_end];
|
||||
if !line.is_empty() {
|
||||
if let Some(item) = direct_project_history_item_from_line(path, line)? {
|
||||
if item.get("id").and_then(Value::as_str) == Some(item_id) {
|
||||
return Ok(Some(item));
|
||||
}
|
||||
}
|
||||
}
|
||||
line_end = newline;
|
||||
}
|
||||
pending = combined[..line_end].to_vec();
|
||||
}
|
||||
|
||||
if !pending.is_empty() {
|
||||
// The append path repairs an unterminated final JSONL record before
|
||||
// writing. A duplicate scan must not reject that repairable tail.
|
||||
if let Ok(Some(item)) = direct_project_history_item_from_line(path, &pending) {
|
||||
if item.get("id").and_then(Value::as_str) == Some(item_id) {
|
||||
return Ok(Some(item));
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
fn direct_project_history_item_from_line(
|
||||
path: &Path,
|
||||
line: &[u8],
|
||||
) -> Result<Option<Value>, String> {
|
||||
if line.iter().all(|byte| byte.is_ascii_whitespace()) {
|
||||
return Ok(None);
|
||||
}
|
||||
let parsed: Value = serde_json::from_slice(line)
|
||||
.map_err(|error| format!("解析 DirectProject 历史失败:{}: {error}", path.display()))?;
|
||||
if parsed.get("type").and_then(Value::as_str) != Some(DIRECT_PROJECT_HISTORY_RECORD_TYPE) {
|
||||
return Err(format!(
|
||||
"DirectProject 历史记录类型无效:{}",
|
||||
path.display()
|
||||
));
|
||||
}
|
||||
let item = parsed
|
||||
.get("payload")
|
||||
.cloned()
|
||||
.ok_or_else(|| format!("DirectProject 历史记录缺少 payload:{}", path.display()))?;
|
||||
if is_direct_project_internal_context_item(&item) {
|
||||
return Ok(None);
|
||||
}
|
||||
Ok(Some(item))
|
||||
}
|
||||
|
||||
pub(crate) fn append_direct_project_history_item_at(
|
||||
root: &Path,
|
||||
item: &Value,
|
||||
) -> Result<(), String> {
|
||||
append_direct_project_history_item_at_with_user_policy(root, item, false)
|
||||
}
|
||||
|
||||
/// Appends a user message authored by AGC itself. Codex response items use
|
||||
/// the default path above, which deliberately ignores echoed user messages;
|
||||
/// this explicit entry point keeps the two sources distinct.
|
||||
pub(crate) fn append_direct_project_user_message_at(
|
||||
root: &Path,
|
||||
item: &Value,
|
||||
) -> Result<(), String> {
|
||||
append_direct_project_history_item_at_with_user_policy(root, item, true)
|
||||
}
|
||||
|
||||
fn append_direct_project_history_item_at_with_user_policy(
|
||||
root: &Path,
|
||||
item: &Value,
|
||||
allow_user_item: bool,
|
||||
) -> Result<(), String> {
|
||||
enforce_project_permission_policy(root, "conversation.write")?;
|
||||
if is_direct_project_internal_context_item(item) {
|
||||
return Ok(());
|
||||
}
|
||||
if !allow_user_item && is_direct_project_codex_user_item(item) {
|
||||
return Ok(());
|
||||
}
|
||||
let _project_lock = crate::project::acquire_project_write_lock(root, "conversation.write")?;
|
||||
let path = history_path(root);
|
||||
let history_exists =
|
||||
prepare_game_creator_private_path_for_read(&path, false, "DirectProject 历史")?;
|
||||
let lock = project_append_lock_for(&path)?;
|
||||
let _append_guard = lock.lock("DirectProject 历史追加写")?;
|
||||
if history_exists {
|
||||
if let Some(item_id) = item.get("id").and_then(Value::as_str) {
|
||||
if let Some(existing) = find_direct_project_history_item_by_id_at(&path, item_id)? {
|
||||
if &existing == item {
|
||||
return Ok(());
|
||||
}
|
||||
return Err(format!("DirectProject 历史 item id 冲突:{item_id}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
let line = record(item)?;
|
||||
append_jsonl_line_unlocked(&path, &line, "DirectProject 历史")
|
||||
}
|
||||
|
||||
fn is_direct_project_codex_user_item(item: &Value) -> bool {
|
||||
if item.get("type").and_then(Value::as_str) == Some("userMessage") {
|
||||
return true;
|
||||
}
|
||||
if item.get("role").and_then(Value::as_str) != Some("user") {
|
||||
return false;
|
||||
}
|
||||
!item
|
||||
.get("id")
|
||||
.and_then(Value::as_str)
|
||||
.is_some_and(|id| id.starts_with("direct-codex:") && id.ends_with(":user"))
|
||||
}
|
||||
|
||||
pub(crate) fn direct_project_local_message_item(
|
||||
role: &str,
|
||||
content: &str,
|
||||
message_id: Option<&str>,
|
||||
) -> Result<Value, String> {
|
||||
let role = role.trim();
|
||||
let content = content.trim();
|
||||
if content.is_empty() || !matches!(role, "user" | "assistant") {
|
||||
return Err("DirectProject 只接受非空 user/assistant 历史消息".to_string());
|
||||
}
|
||||
let mut item = serde_json::json!({
|
||||
"type": "message",
|
||||
"role": role,
|
||||
"content": [{
|
||||
"type": if role == "user" { "input_text" } else { "output_text" },
|
||||
"text": content,
|
||||
}],
|
||||
});
|
||||
if let Some(message_id) = message_id.map(str::trim).filter(|value| !value.is_empty()) {
|
||||
item["id"] = Value::String(message_id.to_string());
|
||||
}
|
||||
Ok(item)
|
||||
}
|
||||
|
||||
pub(crate) fn read_direct_project_history_items_at(root: &Path) -> Result<Vec<Value>, String> {
|
||||
let path = history_path(root);
|
||||
if !prepare_game_creator_private_path_for_read(&path, false, "DirectProject 历史")? {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
let file = File::open(&path)
|
||||
.map_err(|error| format!("打开 DirectProject 历史失败:{}: {error}", path.display()))?;
|
||||
let mut items = Vec::new();
|
||||
let mut reader = BufReader::new(file);
|
||||
loop {
|
||||
let mut line = String::new();
|
||||
let bytes = reader
|
||||
.read_line(&mut line)
|
||||
.map_err(|error| format!("读取 DirectProject 历史失败:{}: {error}", path.display()))?;
|
||||
if bytes == 0 {
|
||||
break;
|
||||
}
|
||||
let had_newline = line.ends_with('\n');
|
||||
let trimmed = line.trim();
|
||||
if trimmed.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let parsed: Value = match serde_json::from_str(trimmed) {
|
||||
Ok(value) => value,
|
||||
Err(_error) if !had_newline => break,
|
||||
Err(error) => {
|
||||
return Err(format!(
|
||||
"解析 DirectProject 历史失败:{}: {error}",
|
||||
path.display()
|
||||
));
|
||||
}
|
||||
};
|
||||
if parsed.get("type").and_then(Value::as_str) != Some(DIRECT_PROJECT_HISTORY_RECORD_TYPE) {
|
||||
return Err(format!(
|
||||
"DirectProject 历史记录类型无效:{}",
|
||||
path.display()
|
||||
));
|
||||
}
|
||||
let item = parsed
|
||||
.get("payload")
|
||||
.cloned()
|
||||
.ok_or_else(|| format!("DirectProject 历史记录缺少 payload:{}", path.display()))?;
|
||||
if is_direct_project_internal_context_item(&item) {
|
||||
continue;
|
||||
}
|
||||
items.push(item);
|
||||
}
|
||||
Ok(items)
|
||||
}
|
||||
|
||||
pub(crate) fn read_direct_project_chat_history_at(
|
||||
root: &Path,
|
||||
) -> Result<LocalConversationResult, String> {
|
||||
let path = history_path(root);
|
||||
let items = read_direct_project_history_items_at(root)?;
|
||||
let messages = items
|
||||
.into_iter()
|
||||
.filter_map(|item| {
|
||||
let role = item.get("role").and_then(Value::as_str)?;
|
||||
if !matches!(role, "user" | "assistant") {
|
||||
return None;
|
||||
}
|
||||
let content = item
|
||||
.get("content")
|
||||
.and_then(Value::as_array)
|
||||
.map(|parts| {
|
||||
parts
|
||||
.iter()
|
||||
.filter_map(|part| part.get("text").and_then(Value::as_str))
|
||||
.collect::<Vec<_>>()
|
||||
.join("")
|
||||
})
|
||||
.unwrap_or_default();
|
||||
(!content.is_empty()).then(|| LocalConversationMessageRecord {
|
||||
schema_version: "agc-direct-project-context.v1".to_string(),
|
||||
role: role.to_string(),
|
||||
content,
|
||||
agent_id: None,
|
||||
message_id: item.get("id").and_then(Value::as_str).map(str::to_string),
|
||||
updated_at: 0,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
Ok(LocalConversationResult {
|
||||
path: path.to_string_lossy().into_owned(),
|
||||
agent_id: None,
|
||||
session_id: None,
|
||||
messages,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{
|
||||
append_direct_project_history_item_at, append_direct_project_user_message_at, history_path,
|
||||
is_direct_project_internal_context_item, read_direct_project_history_items_at,
|
||||
};
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn filters_host_context_but_keeps_real_user_items() {
|
||||
assert!(is_direct_project_internal_context_item(&json!({
|
||||
"type": "message",
|
||||
"role": "developer",
|
||||
"content": [{"type": "input_text", "text": "<skills_instructions>"}]
|
||||
})));
|
||||
assert!(is_direct_project_internal_context_item(&json!({
|
||||
"type": "message",
|
||||
"role": "user",
|
||||
"content": [{"type": "input_text", "text": "<environment_context>"}],
|
||||
"internal_chat_message_metadata_passthrough": {
|
||||
"content_item_kinds": ["environments.environment_context"]
|
||||
}
|
||||
})));
|
||||
assert!(!is_direct_project_internal_context_item(&json!({
|
||||
"type": "message",
|
||||
"role": "user",
|
||||
"content": [{"type": "input_text", "text": "ls ui"}],
|
||||
"internal_chat_message_metadata_passthrough": {
|
||||
"content_item_kinds": ["user.text"]
|
||||
}
|
||||
})));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn filters_codex_contextual_user_item_without_passthrough_metadata() {
|
||||
assert!(is_direct_project_internal_context_item(&json!({
|
||||
"type": "message",
|
||||
"role": "user",
|
||||
"content": [{
|
||||
"type": "input_text",
|
||||
"text": "<environment_context>\n<cwd>/tmp/project</cwd>\n</environment_context>"
|
||||
}]
|
||||
})));
|
||||
assert!(!is_direct_project_internal_context_item(&json!({
|
||||
"type": "message",
|
||||
"role": "user",
|
||||
"content": [{
|
||||
"type": "input_text",
|
||||
"text": "请读取 <environment_context> 中的说明"
|
||||
}]
|
||||
})));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn append_repairs_truncated_tail_before_idempotency_scan() {
|
||||
let root = tempfile::tempdir().expect("temp project");
|
||||
crate::init_local_game_project_at(root.path(), "tail-repair", "尾行修复")
|
||||
.expect("init project");
|
||||
let path = history_path(root.path());
|
||||
std::fs::create_dir_all(path.parent().expect("history parent")).expect("history dir");
|
||||
std::fs::write(
|
||||
&path,
|
||||
br#"{"type":"response_item","payload":{"type":"message"}"#,
|
||||
)
|
||||
.expect("write truncated history tail");
|
||||
let item = serde_json::json!({
|
||||
"type": "message",
|
||||
"role": "assistant",
|
||||
"id": "tail-repair-item",
|
||||
"content": [{"type": "output_text", "text": "已修复"}]
|
||||
});
|
||||
append_direct_project_history_item_at(root.path(), &item).expect("repair and append");
|
||||
let items =
|
||||
read_direct_project_history_items_at(root.path()).expect("read repaired history");
|
||||
assert_eq!(items, vec![item]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn codex_user_echo_is_filtered_but_agc_user_message_is_persisted() {
|
||||
let root = tempfile::tempdir().expect("temp project");
|
||||
crate::init_local_game_project_at(root.path(), "user-echo", "用户回显过滤")
|
||||
.expect("init project");
|
||||
let user = serde_json::json!({
|
||||
"type": "message",
|
||||
"role": "user",
|
||||
"id": "direct-codex:turn-0001:user",
|
||||
"content": [{"type": "input_text", "text": "请创建菜单"}]
|
||||
});
|
||||
append_direct_project_user_message_at(root.path(), &user).expect("persist AGC user");
|
||||
append_direct_project_history_item_at(
|
||||
root.path(),
|
||||
&serde_json::json!({
|
||||
"type": "userMessage",
|
||||
"id": "codex-user-item-1",
|
||||
"clientId": "turn-0001",
|
||||
"content": [{"type": "text", "text": "请创建菜单"}]
|
||||
}),
|
||||
)
|
||||
.expect("ignore Codex echo");
|
||||
append_direct_project_history_item_at(
|
||||
root.path(),
|
||||
&serde_json::json!({
|
||||
"type": "message",
|
||||
"role": "user",
|
||||
"id": "codex-raw-user-item-1",
|
||||
"content": [{"type": "input_text", "text": "请创建菜单"}]
|
||||
}),
|
||||
)
|
||||
.expect("ignore raw Codex user echo");
|
||||
let items = read_direct_project_history_items_at(root.path()).expect("read history");
|
||||
assert_eq!(items, vec![user]);
|
||||
}
|
||||
}
|
||||
@@ -1,54 +0,0 @@
|
||||
use super::direct_project_history::append_direct_project_history_item_at;
|
||||
use serde_json::Value;
|
||||
use std::collections::BTreeMap;
|
||||
use std::path::Path;
|
||||
|
||||
#[derive(Default)]
|
||||
pub(crate) struct DirectProjectHistoryAccumulator {
|
||||
text_by_item_id: BTreeMap<String, String>,
|
||||
}
|
||||
|
||||
impl DirectProjectHistoryAccumulator {
|
||||
pub(crate) fn observe_delta(&mut self, item_id: &str, delta: &str) {
|
||||
self.text_by_item_id
|
||||
.entry(item_id.to_string())
|
||||
.or_default()
|
||||
.push_str(delta);
|
||||
}
|
||||
|
||||
pub(crate) fn complete_item(&mut self, item: &Value) {
|
||||
if let Some(item_id) = item.get("id").and_then(Value::as_str) {
|
||||
self.text_by_item_id.remove(item_id);
|
||||
}
|
||||
}
|
||||
|
||||
fn take_partial_items(&mut self) -> impl Iterator<Item = Value> + '_ {
|
||||
std::mem::take(&mut self.text_by_item_id)
|
||||
.into_iter()
|
||||
.filter(|(_, text)| !text.is_empty())
|
||||
.map(|(item_id, text)| {
|
||||
serde_json::json!({
|
||||
"type": "message",
|
||||
"role": "assistant",
|
||||
"id": item_id,
|
||||
"content": [{"type": "output_text", "text": text}],
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn persist_direct_project_partial_items_at(
|
||||
root: &Path,
|
||||
accumulator: &mut DirectProjectHistoryAccumulator,
|
||||
) -> Result<(), String> {
|
||||
let items = accumulator.take_partial_items().collect::<Vec<_>>();
|
||||
let mut first_error = None;
|
||||
for item in items {
|
||||
if let Err(error) = append_direct_project_history_item_at(root, &item) {
|
||||
if first_error.is_none() {
|
||||
first_error = Some(error);
|
||||
}
|
||||
}
|
||||
}
|
||||
first_error.map_or(Ok(()), Err)
|
||||
}
|
||||
@@ -10,7 +10,7 @@ const MAX_DIRECT_SYSTEM_PROMPT_CHARS: usize = 16 * 1024;
|
||||
const MIN_DIRECT_CLIENT_TURN_ID_CHARS: usize = 6;
|
||||
const MAX_DIRECT_CLIENT_TURN_ID_CHARS: usize = 160;
|
||||
const DIRECT_TAONIER_IDENTITY_GUIDANCE: &str = "对外身份合同:你是“陶泥儿”,是 Genarrative 的游戏创作助手。用户询问你是谁、你的名称或能力时,以陶泥儿的身份回答;不要把 Codex、ChatGPT、OpenAI、模型、通用 AI 助手或内部执行智能体当作自己的名称或对外身份。Codex app-server 仅是客户端内部执行技术;只有用户明确询问底层实现时才可如实说明,同时仍以陶泥儿自称。";
|
||||
const DIRECT_AGC_ENGINEERING_GUIDANCE: &str = "AGC 工程合同:当前 cwd 是用户选择的项目目录。新 Web 游戏使用 npm + Vite,Phaser 固定为 4.2.1,在 `game.js` 或模块中使用 `import Phaser from 'phaser'`;可以按需使用其它 npm 依赖,不得复制 Phaser bundle、使用 import map 或 CDN。先读取当前 cwd 下适用的 AGENTS.md、README 或项目说明。源码使用 cwd 相对路径,例如 `index.html`、`style.css`、`game.js`、`package.json`、`package-lock.json`、`assets/hero.png`;依赖安装与构建使用项目自己的 npm scripts,完成后必须从 `dist/index.html` 试玩。 原生文件工具、patch 和命令参数使用 cwd 相对路径,例如 `index.html`、`style.css`、`game.js`、`assets/hero.png`;如果 Codex 原生文件修改不可用,可以按需用客户端 `agc_write_file` 把文本写入项目相对路径。调用 `agc_write_file` 时,content 必须是目标文件的完整原始 UTF-8 正文;不得把 command.exec 的 Exit code、Wall time、Output 包装、终端日志或解释文字一起复制进 content,命令结果只能用于判断,不能当作文件正文。`../`、绝对路径、`.agent/`、`.git/`、密钥文件和 Runtime 控制面属于客户端边界,不能请求扩权或直接改写。DirectProject 提供 Codex 原生文件、搜索、命令、图片查看、Skill、经客户端注入的 `agc_tools` MCP,以及客户端扩展列表中用户已启用的第三方 MCP。用户明确指定第三方 MCP Server 或工具时,先在当前可用工具中查找并直接调用;找不到时如实说明,不得伪造。你可以按需选择这些能力:`agc_write_file` 写入代码、配置、资源依赖清单或说明文件;`agc_generate_image` 生成普通图片、角色图、视觉规范图(icon-spec)、UI 设计图或发布宣传图;`agc_edit_image` 修改已登记图片;`taonier_prepare_game_art` 准备完整游戏美术包及可用的 canonical 切片;`agc_list_registered_assets`、`agc_list_project_files`、`agc_list_account_assets`、`agc_import_account_assets` 用于发现和接入资源依赖;`agc_create_or_derive_resource` 用于视频、角色动画、音效或背景音乐;`agc_browser_playtest` 用于需要时的本地试玩观察;Skill references 按需使用相对路径直接读取。切图、资源依赖、规范图和试玩都只是可选工具提示,不要求调用、固定顺序或特定产物,AGC 不会据此替你拆任务、编排 DAG、做强验收或阻止继续执行;不要等待 Supervisor、harness 或宿主规划器。不要读取或输出凭据、Token、Cookie、auth.json、.env 或宿主私密路径;项目锁、付费提交、幂等键、下载校验和客户端投影由客户端处理。游戏文件真实变化后客户端可登记资源和版本,Codex 不直接保存或伪造项目版本。";
|
||||
const DIRECT_AGC_ENGINEERING_GUIDANCE: &str = "AGC 工程合同(仅说明项目边界,不是流程门槛):当前 Codex cwd 是用户选择的项目目录(工作区根),源码、素材、音效和其它资源按项目现有结构放置;先按需读取当前 cwd 下适用的 `AGENTS.md`、README 或项目说明,把它们当作项目规范参考。原生文件工具、patch 和命令参数使用 cwd 相对路径,例如 `index.html`、`style.css`、`game.js`、`assets/hero.png`;如果 Codex 原生文件修改不可用,可以按需用客户端 `agc_write_file` 把文本写入项目相对路径。调用 `agc_write_file` 时,content 必须是目标文件的完整原始 UTF-8 正文;不得把 command.exec 的 Exit code、Wall time、Output 包装、终端日志或解释文字一起复制进 content,命令结果只能用于判断,不能当作文件正文。`../`、绝对路径、`.agent/`、`.git/`、密钥文件和 Runtime 控制面属于客户端边界,不能请求扩权或直接改写。DirectProject 提供 Codex 原生文件、搜索、命令、图片查看、Skill、经客户端注入的 `agc_tools` MCP,以及客户端扩展列表中用户已启用的第三方 MCP。用户明确指定第三方 MCP Server 或工具时,先在当前可用工具中查找并直接调用;找不到时如实说明,不得伪造。你可以按需选择这些能力:`agc_write_file` 写入代码、配置、资源依赖清单或说明文件;`agc_generate_image` 生成普通图片、角色图、视觉规范图(icon-spec)、UI 设计图或发布宣传图;`agc_edit_image` 修改已登记图片;`taonier_prepare_game_art` 准备完整游戏美术包及可用的 canonical 切片;`agc_list_registered_assets`、`agc_list_project_files`、`agc_list_account_assets`、`agc_import_account_assets` 用于发现和接入资源依赖;`agc_create_or_derive_resource` 用于视频、角色动画、音效或背景音乐;`agc_browser_playtest` 用于需要时的本地试玩观察;Skill references 按需使用相对路径直接读取。切图、资源依赖、规范图和试玩都只是可选工具提示,不要求调用、固定顺序或特定产物,AGC 不会据此替你拆任务、编排 DAG、做强验收或阻止继续执行;不要等待 Supervisor、harness 或宿主规划器。不要读取或输出凭据、Token、Cookie、auth.json、.env 或宿主私密路径;项目锁、付费提交、幂等键、下载校验和客户端投影由客户端处理。游戏文件真实变化后客户端可登记资源和版本,Codex 不直接保存或伪造项目版本。";
|
||||
const DIRECT_CODEX_ART_SPEC_ASSET_PATH: &str = "assets/art-spec.png";
|
||||
const DIRECT_CODEX_BACKGROUND_ASSET_PATH: &str = "assets/direct-game-background.png";
|
||||
const DIRECT_CODEX_SPRITESHEET_ASSET_PATH: &str = "assets/art-spritesheet.png";
|
||||
@@ -72,21 +72,6 @@ fn direct_codex_game_outputs(root: &Path) -> Vec<(String, &'static str, &'static
|
||||
(entry.to_string(), "game-entry", "text/html"),
|
||||
(format!("{prefix}style.css"), "game-style", "text/css"),
|
||||
(format!("{prefix}game.js"), "game-script", "text/javascript"),
|
||||
(
|
||||
format!("{prefix}package.json"),
|
||||
"game-package",
|
||||
"application/json",
|
||||
),
|
||||
(
|
||||
format!("{prefix}package-lock.json"),
|
||||
"game-lockfile",
|
||||
"application/json",
|
||||
),
|
||||
(
|
||||
format!("{prefix}vite.config.js"),
|
||||
"game-build-config",
|
||||
"text/javascript",
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
@@ -2147,7 +2132,7 @@ fn direct_registered_taonier_slice_paths(root: &Path) -> Vec<String> {
|
||||
let Ok(validated_slices) = validated_art_slices(root) else {
|
||||
return Vec::new();
|
||||
};
|
||||
if validated_slices.is_empty() {
|
||||
if validated_slices.len() != 4 {
|
||||
return Vec::new();
|
||||
}
|
||||
let mut resource_ids = std::collections::HashSet::with_capacity(validated_slices.len());
|
||||
@@ -2718,7 +2703,6 @@ async fn generate_direct_taonier_art_asset_at(
|
||||
asset_kind: asset_kind.to_string(),
|
||||
asset_label: asset_label.to_string(),
|
||||
replace_existing: root.join(output_path).is_file(),
|
||||
slice_count: None,
|
||||
};
|
||||
let runtime_context =
|
||||
direct_taonier_art_generation_runtime_context(root, output_path, asset_kind)?;
|
||||
@@ -2822,9 +2806,9 @@ fn direct_taonier_art_package_result(
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
if includes_spritesheet && slice_paths.is_empty() {
|
||||
if includes_spritesheet && slice_paths.len() != 4 {
|
||||
slice_warnings.push(
|
||||
"当前图集没有可验证的独立切片;只能使用完整图集,不得猜测切片或伪造衍生素材"
|
||||
"当前核心图集没有可验证的独立切片;只能使用完整图集,不得猜测切片或伪造衍生素材"
|
||||
.to_string(),
|
||||
);
|
||||
}
|
||||
@@ -3200,14 +3184,7 @@ fn direct_codex_generated_source() -> GameCreationAppAssetSource {
|
||||
|
||||
fn direct_codex_output_fingerprint(root: &Path) -> String {
|
||||
let mut hasher = Sha256::new();
|
||||
let mut paths: Vec<String> = direct_codex_game_outputs(root)
|
||||
.into_iter()
|
||||
.map(|(p, _, _)| p)
|
||||
.collect();
|
||||
paths.extend(direct_npm_source_paths(root));
|
||||
paths.sort();
|
||||
paths.dedup();
|
||||
for local_path in paths {
|
||||
for (local_path, _, _) in direct_codex_game_outputs(root) {
|
||||
hasher.update(local_path.as_bytes());
|
||||
hasher.update([0]);
|
||||
let path = root.join(local_path);
|
||||
@@ -3223,70 +3200,6 @@ fn direct_codex_output_fingerprint(root: &Path) -> String {
|
||||
format!("{:x}", hasher.finalize())
|
||||
}
|
||||
|
||||
fn direct_npm_source_paths(root: &Path) -> Vec<String> {
|
||||
let base = if root.join("package.json").is_file() {
|
||||
root.to_path_buf()
|
||||
} else if root.join("game/package.json").is_file() {
|
||||
root.join("game")
|
||||
} else {
|
||||
return Vec::new();
|
||||
};
|
||||
let mut output = Vec::new();
|
||||
let mut pending = vec![(base, 0usize)];
|
||||
let mut inspected = 0usize;
|
||||
while let Some((directory, depth)) = pending.pop() {
|
||||
if depth > 16 || inspected >= 4096 {
|
||||
break;
|
||||
}
|
||||
let Ok(entries) = fs::read_dir(directory) else {
|
||||
continue;
|
||||
};
|
||||
for entry in entries.flatten() {
|
||||
inspected += 1;
|
||||
if inspected > 4096 {
|
||||
break;
|
||||
}
|
||||
let name = entry.file_name().to_string_lossy().to_ascii_lowercase();
|
||||
if name.starts_with('.')
|
||||
|| matches!(
|
||||
name.as_str(),
|
||||
"node_modules"
|
||||
| "dist"
|
||||
| "target"
|
||||
| "memory"
|
||||
| "exports"
|
||||
| "auth.json"
|
||||
| "credentials.json"
|
||||
| "game-creator.config.json"
|
||||
| "game-creator.config.local.json"
|
||||
)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
let Ok(kind) = entry.file_type() else {
|
||||
continue;
|
||||
};
|
||||
let path = entry.path();
|
||||
if kind.is_dir() {
|
||||
pending.push((path, depth + 1));
|
||||
} else if kind.is_file()
|
||||
&& matches!(
|
||||
path.extension().and_then(|e| e.to_str()),
|
||||
Some("js" | "mjs" | "cjs" | "ts" | "tsx" | "jsx" | "css" | "html" | "json")
|
||||
)
|
||||
{
|
||||
if let Ok(relative) = path.strip_prefix(root) {
|
||||
if let Some(value) = relative.to_str() {
|
||||
output.push(value.replace('\\', "/"));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
output.sort();
|
||||
output
|
||||
}
|
||||
|
||||
fn direct_browser_evidence_root(root: &Path, attempt: usize) -> Result<std::path::PathBuf, String> {
|
||||
let revision = read_game_creator_agent_runtime_project_revision(root)
|
||||
.map(|value| value.revision)
|
||||
@@ -3718,34 +3631,6 @@ fn sync_direct_codex_project_file_projection_at(
|
||||
)?;
|
||||
registered += 1;
|
||||
}
|
||||
for local_path in direct_npm_source_paths(root) {
|
||||
if direct_codex_game_outputs(root)
|
||||
.iter()
|
||||
.any(|(p, _, _)| p == &local_path)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
if root.join(&local_path).is_file() {
|
||||
let media_type = if local_path.ends_with(".css") {
|
||||
"text/css"
|
||||
} else if local_path.ends_with(".json") {
|
||||
"application/json"
|
||||
} else if local_path.ends_with(".html") {
|
||||
"text/html"
|
||||
} else {
|
||||
"text/javascript"
|
||||
};
|
||||
register_local_asset_at(
|
||||
root,
|
||||
&local_path,
|
||||
"game-source",
|
||||
media_type,
|
||||
"direct-codex",
|
||||
direct_codex_generated_source(),
|
||||
)?;
|
||||
registered += 1;
|
||||
}
|
||||
}
|
||||
if registered == 0 {
|
||||
return Err("Codex 返回后没有可登记的游戏文件".to_string());
|
||||
}
|
||||
@@ -4407,6 +4292,48 @@ fn normalize_direct_client_turn_id(client_turn_id: Option<&str>) -> Result<Strin
|
||||
Ok(client_turn_id.to_string())
|
||||
}
|
||||
|
||||
fn persist_direct_codex_assistant_reply_at(
|
||||
root: &Path,
|
||||
client_turn_id: &str,
|
||||
reply: &str,
|
||||
) -> Result<(), String> {
|
||||
enforce_project_permission_policy(root, "conversation.write")?;
|
||||
let _lock = acquire_project_write_lock(root, "conversation.write")?;
|
||||
append_local_conversation_message_for_session_idempotent_at(
|
||||
root,
|
||||
None,
|
||||
None,
|
||||
LocalConversationMessage {
|
||||
role: "assistant".to_string(),
|
||||
content: reply.to_string(),
|
||||
agent_id: None,
|
||||
},
|
||||
&format!("direct-codex:{client_turn_id}:assistant"),
|
||||
)
|
||||
.map(|_| ())
|
||||
}
|
||||
|
||||
fn persist_direct_codex_user_prompt_at(
|
||||
root: &Path,
|
||||
client_turn_id: &str,
|
||||
prompt: &str,
|
||||
) -> Result<(), String> {
|
||||
enforce_project_permission_policy(root, "conversation.write")?;
|
||||
let _lock = acquire_project_write_lock(root, "conversation.write")?;
|
||||
append_local_conversation_message_for_session_idempotent_at(
|
||||
root,
|
||||
None,
|
||||
None,
|
||||
LocalConversationMessage {
|
||||
role: "user".to_string(),
|
||||
content: prompt.trim().to_string(),
|
||||
agent_id: None,
|
||||
},
|
||||
&format!("direct-codex:{client_turn_id}:user"),
|
||||
)
|
||||
.map(|_| ())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub(crate) async fn chat_with_game_creator_direct_codex(
|
||||
project_path: String,
|
||||
@@ -4438,6 +4365,15 @@ pub(crate) async fn chat_with_game_creator_direct_codex(
|
||||
return Err(error);
|
||||
}
|
||||
};
|
||||
if let Err(error) = persist_direct_codex_user_prompt_at(root, &turn_id, &user_prompt) {
|
||||
audit.finish(false);
|
||||
turn_emitter.emit("failed", Some("none"), None);
|
||||
return Err(redact_agent_runtime_error(
|
||||
root,
|
||||
&format!("Direct 用户消息持久化失败,已拒绝发起回合:{error}"),
|
||||
500,
|
||||
));
|
||||
}
|
||||
let reply = match run_direct_game_creator_turn_at_with_creation_type_and_emitter(
|
||||
root,
|
||||
&user_prompt,
|
||||
@@ -4453,6 +4389,15 @@ pub(crate) async fn chat_with_game_creator_direct_codex(
|
||||
return Err(error);
|
||||
}
|
||||
};
|
||||
if let Err(error) = persist_direct_codex_assistant_reply_at(root, &turn_id, &reply) {
|
||||
audit.finish(false);
|
||||
turn_emitter.emit("failed", Some("none"), None);
|
||||
return Err(redact_agent_runtime_error(
|
||||
root,
|
||||
&format!("Direct 成功回复持久化失败,已拒绝以未落盘状态返回:{error}"),
|
||||
500,
|
||||
));
|
||||
}
|
||||
audit.finish(true);
|
||||
turn_emitter.emit("completed", Some("none"), Some(reply.clone()));
|
||||
Ok(reply)
|
||||
@@ -4466,34 +4411,6 @@ pub(crate) async fn chat_with_game_creator_home_direct_codex(
|
||||
run_direct_game_creator_home_turn(&prompt, attachments.as_deref().unwrap_or_default()).await
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn persist_direct_codex_user_prompt_at(
|
||||
root: &Path,
|
||||
client_turn_id: &str,
|
||||
prompt: &str,
|
||||
) -> Result<(), String> {
|
||||
let item = direct_project_local_message_item(
|
||||
"user",
|
||||
prompt,
|
||||
Some(&format!("direct-codex:{client_turn_id}:user")),
|
||||
)?;
|
||||
append_direct_project_user_message_at(root, &item)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn persist_direct_codex_assistant_reply_at(
|
||||
root: &Path,
|
||||
client_turn_id: &str,
|
||||
reply: &str,
|
||||
) -> Result<(), String> {
|
||||
let item = direct_project_local_message_item(
|
||||
"assistant",
|
||||
reply,
|
||||
Some(&format!("direct-codex:{client_turn_id}:assistant")),
|
||||
)?;
|
||||
append_direct_project_history_item_at(root, &item)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -4578,8 +4495,8 @@ mod tests {
|
||||
persist_direct_codex_assistant_reply_at(root.path(), turn_id, reply)
|
||||
.expect("the App's same-id write converges idempotently");
|
||||
|
||||
let conversation =
|
||||
read_direct_project_chat_history_at(root.path()).expect("read project conversation");
|
||||
let conversation = read_local_conversation_for_session_at(root.path(), None, None)
|
||||
.expect("read project conversation");
|
||||
let message_id = format!("direct-codex:{turn_id}:assistant");
|
||||
let persisted = conversation
|
||||
.messages
|
||||
@@ -4592,7 +4509,7 @@ mod tests {
|
||||
assert!(
|
||||
persist_direct_codex_assistant_reply_at(root.path(), turn_id, "不同回复")
|
||||
.expect_err("same message identity cannot be rebound")
|
||||
.contains("item id 冲突")
|
||||
.contains("messageId 冲突")
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4609,8 +4526,8 @@ mod tests {
|
||||
persist_direct_codex_user_prompt_at(root.path(), turn_id, normalized_prompt)
|
||||
.expect("retry reuses the same user message identity");
|
||||
|
||||
let conversation =
|
||||
read_direct_project_chat_history_at(root.path()).expect("read project conversation");
|
||||
let conversation = read_local_conversation_for_session_at(root.path(), None, None)
|
||||
.expect("read project conversation");
|
||||
let message_id = format!("direct-codex:{turn_id}:user");
|
||||
let persisted = conversation
|
||||
.messages
|
||||
@@ -4623,7 +4540,7 @@ mod tests {
|
||||
assert!(
|
||||
persist_direct_codex_user_prompt_at(root.path(), turn_id, "不同的重试请求")
|
||||
.expect_err("same turn identity cannot be rebound")
|
||||
.contains("item id 冲突")
|
||||
.contains("messageId 冲突")
|
||||
);
|
||||
}
|
||||
|
||||
@@ -7426,7 +7343,7 @@ mod tests {
|
||||
assert_eq!(manifest.versions.len(), 1);
|
||||
assert_eq!(manifest.versions[0].version_id, "initial-1");
|
||||
assert_eq!(manifest.versions[0].project_revision, 1);
|
||||
assert_eq!(manifest.versions[0].resource_bindings.len(), 13);
|
||||
assert_eq!(manifest.versions[0].resource_bindings.len(), 10);
|
||||
for expected_path in DIRECT_CODEX_ART_ASSET_PATHS.into_iter().chain(
|
||||
DIRECT_CODEX_SPRITESHEET_SLICE_PATHS
|
||||
.iter()
|
||||
@@ -7463,7 +7380,7 @@ mod tests {
|
||||
.expect("project revision");
|
||||
assert_eq!(revision.revision, 2);
|
||||
let manifest = read_manifest(&root.path().join(".agent/manifest.json")).expect("manifest");
|
||||
assert_eq!(manifest.assets.len(), 13);
|
||||
assert_eq!(manifest.assets.len(), 10);
|
||||
assert_eq!(
|
||||
manifest
|
||||
.assets
|
||||
@@ -7483,7 +7400,7 @@ mod tests {
|
||||
manifest.versions[1].created_reason,
|
||||
GameIterationVersionCreatedReason::AgentRevision
|
||||
);
|
||||
assert_eq!(manifest.versions[1].resource_bindings.len(), 13);
|
||||
assert_eq!(manifest.versions[1].resource_bindings.len(), 10);
|
||||
|
||||
let unchanged_fingerprint = direct_codex_output_fingerprint(root.path());
|
||||
sync_direct_codex_project_outputs_at(root.path(), Some(&unchanged_fingerprint))
|
||||
@@ -8367,36 +8284,3 @@ mod tests {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod npm_source_projection_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn npm_sources_track_nested_modules_but_ignore_dependencies_and_private_files() {
|
||||
for nested in [false, true] {
|
||||
let root = tempfile::tempdir_in(std::env::temp_dir().canonicalize().unwrap()).unwrap();
|
||||
let source = if nested {
|
||||
root.path().join("game")
|
||||
} else {
|
||||
root.path().to_path_buf()
|
||||
};
|
||||
fs::create_dir_all(source.join("src/scenes")).unwrap();
|
||||
fs::create_dir_all(source.join("node_modules/demo")).unwrap();
|
||||
fs::create_dir_all(source.join(".npm-cache")).unwrap();
|
||||
fs::write(source.join("package.json"), "{}").unwrap();
|
||||
fs::write(source.join("src/scenes/play.ts"), "export const value = 1;").unwrap();
|
||||
fs::write(source.join("node_modules/demo/index.js"), "private dep").unwrap();
|
||||
fs::write(source.join(".npm-cache/auth.json"), "private cache").unwrap();
|
||||
let paths = direct_npm_source_paths(root.path());
|
||||
let prefix = if nested { "game/" } else { "" };
|
||||
assert!(paths.contains(&format!("{prefix}src/scenes/play.ts")));
|
||||
assert!(!paths
|
||||
.iter()
|
||||
.any(|path| path.contains("node_modules") || path.contains(".npm-cache")));
|
||||
let before = direct_codex_output_fingerprint(root.path());
|
||||
fs::write(source.join("src/scenes/play.ts"), "export const value = 2;").unwrap();
|
||||
assert_ne!(direct_codex_output_fingerprint(root.path()), before);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -63,6 +63,7 @@ struct DirectToolBridgeTurnAuthorization {
|
||||
|
||||
struct DirectToolBridgeActiveTurnAuthorization {
|
||||
turn_id: String,
|
||||
allows_regeneration: bool,
|
||||
brief_sha256: Option<String>,
|
||||
completed_result: Option<Value>,
|
||||
resource_request_ids: BTreeMap<String, (String, String)>,
|
||||
@@ -180,23 +181,30 @@ impl DirectToolBridge {
|
||||
&self.url
|
||||
}
|
||||
|
||||
/// Arm exactly one client-owned Direct turn. Codex chooses the business
|
||||
/// operation through the reviewed MCP tool and arguments; the bridge only
|
||||
/// binds that call to the active client turn.
|
||||
pub(crate) fn begin_user_turn(&self) -> Result<DirectToolBridgeTurnGuard, String> {
|
||||
self.state.begin_user_turn()
|
||||
/// Arm exactly one client-owned Direct turn. The raw user message is used
|
||||
/// only for this synchronous decision and is never retained by the bridge.
|
||||
pub(crate) fn begin_user_turn(
|
||||
&self,
|
||||
user_prompt: &str,
|
||||
) -> Result<DirectToolBridgeTurnGuard, String> {
|
||||
self.state.begin_user_turn(user_prompt)
|
||||
}
|
||||
}
|
||||
|
||||
impl DirectToolBridgeState {
|
||||
fn begin_user_turn(self: &Arc<Self>) -> Result<DirectToolBridgeTurnGuard, String> {
|
||||
fn begin_user_turn(
|
||||
self: &Arc<Self>,
|
||||
user_prompt: &str,
|
||||
) -> Result<DirectToolBridgeTurnGuard, String> {
|
||||
let turn_id = direct_taonier_active_invocation_id_at(&self.root)?;
|
||||
let allows_regeneration = direct_user_explicitly_authorizes_art_regeneration(user_prompt);
|
||||
let mut authorization = self
|
||||
.turn_authorization
|
||||
.lock()
|
||||
.map_err(|_| "AGC 工具桥回合授权状态不可用".to_string())?;
|
||||
authorization.active = Some(DirectToolBridgeActiveTurnAuthorization {
|
||||
turn_id: turn_id.clone(),
|
||||
allows_regeneration,
|
||||
brief_sha256: None,
|
||||
completed_result: None,
|
||||
resource_request_ids: BTreeMap::new(),
|
||||
@@ -586,9 +594,12 @@ impl DirectToolBridgeState {
|
||||
.active
|
||||
.as_mut()
|
||||
.ok_or_else(|| "当前没有客户端签发的美术重生成回合授权".to_string())?;
|
||||
if !active.allows_regeneration {
|
||||
return Err("当前用户消息未显式授权重新生成或替换美术".to_string());
|
||||
}
|
||||
match active.brief_sha256.as_deref() {
|
||||
Some(expected) if expected != brief_sha256 => {
|
||||
return Err("当前客户端回合已绑定另一项稳定美术重生成请求".to_string())
|
||||
return Err("当前用户授权已绑定另一项稳定美术重生成请求".to_string())
|
||||
}
|
||||
None => active.brief_sha256 = Some(brief_sha256.clone()),
|
||||
Some(_) => {}
|
||||
@@ -2105,7 +2116,6 @@ async fn bridge_generate_image(state: &DirectToolBridgeState, arguments: &Value)
|
||||
asset_kind: kind.clone(),
|
||||
asset_label: asset_name.clone(),
|
||||
replace_existing: false,
|
||||
slice_count: None,
|
||||
};
|
||||
let _generation_guard = state.image_generation_gate.lock().await;
|
||||
let generated = with_direct_editor_api_credentials(
|
||||
@@ -2726,20 +2736,106 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn regenerate_uses_current_client_turn_and_one_stable_brief() {
|
||||
fn regenerate_requires_current_explicit_user_authorization_and_one_stable_brief() {
|
||||
for prompt in [
|
||||
"继续修复布局",
|
||||
"解释一下重新生成美术是什么意思",
|
||||
"不要重新生成美术,只调整代码",
|
||||
"别换一套美术,继续用现在这套",
|
||||
"解释一下换一套美术按钮",
|
||||
"是否要改变视觉风格?",
|
||||
"Do not regenerate the art; keep the current package.",
|
||||
"I don't want to change the visual style.",
|
||||
"What does use a new art set mean?",
|
||||
"文案写着“换一套美术”",
|
||||
"Yesterday I said regenerate art, but today keep it.",
|
||||
"Please explain how to regenerate art.",
|
||||
"重新生成美术以后再说,现在只修代码",
|
||||
"重做美术先不做,先改玩法",
|
||||
"Regenerate the art maybe later; for now just fix the code.",
|
||||
"把按钮文案改成“请重新生成美术”,不要执行生成工具",
|
||||
"把按钮文案改成‘请重新生成美术’,不要执行生成工具",
|
||||
"Change the button label to 'please regenerate the art'; do not execute it.",
|
||||
"用户之前说请重新生成美术,我只是在复述",
|
||||
"Yesterday the user said please regenerate the art; I am just quoting it.",
|
||||
"以后请重新生成美术,现在先改代码",
|
||||
"你能不能帮我重新生成美术,顺便解释一下价格",
|
||||
"请重新生成美术吗",
|
||||
"请重新生成美术吗,还是只改代码",
|
||||
"请重新生成美术或者只改代码",
|
||||
"请重新生成美术以外的内容",
|
||||
"请重新生成美术,但不要执行生成工具",
|
||||
"不需要重新生成美术",
|
||||
"界面上显示:请重新生成美术",
|
||||
"界面标题是请重新生成美术",
|
||||
"产品经理让我写请重新生成美术",
|
||||
"下周请重新生成美术",
|
||||
"他说«请重新生成美术»",
|
||||
"Could you please regenerate the art",
|
||||
"Please regenerate the art? Or only fix code.",
|
||||
"Please regenerate the art except for the paid generation.",
|
||||
"Please regenerate the art, but do not execute the tool.",
|
||||
"Please regenerate the art, but don’t execute the tool.",
|
||||
"Please regenerate the art, but I don't authorize this paid generation.",
|
||||
"Please regenerate the art, but I don‘t authorize this paid generation.",
|
||||
"Please regenerate the art, but do not execute the paid tool.",
|
||||
"Please regenerate the art, but never execute the paid tool.",
|
||||
"Please regenerate the art, but avoid executing the paid tool.",
|
||||
"Please regenerate the art, but 'do not execute the tool",
|
||||
"Please regenerate the art only if it is free.",
|
||||
"Please regenerate the art only after I confirm the charge.",
|
||||
"Please regenerate the art, but do “not” execute the paid tool.",
|
||||
"请重新生成美术,三天后再执行。",
|
||||
"请重新生成美术,得到我的许可再做。",
|
||||
"请重新生成美术,地面需要无缝循环。",
|
||||
"Please regenerate the art, but skip the paid generation.",
|
||||
"请重新生成美术【生成操作跳过】",
|
||||
"请重新生成美术【仅在零元时执行】",
|
||||
"Please regenerate the art “but skip the paid generation”",
|
||||
"Please regenerate the art; alternatively, just fix the code.",
|
||||
"Please regenerate the art, but do n\u{200B}ot execute the paid tool.",
|
||||
"Please regenerate the art with a clay style.",
|
||||
"I don't need you to regenerate the art",
|
||||
"The UI shows: please regenerate the art",
|
||||
"Please regenerate the art next week",
|
||||
"He said «please regenerate the art»",
|
||||
] {
|
||||
assert!(
|
||||
!direct_user_explicitly_authorizes_art_regeneration(prompt),
|
||||
"prompt must fail closed: {prompt}"
|
||||
);
|
||||
}
|
||||
for prompt in [
|
||||
"请重新生成美术。",
|
||||
"那就请重新生成美术!",
|
||||
"换一套美术",
|
||||
"Please regenerate the art!",
|
||||
] {
|
||||
assert!(
|
||||
direct_user_explicitly_authorizes_art_regeneration(prompt),
|
||||
"prompt must explicitly authorize: {prompt}"
|
||||
);
|
||||
}
|
||||
|
||||
let root = tempfile::tempdir().expect("stable client turn root");
|
||||
let state = direct_tool_bridge_state(root.path().to_path_buf());
|
||||
assert!(state.begin_user_turn().is_err());
|
||||
assert!(state.begin_user_turn("请重新生成美术").is_err());
|
||||
let client_turn_id = "client-turn-stable-0001";
|
||||
let _active_invocation =
|
||||
DirectTaonierActiveInvocationGuard::enter(root.path(), client_turn_id)
|
||||
.expect("client-owned stable invocation");
|
||||
let active_turn = state
|
||||
.begin_user_turn()
|
||||
.expect("client turn authorization state");
|
||||
let ordinary_turn = state
|
||||
.begin_user_turn("继续优化交互")
|
||||
.expect("ordinary turn authorization state");
|
||||
assert!(state.authorize_regeneration_call("陶泥风格").is_err());
|
||||
drop(ordinary_turn);
|
||||
|
||||
let authorized_turn = state
|
||||
.begin_user_turn("请重新生成美术")
|
||||
.expect("authorized regeneration turn");
|
||||
let (turn_id, brief_sha256) = match state
|
||||
.authorize_regeneration_call("陶泥风格")
|
||||
.expect("MCP mode selects regeneration explicitly")
|
||||
.expect("first stable regeneration call")
|
||||
{
|
||||
DirectToolBridgeRegenerationCall::Execute {
|
||||
turn_id,
|
||||
@@ -2768,7 +2864,7 @@ mod tests {
|
||||
panic!("completed stable retry must not execute a second paid call")
|
||||
}
|
||||
}
|
||||
drop(active_turn);
|
||||
drop(authorized_turn);
|
||||
assert!(state.authorize_regeneration_call("陶泥风格").is_err());
|
||||
}
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -78,7 +78,7 @@ pub(crate) use draft_validation::{
|
||||
validate_llm_agent_handoffs, validate_llm_game_draft, validate_non_placeholder_game_html,
|
||||
validate_playable_game_html, validate_safe_game_html_runtime,
|
||||
};
|
||||
pub(crate) use draft_writer::{ensure_legacy_json_generator_project, write_local_game_draft_at};
|
||||
pub(crate) use draft_writer::write_local_game_draft_at;
|
||||
#[allow(unused_imports)]
|
||||
pub(crate) use loop_orchestration::{
|
||||
emit_agent_progress, game_creator_agent_llm_error_is_mud_points_insufficient,
|
||||
|
||||
@@ -412,7 +412,6 @@ pub(crate) struct PlatformArtAssetGenerationOptions {
|
||||
pub(crate) asset_kind: String,
|
||||
pub(crate) asset_label: String,
|
||||
pub(crate) replace_existing: bool,
|
||||
pub(crate) slice_count: Option<usize>,
|
||||
}
|
||||
|
||||
impl Default for PlatformArtAssetGenerationOptions {
|
||||
@@ -424,7 +423,6 @@ impl Default for PlatformArtAssetGenerationOptions {
|
||||
asset_kind: "game-art".to_string(),
|
||||
asset_label: "AI 游戏首版美术素材".to_string(),
|
||||
replace_existing: false,
|
||||
slice_count: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -683,47 +681,6 @@ pub(in crate::agent) fn platform_art_generation_error_result_unknown(error: &str
|
||||
error.starts_with(EXTERNAL_GENERATION_RESULT_UNKNOWN_PREFIX)
|
||||
}
|
||||
|
||||
/// Observe an accepted operation once without waiting. A single GET that
|
||||
/// reports `failed` is authoritative and allows a changed retry to release
|
||||
/// the old local slot; queued/running/unknown outcomes remain protected.
|
||||
async fn accepted_generation_is_authoritatively_failed_once(
|
||||
client: &reqwest::Client,
|
||||
access: &ExternalEditorBindingAccess<'_>,
|
||||
submission_payload: &serde_json::Value,
|
||||
) -> Result<bool, String> {
|
||||
let submission = external_editor_response_data(submission_payload);
|
||||
let operation_id = json_string_field(submission, "operationId")
|
||||
.ok_or_else(|| "External Editor accepted 账本缺少 operationId".to_string())?;
|
||||
access.validate_frozen_session()?;
|
||||
let payload = tokio::time::timeout(
|
||||
Duration::from_secs(3),
|
||||
external_editor_json_request(
|
||||
client
|
||||
.get(format!(
|
||||
"{}{}",
|
||||
access.api_base_url(),
|
||||
access.generation_status_route(&operation_id)
|
||||
))
|
||||
.bearer_auth(access.bearer_token()),
|
||||
"查询平台图片生成任务",
|
||||
),
|
||||
)
|
||||
.await
|
||||
.map_err(|_| "查询平台图片生成任务超时".to_string())??;
|
||||
access.validate_frozen_session()?;
|
||||
let generation = platform_generation_status_data(&payload);
|
||||
match json_string_field(generation, "status").as_deref() {
|
||||
Some("failed") => Ok(true),
|
||||
Some("queued" | "running" | "completed") => Ok(false),
|
||||
Some(status) => Err(format!(
|
||||
"{EXTERNAL_GENERATION_RESULT_UNKNOWN_PREFIX} 平台图片生成任务返回未知状态 {status};operationId={operation_id}"
|
||||
)),
|
||||
None => Err(format!(
|
||||
"{EXTERNAL_GENERATION_RESULT_UNKNOWN_PREFIX} 平台图片生成任务状态响应缺少 status;operationId={operation_id}"
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) async fn external_editor_json_request(
|
||||
request: reqwest::RequestBuilder,
|
||||
action: &str,
|
||||
@@ -1757,16 +1714,24 @@ fn canonical_art_spritesheet_icon_descriptions(prompt: &str) -> Vec<String> {
|
||||
// long creation request cannot reject the atlas before it is queued.
|
||||
const MAX_DESCRIPTION_CHARS: usize = 200;
|
||||
const CONTEXT_PREFIX: &str = ";遵循同一项目视觉规范:";
|
||||
let category =
|
||||
"按当前项目需求生成一组可独立使用的透明素材;数量、类别、排列和切片方式由本次需求决定";
|
||||
let context_budget = MAX_DESCRIPTION_CHARS.saturating_sub(
|
||||
category
|
||||
.chars()
|
||||
.count()
|
||||
.saturating_add(CONTEXT_PREFIX.chars().count()),
|
||||
);
|
||||
let project_context = truncate_inline_bounded(prompt.trim(), context_budget);
|
||||
vec![format!("{category}{CONTEXT_PREFIX}{project_context}")]
|
||||
[
|
||||
"第 1 类(左上):当前玩法的玩家主体或主要操作对象;只生成一个轮廓连贯、可独立使用的完整素材",
|
||||
"第 2 类(右上):当前玩法的方块、目标物、收集物、敌对实体或危险物;只生成一个完整素材",
|
||||
"第 3 类(左下):当前玩法需要的地块、障碍、资源物件或场景装饰;只生成一个完整素材",
|
||||
"第 4 类(右下):得分、受击、成长、失败、胜利或操作反馈特效;只生成一个完整素材",
|
||||
]
|
||||
.into_iter()
|
||||
.map(|category| {
|
||||
let context_budget = MAX_DESCRIPTION_CHARS.saturating_sub(
|
||||
category
|
||||
.chars()
|
||||
.count()
|
||||
.saturating_add(CONTEXT_PREFIX.chars().count()),
|
||||
);
|
||||
let project_context = truncate_inline_bounded(prompt.trim(), context_budget);
|
||||
format!("{category}{CONTEXT_PREFIX}{project_context}")
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn truncate_inline_bounded(value: &str, max_chars: usize) -> String {
|
||||
@@ -2078,13 +2043,13 @@ pub(crate) async fn generate_platform_art_asset_with_required_slices_at(
|
||||
}
|
||||
let generation_prompt = build_platform_art_asset_prompt(prompt, briefs, options);
|
||||
let runtime_context =
|
||||
standalone_platform_art_generation_runtime_context(&generation_prompt, options, false)?;
|
||||
standalone_platform_art_generation_runtime_context(&generation_prompt, options, true)?;
|
||||
generate_platform_art_asset_with_runtime_options_at(
|
||||
root,
|
||||
prompt,
|
||||
briefs,
|
||||
options,
|
||||
false,
|
||||
true,
|
||||
&runtime_context,
|
||||
)
|
||||
.await
|
||||
@@ -2462,34 +2427,6 @@ pub(in crate::agent) async fn request_platform_art_asset_with_runtime_options_at
|
||||
)
|
||||
})?;
|
||||
if snapshot.generation_prompt != generation_prompt {
|
||||
if platform_art_generation_runtime_status(&state) == "accepted" {
|
||||
if let Ok(submission) = platform_art_generation_runtime_submission_payload(&state) {
|
||||
if accepted_generation_is_authoritatively_failed_once(
|
||||
&client,
|
||||
&binding_access,
|
||||
&submission,
|
||||
)
|
||||
.await
|
||||
.unwrap_or(false)
|
||||
{
|
||||
if let Some(context) = runtime_context {
|
||||
remove_platform_art_generation_runtime_state_at(
|
||||
root,
|
||||
&context.agent_id,
|
||||
&context.run_id,
|
||||
)?;
|
||||
}
|
||||
return Box::pin(request_platform_art_asset_with_runtime_options_at(
|
||||
root,
|
||||
prompt,
|
||||
briefs,
|
||||
options,
|
||||
runtime_context,
|
||||
))
|
||||
.await;
|
||||
}
|
||||
}
|
||||
}
|
||||
return Err(format!(
|
||||
"{EXTERNAL_GENERATION_RESULT_UNKNOWN_PREFIX} 当前生成意图与已持久化请求快照不一致,已拒绝将旧操作当作本次请求恢复;原生成账本已保留,需要先完成或对账旧操作"
|
||||
));
|
||||
@@ -2511,36 +2448,6 @@ pub(in crate::agent) async fn request_platform_art_asset_with_runtime_options_at
|
||||
)
|
||||
})?;
|
||||
if snapshot.reference_resource_ids != [current_reference] {
|
||||
if platform_art_generation_runtime_status(&state) == "accepted" {
|
||||
if let Ok(submission) =
|
||||
platform_art_generation_runtime_submission_payload(&state)
|
||||
{
|
||||
if accepted_generation_is_authoritatively_failed_once(
|
||||
&client,
|
||||
&binding_access,
|
||||
&submission,
|
||||
)
|
||||
.await
|
||||
.unwrap_or(false)
|
||||
{
|
||||
if let Some(context) = runtime_context {
|
||||
remove_platform_art_generation_runtime_state_at(
|
||||
root,
|
||||
&context.agent_id,
|
||||
&context.run_id,
|
||||
)?;
|
||||
}
|
||||
return Box::pin(request_platform_art_asset_with_runtime_options_at(
|
||||
root,
|
||||
prompt,
|
||||
briefs,
|
||||
options,
|
||||
runtime_context,
|
||||
))
|
||||
.await;
|
||||
}
|
||||
}
|
||||
}
|
||||
return Err(format!(
|
||||
"{EXTERNAL_GENERATION_RESULT_UNKNOWN_PREFIX} 当前规范图身份与已持久化派生请求不一致,已拒绝恢复旧操作;原生成账本已保留,需要先完成或对账旧操作"
|
||||
));
|
||||
@@ -2649,7 +2556,7 @@ pub(in crate::agent) async fn request_platform_art_asset_with_runtime_options_at
|
||||
serde_json::json!({
|
||||
"referenceId": reference_id,
|
||||
"iconDescriptions": canonical_art_spritesheet_icon_descriptions(&generation_prompt),
|
||||
"sliceCount": options.slice_count,
|
||||
"sliceLayout": "grid-2x2",
|
||||
"screenColor": "auto",
|
||||
"aspectRatio": options.aspect_ratio,
|
||||
"imageSize": options.image_size,
|
||||
@@ -6352,8 +6259,11 @@ fn validate_strict_platform_art_spritesheet_contract(
|
||||
has_transparent_pixels: bool,
|
||||
has_visible_pixels: bool,
|
||||
) -> Result<(), String> {
|
||||
if slices.is_empty() {
|
||||
return Err("spritesheet 图集至少需要一个独立切片".to_string());
|
||||
if slices.len() != 4 {
|
||||
return Err(format!(
|
||||
"strict spritesheet 图集必须恰好包含 4 个独立切片,实际为 {} 个",
|
||||
slices.len()
|
||||
));
|
||||
}
|
||||
let resource_id = resource_id
|
||||
.map(str::trim)
|
||||
@@ -6384,7 +6294,12 @@ fn validate_strict_platform_art_spritesheet_contract(
|
||||
{
|
||||
return Err("strict spritesheet 图集生成 route/kind 与严格图集合同不一致".to_string());
|
||||
}
|
||||
let _requested_slice_layout = spritesheet_slice_layout;
|
||||
if spritesheet_slice_layout.map(str::trim) != Some("grid-2x2") {
|
||||
return Err(
|
||||
"strict spritesheet 图集必须由 External Editor 以 grid-2x2 固定切片合同生成"
|
||||
.to_string(),
|
||||
);
|
||||
}
|
||||
if reference_resource_ids.len() != 1
|
||||
|| reference_resource_ids[0].trim().is_empty()
|
||||
|| reference_resource_ids[0].trim() == resource_id
|
||||
@@ -6719,7 +6634,7 @@ fn existing_platform_art_slice_registrations_are_complete(
|
||||
manifest: &GameCreationAppManifest,
|
||||
registrations: &[PlatformArtSliceManifestRegistration],
|
||||
) -> Result<bool, String> {
|
||||
if registrations.is_empty() {
|
||||
if registrations.len() != 4 {
|
||||
return Ok(false);
|
||||
}
|
||||
let mut resource_ids = std::collections::HashSet::with_capacity(registrations.len());
|
||||
@@ -7679,7 +7594,6 @@ mod canvas_generation_tests {
|
||||
asset_kind: "game-background".to_string(),
|
||||
asset_label: "手工背景".to_string(),
|
||||
replace_existing: true,
|
||||
slice_count: None,
|
||||
};
|
||||
let ordinary =
|
||||
standalone_platform_art_generation_runtime_context("完整生成提示词", &options, false)
|
||||
@@ -9488,7 +9402,6 @@ mod canvas_generation_tests {
|
||||
asset_kind: "icon-spec".to_string(),
|
||||
asset_label: "整包规范图".to_string(),
|
||||
replace_existing: false,
|
||||
slice_count: None,
|
||||
};
|
||||
let prompt = "生成同一套整包美术";
|
||||
let generation_prompt = build_platform_art_asset_prompt(prompt, &[], &options);
|
||||
@@ -10326,7 +10239,6 @@ mod canvas_generation_tests {
|
||||
asset_kind: "game-background".to_string(),
|
||||
asset_label: "整包背景图".to_string(),
|
||||
replace_existing: false,
|
||||
slice_count: None,
|
||||
};
|
||||
let prompt = "保持同一个生成提示词";
|
||||
let generation_prompt = build_platform_art_asset_prompt(prompt, &[], &options);
|
||||
@@ -10778,7 +10690,6 @@ mod canvas_generation_tests {
|
||||
asset_kind: "icon-spec".to_string(),
|
||||
asset_label: "游戏统一视觉规范图".to_string(),
|
||||
replace_existing: false,
|
||||
slice_count: None,
|
||||
};
|
||||
let prompt = "恢复已受理视觉规范图";
|
||||
let generation_prompt = build_platform_art_asset_prompt(prompt, &[], &options);
|
||||
@@ -11353,7 +11264,6 @@ mod canvas_generation_tests {
|
||||
asset_kind: "art-spritesheet".to_string(),
|
||||
asset_label: "游戏首版核心美术素材".to_string(),
|
||||
replace_existing: true,
|
||||
slice_count: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -9,8 +9,8 @@ pub(crate) fn write_local_game_draft_at(
|
||||
if prompt.is_empty() {
|
||||
return Err("创作想法不能为空".to_string());
|
||||
}
|
||||
ensure_legacy_json_generator_project(root)?;
|
||||
validate_llm_game_draft(prompt, draft)?;
|
||||
init_local_game_project_at(root, "local-project-draft", "未命名游戏原型")?;
|
||||
let checkpoint = create_local_project_checkpoint_at(root)?;
|
||||
let timestamp = unix_timestamp();
|
||||
let title = draft.title.trim();
|
||||
@@ -134,38 +134,3 @@ pub(crate) fn write_local_game_draft_at(
|
||||
manifest,
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn ensure_legacy_json_generator_project(root: &Path) -> Result<(), String> {
|
||||
if root.join("game/package.json").exists()
|
||||
|| root.join("package.json").exists()
|
||||
|| !root.join("game/index.html").is_file()
|
||||
|| !root.join(".agent/manifest.json").is_file()
|
||||
{
|
||||
return Err("JSON Generator 仅支持已有的单文件 HTML 项目;新建游戏与 npm / Phaser 4 项目请使用 DirectProject 完成依赖安装、构建和试玩".to_string());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod legacy_generator_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn legacy_generator_rejects_npm_before_writing() {
|
||||
let root = tempfile::tempdir_in(std::env::temp_dir().canonicalize().unwrap()).unwrap();
|
||||
fs::create_dir_all(root.path().join("game")).unwrap();
|
||||
fs::create_dir_all(root.path().join(".agent")).unwrap();
|
||||
fs::write(root.path().join("game/index.html"), "legacy source").unwrap();
|
||||
fs::write(root.path().join(".agent/manifest.json"), "{}").unwrap();
|
||||
assert!(ensure_legacy_json_generator_project(root.path()).is_ok());
|
||||
fs::write(root.path().join("game/package.json"), "{}").unwrap();
|
||||
assert!(ensure_legacy_json_generator_project(root.path())
|
||||
.unwrap_err()
|
||||
.contains("DirectProject"));
|
||||
assert_eq!(
|
||||
fs::read_to_string(root.path().join("game/index.html")).unwrap(),
|
||||
"legacy source"
|
||||
);
|
||||
assert!(!root.path().join(".agent/spec.md").exists());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -44,7 +44,6 @@ pub(crate) async fn run_game_creator_agent_loop_at(
|
||||
project_blackboard: &str,
|
||||
progress: Option<&AgentProgressEmitter<'_>>,
|
||||
) -> Result<GameCreatorAgentLoopResult, String> {
|
||||
ensure_legacy_json_generator_project(root)?;
|
||||
let spec_path = root.join(".agent/spec.md");
|
||||
let findings_path = root.join(".agent/findings.md");
|
||||
let run_id = format!("game-generate-draft-{}", unix_millis());
|
||||
|
||||
@@ -24,7 +24,6 @@ JSON schema:
|
||||
}
|
||||
|
||||
gameHtml 规则:
|
||||
- 此 JSON 协议仅用于已有的单文件 HTML 项目;npm / Phaser 项目必须使用 DirectProject,不能通过 gameHtml 交付 package.json 或模块源码。
|
||||
- 必须是单文件 HTML,不能加载远程脚本、远程图片、远程 CSS 或 CDN。
|
||||
- 必须包含 canvas、canvas getContext、实际绘制调用、键盘或鼠标输入、requestAnimationFrame 主循环、目标、失败或胜利状态、R 或按钮重开。
|
||||
- JavaScript 不要 eval、Function、localStorage、fetch、WebSocket、ServiceWorker。
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user