501
src/services/customWorld.ts
Normal file
501
src/services/customWorld.ts
Normal file
@@ -0,0 +1,501 @@
|
||||
import { coerceWorldAttributeSchema } from '../data/attributeValidation';
|
||||
import {
|
||||
CustomWorldItem,
|
||||
CustomWorldLandmark,
|
||||
CustomWorldNpc,
|
||||
CustomWorldPlayableNpc,
|
||||
CustomWorldProfile,
|
||||
ItemRarity,
|
||||
WorldType,
|
||||
} from '../types';
|
||||
import { generateWorldAttributeSchema } from './attributeSchemaGenerator';
|
||||
|
||||
const CUSTOM_WORLD_RARITIES: ItemRarity[] = [
|
||||
'common',
|
||||
'uncommon',
|
||||
'rare',
|
||||
'epic',
|
||||
'legendary',
|
||||
];
|
||||
|
||||
export const MIN_CUSTOM_WORLD_PLAYABLE_NPC_COUNT = 5;
|
||||
export const MIN_CUSTOM_WORLD_TOTAL_NPC_COUNT = 30;
|
||||
export const MIN_CUSTOM_WORLD_LANDMARK_COUNT = 10;
|
||||
|
||||
export const CUSTOM_WORLD_GENERATION_SYSTEM_PROMPT = `你正在为像素动作 RPG 设计一份自定义世界档案。
|
||||
只返回一个 JSON 对象,不要返回 Markdown、代码块或额外解释。
|
||||
|
||||
输出结构:
|
||||
{
|
||||
"name": "世界名称",
|
||||
"subtitle": "世界副标题",
|
||||
"summary": "世界概述",
|
||||
"tone": "世界基调",
|
||||
"playerGoal": "玩家核心目标",
|
||||
"templateWorldType": "WUXIA 或 XIANXIA",
|
||||
"majorFactions": ["势力甲", "势力乙"],
|
||||
"coreConflicts": ["冲突甲", "冲突乙"],
|
||||
"playableNpcs": [
|
||||
{
|
||||
"name": "角色名称",
|
||||
"title": "称号",
|
||||
"description": "简短描述",
|
||||
"backstory": "背景经历",
|
||||
"personality": "性格特点",
|
||||
"combatStyle": "战斗风格",
|
||||
"tags": ["标签1", "标签2"]
|
||||
}
|
||||
],
|
||||
"storyNpcs": [
|
||||
{
|
||||
"name": "场景角色名称",
|
||||
"role": "身份",
|
||||
"description": "简短描述",
|
||||
"motivation": "动机",
|
||||
"relationshipHooks": ["关系切入口1", "关系切入口2"]
|
||||
}
|
||||
],
|
||||
"landmarks": [
|
||||
{
|
||||
"name": "场景名称",
|
||||
"description": "场景描述",
|
||||
"dangerLevel": "low|medium|high|extreme"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
硬性要求:
|
||||
- 所有生成文本都必须使用中文。
|
||||
- 每个场景角色和地标都必须直接源自玩家设定。
|
||||
- 不要用无关的武侠/仙侠预设素材来凑数。
|
||||
- 必须生成恰好 5 个 playableNpcs。
|
||||
- 必须生成足够多的 storyNpcs,使唯一角色总数至少达到 30。
|
||||
- 至少生成 10 个 landmarks。
|
||||
- 不要生成 items 字段。
|
||||
- 名称必须具体且有辨识度,不要使用 角色1、场景1 之类的占位名。
|
||||
- 名册中要覆盖多种社会身份,不能只有战斗角色。
|
||||
- 地标必须像真实可游玩的场景,能够承载探索、战斗、旅行和剧情推进。
|
||||
- 不要引用现实品牌、受版权保护的 IP 或知名既有人物。`;
|
||||
|
||||
function toText(value: unknown) {
|
||||
return typeof value === 'string' ? value.trim() : '';
|
||||
}
|
||||
|
||||
function toRecordArray(value: unknown) {
|
||||
return Array.isArray(value)
|
||||
? (value.filter((item) => item && typeof item === 'object') as Array<
|
||||
Record<string, unknown>
|
||||
>)
|
||||
: [];
|
||||
}
|
||||
|
||||
function normalizeTags(value: unknown, fallbackTags: string[] = []) {
|
||||
const tags = Array.isArray(value)
|
||||
? value.map((item) => toText(item)).filter(Boolean)
|
||||
: [];
|
||||
return [
|
||||
...new Set((tags.length > 0 ? tags : fallbackTags).filter(Boolean)),
|
||||
].slice(0, 5);
|
||||
}
|
||||
|
||||
function normalizeWorldType(value: unknown, sourceText: string) {
|
||||
const worldType = toText(value).toUpperCase();
|
||||
if (worldType === WorldType.WUXIA || worldType === WorldType.XIANXIA) {
|
||||
return worldType;
|
||||
}
|
||||
return inferWorldTypeFromSetting(sourceText);
|
||||
}
|
||||
|
||||
function normalizeRarity(
|
||||
value: unknown,
|
||||
fallback: ItemRarity = 'rare',
|
||||
): ItemRarity {
|
||||
const rarity = toText(value).toLowerCase() as ItemRarity;
|
||||
return CUSTOM_WORLD_RARITIES.includes(rarity) ? rarity : fallback;
|
||||
}
|
||||
|
||||
function slugify(value: string) {
|
||||
const ascii = value
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9\u4e00-\u9fa5]+/g, '-')
|
||||
.replace(/^-+|-+$/g, '');
|
||||
|
||||
if (ascii) {
|
||||
return ascii.slice(0, 24);
|
||||
}
|
||||
|
||||
return 'entry';
|
||||
}
|
||||
|
||||
function createEntryId(prefix: string, label: string, index: number) {
|
||||
return `${prefix}-${slugify(label || `${prefix}-${index + 1}`)}-${index + 1}`;
|
||||
}
|
||||
|
||||
function inferWorldTypeFromSetting(settingText: string) {
|
||||
if (/[仙灵修真飞升灵脉宗门法器天宫秘境道骨星舰]/u.test(settingText)) {
|
||||
return WorldType.XIANXIA;
|
||||
}
|
||||
return WorldType.WUXIA;
|
||||
}
|
||||
|
||||
function buildSeedPhrase(settingText: string, fallback: string) {
|
||||
const compact = settingText.replace(/\s+/g, '').trim();
|
||||
return compact ? compact.slice(0, 10) : fallback;
|
||||
}
|
||||
|
||||
function buildWorldName(settingText: string, worldType: WorldType) {
|
||||
const seed = buildSeedPhrase(
|
||||
settingText,
|
||||
worldType === WorldType.XIANXIA ? '灵潮' : '江湖',
|
||||
);
|
||||
const suffix = worldType === WorldType.XIANXIA ? '界' : '录';
|
||||
return `${seed}${suffix}`;
|
||||
}
|
||||
|
||||
function buildBaseCustomWorldProfile(settingText: string): CustomWorldProfile {
|
||||
const templateWorldType = inferWorldTypeFromSetting(settingText);
|
||||
const name = buildWorldName(settingText, templateWorldType);
|
||||
const subtitle =
|
||||
templateWorldType === WorldType.XIANXIA ? '灵潮未定' : '风云将起';
|
||||
const summary = settingText.trim()
|
||||
? `这个世界围绕“${settingText.trim().slice(0, 28)}”展开。`
|
||||
: templateWorldType === WorldType.XIANXIA
|
||||
? '灵潮未定,旧秩序正在崩裂。'
|
||||
: '旧案复起,江湖格局正在改变。';
|
||||
const tone =
|
||||
templateWorldType === WorldType.XIANXIA
|
||||
? '空灵、危险、层层递进'
|
||||
: '紧张、克制、暗流涌动';
|
||||
const playerGoal =
|
||||
templateWorldType === WorldType.XIANXIA
|
||||
? '查清异变源头,在诸方势力之前抢到关键线索'
|
||||
: '沿着旧案痕迹追查幕后之人,并守住仍值得相信的人与路';
|
||||
|
||||
return {
|
||||
id: `custom-world-${Date.now().toString(36)}-${slugify(name)}`,
|
||||
settingText: settingText.trim(),
|
||||
name,
|
||||
subtitle,
|
||||
summary,
|
||||
tone,
|
||||
playerGoal,
|
||||
templateWorldType,
|
||||
attributeSchema: generateWorldAttributeSchema({
|
||||
worldType: WorldType.CUSTOM,
|
||||
worldName: name,
|
||||
settingText: settingText.trim(),
|
||||
summary,
|
||||
tone,
|
||||
playerGoal,
|
||||
majorFactions: [],
|
||||
coreConflicts: [summary],
|
||||
}),
|
||||
playableNpcs: [],
|
||||
storyNpcs: [],
|
||||
items: [],
|
||||
landmarks: [],
|
||||
};
|
||||
}
|
||||
|
||||
export function buildFallbackCustomWorldProfile(
|
||||
settingText: string,
|
||||
): CustomWorldProfile {
|
||||
return buildBaseCustomWorldProfile(settingText);
|
||||
}
|
||||
|
||||
function normalizePlayableNpcList(value: unknown) {
|
||||
return toRecordArray(value)
|
||||
.map((item, index) => {
|
||||
const name = toText(item.name);
|
||||
return {
|
||||
id: createEntryId('playable-npc', name, index),
|
||||
name,
|
||||
title: toText(item.title),
|
||||
description: toText(item.description),
|
||||
backstory: toText(item.backstory),
|
||||
personality: toText(item.personality),
|
||||
combatStyle: toText(item.combatStyle),
|
||||
tags: normalizeTags(item.tags),
|
||||
} satisfies CustomWorldPlayableNpc;
|
||||
})
|
||||
.filter((entry) => entry.name)
|
||||
.slice(0, MIN_CUSTOM_WORLD_PLAYABLE_NPC_COUNT);
|
||||
}
|
||||
|
||||
function normalizeStoryNpcList(value: unknown) {
|
||||
return toRecordArray(value)
|
||||
.map((item, index) => {
|
||||
const name = toText(item.name);
|
||||
return {
|
||||
id: createEntryId('story-npc', name, index),
|
||||
name,
|
||||
role: toText(item.role),
|
||||
description: toText(item.description),
|
||||
motivation: toText(item.motivation),
|
||||
relationshipHooks: normalizeTags(item.relationshipHooks),
|
||||
} satisfies CustomWorldNpc;
|
||||
})
|
||||
.filter((entry) => entry.name);
|
||||
}
|
||||
|
||||
function normalizeItemList(value: unknown) {
|
||||
return toRecordArray(value)
|
||||
.map((item, index) => {
|
||||
const name = toText(item.name);
|
||||
const category = toText(item.category);
|
||||
return {
|
||||
id: createEntryId('item', name, index),
|
||||
name,
|
||||
category,
|
||||
rarity: normalizeRarity(item.rarity, 'rare'),
|
||||
description: toText(item.description),
|
||||
tags: normalizeTags(item.tags),
|
||||
} satisfies CustomWorldItem;
|
||||
})
|
||||
.filter((entry) => entry.name && entry.category);
|
||||
}
|
||||
|
||||
function normalizeLandmarkList(value: unknown) {
|
||||
return toRecordArray(value)
|
||||
.map((item, index) => {
|
||||
const name = toText(item.name);
|
||||
return {
|
||||
id: createEntryId('landmark', name, index),
|
||||
name,
|
||||
description: toText(item.description),
|
||||
dangerLevel: toText(item.dangerLevel),
|
||||
} satisfies CustomWorldLandmark;
|
||||
})
|
||||
.filter((entry) => entry.name);
|
||||
}
|
||||
|
||||
export function normalizeCustomWorldProfile(
|
||||
raw: unknown,
|
||||
settingText: string,
|
||||
): CustomWorldProfile {
|
||||
const fallback = buildBaseCustomWorldProfile(settingText);
|
||||
if (!raw || typeof raw !== 'object') {
|
||||
return fallback;
|
||||
}
|
||||
|
||||
const item = raw as Record<string, unknown>;
|
||||
const worldSignalText = [
|
||||
settingText,
|
||||
toText(item.subtitle),
|
||||
toText(item.summary),
|
||||
toText(item.tone),
|
||||
toText(item.playerGoal),
|
||||
].join(' ');
|
||||
const templateWorldType = normalizeWorldType(
|
||||
item.templateWorldType,
|
||||
worldSignalText,
|
||||
);
|
||||
const name =
|
||||
toText(item.name) || buildWorldName(settingText, templateWorldType);
|
||||
const summary = toText(item.summary) || fallback.summary;
|
||||
const tone = toText(item.tone) || fallback.tone;
|
||||
const playerGoal = toText(item.playerGoal) || fallback.playerGoal;
|
||||
const generatedAttributeSchema = generateWorldAttributeSchema({
|
||||
worldType: WorldType.CUSTOM,
|
||||
worldName: name,
|
||||
settingText: settingText.trim(),
|
||||
summary,
|
||||
tone,
|
||||
playerGoal,
|
||||
majorFactions: normalizeTags(item.majorFactions, []),
|
||||
coreConflicts: normalizeTags(item.coreConflicts, [summary]),
|
||||
});
|
||||
|
||||
return {
|
||||
id: `custom-world-${Date.now().toString(36)}-${slugify(name)}`,
|
||||
settingText: settingText.trim(),
|
||||
name,
|
||||
subtitle: toText(item.subtitle) || fallback.subtitle,
|
||||
summary,
|
||||
tone,
|
||||
playerGoal,
|
||||
templateWorldType,
|
||||
attributeSchema: coerceWorldAttributeSchema(
|
||||
item.attributeSchema,
|
||||
generatedAttributeSchema,
|
||||
),
|
||||
playableNpcs: normalizePlayableNpcList(item.playableNpcs),
|
||||
storyNpcs: normalizeStoryNpcList(item.storyNpcs),
|
||||
items: normalizeItemList(item.items),
|
||||
landmarks: normalizeLandmarkList(item.landmarks),
|
||||
};
|
||||
}
|
||||
|
||||
export function buildCustomWorldGenerationPrompt(settingText: string) {
|
||||
return [
|
||||
'请根据下面的玩家设定创建一份自定义世界档案。',
|
||||
'玩家设定:',
|
||||
settingText.trim(),
|
||||
'',
|
||||
'要求:',
|
||||
'- 所有生成文本都必须使用中文。',
|
||||
'- 不要复用预设流派模板来凑数。',
|
||||
'- 必须生成恰好 5 个 playableNpcs。',
|
||||
'- 必须生成足够多的 storyNpcs,使唯一角色总数至少达到 30。',
|
||||
'- 至少生成 10 个真正可游玩的 landmarks。',
|
||||
'- 不要生成任何 items,也不要包含 items 字段。',
|
||||
'- 每个场景角色和地标都必须直接源自玩家设定。',
|
||||
'- 要覆盖多种社会身份,不能只有战斗角色。',
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
export function buildCustomWorldReferenceText(profile: CustomWorldProfile) {
|
||||
const playableNpcText = profile.playableNpcs
|
||||
.slice(0, 3)
|
||||
.map(
|
||||
(npc) =>
|
||||
`- ${npc.name} / ${npc.title}:${npc.description};背景:${npc.backstory};风格:${npc.combatStyle}`,
|
||||
)
|
||||
.join('\n');
|
||||
const storyNpcText = profile.storyNpcs
|
||||
.slice(0, 8)
|
||||
.map(
|
||||
(npc) =>
|
||||
`- ${npc.name} / ${npc.role}:${npc.description};动机:${npc.motivation}`,
|
||||
)
|
||||
.join('\n');
|
||||
const landmarkText = profile.landmarks
|
||||
.slice(0, 10)
|
||||
.map(
|
||||
(landmark) =>
|
||||
`- ${landmark.name}:${landmark.description};危险度:${landmark.dangerLevel}`,
|
||||
)
|
||||
.join('\n');
|
||||
|
||||
return [
|
||||
`自定义世界:${profile.name}`,
|
||||
`副标题:${profile.subtitle}`,
|
||||
`玩家原始设定:${profile.settingText}`,
|
||||
`世界概述:${profile.summary}`,
|
||||
`世界基调:${profile.tone}`,
|
||||
`玩家核心目标:${profile.playerGoal}`,
|
||||
`世界属性轴:${profile.attributeSchema.slots.map((slot) => `${slot.name}:${slot.definition}`).join(';')}`,
|
||||
`可扮演角色档案:\n${playableNpcText || '- 暂无'}`,
|
||||
`世界场景角色档案:\n${storyNpcText || '- 暂无'}`,
|
||||
`关键场景档案:\n${landmarkText || '- 暂无'}`,
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
function countUniqueNames(items: Array<{ name: string }>) {
|
||||
return new Set(items.map((item) => item.name.trim()).filter(Boolean)).size;
|
||||
}
|
||||
|
||||
export function validateGeneratedCustomWorldProfile(
|
||||
profile: CustomWorldProfile,
|
||||
) {
|
||||
const playableCount = countUniqueNames(profile.playableNpcs);
|
||||
const storyCount = countUniqueNames(profile.storyNpcs);
|
||||
const landmarkCount = countUniqueNames(profile.landmarks);
|
||||
const totalNpcCount = countUniqueNames([
|
||||
...profile.playableNpcs,
|
||||
...profile.storyNpcs,
|
||||
]);
|
||||
|
||||
if (playableCount < MIN_CUSTOM_WORLD_PLAYABLE_NPC_COUNT) {
|
||||
throw new Error(
|
||||
`自定义世界生成要求模型至少产出 ${MIN_CUSTOM_WORLD_PLAYABLE_NPC_COUNT} 名可扮演角色。`,
|
||||
);
|
||||
}
|
||||
|
||||
if (totalNpcCount < MIN_CUSTOM_WORLD_TOTAL_NPC_COUNT) {
|
||||
throw new Error(
|
||||
`自定义世界生成要求模型至少产出 ${MIN_CUSTOM_WORLD_TOTAL_NPC_COUNT} 名唯一角色,当前仅返回 ${totalNpcCount} 名。`,
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
storyCount <
|
||||
Math.max(
|
||||
0,
|
||||
MIN_CUSTOM_WORLD_TOTAL_NPC_COUNT - MIN_CUSTOM_WORLD_PLAYABLE_NPC_COUNT,
|
||||
)
|
||||
) {
|
||||
throw new Error('自定义世界生成返回的非可扮演场景角色数量不足。');
|
||||
}
|
||||
|
||||
if (landmarkCount < MIN_CUSTOM_WORLD_LANDMARK_COUNT) {
|
||||
throw new Error(
|
||||
`自定义世界生成要求至少产出 ${MIN_CUSTOM_WORLD_LANDMARK_COUNT} 个场景,当前仅返回 ${landmarkCount} 个。`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function clampSceneImageText(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 describeDangerLevel(dangerLevel: string) {
|
||||
const normalized = dangerLevel.trim().toLowerCase();
|
||||
if (normalized === 'low' || normalized === '低')
|
||||
return '气氛相对平静,但暗藏细节张力';
|
||||
if (normalized === 'medium' || normalized === '中')
|
||||
return '带有明确的探索压力与潜在威胁';
|
||||
if (normalized === 'high' || normalized === '高')
|
||||
return '危险感强烈,空间中有明显压迫感';
|
||||
if (normalized === 'extreme' || normalized === '极高')
|
||||
return '极端危险,环境本身就像会吞没闯入者';
|
||||
return dangerLevel.trim()
|
||||
? `危险氛围:${dangerLevel.trim()}`
|
||||
: '危险气质保持克制但不可忽视';
|
||||
}
|
||||
|
||||
export const DEFAULT_CUSTOM_WORLD_SCENE_IMAGE_NEGATIVE_PROMPT = [
|
||||
'文字',
|
||||
'水印',
|
||||
'logo',
|
||||
'UI界面',
|
||||
'对话框',
|
||||
'边框',
|
||||
'人物近景特写',
|
||||
'多人合照',
|
||||
'模糊',
|
||||
'低清晰度',
|
||||
'畸形建筑',
|
||||
'现代车辆',
|
||||
'监控摄像头',
|
||||
].join(',');
|
||||
|
||||
export function buildCustomWorldSceneImagePrompt(
|
||||
profile: Pick<
|
||||
CustomWorldProfile,
|
||||
'name' | 'subtitle' | 'summary' | 'tone' | 'playerGoal' | 'settingText'
|
||||
>,
|
||||
landmark: Pick<CustomWorldLandmark, 'name' | 'description' | 'dangerLevel'>,
|
||||
) {
|
||||
const worldName = clampSceneImageText(profile.name, 18) || '未命名世界';
|
||||
const worldSubtitle = clampSceneImageText(profile.subtitle, 18);
|
||||
const worldTone = clampSceneImageText(profile.tone, 48);
|
||||
const worldGoal = clampSceneImageText(profile.playerGoal, 48);
|
||||
const worldSummary = clampSceneImageText(profile.summary, 72);
|
||||
const worldSetting = clampSceneImageText(profile.settingText, 72);
|
||||
const landmarkName = clampSceneImageText(landmark.name, 18) || '未命名场景';
|
||||
const landmarkDescription = clampSceneImageText(landmark.description, 96);
|
||||
const dangerMood = describeDangerLevel(landmark.dangerLevel);
|
||||
|
||||
return [
|
||||
'横版幻想 RPG 场景背景概念图,适合作为 2D 游戏战斗与探索背景,环境主体清晰,空间层次明确,电影感光影,细节丰富。',
|
||||
`世界:${worldName}${worldSubtitle ? `,${worldSubtitle}` : ''}。`,
|
||||
worldSetting ? `玩家设定:${worldSetting}。` : '',
|
||||
worldSummary ? `世界概述:${worldSummary}。` : '',
|
||||
worldTone ? `整体基调:${worldTone}。` : '',
|
||||
worldGoal ? `玩家目标关联:${worldGoal}。` : '',
|
||||
`场景名称:${landmarkName}。`,
|
||||
landmarkDescription ? `场景描述:${landmarkDescription}。` : '',
|
||||
`${dangerMood}。`,
|
||||
'不要出现 UI、字幕、文字、水印或 logo,人物仅可作为很小的远景剪影,画面重点放在建筑、地貌、光线与氛围。',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join('');
|
||||
}
|
||||
Reference in New Issue
Block a user