优化:幽都第三轮优化

This commit is contained in:
taojiacheng committed 2026-07-22 18:02:35 +08:00
1 parent 6e5d5428e0
commit bb975aef74
16 files changed
+485 -50

No files matched your search

@@ -39,7 +39,9 @@ function SectAlchemyBody() {
sectContext={{
facilityLevel: level,
discountPercent,
facilityLabel: presentation.facilityLabels.workshop,
facilityLabel:
presentation.facilityLabels.alchemy ??
presentation.facilityLabels.workshop,
scene,
}}
/>
@@ -39,7 +39,9 @@ function SectRefineryBody() {
sectContext={{
facilityLevel: level,
discountPercent,
facilityLabel: presentation.facilityLabels.workshop,
facilityLabel:
presentation.facilityLabels.refinery ??
presentation.facilityLabels.workshop,
scene,
}}
/>
@@ -52,6 +52,7 @@ function getDefaultScope(eventType: string): ListenerScope {
return 'owner_as_actor';
case 'SkillCastEvent':
case 'SkillPreCastEvent':
case 'HitCheckEvent':
case 'DamageRequestEvent':
return 'owner_as_caster';
case 'RoundPreEvent':
@@ -155,6 +155,10 @@ export class DamageSystem {
* ⑦ 最小伤害保证 + 四舍五入
*/
private _onDamageRequest(event: DamageRequestEvent): void {
if (!event.target.isAlive()) {
return;
}
const { target } = event;
const damageType = this._resolveDamageType(event);
@@ -456,7 +460,7 @@ export class DamageSystem {
// 最终判定:在所有 DamageTakenEvent 监听器执行完后,重新检查存活状态
// 如果免死效果生效,target.currentHp 会变为 1,从而跳过此处的阵亡发布
if (target.getCurrentHp() <= 0) {
if (beforeHp > 0 && target.getCurrentHp() <= 0) {
EventBus.instance.publish<UnitDeadEvent>({
type: 'UnitDeadEvent',
timestamp: Date.now(),
@@ -90,6 +90,46 @@ function percentage(value: number): string {
return `${Math.round(value * 100)}%`;
}
function debuffTags(...tags: string[]): string[] {
return [GameplayTags.BUFF.TYPE.DEBUFF, ...tags];
}
function controlTags(...tags: string[]): string[] {
return [
GameplayTags.BUFF.TYPE.DEBUFF,
GameplayTags.BUFF.TYPE.CONTROL,
...tags,
];
}
function layerCurveDetail(label: string, values: readonly number[]): string {
const groups: Array<{ start: number; end: number; value: number }> = [];
values.forEach((value, index) => {
const layer = index + 1;
const previous = groups[groups.length - 1];
if (previous?.value === value) {
previous.end = layer;
} else {
groups.push({ start: layer, end: layer, value });
}
});
return `${label}:${groups.map(({ start, end, value }) => {
const range = start === end ? `${start}层` : `${start}~${end}层`;
return `${range}${percentage(value)}`;
}).join(',')}`;
}
function erosionDetailRows(settings: YouduBuildSettings): string[] {
return [
layerCurveDetail(
'物攻、法攻、物防、法防、身法',
settings.erosionAttributeCurve,
),
layerCurveDetail('受治疗削弱', settings.erosionHealCurve),
'普通驱散每次只移除1层蚀魂',
];
}
function soulFireDetail(settings: YouduBuildSettings): string {
return `拥有3点魂火时:本次魂伤提高${percentage(settings.soulFireBonus)},伤害后消耗全部魂火`;
}
@@ -227,6 +267,7 @@ function temporaryPenalty(
type: BuffType.DEBUFF,
duration: 1,
stackRule: StackRule.REFRESH_DURATION,
tags: debuffTags(),
modifiers: [
{ attrType: AttributeType.ATK, type: ModifierType.ADD, value: attack },
{ attrType: AttributeType.MAGIC_ATK, type: ModifierType.ADD, value: attack },
@@ -278,10 +319,10 @@ function soulLostBuff(settings: YouduBuildSettings): BuffConfig {
duration: 1,
stackRule: StackRule.IGNORE,
dispelPolicy: 'protected',
tags: [
tags: controlTags(
lostTag,
GameplayTags.STATUS.CONTROL.NO_ACTION,
],
),
statusTags: [GameplayTags.STATUS.CONTROL.NO_ACTION],
modifiers: [{
attrType: AttributeType.HEAL_RECEIVED_REDUCTION,
@@ -337,7 +378,7 @@ function forgetfulRiverBuff(settings: YouduBuildSettings): BuffConfig {
type: BuffType.DEBUFF,
duration: settings.forgetDuration,
stackRule: StackRule.REFRESH_DURATION,
tags: [forgetTag, GameplayTags.BUFF.DOT.ROOT],
tags: debuffTags(forgetTag, GameplayTags.BUFF.DOT.ROOT),
statusTags: [stateTag('forgetful-river')],
modifiers: [
{
@@ -470,6 +511,7 @@ function soulErosionBuff(settings: YouduBuildSettings): BuffConfig {
condition('has_not_tag', { scope: 'target', tag: returningTag }),
],
effects: [applyBuff(soulLostBuff(settings), 'target', {
controlHitBonus: settings.highLayerControlHitBonus,
onResistEffects: convergeLostSoul(settings, 'resist'),
})],
},
@@ -654,7 +696,7 @@ function soulErosionBuff(settings: YouduBuildSettings): BuffConfig {
stackRule: StackRule.STACK_LAYER,
maxLayers: 5,
dispelMode: 'one_layer',
tags: [erosionTag],
tags: debuffTags(erosionTag),
statusTags: [stateTag('soul-erosion')],
modifiers: [
...[
@@ -695,7 +737,7 @@ function noReturnBuff(settings: YouduBuildSettings): BuffConfig {
type: BuffType.DEBUFF,
duration: 2,
stackRule: StackRule.REFRESH_DURATION,
tags: [noReturnTag],
tags: debuffTags(noReturnTag),
statusTags: [stateTag('no-return')],
modifiers: [
{
@@ -721,7 +763,7 @@ function shadowBuff(settings: YouduBuildSettings): BuffConfig {
type: BuffType.DEBUFF,
duration: settings.shadowDuration,
stackRule: StackRule.REFRESH_DURATION,
tags: [buffTag('shadow-revealed')],
tags: debuffTags(buffTag('shadow-revealed')),
statusTags: [shadowTag],
modifiers: [{
attrType: AttributeType.EVASION_RATE,
@@ -740,7 +782,10 @@ function pinBuff(duration: number): BuffConfig {
type: BuffType.CONTROL,
duration,
stackRule: StackRule.REFRESH_DURATION,
tags: [buffTag('soul-pinning-nail'), GameplayTags.STATUS.CONTROL.NO_SKILL],
tags: controlTags(
buffTag('soul-pinning-nail'),
GameplayTags.STATUS.CONTROL.NO_SKILL,
),
statusTags: [GameplayTags.STATUS.CONTROL.NO_SKILL],
modifiers: [{
attrType: AttributeType.HEAL_RECEIVED_REDUCTION,
@@ -873,17 +918,17 @@ function compileRuntime(
if (settings.firstShadowExtraLayer) {
listeners.push({
id: 'sect.youdu.first-shadow-extra-layer',
eventType: GameplayTags.EVENT.DAMAGE_TAKEN,
eventType: GameplayTags.EVENT.HIT_CHECK,
scope: GameplayTags.SCOPE.OWNER_AS_CASTER,
priority: EventPriorityLevel.DAMAGE_TAKEN,
priority: EventPriorityLevel.POST_SETTLE,
mapping: { caster: 'owner', target: 'event.target' },
budget: { maxTriggers: 1, reset: 'battle' },
conditions: [
condition('is_hit', {}),
condition('ability_has_tag', {
tag: GameplayTags.ABILITY.SECT.namespace(YOUDU_SECT_ID),
}),
condition('has_tag', { scope: 'target', tag: shadowTag }),
condition('damage_source_is', { damageSource: DamageSource.DIRECT }),
],
effects: [applyErosion(1, settings)],
});
@@ -899,19 +944,21 @@ function compileRuntime(
listeners,
detailRows: [
`控制抗性 +${percentage(0.30 + settings.extraControlResistance)}`,
'本场首次成功受控后立即解除',
'本场首次成功受控后立即解除,并短暂免疫同类控制',
...(settings.heartSoulFireGain > 0
? [
`首次解控时获得${settings.heartSoulFireGain}点魂火;每回合首次成功抵抗控制时获得1点魂火`,
]
: []),
...(settings.heartShield
? ['首次解控后获得10%最大气血护盾']
: []),
...(settings.firstShadowExtraLayer
? ['每场首次命中照影目标的幽都神通额外增加1层蚀魂']
: []),
],
}));
for (const id of ['tide-runtime', 'decree-runtime'] as const) {
const passive = definition(id);
if (passive.kind !== 'passive') throw new Error(`${id}定义错误`);
builder.setAbility(id, factory.passive({
definition: passive,
pathId: settings.pathId,
listeners: [],
}));
}
}
function consumeSoulFire(): EffectConfig {
@@ -956,6 +1003,7 @@ function compileAbilities(
? [`目标有忘川时伤害提高${percentage(settings.sighForgetBonus)}`]
: []),
'命中后增加1层蚀魂',
...erosionDetailRows(settings),
],
}));
@@ -1043,6 +1091,19 @@ function compileAbilities(
`${settings.forgetDirectCoefficient.toFixed(2)} × 法术攻击(魂伤)`,
`忘川持续${settings.forgetDuration}回合,每次行动前造成${coefficient(settings.forgetDotCoefficient)} × 法术攻击(魂伤)`,
`受治疗效果额外降低${Math.round(settings.forgetHealReduction * 100)}%`,
...(settings.forgetSpeedReduction < 0
? [`忘川期间速度降低${percentage(-settings.forgetSpeedReduction)}`]
: []),
...(settings.forgetHighLayerBonus > 0
? [`至少3层蚀魂时持续魂伤提高${percentage(settings.forgetHighLayerBonus)}`]
: []),
...(settings.forgetFourLayerBonus > 0
? [`至少4层蚀魂时持续魂伤额外提高${percentage(settings.forgetFourLayerBonus)}`]
: []),
...(settings.crossingEcho
? ['每回合首次对至少4层目标结算忘川时,追加0.12 × 法术攻击魂伤']
: []),
'命中后增加1层蚀魂',
],
}));
@@ -1054,6 +1115,7 @@ function compileAbilities(
type: BuffType.DEBUFF,
duration: settings.seizeDuration,
stackRule: StackRule.REFRESH_DURATION,
tags: debuffTags(),
modifiers: [
{ attrType: AttributeType.ATK, type: ModifierType.ADD, value: -0.20 },
{ attrType: AttributeType.MAGIC_ATK, type: ModifierType.ADD, value: -0.20 },
@@ -1091,7 +1153,7 @@ function compileAbilities(
const pinHighEffects: EffectConfig[] = [
{
...applyBuff(pinBuff(2), 'target', {
controlHitBonus: settings.pinControlHitBonus,
controlHitBonus: settings.highLayerControlHitBonus,
}),
conditions: [condition('hp_above', { scope: 'target', value: 0 })],
},
@@ -1103,6 +1165,7 @@ function compileAbilities(
type: BuffType.DEBUFF,
duration: 2,
stackRule: StackRule.REFRESH_DURATION,
tags: debuffTags(),
modifiers: [{
attrType: AttributeType.SPEED,
type: ModifierType.ADD,
@@ -1157,7 +1220,11 @@ function compileAbilities(
detailRows: [
`术伤与魂伤各${coefficient(0.20 * settings.mixedDamageMultiplier)} × 法术攻击`,
'命中后增加2层蚀魂',
`封印1回合;施法前至少4层时延长至2回合${settings.pinControlHitBonus > 0 ? `,本次控制命中提高${percentage(settings.pinControlHitBonus)}` : ''}`,
`封印1回合;施法前至少4层时延长至2回合${settings.highLayerControlHitBonus > 0 ? `,本次控制命中提高${percentage(settings.highLayerControlHitBonus)}` : ''}`,
'镇魂钉期间无法施展主动技能,且受到的气血治疗降低100%',
...(settings.pinHighLayerSlow
? ['施法前至少4层时,目标速度降低20%,持续2回合']
: []),
soulFireDetail(settings),
],
}));
@@ -1252,8 +1319,19 @@ function compileAbilities(
extraTags: [soulDamageTag, soulFireConsumerTag],
detailRows: [
`(${settings.finishBaseCoefficient.toFixed(2)} + ${settings.finishPerLayerCoefficient.toFixed(2)} × 蚀魂层数) × 法术攻击(魂伤)`,
'施放条件:目标至少4层蚀魂',
settings.finishRetainedLayers > 0 ? '结算后保留2层蚀魂' : '伤害后清除全部蚀魂',
'施加不归2回合',
`不归期间受到的气血治疗降低80%,速度降低${percentage(-settings.noReturnSpeedReduction)}`,
...(settings.finishAddsForget
? [`结算后施加忘川,持续${settings.forgetDuration}回合`]
: []),
...(settings.oneNameOneJudgment
? ['目标每场首次进入4层时获得标记;下一次终结命中后返还20点已支付法力并消耗标记']
: []),
...(settings.nameInYoudu
? ['结算后若目标气血低于20%,获得3点魂火并减少2回合冷却,每场一次']
: []),
soulFireDetail(settings),
],
}));
@@ -4,10 +4,8 @@ import {
} from '@shared/engine/battle-v5/core/types';
import type { SectDefinitionWithoutPaths } from '../../core';
import {
YOUDU_DECREE_PATH_ID,
YOUDU_SECT_ID,
YOUDU_SOUL_FIRE,
YOUDU_TIDE_PATH_ID,
} from './ids';
export const YOUDU_BASE_DEFINITION: SectDefinitionWithoutPaths = {
@@ -141,16 +139,6 @@ export const YOUDU_BASE_DEFINITION: SectDefinitionWithoutPaths = {
description: '常驻控制韧性,并在每场第一次受控后自行解脱。', role: 'defensive',
unlock: { type: 'method', methodId: 'dead-heart-living-spirit', level: 1 }, visibility: 'internal',
},
{
id: 'tide-runtime', kind: 'passive', baseName: '长夜回潮',
description: '忘川在深蚀之魂上回潮,并从有效潮伤中收拢魂火。', role: 'combo',
unlock: { type: 'active_path', pathId: YOUDU_TIDE_PATH_ID }, visibility: 'internal',
},
{
id: 'decree-runtime', kind: 'passive', baseName: '司命断章',
description: '以照影定名,以镇魄断章,将层数化为裁断。', role: 'finisher',
unlock: { type: 'active_path', pathId: YOUDU_DECREE_PATH_ID }, visibility: 'internal',
},
],
onboarding: {
initialContribution: 30,
@@ -28,7 +28,7 @@ export const YOUDU_DECREE_NODES = [
node('decree-dead-heart-counter', '3', '心寂反照', '心死神活首次解控触发时获得2点魂火。', (f) => f.deepenHeartReflection()),
node('decree-four-gates-closed', '4', '四门皆闭', '镇魂命中施法前至少4层目标时,令其速度降低20%两回合。', (f) => f.enableFourGatesSlow()),
node('decree-punishment-measured', '4', '魂刑有度', '失魂被抵抗时,目标攻击与速度仍降低20%一回合。', (f) => f.enableMeasuredPunishment()),
node('decree-iron-law', '4', '幽都铁律', '对至少4层目标施加镇魂时,控制命中提高15个百分点。', (f) => f.enforceIronLaw()),
node('decree-iron-law', '4', '幽都铁律', '对至少4层蚀魂目标施加控制时,控制命中提高15个百分点。', (f) => f.enforceIronLaw()),
node('decree-five-souls-scattered', '5', '五魄俱散', '目标从失魂回落后,攻击额外降低15%一回合。', (f) => f.enableFiveSoulsPenalty()),
node('decree-returning-barrier', '5', '神归有垣', '心死神活首次解控后,获得10%最大气血护盾。', (f) => f.enableReturningBarrier()),
node('decree-one-name-one-judgment', '5', '一名一判', '目标首次进入4层后获得标记;下一次终结命中返还20法力。', (f) => f.enableOneNameJudgment()),
@@ -36,4 +36,3 @@ export const YOUDU_DECREE_NODES = [
node('decree-seven-inch-severance', 'ultimate', '七寸断魂', '魂兮不归每层追加魂伤由0.20提高到0.25。', (f) => f.severSevenInches()),
node('decree-name-in-youdu', 'ultimate', '名落幽都', '终结后目标低于20%气血时获得3魂火并返还2回合冷却,每场一次。', (f) => f.enableNameInYoudu()),
] as const;
@@ -82,6 +82,7 @@ export const YOUDU_SECT_PRESENTATION: SectPresentationTheme = {
},
facilityLabels: {
archive: '三魂阁', cultivation_room: '返照室', workshop: '镇铁炉',
alchemy: '还魂药庐', refinery: '镇铁炉',
spirit_vein: '黑水阴脉', herb_garden: '彼岸圃', formation: '万魂归窍阵',
},
lockedFacilities: ['formation'],
@@ -106,4 +107,3 @@ export const YOUDU_SECT_PRESENTATION: SectPresentationTheme = {
sweepActivity: '巡灯照影', sweepCanvasLabel: '巡灯照影游戏画布',
},
};
@@ -34,7 +34,7 @@ export interface YouduBuildSettings {
heartSoulFireGain: number;
pinHighLayerSlow: boolean;
lostResistPenalty: boolean;
pinControlHitBonus: number;
highLayerControlHitBonus: number;
lostAfterPenalty: boolean;
heartShield: boolean;
oneNameOneJudgment: boolean;
@@ -78,7 +78,7 @@ export function createYouduBuildSettings(pathId?: SectPathId): YouduBuildSetting
heartSoulFireGain: pathId === 'decree' ? 1 : 0,
pinHighLayerSlow: false,
lostResistPenalty: false,
pinControlHitBonus: 0,
highLayerControlHitBonus: 0,
lostAfterPenalty: false,
heartShield: false,
oneNameOneJudgment: false,
@@ -133,7 +133,7 @@ export class DecreeBuildFacade {
deepenHeartReflection(): void { this.settings.heartSoulFireGain = 2; }
enableFourGatesSlow(): void { this.settings.pinHighLayerSlow = true; }
enableMeasuredPunishment(): void { this.settings.lostResistPenalty = true; }
enforceIronLaw(): void { this.settings.pinControlHitBonus = 0.15; }
enforceIronLaw(): void { this.settings.highLayerControlHitBonus = 0.15; }
enableFiveSoulsPenalty(): void { this.settings.lostAfterPenalty = true; }
enableReturningBarrier(): void { this.settings.heartShield = true; }
enableOneNameJudgment(): void { this.settings.oneNameOneJudgment = true; }
@@ -141,4 +141,3 @@ export class DecreeBuildFacade {
severSevenInches(): void { this.settings.finishPerLayerCoefficient = 0.25; }
enableNameInYoudu(): void { this.settings.nameInYoudu = true; }
}
@@ -31,6 +31,18 @@ function hasImminentHealing(context: AbilitySelectionContext): boolean {
) ?? false;
}
function hasImminentControlOrHealing(
context: AbilitySelectionContext,
): boolean {
return context.opponent?.abilities.getAllAbilities().some(
(ability) =>
ability instanceof ActiveSkill &&
ability.currentCooldown <= 1 &&
(ability.selectionProfile?.intents?.includes('control') ||
ability.selectionProfile?.intents?.includes('heal_hp')),
) ?? false;
}
abstract class YouduSelectionStrategy implements AbilitySelectionStrategy {
constructor(protected readonly tacticId: SectTacticId) {}
abstract select(context: AbilitySelectionContext): AbilitySelectionResult | null;
@@ -106,9 +118,18 @@ export class YouduDecreeSelectionStrategy extends YouduSelectionStrategy {
const erosion = layer(context);
const hasShadow = hasBuff(context, YOUDU_SHADOW_REVEALED);
const fire = context.caster.combatResources.getCurrent(YOUDU_SOUL_FIRE);
const targetHp = context.opponent?.getHpPercent() ?? 1;
if (this.tacticId === 'pin-the-caster' && !hasShadow) {
return this.pick(context, ['reveal-shadow', 'soul-severing-call'], 800);
if (this.tacticId === 'pin-the-caster') {
if (!hasShadow) {
return this.pick(context, ['reveal-shadow', 'soul-severing-call'], 800);
}
if (hasImminentControlOrHealing(context)) {
return this.pick(context, ['pin-soul', 'soul-severing-call'], 810);
}
if (erosion >= 4 && (fire >= 3 || targetHp < 0.45)) {
return this.pick(context, ['soul-shall-not-return', 'pin-soul'], 820);
}
}
if (this.tacticId === 'judge-at-four' && erosion >= 4) {
return this.pick(context, ['soul-shall-not-return', 'pin-soul'], 820);
@@ -60,6 +60,10 @@ describe('幽都战斗与展示投影', () => {
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.facilityLabels).toMatchObject({
alchemy: '还魂药庐',
refinery: '镇铁炉',
});
});
it.each([YOUDU_TIDE_PATH_ID, YOUDU_DECREE_PATH_ID])(
@@ -72,6 +76,9 @@ describe('幽都战斗与展示投影', () => {
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`,
);
},
);
@@ -128,6 +135,11 @@ describe('幽都战斗与展示投影', () => {
realm: '化神',
abilityId: 'youdu-runtime',
}).detailRows.join(';');
const slowPin = resolveSectAbility({
sect: youduState(YOUDU_DECREE_PATH_ID, ['decree-four-gates-closed']),
realm: '化神',
abilityId: 'pin-soul',
}).detailRows.join(';');
expect(sever).toContain('施法前至少3层蚀魂时魂伤提高70%');
expect(sever).toContain('命中后增加2层蚀魂');
@@ -136,12 +148,44 @@ describe('幽都战斗与展示投影', () => {
expect(seize).toContain('术伤与魂伤各0.22 × 法术攻击');
expect(pin).toContain('命中后增加2层蚀魂');
expect(pin).toContain('本次控制命中提高15%');
expect(pin).toContain('受到的气血治疗降低100%');
expect(slowPin).toContain('目标速度降低20%');
expect(heart).toContain('控制抗性 +40%');
expect([sever, seize, pin].join(';')).not.toMatch(
/佛相|sever-high|sever-low|pin-high|pin-low/,
);
});
it('终结与忘川详情完整展示节点条件,且不生成无条件或重复事实', () => {
const finish = resolveSectAbility({
sect: youduState(YOUDU_DECREE_PATH_ID, [
'decree-one-name-one-judgment',
'decree-name-in-youdu',
]),
realm: '化神',
abilityId: 'soul-shall-not-return',
}).detailRows.join(';');
const forget = resolveSectAbility({
sect: youduState(YOUDU_TIDE_PATH_ID, [
'tide-black-water',
'tide-no-return-current',
]),
realm: '化神',
abilityId: 'forgetful-river-tide',
}).detailRows.join(';');
expect(finish).toContain('目标至少4层蚀魂');
expect(finish).toContain('受到的气血治疗降低80%');
expect(finish).toContain('速度降低30%');
expect(finish).toContain('目标每场首次进入4层时获得标记');
expect(finish).toContain('目标气血低于20%');
expect(finish).toContain('每场一次');
expect(finish).not.toContain('伤害:相当于70%法攻');
expect(finish).not.toContain('施展后:魂火:获得3点');
expect(forget).toContain('忘川期间速度降低8%');
expect(forget).toContain('至少4层蚀魂时持续魂伤额外提高30%');
});
it('蚀魂详情展示完整五层曲线、逐层驱散且不出现零值占位', () => {
const sigh = resolveSectAbility({
sect: youduState(),
@@ -3,7 +3,9 @@ import { EventBus } from '@shared/engine/battle-v5/core/EventBus';
import type {
DamageRequestEvent,
ResourceDrainEvent,
UnitDeadEvent,
} from '@shared/engine/battle-v5/core/events';
import { EventPriorityLevel } from '@shared/engine/battle-v5/core/events';
import {
beginRuntimeAction,
shouldTickBuffDuration,
@@ -80,6 +82,36 @@ function setOverride(owner: Unit, attrType: AttributeType, value: number): void
owner.updateDerivedStats();
}
function addBuffImmunity(owner: Unit, tags: string[]): void {
owner.abilities.addAbility(AbilityFactory.create({
slug: `test.buff-immunity.${owner.id}.${tags.join('.')}`,
name: '测试状态免疫',
type: AbilityType.PASSIVE_SKILL,
tags: [GameplayTags.ABILITY.KIND.PASSIVE],
listeners: [{
eventType: GameplayTags.EVENT.BUFF_ADD,
scope: GameplayTags.SCOPE.OWNER_AS_TARGET,
priority: 1_000,
effects: [{ type: 'buff_immunity', params: { tags } }],
}],
}));
}
function addDamageImmunity(owner: Unit, tags: string[]): void {
owner.abilities.addAbility(AbilityFactory.create({
slug: `test.damage-immunity.${owner.id}.${tags.join('.')}`,
name: '测试伤害免疫',
type: AbilityType.PASSIVE_SKILL,
tags: [GameplayTags.ABILITY.KIND.PASSIVE],
listeners: [{
eventType: GameplayTags.EVENT.DAMAGE,
scope: GameplayTags.SCOPE.OWNER_AS_TARGET,
priority: EventPriorityLevel.DAMAGE_APPLY,
effects: [{ type: 'damage_immunity', params: { tags } }],
}],
}));
}
describe('幽都核心机制实际结算', () => {
beforeEach(() => {
EventBus.instance.reset();
@@ -352,6 +384,119 @@ describe('幽都核心机制实际结算', () => {
expect(youdu.buffs.getAllBuffIds()).toContain('test.control.no-skill');
});
it('通用减益免疫阻止蚀魂与忘川,控制免疫只阻止失魂和镇魂钉', () => {
const debuffCaster = unit('debuff-caster');
const debuffTarget = unit('debuff-target');
addBuffImmunity(debuffTarget, [GameplayTags.BUFF.TYPE.DEBUFF]);
ability('one-sigh').execute({ caster: debuffCaster, target: debuffTarget });
ability('forgetful-river-tide').execute({
caster: debuffCaster,
target: debuffTarget,
});
expect(erosion(debuffTarget)).toBeUndefined();
expect(debuffTarget.buffs.getAllBuffIds()).not.toContain(
YOUDU_FORGETFUL_RIVER,
);
const controlCaster = unit('control-caster');
const controlTarget = unit('control-target');
addBuffImmunity(controlTarget, [GameplayTags.BUFF.TYPE.CONTROL]);
setOverride(controlCaster, AttributeType.CONTROL_HIT, 1);
setOverride(controlTarget, AttributeType.CONTROL_RESISTANCE, 0);
ability('one-sigh').execute({ caster: controlCaster, target: controlTarget });
ability('soul-severing-call').execute({
caster: controlCaster,
target: controlTarget,
});
ability('one-sigh').execute({ caster: controlCaster, target: controlTarget });
expect(erosion(controlTarget)?.getLayer()).toBe(4);
ability('pin-soul').execute({ caster: controlCaster, target: controlTarget });
expect(erosion(controlTarget)).toBeDefined();
expect(controlTarget.buffs.getAllBuffIds()).not.toContain(YOUDU_SOUL_LOST);
expect(controlTarget.buffs.getAllBuffIds()).not.toContain(
'sect.youdu.soul-pinning-nail',
);
});
it('先定其形按成功命中而非实际伤害触发,闪避不会消耗次数', () => {
const system = new DamageSystem();
const caster = unit('caster');
const target = unit('target');
const nodes = ['decree-fix-form-first'];
installRuntime(caster, 'decree', nodes);
ability('reveal-shadow', 'decree', nodes).execute({ caster, target });
const sever = ability('soul-severing-call', 'decree', nodes);
EventBus.instance.publish({
type: 'HitCheckEvent',
timestamp: Date.now(),
caster,
target,
ability: sever,
isHit: false,
isDodged: true,
isResisted: false,
});
expect(erosion(target)).toBeUndefined();
EventBus.instance.publish({
type: 'HitCheckEvent',
timestamp: Date.now(),
caster,
target,
ability: sever,
isHit: true,
isDodged: false,
isResisted: false,
});
expect(erosion(target)?.getLayer()).toBe(1);
EventBus.instance.publish({
type: 'HitCheckEvent',
timestamp: Date.now(),
caster,
target,
ability: sever,
isHit: true,
isDodged: false,
isResisted: false,
});
expect(erosion(target)?.getLayer()).toBe(1);
addDamageImmunity(target, [GameplayTags.ABILITY.CHANNEL.TRUE]);
const beforeHp = target.getCurrentHp();
sever.execute({ caster, target });
expect(target.getCurrentHp()).toBe(beforeHp);
expect(erosion(target)?.getLayer()).toBe(3);
system.destroy();
});
it('幽都铁律同时提高五层失魂的控制命中', () => {
const run = (nodes: string[]) => {
const caster = unit(`caster-${nodes.length}`);
const target = unit(`target-${nodes.length}`);
setOverride(caster, AttributeType.CONTROL_HIT, 0);
setOverride(target, AttributeType.CONTROL_RESISTANCE, 0.6);
const sigh = ability('one-sigh', 'decree', nodes);
for (let index = 0; index < 5; index += 1) {
sigh.execute({ caster, target });
}
return target;
};
const withoutNode = run([]);
expect(withoutNode.buffs.getAllBuffIds()).not.toContain(YOUDU_SOUL_LOST);
expect(erosion(withoutNode)?.getLayer()).toBe(3);
const withNode = run(['decree-iron-law']);
expect(withNode.buffs.getAllBuffIds()).toContain(YOUDU_SOUL_LOST);
expect(erosion(withNode)?.getLayer()).toBe(5);
});
it('镇魂控制被抵抗时保留伤害与蚀魂,但不产生镇魂钉禁疗', () => {
const caster = unit('caster');
const target = unit('target');
@@ -419,6 +564,26 @@ describe('幽都核心机制实际结算', () => {
expect(erosion(target)?.getLayer()).toBe(2);
});
it('混合伤害首段致死时只发布一次死亡事件', () => {
const system = new DamageSystem();
const caster = unit('caster');
const target = unit('target');
const deaths: UnitDeadEvent[] = [];
target.setHp(1);
EventBus.instance.subscribe<UnitDeadEvent>(
'UnitDeadEvent',
(event) => deaths.push(event),
-1_000,
);
ability('seize-soul').execute({ caster, target });
expect(target.isAlive()).toBe(false);
expect(deaths).toHaveLength(1);
expect(deaths[0]).toMatchObject({ unit: target, killer: caster });
system.destroy();
});
it('法术伤害回蓝只响应混合技能的术伤包,魂伤仍不可暴击与吸血', () => {
const system = new DamageSystem();
const caster = unit('caster');
@@ -10,10 +10,14 @@ import { describe, expect, it } from 'vitest';
import { resolveSectAbility } from '../..';
import {
YOUDU_FORGETFUL_RIVER,
YOUDU_SHADOW_REVEALED,
YOUDU_SOUL_EROSION,
YOUDU_SOUL_FIRE,
} from '..';
import { YouduTideSelectionStrategy } from '../strategy';
import {
YouduDecreeSelectionStrategy,
YouduTideSelectionStrategy,
} from '../strategy';
import { youduState } from './testState';
function unit(id: string): Unit {
@@ -49,6 +53,8 @@ function addTargetState(target: Unit, erosionLayers: number): void {
function selectionContext(
abilityIds: string[],
erosionLayers: number,
pathId: 'tide' | 'decree' = 'tide',
withShadow = false,
): AbilitySelectionContext {
const caster = unit('caster');
const opponent = unit('opponent');
@@ -59,12 +65,21 @@ function selectionContext(
max: 3,
});
addTargetState(opponent, erosionLayers);
if (withShadow) {
opponent.buffs.addBuff(BuffFactory.create({
id: YOUDU_SHADOW_REVEALED,
name: '照影',
type: BuffType.DEBUFF,
duration: 3,
stackRule: StackRule.REFRESH_DURATION,
}));
}
return {
caster,
opponent,
candidates: abilityIds.map((abilityId, order) => ({
ability: AbilityFactory.create(resolveSectAbility({
sect: youduState('tide'),
sect: youduState(pathId),
realm: '化神',
abilityId,
}).config) as ActiveSkill,
@@ -93,6 +108,34 @@ function addHealingAbility(opponent: Unit, cooldown: number): ActiveSkill {
return heal;
}
function addControlAbility(opponent: Unit, cooldown: number): ActiveSkill {
const control = AbilityFactory.create({
slug: `test.control.${cooldown}`,
name: '测试控制',
type: AbilityType.ACTIVE_SKILL,
cooldown,
targetPolicy: { team: 'enemy', scope: 'single' },
selectionProfile: { intents: ['control'] },
tags: [GameplayTags.ABILITY.FUNCTION.CONTROL],
effects: [{
type: 'apply_buff',
params: {
buffConfig: {
id: `test.control.buff.${cooldown}`,
name: '测试控制状态',
type: BuffType.CONTROL,
duration: 1,
stackRule: StackRule.REFRESH_DURATION,
tags: [GameplayTags.BUFF.TYPE.CONTROL],
},
},
}],
}) as ActiveSkill;
opponent.abilities.addAbility(control);
if (cooldown > 0) control.startCooldown();
return control;
}
describe('幽都自动战术', () => {
it('溺疗只在敌方治疗冷却不超过1回合时优先镇魂', () => {
const strategy = new YouduTideSelectionStrategy('healer-drown');
@@ -130,4 +173,68 @@ describe('幽都自动战术', () => {
'sect.youdu.soul-shall-not-return',
);
});
it('钉法者先补照影,并在敌方控制或治疗进入关键窗口时镇魂', () => {
const strategy = new YouduDecreeSelectionStrategy('pin-the-caster');
const withoutShadow = selectionContext(
['reveal-shadow', 'soul-severing-call', 'pin-soul'],
3,
'decree',
);
expect(strategy.select(withoutShadow)?.ability.id).toBe(
'sect.youdu.reveal-shadow',
);
const context = selectionContext(
['soul-severing-call', 'pin-soul'],
3,
'decree',
true,
);
const control = addControlAbility(context.opponent!, 2);
expect(strategy.select(context)?.ability.id).toBe(
'sect.youdu.soul-severing-call',
);
control.tickCooldown();
expect(strategy.select(context)?.ability.id).toBe('sect.youdu.pin-soul');
});
it('钉法者在无关键窗口的四层击杀线优先终结', () => {
const strategy = new YouduDecreeSelectionStrategy('pin-the-caster');
const context = selectionContext(
['pin-soul', 'soul-shall-not-return'],
4,
'decree',
true,
);
context.caster.combatResources.set(YOUDU_SOUL_FIRE, 3);
expect(strategy.select(context)?.ability.id).toBe(
'sect.youdu.soul-shall-not-return',
);
});
it('四层判决立即终结,取其第五则优先叠到五层', () => {
const judge = new YouduDecreeSelectionStrategy('judge-at-four');
const judgeContext = selectionContext(
['soul-shall-not-return', 'pin-soul'],
4,
'decree',
true,
);
expect(judge.select(judgeContext)?.ability.id).toBe(
'sect.youdu.soul-shall-not-return',
);
const fifth = new YouduDecreeSelectionStrategy('take-the-fifth');
const fifthContext = selectionContext(
['soul-severing-call', 'soul-shall-not-return'],
4,
'decree',
true,
);
expect(fifth.select(fifthContext)?.ability.id).toBe(
'sect.youdu.soul-severing-call',
);
});
});
@@ -238,10 +238,11 @@ export class SectCompiler {
const modifierFacts = (build.abilityPresentationModifiers ?? [])
.filter((modifier) => modifier.abilityId === abilityId)
.flatMap((modifier) => modifier.factRows);
const authoredFacts = ability.detailRows ?? [];
const detailRows = Array.from(
new Set([
...(ability.detailRows ?? []),
...configFacts,
...authoredFacts,
...(authoredFacts.length === 0 ? configFacts : []),
...modifierFacts,
].filter(Boolean)),
);
@@ -24,6 +24,7 @@ const ATTRIBUTE_LABELS: Partial<Record<AttributeType, string>> = {
[AttributeType.SPEED]: '身法',
[AttributeType.EVASION_RATE]: '闪避',
[AttributeType.CONTROL_RESISTANCE]: '控制抗性',
[AttributeType.HEAL_RECEIVED_REDUCTION]: '受治疗削弱',
};
const DAMAGE_SOURCE_LABELS: Partial<Record<DamageSource, string>> = {
@@ -87,6 +88,13 @@ function conditionText(
) {
return `目标状态层数${condition.type === 'buff_layer_at_least' ? '至少' : '少于'}${condition.params.value}层`;
}
if (
(condition.type === 'hp_above' || condition.type === 'hp_below') &&
condition.params.value !== undefined
) {
const subject = condition.params.scope === 'caster' ? '自身' : '目标';
return `${subject}气血${condition.type === 'hp_below' ? '低于' : '高于'}${percent(condition.params.value)}`;
}
return '';
}
@@ -135,6 +143,9 @@ function modifierText(modifier: AttributeModifierConfig): string {
if (modifier.type === ModifierType.ADD) {
return `${label}${modifier.value >= 0 ? '+' : ''}${percent(modifier.value)}`;
}
if (modifier.attrType === AttributeType.HEAL_RECEIVED_REDUCTION) {
return `${label}${modifier.value >= 0 ? '+' : ''}${percent(modifier.value)}`;
}
return `${label}${modifier.value >= 0 ? '+' : ''}${number(modifier.value)}`;
}
@@ -160,6 +171,16 @@ function damageEffectsRow(
if (damageEffects.length === 0) return [];
if (damageEffects.length === 1) {
const layerScalar = damageEffects[0].params.buffLayerScalar;
if (layerScalar) {
const attribute = ATTRIBUTE_LABELS[layerScalar.attribute] ?? layerScalar.attribute;
const range = layerScalar.maxLayers === undefined
? `${layerScalar.minLayers ?? 0}层以上`
: `${layerScalar.minLayers ?? 0}~${layerScalar.maxLayers}层`;
return [
`伤害:基础相当于${scalableValue(damageEffects[0].params.value)},每层目标状态追加${number(layerScalar.coefficientPerLayer * 100)}%${attribute}(${range})`,
];
}
const dynamic = damageEffects[0].params.dynamicScalars?.find(
(scalar) => scalar.source === 'target_missing_hp_ratio',
);
@@ -453,6 +474,8 @@ function describeEffect(
for (const modifier of buff.modifiers ?? []) {
if (modifier.scaleByLayer)
rows.push(`每层:${modifierText(modifier)}`);
else if (!modifier.valueByLayer)
rows.push(`${buff.name}:${modifierText(modifier)}`);
}
rows.push(...formatBuffValueByLayerModifiers(buff.modifiers ?? []));
if (buff.dispelMode === 'one_layer') {
@@ -197,6 +197,7 @@ export const GameplayTags = {
ROUND_START: 'RoundStartEvent',
SKILL_PRE_CAST: 'SkillPreCastEvent',
SKILL_CAST: 'SkillCastEvent',
HIT_CHECK: 'HitCheckEvent',
DODGE: 'DodgeEvent',
BUFF_ADD: 'BuffAddEvent',
BUFF_APPLIED: 'BuffAppliedEvent',