移除客户端图集数量约束并按实际产物交付
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust crates (pull_request) Has been cancelled
Project CI / Backend tests (pull_request) Has been cancelled
Project CI / Native shell tests (pull_request) Has been cancelled
Project CI / Frontend tests (pull_request) Has been cancelled
Project CI / Repository checks (pull_request) Has been cancelled
Project CI / AI game creator shell web tests (pull_request) Has been cancelled

删除美术包和直接生图工具的切片数量参数及按序用途映射
按源图集隔离切片路径并返回完整资源身份与路径标注预览
支持零切片总图交付及动态集合事务和重生成恢复
保留旧数量账本原请求身份并修复完成结果切分声明持久化
同步工具提示词、美术技能、技术规范与定向测试证据
This commit is contained in:
2026-10-05 10:58:08 +01:00
parent 7019dd8634
commit ea18cf8f12
20 changed files with 1141 additions and 494 deletions
@@ -9,7 +9,7 @@
"agc_update_plan.description": "更新当前回合的进度计划,字段与 update_plan 相同:可选 explanation,以及 plan 中的 step/status(pending、in_progress、completed)。它可与其它独立工具并行;同一计划的连续更新按依赖顺序提交。计划完成只表示进度,不代替宿主交付验收。",
"agc_write_file.parameters.path": "当前项目根下的相对路径,例如 game/index.html、assets/manifest.json 或 data/gameplay-spec.md",
"agc_write_file.parameters.content": "仅填写目标文件的完整原始 UTF-8 正文",
"taonier_prepare_game_art.description": "创建或恢复当前 AGC 项目的陶泥儿标准游戏美术包。默认复用有效美术包;根据当前对话需要选择 regenerate 重新生成。授权使用 AGC 客户端当前登录会话;遇到 401/403 时报告客户端登录或权限状态异常并停止。",
"taonier_prepare_game_art.description": "创建或恢复当前 AGC 项目的陶泥儿标准游戏美术包,返回总图、实际切片与路径标注预览;需看图识别用途,内容要求不保证切片数量或顺序。默认复用有效美术包;根据当前对话需要选择 regenerate 重新生成。授权使用 AGC 客户端当前登录会话;遇到 401/403 时报告客户端登录或权限状态异常并停止。",
"taonier_prepare_game_art.parameters.brief": "面向当前游戏的简洁视觉需求:写明主题、风格、玩家主体及状态、目标或收集物、障碍或场景元素、反馈特效等具体需要的素材。工具会补齐沿用规范图和素材独立排布的通用要求;内容类别不代表切片数量或返回顺序",
"taonier_prepare_game_art.parameters.mode": "缺省安全复用有效美术包;Codex 仅在当前对话需要换一套或重新生成时使用 regenerate",
"agc_generate_image.description": "生成一张新图片:普通插画、角色立绘、统一视觉规范图、游戏 UI 设计图或透明游戏素材图集。仅在用户明确要求生成新图时调用。",
@@ -17,10 +17,9 @@
"agc_generate_image.parameters.kind": "image=普通新图(保留生成原图),character=角色图(纯色底生成后自动抠图,产出透明背景立绘,prompt 只描述角色主体),icon-spec=统一视觉规范图,ui-design=完整 UI 设计图,icon-spritesheet=透明游戏素材图集(纯色底生成后自动抠图并切片,项目须已有 icon-spec 规范图),publication-material=发布宣传图",
"agc_generate_image.parameters.assetName": "本地素材的人类可读显示名称",
"agc_generate_image.parameters.outputPath": "可选项目相对输出路径,必须位于 assets/ 且不能覆盖已有文件",
"agc_generate_image.parameters.sliceMode": "仅适用于 kind=icon-spritesheet,且必填:需求明确要求等分网格、固定槽位或指定行列数时传 grid,并用 gridX/gridY 传入需求中的行列数;自由排布、数量不定或只要求一张图集时传 connected-components,需要约束素材张数时用 sliceCount。",
"agc_generate_image.parameters.sliceMode": "仅适用于 kind=icon-spritesheet,且必填:需求明确要求等分网格、固定槽位或指定行列数时传 grid,并用 gridX/gridY 传入需求中的行列数;自由排布、数量不定或只要求一张图集时传 connected-components,实际切片数量由图像决定,内容需求不保证数量或用途顺序;查看返回图片后识别用途。",
"agc_generate_image.parameters.gridX": "grid 模式横向网格数量,只能与 sliceMode=grid 同时提供",
"agc_generate_image.parameters.gridY": "grid 模式纵向网格数量,只能与 sliceMode=grid 同时提供",
"agc_generate_image.parameters.sliceCount": "只与 kind=icon-spritesheet 且 sliceMode=connected-components 同时提供,用于约束目标素材张数;省略时按图像内容自动识别",
"agc_generate_image.parameters.screenColor": "抠图纯色背景,仅用于 kind=character(角色形象)和 kind=icon-spritesheet(图标素材)。生成时把主体置于该纯色背景上,回图后据此抠除背景。取值为 auto 或下列色板 hex 之一,传值只填 hex 本身:#CFEFFF(浅雾蓝)、#B0C2E0(浅钢蓝)、#FFD6C2(暖浅桃色)、#E6D8FF(淡薰衣草紫)、#F4D8E8(浅粉灰)、#7FB3FF(中度天蓝)、#FFF2A8(浅柠黄)、#CFFFE1(淡薄荷绿)、#D8DEE8(浅中性灰)、#D8D2E8(淡灰紫)、#A8F7F0(高对比浅青)、#A0BBA0(灰竹绿);auto 时由服务端自动选色。手动指定时选择与主体颜色明显不同的背景色",
"agc_edit_image.description": "修改一张已登记图片:换装、改色、换背景或局部重绘。sourceLocalAssetId 必须使用 agc_list_registered_assets 返回的当前项目图片 localAssetId。",
"agc_edit_image.parameters.sourceLocalAssetId": "当前项目已登记的图片 localAssetId",
@@ -7,7 +7,7 @@
"playtest.laneDefense": "完成合同要求 lane-defense-v1 交互试玩。game/index.html 必须持续更新 <script id=\"playable-web-game-state\" type=\"application/json\">,JSON 固定包含 schemaVersion=playable-web-game-state.v1、单调递增 sequence、phase=ready|playing|won|lost、正整数 level、selectedDefenderId、defenders 数组、enemies 数组;每个 enemy 必须含非空 id、非负 lane、会随移动变化的 position、health 与正数 maxHealth。界面必须清晰显示一个原创项目标题、至少两个原创防御单位选项、资源与波次状态,以及开始、加速、下一关和重开等可理解操作;玩法类型不授权复刻现有游戏,不得沿用、翻译或近似改写现有作品的角色、单位名、Logo、贴图、标志性布局或受保护视觉语言。界面必须提供 data-playtest-id=\"start\"、data-playtest-id=\"defender-option\"、data-playtest-id=\"lane-cell\"、data-playtest-id=\"speed-up\"、data-playtest-id=\"next-level\"、data-playtest-id=\"restart\" 的真实可点击控件;每个固定 data-playtest-id 在对应受控试玩步骤都必须恰好匹配一个可见且启用(disabled=false)的真实可点击 HTMLElement,同一固定值不得出现在多个控件上。防御单位多选项 UI 只能给一个真实控件设置 data-playtest-id=\"defender-option\" 作为自动化入口,关卡多格 UI 只能给一个真实控件设置 data-playtest-id=\"lane-cell\" 作为自动化入口,其余选项和格子不得复用这两个固定值。受控试玩会依次开始、选择并放置防御单位、加速,要求敌人移动并受伤、关卡进入 won;随后 next-level 必须让 level 增加,restart 必须再次推进 sequence 并回到 ready 或 playing。",
"owner.visualUsage": "本轮必须实际接入已登记的平台美术切片:先用 asset.list 读取 assets/art-spritesheet-slices/manifest.json,再在 game/index.html 的可见 canvas 主循环中为 player、blocks-and-targets、obstacles-and-scene、feedback-effects 四个切片分别创建 Image 并用相对路径加载;在 requestAnimationFrame 绘制中对每个已加载切片调用 ctx.drawImage(image, dx, dy, dw, dh) 或九参数裁剪形式,目标区域必须可见且至少 32×32。只放置 <img>/<picture>、只展示整张 assets/art-spritesheet.png、只写路径或只在注释中引用都不满足完成合同。",
"owner.publishPackage": " publish-package 必须根据本轮实际产物、验证与试玩结果完成 exports/README.md,不得留下模板字段或 forbidden marker。禁止在表示“已完成”或“无”的句子中复述任何 forbidden marker 字面词;请直接陈述实际完成内容。所有 Markdown checklist 必须使用 [x] 或 [X],不得保留未勾选项。",
"owner.visualRequirement": "任务声明中的视觉图片按项目需求选择工具、数量、输出路径、尺寸和布局;需要图集时用 sliceCount 指定切片数量。以实际声明资源的登记状态作为验收依据。",
"owner.visualRequirement": "任务声明中的视觉图片按项目需求选择工具、数量、输出路径、尺寸和布局;图集按实际产物数量返回,查看图片识别用途。以实际声明资源的登记状态作为验收依据。",
"owner.verifyCodePrototype": "code-prototype 必须对可玩入口执行 game.static_smoke;完整 DAG 的最终静态与浏览器验收继续由后续质量任务承担。",
"owner.verifyArtifact": "完成固定正式产物后直接交付,由 Runtime 在收束门内验证本人固定 owner 产物;禁止调用 game.static_smoke、project.verify、command.run_limited 或 preview 工具冒充 owner 产物验证。",
"owner.verifyPublishPackage": "publish-package 必须按本任务的发布完成合同和当前 run 的可用验证门完成验证并交付,验证凭证必须来自当前 run。",
@@ -36,7 +36,7 @@
"preview.start.description": "启动当前项目的 loopback HTTP 预览。",
"preview.validate.description": "用真实浏览器验证桌面和移动预览并保存证据。",
"image.inspect.description": "让视觉模型检查一至两张项目内图片。",
"canvas.asset_generate.description": "通过已配置的 External Editor API 按项目需求生成图片或图集并登记到画布、素材库和项目 assets;可使用已登记资源作为参考。assetKind=icon-spritesheet 的 prompt 去除首尾空白后须为 1 到 200 个 Unicode 字符,保留内部换行并作为单条 iconDescriptions 原样提交,超限拒绝,不截断、不拆条,客户端不追加生图指令。assetKind=icon-spritesheet 时 sliceMode 必填且没有默认值:需求要求等分网格、固定槽位或指定行列数时用 grid 并提供来自需求本身的 gridX/gridY;自由排布、数量不定或只要求一张图集时用 connected-components,可用 sliceCount 约束素材张数;其它 assetKind 不得携带 sliceMode/gridX/gridY。",
"canvas.asset_generate.description": "通过已配置的 External Editor API 按项目需求生成图片或图集并登记到画布、素材库和项目 assets;可使用已登记资源作为参考。assetKind=icon-spritesheet 的 prompt 去除首尾空白后须为 1 到 200 个 Unicode 字符,保留内部换行并作为单条 iconDescriptions 原样提交,超限拒绝,不截断、不拆条,客户端不追加生图指令。assetKind=icon-spritesheet 时 sliceMode 必填且没有默认值:需求要求等分网格、固定槽位或指定行列数时用 grid 并提供来自需求本身的 gridX/gridY;自由排布、数量不定或只要求一张图集时用 connected-components,实际数量由图像内容决定,不按返回顺序推断用途;其它 assetKind 不得携带 sliceMode/gridX/gridY。",
"cocos.editor.execute.description": "在当前项目对应的已打开 Cocos Creator 编辑器中执行一段有界代码;仅提交 code,客户端负责绑定项目与编辑器进程。",
"unity.editor.execute.description": "在当前 Unity 项目已打开的编辑器中执行 C#。仅提交 code;结果待核对时禁止自动重发。",
"godot.editor.execute.description": "在当前 Godot 项目已打开的编辑器中执行支持 return/await 的 GDScript 函数体。仅提交 code;结果待核对时禁止自动重发。",
@@ -53,7 +53,6 @@
"canvas.asset_generate.parameters.sliceMode": "仅 assetKind=icon-spritesheet 生效且必填,没有默认值:等分网格或固定槽位用 grid,自由排布用 connected-components",
"canvas.asset_generate.parameters.gridX": "只与 sliceMode=grid 同时提供",
"canvas.asset_generate.parameters.gridY": "只与 sliceMode=grid 同时提供",
"canvas.asset_generate.parameters.sliceCount": "只与 sliceMode=connected-components 同时提供,用于约束目标素材张数",
"agent.delegate.parameters.acceptanceCriteria": "初次委派必填。带 repairOfDelegationId 的返工或澄清续跑传 null,由 Runtime 从原 delivery 继承权威合同。",
"agent.delegate.parameters.expectedArtifacts": "与 acceptanceCriteria 同进同出:初次委派必填,返工与澄清续跑一起传 null 由 Runtime 继承。"
}
@@ -42,7 +42,7 @@
"art_director_without_editor": "{prompt}\n\n你负责确定原创视觉方向。这是只读协调任务:本轮验收以正式视觉方向结论为准,只完成正式 director 结论并直接交付,不修改项目文件,不调用 canvas.asset_generate。",
"art_director_with_editor": "{prompt}\n\n你负责确定原创视觉方向。根据项目实际需要决定是否生成图片、数量,并选择 canvas.asset_generate 的 assetKind、outputPath、尺寸、比例和提示词。需要参考图时使用已登记资源 ID,生成后核对返回资源、权限、计费和登记状态。",
"art_asset_plan_without_editor": "{prompt}\n\n你负责首版美术资产清单交付。本轮必须写入可解析的 assets/manifest.art.json,记录所需素材、用途、推荐规格和当前未生成状态;不调用 canvas.asset_generate。完成清单后直接交付,由 Runtime 验证本人正式产物;不得调用 project.verify、game.static_smoke 或 preview.validate,也不得编辑 game/index.html。",
"art_asset_plan_with_editor": "{prompt}\n\n你负责按项目实际需求规划和生成美术素材。使用 asset.list 了解已有资源,再按需调用 canvas.asset_generate;数量、文件名、素材类别、切片布局和尺寸由当前需求决定。spritesheet 可通过 sliceCount 指定切片数量,也可以生成普通单图或多张独立图片。生成后核对资源登记、透明度、警告和实际使用情况。",
"art_asset_plan_with_editor": "{prompt}\n\n你负责按项目实际需求规划和生成美术素材。使用 asset.list 了解已有资源,再按需调用 canvas.asset_generate;数量、文件名、素材类别、切片布局和尺寸由当前需求决定。spritesheet 按实际图像内容返回切片,需看图识别用途,也可以生成普通单图或多张独立图片。生成后核对资源登记、透明度、警告和实际使用情况。",
"system_header": "你正在使用 Genarrative AI 游戏创作多智能体 Runtime。你必须直接调用当前请求广告的原生函数:只在复杂任务首次拆解、实际进度变化、steer 调整顺序或最终收束时调用 update_agent_plan,并提交 explanation 与完整 steps。steps 只允许 pending、in_progress、completed 且同时最多一个 in_progress;已完成步骤必须保留 completed 状态,所有必要步骤 completed 后才能调用 respond_to_user。只能请求以下 Runtime 当前注册的原生可执行工具:{tool_catalog}。",
"completion_blocker_protocol": "通用完成阻断规则:如果最新 observation 的 tool 为 runtime.autonomous_completion 且 status 为 blocked,必须先读取该 observation.detail 的 nextRequiredAction,并据此调用合适的读取、修复和验证工具。只有完成要求的动作、取得后续可信 observation 且通过完成门禁后,才能调用 respond_to_user 给出最终回复。",
"command_exec_linux": "command.exec 使用 {\"program\":\"受信任 PATH 中的裸可执行名\",\"args\":[\"逐项 argv\"],\"cwd\":\"可选项目内相对目录\",\"timeoutSeconds\":120};Linux 命令运行在受限沙箱内,允许 bash -lc、管道、重定向和项目脚本,但不接受环境变量、宿主 executable 路径、mount 或网络策略输入",
@@ -1,6 +1,6 @@
{
"schemaVersion": "agc-skill-pack.v1",
"version": "2026-08-26.41",
"version": "2026-08-26.42",
"skills": [
{
"name": "agc-unity-editor",
@@ -101,7 +101,7 @@
"agents/openai.yaml",
"references/platform-art-contract.md"
],
"sha256": "d38571ffb5b12b40f23d04b76edbb06cbda06a11590d4dd0f784b524bf30071c"
"sha256": "e8f887a4199e8aebd720db4cd90ccea4611c1f51f601625e41929163635b4a5b"
},
{
"name": "agc-web-game-development",
@@ -16,7 +16,7 @@ Use
`agc_generate_image` for a single ordinary image, character image, visual-spec
image, UI design image, or publication material; use `agc_edit_image` for an
edit of an existing registered image; use `taonier_prepare_game_art` only for
the complete game-art package and its canonical slices.
the complete game-art package and its actual slices.
With `agc_generate_image`, `kind="character"` and `kind="icon-spritesheet"`
produce transparent-background results; write the prompt for the subject
@@ -29,8 +29,8 @@ required; choose it from the brief:
or brief actually names equal grid cells, fixed slots, or a concrete
column/row count; those dimensions must come from that requirement.
- Use `sliceMode="connected-components"` for free-form sheets, an open number of
subjects, or a request for one sheet; constrain the subject count with
`sliceCount`.
subjects, or a request for one sheet. Actual slice count follows image content;
the tool does not expose a count constraint.
Pass `sliceMode` only for `kind="icon-spritesheet"`, and `gridX`/`gridY` only for
`sliceMode="grid"`. Read the selected mode from the returned result.
@@ -51,7 +51,7 @@ If the tool returns `401` or `403`, stop the operation and report that login or
1. Inspect existing `assets/` and registered project evidence before requesting new art. Reuse suitable assets when they satisfy the current brief. If required visual elements are absent or unsuitable, call the appropriate generation tool during the same game implementation task.
2. For one new image, call `agc_generate_image` with `kind="image"` (or `character`, `icon-spec`, `ui-prototype`, or `publication-material` when that is the explicit intent). For changes to an existing registered image, call `agc_edit_image` with its `sourceLocalAssetId`; do not fake an edit with a new-image request. For a complete game-art package, call `taonier_prepare_game_art` only when the current intent requires new or recoverable platform art. Use `mode="regenerate"` only when the user's latest standalone message clearly and explicitly commands regeneration, such as `请重新生成美术`. A brief, condition, negation, alternative, cost qualifier, deferral, quotation, historical wording, or tool argument does not authorize regeneration. Otherwise use `mode="reuse-or-create"`. Pass a concise game-specific visual brief that names the required gameplay entities, background exclusions, tiling needs, and viewport constraints. Do not call either generation tool for greetings, date questions, or text-only code fixes.
3. Treat the tool result as authoritative. Read `mode`, `assetPaths`, `slicePaths`, `resources`, and every entry in both `warnings` and `sliceWarnings`. Use only the relative paths and identities returned in `resources`. Never invent a resource, slice, platform identity, warning-free result, or successful regeneration.
4. A newly created or explicitly regenerated standard package is complete only when `slicePaths` contains the four canonical independent slices. An empty or partial `slicePaths` result never satisfies an independent-asset requirement: if a `sliceWarning` reports too many or unusable elements, narrow the edit/generation brief or generate the needed independent images and continue the integration; do not guess atlas coordinates, fabricate derivatives, or silently fall back to placeholders. A trusted legacy complete sheet may still be used without slices only when the current request does not require independent assets.
4. Slice count and returned order do not prove semantic roles. Inspect the complete sheet and actual slices, identify entities and states, and decide which satisfy the brief. The tool labels previews by path; use native image viewing for `remainingPreviewPaths` or details not visible in a preview. An empty `slicePaths` with a valid sheet is a completed generation with a warning, not proof that independent assets exist. Use the sheet where suitable; if independent assets are needed, inspect and process the real image or request appropriate additional art within the current authorization. Verify local crops visually and register them through `agc_import_account_assets.localPaths`. Do not guess coordinates or invent Canvas identities. Keep game-specific semantic bindings in game code or configuration, not provenance manifests.
5. Inspect the returned background, complete sheet, and available slice previews before integrating them. Then use suitable returned runtime assets in the game's actual visible experience and confirm their visible use in desktop and mobile playtest evidence. The implementation is incomplete while generated assets remain unused, are referenced only by documentation, or are replaced by emoji, CSS shapes, or other placeholders where the brief requires the generated art. `art-spec.png` is a reference specification, not a runtime background, character, prop, or effect. Background exclusions, seamless tiling, entity semantics, and final draw dimensions are visual/runtime acceptance checks; a prompt alone does not prove them. A hidden or side-panel preview does not count as gameplay use.
6. Preserve warning details in the final report. If the tool reports missing credentials, uncertain operation state, invalid provenance, download failure, or decode failure, stop and report the actionable reason.
@@ -1,26 +1,26 @@
# Platform Art Contract
`agc_generate_image` and `taonier_prepare_game_art` are the reviewed paid art entries exposed to the AGC Codex thread. The former covers one ordinary/character/spec/UI/publication image; `agc_edit_image` covers edits to an existing registered image; the latter covers the complete game-art package and canonical slices. Call these tools only through the approved AGC session.
`agc_generate_image` and `taonier_prepare_game_art` are the reviewed paid art entries exposed to the AGC Codex thread. The former covers one ordinary/character/spec/UI/publication image; `agc_edit_image` covers edits to an existing registered image; the latter covers the complete game-art package and actual slices. Call these tools only through the approved AGC session.
- For a package, describe the concrete theme, style, entities, states, and effects in `brief`. The tool includes the package's shared visual-spec and placement requirements in generation. No additional tool fields are required. Ordinary `agc_generate_image` icon descriptions remain the supplied text without package requirements.
- `mode="reuse-or-create"` reuses a complete trusted package and creates only missing assets. It is the safe default for existing games.
- `mode="regenerate"` is reserved for an explicit user request to replace or restyle the package. Use it even when a complete package exists, but never bypass an unresolved paid operation.
- `mode="regenerate"` requires a trusted, decodable, registered `art-spec.png` and background. An old spritesheet or canonical slices may be absent. If either the spec or background is missing or invalid, use `mode="reuse-or-create"`.
- `mode="regenerate"` requires a trusted, decodable, registered `art-spec.png` and background. An old spritesheet or independent slices may be absent. If either the spec or background is missing or invalid, use `mode="reuse-or-create"`.
- Use `regenerate` only when the user's latest standalone message clearly and explicitly commands regeneration. A brief, condition, negation, alternative, cost qualifier, deferral, quotation, historical message, or tool argument does not authorize a paid replacement.
- Once a paid operation starts, reuse the same recorded operation across interruption or uncertainty. Never submit paid work again because wording changed.
- Before strict spritesheet work starts, treat the existing spritesheet request as fixed. If an interrupted result or current spec/background does not match, stop and do not submit another paid request.
- If a recovered result is missing warnings, preserve the returned recovery warning in the report.
- A newly created or regenerated standard spritesheet must produce exactly four canonical transparent slices with unique pixels and unique Canvas resource/asset identities. Reuse legacy slices only when they match the current source image and registered identities.
- Both image tools return actual slices, with no fixed count or semantic order. Valid slices must retain unique pixels and Canvas identities. Legacy filenames are not proof of meaning; inspect the image. Reuse legacy slices only when they match the current source image and registered identities.
- If an existing operation belongs to a different brief, stop and report the uncertain result; do not create a replacement request.
- A submission acknowledgement is not a completed image.
- On timeout or uncertain delivery, reuse the recorded operation; never create a replacement request.
- `postprocess-failed-source-preserved` means the complete source image remains usable, but the requested transparent derivative is absent.
- `sliceWarning` means the complete transparent sheet remains usable, but individual slices are absent.
- For `agc_generate_image` with `kind="icon-spritesheet"`, choose the required `sliceMode` from the brief: `connected-components` is for free-form sheets with individual subjects; `grid` accepts `gridX` and `gridY` (1-32 each) when the requirement names equal grid cells, fixed slots, or a concrete column/row count. Pass grid dimensions only with `grid`, and `sliceMode` only with `icon-spritesheet`. Read the selected mode and dimensions from the returned metadata.
- The standard art package uses `sliceMode="connected-components"` with `sliceCount=4` because its four canonical slices map to fixed usage paths. Require exactly four slices; if the count differs or the returned `sliceMode` or grid dimensions disagree with the request, treat the package as incomplete and do not integrate it.
- The standard package uses `sliceMode="connected-components"`. Neither client tool exposes or sends `sliceCount`; content categories are generation requirements, not strict component counts. A valid sheet with no slices is returned with a warning. Mode and grid declarations still must agree with the request.
- General and slice warnings can coexist. The tool returns them separately through `warnings` and `sliceWarnings`; callers must preserve every entry and must not downgrade a slice warning into a successful independent-asset claim.
- `assetPaths` contains the complete package paths. Use only slices in `slicePaths` as independent assets.
- `assetPaths` contains the complete package paths; `slicePaths` lists actual independent files. Every embedded preview is labeled by path; inspect `remainingPreviewPaths` with native image viewing. Both tools include all returned slices in `resources`. After inspecting and verifying a local crop, register it with `agc_import_account_assets.localPaths` before using its registered identity.
- Use only the safe registered identities and paths returned in `resources`; never expose or infer prompts, models, upstream routes, absolute paths, URLs, tokens, cookies, or API keys.
- A trusted complete image may be used without fixed slices. Never fabricate missing derivatives.
- `art-spec.png` constrains generation and is never evidence that runtime gameplay art was integrated.
@@ -3,26 +3,57 @@ use super::*;
const ART_SPRITESHEET_PATH: &str = "assets/art-spritesheet.png";
const ART_SLICE_MANIFEST_PATH: &str = "assets/art-spritesheet-slices/manifest.json";
const ART_CONTRACT_RECEIPT_PATH: &str = ".agent/runtime/art-spritesheet-contract.json";
const REQUIRED_SLICE_USAGES: [&str; 4] = [
"player",
"blocks-and-targets",
"obstacles-and-scene",
"feedback-effects",
/// 历史文件仅用于兼容读取和恢复,不表示已经识别过用途。
pub(in crate::agent) const LEGACY_ART_SLICE_PATHS: [&str; 4] = [
"assets/art-spritesheet-slices/player.png",
"assets/art-spritesheet-slices/blocks-and-targets.png",
"assets/art-spritesheet-slices/obstacles-and-scene.png",
"assets/art-spritesheet-slices/feedback-effects.png",
];
pub(in crate::agent) fn art_slice_directory(source_resource_id: &str) -> String {
format!(
"assets/art-spritesheet-slices/{:x}",
Sha256::digest(source_resource_id.as_bytes())
)
}
pub(in crate::agent) fn art_slice_path(source_resource_id: &str, index: usize) -> String {
format!(
"{}/{:03}.png",
art_slice_directory(source_resource_id),
index + 1
)
}
pub(in crate::agent) fn is_managed_art_slice_path(path: &str) -> bool {
if LEGACY_ART_SLICE_PATHS.contains(&path) {
return true;
}
let Some(tail) = path.strip_prefix("assets/art-spritesheet-slices/") else {
return false;
};
let Some((key, name)) = tail.split_once('/') else {
return false;
};
key.len() == 64
&& key
.bytes()
.all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b))
&& name.strip_suffix(".png").is_some_and(|n| {
n.len() == 3
&& n.bytes().all(|b| b.is_ascii_digit())
&& n.parse::<usize>().is_ok_and(|n| (1..=256).contains(&n))
})
}
pub(in crate::agent) fn art_manifest_content() -> String {
serde_json::json!({
"schemaVersion": "game-art-manifest.v1",
"schemaVersion": "game-art-manifest.v2",
"status": "generated",
"assets": [{
"path": ART_SPRITESHEET_PATH,
"kind": GameCreationAppAssetKind::IconSpritesheet.as_str(),
"usage": REQUIRED_SLICE_USAGES,
}],
"assets": [{ "path": ART_SPRITESHEET_PATH, "kind": GameCreationAppAssetKind::IconSpritesheet.as_str() }],
"sliceManifest": ART_SLICE_MANIFEST_PATH,
"requiredSliceUsages": REQUIRED_SLICE_USAGES,
})
.to_string()
}).to_string()
}
#[derive(Clone, Debug, Eq, PartialEq)]
@@ -38,11 +69,12 @@ pub(in crate::agent) fn validated_art_slices(
let file = read_local_project_file_at(root, ART_SLICE_MANIFEST_PATH)?;
let manifest: serde_json::Value = serde_json::from_str(&file.content)
.map_err(|error| format!("图集切片清单不是有效 JSON:{error}"))?;
if manifest
.get("schemaVersion")
.and_then(serde_json::Value::as_str)
!= Some("game-art-slices.v1")
|| manifest.get("source").and_then(serde_json::Value::as_str) != Some(ART_SPRITESHEET_PATH)
if !matches!(
manifest
.get("schemaVersion")
.and_then(serde_json::Value::as_str),
Some("game-art-slices.v1" | "game-art-slices.v2")
) || manifest.get("source").and_then(serde_json::Value::as_str) != Some(ART_SPRITESHEET_PATH)
{
return Err("图集切片清单 schema 或 source 无效".to_string());
}
@@ -83,11 +115,12 @@ pub(in crate::agent) fn validated_art_slices(
}
let receipt: serde_json::Value = serde_json::from_slice(&receipt_bytes)
.map_err(|error| format!("图集私有合同回执不是有效 JSON:{error}"))?;
if receipt
.get("schemaVersion")
.and_then(serde_json::Value::as_str)
!= Some("game-art-spritesheet-contract.v1")
|| receipt.get("source").and_then(serde_json::Value::as_str) != Some(ART_SPRITESHEET_PATH)
if !matches!(
receipt
.get("schemaVersion")
.and_then(serde_json::Value::as_str),
Some("game-art-spritesheet-contract.v1" | "game-art-spritesheet-contract.v2")
) || receipt.get("source").and_then(serde_json::Value::as_str) != Some(ART_SPRITESHEET_PATH)
|| receipt
.get("sliceManifest")
.and_then(serde_json::Value::as_str)
@@ -122,42 +155,63 @@ pub(in crate::agent) fn validated_art_slices(
let slices = manifest
.get("slices")
.and_then(serde_json::Value::as_array)
.filter(|slices| slices.len() == REQUIRED_SLICE_USAGES.len())
.ok_or_else(|| "图集切片清单必须恰好包含四个切片".to_string())?;
.filter(|slices| slices.len() <= 256)
.ok_or_else(|| "图集切片清单数量无效".to_string())?;
let receipt_slices = receipt
.get("slices")
.and_then(serde_json::Value::as_array)
.filter(|slices| slices.len() == REQUIRED_SLICE_USAGES.len())
.ok_or_else(|| "图集私有合同回执必须恰好包含四个切片".to_string())?;
let mut validated_slices = Vec::with_capacity(REQUIRED_SLICE_USAGES.len());
.filter(|items| items.len() == slices.len())
.ok_or_else(|| "图集切片清单与私有回执数量不一致".to_string())?;
let legacy = receipt["schemaVersion"] == "game-art-spritesheet-contract.v1";
if legacy
&& (slices.len() != LEGACY_ART_SLICE_PATHS.len()
|| manifest["schemaVersion"] != "game-art-slices.v1")
{
return Err("旧图集合同集合不完整".to_string());
}
if !legacy && manifest["schemaVersion"] != "game-art-slices.v2" {
return Err("图集清单与回执版本不一致".to_string());
}
let mut validated_slices = Vec::with_capacity(slices.len());
let mut paths = std::collections::HashSet::new();
let mut pixel_sha256s = std::collections::HashSet::new();
let mut resource_ids = std::collections::HashSet::new();
let mut asset_object_ids = std::collections::HashSet::new();
let mut total_bytes = 0usize;
for usage in REQUIRED_SLICE_USAGES {
let slice = slices
.iter()
.find(|slice| slice.get("usage").and_then(serde_json::Value::as_str) == Some(usage))
.ok_or_else(|| format!("图集切片清单缺少 {usage} 素材"))?;
let receipt_slice = receipt_slices
.iter()
.find(|slice| slice.get("usage").and_then(serde_json::Value::as_str) == Some(usage))
.ok_or_else(|| format!("图集私有合同回执缺少 {usage} 素材"))?;
let expected_path = format!("assets/art-spritesheet-slices/{usage}.png");
for (index, slice) in slices.iter().enumerate() {
let path = slice
.get("path")
.and_then(serde_json::Value::as_str)
.filter(|path| *path == expected_path)
.ok_or_else(|| format!("{usage} 切片路径无效"))?;
.filter(|path| {
if legacy {
LEGACY_ART_SLICE_PATHS.contains(path)
} else {
*path
== art_slice_path(
current_asset
.source
.resource_id
.as_deref()
.expect("validated identity"),
index,
)
}
})
.filter(|path| paths.insert(*path))
.ok_or_else(|| "图集切片路径无效或重复".to_string())?;
let receipt_slice = receipt_slices
.iter()
.find(|item| item.get("path").and_then(serde_json::Value::as_str) == Some(path))
.ok_or_else(|| format!("图集私有回执缺少切片:{path}"))?;
let bytes = fs::read(resolve_local_project_path(root, path)?)
.map_err(|error| format!("{usage} 切片无法读取:{error}"))?;
.map_err(|error| format!("{path} 切片无法读取:{error}"))?;
total_bytes = total_bytes
.checked_add(bytes.len())
.ok_or_else(|| "图集切片累计大小溢出".to_string())?;
if bytes.len() > 20 * 1024 * 1024 || total_bytes > 32 * 1024 * 1024 {
return Err("图集切片超过校验大小上限".to_string());
}
let validated = validate_platform_art_png_bytes_with_limits(&bytes, usage)?;
let validated = validate_platform_art_png_bytes_with_limits(&bytes, path)?;
for field in [
"name",
"usage",
@@ -170,24 +224,24 @@ pub(in crate::agent) fn validated_art_slices(
"pixelSha256",
] {
if slice.get(field) != receipt_slice.get(field) {
return Err(format!("{usage} 切片字段 {field} 与私有合同回执不一致"));
return Err(format!("{path} 切片字段 {field} 与私有合同回执不一致"));
}
}
let resource_id = slice
.get("resourceId")
.and_then(serde_json::Value::as_str)
.filter(|value| !value.trim().is_empty())
.ok_or_else(|| format!("{usage} 切片缺少 resourceId"))?;
.ok_or_else(|| format!("{path} 切片缺少 resourceId"))?;
let asset_object_id = slice
.get("assetObjectId")
.and_then(serde_json::Value::as_str)
.filter(|value| !value.trim().is_empty())
.ok_or_else(|| format!("{usage} 切片缺少 assetObjectId"))?;
.ok_or_else(|| format!("{path} 切片缺少 assetObjectId"))?;
if !resource_ids.insert(resource_id)
|| !asset_object_ids.insert(asset_object_id)
|| !pixel_sha256s.insert(validated.pixel_sha256.clone())
{
return Err("四类切片存在重复的身份或像素内容".to_string());
return Err("切片存在重复的身份或像素内容".to_string());
}
if slice.get("width").and_then(serde_json::Value::as_u64) != Some(validated.width.into())
|| slice.get("height").and_then(serde_json::Value::as_u64)
@@ -201,7 +255,7 @@ pub(in crate::agent) fn validated_art_slices(
|| !validated.has_visible_pixels
|| !validated.has_transparent_pixels
{
return Err(format!("{usage} 切片内容未通过校验"));
return Err(format!("{path} 切片内容未通过校验"));
}
validated_slices.push(ValidatedArtSlice {
path: path.to_string(),
@@ -48,6 +48,7 @@ const DIRECT_TAONIER_REGENERATION_WORKFLOW_MAX_BYTES: usize = 96 * 1024 * 1024;
const DIRECT_TAONIER_REGENERATION_ROLLBACK_MAX_BYTES: usize = 64 * 1024 * 1024;
const DIRECT_TAONIER_COMPLETED_WARNING_MAX_COUNT: usize = 16;
const DIRECT_TAONIER_COMPLETED_WARNING_MAX_CHARS: usize = 480;
#[cfg(test)]
const DIRECT_CODEX_SPRITESHEET_SLICE_PATHS: [(&str, &str); 4] = [
("player", "assets/art-spritesheet-slices/player.png"),
(
@@ -393,12 +394,14 @@ impl DirectTaonierArtPackage {
if self.asset_paths != expected_asset_paths {
return Err("陶泥儿整包重生成完成结果的主素材路径无效".to_string());
}
let expected_slice_paths = DIRECT_CODEX_SPRITESHEET_SLICE_PATHS
.iter()
.map(|(_, path)| (*path).to_string())
.collect::<Vec<_>>();
if self.slice_paths != expected_slice_paths {
return Err("陶泥儿整包重生成完成结果必须包含四张 canonical 切片".to_string());
let mut paths = std::collections::HashSet::new();
if self.slice_paths.len() > 256
|| self
.slice_paths
.iter()
.any(|path| !is_managed_art_slice_path(path) || !paths.insert(path))
{
return Err("陶泥儿整包完成结果的切片路径集合无效".to_string());
}
if self.warnings.len() > DIRECT_TAONIER_COMPLETED_WARNING_MAX_COUNT
|| self.slice_warnings.len() > DIRECT_TAONIER_COMPLETED_WARNING_MAX_COUNT
@@ -1158,20 +1161,23 @@ fn recover_direct_taonier_strict_spritesheet_pending_at(
"{DIRECT_TAONIER_RESULT_UNKNOWN_PREFIX} 当前完整严格图集未绑定本轮 retained spritesheet 完成结果;已保留现场,禁止串单完成"
));
}
let slice_paths = direct_registered_taonier_slice_paths(root);
let slice_warnings = if slice_paths.is_empty() {
vec!["图集已恢复,但没有独立切片;请查看总图后决定后续处理".to_string()]
} else {
Vec::new()
};
let package = DirectTaonierArtPackage {
asset_paths: DIRECT_CODEX_ART_ASSET_PATHS
.iter()
.map(|path| (*path).to_string())
.collect(),
slice_paths: DIRECT_CODEX_SPRITESHEET_SLICE_PATHS
.iter()
.map(|(_, path)| (*path).to_string())
.collect(),
slice_paths,
warnings: vec![
"严格图集已完成,但进程在完成结果持久化前中断;原阶段告警无法完整重放"
.to_string(),
],
slice_warnings: Vec::new(),
slice_warnings,
};
complete_direct_taonier_regeneration_workflow_locked_at(root, workflow, package)?;
return Ok(true);
@@ -1412,10 +1418,68 @@ fn direct_taonier_optional_contract_file_sha256_at(
}
fn direct_taonier_strict_snapshot_tracks_manifest_entry(local_path: &str) -> bool {
local_path == DIRECT_CODEX_SPRITESHEET_ASSET_PATH
|| DIRECT_CODEX_SPRITESHEET_SLICE_PATHS
.iter()
.any(|(_, path)| *path == local_path)
local_path == DIRECT_CODEX_SPRITESHEET_ASSET_PATH || is_managed_art_slice_path(local_path)
}
fn direct_taonier_contract_snapshot_paths_at(root: &Path) -> Result<Vec<String>, String> {
let mut paths = DIRECT_TAONIER_STRICT_CONTRACT_SNAPSHOT_PATHS
.iter()
.map(|p| (*p).to_string())
.collect::<Vec<_>>();
let manifest = read_manifest_for_project(root)?;
let source = manifest
.assets
.iter()
.find(|asset| asset.local_path == DIRECT_CODEX_SPRITESHEET_ASSET_PATH)
.and_then(|asset| asset.source.resource_id.as_deref());
if let Some(source) = source {
paths.extend(
manifest
.assets
.iter()
.filter(|asset| {
asset.source.reference_resource_ids == [source]
&& is_managed_art_slice_path(&asset.local_path)
})
.map(|asset| asset.local_path.clone()),
);
}
// 私有回执可覆盖尚未完成顶层补登记的旧产物;公开清单不能授予身份。
let receipt_path =
resolve_local_project_path(root, ".agent/runtime/art-spritesheet-contract.json")?;
if let Ok(metadata) = fs::symlink_metadata(&receipt_path) {
if !metadata.file_type().is_symlink() && metadata.is_file() && metadata.len() <= 256 * 1024
{
if let Ok(receipt) =
fs::read(&receipt_path)
.map_err(|e| e.to_string())
.and_then(|bytes| {
serde_json::from_slice::<serde_json::Value>(&bytes)
.map_err(|e| e.to_string())
})
{
if let Some(slices) = receipt
.get("slices")
.and_then(serde_json::Value::as_array)
.filter(|s| s.len() <= 256)
{
paths.extend(
slices
.iter()
.filter_map(|s| s.get("path").and_then(serde_json::Value::as_str))
.filter(|path| is_managed_art_slice_path(path))
.map(str::to_string),
);
}
}
}
}
let mut seen = std::collections::HashSet::new();
paths.retain(|path| seen.insert(path.clone()));
if paths.len() > DIRECT_TAONIER_STRICT_CONTRACT_SNAPSHOT_PATHS.len() + 256 {
return Err("图集旧合同切片数量超过上限".to_string());
}
Ok(paths)
}
impl DirectTaonierRegenerationRollback {
@@ -1452,8 +1516,9 @@ impl DirectTaonierRegenerationRollback {
})
})
.collect::<Result<Vec<_>, String>>()?;
let strict_contract = DIRECT_TAONIER_STRICT_CONTRACT_SNAPSHOT_PATHS
.into_iter()
let strict_contract = direct_taonier_contract_snapshot_paths_at(root)?
.iter()
.map(String::as_str)
.map(|local_path| {
let previous_sha256 =
direct_taonier_optional_contract_file_sha256_at(root, local_path)?
@@ -1573,7 +1638,17 @@ impl DirectTaonierRegenerationRollback {
}
fn has_complete_strict_contract_snapshot(&self) -> bool {
self.strict_contract.len() == DIRECT_TAONIER_STRICT_CONTRACT_SNAPSHOT_PATHS.len()
self.strict_contract.len() <= DIRECT_TAONIER_STRICT_CONTRACT_SNAPSHOT_PATHS.len() + 256
&& self.strict_contract.iter().all(|entry| {
(DIRECT_TAONIER_STRICT_CONTRACT_SNAPSHOT_PATHS.contains(&entry.local_path.as_str())
|| is_managed_art_slice_path(&entry.local_path))
&& self
.strict_contract
.iter()
.filter(|other| other.local_path == entry.local_path)
.count()
== 1
})
&& DIRECT_TAONIER_STRICT_CONTRACT_SNAPSHOT_PATHS
.iter()
.all(|expected| {
@@ -2356,36 +2431,44 @@ fn direct_taonier_strict_art_package_is_valid(root: &Path) -> bool {
if !direct_taonier_art_package_is_valid(root) {
return false;
}
let expected_slice_paths = DIRECT_CODEX_SPRITESHEET_SLICE_PATHS
.iter()
.map(|(_, path)| (*path).to_string())
let Ok(slices) = validated_art_slices(root) else {
return false;
};
let slice_paths = slices
.into_iter()
.map(|slice| slice.path)
.collect::<Vec<_>>();
if direct_registered_taonier_slice_paths(root) != expected_slice_paths {
if direct_registered_taonier_slice_paths(root) != slice_paths {
return false;
}
let expected_art_manifest = art_manifest_content();
let art_manifest_path = root.join("assets/manifest.art.json");
if !art_manifest_path.is_file() {
return false;
}
if std::fs::read(&art_manifest_path).ok().as_deref() != Some(expected_art_manifest.as_bytes()) {
let Ok(bytes) = std::fs::read(&art_manifest_path) else {
return false;
};
if bytes != art_manifest_content().as_bytes() {
// 旧公开清单只作版本兼容,不把 usage 当作识别结论。
let Ok(legacy) = serde_json::from_slice::<serde_json::Value>(&bytes) else {
return false;
};
if legacy["schemaVersion"] != "game-art-manifest.v1"
|| legacy["assets"][0]["path"] != DIRECT_CODEX_SPRITESHEET_ASSET_PATH
|| legacy["sliceManifest"] != "assets/art-spritesheet-slices/manifest.json"
{
return false;
}
}
let Ok(manifest) = read_manifest_for_project(root) else {
return false;
};
[
let complete = [
DIRECT_CODEX_ART_SPEC_ASSET_PATH,
DIRECT_CODEX_BACKGROUND_ASSET_PATH,
DIRECT_CODEX_SPRITESHEET_ASSET_PATH,
]
.into_iter()
.chain(
DIRECT_CODEX_SPRITESHEET_SLICE_PATHS
.iter()
.map(|(_, path)| *path),
)
.all(|path| direct_taonier_manifest_entry_at(&manifest, path).is_ok())
.chain(slice_paths.iter().map(String::as_str))
.all(|path| direct_taonier_manifest_entry_at(&manifest, path).is_ok());
complete
}
fn direct_registered_taonier_slice_paths(root: &Path) -> Vec<String> {
@@ -3083,9 +3166,6 @@ async fn generate_direct_taonier_art_asset_at(
asset_kind: parsed_asset_kind,
asset_label: asset_label.to_string(),
replace_existing: root.join(output_path).is_file(),
// 标准美术包必须产出四张 canonical 切片:连通域模式下显式声明目标数量,
// 让平台要么给出四张,要么以可执行的 422 说明实际识别数量。
slice_count: (parsed_asset_kind == GameCreationAppAssetKind::IconSpritesheet).then_some(4),
// 切分模式没有默认值:陶泥儿标准美术包按自由排布生成核心图集,因此只在
// art-spritesheet 阶段显式声明连通域切分。
slice_mode: (parsed_asset_kind == GameCreationAppAssetKind::IconSpritesheet)
@@ -3189,7 +3269,7 @@ fn direct_taonier_art_package_result(
};
if includes_spritesheet && slice_paths.is_empty() {
slice_warnings.push(
"当前图集没有可验证的独立切片;只能使用完整图集,不得猜测切片或伪造衍生素材"
"当前图集没有可验证的独立切片;请查看总图后决定使用或进一步处理,不得猜测坐标或伪造素材身份"
.to_string(),
);
}
@@ -3476,11 +3556,7 @@ pub(crate) async fn ensure_direct_taonier_art_package_at(
));
}
}
emit_direct_game_creator_progress(
root,
"art.spritesheet",
"正在生成核心图集并切分四类运行时素材",
);
emit_direct_game_creator_progress(root, "art.spritesheet", "正在生成核心图集并保存实际切片");
let outcome = match generate_direct_taonier_art_asset_at(
root,
prompt,
@@ -8014,6 +8090,78 @@ mod tests {
.expect("art manifest");
}
#[test]
fn direct_package_reuses_actual_slice_sets_and_snapshots_their_identities() {
for count in [0, 2, 4, 6] {
let temp = tempfile::tempdir().unwrap();
let root = temp.path();
init_local_game_project_at(root, "dynamic-package", "动态美术包").unwrap();
register_direct_taonier_art_package_fixture(root);
let source = "taonier-resource-art-spritesheet";
let mut slices = Vec::new();
for i in 0..count {
let path = art_slice_path(source, i);
let bytes = tiny_strict_slice_png(i);
std::fs::create_dir_all(root.join(&path).parent().unwrap()).unwrap();
std::fs::write(root.join(&path), &bytes).unwrap();
let image =
validate_platform_art_png_bytes_with_limits(&bytes, "dynamic fixture").unwrap();
slices.push(serde_json::json!({"path":path, "name":format!("素材 {i}"), "width":image.width, "height":image.height,
"resourceId":format!("dynamic-resource-{i}"), "assetObjectId":format!("dynamic-object-{i}"),
"contentSha256":image.content_sha256, "pixelSha256":image.pixel_sha256}));
}
for (path, schema) in [
(
"assets/art-spritesheet-slices/manifest.json",
"game-art-slices.v2",
),
(
".agent/runtime/art-spritesheet-contract.json",
"game-art-spritesheet-contract.v2",
),
] {
let mut value: serde_json::Value =
serde_json::from_slice(&std::fs::read(root.join(path)).unwrap()).unwrap();
value["schemaVersion"] = schema.into();
value["slices"] = serde_json::json!(slices);
std::fs::write(root.join(path), serde_json::to_vec(&value).unwrap()).unwrap();
}
register_existing_platform_art_slices_at(root).unwrap();
assert!(direct_taonier_strict_art_package_is_valid(root));
let result = direct_taonier_art_package_result(
root,
DIRECT_CODEX_ART_ASSET_PATHS
.iter()
.map(|p| (*p).to_string())
.collect(),
vec![],
vec![],
);
assert_eq!(
result.slice_paths,
(0..count)
.map(|i| art_slice_path(source, i))
.collect::<Vec<_>>()
);
result.validate_completed_workflow_result_at().unwrap();
assert_eq!(!result.slice_warnings.is_empty(), count == 0);
let rollback = DirectTaonierRegenerationRollback::capture_locked(root).unwrap();
rollback.validate().unwrap();
assert!(rollback.strict_contract_matches_previous_at(root).unwrap());
for path in &result.slice_paths {
assert!(rollback
.strict_contract
.iter()
.any(|s| s.local_path == *path));
}
if count > 0 {
std::fs::write(root.join(&result.slice_paths[0]), b"changed").unwrap();
assert!(!rollback.strict_contract_matches_previous_at(root).unwrap());
assert!(!direct_taonier_strict_art_package_is_valid(root));
}
}
}
fn register_direct_taonier_art_slice_entries_fixture(root: &Path) {
for (index, (_, path)) in DIRECT_CODEX_SPRITESHEET_SLICE_PATHS.iter().enumerate() {
register_local_asset_at(
@@ -8677,7 +8825,7 @@ mod tests {
assert!(invalid_package
.slice_warnings
.iter()
.any(|warning| warning.contains("不得猜测切片")));
.any(|warning| warning.contains("不得猜测坐标")));
}
#[test]
@@ -8786,7 +8934,7 @@ mod tests {
assert!(package.slice_paths.is_empty());
assert!(package.warnings.is_empty());
assert_eq!(package.slice_warnings.len(), 1);
assert!(package.slice_warnings[0].contains("不得猜测切片"));
assert!(package.slice_warnings[0].contains("不得猜测坐标"));
}
#[test]
@@ -2640,17 +2640,41 @@ fn bridge_art_resources(
slice_paths: &[String],
) -> Result<Vec<Value>, String> {
let manifest = read_manifest_for_project(root)?;
Ok(asset_paths
asset_paths
.iter()
.chain(slice_paths.iter())
.filter_map(|path| {
.map(|path| {
manifest
.assets
.iter()
.find(|asset| asset.local_path == *path)
.and_then(bridge_art_resource)
.ok_or_else(|| format!("素材缺少可返回的登记身份:{path}"))
})
.collect())
.collect()
}
/// 有界预览与路径逐项绑定;未内嵌的文件仍通过完整路径集合交给 Agent 查看。
fn bridge_art_tool_result(root: &Path, mut payload: Value, paths: &[String]) -> Value {
let mut previews = Vec::new();
let mut remaining = Vec::new();
for path in paths {
if previews.len() < 7 {
if let Ok(data) = bridge_png_content(root, &root.join(path)) {
previews.push((path.clone(), data));
continue;
}
}
remaining.push(path.clone());
}
payload["previewPaths"] = json!(previews.iter().map(|(p, _)| p).collect::<Vec<_>>());
payload["remainingPreviewPaths"] = json!(remaining);
let mut content = vec![json!({"type":"text", "text":payload.to_string()})];
for (path, data) in previews {
content.push(json!({"type":"text", "text":format!("图片预览:{path}")}));
content.push(bridge_tool_image_block(data));
}
json!({"content":content})
}
async fn bridge_prepare_game_art_validated(
@@ -2665,14 +2689,14 @@ async fn bridge_prepare_game_art_validated(
.map_err(|cause| PrepareGameArtError::PackageGenerationFailed { cause })?;
let resources = bridge_art_resources(root, &package.asset_paths, &package.slice_paths)
.map_err(|cause| PrepareGameArtError::ArtResourcesUnreadable { cause })?;
let images = package
let paths = package
.asset_paths
.iter()
.chain(package.slice_paths.iter())
.take(7)
.filter_map(|relative| bridge_png_content(root, &root.join(relative)).ok())
.cloned()
.collect::<Vec<_>>();
Ok(bridge_tool_result(
Ok(bridge_art_tool_result(
root,
json!({
"status": "completed",
"mode": mode.as_str(),
@@ -2681,11 +2705,10 @@ async fn bridge_prepare_game_art_validated(
"warnings": bounded_remote_warnings(&package.warnings),
"sliceWarnings": bounded_remote_warnings(&package.slice_warnings),
"resources": resources,
"derivativePolicy": "新建或重生成的标准图集必须包含四张 canonical 切片;旧可信图集缺少切片时只能按 warning 使用完整图集,不得伪造衍生素材",
"next": "读取这些相对路径并把合适素材接入核心游戏画面"
})
.to_string(),
images,
"derivativePolicy": "内容要求不保证切片数量或用途顺序;查看总图及切片后识别用途。零切片时保留总图与告警;真实本地处理产物通过 agc_import_account_assets.localPaths 登记,不能伪造平台身份",
"next": "查看预览及 remainingPreviewPaths 的图片,确认实体、状态和缺失内容后再接入游戏"
}),
&paths,
))
}
@@ -2809,8 +2832,6 @@ pub(in crate::agent) const GENERATE_IMAGE_IMAGE_SIZES: &[&str] = &["0.5K", "1K",
pub(in crate::agent) const GENERATE_IMAGE_SLICE_MODES: &[&str] = &["connected-components", "grid"];
pub(in crate::agent) const GENERATE_IMAGE_GRID_AXIS_MIN: u32 = 1;
pub(in crate::agent) const GENERATE_IMAGE_GRID_AXIS_MAX: u32 = 32;
pub(in crate::agent) const GENERATE_IMAGE_SLICE_COUNT_MIN: u64 = 1;
pub(in crate::agent) const GENERATE_IMAGE_SLICE_COUNT_MAX: u64 = 256;
/// `agc_generate_image` 允许的入参字段。
const GENERATE_IMAGE_ARGUMENTS: &[&str] = &[
@@ -2823,7 +2844,6 @@ const GENERATE_IMAGE_ARGUMENTS: &[&str] = &[
"sliceMode",
"gridX",
"gridY",
"sliceCount",
"screenColor",
];
@@ -2964,18 +2984,14 @@ fn validate_generate_image_slice_declaration(
slice_mode: Option<&str>,
grid_x: Option<u32>,
grid_y: Option<u32>,
slice_count: Option<usize>,
) -> Result<(), GenerateImageError> {
if kind == GameCreationAppAssetKind::IconSpritesheet {
if slice_mode.is_none() {
return Err(GenerateImageError::SpritesheetSliceModeMissing);
}
if slice_mode == Some("grid") && slice_count.is_some() {
return Err(GenerateImageError::SpritesheetSliceCountNotAllowed);
}
return Ok(());
}
if slice_mode.is_some() || grid_x.is_some() || grid_y.is_some() || slice_count.is_some() {
if slice_mode.is_some() || grid_x.is_some() || grid_y.is_some() {
return Err(GenerateImageError::SliceDeclarationNotForKind { kind });
}
Ok(())
@@ -3033,7 +3049,6 @@ pub(in crate::agent) struct GenerateImageInput {
pub(in crate::agent) slice_mode: Option<String>,
pub(in crate::agent) grid_x: Option<u32>,
pub(in crate::agent) grid_y: Option<u32>,
pub(in crate::agent) slice_count: Option<usize>,
pub(in crate::agent) screen_color: Option<String>,
}
@@ -3080,29 +3095,7 @@ pub(in crate::agent) fn generate_image_input(
return Err(GenerateImageError::GridOutOfRange { axis, got });
}
}
let slice_count = arguments
.get("sliceCount")
.filter(|value| !value.is_null())
.map(|value| {
value
.as_u64()
.filter(|count| {
(GENERATE_IMAGE_SLICE_COUNT_MIN..=GENERATE_IMAGE_SLICE_COUNT_MAX)
.contains(count)
})
.map(|count| count as usize)
.ok_or_else(|| GenerateImageError::SliceCountInvalid {
got: not_text_repr(value),
})
})
.transpose()?;
validate_generate_image_slice_declaration(
kind,
slice_mode.as_deref(),
grid_x,
grid_y,
slice_count,
)?;
validate_generate_image_slice_declaration(kind, slice_mode.as_deref(), grid_x, grid_y)?;
let screen_color = normalize_generate_image_screen_color(arguments, kind)?;
Ok(GenerateImageInput {
prompt,
@@ -3114,7 +3107,6 @@ pub(in crate::agent) fn generate_image_input(
slice_mode,
grid_x,
grid_y,
slice_count,
screen_color,
})
}
@@ -3136,7 +3128,6 @@ async fn bridge_generate_image(
slice_mode,
grid_x,
grid_y,
slice_count,
screen_color,
} = generate_image_input(arguments)?;
let options = PlatformArtAssetGenerationOptions {
@@ -3146,7 +3137,6 @@ async fn bridge_generate_image(
asset_kind: kind,
asset_label: asset_name.clone(),
replace_existing: false,
slice_count,
slice_mode,
grid_x,
grid_y,
@@ -3174,17 +3164,22 @@ async fn bridge_generate_image(
.await
.map_err(|message| GenerateImageError::PlatformArtRequestFailed { message })?;
emit_game_creator_manifest_invalidated(&state.root, "direct-codex-art");
let slice_paths = generated
.slices
.iter()
.map(|slice| slice.local_path.clone())
.collect::<Vec<_>>();
let resources = bridge_art_resources(
&state.root,
std::slice::from_ref(&generated.asset.local_path),
&[],
&slice_paths,
)
.map_err(|message| GenerateImageError::ArtResourcesUnreadable { message })?;
let images = bridge_png_content(&state.root, &state.root.join(&generated.asset.local_path))
.ok()
.into_iter()
let paths = std::iter::once(generated.asset.local_path.clone())
.chain(slice_paths.iter().cloned())
.collect::<Vec<_>>();
Ok(bridge_tool_result(
Ok(bridge_art_tool_result(
&state.root,
json!({
"status": "completed",
"kind": kind,
@@ -3208,14 +3203,10 @@ async fn bridge_generate_image(
"sliceMode": generated.slice_mode,
"gridX": generated.grid_x,
"gridY": generated.grid_y,
"slicePaths": generated
.slices
.iter()
.map(|slice| slice.local_path.clone())
.collect::<Vec<_>>(),
})
.to_string(),
images,
"slicePaths": slice_paths,
"next": "查看总图和实际切片,使用图片查看能力检查 remainingPreviewPaths;按内容识别用途,不按返回顺序或文件名赋义。",
}),
&paths,
))
}
@@ -4681,79 +4672,44 @@ mod tests {
}
#[test]
fn generate_image_slice_declaration_is_explicit_and_self_consistent() {
let missing = validate_generate_image_slice_declaration(
assert!(validate_generate_image_slice_declaration(
GameCreationAppAssetKind::IconSpritesheet,
None,
None,
None,
None,
None
)
.expect_err("icon-spritesheet without sliceMode must fail closed")
.to_user_msg();
assert!(missing.contains("没有默认值"), "{missing}");
assert!(missing.contains("connected-components"), "{missing}");
.is_err());
assert!(validate_generate_image_slice_declaration(
GameCreationAppAssetKind::IconSpritesheet,
Some("connected-components"),
None,
None,
Some(4),
None
)
.is_ok());
assert!(validate_generate_image_slice_declaration(
GameCreationAppAssetKind::IconSpritesheet,
Some("grid"),
Some(3),
Some(2),
None,
Some(2)
)
.is_ok());
let grid_with_count = validate_generate_image_slice_declaration(
GameCreationAppAssetKind::IconSpritesheet,
Some("grid"),
Some(2),
Some(2),
Some(4),
)
.expect_err("grid mode must not carry sliceCount")
.to_user_msg();
assert!(grid_with_count.contains("gridX×gridY"), "{grid_with_count}");
let wrong_kind = validate_generate_image_slice_declaration(
assert!(validate_generate_image_slice_declaration(
GameCreationAppAssetKind::Image,
Some("connected-components"),
None,
None,
None,
None
)
.expect_err("slice declaration must stay scoped to icon-spritesheet")
.to_user_msg();
assert!(
wrong_kind.contains("仅对 kind=icon-spritesheet 生效"),
"{wrong_kind}"
);
let wrong_kind_count = validate_generate_image_slice_declaration(
GameCreationAppAssetKind::Image,
None,
None,
None,
Some(8),
)
.expect_err("sliceCount-only violation must be rejected")
.to_user_msg();
assert!(
wrong_kind_count.contains("sliceCount"),
"sliceCount-only violation must name sliceCount: {wrong_kind_count}"
);
.is_err());
assert!(validate_generate_image_slice_declaration(
GameCreationAppAssetKind::Image,
None,
None,
None,
None
)
.is_ok());
for kind in ["image", "icon-spritesheet"] {
assert!(generate_image_input(&json!({"prompt":"素材", "kind":kind, "sliceMode":"connected-components", "sliceCount":4})).is_err());
}
}
#[test]
@@ -6389,6 +6345,53 @@ mod tests {
turn_ids
}
#[test]
fn art_result_returns_all_resources_and_labels_bounded_previews() {
let temp = tempfile::tempdir().unwrap();
let root = temp.path();
init_local_game_project_at(root, "art-result", "图集返回").unwrap();
let mut paths = vec!["assets/sheet.png".to_string()];
paths.extend((0..8).map(|i| art_slice_path("source", i)));
for (i, path) in paths.iter().enumerate() {
std::fs::create_dir_all(root.join(path).parent().unwrap()).unwrap();
image::RgbaImage::from_pixel(2, 2, image::Rgba([i as u8, 20, 30, 128]))
.save(root.join(path))
.unwrap();
import_canvas_asset_at(
root,
path,
GameCreationAppAssetKind::Icon,
"image/png",
"canvas",
Some(format!("resource-{i}")),
Some(format!("object-{i}")),
Some("task".into()),
None,
None,
)
.unwrap();
}
let resources = bridge_art_resources(root, &paths[..1], &paths[1..]).unwrap();
assert_eq!(resources.len(), paths.len());
let response = bridge_art_tool_result(
root,
json!({"resources":resources, "slicePaths":paths[1..]}),
&paths,
);
let payload: Value =
serde_json::from_str(response["content"][0]["text"].as_str().unwrap()).unwrap();
assert_eq!(payload["previewPaths"], json!(paths[..7]));
assert_eq!(payload["remainingPreviewPaths"], json!(paths[7..]));
assert_eq!(payload["resources"].as_array().unwrap().len(), 9);
for (i, path) in paths.iter().take(7).enumerate() {
assert!(response["content"][1 + i * 2]["text"]
.as_str()
.unwrap()
.contains(path));
assert_eq!(response["content"][2 + i * 2]["type"], "image");
}
}
#[test]
fn bridge_art_resource_exposes_only_the_safe_identity_projection() {
let asset = GameCreationAppAssetManifestEntry {
@@ -346,12 +346,6 @@ fn direct_tools_mcp_specs_for_plugins(
"maximum": 32,
"description": prompt_text!("directTools.agc_generate_image.parameters.gridY")
},
"sliceCount": {
"type": "integer",
"minimum": 1,
"maximum": 256,
"description": prompt_text!("directTools.agc_generate_image.parameters.sliceCount")
},
"screenColor": {
"type": "string",
"description": prompt_text!("directTools.agc_generate_image.parameters.screenColor")
@@ -2849,14 +2843,9 @@ mod tests {
image_tool["inputSchema"]["properties"]["sliceMode"]["enum"],
json!(["connected-components", "grid"])
);
assert_eq!(
image_tool["inputSchema"]["properties"]["sliceCount"]["minimum"],
json!(1)
);
assert_eq!(
image_tool["inputSchema"]["properties"]["sliceCount"]["maximum"],
json!(256)
);
assert!(image_tool["inputSchema"]["properties"]
.get("sliceCount")
.is_none());
assert!(
image_tool["inputSchema"]["properties"]["screenColor"]["description"]
.as_str()
File diff suppressed because it is too large Load Diff
@@ -901,7 +901,10 @@ fn durable_legacy_generation_result(
result: &serde_json::Value,
) -> Result<serde_json::Value, String> {
let mut durable = durable_legacy_generation_object(result);
for field in ["spritesheetWidth", "spritesheetHeight"] {
if let Some(mode) = json_string_field(result, "sliceMode") {
durable.insert("sliceMode".into(), serde_json::Value::String(mode));
}
for field in ["spritesheetWidth", "spritesheetHeight", "gridX", "gridY"] {
if let Some(value) = result.get(field).and_then(serde_json::Value::as_u64) {
durable.insert(field.to_string(), serde_json::Value::from(value));
}
@@ -1115,6 +1118,43 @@ pub(in crate::agent) fn remove_platform_art_generation_runtime_state_at(
/// 原样保留(不迁移、不删除、不阻塞),由对应动作自己的请求迁移。
///
/// 返回 `Ok(false)` 表示没有需要迁移的旧账本。任何身份无法安全解释的情形都失败关闭。
/// 只读匹配旧输出槽,返回其原身份;不改变冻结请求或已受理操作。
pub(super) fn matching_legacy_standalone_platform_art_context_at(
root: &Path,
agent_id: &str,
legacy_run_id: &str,
candidates: &[PlatformArtGenerationRuntimeContext],
) -> Result<Option<PlatformArtGenerationRuntimeContext>, String> {
let path = platform_art_generation_runtime_relative_path(agent_id, legacy_run_id);
let Some(state) =
read_agent_runtime_json_sidecar_with_max_bytes::<PlatformArtGenerationRuntimeState>(
root,
&path,
"External Editor 生成账本",
PLATFORM_ART_GENERATION_RUNTIME_MAX_BYTES,
)?
else {
return Ok(None);
};
let Some(candidate) = candidates.iter().find(|candidate| {
candidate.agent_id == state.agent_id
&& candidate.action_fingerprint == state.action_fingerprint
}) else {
return Ok(None);
};
let identity = format!("{agent_id}:{legacy_run_id}");
let context = PlatformArtGenerationRuntimeContext {
run_id: legacy_run_id.to_string(),
task_id: identity.clone(),
session_id: identity.clone(),
action_id: identity,
..candidate.clone()
};
read_platform_art_generation_runtime_state(root, &context)?
.ok_or_else(|| "旧输出槽账本在读取期间消失,已拒绝创建新请求".to_string())?;
Ok(Some(context))
}
pub(super) fn adopt_legacy_standalone_platform_art_generation_runtime_state_at(
root: &Path,
context: &PlatformArtGenerationRuntimeContext,
@@ -4,8 +4,7 @@
use crate::agent::direct_tool_bridge::{
GENERATE_IMAGE_ASPECT_RATIOS, GENERATE_IMAGE_GRID_AXIS_MAX, GENERATE_IMAGE_GRID_AXIS_MIN,
GENERATE_IMAGE_IMAGE_SIZES, GENERATE_IMAGE_SLICE_COUNT_MAX, GENERATE_IMAGE_SLICE_COUNT_MIN,
GENERATE_IMAGE_SLICE_MODES,
GENERATE_IMAGE_IMAGE_SIZES, GENERATE_IMAGE_SLICE_MODES,
};
use crate::agent::generation::PLATFORM_ART_ASSET_GENERATION_KINDS;
use crate::agent::tool::error::{ProjectPermissionRejection, ToolArgumentsRejection, ToolFailure};
@@ -48,9 +47,7 @@ pub(crate) enum GenerateImageError {
GridAxisNotInteger { axis: &'static str },
GridAxesMissing,
GridOutOfRange { axis: &'static str, got: u32 },
SliceCountInvalid { got: String },
SpritesheetSliceModeMissing,
SpritesheetSliceCountNotAllowed,
SliceDeclarationNotForKind { kind: GameCreationAppAssetKind },
ScreenColorNotForKind { kind: GameCreationAppAssetKind },
ScreenColorMalformed { got: String },
@@ -171,21 +168,12 @@ impl ToolFailure for GenerateImageError {
"生成图片失败:{axis}={got} 超出范围,gridX/gridY 必须在 {GENERATE_IMAGE_GRID_AXIS_MIN} 到 {GENERATE_IMAGE_GRID_AXIS_MAX} 之间。"
)
}
Self::SliceCountInvalid { got } => {
format!(
"生成图片失败:sliceCount「{got}」非法,必须是 {GENERATE_IMAGE_SLICE_COUNT_MIN} 到 {GENERATE_IMAGE_SLICE_COUNT_MAX} 的整数。"
)
}
Self::SpritesheetSliceModeMissing => format!(
"生成图片失败:kind={} 必须显式声明 sliceMode,没有默认值:需求要求等分网格、固定槽位或指定行列数时传 sliceMode=grid 并提供 gridX/gridY;自由排布、数量不定或只要求一张图集时传 sliceMode=connected-components。",
GameCreationAppAssetKind::IconSpritesheet
),
Self::SpritesheetSliceCountNotAllowed => {
"生成图片失败:sliceMode=grid 的素材张数由 gridX×gridY 决定,不接受 sliceCount。"
.to_string()
}
Self::SliceDeclarationNotForKind { kind } => format!(
"生成图片失败:sliceMode/gridX/gridY/sliceCount 仅对 kind={} 生效,当前 kind={kind}。",
"生成图片失败:sliceMode/gridX/gridY 仅对 kind={} 生效,当前 kind={kind}。",
GameCreationAppAssetKind::IconSpritesheet
),
Self::ScreenColorNotForKind { kind } => format!(
@@ -4153,7 +4153,6 @@ pub(crate) fn prepare_local_project_asset_generation(
)?
.unwrap_or_else(|| LOCAL_PROJECT_ASSET_DEFAULT_ASSET_NAME.to_string()),
replace_existing: false,
slice_count: None,
// 切分模式没有默认值:GUI 快速编辑只按自由排布生成图集,因此仅在 icon-spritesheet
// 时显式声明连通域切分;等分网格或固定槽位需求由外部 API 显式传 grid + gridX/gridY。
slice_mode: (asset_kind == GameCreationAppAssetKind::IconSpritesheet)
@@ -4266,7 +4265,6 @@ mod local_project_asset_generation_tests {
assert_eq!(request.prompt, "像素月光厨房主角");
assert_eq!(request.options.asset_kind.as_str(), kind);
assert!(!request.options.replace_existing);
assert_eq!(request.options.slice_count, None);
}
}