From 49a79aee5466a9a01f07d73f6d98d97431da8d2b Mon Sep 17 00:00:00 2001 From: kdletters Date: Fri, 24 Apr 2026 12:42:43 +0800 Subject: [PATCH] fix: refresh custom world publish gate diagnostics --- ...ION_API_SERVER_LLM_MIGRATION_2026-04-23.md | 17 ++ .../crates/api-server/src/custom_world.rs | 118 +++++++++++++ .../PlatformEntryFlowShellImpl.tsx | 160 +++++++++++++++++- .../RpgCreationResultViewImpl.tsx | 19 ++- .../rpg-entry/useRpgCreationResultAutosave.ts | 13 +- 5 files changed, 321 insertions(+), 6 deletions(-) diff --git a/docs/technical/CUSTOM_WORLD_DRAFT_FOUNDATION_API_SERVER_LLM_MIGRATION_2026-04-23.md b/docs/technical/CUSTOM_WORLD_DRAFT_FOUNDATION_API_SERVER_LLM_MIGRATION_2026-04-23.md index c024877b..76c86792 100644 --- a/docs/technical/CUSTOM_WORLD_DRAFT_FOUNDATION_API_SERVER_LLM_MIGRATION_2026-04-23.md +++ b/docs/technical/CUSTOM_WORLD_DRAFT_FOUNDATION_API_SERVER_LLM_MIGRATION_2026-04-23.md @@ -173,3 +173,20 @@ Rust 首版沿用这些结论,但不要求一次性照搬旧 Node 的全部多 3. `server-rs/crates/spacetime-module/src/lib.rs` 4. `server-rs/crates/module-custom-world/src/lib.rs` 5. `docs/technical/SPACETIMEDB_CUSTOM_WORLD_WORKS_AND_AGENT_EXTENSION_STAGE9_DESIGN_2026-04-22.md` + +## 2026-04-24 发布阻断快照刷新修正 + +### 问题 +- 现象:生成 foundation draft 后,结果页已经能看到玩家 premise、主线章节、第一幕场景幕,但底部仍显示旧的发布阻断项,并禁用“发布并进入世界”。 +- 根因:前端结果页直接消费 `session.resultPreview.blockers`。当当前页面 profile 已经补齐结构字段,但服务端 `resultPreview` 仍停留在上一轮快照时,展示态与可点击态会被旧 blocker 锁住。 +- 次要根因:进入世界前的 `sync_result_profile` 发现当前页面 profile 与 session preview 签名一致时会短路,导致旧 `publish_gate_json/result_preview_json` 没机会被强制重算。 + +### 落地方案 +- `PlatformEntryFlowShellImpl` 在 Agent 草稿结果页按当前 `generatedCustomWorldProfile` 判断旧结构 blocker 是否已过期,文案继续沿用后端 `resultPreview.blockers`,前端不新增重复中文提示。 +- 非结构类 blocker 继续继承服务端快照,避免把真实质量阻断误放行。 +- `useRpgCreationResultAutosave.syncAgentDraftResultProfile` 在 `agentSession.resultPreview.publishReady === false` 时不走签名短路,发布前会调用后端 `sync_result_profile` 重建 `publish_gate_json/result_preview_json`。 +- `api-server` 在读取 session 以及执行 `draft_foundation/sync_result_profile/publish_world` 后写入 `custom_world.publish_gate` 诊断日志,记录 blocker code、preview 来源与关键门禁字段是否为空;前端只显示简洁阻断数量,不展示字段细节。 + +### 验收点 +- 生成草稿后,如果当前 profile 已包含 `playerPremise` 或 `anchorContent.playerEntryPoint`、`coreConflicts`、`chapters/sceneChapterBlueprints` 与至少一个 `acts`,结果页不再继续显示旧结构 blocker。 +- 点击“发布并进入世界”前仍会同步到 SpacetimeDB reducer,由后端重新计算最终发布门禁;若仍有非结构质量 blocker,按钮仍保持阻断。 diff --git a/server-rs/crates/api-server/src/custom_world.rs b/server-rs/crates/api-server/src/custom_world.rs index d5ca1d2c..8fcf6b1d 100644 --- a/server-rs/crates/api-server/src/custom_world.rs +++ b/server-rs/crates/api-server/src/custom_world.rs @@ -38,6 +38,7 @@ use spacetime_client::{ CustomWorldWorkSummaryRecord, SpacetimeClientError, }; use std::convert::Infallible; +use tracing::info; use crate::{ api_response::json_success_body, @@ -471,6 +472,7 @@ pub async fn get_custom_world_agent_session( .map_err(|error| { custom_world_error_response(&request_context, map_custom_world_client_error(error)) })?; + log_custom_world_publish_gate_diagnostics("get_session", &session); Ok(json_success_body( Some(&request_context), @@ -998,6 +1000,19 @@ pub async fn execute_custom_world_agent_action( custom_world_error_response(&request_context, map_custom_world_client_error(error)) })?; + if matches!( + action.as_str(), + "sync_result_profile" | "publish_world" | "draft_foundation" + ) { + if let Ok(session) = state + .spacetime_client() + .get_custom_world_agent_session(session_id.clone(), owner_user_id.clone()) + .await + { + log_custom_world_publish_gate_diagnostics(action.as_str(), &session); + } + } + Ok(json_success_body( Some(&request_context), json!({ @@ -1131,6 +1146,109 @@ fn map_custom_world_agent_session_response( } } +fn log_custom_world_publish_gate_diagnostics( + source: &str, + session: &CustomWorldAgentSessionRecord, +) { + let draft_profile = session.draft_profile.as_object(); + let preview_profile = session + .result_preview + .as_ref() + .and_then(|preview| preview.get("preview")) + .and_then(Value::as_object); + let profile = preview_profile.or(draft_profile); + let blocker_codes = session + .publish_gate + .as_ref() + .map(|gate| { + gate.blockers + .iter() + .map(|blocker| blocker.code.as_str()) + .collect::>() + .join(",") + }) + .unwrap_or_default(); + + info!( + target: "custom_world.publish_gate", + source, + session_id = %session.session_id, + stage = %session.stage, + publish_ready = session.publish_gate.as_ref().map(|gate| gate.publish_ready).unwrap_or(false), + blocker_count = session.publish_gate.as_ref().map(|gate| gate.blocker_count).unwrap_or(0), + blocker_codes = %blocker_codes, + has_draft_profile = session.draft_profile.as_object().map(|value| !value.is_empty()).unwrap_or(false), + has_result_preview = session.result_preview.is_some(), + preview_source = session.result_preview.as_ref().and_then(|value| value.get("source")).and_then(Value::as_str).unwrap_or(""), + has_world_hook = has_custom_world_publish_text(profile, &["worldHook", "creatorIntent.worldHook", "anchorContent.worldPromise.hook", "settingText"]), + has_player_premise = has_custom_world_publish_text(profile, &["playerPremise", "creatorIntent.playerPremise", "anchorContent.playerEntryPoint.openingIdentity", "anchorContent.playerEntryPoint.openingProblem", "anchorContent.playerEntryPoint.entryMotivation"]), + has_core_conflicts = has_custom_world_non_empty_text_array(profile, "coreConflicts"), + has_main_chapter = has_custom_world_array(profile, "chapters") || has_custom_world_array(profile, "sceneChapterBlueprints") || has_custom_world_array(profile, "sceneChapters"), + has_scene_act = has_custom_world_scene_act(profile), + "custom world publish gate diagnostics" + ); +} + +fn has_custom_world_publish_text(profile: Option<&Map>, paths: &[&str]) -> bool { + paths.iter().any(|path| { + let mut segments = path.split('.'); + let Some(first_segment) = segments.next() else { + return false; + }; + let mut current = profile.and_then(|value| value.get(first_segment)); + for segment in segments { + current = current.and_then(|value| value.get(segment)); + } + current + .and_then(Value::as_str) + .map(str::trim) + .map(|value| !value.is_empty()) + .unwrap_or(false) + }) +} + +fn has_custom_world_non_empty_text_array(profile: Option<&Map>, key: &str) -> bool { + profile + .and_then(|value| value.get(key)) + .and_then(Value::as_array) + .map(|items| { + items.iter().any(|item| { + item.as_str() + .map(str::trim) + .is_some_and(|value| !value.is_empty()) + }) + }) + .unwrap_or(false) +} + +fn has_custom_world_array(profile: Option<&Map>, key: &str) -> bool { + profile + .and_then(|value| value.get(key)) + .and_then(Value::as_array) + .map(|items| !items.is_empty()) + .unwrap_or(false) +} + +fn has_custom_world_scene_act(profile: Option<&Map>) -> bool { + profile + .and_then(|value| { + value + .get("sceneChapterBlueprints") + .or_else(|| value.get("sceneChapters")) + }) + .and_then(Value::as_array) + .map(|chapters| { + chapters.iter().any(|chapter| { + chapter + .get("acts") + .and_then(Value::as_array) + .map(|acts| !acts.is_empty()) + .unwrap_or(false) + }) + }) + .unwrap_or(false) +} + fn map_custom_world_publish_gate_response( gate: CustomWorldPublishGateRecord, ) -> CustomWorldPublishGateResponse { diff --git a/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx b/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx index b11aef21..80d291da 100644 --- a/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx +++ b/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx @@ -92,6 +92,147 @@ import { usePlatformEntryBootstrap } from './usePlatformEntryBootstrap'; import { usePlatformEntryLibraryDetail } from './usePlatformEntryLibraryDetail'; import { usePlatformEntryNavigation } from './usePlatformEntryNavigation'; +type AgentResultPublishGateView = { + blockers: string[]; + publishReady: boolean; +}; + +type AgentResultBlockerView = { + code?: string; + message: string; +}; + +const AGENT_RESULT_STRUCTURAL_BLOCKER_CODES = new Set([ + 'publish_missing_world_hook', + 'publish_missing_player_premise', + 'publish_missing_core_conflict', + 'publish_missing_main_chapter', + 'publish_missing_first_act', +]); + +function readProfileTextField( + profile: CustomWorldProfile | null, + paths: string[], +) { + for (const path of paths) { + let current: unknown = profile; + for (const segment of path.split('.')) { + if (!current || typeof current !== 'object') { + current = null; + break; + } + current = (current as Record)[segment]; + } + if (typeof current === 'string' && current.trim()) { + return current.trim(); + } + } + return null; +} + +function hasProfileTextArray(profile: CustomWorldProfile | null, key: string) { + const value = profile + ? (profile as unknown as Record)[key] + : null; + return Array.isArray(value) + ? value.some((entry) => typeof entry === 'string' && entry.trim()) + : false; +} + +function hasProfileArray(profile: CustomWorldProfile | null, key: string) { + const value = profile + ? (profile as unknown as Record)[key] + : null; + return Array.isArray(value) && value.length > 0; +} + +function hasSceneAct(profile: CustomWorldProfile | null) { + const rawProfile = profile as unknown as Record | null; + const chapters = + rawProfile && + (Array.isArray(rawProfile.sceneChapterBlueprints) + ? rawProfile.sceneChapterBlueprints + : Array.isArray(rawProfile.sceneChapters) + ? rawProfile.sceneChapters + : []); + return Array.isArray(chapters) + ? chapters.some((chapter) => { + const acts = + chapter && typeof chapter === 'object' + ? (chapter as Record).acts + : null; + return Array.isArray(acts) && acts.length > 0; + }) + : false; +} + +function isAgentResultStructuralBlockerResolved( + profile: CustomWorldProfile, + code: string | undefined, +) { + if (!code || !AGENT_RESULT_STRUCTURAL_BLOCKER_CODES.has(code)) { + return false; + } + + if (code === 'publish_missing_world_hook') { + return Boolean( + readProfileTextField(profile, [ + 'worldHook', + 'creatorIntent.worldHook', + 'anchorContent.worldPromise.hook', + 'settingText', + ]), + ); + } + if (code === 'publish_missing_player_premise') { + return Boolean( + readProfileTextField(profile, [ + 'playerPremise', + 'creatorIntent.playerPremise', + 'anchorContent.playerEntryPoint.openingIdentity', + 'anchorContent.playerEntryPoint.openingProblem', + 'anchorContent.playerEntryPoint.entryMotivation', + ]), + ); + } + if (code === 'publish_missing_core_conflict') { + return hasProfileTextArray(profile, 'coreConflicts'); + } + if (code === 'publish_missing_main_chapter') { + return ( + hasProfileArray(profile, 'chapters') || + hasProfileArray(profile, 'sceneChapterBlueprints') || + hasProfileArray(profile, 'sceneChapters') + ); + } + return hasSceneAct(profile); +} + +function buildAgentResultPublishGateView( + profile: CustomWorldProfile | null, + fallbackBlockers: AgentResultBlockerView[], + fallbackPublishReady: boolean, +): AgentResultPublishGateView { + if (!profile) { + return { + blockers: fallbackBlockers.map((entry) => entry.message), + publishReady: fallbackPublishReady, + }; + } + + const blockers = fallbackBlockers + .filter( + (entry) => + !isAgentResultStructuralBlockerResolved(profile, entry.code), + ) + .map((entry) => entry.message); + + return { + blockers, + publishReady: blockers.length === 0, + }; +} + const CustomWorldGenerationView = lazy(async () => { const module = await import('../CustomWorldGenerationView'); return { @@ -318,9 +459,22 @@ export function PlatformEntryFlowShellImpl({ const agentResultPreview = sessionController.agentSession?.resultPreview ?? null; const agentResultPreviewBlockers = useMemo( - () => agentResultPreview?.blockers?.map((entry) => entry.message) ?? [], + () => agentResultPreview?.blockers ?? [], [agentResultPreview], ); + const agentResultPublishGateView = useMemo( + () => + buildAgentResultPublishGateView( + sessionController.generatedCustomWorldProfile, + agentResultPreviewBlockers, + Boolean(agentResultPreview?.publishReady), + ), + [ + agentResultPreview?.publishReady, + agentResultPreviewBlockers, + sessionController.generatedCustomWorldProfile, + ], + ); const agentResultPreviewQualityFindings = useMemo( () => agentResultPreview?.qualityFindings ?? [], [agentResultPreview], @@ -1928,12 +2082,12 @@ export function PlatformEntryFlowShellImpl({ } publishReady={ sessionController.isAgentDraftResultView - ? Boolean(agentResultPreview?.publishReady) + ? agentResultPublishGateView.publishReady : true } publishBlockers={ sessionController.isAgentDraftResultView - ? agentResultPreviewBlockers + ? agentResultPublishGateView.blockers : [] } qualityFindings={ diff --git a/src/components/rpg-creation-result/RpgCreationResultViewImpl.tsx b/src/components/rpg-creation-result/RpgCreationResultViewImpl.tsx index 34c928fb..aae8ff22 100644 --- a/src/components/rpg-creation-result/RpgCreationResultViewImpl.tsx +++ b/src/components/rpg-creation-result/RpgCreationResultViewImpl.tsx @@ -1,4 +1,4 @@ -import { useMemo, useState } from 'react'; +import { useMemo, useState } from 'react'; import type { Character, CustomWorldProfile } from '../../types'; import { @@ -201,6 +201,23 @@ export function RpgCreationResultView({ {error} ) : null} + {!error && compactAgentResultMode && previewSourceLabel ? ( +
+ 当前结果页数据源:{previewSourceLabel} +
+ ) : null} + {!error && compactAgentResultMode && publishBlockers.length > 0 ? ( +
+ {publishReady + ? '当前世界已满足发布门槛。' + : `当前还有 ${publishBlockers.length} 个发布阻断项,请先补齐后再进入世界。`} + {!publishReady ? ( +
+ 详细诊断已记录到后端日志。 +
+ ) : null} +
+ ) : null} {!error && compactAgentResultMode && publishBlockers.length <= 0 && diff --git a/src/components/rpg-entry/useRpgCreationResultAutosave.ts b/src/components/rpg-entry/useRpgCreationResultAutosave.ts index e0a9403c..ffbc85f0 100644 --- a/src/components/rpg-entry/useRpgCreationResultAutosave.ts +++ b/src/components/rpg-entry/useRpgCreationResultAutosave.ts @@ -192,8 +192,14 @@ export function useRpgCreationResultAutosave( const latestSessionProfileSignature = latestSessionProfile ? stringifyAgentBackedProfile(latestSessionProfile) : ''; + const shouldRefreshPublishGate = Boolean( + agentSession?.resultPreview && !agentSession.resultPreview.publishReady, + ); - if (latestSessionProfileSignature === profileSignature) { + if ( + latestSessionProfileSignature === profileSignature && + !shouldRefreshPublishGate + ) { latestAgentResultSyncSignatureRef.current = profileSignature; return { session: agentSession, @@ -201,7 +207,10 @@ export function useRpgCreationResultAutosave( } satisfies SyncedAgentDraftResult; } - if (latestAgentResultSyncSignatureRef.current === profileSignature) { + if ( + latestAgentResultSyncSignatureRef.current === profileSignature && + !shouldRefreshPublishGate + ) { return { session: agentSession, profile: normalizeAgentBackedProfile(latestSessionProfile ?? profile),