实现资源画布布局持久化 (#116)
Project CI / Frontend tests (push) Failing after 20s
Project CI / Repository checks (push) Successful in 1m2s
Project CI / Backend tests (push) Successful in 2m59s
Project CI / Native shell tests (push) Successful in 11m58s

冻结资源画布布局数据与 CAS 合同
实现双模式本地 sidecar 安全读写
接入二维拖动、默认排版和跨重启恢复
补齐并发冲突、安全边界和界面测试
同步技术文档与共享决策

Reviewed-on: http://192.168.35.82/git/GenarrativeAI/Genarrative/pulls/116
Reviewed-by: 段舒康 <kdletters@qq.com>
Co-authored-by: menghao <mh18530625731@163.com>
Co-committed-by: menghao <mh18530625731@163.com>
This commit was merged in pull request #116.
This commit is contained in:
2026-07-31 12:00:48 +08:00
committed by 段舒康
parent d4075c3423
commit 216407d93e
25 changed files with 4170 additions and 352 deletions
@@ -196,6 +196,31 @@ pub(crate) fn get_local_game_manifest(
read_manifest_for_project(root)
}
#[tauri::command]
pub(crate) fn read_local_project_resource_canvas_layout(
project_path: String,
mode: ProjectResourceCanvasLayoutMode,
) -> Result<ProjectResourceCanvasLayout, String> {
read_project_resource_canvas_layout_at(Path::new(project_path.trim()), mode)
}
#[tauri::command]
pub(crate) fn update_local_project_resource_canvas_layout(
project_path: String,
expected_project_id: String,
mode: ProjectResourceCanvasLayoutMode,
expected_revision: u64,
positions: Vec<ProjectResourceCanvasPosition>,
) -> Result<UpdateProjectResourceCanvasLayoutResult, String> {
update_project_resource_canvas_layout_at(
Path::new(project_path.trim()),
mode,
&expected_project_id,
expected_revision,
positions,
)
}
#[tauri::command]
pub(crate) async fn control_agent_run(
app: tauri::AppHandle,
@@ -30,9 +30,11 @@ use shared_contracts::game_creation_app::{
GameCreationAppCommandRunStatus, GameCreationAppLimitedRunCommandDescriptor,
GameCreationAppManifest, GameCreationAppPermission, GameCreationAppPreviewState,
GameCreationAppPreviewStatus, GameCreationAppTaskState, GameCreationAppTaskStatus,
ProjectResourceCanvasLayout, ProjectResourceCanvasLayoutMode, ProjectResourceCanvasPosition,
UpdateProjectResourceCanvasLayoutResult, UpdateProjectResourceCanvasLayoutStatus,
GAME_CREATION_AGENT_CAPABILITIES, GAME_CREATION_AGENT_RUN_SCHEMA_VERSION,
GAME_CREATION_AGENT_TOOL_CALL_MAX, GAME_CREATION_APP_COMMANDS,
GAME_CREATION_APP_LIMITED_RUN_COMMANDS,
GAME_CREATION_APP_LIMITED_RUN_COMMANDS, GAME_CREATION_RESOURCE_LAYOUT_SCHEMA_VERSION,
};
use tauri::{Emitter, Manager};
use tauri_plugin_dialog::DialogExt;
@@ -1917,6 +1919,8 @@ fn main() {
activate_local_game_preview,
stop_local_game_preview,
get_local_game_preview_status,
read_local_project_resource_canvas_layout,
update_local_project_resource_canvas_layout,
get_local_game_manifest
])
.build(tauri_context)
@@ -10,6 +10,7 @@ mod export;
mod filesystem;
mod manifest;
mod memory;
mod resource_layout;
mod verification;
pub(crate) use agent_db::*;
@@ -19,4 +20,5 @@ pub(crate) use export::*;
pub(crate) use filesystem::*;
pub(crate) use manifest::*;
pub(crate) use memory::*;
pub(crate) use resource_layout::*;
pub(crate) use verification::*;
@@ -159,6 +159,7 @@ pub(crate) fn list_local_project_files_at(
let relative_path = relative_project_path(root, &path)?;
if is_agent_runtime_private_control_path(&relative_path)
|| is_agent_checkpoint_control_path(&relative_path)
|| is_agent_workbench_control_path(&relative_path)
{
continue;
}
@@ -236,6 +237,12 @@ fn is_agent_checkpoint_control_path(normalized_path: &str) -> bool {
&& matches!(parts.next(), Some(part) if part.eq_ignore_ascii_case("checkpoints"))
}
fn is_agent_workbench_control_path(normalized_path: &str) -> bool {
let mut parts = normalized_path.split('/');
matches!(parts.next(), Some(part) if part.eq_ignore_ascii_case(".agent"))
&& matches!(parts.next(), Some(part) if part.eq_ignore_ascii_case("workbench"))
}
pub(crate) fn reject_agent_runtime_private_control_path(
normalized_path: &str,
) -> Result<(), String> {
@@ -245,6 +252,9 @@ pub(crate) fn reject_agent_runtime_private_control_path(
if is_agent_checkpoint_control_path(normalized_path) {
return Err("Agent checkpoint 控制面不可通过通用文件工具访问".to_string());
}
if is_agent_workbench_control_path(normalized_path) {
return Err("Agent workbench 控制面不可通过通用文件工具访问".to_string());
}
Ok(())
}
File diff suppressed because it is too large Load Diff
+34 -25
View File
@@ -4,6 +4,7 @@ import {
type ChangeEvent,
type FormEvent,
type UIEvent,
useCallback,
useEffect,
useLayoutEffect,
useRef,
@@ -64,10 +65,10 @@ import type {
LocalGameMemoryResult,
LocalPreviewResult,
LocalPreviewStatus,
LocalProjectDirectoryStatus,
LocalProjectCheckpointResult,
LocalProjectCheckpointSummary,
LocalProjectDiffResult,
LocalProjectDirectoryStatus,
LocalProjectExportPackageResult,
LocalProjectExportPackagesResult,
LocalProjectFileEntry,
@@ -200,6 +201,7 @@ import {
} from './features/project-workspace/agentRunTrace';
import { DeveloperProjectPanels } from './features/project-workspace/DeveloperProjectPanels';
import { DeveloperRuntimePanels } from './features/project-workspace/DeveloperRuntimePanels';
import { resolveEmbeddedPreviewUrl } from './features/project-workspace/LocalGamePreviewFrame';
import {
appendMemoryContent,
memoryScopeLabel,
@@ -221,7 +223,6 @@ import {
import { handleProjectSummaryChatCommand } from './features/project-workspace/projectSummaryCommands';
import { ProjectSupervisorView } from './features/project-workspace/ProjectSupervisorView';
import { ProjectWorkspaceChatPane } from './features/project-workspace/ProjectWorkspaceChatPane';
import { resolveEmbeddedPreviewUrl } from './features/project-workspace/LocalGamePreviewFrame';
import {
buildGameChatProgressEvidence,
collectGameChatResultImages,
@@ -596,6 +597,9 @@ export function App({
) => Promise<void>)
| null
>(null);
const executeChatAgentReplyRef = useRef<
(prompt: string) => Promise<void>
>(async () => undefined);
const agentConversationSavingRef = useRef(false);
const agentConversationBackgroundBusyRef = useRef(false);
const agentConversationLoadVersionRef = useRef(0);
@@ -620,27 +624,30 @@ export function App({
storeGameChatAutoPreviewAuthorization(authorization);
}
function updateProjectSupervisorRuntime(
runtime: AgentRuntimeState | null,
previous = projectSupervisorRuntimeRef.current,
) {
const nextRuntime = runtime
? normalizeAgentRuntimeState(runtime, previous)
: null;
const nextProjectPath = localProjectPathRef.current;
if (
gameChatOnly &&
nextProjectPath &&
nextRuntime?.runId &&
!isAgentRuntimeTerminalState(nextRuntime)
) {
gameChatObservedRunKeysRef.current.add(
`${nextProjectPath}\n${nextRuntime.runId}`,
);
}
projectSupervisorRuntimeRef.current = nextRuntime;
setProjectSupervisorRuntime(nextRuntime);
}
const updateProjectSupervisorRuntime = useCallback(
(
runtime: AgentRuntimeState | null,
previous = projectSupervisorRuntimeRef.current,
) => {
const nextRuntime = runtime
? normalizeAgentRuntimeState(runtime, previous)
: null;
const nextProjectPath = localProjectPathRef.current;
if (
gameChatOnly &&
nextProjectPath &&
nextRuntime?.runId &&
!isAgentRuntimeTerminalState(nextRuntime)
) {
gameChatObservedRunKeysRef.current.add(
`${nextProjectPath}\n${nextRuntime.runId}`,
);
}
projectSupervisorRuntimeRef.current = nextRuntime;
setProjectSupervisorRuntime(nextRuntime);
},
[gameChatOnly],
);
function updateProjectSupervisorResponseStream(
incoming: AgentRuntimeResponseStream | null | undefined,
@@ -946,7 +953,7 @@ export function App({
disposed = true;
cleanup?.();
};
}, []);
}, [updateProjectSupervisorRuntime]);
useEffect(() => {
const invoke = resolveTauriInvoke();
@@ -5149,6 +5156,8 @@ export function App({
}
}
executeChatAgentReplyRef.current = executeChatAgentReply;
useEffect(() => {
const latch = initialSupervisorMessageLatchRef.current;
if (!gameChatOnly || !latch.prompt || !localProject) {
@@ -5169,7 +5178,7 @@ export function App({
...current,
{ role: 'user', text: latch.prompt, runtimeOwned: true },
]);
void executeChatAgentReply(latch.prompt);
void executeChatAgentReplyRef.current(latch.prompt);
}, [chatAgentBusy, gameChatOnly, initialSupervisorMessage, localProject]);
async function handleProjectSupervisorToolAction(
@@ -1,3 +1,5 @@
/* eslint-disable react-refresh/only-export-components -- The URL guard is exported with its small rendering adapter for focused tests. */
export type LocalGamePreviewLike = {
status?: string | null;
url?: string | null;
@@ -1,3 +1,5 @@
/* eslint-disable react-refresh/only-export-components -- Testable game-chat presentation helpers share this focused view module. */
import { FolderOpen, Send, Settings } from 'lucide-react';
import type {
ComponentProps,
@@ -21,12 +23,12 @@ import type {
import {
formatAgentRuntimeEvent,
isAgentRuntimeTerminalState,
projectProfessionalAgentLabel,
projectNameFromPath,
projectProfessionalAgentLabel,
projectRuntimePlanProgress,
projectRuntimeVisibleCurrentWork,
projectSupervisorCollaboratingAgentRuntimes,
projectSupervisorChatRuntimeStatus,
projectSupervisorCollaboratingAgentRuntimes,
ProjectSupervisorRuntimeControls,
} from '../agent-runtime';
import { RuntimeConfigDialog } from '../runtime-config/RuntimeConfigDialog';
@@ -111,7 +113,12 @@ function gameChatResultImageLabel(kind: string, path: string) {
}
function isGameChatResultImagePath(path: string) {
if (/[\\\u0000-\u001f\u007f]/u.test(path)) {
if (
Array.from(path).some((character) => {
const codePoint = character.codePointAt(0) ?? 0;
return character === '\\' || codePoint <= 0x1f || codePoint === 0x7f;
})
) {
return false;
}
const segments = path.split('/');
+15 -26
View File
@@ -3928,19 +3928,16 @@ iframe.preview-frame {
font-size: 11px;
}
.game-resource-row {
display: flex;
align-items: flex-start;
gap: 12px;
.game-resource-plane {
position: relative;
min-width: 620px;
min-height: 108px;
}
.game-resource-canvas--dependency .game-resource-card {
position: relative;
margin-left: calc(var(--resource-depth, 0) * 12px);
}
.game-resource-card {
position: absolute;
top: 0;
left: 0;
display: grid;
grid-template-columns: 36px minmax(100px, 1fr);
grid-template-rows: auto auto auto;
@@ -3957,7 +3954,11 @@ iframe.preview-frame {
color: #4e382f;
text-align: left;
box-shadow: 0 6px 18px rgb(96 62 47 / 6%);
cursor: pointer;
cursor: grab;
touch-action: none;
user-select: none;
transform: translate3d(var(--resource-x, 0), var(--resource-y, 0), 0);
will-change: transform;
}
.game-resource-card:hover,
@@ -3969,20 +3970,12 @@ iframe.preview-frame {
}
.game-resource-card.is-dragging {
opacity: 0.42;
z-index: 2;
opacity: 0.72;
cursor: grabbing;
}
.game-resource-card.is-drop-before {
box-shadow:
-5px 0 0 -2px #cf7047,
0 8px 22px rgb(195 105 62 / 15%);
}
.game-resource-card.is-drop-after {
box-shadow:
5px 0 0 -2px #cf7047,
0 8px 22px rgb(195 105 62 / 15%);
0 12px 28px rgb(195 105 62 / 24%),
0 0 0 2px rgb(213 123 81 / 18%);
}
.game-resource-card-icon {
@@ -4938,10 +4931,6 @@ iframe.preview-frame {
min-width: 460px;
}
.game-resource-row {
flex-wrap: wrap;
}
.game-run-slice-controls {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,325 @@
import {
GAME_CREATION_RESOURCE_LAYOUT_SCHEMA_VERSION,
type ProjectResourceCanvasLayout,
type ProjectResourceCanvasLayoutMode,
type ProjectResourceCanvasPosition,
type ProjectResourceCanvasSection,
} from '../../../../../packages/shared/src/contracts/gameCreationApp';
export const RESOURCE_CANVAS_CARD_WIDTH = 180;
export const RESOURCE_CANVAS_CARD_HEIGHT = 92;
export const RESOURCE_CANVAS_COLUMN_GAP = 16;
export const RESOURCE_CANVAS_ROW_GAP = 16;
export const RESOURCE_CANVAS_DRAG_THRESHOLD = 5;
export const RESOURCE_CANVAS_TYPE_COLUMNS = 3;
export const RESOURCE_CANVAS_SECTION_MIN_WIDTH = 620;
export const RESOURCE_CANVAS_SECTION_MIN_HEIGHT = 108;
const sectionOrder: ProjectResourceCanvasSection[] = [
'document',
'version',
'art',
'audio',
];
export type ResourceCanvasItem = {
id: string;
category: ProjectResourceCanvasSection;
subtype: string;
label: string;
mediaType: string;
dependencyDepth: number;
};
export type ReconciledResourceCanvasLayout = {
layout: ProjectResourceCanvasLayout;
changed: boolean;
};
export function createEmptyResourceCanvasLayout(
projectId: string,
mode: ProjectResourceCanvasLayoutMode,
): ProjectResourceCanvasLayout {
return {
schemaVersion: GAME_CREATION_RESOURCE_LAYOUT_SCHEMA_VERSION,
projectId,
mode,
revision: 0,
positions: [],
updatedAt: 0,
};
}
function positionsEqual(
left: ProjectResourceCanvasPosition[],
right: ProjectResourceCanvasPosition[],
) {
return (
left.length === right.length &&
left.every((position, index) => {
const other = right[index];
return (
other !== undefined &&
position.resourceId === other.resourceId &&
position.section === other.section &&
position.x === other.x &&
position.y === other.y &&
position.manuallyPlaced === other.manuallyPlaced
);
})
);
}
const RESOURCE_CANVAS_SLOT_WIDTH =
RESOURCE_CANVAS_CARD_WIDTH + RESOURCE_CANVAS_COLUMN_GAP;
const RESOURCE_CANVAS_SLOT_HEIGHT =
RESOURCE_CANVAS_CARD_HEIGHT + RESOURCE_CANVAS_ROW_GAP;
function positionsOverlap(
leftX: number,
leftY: number,
right: ProjectResourceCanvasPosition,
) {
return (
leftX < right.x + RESOURCE_CANVAS_SLOT_WIDTH &&
leftX + RESOURCE_CANVAS_SLOT_WIDTH > right.x &&
leftY < right.y + RESOURCE_CANVAS_SLOT_HEIGHT &&
leftY + RESOURCE_CANVAS_SLOT_HEIGHT > right.y
);
}
class ResourceCanvasOccupancyIndex {
private readonly positionsByCell = new Map<
string,
ProjectResourceCanvasPosition[]
>();
constructor(positions: ProjectResourceCanvasPosition[]) {
positions.forEach((position) => this.add(position));
}
private cellKeys(x: number, y: number) {
const firstColumn = Math.floor(x / RESOURCE_CANVAS_SLOT_WIDTH);
const lastColumn = Math.floor(
(x + RESOURCE_CANVAS_SLOT_WIDTH - 1) / RESOURCE_CANVAS_SLOT_WIDTH,
);
const firstRow = Math.floor(y / RESOURCE_CANVAS_SLOT_HEIGHT);
const lastRow = Math.floor(
(y + RESOURCE_CANVAS_SLOT_HEIGHT - 1) / RESOURCE_CANVAS_SLOT_HEIGHT,
);
const keys: string[] = [];
for (let column = firstColumn; column <= lastColumn; column += 1) {
for (let row = firstRow; row <= lastRow; row += 1) {
keys.push(`${column}:${row}`);
}
}
return keys;
}
add(position: ProjectResourceCanvasPosition) {
for (const key of this.cellKeys(position.x, position.y)) {
const positions = this.positionsByCell.get(key);
if (positions) {
positions.push(position);
} else {
this.positionsByCell.set(key, [position]);
}
}
}
overlaps(x: number, y: number) {
const visited = new Set<ProjectResourceCanvasPosition>();
for (const key of this.cellKeys(x, y)) {
for (const position of this.positionsByCell.get(key) ?? []) {
if (!visited.has(position)) {
visited.add(position);
if (positionsOverlap(x, y, position)) {
return true;
}
}
}
}
return false;
}
}
function defaultDependencyPosition(
resource: ResourceCanvasItem,
occupancy: ResourceCanvasOccupancyIndex,
nextYByColumn: Map<number, number>,
) {
const x =
Math.max(0, Math.round(resource.dependencyDepth)) *
RESOURCE_CANVAS_SLOT_WIDTH;
let y = nextYByColumn.get(x) ?? 0;
while (occupancy.overlaps(x, y)) {
y += RESOURCE_CANVAS_SLOT_HEIGHT;
}
nextYByColumn.set(x, y + RESOURCE_CANVAS_SLOT_HEIGHT);
return { x, y };
}
function defaultTypePosition(
occupancy: ResourceCanvasOccupancyIndex,
nextSlot: number,
) {
let slot = nextSlot;
for (;;) {
const column = slot % RESOURCE_CANVAS_TYPE_COLUMNS;
const row = Math.floor(slot / RESOURCE_CANVAS_TYPE_COLUMNS);
const x = column * RESOURCE_CANVAS_SLOT_WIDTH;
const y = row * RESOURCE_CANVAS_SLOT_HEIGHT;
if (!occupancy.overlaps(x, y)) {
return { point: { x, y }, nextSlot: slot + 1 };
}
slot += 1;
}
}
function compareStableIdentifier(left: string, right: string) {
return left < right ? -1 : left > right ? 1 : 0;
}
function compareResources(
mode: ProjectResourceCanvasLayoutMode,
left: ResourceCanvasItem,
right: ResourceCanvasItem,
) {
if (mode === 'dependency') {
return (
left.dependencyDepth - right.dependencyDepth ||
left.label.localeCompare(right.label, 'zh-CN') ||
compareStableIdentifier(left.id, right.id)
);
}
return (
compareStableIdentifier(left.subtype, right.subtype) ||
compareStableIdentifier(left.mediaType, right.mediaType) ||
left.label.localeCompare(right.label, 'zh-CN') ||
compareStableIdentifier(left.id, right.id)
);
}
export function reconcileResourceCanvasLayout(
source: ProjectResourceCanvasLayout,
resources: ResourceCanvasItem[],
): ReconciledResourceCanvasLayout {
const resourceById = new Map(
resources.map((resource) => [resource.id, resource]),
);
const positionsBySection = new Map(
sectionOrder.map((section) => [
section,
[] as ProjectResourceCanvasPosition[],
]),
);
const preserved = source.positions.filter((position) => {
const resource = resourceById.get(position.resourceId);
const keep = resource?.category === position.section;
if (keep) {
positionsBySection.get(position.section)?.push(position);
}
return keep;
});
const preservedIds = new Set(
preserved.map((position) => position.resourceId),
);
const newResourcesBySection = new Map(
sectionOrder.map((section) => [section, [] as ResourceCanvasItem[]]),
);
for (const resource of resources) {
if (!preservedIds.has(resource.id)) {
newResourcesBySection.get(resource.category)?.push(resource);
}
}
for (const section of sectionOrder) {
const sectionPositions = positionsBySection.get(section) ?? [];
const occupancy = new ResourceCanvasOccupancyIndex(sectionPositions);
const nextYByColumn = new Map<number, number>();
let nextTypeSlot = 0;
const newResources = (newResourcesBySection.get(section) ?? []).sort(
(left, right) => compareResources(source.mode, left, right),
);
for (const resource of newResources) {
let point: { x: number; y: number };
if (source.mode === 'dependency') {
point = defaultDependencyPosition(resource, occupancy, nextYByColumn);
} else {
const placement = defaultTypePosition(occupancy, nextTypeSlot);
point = placement.point;
nextTypeSlot = placement.nextSlot;
}
const position: ProjectResourceCanvasPosition = {
resourceId: resource.id,
section,
x: point.x,
y: point.y,
manuallyPlaced: false,
};
sectionPositions.push(position);
occupancy.add(position);
}
}
const ordered = sectionOrder.flatMap((section) =>
(positionsBySection.get(section) ?? []).sort(
(left, right) =>
left.y - right.y ||
left.x - right.x ||
left.resourceId.localeCompare(right.resourceId),
),
);
return {
layout: {
...source,
positions: ordered,
},
changed: !positionsEqual(source.positions, ordered),
};
}
export function moveResourceCanvasPosition(
layout: ProjectResourceCanvasLayout,
resourceId: string,
section: ProjectResourceCanvasSection,
x: number,
y: number,
): ProjectResourceCanvasLayout {
const normalizedX = Math.max(0, Math.round(x));
const normalizedY = Math.max(0, Math.round(y));
return {
...layout,
positions: layout.positions.map((position) =>
position.resourceId === resourceId && position.section === section
? {
...position,
x: normalizedX,
y: normalizedY,
manuallyPlaced: true,
}
: position,
),
};
}
export function resourceCanvasSectionExtent(
positions: ProjectResourceCanvasPosition[],
) {
return {
width: Math.max(
RESOURCE_CANVAS_SECTION_MIN_WIDTH,
...positions.map(
(position) =>
position.x + RESOURCE_CANVAS_CARD_WIDTH + RESOURCE_CANVAS_COLUMN_GAP,
),
),
height: Math.max(
RESOURCE_CANVAS_SECTION_MIN_HEIGHT,
...positions.map(
(position) =>
position.y + RESOURCE_CANVAS_CARD_HEIGHT + RESOURCE_CANVAS_ROW_GAP,
),
),
};
}
@@ -25,6 +25,22 @@ const originalRevokeObjectUrl = Object.getOwnPropertyDescriptor(
URL,
'revokeObjectURL',
);
const originalPointerEvent = Object.getOwnPropertyDescriptor(
window,
'PointerEvent',
);
class TestPointerEvent extends MouseEvent {
readonly pointerId: number;
constructor(
type: string,
init: MouseEventInit & { pointerId?: number } = {},
) {
super(type, init);
this.pointerId = init.pointerId ?? 0;
}
}
function createMemoryStorage(): Storage {
const values = new Map<string, string>();
@@ -659,6 +675,10 @@ async function openMainProject(projectPath: string) {
}
beforeEach(() => {
Object.defineProperty(window, 'PointerEvent', {
configurable: true,
value: TestPointerEvent,
});
Object.defineProperties(URL, {
createObjectURL: {
configurable: true,
@@ -690,6 +710,11 @@ afterEach(() => {
} else {
Reflect.deleteProperty(URL, 'revokeObjectURL');
}
if (originalPointerEvent) {
Object.defineProperty(window, 'PointerEvent', originalPointerEvent);
} else {
Reflect.deleteProperty(window, 'PointerEvent');
}
});
export {
act,
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,27 @@
import { describe, expect, it } from 'vitest';
import {
GAME_CREATION_RESOURCE_LAYOUT_MAX_SAFE_REVISION,
isSafeProjectResourceCanvasLayoutRevision,
} from '../../../packages/shared/src/contracts/gameCreationApp';
describe('resource canvas layout contract', () => {
it('keeps revisions inside the JSON safe integer range', () => {
const maxRevision = GAME_CREATION_RESOURCE_LAYOUT_MAX_SAFE_REVISION;
expect(JSON.parse(JSON.stringify({ revision: maxRevision }))).toEqual({
revision: maxRevision,
});
expect(isSafeProjectResourceCanvasLayoutRevision(0)).toBe(true);
expect(isSafeProjectResourceCanvasLayoutRevision(maxRevision)).toBe(true);
expect(isSafeProjectResourceCanvasLayoutRevision(maxRevision + 1)).toBe(
false,
);
expect(isSafeProjectResourceCanvasLayoutRevision(-1)).toBe(false);
expect(isSafeProjectResourceCanvasLayoutRevision(1.5)).toBe(false);
expect(isSafeProjectResourceCanvasLayoutRevision(Number.NaN)).toBe(false);
expect(
isSafeProjectResourceCanvasLayoutRevision(Number.POSITIVE_INFINITY),
).toBe(false);
expect(isSafeProjectResourceCanvasLayoutRevision('1')).toBe(false);
});
});
@@ -0,0 +1,208 @@
import { describe, expect, it } from 'vitest';
import {
createEmptyResourceCanvasLayout,
moveResourceCanvasPosition,
reconcileResourceCanvasLayout,
RESOURCE_CANVAS_CARD_HEIGHT,
RESOURCE_CANVAS_CARD_WIDTH,
RESOURCE_CANVAS_COLUMN_GAP,
RESOURCE_CANVAS_ROW_GAP,
type ResourceCanvasItem,
} from '../src/view/project-development/resourceCanvasLayoutModel';
function resource(
id: string,
category: ResourceCanvasItem['category'],
dependencyDepth = 0,
): ResourceCanvasItem {
return {
id,
category,
subtype: 'default',
dependencyDepth,
label: id,
mediaType: category === 'art' ? 'image/png' : 'text/markdown',
};
}
describe('resource canvas layout model', () => {
it('creates non-overlapping defaults and keeps the two modes independent', () => {
const resources = [
resource('a', 'art', 0),
resource('b', 'art', 0),
resource('c', 'art', 1),
];
const dependency = reconcileResourceCanvasLayout(
createEmptyResourceCanvasLayout('project-1', 'dependency'),
resources,
).layout;
const type = reconcileResourceCanvasLayout(
createEmptyResourceCanvasLayout('project-1', 'type'),
resources,
).layout;
expect(dependency.positions).toMatchObject([
{ resourceId: 'a', x: 0, y: 0, manuallyPlaced: false },
{
resourceId: 'c',
x: RESOURCE_CANVAS_CARD_WIDTH + RESOURCE_CANVAS_COLUMN_GAP,
y: 0,
manuallyPlaced: false,
},
{
resourceId: 'b',
x: 0,
y: RESOURCE_CANVAS_CARD_HEIGHT + RESOURCE_CANVAS_ROW_GAP,
manuallyPlaced: false,
},
]);
expect(type.positions).toMatchObject([
{ resourceId: 'a', x: 0, y: 0 },
{
resourceId: 'b',
x: RESOURCE_CANVAS_CARD_WIDTH + RESOURCE_CANVAS_COLUMN_GAP,
y: 0,
},
{
resourceId: 'c',
x: 2 * (RESOURCE_CANVAS_CARD_WIDTH + RESOURCE_CANVAS_COLUMN_GAP),
y: 0,
},
]);
});
it('preserves existing positions, appends new resources, and removes stale ids', () => {
const base = reconcileResourceCanvasLayout(
createEmptyResourceCanvasLayout('project-1', 'type'),
[resource('a', 'document'), resource('stale', 'document')],
).layout;
const moved = moveResourceCanvasPosition(
base,
'a',
'document',
333.4,
41.6,
);
const reconciled = reconcileResourceCanvasLayout(moved, [
resource('a', 'document'),
resource('new', 'document'),
]);
expect(reconciled.changed).toBe(true);
expect(reconciled.layout.positions).toContainEqual({
resourceId: 'a',
section: 'document',
x: 333,
y: 42,
manuallyPlaced: true,
});
expect(
reconciled.layout.positions.some(
(position) => position.resourceId === 'stale',
),
).toBe(false);
expect(
reconciled.layout.positions.find(
(position) => position.resourceId === 'new',
)?.manuallyPlaced,
).toBe(false);
});
it('drops a persisted position whose section no longer matches the resource', () => {
const layout = createEmptyResourceCanvasLayout('project-1', 'dependency');
layout.positions = [
{
resourceId: 'asset-a',
section: 'audio',
x: 10,
y: 10,
manuallyPlaced: true,
},
];
const reconciled = reconcileResourceCanvasLayout(layout, [
resource('asset-a', 'art'),
]);
expect(reconciled.layout.positions).toEqual([
{
resourceId: 'asset-a',
section: 'art',
x: 0,
y: 0,
manuallyPlaced: false,
},
]);
});
it('sorts type defaults by subtype before media type and label with an id fallback', () => {
const typeLayout = reconcileResourceCanvasLayout(
createEmptyResourceCanvasLayout('project-type-order', 'type'),
[
{
...resource('ui-prototype', 'art'),
subtype: 'ui-prototype',
mediaType: 'image/png',
label: '甲界面',
},
{
...resource('art-spritesheet-b', 'art'),
subtype: 'art-spritesheet',
mediaType: 'image/png',
label: '乙图集',
},
{
...resource('art-spritesheet-a', 'art'),
subtype: 'art-spritesheet',
mediaType: 'image/png',
label: '乙图集',
},
],
).layout;
expect(
typeLayout.positions.map(({ resourceId, x, y }) => ({
resourceId,
x,
y,
})),
).toEqual([
{ resourceId: 'art-spritesheet-a', x: 0, y: 0 },
{
resourceId: 'art-spritesheet-b',
x: RESOURCE_CANVAS_CARD_WIDTH + RESOURCE_CANVAS_COLUMN_GAP,
y: 0,
},
{
resourceId: 'ui-prototype',
x: 2 * (RESOURCE_CANVAS_CARD_WIDTH + RESOURCE_CANVAS_COLUMN_GAP),
y: 0,
},
]);
});
it.each(['dependency', 'type'] as const)(
'reconciles 4096 resources in %s mode within the bounded layout budget',
(mode) => {
const resources = Array.from({ length: 4096 }, (_, index) => ({
...resource(`resource-${index.toString().padStart(4, '0')}`, 'art'),
dependencyDepth: index % 64,
mediaType: `image/type-${index % 16}`,
}));
const startedAt = performance.now();
const layout = reconcileResourceCanvasLayout(
createEmptyResourceCanvasLayout('project-performance', mode),
resources,
).layout;
const elapsedMs = performance.now() - startedAt;
expect(layout.positions).toHaveLength(4096);
expect(
new Set(
layout.positions.map((position) => `${position.x}:${position.y}`),
).size,
).toBe(4096);
expect(elapsedMs).toBeLessThan(2000);
},
);
});
File diff suppressed because it is too large Load Diff
@@ -1,6 +1,6 @@
# AI 游戏创作项目开发工作台 PRD
更新时间:`2026-07-20`
更新时间:`2026-07-28`
## 1. 产品定位
@@ -153,6 +153,8 @@ P0 中 `approvalMode` 只能有效写入 `strict`;其它值只能作为不可
### 5.2 资源画布布局(P1
实现状态(2026-07-28):本节布局合同已在独立客户端落地,dependency / type 双模式通过项目内 CAS sidecar 独立持久化;关系线、资源替换、缩放 / 平移等其余 P1 能力仍按本文非目标保持未实现。
```ts
type ProjectResourceCanvasLayout = {
schemaVersion: 'game-creator-resource-layout.v1';
@@ -170,7 +172,80 @@ type ProjectResourceCanvasLayout = {
};
```
两个 mode 是两份独立坐标集合;服务端或本地项目持久层以 `projectId + mode` 做 CAS 更新。
两个 mode 是两份独立坐标集合;独立客户端的本地项目持久层以 `projectId + mode` 做 CAS 更新。
#### 5.2.1 字段语义
- `x / y` 是相对所属 `section` 内容原点的 CSS 像素坐标,落盘前四舍五入为非负整数;坐标不使用 viewport、页面或资源详情浮层坐标系。
- `updatedAt` 是持久层生成的 Unix 毫秒时间戳,前端不得自行覆盖。
- `revision``0` 开始;布局文件不存在时读取接口合成 `revision=0 / positions=[]`,首次成功写入返回 `revision=1`,后续每次成功 CAS 写入递增 `1`。JSON / Tauri / TypeScript 全链路合法范围固定为 `0..=9_007_199_254_740_991``Number.MAX_SAFE_INTEGER`),读取、返回或提交负数、小数、非有限值与超限整数都必须失败关闭。
- 新资源第一次进入某个 mode 时由默认布局写入 `manuallyPlaced=false`;用户完成一次有效拖动后写为 `true`
- 同一份布局中 `resourceId` 必须唯一。持久层允许暂时存在当前资源投影中没有的旧 ID,因为 Agent 文本成果等资源可能晚于 manifest 恢复;前端协调后必须在下一次成功写入中清除已确认失效的坐标。
- 单份布局最多保存 `4096` 个位置,序列化文件不得超过 `2 MiB``resourceId` 最多 `512` 个 Unicode 字符,`x / y` 取值范围固定为 `0..=1_000_000`
#### 5.2.2 本地存储与业务边界
独立客户端把两份布局保存为项目内 UI sidecar:
```text
.agent/workbench/resource-layouts/
├─ dependency.json
└─ type.json
```
- 文件名必须与 payload 的 `mode` 一致;payload 的 `projectId` 必须与当前 `.agent/manifest.json` 一致。
- 布局只属于工作台 UI 状态,不写入 manifest,不增加游戏项目 mutation revision,不使 Runtime verification 失效,不触发权限确认,也不作为 Agent 产物、资产或 Git 提交依据。
- 写入复用项目安全路径解析、普通文件 / 链接校验和原子 JSON sidecar 安装能力;使用资源布局专用系统文件锁串行化 read-check-write,不用前端进程内互斥替代跨窗口锁,也不长期占用 Agent Runtime 的项目 mutation 锁。`.layout.lock` 是持久锁入口,Unix 互斥跟随 `flock` 文件描述符,Windows 互斥跟随不共享的文件句柄;进程退出由操作系统释放,应用不得按 mtime、PID 文本或其它 stale 启发式删除锁文件。
- 主文件损坏、schema 不支持、身份不匹配、文件超限或安全文件检查失败时必须失败关闭,不得把默认空布局覆盖到原文件。若原子安装留下可验证的恢复副本,读取时可按既有 sidecar 恢复规则恢复后再返回。
- 本切片不把布局同步到 `api-server`、SpacetimeDB、云端账号或其它设备。
#### 5.2.3 Tauri 命令合同
```ts
type ReadProjectResourceCanvasLayoutInput = {
projectPath: string;
mode: 'dependency' | 'type';
};
type UpdateProjectResourceCanvasLayoutInput = {
projectPath: string;
expectedProjectId: string;
mode: 'dependency' | 'type';
expectedRevision: number;
positions: ProjectResourceCanvasLayout['positions'];
};
type UpdateProjectResourceCanvasLayoutResult =
| {
status: 'updated';
layout: ProjectResourceCanvasLayout;
}
| {
status: 'conflict';
layout: ProjectResourceCanvasLayout;
};
```
- 读取命令固定为 `read_local_project_resource_canvas_layout`,返回当前 mode 的完整布局;文件不存在时返回合成的 revision `0` 布局,不为只读操作创建目录或文件。
- 更新命令固定为 `update_local_project_resource_canvas_layout`。调用方只提交当前已读取布局的 `expectedProjectId` 身份栅栏,不提交 `projectId / revision / updatedAt` 的权威新值;Tauri 必须先只读确认项目存在、manifest 有效且 projectId 与栅栏一致,随后获取系统锁并在锁内复核 `projectId`、重新读取当前布局,再生成新的 revision 与时间戳。路径被其它窗口重建为新项目时,旧窗口必须在任何布局副作用前失败。不存在目录、普通非项目目录或损坏 manifest 均不得先创建 `.agent/workbench`、锁文件或布局文件。
- `expectedRevision` 与锁内 revision 相同才允许原子写入并返回 `updated`;不同时不得写文件,返回 `conflict` 和锁内最新完整布局。前端不得通过解析错误字符串识别 CAS 冲突。
- Rust 内部 revision 使用 `u64`,但 JSON / Tauri 合同统一限制为 `0..=9_007_199_254_740_991`,每次成功必须严格递增;持久值或 `expectedRevision` 超限时必须拒绝,当前值达到上限时失败关闭并保持原文件字节不变,不得把超出 JavaScript 安全整数范围的值返回前端或用于 CAS。
- 项目无效、布局损坏、字段校验失败和文件系统错误继续作为安全、可理解的 Tauri command error 返回;错误不得包含配置、凭据或项目外绝对路径。
#### 5.2.4 前端布局与协调合同
- 资源卡改用 Pointer Events 驱动二维拖动;超过统一移动阈值后才进入拖动态,普通点击仍打开唯一资源详情浮层,`pointercancel` 恢复拖动前位置。
- 资源只能在原 `section` 内拖动。不同 section 之间既不能通过指针拖入,也不能通过持久 payload 改变当前资源的前端分类事实。
- dependency 默认布局按 `dependencyDepth` 形成横向层级,同层资源纵向寻找第一个不重叠位置;type 默认布局固定按“资源子类型 -> 媒体类型 -> 名称 -> 资源 ID”稳定排序,在分区内从左到右、从上到下寻找第一个空位。布局模型的 `subtype` 必填:manifest 资产使用 `asset.kind`,任务产物、导入附件与 Agent 文本成果分别使用稳定的 `task-artifact``attachment``agent-result`,不得以缺失值或显示文案兜底;资源协调签名必须包含 subtype。卡片尺寸、间距和拖动阈值必须由单一前端布局模型常量维护。
- 资源集合变化时保留全部仍存在的坐标,只为新 ID 计算默认位置,并删除已确认失效的旧 ID;无论 `manuallyPlaced` 为何,已经写入的现存坐标都不得因重新排序、模式切换或新增资源被自动改写。
- 搜索或筛选只隐藏卡片,不删除、压缩或重排其坐标;清空搜索后恢复原位置。
- 窗口尺寸变化只改变可视范围和分区滚动边界,不回写、裁切或缩放持久坐标。当前客户端继续以 `1280×800` 横屏合同验收。
- 打开项目、切换 mode 或当前 mode 首次出现新资源时执行“读取 -> 协调 -> 必要时 CAS 写入”;项目或 mode 已切换后返回的旧异步结果必须丢弃。
- 同一 `projectPath + projectId + mode` 的首次读取与资源集合协调必须分开:资源集合变化不得取消已经发出的读取或保存。同一 scope 内全部手动拖动和资源自动协调写入使用同一 FIFO,任一时刻最多一个 CAS 在途,后一笔必须使用前一笔成功返回的 revision,不能用“最后请求获胜”跳过中间 CAS。切换项目或 mode 后,旧 scope 的在途请求不能阻塞新 scope 队列;前端放弃旧请求槽位并丢弃其迟到响应,后端继续依靠 `expectedProjectId + expectedRevision + 系统锁` 仲裁已发出的请求。
- 某笔 CAS 在途期间,同一 scope 内对相同 `resourceId + section` 重复产生但尚未发送的拖动意图必须折叠为最后坐标;已经在途的请求不得取消,不同资源的顺序不得跨越。队列增长必须受当前资源与分区数量约束,不能随连续 pointer 事件无界累积。
- 用户拖动结束后先乐观更新,再立即提交一次 CAS。成功后以返回布局更新 revision;普通写入失败时恢复最近可信持久布局并提示“布局保存失败,已恢复上次布局”。
- CAS 冲突时直接载入返回的最新布局并提示“布局已在其他窗口更新,请重新拖动”,丢弃所有基于冲突前快照排队的手动拖动,不得自动重放本地旧坐标或静默覆盖另一窗口结果。即使当前在途请求是允许自动重试的资源协调,只要本次冲突实际清除了任何排队手动拖动,也必须按当前 scope 保留重新拖动提示;后续资源协调成功、失败或通用提示定时器都不得静默清除,只有新的手动布局成功保存或切换 scope 才能解除。资源自动协调可以基于冲突返回的新 revision 有界重试,单次资源签名最多追加 `2` 次,持续跨窗口写入时不得无限自旋。
- 缺少 Tauri bridge 的浏览器开发态可以保留当前会话内布局用于界面测试,但不得宣称已经持久保存。
### 5.3 资源类型与替换兼容性(P1)
@@ -273,8 +348,8 @@ type ProjectAgentMudPointAttribution = {
### P1
- 依赖/类型两套坐标持久化。
- 资源关系线与首次自动布局
- 先实施依赖/类型两套坐标持久化、首次默认不重叠布局、跨重启恢复与 CAS 冲突处理
- 资源关系线在布局持久化验收通过后单独实施,不与本切片捆绑伪造完成
- 版本资源高亮、兼容性判断和不可变下一迭代版本。
- 美术/音频编辑状态接线。
@@ -286,7 +361,9 @@ type ProjectAgentMudPointAttribution = {
- Agent.md/Skill 安全合同。
- 高风险审批 Rank 与无需审批运行合同。
## 7. P0 验收
## 7. 验收
### 7.1 P0 验收
1. `1280×800` 下页面无横向或纵向溢出,输入框与 Agent Dock 始终可见。
2. 运行入口不可用时点击给出原因;可用时只在客户端内打开 loopback 预览。
@@ -295,9 +372,20 @@ type ProjectAgentMudPointAttribution = {
5. 风险审批和无需审批不能改变运行策略,点击后明确提示尚未开放;严格审批继续使用现有 Runtime 门禁。
6. 不显示伪造泥点、伪造资源完成度、伪造图片或外部浏览器成功提示。
### 7.2 P1 资源画布布局持久化验收
1. 同一项目在 dependency 与 type mode 分别拖动资源后,关闭并重启客户端,两种 mode 都恢复各自最后一次成功保存的位置。
2. 新资源进入任一 mode 时获得不重叠默认位置,现存资源坐标保持逐项不变;删除资源后,下一次成功写入不再包含已确认失效的 ID。
3. 资源不能跨 document、version、art、audio 分区;点击、搜索、筛选和资源详情浮层行为不因二维拖动回归。
4. 两个窗口基于同一 revision 写入时最多一个成功;失败方收到 `conflict` 与最新完整布局,界面不静默覆盖成功方结果。
5. 布局文件缺失的旧项目可以无迁移打开;损坏、未知 schema、身份冲突、超限和链接文件失败关闭,且原文件不被空布局覆盖。
6. 布局读写不改变 manifest、游戏项目 mutation revision、Runtime verification、Agent 权限与预览状态。
7. `1280×800` 最小横屏下全部资源可通过分区滚动访问,不出现页面级横向或纵向溢出,右侧对话和底部 Agent 状态栏保持可见。
## 8. 非目标
- 本切片不实现 P1/P2 持久合同
- 资源画布布局持久化切片不实现资源关系线、资源替换、不可变迭代版本、画板编辑状态、测试切片、数值参数或泥点归因
- 本切片不保存资源详情浮层位置、画布缩放 / 平移、搜索条件、筛选条件或当前 mode;这些状态如需持久化必须另行扩展合同,不能塞入 `game-creator-resource-layout.v1`
- 不修改 SpacetimeDB schema。
- 不开放普通用户 Agent.md/Skill。
- 不自动确认 Agent 动作,不自动触发可能扣费的生成。
@@ -16,6 +16,16 @@
---
## 2026-07-28 AI 游戏创作资源画布布局使用本地双模式 CAS sidecar
- 背景:项目开发工作台当前只在 React 会话内保存同分类资源的一维拖拽顺序,项目切换或客户端重启后重建默认排列;工作台 PRD 虽已给出二维位置字段,但缺少落盘路径、坐标系、Tauri API、CAS、异常与安全边界,仍不足以直接编码。
- 决策:dependency 与 type 两套布局分别保存为项目内 `.agent/workbench/resource-layouts/dependency.json``type.json`,统一使用 `game-creator-resource-layout.v1``x / y` 是 section 内容 CSS 像素,revision 从缺文件时的 `0` 单调递增;新资源首次默认放置,任何已有坐标不因排序、筛选、模式切换或 resize 被自动覆盖。type 默认布局固定按 `subtype -> mediaType -> label -> id` 排序,manifest 资产使用 `asset.kind`,任务产物、附件与 Agent 文本成果使用稳定 fallback,subtype 同时进入资源协调签名。
- 并发与失败:Tauri 用 `read_local_project_resource_canvas_layout``update_local_project_resource_canvas_layout` 暴露读写,以 `projectId + mode + expectedRevision` 在专用跨窗口布局锁内做 CAS。更新额外携带只读结果中的 `expectedProjectId` 身份栅栏,路径被重建为新项目时旧窗口在锁副作用前失败;Rust 内部 revision 保留 `u64`,但共享 serde、Tauri 输入和前端 IPC 统一限制为 `0..=Number.MAX_SAFE_INTEGER`,达到上限时保持原文件。锁入口文件持久存在,Unix 以 `flock` 文件描述符、Windows 以不共享句柄持有互斥;应用不按 mtime / PID 猜测 stale、不删除锁文件,进程退出由操作系统释放。更新在创建锁目录前只读验证 manifest,锁内复核 projectId;无效根保持零 workbench 副作用。前端以 project/path/mode epoch 丢弃旧 scope 迟到响应,资源变化不得取消首读或同 scope 在途写;同 scope 的手动拖动与资源协调进入单写者 FIFO,后一笔只使用前一笔权威响应的 revision。切换 scope 会释放旧活动槽,旧请求即使卡死也不能阻塞新 scope;同资源尚未发送的连续拖动折叠为最后坐标,已经在途的 CAS 不取消。冲突返回最新完整布局且零写入,前端载入最新值、丢弃基于旧快照排队的手动拖动并要求重新操作;资源协调最多追加两次冲突重试,普通失败恢复最近可信布局。写入复用项目安全路径、链接校验、容量上限、恢复副本与原子替换,损坏或身份冲突不能被空布局覆盖。
- 业务边界:布局是本地工作台 UI sidecar,不进入 manifest,不推进游戏项目 mutation revision,不使 Runtime verification 失效,不触发 Agent 权限,也不属于资产、Agent 产物、Git 或云端事实。本切片不包含关系线、资源替换、浮层位置、缩放 / 平移、搜索 / 筛选条件和当前 mode。
- 影响范围:`packages/shared` 与 Rust `shared-contracts` 的跨边界 DTO、AI 游戏创作 Tauri 项目持久层与命令、项目开发资源画布、定向 Rust / React 测试、工作台 PRD 和客户端实施计划。
- 验证方式:序列化与字段上限测试、缺文件 / 损坏 / 原子恢复 / 链接安全测试、同 revision 双写最多一个成功、两种 mode 跨重启独立恢复、新增资源不移动旧坐标、`1280×800` 横屏无页面级溢出,以及 `npm run agc:typecheck`、定向测试、`npm run check:encoding``git diff --check`
- 关联文档:`docs/prd/【AI游戏创作】项目开发工作台PRD-2026-07-20.md``docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md`
## 2026-07-29 game-chat 在创建 WebView 前确定初始 URL
- 背景:`agc:game-chat` 曾在 Tauri `.setup()` 中读取仍可能是 `about:blank` 或配置期地址的 `client.url()`,再导航到 game-chatWindows WebView2 首航被覆盖后只剩黑边白块或全白原生窗口,刷新无法恢复。
@@ -3864,6 +3864,38 @@
- 约束:一次性语义应按“成功或确定性终态”消费,不按“函数调用次数”消费。项目写锁竞争保留同一授权并轮询重试;成功、显式 deny 与非瞬时失败才清除。授权需持久化项目路径和 accepted runId,重启恢复时仍必须逐项匹配,切换项目不得继承。
- 回归:AppSurface 模拟第一次 `start_local_game_preview` 返回 `项目正在被其他写操作占用`、第二次成功,断言最终渲染游戏区域且启动调用恰为两次;完整 AppSurface 仍需覆盖显式 deny、停止隐藏与项目切换隔离。
## 跨窗口 CAS 锁不能用 mtime stale 删除模拟系统互斥(2026-07-30)
- 现象:两个窗口基于同一 revision 保存资源布局时,正常测试看似只有一个成功;锁文件超过 stale 阈值或两个竞争者同时判断过期时,却可能各自删除 / 重建锁并同时进入 read-check-write,击穿“同 revision 最多一个成功”。无效绝对路径还会在 manifest 报错前遗留 `.agent/workbench/resource-layouts`
- 原因:`create_new` 只保证某一时刻创建文件原子,不保证“判断过期 → 删除 → 重建”整体原子;mtime 不能证明 owner 已退出,token 文本也不能阻止另一个竞争者删除新锁。先获取锁再读 manifest 又把目录创建副作用提前到了项目身份验证之前。
- 处理:锁文件作为持久入口永不由应用删除;Unix 用文件描述符持有 `flock(LOCK_EX | LOCK_NB)`Windows 用 `share_mode(0)` 独占句柄,Drop / 进程退出让操作系统释放锁。安全打开逐级拒绝符号链接 / reparse pointUnix 还核对 owner、硬链接数、inode 和 `0600`。更新携带只用于校验的 `expectedProjectId`,先只读验证 manifest,再获取系统锁并在锁内复核 projectId;不存在根、非项目根、损坏 manifest 和路径复用后的旧窗口都不能创建 workbench。revision 必须 checked increment,耗尽时不能饱和成功。
- 验证:必须覆盖活锁 mtime 被设为 epoch 后竞争者仍拿不到锁、释放后同一 inode 可重新获取、同 revision 并发双写仍恰好一个 updated / 一个 conflict,三类无效根和旧 projectId 零 workbench 副作用,以及 `u64::MAX` revision 保持原文件。锁等待超时只能返回可重试错误,不得转为 stale 删除。
- 关联:`apps/ai-game-creator-shell/src-tauri/src/project/resource_layout.rs``docs/prd/【AI游戏创作】项目开发工作台PRD-2026-07-20.md`
## 旧 scope 的卡死请求不能占住新资源画布队列(2026-07-30)
- 现象:用户在 dependency 布局保存尚未返回时切到 type 或另一个项目,新 scope 已完成读取且拖动已进入队列,但因为全局活动请求引用仍指向旧 scope,新的保存会无限等待旧请求结束。
- 原因:epoch 只阻止迟到响应覆盖新状态,不会自动释放前端单写者槽;把“不能取消已经发出的请求”误写成“所有后续 scope 都必须等待它”,会把一个网络或 IPC 卡死扩大到整个 Hook 生命周期。
- 处理:FIFO 和单写者只约束同一 `projectPath + projectId + mode` scope。切换 scope 或卸载时立即放弃旧活动槽并清空旧队列,旧 Promise 仍可在后台结束,但其结果由 epoch 丢弃,finally 也只能按意图身份清理自己,不能清掉新 scope 的活动请求。后端继续用 `expectedProjectId`、CAS revision 和系统锁仲裁已经发出的旧写入。同 scope 在途 CAS 不取消;其后相同资源与 section 的排队拖动只保留最后坐标,避免连续输入造成无界队列。
- 验证:让旧 mode 更新 Promise 永不先 resolve,切换 mode 后应立即发送并完成新 mode CAS;随后再 resolve 旧请求,新布局、saving 状态和请求数均不得变化。另以百次同资源拖动证明在途请求之后只追加一笔、坐标为最后一次输入。
- 关联:`apps/ai-game-creator-shell/src/view/project-development/useProjectResourceCanvasLayout.ts``apps/ai-game-creator-shell/tests/useProjectResourceCanvasLayout.test.ts`
## 资源协调冲突清除排队拖动时不能静默重试(2026-07-31)
- 现象:资源协调 CAS 在途期间,用户拖动资源形成排队 manual intent;协调请求随后 conflict 并基于权威 revision 自动重试成功,布局正确保留另一窗口结果,但界面没有提示本地拖动已经被丢弃。
- 原因:冲突分支虽然清除了当前 scope 的全部 manual intent,却只按“当前 intent 是否为 manual”或“资源协调是否停止重试”决定提示;当前 intent 为 resources 且可重试时,排队拖动的丢弃事实没有进入提示条件。
- 处理:过滤队列前记录本次是否实际清除了 manual intent。只要当前 manual 发生冲突或清除了任何排队 manual,就必须提示用户重新拖动;冲突前坐标不得自动重放,后续资源协调继续使用冲突响应的权威 revision,并且成功响应不能静默清除提示。
- 验证:定向 Hook 测试固定“resource sync revision 1 在途、manual 排队、权威 revision 2 conflict、resource retry 成功”时序,断言 retry 使用 revision 2、权威坐标保留、旧 manual 不重放且 notice 仍存在。
- 关联:`apps/ai-game-creator-shell/src/view/project-development/useProjectResourceCanvasLayout.ts``apps/ai-game-creator-shell/tests/useProjectResourceCanvasLayout.test.ts`
## Rust u64 revision 不能直接穿过 JavaScript number 边界(2026-07-31
- 现象:资源布局 sidecar 的 revision 在 Rust 中可增长到完整 `u64`,但经 JSON / Tauri 返回 TypeScript 后只能用 `number` 表示;超过 `9_007_199_254_740_991` 时相邻整数会折叠为同一值,窗口可能持续 conflict,甚至用失真的 expectedRevision 破坏 CAS 判等语义。
- 原因:Rust 的 `checked_add` 只防止 `u64` 溢出,不能证明序列化后的整数仍能被 JavaScript 精确表示;纯 Rust `u64::MAX` 测试没有经过真实跨 JSON 合同。
- 处理:保留 Rust `u64` 存储类型,但把共享合同合法域冻结为 `0..=Number.MAX_SAFE_INTEGER`。共享 DTO 对 revision 自定义 serde 校验,Tauri 更新在任何项目或锁副作用前验证 expectedRevisionsidecar 读取拒绝超限值,前端在 IPC 读取、更新响应和请求发送前重复验证非负安全整数;达到上限时写入失败且 sidecar 字节不变。
- 验证:Rust 与 TypeScript 合同测试分别覆盖最大安全值往返、最大值加一拒绝;Tauri 持久层覆盖超限 expectedRevision 零 workbench 副作用、超限 sidecar 原字节保留和最大安全值递增失败;Hook 覆盖不可信读写响应不能进入 CAS。
- 关联:`server-rs/crates/shared-contracts/src/game_creation_app.rs``packages/shared/src/contracts/gameCreationApp.ts``apps/ai-game-creator-shell/src-tauri/src/project/resource_layout.rs``apps/ai-game-creator-shell/src/view/project-development/useProjectResourceCanvasLayout.ts`
## 抽通用 Runtime 时不要把产品持久文件直接变成公共 ABI
- 现象:为了快速“抽 crate”,直接把 Tauri package 内的 `AgentRuntimeState`、sidecar struct 或 Runner protocol 改成 `pub`,第二个消费者虽然能编译,却同时绑定游戏 schema、UI 投影、文件路径和未稳定恢复顺序。
@@ -319,7 +319,7 @@ game-project/
- 页面骨架固定为左侧现有全局导航、中间主视窗、右侧陶泥儿对话和底部子 Agent 状态栏;不新建第二套客户端或平行项目页。
- 中间主视窗提供 `资源管理 / 运行` 切换。`code-prototype` 任务完成前运行入口保持视觉不可用,但仍可点击查看“当前无可运行版本”,不能使用会阻断说明交互的原生 `disabled``aria-disabled`;完成后才允许进入运行表现层。切回资源管理只修改前端展示态,不伪造后端预览暂停结果。
- 资源管理从当前 `GameCreationAppManifest` 派生项目文档、项目版本和 `assets`,并把首页已导入附件作为当前项目上传资源展示。资源按文档、版本、美术、动作、音乐音效分区;`按依赖 / 按类型` 只改变当前前端排列方式,不写回 manifest,也不伪造资源依赖。
- 资源卡首版支持选择聚焦、文档展开 / 收起、搜索和类型筛选的界面交互。拖拽自由排版、画板编辑、生成关系连线、同类型版本资源替换均保留清晰入口或状态提示,但在具备正式布局 / 版本引用写回契约前不保存为业务事实。
- 资源卡支持选择聚焦、文档展开 / 收起、搜索和类型筛选的界面交互。2026-07-28 起,原一维会话拖拽已替换为两套二维坐标与本地 CAS sidecar;画板编辑、生成关系连线、同类型版本资源替换仍不得在缺少各自正式写回契约保存为业务事实。
- 运行表现层首版直接嵌入当前项目的 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`,前端不得自行计算。
- 底部状态栏默认展示策划、美术、程序 3 组,并允许在同一栏展开数值、音频、发布组;状态来自 manifest 与当前 Supervisor run 的 Runtime,悬停显示当前任务与进度。累计泥点必须等待后端计费归因投影;Agent.md 编辑和自定义 Skill 在来源审核、版本、权限、sandbox 与回滚合同完备前不向普通用户开放。
@@ -330,6 +330,25 @@ game-project/
- 外部 Runner 模式下,重试命令的 Session Runtime 快照可能仍指向旧 run,因此响应必须额外返回精确 `acceptedRunId` 作为入队受理事实,前端据此锁定恢复按钮并持续同步该 run,不能用 `state.runId` 是否立即切换判断失败。同一 `agentId + sourceRunId` 已存在非终态 retry successor 时必须幂等复用并返回其 `acceptedRunId`,不得再次入队或追加第二条 retry audit。
- 该界面切片只允许受限的 loopback iframe,不得引入远程 URL、第二套资产模型、前端正式资源关系、前端版本替换真相或前端计费结论。
### 资源画布布局持久化 V1
2026-07-28 起,资源画布布局持久化以工作台 PRD §5.2 和 §7.2 为唯一编码合同,实施边界如下:
- dependency 与 type 分别保存到 `.agent/workbench/resource-layouts/dependency.json``type.json`schema 固定为 `game-creator-resource-layout.v1`。布局是本地工作台 UI sidecar,不进入 manifest、游戏项目 mutation revision、Runtime verification、Agent 产物、资产或云端事实。
- `x / y` 使用 section 内容坐标,`updatedAt` 使用 Unix 毫秒;文件缺失只合成 revision `0` 空布局且不产生只读副作用。每个 mode 按 `projectId + mode + expectedRevision` 做 CAS,成功 revision 加一,冲突返回最新完整布局且不写文件。revision 虽在 Rust 中使用 `u64`,但跨 JSON / Tauri / TypeScript 的合法域固定为 `0..=Number.MAX_SAFE_INTEGER`;共享 DTO 序列化与反序列化、Tauri 输入和前端 IPC 响应均执行同一边界校验。
- Tauri 命令固定为 `read_local_project_resource_canvas_layout``update_local_project_resource_canvas_layout`。写命令先只读确认有效 manifest,再通过持久 `.layout.lock` 入口获取句柄级跨窗口系统锁,并在锁内复核 projectId、重新读取当前 sidecar。Unix 使用 `flock`,Windows 使用不共享文件句柄;释放只通过句柄 Drop / 进程退出完成,不使用 mtime stale 回收,也不删除锁文件。其余写入继续复用安全路径、链接检查、容量上限、恢复副本与原子替换能力;不能只依赖 React 状态或进程内锁。
- 前端从当前项目开发大组件中拆出纯布局模型与持久 Hook。默认布局、碰撞检查、资源增删协调和 section 边界由纯模型负责;读取、异步身份、CAS、错误回滚和冲突载入由 Hook 负责。Hook 以 `projectPath + projectId + mode` epoch 隔离异步结果,资源变化不取消首读或在途保存;单窗口写入经同一 FIFO 串行提交,每笔都使用最近一次成功 / 冲突响应的权威 revision。视图使用 Pointer Events 做二维拖动,保存中仍允许继续拖动并排队,普通点击、搜索、筛选和唯一资源详情浮层语义保持不变。
- 新资源只在第一次进入某个 mode 时计算默认不重叠位置;全部现存坐标保持不变。搜索、筛选、窗口 resize 和 mode 切换不得重排或回写已有坐标,窄视图通过 section 画布范围与滚动访问,不裁切持久坐标。
- type 默认布局固定按 `subtype -> mediaType -> label -> id` 排序。manifest 资产的 subtype 使用 `asset.kind`,任务产物、导入附件和 Agent 文本成果使用稳定的来源 fallback;subtype 必须进入资源协调签名,不能因 MIME 相同而退化成按名称混排。
- 普通保存失败恢复最近可信持久布局;CAS 冲突载入对方最新布局并要求用户重新拖动,同时清除基于旧快照排队的全部手动意图,不自动重放旧坐标。即使冲突发生在允许自动重试的资源协调请求上,只要本次冲突清除了排队手动意图,重新拖动提示就必须绑定当前 scope 保留,不得被后续资源协调成功、失败或通用提示定时器静默清除;新的手动布局成功保存或 scope 切换后才解除。资源自动协调可基于冲突布局最多追加两次重试,持续跨窗口竞争时停止自旋并保留当前会话协调结果。损坏、未知 schema、身份冲突、超限与链接文件失败关闭,不能用空布局覆盖原文件。
- 本切片不包含资源关系线、资源替换、详情浮层位置、缩放 / 平移、搜索 / 筛选条件、当前 mode,也不修改 `api-server` 或 SpacetimeDB。关系线与其它 P1 能力必须在本切片独立验收后继续接入。
实施顺序固定为:先同步 TypeScript / Rust DTO 与序列化测试,再实现 Tauri sidecar 读写和 CAS,随后接入前端纯模型、持久 Hook 与二维拖动,最后完成 Rust 安全测试、React 交互测试、跨重启 / 双窗口验收和文档状态回写。任何一步不得用 `localStorage`、manifest 字段或只在当前 React 会话有效的状态冒充项目持久化。
2026-07-30 前端并发与性能加固状态:首读、资源更新和拖动保存已拆成 scope epoch + scope 内单写者 FIFO;定向 Hook 测试覆盖首读期间资源变化、在途手动 CAS 后资源协调、冲突清除排队拖动、旧 mode 迟到读取 / 写入、新 scope 不等待旧 scope 卡死请求、持续冲突有界停止,以及在途 CAS 后同资源连续拖动折叠为最后坐标。旧 scope 请求已经发出后不做不安全取消,但会释放前端活动槽并由 epoch 丢弃迟到响应;同 scope 的在途请求仍保持唯一。默认布局用 section 分组与二维占用索引替代逐 slot 全量扫描,dependency 使用按列单调游标,type 使用单调 slot 游标;`4096` 项双模式性能回归纳入前端测试,避免恢复到接近 `O(N³)` 的主线程阻塞实现。
2026-07-30 Rust 并发与零副作用加固状态:资源布局锁已由 `create_new + mtime stale 删除` 改为持久锁文件上的 Unix `flock` / Windows 独占句柄,活锁即使 mtime 很旧也不能被另一个写入者回收,释放后仍复用同一文件实例。更新命令携带只用于校验的 `expectedProjectId`,在任何目录创建前先读取 manifest 并拒绝旧项目窗口,锁内再次核对 projectId;不存在根、非项目根、损坏 manifest 和路径重建后的旧窗口均不产生 `.agent/workbench`。revision 在共享 serde、Tauri 命令和前端 IPC 三层限制到 `Number.MAX_SAFE_INTEGER`,达到上限时保持原文件并失败关闭,不能让 Rust `u64` 值在 JavaScript 中失真后击穿 CAS。
## 分阶段实施
1. 在 `platform-agent` 建立游戏创作专业组与种子任务图契约。
@@ -467,6 +467,56 @@ export interface GameCreationAppAssetManifestEntry {
source: GameCreationAppAssetSource;
}
export const GAME_CREATION_RESOURCE_LAYOUT_SCHEMA_VERSION =
'game-creator-resource-layout.v1' as const;
export const GAME_CREATION_RESOURCE_LAYOUT_MAX_SAFE_REVISION = 9_007_199_254_740_991;
export function isSafeProjectResourceCanvasLayoutRevision(
value: unknown,
): value is number {
return (
typeof value === 'number' &&
Number.isSafeInteger(value) &&
value >= 0 &&
value <= GAME_CREATION_RESOURCE_LAYOUT_MAX_SAFE_REVISION
);
}
export type ProjectResourceCanvasLayoutMode = 'dependency' | 'type';
export type ProjectResourceCanvasSection =
| 'document'
| 'version'
| 'art'
| 'audio';
export interface ProjectResourceCanvasPosition {
resourceId: string;
section: ProjectResourceCanvasSection;
x: number;
y: number;
manuallyPlaced: boolean;
}
export interface ProjectResourceCanvasLayout {
schemaVersion: typeof GAME_CREATION_RESOURCE_LAYOUT_SCHEMA_VERSION;
projectId: string;
mode: ProjectResourceCanvasLayoutMode;
revision: number;
positions: ProjectResourceCanvasPosition[];
updatedAt: number;
}
export type UpdateProjectResourceCanvasLayoutResult =
| {
status: 'updated';
layout: ProjectResourceCanvasLayout;
}
| {
status: 'conflict';
layout: ProjectResourceCanvasLayout;
};
export type GameCreationAppPreviewStatus =
| 'stopped'
| 'starting'
+30 -4
View File
@@ -42,6 +42,14 @@ const aiGameCreatorProjectDevelopmentSource = fs.readFileSync(
'apps/ai-game-creator-shell/src/view/project-development/index.tsx',
'utf8',
);
const aiGameCreatorLocalGamePreviewFrameSource = fs.readFileSync(
'apps/ai-game-creator-shell/src/features/project-workspace/LocalGamePreviewFrame.tsx',
'utf8',
);
const aiGameCreatorSupervisorChatOnlyViewSource = fs.readFileSync(
'apps/ai-game-creator-shell/src/features/project-workspace/SupervisorChatOnlyView.tsx',
'utf8',
);
const aiGameCreatorPreviewRustSource = fs.readFileSync(
'apps/ai-game-creator-shell/src-tauri/src/preview.rs',
'utf8',
@@ -2409,20 +2417,38 @@ function assertAiGameCreatorShellUserDevBoundary() {
}
const clientPreviewFrameCount = [
...aiGameCreatorProjectDevelopmentSource.matchAll(/<iframe\b/g),
...aiGameCreatorLocalGamePreviewFrameSource.matchAll(/<iframe\b/g),
].length;
if (clientPreviewFrameCount !== 1) {
throw new Error(
'AI game creator client workbench must own exactly one local preview frame',
);
}
const projectDevelopmentPreviewMountCount = [
...aiGameCreatorProjectDevelopmentSource.matchAll(
/<LocalGamePreviewFrame\b/g,
),
].length;
const supervisorChatPreviewMountCount = [
...aiGameCreatorSupervisorChatOnlyViewSource.matchAll(
/<LocalGamePreviewFrame\b/g,
),
].length;
if (
projectDevelopmentPreviewMountCount !== 1 ||
supervisorChatPreviewMountCount !== 1
) {
throw new Error(
'AI game creator client views must delegate preview rendering to the shared local preview frame',
);
}
for (const snippet of [
'function resolveEmbeddedPreviewUrl(',
"url.protocol !== 'http:' || url.hostname !== '127.0.0.1'",
'sandbox="allow-scripts allow-same-origin allow-forms allow-pointer-lock"',
'src={embeddedPreviewUrl}',
'src={embeddedUrl}',
]) {
if (!aiGameCreatorProjectDevelopmentSource.includes(snippet)) {
if (!aiGameCreatorLocalGamePreviewFrameSource.includes(snippet)) {
throw new Error(
`AI game creator embedded preview boundary drifted: missing ${snippet}`,
);
@@ -2459,7 +2485,7 @@ function assertAiGameCreatorShellUserDevBoundary() {
}
for (const snippet of [
'#[cfg(all(debug_assertions, not(test)))]\npub(crate) fn open_developer_window(',
'#[cfg(all(debug_assertions, not(test)))]\n open_developer_window(app.handle())?;',
'#[cfg(all(debug_assertions, not(test)))]\n if game_chat_launch.is_none() {\n open_developer_window(app.handle())?;\n }',
]) {
if (!aiGameCreatorShellTauriSource.includes(snippet)) {
throw new Error(
@@ -351,15 +351,16 @@ async fn vector_engine_deadline_clips_stalled_attempt_and_prevents_retry() {
}
});
let started_at = Instant::now();
let settings = VectorEngineImageSettings {
let mut settings = VectorEngineImageSettings {
base_url: format!("http://{server_addr}/v1"),
api_key: "test-key".to_string(),
request_timeout_ms: 5_000,
request_deadline: Some(started_at + Duration::from_millis(150)),
request_deadline: None,
};
let http_client =
build_vector_engine_image_http_client(&settings).expect("client should build");
let started_at = Instant::now();
settings.request_deadline = Some(started_at + Duration::from_secs(1));
let error = create_vector_engine_image_generation(
&http_client,
@@ -379,9 +380,16 @@ async fn vector_engine_deadline_clips_stalled_attempt_and_prevents_retry() {
PlatformImageError::Request { timeout: true, .. }
));
assert!(
started_at.elapsed() < Duration::from_secs(1),
started_at.elapsed() < Duration::from_secs(3),
"attempt 应使用剩余 deadline,而不是完整配置 timeout"
);
tokio::time::timeout(Duration::from_secs(1), async {
while request_count.load(Ordering::SeqCst) == 0 {
tokio::task::yield_now().await;
}
})
.await
.expect("mock server should observe the single attempted request");
assert_eq!(request_count.load(Ordering::SeqCst), 1);
server.abort();
}
@@ -293,7 +293,9 @@ pub fn new_game_creation_app_seed_tasks() -> Vec<GameCreationAppTaskState> {
"game/game_design.md",
"assets/ui-prototype.png",
],
["核心循环、胜负条件和第一版关卡目标明确,且已基于规范图生成可读的 16:9 横屏界面原型图"],
[
"核心循环、胜负条件和第一版关卡目标明确,且已基于规范图生成可读的 16:9 横屏界面原型图",
],
),
task(
"balance-director",
@@ -485,6 +487,94 @@ pub struct GameCreationAppAssetManifestEntry {
pub source: GameCreationAppAssetSource,
}
pub const GAME_CREATION_RESOURCE_LAYOUT_SCHEMA_VERSION: &str = "game-creator-resource-layout.v1";
pub const GAME_CREATION_RESOURCE_LAYOUT_MAX_SAFE_REVISION: u64 = 9_007_199_254_740_991;
pub fn validate_project_resource_canvas_layout_revision(revision: u64) -> Result<(), &'static str> {
if revision > GAME_CREATION_RESOURCE_LAYOUT_MAX_SAFE_REVISION {
return Err("资源布局 revision 超出 JavaScript 安全整数范围");
}
Ok(())
}
fn serialize_project_resource_canvas_layout_revision<S>(
revision: &u64,
serializer: S,
) -> Result<S::Ok, S::Error>
where
S: serde::Serializer,
{
validate_project_resource_canvas_layout_revision(*revision)
.map_err(serde::ser::Error::custom)?;
serializer.serialize_u64(*revision)
}
fn deserialize_project_resource_canvas_layout_revision<'de, D>(
deserializer: D,
) -> Result<u64, D::Error>
where
D: serde::Deserializer<'de>,
{
let revision = u64::deserialize(deserializer)?;
validate_project_resource_canvas_layout_revision(revision).map_err(serde::de::Error::custom)?;
Ok(revision)
}
#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(rename_all = "kebab-case")]
pub enum ProjectResourceCanvasLayoutMode {
Dependency,
Type,
}
#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(rename_all = "kebab-case")]
pub enum ProjectResourceCanvasSection {
Document,
Version,
Art,
Audio,
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct ProjectResourceCanvasPosition {
pub resource_id: String,
pub section: ProjectResourceCanvasSection,
pub x: u32,
pub y: u32,
pub manually_placed: bool,
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct ProjectResourceCanvasLayout {
pub schema_version: String,
pub project_id: String,
pub mode: ProjectResourceCanvasLayoutMode,
#[serde(
deserialize_with = "deserialize_project_resource_canvas_layout_revision",
serialize_with = "serialize_project_resource_canvas_layout_revision"
)]
pub revision: u64,
pub positions: Vec<ProjectResourceCanvasPosition>,
pub updated_at: u64,
}
#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(rename_all = "kebab-case")]
pub enum UpdateProjectResourceCanvasLayoutStatus {
Updated,
Conflict,
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct UpdateProjectResourceCanvasLayoutResult {
pub status: UpdateProjectResourceCanvasLayoutStatus,
pub layout: ProjectResourceCanvasLayout,
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(rename_all = "kebab-case")]
pub enum GameCreationAppPreviewStatus {
@@ -1305,7 +1395,9 @@ mod tests {
);
assert_eq!(
design.acceptance_criteria,
["核心循环、胜负条件和第一版关卡目标明确,且已基于规范图生成可读的 16:9 横屏界面原型图"]
[
"核心循环、胜负条件和第一版关卡目标明确,且已基于规范图生成可读的 16:9 横屏界面原型图"
]
);
let art = manifest
@@ -1440,4 +1532,77 @@ mod tests {
json!("canvas-project-1")
);
}
#[test]
fn resource_canvas_layout_uses_frozen_json_contract() {
let layout = ProjectResourceCanvasLayout {
schema_version: GAME_CREATION_RESOURCE_LAYOUT_SCHEMA_VERSION.to_string(),
project_id: "project-layout-1".to_string(),
mode: ProjectResourceCanvasLayoutMode::Dependency,
revision: 1,
positions: vec![ProjectResourceCanvasPosition {
resource_id: "asset-1".to_string(),
section: ProjectResourceCanvasSection::Art,
x: 10,
y: 20,
manually_placed: true,
}],
updated_at: 123,
};
assert_eq!(
serde_json::to_value(layout).expect("resource layout should serialize"),
json!({
"schemaVersion": "game-creator-resource-layout.v1",
"projectId": "project-layout-1",
"mode": "dependency",
"revision": 1,
"positions": [
{
"resourceId": "asset-1",
"section": "art",
"x": 10,
"y": 20,
"manuallyPlaced": true
}
],
"updatedAt": 123
})
);
}
#[test]
fn resource_canvas_layout_revision_rejects_json_outside_js_safe_integer_range() {
let layout = ProjectResourceCanvasLayout {
schema_version: GAME_CREATION_RESOURCE_LAYOUT_SCHEMA_VERSION.to_string(),
project_id: "project-layout-safe-revision".to_string(),
mode: ProjectResourceCanvasLayoutMode::Dependency,
revision: GAME_CREATION_RESOURCE_LAYOUT_MAX_SAFE_REVISION,
positions: vec![],
updated_at: 123,
};
let serialized = serde_json::to_vec(&layout).expect("safe revision should serialize");
let decoded: ProjectResourceCanvasLayout =
serde_json::from_slice(&serialized).expect("safe revision should deserialize");
assert_eq!(
decoded.revision,
GAME_CREATION_RESOURCE_LAYOUT_MAX_SAFE_REVISION
);
let unsafe_layout = ProjectResourceCanvasLayout {
revision: GAME_CREATION_RESOURCE_LAYOUT_MAX_SAFE_REVISION + 1,
..layout
};
assert!(serde_json::to_value(unsafe_layout).is_err());
let unsafe_payload = json!({
"schemaVersion": "game-creator-resource-layout.v1",
"projectId": "project-layout-safe-revision",
"mode": "dependency",
"revision": GAME_CREATION_RESOURCE_LAYOUT_MAX_SAFE_REVISION + 1,
"positions": [],
"updatedAt": 123
});
assert!(serde_json::from_value::<ProjectResourceCanvasLayout>(unsafe_payload).is_err());
}
}