Split custom world generation into staged lightweight batches
Some checks failed
CI / verify (push) Has been cancelled
Some checks failed
CI / verify (push) Has been cancelled
This commit is contained in:
@@ -1,6 +1,12 @@
|
||||
import {isRecord, readStoredJson, writeStoredJson} from '../persistence/storage';
|
||||
import {generateWorldAttributeSchema} from '../services/attributeSchemaGenerator';
|
||||
import {
|
||||
normalizeCustomWorldLandmarks,
|
||||
type CustomWorldLandmarkDraft,
|
||||
} from './customWorldSceneGraph';
|
||||
import {
|
||||
CharacterBackstoryChapter,
|
||||
CharacterBackstoryRevealConfig,
|
||||
CustomWorldItem,
|
||||
CustomWorldLandmark,
|
||||
CustomWorldNpc,
|
||||
@@ -10,12 +16,18 @@ import {
|
||||
CustomWorldNpcVisualRace,
|
||||
CustomWorldPlayableNpc,
|
||||
CustomWorldProfile,
|
||||
CustomWorldRoleInitialItem,
|
||||
CustomWorldRoleSkill,
|
||||
EquipmentSlotId,
|
||||
ItemRarity,
|
||||
ItemStatProfile,
|
||||
ItemUseProfile,
|
||||
WorldType,
|
||||
} from '../types';
|
||||
import {
|
||||
AFFINITY_BACKSTORY_CHAPTER_THRESHOLDS,
|
||||
DEFAULT_PRIVATE_CHAT_UNLOCK_AFFINITY,
|
||||
} from './affinityLevels';
|
||||
import {coerceWorldAttributeSchema} from './attributeValidation';
|
||||
|
||||
const CUSTOM_WORLD_LIBRARY_STORAGE_KEY = 'tavernrealms.custom-world-library.v1';
|
||||
@@ -29,6 +41,32 @@ const ITEM_RARITIES = new Set<ItemRarity>(['common', 'uncommon', 'rare', 'epic',
|
||||
const EQUIPMENT_SLOTS = new Set<EquipmentSlotId>(['weapon', 'armor', 'relic']);
|
||||
const CUSTOM_WORLD_NPC_VISUAL_RACES = new Set<CustomWorldNpcVisualRace>(['human', 'elf', 'orc', 'goblin']);
|
||||
const CUSTOM_WORLD_NPC_VISUAL_GEAR_TYPES = new Set<CustomWorldNpcVisualGearType>(['cloth', 'leather', 'metal', 'melee', 'magic', 'ranged']);
|
||||
const CUSTOM_WORLD_ROLE_ITEM_CATEGORIES = new Set([
|
||||
'武器',
|
||||
'护甲',
|
||||
'饰品',
|
||||
'消耗品',
|
||||
'材料',
|
||||
'稀有品',
|
||||
'专属物品',
|
||||
'专属物',
|
||||
]);
|
||||
const CUSTOM_WORLD_BACKSTORY_CHAPTER_AFFINITIES =
|
||||
AFFINITY_BACKSTORY_CHAPTER_THRESHOLDS;
|
||||
const CUSTOM_WORLD_BACKSTORY_CHAPTER_TITLES = ['表层来意', '旧事裂痕', '隐藏执念', '最终底牌'] as const;
|
||||
|
||||
type CustomWorldRoleFallbackSource = {
|
||||
name: string;
|
||||
title: string;
|
||||
role: string;
|
||||
description: string;
|
||||
backstory: string;
|
||||
personality: string;
|
||||
motivation: string;
|
||||
combatStyle: string;
|
||||
relationshipHooks: string[];
|
||||
tags: string[];
|
||||
};
|
||||
|
||||
type StoredCustomWorldLibrary = {
|
||||
version: number;
|
||||
@@ -63,6 +101,211 @@ function normalizeInitialAffinity(value: unknown, fallback: number) {
|
||||
return Math.max(MIN_CUSTOM_WORLD_AFFINITY, Math.min(MAX_CUSTOM_WORLD_AFFINITY, resolved));
|
||||
}
|
||||
|
||||
function truncateText(value: string, maxLength: number) {
|
||||
const normalized = value.trim().replace(/\s+/g, ' ');
|
||||
if (!normalized) return '';
|
||||
if (normalized.length <= maxLength) return normalized;
|
||||
return `${normalized.slice(0, Math.max(0, maxLength - 1)).trim()}…`;
|
||||
}
|
||||
|
||||
function splitNarrativeSentences(text: string) {
|
||||
const normalized = text.replace(/\s+/g, ' ').trim();
|
||||
if (!normalized) return [];
|
||||
|
||||
const matches = normalized.match(/[^。!?!?]+[。!?!?]?/gu);
|
||||
return (matches ?? [normalized]).map(item => item.trim()).filter(Boolean);
|
||||
}
|
||||
|
||||
function normalizeRoleItemCategory(value: unknown, fallback = '材料') {
|
||||
const category = toText(value);
|
||||
if (CUSTOM_WORLD_ROLE_ITEM_CATEGORIES.has(category)) {
|
||||
return category === '专属物' ? '专属物品' : category;
|
||||
}
|
||||
if (/武器|刀|剑|弓|枪|炮|锤/u.test(category)) return '武器';
|
||||
if (/甲|护|盾|衣|袍/u.test(category)) return '护甲';
|
||||
if (/饰|符|佩|坠|珠|印/u.test(category)) return '饰品';
|
||||
if (/药|食|补给|瓶|卷轴/u.test(category)) return '消耗品';
|
||||
if (/材|矿|木|石|鳞|骨/u.test(category)) return '材料';
|
||||
if (/秘|钥|图|册|录|卷/u.test(category)) return '稀有品';
|
||||
if (/信物|遗物|核心|母印|真符/u.test(category)) return '专属物品';
|
||||
return fallback;
|
||||
}
|
||||
|
||||
function buildFallbackBackstoryReveal(source: CustomWorldRoleFallbackSource): CharacterBackstoryRevealConfig {
|
||||
const normalizedBackstory = source.backstory.trim() || `${source.name}对自己的过去仍有保留。`;
|
||||
const backstorySentences = splitNarrativeSentences(normalizedBackstory);
|
||||
const backstoryLead = backstorySentences[0] ?? normalizedBackstory;
|
||||
const backstoryDetail = backstorySentences.slice(0, 2).join('') || normalizedBackstory;
|
||||
const publicSummary = source.description.trim() || truncateText(normalizedBackstory, 42);
|
||||
const fallbackContents = [
|
||||
source.description.trim() || backstoryLead,
|
||||
backstoryDetail,
|
||||
source.motivation.trim()
|
||||
? `${source.name}真正挂念的,是:${source.motivation.trim()}`
|
||||
: `${source.name}的决定与“${truncateText(backstoryLead, 24)}”直接相关。`,
|
||||
source.personality.trim()
|
||||
? `${source.name}不会轻易说出的底色,是:${source.personality.trim()}`
|
||||
: `${source.name}仍把最深的筹码藏在过去里。`,
|
||||
];
|
||||
|
||||
return {
|
||||
publicSummary,
|
||||
privateChatUnlockAffinity: DEFAULT_PRIVATE_CHAT_UNLOCK_AFFINITY,
|
||||
chapters: CUSTOM_WORLD_BACKSTORY_CHAPTER_AFFINITIES.map((affinityRequired, index) => ({
|
||||
id: `saved-backstory-${index + 1}`,
|
||||
title: CUSTOM_WORLD_BACKSTORY_CHAPTER_TITLES[index] ?? `背景片段${index + 1}`,
|
||||
affinityRequired,
|
||||
teaser: truncateText(fallbackContents[index] ?? normalizedBackstory, 22),
|
||||
content: truncateText(fallbackContents[index] ?? normalizedBackstory, 72),
|
||||
contextSnippet: truncateText(
|
||||
`${source.name}的背景正在向“${fallbackContents[index] ?? normalizedBackstory}”这条线索展开。`,
|
||||
48,
|
||||
),
|
||||
}) satisfies CharacterBackstoryChapter),
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeBackstoryReveal(
|
||||
value: unknown,
|
||||
fallbackSource: CustomWorldRoleFallbackSource,
|
||||
) {
|
||||
const fallback = buildFallbackBackstoryReveal(fallbackSource);
|
||||
if (!isRecord(value)) {
|
||||
return fallback;
|
||||
}
|
||||
|
||||
const rawChapters = Array.isArray(value.chapters)
|
||||
? value.chapters.filter(isRecord)
|
||||
: [];
|
||||
|
||||
return {
|
||||
publicSummary: toText(value.publicSummary, fallback.publicSummary),
|
||||
privateChatUnlockAffinity:
|
||||
typeof value.privateChatUnlockAffinity === 'number' && Number.isFinite(value.privateChatUnlockAffinity)
|
||||
? normalizeInitialAffinity(value.privateChatUnlockAffinity, DEFAULT_PRIVATE_CHAT_UNLOCK_AFFINITY)
|
||||
: fallback.privateChatUnlockAffinity,
|
||||
chapters: CUSTOM_WORLD_BACKSTORY_CHAPTER_AFFINITIES.map((defaultAffinity, index) => {
|
||||
const rawChapter = rawChapters[index];
|
||||
const fallbackChapter = fallback.chapters[index];
|
||||
return {
|
||||
id: rawChapter ? toText(rawChapter.id, fallbackChapter?.id) : fallbackChapter?.id ?? `saved-backstory-${index + 1}`,
|
||||
title: rawChapter ? toText(rawChapter.title, fallbackChapter?.title) : fallbackChapter?.title ?? `背景片段${index + 1}`,
|
||||
affinityRequired: fallbackChapter?.affinityRequired ?? defaultAffinity,
|
||||
teaser: rawChapter ? toText(rawChapter.teaser, fallbackChapter?.teaser) : fallbackChapter?.teaser ?? '',
|
||||
content: rawChapter ? toText(rawChapter.content, fallbackChapter?.content) : fallbackChapter?.content ?? '',
|
||||
contextSnippet: rawChapter ? toText(rawChapter.contextSnippet, fallbackChapter?.contextSnippet) : fallbackChapter?.contextSnippet ?? '',
|
||||
} satisfies CharacterBackstoryChapter;
|
||||
}),
|
||||
} satisfies CharacterBackstoryRevealConfig;
|
||||
}
|
||||
|
||||
function buildFallbackRoleSkills(source: CustomWorldRoleFallbackSource) {
|
||||
const nameSeed = source.title || source.role || source.name || '角色';
|
||||
return [
|
||||
{
|
||||
id: 'saved-role-skill-1',
|
||||
name: `${nameSeed}起手`,
|
||||
summary: truncateText(source.combatStyle || `${source.name}擅长稳住局面。`, 36),
|
||||
style: '起手压制',
|
||||
},
|
||||
{
|
||||
id: 'saved-role-skill-2',
|
||||
name: `${nameSeed}变招`,
|
||||
summary: truncateText(source.personality || `${source.name}习惯在周旋中找破绽。`, 36),
|
||||
style: '机动周旋',
|
||||
},
|
||||
{
|
||||
id: 'saved-role-skill-3',
|
||||
name: `${nameSeed}底牌`,
|
||||
summary: truncateText(source.motivation || `${source.name}会在关键时刻亮出压箱手段。`, 36),
|
||||
style: '爆发终结',
|
||||
},
|
||||
] satisfies CustomWorldRoleSkill[];
|
||||
}
|
||||
|
||||
function normalizeRoleSkills(
|
||||
value: unknown,
|
||||
fallbackSource: CustomWorldRoleFallbackSource,
|
||||
) {
|
||||
const normalized = Array.isArray(value)
|
||||
? value
|
||||
.filter(isRecord)
|
||||
.map((entry, index) => ({
|
||||
id: toText(entry.id, `saved-role-skill-${index + 1}`),
|
||||
name: toText(entry.name),
|
||||
summary: toText(entry.summary, toText(entry.description)),
|
||||
style: toText(entry.style, toText(entry.category, '常用')),
|
||||
} satisfies CustomWorldRoleSkill))
|
||||
.filter(entry => entry.name)
|
||||
.slice(0, 3)
|
||||
: [];
|
||||
|
||||
return normalized.length > 0 ? normalized : buildFallbackRoleSkills(fallbackSource);
|
||||
}
|
||||
|
||||
function buildFallbackRoleInitialItems(source: CustomWorldRoleFallbackSource) {
|
||||
const itemSeed = source.title || source.role || source.name || '角色';
|
||||
return [
|
||||
{
|
||||
id: 'saved-role-item-1',
|
||||
name: `${itemSeed}常备武具`,
|
||||
category: '武器',
|
||||
quantity: 1,
|
||||
rarity: 'rare',
|
||||
description: truncateText(source.combatStyle || `${source.name}随身携带的主要作战物件。`, 36),
|
||||
tags: source.tags.slice(0, 2),
|
||||
},
|
||||
{
|
||||
id: 'saved-role-item-2',
|
||||
name: `${itemSeed}补给包`,
|
||||
category: '消耗品',
|
||||
quantity: 2,
|
||||
rarity: 'uncommon',
|
||||
description: truncateText(source.personality || `${source.name}为长期行动准备的基础补给。`, 36),
|
||||
tags: source.relationshipHooks.slice(0, 2),
|
||||
},
|
||||
{
|
||||
id: 'saved-role-item-3',
|
||||
name: `${itemSeed}私人物件`,
|
||||
category: '专属物品',
|
||||
quantity: 1,
|
||||
rarity: 'rare',
|
||||
description: truncateText(source.backstory || source.motivation || `${source.name}不愿随意交出的信物。`, 36),
|
||||
tags: [...source.tags, ...source.relationshipHooks].slice(0, 3),
|
||||
},
|
||||
] satisfies CustomWorldRoleInitialItem[];
|
||||
}
|
||||
|
||||
function normalizeRoleInitialItems(
|
||||
value: unknown,
|
||||
fallbackSource: CustomWorldRoleFallbackSource,
|
||||
) {
|
||||
const normalized = Array.isArray(value)
|
||||
? value
|
||||
.filter(isRecord)
|
||||
.map((entry, index) => ({
|
||||
id: toText(entry.id, `saved-role-item-${index + 1}`),
|
||||
name: toText(entry.name),
|
||||
category: normalizeRoleItemCategory(entry.category),
|
||||
quantity:
|
||||
typeof entry.quantity === 'number' && Number.isFinite(entry.quantity)
|
||||
? Math.max(1, Math.min(99, Math.round(entry.quantity)))
|
||||
: 1,
|
||||
rarity: typeof entry.rarity === 'string' && ITEM_RARITIES.has(entry.rarity as ItemRarity)
|
||||
? entry.rarity as ItemRarity
|
||||
: 'rare',
|
||||
description: toText(entry.description),
|
||||
tags: toStringArray(entry.tags),
|
||||
} satisfies CustomWorldRoleInitialItem))
|
||||
.filter(entry => entry.name)
|
||||
.slice(0, 3)
|
||||
: [];
|
||||
|
||||
return normalized.length > 0
|
||||
? normalized
|
||||
: buildFallbackRoleInitialItems(fallbackSource);
|
||||
}
|
||||
|
||||
function normalizeEquipmentSlot(value: unknown) {
|
||||
return typeof value === 'string' && EQUIPMENT_SLOTS.has(value as EquipmentSlotId)
|
||||
? value as EquipmentSlotId
|
||||
@@ -144,9 +387,7 @@ function normalizePlayableNpc(value: unknown, index: number): CustomWorldPlayabl
|
||||
const role = toText(value.role, title);
|
||||
const relationshipHooks = toStringArray(value.relationshipHooks);
|
||||
const tags = toStringArray(value.tags);
|
||||
|
||||
return {
|
||||
id: toText(value.id, `saved-playable-${index + 1}`),
|
||||
const fallbackSource = {
|
||||
name,
|
||||
title,
|
||||
role,
|
||||
@@ -155,9 +396,26 @@ function normalizePlayableNpc(value: unknown, index: number): CustomWorldPlayabl
|
||||
personality: toText(value.personality),
|
||||
motivation: toText(value.motivation, toText(value.description)),
|
||||
combatStyle: toText(value.combatStyle),
|
||||
initialAffinity: normalizeInitialAffinity(value.initialAffinity, DEFAULT_PLAYABLE_INITIAL_AFFINITY),
|
||||
relationshipHooks: relationshipHooks.length > 0 ? relationshipHooks : tags.slice(0, 3),
|
||||
tags: tags.length > 0 ? tags : relationshipHooks.slice(0, 5),
|
||||
} satisfies CustomWorldRoleFallbackSource;
|
||||
|
||||
return {
|
||||
id: toText(value.id, `saved-playable-${index + 1}`),
|
||||
name,
|
||||
title,
|
||||
role,
|
||||
description: fallbackSource.description,
|
||||
backstory: fallbackSource.backstory,
|
||||
personality: fallbackSource.personality,
|
||||
motivation: fallbackSource.motivation,
|
||||
combatStyle: fallbackSource.combatStyle,
|
||||
initialAffinity: normalizeInitialAffinity(value.initialAffinity, DEFAULT_PLAYABLE_INITIAL_AFFINITY),
|
||||
relationshipHooks: fallbackSource.relationshipHooks,
|
||||
tags: fallbackSource.tags,
|
||||
backstoryReveal: normalizeBackstoryReveal(value.backstoryReveal, fallbackSource),
|
||||
skills: normalizeRoleSkills(value.skills, fallbackSource),
|
||||
initialItems: normalizeRoleInitialItems(value.initialItems, fallbackSource),
|
||||
templateCharacterId: toText(value.templateCharacterId) || undefined,
|
||||
};
|
||||
}
|
||||
@@ -171,9 +429,7 @@ function normalizeStoryNpc(value: unknown, index: number): CustomWorldNpc | null
|
||||
const role = toText(value.role, title);
|
||||
const relationshipHooks = toStringArray(value.relationshipHooks);
|
||||
const tags = toStringArray(value.tags);
|
||||
|
||||
return {
|
||||
id: toText(value.id, `saved-story-${index + 1}`),
|
||||
const fallbackSource = {
|
||||
name,
|
||||
title,
|
||||
role,
|
||||
@@ -182,9 +438,26 @@ function normalizeStoryNpc(value: unknown, index: number): CustomWorldNpc | null
|
||||
personality: toText(value.personality),
|
||||
motivation: toText(value.motivation),
|
||||
combatStyle: toText(value.combatStyle),
|
||||
initialAffinity: normalizeInitialAffinity(value.initialAffinity, DEFAULT_STORY_NPC_INITIAL_AFFINITY),
|
||||
relationshipHooks: relationshipHooks.length > 0 ? relationshipHooks : tags.slice(0, 3),
|
||||
tags: tags.length > 0 ? tags : relationshipHooks.slice(0, 5),
|
||||
} satisfies CustomWorldRoleFallbackSource;
|
||||
|
||||
return {
|
||||
id: toText(value.id, `saved-story-${index + 1}`),
|
||||
name,
|
||||
title,
|
||||
role,
|
||||
description: fallbackSource.description,
|
||||
backstory: fallbackSource.backstory,
|
||||
personality: fallbackSource.personality,
|
||||
motivation: fallbackSource.motivation,
|
||||
combatStyle: fallbackSource.combatStyle,
|
||||
initialAffinity: normalizeInitialAffinity(value.initialAffinity, DEFAULT_STORY_NPC_INITIAL_AFFINITY),
|
||||
relationshipHooks: fallbackSource.relationshipHooks,
|
||||
tags: fallbackSource.tags,
|
||||
backstoryReveal: normalizeBackstoryReveal(value.backstoryReveal, fallbackSource),
|
||||
skills: normalizeRoleSkills(value.skills, fallbackSource),
|
||||
initialItems: normalizeRoleInitialItems(value.initialItems, fallbackSource),
|
||||
imageSrc: toText(value.imageSrc) || undefined,
|
||||
visual: normalizeCustomWorldNpcVisual(value.visual),
|
||||
};
|
||||
@@ -229,6 +502,49 @@ function normalizeLandmark(value: unknown, index: number): CustomWorldLandmark |
|
||||
description: toText(value.description),
|
||||
dangerLevel: toText(value.dangerLevel),
|
||||
imageSrc: toText(value.imageSrc) || undefined,
|
||||
sceneNpcIds: [],
|
||||
connections: [],
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeLandmarkDraft(
|
||||
value: unknown,
|
||||
index: number,
|
||||
): CustomWorldLandmarkDraft | null {
|
||||
if (!isRecord(value)) return null;
|
||||
|
||||
const normalizedLandmark = normalizeLandmark(value, index);
|
||||
if (!normalizedLandmark) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const rawConnections = Array.isArray(value.connections)
|
||||
? value.connections.filter(isRecord)
|
||||
: [];
|
||||
|
||||
return {
|
||||
...normalizedLandmark,
|
||||
sceneNpcIds: toStringArray(value.sceneNpcIds),
|
||||
sceneNpcNames: [
|
||||
...toStringArray(value.sceneNpcNames),
|
||||
...toStringArray(value.npcNames),
|
||||
...(Array.isArray(value.npcs)
|
||||
? value.npcs
|
||||
.filter(isRecord)
|
||||
.map((item) => toText(item.name))
|
||||
.filter(Boolean)
|
||||
: []),
|
||||
],
|
||||
connections: rawConnections.map((connection) => ({
|
||||
targetLandmarkId: toText(connection.targetLandmarkId),
|
||||
targetLandmarkName:
|
||||
toText(connection.targetLandmarkName) ||
|
||||
toText(connection.target) ||
|
||||
toText(connection.sceneName),
|
||||
relativePosition:
|
||||
toText(connection.relativePosition) || toText(connection.position),
|
||||
summary: toText(connection.summary) || toText(connection.description),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -256,6 +572,16 @@ function normalizeProfile(value: unknown): CustomWorldProfile | null {
|
||||
majorFactions: [],
|
||||
coreConflicts: [summary || playerGoal || settingText || name],
|
||||
});
|
||||
const storyNpcs = Array.isArray(value.storyNpcs)
|
||||
? value.storyNpcs
|
||||
.map((entry, index) => normalizeStoryNpc(entry, index))
|
||||
.filter((entry): entry is CustomWorldNpc => Boolean(entry))
|
||||
: [];
|
||||
const landmarkDrafts = Array.isArray(value.landmarks)
|
||||
? value.landmarks
|
||||
.map((entry, index) => normalizeLandmarkDraft(entry, index))
|
||||
.filter((entry): entry is CustomWorldLandmarkDraft => Boolean(entry))
|
||||
: [];
|
||||
|
||||
return {
|
||||
id: toText(value.id, `saved-custom-world-${Date.now().toString(36)}`),
|
||||
@@ -272,21 +598,16 @@ function normalizeProfile(value: unknown): CustomWorldProfile | null {
|
||||
.map((entry, index) => normalizePlayableNpc(entry, index))
|
||||
.filter((entry): entry is CustomWorldPlayableNpc => Boolean(entry))
|
||||
: [],
|
||||
storyNpcs: Array.isArray(value.storyNpcs)
|
||||
? value.storyNpcs
|
||||
.map((entry, index) => normalizeStoryNpc(entry, index))
|
||||
.filter((entry): entry is CustomWorldNpc => Boolean(entry))
|
||||
: [],
|
||||
storyNpcs,
|
||||
items: Array.isArray(value.items)
|
||||
? value.items
|
||||
.map((entry, index) => normalizeItem(entry, index))
|
||||
.filter((entry): entry is CustomWorldItem => Boolean(entry))
|
||||
: [],
|
||||
landmarks: Array.isArray(value.landmarks)
|
||||
? value.landmarks
|
||||
.map((entry, index) => normalizeLandmark(entry, index))
|
||||
.filter((entry): entry is CustomWorldLandmark => Boolean(entry))
|
||||
: [],
|
||||
landmarks: normalizeCustomWorldLandmarks({
|
||||
landmarks: landmarkDrafts,
|
||||
storyNpcs,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user