Files
Genarrative/src/services/storyEngine/themePack.ts
kdletters cbc27bad4a
Some checks failed
CI / verify (push) Has been cancelled
init with react+axum+spacetimedb
2026-04-26 18:06:23 +08:00

273 lines
11 KiB
TypeScript

import type {
CustomWorldProfile,
ThemePack,
WorldTemplateType,
WorldType,
} from '../../types';
import { detectCustomWorldThemeMode } from '../customWorldTheme';
type ThemePackPreset = Omit<ThemePack, 'id' | 'displayName'> & {
displayName: string;
};
const THEME_PACK_PRESETS: Record<string, ThemePackPreset> = {
mythic: {
displayName: '自定义回响',
toneRange: ['未知', '克制', '余波未定', '局势待开'],
institutionLexicon: ['据点', '同盟', '旅团', '档案室', '哨站', '归舍'],
tabooLexicon: ['失约', '旧痕', '越界', '封存', '误触', '回响'],
artifactClasses: ['信物', '残页', '封匣', '样本', '旧钥', '印记'],
actorArchetypes: ['见证者', '守望人', '异乡来客', '带路人', '失序幸存者'],
conflictForms: ['追查', '护送', '回收', '分歧对峙', '失踪追索'],
clueForms: ['痕迹', '记录', '口供', '残片', '旧图'],
namingPatterns: ['地点+余痕+器类', '势力+旧称+用途', '事件+残响+物件'],
revealStyles: ['循序松口', '线索回指', '保留一层', '让事实自己浮出'],
},
martial: {
displayName: '江湖旧事',
toneRange: ['冷峻', '克制', '刀锋般紧绷', '旧案余震'],
institutionLexicon: ['门派', '镖局', '巡司', '商号', '关隘', '行旅'],
tabooLexicon: ['旧案', '灭门', '失契', '私印', '禁脉', '断誓'],
artifactClasses: ['遗兵', '信物', '令牌', '残卷', '旧佩', '封匣'],
actorArchetypes: ['游侠', '守路人', '旧案见证者', '带路人', '避祸者'],
conflictForms: ['寻仇', '护送', '围剿', '失踪追查', '门派角力'],
clueForms: ['刀痕', '旧誓', '口供', '渡口风声', '残页'],
namingPatterns: ['旧称+伤痕+器类', '地标+余痕+器类', '势力+制式+用途'],
revealStyles: ['试探', '旁敲侧击', '旧事回响', '迟疑松口'],
},
arcane: {
displayName: '灵脉秘闻',
toneRange: ['清冷', '玄异', '高压', '因果牵引'],
institutionLexicon: ['宗门', '法坛', '巡守司', '灵舟会', '洞府', '云阙'],
tabooLexicon: ['封印', '逆脉', '禁术', '残魂', '天契', '旧誓'],
artifactClasses: ['法器', '灵符', '玉简', '残卷', '封匣', '阵核'],
actorArchetypes: ['巡守使', '隐修者', '守秘人', '失格弟子', '旧阵幸存者'],
conflictForms: ['夺脉', '追索', '封印失衡', '宗门旧案', '秘境争夺'],
clueForms: ['灵痕', '阵纹', '残识', '旧契', '法简'],
namingPatterns: ['云阙+残痕+器类', '灵脉+封痕+器类', '誓约+余烬+器类'],
revealStyles: ['碎片式透露', '借象示意', '以术遮掩', '因果回指'],
},
machina: {
displayName: '机巧裂域',
toneRange: ['冷硬', '压迫', '机械失序', '余温未散'],
institutionLexicon: ['财团', '工坊', '舰队', '前哨站', '调查局', '机库'],
tabooLexicon: ['过载', '黑匣', '失控协议', '封存日志', '污染区'],
artifactClasses: ['芯片', '驱动核', '制式装置', '记录模组', '封存匣'],
actorArchetypes: ['技师', '巡检员', '失联驾驶员', '前线回收者', '见证者'],
conflictForms: ['封锁', '回收', '追查事故', '公司内斗', '前线失守'],
clueForms: ['烧蚀痕', '日志', '封条', '校准码', '脉冲残波'],
namingPatterns: ['编号+余痕+器类', '站点+制式+用途', '事故+残片+器类'],
revealStyles: ['日志碎片', '术语遮掩', '延迟承认', '故障回声'],
},
tide: {
displayName: '潮痕旧闻',
toneRange: ['潮湿', '迷雾', '迟滞', '暗潮涌动'],
institutionLexicon: ['港务', '巡海司', '渡船会', '哨塔', '湾区营地', '潮站'],
tabooLexicon: ['沉船', '失契', '潮誓', '禁海区', '回潮夜'],
artifactClasses: ['潮印', '旧锚', '航图', '信标', '封潮匣', '雾骨遗物'],
actorArchetypes: ['渡口守更人', '巡潮者', '失船幸存者', '采录员', '岸线猎人'],
conflictForms: ['封港', '追查失踪', '海路争夺', '潮灾余波', '护送穿渡'],
clueForms: ['潮痕', '盐霜', '航线残页', '旧锚印', '失物清单'],
namingPatterns: ['潮名+伤痕+器类', '港口+遗痕+器类', '誓约+余潮+器类'],
revealStyles: ['传闻递进', '潮汐比喻', '回避本名', '隔雾指认'],
},
rift: {
displayName: '裂界前线',
toneRange: ['焦灼', '边境压力', '失序', '战线余烬'],
institutionLexicon: ['前哨', '巡边队', '断层站', '界桥营', '回收组', '观察哨'],
tabooLexicon: ['断层失守', '回响名单', '界外污染', '封桥令', '旧撤离线'],
artifactClasses: ['界核', '锚印', '边潮样本', '回响记录', '裂缝制式物'],
actorArchetypes: ['边巡者', '失线幸存者', '回收员', '名单记录人', '封桥见证者'],
conflictForms: ['守线', '撤离', '回收异常', '追查失线', '前线补给争夺'],
clueForms: ['裂痕', '界压残波', '名单残页', '旧封条', '警示标记'],
namingPatterns: ['裂界+旧痕+器类', '前哨+制式+用途', '名单+残响+器类'],
revealStyles: ['压力主导', '证词错位', '名单回响', '旧事倒灌'],
},
};
function dedupeStrings(values: Array<string | null | undefined>, limit = 8) {
return [...new Set((values ?? []).map((value) => value?.trim() ?? '').filter(Boolean))]
.slice(0, limit);
}
function collectProfileLexicon(profile: Pick<
CustomWorldProfile,
'majorFactions' | 'coreConflicts' | 'summary' | 'tone' | 'playerGoal'
>) {
return dedupeStrings([
...(profile.majorFactions ?? []),
...(profile.coreConflicts ?? []),
profile.summary,
profile.tone,
profile.playerGoal,
]);
}
function cloneThemePack(mode: string, preset: ThemePackPreset): ThemePack {
return {
id: `theme-pack:${mode}`,
displayName: preset.displayName,
toneRange: [...preset.toneRange],
institutionLexicon: [...preset.institutionLexicon],
tabooLexicon: [...preset.tabooLexicon],
artifactClasses: [...preset.artifactClasses],
actorArchetypes: [...preset.actorArchetypes],
conflictForms: [...preset.conflictForms],
clueForms: [...preset.clueForms],
namingPatterns: [...preset.namingPatterns],
revealStyles: [...preset.revealStyles],
};
}
function collectSemanticAnchorLexicon(
profile: Pick<CustomWorldProfile, 'ownedSettingLayers'>,
) {
const semanticAnchor = profile.ownedSettingLayers?.semanticAnchor;
const expressionProfile = profile.ownedSettingLayers?.expressionProfile;
if (!semanticAnchor && !expressionProfile) {
return [];
}
return dedupeStrings([
...(semanticAnchor?.genreSignals ?? []),
...(semanticAnchor?.conflictForms ?? []),
...(semanticAnchor?.institutionTypes ?? []),
...(semanticAnchor?.tabooTypes ?? []),
...(semanticAnchor?.carrierTypes ?? []),
...(semanticAnchor?.forceSystemTypes ?? []),
...(semanticAnchor?.atmosphereTags ?? []),
...(expressionProfile?.presentationTone ?? []),
...(expressionProfile?.namingDirectives ?? []),
...(expressionProfile?.clueDirectives ?? []),
...(expressionProfile?.revealDirectives ?? []),
]);
}
function resolveThemeModeFromWorldType(
worldType: WorldTemplateType | WorldType | null | undefined,
) {
if (worldType === 'XIANXIA') {
return 'arcane';
}
return 'mythic';
}
export function resolveFallbackThemePack(
worldType: WorldTemplateType | WorldType | null | undefined,
) {
const mode = resolveThemeModeFromWorldType(worldType);
return cloneThemePack(mode, THEME_PACK_PRESETS[mode]!);
}
export function normalizeThemePack(
value: unknown,
fallback: ThemePack,
): ThemePack {
if (!value || typeof value !== 'object') {
return fallback;
}
const item = value as Partial<ThemePack>;
const readList = (candidate: unknown, fallbackValue: string[]) => {
const next = dedupeStrings(candidate as string[], fallbackValue.length);
return next.length > 0 ? next : fallbackValue;
};
return {
id: typeof item.id === 'string' && item.id.trim() ? item.id.trim() : fallback.id,
displayName:
typeof item.displayName === 'string' && item.displayName.trim()
? item.displayName.trim()
: fallback.displayName,
toneRange: readList(item.toneRange, fallback.toneRange),
institutionLexicon: readList(
item.institutionLexicon,
fallback.institutionLexicon,
),
tabooLexicon: readList(item.tabooLexicon, fallback.tabooLexicon),
artifactClasses: readList(item.artifactClasses, fallback.artifactClasses),
actorArchetypes: readList(item.actorArchetypes, fallback.actorArchetypes),
conflictForms: readList(item.conflictForms, fallback.conflictForms),
clueForms: readList(item.clueForms, fallback.clueForms),
namingPatterns: readList(item.namingPatterns, fallback.namingPatterns),
revealStyles: readList(item.revealStyles, fallback.revealStyles),
};
}
export function buildThemePackFromWorldProfile(
profile: Pick<
CustomWorldProfile,
| 'settingText'
| 'summary'
| 'tone'
| 'playerGoal'
| 'templateWorldType'
| 'majorFactions'
| 'coreConflicts'
| 'ownedSettingLayers'
> & {
templateWorldType: WorldTemplateType | WorldType;
},
) {
const mode = detectCustomWorldThemeMode(profile);
const base = cloneThemePack(mode, THEME_PACK_PRESETS[mode]!);
const ownedThemePack = profile.ownedSettingLayers?.expressionProfile?.themePack;
if (ownedThemePack) {
return normalizeThemePack(ownedThemePack, base);
}
const lexicon = collectProfileLexicon(profile);
const semanticLexicon = collectSemanticAnchorLexicon(profile);
const semanticAnchor = profile.ownedSettingLayers?.semanticAnchor;
const expressionProfile = profile.ownedSettingLayers?.expressionProfile;
return normalizeThemePack(
{
...base,
institutionLexicon: dedupeStrings([
...base.institutionLexicon,
...(semanticAnchor?.institutionTypes ?? []),
...lexicon.filter((item) => item.length >= 2),
]),
tabooLexicon: dedupeStrings([
...base.tabooLexicon,
...(semanticAnchor?.tabooTypes ?? []),
...(profile.coreConflicts ?? []).slice(0, 4),
]),
artifactClasses: dedupeStrings([
...base.artifactClasses,
...(semanticAnchor?.carrierTypes ?? []),
]),
conflictForms: dedupeStrings([
...base.conflictForms,
...(semanticAnchor?.conflictForms ?? []),
...(profile.coreConflicts ?? []).slice(0, 3),
]),
clueForms: dedupeStrings([
...base.clueForms,
...(expressionProfile?.clueDirectives ?? []),
...(profile.majorFactions ?? []).slice(0, 3),
]),
namingPatterns: dedupeStrings([
...base.namingPatterns,
...(expressionProfile?.namingDirectives ?? []),
]),
revealStyles: dedupeStrings([
...base.revealStyles,
...(expressionProfile?.revealDirectives ?? []),
]),
toneRange: dedupeStrings([
profile.tone,
...(expressionProfile?.presentationTone ?? []),
...base.toneRange,
]),
actorArchetypes: dedupeStrings([
...base.actorArchetypes,
...semanticLexicon.filter((item) => item.length >= 2),
]),
},
base,
);
}