diff --git a/.hermes/shared-memory/decision-log.md b/.hermes/shared-memory/decision-log.md index b245459b..ce1e68d5 100644 --- a/.hermes/shared-memory/decision-log.md +++ b/.hermes/shared-memory/decision-log.md @@ -1419,6 +1419,14 @@ - 验证方式:`npm run test -- src/components/platform-entry/platformPuzzleDraftRecoveryModel.test.ts`、针对新 Module 和 `PlatformEntryFlowShellImpl.tsx` 执行 ESLint、`npm run test -- src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx -t "persisted generating puzzle draft"`、`npm run typecheck`、`npm run check:encoding`。 - 关联文档:`docs/technical/【前端架构】PlatformPuzzleDraftRecoveryModel收口计划-2026-06-04.md`、`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`。 +## 2026-06-04 Puzzle Publish Asset Gate 收紧 + +- 背景:后端拼图待发布门槛与前端历史恢复逻辑一样偏弱,只要求标题、描述、标签、关卡名和 cover,导致缺关卡画面、UI spritesheet 或关卡背景的半成品可能被标为 `publishReady` / `ready_to_publish`。 +- 决策:`module-puzzle::validate_publish_requirements` 新增三类资产 blocker,要求每关具备 `level_scene_image_*`、`ui_spritesheet_image_*` 与 `level_background_image_*`;`api-server::puzzle::tags::is_puzzle_session_snapshot_publish_ready` 同步使用完整资产包判定。 +- 影响范围:拼图 result preview blockers、publishReady、标签生成后 session stage、从 action payload 构造 fallback session 的 ready 判定。 +- 验证方式:`cargo test -p module-puzzle --manifest-path server-rs/Cargo.toml validate_publish_requirements`、`cargo test -p api-server --manifest-path server-rs/Cargo.toml puzzle_image_generation_builds_fallback_session_from_levels_snapshot`、`cargo test -p api-server --manifest-path server-rs/Cargo.toml puzzle_image_generation_fallback_session_ready_when_asset_pack_complete`、`npm run check:encoding`。 +- 关联文档:`docs/technical/【后端架构】PuzzlePublishAssetGate收紧计划-2026-06-04.md`、`docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md`、`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`。 + ## 2026-06-03 Public Work Presentation 收口 - 背景:作品卡、推荐 runtime meta、排行项、分类项、搜索结果和桌面 hero 共用玩法类型 label 与紧凑计数格式,但规则仍在 `RpgEntryHomeView.tsx` 页面 Implementation 内。 diff --git a/.hermes/shared-memory/pitfalls.md b/.hermes/shared-memory/pitfalls.md index f9043f96..032cd00d 100644 --- a/.hermes/shared-memory/pitfalls.md +++ b/.hermes/shared-memory/pitfalls.md @@ -1245,8 +1245,8 @@ - 现象:拼图创作有时刚结束就跳到“待发布”结果页,但结果页里的正式图还是空的,发布检查随后又会拦住,用户会感觉“已经完成了却又不能发布”。 - 原因:拼图的待发布判定太弱,`build_result_preview` / `validate_publish_requirements` 和 `is_puzzle_session_snapshot_publish_ready` 只检查了作品名、简介、标签、关卡名和 cover 图,没有要求 `level_scene_image_src`、`ui_spritesheet_image_src`、`level_background_image_src` 等完整资产都齐;历史前端恢复链路里的 `hasRecoverableGeneratedPuzzleDraft` / `normalizeRecoveredPuzzleDraftSession` 也只要有 cover 或候选图就会把草稿当成已完成。 -- 处理:前端恢复链路已收口到 `platformPuzzleDraftRecoveryModel.ts`,只有首图、关卡画面、UI spritesheet 与关卡背景资产包完整时才把恢复草稿抬为完成态;后端 `build_result_preview` / `validate_publish_requirements` / `is_puzzle_session_snapshot_publish_ready` 的待发布门槛仍需后续收紧到整套拼图资产包完整。 -- 验证:当某个拼图草稿只补齐首图、但关卡背景或 UI spritesheet 仍缺失时,前端恢复链路不应把它误判为已完成;后端后续修复后也不应进入 `ready_to_publish`。 +- 处理:前端恢复链路已收口到 `platformPuzzleDraftRecoveryModel.ts`,只有首图、关卡画面、UI spritesheet 与关卡背景资产包完整时才把恢复草稿抬为完成态;后端 `build_result_preview` / `validate_publish_requirements` / `is_puzzle_session_snapshot_publish_ready` 也已收紧到同一完整资产包门槛。 +- 验证:当某个拼图草稿只补齐首图、但关卡背景或 UI spritesheet 仍缺失时,前端恢复链路不应把它误判为已完成,后端也不应进入 `ready_to_publish` 或返回 `publishReady=true`。 - 关联:`server-rs/crates/module-puzzle/src/application.rs`、`server-rs/crates/api-server/src/puzzle/tags.rs`、`server-rs/crates/api-server/src/puzzle/draft.rs`、`src/components/platform-entry/platformPuzzleDraftRecoveryModel.ts`、`src/components/puzzle-result/PuzzleResultView.tsx`。 ## WebGL 画布在高 DPR 移动端放大溢出 diff --git a/docs/README.md b/docs/README.md index 6923e065..0834bc7c 100644 --- a/docs/README.md +++ b/docs/README.md @@ -63,6 +63,8 @@ AI 文字游戏模板接入以 [AI_NATIVE_TEXT_GAME_TEMPLATE_MOKU_REFERENCE_PRD_ 平台拼图生成完成后刷新恢复的草稿归一化与可恢复完成态判定收口到 `src/components/platform-entry/platformPuzzleDraftRecoveryModel.ts`,恢复链路只有在首图、关卡画面、UI spritesheet 与关卡背景资产包完整时才抬为 ready,规则见 [【前端架构】PlatformPuzzleDraftRecoveryModel收口计划-2026-06-04.md](./technical/【前端架构】PlatformPuzzleDraftRecoveryModel收口计划-2026-06-04.md)。 +后端拼图发布 / 待发布门槛收紧到首图、关卡画面、UI spritesheet 与关卡背景资产包完整,`module-puzzle` 的 preview blockers 与 `api-server` 的 session stage 判定保持同一规则,方案见 [【后端架构】PuzzlePublishAssetGate收紧计划-2026-06-04.md](./technical/【后端架构】PuzzlePublishAssetGate收紧计划-2026-06-04.md)。 + RPG Agent 结果页发布门禁展示和预览来源 label 收口到 `src/components/platform-entry/platformRpgAgentResultPreviewModel.ts`,壳层只保留 session/profile 编排和结果页 props 传递,规则见 [【前端架构】PlatformRpgAgentResultPreviewModel收口计划-2026-06-04.md](./technical/【前端架构】PlatformRpgAgentResultPreviewModel收口计划-2026-06-04.md)。 平台入口创作生成通知、pending 作品架占位、失败覆盖、拼图稳定 ID 和草稿 Tab 未读点收口到 `src/components/platform-entry/platformDraftGenerationShelfModel.ts`,规则见 [【前端架构】DraftGenerationShelfModel收口计划-2026-06-03.md](./technical/%E3%80%90%E5%89%8D%E7%AB%AF%E6%9E%B6%E6%9E%84%E3%80%91DraftGenerationShelfModel%E6%94%B6%E5%8F%A3%E8%AE%A1%E5%88%92-2026-06-03.md)。 diff --git a/docs/technical/【后端架构】PuzzlePublishAssetGate收紧计划-2026-06-04.md b/docs/technical/【后端架构】PuzzlePublishAssetGate收紧计划-2026-06-04.md new file mode 100644 index 00000000..9b0f263b --- /dev/null +++ b/docs/technical/【后端架构】PuzzlePublishAssetGate收紧计划-2026-06-04.md @@ -0,0 +1,38 @@ +# 【后端架构】Puzzle Publish Asset Gate 收紧计划 + +## 背景 + +拼图前端恢复链路已由 `platformPuzzleDraftRecoveryModel.ts` 收紧:只有首图、关卡画面、UI spritesheet 与关卡背景资产包完整时,才把恢复草稿抬为完成态。但后端仍有两处待发布门槛偏弱: + +- `module-puzzle::validate_publish_requirements(...)` 只校验作品名、描述、标签、关卡名与 cover。 +- `api-server::puzzle::tags::is_puzzle_session_snapshot_publish_ready(...)` 也只校验同一组轻字段,并据此把 session stage 置为 `ready_to_publish`。 + +这会让只有首图但缺关卡正式画面、UI spritesheet 或关卡背景的半成品显示为可发布或进入待发布 stage。 + +## 决策 + +后端拼图待发布门槛统一收紧到完整首关资产包: + +- `module-puzzle` 在 `validate_publish_requirements` 中新增资产 blocker,业务规则继续留在领域模块。 +- `api-server` 的 session snapshot ready 判定复用同一资产语言,避免标签生成后把半成品 session stage 改成 `ready_to_publish`。 +- 本切片不改 SpacetimeDB schema、不改 DTO 字段、不改路由、不改计费和发布动作副作用。 + +## 接口约束 + +- 仍保留既有作品名、描述、标签数量、关卡名、cover 校验。 +- 每个关卡必须具备: + - `cover_image_src`; + - `level_scene_image_src` 或 `level_scene_image_object_key`; + - `ui_spritesheet_image_src` 或 `ui_spritesheet_image_object_key`; + - `level_background_image_src` 或 `level_background_image_object_key`。 +- 缺正式关卡画面、UI spritesheet、关卡背景时,各自输出明确 blocker。 +- `build_result_preview(...).publish_ready` 与 `is_puzzle_session_snapshot_publish_ready(...)` 必须在同一类缺资产草稿上返回 false。 +- `api-server` 从 action payload 构造 fallback session 时,缺资产 levels snapshot 应停留 `image_refining`,不得进入 `ready_to_publish`。 + +## 验收 + +- `cargo test -p module-puzzle --manifest-path server-rs/Cargo.toml validate_publish_requirements` +- `cargo test -p api-server --manifest-path server-rs/Cargo.toml puzzle_image_generation` +- `npm run check:encoding` +- `git diff --check` +- 修改后按仓规尝试 `npm run api-server` 拉起后端并确认 `/healthz`。 diff --git a/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md b/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md index 053fe85c..c5d48c72 100644 --- a/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md +++ b/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md @@ -104,6 +104,8 @@ npm run check:server-rs-ddd - `server-rs/crates/api-server/src/puzzle/mappers.rs` 承接 SpacetimeDB record 到 shared-contracts DTO 的映射。 - `server-rs/crates/api-server/src/puzzle/tags.rs` 保留拼图标签生成、拼图通用错误映射和 SSE helper。 +拼图发布 / 待发布门槛必须同时要求首图、关卡画面、UI spritesheet 与关卡背景资产包完整;`module-puzzle::validate_publish_requirements` 与 `api-server::puzzle::tags::is_puzzle_session_snapshot_publish_ready` 使用同一资产语言,不得只凭 cover、标题、描述和标签把半成品标为 `publishReady` 或 `ready_to_publish`。 + 该拆分只改变 `api-server` 文件组织,不改变 `/api/runtime/puzzle/*` route、DTO、error envelope、SpacetimeDB schema、公开 gallery cache 语义或计费语义;后续继续细分时也必须先保持行为不变,再单独讨论领域规则下沉。 `/api/runtime/puzzle/runs*` 当前接受 `RuntimePrincipal`,可同时识别登录用户 Bearer 和 runtime guest token。推荐页嵌入运行态的正式开局、交换、拖拽、下一关、暂停、道具与排行榜请求,应由前端在登录态下继续携带账号 access token;匿名游客仅在确认为未登录时走 runtime guest token。不要再把拼图 runtime 当成只认普通 Bearer 的纯账号接口。 diff --git a/docs/【玩法创作】平台入口与玩法链路-2026-05-15.md b/docs/【玩法创作】平台入口与玩法链路-2026-05-15.md index 15efac60..05e7171e 100644 --- a/docs/【玩法创作】平台入口与玩法链路-2026-05-15.md +++ b/docs/【玩法创作】平台入口与玩法链路-2026-05-15.md @@ -20,6 +20,8 @@ 拼图生成完成后刷新恢复的草稿归一化与可恢复完成态判定统一由 `platformPuzzleDraftRecoveryModel.ts` 处理。恢复链路只有在首图、关卡画面、UI spritesheet 与关卡背景资产包完整时才可把 draft 和首关状态抬为 `ready`;只有 cover 或候选图的半成品不得直接进入结果页完成态。 +后端拼图发布 / 待发布门槛同样必须要求首图、关卡画面、UI spritesheet 与关卡背景资产包完整:`module-puzzle` preview blockers 与 `api-server` session stage 判定不得只凭 cover、标题、描述和标签把半成品标为 `publishReady` 或 `ready_to_publish`。 + RPG Agent 结果页发布门禁展示由 `platformRpgAgentResultPreviewModel.ts` 判定:平台壳不得重新手写 `CustomWorldProfile` 顶层、`creatorIntent`、`anchorContent`、章节蓝图与首幕 acts 的结构探测,也不得在壳层内联 result preview source label 映射;壳层只负责 session/profile 编排和结果页 props 传递。 统一创作入口覆盖当前可进入创作链路的已有模板:`rpg`、`big-fish`、`puzzle`、`match3d`、`jump-hop`、`wooden-fish`、`square-hole`、`bark-battle`、`visual-novel`、`baby-object-match` 和 `creative-agent`;`airp` 仍是未开放占位,不作为当前统一创作链路目标。拼图、抓大鹅、跳一跳和敲木鱼在前端继续经过 `UnifiedCreationWorkspace` 和 `UnifiedGenerationPage`:`UnifiedCreationWorkspace` 作为平台壳依赖的统一创作编排层,再内部调用 `src/components/unified-creation/workspaces/` 下的 `PuzzleCreationWorkspace`、`Match3DCreationWorkspace`、`JumpHopCreationWorkspace` 和 `WoodenFishCreationWorkspace`。其它已有模板由平台壳用 `UnifiedCreationPage` 包住既有工作台,复用统一标题栏、返回入口、页面级纵向滚动和隐藏字段契约,同时保留各玩法自己的表单、草稿恢复和后续编排。创作页字段清单由后端在 `GET /api/creation-entry/config` 的 `creationTypes[].unifiedCreationSpec` 下发,前端仅在该扩展位缺失时回退到本地默认 spec;字段类型只保留 `text`、`select`、`image`、`audio`。`UnifiedCreationPage` 不在 UI 中额外展示字段说明 chip,也不在右上角显示内部 `playId`、模板 ID 或工作台阶段名;竖屏移动端必须能从标题、表单一路滑到提交按钮。各玩法工作台负责渲染真实输入控件、上传、历史素材、校验和提交,但返回按钮只保留在统一页头,工作台内部不再重复渲染。暗色创作进度卡片位于 `platform-remap-surface` 内时,必须用组件专属 class 覆盖浅色主题 remap,确保白字、浅色边框和进度条底色不会被全局规则改成深色;不要只依赖通用 `text-white*` 类。敲木鱼的音效和功德词条面板不得放进独立内部滚动容器,移动端应跟随页面自然滚动展开。生成页统一展示阶段、当前步骤、总进度、错误和重试动作。 diff --git a/server-rs/crates/api-server/src/app.rs b/server-rs/crates/api-server/src/app.rs index 106c7d77..a9fbf3d5 100644 --- a/server-rs/crates/api-server/src/app.rs +++ b/server-rs/crates/api-server/src/app.rs @@ -4053,8 +4053,7 @@ mod tests { .await .expect("banners body should collect") .to_bytes(); - let payload: Value = - serde_json::from_slice(&body).expect("banners payload should be json"); + let payload: Value = serde_json::from_slice(&body).expect("banners payload should be json"); assert_eq!(payload["eventBanners"][0]["title"], "后台表单公告"); assert_eq!(payload["eventBanners"][0]["renderMode"], "html"); diff --git a/server-rs/crates/api-server/src/puzzle/draft.rs b/server-rs/crates/api-server/src/puzzle/draft.rs index 276a29f5..f5849970 100644 --- a/server-rs/crates/api-server/src/puzzle/draft.rs +++ b/server-rs/crates/api-server/src/puzzle/draft.rs @@ -307,13 +307,18 @@ pub(crate) fn build_puzzle_session_snapshot_from_action_payload( levels, form_draft: None, }; + let stage = if is_puzzle_session_snapshot_publish_ready(&draft) { + "ready_to_publish" + } else { + "image_refining" + }; Ok(PuzzleAgentSessionRecord { session_id: session_id.to_string(), seed_text: String::new(), current_turn: 0, progress_percent: 94, - stage: "ready_to_publish".to_string(), + stage: stage.to_string(), anchor_pack, draft: Some(draft), messages: Vec::new(), @@ -1788,7 +1793,11 @@ pub(crate) fn apply_generated_puzzle_candidates_to_session_snapshot( sync_puzzle_primary_draft_fields_from_level(draft); } session.progress_percent = session.progress_percent.max(94); - session.stage = "ready_to_publish".to_string(); + session.stage = if is_puzzle_session_snapshot_publish_ready(draft) { + "ready_to_publish".to_string() + } else { + "image_refining".to_string() + }; session.last_assistant_reply = Some("拼图图片已经生成,并已替换当前正式图。".to_string()); session.updated_at = format_timestamp_micros(updated_at_micros); session diff --git a/server-rs/crates/api-server/src/puzzle/tags.rs b/server-rs/crates/api-server/src/puzzle/tags.rs index f49cc84e..c6ce0693 100644 --- a/server-rs/crates/api-server/src/puzzle/tags.rs +++ b/server-rs/crates/api-server/src/puzzle/tags.rs @@ -248,6 +248,17 @@ pub(super) fn apply_generated_puzzle_tags_to_session_snapshot( session } +fn has_required_puzzle_asset_ref(image_src: &Option, object_key: &Option) -> bool { + image_src + .as_deref() + .map(str::trim) + .is_some_and(|value| !value.is_empty()) + || object_key + .as_deref() + .map(str::trim) + .is_some_and(|value| !value.is_empty()) +} + pub(super) fn is_puzzle_session_snapshot_publish_ready(draft: &PuzzleResultDraftRecord) -> bool { !draft.work_title.trim().is_empty() && !draft.work_description.trim().is_empty() @@ -261,6 +272,18 @@ pub(super) fn is_puzzle_session_snapshot_publish_ready(draft: &PuzzleResultDraft .as_deref() .map(str::trim) .is_some_and(|value| !value.is_empty()) + && has_required_puzzle_asset_ref( + &level.level_scene_image_src, + &level.level_scene_image_object_key, + ) + && has_required_puzzle_asset_ref( + &level.ui_spritesheet_image_src, + &level.ui_spritesheet_image_object_key, + ) + && has_required_puzzle_asset_ref( + &level.level_background_image_src, + &level.level_background_image_object_key, + ) }) } diff --git a/server-rs/crates/api-server/src/puzzle/tests.rs b/server-rs/crates/api-server/src/puzzle/tests.rs index 86512e7d..1d62d2b0 100644 --- a/server-rs/crates/api-server/src/puzzle/tests.rs +++ b/server-rs/crates/api-server/src/puzzle/tests.rs @@ -474,7 +474,7 @@ fn puzzle_image_generation_builds_fallback_session_from_levels_snapshot() { .expect("fallback session"); let draft = session.draft.expect("draft"); - assert_eq!(session.stage, "ready_to_publish"); + assert_eq!(session.stage, "image_refining"); assert_eq!(draft.work_title, "暖灯猫街作品"); assert_eq!(draft.theme_tags, vec!["猫咪", "雨夜"]); assert_eq!(draft.levels[0].level_id, "puzzle-level-1"); @@ -484,6 +484,62 @@ fn puzzle_image_generation_builds_fallback_session_from_levels_snapshot() { ); } +#[test] +fn puzzle_image_generation_fallback_session_ready_when_asset_pack_complete() { + let levels_json = serde_json::to_string(&vec![json!({ + "level_id": "puzzle-level-1", + "level_name": "雨夜猫街", + "picture_description": "一只猫在雨夜灯牌下回头。", + "candidates": [], + "selected_candidate_id": null, + "cover_image_src": "/generated/puzzle/cover.png", + "cover_asset_id": "asset-cover", + "level_scene_image_src": "/generated/puzzle/level-scene.png", + "level_scene_image_object_key": "generated/puzzle/level-scene.png", + "ui_spritesheet_image_src": "/generated/puzzle/ui-spritesheet.png", + "ui_spritesheet_image_object_key": "generated/puzzle/ui-spritesheet.png", + "level_background_image_src": "/generated/puzzle/level-background.png", + "level_background_image_object_key": "generated/puzzle/level-background.png", + "generation_status": "ready", + })]) + .expect("levels json"); + let payload = ExecutePuzzleAgentActionRequest { + action: "generate_puzzle_images".to_string(), + prompt_text: None, + reference_image_src: None, + reference_image_srcs: Vec::new(), + reference_image_asset_object_id: None, + reference_image_asset_object_ids: Vec::new(), + image_model: Some(PUZZLE_IMAGE_MODEL_GPT_IMAGE_2.to_string()), + ai_redraw: None, + candidate_count: Some(1), + should_auto_name_level: None, + candidate_id: None, + level_id: Some("puzzle-level-1".to_string()), + work_title: Some("暖灯猫街作品".to_string()), + work_description: Some("一套雨夜猫街主题拼图。".to_string()), + picture_description: None, + level_name: None, + summary: Some("当前关卡画面。".to_string()), + theme_tags: Some(vec![ + "猫咪".to_string(), + "雨夜".to_string(), + "灯牌".to_string(), + ]), + levels_json: Some(levels_json.clone()), + }; + + let session = build_puzzle_session_snapshot_from_action_payload( + "puzzle-session-1", + &payload, + Some(levels_json.as_str()), + 1_713_686_401_234_567, + ) + .expect("fallback session"); + + assert_eq!(session.stage, "ready_to_publish"); +} + #[test] fn puzzle_first_level_name_parser_accepts_json_and_normalizes_text() { assert_eq!( diff --git a/server-rs/crates/module-puzzle/src/application.rs b/server-rs/crates/module-puzzle/src/application.rs index df8b1c4f..eca056e2 100644 --- a/server-rs/crates/module-puzzle/src/application.rs +++ b/server-rs/crates/module-puzzle/src/application.rs @@ -541,6 +541,17 @@ pub fn build_result_preview( } } +fn has_required_puzzle_asset_ref(image_src: &Option, object_key: &Option) -> bool { + image_src + .as_deref() + .map(str::trim) + .is_some_and(|value| !value.is_empty()) + || object_key + .as_deref() + .map(str::trim) + .is_some_and(|value| !value.is_empty()) +} + pub fn validate_publish_requirements( draft: &PuzzleResultDraft, author_display_name: Option<&str>, @@ -582,6 +593,36 @@ pub fn validate_publish_requirements( message: "正式拼图图片尚未确定".to_string(), }); } + if !has_required_puzzle_asset_ref( + &level.level_scene_image_src, + &level.level_scene_image_object_key, + ) { + blockers.push(PuzzleResultPreviewBlocker { + id: format!("missing-level-scene-image-{}", level.level_id), + code: "MISSING_LEVEL_SCENE_IMAGE".to_string(), + message: "正式关卡画面尚未生成".to_string(), + }); + } + if !has_required_puzzle_asset_ref( + &level.ui_spritesheet_image_src, + &level.ui_spritesheet_image_object_key, + ) { + blockers.push(PuzzleResultPreviewBlocker { + id: format!("missing-ui-spritesheet-image-{}", level.level_id), + code: "MISSING_UI_SPRITESHEET_IMAGE".to_string(), + message: "UI spritesheet 尚未生成".to_string(), + }); + } + if !has_required_puzzle_asset_ref( + &level.level_background_image_src, + &level.level_background_image_object_key, + ) { + blockers.push(PuzzleResultPreviewBlocker { + id: format!("missing-level-background-image-{}", level.level_id), + code: "MISSING_LEVEL_BACKGROUND_IMAGE".to_string(), + message: "关卡背景图尚未生成".to_string(), + }); + } } if draft.theme_tags.len() < PUZZLE_MIN_TAG_COUNT || draft.theme_tags.len() > PUZZLE_MAX_TAG_COUNT @@ -4011,4 +4052,37 @@ mod tests { .any(|blocker| blocker.code == "MISSING_LEVEL_NAME") ); } + + #[test] + fn validate_publish_requirements_requires_generated_level_asset_pack() { + let anchor_pack = infer_anchor_pack("雨夜猫咪神庙", Some("雨夜猫咪神庙")); + let mut draft = compile_result_draft(&anchor_pack, &[]); + draft.levels[0].cover_image_src = Some("/cover.png".to_string()); + + let blockers = validate_publish_requirements(&draft, Some("玩家")); + let blocker_codes = blockers + .iter() + .map(|blocker| blocker.code.as_str()) + .collect::>(); + assert!(blocker_codes.contains(&"MISSING_LEVEL_SCENE_IMAGE")); + assert!(blocker_codes.contains(&"MISSING_UI_SPRITESHEET_IMAGE")); + assert!(blocker_codes.contains(&"MISSING_LEVEL_BACKGROUND_IMAGE")); + + draft.levels[0].level_scene_image_object_key = + Some("generated/puzzle/level-scene.png".to_string()); + draft.levels[0].ui_spritesheet_image_object_key = + Some("generated/puzzle/ui-spritesheet.png".to_string()); + draft.levels[0].level_background_image_object_key = + Some("generated/puzzle/level-background.png".to_string()); + + let blockers = validate_publish_requirements(&draft, Some("玩家")); + assert!(!blockers.iter().any(|blocker| { + matches!( + blocker.code.as_str(), + "MISSING_LEVEL_SCENE_IMAGE" + | "MISSING_UI_SPRITESHEET_IMAGE" + | "MISSING_LEVEL_BACKGROUND_IMAGE" + ) + })); + } } diff --git a/server-rs/crates/module-runtime/src/application.rs b/server-rs/crates/module-runtime/src/application.rs index 902089a5..ab997c7f 100644 --- a/server-rs/crates/module-runtime/src/application.rs +++ b/server-rs/crates/module-runtime/src/application.rs @@ -161,10 +161,9 @@ fn normalize_creation_entry_announcement_banner_value( ); } - let banner = serde_json::from_value::(Value::Object( - object.clone(), - )) - .map_err(|error| format!("第 {} 条公告对象非法:{error}", index + 1))?; + let banner = + serde_json::from_value::(Value::Object(object.clone())) + .map_err(|error| format!("第 {} 条公告对象非法:{error}", index + 1))?; normalize_creation_entry_event_banner_response(index, banner) } @@ -327,10 +326,7 @@ fn normalize_banner_html_code( } let lower_html_code = html_code.to_ascii_lowercase(); if lower_html_code.contains(" Option