Merge remote-tracking branch 'origin/master' into feat/jenkins-mac-build
Project CI / AI game creator shell Rust shard 1/4 (pull_request) Failing after 17s
Project CI / AI game creator shell Rust shard 2/4 (pull_request) Failing after 17s
Project CI / AI game creator shell Rust shard 4/4 (pull_request) Failing after 17s
Project CI / AI game creator shell Rust shard 3/4 (pull_request) Failing after 17s
Project CI / Backend tests (pull_request) Failing after 19s
Project CI / AI game creator shell Rust smoke (pull_request) Failing after 20s
Project CI / Native shell tests (pull_request) Failing after 19s
Project CI / AI game creator shell Rust crates (pull_request) Failing after 19s
Project CI / Frontend tests (pull_request) Failing after 6s
Project CI / AI game creator shell web tests (pull_request) Failing after 12s
Project CI / Repository checks (pull_request) Failing after 12s
Project CI / AI game creator shell Rust shard 1/4 (pull_request) Failing after 17s
Project CI / AI game creator shell Rust shard 2/4 (pull_request) Failing after 17s
Project CI / AI game creator shell Rust shard 4/4 (pull_request) Failing after 17s
Project CI / AI game creator shell Rust shard 3/4 (pull_request) Failing after 17s
Project CI / Backend tests (pull_request) Failing after 19s
Project CI / AI game creator shell Rust smoke (pull_request) Failing after 20s
Project CI / Native shell tests (pull_request) Failing after 19s
Project CI / AI game creator shell Rust crates (pull_request) Failing after 19s
Project CI / Frontend tests (pull_request) Failing after 6s
Project CI / AI game creator shell web tests (pull_request) Failing after 12s
Project CI / Repository checks (pull_request) Failing after 12s
# Conflicts: # docs/project-memory/shared-memory/pitfalls.md
This commit is contained in:
@@ -302,6 +302,23 @@ jobs:
|
||||
sleep $((attempt * 2))
|
||||
done
|
||||
|
||||
- name: Prepare Godot plugin Rust dependencies
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
for attempt in $(seq 1 5); do
|
||||
if cargo fetch --locked \
|
||||
--target x86_64-unknown-linux-gnu \
|
||||
--manifest-path plugins/agc-godot-editor/native/godot-editor-bridge/Cargo.toml; then
|
||||
break
|
||||
fi
|
||||
if [[ "${attempt}" -eq 5 ]]; then
|
||||
echo 'Godot plugin Cargo dependency fetch failed after 5 attempts.' >&2
|
||||
exit 1
|
||||
fi
|
||||
sleep $((attempt * 2))
|
||||
done
|
||||
|
||||
- name: Run AI game creator shell shared crate gates
|
||||
run: npm run check:native-shells:agc-rust-crates
|
||||
|
||||
|
||||
@@ -0,0 +1,330 @@
|
||||
/**
|
||||
* AGC 总版本号(发号源)。
|
||||
*
|
||||
* 唯一事实源是 OSS 对象 `agc/global-version.json`;渠道清单只写各自本次拿到的号。
|
||||
* 仓库里的 5 个版本文件仍由构建改写,但只作构建输入参考,不作为事实源。
|
||||
*
|
||||
* 发号顺序固定为「先写总号 → 再构建 → 再发渠道清单」:任何一步失败都不回滚,
|
||||
* 只烧号。这样渠道之间不会复用同一个号,代价是可能出现空洞。
|
||||
*/
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
export const AGC_GLOBAL_VERSION_OBJECT_KEY = 'agc/global-version.json';
|
||||
const defaultOssBaseUrl =
|
||||
'https://agc-dev.oss-rg-china-mainland.aliyuncs.com/agc';
|
||||
const versionPattern = /^\d+\.\d+\.\d+$/u;
|
||||
|
||||
function trimTrailingSlashes(value) {
|
||||
return value.replace(/\/+$/u, '');
|
||||
}
|
||||
|
||||
export function ossBaseUrl(env = process.env) {
|
||||
return trimTrailingSlashes(
|
||||
env.AGC_UPDATE_OSS_BASE_URL?.trim() || defaultOssBaseUrl,
|
||||
);
|
||||
}
|
||||
|
||||
export function globalVersionUrl(env = process.env) {
|
||||
return `${ossBaseUrl(env)}/global-version.json`;
|
||||
}
|
||||
|
||||
export function parseVersion(value, label) {
|
||||
if (typeof value !== 'string' || !versionPattern.test(value.trim())) {
|
||||
throw new Error(`${label} 不是有效的三段版本号:${String(value)}`);
|
||||
}
|
||||
return value.trim();
|
||||
}
|
||||
|
||||
export function compareVersions(left, right) {
|
||||
const leftParts = parseVersion(left, '左版本').split('.').map(Number);
|
||||
const rightParts = parseVersion(right, '右版本').split('.').map(Number);
|
||||
for (let index = 0; index < 3; index += 1) {
|
||||
if (leftParts[index] !== rightParts[index]) {
|
||||
return leftParts[index] > rightParts[index] ? 1 : -1;
|
||||
}
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
/** 取较大版本;任一为空时返回另一个。 */
|
||||
export function maxVersion(...versions) {
|
||||
return versions
|
||||
.filter((value) => typeof value === 'string' && value.trim())
|
||||
.map((value) => parseVersion(value, '候选版本'))
|
||||
.reduce(
|
||||
(best, current) =>
|
||||
best == null || compareVersions(current, best) > 0 ? current : best,
|
||||
null,
|
||||
);
|
||||
}
|
||||
|
||||
export function nextVersion(current) {
|
||||
const [major, minor, patch] = parseVersion(current, '总版本')
|
||||
.split('.')
|
||||
.map(Number);
|
||||
if (patch === Number.MAX_SAFE_INTEGER) {
|
||||
throw new Error(`版本号 patch 已达到上限:${current}`);
|
||||
}
|
||||
return `${major}.${minor}.${patch + 1}`;
|
||||
}
|
||||
|
||||
export function readReleaseDryRun(env = process.env) {
|
||||
const value = env.AGC_RELEASE_DRY_RUN?.trim().toLowerCase();
|
||||
return value === '1' || value === 'true';
|
||||
}
|
||||
|
||||
async function fetchJson(url, label, { fetchImpl = fetch } = {}) {
|
||||
let response;
|
||||
try {
|
||||
response = await fetchImpl(url, {
|
||||
headers: { Accept: 'application/json' },
|
||||
});
|
||||
} catch (error) {
|
||||
throw new Error(`读取 ${label} 失败:${error.message}`);
|
||||
}
|
||||
if (response.status === 404) return null;
|
||||
if (!response.ok) {
|
||||
throw new Error(`读取 ${label} 失败:HTTP ${response.status}`);
|
||||
}
|
||||
try {
|
||||
return await response.json();
|
||||
} catch (error) {
|
||||
throw new Error(`${label} 不是有效 JSON:${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/** 渠道清单版本;缺失或 404 时返回 null(首次启用渠道)。 */
|
||||
export async function readChannelVersion(channel, options = {}) {
|
||||
const payload = await fetchJson(
|
||||
`${ossBaseUrl(options.env)}/${channel}/latest.json`,
|
||||
`${channel} 渠道清单`,
|
||||
options,
|
||||
);
|
||||
if (payload == null) return null;
|
||||
const version = typeof payload.version === 'string' ? payload.version : '';
|
||||
if (!version) {
|
||||
throw new Error(`${channel} 渠道清单缺少 version 字段`);
|
||||
}
|
||||
return parseVersion(version, `${channel} 渠道清单 version`);
|
||||
}
|
||||
|
||||
/** 旧协议迁移指针 `agc/latest.json`;只在迁移窗口内存在,仅参与播种。 */
|
||||
export async function readLegacyPointerVersion(options = {}) {
|
||||
const payload = await fetchJson(
|
||||
`${ossBaseUrl(options.env)}/latest.json`,
|
||||
'OSS 迁移指针',
|
||||
options,
|
||||
);
|
||||
if (payload == null) return null;
|
||||
const version = typeof payload.version === 'string' ? payload.version : '';
|
||||
return version ? parseVersion(version, 'OSS 迁移指针 version') : null;
|
||||
}
|
||||
|
||||
export async function readGlobalVersion(options = {}) {
|
||||
const payload = await fetchJson(
|
||||
globalVersionUrl(options.env),
|
||||
'AGC 总版本号',
|
||||
options,
|
||||
);
|
||||
if (payload == null) return null;
|
||||
const version = typeof payload.version === 'string' ? payload.version : '';
|
||||
if (!version) {
|
||||
throw new Error('AGC 总版本号对象缺少 version 字段');
|
||||
}
|
||||
return {
|
||||
...payload,
|
||||
version: parseVersion(version, 'AGC 总版本号 version'),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 一次性播种基线:仓库当前版本、渠道清单与旧迁移指针里的最大值。
|
||||
* 基线本身不发给客户端,首个发放号是 baseline + 1。
|
||||
*/
|
||||
export async function resolveSeedBaseline({
|
||||
channels = ['dev-win', 'dev-mac'],
|
||||
repoVersion = null,
|
||||
env = process.env,
|
||||
fetchImpl = fetch,
|
||||
} = {}) {
|
||||
const candidates = [];
|
||||
if (repoVersion) candidates.push(parseVersion(repoVersion, '仓库当前版本'));
|
||||
for (const channel of channels) {
|
||||
const version = await readChannelVersion(channel, { env, fetchImpl });
|
||||
if (version) candidates.push(version);
|
||||
}
|
||||
const legacy = await readLegacyPointerVersion({ env, fetchImpl });
|
||||
if (legacy) candidates.push(legacy);
|
||||
const baseline = maxVersion(...candidates);
|
||||
if (!baseline) {
|
||||
throw new Error('无法确定总版本号播种基线:仓库版本与渠道清单都不可用');
|
||||
}
|
||||
return baseline;
|
||||
}
|
||||
|
||||
/**
|
||||
* 组装 ossutil 参数。
|
||||
*
|
||||
* 该桶与凭据按 v1 签名使用(ossutil v2 默认 v4,缺 region 会直接失败),
|
||||
* 因此默认显式传 `--sign-version v1`;需要 v4 时用 `AGC_OSS_SIGN_VERSION=v4`
|
||||
* 并同时给 `AGC_OSS_REGION`。
|
||||
*/
|
||||
export function buildOssutilArgs({
|
||||
args,
|
||||
endpoint,
|
||||
accessKeyId,
|
||||
accessKeySecret,
|
||||
env = process.env,
|
||||
}) {
|
||||
const finalArgs = [...args, '--endpoint', endpoint];
|
||||
const region = env.AGC_OSS_REGION?.trim();
|
||||
if (region) finalArgs.push('--region', region);
|
||||
finalArgs.push('--sign-version', env.AGC_OSS_SIGN_VERSION?.trim() || 'v1');
|
||||
if (accessKeyId) {
|
||||
finalArgs.push(
|
||||
'--access-key-id',
|
||||
accessKeyId,
|
||||
'--access-key-secret',
|
||||
accessKeySecret,
|
||||
);
|
||||
}
|
||||
return finalArgs;
|
||||
}
|
||||
|
||||
function runOssutil(args, { env = process.env } = {}) {
|
||||
const binary = env.OSSUTIL_BIN?.trim() || 'ossutil';
|
||||
const endpoint =
|
||||
env.AGC_OSS_ENDPOINT?.trim() || 'oss-rg-china-mainland.aliyuncs.com';
|
||||
const accessKeyId = env.AGC_OSS_ACCESS_KEY_ID?.trim();
|
||||
const accessKeySecret = env.AGC_OSS_ACCESS_KEY_SECRET;
|
||||
if (Boolean(accessKeyId) !== Boolean(accessKeySecret)) {
|
||||
throw new Error('OSS AccessKey ID 和 Secret 必须同时提供');
|
||||
}
|
||||
const result = spawnSync(
|
||||
binary,
|
||||
buildOssutilArgs({
|
||||
args,
|
||||
endpoint,
|
||||
accessKeyId,
|
||||
accessKeySecret,
|
||||
env,
|
||||
}),
|
||||
{ stdio: 'inherit', shell: false, env },
|
||||
);
|
||||
if (result.error) {
|
||||
throw new Error(`无法执行 ${binary},请先安装并配置 ossutil`);
|
||||
}
|
||||
if (result.status !== 0) {
|
||||
throw new Error(`${binary} 执行失败,退出码 ${result.status}`);
|
||||
}
|
||||
}
|
||||
|
||||
/** 写入总版本号对象;dry-run 下只打印将要执行的上传。 */
|
||||
export function writeGlobalVersion(payload, options = {}) {
|
||||
const { env = process.env, dryRun = readReleaseDryRun(env) } = options;
|
||||
const bucket = env.AGC_OSS_BUCKET?.trim() || 'agc-dev';
|
||||
const body = Buffer.from(`${JSON.stringify(payload, null, 2)}\n`, 'utf8');
|
||||
const tempDirectory = fs.mkdtempSync(
|
||||
path.join(os.tmpdir(), 'agc-global-version-'),
|
||||
);
|
||||
const tempPath = path.join(tempDirectory, 'global-version.json');
|
||||
try {
|
||||
fs.writeFileSync(tempPath, body);
|
||||
const ossUrl = `oss://${bucket}/${AGC_GLOBAL_VERSION_OBJECT_KEY}`;
|
||||
if (dryRun) {
|
||||
console.log(
|
||||
`[dry-run] 不写总版本号:${ossUrl} <- ${JSON.stringify(payload)}`,
|
||||
);
|
||||
return { written: false, objectUrl: ossUrl, payload };
|
||||
}
|
||||
runOssutil(['cp', '--force', tempPath, ossUrl], { env });
|
||||
return { written: true, objectUrl: ossUrl, payload };
|
||||
} finally {
|
||||
fs.rmSync(tempDirectory, { force: true, recursive: true });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 发一次号并写回总版本号对象。
|
||||
*
|
||||
* 写后回读校验:若远端值与自己写下的不一致,说明有并发发号,按失败关闭处理
|
||||
* (号已烧,不重试、不回滚),由人工确认后再发。
|
||||
*/
|
||||
export async function issueGlobalVersion({
|
||||
channel,
|
||||
commit = null,
|
||||
buildId = null,
|
||||
repoVersion = null,
|
||||
env = process.env,
|
||||
fetchImpl = fetch,
|
||||
now = () => new Date().toISOString(),
|
||||
writeImpl = writeGlobalVersion,
|
||||
} = {}) {
|
||||
if (!channel) throw new Error('发号必须显式指定 channel');
|
||||
const dryRun = readReleaseDryRun(env);
|
||||
const current = await readGlobalVersion({ env, fetchImpl });
|
||||
let baseline = current?.version ?? null;
|
||||
let seeded = false;
|
||||
if (!baseline) {
|
||||
baseline = await resolveSeedBaseline({ repoVersion, env, fetchImpl });
|
||||
seeded = true;
|
||||
}
|
||||
const issued = nextVersion(baseline);
|
||||
const payload = {
|
||||
version: issued,
|
||||
updatedAt: now(),
|
||||
channel,
|
||||
commit,
|
||||
buildId,
|
||||
};
|
||||
console.log(
|
||||
`[agc-global-version] ${
|
||||
seeded ? `按播种基线 ${baseline} 首发` : `总号 ${baseline}`
|
||||
} -> ${issued}(channel=${channel} dry-run=${dryRun})`,
|
||||
);
|
||||
writeImpl(payload, { env, dryRun });
|
||||
if (!dryRun) {
|
||||
const stored = await readGlobalVersion({ env, fetchImpl });
|
||||
if (!stored || stored.version !== issued) {
|
||||
throw new Error(
|
||||
`总版本号写后回读不一致:期望 ${issued},远端 ${
|
||||
stored?.version ?? '不存在'
|
||||
};可能存在并发发号,本次构建失败关闭`,
|
||||
);
|
||||
}
|
||||
}
|
||||
return issued;
|
||||
}
|
||||
|
||||
/** 渠道高水位断言:请求号低于本渠道清单版本即失败关闭。 */
|
||||
export function assertRequestedVersionNotBelowChannel({
|
||||
requested,
|
||||
channelVersion,
|
||||
channel,
|
||||
}) {
|
||||
const requestedVersion = parseVersion(requested, '请求版本');
|
||||
if (channelVersion == null) return requestedVersion;
|
||||
const current = parseVersion(channelVersion, `${channel} 渠道版本`);
|
||||
if (compareVersions(requestedVersion, current) < 0) {
|
||||
throw new Error(
|
||||
`请求版本 ${requestedVersion} 低于 ${channel} 渠道当前清单版本 ${current};拒绝回退发布`,
|
||||
);
|
||||
}
|
||||
return requestedVersion;
|
||||
}
|
||||
|
||||
/** 只读预览:不写回、不烧号。 */
|
||||
export async function previewNextGlobalVersion(options = {}) {
|
||||
const current = await readGlobalVersion(options);
|
||||
const baseline =
|
||||
current?.version ??
|
||||
(await resolveSeedBaseline({
|
||||
repoVersion: options.repoVersion,
|
||||
env: options.env,
|
||||
fetchImpl: options.fetchImpl,
|
||||
}));
|
||||
return nextVersion(baseline);
|
||||
}
|
||||
@@ -0,0 +1,211 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { test } from 'node:test';
|
||||
|
||||
import {
|
||||
assertRequestedVersionNotBelowChannel,
|
||||
buildOssutilArgs,
|
||||
issueGlobalVersion,
|
||||
maxVersion,
|
||||
nextVersion,
|
||||
previewNextGlobalVersion,
|
||||
resolveSeedBaseline,
|
||||
} from './agc-global-version.mjs';
|
||||
|
||||
function jsonResponse(payload, status = 200) {
|
||||
return {
|
||||
ok: status >= 200 && status < 300,
|
||||
status,
|
||||
json: async () => payload,
|
||||
};
|
||||
}
|
||||
|
||||
/** 以 URL 为键的假 OSS:只读 fetch + 记录写入。 */
|
||||
function createFakeOss({ objects = {} } = {}) {
|
||||
const state = { ...objects };
|
||||
const writes = [];
|
||||
return {
|
||||
state,
|
||||
writes,
|
||||
fetchImpl: async (url) => {
|
||||
const key = String(url).replace(/^https?:\/\/[^/]+\//u, '');
|
||||
if (!(key in state)) return jsonResponse(null, 404);
|
||||
return jsonResponse(state[key]);
|
||||
},
|
||||
writeImpl: (payload, options = {}) => {
|
||||
writes.push({ payload, dryRun: Boolean(options.dryRun) });
|
||||
if (!options.dryRun) state['agc/global-version.json'] = payload;
|
||||
return { written: !options.dryRun, payload };
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
test('播种基线取仓库版本、各渠道清单与旧迁移指针的最大值', async () => {
|
||||
const oss = createFakeOss({
|
||||
objects: {
|
||||
'agc/dev-win/latest.json': { version: '0.1.57' },
|
||||
'agc/dev-mac/latest.json': { version: '0.1.12' },
|
||||
'agc/latest.json': { version: '0.1.60' },
|
||||
},
|
||||
});
|
||||
assert.equal(
|
||||
await resolveSeedBaseline({
|
||||
repoVersion: '0.1.48',
|
||||
env: {},
|
||||
fetchImpl: oss.fetchImpl,
|
||||
}),
|
||||
'0.1.60',
|
||||
);
|
||||
assert.equal(maxVersion('0.1.9', '0.1.10', null), '0.1.10');
|
||||
});
|
||||
|
||||
test('无总号时按播种基线发首号,并把总号写回唯一事实源', async () => {
|
||||
const oss = createFakeOss({
|
||||
objects: {
|
||||
'agc/dev-win/latest.json': { version: '0.1.57' },
|
||||
'agc/dev-mac/latest.json': { version: '0.1.12' },
|
||||
},
|
||||
});
|
||||
const issued = await issueGlobalVersion({
|
||||
channel: 'dev-win',
|
||||
commit: 'a'.repeat(40),
|
||||
buildId: '319',
|
||||
repoVersion: '0.1.48',
|
||||
env: {},
|
||||
fetchImpl: oss.fetchImpl,
|
||||
writeImpl: oss.writeImpl,
|
||||
now: () => '2026-09-20T00:00:00.000Z',
|
||||
});
|
||||
assert.equal(issued, '0.1.58');
|
||||
assert.deepEqual(
|
||||
oss.writes.map((entry) => entry.payload.version),
|
||||
['0.1.58'],
|
||||
);
|
||||
assert.equal(oss.state['agc/global-version.json'].version, '0.1.58');
|
||||
assert.equal(oss.state['agc/global-version.json'].channel, 'dev-win');
|
||||
assert.equal(oss.state['agc/global-version.json'].buildId, '319');
|
||||
});
|
||||
|
||||
test('已有总号时只递增,不再回看渠道清单', async () => {
|
||||
const oss = createFakeOss({
|
||||
objects: {
|
||||
'agc/global-version.json': { version: '0.2.7' },
|
||||
// 渠道清单被手工改小也不能把总号拉回去。
|
||||
'agc/dev-win/latest.json': { version: '0.1.10' },
|
||||
},
|
||||
});
|
||||
const issued = await issueGlobalVersion({
|
||||
channel: 'dev-mac',
|
||||
env: {},
|
||||
fetchImpl: oss.fetchImpl,
|
||||
writeImpl: oss.writeImpl,
|
||||
});
|
||||
assert.equal(issued, '0.2.8');
|
||||
assert.equal(oss.state['agc/global-version.json'].version, '0.2.8');
|
||||
});
|
||||
|
||||
test('dry-run 只预览下一位,不写回、不烧号', async () => {
|
||||
const oss = createFakeOss({
|
||||
objects: { 'agc/global-version.json': { version: '0.3.4' } },
|
||||
});
|
||||
const preview = await previewNextGlobalVersion({
|
||||
env: {},
|
||||
fetchImpl: oss.fetchImpl,
|
||||
});
|
||||
assert.equal(preview, '0.3.5');
|
||||
assert.equal(oss.writes.length, 0);
|
||||
|
||||
const issued = await issueGlobalVersion({
|
||||
channel: 'dev-win',
|
||||
env: { AGC_RELEASE_DRY_RUN: '1' },
|
||||
fetchImpl: oss.fetchImpl,
|
||||
writeImpl: oss.writeImpl,
|
||||
});
|
||||
assert.equal(issued, '0.3.5');
|
||||
assert.deepEqual(
|
||||
oss.writes.map((entry) => entry.dryRun),
|
||||
[true],
|
||||
);
|
||||
assert.equal(oss.state['agc/global-version.json'].version, '0.3.4');
|
||||
});
|
||||
|
||||
test('传入低于本渠道清单的号时失败关闭', () => {
|
||||
assert.equal(
|
||||
assertRequestedVersionNotBelowChannel({
|
||||
requested: '0.1.60',
|
||||
channelVersion: '0.1.60',
|
||||
channel: 'dev-win',
|
||||
}),
|
||||
'0.1.60',
|
||||
);
|
||||
assert.throws(
|
||||
() =>
|
||||
assertRequestedVersionNotBelowChannel({
|
||||
requested: '0.1.59',
|
||||
channelVersion: '0.1.60',
|
||||
channel: 'dev-win',
|
||||
}),
|
||||
/低于 dev-win 渠道当前清单版本/u,
|
||||
);
|
||||
assert.equal(
|
||||
assertRequestedVersionNotBelowChannel({
|
||||
requested: '0.1.1',
|
||||
channelVersion: null,
|
||||
channel: 'dev-mac',
|
||||
}),
|
||||
'0.1.1',
|
||||
);
|
||||
});
|
||||
|
||||
test('写后回读不一致(并发发号)时失败关闭', async () => {
|
||||
const oss = createFakeOss({
|
||||
objects: { 'agc/global-version.json': { version: '0.5.1' } },
|
||||
});
|
||||
await assert.rejects(
|
||||
issueGlobalVersion({
|
||||
channel: 'dev-win',
|
||||
env: {},
|
||||
// 模拟另一个发号进程在写入后覆盖了总号。
|
||||
writeImpl: (payload, options) => {
|
||||
const result = oss.writeImpl(payload, options);
|
||||
// 另一个发号进程紧随其后覆盖总号。
|
||||
oss.state['agc/global-version.json'] = { version: '0.5.9' };
|
||||
return result;
|
||||
},
|
||||
fetchImpl: oss.fetchImpl,
|
||||
}),
|
||||
/写后回读不一致/u,
|
||||
);
|
||||
});
|
||||
|
||||
test('nextVersion 只在 patch 位递增', () => {
|
||||
assert.equal(nextVersion('0.1.9'), '0.1.10');
|
||||
assert.equal(nextVersion('1.0.0'), '1.0.1');
|
||||
assert.throws(() => nextVersion('0.1'), /不是有效的三段版本号/u);
|
||||
});
|
||||
|
||||
test('ossutil 参数默认使用 v1 签名,并可按需带 region 与 v4', () => {
|
||||
const base = {
|
||||
args: [
|
||||
'cp',
|
||||
'--force',
|
||||
'/tmp/a.json',
|
||||
'oss://agc-dev/agc/global-version.json',
|
||||
],
|
||||
endpoint: 'oss-rg-china-mainland.aliyuncs.com',
|
||||
accessKeyId: 'id',
|
||||
accessKeySecret: 'secret',
|
||||
env: {},
|
||||
};
|
||||
const v1 = buildOssutilArgs(base);
|
||||
assert.equal(v1[v1.indexOf('--sign-version') + 1], 'v1');
|
||||
assert.ok(!v1.includes('--region'));
|
||||
assert.equal(v1[v1.indexOf('--access-key-id') + 1], 'id');
|
||||
assert.equal(v1[v1.indexOf('--access-key-secret') + 1], 'secret');
|
||||
|
||||
const v4 = buildOssutilArgs({
|
||||
...base,
|
||||
env: { AGC_OSS_SIGN_VERSION: 'v4', AGC_OSS_REGION: 'cn-beijing' },
|
||||
});
|
||||
assert.equal(v4[v4.indexOf('--region') + 1], 'cn-beijing');
|
||||
assert.equal(v4[v4.indexOf('--sign-version') + 1], 'v4');
|
||||
});
|
||||
@@ -5,6 +5,10 @@ import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
import {
|
||||
assertRequestedVersionNotBelowChannel,
|
||||
issueGlobalVersion as issueAgcGlobalVersion,
|
||||
} from './agc-global-version.mjs';
|
||||
import {
|
||||
defaultEditorFeatures,
|
||||
withDefaultCargoFeatures,
|
||||
@@ -102,6 +106,7 @@ export const agcReleasePathPatterns = [
|
||||
'server-rs/crates/',
|
||||
'plugins/agc-cocos-editor/',
|
||||
'plugins/agc-unity-editor/',
|
||||
'plugins/agc-godot-editor/',
|
||||
'apps/desktop-shell/src-tauri/icons/',
|
||||
'package.json',
|
||||
'package-lock.json',
|
||||
@@ -309,14 +314,35 @@ function replaceVersionLine(source, version, pattern, label) {
|
||||
return source.replace(pattern, `$1${version}$3`);
|
||||
}
|
||||
|
||||
/**
|
||||
* 版本来源固定为 OSS 总版本号(`agc/global-version.json`):
|
||||
* - CI 统一构建由发号 Job 先发号,再通过 AGC_RELEASE_VERSION 透传给各渠道;
|
||||
* - 未传入时(本地手工兜底)由本函数现场发号并写回总号;
|
||||
* - 渠道高水位只做断言:传入号低于本渠道清单版本即失败关闭。
|
||||
*/
|
||||
export async function prepareReleaseVersion(context = resolveReleaseContext()) {
|
||||
const { channel, target } = context;
|
||||
const localVersion = parseVersion(readPackageJson().version, '本地版本');
|
||||
const remoteVersion = await resolveRemoteHighWaterVersion(channel, target);
|
||||
const requestedVersion = process.env.AGC_RELEASE_VERSION?.trim();
|
||||
const nextVersion = requestedVersion
|
||||
? parseVersion(requestedVersion, '指定版本')
|
||||
: nextPatchVersion(localVersion, remoteVersion);
|
||||
? assertRequestedVersionNotBelowChannel({
|
||||
requested: requestedVersion,
|
||||
channelVersion: remoteVersion,
|
||||
channel,
|
||||
})
|
||||
: await issueAgcGlobalVersion({
|
||||
channel,
|
||||
commit:
|
||||
process.env.COMMIT_HASH?.trim() ||
|
||||
process.env.GIT_COMMIT?.trim() ||
|
||||
null,
|
||||
buildId:
|
||||
process.env.BUILD_NUMBER?.trim() ||
|
||||
process.env.AGC_BUILD_ID?.trim() ||
|
||||
null,
|
||||
repoVersion: localVersion,
|
||||
});
|
||||
|
||||
const packageSource = fs.readFileSync(packageJsonPath, 'utf8');
|
||||
fs.writeFileSync(
|
||||
@@ -375,8 +401,8 @@ export async function prepareReleaseVersion(context = resolveReleaseContext()) {
|
||||
|
||||
console.log(
|
||||
requestedVersion
|
||||
? `[ai-game-creator-shell] 渠道 ${channel} 使用指定版本 ${nextVersion}(本地 ${localVersion} / OSS ${remoteVersion ?? '不存在'})`
|
||||
: `[ai-game-creator-shell] 渠道 ${channel} 版本 ${localVersion} / OSS ${remoteVersion ?? '不存在'} -> ${nextVersion}`,
|
||||
? `[ai-game-creator-shell] 渠道 ${channel} 使用发号 Job 下发的总号 ${nextVersion}(本渠道清单 ${remoteVersion ?? '不存在'} / 仓库 ${localVersion})`
|
||||
: `[ai-game-creator-shell] 渠道 ${channel} 本地兜底发号 ${nextVersion}(本渠道清单 ${remoteVersion ?? '不存在'} / 仓库 ${localVersion})`,
|
||||
);
|
||||
return nextVersion;
|
||||
}
|
||||
|
||||
@@ -535,7 +535,7 @@ test('Windows remains the default and explicit Windows overrides macOS environme
|
||||
spawn: (_binary, command) => {
|
||||
assert.ok(
|
||||
command.includes(
|
||||
'--features=cocos-editor-execute,unity-editor-execute',
|
||||
'--features=cocos-editor-execute,unity-editor-execute,godot-editor-execute',
|
||||
),
|
||||
);
|
||||
assert.ok(command.includes('user-config.json'));
|
||||
|
||||
@@ -19,6 +19,6 @@ export function withDefaultCargoFeatures(argv, features) {
|
||||
|
||||
export function defaultEditorFeatures(target) {
|
||||
return target === 'win32' || target.includes('windows')
|
||||
? ['cocos-editor-execute', 'unity-editor-execute']
|
||||
? ['cocos-editor-execute', 'unity-editor-execute', 'godot-editor-execute']
|
||||
: [];
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@ test('Windows release includes the same editor feature as development', () => {
|
||||
buildTauriBuildArguments([], 'x86_64-pc-windows-msvc', 'win32'),
|
||||
[
|
||||
'build',
|
||||
'--features=cocos-editor-execute,unity-editor-execute',
|
||||
'--features=cocos-editor-execute,unity-editor-execute,godot-editor-execute',
|
||||
'--target',
|
||||
'x86_64-pc-windows-msvc',
|
||||
],
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
/**
|
||||
* AGC 总版本号发号入口(CI 发号 Job 与本地手工兜底共用)。
|
||||
*
|
||||
* 用法:
|
||||
* node scripts/issue-global-version.mjs --channel dev-win [--commit <sha>] [--build-id <id>] [--out <file>]
|
||||
* node scripts/issue-global-version.mjs --seed-only
|
||||
* node scripts/issue-global-version.mjs --dry-run --channel dev-win # 只预览,不写回、不烧号
|
||||
*
|
||||
* 输出固定为一行 `AGC_GLOBAL_VERSION=<version>`,便于 Jenkins 直接读取。
|
||||
*/
|
||||
import fs from 'node:fs';
|
||||
|
||||
import {
|
||||
issueGlobalVersion,
|
||||
previewNextGlobalVersion,
|
||||
readGlobalVersion,
|
||||
readReleaseDryRun,
|
||||
resolveSeedBaseline,
|
||||
writeGlobalVersion,
|
||||
} from './agc-global-version.mjs';
|
||||
|
||||
function parseArgs(argv) {
|
||||
const options = {
|
||||
channel: '',
|
||||
commit: '',
|
||||
buildId: '',
|
||||
repoVersion: '',
|
||||
out: '',
|
||||
seedOnly: false,
|
||||
dryRun: readReleaseDryRun(),
|
||||
};
|
||||
for (let index = 0; index < argv.length; index += 1) {
|
||||
const arg = argv[index];
|
||||
const readValue = (label) => {
|
||||
const value = argv[index + 1];
|
||||
if (value == null || value.startsWith('--')) {
|
||||
throw new Error(`${label} 缺少取值`);
|
||||
}
|
||||
index += 1;
|
||||
return value;
|
||||
};
|
||||
switch (arg) {
|
||||
case '--channel':
|
||||
options.channel = readValue('--channel');
|
||||
break;
|
||||
case '--commit':
|
||||
options.commit = readValue('--commit');
|
||||
break;
|
||||
case '--build-id':
|
||||
options.buildId = readValue('--build-id');
|
||||
break;
|
||||
case '--repo-version':
|
||||
options.repoVersion = readValue('--repo-version');
|
||||
break;
|
||||
case '--out':
|
||||
options.out = readValue('--out');
|
||||
break;
|
||||
case '--seed-only':
|
||||
options.seedOnly = true;
|
||||
break;
|
||||
case '--dry-run':
|
||||
options.dryRun = true;
|
||||
break;
|
||||
default:
|
||||
throw new Error(`未知参数:${arg}`);
|
||||
}
|
||||
}
|
||||
return options;
|
||||
}
|
||||
|
||||
function emit(version, options) {
|
||||
console.log(`AGC_GLOBAL_VERSION=${version}`);
|
||||
if (options.out) {
|
||||
fs.writeFileSync(options.out, `${version}\n`, 'utf8');
|
||||
}
|
||||
}
|
||||
|
||||
const options = parseArgs(process.argv.slice(2));
|
||||
process.env.AGC_RELEASE_DRY_RUN = options.dryRun ? '1' : '0';
|
||||
|
||||
if (options.seedOnly) {
|
||||
const current = await readGlobalVersion();
|
||||
if (current) {
|
||||
console.log(
|
||||
`[agc-global-version] 总号已存在(${current.version}),播种跳过;需要重新播种请先人工确认`,
|
||||
);
|
||||
emit(current.version, options);
|
||||
} else {
|
||||
const baseline = await resolveSeedBaseline({
|
||||
repoVersion: options.repoVersion || null,
|
||||
});
|
||||
const payload = {
|
||||
version: baseline,
|
||||
updatedAt: new Date().toISOString(),
|
||||
channel: options.channel || 'seed',
|
||||
commit: options.commit || null,
|
||||
buildId: options.buildId || null,
|
||||
};
|
||||
writeGlobalVersion(payload, { dryRun: options.dryRun });
|
||||
console.log(
|
||||
`[agc-global-version] 播种基线 ${baseline}(尚未发号,首发为下一位)`,
|
||||
);
|
||||
emit(baseline, options);
|
||||
}
|
||||
} else if (options.dryRun) {
|
||||
const preview = await previewNextGlobalVersion({
|
||||
repoVersion: options.repoVersion || null,
|
||||
});
|
||||
console.log(
|
||||
`[agc-global-version] 预览下一个总号 ${preview}(dry-run 不写回、不烧号)`,
|
||||
);
|
||||
emit(preview, options);
|
||||
} else {
|
||||
const issued = await issueGlobalVersion({
|
||||
channel: options.channel || 'manual',
|
||||
commit: options.commit || null,
|
||||
buildId: options.buildId || null,
|
||||
repoVersion: options.repoVersion || null,
|
||||
});
|
||||
emit(issued, options);
|
||||
}
|
||||
@@ -312,34 +312,15 @@ function runShard(executable, shardIndex, shardCount, shardTestNames) {
|
||||
},
|
||||
);
|
||||
|
||||
const failureLines = [];
|
||||
let inFailureList = false;
|
||||
// Rust 的首个 failures: 后有空行,不能按空行结束采集,否则会丢掉 panic 详情。
|
||||
// 只保存有界尾部;成功时不输出,失败时优先输出完整失败段。
|
||||
let stdoutTail = '';
|
||||
let stderr = '';
|
||||
const consumeLine = (rawLine) => {
|
||||
const line = rawLine.replace(/\r$/, '');
|
||||
if (line.includes('failures:')) {
|
||||
inFailureList = true;
|
||||
return;
|
||||
}
|
||||
if (inFailureList) {
|
||||
if (line.trim().length === 0) {
|
||||
inFailureList = false;
|
||||
return;
|
||||
}
|
||||
failureLines.push(line.trim());
|
||||
}
|
||||
};
|
||||
|
||||
child.stdout.setEncoding('utf8');
|
||||
child.stderr.setEncoding('utf8');
|
||||
let stdoutBuffer = '';
|
||||
child.stdout.on('data', (chunk) => {
|
||||
stdoutBuffer += chunk;
|
||||
const lines = stdoutBuffer.split('\n');
|
||||
stdoutBuffer = lines.pop() ?? '';
|
||||
for (const line of lines) {
|
||||
consumeLine(line);
|
||||
}
|
||||
stdoutTail = (stdoutTail + chunk).slice(-64_000);
|
||||
});
|
||||
child.stderr.on('data', (chunk) => {
|
||||
stderr += chunk;
|
||||
@@ -356,12 +337,17 @@ function runShard(executable, shardIndex, shardCount, shardTestNames) {
|
||||
});
|
||||
});
|
||||
child.on('close', (code) => {
|
||||
const output = stdoutTail.replace(/\r\n/g, '\n');
|
||||
const failureStart = output.indexOf('failures:\n');
|
||||
resolve({
|
||||
label,
|
||||
ok: code === 0,
|
||||
durationMs: Date.now() - startedAt,
|
||||
testCount: shardTestNames.length,
|
||||
failures: failureLines,
|
||||
failures: output
|
||||
.slice(failureStart < 0 ? 0 : failureStart)
|
||||
.trim()
|
||||
.split('\n'),
|
||||
stderr,
|
||||
});
|
||||
});
|
||||
@@ -438,7 +424,7 @@ async function main() {
|
||||
}
|
||||
failed = true;
|
||||
console.error(
|
||||
`[rust-shards] ${result.label} FAILED: ${result.testCount} test(s) in ${formatDuration(result.durationMs)}`,
|
||||
`[rust-shards] ${result.label} FAILED (selected ${result.testCount} test(s)) in ${formatDuration(result.durationMs)}`,
|
||||
);
|
||||
for (const failure of result.failures) {
|
||||
console.error(`[rust-shards] ${failure}`);
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import test from 'node:test';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const runner = fileURLToPath(
|
||||
new URL('./run-rust-shell-test-shards.mjs', import.meta.url),
|
||||
);
|
||||
|
||||
function runFixture(t, source) {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'agc-shard-output-'));
|
||||
t.after(() => fs.rmSync(root, { recursive: true, force: true }));
|
||||
fs.mkdirSync(path.join(root, 'src'));
|
||||
fs.writeFileSync(
|
||||
path.join(root, 'Cargo.toml'),
|
||||
'[package]\nname = "shard-output-fixture"\nversion = "0.1.0"\nedition = "2021"\n',
|
||||
);
|
||||
fs.writeFileSync(path.join(root, 'src/lib.rs'), source);
|
||||
const result = spawnSync(
|
||||
process.execPath,
|
||||
[
|
||||
runner,
|
||||
`--manifest=${path.join(root, 'Cargo.toml')}`,
|
||||
'--target-kind=lib',
|
||||
'--no-locked',
|
||||
'--shards=1',
|
||||
`--shard-tmp-root=${path.join(root, 'tmp')}`,
|
||||
],
|
||||
{
|
||||
encoding: 'utf8',
|
||||
timeout: 60_000,
|
||||
windowsHide: true,
|
||||
env: { ...process.env, CARGO_TARGET_DIR: path.join(root, 'target') },
|
||||
},
|
||||
);
|
||||
assert.ifError(result.error);
|
||||
return { status: result.status, output: result.stdout + result.stderr };
|
||||
}
|
||||
|
||||
test('failed shard retains panic details and separates selected count from failures', (t) => {
|
||||
const result = runFixture(
|
||||
t,
|
||||
`
|
||||
#[test]
|
||||
fn passing_case() {}
|
||||
#[test]
|
||||
fn failing_case() {
|
||||
assert_eq!(1, 2, "shard panic evidence");
|
||||
}
|
||||
`,
|
||||
);
|
||||
assert.equal(result.status, 1, result.output);
|
||||
assert.match(result.output, /FAILED \(selected 2 test\(s\)\)/);
|
||||
assert.match(result.output, /failing_case/);
|
||||
assert.match(result.output, /panicked at src[\\/]lib\.rs:/);
|
||||
assert.match(result.output, /shard panic evidence/);
|
||||
assert.match(result.output, /left: 1/);
|
||||
assert.match(result.output, /right: 2/);
|
||||
assert.match(result.output, /1 passed; 1 failed/);
|
||||
});
|
||||
|
||||
test('successful shard keeps its compact summary', (t) => {
|
||||
const result = runFixture(t, '#[test]\nfn passing_case() {}\n');
|
||||
assert.equal(result.status, 0, result.output);
|
||||
assert.match(result.output, /shard 1\/1 ok: 1 test\(s\)/);
|
||||
assert.doesNotMatch(result.output, /test passing_case \.\.\. ok/);
|
||||
});
|
||||
@@ -9,7 +9,9 @@ export const EXPECTED_SKILL_NAMES = Object.freeze([
|
||||
'agc-browser-playtest',
|
||||
'agc-client-projection',
|
||||
'agc-game-production-workflow',
|
||||
'agc-godot-editor',
|
||||
'agc-project-structure',
|
||||
'agc-unity-editor',
|
||||
'agc-web-game-development',
|
||||
'taonier-art-assets',
|
||||
]);
|
||||
|
||||
+13
@@ -1755,6 +1755,7 @@ dependencies = [
|
||||
"editor-adapter-api",
|
||||
"futures",
|
||||
"getrandom 0.3.4",
|
||||
"godot-editor-bridge",
|
||||
"http",
|
||||
"image",
|
||||
"jsonschema",
|
||||
@@ -1981,6 +1982,18 @@ dependencies = [
|
||||
"system-deps",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "godot-editor-bridge"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"editor-adapter-api",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2",
|
||||
"tempfile",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "gtk"
|
||||
version = "0.18.2"
|
||||
|
||||
@@ -13,6 +13,7 @@ cocos-editor = ["cocos-editor-bridge/process-discovery"]
|
||||
cocos-editor-execute = ["cocos-editor", "cocos-editor-bridge/windows-bootstrap"]
|
||||
cocos-editor-injection = ["cocos-editor-execute", "cocos-editor-bridge/windows-injection"]
|
||||
unity-editor-execute = []
|
||||
godot-editor-execute = []
|
||||
|
||||
[build-dependencies]
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
@@ -29,6 +30,7 @@ agent-runtime-core = { path = "../../../server-rs/crates/agent-runtime-core" }
|
||||
cocos-editor-bridge = { path = "../../../plugins/agc-cocos-editor/native/cocos-editor-bridge", default-features = false }
|
||||
editor-adapter-api = { path = "../../../server-rs/crates/editor-adapter-api" }
|
||||
unity-editor-bridge = { path = "../../../plugins/agc-unity-editor/native/unity-editor-bridge" }
|
||||
godot-editor-bridge = { path = "../../../plugins/agc-godot-editor/native/godot-editor-bridge" }
|
||||
base64 = "0.22"
|
||||
axum = "0.8"
|
||||
chromiumoxide = "0.9.1"
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
mod codex_bundle;
|
||||
#[path = "build_support/frontend_dist_guard.rs"]
|
||||
mod frontend_dist_guard;
|
||||
#[path = "build_support/godot_bundle.rs"]
|
||||
mod godot_bundle;
|
||||
#[path = "build_support/runtime_prompt_bundle.rs"]
|
||||
mod runtime_prompt_bundle;
|
||||
|
||||
@@ -213,6 +215,7 @@ fn main() {
|
||||
let manifest_path = manifest_dir.join("prompts/runtime/manifest.json");
|
||||
stage_bundled_codex_cli(&manifest_dir);
|
||||
prepare_unity_editor_helper(&manifest_dir);
|
||||
prepare_godot_editor_extension(&manifest_dir);
|
||||
stage_plugin_workspace(&manifest_dir);
|
||||
stage_cocos_editor_payload(&manifest_dir);
|
||||
let compiled = runtime_prompt_bundle::compile_manifest(&manifest_path)
|
||||
@@ -384,6 +387,39 @@ fn collect_unity_helper_sources(root: &std::path::Path, sources: &mut Vec<PathBu
|
||||
}
|
||||
}
|
||||
|
||||
fn prepare_godot_editor_extension(manifest_dir: &std::path::Path) {
|
||||
println!("cargo:rerun-if-env-changed=CARGO_FEATURE_GODOT_EDITOR_EXECUTE");
|
||||
if env::var_os("CARGO_FEATURE_GODOT_EDITOR_EXECUTE").is_none()
|
||||
|| env::var("TARGET").expect("Cargo TARGET") != "x86_64-pc-windows-msvc"
|
||||
{
|
||||
return;
|
||||
}
|
||||
let root = manifest_dir.join("../../../plugins/agc-godot-editor/native/gdextension");
|
||||
for source in godot_bundle::source_files(&root).unwrap_or_else(|error| panic!("{error}")) {
|
||||
println!("cargo:rerun-if-changed={}", source.display());
|
||||
}
|
||||
assert!(
|
||||
cfg!(windows),
|
||||
"构建 Godot 原生扩展需要 Windows x64 C 编译器"
|
||||
);
|
||||
let status = std::process::Command::new("powershell.exe")
|
||||
// Cargo 可能从 PowerShell 7 启动,Windows PowerShell 应使用自身模块目录。
|
||||
.env_remove("PSModulePath")
|
||||
.args([
|
||||
"-NoProfile",
|
||||
"-NonInteractive",
|
||||
"-ExecutionPolicy",
|
||||
"Bypass",
|
||||
"-File",
|
||||
])
|
||||
.arg(root.join("build.ps1"))
|
||||
.current_dir(&root)
|
||||
.status()
|
||||
.expect("无法启动 Godot 原生扩展构建脚本");
|
||||
assert!(status.success(), "Godot 原生扩展构建失败");
|
||||
godot_bundle::validate(&root).unwrap_or_else(|error| panic!("{error}"));
|
||||
}
|
||||
|
||||
/// 把 `plugins/` 工作区里的插件包随包映射到应用资源目录。
|
||||
///
|
||||
/// 只复制插件运行需要的清单、入口、面板和 native payload,不复制 native 源码、
|
||||
@@ -444,6 +480,15 @@ fn stage_plugin_workspace(manifest_dir: &std::path::Path) {
|
||||
}
|
||||
copy_plugin_tree(&plugin_root.join(&relative), &destination.join(&relative));
|
||||
}
|
||||
if name == "agc-godot-editor" {
|
||||
godot_bundle::stage(
|
||||
&plugin_root.join("native/gdextension"),
|
||||
&destination.join("native/gdextension"),
|
||||
&target,
|
||||
env::var_os("CARGO_FEATURE_GODOT_EDITOR_EXECUTE").is_some(),
|
||||
)
|
||||
.unwrap_or_else(|error| panic!("{error}"));
|
||||
}
|
||||
println!("cargo:rerun-if-changed={}", plugin_root.display());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,215 @@
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
pub const BUNDLE_FILES: [&str; 4] = [
|
||||
"bin/win-x64/agc_godot_editor.dll",
|
||||
"bin/win-x64/metadata.json",
|
||||
"vendor/LICENSE.txt",
|
||||
"vendor/provenance.json",
|
||||
];
|
||||
|
||||
fn plain_metadata(path: &Path) -> Result<fs::Metadata, String> {
|
||||
let metadata = fs::symlink_metadata(path)
|
||||
.map_err(|error| format!("Godot 资源不可读 {}:{error}", path.display()))?;
|
||||
#[cfg(windows)]
|
||||
let linked = {
|
||||
use std::os::windows::fs::MetadataExt;
|
||||
metadata.file_attributes() & 0x400 != 0
|
||||
};
|
||||
#[cfg(not(windows))]
|
||||
let linked = metadata.file_type().is_symlink();
|
||||
if linked {
|
||||
return Err(format!("Godot 资源不能经过链接:{}", path.display()));
|
||||
}
|
||||
Ok(metadata)
|
||||
}
|
||||
|
||||
fn read_bundle_file(root: &Path, relative: &str) -> Result<Vec<u8>, String> {
|
||||
plain_metadata(root)?;
|
||||
let mut path = root.to_path_buf();
|
||||
for component in Path::new(relative).components() {
|
||||
path.push(component);
|
||||
plain_metadata(&path)?;
|
||||
}
|
||||
let metadata = plain_metadata(&path)?;
|
||||
if !metadata.is_file() || metadata.len() == 0 {
|
||||
return Err(format!("Godot 随包资源缺失或为空:{}", path.display()));
|
||||
}
|
||||
if relative.ends_with("metadata.json") && metadata.len() > 64 * 1024 {
|
||||
return Err("Godot 构建元数据超过 64 KiB".to_string());
|
||||
}
|
||||
fs::read(&path).map_err(|error| format!("读取 Godot 资源失败:{error}"))
|
||||
}
|
||||
|
||||
pub fn validate(root: &Path) -> Result<Vec<(&'static str, Vec<u8>)>, String> {
|
||||
let files = BUNDLE_FILES
|
||||
.iter()
|
||||
.map(|relative| read_bundle_file(root, relative).map(|bytes| (*relative, bytes)))
|
||||
.collect::<Result<Vec<_>, _>>()?;
|
||||
let metadata: serde_json::Value = serde_json::from_slice(&files[1].1)
|
||||
.map_err(|error| format!("Godot 构建元数据无效:{error}"))?;
|
||||
for (field, expected) in [
|
||||
("protocol", "agc.godot.editor.v1"),
|
||||
("platform", "windows"),
|
||||
("arch", "x86_64"),
|
||||
("entrySymbol", "agc_godot_editor_init"),
|
||||
("minimumGodotVersion", "4.7"),
|
||||
] {
|
||||
if metadata[field].as_str() != Some(expected) {
|
||||
return Err(format!("Godot 构建元数据 {field} 不匹配"));
|
||||
}
|
||||
}
|
||||
if !metadata["buildId"].as_str().is_some_and(|value| {
|
||||
value.strip_prefix("sha256:").is_some_and(|digest| {
|
||||
digest.len() == 64 && digest.bytes().all(|byte| byte.is_ascii_hexdigit())
|
||||
})
|
||||
}) {
|
||||
return Err("Godot 构建身份无效".to_string());
|
||||
}
|
||||
let actual_sha256 = format!("{:x}", Sha256::digest(&files[0].1));
|
||||
if metadata["sha256"].as_str() != Some(actual_sha256.as_str()) {
|
||||
return Err("Godot DLL 与构建元数据 SHA256 不匹配".to_string());
|
||||
}
|
||||
Ok(files)
|
||||
}
|
||||
|
||||
pub fn stage(root: &Path, destination: &Path, target: &str, enabled: bool) -> Result<(), String> {
|
||||
if target != "x86_64-pc-windows-msvc" || !enabled {
|
||||
return Ok(());
|
||||
}
|
||||
for (relative, bytes) in validate(root)? {
|
||||
let path = destination.join(relative);
|
||||
fs::create_dir_all(path.parent().expect("Godot resource parent"))
|
||||
.map_err(|error| format!("创建 Godot 资源目录失败:{error}"))?;
|
||||
fs::write(&path, bytes).map_err(|error| format!("写入 Godot 资源失败:{error}"))?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn source_files(root: &Path) -> Result<Vec<PathBuf>, String> {
|
||||
plain_metadata(root)?;
|
||||
let mut sources = Vec::new();
|
||||
for entry in fs::read_dir(root).map_err(|error| format!("读取 Godot 源码失败:{error}"))?
|
||||
{
|
||||
let entry = entry.map_err(|error| format!("读取 Godot 源码目录项失败:{error}"))?;
|
||||
if matches!(entry.file_name().to_str(), Some("bin" | ".build")) {
|
||||
continue;
|
||||
}
|
||||
let metadata = plain_metadata(&entry.path())?;
|
||||
if metadata.is_dir() {
|
||||
sources.extend(source_files(&entry.path())?);
|
||||
} else if metadata.is_file() {
|
||||
sources.push(entry.path());
|
||||
}
|
||||
}
|
||||
sources.sort();
|
||||
Ok(sources)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn fixture(root: &Path) {
|
||||
for relative in BUNDLE_FILES {
|
||||
let path = root.join(relative);
|
||||
fs::create_dir_all(path.parent().unwrap()).unwrap();
|
||||
fs::write(path, b"fixture").unwrap();
|
||||
}
|
||||
fs::write(
|
||||
root.join(BUNDLE_FILES[1]),
|
||||
serde_json::to_vec(&serde_json::json!({
|
||||
"protocol": "agc.godot.editor.v1",
|
||||
"platform": "windows",
|
||||
"arch": "x86_64",
|
||||
"entrySymbol": "agc_godot_editor_init",
|
||||
"minimumGodotVersion": "4.7",
|
||||
"buildId": format!("sha256:{}", "a".repeat(64)),
|
||||
"sha256": format!("{:x}", Sha256::digest(b"fixture")),
|
||||
}))
|
||||
.unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stage_only_verified_windows_runtime_and_not_build_inputs() {
|
||||
let source = tempfile::tempdir().unwrap();
|
||||
let destination = tempfile::tempdir().unwrap();
|
||||
fixture(source.path());
|
||||
fs::write(source.path().join("bridge.gd"), "source").unwrap();
|
||||
fs::write(source.path().join("bin/win-x64/extra.dll"), "excluded").unwrap();
|
||||
stage(
|
||||
source.path(),
|
||||
destination.path(),
|
||||
"x86_64-pc-windows-msvc",
|
||||
true,
|
||||
)
|
||||
.unwrap();
|
||||
for relative in BUNDLE_FILES {
|
||||
assert_eq!(
|
||||
fs::read(source.path().join(relative)).unwrap(),
|
||||
fs::read(destination.path().join(relative)).unwrap()
|
||||
);
|
||||
}
|
||||
assert!(!destination.path().join("bridge.gd").exists());
|
||||
assert!(!destination.path().join("bin/win-x64/extra.dll").exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unsupported_or_disabled_targets_need_no_native_artifacts() {
|
||||
let destination = tempfile::tempdir().unwrap();
|
||||
for (target, enabled) in [
|
||||
("aarch64-apple-darwin", true),
|
||||
("x86_64-apple-darwin", true),
|
||||
("x86_64-unknown-linux-gnu", true),
|
||||
("aarch64-pc-windows-msvc", true),
|
||||
("x86_64-pc-windows-msvc", false),
|
||||
] {
|
||||
stage(
|
||||
Path::new("missing-godot-native"),
|
||||
destination.path(),
|
||||
target,
|
||||
enabled,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(fs::read_dir(destination.path()).unwrap().count(), 0);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn incomplete_or_tampered_bundle_fails_before_copying() {
|
||||
let source = tempfile::tempdir().unwrap();
|
||||
let destination = tempfile::tempdir().unwrap();
|
||||
fixture(source.path());
|
||||
fs::write(source.path().join(BUNDLE_FILES[0]), b"tampered").unwrap();
|
||||
assert!(stage(
|
||||
source.path(),
|
||||
destination.path(),
|
||||
"x86_64-pc-windows-msvc",
|
||||
true
|
||||
)
|
||||
.unwrap_err()
|
||||
.contains("SHA256"));
|
||||
assert_eq!(fs::read_dir(destination.path()).unwrap().count(), 0);
|
||||
fixture(source.path());
|
||||
fs::remove_file(source.path().join("vendor/LICENSE.txt")).unwrap();
|
||||
assert!(validate(source.path()).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn source_watch_list_excludes_build_outputs() {
|
||||
let source = tempfile::tempdir().unwrap();
|
||||
fixture(source.path());
|
||||
fs::create_dir(source.path().join(".build")).unwrap();
|
||||
fs::write(source.path().join(".build/bridge.obj"), "generated").unwrap();
|
||||
fs::write(source.path().join("bridge.gd"), "source").unwrap();
|
||||
let sources = source_files(source.path()).unwrap();
|
||||
assert_eq!(sources.len(), 3);
|
||||
assert!(sources.contains(&source.path().join("bridge.gd")));
|
||||
assert!(!sources.iter().any(|path| path
|
||||
.components()
|
||||
.any(|component| component.as_os_str() == "bin" || component.as_os_str() == ".build")));
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,3 @@
|
||||
顾问阶段遵照用户的具体指示行动。
|
||||
顾问阶段遵照用户的具体指示行动,不自主推进项目或主动安排下一步,不提交阶段审批。
|
||||
根据用户指示回答问题、读取相关文档、修改工作区文件,并说明改动可能影响的已有产物。
|
||||
涉及方向性变化或多个可行方案时,先向用户说明影响并等待用户决定。
|
||||
顾问阶段以完成用户当前请求并汇报结果为结束点。
|
||||
|
||||
+1
-1
@@ -3,7 +3,7 @@
|
||||
版本:v3 | 规则:台账放活队列——design 只放结论、分析只放论证、决定与开放问题住这里。编号连续不复用;被推翻的行标 overturned 挂新行,不删行。
|
||||
状态六态:`confirmed`(用户亲口/亲选)/ `auto_decided`(技术类代决,必带理由+推翻条件,用户一键可翻)/ `default_pending`(默认建议兜底,用户未点头)/ `prototype_pending`(待原型验证)/ `pending_user`(等用户拍板)/ `overturned`(被推翻,挂旧行编号)。
|
||||
|
||||
> 编号口径:D-01~D-13 与 exemplars/stardew-analysis.md 台账节选一致(D-04~D-06、D-08~D-10、D-12 原为"就地小权衡,直接登记未开条目",此处按登记口径展开);D-14 起为技术文档期新增,与 stardew-tdd-tech.md 开放问题回执互引。
|
||||
> 编号口径:D-01~D-13 与 templates/stardew-analysis.md 台账节选一致(D-04~D-06、D-08~D-10、D-12 原为"就地小权衡,直接登记未开条目",此处按登记口径展开);D-14 起为技术文档期新增,与 stardew-tdd-tech.md 开放问题回执互引。
|
||||
|
||||
## 当前待办(活队列)
|
||||
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
# 顶层设计:《星露谷物语》
|
||||
|
||||
## 顶层定位与规模锚点
|
||||
顶层设计让玩家每天都在想:
|
||||
顶层不是做长线农场生产线,也不是做以探索战斗为主的活动清单,而是让玩家每天都在想:
|
||||
> "今天做什么?——下雨天不用浇水,正好下矿井;回来的路上把罗宾的生日礼物送了。"
|
||||
|
||||
| 项 | 定义 |
|
||||
|
||||
@@ -5,7 +5,7 @@ name: game-gdd-architecture
|
||||
description: 写游戏策划案(GDD)系统架构时使用。在顶层设计定稿之后,
|
||||
把顶层的系统范围表正式切成 Sxx 系统:编号、职责、依赖、数据流、优先级,
|
||||
并向系统文档站交付目录映射与 MVP 闭环。配套:templates/architecture.md、
|
||||
templates/analysis.md(全局一份)、exemplars/stardew-architecture.md、exemplars/stardew-analysis.md(全局一份)。
|
||||
templates/analysis.md(全局一份)、exemplars/stardew-architecture.md、templates/stardew-analysis.md(全局一份)。
|
||||
---
|
||||
|
||||
# 系统架构写法(策划 agent · 系统架构分册)
|
||||
|
||||
@@ -5,7 +5,7 @@ name: game-gdd-concept
|
||||
description: 写游戏策划案(GDD)概念层时使用。把一句话游戏想法写成一份
|
||||
"一次写对、之后不动"的立项概念文档——它是后续所有设计争议的仲裁依据。
|
||||
任何游戏类型通用。配套:templates/concept-design.md、templates/analysis.md(全局一份)、
|
||||
exemplars/stardew-concept.md、exemplars/stardew-analysis.md(全局一份)。
|
||||
exemplars/stardew-concept.md、templates/stardew-analysis.md(全局一份)。
|
||||
---
|
||||
|
||||
# 概念层写法(策划 agent · 概念层分册)
|
||||
|
||||
@@ -5,7 +5,7 @@ name: game-gdd-top-design
|
||||
description: 写游戏策划案(GDD)顶层设计时使用。在概念层定稿之后,
|
||||
回答"玩家为什么一直玩"——把概念变成可玩的时间结构(循环/资源/取舍/节奏),
|
||||
并向架构层交付系统范围。配套:templates/top-design.md、templates/analysis.md(全局一份)、
|
||||
exemplars/stardew-top-design.md、exemplars/stardew-analysis.md(全局一份)。
|
||||
exemplars/stardew-top-design.md、templates/stardew-analysis.md(全局一份)。
|
||||
---
|
||||
|
||||
# 顶层设计写法(策划 agent · 顶层设计分册)
|
||||
@@ -45,7 +45,7 @@ description: 写游戏策划案(GDD)顶层设计时使用。在概念层定
|
||||
|
||||
| # | 节 | 是什么 | 为什么写 | 和谁咬合 |
|
||||
|---|---|---|---|---|
|
||||
| 1 | 顶层定位与规模锚点 | 承概念定稿 + "让玩家每天都在想"念头句 + 规模参数表(循环单位/段落/复杂度/长期主轴) | 循环单位定错全盘错;定位句防止顶层漂离概念 | 承概念层"概念定稿";念头句是概念层玩家念头的时间维度版 |
|
||||
| 1 | 顶层定位与规模锚点 | 承概念定稿 + 按需说明易混淆方向及排除理由 + "让玩家每天都在想"念头句 + 规模参数表(循环单位/段落/复杂度/长期主轴) | 循环单位定错全盘错;定位句防止顶层漂离概念 | 承概念层"概念定稿";念头句是概念层玩家念头的时间维度版 |
|
||||
| 2 | 设计目标 | 几种回报、如何互相供给 | 回报并列=小游戏拼盘;互相供给才是循环 | 供给关系落到 4~5 的循环里 |
|
||||
| 3 | 核心推动力 | 按项目实际存在的即时、阶段或长期推动力组织 | 玩家"什么时候被什么推着走"的推动结构 | 与实际节奏结构对应 |
|
||||
| 4 | 大循环 | 跨较长时间的循环:文字箭头 + 核心循环图 | 长期留存的结构骨架 | 与 5、7 三层互检:大循环的每环应有小循环供血 |
|
||||
@@ -88,6 +88,7 @@ description: 写游戏策划案(GDD)顶层设计时使用。在概念层定
|
||||
(本节是带写法要领的教学版;实际填写的纯净模板在 templates/top-design.md)
|
||||
|
||||
### 1. 顶层定位与规模锚点
|
||||
承接概念定稿说明核心定位;存在容易混淆的方向时,说明排除方向及理由,表述按项目需要组织。
|
||||
顶层设计让玩家每天都在想:
|
||||
> "__(玩家每天惦记的那件事)"
|
||||
规模锚点表:循环单位 / 段落构成 / 操作复杂度 / 经营复杂度 / 长期主轴排序。
|
||||
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
### C1 例子_星露谷_分析.md(分析金样;→ exemplars/stardew-analysis.md)
|
||||
### C1 例子_星露谷_分析.md(分析金样;→ templates/stardew-analysis.md)
|
||||
|
||||
# 分析:《星露谷物语》
|
||||
|
||||
|
||||
@@ -5,6 +5,9 @@
|
||||
# 顶层设计:《游戏名》
|
||||
|
||||
## 顶层定位与规模锚点
|
||||
核心定位:__。
|
||||
容易混淆的方向及排除理由(按需):__。
|
||||
|
||||
顶层设计让玩家每天都在想:
|
||||
> "__"
|
||||
|
||||
|
||||
@@ -7,12 +7,12 @@
|
||||
|
||||
正式策划文档在文档头部写明版本标记,例如“版本:v1”。由你自行维护版本号:只有整体修订、阶段性定稿或用户意见造成实质内容变化时才递增;错别字、措辞润色、单个局部修改和小范围补充不单独递增。
|
||||
|
||||
阶段审批是每个阶段的最终检查,将已完成的本阶段产物交给用户检阅。提交前,解决所有影响本阶段完成的关键问题,或明确说明它们不阻塞本阶段交付,并更新相关产物。可以保留不阻塞当前阶段的后续事项和待原型验证项。
|
||||
阶段审批是五个策划阶段各自的最终检查,将已完成的本阶段产物交给用户检阅。需要用户选择的关键问题先通过问询解决,阶段审批不承担问询功能。提交前,解决所有影响本阶段完成的关键问题,或明确说明它们不阻塞本阶段交付,并更新相关产物。可以保留不阻塞当前阶段的后续事项和待原型验证项。
|
||||
|
||||
过程文档用于记录关键依据、决定和待办。阶段内优先完成主要设计内容;只有稳定且影响后续工作的决定才需要同步到多个过程文档。阶段提交前,补齐影响验收的关键记录。
|
||||
过程文档用于记录关键依据、决定和待办,不要求实时完整,也不应重复正式设计文档。阶段内优先完成主要设计内容;只有稳定且影响后续工作的决定才需要同步到多个过程文档。阶段提交前,补齐影响验收的关键记录。
|
||||
|
||||
阶段获批后,产物中已经采用的方案作为后续工作的依据,并保留原有决策来源。用户主动质疑或出现新的约束冲突时,再重新讨论相关决定。
|
||||
|
||||
用户说“继续”时,继续推进当前阶段最有价值的工作。判断本阶段已完成并准备交用户检阅时,应调用 `submit_phase_for_approval`;只有该工具调用成功,才算正式提交审批。
|
||||
用户说“继续”时,继续推进当前阶段最有价值的工作。在五个策划阶段中,判断本阶段已完成并准备交用户检阅时,应调用 `submit_phase_for_approval`;只有该工具调用成功,才算正式提交审批。
|
||||
|
||||
用户口头表示已经批准或要求进入下一阶段时,先调用 `get_workflow_status` 确认 Runtime 当前阶段。只有用户批准正式审批请求后,Runtime 才会推进阶段;审批工具是推进阶段的唯一方式。
|
||||
|
||||
@@ -9,5 +9,5 @@
|
||||
{"type":"function","function":{"name":"write_file","description":"创建或覆盖工作目录内的 UTF-8 文本文件。path 使用相对路径。","parameters":{"type":"object","properties":{"path":{"type":"string"},"content":{"type":"string"}},"required":["path","content"],"additionalProperties":false}}},
|
||||
{"type":"function","function":{"name":"search_text","description":"在工作目录内搜索文本。","parameters":{"type":"object","properties":{"query":{"type":"string"},"path":{"type":"string"}},"required":["query"],"additionalProperties":false}}},
|
||||
{"type":"function","function":{"name":"ask_clarification","description":"向用户展示多选项问询澄清卡片,选项数2-4。多选一场景时优先使用本工具,其他场景可以纯文本进行问询。每轮最多调用一次。","parameters":{"type":"object","properties":{"question":{"type":"string"},"options":{"type":"array","items":{"type":"string"}}},"required":["question"],"additionalProperties":false}}},
|
||||
{"type":"function","function":{"name":"submit_phase_for_approval","description":"提交当前策划阶段供用户审批。当你判断当前阶段已经完成并准备交用户检阅时必须调用。用户批准后 Runtime 自动进入下一阶段。","parameters":{"type":"object","properties":{},"additionalProperties":false}}}
|
||||
{"type":"function","function":{"name":"submit_phase_for_approval","description":"提交五个策划阶段中的当前阶段供用户审批。当你判断当前阶段已经完成并准备交用户检阅时必须调用。用户批准后 Runtime 自动进入下一阶段。","parameters":{"type":"object","properties":{},"additionalProperties":false}}}
|
||||
]
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user