refactor: 收口 RPG 结果预览门禁
This commit is contained in:
@@ -1387,6 +1387,14 @@
|
||||
- 验证方式:`npm run test -- src/components/platform-entry/platformMiniGameSessionMappingModel.test.ts`、针对新 Module 和 `PlatformEntryFlowShellImpl.tsx` 执行 ESLint、`npm run typecheck`、`npm run check:encoding`。
|
||||
- 关联文档:`docs/technical/【前端架构】PlatformMiniGameSessionMappingModel收口计划-2026-06-04.md`、`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`。
|
||||
|
||||
## 2026-06-04 Platform RPG Agent Result Preview Model 收口
|
||||
|
||||
- 背景:`PlatformEntryFlowShellImpl.tsx` 内联维护 RPG Agent 结果页发布门禁展示修正和 result preview source label 映射,壳层需要理解 `CustomWorldProfile` 顶层字段、`creatorIntent`、`anchorContent`、章节蓝图和首幕 acts。
|
||||
- 决策:新增 `src/components/platform-entry/platformRpgAgentResultPreviewModel.ts`,收口 `buildPlatformRpgAgentResultPublishGateView` 与 `resolvePlatformRpgAgentResultPreviewSourceLabel`。Module 只做展示层纯判定;壳层继续负责 session/profile 编排、发布副作用和结果页 props 传递。
|
||||
- 影响范围:RPG Agent 结果页发布按钮门禁 blockers、publishReady 展示修正和预览来源 label。
|
||||
- 验证方式:`npm run test -- src/components/platform-entry/platformRpgAgentResultPreviewModel.test.ts`、针对新 Module 和 `PlatformEntryFlowShellImpl.tsx` 执行 ESLint、`npm run typecheck`、`npm run check:encoding`。
|
||||
- 关联文档:`docs/technical/【前端架构】PlatformRpgAgentResultPreviewModel收口计划-2026-06-04.md`、`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`。
|
||||
|
||||
## 2026-06-03 Public Work Presentation 收口
|
||||
|
||||
- 背景:作品卡、推荐 runtime meta、排行项、分类项、搜索结果和桌面 hero 共用玩法类型 label 与紧凑计数格式,但规则仍在 `RpgEntryHomeView.tsx` 页面 Implementation 内。
|
||||
|
||||
@@ -57,6 +57,8 @@ AI 文字游戏模板接入以 [AI_NATIVE_TEXT_GAME_TEMPLATE_MOKU_REFERENCE_PRD_
|
||||
|
||||
平台壳的拼图 runtime 恢复 work、跳一跳 pending session、敲木鱼 detail 恢复 session 和敲木鱼 pending session DTO 映射收口到 `src/components/platform-entry/platformMiniGameSessionMappingModel.ts`,壳层只保留网络、状态、URL 与 stage 副作用,规则见 [【前端架构】PlatformMiniGameSessionMappingModel收口计划-2026-06-04.md](./technical/【前端架构】PlatformMiniGameSessionMappingModel收口计划-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)。
|
||||
|
||||
平台入口创作恢复 URL 私有 query、初始恢复判定、创作直达恢复目标解析、恢复目标身份匹配、跳一跳 / 敲木鱼恢复阶段落点、拼图 runtime query 与拼图稳定身份互推收口到 `src/components/platform-entry/platformCreationUrlStateModel.ts` 和 `src/components/platform-entry/platformPuzzleIdentityModel.ts`,规则见 [【前端架构】CreationUrlStateModel收口计划-2026-06-03.md](./technical/【前端架构】CreationUrlStateModel收口计划-2026-06-03.md)。
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
# 【前端架构】Platform RPG Agent Result Preview Model 收口计划
|
||||
|
||||
## 背景
|
||||
|
||||
`PlatformEntryFlowShellImpl.tsx` 曾内联维护 RPG Agent 结果页的发布门禁展示规则:从 `CustomWorldProfile` 顶层字段、`creatorIntent`、`anchorContent`、章节蓝图和场景章节中反证服务端返回的 legacy blocker 是否已经被当前结果页 profile 补齐,并同时在壳层内把 result preview source 映射成展示标签。
|
||||
|
||||
这些逻辑不读取 React state,不请求网络,不写 URL,也不操作弹窗;它们属于 RPG Agent 结果预览展示的纯判定。壳层继续负责 session、profile、发布动作和结果页 props 编排。
|
||||
|
||||
## 决策
|
||||
|
||||
新增 `src/components/platform-entry/platformRpgAgentResultPreviewModel.ts` 作为 Platform RPG Agent Result Preview **Module**。其公开 **Interface** 为:
|
||||
|
||||
- `buildPlatformRpgAgentResultPublishGateView(profile, fallbackBlockers, fallbackPublishReady)`:无 profile 时沿用服务端 fallback;有 profile 时过滤已经被当前 profile 结构字段满足的发布 blocker,并按剩余 blocker 重算展示态 `publishReady`。
|
||||
- `resolvePlatformRpgAgentResultPreviewSourceLabel(source)`:把 `published_profile`、`session_preview` 和未知 future source 映射成结果页预览来源标签。
|
||||
|
||||
`PlatformEntryFlowShellImpl.tsx` 仍作为 **Adapter**:它只把 `agentResultPreview` 与 `generatedCustomWorldProfile` 交给 Module,并将返回的 blocker / label 传入结果页组件。
|
||||
|
||||
## Interface 约束
|
||||
|
||||
- 无 profile 时不得自行修正 blocker,必须保留 fallback blocker message 与 fallback `publishReady`。
|
||||
- 有 profile 时只过滤已知结构 blocker:`publish_missing_world_hook`、`publish_missing_player_premise`、`publish_missing_core_conflict`、`publish_missing_main_chapter`、`publish_missing_first_act`。
|
||||
- 世界钩子兼容读取 `worldHook`、`creatorIntent.worldHook`、`anchorContent.worldPromise`、`anchorContent.worldPromise.hook` 和 `settingText`。
|
||||
- 玩家前提兼容读取 `playerPremise`、`creatorIntent.playerPremise`、`anchorContent.playerEntryPoint.openingIdentity`、`openingProblem`、`entryMotivation`。
|
||||
- 主章节兼容读取 `chapters`、`sceneChapterBlueprints`、`sceneChapters`;首幕读取 `sceneChapterBlueprints` / `sceneChapters` 下的 `acts`。
|
||||
- 未知 blocker code 不得被前端过滤;未知 source 保留“服务端预览”兜底,不做穷尽删除。
|
||||
|
||||
## Depth / Leverage / Locality
|
||||
|
||||
- **Depth**:壳层以两个函数取得发布门禁展示和 source label;profile 兼容字段路径、legacy blocker code 与兜底规则藏入 Module Implementation。
|
||||
- **Leverage**:后续后端调整 RPG result preview blocker 或新增 source 时,先改 Module 与单测,再让壳层 Adapter 保持结果页 props 编排不变。
|
||||
- **Locality**:RPG Agent 结果预览展示规则集中到一个纯测试面,避免在大型平台壳中继续混杂 profile 结构探测。
|
||||
|
||||
## 验收
|
||||
|
||||
- `npm run test -- src/components/platform-entry/platformRpgAgentResultPreviewModel.test.ts`
|
||||
- `npx eslint src/components/platform-entry/platformRpgAgentResultPreviewModel.ts src/components/platform-entry/platformRpgAgentResultPreviewModel.test.ts src/components/platform-entry/PlatformEntryFlowShellImpl.tsx --quiet`
|
||||
- `npm run typecheck`
|
||||
- `npm run check:encoding`
|
||||
@@ -14,6 +14,8 @@
|
||||
|
||||
拼图 runtime 刷新恢复、跳一跳生成中草稿打开和敲木鱼生成中 / detail 草稿恢复所需的 session / work DTO 映射统一由 `platformMiniGameSessionMappingModel.ts` 构造。平台壳只负责读取后端、写入本地 state、写 URL 和切换 stage;不得在壳层重新手写 sessionId 优先级、pending draft 空素材默认值或拼图稳定 ID 映射。
|
||||
|
||||
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*` 类。敲木鱼的音效和功德词条面板不得放进独立内部滚动容器,移动端应跟随页面自然滚动展开。生成页统一展示阶段、当前步骤、总进度、错误和重试动作。
|
||||
|
||||
创作表单提交前的泥点余额前置校验只允许用独立弹窗提示失败原因,不得把用户退回创作入口或玩法模板列表,也不得清空当前表单状态。当前适用拼图、抓大鹅和汪汪声浪等会在前端提交前校验泥点的生成入口;余额不足、余额读取失败都应停留在当前工作台,由用户关闭提示后继续编辑或自行补足泥点。
|
||||
|
||||
@@ -556,6 +556,10 @@ import {
|
||||
buildPuzzleResultProfileId,
|
||||
buildPuzzleResultWorkId,
|
||||
} from './platformPuzzleIdentityModel';
|
||||
import {
|
||||
buildPlatformRpgAgentResultPublishGateView,
|
||||
resolvePlatformRpgAgentResultPreviewSourceLabel,
|
||||
} from './platformRpgAgentResultPreviewModel';
|
||||
import {
|
||||
resolveSelectionStageAfterMissingCreationState,
|
||||
resolveSelectionStageAfterProtectedDataLoss,
|
||||
@@ -567,10 +571,6 @@ import { usePlatformEntryBootstrap } from './usePlatformEntryBootstrap';
|
||||
import { usePlatformEntryLibraryDetail } from './usePlatformEntryLibraryDetail';
|
||||
import { usePlatformEntryNavigation } from './usePlatformEntryNavigation';
|
||||
|
||||
type AgentResultPublishGateView = {
|
||||
blockers: string[];
|
||||
publishReady: boolean;
|
||||
};
|
||||
type CreationWorkShelfKind = CreationWorkShelfItem['kind'];
|
||||
type CreationFlowReturnTarget = 'create' | 'draft-shelf';
|
||||
type Match3DBackgroundCompileTask = {
|
||||
@@ -653,18 +653,6 @@ async function resumePuzzleProfileSaveArchiveRaw(worldKey: string) {
|
||||
);
|
||||
}
|
||||
|
||||
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',
|
||||
]);
|
||||
const RECOMMEND_RUNTIME_BACKGROUND_AUTH_OPTIONS =
|
||||
BACKGROUND_AUTH_REQUEST_OPTIONS;
|
||||
const RECOMMEND_PUZZLE_BACKGROUND_AUTH_OPTIONS =
|
||||
@@ -1029,130 +1017,6 @@ async function resolvePublicWorkAuthorSummary(
|
||||
return null;
|
||||
}
|
||||
|
||||
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<string, unknown>)[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<string, unknown>)[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<string, unknown>)[key]
|
||||
: null;
|
||||
return Array.isArray(value) && value.length > 0;
|
||||
}
|
||||
|
||||
function hasSceneAct(profile: CustomWorldProfile | null) {
|
||||
const rawProfile = profile as unknown as Record<string, unknown> | 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<string, unknown>).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',
|
||||
'anchorContent.worldPromise.hook',
|
||||
'settingText',
|
||||
]),
|
||||
);
|
||||
}
|
||||
if (code === 'publish_missing_player_premise') {
|
||||
return Boolean(
|
||||
readProfileTextField(profile, [
|
||||
'playerPremise',
|
||||
'creatorIntent.playerPremise',
|
||||
'anchorContent.playerEntryPoint',
|
||||
'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,
|
||||
};
|
||||
}
|
||||
|
||||
function openPuzzleRuntimeStage(
|
||||
setSelectionStage: (stage: SelectionStage) => void,
|
||||
state: PuzzleRuntimeUrlState,
|
||||
@@ -3337,7 +3201,7 @@ export function PlatformEntryFlowShellImpl({
|
||||
);
|
||||
const agentResultPublishGateView = useMemo(
|
||||
() =>
|
||||
buildAgentResultPublishGateView(
|
||||
buildPlatformRpgAgentResultPublishGateView(
|
||||
sessionController.generatedCustomWorldProfile,
|
||||
agentResultPreviewBlockers,
|
||||
Boolean(agentResultPreview?.publishReady),
|
||||
@@ -3397,16 +3261,9 @@ export function PlatformEntryFlowShellImpl({
|
||||
[openPublishShareModal, platformBootstrap],
|
||||
);
|
||||
const agentResultPreviewSourceLabel = useMemo(() => {
|
||||
if (!agentResultPreview?.source) {
|
||||
return null;
|
||||
}
|
||||
if (agentResultPreview.source === 'published_profile') {
|
||||
return '已发布世界';
|
||||
}
|
||||
if (agentResultPreview.source === 'session_preview') {
|
||||
return '会话预览';
|
||||
}
|
||||
return '服务端预览';
|
||||
return resolvePlatformRpgAgentResultPreviewSourceLabel(
|
||||
agentResultPreview?.source,
|
||||
);
|
||||
}, [agentResultPreview]);
|
||||
const featuredGalleryEntries = useMemo(() => {
|
||||
const bigFishPublicEntries = isBigFishCreationVisible
|
||||
|
||||
@@ -0,0 +1,168 @@
|
||||
import { describe, expect, test } from 'vitest';
|
||||
|
||||
import { createRpgCreationPublishedProfileFixture } from '../../../packages/shared/src/contracts/rpgCreationFixtures';
|
||||
import type { CustomWorldProfile } from '../../types';
|
||||
import {
|
||||
buildPlatformRpgAgentResultPublishGateView,
|
||||
type PlatformRpgAgentResultBlockerView,
|
||||
resolvePlatformRpgAgentResultPreviewSourceLabel,
|
||||
} from './platformRpgAgentResultPreviewModel';
|
||||
|
||||
function buildProfile(
|
||||
overrides: Record<string, unknown> = {},
|
||||
): CustomWorldProfile {
|
||||
return {
|
||||
...createRpgCreationPublishedProfileFixture(),
|
||||
worldHook: '潮雾列岛旧灯塔重新点亮。',
|
||||
playerPremise: '玩家从回潮旧灯塔切入沉船旧案。',
|
||||
coreConflicts: ['守灯会与沉船旧案的冲突'],
|
||||
sceneChapterBlueprints: [
|
||||
{
|
||||
id: 'chapter-1',
|
||||
acts: [
|
||||
{
|
||||
id: 'act-1',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
...overrides,
|
||||
} as unknown as CustomWorldProfile;
|
||||
}
|
||||
|
||||
const missingWorldHookBlocker: PlatformRpgAgentResultBlockerView = {
|
||||
code: 'publish_missing_world_hook',
|
||||
message: '缺少世界钩子',
|
||||
};
|
||||
const missingPlayerPremiseBlocker: PlatformRpgAgentResultBlockerView = {
|
||||
code: 'publish_missing_player_premise',
|
||||
message: '缺少玩家前提',
|
||||
};
|
||||
const missingCoreConflictBlocker: PlatformRpgAgentResultBlockerView = {
|
||||
code: 'publish_missing_core_conflict',
|
||||
message: '缺少核心冲突',
|
||||
};
|
||||
const missingMainChapterBlocker: PlatformRpgAgentResultBlockerView = {
|
||||
code: 'publish_missing_main_chapter',
|
||||
message: '缺少主章节',
|
||||
};
|
||||
const missingFirstActBlocker: PlatformRpgAgentResultBlockerView = {
|
||||
code: 'publish_missing_first_act',
|
||||
message: '缺少首幕',
|
||||
};
|
||||
const structuralBlockers: PlatformRpgAgentResultBlockerView[] = [
|
||||
missingWorldHookBlocker,
|
||||
missingPlayerPremiseBlocker,
|
||||
missingCoreConflictBlocker,
|
||||
missingMainChapterBlocker,
|
||||
missingFirstActBlocker,
|
||||
];
|
||||
|
||||
describe('platformRpgAgentResultPreviewModel', () => {
|
||||
test('uses fallback blockers and publish readiness without a profile', () => {
|
||||
expect(
|
||||
buildPlatformRpgAgentResultPublishGateView(
|
||||
null,
|
||||
structuralBlockers.slice(0, 2),
|
||||
false,
|
||||
),
|
||||
).toEqual({
|
||||
blockers: ['缺少世界钩子', '缺少玩家前提'],
|
||||
publishReady: false,
|
||||
});
|
||||
});
|
||||
|
||||
test('filters structural blockers already satisfied by the profile', () => {
|
||||
expect(
|
||||
buildPlatformRpgAgentResultPublishGateView(
|
||||
buildProfile(),
|
||||
[
|
||||
...structuralBlockers,
|
||||
{
|
||||
code: 'future_blocker',
|
||||
message: '未知服务端阻断',
|
||||
},
|
||||
],
|
||||
false,
|
||||
),
|
||||
).toEqual({
|
||||
blockers: ['未知服务端阻断'],
|
||||
publishReady: false,
|
||||
});
|
||||
});
|
||||
|
||||
test('keeps unresolved structural blockers when profile fields are empty', () => {
|
||||
expect(
|
||||
buildPlatformRpgAgentResultPublishGateView(
|
||||
buildProfile({
|
||||
worldHook: '',
|
||||
playerPremise: '',
|
||||
settingText: '',
|
||||
creatorIntent: null,
|
||||
anchorContent: null,
|
||||
coreConflicts: [],
|
||||
chapters: [],
|
||||
sceneChapterBlueprints: [],
|
||||
sceneChapters: [],
|
||||
}),
|
||||
structuralBlockers,
|
||||
true,
|
||||
),
|
||||
).toEqual({
|
||||
blockers: structuralBlockers.map((entry) => entry.message),
|
||||
publishReady: false,
|
||||
});
|
||||
});
|
||||
|
||||
test('resolves structural blockers from nested profile compatibility fields', () => {
|
||||
expect(
|
||||
buildPlatformRpgAgentResultPublishGateView(
|
||||
buildProfile({
|
||||
worldHook: '',
|
||||
playerPremise: '',
|
||||
settingText: '',
|
||||
creatorIntent: {
|
||||
worldHook: '旧灯塔潮路重新开启。',
|
||||
},
|
||||
anchorContent: {
|
||||
playerEntryPoint: {
|
||||
openingProblem: '玩家被卷入沉船旧案。',
|
||||
},
|
||||
},
|
||||
coreConflicts: [''],
|
||||
chapters: [],
|
||||
sceneChapterBlueprints: null,
|
||||
sceneChapters: [
|
||||
{
|
||||
acts: [{}],
|
||||
},
|
||||
],
|
||||
}),
|
||||
[
|
||||
missingWorldHookBlocker,
|
||||
missingPlayerPremiseBlocker,
|
||||
missingFirstActBlocker,
|
||||
],
|
||||
false,
|
||||
),
|
||||
).toEqual({
|
||||
blockers: [],
|
||||
publishReady: true,
|
||||
});
|
||||
});
|
||||
|
||||
test('maps preview source to result label', () => {
|
||||
expect(resolvePlatformRpgAgentResultPreviewSourceLabel(null)).toBeNull();
|
||||
expect(
|
||||
resolvePlatformRpgAgentResultPreviewSourceLabel('published_profile'),
|
||||
).toBe('已发布世界');
|
||||
expect(
|
||||
resolvePlatformRpgAgentResultPreviewSourceLabel('session_preview'),
|
||||
).toBe('会话预览');
|
||||
expect(
|
||||
resolvePlatformRpgAgentResultPreviewSourceLabel('future_source'),
|
||||
).toBe(
|
||||
'服务端预览',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,159 @@
|
||||
import type { RpgCreationPreviewSource } from '../../../packages/shared/src/contracts/rpgCreationPreview';
|
||||
import type { CustomWorldProfile } from '../../types';
|
||||
|
||||
export type PlatformRpgAgentResultBlockerView = {
|
||||
code?: string | null;
|
||||
message: string;
|
||||
};
|
||||
|
||||
export type PlatformRpgAgentResultPublishGateView = {
|
||||
blockers: string[];
|
||||
publishReady: boolean;
|
||||
};
|
||||
|
||||
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<string, unknown>)[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<string, unknown>)[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<string, unknown>)[key]
|
||||
: null;
|
||||
return Array.isArray(value) && value.length > 0;
|
||||
}
|
||||
|
||||
function hasSceneAct(profile: CustomWorldProfile | null) {
|
||||
const rawProfile = profile as unknown as Record<string, unknown> | 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<string, unknown>).acts
|
||||
: null;
|
||||
return Array.isArray(acts) && acts.length > 0;
|
||||
})
|
||||
: false;
|
||||
}
|
||||
|
||||
function isAgentResultStructuralBlockerResolved(
|
||||
profile: CustomWorldProfile,
|
||||
code: string | null | 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',
|
||||
'anchorContent.worldPromise.hook',
|
||||
'settingText',
|
||||
]),
|
||||
);
|
||||
}
|
||||
if (code === 'publish_missing_player_premise') {
|
||||
return Boolean(
|
||||
readProfileTextField(profile, [
|
||||
'playerPremise',
|
||||
'creatorIntent.playerPremise',
|
||||
'anchorContent.playerEntryPoint',
|
||||
'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);
|
||||
}
|
||||
|
||||
export function buildPlatformRpgAgentResultPublishGateView(
|
||||
profile: CustomWorldProfile | null,
|
||||
fallbackBlockers: PlatformRpgAgentResultBlockerView[],
|
||||
fallbackPublishReady: boolean,
|
||||
): PlatformRpgAgentResultPublishGateView {
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
export function resolvePlatformRpgAgentResultPreviewSourceLabel(
|
||||
source: RpgCreationPreviewSource | string | null | undefined,
|
||||
) {
|
||||
if (!source) {
|
||||
return null;
|
||||
}
|
||||
if (source === 'published_profile') {
|
||||
return '已发布世界';
|
||||
}
|
||||
if (source === 'session_preview') {
|
||||
return '会话预览';
|
||||
}
|
||||
return '服务端预览';
|
||||
}
|
||||
Reference in New Issue
Block a user