完成正式项目版本阶段六
扩展 manifest 正式迭代版本合同并强制历史记录追加不可变 接入版本卡、父子关系与绑定资源高亮 补齐共享契约、Tauri 存储与工作台回归测试 同步工作台 PRD、技术方案与项目记忆
This commit is contained in:
@@ -23,8 +23,9 @@ use reqwest::header;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use shared_contracts::game_creation_app::{
|
||||
new_game_creation_app_manifest, new_game_creation_app_seed_tasks,
|
||||
GameCreationAgentArtifactTrace, GameCreationAgentCapabilityDescriptor,
|
||||
GameCreationAgentPassPlanTrace, GameCreationAgentRepairRouteTrace, GameCreationAgentRunStep,
|
||||
validate_game_iteration_versions, GameCreationAgentArtifactTrace,
|
||||
GameCreationAgentCapabilityDescriptor, GameCreationAgentPassPlanTrace,
|
||||
GameCreationAgentRepairRouteTrace, GameCreationAgentRunStep,
|
||||
GameCreationAgentRunTaskGraphTrace, GameCreationAgentRunTrace, GameCreationAgentToolCallTrace,
|
||||
GameCreationAppAgentGroup, GameCreationAppAssetManifestEntry, GameCreationAppAssetSource,
|
||||
GameCreationAppAssetSourceKind, GameCreationAppCommandRunState,
|
||||
|
||||
@@ -631,8 +631,11 @@ pub(crate) fn read_manifest(path: &Path) -> Result<GameCreationAppManifest, Stri
|
||||
.open(source_path)
|
||||
.and_then(|mut file| file.read_to_string(&mut payload))
|
||||
.map_err(|error| format!("读取 {label} 失败:{}: {error}", source_path.display()))?;
|
||||
serde_json::from_str(&payload)
|
||||
.map_err(|error| format!("解析 {label} 失败:{}: {error}", source_path.display()))
|
||||
let manifest: GameCreationAppManifest = serde_json::from_str(&payload)
|
||||
.map_err(|error| format!("解析 {label} 失败:{}: {error}", source_path.display()))?;
|
||||
validate_game_iteration_versions(&manifest.versions)
|
||||
.map_err(|error| format!("校验 {label} 项目版本失败:{error}"))?;
|
||||
Ok(manifest)
|
||||
}
|
||||
|
||||
fn install_manifest_temp_with<F>(
|
||||
@@ -709,6 +712,20 @@ pub(crate) fn write_manifest(
|
||||
path: &Path,
|
||||
manifest: &GameCreationAppManifest,
|
||||
) -> Result<(), String> {
|
||||
validate_game_iteration_versions(&manifest.versions)
|
||||
.map_err(|error| format!("校验 manifest 项目版本失败:{error}"))?;
|
||||
if manifest_storage_exists(path)? {
|
||||
let existing = read_manifest(path)?;
|
||||
if existing.versions.len() > manifest.versions.len()
|
||||
|| existing
|
||||
.versions
|
||||
.iter()
|
||||
.zip(&manifest.versions)
|
||||
.any(|(existing, candidate)| existing != candidate)
|
||||
{
|
||||
return Err("项目版本记录写入后不可修改、删除或重排".to_string());
|
||||
}
|
||||
}
|
||||
let payload = serde_json::to_string_pretty(manifest)
|
||||
.map_err(|error| format!("序列化 manifest 失败:{error}"))?;
|
||||
if let Some(parent) = path.parent() {
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
use super::*;
|
||||
use shared_contracts::game_creation_app::{
|
||||
GameIterationVersion, GameIterationVersionCreatedReason, GameIterationVersionResourceBinding,
|
||||
};
|
||||
|
||||
fn unique_manifest_test_root(test_name: &str) -> PathBuf {
|
||||
std::env::temp_dir().join(format!(
|
||||
@@ -40,6 +43,59 @@ fn manifest_read_and_project_write_recover_previous_file() {
|
||||
fs::remove_dir_all(root).ok();
|
||||
}
|
||||
|
||||
fn version_fixture(
|
||||
version_id: &str,
|
||||
parent_version_id: Option<&str>,
|
||||
project_revision: u64,
|
||||
created_reason: GameIterationVersionCreatedReason,
|
||||
) -> GameIterationVersion {
|
||||
GameIterationVersion {
|
||||
version_id: version_id.to_string(),
|
||||
parent_version_id: parent_version_id.map(str::to_string),
|
||||
project_revision,
|
||||
resource_bindings: vec![GameIterationVersionResourceBinding {
|
||||
slot_id: "player".to_string(),
|
||||
resource_id: "asset-player".to_string(),
|
||||
}],
|
||||
created_reason,
|
||||
created_at: project_revision,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn manifest_versions_are_append_only_at_the_storage_boundary() {
|
||||
let root = unique_manifest_test_root("versions-append-only");
|
||||
let manifest_path = root.join(".agent/manifest.json");
|
||||
let mut manifest = new_game_creation_app_manifest("project-versioned", "版本项目");
|
||||
manifest.versions.push(version_fixture(
|
||||
"version-root",
|
||||
None,
|
||||
1,
|
||||
GameIterationVersionCreatedReason::Initial,
|
||||
));
|
||||
write_manifest(&manifest_path, &manifest).expect("write initial version");
|
||||
|
||||
manifest.versions.push(version_fixture(
|
||||
"version-child",
|
||||
Some("version-root"),
|
||||
2,
|
||||
GameIterationVersionCreatedReason::AgentRevision,
|
||||
));
|
||||
write_manifest(&manifest_path, &manifest).expect("append child version");
|
||||
|
||||
let stable_payload = fs::read(&manifest_path).expect("read stable manifest bytes");
|
||||
manifest.versions[0].resource_bindings[0].resource_id = "asset-mutated".to_string();
|
||||
let error =
|
||||
write_manifest(&manifest_path, &manifest).expect_err("reject mutation of existing version");
|
||||
assert!(error.contains("不可修改、删除或重排"), "{error}");
|
||||
assert_eq!(
|
||||
fs::read(&manifest_path).expect("read untouched manifest bytes"),
|
||||
stable_payload
|
||||
);
|
||||
|
||||
fs::remove_dir_all(root).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn manifest_install_uses_previous_when_direct_replace_fails() {
|
||||
let root = unique_manifest_test_root("replace-fallback");
|
||||
|
||||
@@ -4058,7 +4058,8 @@ iframe.preview-frame {
|
||||
|
||||
.game-resource-card.is-relation-upstream,
|
||||
.game-resource-card.is-relation-downstream,
|
||||
.game-resource-card.is-relation-both {
|
||||
.game-resource-card.is-relation-both,
|
||||
.game-resource-card.is-relation-version-binding {
|
||||
border-color: #d87342;
|
||||
box-shadow:
|
||||
0 8px 22px rgb(195 105 62 / 18%),
|
||||
|
||||
@@ -135,7 +135,6 @@ export type ProjectAgentRuntimeSummary = {
|
||||
|
||||
const emptyProjectAgentRuntimeSummaries: ProjectAgentRuntimeSummary[] = [];
|
||||
const emptyProjectAgentResults: ProjectAgentResultSummary[] = [];
|
||||
const emptyProjectVersions: ProjectVersionResourceSummary[] = [];
|
||||
const RESOURCE_DEPENDENCY_VISUAL_GUTTER = 64;
|
||||
|
||||
type AgentSummary = ProjectAgentRuntimeSummary;
|
||||
@@ -150,7 +149,6 @@ export type ProjectDevelopmentViewProps = {
|
||||
preview?: GameCreationAppPreviewState | null;
|
||||
agentRuntimeSummaries?: ProjectAgentRuntimeSummary[];
|
||||
agentResults?: ProjectAgentResultSummary[];
|
||||
projectVersions?: ProjectVersionResourceSummary[];
|
||||
supervisor: ReactNode;
|
||||
onHomeOpen: () => void;
|
||||
onProjectsOpen: () => void;
|
||||
@@ -255,6 +253,13 @@ function formatMediaDuration(duration: number | null) {
|
||||
return `${minutes}:${seconds.toString().padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
function formatVersionCreatedAt(createdAt: number) {
|
||||
const value = new Date(createdAt);
|
||||
return Number.isFinite(value.getTime())
|
||||
? value.toLocaleString('zh-CN')
|
||||
: String(createdAt);
|
||||
}
|
||||
|
||||
function SafeProjectMarkdown({ content }: { content: string }) {
|
||||
return (
|
||||
<ReactMarkdown
|
||||
@@ -358,7 +363,7 @@ const ResourceCard = memo(function ResourceCard({
|
||||
}: {
|
||||
resource: ProjectResource;
|
||||
selected: boolean;
|
||||
relationState: 'upstream' | 'downstream' | 'both' | null;
|
||||
relationState: 'upstream' | 'downstream' | 'both' | 'version-binding' | null;
|
||||
x: number;
|
||||
y: number;
|
||||
onSelect: (resourceId: string) => void;
|
||||
@@ -386,7 +391,13 @@ const ResourceCard = memo(function ResourceCard({
|
||||
</span>
|
||||
<strong>{resource.label}</strong>
|
||||
<small>{resource.sourceLabel}</small>
|
||||
<small>{resource.path}</small>
|
||||
<small>
|
||||
{resource.version
|
||||
? resource.version.childVersionIds.length > 0
|
||||
? `${resource.version.childVersionIds.length} 个直接子版本`
|
||||
: '暂无直接子版本'
|
||||
: resource.path}
|
||||
</small>
|
||||
</button>
|
||||
);
|
||||
});
|
||||
@@ -399,7 +410,6 @@ export default function ProjectDevelopmentView({
|
||||
preview: previewOverride,
|
||||
agentRuntimeSummaries = emptyProjectAgentRuntimeSummaries,
|
||||
agentResults = emptyProjectAgentResults,
|
||||
projectVersions = emptyProjectVersions,
|
||||
supervisor,
|
||||
}: ProjectDevelopmentViewProps) {
|
||||
const [mode, setMode] = useState<WorkbenchMode>('resources');
|
||||
@@ -443,14 +453,8 @@ export default function ProjectDevelopmentView({
|
||||
(task) => task.id === 'code-prototype' && task.status === 'completed',
|
||||
);
|
||||
const projectedResources = useMemo(
|
||||
() =>
|
||||
projectResourcesFromReadModels(
|
||||
manifest,
|
||||
attachments,
|
||||
agentResults,
|
||||
projectVersions,
|
||||
),
|
||||
[agentResults, attachments, manifest, projectVersions],
|
||||
() => projectResourcesFromReadModels(manifest, attachments, agentResults),
|
||||
[agentResults, attachments, manifest],
|
||||
);
|
||||
const resourceGraphInputs = useMemo<ProjectResourceGraphNodeInput[]>(
|
||||
() =>
|
||||
@@ -603,6 +607,26 @@ export default function ProjectDevelopmentView({
|
||||
() => projectResourceGraphNeighbors(resourceGraph, selectedResourceId),
|
||||
[resourceGraph, selectedResourceId],
|
||||
);
|
||||
const selectedVersionBindingResourceIds = useMemo(() => {
|
||||
const selectedVersion = resources.find(
|
||||
(resource) => resource.id === selectedResourceId,
|
||||
)?.version;
|
||||
if (!selectedVersion) {
|
||||
return new Set<string>();
|
||||
}
|
||||
const boundManifestAssetIds = new Set(
|
||||
selectedVersion.resourceBindings.map((binding) => binding.resourceId),
|
||||
);
|
||||
return new Set(
|
||||
resources
|
||||
.filter(
|
||||
(resource) =>
|
||||
resource.manifestAssetId !== null &&
|
||||
boundManifestAssetIds.has(resource.manifestAssetId),
|
||||
)
|
||||
.map((resource) => resource.id),
|
||||
);
|
||||
}, [resources, selectedResourceId]);
|
||||
const normalizedSearch = searchText.trim().toLowerCase();
|
||||
const visibleResources = useMemo(
|
||||
() =>
|
||||
@@ -1208,6 +1232,10 @@ export default function ProjectDevelopmentView({
|
||||
) : null}
|
||||
{focusedResource.version ? (
|
||||
<>
|
||||
<div>
|
||||
<dt>版本 ID</dt>
|
||||
<dd>{focusedResource.version.versionId}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>项目修订</dt>
|
||||
<dd>{focusedResource.version.projectRevision}</dd>
|
||||
@@ -1219,6 +1247,47 @@ export default function ProjectDevelopmentView({
|
||||
'首个版本'}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>直接子版本</dt>
|
||||
<dd>
|
||||
{focusedResource.version.childVersionIds.length > 0
|
||||
? focusedResource.version.childVersionIds.join('、')
|
||||
: '暂无'}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>创建原因</dt>
|
||||
<dd>
|
||||
{
|
||||
{
|
||||
initial: '初始版本',
|
||||
'resource-replacement': '资源替换',
|
||||
'agent-revision': 'Agent 修订',
|
||||
}[focusedResource.version.createdReason]
|
||||
}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>创建时间</dt>
|
||||
<dd>
|
||||
{formatVersionCreatedAt(
|
||||
focusedResource.version.createdAt,
|
||||
)}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>资源绑定</dt>
|
||||
<dd>
|
||||
{focusedResource.version.resourceBindings.length > 0
|
||||
? focusedResource.version.resourceBindings
|
||||
.map(
|
||||
(binding) =>
|
||||
`${binding.slotId} → ${binding.resourceId}`,
|
||||
)
|
||||
.join(';')
|
||||
: '暂无'}
|
||||
</dd>
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
</dl>
|
||||
@@ -1327,13 +1396,17 @@ export default function ProjectDevelopmentView({
|
||||
resource.id,
|
||||
);
|
||||
const relationState =
|
||||
upstream && downstream
|
||||
? 'both'
|
||||
: upstream
|
||||
? 'upstream'
|
||||
: downstream
|
||||
? 'downstream'
|
||||
: null;
|
||||
selectedVersionBindingResourceIds.has(
|
||||
resource.id,
|
||||
)
|
||||
? 'version-binding'
|
||||
: upstream && downstream
|
||||
? 'both'
|
||||
: upstream
|
||||
? 'upstream'
|
||||
: downstream
|
||||
? 'downstream'
|
||||
: null;
|
||||
return (
|
||||
<ResourceCard
|
||||
key={resource.id}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type {
|
||||
GameIterationVersion,
|
||||
GameCreationAppManifest,
|
||||
ProjectResourceCanvasSection,
|
||||
} from '../../../../../packages/shared/src/contracts/gameCreationApp';
|
||||
@@ -22,12 +23,9 @@ export type ProjectAgentResultSummary = {
|
||||
updatedAt: number;
|
||||
};
|
||||
|
||||
export type ProjectVersionResourceSummary = {
|
||||
versionId: string;
|
||||
export type ProjectVersionResourceSummary = GameIterationVersion & {
|
||||
label: string;
|
||||
projectRevision: number;
|
||||
parentVersionId: string | null;
|
||||
createdAt: number;
|
||||
childVersionIds: string[];
|
||||
};
|
||||
|
||||
export type ProjectResource = {
|
||||
@@ -112,7 +110,6 @@ export function projectResourcesFromReadModels(
|
||||
manifest: GameCreationAppManifest,
|
||||
attachments: ProjectAttachmentResult[],
|
||||
agentResults: ProjectAgentResultSummary[],
|
||||
projectVersions: ProjectVersionResourceSummary[] = [],
|
||||
) {
|
||||
const taskById = new Map(manifest.tasks.map((task) => [task.id, task]));
|
||||
const resources: ProjectResource[] = [];
|
||||
@@ -233,7 +230,22 @@ export function projectResourcesFromReadModels(
|
||||
});
|
||||
}
|
||||
|
||||
for (const version of projectVersions) {
|
||||
const childVersionIdsByParent = new Map<string, string[]>();
|
||||
for (const version of manifest.versions ?? []) {
|
||||
if (!version.parentVersionId) {
|
||||
continue;
|
||||
}
|
||||
const children = childVersionIdsByParent.get(version.parentVersionId) ?? [];
|
||||
children.push(version.versionId);
|
||||
childVersionIdsByParent.set(version.parentVersionId, children);
|
||||
}
|
||||
for (const [index, manifestVersion] of (manifest.versions ?? []).entries()) {
|
||||
const version: ProjectVersionResourceSummary = {
|
||||
...manifestVersion,
|
||||
label: `版本 ${index + 1}`,
|
||||
childVersionIds:
|
||||
childVersionIdsByParent.get(manifestVersion.versionId) ?? [],
|
||||
};
|
||||
resources.push({
|
||||
id: `version:${version.versionId}`,
|
||||
category: 'version',
|
||||
@@ -241,7 +253,9 @@ export function projectResourcesFromReadModels(
|
||||
label: version.label,
|
||||
path: `项目版本 · ${version.versionId}`,
|
||||
mediaType: '正式项目版本',
|
||||
sourceLabel: `项目修订 ${version.projectRevision}`,
|
||||
sourceLabel: version.parentVersionId
|
||||
? `项目修订 ${version.projectRevision} · 父版本 ${version.parentVersionId}`
|
||||
: `项目修订 ${version.projectRevision} · 初始版本`,
|
||||
taskTitle: null,
|
||||
manifestAssetId: null,
|
||||
producerTaskId: null,
|
||||
|
||||
@@ -679,6 +679,98 @@ export function registerProjectWorkbenchFoundationTests() {
|
||||
expect(screen.getByRole('article', { name: /发布 Agent/ })).not.toBeNull();
|
||||
});
|
||||
|
||||
it('renders immutable manifest versions, their parent graph, and bound asset highlights', async () => {
|
||||
const manifest = createGameCreationAppManifest(
|
||||
'workbench-versions',
|
||||
'版本工作台测试',
|
||||
);
|
||||
manifest.assets = [
|
||||
{
|
||||
id: 'asset-player',
|
||||
kind: 'character',
|
||||
mediaType: 'image/png',
|
||||
localPath: 'assets/player.png',
|
||||
source: { kind: 'generated' },
|
||||
},
|
||||
];
|
||||
manifest.versions = [
|
||||
{
|
||||
versionId: 'version-root',
|
||||
parentVersionId: null,
|
||||
projectRevision: 3,
|
||||
resourceBindings: [{ slotId: 'player', resourceId: 'asset-player' }],
|
||||
createdReason: 'initial',
|
||||
createdAt: 100,
|
||||
},
|
||||
{
|
||||
versionId: 'version-child',
|
||||
parentVersionId: 'version-root',
|
||||
projectRevision: 4,
|
||||
resourceBindings: [
|
||||
{ slotId: 'player', resourceId: 'asset-player' },
|
||||
{ slotId: 'historical', resourceId: 'asset-removed' },
|
||||
],
|
||||
createdReason: 'agent-revision',
|
||||
createdAt: 200,
|
||||
},
|
||||
];
|
||||
|
||||
render(
|
||||
React.createElement(ProjectDevelopmentView, {
|
||||
projectName: manifest.name,
|
||||
projectPath: '/tmp/workbench-versions',
|
||||
manifest,
|
||||
attachments: [],
|
||||
recentRunStatus: null,
|
||||
recentRunStopReason: null,
|
||||
supervisor: React.createElement('div', null, '项目总控'),
|
||||
onHomeOpen: vi.fn(),
|
||||
onProjectsOpen: vi.fn(),
|
||||
}),
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '按类型' }));
|
||||
const resourceCards = screen.getAllByTitle('打开资源详情');
|
||||
const rootVersionCard = resourceCards.find((card) =>
|
||||
card.textContent?.includes('版本 1'),
|
||||
);
|
||||
const childVersionCard = resourceCards.find((card) =>
|
||||
card.textContent?.includes('版本 2'),
|
||||
);
|
||||
expect(rootVersionCard?.textContent).toContain('1 个直接子版本');
|
||||
expect(childVersionCard?.textContent).toContain('父版本 version-root');
|
||||
|
||||
fireEvent.click(childVersionCard!);
|
||||
const versionFocus = screen.getByRole('region', { name: '版本 2' });
|
||||
expect(within(versionFocus).getByText('version-child')).not.toBeNull();
|
||||
expect(within(versionFocus).getByText('version-root')).not.toBeNull();
|
||||
expect(within(versionFocus).getByText('Agent 修订')).not.toBeNull();
|
||||
expect(
|
||||
within(versionFocus).getByText(
|
||||
'player → asset-player;historical → asset-removed',
|
||||
),
|
||||
).not.toBeNull();
|
||||
expect(screen.queryByText('asset:asset-removed')).toBeNull();
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '收起资源' }));
|
||||
const playerCard = screen
|
||||
.getAllByTitle('打开资源详情')
|
||||
.find((card) => card.textContent?.includes('player.png'));
|
||||
expect(playerCard?.classList.contains('is-relation-version-binding')).toBe(
|
||||
true,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '按依赖' }));
|
||||
await waitFor(() => {
|
||||
const dependencyPlayerCard = screen
|
||||
.getAllByTitle('打开资源详情')
|
||||
.find((card) => card.textContent?.includes('player.png'));
|
||||
expect(
|
||||
dependencyPlayerCard?.classList.contains('is-relation-version-binding'),
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
it('opens text receipts in the central focus state and restores the resource list context', () => {
|
||||
const manifest = createGameCreationAppManifest(
|
||||
'workbench-resource-details',
|
||||
|
||||
@@ -49,6 +49,18 @@ describe('项目资源投影', () => {
|
||||
source: { kind: 'generated' },
|
||||
},
|
||||
];
|
||||
manifest.versions = [
|
||||
{
|
||||
versionId: 'version-1',
|
||||
parentVersionId: null,
|
||||
projectRevision: 7,
|
||||
resourceBindings: [
|
||||
{ slotId: 'background-music', resourceId: 'registered-bgm' },
|
||||
],
|
||||
createdReason: 'initial',
|
||||
createdAt: 1,
|
||||
},
|
||||
];
|
||||
|
||||
const resources = projectResourcesFromReadModels(
|
||||
manifest,
|
||||
@@ -82,15 +94,6 @@ describe('项目资源投影', () => {
|
||||
updatedAt: 1,
|
||||
},
|
||||
],
|
||||
[
|
||||
{
|
||||
versionId: 'version-1',
|
||||
label: '首个可运行版本',
|
||||
projectRevision: 7,
|
||||
parentVersionId: null,
|
||||
createdAt: 1,
|
||||
},
|
||||
],
|
||||
);
|
||||
|
||||
expect(resources.map(({ id, category }) => ({ id, category }))).toEqual(
|
||||
@@ -134,6 +137,16 @@ describe('项目资源投影', () => {
|
||||
'stable-resource-id',
|
||||
'稳定资源身份测试',
|
||||
);
|
||||
manifest.versions = [
|
||||
{
|
||||
versionId: 'stable-version',
|
||||
parentVersionId: null,
|
||||
projectRevision: 1,
|
||||
resourceBindings: [],
|
||||
createdReason: 'initial',
|
||||
createdAt: 1,
|
||||
},
|
||||
];
|
||||
const first = projectResourcesFromReadModels(
|
||||
manifest,
|
||||
[],
|
||||
@@ -147,15 +160,6 @@ describe('项目资源投影', () => {
|
||||
updatedAt: 1,
|
||||
},
|
||||
],
|
||||
[
|
||||
{
|
||||
versionId: 'stable-version',
|
||||
label: '旧版本名',
|
||||
projectRevision: 1,
|
||||
parentVersionId: null,
|
||||
createdAt: 1,
|
||||
},
|
||||
],
|
||||
);
|
||||
const second = projectResourcesFromReadModels(
|
||||
manifest,
|
||||
@@ -170,17 +174,44 @@ describe('项目资源投影', () => {
|
||||
updatedAt: 2,
|
||||
},
|
||||
],
|
||||
[
|
||||
{
|
||||
versionId: 'stable-version',
|
||||
label: '新版本名',
|
||||
projectRevision: 1,
|
||||
parentVersionId: null,
|
||||
createdAt: 1,
|
||||
},
|
||||
],
|
||||
);
|
||||
|
||||
expect(first.map(({ id }) => id)).toEqual(second.map(({ id }) => id));
|
||||
});
|
||||
|
||||
it('只从 manifest 投影版本并保留直接父子关系', () => {
|
||||
const manifest = createGameCreationAppManifest(
|
||||
'version-projection',
|
||||
'版本投影测试',
|
||||
);
|
||||
manifest.versions = [
|
||||
{
|
||||
versionId: 'version-root',
|
||||
parentVersionId: null,
|
||||
projectRevision: 2,
|
||||
resourceBindings: [],
|
||||
createdReason: 'initial',
|
||||
createdAt: 100,
|
||||
},
|
||||
{
|
||||
versionId: 'version-child',
|
||||
parentVersionId: 'version-root',
|
||||
projectRevision: 3,
|
||||
resourceBindings: [],
|
||||
createdReason: 'agent-revision',
|
||||
createdAt: 200,
|
||||
},
|
||||
];
|
||||
|
||||
const versions = projectResourcesFromReadModels(manifest, [], []).filter(
|
||||
(resource) => resource.category === 'version',
|
||||
);
|
||||
|
||||
expect(versions.map((version) => version.label)).toEqual([
|
||||
'版本 1',
|
||||
'版本 2',
|
||||
]);
|
||||
expect(versions[0]?.version?.childVersionIds).toEqual(['version-child']);
|
||||
expect(versions[1]?.version?.parentVersionId).toBe('version-root');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# AI 游戏创作项目开发工作台 PRD
|
||||
|
||||
更新时间:`2026-08-03`
|
||||
更新时间:`2026-08-03`(阶段六)
|
||||
|
||||
## 1. 产品定位
|
||||
|
||||
@@ -126,11 +126,11 @@ idle -> focused(document|art|audio|version) -> idle
|
||||
- 文档:合法 Agent 文本回执直接使用对话投影内容;项目文件只允许读取当前 manifest 已登记资产或已完成任务产物中的 Markdown、文本、JSON、YAML、TOML,必须经过 `file.read` auto 权限、相对路径、项目边界、普通文件、符号链接 / 硬链接、读取漂移、2 MiB、UTF-8 与扩展名白名单校验。正文使用不执行 HTML、不加载远程图片、不产生可点击外链的安全 Markdown 渲染,并在中央画布内独立滚动;读取失败显示错误空态。
|
||||
- 美术:PNG、JPEG、WEBP 继续使用图片魔数与像素边界预览;GIF、SVG、AVIF、BMP、MP4、WebM、MOV 通过新增受控媒体读取链路按文件签名校验后在中央画布放大聚焦。SVG 额外拒绝脚本、事件处理器、外部资源引用和实体声明;视频使用内置播放控件。读取失败显示错误空态。
|
||||
- 音频:只读取 manifest 已登记音频或已成功导入且登记到 manifest 的附件,按文件签名接受 MP3、WAV、OGG / Opus、M4A、AAC、FLAC;聚焦态展示实际格式、浏览器解码后的时长以及带播放进度和暂停能力的内置播放器。音频任务声明中的未登记路径继续不得读取或播放。
|
||||
- 版本:只展示正式版本 read model 的身份与修订元数据;版本引用高亮和替换留给后续切片。
|
||||
- 版本:只展示 manifest 中正式、不可变的迭代版本记录;版本卡展示项目修订、创建原因与父版本,聚焦态同时展示直接子版本和资源绑定。点击版本卡后高亮仍存在于当前资源投影中的引用资源;缺失历史资源只保留绑定身份,不生成幽灵资源卡。资源替换仍留给后续切片。
|
||||
- mentor 最新决定:资源聚焦不提供工具栏,也不提供工具侧边栏。
|
||||
- 点击资源后,中央主视窗从 `resources.list` 切换为 `resources.focused.document / art / audio / version`,左侧平台导航、右侧 Supervisor 对话和底部 Agent 状态栏保持原位;聚焦容器只包含标题、资源主体、必要元数据与右上角收起按钮,不使用页面级浮层或可拖动标题栏。
|
||||
- 退出聚焦后恢复进入前的搜索条件、dependency / type 布局模式、资源画布滚动位置和选中资源;这些只属于当前前端会话,不写入布局 sidecar。
|
||||
- 阶段四只新增上述受控读取与媒体展示,不新增资源聚焦工具栏 / 工具侧边栏,不新增美术编辑、音频编辑 / 替换、资源重新生成、不可变版本写入或运行模块。飞书原需求中“编辑并生成新资源”的条件项仍暂缓,不能只打开画板却缺少回写、`referenceResourceIds` 血缘登记、新资源自动选中与邻近布局的完整闭环。
|
||||
- 阶段四只新增上述受控读取与媒体展示;阶段六在同一聚焦容器内补齐正式版本只读展示和引用高亮,但不新增资源聚焦工具栏 / 工具侧边栏,不新增美术编辑、音频编辑 / 替换、资源重新生成、版本替换或运行模块。飞书原需求中“编辑并生成新资源”的条件项仍暂缓,不能只打开画板却缺少回写、`referenceResourceIds` 血缘登记、新资源自动选中与邻近布局的完整闭环。
|
||||
|
||||
### 4.4 历史成果与当前状态
|
||||
|
||||
@@ -307,19 +307,29 @@ type ProjectVersionResourceReplacement = {
|
||||
|
||||
### 5.4 游戏迭代版本(P1)
|
||||
|
||||
阶段六实现状态(2026-08-03):正式版本业务真相扩展在本地项目 `.agent/manifest.json` 的可选 `versions` 字段中;旧项目字段缺失时等价于空列表,不根据 checkpoint、布局 sidecar、预览记录或 `game-creator-project-revision.v1` 自动伪造版本。版本数组只允许追加,已有记录不得删除、重排或修改;首轮没有版本创建按钮,也不自动把当前编辑态登记为版本。
|
||||
|
||||
```ts
|
||||
type GameIterationVersion = {
|
||||
versionId: string;
|
||||
parentVersionId: string | null;
|
||||
projectRevision: number;
|
||||
resourceBindings: Array<{ slotId: string; resourceId: string }>;
|
||||
parameterSnapshotId: string;
|
||||
createdReason: 'initial' | 'resource-replacement' | 'agent-revision';
|
||||
createdAt: number;
|
||||
};
|
||||
|
||||
type GameCreationAppManifest = {
|
||||
// 既有字段省略
|
||||
versions?: GameIterationVersion[];
|
||||
};
|
||||
```
|
||||
|
||||
版本写入后不可修改。
|
||||
- `versions` 按追加顺序保存。第一条必须是 `initial + parentVersionId=null`;后续记录必须引用数组中更早出现的父版本,创建原因不能再是 `initial`,从而天然排除自引用、悬空父版本和父子环。
|
||||
- `projectRevision` 与 `createdAt` 必须是 JavaScript 安全非负整数;子版本的修订必须严格大于父版本,创建时间不得早于父版本。
|
||||
- 同一版本内 `slotId` 唯一;`resourceId` 固定保存 manifest asset ID,不保存资源卡显示名称、External Editor resource ID、路径或布局 ID。历史资源已不在当前 manifest 时仍保留原绑定,但界面不为其合成资源卡。
|
||||
- Tauri manifest 存储边界在每次写入前校验完整版本图,并与磁盘中的旧 `versions` 前缀逐项比较;只允许追加新记录,已有记录被修改、删除或重排时写入失败且原文件保持不变。
|
||||
- 版本卡标题由稳定追加序号生成,卡片与聚焦态展示 `versionId / projectRevision / createdReason / parentVersionId`;聚焦态额外展示直接子版本和全部 slot 绑定。点击版本卡只高亮当前投影中唯一匹配 `asset:<resourceId>` 的资源卡,不修改版本或资源。
|
||||
|
||||
### 5.5 测试切片与数值参数(P2)
|
||||
|
||||
@@ -381,7 +391,7 @@ type ProjectAgentMudPointAttribution = {
|
||||
|
||||
- 已实施依赖/类型两套坐标持久化、首次默认不重叠布局、历史坐标跨重启恢复与自动协调 CAS 冲突处理;资源卡手动拖动暂缓。
|
||||
- 资源关系线在布局持久化验收通过后单独实施,不与本切片捆绑伪造完成。
|
||||
- 版本资源高亮、兼容性判断和不可变下一迭代版本。
|
||||
- 已实施正式版本只读模型、版本卡、父子关系与引用资源高亮;资源兼容性判断和不可变下一迭代版本创建仍待后续切片。
|
||||
- 美术/音频编辑状态接线。
|
||||
|
||||
### P2
|
||||
@@ -426,9 +436,18 @@ type ProjectAgentMudPointAttribution = {
|
||||
7. 4096 资源链式 fixture 继续验证拓扑、聚合复杂度和自动布局性能;拖动局部更新与真实 Chromium 拖动帧预算暂缓,不作为当前验收条件。最右侧自环与箭头仍需完整显示。
|
||||
8. Rust 图读取延迟时,dependency sidecar 在图进入 `ready / failed` 前没有读取或写入;首次布局直接使用 Rust 返回的最终 producer 与 dependency depth。重新打开旧布局时手动位置逐项不变,自动位置按最终拓扑协调且相同结果不增加 revision。
|
||||
|
||||
### 7.4 P1 正式项目版本阶段六验收
|
||||
|
||||
1. manifest 缺少 `versions` 时旧项目正常打开且不显示伪造版本;存在合法记录时,固定“项目版本”分区按追加顺序显示稳定版本卡。
|
||||
2. 根版本、父版本和直接子版本关系在卡片或聚焦态可见;悬空父版本、自引用、重复 ID、非递增修订、倒退时间、重复 slot 和超限数字均失败关闭。
|
||||
3. 点击版本卡后,当前 manifest 中仍存在的绑定资产卡被高亮;历史已删除资产只在版本详情保留 ID,不创建幽灵卡,也不把 External Editor resource ID 猜成 manifest asset ID。
|
||||
4. 版本聚焦态只读展示身份、修订、创建原因、父子关系、创建时间和 slot 绑定,不提供编辑、替换、切换、回滚或运行按钮。
|
||||
5. 任意现有 manifest 写入只能保留磁盘版本前缀并追加新记录;修改、删除或重排已有版本时写入失败,原 manifest 字节不被覆盖。
|
||||
6. 版本选择和高亮不写 manifest、布局 sidecar 或 project revision;dependency / type 两种布局都可显示绑定高亮,既有依赖关系 SVG 语义不变。
|
||||
|
||||
## 8. 非目标
|
||||
|
||||
- 本切片不实现资源卡手动拖动,也不实现资源聚焦工具栏、资源聚焦工具侧边栏、美术编辑、音频编辑 / 替换、资源重新生成、资源替换、不可变迭代版本写入、运行模块扩展、测试切片、数值参数或泥点归因;已实现的资源读取和关系图只提供 Rust 只读模型与前端派生展示,不建立新的资源业务真相。
|
||||
- 阶段六不实现资源卡手动拖动,也不实现资源聚焦工具栏、资源聚焦工具侧边栏、美术编辑、音频编辑 / 替换、资源重新生成、资源替换、下一迭代版本创建入口、运行版本切换、版本回滚、运行模块扩展、测试切片、运行态消费版本、数值参数或泥点归因。正式版本记录已经成为 manifest 业务真相,但本阶段只读取、校验和展示已有记录。
|
||||
- 本切片不持久化资源聚焦状态、画布缩放 / 平移、搜索条件、筛选条件或当前 mode;聚焦退出时的列表上下文恢复只限当前前端会话,这些状态如需跨重启保存必须另行扩展合同,不能塞入 `game-creator-resource-layout.v1`。
|
||||
- 不修改 SpacetimeDB schema。
|
||||
- 不开放普通用户 Agent.md/Skill。
|
||||
|
||||
@@ -1,5 +1,13 @@
|
||||
# 决策记录
|
||||
|
||||
## 2026-08-03 正式项目版本阶段六落在 manifest 追加不可变记录
|
||||
|
||||
- 背景:阶段一至五已经完成资源卡禁拖、固定分类投影、中央聚焦、依赖关系图和引用深度排列,但“项目版本”仍只能接受未接线的前端 read model;checkpoint、布局 sidecar 和项目 mutation revision 都不能代表正式可追溯版本。
|
||||
- 决策:本地 `.agent/manifest.json` 新增可选 `versions` 数组,旧项目缺失时只读为空。版本父子图使用父先于子的追加序列,Rust 在读写边界验证完整合同,并在覆盖 manifest 前要求已有磁盘版本是新版本数组的相等前缀,以此禁止修改、删除和重排。前端只从 manifest 投影版本卡;绑定 `resourceId` 固定解释为 manifest asset ID,点击版本在两种布局中高亮仍存在的资产卡。
|
||||
- 边界:阶段六只实现版本卡、父子关系、聚焦详情、引用资源高亮和不可变存储门禁;不自动回填版本,不创建下一版本,不做资源替换、运行版本切换、回滚、测试切片或运行态消费。SpacetimeDB、checkpoint、project revision 与布局 sidecar 均不改变。
|
||||
- 验证方式:共享 Rust / TypeScript 契约测试覆盖 camelCase 与缺省兼容;Tauri manifest 测试覆盖合法追加和历史修改拒绝;前端资源投影与 AppSurface 覆盖版本卡、父子详情、缺失历史资产和 dependency / type 绑定高亮,并运行 shell typecheck、编码检查与 `git diff --check`。
|
||||
- 关联文档:`docs/prd/【AI游戏创作】项目开发工作台PRD-2026-07-20.md`、`docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md`。
|
||||
|
||||
## 2026-08-03 资源依赖阶段五以引用 SCC 深度驱动自动排列
|
||||
|
||||
- 背景:资源关系图已经能展示精确引用与聚合任务流,但 dependency 自动布局只消费可信 producer 对应的任务 DAG 深度;同一任务生成的派生资源、没有 producer 审计的 manifest 资源和资源引用环均无法稳定体现“被引用资源在前、引用资源在后”的顺序。
|
||||
|
||||
@@ -360,7 +360,7 @@ game-project/
|
||||
|
||||
- 页面骨架固定为左侧现有全局导航、中间主视窗、右侧陶泥儿对话和底部子 Agent 状态栏;不新建第二套客户端或平行项目页。
|
||||
- 中间主视窗提供 `资源管理 / 运行` 切换。`code-prototype` 任务完成前运行入口保持视觉不可用,但仍可点击查看“当前无可运行版本”,不能使用会阻断说明交互的原生 `disabled` 或 `aria-disabled`;完成后才允许进入运行表现层。切回资源管理只修改前端展示态,不伪造后端预览暂停结果。
|
||||
- 资源管理从当前 `GameCreationAppManifest`、合法 Agent 文本回执、已导入附件和显式项目版本 read model 派生资源,固定按文档、项目版本、美术资源、音乐音效资源分区;未知任务产物不再兜底为版本,任务声明中的未登记音频也不冒充正式音频。`按依赖 / 按类型` 使用各自前端排列,dependency 模式额外绘制当前 manifest 与资源投影可证明的依赖关系。排列与图层都不写回 manifest,不能推断或伪造缺失依赖。
|
||||
- 资源管理从当前 `GameCreationAppManifest`(包含可选 `versions`)、合法 Agent 文本回执和已导入附件派生资源,固定按文档、项目版本、美术资源、音乐音效资源分区;未知任务产物不再兜底为版本,任务声明中的未登记音频也不冒充正式音频。`按依赖 / 按类型` 使用各自前端排列,dependency 模式额外绘制当前 manifest 与资源投影可证明的依赖关系。排列与图层都不写回 manifest,不能推断或伪造缺失依赖。
|
||||
- 资源卡支持点击聚焦、搜索和类型筛选。2026-07-28 起完成两套二维坐标与本地 CAS sidecar;2026-07-31 起 dependency 模式增加不持久化的原生 SVG 关系图层。2026-08-03 mentor 决定暂缓资源卡拖动,当前卡片不挂载 Pointer Down / Move / Up / Cancel 拖动入口,只允许自动布局和点击聚焦。聚焦态替换中央主视窗内容,保留左侧导航、右侧对话和底部 Agent 状态栏,退出后恢复搜索、布局模式、滚动位置与选中资源;不提供工具栏、工具侧边栏或可拖动标题栏。阶段四已补齐安全本地文档、扩展美术媒体与音频聚焦,正文独立滚动,视频 / 音频使用内置媒体控件,失败显示空态;美术编辑、音频编辑 / 替换、版本替换或运行模块仍不在本阶段。
|
||||
- 运行表现层首版直接嵌入当前项目的 loopback 游戏画面,并展示上一项 / 暂停继续 / 下一项切片控制、素材信息和数值微调面板。`preview.start` 启动本地 server 后把真实 URL 回写工作台,`preview.open` 只激活客户端内运行视图,不再调用系统浏览器;切片、参数调整和自然语言新增调节项首版仍只保留本地 UI 草稿,不修改代码或 manifest。
|
||||
- 右侧继续复用现有 Project Supervisor 会话、Runtime 澄清和确认链路;输入区展示 `严格审批 / 风险审批 / 无需审批` 独立面板。P0 只有严格审批可选;风险审批和无需审批保持视觉不可用但允许点击查看原因,不替代 Runtime 的逐动作权限、确认、sandbox 或 reconciliation 门禁。风险 Rank 算法记录在 `docs/project-memory/todos/【待解决】AI游戏创作高风险审批Rank-2026-07-20.md`,前端不得自行计算。
|
||||
@@ -410,6 +410,10 @@ game-project/
|
||||
|
||||
2026-08-03 阶段五加固:Rust read model 把 producer assignment 与布局深度分离。完整任务图先经 SCC 压缩形成可信 producer 的任务深度下限,精确资源引用图再经迭代式 SCC 压缩和确定性最长层级传播形成所有可见资源的 `dependencyDepths`;因此同一任务的派生资源、缺少 producer 审计的 manifest 资源和引用环都能稳定满足“被引用资源在前、引用资源在后”,没有引用关系的资源保持深度 `0`。前端不递归推导正式依赖层级。dependency 模式以 scope 化 `idle / loading / ready / failed` 状态阻断布局 Hook;图终态前不创建 fallback、不读取或写入 sidecar,图失败只以空图初始化一次。读取已有 dependency 布局时保留全部 `manuallyPlaced=true` 坐标,把 `manuallyPlaced=false` 作为可派生自动位置按最终深度重新协调;结果未变化时不写入。任务流仍按任务对聚合,不为布局计算或 SVG 绘制生成资源笛卡尔积。
|
||||
|
||||
2026-08-03 阶段六:正式迭代版本直接扩展本地 `.agent/manifest.json`,不新增 checkpoint / layout sidecar / SpacetimeDB 平行业务真相。共享 Rust / TypeScript 合同新增可选 `versions: GameIterationVersion[]`;旧项目缺失字段时只读为空,不回填。Rust 在 manifest 读写边界校验版本唯一性、父先于子、根/原因一致、父子修订与时间单调、slot 唯一和 JavaScript 安全整数,并在覆盖已有 manifest 前要求磁盘版本数组是新数组的逐项相等前缀,从存储边界保证历史记录不可修改、删除或重排。
|
||||
|
||||
工作台资源投影只从 `manifest.versions` 构建版本卡,按数组追加顺序生成稳定“版本 N”标题;不再接收前端独立 `projectVersions` 注入。`resourceBindings.resourceId` 只解释为 manifest asset ID,并映射到现有 `asset:<id>` 卡片。选中版本后在 dependency / type 两种布局中高亮当前仍存在的绑定资产;缺失历史资产只留在版本聚焦详情,不能合成幽灵卡或猜测 External Editor resource ID。版本聚焦复用中央只读容器,展示身份、修订、原因、父版本、直接子版本、创建时间与 slot 绑定。本阶段不提供版本创建、替换、切换、回滚、测试切片或运行态消费入口。
|
||||
|
||||
历史命令式 drag preview 句柄与局部连接索引可以保留,但项目工作台不再向资源卡传入该入口。拖动热路径、4096 张真实卡片拖动重渲染和 Chromium p95 门槛统一暂缓;当前回归只要求 Pointer Move 不改变卡片坐标、SVG path 或布局 revision。`ResizeObserver` 仍保持单图层单实例,任何实时 DOM 几何都不得通过 Tauri IPC 往返 Rust。
|
||||
|
||||
## 分阶段实施
|
||||
|
||||
@@ -638,6 +638,16 @@ describe('AI 游戏创作 App 共享契约', () => {
|
||||
updatedAt: 123,
|
||||
},
|
||||
],
|
||||
versions: [
|
||||
{
|
||||
versionId: 'version-1',
|
||||
parentVersionId: null,
|
||||
projectRevision: 7,
|
||||
resourceBindings: [{ slotId: 'player', resourceId: 'asset-player' }],
|
||||
createdReason: 'initial',
|
||||
createdAt: 456,
|
||||
},
|
||||
],
|
||||
assets: [
|
||||
{
|
||||
id: 'asset-player',
|
||||
@@ -678,6 +688,16 @@ describe('AI 游戏创作 App 共享契约', () => {
|
||||
logPath: '.agent/logs/command.log',
|
||||
},
|
||||
],
|
||||
versions: [
|
||||
{
|
||||
versionId: 'version-1',
|
||||
parentVersionId: null,
|
||||
projectRevision: 7,
|
||||
resourceBindings: [{ slotId: 'player', resourceId: 'asset-player' }],
|
||||
createdReason: 'initial',
|
||||
createdAt: 456,
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -537,6 +537,25 @@ export interface GameCreationAppCommandRunState {
|
||||
updatedAt: number;
|
||||
}
|
||||
|
||||
export type GameIterationVersionCreatedReason =
|
||||
| 'initial'
|
||||
| 'resource-replacement'
|
||||
| 'agent-revision';
|
||||
|
||||
export interface GameIterationVersionResourceBinding {
|
||||
slotId: string;
|
||||
resourceId: string;
|
||||
}
|
||||
|
||||
export interface GameIterationVersion {
|
||||
versionId: string;
|
||||
parentVersionId: string | null;
|
||||
projectRevision: number;
|
||||
resourceBindings: GameIterationVersionResourceBinding[];
|
||||
createdReason: GameIterationVersionCreatedReason;
|
||||
createdAt: number;
|
||||
}
|
||||
|
||||
export interface GameCreationAppManifest {
|
||||
schemaVersion: string;
|
||||
projectId: string;
|
||||
@@ -546,6 +565,7 @@ export interface GameCreationAppManifest {
|
||||
assets: GameCreationAppAssetManifestEntry[];
|
||||
preview?: GameCreationAppPreviewState | null;
|
||||
commandRuns?: GameCreationAppCommandRunState[];
|
||||
versions?: GameIterationVersion[];
|
||||
}
|
||||
|
||||
export interface GameCreationAgentToolCallTrace {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashSet;
|
||||
use std::collections::{HashMap, HashSet};
|
||||
|
||||
pub const GAME_CREATION_APP_MANIFEST_SCHEMA_VERSION: &str = "game-creation-app.manifest.v1";
|
||||
pub const GAME_CREATION_AGENT_RUN_SCHEMA_VERSION: &str = "game-creator-agent-run.v1";
|
||||
@@ -611,6 +611,142 @@ pub struct GameCreationAppCommandRunState {
|
||||
pub updated_at: u64,
|
||||
}
|
||||
|
||||
pub const GAME_ITERATION_VERSION_MAX_COUNT: usize = 4096;
|
||||
pub const GAME_ITERATION_VERSION_MAX_BINDING_COUNT: usize = 4096;
|
||||
pub const GAME_ITERATION_VERSION_MAX_SAFE_INTEGER: u64 = 9_007_199_254_740_991;
|
||||
|
||||
#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
|
||||
#[serde(rename_all = "kebab-case")]
|
||||
pub enum GameIterationVersionCreatedReason {
|
||||
Initial,
|
||||
ResourceReplacement,
|
||||
AgentRevision,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct GameIterationVersionResourceBinding {
|
||||
pub slot_id: String,
|
||||
pub resource_id: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct GameIterationVersion {
|
||||
pub version_id: String,
|
||||
pub parent_version_id: Option<String>,
|
||||
pub project_revision: u64,
|
||||
pub resource_bindings: Vec<GameIterationVersionResourceBinding>,
|
||||
pub created_reason: GameIterationVersionCreatedReason,
|
||||
pub created_at: u64,
|
||||
}
|
||||
|
||||
fn validate_iteration_version_id(value: &str, label: &str, max_chars: usize) -> Result<(), String> {
|
||||
if value.is_empty() || value.trim() != value {
|
||||
return Err(format!("{label}不能为空或包含首尾空白"));
|
||||
}
|
||||
if value.chars().count() > max_chars || value.chars().any(char::is_control) {
|
||||
return Err(format!("{label}无效"));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn validate_game_iteration_versions(versions: &[GameIterationVersion]) -> Result<(), String> {
|
||||
if versions.len() > GAME_ITERATION_VERSION_MAX_COUNT {
|
||||
return Err(format!(
|
||||
"项目版本最多支持 {GAME_ITERATION_VERSION_MAX_COUNT} 条"
|
||||
));
|
||||
}
|
||||
|
||||
let mut previous_versions = HashMap::<&str, (u64, u64)>::new();
|
||||
for (index, version) in versions.iter().enumerate() {
|
||||
validate_iteration_version_id(&version.version_id, "版本 ID", 128)?;
|
||||
if previous_versions.contains_key(version.version_id.as_str()) {
|
||||
return Err(format!("项目版本 ID 重复:{}", version.version_id));
|
||||
}
|
||||
if version.project_revision > GAME_ITERATION_VERSION_MAX_SAFE_INTEGER {
|
||||
return Err(format!(
|
||||
"项目版本 {} 的 projectRevision 超出 JavaScript 安全整数范围",
|
||||
version.version_id
|
||||
));
|
||||
}
|
||||
if version.created_at > GAME_ITERATION_VERSION_MAX_SAFE_INTEGER {
|
||||
return Err(format!(
|
||||
"项目版本 {} 的 createdAt 超出 JavaScript 安全整数范围",
|
||||
version.version_id
|
||||
));
|
||||
}
|
||||
|
||||
if index == 0 {
|
||||
if version.parent_version_id.is_some()
|
||||
|| version.created_reason != GameIterationVersionCreatedReason::Initial
|
||||
{
|
||||
return Err(format!(
|
||||
"项目版本 {} 的首条记录必须是无父版本的 initial 版本",
|
||||
version.version_id
|
||||
));
|
||||
}
|
||||
} else {
|
||||
if version.created_reason == GameIterationVersionCreatedReason::Initial {
|
||||
return Err(format!(
|
||||
"项目版本 {} 只有首个版本可以使用 initial 创建原因",
|
||||
version.version_id
|
||||
));
|
||||
}
|
||||
let Some(parent_version_id) = &version.parent_version_id else {
|
||||
return Err(format!(
|
||||
"项目版本 {} 只有首个 initial 版本可以没有父版本",
|
||||
version.version_id
|
||||
));
|
||||
};
|
||||
validate_iteration_version_id(parent_version_id, "父版本 ID", 128)?;
|
||||
let Some((parent_revision, parent_created_at)) =
|
||||
previous_versions.get(parent_version_id.as_str())
|
||||
else {
|
||||
return Err(format!(
|
||||
"项目版本 {} 的父版本必须先于子版本存在",
|
||||
version.version_id
|
||||
));
|
||||
};
|
||||
if version.project_revision <= *parent_revision {
|
||||
return Err(format!(
|
||||
"项目版本 {} 的 projectRevision 必须大于父版本",
|
||||
version.version_id
|
||||
));
|
||||
}
|
||||
if version.created_at < *parent_created_at {
|
||||
return Err(format!(
|
||||
"项目版本 {} 的 createdAt 不能早于父版本",
|
||||
version.version_id
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
if version.resource_bindings.len() > GAME_ITERATION_VERSION_MAX_BINDING_COUNT {
|
||||
return Err(format!(
|
||||
"项目版本 {} 的资源绑定最多支持 {GAME_ITERATION_VERSION_MAX_BINDING_COUNT} 项",
|
||||
version.version_id
|
||||
));
|
||||
}
|
||||
let mut slot_ids = HashSet::new();
|
||||
for binding in &version.resource_bindings {
|
||||
validate_iteration_version_id(&binding.slot_id, "版本资源槽位 ID", 256)?;
|
||||
validate_iteration_version_id(&binding.resource_id, "版本资源 ID", 512)?;
|
||||
if !slot_ids.insert(binding.slot_id.as_str()) {
|
||||
return Err(format!(
|
||||
"项目版本 {} 的资源槽位重复:{}",
|
||||
version.version_id, binding.slot_id
|
||||
));
|
||||
}
|
||||
}
|
||||
previous_versions.insert(
|
||||
version.version_id.as_str(),
|
||||
(version.project_revision, version.created_at),
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct GameCreationAppManifest {
|
||||
@@ -626,6 +762,8 @@ pub struct GameCreationAppManifest {
|
||||
pub preview: Option<GameCreationAppPreviewState>,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub command_runs: Vec<GameCreationAppCommandRunState>,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub versions: Vec<GameIterationVersion>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
|
||||
@@ -757,6 +895,7 @@ pub fn new_game_creation_app_manifest(
|
||||
assets: Vec::new(),
|
||||
preview: None,
|
||||
command_runs: Vec::new(),
|
||||
versions: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1594,6 +1733,17 @@ mod tests {
|
||||
log_path: ".agent/logs/command.log".to_string(),
|
||||
updated_at: 123,
|
||||
});
|
||||
manifest.versions.push(GameIterationVersion {
|
||||
version_id: "version-1".to_string(),
|
||||
parent_version_id: None,
|
||||
project_revision: 7,
|
||||
resource_bindings: vec![GameIterationVersionResourceBinding {
|
||||
slot_id: "player".to_string(),
|
||||
resource_id: "asset-player".to_string(),
|
||||
}],
|
||||
created_reason: GameIterationVersionCreatedReason::Initial,
|
||||
created_at: 456,
|
||||
});
|
||||
manifest.assets.push(GameCreationAppAssetManifestEntry {
|
||||
id: "asset-player".to_string(),
|
||||
kind: "character".to_string(),
|
||||
@@ -1630,6 +1780,95 @@ mod tests {
|
||||
payload["assets"][0]["source"]["canvasProjectId"],
|
||||
json!("canvas-project-1")
|
||||
);
|
||||
assert_eq!(payload["versions"][0]["versionId"], json!("version-1"));
|
||||
assert_eq!(
|
||||
payload["versions"][0]["resourceBindings"][0]["slotId"],
|
||||
json!("player")
|
||||
);
|
||||
assert_eq!(payload["versions"][0]["createdReason"], json!("initial"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn iteration_versions_require_an_append_ordered_parent_graph() {
|
||||
let root = GameIterationVersion {
|
||||
version_id: "version-root".to_string(),
|
||||
parent_version_id: None,
|
||||
project_revision: 4,
|
||||
resource_bindings: vec![GameIterationVersionResourceBinding {
|
||||
slot_id: "player".to_string(),
|
||||
resource_id: "asset-player".to_string(),
|
||||
}],
|
||||
created_reason: GameIterationVersionCreatedReason::Initial,
|
||||
created_at: 100,
|
||||
};
|
||||
let child = GameIterationVersion {
|
||||
version_id: "version-child".to_string(),
|
||||
parent_version_id: Some(root.version_id.clone()),
|
||||
project_revision: 5,
|
||||
resource_bindings: Vec::new(),
|
||||
created_reason: GameIterationVersionCreatedReason::AgentRevision,
|
||||
created_at: 101,
|
||||
};
|
||||
|
||||
validate_game_iteration_versions(&[root.clone(), child.clone()])
|
||||
.expect("valid version graph");
|
||||
|
||||
let mut invalid_child = child.clone();
|
||||
invalid_child.version_id = root.version_id.clone();
|
||||
assert!(
|
||||
validate_game_iteration_versions(&[root.clone(), invalid_child])
|
||||
.expect_err("reject duplicate version id")
|
||||
.contains("版本 ID 重复")
|
||||
);
|
||||
|
||||
let mut invalid_child = child.clone();
|
||||
invalid_child.parent_version_id = Some("missing".to_string());
|
||||
assert!(
|
||||
validate_game_iteration_versions(&[root.clone(), invalid_child])
|
||||
.expect_err("reject missing parent")
|
||||
.contains("父版本必须先于子版本存在")
|
||||
);
|
||||
|
||||
let mut invalid_child = child.clone();
|
||||
invalid_child.project_revision = root.project_revision;
|
||||
assert!(
|
||||
validate_game_iteration_versions(&[root.clone(), invalid_child])
|
||||
.expect_err("reject non-increasing revision")
|
||||
.contains("projectRevision 必须大于父版本")
|
||||
);
|
||||
|
||||
let mut invalid_child = child.clone();
|
||||
invalid_child.created_at = root.created_at - 1;
|
||||
assert!(
|
||||
validate_game_iteration_versions(&[root.clone(), invalid_child])
|
||||
.expect_err("reject time before parent")
|
||||
.contains("createdAt 不能早于父版本")
|
||||
);
|
||||
|
||||
let mut invalid_child = child.clone();
|
||||
invalid_child.project_revision = GAME_ITERATION_VERSION_MAX_SAFE_INTEGER + 1;
|
||||
assert!(
|
||||
validate_game_iteration_versions(&[root.clone(), invalid_child])
|
||||
.expect_err("reject unsafe project revision")
|
||||
.contains("超出 JavaScript 安全整数范围")
|
||||
);
|
||||
|
||||
let mut invalid_child = child;
|
||||
invalid_child.resource_bindings = vec![
|
||||
GameIterationVersionResourceBinding {
|
||||
slot_id: "player".to_string(),
|
||||
resource_id: "asset-player".to_string(),
|
||||
},
|
||||
GameIterationVersionResourceBinding {
|
||||
slot_id: "player".to_string(),
|
||||
resource_id: "asset-player-next".to_string(),
|
||||
},
|
||||
];
|
||||
assert!(
|
||||
validate_game_iteration_versions(&[root, invalid_child])
|
||||
.expect_err("reject duplicate slot")
|
||||
.contains("资源槽位重复")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
Reference in New Issue
Block a user