优化:文案优化,去主题化

This commit is contained in:
taojiacheng committed 2026-07-26 12:57:51 +08:00
1 parent 5b822f7dc8
commit 81a48229d8
16 files changed
+396 -494

No files matched your search

@@ -1,13 +1,46 @@
import { describe, expect, it } from 'vitest';
import { PRODUCTION_SECT_PRESENTATIONS } from './productionRuntime';
import { StandardSectOrganizationModule } from '../core';
import {
PRODUCTION_SECT_PRESENTATIONS,
PRODUCTION_SECTS,
} from './productionRuntime';
const taskIds = [
'gate_sweep',
'mine_patrol',
'pill_delivery',
'artifact_delivery',
'weekly_diligence',
'weekly_tournament',
'weekly_bounty',
'elder_trial',
] as const;
const canonicalNpcRoles = [
{
sigil: '执',
identity: '值日执事',
responsibility: '负责日常委托。',
},
{
sigil: '簿',
identity: '功簿执事',
responsibility: '负责周常委托。',
},
{
sigil: '传',
identity: '传功长老',
responsibility: '负责晋升试炼。',
},
] as const;
describe('production sect affairs presentations', () => {
it('provides twelve named NPCs across the four production sects', () => {
it('provides twelve named NPCs with canonical roles', () => {
const expectedNames = [
['林砚秋', '顾闻锋', '谢停云'],
['明尘', '照业', '渡厄'],
['纪司辰', '许衡章', '容观澜'],
['沈照灯', '白守簿', '楚引归'],
['陆青崖', '裴守拙', '听剑老人'],
['法明', '慧觉', '空慈方丈'],
['知微', '玄衡道人', '观澜真人'],
['照灯', '守簿翁', '归魂婆婆'],
];
const presentations = Object.values(PRODUCTION_SECT_PRESENTATIONS);
@@ -20,10 +53,21 @@ describe('production sect affairs presentations', () => {
for (const presentation of presentations) {
const npcs = Object.values(presentation.affairsRoom.taskNpcs);
expect(new Set(npcs.map((npc) => npc.id)).size).toBe(3);
for (const npc of npcs) {
expect(npc.identity.trim()).not.toBe('');
expect(npc.responsibility.trim()).not.toBe('');
expect(npc.greeting.trim()).not.toBe('');
expect(npcs).toMatchObject(canonicalNpcRoles);
for (const npc of npcs) expect(npc.greeting.trim()).not.toBe('');
expect(presentation.terms.sweepActivity).toBe('清扫山门');
expect(presentation.terms.sweepCanvasLabel).toBe('清扫山门游戏画布');
}
});
it('uses one canonical presentation for every standard task', () => {
const standard = new StandardSectOrganizationModule();
for (const { module } of PRODUCTION_SECTS) {
for (const taskId of taskIds) {
expect(module.organization.tasks.get(taskId)?.presentation).toEqual(
standard.tasks.get(taskId)?.presentation,
);
}
}
});
@@ -151,4 +151,20 @@ describe('LingxiaoOrganizationModule', () => {
expect(first?.inventory.artifacts).toEqual([]);
expect(first?.spiritual_roots).toEqual([]);
});
it.each([
['mine_patrol', '矿场巡视', '矿脉侵扰妖兽'],
['weekly_tournament', '宗门小比', '同门演武傀儡'],
['weekly_bounty', '悬赏残影战', '叛徒残影'],
['elder_trial', '长老试炼', '传功长老化身'],
])('uses canonical copy for %s battle opponents', (taskId, title, name) => {
const result = LINGXIAO_ORGANIZATION.battles.get(taskId)?.create({
player: playerFixture(),
mirror: playerFixture({ id: 'mirror', name: '同门' }),
opponentId: `npc-${taskId}`,
});
expect(result?.title).toBe(title);
expect(result?.opponent.name).toBe(name);
});
});
@@ -5,45 +5,6 @@ import {
/** 红尘剑宗只声明组织玩法的展示主题;核心流程由标准组织模块提供。 */
export const LINGXIAO_ORGANIZATION_THEME: SectOrganizationTheme = {
taskPresentation: {
gate_sweep: {
description: '沿通往山下的石阶清理落叶,完成一轮山门勤务。',
actionLabel: '前往山阶清扫',
dialogue: {
offeredReply: '山阶扫叶便交给我吧',
activeReply: '山阶那桩勤务,请再说一遍',
claimableReply: '山阶已经扫净,请录事查验',
claimedReply: '请替我查查山阶勤务的功簿',
instruction: {
text: '沿通往山下的石阶清去落叶,晨钟再响前回来复命。',
},
},
},
weekly_tournament: {
description: '在试剑台与同门剑影验证本周修行。',
dialogue: {
offeredReply: '本周试剑,我来应战',
activeReply: '试剑台的安排,请再说一遍',
claimableReply: '本周试剑已经结束,请录事查验',
claimedReply: '请替我查查本周试剑的功簿',
instruction: {
text: '去试剑台与同门剑影交手,以本周所得胜过一场,再回来复命。',
},
},
},
elder_trial: {
description: '接下传功长老三式问剑,取得真传资格。',
dialogue: {
offeredReply: '弟子愿受三式问剑',
activeReply: '三式问剑,请长老再作指点',
claimableReply: '弟子已经过关,请长老查验',
claimedReply: '请长老查验弟子的问剑记录',
instruction: {
text: '去试炼场接我三式问剑。胜过剑影,才算有资格在真传名册上落笔。',
},
},
},
},
shopGrants: {
true_cloud_ore: {
name: '百炼山铁',
@@ -51,10 +12,6 @@ export const LINGXIAO_ORGANIZATION_THEME: SectOrganizationTheme = {
},
true_spirit_pill: { name: '问剑蕴神丹' },
},
opponents: {
weekly_tournament: { name: '同门试剑傀儡' },
elder_trial: { name: '传功长老剑影' },
},
stipendGrantNames: { trueHerb: '剑叶灵草' },
};
@@ -325,30 +325,21 @@ export const LINGXIAO_SECT_PRESENTATION: SectPresentationTheme = {
},
affairsRoom: {
description:
'旧剑与归宗名册分列堂中,三席录事各守一案。寻到经办之人,便可当面接下或交回事务。',
'旧剑与功簿分列堂中,三席经办之人各守一案。寻到对应之人,便可当面接下或交回事务。',
taskNpcs: {
daily: {
id: 'lingxiao-lin-yanqiu',
sigil: '录',
name: '林砚秋',
identity: '外务录事',
responsibility: '山门勤务、矿场巡视与丹器交付。',
greeting: '今日外务已经理清,你先说想办哪一桩。',
name: '陆青崖',
greeting: '今日事务已经理清,你先说想办哪一桩。',
},
weekly: {
id: 'lingxiao-gu-wenfeng',
sigil: '簿',
name: '顾闻锋',
identity: '功簿录事',
responsibility: '周勤、试剑与宗门悬赏。',
name: '裴守拙',
greeting: '本周功簿在此,小比与悬赏也都记着。',
},
promotion: {
id: 'lingxiao-xie-tingyun',
sigil: '剑',
name: '谢停云',
identity: '传功长老',
responsibility: '三式问剑与真传考校。',
name: '听剑老人',
greeting: '晋升问的不是锋芒多少,而是这一剑为何而出。',
},
},
@@ -359,7 +350,5 @@ export const LINGXIAO_SECT_PRESENTATION: SectPresentationTheme = {
meridianLoadout: '参悟方案',
abilityChanges: '神通变化',
returnToAffairs: '返回执事堂',
sweepActivity: '山阶扫叶',
sweepCanvasLabel: '山阶扫叶游戏画布',
},
};
@@ -1,48 +1,6 @@
import type { SectOrganizationTheme } from '../../core';
export const TIANYAN_ORGANIZATION_THEME: SectOrganizationTheme = {
taskPresentation: {
gate_sweep: {
description:
'沿观象门与中宫之间校正被云雨冲乱的五行地刻,并记录今日偏差。',
actionLabel: '前往观象阶校正地刻',
dialogue: {
offeredReply: '今日地刻由我来校正',
activeReply: '地刻校正之事,请再说一遍',
claimableReply: '地刻已经校正,请算使查验',
claimedReply: '请替我查查今日校正的记录',
instruction: {
text: '沿观象门至中宫逐段校正被云雨冲乱的五行地刻,并记下今日偏差。',
},
},
},
weekly_tournament: {
description: '在中宫演法台与同门推演傀儡验证本周神通次序。',
actionLabel: '参加中宫衍法',
dialogue: {
offeredReply: '本周衍法,我来落子',
activeReply: '中宫衍法的次序,请再说一遍',
claimableReply: '本周衍法已经完成,请算使查验',
claimedReply: '请替我查查本周衍法的记录',
instruction: {
text: '去中宫演法台与推演傀儡交手,以本周神通次序完成一局。',
},
},
},
elder_trial: {
description: '接下河洛长老布置的三局残阵,在有限术式中完成破局。',
actionLabel: '应对河洛问局',
dialogue: {
offeredReply: '弟子愿解三局残阵',
activeReply: '三局残阵,请长老再作指点',
claimableReply: '残阵已经解开,请长老查验',
claimedReply: '请长老查验弟子的破局记录',
instruction: {
text: '入河洛台解开三局残阵。术式虽有限,落子却不止一种,破局后再来见我。',
},
},
},
},
shopGrants: {
true_cloud_ore: {
name: '归流玄铜',
@@ -50,10 +8,6 @@ export const TIANYAN_ORGANIZATION_THEME: SectOrganizationTheme = {
},
true_spirit_pill: { name: '太初蕴神丹' },
},
opponents: {
weekly_tournament: { name: '同门推演傀儡' },
elder_trial: { name: '河洛长老法影' },
},
facilityNames: {
archive: '五经阁',
cultivation_room: '太初静室',
@@ -324,31 +324,22 @@ export const TIANYAN_SECT_PRESENTATION: SectPresentationTheme = {
},
},
affairsRoom: {
description: '司算院长卷依时辰铺开,三席各自校验日务、周录与晋升问局。',
description: '司算院长卷依时辰铺开,日常、周常与晋升事务各归一席。',
taskNpcs: {
daily: {
id: 'tianyan-ji-sichen',
sigil: '辰',
name: '纪司辰',
identity: '当值算使',
responsibility: '日常校正、地脉与丹器差事。',
name: '知微',
greeting: '今日时序已经排定,眼下可办的差事都在这里。',
},
weekly: {
id: 'tianyan-xu-hengzhang',
sigil: '衡',
name: '许衡章',
identity: '功簿算使',
responsibility: '周录、衍法与宗门悬契。',
name: '玄衡道人',
greeting: '本周诸事已有定数,但落子之人仍要你自己选。',
},
promotion: {
id: 'tianyan-rong-guanlan',
sigil: '洛',
name: '容观澜',
identity: '河洛长老',
responsibility: '三局残阵与晋升考校。',
greeting: '残阵不求唯一解法,只看你能否辨清下一步。',
name: '观澜真人',
greeting: '晋升试炼不求捷径,只看你能否辨清下一步。',
},
},
},
@@ -358,7 +349,5 @@ export const TIANYAN_SECT_PRESENTATION: SectPresentationTheme = {
meridianLoadout: '推演方案',
abilityChanges: '神通衍变',
returnToAffairs: '返回司算院',
sweepActivity: '校正地刻',
sweepCanvasLabel: '天衍地刻校正游戏画布',
},
};
@@ -1,12 +1,5 @@
import { GameplayTags } from '@shared/engine/shared/tag-domain';
import { describe, expect, it } from 'vitest';
import {
PRODUCTION_SECT_IDS,
projectSectCombat,
resolveSectAbility,
resolveSectAbilities,
} from '../..';
import { SectStateValidator, isListedSectAbility } from '../../../core';
import {
TIANYAN_HETU_PATH_ID,
TIANYAN_LANDING_ABILITY_IDS,
@@ -17,6 +10,13 @@ import {
TIANYAN_SECT_PRESENTATION,
TIANYAN_VISIBLE_ABILITY_IDS,
} from '..';
import {
PRODUCTION_SECT_IDS,
projectSectCombat,
resolveSectAbilities,
resolveSectAbility,
} from '../..';
import { SectStateValidator, isListedSectAbility } from '../../../core';
import { tianyanState } from './testState';
describe('天衍圣地战斗投影', () => {
@@ -24,10 +24,17 @@ describe('天衍圣地战斗投影', () => {
const definition = TIANYAN_MODULE.definition;
expect(PRODUCTION_SECT_IDS).toContain('tianyan');
expect(definition.configVersion).toBe(1);
expect(definition.methods.map((method) => method.id)).toEqual(TIANYAN_METHOD_IDS);
expect(definition.abilities.filter(isListedSectAbility).map((ability) => ability.id))
.toEqual(TIANYAN_VISIBLE_ABILITY_IDS);
expect(definition.abilities.filter((ability) => ability.kind === 'default')).toHaveLength(1);
expect(definition.methods.map((method) => method.id)).toEqual(
TIANYAN_METHOD_IDS,
);
expect(
definition.abilities
.filter(isListedSectAbility)
.map((ability) => ability.id),
).toEqual(TIANYAN_VISIBLE_ABILITY_IDS);
expect(
definition.abilities.filter((ability) => ability.kind === 'default'),
).toHaveLength(1);
expect(definition.paths.map((path) => path.id)).toEqual([
TIANYAN_HETU_PATH_ID,
TIANYAN_LUOSHU_PATH_ID,
@@ -37,7 +44,9 @@ describe('天衍圣地战斗投影', () => {
expect(path.nodes).toHaveLength(18);
expect(path.tactics).toHaveLength(3);
for (const layer of path.layers) {
expect(path.nodes.filter((node) => node.layerId === layer.id)).toHaveLength(3);
expect(
path.nodes.filter((node) => node.layerId === layer.id),
).toHaveLength(3);
}
}
});
@@ -86,7 +95,9 @@ describe('天衍圣地战斗投影', () => {
expect(new Set(definition.methods.map((entry) => entry.slot)).size).toBe(6);
expect(definition.onboarding.initialAbilityLoadout).toHaveLength(4);
for (const abilityId of definition.onboarding.initialAbilityLoadout) {
const ability = definition.abilities.find((entry) => entry.id === abilityId);
const ability = definition.abilities.find(
(entry) => entry.id === abilityId,
);
expect(ability).toBeDefined();
expect(ability?.kind).toBe('active');
}
@@ -95,39 +106,69 @@ describe('天衍圣地战斗投影', () => {
it('五幕入门、15个标准地图热点与组织展示主题完整', () => {
expect(TIANYAN_SECT_PRESENTATION.sectId).toBe('tianyan');
expect(TIANYAN_SECT_PRESENTATION.onboarding?.script?.acts).toHaveLength(5);
expect(TIANYAN_SECT_PRESENTATION.onboarding?.script?.backdrop.src)
.toBe('/assets/sect/onboarding/tianyan.webp');
expect(TIANYAN_SECT_PRESENTATION.map?.image)
.toBe('/assets/sect/tianyan-map.webp');
expect(TIANYAN_SECT_PRESENTATION.map?.hotspots?.map((spot) => spot.id))
.toEqual([
'hall', 'archive', 'cliff', 'arena', 'affairs', 'treasury',
'industries', 'cultivation', 'alchemy', 'refinery', 'vein',
'garden', 'gate', 'cave', 'formation',
]);
expect(TIANYAN_SECT_PRESENTATION.map?.hotspots?.find(
(spot) => spot.id === 'formation',
)).toMatchObject({ locked: true, facility: 'formation' });
expect(TIANYAN_ORGANIZATION_THEME.taskPresentation).toMatchObject({
gate_sweep: { actionLabel: '前往观象阶校正地刻' },
weekly_tournament: { actionLabel: '参加中宫衍法' },
elder_trial: { actionLabel: '应对河洛问局' },
});
expect(TIANYAN_ORGANIZATION_THEME.opponents).toMatchObject({
weekly_tournament: { name: '同门推演傀儡' },
elder_trial: { name: '河洛长老法影' },
});
expect(TIANYAN_SECT_PRESENTATION.onboarding?.script?.backdrop.src).toBe(
'/assets/sect/onboarding/tianyan.webp',
);
expect(TIANYAN_SECT_PRESENTATION.map?.image).toBe(
'/assets/sect/tianyan-map.webp',
);
expect(
TIANYAN_SECT_PRESENTATION.map?.hotspots?.map((spot) => spot.id),
).toEqual([
'hall',
'archive',
'cliff',
'arena',
'affairs',
'treasury',
'industries',
'cultivation',
'alchemy',
'refinery',
'vein',
'garden',
'gate',
'cave',
'formation',
]);
expect(
TIANYAN_SECT_PRESENTATION.map?.hotspots?.find(
(spot) => spot.id === 'formation',
),
).toMatchObject({ locked: true, facility: 'formation' });
expect(
TIANYAN_MODULE.organization.tasks.get('gate_sweep')?.presentation
.actionLabel,
).toBe('开始清扫');
expect(
TIANYAN_MODULE.organization.tasks.get('weekly_tournament')?.presentation
.actionLabel,
).toBe('参加小比');
expect(
TIANYAN_MODULE.organization.tasks.get('elder_trial')?.presentation
.actionLabel,
).toBe('挑战长老试炼');
expect(TIANYAN_ORGANIZATION_THEME).not.toHaveProperty('taskPresentation');
expect(TIANYAN_ORGANIZATION_THEME).not.toHaveProperty('opponents');
});
it.each([TIANYAN_HETU_PATH_ID, TIANYAN_LUOSHU_PATH_ID])(
'%s 状态可通过标准校验并生成唯一衍数资源',
(pathId) => {
const sect = tianyanState(pathId);
expect(() => new SectStateValidator().validate(TIANYAN_MODULE, sect)).not.toThrow();
expect(() =>
new SectStateValidator().validate(TIANYAN_MODULE, sect),
).not.toThrow();
const projection = projectSectCombat({ sect, realm: '化神' });
expect(projection).not.toBeNull();
expect(projection?.resources).toEqual([
{ id: 'sect.tianyan.derivation', name: '衍数', icon: '✨', initial: 0, max: 3 },
{
id: 'sect.tianyan.derivation',
name: '衍数',
icon: '✨',
initial: 0,
max: 3,
},
]);
expect(projection?.selectionStrategy).toBeDefined();
},
@@ -136,23 +177,36 @@ describe('天衍圣地战斗投影', () => {
it('六门落印术共享无印与五旧印六分支,并携带元素及灵根失配豁免', () => {
const sect = tianyanState(TIANYAN_HETU_PATH_ID);
for (const abilityId of TIANYAN_LANDING_ABILITY_IDS) {
const config = resolveSectAbility({ sect, realm: '化神', abilityId }).config;
const config = resolveSectAbility({
sect,
realm: '化神',
abilityId,
}).config;
expect(config.effectLayers?.map((layer) => layer.id)).toEqual([
'no-seal', 'old-wood', 'old-fire', 'old-earth', 'old-metal', 'old-water',
'no-seal',
'old-wood',
'old-fire',
'old-earth',
'old-metal',
'old-water',
]);
expect(config.effectPlans).toHaveLength(6);
expect(config.tags).toContain(
GameplayTags.ABILITY.MECHANIC.IGNORE_SPIRITUAL_ROOT_MISMATCH,
);
expect(
config.tags?.some((tag) => tag.startsWith(`${GameplayTags.ABILITY.ELEMENT.ROOT}.`)),
config.tags?.some((tag) =>
tag.startsWith(`${GameplayTags.ABILITY.ELEMENT.ROOT}.`),
),
).toBe(true);
}
});
it('太初玄光是无属性、零耗且不具备落印或灵根失配豁免标签', () => {
const config = resolveSectAbility({
sect: tianyanState(), realm: '化神', abilityId: 'primordial-ray',
sect: tianyanState(),
realm: '化神',
abilityId: 'primordial-ray',
}).config;
expect(config.mpCost).toBe(0);
expect(config.cooldown).toBe(0);
@@ -160,27 +214,33 @@ describe('天衍圣地战斗投影', () => {
GameplayTags.ABILITY.MECHANIC.IGNORE_SPIRITUAL_ROOT_MISMATCH,
);
expect(
config.tags?.some((tag) => tag.startsWith(`${GameplayTags.ABILITY.ELEMENT.ROOT}.`)),
config.tags?.some((tag) =>
tag.startsWith(`${GameplayTags.ABILITY.ELEMENT.ROOT}.`),
),
).toBe(false);
});
it('灼烧与熔岩来源Buff携带火元素及异灵根失配豁免标签', () => {
const sect = tianyanState(TIANYAN_HETU_PATH_ID);
const buffTags = (abilityId: string, layerId: string, buffId: string) => {
const config = resolveSectAbility({ sect, realm: '化神', abilityId }).config;
const config = resolveSectAbility({
sect,
realm: '化神',
abilityId,
}).config;
const effect = config.effectLayers
?.find((layer) => layer.id === layerId)
?.effects.find((entry) =>
entry.type === 'apply_buff' && entry.params.buffConfig.id === buffId,
?.effects.find(
(entry) =>
entry.type === 'apply_buff' &&
entry.params.buffConfig.id === buffId,
);
return effect?.type === 'apply_buff' ? effect.params.buffConfig.tags ?? [] : [];
return effect?.type === 'apply_buff'
? (effect.params.buffConfig.tags ?? [])
: [];
};
const burnTags = buffTags(
'flowing-flame',
'no-seal',
'sect.tianyan.burn',
);
const burnTags = buffTags('flowing-flame', 'no-seal', 'sect.tianyan.burn');
const lavaTags = buffTags(
'earth-bearing-seal',
'old-fire',
@@ -197,11 +257,20 @@ describe('天衍圣地战斗投影', () => {
});
it('基础态可完整解析全部定义能力且只有13门列入玩家列表', () => {
const resolved = resolveSectAbilities({ sect: tianyanState(), realm: '化神' });
expect(resolved).toHaveLength(TIANYAN_MODULE.definition.abilities.length - 2);
expect(resolved.filter((ability) => TIANYAN_VISIBLE_ABILITY_IDS.includes(
ability.id as (typeof TIANYAN_VISIBLE_ABILITY_IDS)[number],
))).toHaveLength(13);
const resolved = resolveSectAbilities({
sect: tianyanState(),
realm: '化神',
});
expect(resolved).toHaveLength(
TIANYAN_MODULE.definition.abilities.length - 2,
);
expect(
resolved.filter((ability) =>
TIANYAN_VISIBLE_ABILITY_IDS.includes(
ability.id as (typeof TIANYAN_VISIBLE_ABILITY_IDS)[number],
),
),
).toHaveLength(13);
});
it('技能详情区分落印术、内景法与天衍秘法', () => {
@@ -222,7 +291,9 @@ describe('天衍圣地战斗投影', () => {
const details = resolveSectAbilities({
sect: tianyanState(TIANYAN_HETU_PATH_ID),
realm: '化神',
}).flatMap((ability) => ability.detailRows).join(';');
})
.flatMap((ability) => ability.detailRows)
.join(';');
expect(details).not.toContain('气血高于0%');
});
@@ -1,45 +1,6 @@
import type { SectOrganizationTheme } from '../../core';
export const WUXIANG_ORGANIZATION_THEME: SectOrganizationTheme = {
taskPresentation: {
gate_sweep: {
description: '沿照壁清理昨夜落下的香灰与血莲残瓣。',
actionLabel: '前往照壁洒扫',
dialogue: {
offeredReply: '照壁拂尘便交给我吧',
activeReply: '照壁那桩日务,请再说一遍',
claimableReply: '照壁已经洒扫干净,请师兄查验',
claimedReply: '请替我查查照壁日务的功簿',
instruction: {
text: '沿照壁清去昨夜落下的香灰与血莲残瓣,做完便回来复命。',
},
},
},
weekly_tournament: {
description: '在无相木人前验证佛魔转相是否仍守得住一念。',
dialogue: {
offeredReply: '本周校身,我愿一试',
activeReply: '无相木人的考校,请再说一遍',
claimableReply: '校身已经结束,请师兄查验',
claimedReply: '请替我查查本周校身的功簿',
instruction: {
text: '去问身场面对无相木人,在佛魔转相之间守住一念,胜过一场再回来。',
},
},
},
elder_trial: {
description: '承受戒律师三问,再以所得之业照还其身。',
dialogue: {
offeredReply: '弟子愿受三问照业',
activeReply: '三问照业,请戒律师再作指点',
claimableReply: '弟子已经照还此业,请戒律师查验',
claimedReply: '请戒律师查验弟子的照业记录',
instruction: {
text: '入问身场承受三问,再以所见之业照还法身。能见、能受、能渡,方算过关。',
},
},
},
},
shopGrants: {
true_cloud_ore: {
name: '无相骨玉',
@@ -47,10 +8,6 @@ export const WUXIANG_ORGANIZATION_THEME: SectOrganizationTheme = {
},
true_spirit_pill: { name: '照身定念丹' },
},
opponents: {
weekly_tournament: { name: '无相试身木人' },
elder_trial: { name: '戒律师法身' },
},
facilityNames: {
archive: '贝叶藏',
cultivation_room: '止观室',
@@ -319,26 +319,17 @@ export const WUXIANG_SECT_PRESENTATION: SectPresentationTheme = {
taskNpcs: {
daily: {
id: 'wuxiang-mingchen',
sigil: '尘',
name: '明尘',
identity: '知客僧',
responsibility: '洒扫、问身与日用交付。',
name: '法明',
greeting: '白榜上的日务都在这里,肯做哪一件便说。',
},
weekly: {
id: 'wuxiang-zhaoye',
sigil: '业',
name: '照业',
identity: '记功僧',
responsibility: '周勤、校身与宗门外契。',
name: '慧觉',
greeting: '功簿只记所行,不替人评说,你可自行查问。',
},
promotion: {
id: 'wuxiang-due',
sigil: '戒',
name: '渡厄',
identity: '戒律师',
responsibility: '三问照业与晋升考校。',
name: '空慈方丈',
greeting: '要过此关,先要肯照见自己带来的业。',
},
},
@@ -349,7 +340,5 @@ export const WUXIANG_SECT_PRESENTATION: SectPresentationTheme = {
meridianLoadout: '参悟方案',
abilityChanges: '三相神通',
returnToAffairs: '返回知客寮',
sweepActivity: '照壁拂尘',
sweepCanvasLabel: '照壁拂尘游戏画布',
},
};
@@ -1,126 +1,6 @@
import type { SectOrganizationTheme } from '../../core';
export const YOUDU_ORGANIZATION_THEME: SectOrganizationTheme = {
taskPresentation: {
gate_sweep: {
title: '巡查魂灯',
description: '沿黑水石径巡查魂灯,记下倒影与行人错开的每一瞬。',
actionLabel: '前往黑水巡灯',
dialogue: {
offeredReply: '今日魂灯由我来巡',
activeReply: '巡灯的路径,请再说一遍',
claimableReply: '魂灯已经巡完,请巡灯使查验',
claimedReply: '请替我查查今日巡灯的记录',
instruction: {
text: '沿黑水石径逐盏巡查魂灯,记下倒影与行人错开的地方,再回来复命。',
},
},
},
mine_patrol: {
title: '平息界隙回声',
description: '前往黑水阴脉,收束从界隙中逸出的失控念灵。',
actionLabel: '前往阴脉',
dialogue: {
offeredReply: '阴脉的回声由我来收束',
activeReply: '界隙回声一事,请再说一遍',
claimableReply: '失控念灵已经平息,请巡灯使查验',
claimedReply: '请替我查查阴脉回声的记录',
instruction: {
text: '去黑水阴脉找到界隙回声,将逸出的失控念灵收束干净,再回来复命。',
},
},
},
pill_delivery: {
title: '返照药契',
description: '寻来一枚合用丹药,供返照室安定离魂弟子的神识。',
actionLabel: '交付定魂丹药',
dialogue: {
offeredReply: '返照室所需丹药,我来寻',
activeReply: '返照药契的要求,请再说一遍',
claimableReply: '丹药已经入契,请巡灯使查验',
claimedReply: '请替我查查返照药契的记录',
instruction: {
text: '替返照室寻来一枚合用丹药,用来安定离魂弟子的神识。',
requirementPrefix: '替返照室寻来',
requirementSuffix: ',带回招魂司验契。',
},
},
},
artifact_delivery: {
title: '镇魂器契',
description: '寻来一件合用且未装备的法宝,用于修补镇魂仪轨。',
actionLabel: '交付镇魂法器',
dialogue: {
offeredReply: '修补镇魂仪轨的法器,我来寻',
activeReply: '镇魂器契的要求,请再说一遍',
claimableReply: '法器已经入契,请巡灯使查验',
claimedReply: '请替我查查镇魂器契的记录',
instruction: {
text: '替招魂司寻来一件合用的未装备法宝,用于修补镇魂仪轨。',
requirementPrefix: '替招魂司寻来',
requirementSuffix: ',带回后用于修补镇魂仪轨。',
},
},
},
weekly_diligence: {
title: '巡灯周录',
description: '一周完成五次招魂司勤务,补全黑水沿岸的巡灯簿。',
actionLabel: '查看巡灯簿',
dialogue: {
offeredReply: '本周巡灯簿也记我一份',
activeReply: '巡灯周录已经记到哪里了',
claimableReply: '本周巡灯簿已经补全,请命簿使查验',
claimedReply: '请替我翻翻本周巡灯簿',
instruction: {
text: '本周完成五次招魂司勤务,将黑水沿岸的巡灯簿补全。',
},
},
},
weekly_tournament: {
title: '照影校术',
description: '在照影场校验招魂、镇魄与送魂之法。',
actionLabel: '进入照影场',
dialogue: {
offeredReply: '本周照影校术,我来应试',
activeReply: '照影场的考校,请再说一遍',
claimableReply: '照影校术已经完成,请命簿使查验',
claimedReply: '请替我查查照影校术的记录',
instruction: {
text: '去照影场校验招魂、镇魄与送魂之法,胜过试魂傀再回来。',
},
},
},
weekly_bounty: {
title: '失名悬契',
description: '追查夺舍邪修、役魂商贩或伪造招魂契者留下的线索。',
actionLabel: '领取悬契',
dialogue: {
offeredReply: '这份失名悬契由我来查',
activeReply: '失名悬契的线索,请再说一遍',
claimableReply: '失名悬契已经办妥,请命簿使查验',
claimedReply: '请替我查查失名悬契的记录',
instruction: {
text: '循悬契追查夺舍邪修、役魂商贩或伪造契书者,找到残影后将其收伏。',
requirementPrefix: '这份悬契要验一件遗物。替我寻来',
requirementSuffix: ',带回后我会核验其魂息。',
},
},
},
elder_trial: {
title: '七灯问名',
description: '随引魂师穿过七灯,守住自己的姓名与归路。',
actionLabel: '踏入七灯阵',
dialogue: {
offeredReply: '弟子愿入七灯问名',
activeReply: '七灯问名,请引魂师再作指点',
claimableReply: '弟子已经走过七灯,请引魂师查验',
claimedReply: '请引魂师查验弟子的问名记录',
instruction: {
text: '踏入七灯阵,依次守住姓名、来处与归路。走过七灯仍不失本心,才算过关。',
},
},
},
},
shopGrants: {
outer_qinglu: {
name: '魂灯油',
@@ -147,12 +27,6 @@ export const YOUDU_ORGANIZATION_THEME: SectOrganizationTheme = {
description: '真传弟子渡魂远行前服用的高阶回气丹。',
},
},
opponents: {
mine_patrol: { title: '阴脉回声', name: '失控念灵' },
weekly_tournament: { title: '照影校术', name: '照影试魂傀' },
weekly_bounty: { title: '失名悬契', name: '役魂商贩残影' },
elder_trial: { title: '七灯问名', name: '引魂师法身' },
},
facilityNames: {
archive: '三魂阁',
cultivation_room: '返照室',
@@ -310,31 +310,22 @@ export const YOUDU_SECT_PRESENTATION: SectPresentationTheme = {
},
},
affairsRoom: {
description: '招魂司内灯影不灭,巡灯、命簿与七灯问名各由一席经办。',
description: '招魂司内灯影不灭,日常、周常与晋升事务各由一席经办。',
taskNpcs: {
daily: {
id: 'youdu-shen-zhaodeng',
sigil: '灯',
name: '沈照灯',
identity: '巡灯使',
responsibility: '巡灯、阴脉与药器交付。',
greeting: '今日还有几盏灯要照、几条归路要寻,你先挑一件。',
name: '照灯',
greeting: '今日卷宗都在灯下,你想办哪一件,直说便是。',
},
weekly: {
id: 'youdu-bai-shoubu',
sigil: '簿',
name: '白守簿',
identity: '命簿使',
responsibility: '周录、照影与失名悬契。',
greeting: '名字与归路都已写入本周命簿,你可逐项查验。',
name: '守簿翁',
greeting: '本周功簿已经归拢,你可逐项查验。',
},
promotion: {
id: 'youdu-chu-yingui',
sigil: '引',
name: '楚引归',
identity: '引魂师',
responsibility: '七灯问名与晋升考校。',
greeting: '七灯之下,守得住自己的名字,才有资格再往前走。',
name: '归魂婆婆',
greeting: '晋升这一关,先要记得自己为何而来。',
},
},
},
@@ -344,7 +335,5 @@ export const YOUDU_SECT_PRESENTATION: SectPresentationTheme = {
meridianLoadout: '参悟方案',
abilityChanges: '魂术变化',
returnToAffairs: '返回招魂司',
sweepActivity: '巡灯照影',
sweepCanvasLabel: '巡灯照影游戏画布',
},
};
@@ -1,11 +1,5 @@
import { GameplayTags } from '@shared/engine/shared/tag-domain';
import { describe, expect, it } from 'vitest';
import {
PRODUCTION_SECT_IDS,
projectSectCombat,
resolveSectAbility,
} from '../..';
import { isListedSectAbility, SectStateValidator } from '../../../core';
import {
YOUDU_DECREE_PATH_ID,
YOUDU_MODULE,
@@ -14,6 +8,12 @@ import {
YOUDU_TIDE_PATH_ID,
YOUDU_VISIBLE_ABILITY_IDS,
} from '..';
import {
PRODUCTION_SECT_IDS,
projectSectCombat,
resolveSectAbility,
} from '../..';
import { isListedSectAbility, SectStateValidator } from '../../../core';
import { YOUDU_ORGANIZATION_THEME } from '../organization';
import { youduState } from './testState';
@@ -22,10 +22,17 @@ describe('幽都战斗与展示投影', () => {
const definition = YOUDU_MODULE.definition;
expect(PRODUCTION_SECT_IDS).toContain('youdu');
expect(definition.methods).toHaveLength(6);
expect(definition.methods.filter((method) => method.isPrimary)).toHaveLength(1);
expect(definition.abilities.filter(isListedSectAbility).map((ability) => ability.id))
.toEqual(YOUDU_VISIBLE_ABILITY_IDS);
expect(definition.abilities.filter((ability) => ability.kind === 'default')).toHaveLength(1);
expect(
definition.methods.filter((method) => method.isPrimary),
).toHaveLength(1);
expect(
definition.abilities
.filter(isListedSectAbility)
.map((ability) => ability.id),
).toEqual(YOUDU_VISIBLE_ABILITY_IDS);
expect(
definition.abilities.filter((ability) => ability.kind === 'default'),
).toHaveLength(1);
expect(definition.paths.map((path) => path.id)).toEqual([
YOUDU_TIDE_PATH_ID,
YOUDU_DECREE_PATH_ID,
@@ -34,10 +41,14 @@ describe('幽都战斗与展示投影', () => {
expect(path.nodes).toHaveLength(18);
expect(path.tactics).toHaveLength(3);
for (const layer of path.layers) {
expect(path.nodes.filter((node) => node.layerId === layer.id)).toHaveLength(3);
expect(
path.nodes.filter((node) => node.layerId === layer.id),
).toHaveLength(3);
}
}
const ids = definition.paths.flatMap((path) => path.nodes.map((node) => node.id));
const ids = definition.paths.flatMap((path) =>
path.nodes.map((node) => node.id),
);
expect(new Set(ids).size).toBe(36);
});
@@ -55,12 +66,18 @@ describe('幽都战斗与展示投影', () => {
initialAbilityLoadout: ['soul-severing-call', null, null, null],
});
expect(YOUDU_SECT_PRESENTATION.onboarding?.script?.acts).toHaveLength(5);
expect(YOUDU_SECT_PRESENTATION.onboarding?.script?.backdrop.src)
.toBe('/assets/sect/onboarding/youdu.webp');
expect(YOUDU_SECT_PRESENTATION.map?.image).toBe('/assets/sect/youdu-map.webp');
expect(YOUDU_SECT_PRESENTATION.onboarding?.script?.backdrop.src).toBe(
'/assets/sect/onboarding/youdu.webp',
);
expect(YOUDU_SECT_PRESENTATION.map?.image).toBe(
'/assets/sect/youdu-map.webp',
);
expect(YOUDU_SECT_PRESENTATION.map?.hotspots).toHaveLength(15);
expect(YOUDU_SECT_PRESENTATION.map?.hotspots?.find((spot) => spot.id === 'formation'))
.toMatchObject({ locked: true, facility: 'formation' });
expect(
YOUDU_SECT_PRESENTATION.map?.hotspots?.find(
(spot) => spot.id === 'formation',
),
).toMatchObject({ locked: true, facility: 'formation' });
expect(YOUDU_SECT_PRESENTATION.facilityLabels).toMatchObject({
alchemy: '还魂药庐',
refinery: '镇铁炉',
@@ -71,11 +88,19 @@ describe('幽都战斗与展示投影', () => {
'%s 可通过标准状态校验并投影唯一魂火资源',
(pathId) => {
const state = youduState(pathId);
expect(() => new SectStateValidator().validate(YOUDU_MODULE, state)).not.toThrow();
expect(() =>
new SectStateValidator().validate(YOUDU_MODULE, state),
).not.toThrow();
const projection = projectSectCombat({ sect: state, realm: '化神' })!;
expect(projection.resources).toEqual([{
id: YOUDU_SOUL_FIRE, name: '魂火', icon: '🔥', initial: 0, max: 3,
}]);
expect(projection.resources).toEqual([
{
id: YOUDU_SOUL_FIRE,
name: '魂火',
icon: '🔥',
initial: 0,
max: 3,
},
]);
expect(projection.selectionStrategy).toBeDefined();
expect(projection.abilities.map((ability) => ability.slug)).not.toContain(
`sect.youdu.${pathId}-runtime`,
@@ -95,17 +120,31 @@ describe('幽都战斗与展示投影', () => {
'soul-shall-not-return': [80, 5],
} as const;
for (const [abilityId, [mpCost, cooldown]] of Object.entries(expected)) {
const config = resolveSectAbility({ sect: state, realm: '化神', abilityId }).config;
const config = resolveSectAbility({
sect: state,
realm: '化神',
abilityId,
}).config;
expect(config.mpCost ?? 0).toBe(mpCost);
expect(config.cooldown ?? 0).toBe(cooldown);
}
const shadow = resolveSectAbility({ sect: state, realm: '化神', abilityId: 'reveal-shadow' }).config;
const shadow = resolveSectAbility({
sect: state,
realm: '化神',
abilityId: 'reveal-shadow',
}).config;
expect(shadow.hitPolicy).toBe('guaranteed');
const finish = resolveSectAbility({ sect: state, realm: '化神', abilityId: 'soul-shall-not-return' }).config;
expect(finish.castConditions).toContainEqual(expect.objectContaining({
type: 'buff_layer_at_least',
params: expect.objectContaining({ value: 4 }),
}));
const finish = resolveSectAbility({
sect: state,
realm: '化神',
abilityId: 'soul-shall-not-return',
}).config;
expect(finish.castConditions).toContainEqual(
expect.objectContaining({
type: 'buff_layer_at_least',
params: expect.objectContaining({ value: 4 }),
}),
);
expect(finish.tags).toContain(GameplayTags.ABILITY.CHANNEL.TRUE);
});
@@ -231,13 +270,20 @@ describe('幽都战斗与展示投影', () => {
'tide-hundred-ghosts',
'tide-dream-invasion',
'tide-last-ferry',
].flatMap((nodeId) => resolveSectAbility({
sect: youduState(YOUDU_TIDE_PATH_ID, [nodeId]),
realm: '化神',
abilityId: 'one-sigh',
}).detailRows).join(';');
]
.flatMap(
(nodeId) =>
resolveSectAbility({
sect: youduState(YOUDU_TIDE_PATH_ID, [nodeId]),
realm: '化神',
abilityId: 'one-sigh',
}).detailRows,
)
.join(';');
const decreeRuntime = resolveSectAbility({
sect: decree, realm: '化神', abilityId: 'youdu-runtime',
sect: decree,
realm: '化神',
abilityId: 'youdu-runtime',
}).detailRows.join(';');
expect(tideRuntime).toContain('每回合首次忘川有效伤害获得1点魂火');
@@ -255,19 +301,26 @@ describe('幽都战斗与展示投影', () => {
sect: youduState(YOUDU_DECREE_PATH_ID),
realm: '化神',
})!;
const listeners = projection.abilities.flatMap((ability) => ability.listeners ?? []);
const listeners = projection.abilities.flatMap(
(ability) => ability.listeners ?? [],
);
const ids = listeners.map((listener) => listener.id).filter(Boolean);
expect(new Set(ids).size).toBe(ids.length);
expect(listeners.filter((listener) =>
listener.budget?.group === 'sect.youdu.decree-control-response-fire'
)).toHaveLength(4);
expect(
listeners.filter(
(listener) =>
listener.budget?.group === 'sect.youdu.decree-control-response-fire',
),
).toHaveLength(4);
});
it('组织主题覆盖全部任务、战斗对手、商店奖励与设施称谓', () => {
expect(Object.keys(YOUDU_ORGANIZATION_THEME.taskPresentation ?? {})).toHaveLength(8);
expect(Object.keys(YOUDU_ORGANIZATION_THEME.opponents ?? {})).toHaveLength(4);
expect(Object.keys(YOUDU_ORGANIZATION_THEME.shopGrants ?? {})).toHaveLength(6);
it('组织主题只覆盖非任务展示内容', () => {
expect(YOUDU_ORGANIZATION_THEME).not.toHaveProperty('taskPresentation');
expect(YOUDU_ORGANIZATION_THEME).not.toHaveProperty('opponents');
expect(Object.keys(YOUDU_ORGANIZATION_THEME.shopGrants ?? {})).toHaveLength(
6,
);
expect(YOUDU_ORGANIZATION_THEME.facilityNames).toMatchObject({
archive: '三魂阁',
cultivation_room: '返照室',
@@ -342,41 +342,22 @@ const tasks: readonly SectTaskDefinition[] = [
];
class StandardSectTaskCatalog implements SectTaskCatalog {
private readonly themedTasks: readonly SectTaskDefinition[];
private readonly byId: ReadonlyMap<string, SectTaskDefinition>;
constructor(theme: SectOrganizationTheme = {}) {
this.themedTasks = tasks.map((task) => {
const override = theme.taskPresentation?.[task.id];
return {
...task,
presentation: {
...task.presentation,
...override,
dialogue: {
...task.presentation.dialogue,
...override?.dialogue,
instruction: {
...task.presentation.dialogue.instruction,
...override?.dialogue?.instruction,
},
},
},
};
});
this.byId = new Map(this.themedTasks.map((task) => [task.id, task]));
constructor() {
this.byId = new Map(tasks.map((task) => [task.id, task]));
}
listDaily(): readonly SectTaskDefinition[] {
return this.themedTasks.filter((task) => task.kind === 'daily');
return tasks.filter((task) => task.kind === 'daily');
}
listWeekly(): readonly SectTaskDefinition[] {
return this.themedTasks.filter((task) => task.kind === 'weekly');
return tasks.filter((task) => task.kind === 'weekly');
}
listPromotion(): readonly SectTaskDefinition[] {
return this.themedTasks.filter((task) => task.kind === 'promotion');
return tasks.filter((task) => task.kind === 'promotion');
}
get(id: SectOrganizationTaskId): SectTaskDefinition | undefined {
@@ -384,7 +365,7 @@ class StandardSectTaskCatalog implements SectTaskCatalog {
}
findByCompletionTag(tag: string) {
return this.themedTasks.find((task) => task.completionTags?.includes(tag));
return tasks.find((task) => task.completionTags?.includes(tag));
}
}
@@ -808,14 +789,11 @@ const battleScenarioDefinitions = {
class StandardSectBattleScenarioCatalog implements SectBattleScenarioCatalog {
private readonly scenarios: ReadonlyMap<string, SectOpponentFactory>;
constructor(theme: SectOrganizationTheme = {}) {
constructor() {
this.scenarios = new Map(
Object.entries(battleScenarioDefinitions).map(([taskId, definition]) => [
taskId,
opponentFactory({
...definition,
...(theme.opponents?.[taskId] ?? {}),
}),
opponentFactory(definition),
]),
);
}
@@ -1049,18 +1027,8 @@ class StandardSectBenefitPolicy implements SectBenefitPolicy {
}
}
export interface SectTaskPresentationOverride extends Partial<
Omit<SectTaskDefinition['presentation'], 'dialogue'>
> {
dialogue?: Partial<Omit<SectTaskDialogueDefinition, 'instruction'>> & {
instruction?: Partial<SectTaskDialogueDefinition['instruction']>;
};
}
export interface SectOrganizationTheme {
taskPresentation?: Partial<Record<string, SectTaskPresentationOverride>>;
shopGrants?: Partial<Record<string, Partial<SectShopDefinition['grant']>>>;
opponents?: Partial<Record<string, { title?: string; name?: string }>>;
facilityNames?: Partial<Record<string, string>>;
stipendGrantNames?: {
herb?: string;
@@ -1079,9 +1047,9 @@ export class StandardSectOrganizationModule implements SectOrganizationModule {
readonly benefits: SectBenefitPolicy;
constructor(readonly theme: SectOrganizationTheme = {}) {
this.tasks = new StandardSectTaskCatalog(theme);
this.tasks = new StandardSectTaskCatalog();
this.economy = new StandardSectEconomyPolicy(theme);
this.battles = new StandardSectBattleScenarioCatalog(theme);
this.battles = new StandardSectBattleScenarioCatalog();
this.benefits = new StandardSectBenefitPolicy(theme);
}
}
@@ -71,17 +71,13 @@ describe('sect task dialogue presentation', () => {
);
});
it('deep-merges sect dialogue overrides with standard instructions', () => {
it('keeps standard dialogue while applying non-task organization themes', () => {
const definition = new StandardSectOrganizationModule({
taskPresentation: {
gate_sweep: {
dialogue: { offeredReply: '这段山路由我来扫' },
},
},
facilityNames: { archive: '宗门藏书阁' },
}).tasks.get('gate_sweep')!;
expect(definition.presentation.dialogue.offeredReply).toBe(
'这段山路由我来扫',
'山门洒扫便交给我吧',
);
expect(definition.presentation.dialogue.instruction.text).toBe(
'去山门步道清理落叶,完成一轮洒扫后回来复命。',
@@ -15,10 +15,21 @@ describe('sect presentation affairs room', () => {
]);
expect(presentation.affairsRoom.taskNpcs.daily.name).toBe('值日执事');
expect(presentation.affairsRoom.taskNpcs.daily.sigil).toBe('执');
expect(presentation.affairsRoom.taskNpcs.daily.responsibility).toBe(
'负责日常委托。',
);
expect(presentation.affairsRoom.taskNpcs.weekly.name).toBe('功簿执事');
expect(presentation.affairsRoom.taskNpcs.weekly.sigil).toBe('簿');
expect(presentation.affairsRoom.taskNpcs.weekly.responsibility).toBe(
'负责周常委托。',
);
expect(presentation.affairsRoom.taskNpcs.promotion.name).toBe('传功长老');
expect(presentation.affairsRoom.taskNpcs.promotion.sigil).toBe('传');
expect(presentation.affairsRoom.taskNpcs.promotion.responsibility).toBe(
'负责晋升试炼。',
);
expect(presentation.terms.sweepActivity).toBe('清扫山门');
expect(presentation.terms.sweepCanvasLabel).toBe('清扫山门游戏画布');
});
it('merges sect-specific room and NPC copy with the defaults', () => {
@@ -28,7 +39,6 @@ describe('sect presentation affairs room', () => {
description: '星轨交汇之处,诸般事务各归其席。',
taskNpcs: {
daily: {
sigil: '辰',
name: '司辰使',
greeting: '今日星轨已经排定。',
},
@@ -41,7 +51,7 @@ describe('sect presentation affairs room', () => {
);
expect(presentation.affairsRoom.taskNpcs.daily).toMatchObject({
id: 'daily-steward',
sigil: '辰',
sigil: '执',
name: '司辰使',
identity: '值日执事',
greeting: '今日星轨已经排定。',
@@ -49,6 +59,36 @@ describe('sect presentation affairs room', () => {
expect(presentation.affairsRoom.taskNpcs.weekly.name).toBe('功簿执事');
});
it('ignores runtime attempts to override canonical NPC role fields', () => {
const unsafeTheme = {
sectId: 'sample-sect',
affairsRoom: {
taskNpcs: {
daily: {
name: '司辰使',
sigil: '辰',
identity: '当值算使',
responsibility: '负责校正地刻。',
},
},
},
terms: {
sweepActivity: '校正地刻',
sweepCanvasLabel: '校正地刻游戏画布',
},
} as unknown as SectPresentationTheme;
const presentation = resolveSectPresentation('sample-sect', unsafeTheme);
expect(presentation.affairsRoom.taskNpcs.daily).toMatchObject({
name: '司辰使',
sigil: '执',
identity: '值日执事',
responsibility: '负责日常委托。',
});
expect(presentation.terms.sweepActivity).toBe('清扫山门');
expect(presentation.terms.sweepCanvasLabel).toBe('清扫山门游戏画布');
});
it('rejects blank fields and duplicate NPC identifiers', () => {
expect(() =>
resolveSectPresentation('sample-sect', {
@@ -85,10 +85,15 @@ export interface SectPresentationTheme {
affairsRoom?: {
description?: string;
taskNpcs?: Partial<
Record<SectAffairsTaskKind, Partial<SectRoomNpcPresentation>>
Record<
SectAffairsTaskKind,
Partial<Pick<SectRoomNpcPresentation, 'id' | 'name' | 'greeting'>>
>
>;
};
terms?: Partial<SectPresentationTerms>;
terms?: Partial<
Omit<SectPresentationTerms, 'sweepActivity' | 'sweepCanvasLabel'>
>;
}
export interface ResolvedSectPresentation {
@@ -331,7 +336,7 @@ const STANDARD_AFFAIRS_ROOM: SectAffairsRoomPresentation = {
sigil: '执',
name: '值日执事',
identity: '值日执事',
responsibility: '登记山门勤务、巡视与日常交付。',
responsibility: '负责日常委托。',
greeting: '今日事务都在这里。你要先看哪一件?',
},
weekly: {
@@ -339,7 +344,7 @@ const STANDARD_AFFAIRS_ROOM: SectAffairsRoomPresentation = {
sigil: '簿',
name: '功簿执事',
identity: '功簿执事',
responsibility: '查验本周功簿、小比与悬赏。',
responsibility: '负责周常委托。',
greeting: '本周卷宗已经归拢,你可逐项查验。',
},
promotion: {
@@ -347,7 +352,7 @@ const STANDARD_AFFAIRS_ROOM: SectAffairsRoomPresentation = {
sigil: '传',
name: '传功长老',
identity: '传功长老',
responsibility: '核验身份进境与晋升试炼。',
responsibility: '负责晋升试炼。',
greeting: '晋升不可躁进。先看看你当前应过的关。',
},
},
@@ -378,8 +383,8 @@ export const STANDARD_SECT_PRESENTATION: Omit<
meridianLoadout: '参悟方案',
abilityChanges: '神通变化',
returnToAffairs: '返回事务堂',
sweepActivity: '宗门勤务',
sweepCanvasLabel: '宗门勤务游戏画布',
sweepActivity: '清扫山门',
sweepCanvasLabel: '清扫山门游戏画布',
}),
});
@@ -414,10 +419,16 @@ export function resolveSectPresentation(
) as SectAffairsTaskKind[]
).map((kind) => [
kind,
{
...STANDARD_SECT_PRESENTATION.affairsRoom.taskNpcs[kind],
...theme?.affairsRoom?.taskNpcs?.[kind],
},
(() => {
const standard = STANDARD_SECT_PRESENTATION.affairsRoom.taskNpcs[kind];
const override = theme?.affairsRoom?.taskNpcs?.[kind];
return {
...standard,
id: override?.id ?? standard.id,
name: override?.name ?? standard.name,
greeting: override?.greeting ?? standard.greeting,
};
})(),
]),
) as Record<SectAffairsTaskKind, SectRoomNpcPresentation>;
const resolved: ResolvedSectPresentation = {
@@ -437,7 +448,12 @@ export function resolveSectPresentation(
STANDARD_SECT_PRESENTATION.affairsRoom.description,
taskNpcs: affairsTaskNpcs,
},
terms: { ...STANDARD_SECT_PRESENTATION.terms, ...theme?.terms },
terms: {
...STANDARD_SECT_PRESENTATION.terms,
...theme?.terms,
sweepActivity: STANDARD_SECT_PRESENTATION.terms.sweepActivity,
sweepCanvasLabel: STANDARD_SECT_PRESENTATION.terms.sweepCanvasLabel,
},
};
for (const [key, value] of Object.entries(resolved.facilityLabels)) {