982 lines
31 KiB
TypeScript
982 lines
31 KiB
TypeScript
import { coerceWorldAttributeSchema } from '../data/attributeValidation';
|
||
import {
|
||
type CreatureArchetypeProfile,
|
||
type CustomWorldCompatibilityProfile,
|
||
type CustomWorldExpressionProfile,
|
||
type CustomWorldOwnedSettingLayers,
|
||
type CustomWorldProfile,
|
||
type CustomWorldReferenceProfile,
|
||
type CustomWorldRuleProfile,
|
||
type CustomWorldSemanticAnchor,
|
||
type RoleArchetypeProfile,
|
||
type SceneArchetypeBucket,
|
||
WorldType,
|
||
} from '../types';
|
||
import {
|
||
type CustomWorldThemeMode,
|
||
detectCustomWorldThemeMode,
|
||
resolveCustomWorldCompatibilityTemplateWorldType,
|
||
} from './customWorldTheme';
|
||
import {
|
||
buildThemePackFromWorldProfile,
|
||
normalizeThemePack,
|
||
} from './storyEngine/themePack';
|
||
|
||
const OWNED_SETTING_LAYER_MIGRATION_VERSION =
|
||
'2026-04-08-owned-setting-layers-v1';
|
||
|
||
const RESOURCE_LABEL_PRESETS: Record<
|
||
CustomWorldThemeMode,
|
||
CustomWorldRuleProfile['resourceLabels']
|
||
> = {
|
||
mythic: {
|
||
hp: '生命',
|
||
mp: '心流',
|
||
maxHp: '生命上限',
|
||
maxMp: '心流上限',
|
||
damage: '势能',
|
||
guard: '防护',
|
||
range: '距离',
|
||
cooldown: '回整',
|
||
manaCost: '心流消耗',
|
||
currency: '旅券',
|
||
},
|
||
martial: {
|
||
hp: '气血',
|
||
mp: '内力',
|
||
maxHp: '气血上限',
|
||
maxMp: '内力上限',
|
||
damage: '招式',
|
||
guard: '防御',
|
||
range: '招距',
|
||
cooldown: '调息',
|
||
manaCost: '内力消耗',
|
||
currency: '铜钱',
|
||
},
|
||
arcane: {
|
||
hp: '元命',
|
||
mp: '灵韵',
|
||
maxHp: '元命上限',
|
||
maxMp: '灵韵上限',
|
||
damage: '术法',
|
||
guard: '护盾',
|
||
range: '术距',
|
||
cooldown: '回息',
|
||
manaCost: '灵韵消耗',
|
||
currency: '灵石',
|
||
},
|
||
machina: {
|
||
hp: '耐久',
|
||
mp: '能量',
|
||
maxHp: '耐久上限',
|
||
maxMp: '能量上限',
|
||
damage: '火力',
|
||
guard: '护盾',
|
||
range: '射程',
|
||
cooldown: '充能',
|
||
manaCost: '能量消耗',
|
||
currency: '配给券',
|
||
},
|
||
tide: {
|
||
hp: '潮命',
|
||
mp: '潮息',
|
||
maxHp: '潮命上限',
|
||
maxMp: '潮息上限',
|
||
damage: '潮势',
|
||
guard: '潮护',
|
||
range: '潮距',
|
||
cooldown: '回潮',
|
||
manaCost: '潮息消耗',
|
||
currency: '潮银',
|
||
},
|
||
rift: {
|
||
hp: '界命',
|
||
mp: '裂能',
|
||
maxHp: '界命上限',
|
||
maxMp: '裂能上限',
|
||
damage: '界势',
|
||
guard: '稳界',
|
||
range: '界距',
|
||
cooldown: '复界',
|
||
manaCost: '裂能消耗',
|
||
currency: '边贸券',
|
||
},
|
||
};
|
||
|
||
const INITIAL_CURRENCY_PRESETS: Record<CustomWorldThemeMode, number> = {
|
||
mythic: 160,
|
||
martial: 160,
|
||
arcane: 140,
|
||
machina: 160,
|
||
tide: 160,
|
||
rift: 160,
|
||
};
|
||
|
||
const SEMANTIC_ANCHOR_PRESETS: Record<
|
||
CustomWorldThemeMode,
|
||
Omit<CustomWorldSemanticAnchor, 'atmosphereTags'>
|
||
> = {
|
||
mythic: {
|
||
genreSignals: ['跨题材冒险', '未知旅境'],
|
||
conflictForms: ['追查', '护送', '回收', '失踪追索'],
|
||
institutionTypes: ['据点', '旅团', '档案室', '归舍'],
|
||
tabooTypes: ['越界', '封存', '失约', '旧痕'],
|
||
carrierTypes: ['信物', '残页', '样本', '旧钥'],
|
||
forceSystemTypes: ['回响', '誓约', '遗物', '余波'],
|
||
},
|
||
martial: {
|
||
genreSignals: ['江湖纷争', '旧案追索'],
|
||
conflictForms: ['寻仇', '围剿', '护送', '失踪追查'],
|
||
institutionTypes: ['门派', '镖局', '巡司', '商号'],
|
||
tabooTypes: ['旧案', '断誓', '禁脉', '失契'],
|
||
carrierTypes: ['遗兵', '令牌', '残卷', '旧佩'],
|
||
forceSystemTypes: ['心法', '招式', '经脉', '誓约'],
|
||
},
|
||
arcane: {
|
||
genreSignals: ['灵异修行', '秘境因果'],
|
||
conflictForms: ['夺脉', '封印失衡', '宗门旧案', '秘境争夺'],
|
||
institutionTypes: ['宗门', '法坛', '巡守司', '灵舟会'],
|
||
tabooTypes: ['封印', '禁术', '残魂', '逆脉'],
|
||
carrierTypes: ['法器', '灵符', '玉简', '阵核'],
|
||
forceSystemTypes: ['灵脉', '术式', '契约', '神识'],
|
||
},
|
||
machina: {
|
||
genreSignals: ['工业前线', '失控科技'],
|
||
conflictForms: ['封锁', '回收', '追查事故', '前线失守'],
|
||
institutionTypes: ['财团', '工坊', '舰队', '调查局'],
|
||
tabooTypes: ['过载', '失控协议', '封存日志', '污染区'],
|
||
carrierTypes: ['芯片', '驱动核', '记录模组', '封存匣'],
|
||
forceSystemTypes: ['科技', '协议', '驱动', '能量网'],
|
||
},
|
||
tide: {
|
||
genreSignals: ['海岸悬疑', '潮灾余波'],
|
||
conflictForms: ['封港', '海路争夺', '追查失踪', '护送穿渡'],
|
||
institutionTypes: ['港务', '巡海司', '渡船会', '潮站'],
|
||
tabooTypes: ['沉船', '禁海区', '回潮夜', '失契'],
|
||
carrierTypes: ['航图', '潮印', '信标', '封潮匣'],
|
||
forceSystemTypes: ['潮汐', '雾潮', '海誓', '异流'],
|
||
},
|
||
rift: {
|
||
genreSignals: ['裂界边境', '战线余烬'],
|
||
conflictForms: ['守线', '撤离', '回收异常', '追查失线'],
|
||
institutionTypes: ['前哨', '巡边队', '断层站', '回收组'],
|
||
tabooTypes: ['断层失守', '界外污染', '封桥令', '旧撤离线'],
|
||
carrierTypes: ['界核', '锚印', '样本', '回响记录'],
|
||
forceSystemTypes: ['裂界', '界压', '污染', '锚定'],
|
||
},
|
||
};
|
||
|
||
const CREATURE_ARCHETYPE_PRESETS: Record<
|
||
CustomWorldThemeMode,
|
||
Array<Omit<CreatureArchetypeProfile, 'id'>>
|
||
> = {
|
||
mythic: [
|
||
{
|
||
label: '潜伏袭击者',
|
||
threatStyle: '借地形潜伏后突然贴身施压。',
|
||
keywords: ['潜伏', '伏击', '前探阻断'],
|
||
},
|
||
{
|
||
label: '群居骚扰者',
|
||
threatStyle: '依靠数量与机动性反复撕扯阵线。',
|
||
keywords: ['群居', '扰动', '消耗'],
|
||
},
|
||
{
|
||
label: '回响追猎者',
|
||
threatStyle: '会追着异常痕迹与关键目标持续压迫。',
|
||
keywords: ['回响', '追索', '持续压迫'],
|
||
},
|
||
],
|
||
martial: [
|
||
{
|
||
label: '潜伏袭击者',
|
||
threatStyle: '先藏身再借速度打出首轮杀招。',
|
||
keywords: ['潜袭', '伏击', '贴身爆发'],
|
||
},
|
||
{
|
||
label: '重甲承压者',
|
||
threatStyle: '站住正面、顶着伤害强行换血。',
|
||
keywords: ['承压', '守线', '正面对撞'],
|
||
},
|
||
{
|
||
label: '远程威胁者',
|
||
threatStyle: '依靠暗器、弓弩或投掷不断压制走位。',
|
||
keywords: ['远程', '压制', '封走位'],
|
||
},
|
||
],
|
||
arcane: [
|
||
{
|
||
label: '灵体回响体',
|
||
threatStyle: '借余波与残识干扰节奏并持续追逼。',
|
||
keywords: ['灵体', '回响', '术式残留'],
|
||
},
|
||
{
|
||
label: '异化污染体',
|
||
threatStyle: '被灵潮扭曲后具备高压近身威胁。',
|
||
keywords: ['异化', '污染', '近身撕咬'],
|
||
},
|
||
{
|
||
label: '机关守卫体',
|
||
threatStyle: '围绕阵核或封印节点进行固守打击。',
|
||
keywords: ['机关', '守卫', '节点压制'],
|
||
},
|
||
],
|
||
machina: [
|
||
{
|
||
label: '远程威胁者',
|
||
threatStyle: '依靠火力、脉冲或投射装置封锁空间。',
|
||
keywords: ['火力', '远程', '封锁'],
|
||
},
|
||
{
|
||
label: '重装阻断者',
|
||
threatStyle: '借重甲和装置正面堵截推进线路。',
|
||
keywords: ['重装', '阻断', '压线'],
|
||
},
|
||
{
|
||
label: '失控追击者',
|
||
threatStyle: '高频位移并持续追杀被标记目标。',
|
||
keywords: ['失控', '追击', '高机动'],
|
||
},
|
||
],
|
||
tide: [
|
||
{
|
||
label: '群居骚扰者',
|
||
threatStyle: '借潮湿地形和数量优势消耗行进队伍。',
|
||
keywords: ['群居', '潮湿', '消耗'],
|
||
},
|
||
{
|
||
label: '潜伏袭击者',
|
||
threatStyle: '利用雾潮与死角打出突袭。',
|
||
keywords: ['迷雾', '潜伏', '突袭'],
|
||
},
|
||
{
|
||
label: '异化污染体',
|
||
threatStyle: '会沿潮灾痕迹持续扩散压迫。',
|
||
keywords: ['潮灾', '异化', '扩散'],
|
||
},
|
||
],
|
||
rift: [
|
||
{
|
||
label: '异化污染体',
|
||
threatStyle: '长期暴露在裂界环境后具备高压侵蚀性。',
|
||
keywords: ['污染', '侵蚀', '裂界'],
|
||
},
|
||
{
|
||
label: '远程威胁者',
|
||
threatStyle: '依靠界压残波或碎片投射逼迫走位。',
|
||
keywords: ['界压', '残波', '远程'],
|
||
},
|
||
{
|
||
label: '机关守卫体',
|
||
threatStyle: '围绕前哨节点和封桥设施持续守线。',
|
||
keywords: ['前哨', '守线', '节点'],
|
||
},
|
||
],
|
||
};
|
||
|
||
function toText(value: unknown) {
|
||
return typeof value === 'string' ? value.trim() : '';
|
||
}
|
||
|
||
function toStringArray(value: unknown, max = 8) {
|
||
if (!Array.isArray(value)) {
|
||
return [];
|
||
}
|
||
|
||
return value
|
||
.map((item) => toText(item))
|
||
.filter(Boolean)
|
||
.slice(0, max);
|
||
}
|
||
|
||
function dedupeStrings(
|
||
values: Array<string | null | undefined>,
|
||
max = 8,
|
||
) {
|
||
return [...new Set(values.map((value) => value?.trim() ?? '').filter(Boolean))]
|
||
.slice(0, max);
|
||
}
|
||
|
||
function splitToneTags(tone: string) {
|
||
return dedupeStrings(tone.split(/[,、,/\s]+/u), 6);
|
||
}
|
||
|
||
function inferInstitutionType(labels: string[]) {
|
||
return dedupeStrings(
|
||
labels.map((label) => {
|
||
if (/[宗门宫阁派]/u.test(label)) return '宗门';
|
||
if (/[司局署院]/u.test(label)) return '调查局';
|
||
if (/[团盟会]/u.test(label)) return '同盟';
|
||
if (/[公司财团]/u.test(label)) return '公司';
|
||
if (/[港坞渡站哨]/u.test(label)) return '前哨';
|
||
if (/[学园学院书院]/u.test(label)) return '学园';
|
||
if (/[舰船坞航]/u.test(label)) return '舰队';
|
||
if (/[部族寨群落]/u.test(label)) return '部族';
|
||
return label.length <= 8 ? label : '';
|
||
}),
|
||
4,
|
||
);
|
||
}
|
||
|
||
function inferForceSystemTypes(profile: CustomWorldProfile) {
|
||
const source = `${profile.settingText} ${profile.summary} ${profile.tone} ${profile.playerGoal}`;
|
||
const detected = [
|
||
/科技|机巧|协议|工坊|脉冲|驱动/u.test(source) ? '科技' : null,
|
||
/海潮|潮汐|海雾|港湾/u.test(source) ? '潮汐' : null,
|
||
/裂界|断层|界桥|灰域|污染/u.test(source) ? '裂界' : null,
|
||
/仙|灵|术|阵|符|秘境|宗门/u.test(source) ? '灵脉' : null,
|
||
/契约|誓约|旧誓/u.test(source) ? '契约' : null,
|
||
/回响|残响|余波/u.test(source) ? '回响' : null,
|
||
];
|
||
|
||
return dedupeStrings(detected, 4);
|
||
}
|
||
|
||
function inferRoleArchetypeLabel(
|
||
role: Pick<CustomWorldProfile['playableNpcs'][number], 'combatStyle' | 'role' | 'tags'>,
|
||
) {
|
||
const source = `${role.role} ${role.combatStyle} ${role.tags.join(' ')}`;
|
||
|
||
if (/[弓|炮|射|远程|投掷|火力]/u.test(source)) {
|
||
return '远程压制型';
|
||
}
|
||
if (/[盾|守|承压|阵线|重甲|护]/u.test(source)) {
|
||
return '续航承压型';
|
||
}
|
||
if (/[影|潜|刺|闪|遁|爆发]/u.test(source)) {
|
||
return '潜行爆发型';
|
||
}
|
||
if (/[法|术|控|阵|咒|机关|干扰|牵制]/u.test(source)) {
|
||
return '控场解构型';
|
||
}
|
||
return '正面推进型';
|
||
}
|
||
|
||
function inferSceneBucketLabel(
|
||
landmark: Pick<CustomWorldProfile['landmarks'][number], 'name' | 'description'>,
|
||
) {
|
||
const source = `${landmark.name} ${landmark.description}`;
|
||
|
||
if (/[港|湾|渡口|码头|潮]/u.test(source)) return '临水渡口区';
|
||
if (/[桥|关|哨|门|入口]/u.test(source)) return '高压入口区';
|
||
if (/[殿|坛|仪式|祭]/u.test(source)) return '仪式神殿区';
|
||
if (/[塔|空桥|高空|云|崖]/u.test(source)) return '高空通路区';
|
||
if (/[厂|工坊|机库|轨|站]/u.test(source)) return '工业热区';
|
||
if (/[洞|穴|地下|遗迹|墓]/u.test(source)) return '地底遗迹区';
|
||
if (/[街|巷|城|镇|居]/u.test(source)) return '群落聚居区';
|
||
|
||
return '叙事缓冲区';
|
||
}
|
||
|
||
function buildRoleArchetypes(profile: CustomWorldProfile) {
|
||
return profile.playableNpcs.slice(0, 6).map((role, index) => ({
|
||
id: `role-archetype-${index + 1}`,
|
||
label: inferRoleArchetypeLabel(role),
|
||
combatFocus: role.combatStyle.trim() || role.role.trim() || '围绕核心职责推进战局。',
|
||
narrativeFunction:
|
||
role.role.trim() || role.description.trim() || '在主线推进中提供关键响应。',
|
||
sourceRoleIds: [role.id],
|
||
sourceTemplateCharacterIds: [],
|
||
tags: dedupeStrings(role.tags, 5),
|
||
})) satisfies RoleArchetypeProfile[];
|
||
}
|
||
|
||
function buildSceneBuckets(profile: CustomWorldProfile) {
|
||
return profile.landmarks.slice(0, 8).map((landmark, index) => ({
|
||
id: `scene-bucket-${index + 1}`,
|
||
label: inferSceneBucketLabel(landmark),
|
||
moodTags: dedupeStrings(
|
||
splitToneTags(profile.tone),
|
||
4,
|
||
),
|
||
keywords: dedupeStrings([landmark.name, landmark.description], 4),
|
||
referenceLandmarkIds: [landmark.id],
|
||
})) satisfies SceneArchetypeBucket[];
|
||
}
|
||
|
||
function buildCreatureArchetypes(mode: CustomWorldThemeMode) {
|
||
return CREATURE_ARCHETYPE_PRESETS[mode].map((creature, index) => ({
|
||
id: `creature-archetype-${index + 1}`,
|
||
...creature,
|
||
})) satisfies CreatureArchetypeProfile[];
|
||
}
|
||
|
||
function buildThemePackSeed(profile: CustomWorldProfile) {
|
||
return buildThemePackFromWorldProfile({
|
||
settingText: profile.settingText,
|
||
summary: profile.summary,
|
||
tone: profile.tone,
|
||
playerGoal: profile.playerGoal,
|
||
templateWorldType: resolveCustomWorldCompatibilityTemplateWorldType(profile),
|
||
majorFactions: profile.majorFactions,
|
||
coreConflicts: profile.coreConflicts,
|
||
ownedSettingLayers: null,
|
||
});
|
||
}
|
||
|
||
function compileSemanticAnchor(
|
||
profile: CustomWorldProfile,
|
||
mode: CustomWorldThemeMode,
|
||
) {
|
||
const preset = SEMANTIC_ANCHOR_PRESETS[mode];
|
||
const creatorIntent = profile.creatorIntent;
|
||
const institutionHints = inferInstitutionType([
|
||
...profile.majorFactions,
|
||
...(creatorIntent?.keyFactions.map((seed) => seed.name) ?? []),
|
||
]);
|
||
const forceSystemTypes = inferForceSystemTypes(profile);
|
||
|
||
return {
|
||
genreSignals: dedupeStrings(
|
||
[...(creatorIntent?.themeKeywords ?? []), ...preset.genreSignals],
|
||
6,
|
||
),
|
||
conflictForms: dedupeStrings(
|
||
[...profile.coreConflicts, ...preset.conflictForms],
|
||
6,
|
||
),
|
||
institutionTypes: dedupeStrings(
|
||
[...institutionHints, ...preset.institutionTypes],
|
||
6,
|
||
),
|
||
tabooTypes: dedupeStrings(
|
||
[...profile.coreConflicts, ...preset.tabooTypes],
|
||
6,
|
||
),
|
||
carrierTypes: dedupeStrings(
|
||
[...(creatorIntent?.iconicElements ?? []), ...preset.carrierTypes],
|
||
6,
|
||
),
|
||
forceSystemTypes: dedupeStrings(
|
||
[...forceSystemTypes, ...preset.forceSystemTypes],
|
||
6,
|
||
),
|
||
atmosphereTags: dedupeStrings(
|
||
[...splitToneTags(profile.tone), ...preset.genreSignals],
|
||
6,
|
||
),
|
||
} satisfies CustomWorldSemanticAnchor;
|
||
}
|
||
|
||
function compileRuleProfile(
|
||
profile: CustomWorldProfile,
|
||
mode: CustomWorldThemeMode,
|
||
) {
|
||
return {
|
||
attributeSchema: profile.attributeSchema,
|
||
resourceLabels: RESOURCE_LABEL_PRESETS[mode],
|
||
economyProfile: {
|
||
initialCurrency: INITIAL_CURRENCY_PRESETS[mode],
|
||
},
|
||
} satisfies CustomWorldRuleProfile;
|
||
}
|
||
|
||
function compileExpressionProfile(
|
||
profile: CustomWorldProfile,
|
||
semanticAnchor: CustomWorldSemanticAnchor,
|
||
) {
|
||
const fallbackThemePack = buildThemePackSeed(profile);
|
||
const themePack = normalizeThemePack(profile.themePack, fallbackThemePack);
|
||
|
||
return {
|
||
themePack,
|
||
presentationTone: dedupeStrings(
|
||
[profile.tone, ...semanticAnchor.atmosphereTags, ...themePack.toneRange],
|
||
8,
|
||
),
|
||
namingDirectives: dedupeStrings(themePack.namingPatterns, 6),
|
||
clueDirectives: dedupeStrings(themePack.clueForms, 6),
|
||
revealDirectives: dedupeStrings(themePack.revealStyles, 6),
|
||
} satisfies CustomWorldExpressionProfile;
|
||
}
|
||
|
||
function compileReferenceProfile(
|
||
profile: CustomWorldProfile,
|
||
mode: CustomWorldThemeMode,
|
||
) {
|
||
return {
|
||
roleArchetypes: buildRoleArchetypes(profile),
|
||
sceneBuckets: buildSceneBuckets(profile),
|
||
creatureArchetypes: buildCreatureArchetypes(mode),
|
||
} satisfies CustomWorldReferenceProfile;
|
||
}
|
||
|
||
function compileCompatibilityProfile(profile: CustomWorldProfile) {
|
||
const compatibilityTemplateWorldType =
|
||
resolveCustomWorldCompatibilityTemplateWorldType(profile);
|
||
|
||
return {
|
||
compatibilityTemplateWorldType,
|
||
legacyTemplateWorldType: compatibilityTemplateWorldType,
|
||
migrationVersion: OWNED_SETTING_LAYER_MIGRATION_VERSION,
|
||
} satisfies CustomWorldCompatibilityProfile;
|
||
}
|
||
|
||
function normalizeRoleArchetypes(
|
||
value: unknown,
|
||
fallback: RoleArchetypeProfile[],
|
||
) {
|
||
if (!Array.isArray(value)) {
|
||
return fallback;
|
||
}
|
||
|
||
const normalized = value
|
||
.map((entry, index) => {
|
||
if (!entry || typeof entry !== 'object') {
|
||
return null;
|
||
}
|
||
|
||
const item = entry as Record<string, unknown>;
|
||
const label = toText(item.label);
|
||
if (!label) {
|
||
return null;
|
||
}
|
||
|
||
return {
|
||
id: toText(item.id) || `role-archetype-${index + 1}`,
|
||
label,
|
||
combatFocus:
|
||
toText(item.combatFocus) ||
|
||
fallback[index]?.combatFocus ||
|
||
'围绕核心职责推进战局。',
|
||
narrativeFunction:
|
||
toText(item.narrativeFunction) ||
|
||
fallback[index]?.narrativeFunction ||
|
||
'在主线推进中提供关键响应。',
|
||
sourceRoleIds: toStringArray(item.sourceRoleIds, 4),
|
||
sourceTemplateCharacterIds: toStringArray(
|
||
item.sourceTemplateCharacterIds,
|
||
4,
|
||
),
|
||
tags: dedupeStrings(toStringArray(item.tags, 5), 5),
|
||
} satisfies RoleArchetypeProfile;
|
||
})
|
||
.filter((entry): entry is RoleArchetypeProfile => Boolean(entry));
|
||
|
||
return normalized.length > 0 ? normalized : fallback;
|
||
}
|
||
|
||
function normalizeSceneBuckets(
|
||
value: unknown,
|
||
fallback: SceneArchetypeBucket[],
|
||
) {
|
||
if (!Array.isArray(value)) {
|
||
return fallback;
|
||
}
|
||
|
||
const normalized = value
|
||
.map((entry, index) => {
|
||
if (!entry || typeof entry !== 'object') {
|
||
return null;
|
||
}
|
||
|
||
const item = entry as Record<string, unknown>;
|
||
const label = toText(item.label);
|
||
if (!label) {
|
||
return null;
|
||
}
|
||
|
||
return {
|
||
id: toText(item.id) || `scene-bucket-${index + 1}`,
|
||
label,
|
||
moodTags: dedupeStrings(toStringArray(item.moodTags, 4), 4),
|
||
keywords: dedupeStrings(toStringArray(item.keywords, 4), 4),
|
||
referenceLandmarkIds: toStringArray(item.referenceLandmarkIds, 4),
|
||
} satisfies SceneArchetypeBucket;
|
||
})
|
||
.filter((entry): entry is SceneArchetypeBucket => Boolean(entry));
|
||
|
||
return normalized.length > 0 ? normalized : fallback;
|
||
}
|
||
|
||
function normalizeCreatureArchetypes(
|
||
value: unknown,
|
||
fallback: CreatureArchetypeProfile[],
|
||
) {
|
||
if (!Array.isArray(value)) {
|
||
return fallback;
|
||
}
|
||
|
||
const normalized = value
|
||
.map((entry, index) => {
|
||
if (!entry || typeof entry !== 'object') {
|
||
return null;
|
||
}
|
||
|
||
const item = entry as Record<string, unknown>;
|
||
const label = toText(item.label);
|
||
if (!label) {
|
||
return null;
|
||
}
|
||
|
||
return {
|
||
id: toText(item.id) || `creature-archetype-${index + 1}`,
|
||
label,
|
||
threatStyle:
|
||
toText(item.threatStyle) ||
|
||
fallback[index]?.threatStyle ||
|
||
'围绕核心威胁方式持续施压。',
|
||
keywords: dedupeStrings(toStringArray(item.keywords, 4), 4),
|
||
} satisfies CreatureArchetypeProfile;
|
||
})
|
||
.filter((entry): entry is CreatureArchetypeProfile => Boolean(entry));
|
||
|
||
return normalized.length > 0 ? normalized : fallback;
|
||
}
|
||
|
||
export function compileOwnedSettingLayersFromLegacyTemplate(
|
||
profile: CustomWorldProfile,
|
||
) {
|
||
const mode = detectCustomWorldThemeMode({
|
||
settingText: profile.settingText,
|
||
summary: profile.summary,
|
||
tone: profile.tone,
|
||
playerGoal: profile.playerGoal,
|
||
templateWorldType: profile.templateWorldType,
|
||
compatibilityTemplateWorldType:
|
||
profile.compatibilityTemplateWorldType ?? profile.templateWorldType,
|
||
ownedSettingLayers: null,
|
||
});
|
||
const semanticAnchor = compileSemanticAnchor(profile, mode);
|
||
|
||
return {
|
||
semanticAnchor,
|
||
ruleProfile: compileRuleProfile(profile, mode),
|
||
expressionProfile: compileExpressionProfile(profile, semanticAnchor),
|
||
referenceProfile: compileReferenceProfile(profile, mode),
|
||
compatibilityProfile: compileCompatibilityProfile(profile),
|
||
} satisfies CustomWorldOwnedSettingLayers;
|
||
}
|
||
|
||
export function normalizeCustomWorldOwnedSettingLayers(
|
||
value: unknown,
|
||
profile: CustomWorldProfile,
|
||
) {
|
||
const fallback = compileOwnedSettingLayersFromLegacyTemplate(profile);
|
||
if (!value || typeof value !== 'object') {
|
||
return fallback;
|
||
}
|
||
|
||
const item = value as Record<string, unknown>;
|
||
const semanticAnchorItem =
|
||
item.semanticAnchor && typeof item.semanticAnchor === 'object'
|
||
? (item.semanticAnchor as Record<string, unknown>)
|
||
: {};
|
||
const ruleProfileItem =
|
||
item.ruleProfile && typeof item.ruleProfile === 'object'
|
||
? (item.ruleProfile as Record<string, unknown>)
|
||
: {};
|
||
const resourceLabelsItem =
|
||
ruleProfileItem.resourceLabels &&
|
||
typeof ruleProfileItem.resourceLabels === 'object'
|
||
? (ruleProfileItem.resourceLabels as Record<string, unknown>)
|
||
: {};
|
||
const expressionProfileItem =
|
||
item.expressionProfile && typeof item.expressionProfile === 'object'
|
||
? (item.expressionProfile as Record<string, unknown>)
|
||
: {};
|
||
const referenceProfileItem =
|
||
item.referenceProfile && typeof item.referenceProfile === 'object'
|
||
? (item.referenceProfile as Record<string, unknown>)
|
||
: {};
|
||
const compatibilityProfileItem =
|
||
item.compatibilityProfile && typeof item.compatibilityProfile === 'object'
|
||
? (item.compatibilityProfile as Record<string, unknown>)
|
||
: {};
|
||
|
||
return {
|
||
semanticAnchor: {
|
||
genreSignals: dedupeStrings(
|
||
toStringArray(
|
||
semanticAnchorItem.genreSignals,
|
||
fallback.semanticAnchor.genreSignals.length,
|
||
),
|
||
fallback.semanticAnchor.genreSignals.length,
|
||
).length
|
||
? dedupeStrings(
|
||
toStringArray(
|
||
semanticAnchorItem.genreSignals,
|
||
fallback.semanticAnchor.genreSignals.length,
|
||
),
|
||
fallback.semanticAnchor.genreSignals.length,
|
||
)
|
||
: fallback.semanticAnchor.genreSignals,
|
||
conflictForms: dedupeStrings(
|
||
toStringArray(
|
||
semanticAnchorItem.conflictForms,
|
||
fallback.semanticAnchor.conflictForms.length,
|
||
),
|
||
fallback.semanticAnchor.conflictForms.length,
|
||
).length
|
||
? dedupeStrings(
|
||
toStringArray(
|
||
semanticAnchorItem.conflictForms,
|
||
fallback.semanticAnchor.conflictForms.length,
|
||
),
|
||
fallback.semanticAnchor.conflictForms.length,
|
||
)
|
||
: fallback.semanticAnchor.conflictForms,
|
||
institutionTypes: dedupeStrings(
|
||
toStringArray(
|
||
semanticAnchorItem.institutionTypes,
|
||
fallback.semanticAnchor.institutionTypes.length,
|
||
),
|
||
fallback.semanticAnchor.institutionTypes.length,
|
||
).length
|
||
? dedupeStrings(
|
||
toStringArray(
|
||
semanticAnchorItem.institutionTypes,
|
||
fallback.semanticAnchor.institutionTypes.length,
|
||
),
|
||
fallback.semanticAnchor.institutionTypes.length,
|
||
)
|
||
: fallback.semanticAnchor.institutionTypes,
|
||
tabooTypes: dedupeStrings(
|
||
toStringArray(
|
||
semanticAnchorItem.tabooTypes,
|
||
fallback.semanticAnchor.tabooTypes.length,
|
||
),
|
||
fallback.semanticAnchor.tabooTypes.length,
|
||
).length
|
||
? dedupeStrings(
|
||
toStringArray(
|
||
semanticAnchorItem.tabooTypes,
|
||
fallback.semanticAnchor.tabooTypes.length,
|
||
),
|
||
fallback.semanticAnchor.tabooTypes.length,
|
||
)
|
||
: fallback.semanticAnchor.tabooTypes,
|
||
carrierTypes: dedupeStrings(
|
||
toStringArray(
|
||
semanticAnchorItem.carrierTypes,
|
||
fallback.semanticAnchor.carrierTypes.length,
|
||
),
|
||
fallback.semanticAnchor.carrierTypes.length,
|
||
).length
|
||
? dedupeStrings(
|
||
toStringArray(
|
||
semanticAnchorItem.carrierTypes,
|
||
fallback.semanticAnchor.carrierTypes.length,
|
||
),
|
||
fallback.semanticAnchor.carrierTypes.length,
|
||
)
|
||
: fallback.semanticAnchor.carrierTypes,
|
||
forceSystemTypes: dedupeStrings(
|
||
toStringArray(
|
||
semanticAnchorItem.forceSystemTypes,
|
||
fallback.semanticAnchor.forceSystemTypes.length,
|
||
),
|
||
fallback.semanticAnchor.forceSystemTypes.length,
|
||
).length
|
||
? dedupeStrings(
|
||
toStringArray(
|
||
semanticAnchorItem.forceSystemTypes,
|
||
fallback.semanticAnchor.forceSystemTypes.length,
|
||
),
|
||
fallback.semanticAnchor.forceSystemTypes.length,
|
||
)
|
||
: fallback.semanticAnchor.forceSystemTypes,
|
||
atmosphereTags: dedupeStrings(
|
||
toStringArray(
|
||
semanticAnchorItem.atmosphereTags,
|
||
fallback.semanticAnchor.atmosphereTags.length,
|
||
),
|
||
fallback.semanticAnchor.atmosphereTags.length,
|
||
).length
|
||
? dedupeStrings(
|
||
toStringArray(
|
||
semanticAnchorItem.atmosphereTags,
|
||
fallback.semanticAnchor.atmosphereTags.length,
|
||
),
|
||
fallback.semanticAnchor.atmosphereTags.length,
|
||
)
|
||
: fallback.semanticAnchor.atmosphereTags,
|
||
},
|
||
ruleProfile: {
|
||
attributeSchema: coerceWorldAttributeSchema(
|
||
ruleProfileItem.attributeSchema,
|
||
fallback.ruleProfile.attributeSchema,
|
||
),
|
||
resourceLabels: {
|
||
hp: toText(resourceLabelsItem.hp) || fallback.ruleProfile.resourceLabels.hp,
|
||
mp: toText(resourceLabelsItem.mp) || fallback.ruleProfile.resourceLabels.mp,
|
||
maxHp:
|
||
toText(resourceLabelsItem.maxHp) ||
|
||
fallback.ruleProfile.resourceLabels.maxHp,
|
||
maxMp:
|
||
toText(resourceLabelsItem.maxMp) ||
|
||
fallback.ruleProfile.resourceLabels.maxMp,
|
||
damage:
|
||
toText(resourceLabelsItem.damage) ||
|
||
fallback.ruleProfile.resourceLabels.damage,
|
||
guard:
|
||
toText(resourceLabelsItem.guard) ||
|
||
fallback.ruleProfile.resourceLabels.guard,
|
||
range:
|
||
toText(resourceLabelsItem.range) ||
|
||
fallback.ruleProfile.resourceLabels.range,
|
||
cooldown:
|
||
toText(resourceLabelsItem.cooldown) ||
|
||
fallback.ruleProfile.resourceLabels.cooldown,
|
||
manaCost:
|
||
toText(resourceLabelsItem.manaCost) ||
|
||
fallback.ruleProfile.resourceLabels.manaCost,
|
||
currency:
|
||
toText(resourceLabelsItem.currency) ||
|
||
fallback.ruleProfile.resourceLabels.currency,
|
||
},
|
||
economyProfile: {
|
||
initialCurrency:
|
||
typeof ruleProfileItem.economyProfile === 'object' &&
|
||
ruleProfileItem.economyProfile &&
|
||
typeof (ruleProfileItem.economyProfile as Record<string, unknown>)
|
||
.initialCurrency === 'number' &&
|
||
Number.isFinite(
|
||
(ruleProfileItem.economyProfile as Record<string, unknown>)
|
||
.initialCurrency,
|
||
)
|
||
? Math.max(
|
||
0,
|
||
Math.round(
|
||
(ruleProfileItem.economyProfile as Record<string, unknown>)
|
||
.initialCurrency as number,
|
||
),
|
||
)
|
||
: fallback.ruleProfile.economyProfile.initialCurrency,
|
||
},
|
||
},
|
||
expressionProfile: {
|
||
themePack: normalizeThemePack(
|
||
expressionProfileItem.themePack,
|
||
fallback.expressionProfile.themePack,
|
||
),
|
||
presentationTone: dedupeStrings(
|
||
toStringArray(
|
||
expressionProfileItem.presentationTone,
|
||
fallback.expressionProfile.presentationTone.length,
|
||
),
|
||
fallback.expressionProfile.presentationTone.length,
|
||
).length
|
||
? dedupeStrings(
|
||
toStringArray(
|
||
expressionProfileItem.presentationTone,
|
||
fallback.expressionProfile.presentationTone.length,
|
||
),
|
||
fallback.expressionProfile.presentationTone.length,
|
||
)
|
||
: fallback.expressionProfile.presentationTone,
|
||
namingDirectives: dedupeStrings(
|
||
toStringArray(
|
||
expressionProfileItem.namingDirectives,
|
||
fallback.expressionProfile.namingDirectives.length,
|
||
),
|
||
fallback.expressionProfile.namingDirectives.length,
|
||
).length
|
||
? dedupeStrings(
|
||
toStringArray(
|
||
expressionProfileItem.namingDirectives,
|
||
fallback.expressionProfile.namingDirectives.length,
|
||
),
|
||
fallback.expressionProfile.namingDirectives.length,
|
||
)
|
||
: fallback.expressionProfile.namingDirectives,
|
||
clueDirectives: dedupeStrings(
|
||
toStringArray(
|
||
expressionProfileItem.clueDirectives,
|
||
fallback.expressionProfile.clueDirectives.length,
|
||
),
|
||
fallback.expressionProfile.clueDirectives.length,
|
||
).length
|
||
? dedupeStrings(
|
||
toStringArray(
|
||
expressionProfileItem.clueDirectives,
|
||
fallback.expressionProfile.clueDirectives.length,
|
||
),
|
||
fallback.expressionProfile.clueDirectives.length,
|
||
)
|
||
: fallback.expressionProfile.clueDirectives,
|
||
revealDirectives: dedupeStrings(
|
||
toStringArray(
|
||
expressionProfileItem.revealDirectives,
|
||
fallback.expressionProfile.revealDirectives.length,
|
||
),
|
||
fallback.expressionProfile.revealDirectives.length,
|
||
).length
|
||
? dedupeStrings(
|
||
toStringArray(
|
||
expressionProfileItem.revealDirectives,
|
||
fallback.expressionProfile.revealDirectives.length,
|
||
),
|
||
fallback.expressionProfile.revealDirectives.length,
|
||
)
|
||
: fallback.expressionProfile.revealDirectives,
|
||
},
|
||
referenceProfile: {
|
||
roleArchetypes: normalizeRoleArchetypes(
|
||
referenceProfileItem.roleArchetypes,
|
||
fallback.referenceProfile.roleArchetypes,
|
||
),
|
||
sceneBuckets: normalizeSceneBuckets(
|
||
referenceProfileItem.sceneBuckets,
|
||
fallback.referenceProfile.sceneBuckets,
|
||
),
|
||
creatureArchetypes: normalizeCreatureArchetypes(
|
||
referenceProfileItem.creatureArchetypes,
|
||
fallback.referenceProfile.creatureArchetypes,
|
||
),
|
||
},
|
||
compatibilityProfile: {
|
||
compatibilityTemplateWorldType:
|
||
compatibilityProfileItem.compatibilityTemplateWorldType === WorldType.XIANXIA
|
||
? WorldType.XIANXIA
|
||
: compatibilityProfileItem.compatibilityTemplateWorldType === WorldType.WUXIA
|
||
? WorldType.WUXIA
|
||
: fallback.compatibilityProfile?.compatibilityTemplateWorldType ?? null,
|
||
legacyTemplateWorldType:
|
||
compatibilityProfileItem.legacyTemplateWorldType === WorldType.XIANXIA
|
||
? WorldType.XIANXIA
|
||
: compatibilityProfileItem.legacyTemplateWorldType === WorldType.WUXIA
|
||
? WorldType.WUXIA
|
||
: fallback.compatibilityProfile?.legacyTemplateWorldType ?? null,
|
||
migrationVersion:
|
||
toText(compatibilityProfileItem.migrationVersion) ||
|
||
fallback.compatibilityProfile?.migrationVersion ||
|
||
OWNED_SETTING_LAYER_MIGRATION_VERSION,
|
||
},
|
||
} satisfies CustomWorldOwnedSettingLayers;
|
||
}
|
||
|
||
export function resolveCustomWorldOwnedSettingLayers(
|
||
profile: CustomWorldProfile | null | undefined,
|
||
) {
|
||
if (!profile) {
|
||
return null;
|
||
}
|
||
|
||
return profile.ownedSettingLayers ?? compileOwnedSettingLayersFromLegacyTemplate(profile);
|
||
}
|
||
|
||
export function resolveCustomWorldRuleProfile(
|
||
profile: CustomWorldProfile | null | undefined,
|
||
) {
|
||
return resolveCustomWorldOwnedSettingLayers(profile)?.ruleProfile ?? null;
|
||
}
|
||
|
||
export function resolveCustomWorldExpressionProfile(
|
||
profile: CustomWorldProfile | null | undefined,
|
||
) {
|
||
return resolveCustomWorldOwnedSettingLayers(profile)?.expressionProfile ?? null;
|
||
}
|
||
|
||
export function resolveCustomWorldSemanticAnchor(
|
||
profile: CustomWorldProfile | null | undefined,
|
||
) {
|
||
return resolveCustomWorldOwnedSettingLayers(profile)?.semanticAnchor ?? null;
|
||
}
|
||
|
||
export function resolveCustomWorldCompatibilityProfile(
|
||
profile: CustomWorldProfile | null | undefined,
|
||
) {
|
||
return resolveCustomWorldOwnedSettingLayers(profile)?.compatibilityProfile ?? null;
|
||
}
|