This commit is contained in:
2026-04-27 22:50:18 +08:00
parent ded6f6ee2a
commit b6c6640548
77 changed files with 5240 additions and 833 deletions

View File

@@ -73,6 +73,51 @@ describe('normalizeCustomWorldProfileRecord role asset descriptions', () => {
expect(profile?.sceneChapterBlueprints?.[0]?.acts).toHaveLength(1);
});
it('把幕配置里的角色名归一到真实角色 id', () => {
const profile = normalizeCustomWorldProfileRecord({
name: '雾港归航',
settingText: '海雾旧案',
playableNpcs: [
{
id: 'playable-cendeng',
name: '岑灯',
title: '返乡守灯人',
role: '主角代理',
},
],
storyNpcs: [
{
id: 'story-luheng',
name: '陆衡',
title: '航运公会审计员',
role: '第一幕主NPC',
},
],
sceneChapterBlueprints: [
{
id: 'scene-chapter-1',
sceneId: 'custom-scene-camp',
title: '开局章节',
acts: [
{
id: 'act-1',
title: '第一幕',
summary: '陆衡先拦住玩家。',
encounterNpcIds: ['陆衡'],
primaryNpcId: '航运公会审计员',
oppositeNpcId: '陆衡',
},
],
},
],
});
const act = profile?.sceneChapterBlueprints?.[0]?.acts[0];
expect(act?.encounterNpcIds).toEqual(['story-luheng']);
expect(act?.primaryNpcId).toBe('story-luheng');
expect(act?.oppositeNpcId).toBe('story-luheng');
});
it('直接读取 Rust 草稿角色字段和形象资源', () => {
const profile = normalizeCustomWorldProfileRecord({
name: '雾港归航',
@@ -121,4 +166,3 @@ describe('normalizeCustomWorldProfileRecord role asset descriptions', () => {
);
});
});

View File

@@ -13,6 +13,7 @@ import {
normalizeCustomWorldLockState,
} from '../services/customWorldCreatorIntent';
import { normalizeCustomWorldOwnedSettingLayers } from '../services/customWorldOwnedSettingLayers';
import { resolveCustomWorldRoleIdReference } from '../services/customWorldRoleReferences';
import {
AnimationState,
CharacterAnimationConfig,
@@ -971,18 +972,30 @@ function normalizeSceneActBlueprint(
value: unknown,
index: number,
sceneId: string,
profileRoles?: {
playableNpcs: CustomWorldPlayableNpc[];
storyNpcs: CustomWorldNpc[];
} | null,
): SceneActBlueprint | null {
if (!isRecord(value)) {
return null;
}
const encounterNpcIds = toStringArray(value.encounterNpcIds);
const encounterNpcIds = toStringArray(value.encounterNpcIds).map((npcId) =>
resolveCustomWorldRoleIdReference(profileRoles, npcId),
);
const stageCoverage = normalizeSceneActStageCoverage(value.stageCoverage);
const advanceRule = toText(value.advanceRule);
const title = toText(value.title);
const summary = toText(value.summary);
const primaryNpcId = toText(value.primaryNpcId, encounterNpcIds[0] ?? '');
const oppositeNpcId = toText(value.oppositeNpcId, primaryNpcId);
const primaryNpcId = resolveCustomWorldRoleIdReference(
profileRoles,
toText(value.primaryNpcId, encounterNpcIds[0] ?? ''),
);
const oppositeNpcId = resolveCustomWorldRoleIdReference(
profileRoles,
toText(value.oppositeNpcId, primaryNpcId),
);
if (!title && !summary && encounterNpcIds.length === 0) {
return null;
@@ -1020,7 +1033,13 @@ function normalizeSceneActBlueprint(
};
}
function normalizeSceneChapterBlueprints(value: unknown) {
function normalizeSceneChapterBlueprints(
value: unknown,
profileRoles?: {
playableNpcs: CustomWorldPlayableNpc[];
storyNpcs: CustomWorldNpc[];
} | null,
) {
if (!Array.isArray(value)) {
return null;
}
@@ -1036,7 +1055,12 @@ function normalizeSceneChapterBlueprints(value: unknown) {
const acts = Array.isArray(entry.acts)
? entry.acts
.map((act, actIndex) =>
normalizeSceneActBlueprint(act, actIndex, sceneId),
normalizeSceneActBlueprint(
act,
actIndex,
sceneId,
profileRoles,
),
)
.filter((act): act is SceneActBlueprint => Boolean(act))
: [];
@@ -1126,6 +1150,11 @@ function normalizeProfile(value: unknown): CustomWorldProfile | null {
.filter((entry): entry is CustomWorldLandmarkDraft => Boolean(entry))
: [];
const playableNpcs = Array.isArray(value.playableNpcs)
? value.playableNpcs
.map((entry, index) => normalizePlayableNpc(entry, index))
.filter((entry): entry is CustomWorldPlayableNpc => Boolean(entry))
: [];
const normalizedProfile = {
id: toText(value.id, `saved-custom-world-${Date.now().toString(36)}`),
settingText,
@@ -1144,11 +1173,7 @@ function normalizeProfile(value: unknown): CustomWorldProfile | null {
value.attributeSchema,
generatedAttributeSchema,
),
playableNpcs: Array.isArray(value.playableNpcs)
? value.playableNpcs
.map((entry, index) => normalizePlayableNpc(entry, index))
.filter((entry): entry is CustomWorldPlayableNpc => Boolean(entry))
: [],
playableNpcs,
storyNpcs,
items: Array.isArray(value.items)
? value.items
@@ -1168,6 +1193,10 @@ function normalizeProfile(value: unknown): CustomWorldProfile | null {
preserveStructuredRecordArray<ThreadContract>(value.threadContracts),
sceneChapterBlueprints: normalizeSceneChapterBlueprints(
value.sceneChapterBlueprints,
{
playableNpcs,
storyNpcs,
},
),
anchorContent: preserveStructuredRecord<EightAnchorContent>(
value.anchorContent,

View File

@@ -1,3 +1,4 @@
import { resolveCustomWorldRoleIdReference } from '../services/customWorldRoleReferences';
import {
canUseLimitedPrimaryNpcChat,
resolveActiveSceneActEncounterFocusNpcId,
@@ -145,9 +146,16 @@ function getAvailableActiveSceneActNpcs(state: GameState) {
return (state.currentScenePreset?.npcs ?? [])
.filter(candidate => {
const candidateIds = [candidate.id, candidate.characterId].filter(
(value): value is string => Boolean(value),
);
const candidateIds = [
candidate.id,
candidate.characterId,
candidate.name,
candidate.title,
]
.map((value) =>
resolveCustomWorldRoleIdReference(state.customWorldProfile, value),
)
.filter(Boolean);
return candidateIds.some(id => activeActNpcIdSet.has(id));
})
.filter(candidate => candidate.characterId !== state.playerCharacter?.id)
@@ -180,8 +188,19 @@ function pickFriendlySceneNpcForActiveAct(state: GameState, npcs: SceneNpc[]) {
return (
npcs.find(
(npc) =>
npc.id === focusNpcId ||
(npc.characterId ? npc.characterId === focusNpcId : false),
resolveCustomWorldRoleIdReference(state.customWorldProfile, npc.id) === focusNpcId ||
resolveCustomWorldRoleIdReference(
state.customWorldProfile,
npc.characterId,
) === focusNpcId ||
resolveCustomWorldRoleIdReference(
state.customWorldProfile,
npc.name,
) === focusNpcId ||
resolveCustomWorldRoleIdReference(
state.customWorldProfile,
npc.title,
) === focusNpcId,
) ?? pickRandomItem(npcs)
);
}

View File

@@ -1,5 +1,6 @@
import { resolveCustomWorldCampScene } from '../services/customWorldCamp';
import { buildCustomCampSceneName } from '../services/customWorldPresentation';
import { resolveCustomWorldRoleIdReferences } from '../services/customWorldRoleReferences';
import {
buildFallbackActorNarrativeProfile,
normalizeActorNarrativeProfile,
@@ -406,9 +407,11 @@ function collectSceneActNpcIdsForScene(
}
chapter.acts.forEach((act) => {
pushNpcId(act.primaryNpcId);
pushNpcId(act.oppositeNpcId);
act.encounterNpcIds.forEach(pushNpcId);
resolveCustomWorldRoleIdReferences(profile, [
act.primaryNpcId,
act.oppositeNpcId,
...act.encounterNpcIds,
]).forEach(pushNpcId);
});
});