Implement scene-based chapter quest progression
Some checks failed
CI / verify (push) Has been cancelled

This commit is contained in:
2026-04-08 11:58:47 +08:00
parent 9d2fc9e4b8
commit bd9fdcbe31
170 changed files with 18259 additions and 1049 deletions

View File

@@ -1 +1,6 @@
{}
{
"sword-princess": {
"generatedVisualAssetId": "visual-1775558475200",
"portrait": "/generated-characters/sword-princess/visual/visual-1775558475200/master.png"
}
}

View File

@@ -0,0 +1,231 @@
import { afterEach, describe, expect, it } from 'vitest';
import { buildExpandedCustomWorldProfile } from '../services/customWorldBuilder';
import {
buildCustomWorldRuntimeCharacters,
getCharacterById,
resolveEncounterRecruitCharacter,
setRuntimeCharacterOverrides,
} from './characterPresets';
import { setRuntimeCustomWorldProfile } from './customWorldRuntime';
function createRole(index: number) {
return {
name: `角色${index + 1}`,
title: `头衔${index + 1}`,
role: `身份${index + 1}`,
description: `角色描述${index + 1}`,
backstory: `角色背景${index + 1}`,
personality: `角色性格${index + 1}`,
motivation: `角色动机${index + 1}`,
combatStyle: `角色战斗风格${index + 1}`,
initialAffinity: 18,
relationshipHooks: [`关系${index + 1}`],
tags: [`标签${index + 1}`],
backstoryReveal: {
publicSummary: `公开背景${index + 1}`,
chapters: [
{
id: `surface-${index + 1}`,
title: '表层来意',
affinityRequired: 10,
teaser: `提示${index + 1}-1`,
content: `内容${index + 1}-1`,
contextSnippet: `摘要${index + 1}-1`,
},
{
id: `scar-${index + 1}`,
title: '旧事裂痕',
affinityRequired: 30,
teaser: `提示${index + 1}-2`,
content: `内容${index + 1}-2`,
contextSnippet: `摘要${index + 1}-2`,
},
{
id: `hidden-${index + 1}`,
title: '隐藏执念',
affinityRequired: 55,
teaser: `提示${index + 1}-3`,
content: `内容${index + 1}-3`,
contextSnippet: `摘要${index + 1}-3`,
},
{
id: `final-${index + 1}`,
title: '最终底牌',
affinityRequired: 80,
teaser: `提示${index + 1}-4`,
content: `内容${index + 1}-4`,
contextSnippet: `摘要${index + 1}-4`,
},
],
},
skills: [
{ name: `技能${index + 1}-1`, summary: '技能摘要1', style: '起手压制' },
{ name: `技能${index + 1}-2`, summary: '技能摘要2', style: '机动周旋' },
{ name: `技能${index + 1}-3`, summary: '技能摘要3', style: '爆发终结' },
],
initialItems: [
{
name: `武器${index + 1}`,
category: '武器',
quantity: 1,
rarity: 'rare' as const,
description: '武器描述',
tags: ['武器标签'],
},
{
name: `补给${index + 1}`,
category: '消耗品',
quantity: 2,
rarity: 'uncommon' as const,
description: '补给描述',
tags: ['补给标签'],
},
{
name: `信物${index + 1}`,
category: '专属物品',
quantity: 1,
rarity: 'rare' as const,
description: '信物描述',
tags: ['信物标签'],
},
],
};
}
describe('characterPresets custom world runtime characters', () => {
afterEach(() => {
setRuntimeCharacterOverrides(null);
setRuntimeCustomWorldProfile(null);
});
it('hydrates story npcs into runtime characters and preserves custom dossiers', () => {
const profile = buildExpandedCustomWorldProfile(
{
name: '裂潮边城',
subtitle: '潮痕未褪',
summary: '一座围绕潮路、断桥和夜港旧案展开的世界。',
tone: '潮湿、压抑、克制',
playerGoal: '查清夜港失踪案和潮路背后的势力牵连。',
templateWorldType: 'WUXIA',
playableNpcs: Array.from({ length: 5 }, (_, index) => ({
...createRole(index),
templateCharacterId:
index === 0
? 'sword-princess'
: index === 1
? 'archer-hero'
: index === 2
? 'girl-hero'
: index === 3
? 'punch-hero'
: 'fighter-4',
})),
storyNpcs: [
{
...createRole(10),
name: '沈雾',
title: '潮路领航人',
role: '夜港向导',
description: '熟悉潮路暗栈与旧渡的人。',
backstory: '曾在断桥坠潮夜里失去整队同伴。',
personality: '谨慎冷静,先观察再表态。',
motivation: '想把失踪航线重新找出来。',
combatStyle: '短刀试探后再借地形逼近。',
initialAffinity: 12,
relationshipHooks: ['断桥旧案', '夜港潮路'],
tags: ['码头', '潮路', '短刀'],
imageSrc: '/custom/npcs/shenwu.png',
visual: {
race: 'human',
bodyColor: 'blue',
headIndex: 2,
hairColorIndex: 3,
hairStyleFrame: 5,
facialHairEnabled: false,
facialHairColorIndex: 1,
facialHairStyleFrame: 0,
mainHand: {
type: 'melee',
file: 'dagger.png',
frameIndex: 4,
},
},
},
{
...createRole(11),
name: '陆沉',
title: '断桥守更',
role: '守桥人',
description: '夜里守着断桥口旧灯火的人。',
},
{
...createRole(12),
name: '顾潮',
title: '潮册记录员',
role: '账房记录员',
description: '在潮账房里整理失踪名册的人。',
},
],
landmarks: [
{
name: '夜港旧栈',
description: '潮雾和旧木桥把视线切成断续几段。',
dangerLevel: 'medium',
sceneNpcNames: ['沈雾', '陆沉', '顾潮'],
connections: [
{
targetLandmarkName: '断桥外沿',
relativePosition: 'forward',
summary: '顺着潮路继续前压就是断桥外沿。',
},
],
},
{
name: '断桥外沿',
description: '旧桥断口还挂着潮湿残旗。',
dangerLevel: 'high',
sceneNpcNames: ['沈雾', '陆沉', '顾潮'],
connections: [
{
targetLandmarkName: '夜港旧栈',
relativePosition: 'back',
summary: '沿旧潮路退回夜港旧栈。',
},
],
},
],
},
'玩家想要一个围绕夜港潮路与断桥旧案展开的世界。',
);
setRuntimeCustomWorldProfile(profile);
const runtimeCharacters = buildCustomWorldRuntimeCharacters(profile);
setRuntimeCharacterOverrides(runtimeCharacters);
const storyRole = profile.storyNpcs[0];
expect(storyRole).toBeTruthy();
const storyCharacter = getCharacterById(storyRole!.id);
const runtimeStoryCharacter = runtimeCharacters.find(
(character) => character.id === storyRole!.id,
);
expect(storyCharacter).toBeTruthy();
expect(runtimeStoryCharacter).toBeTruthy();
expect(storyCharacter?.name).toBe('沈雾');
expect(storyCharacter?.title).toBe('潮路领航人');
expect(storyCharacter?.backstory).toContain('断桥坠潮夜');
expect(storyCharacter?.skills[0]?.name).toBe('技能11-1');
expect(storyCharacter?.portrait).toBe('/custom/npcs/shenwu.png');
expect(storyCharacter?.visual).toEqual(storyRole?.visual);
expect(storyCharacter?.groundOffsetY).toBe(22);
const recruitCharacter = resolveEncounterRecruitCharacter({
characterId: storyRole!.id,
context: storyRole!.role,
npcName: storyRole!.name,
});
expect(recruitCharacter?.id).toBe(storyRole!.id);
expect(recruitCharacter?.name).toBe('沈雾');
});
});

View File

@@ -12,6 +12,7 @@ import {
ConversationGuardStyle,
ConversationTruthStyle,
ConversationWarmStyle,
CustomWorldNpc,
CustomWorldPlayableNpc,
CustomWorldProfile,
Encounter,
@@ -214,11 +215,77 @@ function buildCharacterResourceProfile(character: Character) {
};
}
type CustomWorldRuntimeRole = CustomWorldPlayableNpc | (CustomWorldNpc & {
templateCharacterId?: string;
});
function buildFallbackCustomRuntimeRole(character: Character): CustomWorldRuntimeRole {
return {
id: character.id,
name: character.name,
title: character.title,
role: character.title,
description: character.description,
backstory: character.backstory,
personality: character.personality,
motivation: character.description,
combatStyle: character.skills.map(skill => skill.name).join('、'),
initialAffinity: 18,
relationshipHooks: character.combatTags?.slice(0, 3) ?? [],
tags: character.combatTags ?? [],
backstoryReveal: {
publicSummary: character.description,
chapters: [
{
id: 'surface',
title: '表层来意',
affinityRequired: BACKSTORY_UNLOCK_AFFINITY_EASED,
teaser: character.description,
content: character.backstory,
contextSnippet: character.backstory,
},
{
id: 'scar',
title: '旧事裂痕',
affinityRequired: BACKSTORY_UNLOCK_AFFINITY_FRIENDLY,
teaser: character.backstory,
content: character.backstory,
contextSnippet: character.backstory,
},
{
id: 'hidden',
title: '隐藏执念',
affinityRequired: BACKSTORY_UNLOCK_AFFINITY_TRUSTED,
teaser: character.personality,
content: character.personality,
contextSnippet: character.personality,
},
{
id: 'final',
title: '最终底牌',
affinityRequired: BACKSTORY_UNLOCK_AFFINITY_CLOSE,
teaser: character.skills[0]?.name ?? character.title,
content: character.backstory,
contextSnippet: character.backstory,
},
],
},
skills: character.skills.slice(0, 3).map((skill, index) => ({
id: `preset-skill-${index + 1}`,
name: skill.name,
summary: skill.name,
style: skill.style,
})),
initialItems: [],
visual: character.visual,
};
}
function hydrateCharacterRoleData(
character: Character,
options: {
customWorldProfile?: CustomWorldProfile | null;
customRole?: CustomWorldPlayableNpc | null;
customRole?: CustomWorldRuntimeRole | null;
} = {},
) {
const wuxiaSchema = getPresetWorldAttributeSchema(WorldType.WUXIA);
@@ -227,64 +294,7 @@ function hydrateCharacterRoleData(
const xianxiaProfile = buildCharacterAttributeProfile(character, xianxiaSchema);
const customProfile = options.customWorldProfile
? buildCustomWorldPlayableNpcAttributeProfile(
options.customRole ?? {
id: character.id,
name: character.name,
title: character.title,
role: character.title,
description: character.description,
backstory: character.backstory,
personality: character.personality,
motivation: character.description,
combatStyle: character.skills.map(skill => skill.name).join('、'),
initialAffinity: 18,
relationshipHooks: character.combatTags?.slice(0, 3) ?? [],
tags: character.combatTags ?? [],
backstoryReveal: {
publicSummary: character.description,
chapters: [
{
id: 'surface',
title: '表层来意',
affinityRequired: BACKSTORY_UNLOCK_AFFINITY_EASED,
teaser: character.description,
content: character.backstory,
contextSnippet: character.backstory,
},
{
id: 'scar',
title: '旧事裂痕',
affinityRequired: BACKSTORY_UNLOCK_AFFINITY_FRIENDLY,
teaser: character.backstory,
content: character.backstory,
contextSnippet: character.backstory,
},
{
id: 'hidden',
title: '隐藏执念',
affinityRequired: BACKSTORY_UNLOCK_AFFINITY_TRUSTED,
teaser: character.personality,
content: character.personality,
contextSnippet: character.personality,
},
{
id: 'final',
title: '最终底牌',
affinityRequired: BACKSTORY_UNLOCK_AFFINITY_CLOSE,
teaser: character.skills[0]?.name ?? character.title,
content: character.backstory,
contextSnippet: character.backstory,
},
],
},
skills: character.skills.slice(0, 3).map((skill, index) => ({
id: `preset-skill-${index + 1}`,
name: skill.name,
summary: skill.name,
style: skill.style,
})),
initialItems: [],
},
options.customRole ?? buildFallbackCustomRuntimeRole(character),
options.customWorldProfile.attributeSchema,
character.attributes,
)
@@ -377,6 +387,28 @@ function hashText(value: string) {
return hash;
}
function resolveFallbackRecruitTemplateCharacterId(source: string) {
if (/|||||||||/u.test(source)) {
return 'archer-hero';
}
if (/|||||||||/u.test(source)) {
return 'fighter-4';
}
if (/||||||/u.test(source)) {
return 'punch-hero';
}
if (/||||||||/u.test(source)) {
return 'girl-hero';
}
if (/|使|||殿/u.test(source)) {
return 'sword-princess';
}
return RECRUIT_CHARACTER_FALLBACKS[hashText(source) % RECRUIT_CHARACTER_FALLBACKS.length]
?? RECRUIT_CHARACTER_FALLBACKS[0]
?? 'sword-princess';
}
export function resolveEncounterRecruitCharacter(
encounter: Pick<Encounter, 'characterId' | 'context' | 'npcName'>,
) {
@@ -385,27 +417,7 @@ export function resolveEncounterRecruitCharacter(
}
const source = `${encounter.context} ${encounter.npcName}`;
if (/|||||||||/u.test(source)) {
return getCharacterById('archer-hero');
}
if (/|||||||||/u.test(source)) {
return getCharacterById('fighter-4');
}
if (/||||||/u.test(source)) {
return getCharacterById('punch-hero');
}
if (/||||||||/u.test(source)) {
return getCharacterById('girl-hero');
}
if (/|使|||殿/u.test(source)) {
return getCharacterById('sword-princess');
}
const fallbackId = RECRUIT_CHARACTER_FALLBACKS[hashText(source) % RECRUIT_CHARACTER_FALLBACKS.length]
?? RECRUIT_CHARACTER_FALLBACKS[0]
?? 'sword-princess';
return getCharacterById(fallbackId);
return getCharacterById(resolveFallbackRecruitTemplateCharacterId(source));
}
export function getCharacterEquipment(character: Character) {
@@ -1509,7 +1521,7 @@ function clampInteger(value: number, min: number, max: number) {
function buildCustomWorldSkillVariant(
profile: CustomWorldProfile,
baseCharacter: Character,
role: CustomWorldPlayableNpc,
role: CustomWorldRuntimeRole,
skill: CharacterSkillDefinition,
index: number,
) {
@@ -1590,14 +1602,12 @@ function buildCustomWorldAdventureOpening(
});
}
function buildCustomWorldCharacter(baseCharacter: Character, profile: CustomWorldProfile, index: number) {
const role = profile.playableNpcs[index];
if (!role) {
return baseCharacter;
}
function buildCustomWorldRoleCharacter(
baseCharacter: Character,
profile: CustomWorldProfile,
role: CustomWorldRuntimeRole,
) {
const combatTags = deriveCustomWorldCharacterCombatTags(profile, role, baseCharacter);
const opening = buildCustomWorldAdventureOpening(profile, {
...baseCharacter,
name: role.name,
@@ -1609,11 +1619,15 @@ function buildCustomWorldCharacter(baseCharacter: Character, profile: CustomWorl
return hydrateCharacterRoleData({
...baseCharacter,
id: role.id,
name: role.name,
title: role.title,
description: role.description,
backstory: role.backstory,
backstoryReveal: role.backstoryReveal,
portrait: ('imageSrc' in role && role.imageSrc?.trim()) || baseCharacter.portrait,
visual: 'visual' in role ? role.visual : undefined,
groundOffsetY: 'visual' in role && role.visual ? 22 : baseCharacter.groundOffsetY,
personality: role.personality,
conversationStyle: inferConversationStyleFromText([
role.personality,
@@ -1640,6 +1654,40 @@ function buildCustomWorldCharacter(baseCharacter: Character, profile: CustomWorl
});
}
function pickCustomWorldRoleTemplateCharacter(
role: CustomWorldRuntimeRole,
fallbackIndex: number,
) {
const fallbackTemplateCharacter = PRESET_CHARACTERS[
fallbackIndex % Math.max(1, PRESET_CHARACTERS.length)
] ?? PRESET_CHARACTERS[0];
if (!fallbackTemplateCharacter) {
throw new Error('Missing preset characters for custom world generation');
}
const explicitTemplateCharacter = role.templateCharacterId
? PRESET_CHARACTERS.find(character => character.id === role.templateCharacterId) ?? null
: null;
if (explicitTemplateCharacter) {
return explicitTemplateCharacter;
}
const heuristicTemplateCharacter = PRESET_CHARACTERS.find(
character =>
character.id === resolveFallbackRecruitTemplateCharacterId([
role.role,
role.name,
role.title,
role.combatStyle,
role.description,
role.personality,
role.tags.join(' '),
].join(' ')),
);
return heuristicTemplateCharacter ?? fallbackTemplateCharacter;
}
export function buildCustomWorldPlayableCharacters(profile: CustomWorldProfile | null) {
if (!profile) {
return PRESET_CHARACTERS;
@@ -1650,29 +1698,41 @@ export function buildCustomWorldPlayableCharacters(profile: CustomWorldProfile |
}
return profile.playableNpcs.map((role, index) => {
const fallbackTemplateCharacter = PRESET_CHARACTERS[index % Math.max(1, PRESET_CHARACTERS.length)]
?? PRESET_CHARACTERS[0];
if (!fallbackTemplateCharacter) {
throw new Error('Missing preset characters for custom world generation');
}
const templateCharacter = PRESET_CHARACTERS.find(character => character.id === role.templateCharacterId)
?? fallbackTemplateCharacter;
const templateCharacter = pickCustomWorldRoleTemplateCharacter(role, index);
const customCharacter = buildCustomWorldCharacter(templateCharacter, {
...profile,
playableNpcs: [{
return buildCustomWorldRoleCharacter(
templateCharacter,
profile,
{
...role,
templateCharacterId: role.templateCharacterId ?? templateCharacter.id,
}],
}, 0);
return {
...customCharacter,
id: role.id,
} satisfies Character;
},
);
});
}
export function buildCustomWorldRuntimeCharacters(profile: CustomWorldProfile | null) {
if (!profile) {
return [] as Character[];
}
const playableCharacters = buildCustomWorldPlayableCharacters(profile);
const storyCharacters = profile.storyNpcs.map((role, index) => {
const templateCharacter = pickCustomWorldRoleTemplateCharacter(
role,
profile.playableNpcs.length + index,
);
return buildCustomWorldRoleCharacter(
templateCharacter,
profile,
role,
);
});
return [...playableCharacters, ...storyCharacters];
}
export function setRuntimeCharacterOverrides(characters: Character[] | null) {
runtimeCharacterOverrides.clear();
runtimeCustomWorldCharacters = characters ? [...characters] : [];
@@ -1885,12 +1945,26 @@ export function buildCharacterBackstoryPromptContext(
].filter((snippet): snippet is string => Boolean(snippet));
}
function getCustomWorldRoleSceneIds(profile: CustomWorldProfile, characterId: string) {
const sceneIds = profile.landmarks.flatMap((landmark, index) =>
landmark.sceneNpcIds.includes(characterId)
? [`custom-scene-landmark-${index + 1}`]
: [],
);
return [...new Set(sceneIds)];
}
export function getCharacterHomeSceneId(worldType: WorldType, characterId: string) {
if (isCustomWorldType(worldType)) {
const profile = getRuntimeCustomWorldProfile();
if (!profile || profile.landmarks.length === 0) {
return 'custom-scene-camp';
}
const roleSceneIds = getCustomWorldRoleSceneIds(profile, characterId);
if (roleSceneIds.length > 0) {
return roleSceneIds[0] ?? 'custom-scene-camp';
}
const characterIndex = runtimeCustomWorldCharacters.findIndex(character => character.id === characterId);
const landmarkIndex = Math.max(0, characterIndex) % profile.landmarks.length;
return `custom-scene-landmark-${landmarkIndex + 1}`;
@@ -1906,6 +1980,10 @@ export function getCharacterNpcSceneIds(worldType: WorldType, characterId: strin
if (!profile || profile.landmarks.length === 0) {
return ['custom-scene-camp'];
}
const roleSceneIds = getCustomWorldRoleSceneIds(profile, characterId);
if (roleSceneIds.length > 0) {
return ['custom-scene-camp', ...roleSceneIds].slice(0, 3);
}
const characterIndex = runtimeCustomWorldCharacters.findIndex(character => character.id === characterId);
const firstScene = `custom-scene-landmark-${(Math.max(0, characterIndex) % profile.landmarks.length) + 1}`;
const secondScene = `custom-scene-landmark-${((Math.max(0, characterIndex) + 1) % profile.landmarks.length) + 1}`;

View File

@@ -23,6 +23,7 @@ const TEMPLATE_CHARACTER_TAGS: Record<string, string[]> = {
};
const THEME_FALLBACK_TAGS: Record<CustomWorldThemeMode, string[]> = {
mythic: [],
martial: [],
arcane: ['\u6cd5\u4fee', '\u7b26\u9635', '\u6cd5\u529b'],
machina: ['\u5de5\u5de7', '\u63a7\u573a', '\u62a4\u4f53'],

View File

@@ -1,5 +1,6 @@
import {
Character,
CustomWorldNpc,
CustomWorldPlayableNpc,
CustomWorldProfile,
CustomWorldRoleInitialItem,
@@ -82,7 +83,7 @@ function inferEquipmentSlotFromCategory(category: string): EquipmentSlotId | nul
}
function buildExplicitRoleInventoryItem(
role: CustomWorldPlayableNpc,
role: CustomWorldPlayableNpc | CustomWorldNpc,
item: CustomWorldRoleInitialItem,
index: number,
): InventoryItem {
@@ -111,7 +112,9 @@ function buildExplicitRoleInventoryItem(
};
}
function buildExplicitRoleInventoryItems(role: CustomWorldPlayableNpc | null) {
function buildExplicitRoleInventoryItems(
role: CustomWorldPlayableNpc | CustomWorldNpc | null,
) {
if (!role) {
return [] as InventoryItem[];
}
@@ -121,10 +124,15 @@ function buildExplicitRoleInventoryItems(role: CustomWorldPlayableNpc | null) {
);
}
function resolveCustomWorldPlayableRole(profile: CustomWorldProfile, character: Character) {
function resolveCustomWorldRole(
profile: CustomWorldProfile,
character: Character,
) {
return profile.playableNpcs.find(role => role.id === character.id)
?? profile.storyNpcs.find(role => role.id === character.id)
?? profile.playableNpcs.find(role => role.templateCharacterId === character.id)
?? profile.playableNpcs.find(role => role.name === character.name)
?? profile.storyNpcs.find(role => role.name === character.name)
?? null;
}
@@ -172,7 +180,11 @@ function collectChineseNgrams(value: string, minSize = 2, maxSize = 4, limit = 1
return grams;
}
function buildKeywordBundle(profile: CustomWorldProfile, character: Character, role: CustomWorldPlayableNpc | null) {
function buildKeywordBundle(
profile: CustomWorldProfile,
character: Character,
role: CustomWorldPlayableNpc | CustomWorldNpc | null,
) {
const roleTexts = [
role?.title ?? '',
role?.description ?? '',
@@ -271,7 +283,7 @@ export function buildCustomWorldStarterEquipmentItems(
} satisfies Record<EquipmentSlotId, InventoryItem | null>;
}
const role = resolveCustomWorldPlayableRole(profile, character);
const role = resolveCustomWorldRole(profile, character);
const explicitItems = buildExplicitRoleInventoryItems(role);
const explicitWeapon =
explicitItems.find(item => item.equipmentSlotId === 'weapon') ?? null;
@@ -327,7 +339,7 @@ export function buildCustomWorldStarterInventoryItems(
return [] as InventoryItem[];
}
const role = resolveCustomWorldPlayableRole(profile, character);
const role = resolveCustomWorldRole(profile, character);
const explicitItems = buildExplicitRoleInventoryItems(role);
const bundle = buildKeywordBundle(profile, character, role);
const consumables = queryItems(`inventory:${character.id}:consumables`, {

View File

@@ -1,5 +1,6 @@
import {isRecord, readStoredJson, writeStoredJson} from '../persistence/storage';
import {generateWorldAttributeSchema} from '../services/attributeSchemaGenerator';
import { buildFallbackCustomWorldCampScene } from '../services/customWorldCamp';
import {
buildCustomWorldAnchorPackFromIntent,
deriveCustomWorldLockStateFromIntent,
@@ -514,6 +515,26 @@ function normalizeLandmark(value: unknown, index: number): CustomWorldLandmark |
};
}
function normalizeCampScene(
value: unknown,
fallbackProfile: Pick<
CustomWorldProfile,
'name' | 'summary' | 'tone' | 'playerGoal' | 'settingText' | 'templateWorldType'
>,
) {
const fallback = buildFallbackCustomWorldCampScene(fallbackProfile);
if (!isRecord(value)) {
return fallback;
}
return {
name: toText(value.name, fallback.name),
description: toText(value.description, fallback.description),
dangerLevel: toText(value.dangerLevel, fallback.dangerLevel),
imageSrc: toText(value.imageSrc) || undefined,
};
}
function normalizeLandmarkDraft(
value: unknown,
index: number,
@@ -569,6 +590,14 @@ function normalizeProfile(value: unknown): CustomWorldProfile | null {
const summary = toText(value.summary);
const tone = toText(value.tone);
const playerGoal = toText(value.playerGoal);
const camp = normalizeCampScene(value.camp, {
name,
summary,
tone,
playerGoal,
settingText,
templateWorldType,
});
const generatedAttributeSchema = generateWorldAttributeSchema({
worldType: WorldType.CUSTOM,
worldName: name,
@@ -613,6 +642,7 @@ function normalizeProfile(value: unknown): CustomWorldProfile | null {
.map((entry, index) => normalizeItem(entry, index))
.filter((entry): entry is CustomWorldItem => Boolean(entry))
: [],
camp,
landmarks: normalizeCustomWorldLandmarks({
landmarks: landmarkDrafts,
storyNpcs,

View File

@@ -1,4 +1,8 @@
import { resolveCustomWorldAnchorWorldType } from '../services/customWorldTheme';
import {
type CustomWorldThemeMode,
detectCustomWorldThemeMode,
resolveCustomWorldAnchorWorldType,
} from '../services/customWorldTheme';
import { CustomWorldItem, CustomWorldProfile, InventoryItem, WorldTemplateType, WorldType } from '../types';
let runtimeCustomWorldProfile: CustomWorldProfile | null = null;
@@ -94,9 +98,13 @@ const CATEGORY_DEFAULT_TAGS: Record<string, string[]> = {
: ['rare', '线索'],
: ['rare', '剧情关键'],
};
const WORLD_ITEM_PREFIXES: Record<WorldTemplateType, string[]> = {
[WorldType.WUXIA]: ['江湖', '风雨', '断桥', '青锋', '旧案', '夜行'],
[WorldType.XIANXIA]: ['灵潮', '云阙', '星砂', '裂界', '玄脉', '天舟'],
const WORLD_ITEM_PREFIXES: Record<CustomWorldThemeMode, string[]> = {
mythic: ['远岸', '回声', '曙迹', '长旅', '微光', '新铭'],
martial: ['风雨', '断桥', '青锋', '旧案', '夜行', '残影'],
arcane: ['灵纹', '道痕', '云篆', '星芒', '界辉', '玉简'],
machina: ['铁脊', '脉冲', '新星', '等离', '钢律', '核列'],
tide: ['潮纹', '霜浪', '天澜', '海晕', '潮歌', '沧流'],
rift: ['裂痕', '灰域', '界桥', '断层', '回响', '前哨'],
};
const WORLD_ITEM_NOUNS: Record<string, string[]> = {
: ['刃', '剑', '弓', '枪', '印', '锤'],
@@ -127,7 +135,7 @@ function getWorldSeedLabel(profile: CustomWorldProfile) {
const fromSetting = sanitizeNameFragment(profile.settingText);
if (fromSetting) return fromSetting;
return profile.templateWorldType === WorldType.XIANXIA ? '灵境' : '江湖';
return '旅境';
}
function buildRuntimeItemTags(
@@ -228,7 +236,7 @@ function buildProceduralRuntimeItem(
options: RuntimeCustomWorldItemQueryOptions,
index: number,
) {
const anchorWorldType = resolveCustomWorldAnchorWorldType(profile);
const themeMode = detectCustomWorldThemeMode(profile);
const seed = hashText(`${profile.id}:${seedKey}:${index}`);
const defaultCategory = DEFAULT_RUNTIME_CATEGORIES[0] ?? 'world-item';
const categories = compactStrings(options.categories?.length ? options.categories : [...DEFAULT_RUNTIME_CATEGORIES]);
@@ -236,7 +244,7 @@ function buildProceduralRuntimeItem(
const rarityFloorValue = getRarityFloorValue(options.rarityFloor);
const rarity = inferRuntimeItemRarity(seed, rarityFloorValue);
const tags = buildRuntimeItemTags(category, options, seed);
const prefixPool = WORLD_ITEM_PREFIXES[anchorWorldType];
const prefixPool = WORLD_ITEM_PREFIXES[themeMode];
const nounPool = WORLD_ITEM_NOUNS[category] ?? WORLD_ITEM_NOUNS.;
const fallbackNounPool = ['sigil', 'relic', 'token', 'seal', 'core', 'mark'];
const resolvedNounPool = nounPool ?? fallbackNounPool;

View File

@@ -1,4 +1,11 @@
import { WorldTemplateType, WorldType } from '../types';
import { resolveCustomWorldCampScene } from '../services/customWorldCamp';
import { detectCustomWorldThemeMode } from '../services/customWorldTheme';
import {
type CustomWorldLandmark,
type CustomWorldProfile,
type WorldTemplateType,
WorldType,
} from '../types';
const CUSTOM_WORLD_NPC_IMAGE_POOL = [
'/character/Sword%20Princess/Original/Hero/idle/Idle01.png',
@@ -14,6 +21,187 @@ const SCENE_BACKGROUND_PACKS = [
{ packName: 'Pixel Battle Backgrounds - Pack 3', count: 170 },
] as const;
type SceneImageReference = {
name: string;
keywords: string[];
};
const SCENE_MATCH_STOP_CHARS = new Set([
'的',
'之',
'与',
'和',
'里',
'处',
'中',
'外',
'前',
'后',
'上',
'下',
'左',
'右',
'一',
'二',
'三',
'四',
'五',
'六',
'七',
'八',
'九',
'十',
'场',
'景',
'地',
'方',
'区',
'域',
'路',
'道',
'门',
'台',
'楼',
'城',
'山',
'林',
'湖',
'河',
'谷',
'洞',
'宫',
'殿',
'营',
'崖',
'桥',
]);
const WUXIA_SCENE_IMAGE_REFERENCES: SceneImageReference[] = [
{
name: '山门石阶',
keywords: ['山门', '石阶', '门派', '山路', '石门', '宗门'],
},
{
name: '雨巷长街',
keywords: ['雨巷', '长街', '街市', '巷道', '城镇', '商铺'],
},
{
name: '竹林古道',
keywords: ['竹林', '古道', '林路', '林间', '小径', '山径'],
},
{
name: '断垣村落',
keywords: ['废村', '村落', '断墙', '残垣', '旧屋', '荒宅'],
},
{
name: '古桥渡口',
keywords: ['桥', '渡口', '河岸', '水路', '码头', '舟船'],
},
{
name: '雾林小径',
keywords: ['雾林', '迷雾', '树林', '暗林', '阴森', '野路'],
},
{
name: '边关营地',
keywords: ['营地', '驻地', '营火', '关隘', '边关', '据点', '归舍', '落脚', '住处'],
},
{
name: '地宫通道',
keywords: ['地宫', '墓道', '通道', '地底', '遗迹', '机关'],
},
{
name: '寺庙前庭',
keywords: ['寺庙', '庙宇', '神龛', '前庭', '祭坛', '佛堂'],
},
{
name: '矿道深处',
keywords: ['矿道', '矿坑', '坑道', '矿洞', '洞窟', '地下'],
},
{
name: '铸坊工场',
keywords: ['铸坊', '工场', '铁匠', '锻造', '熔炉', '火光'],
},
{
name: '宫苑内庭',
keywords: ['宫苑', '内庭', '庭院', '府邸', '回廊', '深宫'],
},
] as const;
const XIANXIA_SCENE_IMAGE_REFERENCES: SceneImageReference[] = [
{
name: '云海仙门',
keywords: ['云海', '仙门', '山门', '灵门', '云阶', '门阙'],
},
{
name: '悬空仙岛',
keywords: ['浮岛', '仙岛', '悬空', '高空', '云岛', '浮空'],
},
{
name: '天宫长廊',
keywords: ['天宫', '长廊', '回廊', '宫阙', '高处', '仙宫'],
},
{
name: '灵药花圃',
keywords: ['药圃', '花圃', '灵草', '花海', '园林', '药园'],
},
{
name: '寒玉洞天',
keywords: ['寒玉', '冰洞', '洞天', '冰面', '寒气', '玉壁'],
},
{
name: '熔岩秘境',
keywords: ['熔岩', '火山', '赤焰', '岩浆', '灼热', '焦土'],
},
{
name: '雷殿祭坛',
keywords: ['雷殿', '祭坛', '雷霆', '神殿', '雷光', '仪式'],
},
{
name: '星舟甲板',
keywords: ['星舟', '甲板', '飞舟', '天舟', '高空', '航线'],
},
{
name: '月湖仙洲',
keywords: ['月湖', '湖岸', '湖心', '水面', '水边', '倒影'],
},
{
name: '古仙遗迹',
keywords: ['遗迹', '断碑', '残阵', '古殿', '残墙', '废墟'],
},
{
name: '神木秘境',
keywords: ['神木', '古树', '巨树', '树海', '灵木', '林境'],
},
{
name: '飞瀑仙崖',
keywords: ['飞瀑', '瀑布', '仙崖', '崖边', '水幕', '崖壁'],
},
] as const;
const WORLD_SCENE_IMAGE_REFERENCES: Record<
WorldTemplateType,
readonly SceneImageReference[]
> = {
[WorldType.WUXIA]: WUXIA_SCENE_IMAGE_REFERENCES,
[WorldType.XIANXIA]: XIANXIA_SCENE_IMAGE_REFERENCES,
};
type CustomWorldSceneImageMatchOptions = {
profile?: Pick<
CustomWorldProfile,
| 'id'
| 'name'
| 'summary'
| 'tone'
| 'playerGoal'
| 'settingText'
| 'templateWorldType'
| 'camp'
> | null;
landmark?: Pick<CustomWorldLandmark, 'id' | 'name' | 'description' | 'dangerLevel'> | null;
usedImageSrcs?: Iterable<string>;
};
function hashText(value: string) {
let hash = 0;
for (let index = 0; index < value.length; index += 1) {
@@ -60,6 +248,116 @@ export function normalizeOptionalImageSrc(value: unknown) {
return typeof value === 'string' && value.trim() ? value.trim() : undefined;
}
function uniqueStrings(values: Array<string | null | undefined>) {
return [...new Set(values.map((value) => value?.trim() ?? '').filter(Boolean))];
}
function buildSceneReferencePool(worldType: WorldTemplateType) {
const pool = collectWorldSceneImagePool(worldType);
const references = WORLD_SCENE_IMAGE_REFERENCES[worldType] ?? [];
return references.map((reference, index) => ({
...reference,
imageSrc: pool[index] ?? pool[index % Math.max(pool.length, 1)] ?? '',
}));
}
function buildSourceText(
seedKey: string,
index: number,
worldType: WorldTemplateType,
options: CustomWorldSceneImageMatchOptions,
) {
const profile = options.profile;
const landmark = options.landmark;
const themeHints = profile
? ({
mythic: '归处 旧痕 路途 异象 线索',
martial: '刀剑 风尘 旧约 行路 关隘',
arcane: '云阶 法纹 星辉 秘藏 回响',
machina: '工坊 轨道 装置 核心 机械',
tide: '潮雾 港湾 岸线 水路 回潮',
rift: '裂痕 断层 前线 边界 异压',
} as const)[detectCustomWorldThemeMode(profile)]
: (worldType === WorldType.XIANXIA
? '云阶 法纹 星辉 秘藏 回响'
: '刀剑 风尘 旧约 行路 关隘');
return uniqueStrings([
profile?.name,
profile?.summary,
profile?.tone,
profile?.playerGoal,
profile?.settingText,
themeHints,
landmark?.name,
landmark?.description,
landmark?.dangerLevel,
`scene-${index + 1}`,
seedKey,
]).join(' ');
}
function buildSignalChars(text: string) {
return [
...new Set(
text
.replace(/[^\u4e00-\u9fa5]+/g, '')
.split('')
.filter((char) => char && !SCENE_MATCH_STOP_CHARS.has(char)),
),
];
}
function scoreSceneReference(reference: SceneImageReference, sourceText: string) {
let score = 0;
if (sourceText.includes(reference.name)) {
score += 24;
}
reference.keywords.forEach((keyword) => {
if (!keyword || !sourceText.includes(keyword)) {
return;
}
if (keyword.length >= 4) {
score += 8;
return;
}
if (keyword.length === 3) {
score += 6;
return;
}
score += 4;
});
buildSignalChars([reference.name, ...reference.keywords].join('')).forEach(
(char) => {
if (sourceText.includes(char)) {
score += 1;
}
},
);
return score;
}
function getFirstUnusedImage(
candidates: string[],
usedImageSrcs: Set<string>,
) {
for (const candidate of candidates) {
if (candidate && !usedImageSrcs.has(candidate)) {
return candidate;
}
}
return candidates[0] ?? '';
}
export function getDefaultCustomWorldNpcImage(seedKey: string, index: number) {
const offset = hashText(`${seedKey}:npc:${index}`) % CUSTOM_WORLD_NPC_IMAGE_POOL.length;
return CUSTOM_WORLD_NPC_IMAGE_POOL[offset];
@@ -69,12 +367,152 @@ export function getDefaultCustomWorldSceneImage(
seedKey: string,
index: number,
worldType: WorldTemplateType,
options: CustomWorldSceneImageMatchOptions = {},
) {
const pool = collectWorldSceneImagePool(worldType);
if (pool.length === 0) {
return worldType === WorldType.WUXIA ? '/scene_bg/45_PixelSky.png' : '/scene_bg/47_PixelSky.png';
}
const offset = hashText(`${seedKey}:scene:${index}`) % pool.length;
return pool[offset];
const usedImageSrcs = new Set(
[...(options.usedImageSrcs ?? [])]
.map((value) => normalizeOptionalImageSrc(value))
.filter((value): value is string => Boolean(value)),
);
const sourceText = buildSourceText(seedKey, index, worldType, options);
const referencePool = buildSceneReferencePool(worldType);
const scoredReferences = referencePool
.map((reference, referenceIndex) => ({
imageSrc: reference.imageSrc,
score: scoreSceneReference(reference, sourceText),
tieBreaker: hashText(`${seedKey}:${reference.name}:${referenceIndex}`),
}))
.sort((left, right) => {
if (right.score !== left.score) {
return right.score - left.score;
}
return left.tieBreaker - right.tieBreaker;
});
const matchedReferenceImages = scoredReferences
.filter((entry) => entry.score > 0 && entry.imageSrc)
.map((entry) => entry.imageSrc);
const matchedReferenceImage = getFirstUnusedImage(
matchedReferenceImages,
usedImageSrcs,
);
if (matchedReferenceImage) {
return matchedReferenceImage;
}
const offset = hashText(`${seedKey}:scene:${index}:${sourceText}`) % pool.length;
const rotatedPool = [
...pool.slice(offset),
...pool.slice(0, offset),
];
return getFirstUnusedImage(rotatedPool, usedImageSrcs);
}
export function resolveCustomWorldLandmarkImage(
profile: Pick<
CustomWorldProfile,
'id' | 'name' | 'summary' | 'tone' | 'playerGoal' | 'settingText' | 'templateWorldType'
>,
landmark: Pick<CustomWorldLandmark, 'id' | 'name' | 'description' | 'dangerLevel' | 'imageSrc'>,
index: number,
usedImageSrcs?: Iterable<string>,
) {
const explicitImageSrc = normalizeOptionalImageSrc(landmark.imageSrc);
if (explicitImageSrc) {
return explicitImageSrc;
}
return getDefaultCustomWorldSceneImage(
profile.id || profile.name,
index,
profile.templateWorldType,
{
profile,
landmark,
usedImageSrcs,
},
);
}
export function resolveCustomWorldLandmarkImageMap(
profile: Pick<
CustomWorldProfile,
| 'id'
| 'name'
| 'summary'
| 'tone'
| 'playerGoal'
| 'settingText'
| 'templateWorldType'
| 'landmarks'
| 'camp'
>,
) {
const usedImageSrcs = new Set(
profile.landmarks
.map((landmark) => normalizeOptionalImageSrc(landmark.imageSrc))
.filter((imageSrc): imageSrc is string => Boolean(imageSrc)),
);
const imageMap = new Map<string, string>();
profile.landmarks.forEach((landmark, index) => {
const resolvedImageSrc = resolveCustomWorldLandmarkImage(
profile,
landmark,
index,
usedImageSrcs,
);
if (resolvedImageSrc) {
imageMap.set(landmark.id, resolvedImageSrc);
usedImageSrcs.add(resolvedImageSrc);
}
});
return imageMap;
}
export function resolveCustomWorldCampSceneImage(
profile: Pick<
CustomWorldProfile,
| 'id'
| 'name'
| 'summary'
| 'tone'
| 'playerGoal'
| 'settingText'
| 'templateWorldType'
| 'landmarks'
| 'camp'
>,
) {
const campScene = resolveCustomWorldCampScene(profile);
const explicitImageSrc = normalizeOptionalImageSrc(campScene.imageSrc);
if (explicitImageSrc) {
return explicitImageSrc;
}
const landmarkImageMap = resolveCustomWorldLandmarkImageMap(profile);
const usedImageSrcs = new Set(landmarkImageMap.values());
return getDefaultCustomWorldSceneImage(
profile.id || profile.name,
-1,
profile.templateWorldType,
{
profile,
landmark: {
id: 'custom-scene-camp',
name: campScene.name,
description: campScene.description,
dangerLevel: campScene.dangerLevel,
},
usedImageSrcs,
},
);
}

View File

@@ -67,11 +67,15 @@ function createGameState(): GameState {
}
describe('hostileNpcPresets', () => {
it('combines preset loot with runtime semantic drops', () => {
it('combines preset loot with runtime semantic drops', async () => {
const randomSpy = vi.spyOn(Math, 'random').mockReturnValue(0);
vi.stubGlobal(
'fetch',
vi.fn().mockRejectedValue(new TypeError('network disabled in test')),
);
try {
const loot = rollHostileNpcLoot(createGameState(), [
const loot = await rollHostileNpcLoot(createGameState(), [
{
id: 'monster-03',
name: '断骨祟灵',
@@ -82,7 +86,11 @@ describe('hostileNpcPresets', () => {
expect(
loot.some(item => item.runtimeMetadata?.generationChannel === 'monster_drop'),
).toBe(true);
expect(
loot.find(item => item.id === 'monster-loot:bone-dust')?.runtimeMetadata?.storyFingerprint,
).toBeTruthy();
} finally {
vi.unstubAllGlobals();
randomSpy.mockRestore();
}
});

View File

@@ -1,12 +1,18 @@
import { HostileNpcAnimationConfig, HostileNpcSpriteConfig } from '../components/HostileNpcAnimator';
import { GameState, InventoryItem, MonsterLootEntry, RoleActionDefinition, SceneHostileNpc, WorldType } from '../types';
import {generateRuntimeItemAiIntents} from '../services/runtimeItemAiDirector';
import { GameState, InventoryItem, MonsterLootEntry, RoleActionDefinition, RuntimeItemPlan, SceneHostileNpc, WorldType } from '../types';
import { buildMonsterAttributeProfile } from './attributeProfileGenerator';
import { buildDefaultAxisVector } from './attributeResolver';
import {normalizeBuildTags} from './buildTags';
import { buildRuntimeCustomWorldInventoryItems, resolveRuleWorldType } from './customWorldRuntime';
import hostileNpcOverridesJson from './hostileNpcOverrides.json';
import { buildRuntimeItemGenerationContext } from './runtimeItemContext';
import { buildDirectedRuntimeReward } from './runtimeItemDirector';
import { flattenDirectedRuntimeRewardItems } from './runtimeItemNarrative';
import { generateDirectedRuntimeReward } from './runtimeItemDirector';
import {
applyRuntimeItemNarrativeToExistingItem,
buildRuntimeItemAiIntent,
flattenDirectedRuntimeRewardItems,
} from './runtimeItemNarrative';
import { getPresetWorldAttributeSchema } from './worldAttributeSchemas';
export interface HostileNpcPreset extends HostileNpcSpriteConfig {
@@ -940,12 +946,15 @@ const ALL_HOSTILE_NPC_PRESETS = BASE_HOSTILE_NPC_PRESETS.map(basePreset => merge
export const HOSTILE_NPC_PRESETS_BY_WORLD: Record<WorldType, HostileNpcPreset[]> = {
[WorldType.WUXIA]: ALL_HOSTILE_NPC_PRESETS.filter(monster => monster.worldType === WorldType.WUXIA),
[WorldType.XIANXIA]: ALL_HOSTILE_NPC_PRESETS.filter(monster => monster.worldType === WorldType.XIANXIA),
[WorldType.CUSTOM]: ALL_HOSTILE_NPC_PRESETS.filter(monster => monster.worldType === WorldType.WUXIA),
[WorldType.CUSTOM]: [...ALL_HOSTILE_NPC_PRESETS],
};
export const MONSTER_PRESETS_BY_WORLD = HOSTILE_NPC_PRESETS_BY_WORLD;
export function getHostileNpcPresetById(worldType: WorldType, monsterId: string) {
if (worldType === WorldType.CUSTOM) {
return HOSTILE_NPC_PRESETS_BY_WORLD[WorldType.CUSTOM].find(monster => monster.id === monsterId) ?? null;
}
const resolvedWorldType = resolveRuleWorldType(worldType) ?? WorldType.WUXIA;
return HOSTILE_NPC_PRESETS_BY_WORLD[resolvedWorldType].find(monster => monster.id === monsterId) ?? null;
}
@@ -953,6 +962,9 @@ export function getHostileNpcPresetById(worldType: WorldType, monsterId: string)
export const getMonsterPresetById = getHostileNpcPresetById;
export function getHostileNpcPresetsByWorld(worldType: WorldType) {
if (worldType === WorldType.CUSTOM) {
return HOSTILE_NPC_PRESETS_BY_WORLD[WorldType.CUSTOM];
}
const resolvedWorldType = resolveRuleWorldType(worldType) ?? WorldType.WUXIA;
return HOSTILE_NPC_PRESETS_BY_WORLD[resolvedWorldType];
}
@@ -963,7 +975,100 @@ export function getHostileNpcPresetOverrideById(monsterId: string) {
return HOSTILE_NPC_OVERRIDES[monsterId] ?? null;
}
export function rollHostileNpcLoot(
function inferRuntimePlanFromLootItem(
item: InventoryItem,
context: ReturnType<typeof buildRuntimeItemGenerationContext>,
index: number,
): RuntimeItemPlan {
const normalizedBuildTags = normalizeBuildTags(item.tags, 3);
const targetBuildDirection = normalizedBuildTags.length > 0
? normalizedBuildTags
: normalizeBuildTags(context.playerBuildTags, 3);
return {
slot: item.rarity === 'epic' || item.rarity === 'legendary'
? 'primary'
: index === 0
? 'secondary'
: 'support',
itemKind: item.category === '武器' || item.category === '护甲'
? 'equipment'
: item.category === '消耗品' || item.tags.includes('consumable') || item.tags.includes('healing') || item.tags.includes('mana')
? 'consumable'
: item.category === '材料' || item.tags.includes('material')
? 'material'
: item.category === '专属品' || item.category === '专属物' || item.category === '专属物品'
? 'quest'
: 'relic',
permanence: item.category === '材料'
? 'resource'
: item.category === '消耗品'
? 'timed'
: 'permanent',
narrativeWeight: item.rarity === 'epic' || item.rarity === 'legendary' ? 'heavy' : 'medium',
targetBuildDirection: targetBuildDirection.length > 0 ? targetBuildDirection : ['均衡'],
relationAnchor: context.encounter?.monsterPresetId
? {
type: 'monster' as const,
monsterId: context.encounter.monsterPresetId,
monsterName: context.encounter.npcName,
}
: context.encounterNpcName
? {
type: 'npc' as const,
npcId: context.encounterNpcId ?? undefined,
npcName: context.encounterNpcName,
roleText: context.encounterContextText ?? undefined,
}
: {
type: 'scene' as const,
sceneId: context.sceneId ?? undefined,
sceneName: context.sceneName ?? '战场余烬',
},
} satisfies RuntimeItemPlan;
}
async function decoratePresetLootWithNarrative(
items: InventoryItem[],
context: ReturnType<typeof buildRuntimeItemGenerationContext>,
seedKeyPrefix: string,
) {
if (items.length <= 0) return items;
const plans = items.map((item, index) => inferRuntimePlanFromLootItem(item, context, index));
const fallbackIntents = plans.map(plan => buildRuntimeItemAiIntent(context, plan));
let intents = fallbackIntents;
try {
intents = await generateRuntimeItemAiIntents({
context,
plans,
});
} catch (error) {
console.warn('[HostileNpcPresets] preset loot narrative fallback', error);
}
return items.map((item, index) =>
applyRuntimeItemNarrativeToExistingItem({
item: {
...item,
runtimeMetadata: {
origin: 'procedural',
generationChannel: 'monster_drop',
relationAnchor: plans[index]!.relationAnchor,
seedKey: `${seedKeyPrefix}:preset:${index}`,
sourceReason: intents[index]!.reasonToAppear,
},
},
context,
plan: plans[index]!,
intent: intents[index]!,
preserveName: true,
}),
);
}
export async function rollHostileNpcLoot(
state: GameState,
defeatedHostileNpcs: Array<Pick<SceneHostileNpc, 'id' | 'name'>>,
) {
@@ -979,7 +1084,7 @@ export function rollHostileNpcLoot(
));
}
return defeatedHostileNpcs.flatMap(monster => {
const rewardBatches = await Promise.all(defeatedHostileNpcs.map(async monster => {
const preset = getHostileNpcPresetById(state.worldType!, monster.id);
const presetLoot = preset
? preset.lootTable
@@ -1001,13 +1106,19 @@ export function rollHostileNpcLoot(
monsterPresetId: monster.id,
},
});
const directedReward = buildDirectedRuntimeReward(context, {
seedKey: `monster-loot:${monster.id}:${monster.name}:${state.currentScenePreset?.id ?? 'scene'}`,
itemCount: 2,
fixedKinds: ['material', 'consumable'],
fixedPermanence: ['resource', 'timed'],
});
const seedKey = `monster-loot:${monster.id}:${monster.name}:${state.currentScenePreset?.id ?? 'scene'}`;
const [decoratedPresetLoot, directedReward] = await Promise.all([
decoratePresetLootWithNarrative(presetLoot, context, seedKey),
generateDirectedRuntimeReward(context, {
seedKey,
itemCount: 2,
fixedKinds: ['material', 'consumable'],
fixedPermanence: ['resource', 'timed'],
}),
]);
const runtimeItems = flattenDirectedRuntimeRewardItems(directedReward);
return [...presetLoot, ...runtimeItems];
});
return [...decoratedPresetLoot, ...runtimeItems];
}));
return rewardBatches.flat();
}

View File

@@ -21,7 +21,7 @@ export const MAX_HOSTILE_NPCS_PER_ENCOUNTER = 3;
export const HOSTILE_NPCS_BY_WORLD: Record<WorldType, HostileNpcSpriteConfig[]> = {
[WorldType.WUXIA]: HOSTILE_NPC_PRESETS_BY_WORLD[WorldType.WUXIA].map(({ description: _description, introAction: _introAction, baseStats: _baseStats, worldType: _worldType, ...spriteConfig }) => spriteConfig),
[WorldType.XIANXIA]: HOSTILE_NPC_PRESETS_BY_WORLD[WorldType.XIANXIA].map(({ description: _description, introAction: _introAction, baseStats: _baseStats, worldType: _worldType, ...spriteConfig }) => spriteConfig),
[WorldType.CUSTOM]: HOSTILE_NPC_PRESETS_BY_WORLD[WorldType.WUXIA].map(({ description: _description, introAction: _introAction, baseStats: _baseStats, worldType: _worldType, ...spriteConfig }) => spriteConfig),
[WorldType.CUSTOM]: HOSTILE_NPC_PRESETS_BY_WORLD[WorldType.CUSTOM].map(({ description: _description, introAction: _introAction, baseStats: _baseStats, worldType: _worldType, ...spriteConfig }) => spriteConfig),
};
export const MONSTERS_BY_WORLD = HOSTILE_NPCS_BY_WORLD;

View File

@@ -0,0 +1,105 @@
import type {InventoryItem} from '../types';
import {getBuildTagDefinition} from './buildTags';
import type {InventoryUseEffect} from './inventoryEffects';
const STRUCTURAL_TAG_LABELS: Record<string, string> = {
weapon: '武器',
armor: '护甲',
relic: '遗物',
material: '材料',
consumable: '消耗品',
healing: '疗伤',
mana: '法力',
rare: '稀有',
wuxia: '武侠',
xianxia: '仙侠',
neutral: '中性',
};
function dedupeStrings(values: Array<string | null | undefined>) {
return [...new Set(values.map(value => value?.trim() ?? '').filter(Boolean))];
}
function buildEffectSummaryParts(
item: InventoryItem,
useEffect: InventoryUseEffect | null,
) {
if (useEffect) {
return [
useEffect.hpRestore > 0 ? `恢复 ${useEffect.hpRestore} 点气血` : null,
useEffect.manaRestore > 0 ? `恢复 ${useEffect.manaRestore} 点灵力` : null,
useEffect.cooldownReduction > 0
? `额外推进 ${useEffect.cooldownReduction} 回合冷却`
: null,
useEffect.buildBuffs.length > 0
? `获得 ${useEffect.buildBuffs.map(buff => buff.name).join('、')}`
: null,
];
}
return [
item.tags.includes('healing') ? '在冒险中恢复生命值' : null,
item.tags.includes('mana') ? '帮助回转灵力与技能节奏' : null,
item.tags.includes('weapon') ? '适合进攻型构筑' : null,
item.tags.includes('armor') ? '适合防御型构筑' : null,
item.tags.includes('relic') ? '可作为稀有遗物长期携带' : null,
item.tags.includes('material') ? '可用于制作、锻造或交换' : null,
];
}
export function getInventoryTagLabel(tag: string) {
const normalized = tag.trim();
if (!normalized) return '';
const buildTag = getBuildTagDefinition(normalized);
if (buildTag) {
return buildTag.label;
}
return STRUCTURAL_TAG_LABELS[normalized.toLowerCase()] ?? normalized;
}
export function getInventoryTagLabels(tags: string[]) {
return dedupeStrings(tags.map(getInventoryTagLabel));
}
export function buildInventoryItemDescription(
item: InventoryItem,
useEffect: InventoryUseEffect | null = null,
) {
if (item.description?.trim()) return item.description.trim();
const storyFingerprint = item.runtimeMetadata?.storyFingerprint;
if (storyFingerprint) {
return [
storyFingerprint.visibleClue,
`${storyFingerprint.witnessMark} ${storyFingerprint.unresolvedQuestion}`,
`它会在此刻出现,是因为${storyFingerprint.currentAppearanceReason}`,
].join(' ');
}
const parts = buildEffectSummaryParts(item, useEffect).filter(
(part): part is string => Boolean(part),
);
if (parts.length > 0) {
return `${item.name} 当前可提供这些作用:${parts.join('')}`;
}
switch (item.category) {
case '武器':
return `${item.name} 更适合作为当前战利品中的主战装备,后续可用于替换现有武器或继续锻造。`;
case '护甲':
return `${item.name} 适合用来补足防护与承伤能力,也可留作后续重铸素材。`;
case '饰品':
case '稀有品':
case '专属品':
case '专属物':
case '专属物品':
return `${item.name} 更偏向长期携带与构筑补强,也可能牵出额外线索。`;
case '材料':
return `${item.name} 可用于制作、锻造、交易,或为后续掉落组合做准备。`;
default:
return `${item.name} 可用于后续路线规划、交易或构筑调整。`;
}
}

View File

@@ -536,6 +536,30 @@ export function buildMedievalNpcVisualOverrideFromCustomWorldVisual(visual: Cust
};
}
export function buildMedievalNpcVisualFromCustomWorldVisual(
visual: CustomWorldNpcVisual,
): MedievalNpcVisualSpec {
const override = buildMedievalNpcVisualOverrideFromCustomWorldVisual(visual);
const race = override.race ?? 'human';
return {
race,
bodySrc: override.bodySrc ?? buildBodyPath('black'),
headSrc: override.headSrc ?? buildRaceAssetPath(race, 'head', 1),
hairSrc: override.hairSrc ?? buildRaceAssetPath(race, 'hair', 1),
handSrc: override.handSrc ?? buildRaceAssetPath(race, 'hand', 1),
facialHairSrc: override.facialHairSrc,
headgear: override.headgear,
mainHand: override.mainHand,
offHand: override.offHand,
bodyFrames: override.bodyFrames ?? [0, 1, 2, 3],
headFrame: override.headFrame ?? 0,
hairFrame: override.hairFrame ?? 0,
handFrame: override.handFrame ?? 0,
facialHairFrame: override.facialHairFrame,
};
}
export function getNpcVisualOverrideById(overrideId: string) {
return NPC_VISUAL_OVERRIDES[overrideId] ?? null;
}

View File

@@ -260,6 +260,24 @@ function clampProgress(progress: number | undefined, requiredCount: number) {
return Math.max(0, Math.min(normalizeCount(requiredCount), Math.round(progress ?? 0)));
}
function compactQuestLabel(label: string, maxLength = 6) {
const trimmed = label.trim();
return trimmed.length > maxLength ? trimmed.slice(0, maxLength) : trimmed;
}
function normalizeQuestTitle(rawTitle: string, fallbackTitle: string) {
const title = rawTitle
.replace(/["']/gu, '')
.replace(/[,.!?;:].*$/u, '')
.trim();
if (title && title.length <= 12) {
return title;
}
return fallbackTitle.length <= 12 ? fallbackTitle : fallbackTitle.slice(0, 10);
}
function getScenePrimaryThreat(scene: QuestSceneSnapshot | null, worldType: WorldType | null): SceneQuestThreat | null {
if (!scene) {
return null;
@@ -749,7 +767,7 @@ export function buildFallbackQuestIntent(params: QuestCompilationRequest): Quest
if (threat?.kind === 'defeat_hostile_npc') {
const hostileNpcName = threat.targetHostileNpcName;
return {
title: `压制 ${hostileNpcName}`,
title: `压制${compactQuestLabel(hostileNpcName, 8)}`,
description: `${issuerNpcName} 希望你先处理掉 ${scene?.name ?? '前方区域'} 徘徊的 ${hostileNpcName},再回来交换后续情报。`,
summary: `击退 ${hostileNpcName},然后回去和 ${issuerNpcName} 交谈`,
narrativeType: 'bounty',
@@ -770,7 +788,7 @@ export function buildFallbackQuestIntent(params: QuestCompilationRequest): Quest
if (threat?.kind === 'inspect_treasure' && scene) {
return {
title: `探明 ${scene.name} 的异常`,
title: `${compactQuestLabel(scene.name)}异动`,
description: `${issuerNpcName} 不确定 ${scene.name} 一带出现的异动是真是假,想让你先去看清楚,再回来对一遍情报。`,
summary: `调查 ${scene.name} 的异常,然后回去向 ${issuerNpcName} 汇报`,
narrativeType: 'investigation',
@@ -790,7 +808,7 @@ export function buildFallbackQuestIntent(params: QuestCompilationRequest): Quest
}
return {
title: `${issuerNpcName} 过几招`,
title: `${compactQuestLabel(issuerNpcName)}试炼`,
description: `${issuerNpcName} 想先亲自试一试你的成色,再决定要不要把更关键的事继续交给你。`,
summary: `${issuerNpcName} 切磋一场,然后回来把话说透`,
narrativeType: 'trial',
@@ -813,6 +831,7 @@ export function compileQuestIntentToQuest(
params: QuestCompilationRequest,
intent: QuestIntent,
): QuestLogEntry | null {
const fallbackIntent = buildFallbackQuestIntent(params);
const primaryStep = buildPrimaryQuestStep({
issuerNpcId: params.issuerNpcId,
issuerNpcName: params.issuerNpcName,
@@ -842,9 +861,9 @@ export function compileQuestIntentToQuest(
issuerNpcName: params.issuerNpcName,
sceneId: params.scene?.id ?? null,
questArchetype: intent.narrativeType,
title: intent.title.trim() || buildFallbackQuestIntent(params).title,
description: intent.description.trim() || buildFallbackQuestIntent(params).description,
summary: intent.summary.trim() || buildFallbackQuestIntent(params).summary,
title: normalizeQuestTitle(intent.title, fallbackIntent.title),
description: intent.description.trim() || fallbackIntent.description,
summary: intent.summary.trim() || fallbackIntent.summary,
steps,
reward,
rewardText,

View File

@@ -73,9 +73,10 @@ export function buildRuntimeItemAiIntent(
context: RuntimeItemGenerationContext,
plan: RuntimeItemPlan,
): RuntimeItemAiIntent {
const anchorLabel = resolveAnchorLabel(plan.relationAnchor);
const sourceSeed = sanitizeFragment(context.sceneName, 4)
|| sanitizeFragment(context.customWorldProfile?.name, 4)
|| sanitizeFragment(resolveAnchorLabel(plan.relationAnchor), 4)
|| sanitizeFragment(anchorLabel, 4)
|| '旧誓';
const functionalBias: RuntimeItemAiIntent['desiredFunctionalBias'] = [];
@@ -94,10 +95,12 @@ export function buildRuntimeItemAiIntent(
return {
shortNameSeed: sourceSeed,
sourcePhrase: resolveAnchorLabel(plan.relationAnchor),
reasonToAppear: `${resolveAnchorLabel(plan.relationAnchor)}与最近局势把它推到了你面前。`,
sourcePhrase: anchorLabel,
reasonToAppear: context.generationChannel === 'monster_drop'
? `${anchorLabel}倒下后,${context.sceneName ?? '这片战场'}里最值得带走的残留被翻了出来。`
: `${anchorLabel}与最近局势把它推到了你面前。`,
relationHooks: [
context.encounterContextText ?? context.sceneName ?? resolveAnchorLabel(plan.relationAnchor),
context.encounterContextText ?? context.sceneName ?? anchorLabel,
...context.recentActions,
].filter(Boolean).slice(0, 2) as string[],
desiredBuildTags: [...new Set([
@@ -114,16 +117,16 @@ export function buildRuntimeItemAiIntent(
: 'martial',
visibleClue:
context.relatedNpcNarrativeProfile?.visibleLine
?? `${resolveAnchorLabel(plan.relationAnchor)}身上留下的旧痕`,
?? `${anchorLabel}身上留下的旧痕`,
witnessMark:
context.relatedNpcNarrativeProfile?.debtOrBurden
?? `${resolveAnchorLabel(plan.relationAnchor)}尚未散尽的使用痕`,
?? `${anchorLabel}尚未散尽的使用痕`,
unfinishedBusiness:
context.relatedNpcNarrativeProfile?.contradiction
?? `${resolveAnchorLabel(plan.relationAnchor)}背后还有没说完的问题`,
?? `${anchorLabel}背后还有没说完的问题`,
hiddenHook:
context.relatedNpcNarrativeProfile?.taboo
?? `${resolveAnchorLabel(plan.relationAnchor)}为什么会在此刻重新出现`,
?? `${anchorLabel}为什么会在此刻重新出现`,
reactionHooks: [
...(context.relatedNpcNarrativeProfile?.reactionHooks ?? []),
...(context.activeThreadIds ?? []),
@@ -133,6 +136,8 @@ export function buildRuntimeItemAiIntent(
? 'quest_evidence'
: plan.itemKind === 'material'
? 'scene_relic'
: plan.relationAnchor.type === 'monster'
? 'monster_trophy'
: plan.relationAnchor.type === 'npc'
? 'npc_relic'
: 'faction_issue',
@@ -165,6 +170,38 @@ export function applyRuntimeItemNarrative(params: {
} satisfies InventoryItem;
}
export function applyRuntimeItemNarrativeToExistingItem(params: {
item: InventoryItem;
context: RuntimeItemGenerationContext;
plan: RuntimeItemPlan;
intent: RuntimeItemAiIntent;
preserveName?: boolean;
}) {
const fingerprint = buildRuntimeItemStoryFingerprint(params);
const runtimeMetadata =
params.item.runtimeMetadata ?? {
origin: 'procedural' as const,
generationChannel: params.context.generationChannel,
relationAnchor: params.plan.relationAnchor,
seedKey: `${params.context.generationChannel}:${params.item.id}`,
sourceReason: params.intent.reasonToAppear,
};
return {
...params.item,
name: params.preserveName
? params.item.name
: buildCarrierNarrativeName(params),
description: buildCarrierNarrativeDescription(params),
runtimeMetadata: {
...runtimeMetadata,
relationAnchor: runtimeMetadata.relationAnchor ?? params.plan.relationAnchor,
sourceReason: params.intent.reasonToAppear,
storyFingerprint: fingerprint,
},
} satisfies InventoryItem;
}
export function describeRuntimeRelationAnchor(anchor: RuntimeRelationAnchor | undefined) {
if (!anchor) return '无明确锚点';
return `${anchor.type}:${resolveAnchorLabel(anchor)}`;

View File

@@ -1,8 +1,12 @@
import fs from 'node:fs';
import path from 'node:path';
import { WorldType } from '../types';
import { getDefaultCustomWorldSceneImage } from './customWorldVisuals';
import { CustomWorldProfile, WorldType } from '../types';
import {
getDefaultCustomWorldSceneImage,
resolveCustomWorldCampSceneImage,
resolveCustomWorldLandmarkImageMap,
} from './customWorldVisuals';
import { getScenePresetsByWorld } from './scenePresets';
function resolvePublicAssetPath(assetPath: string) {
@@ -30,4 +34,88 @@ describe('scene background assets', () => {
expect(fs.existsSync(resolvePublicAssetPath(wuxiaImage))).toBe(true);
expect(fs.existsSync(resolvePublicAssetPath(xianxiaImage))).toBe(true);
});
it('keeps ungenerated custom world scenes on independent matched backgrounds', () => {
const generatedImage =
'/generated-custom-world-scenes/test-world/generated-ruins.png';
const profile: CustomWorldProfile = {
id: 'custom-world-test',
settingText: '荒城断碑与边关旧营并存的武侠世界',
name: '断碑边城',
subtitle: '烽烟未熄',
summary: '边关旧营与残城废墟彼此相望,玩家要追查旧案余烬。',
tone: '压抑、克制、潜伏危机',
playerGoal: '追查残城旧案背后的真相',
templateWorldType: WorldType.WUXIA,
majorFactions: [],
coreConflicts: ['边关旧案复起'],
attributeSchema: {
id: 'schema:test',
worldId: 'custom:test',
schemaVersion: 1,
generatedFrom: {
worldType: WorldType.CUSTOM,
worldName: '断碑边城',
settingSummary: '边关旧案',
tone: '压抑',
conflictCore: '旧案复起',
},
slots: [],
},
playableNpcs: [],
storyNpcs: [],
items: [],
landmarks: [
{
id: 'landmark-1',
name: '残城旧营',
description: '断墙、军帐与营火灰烬混在一起,像一处被遗弃的边关驻地。',
dangerLevel: 'high',
imageSrc: generatedImage,
sceneNpcIds: [],
connections: [],
},
{
id: 'landmark-2',
name: '雾锁渡桥',
description: '古桥横跨冷河,雾气压在水面上,只有残灯还在摇晃。',
dangerLevel: 'medium',
sceneNpcIds: [],
connections: [],
},
{
id: 'landmark-3',
name: '地宫裂隙',
description: '墓道向下坍塌,石阶与机关残痕一路通往地底深处。',
dangerLevel: 'extreme',
sceneNpcIds: [],
connections: [],
},
],
themePack: null,
storyGraph: null,
creatorIntent: null,
anchorPack: null,
lockState: null,
generationMode: 'full',
generationStatus: 'complete',
};
const landmarkImageMap = resolveCustomWorldLandmarkImageMap(profile);
const secondImage = landmarkImageMap.get('landmark-2');
const thirdImage = landmarkImageMap.get('landmark-3');
const campImage = resolveCustomWorldCampSceneImage(profile);
expect(landmarkImageMap.get('landmark-1')).toBe(generatedImage);
expect(secondImage).toBeTruthy();
expect(thirdImage).toBeTruthy();
expect(secondImage).not.toBe(generatedImage);
expect(thirdImage).not.toBe(generatedImage);
expect(secondImage).not.toBe(thirdImage);
expect(campImage).toBeTruthy();
expect(campImage).not.toBe(generatedImage);
expect(fs.existsSync(resolvePublicAssetPath(secondImage!))).toBe(true);
expect(fs.existsSync(resolvePublicAssetPath(thirdImage!))).toBe(true);
expect(fs.existsSync(resolvePublicAssetPath(campImage))).toBe(true);
});
});

View File

@@ -177,6 +177,7 @@ describe('scenePresets custom world npc mapping', () => {
expect(scene).toBeTruthy();
expect(npc).toBeTruthy();
expect(npc?.characterId).toBe(npc?.id);
expect(npc?.title).toBe('潮路领航人');
expect(npc?.backstory).toContain('断桥坠潮夜');
expect(npc?.personality).toContain('谨慎冷静');

View File

@@ -1,5 +1,5 @@
import { resolveCustomWorldCampScene } from '../services/customWorldCamp';
import { buildCustomCampSceneName } from '../services/customWorldPresentation';
import { resolveCustomWorldAnchorWorldType } from '../services/customWorldTheme';
import {
buildFallbackActorNarrativeProfile,
normalizeActorNarrativeProfile,
@@ -26,7 +26,11 @@ import {
} from './characterPresets';
import { resolveCustomWorldNpcMonsterPreset } from './customWorldNpcMonsters';
import { getRuntimeCustomWorldProfile, resolveRuleWorldType } from './customWorldRuntime';
import { getMonsterPresetById } from './hostileNpcPresets';
import {
resolveCustomWorldCampSceneImage,
resolveCustomWorldLandmarkImageMap,
} from './customWorldVisuals';
import { getMonsterPresetById, getMonsterPresetsByWorld } from './hostileNpcPresets';
import sceneNpcOverridesJson from './sceneNpcOverrides.json';
import sceneOverridesJson from './sceneOverrides.json';
@@ -124,18 +128,6 @@ function collectWorldImagePool(worldType: WorldType, requiredCount: number) {
return refs;
}
function collectAllImagePool() {
const refs: string[] = [];
for (const pack of PACKS) {
for (let imageNumber = 1; imageNumber <= pack.count; imageNumber += 1) {
refs.push(buildImagePath(pack.packName, imageNumber));
}
}
return refs;
}
function uniqueStrings(values: string[]) {
return [...new Set(values.filter(Boolean))];
}
@@ -305,7 +297,6 @@ export function buildEncounterFromSceneNpc(
function buildCustomSceneNpc(
npc: CustomWorldProfile['storyNpcs'][number],
profile: CustomWorldProfile,
anchorWorldType: WorldType,
): SceneNpc {
const themePack = profile.themePack ?? buildThemePackFromWorldProfile(profile);
const storyGraph =
@@ -316,7 +307,7 @@ function buildCustomSceneNpc(
);
const monsterPreset =
npc.initialAffinity < 0
? resolveCustomWorldNpcMonsterPreset(npc, anchorWorldType)
? resolveCustomWorldNpcMonsterPreset(npc)
: null;
const hostile = npc.initialAffinity < 0 || Boolean(monsterPreset);
const attributeProfile = monsterPreset?.attributeProfile
@@ -339,6 +330,7 @@ function buildCustomSceneNpc(
return {
id: npc.id,
characterId: npc.id,
name: npc.name,
title: npc.title,
role: npc.role,
@@ -384,11 +376,10 @@ function buildCustomSceneId(kind: 'camp' | 'landmark', index = 0) {
}
function buildCustomScenePresets(profile: CustomWorldProfile): ScenePreset[] {
const allImages = collectAllImagePool();
const imageOffset = hashText(profile.id || profile.name) % Math.max(1, allImages.length);
const anchorWorldType = resolveCustomWorldAnchorWorldType(profile);
const baseMonsterPool: string[] = getScenePresetsByWorld(anchorWorldType)
.flatMap((scene: ScenePreset) => getSceneHostileNpcPresetIds(scene))
const campSceneProfile = resolveCustomWorldCampScene(profile);
const landmarkImageMap = resolveCustomWorldLandmarkImageMap(profile);
const baseMonsterPool: string[] = getMonsterPresetsByWorld(WorldType.CUSTOM)
.map((monster) => monster.id)
.filter((monsterId: string, index: number, array: string[]) => array.indexOf(monsterId) === index);
const fallbackMonsterIds: string[] = baseMonsterPool.length > 0 ? baseMonsterPool : [];
const playableCharacters = buildCustomWorldPlayableCharacters(profile);
@@ -423,16 +414,16 @@ function buildCustomScenePresets(profile: CustomWorldProfile): ScenePreset[] {
sceneId: landmarkSceneIds[index] ?? '',
relativePosition:
index === 0 ? 'forward' : index === 1 ? 'left' : 'right',
summary: `营地可直接通往${landmark.name}`,
summary: `${campSceneProfile.name}可直接通往${landmark.name}`,
}))
.filter((connection) => connection.sceneId) as SceneConnectionInfo[];
const customScenes: ScenePreset[] = [
{
id: campSceneId,
name: buildCustomCampSceneName(profile),
description: `你在${profile.name}的临时营地整备行装。${profile.summary}`,
description: campSceneProfile.description,
worldType: WorldType.CUSTOM,
imageSrc: profile.landmarks[0]?.imageSrc ?? allImages[imageOffset] ?? '',
imageSrc: resolveCustomWorldCampSceneImage(profile),
connectedSceneIds: campConnections.map((connection) => connection.sceneId),
connections: campConnections,
forwardSceneId: pickForwardSceneIdFromConnections(campConnections),
@@ -452,7 +443,7 @@ function buildCustomScenePresets(profile: CustomWorldProfile): ScenePreset[] {
.map((npcId) => customStoryNpcById.get(npcId))
.filter(Boolean)
.map((npc) =>
buildCustomSceneNpc(npc!, profile, anchorWorldType),
buildCustomSceneNpc(npc!, profile),
);
if (sceneNpcs.length < 3) {
profile.storyNpcs
@@ -461,7 +452,7 @@ function buildCustomScenePresets(profile: CustomWorldProfile): ScenePreset[] {
)
.slice(0, 3 - sceneNpcs.length)
.forEach((npc) =>
sceneNpcs.push(buildCustomSceneNpc(npc, profile, anchorWorldType)),
sceneNpcs.push(buildCustomSceneNpc(npc, profile)),
);
}
const landmarkConnections = landmark.connections
@@ -489,7 +480,7 @@ function buildCustomScenePresets(profile: CustomWorldProfile): ScenePreset[] {
? ({
sceneId: campSceneId,
relativePosition: 'back',
summary: '可回到临时营地整备',
summary: `可回到${campSceneProfile.name}整备`,
} satisfies SceneConnectionInfo)
: null;
const connections = [
@@ -502,7 +493,7 @@ function buildCustomScenePresets(profile: CustomWorldProfile): ScenePreset[] {
const monsterSliceStart = (index * 2) % Math.max(1, fallbackMonsterIds.length || 1);
const seedMonsterIds: string[] = fallbackMonsterIds.slice(monsterSliceStart, monsterSliceStart + 2);
const hostileNpcs = seedMonsterIds
.map((monsterId: string) => buildHostileSceneNpc(buildCustomSceneId('landmark', index), anchorWorldType, monsterId))
.map((monsterId: string) => buildHostileSceneNpc(buildCustomSceneId('landmark', index), WorldType.CUSTOM, monsterId))
.filter(Boolean) as SceneNpc[];
const combinedNpcs = [...sceneNpcs, ...hostileNpcs];
@@ -511,7 +502,7 @@ function buildCustomScenePresets(profile: CustomWorldProfile): ScenePreset[] {
name: landmark.name,
description: landmark.description,
worldType: WorldType.CUSTOM,
imageSrc: landmark.imageSrc ?? allImages[(imageOffset + index + 1) % Math.max(1, allImages.length)] ?? '',
imageSrc: landmarkImageMap.get(landmark.id) ?? '',
connectedSceneIds,
connections,
forwardSceneId: pickForwardSceneIdFromConnections(connections),
@@ -1090,6 +1081,3 @@ export function buildSceneEntityCatalogText(worldType: WorldType, sceneId: strin
`当前场景残痕:${residueText}`,
].join('\n');
}