Simplify custom world result editing controls
This commit is contained in:
969
src/services/customWorldOwnedSettingLayers.ts
Normal file
969
src/services/customWorldOwnedSettingLayers.ts
Normal file
@@ -0,0 +1,969 @@
|
||||
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 } 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' | 'dangerLevel'>,
|
||||
) {
|
||||
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 landmark.dangerLevel === 'high' || landmark.dangerLevel === 'extreme'
|
||||
? '高压交汇区'
|
||||
: '叙事缓冲区';
|
||||
}
|
||||
|
||||
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: role.templateCharacterId
|
||||
? [role.templateCharacterId]
|
||||
: [],
|
||||
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(
|
||||
[landmark.dangerLevel, ...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: profile.templateWorldType,
|
||||
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) {
|
||||
return {
|
||||
legacyTemplateWorldType: profile.templateWorldType ?? WorldType.WUXIA,
|
||||
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,
|
||||
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: {
|
||||
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;
|
||||
}
|
||||
Reference in New Issue
Block a user