Simplify custom world result editing controls

This commit is contained in:
2026-04-08 19:07:46 +08:00
parent bd9fdcbe31
commit a02f7b6414
125 changed files with 8804 additions and 1462 deletions

View 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;
}