1
Some checks failed
CI / verify (push) Has been cancelled

This commit is contained in:
2026-04-20 11:30:19 +08:00
parent 50759f3c1e
commit 8a7bd90458
85 changed files with 7290 additions and 1903 deletions

View File

@@ -31,6 +31,26 @@ test('npc chat turn schema normalizes player and dialogue aliases', () => {
chattedCount: 1,
recruited: false,
},
questOfferContext: {
state: {
currentScenePreset: {
id: 'scene-inn',
},
},
encounter: {
id: 'npc-liu',
npcName: '柳无声',
},
turnCount: 2,
},
chatDirective: {
sceneActId: 'scene-inn-act-1',
turnLimit: 5,
remainingTurns: 3,
limitReason: 'negative_affinity',
closingMode: 'free',
forceExitAfterTurn: false,
},
});
assert.equal(payload.character.name, '沈行');
@@ -40,4 +60,7 @@ test('npc chat turn schema normalizes player and dialogue aliases', () => {
text: '你刚才那句话是什么意思?',
},
]);
assert.equal(payload.questOfferContext?.turnCount, 2);
assert.equal(payload.chatDirective?.sceneActId, 'scene-inn-act-1');
assert.equal(payload.chatDirective?.remainingTurns, 3);
});

View File

@@ -31,6 +31,21 @@ const baseNpcChatSchema = z.object({
context: jsonObjectSchema,
});
const npcChatDirectiveSchema = z.object({
sceneActId: z.string().trim().min(1).nullable().optional(),
turnLimit: z.number().int().nonnegative().nullable().optional(),
remainingTurns: z.number().int().nonnegative().nullable().optional(),
limitReason: z.enum(['negative_affinity']).nullable().optional(),
closingMode: z.enum(['free', 'foreshadow_close']).nullable().optional(),
forceExitAfterTurn: z.boolean().optional(),
});
const npcChatQuestOfferContextSchema = z.object({
state: jsonObjectSchema,
encounter: jsonObjectSchema,
turnCount: z.number().int().nonnegative(),
});
export const characterChatReplyRequestSchema = baseCharacterChatSchema.extend({
conversationSummary: z.string().optional().default(''),
playerMessage: z.string().trim().min(1),
@@ -59,6 +74,8 @@ export const npcChatTurnRequestSchema = baseNpcChatSchema
dialogue: z.array(jsonObjectSchema).optional(),
playerMessage: z.string().trim().min(1),
npcState: jsonObjectSchema,
questOfferContext: npcChatQuestOfferContextSchema.nullable().optional(),
chatDirective: npcChatDirectiveSchema.nullable().optional(),
})
.superRefine((value, ctx) => {
if (!value.character && !value.player) {

View File

@@ -45,6 +45,7 @@ function resolveCardTitle(
draftProfile.landmarks.find((entry) => entry.id === cardId)?.name ||
draftProfile.threads.find((entry) => entry.id === cardId)?.title ||
draftProfile.chapters.find((entry) => entry.id === cardId)?.title ||
draftProfile.sceneChapters.find((entry) => entry.id === cardId)?.title ||
(draftProfile.camp?.id === cardId ? draftProfile.camp.name : '') ||
'当前卡片'
);

View File

@@ -0,0 +1,211 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { updateDraftCardSections } from './customWorldAgentDraftEditService.js';
import {
CustomWorldAgentDraftCompiler,
normalizeFoundationDraftProfile,
} from './customWorldAgentDraftCompiler.js';
function createSceneChapterDraftProfile() {
return {
name: '雾港列岛',
summary: '潮雾、旧航道和失序港口缠在一起的海岛世界。',
tone: '冷峻、克制、带着海盐和旧铁锈味道。',
playerGoal: '先在失序的港口里站稳,再找出谁在提前布网。',
coreConflicts: ['旧航道解释权正在被重新争夺'],
iconicElements: ['潮雾钟声', '盐火灯塔'],
playableNpcs: [
{
id: 'npc-lin',
name: '林潮',
title: '守潮人',
role: '码头引路人',
publicIdentity: '码头上最懂回潮时间的人。',
publicMask: '码头上最懂回潮时间的人。',
currentPressure: '必须决定今晚要不要帮玩家进港。',
hiddenHook: '他知道第一批被转移的货不是普通货。',
relationToPlayer: '对玩家保持试探,但还愿意给一次机会。',
threadIds: ['thread-smuggling'],
summary: '他像向导,也像仍在权衡站位的守门人。',
},
],
storyNpcs: [
{
id: 'npc-yan',
name: '晏九',
title: '黑市中间人',
role: '封锁码头的人',
publicIdentity: '他负责把不该上岸的东西挡在潮线外。',
publicMask: '他负责把不该上岸的东西挡在潮线外。',
currentPressure: '必须让今晚的码头保持沉默。',
hiddenHook: '他已经替更大的势力提前清过一次场。',
relationToPlayer: '对玩家带着明显敌意,但又不想立刻翻脸。',
threadIds: ['thread-smuggling'],
summary: '他像威胁,也像握着下一跳线索的人。',
},
],
landmarks: [
{
id: 'landmark-docks',
name: '潮汐码头',
description: '涨潮时会吞没半条旧栈桥的码头。',
purpose: '承接玩家和封锁者的第一次正式碰撞。',
mood: '潮声压低,空气里有明显不欢迎的意味。',
importance: '这里是玩家第一章必须破开的门槛。',
secret: '今晚靠岸的货和旧航道失踪案有关。',
dangerLevel: '中高',
imageSrc: '/images/scene/docks-base.webp',
characterIds: ['npc-lin', 'npc-yan'],
threadIds: ['thread-smuggling'],
summary: '这里不是背景,而是第一章真正开始收紧的地方。',
},
],
factions: [],
threads: [
{
id: 'thread-smuggling',
title: '失踪货船去哪了',
type: 'main',
conflictType: '明线',
conflict: '有人在重写旧航道的夜间进出规则。',
stakes: '如果玩家跟不上这条线,整个港口都会先把他排除在外。',
characterIds: ['npc-lin', 'npc-yan'],
landmarkIds: ['landmark-docks'],
summary: '旧航道的解释权正在被重新洗牌。',
},
],
chapters: [
{
id: 'chapter-docks',
title: '码头开场',
openingEvent: '一艘不该靠岸的船提前抵达潮线外。',
playerGoal: '先确认谁在码头上拥有发言权。',
characterIds: ['npc-lin', 'npc-yan'],
landmarkIds: ['landmark-docks'],
understandingShift: '玩家会意识到这不是简单的港口封锁。',
summary: '码头上的第一次碰撞会直接决定后续节奏。',
},
],
sceneChapters: [
{
id: 'scene-chapter-docks',
sceneId: 'landmark-docks',
sceneName: '潮汐码头',
title: '潮汐码头章节',
summary: '玩家会在这里完成试探、逼问和第一次局部收束。',
linkedThreadIds: ['thread-smuggling'],
linkedLandmarkIds: ['landmark-docks'],
acts: [
{
id: 'act-docks-1',
title: '雾里靠岸',
summary: '玩家刚抵达时,林潮先决定要不要放行。',
stageCoverage: ['opening'],
backgroundImageSrc: '/images/scene/docks-act-1.webp',
encounterNpcIds: ['npc-lin', 'npc-yan'],
primaryNpcId: 'npc-lin',
linkedThreadIds: ['thread-smuggling'],
actGoal: '先让玩家拿到码头里的第一句真话。',
transitionHook: '确认站位后,真正的封锁者会压上来。',
advanceRule: 'after_primary_contact',
},
{
id: 'act-docks-2',
title: '封锁加压',
summary: '晏九开始把玩家往更危险的方向逼。',
stageCoverage: ['turning_point', 'climax', 'aftermath'],
backgroundImageSrc: '/images/scene/docks-act-2.webp',
encounterNpcIds: ['npc-yan', 'npc-lin'],
primaryNpcId: 'npc-yan',
linkedThreadIds: ['thread-smuggling'],
actGoal: '把矛盾推向必须接住的下一跳。',
transitionHook: '第 2 幕收束时必须把下一步追踪方向抛出来。',
advanceRule: 'after_chapter_resolution',
},
],
},
],
};
}
test('draft compiler compiles scene chapter cards with act-level editable sections', () => {
const draftProfile = createSceneChapterDraftProfile();
const compiler = new CustomWorldAgentDraftCompiler();
const draftCards = compiler.compileDraftCards(draftProfile);
const sceneChapterCard = draftCards.find((entry) => entry.kind === 'scene_chapter');
const detail = compiler.getDraftCardDetail(draftProfile, 'scene-chapter-docks');
assert.ok(sceneChapterCard);
assert.equal(sceneChapterCard?.title, '潮汐码头章节');
assert.match(sceneChapterCard?.subtitle ?? '', /2 /u);
assert.ok(detail);
assert.equal(detail?.kind, 'scene_chapter');
assert.ok(detail?.editableSectionIds.includes('title'));
assert.ok(detail?.editableSectionIds.includes('act:act-docks-1:title'));
assert.ok(
detail?.sections.some(
(section) =>
section.id === 'act:act-docks-1:backgroundImageSrc' &&
section.value === '/images/scene/docks-act-1.webp',
),
);
assert.ok(
detail?.sections.some(
(section) =>
section.id === 'act:act-docks-2:primaryNpcId' &&
section.value.includes('晏九'),
),
);
});
test('updateDraftCardSections rewrites scene chapter act NPC order and primary npc', () => {
const updatedDraftProfile = updateDraftCardSections({
draftProfile: JSON.parse(JSON.stringify(createSceneChapterDraftProfile())),
cardId: 'scene-chapter-docks',
sections: [
{
sectionId: 'title',
value: '潮汐码头对峙章',
},
{
sectionId: 'act:act-docks-1:title',
value: '封港前夜',
},
{
sectionId: 'act:act-docks-1:backgroundImageSrc',
value: '/images/scene/docks-act-1-night.webp',
},
{
sectionId: 'act:act-docks-1:encounterNpcIds',
value: '晏九\n林潮',
},
{
sectionId: 'act:act-docks-1:transitionHook',
value: '第 1 幕最后要把玩家逼到必须继续追的方向上。',
},
],
});
const normalized = normalizeFoundationDraftProfile(updatedDraftProfile);
const updatedSceneChapter = normalized?.sceneChapters.find(
(entry) => entry.id === 'scene-chapter-docks',
);
const updatedAct = updatedSceneChapter?.acts.find((entry) => entry.id === 'act-docks-1');
assert.ok(updatedSceneChapter);
assert.ok(updatedAct);
assert.equal(updatedSceneChapter?.title, '潮汐码头对峙章');
assert.equal(updatedAct?.title, '封港前夜');
assert.equal(
updatedAct?.backgroundImageSrc,
'/images/scene/docks-act-1-night.webp',
);
assert.deepEqual(updatedAct?.encounterNpcIds, ['npc-yan', 'npc-lin']);
assert.equal(updatedAct?.primaryNpcId, 'npc-yan');
assert.equal(
updatedAct?.transitionHook,
'第 1 幕最后要把玩家逼到必须继续追的方向上。',
);
});

View File

@@ -10,6 +10,8 @@ import type {
CustomWorldFoundationDraftFaction,
CustomWorldFoundationDraftLandmark,
CustomWorldFoundationDraftProfile,
CustomWorldFoundationDraftSceneAct,
CustomWorldFoundationDraftSceneChapter,
CustomWorldFoundationDraftThread,
} from '../../../packages/shared/src/contracts/customWorldAgent.js';
import {
@@ -74,6 +76,39 @@ const EDITABLE_CAMP_SECTION_IDS = [
'dangerLevel',
] as const;
const EDITABLE_SCENE_CHAPTER_BASE_SECTION_IDS = [
'title',
'summary',
] as const;
const SCENE_ACT_STAGE_ORDER = [
'opening',
'expansion',
'turning_point',
'climax',
'aftermath',
] as const;
const SCENE_ACT_STAGE_LABELS: Record<
CustomWorldFoundationDraftSceneAct['stageCoverage'][number],
string
> = {
opening: '开场',
expansion: '铺展',
turning_point: '转折',
climax: '高潮',
aftermath: '余波',
};
const SCENE_ACT_ADVANCE_RULE_LABELS: Record<
CustomWorldFoundationDraftSceneAct['advanceRule'],
string
> = {
after_primary_contact: '主角色首次有效接触后推进',
after_active_step_complete: '当前主动步骤完成后推进',
after_chapter_resolution: '章节进入收束后推进',
};
function toText(value: unknown) {
return typeof value === 'string' ? value.trim() : '';
}
@@ -101,6 +136,28 @@ function toStringArray(value: unknown, maxCount = 8) {
);
}
function normalizeCharacterSkills(value: unknown, fallbackName: string) {
const skills = toRecordArray(value)
.map((item, index) => ({
id: toText(item.id) || `skill-${index + 1}`,
name: toText(item.name) || `技能${index + 1}`,
actionPreviewConfig: toRecord(item.actionPreviewConfig),
}))
.filter((item) => Boolean(item.id));
if (skills.length > 0) {
return skills;
}
return [
{
id: 'skill-1',
name: `${clampText(fallbackName, 10) || '角色'}招牌动作`,
actionPreviewConfig: null,
},
];
}
function slugify(value: string) {
const normalized = value
.trim()
@@ -149,9 +206,40 @@ function resolveEditableSectionIds(kind: CustomWorldDraftCardKind) {
if (kind === 'thread') return [...EDITABLE_THREAD_SECTION_IDS];
if (kind === 'chapter') return [...EDITABLE_CHAPTER_SECTION_IDS];
if (kind === 'camp') return [...EDITABLE_CAMP_SECTION_IDS];
if (kind === 'scene_chapter') return [...EDITABLE_SCENE_CHAPTER_BASE_SECTION_IDS];
return [];
}
function resolveSceneChapterEditableSectionIds(
sceneChapter: CustomWorldFoundationDraftSceneChapter,
) {
return [
...EDITABLE_SCENE_CHAPTER_BASE_SECTION_IDS,
...sceneChapter.acts.flatMap((act) => [
`act:${act.id}:title`,
`act:${act.id}:summary`,
`act:${act.id}:backgroundImageSrc`,
`act:${act.id}:encounterNpcIds`,
`act:${act.id}:actGoal`,
`act:${act.id}:transitionHook`,
]),
];
}
function resolveSceneActStageCoverageLabel(
stageCoverage: CustomWorldFoundationDraftSceneAct['stageCoverage'],
) {
return stageCoverage
.map((stage) => SCENE_ACT_STAGE_LABELS[stage] || stage)
.join('、');
}
function resolveSceneActAdvanceRuleLabel(
advanceRule: CustomWorldFoundationDraftSceneAct['advanceRule'],
) {
return SCENE_ACT_ADVANCE_RULE_LABELS[advanceRule] || advanceRule;
}
function normalizeFaction(
value: unknown,
index: number,
@@ -243,6 +331,7 @@ function normalizeCharacter(
].join(''),
120,
),
skills: normalizeCharacterSkills(record.skills, role || title || name || '角色'),
imageSrc: toText(record.imageSrc) || null,
generatedVisualAssetId: toText(record.generatedVisualAssetId) || null,
generatedAnimationSetId: toText(record.generatedAnimationSetId) || null,
@@ -287,6 +376,7 @@ function normalizeLandmark(
importance: secret || '玩家第一次抵达就会意识到它不只是背景',
secret: secret || '玩家第一次抵达就会意识到它不只是背景',
dangerLevel: dangerLevel || '中',
imageSrc: toText(record.imageSrc) || null,
characterIds: toStringArray(record.characterIds, 8),
threadIds: toStringArray(record.threadIds, 8),
summary:
@@ -410,6 +500,7 @@ function normalizeCamp(value: unknown): CustomWorldFoundationDraftCamp | null {
description: description || '玩家暂时还能整顿情报和喘口气的地方',
mood: dangerLevel || '克制、紧绷,但还能暂时收拢局势',
dangerLevel: dangerLevel || '克制、紧绷,但还能暂时收拢局势',
imageSrc: toText(record.imageSrc) || null,
summary:
summary ||
clampText(
@@ -422,6 +513,342 @@ function normalizeCamp(value: unknown): CustomWorldFoundationDraftCamp | null {
};
}
function normalizeStageCoverage(value: unknown) {
const stageCoverage = Array.isArray(value)
? value
.filter((entry): entry is string => typeof entry === 'string')
.map((entry) => entry.trim())
.filter(
(
entry,
): entry is CustomWorldFoundationDraftSceneAct['stageCoverage'][number] =>
SCENE_ACT_STAGE_ORDER.includes(
entry as (typeof SCENE_ACT_STAGE_ORDER)[number],
),
)
: [];
return [...new Set(stageCoverage)];
}
function buildFallbackSceneActStageCoverage(index: number, actCount: number) {
if (actCount <= 2) {
return index === 0
? (['opening', 'expansion'] as CustomWorldFoundationDraftSceneAct['stageCoverage'])
: (['turning_point', 'climax', 'aftermath'] as CustomWorldFoundationDraftSceneAct['stageCoverage']);
}
if (actCount === 3) {
if (index === 0) {
return ['opening'] as CustomWorldFoundationDraftSceneAct['stageCoverage'];
}
if (index === 1) {
return ['expansion', 'turning_point'] as CustomWorldFoundationDraftSceneAct['stageCoverage'];
}
return ['climax', 'aftermath'] as CustomWorldFoundationDraftSceneAct['stageCoverage'];
}
if (actCount === 4) {
if (index === 0) return ['opening'] as CustomWorldFoundationDraftSceneAct['stageCoverage'];
if (index === 1) return ['expansion'] as CustomWorldFoundationDraftSceneAct['stageCoverage'];
if (index === 2) return ['turning_point'] as CustomWorldFoundationDraftSceneAct['stageCoverage'];
return ['climax', 'aftermath'] as CustomWorldFoundationDraftSceneAct['stageCoverage'];
}
return [SCENE_ACT_STAGE_ORDER[Math.min(index, SCENE_ACT_STAGE_ORDER.length - 1)]];
}
function normalizeSceneAct(
value: unknown,
index: number,
fallback: {
sceneId: string;
sceneName: string;
backgroundImageSrc?: string | null;
encounterNpcIds: string[];
linkedThreadIds: string[];
actCount: number;
},
): CustomWorldFoundationDraftSceneAct | null {
const record = toRecord(value);
if (!record) {
return null;
}
const title = toText(record.title);
const summary = toText(record.summary);
const encounterNpcIds = toStringArray(
record.encounterNpcIds,
Math.max(1, fallback.encounterNpcIds.length || 8),
);
const stageCoverage = normalizeStageCoverage(record.stageCoverage);
if (!title && !summary && encounterNpcIds.length === 0) {
return null;
}
const resolvedEncounterNpcIds =
encounterNpcIds.length > 0 ? encounterNpcIds : fallback.encounterNpcIds;
const primaryNpcId = toText(record.primaryNpcId) || resolvedEncounterNpcIds[0] || '';
return {
id:
toText(record.id) ||
createId(`scene-act-${fallback.sceneId}`, title || fallback.sceneName, index),
title: title || `${index + 1}`,
summary:
summary ||
clampText(
[
title || `${index + 1}`,
toText(record.actGoal) || '这一幕仍需继续精修',
].join(''),
120,
),
stageCoverage:
stageCoverage.length > 0
? stageCoverage
: buildFallbackSceneActStageCoverage(index, fallback.actCount),
backgroundImageSrc:
toText(record.backgroundImageSrc) || fallback.backgroundImageSrc || null,
backgroundAssetId: toText(record.backgroundAssetId) || null,
encounterNpcIds: resolvedEncounterNpcIds,
primaryNpcId,
linkedThreadIds:
toStringArray(record.linkedThreadIds, 8).length > 0
? toStringArray(record.linkedThreadIds, 8)
: fallback.linkedThreadIds,
actGoal:
toText(record.actGoal) ||
(index === 0
? `先在${fallback.sceneName}接住开场 lead`
: index === fallback.actCount - 1
? `${fallback.sceneName}这一章收住`
: `继续逼近${fallback.sceneName}的核心压力`),
transitionHook:
toText(record.transitionHook) ||
(index === fallback.actCount - 1
? '这一幕结束后会把问题推向下一跳。'
: '完成当前推进后,局势会进入下一幕。'),
advanceRule:
toText(record.advanceRule) === 'after_primary_contact' ||
toText(record.advanceRule) === 'after_active_step_complete' ||
toText(record.advanceRule) === 'after_chapter_resolution'
? (toText(record.advanceRule) as CustomWorldFoundationDraftSceneAct['advanceRule'])
: index === 0
? 'after_primary_contact'
: index === fallback.actCount - 1
? 'after_chapter_resolution'
: 'after_active_step_complete',
};
}
function buildFallbackSceneActs(params: {
sceneId: string;
sceneName: string;
sceneSummary: string;
backgroundImageSrc?: string | null;
encounterNpcIds: string[];
linkedThreadIds: string[];
}) {
const actCount = 3;
return [
{
id: `${params.sceneId}-act-1`,
title: `初见 ${params.sceneName}`,
summary: clampText(
`玩家第一次真正接住${params.sceneName}这一章的入口。${params.sceneSummary}`,
120,
),
stageCoverage: buildFallbackSceneActStageCoverage(0, actCount),
backgroundImageSrc: params.backgroundImageSrc || null,
backgroundAssetId: null,
encounterNpcIds: params.encounterNpcIds,
primaryNpcId: params.encounterNpcIds[0] || '',
linkedThreadIds: params.linkedThreadIds,
actGoal: `先在${params.sceneName}接住开场 lead`,
transitionHook: '和主角色完成首次有效接触后,局势会继续加压。',
advanceRule: 'after_primary_contact',
},
{
id: `${params.sceneId}-act-2`,
title: `${params.sceneName}承压`,
summary: clampText(
`玩家开始确认${params.sceneName}不只是背景,而是这一章真正承压的地方。`,
120,
),
stageCoverage: buildFallbackSceneActStageCoverage(1, actCount),
backgroundImageSrc: params.backgroundImageSrc || null,
backgroundAssetId: null,
encounterNpcIds: params.encounterNpcIds,
primaryNpcId: params.encounterNpcIds[0] || '',
linkedThreadIds: params.linkedThreadIds,
actGoal: `继续逼近${params.sceneName}的核心压力`,
transitionHook: '完成当前主动 step 后,这一章会转向收束。',
advanceRule: 'after_active_step_complete',
},
{
id: `${params.sceneId}-act-3`,
title: `${params.sceneName}收束`,
summary: clampText(
`这一幕承担${params.sceneName}的局部收束和下一跳 handoff。`,
120,
),
stageCoverage: buildFallbackSceneActStageCoverage(2, actCount),
backgroundImageSrc: params.backgroundImageSrc || null,
backgroundAssetId: null,
encounterNpcIds: params.encounterNpcIds,
primaryNpcId: params.encounterNpcIds[0] || '',
linkedThreadIds: params.linkedThreadIds,
actGoal: `${params.sceneName}这一章收住`,
transitionHook: '这一幕结束后需要把后续方向明确抛给玩家。',
advanceRule: 'after_chapter_resolution',
},
] satisfies CustomWorldFoundationDraftSceneAct[];
}
function normalizeSceneChapter(
value: unknown,
index: number,
fallback: {
sceneId: string;
sceneName: string;
sceneSummary: string;
linkedThreadIds: string[];
linkedLandmarkIds: string[];
backgroundImageSrc?: string | null;
encounterNpcIds: string[];
},
): CustomWorldFoundationDraftSceneChapter | null {
const record = toRecord(value);
if (!record) {
return null;
}
const sceneId = toText(record.sceneId) || fallback.sceneId;
const sceneName = toText(record.sceneName) || fallback.sceneName;
const title = toText(record.title);
const summary = toText(record.summary);
const actsInput = Array.isArray(record.acts) ? record.acts : [];
const actCount = Math.min(5, Math.max(2, actsInput.length || 3));
const linkedThreadIds =
toStringArray(record.linkedThreadIds, 8).length > 0
? toStringArray(record.linkedThreadIds, 8)
: fallback.linkedThreadIds;
const linkedLandmarkIds =
toStringArray(record.linkedLandmarkIds, 8).length > 0
? toStringArray(record.linkedLandmarkIds, 8)
: fallback.linkedLandmarkIds;
const acts = actsInput
.map((entry, actIndex) =>
normalizeSceneAct(entry, actIndex, {
sceneId,
sceneName,
backgroundImageSrc: fallback.backgroundImageSrc,
encounterNpcIds: fallback.encounterNpcIds,
linkedThreadIds,
actCount,
}),
)
.filter((entry): entry is CustomWorldFoundationDraftSceneAct => Boolean(entry))
.slice(0, 5);
return {
id: toText(record.id) || createId('scene-chapter', sceneName || title, index),
sceneId,
sceneName,
title: title || `${sceneName}章节`,
summary:
summary ||
clampText(
[
sceneName,
fallback.sceneSummary || '这一章的场景节拍仍可继续收紧',
].join(''),
140,
),
linkedThreadIds,
linkedLandmarkIds,
acts: acts.length >= 2 ? acts : buildFallbackSceneActs({
sceneId,
sceneName,
sceneSummary: fallback.sceneSummary,
backgroundImageSrc: fallback.backgroundImageSrc,
encounterNpcIds: fallback.encounterNpcIds,
linkedThreadIds,
}),
};
}
function buildFallbackSceneChapters(params: {
landmarks: CustomWorldFoundationDraftLandmark[];
characters: CustomWorldFoundationDraftCharacter[];
threads: CustomWorldFoundationDraftThread[];
chapters: CustomWorldFoundationDraftChapter[];
}) {
const fallbackCharacterIds = params.characters.slice(0, 3).map((entry) => entry.id);
return params.landmarks.map((landmark, index) => {
const matchingChapter =
params.chapters.find((chapter) => chapter.landmarkIds.includes(landmark.id)) ?? null;
const encounterNpcIds =
landmark.characterIds.length > 0 ? landmark.characterIds : fallbackCharacterIds;
const linkedThreadIds =
landmark.threadIds.length > 0
? landmark.threadIds
: params.threads
.filter((thread) => thread.landmarkIds.includes(landmark.id))
.map((thread) => thread.id)
.slice(0, 4);
return {
id: `scene-chapter-${landmark.id}`,
sceneId: landmark.id,
sceneName: landmark.name,
title: matchingChapter?.title || `${landmark.name}章节`,
summary:
matchingChapter?.summary ||
clampText(
[landmark.summary, matchingChapter?.openingEvent || '这一章会从这里真正展开']
.filter(Boolean)
.join(''),
140,
),
linkedThreadIds,
linkedLandmarkIds: [landmark.id],
acts: buildFallbackSceneActs({
sceneId: landmark.id,
sceneName: landmark.name,
sceneSummary: landmark.summary,
backgroundImageSrc: landmark.imageSrc || null,
encounterNpcIds,
linkedThreadIds,
}),
} satisfies CustomWorldFoundationDraftSceneChapter;
});
}
function resolveSceneChapterFallbackFromRecord(item: unknown, index: number) {
const record = toRecord(item);
const linkedLandmarkIds = toStringArray(record?.linkedLandmarkIds, 8);
return {
sceneId: toText(record?.sceneId) || linkedLandmarkIds[0] || `scene-${index + 1}`,
sceneName:
toText(record?.sceneName) ||
toText(record?.title) ||
`场景章节 ${index + 1}`,
sceneSummary:
toText(record?.summary) ||
'这一章仍可继续精修场景幕结构。',
linkedThreadIds: toStringArray(record?.linkedThreadIds, 8),
linkedLandmarkIds,
backgroundImageSrc: toText(record?.backgroundImageSrc) || null,
encounterNpcIds: toStringArray(record?.encounterNpcIds, 8),
};
}
export function normalizeFoundationDraftProfile(
value: unknown,
): CustomWorldFoundationDraftProfile | null {
@@ -474,6 +901,28 @@ export function normalizeFoundationDraftProfile(
Boolean(item),
),
);
const mergedCharacters = dedupeById([...playableNpcs, ...storyNpcs]);
const explicitSceneChapters = toRecordArray(record.sceneChapters)
.map((item, index) =>
normalizeSceneChapter(
item,
index,
resolveSceneChapterFallbackFromRecord(item, index),
),
)
.filter((item): item is CustomWorldFoundationDraftSceneChapter =>
Boolean(item),
);
const sceneChapters = dedupeById(
explicitSceneChapters.length > 0
? explicitSceneChapters
: buildFallbackSceneChapters({
landmarks,
characters: mergedCharacters,
threads,
chapters,
})
);
const camp = normalizeCamp(record.camp);
const hasStructuredFoundationContent =
playableNpcs.length > 0 ||
@@ -482,13 +931,12 @@ export function normalizeFoundationDraftProfile(
factions.length > 0 ||
threads.length > 0 ||
chapters.length > 0 ||
sceneChapters.length > 0 ||
Boolean(camp);
if (!hasStructuredFoundationContent) {
return null;
}
const mergedCharacters = dedupeById([...playableNpcs, ...storyNpcs]);
const coreConflicts = toStringArray(record.coreConflicts, 6);
return {
@@ -539,6 +987,7 @@ export function normalizeFoundationDraftProfile(
factions,
threads,
chapters,
sceneChapters,
worldHook: toText(record.worldHook) || name || summary,
playerPremise: toText(record.playerPremise),
openingSituation: toText(record.openingSituation),
@@ -636,6 +1085,84 @@ function buildChapterWarnings(chapter: CustomWorldFoundationDraftChapter) {
return warnings;
}
function buildSceneChapterWarnings(params: {
sceneChapter: CustomWorldFoundationDraftSceneChapter;
characterById: Map<string, CustomWorldFoundationDraftCharacter>;
threadById: Map<string, CustomWorldFoundationDraftThread>;
landmarkById: Map<string, CustomWorldFoundationDraftLandmark>;
}) {
const { sceneChapter, characterById, threadById, landmarkById } = params;
const warnings: string[] = [];
if (sceneChapter.acts.length < 2) {
warnings.push('这个场景章节至少需要 2 幕。');
}
if (sceneChapter.acts.length > 5) {
warnings.push('这个场景章节当前超过 5 幕,建议先收束到 5 幕以内。');
}
const linkedLandmarks = sceneChapter.linkedLandmarkIds
.map((id) => landmarkById.get(id))
.filter((entry): entry is CustomWorldFoundationDraftLandmark => Boolean(entry));
sceneChapter.acts.forEach((act, index) => {
const actLabel = `${index + 1}`;
const primaryNpcId = act.encounterNpcIds[0] || act.primaryNpcId;
const actThreadIds =
act.linkedThreadIds.length > 0
? act.linkedThreadIds
: sceneChapter.linkedThreadIds;
if (!act.backgroundImageSrc && !act.backgroundAssetId) {
warnings.push(`${actLabel}还没有绑定背景图。`);
}
if (act.encounterNpcIds.length === 0) {
warnings.push(`${actLabel}还没有配置相遇 NPC。`);
}
if (!primaryNpcId) {
warnings.push(`${actLabel}缺少主角色。`);
}
if (act.primaryNpcId && act.primaryNpcId !== (act.encounterNpcIds[0] ?? '')) {
warnings.push(`${actLabel}的主角色必须放在相遇 NPC 的第一位。`);
}
if (actThreadIds.length === 0) {
warnings.push(`${actLabel}还没有挂到明确线程。`);
}
const unresolvedNpcIds = act.encounterNpcIds.filter((id) => !characterById.has(id));
if (unresolvedNpcIds.length > 0) {
warnings.push(
`${actLabel}存在未进入当前世界角色池的 NPC${unresolvedNpcIds
.slice(0, 3)
.join('、')}`,
);
}
const unresolvedThreadIds = actThreadIds.filter((id) => !threadById.has(id));
if (unresolvedThreadIds.length > 0) {
warnings.push(
`${actLabel}存在未绑定的线程引用:${unresolvedThreadIds
.slice(0, 3)
.join('、')}`,
);
}
if (primaryNpcId && characterById.has(primaryNpcId)) {
const linkedToLandmark = linkedLandmarks.some((landmark) =>
landmark.characterIds.includes(primaryNpcId),
);
const linkedToThread = actThreadIds.some((threadId) =>
threadById.get(threadId)?.characterIds.includes(primaryNpcId),
);
if (!linkedToLandmark && !linkedToThread) {
warnings.push(`${actLabel}的主角色和当前场景/线程的关联还不够明确。`);
}
}
});
return warnings;
}
function buildCampWarnings() {
return [] as string[];
}
@@ -650,6 +1177,7 @@ function buildCharacterAssetHeadline(character: CustomWorldFoundationDraftCharac
generatedVisualAssetId: character.generatedVisualAssetId,
generatedAnimationSetId: character.generatedAnimationSetId,
animationMap: character.animationMap,
skills: character.skills ?? [],
},
roleKind: 'story',
});
@@ -773,6 +1301,7 @@ export class CustomWorldAgentDraftCompiler {
...profile.landmarks.map((entry) => entry.id),
...profile.threads.map((entry) => entry.id),
...profile.chapters.map((entry) => entry.id),
...profile.sceneChapters.map((entry) => entry.id),
].slice(0, 12),
sections: [
buildSection('title', '标题', profile.name),
@@ -1025,6 +1554,129 @@ export class CustomWorldAgentDraftCompiler {
});
});
profile.sceneChapters.forEach((sceneChapter) => {
const uniqueNpcIds = [...new Set(sceneChapter.acts.flatMap((act) => act.encounterNpcIds))];
const readyBackgroundCount = sceneChapter.acts.filter(
(act) => Boolean(act.backgroundImageSrc || act.backgroundAssetId),
).length;
const warnings = buildSceneChapterWarnings({
sceneChapter,
characterById,
threadById,
landmarkById,
});
pushCard({
id: sceneChapter.id,
kind: 'scene_chapter',
title: sceneChapter.title,
subtitle: clampText(
`${sceneChapter.sceneName} · ${sceneChapter.acts.length} 幕 · 背景 ${readyBackgroundCount}/${sceneChapter.acts.length}`,
40,
),
summary: sceneChapter.summary,
linkedIds: [
...sceneChapter.linkedLandmarkIds,
...sceneChapter.linkedThreadIds,
...uniqueNpcIds,
].slice(0, 12),
sections: [
buildSection('sceneName', '所属场景', sceneChapter.sceneName),
buildSection('title', '场景章节标题', sceneChapter.title),
buildSection('summary', '场景章节摘要', sceneChapter.summary),
buildSection(
'actOverview',
'幕结构总览',
sceneChapter.acts
.map((act, index) => {
const primaryNpcName =
resolveCharacterNames([act.encounterNpcIds[0] || act.primaryNpcId]) ||
'待补主角色';
const supportNpcNames =
resolveCharacterNames(act.encounterNpcIds.slice(1)) || '当前没有辅助 NPC';
return [
`${index + 1} 幕|${act.title}`,
`主角色:${primaryNpcName}`,
`辅助 NPC${supportNpcNames}`,
`目标:${act.actGoal}`,
`过渡:${act.transitionHook}`,
].join('\n');
})
.join('\n\n'),
),
buildSection(
'linkedLandmarkIds',
'关联地点',
resolveLandmarkNames(sceneChapter.linkedLandmarkIds),
),
buildSection(
'linkedThreadIds',
'关联线程',
resolveThreadTitles(sceneChapter.linkedThreadIds),
),
...sceneChapter.acts.flatMap((act, index) => {
const actLabel = `${index + 1}`;
const encounterNpcValue =
resolveCharacterNames(act.encounterNpcIds) ||
act.encounterNpcIds.join('、');
const primaryNpcValue =
resolveCharacterNames([act.encounterNpcIds[0] || act.primaryNpcId]) ||
act.encounterNpcIds[0] ||
act.primaryNpcId;
const actThreadTitles =
resolveThreadTitles(
act.linkedThreadIds.length > 0
? act.linkedThreadIds
: sceneChapter.linkedThreadIds,
) || '待补线程挂钩';
return [
buildSection(`act:${act.id}:title`, `${actLabel}标题`, act.title),
buildSection(`act:${act.id}:summary`, `${actLabel}摘要`, act.summary),
buildSection(
`act:${act.id}:backgroundImageSrc`,
`${actLabel}背景图`,
act.backgroundImageSrc || act.backgroundAssetId || '',
),
buildSection(
`act:${act.id}:encounterNpcIds`,
`${actLabel}相遇 NPC`,
encounterNpcValue,
),
buildSection(
`act:${act.id}:primaryNpcId`,
`${actLabel}主角色`,
primaryNpcValue,
),
buildSection(
`act:${act.id}:stageCoverage`,
`${actLabel}阶段覆盖`,
resolveSceneActStageCoverageLabel(act.stageCoverage),
),
buildSection(`act:${act.id}:actGoal`, `${actLabel}目标`, act.actGoal),
buildSection(
`act:${act.id}:transitionHook`,
`${actLabel}过渡钩子`,
act.transitionHook,
),
buildSection(
`act:${act.id}:linkedThreadIds`,
`${actLabel}关联线程`,
actThreadTitles,
),
buildSection(
`act:${act.id}:advanceRule`,
`${actLabel}推进规则`,
resolveSceneActAdvanceRuleLabel(act.advanceRule),
),
];
}),
],
editableSectionIds: resolveSceneChapterEditableSectionIds(sceneChapter),
warningMessages: warnings,
});
});
return cards;
}
}

View File

@@ -23,6 +23,7 @@ const EDITABLE_SECTION_IDS = {
thread: new Set(['title', 'summary', 'conflictType', 'stakes']),
chapter: new Set(['title', 'summary', 'openingEvent', 'playerGoal', 'understandingShift']),
camp: new Set(['name', 'description', 'dangerLevel']),
sceneChapter: new Set(['title', 'summary']),
} as const;
function normalizePatches(sections: DraftSectionPatch[]) {
@@ -52,6 +53,17 @@ function parseStringList(value: string) {
return [...new Set(value.split(/[\n;]+/u).map((item) => item.trim()).filter(Boolean))];
}
function parseReferenceList(value: string) {
return [
...new Set(
value
.split(/[\n,;]+/u)
.map((item) => item.trim())
.filter(Boolean),
),
];
}
function resolveThreadType(value: string) {
if (value.includes('暗') || value.toLowerCase() === 'hidden') {
return 'hidden' as const;
@@ -60,6 +72,61 @@ function resolveThreadType(value: string) {
return 'main' as const;
}
function parseSceneActSectionId(sectionId: string) {
const match = sectionId.match(
/^act:([^:]+):(title|summary|backgroundImageSrc|encounterNpcIds|actGoal|transitionHook)$/u,
);
if (!match) {
return null;
}
return {
actId: match[1],
field: match[2] as
| 'title'
| 'summary'
| 'backgroundImageSrc'
| 'encounterNpcIds'
| 'actGoal'
| 'transitionHook',
};
}
function resolveCharacterIdByReference(
value: string,
draftProfile: NonNullable<ReturnType<typeof normalizeFoundationDraftProfile>>,
) {
const characters = [...draftProfile.playableNpcs, ...draftProfile.storyNpcs];
return (
characters.find((entry) => entry.id === value)?.id ||
characters.find((entry) => entry.name === value)?.id ||
''
);
}
function parseEncounterNpcIds(
value: string,
draftProfile: NonNullable<ReturnType<typeof normalizeFoundationDraftProfile>>,
) {
const references = parseReferenceList(value);
if (references.length === 0) {
throw badRequest('scene act requires at least one encounter NPC');
}
const unresolvedReferences = references.filter(
(reference) => !resolveCharacterIdByReference(reference, draftProfile),
);
if (unresolvedReferences.length > 0) {
throw badRequest(
`unknown scene act NPC reference: ${unresolvedReferences.join('、')}`,
);
}
return references.map((reference) =>
resolveCharacterIdByReference(reference, draftProfile),
);
}
export function updateDraftCardSections(params: UpdateDraftCardSectionsParams) {
const draftProfile = normalizeFoundationDraftProfile(params.draftProfile);
if (!draftProfile) {
@@ -293,6 +360,70 @@ export function updateDraftCardSections(params: UpdateDraftCardSectionsParams) {
return draftProfile as unknown as Record<string, unknown>;
}
const sceneChapter = draftProfile.sceneChapters.find(
(entry) => entry.id === params.cardId,
);
if (sceneChapter) {
patches.forEach(({ sectionId, value }) => {
if (EDITABLE_SECTION_IDS.sceneChapter.has(sectionId as never)) {
if (sectionId === 'title') {
sceneChapter.title = value;
return;
}
if (sectionId === 'summary') {
sceneChapter.summary = value;
}
return;
}
const parsedSceneActSection = parseSceneActSectionId(sectionId);
if (!parsedSceneActSection) {
throw badRequest(`section ${sectionId} is not editable for scene_chapter`);
}
const targetAct = sceneChapter.acts.find(
(entry) => entry.id === parsedSceneActSection.actId,
);
if (!targetAct) {
throw notFound(`scene act ${parsedSceneActSection.actId} not found`);
}
if (parsedSceneActSection.field === 'title') {
targetAct.title = value;
return;
}
if (parsedSceneActSection.field === 'summary') {
targetAct.summary = value;
return;
}
if (parsedSceneActSection.field === 'backgroundImageSrc') {
targetAct.backgroundImageSrc = value || null;
return;
}
if (parsedSceneActSection.field === 'encounterNpcIds') {
const encounterNpcIds = parseEncounterNpcIds(value, draftProfile);
targetAct.encounterNpcIds = encounterNpcIds;
targetAct.primaryNpcId = encounterNpcIds[0] || '';
return;
}
if (parsedSceneActSection.field === 'actGoal') {
targetAct.actGoal = value;
return;
}
if (parsedSceneActSection.field === 'transitionHook') {
targetAct.transitionHook = value;
}
});
return draftProfile as unknown as Record<string, unknown>;
}
if (draftProfile.camp?.id === params.cardId) {
patches.forEach(({ sectionId, value }) => {
if (!EDITABLE_SECTION_IDS.camp.has(sectionId as never)) {

View File

@@ -119,12 +119,16 @@ async function createObjectRefiningSession(
seedText: '一个被潮雾切开的列岛世界。',
});
const message1 = await orchestrator.submitMessage(userId, createdSession.sessionId, {
clientMessageId: 'phase5-ready-1',
text: '玩家是被迫返乡的失职守灯人,开局时正站在即将熄灭的旧灯塔上。',
focusCardId: null,
selectedCardIds: [],
});
const message1 = await orchestrator.submitMessage(
userId,
createdSession.sessionId,
{
clientMessageId: 'phase5-ready-1',
text: '玩家是被迫返乡的失职守灯人,开局时正站在即将熄灭的旧灯塔上。',
focusCardId: null,
selectedCardIds: [],
},
);
await waitForOperation(
orchestrator,
userId,
@@ -132,12 +136,16 @@ async function createObjectRefiningSession(
message1.operation.operationId,
);
const message2 = await orchestrator.submitMessage(userId, createdSession.sessionId, {
clientMessageId: 'phase5-ready-2',
text: '整体主题是海岛悬疑,气质冷峻克制。核心冲突是守灯会与沉船商盟争夺航道解释权。关键人物叫沈砺,是玩家的旧友兼宿敌,其实暗地里在为沉船商盟引路。标志性元素是潮雾钟声、盐火灯塔。',
focusCardId: null,
selectedCardIds: [],
});
const message2 = await orchestrator.submitMessage(
userId,
createdSession.sessionId,
{
clientMessageId: 'phase5-ready-2',
text: '整体主题是海岛悬疑,气质冷峻克制。核心冲突是守灯会与沉船商盟争夺航道解释权。关键人物叫沈砺,是玩家的旧友兼宿敌,其实暗地里在为沉船商盟引路。标志性元素是潮雾钟声、盐火灯塔。',
focusCardId: null,
selectedCardIds: [],
},
);
await waitForOperation(
orchestrator,
userId,
@@ -194,7 +202,10 @@ test('phase5 generate_role_assets only allows a single role and moves session in
session.sessionId,
response.operation.operationId,
);
const snapshot = await orchestrator.getSessionSnapshot(userId, session.sessionId);
const snapshot = await orchestrator.getSessionSnapshot(
userId,
session.sessionId,
);
assert.equal(operation?.status, 'completed');
assert.equal(snapshot?.stage, 'visual_refining');
@@ -216,7 +227,9 @@ test('phase5 sync_role_assets writes fields back, updates coverage and recompile
});
const userId = 'user-phase5-sync-role-assets';
const session = await createObjectRefiningSession(orchestrator, userId);
const characterCard = session.draftCards.find((card) => card.kind === 'character');
const characterCard = session.draftCards.find(
(card) => card.kind === 'character',
);
assert.ok(characterCard);
@@ -255,33 +268,48 @@ test('phase5 sync_role_assets writes fields back, updates coverage and recompile
session.sessionId,
response.operation.operationId,
);
const snapshot = await orchestrator.getSessionSnapshot(userId, session.sessionId);
const profile = normalizeFoundationDraftProfile(snapshot?.draftProfile);
const syncedRole = [...(profile?.playableNpcs ?? []), ...(profile?.storyNpcs ?? [])].find(
(entry) => entry.id === characterCard!.id,
const snapshot = await orchestrator.getSessionSnapshot(
userId,
session.sessionId,
);
const profile = normalizeFoundationDraftProfile(snapshot?.draftProfile);
const syncedRole = [
...(profile?.playableNpcs ?? []),
...(profile?.storyNpcs ?? []),
].find((entry) => entry.id === characterCard!.id);
const syncedCard = snapshot?.draftCards.find(
(card) => card.id === characterCard!.id,
);
const syncedCard = snapshot?.draftCards.find((card) => card.id === characterCard!.id);
const syncedAssetSummary = snapshot?.assetCoverage.roleAssets.find(
(entry) => entry.roleId === characterCard!.id,
);
const latestRecord = await sessionStore.get(userId, session.sessionId);
assert.equal(operation?.status, 'completed');
assert.equal(syncedRole?.imageSrc, '/generated/characters/shenli-portrait.png');
assert.equal(
syncedRole?.imageSrc,
'/generated/characters/shenli-portrait.png',
);
assert.equal(syncedRole?.generatedVisualAssetId, 'visual-shenli-1');
assert.equal(syncedRole?.generatedAnimationSetId, 'animation-set-shenli-1');
assert.equal(
(syncedRole?.animationMap as Record<string, { basePath?: string }> | null)?.idle
?.basePath,
(syncedRole?.animationMap as Record<string, { basePath?: string }> | null)
?.idle?.basePath,
'/generated/characters/shenli/idle',
);
assert.equal(syncedAssetSummary?.status, 'complete');
assert.equal(syncedCard?.assetStatusLabel, '动作已就绪');
assert.ok(syncedCard?.subtitle.includes('动作已就绪'));
const syncedSkillIds = syncedRole?.skills.map((skill) => skill.id) ?? [];
assert.ok(syncedSkillIds.length > 0);
assert.equal(syncedAssetSummary?.status, 'animations_ready');
assert.deepEqual(
syncedAssetSummary?.missingAnimations,
syncedSkillIds.map((skillId) => `skill:${skillId}`),
);
assert.equal(syncedCard?.assetStatusLabel, '动作补齐中');
assert.ok(syncedCard?.subtitle.includes('动作补齐中'));
assert.ok(
snapshot?.messages.some(
(message) =>
message.kind === 'action_result' && message.text.includes('动作已就绪'),
message.kind === 'action_result' && message.text.includes('动作补齐中'),
),
);
assert.ok((latestRecord?.checkpoints.length ?? 0) >= 2);

View File

@@ -0,0 +1,84 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { buildRoleAssetSummary } from './customWorldAgentRoleAssetStateService.js';
test('role asset summary only requires run attack and configured skill actions', () => {
const summary = buildRoleAssetSummary({
role: {
id: 'role-shenli',
name: '沈砺',
threadIds: ['thread-1'],
imageSrc: '/generated/shenli/portrait.png',
generatedVisualAssetId: 'visual-shenli',
generatedAnimationSetId: 'animation-shenli',
animationMap: {
run: { basePath: '/generated/shenli/run' },
attack: { basePath: '/generated/shenli/attack' },
},
skills: [
{
id: 'skill-tidelight',
name: '潮灯斩',
actionPreviewConfig: {
basePath: '/generated/shenli/skill-tidelight',
},
},
],
},
roleKind: 'playable',
});
assert.equal(summary.status, 'complete');
assert.deepEqual(summary.missingAnimations, []);
});
test('role asset summary marks missing skill actions as required gaps', () => {
const summary = buildRoleAssetSummary({
role: {
id: 'role-yunhe',
name: '云禾',
threadIds: [],
imageSrc: '/generated/yunhe/portrait.png',
generatedVisualAssetId: 'visual-yunhe',
generatedAnimationSetId: 'animation-yunhe',
animationMap: {
run: { basePath: '/generated/yunhe/run' },
attack: { basePath: '/generated/yunhe/attack' },
},
skills: [
{
id: 'skill-wave',
name: '断潮步',
actionPreviewConfig: null,
},
],
},
roleKind: 'story',
});
assert.equal(summary.status, 'animations_ready');
assert.deepEqual(summary.missingAnimations, ['skill:skill-wave']);
});
test('role asset summary treats idle and die as optional', () => {
const summary = buildRoleAssetSummary({
role: {
id: 'role-lin',
name: '林砂',
threadIds: [],
imageSrc: '/generated/lin/portrait.png',
generatedVisualAssetId: 'visual-lin',
generatedAnimationSetId: 'animation-lin',
animationMap: {
run: { basePath: '/generated/lin/run' },
attack: { basePath: '/generated/lin/attack' },
},
skills: [],
},
roleKind: 'story',
});
assert.equal(summary.status, 'complete');
assert.deepEqual(summary.missingAnimations, []);
});

View File

@@ -5,13 +5,13 @@ import type {
CustomWorldRoleAssetSummary,
} from '../../../packages/shared/src/contracts/customWorldAgent.js';
const CORE_ROLE_ANIMATION_KEYS = [
'idle',
'run',
'attack',
'hurt',
'die',
] as const;
const REQUIRED_ROLE_ANIMATION_KEYS = ['run', 'attack'] as const;
type DraftRoleSkillRecord = {
id: string;
name: string;
actionPreviewConfig?: Record<string, unknown> | null;
};
type DraftRoleRecord = {
id: string;
@@ -21,6 +21,7 @@ type DraftRoleRecord = {
generatedVisualAssetId?: string | null;
generatedAnimationSetId?: string | null;
animationMap?: Record<string, unknown> | null;
skills: DraftRoleSkillRecord[];
};
type DraftRoleKind = 'playable' | 'story';
@@ -65,11 +66,8 @@ function toAnimationMap(value: unknown) {
return toRecord(value);
}
function hasAnimationSlot(
animationMap: Record<string, unknown> | null | undefined,
slot: string,
) {
const entry = toRecord(animationMap?.[slot]);
function hasAnimationAsset(entryValue: unknown) {
const entry = toRecord(entryValue);
if (!entry) {
return false;
}
@@ -77,6 +75,41 @@ function hasAnimationSlot(
return Boolean(toText(entry.basePath) || toText(entry.spriteSheetPath));
}
function hasAnimationSlot(
animationMap: Record<string, unknown> | null | undefined,
slot: string,
) {
return hasAnimationAsset(animationMap?.[slot]);
}
function normalizeRoleSkills(value: unknown, fallbackName = '角色') {
const skills = toRecordArray(value)
.map((item, index) => ({
id: toText(item.id) || `skill-${index + 1}`,
name: toText(item.name) || `技能${index + 1}`,
actionPreviewConfig: toRecord(item.actionPreviewConfig),
}))
.filter((item) => Boolean(item.id));
if (skills.length > 0) {
return skills;
}
return [
{
id: 'skill-1',
name: `${toText(fallbackName).slice(0, 10) || '角色'}招牌动作`,
actionPreviewConfig: null,
},
];
}
function collectMissingSkillActions(role: DraftRoleRecord) {
return role.skills
.filter((skill) => !hasAnimationAsset(skill.actionPreviewConfig))
.map((skill) => `skill:${skill.id}`);
}
function resolvePriorityTier(
role: DraftRoleRecord,
roleKind: DraftRoleKind,
@@ -127,6 +160,7 @@ function collectDraftRoles(profileInput: unknown) {
generatedVisualAssetId: toText(item.generatedVisualAssetId) || null,
generatedAnimationSetId: toText(item.generatedAnimationSetId) || null,
animationMap: toAnimationMap(item.animationMap),
skills: normalizeRoleSkills(item.skills, toText(item.role) || name),
};
};
@@ -160,7 +194,9 @@ function collectDraftRoles(profileInput: unknown) {
];
}
export function resolveRoleAssetStatusLabel(status: CustomWorldRoleAssetStatus) {
export function resolveRoleAssetStatusLabel(
status: CustomWorldRoleAssetStatus,
) {
if (status === 'complete') {
return '动作已就绪';
}
@@ -182,9 +218,12 @@ export function buildRoleAssetSummary(params: {
}): CustomWorldRoleAssetSummary {
const { role, roleKind } = params;
const priorityTier = resolvePriorityTier(role, roleKind);
const missingAnimations = CORE_ROLE_ANIMATION_KEYS.filter(
(slot) => !hasAnimationSlot(role.animationMap, slot),
);
const missingAnimations = [
...REQUIRED_ROLE_ANIMATION_KEYS.filter(
(slot) => !hasAnimationSlot(role.animationMap, slot),
),
...collectMissingSkillActions(role),
];
const hasPortrait =
Boolean(role.imageSrc) && Boolean(role.generatedVisualAssetId);
const hasAnimationSet = Boolean(role.generatedAnimationSetId);
@@ -210,10 +249,7 @@ export function buildRoleAssetSummary(params: {
};
}
export function getRoleAssetSummaryById(
draftProfile: unknown,
roleId: string,
) {
export function getRoleAssetSummaryById(draftProfile: unknown, roleId: string) {
const roleEntry = collectDraftRoles(draftProfile).find(
(entry) => entry.role.id === roleId,
);
@@ -281,8 +317,7 @@ export function mergeRoleAssetIntoDraftProfile(
return touched;
};
const touched =
updateRoleList('playableNpcs') || updateRoleList('storyNpcs');
const touched = updateRoleList('playableNpcs') || updateRoleList('storyNpcs');
if (!touched || !updatedRole) {
throw new Error('目标角色不存在,无法同步角色资产。');