diff --git a/src/react-app/routes/game/sect/alchemy/route.tsx b/src/react-app/routes/game/sect/alchemy/route.tsx index 2a9a9f9a..e0c16344 100644 --- a/src/react-app/routes/game/sect/alchemy/route.tsx +++ b/src/react-app/routes/game/sect/alchemy/route.tsx @@ -39,7 +39,9 @@ function SectAlchemyBody() { sectContext={{ facilityLevel: level, discountPercent, - facilityLabel: presentation.facilityLabels.workshop, + facilityLabel: + presentation.facilityLabels.alchemy ?? + presentation.facilityLabels.workshop, scene, }} /> diff --git a/src/react-app/routes/game/sect/refinery/route.tsx b/src/react-app/routes/game/sect/refinery/route.tsx index 5bcc10c5..b73deb54 100644 --- a/src/react-app/routes/game/sect/refinery/route.tsx +++ b/src/react-app/routes/game/sect/refinery/route.tsx @@ -39,7 +39,9 @@ function SectRefineryBody() { sectContext={{ facilityLevel: level, discountPercent, - facilityLabel: presentation.facilityLabels.workshop, + facilityLabel: + presentation.facilityLabels.refinery ?? + presentation.facilityLabels.workshop, scene, }} /> diff --git a/src/shared/engine/battle-v5/core/listenerExecution.ts b/src/shared/engine/battle-v5/core/listenerExecution.ts index 2ded4426..b9073f4a 100644 --- a/src/shared/engine/battle-v5/core/listenerExecution.ts +++ b/src/shared/engine/battle-v5/core/listenerExecution.ts @@ -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': diff --git a/src/shared/engine/battle-v5/systems/DamageSystem.ts b/src/shared/engine/battle-v5/systems/DamageSystem.ts index b0cb006f..96417581 100644 --- a/src/shared/engine/battle-v5/systems/DamageSystem.ts +++ b/src/shared/engine/battle-v5/systems/DamageSystem.ts @@ -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({ type: 'UnitDeadEvent', timestamp: Date.now(), diff --git a/src/shared/engine/sect/content/youdu/base/YouduBaseCompiler.ts b/src/shared/engine/sect/content/youdu/base/YouduBaseCompiler.ts index 6c7617dc..f40cc042 100644 --- a/src/shared/engine/sect/content/youdu/base/YouduBaseCompiler.ts +++ b/src/shared/engine/sect/content/youdu/base/YouduBaseCompiler.ts @@ -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), ], })); diff --git a/src/shared/engine/sect/content/youdu/definition.ts b/src/shared/engine/sect/content/youdu/definition.ts index 3b30b006..be0f7ecb 100644 --- a/src/shared/engine/sect/content/youdu/definition.ts +++ b/src/shared/engine/sect/content/youdu/definition.ts @@ -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, diff --git a/src/shared/engine/sect/content/youdu/paths/decree/nodes.ts b/src/shared/engine/sect/content/youdu/paths/decree/nodes.ts index 9a0f15d6..bd1715e6 100644 --- a/src/shared/engine/sect/content/youdu/paths/decree/nodes.ts +++ b/src/shared/engine/sect/content/youdu/paths/decree/nodes.ts @@ -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; - diff --git a/src/shared/engine/sect/content/youdu/presentation.ts b/src/shared/engine/sect/content/youdu/presentation.ts index b419e68c..c3124ca6 100644 --- a/src/shared/engine/sect/content/youdu/presentation.ts +++ b/src/shared/engine/sect/content/youdu/presentation.ts @@ -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: '巡灯照影游戏画布', }, }; - diff --git a/src/shared/engine/sect/content/youdu/shared/buildFacade.ts b/src/shared/engine/sect/content/youdu/shared/buildFacade.ts index 2f0f9071..26c2e0da 100644 --- a/src/shared/engine/sect/content/youdu/shared/buildFacade.ts +++ b/src/shared/engine/sect/content/youdu/shared/buildFacade.ts @@ -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; } } - diff --git a/src/shared/engine/sect/content/youdu/strategy.ts b/src/shared/engine/sect/content/youdu/strategy.ts index 5f5c30b3..00833623 100644 --- a/src/shared/engine/sect/content/youdu/strategy.ts +++ b/src/shared/engine/sect/content/youdu/strategy.ts @@ -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); diff --git a/src/shared/engine/sect/content/youdu/tests/combatProjection.test.ts b/src/shared/engine/sect/content/youdu/tests/combatProjection.test.ts index 4d3959d2..fa91df54 100644 --- a/src/shared/engine/sect/content/youdu/tests/combatProjection.test.ts +++ b/src/shared/engine/sect/content/youdu/tests/combatProjection.test.ts @@ -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(), diff --git a/src/shared/engine/sect/content/youdu/tests/coreMechanics.test.ts b/src/shared/engine/sect/content/youdu/tests/coreMechanics.test.ts index 680f3aa3..b78b43b1 100644 --- a/src/shared/engine/sect/content/youdu/tests/coreMechanics.test.ts +++ b/src/shared/engine/sect/content/youdu/tests/coreMechanics.test.ts @@ -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', + (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'); diff --git a/src/shared/engine/sect/content/youdu/tests/strategy.test.ts b/src/shared/engine/sect/content/youdu/tests/strategy.test.ts index 89e4c0e5..f76d694d 100644 --- a/src/shared/engine/sect/content/youdu/tests/strategy.test.ts +++ b/src/shared/engine/sect/content/youdu/tests/strategy.test.ts @@ -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', + ); + }); }); diff --git a/src/shared/engine/sect/core/compilation/SectCompiler.ts b/src/shared/engine/sect/core/compilation/SectCompiler.ts index c6bc2c96..90dacbcf 100644 --- a/src/shared/engine/sect/core/compilation/SectCompiler.ts +++ b/src/shared/engine/sect/core/compilation/SectCompiler.ts @@ -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)), ); diff --git a/src/shared/engine/sect/core/presentation/abilityFacts.ts b/src/shared/engine/sect/core/presentation/abilityFacts.ts index 8e2358de..364cacfd 100644 --- a/src/shared/engine/sect/core/presentation/abilityFacts.ts +++ b/src/shared/engine/sect/core/presentation/abilityFacts.ts @@ -24,6 +24,7 @@ const ATTRIBUTE_LABELS: Partial> = { [AttributeType.SPEED]: '身法', [AttributeType.EVASION_RATE]: '闪避', [AttributeType.CONTROL_RESISTANCE]: '控制抗性', + [AttributeType.HEAL_RECEIVED_REDUCTION]: '受治疗削弱', }; const DAMAGE_SOURCE_LABELS: Partial> = { @@ -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') { diff --git a/src/shared/engine/shared/tag-domain/gameplayTags.ts b/src/shared/engine/shared/tag-domain/gameplayTags.ts index aa561c07..09ddbfe3 100644 --- a/src/shared/engine/shared/tag-domain/gameplayTags.ts +++ b/src/shared/engine/shared/tag-domain/gameplayTags.ts @@ -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',