初始仓库迁移
Some checks failed
CI / verify (push) Has been cancelled

This commit is contained in:
2026-04-04 23:57:06 +08:00
parent 80986b790d
commit c49c64896a
18446 changed files with 532435 additions and 2 deletions

501
src/services/customWorld.ts Normal file
View 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('');
}