Implement scene-based chapter quest progression
Some checks failed
CI / verify (push) Has been cancelled
Some checks failed
CI / verify (push) Has been cancelled
This commit is contained in:
@@ -1 +1,6 @@
|
||||
{}
|
||||
{
|
||||
"sword-princess": {
|
||||
"generatedVisualAssetId": "visual-1775558475200",
|
||||
"portrait": "/generated-characters/sword-princess/visual/visual-1775558475200/master.png"
|
||||
}
|
||||
}
|
||||
|
||||
231
src/data/characterPresets.customWorld.test.ts
Normal file
231
src/data/characterPresets.customWorld.test.ts
Normal 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('沈雾');
|
||||
});
|
||||
});
|
||||
@@ -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}`;
|
||||
|
||||
@@ -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'],
|
||||
|
||||
@@ -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`, {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
105
src/data/itemPresentation.ts
Normal file
105
src/data/itemPresentation.ts
Normal 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} 可用于后续路线规划、交易或构筑调整。`;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)}`;
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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('谨慎冷静');
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user