249 lines
6.8 KiB
TypeScript
249 lines
6.8 KiB
TypeScript
import type {
|
||
CustomWorldCoverProfile,
|
||
CustomWorldCoverSourceType,
|
||
CustomWorldItem,
|
||
CustomWorldPlayableNpc,
|
||
} from '../runtimeTypes.js';
|
||
|
||
/**
|
||
* 工作包 G:
|
||
* 把 runtime profile 编译流程里的通用常量、基础文本归一和通用小型归一逻辑收口到共享模块,
|
||
* 让角色、地点、场景章节和主编译入口都不再重复维护这些基础能力。
|
||
*/
|
||
|
||
const MIN_CUSTOM_WORLD_AFFINITY = -40;
|
||
const MAX_CUSTOM_WORLD_AFFINITY = 90;
|
||
const CUSTOM_WORLD_RARITIES = [
|
||
'common',
|
||
'uncommon',
|
||
'rare',
|
||
'epic',
|
||
'legendary',
|
||
] as const;
|
||
const CUSTOM_WORLD_ROLE_ITEM_CATEGORIES = [
|
||
'武器',
|
||
'护甲',
|
||
'饰品',
|
||
'消耗品',
|
||
'材料',
|
||
'稀有品',
|
||
'专属物品',
|
||
'专属物',
|
||
] as const;
|
||
|
||
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 MIN_CUSTOM_WORLD_STORY_NPC_COUNT = Math.max(
|
||
0,
|
||
MIN_CUSTOM_WORLD_TOTAL_NPC_COUNT - MIN_CUSTOM_WORLD_PLAYABLE_NPC_COUNT,
|
||
);
|
||
|
||
export const PLAYABLE_TEMPLATE_CHARACTER_IDS = [
|
||
'sword-princess',
|
||
'archer-hero',
|
||
'girl-hero',
|
||
'punch-hero',
|
||
'fighter-4',
|
||
] as const;
|
||
|
||
export function toText(value: unknown) {
|
||
return typeof value === 'string' ? value.trim() : '';
|
||
}
|
||
|
||
export function toFiniteInteger(value: unknown) {
|
||
return typeof value === 'number' && Number.isFinite(value)
|
||
? Math.round(value)
|
||
: undefined;
|
||
}
|
||
|
||
export function toRecord(value: unknown) {
|
||
return value && typeof value === 'object' && !Array.isArray(value)
|
||
? (value as Record<string, unknown>)
|
||
: null;
|
||
}
|
||
|
||
export function toRecordArray(value: unknown) {
|
||
return Array.isArray(value)
|
||
? (value.filter((item) => item && typeof item === 'object') as Array<
|
||
Record<string, unknown>
|
||
>)
|
||
: [];
|
||
}
|
||
|
||
export function toStringArray(value: unknown, nestedKey?: string) {
|
||
if (!Array.isArray(value)) {
|
||
return [];
|
||
}
|
||
|
||
return value
|
||
.map((item) => {
|
||
if (typeof item === 'string') {
|
||
return item.trim();
|
||
}
|
||
if (nestedKey && item && typeof item === 'object') {
|
||
return toText((item as Record<string, unknown>)[nestedKey]);
|
||
}
|
||
return '';
|
||
})
|
||
.filter(Boolean);
|
||
}
|
||
|
||
export 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);
|
||
}
|
||
|
||
export function clampText(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()}…`;
|
||
}
|
||
|
||
export function slugify(value: string) {
|
||
const ascii = value
|
||
.toLowerCase()
|
||
.replace(/[^a-z0-9\u4e00-\u9fa5]+/g, '-')
|
||
.replace(/^-+|-+$/g, '');
|
||
|
||
return ascii ? ascii.slice(0, 24) : 'entry';
|
||
}
|
||
|
||
export function createEntryId(prefix: string, label: string, index: number) {
|
||
return `${prefix}-${slugify(label || `${prefix}-${index + 1}`)}-${index + 1}`;
|
||
}
|
||
|
||
export function clampCustomWorldAffinity(value: number) {
|
||
return Math.max(
|
||
MIN_CUSTOM_WORLD_AFFINITY,
|
||
Math.min(MAX_CUSTOM_WORLD_AFFINITY, Math.round(value)),
|
||
);
|
||
}
|
||
|
||
export function normalizeInitialAffinity(value: unknown, fallback: number) {
|
||
return typeof value === 'number' && Number.isFinite(value)
|
||
? clampCustomWorldAffinity(value)
|
||
: fallback;
|
||
}
|
||
|
||
export function normalizeRarity(
|
||
value: unknown,
|
||
fallback: (typeof CUSTOM_WORLD_RARITIES)[number] = 'rare',
|
||
) {
|
||
const rarity = toText(value).toLowerCase();
|
||
return CUSTOM_WORLD_RARITIES.includes(
|
||
rarity as (typeof CUSTOM_WORLD_RARITIES)[number],
|
||
)
|
||
? (rarity as (typeof CUSTOM_WORLD_RARITIES)[number])
|
||
: fallback;
|
||
}
|
||
|
||
export function normalizeRoleItemCategory(value: unknown, fallback = '材料') {
|
||
const category = toText(value);
|
||
if (
|
||
(CUSTOM_WORLD_ROLE_ITEM_CATEGORIES as readonly string[]).includes(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;
|
||
}
|
||
|
||
export function normalizeCustomWorldCoverCharacterRoleIds(
|
||
value: unknown,
|
||
playableNpcs: Array<Pick<CustomWorldPlayableNpc, 'id'>>,
|
||
) {
|
||
const availableIds = new Set(
|
||
playableNpcs.map((entry) => entry.id.trim()).filter(Boolean),
|
||
);
|
||
const selectedIds = Array.isArray(value)
|
||
? [
|
||
...new Set(
|
||
value
|
||
.map((entry) => toText(entry))
|
||
.filter((entry) => entry && availableIds.has(entry)),
|
||
),
|
||
].slice(0, 3)
|
||
: [];
|
||
|
||
if (selectedIds.length > 0) {
|
||
return selectedIds;
|
||
}
|
||
|
||
return playableNpcs
|
||
.map((entry) => entry.id.trim())
|
||
.filter(Boolean)
|
||
.slice(0, 3);
|
||
}
|
||
|
||
export function buildDefaultCustomWorldCover(
|
||
playableNpcs: Array<Pick<CustomWorldPlayableNpc, 'id'>>,
|
||
): CustomWorldCoverProfile {
|
||
return {
|
||
sourceType: 'default' as const,
|
||
imageSrc: null,
|
||
characterRoleIds: normalizeCustomWorldCoverCharacterRoleIds(
|
||
undefined,
|
||
playableNpcs,
|
||
),
|
||
};
|
||
}
|
||
|
||
export function normalizeCustomWorldCover(
|
||
value: unknown,
|
||
playableNpcs: Array<Pick<CustomWorldPlayableNpc, 'id'>>,
|
||
): CustomWorldCoverProfile {
|
||
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||
return buildDefaultCustomWorldCover(playableNpcs);
|
||
}
|
||
|
||
const item = value as Record<string, unknown>;
|
||
const sourceType: CustomWorldCoverSourceType =
|
||
item.sourceType === 'uploaded' || item.sourceType === 'generated'
|
||
? item.sourceType
|
||
: 'default';
|
||
const imageSrc = toText(item.imageSrc) || null;
|
||
|
||
if (sourceType !== 'default' && imageSrc) {
|
||
return {
|
||
sourceType,
|
||
imageSrc,
|
||
characterRoleIds: [],
|
||
};
|
||
}
|
||
|
||
return buildDefaultCustomWorldCover(playableNpcs);
|
||
}
|
||
|
||
export function normalizeItemList(value: unknown) {
|
||
return toRecordArray(value)
|
||
.map((item, index) => {
|
||
const name = toText(item.name);
|
||
const category = toText(item.category);
|
||
return {
|
||
id: toText(item.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);
|
||
}
|